How to create a good and secure Docker image

How to create a good and secure Docker image

Publié le


Do not index
Do not index
Primary Keyword
Lié à Analyse sémantique (Articles liés) 1
Lié à Analyse sémantique (Articles liés)
Statut rédaction
A améliorer
Lié à Analyse sémantique (Articles liés) 2

I. Description

This document provides a comprehensive guide to create a secure and efficient Docker image. It will guide you through the process of creating a Docker image that is secure, efficient, and utilizes Docker layer caching to speed up the build process.

II. General Approach

1. Use specific tag to identify image

When specifying the base image for your Dockerfile, it's important to use a specific version tag, not the latest tag. This ensures that the build is repeatable and that it won't suddenly break due to changes in the base image.
FROM <BASE_IMAGE>:<VERSION>
You can also use an argument to easily switch between different versions of the image. For even greater security, consider using the SHA256 digest of the image instead of the tag. This ensures that you are always using the exact same image for each build, providing a high level of reproductability.

2. Use secure version of base image

Not all Docker images are created equal. Some may contain vulnerabilities or be outdated. Use a tool like Snyk to scan your base image for vulnerabilities. This will help you choose a secure base image, or identify necessary security updates. This is a crucial step in ensuring that your Docker image is secure from the start.

3. Cache system level dependencies during very first stage

Installing system packages manager tool like apk or apt is a common step in many Dockerfiles. This step should be cached to speed up the build process, as these packages don't change often. Be sure to clean up the package manager cache to reduce the size of the image. This step is important for optimizing the build process and keeping the image size as small as possible

4. Install application dependencies

For each language, we might use a different package manager.
For examples:
  • Python (Pipfile, requirements.txt)
  • Powered by Node.js: React.js, Angular.js, Vue.js .. (package.json)
  • Maven + Java (pom.file)
  • Symfony (composer.json)
  • ...
To cache this step, we just need to copy the requirement file to docker and do dependency installation before copying the rest of the application code.
This ensures that the Docker build process can cache the step of installing dependencies, which can significantly speed up subsequent builds.
 
With Python, using pip tool, we can do like this:
COPY requirements.txt /code
RUN pip install --no-warn-script-location --user -r requirements.txt
After steps, we have all need packages to run application. Normally they are located in specific location. It’s a good idea to remember that installation directory so that we can copy them to the final build image.

5. Provide more choices to your package manager tool

We might have gain more choices on the table through using docker build args.
Depending on the needs, we might consider to provide different package manager tools in the same Dockerfile to provide flexibily. For example, with Node.js powered projects, it’s common have either npm or yarn as package manager tool. With Python projects it can be Pipenv or just pip. To deal this with multi-choices, Docker provides us ONBUILD instruction
The ONBUILD instruction adds to the image a trigger instruction to be executed at a later time, when the image is used as the base for another build. The trigger will be executed in the context of the downstream build, as if it had been inserted immediately after the FROM instruction in the downstream Dockerfile
Combine docker build args with ONBUILD instruction, we can create something like switch-case in programming language
# Global ARG, can be either a or b
ARG BUILD_ENV=a

# Stage 1: Install system packages (using apt)
ARG IMAGE_VERSION=3.13
FROM alpine:${IMAGE_VERSION} as prepare-image
# Instructions...

# Case A
FROM prepare-image as my-image-a
ONBUILD RUN echo case A
ONBUILD RUN touch file_a.txt

# Case B
FROM prepare-image as my-image-b
ONBUILD RUN echo case B
ONBUILD RUN touch file_b.txt

# Stage 3: Actually install python packages
# Produce child image with Python suitable packaging tool
# depending on global ARG value - BUILD_ENV
FROM my-image-${BUILD_ENV} AS build-image
Sample out if use default value of BUILD_ENV
notion image
As you see, docker only run instructions in case A. The instructions in case B are just registered, but not triggered because of invalid BUILD_ENV.
Leverage this technique allows us to provide different options to package manage tool. For example. we can also provide either pip or pipenv for Python projects. See more example here.

6. Copy entire source code to final build image

We are at the final build image. Here we copy all source code to the image.
Remember to have .dockerignore file to avoid copy secrets, unwanted files which might bloat up the final image.
Also we should use secure, lightweight image as final image.
In addition, we also need to copy packages installed in step 5 to our final image.
For pip tool, it can be done like this:
COPY --from=build-image /root/.local /root/.local
# We might need to extend PATH to have executable tool like gunicorn available
ENV PATH=/root/.local/bin:$PATH

