Dockerfileを1行ずつ解剖
ビルドが遅い、イメージが太る、docker stopで止まらない。原因は書き順と書式にある。現実的なDockerfileを1行ずつ精読し、キャッシュ順序からマルチステージ、非root化まで根拠を持って書けるようになる。
- イメージは命令ごとのレイヤーで、変更行以降のキャッシュは無効になる。依存定義だけ先にCOPYしてRUN npm ciを済ませ、ソース全体を後からCOPYする順序がビルド時間を左右する。
- CMDとENTRYPOINTのシェル形式はshがPID 1となり、SIGTERMがアプリへ届かない。docker stopは既定10秒後にSIGKILLするため、JSON配列のexec形式を原則とする。
- EXPOSEは通信を待つポートの宣言にすぎず、実際の公開はdocker runの-p指定で行う。マルチステージビルドでビルド専用の道具を最終イメージから切り離し、USERで非rootユーザーに落とすのが本番イメージの定石だ。
この設定ファイルは何者か
Dockerfileは、コンテナイメージの組み立て手順を上から順に記した設計書だ。docker buildを実行すると、ビルダー(現行の既定はBuildKit)がこのファイルを読み、FROMで指定した土台に命令を1つずつ適用していく。各命令の結果は読み取り専用の「レイヤー」として積み重なり、イメージの実体は、レイヤーの束に起動時の既定値(実行コマンドや環境変数)のメタデータを添えたものだ。
押さえておきたいのは、Dockerfileがビルド時の設定であって、実行時の設定ではない点だ。ここで決められるのは「何を含み、どう起動するイメージか」まで。ポートの割り当てやボリュームは、docker runやComposeといった実行側の領分だ。この線引きが分かると、後述するEXPOSEの誤解も自然に解ける。コンテナそのものの正体に迫るなら作って学ぶが近道だ。
横にスクロール
1行ずつ解剖
題材は、Node.js製Webサーバーをビルドして動かす次のDockerfileだ。
# ---- ビルドステージ ----
FROM node:22-bookworm-slim AS build
WORKDIR /app
# 依存定義だけを先にコピーしてキャッシュを効かせる
COPY package.json package-lock.json ./
RUN npm ci
# ソース本体をコピーしてビルドし、開発用依存を除去
COPY . .
RUN npm run build && npm prune --omit=dev
# ---- 実行ステージ ----
FROM node:22-bookworm-slim
ENV NODE_ENV=production
WORKDIR /app
COPY --from=build --chown=node:node /app/dist ./dist
COPY --from=build --chown=node:node /app/node_modules ./node_modules
COPY --from=build --chown=node:node /app/package.json ./
EXPOSE 3000
USER node
ENTRYPOINT ["node", "dist/server.js"]
以下、命令のグループごとに読み解く。
FROMとマルチステージ——土台をタグとダイジェストで固定する
FROMはベースイメージを指定する。タグを省略するとlatestが補われるが、タグは付け替え可能なラベルにすぎず、同じタグでも取得時期によって中身が変わりうる。再現性を高めるには、バージョンとOS世代まで含むタグ(例では22系とDebian bookwormのslim版)を選び、さらに厳密にはダイジェストで固定する。
# タグとダイジェストを併記した場合はダイジェスト側が優先される
FROM node:22-bookworm-slim@sha256:64桁のダイジェスト値
ダイジェストはイメージの内容から計算される識別子なので、この書き方ならいつビルドしても同一の土台から始まる。タグは人間向けの注記として残る。
サンプルにはFROMが2回登場する。これがマルチステージビルドで、前半のステージにAS buildと名前を付け、後半はまっさらな同じベースからやり直す。出荷されるのは最後のステージだけで、ビルドの道具がどれだけ前半を汚しても最終成果物には影響しない。
RUN——レイヤーとキャッシュを制する
RUNはシェルコマンドを実行し、その結果のファイル変更を新しいレイヤーとして記録する。ビルドキャッシュは命令ごとに判定され、RUNは命令文字列が、COPYは対象ファイルの内容が前回と一致すれば再利用される。重要なのは、ある行のキャッシュが無効になると、それ以降の行がすべて作り直しになることだ。
サンプルの順序はこの性質から逆算している。package.jsonとpackage-lock.jsonだけを先にCOPYしてRUN npm ciを済ませておけば、ソースコードをどれだけ書き換えても依存インストールのレイヤーは再利用され続ける。先にCOPY . .と書いてしまうと、1文字の修正でもnpm ciからやり直しになる。
&&での連結にも理由がある。Debian系でパッケージを追加するなら、1つのRUNへまとめるのが定石だ。
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
apt-get updateを単独のRUNに分けると、その行だけ古いキャッシュが再利用され、後続のinstallが古いパッケージ一覧を参照してしまう。また、レイヤーは追記式なので、前のレイヤーで生まれたファイルを後のRUNで消してもイメージは小さくならない。一時ファイルの掃除は、生んだRUNの中で完結させる。
COPYとADD——.dockerignoreと三点セットで考える
COPYはビルドコンテキスト(docker buildに渡したディレクトリ)からイメージへファイルを写す、それだけの命令だ。ADDはさらにローカルのtarアーカイブの自動展開とURLからの取得もこなすが、多機能なぶん挙動の予測が難しい。公式のベストプラクティスも原則COPYを推し、ADDはtar展開が必要な場面に限るとしている。
| 観点 | COPY | ADD |
|---|---|---|
| コンテキストからのコピー | できる | できる |
| ローカルtarの自動展開 | しない | する |
| URLからの取得 | できない | できる |
| 推奨される場面 | 原則すべて | tar展開が必要な時だけ |
マルチステージビルドではCOPY --from=buildのようにコピー元を他のステージへ切り替えられる。実行ステージが持ち込むのはビルド済みのdistと刈り込んだnode_modulesだけで、開発用依存は前半のステージごと捨てられる。
コピーの入口を絞るのが.dockerignoreだ。コンテキスト直下に置けば、node_modules・.git・.envのような不要あるいは危険なファイルを転送前に除外できる。転送が軽くなるうえ、COPY . .で秘密情報が紛れ込む事故や、無関係なファイルの変更によるキャッシュ破壊も防げる。
EXPOSEとUSER——公開しない宣言と、rootをやめる一行
EXPOSE 3000は「このコンテナは3000番で待ち受ける」という宣言であり、ホスト側では何も起きない。実際の公開はdocker runの-p 8080:3000のような明示指定か、-P(宣言済みポートの一括割り当て)で初めて行われる。
つまりEXPOSEは運用者と周辺ツールへ向けたドキュメントだ。Compose利用時のports設定との対応はdocker-compose.ymlの解剖で扱う。
一方のUSERは実行権限を決める。指定しなければコンテナ内のプロセスはrootで動く。USER node以降はRUNや起動プロセスが非特権ユーザーで実行され、侵入された場合の被害範囲を狭められる。node公式イメージにはnodeユーザーが用意されているが、無いイメージではRUNでユーザーを作ってから切り替える。ファイル所有権はサンプルのようにCOPYの--chownで揃えるのが手早い。
ENTRYPOINTとCMD——起動コマンドとシグナルの行方
両者は起動時のコマンドを分担する。ENTRYPOINTが固定の本体、CMDがその既定引数だ(ENTRYPOINTが無ければCMDが既定コマンドになる)。docker run末尾の引数はCMDだけを置き換えるので、変えない部分をENTRYPOINT、差し替えたい部分をCMDに分けると使い勝手がよい。
もう1つの軸が書式で、JSON配列で書くexec形式と、素の文字列で書くシェル形式がある。
# exec形式:nodeがそのままPID 1になる
ENTRYPOINT ["node", "dist/server.js"]
# シェル形式:/bin/sh -c を介して起動される
CMD node dist/server.js
exec形式では指定したプログラムがPID 1として直接起動し、docker stopが送るSIGTERMを自分で受け取れる。シェル形式ではPID 1が/bin/shになり、shは受け取ったシグナルを子プロセスへ転送しないため、アプリにSIGTERMが届かない。猶予時間(既定10秒)の後はSIGKILLで強制終了され、グレースフルシャットダウンは動かない。本番イメージのENTRYPOINTとCMDはexec形式で書くのが原則だ。
つまずきやすい点
依存インストールより先のCOPY . .。ソースを1行直すだけでキャッシュが崩れ、毎回npm ciが走る。依存定義のCOPY、インストール、ソース全体のCOPYの順へ直すだけでビルド時間は激減する。
latestタグでの運用。今日と来週で違うイメージが落ちてくるため、ビルドの再現も障害時の切り戻しもできない。バージョン付きタグを基本に、必要ならダイジェストまで固定し、更新は差分を確認しながら明示的に行う。
シェル形式CMDとPID 1問題。docker stopのたびに10秒固まってから落ちるコンテナは、まずこれを疑う。exec形式へ改めるか、起動スクリプトを挟む場合はスクリプト末尾のexecで本体プロセスにPID 1を譲る。
RUNの分けすぎによる肥大。生成と削除を別のレイヤーに分けると、消したはずのファイルが下のレイヤーに残り続ける。掃除は生成と同じRUNで完結させる。ただし何でも1行へ繋ぐとキャッシュの粒度が粗くなるので、変更頻度が近い処理どうしで束ねるのが現実解だ。
まとめ
Dockerfileは、レイヤーとキャッシュの都合から逆算して書く設定ファイルだ。順序は「変わりにくいものを上、変わりやすいものを下」、起動は「exec形式が原則」、土台は「タグとダイジェストで固定」、仕上げは「マルチステージとUSERで軽く安全に」。この4点を押さえれば、ビルド時間・イメージサイズ・停止挙動の悩みはあらかた片付く。ビルドをCI/CDへ組み込む先の話はDevOpsから辿ってほしい。
設定ファイル解剖の記事ガイド
Dockerfileを1行ずつ解剖を実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
設定ファイル
比較で見る軸
難易度: advanced / カテゴリ: 設定ファイル解剖 / タグ数: 4
導入後に効く点
CMDとENTRYPOINTのシェル形式はshがPID 1となり、SIGTERMがアプリへ届かない。docker stopは既定10秒後にSIGKILLするため、JSON配列のexec形式を原則とする。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- 設定ファイル解剖
- タグ数
- 4
判断チェックリスト
- 自社の用途が「設定ファイル / Docker」に近いか確認する。
- 強みである「イメージは命令ごとのレイヤーで、変更行以降のキャッシュは無効になる。依存定義だけ先にCOPYしてRUN npm ciを済ませ、ソース全体を後からCOPYする順序がビルド時間を左右する。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。