- Published on
Dockerイメージのサイズを減らす — レイヤーの真実、multi-stage build、ベースイメージの実コスト
- Authors

- Name
- Youngju Kim
- @fjvbn20031
- はじめに — 1.9GBのNodeイメージはどこから来たのか
- イメージが膨らむ場所は推測せず特定する
- ファイルを消してもイメージが小さくならない理由
- マルチステージビルド — 何だけをコピーするか
- ベースイメージの選択 — alpineがいつ損になるのか
- .dockerignoreはイメージではなくビルドコンテキストを減らす
- サイズより重要なもの — レイヤー再利用率と実際のpull時間
- おわりに — 消すのではなく最初から入れないこと
はじめに — 1.9GBのNodeイメージはどこから来たのか
デプロイはできます。問題はイメージが1.9GBだという点です。レジストリのストレージ費用は毎月上がり、ノードがスケールアウトするたびにPodがReadyになるまで90秒かかります。ロールバック1回でまた90秒です。
docker imagesを開いてみると、ベースに使ったnode:22がすでに1.1GBで、残りの800MBがどこから来たのかは誰も説明できません。この状態で検索すると、たいてい「alpineを使いましょう」「RUNを1つにまとめましょう」といった答えが出てきます。どちらも半分だけ正しく、残りの半分は状況によっては損になります。
この記事はサイズが大きくなるメカニズムから押さえます。ユニオンファイルシステムがレイヤーをどう積むかを理解すれば、なぜRUN rm -rfがサイズを1バイトも減らせないのか、なぜマルチステージビルドが唯一確実な解法なのか、そしてなぜあるチームにとってはサイズそのものが誤った最適化目標なのかが一度に整理されます。
イメージが膨らむ場所は推測せず特定する
最適化の第一歩はDockerfileを直すことではなく、レイヤーを開いてみることです。Dockerは各命令が作ったレイヤーのサイズをそのまま記録しています。
docker history myapp:latest
IMAGE CREATED CREATED BY SIZE
a3f91c2e77b1 3 minutes ago CMD ["node" "dist/server.js"] 0B
<missing> 3 minutes ago RUN /bin/sh -c npm run build # buildkit 48.2MB
<missing> 3 minutes ago RUN /bin/sh -c npm install # buildkit 901MB
<missing> 4 minutes ago COPY . . # buildkit 286MB
<missing> 4 minutes ago RUN /bin/sh -c apt-get update && apt-get inst… 412MB
<missing> 2 weeks ago /bin/sh -c #(nop) CMD ["node"] 0B
<missing> 2 weeks ago /bin/sh -c #(nop) COPY file:4d192565a7220e13… 388B
<missing> 2 weeks ago /bin/sh -c set -ex && for key in 4ED778F539E… 5.3MB
<missing> 2 weeks ago /bin/sh -c #(nop) ENV NODE_VERSION=22.14.0 0B
<missing> 2 weeks ago /bin/sh -c #(nop) ADD file:07cf5b0f8bd1d5d5… 74.8MB
ここですでに3つ見えています。npm installが901MB、ビルドツールのインストールが412MB、COPY . .が286MBです。ソースコードが286MBであるはずがないので、.gitやローカルのnode_modulesが丸ごと入ったという意味です。
docker historyはレイヤーごとの総量しか見せません。レイヤーの中で何が無駄になっているかはdiveで見ます。
dive myapp:latest --ci --lowestEfficiency=0.95
Analyzing image
efficiency: 71.8443 %
wastedBytes: 407583744 bytes (408 MB)
userWastedPercent: 21.4127 %
Filename Total Space
/root/.npm/_cacache 214 MB
/var/lib/apt/lists 48 MB
/app/.git 61 MB
/usr/share/doc 23 MB
/tmp/build-deps 62 MB
wastedBytesは、上位レイヤーで上書きまたは削除されたものの、下位レイヤーにはそのまま残っているバイト数です。408MBが転送され保存されるのに、コンテナからは見えることすらありません。この数字がなぜ生まれるのかが次節のテーマです。
ファイルを消してもイメージが小さくならない理由
Dockerイメージは読み取り専用レイヤーを積み上げた構造であり、このスタックは決して巻き戻りません。overlayfsは上位レイヤーでファイルが削除されると、元を消す代わりにホワイトアウトのエントリを1つ追加します。マウントされた結果ではファイルが消えたように見えますが、下位レイヤーのデータは物理的にそのまま存在し、イメージにもそのまま含まれます。
ですから以下のDockerfileは何も節約しません。
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y build-essential
RUN make -C /src all
RUN apt-get purge -y build-essential && apt-get autoremove -y
RUN rm -rf /var/lib/apt/lists/*
docker build -t bad-cleanup . && docker history bad-cleanup --format '{{.Size}}\t{{.CreatedBy}}'
0B RUN /bin/sh -c rm -rf /var/lib/apt/lists/* # buildkit
2.1MB RUN /bin/sh -c apt-get purge -y build-essential && apt-get autoremove -y # buildkit
18.4MB RUN /bin/sh -c make -C /src all # buildkit
396MB RUN /bin/sh -c apt-get update && apt-get install -y build-essential # buildkit
74.8MB /bin/sh -c #(nop) ADD file:...
purgeのレイヤーは、サイズを減らすどころか2.1MBを追加しました。削除マーカーと更新されたパッケージDBが新しいレイヤーに記録されたためです。396MBはそのまま残ります。
ルールは1つです。作ったものは、作ったそのRUNの中で消さなければなりません。
FROM debian:bookworm-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends build-essential \
&& make -C /src all \
&& apt-get purge -y --auto-remove build-essential \
&& rm -rf /var/lib/apt/lists/*
こうするとレイヤーは1つになり、そのレイヤーの最終状態にだけ成果物が残ります。ここでよく誤って拡張される助言が「だからすべてのRUNを1つにまとめろ」です。それはキャッシュの再利用を破壊します。まとめるべきなのは、生成と後始末が対になっているコマンドだけです。依存関係のインストールとソースのビルドは、むしろ分けるべきです。
パッケージマネージャごとに片付ける対象は決まっています。
# Debian / Ubuntu
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl \
&& rm -rf /var/lib/apt/lists/*
# Alpine
RUN apk add --no-cache ca-certificates curl
# Python
RUN pip install --no-cache-dir -r requirements.txt
# Node
RUN npm ci --omit=dev && npm cache clean --force
apk add --no-cacheとpip install --no-cache-dirはキャッシュを残さないオプションであって、事後の片付けではありません。順序ではなくオプションで解決できる場合は、オプションを使うほうが常に安全です。
マルチステージビルド — 何だけをコピーするか
同じRUNの中で片付けるルールを守っても、コンパイラやヘッダファイルは結局どこかに存在しなければなりません。マルチステージはその存在自体を最終イメージから取り除きます。ビルダーステージのレイヤーは、最終イメージのマニフェストにそもそも入りません。
肝心なのは文法ではなく、最後のステージへ何を持ち込むかという判断です。まずは誤った例から見ます。
# アンチパターン: マルチステージを使いながら丸ごとコピー
FROM node:22 AS builder
WORKDIR /app
COPY . .
RUN npm install && npm run build
FROM node:22-slim
WORKDIR /app
COPY /app /app
CMD ["node", "dist/server.js"]
COPY --from=builder /app /appはdevDependencies、ソースコード、テストフィクスチャ、ビルドキャッシュをすべて持ってきます。ベースを替えただけなので削減幅はほぼありません。きちんと使うとこうなります。
# syntax=docker/dockerfile:1.7
FROM node:22-bookworm-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM node:22-bookworm-slim AS builder
WORKDIR /app
COPY /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:22-bookworm-slim AS prod-deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
FROM gcr.io/distroless/nodejs22-debian12:nonroot
WORKDIR /app
COPY /app/node_modules ./node_modules
COPY /app/dist ./dist
COPY package.json ./
USER nonroot
CMD ["dist/server.js"]
ステージが4つある理由があります。depsはdevDependenciesまで含めてビルドに使い、prod-depsは本番用の依存関係だけを別にインストールします。2つのステージは互いに独立なので、BuildKitが並列に実行します。最終イメージにはランタイムの依存関係とバンドル成果物、そしてpackage.jsonだけが入ります。
コンパイル言語はもっと劇的です。
FROM golang:1.23-bookworm AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/api ./cmd/api
FROM gcr.io/distroless/static-debian12:nonroot
COPY /out/api /api
USER nonroot
ENTRYPOINT ["/api"]
docker images --format '{{.Repository}}:{{.Tag}}\t{{.Size}}' | head -3
api:distroless 14.8MB
api:naive-golang 1.24GB
node-app:optimized 198MB
CGO_ENABLED=0が重要です。cgoが有効だとバイナリがglibcに動的リンクされ、distroless/staticやscratchで即座に落ちます。静的リンクが不可能な場合はdistroless/baseを使えば、glibcとCA証明書が入っています。
scratchを使うときに忘れやすい3つは、CA証明書、タイムゾーンデータ、そして/etc/passwdです。TLS検証がx509: certificate signed by unknown authorityで失敗するなら、たいていは1つ目です。
FROM scratch
COPY /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY /usr/share/zoneinfo /usr/share/zoneinfo
COPY /out/api /api
ENTRYPOINT ["/api"]
ベースイメージの選択 — alpineがいつ損になるのか
「alpineを使えば小さくなる」は事実です。問題は何を対価に支払うかです。
alpineはglibcの代わりにmusl libcを使います。Pythonのエコシステムでは、これがそのままコストになります。PyPIのバイナリホイールの大半はmanylinuxタグで配布され、これはglibcを前提としています。musl環境ではmusllinuxホイールがあるパッケージだけがバイナリでインストールされ、なければpipがソースからコンパイルします。
# python:3.12-slim
$ time pip install pandas==2.2.3
Downloading pandas-2.2.3-cp312-cp312-manylinux_2_17_x86_64.whl (12.7 MB)
Successfully installed pandas-2.2.3
real 0m9.412s
# python:3.12-alpine
$ time pip install pandas==2.2.3
Downloading pandas-2.2.3.tar.gz (4.4 MB)
Building wheel for pandas (pyproject.toml) ... done
Successfully installed pandas-2.2.3
real 11m38.204s
ビルドツールを入れなければならないのでイメージもまた大きくなり、CI時間は70倍になります。最近は主要なパッケージがmusllinuxホイールも併せて配布していますが、社内パッケージや古い依存関係が1つ引っかかるだけでそのまま再現します。
2つ目のコストはDNSです。muslのリゾルバは長い間、512バイトを超えるUDP応答に対してTCPで再試行せず、AとAAAAのクエリを同じソケットで同時に送っていました。Kubernetesの既定のndots: 5設定と組み合わさると検索ドメインが複数付いて応答が大きくなり、conntrackの競合まで重なって断続的なName does not resolveが発生します。musl 1.2.4でTCPフォールバックが入り、Alpine 3.18以降はこれを含みますが、固定された旧バージョンのalpineイメージを使う現場は依然として多くあります。
3つ目は性能です。muslのmallocはglibcよりマルチスレッドのアロケーションで遅いです。スレッドを多用するJVMやネイティブ拡張の多いワークロードでは、イメージ100MBを節約して遅延を失うことがあります。
| ベース | 非圧縮サイズ | libc | シェル/パッケージマネージャ | 適した用途 | 注意点 |
|---|---|---|---|---|---|
debian:bookworm | 約117MB | glibc | あり | ビルダーステージ | ランタイムには過剰 |
debian:bookworm-slim | 約75MB | glibc | あり | ほとんどのランタイム | ドキュメント/ロケール削除済み |
python:3.12-slim | 約130MB | glibc | あり | Pythonサービスの既定値 | コンパイラなし |
alpine:3.20 | 約8MB | musl | あり | 静的バイナリ、シェルが必要な場合 | ホイール再ビルド、DNS、malloc |
distroless/base | 約20MB | glibc | なし | 動的リンクのバイナリ | シェルでのデバッグ不可 |
distroless/static | 約2MB | なし | なし | Go/Rustの静的ビルド | cgo使用時は失敗 |
scratch | 0B | なし | なし | 完全な静的バイナリ | CA/tzdataを自分でコピー |
実務上の既定値は単純です。GoとRustはdistroless static、PythonとJavaはslim、alpineは最終イメージが静的バイナリであるか、シェルツールがどうしても必要な場合にだけ使います。
distrolessを選ぶときの本当の決定はサイズではなく運用方法です。コンテナにシェルがないのでdocker exec -it ... shは動きません。代わりにデバッグコンテナを付けます。
kubectl debug -it api-7d9f8c6b4-x2ktl \
--image=busybox:1.36 --target=api --share-processes
.dockerignoreはイメージではなくビルドコンテキストを減らす
.dockerignoreの効果はよく誤解されます。これはイメージサイズの最適化ツールではなく、転送量の最適化ツールです。docker build .を実行すると、まずカレントディレクトリ全体がビルダーへ転送されます。ここに.gitやローカルのnode_modulesがあると、ビルドのたびに数百MBを送ることになります。
docker build -t myapp .
[+] Building 41.6s (12/12) FINISHED
=> [internal] load build context 18.3s
=> => transferring context: 612.44MB 18.1s
18秒がファイルのコピーだけに使われました。.dockerignoreを追加したあとを見ます。
.git
.gitignore
node_modules
dist
coverage
**/*.log
.env
.env.*
Dockerfile
docker-compose*.yml
README.md
[+] Building 21.9s (12/12) FINISHED
=> [internal] load build context 0.4s
=> => transferring context: 3.71MB 0.3s
COPY . .を使っているなら、イメージサイズにもそのまま影響します。ただし.envを除外する理由はサイズではなくセキュリティです。コンテキストに入ったシークレットはCOPY . .の一行でイメージに永久に埋め込まれます。
BuildKitはコンテキストを差分転送するので、2回目のビルドからは変更分だけを送ります。それでもCIでは毎回新しいビルダーなので、最初の18秒がパイプラインのたびに繰り返されます。
サイズより重要なもの — レイヤー再利用率と実際のpull時間
ここで一度方向を変える必要があります。目標はイメージサイズではなく、デプロイ時間とコストです。そしてその2つは思ったほど比例しません。
レジストリからイメージを受け取るとき、Dockerはすでにローカルにあるレイヤーを再度受け取りません。そして転送されるのは圧縮されたレイヤーです。
docker pull registry.example.com/api:v312
v312: Pulling from api
9c704ecd0c69: Already exists
2f8a1e3b7d44: Already exists
b17d2a9e4c31: Already exists
7e2c9f04a8b6: Pull complete
d3a91b2e5f77: Pull complete
Digest: sha256:4f9a...
Status: Downloaded newer image for registry.example.com/api:v312
400MBのイメージで実際に受け取ったのが12MBということもあり得ます。逆に、200MBの「小さな」イメージでも依存関係のレイヤーがコミットのたびに無効化されれば、毎回200MBを転送します。レイヤーの順序はサイズよりデプロイ時間を大きく左右します。
ですからDockerfileは、変更頻度の低いものから高いものの順に並べます。
FROM python:3.12-slim
# 1. ほとんど変わらない: システムパッケージ
RUN apt-get update \
&& apt-get install -y --no-install-recommends libpq5 \
&& rm -rf /var/lib/apt/lists/*
# 2. たまに変わる: 依存関係のリスト
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# 3. コミットのたびに変わる: ソース
COPY src/ ./src/
CMD ["python", "-m", "src.main"]
測るべき値は3つです。圧縮後の転送サイズ、コールドスタートでのpull時間、そしてローリングアップデート時に実際に新しく受け取るレイヤーの割合です。
# 圧縮サイズはマニフェストで確認
crane manifest registry.example.com/api:v312 \
| jq '[.layers[].size] | add / 1048576 | round'
71
# コールドキャッシュでのpull時間を計測
docker image rm registry.example.com/api:v312 >/dev/null
time docker pull registry.example.com/api:v312
real 0m6.284s
これらの数字が目標値の内側にあるなら、200MBを150MBに削るためにalpineへ移してPythonホイールを再ビルドする作業は純粋な損失です。逆に、デプロイのたびに900MBのnode_modulesレイヤーを新しく受け取っているなら、イメージの総サイズに触れる前にレイヤーの順序から直すべきです。
おわりに — 消すのではなく最初から入れないこと
イメージ最適化で覚えておく文はひとつです。レイヤーは巻き戻らないので、最終イメージに入ってはいけないものは、そもそもそのステージで作らないことです。
優先順位はこの順番が実用的です。まず.dockerignoreでコンテキストを整理し、ビルドツールが必要ならマルチステージで分離し、後始末は生成と同じRUNの中で行い、ベースはランタイムの特性に合わせて選び、最後に変更頻度の順にレイヤーを配置します。そしてこれらすべての成果は、イメージサイズではなくコールドスタートのpull時間で検証します。