Skip to content
Published on

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

シェア
Authors

はじめに — 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-cachepip 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 --from=builder /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 --from=deps /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 --from=prod-deps /app/node_modules ./node_modules
COPY --from=builder /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 --from=builder /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/staticscratchで即座に落ちます。静的リンクが不可能な場合はdistroless/baseを使えば、glibcとCA証明書が入っています。

scratchを使うときに忘れやすい3つは、CA証明書、タイムゾーンデータ、そして/etc/passwdです。TLS検証がx509: certificate signed by unknown authorityで失敗するなら、たいていは1つ目です。

FROM scratch
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo
COPY --from=builder /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約117MBglibcありビルダーステージランタイムには過剰
debian:bookworm-slim約75MBglibcありほとんどのランタイムドキュメント/ロケール削除済み
python:3.12-slim約130MBglibcありPythonサービスの既定値コンパイラなし
alpine:3.20約8MBmuslあり静的バイナリ、シェルが必要な場合ホイール再ビルド、DNS、malloc
distroless/base約20MBglibcなし動的リンクのバイナリシェルでのデバッグ不可
distroless/static約2MBなしなしGo/Rustの静的ビルドcgo使用時は失敗
scratch0Bなしなし完全な静的バイナリ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時間で検証します。