DiveでDockerイメージの各レイヤーを徹底分析:1.5GBから120MBへ「軽量化」した方法

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

Dockerイメージが深夜2時に容量の「ブラックホール」になった話

午前2時、スマホが激しく震えました。監視システムのPrometheusが、本番環境のeコマースプロジェクトのクラスターでDisk Pressure(ディスク容量不足)の赤色アラートを発していました。急いで確認すると、不可解な事実が判明しました。今日の午後にデプロイしたばかりのマイクロサービスが、なんと1.5GBも占有していたのです。実際の中身(ソースコード)は数MB程度しかないはずなのに。

その時の絶望感といったらありません。Dockerイメージの中に「犯人」が潜んでいるのは分かっていましたが、どうやってそれを見つけ出せばいいのか?docker imagesコマンドでは合計サイズしか分かりません。docker historyコマンドは実行されたコマンドの羅列を表示するだけで、どのレイヤーのどのファイルが容量を食っているのかまでは特定できません。

この状況は、以前2日間かけてメモリリークをデバッグした時のことを思い出させました。どちらも共通の弱点、つまりコンテナ内部の不透明さがありました。そこで出会ったのがDiveです。これは、不要なファイルで徹夜したくないすべてのDevOpsやバックエンドエンジニアが持っておくべきツールです。

なぜ通常の手法では太刀打ちできないのか?

通常、イメージが重いと感じたとき、エンジニアは次のようなコマンドを使います:

docker history my-app:latest

返ってくる結果は大抵このような感じです:

IMAGE          CREATED        CREATED BY                                      SIZE      COMMENT
7f928e345b1c   2 hours ago    COPY . . # buildkit                             850MB     
<missing>      2 hours ago    RUN npm install # buildkit                      450MB     
...

これを見ると、COPY . .のレイヤーが850MBもあります。しかし、この850MBの正体は何でしょうか?肥大化したnode_modules?誤ってコピーされたログファイル?それとも、.dockerignoreに入れ忘れたPDFドキュメントやテスト用画像でしょうか?docker historyはこれらの質問には答えてくれません。

核心的な問題は、Dockerのレイヤーの累積性にあります。例えばレイヤー1で500MBのファイルをダウンロードし、レイヤー2でrmコマンドを使ってそれを削除したとします。結果として、最終的なイメージは依然としてその500MBを「背負った」ままなのです。ファイルは最終層で非表示になるだけで、イメージの履歴の中には居座り続けます。これはDocker初心者がよく陥る典型的な罠です。

Dive — Dockerイメージの隅々まで覗く「顕微鏡」

Diveは、Dockerイメージの内容をレイヤーごとに探索できるオープンソースツールです。単にファイルをリストアップするだけではありません。レイヤー間でどのファイルが新規追加(A – Added)、変更(M – Modified)、削除(D – Removed)されたかを明確に示してくれます。

爆速でDiveをインストールする方法

Linux (Ubuntu/Debian)では、安定性を考えて.debファイルからインストールするのがおすすめです:

wget https://github.com/wagoodman/dive/releases/download/v0.10.0/dive_0.10.0_linux_amd64.deb
sudo apt install ./dive_0.10.0_linux_amd64.deb

Macユーザーならbrewで手軽にインストールできます:

brew install dive

あるいは、Docker自体を使って直接実行することも可能です(面白い使い方ですね):

docker run --rm -it \
    -v /var/run/docker.sock:/var/run/docker.sock \
    wagoodman/dive:latest <your-image-tag>

実際のイメージを分析する

先ほどの1.5GBのイメージを調査するには、次のコマンドを入力します:

dive my-app:latest

Dive’s インターフェースが開き、2つの直感的なパネルが表示されます:

  • 左側 (Layers): イメージのレイヤー一覧。矢印キーで移動できます。
  • 右側 (Current Layer Contents): 選択したレイヤーにおけるファイルシステムの全構造。

鉄則:右側のパネルの色に注目してください。

  • 黄色: 内容が変更されたファイル。
  • 緑色: このレイヤーで新しく追加されたファイル。
  • 赤色: 削除されたファイル(ただし、前のレイヤーでは容量を占有したまま)。

ケーススタディ:消えた謎の500MBを追え

Diveでeコマースプロジェクトのイメージを調査したところ、初歩的なミスを発見しました。npm installを実行しているレイヤーで、容量が500MBも急増していたのです。右側のパネルを見ると、/root/.npmディレクトリがそのほとんどを占めていました。

結局、古いDockerfileに次のように記述していたのが原因でした:

RUN npm install

このコマンドはデフォルトでnpmのキャッシュをrootのホームディレクトリに保存します。たとえ後で不要なnode_modulesを削除したとしても、そのキャッシュは永遠にそのレイヤーに残ってしまいます。

さらに、DiveはImage Efficiency Score(イメージ効率スコア)も提供してくれます。当時の私のイメージはわずか65%でした。これは、容量の35%が「Wasted Space」(無駄なスペース)であることを意味します。Diveは無駄を生んでいるファイルを正確にリストアップしてくれるので、どこを「手術」すべきかが一目で分かります。

最適化戦略:Dockerイメージを常に軽量に保つために

Diveで原因を特定した後、イメージを軽量化するために以下の3ステップのプロセスを適用しました:

1. マルチステージビルド(Multi-stage Build)の導入

これが最も重要なテクニックです。ビルドと実行の両方に1つのイメージを使うのではなく、2つの段階に分けます。最初の段階ではビルドツールが揃ったイメージを使い、次の段階では実行に必要なファイルだけをAlpineのような超軽量イメージにコピーします。

# ビルドステージ
FROM node:18-alpine 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"]

2. RUNコマンドの集約と即時のクリーンアップ

OSのパッケージをインストールする必要がある場合は、同じRUNコマンド内でインストールとキャッシュの削除を行ってください。そうすることで、削除コマンドが実際にレイヤーのメモリを解放する効果を発揮します。

RUN apt-get update && apt-get install -y \
    git \
    && rm -rf /var/lib/apt/lists/*

3. .dockerignoreを忘れない

.dockerignore.gitignoreと同じくらい重要だと考えてください。.gitディレクトリやローカルのnode_modules、テスト用ドキュメントなどがDockerコンテキストに入り込まないようにしましょう。漏れた1MBは、レイヤーを重ねるごとに何倍にも増幅されてしまいます。

待望の結果

Diveで分析しマルチステージビルドを適用した結果、イメージを1.5GBからわずか120MBまで軽量化することに成功しました。システムは驚くほどスムーズに動き、レジストリからサーバーへのイメージのプル時間は数分から数秒へと短縮されました。

Dockerイメージのサイズを侮ってはいけません。軽量なイメージはクラウドのストレージコストを節約するだけでなく、CI/CDプロセスを高速化し、セキュリティの脆弱性を大幅に減らすことにも繋がります。もし次にイメージが「肥満気味」だと感じたら、すぐにDiveを起動してチェックしてみてくださいね!

Share: