背景:1GB超のイメージがリソースを食い尽くしている
ある朝、本番環境へのデプロイ中に気づいた。CI/CDパイプラインがレジストリへのイメージプッシュだけで約8分かかっている。サーバーへのプルにさらに5分。シンプルなNode.jsアプリなのに1.2GB — アプリが複雑なわけではなく、最初の日からずっと雑に書かれたDockerfileを誰も手を入れていなかっただけだ。
30台以上のコンテナが動く本番クラスターで、この記事で紹介するテクニックを適用したところ、リソース使用量を40%削減できた — ディスクスペースからイメージレイヤーのRAMキャッシュまで。思っていた以上に効果があった。
イメージが重いと様々な問題が連鎖する。CI/CDが遅くなり、プル時の帯域幅を無駄に消費し、コンテナの起動に時間がかかり、レジストリのストレージ料金は毎月GBごとに課金される。数十のサービスが動くマイクロサービス環境では、その数字はあっという間に膨らむ。
Dockerイメージが肥大化する原因
修正する前に、なぜイメージが重くなるのかを理解する必要がある。主に3つの原因に集約される。
ベースイメージが重すぎる
デフォルトのnode:18やpython:3.11を使うと、フル構成のDebianイメージ — 約900MBから1GB — を引っ張ってくることになる。その大半は本番環境で一度も使わないツール、ライブラリ、manページだ。
ビルド成果物と依存関係の残留
アプリをビルドする際、コンパイル、パッケージのダウンロード、キャッシュファイルの生成といった各ステップがレイヤーにコミットされる。同じRUNコマンド内で削除しない限り、それらはイメージに永続的に残り続ける — 後のステップでrm -rfしても関係ない。
レイヤーの順序が非合理的
COPY、RUN、ADDの各命令は新しいレイヤーを生成する。変更が加わるとレイヤーキャッシュが無効化され、それ以降のレイヤーをすべて再ビルドしなければならない。不要なレイヤーが多いほど、イメージは重くなりビルドも遅くなる。
診断:どこが太っているのか確認する
追加インストール不要。Dockerには分析ツールが標準搭載されている。以下のコマンドでどのレイヤーが最も容量を占めているか確認できる。
# 全イメージのサイズを確認
docker images
# イメージの各レイヤーを分析
docker history my-app:latest
# サイズ付きでレイヤーの詳細を表示
docker history --no-trunc --format "table {{.CreatedBy}}\t{{.Size}}" my-app:latest
より深く分析したい場合はdiveをインストールしよう — 各レイヤーでどのファイルが追加・削除・変更されたかを可視化できる優れたオープンソースツールだ。
# Ubuntu/Debianにdiveをインストール
wget https://github.com/wagoodman/dive/releases/download/v0.12.0/dive_0.12.0_linux_amd64.deb
sudo dpkg -i dive_0.12.0_linux_amd64.deb
# イメージを分析
dive my-app:latest
diveは「無駄なスペース」も計算してくれる — 後のレイヤーで削除されたにもかかわらずイメージ内に占有し続けているファイルの割合だ。この数字は思っている以上に高いことが多い。
実践的な最適化テクニック5選
1. AlpineまたはSlimベースイメージを使う
小さな変更で即座に数百MBを節約できる。
# 変更前:約900MB
FROM node:18
# 変更後:約180MB
FROM node:18-alpine
# またはslimバリアント:約240MB(glibcが必要な場合にalpineよりエラーが少ない)
FROM node:18-slim
Alpineはglibcではなくmusl libcを使用しているため、一部のNode.jsネイティブモジュールは再ビルドが必要になる。Alpineでエラーが出る場合は先にslimを試してみよう — リスクが低く、それでもデフォルトのベースイメージと比べて大幅に小さい。
2. マルチステージビルド — 最も劇的な効果をもたらすテクニック
マルチステージビルドはビルド環境をランタイム環境から分離する。最初のステージでコンパイルと依存関係のインストールを行い、最終ステージには実行に必要なものだけを含める — ソースコードなし、devDependenciesなし、npmキャッシュなし。
# ステージ1:ビルド
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# ステージ2:本番(必要なものだけを含む)
FROM node:18-alpine AS production
WORKDIR /app
# 本番依存関係のインストールのためにpackageファイルのみをコピー
COPY package*.json ./
RUN npm ci --only=production && npm cache clean --force
# builderステージからビルド出力をコピー
COPY --from=builder /app/dist ./dist
EXPOSE 3000
CMD ["node", "dist/index.js"]
実際の結果:一般的なNode.jsアプリで本番イメージが800MBから150〜200MBに削減される。Goはさらに印象的だ — 静的バイナリはランタイム不要で、scratchイメージから実行できる。
# ビルドステージ
FROM golang:1.21-alpine AS builder
WORKDIR /app
COPY go.* ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main .
# 本番ステージ — わずか約5MB!
FROM scratch
COPY --from=builder /app/main /main
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
EXPOSE 8080
CMD ["/main"]
3. レイヤーの順序を最適化してRUNコマンドをまとめる
各RUNはレイヤーを生成する。さらに重要なのは、レイヤーNでファイルを作成してレイヤーN+1で削除しても、そのファイルはイメージに残り続ける — 見えなくなるだけで、実際には削除されていない。
# 誤り:aptキャッシュが最初のレイヤーに残ってしまう
RUN apt-get update && apt-get install -y curl git
RUN apt-get clean && rm -rf /var/lib/apt/lists/*
# 正しい:1つのRUNにまとめ、同じレイヤー内でクリーンアップ
RUN apt-get update && apt-get install -y \
curl \
git \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
また、変更頻度に応じてレイヤーを並べよう — 最も変更が少ないものを上に置くことで、再ビルド時のキャッシュを最大限活用できる。
# 適切な順序:変更が少ないものを先に、頻繁に変わるものを後に
FROM node:18-alpine
WORKDIR /app
# 1. パッケージファイル(依存関係の追加・削除時のみ変更)
COPY package*.json ./
RUN npm ci --only=production && npm cache clean --force
# 2. ソースコード(最も頻繁に変更される)
COPY src/ ./src/
CMD ["node", "src/index.js"]
4. .dockerignoreを正しく使う
.dockerignoreファイルは.gitignoreと同じように動作する — ビルドコンテキストをDockerデーモンに送る前に不要なファイルやフォルダを除外する。このファイルが欠けているのは、他の人のDockerfileをレビューする際に最もよく見かける典型的なミスだ。
# .dockerignore
node_modules
npm-debug.log
.git
.gitignore
.env
.env.*
README.md
docs/
tests/
coverage/
*.test.js
*.spec.js
Dockerfile
docker-compose*.yml
.dockerignore
.dockerignoreがない状態でCOPY . .を実行すると、ローカルのnode_modulesディレクトリ全体がビルドコンテキストにコピーされる — 数百MBになることもあり、ビルドが遅くなりイメージサイズも増大する。たとえ後からRUN npm ciで上書きしても関係ない。
5. ADDを避けてCOPYを優先する
ADDにはアーカイブの自動展開とインターネットからのURL取得という追加機能がある。便利に聞こえるが、その動作は予想外の問題を引き起こしやすい — 特にURLが変わった場合やアーカイブの構造が期待通りでない場合。ローカルファイルにはCOPYを、URLにはRUN curlを使おう。明確で、デバッグしやすく、同じレイヤー内でクリーンアップもできる。
結果を計測して継続的に管理する
ビルド後はすぐに比較しよう。
# 比較用に別タグでイメージをビルド
docker build -t my-app:optimized .
# サイズを比較
docker images | grep my-app
# 出力例:
# my-app optimized sha256:abc... 2 minutes ago 187MB
# my-app latest sha256:def... 1 hour ago 1.1GB
時間とともにイメージが再び肥大化しないよう、CI/CDパイプラインにサイズチェックを追加しよう — 閾値を超えたらビルドを即座に失敗させる。
# イメージが300MBを超えたらCIを失敗させる
IMAGE_SIZE=$(docker image inspect my-app:latest --format='{{.Size}}')
MAX_SIZE=314572800 # 300MB(バイト単位)
if [ "$IMAGE_SIZE" -gt "$MAX_SIZE" ]; then
echo "ERROR: イメージサイズ ${IMAGE_SIZE} が制限 ${MAX_SIZE} を超えています"
exit 1
fi
echo "イメージサイズ OK: ${IMAGE_SIZE} バイト"
GitHub ActionsやGitLab CIを使っている場合は、BuildKitを有効にしよう — 従来のDockerビルドよりもキャッシュ効率が高く、特にマルチステージビルドで効果を発揮する。
# ローカルビルド時にBuildKitを有効化
DOCKER_BUILDKIT=1 docker build -t my-app:latest .
5つのテクニックをすべて適用すれば、Node.jsは通常1GB超から150〜250MBに削減できる。GoやRustなら20MB未満も狙える。Dockerfileを一度書き直すだけで、デプロイのたびに、毎日、ずっと時間を節約し続けられる。
