開発チームがお届けするブログリレーです!
既に公開されている記事もありますので、こちらから他のメンバーの投稿もぜひチェックしてみてください!
はじめに
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が不要な環境では無駄にサイズが大きくなります。
最後に
マルチステージビルドについて比較してみて、かなり理解が深まりました。
使い分けとしては、よりセキュアで軽量なイメージを作成できるマルチステージは本番環境使用に適しており、
デバッグツール等が含まれた単一ステージは、開発環境に適していると感じました。
手放しでとりあえずやっておくと良いというものではないので、運用面やメンテナンス性を踏まえて環境ごとに採用すべきか検討していこうと思います。