Docker・インフラ構築

Dockerイメージのレイヤー構造:overlay2とコピーオンライトの内部挙動

Dockerコンテナの中でファイルを書き換えたとき、その変更は「イメージを書き換えている」わけではない。読み取り専用のイメージレイヤーを残したまま、コンテナ専用の書き込みレイヤーに変更分を積み上げている。…

Dockerイメージのレイヤー構造:overlay2とコピーオンライトの内部挙動

この仕組みを知らないまま、コンテナ内でログを大量に出したり、データベースの本体を保存したりすると、ある日突然、書き込み速度の低下やディスク使用量の増大に直面する。しかも、見た目には単なるファイル操作にしか見えないため、原因の切り分けが難しい。

Dockerのストレージドライバーで広く使われているoverlay2は、LinuxカーネルのOverlayFSを利用して、複数のディレクトリをひとつのファイルシステムに見せている。ポイントは、コンテナから見えているディレクトリが、そのままひとつの実体ではないことだ。

この記事では、Dockerのレイヤー構造とoverlay2の仕組みを、LowerDir、UpperDir、MergedDir、WorkDirという4つの要素から整理する。さらに、コピーオンライトで発生するcopy_upがなぜファイル単位なのか、そしてアプリケーションのデータやログをどこへ保存すべきなのかまで、個人開発の運用目線で掘り下げていく。

Dockerイメージは、最初からひとつのファイルシステムではない

Dockerfileからイメージをビルドすると、ひとつの巨大なファイルシステムが生成されるように見える。しかし実際には、命令ごとの変更が複数のレイヤーとして積み重なる。

たとえば、次のようなDockerfileを考える。

  • ベースイメージを指定する
  • パッケージをインストールする
  • アプリケーションの依存関係を追加する
  • ソースコードを配置する
  • 起動コマンドを設定する

このとき、各命令によるファイルシステムの変更は、イメージレイヤーとして管理される。Dockerはこれらの読み取り専用レイヤーを組み合わせ、コンテナ起動時にひとつのディレクトリ構造として見せる。

イメージをレイヤーに分けるメリットは、キャッシュと共有にある。

たとえば、Laravelアプリケーションのソースコードだけを変更した場合、依存パッケージをインストールするレイヤーまで毎回作り直す必要はない。変更されていないレイヤーは再利用できる。また、同じベースイメージを使う複数のコンテナがあれば、共通する読み取り専用レイヤーを共有できる。

一方で、コンテナを起動したあとに発生する変更は、イメージレイヤーへ直接書き込まれない。ここで登場するのが、コンテナ用の書き込みレイヤーだ。

イメージレイヤーとコンテナレイヤーの違い

Dockerのレイヤー構造を理解するとき、最初に分けて考えたいのは次の2種類である。

レイヤー主な役割書き込み寿命
イメージレイヤーベースOS、パッケージ、アプリケーションの初期状態を保持原則として読み取り専用イメージを削除するまで
コンテナの書き込みレイヤー起動後のファイル変更を保持可能コンテナを削除するまで
ボリュームデータベースやアップロードファイルなど永続データを保持可能コンテナとは別に管理
バインドマウントホスト側のディレクトリをコンテナへ接続可能ホスト側のファイルとして管理

ここで見落とされやすいのが、コンテナの書き込みレイヤーとボリュームは別物だという点だ。

コンテナ内で作成したファイルは、特別な設定をしていなければ、コンテナの書き込みレイヤーに保存される。コンテナを削除すれば、そのデータも失われる。再作成すれば消える前提の場所であり、永続化を目的とした保存先ではない。

Docker ComposeでMySQLやPostgreSQLを動かす場合、データディレクトリには通常ボリュームを割り当てる。Laravelであれば、ユーザーがアップロードしたファイルやアプリケーションが生成する永続データも、コンテナの書き込みレイヤーに放置しない設計が必要になる。

コンテナの書き込みレイヤーは「一時的な変更を受け止める場所」であって、データを安心して預ける保管庫ではない。

overlay2がディレクトリを重ね合わせる仕組み

