共有レンタルサーバーの罠 #5/全5本(最終回)。症状→原因→対策の索引は 🔗 罠ハブ から。
はじめに
phpdotenv を使いつつ getenv() も併用していて、共有レンタルサーバーに .env で設置している方へ。
共有レンタルサーバー向けの配布物(.env だけを置く設置形態)を用意する過程で、この設置を手元のコンテナで再現したところ、一部の設定(APP_BASE_PATH / BASE_DOMAIN / ORG_SLUG / TENANT_RESOLUTION / MEDIA_STORAGE_DRIVER)だけがそろって既定値に落ちました。.env の中身は正しく、APP_ENV や DB 接続は効いているのに、です。Docker 本番では実環境変数で値を渡していたため表面化していなかった潜在バグです。
犯人は、vlucas/phpdotenv を v4→v5 へ上げたときに変わっていた「環境変数を載せる場所」。v5 の既定は putenv() しないため、getenv() 直読みのコードだけが静かに壊れていました。この記事では、原因を実装で確かめ、getenv() 直読みを1箇所で救う env ブリッジで直すまでを追います(解決済み)。
この記事で分かること:
- phpdotenv v5 の既定アダプタが
$_ENV/$_SERVERのみでputenv()しないこと、その事実確認の仕方 - 同じ
.envでも「読み口が$_ENVかgetenv()か」で結果が割れる仕組み -
getenv()直読みを各所に散らしたまま、エントリポイント1箇所で吸収する env ブリッジ(実環境変数優先)
なぜ一部の設定だけ既定値に落ちるのか
v5 は .env を $_ENV / $_SERVER にだけ載せ、putenv() しません。だから getenv() 直読みの設定だけが既定値に落ちます(下図)。
phpdotenv v5 の既定は「$_ENV / $_SERVER のみ」
まず事実確認から。今回のプロジェクトは vlucas/phpdotenv: ^5.6(実体は v5.6.3)を使っています。エントリポイントでの読み込みはごく普通の形です。
Dotenv\Dotenv::createImmutable(dirname(__DIR__))->safeLoad();
ここがポイントで、v5 の createImmutable() / createMutable() が使う「既定アダプタ」には putenv() を書くアダプタが含まれていません。ライブラリのソースを見ると一目瞭然です。
// vendor/vlucas/phpdotenv/src/Repository/RepositoryBuilder.php
private const DEFAULT_ADAPTERS = [
ServerConstAdapter::class, // $_SERVER に書く
EnvConstAdapter::class, // $_ENV に書く
];
既定アダプタは $_SERVER と $_ENV の2つだけ。getenv() / putenv() に相当する PutenvAdapter は入っていません。
PutenvAdapter が有効になるのは、名前に Unsafe が付いた明示的なファクトリを呼んだときだけです。
// vendor/vlucas/phpdotenv/src/Dotenv.php
public static function createUnsafeImmutable($paths, ...): self
{
$repository = RepositoryBuilder::createWithDefaultAdapters()
->addAdapter(PutenvAdapter::class) // ここで初めて putenv される
->immutable()
->make();
// ...
}
つまり getenv() を経由して .env の値を読みたいなら、明示的に createUnsafeImmutable()(または ->addAdapter(PutenvAdapter::class))を選ぶ必要がある ということです。v4 までは既定で putenv() していたので、素朴に v5 へ上げると getenv() 直読みのコードが静かに壊れます。「危険(unsafe)」という名前が付いているとおり、getenv() はスレッドセーフでない等の理由で v5 では既定から外されました。
「効く設定」と「落ちる設定」を分けたのは読み口の違い
なぜ 全滅ではなく一部だけ 落ちたのか。それは、設定を読む口が2系統あったからです。
DB 接続などを組み立てる設定ローダは、$_SERVER / $_ENV を直接見ていました。
// ConfigLoader::readEnvironmentValues()
$value = $_SERVER[$key] ?? $_ENV[$key] ?? null;
こちらは v5 の既定アダプタが書き込む場所そのものなので、.env しか無くても正しく効きます。DB 接続情報が生きていたのはこのためです。
一方、あとから足したプロバイダ設定は getenv() を直読みしていました。
// src/Http/BasePath.php
$raw = getenv('APP_BASE_PATH');
// src/Http/RuntimeServiceProvider.php
$resolvedSlug = (string) (getenv('ORG_SLUG') ?: '');
$resolvedDomain = (string) (getenv('BASE_DOMAIN') ?: 'localhost');
$resolvedMode = $sysConfig->get('tenant_resolution_mode')
?: (string) (getenv('TENANT_RESOLUTION') ?: 'single');
// src/Media/MediaServiceProvider.php
$driver = getenv('MEDIA_STORAGE_DRIVER') ?: 'local';
.env は $_ENV / $_SERVER にしか載っていないので、これらの getenv() は軒並み false を返し、?: の右側(既定値)に落ちます。同じ .env を読んでいるのに、読み口が $_ENV か getenv() かで結果が割れた ——これが「一部だけ既定値化」の正体でした。
Docker 本番で問題が出なかったのは、そこでは .env ファイルではなくコンテナの実環境変数として値を渡していたからです。実環境変数なら getenv() からも $_ENV からも読めるので、どちらの読み口でも効いていて、罠が露見しませんでした。
対策:env ブリッジで getenv() 直読みを救う
正攻法は「読み口を $_ENV に統一する」か「createUnsafeImmutable() に切り替える」かのどちらかです。ただ既存の getenv() 直読みが各所に散っており、一気に置換するのはリスクが高い。そこで、エントリポイントで一度だけ $_ENV → getenv() へ橋渡しするブリッジを1箇所に置いて吸収しました。実際に入れたコードがこれです。
// public_html/index.php
// 共有ホスティングブリッジ: phpdotenv v5 は .env を $_ENV/$_SERVER にしか
// 書かず putenv しないため、getenv() 直読みのプロバイダ設定(APP_BASE_PATH /
// BASE_DOMAIN / ORG_SLUG / TENANT_RESOLUTION / MEDIA_STORAGE_DRIVER)が「.env しか
// 無い」設置で全て既定値に落ちる。ここで .env を一度読み、実環境変数が
// 無いキーだけ putenv で写す(実環境変数が優先=Docker 系デプロイは挙動不変)。
if (is_file(dirname(__DIR__) . '/.env')) {
Dotenv\Dotenv::createImmutable(dirname(__DIR__))->safeLoad();
foreach ($_ENV as $key => $value) {
if (is_string($value) && getenv($key) === false) {
putenv($key . '=' . $value);
}
}
}
ポイントは3つあります。
-
.envが存在するときだけ動く。is_file()でガードしているので、.envを置かず実環境変数だけで運用する Docker 系デプロイでは、このブロックは丸ごとスキップされます。 -
実環境変数を絶対に上書きしない。
getenv($key) === false(=まだ載っていないキー)だけをputenv()します。実環境変数が既にあるキーには触れないので、実環境変数が常に優先されます。設置形態による挙動差が出ません。 -
getenv()を選ぶ理由。createImmutable()は$_ENV/$_SERVERを既に埋めているので、足りないのはgetenv()側だけ。だから$_ENVを回してgetenv()へ写せば、既存のgetenv()直読みコードをそのまま生かせます。読み口の置換ゼロで挙動を揃えられるのが狙いです。
createUnsafeImmutable() に差し替える手もありますが、その場合 .env の値が 無条件に putenv() されるため、「実環境変数を優先したい」という要件を別途書く必要が出ます。今回は「実環境変数優先・.env は穴埋め」を明示したかったので、既定の createImmutable() + 手動ブリッジにしました。
なお、この設置フローには派生の落とし穴がもう一つあります。immutable リポジトリは 既にセットされたキーを上書きしない ため、インストーラが .env を書き直したあと同一プロセス内で読み直しても古い値が残ります。インストーラ側では、書いた直後に $_ENV / $_SERVER / putenv() を明示的に上書きしてから後続処理へ渡す、という対処を入れています。
// public_html/install/index.php(.env を書いた直後)
foreach ($env as $key => $value) {
$_ENV[$key] = $value;
$_SERVER[$key] = $value;
putenv($key . '=' . $value);
}
「immutable は上書きしない」という v5 の性質が、リクエスト入口と設置処理の両方で顔を出したかたちです。
学び
-
ライブラリのメジャー更新は「環境変数の載る場所」を変えることがある。 phpdotenv は v4→v5 で
getenv()/putenv()を既定から外し、$_ENV/$_SERVERのみになりました。getenv()直読みのコードは、この一点だけで静かに既定値化します。 -
読み口を1本化する。 理想は
$_ENV(または設定ローダ)に寄せること。過渡期はエントリポイントの env ブリッジ1箇所で$_ENV→getenv()を吸収すると、散らばったgetenv()を触らずに揃えられます。 -
「実環境変数を上書きしない」を明示する。
.envが有る設置と、実環境変数だけの設置(Docker 等)の両方を1つのコードで通すには、「足りないキーだけ穴埋め」の条件が効きます。createUnsafe*で無条件にputenv()するときは、優先順位の要件が消えていないか確認しましょう。 -
同じ
.envでも設置形態で結果が割れる。 Docker(実環境変数)では露見せず、共有レンサバ(.envのみ)で初めて出る、という時間差バグになりがちです。設置形態ごとに一度は実機確認を。
一次資料
-
vlucas/phpdotenvv5.6.3 の実装- 既定アダプタが
$_SERVER/$_ENVのみ(PutenvAdapterを含まない):vendor/vlucas/phpdotenv/src/Repository/RepositoryBuilder.php(DEFAULT_ADAPTERS) -
getenv()を載せるにはcreateUnsafeImmutable()等でPutenvAdapterを明示追加:vendor/vlucas/phpdotenv/src/Dotenv.php
- 既定アダプタが
- 現象と対処(共有ホスティング Tier A 設置・#707 / PR #708)
- env ブリッジ本体:
public_html/index.php -
getenv()直読み側の例:src/Http/BasePath.php/src/Http/RuntimeServiceProvider.php/src/Media/MediaServiceProvider.php -
$_SERVER/$_ENV直読み側(既定でも効く設定ローダ): NENE2ConfigLoader::readEnvironmentValues() - 設置時の immutable 上書き対処:
public_html/install/index.php
- env ブリッジ本体:
関連記事
「共有レンタルサーバーの罠」シリーズの ⑤(最終回) です。
- 🔗 共有レンタルサーバーの罠ハブ(症状→原因→対策の索引)(全5本の入口・随時更新)
- ①「InMemory レート制限が発動しない」/②「Authorization ヘッダが消える」/③「作成1秒の org が即消える(JST×UTC)」/④「派生画像が全部500になる(AVIF)」(全て公開済)
── 森 秀之(彩音インターナショナル) 🔗 ayane.co.jp