本文へ移動
購読
GUIDE#devtools更新 2026.09.24

Dockerfileの書き方:マルチステージビルド・レイヤーキャッシュ・イメージ軽量化

Dockerfileの書き方を実測つきで整理。キャッシュが効く順番、.dockerignore、uvを使ったPythonのマルチステージビルドで1.65GBを200MBにした手順、secretとVOLUMEの注意点。

level
中級
time
約30分
stack
Docker Engine 29 / BuildKit(buildx)/ Python 3.12 / uv 0.12
author
@ryoma_t
// 前提条件
docker runとdocker buildの基本操作ができること / BuildKit(buildxプラグイン)が使える環境であること
マルチステージビルドで作ったFastAPIイメージのサイズと、docker historyで実行用ステージのレイヤー構成を確認したターミナル
画像:nagoya.dev(実際の画面・実行結果をもとに作成)

Dockerfileとは、コンテナイメージの作り方(土台にするイメージ、コピーするファイル、実行するコマンド)を上から順に書いたテキストファイルで、docker buildがこれを1行ずつ実行してイメージを作ります。書き方しだいで、同じアプリでもイメージの大きさとビルドの待ち時間が大きく変わります。

ここでは、uvで依存を管理するPython(FastAPI)の小さなAPIを題材に、素朴な1段構成のDockerfileとマルチステージビルドのDockerfileを実際にビルドし、サイズとキャッシュの効き方を比べました。検証環境はDocker Engine 29.1.3、docker CLI 29.8.1とbuildx 0.37.1(BuildKit 0.26.2)で、数値はすべて執筆時点のものです。

この記事でわかること

  • Dockerfileの基本命令と、命令ごとにレイヤーとキャッシュができる仕組み
  • キャッシュが効く命令の順番と、.dockerignoreの効果(実測)
  • uvを使ったPythonアプリのマルチステージビルドと、1段構成とのサイズ比較
  • ARGとENVの違い、ビルド時の秘密情報をsecretマウントで渡す理由
  • VOLUME命令とdocker volumeの関係、docker build --checkでの検査

Dockerfileとは:命令・レイヤー・キャッシュの関係

イメージは、ファイルシステムの差分である「レイヤー」の積み重ねです。RUN・COPY・ADDはファイルを変えるのでレイヤーを1つ作り、ENV・CMD・EXPOSE・USERは設定情報だけを変えます。docker historyで見ると、後者はサイズが0Bになっています。

ビルドのキャッシュは、命令の文字列と入力(COPYならコピー元ファイルの中身)が前回と同じかどうかで判定されます。ある命令のキャッシュが外れると、それより下の命令はすべて作り直しです。つまり「変わりにくいものを上に、変わりやすいものを下に」並べるのが、書き方の基本方針になります。ファイル名は拡張子なしのDockerfileが既定で、別の名前にしたときはdocker build -f Dockerfile.dev .のように-fで指定します。

Dockerfileの書き方の基本:1段構成の例と問題点

題材のアプリは、pyproject.toml・uv.lock・main.pyの3ファイルです。uv.lockはuvのuv lockで作ったロックファイルで、依存の正確なバージョンが入っています。

pyproject.toml
[project]
name = "hello-api"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
    "fastapi>=0.115",
    "uvicorn>=0.30",
]
main.py
from fastapi import FastAPI

app = FastAPI()


@app.get("/health")
def health() -> dict[str, str]:
    return {"status": "ok"}

まず、よく見かける形の1段構成です。動きはしますが、あとで比べるための「悪い例」として書いています。

Dockerfile.single(比較用)
FROM python:3.12
WORKDIR /app
COPY . .
RUN pip install fastapi uvicorn
CMD uvicorn main:app --host 0.0.0.0 --port 8000

問題は4つあります。1つ目は、コンパイラなども入ったフルサイズのpython:3.12を土台にしていること。2つ目はCOPY . .がpip installより上にあるため、main.pyを1文字直すだけで依存のインストールがやり直しになること。3つ目は、uv.lockを無視してpip installしているので、ビルドした日によって入るバージョンが変わること。4つ目は、rootのまま動くことです。docker build --checkを使うと、ビルドせずにDockerfileの書き方だけを検査でき、この例ではCMDの書き方が指摘されました。

出力(docker build --check -f Dockerfile.single .)
Check complete, 1 warning has been found!

