😽

Dockerfileのベストプラクティスについて

に公開

はじめに

こんにちは。
プログラミング初心者wakinozaと申します。
勉強中に調べたことを記事にまとめています。

十分気をつけて執筆していますが、なにぶん初心者が書いた記事なので、理解が浅い点などあるかと思います。
間違い等あれば、指摘いただけると助かります。

記事を参考にされる方は、初心者の記事であることを念頭において、お読みいただけると幸いです。

対象読者

  • Dockerを勉強中の方

動作環境

  • Windows11
  • Visual Studio Code
  • Docker 29

記事のテーマ

前回は、Dockerfileの基本的な記述方法についてまとめました。

https://zenn.dev/wakinoza/articles/f97a0674b3b1b0

今回は、公式で紹介されているDockerfile記述方法のベストプラクティスについて解説し、前回紹介した簡易なDockerfileをリファクタリングしていきます。

目次

1. Dockerfileのベストプラクティス
2. Dockerfileの各コマンドの注意点
3. ベストプラクティスに則ってリファクタリング

1. Dockerfileのベストプラクティス

カスタムDockerイメージを作成する場合は、Dockerfileを利用します。
しかし、Dockerfileには様々な書き方が存在します。書き方ひとつで、ビルド速度、セキュリティ、実行時のパフォーマンスなどが大きく異なるため、よりよいDockerfileを書くための注意点を知っておく必要があります。
この記事では、公式ドキュメント上に公開されている「Dockerfileを書くベストプラクティス」という情報について解説していきます。

1-1.一時的なコンテナを作成

生成するコンテナは一時的(エフェメラル)であるべきです。
そのためには、コンテナを停止・削除しても設定やデータなどが消去されない状態を構築する必要があります。例えば、設定は環境変数で渡し、データはボリュームを設定するなどの設計が不可欠です。

1-2. ビルド・コンテクストの理解 と .dockerignoreの設定

イメージをビルドするときに以下のコマンドを実行します。

docker build .

この「.(カレントディレクトリ)」の部分が「ビルドコンテクスト」です。

デフォルトでは、指定されたディレクトリ内のファイルがビルド処理の対象となります。その際、不要なファイルが含まれると、転送コストが増大したり、ビルドキャッシュの効率が低下したりします。
そのため、ディレクトリにあらかじめ、「.dockerignore」ファイルを設定し、ビルド時の転送対象から外したいファイル名や拡張子を記述しておきます。
.dockerignoreに記載が推奨される事項は以下の通りです

# --- 1,ビルドツールの成果物 ---
target/
build/
bin/
.gradle/
out/

# --- 2,IDEの設定ファイル ---
.idea/
*.iml
.setting/
.vscode/
.project
.classpath
.nb-gradle/

# --- 3,GitやOSの管理ファイル ---
.git/
.gitignore
DS_Store
Thumbs.db

# --- 4,ログや機密情報の書かれたファイル ---
*.log
.env
docker-compose.yml

.dockerignoreを設定することで、不要なファイルが転送される事態を防ぐことができ、ビルドを高速化できます。

1-3. マルチステージ・ビルドを使う

マルチステージ・ビルドとは、イメージの軽量化を実現する手法です。

Javaの環境構築で例えると、JARファイルのビルド時はコンパイラやビルドツールなどの重いソフトウェアが必要ですが、実行時には軽量なJREがあれば十分です。
しかし、マルチステージ・ビルドの手法を使わない場合、実行用イメージにもJDKやMavenが含まれてしまい、イメージが重くなってしまいます。

そこで、Dockerfile内に複数のステージを記述し、各ステージの成果物のうち必要なものだけを次のステージにコピーするという手法で、最終的なイメージを軽量化します。

なぜそのようなことが必要かというと、それはDockerイメージが読み取り専用のレイヤーを積み重ねる構造であるためです。
実は、Dockerイメージは、データサイズを抑えるため、「差分しか収録しない」という作り方をしています。
例えば、元となるイメージにAという変更を加えたイメージAを作ったとします。この時、イメージAには、元のイメージとは異なる「差分」だけが含まれています。また、イメージAにさらに変更を加えたイメージBを作成した場合、イメージBには、AとBの差分しか含まれません。
このようにイメージの内部は、差分が階層化した構造になっています。この階層のことを「レイヤー」と言います。

