共有レンタルサーバーの罠 #3/全5本。症状→原因→対策の索引は 🔗 罠ハブ から。
はじめに
date.timezone が Asia/Tokyo の共有レンタルサーバーで、UTC の日時を DB に書いている方へ。
ある日、使い捨てデモ環境の掃除 cron(sweep)が、作成 1 秒のデモ組織まで「期限切れ」として即座に全滅させる障害を起こしました。TZ を UTC で動かしているローカルの Docker では出ません。
犯人は、UTC で書かれた created_at をタイムゾーン指定なしで new DateTimeImmutable() で素パースしていたこと。ホスト既定 TZ が JST のため、あらゆる作成時刻が実際より 9 時間古く解釈され、TTL 3 時間を全部飛び越えていました。この記事では、その障害を本番ログで実証し、TZ を明示する 1 行修正を入れ、JST/UTC 両ホストで回る回帰テストで封じるまでを時系列で追います(解決済み)。
この記事で分かること:
-
new DateTimeImmutable($str)の素パースが暗黙にホスト TZ へ依存する仕組みと、本番だけで牙を剥く理由 - UTC 契約の日時文字列を型(
new DateTimeZone('UTC'))で固定する 1 行修正 - ホスト TZ を
-d date.timezone=...で揺さぶり、ミューテーションまで確認する回帰テストの書き方
前提を共有します。使い捨てデモ環境は、デモリンクを踏むたび専用の org を作り、TTL(3 時間)超過分を毎時の sweep で刈り取る設計です。created_at は MySQL の CURRENT_TIMESTAMP(実効 UTC)で入ります。対象読者は PHP の実務中級、境界(DB は UTC/表示は JST)を型でどう固定するかが主題です。
障害の時系列
現象:デモ組織が「再訪すると新品」になる
最初の違和感は、「デモ環境を開いて操作している途中で、自分の org が消える」という散発的な報告でした。ところが再度アクセスすると新しい org が発行されて動くので、「毎回新品になるのが仕様」に見えて露見しにくい。実害の痕跡はログに残っていました。
sweep のログ var/sweep.log に、こういう行が並んでいたのです(引用は当時の障害記録の省略形です。実際のログ書式はこのほかに overflow と throttle file(s) pruned の欄を含みます)。
4 org(s) total, 4 expired, 4 reaped
存在する org を全件 expired 判定し、全件刈り取っている(reaped = 刈り取り実行数)。調査を始めた時点で、デモ組織は 0 件でした。毎時 10 分に走る cron が、閲覧中の org も含めて毎回全滅させていたわけです。
本番の前提を実測する
「作成直後の org が期限切れになる」という現象は、ホストのタイムゾーンとDB の書き込みタイムゾーンの食い違いを疑うのが定石です。憶測で直す前に、本番の 2 つの前提を実測しました。
| 項目 | 実測値 |
|---|---|
PHP CLI の date.timezone
|
Asia/Tokyo |
MySQL の NOW()
|
UTC_TIMESTAMP() と一致(time_zone=SYSTEM だが実効 UTC)→ created_at は UTC で書き込み
|
このデモ環境の created_at は、アプリが明示的に書くのではなく、マイグレーション(Phinx の addTimestamps())が張った DB デフォルト(CURRENT_TIMESTAMP)で入ります。MySQL サーバが実効 UTC であることをここで実測して初めて、「UTC で書かれた文字列を JST として読む → 9 時間過大な経過時間」という筋書きが確定しました。
修正前の実打で再現する
前提が揃ったので、本番と同じ構成で最小再現をとりました。
- デモ生成エンドポイントで org を 1 個作る(作成からの経過 = 1 秒)
- 掃除スクリプトを手で走らせる:
php8.4 tools/sweep-demo.php
結果:
1 org(s) total, 1 expired, 0 overflow, 1 reaped
作成 1 秒の org が TTL(3h) 超過扱いで即 reap。ログに残っていた「全滅」と同じ現象を、手元で決定論的に再現できました。ここまでで「タイムゾーンの素パースが犯人」という仮説が実測で裏付けられます。
なぜ全滅したのか — 素パースは暗黙のホスト TZ 依存
UTC で書かれた文字列をホスト TZ(JST)として読むと、同じ瞬間が 9 時間手前へズレます(下図)。
sweep の期限判定はシンプルです。フレームワーク側の掃除ロジックは、こう書かれています(DisposableDemoSweeper)。
// clock は UtcClock(now() は UTC)
$cutoff = $this->clock->now()->getTimestamp() - ($this->config->ttlHours * 3600);
foreach ($orgs as $org) {
if ($org->createdAt->getTimestamp() < $cutoff) {
$expired[] = $org->orgId; // 作成が cutoff より前 = 期限切れ
}
}
「今」は UtcClock から UTC で取り、そこから TTL(3 時間)を引いた cutoff より createdAt が古ければ期限切れ。ロジック自体は正しい。問題は createdAt をどう作っていたかでした。
呼び出し側(tools/sweep-demo.php)が、DB の行をこう組み立てていました。
$records[] = new DemoOrgRecord(
orgId: (int) $row['id'],
createdAt: new DateTimeImmutable((string) $row['created_at']), // ← TZ 未指定
);
new DateTimeImmutable('2026-07-10 11:00:00') のようにタイムゾーンを指定しないと、PHP はその文字列をホストの既定タイムゾーンの壁時計として解釈します。本番のホストは Asia/Tokyo。
- DB の
created_atは UTC の壁時計(例:2026-07-10 11:00:00は本当は UTC の 11:00) - それを JST として読む →
2026-07-10 11:00:00+09:00= UTC 02:00 の瞬間 - つまり
getTimestamp()が実際より 9 時間手前を指す
一方 cutoff の側は UtcClock から正しく UTC で取っている。片側だけ 9 時間ズレた結果、作成 1 秒の org でも「9 時間前に作られた」ことになり、TTL 3 時間を軽々と超えて全件 expired。これが「全滅」の正体です。
ポイントは、フレームワークの契約はちゃんと UTC を要求していたことです。DemoOrgRecord の docblock にはこう明記されていました。
/**
* ...
* $createdAt must be in UTC, matching {@see \Nene2\Http\ClockInterface}.
*/
契約は「UTC で渡せ」と言っている。素パースはその契約を暗黙のうちに破り、ホストのロケール設定に判定を委ねてしまっていた——共有レンタルサーバーのように「PHP の date.timezone が JST に設定されている」環境で牙を剥くタイプのバグです。
対策:パースの TZ を型で固定する
修正は 1 行、タイムゾーンを明示するだけです。
$records[] = new DemoOrgRecord(
orgId: (int) $row['id'],
// created_at は UTC で書かれている(UTC サーバの MySQL CURRENT_TIMESTAMP。
// SQLite の CURRENT_TIMESTAMP も常に UTC)。UTC として明示パースする。
// ホスト既定 TZ が UTC より進んでいる本番(Asia/Tokyo)では、素パースだと
// すべての新規 org が数時間古く見え、その場で期限切れになる。
createdAt: new DateTimeImmutable((string) $row['created_at'], new DateTimeZone('UTC')),
);
new DateTimeZone('UTC') を第 2 引数に渡すと、created_at の壁時計を UTC として解釈し、cutoff(UTC)と同じ土俵で getTimestamp() を比較できます。これでホストの date.timezone が何であっても結果は変わりません。
本番反映は、変更が tools/sweep-demo.php の 1 ファイルだけだったので、rsync -avc で当該ファイルだけ更新(.env や var/、DB スキーマは一切触らない)。反映後の実打検収は次のとおり、すべて通りました。
| 検収項目 | 結果 |
|---|---|
| fresh org(作成直後)が生存 |
2 org(s) total, 1 expired, 1 reaped — 生き残りは fresh のみ |
| TTL 超過 org だけ reap | backdate した方だけ reap |
| 固定ショーケース org は不可触 | 健在 |
| 冪等(2 回目は no-op) | 1 org(s) total, 0 expired, 0 overflow, 0 reaped |
JST/UTC 両実行の回帰テスト
ワンライナ修正で怖いのは、次に誰かが写経したときに同じ素パースが復活することです。単体テストを 1 つの TZ でしか回さないと、CI が UTC で回っている限り緑のままバグが通り抜けます。そこで「本番の JST ホスト」を再現するテストを足しました(tests/Demo/SweepDemoScriptTest.php・新規)。
方針は 2 つ。
-
実スクリプトを子プロセスで走らせる。関数を呼ぶのではなく、
tools/sweep-demo.phpそのものをproc_openで起動する。パース箇所も含めた実際の実行経路をまるごと固定できる。 -
ホストの
date.timezoneを明示的に切り替える。php -d date.timezone=Asia/Tokyo(本番構成)とUTCの両方で同じシナリオを流し、結果が一致することを assert する。
private function runSweep(string $timezone): string
{
$root = dirname(__DIR__, 2);
$command = [
PHP_BINARY,
'-d', 'date.timezone=' . $timezone, // ← ここで本番の JST を再現
$root . '/tools/sweep-demo.php',
];
// .env より明示 env を優先(Dotenv::safeLoad() は既存値を上書きしない)
$env = [
'DB_ADAPTER' => 'sqlite',
'DB_NAME' => $this->dbPath,
'DEMO_TTL_HOURS' => '3',
'DEMO_MAX_ORGS' => '200',
'PATH' => (string) getenv('PATH'),
];
$process = proc_open($command, [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes, $root, $env);
// ... stdout を回収して返す(exit code 0 を assert)
}
シナリオは、UTC で書いた created_at を 3 種類仕込みます。
public function testFreshOrgSurvivesAndExpiredOrgIsReapedOnAJstHost(): void
{
$nowUtc = time();
$this->insertOrg(1, 'demo-fresh', gmdate('Y-m-d H:i:s', $nowUtc - 60)); // 1 分齢
$this->insertOrg(2, 'demo-expired', gmdate('Y-m-d H:i:s', $nowUtc - 4 * 3600)); // TTL(3h) 超過
$this->insertOrg(3, 'demo', gmdate('Y-m-d H:i:s', $nowUtc - 30 * 24 * 3600)); // 固定ショーケース org(ハイフンなし)
$output = $this->runSweep('Asia/Tokyo');
self::assertStringContainsString('2 org(s) total, 1 expired, 0 overflow, 1 reaped', $output);
self::assertSame(['demo', 'demo-fresh'], $this->remainingSlugs());
}
created_at は必ず gmdate()(= UTC)で作るのがミソです。「DB は UTC で書く」という本番の前提をテスト側でも守り、そこから先の読み取りだけをホスト TZ で揺さぶる。
このシナリオで固定するのは 3 点です。
- fresh org(1 分齢)は生存する — 修正前はこれが JST ホストで 9 時間齢に化けて刈られていた
- TTL 超過 org(4 時間齢)はちゃんと reap される — 期限切れ判定そのものは殺さない
-
固定ショーケース org(slug
demo・ハイフンなし)は不可触 — sweep のクエリはslug LIKE 'demo-%'で、ハイフンなしのdemoは最初から対象外。だからtotalは 2、残る slug にdemoが含まれる
UTC ホスト版(testBehavesIdenticallyOnAUtcHost)は同じ assert を UTC で流し、どちらのホストでも挙動が一致することを保証します。
そして重要なのがミューテーション検証です。修正(new DateTimeZone('UTC'))を外して素パースに戻すと、JST ケースは fresh org まで刈られて 2 expired, 2 reaped(残る slug は demo だけ)になり、テストが赤くなることを確認済み。「このテストは本当にこのバグを捕まえる」という保証がなければ、回帰テストは飾りです。
学び
-
new DateTimeImmutable($str)の素パースは、暗黙のホスト TZ 依存。開発機(UTC や、たまたま合っている環境)で緑でも、date.timezone=Asia/Tokyoの共有レンタルサーバーで牙を剥く。UTC 契約の文字列は必ずnew DateTimeZone('UTC')を添えてパースする。 - 境界を型で固定する。DB は UTC、表示は JST——この境界は「規約」ではなくパースの引数で固定できる。docblock に「UTC で渡せ」と書いてあっても、呼び出し側が素パースすれば契約は破れる。破れない書き方をデフォルトにする。
-
時刻バグは片側だけズレると起きる。今回は
cutoff側(UtcClock)が正しく、createdAt側だけがズレた。「今」と「作成時刻」を同じ土俵(同じ TZ)でgetTimestamp()比較しているかを疑う。 -
回帰テストはホスト TZ を揺さぶってこそ意味がある。CI が回っている TZ でしかテストしないと、TZ 依存バグは緑をすり抜ける。本番の TZ を
-d date.timezone=...で明示的に再現し、JST/UTC の両方で同じ結果を assert する。ミューテーション(修正を外すと赤)まで確認して初めて回帰テストと呼べる。
このデモ環境は同じ雛形から複数のサービスへ写経して展開しており、実は別サービスで先に同型のバグが顕在化していました。写経元のフレームワークは最初から UTC 明示パースだったのに、写経の過程で 2 つのサービスが TZ 指定を落としていた——横展開の警告を受けて全サービスを点検し、これで手元の全サービスの sweep が UTC 明示パースに揃いました。写経時のチェック観点に「時刻文字列のパース TZ」を入れるのが、いちばん安い再発防止です。
一次資料
- 修正:nene-clear #281(issue #280)(実スクリプトの UTC 明示パース化+ JST/UTC 回帰テスト)、同型の先行修正 nene-deal #72(回帰テストは #106)
- フレームワーク側の契約:
Nene2\Demo\DemoOrgRecord($createdAtは UTC)、Nene2\Demo\DisposableDemoSweeper(TTL/overflow 判定)
関連記事
- 🔗 共有レンタルサーバーの罠ハブ(症状→原因→対策の索引)(全5本の入口・随時更新)
- 同シリーズ:① 共有レンタルサーバーで InMemory レート制限が発動しない|PHP CGI・flock、④ 共有レンタルサーバーで派生画像が全部500になる|AVIF交渉・エンコーダ不在
-
時間とテストの連作:
time()と裸のdate()を独自リンタで狩る(公開済)→ 本記事(TZ 素パースの障害) → Clockを注入したのにテストがフレークした(公開済)。同じ「時刻」テーマでも、本記事はパースの TZ 依存が本番で起こした障害の実話が主役です。 - 「URL を踏むだけで試せる」デモ環境を使い捨てテナント方式で作る
── 森 秀之(彩音インターナショナル) 🔗 ayane.co.jp