「そのDockerfile、卒業しよう」実務で通用するベストプラクティス
概要
どすこいです!
この記事では、Dockerfileを実務で扱う際に知っておくと大きく効率が上がる設計ガイドを書きました!
Dockerそのものの仕組みには深入りせず、実際にDockerfileを書く場面でつまずきやすい部分だけを解説します!
なお、扱う例はGoを想定しています。
この記事で行わないこと
- Dockerの基礎
- ネットワーク、ボリューム、Docker Engineの詳細解説
なお、Dockerそのものについて知りたい方は以下のサイトがおすすめです!
対象読者
- 業務でDockerfileを0から書く機会を得たエンジニア
- 学習中で、Dockerfileのベストプラクティスについて知りたい方
この記事で伝えたいこと
- Dockerfileを最適化する際に何を判断基準にすべきかについて知る
解決したい課題
初心者が書くDockerfileには次のような課題が発生しやすいです。
- イメージが大きく、デプロイ時間が無駄にかかる
- キャッシュを活かせず毎回フルビルドになる
- 開発用と本番用の設定が混ざって扱いにくい
- ルートユーザで動いてしまい安全でない
- ENTRYPOINTとCMDの書き方の違いを理解していない
- Dockerfileの可読性と統一性が崩れがち
FROM golang:latest
WORKDIR /app
COPY . .
RUN go mod download
RUN go build -o server
RUN apt-get update
RUN apt-get install -y git
CMD ["./server"]
imageのsize
❯ docker image ls | grep 'myapp'
myapp bad dff1f8bf2073 916MB
ベストプラクティス
それでは、各項目に対してダメな例と良い例を提示しながら改善点を解説します。
マルチステージビルドを使う
マルチステージビルドとは、ビルドに必要な環境と実行に必要な環境を分離することです。
特にGoのようなコンパイル言語では効果が大きく、イメージサイズ、安全性、ビルド速度のすべてに効果があります。
🙅♂️Bad
ビルド環境と実行環境を同じにしてしまう。
FROM golang:1.22
WORKDIR /app
COPY . .
RUN go build -o server
CMD ["./server"]
この構成の問題点は次の通りです。
- サイズが大きく、不要なビルドツールが全て実行環境に残る。
- アプリ本体は数十〜数百 MB 程度なのに、実行環境は1GB程度になることも珍しくありません。
- 不要なツールが存在するため、攻撃対象領域も広く安全性の観点でも問題があります。
🙆♂️Good
ビルドと実行を明確に分離する。
FROM golang:1.22 AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o server
FROM gcr.io/distroless/base-debian12
WORKDIR /app
COPY --from=builder /app/server .
ENTRYPOINT ["./server"]
この構成で改善される点は次の通りです。
- イメージサイズの劇的削減
- 実行環境の安全性が向上
- CI/CD の速度改善
- 本番環境に開発ツールが残らないのでセキュリティが向上
- キャッシュが効きやすくなる
キャッシュを最大限活かす
Dockerのビルドキャッシュはレイヤー単位で管理されており、レイヤーが変更されなければ、以前の結果をそのまま再利用します。
この特性を理解して Dockerfileを設計すると、ビルド時間が大幅に短縮されます。
キャッシュを最大化するための基本的な考えは「変更が発生しない部分を先に書く」ことです。
🙅♂️Bad
依存関係のダウンロードより前にアプリ全体をコピーしてしまう記述。
COPY . .
RUN go mod download
RUN go build
この構成の問題点は次の通りです。
- ソースコードが一行でも変わるたびにCOPY . . のレイヤーが更新される
- go mod downloadが毎回実行されるためビルド時間が長い
- 小さな変更でも全レイヤーが無効化されることがある
🙆♂️Good
依存関係の取得を先に行い、キャッシュが効くように配置する。
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build
この構成で改善される点は次の通りです。
- 依存関係レイヤーがほぼ固定になる
- ソースコードが変わっても go mod downloadがキャッシュでスキップされる
- ビルド時間が安定して短縮される
- マルチステージビルドと相性が良い
RUNのまとめ方を適切にする
RUN命令はそのたびにレイヤーを生成するため、回数が増えるとイメージサイズが大きくなります。
一方で無理に一つにまとめすぎると可読性が落ち、トラブルシューティングが難しくなります。
適度にまとめることがDockerfile設計の基本方針になります。
🙅♂️Bad
必要なパッケージのインストールを複数行に分け、無駄にレイヤーを増やしてしまう例。
RUN apt-get update
RUN apt-get install -y git
RUN apt-get clean
RUN rm -rf /var/lib/apt/lists/*
この書き方の問題点は次の通りです。
- レイヤーを毎回分割してしまいイメージサイズが大きくなる
- キャッシュ効率が悪くなる
- 操作の意図が分かりづらく可読性が低い
🙆♂️Good
関連する処理をひとつのRUNにまとめ、必要なクリーンアップも同時に行う。
RUN apt-get update && \
apt-get install -y --no-install-recommends git && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
この書き方で改善される点は次の通りです。
- レイヤーが1つにまとまり、イメージサイズが抑えられる
- パッケージリストやキャッシュファイルが残らないため軽量
- 一連の処理が上から下まで自然に読める形になり、意図が明確
- マルチステージビルドやdistrolessとの組み合わせでも扱いやすい
RUNの記述は「関連する操作はひとまとまり」にするのが最適であり、逆に性質が異なる操作を無理にまとめすぎる必要はないです。
不要ファイルをイメージに含めない
Dockerfile の最適化で見落とされやすいのが、ビルドコンテキストに含まれる不要ファイルです。
Dockerはdocker buildの際に現在のディレクトリ(コンテキスト)を丸ごと送信するため、余計なファイルが多いほどビルドが遅くなり、イメージにも不要物が含まれやすくなります。
こうした問題を防ぐための鍵が .dockerignoreです。
🙅♂️Bad
.dockerignoreが空のまま、または存在しないケース。
(空ファイル)
問題点は次の通りです。
- .gitディレクトリを含む非常に重いコンテキストが毎回Dockerに送られる
- testディレクトリやドキュメントのような本番に不要なファイルが全てイメージに含まれる
- ビルドが遅くなり、キャッシュの効率も低下する
- セキュリティ的に含めるべきでないファイルが混入する可能性がある
🙆♂️Good
本番に不要なディレクトリとファイルを明確に除外した.dockerignoreを用意する。
.git
test
node_modules
*.md
*.log
Dockerfile
docker-compose.yml
改善される点は次の通りです。
- コンテキストサイズが大幅に削減され、ビルドが高速化される
- イメージに不要ファイルが入り込まず、安全性と軽量性が向上する
- 本番環境で不必要な情報が露出しなくなる
- マルチステージビルドと組み合わせると、ビルド工程がより明快になる
特に .git とドキュメント類を除外するだけでも、ビルド時間とプッシュ/プル時間が大きく改善されます。
Dockerfileの書き方を改善するのと同じくらい、.dockerignoreの整備は重要です。
ENTRYPOINTとCMDの使い分け
コンテナ起動時の挙動を正しく制御するためには、ENTRYPOINTとCMDを適切に使い分ける必要があります。
どちらも「コンテナが起動するときに何を実行するか」を設定しますが、役割は異なります。
ENTRYPOINTは必ず実行されるメインコマンドで、CMDはそのデフォルト引数や上書き可能な設定を扱います。
この違いを理解していないと、意図しない挙動になったり、デプロイ時に柔軟性が失われたりします。
🙅♂️Bad
すべてをCMDの中にまとめてしまい、固定すべきコマンドと可変の引数が区別されていない例。
CMD ["./server", "--port=8080"]
この書き方の問題点は次の通りです。
- docker runの際に引数を渡すとCMDが完全に上書きされ、メインコマンド自体が消えてしまう
- 本来固定すべきアプリの起動コマンドと、環境によって変えたい引数が混ざってしまう
- 実行環境によって挙動が変わりやすく予期しない不具合を生みやすい
🙆♂️Good
アプリのメインコマンドをENTRYPOINTに固定し、変更可能な部分をCMDに委ねる。
ENTRYPOINT ["./server"]
CMD ["--port=8080"]
改善される点は次の通りです。
- メインコマンドが常に固定され、引数だけを差し替えられる
- docker runやデプロイツールからCMDのみ上書きでき、柔軟性が高まる
- ENTRYPOINTとCMDの責務が明確になり、予期しない上書きがなくなる
- コンテナ起動時の挙動が理解しやすく、レビュー時のミスも減る
実務では、ENTRYPOINTでアプリの起動コマンドを固定し、CMDで実行時の設定を制御する形が標準的です。
さらに、本番環境で構成を変更したい場合はCMDをoverrideするだけで済むため、
開発環境、本番環境、CI環境でパラメータの切り替えが容易になります。
distrolessの安全性と利点
distrolessはGoogleが提供する、アプリケーション実行に必要な最小限のファイルだけを含んだイメージです。
シェルやパッケージマネージャ、不要なライブラリを一切含まないため、非常に軽量で安全性が高い点が特徴です。
従来のalpineやdebianベースのイメージと比べて攻撃対象領域が小さく、実行環境の責務を明確にできます。
🙅♂️Bad
実行環境として一般的なLinuxディストリビューションを使い続けてしまう。
FROM debian:latest
WORKDIR /app
COPY ./server .
CMD ["./server"]
この書き方の問題点は次の通りです。
- latest タグに依存してしまい、ビルド結果が環境によって変動する
- bash、apt、systemdなど本番で不要なものが大量に含まれる
- ライブラリ数が多く、攻撃対象領域が非常に広い
- イメージサイズが大きく、デプロイの遅延につながる
🙆♂️Good
distrolessを使い、実行環境を最小単位にする。
FROM gcr.io/distroless/base-debian12
WORKDIR /app
COPY --from=builder /app/server .
ENTRYPOINT ["./server"]
改善される点は次の通りです。
- シェル、パッケージマネージャ、不必要なライブラリが排除される
- 攻撃対象領域が最小化され、CVEの影響を受けにくくなる
- イメージサイズが大幅に小さくなるためデプロイが速くなる
- 実行環境が固定化されるため、本番と開発の挙動差が減る
- 本番環境で余計なプロセスが存在しないため、運用時のトラブルが減る
特に distrolessにシェルが含まれない点は、安全性の観点で重要です。
コンテナ内での不要な操作を避けられ、意図しないファイル操作やスクリプト実行を防止できます。
また、CIやCDのパイプラインにおいてもdistrolessの軽量性は有利で、プッシュとプルの速度が改善され、全体のフィードバックサイクルが短くなります。
最小権限のユーザーで実行する
ルートレスコンテナ
Dockerコンテナはデフォルトではroot権限で動作します。
これは便利な反面、アプリケーションが予期せず高い権限を持つことになり、権限昇格のリスクや、コンテナ突破時にホスト側への影響を拡大させる要因となります。
ルートレスコンテナは、この問題を避けるためにコンテナを非rootユーザーで実行する考え方です。
Webアプリケーションのようにroot権限を必要としないサービスでは、最小権限での実行を徹底することで安全性が大きく向上します。
DockerfileではUSER命令を使って実現できます。
🙅♂️Bad
実行ユーザーを指定せず、rootのままコンテナを動かしてしまう。
FROM gcr.io/distroless/base-debian12
WORKDIR /app
COPY --from=builder /app/server .
ENTRYPOINT ["./server"]
この書き方の問題点は次の通りです。
- アプリケーションがroot権限で動作し、万が一脆弱性があれば攻撃範囲が大きい
- 書き込み可能領域が広く、意図しないファイル変更につながる
- セキュリティスタンダード(CIS ベンチマーク等)に準拠しない
🙆♂️Good
非rootユーザーを作成し、そのユーザーで実行する。
FROM golang:1.22 AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o server
FROM gcr.io/distroless/base-debian12
WORKDIR /app
# 非rootユーザーに切り替える(distrolessでは65532が推奨)
USER 65532
COPY --from=builder /app/server .
ENTRYPOINT ["./server"]
改善される点は次の通りです。
- アプリケーションがroot権限を持たず、突破されても被害が限定的になる
- セキュリティ標準に準拠した実行環境となる
- distrolessはデフォルトで65532番の非rootユーザーを想定しており相性が良い
- ファイル書き込みの範囲が制御され、意図しない変更が起きにくい
distrolessはユーザー管理機能を提供するため、わざわざuseradを実行せずUSER 65532だけで非root実行ができる点も非常に便利です。
ルートレスコンテナが推奨される理由
ルートレスコンテナは単なる安全対策ではなく、コンテナ運用の基準として事実上のスタンダードになりつつあります。
理由は次の通りです。
- ホスト上のrootを奪われるリスクが低減する
- 攻撃者が使用できるシステムコールが制限される
- 最小権限の原則に自然に従う
- 監査ツールが安全な実行環境として評価しやすい
ビルドしたイメージのセキュリティチェックを行なう
コンテナイメージをビルドした後に必ず実施すべき工程が、イメージのセキュリティスキャンです。
このチェックを省略すると、脆弱性を含んだまま本番環境にデプロイしてしまうリスクがあります。
なぜイメージスキャンが必要か
コンテナイメージにはベースイメージのライブラリやミドルウェア、アプリケーションの依存モジュールなどが含まれています。
これらに既知の脆弱性(CVE)が含まれていたり、パッケージが古く保守終了状態であったりすると、アプリケーションに重大なセキュリティリスクをもたらします。
また、コンテナイメージは "動くパッケージ" であるため、セキュリティ不備がそのまま本番で影響を及ぼしやすく、従来の VM よりも迅速な対策が求められます。
🙅♂️Bad
スキャンを行わずそのままイメージをプッシュ・デプロイしてしまう。
# ビルド後すぐに registry へプッシュ
docker build -t myapp:latest .
docker push myapp:latest
この手順の問題点は次の通りです。
- ベースイメージやアプリ依存に脆弱性があっても検出されない
- 本番環境で問題が発覚すると修正に時間がかかる
- 規定されたセキュリティチェックが存在しないため、運用上の監査リスクが高まる
🙆♂️Good
イメージビルド後に Docker Scout CLI を使って脆弱性スキャンを行い、重大な脆弱性を含む場合はプッシュを拒否するように制御する。
docker build -t myapp:latest .
docker scout cves myapp:latest --exit-code --only-severity critical,high
if [ $? -ne 0 ]; then
echo "Critical or High severity vulnerabilities detected. Aborting push."
exit 1
fi
docker push myapp:latest
この流れで改善される点は次の通りです。
- ビルド直後に既知脆弱性が含まれていないかを自動で検出できる
- スキャンをパイプラインに組み込むことで手動ミスや見落としを防止できる
- 合格条件を定めることで運用基準が整い、デプロイ品質が安定する
- 監査やコンプライアンスで「脆弱性チェック実施済み」という証跡を残しやすくなる
実践時のチェック項目とベストプラクティス
セキュリティスキャンを効果的に活用するためには、以下のような観点で設定することが重要です。
- ベースイメージを常に最新に保つ
- スキャンツール(Trivy、Clair、Aqua、Sysdigなど)をCIに統合する
- 「重大な脆弱性(High/Critical)」以上をデプロイ禁止条件にする
- スキャン結果をログとして保存し、アラートをトリガーする
- 定期的な再スキャン(週次・月次)を実施し、イメージに潜んだ新規 CVE を検知する
- スキャン対象には本番用イメージだけでなく開発・ステージング用も含める
注意点
- スキャンを一回やれば終わりではない。リリース後もライブラリが更新され脆弱性が発覚するため、定期監査が必要です。
- スキャンが “0 リスク”を保証するわけではない。独自コードの脆弱性や構成ミスまでは検出できないため、別のセキュリティ対策と併用する。
- スキャン時間が長くてビルドパイプラインが遅くなりすぎる場合、スキャン対象を “差分ライブラリのみ” にする戦略も検討する。
Linter を使って品質を担保する
Dockerfileは構文がシンプルな一方で、人によって書き方が大きく異なりやすく、レビュー時にスタイルのばらつきが出やすいファイルです。
また、レイヤー構造・キャッシュ・パッケージ管理の扱いなど、正しい知識がないと気づきにくい問題も多く含まれます。
Linterを導入することで、こうした問題の早期発見と品質の標準化が可能になります。
代表的なツールが Hadolint です。
HadolintはDockerfileを静的解析し、以下のような問題を自動で指摘します。
- latestタグの使用
- レイヤー分割の非効率性
- apt-getの使い方の誤り
- キャッシュ非利用
- root実行や不要なパッケージの残存
🙅♂️Bad
Linter を導入せず、人手だけでレビューしている。
FROM ubuntu:latest
RUN apt-get update
RUN apt-get install -y git
この場合に起きやすい問題は次の通り。
- latest タグのためビルドの再現性が低い
- RUN の分割によるレイヤー増加
- キャッシュ効率が悪くビルドが遅い
- セキュリティ警告を見逃す可能性が高い
レビュー担当者のスキルに依存し、品質が安定しない。
🙆♂️Good
Hadolintを導入し、自動チェックをCIに組み込む。
ローカルでの実行例
brew install hadolint
hadolint Dockerfile
GitHub Actions の例
- name: Run Hadolint
uses: hadolint/hadolint-action@v3.1.0
with:
dockerfile: Dockerfile
改善される点は次の通り。
- latest タグの使用や不適切なRUN命令が自動で検出される
- apt-get の順序やクリーンアップ漏れなど、気づきにくい問題を早期発見できる
- ベストプラクティスが共通化され、チーム全体で統一された Dockerfile が書ける
- ローカルと CI の両方でチェックできるため、品質のばらつきが減る
ベストプラクティスを利用した場合のDockerfile
FROM golang:1.24.5 AS builder
WORKDIR /app
# 依存関係キャッシュを効かせるために先に go.mod だけコピー
COPY go.mod go.sum ./
RUN go mod download
# アプリコードをコピー
COPY . .
# 本番用バイナリをビルド
RUN CGO_ENABLED=0 GOOS=linux go build -o server
FROM gcr.io/distroless/base-debian12
WORKDIR /app
# distrolessでは65532が非rootユーザーとして推奨されている
USER 65532
# builderからビルド成果物だけコピー
COPY /app/server .
# メインコマンド(固定)
ENTRYPOINT ["./server"]
# 可変部分(例:ポートなど)
CMD ["--port=8080"]
imageのsize
myapp good 79ba1d2e3d44 31.4MB
916MBから31.4MBまでsizeを削減できることができました!👏
おわりに
Dockerfileは構造が単純に見える一方、書き方ひとつでビルド速度、デプロイ速度、安全性、イメージサイズに大きく差が出ます。
この記事をもとに、ぜひ自分のプロジェクトのDockerfileを見直してみてください!
ごっづぁんです!!
参考記事
Discussion
すごい良い記事でした! これ、よんどけ! ってだけでレビューする負担が減らせる!
よい纏めかたでした!!!
解説ありがとうございます。Dockerfile の範疇を少し超えてしまうと思いますが、実務ではさらに環境別設定も必要だと思います。Docker(file) の環境別設定のベストプラクティスも記事にしていただけるとありがたいです。
RUNをまとめようはもう不要でいいと思います。今はキャッシュマウントがあり、イメージの外にダウンロードしてきたものを保存しておいてくれます。apt-getやgo get, pip installなどの対象が100個から101個に増えた場合、レイヤーキャッシュ方式だ101個再ダウンロードしますが、キャッシュマウントだと増えた1つだけで済みますし、最終イメージにゴミも残りません。以前、こちらに書きました。
まあキャッシュマウントが入る前から、少なくともマルチステージビルドを使うなら最終イメージ以外は意味がないですし。こちらもここに書きました。この2つを除外してなおRUNをまとめる理由はもう思いつかないです。