Cloudflare R2の使用量をWorkers cronで監視するシステム図。4段階のステートマシン(Normal, Warning, Grace, Blocked)とGraphQL API連携、通知、アップロード拒否の仕組みが描かれている。

Cloudflare R2 の使用量を Workers cron で監視する——4段階ステートマシンと grace period の設計

導入

ある Web アプリで、ユーザーが画像をアップロードする機能を Cloudflare R2 に乗せて運用しています。ストレージ系のサービスで地味に怖いのが「気づいたら容量上限を踏み抜いていて、ユーザーのアップロードが落ちる」という事故です。R2 はオブジェクトストレージなので、Free プランでも 10GB という上限があり、複数バケットを持つと特に追いきれません。

今回、Workers の cron トリガーから Cloudflare GraphQL API を叩いて使用量のスナップショットを取り、閾値を超えたら通知し、猶予期間(grace period)が過ぎたら新規アップロードを拒否する、という監視機構を実装しました。仕組みの中核は 4 段階のステートマシン猶予期間の固定化 です。同じパターンは Cloudflare R2 以外のストレージ・帯域監視にも転用できるので、設計判断と勘所を書き残しておきます。

全体像

監視機構の構成要素は以下のとおりです。

  • トリガー: Cloudflare Workers の cron(*/60 * * * * 等で毎時 0 分起動)
  • 使用量取得: Cloudflare GraphQL Analytics API の r2StorageAdaptiveGroups
  • 状態保存: Workers KV に JSON 形式(バケットごとに level / graceUntil 等)
  • 判定ロジック: 純粋関数 resolveLevel() で 4 段階に分類
  • 通知: Push 通知 + メール(notifyStorageAlert()
  • アップロード拒否: 純粋関数 isR2UploadAllowed() をルートから呼ぶ

cron 1 周回の処理フローは次のとおりです。

flowchart TD
    A[cron 起動] --> B[buildMonitorTargets]
    B --> C{バケット毎にループ}
    C --> D[GraphQL API で使用量取得]
    D --> E[resolveLevel で判定]
    E --> F{level に変化あり?}
    F -->|なし| H[次のバケット]
    F -->|あり| G[notifyStorageAlert]
    G --> I{通知 1 件以上成功?}
    I -->|いいえ| H
    I -->|はい| J[KV に新 state を put]
    J --> H
    H --> C
    C -->|全て完了| K[ctx.waitUntil で終了]

ステートマシンの遷移は次の 4 段階です。

level 条件 挙動
normal 使用量 < 閾値(例:limit × 0.8) 通常運用、通知なし
warning 閾値 ≤ 使用量 < limit 警告通知、まだアップロード可
grace 使用量 ≥ limit、猶予期間中 上限超過通知、猶予期限まではアップロード可
blocked 使用量 ≥ limit、猶予期間切れ アップロード拒否、復旧通知待ち
stateDiagram-v2
    [*] --> normal
    normal --> warning: 使用量 ≥ limit × 0.8
    warning --> normal: 使用量 < limit × 0.8
    warning --> grace: 使用量 ≥ limit (graceUntil = now + 7d)
    grace --> normal: 使用量 < limit (復旧通知)
    grace --> blocked: 猶予期限切れ
    blocked --> normal: 使用量 < limit (復旧通知)

「grace」を 1 段挟むのがポイントです。Free 枠で運用しているサービスでも、ユーザーに予告なくアップロードを止めるのは UX 的に厳しいので、デフォルト 7 日の猶予期間を置いて、その間に追加課金や不要ファイルの削除など対応の機会を作ります。

ステートマシンの判定ロジック

判定は純粋関数で書きます。KV や DB を一切参照しないので、テストが楽になります。

function resolveLevel(
  usageBytes: number,
  limitBytes: number,
  warningRatio: number,
  graceUntil: string | null,
  nowMs: number,
): 'normal' | 'warning' | 'grace' | 'blocked' {
  if (usageBytes < limitBytes * warningRatio) return 'normal'
  if (usageBytes < limitBytes) return 'warning'
  if (!graceUntil) return 'grace'
  return nowMs < Date.parse(graceUntil) ? 'grace' : 'blocked'
}

graceUntilnull のときは「これから grace に入る瞬間」と解釈し、grace を返します。値がセットされていれば、現在時刻と比較して graceblocked かを決めます。

ここで重要なのは graceUntil を grace への初回遷移時にだけ計算・固定する という運用です。毎時の cron で再計算してしまうと、猶予期限が永遠に伸び続けてしまうからです。実装側では「KV に既存の graceUntil があれば引き継ぐ、無ければ now + 7 日で確定」としています。

アップロード拒否は純粋関数 1 本で

アップロード処理側で「いま受け付けて良いか」を判定するときも、純粋関数 1 本で書けるようにしておくとコードがシンプルになります。

export function isR2UploadAllowed(params: {
  usageBytes: number
  limitBytes: number
  graceUntil: string | null
  nowMs?: number
}): boolean {
  if (params.usageBytes < params.limitBytes) return true
  if (!params.graceUntil) return true
  return (params.nowMs ?? Date.now()) < Date.parse(params.graceUntil)
}

ルート側ではこの関数を 1 行呼ぶだけで判定が完結します。テストも expect(isR2UploadAllowed({ ... })).toBe(true / false) という素直な形で書け、境界条件のケースを網羅しやすいです。

「KV から最新の使用量を読んできて、それを引数に渡す」という前段は別の関数に分離しておきます。「監視値を取得する責務」と「許可判定の責務」を分けることで、テストの組み合わせ爆発を防げます。

通知成功を state 遷移の条件にする

設計判断として一番効いた工夫が、通知が 1 件も成功しなかった周回では state を進めない ことです。

const notified = await notifyStorageAlert(env, target, alertMessage)
if (!notified) {
  continue  // state を更新しない
}
nextState = setTargetState(nextState, target, { level, graceUntil, ... })
changed = true

これがないと、たとえば Push 通知のトークンが切れていたり、メールサービスが障害でダウンしていたりした場合に、ユーザーに警告が届かないまま blocked まで一気に到達してしまいます。「通知が届くまでは state を進めない」という安全弁を入れることで、運用者の設定漏れに対しても堅牢になります。

ただし「通知先が 1 件も登録されていない」ケースは別です。これは設定の不備なので、console.warn でログに出してスキップ扱いにします。永遠に state が進まなくなるのを避けるためです。

書き込みコストを抑える changed フラグ

Cloudflare Workers KV は読み取りに比べて書き込みが高くつくため、変更がない周回では KV への put を呼ばないようにします。

let changed = false
// ... ステート計算
if (changed) {
  await env.STORAGE_STATE.put(STATE_KEY, JSON.stringify(nextState))
}

level が変わらず、かつ graceUntil も変わらない場合は changed = false のままです。Free 枠で大量のバケットを毎時監視するときに地味に効きます。

エラーは飲み込んで cron 全体を止めない

cron で複数のタスクを並列に走らせている場合、ストレージ監視が失敗しても他のタスク(リマインダー送信、データ集計など)まで巻き添えで止めるのは避けたいところです。

const asyncTasks: Promise<unknown>[] = []
asyncTasks.push(
  monitorR2Storage(env).catch((e) => {
    console.error('R2 monitor failed:', e)
  }),
)
// ...他の cron タスクも同様に push
ctx.waitUntil(Promise.allSettled(asyncTasks))

エントリポイントで .catch() してから asyncTasks に push し、最後に Promise.allSettled で待つ形にします。1 つの失敗が他に波及せず、ログには残るので障害解析もできます。

環境変数ベースのバケット定義

将来バケットを追加する可能性があるなら、監視対象を環境変数から組み立てる関数を 1 個書いておくと拡張が楽になります。

function buildMonitorTargets(env: Env): MonitorTarget[] {
  const targets: MonitorTarget[] = []
  if (env.R2_BUCKET_FREE) {
    targets.push({
      key: 'free',
      bucket: env.R2_BUCKET_FREE,
      limitBytes: 10 * 1024 * 1024 * 1024,
    })
  }
  if (env.R2_BUCKET_PRO) {
    targets.push({
      key: 'pro',
      bucket: env.R2_BUCKET_PRO,
      limitBytes: 100 * 1024 * 1024 * 1024,
    })
  }
  return targets
}

環境変数が空のものはスキップする実装にしておくと、開発環境では R2_BUCKET_FREE だけ、本番では複数プラン全部、といった使い分けが wrangler.toml の差分だけで済みます。

実用アドバイス(明日から試せる内容)

  1. 使用量取得は GraphQL Analytics API を直接叩く:REST API には R2 の使用量エンドポイントが無いので、r2StorageAdaptiveGroupspayloadSize + metadataSize を合算します。Cloudflare Dashboard も同じエンドポイントを参照しているので信頼できます。
  2. 判定ロジックは純粋関数に分離resolveLevelisR2UploadAllowed を KV / DB から切り離しておくと、ステートマシンの境界条件テストが一気に書きやすくなります。
  3. grace period は初回遷移時に固定:cron が毎時走ると、再計算するたびに猶予期限が伸びるバグが出ます。「既存値があれば引き継ぐ」を徹底します。
  4. 通知失敗時は state を進めない:警告通知が失敗したまま blocked まで到達するのを防ぐ安全弁です。1 行のガードで運用が一段堅牢になります。

まとめ

  • R2 使用量監視は「Workers cron + GraphQL API + KV」の 3 点セットで素直に組めます
  • 4 段階ステートマシン(normal → warning → grace → blocked)と猶予期間で、ユーザー体験を守りながら超過に対処できます
  • 判定は純粋関数、書き込みは差分検知、エラーは局所化、の 3 原則で運用コストが下がります