効率的なDockerfileの書き方:イメージサイズとビルド時間を最適化するコツ

Docker tutorial - IT technology blog
Docker tutorial - IT technology blog

実際のプロジェクトでDockerを使い始めた頃、StackOverflowからDockerfileをコピーしてそのまま動かすだけだった。ビルドできて、コンテナが動けばそれでよし、という感じで。でも時間が経つにつれ、イメージは1.2GBになり、CIへのプッシュごとにビルドに8分かかるようになって、チームメンバーから不満が出始めた。そこでようやく、Dockerfileについて本腰を入れて調べることにした。

振り返ると、当時は基本的なミスを山ほど犯していた。node_modulesディレクトリごとイメージにコピーしたり、.dockerignoreを忘れてビルドコンテキストが800MBになったり、一つのRUNでパッケージをインストールして別のRUNでキャッシュを削除すればイメージが小さくなると思い込んでいたり。この記事では、何度もビルドに失敗して学んだことを、文字通り「失敗の経験」として共有したい。

Dockerfileは内部でどう動いているのか?

レイヤーの仕組みを理解すれば、最適化の方向性が自然と見えてくる。Dockerfile内の各命令(RUNCOPYADDなど)は新しいレイヤーを作成する。Dockerはこれらのレイヤーをキャッシュし、レイヤーが変更されていなければ、次回のビルドではコマンドを再実行せずにキャッシュを使用する。

問題は、あるレイヤーが変更されると、それ以降のすべてのレイヤーが無効化されることだ。1行コードを変えただけでビルドが遅くなる原因はここにある。

命令を正しい順序で並べる

常に意識しているルールがある:変更頻度の低いものほど上に配置する。

悪い例——これは最初の頃によく書いていたNode.jsアプリのDockerfileのパターンだ:

# 悪い例:依存関係のインストール前にコードをコピーしている
FROM node:20-alpine
WORKDIR /app
COPY . .
RUN npm install
CMD ["node", "server.js"]

問題点:コードを少し修正するだけ——コメント1行変えても——COPY . .レイヤーが変わるためnpm installが最初からやり直しになる。依存関係が数百個あるプロジェクトでは、毎回2〜5分かかる。

正しいアプローチ:依存関係のインストール部分を先に分離する:

# 良い例:パッケージファイルを先にコピーしてインストール、その後コードをコピー
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
CMD ["node", "server.js"]

これでnpm ciが再実行されるのはpackage.jsonまたはpackage-lock.jsonが変更された時だけになる。通常のコード修正ではこのステップがキャッシュされ、ビルドが明らかに速くなる。

マルチステージビルド——イメージサイズ削減の切り札

この技術を初めて見たとき、こんなにイメージが小さくなれるとは信じられなかった。原理はシンプルだ:重いイメージでビルドを行い、最終イメージには必要なアーティファクトだけをコピーして、コンパイラやツールチェーン、中間ファイルはすべて捨てる。

Goアプリケーションの例:

# ステージ1:ビルド
FROM golang:1.22-alpine 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 alpine:3.19
RUN apk --no-cache add ca-certificates
WORKDIR /root/
COPY --from=builder /app/main .
CMD ["./main"]

golang:1.22-alpineイメージは約300MB。最終イメージはコンパイル済みバイナリとalpineベースのみ——合計約15〜20MBだ。10分の1以上の削減になる。

同じ技術はPython(wheelをビルドしてslimイメージにコピー)、Java(Mavenビルド後にJARをコピー)、React(npmビルド後にdistをnginxにコピー)にも応用できる。

適切なベースイメージを選ぶ

ubuntu:latestはなじみ深い選択肢だが、最も重い選択肢でもある。UbuntuベースイメージはプロダクションでほぼΩ必要ない多くのツールも含め、70〜80MB程度になる。

  • Alpine Linux-alpine):最も軽量(約5MB)、glibcの代わりにmusl libcを使用——一部のCライブラリで問題が生じることがある
  • Debian Slim-slim):サイズと互換性のバランスが良い(約30〜80MB)
  • Distroless(Google):シェルもパッケージマネージャも持たない——セキュリティは最高だが、デバッグが難しい

