Dockerイメージは、何も考えずに作ると、コンパイラやビルド用のライブラリまで含んだ大きなものになりがちです。
イメージが大きいと、レジストリへのpushやデプロイ先でのpullに時間がかかり、不要なツールが残る分だけ、脆弱性の対応範囲も広がります。
この記事では、ビルドに使う環境と、実行に使う環境を分ける「マルチステージビルド」の書き方を、Pythonアプリの例で解説します。
あわせて、イメージを小さく保つための記述順と、移行時につまずきやすい点も紹介します。
なぜイメージが大きくなるのか
Dockerfileの RUN・COPY・ADD の命令は、それぞれがイメージのレイヤーになります。
レイヤーは積み重ねで記録されるため、あとの命令でファイルを削除しても、前のレイヤーに含まれたデータは消えません。
たとえば、ビルドのために gcc を入れ、最後の行で削除しても、イメージのサイズはほとんど減りません。
つまり、「入れてから消す」のではなく、「最終的なイメージに、最初から入れない」ことが大切です。
これを実現する仕組みが、マルチステージビルドです。
マルチステージビルドの基本
1つのDockerfileに、FROM を複数書けます。
FROM ごとに新しいステージが始まり、最後のステージが、出来上がるイメージになります。
前のステージの成果物は、COPY --from=ステージ名 で必要なものだけを取り出せます。
# ステージ1:ビルド用
FROM ベースイメージ AS builder
# ……コンパイルや依存のインストール……
# ステージ2:実行用(これが最終イメージになる)
FROM ベースイメージ
COPY --from=builder /ビルド結果 /配置先
| 書き方 | 意味 |
|---|---|
FROM イメージ AS 名前 |
ステージに名前を付ける |
COPY --from=名前 元 先 |
指定したステージから、ファイルをコピーする |
docker build --target 名前 . |
指定したステージまでで、ビルドを止める |
ステージには番号(最初が 0)でも参照できますが、FROM の順序を入れ替えたときに壊れるため、名前を付けるほうが安全です。
--target を使うと、テスト用のステージだけをビルドする、といった使い分けもできます。
具体例:PostgreSQLに接続するPythonアプリ
PostgreSQL用のドライバー psycopg2 をソースからインストールする場合を例にします。
インストール時には、Cコンパイラ(gcc)と開発用ヘッダー(libpq-dev)が必要ですが、実行時に必要なのは共有ライブラリ(libpq5)だけです。
1つのステージで書いた場合
FROM python:3.13-slim
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends gcc libpq-dev
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["python", "main.py"]
この書き方では、gcc と開発用ヘッダーが、実行用のイメージにそのまま残ります。
マルチステージで書いた場合
# ---- ビルド用 ----
FROM python:3.13-slim AS builder
RUN apt-get update \
&& apt-get install -y --no-install-recommends gcc libpq-dev \
&& rm -rf /var/lib/apt/lists/*
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# ---- 実行用 ----
FROM python:3.13-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends libpq5 \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
WORKDIR /app
COPY . .
RUN useradd --system --uid 10001 appuser
USER appuser
CMD ["python", "main.py"]
ポイントは次のとおりです。
- 依存パッケージを、仮想環境(
/opt/venv)にまとめてインストールする。 - 実行用のステージには、仮想環境のディレクトリだけをコピーする。
- 実行用のステージには、実行時に必要なライブラリ(
libpq5)だけを入れる。 apt-getのパッケージ一覧(/var/lib/apt/lists)は、同じRUNの中で削除する。- 実行ユーザーを、rootではない専用ユーザーにする。
効果は、docker image ls で2つのイメージのサイズを比べると確認できます。
どのレイヤーが大きいかは、docker history イメージ名 で調べられます。
減り方は、依存パッケージの内容によって変わるため、自分のプロジェクトで測ってください。
キャッシュを効かせる記述順
イメージの大きさと並んで、ビルドの速さも大切です。
Dockerは、命令とその入力が変わっていなければ、前回のレイヤーを再利用します。
ある命令のキャッシュが使えなくなると、それ以降の命令はすべて実行し直しになります。
そのため、変更が少ないものを上に、変更が多いものを下に書きます。
- ベースイメージとOSのパッケージ
- 依存の定義ファイル(
requirements.txtなど)のコピーと、インストール - アプリのコード(
COPY . .)
先に COPY . . を書くと、コードを1行直しただけで、依存のインストールからやり直しになります。
あわせて、.dockerignore で、ビルドに不要なファイルを除外します。
.git、仮想環境(.venv)、__pycache__、.env などが代表的です。
不要なファイルが減るとビルドが速くなり、.env のような秘密情報を誤ってイメージに入れることも防げます。
注意点と落とし穴
ビルド用と実行用で、ベースイメージをそろえる
仮想環境は、作成したときのPythonの場所とバージョンに依存しています。
ビルド用が python:3.13-slim、実行用が python:3.12-slim のように違うと、コピーした仮想環境が動きません。
また、slim(Debian系、glibc)でビルドしたものを alpine(musl)で動かすと、ネイティブ拡張が読み込めないことがあります。
両方のステージで、同じベースイメージを使うのが確実です。
実行時に必要なライブラリを入れ忘れる
ビルド用のステージでは動いていたのに、実行用のイメージで ImportError や共有ライブラリが見つからないエラーが出ることがあります。
多くは、例の libpq5 のような、実行時のライブラリの入れ忘れです。
マルチステージに移行したら、イメージを起動して、主要な処理が動くかを確認します。
秘密情報をビルド引数に渡さない
非公開リポジトリのトークンなどを ARG や ENV で渡すと、ビルド用のステージであっても、ビルドキャッシュやビルドの記録に残るおそれがあります。
BuildKit(現在のDockerの標準のビルダー)では、RUN --mount=type=secret を使うと、レイヤーに残さずに秘密情報を使えます。
イメージを小さくしすぎない
シェルすら含まない最小限のイメージは、軽い反面、障害時にコンテナの中で調査ができません。
最初は slim 系のイメージで始め、必要に応じて、さらに小さいイメージを検討する順序がおすすめです。
まとめ
マルチステージビルドは、「ビルドに必要なもの」と「実行に必要なもの」を分けて、最終的なイメージに後者だけを入れる仕組みです。
コンパイラや開発用ヘッダーが必要な依存があるなら、まず移行を検討する価値があります。
移行では、両ステージのベースイメージをそろえることと、実行時のライブラリを入れることの2点を確認してください。
記述順と .dockerignore を見直せば、イメージの大きさだけでなく、ビルドの時間も短くできます。