Dockerイメージ最適化実践:1GBから100MBへのスリム化の旅
月曜日の朝9時、CI/CDパイプラインが赤ランプを点灯させていた。画面上で8分も走り続けているビルドタスクを眺めながら、焦りを感じていた。今回のデプロイはたかが数行のコード変更だったのに、イメージのアップロードだけで5分もかかっている——イメージがなんと1.2GBもあるからだ。
上司がチャットで「なんでまだリリースできないの?」と聞いてきた。
スクリーンショットを撮って送った:イメージをアップロード中、進捗23%。
その瞬間、決心した。この状況はもう我慢できないと。
その後、このNode.jsアプリのDockerイメージに対して一連の最適化を行った。結果は?98MBまで削減。ビルド時間は8分から2分へ。なんと10倍もの差が生まれた。
この記事では、その実践経験をシェアしたい。抽象的な話は一切なし。一歩一歩、データによる比較を示し、どのコードもそのまま使えるものだけを紹介する。
なぜあなたのイメージはこんなに大きいのか?
正直に言うと、初めて docker history でその1.2GBのイメージを確認した時、私は少し呆然とした。
docker history my-app:latest
出力はだいたいこんな感じだった:
IMAGE CREATED SIZE
abc123def456 2 hours ago 850MB # npm install の生成物
def456abc123 2 hours ago 180MB # ベースイメージ node:18
...
850MBが npm install の1層だけで占めていた。私は一瞬立ち尽くした——これ、どうやってこんなに大きくなったんだ?
イメージ肥大化の4つの原因
最初の問題:ベースイメージの選択ミス。
私のDockerfileの最初の行はこうなっていた:
FROM node:18
node:18 このイメージはDebianベースで、私には不要なものが大量に含まれていた:パッケージマネージャー、システムツール、開発ライブラリ。ベースイメージだけで900MBだ。
公式イメージのサイズ比較を見てみよう:
| イメージ | サイズ |
|---|---|
| node:18 | ~900MB |
| node:18-slim | ~230MB |
| node:18-alpine | ~170MB |
| alpine:3.18 | ~5.5MB |
| distroless/static | ~2MB |
見ただろう?node:18-alpine に変えるだけで730MB節約できる。
2つ目の問題:ビルドツールのクリーンアップ不足。
イメージの中に gcc、make、python をインストールしていた。なぜなら、いくつかのnpmパッケージがコンパイルを必要としたからだ。しかし問題は——コンパイルが終わった後も放置していて、これらのツールがイメージの中で場所を取っていた。
3つ目の問題:レイヤーの累積効果。
各 RUN 命令は新しいレイヤーを作成する。私のDockerfileには十数個のRUNがあり、各レイヤーは前のレイヤーのすべてのファイルを含んでいる。削除したファイルも実際には残っていて、ただ「隠されている」だけだ。
4つ目の問題:キャッシュの未最適化。
COPY . . を最初に置いていた。つまり——コードを1行変えるたびに、イメージ全体を再ビルドしなければならない。npm install は毎回実行され、大量の依存パッケージをダウンロードする。
diveツールで詳しく見る
docker history だけでは直感的に分からない。dive というツールをおすすめする。イメージの各レイヤーを「剥き出し」にして見せてくれる。
インストールは簡単:
# macOS
brew install dive
# Linux
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
インタラクティブな画面が表示され、左側に各レイヤー、右側にファイルの変化が見える。上下キーでレイヤーを切り替え、各レイヤーでどのファイルが追加・削除・変更されたかを明確に確認できる。
初めて使った時、私は発見した:node_modules が2回も登場していた——/app/node_modules に1回、ビルドステージの一時ディレクトリに1回。これでどれだけのスペースを無駄にしていたのだろうか?
5ステップ最適化フレームワーク
よし、問題は把握した。次は解決策だ。
これらの方法を5ステップのフレームワークに整理した。各ステップには具体的な効果データがある。
ステップ1:軽量ベースイメージを選択
これは最も簡単なステップ。1行変えるだけで効果が出る。
# 変更前
FROM node:18
# 変更後
FROM node:18-alpine
効果は?900MB → 170MB。730MB節約、ベースイメージを変えただけで。
3種類の軽量イメージ、どう選ぶ?
| イメージタイプ | 適用シーン | メリット・デメリット |
|---|---|---|
| Alpine | 一般的な選択 | 小さい、パッケージ管理が充実、ただしmuslで互換性問題が発生する可能性 |
| Distroless | セキュリティ優先 | 極小、シェルなし、ただしデバッグが困難 |
| Scratch | 静的コンパイル言語(Go、Rust) | 最小(0MB)、静的コンパイルが必要 |
ほとんどの場合、Alpineは良い選択だ。Node.js公式の -alpine イメージを使えば、glibcの互換性問題はほぼ解決されている。
ステップ2:マルチステージビルドを使用
これは次元を超える打撃だ。原理はシンプル:ビルド環境と実行環境を分離し、最終イメージには実行に必要なものだけを残す。
# ビルドステージ
FROM node:18 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
# 実行ステージ
FROM node:18-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
CMD ["node", "dist/main.js"]
鍵は COPY --from=builder という行。ビルダーステージから必要なファイルだけを「盗む」。
あの1.2GBのイメージに、マルチステージビルドを追加したら200MBになった。ビルドステージの850MBのnode_modulesと各種ツールは、すべて捨てられた。
ステップ3:レイヤーキャッシュを最適化
Dockerのレイヤーキャッシュメカニズムはこうなっている:あるレイヤーが変わらなければ、キャッシュを使う。
問題は、私のDockerfileの順序が逆だった:
# 誤った例
COPY . . # 先に全ファイルをコピー
RUN npm install # その後依存をインストール
こう書くと、コードを1行変える → COPY . . が変化 → キャッシュが無効 → npm install が再実行。
正しい書き方:
# 正しい例
COPY package*.json ./ # 先に依存記述ファイルだけコピー
RUN npm install # 依存をインストール(依存が変わらなければキャッシュを再利用)
COPY . . # 最後にソースコードをコピー(ソースは頻繁に変わるので最後に)
この変更後、package.json が変わらなければ、npm install はキャッシュを直接使う。ビルド時間は3分から30秒に短縮された。
もう一つのテクニック:RUN命令をマージする。
# 変更前(4層)
RUN apt-get update
RUN apt-get install -y curl
RUN apt-get clean
RUN rm -rf /var/lib/apt/lists/*
# 変更後(1層)
RUN apt-get update && \
apt-get install -y --no-install-recommends curl && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
なぜか?各RUNは1層だから。「削除」したファイルも実際には前の層に残っている。1つの命令にマージすれば、中間ファイルはイメージに入らない。
ステップ4:.dockerignore を設定
多くの人がこれを見落とす。docker build の時、Dockerはディレクトリ全体をdaemonにパッケージして送る。ローカルに500MBの node_modules があれば、それも全部パッケージされる。
プロジェクトのルートに .dockerignore を作成:
.git
node_modules
*.log
.env
docker-compose.yml
README.md
.vscode
tests
coverage
効果は?ビルドコンテキストは500MBから50MBに削減。docker build コマンドの実行が大幅に速くなった。
ステップ5:不要ファイルのクリーンアップ
aptでパッケージをインストールしなければならない場合、クリーンアップを忘れないで:
RUN apt-get update && \
apt-get install -y --no-install-recommends \
curl \
ca-certificates && \
apt-get clean && \
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
いくつかのポイント:
--no-install-recommendsで「推奨」パッケージをインストールしない、かなりのスペース節約になるapt-get cleanでaptキャッシュをクリーンアップrm -rf /var/lib/apt/lists/*でパッケージリストを削除
このステップで、さらに50-100MB節約できる。
実践例:3言語のイメージ最適化全プロセス
話すだけでは不十分。3つの言語の完全なDockerfileを用意した。どれもそのままコピーして実行できる。
Node.js アプリケーション
初期状態:node:18 ベースイメージ、900MB
# 最適化後の完全なDockerfile
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN npm run build
FROM node:18-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
USER node
CMD ["node", "dist/main.js"]
最適化の道のり:
- alpineに切り替え:900MB → 170MB
- マルチステージビルド:170MB → 120MB
- 本番依存のみインストール(
npm ci --only=production):120MB → 98MB
Go アプリケーション
Goは静的コンパイル言語、Docker最適化に天生で適している。
# ビルドステージ
FROM golang:1.22-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 .
# 実行ステージ(scratch空イメージを使用)
FROM scratch
COPY --from=builder /app/main /main
ENTRYPOINT ["/main"]
効果:800MB → 10MB以下。
見間違いではない。scratch は空のイメージで、中にあるのはコンパイルしたバイナリファイルだけ。Goは静的コンパイル後、動的ライブラリに依存しないため、そのまま実行できる。
Python アプリケーション
Pythonは少し複雑だ。Alpineはmuslを使い、glibcではないため、いくつかのパッケージで問題が発生する可能性がある。
FROM python:3.11-alpine AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --user -r requirements.txt
COPY . .
FROM python:3.11-alpine
WORKDIR /app
COPY --from=builder /root/.local /root/.local
COPY --from=builder /app .
ENV PATH=/root/.local/bin:$PATH
CMD ["python", "main.py"]
注意点:
pip installでエラーが出る場合、musl-devとgccのインストールが必要かもしれない- 一部の科学計算パッケージ(numpy、pandas)はAlpine上でパフォーマンス問題がある可能性
- 安全策として
python:3.11-slim(Debianベース)を使うのも良い
よくある落とし穴と解決策
最適化の道中、私は多くの落とし穴に遭遇した。事前に伝えておくので、あなたも同じ轍を踏まないように。
落とし穴1:Alpineのmusl互換性問題
現象:あるnpmパッケージのインストールに失敗、glibc が見つからないというエラー。
原因:Alpineはmusl libcを使用し、標準のglibcではない。一部のパッケージはglibcに依存している。
解決策:
- Node.jsの場合:公式
node:18-alpineイメージを使用、ほとんどの問題は処理済み - Pythonの場合:インストールできなければ
debian:slimを使用 - Alpineをどうしても使う場合:
apk add gcompatで互換レイヤーを試す
落とし穴2:Scratchイメージはデバッグできない
現象:docker exec -it container sh がエラーになる、scratchにはシェルがないから。
解決策:
- 本番はscratch、デバッグ段階はalpineを使用
- または別にデバッグ用イメージを作成:
FROM alpine COPY --from=production /app /app CMD ["sh"]
落とし穴3:CI/CDでキャッシュが失われる
現象:ローカルではビルドが速いが、CIでは毎回ゼロから始まる。
原因:CI環境はDockerキャッシュを保持しない。
解決策:BuildKitのキャッシュマウントを使用:
# syntax=docker/dockerfile:1
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm \
npm install
こうするとnpmキャッシュが永続化され、CIでも再利用できる。
落とし穴4:.dockerignore の除外過多
現象:ビルドエラー、あるファイルが見つからないというメッセージ。
原因:.dockerignore が必要なファイルも除外している。
解決策:
- 段階的に除外、まず明らかなものを(node_modules、.git)
docker build --no-cacheでクリーンビルドを検証- 例外が必要な場合は
!を使用:tests !tests/fixtures
最適化効果の検証と継続的改善
最適化が終わったら、効果をどう検証するか?
検証方法
1. イメージサイズの確認
docker images my-app
2. レイヤーの詳細確認
docker history my-app:latest --no-trunc
3. 可視化分析
dive my-app:latest
パフォーマンスのトレードオフ
イメージが小さくなったが、実行時に問題はあるか?
Alpineのmusl vs glibc:
- muslはより軽量だが、一部のシナリオでパフォーマンスがやや劣る可能性
- アプリケーションがシステムコールを多用する場合、負荷テストで比較を推奨
Scratchのセキュリティ:
- 最小の攻撃面、最も安全
- ただし問題が発生した場合、コンテナに入って調査できない
私のアドバイス:セキュリティの観点から、scratchが使えるならscratchを使う。デバッグ段階はalpineに切り替える。CIでは両方のイメージを作成し、-debug サフィックスが付いている方がデバッグ版。
継続的改善の提案
- 定期的なベースイメージ更新:毎月確認、セキュリティ脆弱性がかなり修正されている
- イメージサイズ監視:CIでチェックを追加、100MBを超えたらアラート
- hadolintの使用:Dockerfile静的チェックツール、事前に問題を発見
docker run --rm -i hadolint/hadolint < Dockerfile
おすすめツール
| ツール | 用途 | インストール |
|---|---|---|
| dive | イメージ分析 | brew install dive |
| hadolint | Dockerfileチェック | brew install hadolint |
| docker-slim | 自動スリム化 | brew install docker-slim |
docker-slim は面白い。イメージの実行時に実際にどのファイルが使われたかを分析し、使われていないものをすべて削除する。ただし、テスト環境で先に実行することをおすすめする。本番環境を壊さないように。
Dockerイメージ最適化5ステップ法
分析から検証までの完全な最適化プロセスで、Dockerイメージを1GBから100MBに削減
⏱️ Estimated time: 30 min
- 1
Step1: 軽量ベースイメージを選択
ベースイメージを完全版からスリム版に切り替え:
• node:18 → node:18-alpine(900MB → 170MB)
• golang:1.22 → golang:1.22-alpine
• python:3.11 → python:3.11-alpine
注意:Alpineはmusl libcを使用、一部のglibc依存パッケージは追加処理が必要 - 2
Step2: マルチステージビルドを使用
ビルド環境と実行環境を分離:
• ビルドステージ:コンパイルツール、ビルド依存をインストール
• 実行ステージ:ビルド産物と実行時依存だけをコピー
• FROM ... AS builder でビルドステージを定義
• COPY --from=builder でビルド産物をコピー - 3
Step3: レイヤーキャッシュ順序を最適化
Dockerfile命令の順序を調整してキャッシュ再利用を最大化:
• 先に依存記述ファイルをコピー(package.json、requirements.txt)
• その後依存をインストール(npm install、pip install)
• 最後にソースコードをコピー(COPY . .)
複数のRUN命令をマージ、中間ファイルの残留を回避 - 4
Step4: .dockerignore を設定
プロジェクトルートに .dockerignore ファイルを作成し、不要なファイルを除外:
• .git、node_modules、*.log
• .env、.vscode、tests
• docker-compose.yml、README.md
これでビルドコンテキストを大幅に削減、ビルド速度を向上 - 5
Step5: 不要ファイルをクリーンアップ
RUN命令でキャッシュと一時ファイルをクリーンアップ:
• --no-install-recommends で推奨パッケージのインストールを回避
• apt-get clean でパッケージキャッシュをクリーンアップ
• rm -rf /var/lib/apt/lists/* でパッケージリストを削除
• /tmp/* と /var/tmp/* をクリーンアップ
まとめ
たくさん話したが、核心はこの5ステップだ:
- 正しいベースイメージを選ぶ:alpineが使えるなら使う、静的コンパイルならscratch
- マルチステージビルド:ビルドと実行を分離、必要なものだけ残す
- レイヤーキャッシュを最適化:依存記述ファイルを先に、ソースコードを後に
- .dockerignore を設定:不要なファイルを除外
- 残存ファイルをクリーンアップ:aptキャッシュ、一時ファイルはすべて削除
今すぐあなたのDockerイメージをチェックしてみよう。docker history でどのレイヤーが一番スペースを占めているか確認して、この記事の方法を試してみて。
コメント欄で、あなたの最適化成果を教えてください:何MBから何MBに削減できましたか?
FAQ
AlpineとDebianベースイメージの違いは何ですか?
マルチステージビルドはビルド速度に影響しますか?
Scratchイメージはどのようなシーンに適していますか?
Alpineのglibc互換性問題をどう解決しますか?
CI/CD環境でDockerキャッシュをどう保持しますか?
5 min read · 公開日: 2026年3月20日 · 更新日: 2026年3月20日
関連記事
AI コード生成のミスを減らす?この5つの Prompt テクニックで効率50%アップ
AI コード生成のミスを減らす?この5つの Prompt テクニックで効率50%アップ
Cursor 上級テクニック:開発効率を倍増させる10の実践的手法(2026年版)
Cursor 上級テクニック:開発効率を倍増させる10の実践的手法(2026年版)
Cursor バグ修正完全ガイド:エラー分析から解決策検証までの効率的ワークフロー

コメント
GitHubアカウントでログインしてコメントできます