The process of containerization is ubiquitous.
It has soaked in almost every branch of software development. It is therefore not surprising that 50% of vulnerable
targets are attacked within one hour.
That’s why it is good to know the basic rules of creating images for the containers. This article is willing to cover such
best approaches to Dockerfile creation. It is going to be kept in the convention of cookbook/handbook. There won’t be
much in-depth diving into how container technology works or each of the introduced tools. Rather some quick and sharp methods that
can be introduced in your everyday development process. Some tips, which will be given here, are sometimes based
on my own experience. If you disagree with anything or see a better way you can freely share that knowledge in the
comment section or on Slack.
This article was written originally for Netguru.
Table of Contents
Approach
What we want to achieve is the most secure and lightweight image, which will keep its functionality. Let’s start defining from the end.
- By functionality, I mean that the app from
simple-web-appwill be running in the container, and its endpoint will be working. - Lightweight image means faster builds and better security (smaller attack surface). We will obtain the size of each image
from the output of the
docker imagescommand. - For security scanning I picked up
trivyscanner. The amount of found vulnerabilities will be the measurement of security quality. Also, the SBOM will be generated by the Docker featuredocker sbomfor each image to measure its amount of dependencies and potential attack surface.
Our target is to obtain the lowest size of our initial image, the lowest amount of found vulnerabilities by the Trivy scanner
and the lowest amount of dependencies in SBOM generated by docker sbom with functionality maintained.
The command used to build images is docker buildx build --tag <tag-name> . unless told it is different. The buildx
in the command is making use of the BuildKit feature to create the image.
All code related to this article can be found here.
Steps
Step 1 - init Docker image
FROM python
WORKDIR /app
COPY requirements.txt ./
RUN pip install -r requirements.txt
COPY . .
ENTRYPOINT ["uvicorn"]
CMD ["main:app", "--host", "0.0.0.0", "--port", "80"]
For sure, it is not production-ready, although the output of running such an image is a “working” application.
It has a lot of space to improve. We will try to cover it as much as we can.
Let’s start with explaining every instruction from the Dockerfile in more detail:
FROM python- initialize parent image as official python image from Docker HubWORKDIR /app- set workdir to/appCOPY requirements.txt .- copy therequirements.txtfile from the local machine to the container filesystemRUN pip install -r requirements.txt- run command installing requirements inside the containerCOPY . .- copy all the files from the current location in the local machine to the container filesystemENTRYPOINT ["uvicorn"]- entry point command that is starting anuvicornweb serverCMD ["main:app", "--host", "0.0.0.0", "--port", "80"]- args to the entry point command which are telling to start a web server on0.0.0.0:80inside the container network (we need to map it f.e. while doingdocker run)
Let’s init our quality table:
| Image | Size | Vulnerabilities | SBOM |
|---|---|---|---|
| init | 1.04GB | 1102 (UNKNOWN: 4, LOW: 597, MEDIUM: 270, HIGH: 212, CRITICAL: 19) | 442 |
Step 2 - Parent Image
Let’s focus on the first instruction in our Dockerfile - FROM python. It is setting the parent image as python,
with the latest version. Such a strategy is very error-prone:
- you don’t know what version of python you are downloading.
- with every minor version update of python you will download such a change with every build - your code may not be ready for that.
- you don’t know what distro is under your image.
- your level of control over this image is very low.
It may cause weird scenarios where one day your code works, and another it doesn’t despite the lack of changes in it (at least from your perspective). That’s why we want to have the highest level of control over our parent image.
#line below has changed
FROM python:3.11-slim-bullseye
WORKDIR /app
COPY requirements.txt ./
RUN pip install -r requirements.txt
COPY . .
ENTRYPOINT ["uvicorn"]
CMD ["main:app", "--host", "0.0.0.0", "--port", "80"]
We added the newest stable version - 3.11 and the latest stable Debian
distribution slim-bullseye. The decision to pick
up slim over alpine was made because of the performance
issues with the latter one.
From a security point of view, you should NOT use unofficial images. The official ones are curated
and their quality standards are the responsibility of some serious entities (at least they are saying so). But… if you really
need to use some random image, you MUST build it on your own. You need to possess the Dockerfile (and, of course,
validate it) and run the docker build on your own. Do NOT use the already-built images. Mostly because you can’t say:
- what is inside, is there any malware code
- how this image has been built
- who is responsible for keeping all updates up-to-date
- is this image up-to-date with security updates
In short, it means you are giving away lots of the control to some random character from the Internet. It is the situation you want to avoid.
Let’s add another row to our table.
| Image | Size | Vulnerabilities | SBOM |
|---|---|---|---|
| parent-image | 234MB | 87 (UNKNOWN: 0, LOW: 67, MEDIUM: 8, HIGH: 11, CRITICAL: 1) | 120 |
Step 3 - Caching
Docker is using sequential layer caching. To utilize this functionality, the dependencies import and installation are separated from the source code import.
FROM python:3.11-slim-bullseye
WORKDIR /app
#here we are importing and then installing deps
COPY requirements.txt ./
RUN pip install -r requirements.txt
#here we are importing our source code (generally all files from project for now)
COPY . .
ENTRYPOINT ["uvicorn"]
CMD ["main:app", "--host", "0.0.0.0", "--port", "80"]
Thanks to this Docker, with each change in the source code, will “be aware” that the change is only affecting the layer
linked to the instruction (COPY . .) of copying the code. Because of that, it will start rebuilding the image from that step.
The layer responsible for deps will be left untouched, as it is happening before the code import.
Another feature is caching your dependencies across different builds. With frequently changing dependencies, the current Dockerfile will try to download the previously downloaded packages in every run. It is the behavior we want to avoid, as these packages were already at least once downloaded. We don’t want to waste our time on the same thing twice.
To describe it more preciously:
Now, to raise some awareness in our dependency manager, we will use the dependencies cache mechanism:
FROM python:3.11-slim-bullseye
WORKDIR /app
COPY requirements.txt .
#change in the line below
RUN --mount=type=cache,target=/root/.cache \
pip install -r requirements.txt
COPY . .
ENTRYPOINT ["uvicorn"]
CMD ["main:app", "--host", "0.0.0.0", "--port", "80"]
--mount=type=cache is pretty self-descriptive, we are dealing with cache, and thus we are using this type.
target=/root/.cache - this is the directory, in which are dependencies will be stored ACROSS all builds.
Thanks to this, we won’t experience re-downloading packages, which are already in use.
If you run the scenario described above you will notice a change in the behavior of the build run. The logs are now telling us
#8 1.919 Collecting fastapi==0.88.0
#8 1.920 Using cached fastapi-0.88.0-py3-none-any.whl (55 kB)
for package already downloaded, instead of
#8 2.088 Collecting fastapi==0.88.0
#8 2.153 Downloading fastapi-0.88.0-py3-none-any.whl (55 kB)
#8 2.164 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 55.5/55.5 kB 5.5 MB/s eta 0:00:00
when we were building the image without caching.
After this point, we are reverting requirements.txt to its initial state (without django).
And another one, just for statistics.
| Image | Size | Vulnerabilities | SBOM |
|---|---|---|---|
| caching | 219MB | 87 (UNKNOWN: 0, LOW: 67, MEDIUM: 8, HIGH: 11, CRITICAL: 1) | 120 |
Step 4 - Multi-stage builds
Our Dockerfile is doing everything in one go: building dependencies and then launching the app. Because of that, it has common
environment for both of these activities. With mutli-stage builds
we can separate both environments and actions from each other. Thanks to this we can:
- have more constant env over time for building deps
- abandon some tools only needed while building deps in our final image, which leads to less sized image
- better readability
- more control over each env
FROM python:3.11-slim-bullseye AS base
FROM base AS builder
WORKDIR /app
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache \
pip install --no-warn-script-location -r requirements.txt
FROM python:3.11-slim-bullseye as final-image
WORKDIR /app
COPY --from=builder /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages
COPY --from=builder /usr/local/bin /usr/local/bin
COPY . .
ENV PYTHONPATH=/usr/local/lib/python3.11/site-packages \
PATH="/usr/local/bin:${PATH}"
ENTRYPOINT ["uvicorn"]
CMD ["main:app", "--host", "0.0.0.0", "--port", "80"]
Whew, a little change, right? Let’s go through it.
The first image is base and possesses the dependencies needed to create a python env. It is the place where you can f.e. define
python envs or upgrade your dependency manager (pip).
The second image is named builder and its responsibility is to build all project dependencies. We are installing all
dependencies based on requirements.txt with pip as usual. --no-warn-script-location is just a cosmetic change to
hide the error message related to the PATH environment variable.
The third and final image is the moment when we are launching our app. But, contrary to the previous Dockerfiles, we are not
installing here any dependencies. We can just copy already prepared libs and binaries from the previous image. Thanks to
this we can drop dependencies related to the builder image like pip. We will make use of this in future steps.
Dockerfile seems to be larger now, but will it echo the actual size of the image?
| Image | Size | Vulnerabilities | SBOM |
|---|---|---|---|
| multi-stage | 225MB | 87 (UNKNOWN: 0, LOW: 67, MEDIUM: 8, HIGH: 11, CRITICAL: 1) | 120 |
The quality did not change, because our final image is still based on python:3.11-slim-bullseye, despite the fact it
does not need to. Let’s make full use of the introduced multi-staging.
Step 5 - ‘Distroless’ image
Every container got its own OS. Usually, these OSs have lots of packages or binaries that are not needed for our app to run. In the end, we only need our source code and runtime dependencies. And here comes distroless image. They are images based on Debian with erased almost every program you would expect to find in the standard Linux distribution. Thanks to this they are very small, and this is what we are expecting from our image, right?
FROM python:3.11-slim-bullseye AS base
FROM base AS builder
WORKDIR /app
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache \
pip install --no-warn-script-location -r requirements.txt
#new image used below
FROM gcr.io/distroless/python3-debian11 as final-image
WORKDIR /app
COPY --from=builder /usr/local/bin/uvicorn /usr/local/bin
COPY --from=builder /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages
ENV PYTHONPATH=/usr/local/lib/python3.11/site-packages \
PATH="/usr/local/bin:${PATH}"
COPY . .
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "80"]
We didn’t need to change a lot, mostly because we already prepared for this change in the last step. Who would guess…? Let’s check how will it affect the quality of our final image.
| Image | Size | Vulnerabilities | SBOM |
|---|---|---|---|
| distroless | 151MB | 52 (UNKNOWN: 0, LOW: 35, MEDIUM: 6, HIGH: 9, CRITICAL: 2) | 49 |
We are progressing, that’s for sure. But still far from perfect. Let’s take a look at more security aspects of our image before a final clash with its size.
Step 6 - Build context
So far our command used to build the image has been docker buildx build --tag <tag-name> .. The last character of it
.is our build context. It is telling Docker to take everything from our current directory and use it in the image build process. It can be a very dangerous approach, because sometimes we may have files, we don’t want to put into containers, like secrets, Excel files with data, random images, or .git history. In order to avoid it, we can follow two approaches.
- Use a more precise build context.
Right now we are copying everything from the build context into the container (
COPY . .). To have more control over what we are taking, we should use theCOPYmethod more explicitly -COPY ./src .. Thanks to a little refactor we have our source code in one directory, which can be explicitly copied into the image. - Use .dockerignore
Philosophy is the same as with
.gitignore- you are adding files, which should be ignored by the docker build context.
Let’s check how this tiny change will influence our image.
| Image | Size | Vulnerabilities | SBOM |
|---|---|---|---|
| build-context | 128MB | 52 (UNKNOWN: 0, LOW: 35, MEDIUM: 6, HIGH: 9, CRITICAL: 2) | 47 |
A little step further…
Step 7 - System packages
When running an app based on a more complex image than distroless or scratch you should
keep your OS dependencies up-to-date. Theoretically, it is the responsibility of the image maintainer, but it is a good approach
to do it by yourself.
RUN apt-get update && apt-get install -y --no-install-recommends && rm -rf /var/lib/apt/lists/*
--no-install-recommends is used here, because we don’t want to install any additional packages.
/var/lib/apt/lists is the directory, where the package information cache is being stored. After the update, we don’t need it
anymore so it can be deleted to reduce a little the size of our image.
Since we are using distroless image we even can’t trigger such a command, because our base image doesn’t have package manager anymore. But… we can care of the python dependencies manager and make it up-to-date.
FROM python:3.11-slim-bullseye AS base
#here we added updating pip
RUN pip install --upgrade pip
FROM base AS builder
WORKDIR /app
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache \
pip install --no-warn-script-location -r requirements.txt
FROM gcr.io/distroless/python3-debian11 as final-image
WORKDIR /app
COPY --from=builder /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages
COPY --from=builder /usr/local/bin/uvicorn /usr/local/bin/uvicorn
COPY /src .
ENV PYTHONPATH=/usr/local/lib/python3.11/site-packages \
PATH="/usr/local/bin:${PATH}"
CMD ["/usr/local/bin/uvicorn", "main:app", "--host", "0.0.0.0", "--port", "80"]
The added command is upgrading pip.
For formalities, let’s add our new image to the table.
| Image | Size | Vulnerabilities | SBOM |
|---|---|---|---|
| system-packages | 128MB | 52 (UNKNOWN: 0, LOW: 35, MEDIUM: 6, HIGH: 9, CRITICAL: 2) | 47 |
Step 8 - Non-root user
Default user being used by Docker while running instructions from Dockerfile is root. As we want to stick to the least
privileged user, we do not want our app being run by root. Contrary to the building process, which can be done by root.
This way, the user running the app will be able only to read our source code, not to manipulate it (without write access).
In my opinion, it reduces the surface of potential harm caused in case of hijacking the container by some shady character.
Let’s check who currently is running our app.
# whoami
root
# ls -la
total 16
drwxr-xr-x 1 root root 4096 Dec 23 16:36 .
drwxr-xr-x 1 root root 4096 Dec 23 16:36 ..
-rw-r--r-- 1 root root 21 Dec 23 16:39 main.py
# echo "print('Hello world')" > main.py
# python main.py
Hello world
As you may see in these logs, the user can add a new code and trigger it. It is the situation we need to avoid.
Usually, you could create a new user by example command:
RUN useradd -m python-user
and pin it to the Dockerfile entry point command just before running it with the instruction USER:
USER python-user
ENTRYPOINT ["uvicorn"]
CMD ["main:app", "--host", "0.0.0.0", "--port", "80"]
In distroless images, we only need to specify the tag :nonroot to run the app as a non-root user.
FROM python:3.11-slim-bullseye AS base
RUN pip install --upgrade pip
FROM base AS builder
WORKDIR /app
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache \
pip install --no-warn-script-location -r requirements.txt
# tag added below
FROM gcr.io/distroless/python3-debian11:nonroot as final-image
WORKDIR /app
COPY --from=builder /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages
COPY --from=builder /usr/local/bin/uvicorn /usr/local/bin/uvicorn
COPY /src .
ENV PYTHONPATH=/usr/local/lib/python3.11/site-packages \
PATH="/usr/local/bin:${PATH}"
CMD ["/usr/local/bin/uvicorn", "main:app", "--host", "0.0.0.0", "--port", "80"]
Now, the user triggering the app is not a root, but the source code belongs to the root.
Thanks to this users can only read source code, not modify it. Thus, it can not insert any malicious code.
$ whoami
nonroot
$ ls -la
total 12
drwxr-xr-x 1 nonroot nonroot 4096 Dec 23 16:47 .
drwxr-xr-x 1 root root 4096 Dec 23 16:47 ..
-rw-r--r-- 1 root root 118 Dec 21 10:04 main.py
$ cat main.py
from fastapi import FastAPI
app = FastAPI()
@app.get("/")
async def root():
return {"message": "Hello World"}
$ echo "print('Hello world')" > main.py
sh: 5: cannot create main.py: Permission denied
It won’t influence our image size, but for sure it is a much improvement
| Image | Size | Vulnerabilities | SBOM |
|---|---|---|---|
| non-root-user | 128MB | 52 (UNKNOWN: 0, LOW: 35, MEDIUM: 6, HIGH: 9, CRITICAL: 2) | 47 |
Step 9 - Linting
Going through each of the steps with every new Dockerfile would be a very time-consuming and complex chore. Thus, we need
automation of this process to some degree. Here comes the linting tool hadolint. It
helps to ensure that Dockerfile is being written with aligning to best practices.
../linting/Dockerfile:2 DL3013 warning: Pin versions in pip. Instead of `pip install <package>` use `pip install <package>==<version>` or `pip install --requirement <requirements file>`
../linting/Dockerfile:2 DL3042 warning: Avoid use of cache directory with pip. Use `pip install --no-cache-dir <package>`
../linting/Dockerfile:7 DL3042 warning: Avoid use of cache directory with pip. Use `pip install --no-cache-dir <package>`
Linter is telling us that we got three issues with pip caching. All of them are false negatives. We will use the flag
directly in the Dockerfile to suppress it.
FROM python:3.11-slim-bullseye AS base
# hadolint ignore=DL3013,DL3042
RUN pip install --upgrade pip
FROM base AS builder
WORKDIR /app
COPY requirements.txt .
# hadolint ignore=DL3042
RUN --mount=type=cache,target=/root/.cache \
pip install --no-warn-script-location -r requirements.txt
FROM gcr.io/distroless/python3-debian11:nonroot as final-image
WORKDIR /app
COPY --from=builder /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages
COPY --from=builder /usr/local/bin/uvicorn /usr/local/bin/uvicorn
COPY /src .
ENV PYTHONPATH=/usr/local/lib/python3.11/site-packages \
PATH="/usr/local/bin:${PATH}"
CMD ["/usr/local/bin/uvicorn", "main:app", "--host", "0.0.0.0", "--port", "80"]
Let’s check how aligning with the best practices influence quality of our image.
| Image | Size | Vulnerabilities | SBOM |
|---|---|---|---|
| linting | 128MB | 52 (UNKNOWN: 0, LOW: 35, MEDIUM: 6, HIGH: 9, CRITICAL: 2) | 47 |
Step 10 - DockerSlim
Let’s recap our target. It is to build the most lightweight and secure image without breaking the functionality of the app inside. If we take a look at the SBOM of the previous image we can see, that there are many packages, we can not tell if we need them at the first sight. Is there a space to improve? The tool which can answer that question is DockerSlim.
Not only it can brush the image from useless deps, but it also can be used as a linter or to x-ray the image of its content. By default, it is creating Seccomp and AppArmor security profiles for the processed image. Generally, it is a wide tool, which, to fully understand, needs more time invested than this handbook.
Okay, let’s build the new image and use DockerSlim on it.
docker-slim build --target docker-slim:latest --expose 80
Port 80 is the only port used by our app. We need to expose it, so the docker-slim could check which packages are being
“touched” by our app.
Let’s check how is the output image presenting.
| Image | Size | Vulnerabilities | SBOM |
|---|---|---|---|
| docker-slim | 19.4MB | 0 | 0 |
:nois:
Summary
Final table with each step.
| Image | Size | Vulnerabilities | SBOM |
|---|---|---|---|
| init | 1.04GB | 1102 (UNKNOWN: 4, LOW: 597, MEDIUM: 270, HIGH: 212, CRITICAL: 19) | 442 |
| parent-image | 234MB | 87 (UNKNOWN: 0, LOW: 67, MEDIUM: 8, HIGH: 11, CRITICAL: 1) | 120 |
| caching | 219MB | 87 (UNKNOWN: 0, LOW: 67, MEDIUM: 8, HIGH: 11, CRITICAL: 1) | 120 |
| multi-stage | 225MB | 87 (UNKNOWN: 0, LOW: 67, MEDIUM: 8, HIGH: 11, CRITICAL: 1) | 120 |
| distroless | 151MB | 52 (UNKNOWN: 0, LOW: 35, MEDIUM: 6, HIGH: 9, CRITICAL: 2) | 49 |
| build-context | 128MB | 52 (UNKNOWN: 0, LOW: 35, MEDIUM: 6, HIGH: 9, CRITICAL: 2) | 47 |
| system-packages | 128MB | 52 (UNKNOWN: 0, LOW: 35, MEDIUM: 6, HIGH: 9, CRITICAL: 2) | 47 |
| non-root-user | 128MB | 52 (UNKNOWN: 0, LOW: 35, MEDIUM: 6, HIGH: 9, CRITICAL: 2) | 47 |
| linting | 128MB | 52 (UNKNOWN: 0, LOW: 35, MEDIUM: 6, HIGH: 9, CRITICAL: 2) | 47 |
| docker-slim | 19.4MB | 0 | 0 |
We started from a 1.04 GB sized image with over 1k vulnerabilities and we went down to 19.4 MB and 0 vulns. The very last
image is much closer to the production image than any other during this process. The quality increase is tremendous. During
this journey, we learned a few tools, which can be used to automate the process of building the image, like hadolint or
DockerSLim. We got familiar with the most common security approaches, like least privileged user, control over build context
or Seccomp and AppArmor security profiles, which are lowering the potential harm caused by cyberattacks.
Extending sources
https://earthly.dev/blog/docker-slim/ - DockerSlim light intro
https://pythonspeed.com/ - blog about containerization and python efficiency
https://www.youtube.com/@DockerIo - Docker official channel
https://rootlesscontaine.rs/ - about rootless-mode
https://dev.to/abdelino17/package-your-fastapi-application-with-distroless-docker-images-16k9 - multi-staging with Pipfile
https://www.merixstudio.com/blog/docker-multi-stage-builds-python-development/ - multi-staging with wheels
https://falco.org/ - runtime security monitoring of container