イメージを小さくする順序
一言でいうと
マルチステージの核心は文法ではなく、最終ステージに何だけをコピーするのかです。ビルダーステージのレイヤーは、最終イメージのマニフェストにそもそも入りません。
なぜ必要なのか
1.9GBのNode.jsのイメージがありました。ノードが増えるたびに、PodがReadyになるまで90秒、ロールバック1回でさらに90秒かかります。推測せず、まずdocker historyを見ます。
RUN npm install 901MB
COPY . . 286MB
RUN apt-get update && ... 412MB
ADD file:07cf5b0f8bd1d5d5… 74.8MB
ソースコードが286MBのはずはないので、.gitやローカルのnode_modulesがまるごと入ってしまったという意味です。ツールでもう一度測ると、無駄が408MB、効率が71.8%と出ます。408MBが転送・保存されますが、コンテナからは見えもしません。
どう動くのか
ここで、最も直感に反する事実があります。次のように整理しても、1バイトも減りません。
0B RUN rm -rf /var/lib/apt/lists/*
2.1MB RUN apt-get purge -y build-essential && apt-get autoremove -y
18.4MB RUN make -C /src all
396MB RUN apt-get update && apt-get install -y build-essential
purgeのレイヤーは、サイズを減らすどころか、2.1MBを加えました。削除の印と更新されたパッケージDBが、新しいレイヤーに記録されたためです。396MBはそのままです。
ルールは1つです。作ったものは、作ったそのRUNの中で削除します。ただし、「だからすべてのRUNをまとめなさい」につながってはいけません。それはキャッシュの再利用を壊します。まとめるべきなのは、生成と後始末が対になっている命令だけです。
マルチステージのアンチパターンも明確です。
FROM node:22 AS builder
COPY . . ; RUN npm install && npm run build
FROM node:22-slim
COPY --from=builder /app /app # devDeps, 소스, 테스트, 빌드 캐시 전부
ベースだけを変えたようなもので、削減幅がほとんどありません。アーティファクトだけを選んでコピーする必要があります。
現場での姿
ベースを選ぶとき、サイズだけを見ると損をします。
| ベース | サイズ | libc | 注意点 |
|---|---|---|---|
debian:bookworm-slim |
約75MB | glibc | 無難なデフォルト |
python:3.12-slim |
約130MB | glibc | コンパイラーなし |
alpine:3.20 |
約8MB | musl | wheelの再ビルド・DNS・malloc |
distroless/static |
約2MB | なし | cgoを使うと失敗 |
scratch |
0B | なし | CA・tzdataを直接コピー |
Alpineが損になる代表的なケースが、Pythonのwheelです。同じpandasのインストールが、slimでは9.4秒なのに、Alpineではソースコンパイルに切り替わり、11分38秒かかります。CIの時間が70倍です。そして、scratchを使うときに忘れやすい3つが、CA証明書、タイムゾーンデータ、/etc/passwdです。TLS検証がx509: certificate signed by unknown authorityで失敗したら、たいてい1つ目が原因です。
最後に、方向を1つ修正する必要があります。サイズよりレイヤーの再利用率です。400MBのイメージで、実際にダウンロードするのは12MBかもしれず、200MBの「小さい」イメージでも、依存関係のレイヤーがコミットのたびに無効化されると、毎回200MBを転送します。成果は、イメージサイズではなくコールドスタートのプル時間で検証します。
削除したファイルが、なぜイメージから消えないのか
マルチステージを学ぶ前によく使う方法が、「インストールしてから削除する」です。しかし、こう書いても、イメージは1バイトも小さくなりません。
RUN apt-get install -y build-essential # 레이어 A: 400MB 늘어남
RUN apt-get purge -y build-essential # 레이어 B: 삭제 표시만 기록
それぞれのRUNはレイヤーを1つ作り、レイヤーは積み重なるだけで、前のものを消せません。Bは「そのファイルたちは存在しないように見せよ」という印を持つだけで、Aの400MBはそのままデプロイされ、そのままダウンロードされます。しかも、そのファイルは今でも取り出せます。ビルドの途中で一時的に入れて削除した機密情報がイメージに残る経路が、これです。
1つのレイヤーの中で終わらせれば、小さくなります。インストールと削除を1つのRUNにまとめると、レイヤーに記録されるのは、そのコマンドが終わったあとの状態だけです。
RUN apt-get update && apt-get install -y --no-install-recommends build-essential && make install && apt-get purge -y build-essential && rm -rf /var/lib/apt/lists/*
それでもマルチステージのほうが優れています。上の方式は、キャッシュをまるごと失うという代償を払います。ソースが1文字変わっただけでも、apt-get installからやり直しになります。マルチステージは、ビルドステージのキャッシュをそのまま残しながら、最終イメージにはCOPY --fromで持ち込むと決めたものだけを入れます。結果ではなく境界が明確になることが核心です。
キャッシュは、変化する順に並べます。依存関係の一覧を先にコピーしてインストールし、ソースはそのあとにコピーします。逆にすると、ソースを1行直すたびに、依存関係を新しくダウンロードします。
COPY go.mod go.sum ./
RUN go mod download # go.mod 가 그대로면 캐시가 산다
COPY . .
RUN go build -o /app ./cmd/server
ベースイメージは、実行に必要なもので選びます。静的にリンクしたGoのバイナリは、scratchでも動きます。TLSを使うならca-certificatesが、ユーザーを照会するなら/etc/passwdが必要です。Alpineは小さいものの、musl libcなので、glibcを前提にビルドしたバイナリが、静かに異なる動作をすることがあります。Pythonのように拡張モジュールをコンパイルする言語では、Alpineのほうがむしろイメージも大きく、ビルドも遅くなることがよくあります。
次のラボですること
ビルダーステージでアーティファクトを作り、最終ステージにはそれだけをコピーして、サイズがどれだけ小さくなるかを測り、scratchの上に静的バイナリ1つだけを載せたイメージが本当に動作するか(そして、シェルがないこと)を確認します。