Docker イメージのレイヤー構造を理解する

概要

Docker を使い始めると、docker build のログに CACHED が並んだり、Dockerfile の書き方を少し変えるだけでビルド時間が大きく変わったりする場面に出会います。その背景にあるのが「イメージのレイヤー構造」です。

この記事では、Docker イメージがどのように積み重なっているのか、なぜレイヤーという仕組みなのか、そしてその理解がビルド時間やイメージサイズの最適化にどう直結するのかを、入門者向けに整理します。

レイヤーとは何か

Docker イメージは単一の巨大なファイルではなく、読み取り専用のレイヤーを積み重ねた構造になっています。各レイヤーは「前のレイヤーからの差分(ファイルシステムの変更点)」であり、Dockerfile の1命令(RUN / COPY / ADD など)が原則として1つのレイヤーを生み出します。

FROM python:3.12-slim        # ベースイメージのレイヤー群
WORKDIR /app                 # メタデータ中心
COPY requirements.txt .      # レイヤー: requirements.txt を追加
RUN pip install -r requirements.txt  # レイヤー: 依存を追加
COPY . .                     # レイヤー: アプリ本体を追加
CMD ["python", "main.py"]

このイメージは、下から順にレイヤーが積み重なったスタックとしてできあがります。

レイヤーを実際に見てみる

docker history で、イメージがどんなレイヤーで構成されているかを確認できます。

docker build -t layer-demo .
docker history layer-demo

出力例(抜粋・整形):

IMAGE     CREATED BY                                 SIZE
<hash>    CMD ["python" "main.py"]                   0B
<hash>    COPY . . # buildkit                        12.3kB
<hash>    RUN pip install -r requirements.txt        45.2MB
<hash>    COPY requirements.txt . # buildkit         210B
<hash>    WORKDIR /app                               0B
<missing> ...(ベースイメージ由来)                     ...

各行が1レイヤーに対応し、どの命令がどれだけのサイズを生んでいるかが一目でわかります。

なぜレイヤー構造なのか — 2つの利点

1. キャッシュによるビルド高速化

Docker はビルド時、各命令の結果をレイヤーとしてキャッシュします。「その命令と、それより前の状態が変わっていない」場合、キャッシュを再利用して CACHED と表示し、処理をスキップします。

重要なのは、あるレイヤーが変わると、それ以降のレイヤーのキャッシュはすべて無効になるという点です。レイヤーは差分の積み重ねなので、途中が変われば上流も作り直しになります。

2. レイヤーの共有によるストレージ・転送の節約

同一ダイジェストのレイヤーは複数のイメージ間で共有されます。同じベースイメージを使う複数のアプリをビルドしても、ベース部分のレイヤーはディスク上に1つだけ持てばよく、docker pull / docker push でも既に持っているレイヤーは転送されません。

キャッシュを効かせる書き方

この仕組みを踏まえると、変更頻度の低いものを先(下)に、高いものを後(上)に置くのが定石になります。

悪い例:

FROM python:3.12-slim
WORKDIR /app
COPY . .                             # ソースを丸ごと先にコピー
RUN pip install -r requirements.txt  # ソースが変わるたびに再実行される
CMD ["python", "main.py"]

アプリのソースを少し直しただけでも COPY . . のレイヤーが変わり、その後の pip install のキャッシュまで無効化されます。依存が変わっていなくても毎回インストールが走ってしまいます。

良い例:

FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .              # 依存定義だけ先にコピー
RUN pip install -r requirements.txt  # requirements.txt が変わらない限りキャッシュされる
COPY . .                             # ソース変更はこのレイヤー以降だけに影響
CMD ["python", "main.py"]

依存定義を先にコピーして依存インストールを済ませ、頻繁に変わるアプリ本体を最後にコピーします。これで、ソースだけの変更なら重い依存インストールがキャッシュされ、ビルドが大幅に速くなります。

イメージサイズを小さくする

レイヤーは差分の積み重ねなので、途中で作ったファイルを後のレイヤーで削除しても、サイズは減りません。削除は「削除したという差分レイヤー」を積むだけで、元のファイルは下のレイヤーに残り続けます。

# これはサイズ削減にならない
RUN wget https://example.com/big-archive.tar.gz
RUN tar xf big-archive.tar.gz
RUN rm big-archive.tar.gz    # 別レイヤーで消しても元ファイルは残る

同じ命令の中で完結させれば、そのレイヤー内に大きな一時ファイルを残さずに済みます。

RUN wget https://example.com/big-archive.tar.gz \
    && tar xf big-archive.tar.gz \
    && rm big-archive.tar.gz

パッケージマネージャのキャッシュも同一 RUN 内で削除します。

RUN apt-get update \
    && apt-get install -y --no-install-recommends curl \
    && rm -rf /var/lib/apt/lists/*

より根本的な削減にはマルチステージビルドが有効です。ビルド専用ステージで生成物を作り、実行用ステージには成果物だけをコピーすることで、コンパイラや中間ファイルを最終イメージから排除できます。

# ビルドステージ
FROM golang:1.22 AS builder
WORKDIR /src
COPY . .
RUN go build -o /app/server .

# 実行ステージ(最終イメージ)
FROM gcr.io/distroless/base-debian12
COPY --from=builder /app/server /server
ENTRYPOINT ["/server"]

確認方法

  • docker history <image> … レイヤーごとのサイズと生成コマンドを確認
  • docker build のログで CACHED の有無を確認
  • docker image ls … 最終的なイメージサイズを比較
  • 悪い例/良い例を用意し、ソースだけを変更して2回ビルドし、2回目のビルド時間の差を体感する

注意点

  • .dockerignore を用意しないと COPY . . が .git や不要ファイルまで取り込み、キャッシュが無駄に壊れたりサイズが膨らんだりする
  • WORKDIR / ENV などメタデータ中心の命令は実質的なサイズ差分がほぼないこともある
  • BuildKit(近年のデフォルト)ではキャッシュや並列ビルドの挙動が賢くなっているが、「変更頻度の低いものを先に」の原則は変わらない

まとめ

  • Docker イメージは読み取り専用の差分レイヤーの積み重ねである
  • レイヤー構造ゆえに、キャッシュによる高速化とレイヤー共有による節約が効く
  • あるレイヤーが変わると、それ以降のレイヤーのキャッシュは無効になる
  • 「変更頻度の低いものを先に、高いものを後に」がキャッシュ最適化の基本
  • 途中で削除してもサイズは減らない。同一 RUN 内で完結させるか、マルチステージビルドを使う

レイヤー構造を一度理解すると、Dockerfile を書くときの判断基準が明確になります。まずは docker history を眺めるところから始めてみてください。

\ 最新情報をチェック /

コメントを残す

このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください。