はじめに
Dockerfileの基本構文は理解できた。しかし、本番環境にデプロイするイメージが1GB以上になっていたり、ビルドに毎回10分以上かかっていたりしないでしょうか。開発環境では問題なくても、本番運用を見据えると「軽量化」「ビルド高速化」「セキュリティ」が重要な課題となります。
本記事では、Dockerfileのベストプラクティスとして、マルチステージビルド、レイヤーキャッシュの最適化、ベースイメージの選定、セキュリティ対策を体系的に解説します。この記事を読み終えると、以下のことができるようになります。
- マルチステージビルドで最終イメージを大幅に軽量化できる
- レイヤーキャッシュを意識した効率的なDockerfileを設計できる
- Alpine、distrolessなど用途に応じたベースイメージを選定できる
- non-rootユーザー実行などセキュリティベストプラクティスを適用できる
前提として、Dockerfileの基本構文を理解していることを想定しています。まだ基本を押さえていない場合は、Dockerfile入門 - 基本構文とビルドの流れを参照してください。
軽量なDockerイメージの重要性
本題に入る前に、なぜDockerイメージの軽量化が重要なのかを確認しておきましょう。
イメージサイズが大きいと何が問題か
| 影響 | 説明 |
|---|---|
| ビルド時間の増加 | レイヤーが大きいほど転送・圧縮に時間がかかる |
| デプロイ時間の増加 | レジストリからのpullに時間がかかり、スケールアウトが遅延する |
| ストレージコストの増大 | レジストリやノードでのディスク使用量が増加する |
| セキュリティリスクの増大 | 不要なパッケージが多いほど脆弱性の攻撃対象が広がる |
| CI/CDパイプラインの遅延 | 毎回のビルド・デプロイでチームの開発効率が低下する |
特にKubernetesなどオーケストレーション環境では、Podのスケールアウト速度がイメージサイズに直結します。100MBのイメージと1GBのイメージでは、起動時間に大きな差が生じます。
軽量化の効果
軽量なイメージを作成することで、以下の効果が得られます。
- ビルド・デプロイ時間の短縮
- ストレージコストの削減
- 起動時間の高速化
- セキュリティ攻撃対象の縮小
これらの効果を実現するために、以降で紹介するベストプラクティスを順に適用していきましょう。
マルチステージビルドの活用
マルチステージビルドは、Dockerイメージの軽量化において最も効果的な手法です。ビルド環境と実行環境を分離することで、最終イメージに不要なビルドツールや中間ファイルを含めずに済みます。
マルチステージビルドとは
従来のDockerfileでは、アプリケーションのビルドから実行まで単一のイメージで行っていました。この場合、ビルドに必要なコンパイラやSDKがそのまま最終イメージに残ってしまいます。
マルチステージビルドでは、複数のFROM命令を使用して、ビルド用のステージと実行用のステージを分離します。
flowchart LR
subgraph BuildStage["Build Stage (大きい)"]
SDK["SDK/コンパイラ"]
Source["ソースコード"]
Deps["依存関係"]
Artifacts["ビルド成果物"]
end
subgraph RuntimeStage["Runtime Stage (小さい)"]
Runtime["ランタイムのみ"]
Binary["バイナリ/成果物"]
MinDeps["最小限の依存関係"]
end
BuildStage --> RuntimeStage基本的なマルチステージビルドの例(Go)
Go言語のアプリケーションを例に、マルチステージビルドの基本パターンを見てみましょう。
|
|
ポイントは以下のとおりです。
AS builderでステージに名前を付ける- ビルドステージでコンパイルを完了する
COPY --from=builderで必要な成果物のみを実行ステージにコピーする- 実行ステージには軽量なベースイメージ(この例では
scratch)を使用する
サイズ比較
単一ステージとマルチステージでイメージサイズを比較してみましょう。
| 構成 | ベースイメージ | 最終サイズ |
|---|---|---|
| 単一ステージ | golang:1.24 | 約850MB |
| マルチステージ | scratch | 約10MB |
マルチステージビルドにより、イメージサイズを99%近く削減できる場合もあります。
Node.jsでのマルチステージビルド例
コンパイル言語だけでなく、Node.jsのようなインタプリタ言語でもマルチステージビルドは有効です。
|
|
開発用の依存関係(devDependencies)やビルドツールは最終イメージに含まれず、本番環境に必要なファイルのみがコピーされます。
ステージの再利用
複数のイメージで共通するステージがある場合は、共通ステージを定義して再利用できます。
|
|
--targetオプションで特定のステージをビルドできます。
|
|
レイヤーキャッシュ最適化のコツ
Dockerはビルド時にレイヤー単位でキャッシュを作成します。キャッシュを効果的に活用することで、ビルド時間を大幅に短縮できます。
レイヤーキャッシュの仕組み
Dockerfileの各命令はイメージレイヤーを生成します。ビルド時に、Docker(BuildKit)は各レイヤーのキャッシュが有効かどうかをチェックし、変更がなければキャッシュを再利用します。
命令1 (FROM) --> キャッシュあり --> 再利用
命令2 (COPY) --> キャッシュあり --> 再利用
命令3 (RUN) --> 変更あり --> 再ビルド
命令4以降 --> キャッシュ無効 --> すべて再ビルド
重要なのは、あるレイヤーでキャッシュが無効になると、それ以降のすべてのレイヤーも再ビルドされるという点です。
キャッシュを活かす命令の順序
キャッシュを最大限活用するには、変更頻度の低い命令を先に、変更頻度の高い命令を後に配置します。
悪い例(ソースコード変更のたびに依存関係も再インストール):
|
|
良い例(依存関係のキャッシュを活用):
|
|
良い例では、package.jsonが変更されない限りnpm ciのレイヤーがキャッシュされ、ソースコードの変更だけでは依存関係の再インストールが発生しません。
apt-getの最適化
Debian/Ubuntuベースのイメージでは、apt-getの使い方がキャッシュ効率とイメージサイズに影響します。
推奨パターン:
|
|
ポイントは以下のとおりです。
apt-get updateとapt-get installを同じRUN命令で実行する--no-install-recommendsで推奨パッケージのインストールを抑制する- パッケージをアルファベット順に並べて可読性と保守性を向上させる
/var/lib/apt/lists/*を削除してレイヤーサイズを削減する
.dockerignoreの活用
.dockerignoreファイルを設定することで、ビルドコンテキストから不要なファイルを除外できます。これにより、ビルドコンテキストの転送時間短縮とキャッシュ効率の向上が期待できます。
# .dockerignore
.git
.gitignore
node_modules
npm-debug.log
Dockerfile
.dockerignore
*.md
.env
.env.*
coverage
dist
build
特にnode_modulesや.gitなどの大きなディレクトリを除外することで、ビルド時間が大幅に短縮されます。
BuildKitのキャッシュマウント
BuildKitを使用すると、パッケージマネージャのキャッシュをビルド間で共有できます。
|
|
--mount=type=cacheにより、npmのキャッシュディレクトリがビルド間で永続化され、パッケージのダウンロード時間を短縮できます。
ベースイメージ選定(Alpine/distroless)
ベースイメージの選定は、イメージサイズとセキュリティに直接影響します。用途に応じて適切なベースイメージを選びましょう。
ベースイメージのサイズ比較
主要なベースイメージのサイズを比較してみましょう。
| イメージ | サイズ | 特徴 |
|---|---|---|
| ubuntu:24.04 | 約76MB | 汎用的、パッケージ豊富 |
| debian:bookworm-slim | 約74MB | slimでサイズ削減 |
| alpine:3.21 | 約7MB | 軽量、musl libc使用 |
| gcr.io/distroless/static-debian12 | 約2MB | シェルなし、最小構成 |
| scratch | 0MB | 空のイメージ |
Alpineイメージ
Alpineは軽量なLinuxディストリビューションで、muslライブラリとBusyBoxをベースにしています。
メリット:
- 非常に軽量(約7MB)
- apkパッケージマネージャが使用可能
- シェルが利用できるためデバッグが容易
デメリット:
- glibcではなくmusl libcを使用するため、互換性問題が生じる場合がある
- 一部のパッケージはAlpine向けに最適化されていない
Alpineを使用したDockerfile例:
|
|
distrolessイメージ
GoogleがメンテナンスするdistrolessイメージはDockerイメージのセキュリティベストプラクティスを体現しています。シェルやパッケージマネージャを含まず、アプリケーションの実行に必要な最小限のファイルのみで構成されています。
主なdistrolessイメージは以下のとおりです。
| イメージ | 用途 |
|---|---|
| gcr.io/distroless/static-debian12 | 静的リンクバイナリ(Go等) |
| gcr.io/distroless/base-debian12 | 動的リンクが必要な場合 |
| gcr.io/distroless/cc-debian12 | C/C++アプリケーション |
| gcr.io/distroless/java21-debian12 | Javaアプリケーション |
| gcr.io/distroless/nodejs22-debian12 | Node.jsアプリケーション |
| gcr.io/distroless/python3-debian12 | Pythonアプリケーション |
メリット:
- 極めて軽量
- シェルがないため攻撃対象が極小化される
- CVEスキャンのノイズが減少する
デメリット:
- シェルがないためデバッグが困難
- 追加パッケージのインストールが不可能
distrolessを使用したマルチステージビルド例(Go):
|
|
nonrootタグを使用することで、デフォルトでnon-rootユーザー(UID 65532)として実行されます。
デバッグ用イメージ
distrolessにはデバッグ用のイメージも用意されています。
|
|
本番では通常のdistrolessを使用し、問題調査時のみdebugタグ付きイメージを使用することで、セキュリティと運用性を両立できます。
ベースイメージ選定のフローチャート
静的リンクバイナリ(Go等)?
|
+-- Yes --> scratch または distroless/static
|
+-- No --> 動的リンクが必要?
|
+-- Yes --> ランタイム必要?
| |
| +-- Yes --> distroless/java, nodejs等
| |
| +-- No --> distroless/cc または distroless/base
|
+-- パッケージ追加が必要?
|
+-- Yes --> alpine または slim
|
+-- No --> distroless推奨
セキュリティベストプラクティス(non-root実行など)
コンテナのセキュリティは、本番環境運用において欠かせない要素です。Dockerfileレベルで適用できるセキュリティベストプラクティスを紹介します。
non-rootユーザーでの実行
デフォルトでは、コンテナ内のプロセスはrootユーザーとして実行されます。コンテナがエスケープされた場合、ホストシステムにroot権限でアクセスされるリスクがあります。
non-rootユーザーを作成して使用する例:
|
|
ポイントは以下のとおりです。
- 明示的なUID/GIDを指定することで、イメージ再ビルド間でIDの一貫性を保つ
--chownオプションでファイルの所有者を設定するUSER命令でプロセス実行ユーザーを切り替える
公式イメージには、あらかじめnon-rootユーザーが定義されているものもあります。
|
|
ファイルシステムを読み取り専用にする
コンテナのファイルシステムを読み取り専用にすることで、マルウェアの書き込みを防止できます。
|
|
Dockerfileでは、アプリケーションが書き込みを必要としないディレクトリ構造になっているか確認しましょう。
機密情報の取り扱い
Dockerイメージに機密情報(パスワード、APIキーなど)を含めてはいけません。
悪い例:
|
|
良い例(ビルド時のシークレット):
|
|
|
|
本番環境では、Kubernetes Secretsや外部のシークレット管理サービス(HashiCorp Vault等)を使用することを推奨します。
不要なパッケージを含めない
不要なパッケージは攻撃対象を広げるだけでなく、イメージサイズも増加させます。
|
|
--no-install-recommendsオプションで推奨パッケージのインストールを抑制し、本当に必要なパッケージのみをインストールします。
イメージの脆弱性スキャン
Docker ScoutやTrivyなどのツールを使用して、イメージの脆弱性を定期的にスキャンしましょう。
|
|
CI/CDパイプラインにスキャンを組み込むことで、脆弱性のあるイメージがデプロイされることを防止できます。
ベースイメージのバージョン固定
タグは可変であるため、latestや3.21のようなタグだけでは、意図しないバージョンがpullされる可能性があります。
推奨パターン:
|
|
ダイジェストを使用することで、完全に同一のイメージが使用されることが保証されます。ただし、セキュリティアップデートを受け取るために、定期的にダイジェストを更新する運用が必要です。
実践:最適化前後の比較
これまでのベストプラクティスを適用した前後のDockerfileを比較してみましょう。
最適化前
|
|
問題点:
- フルサイズのnodeイメージを使用(約1GB)
- devDependenciesもインストールされる
- レイヤーキャッシュが効率的に使われない
- rootユーザーで実行される
最適化後
|
|
改善点:
- Alpineベースで軽量化
- マルチステージビルドでビルド成果物のみを含む
npm ci --only=productionで本番依存関係のみをインストール- non-rootユーザー(node)で実行
- レイヤーキャッシュを効率的に活用
サイズ・セキュリティ比較
| 項目 | 最適化前 | 最適化後 |
|---|---|---|
| イメージサイズ | 約1.2GB | 約150MB |
| CVE検出数 | 高(多数のパッケージ含む) | 低(最小構成) |
| 実行ユーザー | root | node(非root) |
| ビルド時間(キャッシュあり) | 遅い | 高速 |
まとめ
本記事では、Dockerfileのベストプラクティスとして、以下の内容を解説しました。
- マルチステージビルド: ビルド環境と実行環境を分離し、最終イメージを大幅に軽量化する
- レイヤーキャッシュ最適化: 変更頻度を意識した命令の順序づけと
.dockerignoreの活用で、ビルド時間を短縮する - ベースイメージ選定: Alpine、distroless、scratchなど用途に応じた選択で、サイズとセキュリティを最適化する
- セキュリティベストプラクティス: non-rootユーザー実行、機密情報の適切な取り扱い、脆弱性スキャンの導入
これらのベストプラクティスを適用することで、本番環境でも安心して使用できる、軽量・高速・安全なDockerイメージを作成できます。次のステップとして、言語やフレームワークごとのDockerfile最適化パターンを学ぶことで、さらに実践的なスキルを身につけることができます。