開発チームがお届けするブログリレーです!
既に公開されている記事もありますので、こちらから他のメンバーの投稿もぜひチェックしてみてください!

はじめに

Dockerで開発環境を作成するという事は何度か経験してきましたが、マルチステージビルドとは何かがいまいち理解できておらず導入できてませんでした。
今回は、単一ステージとマルチステージ両方のビルドを試してみて結果を比較、そして使い道を確かめてみようと思います。

プロジェクト構成

今回は、Pythonのパッケージマネージャーであるuvを使用したFastAPIアプリのプロジェクトで試していきます。
uv公式が提供しているastral-sh/uv-docker-example を使わせていただきました。

ファイル構成

主なファイルの構成は以下の通りです。

.
├── Dockerfile # 単一ステージビルド用
├── multistage.Dockerfile # マルチステージビルド用
├── compose.yml # Docker Compose設定
├── pyproject.toml # プロジェクト設定
├── uv.lock # 依存関係のロックファイル
└── src/ # アプリケーションソースコード

依存関係

  • Python 3.12
  • FastAPI
  • uv(Pythonパッケージマネージャー)

Dockerfileの違い

Dockerfileは、単一ステージ用とマルチステージ用で2つに分かれています。

  • Dockerfile
# Use a Python image with uv pre-installed
FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim

# Install the project into `/app`
WORKDIR /app

# Enable bytecode compilation
ENV UV_COMPILE_BYTECODE=1

# Copy from the cache instead of linking since it's a mounted volume
ENV UV_LINK_MODE=copy

# Install the project's dependencies using the lockfile and settings
RUN --mount=type=cache,target=/root/.cache/uv \
    --mount=type=bind,source=uv.lock,target=uv.lock \
    --mount=type=bind,source=pyproject.toml,target=pyproject.toml \
    uv sync --frozen --no-install-project --no-dev

# Then, add the rest of the project source code and install it
# Installing separately from its dependencies allows optimal layer caching
ADD . /app
RUN --mount=type=cache,target=/root/.cache/uv \
    uv sync --frozen --no-dev

# Place executables in the environment at the front of the path
ENV PATH="/app/.venv/bin:$PATH"

# Reset the entrypoint, don't invoke `uv`
ENTRYPOINT []

# Run the FastAPI application by default
# Uses `fastapi dev` to enable hot-reloading when the `watch` sync occurs
# Uses `--host 0.0.0.0` to allow access from outside the container
CMD ["fastapi", "dev", "--host", "0.0.0.0", "src/uv_docker_example"]
  • multistage.Dockerfile
# An example using multi-stage image builds to create a final image without uv.

# First, build the application in the `/app` directory.
# See `Dockerfile` for details.
FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim AS builder
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy
WORKDIR /app
RUN --mount=type=cache,target=/root/.cache/uv \
    --mount=type=bind,source=uv.lock,target=uv.lock \
    --mount=type=bind,source=pyproject.toml,target=pyproject.toml \
    uv sync --frozen --no-install-project --no-dev
ADD . /app
RUN --mount=type=cache,target=/root/.cache/uv \
    uv sync --frozen --no-dev


# Then, use a final image without uv
FROM python:3.12-slim-bookworm
# It is important to use the image that matches the builder, as the path to the
# Python executable must be the same, e.g., using `python:3.11-slim-bookworm`
# will fail.

# Copy the application from the builder
COPY --from=builder --chown=app:app /app /app

# Place executables in the environment at the front of the path
ENV PATH="/app/.venv/bin:$PATH"

# Run the FastAPI application by default
CMD ["fastapi", "dev", "--host", "0.0.0.0", "/app/src/uv_docker_example"]

それぞれのDockerfile内では大まかに

  • uvを使用した依存関係のインストール
  • アプリケーションコードのコピー

を行っています。
マルチステージビルドではuvをbuilderステージとして依存関係のインストールを行い、最終ステージはuvを除外して実行環境のみが含まれるようにしています。
このビルド方法でどのような違いが生まれるか試してみます。

ビルドの実行

では、ビルドを試していこうと思います。
初回と2回目以降ではビルド時間に差が出そうなので、それぞれ2回試していきます。

1. 単一ステージビルド(Dockerfile)

  • 初回
