共有レンタルサーバーの罠 #1/全5本。症状→原因→対策の索引は 🔗 罠ハブ から。
はじめに
共有レンタルサーバー(HETEML など CGI/suEXEC 系)で PHP アプリを運用している方へ。
デモの作りすぎを止めるレート制限を共有ホスティングに載せるとき、カウンタの置き場所を間違えると「本番だけ何回連打しても 429 が返らない」という壊れ方をします。ローカルの Docker では期待どおり効くのに、です。踏みやすい型なので、この記事は最初からそれを避けた設計を実コードで示します。
理由は「レート制限のカウンタをプロセス内メモリに置く」設計にあります。この記事では、なぜ CGI 系の共有ホスティングで InMemory 実装が発動しないのかを実行モデルから示し、だから最初からファイルに退避する fixed window(flock 排他・fail-open)で実装した——その具体を実コードで追います。
この記事で分かること:
- 共有ホスティング(CGI/suEXEC・PHP-FPM)で InMemory カウンタが機能しない構造的な理由
- ファイル退避の fixed window レート制限を
flockで安全に実装する具体 - 壊れたとき「通すか止めるか」(fail-open / fail-close)を用途で決める判断
前提を先に共有します。自己ホスト型の請求書管理 OSS NeNe Invoice(PHP + SPA)に、「URL を踏むだけで試せる」使い捨てデモ環境を載せています。デモリンクを踏むたびに専用のデモ組織(org)を 1 個作り、TTL(3 時間)超過分を毎時の cron で刈り取る設計です。作りっぱなしだと org が無限に増えるので、1 クライアントあたり 1 時間 30 回のレート制限(throttle)を入れました。自作フレームワーク NENE2 には RateLimitStorageInterface という差し込み口があり、開発用に InMemoryRateLimitStorage が同梱されています。ローカルの Docker なら InMemory でも連打で 429 が返り、テストも緑になります。ですが本番は共有レンタルサーバー(HETEML)。リクエスト毎プロセスの CGI 系であるここでは、InMemory を選んだ時点で「何回連打しても 429 が返らない」ことが確定します——理由は実行モデルにあります。だからこのデモの throttle は、最初から InMemory を避けてファイル退避で実装しました。
原因:InMemory カウンタはプロセスごと消える
共有ホスティングではリクエストごとにプロセスが生き死にするため、プロセス内メモリのカウンタは毎回 1 に戻ります。
同梱されている InMemoryRateLimitStorage の実体は、ただの PHP 配列です。
final class InMemoryRateLimitStorage implements RateLimitStorageInterface
{
/** @var array<string, array{count: int, reset_at: int}> */
private array $store = [];
public function hit(string $key, int $windowSeconds): array
{
$now = time();
if (!isset($this->store[$key]) || $this->store[$key]['reset_at'] <= $now) {
$this->store[$key] = [
'count' => 1,
'reset_at' => $now + $windowSeconds,
];
} else {
$this->store[$key]['count']++;
}
return $this->store[$key];
}
}
hit() を呼ぶたびにカウントを +1 し、閾値を超えたら弾く。ロジックとしては正しい fixed window です。docblock にも用途がはっきり書いてあります。
State is held in a plain PHP array and is therefore NOT shared between PHP-FPM worker processes. For production, inject a shared implementation (Redis, Memcached, or a database-backed store)
問題は private array $store の寿命です。この配列は PHP プロセスのメモリにしか存在せず、プロセスが死ねば一緒に消えます。そして共有レンタルサーバーの実行モデルはここに致命的に噛み合いません。
- 多くの共有ホスティング(HETEML など)は PHP を CGI / suEXEC 系で動かす。ざっくり言うとリクエストごとにプロセスが起きて、レスポンスを返して死ぬ。つまり
$storeは毎リクエスト空の[]から始まる。 - 仮に PHP-FPM のようにワーカーが常駐する構成でも、リクエストは複数ワーカーに散る。ワーカー A で数えたカウントはワーカー B には見えない。docblock が言う「NOT shared between worker processes」がまさにこれ。
結果、CGI 系では hit() は毎回まっさらな配列に対して count=1 を書き込むだけになります。閾値を 30 に設定していようが、カウンタが 2 以上に育つ前にプロセスごと消えるので、count > 30 は成立しようがない(FPM 常駐でも、カウントがワーカー間に散るぶん実効閾値が大きく狂います)。制限が発動しないのではなく、状態が保持されないので発動しようがないわけです。
ローカルの docker compose では単一プロセスが生き続けるため、$store が育って正しく 429 が返る。だから「ローカルでは再現しない・本番だけ効かない」という、一番気づきにくいズレになりがちです。InMemory のまま共有ホスティングへ出していたら、まさにこれを踏んでいました。
ここは考え方が同じであれば言語を問いません。「プロセス内メモリにレート制限の状態を置く」のは、リクエスト毎プロセスの環境では常に壊れます。
対策:ファイル退避の fixed window(flock 排他)
共有ホスティングでプロセスをまたいで生き残る状態の置き場所は、実質「書き込めるディスク」か「外部ストア(Redis / Memcached / DB)」です。HETEML には Redis も Memcached もなく、アプリが書けるのは var/ ディレクトリだけ。そこで、カウンタを var/rate-limits/ 配下の JSON ファイルに置く FileRateLimitStorage を実装しました。同じリポの FileRecurringRunThrottle(定期請求の実行スタンプをファイルに置く)と同じ発想です。
インターフェースは InMemory 版と同一(hit(string $key, int $windowSeconds): array)なので、差し込み先はストレージを差し替えるだけで済みます。
public function hit(string $key, int $windowSeconds): array
{
$now = $this->clock->now()->getTimestamp();
$dir = $this->baseDir . '/rate-limits';
if (!is_dir($dir)) {
@mkdir($dir, 0o775, true);
}
$file = $dir . '/' . hash('sha256', $key) . '.json';
$handle = @fopen($file, 'c+');
if ($handle === false) {
error_log(sprintf('NeNe Invoice: could not persist rate-limit state at %s; this hit is not counted against the window.', $file));
return ['count' => 1, 'reset_at' => $now + $windowSeconds];
}
try {
flock($handle, LOCK_EX);
$raw = stream_get_contents($handle);
$state = is_string($raw) && $raw !== '' ? json_decode($raw, true) : null;
$count = 1;
$resetAt = $now + $windowSeconds;
if (is_array($state) && isset($state['count'], $state['reset_at']) && (int) $state['reset_at'] > $now) {
$count = (int) $state['count'] + 1;
$resetAt = (int) $state['reset_at'];
}
rewind($handle);
ftruncate($handle, 0);
fwrite($handle, (string) json_encode(['count' => $count, 'reset_at' => $resetAt]));
return ['count' => $count, 'reset_at' => $resetAt];
} finally {
flock($handle, LOCK_UN);
fclose($handle);
}
}
ポイントを分解します。
キー設計とファイル配置
throttle のキーは、フレームワーク側の既定で demo-start:ip:<REMOTE_ADDR>(クライアント IP 単位)になっています。これをそのまま sha256 でハッシュしてファイル名にします。
$file = $dir . '/' . hash('sha256', $key) . '.json';
ハッシュにするのは、キーに何が入っても(IP、将来的にユーザ ID など)安全なファイル名になり、パス長やパストラバーサルを気にしなくて済むからです。1 キー = 1 ファイルなので、キー同士でロック競合しません。
なお、この実装は REMOTE_ADDR をそのまま使っており、X-Forwarded-For の考慮は入れていません。前段に IP を付け替えるリバースプロキシや CDN が挟まる構成へ写経すると全クライアントが 1 バケツを共有して正規の利用者まで 429 になるので、まず自分の環境で REMOTE_ADDR に何が入るかを実測してから決めてください。また CGNAT や社内 NAT では複数ユーザーが同一 IP を共有するため、この上限は「同一 IP からの合計 30 回」という意味になります。
fixed window の判定
読み込んだ状態の reset_at がまだ未来なら同じ窓の続きとして count を +1、reset_at は据え置き。すでに過ぎていれば新しい窓として count=1・reset_at = now + windowSeconds。これが fixed window の本体です。
fixed window には、窓の境界をまたぐと最悪 2×limit(この設定なら短時間に最大 60 回)通ってしまう既知の特性があります。この用途は「作りすぎ防止」で、上位に org 天井と sweep の歯止めがあるため許容しています。ログイン試行制限のようなセキュリティ境界に写経する場合は sliding window 等を検討してください。
窓が生きている間 reset_at が動かないことは、テストでも固定しています。
self::assertSame($first['reset_at'], $third['reset_at'], 'window expiry must not move on subsequent hits');
そして「別インスタンス(=別プロセス相当)から hit() してもカウントが積み上がる」ことも、InMemory では絶対に通らないテストとして明示的に置いています。
public function test_state_survives_across_instances_like_separate_processes(): void
{
$key = 'demo-start:ip:203.0.113.7';
(new FileRateLimitStorage($this->baseDir, new FixedClock('2026-07-09T00:00:00Z')))->hit($key, 3600);
$second = (new FileRateLimitStorage($this->baseDir, new FixedClock('2026-07-09T00:00:30Z')))->hit($key, 3600);
self::assertSame(2, $second['count']);
}
窓幅と上限は呼び出し側で 30 回 / 3600 秒(1 時間 30 回)に設定しています。判定は count > limit、つまり31 回目で弾く形です。
$result = $this->throttleStorage->hit($key, $this->throttleWindowSeconds);
if ($result['count'] > $this->throttleLimit) {
$retryAfter = max(0, $result['reset_at'] - $this->clock->now()->getTimestamp());
throw new DemoThrottledException(/* ... */, $retryAfter);
}
Retry-After は reset_at - now から出しているので、429 のときに「あと何秒(何分)で解除されるか」をそのままユーザに返せます。
flock による排他と fail-open
ファイルにカウンタを置くと、同じ IP からの同時リクエストが同じファイルを read-modify-write する競合が生まれます。読んで +1 して書く間に別リクエストが割り込むと、カウントを取りこぼす(実際より少なく数える)。ここを flock の排他ロックで直列化しています。
$handle = @fopen($file, 'c+'); // c+ : 無ければ作成・read/write・truncate しない
// ...
flock($handle, LOCK_EX); // 排他ロック(他は待たされる)
// read → +1 → write
flock($handle, LOCK_UN); // finally で必ず解放
fclose($handle);
-
fopen(..., 'c+')はファイルを truncate せずに開き、無ければ作る。既存カウントを読んでから上書きしたいのでw+(開いた瞬間 0 バイトに切り詰める)ではなくこれ。 -
LOCK_EXはブロッキング。同じファイルを触る後続リクエストはロックが空くまで待つので、read-modify-write がアトミックになります。 - ロック解放と
fcloseはfinallyに置いているので、途中で例外が飛んでもロックは残りません。
書き込めないときは通す(fail-open)
共有ホスティングでは、ディスク quota・パーミッション・var/ の欠損などでファイルを開けないことが起こりえます。そのとき throttle をどう振る舞わせるか、は設計判断です。この実装は fail-open(開けなかったら通す)を選んでいます。
$handle = @fopen($file, 'c+');
if ($handle === false) {
error_log(/* ... 状態を永続化できなかった旨 ... */);
return ['count' => 1, 'reset_at' => $now + $windowSeconds];
}
fopen が失敗したら、そのヒットを「新しい窓の 1 回目」として返す=呼び出し側から見ればカウント 1 なので throttle を通過します。ログには「このヒットは窓にカウントされていない」と残す。
fail-open にした理由は、この throttle が守っているのはセキュリティ境界ではなく“作りすぎ防止”だからです。ストレージが壊れたときに正規ユーザのデモ開始まで全部 503 で止める(fail-close)より、多少の作りすぎを許して機能を生かす方が、この用途では損失が小さい。しかも作りすぎの上限は throttle だけに依存しておらず、インスタンス全体のデモ org 数の天井(別途 COUNT(*) で判定)と毎時の sweepが最終的なブレーキとして残ります。二重三重の歯止めがあるからこそ、一段目の throttle は fail-open で割り切れる、という構図です。
用途がログイン試行数のようなセキュリティ境界なら、この判断は逆(fail-close)にすべきです。fail-open / fail-close は「壊れたときに安全側はどっちか」を用途ごとに決める話で、無条件の正解はありません。
溜まったファイルの掃除
キーごとにファイルが増える(IP の数だけ *.json が溜まる)ので、放置するとゴミが蓄積します。デモ org を刈る毎時の sweep スクリプトのついでに、窓幅の 2 倍より古い(=完全に失効した)状態ファイルを消しています。
$staleBefore = $clock->now()->getTimestamp() - (DemoServiceProvider::THROTTLE_WINDOW_SECONDS * 2);
foreach (glob($root . '/var/rate-limits/*.json') ?: [] as $window) {
$mtime = @filemtime($window);
if ($mtime !== false && $mtime < $staleBefore) {
@unlink($window);
}
}
窓幅ちょうどではなく ×2 にしているのは、reset_at の境界付近で消し過ぎて現役の窓を巻き込まないための安全マージンです。なおこの @unlink は throttle 側の flock と理論上レースします(fopen 直後・flock 前に消されると、そのリクエストのカウントが新しい窓に載らない)が、対象が失効ファイルだけなので取りこぼしは最大 1 カウント・fail-open 方向であり、許容しています。
学び
- 共有ホスティングでは「状態はプロセス外」が大前提。リクエスト毎にプロセスが生き死にする(あるいはワーカー間で共有されない)環境では、プロセス内メモリに置いた状態は無いのと同じ。レート制限・キャッシュ・カウンタの類は、最初から永続先(ファイル / DB / 外部ストア)を選ぶ。
- 「ローカルで再現しない・本番だけ壊れる」バグはこの型が多い。単一プロセスで動く開発環境は、プロセス跨ぎの状態共有問題を構造的に隠す。InMemory 実装のテストがいくら緑でも、それは「1 プロセス内で正しい」ことしか保証していない。だから “別インスタンスから hit しても積み上がる” という、プロセス跨ぎを模したテストを 1 本入れておくと、退行を早期に捕まえられる。
-
ファイルに状態を置くなら read-modify-write を
flockで直列化する。開くのは truncate しないc+、ロック解放はfinally。同一キー = 同一ファイルにすればロック粒度も自然に細かくなる。 - fail-open / fail-close は用途で決める。この throttle は“作りすぎ防止”で、上位に org 天井と sweep という歯止めがあるので fail-open。セキュリティ境界なら fail-close。壊れたときの安全側がどちらかを、実装前に言語化しておく。
同じ「共有レンタルサーバーで踏んだ罠」シリーズとして、② Authorization ヘッダが前段プロキシに握りつぶされる話も書いています。③ 共有レンタルサーバーで作成1秒のorgが即消える|JST×UTC created_at 素パース も公開しました。共有ホスティングは、ローカルの Docker ではまず出ない壊れ方の宝庫です。
一次資料
-
nene-invoice/src/Demo/FileRateLimitStorage.php— file-backed fixed window 本体(flock排他・fail-open) -
nene-invoice/tests/Demo/FileRateLimitStorageTest.php— プロセス跨ぎ・窓の継続/失効・キー独立のテスト -
nene-invoicevendor:Nene2\Middleware\InMemoryRateLimitStorage/RateLimitStorageInterface— 同梱 InMemory 実装とインターフェース契約 -
nene-invoice/vendor/hideyukimori/nene2/src/Demo/CountingDemoCapacityGuard.php—hit()の消費側(count > limit判定・Retry-After算出) -
nene-invoice/src/Demo/DemoServiceProvider.php— 上限 30 回 / 窓 3600 秒・ストレージ配線 -
nene-invoice/tools/sweep-demo.php— 失効した窓ファイルの掃除(窓幅×2 閾値) - nene-invoice #608(/demo のレート制限ギャップ)/PR #611(Demo consumer 化・FileRateLimitStorage 新設)
関連記事(共有レンタルサーバーの罠シリーズ)
- 🔗 罠ハブ:症状→原因→対策の索引(全5本の入口・随時更新)
- ② Authorization ヘッダが前段プロキシに握りつぶされる(公開済)
- ③ 共有レンタルサーバーで作成1秒のorgが即消える|JST×UTC created_at 素パース(公開済)
- ④ 共有レンタルサーバーで派生画像が全部500になる|AVIF交渉・エンコーダ不在(公開済)
── 森 秀之(彩音インターナショナル) 🔗 ayane.co.jp