WARNING: JSONArgsRecommended - https://docs.docker.com/go/dockerfile/rule/json-args-recommended/
JSON arguments recommended for CMD to prevent unintended behavior related to OS signals
Dockerfile.single:5
--------------------
   3 |     COPY . .
   4 |     RUN pip install fastapi uvicorn
   5 | >>> CMD uvicorn main:app --host 0.0.0.0 --port 8000
   6 |
--------------------

CMD uvicorn ...のようなシェル形式は/bin/sh -c経由で起動されるため、docker stopが送るSIGTERMをシェルが受け取り、uvicornまで届きません。結果として10秒の猶予のあと強制終了(SIGKILL)されます。CMD ["uvicorn", "main:app", ...]のJSON形式(exec形式)で書けば、uvicornがPID 1として直接シグナルを受け取り、処理中のリクエストを片付けてから終了できます。検証では、シェル形式で起動したコンテナのPID 1は/bin/sh(Debianのdash)で、docker stopに10.2秒かかりました。JSON形式ではPID 1がuvicornになり、0.4秒でApplication shutdown complete.をログに出して終了しました。

レイヤーキャッシュが効く順番と.dockerignore

依存のインストールは、依存を定義したファイル(ここではpyproject.tomlとuv.lock)だけを先に取り込んで実行し、アプリのコードはその後にコピーします。こうすると、コードを直したときに作り直されるのは最後の数行だけです。main.pyを1行変えて再ビルドしたところ、1段構成は依存のインストールからやり直して8.2秒、後述のマルチステージ構成は依存の層がCACHEDのまま1.5秒でした(検証環境での1回の計測で、ネットワークの速さで大きく変わります)。

BuildKitのRUN --mount=type=cacheを使うと、キャッシュが外れて依存を入れ直す場合でも、ダウンロード済みのパッケージを使い回せます。キャッシュはイメージの外(BuildKit側)に保存されるため、イメージのサイズは増えません。

見落とされがちなのが.dockerignoreです。docker buildはまずカレントディレクトリの中身(ビルドコンテキスト)をまとめてBuildKitへ送り、COPY . .はそこからコピーします。検証で30MBのダミーファイルを入れた.venvを置いたところ、.dockerignoreがないとtransferring context: 30.01MB、あるとtransferring context: 119Bでした。さらに、.dockerignoreがないとコンテナの中に.envまでコピーされていました。

.dockerignore
.git
.venv
__pycache__/
*.pyc
.env
Dockerfile*

マルチステージビルドでPythonアプリのイメージを軽くする

マルチステージビルドは、1つのDockerfileにFROMを複数書き、前のステージで作った成果物だけを最後のステージへCOPY --fromで持ち込む書き方です。依存のダウンロードやビルドに使った道具(uvやキャッシュ)は最終イメージに残りません。

Dockerfile
# syntax=docker/dockerfile:1
ARG PYTHON_VERSION=3.12

# ---- builder: 依存パッケージだけを /app/.venv に入れる ----
FROM python:${PYTHON_VERSION}-slim AS builder
COPY --from=ghcr.io/astral-sh/uv:0.12 /uv /bin/uv
ENV UV_COMPILE_BYTECODE=1 \
    UV_LINK_MODE=copy \
    UV_PYTHON_DOWNLOADS=0
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 --locked --no-dev --no-install-project

# ---- runtime: .venv とアプリのコードだけを持ち込む ----
FROM python:${PYTHON_VERSION}-slim
RUN useradd --create-home --uid 10001 app
WORKDIR /app
COPY --from=builder /app/.venv /app/.venv
COPY main.py ./
ENV PATH="/app/.venv/bin:$PATH"
USER app
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s \
  CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health')"]
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

builderステージでは、uvのバイナリを公式イメージからCOPY --fromで1つだけ取り出し、pyproject.tomlとuv.lockを--mount=type=bindで読み込みます。bindマウントはCOPYと違ってレイヤーを作らず、2ファイルの中身が変わらない限りこのRUNはキャッシュされます。--lockedはuv.lockとpyproject.tomlがずれていればビルドを止め、--no-install-projectは依存だけを入れる指定です。環境変数の3つは、.pycを事前に作る(起動を速くする)、キャッシュのマウント先からはコピーで展開する、Pythonをダウンロードさせない、という意味です。