overlay2は、Linuxカーネルが提供するOverlayFSを利用するストレージドライバーだ。複数のディレクトリを重ね合わせ、ユーザーやプロセスからはひとつのディレクトリに見える状態を作る。

この構造は、主に次の4つのディレクトリで説明できる。

  • LowerDir
  • UpperDir
  • MergedDir
  • WorkDir

名前だけを見ると抽象的だが、役割は明確だ。

LowerDir:読み取り専用のイメージレイヤー

LowerDirは、Dockerイメージを構成する読み取り専用のレイヤーである。

ベースイメージに含まれるファイル、パッケージマネージャーで導入したライブラリ、アプリケーションの初期ファイルなどが、複数のLowerDirに分かれて格納される。

OverlayFSは、複数のLowerDirを下位レイヤーとして扱える。overlay2は最大128個のLowerDirをネイティブにサポートしている。つまり、イメージが複数のビルドレイヤーに分かれていても、それらを重ね合わせてひとつのディレクトリツリーとして表示できる。

LowerDirにあるファイルは、コンテナから読むことができる。しかし、そのファイルをコンテナ内で変更するとき、LowerDir自体が直接書き換えられるわけではない。

イメージレイヤーを共有できることがDockerの大きな利点だからだ。ひとつのコンテナがイメージの中身を変更してしまえば、同じイメージを利用する別のコンテナへ影響が及んでしまう。そこで、変更はUpperDirへ退避される。

UpperDir:コンテナ専用の書き込みレイヤー

UpperDirは、コンテナ起動後の変更を書き込むためのレイヤーだ。

新しいファイルを作成した場合、そのファイルはUpperDirに保存される。LowerDirに存在するファイルを変更した場合も、対象ファイルをUpperDirへコピーしてから変更する。

この「変更前にUpperDirへ複製する」処理が、OverlayFSにおけるcopy_upである。

UpperDirは、そのコンテナに固有の書き込み領域だ。コンテナを停止して再起動するだけなら残るが、コンテナ自体を削除すれば、通常はUpperDirの内容も一緒に失われる。

ここがボリュームとの大きな違いになる。

MergedDir:コンテナから見えている統合ディレクトリ

MergedDirは、LowerDirとUpperDirを重ね合わせた結果として、コンテナへ見せるディレクトリである。

コンテナ内で/etc, /var, /appなどを見たとき、プロセスが直接見ているのはMergedDirに相当する統合された状態だ。そこに存在するファイルが、LowerDir由来なのかUpperDir由来なのかを、通常のアプリケーションが意識することはない。

同じパスにLowerDirとUpperDirの両方からファイルが提供されている場合、上位にあるUpperDirの内容が優先される。LowerDirの元ファイルは残っているが、MergedDirからはUpperDir側のファイルが見える。

削除も少し特殊だ。LowerDirのファイルを削除した場合、読み取り専用のLowerDirから実体を消すことはできない。その代わり、UpperDir側に削除を表現する仕組みが置かれ、MergedDirからは対象ファイルが消えたように見える。

ファイルシステムをひとつに見せながら、実体は複数層に分かれている。これがoverlay2の基本的な見え方だ。

WorkDir:OverlayFS内部の作業領域

WorkDirは、OverlayFSが内部処理で利用する作業用ディレクトリである。

通常、アプリケーションが直接触る場所ではない。LowerDirからUpperDirへファイルを移動したり、レイヤーの状態を整えたりする際に、OverlayFSが一時的な処理領域として利用する。

Dockerのoverlay2を調査すると、これらのディレクトリが/var/lib/docker/overlay2配下に存在することを確認できる。ただし、そこにあるディレクトリ名を見て、手作業でファイルを移動したり削除したりするのは危険だ。

Dockerの管理情報と実体の対応関係を壊す可能性があるため、調査は読み取りにとどめ、不要なイメージやコンテナの削除はDockerのコマンド経由で行うべきである。

コピーオンライトは「変更した部分だけ」のコピーではない

コピーオンライト、いわゆるCoWという言葉から、変更したブロックだけを複製する仕組みをイメージする人は少なくない。しかし、OverlayFSのcopy_upはブロック単位ではない。