このレイヤー構造は、データサイズを抑えるための効率的な仕組みですが、その反面、下のレイヤーのデータをイメージ全体から削除できないという欠点もあります。

例として、Javaのコンパイルの流れを見ていきます。

  1. [Layer 1] JDKをインストール
  2. [Layer 2] ソースをコンパイル
  3. [Layer 3] JDKを削除

上のようにイメージに変更を加えるごとに、レイヤーが積み重なっていきます。最後のレイヤー3でJDKを削除して軽量化できているように見えますが、Dockerイメージは読み取り専用のレイヤーを積み重ねる構造であるため、後のレイヤーでファイルを削除(rm)しても、それ以前のレイヤーに書き込まれた実データがイメージから消えることはありません。
レイヤーが積み重なる構造上、削除コマンドを実行してもイメージの物理サイズは削減されず、最終イメージが肥大化する原因となります。

これを解消する手段が、「マルチステージ・ビルド」です。マルチステージ・ビルドでは、「削除しても、データ量は減らない」という欠点を克服するために、複数のステージでビルドします。
具体的には、ビルド用のステージと実行用のステージなど用途別のステージを準備します。ビルド用のステージ生成したJARファイルなどの成果物を、次のステージにコピーします。
Dockerは複数のステージがある場合、最後のステージの内容のみを最終的なイメージとして出力します。それ以前のステージのデータは実行用イメージには含まれないため、結果としてイメージサイズを劇的に削減できます。
複数のステージでイメージを作成することで、必要なデータだけを含む軽量なイメージがビルドできるのです。

以下は、簡単なJavaファイルを実行するイメージを作成するDockerfileです。

# --- Stage 1: Build Stage ---
# ASコマンドで名前を付ける
FROM eclipse-temurin:21-jdk-alpine AS builder

WORKDIR /app

COPY Main.java .

RUN javac Main.java


# --- Stage 2: Run Stage ---
# 実行には JDK ではなく、より軽量な JRE を使用
FROM eclipse-temurin:21-jre-alpine

WORKDIR /app

# ビルドステージからコンパイル済みの .class ファイルだけをコピー
COPY --from=builder /app/Main.class .

CMD ["java", "Main"]

まず、ステージ1でデータ量の重いJDKを利用してコンパイルを行い、成果物であるクラスファイルを生成します。後の処理のために、FROM命令に「AS」句を付与して、ステージ1に「builder」という名前を付けておきます。

次に、ステージ2では、実行に必要なJREという軽量な実行環境を準備し、「COPY --from=」コマンドで「builder」と名付けたステージ1からクラスファイルをコピーします。
このDockerfileは2つのステージが記述されているため、最終的に生成されるイメージはステージ2の成果物となります。重いJDKを基にしたステージ1のデータは最終的なイメージのデータ量に含まれません。

マルチステージ・ビルドによって、イメージを軽量化するメリットは、ビルド時間の短縮だけではありません。
JDKなどのビルド環境には多くの脆弱性が含まれる可能性があります。不要なビルド環境のデータを実行環境から排除することで、攻撃対象領域の最小化も実現でき、セキュリティリスクが軽減できるのです。

1-4. 不要なパッケージをインストールしない

Dockerにおいての「パッケージ」とは、OS上に追加でインストールしたいツール(git,vim,curlなど)を指します。
パッケージが必要な場合は、RUNコマンドで指示します。

イメージと言葉の意味が似ていますが、「イメージ」はコンテナのベースとなるもの、「パッケージ」はイメージを作る過程に必要な機能を追加するものといった違いがあります。

「もしかしたら使うかも」という理由で、パッケージを追加すると、イメージサイズが増えてしまったり、攻撃者の侵入ツールとして利用される可能性があるためセキュリティリスクが増大します。

1-5. アプリケーションを切り離す

