TT Lab
はじめる
学ぶ 学習パス コース

イメージのビルド

イメージを小さくする順序

TT Labで続きを見る

一言でいうと

マルチステージの核心は文法ではなく、最終ステージに何だけをコピーするのかです。ビルダーステージのレイヤーは、最終イメージのマニフェストにそもそも入りません。

なぜ必要なのか

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つだけを載せたイメージが本当に動作するか(そして、シェルがないこと)を確認します。