はじめに
ECS上で動かしているFastAPIのエージェント部分を、AgentCore Runtimeに切り出す検討をしています。そこで気になったのが「障害が起きたとき、会話や処理はどうなるんだろう?」というところでした。
ドキュメントを読んでもセッションのライフサイクルは書いてあるのですが、実際に壊れたときの挙動までは書かれていません。
というわけで、自分で壊して測ってみました。東京リージョンでPUBLICモードとVPCモードのRuntimeを並べて、computeを強制終了したりネットワークを切ったりしています。
そもそもセッションとmicroVMはどういう関係か
まず前提の整理からです。AgentCore Runtimeは、論理的なセッション(runtimeSessionId)と物理的なcompute(microVM)を分けて管理しています。
セッションはRuntimeのARNが消えるまで論理的に生き続けます。一方でmicroVMは使い捨てで、以下のタイミングで終了します。
| 終了のきっかけ | タイミング |
|---|---|
| アイドルタイムアウト | 既定15分 |
| 最大ライフタイム | 既定8時間 |
| 明示的な停止 |
StopRuntimeSession API |
| ヘルスチェック不合格 | 応答しなくなったとき |
大事なのは、microVMが死んでもセッションは死なないという点です。次にinvokeが来たときに「このセッションのVMはあるかな?」と確認して、無ければ新しく作ります。
つまりフェイルオーバーといっても「壊れたVMを治す」わけではなく、捨てて作り直しているだけなんですよね。ペットではなく家畜の扱い、という感じです。
boot_idでVMの世代を見分ける
ここで困るのが、外から見ていると「VMが作り直されたのかどうか」が分からないことです。同じセッションIDで呼んでいる限り、レスポンスは普通に返ってきます。
そこで、エージェントのモジュールレベルでUUIDを生成して、レスポンスに含めるようにしました。
import uuid
# モジュールの評価はVM起動時に1回だけ走る
BOOT_ID = str(uuid.uuid4())[:8]
INVOKE_COUNT = 0
@app.entrypoint
def invoke(payload: dict) -> dict:
global INVOKE_COUNT
INVOKE_COUNT += 1
...
return {"boot_id": BOOT_ID, "invoke_count": INVOKE_COUNT, ...}
モジュールが読み込まれるのはVMが起動したときの1回だけなので、boot_idはVM単位で一意になります。同じセッションIDで呼んでboot_idが変わっていたら、裏でVMが作り直された証拠というわけです。
この小技のおかげで、以降の検証がかなり見やすくなりました。
computeを強制終了してみる
まずは一番わかりやすいケースからです。StopRuntimeSession でcomputeを落として、同じセッションIDで呼び直してみます。
事前に「合言葉はスイカです」と覚えさせておいて、強制終了のあとで聞き直す形にしました。
① 初回 1.31s boot=96cc1983 turns=2
② 2回目 0.59s boot=96cc1983 turns=4 → 「合言葉はスイカ」を覚えている
--- StopRuntimeSession(受付 0.19s)---
③ 再開 1.39s boot=bcce05bb turns=2 → 「記憶にありません」
結果はこうなりました。
- 停止から復旧まで1.39秒。しかも409エラーのリトライすら発生せず、1回目のinvokeで復旧しています
- boot_idが
96cc1983からbcce05bbに変わっているので、別のVMに作り直されたことが分かります - そして会話の記憶はきれいに消えました
呼び出し側から見ると、エラーすら返らずに1.4秒ほど遅いだけです。boot_idを仕込んでいなければ、裏でVMが入れ替わったことに気づけなかったと思います。
PUBLICモードでも同じことを測ってみましたが、1.46秒でほぼ同じでした。computeの障害復旧はネットワークモードとは無関係ということですね。セッションとmicroVMのライフサイクルの話なので、まあ当然といえば当然です。
ついでにコールドスタートも両モードで比べてみました。
| モード | 初回invoke(毎回新しいセッション) |
|---|---|
| PUBLIC | 1.13秒 / 1.05秒 |
| VPC | 1.07秒 / 0.91秒 |
ドキュメントには「VPCモードはENIのアタッチ分だけコールドスタートが増える」と書かれているのですが、今回の軽量なイメージでは差が出ませんでした。あらかじめ温められたVMが割り当てられているのかもしれませんね。
なお、メモリ上の会話履歴が消えるのはこの通りなので、文脈を継続させたいならAgentCore Memoryなどに外出しする必要があります。
これは実際に確かめました。AgentCore Memoryの短期記憶を足して同じテストを流すと、VMが作り直されても会話が続きます。
② 2回目 boot=4b9e647a turns=4 → 「合言葉は『スイカ』です」
--- StopRuntimeSession ---
③ 再開 boot=227a284f turns=6 復元=2 → 「合言葉は『スイカ』です」
boot_idは変わっている(別のVM)のに、答えられています。エージェント側でやることは、起動時に get_last_k_turns で読み直して、応答後に create_event で書き戻すだけです。所要時間も1.23秒で、Memoryなしの1.36秒と変わりませんでした。読み直しのオーバーヘッドはほぼ無視できます。
エグレスを殺してみる
次はVPCモードならではの障害です。エグレス(egress)は外に出ていく通信のことで、ここではエージェントがBedrockを呼びに行く経路を指しています。反対に外から入ってくる通信はイングレス(ingress)ですね。
VPCモードで制御されるのはこのエグレス側だけなので、ここを壊すとどうなるかを見てみます。プライベートサブネットの 0.0.0.0/0 → NAT の経路を消して、外に出られない状態を作りました。
エージェント側には、モデルを呼ばずに応答するだけの ping モードと、TCP到達性だけ見る probe モードを仕込んであります。
| 呼び出し | 結果 |
|---|---|
ping(モデル呼び出しなし) |
0.15秒で正常応答 |
probe(TCP 443) |
15秒でタイムアウト |
agent(モデル呼び出し) |
ConnectTimeoutError |
エグレスが完全に死んでいるのに、pingは0.15秒で元気に返ってきます。
AgentCoreのヘルスチェックが見ているのは「コンテナがpingに応答するか」だけで、そのコンテナが外の世界に到達できるかどうかは評価していないんですよね。なので、
- VMは破棄されない(作り直しによる自動復旧が起きない)
- 別のAZに移動もしない
- トラフィックは壊れたVMに流れ続ける
という状態になります。ECSの頃はALBのヘルスチェックに /health を仕込んで、DB疎通に失敗したタスクをターゲットグループから外していました。あの仕組みはAgentCoreには存在しないということになります。
computeが死んだときはAgentCoreが助けてくれましたが、こちらは誰も助けてくれません。フェイルオーバー耐性はアプリ側で作る必要がありそうです。
ちなみにこの試験ができるのはVPCモードだけです。PUBLICモードだとエグレス経路はAWSの管理下にあって、そもそも壊せません。裏を返すと、ここがVPCモードにしたときに増える固有のリスクということになりますね。
ハング1本でVMが沈む
さらに内側を見ていたら、もっと厄介な連鎖が起きていました。boto3のタイムアウトを既定のままにしていたときの挙動です。
agent 呼び出し 60.51s → ConcurrencyException: Agent is already processing a request
経路を復旧後 まだ失敗(詰まった呼び出しがAgentをロックしたまま)
ネットワーク待ちでハングした呼び出しがStrandsの Agent を掴んだままになって、後続のinvokeが軒並み弾かれていました。しかもネットワークを復旧させても、すぐには回復しません。詰まった呼び出しがタイムアウトするまで、VMごと機能停止したままです。
invoke_count が1回の呼び出しで2つ進んでいたので、ハング中に内部リトライが走っていたことも分かりました。リトライした先でロックにぶつかって即エラー、というわけですね...
対策としては、明示的に短いタイムアウトを設定することでした。
from botocore.config import Config
from strands.models import BedrockModel
BedrockModel(
model_id=MODEL_ID,
region_name=REGION,
boto_client_config=Config(
connect_timeout=3,
read_timeout=15,
retries={"max_attempts": 1, "mode": "standard"},
),
)
これで同じ試験をやり直すと、24.62秒で ConnectTimeoutError として素直に失敗するようになりました。そして経路を復旧させたあとは、0.54秒で正常復帰しています。
microVMは1セッションを専有するので、Agent をモジュールレベルで保持するのは理にかなった設計です。ただその裏返しで、1本のハングがVM全体を止めてしまう構造になっているんですよね。ここは本番に持っていく前に必ず設定しておきたいところです。
AZ片系障害を起こしてみる
最後に、AZ障害に近い状況を作ってみました。AZそのものは落とせないので、1a側のNAT経路だけを消して、1cは生かしておくという形で近似しています。
まず新規セッションを8本張って、どこに着地するかを見てみました。
新規セッション #1 19.78s boot=245d1b5a ❌ 断 ← 1aに着地
新規セッション #2 1.00s boot=b7bcd8c8 ✅ 到達 ← 1cに着地
新規セッション #3 1.11s boot=d87c8902 ✅ 到達
新規セッション #4 18.91s boot=4ce8066a ❌ 断
...
→ 成功 5/8
セッションは両方のAZに分散して配置されていました。健全な1cに着地したものは1秒台で普通に動いています。natGateways: 2 にしておいた効果がちゃんと出ました。NATが1台構成だったら、そのAZが落ちた時点で8本とも全滅していた計算になります。
問題はここからでした。壊れた1aに着地したセッションをリトライしてみると...
リトライ #1 19.23s boot=245d1b5a ❌ 断
リトライ #2 19.09s boot=245d1b5a ❌ 断
リトライ #3 18.58s boot=245d1b5a ❌ 断
boot_idが1ミリも変わりません。30秒リトライし続けても、同じ壊れたVMに流され続けます。
これはAgentCoreがinvokeのたびに「どのVMに流すか」を選び直していないからです。動きとしてはこうなっています。
invoke到着 → 「このセッションIDのVMはあるか?」
ある → そのVMに流す(AZの抽選は起きない)
ない → 抽選して新しいVMを作る
AZが決まるのはVMが生まれる瞬間の1回だけで、以降そのVMが生きている限り固定されます。ALBのように毎回ロードバランスされるものだと思っていると、ここで完全に足をすくわれます。セッションIDとVMが強く紐づいているからこそ会話履歴が保てるわけで、設計としては筋が通っているのですが...
では脱出方法はというと、StopRuntimeSession でVMを殺すことでした。
compute を強制終了 → 再 invoke
再配置試行 #1 1.00s boot=0d751bff ✅ 到達 ← 健全なAZに再配置され復旧
VMを消すと抽選がやり直されて、今度は健全なAZを引き当てました。リトライではなくVMを捨てるのが正解、というのが今回いちばんの学びかもしれません。
ここで気をつけたいのが、抽選が走るのはVMが作られる瞬間だけということです。「1回stopして、あとはリトライで粘る」という書き方をすると、2回目以降は同じVMに流れるので永遠に直りません。実際に私はこの間違いを検証スクリプトでやらかしました。
引き直し #1 boot=2cf92b8f ❌ ← stopが効いてVMは変わった。でもまた壊れたAZを引いた
リトライ #2 boot=2cf92b8f ❌ ← boot_idが同じ。ただのリトライなので直らない
リトライ #3 boot=2cf92b8f ❌
引き直したいなら、試行のたびにstopを叩く必要があります。
ただし、この引き直しは一発で決まるとは限りませんでした。抽選なので、また壊れたAZを引くこともあります。実際に配置がどのくらい散るのか数えてみました。
| 健全AZを引いた回数 | |
|---|---|
| 初回配置 | 9 / 18 |
| StopRuntimeSession後の引き直し | 8 / 13 |
AZが2つでうち1つが壊れている状況なので、だいたい五分五分のランダムな抽選という結果です。サンプルが少ないので正確な確率とは言えませんが、少なくとも「1回引き直せば必ず助かる」ものではないことは分かります。
ただ、独立したコイン投げと考えるのも危なそうです。同じテストを繰り返すと、実行単位で結果が大きく振れました。
| 実行 | 8本中 健全AZに着地した数 |
|---|---|
| 1回目 | 5 |
| 2回目 | 3 |
| 3回目 | 2 |
| 4回目 | 7 |
ならせば五分五分なのですが、1回の実行の中では成功や失敗が連続して固まる傾向がありました。AgentCore側の暖機済みVMのプールが、そのときどちらかのAZに偏っているのかもしれません(あくまで推測です)。
設計上は「50%だから2回引けば75%助かる」という計算をあてにしないほうが安全、ということになりますね。
実際、引き直しを2回繰り返しても救えなかったセッションもありました。
#1✗(19.38s,d6e4754d) #2✗(18.33s,d6e4754d) →STOP
#3✗(18.98s,d6c58b24) #4✗(19.16s,d6c58b24) →STOP
#5✗(18.78s,40ae408e) #6✗(19.1s,40ae408e) → 120秒かけて力尽きた
boot_idの動きを見ると、STOPのたびに新しいVMが作られている(d6e4754d → d6c58b24 → 40ae408e)のに、3回とも壊れたAZを引いてしまっています。運が悪いとこうなる、ということですね...
本番設計で気をつけたいこと
実測して見えてきたことを整理すると、障害の種類によって「誰が直すのか」がはっきり分かれます。
| 障害 | 誰が直すか | 体感 |
|---|---|---|
| computeの死 | AgentCoreが自動で作り直す | 1.4秒遅いだけ。文脈は消える |
| エグレスの死 | 誰も直さない | VMは健全なままエラーを返し続ける |
| 依存先の一時障害 | アプリのリトライ | タイムアウト設計次第でVMごと沈む |
なので、呼び出し側にはこういうリカバリを持たせることにしました。ポイントは、ただリトライするのではなく、一定回数失敗したらVMを捨てるところです。
def invoke_with_recovery(session_id, payload,
max_attempts=6, escalate_after=2):
failures = 0
for attempt in range(1, max_attempts + 1):
try:
body = invoke_agent_runtime(session_id, payload)
if body.get("ok"):
return body
except ClientError as e:
if "Conflict" in str(e):
# プロビジョニング中の一時的な衝突。VMを捨てると逆効果なので待つだけ
time.sleep(BACKOFF * attempt)
continue
failures += 1
if failures >= escalate_after and attempt < max_attempts:
# リトライを重ねても直らないならVMを捨ててAZを引き直す
stop_runtime_session(session_id)
failures = 0
time.sleep(BACKOFF)
raise RuntimeError("復旧できませんでした")
これを実際に動かして、1a側のエグレスを切った状態で新規セッションを8本さばかせてみました。
| 結果 | 本数 |
|---|---|
| 健全AZに直接着地(リトライ不要) | 3 |
| リカバリで救出(stop 1〜2回) | 4 |
| 救えず(stop 2回でも駄目) | 1 |
8本中7本は救えましたが、1本は落としました。救出できたケースも40〜80秒かかっているので、ユーザー向けの同期リクエストだと厳しい時間です。パラメータ(max_attempts と escalate_after)は、許容できる待ち時間と相談して決める必要がありますね。
ちなみに RetryableConflictException(409)の分岐も入れてありますが、こちらは今回一度も再現できませんでした。StopRuntimeSessionの直後に間を置かず呼んでも普通に応答が返ってきます。ドキュメントには「プロビジョニング中に叩くと返る」と書かれているので、念のため入れてあるという位置づけです。
あわせて押さえておきたいのがこのあたりです。
- サブネットは必ず2AZ以上にする。1AZ構成だとそのAZの障害で新規セッションすら作れなくなります
- NATもAZごとに置く。CDKなら
natGateways: 2の1行です - 会話の文脈はVMの外に出す。AgentCore Memoryなどに逃がしておかないと、VMの作り直しで消えます
- 非冪等な処理はリトライでの二重実行に注意する。処理の途中で切れても再開はしてくれないので、冪等キーの設計が要ります
さいごに
リトライやフェールオーバーはVPCモードだと挙動が変わります
実装時は注意が必要そうです
参考




