[fix] 本番リリースに向けてAPIのホットパス・エラー表現・権限・CSV出力を修正 - #2
Merged
Conversation
本番リリースに向けた負荷テストの手順とリリース前の確認事項を docs/load-testing.md に整理した。修正作業を先に進めるため、計画を 先にリポジトリへ残す。 主な内容: - Free プランの上限とスキャン1回あたりのコスト(D1書き込み1行 + 読み取り2行 = 1日あたり最大10万スキャン) - 測定結果を読み違えないための注意点(1台からはエッジを飽和できない、 見るべきはCPU時間とrows read/written、429/1015はCloudflare側の 濫用対策の可能性) - k6 を選ぶ理由(オープンモデル負荷、redirects:0、thresholds) - シードデータに偏りを付ける理由と複数プロジェクト分散の必要性 - シナリオ S1〜S6(ローカル)と P1〜P3(本番、計約14,000件) - 後片付けでDELETEもrows writtenに計上される点と、DB作り直しが 書き込みゼロで済むこと - リリース前チェックリストとロールバック手順 - 将来のシール印刷ビューに向けた設計メモ あわせて .idea を .gitignore に追加した。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
想定運用は「ポスターなどの物ごとにQRを1つ生成し、必要であれば場所を 記載する」だが、ユニーク制約が (project_id, medium, location) だった ため、場所を空にした2枚目のポスターが409で作成できなかった。制約が 「媒体×場所ごとに1つ」をモデル化していて運用と矛盾していた。 - ユニーク制約を (project_id, name) に張り替え、location を任意化 (未設定は NULL ではなく '' で保持し、読み取り・CSV・インデックスが 見る表現を1つにする)。同じポスターへのQR二重発行を検知でき、名前から 組むダウンロードファイル名もプロジェクト内で一意になる。 - AccessLogs に qr_id → QRCodes(id) の外部キーを ON DELETE CASCADE で 追加。従来は外部キーが無く、プロジェクト削除でスキャン履歴が孤児化 してUIから到達できない行が残っていた。ローカルD1で、カスケード削除と 存在しないqr_idの拒否がどちらも実際に効くことを確認済み。 - (project_id, accessed_at DESC) の複合インデックスを追加し、冗長に なった idx_access_logs_project_id を削除。アクセスログ一覧とCSVは どちらも project_id で絞って accessed_at 降順に並べるのでソート段が 消え、あわせてスキャン1件あたりのインデックス書き込みが1つ減る。 - AccessLogs に is_bot 列を追加。ボットのヒットは捨てずにフラグで残す ことで、判定を後から改良して過去分を再集計できる。 - Drizzleスキーマに accessLogs.id を追加。SQL側には存在するのにモデルに 無く、accessed_at の安定したタイブレーカもCSVのキーセットカーソルも 表現できなかった。 移行は migrations/0002 に用意した。孤児化したログは新しい外部キーを 満たせないため移行時に削除される(件数を事前に数えるクエリをコメントに 記載)。schema.sql と 0001 のコメントも旧インデックス前提だったので更新。 QRCodesテーブルがQR画像ではなくメタデータを保持していること(画像は クライアント側で都度生成する)も、PR #1 のレビュー指摘を受けてコメントで 明示した。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
core.autocrlf=true のWindows環境では作業ツリーがCRLFになり、biomeが LFで整形するため `pnpm lint` が全ファイルで改行差分のエラーを出していた。 一方CI(ubuntu)はLFでチェックアウトするので通ってしまい、ローカルだけが 原因不明に落ちる状態だった。 git側は既に全ファイルLFで保存されていたので、リポジトリの内容は変わらず 作業ツリーの改行が揃うだけ。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
.claude/settings.local.json のようなマシン固有の未追跡ファイルが lint 対象になっており、CI には存在しないためローカルだけが落ちていた。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
スキャンのホットパスは全来場者が通る唯一の経路だが、301を返しD1へ3回 逐次アクセスし、しかもログのINSERTがレスポンスをブロックしていた。 - 301 → 302 + Cache-Control: no-store。301はブラウザとアプリ内WebViewに 無期限キャッシュされるため、(1) 同一端末の再スキャンがAPIに届かず計測が 過少になり、(2) 遷移先を変更しても既にスキャンした人には永久に届かな かった。既に301を受け取った端末は救済できない(QR再発行=再印刷しかない) ため、ポスター印刷前・本番負荷テスト前に入れる必要がある。GETなので307の メソッド保持は無意味で、QRリーダー内蔵ブラウザの互換性から302を選んだ。 - QR参照とプロジェクト参照を1本のleftJoinに統合し、逐次往復を1回削減。 innerJoinではなくleftJoinにして「QRが無い」「プロジェクトが無い」の 既存2分岐とステータスコードをそのまま再現している。 - ログのINSERTを executionCtx.waitUntil に移した。D1の書き込みはプライマリ 宛で3つのクエリの中で最も遅いのに、来場者にとっては不要な待ち時間だった。 代償として書き込み失敗がクライアントから見えなくなるので、失敗時に access_log_insert_failed を1行JSONで出しWorkers Logsで数えられるように し、さらに LOG_WRITE_MODE=sync でデプロイなしに同期へ戻せるようにした。 - ボット判定を src/user-agent.ts に切り出し、除外せず is_bot=1 で記録する ように変更。従来は facebook / Discordbot の部分一致だけで、Twitterbot や Slackbot が実スキャンとして黙って計上されていた。逆に LINE のアプリ内 ブラウザ(Line/13.x)は実ユーザーなので、明示的に除外して誤ってボット扱い しないようにしている(ここを逆にすると最大の実トラフィックが消える)。 ローカルD1で検証済み: 人間UA/LINEアプリ内ブラウザは302 + no-store、 line-poker と Twitterbot は200のOGメタHTML、不明IDは404で書き込みゼロ。 waitUntilの書き込みはリクエスト数と1:1で着地し、is_bot は Line/13.x が0、 line-poker と Twitterbot が1。LOG_WRITE_MODE=sync も動作を確認した。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
## エラーコード
文言をサーバーが決めていたため翻訳が原理的に不可能だった。英語の開発者
向け文字列("Invalid request body")がそのまま利用者に出る一方、409だけは
日本語がハードコードされていて、日本語UIの利用者には英語が、英語UIの
利用者には日本語が出ていた。src/errors.ts に安定したコードを定義し、
文言はクライアントが持つ形に変えた。ログイン失敗(INVALID_PASSWORD)を
セッション切れ(UNAUTHORIZED)と別コードにしたのは、パスワード間違いを
「セッションが切れました」と表示させないため。
## 遷移先URLの検証(セキュリティ)
zod の .string().url() は javascript: / data: / vbscript: / file: を全て
通すことを実測で確認した(zod 3.25.76)。その値をWorkerが302でリダイレクト
し、管理UIが <a href> として描画するため、プロジェクトを作成・編集できる
人による保存型XSS/オープンリダイレクトになっていた(管理画面のオリジンには
APIトークンがある)。http/https のみに制限した。
## 場所の任意化
「物ごとにQRを1つ生成し、必要であれば場所を記載する」運用に合わせ location
を任意にした。あわせて更新時の判定を真偽値から `!== undefined` に変更した。
空文字が偽なので、従来は一度入力した場所を消すことが不可能だった。
## 並び順
ORDER BY が無く、D1が返す順序は不定だった。そのため作成直後のプロジェクトが
1ページ目に現れる保証がなく(利用者は作成に失敗したと考えて作り直す)、
ページ間で行が重複・欠落しうる。全一覧エンドポイントに作成日時の降順を付け、
同一ミリ秒の並びを確定させるため第2キー(project_id / id)を足した。
## 集計の絞り込み
GET /projects は10行を描画するために、ページに関係なく AccessLogs と QRCodes
の全件を GROUP BY していた。現ページのIDに絞る形(inArray)にし、D1のバインド
パラメータ上限(約100)に対して余裕を持たせるためこのエンドポイントの limit
上限を50に下げた。
## 権限チェック
GET /projects, GET /:id, POST /, PUT /:id, GET /:id/access-logs, CSV は
認証のみで認可が無く、READMEの表と食い違っていた。単一管理者に
ALL_PERMISSIONS が付く現状では露出しないが、Verifierの差し替え口を用意した
意味が消えるので VIEW / EDIT / ANALYTICS を付けた。
## その他
- GET /projects/qrcodes が全プロジェクト横断の無制限 .all() だったのを
ページング化(Webからは未使用のため戻り値の形の変更は安全)。
- zod 全文字列に .max() を付与し、1行のサイズ=DBとCSVのサイズを有界にした。
- QR削除時の明示的な db.delete(accessLogs) を削除。migration 0002 で追加した
ON DELETE CASCADE が実際のD1バインディング経由で効くことを確認済み
(アクセスログ2件 → QR削除 → 0件)。削除あたりD1書き込みが1つ減る。
注意: Web側がエラーコードを見るのは後続PRなので、それまでの間は日本語UIでも
英語のフォールバック文言が出る。APIだけ先にデプロイする場合はその窓が開く。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
従来は全件を .all() してメモリ上で1本の巨大文字列を組み立てていた。10万行
だと Workers Free の CPU 10ms 上限(エラー1102)を確実に超え、128MBの
isolate上限にも近づくため、混雑したプロジェクトで1回クリックするだけで
Workerが落ちうる状態だった。
- ReadableStream + (accessed_at, id) のキーセットページングに変更。OFFSET
ページングは大きなテーブルで二次的に劣化する(SQLiteがスキップ分を毎回
走査して捨てる)が、カーソルなら各ページが
idx_access_logs_project_accessed_at 上の有界な範囲走査になる。
2,000行/ページ × 25ページ = 50,000行で、サブリクエスト50件上限の半分。
- 件数上限を超えたら 500 ではなく 413 + 実件数と上限を返し、期間を絞れば
よいことが分かるようにした。上限は CSV_MAX_ROWS でデプロイなしに変更可能。
- ?from / ?to の期間指定を追加。日付のみ(YYYY-MM-DD)ならUTCの1日全体に広げる。
- ボット列を追加。is_bot を出すことで、リンクプレビュークローラー由来の
アクセスをExcel側で除外して集計できる。
- ファイル名を UUID からプロジェクト名ベースに変更(RFC 5987 のUTF-8形式と
ASCIIフォールバックの両方を付与)。従来は全ダウンロードが
access-logs-3f2b….csv だった。
- CSV_EXPORT_ENABLED の判定を真偽値でも受けるようにした。wrangler.jsonc の
vars はJSONなので `true` と引用符なしで書くのはありがちな間違いで、
厳密な === 'true' だと理由の分からないまま無効のままになる。
検証: 5,000行を「5種類の日時のみ」でシードし、2,000行のページ境界が同一
日時のグループ内に落ちる条件で確認した(id のタイブレーカが無いと壊れる
ケース)。結果は 5,000行・重複0・欠落0、BOMとCRLF維持、ボット列500行、
新しい順。期間指定は 1日=1000行 / 2日=2000行 と一致。上限超過は
413 + {total:5000,max:1000} を返し、期間を1日に絞ると200で1000行取得できた。
あわせて、ローカルD1へのシードで実測した2つの落とし穴を docs に記録した
(wrangler dev 起動中は書き込めない / 1ファイルに約4MBの文を詰めると
workerd が HashIndex エラーで落ちる)。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
## ログインのレート制限 共有パスワード1つに対してレート制限が一切なく、インターネットから自由に 総当たりできる状態だった。*.workers.dev には WAF のレート制限ルールが 適用されないため、Workers のレート制限バインディングを使う(追加サービス 不要・無料・wrangler dev でも動く)。キーは CF-Connecting-IP なので、 攻撃者のIPが制限されるだけで正規の管理者は影響を受けない。 バインディングが無い場合は意図的に fail-open にし、代わりに login_rate_limiter_missing を構造化ログに出す。バインディング欠落で全ログイン を拒否するのは自傷的な障害で、総当たりのリスクより悪い。 ## ヘルスチェック /healthz(GIT_SHAを返す)と /readyz(SELECT 1 でD1到達性を確認、失敗で503)。 GET / はQRリダイレクトのもので ?id= が無いと404を返すため、監視先に 使うと永遠にアラートが鳴る。/readyz を1分間隔で叩いても約1,440読み取り/日で 500万/日のクォータに対して無視できる。 ## CORS を fail-closed に allowed が空のとき '*' にフォールバックしていた。つまり ALLOWED_ORIGINS の 設定漏れやタイポが、Authorization を許可したワイルドカードCORS——最も 許容的な設定——に静かに変わる。最もありがちなミスで最悪の設定になるので、 空の許可リストはブラウザからの呼び出しを拒否するようにした。QRリダイレクト やcurl・監視はCORSの対象外なので影響しない。 ## その他 - 未処理エラーのログを1行JSONにし、method/path/cf-ray で検索できるようにした。 - セッションTTLを24時間から8時間(1イベント日)に短縮。全権限のBearer トークンが localStorage にあるため、寿命を縮めるのが最も安価な影響範囲の 縮小になる。24時間だと初日に漏れたトークンが翌日も使えた。 検証: /healthz と /readyz が200、GET / は404。誤パスワード14回で 401×10 → 429×4 と正確に10回で切り替わり RATE_LIMITED を返す。JWTの iat→exp が8時間。許可オリジンには ACAO が付き、未許可オリジンには付かない。 プリフライトは204で必要なヘッダが揃う。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
スキャン経路はリクエストごとにD1から遷移先を読む。その読み取りが例外を投げると onError に落ち、**全来場者に素の 500 が返る**——貼り出したポスターが一斉に無反応に なる。最も起こりやすいのはD1の障害そのものより**クォータ枯渇**で、Freeプランの 読み取り500万行/日を超えると Worker は生きたままD1呼び出しだけが失敗する。 ## なぜ遷移先URLではなくキーワードなのか QRから復元できるURLは、QRに入っていなければならない。暗号技術でこれは回避できない ——ハッシュは一方向なのでWorkerがどんな鍵を持っていても復元できず、認証付き暗号は 平文より長い暗号文になる。実測すると 53x53 → 69x69(署名)/ 73x73(暗号化)まで シンボルが育った。キーワードなら +4モジュール(57x57)で収まる。 副次的に、**オープンリダイレクトが構造的に不可能**になる。`&p=` は運用者が設定した 一覧から選ぶだけなので攻撃者は任意のURLを指定できず、署名も不要で、誤り訂正Hも 下げずに済む。 ## 変更 - `src/fallback.ts`: キーワード → 設定 → 静的FALLBACK_URL の解決を純粋関数に。 設定はオブジェクトでもJSON文字列でも受ける(`vars` はJSONなので引用符の有無で 静かに無効化されるのを防ぐ、CSV_EXPORT_ENABLED と同じ理由)。**解決したURLは http(s) 検証を通す**——自分の設定由来でも `javascript:` を貼るミスがオープン リダイレクトになるため、parse時とresolve時の二層で弾く。 - `src/url.ts`: その protocol 判定を projects.ts と共有。 - `forward.ts`: D1参照を try/catch で囲み、**「見つからない」と「繋がらない」を 例外の有無で区別**する。`.get()` は not-found では throw せず undefined を返すので、 404 の意味は一切変わらない。フォールバック時は 302 + `Cache-Control: no-store` (キャッシュされると復旧後もフォールバック先に飛び続けるため必須)、解決できなければ 素の500ではなく **503 + 日本語の分かるページ**。 - **正常時の自己診断**: キーワードが設定に無ければ `scan_fallback_not_configured` を isolate あたり1回だけ出す。障害が起きて初めて設定漏れに気づく事態を防ぐ。全スキャンで 出すとログが溢れるのでモジュールスコープのSetで抑制し、`reason` で「設定漏れ」と 「QRがキーワードを持たない」を区別する。 - `Projects.fallback_key` 列(migration 0003、ALTER一行。0002のようなテーブル再作成なし)。 zod は ASCII 限定——日本語キーワードはURLエンコードで1文字9文字になり 61x61 に育つ。 更新は `!== undefined` 判定で空文字によるクリアを可能にする。 - `SIMULATE_DB_FAILURE`: ローカル検証専用。ローカルD1は組み込みで「落とす」ことが できず、これなしでは**今回の主目的である障害経路を手で確認できない**。使用のたびに ログを出すので本番に残っても気づける。 - packages/api に vitest を追加(11本)。この経路はD1が壊れている時しか動かないので、 通常利用では一度も実行されない——だからこそ単体テストの価値がある。 ## 受け入れる代償 **D1障害中はリダイレクトは動くがスキャンは計上されない。** `AccessLogs.project_id` は NOT NULL で `qr_id` は QRCodes への外部キーがあり、DBが読めない状況では有効な行を 作れない。`scan_unlogged_due_to_db_failure` で件数だけは分かるようにした。 ## 検証(SIMULATE_DB_FAILURE で実測) - **正常時は設定値ではなくDBの遷移先に飛ぶ**(最重要の非退行)。存在しないIDは404のまま。 - 障害時: 設定ありのキーワード → その遷移先 / 設定なし → 静的FALLBACK_URL / `&p=` なしの旧QR → 静的FALLBACK_URL / 何も設定なし → 503+日本語ページ。 すべて `Cache-Control: no-store` 付き。 - 自己診断: 設定漏れのキーで15リクエスト → ログは**キーごとに1行のみの計2行**、 設定済みキーでは0行。 注意: 検証中、TaskStop が wrangler の親プロセスを止めても **workerd の子プロセスが 残ってポートを保持し続ける**ため、古いサーバーに対してテストしてしまう事故があった。 ポート単位で kill してから起動し直す必要がある。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
管理画面は FALLBACK_DESTINATIONS を自分で読めない——Workerのバインディングで ブラウザからは見えないため。一覧をAPIが返すことで、キーワード欄を自由入力から 選択式にできる。 これにより「設定に無いキーワードを打ち込んでしまい、フォールバックが何もしない」 という失敗が構造的に起きなくなり、wrangler.jsonc の増減がコード変更なしで そのままフォームに反映される。 `/:id` より前に登録してリテラルパスがルートマッチを取るようにし、実在 プロジェクトの取得と衝突しないことを確認済み。並び順はJSONの書き方に依存しない よう key でソートする。静的な FALLBACK_URL も併せて返し、キーワード未設定時に どこへ飛ぶのかをUI側で説明できるようにした。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- instagram -> https://www.instagram.com/nutfes?igsh=... - web -> https://www.nutfes.net/ wrangler dev の実設定で確認: 候補一覧に2件が出て、D1障害中に &p=instagram で Instagram、&p=web で公式サイトへリダイレクトする。Instagram側の共有パラメータ (igsh)も欠けずに渡っている。 なお現時点で web の転送先は FALLBACK_URL と同一なので、「web」を選んでも 「なし」でも結果は変わらない。将来どちらかを変えたときに初めて分岐する。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What & why
本番リリース前に、スキャンのホットパスと管理APIの問題を潰しました。計画と根拠は docs/load-testing.md に残しています。
リリースブロッカー
スキーマ変更(ユニーク制約の張り替え)
想定運用は「ポスターなどの物ごとにQRを1つ生成し、必要であれば場所を記載する」ですが、ユニーク制約が (project_id, medium, location) だったため、場所を空にした2枚目のポスターが409で作成できませんでした。制約が「媒体×場所ごとに1つ」をモデル化していて運用と矛盾していたので、(project_id, name) に張り替え location を任意化しました。副次効果として、名前ベースのダウンロードファイル名がプロジェクト内で一意になります。
あわせて AccessLogs に外部キー(ON DELETE CASCADE)を追加しました。従来は外部キーがなく、プロジェクト削除でスキャン履歴が孤児化してUIから到達できない行が残っていました。行数が少ない今のうちにテーブル再作成をしています。
エラー表現
文言をサーバーが決めていたことが翻訳を不可能にしていました——英語の開発者向け文字列(Invalid request body)が日本語利用者に、ハードコードされた日本語の409が英語利用者に出ていました。src/errors.ts に安定した機械可読コードを定義し、文言はクライアントが持つ形にしました。
性能
権限チェック
GET /projects / GET /:id / POST / / PUT /:id / GET /:id/access-logs / CSV が認証のみで認可がなく、READMEの表と食い違っていました。単一管理者に ALL_PERMISSIONS が付く現状では露出しませんが、Verifier の差し替え口を用意した意味が消えるので VIEW / EDIT / ANALYTICS を付けました。
ボット判定
facebook / Discordbot の部分一致だけで、Twitterbot や Slackbot が実スキャンとして黙って計上されていました。判定を src/user-agent.ts に切り出し、除外せず is_bot=1 で記録するようにしました(捨てないので判定を後から改良して過去分を再集計できます)。逆に LINEのアプリ内ブラウザ(Line/13.x)は実ユーザーなので明示的に除外しています——ここを逆にすると最大の実トラフィックが消えます。
How this was tested
ローカルD1での実測:
注意