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の変更によるトラブルを避け、安定した同期を実現できます。
Discussion