LowerDirにあるファイルを変更すると、対象ファイル全体がUpperDirへコピーされる。

たとえば、数百メガバイトあるファイルの一部だけを書き換えたとしても、コピーアップの段階ではファイル全体が対象になる。データベースの内部ファイルや大きなログファイルをコンテナの書き込みレイヤー上で更新すると、書き込み性能やディスク使用量に影響が出やすい理由はここにある。

読み取りだけなら、LowerDirの共有を活かせる

LowerDirのファイルを読むだけであれば、UpperDirへのコピーは発生しない。

同じイメージを使う複数のコンテナが、共通のライブラリやアプリケーションの静的ファイルを読み取れるのは、この読み取り専用レイヤーを共有できるからだ。

この特性は、イメージ設計にも関係する。頻繁に変更されない依存関係や実行環境をイメージレイヤーへまとめ、実行時に変わるデータをボリュームや外部ストレージへ逃がす。変更頻度の違うものを分離することで、キャッシュも運用も安定する。

変更、所有者変更、権限変更でもcopy_upが起こる

copy_upは、ファイルの内容を書き換えたときだけ発生するわけではない。

LowerDirにあるファイルに対して、次のような操作をした場合も、UpperDirへのコピーが発生する可能性がある。

  • ファイル内容の変更
  • chmodによるパーミッション変更
  • chownによる所有者・グループ変更
  • タイムスタンプなどメタデータの変更

つまり、アプリケーションが中身を書き換えていないつもりでも、起動処理で所有者や権限を整えているだけで、ファイルのコピーが発生することがある。

たとえば、コンテナ起動時にアプリケーションディレクトリ全体へchown -Rを実行する設計は、対象ファイルが多いほど影響を広げやすい。権限調整そのものは必要な場合があるが、どのレイヤーのファイルに対して実行しているのかは把握しておきたい。

ここは、Dockerfileの書き方と実行時処理を分けて考えるポイントでもある。ビルド時に確定できる所有者や権限はイメージ作成時に設定し、コンテナ起動のたびに大量のファイルを再処理する構成を避ける。小さな差に見えるが、コンテナの起動数が増えると運用上の差になる。

CoWのコストは、書き換えた文字数ではなく、最初にUpperDirへ移されるファイルの大きさで決まる。

overlay2のパフォーマンスを左右するもの

overlay2は、Dockerコンテナを動かすうえで十分に実用的なストレージドライバーである。ただし、どんな書き込みにも向いているわけではない。

特に差が出やすいのは、次のような処理だ。

大きな既存ファイルを更新する処理

LowerDirから読み込んだ大きなファイルを更新すると、最初の変更時にファイル全体のcopy_upが発生する。

一度UpperDirへコピーされたあとは、以降の処理が毎回同じ規模のコピーになるとは限らない。しかし、初回のコピーコストと、UpperDirに保存され続けることによるディスク消費は残る。

データベースのデータファイル、巨大なインデックス、頻繁に追記されるログなどは、この構造と相性がよくない。

小さなファイルを大量に変更する処理

1ファイルあたりのサイズが小さくても、ファイル数が多ければ、コピーやメタデータ処理の回数が増える。

PHPアプリケーションの依存関係やキャッシュディレクトリ、フレームワークが生成する一時ファイルなど、数千・数万単位のファイルがある場所では、起動時の権限変更や一括処理が負担になることがある。

ここで大切なのは、「overlay2が遅い」と一括りにしないことだ。

  • 読み取り中心なのか
  • 既存ファイルの更新が多いのか
  • 新規ファイルの作成が多いのか
  • ひとつのファイルが大きいのか
  • 小さなファイルが大量にあるのか
  • 起動時に権限変更をしているのか

この切り分けなしに、ストレージドライバーの変更だけで解決しようとすると、仮説と検証がずれる。

書き込み先をボリュームへ分離する

データベース本体や永続的なアップロードファイルは、コンテナの書き込みレイヤーではなく、ボリュームやバインドマウントへ分離する。

Docker Composeであれば、サービスのデータディレクトリに名前付きボリュームを割り当てる構成が基本になる。これにより、コンテナのUpperDirと、永続化したいデータの保存先を分けられる。

もちろん、ボリュームにすればすべての問題が消えるわけではない。ホスト側のディスク性能、ファイルシステム、バックアップ設計、権限設定など、別の論点は残る。

しかし少なくとも、コンテナのライフサイクルとデータのライフサイクルを分離できる。この分離は、個人開発でも本番運用でも、コンテナを使い続けるための土台になる。

Dockerの実体を確認するコマンド

overlay2の仕組みを頭で理解するだけでは、実際のトラブルには対応しにくい。まずは現在のDocker環境で、どのストレージドライバーが使われているのかを確認する。

ストレージドライバーは、docker info | grep "Storage Driver"で確認できる。環境によっては表示形式が異なるため、docker info全体からStorage Driverの項目を探してもよい。

overlay2が表示されていれば、LinuxカーネルのOverlayFSを利用している構成だ。

マウント状況を確認したい場合は、mount | grep overlayを使う。実際のマウント情報にはLowerDirやUpperDir、MergedDirなどが含まれることがあり、どのディレクトリを重ね合わせているかを確認できる。

ただし、ここで表示されるパスを見て、すぐに直接操作してはいけない。確認の目的は、Dockerがどのような構造で動いているかを把握することだ。

ディスク使用量は複数の角度から見る

Dockerのディスク使用量を調べるとき、ホストのdfだけでは原因が分からないことがある。

Docker全体の使用状況は、docker system dfで確認できる。イメージ、コンテナ、ボリュームなど、どこが容量を使っているかを大まかに把握できる。

さらに、実行中のコンテナについては、docker ps -sで書き込みレイヤーのサイズを確認できる。ここで増え続けているコンテナがあれば、アプリケーションがUpperDirへデータを書き込んでいる可能性がある。

調査の順番としては、次の流れが現実的だ。

1. docker infoでストレージドライバーを確認する

2. docker ps -sでコンテナごとの書き込み量を見る

3. docker system dfでイメージやボリュームの使用状況を分ける

4. コンテナ内のログ、キャッシュ、一時ファイル、データベース領域を確認する

5. 永続データの保存先をボリュームへ移す

6. 必要に応じてイメージレイヤーと起動時処理を見直す

この順序なら、いきなりDockerの内部ディレクトリを掘り始めずに済む。症状と保存先を結びつける。地味だが、原因究明の速度を左右する。

Dockerイメージのレイヤー設計で意識すること

overlay2の挙動を理解すると、Dockerfileの設計にもいくつか見直しポイントが出てくる。

変更頻度の違うファイルを分ける

Dockerイメージのレイヤーは、Dockerfileの命令によって積み重なる。頻繁に変更されるファイルと、ほとんど変更されない依存関係を同じレイヤーで扱うと、ビルドキャッシュを活かしにくい。

Laravelのようなアプリケーションでは、依存関係の定義ファイルを先にコピーして依存パッケージを導入し、その後にソースコードをコピーする構成がよく使われる。

狙いは、ソースコードを変更しても依存関係のインストールレイヤーを再利用することだ。これは実行時のcopy_upとは別の話だが、どちらも「変更単位を適切に分ける」という同じ設計思想に立っている。

  • ビルド時は、キャッシュを活かせる単位でレイヤーを分ける
  • 実行時は、永続データをUpperDirから分離する
  • 起動時は、大量のファイルを毎回変更しない
  • 運用時は、コンテナとデータの寿命を分ける

この4つを混同しないことが重要だ。

マルチステージビルドは実行時の書き込み問題とは別に考える

マルチステージビルドを使うと、ビルドに必要なツールや中間ファイルを最終イメージへ持ち込まずに済む。最終イメージを小さくし、不要なファイルを減らす効果がある。

ただし、最終イメージを小さくしたからといって、実行時のUpperDirへの書き込み問題が自動的に解決するわけではない。

ビルド時のイメージサイズと、実行時の書き込み量は別の指標である。

