※本記事は、Zennに投稿した記事の転載です。元記事: https://zenn.dev/uekibachidan/articles/316df8ff20efa4
はじめに
前回、AI関連の記事だけを集めるまとめサイトを「LLMを使わずに」作った話を書いた。
→ https://qiita.com/uekibachidan/items/39019e14fb70caacaf8e
日本語AIテックまとめ — https://technews.uekibachidan.com/
今回はその裏側、運用とパフォーマンスの話。
方針は最初からひとつで、ランニングコストをかけない。ドメイン代以外は月0円で回す。
要は無料枠の範囲でやりくりする縛りプレイなんだけど、実際にやってみると、普段の開発では気にも留めないような所に壁が立っていた。
この記事は、その壁に順番にぶつかっていった記録。
前提:無料枠の制約
先に、今回関係してくる無料枠の数字を並べておく。後から出てくる壁は、だいたいここから生えてくる。
- Cloudflare Workers(無料):CPU時間 10ms/リクエスト
- GitHub Actions(privateリポジトリ・無料):月2,000分
- Cloudflare Workers KV(無料):読み書き回数に日次上限(今回の規模では余裕あり)
ポイントはWorkersの「CPU時間10ms」。これは応答にかかった時間ではなく、純粋にCPUを使った時間で、fetchで外部を待っている時間は含まれない。
待つのはタダ、手を動かすと課金される世界。この感覚があとで効いてくる。
第1の壁:バックエンド無し構想、CORSと429に散る
もともとはサーバーを持たずブラウザだけで完結する形にしたかった。サーバー代も運用の手間もかからないので、個人開発だとこれが一番ラク。
で、最初はブラウザから直接RSSを取りに行く形で動かしていた。
……が、動かしてみるとログにRSS取得のエラーが大量に出ていた。ブラウザから他ドメインのRSSを直接叩くと弾かれる(いわゆるCORS)し、叩きすぎると 429(Too Many Requests)も返ってくる。ブラウザ単独だと、こういうのを回避する手立てがほぼ無い。
結局、リクエストのヘッダを調整したり、叩く間隔を空けたり、失敗時にキャッシュへ逃がしたり——といった制御はサーバー側でやるしかなく、RSSを取りに行く部分をサーバーに移した。
当初の「バックエンド無し」からは外れたけど、集約サイトだとここは避けられなかった。
対策:サーバー側fetch+多段キャッシュ
この時点での構成はこう。
- ページはISRで10分ごとに再生成
- RSSのfetch結果もNext.jsのデータキャッシュに10分(Cloudflare上での実体はKV。OpenNextの
kvIncrementalCacheを指定するだけ) - さらにワーカー内のメモリにも30分持って、取得に失敗したときはここへ逃がす
- はてなブログでホストされている企業ブログは並列で叩くと429が返るので、そこだけ逐次フェッチ+300msの間隔
- 全体に8秒のグローバルタイムアウトを置いて、1ソースが遅くても全体を巻き込まない
「アクセスのたびに配信元へRSSを取りに行かない」を徹底した形で、配信元への負荷も自分のコストも抑えられる。
これで完成、平和に運用開始。……のはずだった。
第2の壁:CloudflareからCPU超過エラーが届く
しばらく運用していて、Cloudflareのダッシュボードを見るとエラーが出ていた。
WorkerがCPU時間の上限(10ms)を超えて落とされている。
キャッシュしてるのになんで?と一瞬思ったけど、冷静に考えれば当たり前だった。
大半のリクエストはキャッシュを返すだけなのでセーフ。ただし10分に1回、キャッシュを引き直す当番に当たったリクエストだけは、24サイト分のRSSを取得してパースする仕事を背負う。
fetchの待ち時間はノーカンでも、パースは思いっきりCPUを使う。XMLを24本パースして10msに収まるわけがない。
※10msというと一瞬に思えるけど、「CPU時間の10ms」は体感よりずっと短い。
Workerの中でパースを軽くする方向も考えたけど、ソースは今後も増やしたいので、削って収める戦いは筋が悪い。
なので発想を変えて、CPUを食う仕事をWorkerの外へ追い出すことにした。
- 収集:GitHub Actionsのcron(30分ごと)でRSS巡回+パース+JSON生成 → KVへ書き込み
- 配信:WorkerはKVを読んで返すだけ
Actionsのランナー上ならCPUをどれだけ使っても10ms制限とは無縁(代わりに月2,000分の実行時間枠と付き合う)。
移行後、CPU超過エラーは消えた。1回の実行は50秒弱。
めでたしめでたし。……とはいかなかった。
第3の壁:GitHub Actionsのcronが時間通りに来ない
30分ごとのはずのActionsが、実行履歴を見ると1時間に1回くらいしか動いていない。
ひどいと2時間近く空く。
cronの書き方を疑ったけど、設定は正しい。
混雑しがちな毎時0分・30分を避けて 13,43 * * * * にする、という定石も最初から踏んでいた。それでもこれ。
調べてみると、そもそもGitHub Actionsのscheduleは実行時刻を保証していない。
混雑時は遅延やスキップが起きる仕様で、無料プランのprivateリポジトリでは特に顕著らしい。「30分間隔の指定が実質1時間に1回」は、よくあることの範囲だった。
バッチ処理ならそれでも困らないけど、ニュースサイトで更新が気まぐれに1〜2時間止まるのはさすがに困る。
対策:Worker Cronから叩き起こす
scheduleがあてにならないなら、起動だけ外からしてやればいい。
GitHub Actionsには workflow_dispatch(APIからの起動)があるので、Cloudflare WorkerのCron Triggerから定期的にこれを叩くことにした。
Cloudflare側のCron Triggerはちゃんと時間通りに発火する。そして「起動APIを1回fetchするだけ」ならCPU時間はごくわずか——今度こそ10ms制限と平和に共存できる。
// wrangler.jsonc
"triggers": {
"crons": [
"13,43 22-23,0-15 * * *", // JST 7:00〜翌0:59 → 30分ごと
"13 16-21 * * *" // JST 1:00〜6:59 → 1時間ごと(理由は後述)
]
}
async scheduled(event, env, ctx) {
await fetch(
"https://api.github.com/repos/<owner>/<repo>/actions/workflows/collect-news.yml/dispatches",
{
method: "POST",
headers: {
"Authorization": `Bearer ${env.GITHUB_PAT}`,
"Accept": "application/vnd.github+json",
"User-Agent": "cron-trigger",
"Content-Type": "application/json",
},
body: JSON.stringify({ ref: "master" }),
}
);
},
※トークンはFine-grained PATで、対象リポジトリ1つ・Actionsの読み書きだけに権限を絞って発行し、wrangler secret put で登録。コードには書かない。
※有効期限切れ=更新停止になるので、期限はカレンダーなりに控えておくこと(自分は1年後にリマインダーを仕込んだ)。
ワークフロー側の schedule: は削除して、workflow_dispatch: だけ残した。あてにならない方を残しておくと、たまに二重起動して無料枠を無駄に食うので。
「足すだけ」のはずが二転びした(OpenNext×scheduledの罠)
……と、ここまでは話が簡単そうに見えるけど、実はこの「scheduledハンドラを足す」が一番転んだ。
1回目:エントリポイントを差し替えたら本番500エラー。
このサイトはNext.jsをOpenNextでWorkersに載せているので、Workerの本体はOpenNextが自動生成する(.open-next/worker.js)。自分のコードに scheduled() を書く場所がそもそも無い。
そこで最初は、生成されたworkerをimportして scheduled() を足したラッパーを作り、エントリポイントをそちらに向けた。ビルドは普通に通る。レビューも通る。で、本番だけ500。
原因は、OpenNextの生成コードの中にある動的インポートだった。
// OpenNextが生成するコードの中身
const { handler } = await import("./server-functions/default/handler.mjs");
この相対パスは実行時にエントリポイントの位置を基準に解決される。エントリポイントをプロジェクトルートのラッパーに動かした瞬間、存在しないパスを探しに行って死ぬ。ビルドも静的なレビューも全部通るのに、動かすと死ぬ。実行時だけの罠。
対策として、ラッパー方式はやめて、ビルド後に生成物へ scheduled() を注入するスクリプトを挟む方式に変更した(エントリポイントは元のまま)。
2回目:自動デプロイで注入が消える。
GitHubにpushするとCloudflareのGit連携が自動ビルド&デプロイしてくれるんだけど、そこで走るのはデフォルトのビルドコマンド(opennextjs-cloudflare build)だけ。注入スクリプトが実行されず、scheduled無しの状態でデプロイされて Handler does not export a scheduled() function エラー。
package.jsonに注入込みのビルドコマンドを用意して、CloudflareダッシュボードのBuild commandをそちらに向けて解決。
"cf-build": "opennextjs-cloudflare build && node scripts/inject-cron.js"
教訓:ビルド成果物に後から手を入れる方式を採るなら、すべてのデプロイ経路で同じ手が入るかまで確認が必要。手元のデプロイと自動デプロイでビルドコマンドが違う、は普通にある。
余談だけど、GitHubのscheduleには「60日間リポジトリに動きがないと自動停止する」という罠もある。workflow_dispatch起動ならこれも無関係になるので、放置気味の個人プロジェクトには地味に嬉しい副作用。
無料枠の家計簿:深夜は間引く
これで30分ごとに確実に動くようになった。
ただ、今度はActionsの月2,000分と相談することになる。
1回の実行は50秒弱だけど、課金は1分単位の切り上げなので、実質1回=1分。
- 終日30分間隔:48回/日 → 月約1,490分(枠の74%)
ちょっと攻めすぎ。何かの拍子に実行時間が延びたら溢れる。
なので深夜(JST 1時〜7時)だけ1時間間隔に間引いた。寝ている間のニュースの鮮度に30分単位でこだわる必要はないので。
- 間引き後:42回/日 → 月約1,300分(枠の65%)
※ここで地味に引っかかったのがcronのタイムゾーン。Cloudflareのcron指定はUTCで、JSTの日中はUTCだと日を跨ぐ(22時〜翌15時)。
範囲指定で 22-15 とは書けないので、22-23,0-15 に分割する必要がある。
最終形と学び
最終的な構成はこうなった。
CPUを使う仕事は全部GitHub Actions側に寄せて、Cloudflare側は「時間通りに起こす」と「読んで返す」だけ。
ランニングコストはドメイン代以外0円のまま。
振り返ると、詰まった場所はどれも「普段の開発では気にも留めない所」だった。
CPU時間なんてローカルで意識したことがないし、cronは指定した時刻に動くものだと思って生きてきた。
無料枠の制約の中でやりくりするのは、不便というより、だんだんパズルとして楽しくなってくる。学びも多いので、個人開発の縛りプレイとしてはおすすめ。
サイトは引き続き公開中。「更新止まってるぞ」と気づいたら、たぶん第4の壁にぶつかっているので教えてもらえると嬉しい。
日本語AIテックまとめ — https://technews.uekibachidan.com/
この記事は AI(Claude)と共同執筆しました。
