2024 年の夏、運営している壁紙アプリの 1 つで「画像生成プロンプトを少しだけ最適化したつもりの変更」を本番に直接出した翌朝、レビュー欄が荒れていたことがあります。問題はプロンプトそのものではなく、テンプレートの条件分岐を 1 行書き換えたことでフォールバック経路が動かなくなっていたことでした。AdMob のセッション単価は半日でいつもの 6 割まで落ち、復旧までの数時間で本来得られたはずの売上は数万円単位で消えました。「全量切り替え」の怖さを、AI エージェントを組み込んだ機能ほど痛感する瞬間はありません。
エージェントは決定論的なコードと違って、入力分布が変わると挙動が静かにズレます。回帰テストが通っていても、本番のユーザー入力の長尾で破綻していることに後から気づきます。だからこそ「段階的に出す」「悪化したら速やかに戻す」を仕組み化しておく必要があります。ここで Antigravity をハブにして、カナリアデプロイ+ Burn-Rate SLO ベースの自動ロールバックを、個人開発でも維持できる粒度で組み立てる方法を書き残します。個人でストアに出したアプリを長く運用してきた実感として、「壊れたあとに気づく」から「悪化の兆候の段階で自動的に巻き戻す」へ設計を移したとき、デプロイ前夜の胃の重さがいちばん変わりました。
カナリアと「普通の A/B テスト」を分けて考える
A/B テストは「どちらの方が良いか」を測定する仕組みで、両方とも『十分まとも』であることが前提です。一方カナリアは「新版が壊れていないか」を確認する仕組みで、悪い側を早期に検出して全量公開を止めることが目的です。AI エージェントの場合、評価指標は単なる成功率ではなく、応答品質・コスト・レイテンシ・安全性の 4 軸を同時に見る必要があります。
私が個人開発の現場で採用している境界線は次の通りです。
カナリア: 「壊れていない」ことを保証するフェーズ。トラフィック割合は 1% → 10% → 50% → 100% と段階的に上げ、各段階で Burn-Rate が閾値以下であることを次段階の条件にします
A/B テスト: カナリア通過後の「より良いか」を測定するフェーズ。十分なサンプル数が貯まるまで両系統を並行運用します
両者を混同すると、A/B テストの「効果を見たい」という気持ちが優先され、明らかに壊れた変更を見逃すリスクが上がります。先に「壊れていない確認」、後に「効果検証」という順序を SKILL.md レベルで固定するのが、結局のところ一番事故が減るやり方でした。
SLI / SLO / エラーバジェット / Burn-Rate を AI エージェント向けに整理する
カナリアの判定基準は、感覚や雰囲気ではなく数値で固定しておく必要があります。AI エージェントを運用するときに必須となる 4 つの定義を整理します。
SLI(Service Level Indicator) : 計測する指標。AI エージェントでは「成功率(タスク完遂率)」「ハルシネーション検出率」「P95 レイテンシ」「コスト/リクエスト」を最低でも 4 系統測ります
SLO(Service Level Objective) : SLI に対する目標値。例: 「成功率 99.0%、P95 レイテンシ 8,000ms 以下、コスト ≤ $0.012 / リクエスト」など
エラーバジェット : 一定期間内に許容される失敗の総量。例: 月次 SLO 99% なら、エラーバジェットは月の総リクエストの 1.0%
Burn-Rate : 一定の時間窓でエラーバジェットがどれくらいの速度で消費されているか。例: 1 時間で月次バジェットの 1% を使ったら Burn-Rate = 7.2(月単位に正規化)
カナリアでは Burn-Rate を「カナリア区間のトラフィックに対して」計算します。全体に対する Burn-Rate ではないことに注意してください。カナリアが 5% のトラフィックを受け持っている場合、その 5% の中での失敗率を見ます。これを混同すると、「全体としては安定して見えるが、新版に当たったユーザーの 30% が失敗している」状況を見逃します。
私が運用している壁紙系のアプリでは、AI 画像生成エージェントに対して次の SLO を設定しています。
成功率(妥当な画像が返ってくる率): 98.5% 以上
P95 レイテンシ: 12,000ms 以下
セッションあたり生成コスト: $0.06 以下
不適切画像検出率(安全フィルタを通過した不適切コンテンツの推定割合): 0.1% 以下
数字は事業のリスク許容度から逆算します。月間アクティブユーザー数が大きいアプリほど、わずか 0.1% の悪化でも被害規模が大きくなるため、SLO を保守的に設定しておく必要があります。
カナリアトラフィック分割を Antigravity で実装する
トラフィック分割は、エッジ(Cloudflare Workers)かエントリポイント(Next.js / Hono のミドルウェア)で実装します。重要なのは「同じユーザーが常に同じバージョンに振り分けられる」スティッキネスです。リクエストごとにランダムに振ると、ユーザーが交互に新旧版に当たり、体験が支離滅裂になります。
Antigravity の Agent モードで以下の設計を依頼すると、ハッシュベースの安定振り分けが自動生成されます。実際に使っているコード例を貼ります。
// src/lib/canary-router.ts
// AI エージェントのバージョンをユーザーIDのハッシュで決定的に振り分ける
// 同一ユーザーは常に同じバージョンに振られる(スティッキー)
import { createHash } from "node:crypto" ;
export type AgentVersion = "stable" | "canary" ;
export interface CanaryConfig {
canaryPercent : number ; // 0-100 のカナリア割合
forceFlag ?: AgentVersion ; // 緊急時の強制指定(自動ロールバック発火時に使う)
}
export function selectAgentVersion (
userId : string ,
config : CanaryConfig
) : AgentVersion {
// 緊急ロールバック中なら強制的に stable に倒す
if (config.forceFlag === "stable" ) return "stable" ;
if (config.canaryPercent <= 0 ) return "stable" ;
if (config.canaryPercent >= 100 ) return "canary" ;
// userId をハッシュして 0-99 の整数に変換
// ハッシュ関数は安定したものを使うこと(Math.random は禁止)
const h = createHash ( "sha256" ). update (userId). digest ();
const bucket = h. readUInt16BE ( 0 ) % 100 ;
return bucket < config.canaryPercent ? "canary" : "stable" ;
}
// 期待する動作:
// canaryPercent=10 なら、約10%のユーザーが安定的に canary に振られる
// 同一userIdは常に同じバージョンに振られる(セッション中ブレない)
「同一ユーザーが常に同じバージョンに振られる」という性質は、Math.random() ベースの実装では絶対に得られません。Antigravity のコード生成に頼るときも、Agent から出てきたコードが乱数ベースになっていたら必ず指摘して書き換えさせる必要があります。ここを誤ると、後の Burn-Rate 計測が「ユーザー単位」で見えなくなり、根本的な事故調査が困難になります。
カナリアの割合は、Cloudflare の KV あるいは Durable Object に保管しておき、ロールバック時に Antigravity Agent から即座に書き換えられるようにします。デプロイのたびにコード変更で割合を上げ下げするのではなく、設定値の変更で動かせるようにしておくのが大切です。
// src/lib/canary-config.ts
// カナリア設定をCloudflare KVから読み込み、エッジで即時反映する
export async function getCanaryConfig (
env : { CANARY_KV : KVNamespace }
) : Promise < CanaryConfig > {
const raw = await env. CANARY_KV . get ( "agent:image-gen:canary" );
if ( ! raw) {
// フェイルセーフ: KV接続失敗時は安全側(stable 100%)に倒す
return { canaryPercent: 0 };
}
try {
return JSON . parse (raw) as CanaryConfig ;
} catch {
return { canaryPercent: 0 };
}
}
// 期待する動作:
// KV書き換えから約60秒以内(Cloudflareのキャッシュ無効化挙動に依存)で
// 全エッジに反映される
「KV 読み込み失敗時は安全側に倒す」というポイントは、エッジで発生しうる障害を考えると必須です。ここを throw で落としたり、ランダムに振り分ける実装にすると、KV 障害時にカナリアが暴走します。
メトリクス収集パイプラインを Agent タスクとして組み込む
カナリアの判定には、リアルタイムに近い粒度でメトリクスを収集する必要があります。私の運用環境では、エージェントの実行ログを Cloudflare Workers Analytics Engine と BigQuery の 2 系統に流し、判定用は Analytics Engine の数十秒粒度のクエリで回しています。
// src/lib/agent-telemetry.ts
// エージェント実行の結果を AnalyticsEngine に送る
// バージョン別に成功率・レイテンシ・コストを分けて集計可能にする
export interface AgentExecutionResult {
version : AgentVersion ;
userId : string ;
success : boolean ;
latencyMs : number ;
costUsd : number ;
errorCode ?: string ;
}
export function recordAgentExecution (
env : { TELEMETRY : AnalyticsEngineDataset },
result : AgentExecutionResult
) : void {
env. TELEMETRY . writeDataPoint ({
indexes: [result.version], // バージョン別に高速集計するためのインデックス
blobs: [
result.userId,
result.success ? "success" : "failure" ,
result.errorCode ?? "" ,
],
doubles: [result.latencyMs, result.costUsd],
});
}
// 期待する動作:
// SELECT _sample_interval, blob2, AVG(double1), AVG(double2)
// FROM telemetry WHERE index1 = 'canary' AND timestamp > NOW() - INTERVAL 5 MINUTE
// で 5 分間の canary 系統の平均レイテンシ・平均コストを取得できる
Antigravity Agent に依頼するときのコツは、「エージェント実行のメトリクスを記録する関数を 1 つに集約して、すべての呼び出し経路から必ず通るようにしてください」と明示することです。これを言わないと、エージェントごとにバラバラの記録方法が散らばり、後で集計するときに整合性が取れません。
Burn-Rate 計算と多重ウィンドウアラート
カナリアの判定で核となるのが Burn-Rate の計算です。Google の SRE 本で紹介されている「多重ウィンドウ・多重 Burn-Rate」アラートを AI エージェント向けに簡略化したものを使います。
// src/lib/burn-rate.ts
// カナリア系統の Burn-Rate を計算する
// 短期(5分)と長期(1時間)の両方で閾値を超えたときにアラート発火
export interface SLOTarget {
windowDays : number ; // 例: 30(月次SLO)
successRateTarget : number ; // 例: 0.985(98.5%)
}
export interface MetricsWindow {
totalRequests : number ;
failedRequests : number ;
windowSeconds : number ;
}
/**
* 指定ウィンドウでのBurn-Rateを計算する
* Burn-Rate = (実測失敗率 / 許容失敗率) を 月単位に正規化
* 値が1なら、月のエラーバジェットをちょうど1ヶ月で使い切るペース
* 値が10なら、月のバジェットを3日で使い切るペース(危険)
*/
export function computeBurnRate (
metrics : MetricsWindow ,
slo : SLOTarget
) : number {
if (metrics.totalRequests === 0 ) return 0 ;
const observedErrorRate = metrics.failedRequests / metrics.totalRequests;
const allowedErrorRate = 1 - slo.successRateTarget;
if (allowedErrorRate <= 0 ) return Number.POSITIVE_INFINITY;
return observedErrorRate / allowedErrorRate;
}
/**
* 最初に私が書いた判定。短期5分で14.4、長期1時間で6.0 を AND で結ぶ
* ※ この閾値の当て方は誤っていました。理由はこのコードの下に書きます
*/
export function shouldRollbackLegacy (
short : MetricsWindow , // 5分窓
long : MetricsWindow , // 1時間窓
slo : SLOTarget
) : boolean {
return computeBurnRate (short, slo) > 14.4
&& computeBurnRate (long, slo) > 6.0 ;
}
この判定を、しばらくのあいだ本番で回しておりました。誤発火は一度もなく、本物の事故を一度だけ二十分で止めてくれたので、私はこれで足りていると思い込んでいたのです。
思い違いに気づいたのは、カナリアを 6 時間走らせたあとで「失敗率が 1 割を超えたまま、ウォッチドッグが一度も発火していない」ログを見返したときでした。原典(SRE Workbook 第 5 章 Alerting on SLOs )の表 5-8 を開き直すと、14.4 と 6.0 は短期と長期で分け合う値ではありません。同じ Burn-Rate を長短ふたつの窓に同時に当て、その組を重さごとに OR で並べる ——これが多重ウィンドウ・多重 Burn-Rate の本来の形でした。
14.4 は「1 時間窓と 5 分窓の両方 に当てる値」、6 は「6 時間窓と 30 分窓の両方 に当てる値」で、この二層は AND ではなく OR で並びます。私は速い層と遅い層をひとつの AND に畳んでしまい、遅い層をまるごと落としていたことになります。
手元で検出時刻を測り直しました。SLO 98.5%(許容失敗率 1.5%)、ウォッチドッグは 5 分間隔、カナリアの失敗が一定率で続く前提で、各ルールが初めて真になる時刻を 24 時間まで探索した結果です。
この表を出したとき、肝が冷えました。旧実装は失敗率 30% を 20 分で止めます——長期側に当てた 6.0 が緩いぶん、原典の速い層(45 分)よりも速く反応します。そこは悪くないのです。問題はその下でした。失敗率 9% から 21.6% の帯、つまり「まだ全壊ではないけれど確実に悪化している」という、カナリアがいちばん拾うべき領域が丸ごと素通りしていました。 5 分窓の Burn-Rate が 14.4 に届かないかぎり、AND の左側が永遠に false のままだからです。SLO 98.5% での 14.4x は失敗率 21.6% に相当しますから、10 件に 1 件が失敗する新版でも、旧実装は静観し続けます。
原典が速い層と遅い層を OR で並べているのは、まさにこの帯を遅い層に拾わせるためでした。修正版はこうなります。
// src/lib/burn-rate.ts(修正版)
// SRE Workbook 表 5-8 の多重ウィンドウ・多重 Burn-Rate をそのまま写す
// 速い層: 1時間窓 AND 5分窓 がともに 14.4 超 → 即時ロールバック
// 遅い層: 6時間窓 AND 30分窓 がともに 6 超 → 一段だけ絞って人間へ渡す
// 同じ Burn-Rate を長短の窓に当て、層どうしは OR で結ぶのが要点
export type RollbackTier = "none" | "slow" | "fast" ;
export interface BurnWindows {
m5 : MetricsWindow ;
m30 : MetricsWindow ;
h1 : MetricsWindow ;
h6 : MetricsWindow ;
}
export interface WindowReadiness {
fastReady : boolean ; // 5分窓・1時間窓のサンプル数が足りているか
slowReady : boolean ; // 30分窓・6時間窓のサンプル数が足りているか
}
export function classifyRollback (
w : BurnWindows ,
slo : SLOTarget ,
ready : WindowReadiness = { fastReady: true , slowReady: true }
) : RollbackTier {
const br = ( m : MetricsWindow ) => computeBurnRate (m, slo);
// 速い層(1時間で月次バジェットの2%を焼く速度)
if (ready.fastReady && br (w.h1) > 14.4 && br (w.m5) > 14.4 ) return "fast" ;
// 遅い層(6時間で月次バジェットの5%を焼く速度)
if (ready.slowReady && br (w.h6) > 6 && br (w.m30) > 6 ) return "slow" ;
return "none" ;
}
// 期待する動作(SLO 98.5% / ウォッチドッグ5分間隔):
// 失敗率 30% → 45分後に "fast"
// 失敗率 10% → 速い層には当たらず、5.4時間後に "slow"
// 失敗率 5% → どちらの層にも当たらない(Ticket層=3日窓の担当)
// 旧実装は下2つを永久に検出できませんでした
サンプル数の足切りを層ごとに持たせているのは、短い窓だけで足切りすると遅い層が働かなくなるためです。深夜帯のように 5 分窓が 50 件に届かない時間帯でも、6 時間窓は十分な母数を持っています。ここを共通の if でまとめて return してしまうと、せっかく足した遅い層が夜のあいだだけ消えます。
速い層は自動で 0% まで戻し、遅い層は一段だけ絞って人間に渡します。 すべてを自動ロールバックにしなかったのは、遅い層で落ちる Burn-Rate には「新版が悪い」以外の原因——上流 API の一時的な劣化、季節性のある入力分布の変化——が混じるからです。速い層の 45 分は自動で守る価値があり、遅い層の 5.4 時間は人間が状況を見るだけの猶予がある、という線の引き方をしています。
閾値そのものを下げる調整も残しています。私は画像生成エージェントで速い層を 12.0、遅い層を 4.5 に置いています。原典より敏感な設定ですが、こちらが受け止めているのは能動的にリトライしてくれない一般の利用者で、早めに止まる痛みのほうが復旧の痛みより軽いと感じているためです。ただ、閾値をいじる前に層の構造を正すほうが、はるかによく効きます 。私の場合、14.4 を 12.0 に下げたところで、5 分窓が主語であるかぎりあの帯は拾えないままでした。同じ取りこぼしをしている実装は、案外少なくないのかもしれません。
自動ロールバックトリガーの実装
Burn-Rate 判定の結果を受けて、実際にカナリアトラフィックを 0% に戻す処理を組みます。ここは Antigravity の Background Agent タスクで定期的に回すのが運用上ラクです。
// src/agents/canary-watchdog.ts
// Antigravity Background Agent として5分ごとに実行
// カナリアの Burn-Rate を監視し、危険水域なら自動ロールバックする
import { queryAnalyticsEngine } from "./telemetry-query" ;
import { computeBurnRate, classifyRollback } from "../lib/burn-rate" ;
import { getCanaryConfig, setCanaryConfig } from "../lib/canary-config" ;
const SLO = { windowDays: 30 , successRateTarget: 0.985 };
export async function canaryWatchdogTick ( env : Env ) : Promise < void > {
const w = {
m5: await queryAnalyticsEngine (env, "canary" , 300 ), // 5分窓
m30: await queryAnalyticsEngine (env, "canary" , 1800 ), // 30分窓
h1: await queryAnalyticsEngine (env, "canary" , 3600 ), // 1時間窓
h6: await queryAnalyticsEngine (env, "canary" , 21600 ), // 6時間窓
};
// サンプル数の足切りは層ごとに持つ
// 短い窓だけでまとめて return すると、低トラフィック帯で遅い層が消える
const ready = {
fastReady: w.m5.totalRequests >= 50 && w.h1.totalRequests >= 500 ,
slowReady: w.m30.totalRequests >= 200 && w.h6.totalRequests >= 2000 ,
};
if ( ! ready.fastReady && ! ready.slowReady) {
console. log ( `[watchdog] insufficient samples for both tiers, skipping` );
return ;
}
const tier = classifyRollback (w, SLO , ready);
if (tier === "none" ) return ;
const detail = {
br5m: computeBurnRate (w.m5, SLO ),
br30m: computeBurnRate (w.m30, SLO ),
br1h: computeBurnRate (w.h1, SLO ),
br6h: computeBurnRate (w.h6, SLO ),
};
if (tier === "fast" ) {
console. error ( `[watchdog] FAST BURN -> full rollback` , detail);
// カナリア割合を 0% に倒し、強制的に stable に戻す
await setCanaryConfig (env, { canaryPercent: 0 , forceFlag: "stable" });
await notifyRollback (env, { tier, ... detail });
return ;
}
// 遅い層は自動で 0% にせず、一段だけ絞って人間の判断へ渡す
const current = await getCanaryConfig (env);
const stepped =
current.canaryPercent >= 50 ? 10 : current.canaryPercent >= 10 ? 1 : 0 ;
console. warn (
`[watchdog] SLOW BURN -> step down ${ current . canaryPercent }% to ${ stepped }%` ,
detail
);
await setCanaryConfig (env, { canaryPercent: stepped });
await notifyRollback (env, { tier, ... detail });
}
// 期待する動作:
// 5分ごとに実行され、速い層に当たれば canary を即停止
// 遅い層に当たれば 50%→10%→1% と一段ずつ絞り、通知して人間の判断を待つ
// 足切りは層ごと(深夜帯でも6時間窓は母数を持つため遅い層は生き残る)
「サンプル数が少なすぎる場合は判定を保留する」という分岐は、深夜帯のような低トラフィック帯で誤発火を防ぐために欠かせません。アプリ系だと深夜は通常時の 1 割程度までトラフィックが落ちますので、ここを忘れるとロールバックが次々に発動して運用のほうが先に壊れます。あわせて、足切りを層ごとに分けた点を思い出していただければと思います。共通の return でまとめると、いちばん静かな時間帯にかぎって遅い層の目が閉じてしまうのです。
Antigravity の Background Agent としてデプロイする際は、agent.config.json で cron: "*/5 * * * *" を指定して 5 分ごとに実行させます。Agent モードであれば、canaryWatchdogTick を呼び出す Worker をスキャフォールドする一連の作業を、自然言語の指示で一気に生成できます。
デプロイの「ステータスマシン」を明示的にコードで管理する
カナリアデプロイは状態遷移そのものです。私の手元では次の 6 状態を type で固定し、許容されない遷移は型エラーになるようにしています。
// src/lib/deploy-state.ts
// カナリアデプロイの状態を有限状態機械として表現する
// 不正な状態遷移をコンパイル時に防ぐ
export type DeployState =
| { kind : "idle" }
| { kind : "canary" ; percent : 1 | 10 | 50 ; startedAt : number }
| { kind : "promoting" ; from : number ; to : 100 ; startedAt : number }
| { kind : "live" ; deployedAt : number }
| { kind : "rolling-back" ; reason : string ; startedAt : number }
| { kind : "rolled-back" ; reason : string ; rolledBackAt : number };
export type DeployEvent =
| { type : "start" ; initialPercent : 1 }
| { type : "advance" ; nextPercent : 10 | 50 }
| { type : "promote" }
| { type : "complete" }
| { type : "rollback" ; reason : string }
| { type : "rollback-complete" };
export function transition (
state : DeployState ,
event : DeployEvent
) : DeployState | null {
// idle → canary(1%)
if (state.kind === "idle" && event.type === "start" ) {
return { kind: "canary" , percent: 1 , startedAt: Date. now () };
}
// canary(N%) → canary(N+%)
if (state.kind === "canary" && event.type === "advance" ) {
return { kind: "canary" , percent: event.nextPercent, startedAt: Date. now () };
}
// canary(50%) → promoting(50→100)
if (state.kind === "canary" && state.percent === 50 && event.type === "promote" ) {
return { kind: "promoting" , from: 50 , to: 100 , startedAt: Date. now () };
}
// promoting → live
if (state.kind === "promoting" && event.type === "complete" ) {
return { kind: "live" , deployedAt: Date. now () };
}
// any → rolling-back(緊急時はどの状態からでも戻せる)
if (event.type === "rollback" ) {
return { kind: "rolling-back" , reason: event.reason, startedAt: Date. now () };
}
// rolling-back → rolled-back
if (state.kind === "rolling-back" && event.type === "rollback-complete" ) {
return {
kind: "rolled-back" ,
reason: state.reason,
rolledBackAt: Date. now (),
};
}
// 上記以外の遷移は許可しない
return null ;
}
// 期待する動作:
// transition({kind:"idle"}, {type:"start", initialPercent:1})
// => {kind:"canary", percent:1, startedAt:1715... }
// transition({kind:"canary", percent:1, ...}, {type:"promote"})
// => null (50%を経由していないのでpromote不可)
状態を型で固定すると、Antigravity Agent にコードを書かせるときに「許可されていない遷移は型エラーになるから安心」と言えます。逆に文字列の status フィールド 1 つで管理していると、Agent は気軽に新しい状態名を作ってしまい、整合性が少しずつ崩れていきます。
実際の運用では、canary(1%) → canary(10%) → canary(50%) → promoting → live の各遷移に最低 30 分の観察期間を入れます。短すぎると Burn-Rate の計測サンプルが集まりません。私は壁紙アプリでは 30 分、決済が絡む癒し系アプリでは 2 時間置いています。事故の影響度に応じて長さを変えるのが原則です。
よくある落とし穴と回避策
実際に運用してきて何度か踏んだ罠を書いておきます。読者が同じ穴に落ちる確率を少しでも下げられたら嬉しいです。
乱数ベースの振り分けで失敗する : 既に書きましたが、Math.random() ベースだと「同一ユーザーが交互に新旧版を体験する」現象が起き、Burn-Rate の意味が崩れます。必ずユーザーID(または IP のハッシュ)を使ったスティッキー振り分けにしてください
キャッシュ層で旧版のレスポンスを返してしまう : エッジキャッシュや CDN が canary レスポンスを stable のキャッシュに混入させると、判定が壊れます。Vary ヘッダにバージョン識別子を入れるか、URL パスにバージョンを含める必要があります
メトリクスの粒度が粗すぎる : 1 時間粒度だと反応が遅すぎます。最低でも 1 分粒度、できれば 30 秒粒度で集計できる仕組みにしておきます。Cloudflare Analytics Engine か Datadog の Live Tail が個人開発でも手が届く選択肢です
エラーバジェットを「全体」で見てしまう : カナリア区間の判定は、必ずカナリア系統内のエラー率で見ます。全体のエラー率で見ると、95% が stable で動いている間は値が薄まり、カナリアの異常が見えなくなります
ロールバック後のクリーンアップを忘れる : カナリアを 0% に戻したあと、KV や Durable Object の forceFlag を解除しないままだと、次のデプロイで「canaryPercent を上げても全部 stable に倒される」というハマり方をします。rolled-back 状態に入ったタイミングで forceFlag を消す処理を、状態遷移と連動させてください
Background Agent の冪等性が崩れている : Watchdog Agent が同じ判定を 2 回連続で実行すると、2 回目で別の意思決定をする可能性があります。最後の判定時刻を KV に保存し、判定間隔が短すぎる場合はスキップする実装を入れておきます
シャドーモードを併用するパターン を採るとさらに安全度が上がります。シャドーは本番トラフィックをコピーして新版に流すだけで、ユーザーへの返答は stable から返す方式です。カナリアの 1% 段階の前にシャドーを 1 週間挟むと、レスポンスの差分を観察できる時間が確保できて精神的にも余裕が出ます。
ロールバックは「使う前」にテストしておく
一度も発動させたことのないロールバック機構は、ロールバック機構ではなく約束の紙切れです。Burn-Rate ウォッチドッグを初めて信用する瞬間が本番事故の最中であってはなりません。
私が本番でカナリアを信用する前に必ずやっている演習が 2 種類あります。
1 つ目は合成的な失敗注入です。フィーチャーフラグで 3% の人為的失敗を仕込んだカナリア版をデプロイし(カナリア系統だけ 30 リクエストに 1 回エラーを返すラッパーを通します)、ウォッチドッグが 1 周期以内にカナリアを 0% に戻すかを確認します。リアリティが目的ではなく、メトリクス → Burn-Rate 計算 → KV 書き換え → エッジ伝搬の配線が「最後まで」つながっていることを確かめるのが目的です。
// src/lib/synthetic-failure.ts
// カナリア演習用の人為的エラー注入
// 本番では FAILURE_RATE_KV が未設定のため発動しない設計
export function maybeInjectFailure (
version : AgentVersion ,
env : { FAILURE_RATE_KV : KVNamespace }
) : Promise < void > {
if (version !== "canary" ) return Promise . resolve ();
return env. FAILURE_RATE_KV . get ( "drill:failure-rate" ). then ( rateStr => {
const rate = parseFloat (rateStr ?? "0" );
if (rate > 0 && Math. random () < rate) {
throw new Error ( "synthetic_canary_failure" );
}
});
}
// 期待する動作:
// drill:failure-rate に "0.03" を入れると約3%のカナリア要求が失敗扱いになり
// 2ティック以内にウォッチドッグが発火する
2 つ目は「シャドーロールバック」演習です。挙動が十分理解されている既存エージェントで、5 分間カナリア状態にしたあと、Antigravity Agent コンソールから手動で forceFlag を stable に倒し、すべての層を追跡します — KV が更新されたか、エッジキャッシュが切り替わったか、観測ダッシュボードでバージョンフィールドが戻ったか、デプロイ状態機械がきちんと遷移ログを出しているか。四半期に 1 回これをやることで、私は過去 2 件の回帰を捕まえました(Vary ヘッダ変更によりスティッキネスが壊れていたケースと、CDN キャッシュ TTL の設定ミスで 6 時間前の CanaryConfig が配信されていたケース)。
最も重要な指標は「ロールバックレイテンシ」です。ウォッチドッグが判定した瞬間から、全エッジ POP が stable を返すまでの所要時間。私の運用では 90 秒を上限にしています。この数字を測れていないなら、ブラスト半径を「知っている」のではなく「知っているつもり」になっているだけです。
コストを第一級のロールバックシグナルとして扱う
レイテンシと成功率は分かりやすい SLI です。コスト/リクエストは静かな指標で、私が最も痛い目を見たのもこの指標を明示的に監視していなかったときでした。
2025 年初頭、壁紙アプリのエージェントに「自己訂正ループ」を入れたリリースがありました。最初に生成した画像が品質しきい値を下回ったとき、エージェントが自分でリファインを再リクエストする仕組みです。意図は素直で、テスト結果は良好、成功率もほぼ変わりません。カナリア中に見落としていたのは、想定 5% で発動するはずだった再リクエスト経路が、プロンプト解析のエッジケースで約 40% の要求に対して発動していたことです。コスト/リクエストはおよそ 3 倍に跳ね上がりました。気づくまでにカナリアは 10% で 6 時間動き続け、数千円相当の推論予算がそのまま溶けていました。
それ以来、コストは 4 つの必須 Burn-Rate ストリームの 1 つになりました。閾値は意図的に失敗率より厳しめに設定しています。技術的には何も壊れていなくても、コスト劣化はビジネスを直接削るからです。
// src/lib/cost-burn-rate.ts
// コストを第一級SLOとして扱う
// 持続的なコスト劣化は失敗と同じ重さで扱う
export interface CostWindow {
totalRequests : number ;
totalCostUsd : number ;
windowSeconds : number ;
}
export function computeCostRatio (
canary : CostWindow ,
stable : CostWindow
) : number {
if (canary.totalRequests === 0 || stable.totalRequests === 0 ) return 1 ;
const canaryAvg = canary.totalCostUsd / canary.totalRequests;
const stableAvg = stable.totalCostUsd / stable.totalRequests;
if (stableAvg === 0 ) return Number.POSITIVE_INFINITY;
return canaryAvg / stableAvg;
}
// 期待する動作:
// 1.0 はコスト/リクエストが変化していない
// 2.5 はカナリア要求が stable の 2.5 倍のコストになっている
// 私の運用では 30 分窓で ratio > 1.5 が続いたらロールバック
設計上の重要なポイントは、過去の絶対値ではなく「同時刻の stable コホート」と比較することです。これによりプロバイダ側の料金改定や、ワークロード自体の自然な変化(両系統を等しく押し上げるもの)を自動補正できます。本当に検出したいのは「先週同曜日より高い」ではなく「いま隣で動いている stable より高い」ことです。
プロンプトバージョニングとの役割分担
エージェントのリリースの多くは「全体が変わった」ではなく、もっと小さな変更です。プロンプトをひと言締めた、ツールの説明文を書き直した、システムメッセージにガードレールを 1 行足した、といったレベル。これら全部を完全なカナリアプロセスにのせると、個人開発では維持できないオーバーヘッドになります。
私の運用では、プロンプトのみの変更は別のもっと軽いトラックで扱い、コードやモデルの変更だけがフルカナリアパイプラインに乗ります。境界はこう引いています。
プロンプト変更のみ : プロンプトバージョニング側で A/B テスト。Burn-Rate ウォッチドッグは観測しますが、ブラスト半径が小さいぶん閾値は緩めます。ロールバックは KV を 1 行戻すだけ
コードまたはモデル変更 : フルカナリアパイプライン。4 段階のトラフィック昇格、二重ウィンドウ Burn-Rate、自動ロールバック、状態機械による遷移強制をすべて適用
SKILL.md レベルでこの線をはっきり引いておくことで、「すべての変更を同じ重さで扱って運用注意力を浪費する」状態から抜け出せます。逆に注意したいのは、「プロンプト変更」を装っていても実質的にはコード変更と等価なケース(YAML に新しいツール参照を 1 行足すなど)です。差分が小さくても役割としてはコード変更側に分類するのが安全です。
個人開発でも回せる軽量版運用
ここまでの設計は中規模以上を想定した内容ですが、個人開発でも 80% は同じ仕組みを再利用できます。軽量化のポイントを 3 つに絞ります。
メトリクスは Cloudflare Analytics Engine 1 本でよい : Datadog や Honeycomb は強力ですが、月額数百ドル単位の固定費がかかります。Analytics Engine は 1,000 万書き込みまで無料相当で、Burn-Rate 計算には十分な精度が出ます
Burn-Rate ウォッチドッグは Cron Trigger 1 本で十分 : 専用の Durable Object を立てる必要はなく、5 分ごとに Cron で起動する Worker で運用できます。CPU 時間も 1 回数十ミリ秒で済みます
状態遷移の通知は Discord Webhook で十分 : Slack 有料プランを契約していなくても、Discord の Webhook はチームの可視性を担保するのに必要十分です。私は個人プロジェクトの通知は基本的に全部 Discord に流しています
エージェントの本番モニタリングと プロンプトのバージョニング・AB テスト と組み合わせると、個人開発でも「壊れたら気づいて、自動で戻る」最低限のセーフティネットが揃います。同じ仕組みは エージェントの評価フレームワーク とも連動させやすく、回帰検知の精度が高まります。
まずは今日できる小さな一歩から
明日からいきなり全 Agent をカナリアデプロイ化する必要はありません。私のおすすめは、まず「最もユーザー影響の大きい Agent 1 つ」を選び、その Agent 専用の Burn-Rate ダッシュボードを Cloudflare Analytics Engine で 1 枚作ることです。ダッシュボードがあるだけで、何かが壊れたときの初動が、はっきり速くなります。そこから、カナリアトラフィック分割 → 自動ロールバック → 状態遷移管理の順に積み上げると、無理なく仕組みが育っていきます。
エージェントは生き物のように挙動が日々ズレていきます。完全に止められないなら、せめて「悪い方向にズレたときに、誰の手も借りずに巻き戻る」仕組みを置いておく — それが個人開発であっても本番品質を維持するための、私なりの結論です。読んでくださってありがとうございました。同じように個人開発で AI エージェントを本番運用されている方の参考になれば幸いです。