ここを一緒に扱うと、「イメージを軽量化したのに、コンテナのディスク使用量が増える」という状況が起こる。改善施策の効果を測るときも、ビルド時間、イメージサイズ、コンテナの書き込み量、アプリケーションの応答性能を分けて見る必要がある。

プロダクト改善と同じで、ひとつの数字だけを追うと判断を誤る。

どのデータをUpperDirに置いてよいのか

コンテナの書き込みレイヤーを完全に使ってはいけないわけではない。問題は、そこへ何を置くかだ。

一時的な処理結果や、コンテナが短時間だけ利用するキャッシュであれば、UpperDirでも成立するケースはある。コンテナを削除すれば消えてよいデータなら、ボリュームを割り当てる必要がない場合もある。

一方で、次のデータは保存先を慎重に分けたい。

  • データベースのデータ本体
  • ユーザーがアップロードした画像やファイル
  • アプリケーションが生成した永続的な帳票
  • 長期間保存するアクセスログ
  • 再起動後も残したいキャッシュ
  • バックアップ対象となるデータ

これらをUpperDirへ置くと、コンテナ削除時の消失リスクだけでなく、書き込みレイヤーの肥大化にもつながる。

特にログは見落としやすい。アプリケーションのログを標準出力へ流すのか、ホスト側へ保存するのか、ログ基盤へ送るのか。方針を決めないままコンテナ内のファイルへ追記し続けると、気づいたときにはDockerの保存領域を圧迫している。

ユーザーの痛みは、いつもエラーメッセージとして現れるとは限らない。レスポンスが徐々に遅くなる。デプロイ後の起動に時間がかかる。ディスク容量のアラートが断続的に出る。こうした小さな違和感の背後に、UpperDirへの書き込みが隠れていることがある。

overlay2を疑うべき症状と、疑う前に見る場所

コンテナの書き込みが遅いとき、すぐに「overlay2の性能限界だ」と結論づけるのは早い。

まず、アプリケーションがどのパスへ書き込んでいるかを確認する。書き込み先がボリュームなのか、バインドマウントなのか、それともコンテナの書き込みレイヤーなのかで、調査の方向が変わる。

次に、書き込んでいるファイルの性質を見る。

  • 既存の大きなファイルを更新していないか
  • 同じファイルへ繰り返し追記していないか
  • 小さなファイルを大量に生成していないか
  • 起動時に再帰的なchownchmodを実行していないか
  • コンテナの再作成で消えてはいけないデータを保存していないか

この確認をせずに、ストレージドライバーを変更したり、ホストの設定を大きく変えたりするのは、検証として粗い。

施策は小さく分ける。たとえば、まずデータベースのデータディレクトリだけをボリュームへ移す。次にログの保存方式を変える。その後、起動時の権限変更を見直す。変更前後で、コンテナの書き込み量、起動時間、アプリケーションの応答を観察する。

これは派手な改善ではない。しかし、どの施策が効いたのかを説明できる。個人開発では、説明できる改善のほうが次の判断につながる。

overlay2の理解を、実装の判断に変える

overlay2の内部構造を知る目的は、Dockerの内部ディレクトリを眺めることではない。

LowerDirにあるファイルは共有される読み取り専用の資産。UpperDirはコンテナ固有の変更領域。MergedDirはそれらを統合して見せる窓口。WorkDirはOverlayFSが処理を進めるための内部領域。

この関係を押さえれば、次の判断がしやすくなる。

イメージへ入れるもの

アプリケーションの実行に必要で、頻繁には変わらないもの。OSパッケージ、ランタイム、依存ライブラリ、初期状態のアプリケーションファイルなどだ。

ボリュームへ出すもの

コンテナを削除しても残したいもの。データベース、アップロードファイル、永続的な生成物などだ。

実行時に生成してよいもの

コンテナがなくなれば消えてもよいもの。一時ファイル、短命なキャッシュ、処理途中のデータなどだ。

外部へ送るもの

コンテナ内へ蓄積させたくないもの。長期保存するログ、監視メトリクス、バックアップなどが該当する。

この分類に唯一の正解があるわけではない。開発環境と本番環境でも変わる。大切なのは、保存先を無意識に決めないことだ。

