Distroless Images

Distroless container images, unlike the traditional ones, does not include software that are common in distro-based images, such as package managers and shells. This approach aims to minimize the image size and reduce vulnerabilities by removing unnecessary components to run an application.

These types of images are suitable for production environments rather than running interactive containers, since they are often smaller and have less attack vectors than traditional images.

Because distroless images include only essential components to run an application, it might seem that you’re limited to the components that are provided by the distroless base image, since there is no shells or package managers. This is when Docker multi-stage build feature shines.


Multi-Stage builds

One can tell that the multi-stage build feature is being used when a Dockerfile has multiple FROM statements in it. With this feature, each stage has a well-defined purpose other than running the application, which is the responsibility of the final stage. The earlier stages are typically used to build an application and run tests.

This is particular useful when we are using distroless images. Since we don’t have usual resources provided by a regular image, we can build the application at the first stage of a docker image and then share only the artifacts to the final stage, which can be build on top of a distroless image.


Developing a custom distroless image

The snippet bellow is from CGRFlask.Dockerfile:

FROM cgr.dev/chainguard/python:latest-dev AS build
WORKDIR /app
RUN git clone https://github.com/lkellermann/LINUXtips-Giropops-Senhas.git /app && rm -rf .git
RUN python -m venv venv
ENV PATH="/app/venv/bin:$PATH"
RUN pip install --no-cache-dir -r requirements.txt

FROM cgr.dev/chainguard/python:latest AS app
WORKDIR /app
COPY --from=build /app /app
ENV PATH="/app/venv/bin:$PATH"
EXPOSE 5000
ENTRYPOINT [ "flask"]
CMD [ "run", "--host=0.0.0.0" ]

The syntax to build the image is the following:

docker image build -f docker/CGRFlask.Dockerfile -t <your dockerhub user>/<image name>:<version> .

In our example:

docker image build -f docker/CGRFlask.Dockerfile -t kellermann92/linuxtips-giropops-senhas:1.0 .

The image above has two stages, build and app.

The build stage is build on top of a regular image provided by Chainguard. After defining the working directory, the GitHub repository containing the application is cloned into it. Then, we need to create a virtual environment that is going to contain all dependencies of the application and then, share this environment with the final stage. After creating the virtual environment, the path to the binary that enables executing the code using the virtual environment is added to the PATH environment variable. Then, the dependencies from requirements.txt files are installed in the virtual environment.

The app stage is build on top of a distroless image. In order to install the dependencies of our application, we copy the whole app folder from the buildstage, which contains our application code and the virtual environment with the necessary packages installed. After that, we update the PATH environment variable by adding the binary path to run the Python virtual environment. And finally, we start our flask application.


Looking for vulnerabilities

Despite having less components and less layers than a regular container image, it’s still important to scan them in order to detect any know vulnerabilities, like misconfiguration of the base image or outdated packages. In our example, we are going to use Trivy and Docker Scout.

Using Trivy

After building your image, run the following command:

trivy image <your dockerhub user>/<your image>:1.0

In our example:

trivy image kellermann92/linuxtips-giropops-senhas:1.0

You should get a similar result as the image below:

Trivy Scans Results

As we can see in the the image above, Trivy didn’t catch any vulnerabilities in the developed image. However, it’s not a guarantee that the image is not vulnerable. It’s possible that nothing was detected because the database of vulnerabilities that Trivy uses has no knowledge about the issues we have. To double-check this, we can use another vulnerability scan tool.

Using Docker Scout

You must have a DockerHub account and upload the image by running:

docker image push kellermann92/linuxtips-giropops-senhas:1.0

then

 docker scout cves kellermann92/linuxtips-giropops-senhas:1.0

The output will look like the image below:

Scout Scans Results

The output will suggest you to run the following command in order to view the base image update recommendations:

docker scout recommendations kellermann92/linuxtips-giropops-senhas:1.0

Depending on the time you execute the command, you may have recommentations to follow or not. In the image bellow, there are no recommendations:

Scout No Recommendations

Enabling Docker Scout Image analysis on DockerHub

Go to your repo, settings and select Docker Scout image analysis and save.

Scout in Dockerhub


Running the application

To run the password generator application, you can start by running the Redis container or the Flask container.

To start the Redis container, run the following command:

docker container run -d --name redis -p 6379:6379 redis:alpine3.20

To start the Flask container with the distroless image created in the previous sections, run the following command:

docker container run -d -p 5000:5000 -e REDIS_HOST=<your machine local IP> --name <container name> <your dockerhub username>/<image name>:<version>

In my machine, I ran the following:

docker container run -d -p 5000:5000 -e REDIS_HOST=192.168.0.76 --name linuxtips-giropops-senhas kellermann92/linuxtips-giropops-senhas:1.0

Now, you can access the application by typing in your browser 127.0.0.1:5000:

Scout in Dockerhub


Using docker compose

There is a better approach at achieving the task above, which is by using docker compose. This is a plugin that orchestrates multiple docker containers. It’s meant to be used in development environment, not in production. With it, you can define volumes that are shared between containers, or connect them using the same network (instead of using the bridge connection as we did before).

The docker compose file to run the same application is presented below:

services:
  giropops-senhas:
    build:
      context: ./docker
      dockerfile: CGRFlask.Dockerfile
    #container_name: giropops-senhas
    ports:
      - 5000:5000
    networks:
      - giropops
    environment:
      REDIS_HOST: redis
    volumes:
      - type: volume
        source: strigus
        target: /strigus
    depends_on:
      - redis
    labels:
        com.leandroasaservice.description: my app on docker compose
        com.leandroasaservice.version: 1.0
    deploy:
      replicas: 1
      update_config:
        parallelism: 1
        delay: 10s
      resources:
        reservations:
          cpus: "0.10"
          memory: 128M
        limits:
          cpus: "0.5"
          memory: 256M
      restart_policy:
        condition: on-failure
        delay: 5s
        max_attempts: 3
    devices:
      - /dev/bus/usb:/dev/bus/usb
    dns:
      - 8.8.8.8
      - 8.8.4.4
    healthcheck:
      test: ["CMD", "flask", "--version"]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 10s
  redis:
    image: redis:alpine3.20
    command: redis-server --appendonly yes
    networks:
      - giropops
    volumes:
      - strigus:/strigus
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 10s
    labels:
        com.leandroasaservice.description: redis on docker compose
        com.leandroasaservice.version: 1.0

networks:
  giropops:
    driver: bridge
    ipam:
      config:
        - subnet: 172.20.0.0/16
    labels:
      com.leandroasaservice.network: giropops_network

volumes:
  strigus:
    driver: local
    driver_opts:
      type: none
      device: ~/pick2024/strigus
      o: bind
    labels:
      com.leandroasaservice.volume: strigus_volume

References