実際のプロジェクトでDockerを使い始めた頃、StackOverflowからDockerfileをコピーしてそのまま動かすだけだった。ビルドできて、コンテナが動けばそれでよし、という感じで。でも時間が経つにつれ、イメージは1.2GBになり、CIへのプッシュごとにビルドに8分かかるようになって、チームメンバーから不満が出始めた。そこでようやく、Dockerfileについて本腰を入れて調べることにした。
振り返ると、当時は基本的なミスを山ほど犯していた。node_modulesディレクトリごとイメージにコピーしたり、.dockerignoreを忘れてビルドコンテキストが800MBになったり、一つのRUNでパッケージをインストールして別のRUNでキャッシュを削除すればイメージが小さくなると思い込んでいたり。この記事では、何度もビルドに失敗して学んだことを、文字通り「失敗の経験」として共有したい。
Dockerfileは内部でどう動いているのか?
レイヤーの仕組みを理解すれば、最適化の方向性が自然と見えてくる。Dockerfile内の各命令(RUN、COPY、ADDなど)は新しいレイヤーを作成する。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には-slim、kubectl 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 scoutやtrivyを使って定期的にCVEをスキャンし、意図的にアップデートするようにしよう。
まとめ
理論より「痛い目を見たから」こそ身に染みていること:
- レイヤーは変更頻度の低い順から高い順に並べる
- コンパイル型言語にはマルチステージビルドを使う
- 最初のビルド前に必ず
.dockerignoreを用意する RUNコマンドはまとめて、同じレイヤー内でキャッシュを削除する- プロダクションでは非rootユーザーで実行する
- ベースイメージのバージョンを固定し、
latestは使わない
プロジェクトによってトレードオフは異なる——イメージが小さくなってもデバッグが難しくなったり、ビルドが速くなっても設定が複雑になったりすることもある。大切なのは、テンプレートを盲目的にコピーしていた昔の自分と違い、「なぜそうするのか」を理解することだ。
自分のDockerfileを確認したいなら、docker history <image_name>を実行して各レイヤーの重さを見てみよう——大抵、最も重い箇所に最適化すべき問題がすぐに見えてくる。