Dockerを導入すると、アプリケーションの起動コマンドやネットワーク設定へ意識が向きやすい。しかし、コンテナはファイルシステムの上でも動いている。どこから読み、どこへ書き、どのタイミングでコピーが発生するのか。その視点があるだけで、トラブルの見え方は変わる。

まとめ:レイヤーを知ると、Dockerの設計が現実に近づく

Dockerのoverlay2は、LinuxカーネルのOverlayFSを使って、複数の読み取り専用レイヤーと書き込み可能なレイヤーを重ね合わせる。

構成要素を整理すると、次のようになる。

  • LowerDirは、Dockerイメージを構成する読み取り専用レイヤー
  • UpperDirは、コンテナ起動後の変更を保持する書き込みレイヤー
  • MergedDirは、LowerDirとUpperDirを統合してコンテナへ見せる領域
  • WorkDirは、OverlayFSが内部処理で利用する作業領域
  • LowerDirのファイルを変更すると、copy_upでUpperDirへファイル全体がコピーされる
  • chmodchownなど、メタデータ変更でもcopy_upが発生する可能性がある
  • データベースや永続ファイルは、UpperDirではなくボリュームなどへ分離する

コピーオンライトは、Dockerを高速かつ効率的に扱うための重要な仕組みだ。一方で、「変更した部分だけが軽くコピーされる」という理解に寄りかかると、ファイル単位の挙動を見落とす。

個人開発では、サービスの機能追加を優先するあまり、ログやキャッシュ、データベースの保存先が後回しになりやすい。僕自身も、機能を動かすことに集中していると、運用時の書き込み経路まで設計しきれないことがある。だからこそ、コンテナを作った時点で「このデータはどのレイヤーに存在するのか」を一度言語化しておきたい。

次に試したいことは、アプリケーションごとに書き込み先を棚卸しし、UpperDirへ残すデータをさらに減らすことだ。まずはdocker ps -sdocker system dfで現状を確認する。仮説を立て、保存先を変え、コンテナの書き込み量と起動時の挙動を比較する。

Dockerのレイヤー構造は、普段は意識しなくても開発を進められる。しかし、仕組みを理解していると、遅延やディスク使用量の変化を「なんとなくの不調」で終わらせず、次の検証へつなげられる。個人開発のインフラは、最初から完璧にするものではない。観測して、分けて、また試す。その繰り返しで、少しずつプロダクトの足元が強くなる。

Related reading: Docker・インフラ構築をわかりやすく解説 and Docker・インフラ構築の違いと判断基準.

よくある質問

Dockerのイメージレイヤーとコンテナの書き込みレイヤーは何が違いますか?
イメージレイヤーはベースOSやパッケージ、アプリケーションの初期状態を保持する読み取り専用の層です。コンテナの書き込みレイヤーは、起動後に発生した変更を保持しますが、コンテナを削除すると通常は内容も失われます。
overlay2のLowerDir、UpperDir、MergedDir、WorkDirとは何ですか?
LowerDirは読み取り専用のイメージレイヤー、UpperDirはコンテナ専用の書き込みレイヤーです。MergedDirは両者を統合してコンテナから見せる領域で、WorkDirはOverlayFSが内部処理に使う作業領域です。
overlay2のcopy_upはファイルの一部だけをコピーしますか?
いいえ。LowerDirにあるファイルを変更すると、OverlayFSのcopy_upでは対象ファイル全体がUpperDirへコピーされます。そのため、大きなデータベースファイルやログファイルの更新では、書き込み性能やディスク使用量に影響が出やすくなります。
Dockerでデータベースやアップロードファイルをどこに保存すべきですか?
データベースのデータ本体やユーザーがアップロードしたファイルなど、コンテナ削除後も残したいデータはボリュームやバインドマウントへ分離します。これにより、コンテナのライフサイクルとデータのライフサイクルを分けられます。
Dockerコンテナの書き込みレイヤーの使用量はどう確認できますか?
実行中のコンテナごとの書き込みレイヤーのサイズはdocker ps -sで確認できます。Docker全体のイメージ、コンテナ、ボリュームなどの使用状況はdocker system dfで確認できます。

参考情報