Docker イメージサイズの最適化:1GBから数十MBへ

Docker tutorial - IT technology blog
Docker tutorial - IT technology blog

背景:1GB超のイメージがリソースを食い尽くしている

ある朝、本番環境へのデプロイ中に気づいた。CI/CDパイプラインがレジストリへのイメージプッシュだけで約8分かかっている。サーバーへのプルにさらに5分。シンプルなNode.jsアプリなのに1.2GB — アプリが複雑なわけではなく、最初の日からずっと雑に書かれたDockerfileを誰も手を入れていなかっただけだ。

30台以上のコンテナが動く本番クラスターで、この記事で紹介するテクニックを適用したところ、リソース使用量を40%削減できた — ディスクスペースからイメージレイヤーのRAMキャッシュまで。思っていた以上に効果があった。

イメージが重いと様々な問題が連鎖する。CI/CDが遅くなり、プル時の帯域幅を無駄に消費し、コンテナの起動に時間がかかり、レジストリのストレージ料金は毎月GBごとに課金される。数十のサービスが動くマイクロサービス環境では、その数字はあっという間に膨らむ。

Dockerイメージが肥大化する原因

修正する前に、なぜイメージが重くなるのかを理解する必要がある。主に3つの原因に集約される。

ベースイメージが重すぎる

デフォルトのnode:18python:3.11を使うと、フル構成のDebianイメージ — 約900MBから1GB — を引っ張ってくることになる。その大半は本番環境で一度も使わないツール、ライブラリ、manページだ。

ビルド成果物と依存関係の残留

アプリをビルドする際、コンパイル、パッケージのダウンロード、キャッシュファイルの生成といった各ステップがレイヤーにコミットされる。同じRUNコマンド内で削除しない限り、それらはイメージに永続的に残り続ける — 後のステップでrm -rfしても関係ない。

レイヤーの順序が非合理的

COPYRUNADDの各命令は新しいレイヤーを生成する。変更が加わるとレイヤーキャッシュが無効化され、それ以降のレイヤーをすべて再ビルドしなければならない。不要なレイヤーが多いほど、イメージは重くなりビルドも遅くなる。

診断:どこが太っているのか確認する

追加インストール不要。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を一度書き直すだけで、デプロイのたびに、毎日、ずっと時間を節約し続けられる。

Share: