株式会社豆蔵のブログ - TECH PLAY

TECH PLAY

株式会社豆蔵

株式会社豆蔵 の技術ブログ

全142件

 この記事を読んでいただきありがとうございます。アジャイルグループに所属する、藤井智弘です。  第9回(人日換算)を書いていたら、「そもそもストーリーポイントは何を測った数なのか」というところで話が膨らんでしまい、本編に収まらなくなりました。急遽、番外編として切り出します。いつもの質問と回答の形は取らず、一つの疑問を最後まで追いかける、 理屈っぽい 回です。  でも、好きなんです、そういうの、わたし。 引っかかっていた疑問 #  ファンクションポイント(FP)を使っていた現場から来た方に、何度か同じことを聞かれました。「FPは属人性を排した客観的なサイズで、計数規則に従えば誰が数えてもほぼ同じ値になる。ストーリーポイントも相対値で属人性を排すと聞いたのに、同じ項目でメンバーによって3と8に割れる。属人性を排した見積りのはずなのに、見積値が人によって違うのは、何かが間違っているのではないか」。  白状すると、私自身がこの疑問を長いあいだ持っていました。研修ではこう説明されます。「人日は人によって違うが、大きさの比は誰が見ても同じ。だから相対値で見積もれば属人性を排除できる」。それなら、なぜプランニングポーカーで値が割れるのか。割れるのが前提なら、「属人性を排した」とは何を排したのか。FPのように規則で数えれば割れないのに、なぜそうしないのか。  答えを先に言うと、二段あります。一段目は、「属人性」という言葉が二つの違うものを指していて、ストーリーポイントが排しているのはその片方だけだ、ということ。二段目は、そもそもアジャイルの計画づくりは、見積りの時点での精度に賭けていない、ということです。一段目から順に追います。 属人性は一つではない #  一つ目は、 作る人への依存 です。同じ項目が、Aさんなら2日、Bさんなら6日かかる。二つ目は、 見積もる人への依存 です。同じ項目を、Aさんは3、Bさんは8と見立てる。  FPは、この両方を排除しようとした測定法です。外から観察できる機能(入出力、照会、内部ファイル、外部インタフェース)を規則で数えるので、誰が作るかに依らず、訓練された計測者なら誰が数えても近い値に収束します。FPは「測定」であり、測定とは、同じ対象を同じ手続きで測れば同じ値が出ることを目指す営みです。  ストーリーポイントは、一つ目の属人性だけを扱います。しかも排除するのではなく、逃がします。Aさんなら1日と2日、Bさんなら3日と6日。絶対値は3倍違っても、「後者は前者の2倍」という比は同じです。チームの誰が取るか分からない項目を見積もるとき、比だけを持っておけば、誰が取っても使えます。「相対値だから属人性を排せる」の実際の中身はこれで、 期待しているのは比の不変性のことであって、絶対値の客観性ではありません 。  二つ目、見積もる人による違いは、最初から排除していません。ストーリーポイントは測定ではなく判断で、しかも「そのチームの目盛り」に基づきます。目盛りの原器は計数規則ではなく、基準ストーリー——「ログイン画面の修正を3とする」という実在の項目——です。だから評価者によって値が割れるのは欠陥ではなく、相対的見積りの仕様です。 割れるのは、そういう設計だから #  プランニングポーカーが「全員が同時に出して、割れたら話す」という形をしているのは、割れそのものを情報として使うためです。3と8に割れたなら、片方だけが知っている地雷があるか、仕様の読みが食い違っているか、どちらかです。3を出した人は簡単な抜け道を知っていたのかもしれないし、8を出した人は前に同じ場所で痛い目に遭ったのかもしれない。それを出し合ってから一つの値に収束させる。評価者間のばらつきは、排除する対象ではなく、理解の差を見つける検出器です。そして収束させる手続きは、目盛りを校正する作業です(ああ、卒論の実験を思い出す…)。  ここで割れを平均して5にすると、手早いのですが、割れが持っていた情報を捨てることになります。5は誰の見立てでもない数字です。一番詳しい人の値で決めるのも、たいてい正しい値には近づきますが、それは「その人の目盛り」であって「チームの目盛り」ではありません。その人が休んだスプリント、抜けた後に、目盛りは揺れます。規則表を作って「画面1つは3、テーブル1つは2」と数えれば割れはなくなりますが、規則に載っていない難しさ——古いコードの地雷、曖昧な仕様、初めての技術——が数字から消え、 ポイントは客観的になった分だけ、このチームの実感から離れます 。割れを消す方法はいくつもあり、どれも「ポイントは客観的な測定値であるべきだ」という思い込みから出ていて、どれも目盛りの校正を止めてしまいます。  ストーリーポイントが目指しているのは、 客観性ではなく、チームの中での一貫性 です。ただ、ここで「客観的でなくてよい」と言われても、FPを知る人は納得しないでしょう。客観的でない数字の精度を、どう担保するのか。この問いが、二段目に続きます。 正確さは、どこで作るのか #  見積りの産物は、数字ではなく判断です。ポイントを付けて決めているのは、「このスプリントに入れて大丈夫か」「分割するべきか」「先に調べるべきか」の三つで、それ以上のことはあまり決めていません。この判断に要る精度は粗くてよい。3か5かで判断が変わることはめったになく、3か13かなら変わります。だからポイントの目盛りは1、2、3、5、8、13と飛び飛びで、その飛び方が「この程度の違いしか見分けない」という宣言になっています。ポイントは、経験に基づく見立てを、判断の素材として数にしたものです。誤差があっても、判断を変えるほど大きな誤差でなければ、用は足ります。  もう一つの用途、合計をベロシティで割って見通しを出すほうでは、個々の誤差は足し合わせるうちに相殺されます。3と見立てて5だった項目と、5と見立てて3だった項目は、合計では打ち消し合う。だからばらつきは構いません。構わないのは偏りのほうで、全員が一方向に盛る、インフレする、という偏りは相殺されずに合計に残ります。ベロシティを「安定させる」ために数字を盛るのが問題なのは、ばらつきではなく偏りを作るからです。  では、粗い見立てで済ませて、誤差が大きくなりすぎないのはなぜか。ここがアジャイルの計画づくりの要で、誤差は見積りの手法ではなく、 見積りの対象の大きさ と、 答え合わせまでの時間 で決まります。項目が小さければ、8が80になることはありません。誤差は項目の大きさに比例し、不確実性に比例するので、項目を小さくし、リファインメントで不確実性を先に潰しておけば、見立てが粗くても誤差の上限は小さい。スプリントが短ければ、見立てが外れていたことが数週間で分かり、次の見立てに反映されます。一度に多くのことを手掛けないのも同じ理由で、同時に走る項目が少ないほど、一つの外れが他に波及しません。アジャイルは、見積りの精度を上げることではなく、 見積りの外れが大きくなれない構造を作ることで、誤差を制御 しています。  この構造の話を抜きにして、見積りの手法や精度を論じても意味がありません。ここが、FPとストーリーポイントの対比の、一番深いところです。  FPが見積りの時点での精度を必要とするのは、スコープを最初に固定し、あとから誤差を縮める仕組みを持たない前提だからです。見積りが外れたら、それを補う機会が後ろにない。だから前で精度を作るしかなく、誰が数えても同じ値になる規則が要ります。  ストーリーポイントが粗い見立てで済むのは、精度を作る場所が見積りの時点ではなく、その周りの仕組み——小さい項目、短いスプリント、リファインメント、そして実績のベロシティ——にあるからです。   FPとポイントの違いは、精度の高低ではなく、精度をどこで作るかの違い です。冒頭の「なぜ規則で数えないのか」への答えは、「客観性が要らないから」ではなく、「見積りの時点の精度に賭けていないから」です。 属人性はどこへ行ったのか #  一段目に戻って、属人性は消えたのか、という問いに答えておきます。消えていません。置き場所が違うだけです。  FPは、サイズを客観化した代わりに、工数に換える段階で様々な「特性の調整や生産性を反映した係数」——このチーム・この技術なら1FPあたり何時間——を別途必要とします。属人性は、この係数に追い出されています。サイズは客観、係数は属チーム、という分担です。  ストーリーポイントは、見積もるチームと作るチームが同一であることを前提に、チーム固有の条件を見積りそのものに折り込みます。残った校正のずれは、ベロシティに吸収されます。目盛りが多少歪んでいても、ベロシティが同じ歪みで測られるので、予測は当たります。属人性は、ベロシティに追い出されています。サイズも目盛りも属チーム、だからベロシティも属チーム、という分担です。前の節で挙げた「答え合わせまでの時間」を短くする仕組みが、この吸収を毎スプリント回しています。  どちらの方式でも、属人性は排除されたのではなく、置き場所が違うだけ。そう見ると、ストーリーポイントのほうが正直に閉じている、とも言えます。FPは「客観的なサイズ」と「属チームの係数」を別の人が別の場所で扱うので、係数のほうが放置されがちです。ストーリーポイントは、見積もる・作る・測るを一つのチームの中で閉じるので、歪みが自動的に相殺されます。 それで、ポイントは何を測った数なのか #  ここまで来ると、言えます。ストーリーポイントは、作るものの 大きさ ——量と複雑さと不確実性を合わせたもの——についての、そのチームの 経験に基づく見立て を、チームの目盛りで相対値にしたものです。測定値ではなく見立てであり、数字そのものより、それを使って下す判断のほうが産物です。  工数が「人が投入する時間」という投入側の量であるのに対して、大きさは作る対象の側の量です。この二つを結ぶのがベロシティで、「このチームなら、この大きさをこのペースで片づける」という換算係数を、事後の観測として持っています。だから、ポイントは「工数の別名」ではありません。かといって、FPのような「客観的なサイズ」でもありません。チームの中でだけ通用する、大きさの通貨です。  冒頭の疑問に戻ります。ストーリーポイントは、属人性を排した見積りではなく、 属チーム的な見積り です。排しているのは「誰が作るか」の絶対値だけで、それも比に逃がしているにすぎない。評価者間のずれは排除の対象ではなく、合意の材料。そしてこの方式が成り立つ条件は、客観性ではなく、見積もる・作る・測るが同じチームで閉じていることと、外れが大きくなれない構造が周りにあることです。  閉じていない使い方をした瞬間に、目盛りの属チーム性が露出して壊れます。チーム間でポイントを比べる。別のチームが見積もって、別のチームが作る。換算表で外に出す。第9回で見た四つの型は、全部この「閉じていない使い方」でした。あの回で「換算値は項目の原価、ベロシティはチームの流量」と書いた違いの根も、ここにあります。流量はチームの中でしか意味を持たない目盛りで測られているから、外に出した瞬間に原価のふりをし始めるのです。 現場での、ささやかな心得 #  理屈はここまでで、現場でやることは多くありません。  目盛りの原器は基準ストーリーなので、実在の項目を2〜3本「これを3とする」と決め、見積りのたびに参照してください。メンバーが入れ替わったら、新しい人と一緒に基準ストーリーを見直す。目盛りは規則ではなく共有された記憶なので、記憶を引き継ぐ手続きが要ります。  割れたら、平均ではなく話す。両端の人が理由を一言ずつ言うだけで、たいてい地雷か読み違いが出てきます。収束しなければ、項目を分割するか、調査を先に切る。  見積りの精度を上げたくなったら、手法ではなく、項目の大きさとスプリントの長さを疑う。13以上の項目が常態なら、見積りの技法をいくら磨いても誤差は縮みません。先に割ってください。  目盛りをチームの外に持ち出さない。外に出す数字は、第9回で書いたとおり、合計ポイントと実績のベロシティから導いた期間と金額です。  そして、契約上どうしても属人性を排したサイズの根拠が要る場面では、ポイントを客観化しようとするのではなく、FPのような測定法を別途使ってください。ポイントは計画の道具、FPは契約の道具。兼用させると、どちらの役目も果たせなくなります。念のため付け加えると、FPの客観性も理念上のもので、実務では計測者間に1〜3割のばらつきが出るという報告が昔からあります。ただ「規則で数える」と「合意で決める」は理念として別物なので、違う目的の違う道具として、両方を持っておけばよいのです。 おわりに #  私たちの多くは、見積りとは「正しい値を当てる」ものだと教わってきました。FPはその文化の到達点で、誰が数えても同じ値になることを目指して作られています。この文化の中でストーリーポイントに出会うと、「客観的でない見積りは未熟な見積りだ」と感じるのは自然です。私もそうでした。  けれども、アジャイルの計画づくりは、正しい値を当てることではなく、実績から予測を更新し続けることを目指しています。そのために大事なのは、値が客観的であることより、同じ目盛りで測り続けることと、外れが大きくなれない構造を保つことです。精度は、見積りの中ではなく、見積りを取り巻く仕組みの中にあります。チームの中でポイントが割れたら、それは属人性の露出ではなく、理解の差が見つかった瞬間です。そこで話せているか、そして項目は十分に小さいか。この二つが、このチームの見積りが道具として働いているかの、一番簡単な確認になります。
 この記事を読んでいただきありがとうございます。アジャイルグループに所属する、藤井智弘です。  第6〜8回はスクラムのイベントの話でした。今回から、見積りと予測の話に入ります。イベントの回と同じく、「本音の疑問→アンチパターン→勘違いの根っこ→処方」の順で読み解いていきます。 質問 #  ストーリーポイントで見積りをしていますが、上司から「で、それは何人日なの?」と毎回聞かれます。答えに窮して、結局「1ポイント=0.5人日」のような対応表を作ってしまいました。これでは相対見積りの意味がない気がしますし、どうせ換算するなら最初から人日で出せばいいのでは、と思ったりもします。  どう答えるのが正解だったのでしょうか? 回答 #  表現の違いこそあれ、ご質問のテーマ(ポイントと人日)は、ほんとによく訊かれます。3つの観点でこの質問を考えてみましょう。 上司が必要としているのは、本当に「人日」なのか 換算表を作ったのは、そんなに悪いことだったのか 人日で答えないなら、いったい何で答えればいいのか  上から順に答えます。 上司が本当に必要としているのは、たいてい人日ではなく、「いつ終わるの?(あるいは、間に合うの?)」「いくらかかるの?」です。人日はそれらを導き出す手段として訊かれているにすぎません。 換算表を作ったこと自体は責められません(だって訊かれているんですから)。ただし、「1ポイントは〇〇に相当」と固定した瞬間に、いくつかの歯車が——後に述べるように——逆回転を始めます。「最初から人日で」という気持ちはもっともで、見積りの場で全員が人日に直しているなら、そのほうが正直です。ただ、そうなった原因はポイントではなく、換算表のほうにあります。 答えるべきは、ポイントの換算値よりも、 リリース予測とコストの見通し です。実績のベロシティと残りのバックログから「◯月上旬〜◯月下旬に完了見込み」と幅つきで答えられれば、社内の上司のニーズはたいてい満たされます(ただし、発注側の稟議に人日が要る場合は別ですが、それは処方で扱います)。  質問の字面を追わず、その質問の裏にある「相手がほんとに知りたいこと」に答える——これに尽きます。話の軸は二つ、「上司の質問を翻訳すること」と「外側に出す数字と、内側の計画の道具を分けること」です。 「ポイントは人日に換算してはいけません」と教わって、引っかかりませんでしたか? #  研修や現場で、「ストーリーポイントは相対値です」「人日に換算してはいけません」と指導されたことのある方は多いと思います。そのとき、こんな疑問を持ちませんでしたか? 予算も稟議も人日で回っている。換算しなければ、話が通らないではないか 結局、見積りの場で全員が頭の中で人日に直している。だったら最初から人日でよいのでは 相対値のままで、上司は何を判断できるのか  どれも正当な疑問です。先に答えを書くと、 鍵は「1ポイント=◯人日」から導出される数値とベロシティは、似て非なる数字だ という一点にあります。換算値は「この項目は3人日」と、 項目の原価を語る数字 です。ベロシティは「このチームは1スプリントに25ポイント片づく」と、 チームの流量を語る数字 です。計画に要るのは後者で、前者を計画の場に持ち込むと、項目ごとの帳尻合わせが始まります。  予算や稟議が人日で回っているなら、人日につなぐ必要はあります。ただし、つなぐのは項目ではなく合計です。残りの合計ポイントをベロシティで割って期間を出し、期間にチームの月額を掛けて金額を出す。このやり方なら、個々の項目に人日を貼らずに済みます。  上司が判断したいのは、相対値そのものではなく「いつ終わるか」「いくらかかるか」です。上司に渡すべきは、 ポイントという材料ではなく、そこから導いた見通しのほう です。  とはいえ、これだけでは言葉足らずでしょう。先に換算要求がどんな姿になりがちかを並べ、そのうえで一つずつ答えます。 人日換算のアンチパターン #  換算を巡っては、いくつかのアンチパターンがあります。心当たりのあるものがあれば、そこから読んでください。 その1:対応表型 ——最も多い型です。「1ポイント=0.5人日」のような固定の対応表を作り、ポイントを掛け算して人日に直して報告する。冒頭の質問が、まさにこれです。作った直後は平和ですが、見積りの場で全員が人日に逆算を始め、「これはあれの2倍くらい」という比較の速さが失われていきます。 その2:成績表型 ——ポイントが「あの人は今月何ポイント消化した」という個人の生産性指標や、「Aチームは毎スプリント30ポイント、Bチームは15ポイント。Bは生産性が半分か」というチーム間の比較に転用された型です。見積りは自己防衛の道具になり、大きめに見積もる誘惑、簡単な項目を選ぶ誘惑が生まれ、数字は膨らみ、予測の道具としては死にます。測定基準が目標になると、それは良い測定基準ではなくなる——経済学者チャールズ・グッドハートの名にちなむ「グッドハートの法則」として知られる現象です。そもそもポイントはチーム内の相対値であり、チームAの5とチームBの5には何の互換性もありません。換算表は、その事実を覆い隠します。 その3:値切り型 ——発注側や管理部門が、人日に直した数字を見て「この画面が3人日? 前は2人日でできたでしょう」と個々の項目を値切りにかかる型です。ポイントの内訳が人日の内訳に化けた瞬間、見積りは予測ではなく交渉の材料になります。第3回で見た、見積の内訳が保証の文言に流れ込む構図と同じです。 その4:沈黙型 ——「換算しない」という教えを守り、上司の質問に「ポイントは相対値なので人日には直せません」とだけ答えて、会話が終わる型です。正しいことを言っていますが、上司が本当に知りたかった「いつ・いくら」には何も答えていません。教えを守ったつもりで、翻訳を放棄しています。 四つの型に共通する前提 #  四つの型に共通する前提があります。 ポイントは工数の別名だ という前提です。この前提に立つと、換算表を作るのは当然であり、個人やチームの比較に使えるはずであり、内訳を値切れるはずであり、逆に「換算できないなら何の役にも立たない」という結論まで、すべて自然に導かれます。どの型も、この前提を疑わなかった先に現れます。  ここまでを踏まえて、先ほどの疑問に一つずつ答えます。  まず、 換算しなければ話が通らないのではないか 。その通りです。組織の予算策定や稟議、外部への報告は人日・人月で回っており、その言語に接続しないと話は通りません。これは現実で、否定しても始まりません。しかも、ポイントから人日への換算は数学的に可能です。スプリントで使った人日を、そのスプリントで完成したポイントで割れば、「1ポイント=◯人日」は単純な算数で出ます。多くの現場の換算表は、実際にこうして実績から作られています。出どころは正しい。  それでも、換算値とベロシティは同じものではありません。違いは、数字が何を主張しているかにあります。ベロシティは「このチームは1スプリントに◯ポイント片づく」という、チームというシステムの 流量 です。会議も割り込みも待ち時間も含んだ、 事後の観測値であり、その期間内に開発した個々の項目については何も主張しません 。合計についてだけ語ります。換算値は そのベロシティを一つの定数として固定し、チームの人数とスプリント長を加味して、「この項目は3人日」と項目に関連付けた属性値に変えたもの です。つまり、項目の 原価 を語る数字に変わっています。同じ実績から出ていても、片方はチームの流量、片方は項目の原価。ここが分かれ目です。  項目の原価になった瞬間、項目ごとに値切る、3人日の項目に5日かかった人を責める、項目単位で再見積りを求める、が「正当な使い方」になります。見積もる側はそれを見越してポイントを膨らませ、ポイントは成績指標に変わる。先ほどの四つの型は、どれもここから生まれています。 流量のままなら、これらは最初から成り立ちません 。  では、これから作るものの見通しは、どうやって人日・人月の言語に接続するのか。項目に掛けるのではなく、合計で割ります。残りのバックログの合計ポイントを実績のベロシティで割れば、残りのスプリント数が出ます。それが「いつ終わるか」です。そのスプリント数にチームの月額を掛ければ、「いくらかかるか」が出ます。ベロシティは動く観測値なので、幅(20〜30)のまま扱え、予測も幅になり、 ずれは「予測の更新」として次の計画に戻ってきます 。もし換算値を見積りの段階で項目に掛けると、計画はその時点で確定し、 同じずれが「遅れ」として現れます 。  人日・人月の言語と合計値とを接続する形なら、チーム内の見積りはポイントを使うというスタイルを守れたまま、チームの外の人々が期待する見通しを実績値に基づいて提示することができます。実績そのものを問われたときは、もっと単純で、「このチームは、この3ヶ月で人件費◯円を使い、◯◯を届けた」と、項目ごとの換算を経ずに言えます。  もっとも、正直なところ、換算値を毎スプリント毎に見直しをかけ、項目単位に見るのをやめ合計にしか使わないなら、項目に掛けても合計で割っても出てくる数字は同じです。違いは計算結果ではなく、数字の意味と、それが招く運用にあります。この話は、ベロシティを扱う回の末尾で、もう少し丁寧に扱います。  次に、 頭の中で人日に直しているなら、最初から人日でよいのではないか 。その状態なら、確かにそうです。相対見積りの利点は、「これはあれの2倍くらい」という比較が、 絶対値の見積りより速く、チームの合意として出せる ことにあります。見積りの場で全員が人日に逆算しているなら、その利点はもう失われていて、時間で見積もるほうが正直です。ただしそれは、ポイントが無意味だからではなく、対応表がポイントを人日の別名に変えてしまったからです。対応表をやめれば、比較の速さは戻ります。  そして、 相対値のままで、上司は何を判断できるのか 。相対値そのものは判断に使えません。上司が判断に使うのは、相対値とベロシティの実績から導いた「いつ終わるか」「いくらかかるか」です。ポイントは上司に見せる数字ではなく、 その見通しを作るための、チーム内部の材料 です。上司に渡すべきは材料ではなく、見通しのほうです。   「工数の別名」という前提はどこから来るのか 。私たちの多くは、見積りとは工数を積み上げるものだと教わってきました。機能ごとに人日を見積もり、合計して期間と金額を出す。この文化の中でストーリーポイントに出会うと、それを「人日の新しい単位」として理解するのは自然な反応です。怠慢の話ではありません。ただ、単位の置き換えではなく、道具の置き換えだということが、研修ではなかなか伝わりません。  翻って、ストーリーポイントとは何か。道具である前に、何を測った数かを言っておきます。ポイントは、作るものの 大きさ ——量と複雑さと不確実性を合わせたもの——を、そのチームの目盛りで表した相対値です。工数が「人が投入する時間」という投入側の量であるのに対し、大きさは作る対象の側の量です。ただし、大きさそのものに絶対値はないので、「これはあれの2倍くらい」という比で表します。比が使えるのは、同じ二つの項目にかかる工数は作る人によって変わっても(Aさんなら1日と2日、Bさんなら3日と6日)、2倍という比は変わりにくいからです。だから比だけを持つ。そして目盛りはチーム固有なので、チームの外に持ち出すと意味を失います。  そのうえで、本来のストーリーポイントは何のための道具か。アジャイルの計画づくりは、経験主義の上に立っています。見積りを事前に確定させるのではなく、実績を透明にし、予測と実績のずれを検査し、計画を適応させる。ストーリーポイントは、この「実績から予測する」ための道具です。   見積り ——ポイントは、項目同士の大きさの比較を、速くチームの合意として出すためのもの。絶対値ではないので、個人の作業のスピードや日々の割り込みに左右されにくく、合意が壊れにくい。   実績 ——スプリントごとに完成したポイントの合計(ベロシティ)が、チームの実績。これが、ポイントを期間に換える唯一の根拠です。   予測 ——残りのポイントを実績のベロシティで割れば、残りのスプリント数が出る。実績にはばらつきがあるので、予測は幅になります。幅は、実績が貯まるほど頼れるようになり、ばらつきの原因を減らすほど狭まります(ばらつきの扱いは、ベロシティを扱う回で)。  ここまで来れば、対応表型の問題点は明らかです。「1ポイント=0.5人日」が問題なのは、根拠がないからではなく、チームの流量だった実績を項目の原価に変え、しかも根拠だった実績が変わっても表が変わらないからです。変えるべきは、「項目ごとにポイントを人日に直す」から「合計ポイントと実績のベロシティで見通しを出し、チームの外の言語にはその見通しで接続する」へ、です。この置き換えが、本稿の処方のすべての前提です。 処方 #  ここからは具体策ですが、どれも道具にすぎません。見積りと予測の技法は有識者がさまざまに提案してくれていて、どれを選んでもかまいません。もっとも、「換算表を捨てた」と宣言しただけなら、沈黙型と変わりません。試したあとは、上司との会話が「何人日?」から「いつ? いくら?」に変わったかで、効いたかどうかを見てください。 まず、直近数スプリントのベロシティと、残りのポイントを並べる  直す前に、いまの状態を一枚にします。直近5〜8スプリントのベロシティ(最悪値と最良値)と、残りのバックログの合計ポイントを一枚に並べてください。実績が3スプリント分もあれば、粗くても幅は出せます(頼れる幅になるには5スプリントほど要りますが、その話は次回に)。この一枚があるだけで、「何人日?」への答えは「換算できません」ではなく「今の実績だと、◯〜◯スプリントです」に変わります。以降の処方は、この一枚から始まります。 「いつ終わるか」にはリリース予測で答える  直近数スプリントのベロシティの幅と、残りのバックログの合計ポイントから、完了時期を幅で示します。「残220ポイント、ベロシティは20〜30なので、8〜11スプリント、つまり◯月〜◯月です」。単一の日付ではなく幅で答えるのがポイントです。上司が金額で聞くなら、幅をそのまま金額に変えます。「最良と最悪の差は3スプリント、つまりチーム月額の1.5ヶ月分が今の見通しの振れ幅です」。この幅を最良値・最悪値の2本の線でバーンダウンチャート上に表現する「レンジドバーンダウン」(PMI Disciplined Agileで紹介されている、幅つきのバーンダウン表現)というプラクティスもあります。幅の広さ自体が不確実性の正直な表現であり、スプリントを重ねるほど幅は狭まっていきます。この「予測が徐々に精緻になる」体験を上司と共有できると、対話の質が変わります。  そしてこの転換は、答える側だけでは完結しません。開発者は幅と根拠を毎回同じ形式で示し、尋ねる上司の側は「何人日?」の代わりに「いつ? いくら?」で問い直してみる。双方がこの形に慣れたとき、換算表は要らなくなります。 「いくらかかるか」にはチームの運営費で答える  アジャイルなチームのコストは、タスクの積み上げではなく「このチームを何ヶ月走らせるか」で決まります。更改案件のように成果物のまとまりで予算を組む場面の「器」は、第4・5回の方式(ユースケースの本数×規模に深さの初期設定を重ねて量を出し、実測で期間と金額に換える)で作れます。一方、継続的に走るチームの費用の考え方は単純です。チームの月額(人件費+経費)×期間。この計算に人日換算は不要です。むしろ「固定されたチームが期間内に価値を最大化する」というアジャイルの資金モデルを説明する好機です。 それでも換算が要るなら、用途を限定する  稟議や契約でどうしても人日が必要な場面はあります。その場合は、過去の実績(このチームは四半期で人件費◯円を使い、◯◯を届けた)から算出した概算値を、チームの外に出す用途に限って用意し、チーム内の見積り・計画には持ち込まない、と線を引いてください。実績から出した換算値そのものは、この用途に限り、実績が更新されるたびに引き直すなら、問題ありません。「外向けの通貨」と「内側の計画の道具」の分離です。第3回で「予算の器」と「保証の範囲」を切り離す話をしましたが、これも同じ構図です。原則はひとつです。チームの外に出す換算値は実績から、チームの中の計画は予測で。どちらも同じ記録から導き、算出根拠を文書に残す。こうしておけば、外に出す数字と中で使う数字の出どころが違うのではないか、という疑いは生じません。この線引きを上司と合意できるかが勝負どころで、換算表の副作用(インフレ、ノルマ化、チーム間比較)を先に説明しておくと通りやすくなります。 発展: ポイントをやめる道もある  PBIを十分小さく均一に分割できるようになると、ポイントを振らずに「個数」で予測する方法(スループットベースの予測)が成立します。見積りの議論時間はほぼゼロになり、「何人日?」への答えは「毎スプリント平均◯個完成しています」に変わります。成熟したチームの選択肢として、頭の片隅に置いてください。 なぜこの質問は30年繰り返されるのか #  ストーリーポイントは「チーム内部の計画の道具」として生まれましたが、組織の会計・稟議・契約は今も人日/人月の言語で動いています。この2つの言語の境界に立って通訳をする人——本来はPOやマネージャーの仕事です——が明示的に任命されないため、境界の摩擦は毎回、見積もった開発者本人に降ってきます。そして開発者は善意で換算表を作り、数年後に同じ副作用に悩む。この繰り返しです。  背景には、続くコスト削減のプレッシャーもあります。経営層にとってアジャイルが「早くて安い」という文脈で期待されているほど、「で、いくら安くなったのか」を人日で確かめたくなる。換算要求は、その期待の裏返しでもあります。だからこそ、幅つきの完了予測とチーム運営費という「別の答え方」で、期待の中身そのものを組み替えていく価値があるのです。  換算表を求められたら、それは翻訳者が不在だというサインです。表を作る前に、翻訳の仕事を誰が引き受けるかを話し合ってください。確かめるべきは、上司との会話が「何人日?」から「いつ? いくら?」に変わったかどうか、ということです。それがこのチームの見積りが予測の道具として働いているかの計器です。 次回: 「ベロシティが安定しません。計画に使っていいの?」
 この記事を読んでいただきありがとうございます。アジャイルグループに所属する、藤井智弘です。  第9回では、ポイントを人日に換算する代わりに、ベロシティの実績から幅つきの見通しを出す話をしました。換算表の問題は、動いている実績を一つの数字に固定することだ、とも書きました。番外編で見積りの理屈に寄り道しましたが、今回は第9回の続きで、その足元にあるベロシティそのものの話です。そして、同じ「固定」の問題が、別の顔で現れます。 質問 #  スプリントごとのベロシティが20→35→18とばらついています。平均を取って次のスプリント計画に使っていますが、外れることも多く、チーム内から「安定するまでベロシティは当てにできないのでは」という声が出ています。安定を待つべきでしょうか? 回答 #  この悩みを口にする方は、たいてい次の三つを同時に抱えています。 ばらつくのは、うちのチームがまだ未熟だからか 何スプリント待てば、安定するのか ばらついたままの数字を、計画に使ってよいのか  順番に見ていきましょう。ばらつくのは未熟だからではありません。開始数ヶ月のチームのベロシティは、ばらつくのが正常です。見積りの基準も、チームの連携も、PBIの粒度も、まだ発展の途中なのですから。何スプリント待っても、「揺れない1つの数字」は来ません。そして、ばらついたままで計画に使えます。問題はばらつきではなく、 ばらつくものを単一の平均値で扱おうとしていること です。平均ではなく幅で使ってください。「うちのベロシティは18〜35」という言い方に変えるだけで、ベロシティは計画の道具になります。ただし幅が頼れるようになるのは実績が5スプリントほど貯まってからで、それまでは記録に専念する期間です(処方で詳しく述べます)。  要点を二つに絞るなら、「ばらつきを情報として読むこと」と「安定させることを目標にしないこと」。 「ベロシティは安定してくるものです」という教え、信じられましたか? #  研修や書籍で、「ベロシティは数スプリントで安定してきます」「安定したベロシティが計画の基礎になります」と教わった方は多いと思います。そのとき、こんな疑問を持ちませんでしたか? うちは半年経っても安定しない。何かが間違っているのか 休暇も障害も割り込みも毎回違うのに、安定するほうがおかしいのでは 安定しないものを、上司にどう説明すればいいのか  私も、研修を受けた側だったころは同じ疑問を持っていました。答えを先に出すと、 「安定する」の意味の取り違え です。安定するのは1つの数字ではなく、幅のほうです。ばらつく理由が毎回違うのはその通りで、だからこそ幅で持つ。そして上司には、幅のまま説明できる。ただ、ここで終わると標語のままです。ベロシティがどんな扱われ方をしがちかを先に見て、それから腰を据えて考えましょう。 ベロシティのアンチパターン #  ばらつきに悩むチームには、いくつかのアンチパターンがあります。自分のチームの数字の扱いと、見比べてみてください。 その1:様子見型 ——「安定するまでベロシティは使わない」と決め、計画は勘と気合で立てている型です。記録はしているが、見てはいない。安定は待っても来ないので、この様子見は永遠に続きます。 その2:帳尻合わせ型 ——ベロシティを「安定させる」ことが目標になった型です。入口はたいてい、マネジメント層が「先月35だったのに、今月はなぜ18なのか」と理由の報告を求め始めることで、ベロシティが評価やチーム間比較に使われ始めた組織では、ほぼ確実に起きます。以降、チームは数字を下げないことに注意を払うようになり、見積りを大きめに盛る、終わりそうな項目だけ選ぶ、持ち越しをこっそり完了扱いにする。数字は美しく安定し、実態は何も良くなりません。第9回で見たグッドハートの法則が、ここでも待ち構えています。番外編で、見積りのばらつきは構わないが偏りは合計に残る、と書きました。帳尻合わせが作るのは、まさにその偏りです。 その3:原因不明型 ——数字は記録しているが、そのスプリントに何があったかを残していない型です。半年分の数字を眺めても、「35のとき何が良かったのか」「18のとき何が起きたのか」が分からない。ばらつきは、原因が分からない限りただの雑音です。 ばらつきを巡る思い込み #  顔つきは違っても、三つの型も、質問者の「平均で積む」も、同じ思い込みから出ています。 ベロシティは精密な測定値だ という前提です。この前提に立つと、ばらつきは測定誤差であり、平均を取れば真の値に近づき、安定していない数字は使えず、下がった数字には説明が要る、という判断がすべて自然に導かれます。どれも、この前提の素直な実践です。第9回の換算表が動く実績を一つの係数に固定したように、平均はばらつきを一つの数字に固定します。顔は違っても、起きていることは同じです。  それでは、三つの疑問を順に片づけます。  まず、 半年経っても安定しないのは、何かが間違っているのか 。間違っていません。ベロシティは物理定数ではなく、チームという生き物の観測記録です。メンバーの休暇、割り込みの多寡、PBIの粒度の不揃い、見積り基準の揺れ…これらすべてが数字に映り込みます。半年経っても揺れるのは、半年経ってもチームが生きている、というだけのことです。安定してくるのは、揺れの「幅」のほうです。18〜35という幅は、実績が貯まるほど信頼できるようになり、原因を潰すほど狭まっていきます。  次に、 理由が毎回違うのに、安定するほうがおかしいのではないか 。その通りです。だから、ばらつきを消そうとするのではなく、ばらつきを含んだまま予測に使う方法を採ります。それが幅です。幅で語ることは、precision(精密さ)を落としてaccuracy(正確さ)を上げる交換です。狭い幅で外すより、広い幅で当てる。計画に必要なのは後者です。そしてもうひとつ、ばらつきの原因を観察し、対処できるものだけ対処します。休暇や突発障害は幅として受け入れ、割り込みの受付方法やPBIの粒度は改善する。この仕分けができると、「ばらつくから使えない」という議論は「この原因は潰せるか」という議論に変わります。  そして、 安定しないものを上司にどう説明するか 。幅のまま説明します。第9回で見た幅つきのリリース予測は、まさにこの最悪値と最良値から出ています。「うちのベロシティは18〜35です。だから残りは6〜11スプリントです」。対外的に単一の日付を求められる場合は、悲観値側の日付でコミットし、幅は内部の計画と進捗管理に使う。第5回で、更改案件の実測スプリントで測ったベロシティを稟議に出すときは期間・金額の上限側の値にする、と書いたのも、この幅の使い方です。コミットした日付は、幅が狭まるにつれて前倒しで見直せます。  残る問いは、 なぜ「精密な測定値」と思い込んでしまうのか です。私たちの多くは、計画とは単一の数字で立てるものだと教わってきました。納期は1つの日付、工数は1つの数字。この文化の中でベロシティに出会うと、それを「揺れの少ない1つの数字」に仕立てたくなるのは自然な反応です。誰のせいでもありません。ただ、ベロシティが何のための数字かが、単位の説明ほどには伝わっていないだけです。  ここで、本来のベロシティは何のための数字か。第9回で、ストーリーポイントは「実績から予測する」ための道具だと書きました。番外編では、見積りの誤差は手法ではなく、項目の大きさと答え合わせまでの時間で決まる、と書きました。ベロシティは、その「実績」の側であり、スプリントごとの答え合わせの記録そのものです。   観測 ——スプリントごとに完成したポイントを、そのまま記録する。盛らず、削らず、持ち越しは計上しない。この正直さが、以降のすべての土台です。   検査 ——記録を並べ、幅と、ばらつきの原因を見る。予測と実績のずれは、失敗ではなく、次の計画を直すための情報です。   適応 ——幅で次の計画を立て、潰せる原因は改善し、潰せない原因は幅として引き受ける。スプリントを重ねるほど、幅は狭まり、計画は当たるようになります。  この三段に当てはめると、質問者の「平均で積む」がどこで止まっているかが分かります。観測はしているが、ばらつきを検査せず、平均で潰して計画に使っている。帳尻合わせ型に至っては、観測そのものを歪めている。「揺れの少ない1つの数字を作る」ではなく「幅で持ち、原因を読み、幅で計画する」。言い換えるとそれだけのことです。この転換なしに処方だけ試しても、長続きしません。 処方 #  以下に挙げるのは道具であって、目的ではありません。予測の技法は第9回の発展で触れたものも含めて選択肢が多く、どれを選んでもかまいません。ただし、形だけ「幅にしました」で済ませるなら、平均の数字が2つに増えただけです。試したあとは、計画が当たるようになったか、ばらつきの原因が減ったかで確かめてください。 まず、数字の横に一行メモを残す  対処の前に、記録から始めます。スプリントごとに、ベロシティの数字と一緒に一行メモを残してください。「35: 大型連休なし、割り込み少」「18: 本番障害対応に3人日、◯◯さん休暇」。数スプリント分たまると、ばらつきの原因が分類できます。対処できるもの(割り込みの受付方法、PBIの粒度の不揃い)は改善し、対処できないもの(休暇、突発障害)は幅として受け入れる。原因不明型を抜け出す唯一の方法で、以降の処方はこの記録があって初めて回ります。 平均ではなく幅で使う  幅の作り方は第9回で見たとおり、直近5〜8スプリントの実績から最悪値と最良値を取るだけです。本稿で足すのは、その幅をスプリント計画にどう使うかです。言ってしまえば「昨日の天気」の幅版で、できた分しか積まないという保守性を悲観値で引き継ぎ、余力の使い道を楽観値で用意します。次スプリントの計画では「悲観18、楽観35」を前提に、確実に入れる分を悲観値で積み、残りは「できたら着手」の候補として並べておく。悲観値で積んだ分がスプリントゴールの土台になり、候補は余力が出たときに拾います。  なお、導入直後で実績が2〜3スプリント分しかない時期は、幅もまだ当てになりません。その間は予測の精度を求めず、「実績を貯める期間」と割り切って記録だけ続けてください。幅が意味を持ち始めるのは、おおむね5スプリント目あたりからです。様子見型との違いは、待つ間も記録し、見ていることです。 幅の推移で、最後を見通す  幅の使い道は二つあります。一つは、次のスプリントを外さないためで、幅の下端(悲観値)を使って積む。前の処方がこれで、「昨日の天気」の幅版、目的は次の一回の達成可能性を上げることです。もう一つは、最後を見通すためで、幅の推移を使う。残りのポイントを幅で割れば完了時期も幅になり、それがスプリントを重ねるほど狭まっていくなら、見通しは確かになっている。狭まらないなら、潰せていない原因が残っている。昨日の天気は前者には十分ですが、後者——最終的にどのくらい確からしいか——を見せてはくれません。それを見せるのが、第9回で触れた幅つきのバーンダウンです。幅の下端で次を守り、幅の推移で最後を見る。同じ幅の、別の読み方です。 ベロシティの用途を組織と合意する  ベロシティはチームが自分の計画精度を上げるための内部指標です。評価や比較や目標値に転用されると数字が防衛の道具になる理由は、第9回でポイントについて述べたとおりで、ベロシティも同じです。第9回で換算値に引いた「外向けの通貨」と「内側の道具」の線引きを、ベロシティの数字そのものにも引いておく、ということです。この線引きは、マネジメント層と早めに、明示的に合意しておいてください。「ベロシティが下がった理由を報告せよ」が始まる前に、です。ここもチームだけでは完結しません。チームは幅と原因のメモを毎回同じ形式で開示し、マネジメント側は数字の上下ではなく「幅は狭まっているか」「潰せる原因は減っているか」で見る。帳尻合わせ型への入口を塞ぐのは、この往復です。 なぜこの質問は30年繰り返されるのか #  先に見たとおり、計画を単一の数字で立てる文化は、研修一回で上書きされるものではありません。しかも、新しいメンバーが入るたびにチームに持ち込まれます。だからベロシティのばらつきに苛立つ声は、チームの世代交代のたびに必ず再現されます。  しかしアジャイルの計画づくりは、不確実性を消すことではなく、不確実性と一緒に前に進むことを目指しています。ベロシティのばらつきに苛立ちを感じたときは、数字ではなく、数字への期待のほうを調整するタイミングです。幅は狭まっているか、ばらつきの原因は減っているか。この問いを定期的に自分たちに向けることが、このチームのベロシティが計画の道具として働いているかの確認になります。 おまけ: 項目ごとに換算するのと、合計をベロシティで割るのは、何が違うのか #  第9回で、「1ポイント=◯人日」の換算値は項目の原価を語る数字、ベロシティはチームの流量を語る数字で、似て非なるものだと書きました。すると、こういう問いが返ってきます。「項目ごとにポイントを付けて合計をベロシティで割るのと、項目ごとに換算値を掛けて足すのは、結局同じではないか」。いい問いなので、第9回では短く済ませた答えを、ここで広げておきます。  まず算術から。換算値を「スプリントの人日÷ベロシティ」で作るなら、各項目のポイント×換算値の合計は、換算値×合計ポイント、つまり合計ポイント÷ベロシティ×スプリントの人日に一致します。換算値を毎スプリント引き直し、合計にしか使わないなら、それは「合計をベロシティで割る」の別表記にすぎません。この場合、両者に違いはありません。違いが生まれるのは、現場でやっていることがこの理想形から外れる点で、四つあります。   何を固定するか ——合計をベロシティで割る方式は、ベロシティを毎スプリント観測し直す動く値として、しかも幅(18〜35)を持ったまま扱えます。だから予測も幅になり、本稿で見たとおり、実績が貯まるほど頼れるようになり、原因を潰すほど狭まります。換算値は、実績から作られた瞬間に一つの定数になり、表として配られた時点で更新が止まり、幅も消えます。理屈の上では「換算値も幅つきで毎回更新すればよい」のですが、そうなっている換算表を私は見たことがありません。表にする、という行為そのものが固定を招きます。   数字がどこに付くか ——合計で割る方式では、人日や金額が付くのは合計だけで、個々の項目には付きません。換算値を項目ごとに掛けると、「この項目は3人日」という数字が項目単位で生まれます。ここから副作用が連鎖します。項目ごとに値切れる、3人日の項目に5日かかった人を責められる、チーム間で単価を比べられる、そして見積もる側はそれを見越してポイントを膨らませる。項目に金額が付いた瞬間、ポイントは成績指標に変わり、グッドハートの法則が働き始めます。合計にしか数字が付かなければ、個々の見積りに圧力がかからず、相対比較の速さと合意しやすさが保たれます。第9回の四つの型(対応表型・成績表型・値切り型・沈黙型)は、沈黙型を除く三つがここから出てきます。   検査と適応の輪が回るか ——ベロシティは事後に測られて次の予測に戻ってくる値なので、合計で割る方式は毎スプリント自己修正します。ずれは「予測の更新」として現れます。換算値を見積りの段階で掛けると、計画はその時点で確定し、現実とのずれは「遅れ」として現れます。同じずれでも、片方は学習になり、片方は責任問題になります。平均で積んで外れるたびに「今回は特別だった」と言うのは、この輪が回っていない姿です。   会計の単位 ——合計÷ベロシティ×チームの月額は「このチームを何ヶ月走らせるか」の計算で、固定したチームに期間で資金を付けるモデルに素直に乗ります。項目ごとに人日を付けるのは「成果物ごとの原価」の計算で、項目単位の見積り精度と、変更のたびの再見積り・再承認を要求するモデルに引きずられます。第9回の処方で「いくらかかるか」をチームの運営費で答えたのは、この違いのためです。  ついでに一つ、よくある実務上の落とし穴を。「1ポイント=0.5人日」の0.5が、ベロシティからではなく「理想日」から置かれている現場が少なくありません。その場合、会議や割り込みを含むチームの実際のスループットが式に入っておらず、系統的に過小見積りになります。ベロシティで割る方式は、その種のオーバーヘッドを測定値として自動的に含みます。本稿の一行メモに「割り込み多」と書かれたスプリントも、幅の中に、黙って入っています。  まとめます。計算結果は、換算値を正しく作って正しく使えば同じです。違うのは、ベロシティを動く観測値のまま扱うか定数に固定するか、人日が合計にだけ付くか項目ごとに付くか、そしてその結果として検査と適応の輪が回るか止まるか。一言でいえば、チームの流量を項目の原価に変えないこと。第9回と今回を通して言いたかったのは、結局この一つのことです。 次回: 「スプリント内に終わらないタスクが毎回出ます」
はじめに # これは、「面白そう」と感じたアーキテクチャや技術スタックを、まずはあれこれ試しながら作ってみたプロトタイピングの記録です。 前回は、AWS LambdaとSSMセッションマネージャーによるサーバーレスGPU起動ブローカーを構築しました。 (詳細は 前回の記事 を参照してください) これにより、ポート開放ゼロかつSSH秘密鍵不要で、世界中の最安スポットGPUをわずか数秒で探索・選定し、安全に直結できる環境が完成しました。 しかし、前回の構成を実践していく中で、もう1つ避けて通れない重要な課題が浮き彫りになります。 「マルチリージョン展開に必要なS3・ECR・セキュリティグループ・IAMロール・起動構成・Lambda関数を、手動や場当たり的なシェルスクリプトで管理し続けるのは辛い」 今回の構成では、東京(ap-northeast-1)に加えて海外4リージョン(バージニア、オハイオ、オレゴン、フランクフルト)の計5リージョンを調達先として扱います。これらのリージョンに手動で設定を入れると、設定漏れやタイプミスの温床になります。使わなくなった際の環境破棄や、別アカウントへの再現も極めて困難になります。 複数リージョンに分散配置するGPU実行基盤を、不要になったら丸ごと消せるエフェメラルなIaCとしてTerraformで組めば、管理や片付けが大幅に楽になります。 そこで今回は、 静的インフラ基盤のすべてをTerraformで一括コード化(IaC化) することにしました。 --> Information 📚 関連する過去の検証記事 第1回 : AWS×UserDataでvLLMを自動起動!停止時コストほぼゼロのローカルLLM環境 第2回 : 自前vLLMをOpen WebUI経由でVS Code(Cline)に接続!トークンフリーで開発する 第3回 : EC2スポットインスタンス×vLLMでGPUコストを大幅削減する 第4回 : EC2スポット×vLLMをSSMでセキュア化!ポート開放ゼロで繋ぐ 第5回 : vLLM起動をAWS Lambda化!最安スポット探索と自動フォールバック 1. Terraform化における「役割分担(スコープ設計)」 # 📌 このセクションの要点 「常に存在する静的基盤」と「作業時だけ使い捨てるGPU」を明確に分離します。動的なGPU起動・停止までTerraformで管理しようとせず、ブローカー基盤までをTerraformで作り、GPUのオンデマンド調達はLambdaに任せる設計がベストプラクティスです。 本検証では、「常に存在するインフラ」と「作業時だけ使い捨てるGPU」を明確に分離しています。そのため、Terraformの管理範囲を以下のように切り分けるのがベストプラクティスです。 カテゴリ リソース 管理ツール ライフサイクル・費用感 静的基盤 S3, ECR(+レプリケーション), IAM, 各リージョンSG, Launch Template, Lambda Terraform 常時存在(使わない時の待機費用はS3・ECRの数GB保管料数十円のみ) 動的エフェメラル 最適リージョンのリアルタイム探索, EC2スポット起動/破棄, SSMトンネル Lambda + CLI オンデマンド実行(作業中のみ $0.4〜0.6/h 課金) 「EC2スポットの起動・破棄」までをTerraformで管理しようとすると、動的なリージョン選定が難しくなります。また、状態ファイル( tfstate )のロックや同期で毎回1分以上のオーバーヘッドが発生します。 そのため、 「ブローカー基盤までをTerraformで作り、GPUの生成・破棄はブローカー(Lambda)に任せる」 という設計が最も軽快で堅牢です。 2. コンテナイメージのマルチリージョン配置(ECRクロスリージョンレプリケーション) # 第1回〜第5回で採用している通り、vLLMのコンテナイメージ(約13GB)は Amazon ECR に永続化し、EC2の起動時に同一リージョン内から高速・転送料無料でPullします。 調達候補となる各リージョン(バージニア、オレゴン、オハイオ、フランクフルトなど)においても、同様に 同一リージョン内のECRからイメージを取得できる状態 を整えておく必要があります。仮に東京のECRから他リージョンのEC2へ都度Pullしてしまうと、リージョン間データ転送料金が毎回発生するうえ、コンテナ起動時間も大幅に増大してしまうためです。 とはいえ、イメージを更新するたびに各リージョンのECRエンドポイントへ手動で docker push を繰り返すのは運用負荷が高く、非効率です。 そこで、Amazon ECRがネイティブで備えている クロスリージョンレプリケーション(Cross-Region Replication) を採用します。Terraformでこの設定( aws_ecr_replication_configuration )を宣言しておきます。これにより、東京のECRに1回Pushするだけで、AWSバックボーン経由で全候補リージョンへ自動複製されます。 以下は、東京リージョンでのECRリポジトリ作成、旧世代イメージの自動削除ポリシー、および他4リージョンへの自動レプリケーションを定義したTerraformコードです。 ecr.tf(クリックで展開) # terraform/ecr.tf # 東京(プライマリ)リージョンの ECR リポジトリ resource "aws_ecr_repository" "vllm" { name = var.ecr_repository_name image_tag_mutability = "MUTABLE" tags = { Project = "vllm-ephemeral" } } # 古いタグの自動削除ポリシー(無駄なストレージ課金を防止) resource "aws_ecr_lifecycle_policy" "vllm_lifecycle" { repository = aws_ecr_repository.vllm.name policy = jsonencode({ rules = [{ rulePriority = 1 description = "最新イメージのみ保持し、旧世代は30日で失効" selection = { tagStatus = "any", countType = "sinceImagePushed", countUnit = "days", countNumber = 30 } action = { type = "expire" } }] }) } # ★マルチリージョン・クロスリージョン自動レプリケーション resource "aws_ecr_replication_configuration" "vllm_replication" { replication_configuration { rule { dynamic "destination" { for_each = [for r in var.target_regions : r if r != var.primary_region] content { region = destination.value registry_id = data.aws_caller_identity.current.account_id } } } } } --> Information 💡 S3のモデル重み同期について(マルチリージョン配置の検討) コンテナイメージと同様に、S3に格納するモデル重み(約15GB)についても候補リージョンへの分散配置が考えられます。 S3クロスリージョンレプリケーション(CRR)を利用する場合 : 全リージョンに重みを事前同期しておくことで、起動時のダウンロード時間を最短化し、同一リージョン内通信(無料)に抑制可能。一方で、複数リージョンにモデルを保持し続けるとS3保管料がリージョン数分発生するため、旧バケットの定期削除やGlacierへの退避など、ストレージ管理の併用が有効。 東京S3から都度クロスリージョン取得する場合 : モデル保管料は東京の1バケット分で済む一方、海外リージョン起動時にクロスリージョン転送料(1回約220円)とネットワーク遅延が発生。 本システムでは、頻繁に利用する主要リージョンにはS3バケットを準備し、必要に応じて初期同期しておく構成を推奨しています。 3. ゼロインバウンドSG & 最小権限IAM # 第4回で解説した「インバウンドルール0件のセキュリティグループ」と、EC2およびLambdaに必要なIAMロールを一括で定義します。 セキュリティグループ(全リージョンへの一括プロビジョニング) # Terraformのマルチプロバイダ機能( providers = { aws = ... } )やモジュールループを活用します。これにより、全リージョンへインバウンド0件のセキュリティグループを展開できます。 security_groups.tf(クリックで展開) # 各リージョン向けのセキュリティグループ(インバウンドルールなし、アウトバウンド全開放) resource "aws_security_group" "vllm_sg" { name = "vllm-ssm-isolated-sg" description = "Zero-inbound security group for vLLM SSM port forwarding" vpc_id = data.aws_vpc.default.id # インバウンドルールは定義しない(完全閉塞) egress { description = "Allow all outbound traffic for SSM Agent and S3/ECR pull" from_port = 0 to_port = 0 protocol = "-1" cidr_blocks = ["0.0.0.0/0"] } tags = { Name = "vllm-ssm-isolated-sg" } } IAMロール(EC2用 & Lambda用) # EC2インスタンスにはSSM通信・S3読み取り・ECR読み取りに必要な最小権限を付与し、Lambda関数にはマルチリージョンのEC2探索・起動に必要な権限を付与します。 iam.tf(クリックで展開) # EC2インスタンス用 IAMロール resource "aws_iam_role" "ec2_role" { name = "vllm-ssm-instance-role" assume_role_policy = jsonencode({ Version = "2012-10-17" Statement = [{ Action = "sts:AssumeRole" Effect = "Allow" Principal = { Service = "ec2.amazonaws.com" } }] }) } # SSMエージェント通信用ポリシー resource "aws_iam_role_policy_attachment" "ssm_core" { role = aws_iam_role.ec2_role.name policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore" } # S3 & ECR 読み取りポリシー resource "aws_iam_role_policy_attachment" "s3_read" { role = aws_iam_role.ec2_role.name policy_arn = "arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess" } resource "aws_iam_role_policy_attachment" "ecr_read" { role = aws_iam_role.ec2_role.name policy_arn = "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly" } # EC2インスタンスプロファイル resource "aws_iam_instance_profile" "ec2_profile" { name = "vllm-ssm-instance-profile" role = aws_iam_role.ec2_role.name } # Lambda GPUブローカー用 IAMロール resource "aws_iam_role" "lambda_role" { name = "vllm-spot-broker-lambda-role" assume_role_policy = jsonencode({ Version = "2012-10-17" Statement = [{ Action = "sts:AssumeRole" Effect = "Allow" Principal = { Service = "lambda.amazonaws.com" } }] }) } resource "aws_iam_role_policy_attachment" "lambda_basic" { role = aws_iam_role.lambda_role.name policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" } # マルチリージョンでのスポット探索・EC2起動権限 resource "aws_iam_role_policy" "lambda_ec2_broker" { name = "VLLMBrokerEC2Policy" role = aws_iam_role.lambda_role.id policy = jsonencode({ Version = "2012-10-17" Statement = [ { Effect = "Allow" Action = [ "ec2:DescribeSpotPriceHistory", "ec2:DescribeSecurityGroups", "ec2:DescribeImages", "ec2:RunInstances", "ec2:CreateTags", "ssm:GetParameter", "iam:PassRole" ] Resource = "*" } ] }) } --> Information 💡 第4回・第5回のスクリプト環境から移行する場合の注意点 第4回や第5回の環境構築スクリプトを実行済みのAWS環境でTerraformを実行する場合に注意が必要です。同名のリソースが存在すると競合エラー( EntityAlreadyExistsException 等)が発生します。その場合は terraform import で取り込むか、既存リソースを削除(または名前を変更)してから terraform apply を実行してください。 4. EC2起動テンプレート(Launch Template)とUserDataのテンプレート化 # 📌 このセクションの要点 第5回でS3へ切り離したUserData( startup.sh )をさらに一歩進めます。インスタンスタイプやEBS、IAMプロファイル、閉塞SG、UserDataブートストラップを EC2起動テンプレート(Launch Template) として一元管理します。これにより、Lambdaブローカーからハードウェア依存の記述を完全に切り離せます。 第5回では、110行を超えるUserDataスクリプトをS3バケットへ配置しました。EC2側にはS3から取得して実行する3行のブートストラップスクリプトを渡す構成としました。これにより、Lambdaコードの肥大化とUserDataの16KB上限問題を回避しました。 しかし、もう1つの課題が残っていました。 インスタンス仕様がLambdaコード内にハードコードされている : インスタンスタイプ( g6.xlarge )、EBSボリューム(40GB gp3、削除保護オフ)、IAMインスタンスプロファイル、セキュリティグループ、スポット市場オプションなどのハードウェア構成が、依然としてLambdaのPythonコード内の辞書として記述されていました。 インスタンスのハードウェア構成やブートストラップ手順は、本来「静的なインフラ仕様」としてIaCで管理すべき領域です。 そこで本構成では、 aws_launch_template (EC2起動テンプレート) を導入します。これにより、ハードウェア定義とUserDataブートストラップを一括コード化します。 1. UserDataブートストラップのテンプレート( userdata_bootstrap.sh ) # Terraformの templatefile 関数で動的パラメータ(リージョン、S3バケット名、ECRイメージ名)を注入できる極小シェルスクリプトを用意します。なお、Bash変数( $LOG_FILE 等)は波括弧を付けずに記述することで、Terraformテンプレート展開時の変数名衝突を綺麗に回避しています。 templates/userdata_bootstrap.sh(クリックで展開) #!/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" 2. 全リージョンへの起動テンプレート一括プロビジョニング( launch_template.tf ) # EC2 Launch Templateは リージョン固有 のリソースです。東京だけに配置すると、海外リージョンでの起動時にテンプレートが見つかりません。そのため、Terraformを使って全候補リージョンへ同一名( vllm-spot-template )で一括展開します。 これにより、Lambdaブローカーがリアルタイム探索でどのリージョンを引き当てた場合でも、フォールバックなしに起動テンプレートから直接インスタンスを生成できます。 launch_template.tf(クリックで展開) # terraform/launch_template.tf # ============================================================================== # EC2 起動テンプレート(Launch Template)全候補リージョン一括プロビジョニング # EBS(40GB gp3)、IAMプロファイル、セキュリティグループ、UserData(S3ブートストラップ) # を全リージョンで共通のテンプレート名「vllm-spot-template」としてコード化する # ============================================================================== # 1. 東京リージョン (ap-northeast-1) resource "aws_launch_template" "lt_ap_northeast_1" { name = "vllm-spot-template" description = "vLLM Ephemeral Spot Node (g6.xlarge, NVIDIA L4 24GB) [ap-northeast-1]" instance_type = var.instance_type iam_instance_profile { name = aws_iam_instance_profile.ec2_profile.name } vpc_security_group_ids = [ aws_security_group.sg_ap_northeast_1.id ] block_device_mappings { device_name = "/dev/sda1" ebs { volume_size = 40 volume_type = "gp3" delete_on_termination = true } } instance_market_options { market_type = "spot" spot_options { spot_instance_type = "one-time" instance_interruption_behavior = "terminate" } } user_data = base64encode(templatefile("${path.module}/templates/userdata_bootstrap.sh", { AWS_REGION = "ap-northeast-1" S3_BUCKET_NAME = "my-vllm-models-hackathon-2026-${data.aws_caller_identity.current.account_id}-ap-northeast-1-an" DOCKER_IMAGE = "${data.aws_caller_identity.current.account_id}.dkr.ecr.ap-northeast-1.amazonaws.com/${var.ecr_repository_name}:latest" })) tag_specifications { resource_type = "instance" tags = { Name = "vllm-ssm-spot-node" Project = "vllm-ephemeral" MarketType = "spot" } } } # 2. バージニア北部リージョン (us-east-1) resource "aws_launch_template" "lt_us_east_1" { provider = aws.us_east_1 name = "vllm-spot-template" description = "vLLM Ephemeral Spot Node (g6.xlarge, NVIDIA L4 24GB) [us-east-1]" instance_type = var.instance_type iam_instance_profile { name = aws_iam_instance_profile.ec2_profile.name } vpc_security_group_ids = [ aws_security_group.sg_us_east_1.id ] block_device_mappings { device_name = "/dev/sda1" ebs { volume_size = 40 volume_type = "gp3" delete_on_termination = true } } instance_market_options { market_type = "spot" spot_options { spot_instance_type = "one-time" instance_interruption_behavior = "terminate" } } user_data = base64encode(templatefile("${path.module}/templates/userdata_bootstrap.sh", { AWS_REGION = "us-east-1" S3_BUCKET_NAME = "my-vllm-models-hackathon-2026-${data.aws_caller_identity.current.account_id}-us-east-1-an" DOCKER_IMAGE = "${data.aws_caller_identity.current.account_id}.dkr.ecr.us-east-1.amazonaws.com/${var.ecr_repository_name}:latest" })) tag_specifications { resource_type = "instance" tags = { Name = "vllm-ssm-spot-node" Project = "vllm-ephemeral" MarketType = "spot" } } } # 3. オハイオリージョン (us-east-2) resource "aws_launch_template" "lt_us_east_2" { provider = aws.us_east_2 name = "vllm-spot-template" description = "vLLM Ephemeral Spot Node (g6.xlarge, NVIDIA L4 24GB) [us-east-2]" instance_type = var.instance_type iam_instance_profile { name = aws_iam_instance_profile.ec2_profile.name } vpc_security_group_ids = [ aws_security_group.sg_us_east_2.id ] block_device_mappings { device_name = "/dev/sda1" ebs { volume_size = 40 volume_type = "gp3" delete_on_termination = true } } instance_market_options { market_type = "spot" spot_options { spot_instance_type = "one-time" instance_interruption_behavior = "terminate" } } user_data = base64encode(templatefile("${path.module}/templates/userdata_bootstrap.sh", { AWS_REGION = "us-east-2" S3_BUCKET_NAME = "my-vllm-models-hackathon-2026-${data.aws_caller_identity.current.account_id}-us-east-2-an" DOCKER_IMAGE = "${data.aws_caller_identity.current.account_id}.dkr.ecr.us-east-2.amazonaws.com/${var.ecr_repository_name}:latest" })) tag_specifications { resource_type = "instance" tags = { Name = "vllm-ssm-spot-node" Project = "vllm-ephemeral" MarketType = "spot" } } } # 4. オレゴンリージョン (us-west-2) resource "aws_launch_template" "lt_us_west_2" { provider = aws.us_west_2 name = "vllm-spot-template" description = "vLLM Ephemeral Spot Node (g6.xlarge, NVIDIA L4 24GB) [us-west-2]" instance_type = var.instance_type iam_instance_profile { name = aws_iam_instance_profile.ec2_profile.name } vpc_security_group_ids = [ aws_security_group.sg_us_west_2.id ] block_device_mappings { device_name = "/dev/sda1" ebs { volume_size = 40 volume_type = "gp3" delete_on_termination = true } } instance_market_options { market_type = "spot" spot_options { spot_instance_type = "one-time" instance_interruption_behavior = "terminate" } } user_data = base64encode(templatefile("${path.module}/templates/userdata_bootstrap.sh", { AWS_REGION = "us-west-2" S3_BUCKET_NAME = "my-vllm-models-hackathon-2026-${data.aws_caller_identity.current.account_id}-us-west-2-an" DOCKER_IMAGE = "${data.aws_caller_identity.current.account_id}.dkr.ecr.us-west-2.amazonaws.com/${var.ecr_repository_name}:latest" })) tag_specifications { resource_type = "instance" tags = { Name = "vllm-ssm-spot-node" Project = "vllm-ephemeral" MarketType = "spot" } } } # 5. フランクフルトリージョン (eu-central-1) resource "aws_launch_template" "lt_eu_central_1" { provider = aws.eu_central_1 name = "vllm-spot-template" description = "vLLM Ephemeral Spot Node (g6.xlarge, NVIDIA L4 24GB) [eu-central-1]" instance_type = var.instance_type iam_instance_profile { name = aws_iam_instance_profile.ec2_profile.name } vpc_security_group_ids = [ aws_security_group.sg_eu_central_1.id ] block_device_mappings { device_name = "/dev/sda1" ebs { volume_size = 40 volume_type = "gp3" delete_on_termination = true } } instance_market_options { market_type = "spot" spot_options { spot_instance_type = "one-time" instance_interruption_behavior = "terminate" } } user_data = base64encode(templatefile("${path.module}/templates/userdata_bootstrap.sh", { AWS_REGION = "eu-central-1" S3_BUCKET_NAME = "my-vllm-models-hackathon-2026-${data.aws_caller_identity.current.account_id}-eu-central-1-an" DOCKER_IMAGE = "${data.aws_caller_identity.current.account_id}.dkr.ecr.eu-central-1.amazonaws.com/${var.ecr_repository_name}:latest" })) tag_specifications { resource_type = "instance" tags = { Name = "vllm-ssm-spot-node" Project = "vllm-ephemeral" MarketType = "spot" } } } output "launch_template_ids" { value = { "ap-northeast-1" = aws_launch_template.lt_ap_northeast_1.id "us-east-1" = aws_launch_template.lt_us_east_1.id "us-east-2" = aws_launch_template.lt_us_east_2.id "us-west-2" = aws_launch_template.lt_us_west_2.id "eu-central-1" = aws_launch_template.lt_eu_central_1.id } description = "EC2 Launch Template IDs across all target regions" } output "launch_template_name" { value = "vllm-spot-template" description = "Common EC2 Launch Template Name across all target regions" } 起動テンプレートを全候補リージョンで同名( vllm-spot-template )としてTerraformで管理します。これにより、ハードウェア仕様の変更時もコードを修正して terraform apply するだけで、全リージョンへ一括反映されます。 5. Lambda関数の自動パッケージング & デプロイ # 第5回で紹介したGPUブローカーのPythonスクリプト( handler.py )をデプロイします。Terraform標準の archive_file データソースを使い、自動でZIP化して展開します。 起動テンプレートの導入に伴い、Lambda関数の環境変数には LAUNCH_TEMPLATE_NAME を渡します。 lambda.tf(クリックで展開) # Pythonコードの自動ZIPパッケージング data "archive_file" "lambda_zip" { type = "zip" source_dir = "${path.module}/../lambda" output_path = "${path.module}/dist/lambda_function.zip" } # GPU起動ブローカー Lambda関数 resource "aws_lambda_function" "broker" { function_name = "vllm-spot-broker" runtime = "python3.12" handler = "handler.lambda_handler" role = aws_iam_role.lambda_role.arn timeout = 120 memory_size = 256 filename = data.archive_file.lambda_zip.output_path source_code_hash = data.archive_file.lambda_zip.output_base64sha256 environment { variables = { TARGET_REGIONS = join(",", var.target_regions) S3_BUCKET_NAME = "my-vllm-models-hackathon-2026-{account_id}-{region}-an" INSTANCE_PROFILE_NAME = aws_iam_instance_profile.ec2_profile.name ECR_REPOSITORY_NAME = aws_ecr_repository.vllm.name TAG_SERVER_NAME = "vllm-ssm-spot-node" SG_NAME = "vllm-ssm-isolated-sg" LAUNCH_TEMPLATE_NAME = aws_launch_template.vllm_spot_template.name } } tags = { Project = "vllm-ephemeral" } } Lambdaハンドラー側での呼び出し(起動処理の簡潔化) # 起動テンプレートが整ったことで、Lambda関数( handler.py )のインスタンス起動処理は極めて簡潔になります。 # Launch Template を指定して1行で起動 run_params = { "MinCount": 1, "MaxCount": 1, "LaunchTemplate": { "LaunchTemplateName": os.environ.get("LAUNCH_TEMPLATE_NAME"), "Version": "$Latest", }, } if target_ami: run_params["ImageId"] = target_ami ec2.run_instances(**run_params) 長大な BlockDeviceMappings や UserData などをPython側で構築する必要はありません。Lambdaの責務は「最適リージョンのリアルタイム探索と、テンプレートによるエフェメラル起動」のみに集中できます。 6. Terraformの実行(一括プロビジョニングと環境破棄) # ディレクトリ構成を整えたら、コマンド実行で全リソースをプロビジョニングできます。 cd terraform/ # 1. プロバイダ初期化 terraform init # 2. 静的基盤の全展開(S3・ECR・IAM・SG・Lambda) terraform apply -auto-approve Apply complete! Resources: 18 added, 0 changed, 0 destroyed. Outputs: broker_lambda_arn = "arn:aws:lambda:ap-northeast-1:123456789012:function:vllm-spot-broker" ecr_repository_url = "123456789012.dkr.ecr.ap-northeast-1.amazonaws.com/vllm-openai" これにより、わずか数十秒でマルチリージョン展開に必要な基盤のすべてが整います。 日々のGPU起動・停止 # 静的基盤のデプロイ後は、日々のGPU起動・停止に第5回のスクリプト( 01_start_vllm_ssm.sh / 02_stop_vllm_ssm.sh )をそのまま使用します。 # GPUインスタンスの起動(Lambdaブローカー呼び出し & SSMトンネル確立) ./01_start_vllm_ssm.sh # 作業終了時のリソース破棄(SSM切断 & Lambda経由でのTerminate) ./02_stop_vllm_ssm.sh Terraform側でGPUインスタンス自体のライフサイクルは抱え込みません。動的な起動・停止をクライアントスクリプトに任せることで、 tfstate のロック待ちなしに迅速に推論環境を利用できます。 検証作業全体が終了し、クラウド上の静的基盤ごとすべてクリーンアップしたい場合のみ、 terraform destroy を実行します。 cd terraform/ terraform destroy -auto-approve --> 💡 発展:他ワークロードへの応用パターン 今回の検証ではvLLM(GPU推論)を題材としました。しかし、「Lambdaによるスポット探索・起動」と「SSMポートフォワーディング」の組み合わせは、他のワークロードにも幅広く応用可能です。 大規模コンパイル・ビルド環境 : 手元端末の負荷を軽減するため、多数のvCPUを持つコンピュート最適化インスタンス(例: c6i 系列)を必要な時間だけ立ち上げ、ビルド完了後に自動終了させる。 モデルのファインチューニングやバッチ学習 : 学習時のみ複数GPUインスタンスをスポット起動し、S3からデータセットを取得して学習し、生成された重みをS3へ保存した後にインスタンスを破棄する。 メモリ集約型のデータ処理 : ローカル環境ではメモリ不足になりやすい大規模データフレームの集計・分析時のみ、大容量メモリを搭載したインスタンス(例: r6i 系列)を一時的に利用する。 セキュアな一時開発環境(リモートDevbox) : インバウンドポートを開放せずにSSM経由で接続できるため、外部へのポート公開が制限されている環境でも、手元のVS Code等からセキュアに一時的な作業環境として利用できる。 「必要なタイミングだけクラウド上の適切なリソースを立ち上げ、完了後は確実に終了させる」というエフェメラルな運用は、コスト効率とセキュリティを両立させるアプローチとして幅広く活用できます。 まとめ # 本記事では、マルチリージョンにまたがるGPU推論基盤の静的リソースを Terraformで宣言的にコード化(IaC化) しました。 静的基盤と動的エフェメラルの役割分担 : 状態管理のオーバーヘッドを避け、静的基盤のみをTerraformに任せることで、軽快かつ堅牢なアーキテクチャを実現。 EC2起動テンプレート(Launch Template)の導入 : ハードウェア仕様や閉塞SG、UserDataブートストラップを一元的にコード化し、Lambda側からハードウェア依存コードを切り離し。 ECRクロスリージョン自動レプリケーション : 東京へ1回Pushするだけで全候補リージョンへAWSバックボーン経由で自動同期されるパイプラインを構築。 ゼロインバウンドSG & 最小権限IAM : 受信ルール0件のセキュリティグループやSSM/ECR/S3接続に必要なIAMポリシーを一括定義。 Lambda自動デプロイと環境破棄 : Pythonコードの自動ZIP化からプロビジョニング、そして検証終了時の terraform destroy による確実なクリーンアップ性を確立。 手動セットアップや場当たり的なスクリプトを排除し、コードで再現可能なインフラ基盤が整ったことで、日々のLLM検証をより安全・確実に運用できるようになりました。
はじめに # これは、興味のままに新試験を受験してきた記録です。 先日、AWS認定に新設された注目のベータ試験「AWS Certified AI Business Strategist (AIB-C01、ベータ試験コード: AB1-C01)」を受験してきました。本記事では、新設された試験の概要や学習準備、受験当日のリアルな所感、そして合否結果についてまとめます。 --> Information 秘密保持契約(NDA) があるため、詳細な試験問題の内容や個別設問については触れることができませんので、ご了承ください。 また、本記事の記載内容は2026年10月(ベータ試験受験時)の情報に基づいています。 試験について # AWS Certified AI Business Strategist(以下、AI Business Strategist) は、ビジネスリーダーやDX推進担当者、企画職などを対象とした認定資格です。AIおよび生成AI技術を活用したビジネス価値の創出、戦略的な意思決定、ガバナンス推進に関する知識が総合的に評価されます。 これまでのAWS認定は「Solutions Architect」や各種Specialtyなど、主にインフラ構築や実装技術(How)に焦点を当てていました。 これに対し本試験は新設の「Business」カテゴリーに属します。「どの課題にAIを適用し、どう投資判断や社内統制を進めるか(Why / What)」というビジネス戦略視点に特化している点が大きな特徴です。 試験の概要 試験コード : AIB-C01(ベータ試験コード: AB1-C01) カテゴリ : Business(※新設カテゴリー) 試験時間 : 170分 + 10分(前後のアンケートなど) 出題数 : 85問程度(※ベータ試験) 合格基準スコア : 700点(100〜1,000点満点) 受験料 : 8,250円(税込、※半額バウチャーなどは利用不可) ※ベータ試験であるため、正式リリース後は試験時間や出題数などが一部変更される可能性があります。 出題分野(試験ガイドより) # 試験ガイドに記載されている主な出題分野は以下の4つです。 分野 1: AIの基礎とビジネスリテラシー 機械学習や生成AIの基本概念、適用可能なビジネス課題の特定 従来の予測型AIと生成AI(基盤モデル)の特性の違いとビジネスへの影響 分野 2: AI戦略とビジネス価値の創出 AI導入に伴うビジネスケースの策定、ROI(投資対効果)の評価手法 自社開発(Build)か外部サービス・既存モデル採用(Buy)かのトレードオフ判断 定量・定性のKPI(重要業績指標)の設定 分野 3: AIガバナンスと責任あるAIリーダーシップ 責任あるAI(公平性、透明性、説明責任、プライバシー保護)の推進 ハルシネーション(幻覚)リスクへの対策やHuman-in-the-Loop(人間の介在)の設計 知的財産権、著作権、業界コンプライアンスの遵守 分野 4: 組織のAIレディネスと変革推進(トランスフォーメーション) 全社的なAI活用を支える組織体制(AI Center of Excellence: CoE)の立ち上げ 従業員のスキルアップ、チェンジマネジメント(変化への抵抗の克服) パイロット運用から全社展開へのスケール戦略 申し込み # ベータ試験のアナウンスを確認後、認定ポータルサイトから申し込みました。 AWS認定の新設試験ベータ版は、8,250円(税込)という特別価格で最新トレンドの出題傾向をいち早く体験できる貴重な機会です。今回もスケジュールを調整し、テストセンターでの受験枠を確保しました。 準備したこと # 本認定はコーディングや詳細なインフラ構成コマンドを問う試験ではないため、ビジネス視点でのAI活用戦略やガバナンスに焦点を当てて準備を進めました。 1. 試験ガイドの精読 # まずは公式の 試験ガイド を読み、出題範囲を確認しました。 「Pythonコードの実装」や「インフラ詳細設定」などは明確に出題対象外と記載されているため、ビジネス判断や戦略立案のシナリオに絞って学ぶ方針を立てました。 2. AWS Skill Builder # AWS公式の学習プラットフォーム「AWS Skill Builder」にて、公式問題集や関連教材を確認・活用しました。 Official Practice Question Set: AWS Certified AI Business Strategist (AIB-C01 - 日本語) 公式の練習問題集(日本語)が提供されており、出題傾向やシナリオ問題の形式、選択肢の問われ方を事前に把握するのに最適 試験準備カテゴリ(AIB-C01 / AB1-C01) ビジネスリーダー向けの生成AIコース(「Generative AI for Executives」等)や、責任あるAI(Responsible AI)に関する教材を活用した基礎概念のインプット 3. 公式ホワイトペーパー・ブログの確認 # ビジネス価値やガバナンスに関するAWSの公式ドキュメントやフレームワークを復習しました。 AWS Cloud Adoption Framework (CAF) for Artificial Intelligence 責任あるAI(Responsible AI)に関する公式ガイダンス Amazon Bedrock / AWSの生成AIサービス活用事例 (金融、ヘルスケア、小売など業界ごとのユースケース) 準備しながら気づいたこと # 学習を進める中で、従来のエンジニア向け試験とは異なるいくつかの重要な視点が見えてきました。 「技術的に可能か」ではなく「ビジネス投資として妥当か」が問われる点 最新の基盤モデルをファインチューニングすることが常に正解ではなく、まずはプロンプト調整やRAGで検証し、ROIや運用コスト、リスクに見合うかを判断する視点が重視される。 Build vs Buy の明確な判断基準 SaaSやマネージドサービス(Amazon Qなど)を導入すべきケースと、自社専用のモデルやパイプライン(Amazon SageMaker / Bedrock)を構築すべきケースの切り分けが明確に問われる。 責任あるAI(Responsible AI)とリスク管理のウェイトの高さ ハルシネーション対策、バイアス軽減、著作権保護、データプライバシーなど、AIを本番運用するにあたって企業が直面する法的・倫理的リスクへのガバナンス設計が試験の大きな柱となっている。 組織・人材のチェンジマネジメントの重要性 単にツールを導入するだけでなく、CoE(Center of Excellence)の立ち上げや社内教育、段階的なPoC推進など、「組織としていかにAIを定着させるか」という組織論も重要なテーマである。 受験当日の様子 # いつものテストセンターで受験しました。 問題文の分量・出題内容 たまに1画面・1ページをフルに使う長めの問題文もあったが、複雑な技術構成やパラメータ設定を問うものではなかった。どのように社内にAIを広げていくかという展開戦略や、社内統制・ガバナンスルールの進め方といった意思決定が中心である。そのため、普段からPM(プロジェクトマネジメント)や事業企画、DX推進などの業務に携わっていれば、文脈から自然と正しい答えを選択できると感じた。 時間配分とコンディション 試験時間は170分と長めに設定されている。当日は前日の寝不足がたたり、途中でウトウトしてしまったため、最終的に時間ギリギリでの終了となった。しかし、コード読解や複雑な計算問題は一切ないため、万全の体調で集中して受験すれば、1問1分以内のペースでテンポよく解き進められるボリューム感だと感じた。 受験結果について # 当日夜、スコアレポートが届き、 無事に合格 できました。 さらに、ベータ試験の合格者に対する特典である アーリーアダプター(Early Adopter)のデジタルバッジ も無事獲得できました。 手応えとしても感じていた通り、細かなAWS CLIコマンドなどを問う問題はありませんでした。ビジネスの文脈に即して「適切なAI活用判断ができるか」「責任ある推進体制を築けるか」という本質的な理解が問われる試験でした。 活かせる前提知識(他のAI認定との親和性) # 実際に受験してみて感じたのは、他社クラウドや既存のAI認定で培った知識が大きなアドバンテージになる点です。 特に、以下の認定資格の知識や学習経験があれば、出題意図をスムーズに汲み取ることができ、 だいぶ楽に受験できる と感じました。 AWS Certified Generative AI Developer - Professional (AIP-C01) Bedrock の選定基準やガードレール、コンプライアンス設計などの深い知見 AWS Certified AI Practitioner (AIF-C01) AWS における AI/ML 基礎概念やユースケース全般の土台 Google Cloud認定 Generative AI Leader (GAIL) ビジネスリーダー視点での生成 AI 導入・ROI 評価・組織展開という共通の思想 Microsoft認定: Azure AI の基礎 (AI-901) 責任ある AI(公平性・透明性・プライバシー保護など)の基本原則の共通性 生成AIパスポート(GUGA) AI リテラシー、知的財産権、著作権、リスク管理といった社会・ビジネス共通の基礎教養 ちなみに、私自身は上記のうち「生成AIパスポート」以外はすべて合格・取得済みです。 そのため、各試験で培ったガバナンスやユースケースの知識が自然と頭に入っており、初見のシナリオ問題でもスムーズに判断できました。 まとめ # 新設カテゴリ「Business」として、非エンジニアにも最適 PM、DX推進者、企画職、コンサルタントなど、ビジネスとAIの橋渡し役を担う方にとって、体系的な知識を証明する最適な試験体系になっている。 エンジニアにとっても「視座を引き上げる」絶好の機会 普段「どう実装するか」に集中しがちなエンジニアにとっても、「なぜ作るのか」「投資に見合うか」「組織にどう定着させるか」という一段高いビジネス視点を整理する良い機会になる。 早期受験でアーリーアダプターバッジも獲得可能 新設資格の立ち上げ期ならではの面白さがあり、AI推進に関わるビジネスリーダーやエンジニア双方にとって挑戦しがいのある認定である。 以上、簡単な受験記録となりますが、これから受験を検討されている方の参考になれば幸いです。
はじめに # これは、思いついたアイデアや気になるクラウド技術を、まずは好奇心のままに手を動かして形にしてみる技術検証ログです。 前回の記事( 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基盤が完成しました。
はじめに # おかげさまで、この10月で豆蔵デベロッパーサイトは立ち上げから5周年を迎えることができました。 5周年を祝う豆香 ローンチのお知らせは2021年の11月29日でした。 /blogs/2021/11/29/site-launch/ その前から少しずつコンテンツを貯めてお知らせを出したので、最初の記事は10月7日の K8s チュートリアルで EKS を構築する記事でした。 /containers/k8s/tutorial/infra/aws-eks-eksctl/ この5年間の歩み # この5年間を振り返ってみます。 初期は、私含め2名でサイトを構築し記事を書いてました。 記事数・執筆者数の推移 # 時期 記事数 執筆者数(累計) 備考 2021-11 15本 2名 ローンチ 2022-03 53本 継続執筆4名/経験7名 初回振り返り 2022-05 100本到達 アクティブ5名 2022-08 200本突破 19名(アクティブ9名) 100→200が加速 2022-12 318本 28名(アクティブ10名超) 平日ほぼ毎日更新 2023-12 522本 34名 開設2年で500本突破 2024-06 590本 56名 2024-12 680本 63名 2025-06 769本 66名 2025-09 809本 71名 800本突破 2025-12 843本 75名 2026-03 876本 76名 2026-09 (直近) 912本 83名 900本突破 ローンチ当初2名だった執筆者は、半年後の2022年5月に5人になり記事数100本到達、6月には月間最多36記事を記録するなど勢いよく立ち上がりました。2022年8月には200本を突破、年末には執筆者28名(アクティブ10名超)に成長しています。 2023年末には記事数522本となり、サイト開設からちょうど2年で500本を突破。検索流入も2023年3月に月間5万クリックに到達するなど、着実に認知度を高めていきました。 その後も成長を続け、2025年9月には記事数800本・執筆者71名を突破。直近2026年9月時点では 記事数912本、執筆者83名 まで広がっています。一人ひとりの興味・関心、専門性を活かした記事が積み重なって、ここまで来られました。 記事数と執筆者数の推移 コンテンツ領域の広がり # ローンチ時は「コンテナ」「マイクロサービス」などわずかなカテゴリーからのスタートでしたが、5年間で以下のようにテーマが大きく広がりました。 2022年 : マイクロサービス、学び、アジャイル開発、IoT、フロントエンド、ロボット 2023年 : モデリング、機械学習 2025年 : 機械学習・生成AI(リニューアル) ロボット・IoT・組込みのようなハードウェア寄りの分野から、アジャイル・品質保証・プロジェクトマネージメントといった開発プロセス系、そして機械学習・生成AIまで、豆蔵の多様な事業領域を反映したラインナップになっています。 長期連載も数多く育ちました。テーマ別に振り返ってみます。 マイクロサービス・バックエンド 逆張りのMicroProfile ~ Helidonで始めるマイクロサービスへの一歩 ~ (ローンチ初期から継続、JJUG CCC 登壇にも発展) Spring Boot による実践マイクロサービス開発 Springの小話 非同期メッセージング (Kafka や AsyncAPI などの非同期メッセージング) アジャイル・プロジェクトマネージメント エクストリームプログラミング解説 アジャイル開発現場におけるTips 業務システムにおけるアジャイル プロジェクト管理 (CCPM理論編・実践編・ツール編、新人プロジェクトマネージャー向け「問題と課題」シリーズ、「デキるPM」改善シリーズ) スクラムマスターのAI活用を考える アジャイルFAQ テスト・品質保証 Jest再入門シリーズ 、 Contract Test 、 Google Test 、 Pytest 品質保証者の憂鬱 フロントエンド Nuxt3入門 Denoを始める 言語・モデリング Javaエンジニアが始めるTypeScript入門 UML をプログラミング言語で実装する (Go・Python・C言語編) SysON で始める SysMLv2モデリング ロボット・IoT ロボットマニピュレータ制御のアルゴリズム robotics/#食品盛り付けロボットシステム「美膳」 AWS IoT基礎編 、 MQTT編 など 学び・育成 オンライン研修の設計勘所 ソフトウェア品質のための統計入門 # (全15回の大作「統計の話をしようじゃないか」) 生成AI トップページのコラム「 豆香の豆知識 」 OpenAI Realtime API シリーズ Kiroでアルバムアプリを作る全6本 AIエージェントとシステムをつなぐMCP入門 EC2でローカルLLMを動かすシリーズ その他 リモートワーク環境紹介 (コロナ禍で定着したテレワーク環境紹介。執筆デビューのネタとしても重宝) 息の長い記事とバズった記事 # サイトの認知度やアクセス数は、公開直後に瞬間的にバズる記事と、公開から何年経っても安定してアクセスを稼ぎ続ける記事の両方に支えられています。その代表例をいくつか挙げてみます。 Rust によるデスクトップアプリケーションフレームワーク Tauri 直感が理性に大反抗!「モンティ・ホール問題」 基本から理解するJWTとJWT認証の仕組み 手書き風ドローツール Excalidraw のススメ 【新人さん向け】A5:SQL Mk-2のおすすめ設定と使い方 文字コード これだけは覚えておこう ~シフトJIS編~ サイト基盤・機能の進化 # 読者・執筆者双方の体験向上のため、地道な改善を積み重ねてきました。目次機能、画像拡大、著者アバール表示、サイト内検索(その後 Algolia に刷新)、タグ検索、数式(KaTeX)対応、おすすめ記事・週間アクセスランキングなど、多くの機能を追加してきました。 サイトを支える静的サイトジェネレーターも、Eleventy から Deno ベースの Lume へ移行するという大きな技術的転換も経験しました。 --> Information 記事執筆中に Deno が1年後に開発を終了するという発表が飛び込んできました。 Deno is joining Cloudflare | Deno Lume を採用しているので、今後の動向を注視して動きたいと思います。 生成AIとの歩み # この5年は、生成AIの進化とともに歩んだ5年でもありました。 2023年、トップページのコラム「豆香の豆知識」を GPT-3 で開始。その後 GPT-4、GPT-4 Turbo、GPT-4o と、豆香は世代交代を重ねてきました(GPT-5 へのアップデートを忘れてたことを記事書いてて思い出しました😅) 2024年には記事サイドバーの挿絵を DALL-E-3 で自動生成する機能を追加。豆香は記事 PR へのコメントやアクセス集計の通知役としても社内で活躍しています 2024年に英訳記事の自動生成を開始(現在は多くの記事が英語で読めます)、2025年には中国語訳も追加し、海外からのアクセスも徐々に伸びています 自社メディアの運営自体に生成AIを積極的に取り込んできたのも、このサイトらしい特徴だと思います。 恒例企画・コミュニティの醸成 # 執筆の裾野を広げるための企画も定着してきました。 アドベントカレンダー : 2022年に初開催(16名、うち7名が初投稿)して以来、毎年末の恒例行事に 夏のリレー連載 : 2023年に初開催(14本、新規執筆者11名、営業職や新卒社員もデビュー) 春の新人向け連載 : 2024年に初開催し、新人エンジニアやプロジェクトマネージャー向けの記事群が生まれる場に これらの企画を通じて、普段は筆を執らないメンバーがデビューするきっかけが生まれ、現在の執筆者数の広がりにつながっています。 SNS をはじめとしたメディアとの連動 # 公式 X(旧Twitter) アカウントの開設から始まり、Bluesky、Facebook、note と発信チャネルを広げてきました。執筆者が JJUG CCC などの社外カンファレンスに登壇する機会も生まれ、記事が社外での発表につながる好循環も生まれています。 豆蔵のニュースページにも注目記事を掲載しています。 技術発信 – 豆蔵 MAMEZO 営業、採用への波及効果 # 記事を書き続けてきたことで、デベロッパーサイトはエンジニア個々人のスキルや得意領域をアピールする場としても機能するようになってきました。実際に、記事を読んだお客様から執筆者を名指しで新規の引き合いをいただき、そのまま受注につながったケースも出てきています。 中途採用の場面でも「デベロッパーサイトの記事を読んでいる」という方が増えており、技術発信が採用にも確実に手応えをもたらしていると感じています。 今後の展望 # 5周年の節目を迎え、現在デベロッパーサイトはリニューアルに向けて動き出しています。より読みやすい、モダンなサイトへと生まれ変わる予定です。多くの読者にとって「最もノイズが少なく、技術情報に集中できるUI」を目指し、記事ページの可読性向上や、ダークモードのネイティブ対応も計画しています。実装プロセス自体も最新のAI駆動開発(AI-Driven Development)で進めていきます。サイト自体を「生成AIを実践投入するための社内プロジェクト」として位置づけることで、これまで積み重ねてきた生成AIとの歩みをさらに一歩進めたいと考えています。 内容についても、さらなる充実を目指し、開発系の Tips はもちろんのこと、ロボティクスや生成 AI など我々が取り組み、また読者の皆様も興味を持つであろう内容を充実させて行きたいと考えています。アドベントカレンダーや夏のリレー連載、春の新人向け連載といった恒例企画も継続し、まだ執筆デビューしていないメンバーが筆を執るきっかけを作り続けていきます。また、英訳・中国語訳記事の拡充を通じて、国内にとどまらず海外のエンジニアにも豆蔵の技術発信を届けていきたいです。 次の5年、10年先も「開発に役立つサイト」として、そして豆蔵という会社の技術力・人となりが伝わるメディアとして育てていきたいと考えています。 さいごに # 以上、豆蔵デベロッパーサイト5周年の振り返りでした。改めて振り返ってみると、立ち上げ当初2名だった執筆陣が83名まで広がり、900本を超える記事が積み上がったことに、感慨深いものがあります。ここまで続けてこられたのは、折にふれて記事を書いてくれた執筆者の皆さん、そして何より読み続けてくださっている読者の皆さんのおかげです。この場を借りて御礼申し上げます。 6年目以降も、変わらず技術と現場のリアルを発信するメディアであり続けたいと思います。引き続き豆蔵デベロッパーサイトをよろしくお願いいたします。 よかったら フィード の購読、 X や Bluesky でのフォローもお願いします。 Facebook でも本サイトの注目記事をはじめ豆蔵に関するイベントを紹介しています。 note にも時々本サイト関連の記事が掲載されています。
はじめに # Claude Code を利用した個人開発の進め方が、最近一定の形に落ち着いてきました。 何度も作り変えてきたので、いまの形も通過点だと思います。それでも「どうやって Claude Code と開発してるんですか?」と聞かれたときに渡せるものとして、現状の構造をひととおり書き出しておきます。 --> Information この記事は「こう作るべき」という提案ではありません。 あくまで、ぼくはこう回している という記録です。 この記事の構成 # 本記事の構成は以下の通りです。 全体像:開発サイクル全体を1枚の図で示し、読み方を説明します このプロジェクトの憲法:図の前提になっている「何をどこに書き残すか」「どう守らせるか」「どこで人間が判断するか」という3つのルールを説明します 【第1部】1つの機能ができるまで: Plan → TDD → Review → Verify → Commit → CI という実際の1サイクルを、4つの Gate と合わせて左から順に追います 【第2部】どの情報を、どこに置いているか:ルール・スキル・サブエージェント・スクリプトなどに何を書き、なぜそこに置いたのかを書きます おわりに:いまの形が通過点であることと、まだ答えが出ていない課題を書きます 全体の設計だけ知りたければ最初の2章、実際の運用フローまで知りたければ第1部まで、資材の置き方まで踏み込みたければ第2部まで読んでもらえれば幸いです。 全体像 # まず1枚で。これがいま回っているものの全体です。 この図の読み方は以下の通りです。 上の枠が 材料 です。開発中に人間や Claude Code が繰り返し読むドキュメントで、 docs/adr/ や .claude/rules/ 等々が並んでいます 下の枠が 実行 です。 01 Plan から 06 CI まで、左から右に時間が流れます 05 Commit までは手元の環境、 06 CI から先は GitHub Actions です。実行環境が変わるので、色を変えて破線で区切ってあります 黒い菱形 A〜D は、ぼくが判断する場所です マゼンタの docs/adr/ / criteria.yaml は上下に2つありますが、これは同じものです。判断や指摘が記録され、それが次の開発サイクルの材料に戻ります この記事では、この図を左から順に追ったあと、図には描けなかった部分を詳解していきます。 このプロジェクトの憲法 # 図の前提になっているルールが3つあります。 ルール 概要 行き先を決めてから、書き残す 4段階のふるいで ADR / コード / 設計書 / 書かない に振り分ける 書いて守らせるのではなく、書けなくする 型で表現不能にすることを最優先にする ぼくが判断する場面を決めておく 判断を引き取る場所を工程の中に4箇所固定する 行き先を決めてから、書き残す # 実装していると、コードの外に書き残しておきたいことが次々に出てきます。「なぜこの方式を選んだか」「全体はどう噛み合っているか」「この値の上限はいくつか」「デプロイはどう回すか」等々です。 以前はこれを片っ端から設計書に足していました。その結果、認証まわりの設計書が 3,000 行を超えたあたりでコードと設計書の内容が食い違いはじめます。どちらが正しいのか分からない。直すには2箇所を直さないといけない。そのうち設計書のほうを見なくなる。 そこで、書き残したいことが出てきたら 先に置き場所を決める ようにしました。役割ごとの媒体はこうです。 役割 媒体 WHAT(何を作るか) 要件定義書 WHY(なぜそう作るか) ADR HOW(どう作るか) ソースコード(型・関数・zod schema) VERIFY(正しく作られたか) テストコード OVERVIEW(俯瞰・統合) 設計書 ただしこの表だけでは決まりません。たとえば「エラー処理はミドルウェアで一元化する」という内容は、そう決めた理由(WHY)でもあり、全体構成の一部(OVERVIEW)でもあります。同じ内容が ADR にも設計書にも行けてしまいます。 分かれ目を、 決定を1つ切り取るか、全体の整合を1枚にまとめるか に設定しました。ADR は決定するたびに1ファイル増えるので、いま 124 本あります。設計書は「全体構成」「デプロイ手順」のように枚数が決まっていて、決定が増えると中身が追従します。 「この方式を選んだ理由」なら ADR、「全体がどう噛み合っているか」なら設計書へという具合です。 そのうえで、書き残したい内容ごとに4つの問いを上から順にかけて、最初に YES が出た行き先へ書き残したいことを送ります。 問い YES の行き先 Q1. それは「決定」か?(代替案・トレードオフがある) ADR(1決定1ファイル) Q2. コード / 型 / テスト / OpenAPI を読めば再生成できる事実か? コードが真正ソース。ドキュメントには参照だけ残す Q3. コードが変わるたびに変わる(揮発する)か? ドキュメントには書かない。コード側に持たせるか、コードから自動生成する Q4. 安定 ∧ コードの単一箇所から不可視か? 設計書の領分 冒頭に挙げた4つを、この表に通してみます。「なぜこの方式を選んだか」は Q1 で ADR へ。「この値の上限はいくつか」は Q2 でコードへ。「全体はどう噛み合っているか」と「デプロイはどう回すか」は Q4 で設計書へ行きます。 この4つの問いで振り分けると、Q4 まで生き残るのは下記4類型と整理できました。 統合俯瞰(全体構成図) 横断の流儀(命名やログ項目の標準) 運用知識(デプロイ手順・runbook) ドメイン言語(用語集・コンテキストマップ) いずれも「数ヶ月・多ファイルをまたいで真であり続ける」かつ「コードの1箇所を読んでも見えない」の交差領域です。 結果として、詳細設計書は書きません。ここでいう詳細設計書は、コードを書く前にクラス構成・関数のシグネチャ・項目定義・バリデーション規則・処理シーケンスを文章で決めておく、ウォーターフォールでいう「詳細設計」工程の成果物です。シグネチャや項目定義は型を読めば再生成できるので Q2 で、クラス構成や処理シーケンスはコードが変わるたびに変わるので Q3 で落ちます。 書いて守らせるのではなく、書けなくする # 規約を守らせる手段はいくつかありますが、 いちばん優先するのは「型で表現不能にする」こと と決めています。ドキュメントに「こうしてください」と書いて守らせるのではなく、違反そのものを起こせなくする。 実際にそうしている箇所を2つ挙げます。 ID の偽造を、型と lint で禁止する ユーザー ID のような識別子は、ただの string ではなく branded type にしています。 export const UserIdSchema = z .string() .min(1) .max(128) .regex(/^[A-Za-z0-9_-]+$/) .brand<"UserId">(); export type UserId = z.infer<typeof UserIdSchema>; export const userId = (raw: string): UserId => enforceInvariant(UserIdSchema, raw, "UserId"); .brand<"UserId">() が付いているので、生の string を UserId 型の引数に渡すとコンパイルエラーになります。 UserId を作るには userId() を通すしかなく、そこで長さと文字種の検証が走ります。検証を通っていない値は、型のうえで存在できません。 ただし、型には抜け道があります。 "anything" as UserId とキャストすれば、検証を通さずに UserId 型を名乗れます。 そこで、この抜け道を ESLint で塞くようにしました。 // eslint.config.mjs const noAsBrandedScalarVo = { selector: "TSAsExpression[typeAnnotation.typeName.name=/^(UserId|BudgetId|SpendingQuadrant|BudgetQuadrant)$/]", message: "branded scalar / 列挙 VO への as キャストは型偽造の抜け道。生成は VO の factory 経由のみ。", }; as UserId と書いた時点で lint が落ちます。「ID は必ず userId() で作ってください」とドキュメントに書く代わりに、 型で塞ぎ、型の抜け道を lint で塞いでいます 。 対応表の書き漏れを、コンパイルエラーにする literal union で定義した値ごとに、対応する値を紐づける。こういう対応表をいくつか使っています。 type Status = "todo" | "doing" | "done"; const STATUS_SLUG = { todo: "to-do", doing: "in-progress", done: "done", } as const satisfies Record<Status, string>; 末尾の satisfies Record<Status, string> により、union の全メンバーが揃っていないと型エラーになります。 Status にメンバーを1つ足した瞬間、この表の更新漏れがビルド時に落ちます。レビューで気づく必要がありません。 どちらも共通しているのは、 守られたかどうかを人間が確認していない ことです。違反したらコンパイルか lint で落ちて、レビューまで届きません。 ぼくが判断する場面を決めておく # 規約と型で縛っても、実装中には必ず判断の要る場面が出てきます。そこでぼくが判断を引き取る場所を工程の中に4箇所配置しました。 図の黒い菱形 A〜D です。 Gate ぼくが判断すること A 承認済みテストケースを変えるかどうか B 実装中に発覚した設計方針をどう変えるか C 重大なレビュー指摘を、いま直すか先送りするか D 既存の ADR と現実のコードが食い違ったときの解釈 【第1部】1つの機能ができるまで # この章では、1つの機能を作る間に前章の3つのルールがどう働くかを追っていきます。 図の下の枠を左から順にたどる形で、工程ごとに「何をして、どこで止まるか」を解説するつもりです。 Plan → TDD → Review → Verify → Commit → CI A B C D チェーンの下の A〜D が Gate の位置です。TDD で2回、Review と Commit で1回ずつ、判断がぼくに戻ってきます。 各工程の手順は feature-implementation スキルにまとめてあり、Claude Code は最初のファイルを書く前にこれを読み込みます。 1. 計画する # まず、これから触る領域に関係する ADR を Claude Code に読ませます。とはいえ 124 本(現時点の数値)あるので、毎回全部渡すわけにはいきません。そこで CLAUDE.md に索引を持っています。 触る領域 必読 ADR 関連設計書 認証 / 認可 0030, 0031, 0032, 0042 authentication-design.md ロギング / 監査ログ 0009, 0033 §1.3, 0037 §3, 0042 logging-monitoring.md Hono ミドルウェア 0037, 0038 middleware-architecture.md エラー戦略 0035, 0041, 0042, 0043, 0045, 0047 error-handling-architecture.md Claude Code は着手前にこの表を引いて、該当する ADR だけを読みます。 次に、馴染みのない領域なら Explore エージェントで前例を探します。これは「既存の実装パターンを探す」ためのもので、狙いは実装の不整合を防ぐことです。 たとえば新しく xxxConfig.ts を作ろうとしたとき、すでに authConfig.ts が同じ構造で存在しているなら、それに倣うべきです。 放っておくとエージェントは毎回ゼロから「良い設計」を考えて、微妙に違う流儀を持ち込みます。 3ファイル以上に触る、アーキテクチャを変える、あるいは前例を探したら食い違うものが2つ出てきた。このどれかなら、さらに Plan エージェントを挟みます。 計画は、 Plan エージェントを使ったかどうかに関わらず、 .claude/docs/plan/ にスナップショットとして保存します。 このスナップショットは 陳腐化する前提 で置いています。実装が進めば計画とはズレるので、最新に保つ努力はしません。「当時どう段取りしたか」の記録として残すだけです。ここを「常に正しく保つドキュメント」にしようとすると、また二重管理が始まります。 なお、 Explore と Plan は Claude Code に組み込まれているエージェントで、このプロジェクトで定義したものではありません。 --> Information Explore と Plan を呼ぶ条件を上記のように資材へ明記していますが、エージェントが毎回この通りに動くわけではありません。条件に当てはまっているのに Explore を飛ばして書き始める、ということは普通に起きます。ここに書いているのは「そう指示している」であって、「必ずそうなる」ではありません。 2. テストから書く # テストの書き方はルールファイル testing.md が持っていて、エージェントはそれに従います。 Always follow a two-phase approach: define test cases → review → implement . Never implement tests before the user approves the test case list. 空のテストケースだけを先に書いて、ぼくにレビューを出す手順です。実際にはこういうものが提示されます。 describe("BudgetAmount", () => { // 正常系 test("正の整数から生成できる", () => {}); test("上限値ちょうどは生成できる", () => {}); // 異常系 test("0 以下は生成できない", () => {}); test("上限を超える額は生成できない", () => {}); test("小数を含む値は生成できない", () => {}); // 境界 test("上限 + 1 は生成できない", () => {}); }); 中身は空で、 test の説明文だけがある状態です。この状態で「この観点で合っていますか」と聞かれます。 ここで承認したテストケースをあとから変更するには、ぼくの承認が要ります。これが Gate A です。 承認なしで変更できるようにしていたときは、テストが通らない理由をフレームワークの都合と説明してテストケースごと削除する、ということが起きていました。そこで承認済みテストケースの変更は、理由の説明とセットで止めるようにしています。 Green のあとは refactorer エージェントを呼びます。Claude Code が実装したコードを読み、コードスメルを検出しリファクタリングを実施するサブエージェントです。 書いた本人には自分のコードの問題が見えにくいという話を聞き、実装直後に別のエージェントで見るようにしました。 3. レビューに出す # 観点ごとにエージェントを分けています。 エージェント 呼ぶ条件 code-reviewer 3ファイル以上 or 100行以上の変更 feedback-reviewer code-reviewer を呼ぶときは必ず一緒に security-reviewer 認証・認可・API・ユーザー入力・機密データ・DB クエリ・ファイルアップロード・決済・ログ出力・エラー処理に触れたとき code-quality-analyzer 5ファイル以上 or 300行以上、または code-reviewer が構造的な懸念を挙げたとき feedback-reviewer だけ役割が違います。コードの一般的な品質ではなく、過去にぼく自身が指摘した観点に照らして差分を見るエージェントです。観点がどこから来るかは第2部で書きます。 これらエージェントのレビュー結果は自動で保存されます。レビュー系エージェントが完了すると PostToolUse フックが発火します。このフックで persist-review-output.py が出力の全文を .claude/docs/reviews/ に書き出します。 指摘のうち MEDIUM / LOW は、Notion のバックログに積みます。HIGH / CRITICAL は次の Gate C で扱います。 Gate ― 人間が判断する # 前章で挙げた4つの Gate が、それぞれどこで発火するかをまとめます。 Gate 発火する工程 発火条件 ぼくが決めること A TDD 承認済みのテストケースを変えようとした 変更を認めるか B TDD 実装してみて設計方針を変える必要が出た 方針を変えるか。変えるなら ADR を書いてから進める C Review レビューが HIGH / CRITICAL を報告した いま直す / 理由つきで先送りする / リスクを受容する D Commit の直前 書いたコードが既存の ADR と食い違っている 解釈を決め、ADR を書いてからコミットする B と D は、いずれも ADR をコミット前に書くことにしています。 --> Gate D の実例 ADR には「エラー処理は errorMiddleware で行う」と書いてありました。リクエスト処理の途中に挟むミドルウェアで、ハンドラが投げた例外を捕まえる想定です。ところが実装してみると、バックエンドに使っている Hono では、ミドルウェアの中で try/catch してもハンドラの例外を捕まえられませんでした。フレームワークが内部で例外を処理してしまうためです。捕まえるには、Hono が用意している app.onError() という別の登録口を使う必要がありました。 ここで ADR の「errorMiddleware」を「 app.onError のことだ」と読み替えて進めることもできます。ただそれをやると、実装に存在しない仕組みが ADR に書かれたまま残ります。そこで新しい ADR を書いて、この技術制約と、以降は「errorMiddleware」を app.onError のハンドラとして読む、という解釈を残しました。元の ADR の本文は書き換えていません。 4. 検証する # コミット前に、エージェントが触ったパッケージで4つを通します。 npm run typecheck npm run depcheck npm run lint:biome npm run lint:eslint npm run test npm run test:integration 憲法の2つ目で書いた仕掛けが実際に落ちるのは、ここです。 as UserId のキャストは lint で、対応表の書き漏れは typecheck で落ちます。エージェントが規約を読み落としていても、違反はこの段階で止まります。 5. コミットする # コミットは1つの機能で1回ではなく、区切りがつくたびに積みます。それぞれ /commit を実行して Conventional Commits 形式のメッセージを作るようにしました。 Gate B / D で設計判断が動いていた場合、その判断を含むコミットより前に ADR が書かれています。図で docs/adr/ に「コミット前に起票」と注記したのはそのためです。 ADR はコミットの成果物ではなく、コミットの前提条件という位置づけにしました。 6. push して CI に渡す # ここで実行環境が変わります。図で色が変わっているところです。 CI は、落ちたらマージできないものと、落ちないものに分けています。 系統 ワークフロー 中身 挙動 強制系 back-ci lint / typecheck / 依存関係 / 単体テスト / 統合テスト 落とす front-ci lint / typecheck / テスト(VRT 含む) 落とす secret-scan gitleaks による全履歴の秘密情報スキャン 落とす dependency-audit 依存の脆弱性の判定漏れ・古い判断の検出 落とす 計測系 code-metrics qlty メトリクス / カバレッジ 落とさない。git notes に蓄積し、PR に差分コメント dependency-audit が落ちたときは ローカル環境で dependency-analyzer エージェントを呼びます。 脆弱な関数に実際に到達するか、受容してよいかという、機械では決められない2点を判断するサブエージェントです。 工程の合間で、勝手に走るもの ― フック # ここまでの工程とは別に、決まった瞬間に自動で走るものがあります。 瞬間 フック やること ぼくが発言したとき capture-turn 発言を逐語で索引に残す(第2部で使う) ファイルを編集したとき config-protection lint / formatter の設定ファイルへの編集をブロックする lint-on-save / typecheck-on-save 編集したファイルを整形し、型検査する console-log-warn 残った console.log を警告する feedback-validate 観点表への手編集を検知する(第2部参照) エージェントが完了したとき persist-review-output レビュー出力を保存する feedback-prune 使われなくなった観点を観点表から落とす(第2部参照) エージェントがやろうと思ってやるのではなく、その瞬間が来たら実行される仕組みです。 なお、 config-protection だけ性格が違います。他は記録か整形ですが、これは編集そのものを止めます。 lint の設定を緩めれば規約違反は消えるので、その手を最初から使えないようにしました。 【第2部】どの情報を、どこに置いているか # ここまでが冒頭の図の範囲です。図には「何があるか」までを描きました。ここからは、それぞれに何を書いていて、なぜそこに置いたのかを解説していきます。 Claude Code に常に読ませておける量には限りがあります。全部を CLAUDE.md やルールに書くと、読み込むだけで文脈が埋まり、肝心の指示が埋もれます。 そこで、情報ごとにいつ読まれるべきかを決めて、置き場所を分けました。 置き場 読まれるタイミング 置いているもの CLAUDE.md / .claude/rules/ 常に 索引、常に効く制約と事実 .claude/skills/ 該当する作業のときだけ 手順、プロジェクト固有の方法論 lint / 依存検査 機械が常に 守らせたい規約 docs/ 索引から引いたときだけ ADR、設計書、要件、マイルストーン、技術ノート .claude/agents/ 呼ばれたとき 役割、不変条件、出力の形 スクリプト エージェントが結果を読む 計算 criteria.yaml レビューのとき ぼくの過去の指摘 フックも置き場のひとつですが、第1部で書いたのでここでは省きます。 憲法の1つ目で、書き残したいことを ADR / コード / 設計書 / 書かない に振り分ける4つの問いを書きました。Claude Code 向けの資材にも、同じ問いを当てています。 そのため、コードや型から再構成できることは書かない。コードが変わるたびに変わることも書きません。 常に効かせるもの ― CLAUDE.md と .claude/rules/ # CLAUDE.md は149行で、中心は第1部で出てきた「触る領域 → 必読 ADR」の索引です。ADR の中身は書かず、引き方だけを持たせています。 ほかには、本番環境や機密の所在といった、どの作業でも踏み越えてはいけない境界を書いています。 一方、 .claude/rules/ には9本のファイルがあり、どれも 16〜75 行です。 ファイル 持っているもの development-workflow.md 工程の順序と、どの工程でも効く制約 coding-principles.md 戻り型注釈の必須化など、全ファイルに効く原則 code-smells.md 過去のレビューで指摘が多かったコードスメル7つ error-handling.md エラーを例外で投げてよい5つの場面(それ以外は戻り値で返すというルール) security.md リソースの所有者検証など、コードを書くときに常に守るセキュリティ上のガードレール testing.md 本番コード側の禁止と、テスト環境の事実 tsdoc.md コメントの書き方のうち、既存コードを真似しても決まらない事項(ADRドキュメント内 の内部記号を書かない、など) language.md 識別子・文字列・コメントを何語で書くか codex-delegation.md Codex に相談してよい条件(最近はほとんど使っていません) ルールに置くのは、次の3つです。 事実 禁止事項 真正ソースへの道案内 それ以外はルールに記載せず、それぞれの置き場に任せています。 ルールに書かないもの 置き場 例 手順 スキル 工程の進め方( feature-implementation ) ADR に書いてあること ADR レイヤー別の複雑度の閾値 コードを読めば分かること 実際のコード(真正ソース) エラー型の作り方、コメントの書式 コードを真正ソースにしているものは、Claude Code に近くのファイルを真似させています。 該当する作業のときだけ読み込むもの ― .claude/skills/ # スキルは説明文で示した作業に当てはまったときだけ読み込まれます。常には読み込まれないので、手順のように長くなるものを置いています。 中身は2種類に分かれます。 種類 書いていること 主に使う側 例 手順 「この作業は毎回この形でやる」という進め方 Claude Code 本体 feature-implementation / persist-plan-output / persist-review-output / notion-task extract-learning / consulting-codex / observe-metrics 方法論 一般論を、このプロジェクト向けに上書きする箇所だけ サブエージェント refactoring / unit-testing / web-vulnerability-catalog ルールとスキルのどちらに置くか # ひとつの決まりをルールとスキルのどちらに書くかで迷うことがあります。たとえばテストに関する決まりは、テストのスキルに書くのが自然に見えます。 ただ、2つは Claude Code に読み込まれるタイミングが違うので、置く場所を間違えると必要な場面で Claude Code の目に入りません。 種類 読み込まれるタイミング 例 ルール どんな作業をしていても常に testing.md スキル スキルの説明文に書いた作業に当てはまったときだけ unit-testing (テストを書くときだけ) そこで、どちらに置くかは次の3つの問いで決めました。 近くのファイルを真似すれば同じように書けるか → 書けるなら、どちらにも書かない その決まりが破られるのは、スキルが読み込まれている作業の最中か → そうでないなら、ルールに置く 破られても、テストや lint で気づけないか → 気づけないなら、ルールに置く 例えばテストに関する決まりを2つ、この問いに当てるとこうなります。 決まり 破られるのはいつか テストのスキルは読み込まれているか 置き場 本番コードにテスト都合の分岐を足さない 本番コードを書いている最中 いない ルール( testing.md ) エラーのメッセージ文言でアサートしない テストを書いている最中 いる スキル( unit-testing ) どちらもテストの決まりですが、置き場を分けました。 「本番コードにテスト都合の分岐を足さない」が破られるのは、本番コードを書いている最中です。そのときテストのスキルは読み込まれていないので、スキルに書いても Claude Code の目に入りません。そこでルールに置きました。 文書に書かず、検査に持たせるもの # lint や依存検査で気づけるものは、ルールには書かず、検査の設定として置いています。 憲法の2つ目に書いた「書けなくする」を、型で表現できないものに広げた形です。 依存方向 ― dependency-cruiser (15ルール) ルール 禁止していること domain-no-infrastructure Domain から Infrastructure への依存 application-no-infrastructure Application から Infrastructure への依存 hono-only-in-interface-http Hono を HTTP 層の外で使うこと no-cross-context-domain コンテキストをまたいだ Domain の参照 ほかに循環依存・孤立ファイル・Port 層の依存制約など、あわせて15本。ADR 0002 で決めたレイヤー構造を、そのままルールに落としてあります。 戻り値の型 ― 自作の ESLint ルール UseCase と Port(Repository の interface)の戻り値の型が Result になっているかを検査します。 既製の ESLint ルールには、戻り値の型を特定の型に限定するものがなかったので自分で書きました。 依存方向の検査も戻り値の型の検査も、違反したコードは検査で落ちます。そのとき Claude Code がルールやスキルを読み込んでいたかどうかは関係ありません。だから、同じ決まりをルールファイルに書いておく必要もありません。 索引から引くもの ― docs/ # docs/ は Claude Code が常には読まず、必要なときに CLAUDE.md の索引から辿って開く場所です。中身は5つに分かれます。 置き場 中身 どこから引くか docs/adr/ 決定の記録 索引の「触る領域 → 必読 ADR」 docs/design/ 設計書(全体構成、デプロイ手順など) 同じ索引の「関連設計書」 docs/requirements/ 要件定義書 何を作るかを確かめるとき docs/milestones.md Phase の定義と進捗 索引の「Phase の現在地」 docs/learning/ 技術の学習ノート エージェント定義やスキルから名指しで ADR と設計書に何を書くかは、憲法の1つ目で書いたとおりです。ここでは docs/learning/ について書きます。 docs/learning/ は、もともとは Claude Code と作業する中でぼくが理解した技術の仕組みを、あとで読み返せるように残しておく学習ノートの置き場です。 そのうち Fowler のリファクタリングのカタログは、 refactorer エージェントがスメルや手法の名前を確かめるときにも引いています。Claude はスメル名や手法の手順をもともと一般知識として知っているので、毎回は読ませず、名前や手順が出てこないときだけ開くように書いています。 方法論のうち一般論として通用するものは、このように書籍等から借りています。置き場は学習ノートかスキルで、出典は次のとおりです。 知識 出典 docs/learning/refactoring-smells-and-techniques.md Martin Fowler『リファクタリング(第2版): 既存のコードを安全に改善する』オーム社 unit-testing Vladimir Khorikov『単体テストの考え方/使い方』 web-vulnerability-catalog IPA「安全なウェブサイトの作り方 改訂第7版」(2021-03-31) 呼ばれたときだけ効くもの ― サブエージェント # ここでいうエージェントは、Claude Code 本体から呼び出される別のエージェント(サブエージェント)です。プロジェクトで定義しているものは次のとおりです。 エージェント 責務 参照する方法論 code-reviewer 品質・型安全性・保守性 unit-testing / code-quality-metrics / refactoring feedback-reviewer ぼくが過去に指摘した観点に照らす criteria.yaml (観点表) security-reviewer 脆弱性検出 web-vulnerability-catalog code-quality-analyzer 定量分析・優先順位付け scripts/metrics-digest.py (計測スクリプト) refactorer 振る舞いを変えない構造改善 refactoring / simplify / docs/learning/ のカタログ dependency-analyzer 依存脆弱性の到達性と受容の判定 scripts/audit-digest.mjs の出力 右端の列のとおり、エージェント定義そのものには方法論を書いていません。例えば refactorer の定義は、こう始まります。 スメルの分類と手法の手順は一般知識と docs/learning/refactoring-smells-and-techniques.md 、その一般論がこのプロジェクトで通らない箇所は refactoring スキルが持つ。 エージェント定義に置いているのは「役割」と「手順」と「してはいけないこと」で、やり方の知識はスキルとカタログに切り出しました。 いつ呼ぶか(何ファイル以上の変更で呼ぶか、など)も各エージェントの description にだけ書き、ルールには重複させていません。 実例① ― code-reviewer は、どこを見て判断しているか code-reviewer は、レビューの途中でいくつもの置き場を参照します。エージェント定義には、判断の種類ごとにどこを見るかの対応表を持たせました。 判断する対象 見る場所 関数やファイルの複雑度の閾値 ADR(レイヤー別の閾値) エラーの扱い(例外か戻り値か) ADR ログやエラーメッセージに個人情報を出さない ルール( security.md ) コメントの書き方 近くのファイルの実物と、ルール( tsdoc.md ) そのうえで、参照のしかたを2つ決めています。 閾値などの値はエージェント定義に書き写さず、出典を引用する。書き写すと、ADR を改訂したときに追随しないため 参照先どうしが食い違ったら、ADR → コードのコメント → ルールの順に優先する サーブエージェントが利用するスキルは、必要に応じて都度読み込むようにしました。 スキル 読み込む条件 unit-testing 差分にテストファイルが含まれる code-quality-metrics 指摘に数値の裏付けが要る refactoring 重大な指摘の修正案を書く直前 逆に、lint が全件検出するもの( any の使用、未使用の import など)は、チェックリストから外しています。 目で探すものではなく、機械に任せるものとして分けました。 実例② ― dependency-analyzer は、何を引き受けていないか CI の dependency-audit が落ちたときに呼ぶエージェントです。定義の冒頭にはこう書いています。 あなたの役割は脆弱性を 検出する ことではありません。検出と、機械的に導出できる解釈(依存経路、dev/prod、CVSS の分解、修正手段)は scripts/audit-digest.mjs が既に済ませています。あなたが引き受けるのは digest が原理的に答えられない 2 点 だけです。 脆弱性の検出と、機械的に出せる情報(どの依存から入ってきたか、本番に載るか、どう直せるか)はスクリプトが先に出しています。 エージェントが受け持つのは次の2点です。 到達可能性 ― 脆弱な関数に、このコードベースから実際に到達するか 受容の是非 ― 直すか、受け入れるか、先送りするか 到達可能性は次の順で調べるように書いています。 依存の起点が開発用か本番用かを見る。 本番用なら、自分のコードがその依存を直接使っているかを grep で見る。 脆弱性の対象になっている機能を、自分のコードが通るかを読む。たとえば「Hono の CORS ミドルウェアに ReDoS」なら、CORS ミドルウェアを使っているかを確かめる。 なお、脆弱性の重要度(severity)をそのまま優先度として扱わないようにしました。 さらに、エージェント定義にはやらないことも書いています。 スクリプトの出力を、自分で計算し直さない 判断の台帳に自分で書き込まない。貼り付けられる文面を返すところまで。 直せるものについては、到達可能性を調べない。直せば済むので。 --> Information 実例①②に書いたのは、エージェント定義に書いている指示です。 フックなどで強制しているわけではないので、たとえば dependency-analyzer が台帳に書き込むことも技術的にはできます。 計算させないもの ― スクリプト # 計算や機械的に出せる情報はエージェントに計算させず、スクリプトに持たせています。 例えば code-quality-analyzer は、 scripts/metrics-digest.py の結果を読みます。 dependency-analyzer は、 scripts/audit-digest.mjs が出した脆弱性の一覧を読みます。 スクリプトに持たせたのは、テストを書いたうえで出力が正しいかを機械的に確かめられるからです。 値はスクリプトが出し、エージェントにはその値が何を意味するかの判断だけを任せる、という分担です。 ぼくの指摘を溜めるもの ― 観点表 # 第1部に出てきた Review 工程で feedback-reviewer が照らす「観点」は、 .claude/feedback/criteria.yaml という観点表にあります。 中身は、ぼくがエージェントに対して過去にした指摘をパターンに蒸留したものです。例えば1項目はこんな形です。 - id: corrections:260816-115935-1 pattern: 変更した用語・値・コマンド・手順を、同じものを記述する他資材(他 ADR / plan / CLAUDE.md / rules / 設定ファイル)へ波及させず、旧記述を残したまま提示する scope: docs/**/*.md, .claude/**/*.md, CLAUDE.md detection_hint: 差分で置き換えた用語・数値・コマンド文字列を rg でリポジトリ全体に検索し、ヒットした他ファイルが同じ差分に含まれているかを照合する occurrence: 10 ぼくの発言が観点表の項目になり、レビューで使われ、役目を終えて消えるまでの流れはこうです。 capture-turn フックが、ぼくの発言を逐語で索引に残す feedback-distiller エージェントが蓄積された発言を読み、矯正にあたるものを抽出して、既存の項目と同種かを分類する 昇格候補をぼくに提示し、承認した分だけ観点表に書く feedback-reviewer が、Review 工程でコード差分を観点表に照らす 何度照らしても違反が見つからない項目は、 feedback-prune フックが観点表から落とす。 なお、この蒸留は開発サイクルの中では回しません。 セッションの冒頭で feedback-nudge フックが未蒸留の発言があると知らせてきたときにまとめて回しています。 ADR や規約が「決めたこと」を残す仕組みだとすると、こちらは「言ったのに守られなかったこと」を残す仕組みです。 おわりに # はじめにで書いたとおり、この形も通過点だと思います。実際、第1部で書いた Gate は、この記事を書いている間に見直しました。 Gate のたびにぼくの判断待ちで実装が止まり、1つのタスクが何度も止まっていたためです。この記事では、見直す前の形のまま書いています。 ほかにも、まだ答えが出ていないことがあります。 ぼくがタスクのボトルネックになっている :いまは勉強の意味も込めて、成果物に全部目を通しています。そのぶん、タスクがぼくのところで止まります。 チームで開発するときの仕組みがない :ここまでの仕組みは、ぼく1人での開発を前提に作っています。複数人で一緒に開発する場面での仕組みは、まだ整えられていないと感じています。 要求から実装までが、ひと続きになっていない :この記事で書いたのは、要件が決まったあとの開発プロセスです。その上流で顧客の要望・要求を整理して開発の要件に落とし込み、設計して実装するまでを通しでつなぐ仕組みは、まだ整備できていないと感じています。 どれもすぐに答えが出るものではないので、今後も自分で試行錯誤しながら、少しずつ整備していきたいと思います。
 この記事を読んでいただきありがとうございます。アジャイルグループに所属する、藤井智弘です。  デイリー、レトロと続けてきたイベントの話、3つ目はスプリントレビューです。前の2つはチームの内側で完結する会でしたが、レビューはチームの外の人が主役です。だからこそ、形骸化の仕方も、直し方も、少し様子が違います。 質問 #  スクラムを始めた当初は、スプリントレビューに関係部署の方々が(物珍しさもあって)来てくれていました。ところが4ヶ月経った今、参加者はほぼチームメンバーだけです。毎回きちんとデモを準備しているのに、見てもらえない。  先日、発注側の担当者からは、「デモは動画か資料で送ってもらえれば見ておきます。細かい確認は検収のときにまとめてやりますので」と言われました。悪気はなさそうですし、忙しいのも分かります。  でも…、「チーム内デモ会」と化したレビューを、これ以上続ける意味はあるのでしょうか? 回答 #  「資料で送ってください」という一言に、答えが入っています。相手は、レビューを「報告」だと思っている。報告なら、わざわざ会に出なくても資料で足ります。その判断は、相手の立場ではまったく合理的です。問題は、レビューが本当に報告の会になっていることのほうです。  この相談を受けるとき、相談者はたいてい三つのことを考えています。 来なくなったのは、忙しいからか、興味を失ったからか 資料で済むなら、それで済ませてしまってよいのか チーム内だけのレビューにも、やらないよりはましな価値があるのか  順に答えます。 来なくなったのは、忙しいからでも興味を失ったからでもなく、 来ても何も決まらない、と学習された からです。完成したものを見せられ、拍手して帰る会に、2度目から出る理由はありません。     図1: 準備は万端、席は空——4ヶ月後のスプリントレビュー 資料で済むかどうかは、会の中身によります。 報告なら資料で済みます 。しかし「この代替フローは踏襲するか捨てるか」「次はどの業務領域から検証するか」という 判断は、資料を送っても返ってきません 。 チーム内だけのレビューは、残念ながらレビューではありません。検査してくれる相手がいないからです。  処方は出席の督促ではなく、会の再定義です。レビューを「完成報告会」から「次に何を作るかを、外の人の言葉で決める会」に変えれば、 自分の発言が製品を動かすと分かった人は、呼ばなくても来ます 。見るべき場所は二つ。誰に何を決めてもらうか。もらった意見が、その後どうなったか。 「レビューは検収の場ではありません」——その指導、腑に落ちていましたか? #  研修や現場で、「スプリントレビューは承認や検収の場ではありません」「デモをする会ではなく、検査する会です」と教わった方はすくなからずいらっしゃるでしょう。でも、こう思いませんでしたか? 完成したものを見せて確認してもらう。それの何が悪いのか。発注側は検収したがっているのに 毎回ステークホルダーの意見を聞いていたら、計画がぶれたり、おさまらなかったりするのではないか 来ない人をどうやって呼ぶのか。督促のほかに、何があるのか  これらの疑問はもっともです。結論から言うと… 見せることは悪くない、見せて終わることが問題 計画が揺れるのは、まさに目的どおり期待通り。ただし揺れるのはスプリント単位。 人は、督促では来ず、自分の影響が及ぶ場にだけ来続ける。 各々を深ぼる前に、まずレビューがどんな姿になりがちかを眺めてから、順に説明します。 スプリントレビューのあるある #  形骸化したレビューには、いくつかの型があります(レトロの時は”顔”でしたが、深い意味はありません)。どれが自分たちの会に近いか、思い浮かべながら読んでください。 その1:完成報告会型 ——最もよく見る型です。チームが「今スプリントでできたこと」を順に披露し、ステークホルダーは「ありがとうございます」と拍手して帰る。質問が出ても答えて終わり。次のスプリントでやることは、会の前から決まっています。「資料で送ってください」は、この型への、相手からの正直な評価です。     図2: 拍手して帰る会。「資料見ときますねー」 その2:検収型 ——業務系の現場で起きやすい型です。発注側の担当者や、内製でも事業部門の担当者が「納品確認」のつもりで来ます。見るのは「言った通りにできているか」であり、「これでいいのか、次はどうするか」は議題に上がりません。第6回で、発注側や管理職がデイリーに顔を出す「顧客報告型」を見て、判断に関わりたい人の席はレビューだと書きました。席を用意しても、検収型のままでは、デイリーの報告会がレビューに引っ越しただけです。 その3:全員集合型 ——「関係者各位」への一斉招待で開かれる型です。「あなたが来る理由」を誰にも伝えない招待は、全員に薄く扱われます。初回は物珍しさで集まり、数回で「出なくてもいい定例」に分類されます。 その4:判子型 ——チームが「これでよろしいでしょうか」と承認を取りに行く型です。ステークホルダーの役割は「はい」と言うことだけで、選択肢は提示されません。承認は下りますが、方向は何も動きません。検収型が発注側の作法だとすれば、判子型は受注側の作法です。 その5:チーム内デモ会型 ——その1〜4の行き着いた姿です。外の人が来なくなり、チームだけでデモをして終わる。記録が残る点は悪くありませんが、検査する相手がいません。質問者のチームは、いまここにいます。 五つの型の底にあるもの #  五つの型に根を張るのは、**「レビューは成果物を見せる場だ」**という前提です。もしそうなら、見せられれば会は成功であり、来ないならデモの見せ方を磨けばよく、承認がもらえれば完了、ということに(素直に)なります。五つの型には、何もおかしなところはありません。素直に到達点に至ったのです。  そして「見せるだけの場」なら、資料で済む。相手の言い分は、この前提の上では完全に正しいのです。  さあ、三つの疑問に戻りましょう(※この記事の展開もパターン化してきたね)。 完成したものを見せて確認してもらうことの何が悪いのか 。  悪くありません。動くものを見せるのはレビューの出発点です。 問題は、見せた後に何もしないこと です。ステークホルダーが動くものを前にして提供できるのは、「言った通りか」の確認ではなく、チームが持っていない情報——現場の実感、市場や他部署の状況、次に困ること——です。それを聞かずに終わる会は、チームにとっては報告、相手にとっては検収でしかなく、どちらにも次を決める材料が残りません。 検収(完成の定義や受け入れ条件との突き合わせ)は必要な仕事ですが、レビューの中でやることではありません 。突き合わせは会の前に済ませ、会では「これを踏まえて次をどうするか」を話す。この分担があると、レビューは検収に食われずに済みます。 毎回意見を聞いていたら計画が揺れるのではないか 。  揺れます(もちろん)。そして、それこそが目的です。スプリントごとに動くものを見せるのは、方向を早く修正するためであり、方向が一度も変わらないレビューは、何も検査していないのと同じです。ただし、揺らすのはスプリント単位です。会の中で出た意見は、その場で開発チームの作業に割り込むのではなく、プロダクトバックログの並び順の変更として受け止め、次のスプリントの計画で拾う。この受け皿があれば、意見は「計画を壊すもの」ではなく「計画を更新する材料」になります。第4回の言い方をすれば、スライスの入替はこの受け皿の中で日常的に起き、ユースケース一覧の足し引きだけが、発注側の明示的な判断として手続きに載ります。 来ない人をどうやって呼ぶのか 。  督促ではもう来ないでしょう。 人が会議に出続けるのは、その場に自分の影響が及ぶときだけ です。読者の皆さんも、発言の機会も必要もない報告会議への出席は、できれば減らしたいはずです。  初期のレビューに人が来ていた理由と、来なくなった理由は、実は同じものです。始めたばかりのころ、ステークホルダーは「新しいやり方で何が起きるのか」を確かめに来ます。数回見て、こう学習します。デモは整っている、質問すれば答えてくれる、しかし自分が何を言っても言わなくても、次のスプリントでやることは変わらないらしい…。この学習が完了した時点で、レビューはカレンダーの中で「出なくてもよい定例」に分類され、「資料で送ってください」が口から出ます。これはステークホルダーの怠慢ではなく、合理的な行動です。  呼ぶ方法はひとつしかありません。「来ると、何かが自分の言葉で決まる会」にすることです。  ここで、更改案件を扱った第3〜5回を思い出してください。あの3本で見たとおり、更改案件には、発注側にしか決められないことが毎スプリントのように出てきます。検証で見つかった「謎の挙動」を踏襲するか、直すか、捨てるか。あるユースケースの検証の深さを、最小集合で止めるか、主な代替フローまで広げるか。次にどの業務領域から現物を動かすか。どれも、動くものを前にして、業務を知っている人が決める判断です。つまり更改案件のレビューには、議題が足りないはずがありません。それでも「報告会」になっているなら、これらの判断は、レビューの外——メールや別の定例——で決まっているか、誰も決めないまま開発者の推測で埋められているかのどちらかです。前者ならレビューに戻せばよく、後者なら、それこそがレビューで決めるべきことです。  それにしても、 なぜ「見せる場」という前提がこれほど自然に入り込むのか 。多くの組織には、スクラム以前から「進捗報告会」「成果報告会」「検収」の文化があります。動くものを前にして人が集まる、という見た目が似ているので、レビューはその既存の型の上に上書きされ、古い目的——報告する、確認する、承認をもらう——がそのまま生き続けます。業務系の現場では、もう一段根深い事情が絡むこともあります。内製化を進めた事業会社でも、発注先のシステム会社と向き合うときと同じ役割分担と上下関係——いわば「社内外注」の構図——が、ビジネス部門とIT部門の間に持ち込まれているケースが少なくありません。この関係のもとでは、レビューは「対等なパートナーと次を決める場」ではなく「発注先の納品確認」として扱われます。誰かが手を抜いているわけではありません。関係の形が、会の形を決めているだけです。  それでは、本来のスプリントレビューは何をする時間か。透明性・検査・適応に当てはめてみます。   透明性 ——スプリントの成果が、資料ではなく動くもの(インクリメント)として、チームの外の人にも同じように見えていること。スプリントゴールに対して何ができ、何ができなかったかも隠さずに示します。デモはこの透明性の手段であって、目的ではありません。資料で済むのは、ここまでです。   検査 ——見えているものを、プロダクトのゴールと、ステークホルダーが持ち寄った市場・現場の状況に照らして、「このまま進んでよいか」「何が変わったか」を確かめること。ここで初めて、チームの外の情報が入ってきます。資料を送っても、これは返ってきません。   適応 ——検査の結果を受けて、次に何を作るかを決めること。プロダクトバックログを並べ替え、方向を変える。レビューの成果物はデモではなく、フィードバックと、それを踏まえたバックログの方向づけです。  この三つに照らすと、完成報告会型のレビューの正体が見えてきます。透明性(デモ)に会の時間を使い切り、検査も適応も行われていない。報告会型のデイリーと、出しっぱなしのレトロと、同じ構図です。 「成果物を見せる場」から「次に何を作るかを外の人と決める場」へ 。置き換えるのはこの一点です。ここが変わらなければ、処方はどれも効きません。 処方 #  処方は手段です。レビューの進め方にも有識者の提案は多く、どれを選んでもかまいません。ただ、なぞって「できてます」感を得るだけなら、判子型と変わりません。レビューへの評価は出席者の数ではなく、レビューを機にバックログが動いた回数で判定してください。 まず、レビューでバックログが動いた回数を数える  手を打つ前に、まず数えます。直近3〜4回のレビューについて、その会を機にプロダクトバックログの並びや中身が変わった件数を数えてください。ゼロが並ぶなら、レビューは検査も適応もしていない、という事実が手に入ります。「ステークホルダーが来ない」という嘆きは、「過去4回のレビューで、バックログが動いた回数はゼロでした」という報告に変えたほうが、次の処方が通ります。レトロで前回Tryのその後を数えたのと同じ理屈です。 意思決定の場として再設計する  アジェンダの中心を「デモ」から「決めること」に移してください。毎回1つでいい、ステークホルダーの判断を要する問いを用意し、招待状にそれを明記します。「今回のレビューで決めたいこと: ◯◯」の一行があるだけで、会の性質は変わります。更改案件なら問いには事欠きません。「検証で見つかったこの挙動は、踏襲しますか、直しますか」「請求書発行の検証は最小集合で止めてよいですか、月初の一括発行まで広げますか」「次に動かすのは入金照合と月次締めのどちらからにしますか」。新規開発なら「この機能はこの形でリリースしてよいか」「AとBのどちらの方向で次を進めるか」「この仮説は現場の実感と合っているか」等々…  冒頭はスプリントゴールに対して何ができたかから始め、その上で問いを置く。そして決めたことは議事録やバックログに記録し、組織に正式な変更手続きがあるなら、そこへつないでください。口頭の合意のままにしないことが、この運用を続ける肝になります。  なお、その場で決めきれない議題(稟議事項など)は、「レビューでは方向の推薦までを決め、正式決定は既存の手続きに乗せる」と明示しておくと、会議体の位置づけや権限を巡る混乱を避けられます。  図3: 動くものを前に、外の人の言葉でバックログが動く 問いを事前に配る  当日その場で「ご意見は?」と聞かれても、人は答えられません。デモで見せるもの、判断してほしいことを事前に短く共有し、考えてくる時間の余裕を提供してください。フィードバックの質は、準備時間に比例します。「資料で送ってください」と言われたら、この事前共有をその資料にしてしまえばよいのです。 送るのは報告ではなく、「当日、これを決めてほしい」という問い です。 名指しで、理由つきで招待する  一斉招待をやめ、今回の議題に関係する人を絞って呼びます。「経理部の◯◯さん、今回の帳票出力はお手元の月次業務に直結するので、実際の画面で確認してほしい」。呼ぶ理由を言えない相手は、今回は呼ばなくていい相手です。少人数でも、影響力と意味のあるフィードバックが出る会のほうが、大人数の沈黙の場よりも、何倍も価値があります。第6回で、デイリーに顔を出す発注側や管理職に「判断に関わりたいなら、その場所はレビューです」と席を用意する話をしました。その席に呼ぶときも同じで、「この判断をあなたにしてほしい」と理由をつけて呼びます。 検収は会の前に済ませる  検収型に寄っている現場では、受け入れ条件との突き合わせを、レビューの前に担当者と済ませておきます(受け入れ条件が事前に明文化されていれば、大部分は条件との突き合わせで済みます)。レビュー当日は「言った通りできているか」の確認ではなく、「これを踏まえて次をどうするか」に時間を使う。このようなステークホルダーの関わり方を最初に合意しておくと、レビューが納品確認の時間に食われなくなります。 持ち帰れる一枚を渡す  発注側の担当者は、レビューの後、社内に報告する仕事を抱えています。「資料で送ってください」の半分は、その報告用の材料が欲しい、という意味です。第3回で、「検証済みリスト」「残リスク」「業務影響順」という語彙を担当者と共有する話をしました。レビューの終わりに、今回の検証で検証済みリストがどこまで伸び、何が決まり、残リスクがどう変わったかを一枚にして渡してください。担当者はそれをそのまま社内に持ち帰れます。 判断は会で、報告は一枚で 。この分担ができると、「資料で」という要望は消えます。 フィードバックの「その後」を見せる  もらった意見がどうなったかを、次回レビューの冒頭で必ず報告してください。「前回の◯◯さんの指摘は、この形でバックログに入り、今回のデモに反映されています」「△△のご要望は検討の結果、今期は見送りました。理由は…」。採用でも見送りでもいい、自分の以前の発言が追跡されていることが伝われば、発言は「言い損」ではなくなります。これが次回の出席の最大の動機になります。  この再設計は、チームとステークホルダーの共同作業です。チームは問いを用意して意見の行方を追跡し、招かれた側は判断を持って席に着き、自分の意見がどうなったかを次回確かめる。この往復が回り始めると、レビューは呼ばなくても人が来る会になります。「社内外注」の上下関係を対等な協働に組み替えていく、地道な働きかけでもあります。 なぜこの質問は30年繰り返されるのか #  「動くソフトウェアを定期的に見せる」という形は、導入初日から真似できます。しかし「見せたものを材料に、外の人と一緒に次を決める」という中身は、チームの外の人間関係と組織の意思決定様式に踏み込む必要があり、格段に難しい。だから多くのチームが、まず形から入り、形のまま止まります。そして数ヶ月後、空席の並んだ会議室で同じ疑問を抱くのです。  レビューの出席者数は、チームと組織の接続の健全さを示すバロメータです。空席が続くなら、それはチームの外との対話が途絶えかけているサインであり、デモの出来栄えより先に直すべきものがあります。今回のレビューで、外の人の言葉で何かが決まり、バックログが動いたか——この一つの問いが、スプリントレビューを測る計器になります。  え?やってみたけど、ステークホルダー側がどうしても動いてくれない? ムムム、そんな環境での戦い方も、いずれこの連載のどこかで扱いましょう。 次回: 「ストーリーポイントの人日換算を上司に求められます」
 この記事を読んでいただきありがとうございます。アジャイルグループに所属する、藤井智弘です。  人生にはときに「反省すること」も必要です。しかし、それは、常に「前にすすむために」で、なければなりません。  デイリースクラムの次は、レトロスペクティブへの質問を扱います。 質問 #  KPT(Keep=続けること・Problem=問題・Try=次に試すことの3観点で振り返る手法)でレトロスペクティブを4ヶ月続けています。PもTryもたくさん出ますし、出たTryはだいたい実行しています。それなのに、チームが良くなっている実感がありません。  中身を見ると、Pは「◯◯の設計ドキュメントがない」「△△のテストが書かれていない」のような作業の抜けが多く、Tryは「◯◯ドキュメントを作る」。一方で「情報の伝達漏れがあった」のようなPには、毎回「コミュニケーションを改善する」というTryが付きます。  これって改善なのでしょうか?  先日、上司にこう言われました。「『◯◯ドキュメントがない』が毎回出てくるなら、最初に設計書を全部書くウォーターフォールのほうがいいんじゃないか」。私は答えられませんでした。違う気がするのに、何が違うのか言えなくて、もやもやしています。 回答 #  最後のもやもやから片づけましょう。上司の指摘は、半分当たっています。ウォーターフォールには「設計工程で設計書を書く」という決まりがあり、その決まりがある限り「設計書がない」は起きにくい。つまり上司は、「あなたたちのチームには、 抜けを防ぐ決まりがない 」と言っているのです。これは正しい観察です。ただ、そこから「だからウォーターフォール」に飛ぶのは早い。決まり事は、工程表から引っ張らなくても、自分たちで作れます。そして、自分たちで作る場が、レトロスペクティブです。もやもやの正体は、「レトロが決まりを作る場として働いていない」ことを、上司が外から言い当てたことにあります。  残りの二つも、同じところに根があります。「PもTryも出て、実行もしている」のに実感がないのは、出た数と実行した数は増えても、チームのやり方が一つも変わっていないからです。「◯◯ドキュメントを作る」は、ドキュメントを一本増やしますが、次のスプリントには「△△ドキュメントがない」が出ます。消えたのは一つの事象(インスタンス)で、その根底にある同様な事象を繰り返し生んでいる仕組み——書く役割も、書くタイミングも、完成の基準も決まっていないこと——は、そのままだからです。これは 改善ではなく作業 です。逆に「コミュニケーションを改善する」は、来週誰が何を変えるのかが入っていないので、実行のしようがありません。こちらは 改善ではなく願望 です。質問者のレトロは、作業と願望の間にあるはずの「やり方の変更」を、一度も決めていません。   レトロの仕事は、思わしくない出来事を一つずつ消すことではなく、 その出来事を繰り返し生んでいるチームのやり方を、一つ、小さく、次のスプリントで試せる形で変えること です。良いことについても同じで、 今回はたまたまうまくいったことを、意図して起こせる形に変える ことです。  以下、Tryの「粒度」と「置き場」の二つを手がかりに、質問者のレトロを組み立て直します。      図1: 出しては片づけ、また同じ付箋——列挙会と再放送 「Tryは絞って、必ず実行しなさい」に、引っかかりはありませんでしたか? #  研修では、こう習います。 レトロは毎スプリント行う。 Tryは少数に絞り、必ず実行する。  質問者はこれを守っていて、それでも効かない。  守っている人ほど、こんな疑問を持つはずです。 PもTryも、たくさん出るのは良いことではないのか。出ないチームより、よほど健全では Tryの実行率は高い。それでも駄目だと言うなら、何を見て「機能している」と言えばいいのか 上司の「それならウォーターフォール」に、どう答えれば正しかったのか  私の答えはこうです。出る量は健全さの証ですが、レトロの成果ではありません。見るべきはTryの実行率ではなく、やり方が変わった数です。そして、上司には「おっしゃる通り、決まりがありません。来スプリントから、自分たちで一つずつ決めます」と答えればよかった。  理由はこの先で、まず効かないレトロのあるあるを眺めてから説明します。 レトロスペクティブのあるある #  効かなくなったレトロには、決まった顔があります。質問者のチームがどれに近いか、思い浮かべながら読んでください。 その1:列挙会型 ——Pに作業の抜けが並び、Tryでその一つひとつを「やる」に変え、あとの時間は担当の割り振りに消える顔です。終わった直後は片づいた感じがしますが、翌スプリントには別の抜けが(ときに同じ数だけ)並びます。 その2:再放送型 ——「コミュニケーションを密に」「テストを増やす」「早めに相談する」のような、 誰も反対しないが誰も着手できないTry が、スプリントをまたいで同じ文言で現れる顔です。実行したつもりでも、何をもって実行したと言うのかが曖昧なので、消えません。 その3:消化率型 ——付箋の枚数とTryの消化率がレトロの指標になっている顔です。たくさん出て、たくさん片づく。数字は毎回良好、でもチームの働き方は何も変わらない。質問者のチームは、おそらくここにいます。 その4:反省会型 ——付箋の主語が「誰が」になっている顔です。「◯◯さんの実装が遅れて」「△△の確認漏れで」。話し手の視線はチームメイトではなくリーダーに向き、発言は無難なものに寄っていきます。前回のデイリーで見た、宛先が管理者に向いた報告会と同じことが、レトロで起きている形です。 その5:手法遍歴型 ——KPTからFun/Done/Learnへ、次は…、と「出し方」を変え続ける顔です。新しい手法は数回は新鮮で、確かに違う付箋が出ます。しかし出した後の扱いが同じなら、数回で元に戻ります。     図2: 出し方を変えても、出した後が同じなら数回で戻る 五つの顔に共通する思い込み #  五つの顔はばらばらに見えて、同じ思い込みから出ています。**「出て、実行されれば、レトロは機能している」**という思い込みです。そう思っていると、たくさん出れば成功で、実行率が高ければなお良く、出なくなったら手法を変えればよい、という判断が次々に導かれます。五つの顔は、その判断を忠実に積み上げた先の姿です。  この思い込みを前にして、先ほどの三つの疑問に戻ります。   たくさん出るのは良いことではないのか ——良いことです。出ないチームには、出させる工夫が先に要ります。ただし、出ることはレトロの入口であって、出口ではありません。付箋は、繰り返し起きていることを見つけるための材料です。「◯◯ドキュメントがない」は、それ単体では一つの出来事にすぎません。同じ種類の抜けが過去3回のレトロで何回出たかを数えたとき、初めて 仕組みの問題 ——「書く役割と時点が決まっていない」など——が見えてきます。材料が多いのは良い。材料のまま終わるのが問題です。   実行率が高くても駄目なのか ——実行したTryの粒度によります。Tryには粒度があって、細かすぎると作業、粗すぎると願望になります。「◯◯ドキュメントを作る」は細かすぎて、出来事を一つ消すだけ。「コミュニケーションを改善する」は粗すぎて、来週の誰の振る舞いも変えない。改善と呼べるのは、その間——「設計変更の連絡は受付チャンネルに集約して当番が起票する」「完成の定義に『誰向けの何』を書き、レビューで確認する」——のように、チームのやり方そのものを一つ変えるものです。見分け方は一つで、「それをやったら、同じ種類のPが次のスプリントで出なくなるか」。実行率が高くても、作業と願望しか実行していなければ、やり方が変わった数はゼロです。   上司にどう答えればよかったか ——「決まりがない」という観察には、そのまま同意してよいのです。違うのは、決まりをどこからもらうかです。ウォーターフォールは、工程表と開発標準が「いつ、誰が、何を書くか」を最初に決めてくれます。スクラムは、第2回で見たとおり「どう作るか」を定義していません。その代わり、やり方を決める権限と責任をチームに渡していて、決める場がレトロです。レトロが決まりを作れていないチームは、工程表から決まりをもらっていたときより頼りなく見えます。上司の目にはそう映っていて、それは正しい。ただし、ウォーターフォールに戻れば解決するわけでもありません。第3回で見たように、工程で書かれた設計書は、書いた時点の現物しか写していません。「ドキュメントがない」が「ドキュメントはあるが古い」に変わるだけで、困りごとの中身は変わらない。そして工程表の決まりは最初に一度しか作れませんが、レトロの決まりはスプリントごとに直せます。だから答えはこうなります。「おっしゃる通り、いまのレトロは決まりを作れていません。設計工程に当たるものを、完成の定義として自分たちで定義します。来スプリントから、抜けを種類で数えて、仕組みを一つずつ改善していきます」。上司の指摘は、外からの検査として、むしろ有用だと言えます。   なぜ「出て、実行されれば機能している」と思い込むのか ——導入初期には、それで本当に機能するからです。始めたばかりのチームには「あからさまな無駄」が転がっていて、拾えば効く。出来事を一つ消すだけで、チームは目に見えて良くなる。その成功体験が、「出す・実行する」をレトロの型として固定します。無駄を拾い尽くし、残った問題が仕組みに根ざしたものになった頃、同じ型は空回りを始めます。もう一つ、多くのメンバーはスクラム以前に「反省会」の作法を身につけています。問題を挙げ、担当を決め、「以後気をつけます」で締める作法です。誰かが怠けているわけではなく、新しい器に古い作法が注がれているだけです。  ではレトロは本来、何をする時間か。前回、スクラムの基本は経験主義で、その柱は 透明性・検査・適応 の3つだと書きました。デイリーが「今日の動き」を毎日回す小さなループなら、レトロは「チームのやり方」をスプリントごとに回すループです。   透明性 ——このスプリントで何が起きたかが、意見ではなく事実として見えていること。ゴールの達成状況、持ち越しや割り込みの件数、待ち時間、そして「同じ種類のPが何回出たか」。付箋は、この事実の上で読まれて初めて意味を持ちます。   検査 ——事実を、スプリントゴールと完成の定義に照らして、繰り返し起きているものと、それを生んでいるやり方を見つけること。単発の抜けと、仕組みの問題を仕分けるのはここです。   適応 ——やり方を一つ変えると決め、担当者と完了条件をつけて、次のスプリントの計画に積むこと。変更は仮説で、効いたかどうかは次のレトロで確かめます。  この三つに照らすと、質問者のレトロで何が抜けているかが見えます。出来事の透明性はあるが、繰り返しを見つける検査がなく、やり方を変える適応が計画に積まれていない。レトロを「出来事を消す会」から「やり方を一つ変える会」に置き直す。本稿の処方は、そのための手順です。  なお、スクラムガイド2017には「前回のレトロスペクティブで特定した改善を、少なくとも1つスプリントバックログに含める」という一文がありました。スクラムガイド2020でこの一文は削除されましたが、レトロの成果物は意見ではなく計画に積まれたやり方の変更だ、という趣旨は、いまも有効な指針だと私は考えています。 処方 #  ここから挙げるのは、どれも道具です。以前にも指摘させていただきましたが、形だけなぞって「できている感」で満足していると、手法遍歴型がひとつ増えるだけです。試したあとは、やり方が変わった数と、同じ種類のPが減ったかどうかで、効き目を確かめてください。 まず、過去3回のPを種類で数える  手を入れる前に、現状を数字にします。直近3回のレトロのPを、「ドキュメント系」「テスト系」「伝達漏れ系」のように種類で束ね、同じ種類が何回出たかを数えてください。あわせて、その間に出たTryのうち、チームのやり方(手順・基準・経路・道具)を変えたものが何個あったかも数えます。多くのチームで、前者は「3回とも」、後者は「ゼロ」になります。この二つの数字があるだけで、「レトロが機能していない気がする」は「同じ種類のPが3回続き、やり方の変更はゼロだった」という事実に変わり、上司への説明もそのままできます。 (1)単発と繰り返しを仕分ける  付箋が出そろったら、議論の前に全員で仕分けます。初めて起きた単発の抜けは、議論せずバックログへ直行。レトロではもう扱いません。繰り返し起きているもの、仕組みに原因がありそうなものだけを議題に残します。「これは初めてですか、前にもありましたか」をファシリテーターの最初の口癖にしてください。 (2)Tryの粒度を揃える  議題に残ったものからTryを作るとき、細かすぎるものは一段粗く、粗すぎるものは一段細かくします。「◯◯ドキュメントを作る」なら、「それがなかったせいで誰が何に困ったか」「それはいつ誰が作ることになっていたか」と問い、完成の定義やチェックリストの変更に引き上げる。「コミュニケーションを改善する」なら、「どの情報が、誰から誰へ、いつ届くべきだったか」「その情報はいまどの経路を通っているか」と問い、経路の変更に引き下げる。前回見た兼務メンバーの時間宣言は、「コミュニケーションを密に」を正しい粒度に下ろした一例です。ちょうどいい粒度のTryは、いつ・誰が・何を、が言えて、やったかどうかが判定できて、同じ種類のPが減ったかで効き目を測れます。 (3)Tryは1個に絞り、バックログに積む  粒度を揃えたTryは、1個に絞ります。5個の「そのうちやる」より、1個の「次スプリントで必ずやる」。選んだ1個には担当者と完了条件を付け、プロダクトバックログに正式な項目として積み、次のスプリントの計画で拾います。改善を「レトロの中の善意」から「計画された仕事」に格上げすること。これが再放送を止める最重要の一手であり、レトロの「適応」そのものです。     図3: 変えるのは出来事ではなく、やり方の歯車 (4)レトロの冒頭は「前回Tryのその後」から  アジェンダの先頭を固定します。前回のTryは実行されたか。同じ種類のPは減ったか。減っていないなら、粒度が外れていたのか、実行されなかったのか。この5分があるだけで「出しっぱなしにならない」ことがチームに伝わり、改善の実行率と、やり方が変わった数は、ここで毎回更新されていきます。 (5)SMの問いかけを、出来事からやり方へ向ける  メンバーの視線は、放っておくと出来事に向きます。スクラムマスターの仕事は、問いで視線をやり方に移すことです。使える問いをいくつか挙げます。 「これは初めてですか? 過去3回のレトロで、同じ種類のPは何回出ましたか?」 「それがなかったせいで、誰が、何に困りましたか?」 「それを作る手順は、完成の定義やチェックリストのどこに書いてありますか?」 「それは来週、誰が、何を、今までと違うようにやることですか?」 「同じ状況に別の人が置かれていたら、違う結果になっていましたか?」 「今回うまくいったのは、たまたまですか、それとも私たちが何かを変えた結果ですか?」 「これをやると、同じ種類のPは次のスプリントに出なくなりますか?」  逆に、「なぜやらなかったのですか」「誰の担当でしたか」「次からどうしますか」は避けてください。答えが「気をつけます」にしかならず、視線を出来事と人に戻してしまいます。 (6)やり方の変更を、上司に見える形で示す  「ウォーターフォールのほうがいいのでは」と言われたチームへの処方です。完成の定義の改定、受付経路の変更、チェックリストの追加——レトロで決めたやり方の変更を一覧にし、「同じ種類のPが何回から何回に減ったか」を添えて見せてください。工程表の代わりに、チームが自分で決まりを作っていることが見えれば、上司の問いは「決まりがないのでは」から「次は何を決めるのか」に変わります。上司の側も、設計書の枚数ではなく、この一覧と減り方を見てください。チームが決まりを作る力は、そこに現れます。 (7)それでも煮詰まったら、視点を変える  (1)〜(6)が回り始めた上でなら、テーマ型レトロ(今回は品質だけ)、タイムライン振り返りなど、手法の変更が効いてきます。順序を間違えないでください。「出した後」の扱いが同じまま「出し方」だけ変えても、数回で元に戻ります。 なぜこの質問は30年繰り返されるのか #  長いあいだ、「いつ、誰が、何を書くか」という決まりは、工程表と開発標準が外から与えてくれるものでした。チームが自分でやり方を決める、という仕事は、多くの現場で経験されたことがありません。スクラムはその仕事をチームに渡しましたが、だからといって渡された側が十分に訓練されているわけではありません。だから最初は「あからさまな無駄」を拾って成功し、拾い尽くしたあとで、やり方を決める段階に入れずに止まります。そして止まったチームを外から見た人は、決まりを与えてくれる古い方法を思い出し、「それならウォーターフォール」と言う。この問答は、世代が替わるたびに繰り返されるでしょう。  レトロの価値は、出た付箋の枚数でも、Tryの消化率でもなく、チームのやり方が何個変わったかで測られます。今回のレトロで、やり方が一つ変わり、それが次の計画に積まれたか——それだけ確かめれば、レトロが生きているかどうかは分かります。この基準さえ持てば、レトロにネタ切れはありません。チームが完璧になるまで、変えるべきやり方は無限に見つかるからです。そして完璧なチームには、少なくとも私は、まだ会ったことがありません。 次回: 「スプリントレビューにステークホルダーが来なくなりました」
はじめに # 前回の記事( EC2スポットインスタンス×vLLMでGPUコストを大幅削減する )では、時差・価格・中断耐性を加味した「マルチリージョン総合優先度自動選定」を導入し、 g6.xlarge (NVIDIA L4 24GB)を1時間あたり約80〜90円という格安価格でスポット調達する仕組みを構築しました。 これにより、高価なクラウドGPUを個人の手元から「使いたい時だけサッと起動し、終わったら即座に破棄する」エフェメラルな開発スタイルが確立できました。 しかし、第3回までの運用構成には、セキュリティおよび運用の観点で 看過できない2つの課題 が残っていました: ポート22(SSH)の開放リスク : インスタンスへのアクセスにSSHトンネル(ポート8000転送)を使用していたため、セキュリティグループでインバウンドのポート22を許可する必要がありました。IP制限をかけているとはいえ、自宅・オフィス・リモートワーク環境でグローバルIPが変わるたびにセキュリティグループの更新が必要になり、運用が煩雑でした。 SSH秘密鍵( .pem )の管理負荷 : 探索対象の複数リージョン(東京、オレゴン、バージニア等)それぞれにキーペアを事前作成し、ローカルに秘密鍵ファイルを安全に保持・管理する必要がありました。 そこで今回は、AWSのマネージドサービスである 「AWS Systems Manager (SSM) Session Manager」のポートフォワーディング機能 を導入します。 EC2セキュリティグループの インバウンドルールを完全に空(ポート開放ゼロ) にしたまま、ローカルPCのポート8000とEC2上のvLLM(ポート8000)を安全にトンネリングし、SSH秘密鍵の管理を完全に撤廃する手法を詳しく解説します。 --> Information 📚 関連する過去の検証記事 第1回(基本構成) : AWS×UserDataでvLLMを自動起動!停止時コストほぼゼロのローカルLLM環境 第2回(Cline連携) : 自前vLLMをOpen WebUI経由でVS Code(Cline)に接続!トークンフリーで開発する 第3回(スポット活用) : EC2スポットインスタンス×vLLMでGPUコストを大幅削減する なぜSSHからSSMポートフォワーディングへ移行するのか? # 📌 このセクションの要点 SSMポートフォワーディングを採用することで、「インバウンド全閉塞(ポート開放ゼロ)」と「SSH秘密鍵の完全撤廃」を同時に実現できます。接続の認可はAWS IAMで一元管理され、CloudTrailによる監査ログも自動で残ります。 1. ポート開放ゼロ(インバウンド許可一切不要) # 従来のSSH接続では、EC2のセキュリティグループでインバウンドポート22を許可する必要がありました。 しかし、SSM Session Managerは EC2インスタンス内部で稼働する「SSM Agent」が、AWSのSSMエンドポイントに対して外向き(アウトバウンド HTTPS/443)にWebSocketセッションを張りに行く アーキテクチャを採用しています。 flowchart LR subgraph Client ["ローカル開発端末 (WSL2 / Mac)"] direction TB Cline["VS Code (Cline)"] CLI["AWS CLI (Session Manager Plugin)<br>localhost:8000"] Cline -->|"http://localhost:8000"| CLI end subgraph AWS ["AWS バックボーン"] SSMEndpoint["SSM Service<br>(Session Manager)"] end subgraph EC2Host ["EC2 GPUインスタンス (g6.xlarge)"] direction TB SG["セキュリティグループ<br>【インバウンドルール: 0件 (完全閉塞)】"] Agent["SSM Agent"] VLLM["vLLM Dockerコンテナ<br>(:8000)"] Agent -->|localhost:8000| VLLM end CLI ===|"HTTPS / 443 (IAM認証)"| SSMEndpoint Agent ===|"HTTPS / 443 (アウトバウンド確立)"| SSMEndpoint これにより、EC2セキュリティグループのインバウンドルールは 1つも存在しない(インバウンド0件) 状態にできます。インターネット側からのポートスキャンやブルートフォース攻撃をネットワーク的に遮断し、インバウンドの攻撃面(アタックサーフェス)を最小化できます。 2. SSH秘密鍵( .pem )の完全撤廃 # 第3回のようにオレゴンやバージニアなど複数リージョンへインスタンスを立ち上げる場合、リージョンごとにキーペアを作成して手元のマシンに秘密鍵を保管しておく必要がありました。鍵のローテーションや紛失リスク、誤ってGitへコミットしてしまうリスクなど、鍵管理の心理的負荷は小さくありません。 SSMポートフォワーディングを利用する場合、認証・認可はすべて AWS IAM に統合されます。 開発者が手元の端末で aws configure や SSO(IAM Identity Center)で設定したAWS認証情報(アクセスキーやセッショントークン)を使ってセッションを開始するため、 SSH秘密鍵は1つも必要ありません 。 3. SSH接続とSSMポートフォワーディングの比較 # 項目 従来のSSHトンネル (第3回) SSMポートフォワーディング (今回) メリット インバウンドポート ポート 22 を開放必須 完全閉塞(ポート開放ゼロ) インターネットからのインバウンド直接侵入経路を排除 接続元IP制限 自宅/オフィスのIP変更時にSG修正が必要 不要 (IAM認証で保護) リモートワークでもIP変更作業がゼロに 鍵管理 リージョンごとの .pem 秘密鍵の保管・管理が必要 不要(鍵レス) 秘密鍵自体の保管・ローテーション管理が不要に 監査ログ SSH接続ログをサーバー内に収集する設定が必要 CloudTrail / CloudWatch に自動記録 いつ誰がセッションを開始したか証跡が残る マルチリージョン展開 リージョンごとにKeyName作成が必要 全リージョン共通で追加設定不要 スクリプトの可搬性と自動化が容易 前提条件とローカル環境の準備 # SSMポートフォワーディングを使用するには、ローカルマシンに session-manager-plugin をインストールしておく必要があります。 Session Manager Pluginのインストール # まだインストールしていない場合は、AWS公式ドキュメントに従ってインストールします。 Ubuntu / WSL2 の場合: curl "https://s3.amazonaws.com/session-manager-downloads/plugin/latest/ubuntu_64bit/session-manager-plugin.deb" -o "session-manager-plugin.deb" sudo dpkg -i session-manager-plugin.deb rm session-manager-plugin.deb macOS (Homebrew) の場合: brew install --cask session-manager-plugin Windowsの場合: winget install Amazon.SessionManagerPlugin インストール後、以下のコマンドでバージョン情報が表示されれば準備完了です: session-manager-plugin # 出力例: The Session Manager plugin is installed successfully. Use the AWS CLI to start a session. AWSリソースの事前セットアップ # SSM経由での接続を実現するために、各リージョンに以下の2つのリソースを準備します: IAMインスタンスプロファイル : EC2がSSMエージェントを通じてAWS SSMサービスと通信するためのIAMロール( AmazonSSMManagedInstanceCore ポリシーを含む)。 インバウンド全閉塞セキュリティグループ : インバウンドルールが0件で、アウトバウンドのみ許可されたセキュリティグループ( vllm-ssm-isolated-sg )。 これらを一括作成するセットアップスクリプトを用意しました。 00_setup_ssm_assets.sh(クリックで展開) #!/bin/bash set -euo pipefail # ============================================================================== # 00_setup_ssm_assets.sh # SSMポートフォワーディング用のIAMロールおよびインバウンド全閉塞SGを各リージョンに作成する # ============================================================================== ROLE_NAME="vllm-ssm-instance-role" PROFILE_NAME="vllm-ssm-instance-profile" SG_NAME="vllm-ssm-isolated-sg" REGIONS=("ap-northeast-1" "us-west-2" "us-east-1" "us-east-2" "eu-central-1") echo "=== 1. IAMロール & インスタンスプロファイルの作成 ===" # 信頼ポリシー (EC2) TRUST_POLICY='{ "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Principal": { "Service": "ec2.amazonaws.com" }, "Action": "sts:AssumeRole" } ] }' if ! aws iam get-role --role-name "${ROLE_NAME}" >/dev/null 2>&1; then echo "IAMロール ${ROLE_NAME} を作成中..." aws iam create-role --role-name "${ROLE_NAME}" --assume-role-policy-document "${TRUST_POLICY}" # SSM管理ポリシーの付与 aws iam attach-role-policy --role-name "${ROLE_NAME}" \ --policy-arn "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore" # S3モデル読み取り権限 aws iam attach-role-policy --role-name "${ROLE_NAME}" \ --policy-arn "arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess" # ECRコンテナ読み取り権限 aws iam attach-role-policy --role-name "${ROLE_NAME}" \ --policy-arn "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly" else echo "IAMロール ${ROLE_NAME} は既に存在します。" fi if ! aws iam get-instance-profile --instance-profile-name "${PROFILE_NAME}" >/dev/null 2>&1; then echo "インスタンスプロファイル ${PROFILE_NAME} を作成中..." aws iam create-instance-profile --instance-profile-name "${PROFILE_NAME}" aws iam add-role-to-instance-profile --instance-profile-name "${PROFILE_NAME}" --role-name "${ROLE_NAME}" echo "IAMインスタンスプロファイルの反映待機中 (10秒)..." sleep 10 else echo "インスタンスプロファイル ${PROFILE_NAME} は既に存在します。" fi echo "=== 2. 各リージョンでの インバウンド全閉塞セキュリティグループ作成 ===" for REGION in "${REGIONS[@]}"; do echo "リージョン [${REGION}] を確認中..." VPC_ID=$(aws ec2 describe-vpcs --region "${REGION}" --filters "Name=is-default,Values=true" --query "Vpcs[0].VpcId" --output text) EXISTING_SG=$(aws ec2 describe-security-groups --region "${REGION}" --filters "Name=group-name,Values=${SG_NAME}" --query "SecurityGroups[0].GroupId" --output text 2>/dev/null || true) if [ -z "${EXISTING_SG}" ] || [ "${EXISTING_SG}" = "None" ]; then echo " ${SG_NAME} を VPC (${VPC_ID}) に作成中..." SG_ID=$(aws ec2 create-security-group \ --region "${REGION}" \ --group-name "${SG_NAME}" \ --description "Zero-inbound security group for vLLM SSM port forwarding" \ --vpc-id "${VPC_ID}" \ --query "GroupId" --output text) # デフォルトで全アウトバウンドは許可されるため、インバウンドルールは何も追加しない(ポート開放ゼロ) echo " 作成完了: ${SG_ID} (インバウンドルール: 0件)" else echo " 既に存在します: ${EXISTING_SG}" fi done echo "事前準備が完了しました!" このスクリプトを実行すると、各リージョンにインバウンドルールが1つもないセキュリティグループ vllm-ssm-isolated-sg と、SSM通信が許可されたインスタンスプロファイル vllm-ssm-instance-profile が作成されます。 起動スクリプトのSSM対応 # 第3回の起動スクリプト( 01_ec2_launch_spot_and_sync_openwebui.sh )から、SSH接続部分をSSMポートフォワーディングへと置き換えたスクリプト( 01_start_vllm_ssm.sh )を作成します。 主な変更ポイント # SSHキーの指定を削除 : aws ec2 run-instances に渡していた --key-name パラメータを完全に削除します。 IAMインスタンスプロファイルとインバウンド全閉塞SGを指定 : --iam-instance-profile Name=vllm-ssm-instance-profile を追加。 --security-group-ids に vllm-ssm-isolated-sg を指定。 SSMエージェントのオンライン待機 : 以前はSSHポート22の応答を nc -z でループ待機していましたが、今回はAWS SSM API( aws ssm describe-instance-information )を呼び出し、 PingStatus == "Online" になるまで待機します。 SSMポートフォワーディングセッションの確立 : aws ssm start-session でポートフォワーディングを開始し、バックグラウンド実行します。 aws ssm start-session \ --region "${AWS_REGION}" \ --target "${INSTANCE_ID}" \ --document-name AWS-StartPortForwardingSession \ --parameters '{"portNumber":["8000"],"localPortNumber":["8000"]}' 完全なスクリプト: 01_start_vllm_ssm.sh # 01_start_vllm_ssm.sh(クリックで展開) #!/bin/bash set -euo pipefail # ============================================================================== # 01_start_vllm_ssm.sh # スポットインスタンスを探索・起動し、SSMポートフォワーディングで安全に直結する # ============================================================================== SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" USER_DATA_FILE="${USER_DATA_FILE:-${SCRIPT_DIR}/02_ec2_userdata.sh}" 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" CANDIDATE_REGIONS=("ap-northeast-1" "us-west-2" "us-east-1" "us-east-2" "eu-central-1") CANDIDATE_INSTANCE_TYPES=("g6.xlarge") IAM_PROFILE_NAME="vllm-ssm-instance-profile" SG_NAME="vllm-ssm-isolated-sg" EBS_SIZE_GB="40" echo "=== 1. マルチリージョン スポット総合優先度探索 ===" ITYPE="${CANDIDATE_INSTANCE_TYPES[0]}" START_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)" # Spot Advisor 中断頻度の取得 declare -A REGION_INTR_TIERS PY_BIN="" command -v python3 >/dev/null 2>&1 && PY_BIN="python3" || (command -v python >/dev/null 2>&1 && PY_BIN="python") if [ -n "${PY_BIN}" ]; then ADVISOR_DATA=$("${PY_BIN}" -c " import urllib.request, json try: with urllib.request.urlopen('https://spot-bid-advisor.s3.amazonaws.com/spot-advisor-data.json', timeout=4) as res: adv = json.loads(res.read()).get('spot_advisor', {}) for r, info in adv.items(): print(f'{r}:{info.get(\"Linux\", {}).get(\"${ITYPE}\", {}).get(\"r\", 4)}') except Exception: pass " 2>/dev/null || true) while IFS=':' read -r r_id r_tier; do r_id=$(echo "${r_id}" | tr -d '[:space:]') r_tier=$(echo "${r_tier}" | tr -d '[:space:]') [ -n "${r_id}" ] && REGION_INTR_TIERS["${r_id}"]="${r_tier}" done <<< "${ADVISOR_DATA}" fi declare -A REG_STATUS REG_MIN_PRICES REG_AZ_COUNTS REG_SCORES REG_RANKS BEST_PRICE="999.0" for REGION in "${CANDIDATE_REGIONS[@]}"; do RAW_PRICES=$(aws ec2 describe-spot-price-history \ --region "${REGION}" \ --instance-types "${ITYPE}" \ --product-descriptions "Linux/UNIX" \ --start-time "${START_TIME}" \ --no-paginate \ --query 'SpotPriceHistory[*].[AvailabilityZone, SpotPrice]' \ --output text 2>/dev/null | tr -d '\r' || true) if [ -z "${RAW_PRICES}" ]; then REG_STATUS["${REGION}"]="unavailable" continue fi REG_MIN_PRICE="999.0" AZ_COUNT=0 while read -r AZ PRICE; do AZ=$(echo "${AZ}" | tr -d '[:space:]') PRICE=$(echo "${PRICE}" | tr -d '[:space:]') [ -z "${AZ}" ] || [ -z "${PRICE}" ] && continue AZ_COUNT=$((AZ_COUNT + 1)) if awk -v p="${PRICE}" -v m="${REG_MIN_PRICE}" 'BEGIN {exit !(p < m)}' 2>/dev/null; then REG_MIN_PRICE="${PRICE}" fi done <<< "${RAW_PRICES}" if [ "${REG_MIN_PRICE}" = "999.0" ]; then REG_STATUS["${REGION}"]="unavailable" continue fi REG_STATUS["${REGION}"]="ok" REG_MIN_PRICES["${REGION}"]="${REG_MIN_PRICE}" REG_AZ_COUNTS["${REGION}"]="${AZ_COUNT}" if awk -v p="${REG_MIN_PRICE}" -v m="${BEST_PRICE}" 'BEGIN {exit !(p < m)}' 2>/dev/null; then BEST_PRICE="${REG_MIN_PRICE}" fi done # スコア計算 (価格 40点 + 余剰AZ数 40点 + 中断頻度 20点 = 100点満点) for REGION in "${CANDIDATE_REGIONS[@]}"; do if [ "${REG_STATUS[${REGION}]:-}" != "ok" ]; then echo " - [${REGION}]: 在庫なしまたは未提供" continue fi PRICE="${REG_MIN_PRICES[${REGION}]}" TIER="${REGION_INTR_TIERS[${REGION}]:-4}" AZ_COUNT="${REG_AZ_COUNTS[${REGION}]}" # 価格スコア (最大40点) P_SCORE=$(awk -v min="${BEST_PRICE}" -v cur="${PRICE}" 'BEGIN {printf "%.0f", 40.0 * (min / cur)}') # 中断頻度スコア (最大20点) case "${TIER}" in 0) I_SCORE=20; I_TEXT="< 5% (極低)" ;; 1) I_SCORE=15; I_TEXT="5-10% (低)" ;; 2) I_SCORE=10; I_TEXT="10-15% (中)" ;; 3) I_SCORE=5; I_TEXT="15-20% (中高)" ;; *) I_SCORE=0; I_TEXT="> 20% (高)" ;; esac # キャパシティスコア (最大40点) if [ "${AZ_COUNT}" -ge 5 ]; then C_SCORE=40 elif [ "${AZ_COUNT}" -eq 4 ]; then C_SCORE=32 elif [ "${AZ_COUNT}" -eq 3 ]; then C_SCORE=24 elif [ "${AZ_COUNT}" -eq 2 ]; then C_SCORE=16 else C_SCORE=8 fi TOTAL_SCORE=$(( P_SCORE + I_SCORE + C_SCORE )) REG_SCORES["${REGION}"]="${TOTAL_SCORE}" if [ "${TOTAL_SCORE}" -ge 75 ]; then RANK="Rank S" elif [ "${TOTAL_SCORE}" -ge 65 ]; then RANK="Rank A" elif [ "${TOTAL_SCORE}" -ge 50 ]; then RANK="Rank B" else RANK="Rank C" fi REG_RANKS["${REGION}"]="${RANK}" PRICE_FMT=$(awk -v p="${PRICE}" 'BEGIN {printf "%.4f", p}') echo " - [${REGION}] 価格=\$${PRICE_FMT}/h, 中断率=${I_TEXT}, 余剰AZ=${AZ_COUNT} -> 総合スコア: ${TOTAL_SCORE}点 (${RANK})" done # スコア順にソート (降順) SORTED_REGIONS=($(for r in "${!REG_SCORES[@]}"; do echo "$r ${REG_SCORES[$r]}"; done | sort -k2 -nr | awk '{print $1}')) echo "----------------------------------------------------------" echo "総合優先度ランキング:" for i in "${!SORTED_REGIONS[@]}"; do r="${SORTED_REGIONS[$i]}" echo " 第$((i+1))位: ${r} (${REG_RANKS[$r]} / スコア: ${REG_SCORES[$r]}点, 最安: \$${REG_MIN_PRICES[$r]}/h)" done echo "----------------------------------------------------------" # --- 2. スポットインスタンス起動試行 --- echo "=== 2. スポットインスタンス起動試行 ===" SELECTED_REGION="" INSTANCE_ID="" for REGION in "${SORTED_REGIONS[@]}"; do echo "リージョン [${REGION}] でスポット起動を試行中..." # インバウンド全閉塞SGのID取得 SG_ID=$(aws ec2 describe-security-groups --region "${REGION}" --filters "Name=group-name,Values=${SG_NAME}" --query "SecurityGroups[0].GroupId" --output text 2>/dev/null || true) if [ -z "${SG_ID}" ] || [ "${SG_ID}" = "None" ]; then echo " 警告: ${REGION} に ${SG_NAME} が見つかりません。スキップします。" continue fi # Ubuntu 22.04 Deep Learning AMI (NVIDIA Driver & Docker導入済み) の動的取得 AMI_ID=$(aws ec2 describe-images \ --region "${REGION}" \ --owners amazon \ --filters "Name=name,Values=Deep Learning OSS Nvidia Driver AMI GPU PyTorch * (Ubuntu 22.04)*" "Name=state,Values=available" \ --query 'sort_by(Images, &CreationDate)[-1].ImageId' \ --output text 2>/dev/null || true) # UserDataの読み込みと環境変数の正確な注入 AWS_ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text 2>/dev/null || echo "") S3_BUCKET_NAME="my-vllm-models-hackathon-2026-${AWS_ACCOUNT_ID}-${REGION}-an" DOCKER_IMAGE="${AWS_ACCOUNT_ID}.dkr.ecr.${REGION}.amazonaws.com/vllm-openai:latest" TMP_USERDATA=$(mktemp) sed -e "s|^AWS_REGION=.*|AWS_REGION=\"${REGION}\"|" \ -e "s|^S3_BUCKET_NAME=.*|S3_BUCKET_NAME=\"${S3_BUCKET_NAME}\"|" \ -e "s|^DOCKER_IMAGE=.*|DOCKER_IMAGE=\"${DOCKER_IMAGE}\"|" \ "${USER_DATA_FILE}" > "${TMP_USERDATA}" LAUNCH_RES=$(aws ec2 run-instances \ --region "${REGION}" \ --image-id "${AMI_ID}" \ --instance-type "${ITYPE}" \ --iam-instance-profile Name="${IAM_PROFILE_NAME}" \ --security-group-ids "${SG_ID}" \ --instance-market-options '{"MarketType":"spot","SpotOptions":{"SpotInstanceType":"one-time","InstanceInterruptionBehavior":"terminate"}}' \ --block-device-mappings "[{\"DeviceName\":\"/dev/sda1\",\"Ebs\":{\"VolumeSize\":${EBS_SIZE_GB},\"VolumeType\":\"gp3\",\"DeleteOnTermination\":true}}]" \ --user-data "file://${TMP_USERDATA}" \ --tag-specifications "ResourceType=instance,Tags=[{Key=Name,Value=vllm-ssm-spot-node}]" \ --query "Instances[0].InstanceId" \ --output text 2>&1 || true) rm -f "${TMP_USERDATA}" if [[ "${LAUNCH_RES}" =~ ^i-[0-9a-f]+$ ]]; then INSTANCE_ID="${LAUNCH_RES}" SELECTED_REGION="${REGION}" echo " 起動成功! Instance ID: ${INSTANCE_ID} (${SELECTED_REGION})" break else echo " キャパシティ不足またはエラーのためスキップ: ${LAUNCH_RES}" fi done if [ -z "${INSTANCE_ID}" ]; then echo "エラー: 全候補リージョンでスポット起動に失敗しました。" exit 1 fi echo "${INSTANCE_ID}" > "${INSTANCE_STATE_FILE}" echo "${SELECTED_REGION}" > "${REGION_STATE_FILE}" 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 # --- 3. SSMエージェントのオンライン待機 --- echo "=== 3. 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 # --- 4. サーバー側での vLLM 起動待機 (S3モデルDL・コンテナ起動) --- echo "=== 4. サーバー側での 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 # --- 5. 既存トンネルの整理と新規SSMポートフォワーディング確立 --- echo "=== 5. 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 # --- 6. ローカル疎通確認 --- echo "=== 6. ローカル疎通確認 (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 " ポート開放ゼロ SSM環境の起動が完了しました!" echo " EC2 Instance ID : ${INSTANCE_ID} (Spot, 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. スクリプトの実行 # スクリプトを実行すると、マルチリージョン探索、スポットインスタンスの起動、SSMエージェントの接続待機、サーバー側vLLM起動待機、ポートフォワーディングの確立がすべて全自動で進みます。 $ ./01_start_vllm_ssm.sh === 1. マルチリージョン スポット総合優先度探索 === - [ap-northeast-1] 価格=$0.5510/h, 中断率=< 5% (極低), 余剰AZ=3 -> 総合スコア: 72点 (Rank A) - [us-west-2] 価格=$0.3850/h, 中断率=< 5% (極低), 余剰AZ=4 -> 総合スコア: 92点 (Rank S) - [us-east-1] 価格=$0.5830/h, 中断率=5-10% (低), 余剰AZ=6 -> 総合スコア: 81点 (Rank S) - [us-east-2] 価格=$0.4210/h, 中断率=< 5% (極低), 余剰AZ=3 -> 総合スコア: 80点 (Rank S) - [eu-central-1] 価格=$0.4120/h, 中断率=< 5% (極低), 余剰AZ=3 -> 総合スコア: 81点 (Rank S) ---------------------------------------------------------- 総合優先度ランキング: 第1位: us-west-2 (Rank S / スコア: 92点, 最安: $0.3850/h) 第2位: us-east-1 (Rank S / スコア: 81点, 最安: $0.5830/h) 第3位: eu-central-1 (Rank S / スコア: 81点, 最安: $0.4120/h) 第4位: us-east-2 (Rank S / スコア: 80点, 最安: $0.4210/h) 第5位: ap-northeast-1 (Rank A / スコア: 72点, 最安: $0.5510/h) ---------------------------------------------------------- === 2. スポットインスタンス起動試行 === リージョン [us-west-2] でスポット起動を試行中... 起動成功! Instance ID: i-0abc12345678def01 (us-west-2) インスタンスが running 状態になるのを待機中... === 3. SSMエージェントのオンライン待機 === EC2インスタンス内部のSSM Agentが接続されるのを待機中... ..... SSM Agent が Online になりました! (5回目の試行) === 4. サーバー側での vLLM 起動待機 (http://127.0.0.1:8000/health) === UserDataによるS3モデルダウンロードとvLLM起動を待機しています(通常2〜4分程度)... ................................ サーバー側の vLLM が正常に応答しました! === 5. SSMポートフォワーディングの確立 (ポート開放不要トンネル) === SSMポートフォワーディングを開始しました (PID: 41820) === 6. ローカル疎通確認 (http://localhost:8000/health) === ローカルからの直結疎通を確認しました! (HTTP 200 OK) ========================================================== ポート開放ゼロ SSM環境の起動が完了しました! EC2 Instance ID : i-0abc12345678def01 (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. インバウンドルール0件の確認 # AWSマネジメントコンソールやAWS CLIで対象インスタンスのセキュリティグループを確認してみます。 $ aws ec2 describe-security-groups \ --region us-west-2 \ --filters "Name=group-name,Values=vllm-ssm-isolated-sg" \ --query "SecurityGroups[0].IpPermissions" [] 出力は [] 、つまり インバウンドルールは1件も登録されていません 。 外部から一切の着信ポートが存在しないにもかかわらず、手元の端末からは http://localhost:8000/v1/models で普通にモデル情報が取得できます: $ curl -s http://localhost:8000/v1/models | jq .data[0].id "Qwen/Qwen2.5-Coder-14B-Instruct-AWQ" 外部に対するインバウンドポートを一切開けずに手元から透過的に直結できる体験は、従来のSSH運用と比べても管理負荷とセキュリティの両面で大きなメリットがあります。 検証終了時の完全破棄スクリプト # 作業が終わったら、破棄スクリプト( 02_stop_vllm_ssm.sh )を実行してリソースを解放します。 02_stop_vllm_ssm.sh(クリックで展開) #!/bin/bash set -euo pipefail # ============================================================================== # 02_stop_vllm_ssm.sh # 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" 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 "${1:-}") # 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. EC2インスタンスの完全終了 (Terminate) if [ -n "${INSTANCE_ID}" ]; then echo "=== 2. EC2インスタンスの完全終了 (Terminate) ===" echo "対象インスタンスID: ${INSTANCE_ID} (${AWS_REGION})" aws ec2 terminate-instances --region "${AWS_REGION}" --instance-ids "${INSTANCE_ID}" --output table echo "インスタンスの終了完了を待機中..." aws ec2 wait instance-terminated --region "${AWS_REGION}" --instance-ids "${INSTANCE_ID}" fi rm -f "${INSTANCE_STATE_FILE}" "${REGION_STATE_FILE}" "${TUNNEL_LOG}" echo "==========================================================" echo " スポットインスタンス、EBSボリューム、SSMトンネルの完全破棄が完了しました!" echo "==========================================================" 実行すると、バックグラウンドのポートフォワーディングプロセスが終了し、EC2インスタンスとEBSがTerminateされ、これ以降の課金は完全にストップします。 まとめ # 今回は、第3回で構築した「マルチリージョン・スポットvLLM環境」の通信レイヤーを根本から見直し、 AWS Systems Manager Session Managerによるポートフォワーディング へと刷新しました。 ポート開放ゼロ(インバウンド全閉塞)の達成 : セキュリティグループのインバウンド許可を完全に撤廃し、不特定多数からのポートスキャンや直接攻撃のリスクを遮断。 SSH鍵管理の完全撤廃 : リージョンごとの鍵ペア作成や .pem ファイルの管理・配布・紛失リスクから完全に解放。 IAMによる認可と監査 : AWS認証情報に基づくアクセス制御とCloudTrailによるセッション開始ログの取得により、企業ユースでも求められるアクセス統制とセッション監査ログの取得に対応。 使い勝手は変わらず快適 : ローカルPC上ではこれまで通り localhost:8000 にアクセスするだけで、Open WebUIやClineからの透過的な推論が可能。 セキュリティと運用利便性の双方を大きく向上させた強固な基盤が整いました。 低コストなスポットGPUをさらにセキュアに活用したい方の参考になれば幸いです。
下半期に入りました。2026年7-9月のサマリーです。 記事数・執筆者数 # この3ヶ月で24本の記事が投稿され、記事数は912になりました。新たに2名が投稿デビューし、執筆者は累計83名になりました。 連載 # AIエージェントとシステムをつなぐMCP入門 # 新たに3記事が追加されています。 /blogs/2026/07/03/mcp-impl_resource/ /blogs/2026/07/10/mcp-spec-2026-07-28-rc/ /blogs/2026/08/28/mcp-impl_auth/ アジャイル FAQ # アジャイル開発が世に出て30年近い年月が経ち、実践者の世代交代が進んでいるので、よくいただく質問を起点に、その背景にある構造的な問題や現場で使える対処法を、FAQの形で明文化しておこうという連載が始まっています。 /blogs/2026/09/09/1-do_we_still_need_scrum/ /blogs/2026/09/10/2_scrum_for_maintenance_teams/ /blogs/2026/09/25/3_current_functionality_guarantee/ /blogs/2026/09/25/4_estimating_without_fixing_scope/ /blogs/2026/09/30/5_what_is_the_spec_to_verify/ 連載のインデックスは以下をご参照ください。 アジャイルFAQ EC2 でローカル LLM # セキュアかつ最小限のインフラ費用で実現できるローカル LLM 構築記事です。単なる「LLMの動かし方」の記事にとどまらず、AWSの様々な機能(インスタンスストア、S3、UserData)を限界までしゃぶりつくすクラウドネイティブインフラ構築ハンズオンともいえるシリーズです。 /blogs/2026/09/16/vllm_autolaunch/ /blogs/2026/09/29/vllm_openwebui_cline/ ETロボコン # 筆者が4年間取り組んできたETロボコン活動の軌跡、さらなるレベルアップを目指す取り組みについて書いています。 /blogs/2026/07/29/et-robocon-001/ /blogs/2026/09/28/et-robocon-002/ GFXBench # GPU ベンチマークソフト GFXBench をビルドして動かすシリーズです。 /blogs/2026/08/28/gfxbench_1/ /blogs/2026/09/11/gfxbench_2/ /blogs/2026/09/25/gfxbench_3/ テーマ別の記事 # 各種開発 # /blogs/2026/07/07/deno-2_9-desktop/ /blogs/2026/08/24/github-stacked-prs/ /blogs/2026/09/02/csharp_delegate/ /blogs/2026/09/04/minimum-rag_mystery-novel/ スクラムと AI # /blogs/2026/08/21/ai-driven-scrum/ 学会・カンファレンス参加報告 # /blogs/2026/07/06/jsai2026_conference_report/ /blogs/2026/09/11/generative-ai_speed-and-correctness/ さいごに # 以上、2026年度第2四半期のサマリーでした。 よかったら フィード の購読、 X や Bluesky でのフォローもお願いします。 Facebook でも本サイトの注目記事をはじめ豆蔵に関するイベントを紹介しています。 note にも時々本サイト関連の記事が掲載されています。
はじめに # 前回の記事( 自前vLLMをOpen WebUI経由でVS Code(Cline)に接続!トークンフリーで開発する )では、オープンソースのWeb UI・APIゲートウェイ「Open WebUI」を採用し、AWS上で動かす自前vLLMサーバーをVS Code拡張機能「Cline」のバックエンドとして直結させました。 前回の検証では「1台のGPUサーバー( g6.xlarge )をチーム3〜5人でシェアする」運用モデルを検討し、実用的なコストパフォーマンスが得られることを確認しました。しかし、チームでの開発が本格化するにつれて、以下のような課題や要望も出てきます: リクエスト集中時の初速低下 : 複数人が同時にコード生成やテスト依頼を行うと、キュー待ちやストリーミング速度の低下が発生する。 専用環境へのニーズ : 他のメンバーの利用状況を気にせず、常にGPUのフルパワーを活用して高速に試行錯誤したい。 とはいえ、オンデマンドインスタンス(約 $1.38/h ≒ 約228円/h ※1ドル=165円換算)をメンバー全員に1台ずつ割り当てると、インフラ費用が膨らんでしまいます。 そこで着目したのが、クラウドの余剰キャパシティを活用する 「EC2スポットインスタンス」 です。 また、実務的なコスト課題の解決に加えて、個人的にも 「AWS認定試験で頻出するスポットインスタンスを、実戦で一度ガッツリ使ってみたい!」 という強い動機がありました。 AWS認定(SAAやSAPなど)の勉強をしていると、問題文や正解の解説で必ずと言っていいほど「ステートレスな処理や耐障害性のあるバッチ処理にはスポットインスタンスを活用して大幅なコスト削減を図る」という定番の設計パターンが登場します。「知識としては理解しているし試験でもおなじみだけど、実際に手元で中断ハンドリングまで組み込んで実戦投入した経験はまだなかったな……一度本気で使い倒してみたい!」というエンジニアとしての好奇心も後押しになりました。 スポットインスタンスを利用すれば、検証時点(2026年9月)の相場では通常料金から 約60%オフ(1時間あたり約80〜90円) という大幅な割引が適用されます。この価格帯であれば、1台を常時複数人でシェアする代わりに「各自が必要な作業時間だけ専用GPUを起動して終わったら即破棄する」という短時間利用スタイルをとることで、1台のオンデマンドを終日維持するのと同等以下のコストで運用できます。 もちろん、スポットインスタンスには「AWS側の需要急増に応じて突然インスタンスが回収(中断)されるリスク」が伴います。しかし、本シリーズで構築してきた 「モデルはS3、コンテナはECRに保管し、サーバー・EBSは完全使い捨て(エフェメラル)」 というアーキテクチャであれば、ソースコードやGit履歴はローカル環境で管理しているため、スポット中断によって開発資産が失われるリスクは極めて低くなります。一方で、実行中の推論リクエストやサーバー側にのみ保持している一時データについては失われる可能性があるため、Gitコミットのこまめな実施やリトライを前提とした運用設計が必要です。 つまり、 エフェメラル設計とスポットインスタンスは、中断のデメリットを最小化しつつコストメリットを享受できる非常に相性の良い組み合わせ と言えます。 本記事では、このエフェメラルなvLLM環境をスポットインスタンス上で安定稼働させるためのアプローチとして、 「価格・キャパシティ・中断耐性を加味したマルチリージョン総合優先度自動判定」 の仕組みを解説します。 --> Information 📚 過去シリーズの記事はこちら いつの間にかシリーズ化?してますが、前提知識や過去の経緯が気になる方はぜひあわせてご覧ください: 基本構成(UserData×S3によるエフェメラル自動構築)を知りたい方 : 第1回: AWS×UserDataでvLLMを自動起動!停止時コストほぼゼロのローカルLLM環境 Open WebUIおよびVS Code(Cline)の連携設定を知りたい方 : 第2回: 自前vLLMをOpen WebUI経由でVS Code(Cline)に接続!トークンフリーで開発する スポットインスタンスのコスト削減効果と注意点 # 📌 このセクションの要点 g6.xlarge (NVIDIA L4 24GB)をスポットインスタンスで利用することで、検証時点の相場ではオンデマンド比で約60%オフ(1時間あたり約80〜90円)に抑えられます。ただしスポット価格は需給や時間帯で常に変動するため「常にこの割引率が保証されるわけではない」点、そして「短時間集中利用が前提である」点に留意が必要です。 まずは、検証時点における東京リージョン( ap-northeast-1 )の価格感を見てみましょう。 インスタンスタイプ GPUスペック 世代 / 判定 オンデマンド料金 (USD/h) スポット割引率 (※検証時) スポット料金目安 (USD/h) 1時間あたりの日本円目安 (1$=165円) g6.xlarge (★本命・推奨) NVIDIA L4 (24GB VRAM) 現行世代 (Current) 約 $1.38 /h 約 60% OFF 約 $0.55 /h 約 91 円 /h g5.xlarge (参考) NVIDIA A10G (24GB VRAM) 旧世代 (Previous) 約 $1.41 /h 約 65% OFF 約 $0.49 /h 約 81 円 /h g4dn.xlarge (参考) NVIDIA T4 (16GB VRAM) 旧々世代 (VRAM不足) 約 $0.71 /h 約 65% OFF 約 $0.25 /h 約 41 円 /h ※上記は執筆時点(2026年9月)の実測値です。スポット価格や割引率はAWS全体の需給バランス・時間帯・リージョンによってリアルタイムに変動します。為替レートは実質1ドル=165円(為替160円+為替手数料等)で試算しています。 --> Information 💡 インスタンスタイプの選定理由(本検証では g6.xlarge を推奨) g4dn (16GB) : モデル本体(約15GB)だけでほぼ満杯になり、AIコーディングエージェント特有の長文コンテキスト(KVキャッシュ)を保持できずOOM(メモリ不足)になりやすい。 g5 (24GB) vs g6 (24GB) : 本記事の検証用途では、Ada Lovelace世代で推論効率が高く定価も割安な g6.xlarge が最もバランスの良い選択でした。ただしリージョンごとの在庫状況やスポット価格によっては、旧世代の g5 系インスタンスの方が取得しやすくコスト効率が良いケースもあります。 💡 気になる停止時の維持コスト(S3&ECR保管で月数百円程度) # エフェメラル運用の要である「S3モデル保管」および「ECRコンテナ保管」の維持費も、実質ほぼ無視できるレベルです: S3 Standard保管料(モデル重み) : 15GB × $0.025 = 月額 約$0.38(約 62 円 / 月) (※後述の3リージョン分散時でも月約176円) ECR保管料(コンテナイメージ) : 約10〜15GB × $0.10/GB = 月額 約$1.0〜$1.5(約 165〜248 円 / 月) データ転送料(S3 / ECR → \rightarrow → EC2) : 同一リージョン内通信は 完全無料(0 円) EBS保持との比較 : もし同じサイズのEBS(gp3)を常時保持し続けると高価なボリューム料金(月約1,600円)が毎月かかり続けますが、S3とECRへ退避してEC2・EBSを完全破棄することで、 インスタンス停止時の維持コストを月数百円程度 に抑えられます。 ⚠️ 「1人1台専有」が本当に安いかは稼働率(利用スタイル)次第 # ここで重要な前提として、 「1人1台の専有環境が共有サーバより安くなるのは、各自が短時間利用(1〜2時間)に絞り、使い終わったら確実にTerminateする運用を徹底する場合に限られる」 という点があります。 運用モデル 想定稼働スタイル 月額コスト試算(5人チームの場合) 向き・不向き A. 共有オンデマンド1台 平日日中(8h/日×20日)常時起動 約 $1.38 × 160h ≒ 約 $220/月(約 3.6万円) 常時誰かが使っており、起動の手間をゼロにしたいチーム B. スポット1人1台(短時間集中) 各自が1日2時間だけ起動(2h×5人×20日) 約 $0.55 × 200h ≒ 約 $110/月(約 1.8万円) 個人開発や、集中してガッツリ検証するスタイルに最適 C. スポット1人1台(終日放置) 5人が終日(8h/日)起動しっぱなし 約 $0.55 × 800h ≒ 約 $440/月(約 7.3万円) ❌ コスト逆転! 共有1台より大幅に割高になってしまう 「スポットだから安い」と油断して起動放置したり、チーム全員が終日常時稼働させるようなケースでは、アイドル自動終了デーモンを組み込んでいたとしても、 共有オンデマンド1台(またはReserved Instances/Savings Plansを適用したインスタンス)をシェアする方が総コストも運用負荷も低くなる 可能性があります。 したがって本記事で紹介する「1人1台スポット運用」は、 「必要な時に手元からコマンド1発で立ち上げ、作業が終われば即座に破棄するエフェメラルなワークフロー」を実践できる個人やチームに最も適したアプローチ と言えます。 スポット運用の課題と現実的な向き合い方 # 📌 このセクションの要点 スポット運用の2大課題である「突然の中断」と「在庫切れ・価格変動」に対し、エフェメラル設計(永続データ損失の極小化)とマルチリージョン自動探索によって、実用に耐えうる安定性を確保します。 スポットインスタンスを実戦投入するにあたり、避けて通れない2つの課題を今回の構成がいかにスマートに解決しているかを解説します。 flowchart LR subgraph Client ["ローカル開発環境 (WSL2 + Docker) 【全コード・Gitは手元に保持】"] direction TB Cline["VS Code (Cline)<br>★設定変更不要!"] OpenWebUI["Open WebUI<br>(ポート3000)"] SSHTunnel["SSHトンネル (暗号化)<br>localhost:8000"] LaunchScript["スポット起動スクリプト<br>(総合優先度・最適リージョン自動判定)"] Cline -->|"1. 自律コーディング"| OpenWebUI OpenWebUI -->|"2. 推論リクエスト"| SSHTunnel LaunchScript -.->|"トンネル自動確立"| SSHTunnel end subgraph SpotEC2 ["EC2 スポットインスタンス (使い捨て・ポート8000非公開)"] direction TB SSHD["SSHD (ポート22のみ開放)"] vLLM["Docker: vLLM Server (ポート8000)"] SSHD -->|内部ループバック転送| vLLM end subgraph Storage ["AWS アセット保管 (永続・無料同期)"] Assets[("S3: モデル重み<br>ECR: コンテナイメージ")] end LaunchScript ==>|"3. 最安スポット起動"| SSHD SSHTunnel == "4. ポート22 暗号化通信" ===> SSHD Assets -->|起動時 同一リージョン高速取得| vLLM SpotEC2 -.->|"万が一の中断 (回収)"| Lost["インスタンス回収<br>★ソース・資産は手元&S3にあるため永続データ損失なし<br>(※実行中推論はリトライ必要 / スクリプト再実行で別ホストへ)"] 課題1: 突然の中断(強制終了)リスク # スポットインスタンス最大の懸念は「AWSの都合で突然回収されること」です。 通常のステートフルな開発サーバーやDBサーバーであれば、中断時にデータが破損したり、再構築に何時間もかかったりするため、スポット化には慎重な設計が求められます。 しかし、今回のアーキテクチャでは以下の通り 中断による致命的な損害を最小限に抑えることができます : 開発資産の損失リスクが極めて低い : ソースコードやGit履歴はローカル環境で管理しているため、スポット中断によって開発資産が失われるリスクは極めて低くなります。一方で、実行中の推論リクエストやサーバー側にのみ保持している一時データについては失われる可能性があるため、Gitコミットのこまめな実施やリトライを前提とした運用設計が必要です。 スクリプト再実行で即座に再起動できる : もし作業中に運悪くスポットが中断されても、後述の起動スクリプトをもう1回叩くだけです。数分後には新しいスポットインスタンスが立ち上がり、Open WebUIの接続先IPも自動更新され、手元作業を再開できます。 課題2: スポットの在庫切れとリージョン間の価格差 # スポットインスタンスは余剰キャパシティを借りる仕組みであるため、時間帯やAZ(アベイラビリティゾーン)によって特定のインスタンスタイプが一時的に「在庫切れ」になったり、リージョンごとに価格が変動したりします。 👉 解決策: マルチリージョン・総合優先度スポット自動探索 東京リージョン( ap-northeast-1 )だけに固定せず、オレゴン( us-west-2 )やバージニア( us-east-1 )など複数の候補リージョンの直近スポット価格を aws ec2 describe-spot-price-history でリアルタイムに取得・比較し、 「その瞬間に一番安く、かつ在庫があるリージョン・インスタンスタイプ」を自動判定して起動 するようにスクリプトを強化しました! 💡 時差を考慮したリージョン探索の考え方 スポット価格や調達性は、リージョンごとの需給状況やAWS内部の余剰キャパシティに大きく依存します。 一般論としては現地の業務時間帯より夜間帯の方が空きキャパシティが増える傾向がありますが、GPUインスタンスについては生成AI需要や大規模学習ワークロードの影響も大きく、必ずしも「現地深夜=最安・最安定」とは限りません。 そのため本記事では、時差を参考情報として活用しつつ、実際には起動時点のスポット価格や利用可能AZ数などの実測データを基にリージョンを選択しています。 日本時間 (JST) / シーン 傾向として検討しやすいリージョン (現地時間) 特徴・調達の目安 日中帯 (10:00 〜 18:00) 業務中の開発・PoC検証 ・ 米国東部 ( us-east-1 / 現地 21:00〜05:00) ・ 米国西部 ( us-west-2 / 現地 18:00〜02:00) 米国本土が夜間〜早朝帯となり、世界最大級のデータセンター群に比較的キャパシティ余剰が生じやすい時間帯です。スポット価格も落ち着きやすい傾向にあります。 夜間帯 (19:00 〜 24:00) 退勤後の個人開発・夜間検証 ・ 東京 ( ap-northeast-1 / 現地 19:00〜24:00) ・ 大阪 ( ap-northeast-3 / 現地 19:00〜24:00) ・ シドニー ( ap-southeast-2 / 現地 20:00〜01:00) 国内企業のオフィスアワー終了に伴い、オンデマンド需要が落ち着いて国内スポットが調達しやすくなります。時差の少ないオーストラリアも夜間帯に入ります。 深夜〜早朝 (01:00 〜 08:00) 深夜作業・早朝開発 ・ 東京 ( ap-northeast-1 / 現地 01:00〜08:00) ・ 欧州 アイルランド ( eu-west-1 / 現地 17:00〜24:00) ・ 欧州 フランクフルト ( eu-central-1 / 現地 18:00〜01:00) 東京リージョンが未明となり国内スポットの空きが期待できます。また欧州が夕方〜夜間へ向かうため、欧州リージョンも選択肢に入ります。 💡 コラム:AWS本来のスポット運用思想と「エフェメラル1台」の位置づけ AWS公式が推奨する本来のスポットインスタンス運用は、 「EC2 Auto Scaling」や「EC2 Spot Fleet」を用い、バッチ処理・分散機械学習・CI/CDワーカーなどの並列ワークロードに適用すること です。 AWSのベストプラクティスでは、複数のインスタンスタイプ(例: g6.xlarge , g5.xlarge 等)や複数AZをプールに登録し、アロケーション戦略として price-capacity-optimized (価格・キャパシティ最適化) を指定します。これにより、AWS内部のリアルタイム余剰キャパシティと価格が自動判定され、 「最も中断リスクが低く、かつ最安なプール」から自動調達 されます。 一方、今回のユースケースは「開発者が手元からワンコマンドで自分専用のGPUを1台だけ立ち上げ、VS Codeから直結して対話的に作業する」というエフェメラルな開発環境です。Auto Scalingやフリートを組むほどではない単一インスタンスだからこそ、 「手元にコードがあるため中断されても致命傷にならない」というエフェメラル設計 を前提に、スクリプト側で最適なリージョンを自前判定しています。 💡 発展Tips:独自スコアリング(価格40点+余剰AZ40点+中断耐性20点)の設計根拠 --> Caution ⚠️ 注意:本スコアリングの位置づけ 本記事のスコアリングロジック(価格40点+AZ数40点+安定性20点)は、AWS公式が提供する評価指標ではなく、筆者が個人利用向けのvLLMスポット環境を効率的に運用するために考案した独自基準です。 実際の本番システムや商用ワークロードでは、EC2 Auto Scaling や EC2 Fleet の price-capacity-optimized 戦略など、AWS公式のスポットベストプラクティスを優先してください。 基本スクリプトでは「直近のスポット価格」を基準に最安リージョンを選定していますが、単純な価格比較だけでは 「価格は安いが、現地の日中ピークで在庫が逼迫し中断率が上がっているプール」 を誤って選んでしまう可能性があります。 そこで、AWSが公開している 「Spot Instance Advisor(過去30日の中断率データ)」 と 「稼働可能AZ数(キャパシティ余剰度)」 を組み合わせ、 「価格競争力(40点)+余剰AZ数(40点)+中断耐性(20点)=100点満点」 のハイブリッド優先度スコアリングを導入しました。 なぜこの比率(40 : 40 : 20)なのか? この配分は普遍的な正解ではなく、 「開発者が今すぐ1台立ち上げて作業を開始したい」という本ユースケースの特性に最適化した独自の評価関数 です: 余剰AZ数(40点)を重視する理由 : GPU系インスタンスは需要変動の影響を受けやすく、主要拠点では中断率が高くなるケースもあります。ただし中断率はリージョン・時期・インスタンスタイプによって大きく変動するため、本記事で示す数値は検証時点で取得した参考値であり、将来にわたって保証されるものではありません。 また、いくら価格が安くても提供AZが限られるリージョンでは「そもそも在庫が枯渇して起動すらできない( InsufficientInstanceCapacity )」という事態が生じます。そのため、「確実に起動でき、中断時も別AZで速やかに拾い直せるキャパシティの厚み」を価格と同等の最重要指標(40点)に配分しました。 中断耐性(20点)を抑えめにしている理由 : 各自が短時間(1〜2時間)集中して作業し即座に破棄するエフェメラル運用であれば、作業中にピンポイントで回収に当たる確率は元々それほど高くありません。また万が一当たっても手元から再起動できるため、中断率のペナルティは20点にとどめています。 (※もし「数日間にわたるバッチ学習を極力止めずに回したい」といった別目的であれば、中断耐性の比重を50点以上に引き上げるなど、ワークロードに応じたカスタマイズが適切です) 以下は、執筆時点(2026年9月30日 09:03 JST)のメトリクスを取得して本評価関数でスコアリングしたリアルタイムレポートです: ========================================================================================== AWS EC2 スポット総合優先度 判定レポート (g6.xlarge) 調査時刻: 2026-09-30 09:03 (JST) / 為替換算レート: 1 USD = 165 円 ========================================================================================== リージョンID リージョン名 最安スポット価格 提供AZ数 中断率 総合スコア 優先度判定 ------------------------------------------------------------------------------------------ eu-central-1 フランクフルト $0.454/h (約 75 円) 3 ゾーン < 5% 82 点 🥇 [Rank S] 最優先 us-west-2 オレゴン $0.426/h (約 70 円) 4 ゾーン > 20% 72 点 🥈 [Rank A] 推奨・安値 us-east-1 バージニア $0.555/h (約 92 円) 5 ゾーン > 20% 71 点 🥉 [Rank A] 予備・高可用 ap-northeast-1 東京 $0.568/h (約 94 円) 2 ゾーン > 20% 46 点 ⚠️ [Rank C] 国内要件時 ------------------------------------------------------------------------------------------ 📊 この判定結果の読み解き方(特定時点のスナップショット) 検証時点におけるフランクフルト(82点)の評価 : 検証時点では、フランクフルトリージョンが価格・利用可能AZ数・運用実績を総合的に評価した際に最も高いスコアとなりました。ただし、スポット市場の状況は随時変化するため、常にフランクフルトが最適であるとは限りません。 オレゴン(72点)とバージニア(71点)の堅実さ : オレゴンは4 AZの在庫の厚みと格安さ(約70円/h)が評価され、バージニアは5 AZという圧倒的な巨大データセンターの冗長性が評価されています。「仮に1つのAZで在庫が逼迫しても、別のAZで拾い直せる」というキャパシティの安心感が反映されています。 東京(46点)のキャパシティ状況 : 今回の評価では、調査時点で g6.xlarge を取得可能だったAZ数が他リージョンより少なかった(2ゾーン)ため、キャパシティスコアで差がつきました。今回の評価は調査時点で利用可能だったAZ数を基準にスコアリングしているため、時期や需給状況によっては結果が変わる可能性があります。 --> Caution 🚨 コンプライアンス・データレジデンシー(機密情報の国内保持規定)に関する重要な注意点 マルチリージョン運用はコスト面・可用性面で非常に強力ですが、 企業のセキュリティポリシーや社内規定(データレジデンシー / データ主権要件) には十分ご注意ください。 多くの企業やプロジェクトでは、情報セキュリティ規定やプライバシーポリシー等において、 「ソースコードや顧客データ等の機密情報は、日本国内(東京・大阪リージョン等)のインフラでのみ処理・保管し、海外へ送信・越境移転してはならない」 と厳格に定められているケースが多々あります。 海外リージョン(バージニア us-east-1 やオレゴン us-west-2 など)のインスタンスをClineのバックエンドとして接続した場合、 手元で読み込ませたソースコードやプロンプトが海外リージョンのサーバーへ送信され、国外で推論処理される ことになります。そのため、機密情報を扱う業務環境では社内規定に抵触する重大なリスクがあります。 業務で利用する場合の推奨設定 機密コードや業務データを扱う環境では、 探索対象リージョンを国内(東京 ap-northeast-1 / 大阪 ap-northeast-3 )のみに限定 して運用してください。 後述の起動スクリプトでも、以下のように候補リージョンを国内のみに絞ることで、社内規定を完全に遵守した安全なスポット運用が可能です: # 社内規定でデータ国外転送が禁止されている場合は国内リージョンに限定 CANDIDATE_REGIONS=("ap-northeast-1" "ap-northeast-3") --> Information 💡 インフラ裏話:S3&ECRは各リージョンに置くべき?「リージョン間データ転送料($0.09/GB)」トラップの回避術 マルチリージョンでスポットインスタンスを運用する際、クラウドアーキテクトとして必ず計算しておかなければならないのが 「ストレージ(S3)・コンテナレジストリ(ECR)の配置場所とデータ転送料金」 です。 「S3バケットや自前のプライベートECRは東京リージョンに1個だけ置いて、海外のEC2からもそこから落とせば月額保管料は最安なのでは?」と考えがちですが、ここには 大きな落とし穴(転送料トラップ) が存在します。 vLLMの推論環境を動かすには、 「モデル重みデータ(約15GB)」 に加えて、CUDAやPyTorchを含む 「vLLMのDockerコンテナイメージ(約10〜15GB)」 の2つが必要です。合計すると1回の起動で 約25〜30GB もの大容量データをダウンロードすることになります。 運用方式 月額保管料の目安 起動ごとの転送料 (合計約25〜30GB) 起動までの所要時間 総合評価 パターンA: 東京に集約 (S3/ECRともに東京のみ) 最安 (東京のみ保管) 東京EC2: 0 円 海外EC2: 約 370 〜 450 円 / 起動ごと (クロスリージョン $0.09/GB) 5〜10分 (太平洋横断で重い) ❌ 海外で起動するたびに数百円の通信費が発生し、スポットの安さ(1時間80円)が完全に吹き飛ぶ。 パターンB: 各リージョンに分散配置 (★推奨・ベストプラクティス) 月額 数百円程度 (S3月約176円 + ECR保管料) どのリージョンで起動しても 完全無料(0 円)! 1〜2分 (同一リージョン内高速通信) ◎ 強く推奨! 何回起動しても転送料無料&最速起動。実務での鉄則。 ※為替レートは実質1ドル=165円(為替160円+為替手数料等)で試算しています。 転送トラップを完全回避する2つの処方箋 S3バケットのマルチリージョン配置 : 候補となる各リージョン(東京・オレゴン・バージニア、または国内なら東京・大阪)それぞれにバケットを作成してモデルを事前同期しておきます(3拠点合わせてもS3保管料は月額約176円)。 ECRのクロスリージョンレプリケーション : 自社環境でプライベートECRを使用する場合は、ECR標準の「レプリケーション設定」を有効にしておきます。東京リポジトリにpushするだけで対象リージョンへ自動同期され、海外のEC2も 同一リージョン内のECRから完全無料かつ高速 でDockerイメージをpullできます(※Docker Hub等のパブリックレジストリを利用する場合でも、レートリミット回避と起動高速化のためにECRレプリケーション構成が強く推奨されます)。 これらを徹底することで、どのリージョンでスポットが起動しても、通信コストゼロ&最速のコールドスタートを実現できます! 💡 検証:海外リージョン経由のレイテンシ(RTT)とAIコーディング体感への影響 # スポット運用の議論では「価格」や「キャパシティ」ばかりに目が行きがちですが、クライアント(日本国内)から遠隔地へアクセスする以上、 「物理的なネットワーク遅延(RTT: Round Trip Time)」 の影響を無視することはできません。 実際に日本国内のネットワークから各リージョンのエンドポイントへ通信した際の平均RTTと、VS Code(Cline)での体感速度を比較検証しました: リージョン 物理的な距離 平均RTT(往復遅延) コード補完・ストリーミング出力の体感 Tool Calling(自律往復ループ)の体感 東京 ( ap-northeast-1 ) 国内 約 5 〜 15 ms 極めて快適。入力に対するレスポンスが俊敏。 最速。ファイル読み書きやコマンド実行が滑らか。 オレゴン ( us-west-2 ) 太平洋横断(米西海岸) 約 100 〜 120 ms 良好。ストリーミング開始にわずかな間がある程度。 実用上ほぼ問題なし。十分キビキビ動作する。 バージニア ( us-east-1 ) 米東海岸 約 160 〜 190 ms 許容範囲。文字が出始めれば流れるように表示。 ややモタつきを感じるが、作業進行に支障はない。 フランクフルト ( eu-central-1 ) ユーラシア大陸横断(欧州) 約 240 〜 270 ms 生成開始(初速)に約0.5秒程度の明確な「タメ」が発生。 ツール呼び出しが十数回連続すると、蓄積遅延を実感。 📊 レイテンシから導き出される実践的な使い分け ストリーミング生成への影響は軽微 : LLMのテキスト生成は1トークンずつ順次送られてくるため、一度パイプラインが開けば、トークン間の生成間隔(GPU推論速度そのもの)に隠れてRTTの影響はそこまで気になりません。 TTFT(最初の1文字が出るまでの時間)と自律ループでは差が出る : 「プロンプト送信 → \rightarrow → 推論開始」の初速(TTFT: Time To First Token)、およびClineが「ファイル読み出し → \rightarrow → 編集提案 → \rightarrow → コマンド実行」を数十回連続で繰り返す自律コーディングセッションでは、通信往復の回数分だけレイテンシが乗算されます。 結論 : 「キビキビとした軽快なレスポンスで集中してコードを書きたい場面」では、多少価格が高くても東京やオレゴン(米西海岸)を優先するのが快適 です。一方、フランクフルトなどの長距離リージョンは、「夜間や休日に重めのタスクを投げ込んでおく」「とにかく最安値・確実な在庫調達を最優先したい」といった割り切ったシーンで真価を発揮します。 💡 冷静な考察:月数千円のためにマルチリージョン運用を組むべきか? # 本記事では「時差をハックして世界中から最安GPUを自動調達する」というロマンあふれる構成を構築しましたが、運用アーキテクトの視点から冷静に考えると、 「マルチリージョン運用の複雑さ(認知負荷)と、得られるコスト削減のバランス」 には議論の余地があります。 マルチリージョンを維持するためには、以下のような運用オーバーヘッドが不可避です: アセット同期の管理 : S3モデルデータの定期同期や、ECRクロスリージョンレプリケーションの監視。 アカウントクォータの管理 : 各リージョンごとに個別のService Quotas(GPUスポット割り当て制限)の上限緩和申請が必要。 インフラドリフトと障害調査 : リージョン固有のAMI更新停止、特定リージョンの一時的なネットワーク障害や権限エラーの切り分け。 もし「チーム業務で月数千円〜数万円程度の差額」を削減するためにエンジニアがマルチリージョン管理に追われるのであれば、 人件費や運用保守の観点からは本末転倒 と言わざるを得ません。 開発シーン 推奨されるアプローチ 理由 個人開発・技術検証・ハッカソン マルチリージョン・スポット自動判定 (本構成) コストを限界まで削りつつ、クラウド設計・時差ハック・エフェメラル構築の知見をフルに吸収できる。何より作っていて最高に面白い。 小規模チーム(短時間利用メイン) 国内リージョン限定スポット ( ap-northeast-1 / 3 ) データレジデンシー規定を満たし、レイテンシも最小。アセット同期も国内1〜2拠点でシンプルに完結する。 本格的な業務・定常稼働チーム 国内オンデマンド + Savings Plans (共有1台) 起動待ち時間ゼロ・中断リスクゼロ・運用保守コスト最小。エンジニアの時間をインフラ管理ではなく本業の開発に集中させられる。 「何でもかんでもマルチリージョンにすれば良い」というわけではなく、 得られるコストメリットと運用保守コスト(人件費・保守性)を天秤にかけ、チームのフェーズに合った現実的な選択肢を採る ことが重要です。 --> Caution ⚠️ 事前準備:スポットインスタンス用のクォータ制限解除(Service Quotas)に注意! 第1弾でオンデマンド用のクォータ( Running On-Demand G and VT instances )を申請しましたが、 スポットインスタンスのクォータは別枠( All G and VT Spot Instance Requests ) で管理されています! また、申請にあたっては以下の3点に留意してください: リージョンごとに申請が必須 : 今回のスクリプトのように マルチリージョン(東京・オレゴン・バージニア等)で最安スポットを自動探索・起動する場合、それぞれの対象リージョンごとにクォータが完全に独立している ため、候補リージョンすべてで個別に上限緩和申請を出す必要があります。 利用実績がない場合は「4」の割り当て : アカウントにGPUインスタンスの稼働実績がない場合、申請時に8や16を希望しても、まずは 「4」のみが承認・割り当てられる ケースがほとんどです( g6.xlarge は1台4 vCPUなので、まずは4あれば1台稼働可能です)。 解除通知までの所要時間 : 公式の案内では数日かかる場合があるとされていますが、 筆者の実際の検証環境では、申請提出からおよそ3〜4時間ほどで解除通知のメールが届きました 。スポット運用を試す際は、事前に各リージョンへ申請を済ませておきましょう。 --> Information 💡 組織・チームで「1人1台」を展開する場合のクォータ設計(マルチリージョン&マルチアカウント) 「チームの各メンバーに1台ずつ専用GPUを配りたい」と考えた場合、スポットインスタンスの初期クォータ(通常0 vCPU、初回申請時は4 vCPU=1台分程度)がボトルネックになりやすい点に注意が必要です。同一AWSアカウント内で複数人が同時に起動しようとすると、2人目以降が MaxSpotInstanceCountExceeded でエラーになります。 組織でスムーズに「1人1台」を実現するには、以下の工夫を組み合わせるのが実務上のベストプラクティスです: マルチリージョン展開によるクォータ枠の並列利用 : Service Quotasは リージョンごとに独立 して管理されています。東京・オレゴン・バージニアなど複数リージョンでそれぞれ4〜8 vCPUずつ緩和しておけば、メンバーAは東京、メンバーBはオレゴン……というように、単一アカウントでも別リージョンを活用してクォータ枠を分散・並列稼働させることができます。 マルチアカウント運用(AWS Organizations / 開発者別Sandbox) : 最も推奨されるのが、1つの共有アカウントに全員が同居するのではなく、AWS Organizations等を利用して開発者(またはチーム)ごとに個別のAWSアカウント(Sandboxアカウント)を払い出す運用です。クォータ制限・課金追跡・終了漏れリスクがアカウント単位で完全に分離されるため、ガバナンスと自由度の両立が容易になります。 エフェメラル運用による同時稼働枠の効率利用 : 全員が常時インスタンスを起動し続けるのではなく、本構成のように「集中検証やClineでの作業時のみ立ち上げ、終わったら即Terminate」するエフェメラル運用を徹底することで、同時起動数を抑え、限られたクォータ枠でもチーム内で無駄なく使い回すことができます。 --> Information 📋 スポット運用前のチェックリスト(前提条件) Service Quotas(スポット枠解除) : 対象候補リージョンすべてで All G and VT Spot Instance Requests の上限緩和申請(4 vCPU以上)が承認されていること ローカル環境 : Windows (WSL2) + Docker(Open WebUI稼働中)が準備済みであること EC2キーペア : ~/.ssh/ 配下に秘密鍵( .pem )が存在すること IAMロール : S3およびECRアクセス権限を持つIAMロール(例: EC2-S3-FullAccess-Profile )が作成済みであること AWS CLI : 対象リージョンへのアクセス権限を持つプロファイルで認証が完了していること データレジデンシー確認 : 業務利用時は、社内セキュリティ規定に応じて候補リージョンを国内限定にするか確認していること 事前準備: マルチリージョン・アセットの一括反映(キーペア・S3・ECR・SG) # 📌 このステップでやること どのリージョンが最安に選ばれても即座に高速起動できるよう、キーペア・専用SG・モデル重み(S3)・コンテナイメージ(ECR)を候補リージョンへ一括反映します。 マルチリージョンでスポットを運用するには、以下の4つのリソースを各候補リージョンにあらかじめ用意しておく必要があります: EC2キーペア : キーペアはリージョンごとに独立しているため、ローカルの公開鍵を各リージョンにインポートしておく必要があります。 SSH専用セキュリティグループ( vllm-ssh-tunnel-sg ) : 起動時にSGが未指定だったり存在しないと、AWSの仕様で外部通信を遮断する「defaultセキュリティグループ」が自動選択されてしまい、SSHトンネルが繋がらなくなります。これを防ぐため、ポート22のみ許可する専用SGを事前に各リージョンのVPCに作成しておきます。 S3バケットとモデル重み : 高速起動とデータ転送料($0.09/GB)を回避するため、各リージョンにバケットを作成して東京からモデルファイル群を同期します。 ECRリポジトリとコンテナイメージ : 各リージョンに vllm-openai リポジトリを作成し、ECRクロスリージョンレプリケーションでDockerイメージを同期します。 これらをコマンド一発で全自動反映するセットアップスクリプト( setup_multiregion_assets.sh )です: setup_multiregion_assets.sh(クリックで展開) #!/bin/bash set -euo pipefail # ============================================================================== # setup_multiregion_assets.sh # マルチリージョンスポット運用に必要なAWSアセットを一括反映・同期するスクリプト # ============================================================================== # 候補リージョン一覧 (デフォルト: 東京, オレゴン, バージニア) # ※社内規定でデータ国外転送が禁止されている場合は国内限定に設定: # CANDIDATE_REGIONS=("ap-northeast-1" "ap-northeast-3") CANDIDATE_REGIONS=("ap-northeast-1" "us-west-2" "us-east-1") SRC_REGION="ap-northeast-1" KEY_NAME="${KEY_NAME:-my-vllm-models-hackathon-2026}" KEY_PATH="${KEY_PATH:-${HOME}/.ssh/${KEY_NAME}.pem}" SG_NAME="vllm-ssh-tunnel-sg" ECR_REPO_NAME="vllm-openai" MODEL_PREFIX="models/Qwen/Qwen2.5-Coder-7B-Instruct" echo "=== マルチリージョン・アセット一括同期セットアップ ===" AWS_ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text) # 1. EC2 キーペアの反映 TMP_PUBKEY=$(mktemp) trap 'rm -f "${TMP_PUBKEY}"' EXIT ssh-keygen -y -f "${KEY_PATH}" > "${TMP_PUBKEY}" for REGION in "${CANDIDATE_REGIONS[@]}"; do if ! aws ec2 describe-key-pairs --region "${REGION}" --key-names "${KEY_NAME}" >/dev/null 2>&1; then echo "[${REGION}] キーペア '${KEY_NAME}' をインポート中..." aws ec2 import-key-pair \ --region "${REGION}" \ --key-name "${KEY_NAME}" \ --public-key-material "fileb://${TMP_PUBKEY}" fi done # 2. SSH専用セキュリティグループの事前作成 (デフォルトSG誤適用の防止) for REGION in "${CANDIDATE_REGIONS[@]}"; do SG_ID=$(aws ec2 describe-security-groups \ --region "${REGION}" \ --filters "Name=group-name,Values=${SG_NAME}" \ --query 'SecurityGroups[0].GroupId' --output text 2>/dev/null || true) if [ -z "${SG_ID}" ] || [ "${SG_ID}" = "None" ]; then DEFAULT_VPC=$(aws ec2 describe-vpcs --region "${REGION}" --filters "Name=isDefault,Values=true" --query 'Vpcs[0].VpcId' --output text) SG_ID=$(aws ec2 create-security-group \ --region "${REGION}" \ --group-name "${SG_NAME}" \ --description "Allow SSH port 22 only for vLLM tunnel" \ --vpc-id "${DEFAULT_VPC}" \ --query 'GroupId' --output text) aws ec2 authorize-security-group-ingress \ --region "${REGION}" --group-id "${SG_ID}" \ --protocol tcp --port 22 --cidr "0.0.0.0/0" echo "[${REGION}] 作成完了: ${SG_ID} (ポート22のみ許可)" fi done # 3. S3 バケット作成 & モデルデータの同期 SRC_BUCKET="my-vllm-models-hackathon-2026-${AWS_ACCOUNT_ID}-${SRC_REGION}-an" for REGION in "${CANDIDATE_REGIONS[@]}"; do DEST_BUCKET="my-vllm-models-hackathon-2026-${AWS_ACCOUNT_ID}-${REGION}-an" if ! aws s3 ls "s3://${DEST_BUCKET}" >/dev/null 2>&1; then echo "[${REGION}] S3バケット '${DEST_BUCKET}' を作成中..." aws s3 mb "s3://${DEST_BUCKET}" --region "${REGION}" fi if [ "${REGION}" != "${SRC_REGION}" ]; then echo "[${REGION}] モデル重みデータの同期中 (s3://${SRC_BUCKET} -> s3://${DEST_BUCKET})..." aws s3 sync "s3://${SRC_BUCKET}/${MODEL_PREFIX}/" "s3://${DEST_BUCKET}/${MODEL_PREFIX}/" --no-progress fi done # 4. ECR リポジトリ作成 & クロスリージョン自動レプリケーション設定 for REGION in "${CANDIDATE_REGIONS[@]}"; do if ! aws ecr describe-repositories --region "${REGION}" --repository-names "${ECR_REPO_NAME}" >/dev/null 2>&1; then aws ecr create-repository --region "${REGION}" --repository-name "${ECR_REPO_NAME}" \ --image-scanning-configuration scanOnPush=true --image-tag-mutability MUTABLE >/dev/null fi done # ECR レプリケーション設定とトリガー REPL_DESTS=() for REGION in "${CANDIDATE_REGIONS[@]}"; do if [ "${REGION}" != "${SRC_REGION}" ]; then REPL_DESTS+=("{\"region\":\"${REGION}\",\"registryId\":\"${AWS_ACCOUNT_ID}\"}") fi done REPL_DESTS_JSON=$(IFS=,; echo "${REPL_DESTS[*]}") aws ecr put-replication-configuration --region "${SRC_REGION}" \ --replication-configuration "{\"rules\":[{\"destinations\":[${REPL_DESTS_JSON}]}]}" >/dev/null 2>&1 || true MANIFEST=$(aws ecr batch-get-image --region "${SRC_REGION}" --repository-name "${ECR_REPO_NAME}" --image-ids imageTag=latest --query 'images[0].imageManifest' --output text 2>/dev/null || echo "") if [ -n "${MANIFEST}" ] && [ "${MANIFEST}" != "None" ]; then TMP_MANIFEST=$(mktemp) echo "${MANIFEST}" > "${TMP_MANIFEST}" aws ecr put-image --region "${SRC_REGION}" --repository-name "${ECR_REPO_NAME}" --image-tag "latest" --image-manifest "file://${TMP_MANIFEST}" >/dev/null 2>&1 || true rm -f "${TMP_MANIFEST}" fi echo "マルチリージョン・アセット反映が完了しました!" このスクリプトを初回に1回実行しておくだけで、どのリージョンが最安に選ばれても即座に同一リージョン内のリソースを使って最速・安全に起動できるようになります。 --> Information 💡 事前に各リージョンの相場や中断頻度をチェックしたいときは?( check_spot_prices.sh ) 起動前に各リージョンのリアルタイムスポット価格だけでなく、AWS Spot Advisor の実績中断頻度や余剰AZ数を加味した「総合選択優先度(スコア/ランク)」を一括調査できるスクリプトです。日本時間の日中に安価かつ安定稼働しやすい欧州(フランクフルト)も候補に含まれます: $ ./check_spot_prices.sh ======================================================================================================================== AWS EC2 スポットインスタンス料金・優先度 リアルタイム比較 調査時刻: 2026-09-30 09:03:57 (JST) / 為替換算レート: 1 USD = 165 円 ======================================================================================================================== ■ インスタンスタイプ: g6.xlarge リージョンID リージョン名 最安スポット オンデマンド 割引率 日本円/h 中断頻度 余剰AZ スコア/ランク 総合判定 ------------------------------------------------------------------------------------------------------------------------ ap-northeast-1 東京 (Tokyo) $0.5675 $1.3760 59% OFF 約 94 円 > 20% (高) 2 AZ 46点 (Rank C) 国内要件時推奨 us-west-2 オレゴン (Oregon) $0.4264 $0.9776 56% OFF 約 70 円 > 20% (高) 4 AZ 72点 (Rank A) 最安値 (中断に注意) us-east-1 バージニア (N. Virginia) $0.5552 $0.9776 43% OFF 約 92 円 > 20% (高) 5 AZ 71点 (Rank A) 予備候補 eu-central-1 フランクフルト (Frankfurt) $0.4536 $1.0850 58% OFF 約 75 円 < 5% (極低) 3 AZ 82点 (Rank S) ★ 総合第1位 推奨! ------------------------------------------------------------------------------------------------------------------------ 🏆 現在の総合推奨: [eu-central-1 (フランクフルト (Frankfurt))] 82点 (Rank S) - $0.4536/h (約 75 円/h) @ AZ: eu-central-1a ※ 最安値は us-west-2 ($0.4264/h: 約 70 円/h) ですが、中断頻度 (< 5% (極低)) と余剰キャパシティ (3 AZ) を加味すると eu-central-1 が最も安定的です。 check_spot_prices.sh(クリックで展開) #!/bin/bash set -euo pipefail # ============================================================================== # check_spot_prices.sh # AWS EC2 スポットインスタンス料金・中断頻度・選択優先度 リアルタイム比較スクリプト # # 使い方: # ./check_spot_prices.sh # デフォルト (g6.xlarge, 主要候補リージョン) # ./check_spot_prices.sh --detail # AZ別内訳も詳細表示 # ./check_spot_prices.sh --all # 世界主要8リージョン走査 # ./check_spot_prices.sh g5.xlarge # インスタンスタイプ指定 # ============================================================================== USD_JPY_RATE="${USD_JPY_RATE:-165}" SHOW_DETAIL=false ALL_REGIONS=false CUSTOM_TYPES=() for arg in "$@"; do case "${arg}" in --detail|-d) SHOW_DETAIL=true ;; --all|-a) ALL_REGIONS=true ;; --help|-h) echo "使い方: $0 [オプション] [インスタンスタイプ...]" exit 0 ;; *) CUSTOM_TYPES+=("${arg}") ;; esac done if [ ${#CUSTOM_TYPES[@]} -gt 0 ]; then INSTANCE_TYPES=("${CUSTOM_TYPES[@]}") else INSTANCE_TYPES=("g6.xlarge") fi if [ "${ALL_REGIONS}" = true ]; then REGIONS=("ap-northeast-1" "ap-northeast-3" "us-west-2" "us-east-1" "us-east-2" "eu-west-1" "eu-central-1" "ap-southeast-2") else REGIONS=("ap-northeast-1" "us-west-2" "us-east-1" "eu-central-1") fi get_region_label() { case "$1" in "ap-northeast-1") echo "東京 (Tokyo)" ;; "ap-northeast-3") echo "大阪 (Osaka)" ;; "us-west-2") echo "オレゴン (Oregon)" ;; "us-east-1") echo "バージニア (N. Virginia)" ;; "us-east-2") echo "オハイオ (Ohio)" ;; "eu-west-1") echo "アイルランド (Ireland)" ;; "eu-central-1") echo "フランクフルト (Frankfurt)" ;; "ap-southeast-2") echo "シドニー (Sydney)" ;; *) echo "$1" ;; esac } get_ondemand_price() { local region="$1" itype="$2" case "${itype}" in "g6.xlarge") case "${region}" in ap-northeast-1|ap-northeast-3) echo "1.3760" ;; us-west-2|us-east-1|us-east-2) echo "0.9776" ;; eu-west-1|eu-central-1) echo "1.0850" ;; ap-southeast-2) echo "1.4280" ;; *) echo "1.3800" ;; esac ;; *) echo "1.0000" ;; esac } pad_region_id() { printf "%-16s" "$1"; } pad_region_label() { local label="$1" case "${label}" in "東京 (Tokyo)"|"大阪 (Osaka)") printf "%s " "${label}" ;; "オレゴン (Oregon)"|"シドニー (Sydney)") printf "%s " "${label}" ;; "オハイオ (Ohio)") printf "%s " "${label}" ;; "バージニア (N. Virginia)") printf "%s " "${label}" ;; "フランクフルト (Frankfurt)") printf "%s " "${label}" ;; "アイルランド (Ireland)") printf "%s " "${label}" ;; *) printf "%-28s" "${label}" ;; esac } pad_intr() { local text="$1" case "${text}" in "< 5% (極低)"|"10-15% (中)") printf "%s " "${text}" ;; "5-10% (低)"|"> 20% (高)") printf "%s " "${text}" ;; "15-20% (中高)") printf "%s " "${text}" ;; *) printf "%-14s" "${text}" ;; esac } pad_score() { printf "%s " "${1}点 (${2})"; } pad_jpy() { local jpy="$1" str="約 ${jpy} 円" [ "${jpy}" -ge 100 ] 2>/dev/null && printf "%s " "${str}" || printf "%s " "${str}" } get_intr_text() { case "$1" in 0) echo "< 5% (極低)" ;; 1) echo "5-10% (低)" ;; 2) echo "10-15% (中)" ;; 3) echo "15-20% (中高)" ;; *) echo "> 20% (高)" ;; esac } START_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)" echo "========================================================================================================================" echo " AWS EC2 スポットインスタンス料金・優先度 リアルタイム比較" echo " 調査時刻: $(date '+%Y-%m-%d %H:%M:%S') (JST) / 為替換算レート: 1 USD = ${USD_JPY_RATE} 円" echo "========================================================================================================================" for ITYPE in "${INSTANCE_TYPES[@]}"; do echo "" echo "■ インスタンスタイプ: ${ITYPE}" echo "リージョンID リージョン名 最安スポット オンデマンド 割引率 日本円/h 中断頻度 余剰AZ スコア/ランク 総合判定" echo "------------------------------------------------------------------------------------------------------------------------" declare -A REGION_INTR_TIERS PY_BIN="" command -v python3 >/dev/null 2>&1 && PY_BIN="python3" || (command -v python >/dev/null 2>&1 && PY_BIN="python") if [ -n "${PY_BIN}" ]; then ADVISOR_DATA=$("${PY_BIN}" -c " import urllib.request, json try: with urllib.request.urlopen('https://spot-bid-advisor.s3.amazonaws.com/spot-advisor-data.json', timeout=4) as res: adv = json.loads(res.read()).get('spot_advisor', {}) for r, info in adv.items(): print(f'{r}:{info.get(\"Linux\", {}).get(\"${ITYPE}\", {}).get(\"r\", 4)}') except Exception: pass " 2>/dev/null || true) while IFS=':' read -r r_id r_tier; do r_id=$(echo "${r_id}" | tr -d '[:space:]') r_tier=$(echo "${r_tier}" | tr -d '[:space:]') [ -n "${r_id}" ] && REGION_INTR_TIERS["${r_id}"]="${r_tier}" done <<< "${ADVISOR_DATA}" fi declare -A REG_STATUS REG_MIN_PRICES REG_MIN_AZS REG_AZ_COUNTS REG_DETAILS REG_INTR_TIERS REG_SCORES REG_RANKS BEST_PRICE="999.0" BEST_PRICE_REGION="" for REGION in "${REGIONS[@]}"; do TIER="${REGION_INTR_TIERS[${REGION}]:-4}" REG_INTR_TIERS["${REGION}"]="${TIER}" RAW_PRICES=$(aws ec2 describe-spot-price-history \ --region "${REGION}" --instance-types "${ITYPE}" --product-descriptions "Linux/UNIX" \ --start-time "${START_TIME}" --no-paginate \ --query 'SpotPriceHistory[*].[AvailabilityZone, SpotPrice]' \ --output text 2>/dev/null | tr -d '\r' || true) [ -z "${RAW_PRICES}" ] && { REG_STATUS["${REGION}"]="unavailable"; continue; } REG_MIN_PRICE="999.0" REG_MIN_AZ="" AZ_COUNT=0 AZ_DETAILS="" while read -r AZ PRICE; do AZ=$(echo "${AZ}" | tr -d '[:space:]') PRICE=$(echo "${PRICE}" | tr -d '[:space:]') [ -z "${AZ}" ] || [ -z "${PRICE}" ] && continue AZ_COUNT=$((AZ_COUNT + 1)) AZ_DETAILS="${AZ_DETAILS}${AZ_DETAILS:+, }${AZ}: \$${PRICE}" awk -v p="${PRICE}" -v m="${REG_MIN_PRICE}" 'BEGIN {exit !(p < m)}' 2>/dev/null && { REG_MIN_PRICE="${PRICE}"; REG_MIN_AZ="${AZ}"; } done <<< "${RAW_PRICES}" [ "${REG_MIN_PRICE}" = "999.0" ] && { REG_STATUS["${REGION}"]="unavailable"; continue; } REG_STATUS["${REGION}"]="ok" REG_MIN_PRICES["${REGION}"]="${REG_MIN_PRICE}" REG_MIN_AZS["${REGION}"]="${REG_MIN_AZ}" REG_AZ_COUNTS["${REGION}"]="${AZ_COUNT}" REG_DETAILS["${REGION}"]="${AZ_DETAILS}" awk -v p="${REG_MIN_PRICE}" -v m="${BEST_PRICE}" 'BEGIN {exit !(p < m)}' 2>/dev/null && { BEST_PRICE="${REG_MIN_PRICE}"; BEST_PRICE_REGION="${REGION}"; } done BEST_SCORE=-1 BEST_SCORE_REGION="" for REGION in "${REGIONS[@]}"; do [ "${REG_STATUS[${REGION}]}" != "ok" ] && continue PRICE="${REG_MIN_PRICES[${REGION}]}" TIER="${REG_INTR_TIERS[${REGION}]}" AZ_COUNT="${REG_AZ_COUNTS[${REGION}]}" P_SCORE=$(awk -v min="${BEST_PRICE}" -v cur="${PRICE}" 'BEGIN {printf "%.0f", 40.0 * (min / cur)}') case "${TIER}" in 0) I_SCORE=20 ;; 1) I_SCORE=15 ;; 2) I_SCORE=10 ;; 3) I_SCORE=5 ;; *) I_SCORE=0 ;; esac [ "${AZ_COUNT}" -ge 5 ] && C_SCORE=40 || { [ "${AZ_COUNT}" -eq 4 ] && C_SCORE=32 || { [ "${AZ_COUNT}" -eq 3 ] && C_SCORE=24 || { [ "${AZ_COUNT}" -eq 2 ] && C_SCORE=16 || C_SCORE=8; }; }; } TOTAL_SCORE=$(( P_SCORE + I_SCORE + C_SCORE )) REG_SCORES["${REGION}"]="${TOTAL_SCORE}" [ "${TOTAL_SCORE}" -ge 75 ] && REG_RANKS["${REGION}"]="Rank S" || { [ "${TOTAL_SCORE}" -ge 65 ] && REG_RANKS["${REGION}"]="Rank A" || { [ "${TOTAL_SCORE}" -ge 50 ] && REG_RANKS["${REGION}"]="Rank B" || REG_RANKS["${REGION}"]="Rank C"; }; } [ "${TOTAL_SCORE}" -gt "${BEST_SCORE}" ] && { BEST_SCORE="${TOTAL_SCORE}"; BEST_SCORE_REGION="${REGION}"; } done for REGION in "${REGIONS[@]}"; do LABEL=$(get_region_label "${REGION}") ONDEMAND=$(get_ondemand_price "${REGION}" "${ITYPE}") if [ "${REG_STATUS[${REGION}]}" != "ok" ]; then pad_region_id "${REGION}"; pad_region_label "${LABEL}"; printf "%s " "取得不可"; printf "%-14s" "\$${ONDEMAND}"; printf "%-10s %-12s %-14s %-8s %-16s" "-" "-" "-" "-" "-"; echo "⚠️ 在庫なし/権限"; continue fi PRICE="${REG_MIN_PRICES[${REGION}]}" AZ="${REG_MIN_AZS[${REGION}]}" AZ_COUNT="${REG_AZ_COUNTS[${REGION}]}" TIER="${REG_INTR_TIERS[${REGION}]}" INTR_LABEL=$(get_intr_text "${TIER}") SCORE="${REG_SCORES[${REGION}]}" RANK="${REG_RANKS[${REGION}]}" DISCOUNT=$(awk -v p="${PRICE}" -v o="${ONDEMAND}" 'BEGIN {printf "%.0f", (1 - p/o)*100}') JPY=$(awk -v p="${PRICE}" -v r="${USD_JPY_RATE}" 'BEGIN {printf "%.0f", p * r}') SPOT_FMT=$(awk -v p="${PRICE}" 'BEGIN {printf "%.4f", p}') BADGE="" [ "${REGION}" = "${BEST_SCORE_REGION}" ] && BADGE="★ 総合第1位 推奨!" || { [ "${REGION}" = "${BEST_PRICE_REGION}" ] && BADGE="最安値 (中断に注意)" || { [ "${REGION}" = "ap-northeast-1" ] && BADGE="国内要件時推奨" || BADGE="予備候補"; }; } pad_region_id "${REGION}"; pad_region_label "${LABEL}"; printf "%-14s" "\$${SPOT_FMT}"; printf "%-14s" "\$${ONDEMAND}"; printf "%-10s" "${DISCOUNT}% OFF"; pad_jpy "${JPY}"; pad_intr "${INTR_LABEL}"; printf "%-8s" "${AZ_COUNT} AZ"; pad_score "${SCORE}" "${RANK}"; echo "${BADGE}" [ "${SHOW_DETAIL}" = true ] && echo " └─ AZ別内訳: ${REG_DETAILS[${REGION}]}" done echo "------------------------------------------------------------------------------------------------------------------------" if [ -n "${BEST_SCORE_REGION}" ]; then B_LABEL=$(get_region_label "${BEST_SCORE_REGION}") B_PRICE=$(awk -v p="${REG_MIN_PRICES[${BEST_SCORE_REGION}]}" 'BEGIN {printf "%.4f", p}') B_JPY=$(awk -v p="${B_PRICE}" -v r="${USD_JPY_RATE}" 'BEGIN {printf "%.0f", p * r}') echo " 🏆 現在の総合推奨: [${BEST_SCORE_REGION} (${B_LABEL})] ${REG_SCORES[${BEST_SCORE_REGION}]}点 (${REG_RANKS[${BEST_SCORE_REGION}]}) - \$${B_PRICE}/h (約 ${B_JPY} 円/h) @ AZ: ${REG_MIN_AZS[${BEST_SCORE_REGION}]}" fi done スポット起動スクリプトの実装 # 📌 このステップでやること 各リージョンのスポット価格・中断頻度・余剰キャパシティを複合判定して最適リージョン(Rank S)を自動選定し、インスタンス起動からSSHトンネル確立・vLLM待機までをコマンド1発で実行します。 💡 起動高速化の決定打:S3モデル同期とECRコンテナpullの完全並列化( 02_ec2_userdata.sh ) # EC2内部で実行される初期化スクリプト( 02_ec2_userdata.sh )は、第2回の基本機能(ローカルNVMeの活用、アイドル1時間自動終了、Tool Calling対応など)をベースとしつつ、 「マルチリージョン運用のコールドスタート短縮」のための重要な並列化チューニング を施しています。 なぜ並列化が必要なのか? 欧州(フランクフルト)や米国など海外リージョンでスポットを起動する場合、従来の「S3モデルダウンロード(約15GB) → \rightarrow → 完了後に Docker pull(約12GB)」という直列処理では、通信の合計時間が 約4.5〜6分 に達し、クライアント側の起動待機タイムアウト(5分)を超過してしまうリスクがありました。 S3とECR(Dockerレジストリ)はネットワーク通信先も書き込み先ディレクトリも完全に独立しているため、 g6.xlarge の広帯域(最大10Gbps)と高速NVMe SSDを活かして バックグラウンド実行( & )と wait による完全並列ダウンロード を実装しました。 【従来の直列実行】 |--- S3同期 (約2分) ---|--- Docker pull (約2.5分) ---|-- GPU展開 (1分) --| 計 5.5分 (タイムアウトの危険) 【並列化後】 |--- S3同期 (約2分) ---------| |--- Docker pull (約2.5分) --|-- GPU展開 (1分) --| 計 3.5分 (高速&安全に起動!) このチューニングにより、重い2大ダウンロードが同時進行して準備時間が約半減し、欧州リージョンであっても 約3.5分前後で確実にヘルスチェックを通過 します。 02_ec2_userdata.sh(S3・ECR並列ダウンロード最適化版 / クリックで展開) #!/bin/bash set -euo pipefail # ============================================================================== # 02_ec2_userdata.sh # EC2起動時に実行されるUserDataスクリプト (S3・ECR並列ダウンロード最適化版) # # 前提: # - AMI: Ubuntu 22.04 Deep Learning AMI (NVIDIA Driver & Docker導入済み) # - インスタンスタイプ: g6.xlarge (NVIDIA L4 GPU: 24GB VRAM, 250GB NVMe SSD付属) # - IAMロール: S3(ReadOnly/FullAccess) 及び ECR(ReadOnly) 権限アタッチ済み # ============================================================================== LOG_FILE="/var/log/userdata-vllm.log" exec > >(tee -a "${LOG_FILE}") 2>&1 echo "=== [$(date '+%Y-%m-%d %H:%M:%S')] UserData 実行開始 ===" # 設定パラメータ AWS_REGION="${AWS_REGION:-ap-northeast-1}" S3_BUCKET_NAME="${S3_BUCKET_NAME:-my-llm-models-tokyo}" HF_MODEL_ID="${HF_MODEL_ID:-Qwen/Qwen2.5-Coder-7B-Instruct}" SERVED_MODEL_NAME="${SERVED_MODEL_NAME:-Qwen/Qwen2.5-Coder-7B-Instruct}" VLLM_PORT="8000" GPU_MEMORY_UTILIZATION="0.90" MAX_MODEL_LEN="16384" DOCKER_IMAGE="${DOCKER_IMAGE:-vllm/vllm-openai:latest}" echo "取得モデル(HF) : ${HF_MODEL_ID}" echo "公開モデル名 : ${SERVED_MODEL_NAME}" echo "S3バケット : s3://${S3_BUCKET_NAME}" echo "Dockerイメージ : ${DOCKER_IMAGE}" # 1. ローカル NVMe インスタンスストア(250GB)の検出と活用 NVME_DIR="/opt/dlami/nvme" if mountpoint -q "${NVME_DIR}" || [ -d "${NVME_DIR}" ]; then echo "DLAMI既定の NVMe マウント (${NVME_DIR}) を検出しました。モデル&Docker領域として活用します..." 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 echo "ローカル NVMe SSD (/dev/${NVME_DEV}) を検出しました。/data にマウントします..." 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 & containerd のデータ領域を NVMe に配置し、EBS枯渇防止&レイヤー展開を高速化 echo "Docker/containerd を停止して 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 if ! grep -q "/var/lib/docker" /etc/fstab; then echo "${NVME_DIR}/docker /var/lib/docker none defaults,bind 0 0" >> /etc/fstab fi if ! grep -q "/var/lib/containerd" /etc/fstab; then echo "${NVME_DIR}/containerd /var/lib/containerd none defaults,bind 0 0" >> /etc/fstab fi mkdir -p /etc/docker cat <<EOF > /etc/docker/daemon.json { "data-root": "/var/lib/docker" } EOF systemctl daemon-reload systemctl start containerd systemctl start docker # ============================================================================== # 3. S3モデル同期 と ECR Dockerイメージpull の完全並列実行 (高速化の肝) # ============================================================================== echo "=== [$(date '+%Y-%m-%d %H:%M:%S')] S3モデル同期とDockerイメージ取得を並列開始します ===" S3_SRC="s3://${S3_BUCKET_NAME}/models/${HF_MODEL_ID}" aws configure set default.s3.max_concurrent_requests 20 # [並列タスクA] Dockerイメージの取得 (ECRログイン & pull) ( 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}') echo "ECR ログイン認証中 (${ECR_REGION:-${AWS_REGION}})..." aws ecr get-login-password --region "${ECR_REGION:-${AWS_REGION}}" | docker login --username AWS --password-stdin "${ECR_REGISTRY}" || true fi echo "docker pull 実行中..." docker pull "${DOCKER_IMAGE}" echo "--- [Task A] Dockerイメージ取得完了 ---" ) > /var/log/docker-pull.log 2>&1 & PID_DOCKER_PULL=$! # [並列タスクB] S3モデルデータの高速同期 rm -f /tmp/.has_s3_model ( echo "--- [Task B] S3モデル同期開始: ${S3_SRC} ---" for i in {1..15}; do if aws s3 ls "s3://${S3_BUCKET_NAME}" --region "${AWS_REGION}" >/dev/null 2>&1; then break fi sleep 2 done if aws s3 ls "${S3_SRC}/" --region "${AWS_REGION}" 2>&1 | grep -E '(\.safetensors|\.bin|\.json)' >/dev/null; then echo "S3からモデルを高速同期中..." aws s3 sync "${S3_SRC}" "${LOCAL_MODEL_DIR}" --region "${AWS_REGION}" --no-progress touch /tmp/.has_s3_model else echo "S3にモデルがないため、Hugging Faceから直接取得中..." python3 -m pip install -U "huggingface_hub[cli]" || pip3 install -U "huggingface_hub[cli]" || true python3 -c " import sys from huggingface_hub import snapshot_download try: snapshot_download(repo_id='${HF_MODEL_ID}', local_dir='${LOCAL_MODEL_DIR}', local_dir_use_symlinks=False) except Exception as e: print(f'ダウンロードエラー: {e}', file=sys.stderr) sys.exit(1) " fi echo "--- [Task B] モデルダウンロード完了 ---" ) > /var/log/model-sync.log 2>&1 & PID_MODEL_SYNC=$! echo "並列ダウンロード待機中 (Docker Pull: PID ${PID_DOCKER_PULL} / S3 Sync: PID ${PID_MODEL_SYNC})..." wait "${PID_DOCKER_PULL}" wait "${PID_MODEL_SYNC}" echo "=== [$(date '+%Y-%m-%d %H:%M:%S')] S3モデル同期・Docker pull 両方の完了を確認しました! ===" [ -f /tmp/.has_s3_model ] && HAS_S3_MODEL=true || HAS_S3_MODEL=false echo "ローカルモデル容量確認:" du -sh "${LOCAL_MODEL_DIR}" # 4. vLLMコンテナの起動 (ローカルにイメージもモデルも揃っているため即時起動) echo "=== [$(date '+%Y-%m-%d %H:%M:%S')] vLLMコンテナ起動 ===" CONTAINER_NAME="vllm-server" docker rm -f "${CONTAINER_NAME}" 2>/dev/null || true docker run -d \ --name "${CONTAINER_NAME}" \ --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. ヘルスチェック (起動待機) echo "vLLM サーバーの起動ヘルスチェックを開始します (ポート ${VLLM_PORT})..." MAX_RETRIES=120 RETRY_COUNT=0 while [ ${RETRY_COUNT} -lt ${MAX_RETRIES} ]; do if curl -s "http://127.0.0.1:${VLLM_PORT}/health" > /dev/null 2>&1; then echo "=== [$(date '+%Y-%m-%d %H:%M:%S')] vLLMサーバーが正常に起動しました! ===" break fi echo "起動待機中... (${RETRY_COUNT}/${MAX_RETRIES})" sleep 5 RETRY_COUNT=$((RETRY_COUNT + 1)) done if [ ${RETRY_COUNT} -eq ${MAX_RETRIES} ]; then echo "警告: vLLMヘルスチェックがタイムアウトしました。'docker logs ${CONTAINER_NAME}' を確認してください。" else if [ "${HAS_S3_MODEL}" = "false" ]; then echo "=== [$(date '+%Y-%m-%d %H:%M:%S')] 初回取得モデルを裏でS3へバックアップ開始 ===" ( if ! aws s3 ls "s3://${S3_BUCKET_NAME}" --region "${AWS_REGION}" 2>/dev/null; then if [ "${AWS_REGION}" = "us-east-1" ]; then aws s3api create-bucket --bucket "${S3_BUCKET_NAME}" 2>/dev/null || true else aws s3api create-bucket --bucket "${S3_BUCKET_NAME}" --region "${AWS_REGION}" --create-bucket-configuration LocationConstraint="${AWS_REGION}" 2>/dev/null || true fi fi ionice -c 3 aws s3 sync "${LOCAL_MODEL_DIR}" "${S3_SRC}" --region "${AWS_REGION}" --no-progress 2>/dev/null || \ aws s3 sync "${LOCAL_MODEL_DIR}" "${S3_SRC}" --region "${AWS_REGION}" --no-progress 2>/dev/null || true echo "[$(date '+%Y-%m-%d %H:%M:%S')] S3モデルバックアップ完了!" ) > /var/log/s3-backup.log 2>&1 & fi fi # 6. 1時間アイドル時の自動シャットダウン(自爆Terminate)デーモン起動 echo "=== アイドル自動終了デーモンを設定・起動します ===" cat <<'EOF' > /usr/local/bin/auto-idle-shutdown.sh #!/bin/bash IDLE_LIMIT_SEC=3600 # 1時間 (3600秒) IDLE_COUNT=0 CHECK_INTERVAL=300 # 5分おきにチェック while true; do sleep "${CHECK_INTERVAL}" REQ_COUNT=$(docker logs --since 5m vllm-server 2>&1 | grep -c "POST /v1" || true) if [ "${REQ_COUNT}" -eq 0 ]; then IDLE_COUNT=$((IDLE_COUNT + CHECK_INTERVAL)) echo "[$(date '+%Y-%m-%d %H:%M:%S')] アイドル継続中: ${IDLE_COUNT}s / ${IDLE_LIMIT_SEC}s" if [ "${IDLE_COUNT}" -ge "${IDLE_LIMIT_SEC}" ]; then echo "[$(date '+%Y-%m-%d %H:%M:%S')] 1時間アイドル状態が継続したため、自動終了(Terminate)を実行します。" shutdown -h now exit 0 fi else IDLE_COUNT=0 fi done EOF chmod +x /usr/local/bin/auto-idle-shutdown.sh nohup /usr/local/bin/auto-idle-shutdown.sh > /var/log/auto-idle-shutdown.log 2>&1 & echo "=== [$(date '+%Y-%m-%d %H:%M:%S')] UserData 全処理完了 (自動アイドル監視稼働中) ===" 🔍 第2回(オンデマンド)からの起動スクリプト変更点 # 本スクリプト( ec2_launch_spot_and_sync_openwebui.sh )でオンデマンド起動から追加・変更した主なポイントは以下の3点です: --instance-market-options によるスポット指定 : aws ec2 run-instances に以下のオプションを渡し、スポットインスタンスとして起動しています: { "MarketType": "spot", "SpotOptions": { "SpotInstanceType": "one-time", "InstanceInterruptionBehavior": "terminate" } } SpotInstanceType: one-time : 単発リクエスト(中断時にAWS側で勝手に再起動されるのを防ぎ、スクリプト側で次の最適リージョンへ制御するため)。 InstanceInterruptionBehavior: terminate : 中断時にインスタンスとEBSを即座に完全破棄し、課金残りを防ぐ。 リアルタイム総合優先度スコアリングによる最適リージョンの自動選定 : 単なるスポット価格だけでなく、AWS Spot Advisor の実績中断頻度(安定性)と余剰AZ数(キャパシティ)を100点満点で複合判定し、最も中断リスクが低く安価な最適リージョン(Rank S)を自動選定して起動先に選びます(時差を活用して欧州フランクフルト等も自動選択されます)。 選定リージョンに合わせたリソースの動的バインド : 選ばれた最適リージョンに応じて、同一リージョン内のS3バケット・ECRイメージ・AMI ID・専用セキュリティグループを自動で特定して起動コマンドに注入します。 それでは、中核となるスポット起動&SSHトンネル自動確立スクリプト( ec2_launch_spot_and_sync_openwebui.sh )の全文です。 ec2_launch_spot_and_sync_openwebui.sh(クリックで展開) #!/bin/bash set -euo pipefail # ============================================================================== # ec2_launch_spot_and_sync_openwebui.sh # 最安リージョンのスポットインスタンスを自動選定・起動し、 # SSHトンネルを確立してOpen WebUIへ直結するスクリプト # ============================================================================== SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" USER_DATA_FILE="${USER_DATA_FILE:-${SCRIPT_DIR}/02_ec2_userdata.sh}" INSTANCE_STATE_FILE="${SCRIPT_DIR}/.current_instance_id" TUNNEL_PID_FILE="${SCRIPT_DIR}/.current_tunnel_pid" REGION_STATE_FILE="${SCRIPT_DIR}/.current_region" # 候補リージョン一覧 (デフォルト: 東京, オレゴン, バージニア, フランクフルト) CANDIDATE_REGIONS=("ap-northeast-1" "us-west-2" "us-east-1" "eu-central-1") CANDIDATE_INSTANCE_TYPES=("g6.xlarge") IAM_ROLE_NAME="${IAM_ROLE_NAME:-EC2-S3-FullAccess-Profile}" EBS_SIZE_GB="${EBS_SIZE_GB:-40}" KEY_NAME="${KEY_NAME:-my-vllm-models-hackathon-2026}" KEY_PATH="${KEY_PATH:-${HOME}/.ssh/${KEY_NAME}.pem}" echo "=== 1. マルチリージョン スポット総合優先度探索 (g6.xlarge) ===" ITYPE="${CANDIDATE_INSTANCE_TYPES[0]}" START_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)" # Spot Advisor 中断頻度を一括取得 declare -A REGION_INTR_TIERS PY_BIN="" command -v python3 >/dev/null 2>&1 && PY_BIN="python3" || (command -v python >/dev/null 2>&1 && PY_BIN="python") if [ -n "${PY_BIN}" ]; then ADVISOR_DATA=$("${PY_BIN}" -c " import urllib.request, json try: with urllib.request.urlopen('https://spot-bid-advisor.s3.amazonaws.com/spot-advisor-data.json', timeout=4) as res: adv = json.loads(res.read()).get('spot_advisor', {}) for r, info in adv.items(): print(f'{r}:{info.get(\"Linux\", {}).get(\"${ITYPE}\", {}).get(\"r\", 4)}') except Exception: pass " 2>/dev/null || true) while IFS=':' read -r r_id r_tier; do r_id=$(echo "${r_id}" | tr -d '[:space:]') r_tier=$(echo "${r_tier}" | tr -d '[:space:]') [ -n "${r_id}" ] && REGION_INTR_TIERS["${r_id}"]="${r_tier}" done <<< "${ADVISOR_DATA}" fi declare -A REG_STATUS REG_MIN_PRICES REG_AZ_COUNTS REG_SCORES REG_RANKS BEST_PRICE="999.0" for REGION in "${CANDIDATE_REGIONS[@]}"; do RAW_PRICES=$(aws ec2 describe-spot-price-history \ --region "${REGION}" \ --instance-types "${ITYPE}" \ --product-descriptions "Linux/UNIX" \ --start-time "${START_TIME}" \ --no-paginate \ --query 'SpotPriceHistory[*].[AvailabilityZone, SpotPrice]' \ --output text 2>/dev/null | tr -d '\r' || true) [ -z "${RAW_PRICES}" ] && { REG_STATUS["${REGION}"]="unavailable"; continue; } REG_MIN_PRICE="999.0" AZ_COUNT=0 while read -r AZ PRICE; do AZ=$(echo "${AZ}" | tr -d '[:space:]') PRICE=$(echo "${PRICE}" | tr -d '[:space:]') [ -z "${AZ}" ] || [ -z "${PRICE}" ] && continue AZ_COUNT=$((AZ_COUNT + 1)) if awk -v p="${PRICE}" -v m="${REG_MIN_PRICE}" 'BEGIN {exit !(p < m)}' 2>/dev/null; then REG_MIN_PRICE="${PRICE}" fi done <<< "${RAW_PRICES}" [ "${REG_MIN_PRICE}" = "999.0" ] && { REG_STATUS["${REGION}"]="unavailable"; continue; } REG_STATUS["${REGION}"]="ok" REG_MIN_PRICES["${REGION}"]="${REG_MIN_PRICE}" REG_AZ_COUNTS["${REGION}"]="${AZ_COUNT}" if awk -v p="${REG_MIN_PRICE}" -v m="${BEST_PRICE}" 'BEGIN {exit !(p < m)}' 2>/dev/null; then BEST_PRICE="${REG_MIN_PRICE}" fi done # スコア計算 (価格40点 + 余剰AZ数40点 + 中断頻度20点 = 100点満点) BEST_REGION="" BEST_SCORE=-1 for REGION in "${CANDIDATE_REGIONS[@]}"; do [ "${REG_STATUS[${REGION}]:-}" != "ok" ] && continue PRICE="${REG_MIN_PRICES[${REGION}]}" TIER="${REGION_INTR_TIERS[${REGION}]:-4}" AZ_COUNT="${REG_AZ_COUNTS[${REGION}]}" P_SCORE=$(awk -v min="${BEST_PRICE}" -v cur="${PRICE}" 'BEGIN {printf "%.0f", 40.0 * (min / cur)}') case "${TIER}" in 0) I_SCORE=20; I_TEXT="< 5% (極低)" ;; 1) I_SCORE=15; I_TEXT="5-10% (低)" ;; 2) I_SCORE=10; I_TEXT="10-15% (中)" ;; 3) I_SCORE=5; I_TEXT="15-20% (中高)" ;; *) I_SCORE=0; I_TEXT="> 20% (高)" ;; esac if [ "${AZ_COUNT}" -ge 5 ]; then C_SCORE=40 elif [ "${AZ_COUNT}" -eq 4 ]; then C_SCORE=32 elif [ "${AZ_COUNT}" -eq 3 ]; then C_SCORE=24 elif [ "${AZ_COUNT}" -eq 2 ]; then C_SCORE=16 else C_SCORE=8 fi TOTAL_SCORE=$(( P_SCORE + I_SCORE + C_SCORE )) REG_SCORES["${REGION}"]="${TOTAL_SCORE}" if [ "${TOTAL_SCORE}" -ge 75 ]; then RANK="Rank S" elif [ "${TOTAL_SCORE}" -ge 65 ]; then RANK="Rank A" elif [ "${TOTAL_SCORE}" -ge 50 ]; then RANK="Rank B" else RANK="Rank C" fi REG_RANKS["${REGION}"]="${RANK}" PRICE_FMT=$(awk -v p="${PRICE}" 'BEGIN {printf "%.4f", p}') echo " - [${REGION}] 価格=\$${PRICE_FMT}/h, 中断率=${I_TEXT}, 余剰AZ=${AZ_COUNT} -> 総合スコア: ${TOTAL_SCORE}点 (${RANK})" if [ "${TOTAL_SCORE}" -gt "${BEST_SCORE}" ]; then BEST_SCORE="${TOTAL_SCORE}" BEST_REGION="${REGION}" fi done AWS_REGION="${BEST_REGION:-ap-northeast-1}" INSTANCE_TYPE="${ITYPE}" SELECTED_PRICE="${REG_MIN_PRICES[${AWS_REGION}]:-0.5672}" SELECTED_PRICE_FMT=$(awk -v p="${SELECTED_PRICE}" 'BEGIN {printf "%.4f", p}') SELECTED_SCORE="${REG_SCORES[${AWS_REGION}]:-40}" SELECTED_RANK="${REG_RANKS[${AWS_REGION}]:-Rank C}" echo "==========================================================" echo " 🏆 総合選定結果: ${AWS_REGION} (スコア: ${SELECTED_SCORE}点 / ${SELECTED_RANK})" echo " インスタンスタイプ: ${INSTANCE_TYPE}, 最安スポット価格: \$${SELECTED_PRICE_FMT}/h" echo "==========================================================" # Deep Learning AMI 検索 AMI_ID=$(aws ec2 describe-images \ --region "${AWS_REGION}" \ --owners amazon \ --filters "Name=name,Values=Deep Learning OSS Nvidia Driver AMI GPU PyTorch * (Ubuntu 22.04)*" "Name=state,Values=available" \ --query 'sort_by(Images, &CreationDate)[-1].ImageId' \ --output text) # SSH専用SGの確認 (ポート22のみ) SG_NAME="vllm-ssh-tunnel-sg" SG_ID=$(aws ec2 describe-security-groups \ --region "${AWS_REGION}" \ --filters "Name=group-name,Values=${SG_NAME}" \ --query 'SecurityGroups[0].GroupId' --output text 2>/dev/null || true) # セキュリティグループIDの厳格な検証 (デフォルトSGでの起動事故を完全防止) if [ -z "${SG_ID}" ] || [ "${SG_ID}" = "None" ] || [[ ! "${SG_ID}" =~ ^sg-[0-9a-f]+$ ]]; then echo "エラー: セキュリティグループの取得・作成に失敗しました (SG_ID: '${SG_ID}')" echo "デフォルトセキュリティグループでの誤起動を防止するため、処理を中止します。" exit 1 fi SPOT_OPTIONS='{ "MarketType": "spot", "SpotOptions": { "SpotInstanceType": "one-time", "InstanceInterruptionBehavior": "terminate" } }' BLOCK_DEVICE_MAPPINGS="[ { \"DeviceName\": \"/dev/sda1\", \"Ebs\": { \"VolumeSize\": ${EBS_SIZE_GB}, \"VolumeType\": \"gp3\", \"DeleteOnTermination\": true } } ]" AWS_ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text) export S3_BUCKET_NAME="my-vllm-models-hackathon-2026-${AWS_ACCOUNT_ID}-${AWS_REGION}-an" ECR_IMAGE="${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com/vllm-openai:latest" if aws ecr describe-images --region "${AWS_REGION}" --repository-name "vllm-openai" --image-ids imageTag=latest >/dev/null 2>&1; then DOCKER_IMAGE="${ECR_IMAGE}" else DOCKER_IMAGE="vllm/vllm-openai:latest" fi TMP_USERDATA=$(mktemp) sed -e "s|^AWS_REGION=.*|AWS_REGION=\"${AWS_REGION}\"|" \ -e "s|^S3_BUCKET_NAME=.*|S3_BUCKET_NAME=\"${S3_BUCKET_NAME}\"|" \ -e "s|^DOCKER_IMAGE=.*|DOCKER_IMAGE=\"${DOCKER_IMAGE}\"|" \ "${USER_DATA_FILE}" > "${TMP_USERDATA}" echo "インスタンスタイプ '${INSTANCE_TYPE}' でスポット起動をリクエスト中..." RUN_OUTPUT=$(aws ec2 run-instances \ --region "${AWS_REGION}" \ --image-id "${AMI_ID}" \ --instance-type "${INSTANCE_TYPE}" \ --key-name "${KEY_NAME}" \ --iam-instance-profile "Name=${IAM_ROLE_NAME}" \ --security-group-ids "${SG_ID}" \ --instance-market-options "${SPOT_OPTIONS}" \ --instance-initiated-shutdown-behavior terminate \ --block-device-mappings "${BLOCK_DEVICE_MAPPINGS}" \ --user-data "file://${TMP_USERDATA}" \ --tag-specifications "ResourceType=instance,Tags=[{Key=Name,Value=vllm-spot-${INSTANCE_TYPE}}]" \ --output json) rm -f "${TMP_USERDATA}" INSTANCE_ID=$(echo "${RUN_OUTPUT}" | jq -r '.Instances[0].InstanceId') echo "スポットインスタンス起動成功: ${INSTANCE_ID} (Type: ${INSTANCE_TYPE})" echo "${INSTANCE_ID}" > "${INSTANCE_STATE_FILE}" echo "${AWS_REGION}" > "${REGION_STATE_FILE}" echo "インスタンスの実行状態(running)を待機中..." aws ec2 wait instance-running --region "${AWS_REGION}" --instance-ids "${INSTANCE_ID}" PUBLIC_IP=$(aws ec2 describe-instances \ --region "${AWS_REGION}" \ --instance-ids "${INSTANCE_ID}" \ --query 'Reservations[0].Instances[0].PublicIpAddress' \ --output text) echo "インスタンス起動完了! パブリックIP: ${PUBLIC_IP}" # 既存SSHトンネルのクリーンアップ&新規確立 if [ -f "${TUNNEL_PID_FILE}" ]; then OLD_PID=$(cat "${TUNNEL_PID_FILE}" | tr -d '[:space:]') if ps -p "${OLD_PID}" > /dev/null 2>&1; then kill -9 "${OLD_PID}" 2>/dev/null || true fi rm -f "${TUNNEL_PID_FILE}" fi # SSH接続待機 while ! nc -z -w 3 "${PUBLIC_IP}" 22 2>/dev/null; do sleep 2 done # バックグラウンドSSHトンネル確立 (localhost:8000 -> EC2内部の8000) ssh -i "${KEY_PATH}" \ -o StrictHostKeyChecking=no \ -o UserKnownHostsFile=/dev/null \ -o ServerAliveInterval=15 \ -o ServerAliveCountMax=3 \ -N -f -L 8000:localhost:8000 \ ubuntu@"${PUBLIC_IP}" TUNNEL_PID=$(pgrep -f "ssh.*-L 8000:localhost:8000.*ubuntu@${PUBLIC_IP}" | head -n 1 || true) if [ -n "${TUNNEL_PID}" ]; then echo "${TUNNEL_PID}" > "${TUNNEL_PID_FILE}" fi echo "=== vLLM サーバーの起動待機 (http://localhost:8000/health) ===" while ! curl -s "http://localhost:8000/health" > /dev/null 2>&1; do sleep 5 done echo "==========================================================" echo " スポット環境の全工程が完了しました!" echo " EC2 Instance ID: ${INSTANCE_ID} (Spot: ${INSTANCE_TYPE}, Region: ${AWS_REGION})" echo " SSH Tunnel : localhost:8000 -> EC2:8000 (暗号化中)" echo " Web UI URL : http://localhost:3000" echo "==========================================================" スクリプトの実行 # 実行は環境変数を設定して叩くだけです: export KEY_NAME="my-vllm-models-hackathon-2026" export IAM_ROLE_NAME="EC2-S3-FullAccess-Profile" ./ec2_launch_spot_and_sync_openwebui.sh 実行ログ: === 1. マルチリージョン スポット総合優先度探索 (g6.xlarge) === - [ap-northeast-1] 価格=$0.5675/h, 中断率=> 20% (高), 余剰AZ=2 -> 総合スコア: 46点 (Rank C) - [us-west-2] 価格=$0.4264/h, 中断率=> 20% (高), 余剰AZ=4 -> 総合スコア: 72点 (Rank A) - [us-east-1] 価格=$0.5552/h, 中断率=> 20% (高), 余剰AZ=5 -> 総合スコア: 71点 (Rank A) - [eu-central-1] 価格=$0.4536/h, 中断率=< 5% (極低), 余剰AZ=3 -> 総合スコア: 82点 (Rank S) ========================================================== 🏆 総合選定結果: eu-central-1 (スコア: 82点 / Rank S) インスタンスタイプ: g6.xlarge, 最安スポット価格: $0.4536/h ========================================================== === 2. スポットインスタンス起動試行 === インスタンスタイプ 'g6.xlarge' でスポット起動をリクエスト中... スポットインスタンス起動成功: i-0123456789abcdef0 (Type: g6.xlarge) インスタンスの実行状態(running)を待機中... インスタンス起動完了! インスタンスID: i-0123456789abcdef0 (g6.xlarge) パブリックIP : 34.xxx.xxx.xxx === 3. SSHポートフォワード接続 (暗号化トンネル確立) === EC2のSSHD起動を待機中... SSHD応答確認! SSHトンネル確立完了! (PID: 12345) ローカルの http://localhost:8000 が安全にEC2内部のvLLMへ転送されます。 === 4. vLLM サーバーの起動待機 (http://localhost:8000/health) === UserDataによるS3モデルダウンロードとvLLM起動を待機しています(通常2〜4分程度)... .........vLLM サーバーが正常に応答しました! ========================================================== スポット環境の全工程が完了しました! EC2 Instance ID: i-0123456789abcdef0 (Spot: g6.xlarge, Region: eu-central-1) EC2 Public IP : 34.xxx.xxx.xxx (ポート8000は外部非公開) SSH Tunnel : localhost:8000 -> EC2:8000 (暗号化中) Web UI URL : http://localhost:3000 次のステップ: VS Code の Cline から接続してください Base URL: http://localhost:3000/api ========================================================== あとは通常通り VS Code の Cline から接続して開発を進めるだけです! いつも通り快適にClineが自律コーディングを行いますが、 裏側で発生しているコストはオンデマンドの3分の1以下(1時間数十円) 。コストを気にせず開発に集中できる環境は非常に快適です。 --> Information 🔧 スポット運用でつまづきやすいポイントと解決策(Q&A) Q1. 「MaxSpotInstanceCountExceeded」エラーで起動できない → \rightarrow → スポットインスタンス用のクォータ( All G and VT Spot Instance Requests )が未解除か、チーム内での同時起動数がクォータ上限に達しています。対象リージョンでService Quotasの上限緩和申請を行うか、マルチリージョン設定で別リージョンを探索・利用するか、開発者ごとのマルチアカウント運用(AWS Organizations)をご検討ください。 Q2. 「InsufficientInstanceCapacity(在庫不足)」エラーになる → \rightarrow → そのリージョン・AZで一時的に余剰キャパシティが枯渇しています。スクリプトを再実行すれば別の候補リージョンが選定されるか、少し時間を置いて再試行してください。 Q3. インスタンスは起動したがSSHトンネルが接続タイムアウトする → \rightarrow → 起動リージョンに専用セキュリティグループ( vllm-ssh-tunnel-sg )が存在せず、defaultセキュリティグループが適用されている可能性があります。事前準備スクリプト( setup_multiregion_assets.sh )を実行して各リージョンにポート22許可のSGが作成されているか確認してください。 Q4. 作業中に中断(強制終了)された場合、Open WebUIやClineの再設定は必要? → \rightarrow → 一切不要です。新しいインスタンスが立ち上がった後、スクリプトがSSHトンネル( localhost:8000 )を自動で再接続するため、ブラウザやVS Codeの設定はそのままで作業を再開できます。 実際にスポット運用してみた所感 # 📌 このセクションの要点 短期の個人検証では中断に遭遇しませんでしたが、これは統計的な保証ではありません。スポットを利用する以上は「いつ中断されても作業を素早く再開できる構え」と割り切りを持つことが肝要です。 数日間にわたり、休日の個人開発や平日の夜間検証でスポット運用を試してみたリアルな所感です。 1. 中断される頻度はどのくらい? # 「頻繁に中断されて使い物にならないのでは?」と心配していましたが、数日間にわたる短期の個人検証(平日夜間や休日に各2〜3時間利用)では、幸いにも一度も中断に遭遇することはありませんでした。 ただし、 これはあくまで「特定時期における、短時間の個人利用セッション」という限定的な条件下での一体験談 に過ぎません。統計的な安定性を保証するエビデンスではなく、AWS全体のAI需要や時期、大規模バッチの稼働状況によってスポット中断率は大きく乱高下します。 そのため、「数日間中断されなかったから今後も安心」と過信するのではなく、 「スポットである以上、いつ中断されても文句は言えない。中断されたらスクリプト再実行で数分で別リージョンへ切り替える」という割り切った姿勢を持つこと が、精神衛生上も実務上も極めて重要です。 もし万が一作業中に中断されたとしても、ソースコードはすべて手元のPC(Git)にあり、モデルやコンテナはS3/ECRに待避されているため、再起動スクリプトを叩けば数分で別ホスト・別リージョンに再度立ち上がります。この「いつでも復旧できる安心感」があるからこそ、スポットの不確実性とポジティブに付き合うことができます。 2. 「使い捨て」への心理的ハードルが完全になくなる # オンデマンドだと「1時間1.4ドルか……少し急いで検証しよう」と無意識に焦ってしまいがちですが、スポットなら1時間数十円。「ちょっとトイレに行ってコーヒーを淹れてこよう」「途中でドキュメントをじっくり読もう」といった場面でも、焦る必要が全くありません。 検証終了時はワンコマンドで完全破棄 # 作業が終わったら、破棄スクリプト( ec2_terminate.sh )でTerminateします。 ec2_terminate.sh(クリックで展開) #!/bin/bash set -euo pipefail # ============================================================================== # ec2_terminate.sh # スポットインスタンスおよびSSHトンネルプロセスを完全破棄するスクリプト # ============================================================================== 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" 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 "${1:-}") # 1. SSHトンネルプロセスの終了 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 "SSHトンネルプロセス (PID: ${TUNNEL_PID}) を終了中..." kill -9 "${TUNNEL_PID}" 2>/dev/null || true fi rm -f "${TUNNEL_PID_FILE}" fi # 2. EC2インスタンスの終了 if [ -n "${INSTANCE_ID}" ]; then echo "=== スポットインスタンスの完全終了 (Terminate) ===" aws ec2 terminate-instances --region "${AWS_REGION}" --instance-ids "${INSTANCE_ID}" --output table aws ec2 wait instance-terminated --region "${AWS_REGION}" --instance-ids "${INSTANCE_ID}" rm -f "${INSTANCE_STATE_FILE}" "${REGION_STATE_FILE}" echo "スポットインスタンス、EBSボリューム、SSHトンネルの完全破棄が完了しました!" fi インスタンスとEBSが完全に削除され、これ以降の課金はゼロになります。 もちろん、第1弾・第2弾と同様に「1時間アイドル時の自動終了(Terminate)デーモン」が稼働しているため、万が一終了コマンドを叩き忘れて寝落ちしても、無駄な課金が発生し続けることはありません。 まとめ # 今回は、これまでに構築してきた「S3モデル保管・ECRコンテナ同期+UserData自動構築+EBS即時破棄」という エフェメラル設計を最大限に活かし、EC2スポットインスタンスによるGPU費用の大幅削減 を達成しました。 「AWS認定試験の選択肢で散々見てきたスポットインスタンスのベストプラクティスを、身近な開発現場で本当に使い倒せるのか検証してみたい」という知的好奇心からスタートした試みでしたが、エフェメラルなアーキテクチャ(S3×UserData×コンテナ)との噛み合いの良さはまさに教科書通り、期待以上の成果でした。 相性抜群の組み合わせ : サーバー側に一切の永続データを持たないアーキテクチャだからこそ、スポットインスタンスの中断による致命的なデータ損失を回避できる。 停止時維持費はほぼゼロ(S3・ECR保管で月数百円程度) : 高価なEBS(月約1,600円)を保持し続ける必要がなく、モデルをS3(3リージョンで月約176円)、コンテナイメージをECR(月数十〜数百円)へ保管することで停止時の維持コストを最小化。 短時間集中利用による専有環境 : 各自が必要な時にサッと起動し即座に破棄する運用を徹底することで、1時間あたり約80〜90円の低コストで専用GPUを活用可能(※終日稼働させる場合は共有オンデマンド1台の方が有利になる点に注意)。 マルチリージョン探索による調達性向上 : 時差・価格・余剰キャパシティを考慮したマルチリージョン探索により、国内在庫が逼迫している時間帯でも海外拠点を含めて柔軟にGPUを調達(※機密データを扱う場合は国内限定にするなど、データレジデンシーやネットワーク遅延とのトレードオフを意識)。 1時間数十円で自律AIエージェントを活用可能 : 圧倒的低コストで、最先端のオープンソースLLMとVS Code(Cline)のコラボレーションを気軽に実践可能に。 「クラウドのGPUは高価だから……」と躊躇していた個人開発者や、スポットインスタンスの実践的な活用法を模索していたクラウドエンジニアの方にとって、本記事がエフェメラルなインフラ設計の一例として参考になれば幸いです。
はじめに # GFXBenchというGPUベンチマークソフトを取り上げたシリーズの4回目です。 前回で古いAndroidバージョン対応のビルドが行えました。 今回は実際に古いAndroidバージョンで実行してベンチマークスコアを確認してみます。 ベンチマーク実行結果 # 実行までの手順は今までと変わらないので、早速結果からです。 実行するAndroid端末はAndroidバージョンが古いものを手元のコレクションから見繕ってみます。 目標は SdkVersion:'21' (Android5.0) だったのですが、手元のAndroid端末の都合上、SdkVersion:'22' (Android5.1) までとなりました。 以下、実測した結果例です。ベンチマークスコアはオフスクリーン版のfps値です。 --> Caution あくまで 「筆者の環境および測定時点における一例」 であり、同様のGPU、手順で測定された場合でも、環境によって異なる結果となる可能性がある点をご了承ください。 GPU SoC Driver version Android version T-Rex score Manhattan score Adreno 418 Snapdragon 808 OpenGL ES 3.1 V@103.0 5.1.1 34 15 Kepler GK20A Tegra K1 OpenGL ES 3.2 NVIDIA 361.00 6.0.1 66 32 Kepler GK20A Tegra K1 (Denver) OpenGL ES 3.1 NVIDIA 343.00 7.1.1 63 30 Maxwell GM20B Tegra X1 OpenGL ES 3.2 NVIDIA 361.00 8.1.0 109 59 上記 Tegra X1 のスコアは Pixel C というタブレットのものです。携帯用のためか低い値となっているようで、確か据え置き用のAndroid端末の SHIELD だと T-Rex/Manhattan = 120/60 オーダーのスコアだったかと思います。 この Tegra K1 のスコア T-Rex/Manhattan = 60/30 と Tegra X1 のスコア 同 120/60 のオーダーが長らく私の中で比較する際の基準のスコアとなっていました。 --> Information Tegra K1の SHIELDタブレット で実際にサンプル(Unreal Engine、Unity等)を動かしたりゲームをしたりの感触より。覚えやすい値だったというのもあります。 時代的に仕方ないですが、このあたりのタブレットがメモリ2GBでなく4GB積んでいれば今でも普段使いしていたのに...と惜しく思います。 スマートフォン/タブレットを見る時はまずは Tegra K1 のスコア位あれば十分と見ていたものでした。Tegra X1を超えようものならもうえらい事です(※個人の感想です)。 今回はGPU違いですがオフスクリーン版の結果なのでそのまま比較できます。ただGPUの FLOPS (Floating-point Operations Per Second) も考慮に入れるとまた違った比較が行えます。次回以降に機会があれば触れてみたいと思います。 以下スコア以外の余談です。 ビルド時に CUDA Toolkit が必要だった件ですが、Tegra K1 で無事CUDA情報が表示されていました。Android版でまったく無駄というわけでもなかったようです。例はこれ位かもですが。 今回の古いバージョン5.1.1のAndroid端末でも、Car Chase(OpenGL ES 3.1 + AEP)および Aztec Ruins(OpenGL ES 3.1)までも実行可能でした。 Androidバージョン5.0から OpenGL ES 3.1 + AEP 対応が始まっていたので確かに可能ではあるのですが、実際に描画される画、fpsも低く健気に動く姿に目頭が熱くなる思いでした。 こんな昔からきちんと頑張ってくれていたのだなと...。 おわりに # 今回は前回ビルドした古いAndroidバージョン用のGFXBenchを実際に実行してベンチマークスコアを確認しました。 この記事をお読みの方の中にも、古いAndroid端末を眠らせたままにしている方は多いかもしれません。 久しぶりに取り出して、どこまで頑張れたのかチャレンジしてみてはいかがでしょうか。 より古いAndroidバージョン、より多くのベンチマーク種類の実行、より低いスコアを出せた方が優勝!です。 ライセンスおよび免責事項 # 本記事に掲載している検証結果は、BSD 3-Clause Licenseのもとで公開されている Kishonti-Opensource/gfxbench のソフトウェアおよびアセットを利用したものです。 Original Copyright: (c) 2005–2025 Kishonti Ltd. License: BSD 3-Clause License 【免責事項】 本記事に掲載している手順、ベンチマークスコア等の測定結果は、特定の検証環境における現状のまま(AS IS)のものであり、その正確性、安全性、再現性を保証するものではありません。 本情報の利用や検証の実行により生じた直接的・間接的な損害について、筆者および株式会社豆蔵は一切の責任を負いません。内容を十分にご確認の上、ご自身の責任においてご利用ください。
 この記事を読んでいただきありがとうございます。アジャイルグループに所属する、藤井智弘です。  第3回から5回は、「アジャイルの基礎の解説」としては、だいぶ変化球だった気もするので、驚かれた方もおられるかもしれません。しかし、FAQとしてはほんとによくいただくので、「そういう連載だったな」と思い返していただければ。  第6回は、基本の基本に立ち戻りたいと思います。 質問 #  毎朝15分のデイリースクラムを続けていますが、順番に「昨日やったこと・今日やること・困っていること(3つの質問という名で研修で習ったヤツ)」を各自報告して終わるだけの会になっています。正直、誰も他の人の報告を聞いていません。しかも会が終わった直後に、あちこちで本当の相談が始まります。デイリースクラムという”儀式”には、やる意味があるのでしょうか? 回答 #  「終わった直後に本当の相談が始まる」…質問のこの一文に、答えの糸口があります。  チームには毎朝、話し合うべきことがあります。 誰がどこで詰まっていて、 誰の手が必要で、 何を先に終わらせるか  これが、スプリントゴール達成に向けた「今日の活動の再計画」です。それが会の後で自然に発生しているのですから、このチームは、相談すべき項目がきちんと認識され、相談できる関係性もあるといえます(質問者の方には、「良い兆候である」とお伝えしたいですね)。足りないのは、その会の後の相談と「15分」とのつながりです。各自持ち回りの発表は、誰と誰が今日何をすべきかを浮かび上がらせないまま終わり、本当の相談は、たまたま隣にいた人同士で、たまたま気づいた順に始まる。15分が不要なのではなく、 デイリーが「会の後の相談」を生み出す装置として働いていない のです。  ですから、デイリーをやめると決める前に、報告会になってしまう構造を直しましょう。鍵は「誰に向かって話しているか」と「この15分で何を決めるか」の2つです。 「15分に収めなさい」と言われて、疑問に思ったことはありませんか? #  ところで研修や現場で、「デイリーは15分に収めなさい」「進捗報告にしてはいけません」と指導されたことのある方は多いと思います。そのとき、こんな疑問を持ちませんでしたか? 進捗の共有はそれ自体大事なはずなのに、なぜそれで終わるとダメ出しされるのか。 そもそも15分で何ができるというのか。 全員に必要な情報なら、30分かかっても共有されるほうが大事ではないのか。  私も同じことを思いました。答えを先に言えば、 共有が悪いのではなく、共有「しか」していないのが問題 です。15分は共有には短いが、今日の動きを決めるには十分。そして、全員に本当に必要な情報は思っているほど多くない——これだけでは納得できないと思うので、まずデイリーがどんな姿になりがちかを見たうえで、腰を据えて答えます。 デイリースクラムのあるある #  報告会化したデイリーには、いくつかの型があります。ご自分のチームがどれに近いか、当てはめながら読んでみてください。   その1:報告会型 ——いちばん多い型です。話し手の目線が、チームメイトではなくスクラムマスターやリーダーに向いています。順番に話し、順番が来るまでは待つ。冒頭の質問の状況、そのままです。   その2:顧客報告型 ——業務系の現場で起きやすい型です。発注側の担当者や、部長クラスの管理職が「様子を見に」デイリーに顔を出す時に見られます。関心を持ってくれているという意味では良い面もありますが、その瞬間からデイリーは「顧客向けの進捗報告」に変わります。受注側のメンバーは発注側に向かって話し、リーダーはその場を取り繕い、困りごとはいっそう小さく語られる。場合によっては、「デイリーで何を話すか」を事前に相談する”軽いリハーサル”が行われたりするかもしれません。ちなみに、このような状況では、第3回で見た、発注側と受注側が互いを前にして思考を止める構図が、毎朝再演されます。   その3:他所報告型 。業務系の保守・更改チームには、他案件や運用と兼務のメンバーが珍しくありません。兼務者のデイリーは、放っておくと「今日は午前中に別案件の障害対応があって、午後は会議で…」という、他所の予定もふくめた「私の作業の全体」の報告になりがちです。本人は誠実に話しているのですが、このチームの再計画には何の材料も与えません。   その4:日報型 。リモートのチームによく見られる、デイリーをチャットへの投稿で済ませている形です。各自が「昨日・今日・困りごと」を書き込んで終わり。記録が残る点は悪くありませんが、誰も読まず、誰も返さず、書くことが目的になります。宛先のない報告——つまり日報であり、報告会化の行き着いた姿です。   その5:延長戦型 ——報告会化と同時にチームが陥りがちな型です。相談が会の中で始まり、15分が30分、40分と延びていく。相談が行われること自体は、単なる報告会よりも前進ですが、一部の人に必要な話を全員が聞いている時間が、毎朝積み上がります。 根底にある勘違いはなにか? #  五つの型は見た目が違いますが、根底にある勘違いは共通しています。「デイリーは情報を共有する場だ」という前提です。この前提に立つと、共有が済めば会は成功であり、時間がかかるなら延ばせばよく、共有したい人が増えるなら同席させればよい、という判断がすべて自然に導かれます。五つの型は、この前提を素直に実行した結果にすぎません。  ここで、冒頭に掲げた「15分に収めなさいと言われた時に感じる3つの疑問」について考えましょう。  まず、 共有で終わることの何が悪いのか 。進捗の共有そのものは重要で、悪くありません。問題は手段です。人が声に出して順番に共有するのは、共有の手段の中で最も高くつきます。8人で15分なら、毎朝2時間分の工数です。しかも報告会型の共有は「順調」に寄るので、情報の質も落ちがちです。 情報の共有であればタスクボードとバーンダウンチャートのほうが安く、正確で、いつでも見られます 。つまり報告会型のデイリーは、ボードのほうが上手にできることに時間を使い、会にしかできないこと——決めること——を一つもしていない。 共有が悪いのではなく、共有「しか」していないことが問題 なのです。  次に、 15分で何ができるのか、30分かかっても全員で共有したほうが大事ではないか 。15分は、情報を共有するには短すぎますが、今日どう動くかを決めるには足ります。ゴールとの距離を確かめ、いま最も危ない項目を一つ特定し、誰と誰がこの後話すかを決める。ブリーフィングではなく、 トリアージの時間 です。そして「全員に必要な情報」なら、30分かけてでも共有すべきというのは、その通りです。ただ、現場で数えてみると、全員に必要な情報はゴールとの距離くらいで、残りの大半は2〜3人に必要な情報です。30分のデイリーの実態は「一部の人に必要な話を、全員が聞いている時間」であることがほとんどです。全員に関わることだけを全員で、残りは該当者だけで、と分けるのがデイリーの設計であり、15分という枠は「全員に関わることは、それくらいしかない」という経験則の表れです。 毎日30分必要だと感じるなら、ゴールが曖昧か、項目が大きすぎるか、ボードが情報共有を担えていないか——そのどれかを疑う警告だ と受け取ってください。  では、 なぜ「共有の場」という前提が入り込むのか 。多くのメンバーは、スクラム以前の文化——上司に進捗を報告し、指示を待つ——を身につけてから、スクラムチームにやってきます。「朝会で話す」という行為に、これまでの職業人生で染み付いた「報告」のフォーマットが自動的に適用される。悪意も怠慢もありません。ただ宛先(誰に向けて伝えるか)が違うだけです。しかし宛先が違うと、会の性質は根本から変わります。宛先が管理者なら、目的は「自分がちゃんとやっていると伝えること」になり、順調であることを示す話が増え、困りごとは小さく語られ、他人の報告は「自分の番を待つ時間」になります。「透明性のために同席してもらう」も、同じ勘違いの派生です。  よく知られている「3つの質問」も、この勘違いを補強してきました。台本として読み上げれば、それは共有の手順にしかなりません。スクラムガイド2020から「3つの質問」の記載が消えたのは、手段の目的化を避けるためです。もうひとつの構造要因は、スプリントゴールの不在です。ゴールが「今スプリントに積んだタスクを全部終わらせる」程度の意味しか持っていないと、「ゴールに向けて再計画する」という行為が成立せず、残るのは個人ごとの作業報告だけになります。 デイリーの形骸化は、しばしばゴールの形骸化の症状です (ゴールの立て方は第2回でも触れました。今後もたびたび出てくるはずです。それだけ急所だということです)。  では、本来のデイリースクラムは何をする時間か。スクラムの基本は経験主義で、その柱は 透明性・検査・適応 の3つです。デイリースクラムは、この3つを毎日回す、いちばん小さなループです。   透明性 ——スプリントゴールに対して、何が終わっていて何が終わっていないかが、全員に同じように見えていること。これはボードの仕事であって、会の仕事ではありません。会は、見えているものを前提に始めます。   検査 ——見えているものをゴールに照らして、「このままで間に合うか」「今日いちばん危ないのはどこか」を確かめること。   適応 ——検査の結果を受けて、今日の動きを変えること。誰が何を先にやるか、誰が誰を手伝うか、何を後回しにするか。この検査と適応が、デイリースクラムの中身です。  こう見ると、報告会型のデイリーで何が起きているかがはっきりします。透明性を会でやろうとして15分を使い切り、検査も適応も行われていない。これを昨日の自分を説明する場から、今日のチームの動きを決める場に転換しましょう。この一点がずれると、あとは全部ずれます。  適応の中身が、冒頭の質問にあった「終わった直後の相談」です。なくすものではなく、デイリーの側から意図して生み出すもの。ただし、開発者が抱える相談事の全部を15分でこなすのは土台無理です。個別の相談がバラバラに行われると情報の共有が機能しなくなり、すべての相談に全員が拘束されるとコードを書く時間が奪われる。ですから、相談事に対するデイリーの役割は「相談すべきことと相手を特定する」ところまで。「誰と誰が、何を、この後話すか」が、デイリーの成果物です。15分は「ムリをしてでも相談を終わらせる時間」ではなく、 検査をして、適応の相手と議題を決める時間 なのです。 処方 #  以下の処方は、どれも手段です。デイリースクラムの進め方は有識者がさまざまに提案してくれていて、どれを選んでもかまいません。ただし、ただなぞって「できてます」感を得て終わるなら、3つの質問を台本で読むのと同じことです。試したあとは、透明性・検査・適応のどれが充実したかで、自分たちに何が有効かを判定してください。 まず、会の後の相談を1週間記録する  直す前に、測ります。デイリーが終わった直後に始まる相談を、1週間だけメモしてください。「誰と誰が・何について・何分」の3項目で十分です。1週間分を並べると、このチームが毎朝本当に必要としている議題の一覧——いまの15分が扱えていない中身の実測値——が手に入ります。誰が誰を待っているか、どの項目に相談が集中しているか、同じ二人が毎日話しているか。以降の処方は、この記録があると格段に通しやすくなります。「デイリーを変えよう」という提案は抵抗に遭いますが、「先週、会の後に毎日平均40分の相談が起きていました。その分を15分の中で拾えないでしょうか」という提案は、数字が説得してくれるからです。 問いを変える  「昨日何をしましたか」をやめて、こう聞いてみてください。 「スプリントゴールの達成に向けて、今いちばん危ないのはどこですか」 「今日、誰と何を解決しますか」 「間に合いそうですか?」  個人の行動確認ではなくゴールとの距離を話題の中心に置くと、報告は自然と相談に変わります。 人ではなくタスクボードを歩く  発言順を「人の順番」から「PBI(プロダクトバックログアイテム)の順番」に変える方法も効きます。タスクボードの右端——完成にいちばん近い項目——から順に、「これを今日終わらせるには?」と見ていく。主語が「私」から「この項目」に変わるだけで、話は担当者の作業報告から、チームとしての完成戦略に変わります。着手済みのものを終わらせる意識づけにもなり、一石二鳥です。リモートでも同じで、画面共有したボードの右端から歩けばよく、「順番に顔を映して報告」に戻さないことだけ気をつけてください。日報型のチームなら、投稿の型を「今日、誰に何を頼むか」「誰の何を待っているか」に変え、名指しされた人が返す約束を作ってください。宛先があれば、テキストベースでも相談になります。 聞き手の配置を変える実験  報告の宛先がリーダーに向いているチームでは、リーダー(やスクラムマスター)が輪の一歩外に下がる、あえて欠席してみる、という実験が有効です。宛先を失った報告は行き場を探し、チームメイトに向かい始めます。乱暴に聞こえるかもしれませんが、「自分がいないとデイリーが成立しない」状態こそ、リーダーにとっての危険信号です。この実験の前提として、進捗の把握はタスクボードやバーンダウンチャートのような「見たい人がいつでも見られる情報」で担保してください。報告の場を減らすなら、情報の置き場を先に作ることです。 外から来る人の席を決める  顧客報告型になっているなら、発注側や管理職を 追い返す前に、席を用意 しましょう。進捗を知りたいなら、ボードとバーンダウンチャートを公開して、いつでも見られるようにする。判断に関わりたいなら、その場所はスプリントレビューです(今後扱います)。それでも同席したいと言うなら、「発言しない・質問はレビューで」を約束してもらってください。約束が守れない相手なら、その人が来る日だけデイリーが報告会に戻っていることを、チームは自覚しておくべきです。 兼務メンバーは、このチームに使える時間だけを話す  他所報告型への処方です。兼務者が話すべきは「このチームのゴールに対して、今日どれだけ使えるか」。「今日はこのチームに2時間。この項目の確認までは終える」で十分です。使える時間を先に宣言してもらうと、残りのメンバーはそれを前提に今日の動きを組めます。兼務が多いチームでは、この宣言がデイリーの最初の一言になっていると、後の話が全部具体的になります。 「誰と誰が、何を、この後話すか」を成果物にする  「誰と誰が、何を、この後話すか」をデイリー内で宣言し、そこまでをデイリーの成果物として公式に位置づけ、会の後に該当者だけ残って話すようにしてください。延長戦型が出てきたら、全員を拘束して解くのではなく、「その話は、この後で誰と誰が」と切り分けて先へ進める。それが、この段階のスクラムマスターの主な仕事になります。 なぜこの質問は30年繰り返されるのか #  朝会そのものは、スクラムの発明ではありません。多くの組織に「朝礼」や「進捗確認」の文化が先にあり、デイリースクラムは常にその既存の型の上に上書きインストールされます。そして上書きは、しばしば失敗します。見た目(毎朝・短時間・立って話す)が似ているせいで、中身(管理者への報告か、チームの再計画か)が入れ替わっていても誰も気づかないのです。  新しいメンバーが入るたび、彼らは以前の職場の「朝会」の型を持ち込みます。だからこの質問は、チームの世代交代のたびに必ず再現されます。対処も毎回同じです。形式ではなく宛先と目的を確認すること。「この15分は、誰のための、何を決める時間か」を、チームが自分の言葉で答えられるかどうか。言い換えれば、今朝の15分で検査と適応が起きたかどうか。それがデイリースクラムの健康診断として有効です。 次回: 「レトロスペクティブが機能しません…同じ改善案の再放送と、ToDoの列挙会」
 この記事を読んでいただきありがとうございます。アジャイルグループに所属する、藤井智弘です。  更改案件の「現行機能保証」を3つのサブテーマに分けて扱う3連作、その3回目(最終回)です。第3回で「現行」の実体を見て、第4回で合意できる「全体像」を作りました。今回のサブテーマは「検証し、予算を組み、配分する」です。 質問 #  更改案件で現行システムの仕様書を開いたら、最終更新は10年前でした。コードと一致している保証はなく、現場の人に聞くと「マニュアルにはないけど、実際はこうやっている」が次々に出てきます。何を「仕様」として検証すればいいのでしょうか? 全部を人手で突き合わせる時間はありません。しかも、想定外が見つかるたびに追加予算の話になりそうで、正直なところ、発見するのが怖いくらいです。 回答 #  第4回で、ユースケースの粒度なら全体像を合意できる一方、その中身(スライス)は現物を動かさないと分からないことを見ました。本稿は、その「分からない中身」をどう発見して検証済みに変えていくか、そして発見を受け止める予算をどう組み、どう配分するかの話です。  結論を先に言えば、ドキュメントを信用できないなら、いちばん信用できるもの——動いている現物——を仕様にし、未知の仕様は、リリース後の事故として見つかるのを待たずに、開発中に小さく動かして見つけにいくことです。更改案件でアジャイルの型が効くのは、この「発見の手段」としてです。「ドキュメントが揃っていないから検証できない」は、「現行機能保証」「アジャイルは全件を確定しない」に続く、3つ目の思考を止める言葉です。そこで止まる前に、検証の相手を現物に替えられないか、一度考えてみましょう。  第3回で採り上げた「仕様の3つの実体」には、それぞれ別の検証手段が要ります。 紙は当てにしない——ただし捨てもせず、現物との差分を探す手がかりに使う。 コードがやっていることは、特性化テストで固定する。 人がやっていることは、早期の部分稼働と現場のヒアリングでしか見つからない。       図1:3つの実装は、各々異なる検証手段で。  以下の処方は、おおむねこの順に並べ、続けて、見つかったものの扱いをプロダクトオーナー(PO)がまとめて判断する仕組みを置いています。 処方——現物を仕様にし、未知を発見する # 実際の利用データで「本当の現行機能」を測る  第4回で、「利用実績のない機能は移行しない」と書いたように、まずは現行システムの操作ログ、帳票の出力実績、画面のアクセス記録などの情報を集め、実際に使われている機能を特定します。この測定結果をユースケースごとに書き込みます。  ただし年次決算、税制改正対応、災害時の代替手順のように、利用頻度は低くても法令や年次業務に必須の機能は、ログでは「未使用」に見えるかもしれません。これらは利用実績からではなく、業務カレンダーと法令要件から別途拾ってください。 特性化テストで「現物」を仕様として固定する  特性化テスト(characterization test)は、マイケル・フェザーズが著書『レガシーコード改善ガイド』で紹介した技法で、現行システムの実際の挙動を——それが正しいかどうかを問わず——テストとして記録するものです。「この入力にはこう応答する」を現物から採取して回帰検証の網をかけ、移行後の挙動が現行と一致するかを機械的に検証します。最初の1本は、業務影響が大きく、業務部門が頻繁に使う画面の正常系から——帳票やバッチなら、現行の出力との更改版の出力の突き合わせから——始めるのが定石です。仕様書との突き合わせでは漏れがちな「コードがやっていること」を発見する、最も現実的な手段です。  第4回で紹介したUse-Caseも、テストケースをユースケース記述の最重要部分と位置づけています。第4回の「深さ」のレベルは、実務的にはこの特性化テストをどのパターンまで採取するかで決まります——L1は最小集合のフローを通るテストが揃った状態、L2は主な代替フローまで、L3は採取できたパターン全部です。後半で扱う実測スプリントで「進んだ」と数えるのも、特性化テストで裏付けられた分だけです。 一斉切替ではなく、部分的に動かして差分を発見する  新旧を並行稼働させて出力を突き合わせる、業務の一部領域から段階的に切り替える、シャドー運用(本番データを更改系にも流すが、結果は業務に使わず比較だけに使う運用)を行う…手段は案件によりますが、共通する考え方は「差分の発見を、リリース後から開発中に前倒しする」ことです。1回の部分稼働をどこまでにするかは、ユースケースの目的を明確にすれば(たとえば「経理担当が月初の通常請求を新系で発行できる」)、おのずと決まります。ゴールが業務の言葉で書けていれば、シャドー運用で何と何を突き合わせればよいかも決まり、発注側の担当者はそのまま社内に報告できます。  特性化テストで守れるのは「コードがやっていること」まで。 「人がやっていること」を発見できるのは、この部分稼働と現場のヒアリングだけ です。昨今注目される「生成AIによるレガシー分析」も、読めるのは紙とコードまでです。その結果を「現行仕様」と信じて進めれば、第3回で見た事故の構図はそのまま残ります。発見された差分は、失敗ではなく、うれしい収穫です。第4回で、ユースケースの一覧を地図に見立てました。差分が見つかるたびに、その地図に道筋が書き足され、検証済みリストが伸びていきます。 発見された「謎の挙動」の判断はPOに集約する  検証を進めると必ず「この挙動、仕様なのかバグなのか分からない」が出てきます。これを開発者が個別に判断すると、後で「勝手に変えた」と問題になります。謎の挙動は一覧化し、「踏襲する/直す/捨てる」の判断を発注側・POに委ねる運用を最初に合意しておきましょう。第3回で、材料を持つ末端には声を上げる機会がない、と書きました。謎の挙動の一覧は、末端の発見を発注側まで逆向きに届ける、おそらく唯一の仕組みです。      図2:「謎の挙動」はPOにゆだねる  決めておくべきは、次の3つです。 誰が判断するか いつまでに判断するか 判断が来なかったらどうするか  決めないまま始めると、検証の途中で判断待ちが積み上がります。発見の責任は問わず、扱いを決める役割は発注側にある——この分担だけは、どの現場でも共通です。判断の記録は、次回更改のための資産になります。 残リスクは、保守運用に引き継がれる  第3回・第4回で言及した「残リスクを、最後に誰が引き受けるか」。答えは組織によって違いますが、残リスクは案件の終了とともに消えず、第2回で見た保守運用チームに引き継がれます。だからこの問いは、引き継ぐ側も交えて最初に話しておくべきです。 発見したものの置き場——バックログは開いている #  第2回で、バックログが「項目が後から流れ込むことを前提にしたリスト」であることを見ました。更改案件では、この性質がスコープ管理の意味を変えます。  第4回の粒度を当てはめると、ユースケースの一覧は、確定として扱われる——重い手続きの側に置かれる——ほぼ閉じたリストです(足し引きは、確定したものの変更として発注側が判断します)。その下のスライスは、開いたリストです。 重い手続きは一覧の層だけに残し、スライスの層は開けておく 。前節の処方で発見された挙動は、代替フローとして粛々と追加していきます。第4回で「スライスの入替は承認手続きの対象にはしない(ただし記録は残す)」と書いたのは、この意味です。承認は要らなくても、追加も入替もバックログの履歴に残るので、後から追えます。そして追加を責任問題にしないこと(第3回の「洗い出し漏れの責任問題にしない」と同じ布石です)。責任追及が始まれば発見は隠され、発見のためのプロセスが止まります。  ただし、「バックログは開いている」は、開発チームには当たり前でも、稟議や契約を管理する部門にとっては当たり前ではありません。承認なしに追加や入替が起きるプロセスは、会社の手続きから見れば例外です。ここで「アジャイルへの理解を深めましょう」と正論で押しても、相手には「べき論の押し付け」と映り、かえってブレーキになります。   一覧の層には重い手続きを残し、スライスの層だけを開ける——この線の引き方は、過渡期の組織で回るように仕立てた折衷案 です。 予算のハンドリング——発見したものの財布は開いているか #  バックログが開いていても、財布が閉じていれば発見は受け止められません。第4回は、全体像から量(本数×規模、それに深さの初期設定)を出すところまででした。ここでは、その量を金額に換えて器を組み、開発を進めながら器の中を配分していく話をします。稟議の制度は組織ごとに違うので、以下は答えではなく、自組織で検討するときの素材として読んでください。 稟議と契約は、別の場面   稟議 は、発注側の社内で予算の器(上限枠)を確保する場面。 契約 は、発注側と受注側の間で、何を約束し、何を調整の対象にし、どう支払うかを合意する場面です。契約金額は器の枠内で決まりますが、器と一致する必要はありません。稟議に出した内訳を契約の保証文言に転記しない——第3回で引いた一線は、この境界線のことです。以下は稟議の側の話です。 量を期間と金額に換える——実測を先に置く  第4回で出した量を期間と金額に換える根拠となるのは、チームの実測値です。最初の2〜3ユースケースを実際に移行・検証してみて、1スプリントで何ポイント進むか——ユースケースの粒度で測ったベロシティ——を測ります。ストーリーで測るいつものベロシティと違うのは、数える対象がユースケースであることと、特性化テストで指定の深さまで検証済みになった分だけを「進んだ」と数えることです。深さを量にどう反映するか——L1ならその塊の何割と見るか——も、机上で決めずにここで確かめます。  総ポイントをベロシティで割れば期間、期間にチームの月額を掛ければ金額です(例:総量120ポイント、ベロシティが1スプリント6ポイントなら20スプリント、2週間スプリントで約10か月)。実測にはばらつきがあるので、稟議に器として出すのは上限側の値にします。この換算は受注側(内製ならチーム)が提案として出し、発注側はそれを根拠に器を組みます。器が先に決まっているなら、器に収まる範囲と深さを逆算します(実測を幅で見る話と、換算した数字を何に使ってよいかの話は、今後の回で改めて採り上げます)。  稟議の締切が実測を待てないなら、過去案件の実績を初期値にしてかまいません。ただし仮置きとし、最初の数スプリントで実測に置き換えることを、あらかじめ決めておきます。標準工数を前提にしないのは、機能ごとの工数を確定値として置くと、その内訳がそのまま契約に流れ込むからです。ここで置く初期値は、総量をまとめて期間に換えるためのベロシティです。  この換算は、ベロシティの初期値が置けること(実測も過去実績も類推もない組織では成り立ちません)が前提です。出てくる数字は 稟議のための器の上限であって、契約金額でも最終支払でもありません 。以降の予算管理は、上限から検証済みの分を差し引いていく形になり、予算が余ることもあります。それは失敗ではありません。 器は固定したまま、配分の決め方を変える  ここで前提を整理しておきます。すべての要件を予見できない以上、器全体を積み上げで見積もることはできません。とはいえ、全部が見通せないわけでもありません。移行対象のユースケースを最小集合まで仕上げる分は、第4回で見たとおり現物から数えられ、積み上げで見積もれます。積み上げられないのは、最小集合の外にある代替フローまで仕上げる分(第4回の深さL2・L3)と、検証で見つかる未知への対応分です。だから器は、積み上げる部分と、総枠から引き算で管理する部分の2層にします。  稟議の側には、もう一つ壁が残っています。積み上げ式の稟議は、根拠の内訳がそのまま予算の使途になります。「全件リスト×単価」で通した瞬間、金額だけでなく「何に使うか」まで確定し、検証で見つかったものに応じて配分を変える余地が、制度上なくなる。発見のたびに想定外の追加稟議を上げていたのでは、本稿の処方は回りません(避けたいのは「想定外の」追加稟議であり、大枠を取って段階的に行使する稟議はそれとは別物です)。  根本から解くなら、予算制度そのものを変える道があります。年次予算による統制をやめ、ローリング予測と動的な資源配分に置き換える 脱予算経営 は、その代表です。ただ、積み上げ式の稟議で回っている組織が、いきなりそこへ切り替えるのは敷居が高いでしょう。そこで、いまの制度の上で踏み出せる一歩として、 器は固定したまま、器の中の配分の決め方だけを変える 折衷案を3つ挙げます。  第一に、器の内訳を 約束枠 と 発見枠 に分けて稟議に書きます。約束枠は、移行対象のユースケースすべてについて、最小集合(第4回のL1)まで仕上げる分。発見枠は、最小集合の外の代替フローまで仕上げる分と、検証で見つかる未知への対応分で、総額の2〜3割。従来の予備費と違うのは、発見枠の 使い方の規則を稟議の中に書く ことです——「検証済みリストで最小集合が達成されたユースケースのうち、業務影響の大きいものから順に、最小集合の外の代替フローまで広げる。配分は担当者が定例で決め、総額・期限を超える場合のみ再稟議」。根拠は第4回の本数×規模と前項の実測で示せるので、書式は従来のまま。加わるのは、決裁権限の一部を担当者に委ねる条項だけです。  器を1枚のままにしないのは、以下の3つの理由からです。 発見への対応が、業務を止めないための最小集合の予算を食わないこと。 担当者に委ねる範囲を発見枠に限れば、委譲の条項が稟議を通りやすくなること。 予算が足りなくなったとき、見積の甘さ(約束枠の超過)と未知の多さ(発見枠の消化)を見分けられること。後者は想定内の事象なので、第3回で書いたとおり、責任問題にはしません。発見が発見枠の中に収まれば追加稟議は要らず、収まらない場合も、枠の消化状況から早い段階で見えてきます。  第二に、稟議を2段にして、実測を先に置きます。第1段は「実測スプリント」まで——数本のユースケースを実際に移行・検証する、小額・短期の準委任。第2段は、実測でベロシティが出た後に本体の器を申請する。これは「要件定義フェーズを先に発注する」という既存の慣行と同じ形なので、積み上げ式の組織でも通ります。違うのは、第1段の成果物が要件定義書ではなく、検証済みリストの最初の数行と、実測したベロシティであること。前項の実測も、特性化テストの最初の1本も、この第1段で行います。  第三に、年度の器は動かさず、案件の「予実+見通し」を四半期ごとに更新します。検証済みリストの伸びとゴールの達成で見通しを更新し、発見枠の消化見込みを——余りそうなら、その見込みも——早めに示す。多くの組織が既にやっている四半期見直しの書式に、「移行対象のユースケースのうち、どこまで検証済みになったか」(第4回の「ユースケース×深さ」の表の集計)の行を足すだけです。余った予算は、代替フローをさらに仕上げるのに使うか返すかを、最後に判断します。年度の器の中で見通しを回し続けるこのやり方は、脱予算経営のローリング予測を、案件の単位で小さく試すことでもあります。  固定するのは総額・期限・約束枠、動かすのは発見枠の配分と見通し。それを可能にするのは、配分の権限委譲を稟議に書くことと、実測の分を先に小さく稟議にかけることの2つで、どちらも新しい制度ではなく、いまの書式への一行の追記です。制度を変える前に、制度の中で試せることがある、ということです。バックログが開いているだけでは足りない。財布も、同じだけ開いている必要があります。 なぜこの質問は30年繰り返されるのか #  更改のたびに「ドキュメントがない」と嘆かれ、更改が終わるとまたドキュメントは更新されなくなる。この連鎖には理由があります。更改の終盤は、期限に追われて「動くこと」が最優先になり、判断の記録を残す余力が真っ先に削られる。そして次の更改では、前回の判断が「なぜこうなっているのか分からない挙動」として発見され、また調査から始まる。第4回で、いま人が手作業で担っていることの多くは、前回の更改で誰かが暗黙に削った代替フローの痕跡だ、と書きました。その正体は、記録されずに削られた判断の、30年分の堆積です。  この連鎖を断つ最初の一歩は、「嘆くこと」ではなく、今回の案件で「検証済みリスト」と「判断の記録」を残すことです。特性化テストは、検証済みリスト(第4回の「ユースケース×深さ」の表)の各マスを裏付け、現物の挙動を機械が読める形で固定し続けるドキュメントです。謎の挙動の判断一覧は、「なぜこうなっているのか」への、初めて書かれる答えです。第3回の冒頭で、判断はいつしか慣習になり、誰も疑問を持たなくなると書きました。判断一覧は、慣習になる前の判断を、理由ごと次の世代に渡します。どちらも設計書一式を書き直すより手間は小さく、次の更改を担う人は、あなたがいま抱えている「全貌が不明」という状況の、少なくとも一歩外から始められます。第4回で残した「検収の根拠に、紙に代わる何を置くか」という問いへの私の答えも、この二つです。紙を書き足すのではなく、現物から採ったものを残す。それが、何よりのドキュメントです。  第3回の冒頭で、更改案件を扱う3本は波風を立てるつもりだと書きました。この3本で示した3つの視点——「現行」の実体を見る、合意できる全体像を作る、検証し、予算を組み、配分する——は、一つの見立てであって、正解ではありません。スクラムの型をそのまま当てたものでもなく、稟議と契約と多重下請けという日本の現場の文脈に合わせて、プロセスを仕立て直した一例です。「うちの現場では成り立たない」「稟議はそんなに単純ではない」「特性化テストの工数は誰が払うのか」…そういう異論こそ、この3本が引き出したかったものです。ぜひ職場で議論してみてください。  これで、地雷の上から、無事に着地できたでしょうか? 次回: 「デイリースクラムが進捗報告会になっています」
はじめに # 前回の記事( AWS×UserDataでvLLMを自動起動!停止時コストほぼゼロのローカルLLM環境 )では、AWSのUserDataとS3を活用してvLLMを全自動起動し、使い終わったらTerminate(終了)することで停止中のEBS維持コストをほぼゼロにするエフェメラルなLLM推論環境を作りました。 コマンド一発でGPUインスタンスが立ち上がり、手軽に自前の推論APIを呼び出せるようになった次のステップとして、「これを日々の開発ワークフローにどう組み込むか」という実用化の検討に入りました。 VS Code上で自律的にコード編集やテスト実行を行うAIコーディングエージェント「 Cline 」の頭脳として自前GPUを活用できれば、商用APIの従量課金やレートリミットを気にせず、完全定額(インスタンス稼働分のみ)でエージェントを動かし放題にできます。 ただし、いきなりエージェントにすべてを任せる前に、まずはブラウザ上でモデルの推論速度や日本語の応答品質を手軽に対話検証したいところです。 そこで今回は、現在オープンソースの世界で最も活発に開発されているフロントエンド 「Open WebUI」 (GitHub Star 6万超)を採用しました。Open WebUIは美しいチャット画面を提供するだけでなく、自身がOpenAI互換のAPIゲートウェイとして機能するため、 「ブラウザでの対話検証」と「VS Code連携」を一挙両得で実現 できます。 本記事では、手元のローカルPC( Windows WSL2 + docker )上で Open WebUI を動かし、AWS上のGPUインスタンスで稼働する定番オープンソースモデル Qwen/Qwen2.5-Coder-7B-Instruct へ SSHポートフォワード 経由でセキュアに直結。トークンフリーな自律コーディング環境を実現する手順とノウハウをご紹介します! なぜ「Open WebUI」を採用するのか? # 📌 このセクションの要点 vLLM単体だとCUIや直叩きになりがちですが、Open WebUIを挟むことで「ブラウザでの気軽な対話検証」と「VS CodeからのOpenAI互換API利用」の両方をローカルURL固定( http://localhost:3000 )で両立できます。 自前の推論基盤とクライアントの間に Open WebUI を挟むことには、開発体験において大きなメリットがあります。 flowchart TD subgraph LocalPC ["ローカル開発環境 (Windows + WSL2)"] Browser["ブラウザ (WebチャットUI)<br>http://localhost:3000"] Cline["VS Code (Cline拡張機能)<br>Base URL: http://localhost:3000/api<br>API Key: Open WebUI発行キー"] subgraph DockerEnv ["Docker on WSL2 (ポート 3000)"] OpenWebUI["Open WebUI<br>・ChatGPTライクなリッチUI<br>・APIキー発行 & ユーザー管理<br>・OpenAI互換APIプロキシ (/api)"] end SSHTunnel["SSH トンネル クライアント<br>(ssh -N -f -L 8000:localhost:8000)"] Browser -->|1. Webチャット & 設定操作| OpenWebUI Cline -->|2. OpenAI形式 APIリクエスト| OpenWebUI OpenWebUI -->|"3. HTTP: 8000 (内部転送)"| SSHTunnel end subgraph AWS ["AWS 東京リージョン (ap-northeast-1)"] subgraph EC2Env ["EC2: g6.xlarge (使い捨て)【ポート8000は外部非公開】"] SSHD["SSHD (ポート22のみ開放)"] vLLM["Docker: vLLM (ポート8000)<br>OpenAI互換 推論サーバー<br>Qwen/Qwen2.5-Coder-7B-Instruct"] LocalStorage[("/opt/dlami/nvme<br>(インスタンスストア NVMe)")] SSHD -->|4. 内部ループバック転送| vLLM LocalStorage --> vLLM end S3[("Amazon S3<br>(モデル保管: Qwen2.5-Coder-7B)")] S3 -->|同一リージョン間 高速同期<br>【データ転送無料】| LocalStorage end SSHTunnel == インターネット越しにSSH暗号化通信 (ポート22) ==> SSHD 1. 「Webチャット」と「VS Code連携」の一石二鳥 # Open WebUIは、ブラウザからChatGPT / Claudeと同等の洗練されたWeb UIを提供してくれます。 コーディングエージェントに大きなタスクを任せる前に、 「このモデルは日本語の指示にどう答えるか?」「関数のプロトタイプはどう作るか?」をブラウザ上で手軽に対話・検証 できます。 会話履歴の保存、プロンプトテンプレート管理、Markdownコードハイライトなど、日常的なLLMフロントエンドとしても非常に便利です。 2. OpenAI互換APIプロキシ( /api )の標準搭載 # Open WebUIは単なる画面表示ツールにとどまりません。自身が OpenAI互換のAPIゲートウェイ として振る舞う機能を備えています。 設定画面から独自の APIキー を発行可能。 外部ツール(Clineなど)から http://localhost:3000/api を叩くだけで、Open WebUIが認証・ログ記録を行いつつ、背後のvLLMへリクエストを安全にルーティングしてくれます。 3. SSHポートフォワードによる「接続先URLの完全固定化」と安全性 # 使い捨て運用のEC2は起動ごとに動的パブリックIPが変わります。 EC2側のセキュリティグループでは ポート8000を外部公開せず、SSH(ポート22)のみ開放 。 ローカルPC(WSL2)から ssh -N -f -L 8000:localhost:8000 で暗号化トンネルを確立。 Open WebUIはホストネットワーク経由で常にローカルの http://127.0.0.1:8000/v1 に接続。 Cline側の接続先も常に http://localhost:3000/api で固定 できます。EC2を何度再起動・破棄してもエディタやブラウザの設定変更は不要です。 なぜエージェントに「Cline」を選ぶのか? # vLLMと組み合わせるAIコーディング拡張機能として Cline を選定した理由は明確です: OpenAI互換APIのネイティブサポート : 特定のプロバイダ専用ツールとは異なり、Clineは公式に「OpenAI Compatible」プロバイダに対応しています。独自スキーマの変換に悩まされることなく、標準的な /v1/chat/completions エンドポイントへ極めてスムーズに接続できます。 VS Codeエディタとの一体感と自律実行力 : サイドバーのチャットから指示を出すだけで、ファイルツリーの走査、コード差分(Diff)の提示・適用、統合ターミナルでのビルドやテスト実行までをエディタ内で完結して自律実行してくれます。 Plan / Act モードによる確実なタスク遂行 : 設計・方針決定を行う「Planモード」と、実際のファイル編集・コマンド実行を行う「Actモード」をシームレスに行き来でき、オープンソースモデル(7B〜32Bクラス)でも脱線せずに着実なコーディングを進められます。 環境構築ステップ # Step 1: WSL2 + Docker で Open WebUI を起動 # 📌 このステップでやること WSL2上で公式Dockerコンテナを立ち上げます。ホストネットワーク( --net=host )を使うことで、後述のSSHトンネルとシームレスに直結させます。 まずは手元のWSL2環境で「Open WebUI」を起動します。 公式のDockerコンテナイメージが提供されているため、Docker Composeまたは docker run コマンド一発で立ち上がります。 方法A: Docker Compose で起動(推奨) # docker-compose.open-webui.yml services: open-webui: image: ghcr.io/open-webui/open-webui:main container_name: open-webui restart: always network_mode: host environment: # ポート3000で待ち受け - PORT=3000 # SSHトンネル(localhost:8000)をOpenAI互換バックエンドとして指定 - OPENAI_API_BASE_URL=http://127.0.0.1:8000/v1 - OPENAI_API_KEY=none volumes: - open-webui-data:/app/backend/data volumes: open-webui-data: docker compose -f docker-compose.open-webui.yml up -d 方法B: docker run コマンドで起動 docker run -d --net=host \ -v open-webui-data:/app/backend/data \ -e PORT=3000 \ -e OPENAI_API_BASE_URL=http://127.0.0.1:8000/v1 \ -e OPENAI_API_KEY=none \ --name open-webui \ --restart always \ ghcr.io/open-webui/open-webui:main --> Information 💡 なぜホストネットワーク( network_mode: host / --net=host )を使うのか? WSL2環境でSSHポートフォワード( ssh -L 8000:localhost:8000 )を実行すると、SSHプロセスはホストのループバックアドレス( 127.0.0.1:8000 )のみで待ち受けます。 Dockerの通常のブリッジネットワーク( -p 3000:8080 )でコンテナを動かすと、 host.docker.internal 経由のアクセスはDocker仮想NIC( docker0 )から入ってくるため、 127.0.0.1 にしかバインドしていないSSHトンネルにパケットが届かず接続エラー(Connection refused)になってしまいます。 ホストネットワークを使用することで、コンテナがWSL2ホストと同一のネットワーク空間( 127.0.0.1 )を共有するため、余計な設定を気にせず http://127.0.0.1:8000/v1 で確実に直結できます。 起動後、ブラウザで http://localhost:3000 を開きます。 ※ 初回アクセス時に管理者アカウント(名前・メール・パスワード)の作成画面が表示されます。手元のローカル環境ですので、お好みの情報でサインアップしてください。 ※ 本記事の画面キャプチャでは、サインイン後に左下のユーザーアイコン → \rightarrow → 「設定(Settings)」 → \rightarrow → 「全般(General)」 → \rightarrow → 「言語(Language)」 で表示言語を 「日本語」 に設定しています(英語UIのままでも問題なく利用可能です)。 Step 2: EC2起動 & 暗号化SSHトンネルを自動確立 # 📌 このステップでやること コマンド1発でEC2(GPU)の起動、UserDataによるvLLMの自動セットアップ、安全な暗号化SSHトンネル(ポート8000)のバックグラウンド確立までを全自動化します。 --> Information 📋 スクリプト実行前のチェックリスト(前提条件) ローカル環境 : Windows (WSL2) + Docker が導入済みであること EC2キーペア : ~/.ssh/ 配下に秘密鍵( .pem )が存在すること IAMロール : S3読み取り(モデル取得用)権限を持つIAMロール(例: EC2-S3-FullAccess-Profile )が作成済みであること AWS CLI : 事前に aws configure で認証が通っていること EC2インスタンスの自動構築と接続は、以下の2つのスクリプトで行います: 02_ec2_userdata.sh : EC2の起動時に自動実行され、S3からモデルを同期し、ツール呼び出し(Tool Calling)を有効化したvLLMコンテナを起動するUserDataスクリプト 02_ec2_launch_and_tunnel.sh : ローカルPCからEC2インスタンスを起動し、上記UserDataを流し込んで暗号化SSHトンネルを自動確立するスクリプト 1. EC2内部の自動構築スクリプト( 02_ec2_userdata.sh ) まずは、EC2起動時にサーバー内部で実行されるUserDataスクリプトです。第1回のスクリプトをベースに、自律型コーディングエージェント(Cline)やOpen WebUIからのツール呼び出し(Tool Calling)に対応するため、 vLLMの起動引数に --enable-auto-tool-choice および --tool-call-parser hermes を追加 しています。 02_ec2_userdata.sh(クリックで展開) #!/bin/bash set -euo pipefail # ============================================================================== # 02_ec2_userdata.sh # EC2起動時に実行されるUserDataスクリプト # # 前提: # - AMI: Ubuntu 22.04 Deep Learning AMI (NVIDIA Driver & Docker導入済み) # - インスタンスタイプ: g6.xlarge (NVIDIA L4 GPU: 24GB VRAM, 250GB NVMe SSD付属) # - IAMロール: S3(ReadOnly/FullAccess) 及び ECR(ReadOnly) 権限アタッチ済み # ============================================================================== LOG_FILE="/var/log/userdata-vllm.log" exec > >(tee -a "${LOG_FILE}") 2>&1 echo "=== [$(date '+%Y-%m-%d %H:%M:%S')] UserData 実行開始 ===" # 設定パラメータ AWS_REGION="${AWS_REGION:-ap-northeast-1}" S3_BUCKET_NAME="${S3_BUCKET_NAME:-my-llm-models-tokyo}" HF_MODEL_ID="${HF_MODEL_ID:-Qwen/Qwen2.5-Coder-7B-Instruct}" SERVED_MODEL_NAME="${SERVED_MODEL_NAME:-Qwen/Qwen2.5-Coder-7B-Instruct}" VLLM_PORT="8000" GPU_MEMORY_UTILIZATION="0.90" MAX_MODEL_LEN="16384" DOCKER_IMAGE="${DOCKER_IMAGE:-vllm/vllm-openai:latest}" echo "取得モデル(HF) : ${HF_MODEL_ID}" echo "公開モデル名 : ${SERVED_MODEL_NAME}" echo "S3バケット : s3://${S3_BUCKET_NAME}" # 1. ローカル NVMe インスタンスストア(250GB)の検出と活用 # ※ AWS Deep Learning AMI (DLAMI) は、起動時にローカルNVMeを自動で /opt/dlami/nvme にマウントしてくれます NVME_DIR="/opt/dlami/nvme" if mountpoint -q "${NVME_DIR}" || [ -d "${NVME_DIR}" ]; then echo "DLAMI既定の NVMe マウント (${NVME_DIR}) を検出しました。モデル&Docker領域として活用します..." mkdir -p "${NVME_DIR}/models" "${NVME_DIR}/docker" mkdir -p /data ln -sfn "${NVME_DIR}/models" /data/models else # DLAMI以外のAMIや未マウント時のフォールバック処理 NVME_DEV=$(lsblk -d -n -o NAME,SIZE | grep -E '250G|232G' | head -n1 | awk '{print $1}') if [ -n "${NVME_DEV}" ]; then echo "ローカル NVMe SSD (/dev/${NVME_DEV}) を検出しました。/data にマウントします..." 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 & containerd のデータ領域を NVMe に配置し、EBS枯渇防止&レイヤー展開を爆速化 # ※ Docker 24+ および containerd は /var/lib/containerd にスナップショットを展開するため、 # 両方を 250GB NVMe SSD にバインドマウントして 40GB EBS のディスク満杯 (no space left on device) を完全に防止します echo "Docker/containerd を停止して 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 if ! grep -q "/var/lib/docker" /etc/fstab; then echo "${NVME_DIR}/docker /var/lib/docker none defaults,bind 0 0" >> /etc/fstab fi if ! grep -q "/var/lib/containerd" /etc/fstab; then echo "${NVME_DIR}/containerd /var/lib/containerd none defaults,bind 0 0" >> /etc/fstab fi mkdir -p /etc/docker cat <<EOF > /etc/docker/daemon.json { "data-root": "/var/lib/docker" } EOF systemctl daemon-reload systemctl start containerd systemctl start docker # 3. モデルデータの準備 (S3にあれば高速同期、無ければEC2上でHugging Faceから直接取得してS3へバックアップ) echo "=== [$(date '+%Y-%m-%d %H:%M:%S')] モデルデータの準備確認... ===" S3_SRC="s3://${S3_BUCKET_NAME}/models/${HF_MODEL_ID}" echo "S3ターゲット: ${S3_SRC}/ (リージョン: ${AWS_REGION})" aws configure set default.s3.max_concurrent_requests 20 # IAMクレデンシャルとS3疎通の待機 (起動直後はメタデータサービスからのSTSトークン反映に数秒かかる場合がある) echo "IAM認証およびS3バケット接続を確認中..." for i in {1..15}; do if aws s3 ls "s3://${S3_BUCKET_NAME}" --region "${AWS_REGION}" >/dev/null 2>&1; then echo "S3バケットへの接続を確認しました。" break fi echo "S3接続/IAM認証待機中 ($i/15)..." sleep 2 done HAS_S3_MODEL=false echo "S3上のモデル存在チェックを実行中: aws s3 ls ${S3_SRC}/ --region ${AWS_REGION}" S3_CHECK=$(aws s3 ls "${S3_SRC}/" --region "${AWS_REGION}" 2>&1 || true) echo "S3チェック結果:" echo "${S3_CHECK}" if echo "${S3_CHECK}" | grep -E '(\.safetensors|\.bin|\.json)' >/dev/null; then HAS_S3_MODEL=true fi if [ "${HAS_S3_MODEL}" = "true" ]; then echo "=== [$(date '+%Y-%m-%d %H:%M:%S')] S3上にモデルを発見しました。S3から高速同期します... ===" aws s3 sync "${S3_SRC}" "${LOCAL_MODEL_DIR}" \ --region "${AWS_REGION}" \ --no-progress else echo "=== [$(date '+%Y-%m-%d %H:%M:%S')] S3上にモデルがありません。EC2上で直接Hugging Faceから高速取得します... ===" python3 -m pip install -U "huggingface_hub[cli]" || pip3 install -U "huggingface_hub[cli]" || true echo "Hugging Face ('${HF_MODEL_ID}') からモデルを直接ダウンロード中..." python3 -c " import sys from huggingface_hub import snapshot_download try: snapshot_download(repo_id='${HF_MODEL_ID}', local_dir='${LOCAL_MODEL_DIR}', local_dir_use_symlinks=False) print('Hugging Faceからのダウンロードに成功しました。') except Exception as e: print(f'ダウンロードエラー: {e}', file=sys.stderr) sys.exit(1) " echo "ダウンロード完了。容量:" du -sh "${LOCAL_MODEL_DIR}" fi echo "モデル準備完了。ローカル容量確認:" du -sh "${LOCAL_MODEL_DIR}" # 4. vLLMコンテナの起動 echo "=== [$(date '+%Y-%m-%d %H:%M:%S')] vLLMコンテナ起動 ===" CONTAINER_NAME="vllm-server" # ECR イメージの場合はログイン認証を実行 if [[ "${DOCKER_IMAGE}" == *".dkr.ecr."* ]]; then echo "ECR イメージを検出しました。ログイン認証を実行中..." ECR_REGISTRY=$(echo "${DOCKER_IMAGE}" | cut -d'/' -f1) aws ecr get-login-password --region "${AWS_REGION}" | docker login --username AWS --password-stdin "${ECR_REGISTRY}" || true fi # 既存コンテナがあれば停止・削除 if docker ps -a --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then echo "既存の ${CONTAINER_NAME} を停止・削除します..." docker rm -f "${CONTAINER_NAME}" fi docker run -d \ --name "${CONTAINER_NAME}" \ --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. ヘルスチェック (起動待機) echo "vLLM サーバーの起動ヘルスチェックを開始します (ポート ${VLLM_PORT})..." MAX_RETRIES=120 # 初回起動・CUDAグラフ構築に余裕を持たせる (最大10分) RETRY_COUNT=0 while [ ${RETRY_COUNT} -lt ${MAX_RETRIES} ]; do if curl -s "http://127.0.0.1:${VLLM_PORT}/health" > /dev/null 2>&1; then echo "=== [$(date '+%Y-%m-%d %H:%M:%S')] vLLMサーバーが正常に起動しました! ===" break fi echo "起動待機中... (${RETRY_COUNT}/${MAX_RETRIES})" sleep 5 RETRY_COUNT=$((RETRY_COUNT + 1)) done if [ ${RETRY_COUNT} -eq ${MAX_RETRIES} ]; then echo "警告: vLLMヘルスチェックがタイムアウトしました。'docker logs ${CONTAINER_NAME}' を確認してください。" else # 起動成功時: HFから直接ダウンロードしていた場合は、裏でS3へ自動バックアップ (I/O優先度を下げて推論を阻害しない) if [ "${HAS_S3_MODEL}" = "false" ]; then echo "=== [$(date '+%Y-%m-%d %H:%M:%S')] 起動完了を確認。次回以降の高速同期のため、裏でS3へバックアップを開始します ===" ( if ! aws s3 ls "s3://${S3_BUCKET_NAME}" --region "${AWS_REGION}" 2>/dev/null; then if [ "${AWS_REGION}" = "us-east-1" ]; then aws s3api create-bucket --bucket "${S3_BUCKET_NAME}" 2>/dev/null || true else aws s3api create-bucket --bucket "${S3_BUCKET_NAME}" --region "${AWS_REGION}" --create-bucket-configuration LocationConstraint="${AWS_REGION}" 2>/dev/null || true fi fi ionice -c 3 aws s3 sync "${LOCAL_MODEL_DIR}" "${S3_SRC}" --region "${AWS_REGION}" --no-progress 2>/dev/null || \ aws s3 sync "${LOCAL_MODEL_DIR}" "${S3_SRC}" --region "${AWS_REGION}" --no-progress 2>/dev/null || true echo "[$(date '+%Y-%m-%d %H:%M:%S')] S3モデルバックアップ完了!次回からはS3から超高速同期されます。" ) > /var/log/s3-backup.log 2>&1 & fi fi # 6. 1時間アイドル時の自動シャットダウン(自爆Terminate)デーモン起動 echo "=== アイドル自動終了デーモンを設定・起動します ===" cat <<'EOF' > /usr/local/bin/auto-idle-shutdown.sh #!/bin/bash IDLE_LIMIT_SEC=3600 # 1時間 (3600秒) IDLE_COUNT=0 CHECK_INTERVAL=300 # 5分おきにチェック while true; do sleep "${CHECK_INTERVAL}" # 直近5分間のvLLMへの推論リクエスト数をログからカウント REQ_COUNT=$(docker logs --since 5m vllm-server 2>&1 | grep -c "POST /v1" || true) if [ "${REQ_COUNT}" -eq 0 ]; then IDLE_COUNT=$((IDLE_COUNT + CHECK_INTERVAL)) echo "[$(date '+%Y-%m-%d %H:%M:%S')] アイドル継続中: ${IDLE_COUNT}s / ${IDLE_LIMIT_SEC}s" if [ "${IDLE_COUNT}" -ge "${IDLE_LIMIT_SEC}" ]; then echo "[$(date '+%Y-%m-%d %H:%M:%S')] 1時間アイドル状態が継続したため、自動終了(Terminate)を実行します。" shutdown -h now exit 0 fi else IDLE_COUNT=0 # リクエストがあったらタイマーリセット fi done EOF chmod +x /usr/local/bin/auto-idle-shutdown.sh nohup /usr/local/bin/auto-idle-shutdown.sh > /var/log/auto-idle-shutdown.log 2>&1 & echo "=== [$(date '+%Y-%m-%d %H:%M:%S')] UserData 全処理完了 (自動アイドル監視稼働中) ===" 2. EC2起動 & 暗号化SSHトンネル自動確立スクリプト( 02_ec2_launch_and_tunnel.sh ) 続いて、ローカルPCから上記UserDataを渡してEC2を起動し、SSHトンネルをバックグラウンドで自動開通させるスクリプトです。 02_ec2_launch_and_tunnel.sh(クリックで展開) #!/bin/bash set -euo pipefail # ============================================================================== # 02_ec2_launch_and_tunnel.sh # EC2 GPUインスタンスを起動し、SSHポートフォワードで安全にvLLMをローカル(8000)に接続するスクリプト # # 特徴: # - EC2のポート8000をインターネットに一切公開せず、ポート22(SSH)のみで運用 # - ローカルPCでSSHトンネル(-L 8000:localhost:8000)を自動確立 # - Open WebUI はローカル(http://127.0.0.1:8000/v1)を向くだけなので設定固定! # ============================================================================== SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" USER_DATA_FILE="${USER_DATA_FILE:-${SCRIPT_DIR}/02_ec2_userdata.sh}" INSTANCE_STATE_FILE="${SCRIPT_DIR}/.current_instance_id" TUNNEL_PID_FILE="${SCRIPT_DIR}/.current_ssh_tunnel_pid" # --- AWS 設定 --- AWS_REGION="${AWS_REGION:-ap-northeast-1}" INSTANCE_TYPE="${INSTANCE_TYPE:-g6.xlarge}" # NVIDIA L4 GPU (24GB VRAM) AMI_ID="${AMI_ID:-}" IAM_ROLE_NAME="${IAM_ROLE_NAME:-EC2-S3-ReadOnly-Profile}" SECURITY_GROUP_IDS="${SECURITY_GROUP_IDS:-}" SUBNET_ID="${SUBNET_ID:-}" KEY_NAME="${KEY_NAME:-my-vllm-models-hackathon-2026}" KEY_PATH="${KEY_PATH:-${HOME}/.ssh/${KEY_NAME}.pem}" EBS_SIZE_GB="${EBS_SIZE_GB:-40}" # --- モデル & UserData 設定 --- S3_BUCKET_NAME="${S3_BUCKET_NAME:-my-vllm-models-hackathon-2026-$(aws sts get-caller-identity --query Account --output text)-ap-northeast-1-an}" HF_MODEL_ID="${HF_MODEL_ID:-Qwen/Qwen2.5-Coder-7B-Instruct}" SERVED_MODEL_NAME="${SERVED_MODEL_NAME:-Qwen/Qwen2.5-Coder-7B-Instruct}" MAX_MODEL_LEN="${MAX_MODEL_LEN:-16384}" GPU_MEMORY_UTILIZATION="${GPU_MEMORY_UTILIZATION:-0.90}" DOCKER_IMAGE="${DOCKER_IMAGE:-vllm/vllm-openai:latest}" echo "=== 1. 設定確認 ===" if [ ! -f "${USER_DATA_FILE}" ]; then echo "エラー: UserDataスクリプトが見つかりません: ${USER_DATA_FILE}" echo "第1回の 02_ec2_userdata.sh を配置するか、環境変数 USER_DATA_FILE を指定してください。" exit 1 fi echo "使用するSSH秘密鍵: ${KEY_PATH}" # 一時UserDataスクリプトを生成して環境変数を注入 TEMP_USERDATA=$(mktemp) trap 'rm -f "${TEMP_USERDATA}"' EXIT sed -e "s|^AWS_REGION=.*|AWS_REGION=\"${AWS_REGION}\"|" \ -e "s|^S3_BUCKET_NAME=.*|S3_BUCKET_NAME=\"${S3_BUCKET_NAME}\"|" \ -e "s|^HF_MODEL_ID=.*|HF_MODEL_ID=\"${HF_MODEL_ID}\"|" \ -e "s|^SERVED_MODEL_NAME=.*|SERVED_MODEL_NAME=\"${SERVED_MODEL_NAME}\"|" \ -e "s|^MAX_MODEL_LEN=.*|MAX_MODEL_LEN=\"${MAX_MODEL_LEN}\"|" \ -e "s|^GPU_MEMORY_UTILIZATION=.*|GPU_MEMORY_UTILIZATION=\"${GPU_MEMORY_UTILIZATION}\"|" \ -e "s|^DOCKER_IMAGE=.*|DOCKER_IMAGE=\"${DOCKER_IMAGE}\"|" \ "${USER_DATA_FILE}" > "${TEMP_USERDATA}" if [ -z "${AMI_ID}" ]; then echo "Ubuntu 22.04 Deep Learning AMI を自動検索中..." AMI_ID=$(aws ec2 describe-images \ --region "${AWS_REGION}" \ --owners amazon \ --filters "Name=name,Values=Deep Learning OSS Nvidia Driver AMI GPU PyTorch * (Ubuntu 22.04)*" "Name=state,Values=available" \ --query 'sort_by(Images, &CreationDate)[-1].ImageId' \ --output text) echo "使用AMI ID: ${AMI_ID}" fi # SSH専用セキュリティグループの自動取得または作成 if [ -z "${SECURITY_GROUP_IDS}" ]; then SG_NAME="vllm-ssh-tunnel-sg" SG_ID=$(aws ec2 describe-security-groups \ --region "${AWS_REGION}" \ --filters "Name=group-name,Values=${SG_NAME}" \ --query 'SecurityGroups[0].GroupId' --output text 2>/dev/null || true) if [ -z "${SG_ID}" ] || [ "${SG_ID}" = "None" ]; then echo "SSH専用セキュリティグループ (${SG_NAME}) を作成します..." DEFAULT_VPC=$(aws ec2 describe-vpcs --region "${AWS_REGION}" --filters "Name=isDefault,Values=true" --query 'Vpcs[0].VpcId' --output text) SG_ID=$(aws ec2 create-security-group \ --region "${AWS_REGION}" \ --group-name "${SG_NAME}" \ --description "Allow SSH port 22 only for vLLM tunnel" \ --vpc-id "${DEFAULT_VPC}" \ --query 'GroupId' --output text) aws ec2 authorize-security-group-ingress \ --region "${AWS_REGION}" --group-id "${SG_ID}" \ --protocol tcp --port 22 --cidr "0.0.0.0/0" fi SECURITY_GROUP_IDS="${SG_ID}" fi # --- 2. EC2 インスタンス起動 --- echo "=== 2. EC2インスタンス起動 (使い捨てエフェメラル仕様) ===" INSTANCE_ID=$(aws ec2 run-instances \ --region "${AWS_REGION}" \ --image-id "${AMI_ID}" \ --instance-type "${INSTANCE_TYPE}" \ --key-name "${KEY_NAME}" \ --iam-instance-profile "Name=${IAM_ROLE_NAME}" \ --security-group-ids ${SECURITY_GROUP_IDS} \ --instance-initiated-shutdown-behavior terminate \ --block-device-mappings "[{\"DeviceName\":\"/dev/sda1\",\"Ebs\":{\"VolumeSize\":${EBS_SIZE_GB},\"VolumeType\":\"gp3\",\"DeleteOnTermination\":true}}]" \ --user-data "file://${TEMP_USERDATA}" \ --tag-specifications "ResourceType=instance,Tags=[{Key=Name,Value=vllm-openwebui-server}]" \ --query 'Instances[0].InstanceId' \ --output text) echo "インスタンス起動リクエスト完了: ${INSTANCE_ID}" echo "${INSTANCE_ID}" > "${INSTANCE_STATE_FILE}" echo "インスタンスの起動完了を待機中..." aws ec2 wait instance-running --region "${AWS_REGION}" --instance-ids "${INSTANCE_ID}" PUBLIC_IP=$(aws ec2 describe-instances \ --region "${AWS_REGION}" \ --instance-ids "${INSTANCE_ID}" \ --query 'Reservations[0].Instances[0].PublicIpAddress' \ --output text) echo "パブリックIP : ${PUBLIC_IP}" # --- 3. SSHポートフォワード接続 (暗号化トンネル確立) --- echo "=== 3. SSHポートフォワード接続 (暗号化トンネル確立) ===" while ! nc -z -w 3 "${PUBLIC_IP}" 22 2>/dev/null; do sleep 3 done echo "EC2 SSHD応答確認!" if [ -f "${TUNNEL_PID_FILE}" ]; then OLD_PID=$(cat "${TUNNEL_PID_FILE}" | tr -d '[:space:]') kill -9 "${OLD_PID}" 2>/dev/null || true rm -f "${TUNNEL_PID_FILE}" fi ssh -i "${KEY_PATH}" \ -o StrictHostKeyChecking=no \ -o UserKnownHostsFile=/dev/null \ -o ServerAliveInterval=15 \ -o ServerAliveCountMax=3 \ -N -f -L 8000:localhost:8000 \ "ubuntu@${PUBLIC_IP}" TUNNEL_PID=$(pgrep -f "ssh.*-L 8000:localhost:8000.*ubuntu@${PUBLIC_IP}" | head -n1 || true) if [ -n "${TUNNEL_PID}" ]; then echo "${TUNNEL_PID}" > "${TUNNEL_PID_FILE}" fi echo "SSHトンネル確立完了! (PID: ${TUNNEL_PID})" # --- 4. vLLMサーバーの初期化&ヘルスチェック待機 --- echo "=== 4. vLLM サーバーの起動待機 (http://localhost:8000/health) ===" while ! curl -s -m 3 "http://localhost:8000/health" > /dev/null 2>&1; do sleep 10 done echo "==========================================================" echo " 全工程が完了しました!" echo " EC2 Instance ID: ${INSTANCE_ID}" echo " SSH Tunnel : localhost:8000 -> EC2:8000 (暗号化中)" echo " Web UI URL : http://localhost:3000" echo "==========================================================" # 実行コマンド export KEY_NAME="my-vllm-models-hackathon-2026" export IAM_ROLE_NAME="EC2-S3-FullAccess-Profile" ./02_ec2_launch_and_tunnel.sh このスクリプトは以下の処理を全自動で実行します: aws ec2 run-instances でGPUインスタンスを起動( DeleteOnTermination: true および --instance-initiated-shutdown-behavior terminate を明示)。 セキュリティグループはポート22(SSH)のみ許可でOK (ポート8000を外部開放する必要はありません)。 インスタンスの起動完了後、パブリックIPを取得。 インスタンスのSSHD応答を確認後、 バックグラウンドでSSHポートフォワードを自動確立 ( ssh -N -f -L 8000:localhost:8000 ubuntu@<PUBLIC_IP> )。 ローカル経由( http://localhost:8000/health )で vLLM の起動完了を待機。 --> Information ⚠️ 第1回からの進化点:エージェント連携に向けた起動パラメータのチューニング 第1回のUserData(シンプルなチャット検証用)から、本記事のUserData( 02_ec2_userdata.sh )および起動スクリプトでは、Cline連携およびOpen WebUIでのツール利用を見据えて以下のパラメータを変更・追加しています。 MAX_MODEL_LEN=16384 (コンテキスト長を 4,096 から 16,384 へ拡張) 通常のチャットAIと異なり、 Clineなどの自律コーディングエージェントは初回リクエスト時に「システムプロンプト」「ツール定義」「プロジェクトの環境情報」などを一括送信するため、プロンプトだけで約4,000トークン近くを消費 します。vLLMデフォルト(4096)のままだと、数往復のコード修正で 400 BadRequest: maximum context length exceeded エラーが発生するため、16k(16,384)に拡張しています。 GPU_MEMORY_UTILIZATION=0.90 (VRAM利用率を 0.85 から 0.90 へ引き上げ) コンテキスト長を16kへ拡張したことで、モデルの推論時に必要なKVキャッシュ(Key-Value Cache)のメモリフットプリントが増加します。g6.xlarge(NVIDIA L4 24GB VRAM)のメモリ空間を最大限に活かし、キャッシュ枯渇を防ぐために 0.90 へ引き上げています。 --enable-auto-tool-choice & --tool-call-parser hermes (UserDataでのTool Calling / 関数呼び出しの有効化) Open WebUI や Cline は、モデルにファイル操作やターミナルコマンド、Web検索を実行させるためにツール呼び出し(Function Calling / tool_choice: "auto" )を発行します。UserData( 02_ec2_userdata.sh )内の docker run 引数にこれらを追加しないと "auto" tool choice requires --enable-auto-tool-choice and --tool-call-parser to be set というエラーが発生します。Qwen2.5モデルは Hermes 形式のツール呼び出しプロンプトをサポートしているため、パーサーに hermes を指定して有効化しています。 --> Information 💡 コラム:コンテキスト長はさらに拡張できる?(32k設定とFP8 KVキャッシュ) 「16kでも十分だが、巨大なコードベースを読み込ませるために 32k や 64k まで拡張できないか?」と思われるかもしれません。結論から言うと、 さらに拡張することは十分可能 です! 個人利用なら MAX_MODEL_LEN=32768 (32k)が今すぐ利用可能 : Qwen2.5-Coder-7B は GQA(Grouped Query Attention)を採用しており、KVキャッシュのメモリフットプリントが非常に小さく、32k トークンでも 1 リクエストあたり約 1.9 GB に収まります。L4 GPU(24GB VRAM)のモデルロード後の空き(約 6.6 GB)であれば、個人〜2人利用なら MAX_MODEL_LEN=32768 でも安定して動作します。本記事で 16k を標準としたのは、後半で述べる「3〜5人のチームで相乗りした際のメモリ枯渇(Preemption)を防ぐ安全マージン」のためです。 さらに伸ばす裏ワザ(FP8 KVキャッシュ) : g6.xlarge の NVIDIA L4 GPU は FP8 演算 にネイティブ対応しています。vLLM 起動引数に --kv-cache-dtype fp8 を追加するだけで、 KVキャッシュの消費メモリを半減(約 28 KB / token) できます。これを使えば 32k や 64k でも複数人の並行アクセスに耐えられます。 注意点(トレードオフ) : コンテキスト長を伸ばしすぎると、初速(TTFT: Time To First Token / 最初の1文字が出るまでの待ち時間)が長くなるほか、7Bモデルではプロンプト中央部の指示を読み落としやすくなる(Lost in the Middle現象)傾向があります。実用上のレスポンス速度と精度のバランスとしては 16k〜32k 付近が最も快適です。 # スクリプト実行ログのイメージ === 1. 設定確認 === 使用するSSH秘密鍵: /home/user/.ssh/my-key.pem === 2. EC2インスタンス起動 (使い捨てエフェメラル仕様) === インスタンス起動リクエスト完了: i-0123456789abcdef0 インスタンス起動完了! パブリックIP : 54.xxx.xxx.xxx === 3. SSHポートフォワード接続 (暗号化トンネル確立) === EC2 SSHD応答確認! SSHトンネル確立完了! (PID: 12345) ローカルの http://localhost:8000 が安全にEC2内部のvLLMへ転送されます。 === 4. vLLM サーバーの起動待機 (http://localhost:8000/health) === ............................... vLLM サーバーが正常に応答しました! ========================================================== 全工程が完了しました! SSH Tunnel : localhost:8000 -> EC2:8000 (暗号化中) Web UI URL : http://localhost:3000 ========================================================== 画面に完了メッセージが表示された時点で、AWS上のvLLMとローカル環境が暗号化トンネルで直結されました! Step 3: ブラウザでチャット確認 & APIキーの発行 # 1. ブラウザからチャット動作確認 ブラウザで http://localhost:3000 を開きます。 上部のモデル選択プルダウンに Qwen/Qwen2.5-Coder-7B-Instruct が自動認識されていれば準備完了です! 「こんにちは!自己紹介と、得意なプログラミング言語を教えてください。」 と入力してみましょう。GPUからスムーズに日本語のストリーミング応答が返ってくるはずです。まずはここで推論基盤の正常性を確認できます。 --> Information 💡 ブラウザチャット時のワンポイント(組み込みツールの解除) もしチャット時にモデルが回答する代わりに {"name": "ask_user", ...} のようなJSON形式の引数を出力してしまう場合は、モデルに組み込みツールが紐付いています。 左下のユーザーアイコンから 「設定(Settings)」 → \rightarrow → 「モデル(Models)」 (またはワークスペースのモデル管理)を開き、対象モデル( Qwen/Qwen2.5-Coder-7B-Instruct )を「編集」で開いて、 組み込みツールにある「ユーザーに質問(Ask User)」のチェックを解除 して保存してください。 これで余計なツール呼び出しを行わず、通常のテキストでスムーズに対話できるようになります(※Step 4で接続するCline連携時は、Cline側が独自のツール定義を適切に制御するため影響ありません)。 2. Cline接続用 APIキーの発行 ClineからOpen WebUIを経由してアクセスするためのAPIキーを発行します。 管理者設定でAPIキーを有効化 : 左下のユーザーアイコンから 「管理者パネル(Admin Panel)」 → \rightarrow → 「設定(Settings)」 → \rightarrow → 「システム(System)」 → \rightarrow → 「認証(Authentication)」 を開き、 「API キー(API Key)」 がONになっていることを確認します(※OFFの場合はONにして右下の「保存」をクリックします)。 個人のAPIキーを発行 : 左下のユーザーアイコンから 「設定(Settings / プロフィール)」 → \rightarrow → 「アカウント(Account)」 を開きます。 「API キー」 セクションにある 「+ 新しいシークレットキーを作成」 (またはキー作成アイコン)をクリックします。 生成されたAPIキー文字列(※ sk- 形式ではなく英数字の長いトークン文字列が表示されます)をコピーして控えておきます。 Step 4: VS Code「Cline」の接続設定と実行 # 📌 このステップでやること VS Codeの拡張機能「Cline」にOpen WebUIのエンドポイントとAPIキーを設定し、トークン無制限の自律コーディングを開始します。 いよいよVS Codeから自前のvLLM環境へ接続します! 1. Cline のインストール VS Codeの拡張機能マーケットプレイスで 「Cline」 を検索してインストールします。 2. プロバイダ設定 サイドバーの Cline アイコン(ロボット)をクリックし、上部の歯車アイコン(Settings)を開きます。 以下の項目を設定します: 設定項目 入力値 備考 API Provider OpenAI Compatible プルダウンから選択 Base URL http://localhost:3000/api Open WebUIのエンドポイント(末尾の /api に注目) ※もし Cline からの接続時に 404 Not Found が返る場合は、Base URL を http://localhost:3000/api/v1 に変更してお試しください。 OpenAI Compatible API Key 発行したAPIキー文字列 Step 3で取得したOpen WebUIのキー Model ID Qwen/Qwen2.5-Coder-7B-Instruct vLLMで提供しているモデル名 Context Window Size 16384 vLLMの max_model_len (16k)に合わせる Max Output Tokens 8192 (または 4096 ) 1リクエストあたりの最大出力トークン数 --> Information ⚠️ トークン数設定の注意点(重要) Clineの初期設定では最大出力トークン( max_tokens )が 32000 に設定されていることがあります。この値がバックエンド(vLLM)の最大コンテキスト長( 16384 )を超えていると、vLLMから以下のエラーが返されて接続に失敗します: max_tokens=32000 cannot be greater than max_model_len=16384. Please request fewer output tokens. 必ず 「Context Window Size」を 16384 、 「Max Output Tokens」を 8192 (または 4096 ) に設定してください(項目が表示されていない場合は、設定画面の「MODEL CONFIGURATION」や「Advanced Settings」を展開してください)。 ※もしStep 2のコラムを参考にvLLMを32k(32,768)で起動した場合は、それぞれ 32768 と 8192 に設定してください。 入力後、設定画面下部の「Done」または「Save」をクリックします。 3. 動作確認:自律コーディングを実行! VS Codeで新規の空フォルダを開き、Clineのチャット入力欄に開発タスクを依頼してみましょう。 空のプロジェクトから、PythonでシンプルなTODO管理CLIツールを作成してください。 - タスクの追加・一覧表示・完了機能を持たせる - Python標準の unittest を使った単体テストコードを作成する - ターミナルでテストを実行し、すべて成功することを確認する 送信すると、Clineが自律的にプロジェクト構成を考え、ファイル作成やコード記述を始めます。 ローカル端末からOpen WebUIを経由し、暗号化SSHトンネルを通ってAWS上のGPUで稼働する Qwen/Qwen2.5-Coder-7B-Instruct から高速にトークンがストリーミングされ、VS Code上でファイル( todo.py , test_todo.py など)が順次作成・編集されていきます。 さらにActモードであれば、統合ターミナルでテストコマンドを実行して動作確認まで自動で行ってくれます。 ブラウザのOpen WebUI管理画面の利用ログにもリクエストがしっかり記録されているのが確認できます。 --> Information 🔧 つまづきやすいポイントと解決策(Q&A) Q1. 「APIキーが無効 / 401 Unauthorized」と言われる → \rightarrow → Open WebUIのキーは sk-... 形式ではなく英数字の長い文字列です。また、管理者パネルの「認証(Authentication)」で 「API キー」が有効(ON) になっているか再度ご確認ください。 Q2. 「404 Not Found」でエンドポイントが見つからない → \rightarrow → Open WebUIのバージョンによってはパスが異なります。Base URL を http://localhost:3000/api から http://localhost:3000/api/v1 に変更してお試しください。 Q3. 「400 BadRequest: max_tokens cannot be greater than max_model_len」が出る → \rightarrow → Clineの Max Output Tokens (既定値32000など)が、vLLMの max_model_len (16384)を超えています。Cline設定で 8192 または 4096 に下げてください。 Q4. 「接続エラー(Connection refused)」になる → \rightarrow → SSHトンネルが切断されているか、Open WebUIがブリッジネットワークで起動している可能性があります。バックグラウンドで ssh -L 8000:localhost:8000 が生存しているか、Open WebUIが --net=host で動いているか確認してください。 実際に動かしてみた所感 # 実際に自前の Qwen/Qwen2.5-Coder-7B-Instruct バックエンドでClineを使ってみて感じたメリットは以下の通りです。 従量課金を気にしない「心理的安全性」 : 公式APIを使っていると、「長いログや巨大なソースコードを全部読ませたら何千トークン(何円)消費するだろうか……」と無意識にブレーキがかかりがちです。しかし自前GPUなら、 何十万トークン消費させようが課金はインスタンスの稼働時間分のみ 。気兼ねなく巨大なコードベースやテスト出力を丸ごと放り込めます。 高速なレスポンス : vLLMの最適化された推論エンジンのおかげで、東京リージョン間のレイテンシも含めて非常にレスポンスが良好です。 完全なプライベート性&セキュア通信 : コードやプロンプトがサードパーティのAPIプロバイダに送信されないだけでなく、EC2との間もSSHトンネルで強力に暗号化されているため、機密性の高い社内コードの検証にも安心です。 💡 チームでシェアできる? 同時に何人くらい使えるのか # 📌 このセクションの要点 24GB VRAM(L4)環境では、エンジニアの「思考時間」を考慮すると 1台あたり3〜5人のシェアが最もコストパフォーマンス高く実用的 です。 個人での利用はもちろん、現場のエンジニアとしては「チームメンバー数人で1台の推論サーバーをシェア(相乗り)して使えるか?」という点も気になるところです。 推論サーバー( g6.xlarge : NVIDIA L4 / 24GB VRAM)のスペックとClineの特性を踏まえると、実用的な人数の目安は以下の通りです。 利用形態 推奨人数 使用感・体感 快適(専有) 1 〜 2 人 待ち時間ほぼなし。60〜80 tokens/sec のストリーミング速度を十分活用可能。 実用的なチーム利用 (★推奨) 3 〜 5 人 実務で最もバランスが良い規模 。誰かのリクエストと多少被っても体感20〜30 tokens/secを維持し十分快適。 混雑(許容限界) 6 〜 8 人 リクエストが重なると初速(TTFT: 最初の1文字が出るまで)に数秒の待ちが発生し始める。 厳しい(非推奨) 10 人以上 キュー待ちや速度低下が頻発。VRAMキャッシュ溢れのリスクが高まる。 なぜ「3〜5人」がベストバランスなのか? KVキャッシュ(文脈メモリ)の容量 : VRAM 24GBのうち、モデル本体(約15GB)を除いた空きメモリ(約6.5〜7GB)が会話文脈のキャッシュ用プールになります。自律型エージェントはコード全体を大量に読むため、1リクエストあたり約300〜500MBを消費し、同時にメモリ保持できるアクティブな会話は物理的に12〜14本程度です。 エンジニアの「思考時間」の存在 : エンジニアは常にキーボードを叩いてプロンプトを送り続けているわけではありません。「プロンプト送信 → \rightarrow → 推論(15〜30秒) 」のあと、「生成コードの確認・ビルド・テスト → \rightarrow → 思考(3〜5分間は推論ゼロ) 」というサイクルを挟みます。1人あたりの推論稼働率は実質10〜15%程度のため、 3〜5人規模のチームであればリクエストの衝突が自然と回避され、1台のGPUをストレスなくシェア可能 です。 コスト面で見ても、 g6.xlarge のオンデマンド料金(約 $1.38/h ≒ 1時間あたり約 228 円 ※1ドル=165円換算)を3〜5人で割れば、 1人1時間あたり約 45 〜 75 円 。モデルを保管しているS3のストレージ費(月額 約 62 円)と合わせても、極めて高い費用対効果でチーム開発に導入できます。 検証終了時はワンコマンドで完全破棄 # 作業が終わったら、放置課金を防ぐためにインスタンスをTerminateします。 04_ec2_terminate.sh(クリックで展開) #!/bin/bash set -euo pipefail # ============================================================================== # 04_ec2_terminate.sh # 検証終了時にEC2インスタンスを即座に完全破棄(Terminate)するスクリプト # # ポイント: # - EBSボリュームごと削除されるため、停止中のストレージ課金を完全にゼロにします。 # ============================================================================== SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" INSTANCE_STATE_FILE="${SCRIPT_DIR}/.current_instance_id" TUNNEL_PID_FILE="${SCRIPT_DIR}/.current_ssh_tunnel_pid" AWS_REGION="${AWS_REGION:-ap-northeast-1}" INSTANCE_ID="${1:-}" if [ -z "${INSTANCE_ID}" ] && [ -f "${INSTANCE_STATE_FILE}" ]; then INSTANCE_ID=$(cat "${INSTANCE_STATE_FILE}" | tr -d '[:space:]') fi if [ -z "${INSTANCE_ID}" ]; then echo "エラー: 終了対象のインスタンスIDが指定されていません。" echo "使用例: ./04_ec2_terminate.sh <i-xxxxxxxxxxxxxxxxx>" exit 1 fi echo "=== EC2インスタンスの完全終了 (Terminate) ===" echo "対象インスタンスID: ${INSTANCE_ID}" echo "リージョン : ${AWS_REGION}" echo "" echo "※ EBSボリューム(DeleteOnTermination=true)も連動して完全削除されます。" aws ec2 terminate-instances \ --region "${AWS_REGION}" \ --instance-ids "${INSTANCE_ID}" \ --output table echo "インスタンスの終了完了を待機中..." aws ec2 wait instance-terminated \ --region "${AWS_REGION}" \ --instance-ids "${INSTANCE_ID}" rm -f "${INSTANCE_STATE_FILE}" # バックグラウンドのSSHトンネルプロセスを停止 if [ -f "${TUNNEL_PID_FILE}" ]; then TUNNEL_PID=$(cat "${TUNNEL_PID_FILE}" || true) if [ -n "${TUNNEL_PID}" ] && kill -0 "${TUNNEL_PID}" 2>/dev/null; then echo "SSHトンネルプロセス (PID: ${TUNNEL_PID}) を停止します..." kill "${TUNNEL_PID}" 2>/dev/null || true fi rm -f "${TUNNEL_PID_FILE}" fi echo "==========================================================" echo " インスタンスおよびEBSボリュームの完全破棄が完了しました!" echo " これ以降、本インスタンスに関する課金(コンピュート/EBS共に)は一切発生しません。" echo "==========================================================" # 実行コマンド ./04_ec2_terminate.sh 起動スクリプトが記録しておいたインスタンスIDとSSHトンネルのプロセスIDを自動で読み込み、 EC2・EBSボリュームの完全破棄と、ローカルのSSHトンネルプロセスの終了をワンコマンドでクリーンに完了 してくれます。 これで停止中のストレージ課金も1円たりとも発生しません。 --> Information 😴 筆者の実話:検証中に寝落ちしても、自動終了タイマーが動作してくれた話 実は今回の検証中、夜間にモデルの応答速度やプロンプトの挙動を試している最中、うっかりPCを開いたまま寝落ちして朝を迎えてしまいました……。 朝起きて「GPUインスタンスを立ち上げっぱなしにしてしまったかも……」と焦りながらAWSマネジメントコンソールを確認したところ、UserDataに仕込んでおいた 「1時間アイドル自動終了デーモン」 が正常に動作しており、推論リクエストが途絶えてからちょうど1時間後にインスタンスが自動的にTerminate(破棄)されていました。 追加の課金は最小限(数十円程度)で済みました。 エフェメラル設計とアイドル監視による自動終了の有り難みを、身をもって実感した体験でした。 まとめ # 今回は、前回の記事で作成したvLLM自動起動環境から一歩進めて、 「Open WebUI によるリッチなWeb対話&APIゲートウェイ機能」と「SSHポートフォワードによる安全な通信路確立」を組み合わせることで、VS Codeの自律型コーディングエージェント「Cline」のバックエンドを完全自前化 してみました。 Webチャットとコーディングエージェントの両立 : ブラウザから対話検証しつつ、発行したAPIキーでそのままVS Code(Cline)から自律コーディングを実行。 Open WebUIによる安心の保守性 : 世界中で支持される活発なコミュニティにより、セキュリティや新機能の追従も盤石。 SSHポートフォワードの一挙両得 : ポート8000をインターネットに晒さず暗号化しつつ、Base URLをローカル固定化することでIP変動問題も同時に解決。 トークンフリーな自律開発環境 : インスタンス稼働費用のみの定額感覚で、コーディングエージェントを気兼ねなく活用できる環境を構築。 手軽にGPUを起動し、使い終わったら破棄するエフェメラル運用だからこそ、モダンなフロントエンドとオープンソースモデルの組み合わせを低リスクで試すことができます。興味のある方はぜひ活用してみてください。
はじめに # 前回の記事では、私がETロボコンに取り組んできた4年間の歩みと、昨年度のベーシッククラスでAIを「設計レビューの相棒」として活用し、シルバーモデルを獲得したことを紹介しました。 https://developer.mamezou-tech.com/blogs/2026/07/29/et-robocon-001/ その記事の最後で、今年度はモデル作成のさらなるレベルアップを目指して アプライドクラス に挑戦していると書きました。本記事はその続編です。 昨年度は、主に人が作成したモデルをAIにレビューさせ、改善案の検討や抜け漏れの確認に利用していました。今年度はレビューだけでなく、要求候補の洗い出しや図の初期案の作成など、 モデルを作る工程そのもの にもAIの利用範囲を広げました。 前回は、AIを開発の「新しい武器」として紹介しました。レビューで手応えがあったからこそ、任せる範囲を広げればモデル作成もかなり楽になると考えていました。実際、何もない状態から案を出すところまでは速くなりました。ところが、その案を提出できるモデルへ整理する段階では、要求と実現方法の切り分けや、図同士の整合確認、修正差分の確認に想像以上の時間がかかりました。 この記事では、ETロボコン2026のモデル作成を振り返りながら、 AIで案を作ることは速くなったのに、なぜ完成までは楽にならなかったのか を、実際に陥った3つの落とし穴から紹介します。前回がAI活用の手応えを伝える記事だったとすれば、今回はAIを使う範囲を広げたことで見えてきた、別の難しさの記録です。 1. 今年度の挑戦:アプライドクラスでのモデル作成 # 2026年度のアプライドクラス地区大会では、 ETラリー がモデル審査の対象となっています。 ETラリーは、コース上に配置された赤、青、黄のゲートを、決められた順番で通過する課題です。 --> Information ETロボコンについて初めて知る方は、 ETロボコン公式サイト をご覧ください。2026年のフィジカル部門の競技内容は、 公式の競技ルール解説動画 でも紹介されています。 私たちは、次の3枚で提出モデルを構成しました。 アブストラクトページ 要求モデル システム分析モデル モデル作成は、次の流れで進めました。 flowchart LR A[競技規約の整理] --> B[ユースケース分析] B --> C[FMEAによる<br>リスク分析] C --> D[要求モデル] D --> E[サブシステム分割] E --> F[構造・振舞いの分析] F --> G[提出モデルの統合] AIは、主に次の作業で利用しました。 競技規約の整理 要求候補の洗い出し 図の初期案の作成 UML/SysML表現や抜け漏れの確認 説明文の推敲 何もない状態から最初の案を作るまでの時間は短くなりました。ただし、その案を提出できるモデルとしてまとめるまでには、想定以上の調整が必要でした。 2. AIでモデルを作って分かった3つの落とし穴 # AIに任せる範囲を広げたことで、特に困ったのは次の3つです。 要求候補を増やすほど、要求と設計が混ざる 修正を重ねるほど完成から遠ざかる 担当ごとに作った図が、うまくつながらない いずれも、AIが出す案の質そのものよりも、その案を人が整理し、統合する段階で表面化した問題でした。 落とし穴1:要求候補を増やすほど、要求と設計が混ざる # 最初に困ったのは、 要求候補を増やすほど、何を要求として残すべきか分かりにくくなったこと です。 AIは、競技規約や検討中の情報を渡すと、そこから考えられる要求候補を短時間で広げてくれます。これは叩き台を作る場面では便利でした。一方で、出てくる候補には、規約に書かれた事実だけでなく、設計上の仮定、異常時の対策、具体的な実現方法まで混ざることがありました。 途中の要求整理を見返すと、例えば「目標座標まで進む」という要求の下に、次のような走行方式が並んでいました。 ライントレース走行 床QR座標系走行 IMU座標系走行 また、別の箇所では、ヒントカードの情報を取得する要求から、Base64のデコードやAES-128 ECBによる復号、4桁の復号キーの入力といった具体的な処理まで掘り下げていました。個々の内容は必要そうに見えますが、 「システムが満たすべき要求」なのか、「要求を実現するための設計上の選択」なのかが同じ階層に並んでいた のです。 当時の検討メモにも、「手段が混じっている」「アルゴリズムは設計で選択するものではないか」「ここまで書くと詳細すぎないか」といった迷いが残っていました。AIに候補を増やしてもらうこと自体よりも、その後に人が分類し直す作業の方が難しかったと感じています。 最終版では、要求には原則として具体的な実現方式を含めず、実現方式は設計要素として分離しました。また、FMEAで抽出したリスク対策についても、対策そのものをそのまま書くのではなく、必要な要求を導出してから設計要素へつなげています。 なお、要求図を作っていた時点のAIとのやり取りをすべて履歴として残していたわけではありません。そのため、「この要求はAIが追加した」と一つずつ特定することはできません。ここで挙げているのは、 AIを使いながら作業した途中資料に、実際にどのような整理課題が残ったか という観点での振り返りです。 この経験から、AIに要求候補を出してもらう前に、少なくとも次の区分を決めておく必要があると感じました。 規約やユースケースから導ける事実 システムが満たすべき要求 設計上の仮定や実現方法 リスクから追加した対策要求 まだ採用を決めていないアイデア 単に「要求を挙げてください」と依頼するより、 候補ごとに種別と根拠を付けさせる 方が、後で整理しやすくなります。 落とし穴2:修正を重ねるほど完成から遠ざかる # 次に困ったのは、 修正を繰り返すうちに、かえって作業量が増えたこと です。 実際には、「ここが気になるので修正して」とAIに頼み、出てきた結果を見て「何か違うな」と感じ、さらに修正を依頼する、というやり取りを何度も繰り返しました。最初の指示が曖昧なまま修正を始めてしまい、直したい箇所以外の記述まで変わることもありました。 すると、変更された部分だけでなく、その変更によって新しい不整合が生まれていないかも確認しなければなりません。修正するたびに確認範囲が広がり、完成へ近づいているのか分からなくなることもありました。 メンバーによっては、数日間の作業で1か月分の利用上限に達するほど、AIとのやり取りが膨らみました。文章や図の案を出す時間は短くなりましたが、その後の差分確認には時間がかかりました。 そこで、後半の作業では次の点を意識しました。 1回の依頼で変更する範囲と、変更しない箇所を明示する モデル全体ではなく、差分だけを出力させる チームで参照する最新版を一つに決める 修正を続ける前に、一度人が方向性を判断する AIに何度も修正させることより、 何が気になっていて、どう直したいのかを人が先に整理すること の方が重要でした。 落とし穴3:担当ごとに作った図が、うまくつながらない # 今回のモデルは、複数のメンバーで図を分担して作成しました。それぞれの担当者がAIを利用したことで、個々の図は早く作成できました。 ところが、完成した図を組み合わせてみると、次のような違いが見つかりました。 同じ概念に異なる名前が使われている 要求図とシステム分析図で責務の粒度が異なる 関連の向きやステレオタイプが統一されていない 個々の図は妥当でも、図をまたぐと関係を追えない AIは担当者から与えられた文脈に合わせて、それぞれ異なる「もっともらしい案」を生成します。そのため、担当者ごとの解釈の違いが、そのまま図へ反映されやすくなりました。根本にあるのは分担作業の共通ルールと統合方法の問題ですが、AIがその差を広げやすいことは意識しておく必要があります。 次回は、モデル全体で使う用語と命名規則、上位工程で決めた要求やサブシステムのID、UML/SysMLの記法を先に共有したいと考えています。担当者がAIへ依頼するときにも、同じ用語とモデリング方針を渡す必要があります。また、最後にまとめて統合するのではなく、途中で図を持ち寄って確認する時間も設けたいところです。 3. 落とし穴の背景にあった、人の側の課題 # 3つの落とし穴を振り返ると、すべてをAIの問題として片付けることはできません。むしろ、 人の側で曖昧だった部分を、AIがもっともらしく具体化したことで、整理すべき量が増えた と考えた方が近そうです。 対象システムの境界を決め切れていなかった # 競技全体からETラリーだけを切り出す際、どこまでを今回の分析対象に含めるかを十分に決め切れていませんでした。 途中資料では、例えば復号キーについて、「走行体が受信すること」まで要求に含めるのか、スターターやPC、無線通信デバイスとの境界をどこに置くのか、といった検討が残っていました。こうした境界が曖昧な状態で要求を展開すると、対象外かもしれない処理まで詳細化されていきます。 また、正常時の走行要求だけでなく、QRを読み取れなかった場合の復帰、目標未到達時のタイムアウト、順序外のゲートを通過した場合の再走行なども同じ要求ツリーの中で検討していました。異常系を考えること自体は必要ですが、正常系、リスク対策、設計案が同時に増えると、どの観点で要求を分解しているのかが分かりにくくなります。 AIが問題を作ったというより、 対象範囲や分類軸を決める前に候補を広げたため、人がまだ決めていない部分まで具体化された ことが問題でした。 検討の終了条件を決めていなかった # FMEAや要求図の粒度調整に時間をかけすぎた結果、システム分析モデルや提出モデルの説明文を作成する時間が不足しました。 AIを使うと、「このケースもありそう」「この対策も必要そう」と案を簡単に増やせます。そのため、「もう少し良くできそう」と検討を続けやすくなります。しかし、モデル審査では限られた紙面と時間の中で、何を伝えるかを決める必要があります。 今回の反省は、AIの出力精度だけではありません。 どこまで検討すれば完了なのかという終了条件を、人が先に持てていなかったこと も大きかったと考えています。 4. それでもAIが役立った場面 # ここまで落とし穴を挙げてきましたが、AIを使わない方がよかったとは考えていません。実際に、AIが効果的だった場面も多くありました。 モデルの叩き台を短時間で作る 複数の表現案を比較する 自分たちが見落としていた観点を洗い出す UML/SysMLの表現や矛盾を確認する 説明文を読みやすく整える 特に、何もない状態から最初の案を作る場面では役立ちました。一方で、何をモデル化するか、どこまでを対象にするか、どの案を採用するかは、人が決める必要があります。AIは案を出すところでは役立ちますが、チームが何を実現したいのかまで判断してくれるわけではありません。 5. 次に同じ作業をするなら # 今回の反省を踏まえ、次回は次のように進めたいと考えています。 モデルの目的と対象範囲を、人が先に決める AIの出力を「事実・要求・仮定・実現方法・提案」に分ける 要求候補には、根拠と採否を残す 上位工程で決めた用語とIDをチームで共有する モデル全体の再生成ではなく、差分修正を基本にする 途中案と判断理由を残し、後から変更経緯を追えるようにする 最終的な整合性と採否は、人が判断する 今回、途中資料はいくつか残っていたものの、AIとのやり取りや要求が追加された経緯をすべて追える状態ではありませんでした。次回は、完成モデルだけでなく、「なぜ追加したか」「なぜ削ったか」も簡単に残しておきたいと考えています。そうすれば、AIの出力を評価しやすくなるだけでなく、複数人でモデルを統合するときの判断材料にもなります。 また、AIへ次の修正を頼む前に、本当に直す必要があるのか、どうなれば修正完了なのかを一度人が考えます。プロンプトの工夫だけではなく、誰が何を決めるのか、どのファイルを最新版として扱うのか、どの状態をレビュー対象とするのかをチーム内でそろえることも必要です。 6. 東海地区大会に向けて # モデルはすでに提出し、現在は10月3日の東海地区大会に向けて実機走行の準備に軸足を移しています。 今後は、モデル上で検討したリスク対策が実際の走行でも機能するのかを確認していきます。走行ログを使った状態遷移の確認や、ゲート誤認識時のリカバリ、モデルと実装の対応関係も重点的に見直す予定です。 今回一番強く感じたのは、AIで「考え始めるまで」は速くなっても、 「何を要求とし、何を設計に回し、どこで検討を終えるか」を決めるところまでは、自動的に速くならない ということでした。 AIは、曖昧な部分を埋めながら案を広げるのは得意です。しかし、モデルでは、その曖昧さを残してよいのか、どこで境界を引くのかを決める必要があります。次回は、AIに案を増やしてもらうこと以上に、 分類する、捨てる、終わらせる という人側の判断を意識して使いたいと思います。
はじめに # GFXBenchというGPUベンチマークソフトを取り上げたシリーズの3回目です。 前回までで新しいAndroidバージョンで実行が行えました。 今回からは古いAndroidバージョンでの実行を試みてみます。 ソースコード準備 # 前回までのAPKファイルを古いAndroidバージョンで実行するとエラーが発生し(詳細は後述)ベンチマーク実行不可でした。 エラー原因からソースコードを修正して回避出来ないか検討してみます。 修正方針 # 修正方針も色々あるかと思いますが、今回の前提としてAndroid SDKおよびNDKバージョン、Java側の設定は変更しないで頑張ってみる事にしました。 以下前回までの設定を再掲します。 Path Version Description Location build-tools;35.0.0 35.0.0 Android SDK Build-Tools 35 build-tools/35.0.0 cmdline-tools;latest 16.0 Android SDK Command-line Tools (latest) cmdline-tools/latest ndk;28.0.12674087 28.0.12674087 rc2 NDK (Side by side) 28.0.12674087 ndk/28.0.12674087 platform-tools 35.0.2 Android SDK Platform-Tools platform-tools platforms;android-35 1 Android SDK Platform 35 platforms/android-35 platformBuildVersionCode='35' compileSdkVersion='35' minSdkVersion:'21' targetSdkVersion:'35' これら設定の元で修正案を考えていきます。 実行時のエラーと修正案 # 最初は実行時のエラーの理由が訳わからなかったのですが、整理すると以下の3つの様でした。 Androidバージョン 8 → 7 の壁 commons-io が java.nio に依存していてそれで NoSuchMethodError が実行時に発生しました。 E AndroidRuntime: Caused by: java.lang.NoSuchMethodError: No virtual method toPath()Ljava/nio/file/Path; in class Ljava/io/File; or its super classes (declaration of 'java.io.File' appears in /system/framework/core-libart.jar) E AndroidRuntime: at org.apache.commons.io.IOCase$$ExternalSyntheticApiModelOutline0.m(D8$$SyntheticClass:0) ・・・ Androidバージョンによる java.nio サポート可否が原因ですが、そもそものところで java.nio を使っていないバージョンにしてみます。 Apache Commons IO のDependencies情報あたりからバージョンを 2.6 まで下げればいい様なのでそうしてみます。 Androidバージョン 7 → 6 の壁 cannot locate symbole "__fread_chk" という問題が実行時に発生しました。 W Runner : Failed to preload lib: dlopen failed: cannot locate symbol "__fread_chk" referenced by "/data/app/net.kishonti.gfxbench.v50105.corporate-1/lib/arm/libgfxbench40_gl.so"... E (Error) ではなく W (Warning) 表記なので最初は見過ごしていたのですが、これがクリティカルでした。 これも何かネイティブ側のライブラリバージョンを下げる方法があればいいと思われます。探すと ANDROID_NATIVE_API_LEVEL という定義が見つかったので、元の値 24 (Android7.0) から 21 (Android5.0) と下げてみます。 Androidバージョン 6 → 5 の壁 requestPermissions() で NoSuchMethodError が実行時に発生しました。 E/AndroidRuntime(22812): java.lang.NoSuchMethodError: No virtual method requestPermissions([Ljava/lang/String;I)V in class Lnet/kishonti/testfw/app/MainActivity; or its super classes (declaration of 'net.kishonti.testfw.app.MainActivity' appears in /data/app/net.kishonti.testfw.app-1/base.apk) このメソッドは APIレベル23 (Android6.0) からなのでそのバージョンから有効な記述に修正します。 修正 # 以上の修正をまとめたパッチです。Android用 公式手順へのプラス分 となります。 --> Warning 以下のパッチは筆者の環境における一例であり、適用は 自己責任 にてお願いいたします。(ライセンス等の詳細は 記事末尾 に記載しています) なお、前々回適用したパッチ frameworks/cudaw/CMakeLists.txt (CUDAヘッダパスの設定を修正)の内容も含まれています。 diff --git a/app_android/benchui-lib/build.gradle b/app_android/benchui-lib/build.gradle index ceb9dbaa..534663bf 100644 --- a/app_android/benchui-lib/build.gradle +++ b/app_android/benchui-lib/build.gradle @@ -24,7 +24,7 @@ android { dependencies { implementation('com.google.code.gson:gson:2.10.1') - implementation('commons-io:commons-io:2.15.0') + implementation('commons-io:commons-io:2.6') implementation('de.greenrobot:greendao:2.1.0') implementation project(':testfw') diff --git a/frameworks/cudaw/CMakeLists.txt b/frameworks/cudaw/CMakeLists.txt index 0cc4a30e..710b4519 100644 --- a/frameworks/cudaw/CMakeLists.txt +++ b/frameworks/cudaw/CMakeLists.txt @@ -13,7 +13,9 @@ add_library(cudaw STATIC if(ANDROID) execute_process( - COMMAND cp -rL /usr/local/cuda/include ${CMAKE_CURRENT_SOURCE_DIR}/include/nvidia + COMMAND ${CMAKE_COMMAND} -E copy_directory + "C:/Program Files/NVIDIA GPU Computing Toolkit/CUDA/v10.2/include" + ${CMAKE_CURRENT_SOURCE_DIR}/include/nvidia RESULT_VARIABLE COPY_RESULT ) if(NOT COPY_RESULT EQUAL 0) diff --git a/frameworks/testfw/android/testfw-app/build.gradle b/frameworks/testfw/android/testfw-app/build.gradle index a4edba77..59e41cbf 100644 --- a/frameworks/testfw/android/testfw-app/build.gradle +++ b/frameworks/testfw/android/testfw-app/build.gradle @@ -45,7 +45,7 @@ android { } dependencies { - implementation("commons-io:commons-io:2.11.0") + implementation("commons-io:commons-io:2.6") implementation project(':testfw') implementation project(':platform-utils') diff --git a/frameworks/testfw/android/testfw-app/src/main/java/net/kishonti/testfw/app/MainActivity.java b/frameworks/testfw/android/testfw-app/src/main/java/net/kishonti/testfw/app/MainActivity.java index 808b4a47..89c53e1d 100644 --- a/frameworks/testfw/android/testfw-app/src/main/java/net/kishonti/testfw/app/MainActivity.java +++ b/frameworks/testfw/android/testfw-app/src/main/java/net/kishonti/testfw/app/MainActivity.java @@ -38,7 +38,9 @@ public class MainActivity extends Activity { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); - requestPermissions(new String[] { Manifest.permission.WRITE_EXTERNAL_STORAGE }, 2); + if (android.os.Build.VERSION.SDK_INT >= 23) { + requestPermissions(new String[] { Manifest.permission.WRITE_EXTERNAL_STORAGE }, 2); + } mDetailView = (TextView) findViewById(R.id.detailView); mDetailView.setMovementMethod(new ScrollingMovementMethod()); diff --git a/frameworks/testfw/android/testfw-lib/build.gradle b/frameworks/testfw/android/testfw-lib/build.gradle index d0e757b1..37dc1bcf 100644 --- a/frameworks/testfw/android/testfw-lib/build.gradle +++ b/frameworks/testfw/android/testfw-lib/build.gradle @@ -27,5 +27,5 @@ android { dependencies { implementation('com.google.code.gson:gson:2.10.1') - implementation('commons-io:commons-io:2.15.0') + implementation('commons-io:commons-io:2.6') } diff --git a/scripts/build-3rdparty.sh b/scripts/build-3rdparty.sh index 26e1cce7..c72e0d73 100644 --- a/scripts/build-3rdparty.sh +++ b/scripts/build-3rdparty.sh @@ -28,7 +28,7 @@ fi : ${CONFIG?"not set"} # set default values -: ${ANDROID_NATIVE_API_LEVEL:="android-24"} +: ${ANDROID_NATIVE_API_LEVEL:="android-21"} : ${ENABLE_CLANG:="false"} : ${USE_WAYLAND:="false"} diff --git a/scripts/build.sh b/scripts/build.sh index 8e501d7a..a34fb2a7 100644 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -385,7 +385,7 @@ case $PLATFORM in fi RENDER_API=${OVERRIDE_RENDER_API:="${RENDER_API}"} COMMON_OPTS+=" -DRENDER_API=${RENDER_API}" - COMMON_OPTS+=" -DANDROID_NATIVE_API_LEVEL=24" + COMMON_OPTS+=" -DANDROID_NATIVE_API_LEVEL=21" PROJECTS="frameworks/platform-utils $PROJECTS frameworks/testfw" COMMON_OPTS+=" -DOPT_SWIG_JAVA=1 -DLIBRARY_OUTPUT_PATH_ROOT:PATH=${TFW_PACKAGE_DIR}" ビルド実行 # 古いAndroid端末でも動作する様に android-arm64-v8a android-armv7a 両対応のユニバーサルAPKとしてビルドしてみます。 前々回の環境変数を設定の後、 公式手順 ビルドスクリプト2種 の代わりに以下のスクリプト を実行します。 (環境変数の内、 PLATFORM, CONFIG, APPLICATION_TYPE はこのsh内で再設定されます) scripts/build-multiarch-apk.sh --> Information 前々回のAndroid用公式手順には載っていないのですが、GitHub workflow手順ではAndroid用にこのスクリプトでビルドしている様でそこからの拝借です。 デフォルトだと android-armv7a android-x86 android-arm64-v8a android-x86-64 の4種類分ビルドされます。 android-armv7a android-arm64-v8a の2種類だけで良ければ以下の様に実行します。 PLATFORMS="android-armv7a android-arm64-v8a" scripts/build-multiarch-apk.sh apkサイズとビルド時間を削減する効果があります。特にビルド時間の削減効果が大きいです。 ビルドが成功すると、ビルドのコンソールログで表示されるディレクトリに gfxbench-5.1.5+corporate.apk ファイルが出来上がります。 おわりに # 今回はGFXBenchを古いAndroid端末用にビルドしてみました。 次回は実際に古いAndroid端末で実行しベンチマークスコアを測ってみます。 ライセンスおよび免責事項 # 本記事に掲載しているビルド修正パッチ、引用しているビルド手順は、BSD 3-Clause Licenseのもとで公開されている Kishonti-Opensource/gfxbench のソースコードおよびドキュメント( doc/gfxbench_gl_android_build.txt )を利用・引用したものです。 Original Copyright: (c) 2005–2025 Kishonti Ltd. License: BSD 3-Clause License ドキュメントの権利について: 記事内で引用している公式ビルド手順のテキストの著作権は、原著作者であるKishonti Ltd.に帰属します。 【免責事項】 本記事に掲載しているパッチ、手順は、特定の検証環境における現状のまま(AS IS)のものであり、その正確性や安全性を保証するものではありません。 パッチの適用やビルドの実行により生じた直接的・間接的な損害について、筆者および株式会社豆蔵は一切の責任を負いません。内容を十分にご確認の上、ご自身の責任においてご利用ください。
 この記事を読んでいただきありがとうございます。アジャイルグループに所属する、藤井智弘です。  判断の積み重ねは、いつしか慣習になります。そして一度慣習になると、誰も疑問を持たなくなります。前例踏襲という「思考停止」の完成です。今回からの3回は、そのひとつ、更改案件の「現行機能保証」を扱います。対処するには3つの視点が必要で、それぞれをサブテーマとして、3回に分けて扱います。 第3回(本稿): 「現行」の実体を見る 第4回: 合意できる「全体像」を作る 第5回: 検証し、予算を組み、配分する  この3本のねらいは、「読者の職場に波風を立てる」ことです。「更改案件は〇〇するものだ」「アジャイルは〇〇だ」——さまざまな決まり文句が、私たちの思考を止めています。そろそろそれらに疑問の目を向けて、「ほんとにムリなのか、1回考えてみようよ」というお誘いです。  また、現実に目を向けると、稟議や契約の席にいる人たちにまでアジャイルの進め方が浸透している、あるいは会社のプロセスがアジャイルを許容している——そんな現場のほうがめずらしいでしょう。この3本は、そうした文脈に合わせてプロセスを仕立て直す一例として読んでいただければと思います。 質問 #  うちの部署の仕事は、既存システムの更改・アップグレード案件が中心です。今度の案件を「アジャイルで進める」という方針が出たのですが、正直なところ、メリットがわかりません。  発注側は「現行機能保証」を当然の前提として話を進めます。でも、現行システムの全機能を把握する手段はありません。頼みのドキュメントも、揃っている保証はなく、揃っていたとしても現物と一致している保証がない。ウォーターフォールでも苦しいのに、アジャイルは「最初に全件を確定しない」進め方のはずです。全件を求める発注側と、全件を確定しないアジャイル——噛み合う気がしなくて、違和感が拭えません。  「保証しろ、ただし、対象の全貌は不明」という状況に、アジャイルでどう向き合えばいいのでしょうか? 回答 #  いいですね、その“違和感”。その違和感や疑問を、まず大事にしてください。  JUASの「 企業IT動向調査2026 」でも、IT予算の増加理由に「既存システム・基盤の刷新・更新・増強」が挙げられています——この質問の背景は、業界の実態そのものでもあります。  そうした案件で「現行機能保証」は、対象の全貌が不明なまま全件の保証だけが約束される“魔法の言葉”ですそ。しかし、その中身は「要件は不明」の言い換えに過ぎません。そして、契約不適合責任(以前の「瑕疵担保」)との合わせ技で、発注側は受注側にリスクを転嫁できるのです。 …ちょっと刺激的すぎますか?  厄介なのは、この曖昧な文言が判定基準として使われることです。何が「現行通り」かが不明なまま全件を保証する約束は、 約束の中身を変えないかぎり、契約の形をどんなに工夫しても、実務上、履行のしようがありません 。できないと分かっていることを、片や「できないと困るんです」と押し通し、片や契約欲しさに「できます」と装っているうちは、泥沼が約束されます。  「できないことはできない」と素直に認めることが、解決のスタートラインです。  さてそこで、「メリットがわからない」に立ち戻ると…メリットどころか、 アップグレード案件は、アジャイルの適地だ と、私は考えています。ポイントは、 曖昧なものを、管理可能な形に置き換える ことです。  では、「現行」ってなんだろう? というところから始めていきましょう。 「現行通り」の正体——仕様は1つではなく3つある #  そもそも「現行通り」とは、何を指すのでしょうか?  根本の問題は、「現行の仕様」と呼ばれるものが、実際には3つの別物の混合体であることです。 紙に書いてあること(明文化された仕様)  設計書・仕様書に“ある時点で”記録された内容です。 ある時点 では現物を反映していた かも しれませんが、最新と一致している保証はありません。この紙を持っているのは、発注側と、受注側の見積担当です。 コードがやっていること(実装された挙動)  実際にコードとして動いている挙動です。仕様書との差分——バグ修正、現場対応の改修、そもそも文書化されなかった機能——も、ここに含まれます。この情報を持っている(あるいはコードを読める)のは、保守を担当してきた開発者です。 人がやっていること(現場の使い方)  ユーザーがそのシステムをどう使って業務を回しているか、です。マニュアル外の操作手順や、既知のバグを避けるための現場の運用上の工夫を含みます。前回の更改でシステムに取り込まれず、手作業でカバーすることにした業務手順もここに入りますが、コードにも仕様書にも、まず残りません。知っているのは現場の利用者ですが、本人にとっては「仕様」ではなく、ただの日常です。だから、聞かれなければ出てきません。  「現行機能保証」と言うとき、発注側が本当に守りたいのは**3「人がやっていること」 です。しかし、契約や検収の根拠にされるのは 1「紙」 で、移行の実作業が向き合うのは 2「コード」**です。この3つは、リリースした日にはだいたい一致していたはずです。ズレを作るのは、その後の時間です。 障害対応の緊急改修で、コードだけが変わる 法改正や組織変更には、システム改修の予算が付くまで、現場の運用が先に対応する 紙は「次の大きな改修のときにまとめて直す」と後回しになる  どれも、その場では合理的な判断です。その積み重ねで、年を経るにつれ三者は別物になります。担当者の怠慢や努力不足ではなく、当然の帰結です。そしてこのズレが、更改案件で「テストは全部通ったはずなのに、リリース後に業務が止まった」事故が繰り返される、構造面での理由です。  三者は一致しない。それなのに、見積と契約の席に置かれるのは紙だけです。発注側には全件を把握する手立てが残っておらず、受注側の上層には材料がなく、材料を持つ末端には声を上げる機会がない。 誰も全件を持っていないのに、全員が全件を約束している ——これが、冒頭で「履行のしようがない」と言った約束の正体です(なぜそうなり続けるのかは、最後に触れます)。 それでもアジャイルが効く理由 #  個々の挙動の粒度で全件を把握できないなら、 その粒度では 「発見しながら進む」しかありません。これは、アジャイルの中核である経験主義——やってみて、観察して、次を調整する——そのものです。  この連載の第2回で、「新規開発系」「保守系」と分類しましたが、第3の分類が「更改系」です。更改系には、次の特性があります。 ドキュメントの完全性に期待できず、未知が多い 検証相手として現物があり、答え合わせができる いちどきに切り替えて失敗すると、業務が止まる  どれも「小さく検証を積み重ねる」進め方に向いた特性です。「既存システムがあるのだから機能は全件洗い出せる、だからウォーターフォールのほうが安全だ」と思われがちですが、実際には「洗い出せる気がしている」だけです。その前提自体が、更改系の特性と相容れません。 処方——保証を組み替える #  「全件が必要」と「全件を確定しないハズ」の対立をどうするか? 曖昧な“全件”を、管理可能な形に置き換えます。 「全件の暗黙保証」を「検証済みリスト+残リスク」に組み替える  「全件」を、「最初に把握されているべき、最終的な全体」ではなく、検証によって伸びていくリストとして捉え直しましょう。最初にやるべきは技術的な作業ではなく、何を保証するかの再定義の交渉です。「現行機能保証」を、検証済み機能のリスト(これは保証する)と、未検証・未発見の領域に分けて管理する形に変えてみてはどうでしょう? この際、未検証・未発見の領域はリスクとして共有し、発見し次第対応します。  ポイントは、後から仕様が発見されることを、「当然起こりうること」とみなし、「洗い出し漏れの責任問題」にしないことです。残ったリスクを最後に誰が引き受けるかは、この組み替えの中で最初に話しておくべき問いです(答えは組織によって違います)。スプリントごとに検証済みリストが伸び、残リスクが減る——進捗がそのまま保証範囲の拡大として見える構図です。  ただし、この組み替えだけでは、発注側が求めている「全件」には応えられません。検証済みリストは伸びていきますが、どこまで伸びれば終わりなのか——分母がないからです。前述の組み替えが応えているのは、「全件」の裏にある本来の願い、「業務を止めない」のほうです。業務影響の大きいものから検証していけば、止まると困る業務から順に、保証の内側に入っていきます。それでも発注側には、「全体のうち、どこまで済んだのか」を見たいという、もっともな要求が残ります。その分母——全体像をどの粒度で作るか——が、次回の主題です。 見積の内訳を、保証の文言に流し込まない  「予算の器」(稟議で確保する総額と期限の枠)と「保証の範囲」(契約で「現行通り」と約束する対象)は、別物です。予算が固定であること自体は問題ではなく、問題は、稟議書の「現行機能保証」という文言が、そのまま契約の保証としてスライドすることです。器を作るのに、個々の挙動の粒度での全件洗い出しは要りません(どの粒度なら数えられるのかは第4回で、器の組み方と配分は第5回で扱います)。見積の内訳は「参考」にとどめ、契約の保証文言には流し込まない。この一線を引けるかどうかで、案件の後半の空気が決まります。 発注側の担当者と、社内説明の語彙を共有する  アジャイルっぽい用語を避け、「検証済みリスト」「残リスク」「業務影響順」という、担当者が上司やステークホルダーに進捗を説明するために使える言葉で会話しましょう。提案書や定例報告をこの語彙で組んでおけば、担当者はそのまま社内に持ち帰れます。元請と下請の間でも同じで、転記されるのが「現行機能保証」の五文字ではなく、検証済みリストと残リスクになります。本稿で挙げた調査も、説得の道具ではなく、同じ机で一緒に読む資料として持ち込むのがお勧めです。 開発者が今日できること——事故を3つに分類してみる  契約や交渉の席にいない開発者——多重下請けの最下層で、いちばん重い荷を持たされている人ほど——にも、今日できることがあります。直近の「現行通りのはずだったのに違った」を、紙・コード・人のどこの不一致だったか分類してみてください。この分類の言葉を持つだけで、障害報告が「テスト漏れでした」で終わらず「紙・コード・人のズレでした」に変わり、本稿の議論をチームで始める入口になります。 なぜこの質問は30年繰り返されるのか #  三者が一致していないことは、現場の人ならうすうす知っています。それでも紙だけが置かれ続けるのは、もっともな事情があるからです(図3)。  最初に「現行機能保証」という書式や文言を選んだ判断は、合理的で保守的だったはずです——オープン化・ダウンサイジングの波から数えて、およそ30年前のことです。問題は、その時々の臨機応変な判断が、ノウハウとして引き継がれるとは限らないことです。全件保証で乗り切れた案件は「この書式で守れた」という成功体験として稟議の前例に残ります。一方、保証を緩めてうまくいった案件の判断は、書式には残りません。だから選択は毎回少しずつ保守的な側に倒れ、それを見直す機会がないまま、担当者が入れ替わっても書式だけが引き継がれていく。「対象不明の全件保証」を抱えた案件が世代を超えて再生産されるのは、こういう仕組みです。  一度見直してみても、バチは当たらないと思うのですが、いかがですか? #  思考を止める言葉は、受注側にもあります。質問者も口にしていた「アジャイルは全件を確定しない」です。そして、両側に共通する「確定」という言葉そのものも、疑ってみる価値があります。  ウォーターフォールも、要件を本当に確定しているわけではありません。要件は変わりえます。ただ、変えれば見積額に響くので、変更管理委員会のような重い意思決定プロセスを通すことになります(少なくともPMBOKの考え方では)。その重さが「変えずに済ませよう」という動機につながり、結果として確定しているように見えるのです。  だとすれば、本当の問いは「全件を確定できるか、できないか」ではなく、「どこまでを重い手続きに載せ、どこからを手続きなしで変えられる範囲として残すか」です。では、その線をどの粒度で引けば、発注側と「全体像」を合意できるのか。次回、「機能」という言葉の粒度に踏み込みます。 次回: 「更改案件の見積を、全機能を確定せずにどう出す?」