🐳

Docker Composeの落とし穴:volumesのファイル指定で起きる同期トラブルとその解決法

Docker Composeのvolumesにおいてファイルパスを直接指定すると、ファイルが再作成されるなどでinodeが変更された場合に、コンテナ側でファイルの追跡ができなくなり、変更が反映されなくなることがあります。
この記事では、TROCCO/COMETAの開発者が実際に遭遇した問題の具体例と、解決方法について紹介します。

開発中に実際に発生した同期トラブル

TROCCOやCOMETAのプロダクト開発では、Docker Composeで立ち上げたコンテナ内でファイルを更新し、その結果をホスト側と同期させていました。

具体的には、以下のような運用をしていました。

  • コンテナ内でビルド処理を実行し、その結果をファイルとして書き込む。
  • ビルド結果のファイルをコンテナとホスト間で同期。

しかし、以下のような問題が頻発していました。

  • コンテナ内のビルド結果がホストに反映されないことがある。
  • 開発中は気付かず、CIのエラーで初めて問題が発覚する。
  • コンテナの再起動を行うと問題が一時的に解決するが、根本原因は不明だった。

この原因不明の同期トラブルが開発効率を低下させていました。

根本原因:inodeの不一致

Docker Composeのvolumesは、Linuxカーネルのbind mount機能を使用してホストとコンテナ間でファイル共有を実現しています。

ホスト、コンテナで参照するinodeが一致することで、同一のファイル実態を参照することができます。
参照するinodeが不一致になると、同期が破綻します。

inodeとは何か?

inode(index node)とは、Linux/Unixファイルシステムでファイルやディレクトリの情報を保持するデータ構造です。ファイルの内容を指し示すポインタやファイルサイズ、アクセス権限、更新時刻などが格納されています。ディレクトリ内でファイル名とinode番号が紐付けられています。

$ ls -i sample.txt # -i オプションでファイルのinodeが確認できる
1573132 sample.txt

$ stat sample.txt # stat でより詳細なinode情報が確認できる
  File: sample.txt
  Size: 7               Blocks: 8          IO Block: 4096   regular file
Device: 10301h/66305d   Inode: 1573132     Links: 1
Access: (0664/-rw-rw-r--)  Uid: ( 1000/  ubuntu)   Gid: ( 1000/  ubuntu)
Access: 2025-05-23 13:21:21.919038369 +0000
Modify: 2025-05-23 13:21:17.141029230 +0000
Change: 2025-05-23 13:21:17.141029230 +0000
 Birth: 2025-05-23 13:20:43.385969044 +0000

$ stat app/ # ディレクトリもinodeを持つ
  File: app/
  Size: 4096            Blocks: 8          IO Block: 4096   directory
Device: 10301h/66305d   Inode: 1580735     Links: 2
Access: (0775/drwxrwxr-x)  Uid: ( 1000/  ubuntu)   Gid: ( 1000/  ubuntu)
Access: 2025-05-23 13:20:48.237977470 +0000
Modify: 2025-05-23 13:20:47.584976337 +0000
Change: 2025-05-23 13:20:47.584976337 +0000
 Birth: 2025-05-16 13:27:59.446996913 +0000

inodeが変更される具体的な例

ファイル再作成でinodeが変更される例を以下に示します。

$ echo "initial" > sample.txt
$ ls -i sample.txt
1573132 sample.txt

# inode変更を確実にするため、別ファイルを作成後rename
$ rm sample.txt
$ echo "new content" > sample.tmp
$ mv sample.tmp sample.txt
$ ls -i sample.txt
1583050 sample.txt  # inodeが変化

なお、gitのブランチ切り替えなどでファイルの差分が大きい場合にも、ファイルが再作成されinodeが変わることがあります。

私たちの問題も、開発中のgitのブランチ切り替えがきっかけで発生していました。

問題を再現するDocker Composeの例

以下のようなディレクトリ構成において、sample.txtをコンテナにマウントすることを考えます。

$ tree .
.
├── app
│   └── sample.txt
└── compose.yml

次のようなcompose.yamlを用意します。

version: '3'
services:
  i18n-builder:
    image: alpine
    command: tail -f /dev/null
    volumes:
      - ./app/sample.txt:/app/sample.txt

ファイルを作成しinodeを確認します。

$ echo "TROCCO" > app/sample.txt
$ docker compose up -d

