Smaller Python Docker Images with Build Mounts

If you want small Docker images and fast builds, you're likely already using Docker's multi-stage builds. For Python applications, this often means one stage to download and compile any wheels and another stage to install them and add your application source code. For example:

FROM python:3.10 AS build
COPY requirements.txt .
RUN pip wheel -r requirements.txt \
    --wheel-dir /whl \
    --no-cache-dir

FROM python:3.10-slim
COPY --from=build /whl/*.whl /whl/
RUN pip install --no-deps /whl/*.whl

Given a small requirements.txt, we can build this image with Docker BuildKit:

❯ cat requirements.txt
ciso8601
flask
numpy
pandas

❯ DOCKER_BUILDKIT=1 docker build --no-cache -t python-with-copy .
[+] Building 22.3s (12/12) FINISHED
 => [internal] load build definition from copy.Dockerfile                       0.0s
 => => transferring dockerfile: 42B                                             0.0s
 => [internal] load .dockerignore                                               0.0s
 => => transferring context: 2B                                                 0.0s
 => [internal] load metadata for docker.io/library/python:3.10-slim             0.0s
 => [internal] load metadata for docker.io/library/python:3.10                  0.0s
 => [internal] load build context                                               0.0s
 => => transferring context: 37B                                                0.0s
 => CACHED [build 1/3] FROM docker.io/library/python:3.10                       0.0s
 => CACHED [stage-1 1/3] FROM docker.io/library/python:3.10-slim                0.0s
 => [build 2/3] COPY requirements.txt .                                         0.0s
 => [build 3/3] RUN pip wheel -r requirements.txt     --wheel-dir /whl     --n  9.5s
 => [stage-1 2/3] COPY --from=build /whl/*.whl /whl/                            0.1s
 => [stage-1 3/3] RUN pip install --no-deps /whl/*.whl                          9.5s
 => exporting to image                                                          2.2s
 => => exporting layers                                                         2.2s
 => => writing image sha256:242b56c7f1b6f181986d62fcd80afde5287d5f2d8cdaddd96d  0.0s
 => => naming to docker.io/library/python-with-copy                             0.0s

We can inspect the history of the image to view the sizes of each layer:

❯ docker history python-with-copy
IMAGE          CREATED         CREATED BY                                      SIZE      COMMENT
242b56c7f1b6   8 minutes ago   RUN /bin/sh -c pip install --no-deps /whl/*.…   127MB     buildkit.dockerfile.v0
<missing>      8 minutes ago   COPY /whl/*.whl /whl/ # buildkit                28.9MB    buildkit.dockerfile.v0
<missing>      2 weeks ago     /bin/sh -c #(nop)  CMD ["python3"]              0B
<missing>      2 weeks ago     /bin/sh -c set -ex;   savedAptMark="$(apt-ma…   9.51MB
<missing>      2 weeks ago     /bin/sh -c #(nop)  ENV PYTHON_GET_PIP_SHA256…   0B
<missing>      2 weeks ago     /bin/sh -c #(nop)  ENV PYTHON_GET_PIP_URL=ht…   0B
<missing>      2 weeks ago     /bin/sh -c #(nop)  ENV PYTHON_SETUPTOOLS_VER…   0B
<missing>      2 weeks ago     /bin/sh -c #(nop)  ENV PYTHON_PIP_VERSION=21…   0B
<missing>      2 weeks ago     /bin/sh -c cd /usr/local/bin  && ln -s idle3…   32B
<missing>      2 weeks ago     /bin/sh -c set -ex   && savedAptMark="$(apt-…   29.5MB
<missing>      4 weeks ago     /bin/sh -c #(nop)  ENV PYTHON_VERSION=3.10.0    0B
<missing>      4 weeks ago     /bin/sh -c #(nop)  ENV GPG_KEY=A035C8C19219B…   0B
<missing>      4 weeks ago     /bin/sh -c set -eux;  apt-get update;  apt-g…   3.11MB
<missing>      4 weeks ago     /bin/sh -c #(nop)  ENV LANG=C.UTF-8             0B
<missing>      4 weeks ago     /bin/sh -c #(nop)  ENV PATH=/usr/local/bin:/…   0B
<missing>      4 weeks ago     /bin/sh -c #(nop)  CMD ["bash"]                 0B
<missing>      4 weeks ago     /bin/sh -c #(nop) ADD file:16dc2c6d1932194ed…   80.4MB

From this, we can see that COPY /whl/*.whl /whl/ adds 28.9MB to our Docker image. We don't need the wheels in our final image, but we can't free up this space with a RUN rm -rf /whl/ instruction, so what do we do?

We can use Docker BuildKit's build mounts feature. This is most commonly used to pass secrets or SSH keys to the build, but we're interested in the bind mount type, which "can be used to bind files from other part of the build without copying".

We use it like so:

# syntax=docker/dockerfile:1.2
FROM python:3.10 AS build
COPY requirements.txt .
RUN pip wheel -r requirements.txt \
    --wheel-dir /whl \
    --no-cache-dir

FROM python:3.10-slim
RUN --mount=type=bind,target=/whl,source=/whl,from=build \
    pip install --no-deps /whl/*.whl

We can build this image:

❯ DOCKER_BUILDKIT=1 docker build --no-cache -t python-with-mount .
[+] Building 22.6s (15/15) FINISHED
 => [internal] load build definition from mount.Dockerfile                      0.0s
 => => transferring dockerfile: 43B                                             0.0s
 => [internal] load .dockerignore                                               0.0s
 => => transferring context: 2B                                                 0.0s
 => resolve image config for docker.io/docker/dockerfile:1.3                    0.6s
 => CACHED docker-image://docker.io/docker/dockerfile:1.3@sha256:42399d4635edd  0.0s
 => [internal] load build definition from mount.Dockerfile                      0.0s
 => [internal] load .dockerignore                                               0.0s
 => [internal] load metadata for docker.io/library/python:3.10-slim             0.0s
 => [internal] load metadata for docker.io/library/python:3.10                  0.0s
 => [internal] load build context                                               0.0s
 => => transferring context: 37B                                                0.0s
 => CACHED [stage-1 1/2] FROM docker.io/library/python:3.10-slim                0.0s
 => CACHED [build 1/3] FROM docker.io/library/python:3.10                       0.0s
 => [build 2/3] COPY requirements.txt .                                         0.0s
 => [build 3/3] RUN pip wheel -r requirements.txt     --wheel-dir /whl     --n  9.7s
 => [stage-1 2/2] RUN --mount=type=bind,target=/whl,source=/whl,from=build      9.2s
 => exporting to image                                                          2.1s
 => => exporting layers                                                         2.0s
 => => writing image sha256:1d677e47533d54467300be2c1b4898cb7b5d07b539877c2f07  0.0s
 => => naming to docker.io/library/python-with-mount                            0.0s

And inspect its layers:

❯ docker history python-with-mount
IMAGE          CREATED          CREATED BY                                      SIZE      COMMENT
1d677e47533d   59 seconds ago   RUN /bin/sh -c pip install --no-deps /whl/*.…   127MB     buildkit.dockerfile.v0
<missing>      2 weeks ago      /bin/sh -c #(nop)  CMD ["python3"]              0B
<missing>      2 weeks ago      /bin/sh -c set -ex;   savedAptMark="$(apt-ma…   9.51MB
<missing>      2 weeks ago      /bin/sh -c #(nop)  ENV PYTHON_GET_PIP_SHA256…   0B
<missing>      2 weeks ago      /bin/sh -c #(nop)  ENV PYTHON_GET_PIP_URL=ht…   0B
<missing>      2 weeks ago      /bin/sh -c #(nop)  ENV PYTHON_SETUPTOOLS_VER…   0B
<missing>      2 weeks ago      /bin/sh -c #(nop)  ENV PYTHON_PIP_VERSION=21…   0B
<missing>      2 weeks ago      /bin/sh -c cd /usr/local/bin  && ln -s idle3…   32B
<missing>      2 weeks ago      /bin/sh -c set -ex   && savedAptMark="$(apt-…   29.5MB
<missing>      4 weeks ago      /bin/sh -c #(nop)  ENV PYTHON_VERSION=3.10.0    0B
<missing>      4 weeks ago      /bin/sh -c #(nop)  ENV GPG_KEY=A035C8C19219B…   0B
<missing>      4 weeks ago      /bin/sh -c set -eux;  apt-get update;  apt-g…   3.11MB
<missing>      4 weeks ago      /bin/sh -c #(nop)  ENV LANG=C.UTF-8             0B
<missing>      4 weeks ago      /bin/sh -c #(nop)  ENV PATH=/usr/local/bin:/…   0B
<missing>      4 weeks ago      /bin/sh -c #(nop)  CMD ["bash"]                 0B
<missing>      4 weeks ago      /bin/sh -c #(nop) ADD file:16dc2c6d1932194ed…   80.4MB

As expected, the COPY step and its 28.9MB are gone. This is about 10% of our initial image size freed up!