$ docker build -t uv-docker-example:normal .
[+] Building 22.6s (10/10) FINISHED
 => [internal] load build definition from Dockerfile
 => [internal] load metadata for ghcr.io/astral-sh/uv:python3.12-bookworm-slim
 => [internal] load .dockerignore
 => [stage-0 1/5] FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim
 => [stage-0 2/5] WORKDIR /app
 => [stage-0 3/5] RUN --mount=type=cache,target=/root/.cache/uv \
    --mount=type=bind,source=uv.lock,target=uv.lock \
    --mount=type=bind,source=pyproject.toml,target=pyproject.toml \
    uv sync --frozen --no-install-project --no-dev
 => [stage-0 4/5] ADD . /app
 => [stage-0 5/5] RUN --mount=type=cache,target=/root/.cache/uv \
    uv sync --frozen --no-dev
 => exporting to image
 => => writing image sha256:3a70852c628ed9cf0dbabc5510a63b4f90d4d1a3285349c203848bcb24536034
 => => naming to docker.io/library/uv-docker-example:normal
  • 2回目
❯ docker builder prune -f && docker build -t uv-docker-example:normal .
ID                                              RECLAIMABLE     SIZE            LAST ACCESSED
onkoyuhutyhp6mht5td1bwg2f*                      true            1.139kB         12 seconds ago
uqzw227lbt62gfuu6cfill3p8*                      true    220.3kB         12 seconds ago
sdhisacynms395u0gpavxew84*                      true    66B             12 seconds ago
Total:  221.5kB
[+] Building 0.4s (10/10) FINISHED                                                   docker:rancher-desktop
 => [internal] load build definition from Dockerfile                                                   0.0s
 => => transferring dockerfile: 1.25kB                                                                 0.0s
 => [internal] load metadata for ghcr.io/astral-sh/uv:python3.12-bookworm-slim                         0.3s
 => [internal] load .dockerignore                                                                      0.0s
 => => transferring context: 106B                                                                      0.0s
 => [stage-0 1/5] FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim@sha256:bccff1a52988d078ce51ca71f  0.0s
 => [internal] load build context                                                                      0.0s
 => => transferring context: 227.01kB                                                                  0.0s
 => CACHED [stage-0 2/5] WORKDIR /app                                                                  0.0s
 => CACHED [stage-0 3/5] RUN --mount=type=cache,target=/root/.cache/uv     --mount=type=bind,source=u  0.0s
 => CACHED [stage-0 4/5] ADD . /app                                                                    0.0s
 => CACHED [stage-0 5/5] RUN --mount=type=cache,target=/root/.cache/uv     uv sync --frozen --no-dev   0.0s
 => exporting to image                                                                                 0.0s
 => => exporting layers                                                                                0.0s
 => => writing image sha256:3a70852c628ed9cf0dbabc5510a63b4f90d4d1a3285349c203848bcb24536034           0.0s
 => => naming to docker.io/library/uv-docker-examp

2. マルチステージビルド(multistage.Dockerfile)

  • 初回
> docker build -t uv-docker-example:multistage -f multistage.Dockerfile .
[+] Building 22.6s (14/14) FINISHED
 => [internal] load build definition from multistage.Dockerfile
 => [internal] load metadata for docker.io/library/python:3.12-slim-bookworm
 => [internal] load metadata for ghcr.io/astral-sh/uv:python3.12-bookworm-slim
 => [builder 1/5] FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim
 => [builder 2/5] WORKDIR /app
 => [builder 3/5] RUN --mount=type=cache,target=/root/.cache/uv \
    --mount=type=bind,source=uv.lock,target=uv.lock \
    --mount=type=bind,source=pyproject.toml,target=pyproject.toml \
    uv sync --frozen --no-install-project --no-dev
 => [builder 4/5] ADD . /app
 => [builder 5/5] RUN --mount=type=cache,target=/root/.cache/uv \
    uv sync --frozen --no-dev
 => [stage-1 1/2] FROM python:3.12-slim-bookworm
 => [stage-1 2/2] COPY --from=builder --chown=app:app /app /app
 => exporting to image
 => => writing image sha256:ccc2b60521b9720b93e9b48517776080579e36e8c1dae5b767ffbc1b4c897432
 => => naming to docker.io/library/uv-docker-example:multistage
  • 2回目(一部割愛)
