ある日、Remote Config の本番値を書き換えるエージェントを走らせていて、肝が冷えました。意図したのは「キャンペーンフラグを1つ true にする」だけだったのに、エージェントは古いキーをいくつか掃除しようとして、まだ生きている設定まで消す計画を立てていたのです。幸いそのときは手元のログを見ていて、適用される直前に止められました。けれど、もし夜間の自動ジョブだったら、運営している壁紙アプリ群の表示が一斉に壊れていたかもしれません。
私は個人開発で iOS / Android アプリを作りながら、運用の定型作業の多くを Antigravity の並列エージェントに任せています。ただ、エージェントが「外部に対して何かを変える」操作だけは、ずっと怖さが抜けませんでした。コードの変更ならレビューもロールバックもできます。けれど AdMob の設定、App Store Connect のメタデータ、Remote Config の本番値、Cloudflare の DNS — これらは一度叩いてしまうと、なかったことにするのが難しいものばかりです。
木工の世界に「測るのは何度でも、切るのは一度だけ」という言い回しがあります。寸法は納得がいくまで確かめてよい、けれど鋸を入れるのは最後の一度だけ、という考え方です。エージェントに外部操作を任せるようになってから、私はこの感覚を仕組みとして取り戻したいと思うようになりました。このノートは、そのために組んだ「副作用ゼロのドライラン層」の設計と実装の記録です。実際に動く TypeScript の最小実装を載せていますので、同じように外部副作用を伴うエージェントを運用している方の参考になればと思います。
シャドウ実行やカナリアでは「一回目」を守れない
エージェントの安全装置として、シャドウ実行やカナリアはよく知られています。私も最初はそれで足りると思っていました。けれど運用してみると、どちらも「初めて本番に触れる、その一回目」を守ってはくれないことに気づきました。
シャドウ実行は、エージェントを本番と並走させつつ、その出力を実際には反映しない手法です。観察には向いていますが、副作用を伴う操作にはそのまま使えません。「Remote Config を書き換える」という操作のシャドウは、書き換えないなら観察になりませんし、書き換えるなら本番が変わってしまいます。読み取り中心のエージェントには有効でも、書き込みエージェントの一回目は守れないのです。
カナリアは、変更を一部のユーザーや一部の対象にだけ先に適用し、問題なければ広げていく手法です。これは「適用後」に効きます。けれど私が怖かったのは、適用そのものが間違っているケースでした。さきほどの Remote Config の例で言えば、「生きている設定を消す」という計画自体が誤りで、それを1%に適用しようが100%に適用しようが、間違いは間違いです。カナリアは正しい変更の影響範囲を絞りますが、誤った変更を事前に弾いてはくれません。
私に必要だったのは、適用の前に「これから何が起きるか」を完全な形で、しかし副作用なしに、目で確かめられる仕組みでした。これがドライランです。エンジニアリングの言葉でいえば、terraform plan と terraform apply の関係に近いものを、エージェントの任意の外部操作に対して用意したい、ということでした。
ドライランが成立する条件は「副作用の集約」
ドライランを後から足そうとすると、たいてい最初の壁にぶつかります。副作用がコードのあちこちに散らばっていて、「ここを止めればすべての変更を止められる」という一点が存在しないのです。エージェントのツール実装が、それぞれ勝手に fetch を呼んだり SDK を叩いたりしていると、ドライランのために全箇所へ条件分岐を足すことになり、抜け漏れが必ず出ます。一箇所でも素通りすれば、ドライランは嘘をつきます。
ですから設計の出発点は、ドライランの仕組みそのものではなく、外部に変化を起こす操作を一箇所に集めること です。私はこれを「副作用境界(SideEffectBoundary)」と呼んでいます。エージェントのツールは、副作用を自分で実行せず、必ずこの境界に「こういう変更をしたい」と申告する。境界は、本番モードならそれを実行し、ドライランモードなら実行せずに記録だけする。この一本化ができて初めて、ドライランは信頼できるものになります。
// effect.ts — 外部副作用を表す「計画されたアクション」の型
export type EffectKind =
| "http" // 外部APIへの書き込みリクエスト
| "kv-write" // KV / Remote Config などの書き込み
| "file-write" // リポジトリやアセットの書き込み
| "delete" ; // 何かの削除(最も危険なので独立させる)
export interface Effect {
kind : EffectKind ;
// 人間が読んで「何が起きるか」を理解できる一行サマリ
summary : string ;
// 実際に実行するときに使う、構造化された詳細
target : string ; // 例: "remote-config/campaign_flag"
before ?: unknown ; // 変更前の値(取得できる場合のみ)
after ?: unknown ; // 変更後の値
// 本番適用時に呼ばれる実行関数。ドライランでは呼ばれない
commit : () => Promise < void >;
}
ポイントは、Effect が「やりたいことの宣言」と「実際にやる関数(commit)」を一つにまとめている点です。宣言部分はドライランで人間に見せるために使い、commit は本番適用のときだけ呼びます。両者を同じオブジェクトに閉じ込めることで、「計画では消すと言っていたのに、実行では別の値を消した」という乖離が起きにくくなります。
境界をモードで切り替える
次に、Effect を受け取る境界そのものを実装します。境界はモードを持ち、本番なら即実行、ドライランなら記録だけ、という振る舞いを切り替えます。
// boundary.ts — 副作用境界の本体
import { Effect } from "./effect" ;
export type RunMode = "dry-run" | "commit" ;
export class SideEffectBoundary {
private planned : Effect [] = [];
constructor ( private mode : RunMode ) {}
// エージェントのツールは副作用を直接実行せず、必ずこれを呼ぶ
async apply ( effect : Effect ) : Promise < void > {
if ( this .mode === "dry-run" ) {
// 実行はしない。計画として積むだけ
this .planned. push (effect);
return ;
}
// 本番モード: 記録してから実行する(実行前に痕跡を残す)
this .planned. push (effect);
await effect. commit ();
}
// 削除は危険度が高いので、件数の急増を検知できるようにしておく
get deleteCount () : number {
return this .planned. filter (( e ) => e.kind === "delete" ). length ;
}
get plan () : readonly Effect [] {
return this .planned;
}
}
エージェントのツール側は、こう書き換えます。直接 SDK を叩いていたコードを、boundary.apply() 経由に通すだけです。
// tools/remote-config.ts — ツールから副作用を申告する例
import { SideEffectBoundary } from "../boundary" ;
export async function setCampaignFlag (
boundary : SideEffectBoundary ,
key : string ,
value : boolean ,
client : RemoteConfigClient ,
) : Promise < void > {
const before = await client. get (key); // 読み取りは副作用ではないので常に実行
await boundary. apply ({
kind: "kv-write" ,
summary: `Remote Config ${ key } を ${ before } から ${ value } へ変更` ,
target: `remote-config/${ key }` ,
before,
after: value,
commit : async () => {
await client. set (key, value);
},
});
}
この形にしておくと、ドライランかどうかをツールが意識する必要がなくなります。ツールは「何をしたいか」を境界に伝えるだけで、実行するか記録するかは境界が決めます。関心の分離がきれいに効く設計です。
計画を差分として人間に見せる
記録された計画は、適用前にレビューできなければ意味がありません。私はこれを、コードレビューの差分と同じ感覚で読めるように整形しています。とくに削除は赤、書き込みは黄、というふうに危険度で視覚的に区別すると、夜間ジョブのログを朝に流し読みするときでも事故の芽に気づけます。
// render.ts — 計画を人間が読める差分テキストに整形する
import { Effect } from "./effect" ;
export function renderPlan ( plan : readonly Effect []) : string {
if (plan. length === 0 ) return "(変更なし)" ;
const lines : string [] = [];
const deletes = plan. filter (( e ) => e.kind === "delete" ). length ;
lines. push ( `計画された変更: ${ plan . length }件(うち削除 ${ deletes }件)` );
lines. push ( "" );
for ( const e of plan) {
const mark = e.kind === "delete" ? "[削除]" : "[変更]" ;
lines. push ( `${ mark } ${ e . target }` );
lines. push ( ` ${ e . summary }` );
if (e.kind !== "delete" && e.before !== undefined ) {
lines. push ( ` before: ${ JSON . stringify ( e . before ) }` );
lines. push ( ` after: ${ JSON . stringify ( e . after ) }` );
}
}
return lines. join ( " \n " );
}
実際の運用では、この差分を Slack か手元のログに流し、削除件数が事前に決めたしきい値を超えていたら、本番適用へ進む前に必ず人間の承認を挟むようにしています。冒頭の Remote Config の事故は、まさにこの「削除件数のしきい値」で止まったケースでした。意図は1件の書き込みだけだったのに、計画には削除が4件含まれていた。数が合わない時点で、何かがおかしいと分かります。
計画をそのまま本番に適用する二相設計
ドライランで作った計画を、人間が承認したら、今度はそれを本番に適用します。ここで大事なのは、ドライランで見せた計画と、本番で実行する操作が、同じ Effect の列であること です。別々に組み立て直すと、「レビューしたものと違うものが実行される」という最悪の乖離が起きます。
// run.ts — 二相実行: まずドライラン、承認後に同じ計画を commit
import { SideEffectBoundary } from "./boundary" ;
import { renderPlan } from "./render" ;
export async function runWithRehearsal (
agentTask : ( b : SideEffectBoundary ) => Promise < void >,
approve : ( planText : string , deletes : number ) => Promise < boolean >,
) : Promise <{ applied : boolean ; planText : string }> {
// 第1相: ドライラン。副作用ゼロで計画だけを作る
const dry = new SideEffectBoundary ( "dry-run" );
await agentTask (dry);
const planText = renderPlan (dry.plan);
// 計画を人間(またはルール)が承認するか判定
const ok = await approve (planText, dry.deleteCount);
if ( ! ok) {
return { applied: false , planText };
}
// 第2相: 承認された計画をそのまま実行する
// 注意: agentTask を再実行せず、第1相で得た Effect を commit する
const commit = new SideEffectBoundary ( "commit" );
for ( const effect of dry.plan) {
await commit. apply (effect);
}
return { applied: true , planText };
}
ここで一つ、設計上の分かれ道があります。第2相で「同じ Effect を commit する」のか、それとも「agentTask をもう一度 commit モードで走らせ直す」のかです。私は前者を選びました。後者は一見シンプルですが、エージェントの判断には揺らぎがあり、二回目の実行が一回目と違う計画を立てる可能性があります。せっかくレビューした計画と、実際に適用される操作がずれては、ドライランの意味がありません。レビューしたまさにその Effect 列を適用する。これが二相設計の肝です。
ドライランで嘘をつかせないための「読み取りの一貫性」
ここからが、公式ドキュメントにもあまり書かれていない、実運用でつまずいた部分です。ドライランは副作用を起こさないぶん、読み取った世界の状態が、本番適用の瞬間まで変わらない前提 で計画を立てます。けれど現実には、ドライランと本番適用の間に時間が空きます。承認を待つ数分、数時間のあいだに、外部の状態が変わっていることがあるのです。
私が踏んだのは、こういうケースでした。ドライランの時点では Remote Config のあるキーが存在していたので、「それを更新する」という計画が立った。けれど承認待ちの間に、別のジョブがそのキーを消していた。承認後に commit を走らせたら、存在しないキーを更新しようとしてエラーになった、という流れです。計画は嘘ではなかったのですが、世界のほうが変わってしまったわけです。
対策として、Effect に「前提条件(precondition)」を持たせ、commit の直前にそれを確かめるようにしました。ドライランで読んだ before の値が、適用直前にもまだ同じであることを確認し、違っていたら適用を中断します。楽観的ロックの考え方を、エージェントの外部操作に持ち込んだ形です。
// effect.ts に precondition を追加
export interface Effect {
kind : EffectKind ;
summary : string ;
target : string ;
before ?: unknown ;
after ?: unknown ;
// commit 直前に呼ばれる。false を返したら世界が変わっているので中断する
precondition ?: () => Promise < boolean >;
commit : () => Promise < void >;
}
// boundary.ts の commit 時に前提条件を検証する
async apply (effect: Effect): Promise <void> {
if (this.mode === "dry-run" ) {
this .planned. push (effect);
return ;
}
if (effect.precondition) {
const stillValid = await effect. precondition ();
if ( ! stillValid) {
throw new StalePlanError (
`計画が古くなっています: ${ effect . target } の前提が変わりました` ,
);
}
}
this.planned.push(effect);
await effect.commit();
}
この一手間を入れてから、「レビューしたときと世界が違う」種類の事故はゼロになりました。ドライランは未来を約束するものではなく、「いま計画を立てた時点の世界」を写したものにすぎません。その前提が崩れていないかを適用直前に問い直す。ここまでやって、ようやくドライランは信頼できる安全装置になります。
運用してみて変わった数値
このドライラン層を6サイトの自律運用に組み込んでから、いくつかの数値がはっきり変わりました。
導入前は、外部副作用を伴うエージェントジョブで、意図しない変更が本番に到達する事故が月に2〜3件ほどありました。多くは軽微で、すぐ戻せるものでしたが、Remote Config やストアメタデータのように戻しにくいものも含まれていました。ドライラン層と削除件数しきい値を入れてからの直近2ヶ月で、本番に到達した誤操作はゼロです。止まったのは、ドライランの差分レビューと precondition で弾かれた計画が、合わせて11件ありました。
副作用としてレビューにかかる時間も測りました。差分の整形をやめて全文ログを読んでいた頃は、1ジョブあたり平均で4〜5分かけて確認していました。削除を赤、件数をヘッダに出す整形を入れてからは、平常時は10秒ほどの流し読みで済み、件数がしきい値を超えたときだけ立ち止まる、というメリハリがつきました。安全装置が軽いほど、形骸化せずに続けられます。重い安全装置は、いつのまにか「とりあえず承認」になって意味を失います。
コスト面では、ドライランは実行を伴わないぶん、外部APIの書き込みクォータをほぼ消費しません。一方でドライランの計画作成にもエージェントの推論コストはかかるので、毎回ドライランしてから本番、と二度走らせると推論コストは増えます。私は危険度の高い操作(削除・本番設定の書き換え)だけ二相にし、安全な読み取り中心のジョブはドライランを省く、という線引きにしています。
どこにドライランを入れ、どこに入れないか
最後に、状況別の判断基準を共有します。すべての操作をドライランにするのは、コスト的にも運用負荷的にも現実的ではありません。
ドライランを必ず入れているのは、取り消しにくい外部副作用 です。具体的には、本番 Remote Config の書き換え、ストアのメタデータ更新、DNS や Cloudflare 設定の変更、そして種類を問わず削除です。これらは一度実行すると、復旧に手間がかかるか、復旧できないことすらあります。「測るのは何度でも、切るのは一度だけ」という言葉が、まさにこの領域に対応します。
逆にドライランを省いているのは、容易に取り消せる操作 と読み取り中心のジョブ です。Git のコミットはレビューもロールバックもできますし、記事の下書き生成はそもそも本番に直接出ません。こうした操作にまでドライランを挟むと、二度手間が運用を重くするだけで、守れる事故がほとんどありません。
判断軸を一つだけ挙げるなら、「これは戻せるか」です。戻せる操作はカナリアやロールバックで十分対応できます。戻せない操作にだけ、適用前に空振りで確かめるドライランを充てる。シャドウ実行は観察に、カナリアは適用後の影響範囲の制御に、ドライランは適用前の誤りの遮断に — この三つは競合するものではなく、守る局面が違う道具だと考えています。
エージェントに外部操作を任せるというのは、信頼の問題であると同時に、設計の問題です。信頼できるかどうかを人間が毎回判断し続けるのは続きませんし、かといって全面的に任せきるのも怖い。その間を埋めるのが、適用前にもう一度だけ確かめる仕組みだと感じています。測るのは何度でも、切るのは一度だけ。この古い流儀を、私はこういう形でコードに翻訳しているのだと思います。同じように外部副作用と向き合っている方の、設計のヒントになれば幸いです。