1つのコンテナに1つのプログラムを入れることが望ましいです。
例えば、WordpressとMySQLを利用したい場合、1つのコンテナに2つのプログラムを格納するのではなく、別々のコンテナにプログラムを1つずつ格納し、それぞれをネットワークで紐づけます。

「1コンテナに1プログラム」を徹底すると、特定のプログラムだけを更新したり、コンテナを再利用したりといったことが容易になります。

1-6. レイヤーの数は最小に

「RUN」「COPY」「ADD」コマンドは、レイヤーを追加します。

複数の命令がある場合は「&&」をはさむことで、複数の命令を1つのコマンド(1つのレイヤー)にまとめることも可能です。

しかし、命令をまとめることには、メリット・デメリット両方があるため、時と場合に応じて使い分けます。

まず、命令をまとめることでメリットがある場合を見ていきましょう。
以下は、OSのパッケージをインストールする命令です。

RUN apt-get update
RUN apt-get install -y curl
RUN apt-get install -y git

上のように複数のRUNコマンドに分けると、下層のレイヤーのデータ量を削除できないため、イメージサイズが増大します。
OSの設定やツールのインストールなど、中身が頻繁に変更されない命令の場合は、命令をまとめてレイヤーを最小限にするほうが、効率的です。
命令をまとめたのが、以下のコードです。

RUN apt-get update && apt-get install -y \
    curl \
    git

次に、命令をまとめることでデメリットがある場合を見ていきましょう。

CI(継続的インテグレーション)環境においては、コードをプッシュするたびに、Dockerイメージのビルドが走ります。
Dockerはイメージのビルド時に、毎回新しいイメージを生成するのではなく、キャッシュされた既存のイメージが再利用できるかを調べています。
特にADDやCOPYコマンドでは、イメージに含まれるファイルの内容が検査され、変更がない場合はキャッシュを使いまわします。一方、どこかのレイヤーキャッシュに「変更あり」と判断されると、それ以降の命令のすべてのキャッシュが捨てられ、再実行します。
そのため、レイヤーをまとめすぎると、キャッシュの利用によるビルド時間短縮の恩恵が受けにくくなります。

キャッシュ効率を考える場合は、ツールのインストール変更のや変更の少ない設定ファイルなどと、変更の多いsrcディレクトリを別レイヤーに分けることが重要です。

# --- キャッシュ効率が悪い例 ---
FROM eclipse-temurin:21-jdk-alpine
WORKDIR /app

# ① 全ファイルを一気にコピー(pom.xmlもsrcも全部)
COPY . .

# ② ビルドを実行
# ここでライブラリのダウンロードとコンパイルが同時に走る
RUN ./mvnw package

このDockerfileでは、変更頻度の多いsrcディレクトリのコピーが①にあります。そのため、ソースコードを1行でも変更すると、以後すべてのレイヤーキャッシュが破棄され、再実行されます。ビルドのたびに②
のライブラリのダウンロードが実行されるため、ビルド時間が長くなります。

# --- キャッシュ効率が良い例 ---
FROM eclipse-temurin:21-jdk-alpine AS builder
WORKDIR /app

# --- レイヤー1: ビルドツール(Wrapper)のコピー ---
# 頻繁に変わらないツール類だけを先にコピー
COPY .mvn/ .mvn
COPY mvnw pom.xml ./

# --- レイヤー2: 依存ライブラリのダウンロード (重い処理) ---
# ここでライブラリだけを先にダウンロードしてキャッシュ化する!
# ソースコードがなくても、pom.xmlさえあれば実行可能
RUN ./mvnw dependency:go-offline

# --- レイヤー3: ソースコードのコピー ---
# ここで初めてソースコードを入れる
COPY src ./src

# --- レイヤー4: アプリのビルド (コンパイル) ---
# すでにライブラリは揃っているので、自分のコードのコンパイルだけで済む
RUN ./mvnw package -DskipTests

上のコードでは、変更頻度の低いビルドツールのコピーや設定ファイルのコピー、ライブラリのダウンロードを別レイヤーに分けています。
もし、srcディレクトリのコードを書き換えても、レイヤー1と2には変更がないため、レイヤーキャッシュが利用されます。実質的にレイヤー3から実行されるため、ビルド時間が大幅に短縮できます。