❯ docker builder prune -f && docker build -t uv-docker-example:multistage -f multistage.Dockerfile .
ID                                              RECLAIMABLE     SIZE            LAST ACCESSED
ejqkdzoea94fqrzbbnw7xtfz7*                      true            484B            7 minutes ago
...
Total:  1.075GB
[+] Building 2.9s (13/13) FINISHED                                                   docker:rancher-desktop
 => [internal] load build definition from multistage.Dockerfile                                        0.0s
 => => transferring dockerfile: 1.19kB                                                                 0.0s
 => [internal] load metadata for docker.io/library/python:3.12-slim-bookworm                           2.7s
 => [internal] load metadata for ghcr.io/astral-sh/uv:python3.12-bookworm-slim                         0.6s
 => [internal] load .dockerignore                                                                      0.0s
 => => transferring context: 106B                                                                      0.0s
 => [builder 1/5] FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim@sha256:bccff1a52988d078ce51ca71f  0.0s
 => [stage-1 1/2] FROM docker.io/library/python:3.12-slim-bookworm@sha256:37bc09f7400bd3666285a79c458  0.0s
 => [internal] load build context                                                                      0.0s
 => => transferring context: 227.01kB                                                                  0.0s
 => CACHED [builder 2/5] WORKDIR /app                                                                  0.0s
 => CACHED [builder 3/5] RUN --mount=type=cache,target=/root/.cache/uv     --mount=type=bind,source=u  0.0s
 => CACHED [builder 4/5] ADD . /app                                                                    0.0s
 => CACHED [builder 5/5] RUN --mount=type=cache,target=/root/.cache/uv     uv sync --frozen --no-dev   0.0s
 => CACHED [stage-1 2/2] COPY --from=builder --chown=app:app /app /app                                 0.0s
 => exporting to image                                                                                 0.0s
 => => exporting layers                                                                                0.0s
 => => writing image sha256:ccc2b60521b9720b93e9b48517776080579e36e8c1dae5b767ffbc1b4c897432           0.0s
 => => naming to docker.io/library/uv-docker-example:multistage                                        0.0s

比較結果

イメージサイズ

  • 単一ステージビルド: 238MB
  • マルチステージビルド: 202MB
  • 削減率: 約15%(36MB)

ビルド時間

  • 初回ビルド: 両方とも22.6秒
  • 2回目以降(キャッシュあり):
    • 単一ステージ: 0.4秒
    • マルチステージ: 2.9秒

レイヤー数

  • 単一ステージ: 5レイヤー
  • マルチステージ: 7レイヤー(builder: 5 + 最終ステージ: 2)

ざっくりまとめると、イメージサイズはマルチステージビルドの方が小さくなるが2回目以降のビルド時間は単一ビルドより掛かるという結果になりました!

結論

比較結果を踏まえて、結論をまとめてみます。
※Dockerfileの内容によって、結果が異なる可能性がある点はご留意下さい。

マルチステージビルドのメリデメ

✓ 最終イメージのサイズが小さい

今回の場合は、uvが最終イメージに含まれない為その分サイズダウンされました。

✓ セキュリティリスクの低減

不要なビルドツールや開発用パッケージを除く事ができるので、より安全性が高まります。

✕ ビルド時間がやや長くなることがある

ステージが増える分、キャッシュの効き方やレイヤーの再利用性が下がる可能性がありそうです。

単一ステージビルドのメリデメ

✓ ビルド時間が短い(特に2回目以降)

マルチステージビルドよりステージが少ない為、より高速になる可能性が高いです。
※環境により差が少ない可能性はあります。

✓ 開発環境として使用しやすい

ビルドツール(uv)が残っている為デバッグしやすく、開発時に使いやすいイメージになってます。
他言語でもパッケージマネージャーを別ステージにする場合は同様です。

✕ 最終イメージに不要なツール等が含まれる可能性がある

今回の場合はuvが最終イメージに含まれている為、uvが不要な環境では無駄にサイズが大きくなります。

最後に

マルチステージビルドについて比較してみて、かなり理解が深まりました。
使い分けとしては、よりセキュアで軽量なイメージを作成できるマルチステージは本番環境使用に適しており、
デバッグツール等が含まれた単一ステージは、開発環境に適していると感じました。
手放しでとりあえずやっておくと良いというものではないので、運用面やメンテナンス性を踏まえて環境ごとに採用すべきか検討していこうと思います。