はじめに

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言語のアプリケーションを例に、マルチステージビルドの基本パターンを見てみましょう。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# ステージ1: ビルドステージ
FROM golang:1.24 AS builder

WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o main .

# ステージ2: 実行ステージ
FROM scratch

COPY --from=builder /app/main /main
CMD ["/main"]

ポイントは以下のとおりです。

  • AS builderでステージに名前を付ける
  • ビルドステージでコンパイルを完了する
  • COPY --from=builderで必要な成果物のみを実行ステージにコピーする
  • 実行ステージには軽量なベースイメージ(この例ではscratch)を使用する

サイズ比較

単一ステージとマルチステージでイメージサイズを比較してみましょう。

構成 ベースイメージ 最終サイズ
単一ステージ golang:1.24 約850MB
マルチステージ scratch 約10MB

マルチステージビルドにより、イメージサイズを99%近く削減できる場合もあります。

Node.jsでのマルチステージビルド例

コンパイル言語だけでなく、Node.jsのようなインタプリタ言語でもマルチステージビルドは有効です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
# ステージ1: ビルドステージ
FROM node:22-alpine AS builder

WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# ステージ2: 実行ステージ
FROM node:22-alpine AS runner

ENV NODE_ENV=production
WORKDIR /app

COPY --from=builder /app/package*.json ./
RUN npm ci --only=production
COPY --from=builder /app/dist ./dist

USER node
EXPOSE 3000
CMD ["node", "dist/index.js"]

開発用の依存関係(devDependencies)やビルドツールは最終イメージに含まれず、本番環境に必要なファイルのみがコピーされます。

ステージの再利用

複数のイメージで共通するステージがある場合は、共通ステージを定義して再利用できます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# 共通ベースステージ
FROM node:22-alpine AS base
WORKDIR /app
COPY package*.json ./

# 開発ステージ
FROM base AS development
RUN npm install
COPY . .
CMD ["npm", "run", "dev"]

# 本番ビルドステージ
FROM base AS builder
RUN npm ci
COPY . .
RUN npm run build

# 本番実行ステージ
FROM node:22-alpine AS production
ENV NODE_ENV=production
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
USER node
CMD ["node", "dist/index.js"]

--targetオプションで特定のステージをビルドできます。

1
2
3
4
5
# 開発用イメージをビルド
docker build --target development -t myapp:dev .

# 本番用イメージをビルド
docker build --target production -t myapp:prod .

レイヤーキャッシュ最適化のコツ

Dockerはビルド時にレイヤー単位でキャッシュを作成します。キャッシュを効果的に活用することで、ビルド時間を大幅に短縮できます。

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

Dockerfileの各命令はイメージレイヤーを生成します。ビルド時に、Docker(BuildKit)は各レイヤーのキャッシュが有効かどうかをチェックし、変更がなければキャッシュを再利用します。

命令1 (FROM)     --> キャッシュあり --> 再利用
命令2 (COPY)     --> キャッシュあり --> 再利用
命令3 (RUN)      --> 変更あり     --> 再ビルド
命令4以降        --> キャッシュ無効 --> すべて再ビルド

重要なのは、あるレイヤーでキャッシュが無効になると、それ以降のすべてのレイヤーも再ビルドされるという点です。

キャッシュを活かす命令の順序

キャッシュを最大限活用するには、変更頻度の低い命令を先に、変更頻度の高い命令を後に配置します。

悪い例(ソースコード変更のたびに依存関係も再インストール):

1
2
3
4
5
FROM node:22-alpine
WORKDIR /app
COPY . .
RUN npm ci
CMD ["node", "index.js"]

良い例(依存関係のキャッシュを活用):

1
2
3
4
5
6
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
CMD ["node", "index.js"]

良い例では、package.jsonが変更されない限りnpm ciのレイヤーがキャッシュされ、ソースコードの変更だけでは依存関係の再インストールが発生しません。

apt-getの最適化

Debian/Ubuntuベースのイメージでは、apt-getの使い方がキャッシュ効率とイメージサイズに影響します。

推奨パターン:

