この問題のほとんどすべての記事はボリュームのせいにするが、修正にこれほど時間がかかるのはまさにそのためだ。動いていた bind マウントがあり、社内 VPN が接続され、そして docker compose up はソースパスの欠落、空のディレクトリ、あるいはエンジンが作成を拒むマウントで失敗するようになった。私が片付けた環境では、Compose ファイルは変わっておらず、パスも正しかった。変わったのはどのデーモンが要求を受け取ったかであり、マウントのエラーはその違いが最初に現れた場所にすぎない。

ボリュームは動いていない。動いたのは要求だ

ある -v /srv/app:/app フラグは、シェルが行うディレクトリのコピーではない。クライアントはそれを API 要求に変え、応答するデーモンが /srv/app を自分のファイルシステムに対して解決する。両側が同じカーネル上で動いているとき、そのパスはファイルマネージャーで見えているものと同じだ。エンドポイントが変わると、同じ文字列があなたのプロジェクトディレクトリを知らないマシンで引かれ、デーモンはソースが無いと報告するか、その場所に黙って空のディレクトリを作る。

名前付きボリュームが動き続け、bind マウントがそうでないのも同じ理由だ。 -v appdata:/app はデーモンが所有するストレージに名前を付けるので、ホスト側のパスがどこかに存在する必要はない。あるサービスを bind マウントから名前付きボリュームに変えてエラーが消えたとしても、問題を直したことにはならない。クライアントとデーモンが、そのパスがどのファイルシステムに属するかで一致していないことを確認しただけであり、その不一致は本当のホストディレクトリが再び必要になった瞬間に戻ってくる。

マウントに触る前にエンドポイントを表示する

クライアントをエンドポイントに向ける設定はいくつかあり、それらは常に一致するわけではない。 --context フラグ、 DOCKER_CONTEXT、 DOCKER_HOST 現在のシェル、そしてクライアント設定に保存されたコンテキストだ。優先順位を考える代わりに、すべてを表示して答えを比べよう。

docker context show
docker context ls
docker version
docker info --format "{{.Name}} {{.OperatingSystem}} {{.ServerVersion}}"

クライアント設定は ~/.docker/config.json では Linux と macOS に、 %USERPROFILE%\.docker\config.json は Windows にあり、現在のターミナルで export した値はそのセッションに限り保存された値を上書きする。エンドポイントがローカルのソケットではなくホスト名なら、名前解決とルートがどこに着地するかを決める。そしてそれは VPN クライアントが書き換えるものそのものだ。ルートを追加したりリゾルバを差し替えたりするトンネルは、以前はこのノート PC を指していた名前を社内ネットワーク内のアドレスへ送りうる。

設定ではなく状態を比べる。トンネルを切った状態で docker ps -a と docker images を実行し、次にトンネルを上げた状態でもう一度実行する。コンテナの一覧が違うことは、どんな設定ファイルよりも強い証拠だ。別のデーモンが要求に応答したことを示しているからだ。

そのパスがそこに存在するかデーモンに尋ねる

どのデーモンに届いたか分かったら、そのデーモンにディレクトリを見せよう。 --mount 形式は短いフラグより明確なメッセージで失敗する。コンテナを起動する前にソースを検証するからだ。

docker run --rm --mount type=bind,source=/srv/app,target=/app alpine ls -la /app

ソースが無いと報告されたら、応答したマシン上で作成するか、サービスを名前付きボリュームに向けてデータをコピーしよう。この確認より前にボリュームを作り直したりエンジンを再インストールしたりしてはいけない。そうした操作は環境を変えすぎて不一致を隠すが、不一致はそのまま残る。

もう一つの経路はまったく別の場所で動く。image の取得はデーモンの仕事であり、クライアントの仕事ではない。取得の成功はデーモンが registry に届くことだけを証明し、動く bind マウントはパスが解決したことだけを証明する。それぞれの経路を実際に実行される場所で試さなければ、間違ったほうで午後を費やすことになる。

イントラネット名は別の理由で失敗する

同じ報告の後半はたいてい、コンテナが intranet.corp.exampleに到達できないというものだ。これは別の仕組みであり、二つ目のボリューム問題ではない。コンテナは起動時に /etc/resolv.conf を受け取るが、これはデーモンのリゾルバ設定から生成されるので、コンテナ作成後に VPN が追加した DNS サーバーは、コンテナを作り直すまでそこに現れない。

ユーザー定義ネットワーク上の Docker の組み込みリゾルバは、外部の問い合わせをホストに設定されたサーバーへ転送する。これは VPN がホストのリゾルバを書き換えるときには助けになり、分割 DNS が内部ゾーンをデーモンから見えないサーバーへ送るときには害になる。決して変わらない名前については、狭く明示的な対応付けがこの議論を避ける。

services:
  web:
    extra_hosts:
      - "intranet.corp.example:10.20.30.40"

これはコンテナの hosts ファイルにエントリを書き込むので、コンテナはトンネルについて何も知らずに済み、アドレスも固定される。内部ホストが移ると、誰かが Compose ファイルを編集するまでエントリは間違ったままになる。固定された機器や固定したデータベースにはこれを使い、動く名前は DNS に任せよう。

再インストールが実際に変えるもの

Docker Desktop を再インストールすると VM、ソケット、コンテキスト一覧が作り直され、マウントはまた動き出す。その結果は本物だが何も教えてくれない。だから次の VPN 更新のあとに同じ不具合が戻る。再インストールはクライアントが使っていたエンドポイントを置き換えただけで、プロジェクトが名付けるパスとデーモンが実際に読むファイルシステムを突き合わせたわけではない。

私の作業順は短い。クライアントがどのエンドポイントを使うかを確認する。コンテナ一覧を比べてどのデーモンが応答したかを確認する。そのデーモンにソースパスの一覧を出させる。それから初めてマウントを編集し、hosts エントリを足し、ボリューム種別を変える。VPN 越しに働くチームのために Compose ファイルを保守しているなら、デーモンが所有すべきデータには名前付きボリュームを選び、ホスト側のパスが契約の一部にならないようにしよう。

関連記事