shibomb

Dockerイメージを1GBから最小化!マルチステージビルドと非root実行

Docker イメージが 1GB を超えて push にも pull にも時間がかかり、中身を見ると gcc や devDependencies、ソースコード一式まで本番に入っている。そんな状況に心当たりはありませんか。原因の多くは、ビルド用の道具と実行用の成果物を同じイメージに詰め込んでいることです。この記事では Docker のマルチステージビルドで両者を分離し、Node.js アプリを例に、イメージを小さく、かつ攻撃されにくくする手順を Dockerfile 付きで順に説明します。

なぜDockerイメージの肥大化と不要ツールの残存が危険なのか

まず転送コストの問題があります。イメージが大きいと、CI での push、本番ノードでの pull、オートスケールで新しいノードが立ち上がるときの待ち時間がすべて延びます。たとえば負荷が急増してコンテナを増やしたい場面で、イメージの取得に数十秒かかれば、その間ユーザーは遅いレスポンスを受け取り続けます。

次にセキュリティです。イメージに含まれるパッケージは、すべて脆弱性スキャンの対象になります。コンパイラ、パッケージマネージャ、curl のような通信ツールが残っていると、スキャン結果に出る指摘が増えます。さらに、万一アプリが侵入された場合に、攻撃者が追加のツールを手元で作れる環境を与えることになります。コンテナ セキュリティの基本は「入れていないものは悪用されない」です。

見落としやすいのが、レイヤーの性質です。Docker のイメージは読み取り専用のレイヤーの積み重ねで、あとの RUN rm -rf で消しても、前のレイヤーにはファイルが残ります。つまり同じステージの中で「ビルドして、最後に掃除する」やり方では容量は減りませんし、うっかり COPY した .env が docker history やレイヤーの展開から読み出せる場合もあります。掃除ではなく、最初から入れないという発想が必要です。

マルチステージビルドの仕組み:ビルド環境と実行環境を完全に分離する

マルチステージビルドは、1 つの Dockerfile に複数の FROM を書く機能です。それぞれの FROM が 1 つのステージになり、COPY --from=ステージ名 で前のステージから必要なファイルだけを取り出せます。最終的にイメージとして残るのは、最後のステージだけです(公式ドキュメントの Multi-stage builds で説明されています)。

具体的には、次のような役割分担になります。

  1. ビルドステージ:コンパイラや devDependencies を含む、重い環境でソースをビルドする
  2. 実行ステージ:軽量なベースイメージに、ビルド結果と本番用の依存だけを置く

ビルドステージは、ビルドキャッシュには残りますが、push されるイメージには含まれません。ですから、ビルド環境の中に何があっても、本番に持ち込まれることはありません。分離は「消す」のではなく「そもそも持っていかない」ことで実現されます。

注意点として、マルチステージにすれば自動で軽くなるわけではありません。最後のステージで COPY --from=build /app . と丸ごとコピーすれば、肥大化したままです。何を渡すのかを自分で選ぶ作業が、Dockerfile の書き方の核心になります。

ハンズオン:Node.jsコンテナを1GB超から100MB未満へスリム化する

TypeScript で書いた Node.js アプリが、npm run build で dist/ に JavaScript を出力する構成を想定します。まず、肥大化しがちな単一ステージの例です。

FROM node:24
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
CMD ["node", "dist/main.js"]

フルイメージの node タグは Debian ベースで、ビルドツール類を含むため、これだけで 1GB 前後になります。そこに devDependencies とソースが乗ります。これを 3 ステージに分けます。

# syntax=docker/dockerfile:1

FROM node:24 AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:24-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev

FROM node:24-alpine
ENV NODE_ENV=production
WORKDIR /app
COPY --from=deps --chown=node:node /app/node_modules ./node_modules
COPY --from=build --chown=node:node /app/dist ./dist
COPY --chown=node:node package.json ./
USER node
EXPOSE 3000
CMD ["node", "dist/main.js"]

deps ステージを別に用意したのは、本番用の依存だけを入れ直すためです。ネイティブモジュールを使う場合は、実行環境と同じ alpine(musl)上でインストールしないと動かないことがあるので、ここは実行ステージと同じベースに揃えています。バージョンは、お使いの LTS に合わせて読み替えてください。

結果は docker image ls で確認します。ここで一点正直に書くと、Node.js のランタイム自体が alpine 版でも数十MB以上あり、ディスク上の表示サイズが 100MB を切るかはアプリの依存量しだいです。レジストリ上の圧縮サイズなら 100MB 未満に収まるケースが多い、という程度に受け止めてください。「100MB未満」という目安は、1GB超から約100MB前後を目指す典型例の話で、保証値ではありません。

セキュリティを固める非rootユーザー実行と.dockerignoreの活用法

上の Dockerfile の USER node が、非 root 実行の指定です。公式の node イメージには node ユーザーが最初から用意されています。root のまま動かすと、アプリに脆弱性があったときにコンテナ内で何でもできてしまうため、Docker の Dockerfile ベストプラクティスでも非 root での実行が勧められています。COPY --chown を付けておけば、実行ユーザーがファイルを読めずに起動で失敗する、という事故も防げます。

もう 1 つが .dockerignore です。ビルドコンテキストに入れたくないファイルを列挙します。

node_modules
dist
.git
.env
*.log
Dockerfile

COPY . . は、ここに書いていないものをすべて送ります。.git の履歴や .env が、ビルドステージに紛れ込む原因はたいてい .dockerignore の書き漏れです。実行ステージには渡らなくても、ビルドキャッシュや CI のログに残る可能性があるので、除外しておくに越したことはありません。ローカルの node_modules を除外する理由は、OS が違うバイナリが混ざるのを避けるためです。

ビルド時に秘密情報(社内 npm レジストリのトークンなど)が必要な場合は、ARG や ENV で渡さず、BuildKit の RUN --mount=type=secret を使います。個人的には、これを知らないまま ARG NPM_TOKEN を使っているケースが、いちばん事故になりやすいと感じています。最後に Trivy などのスキャナで、イメージを定期的に検査する習慣をつけると安心です。

イメージ最適化でビルド時間短縮と堅牢な本番運用を両立させよう

軽量化は容量だけでなく、ビルド時間にも効きます。Dockerfile では、変更頻度の低いものを上に置くのが基本です。package*.json を先にコピーして npm ci を実行しておけば、ソースだけを修正した場合は依存インストールのレイヤーがキャッシュから再利用されます。また BuildKit(Docker Engine 23.0 以降は標準)は、互いに依存しないステージを並列に処理します。先ほどの build と deps がまさにそれで、CI の待ち時間の短縮につながります。

デバッグ用には docker build --target build . が便利です。途中のステージだけを実行できるので、ビルドで失敗したときの調査に使えます。本番イメージにシェルやツールを足したくなったら、そこを我慢して、調査用のステージを別に用意するほうが堅牢です。

ベースイメージは、alpine から始めるのが無難だと思います。さらに削りたければ、シェルすら含まない distroless も選択肢ですが、デバッグの難しさとのトレードオフがあり、チームの慣れしだいで好みが分かれます。まずは alpine で十分でしょう。

手元でいちばん大きいイメージを docker image ls で探し、ビルド用のステージを 1 つ切り出すところから始めてください。それだけで、最初の数百MBは削れるはずです。

関連記事