1
2
3
4
5
RUN apt-get update && apt-get install -y --no-install-recommends \
    curl \
    git \
    vim \
    && rm -rf /var/lib/apt/lists/*

ポイントは以下のとおりです。

  • apt-get updateapt-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を使用すると、パッケージマネージャのキャッシュをビルド間で共有できます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# syntax=docker/dockerfile:1

FROM node:22-alpine

WORKDIR /app
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm \
    npm ci
COPY . .
CMD ["node", "index.js"]

--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例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
FROM node:22-alpine

RUN apk add --no-cache \
    curl \
    tzdata

WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .

USER node
CMD ["node", "index.js"]

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):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
FROM golang:1.24 AS builder

WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /app/main .

FROM gcr.io/distroless/static-debian12:nonroot

COPY --from=builder /app/main /main
ENTRYPOINT ["/main"]

nonrootタグを使用することで、デフォルトでnon-rootユーザー(UID 65532)として実行されます。

デバッグ用イメージ

distrolessにはデバッグ用のイメージも用意されています。

1
2
3
4
5
# 本番用
FROM gcr.io/distroless/static-debian12:nonroot

# デバッグ用(BusyBoxシェルを含む)
FROM gcr.io/distroless/static-debian12:debug-nonroot

本番では通常の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ユーザーを作成して使用する例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
FROM node:22-alpine

# non-rootユーザーとグループを作成
RUN addgroup -g 1001 -S appgroup && \
    adduser -u 1001 -S appuser -G appgroup

WORKDIR /app
COPY --chown=appuser:appgroup package*.json ./
RUN npm ci --only=production
COPY --chown=appuser:appgroup . .

# non-rootユーザーに切り替え
USER appuser

EXPOSE 3000
CMD ["node", "index.js"]

ポイントは以下のとおりです。

  • 明示的なUID/GIDを指定することで、イメージ再ビルド間でIDの一貫性を保つ
  • --chownオプションでファイルの所有者を設定する
  • USER命令でプロセス実行ユーザーを切り替える

公式イメージには、あらかじめnon-rootユーザーが定義されているものもあります。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# nodeイメージには"node"ユーザーが定義済み
FROM node:22-alpine

WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .

USER node
CMD ["node", "index.js"]

ファイルシステムを読み取り専用にする

コンテナのファイルシステムを読み取り専用にすることで、マルウェアの書き込みを防止できます。

1
docker run --read-only --tmpfs /tmp myapp:latest

Dockerfileでは、アプリケーションが書き込みを必要としないディレクトリ構造になっているか確認しましょう。

機密情報の取り扱い

Dockerイメージに機密情報(パスワード、APIキーなど)を含めてはいけません。

悪い例:

1
2
3
# 機密情報がイメージに残る
ENV DATABASE_PASSWORD=mysecretpassword
COPY secrets.txt /app/

良い例(ビルド時のシークレット):

1
2
3
4
5
6
7
# syntax=docker/dockerfile:1

FROM alpine

# シークレットはマウントされるが、レイヤーには残らない
RUN --mount=type=secret,id=db_password \
    cat /run/secrets/db_password > /dev/null
1
docker build --secret id=db_password,src=./db_password.txt .

本番環境では、Kubernetes Secretsや外部のシークレット管理サービス(HashiCorp Vault等)を使用することを推奨します。

不要なパッケージを含めない

不要なパッケージは攻撃対象を広げるだけでなく、イメージサイズも増加させます。

1
2
3
RUN apt-get update && apt-get install -y --no-install-recommends \
    必要なパッケージのみ \
    && rm -rf /var/lib/apt/lists/*

--no-install-recommendsオプションで推奨パッケージのインストールを抑制し、本当に必要なパッケージのみをインストールします。

イメージの脆弱性スキャン

Docker ScoutやTrivyなどのツールを使用して、イメージの脆弱性を定期的にスキャンしましょう。

1
2
3
4
5
# Docker Scoutでスキャン
docker scout cves myapp:latest

# Trivyでスキャン
trivy image myapp:latest

CI/CDパイプラインにスキャンを組み込むことで、脆弱性のあるイメージがデプロイされることを防止できます。

ベースイメージのバージョン固定

タグは可変であるため、latest3.21のようなタグだけでは、意図しないバージョンがpullされる可能性があります。

推奨パターン:

1
2
# ダイジェストでバージョンを固定
FROM node:22-alpine@sha256:c13b26e7e602ef2f1074aef304ce6e9b7dd284c419b35d89fcf3cc8e44a8def9

ダイジェストを使用することで、完全に同一のイメージが使用されることが保証されます。ただし、セキュリティアップデートを受け取るために、定期的にダイジェストを更新する運用が必要です。

実践:最適化前後の比較

これまでのベストプラクティスを適用した前後のDockerfileを比較してみましょう。

最適化前

1
2
3
4
5
6
7
8
FROM node:22

WORKDIR /app
COPY . .
RUN npm install

EXPOSE 3000
CMD ["npm", "start"]

問題点:

  • フルサイズのnodeイメージを使用(約1GB)
  • devDependenciesもインストールされる
  • レイヤーキャッシュが効率的に使われない
  • rootユーザーで実行される

最適化後

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# syntax=docker/dockerfile:1

# ビルドステージ
FROM node:22-alpine AS builder

WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# 実行ステージ
FROM node:22-alpine AS runner

ENV NODE_ENV=production
WORKDIR /app

# non-rootユーザーで実行
USER node

COPY --from=builder --chown=node:node /app/package*.json ./
RUN npm ci --only=production
COPY --from=builder --chown=node:node /app/dist ./dist

EXPOSE 3000
CMD ["node", "dist/index.js"]

改善点:

  • Alpineベースで軽量化
  • マルチステージビルドでビルド成果物のみを含む
  • npm ci --only=productionで本番依存関係のみをインストール
  • non-rootユーザー(node)で実行
  • レイヤーキャッシュを効率的に活用

サイズ・セキュリティ比較

項目 最適化前 最適化後
イメージサイズ 約1.2GB 約150MB
CVE検出数 高(多数のパッケージ含む) 低(最小構成)
実行ユーザー root node(非root)
ビルド時間(キャッシュあり) 遅い 高速

まとめ

本記事では、Dockerfileのベストプラクティスとして、以下の内容を解説しました。

  • マルチステージビルド: ビルド環境と実行環境を分離し、最終イメージを大幅に軽量化する
  • レイヤーキャッシュ最適化: 変更頻度を意識した命令の順序づけと.dockerignoreの活用で、ビルド時間を短縮する
  • ベースイメージ選定: Alpine、distroless、scratchなど用途に応じた選択で、サイズとセキュリティを最適化する
  • セキュリティベストプラクティス: non-rootユーザー実行、機密情報の適切な取り扱い、脆弱性スキャンの導入

これらのベストプラクティスを適用することで、本番環境でも安心して使用できる、軽量・高速・安全なDockerイメージを作成できます。次のステップとして、言語やフレームワークごとのDockerfile最適化パターンを学ぶことで、さらに実践的なスキルを身につけることができます。

参考リンク