おすすめ:Go/Nodeには-alpine、ネイティブライブラリが必要なPythonには-slimkubectl execやサイドカーコンテナでのデバッグに慣れているチームのプロダクション環境にはdistrolessを使おう。

.dockerignoreを忘れずに

.dockerignoreファイルを作らないのは、Dockerfileをレビューする際に初心者に最もよく見られるミスだ。このファイルは、Dockerがビルドコンテキストをデーモンに送る前に不要なディレクトリやファイルを除外する——これがないと、node_modules.gitディレクトリも含め、プロジェクトディレクトリ内のすべてが送信される。

Node.jsプロジェクト向けの.dockerignoreの例:

node_modules
npm-debug.log
.git
.gitignore
.env
*.md
dist
.dockerignore
Dockerfile
.DS_Store

このファイルがないと、コンテナ内でnpm installを実行するにもかかわらず、Dockerはビルドのたびにnode_modules(数百MBになることもある)をデーモンに送り続ける。自分もこのミスをやらかして、ビルドコンテキストが800MBになり、毎回のビルドでコンテキストのアップロードだけに30〜40秒余計にかかっていた。

実践:RUNコマンドをまとめてキャッシュを削除する

RUNごとにレイヤーが作られる。パッケージをインストールした後、別のRUNコマンドでキャッシュを削除しても、キャッシュは前のレイヤーに残ったまま——イメージは少しも小さくならない。

# 悪い例:別のレイヤーでキャッシュを削除しても効果がない
RUN apt-get update
RUN apt-get install -y curl git
RUN rm -rf /var/lib/apt/lists/*

# 良い例:1つのRUNにまとめ、同じレイヤー内でキャッシュを削除する
RUN apt-get update && apt-get install -y \
    curl \
    git \
    && rm -rf /var/lib/apt/lists/*

Alpineの場合はこう使う:

RUN apk add --no-cache curl git

apk--no-cacheフラグは最初からキャッシュインデックスを作成しないので、よりすっきりしている。

非rootユーザーでコンテナを実行する

デフォルトではコンテナはrootユーザーで実行される。アプリに脆弱性があった場合、攻撃者はコンテナ内でroot権限を得る——ホストの設定によっては、外部へのエスケープも可能になる。これは理論上の話ではない:CVE-2019-5736(runcエスケープ)は、コンテナ内のroot権限がホストのroot権限につながった実際の事例だ。Dockerfileに以下を追加しよう:

FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .

# 専用ユーザーを作成する
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser

CMD ["node", "server.js"]

バージョンを固定する——latestは使わない

latestタグは時間とともに変わる。ベースイメージが更新されれば、今日のビルドと3ヶ月後のビルドが全く異なる結果になる可能性がある。

# 避けるべき書き方
FROM python:latest

# 推奨:具体的なバージョンを固定する
FROM python:3.12.3-slim-bookworm

バージョンを固定したら、docker scouttrivyを使って定期的にCVEをスキャンし、意図的にアップデートするようにしよう。

まとめ

理論より「痛い目を見たから」こそ身に染みていること:

  1. レイヤーは変更頻度の低い順から高い順に並べる
  2. コンパイル型言語にはマルチステージビルドを使う
  3. 最初のビルド前に必ず.dockerignoreを用意する
  4. RUNコマンドはまとめて、同じレイヤー内でキャッシュを削除する
  5. プロダクションでは非rootユーザーで実行する
  6. ベースイメージのバージョンを固定し、latestは使わない

プロジェクトによってトレードオフは異なる——イメージが小さくなってもデバッグが難しくなったり、ビルドが速くなっても設定が複雑になったりすることもある。大切なのは、テンプレートを盲目的にコピーしていた昔の自分と違い、「なぜそうするのか」を理解することだ。

自分のDockerfileを確認したいなら、docker history <image_name>を実行して各レイヤーの重さを見てみよう——大抵、最も重い箇所に最適化すべき問題がすぐに見えてくる。

Share: