Dockerfileの基本命令とベストプラクティス

DockerfileのFROM、COPY、RUN、CMD、ENTRYPOINTなど主要命令の使い方に加え、レイヤーキャッシュの仕組み、マルチステージビルドによる軽量化、非rootユーザーでの実行など実践的な最適化手法を解説します。

Dockerfileは、Dockerイメージを自動的に構築するためのテキストファイルです。Dockerfileに記述された命令は、上から順に実行され、最終的にDockerイメージが作成されます。ここでは、Dockerfileでよく使用される主要な命令について解説したうえで、その命令の並べ方がビルド速度やイメージサイズにどう影響するか——レイヤーキャッシュの仕組み、マルチステージビルド、非rootユーザーでの実行——という、実務でDockerfileを書く際に避けて通れない最適化のポイントまで踏み込みます。

FROM

ベースとなるイメージを指定します。Dockerfileの最初の命令でなければなりません。

FROM <イメージ名>[:<タグ>]
# 例: FROM ubuntu:22.04
# 例: FROM python:3.9-slim-buster

LABEL

イメージのメタデータを指定します。キーと値のペアで記述し、イメージに関する情報(作成者、バージョン、説明など)を付与できます。

LABEL <キー>="<値>" [<キー>="<値>" ...]
# 例: LABEL maintainer="Your Name <your.email@example.com>"
# 例: LABEL version="1.0" description="My custom web app image"

USER

以降の RUN, CMD, ENTRYPOINT 命令が実行されるユーザーを指定します。デフォルトは root です。セキュリティのため、特権を必要としない操作では非rootユーザーを指定することが推奨されます。

USER <ユーザー名>[:<グループ名>]
# 例: USER appuser

WORKDIR

以降の RUN, CMD, ENTRYPOINT, COPY, ADD 命令が実行される作業ディレクトリを指定します。相対パスで指定した場合、前の WORKDIR 命令からの相対パスとなります。

WORKDIR /path/to/workdir
# 例: WORKDIR /app

EXPOSE

コンテナがリッスンするポートを指定します。これはドキュメント目的であり、実際にポートを公開するには docker run -p オプションが必要です。

EXPOSE <ポート番号> [<ポート番号>/<プロトコル> ...]
# 例: EXPOSE 80
# 例: EXPOSE 8080/tcp 8080/udp

COPY

ホストマシン(Dockerデーモンが動作しているマシン)のファイルやディレクトリを、コンテナイメージ内にコピーします。

COPY <ホストのパス> <コンテナのパス>
# 例: COPY ./app /app
# 例: COPY requirements.txt /tmp/

ADD

COPY と似ていますが、次の追加機能があります。

  • ホストのパス がURLの場合、ファイルをダウンロードしてコンテナに配置します。
  • ホストのパス が圧縮ファイル(tar, gzip, bzip2など)の場合、コンテナ内で自動的に展開します。
ADD <ホストのパスまたはURL> <コンテナのパス>
# 例: ADD https://example.com/app.tar.gz /app/

通常は COPY の方が推奨されます。ADD の追加機能が必要な場合にのみ使用します。

RUN

イメージのビルド時にコンテナ内でコマンドを実行します。これにより、イメージにソフトウェアをインストールしたり、ファイルをセットアップしたりできます。

RUN <コマンド>
# 例: RUN apt-get update && apt-get install -y nginx
# 例: RUN pip install -r requirements.txt

複数のコマンドを && でつなぎ、1つの RUN 命令にまとめることで、イメージのレイヤー数を減らし、ビルドキャッシュを効率的に利用できます。

CMD

コンテナが起動したときに実行されるデフォルトのコマンドを指定します。Dockerfile内で1つしか指定できません。複数ある場合は最後のものが有効になります。

CMD ["実行可能ファイル", "引数1", "引数2"] # exec形式 (推奨)
# 例: CMD ["nginx", "-g", "daemon off;"]
# 例: CMD ["python", "app.py"]

CMD command param1 param2 # shell形式
# 例: CMD python app.py

shell 形式の場合、コマンドは /bin/sh -c で実行されます。

ENTRYPOINT

コンテナが起動したときに実行されるコマンドを指定します。CMD とは異なり、ENTRYPOINT は常に実行され、CMDENTRYPOINT の引数として扱われます。

ENTRYPOINT ["実行可能ファイル", "引数1"] # exec形式 (推奨)
# 例: ENTRYPOINT ["/usr/sbin/nginx"]

ENTRYPOINT command param1 param2 # shell形式
# 例: ENTRYPOINT nginx -g "daemon off;"

ENTRYPOINTCMD を組み合わせることで、柔軟なコンテナ起動コマンドを定義できます。 例えば、ENTRYPOINT ["nginx"]CMD ["-g", "daemon off;"] の場合、コンテナ起動時には nginx -g "daemon off;" が実行されます。docker run コマンドで引数を指定すると、CMD の内容が上書きされます。

ここまでは各命令の基本的な使い方を見てきました。以降では、これらの命令を実際にDockerfileとして書くときに押さえておくべき、より実践的な最適化のポイントを解説します。

検証環境についての注記: 本記事の執筆環境にはdockerコマンドが存在せず(docker --versionはコマンド不明として失敗します)、Dockerデーモンを起動して実際にイメージをビルドすることができませんでした。そのため、以降のビルド挙動の説明は実測ログではなく、 Dockerfileリファレンス および ビルドキャッシュに関する公式ドキュメント に記載された仕様に基づく解説です。実測ではなく仕様に基づく解説である箇所には、その都度明記します。

レイヤーキャッシュの仕組み

Dockerfileの各命令は、実行されるたびにイメージに新しいレイヤーを1つ追加します。レイヤーは読み取り専用のファイルシステム差分で、命令を上から順に適用しながら積み重なっていき、最終的に全レイヤーを重ね合わせたものが1つのDockerイメージになります。

キャッシュキーとその無効化条件

docker buildは各命令を実行する前に、「同じ入力に対してこの命令を過去に実行したことがあるか」をレイヤーごとに確認し、あれば実行をスキップして既存のレイヤーをそのまま再利用します。これがビルドキャッシュです。キャッシュが有効かどうかを判定する基準は命令の種類によって異なります。

  • RUNのようにコマンド文字列を引数に取る命令は、命令の文字列そのもの(コマンドとその引数)が前回のビルドと完全に一致するかどうかで判定されます。
  • COPYADDのようにホスト側のファイルをイメージに取り込む命令は、コマンド文字列だけでなく、コピー対象ファイルの内容とメタデータのチェックサムも比較されます。1バイトでも内容が変更されていれば、そのファイルを参照するCOPY/ADD命令のキャッシュは無効になります。

そして最も重要な性質が、あるレイヤーのキャッシュが無効になると、Dockerfile内でそれ以降にある全レイヤーのキャッシュも連鎖的に無効になるという点です。BuildKitはレイヤーをDockerfileの命令順に依存関係を持つチェーンとして扱うため、途中のレイヤーへの入力が変わると、そこから先は「入力が前回と同じかどうか」を判定する前提そのものが崩れ、無条件に再実行されます。再実行の結果が前回と全く同じ内容になる場合でも、実際にコマンドを走らせて確認するまでは同一だと保証できないため、スキップはできません。

だから「依存関係のインストールを先に、アプリコードは後に」が定石になる

この性質から導かれる実務上の帰結が、package.json(やrequirements.txtgo.modなど依存関係の定義ファイル)をCOPYしてから依存関係をインストールし、その後にアプリケーションコード全体をCOPYするという順序です。

# 最適化された順序
FROM node:20-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm install
COPY . .
CMD ["node", "server.js"]

アプリケーションコードは依存関係定義ファイルよりもずっと高い頻度で変更されます。この順序であれば、package.jsonが変わらない限りCOPY package.json ...RUN npm installのレイヤーはキャッシュから再利用され、ネットワークI/Oとディスク書き込みを伴う比較的重い処理であるnpm installを毎回のビルドで避けられます。逆に次のような素朴な順序では、この最適化が効きません。

# 素朴な順序(最適化なし)
FROM node:20-slim
WORKDIR /app
COPY . .
RUN npm install
CMD ["node", "server.js"]

COPY . .がアプリケーションコード全体(package.jsonを含む)を一括でコピーするため、ソースコードの1行でも変更すればCOPY . .のキャッシュが無効になり、その直後にあるRUN npm installも連鎖的に再実行されます。依存関係が1つも変わっていなくても、です。

検証:命令の順序でキャッシュ効率はどう変わるか

上記2つのDockerfile(素朴な順序 / 最適化した順序)に対して、app.jsの中身だけを1行変更してから再度docker buildした場合に何が起こるかを、命令単位で追ってみます(前述の注記の通り、本環境ではDockerが利用できないため、以下は実測ではなく公式ドキュメントの仕様に基づく手順のトレースです)。

Dockerfileの命令順序とレイヤーキャッシュの伝播を示す図。左側は素朴な順序(FROM→COPY . .→RUN npm install→CMD)でapp.jsのみを変更した場合、COPY . .以降の全レイヤーがキャッシュミスとなり再実行される。右側は最適化した順序(FROM→COPY package*.json→RUN npm install→COPY . .→CMD)で同じ変更を行った場合、package*.jsonが不変なためFROMからRUN npm installまではキャッシュヒットし、COPY . .以降のみが再実行される

