症状

DrupalのFunctionalテスト(BrowserTestBase)を Docker で走らせたら、1件も通りませんでした。しかも failure ではなく error で落ちます。この違いは効きます。**failure は「コードが間違っている」、error は「テストが実行できていない」**なので、error を failure と読み違えると、動いているコードを疑って何時間も溶かします。

環境はこうです。dev のサイトは https://rikkama.localhost で動いています。テストは php コンテナから、web コンテナ(Apache)へ実HTTPリクエストを投げます。両者は Docker のネットワークで繋がっていて、rikkama.localhost は web コンテナを指すように解決されるはずでした。

SIMPLETEST_BASE_URL='https://rikkama.localhost' phpunit ...

これが動きません。

最初に確かめたこと

まず名前解決を疑いました。php コンテナから rikkama.localhost が web コンテナに解決されるかを確認します。

$ docker exec php getent hosts rikkama.localhost
172.18.0.5   rikkama.localhost     # web コンテナのIP。解決自体は正しい

解決は正しい。web コンテナのIPが返っています。ところが curl は届きません。

$ docker exec php curl -sk https://rikkama.localhost/ -o /dev/null -w '%{remote_ip}\n'
127.0.0.1                          # ← getent と違うIPに行っている

getent172.18.0.5(web コンテナ)を返すのに、curl は 127.0.0.1(自分自身)に接続しています。名前解決の結果を、curl が見ていません。

犯人: RFC 6761

curl は *.localhost を特別扱いします。根拠は RFC 6761 の 6.3 で、localhost とそのサブドメインは「常にループバックを指す」と規定されています。これを実装しているソフトウェアは、.localhost で終わるホスト名をDNSに一切問い合わせず、直接 127.0.0.1(と ::1)へ向けます。

つまり rikkama.localhost という名前を選んだ時点で、curl は Docker の名前解決を無視して自分自身に接続することが確定していました。getent が正しいIPを返しても関係ありません。curl はそもそも聞きに行っていないからです。

ブラウザで開くと rikkama.localhost は普通に見えるので、この挙動は盲点になります。ブラウザは .localhost をループバックに向けますが、そこには(ホスト側で)ちゃんと web が listen しているので見える。一方 php コンテナの中の 127.0.0.1 には Apache がいないので、curl は届かない。同じホスト名が、見る場所によって別のものを指しています。

解決: docker内部でだけ解決する名前を使う

.localhost をやめて、Docker のネットワーク内部でだけ解決される名前 ―― サービス名 web ―― を使います。これは RFC 6761 の特別扱いに当たらないので、curl も普通に名前解決します。

SIMPLETEST_BASE_URL='http://web' phpunit ...

ただしこれだけでは今度は Drupal 側で弾かれます。

The provided host name is not valid for this server.

Drupal の trusted_host_patternsweb が無いためです。リクエストは届いたのに、ホスト名が信頼リストに無いので 400 を返す。これも 400 = error であって failure ではないので、また実行できていない状態です。

信頼ホストに web を足します。ここで settings.php置く場所が問題になりました。

trusted_host_patterns を足す場所

Drupal の settings.php は途中で settings.local.php を include します。そして dev の settings.local.php は、trusted_host_patterns代入で上書きしていました。

// settings.local.php(dev 用)
$settings['trusted_host_patterns'] = [
  '^rikkama\.localhost$',
];

代入(=)なので、これより前に何を書いても消えます。したがってテスト用ホストの追加は、settings.local.php の include よりに置く必要があります。

// settings.php ―― settings.local.php の include より後に置く
if (getenv('SIMPLETEST_BASE_URL') !== FALSE) {
  $host = parse_url((string) getenv('SIMPLETEST_BASE_URL'), PHP_URL_HOST);
  if (is_string($host) && $host !== '') {
    $settings['trusted_host_patterns'][] = '^' . preg_quote($host, '/') . '$';
  }
}

3点、意図があります。

SIMPLETEST_BASE_URL が設定されているときだけ動くので、本番には一切影響しません。本番ではこの環境変数が無いため、信頼ホストは変わりません。

web は Docker ネットワークの外から名乗れない内部名です。だから dev で信頼リストに入れても、外部の攻撃者が Host: web を送って通ることはありません。

そして []追加しているので、settings.local.php が設定した既存のホスト(rikkama.localhost)は残ります。ブラウザからの通常アクセスも、テストからのアクセスも、両方通ります。

何が怖かったか

この問題の本当の怖さは、テストが「無い」のと区別がつかないことでした。error になったテストは、CIのサマリでは赤くなりますが、「コードのバグ」ではなく「実行できませんでした」です。それを見て「テスト側の設定の問題だろう」と後回しにすると、そのテストが守っていたはずのコードは一度も検証されないまま通り続けます。

実際、あるモジュールのFunctionalテスト11件が、この設定ミスのせいで長期間一度も実行されていませんでした。テストは書いてある、CIは回っている、なのに中身は動いていない。「テストがある」という安心だけがあって、実体が無い状態です。

まとめ

  • SIMPLETEST_BASE_URL.localhost ホストを使わない。 curl は RFC 6761 で .localhost をDNS無視で 127.0.0.1 に向けるため、Docker 内から web コンテナに届かない
  • Docker ネットワーク内部でだけ解決するサービス名(web 等)を使う。 外から名乗れないので信頼ホストに足しても安全
  • trusted_host_patterns への追加は settings.local.php の include より後に置く。 local 側が代入で上書きしていると、前に書いた分は消える
  • Functional テストの error は failure ではない。 「コードが壊れている」ではなく「テストが実行できていない」であり、放置するとテストがある顔をして中身が空になる

この記事は、事業・Webサイトの売買プラットフォーム RIKKA M&A を開発する中で得た知見です。RIKKA M&A は GitHub リポジトリを解析する技術デューデリジェンスを備え、Drupal 11 / PHP / LLM で構築・運用しています。

サイト・アプリ・AIプロダクトM&Aのプラットフォーム RIKKA M&A