朝 7 時に Slack のアラート通知で目が覚めた朝がありました。Gemini Lab の API 失敗率が直近 30 分で 4.2% に上がっていて、その間ずっと、私はそれに気づいていませんでした。AdMob の月次レポートを開いていた裏側でユーザーが静かに離脱していたわけです。
2014 年から個人開発でモバイルアプリを続けてきた身として、可用性を「いつもの感覚」で守れた時代は確かにありました。アプリが 1 本、サービスが 1 つだった頃の話です。今は Dolice Labs として 4 サイトを並行運用していて、Gemini API はそのほぼ全てに薄く広く埋め込まれています。1 サイトの劣化を別サイトの作業中に気づくのは、もう無理です。
ここでは私が現在運用している SLO(Service Level Objective)と Error Budget の設計を、Cloudflare Workers と KV を使う前提で書き残しておきます。Google SRE 本に出てくる本格的な Multi-Window Multi-Burn-Rate アラートを、個人開発で運用可能な粒度に削った版です。
なぜ「いつもの感覚」では複数サイト運用は守れないか
廣川自身、長く個人開発をやっていて「数字より勘の方が当たる」という時期は確かにあったように思います。ただ、AdMob 月収を支えるアプリが累計 5,000 万 DL を超え、ブログサイトが 4 つに増えた今の構成で同じやり方を続けると、次のような事故が起きやすくなります。
ある日 Gemini API がエラーを返し始めても、別のサイトの記事生成タスクを見ている間は気づきません。気づいた頃には Search Console のクロール失敗が積み上がり、Helpful Content System の評価が下がる、というシナリオが現実に起きました。Claude Lab で 2026 年春に経験した GSC インデックス崩壊の遠因の一つも、こうした「静かな劣化を見逃した時間」だったと反省しています。
SLO を定義するということは、許容できる劣化の量を数値で決めるということです。許容できなかった瞬間を機械が代わりに見張ってくれる仕組みにすることで、個人開発でも「気づくのが遅れる」リスクを構造的に下げられます。
SLO の構成要素を最小限で押さえる
ここで使う言葉を最短で揃えておきます。SRE 本の語彙そのままで覚える必要はなくて、運用に必要な分だけで充分です。
- SLI(Service Level Indicator)— 計測したい指標。ここでは「成功したリクエストの割合」を採用します。
- SLO(Service Level Objective)— SLI に対する目標値。例えば「直近 30 日で 99.0% 以上」のように決めます。
- Error Budget — 1 − SLO の許容失敗量。99.0% なら 1.0% が予算で、これを 30 日で配分します。
- Burn Rate — エラー予算を消費する速度。1.0 だと 30 日でちょうど使い切るペース、10.0 だと 3 日で使い切るペースです。
私は Gemini API 呼び出しに対して、サイトごとに次のように SLO を分けています。Rork Lab のように記事数とアクセスが安定しているサイトと、Antigravity Lab のように比較的小規模でアクセスの揺れが大きいサイトを、同じ目標で運用するのは無理があるからです。
| サイト | SLO(直近 30 日成功率) | 月の Error Budget |
| Claude Lab | 99.0% | 1.0% |
| Gemini Lab | 99.0% | 1.0% |
| Rork Lab | 99.5% | 0.5% |
| Antigravity Lab | 98.5% | 1.5% |
サイト規模やリクエストの性質に応じて目標を変えることで、過剰な保守も足りない保守も避けやすくなります。
SLI を Cloudflare Workers で 1 分粒度に集計する
実装の中心は、Gemini API への呼び出し結果を Cloudflare KV に 1 分粒度のバケットで書き込み、Workers から読み出して Burn Rate を計算する、というシンプルな構造です。Durable Objects まで使わなくても、個人開発の規模なら KV で十分回ります。
// src/lib/slo/recorder.ts — Gemini API 呼び出し結果を 1 分粒度で記録する
import type { KVNamespace } from "@cloudflare/workers-types";
export type SiteId = "claudelab" | "gemilab" | "rorklab" | "antigravitylab";
interface MinuteBucket {
total: number;
errors: number;
}
const BUCKET_TTL_SECONDS = 60 * 60 * 24 * 35; // 35 日保持(30 日 SLO + バッファ)
function bucketKey(site: SiteId, minute: number): string {
return `slo:${site}:m:${minute}`;
}
function currentMinute(): number {
return Math.floor(Date.now() / 60_000);
}
export async function recordCall(
kv: KVNamespace,
site: SiteId,
success: boolean,
): Promise<void> {
const minute = currentMinute();
const key = bucketKey(site, minute);
const existing = await kv.get<MinuteBucket>(key, "json");
const next: MinuteBucket = existing
? { total: existing.total + 1, errors: existing.errors + (success ? 0 : 1) }
: { total: 1, errors: success ? 0 : 1 };
await kv.put(key, JSON.stringify(next), {
expirationTtl: BUCKET_TTL_SECONDS,
});
}
Gemini API 呼び出しのラッパー側では、成功・失敗を判定してこの recordCall を必ず呼ぶようにしておきます。リトライ後の最終結果だけを記録するのか、各試行を記録するのか、ここは意図で分かれるところで、私は「ユーザーから見た成否」だけを記録するルールにしています。
// src/lib/gemini/call.ts — 既存呼び出しに SLO 記録を埋め込む
export async function callGemini(
env: Env,
site: SiteId,
prompt: string,
): Promise<string> {
try {
const response = await retryWithBackoff(() => rawCall(env, prompt));
await recordCall(env.SLO_KV, site, true);
return response;
} catch (err) {
await recordCall(env.SLO_KV, site, false);
throw err;
}
}
KV の書き込みは結果整合で順序保証もないため、同一分のバケットに対する read-modify-write が競合することがあります。個人開発の規模では衝突確率が低いので私は無視していますが、トラフィックが増えた場合は Durable Objects に移すか、waitUntil でバッチ集計する設計に切り替えます。
Burn Rate を計算する集計クエリ
書き込んだバケットを期間ぶんまとめて読み出し、Burn Rate を計算します。短い時間窓(5 分・1 時間)と長い時間窓(6 時間・1 日)の両方を持つのが、後の Multi-Window アラートにつながります。
// src/lib/slo/burn-rate.ts
export interface SloConfig {
site: SiteId;
targetSuccessRate: number; // 例: 0.99
}
export interface WindowResult {
windowMinutes: number;
total: number;
errors: number;
errorRate: number;
burnRate: number; // errorRate / (1 - targetSuccessRate)
}
async function readWindow(
kv: KVNamespace,
site: SiteId,
windowMinutes: number,
): Promise<{ total: number; errors: number }> {
const now = Math.floor(Date.now() / 60_000);
const keys = Array.from({ length: windowMinutes }, (_, i) =>
bucketKey(site, now - i),
);
const values = await Promise.all(
keys.map((k) => kv.get<MinuteBucket>(k, "json")),
);
return values.reduce(
(acc, v) => ({
total: acc.total + (v?.total ?? 0),
errors: acc.errors + (v?.errors ?? 0),
}),
{ total: 0, errors: 0 },
);
}
export async function computeBurnRate(
kv: KVNamespace,
cfg: SloConfig,
windowMinutes: number,
): Promise<WindowResult> {
const { total, errors } = await readWindow(kv, cfg.site, windowMinutes);
const errorRate = total === 0 ? 0 : errors / total;
const budget = 1 - cfg.targetSuccessRate;
return {
windowMinutes,
total,
errors,
errorRate,
burnRate: budget === 0 ? 0 : errorRate / budget,
};
}
例えば SLO 99.0%(予算 1.0%)のサイトで直近 1 時間のエラー率が 5.0% なら、Burn Rate は 5.0 です。これは「このペースだと月の予算を 6 日で使い切る」ことを意味します。直感的に速いペースだと分かります。
Multi-Window Multi-Burn-Rate アラートを個人開発向けに簡略化する
Google SRE 本の Multi-Window Multi-Burn-Rate は誤検知を抑える定石ですが、組み合わせが多すぎて個人開発では読みづらくなります。私は次の 4 段階に削って運用しています。
- 激烈モード — 5 分窓で Burn Rate ≥ 14.4。1 時間で月予算の 2% を消費する速度。即時 Slack 通知 + 機能凍結検討。
- 強警告モード — 1 時間窓で Burn Rate ≥ 6.0。6 時間で月予算の 5% を消費する速度。Slack 通知。
- 遅延警告モード — 6 時間窓で Burn Rate ≥ 1.0。1 日でも継続するなら、根本対策を平日中に着手。
- 観察モード — 24 時間窓で Burn Rate ≥ 0.5。週末作業の対象として記録だけ残す。
それぞれのしきい値は GCP の Cloud Monitoring 推奨に近い数字ですが、個人開発の現実に合わせて閾値を緩めに振ってあります。短時間窓と長時間窓の両方が条件を満たしたときだけ発火させると、誤報がさらに減って体感が落ち着きます。
// src/lib/slo/alert.ts — Multi-Window 評価を行う
export type AlertLevel = "critical" | "high" | "medium" | "low" | "ok";
export async function evaluateAlerts(
kv: KVNamespace,
cfg: SloConfig,
): Promise<AlertLevel> {
const w5 = await computeBurnRate(kv, cfg, 5);
const w60 = await computeBurnRate(kv, cfg, 60);
const w360 = await computeBurnRate(kv, cfg, 360);
const w1440 = await computeBurnRate(kv, cfg, 1440);
// 短時間と長時間の両方で同時にしきい値を超えたときだけ発火
if (w5.burnRate >= 14.4 && w60.burnRate >= 6.0) return "critical";
if (w60.burnRate >= 6.0 && w360.burnRate >= 1.0) return "high";
if (w360.burnRate >= 1.0 && w1440.burnRate >= 0.5) return "medium";
if (w1440.burnRate >= 0.5) return "low";
return "ok";
}
私の運用では Critical のときだけ Slack の #dolice-labs-alerts に push 通知が来るようにして、High 以下は日次の朝会代わりのレポート(自分宛のメール)にまとめています。夜中にスマホが鳴って起きる回数が減るだけで、長期運用のストレスが目に見えて下がりました。
Error Budget を使い切ったとき、何を凍結するか
ここが SLO 運用で一番大事な部分です。予算を使い切ったときに「何もしない」と決めてしまうと、SLO は単なる飾りになります。私は次の優先順位で機能を絞ります。
Phase 1: 新機能のリリースを止める
features.enabled というフラグを KV から読む構造にしてあるため、CI に Burn Rate チェックを噛ませて自動で新機能をオフにできるようにしています。具体的には、Multi-Window が medium 以上の状態が 6 時間続いたら、その時点で配信中の A/B テストやロールアウト中の機能を停止します。
Phase 2: モデルを一段落とす
Gemini 2.5 Pro を使っていた処理を Gemini 2.5 Flash に落とすことで、Google 側の障害ドメインから少しだけ離れることがあります。Pro と Flash で同じインフラを共有していても、レート制限・スロットリングの掛かり方が違うため、私の運用では Pro が 503 を返している間でも Flash は応答してくれる時間帯が一定数あります。
// src/lib/gemini/select-model.ts
export async function selectModel(
kv: KVNamespace,
cfg: SloConfig,
): Promise<"gemini-2.5-pro" | "gemini-2.5-flash"> {
const alert = await evaluateAlerts(kv, cfg);
if (alert === "critical" || alert === "high") {
return "gemini-2.5-flash";
}
return "gemini-2.5-pro";
}
Phase 3: ユーザーに正直に状況を伝える
Critical の状態が 30 分続いた場合、対象ページの先頭に「現在 AI 機能が一時的に不安定です」というバナーを出す KV フラグを立てます。読者から見て「壊れたまま放置している」より「気づいて告知している」の方が信頼を失いません。
国際芸術賞を 17 冠いただいた美術活動の側からも同じことを学んでいます。展示中の作品でトラブルが起きたとき、最も信頼を損なうのは「気づかないふり」です。技術運用でも同じだと考えています。
個人開発で SLO を運用するときの落とし穴
実際に半年運用してきて、これは事前に書いておかないと忘れそうだと感じた落とし穴を 5 つ挙げておきます。
- トラフィックがゼロの分のバケット扱い — 1 分間に 1 リクエストもないバケットを Burn Rate に含めると、低トラフィックサイトで分母が小さくなりすぎてノイズが増えます。私は
total < 10 のバケットを集計から除外しています。
- 再試行を「成功」に数えるかどうか — 内部再試行で最終的に成功した場合、ユーザーから見れば成功です。SLI には最終結果だけを記録する、と最初に決めておく方が後でブレません。
- KV のリージョン整合 — Cloudflare KV は eventually consistent なので、書き込み直後の read-modify-write は古い値を読みます。個人開発の規模で問題になることは稀ですが、Burn Rate が突然 0 になる瞬間が発生したら疑う場所です。
- コスト面の SLO を分けて見る — 可用性 SLO を満たしていても、月の AdMob 収益を上回るコストを Gemini API が消費していれば本末転倒です。コスト Burn Rate も別途持つことを推奨します。
- SLO の改訂サイクル — 一度決めた SLO は四半期ごとに見直すルールにしておきます。私は 4 サイトの SLO 改訂会議(と言っても自分一人ですが)を Notion のテンプレートに残しています。
次に手を付けるなら
SLO の運用を始めるとき、いきなり 4 サイト全部に導入するのはお勧めしません。私の場合は、まず Gemini Lab だけに 1 サイトのみで導入して、2 週間しっかり数字を見てから残り 3 サイトへ広げました。Burn Rate のしきい値も、最初の 2 週間は通知だけ出して機能凍結は人手で判断する、という慣らし運転を経るのが良いと感じています。
1997 年 16 歳のとき、独学でプログラミングを覚えながら「インターネットで世界に届ける」という感覚を初めて持ちました。あのときから 29 年、個人開発の道具立てはずいぶん複雑になりましたが、「気づける状態を保つこと」「信頼を静かに積むこと」だけは変わっていない気がしています。SLO はそのための、地味ですが効く道具の一つです。
実装を読み返してくださりありがとうございました。Burn Rate の数字が落ち着いている朝の Slack を、一緒に増やしていけたら嬉しいです。