命令素朴な順序最適化した順序
FROM node:20-slimキャッシュ再利用(ベースイメージは不変)キャッシュ再利用
COPY package*.json ./(この命令自体が存在しない)キャッシュ再利用(package.jsonは無変更)
RUN npm installキャッシュミスCOPY . .が無効化されたため連鎖的に再実行)キャッシュ再利用(入力のpackage*.jsonが無変更)
COPY . .キャッシュミス(app.jsのチェックサムが変化)キャッシュミス(同左)
CMD [...]キャッシュミス(先行レイヤーが無効化されたため)キャッシュミス(同左。ただしCMDはメタデータ命令でありレイヤー自体の生成はほぼ一瞬)

最適化した順序では、依存パッケージのインストールという最も時間のかかる処理をスキップできるのに対し、素朴な順序では毎回npm installが走ります。依存パッケージの数が多いプロジェクトやCIパイプラインでは、これがビルド1回あたり数十秒〜数分の差になり得ますが、実際の短縮時間はネットワーク速度・パッケージ数・レジストリ側のキャッシュ状態に依存するため、ここでは一般化した秒数を断定しません。

Dockerfile構文の妥当性について

Docker実行環境がないため、上記2つのDockerfileを実際にビルドして検証することはできませんでしたが、 Dockerfileリファレンス に記載された各命令の構文(引数の取り方、命令の並び順の制約)と照らし合わせて目視で確認しており、構文上の誤りはありません。BuildKitが使える環境であれば、後述するdocker build --checkでこれを機械的に検証できます。

マルチステージビルドでイメージを軽量化する

RUN apt-get install -y build-essentialのように、ビルド時にしか使わないツールチェーンを1つのイメージに全て詰め込むと、実行時には不要なコンパイラやヘッダファイルまでイメージに残り続け、イメージサイズが肥大化します。マルチステージビルドは、この問題をDockerfile内に複数のFROMを書くことで解決します。各FROMは独立した新しいビルドステージを開始し、COPY --from=<ステージ名>で前のステージの成果物だけを次のステージに持ち越せます。

シングルステージ版

FROM golang:1.25
WORKDIR /src
COPY . .
RUN go build -o /bin/hello .
CMD ["/bin/hello"]

このイメージには、実行に不要なGoツールチェーン一式(コンパイラ、標準ライブラリのソース、モジュールキャッシュなど)がそのまま残ります。

マルチステージ版

# ビルドステージ: フルツールチェーンを含む
FROM golang:1.25 AS build
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -o /bin/hello .

# ランタイムステージ: 実行に必要な成果物だけをコピー
FROM scratch
COPY --from=build /bin/hello /bin/hello
CMD ["/bin/hello"]

AS buildでビルドステージに名前を付け、COPY --from=buildでそのステージから静的バイナリ1つだけをコピーしています。最終イメージにはscratch(完全に空のベースイメージ)とバイナリしか含まれず、Goのツールチェーンやソースコードはビルドステージに残ったまま最終イメージには一切含まれません。この構文は Docker公式ドキュメントのマルチステージビルドの解説 で示されている構成に沿っています。

イメージサイズの目安(本記事での実測ではなく、公開ベンチマークからの引用)

Dockerが利用できないため、本記事ではこの2つのイメージを実際にビルドしてサイズを計測していません。以下の数値は本記事のために測定したものではなく、第三者が公開しているベンチマーク記事からの引用であることを明示します。

  • golang公式イメージ(ビルドツールチェーン一式を含む)はタグによって800MB前後〜1GB超という報告があります( Docker Multi-Stage Builds for Go: From 1GB to 12MB Images )。
  • 同じアプリケーションをscratchベースのランタイムステージへ切り出すと、最終イメージは静的バイナリの実サイズ(小規模なCLIツールで10〜40MB程度)まで縮小できると報告されています(同上、および Simple Golang Dockerfile with multi staged builds )。

数値は使用するベースイメージのタグやアプリケーションの依存ライブラリによって大きく変動するため、自分のプロジェクトで効果を確認したい場合はdocker imagesでシングルステージ版とマルチステージ版のサイズを直接比較することを推奨します。

docker build -t hello-single -f Dockerfile.single .
docker build -t hello-multi -f Dockerfile.multi .
docker images | grep hello

Node.jsやPythonのようなインタプリタ言語でも考え方は同じです。TypeScriptのビルド専用ステージでtscやdevDependenciesを使い、実行用ステージには最小構成のベースイメージとビルド成果物・本番用の依存パッケージだけをコピーします。

FROM node:20 AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:20-slim AS runtime
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
COPY package.json ./
CMD ["node", "dist/server.js"]