# ホスト側のinode確認
$ ls -i app/sample.txt
1583050 app/sample.txt

# コンテナ側のinode確認
$ docker compose exec i18n-builder ls -i app/sample.txt
1583050 app/sample.txt

次にファイルを再作成します。

$ rm app/sample.txt
$ echo "TROCCO" > app/sample.tmp
$ mv app/sample.tmp app/sample.txt

再作成後のinodeを確認すると不一致が生じています。

# ホスト側
$ ls -i app/sample.txt
1573132 app/sample.txt

# コンテナ側
$ docker compose exec i18n-builder ls -i app/sample.txt
1583050 app/sample.txt  # inodeは古いまま

この状態ではコンテナ側の変更がホスト側に反映されません。

$ docker compose exec i18n-builder sh -c 'echo "COMETA" > /app/sample.txt'
$ cat app/sample.txt
TROCCO  # ホスト側は更新されず

解決方法:ディレクトリをマウントする

解決策はファイル単位ではなくディレクトリ単位でマウントすることです。

version: '3'
services:
  app:
    image: alpine
    command: tail -f /dev/null
    volumes:
      - ./app:/app/

この方法で再作成を行うと、ホスト側とコンテナ側のinodeが一致し、変更も正しく反映されます。

実際に確認してみましょう。

先程と同様に、ファイルを作成し、inodeを確認します

$ echo "TROCCO" > app/sample.txt
$ docker compose up -d

# ホスト側のファイルのinodeの確認
$ ls -i app/sample.txt
1583050 sample.txt

# コンテナ側のファイルのinodeの確認(ホスト側のinodeと一致)
$ docker compose exec i18n-builder ls -i app/sample.txt
1583050 sample.txt

ホスト側でファイルの再作成を行います

$ rm app/sample.txt
$ echo "TROCCO" > app/sample.tmp  # 一旦別名で作成
$ mv app/sample.tmp app/sample.txt         # renameで置き換える

ホスト側とコンテナ側でファイルのinodeを確認すると、先ほどとは異なりinodeが一致していることが確認できます。

# ホスト側のファイルのinodeの確認
$ ls -i app/sample.txt
1573132 sample.txt # inodeが変化

# コンテナ側のファイルのinodeの確認
$ docker compose exec i18n-builder ls -i app/sample.txt
1573132 sample.txt # inodeが変化し、ホスト側と一致

この状態でコンテナ側のファイルを変更みると、ホスト側に正しく反映されます。

$ docker compose exec i18n-builder sh -c 'echo "COMETA" > /app/sample.txt'
$ docker compose exec i18n-builder cat app/sample.txt
COMETA # コンテナ側はCOMETAに変更されている

$ cat app/sample.txt
COMETA # ホスト側もCOMETAに変更されている

なぜディレクトリ指定が必要か

ディレクトリは、ファイル名とinodeのマッピング情報を管理しています。
ファイル直接マウントの場合、ディレクトリのinodeがコンテナ立ち上げ時から一致していないためディレクトリ内のファイルのinode変更が同期されません。

ファイル直接マウントの場合

    volumes:
      - ./app/sample.txt:/app/sample.txt

appディレクトリのinodeが一致していない事がわかります

$ docker compose up -d

# ホスト側のappディレクトリのinode
$ stat -c %i app/
1580735

# コンテナ側
$ docker compose exec i18-builder stat -c %i app/
554339

ホスト側とコンテナ側で別のディレクトリとして扱われるため、ディレクトリ内のファイルinodeが変更される操作がある場合、同期されません。

ディレクトリマウントの場合

    volumes:
      - ./app:/app/

ディレクトリをマウントする場合、appディレクトリのinodeが一致していることがわかります。

$ docker compose up -d

# ホスト側のappディレクトリのinode
$ stat -c %i app/
1580735

# コンテナ側もホスト側と同じinodeを持つ
$ docker compose exec i18-builder stat -c %i app/
1580735

まとめ

Docker Composeでファイル同期する際は、ファイル単位ではなくディレクトリ単位でマウントを行いましょう。

解決策

  • ./app/file.txt:/app/file.txt(ファイル直接マウント)
  • ./app:/app(ディレクトリマウント)

これにより、inodeの変更によるトラブルを避け、安定した同期を実現できます。

株式会社primeNumber

Discussion