ブログ
Web 制作・システム開発に関する記事を公開しています。
Cloudflare D1 の無料枠を守るためだけに作らなかった 4 つの仕組み
D1 の読み取りを 1 日 2,110 万行(無料枠の 422%)から 190 万行前後まで下げる過程で、「次はこれを作ればさらに減る」という案が 4 つ出ました。公開時にキャッシュ済みの 404 を消すミドルウェア、404 ページを D1 を読まない静的ページにする案、リダイレクト表を Bulk Redirects へ同期する仕組み、スキャナをエッジで落とす WAF ルール。どれも半日で作れて効果も数字で見えていましたが、4 つとも作りませんでした。守る対象が今は圧迫されていないこと、持ち続ける費用が作る費用より高いこと、圧迫されたときの第一手は課金の方が確実なこと。判断の材料と、再訪の条件をまとめます。
Bot Fight Mode を無料プランで使うと GitHub Actions が 403 になる
Cloudflare の Bot Fight Mode を有効にしていた 2 サイトだけ、GitHub Actions からの死活チェックが 403 になりました。切って再実行すると全サイト 200。無料プランの Bot Fight Mode は Ruleset Engine の外で動くため WAF カスタムルールの Skip / Allow が効かず、例外を作るには Pro の Super Bot Fight Mode が要ります。監視・CLI・Webhook 受信・確認の curl など自分の非ブラウザ通信が全部判定の対象になる一方、守りたい Workers の起動枠(34%)も D1(30%)も圧迫されていなかったので、8 月に再検討して入れないと決めた経緯と、再訪の条件をまとめます。
Cloudflare Workers の Cron で曜日 0 は拒否され、7 は土曜になる
監視用の Worker に週 1 回の Cron を「30 23 * * 0」で入れたら、wrangler deploy がスクリプトのアップロードだけ成功して Cron の登録に失敗しました(invalid cron string)。0 を 7 に変えて登録は通りましたが、Cloudflare の曜日は 1 = 日曜〜7 = 土曜で、7 は土曜でした。公式ドキュメントの 1 行で気づき、SUN に直した経緯と、デプロイ後に schedules を確認する手順、D1 の読み取りを「1 起動あたりの行数」で 3 時間ごとに見張る Worker の設計をまとめます。
EmDash の記事を API で一括更新する。_rev の場所と draft → publish
Astro 製の CMS「EmDash」で、タイトル 51 本・本文の言い回し 15 本・記事間リンク 32 本を 1 日で直すために、API を直接叩いて一括更新しました。楽観ロックの _rev は GET レスポンスの item の外側(data._rev)にあり、version を渡すと 400 になります。PUT を status: draft で保存して publish を分け、Portable Text は text キーだけを歩けばコードブロックと HTML ブロックを壊しません。タグの割り当てはコンテンツ更新と別経路で、キャッシュ失効も updatedAt も動き方が違う点まで、110 記事で踏んだことをまとめます。
Astro の /_astro/ 画像が移行後に 404 で叩かれ続ける原因と対処
WordPress をヘッドレスに使った Astro の静的ビルドから EmDash へ作り直したあと、旧ビルドの /_astro/<名前>-1024x717_<ハッシュ>.jpeg に週 700 回以上の 404 が来ていました。User-Agent は普通のスマートフォンのブラウザで、画像検索からの着地でした。EmDash のリダイレクト表は拡張子付きのパスを引かないため 747 行入れても発火せず、ミドルウェアでファイル名からメディアを引いて 301 する形にして、週 1,640 件の 404 を 156 件に減らしました。残った 1 枚は同じ画像が別名で 2 回アップロードされていたもので、Redirect Rule 1 本で受けた経緯までまとめます。
Astro + Cloudflare で公開した記事が 404 のままになる原因
記事を公開したのに、記事の URL だけが「ページが見つかりません」のままでした。公開前にその URL を一度でも開いていると、404 のレスポンスがエッジに 30 日キャッシュされ、公開しても消えません。Astro の routeRules はリクエストのパスで照合してステータスを見ず、EmDash が公開時に失効させるタグ(コレクション名と記事 ID)は 404 には付いていないためです。purge の仕組みを足す案を作って取り下げ、404.astro で Astro.cache.set({ maxAge: 300, swr: 0 }) を呼んで 5 分に上書きするだけにした経緯と、swr を忘れると起きることを実測で書きます。
Cloudflare にデプロイしたのに反映されないのはブラウザのキャッシュ
デプロイ後にブラウザで開くと古いページのままで、ハードリロードすると新しくなる状態になりました。エッジは正しく更新されていて、原因はレスポンスに Cache-Control が 1 つも無かったことです。指示が無いとブラウザは Last-Modified から有効期限を推測するため、古い記事ほど長く保持されます。エッジ用のヘッダはエッジで取り除かれるので、ブラウザ向けの指示は別に出す必要がありました。
EmDash で記事にタグを付けても本番に反映されない原因
記事にタグを付け直しても、本番のタグ一覧に出てきませんでした。CMS がキャッシュを自動失効させているのはコンテンツの CRUD だけで、タグの割り当て・サイト設定・メニュー・ウィジェットエリアは別経路になっていました。エッジキャッシュの保持時間を 30 日に延ばしたことで顕在化した穴で、CMS 本体に patch を当てず、アプリ側のミドルウェア 1 か所で塞いだ記録です。
Cloudflare の Purge Everything で Workers Cache が消えない理由
ダッシュボードから Purge Everything を実行しても、ページが古いまま返ってきました。age が単調増加していることから消えていないと分かります。原因は消そうとしていたキャッシュがゾーンの CDN キャッシュではなく Workers Caching だったことで、ゾーンレベルの purge はこの層に届きません。あわせて、デプロイ時の purge がそもそも不要である理由も確かめました。
キャッシュを 30 日にしても D1 の読み取りが減らない原因
エッジキャッシュの保持時間を 5 分から 30 日へ延ばしても、データベースの読み取りが目標の 2 倍を超えたままでした。クエリ単位で分解すると 7 割がサイドバーの件数表示のための集計で、しかもサイドバーを消しても止まりませんでした。CMS のミドルウェアが全ページでターム一覧を先読みしていたためです。切り分けに使ったのは 1 起動あたりの読み取り行数と、クエリの実行回数がリクエスト数の何倍かという比率でした。
Cloudflare D1 の読み取りが 1 日 2,100 万行になった原因
1 つの Cloudflare アカウントで CMS のサイトを 8 つ運用していて、データベースの読み取りが 1 日 2,100 万行ありました。使用量は返した行数ではなくスキャンした行数で数えられるため、1 ページ表示するたびに数千行が乗っていたのが原因です。CMS を使っていてクエリを自分で書いていない状況で、キャッシュの保持時間とテンプレートの見直しだけで 91% 減らした記録です。1 リクエストあたりの単価が、1 アカウントに載せられるサイト数の上限を決めていました。
実況 AI が一歩遅れるので語るのをやめて相槌にした
カメラの前でゲストが手元の対象を次々切り替える様子を実況する音声 AI は、対象ごとに語ると構造的に追いつけず常に一つ遅れました。実際の MC に寄せて基本は相槌、語るのは掲げた・止めた・声を上げたときだけにすると、高速な切り替え中も体験として成立しました。一方で検出器が「動きの山」しか見ておらず、止めて掲げた 20 秒間(差分 4〜8)を見逃し、静止イベントを足したら既存トリガーと二重発火して自己キャンセルが連鎖した経緯と、直列キューでの対処を記録しています。
OpenAI Realtime API に映像を流せないので 1fps の静止画で代用した
OpenAI の Realtime API には映像ストリームの入力がなく、音声実況 AI が 21 秒前の絵に反応していました。画像を「返事を求めない文脈」として 1fps で積み、「今切り替えるべき出来事か」だけを軽量な視覚言語モデルに一問で聞く設計に変えると、判定の中央値 1.7〜1.9 秒で手の動きを正しく棄却し、手元の実物の名前も当てました。検出 0.25 + 判定 1.7 + 発話 0.8〜1.9 秒の合計が人の動作と同じオーダーという限界と、次の一手まで含めた記録です。
OpenAI Realtime API の unknown_parameter で設定が全部消える
OpenAI の Realtime API で日本語のキャラクターを動かしていたら、設定に一行足しただけで英語の素のアシスタント(Hey there! What would you like to talk about)が出てきました。ベータ時代の名前 max_response_output_tokens が unknown_parameter となり、session.update が丸ごと拒否されて指示・声・VAD・転写言語がすべて初期状態に落ちていたのが原因です。session.updated を待ってから開始する、エラーは永続ログへ書く、計測にビルド SHA を記録する、という再発防止の記録です。
無言なのに AI が誰かと会話している原因は自分の残響だった
スピーカー運用の無言テストで、音声 AI が「なよぎら。」「そばランチ。」といった転写に返事をし、自分が言った単語が入力転写に現れていました。マイクの RMS は 0.001〜0.005 で静寂なのに、Web Audio API の自前再生はブラウザのエコーキャンセルの基準信号になりにくく、残留がサーバーの VAD に拾われて転写の幻聴と連鎖していました。ノイズゲートも VAD も「AI の声も声」なので効かず、再生と収音を WebRTC に預けて解決した記録です。
OpenAI Realtime API の response.cancel が効かない原因
OpenAI の Realtime API で発話を打ち切ろうと response.cancel を送ると response_cancel_not_active が返り、スピーカーからは前の話題が流れ続けました。モデルは 12 秒分の発話を約 2 秒で生成し終えていて、止めたかったのは生成ではなく再生でした。新しい応答が再生待ち行列の後ろに積まれて実況が一つ遅れる現象と、音が出る前の応答を打ち切ると出力ゼロの応答が連鎖する現象を、生成・送出・再生の 3 状態に分けて直した記録です。
LLM は喋っているのに音声が出ない原因は進行装置のフラッシュ
ライブ音声 LLM の上に番組進行の装置(状態機械・フェーズ転換・キュー注入)を被せたら、出力トークンは計上されているのにスピーカーから何も出なくなりました。原因は検出器の誤発火で 60 秒に 7 回フェーズが空転し、転換のたびに再生キューをフラッシュしていたことです。同じ条件でフラッシュを外すと発話が成立しました。制御を足すのではなく合図を引き算し、状態機械の責務をセッション寿命だけに縮めた設計の記録です。
検出器の精度ではなく誤検知時の体験で仕様を書く
リアルタイム AI アプリの動き検出で、精度チューニングが終わらない問題を精度を上げずに解決しました。検出結果の使い方を信頼度で二層に分け、低信頼側には疑問形の先走り反応しか許さない設計にすると、雑な検出器のまま出荷できます。64x48 のフレーム差分・約 30 行の実装で、音声実況 AI のシーン遷移がハンズフリーで 6 サイクル回りました。誤検知は早とちりというキャラクターの芸として体験に吸収されます。
システムプロンプトの禁止事項が効きすぎて出力が痩せる
映像を見て実況する音声 AI のシステムプロンプトに「見えていても知らないふりを通す」と書いたら、対象物の存在への言及まで消えました。「数字を数えない」と書いたら、目の前の個数の実況まで消えました。表層の語で書いた禁止は、モデルが安全側に過剰般化します。観察(可)と同定・評価(不可)のレベルに分けて対例つきで書き直すと、抑制の副作用が消えました。
Playwright の CI でフェイクカメラが NotFoundError になる
コンテナ CI の Playwright(Chromium)でフェイクメディアデバイスのフラグを渡しても getUserMedia が NotFoundError になります。headless・xvfb-run・PulseAudio ダミー・ファイル指定式の 4 手法を試して全滅でした。テスト環境を直すのは諦め、アプリ側に「マイクなしでも動く縮退」を実装して E2E を縮退経路で通しました。もともと仕様にあったエラー縮退要件が、テストより先に実装される形になりました。