bash
イベント
該当するコンテンツが見つかりませんでした
マガジン
該当するコンテンツが見つかりませんでした
技術ブログ
はじめに こんにちは、Insight Edgeの小林まさみつです。 ※ 本記事はPoC案件での技術検証の内容であり、商用環境での実用性は今後の検証課題です Claude Codeのようなコーディングエージェントに実装を任せる場面は珍しくなくなりました。一方で、実装を任せたあとの「意図どおりに動いているかを確認する」「レビューする」「マージして後片付けをする」といった作業は、人の手に残りがちです。 今回は、あるPoC案件の開発で、タスクの起票からレビュー、マージ依頼までをAIエージェントに任せる仕組みを作って運用しました。本記事では、背景、設計の考え方であるハーネスエンジニアリング、具体的な仕組み、導入結果と今後の課題を紹介します。 なお、本記事は実務で行った内容をもとにしていますが、案件を特定できないよう、案件の内容はぼかして書いています。 TL;DR タスクの起票、設計、実装、レビュー、マージ依頼、後片付けをAIエージェントに任せ、人は「planとDRのレビュー」と「マージ」の判断だけを担当した 仕組みで強制する制約( deny 、フック、CI)と、プロンプト側で規定する振る舞い(レビューの3分類など)を分けて設計した PoCでの成果であり、商用の開発で通用するかは今後の検証課題 目次 背景 ハーネスエンジニアリングを活用した開発 具体的な仕組み 導入結果 今後の課題 まとめ 参考文献 背景 対象は、ドキュメントを分析し、意思決定を支援するレポートを作るPoCです。バックエンドはFastAPI、フロントエンドはReact、分析はクラウド上のLLMを使ったマルチエージェントで行っています。 このPoCでは、次の状況が重なっていました。 他案件のリリースと時期が重なり、人手が足りない 開発者が1人のため、コードレビューをする人がいない 開発タスクが60件を超え、どれも「worktreeを切る → 実装 → PR → レビュー → マージ → 後片付け」という同じ作業の繰り返しになる 一方で、PoCなので要件が限られており、試したい手法をすぐ検証できる立場でもありました。そこで、定型作業をまとめてAIに渡し、人は「何を作るか」の判断に集中する形を目指しました。 実際にどの程度の範囲を回したかは、タスクの内訳で伝わりやすいと思います。認証認可、セキュリティ対策、性能とコストの改善、テスト戦略とE2Eテスト、インフラのコード化とCI/CD、アクセシビリティの改善といった、機能要件から非機能要件、DevOpsまで幅広い領域が含まれます。これらすべてのタスクを、この仕組みで進めました。 ハーネスエンジニアリングを活用した開発 この仕組みを設計するうえで参考にしたのが、Birgitta Böckelerさんの記事 Harness engineering for coding agent users (martinfowler.com、2026年4月)です。 Agent = Model + Harness 記事では、エージェントを「Agent = Model + Harness」と捉えます。ハーネスとは、モデル以外のすべて、つまりエージェントを取り巻く仕組みのことです。エージェント製品に最初から組み込まれた部分(inner harness)と、利用者が自分のユースケース向けに作る部分(outer harness)があり、本記事で扱うのは後者です。 outer harnessは、2種類の制御で構成されます。 ガイド :エージェントが動く前に方向づける(feedforward) センサー :動いた結果を観測し、自己修正させる(feedback) さらに、それぞれを次の2種類に分けて考えられます。 computational :機械的に決まる。テスト、リンター、権限設定など inferential :LLMの判断に頼る。レビューや方針の記述など 人の役割は、同じ問題が繰り返されたときに、ガイドとセンサーを改善してエージェントを操ることです。 この関係を、本記事の内容に当てはめて図にすると次のようになります。 ハーネス概要 この図は、Böckelerさんの記事にある図を、本プロジェクトの部品で描き直したものです。原典の図は 記事 で確認できます。 人がやること、AIがやること ハーネスで周囲を固めたうえで、人の仕事は「判断」に絞りました。 担当 やること 人 起票の指示、着手の指示、planファイルとDecision Recordのレビュー(重要なタスクはテストケースも確認)、マージ、必要に応じたデプロイ作業 AI 要望の分解と調査、Issueの起票、カンバンの移動、設計、実装、テスト、PR作成、デモ動画の撮影と添付、レビューと指摘対応、マージ依頼、worktreeなどの後片付け Decision Record(DR)は、意思決定の理由と採用しなかった代替案を残すためのMarkdownです。タスクごとにAIが書き、人が読んで方針を確認します。 このプロジェクトの部品を並べる 本プロジェクトの部品を、ガイドとセンサー、computationalとinferentialの2軸で整理すると次のようになります。各部品の詳細は、次章で紹介します。 computational(機械的) inferential(LLMの判断) ガイド (事前) settings.json の deny でPRのマージを禁止、レビュー系エージェントに書き込みツールを与えない、worktreeによる作業の分離 CLAUDE.md の設計原則とコメント規約、DRのテンプレート、Issueの「やらないこと」 センサー (事後) 編集後のformatフック、コミット時のシークレット検査、CI(lint、単体テスト、E2E、ビジュアルとa11yの検査、ライセンス検査)、PRへのカバレッジコメント、ミューテーションテスト レビュー指摘の3分類、デモ動画の比較 この表を作ると、何が「強制」で何が「約束」なのかが見えます。実際には、仕組みで強制できているのは、 deny 、ツール権限、シークレット検査、CIだけです。ループの3ラウンド上限や、指摘の3分類、Slack通知の条件は、プロンプトに書いた約束にすぎません。この線引きをどう考えるかは、 今後の課題 で触れます。 具体的な仕組み 構成 構成は、Claude Codeのスキル2つ( /create-task 、 /run-task )と、サブエージェント3つ(planエージェント、devエージェント、reviewエージェント)です。 エージェントのワークフロー ポイントは、エージェント同士が直接やり取りしないことです。司令塔(Orchestrator、 /run-task )が SendMessage で仲介するため、各エージェントは自分の役割だけを見ればよくなります。また、Claude Codeのサブエージェント機能を利用することで、コンテキストを分離しつつ、必要な情報を共有できます。 1タスクの流れ 全体の流れは次のとおりです。人が関わるのは、planとDRのレビューと、最後のマージだけです。 起票( /create-task ) :要望を分解し、コードベースを並列に調査して既存Issueとの重複を判定します。続いて、選択式の質問で要件を固め、優先度とストーリーポイントを見積もります。最終確認を取ったうえで、固定の節構成(背景・目的、要件、やること、やらないこと、依存関係)と固定のラベル(優先度、ストーリーポイント、変更対象項目など)でIssueを起票し、カンバンに登録します。「やらないこと」を明記しておくと、後のレビューで範囲の逸脱を検出できます。 たとえば、次のように要望を一言伝えるだけで起票されます。以下は、実際に起票されたIssueの例です(一部をマスクしています)。 /create-task 画面サイズが大きいと余白が目立つ。見づらいので幅をディスプレイサイズに応じて調整したい 起票されたIssue 設計(planエージェント) :Issueを確認し、作業用のworktreeとブランチを作ります。カンバンを「進行中」に移し、planファイルとDRを書きます。planが完了するまで実装は始めません。人が確認するポイントはここです。正しい設計が行われているかをレビューし、必要に応じて修正指示を出します。問題が無くなれば、実装フェーズに進みます。 planファイルには、背景と要件の要約、設計方針の結論、ファイル単位の実装ステップ、テスト方針、そして「設計上の懸念点・判断が分かれる点」を書かせます。最後の項目は、人がレビューするときの着眼点になります。DRには、背景、決定事項とその理由、検討した代替案と採用しなかった理由を残します。後から「なぜこうなっているのか」をたどるための記録です。 実装(devエージェント) :planに沿って実装します。テストは必須で、LLM呼び出しのみ時間とコストの兼ね合いからモックにします。formatとテストを通してからPRを作り、PRの先頭行にIssueを閉じる記法を書いてカンバンと連動させます。さらに、人のレビューをしやすくするために、変更前後の画面を並べたデモ動画を録画してPRに添付します。後段のレビュー指摘を受け取った際、内容に同意できない場合は、反論してよいことにしています。 一方で、force push、マージ、Issueの「やらないこと」への着手は禁止です。 PRに添付されたデモ動画。左が変更前、右が変更後 レビュー(reviewエージェント) :次の8観点をレビューします。 シークレットの混入 「やらないこと」への逸脱 要件の充足 アーキテクチャ方針が適切か テストの有無と妥当性 Decision Recordが適切に記録されているか 実装品質 セキュリティ これに加えて、デモ動画の挙動がIssueの要件と一致しているかも確認します。ループは最大3ラウンドで、反論が2往復続いた場合は打ち切って人に判断を仰ぎます。これは過剰な議論や実装を防ぐための仕組みです。試しにラウンド数に制限をかけずに運用してみたところ、4ラウンド以降は些細な指摘が延々と続き、効率が大幅に低下しました。そのため、3ラウンドを上限としました。 reviewerエージェントが投稿したレビューコメントの例 マージと後片付け :レビューが承認されると、SlackのIncoming Webhookにより通知が届きます。人が内容を確認してマージすると、司令塔がマージを確認し、worktree、ローカルブランチ、リモートブランチを削除して main を更新します。マージだけは人が握り、それ以外は自動で片付く設計です。また、Slackのメッセージには追加で対応が必要な事柄(例えば、インフラリソースの変更など)も含めるようにし、何をすれば良いかが一目でわかるように工夫しています。 実装例:computationalなガイドとセンサー 強制力のある部分は、設定ファイルに数行書くだけで実現できます。たとえば、PRのマージをエージェントに禁止し、ファイルを編集するたびに自動でフォーマットをかける設定は、 .claude/settings.json に次のように書きます。 { " permissions ": { " deny ": [ " Bash(gh pr merge:*) " ] } , " hooks ": { " PostToolUse ": [ { " matcher ": " Edit|Write|MultiEdit ", " hooks ": [{ " type ": " command ", " command ": " mise run format " }] } ] } } deny はプロンプトでの「マージしないでください」と違い、エージェントがこのコマンドを実行できなくなります。実は最初の版では、reviewエージェントが承認後に自動でマージしていました。マージだけは人が握る、という方針をルールとして書くのではなく、仕組みで担保した例です。 --- name : review description : devエージェントが作成したPRをレビューし、承認時は司令塔へ報告する。マージは行わない。 tools : Bash, Read, Glob, Grep, SendMessage model : opus --- コミット時のシークレット検査(pre-commit)は、あえて検査を1つに絞っています。整形や単体テストは数十秒かかり、コミットのたびに走らせるには重いためです。フックが守るのは、一度コミットすると履歴から消すのが極めて高コストな「シークレット」だけにし、残りはCIに任せています。CIと同じコマンド、同じgitleaksのバージョンを使うため、フックが通ればCIでも通ります。なお、前述のPostToolUseフックにより、ファイルを編集するたびに自動でフォーマットがかかるようになっているため、基本的にはCIでLinter/Formatterによる失敗は起こりません。 #!/bin/sh set -e exec mise run scan:secrets:staged センサーの事例①:指摘を握りつぶさせない3分類 最初のレビューの運用では、「軽微な指摘ならAPPROVEしてコメントを残す」という余地がありました。その結果、マージ済みの3タスクで、10件以上の指摘が誰にも対応されないまま残りました。「任意対応」は、誰も対応しないことの丁寧な言い換えでした。 そこで、レビューの指摘を必ず次の3つのどれかに分類するようにしました。「任意」という分類は作りません。 分類 判断 扱い 狙い 要修正 このPRの目的や品質に必要 このPRで直す 品質を守り、要件を満たす 別タスク化 重要ではないが、いずれ直すべき 優先度を下げてIssueを起票する レビュー負荷の軽減と、過剰な実装の防止 取り下げ 些細で、直しても価値がほとんどない 理由を書いて撤回する 些細な対応の繰り返しによる、フローの長期化と無駄な実装の防止 「別タスク化」には、いまは過剰でも将来あるとよい改善も含めます。起票が終わるまでは、Slack通知もマージもできない流れにしました。これで、指摘が放置されることも、些細な指摘でPRが延々と往復することも減りました。 reviewエージェントのプロンプトには、次のように書いています(抜粋)。 すべての指摘は、次の3分類のいずれかに必ず割り当てます。 「任意対応」「参考」「好みの問題」「余力があれば」といった曖昧な分類を作ることを禁止します。曖昧な分類は、対応されないことを丁寧に言い換えただけで、実質的に指摘を捨てています。 「取り下げ」を分類に用意しているのは、指摘の水準に達しない些細な気づきを無理に起票させないためです。すべてを別タスク化すると起票が膨張し、Projectsのボードが機能しなくなります。 直す価値があるかを自分に問い、無ければ取り下げてください。 禁止する分類の名前まで具体的に列挙し、「なぜ禁止するのか」「なぜこの分類が必要なのか」という理由を添えている点がポイントです。理由を書いておくと、エージェントが想定外のケースに出会っても、意図に沿って判断してくれます。判定は「要修正が1件でもあればREQUEST_CHANGES、0件ならAPPROVE」と機械的に決まるようにし、別タスク化を提案した指摘は、GitHubのレビュー本文にも「別タスク化を提案」と明記させています。レビューを読んだ人が、その指摘が放置されたのか、起票されたのかを判別できるようにするためです。 センサーの事例②:使い捨てのデモ動画 もう一つのセンサーが、デモ動画です。PRのベースとなるコミットと、PRのブランチを並べて録画し、PRに添付します。コードを読むだけでは気づきにくい「見た目や動きの食い違い」を、レビュー時に確認できます。動画は使い捨てで、リポジトリにはコミットしません。 工夫①:役割ごとにモデルを変える エージェントごとに、使うモデルを変えています。 エージェント モデル 理由 plan fable 設計の質が後工程の品質を決める dev sonnet 設計どおりにコードへ落とす作業のため review opus 品質を担保したいが、最上位のモデルではコストが高すぎるため、折衷案とした 工夫②:運用の手間を減らす 人とエージェントの手作業を、自動化と検出に置き換えました。 カンバンの移動 :カンバンの「Todo → 進行中」だけは、planエージェントが移します。残りはGitHub Projectsの自動化に任せています。PRの先頭行にIssueを閉じる記法があれば「レビュー中」へ、マージされれば「完了」へ移ります。 Slack通知 :通知は1タスクにつき1通に絞りました。送る条件は、レビューがAPPROVEであること、別タスクの起票が終わっていること、PRがマージ可能な状態( MERGEABLE かつ CLEAN )であること、の3つがすべて揃ったときです。なお、MCP経由で投稿すると投稿者が自分自身になり、通知が鳴りません。そのため、Slack Appを作り、Incoming Webhook経由で投稿しています。 投稿はシェルスクリプトに閉じ込めています。Webhook URLを取得し、このスクリプトの中でだけ扱うことで、URLがシェル履歴や会話ログに残らないようにしています。エージェントには、このスクリプトの実行だけを許可します。 #!/usr/bin/env bash set -euo pipefail WEBHOOK_URL = " <YOUR_WEBHOOK_URL_HERE> " RESPONSE = " $( curl -sS -X POST -H ' Content-type: application/json ' \ --data @ " $1 " " $WEBHOOK_URL " ) " unset WEBHOOK_URL [ " $RESPONSE " = "ok" ] || { echo " Slackへの送信に失敗: ${RESPONSE} " >&2; exit 3 ; } 通知の本文には、マージコマンドと、マージ後に人がやることも含めます。たとえばインフラの変更を伴うPRなら、「マージ後にterraform applyを実行し、実環境で確認する」といった手順まで書いてあるため、通知を開いた時点で何をすればよいかが分かります。 Slackに届くマージレビュー依頼の通知 コンフリクト :最初は、並行して進めるタスクのうちコンフリクトしそうな組を事前に予測しようとしましたが、予測は外れました。そこで、予測をやめて検出に切り替えました。人から1件のマージ報告を受けるたびに、司令塔が残りのPRについて次のコマンドを実行します。 for n in { 残っているPR番号 } ; do gh pr view $n --json number,mergeable,mergeStateStatus done 結果が MERGEABLE ならそのまま進め、 CONFLICTING ならdevエージェントにコンフリクトの解消を依頼します。 UNKNOWN は、GitHubがまだ判定を計算中の状態です。これをコンフリクトと誤認すると不要な取り込みを依頼してしまうため、数秒待ってから再取得するようにプロンプトに明記しています。解消はrebaseではなくmergeで行います。rebaseはforce pushが必要になり、devエージェントに禁止しているルールとぶつかるためです。同じPRで解消に2回失敗した場合は、別の変更を壊している可能性があるため、人にエスカレーションします。 導入結果 数値 0.8人月での開発実績は次のとおりです。 項目 値 マージしたPR(bot除く) 86本 PR作成からマージまでの時間 中央値 約0.8時間、平均 約2.6時間 テストコードとプロダクトコードの行数比 約1.7倍 人の工数 1タスクあたり平均 約15分 実装済みの主な機能/非機能は次のとおりです。 区分 主な内容 機能要件:分析 複数の観点での分析と集約、追加の質問チャット、根拠箇所へのトレーサビリティ、追加資料での再分析とバージョン管理、失敗分だけの再実行 機能要件:レポートとUX 経済性シミュレーション、リスク注記、履歴の検索、PDF・JSONの入出力、ブラウザを閉じても続く分析と進捗配信、ダークモードとアクセシビリティ対応 非機能:認証とセキュリティ MFA込みの認証認可とユーザー管理、LLM認証の鍵レス化、保存データの暗号化、シークレット・ライセンス検査 非機能:性能とコスト プロンプトキャッシュと圧縮、ストリーミング化、リトライ、分析結果の永続化 非機能:品質保証 単体テスト、E2E、ビジュアルリグレッションとa11yの自動監査、ミューテーションテスト DevOps・基盤 Terraformによるインフラのコード化、GitHub ActionsのCI、mainマージでの自動デプロイ、依存更新の自動化 定性的な変化 人がやることは、「何を作るか」と「planとDRのレビュー」に絞られました。他案件の対応と並行しながらでも、この案件を止めずに進められたのが大きな変化です。 今後の課題 良い点だけでなく、残っている課題や今後検証したい点も挙げておきます。 設計と適用範囲 AIの処理時間とレートリミット :人の工数は小さくなりましたが、PRが出るまでの時間は、AIの処理時間とレートリミットに縛られます。設計、実装、最大3ラウンドのレビュー、デモ動画の録画にはそれぞれ時間がかかります。並行して回すタスクは最大5件を目安にしていますが、増やすほどレートリミットにぶつかりやすくなります。また、並行するPRが増えるほど、コンフリクトの確率も上がります プロンプト上のルールをどこまで仕組みで強制するか :仕組みで強制できているのは deny 、ツール権限、シークレット検査、CIだけで、ループの3ラウンド上限、指摘の3分類、Slack通知の条件はプロンプトに書いた約束にすぎません。PoCではこれで十分でしたが、商用開発に適用するなら、ここが最初の論点になると考えています 商用開発でも同じ形が通用するか :今回のPoCは要件が限られており、効率を最優先できました。品質や監査の要求が高い商用開発で、同じ人の関与の度合いでよいかは、今後の検証課題です 運用上の課題 devエージェント、reviewエージェント、人が同じGitHubアカウントを使っているため、GitHub上の承認が形式的になりやすい。実際、自分のPRにはGitHubの機能におけるAPPROVEができず、コメントで代用している ミューテーションテストは手動で、適切な頻度は検討中 バックグラウンドで動くサブエージェントが、権限の確認待ちで何も言わずに止まることがある まとめ 本記事では、タスクの起票からレビュー、マージ依頼までをAIエージェントに任せる仕組みを、ハーネスエンジニアリングの考え方とあわせて紹介しました。人によるレビュー粒度とプロンプトの設計を適切に行えば、効果的に使えるという手応えを得ました。商用の開発でそのまま通用するかは、今後の検証課題です。 案件に依存せず、他のプロジェクトにも持ち出せると考えている点は次の3つです。 AIに作業を任せるだけでなく、周囲の仕組みを設計する :事前に縛るガイドだけでも、事後に検知するセンサーだけでも足りません。指摘の3分類のように、抜け道が実際に指摘の放置を生んだ箇所を、センサー側で塞ぎました。 強制できるものは仕組みに寄せる :マージの禁止は deny で、シークレットの混入はpre-commitとCIで防いでいます。プロンプトに書いた約束は、あくまで「守られやすくなる」だけです。また、最初から完成した仕組みではなく、自動マージの全廃、3分類、コンフリクト検出、デモ動画の比較方式への修正など、事故のたびにDRに残して直してきた結果です。 人の仕事を「作業」から「判断」に寄せる :人の仕事を「判断」に絞れたことが、1人でも複数の案件を並行して進められた一番の要因でした。 AIエージェントによる開発の半自動化を検討されている方の参考になれば幸いです。 参考文献 Harness engineering for coding agent users(Birgitta Böckeler、martinfowler.com)
目次 はじめに 前提 結論 理由1: プログラムの呼び出しを1つのシェルコマンドに合成できる 理由2: 修正が次のツール呼び出しから反映される MCPを選ぶ場面 まとめ 参考文献 はじめに こんにちは、ヘルシカでiOSエンジニアをしている谷口恭一です。 Claude Codeに開発の作業を任せるようになって私が必要だと感じたのは、人が確かめなくてもエージェントが作業を最後まで進められる状態でした。結果を毎回自分で確かめて、指示し直すのをやめたかったからです。そのためにClaude Codeに足そうと考えたのは、2つの仕組みでした。1つは、エージェントがルールから外れないようにするガードレール。もう1つは、エージェントが作業し、ビルドやテストで検証し、通らなければ修正する、というフィードバックループです。このループが人を介さずに回れば、エージェントは自律的に作業を最後まで進められます。こうしたモデルの周りの仕組みを設計することは、ハーネスエンジニアリングと呼ばれています( OpenAI 、2026-02-11)。 Claude Code自体もモデルの周りのハーネスですが、本記事では、Claude Codeに自分たちで足すプログラム・スキル・フック・権限ルールをハーネスと呼びます。 本記事では、ハーネスのプログラムの例として、エージェント用のタスク管理のプログラムを使います。バックログの項目を決まった形式で読み書きするプログラムで、 backlog というコマンドで動くとします。Claudeはこれを使って、次のように作業を進めます。 backlog show-next-items で、次にやる項目を取り出す backlog take-item で項目を自分の担当にして、実装する テストが通ったら、コミットのハッシュとテスト結果を記録に付けて、 backlog move-item --to=done で完了にする このプログラムには、ガードレールとフィードバックループの両方が入っています。人の判断を待っている項目は、 take-item が担当にさせません。完了にするときに完了条件に達していなければ、何が足りないかを添えて差し戻し、Claudeはそれを読んで直します。 こうしたプログラムをClaude Codeから使えるようにする方法は2つあります。 インターフェース Claudeからの見え方 MCPサーバー MCPツールとして加わる。名前は mcp__<server>__<tool> CLI 組み込みのBashツールから実行するコマンド スキル(使い方などの説明)とフック(決まったタイミングでClaude Codeが実行する処理)は、どちらのインターフェースとも組み合わせられるので、違いが出るのはプログラムのインターフェースです。では、このプログラムはMCPとCLIのどちらで作るのがよいのでしょうか。 前提 本記事が扱うのは、開発者がローカル環境で自分の開発のために使う、ガードレールとフィードバックループのためのプログラムです。SlackやGitHub、社内のAPIなどにつなぐためのMCPサーバーは、外部サービスと連携するためのもので、この話の範囲の外にあります。 また、本記事でCLIと呼ぶのは呼ばれるたびに起動し、最新のコードを実行するコマンドです。CLIでも、常駐プロセス(デーモン)に処理を任せる作りでは、古いコードが動き続けることがあります。状態をローカル環境でファイルなどに都度書き出す仕組みにすることなどによって、常駐プロセスを持たない作りにしています。 調査したドキュメントと仕様の記述は2026年10月13日時点のものです。 結論 エージェントが自律的にフィードバックループを回し、失敗したらハーネスを自己改善する。この仕組みを作るなら、プログラムのインターフェースはCLIのほうが良いと私は考えています。 理由は2つです。1つ目に、Claude CodeがCLIをパイプや条件分岐でつなぎ、中間結果を絞り込んでから読むように動作するようになってきたからです。MCPツールでは、中間結果が毎回すべてコンテキストに戻ることが弱点だと考えています。2つ目に、CLIの場合はエージェントが修正した自分自身のコードが、次のツール呼び出しから確実に反映されることにあります。stdioのMCPサーバーは、仕組みを足さない限り再接続するまで古いコードで動き続けてしまうので、自己改善のループにおける弱点だと考えています。 理由1: プログラムの呼び出しを1つのシェルコマンドに合成できる 私の環境では、Claudeが && や | でいくつものコマンドをつないだ1つのシェルコマンドを組み、Claude Codeがそれを1回のBashツールの呼び出しで実行することが増えました。Claudeのモデル性能の向上により、暗号のような長いコマンドを一気に正確に実行する様子を見たことがある人は多いかも知れません。CLIはこのシェルコマンドの中に組み込めます。次の例は、テストが通ったときだけ、今のコミットのハッシュを付けて項目を完了にするシェルコマンドです。 npm test && backlog move-item B012 --to=done --evidence="$(git rev-parse --short HEAD) とテスト" --json | jq '{status, unblocked}' テストが失敗すれば && の後ろは実行されず、項目は完了になりません。テストが通ったときだけ backlog を呼び、その結果から、新しいステータスと、この項目を待っていて着手できるようになった項目を取り出して読みます。 Claudeは現状これらのコマンドの組み合わせをその場で自律的に考える能力があり、モデル側に柔軟に効率的なコマンド実行を任せてしまうことは大きなメリットがあると考えています。 ClaudeはMCPツールも、互いに関係しない呼び出しならモデルの1回の応答で並列に呼び出せます。しかし、前の結果による条件分岐や中間結果の絞り込みなどは結局できないことになります。よってMCPツールの結果はいったんすべてClaudeのコンテキストに戻り、Claudeが読んでから次の呼び出しを組む流れが強制されてしまいます。 ツールを1つずつ呼ぶと、呼び出しのたびに推論が1回必要になり、中間結果がコンテキストにたまります( Introducing advanced tool use on the Claude Developer Platform 、 Code execution with MCP )。Anthropicの対策は、エージェントが書いたコードの中からツールを呼ばせる、Claude APIのProgrammatic Tool Callingです。Claude Codeでこれに相当するのは、Bashツールから実行するCLIです。 フィードバックループでは、この差が回数に比例して積み重なります。「テストが通ったら完了にし、必要な部分を取り出して読む」を項目ごとに繰り返すからです。CLIなら1つのシェルコマンドで済み、Claudeが読むのは絞り込んだ結果だけになります。これらすべてを1つのスクリプトにして再利用可能にする設計も考えられますが、シンプルなツールをモデル側がうまく組み合わせるほうが柔軟性の観点から良いのではないかと考えています。 シェルコマンドの権限ルールは、 && や | で区切った部分ごとに判定されます。自作のCLIのほかに、 jq のように一緒に使うコマンドも権限ルールに入れておきます。長いシェルコマンドは、権限プロンプトで人が読んで判断しにくくなる点には注意が必要です。 理由2: 修正が次のツール呼び出しから反映される ハーネス自身の自己改善までエージェントに任せたい場合のことを考えた時に、修正がすぐに適応されるかという観点が重要になると考えています。 エージェントが作業の中で失敗したときに、原因になったハーネス(プログラム・スキル・フック・権限ルール)をエージェント自身が修正し、修正したハーネスで作業を続ける流れを作るイメージです。作業を回すループの外側で、エージェントがハーネスを直すループが回ります。 例のタスク管理のプログラムで考えます。エージェントが、人の判断を待っている項目を完了にしてしまったとします。原因は、 move-item が人の判断待ちかどうかを確かめていなかったことです。エージェントはこの確認を backlog のコードに足し、テストを通します。CLIなら、次に backlog move-item を呼んだときから新しいルールで判定されます。stdioのMCPサーバーなら、再接続するまで古いルールのまま動くので、同じ失敗が起き得ます。 このループを自律的に回すには、エージェントが修正したものが、実行中のセッションにその場で反映される必要があります。修正のたびに人がMCPサーバーの再接続やClaude Codeの再起動をするなら、ループはそこで人を待ちます。 stdioのMCPサーバーは、Claude Codeが自動では再接続しません( MCP )。 list_changed 通知でツールの一覧は再取得されますが、実行中のプロセスは再起動されず、コードの修正を反映するには /mcp からの再接続が要ります。定期実行のように人が見ていないセッションでは再接続する人がいないので、自己改善のループはそこで止まります。 MCPでも、仕組みを足せば再接続なしに新しいコードで動かせます。プロキシやファイル監視でサーバーを入れ替える方法や、CLIをラップするMCPサーバー(ラッパー)を作り、 tools/call を受けるたびにラップしたCLIを起動する方法です。ただし、どちらも管理するプロセスが1つ増えます。また、ツールを追加したり名前を変えたりしたときは、Claude Codeに確実には反映されません。ラッパーの場合は中身が結局CLIになるので、理由1の合成はできないまま、CLIとMCPサーバーの2つを持つことになります。CLIなら、何も足さずに次の呼び出しから反映されます。 Claude CodeのBashツールは、シェルコマンドごとに別のプロセスで実行します( ツールリファレンス )。常駐プロセスを持たないCLIなら、修正すれば次のツール呼び出しから新しいコードで動きます。ビルドが要るCLIでも、エージェントがビルドとインストールまで済ませれば同じです。フックから呼ぶCLIも同じです。プロジェクトの .claude/ に置いたスキルと設定(フックや権限ルール)も、再起動なしで実行中のセッションに反映されます。プログラム・スキル・フックのどこをエージェントが修正しても、次のツール呼び出しから新しいハーネスで動きます。 MCPを選ぶ場面 ユーザー単位の認可が要るときは、MCPのほうが向いていると考えます。MCPの仕様では、HTTPでつなぐサーバーはOAuth 2.1で認可し、ユーザーごとにアクセストークンを発行して、スコープで権限を絞れます。複数のユーザーが使うサービスなら、これは大きな利点です。また、そもそも暗号のようなBash実行をAuto Modeで許していることが前提であり、適切な権限管理をしたい場合はやはりMCPを使うべきだと思います。手元の環境をCLIで用意し、どうしても必要な時にMCP化を検討するという原則が最も適切なのではないかと私は考えています。「AIから呼ぶからMCPが良い」と私は最初は思っていましたが、そうではないと後から思いました。 まとめ エージェントが自律的にフィードバックループを回し、失敗したらハーネスも自分で直していくような仕組みを目指すなら、プログラムのインターフェースはCLIのほうが良いと私は考えています。CLIなら、Claudeがその場でシェルコマンドを組み、中間結果を絞り込んでから読めるからです。また、エージェントがハーネスを修正すると、次のツール呼び出しから反映されるからです。このような軽量なハーネス用CLIを設計するときは、結果をJSONで返し、常駐プロセスを持たない作りにしておくと、この2つの利点を活かせます。 一方で、ユーザー単位の認可が要るときはMCPが向いています。どちらを選ぶかは、目的によるという結論になると思います。Claude Codeを使う時に同じような迷いがあった場合、1つの参考にしていただけると嬉しいです。 参考文献 OpenAI, Harness engineering: leveraging Codex in an agent-first world , 2026-02-11 Anthropic, Introducing advanced tool use on the Claude Developer Platform , 2025-11-24 Anthropic, Code execution with MCP: Building more efficient agents , 2025-11-04 Claude Codeドキュメント: MCP / ツールリファレンス
はじめに # これは、思いついたアイデアや気になるクラウド技術を、まずは好奇心のままに手を動かして形にしてみる技術検証ログです。 前回の記事( EC2スポット×vLLMをSSMでセキュア化!ポート開放ゼロで繋ぐ )では、AWS Systems Manager(SSM)Session Managerによるポートフォワーディングを導入し、セキュリティグループのインバウンドを完全に閉塞(ポート開放ゼロ)しながら、SSH鍵管理を完全撤廃したセキュアなGPU利用環境を構築しました。 しかし、第3回・第4回と機能を追加していく中で、 クライアント側(WSL / Mac等)の起動スクリプト(Bash)に新たな運用課題 が生じていました: 探索レイテンシーの遅さ(直列処理の限界) : BashスクリプトからAWS CLIを使って複数リージョンのスポット価格、AZ候補、Spot Advisor中断率を逐次(直列に)取得していたため、探索とスコアリングだけで15〜30秒近く待たされる。 スポット枯渇(全滅)時の対応 : 世界的なAIブームの影響で、時間帯によっては候補リージョンのスポットキャパシティが全滅( InsufficientInstanceCapacity )することがあります。その際、エラーでスクリプトが止まり、手動でオンデマンドインスタンスを立て直すなどの二度手間が発生していました。 ローカル環境の肥大化と保守性 : Bashスクリプト内に価格パース、中断率計算、JSON加工、AWKによるスコアリングロジックが詰め込まれ、シェルスクリプトのメンテナンス性が限界に達していました。 そこで今回は、「いっそクラウド側で世界中の最安スポットGPUを並列で高速に自動探索・起動してくれるサーバーレスAPIを作ってしまえば面白いのでは?」というアイデアを形にするため、これらの判定・探索ロジックをすべてクライアントから切り離し、 AWS Lambdaによる「サーバーレスGPUブローカー」 へと進化させます。 Pythonの concurrent.futures によるマルチリージョン・マルチAZの高速並列スキャン(1秒台で完了)と、 スポット枯渇時のスマートな「オンデマンド・フォールバック機能」 を実装した実践的な手法を紹介します。 --> Information 📚 関連する過去の検証記事 第1回(基本構成) : AWS×UserDataでvLLMを自動起動!停止時コストほぼゼロのローカルLLM環境 第2回(Cline連携) : 自前vLLMをOpen WebUI経由でVS Code(Cline)に接続!トークンフリーで開発する 第3回(スポット活用) : EC2スポットインスタンス×vLLMでGPUコストを大幅削減する 第4回(SSMセキュア化) : EC2スポット×vLLMをSSMでセキュア化!ポート開放ゼロで繋ぐ サーバーレスGPUブローカーのアーキテクチャ # 📌 このセクションの要点 クライアントは探索・判定ロジックから解放され、「Lambdaを叩いて起動結果を受け取る」シンプルな構成に。Lambda側で全リージョンのBoto3並列クエリ・総合スコアリング・スポット起動試行・オンデマンドフォールバックを一手に引き受けます。 flowchart TD subgraph Client ["ローカル開発端末 (Bashスクリプト)"] CLI["./01_start_vllm_ssm.sh<br>(--on-demand / --strategy)"] Tunnel["SSM ポートフォワーディング<br>(localhost:8000 -> EC2:8000)"] end subgraph AWS_Mgmt ["AWS 管理基盤 (Lambda)"] Broker["vLLM Broker Lambda<br>(Python 3.12 / Boto3)"] Advisor["AWS Spot Advisor<br>(JSON 中断率)"] end subgraph Multi_Region ["マルチリージョン EC2 群"] direction LR R1["東京 (ap-northeast-1)"] R2["オレゴン (us-west-2)"] R3["バージニア (us-east-1)"] R4["オハイオ (us-east-2)"] R5["フランクフルト (eu-central-1)"] end CLI -->|"1. invoke (JSON)"| Broker Broker -.->|"並列価格・AZ取得 (1秒)"| Multi_Region Broker -.->|中断頻度取得| Advisor Broker -->|"2. スポット起動試行 (順次)"| Multi_Region Broker -- "スポット全滅時: エラー案内 or<br>オンデマンド(cost/latency)起動" --> Multi_Region Broker -->|"3. Instance ID & Region 返却"| CLI CLI -->|4. SSM ポートフォワード接続| Tunnel アーキテクチャの進化ポイント # 項目 第4回までの構成 (クライアントBash) 今回の構成 (Lambda ブローカー) メリット 探索速度 15〜30秒以上(CLIを直列実行) 約1〜2秒(Pythonスレッド並列) 待ち時間を大幅に短縮 クライアントの責務 価格・中断率の取得、探索計算、起動試行 Lambda呼び出しとSSMトンネル接続のみ 複雑な探索ロジックから解放 スポット枯渇時 エラーで中断、手動対応 明確なエラー案内 & オンデマンド切替 開発作業を止めずにリカバリ可能 通常起動戦略 なし 「コスト最安」または「レイテンシー最小」 業務の優先度に応じた柔軟な選択 スポット枯渇時のフォールバック設計 # スポットインスタンスは余剰キャパシティを利用するため、世界中でGPU需要がピークに達した時間帯には、全候補リージョンで InsufficientInstanceCapacity が返ってくる「スポット枯渇」が発生します。 本システムでは、以下の2段構えのフォールバックポリシーを採用しています: デフォルト(スポット起動時) : 候補リージョンすべてでスポット起動を試み、すべて在庫切れになった場合は即座にエラー ALL_SPOT_EXHAUSTED を返却。エラーメッセージには「通常起動オプション( --on-demand )を付与して再実行できる旨」を分かりやすく案内します。勝手にオンデマンド課金に切り替わって意図せぬ出費が発生する事故を未然に防ぎます。 オンデマンド指定時( --on-demand ) : どうしても今すぐ作業を進めたい場合、ユーザーは明示的に --on-demand フラグを指定して再実行します。その際、以下の2つの戦略( --strategy )を選択できます: cost (最安コスト重視・デフォルト) : 海外リージョンを含めた全リージョンのオンデマンド定価(ドル換算)を比較し、最も定価が安いリージョンで起動。 latency (低遅延重視) : 物理的に最も近い東京リージョン( ap-northeast-1 )を最優先で起動し、快適なタイピング・レスポンスを確保。 Lambda関数の実装 ( handler.py ) # Lambda関数のコアロジックです。外部ライブラリを必要とせず、標準ライブラリ( concurrent.futures , urllib.request )と boto3 だけで完結します。 --> Information 💡 工夫ポイント:UserDataのS3外出しと3行ブートストラップ 本実装では、初期化処理(NVMeマウント、Docker領域移動、並列同期、vLLM起動、アイドル自動停止)の長大なBashスクリプト本体を S3( s3://${S3_BUCKET}/scripts/startup.sh )へ外出し しています。 EC2に渡すUserDataは「S3からスクリプトを取得して実行するわずか3行」に留めることで、以下のメリットが得られます: Lambdaコードの大幅なスリム化 : 100行を超えるBashヒアドキュメントを排除でき、コード全体の可読性が向上。 高速な試行錯誤 : 起動スクリプトを微修正したいとき、Lambda関数を再ビルド・再デプロイすることなく、S3上のスクリプトを更新するだけで即時反映。 UserDataの16KB制限回避 : スクリプトがどれだけ肥大化しても、EC2のUserDataサイズ上限(16KB)に抵触しません。 handler.py(クリックで展開) """ vLLM GPU Spot Broker Lambda マルチリージョンのスポット探索、最適リージョン選定、Spot起動、Terminateを統括するサーバーレスブローカー """ import json import logging import urllib.request import concurrent.futures import boto3 from botocore.config import Config import os logger = logging.getLogger() logger.setLevel(logging.INFO) # 候補リージョンとインスタンスタイプ env_regions = os.environ.get("TARGET_REGIONS") CANDIDATE_REGIONS = [r.strip() for r in env_regions.split(",") if r.strip()] if env_regions else ["ap-northeast-1", "eu-central-1", "us-east-2", "us-east-1", "us-west-2"] INSTANCE_TYPE = os.environ.get("INSTANCE_TYPE", "g6.xlarge") IAM_ROLE_NAME = os.environ.get("INSTANCE_PROFILE_NAME", "vllm-ssm-instance-profile") TAG_SERVER_NAME = os.environ.get("TAG_SERVER_NAME", "vllm-ssm-spot-node") S3_BUCKET_NAME = os.environ.get("S3_BUCKET_NAME", "vllm-models-storage") ECR_REPOSITORY_NAME = os.environ.get("ECR_REPOSITORY_NAME", "vllm-openai") # リージョン別 AMI ID キャッシュ (Ubuntu 22.04 Deep Learning AMI) AMI_MAPPING = { "ap-northeast-1": "ami-0152728adca68ed36", "eu-central-1": "ami-0efde0090dab8cacf", "us-east-2": "ami-032b37e4db407994d", "us-east-1": "ami-012ba162b9cd2729c", "us-west-2": "ami-0ca70308d230e8a6e", } # リージョン別 オンデマンド参考価格 ($/h: g6.xlarge) と東京からの目安RTT (ms) ON_DEMAND_METRICS = { "ap-northeast-1": {"price": 1.3412, "latency_ms": 15, "name": "東京 (最低遅延・国内)"}, "us-west-2": {"price": 0.9840, "latency_ms": 110, "name": "オレゴン (コスト最安・中遅延)"}, "us-east-2": {"price": 0.9840, "latency_ms": 150, "name": "オハイオ (コスト最安)"}, "us-east-1": {"price": 0.9840, "latency_ms": 170, "name": "バージニア (コスト最安)"}, "eu-central-1": {"price": 1.2024, "latency_ms": 230, "name": "フランクフルト"}, } # S3外出しブートストラップ UserData (本体は s3://.../scripts/startup.sh から高速取得) USERDATA_BOOTSTRAP = """#!/bin/bash set -euo pipefail LOG_FILE="/var/log/userdata-bootstrap.log" exec > >(tee -a "${LOG_FILE}" | logger -t user-data -s 2>/dev/console) 2>&1 AWS_REGION="__AWS_REGION__" S3_BUCKET="__S3_BUCKET_NAME__" DOCKER_IMAGE="__DOCKER_IMAGE__" echo "=== S3から起動スクリプト (startup.sh) を取得 ===" aws s3 cp "s3://${S3_BUCKET}/scripts/startup.sh" /tmp/startup.sh --region "${AWS_REGION}" chmod +x /tmp/startup.sh echo "=== startup.sh 実行 ===" /tmp/startup.sh "${AWS_REGION}" "${S3_BUCKET}" "${DOCKER_IMAGE}" """ def get_spot_advisor_data(): """Spot Advisor から中断頻度ランクを取得""" url = "https://spot-bid-advisor.s3.amazonaws.com/spot-advisor-data.json" tiers = {} try: req = urllib.request.Request(url, headers={"User-Agent": "Lambda-Broker"}) with urllib.request.urlopen(req, timeout=3) as res: adv = json.loads(res.read().decode("utf-8")).get("spot_advisor", {}) for r, info in adv.items(): tier = info.get("Linux", {}).get(INSTANCE_TYPE, {}).get("r", 4) tiers[r] = tier except Exception as e: logger.warning(f"Spot Advisor 取得失敗: {e}") return tiers def query_region_metrics(region, advisor_tiers): """単一リージョンのスポット価格・AZ数を取得""" boto_cfg = Config(connect_timeout=2, read_timeout=3, retries={"max_attempts": 1}) ec2 = boto3.client("ec2", region_name=region, config=boto_cfg) try: res = ec2.describe_spot_price_history( InstanceTypes=[INSTANCE_TYPE], ProductDescriptions=["Linux/UNIX"], MaxResults=10, ) prices = res.get("SpotPriceHistory", []) if not prices: return None # 最安値とAZ数 min_price = min(float(p["SpotPrice"]) for p in prices) azs = set(p["AvailabilityZone"] for p in prices) az_count = len(azs) tier = advisor_tiers.get(region, 4) return { "region": region, "min_price": min_price, "az_count": az_count, "tier": tier, } except Exception as e: logger.error(f"Error querying {region}: {e}") return None def select_best_region(): """全候補リージョンを並列評価して最適リージョンを算出""" advisor_tiers = get_spot_advisor_data() metrics = [] with concurrent.futures.ThreadPoolExecutor(max_workers=len(CANDIDATE_REGIONS)) as executor: futures = {executor.submit(query_region_metrics, r, advisor_tiers): r for r in CANDIDATE_REGIONS} for future in concurrent.futures.as_completed(futures): res = future.result() if res: metrics.append(res) if not metrics: raise RuntimeError("全リージョンでスポット価格を取得できませんでした") best_price = min(m["min_price"] for m in metrics) # スコア計算: 価格40 + 余剰AZ40 + 中断耐性20 best_item = None max_score = -1 for m in metrics: p_score = int(40.0 * (best_price / m["min_price"])) tier = m["tier"] i_score = {0: 20, 1: 15, 2: 10, 3: 5}.get(tier, 0) az = m["az_count"] if az >= 5: c_score = 40 elif az == 4: c_score = 32 elif az == 3: c_score = 24 elif az == 2: c_score = 16 else: c_score = 8 total_score = p_score + i_score + c_score m["score"] = total_score if total_score >= 75: m["rank"] = "Rank S" elif total_score >= 65: m["rank"] = "Rank A" elif total_score >= 50: m["rank"] = "Rank B" else: m["rank"] = "Rank C" if total_score > max_score: max_score = total_score best_item = m return best_item, metrics def get_or_create_ssm_security_group(ec2_client, region): """インバウンド完全遮断(受信0ルール)のSSM専用セキュリティグループ""" sg_name = "vllm-ssm-isolated-sg" try: res = ec2_client.describe_security_groups( Filters=[{"Name": "group-name", "Values": [sg_name]}] ) if res["SecurityGroups"]: return res["SecurityGroups"][0]["GroupId"] except Exception: pass # 作成(デフォルトVPC) vpcs = ec2_client.describe_vpcs(Filters=[{"Name": "isDefault", "Values": ["true"]}])["Vpcs"] vpc_id = vpcs[0]["VpcId"] if vpcs else ec2_client.describe_vpcs()["Vpcs"][0]["VpcId"] new_sg = ec2_client.create_security_group( GroupName=sg_name, Description="Zero inbound ports allowed - access exclusively via AWS Systems Manager (SSM)", VpcId=vpc_id, ) # インバウンドルールは一切追加しない(完全閉塞) return new_sg["GroupId"] def get_latest_dlami(ec2_client): """最新のUbuntu 22.04 Deep Learning AMI IDを動的取得""" try: res = ec2_client.describe_images( Owners=["amazon"], Filters=[{"Name": "name", "Values": ["Deep Learning OSS Nvidia Driver AMI GPU PyTorch 2.* (Ubuntu 22.04)*"]}], ) images = sorted(res.get("Images", []), key=lambda x: x["CreationDate"]) if images: return images[-1]["ImageId"] except Exception as e: logger.warning(f"Failed to query latest DLAMI: {e}") return None def select_on_demand_region(strategy="cost"): """通常起動(オンデマンド)時の最適リージョンを選定 - cost: オンデマンド単価が最も安いリージョン(同価格なら低遅延優先: us-west-2 > us-east-2 > us-east-1) - latency: 東京からの遅延が最小のリージョン(ap-northeast-1 優先) """ valid_regions = [r for r in CANDIDATE_REGIONS if r in ON_DEMAND_METRICS] if not valid_regions: valid_regions = CANDIDATE_REGIONS if strategy == "latency": return sorted(valid_regions, key=lambda r: ON_DEMAND_METRICS.get(r, {}).get("latency_ms", 999)) else: return sorted( valid_regions, key=lambda r: ( ON_DEMAND_METRICS.get(r, {}).get("price", 999), ON_DEMAND_METRICS.get(r, {}).get("latency_ms", 999), ), ) def launch_instance(region, account_id, is_spot=True): """指定リージョンでインスタンス(スポットまたは通常起動)を起動""" ec2 = boto3.client("ec2", region_name=region, config=Config(retries={"max_attempts": 1})) ami_id = AMI_MAPPING.get(region) if not ami_id: ami_id = get_latest_dlami(ec2) if not ami_id: raise ValueError(f"AMI ID not configured for region {region}") sg_id = get_or_create_ssm_security_group(ec2, region) s3_bucket = S3_BUCKET_NAME if not s3_bucket or s3_bucket == "vllm-models-storage" or "*" in s3_bucket: s3_bucket = f"my-vllm-models-hackathon-2026-{account_id}-{region}-an" elif "{account_id}" in s3_bucket or "{region}" in s3_bucket: s3_bucket = s3_bucket.replace("{account_id}", account_id).replace("{region}", region) docker_image = f"{account_id}.dkr.ecr.{region}.amazonaws.com/{ECR_REPOSITORY_NAME}:latest" # UserData 生成 (S3外出しブートストラップ) userdata_script = USERDATA_BOOTSTRAP.replace("__AWS_REGION__", region) userdata_script = userdata_script.replace("__S3_BUCKET_NAME__", s3_bucket) userdata_script = userdata_script.replace("__DOCKER_IMAGE__", docker_image) def do_run(target_ami): run_params = { "ImageId": target_ami, "InstanceType": INSTANCE_TYPE, "MinCount": 1, "MaxCount": 1, "IamInstanceProfile": {"Name": IAM_ROLE_NAME}, "SecurityGroupIds": [sg_id], "InstanceInitiatedShutdownBehavior": "terminate", "BlockDeviceMappings": [ { "DeviceName": "/dev/sda1", "Ebs": { "VolumeSize": 40, "VolumeType": "gp3", "DeleteOnTermination": True, }, } ], "UserData": userdata_script, "TagSpecifications": [ { "ResourceType": "instance", "Tags": [ {"Key": "Name", "Value": TAG_SERVER_NAME}, {"Key": "Project", "Value": "vllm-ephemeral"}, {"Key": "MarketType", "Value": "spot" if is_spot else "on-demand"}, ], } ], } if is_spot: run_params["InstanceMarketOptions"] = { "MarketType": "spot", "SpotOptions": { "SpotInstanceType": "one-time", "InstanceInterruptionBehavior": "terminate", }, } return ec2.run_instances(**run_params) try: run_res = do_run(ami_id) except Exception as e: if "InvalidAMIID" in str(e): logger.info(f"AMI {ami_id} invalid in {region}, querying latest dynamically...") dynamic_ami = get_latest_dlami(ec2) if dynamic_ami and dynamic_ami != ami_id: run_res = do_run(dynamic_ami) else: raise else: raise instance_id = run_res["Instances"][0]["InstanceId"] return instance_id def launch_spot_instance(best_region_info, account_id): """後方互換性エイリアス""" return launch_instance(best_region_info["region"], account_id, is_spot=True) def terminate_all_instances(): """全候補リージョンから稼働中の vLLM インスタンス(スポット・オンデマンド問わず)を検索して Terminate""" terminated = [] def check_and_term(region): ec2 = boto3.client("ec2", region_name=region) try: res = ec2.describe_instances( Filters=[ {"Name": "tag:Name", "Values": [TAG_SERVER_NAME]}, {"Name": "instance-state-name", "Values": ["pending", "running", "stopping"]}, ] ) ids = [i["InstanceId"] for r in res.get("Reservations", []) for i in r.get("Instances", [])] if ids: ec2.terminate_instances(InstanceIds=ids) return [{"region": region, "instance_id": i} for i in ids] except Exception as e: logger.error(f"Error terminating in {region}: {e}") return [] with concurrent.futures.ThreadPoolExecutor(max_workers=len(CANDIDATE_REGIONS)) as executor: results = executor.map(check_and_term, CANDIDATE_REGIONS) for r_list in results: terminated.extend(r_list) return terminated def terminate_all_spot_instances(): """後方互換性エイリアス""" return terminate_all_instances() def lambda_handler(event, context): """Lambda メインエントリーポイント""" action = event.get("action", "launch") mode = event.get("mode", "spot") # "spot" or "on-demand" if event.get("on_demand") is True or event.get("use_on_demand") is True: mode = "on-demand" strategy = event.get("strategy", "cost").lower() # "cost" or "latency" account_id = context.invoked_function_arn.split(":")[4] logger.info(f"Received action: {action}, mode: {mode}, strategy: {strategy} (Account: {account_id})") if action == "launch": if mode == "on-demand": # 通常起動(オンデマンド) candidates = select_on_demand_region(strategy) logger.info(f"On-Demand candidate order (strategy={strategy}): {candidates}") failed_attempts = [] last_error = None for region in candidates: info = ON_DEMAND_METRICS.get(region, {"price": 1.0, "latency_ms": 100, "name": region}) logger.info(f"Attempting On-Demand launch in {region} ({info.get('name')})...") try: instance_id = launch_instance(region, account_id, is_spot=False) logger.info(f"Successfully launched On-Demand instance {instance_id} in {region}") return { "status": "success", "action": "launch", "market_type": "on-demand", "strategy": strategy, "region": region, "region_name": info.get("name"), "instance_id": instance_id, "instance_type": INSTANCE_TYPE, "on_demand_price": info.get("price"), "latency_ms": info.get("latency_ms"), "fallback_attempts": failed_attempts, "note": f"通常起動(オンデマンド)で起動しました(優先戦略: {strategy})。", } except Exception as e: err_str = str(e) if "VcpuLimitExceeded" in err_str or "InstanceLimitExceeded" in err_str: reason = "オンデマンドGPUクォータ上限到達" else: reason = f"{type(e).__name__}" logger.warning(f"On-Demand launch failed in {region} ({reason})") failed_attempts.append({"region": region, "reason": reason}) last_error = e continue return { "status": "error", "error_code": "ALL_ON_DEMAND_FAILED", "message": f"すべてのオンデマンド候補リージョンで起動に失敗しました。Last error: {last_error}", "fallback_attempts": failed_attempts, } else: # デフォルト: スポット起動 _, all_metrics = select_best_region() sorted_candidates = sorted(all_metrics, key=lambda x: x.get("score", 0), reverse=True) logger.info(f"Ranked spot candidates: {[c['region'] for c in sorted_candidates]}") failed_attempts = [] last_error = None for candidate in sorted_candidates: region = candidate["region"] logger.info(f"Attempting spot launch in {region} (Score: {candidate.get('score')}, Rank: {candidate.get('rank')})...") try: instance_id = launch_instance(region, account_id, is_spot=True) logger.info(f"Successfully launched spot instance {instance_id} in {region}") return { "status": "success", "action": "launch", "market_type": "spot", "region": region, "instance_id": instance_id, "instance_type": INSTANCE_TYPE, "spot_price": candidate.get("min_price"), "score": candidate.get("score"), "rank": candidate.get("rank"), "candidates": all_metrics, "fallback_attempts": failed_attempts, } except Exception as e: err_str = str(e) if "InsufficientInstanceCapacity" in err_str: reason = "スポット余剰キャパシティ不足 (在庫切れ)" elif "MaxSpotInstanceCountExceeded" in err_str: reason = "スポットクォータ上限到達" else: reason = f"{type(e).__name__}" logger.warning(f"Spot launch failed in {region} ({reason}), trying next candidate...") failed_attempts.append({ "region": region, "score": candidate.get("score"), "rank": candidate.get("rank"), "reason": reason, }) last_error = e continue # ★ 全リージョンでスポットが取得できなかった場合のエラーレスポンス return { "status": "error", "error_code": "ALL_SPOT_EXHAUSTED", "message": ( "すべての候補リージョンでスポットインスタンスの起動に失敗しました(キャパシティ不足等)。\n" "通常起動(オンデマンド)で起動する場合は、スクリプトに '--on-demand' オプションを指定してください。\n" " 例: ./01_start_vllm_ssm.sh --on-demand --strategy cost # コスト最安(米オレゴン等: 約$0.98/h)\n" " 例: ./01_start_vllm_ssm.sh --on-demand --strategy latency # レイテンシー最小(東京: 約$1.34/h)" ), "fallback_attempts": failed_attempts, } elif action == "terminate": terminated = terminate_all_instances() return { "status": "success", "action": "terminate", "terminated_instances": terminated, "count": len(terminated), } elif action == "status": best_region_info, all_metrics = select_best_region() return { "status": "success", "action": "status", "recommended_region": best_region_info, "all_metrics": all_metrics, } else: return { "status": "error", "message": f"Unknown action: '{action}'. Must be 'launch', 'terminate', or 'status'.", } S3に配置する初期化スクリプト ( startup.sh ) # S3( s3://${S3_BUCKET}/scripts/startup.sh )に配置する初期化シェルスクリプト本体です。インスタンス起動時にブートストラップUserData経由でS3から自動ダウンロードされ、実行されます。 NVMeマウント、Docker領域の退避、S3モデル同期とDocker pullの並列処理、vLLM起動、およびアイドル自動停止デーモンの全処理がこのスクリプトにカプセル化されています。 startup.sh(クリックで展開) #!/bin/bash set -euo pipefail LOG_FILE="/var/log/userdata-vllm.log" exec > >(tee -a "${LOG_FILE}" | logger -t user-data -s 2>/dev/console) 2>&1 echo "=== [$(date '+%Y-%m-%d %H:%M:%S')] startup.sh 実行開始 (S3外出しブートストラップ版) ===" AWS_REGION="${1:-$(curl -s http://169.254.169.254/latest/meta-data/placement/region)}" S3_BUCKET_NAME="${2:-}" DOCKER_IMAGE="${3:-}" HF_MODEL_ID="Qwen/Qwen2.5-Coder-7B-Instruct" SERVED_MODEL_NAME="Qwen/Qwen2.5-Coder-7B-Instruct" VLLM_PORT="8000" GPU_MEMORY_UTILIZATION="0.90" MAX_MODEL_LEN="16384" # 1. ローカル NVMe インスタンスストア(250GB)の検出と活用 NVME_DIR="/opt/dlami/nvme" if mountpoint -q "${NVME_DIR}" || [ -d "${NVME_DIR}" ]; then echo "DLAMI既定の NVMe マウント (${NVME_DIR}) を活用します..." mkdir -p "${NVME_DIR}/models" "${NVME_DIR}/docker" mkdir -p /data ln -sfn "${NVME_DIR}/models" /data/models else NVME_DEV=$(lsblk -d -n -o NAME,SIZE | grep -E '250G|232G' | head -n1 | awk '{print $1}') if [ -n "${NVME_DEV}" ]; then mkfs.ext4 -F "/dev/${NVME_DEV}" || true mkdir -p /data mount -o noatime "/dev/${NVME_DEV}" /data || true else mkdir -p /data fi mkdir -p /data/models /data/docker NVME_DIR="/data" fi LOCAL_MODEL_ROOT="/data/models" LOCAL_MODEL_DIR="${LOCAL_MODEL_ROOT}/${HF_MODEL_ID}" mkdir -p "${LOCAL_MODEL_DIR}" chmod 777 "${LOCAL_MODEL_ROOT}" # 2. Docker データ領域を NVMe に配置 systemctl stop docker containerd || true mkdir -p "${NVME_DIR}/docker" "${NVME_DIR}/containerd" mkdir -p /var/lib/docker /var/lib/containerd cp -a /var/lib/docker/* "${NVME_DIR}/docker/" 2>/dev/null || true cp -a /var/lib/containerd/* "${NVME_DIR}/containerd/" 2>/dev/null || true mount --bind "${NVME_DIR}/docker" /var/lib/docker mount --bind "${NVME_DIR}/containerd" /var/lib/containerd systemctl daemon-reload systemctl start containerd systemctl start docker # 3. S3モデル同期 と Docker pull の並列実行 S3_SRC="s3://${S3_BUCKET_NAME}/models/${HF_MODEL_ID}" aws configure set default.s3.max_concurrent_requests 20 ( echo "--- [Task A] Dockerイメージ取得: ${DOCKER_IMAGE} ---" if [[ "${DOCKER_IMAGE}" == *".dkr.ecr."* ]]; then ECR_REGISTRY=$(echo "${DOCKER_IMAGE}" | cut -d'/' -f1) ECR_REGION=$(echo "${ECR_REGISTRY}" | awk -F. '{print $4}') aws ecr get-login-password --region "${ECR_REGION:-${AWS_REGION}}" | docker login --username AWS --password-stdin "${ECR_REGISTRY}" || true fi docker pull "${DOCKER_IMAGE}" ) > /var/log/docker-pull.log 2>&1 & PID_DOCKER=$! ( echo "--- [Task B] S3モデル高速同期 ---" aws s3 sync "${S3_SRC}" "${LOCAL_MODEL_DIR}" --region "${AWS_REGION}" --no-progress ) > /var/log/model-sync.log 2>&1 & PID_S3=$! wait "${PID_DOCKER}" wait "${PID_S3}" echo "=== 並列ダウンロード完了! ===" # 4. vLLMコンテナ起動 docker run -d \ --name "vllm-server" \ --restart unless-stopped \ --gpus all \ --ipc=host \ -p "${VLLM_PORT}:8000" \ -v "${LOCAL_MODEL_ROOT}:/models" \ "${DOCKER_IMAGE}" \ --model "/models/${HF_MODEL_ID}" \ --served-model-name "${SERVED_MODEL_NAME}" \ --gpu-memory-utilization "${GPU_MEMORY_UTILIZATION}" \ --max-model-len "${MAX_MODEL_LEN}" \ --trust-remote-code \ --enable-auto-tool-choice \ --tool-call-parser hermes # 5. アイドル1時間自動終了デーモン cat <<'EOF_IDLE' > /usr/local/bin/auto-idle-shutdown.sh #!/bin/bash IDLE_LIMIT=3600 IDLE_COUNT=0 while true; do sleep 300 REQ=$(docker logs --since 5m vllm-server 2>&1 | grep -c "POST /v1" || true) if [ "${REQ}" -eq 0 ]; then IDLE_COUNT=$((IDLE_COUNT + 300)) [ "${IDLE_COUNT}" -ge "${IDLE_LIMIT}" ] && { shutdown -h now; exit 0; } else IDLE_COUNT=0 fi done EOF_IDLE chmod +x /usr/local/bin/auto-idle-shutdown.sh nohup /usr/local/bin/auto-idle-shutdown.sh > /dev/null 2>&1 & echo "=== startup.sh 完了 (SSM待機中) ===" Lambdaのデプロイスクリプト ( 00_deploy_lambda.sh ) # 作成した handler.py をZIPアーカイブ化してAWS Lambdaにデプロイし、同時に startup.sh をS3バケットへ自動同期するシェルスクリプトです。 00_deploy_lambda.sh(クリックで展開) #!/bin/bash set -euo pipefail # ============================================================================== # 00_deploy_lambda.sh # GPU起動ブローカーLambda関数およびIAM実行ロールを自動作成・更新するスクリプト # ============================================================================== SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" LAMBDA_DIR="$(cd "${SCRIPT_DIR}/../lambda" && pwd)" FUNCTION_NAME="vllm-spot-broker" ROLE_NAME="vllm-spot-broker-lambda-role" REGION="ap-northeast-1" echo "=== 1. Lambda 実行ロール (${ROLE_NAME}) の作成/確認 ===" AWS_ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text) TRUST_POLICY='{ "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Principal": { "Service": "lambda.amazonaws.com" }, "Action": "sts:AssumeRole" } ] }' if ! aws iam get-role --role-name "${ROLE_NAME}" >/dev/null 2>&1; then echo "IAMロールを作成中..." aws iam create-role \ --role-name "${ROLE_NAME}" \ --assume-role-policy-document "${TRUST_POLICY}" \ --description "Execution role for vLLM Spot Broker Lambda" >/dev/null # 基本ログ出力権限 aws iam attach-role-policy \ --role-name "${ROLE_NAME}" \ --policy-arn "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" # EC2 & IAM PassRole 権限インラインポリシー BROKER_POLICY="{ \"Version\": \"2012-10-17\", \"Statement\": [ { \"Effect\": \"Allow\", \"Action\": [ \"ec2:Describe*\", \"ec2:RunInstances\", \"ec2:TerminateInstances\", \"ec2:CreateSecurityGroup\", \"ec2:CreateTags\" ], \"Resource\": \"*\" }, { \"Effect\": \"Allow\", \"Action\": \"iam:PassRole\", \"Resource\": [ \"arn:aws:iam::${AWS_ACCOUNT_ID}:role/vllm-ssm-instance-role\", \"arn:aws:iam::${AWS_ACCOUNT_ID}:role/VllmServerSSMRole\" ] } ] }" aws iam put-role-policy \ --role-name "${ROLE_NAME}" \ --policy-name "VllmBrokerPermissions" \ --policy-document "${BROKER_POLICY}" echo "IAMロール作成完了。権限の伝播を少し待機中..." sleep 10 else echo "既存のIAMロールを使用します: ${ROLE_NAME}" fi ROLE_ARN="arn:aws:iam::${AWS_ACCOUNT_ID}:role/${ROLE_NAME}" echo "=== 2. Lambda コードのパッケージング ===" TMP_ZIP=$(mktemp --suffix=.zip) rm -f "${TMP_ZIP}" (cd "${LAMBDA_DIR}" && zip -q -r "${TMP_ZIP}" handler.py) echo "=== 2.5 起動スクリプト (startup.sh) を S3 へ同期 ===" S3_BUCKET="my-vllm-models-hackathon-2026-${AWS_ACCOUNT_ID}-${REGION}-an" if aws s3 ls "s3://${S3_BUCKET}" >/dev/null 2>&1; then echo "startup.sh を s3://${S3_BUCKET}/scripts/startup.sh へアップロード中..." aws s3 cp "${SCRIPT_DIR}/startup.sh" "s3://${S3_BUCKET}/scripts/startup.sh" --region "${REGION}" fi echo "=== 3. Lambda 関数 (${FUNCTION_NAME}) の作成/更新 ===" if aws lambda get-function --region "${REGION}" --function-name "${FUNCTION_NAME}" >/dev/null 2>&1; then echo "既存のLambda関数コードを更新中..." aws lambda update-function-code \ --region "${REGION}" \ --function-name "${FUNCTION_NAME}" \ --zip-file "fileb://${TMP_ZIP}" >/dev/null else echo "新しいLambda関数を作成中..." aws lambda create-function \ --region "${REGION}" \ --function-name "${FUNCTION_NAME}" \ --runtime "python3.12" \ --role "${ROLE_ARN}" \ --handler "handler.lambda_handler" \ --zip-file "fileb://${TMP_ZIP}" \ --timeout 60 \ --memory-size 256 \ --description "Multi-region spot instance broker for vLLM" >/dev/null fi rm -f "${TMP_ZIP}" echo "=== デプロイ完了! ===" echo "Lambda 関数名: ${FUNCTION_NAME} (${REGION})" 探索ロジックを手放したクライアント側スクリプト ( 01_start_vllm_ssm.sh ) # ローカルPC側の起動スクリプトは、複雑なマルチリージョン計算や中断率データの取得をすべてクラウド側へ手放し、 「Lambdaを呼び出して起動されたインスタンス情報を受け取り、SSMポートフォワーディングを張る」 というローカル環境の接続・トンネル管理に専念する構成へと進化します。 01_start_vllm_ssm.sh(クリックで展開) #!/bin/bash set -euo pipefail # ============================================================================== # 01_start_vllm_ssm.sh # Lambdaブローカーを呼び出して最安スポットを起動し、SSMポートフォワーディングで安全に直結する # ============================================================================== SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" INSTANCE_STATE_FILE="${SCRIPT_DIR}/.current_instance_id" TUNNEL_PID_FILE="${SCRIPT_DIR}/.current_tunnel_pid" REGION_STATE_FILE="${SCRIPT_DIR}/.current_region" TUNNEL_LOG="${SCRIPT_DIR}/.current_tunnel.log" BROKER_REGION="ap-northeast-1" FUNCTION_NAME="vllm-spot-broker" # オプション解析 USE_ON_DEMAND="false" STRATEGY="cost" while [[ $# -gt 0 ]]; do case "$1" in --on-demand) USE_ON_DEMAND="true" shift ;; --strategy) STRATEGY="${2:-cost}" shift 2 ;; -h|--help) echo "使用方法: $0 [オプション]" echo "" echo "オプション:" echo " --on-demand 通常起動(オンデマンド)で起動する(デフォルトはスポット起動)" echo " --strategy <cost|latency> 通常起動時の優先戦略(デフォルト: cost)" echo " cost : オンデマンド単価が最も安いリージョンを選択(米オレゴン等: 約\$0.98/h)" echo " latency : レイテンシーが最も小さいリージョンを選択(東京: 約\$1.34/h)" echo " -h, --help ヘルプを表示" exit 0 ;; *) echo "不明なオプション: $1" echo "ヘルプ: $0 --help" exit 1 ;; esac done TMP_RES=$(mktemp) if [ "${USE_ON_DEMAND}" = "true" ]; then echo "=== 1. サーバーレスGPUブローカー(Lambda)呼び出し [通常起動 / 戦略: ${STRATEGY}]... ===" PAYLOAD=$(jq -n --arg mode "on-demand" --arg strategy "${STRATEGY}" '{"action":"launch","mode":$mode,"strategy":$strategy}') else echo "=== 1. サーバーレスGPUブローカー(Lambda)呼び出し [スポット探索 & 起動]... ===" PAYLOAD='{"action":"launch","mode":"spot"}' fi aws lambda invoke \ --region "${BROKER_REGION}" \ --function-name "${FUNCTION_NAME}" \ --payload "${PAYLOAD}" \ --cli-binary-format raw-in-base64-out \ "${TMP_RES}" >/dev/null STATUS=$(jq -r '.status // "error"' "${TMP_RES}") if [ "${STATUS}" != "success" ]; then ERROR_CODE=$(jq -r '.error_code // ""' "${TMP_RES}") MESSAGE=$(jq -r '.message // ""' "${TMP_RES}") echo "==========================================================" if [ "${ERROR_CODE}" = "ALL_SPOT_EXHAUSTED" ]; then echo "❌ スポット在庫切れ: 全候補リージョンでスポットインスタンスを調達できませんでした。" echo "----------------------------------------------------------" echo "【試行した候補リージョンと結果】" jq -r '.fallback_attempts[]? | " - \(.region) (\(.score // "-")点 / \(.rank // "-")): \(.reason)"' "${TMP_RES}" echo "" echo "💡 通常起動(オンデマンド)であれば確実に起動可能です。" echo " 以下のコマンドで再実行してください:" echo "" echo " # コスト最安リージョン(米オレゴン等: 約\$0.98/h)で通常起動:" echo " $0 --on-demand --strategy cost" echo "" echo " # レイテンシー最小リージョン(東京: 約\$1.34/h)で通常起動:" echo " $0 --on-demand --strategy latency" else echo "エラー: Lambdaの起動リクエストが失敗しました。" echo "${MESSAGE}" cat "${TMP_RES}" fi echo "==========================================================" rm -f "${TMP_RES}" exit 1 fi MARKET_TYPE=$(jq -r '.market_type // "spot"' "${TMP_RES}") INSTANCE_ID=$(jq -r '.instance_id' "${TMP_RES}") SELECTED_REGION=$(jq -r '.region' "${TMP_RES}") echo "${INSTANCE_ID}" > "${INSTANCE_STATE_FILE}" echo "${SELECTED_REGION}" > "${REGION_STATE_FILE}" echo "==========================================================" if [ "${MARKET_TYPE}" = "on-demand" ]; then STRAT=$(jq -r '.strategy // "cost"' "${TMP_RES}") PRICE=$(jq -r '.on_demand_price // "-"' "${TMP_RES}") LATENCY=$(jq -r '.latency_ms // "-"' "${TMP_RES}") REG_NAME=$(jq -r '.region_name // ""' "${TMP_RES}") echo " ⚡ 通常起動(オンデマンド)完了: 戦略 = ${STRAT}" echo " 選定リージョン : ${SELECTED_REGION} (${REG_NAME})" echo " インスタンスID : ${INSTANCE_ID}" echo " オンデマンド価格: \$${PRICE}/h" echo " 目安レイテンシー: 約 ${LATENCY} ms" else PRICE=$(jq -r '.spot_price // "-"' "${TMP_RES}") SCORE=$(jq -r '.score // "-"' "${TMP_RES}") RANK=$(jq -r '.rank // "-"' "${TMP_RES}") echo " 🏆 最適スポットリージョン選定完了: ${SELECTED_REGION} (${RANK} / ${SCORE}点)" echo " インスタンスID: ${INSTANCE_ID}" echo " スポット価格 : \$${PRICE}/h" FALLBACK_COUNT=$(jq -r '.fallback_attempts | length // 0' "${TMP_RES}") if [ "${FALLBACK_COUNT}" -gt 0 ]; then echo " ⚠️ 上位候補リージョンで一時的なスポット在庫不足が発生したため、自動フォールバックしました:" jq -r '.fallback_attempts[] | " - \(.region) (\(.score)点 / \(.rank)): \(.reason)"' "${TMP_RES}" fi fi echo "==========================================================" rm -f "${TMP_RES}" # インスタンスの実行状態(running)待機 echo "インスタンスが running 状態になるのを待機中..." aws ec2 wait instance-running --region "${SELECTED_REGION}" --instance-ids "${INSTANCE_ID}" # Git Bash環境向けにSessionManagerPluginのパスを追加 if [ -d "/c/Program Files/Amazon/SessionManagerPlugin/bin" ]; then export PATH="${PATH}:/c/Program Files/Amazon/SessionManagerPlugin/bin" fi # --- 2. SSMエージェントのオンライン待機 --- echo "=== 2. SSMエージェントのオンライン待機 ===" echo "EC2インスタンス内部のSSM Agentが接続されるのを待機中..." SSM_OK=false for i in {1..30}; do STATUS=$(aws ssm describe-instance-information \ --region "${SELECTED_REGION}" \ --filters "Key=InstanceIds,Values=${INSTANCE_ID}" \ --query "InstanceInformationList[0].PingStatus" \ --output text 2>/dev/null || true) if [ "${STATUS}" = "Online" ]; then echo " SSM Agent が Online になりました! (${i}回目の試行)" SSM_OK=true break fi echo -n "." sleep 3 done echo "" if [ "${SSM_OK}" != "true" ]; then echo "エラー: SSM Agent が Online になりませんでした。IAMロールやアウトバウンド通信を確認してください。" exit 1 fi # --- 3. サーバー側での vLLM 起動待機 (S3モデルDL・コンテナ起動) --- echo "=== 3. サーバー側での vLLM 起動待機 (http://127.0.0.1:8000/health) ===" echo "UserDataによるS3モデルダウンロードとvLLM起動を待機しています(通常2〜4分程度)..." SERVER_READY=false for i in {1..90}; do CMD_ID=$(aws ssm send-command \ --region "${SELECTED_REGION}" \ --instance-ids "${INSTANCE_ID}" \ --document-name "AWS-RunShellScript" \ --parameters 'commands=["curl -s -m 2 http://127.0.0.1:8000/health >/dev/null && echo OK || echo WAITING"]' \ --query "Command.CommandId" \ --output text 2>/dev/null || true) if [ -n "${CMD_ID}" ]; then sleep 2 RES=$(aws ssm get-command-invocation \ --region "${SELECTED_REGION}" \ --command-id "${CMD_ID}" \ --instance-id "${INSTANCE_ID}" \ --query "StandardOutputContent" \ --output text 2>/dev/null || true) if [[ "${RES}" =~ "OK" ]]; then echo " サーバー側の vLLM が正常に応答しました!" SERVER_READY=true break fi fi echo -n "." sleep 5 done echo "" if [ "${SERVER_READY}" != "true" ]; then echo "警告: サーバー側のvLLM起動待機がタイムアウトしました。UserDataログを確認してください。" fi # --- 4. 既存トンネルの整理と新規SSMポートフォワーディング確立 --- echo "=== 4. SSMポートフォワーディングの確立 (ポート開放不要トンネル) ===" if [ -f "${TUNNEL_PID_FILE}" ]; then OLD_PID=$(cat "${TUNNEL_PID_FILE}" | tr -d '[:space:]') if [ -n "${OLD_PID}" ] && ps -p "${OLD_PID}" > /dev/null 2>&1; then echo "古いポートフォワードプロセス (PID: ${OLD_PID}) を終了します..." kill -9 "${OLD_PID}" 2>/dev/null || true fi rm -f "${TUNNEL_PID_FILE}" fi pkill -f "AWS-StartPortForwardingSession.*8000" 2>/dev/null || true # バックグラウンドでSSMポートフォワーディングを開始(ログを .current_tunnel.log に出力) nohup aws ssm start-session \ --region "${SELECTED_REGION}" \ --target "${INSTANCE_ID}" \ --document-name AWS-StartPortForwardingSession \ --parameters '{"portNumber":["8000"],"localPortNumber":["8000"]}' \ > "${TUNNEL_LOG}" 2>&1 & TUNNEL_PID=$! echo "${TUNNEL_PID}" > "${TUNNEL_PID_FILE}" echo "SSMポートフォワーディングを開始しました (PID: ${TUNNEL_PID})" sleep 2 # --- 5. ローカル疎通確認 --- echo "=== 5. ローカル疎通確認 (http://localhost:8000/health) ===" LOCAL_OK=false for i in {1..10}; do if curl -s -m 3 "http://localhost:8000/health" > /dev/null 2>&1; then echo " ローカルからの直結疎通を確認しました! (HTTP 200 OK)" LOCAL_OK=true break fi echo -n "." sleep 2 done echo "" if [ "${LOCAL_OK}" != "true" ]; then echo "警告: ローカル接続に失敗しました。トンネルログを確認してください:" cat "${TUNNEL_LOG}" 2>/dev/null || true fi echo "==========================================================" echo " サーバーレスGPUブローカーによる直結完了!" echo " EC2 Instance ID : ${INSTANCE_ID} (${MARKET_TYPE}, Region: ${SELECTED_REGION})" echo " セキュリティ : インバウンドルール 0件(完全閉塞)" echo " トンネル接続 : localhost:8000 -> EC2:8000 (SSM暗号化)" echo " Web UI / API : http://localhost:8000/v1" echo "" echo " VS Code の Cline から接続してください (Base URL: http://localhost:8000/v1)" echo " 検証終了時は以下のコマンドでリソースを破棄してください:" echo " ./02_stop_vllm_ssm.sh" echo "==========================================================" 実際の動作パターンと検証 # 1. 通常のスポット起動(1秒台で最適リージョンを選定・起動要求) # 引数なしで実行すると、Lambdaが全リージョンを並列評価し、最安・低中断率のスポットをわずか1秒台で引き当てます。 $ ./01_start_vllm_ssm.sh === 1. サーバーレスGPUブローカー(Lambda)を呼び出し中 [スポット起動]... === ========================================================== 🏆 最適スポットリージョン選定完了: us-west-2 (Rank A / 68点) インスタンスID: i-087654321fedcba09 スポット価格 : $0.4264/h ========================================================== インスタンスが running 状態になるのを待機中... === 2. SSMエージェントのオンライン待機 === EC2インスタンス内部のSSM Agentが接続されるのを待機中... . SSM Agent が Online になりました! (1回目の試行) === 3. サーバー側での vLLM 起動待機 (http://127.0.0.1:8000/health) === UserDataによるS3モデルダウンロードとvLLM起動を待機しています(通常2〜4分程度)... ................................ サーバー側の vLLM が正常に応答しました! === 4. SSMポートフォワーディングの確立 (ポート開放不要トンネル) === SSMポートフォワーディングを開始しました (PID: 53120) === 5. ローカル疎通確認 (http://localhost:8000/health) === . ローカルからの直結疎通を確認しました! (HTTP 200 OK) ========================================================== サーバーレスGPUブローカーによる直結完了! EC2 Instance ID : i-087654321fedcba09 (spot, Region: us-west-2) セキュリティ : インバウンドルール 0件(完全閉塞) トンネル接続 : localhost:8000 -> EC2:8000 (SSM暗号化) Web UI / API : http://localhost:8000/v1 VS Code の Cline から接続してください (Base URL: http://localhost:8000/v1) 検証終了時は以下のコマンドでリソースを破棄してください: ./02_stop_vllm_ssm.sh ========================================================== 2. スポット枯渇時(フォールバック案内) # 世界的にGPUキャパシティが逼迫している時間帯では、Lambdaがすべての候補リージョンを試みた上でスマートにフォールバック案内を返します: $ ./01_start_vllm_ssm.sh === 1. サーバーレスGPUブローカー(Lambda)を呼び出し中 [スポット起動]... === ========================================================== ❌ スポット在庫切れ: 全候補リージョンでスポットインスタンスを調達できませんでした。 ---------------------------------------------------------- 【試行した候補リージョンと結果】 - us-west-2 (68点 / Rank A): スポット余剰キャパシティ不足 (在庫切れ) - us-east-2 (74点 / Rank A): スポット余剰キャパシティ不足 (在庫切れ) - eu-central-1 (78点 / Rank S): スポット余剰キャパシティ不足 (在庫切れ) - us-east-1 (68点 / Rank A): スポット余剰キャパシティ不足 (在庫切れ) - ap-northeast-1 (43点 / Rank C): スポット余剰キャパシティ不足 (在庫切れ) 💡 通常起動(オンデマンド)であれば確実に起動可能です。 以下のコマンドで再実行してください: # コスト最安リージョン(米オレゴン等: 約$0.98/h)で通常起動: ./01_start_vllm_ssm.sh --on-demand --strategy cost # レイテンシー最小リージョン(東京: 約$1.34/h)で通常起動: ./01_start_vllm_ssm.sh --on-demand --strategy latency ========================================================== 3. オンデマンド起動への切り替え # 今すぐ作業を進めたい場合は、案内通りに --on-demand を付与して再実行します。 最安コスト重視( --strategy cost ): $ ./01_start_vllm_ssm.sh --on-demand --strategy cost === 1. サーバーレスGPUブローカー(Lambda)を呼び出し中 [通常起動 / 戦略: cost]... === ========================================================== ⚡ 通常起動(オンデマンド)完了: 戦略 = cost 選定リージョン : us-west-2 (オレゴン (コスト最安・中遅延)) インスタンスID : i-01122334455667788 オンデマンド価格: $0.9840/h 目安レイテンシー: 約 110 ms ========================================================== ... 低遅延重視( --strategy latency ): $ ./01_start_vllm_ssm.sh --on-demand --strategy latency === 1. サーバーレスGPUブローカー(Lambda)を呼び出し中 [通常起動 / 戦略: latency]... === ========================================================== ⚡ 通常起動(オンデマンド)完了: 戦略 = latency 選定リージョン : ap-northeast-1 (東京 (最低遅延・国内)) インスタンスID : i-09988776655443322 オンデマンド価格: $1.3412/h 目安レイテンシー: 約 15 ms ========================================================== ... 検証終了時のリソース破棄スクリプト ( 02_stop_vllm_ssm.sh ) # 作業が終了したら、破棄スクリプト( 02_stop_vllm_ssm.sh )を実行します。ローカルのSSMトンネルプロセスを安全に切断した上で、Lambdaブローカーに終了リクエストを発行し、EC2インスタンスおよびEBSボリュームを確実に破棄します。 02_stop_vllm_ssm.sh(クリックで展開) #!/bin/bash set -euo pipefail # ============================================================================== # 02_stop_vllm_ssm.sh # SSMポートフォワードプロセスを終了し、Lambda経由でGPUインスタンスを破棄する # ============================================================================== SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" INSTANCE_STATE_FILE="${SCRIPT_DIR}/.current_instance_id" TUNNEL_PID_FILE="${SCRIPT_DIR}/.current_tunnel_pid" REGION_STATE_FILE="${SCRIPT_DIR}/.current_region" TUNNEL_LOG="${SCRIPT_DIR}/.current_tunnel.log" BROKER_REGION="ap-northeast-1" FUNCTION_NAME="vllm-spot-broker" # 1. SSMポートフォワードプロセスの終了 echo "=== 1. SSMポートフォワードプロセスの終了 ===" if [ -f "${TUNNEL_PID_FILE}" ]; then TUNNEL_PID=$(cat "${TUNNEL_PID_FILE}" | tr -d '[:space:]') if [ -n "${TUNNEL_PID}" ] && ps -p "${TUNNEL_PID}" > /dev/null 2>&1; then echo "SSMポートフォワードプロセス (PID: ${TUNNEL_PID}) を終了中..." kill -9 "${TUNNEL_PID}" 2>/dev/null || true fi rm -f "${TUNNEL_PID_FILE}" fi pkill -f "AWS-StartPortForwardingSession.*8000" 2>/dev/null || true # 2. Lambdaブローカーへのインスタンス破棄リクエスト echo "=== 2. サーバーレスGPUブローカー(Lambda)へリソース破棄リクエスト ===" TMP_RES=$(mktemp) aws lambda invoke \ --region "${BROKER_REGION}" \ --function-name "${FUNCTION_NAME}" \ --payload '{"action":"terminate"}' \ --cli-binary-format raw-in-base64-out \ "${TMP_RES}" >/dev/null STATUS=$(jq -r '.status // "error"' "${TMP_RES}" 2>/dev/null || echo "error") if [ "${STATUS}" = "success" ]; then COUNT=$(jq -r '.count // 0' "${TMP_RES}") echo "==========================================================" echo " 🛑 破棄完了! 稼働中のインスタンス ${COUNT} 台を Terminate しました。" jq -r '.terminated_instances[]? | " - [\(.region)] \(.instance_id)"' "${TMP_RES}" 2>/dev/null || true echo "==========================================================" else echo "警告: Lambda経由の破棄でエラーが返されました。ローカル情報による直接破棄を試みます..." AWS_REGION=$(cat "${REGION_STATE_FILE}" 2>/dev/null | tr -d '[:space:]' || echo "ap-northeast-1") INSTANCE_ID=$(cat "${INSTANCE_STATE_FILE}" 2>/dev/null | tr -d '[:space:]' || echo "") if [ -n "${INSTANCE_ID}" ]; then aws ec2 terminate-instances --region "${AWS_REGION}" --instance-ids "${INSTANCE_ID}" >/dev/null 2>&1 || true echo "インスタンス ${INSTANCE_ID} (${AWS_REGION}) を直接Terminateしました。" fi fi rm -f "${TMP_RES}" "${INSTANCE_STATE_FILE}" "${REGION_STATE_FILE}" "${TUNNEL_LOG}" echo "スポットインスタンス、EBSボリューム、SSMトンネルの破棄が完了しました!" まとめ # 今回は、マルチリージョンのGPU調達ロジックを AWS LambdaによるサーバーレスGPUブローカー へと刷新しました。 並列探索による高速化 : Pythonのマルチスレッドにより、全リージョン・AZのスポット価格・中断率取得から判定までをわずか1〜2秒で完結。 クライアントの責務スリム化 : 複雑なマルチリージョン探索や中断率集計、フォールバックの重厚なロジックをクラウド側へ切り離し、ローカルスクリプトは接続とプロセス管理のみに専念。 耐障害性の向上(スマートなフォールバック) : スポット全滅時も勝手に高額課金せず、明確なエラー通知と再試行オプション(コスト最安 vs 低遅延)を提供。 クラウドの余剰リソースを徹底的に使い倒すための、極めて実用的で回復力の高いGPU基盤が完成しました。
動画
該当するコンテンツが見つかりませんでした