このようにレイヤーをまとめるかは、状況に応じて判断する必要があります。
マルチステージビルドを併用するケースが多いと思うので、最終ステージより前はキャッシュ効率を優先した記述を行い、最終ステージのみレイヤー数とキャッシュ効率のバランスを意識して記述するという運用が現実的です。

1-7. 複数行にわたる引数は適切に並べる

レイヤーをまとめるために、複数の命令を1行にまとめる場合は、アルファベット順に並べると管理が楽になり、誤字脱字を防ぎます。
また、「\」(バックスラッシュ)を入れることで、命令を複数行に分けて記述することができるため、可読性が向上します。

以下は、Docker公式ドキュメントに記載されている記述例です。

RUN apt-get update && apt-get install -y \
  bzr \
  cvs \
  git \
  mercurial \
  subversion \
  && rm -rf /var/lib/apt/lists/*

2. Dockerfileの各コマンドの注意点

次に、コマンドごとの注意点について、代表的なものを説明していきます。

2-1. FROM

イメージの基礎を指定するコマンドです。
指定できる公式イメージには、Linux単体のものやLinuxにアプリケーションが同梱されたものなど、多数存在します。
そのすべてに共通するのが、Linuxのディストリビューションです。
より利用されるLinuxディストリビューションには、以下のものがあります。

イメージ名 ディストリビューション 説明
debian Debian Debianの公式イメージ。ある程度幅広い機能を利用したいときに有用
ubuntu Ubuntu Ubuntuの公式イメージ。Debianよりはイメージサイズが重め
busybox BusyBox 組み込み目的で作られた、非常にサイズの小さいLinux。イメージサイズを極小にしたいときに有用
alpine Alpine Linux 軽量型Linuxだが、パッケージマネージャーが付属している。何かのソフトウェアをインストールするときは、busyboxより使い勝手が良い。

Docker公式のベストプラクティスでは、用途に応じてできるだけ小さなベースイメージを選ぶことが推奨されています。
その選択肢の一つとして、軽量な alpine イメージがよく利用されます。

2-2. RUN

RUNコマンドでパッケージをインストールする際に、「apt-get update」と「apt-get install」という命令をセットで使います。この時、「apt-get update」と「apt-get install」が同一のRUNコマンド内にで同時実行する必要があります。分けて記述してしまうと、「apt-get update」の結果がキャッシュされ、その後「apt-get install」を実行してもキャッシュされている古いパッケージがインストールされてしまう「キャッシュの不整合」がおこるためです。

例を見てみましょう。
以下は、「Dockerfileのベストプラクティス」で紹介されている記述例です。

# --- 非推奨の記述1 ---
FROM ubuntu:22.04
RUN apt-get update
RUN apt-get install -y curl

上のDockerfileからイメージをビルドしたとします。初回のビルドは、何も問題なく成功します。
しかし、後日、別のパッケージを追加したくなって先のDockerfileを以下のように変更したとします。

# --- 非推奨の記述2 ---
FROM ubuntu:22.04
RUN apt-get update
RUN apt-get install -y curl git

一見問題ないように見えますが、「apt-get update」の記述されたRUNコマンドに変更がないため、Dockerは以前の命令と同一だと判断し、キャッシュを利用してしまいます。キャッシュされた古い情報を基に「apt-get install -y curl」を実行してしまうため、インストールエラーが発生し、ビルドが失敗してしまいます。

このような事態を防ぐためには、「apt-get update」と「apt-get install -y」命令を同一のRUNコマンドに記述します。同一のRUNコマンドの記述することで、一続きの命令として1つのレイヤーを形成します。そのため、install対象を変更した際にも、キャッシュを利用せず「update」から新規実行されます。

--- 推奨の記述 ---
# 常に最新のリストでインストールされる
RUN apt-get update && apt-get install -y \
    curl \
    git \
    && rm -rf /var/lib/apt/lists/*

最後の列の「&& rm -rf /var/lib/apt/lists/*」はaptキャッシュをクリーンアップして、イメージサイズを軽量化するための記述です。

2-3. ADD と COPY

ファイルをコピーする際は、ADDコマンドではなく、COPYコマンドを使うことが推奨されます。

ADDコマンドとCOPYコマンドは似た働きをしますが、その仕組みは異なります。
COPYコマンドの機能は、ファイルをコピーするとこと。一方のADDコマンドは、ファイルのコピーに加えて、tarアーカイブの自動展開や、リモートURLからの取得といった追加機能を持っています。

ファイルをコピーする際は、原則としてCOPYコマンドを使用し、リモートURLからのダウンロードや、tarファイルの自動展開が必要な場合に限りADDコマンドを使用します。

2-4. USER

セキュリティ上の観点から、rootユーザーでの実行は推奨されていません。作業用のユーザーを設定し、ユーザーを切り替えたうえで実行することが推奨されます。

2-5. WORKDIR

コンテナ内に作業用ディレクトリを設定する場合は、WORKDIRコマンドで絶対パスを指定します。

3. ベストプラクティスに則ってリファクタリング

では、実際にベストプラクティスに則ってDockerfileを記載していきましょう。
例として、以下のシンプルなDockerfileをリファクタリングしていきます。
以下は、Javaファイルを実行するためのイメージを生成するDockerfileです。

# --- リファクタリング前 ---
FROM eclipse-temurin:21-jdk-alpine

WORKDIR /app

COPY Main.java .

RUN javac Main.java

CMD ["java", "Main"]

上の記述を見ると、シングルステージでビルドしています。マルチステージ・ビルドに変更することで、イメージデータを軽量化できそうです。
また、rootユーザーのまま実行しているため、セキュリティ上のリスクがあります。
以上の点を変更した状態が、以下になります。

# --- リファクタリング後 ---
# --- ステージ1:ビルド用 ---
FROM eclipse-temurin:21-jdk-alpine AS builder

WORKDIR /app

COPY Main.java .

# コンパイルを実行
RUN javac Main.java


# --- ステージ2:実行用 ---
# 実行には JDK ではなく、より軽量な JRE を使用
FROM eclipse-temurin:21-jre-alpine

WORKDIR /app

# セキュリティのため、実行用のユーザーを作成
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser

# ビルドステージからコンパイル済みの .class ファイルだけをコピー
COPY --from=builder /app/Main.class .

# 実行コマンド
CMD ["java", "Main"]

作成したイメージサイズは、以下の通りです。

スクショ画像

singlestageイメージ(リファクタリング前)と比べ、multistageイメージ(リファクタリング後)のほうがイメージサイズが半分になっていることが、わかります。
マルチステージ・ビルドによって、重いJDKを含まないイメージを生成し、軽量化に成功しています。

また、RUNコマンドで実行用ユーザーを設定し、USERを実行用ユーザーに切り替えているため、rootユーザーでの実行を回避し、セキュリティリスクを軽減しています。

このように、Dockerfileのベストプラクティスを守ることで、より軽量で、より安全なイメージをビルドすることができるのです。

まとめ

  • Dockerfileは単なるビルド手順ではなく、ビルド速度・イメージサイズ・セキュリティを左右する設計要素である

  • .dockerignore を適切に設定することで、不要なファイル転送を防ぎ、ビルドの高速化とキャッシュ効率の向上が実現できる

  • マルチステージ・ビルドを活用することで、ビルド環境と実行環境を分離し、軽量で安全なDockerイメージを作成できる

  • レイヤー構造とビルドキャッシュの仕組みを理解したうえでDockerfileを記述することが、CI/CD環境でのビルド効率向上につながる

  • 非rootユーザーでの実行や不要なパッケージの排除により、攻撃対象領域を最小化し、セキュアなコンテナ運用が可能となる

  • ベストプラクティスを意識したDockerfile設計は、開発環境から本番環境まで一貫した再現性と保守性を確保するうえで重要である


記事は以上です。
最後までお読みいただき、ありがとうございました。

参考情報一覧

この記事は以下の情報を参考にして執筆しました。

GitHubで編集を提案

Discussion