Dockerイメージが1.2GB→90MBに激減。マルチステージとalpineで軽くするDockerfileの書き方
巨大なDockerイメージを軽くする実践手順。レイヤーの仕組み、マルチステージビルド、alpine/distrolessの選び方、.dockerignore、キャッシュ活用を、僕が90MBまで削った実例で解説。
docker images を叩いたら、自分のNode.jsアプリのイメージが 1.2GB ありました。
中身は数百KBのJavaScriptと、APIサーバーひとつ。なのに1.2GB。スーツケースに着替え3日分を入れたら、なぜか冷蔵庫が一緒に入っていた、みたいな状態です。
CIのビルドは毎回4分待たされ、レジストリへのpushは遅く、デプロイのたびにこの塊が転送される。最初の僕のDockerfileは、たった6行で、見事にこれをやらかしていました。
そこから手を入れて、最終的に 90MB まで落としました。アプリは1行も変えていません。変えたのはDockerfileの書き方だけです。今日はそのときに効いた順番を、コピペで動く形で全部出します。複数コンテナをまとめて起動する話(app + DB + Redis)は別記事にまとめたので、ここでは「1枚のイメージをどう薄く焼くか」に集中します。
この記事の要点
- Dockerイメージはレイヤーの積み重ね。変わりにくいものを下、変わりやすいものを上に置くとキャッシュが効いて爆速になる。
- マルチステージビルドで「ビルド道具」と「実行する中身」を分けると、本番イメージからコンパイラやdevDependenciesが消えてゴッソリ軽くなる。
- ベースイメージは
slim(迷ったらこれ・約220MB)→alpine(約50MB・musl注意)→distroless(シェルなし・最小)の順で検討する。 .dockerignoreは軽量化と秘密情報の事故防止の両方に効く。.envを入れ忘れると鍵がイメージに焼き込まれる。- 仕上げは
docker historyで「どのレイヤーが太いか」を実測してから削る。勘で消さない。
まず「なぜ太るのか」をレイヤーで理解する
Dockerイメージは1枚岩のファイルではありません。Dockerfileの命令1行ごとにレイヤーという薄い層ができて、それが重なってできています。FROM で土台、COPY で1枚、RUN でまた1枚、という具合です。
ここで大事なのが2つ。
ひとつ、レイヤーは足し算でしか増えないこと。前のレイヤーで入れたものを次の RUN で消しても、消す前のレイヤーはイメージの中に残り続けます。引っ越しで一度部屋に運び込んだ家具は、あとで捨てても「運び込んだ記録」は段ボールに残る、みたいなイメージです。だから「インストール → 削除」を別々の行でやると、消したはずのものがイメージを太らせます。
ふたつ、変わっていないレイヤーはキャッシュが使い回されること。これがビルド速度の鍵です。下のレイヤーが1つでも変わると、その上は全部作り直しになります。
この2つを頭に入れるだけで、Dockerfileの書き方がガラッと変わります。
キャッシュが効く順番でCOPYする
いちばん多い失敗が、これです。最初に全部コピーしてしまうパターン。
# アンチパターン:ソースを全部入れてから依存をインストール
FROM node:22-slim
WORKDIR /app
COPY . .
RUN npm ci
CMD ["node", "dist/index.js"]
何が悪いか。COPY . . でソースコードを1文字でも直すと、その下にある RUN npm ci のキャッシュが毎回吹き飛びます。依存パッケージは何も変えていないのに、毎回フルでダウンロードし直す。これでビルドが遅くなります。
正しくは、変わりにくい「依存の定義」を先にコピーして、先にインストールします。
FROM node:22-slim
WORKDIR /app
# package.json と lockfile だけ先にコピー
COPY package.json package-lock.json ./
RUN npm ci
# ソースはそのあと
COPY . .
RUN npm run build
CMD ["node", "dist/index.js"]
package-lock.json は依存を更新したときしか変わりません。だから RUN npm ci のレイヤーはほとんどキャッシュが効いて、ソースを直しただけのビルドは数秒で終わります。「変わらないものほど下(先)に」が合言葉です。
マルチステージビルドで実行に要らないものを捨てる
ここが軽量化の本丸です。
TypeScriptをコンパイルするには typescript や tsc が要ります。でも、ビルドが終わった後の本番イメージにコンパイラは1ミリも要りません。出来上がった dist/ だけ動けばいい。なのに普通に書くと、ビルド道具が全部イメージに残ります。これが太る最大の原因でした。
そこで マルチステージビルド の出番です。FROM を複数書いて「ビルド専用の箱」と「本番で動かす箱」を分け、最後の箱には成果物だけを COPY --from= で引っ張ってきます。ビルド専用の箱はまるごと捨てられます。
下が、その土台になるマルチステージのDockerfileです。src/index.ts を dist/index.js にビルドするNode.js APIを想定しています。そのままコピペで動きます。 この slim 版でまず数百MB単位で落ちて、後述のベース差し替えで最終的に90MBまで持っていきました。
# syntax=docker/dockerfile:1
# --- ステージ1: 依存をインストール(本番用だけ) ---
FROM node:22-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
# 本番依存だけ入れる。devDependencies は入れない
RUN npm ci --omit=dev
# --- ステージ2: ビルド(ここで初めて dev 道具を使う) ---
FROM node:22-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY tsconfig.json ./
COPY src ./src
RUN npm run build
# --- ステージ3: 本番イメージ(成果物だけ受け取る) ---
FROM node:22-slim AS runner
ENV NODE_ENV=production
WORKDIR /app
# 本番依存と、ビルド済み dist だけをコピー
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY package.json ./
# root で動かさない(脆弱性が出たときの被害を小さくする)
RUN groupadd --system --gid 1001 nodejs \
&& useradd --system --uid 1001 --gid nodejs appuser
USER appuser
EXPOSE 3000
CMD ["node", "dist/index.js"]
ポイントを3つだけ。
depsステージは--omit=devで本番依存だけ入れる。typescriptやtsxのような開発用は本番イメージに渡しません。- ビルドは
buildステージに隔離する。ここはコンパイラを使うけど、最後のrunnerにはdist/しか渡さないので、ビルド道具はイメージに残りません。 runnerを非rootユーザーで動かす。これはサイズではなく安全のため。アプリに穴があっても、被害を箱の中の最小権限に閉じ込めます。
これだけで、COPY . . 一発のイメージから数百MB単位で軽くなります。Claude Codeにこのファイルをレビューさせるなら、「本番イメージに devDependencies が混入する経路はないか」「COPY --from で必要な成果物を取りこぼしていないか」を名指しで聞くと、見落としが減ります。
ベースイメージの選び方:slim / alpine / distroless
土台を変えると、それだけでサイズが大きく動きます。Node.jsの公式イメージで比べると、ざっくりこうです(数字は目安)。
| タグ | サイズ感 | C標準ライブラリ | シェル/パッケージ管理 | 向いている場面 |
|---|---|---|---|---|
node:22(無印・debian) | 約950MB | glibc | あり | 中身を全部試したい検証用 |
node:22-slim | 約220MB | glibc | あり(apt) | 迷ったらこれ。本番の標準 |
node:22-alpine | 約50MB | musl | あり(apk) | サイズが要件。musl差に注意 |
distroless(nodejs) | 最小級 | glibc | なし | 攻撃面を最小化したい本番 |
選び方の現実的な順番はこうです。
まず slim。 Docker公式も「最小のベースイメージを選べ」と言っていますが、いきなり尖らせる必要はありません。slim はDebianベースで glibc と apt が使えて、サイズも十分小さい。多くのアプリはここで止めて問題ないです。
サイズが厳しいなら alpine。 約50MBまで落ちます。ただし注意がひとつ。AlpineはC標準ライブラリに glibc ではなく musl を使っています。これが原因で、ネイティブモジュール(C++で書かれた依存)がソースからのコンパイルにフォールバックしてビルドがかえって遅くなることや、まれに動作が変わることがあります。「全部テストして、サイズが本当に要件のとき」に寄せるのがおすすめです。
攻撃面を極限まで削るなら distroless。 Googleが配っている、シェルもパッケージ管理も入っていない最小イメージです。シェルがないので、外から侵入してもコマンドを叩けない。セキュリティスキャンの結果が問われる本番に向きます。ただしシェルがない=本番コンテナに入って調査できないということ。ログとトレースが整っていないと、障害時に詰みます。「逃げ道を消すのが目的で、それが同時にリスクでもある」ものだと理解して使ってください。
distroless版の runner ステージはこう書き換えます。
# 最終ステージだけ distroless に差し替える例
# :nonroot タグで uid 65532 の非rootユーザーとして起動する
FROM gcr.io/distroless/nodejs22-debian12:nonroot AS runner
ENV NODE_ENV=production
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
# ENTRYPOINT が node 固定なので CMD はスクリプトのパスだけ(シェルがないため)
EXPOSE 3000
CMD ["dist/index.js"]
distrolessはエントリポイントが node に設定済みなので、CMD にはスクリプトのパスだけを渡します。sh -c 形式は使えません(シェルがないから)。ここを CMD ["node", "..."] のままにすると動かないので注意です。
.dockerignore は軽量化と「鍵漏れ防止」の両方に効く
.dockerignore は地味ですが、入れない理由がないファイルです。役割は2つ。
ひとつはビルドに送るファイルを減らすこと。COPY . . をすると、カレントディレクトリの中身がまるごとDockerに送られます。node_modules や .git が混ざると、転送もキャッシュ判定も遅くなります。
もうひとつが本当に大事で、秘密情報の焼き込み事故を防ぐこと。.env を除外し忘れると、APIキーやDBパスワードがイメージのレイヤーに残ります。一度焼き込まれた鍵は、あとで .env を消してもレイヤー履歴から取り出せてしまう。レジストリに上げた瞬間に詰みます。鍵やトークンの扱い全般はClaude Codeセキュリティの勘所にまとめたので、本番運用の前に一度目を通しておくと安心です。
最低限これを置いてください。
.git
node_modules
dist
coverage
.env
.env.*
!.env.example
npm-debug.log*
Dockerfile*
docker-compose*.yml
.dockerignore
.env.* で環境別ファイルもまとめて除外し、!.env.example でサンプルだけ通す、という書き方が安全です。
「どこが太いか」を実測してから削る
軽量化で一番やってはいけないのが、勘で消すこと。「たぶんこれ要らない」で消すと、動かなくなって時間を溶かします。
イメージのどのレイヤーが太いかは、docker history で一発で見えます。
# ビルドする
docker build -t myapp:slim .
# レイヤーごとのサイズを大きい順で確認する
docker history myapp:slim --human --format "table {{.Size}}\t{{.CreatedBy}}"
# 最終的なイメージサイズを確認する
docker images myapp:slim
docker history を見ると、「npm ci のレイヤーが200MB食ってる」みたいに犯人が分かります。そこで初めて、「本番依存だけにする」「ベースを alpine に寄せる」といった対策を、効くと分かったうえで打てます。
僕が1.2GBから削ったときも、最初に効いたのは小細工ではなく、マルチステージで devDependencies を本番から外したことでした。docker history で太いレイヤーから順に潰す。これがいちばん速い道でした。
ちなみにビルドキャッシュが溜まりすぎてディスクを圧迫したら、docker builder prune で掃除できます。CIで --no-cache を付けたいのは、依存を最新で取り直したいときだけ。普段はキャッシュを効かせたほうが速いです。
よくある質問
Q. alpineにすれば一番軽いし速いんですよね?
A. 軽いのは本当ですが、速いとは限りません。Alpineは musl を使うので、ネイティブモジュールがソースからコンパイルされてビルドが遅くなることがあります。アプリの実行速度も基本変わりません。まず slim で組んで、サイズが要件になったら alpine を検証する順番が安全です。
Q. マルチステージにすると逆にDockerfileが長くなって面倒では? A. 行数は増えますが、本番イメージからビルド道具が消えるメリットのほうが大きいです。サイズが小さければpushもデプロイも速く、攻撃面も減ります。長さより「最後の箱に何が残るか」で判断してください。
Q. RUNを1行にまとめろと言われるのはなぜ?
A. レイヤーは足し算でしか増えないからです。apt-get install して別の行で apt-get clean しても、インストール分のレイヤーは残って太ります。RUN apt-get update && apt-get install -y x && rm -rf /var/lib/apt/lists/* のように、入れて消すまでを1つの RUN に収めると、消した分が本当に消えます。
Q. distrolessは本番で使って大丈夫?
A. セキュリティ面では強い選択です。ただしシェルがないので、障害時にコンテナへ入っての調査ができません。ログ・メトリクス・トレースが整っているチームなら有力。まだ手探りの段階なら、slim で運用に慣れてから移るのをおすすめします。
Q. 複数のコンテナ(DBやRedis)をまとめて起動したい場合は?
A. それはDockerfile単体ではなくComposeの領域です。depends_on と healthcheck で起動順や接続待ちを組む話はdocker-compose開発環境の設計にまとめました。本記事の runner ステージで焼いたイメージを、そのままComposeから使えます。
実際に試した結果
1.2GBを90MBまで削ってみて、いちばん効いたのは順番でした。
最初に docker history で太いレイヤーを見て、犯人が「ビルド道具と devDependencies」だと分かった。次にマルチステージで本番イメージから道具を全部外した。これで一気に大半が落ちました。ベースを alpine に寄せたのは最後の仕上げで、削れた量でいえばマルチステージのほうがずっと大きかったです。
逆に、最初から alpine や distroless に飛びついていたら、musl のビルド問題やシェルなしの調査不能でハマって、もっと時間を食っていたはずです。まず slim とマルチステージで土台を固め、docker history で実測しながら削る。 これが遠回りに見えていちばん速い、というのが今の結論です。
イメージ作りに慣れたら、CIで自動ビルドして壊れたら止める運用に進むと安定します。手順をテンプレ化したい人はClaudeCodeLabのプロダクト集を、チームのリポジトリに合わせてDockerfileやレビュー観点まで決めたい人は研修・相談を覗いてみてください。公式の最新仕様は必ずDocker build best practicesで確認を。土台が変わっても、薄く焼く考え方は同じです。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
制作会社がClaude Codeに触らせる前に決める権限チェックリスト
クライアントサイトを壊さずにAI編集を使うための、制作会社向け権限と確認の型です。
SaaSサポートのバグ報告をClaude Codeで再現手順に変える実務フロー
問い合わせ文をそのまま開発へ投げず、再現手順、証拠、次の一手に整えるサポート向け手順です。
Obsidianの古いメモをClaude Codeの指示書に変える10分ルーチン
Obsidianに溜めたメモが毎回ゴミになる人へ。事実・決定・未確認に仕分けして、Claude Codeがそのまま動ける指示書に変える朝の10分の型を紹介します。