同じイメージからコンテナを 10 個起動したのに、なぜディスクは 10 倍にならないのか?
数百 MB のイメージから docker run でコンテナを 10 個立ち上げても、ディスクの空きはほとんど減らない。一方で Dockerfile に RUN rm を足して巨大ファイルを消したのに、イメージは 1 バイトも小さくならない。この 2 つの不思議は、どちらも「コンテナのファイルシステムは複数のディレクトリを重ねて見せているだけ」という同じ仕組みから生まれている。
- イメージは読み取り専用の「差分レイヤー」の積み重ねで、同じイメージから作ったコンテナは全員がそのレイヤーを共有し、各自は薄い書き込み層だけを持つ
- OverlayFS が下層(lowerdir)と書き込み層(upperdir)を 1 つのディレクトリ(merged)に重ねて見せ、既存ファイルへの初回書き込み時だけファイル丸ごとを上にコピーする(copy-up)
- 削除は下層を消さず「無いことにする印(whiteout)」を上に置くだけなので、後のレイヤーで消してもイメージは縮まない。書き込み層はコンテナ削除で消えるため、永続データや書き込みの多いデータはボリュームへ逃がす
イメージは「差分の積み重ね」でできている
コンテナイメージは 1 つの大きなファイルではなく、レイヤーの積み重ねである。レイヤーとは、Dockerfile の命令ごとに「直前の状態から何が増えた・変わった・消えたか」だけを記録したファイル群のことだ。FROM alpine のベース、RUN apk add で増えたファイル、COPY で置いたアプリのコード、とそれぞれが別のレイヤーになる。
レイヤーは一度作られたら読み取り専用(RO)で、後から書き換えられることはない。書き換えないと決めているからこそ、同じ内容のレイヤーをディスク上に 1 つだけ置き、複数のイメージやコンテナから安全に参照できる。docker pull で「Already exists」と表示されて一部のレイヤーがダウンロードされないのも、手元に同じレイヤーがすでにあるからだ。
コンテナを起動すると、Docker はこの読み取り専用レイヤーの山の一番上に、そのコンテナ専用の空の書き込み層(コンテナ層)を 1 枚だけ足す。コンテナ内での変更はすべてこの層に入り、下のイメージレイヤーは変わらない。10 個起動しても増えるのは 10 枚の薄い書き込み層だけなので、ディスクは 10 倍にならない。これが lead の 1 つ目の問いへの答えである。
OverlayFS ―― ディレクトリを重ねて 1 枚に見せる
では、別々に置かれた複数のレイヤーが、コンテナの中からはなぜ普通の 1 つの / に見えるのか。これを担うのが Linux カーネルの OverlayFS で、複数のディレクトリを上下に重ね、1 つのディレクトリとして見せるファイルシステムである。読み取り専用の下層を lowerdir、書き込みを受ける上層を upperdir、重ねた結果見える場所を merged と呼ぶ(workdir は OverlayFS が内部処理に使う作業用ディレクトリ)。
container A container B
┌────────────┐ ┌────────────┐
│ upper (RW) │ │ upper (RW) │
└────────────┘ └────────────┘
│ │
└─────────┐ ┌────────┘
▼ ▼
┌──────────────────────────────┐
│ layer3: COPY app/ (RO) │
├──────────────────────────────┤
│ layer2: RUN apk add (RO) │
├──────────────────────────────┤
│ layer1: alpine base (RO) │
└──────────────────────────────┘
lowerdir: 全コンテナで共有
ファイルを読むとき、OverlayFS は上の層から順に探し、最初に見つかったものを返す。同じ名前のファイルが上下両方にあれば、上層(コンテナ層)のものが下層のものを覆い隠す。中身を手で組み立てるなら、カーネルのドキュメントにある次の 1 行と同じことを Docker がやっている。
mount -t overlay overlay \ -olowerdir=/lower,upperdir=/upper,workdir=/work /merged
なお Docker Engine 29.0 以降の新規インストールでは、イメージの保存は従来の overlay2 ストレージドライバではなく containerd のイメージストアが既定になり、その中で overlayfs スナップショッタが使われる。管理する主体は変わったが、OverlayFS で層を重ねるという仕組み自体は同じだ。自分の環境がどちらかは docker info -f '{{ .DriverStatus }}' で確認でき、containerd 側なら io.containerd.snapshotter.v1 と表示される。
全員で共有する 1 冊の原本と、各自に配られた透明なフィルムに近い。原本には誰も書き込めない。あるページを直したい人は、そのページを丸ごとコピーして自分のフィルムに貼り、そこに書き込む(copy-up)。ページを「消したい」ときは原本を破るのではなく、フィルムに「このページは無い」と書いた黒い紙を貼る(whiteout)。だから原本は 1 冊のままで、人数分増えるのはフィルムだけだし、黒い紙を何枚貼っても原本は 1 グラムも軽くならない。
書き込みと削除 ―― copy-up と whiteout
下層にあるファイルを初めて書き換えるとき、OverlayFS はそのファイルをまず上層へコピーし、コピーの方を書き換える。これを copy-up と呼ぶ。OverlayFS はブロック単位ではなくファイル単位で動くため、1 GB のファイルの末尾に 1 バイト追記するだけでも、最初の 1 回はファイル全体のコピーが走る。2 回目以降の書き込みはすでに上層にあるコピーへ直接行われる。権限や所有者の変更(chmod / chown)でも copy-up が起きる。
操作 upper で起きること read a.txt 何もしない(下層を読む) write a.txt a.txt を丸ごと複製して書換 rm b.txt b.txt の whiteout を作る
削除はさらに意外な動きをする。下層は読み取り専用なので、ファイルを本当に消すことはできない。代わりに上層へwhiteout(カーネルの実装ではデバイス番号 0/0 のキャラクタデバイス)を置き、「この名前は存在しないことにする」と OverlayFS に伝える。ディレクトリ丸ごとの削除では、上層のディレクトリに opaque(不透明)の印を付けて下層の中身を隠す。
ここで lead の 2 つ目の問いが回収できる。Dockerfile で RUN を 2 行に分けて「巨大ファイルを置く」「それを消す」と書くと、1 つ目のレイヤーには巨大ファイルが残り、2 つ目のレイヤーには whiteout が載るだけだ。消したように見えるのは重ねた結果の見え方であって、イメージにはファイルの実体が残り続ける。サイズを減らしたければ、取得と削除を同じ RUN 内で行うか、マルチステージビルドで成果物だけを最終イメージに COPY する必要がある。
# NG: 1 つ目のレイヤーに実体が残る RUN curl -o /tmp/sdk.tgz https://example.com/sdk.tgz RUN tar xzf /tmp/sdk.tgz -C /opt && rm /tmp/sdk.tgz # OK: 同じレイヤー内で消すので実体が残らない RUN curl -o /tmp/sdk.tgz https://example.com/sdk.tgz \ && tar xzf /tmp/sdk.tgz -C /opt && rm /tmp/sdk.tgz
コンテナ層に書いてはいけないもの
書き込み層はコンテナと運命を共にする。docker rm するとコンテナ層ごと削除されるため、そこに書いたアップロード画像や SQLite のファイルも消える。また書き込みのたびに OverlayFS を経由し、大きなファイルでは初回に copy-up も挟まるので、DB のデータファイルのように書き込みの多いデータには向かない。Docker のドキュメントも、書き込みの多いデータやコンテナより長生きさせたいデータにはボリュームを使うよう勧めている。ボリュームはホスト側のディレクトリをそのままマウントするので、OverlayFS の層を通らない。
# /var/lib/postgresql/data だけ層の外(ボリューム)に出す docker run -d -v pgdata:/var/lib/postgresql/data postgres:17
Kubernetes では、コンテナ層への書き込みは ephemeral-storage(ノードのローカルディスク上の一時領域)として計上される。kubelet が数えるのは書き込み層、emptyDir、Pod のログなどで、コンテナの書き込み層とログの合計が limits.ephemeral-storage を超えると、kubelet はその Pod を退避(eviction)対象にする。limit を書かなければ上限なしでノードのディスクを食い続け、ノード全体のディスク逼迫として別の Pod まで巻き込みうる。
resources:
requests:
ephemeral-storage: "1Gi"
limits:
ephemeral-storage: "2Gi" # 超えると Pod が evict される
アプリがログを標準出力ではなくコンテナ内のファイルに書き続けていると、それは書き込み層に溜まり、ephemeral-storage を消費し続ける。「メモリもCPUも余裕なのに Pod が Evicted になる」とき、まず疑う場所がここである。
多いのは 3 つの場面だ。1 つ目は「Dockerfile で削除したのにイメージが痩せない」で、docker history でどのレイヤーが重いかを見れば、消したはずのファイルがどの命令で入ったかが分かる。2 つ目は Pod の Evicted で、kubectl describe pod に ephemeral-storage 関連のメッセージが出ていれば、コンテナ内へのログ書き出しや一時ファイルの溜め込みを疑う。3 つ目は、大きなファイルを開いて書き換える処理だけが初回に妙に遅い、というケースで、copy-up が原因のことがある。また OverlayFS では、下層にあるファイルやディレクトリに対する rename(2) が EXDEV エラーになる場合があり、一部のツールが「コピーして消す」方式にフォールバックするのはこのためだ。
Docker Desktop が動く Mac で、(1) コンテナを増やしても増えるのは書き込み層だけであること、(2) 別レイヤーでの削除がイメージを縮めないことを確かめる。
# (1) 同じイメージから 3 個起動し、1 個だけに 50MB 書く for i in 1 2 3; do docker run -d --name cow$i nginx:alpine; done docker exec cow1 dd if=/dev/zero of=/big bs=1M count=50 docker ps -s --filter name=cow docker diff cow1 | head # (2) 50MB 置いて、次のレイヤーで消す printf 'FROM alpine\nRUN dd if=/dev/zero of=/big bs=1M count=50\nRUN rm /big\n' \ | docker build -t cowtest - docker history cowtest # 後片付け docker rm -f cow1 cow2 cow3 && docker rmi cowtest
(1) の SIZE 列は、cow1 だけが約 50MB で cow2・cow3 はごく小さい。括弧内の virtual(イメージ分を足した見かけの大きさ)は 3 つとも同程度だが、イメージ部分は共有なので足し算してはいけない。docker diff には追加を表す A /big が出る。(2) の docker history では RUN dd の行が約 50MB、RUN rm の行がほぼ 0B と表示され、削除がサイズを取り戻していないことが分かる。
- コンテナを起動するたびにイメージがまるごと複製される — 複製されるのは何もなく、イメージレイヤーは全コンテナで共有される。各コンテナに増えるのは変更分だけを持つ書き込み層である。
- Dockerfile の後半で
rmすればイメージは小さくなる — 別のRUNでの削除は whiteout を足すだけで、前のレイヤーに実体が残る。同じRUN内で消すか、マルチステージビルドを使う。 - コンテナ内の書き込みは再起動で消える — 消えるのはコンテナを削除したときで、
docker stop→startの再起動では書き込み層は残る。逆に Kubernetes では再起動のたびに新しいコンテナが作られるため、書き込み層の中身は引き継がれない前提で設計する。
- イメージレイヤー
- Dockerfile の命令ごとの差分を記録した読み取り専用のファイル群。複数のイメージ・コンテナで共有される。
- 書き込み層(コンテナ層)
- コンテナごとに 1 枚だけ足される読み書き可能な最上層。コンテナ削除とともに消える。
- OverlayFS
- lowerdir(下層)と upperdir(上層)を重ね、merged に 1 つのディレクトリとして見せる Linux カーネルのファイルシステム。
- copy-up
- 下層のファイルを初めて変更するとき、ファイル全体を上層へコピーしてから書き換える OverlayFS の動作。
- whiteout
- 下層のファイルを「存在しないことにする」ために上層へ置く印。下層の実体は残る。
- ephemeral-storage
- Kubernetes が書き込み層・emptyDir・ログなどのノードローカルな一時ディスク使用量を数えるリソース。
- Storage drivers | Docker Docs — レイヤー・コンテナ層・copy-on-write と
docker ps --sizeの size / virtual size の読み方の一次資料。 - OverlayFS storage driver | Docker Docs — lowerdir / upperdir / merged の対応、copy-up のコスト、whiteout、rename の制約まで実装寄りに説明している。
- Overlay Filesystem — The Linux Kernel documentation — whiteout がデバイス番号 0/0 のキャラクタデバイスであることなど、カーネル側の定義。
- Local ephemeral storage | Kubernetes — 書き込み層とログが ephemeral-storage に計上され、limit 超過で eviction される条件。