言語を切り替える
テーマを切り替える

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つ目の問題:ビルドツールのクリーンアップ不足。

イメージの中に gccmakepython をインストールしていた。なぜなら、いくつかの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"]

最適化の道のり

  1. alpineに切り替え:900MB → 170MB
  2. マルチステージビルド:170MB → 120MB
  3. 本番依存のみインストール(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-devgcc のインストールが必要かもしれない
  • 一部の科学計算パッケージ(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 サフィックスが付いている方がデバッグ版。

継続的改善の提案

  1. 定期的なベースイメージ更新:毎月確認、セキュリティ脆弱性がかなり修正されている
  2. イメージサイズ監視:CIでチェックを追加、100MBを超えたらアラート
  3. hadolintの使用:Dockerfile静的チェックツール、事前に問題を発見
    docker run --rm -i hadolint/hadolint < Dockerfile

おすすめツール

ツール用途インストール
diveイメージ分析brew install dive
hadolintDockerfileチェックbrew install hadolint
docker-slim自動スリム化brew install docker-slim

docker-slim は面白い。イメージの実行時に実際にどのファイルが使われたかを分析し、使われていないものをすべて削除する。ただし、テスト環境で先に実行することをおすすめする。本番環境を壊さないように。

Dockerイメージ最適化5ステップ法

分析から検証までの完全な最適化プロセスで、Dockerイメージを1GBから100MBに削減

⏱️ Estimated time: 30 min

  1. 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. 2

    Step2: マルチステージビルドを使用

    ビルド環境と実行環境を分離:

    • ビルドステージ:コンパイルツール、ビルド依存をインストール
    • 実行ステージ:ビルド産物と実行時依存だけをコピー
    • FROM ... AS builder でビルドステージを定義
    • COPY --from=builder でビルド産物をコピー
  3. 3

    Step3: レイヤーキャッシュ順序を最適化

    Dockerfile命令の順序を調整してキャッシュ再利用を最大化:

    • 先に依存記述ファイルをコピー(package.json、requirements.txt)
    • その後依存をインストール(npm install、pip install)
    • 最後にソースコードをコピー(COPY . .)

    複数のRUN命令をマージ、中間ファイルの残留を回避
  4. 4

    Step4: .dockerignore を設定

    プロジェクトルートに .dockerignore ファイルを作成し、不要なファイルを除外:

    • .git、node_modules、*.log
    • .env、.vscode、tests
    • docker-compose.yml、README.md

    これでビルドコンテキストを大幅に削減、ビルド速度を向上
  5. 5

    Step5: 不要ファイルをクリーンアップ

    RUN命令でキャッシュと一時ファイルをクリーンアップ:

    • --no-install-recommends で推奨パッケージのインストールを回避
    • apt-get clean でパッケージキャッシュをクリーンアップ
    • rm -rf /var/lib/apt/lists/* でパッケージリストを削除
    • /tmp/* と /var/tmp/* をクリーンアップ

まとめ

たくさん話したが、核心はこの5ステップだ:

  1. 正しいベースイメージを選ぶ:alpineが使えるなら使う、静的コンパイルならscratch
  2. マルチステージビルド:ビルドと実行を分離、必要なものだけ残す
  3. レイヤーキャッシュを最適化:依存記述ファイルを先に、ソースコードを後に
  4. .dockerignore を設定:不要なファイルを除外
  5. 残存ファイルをクリーンアップ:aptキャッシュ、一時ファイルはすべて削除

今すぐあなたのDockerイメージをチェックしてみよう。docker history でどのレイヤーが一番スペースを占めているか確認して、この記事の方法を試してみて。

コメント欄で、あなたの最適化成果を教えてください:何MBから何MBに削減できましたか?

FAQ

AlpineとDebianベースイメージの違いは何ですか?
Alpineはmusl libcとbusyboxベースで、サイズはわずか5.5MB。Debianは標準的なglibcベースで、サイズは約80MB。Alpineはより小さいですが、互換性問題が発生する可能性があります。Debianはより安定していますが、サイズが大きいです。
マルチステージビルドはビルド速度に影響しますか?
初回ビルドは少し遅くなります(2つのステージをビルドする必要があるため)が、最終イメージサイズは大幅に削減されます。その後のビルドでは、キャッシュメカニズムにより単一ステージビルドと同等、あるいはそれ以上の速度になります。
Scratchイメージはどのようなシーンに適していますか?
Scratchは空のイメージで、静的コンパイル言語(Go、Rust)に適しています。本番環境で極限のセキュリティとサイズを追求する場合に使用します。デバッグ時はalpineに切り替えるか、別途デバッグイメージを作成する必要があります。
Alpineのglibc互換性問題をどう解決しますか?
3つの方法:1)公式の-alpineイメージを使用(ほとんど処理済み);2)gcompat互換レイヤーをインストール(apk add gcompat);3)debian:slim に切り替える。
CI/CD環境でDockerキャッシュをどう保持しますか?
BuildKitのキャッシュマウント機能を使用:RUN --mount=type=cache,target=/root/.npm npm install。これでCI環境でも依存キャッシュを再利用でき、ビルド速度は5-10倍向上します。

5 min read · 公開日: 2026年3月20日 · 更新日: 2026年3月20日

コメント

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

関連記事