0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

共有レンタルサーバーで作成1秒のorgが即消える|JST×UTC created_at 素パース

0
Last updated at Posted at 2026-07-18

共有レンタルサーバーの罠 #3/全5本。症状→原因→対策の索引は 🔗 罠ハブ から。

はじめに

date.timezoneAsia/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 に、こういう行が並んでいたのです(引用は当時の障害記録の省略形です。実際のログ書式はこのほかに overflowthrottle 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_atUTC で書き込み

このデモ環境の created_at は、アプリが明示的に書くのではなく、マイグレーション(Phinx の addTimestamps())が張った DB デフォルト(CURRENT_TIMESTAMP)で入ります。MySQL サーバが実効 UTC であることをここで実測して初めて、「UTC で書かれた文字列を JST として読む → 9 時間過大な経過時間」という筋書きが確定しました。

修正前の実打で再現する

前提が揃ったので、本番と同じ構成で最小再現をとりました。

  1. デモ生成エンドポイントで org を 1 個作る(作成からの経過 = 1 秒
  2. 掃除スクリプトを手で走らせる: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 で当該ファイルだけ更新(.envvar/、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 つ。

  1. 実スクリプトを子プロセスで走らせる。関数を呼ぶのではなく、tools/sweep-demo.php そのものを proc_open で起動する。パース箇所も含めた実際の実行経路をまるごと固定できる。
  2. ホストの 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 判定)

関連記事


── 森 秀之(彩音インターナショナル) 🔗 ayane.co.jp

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?