runtimeステージは同じpython:3.12-slimから始め、.venvとmain.pyだけをコピーします。コードと.venvはrootの所有のまま置き、UID 10001のappユーザーで実行するので、アプリが乗っ取られても自分のコードを書き換えられません(検証でもtouch: cannot touch '/app/x': Permission deniedになりました)。2つのステージの土台をそろえているのは、.venvの中のpythonが土台のPythonへのシンボリックリンクだからで、違う土台にすると起動できなくなります(つまずきポイントで実例を示します)。

イメージDISK USAGECONTENT SIZE
hello-api:single(1段構成・python:3.12)1.65GB425MB
hello-api:multi(マルチステージ・python:3.12-slim)200MB48.4MB
参考:python:3.12-slim そのもの179MB45.4MB

表の2列は、Docker 29のcontainerdイメージストアでdocker imagesが表示する値です。CONTENT SIZEは圧縮されたレイヤーの合計でpullやpushで転送される量、DISK USAGEは展開後にディスクを使う量を指します(従来のストレージドライバーを使う環境やDockerの版によっては、SIZE列が1つだけ表示されます)。ステージを分けたことで、土台のslimイメージに上乗せされたのは.venvの17.4MBとmain.pyだけになりました。Goのように依存を含めた単一のバイナリを作れる言語なら、最終ステージをdistrolessやscratchにしてさらに小さくできます。

ステージを分けても軽くならない、あるいは向かないケースもあります。実行時にgccやヘッダーファイルが必要なライブラリ(実行中に拡張をビルドするもの)は、builderだけに入れると動きません。また、依存がほとんどない数行のスクリプトなら、土台のイメージがサイズの大半を占めるので、1段構成でslimイメージを使うだけで十分です。

ARGとENVの違い、秘密情報はsecretマウントで渡す

ARGはビルド中だけ使える変数で、docker build --build-argで上書きできます。上のDockerfileのPYTHON_VERSIONのように、最初のFROMより前に書いたARGはFROMの行でも使えます。ENVはイメージに記録され、コンテナの実行時にも環境変数として残ります。

社内のパッケージサーバーのトークンのような秘密情報をARGで渡すのは避けます。RUNで使ったARGの値はイメージの履歴に残り、イメージを受け取った人なら誰でも読めるからです。代わりにBuildKitのsecretマウントを使うと、値はそのRUNの間だけ/run/secrets/の下に置かれ、履歴にもイメージにも残りません。

ARGで渡した場合(docker history --no-trunc の出力)
RUN |1 API_TOKEN=demo-not-real-123 /bin/sh -c echo "token length: ${#API_TOKEN}" > /build-info.txt # buildkit
ARG API_TOKEN=demo-not-real-123
secretマウントで渡す例
# syntax=docker/dockerfile:1
FROM alpine:edge
RUN --mount=type=secret,id=api_token \
    echo "token length: $(wc -c < /run/secrets/api_token)" > /build-info.txt
terminal
docker build --secret id=api_token,src=token.txt -t secret-demo -f Dockerfile.secret .

secretマウント版では、履歴に残るのはRUN /bin/sh -c echo "token length: $(wc -c < /run/secrets/api_token)" ...という命令文だけで、値は出てきませんでした。完成したイメージでls /run/secretsを実行してもNo such file or directoryになり、ファイルも残っていません。

VOLUME命令とdocker volume:データの置き場所は実行時に決める

DockerfileのVOLUME /dataは「このパスはボリュームにする」という宣言で、docker runで-vを指定しなかった場合、実行のたびに名前のない(ハッシュ値の名前の)ボリュームが作られます。検証ではVOLUMEを書いたイメージを--rmなしで1回実行しただけでdocker volume lsの件数が7から8に増え、docker rm -vで消すまで残りました。

アプリのイメージにはVOLUMEを書かず、データの置き場所は実行する側で決めるのがおすすめです。docker run -v appdata:/data hello-api:multiのように名前付きボリュームを渡せば、コンテナを作り直してもデータが残り、docker volume ls・docker volume inspect appdata・docker volume rm appdataで管理できます。複数のコンテナを組み合わせるなら、名前付きボリュームとバインドマウントの使い分けはDocker Compose入門の表を参考にしてください。

VOLUMEを宣言したパスは、子イメージの側でVOLUMEを取り消す方法がありません。公式イメージの中にもVOLUMEを持つものがあるので、テストで--rmなしのdocker runを繰り返すと、名前のないボリュームが静かに溜まっていきます。後片付けはdocker rm -v(またはdocker rm -fv)で行ってください。

つまずきポイント

