※この記事は個人開発の遊びの内容です。
サービスの前提
VTuberの配信情報を通知するDiscordBot
- 導入サーバー数: 約450サーバー(2026/1/28現在)
- 通知頻度: 1分ごとに配信情報を全Discordサーバーへと配信する(昔ながらのCron方式)
関連資料:
Discord APIの制限
Discord APIには50リクエスト/秒のGlobal Rate Limitと個別のエンドポイントへのRate Limitがあります。これを超えると429レスポンスが返され、Retry-Afterヘッダーで指定された時間だけ待機する必要があります。
Cloudflare Workersの制限
Cloudflare Workersには同時接続6本という制限があります。
この制限は公式ドキュメントによると6接続を超えたリクエストは待機キューに入り、既存接続が閉じるまで開始されないという説明がされています。
公式ドキュメントの説明:
Once an invocation has six connections open, it can still attempt to open additional connections. These attempts are put in a pending queue — the connections will not be initiated until one of the currently open connections has closed.
ただ実際に運用している中でどうにも待機キューからDropしてしまうリクエストがあるような動きをすることがありました。 似たようなIssueもありこれがCloudflareの意図した仕様なのか、workerdの実装上の問題なのかはよく分かってません。(誰か知ってたら教えてください)
いずれにせよ、Cloudflare WorkersからDiscord APIのような外部API呼び出しでは、このような不確実性を考慮した設計が必要です。
全体アーキテクチャ
Cron → Workflows → Service Bindings という流れで処理しています。

各層の役割:
| 層 | 技術 | 役割 |
|---|---|---|
| スケジューリング | Cron Triggers | 1分ごとにWorkflowを起動 |
| 処理分割 | Workflows | 100チャンネル単位でステップ分割(SubRequest数に1024の制限がある) |
| RPC呼び出し | Service Bindings | 同一isolateでの実行(になる場合がある) |
| 状態管理 | グローバル変数 | discordenoのRate Limit状態・接続数の保持 |
| Rate Limit | discordeno | 429自動リトライ・エンドポイント別追跡 |
Cloudflare Queueを使った分散処理も検討しましたが、今回のユースケースでは以下の理由でWorkflows + Service Bindingsを採用しました。
- 実際に運用していく中でキューイングした時にConsumer側の実行開始時間が安定しない
- 運によりますが短時間であればDiscordClientを同一isolateで扱える(
discordenoのRestManagerは、APIレスポンスの
X-RateLimit-*ヘッダーを解析し、エンドポイントごとのRate Limit状態を内部で保持しているので相性がいい)
なので、今回は単一WorkerへService Bindingで接続するような感じにしてます。
グローバル変数によるソフトリミット
Cloudflare Workersでは、グローバル変数がisolate内で共有される場合があります。これを活用してDiscord ClientとConcurrency Limiterをグローバルに保持しています。
ちなみに公式ドキュメントでは、グローバル変数による状態管理は推奨されていません:
Global variables are not reliable for storing state or passing data between requests because isolates can be evicted at any time.
今回はRateLimit50rpsギリギリを攻めつつ、インメモリでヘッダー制御等を効率よく行いたかったのでグローバル変数を利用しました。
/** * Global concurrency limiter for Cloudflare Workers. * Cloudflare Workers have a limit of 6 simultaneous outbound connections. * This limiter is shared across all requests within the same isolate. */ let globalConcurrencyLimiter: DiscordConcurrencyLimiter | null = null; const getOrCreateConcurrencyLimiter = (): DiscordConcurrencyLimiter => { if (!globalConcurrencyLimiter) { globalConcurrencyLimiter = createDiscordConcurrencyLimiter(); AppLogger.info("Discord concurrency limiter initialized"); } return globalConcurrencyLimiter; }; /** * Global Discord client instance shared across all requests within the same isolate. * This ensures that the discordeno RestManager's internal state (connection pool, * rateLimitedPaths map, etc.) is shared across requests for better efficiency. */ let globalDiscordClient: IDiscordClient | null = null; export const getOrCreateDiscordClient = (env: DiscordEnv): IDiscordClient => { if (globalDiscordClient) { return globalDiscordClient; } globalDiscordClient = createDiscordClient(env); return globalDiscordClient; };
discordenoが側で下記を保持しているのでアプリ層は接続数の制御のみをするようにしてます。
rateLimitedPaths: エンドポイントごとのRate Limit状態queues: 同一エンドポイントへのリクエストを直列化X-RateLimit-*ヘッダーの自動解析
正直あまり推奨できない方法なので、厳密な状態管理が必要な場合は、Durable Objectsの使用を検討したほうが良いと思います。
色々問題はあるものの1分以内で処理は終わっているので、頑張っているんじゃないかなと思います。(そもそもCronでやらないほうが良いという話は置いておいて)