node:20(フル版。devDependenciesのビルドに必要なネイティブモジュールのコンパイルツール等を含む)でビルドし、実行はnode:20-slim(Debianベースの最小構成)に成果物だけを渡すことで、最終イメージにはTypeScriptコンパイラやdevDependenciesが含まれません。

セキュリティ: rootで実行しないという原則

USER命令の節で触れた通り、Dockerコンテナ内のプロセスはUSER命令を指定しない限りデフォルトでroot(UID 0)として実行されます。これは単なるお作法の問題ではなく、実際の攻撃対象領域(アタックサーフェス)に直結します。

何が問題なのか

コンテナ内のrootは、--userns-remapなどユーザー名前空間の分離を使わない標準構成では、ホストOSのrootと同一のUID 0を共有します。この前提のもとでは、次のようなリスクがあります。

  1. コンテナエスケープの被害拡大: カーネルの脆弱性やDockerランタイム自体のバグを突いてコンテナからホストへ「脱出」する攻撃が成立した場合、コンテナ内でroot権限を持っていれば、ホスト上でもroot相当の操作ができてしまう可能性が高くなります。非rootユーザーで実行していれば、同じ脆弱性が突かれても被害を権限の範囲内に限定できる可能性が上がります。
  2. アプリケーションの脆弱性の影響拡大: アプリケーション自体に任意コード実行の脆弱性があった場合(例: 依存パッケージのサプライチェーン攻撃)、rootで動いていればコンテナ内のファイルシステム全体・全プロセスに対する制御を奪われます。非rootであれば、そのユーザーの権限で読み書きできる範囲に被害を限定できます。
  3. ボリュームマウントを介した権限昇格: ホストのディレクトリをbind mountしている場合、コンテナ内のrootが書き込んだファイルは、ホスト側から見てもroot所有のファイルとして残ります。意図せずホスト側の設定ファイルなどを書き換えられるリスクがあります。

before / after

# before: USERを指定しない → rootのまま実行される
FROM node:20-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm install
COPY . .
CMD ["node", "server.js"]
# after: 非rootユーザーを作成して切り替える
FROM node:20-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm install
COPY . .
# システムユーザーを作成し、アプリのファイルの所有者を切り替える
RUN groupadd --gid 1001 appgroup \
  && useradd --uid 1001 --gid appgroup --shell /bin/false --create-home appuser \
  && chown -R appuser:appgroup /app
USER appuser
CMD ["node", "server.js"]

node公式イメージには実はnodeという汎用の非rootユーザーがあらかじめ用意されており、多くの場合USER nodeと1行書くだけで済みます。汎用の非特権ユーザーが提供されているベースイメージでは、それを積極的に活用するのが最も手軽です。独自にユーザーを作る場合は、UID/GIDを固定値(例: 1001)で明示しておくと、ホスト側のbind mountとの権限の対応関係を予測しやすくなります。

USER命令を追加すると、EXPOSEで1024番未満の特権ポート(例: 80番)を直接listenしようとした場合、非rootユーザーでは権限エラーになる点にも注意が必要です。その場合は8080番のような非特権ポートでlistenし、コンテナ外側からのポートマッピング(docker run -p 80:8080)で解決するのが一般的です。

2024〜2025年の変更: docker build –check

BuildKitは継続的に開発されており、2024年以降もいくつか実務に関わる変更がありました。ここでは特に構文検証に関わるdocker build --checkを紹介します( Docker公式ブログでの発表 は2024年7月、Docker Desktop 4.33での提供開始)。

docker build --check .

--checkを付けると、実際にイメージをビルドせずに、Dockerfileが ビルドチェックのルール一覧 (ステージ名の大文字小文字の一貫性、非推奨命令の使用、ENTRYPOINT/CMDのexec形式推奨、.dockerignoreで除外されているはずのファイルへの参照など、公開されているだけで21種類のルール)に違反していないかを1秒未満で検証します。デフォルトでは警告があってもビルド自体は失敗しませんが、Dockerfile先頭に# check=error=trueディレクティブを追加すると、CI上で警告を検出した際に非ゼロの終了コードでビルドを失敗させられます。

本記事のDockerfile例についても、この仕組みで機械的に検証するのが理想的でした。しかし本記事の執筆環境にはDockerが存在しないため、docker build --check自体を実行することはできていません。構文の妥当性は前述の通り、公式リファレンスとの目視での突き合わせにとどまっている点を明記します。

このほか、2024年後半にはdocker buildx historyコマンド(過去のビルドの詳細な検査・再実行を可能にするサブコマンド)が追加され、CI/CDパイプラインにおけるビルドの可観測性が向上しています。

参考


関連ツール