7. Create least privileged user to run our application

For security reasons, it's best to run the application as a non-root user with just enough permissions to run the application. This can help to limit the potential damage if the application is compromised. This is a key step in securing your Docker image, as it limits the potential damage that can be done if the application is compromised.
RUN addgroup -S javauser -g 433 && adduser -S -u 431 -G javauser -s /bin/false -D -H -h /home/app -g "Docker image user" javauser
RUN mkdir /bin/false && chown -R javauser:javauser /bin/false
USER javauser

8. Use dumb-init as PID 1

To avoid Docker and the PID 1 zombie reaping problem (see more here), we should use tool like dumb-init to run as PID 1.
For example, to run Java application with environment variable, we use dumb-init like this:
RUN apk add dumb-init
# Use dumb-init as PID 1. See more at https://github.com/Yelp/dumb-init
ENTRYPOINT ["/usr/bin/dumb-init", "--"]
CMD ["sh", "-c","java -jar $JAR_NAME"]

9. Isolation of resources

Docker provides features to limit the resource usage of containers, such as CPU, memory, and disk I/O. This can help to prevent a single container from consuming all the host's resources, which can be particularly important in a multi-tenant environment. This is an important step in ensuring that your Docker containers are good citizens on the host system.
 
For example, you can limit a container's CPU and memory usage using the -c (or --cpu-shares) and -m (or --memory) options of the docker runcommande.
docker run -c 512 -m 300M my_image:tag
In this example, the container is limited to 512 CPU "shares" and 300 MB of memory. This means that the container cannot use more than these resource quantities.

10. Secret management

Never include secrets, such as passwords and API keys, in the Docker image itself. Instead, use Docker's secret management features, or pass secrets as environment variables. This is a key security best practice, as it prevents sensitive data from being included in the Docker image, where it could potentially be exposed.
 
For example, to pass a secret as an environment variable, you can use the -e (or --env) option of the docker runcommand.
docker run -e "API_KEY=abcdef123456" my_image:tag
In this example, the API key is passed to the application inside the container as the API_KEYenvironment variable.

11. Regular updates of images

Just like any other software, Docker images need to be updated regularly to include the latest security patches. This can be automated using tools like Dependabot. Regular updates are crucial for maintaining the security of your Docker images.

12. Minimize attack surface

Use minimal base images to reduce the attack surface. Avoid installing unnecessary packages in the image. Also use multi-stage builds to separate the build and production stages, keeping only the necessary files in the final image. This is a key best practice for creating secure Docker images.

13. Access control

Use Docker's access control features, such as roles and permissions, to limit what users can do. For example, you can prevent unauthorized users from starting or stopping containers, or from changing their configuration. This is an important step in securing your Docker environment.

14. Image scanning

Use tools to scan Docker images for known vulnerabilities. This can be integrated into your CI/CD pipeline to ensure that images are scanned before they are deployed. Regular image scanning is a key part of maintaining the security of your Docker images.
 
For example, you can use Docker Content Trust (DCT) to sign and verify Docker images, preventing unauthorized users from modifying the images.
To activate DCT, you can use the DOCKER_CONTENT_TRUSTenvironment variable.
export DOCKER_CONTENT_TRUST=1
With DCT enabled, Docker checks the signature of all images before using them. This prevents the use of images modified by unauthorized users.

15. Review your Dockerfile with Snyx 10 Docker best practices

Snyk has outlined 10 best practices for Dockerfiles :
  1. Prefer minimal base images
  1. Least privileged user
  1. Sign and verify images to mitigate MITM attacks
  1. Find, fix and monitor for open source vulnerabilities
  1. Don’t leak sensitive information to Docker images
  1. Use fixed tags for immutability
  1. Use COPY instead of ADD
  1. Use metadata labels
  1. Use multi-stage build for small and secure docker images
  1. Use a linter (Hadolint)
 
Review your Dockerfile against these best practices to ensure it's secure and efficient. This is a good final step to ensure that your Dockerfile follows best practices.

References:

 

S'inscrire à la newsletter DevSecOps Keltio

Pour recevoir tous les mois des articles d'expertise du domaine

S'inscrire

Sujets

Pour aller plus loin