青色に照らされたサーバーブレードが並ぶデータセンター
現場の実践

Dockerイメージが2GBに肥大化する原因と移行コストへの影響

目次を見る

オンプレミスからクラウドへのコンテナ移行を進めているなら、Dockerイメージのサイズは想像以上にコストと障害リスクに直結します。

レジストリ(コンテナイメージを保管・配布する場所。AWSならECR、GCPならArtifact Registryなど)の転送課金やPull(イメージ取得)のタイムアウトは、イメージが肥大化しているほど悪化します。本番環境でのスケールアウトやデプロイが遅いと感じているインフラ担当者、SREの方に向けて、原因の切り分け方と対策を整理します。

何が起きるか:2GBイメージが引き起こす運用トラブル

Node.jsアプリケーションのDockerイメージが2GBを超えると、複数の現場で同時多発的に問題が表面化します。

まず本番環境へのPullがタイムアウトします。オートスケーリングでPod(Kubernetesの実行単位)が新規追加されるたびに、ノードにキャッシュがなければ2GB分のレイヤーをネットワーク越しに毎回引っ張ることになります。

次にレジストリのコスト増加です。AWSのECRやGCPのArtifact Registryは保存容量とデータ転送量に応じて課金されるため、イメージサイズが膨らむほど月々の請求が積み上がります。

さらにセキュリティスキャンでの検出項目も増えます。イメージに含まれるパッケージが多いほど、CVE(既知の脆弱性情報)がヒットする確率が上がり、攻撃対象領域(アタックサーフェス)も広がります。

なぜ起きるか:コンテナをVMのように扱っている

根本原因は、コンテナを仮想マシン(VM)と同じ感覚で扱っていることにあります。

典型的な肥大化したNode.jsイメージには、Debianベースのnodeベースイメージ(それ単体で1GB超)、開発用依存パッケージまで含んだnode_modules(webpack、jest、eslintなど本番に不要なもの)、.gitを含むソースツリー全体、そしてネイティブモジュールのビルドのためだけに追加されたコンパイラツールチェーンが含まれます。

アプリケーションコード自体はせいぜい10MBです。残りはすべて「荷物」であり、本番環境まで運ぶ必要のないものです。

この荷物は単にディスクを圧迫するだけではありません。出荷するパッケージの数だけ攻撃対象領域が広がり、ツールチェーン内のCVEはセキュリティスキャンの検出項目として積み上がります。加えて、キャッシュのないノードはこの全量をネットワーク越しに毎回取得することになり、デプロイの体感速度にも直結します。

自分のプロジェクトが該当するか確認する方法

対策に入る前に、まず現状を数値で把握します。

# イメージサイズを確認(圧縮前・ローカルでのサイズ)
docker images | grep <イメージ名>

# どの命令がどれだけのサイズを作っているかをレイヤー単位で確認
docker history <イメージ名>

docker historyで見て、数百MB単位の塊を作っているレイヤーがあれば、そこが肥大化の犯人です。より詳しく掘り下げたい場合はdiveというオープンソースツールを使うと、レイヤーごとの中身をGUIに近い感覚で確認できます。

併せてDockerfileを開いて、次の点を確認してください。

  • FROMに指定しているベースイメージがDebianベースかAlpineベースか(node:22かnode:22-alpineか)
  • マルチステージビルド(複数のFROMを使い、最終イメージには一部の成果物だけをコピーする手法)を使っているか
  • COPY . .の前に.dockerignoreがあるか、node_modulesや.gitを除外しているか
  • npm installではなくnpm ciを使っているか(ロックファイル通りに厳密インストールする違い)

これらが揃っていなければ、ほぼ確実に不要な荷物を本番イメージに含めています。

対策の手順:マルチステージビルドで荷物を切り離す

マルチステージビルドの考え方はシンプルです。フルツールチェーンを持つステージでビルドし、完成した成果物だけを最小限のランタイムステージにコピーします。ビルドステージ自体は破棄され、最終ステージだけが出荷対象になります。