症状・エラーメッセージ原因対処
the --mount option requires BuildKit.とDEPRECATED: The legacy builder is deprecatedbuildxプラグインがなく、旧来のビルダーで動いている。Ubuntu標準のdocker.ioだけを入れた環境で起きたUbuntuのdocker-buildxパッケージ(Docker公式のリポジトリならdocker-buildx-plugin)を入れる
exec /app/.venv/bin/uvicorn: no such file or directory.venvのpythonが、builderにしかないPython(検証では/root/.local/share/uv/python/cpython-3.13-...)を指している両ステージの土台を同じにし、UV_PYTHON_DOWNLOADS=0でuvにPythonを取得させない
error: No interpreter found for Python 3.13 in managed installations or search pathUV_PYTHON_DOWNLOADS=0の状態で、土台にない版のPythonを要求した.python-versionやrequires-pythonを土台のイメージ(ここでは3.12)に合わせる
warning: Failed to hardlink files; falling back to full copy.キャッシュのマウント先と.venvが別のファイルシステムで、ハードリンクが張れないUV_LINK_MODE=copyを設定する(上のDockerfileでは設定済み)

動作確認

ビルドしたら、サイズ、ヘルスチェックの状態、実行ユーザーの3点を確かめます。ポートはホストの127.0.0.1だけに公開しています。-p 18000:8000のようにアドレスを省くと全インターフェースで待ち受け、ファイアウォールの設定によっては外から届いてしまいます。

terminal
$ cd ~/hello-api
$ docker build --check .
Check complete, no warnings found.
$ docker build -t hello-api:multi .
$ docker images hello-api
IMAGE             ID             DISK USAGE   CONTENT SIZE   EXTRA
hello-api:multi   40ec13295c14        200MB         48.4MB
$ docker run -d --name hello-test -p 127.0.0.1:18000:8000 hello-api:multi
$ curl -s http://127.0.0.1:18000/health
{"status":"ok"}
$ docker ps --filter name=hello-test --format '{{.Names}}  {{.Status}}'
hello-test  Up 9 seconds (healthy)
$ docker exec hello-test id
uid=10001(app) gid=10001(app) groups=10001(app)
$ docker rm -fv hello-test

STATUSに(healthy)が付くのは、DockerfileのHEALTHCHECKが成功している印です。起動直後は(health: starting)と表示され、--start-periodの5秒を過ぎて最初のチェックが通るとhealthyに変わります。レイヤーの中身と大きさはdocker history hello-api:multiで確認でき、今回はCOPY /app/.venvの17.4MBとCOPY main.pyの12.3kBが上乗せ分のすべてでした。

よくある質問

Dockerfileに拡張子は必要ですか?

必要ありません。拡張子なしのDockerfileという名前にすると、docker build .が自動で読みます。用途別に分けたいときはDockerfile.devやapi.Dockerfileのように名前を付け、docker build -f Dockerfile.dev .で指定します。

Dockerfileはどうやって実行しますか?

docker build -t 名前:タグ .をDockerfileのあるディレクトリで実行するとイメージができ、docker runでそのイメージからコンテナを起動します。末尾の.はビルドコンテキスト(BuildKitへ送るディレクトリ)の指定で、COPYはこの中のファイルしか参照できません。

COPYとADDはどう使い分けますか?

COPYはビルドコンテキストのファイルをコピーするだけです。ADDはそれに加えて、URLからのダウンロードやローカルのtarアーカイブの自動展開も行います。意図しない展開やダウンロードを避けるため、通常はCOPYを使い、tarを展開したいときだけADDを選ぶのが安全です。

ARGとENVの違いは何ですか?

ARGはビルド中だけ有効な変数で、--build-argで値を変えられます。ENVはイメージに記録され、コンテナの実行時にも環境変数として残ります。どちらもdocker historyなどから値が見えるため、パスワードやトークンはARGにもENVにも入れず、secretマウントで渡します。

まとめ

Dockerfileは、依存の定義→依存のインストール→コードのコピーの順に並べ、.dockerignoreでコンテキストを絞るだけで、再ビルドの待ち時間が大きく変わります。道具を最終イメージから外すマルチステージ構成にすると、今回の例では1.65GBが200MBになりました。秘密情報はsecretマウント、データの置き場所は実行時の-vで決め、仕上げにdocker build --checkをかけます。

次に読む

$ subscribe --weekly
毎週月曜、企業ITとホームラボの要点を5分で。