AAtsushi's Blog
Infrastructure

Docker Best Practices

→ 日本語版を読む

A summary of Docker best practices I researched.

General Guidelines

  • Pin the version of the base image. Using digest references is even better.
  • Make use of build cache
  • Use backslashes in RUN to split arguments across multiple lines
  • Isolate applications. Do not put multiple applications in a single container.
  • Do not install unnecessary packages. For example, a DB image does not need a text editor.
  • Use multi-stage builds to reduce the final image size.
  • Use a .dockerignore file to exclude files unrelated to the build.

Image

  • Use official images as base images (do not use your own images as base images).
  • Alpine is recommended due to its small size.

LABEL

  • Adding labels allows you to search for images and containers.

  • Dockerfile dockerfile LABEL python_version="3.8.12"

  • Searching

> docker images --filter "label=python_version=3.8.12"
REPOSITORY   TAG       IMAGE ID       CREATED              SIZE
mmtest       latest    b2cf0b43f1dc   About a minute ago   2.39GB
> docker container ls --filter "label=python_version=3.8.12"
CONTAINER ID   IMAGE     COMMAND       CREATED          STATUS          PORTS     NAMES
b15779dec25a   mmtest    "/bin/bash"   13 seconds ago   Up 12 seconds             blissful_chebyshev

RUN

  • When statements in RUN become long, split them across multiple lines using backslashes for readability.
  • RUN apt-get has some counterintuitive behaviors:
    • Always include apt-get install in the same RUN statement as apt-get update (a technique called "cache busting"). Running apt-get update alone causes cache problems and can cause issues with subsequent apt-get install.
      • OK
  RUN apt-get update && RUN apt-get install -y curl

    * You can also bust the cache with version pinning:
RUN apt-get update && RUN apt-get install -y curl=7.74.0

  * NG
    * When building, Docker stores all layers in the Docker cache. If you build multiple times, Docker considers the first and subsequent instructions identical and reuses the cache from the previous step. As a result, `apt-get update` is not executed, and there is a possibility of obtaining outdated versions of curl and nginx packages.
RUN apt-get update
RUN apt-get install -y curl

* Cleaning the apt cache
  * Delete files under /var/lib/apt/lists to reduce image size.
RUN apt-get update && RUN apt-get install -y curl
  && rm -rf /var/lib/apt/lists/*

* Error handling in pipes
  * `RUN` executes with `/bin/sh -c`. Since the exit code is only evaluated from the last operation in the pipe, errors in the middle of the pipe may not be detected.
  * Use `set -o pipefail &&` to fail on errors.
RUN set -o pipefail && wget -O - https://some.site | wc -l > /number

ENV

  • Setting version numbers with ENV makes maintenance easier.
  ENV PG_MAJOR=9.3
  ENV PG_VERSION=9.3.4
  RUN curl -SL https://example.com/postgres-$PG_VERSION.tar.xz | tar -xJC /usr/src/postgres && …
  ENV PATH=/usr/local/postgres-$PG_MAJOR/bin:$PATH

  • ENV creates a layer just like RUN. If you unset an environment variable in a layer after ENV, it remains in the layer and can be dumped. To prevent this and properly unset the environment variable, you need to set and unset it within a single RUN command.
# syntax=docker/dockerfile:1
FROM alpine
RUN export ADMIN_USER="mark" \
    && echo $ADMIN_USER > ./mark \
    && unset ADMIN_USER
CMD sh

  • Alternatively, put all commands into a shell script and execute it with a RUN command.

ADD or COPY

COPY copies files from the build context or a stage in a multi-stage build into the container.

ADD, on the other hand, fetches and adds files from a remote HTTPS or Git repository.

You can also use bind mounts instead of COPY. Bind mounts are more efficient than COPY because they can include files from the build context.

Files mounted via bind mount are temporarily mounted for a single RUN execution and are not persisted in the final image. Use COPY if you want to persist files in the final image.

RUN --mount=type=bind,source=requirements.txt,target=/tmp/requirements.txt \
    pip install --requirement /tmp/requirements.txt

For more reliable build caching, ADD is preferable to manually adding remote files with wget or tar.

ENTRYPOINT

The best practice is to specify the command with ENTRYPOINT and set command parameters in CMD. You can pass parameters via docker run to override the CMD arguments.

FROM buildpack-deps:bullseye
ENTRYPOINT ["/bin/bash", "-c"]
CMD ["ls"]

USER

  • When running a service as an unprivileged user, use USER to switch to a non-root user. When switching to a non-root user with USER, create the group and user in advance.
  RUN groupadd -r postgres && useradd --no-log-init -r -g postgres postgres

  • However, avoid frequent use of USER to reduce complexity.
  • Avoid installing and using sudo, as it can cause unexpected TTY and signal forwarding behavior issues. If you absolutely need to use sudo, for example when initializing a daemon as root and running it as non-root, consider using gosu.

WORKDIR

  • For clarity and readability, always use absolute paths for WORKDIR.
  • Avoid writing RUN cd … && do-something from a readability and maintainability perspective. Use WORKDIR instead.

References

Best practices for Dockerfile instructions

Dockerfile reference