よくある失敗は依存関係の扱いです。ビルドステージではnpm ci --omit=devを使えません。TypeScriptやバンドラーなど、ビルドには開発用依存パッケージが必要だからです。一方で、ビルドステージのnode_modulesをそのまま本番に持ち込んでもいけません。そこには不要な開発用パッケージが混入しています。

実務で機能する構成は3ステージに分けます。

# syntax=docker/dockerfile:1
# Stage 1: フルツールチェーンでビルド
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# Stage 2: 本番用依存パッケージのみ
FROM node:22-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev

# Stage 3: 最小ランタイム
FROM node:22-alpine AS runtime
WORKDIR /app
RUN addgroup -S app && adduser -S app -G app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY package.json ./
USER app
EXPOSE 3000
CMD ["node", "dist/server.js"]

いくつか補足します。COPY package*.json ./をCOPY . .より先に書くのは、依存関係のレイヤーをキャッシュさせるためです。ロックファイルが変わらない限り、ソースコードを編集しても依存関係の再インストールが走りません。

USER appで非rootユーザーを指定するのも重要です。rootで動くコンテナは、セキュリティレビューで最初に指摘される項目の一つです。わずか数行で回避できます。

ベースイメージのバージョン固定にも注意が必要です。node:22-alpineはnode:latestより安全ですが、タグ自体は可変(ミュータブル)です。パッチバージョンが新しくなれば中身が変わります。本当に不変にしたいならnode@sha256:...のようにダイジェスト指定が必要です。

最後に.dockerignoreでnode_modules、.git、テストコードを除外しておくと、ビルドコンテキスト(Dockerビルド時に送られるファイル群)が小さくなり、意図しないファイルがレイヤーに混入するのを防げます。

サイズ削減効果の目安と誤解しやすい点

数値を見る際に注意したいのは、計測条件がそろっているかです。ローカルのdocker imagesで見えるサイズは非圧縮、レジストリに保存される際は圧縮されるため、同じイメージでも見かけ上2〜3倍ほど差が出ます。他の記事と数値を比較する際、圧縮後と非圧縮の数値を混同すると「50MBになった」という誇張が生まれがちです。

構成非圧縮サイズの目安
フルnode:22ベース、開発依存込み、ソースツリー込み約2GB
マルチステージ+node:22-alpineランタイム150〜250MB
マルチステージ+distrolessランタイム同程度、攻撃対象領域はさらに縮小

distroless(Googleが提供する、シェルやパッケージマネージャーを含まない最小イメージ)はサイズ自体はAlpineと大差ない場合がありますが、CVE検出対象になりうるバイナリが少ない分、スキャン結果はよりクリーンになる傾向があります。クラウド移行の過程でセキュリティ監査が入るプロジェクトでは検討する価値があります。

まとめ:移行前にイメージの中身を棚卸しする

オンプレからクラウドへの移行では、アプリケーションの動作確認に意識が向きがちですが、イメージサイズの棚卸しも同じくらい運用コストに効いてきます。

まずはdocker historyとdiveで現在のイメージの中身を可視化し、どのレイヤーが肥大化の原因かを特定してください。そのうえでマルチステージビルドに切り替え、ビルド専用ステージと本番ランタイムステージを分離します。

npm ciの利用、.dockerignoreの整備、非rootユーザーの設定、ベースイメージのダイジェスト固定は、いずれも数行の変更で済みながら、レジストリコストとセキュリティスキャンの両方に効果があります。クラウド移行のタイミングは、こうした積み重なった「荷物」を見直す良い機会になります。

参考

How to Shrink a 2GB Docker Image with Multi-Stage Builds

この記事について: 本記事は AI を活用して作成し、forva AI 編集部が内容を確認・監修しています。

AI 駆動開発のご相談は forva AI へ。まずはお気軽にどうぞ。