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

TECH PLAY

株式会社豆蔵

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

全131件

はじめに # 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)のものであり、その正確性、安全性、再現性を保証するものではありません。 本情報の利用や検証の実行により生じた直接的・間接的な損害について、筆者および株式会社豆蔵は一切の責任を負いません。内容を十分にご確認の上、ご自身の責任においてご利用ください。
はじめに # 前回の記事( 自前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は高価だから……」と躊躇していた個人開発者や、スポットインスタンスの実践的な活用法を模索していたクラウドエンジニアの方にとって、本記事がエフェメラルなインフラ設計の一例として参考になれば幸いです。
下半期に入りました。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 にも時々本サイト関連の記事が掲載されています。
 この記事を読んでいただきありがとうございます。アジャイルグループに所属する、藤井智弘です。  第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に案を増やしてもらうこと以上に、 分類する、捨てる、終わらせる という人側の判断を意識して使いたいと思います。
 この記事を読んでいただきありがとうございます。アジャイルグループに所属する、藤井智弘です。  更改案件の「現行機能保証」を3つのサブテーマに分けて扱う、その2回目です。前回(第3回)は、「現行」と呼ばれるものの実体が3つあることを見て、最後に「確定」という前提を疑いました。本稿では、発注側と合意できる「全体像」の作り方を考えます。 質問 #  既存システムの更改案件で、アジャイルで進める提案をしたところ、発注側から「見積の根拠として機能一覧を出してほしい」と言われました。「アジャイルは最初に全件を確定しない進め方です」と説明すると、「では何にいくら払うのか」と返され、そこで話が止まりました。  全機能を確定せずに、稟議に通る見積と、契約で交わせる約束は、どう作ればいいのでしょうか? 回答 #  見積の根拠として全体像を求められたら…出せます。鍵は、どの粒度で出すか、です。粒度を選べば、発注側が慣れた形の一覧と、そこからの規模感が出せます。ただしそれは、開発する中身がすべて最初に固定されることを意味しません。  第3回の最後に、問いを「確定できるか、できないか」から「 どこまでを重い手続きに載せ、どこからを手続きなしで変えられる範囲として残すか 」へ置き換えました。提出した機能一覧は、おそらく確定として扱われる——重い手続きの側に置かれます。ならば、その側に置いても守れる粒度を選び、柔軟性はその内側に残せばよい。  全体像を出すことと、柔軟さを残すことは、粒度を選べば両立します。 「全体像を見たい」は当たり前の要求——ただし、言葉の粒度が発注側と受注側で違う #  現場の支援でよく感じるのは、発注側と受注側は、同じ「機能」という言葉を使いながら、思い浮かべている粒度が違うのではないか? ということです。受注側のアジャイルチームが思い浮かべるのは「ストーリー」——研修で、スプリント(1〜2週間)でテストまで終えるんだぞと習ったアレです。一方、発注側の頭にあるのは、予算獲得の根拠になった機能一覧——画面や帳票の単位で書かれた、ストーリーよりかなり大きな粒度です。この粒度の違いが議論の俎上に載ることは、ほとんどありません。だから、予算やスケジュールの都合で「ある機能の開発をやめる」となったとき、失うものの大きさの受け止め方が、発注側と受注側で食い違うことも珍しくありません。  粒度の認識の違いに加えて、研修で「更改系の文脈」を教わる機会がほとんどないことも、問題を難しくしています。  アジャイルの教科書や事例の多くは、新規開発系——とくにネットサービスのように、何が受け入れられるかは出してみないと分からない世界——を舞台にしています。「全件を確定しない」は、その文脈では合理的な判断です。  では、更改系はどうでしょう? 業務はすでに回っていて、新システムの価値は、まず網羅性——抜けがあれば業務が止まる——にあり、ゴールも最初から決まっています。段階的に組み立てるには、全体を俯瞰して「今回はどこまでやるか」を選ぶ必要がある。発注側が「全体像を見せてほしい」と言うのは、「これで業務が回るのかを確かめたい」からで、当たり前の要求です。そこへネットサービスの文脈の論理——「全件を確定しない」——をそのまま持ち込めば、会話がすれ違うのも当然です。 なぜユースケースか——粒度の構造が、最初から決まっている #  「アジャイルといえばストーリー」というイメージが定着していますが、アジャイルの世界にも粒度を整理する仕組みはあります。エピックやフィーチャーといったタイプを加え、大きな括りから小さな単位へと粒度を変え、それぞれの粒度で何を見るか——事業の狙いか、利用者の使い方か——という視点まで整理する仕組みです。ただ、このタイプの扱いは手法によって異なり、粒度の設計をチームが自分で決める部分が多いため、経験の浅いチームには大きな負担になります。  そこで本稿では、粒度の構造があらかじめ定義されている ユースケース を紹介します(あくまで「選択肢を提示する」という本連載の趣旨に沿って、ひとつの選択肢として)。参考にするのは、Ivar Jacobsonらの「 Use-Case 3.0 」(Jacobson, Spence, de Mendonca, 2024年)——ユースケースを、アジャイル開発を駆動する軽い実践として整理し直したガイドで、ユーザーストーリーとの併用を前提にしています。  具体的な使い方は、のちほどサンプルでお見せします。その前に、基礎を確認しておきましょう。  ユースケースとは、「ある特定の利用者の目的を達成するための、システムの使い方のすべて」と定義できます。構成要素は3つです。 アクター (誰が) ユースケース (何のために、何ができればいいか) フロー (どうやって)—— 基本フロー (目的に至る最も単純な道筋)と、そこから枝分かれする 代替フロー (別の道筋、例外、失敗時の扱い)の束           図1:ユースケース図 。  図1は、アクターとユースケースの関係を表すユースケース図です。誰が何のためにシステムを使うのか、全体を俯瞰できます。  一つひとつのユースケースの中では、利用者が実際にシステムを操作します。その大まかな手順がフローです。  基本フローと代替フローの関係は、後述のサンプル(図2)で具体的に見ます。  このように、ユースケースには、全体を俯瞰する段(アクターとユースケース)と、個々の中身を見る段(フロー)が、道具として組み込まれています。これが「構造があらかじめ定義されている」の意味です。  ここまでを足がかりに、実際の例をみて理解を深めましょう。 請求業務で組んでみる——4つの手順と、そこで使う概念 #  ここからは、請求業務を例に、ユースケースで更改案件を組む手順を4つに分けてなぞり、各手順で使う概念をその場で定義していきます。例はあくまで一例で、手順と概念は業務を選びません。エピック/フィーチャー/ストーリーで組みたい方は、ユースケースを、お使いの手法でいちばん近い段(エピックかフィーチャー)に、スライスをストーリーの束に読み替えてください。 (1)一覧を作る——画面ではなく、目的で数える  材料は、既存の仕様書や存在するシステムから得た現行の画面一覧・帳票一覧・インタフェース一覧です。それぞれを「誰の、どの目的のためにあるか」という観点で整理してみましょう。  請求業務なら、次のような表になります。 ユースケース(アクター+目的) 対応する現行の画面・帳票・IF 経理担当が、請求書を発行する 請求書発行画面、請求一覧画面、請求書PDF、月次一括発行バッチ、請求取消画面 営業担当が、担当顧客の請求状況を確認する 請求一覧画面 経理担当が、入金を請求と突き合わせる 入金照合画面、銀行入金IF 経理責任者が、月次の請求を締める 月次締め処理画面 経理担当が、仕訳を会計システムへ連携する 会計連携IF(夜間バッチ)  表を見ると、2つのことに気づきます。請求取消画面には、独立した目的がありません。「請求書を発行する」という大きな目的の中での処理の一つであり、ユースケースでは代替フロー(発行済みを取り消す)として組み入れられます。逆に請求一覧画面は、経理担当と営業担当が別の目的で使うので、2本のユースケースにまたがります。手動起動のバッチのように、現物から見えにくいものはヒアリングで補います。  画面ではなく目的で数えるのは、 業務が回るかどうかは、目的の集合が業務をカバーするかどうかで決まる からです。情報は現行の画面単位で集めますが、やりたいことは画面を再現することではありません。新しいシステムでは画面を一からデザインし直すことも珍しくなく、画面の数や並びは変わりえます。変わらないのは、誰が何のためにそれを使うか——目的のほうです。  こうして採った5本を、アクターとの関係で1枚に描いたものがユースケース図(前出の図1)です。誰が、何のためにこのシステムを使うのかを俯瞰でき、この時点で抽象度の高い全体像がつかめます。業務領域ごとに同じ作業をすれば、案件全体の一覧になります。この一覧が全体像の骨組みです。見積の根拠にするには、次の(2)(3)までを、移行対象のユースケースすべてについて済ませておきます。 (2)ユースケースごとに、1枚のアウトラインを書く——今把握できている範囲を明示する  次に、移行対象のユースケースごとに1枚ずつ、アウトラインを書きます。基本フローを箇条書きにし、そこから枝分かれする主な代替フローを、いま分かっている分だけ並べます。書くのは箇条書きまでで、画面項目や処理の詳細には踏み込みません。「請求書を発行する」なら、こうなります。 ユースケース: 請求書を発行する アクター: 経理担当 目的: 締め済みの取引に対して請求書を発行し、送付する 基本フロー: 1. 締め済み取引を一覧表示する → 2. 請求先を選ぶ → 3. 請求内容を確認する → 4. 請求書を発行する → 5. 送付する 代替フロー: A1 複数取引を合算して1枚にする/A2 月初に一括発行する/A3 発行済みを取り消す/A4 請求先の与信が止まっている/A5 送付先が未登録     図2:ユースケース記述(ユーザによる操作フローとバリエーション)  この1枚が、その目的のためにシステムがどう使われるかの 全体 です。A1〜A5は、いま分かっている代替フローです。この先に未発見の道筋があること、その位置が「A5の次」だと言えることが、この1枚の値打ちです。ユースケースなら、 分かっている範囲と分かっていない範囲を同じ1枚の上で言える 。全体感とは、全部を知っていることではなく、知らない部分の位置が分かっていることです。Use-Case 3.0も、ユースケースの大きさと複雑さを把握するには、この程度のアウトラインが必要だとしています。範囲外と判断したユースケースは、一覧に名前を残すだけで構いません。 (3)最小集合を決める——業務が止まらない線を引く  基本フローが通れば、業務は止まりません。ただし代替フローの中にも、削ると業務が止まるものがあります。「請求書を発行する」なら、A2(月初に一括発行する)は、月初の請求業務が手作業では回らないなら必須です。A4(与信停止)も、与信管理が法令や社内規程で定められていれば落とせません。経理部門と話して、たとえば「基本フロー+A2+A4」と決めます。  この「基本フロー+必須の代替フロー」が、各ユースケースの 最小集合 で、これが受入条件になります。残りのA1・A3・A5は、後で見る「深さ」で調整する側に回ります。最小集合を業務部門と定義しておかないと、「業務が回ると言ったのに回らない」問題が起きます。最小集合も、移行対象のユースケースごとに、見積の前に決めておきます。 (4)スライスを切る——スプリントで仕上げる単位  ここからは、あるスプリントで開発に着手するユースケースだけを対象とする作業です。開発の単位は、この1枚から切り出す スライス ——ユースケースの始点から終点までを通る道筋を1本以上、テストケースごと切り出したもの——で、1スプリントで検証まで終えられる大きさに切ります。「請求書を発行する」なら、最初のスライスは「通常の請求を1件、基本フローで発行する」——テストケースは、締め済み取引1件から請求書PDFが出るまで。次に「A2 月初に一括発行する」、その次に「A4・A5 発行できない場合の扱い」をまとめて1つ、という具合に、最小集合から順に切り出します。A1とA3は未着手のまま1枚の上に残しておき、深さを上げる判断が出たときに切ればよいのです。  チームがストーリーを使っているなら、スライスを数枚のストーリーに割ってスプリントに載せます(最初のスライスなら「一覧を表示する」「請求先を選ぶ」…)。スライスは、そのスプリントのゴールとして働きます。              図3: 粒度の3段——何を「1件」と数えるか  4つの手順を通すと、粒度は3段になります。 業務領域 : 「請求業務」「在庫管理」のような括り ユースケース (発注側の「機能」に相当): アクターと目的の組。一覧に載せ、全体像として合意し、見積る単位 スライス (スプリントゴールに相当): バックログに載る単位。チームがストーリーを使うなら、スライスを数枚のストーリーに割って実装する              図4:  第3回で保留した「何を1件と数えるか」の答えが、これです。全件が把握できないのはスライスの粒度の話で、ユースケース(フローを含む)の粒度なら全件は現物から取れます。またこれは、画面を起点として会話を進めることで、発注側がこれまで慣れてきた粒度で全体像を議論できるようになります。そして、「その時点で把握できないもの」は、スライス以下で扱われます。「アジャイルは全件を確定しない」は、更改系に限って言えば「スライスの粒度では確定しない」です。一覧と最小集合なら、現物が有限なので、確定として扱われても——重い手続きの側に置いても——守れます。深さとスライスはその内側で、手続きなしで動かせる側に残し、内訳として約束の文言にも入れません(第3回で引いた一線です)。  ユースケースの一覧は、いわば地図として働きます。業務影響の大きいものから現物を動かして検証し、見つかった道筋を代替フローとして足していくことで、地図ができあがっていくのです。 処方——全体像を、見積と合意の道具にする # 何を約束し、何を調整するか  粒度を分けると、約束と調整を別々の段に割り当てられます。契約での「全件」の約束は、ユースケース粒度で結びます——一覧に全件を載せ、移行すると判断したユースケースは最小集合まで必ず仕上げる。調整弁は本数ではなく、各ユースケースの検証の 深さ ——最小集合まで(L1)か、主な代替フローまで(L2)か、採取済みの全フローまで(L3)か——です。スライスの入替や優先順位づけは、プロダクトオーナー(PO)と開発チームが日常的に行い、承認手続きの対象にはしない(ただし記録は残す)。ユースケース単位の取捨(利用実績のないものは「移行しない」と判断して外す、など)は、確定したものの変更として、発注側が明示的に判断します。誰がどの粒度で決めるかを最初に決めておくと、「勝手に変えた」も「全部やると言ったはずだ」も起きにくくなります。検証済みリストを「ユースケース×深さ」の表にしておくと、進捗と残リスクが発注側にもそのまま読めます。              図5:  深さを浅くしたとき、つまり最小集合の外にある代替フローを削ったときに変わるのは、その業務を「どれだけ楽に、どれだけ広い状況で」回せるかです。削った分は業務手順(手作業)で担うことになり、負担は業務部門側に移るので、代替手順の合意は削る判断とセットで行います。第3回の「仕様は3つある」に戻れば、代替フローを削るとは、意識的に「人がやっていること」を作ることです。いま人が手作業で担っていることの多くは、前回の更改で誰かが暗黙に削った代替フローの痕跡で、今回の違いは、判断の記録を残して削ることです。 稟議に載せる見積——本数×規模  稟議の時点でユースケースに書くのは、手順(1)〜(3)で作ったもの——アクターと目的の一文、基本フローと主な代替フローの箇条書き、最小集合——に、主な入出力と連携先を添える程度で十分です。画面項目定義や処理ロジック、例外の全列挙といった、ウォーターフォールなら基本設計で固める中身は、代替フローの発見と検証に委ねます。  量は、 本数×規模 で出します。本数は一覧の件数で、「ユースケース×画面」の対応表を添えれば画面一覧にも読み替えられます。規模は各ユースケースの相対サイズで、画面の項目数ではなく、代替フローが何本ぶら下がっているかという塊の厚み——「請求書を発行する」は厚く、「担当者マスタを保守する」は薄い——をポイントにします(S=1、M=3、L=8など)。これに 深さの初期設定 (L1〜L3)が重なり、L1にとどめるものが多ければ総量は小さく、L3まで上げるものが多ければ大きくなります。優先度と深さの初期値の根拠には、 現行の利用実績 (利用件数・利用部署数)を使います。  見た目は「機能一覧×規模」というウォーターフォールの見積と似た形式なので、稟議の書式にそのまま載ります。ただし、ここまでは量であって、工数でも金額でもありません。量を期間と金額に換えるのはチームの実測で、その段取りは次回にまとめます。 決めておくべき問い  最小集合をどこまで細かく書くか。深さの変更手続きをどうするか。検証しても残ったリスクを誰が引き受けるか。検収の根拠に、紙に代わる何を置くか。ここから先は案件と組織で答えが違うので、本稿では決めません。大事なのは、これらの問いを同じ机で話し始めることです。稟議に載る見積を用意するのも、承認の要らない調整の範囲を先に決めておくのも、教科書どおりのスクラムではありません。いまの会社のプロセスの上で回る形に、仕立て直しています。 注意——ユースケースが「紙」に戻る瞬間  警戒すべきは、「アウトラインを超えて、ユースケース記述を全ユースケースぶん、着手前に書き込む」という誘惑です。それをやると、第3回で見た、紙だけが全件の代わりに席に置かれる構造に戻ります。紙になるか発見の道具になるかは、書式ではなく、いつ・どこまで書くかの判断の問題です。 なぜこの質問は30年繰り返されるのか #  開発標準も、契約テンプレートも、アジャイルの教科書も、「機能」を一語で語ってきました。粒度のズレが言葉に隠れている限り、どちらの側も相手が約束を破ったように感じ、その感覚が世代を超えて引き継がれます。  本稿の3段の粒度は「解」ではなく、止まっていた対話を再開するためのきっかけとなる道具です。「全件は確定できない」で終わっていた話を、「どの粒度で線を引くか」という具体的な問いに変える。「アジャイルならストーリー」も「うちは機能分解だからアジャイルは無理」も、道具と運び方を混同した思考停止です。標準の言葉ではなく、実プロジェクトの粒度で話す。それだけで、30年止まっていた会話の形は変えられるのではないでしょうか。  残るのは、スライス粒度の未知をどう発見して検証済みに変えていくか、その発見を受け止める予算をどう組み、どう配分するかです。次回、別の質問として扱います。  筆者もこれまで各種の媒体で記事を書いてきましたが、この3連作ほど、「自分はいま地雷の上に両足で乗っている」と実感したことはありません。次回は、この地雷の上で「何を仕様にして検証するのか」まで踏み込み、3本を着地させましょう。 次回: 「ドキュメントが信用できない現行システム、何を『仕様』にして検証する?」
 この記事を読んでいただきありがとうございます。アジャイルグループに所属する、藤井智弘です。  判断の積み重ねは、いつしか慣習になります。そして一度慣習になると、誰も疑問を持たなくなります。前例踏襲という「思考停止」の完成です。今回からの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の考え方では)。その重さが「変えずに済ませよう」という動機につながり、結果として確定しているように見えるのです。  だとすれば、本当の問いは「全件を確定できるか、できないか」ではなく、「どこまでを重い手続きに載せ、どこからを手続きなしで変えられる範囲として残すか」です。では、その線をどの粒度で引けば、発注側と「全体像」を合意できるのか。次回、「機能」という言葉の粒度に踏み込みます。 次回: 「更改案件の見積を、全機能を確定せずにどう出す?」
はじめに # 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)のものであり、その正確性や安全性を保証するものではありません。 パッチの適用やビルドの実行により生じた直接的・間接的な損害について、筆者および株式会社豆蔵は一切の責任を負いません。内容を十分にご確認の上、ご自身の責任においてご利用ください。
はじめに # オープンソースLLMが急速に進化する中、「機密情報や社内コードも気にせず扱える、自分専用のローカルLLM環境が欲しいな」と思い立ちました。 ただ、いざ本格的に手元(オンプレミス)で動かそうとすると、大容量VRAMを積んだGPU搭載PCが必要になり、 調達するのに数十万円もの初期費用(イニシャルコスト) がかかってしまいます。「まずは効果があるか試してみたい」という検証段階で、いきなり高額なハードウェア購入稟議を通すのはハードルが高いものです。 だからこそ、初期投資ゼロ・使った時間分だけ(数十円〜数百円単位)ですぐに始められ、不要になればいつでも撤退できる 「スモールスタート」というクラウド最大の利点 を活かしたいと考えました。 「クラウド上で手軽にLLMを動かすにはどうすればいいか……」と思いを巡らせていたとき、マネジメントコンソールから手動でGPUインスタンスを立ち上げてセットアップする方法も考えました。 しかし、インフラエンジニアとして実用的な開発環境を模索する中で、どうしても大事にしたい想いが湧いてきました。 「これから登場する様々なオープンソースモデルを、気兼ねなくどんどん試せる環境にしたい!」 次々と新しいモデルがリリースされる昨今、モデルを入れ替えたり検証したりするたびに手動でセットアップを繰り返すのは大変ですし、使っていない期間に大容量ストレージ(EBS)の維持費(月額1,500円強)が発生し続けるのも避けたいところです。 また、「使い終わったら手動で停止・削除しよう」と思っていても、うっかり停止を忘れて思わぬ課金が発生してしまうリスクも防ぎたいところです。 そこで今回は、これから様々なモデルを快適に試していくための土台として、 「起動の全自動化(UserData)」 と 「使わない時間の固定費ほぼゼロ化(S3モデル保管 & EBS即時破棄)」 を両立したエフェメラル(使い捨て)推論基盤を構築しました。 今回構築したアーキテクチャのゴール # Linux (Ubuntu 22.04 Deep Learning AMI) × UserData による完全無人セットアップ モデルデータはS3、コンテナイメージはECRに退避させ、起動時に同一リージョン間(転送料無料)で高速同期・Pull 高スループット推論エンジン「vLLM」をDockerコンテナで自動起動 検証後はインスタンスごと即座に「終了(Terminate)」してEBSを破棄! 停止時の保持コストほぼゼロ運用 この構成を作っておけば、モデルの差し替えもS3のパスを変えるだけで済み、いつでもコマンド一発で最新のオープンソースLLMを安全・最安で試し放題になります。 クラウド認定全冠エンジニアの視点から、コストと自動化を追求した構築手順を詳しくご紹介します! 全体アーキテクチャとエフェメラル設計 # 今回の全体構成は以下のようになっています。 flowchart TD subgraph ClientEnv ["クライアント環境 / 開発PC"] Client["開発者端末<br>(curl / API呼び出し)"] LaunchScript["起動スクリプト<br>(03_ec2_launch.sh)"] end subgraph AWS ["AWS 東京リージョン (ap-northeast-1)"] subgraph EC2Env ["EC2: g6.xlarge (使い捨て)"] UserData["UserData スクリプト<br>(起動時に完全自動実行)"] vLLM["Docker: vLLM OpenAI Server<br>(Port 8000)"] UserData -->|起動時同期| LocalStorage[("/data/models<br>(無料NVMe SSD 250GB: 超爆速I/O)")] LocalStorage --> vLLM end S3[("Amazon S3<br>s3://my-llm-models-tokyo<br>(モデル永続保管)")] ECR[("Amazon ECR<br>vllm-openai:latest<br>(コンテナ保管)")] S3 -->|同一リージョン間 高速同期<br>【データ転送料: 完全無料】| LocalStorage ECR -->|同一リージョン間 高速Pull<br>【データ転送料: 完全無料】| vLLM end LaunchScript -->|1. EC2起動 & IP取得| EC2Env Client -->|"2. OpenAI互換API呼び出し<br>(/v1/chat/completions)"| vLLM この構成のポイント # 完全エフェメラル(使い捨て)なEC2運用 : 重たいモデル本体(約15GB)はAmazon S3に、vLLMコンテナイメージはAmazon ECRに永続化しておきます。EC2起動時にUserData経由でS3・ECRからローカルにサッと引き出し、検証が終わったらEC2を躊躇なく  Terminate(終了)  します。ルートボリュームも自動破棄されるため、高価なEBSの放置課金を完全に排除できます。 無料付属のローカルNVMe SSD(インスタンスストア 250GB)をフル活用 : g6.xlarge には追加費用 0 円(無料)で 250GB のローカル NVMe SSD(インスタンスストア) が最初から付属しています。一般的にインスタンスストアは「停止や終了でデータが揮発(消滅)する」ため敬遠されがちですが、本構成は 「重みはS3、コンテナはECRに永続化し、EC2は使い捨てる」 という設計のため、揮発性が一切デメリットになりません。PCIe 直結の爆速 I/O(1,000〜2,000MB/s超)をフル活用してモデルのGPUロード時間を十数秒レベルに短縮しつつ、ルートEBS容量を 100GB → \rightarrow → 40GB (※DLAMIのスナップショット制約上の最小サイズ)へ大幅スリム化できます。 停止時の維持コストは「ほぼゼロ(月額約145円)」&ダウンロード転送量は完全無料 : 「S3やECRに置くとしても、保管料や転送料金がかさむのでは?」と心配になるかもしれませんが、実質的なコストはほぼ無視できるレベルです(※1ドル=165円換算): S3 Standard保管料 : 15GB × $0.025/GB = 月額 約$0.38(約 62 円 / 月) ECR保管料 : 圧縮コンテナイメージ 約5GB × $0.10/GB = 月額 約$0.50(約 83 円 / 月) 合計保管コスト : 月額 約$0.88(約 145 円 / 月) データ転送料(S3 / ECR → \rightarrow → EC2) : 同一リージョン(東京)間転送のため 完全無料(0 円) EBS保持との比較 : もし同じ100GBのEBSボリューム(gp3)を停止状態で保持し続けると 月額 約$9.6(約 1,584 円 / 月) が溶け続けます。S3とECRに逃がすことで 約91%のコスト削減 になり、使わない月の維持費もわずか約145円に抑えられます。 Docker Hub ではなく Amazon ECR を使う理由 : vLLM の公式イメージは Docker Hub( vllm/vllm-openai:latest )でも配布されていますが、インターネット経由でのダウンロードとなるため、回線混雑や Docker Hub の匿名レートリミット(Pull 回数制限)に遭遇するリスクがあります。同一リージョンの ECR に配置することで、AWS 内部の超高速バックボーン経由で安定して爆速 Pull が可能になります。 vLLM の採用 : PagedAttention技術により、高スループットかつ省メモリで大規模モデルを扱える推論エンジン「vLLM」をDockerで起動します。標準でOpenAI互換API(ポート8000)を公開してくれるため、既存の様々なAIツールやスクリプトからそのまま利用可能です。 事前準備・前提条件 # 本手順を進めるにあたり、ローカル端末側に以下のツールがセットアップされていることを前提とします。 AWS CLI : S3バケットの作成やEC2の起動に使用します。適切な権限(S3・EC2へのアクセス権)を持つIAMユーザー/ロールで aws configure を済ませておいてください。 Hugging Face CLI ( huggingface-cli ) : モデルのダウンロードに使用します。Python環境で以下を実行してインストールします。 pip install -U "huggingface_hub[cli]" ※なお、Llama系やGemma系などの利用規約への同意が必要なゲーテッドモデル、あるいはレート制限を回避して安定ダウンロードしたい場合は、事前に huggingface-cli login を実行して Hugging Face のアクセストークンを設定しておきましょう。 jq : スクリプト内でJSON解析を行うため、インストール( sudo apt install jq や brew install jq 等)しておくと便利です。 【最重要】AWS Service Quotas(GPUインスタンスの割り当て上限緩和) : AWSの初期アカウントや多くの環境では、GPUインスタンス(G系・P系)の起動可能数(vCPU数)が デフォルトで「0」 に設定されています。この制限を解除しないと、EC2起動時に VcpuLimitExceeded エラーが発生して起動に失敗します。 対象クォータ名 : Running On-Demand G and VT instances (オンデマンド G および VT インスタンスの実行) 申請場所 : AWSマネジメントコンソール → 「Service Quotas」 → 「Amazon Elastic Compute Cloud (Amazon EC2)」 必要vCPU数 : g6.xlarge は 1台あたり 4 vCPU を消費します。 ⚠️ リージョンごとに申請が必要 : クォータはリージョン単位で独立して管理されています。東京リージョンで制限解除してもオレゴンやバージニアには反映されませんので、必ずインスタンスを立ち上げるリージョン(今回は東京: ap-northeast-1 )を選択した状態で申請してください。 💡 利用実績による割り当て制限 : アカウントにGPUインスタンスの利用実績がまだない場合、申請時に「8」や「16」などを希望しても、まずは 「4」のみが承認・割り当てられる のが通例です(1台動かす分には4で十分です)。 ⏱️ 解除通知までの所要時間 : AWS公式には「数日かかる場合がある」と案内されていますが、 筆者の実際の検証環境では、申請からおよそ3〜4時間ほどで解除完了(承認)の通知メールが届きました 。とはいえ即時反映ではないため、検証を思い立ったら真っ先に申請を出しておくことを強くおすすめします! 環境構築ステップ # それでは、実際に環境を作っていきましょう。構築に必要な資材はすべてスクリプト化してあります。 共通設定パラメータ(環境変数一覧) # 本手順で利用するスクリプト群( 01_sync_assets.sh , 02_ec2_userdata.sh , 03_ec2_launch.sh , 04_ec2_terminate.sh )は、環境変数を通じて柔軟に挙動をカスタマイズできるように設計されています。 必要に応じて、事前にターミナルで export して指定するか、スクリプト先頭のデフォルト値を編集してください。 環境変数名 デフォルト値 必須/任意 説明・設定例 AWS_REGION ap-northeast-1 任意 デプロイ先のAWSリージョン(東京: ap-northeast-1 , オレゴン: us-west-2 など) S3_BUCKET_NAME my-llm-models-tokyo 要変更 モデル重みを保管するS3バケット名(※全世界で一意の名前を指定してください) HF_MODEL_ID Qwen/Qwen2.5-Coder-7B-Instruct 任意 Hugging Face上のモデル識別子(試したいオープンソースモデルを指定可能) SERVED_MODEL_NAME Qwen/Qwen2.5-Coder-7B-Instruct 任意 OpenAI互換APIで外部(クライアント)に公開・要求するモデル名 DOCKER_IMAGE vllm/vllm-openai:latest 任意 使用するvLLMイメージ(ECRのURI、またはDocker Hubのイメージ名) INSTANCE_TYPE g6.xlarge 任意 GPUインスタンスタイプ( g6.xlarge : NVIDIA L4 24GB VRAM + 250GB NVMe付属) EBS_SIZE_GB 40 任意 ルートEBSサイズ(GB)。モデルやDockerはNVMeに置くため、DLAMI最小スナップショットサイズの40GBで十分 IAM_ROLE_NAME EC2-S3-ECR-ReadOnly-Profile 任意 EC2にアタッチするIAMプロファイル名(未作成時はスクリプトが自動生成) SECURITY_GROUP_IDS (未指定時は自動作成) 任意 適用するセキュリティグループID(空の場合はポート8000/22を開放したSGを自動生成) KEY_NAME (未指定) 任意 SSH接続用のキーペア名(SSHトンネルやOS内デバッグを行う場合に指定) MAX_MODEL_LEN 4096 任意 vLLMの最大コンテキストトークン長(長文を扱う場合は 8192 等に調整) GPU_MEMORY_UTILIZATION 0.85 任意 vLLMが事前確保するGPUメモリ(VRAM)の割合(0.85〜0.90を推奨) --> Information 💡 最短で始める場合の設定例 S3バケット名だけご自身の一意な名前に設定すれば、その他のパラメータはすべてデフォルト値のまますぐに起動可能です。 export AWS_REGION="ap-northeast-1" # アカウントIDを付与して一意なバケット名にする例 export S3_BUCKET_NAME="my-llm-models-$(aws sts get-caller-identity --query Account --output text)-tokyo" Step 1: S3へのモデル配置 & ECRへのコンテナイメージ登録 # まずはアセットの母艦となる S3 バケットと Amazon ECR リポジトリを東京リージョンに用意し、モデル重みとコンテナイメージを登録しておきます。今回はコーディング特化型として高い実績と定評を誇る定番オープンソースモデル Qwen/Qwen2.5-Coder-7B-Instruct を例に進めます(※好みのモデルを選びたい方は Hugging Face Models一覧 から探してみてください)。 --> Information 💡 本構成(g6.xlarge / 24GB VRAM)で動かせるモデルの選定基準 「Qwen以外のオープンソースモデルを試したい場合、どう選べばいいか?」の目安をまとめました。 パラメータ規模の目安(VRAM 24GB の制約) : 7B〜9B クラス(bfloat16 / fp16) : 最もおすすめ(スイートスポット) 。重み(約14〜18GB)を展開してもKVキャッシュに約4〜8GB残り、4K〜8Kトークンを余裕で処理できます(例: Qwen/Qwen2.5-Coder-7B-Instruct , meta-llama/Llama-3.1-8B-Instruct , google/gemma-2-9b-it , google/gemma-4-E4B-it )。 14B クラス : 無量子化(bfloat16: 約28GB)では24GBに収まりませんが、 AWQ / GPTQ / FP8 などの量子化版(約8〜10GB) を選べば24GB VRAMで快適に動作します(例: Qwen/Qwen2.5-14B-Instruct-AWQ )。 32B クラス : AWQ(4bit: 約17GB)で動作可能ですが、KVキャッシュの残余が少なくなるため長文処理にはコンテキスト制限が必要です。 アーキテクチャ(GQAモデルを推奨) : GQA(Grouped Query Attention) を採用したモデル(Qwen 2.5系、Llama 3.1系、Gemma 2系など)はKVキャッシュのメモリ消費が極めて小さいため、長文や高スループット推論に最適です。初代Gemma 7BのようなMHA(Multi-Head Attention)モデルはKVキャッシュを大量消費するためコンテキスト長を控えめにする必要があります。 モデル種別(必ず「Instruct / Chat」を選ぶ) : チャットAPIやClaude Code等のエージェントから呼び出すには、指示追従・対話用チューニングが施された -Instruct 、 -it 、 -Chat が付いたモデルを指定してください(無印のベースモデルは文章の続きを補完するだけになるため対話できません)。 Hugging Face Gatedモデルの注意点 : Llama系やGemma系など利用規約への同意が必要なモデルは、事前にHugging Face上で承認(Acknowledge license)を行い、初回S3同期時に HF_TOKEN が必要になります(S3格納後はEC2側でのトークン管理は不要です)。 以下のスクリプト( 01_sync_assets.sh )を実行します。 #!/bin/bash set -euo pipefail AWS_REGION="ap-northeast-1" AWS_ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text) S3_BUCKET_NAME="my-llm-models-tokyo" MODEL_ID="Qwen/Qwen2.5-Coder-7B-Instruct" LOCAL_MODEL_DIR="/tmp/models/${MODEL_ID}" ECR_REPO_NAME="vllm-openai" ECR_IMAGE="${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com/${ECR_REPO_NAME}:latest" # 1. 東京リージョンにS3バケットを作成 if ! aws s3api head-bucket --bucket "${S3_BUCKET_NAME}" 2>/dev/null; then aws s3api create-bucket \ --bucket "${S3_BUCKET_NAME}" \ --region "${AWS_REGION}" \ --create-bucket-configuration LocationConstraint="${AWS_REGION}" fi # 2. Hugging FaceからモデルをダウンロードしてS3へ同期 mkdir -p "${LOCAL_MODEL_DIR}" huggingface-cli download "${MODEL_ID}" --local-dir "${LOCAL_MODEL_DIR}" --local-dir-use-symlinks False aws s3 sync "${LOCAL_MODEL_DIR}" "s3://${S3_BUCKET_NAME}/models/${MODEL_ID}" \ --region "${AWS_REGION}" \ --no-progress # 3. Amazon ECR リポジトリの作成とコンテナイメージの登録 if ! aws ecr describe-repositories --repository-names "${ECR_REPO_NAME}" --region "${AWS_REGION}" 2>/dev/null; then aws ecr create-repository \ --repository-name "${ECR_REPO_NAME}" \ --region "${AWS_REGION}" \ --image-scanning-configuration scanOnPush=true fi # ECRへのDockerログイン aws ecr get-login-password --region "${AWS_REGION}" | \ docker login --username AWS --password-stdin "${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com" # vLLM公式イメージをPullしてECRへPush docker pull vllm/vllm-openai:latest docker tag vllm/vllm-openai:latest "${ECR_IMAGE}" docker push "${ECR_IMAGE}" この事前準備は初回に1度だけ行えばOKです。モデル重みとコンテナイメージをAWS内に揃えておくことで、以降はEC2を何回作り直しても、同一リージョン内のS3とECRから爆速で引き出すことができます。 Step 2: UserDataによる自動セットアップスクリプト # 推論用EC2インスタンスの起動時に自動実行させるシェルスクリプト( 02_ec2_userdata.sh )です。 インスタンス起動の基本仕様 AMI : Deep Learning OSS Nvidia Driver AMI GPU PyTorch 2.x (Ubuntu 22.04) ※AWS公式のDeep Learning AMIを使用することで、NVIDIAドライバやDocker、NVIDIA Container Toolkitがセットアップ済みの状態でスタートできます。 インスタンスタイプ : g6.xlarge (NVIDIA L4 GPU / 24GB VRAM) ※元記事で検証されていた手軽な g4dn.xlarge (T4 / 16GB)も素晴らしい選択肢ですが、今回は長文コンテキストを扱うコーディングエージェント用途を見据え、大容量24GB VRAMを備えた現行世代の g6.xlarge を採用します。後述の通り、長文コンテキストを扱う用途では24GBのVRAMが大きなアドバンテージになります。 IAMロール : S3バケットに対する読み取り権限( s3:GetObject , s3:ListBucket )および Amazon ECR に対する読み取り権限( AmazonEC2ContainerRegistryReadOnly ) を付与したインスタンスプロファイルをアタッチします。 ストレージ (EBS & NVMe SSD) : ルートボリューム (EBS) : 40GB (gp3) 。「終了時に削除 (Delete on Termination)」を True に設定。使用するDeep Learning AMIのルートスナップショットサイズが40GBのため、これが指定可能な最小サイズとなります。モデルやDocker本体はNVMeに逃がすため、OSと基本ツールのみを配置する最小限の40GBで十分です。 モデル & Docker/containerd配置先 (ローカル NVMe SSD) : g6.xlarge に最初から 無料(追加料金0円)で付属する 250GB のローカル NVMe SSD(インスタンスストア) を活用。モデル重み(約15GB)だけでなく、Docker および containerd の保存領域( /var/lib/docker および /var/lib/containerd : 約15GB超)も NVMe 上にバインドマウントすることで、40GB EBS の枯渇( write ...: no space left on device )を完全に防ぎつつコンテナの Pull & レイヤー展開を爆速化します。 シャットダウン時の動作 : --instance-initiated-shutdown-behavior terminate を指定。OS内部からシャットダウンが実行された際に、単なる停止(Stop)ではなく 自動的にインスタンスを「終了(Terminate)」してEBSごと全破棄 するように設定します。 UserData の中身 ( 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.85" MAX_MODEL_LEN="4096" 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 # 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 全処理完了 (自動アイドル監視稼働中) ===" --> Information 💡 インフラ極限チューニング:なぜEBSを100GB盛るより「無料のNVMe」を使うべきなのか? AWS の GPU インスタンス( g6.xlarge や g4dn.xlarge など)のスペック表をよく見ると、ストレージ欄に 「1 x 250 NVMe SSD」 と記載されています。これはインスタンスに物理直結されたローカル SSD(インスタンスストア)で、 インスタンス料金に含まれており追加課金は一切かかりません(0円) 。 比較項目 EBS (gp3 40〜100GB) ローカル NVMe SSD (250GB) 追加費用 従量課金対象 完全無料(0 円 / インスタンス付属) 読み書き帯域 ネットワーク経由(基本 125 MB/s ) 1,000 〜 2,000 MB/s 超(PCIe直結物理SSD) 15GBモデルのGPU読込時間 約 15 分 (ディスクI/O競合で長時間の待機) 1 分未満(圧倒的爆速!) データの永続性 保持可能 インスタンス停止・終了で消滅(揮発性) 実機検証でも、 EBS上にモデルを置いていたときはGPUへの重みロードだけで約15分も待たされていたのが、ローカルNVMe SSDに逃がしたことで「1分未満」へと劇的に短縮 されました! 通常のシステム構築では「サーバーを停止するとデータが消えてしまう」という揮発性が弱点になりますが、 「アセットは S3/ECR に永続化し、EC2 は使い捨てる」 というエフェメラル設計を採ることで、 「揮発性のデメリットはゼロ、NVMe の圧倒的 I/O 速度と EBS 削減のメリットだけを総取り」 できるようになります! さらに嬉しいことに、今回使用している AWS 公式の Deep Learning AMI (Ubuntu 22.04) は、起動時にローカル NVMe を自動検出して /opt/dlami/nvme に自動マウント してくれる親切設計になっています。 手動でパーティション作成をする必要すらなく、モデル重みだけでなく Docker/containerd の保存領域もこの NVMe に向ける ことで、大容量 Docker イメージによる EBS 圧迫を完全回避し、コンテナのレイヤー展開速度も劇的に高速化されます。 --> Information 💡 寝落ち・停止忘れ対策!「1時間アイドルで自動自爆(Terminate)」する安全装置 「検証に夢中になって作業していたら、深夜になってそのままベッドで寝落ちしてしまった……」 「使い終わったのに破棄コマンドを叩くのを忘れて丸一日放置してしまった……」 GPUインスタンスを触るエンジニアなら誰もが一度は経験する恐怖のシナリオです。オンデマンドのGPUインスタンスは1時間あたり約200円、一晩(8時間)放置するだけで約1,600円が吹き飛びます。 そこで本構成では、UserDataの末尾で 「直近1時間(3600秒)推論リクエストが来なかったら自動でOSをシャットダウン( shutdown -h now )する監視デーモン」 をバックグラウンド起動しています。 さらに、EC2起動オプションに --instance-initiated-shutdown-behavior terminate を付与しているため、OSシャットダウンと同時に EC2インスタンスが自動的にTerminate(完全終了)され、EBSボリュームも根こそぎ道連れ破棄 されます! 高価なCloudWatchアラームやLambdaを外側に組まなくても、インスタンス単体の自己防衛ロジック(自爆装置)として月額追加コストゼロで完全なセーフティネットが機能します。 Step 3: ワンコマンド起動スクリプト ( 03_ec2_launch.sh ) # マネジメントコンソールで毎回ポチポチ起動するのも大変なので、CLIから1発で呼び出せるスクリプト( 03_ec2_launch.sh )を用意しました。 #!/bin/bash set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" USER_DATA_FILE="${SCRIPT_DIR}/02_ec2_userdata.sh" INSTANCE_STATE_FILE="${SCRIPT_DIR}/../.current_instance_id" AWS_REGION="${AWS_REGION:-ap-northeast-1}" INSTANCE_TYPE="${INSTANCE_TYPE:-g6.xlarge}" # NVIDIA L4 GPU (24GB VRAM) IAM_ROLE_NAME="${IAM_ROLE_NAME:-EC2-S3-ECR-ReadOnly-Profile}" EBS_SIZE_GB="${EBS_SIZE_GB:-40}" # DLAMIスナップショット制約(40GB以上)の最小値。モデルやDockerはNVMeに配置するため40GBで十分 # 1. 最新の Deep Learning OSS Nvidia Driver AMI を自動検索 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) # 2. セキュリティグループの自動確認・作成 (ポート8000, 22) DEFAULT_VPC=$(aws ec2 describe-vpcs --region "${AWS_REGION}" --filters "Name=isDefault,Values=true" --query "Vpcs[0].VpcId" --output text) EXISTING_SG=$(aws ec2 describe-security-groups --region "${AWS_REGION}" --filters "Name=vpc-id,Values=${DEFAULT_VPC}" "Name=group-name,Values=vllm-sg" --query "SecurityGroups[0].GroupId" --output text 2>/dev/null || echo "") if [ -n "${EXISTING_SG}" ] && [ "${EXISTING_SG}" != "None" ]; then SG_ID="${EXISTING_SG}" else SG_ID=$(aws ec2 create-security-group --region "${AWS_REGION}" --group-name "vllm-sg" --description "SG for vLLM API" --vpc-id "${DEFAULT_VPC}" --query "GroupId" --output text) aws ec2 authorize-security-group-ingress --region "${AWS_REGION}" --group-id "${SG_ID}" --protocol tcp --port 8000 --cidr "0.0.0.0/0" aws ec2 authorize-security-group-ingress --region "${AWS_REGION}" --group-id "${SG_ID}" --protocol tcp --port 22 --cidr "0.0.0.0/0" fi # 3. IAM インスタンスプロファイルの作成 (S3 & ECR 読み取り権限) if ! aws iam get-instance-profile --instance-profile-name "${IAM_ROLE_NAME}" >/dev/null 2>&1; then aws iam create-role --role-name "${IAM_ROLE_NAME}-Role" \ --assume-role-policy-document '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"ec2.amazonaws.com"},"Action":"sts:AssumeRole"}]}' aws iam attach-role-policy --role-name "${IAM_ROLE_NAME}-Role" --policy-arn arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess aws iam attach-role-policy --role-name "${IAM_ROLE_NAME}-Role" --policy-arn arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly aws iam create-instance-profile --instance-profile-name "${IAM_ROLE_NAME}" aws iam add-role-to-instance-profile --instance-profile-name "${IAM_ROLE_NAME}" --role-name "${IAM_ROLE_NAME}-Role" sleep 5 # IAM伝播待機 fi # 4. UserData を Base64 エンコードしてインスタンス起動 USERDATA_BASE64=$(base64 -w 0 "${USER_DATA_FILE}" 2>/dev/null || base64 "${USER_DATA_FILE}" | tr -d '\r\n') INSTANCE_ID=$(aws ec2 run-instances \ --region "${AWS_REGION}" \ --image-id "${AMI_ID}" \ --instance-type "${INSTANCE_TYPE}" \ --iam-instance-profile "Name=${IAM_ROLE_NAME}" \ --security-group-ids "${SG_ID}" \ --user-data "${USERDATA_BASE64}" \ --block-device-mappings "[{\"DeviceName\":\"/dev/sda1\",\"Ebs\":{\"VolumeSize\":${EBS_SIZE_GB},\"VolumeType\":\"gp3\",\"DeleteOnTermination\":true}}]" \ --instance-initiated-shutdown-behavior terminate \ --tag-specifications "ResourceType=instance,Tags=[{Key=Name,Value=vllm-server-temp}]" \ --query 'Instances[0].InstanceId' \ --output text) echo "インスタンス起動開始: ${INSTANCE_ID}" echo "${INSTANCE_ID}" > "${INSTANCE_STATE_FILE}" # 5. 起動完了とパブリックIP取得待機 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}" echo "vLLM の起動ヘルスチェック待機中 (通常7〜10分程度)..." # 6. /health が 200 OK を返すまでポーリング while ! curl -s "http://${PUBLIC_IP}:8000/health" > /dev/null 2>&1; do echo -n "." sleep 5 done echo "" echo "🎉 vLLMサーバーの準備が完了しました! (http://${PUBLIC_IP}:8000)" このスクリプトを実行するだけで: 最新の Deep Learning AMI を自動検索 ポート8000と22を開放したセキュリティグループを自動構成 S3およびECRの読み取り権限を持ったIAMインスタンスプロファイルを自動紐付け DeleteOnTermination: true および --instance-initiated-shutdown-behavior terminate を指定して使い捨てEC2を起動 インスタンスの起動とパブリックIP取得後、 http://<PUBLIC_IP>:8000/health が 200 OK を返すまでポーリング待機(S3からのモデル同期やECRからの大容量イメージPull・解凍を含め、 通常7〜10分程度で自動起動 ) 起動したインスタンスIDを次回破棄用に .current_instance_id に自動保存 --> Information ⏱️ 起動所要時間(約7〜10分)のリアルな内訳 「コマンドを叩いてから使えるようになるまでどれくらい待つか?」は実運用で気になるポイントです。実際の検証環境での内訳は以下の通りです: EC2インスタンス初期化 & NVMeバインドマウント : 約30秒〜1分 ECRからのvLLMイメージPull & レイヤー展開 : 約4〜6分 (※CUDAやPyTorchを含むvLLMコンテナイメージは展開時16GB以上の大容量となるため、ここが最も時間を要します) S3からのモデル重み(約15GB)高速同期 : 約1〜2分(同一リージョン間) vLLMコンテナ起動 & GPUへの重みロード・CUDAグラフ構築 : 約1〜2分 (※EBS上にモデルを配置していた際はGPUへの重みロードだけで約15分かかっていましたが、NVMe化により1分未満へと劇的に高速化されています) 手動でGUIやSSHに入って何十コマンドも叩いてセットアップするのに比べれば、 「コマンド1発叩いて席を立ち、コーヒーを淹れて一息ついて戻ってくる頃(約7〜10分後)には自分専用のGPU推論環境が完成している」 という体験は非常に快適です! コンソールに「準備完了!」と表示されたら、もうvLLMサーバーは即座に利用可能です! 動作確認:OpenAI互換APIを叩いてみる # ターミナルから curl でリクエストを送ってみましょう。 Windows(PowerShell / Git Bash)やMac/Linuxを問わずクォート破損トラブルを防ぐため、JSONファイルを作成して -d @req.json 形式で渡すのが最も確実です。 PUBLIC_IP="<起動したEC2のパブリックIP>" # 1. リクエストボディのJSONを作成 cat <<'EOF' > req.json { "model": "Qwen/Qwen2.5-Coder-7B-Instruct", "messages": [ {"role": "user", "content": "AWSの東京リージョンでGPUインスタンスをエフェメラルに運用するメリットを3行で教えてください。"} ] } EOF # 2. vLLMのOpenAI互換エンドポイントへリクエスト curl http://${PUBLIC_IP}:8000/v1/chat/completions \ -H "Content-Type: application/json" \ -d @req.json --> Information Windows環境でのTips : -d '{ ... }' のようにコマンドライン引数へ直接インラインでJSONを記述すると、Windows(PowerShell、コマンドプロンプト、Git Bash)の引数エスケープ処理によりダブルクォートが剥がれ、 {"detail":"There was an error parsing the body"} というJSONパースエラーが発生しやすくなります。上記のようにファイル( -d @req.json )経由で渡すことで、どのOS環境でも安全に実行できます。 実行結果(レスポンス例): { "id": "chatcmpl-a662010ed226ea08", "object": "chat.completion", "created": 1789410574, "model": "Qwen/Qwen2.5-Coder-7B-Instruct", "choices": [ { "index": 0, "message": { "role": "assistant", "content": "1. コスト効率: エフェメラルなGPUインスタンスは、使用時間が短い場合や一時的な作業に適しているため、費用がかからない時間帯を利用できます。\n2. スケーラビリティ: 必要に応じて簡単にインスタンスを増減させることができます。\n3. 安全性: 使用後すぐにインスタンスが削除され、データ漏洩などのリスクが軽減されます。" }, "finish_reason": "stop" } ], "usage": { "prompt_tokens": 58, "total_tokens": 163, "completion_tokens": 105 } } 見事に自前のGPUサーバーから高速に応答が返ってきました! 標準的なOpenAI互換エンドポイントなので、Pythonの openai ライブラリや各種GUIクライアント(Open WebUI等)からもそのまま繋ぐことができます。 --> Caution ⚠️ セキュリティに関する重要な注意点:通信の平文(HTTP)とセキュア化 今回の検証構成では、手軽に動作確認を行うために ポート8000のHTTP(平文) で直接リクエストを送っています。 しかし、インターネット経由で平文通信を行うと、送受信するプロンプトやモデルの回答が途中の経路で盗聴・改ざんされるリスクがあります。 本番環境や機密性の高いコード・データを扱う場合は、必ず以下のいずれかの方法で 通信をセキュア化 してください。 SSHポートフォワード(最も手軽でおすすめ) : EC2のセキュリティグループでポート8000をインターネットに開放せず、SSH(ポート22)のみを許可します。ローカル端末から以下のコマンドでトンネルを掘ることで、暗号化されたSSH通信経由で http://localhost:8000 として安全にアクセスできます。 ssh -i <your-key.pem> -N -L 8000:localhost:8000 ubuntu@<EC2のパブリックIP> リバースプロキシによるSSL/TLS化(HTTPS) : EC2内に Nginx や Caddy などのリバースプロキシを配置し、Let's Encrypt 等の証明書を適用してHTTPS通信(ポート443)を終端させます。または、AWSの Application Load Balancer (ALB) と AWS Certificate Manager (ACM) を手前に配置するのもクラウドネイティブな王道構成です。 プライベートネットワーク(VPN / Tailscale)の活用 : AWS Client VPN や Tailscale、WireGuard などを導入し、パブリックIPを経由せずプライベートIPアドレス空間内で通信を完結させます。 検証が終わったら即座に完全破棄!(寝落ちしても安心の二重防御) # 作業が終わったら、 インスタンスを「停止(Stop)」ではなく「終了(Terminate)」 します。 用意した破棄スクリプト( 04_ec2_terminate.sh )を実行するだけです。 #!/bin/bash set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" INSTANCE_STATE_FILE="${SCRIPT_DIR}/../.current_instance_id" 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が指定されていません。" exit 1 fi echo "=== EC2インスタンスの完全終了 (Terminate) ===" echo "対象インスタンスID: ${INSTANCE_ID}" 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}" echo "==========================================================" echo " インスタンスおよびEBSボリュームの完全破棄が完了しました!" echo " これ以降、本インスタンスに関するコンピュート/EBS課金は一切発生しません。" echo "==========================================================" 実行はワンコマンドです: ./scripts/04_ec2_terminate.sh EBSボリュームの「Delete on Termination」が有効になっているため、インスタンス終了と同時に100GBのEBSストレージも綺麗サッパリ消滅します。 「これで高価なEBS放置課金はゼロ!停止中の維持費も月額わずか百数十円の保管料のみ!」という圧倒的な安心感 を得ることができます。 もし破棄スクリプトの実行を忘れて寝落ちしてしまったら? # ご安心ください。先ほど UserData に仕込んだ 「アイドル自動終了デーモン」 が控えています。 最後のAPIリクエストから1時間推論リクエストが途絶えると、インスタンス自身が自動で shutdown -h now を実行。そして --instance-initiated-shutdown-behavior terminate が設定されているため、そのまま自動でインスタンス終了(Terminate)&EBS道連れ破棄されます! 「手動での即時破棄」と「1時間アイドルの自動自爆」という 二重の安全装置 があるため、深夜の検証でも安心して眠りにつくことができます。 また触りたくなったら? S3にモデルは保管されているので、 ./scripts/03_ec2_launch.sh を叩けば、数分後には全く同じ環境が蘇ります。これぞまさにクラウドの醍醐味です。 ハマりポイントと対策 # 1. IAMロールの付け忘れ・ポリシー不足 # UserData内で aws s3 sync や aws ecr get-login-password を実行するため、EC2にアタッチするIAMロールには以下2つのポリシーが必須です: S3バケットへの読み取り権限( s3:GetObject および s3:ListBucket 、または AmazonS3ReadOnlyAccess ) ECRリポジトリからのイメージPull権限( AmazonEC2ContainerRegistryReadOnly ) ロールやポリシーが付いていないと、UserDataの実行ログ( /var/log/userdata-vllm.log )に AccessDenied が出力され、モデルやコンテナイメージの取得で起動が止まってしまいます。 2. リージョンの不一致によるデータ転送料課金 # S3バケットとEC2のリージョンが異なっていると(例: S3が us-east-1 でEC2が ap-northeast-1 )、モデルダウンロード時に インターネット経由のリージョン間データ転送料(数GB〜数十GB分)がしっかり課金 されてしまいます。必ず両者を同じリージョン(今回は東京 ap-northeast-1 )に揃えましょう。同一リージョン内であれば転送料は 0円 です。 3. vLLM起動時のVRAMメモリ確保設定 ( --gpu-memory-utilization ) # vLLMはデフォルトでGPUメモリ(VRAM)の90%〜95%を一気に事前確保しようとします。 モデルの重みサイズに対してコンテキスト長( --max-model-len )を大きく取りすぎると、KVキャッシュの領域が足りずにOut Of Memory (OOM) でコンテナがクラッシュすることがあります。 VRAM 24GBのインスタンスで動かす場合、 --gpu-memory-utilization 0.90 、 --max-model-len 8192 あたりから調整を始めるのがおすすめです。 4. GPUインスタンスの起動エラー ( VcpuLimitExceeded ) # 起動スクリプトを実行した際に以下のようなエラーが出た場合、アカウントのGPUインスタンス割り当て上限(vCPU上限が0)に達しています。 An error occurred (VcpuLimitExceeded) when calling the RunInstances operation: You have requested more vCPU capacity than your current vCPU limit of 0 allows for the instance bucket that the specified instance type belongs to... 事前準備の項目に記載した通り、AWSマネジメントコンソールの Service Quotas から Running On-Demand G and VT instances の上限緩和申請(4 vCPU以上)を行ってください。 まとめ # 今回は、日々の開発や技術検証でオープンソースLLMを快適に使い倒すための土台として、 「Linux × UserData × vLLM × S3 & ECR」による完全自動・エフェメラルなLLM検証環境 を構築しました。 環境構築の完全自動化 : UserDataにより、起動からvLLMのコンテナ稼働までを完全自動化。 停止時の維持費「ほぼゼロ」の実現 : モデルをS3、コンテナイメージをECRに逃がすことで、EC2を完全使い捨て(Terminate)可能にし、高価なEBSの放置コストを完全に排除(停止中の維持費は月額わずか約145円の保管料のみ)。 寝落ち・停止忘れ対策の自動自爆機能 : 1時間推論リクエストがないとOSが自律シャットダウンし、EC2とEBSを自動Terminate。高価なGPUの課金爆発を徹底防御。 OpenAI互換のエンドポイント : ポート8000で標準APIが立ち上がるため、あらゆるクライアントから接続可能。 「クラウドのGPUを使ってみたいけれど、コストや環境維持が心配……」という方は、ぜひこの「持たない贅沢」なエフェメラル構成を試してみてください!
はじめに # GFXBenchというGPUベンチマークソフトを取り上げたシリーズの2回目です。 前回でGitHub公開されたGFXBenchのビルドが行えました。 今回は実際に実行してベンチマークスコアを確認してみます。 ベンチマーク紹介 # GFXBenchは様々なベンチマークから構成されており、起動時に選択して実行する様になっています。 カテゴリは大きく 高レベルテスト と 低レベルテスト に分かれています。 今回取り上げるのは 高レベルテスト の方で、その中からいくつか抜粋して紹介します。 --> Information 以降画像は筆者のスマートフォンのGFXBench実行時スクリーンショットより引用 スマートフォン/タブレットによってはレイアウト等見え方が異なる場合があります T-Rex # Manhattan # Manhattan 3.1 # Car Chase # Aztec Ruins # 色々説明されていますが、まずは "Required minimum API" の部分だけに注目してみます。 以下の様にベンチマークが OpenGL ESバージョン に伴って進化してきた事がわかります。 (GFXBenchのバージョンアップで各ベンチマークが追加されるという見え方) ベンチマーク OpenGL ES version T-Rex 2.0 Manhattan 3.0 Manhattan 3.1 3.1 Car Chase 3.1 + AEP (Android Extension Pack) そのため、そのAndroid端末が対応している OpenGL ESバージョン によって実行出来るベンチマークが変わります。実行できない場合は後のベンチマーク選択画面で選択出来ない状態となります。 ※ Aztec Ruins は路線が変わった様なので省略(APIのバージョンではなくマルチAPIでのベンチマークというニュアンスになった模様) ベンチマークインストール # 前回ビルドを行ったapkファイルをAndroid端末にインストールします。 Android端末側は開発者モードになっていてビルド用PCとadb接続出来ているとします。 なお、adbコマンド実行時は Git Bash でなく Windowsターミナル を使用した方が(何かと)トラブルが起きずに済みます。 adb install gfxbench-5.1.5+corporate.apk ベンチマーク実行 # Android端末上からGFXBenchのアイコンをクリックし起動します。 初回起動時は「Pushed data not found」という表示が出ます。 「OK」を選択してapkバンドルのデータをアプリデータ領域にコピーをします。 --> Information 前回も触れましたが初回起動時にこのコピー分だけアプリサイズが増える事になります。 手動でデータを配置する手順も上記表示の様に案内されます。 起動すると、ベンチマークの初期画面が表示されます。 そこで "テスト選択" ボタンを押すと テスト選択 の画面になります。 実行するベンチマークとしては筆者の思い入れより以下の2つとします。 T-Rex Manhattan また、それぞれについて、オンスクリーン版とオフスクリーン版があります。 オンスクリーン版では実際に画面にベンチマークが描画されて実行されます。そのためAndroid端末の実際の画面サイズにベンチマークスコアが依存します。 オフスクリーン版では画面に描画されず(進行がわかる程度の描画はあり)オフスクリーンサイズ固定で実行されます。そのためAndroid端末の実際の画面サイズに依存せずベンチマークスコアを得る事が出来ます。 Android端末を横断的に(画面サイズに依存せず)GPU性能を比較したい場合はこちらを選択します。 最初起動すると全ベンチマークがチェックされた状態になっているので、実行したいベンチマークのみをチェックし "開始" ボタンを押します。 (高レベルテスト、低レベルテスト といったカテゴリでチェックを外すと一括でチェックを外せるので便利です) ベンチマーク実行結果 # 実際にオフスクリーン版を実行してみます。 (画面描画を見たい方はオンスクリーン版でお楽しみください) 実行するAndroid端末はAndroidバージョンが(比較的)新しいものを手元のコレクションから見繕ってみます。 なお、今回対象とするAndroid端末のスペック帯はベンチマークスコアが最大でも 100台半ば 程度のfpsのものとします。 (ハイスペックな端末にとってはいまや T-Rex/Manhattan ベンチマークは軽い部類となってしまいましたので...。 その場合はもっと重いベンチマークが適していると思われます) 以下、実測した結果例です。ベンチマークスコアはオフスクリーン版のfps値です。 --> Caution あくまで 「筆者の環境および測定時点における一例」 であり、同様のGUP、手順で測定された場合でも、環境によって異なる結果となる可能性がある点をご了承ください。 GPU SoC Driver version Android version T-Rex score Manhattan score ARM Mali G57 MC1 Allwinner A537 OpenGL ES 3.2 v1.r51p0-00eac0.26a7a06524af59d6533aad5e5bab3098 15 19 13 ARM Mali G57 MC2 Mediatek Helio G99 OpenGL ES 3.2 v1.r32p1-01eac0.394145956bc7cd8e697b330aba11e3d3 13 57 37 ARM Mali G57 MC3 Mediatek Dimensity 800U OpenGL ES 3.2 v1.r32p1-01eac0.461cd25a1c7796cc6d3ad05234c053ac 12 85 54 偶然にも:-) core数(MC)違いのGPUですが、core数が多いもの程いい結果となりました(それはそう)。 もう少し比較をするためにベンチマークスコアをcore数で割ってみます。 GPU T-Rex/core score Manhattan/core score ARM Mali G57 MC1 19 13 ARM Mali G57 MC2 28.5 18.5 ARM Mali G57 MC3 28.3 18 今回のAndroid端末のMC2, MC3のものは同じ傾向(同等MC1想定値からの比例関係)である事がわかります。 その値からすると今回のAndroid端末のMC1のものは控えめな値である事もわかります。 低スペック帯なのでそこまで周波数が高くない?等の理由で控えめなのかもしれません。 おわりに # 今回はGFXBenchの内容紹介と、前回ビルドしたGFXBenchを実際に実行してベンチマークスコアを確認しました。 次回は今回実行したAndroid端末よりも古いAndroidバージョンで実行したい場合の手順を取り上げます。 ライセンスおよび免責事項 # 本記事に掲載しているスクリーンショット、検証結果は、BSD 3-Clause Licenseのもとで公開されている Kishonti-Opensource/gfxbench のソフトウェアおよびアセットを利用・引用したものです。 Original Copyright: (c) 2005–2025 Kishonti Ltd. License: BSD 3-Clause License 画像等の権利について: 記事内で引用しているGFXBenchのベンチマーク実行画面およびUIの著作権は、原著作者であるKishonti Ltd.に帰属します。 【免責事項】 本記事に掲載している手順、ベンチマークスコア等の測定結果は、特定の検証環境における現状のまま(AS IS)のものであり、その正確性、安全性、再現性を保証するものではありません。 本情報の利用や検証の実行により生じた直接的・間接的な損害について、筆者および株式会社豆蔵は一切の責任を負いません。内容を十分にご確認の上、ご自身の責任においてご利用ください。
アジャイルグループの中村です。 生成AIカンファレンス2026 に参加しました。生成AIの社会実装をテーマに、産業・開発・知能の3つの視点から、AIエージェントやフィジカルAIなど幅広いテーマを扱うイベントです。 AIで速くなった、その先への不安 # 2026年に入り、AIエージェントによるコーディングの高速化は一気に現実味を増しました。自身も個人開発アプリのメンテでPoC程度に触れましたが、その程度の経験でも「10倍の生産性」といった表現が、以前ほど大げさには聞こえなくなりました。エンジニアとしては、素直に興奮します。 一方、アジャイルコーチとしての自分は、ふたつの不安を覚えました。 ひとつは、成果物を大量に作れるようになれば、テストからリリースまでの後工程が渋滞するのではないか。 もうひとつは、製造コストの制約が薄くなれば、「どんどん作って、後から考えよう」と要件定義が雑になるのではないか。 今回のカンファレンスでは、このふたつの懸念が国内の現場でどう現れているのかを確かめたいと考えていました。百聞は一見に如かず、です。 結論から言えば、懸念していた構造は、すでに現場の課題として現れていました。ただし、それを前提に仕事のあり方を変えようとする実践も、すでに始まっています。 どのセッションも、いわゆる「キラキラ事例」とは一線を画す、生々しく骨太な内容でした。 そのなかから、ふたつの不安にそれぞれ重なったセッションを紹介します。 不安その1:速くなった仕事は、後工程で渋滞するのではないのか # 『生成AIの現在地:最前線の研究を社会実装する』 # <Sakana AI株式会社 大村壮太氏> AIと科学をテーマに、AIによる研究活動の高速化と、その先で顕在化している課題が紹介されました。いくつか提示された課題の中で、いちばん印象に残ったのが次のキーワードです。 「頭と終わりは人間」 頭とは「問題空間の定義」です。 科学者としてのAIの性能は上がり続け、研究のループは高速化しました。一方で、AIの介入によって、科学の扱う問題空間そのものが狭まってしまう懸念もあります。 終わりとは「理解・解釈」です。 思考の一部をAIに任せられても、成果を理解し、意味を解釈する人間の仕事までは消えません。AIが成果を生み出す速度に、人間側の処理能力が追いつかなくなる。研究助成の審査や論文査読は、その具体例として挙げられていました。 また、「理解がないと、知識と創造が阻害される。フィードバックループがない」との言葉にも、深くうなずかされました。 ソフトウェア開発のレビューや検収に当てはめれば、開発リーダーや顧客に負荷が集中することは容易に想像できます。 不安その2:作るのが安くなったら、要件定義が雑になるのではないか # 『“PoC止まり”を超える、小売AXの最前線』 # <株式会社アンドエスティHD 甲斐裕樹氏 / 株式会社Algomatic 鴨居啓人氏> アパレル小売店舗の店長業務を支援する「店長AIエージェント」の事例です。 売上分析や在庫検索などをAIエージェントから利用できる仕組みで、在庫検索業務を75%削減したと紹介されていました。 「まず現場で使ってもらえるもの」 この言葉こそ、ふたつ目の不安に対するひとつの答えに見えました。 製造コストが下がったから、とりあえず作るのではない。誰の何を変えたいのかを先に置き、使ってもらいながら確かめる。作ることが容易になるほど、むしろ問題設定とフィードバックの質が重要になる。 そして驚いたのは、大企業へAIを浸透させていく実行力です。 店長の業務を変えるという明確なニーズから出発し、数店舗からスモールスタートする。現場のフィードバックに基づいて改善しながら対象店舗を増やし、スコープも広げていく。非IT部門も含む部門横断チームが方向性を示し、オーナーシップをもって主導する。 活字に書き出してしまうと、ユースケースや進め方に目新しさはありません。 当たり前のことをやり抜く。その積み重ねが、75%という数字につながったのだと受け止めました。 まとめ # 私なりに整理すると、問いはふたつです。 その仕事は正しいか。 顧客価値を正しく定義し、本当に取り組むべき問題を選べているか。 仕事は正しく行われているか。 一部だけを速くするのではなく、価値を届けるまでのフロー全体が流れているか。 生成AIによって仕事は速くなります。だからこそ、こうした昔からの問いに向き合うことが、より重要になると考えます。 要求工学を価値の柱としてきた弊社こそ、ここに出番があるのでは――と、小さくガッツポーズしつつ、このふたつの問いを自分自身の宿題としても持ち帰りたいと思います。
この記事を読んでいただきありがとうございます。アジャイルグループに所属する、藤井智弘です。 質問 #  うちのチームは既存システムの保守運用が中心です。不具合対応や改善要望への対応がメインで、大きな新機能をスプリントで計画的に作っていくような仕事ではありません。研修で習ったスクラムは新規開発が前提のように見えますし、うちは割り込みも多い。 うちにはスクラムは合わないのではないでしょうか? 回答 #  合わないのは、スクラムではなく「スクラム解説の前提」のほうです。視点を変えると、保守運用はアジャイルにとってかなり自然な環境です。ウォーターフォール文化が身に染み付いた開発チームは、「最初にすべてを決めない」という考え方に慣れるのに苦労します。一方、保守運用の世界では「最初からすべて決めない」のは“当たり前”です。  さらに言えば、保守運用が向いているのはスクラムの「仕事の管理」の側面だけではありません。スクラムが定義していない、しかしアジャイルに不可欠なもう半分——テストや設計、リリースといった“モノづくりの技術”——を鍛える場として、保守運用はおそらく最良の舞台です。本稿の後半はそこに重心を置きます。  まずは、合う合わないを拙速に結論づける前に、「新規開発と保守運用は、本当に別物なのか」から考え直してみましょう。 「新規開発」と「保守運用」は、本当に別物か #  この質問の土台には、「新規機能の開発」と「保守運用の不具合対応・改善要望対応」はまったく違う仕事だ、という感覚があります。  スクラムを前提に、まずはここを疑ってみます。以下、前者を「新規開発系」、後者を「保守系」と便宜上呼び分けます。  バックログの視点で両者を並べてみると、こうなります。 新規開発系: 作りたい機能が並ぶ → 価値やリスクで優先順位をつける → 小さく完成させて届ける → フィードバックを得て次を決める 保守系: 不具合報告や改善要望が流れ込む → 影響度や緊急度で優先順位をつける → 直して届ける → 利用者の反応や再発状況を見て次を決める   新規かどうかにかかわらず、価値のある作業項目が流入し、それらに優先順位をつけ、小さく完成させて届け、結果を見て次を判断する。一歩引いてみると、両者は構造的には同じです。スクラムが回そうとしているループそのものを、どちらの「系」も回しています。そしてどちらのバックログも、項目が後から流れ込むことを前提にしたリストです。全件が最初に揃っているべきというリストではありません。  では、何も違わないのかというと、差はあります。ただしそれは「種類の差」ではなく「程度の差」です。 項目のサイズと独立性 : 保守系の項目は小粒で、互いに独立していることが多い。新規開発系は大きく、項目間の依存が強くなりがち 流入の予測可能性 : 保守系は突発の流入が多く、来週何をやるかを今週決めきれない。新規開発系は(保守系と比べて)計画が立てやすい ステークホルダーの期待の形 : 新規開発系には「いつ、何が出るのか」が問われ、保守系には「どれだけ早く、確実に直るのか」が問われる  これらはスプリントの長さ、ゴールの立て方、キャパシティの配分といった「運用パラメータ(第1回参照)」を変える理由にはなりますが、「スクラムをやる/やらない」を分ける理由にはなりません。同じ考え方の上で、パラメータ=調整のしかたが違うだけです。 スクラムが定義していない「もう半分」 #  もう一歩進めます。ここまでの話は、バックログと優先順位づけ、つまりスクラムが定義する「仕事の管理」の側面でした。しかしスクラムガイドを読み返すと、気づくことがあります。 スクラムは「どう作るか」を一切定義していません。 テストをどう書くか、設計をどう保つか、どの頻度で統合しリリースするか…これらはスクラムの外にあります。  アジャイルのこの「もう半分」を担ってきたのが、XP(エクストリーム・プログラミング)に代表される構築プラクティスです。自動テスト(そしてテストを先に書いてから実装するテスト駆動開発)、リファクタリング(動きを変えずに内部構造を整えること)、継続的インテグレーション(変更を頻繁に統合し自動でビルド・テストすること)、小さなリリース、シンプルな設計。スクラムだけを導入して「スプリントごとに動くものが出てこない」「スピードが落ち続ける」と悩むチームの多くは、この半分が欠けています。管理の枠組みだけあって、その中で回すモノづくりの足腰がない状態です。 保守運用は、その「もう半分」を鍛える最良の舞台だ #  ウォーターフォール文化に染まった開発チームがアジャイルに移行するとき、最大の障害はたいてい「全部決めてから作りたい」という習慣(いや欲求)です。線表を引き、要件を固め、設計を固め、実装へ…この習慣を捨てるのに、多くのチームが何ヶ月も、ときに何年も苦しみます。  ところが保守運用の現場を見てください。  来月どんな不具合が報告されるか、誰も知りません。半年先の対応計画を精緻に立てることに意味がないことを、全員が体で知っています。つまり、 「最初にすべて決めない」状態が、意図せずすでに達成されている のです。開発チームが苦労して手放す習慣が、環境としてそもそも成立しません。  そして、ここからが本題です。保守運用の日常業務は、XPの技術プラクティスの練習問題そのものです。 自動テスト : 不具合を直すとき、「まず再現するテストを書き、それを通す」のは最も自然な手順です。直した箇所が次の改修で壊れないよう回帰テストの網を増やしていくことも、保守の現場では「当然のこと」として受け入れられます。多くの未熟なアジャイルプロジェクトで「テストはリリース間際にやればよい」という誤解(開きなおり?)が平然と通っているのとは大きな違いです。 リファクタリング : 保守系の変更は小粒です。「触ったところを少しだけきれいにして戻す」を毎回やる習慣が、大規模な作り直しをせずにコードを健全に保つ唯一の現実的な方法であり、保守運用は開発者に練習する機会を多く与えてくれます。 継続的インテグレーションと小さなリリース : 項目が独立しているため、1件ずつ統合し、1件ずつ届けられます。「小さく完成させて届ける」サイクルが短く、ビルド・テスト・デプロイの自動化に投資する動機も見返りも、新規開発より明確です。 完成の定義 (何をもって「終わった」とするかのチームの合意): スプリントごとに何度も「完成」を経験できるので、見積り・分割・「完成とは何か」の合意といった基本動作の練習回数を、新規開発案件よりはるかに多く稼げます。検査と適応のリズムも、障害の振り返りや再発防止という形で日常業務と地続きです。  このように見ていくと、「保守運用にスクラムは合わない」のではなく、むしろ「スクラムの管理の枠組みと、XP的なモノづくりの技術の両方を身につけるなら、保守運用は良い舞台である」という見方が、十分に成り立ちます。 でも限界も知っておこう #  ただし、限界もあります。保守運用で鍛えやすいのはモノづくりの側であって、スクラムの管理の側には練習しにくいことが2つあります。 スプリントゴールで仕事を「束ねる」力 。小粒で独立した項目をこなすだけなら、ゴールは「今スプリントの分を終わらせる」に退化しがちです。バラバラの作業を1つの目的に束ねる練習は、意識しないと発生しません。 価値の優先順位を巡る判断 。「直すのが当然」の不具合が相手では、「もたらす価値に基づいて何を作り、何を作らないか優先順位をつける」という、プロダクトオーナーの筋トレの機会が乏しくなります。  ほぼアジャイル初学者ばかりのチームが、いきなり本番開発に突撃して失敗している現場を少なからず見ていると、「まず保守運用でモノづくりの技術を身につける」→「新規開発でゴールやPOの筋トレに取り組みながら開発を進める」というアプローチは、十分に検討に値すると思います。  とりわけ日本では、この「モノづくりの技術」への投資が構造的に不足しがちです。経営層の中には、アジャイルを「早い、安い」という文脈で捉えている方が少なくありません。その結果、体制の編成は単価優先になり、育成への投資が省かれ、設計からコーディング、テストに至る“モノづくり”の能力がチームとして整わなくなります。スクラムの研修は受けたが、テストの自動化もリファクタリングも誰もやったことがない——そんなチームが「スクラムを始めた」と称して本番開発に突撃する光景は、珍しくありません。責めるべきは個々のエンジニアではなく、この編成と投資の意思決定です。  もっともこの意思決定も、コスト削減の制約の下では、その場その場では合理的な選択ではあります。合理的な選択の積み重ねが望まぬ結果に行き着く——この構造は、次回からの更改案件の話でも繰り返し登場します。  私見では、これがアジャイル開発の失敗の大きな要因のひとつです。保守運用を鍛錬の舞台にする提案は、この足腰を日常業務の中で鍛え直す提案でもあります。  経営層の方に、「早い、安い」の代わりに期待していただきたいのは、「変更1件のリードタイムが縮む」「切替やリリースの事故が減る」「見積の外れ幅が狭まる」という、測れる変化です。これらは、本連載の今後の回で採り上げようかと考えています。  そしてもちろん、開発チームを「価値の創造エンジン」という目で見ていただきたい。コストセンターとして単価を削る対象ではなく、 投資して育てる対象 として。 それでも現場で困ることへの処方 #  保守運用へ適用するとして、よく起こるだろう困りごとへの対処法もいくつか挙げておきましょう。 スプリントゴールが立てられない  前述したようにゴール設定はなかなか難しいかもしれません。スプリントを回し始めて数ヶ月のチームなら、ここでいっそのこと割り切って、ムリにゴール設定せず、まず「自動テストとリファクタリングを日常の手順に組み込む」といったモノづくりの基本動作に集中してもいいんじゃないかと思います。  もちろん、「ゴール決めを禁止する」意図はありません。ゴールを「機能のまとまり」で立てようとすると詰まります。改善・削減・安定化の観点で考えると立てやすくなるでしょう。「今月のアラート件数を半減する」「この手順書を廃止できる自動化を完成させる」「問い合わせ上位3件をFAQ化して問い合わせを減らす」。これらは立派なスプリントゴールであり、前述の「束ねる力」の練習にもなります。 割り込みで計画が壊れる  まず1〜2スプリント、割り込みの件数・時間・発生元を記録し、感覚ではなく実績の比率で「計画枠」と「割り込み枠」を分けてキャパシティを設計してください。詳しい運用(記録のとり方、当番制、受付の一本化など)は、第12回あたりでまとめて扱う予定です。 そもそもスプリントという区切りが実態に合わない  割り込みが恒常的に過半を占めるようなら、スクラムにこだわらず、カンバン(スプリントを区切らず、流れで仕事を管理する手法)の併用・移行を正面から検討してください。流れてくる仕事を順に引き取り、流れの速さと詰まりを改善するカンバンは、フロー中心の現場の実態に素直に合います。  スクラムは目的ではなく手段です。「スクラムをやめる」ことは「アジャイルをやめる」ことではありません。 なぜこの質問は30年繰り返されるのか #  理由は単純で、アジャイルの解説・研修・成功事例のほとんどが「新機能を開発するチーム」を暗黙の主人公にして書かれてきたからです。読者の多数派が保守運用に携わっているにもかかわらず、教材の主人公は常に新規開発チームでした。加えて日本では、新入社員研修でウォーターフォール型の開発はしっかり教えられる一方、アジャイルはほとんど扱われません。「教材がない」状態は、キャリアの入口から始まっているのです。  だから保守運用の実践者は、世代が替わるたびに「自分たちは例外なのではないか」「新しいアプローチに触れる機会が奪われているのではないか」という同じ不安を抱きます。  しかし本稿で見たように、例外なのは現場ではなく教材の前提のほうです。「先が読めない中で、小さく完成させ、学びながら進む」というスクラムの舞台装置は整っている。そのうえ、スクラムが定義しないモノづくりの技術を鍛える練習問題が、毎日流れ込んでくる。  保守運用は アジャイルの周縁ではなく 、むしろ その足腰を作る場所 だと考えてはいかがでしょうか? 次回: 「アップグレード案件の『現行機能保証』にどう向き合う?」
連載をはじめるにあたって #  この記事を読んでいただきありがとうございます。アジャイルグループに所属する、藤井智弘です。  「豆蔵最後のアルバイト」という謎の肩書でお客様を煙に巻きながら、日々研修やご支援に従事している中で、同じような質問を何年にも渡って繰り返しいただいてきました。アジャイル開発が世に出て30年近い年月が経ち、実践者の世代交代が進んでいるので、かつて議論し尽くされた(かに見えた)質問が、打者一巡よろしく繰り返し話題にのぼるのも当然とも思います。  そうしたよくいただく質問を起点に、その背景にある構造的な問題や現場で使える対処法を、FAQの形で明文化しておこう、というのが、これから始める連載の趣旨です。  対象となる読者像は、以下のように設定しました。 研修や専門書での学習によって、「スプリントってなに?」という基本的な用語レベルは知っている 新年度になってスプリントを回し始めて数ヶ月、「これでいいんだっけ?」という引っかかりが溜まりはじめた 基礎を学んだ方がもう一歩…「知っているのに、現場でうまくいかないのはなぜか」…掘り下げて考える機会を作ろうということがねらいです。  実際にいただいた質問を起点にしているので、タイトルを見ると「開発者視点」と思われそうですが、「アジャイルに関わる人であれば知っておいたほうが良い内容」を意識しました。ですので、チームを率いるリーダーや、発注側・経営側としてスクラムのチームと向き合う立場の方もお付き合いください。  本題に入る前に、お断りを3ついれさせていただきます。 「正解」ではなく「選択肢」。 …課題への対処法は、プロジェクトが置かれた文脈(コンテキスト)に応じて、異なるアプローチを採ることも珍しくありません。本稿でも課題への対処法を提示することがありますが、それらは「正解」というよりも、「皆さんが採りうる選択肢」と理解してください。他の書籍やブログで提案されているアプローチとは異なることもあるでしょうが、「選択肢が増えた」くらいに捉えていただき、ご自身のチームの文脈で何をすればいいかを考えるきっかけとしてください。 「AIツールの具体的な使い方」の開設ではありません。 …昨今開発現場でAI利用に関する関心が高まっていますが、本連載でAIツールの具体的な使い方を解説することはありません。きっと別の人が別の記事で採り上げてくれるでしょう。 餅は餅屋、法的なことは専門家にまかせる。 …請負契約等の法的・契約実務上の論点も守備範囲外とさせていただきます。  少々前置きが長くなりました。初回のFAQには、いままさに研修や導入支援の場で受けることが増えている、この時代ならではの質問を選びました。 質問 #  AIがコードを書き、テストを書き、設計案まで出してくれる時代です。開発のスピードは桁違いに上がりました。正直なところ、いまさらスクラムの基礎を学ぶ意味はあるのでしょうか? スプリントで区切って人間が見積りや振り返りをするのは、AIのスピードの前では、“儀式”に見えます。 回答 #  学ぶ意味はあるし、重要性はむしろ増しているとさえ思います。なぜなら、スクラムが管理しているのは「作るスピード」ではなく「進むべき方向」だからです。  AIが劇的に速くしたのは「どのように作るか」であって、「何を作るか」「作った結果から何を学ぶか」は人が主体的に行う活動であり、AIが助けにはなっても、AIに丸投げすることはできません。作るスピードが上がるほど、間違ったものも速く作れるようになる——この単純な事実が、検査と適応(作ったものとやり方を頻繁に点検し、次を調整し続けること)という地味な基礎活動の価値を、かつてなく高めています。 AIが速くしたもの、しなかったもの #  まず、冷静に仕分けをしましょう。生成AIが確かに変えたのは、実装・テストコード作成・定型設計・調査といった「どのように作るか」の領域です。ここが数倍(いやもっとかな?)速くなったという主張に異論はありませんし、私自身実感してもいます。  では、次の問いはどうでしょうか。 この機能は誰のどんな業務課題を解決するのか。 リリースした結果、狙った効果は出たのか。 出なかったとして、次に何を変えるのか。 チームの今のやり方は機能しているか…。  これらは、人が主体的に意思決定するテーマであり、AIは答えを持っていません。もっともらしい答えの文章は生成してくれますが、その検証責任は人に残ります。そしてお気づきの通り、「何を作るか」「作った結果から何を学ぶか」「チームでどう検査と適応を回すか」は、アジャイルがこの30年間一貫して問うてきたことそのものです。AIは、アジャイルが担ってきたこれらの問いを何ひとつ引き受けてくれていない。むしろ、問いに向き合わないまま作れてしまうスピードだけが上がったのです(少なくともこれまでのところは)。 “AIいいなり開発”という新しくて古い病気 #  その帰結として、現場で目にするようになった光景があります。AIの生成物を、十分な検証もせず、その意図を理解もせずただ丸呑みして積み上げていく開発…「それじゃぁ『 AIいいなり開発(AI-Dictated-Development) 』じゃないか!」と叫びたくなる姿です。本来の「 AI駆動開発(AI-Driven-Development) 」とは似て非なるものです。 イラスト左:AI駆動   イラスト右:AIいいなり  AIいいなり開発の特徴は、一つひとつの成果物がもっともらしいことです。コードは動き、テストは通り、ドキュメントは整っている。問題は、その全体が誰の何のために作られたのか、誰も満足に説明できないことです。検証されていない仮定の上に、検証されていない生成物が高速に積み上がっていく。従来なら数ヶ月かかった「間違った方向への行軍」が、数週間で再現できるようになりました。  しかし、これは新しい病気ではありません。「要件定義書に書いてあったから作りました」「言われた通りに作りました」——意図を問わない開発は昔からありました。AIはそれを高速化し、かつ「作った感」で覆い隠すのがうまくなっただけです。  診断が同じなら、処方も同じ系統です。 スクラムの基礎は、AIの出力に対してこそ効く #  この連載で扱う基礎を、AIの文脈に置き直してみます。 宛先と目的を問う習慣 は、「このコードは誰の何のためか」(fig002)をAIの出力に対しても問わせます。生成物がもっともらしいほど、この問いだけが人の仕事として残ります 小さく完成させて検査するリズム は、生成されたものを鵜呑みにせず、動かして・使わせて・学ぶ機会を強制的に確保します。スプリントは「人間が遅いから区切る」のではなく、「学習のリズムを設ける」ための仕組みなので、生成が速くなっても不要にはなりません。むしろ検証すべき生成物が増えた分、検査のリズムの価値は上がります 計測と記録の規律 は、「AIで速くなった気がする」を検証可能な事実に変えます。リードタイム(着手から届くまでの時間)は本当に縮んだのか、手戻りは増えていないか。道具の評価もまた、検査と適応の対象です  逆に、変わってよいものもあります。スプリントの長さ、見積りのやり方、ドキュメントの作り方…これらはスクラムの進行を調整する“運用パラメータ”であり、AIによって最適値が変わり得ます(この「基本」と「運用パラメータ」の区別は、次回以降の各論でも繰り返し登場します)。生成が速いなら、スプリントはもっと短くできるかもしれない。見積りの議論は、作って確かめる方が速い場面が増えるかもしれない。パラメータは大胆に見直してください。この技術の大変革の時代に、「批判精神なき前例踏襲は、“罪”」です。 これらパラメータの変更もチームで議論し合意し、その効果を検査する対象です。周囲を無視して現場で好き勝手やっていい話ではありません。  そして、ここまで説明した検査と適応のループそのものを外した瞬間、残るのは「高速ないいなり」です。 なぜ、新しい技術が生まれるたびに、似たような質問が繰り返されるのか #  「新しい道具が来たから、プロセスの基礎はもう不要では?」という問いは、実はAIが初めてではありません。CASEツール(設計図からコードを自動生成する1980〜90年代のツール群)が自動生成する、RAD(試作を繰り返して短期間で作る1990年代の開発手法)で開発が桁違いに速くなる、ローコードでプログラマーが不要になる…新しい道具が現れるたびに、同じ問いが再演されてきました。打者一巡どころか、これはもう何巡目かの打席です。  そして毎回、結論も同じでした。道具は「どのように作るか」を変え、ときに劇的に改善する。しかし「何を作るべきか」「作った結果から何を学ぶか」は道具の外に残り続け、そこを疎かにしたプロジェクトは、速い道具で速く失敗した…。  AIが過去の道具と規模の違う変化であることは認めます。だからこそ、と言うべきでしょう。道具が強力になるほど、ハンドルを握る側の基礎が問われます。本連載がこれから扱うのは、そのハンドルの握り方です。 今後の予定 #  次回以降の予定ですが、タイトルと順序を以下に列挙しておきます。 ただ、これらは仮のもので、断りなく変更される可能性があることをご承知おきください。 第2回: 保守運用チームにスクラムは合わないのでは? 第3回: アップグレード案件の「現行機能保証」にどう向き合う? 第4回: 更改案件の見積を、全機能を確定せずにどう出す? 第5回: ドキュメントが信用できない現行システム、何を『仕様』にして検証する? 第6回: デイリースクラムが進捗報告会になっています 第7回: レトロスペクティブが機能しません…同じ改善案の再放送と、ToDoの列挙会 第8回: スプリントレビューにステークホルダーが来なくなりました 第9回: ストーリーポイントの人日換算を上司に求められます 第10回: ベロシティが安定しません。計画に使っていいの? 第11回: スプリント内に終わらないタスクが毎回出ます 第12回: 割り込み作業はどう計画に入れる? 第13回: POが多忙でチームに来ません 第14回: スクラムマスターは何をする人? 暇そうに見えます 第15回: 自己組織化と言われても、結局リーダーが全部決めています 第16回: 技術的負債の返済をPOが優先してくれません 第17回: ドキュメントはどこまで書けばいい? 第18回: 最初は改善が進んだのに、最近チームが停滞している気がします 第19回(最終回): 連載の総括  長い文章にお付き合いいただきありがとうございました。 次回: 「保守運用チームにスクラムは合わないのでは?」
はじめに # 大まかな構成は把握していても、細かい実装には手が回らずぼんやりとしか理解してなかったので、勉強を兼ねてRAG(Retrieval-Augmented Generation)を実装してみました。 今回は、RAGの核となる「ベクトル化」「類似度検索」「コンテキスト注入」を理解することを目的に、フレームワークやVectorDBをあえて使わず、最小構成で動かしてみます。 これらは実運用ではライブラリやVectorDBに置き換える選択肢がありますが、まずは本質を素の形で体感します。 そのうえで、ミステリーというわかりやすい題材を使って、RAGの動作をイメージしやすい形で見ていきます。 本記事で使用している用語の定義 Instruction: 役割や振る舞いの指定 ドキュメント: 原文である文書のこと プロンプト: 質問や依頼文の原文 クエリ:指すモノはプロンプトと同じですが、検索に使用する質問を指す場合、呼び変えています チャンク: 原文をもとに分割された状態 コンテキスト: LLMに渡す背景情報 本記事で使用している図、小説 Geminiで生成したものを用いています。 技術スタック # node 26 Google AI Studio gemini-embedding-2(Embeddingモデル) gemini-3.5-flash-lite(テキスト出力モデル) typescript 7 @google/genai 2.17.1 --> Google AI Studio 無料枠でも十分に試せて、導入ハードルが低いことから、今回利用しました。 事前準備として Google AI Studio の無料枠を使ってAPI Keyを取得します。 無料枠はクレジットカード登録不要で試せます。 サンプルアプリ # まずRAGの基本構造を見えるようにするため、今回のサンプルではあえてフレームワークやVectorDBを使わずに、最小構成で動かしてみました。 このアプリでは、RAGの本質である「ドキュメントをどのように扱うか」「どこを類似検索するか」「どの情報をLLMに渡すか」を、最小限のコードで確認できます。 本記事で掲載しているコードは こちら で公開しています。 スペック # Instruction 空行で分割する。 起動後、ユーザーに選択を促し、選択した内容をLLMに渡すコンテキストの一部に含める。 ドキュメント 空行で分割する。 プロンプト 空行で分割する 起動後、ユーザーに選択を促し、選択した内容をLLMに渡すコンテキストの一部に含める。 類似度検索は、Top1のスコアを基準に相対評価で決定する。 処理フロー # 今回作成したアプリの処理フローは下図の通りです。 sequenceDiagram autonumber actor cli participant app as 今回のアプリ participant llm as Google AI Studio cli --> app: 起動 app -->> cli: Instructionの選択確認 cli --> app: Instruction選択 app --> llm: ドキュメントの組み込み llm -->> app: ベクトル化されたドキュメント app -->> cli: Promptの選択確認 cli --> app: Prompt選択 app --> llm: プロンプトの組み込み llm -->> app: ベクトル化されたプロンプト app --> app: コサイン類似度計算 app --> app: 類似度検索 app --> llm: 回答生成 llm -->> app: 回答 app -->> cli: 回答 ベクトル化(Embedding) # 自然言語や画像などを人が使うデータを、AIが理解できるように意味を表す数値のリスト(ベクトル)に変換する処理です。 LLMやEmbeddingモデルは、文字をトークン単位で処理し、学習を通じて言葉どうしの関係を扱います。 ただし、文章どうしの意味の近さを検索で直接比較するには、数値として扱える形にする必要があります。 たとえば「有休」と「有給休暇」のような近い意味の言葉を、近い位置に配置するのがEmbeddingです。 そこで、テキストをEmbeddingモデルを使って「意味の位置関係を表す数字のリスト」に変換します。これがベクトル化です。 具体例で考える(イメージ)たとえば言葉を「ジャンル」「フォーマット」などの軸(次元)で数値化してみます。 言葉 軸①:仕事っぽさ 軸②:休みっぽさ ベクトル(座標) 有給休暇 0.9 0.9 [0.9, 0.9] 有休 0.9 0.9 [0.9, 0.9] リモートワーク 0.8 0.2 [0.8, 0.2] ラーメン 0.0 0.0 [0.0, 0.0] ※Embeddingモデルは、この軸を膨大な数(今回は3072個)使って、複雑な言葉の意味を「数字のリスト」として表現しています。 ベクトル化することで、文字がまったく違っても「有給休暇」と「有休」はグラフ上で近い位置にあるということをAIが理解できるようになります。 const vectorizedDoc: number[][] = await embedAndVectorizedContents(LLM_MODEL_EMBEDDED, documents); async function embedContent(model: string, content: string): Promise<EmbedContentResponse> { // モデルに渡したcontentsが数値化されて返される return await ai.models.embedContent({ model, contents: content }); } async function embedAndVectorizedContent(model: string, content: string): Promise<number[]> { // モデルから返された値を後続のコサイン類似度算出で使いやすいように数値配列に変換 return extractValues(await embedContent(model, content)); } async function embedAndVectorizedContents(model: string, contents: string[]): Promise<number[][]> { const res = await Promise.all( contents.map(async (doc) => { return await embedAndVectorizedContent(model, doc); }), ); return res; } --> Gemini Embedding 2のクエリとドキュメント指定について 検索用途の場合、クエリとドキュメントを役割に応じた形式でEmbeddingすることが推奨されています。 たとえば、クエリは task: question answering | query: ... 、ドキュメントは title: ... | text: ... と付与します。 サンプルでは、ベクトル化とコサイン類似度の仕組みに焦点を絞るため、クエリとドキュメントを同じ形式でEmbeddingしています。 実運用では 公式ドキュメント に沿って用途別の形式を使うことで、検索精度の改善が期待できます。 コサイン類似度の考え方(Cosine Similarity) # 2つの文章(ベクトル)の「意味がどれくらい似ているか」を0〜1(または -1 〜1)の数値で判定すること。 テキストがすべて「数字のリスト(矢印)」になったので、プロンプトのベクトルと、ドキュメント側のベクトルの「向きの近さ」を計算できます。 なぜ「距離」ではなく「向き(角度)」なのか 距離で比べた場合: 長文の「有休についての詳しい説明」と、短文の「有休はいつ」というプロンプトは、文章の長さに対応するベクトルの大きさも含めて比べるため、「離れている」と判定される場合があります。 角度で比べた場合: 長さが違っても、テーマが同じなら矢印の向いている「方向」がほぼ一緒になります。 コサイン類似度は、ベクトルの大きさの影響を除き、向きだけを比べる計算です。 1.0に近ければ意味が近く、0.0に近ければ関係ないコンテキストと言えます。 function cosineSimilarity(v1: number[], v2: number[]): number { const dotProduct = v1.reduce((sum, a, idx) => sum + a * v2[idx], 0); const mag1 = Math.sqrt(v1.reduce((sum, a) => sum + a * a, 0)); const mag2 = Math.sqrt(v2.reduce((sum, b) => sum + b * b, 0)); if (!mag1 || !mag2) return 0; return dotProduct / (mag1 * mag2); } 類似度検索で関連情報を絞り込む(Vector Search) # プロンプトのベクトルとドキュメントのベクトルを「コサイン類似度」で比較し、関連度の高いテキストを抽出する処理です。 プロンプトに近しいドキュメントを使って回答生成するため、チャンク粒度によっては、複数のチャンクを扱う必要があります。 「単純にTop n固定で取得する」と、関係のない情報(ノイズ)までLLMに渡してしまい、ハルシネーション(誤情報)や精度の低下に繋がります。 今回は「Top 1のスコアに対する閾値を設けて、その範囲内のn件を取得する」方法でコンテキストを特定する方法を採りました。 これにより、**「関連する情報が複数ある時はまとめて取得し、関連情報が1つしかない時はムダなノイズを混ぜない」**制御ができました。 function selectRelevantContents( scoredContents: ScoredContent[], topN: number, relativeScoreMargin: number, ): ScoredContent[] { const topContents = [...scoredContents] .sort((a, b) => b.score - a.score) .slice(0, topN); // Top1のスコアから相対的にみて関連の深いドキュメントを抽出 const filteredContents = topContents .filter((item) => item.score >= topContents[0].score - relativeScoreMargin); return filteredContents; } 回答生成 # 抽出したテキストを「コンテキスト(背景情報)」としてプロンプトに埋め込み、LLMに回答させる処理です。 const prompt = ` 以下のInstructionおよびコンテキストに基づいて回答してください。 Instruction: ${instruction} コンテキスト(背景情報): ${retrievedContext} 質問: ${query}`; // LLMにコンテキストと質問を投げて、回答を生成 const response = await ai.models.generateContent({ model: LLM_MODEL_GENERATED, contents: prompt, }); console.log(`【LLMの回答】:\n${response.text}`); ミステリーを使ってRAGの挙動を確認してみる # ここからは、サンプルアプリで解説した内容をミステリー小説を題材にして確認します。 RAGのポイントは「何を選ぶか」と「どの情報を渡すか」であり、ミステリーはこの検証に適した題材です。 ミステリー小説(ドキュメント) # 第1章:吹雪の夜 標高1,500メートルに位置する観測所「雪山荘」。12月24日夜、猛烈な吹雪により館は完全な孤立状態となった。当時館内にいたのは、館主の神楽坂、助手の有馬、研究員の黒田、家政婦の白石の4名のみである。 第2章:開かずの密室 翌朝8時、敷地内の鐘楼2階で神楽坂が遺体となって発見された。現場の扉は内側から重厚なボルトで施錠されており、窓もすべて内側から木枠が打ち付けられていた。室内に鍵はなく、神楽坂のポケットに入っていた1本のマスターキーが唯一の鍵だった。 第3章:証言とアリバイ 事件当夜の午前0時(推定死亡時刻)、全員の行動ログが残されている。有馬は1階の無線室で外部との交信を試みており、その通信記録はログサーバーに刻まれていた。家政婦の白石は食堂でずっと明日の朝食の仕込みをしており、防犯カメラにその姿が映っていた。一方、黒田は「自分の部屋で寝ていた」と主張するが、彼を証明する客観的な証拠は何もない。 第4章:現場に残された違和感 鐘楼2階の窓枠には、極めて細い「ピアノ線」の摩擦痕と、わずかな「氷の破片」が残されていた。また、鐘楼の外壁を伝う外排水管には、上から吊り下げられたような擦れ傷が存在していた。 第5章:決定的な証拠 警察の鑑識により、外排水管の擦れ傷から黒田のコートの繊維が検出された。また、黒田の自室のゴミ箱からは、細かく切断された大量のピアノ線の端切れと、外からの吹雪で削られた氷を固めるために使われた保温ボトルの残痕が発見された。これにより、黒田が氷で作った重りとピアノ線、排水管を利用し、屋外から窓の木枠越しに内側のボルトを閉めて密室を作り上げたトリックと、彼が犯人であることが確定した。 探偵の設定(Instruction) # 探偵1人目 探偵の検埼索斗 口調が軽い感じの探偵です。 探偵2人目 ロンドン出身の調査ジャーナリストのEleanor Reed 「Case noted. Moving on.」が決まり文句。 3人目 新米刑事の小室慎一 推理はまだまだだけど、好感の持てるキャラクターです。 実践:質問と回答 # 同じミステリーでも、渡すコンテキストの数や、Instructionの違いによって回答の質や方向性が変わります。 ここでは、その変化を具体例で確認します。 1. 単一コンテキストを使う必要がある質問 # コンテキストを使って回答できるか検証します。 Instruction: 探偵の検埼索斗 質問: 怪しい人物は LLMの回答とコンテキスト利用ログ 実行時、どのように数値化され、どのチャンクを選択したのか見えるように debug info として参考掲載しています。 やあ、検埼索斗だ。ちょいと今回の事件のログを洗わせてもらったぜ。 推理を進めると、状況はこうだな。 まず、有馬は1階の無線室で外部と交信していて、その通信記録がしっかりログに残ってる。お次は家政婦の白石さん、彼女は食堂で朝食の仕込みをしていて、防犯カメラにバッチリその姿が映っていた。 さて、問題はもう一人の黒田クンさ。「自分の部屋で寝てた」なんて主張してるけど、それを裏付ける客観的な証拠は――ゼロ、ナシ、何もなし、と。 つまり、現時点で**アリバイがない人物は黒田**だな。いやはや、怪しい影がプンプンするじゃねえか。 --- debug info --- embedding model: gemini-embedding-2 generated model: gemini-3.5-flash-lite retrieval: topN=2, top1 score margin=0.05 instruction: resources/novel.inspection.txt document: C:\...\developer-site-article-examples\rag-sample\resources\novel.context.txt prompt: C:\...\developer-site-article-examples\rag-sample\resources\novel.prompt.txt query: アリバイがない人物は誰ですか? chunk scores: 1. 第3章:証言とアリバイ: score=0.7098 selected 2. 第1章:吹雪の夜: score=0.6258 excluded 3. 第5章:決定的な証拠: score=0.6219 excluded 4. 第4章:現場に残された違和感: score=0.5941 excluded 5. 第2章:開かずの密室: score=0.5929 excluded アリバイのない「黒田」をコンテキストから導き出して、しっかり回答してくれました。 2. 複数コンテキストを使う必要がある質問 # 関連するコンテキストを使って回答できるか検証します。 Instruction: 探偵の検埼索斗 質問: 密室トリックを解き明かす(伏線が存在する) LLMの回答とコンテキスト利用ログ やあ、検埼索斗だ。ちょいと軽い感じに見えるかもしれないが、事件の推理にかけては任せてくれよな。 さて、今回の「開かずの密室」のからくりさっきまでじっくり洗い出してみたんだけどさ、なかなかよくできたトリックだったわけよ。じゃあ、証拠に基づいてサクッと解説してやるよ。 ヒントと答えをまとめると、ポイントは**「氷」「ピアノ線」「外排水管」**の3つだ。 1. **氷の重り** 外からの吹雪で削られた氷を固めて「重り」を作ったんだ。これがトリックのキモになる部分ね。 2. **ピアノ線** 黒田の部屋のゴミ箱から、細かく切断された大量のピアノ線の端切れが見つかっている。こいつが遠隔操作の糸代わりってわけだ。 3. **外排水管と窓の木枠** 外排水管からは黒田のコートの繊維がバッチリ検出されてる。犯人は屋外から窓の木枠越しにピアノ線と氷の重りを使って、内側のボルトを操作したのさ。 つまりどういうことか? 犯人は、外からピアノ線と氷で作った重りを使って巧妙に内側のボルトを閉め、完璧な密室を作り上げたってわけ。ちなみに、使った氷の重りは時間が経てば勝手に溶けて証拠隠滅できるって寸法よ。いやはや、頭は使うもんだよねぇ。 ま、そういうわけで、今回の事件もスッキリ解決って感じだな! --- debug info --- embedding model: gemini-embedding-2 generated model: gemini-3.5-flash-lite retrieval: topN=2, top1 score margin=0.05 instruction: resources/novel.inspection.txt document: C:\...\developer-site-article-examples\rag-sample\resources\novel.context.txt prompt: C:\...\developer-site-article-examples\rag-sample\resources\novel.prompt.txt query: 密室はどのように作られましたか?仕掛けのヒントを教えてください。 chunk scores: 1. 第5章:決定的な証拠: score=0.7352 selected 2. 第2章:開かずの密室: score=0.7212 selected 3. 第4章:現場に残された違和感: score=0.6732 excluded 4. 第3章:証言とアリバイ: score=0.6551 excluded 5. 第1章:吹雪の夜: score=0.6432 excluded 関連性が高いとして導出された2, 5章の内容をもとに回答が返ってきました。 使い古されたようなトリックですが、しっかり回答してくれました。 3. コンテキストの存在しない質問 # コンテキストが存在しない質問をして、ハルシネーションの発生を検証します。 Instruction: 探偵の検埼索斗 質問: 犯人の動機は(情報が一切ない = RAGのガードレール検証) LLMの回答とコンテキスト利用ログ やあ、検埼索斗だ。軽そうに見えて頭のキレは抜群って評判の、あの探偵さ。 さて、今回の事件、なかなか厄介な「開かずの密室」だな。鐘楼の2階で神楽坂さんが遺体で見つかって、扉も窓も内側からがっちり施錠されていた。部屋には鍵がなくて、神楽坂さんのポケットにあったマスターキーが唯一の鍵だったってわけだ。 ……で、君からの質問は「犯人が神楽坂さんを殺害した『動機』は何ですか?」だな。 ふむ。証拠(コンテキスト)をよーく読んでみたんだけどさ……。 実はこの提供された情報の中には、**犯人がなぜ神楽坂さんを殺害したのかという「動機」については一言も書かれていない**んだよね。 わかるのは、現場が標高1,500メートルの孤立した「雪山荘」で、容疑者が助手の有馬、研究員の黒田、家政婦の白石の3人に絞られているってことと、密室のトリックの状況証拠くらいだ。 動機についてのピースがまだ揃ってない。これじゃあ、いくら名探偵の俺でも推理のしようがないってわけ! もう少し事件の背景や人間関係がわかる証拠を持ってきてくれよ。そしたらこの華麗な頭脳で、犯人の下心まで丸裸にしてあげるからさ! --- debug info --- embedding model: gemini-embedding-2 generated model: gemini-3.5-flash-lite retrieval: topN=2, top1 score margin=0.05 instruction: resources/novel.inspection.txt document: C:\...\developer-site-article-examples\rag-sample\resources\novel.context.txt prompt: C:\...\developer-site-article-examples\rag-sample\resources\novel.prompt.txt query: 犯人が神楽坂さんを殺害した『動機』は何ですか? chunk scores: 1. 第2章:開かずの密室: score=0.7569 selected 2. 第1章:吹雪の夜: score=0.7171 selected 3. 第5章:決定的な証拠: score=0.6835 excluded 4. 第3章:証言とアリバイ: score=0.6819 excluded 5. 第4章:現場に残された違和感: score=0.6435 excluded 今回の小説には「動機」を書いていないため、分からないと回答していますね。 ハルシネーションが防げたように見えますが、これは「証拠以外から推測しないよう指定したInstruction」が効いているのではないかと思われます。 RAGは関連性の低いチャンクを取得する可能性もあるため、根拠のない回答を完全には防げません。 実運用では最低スコアの検索結果棄却や、根拠不足時の回答方針も考慮する必要がありそうです。 コンテキストに関する文章を外して再検証 LLMに渡すコンテキストから、コンテキストを読むように言及する記述を除去してハルシネーションが発生するか検証します。 LLMの回答とコンテキスト利用ログ ふむ……事件の真相に迫る重要な質問だな。 だが、提示されたデータ(コンテキスト)を隅々まで検証したが、神楽坂が殺害された具体的な**『動機』**に関する記述はどこにも見当たらなかった。 分かるのは以下の客観的事実だけだ: * 犯行現場は完全な密室(内側からのボルト施錠と木枠の打ち付け)。 * 被害者である神楽坂のポケットから発見されたマスターキーが唯一の鍵。 * 容疑者は館内にいた有馬、黒田、白石の3名に絞られている。 動機を暴くには、彼らの人間関係や過去の秘密、あるいは遺留品のさらなる調査が必要不可欠だな。……まだ見えていないピースを探すとするか。 --- debug info --- embedding model: gemini-embedding-2 generated model: gemini-3.5-flash-lite retrieval: topN=2, top1 score margin=0.05 instruction: resources/novel.inspection.txt document: C:\...\developer-site-article-examples\rag-sample\resources\novel.context.txt prompt: C:\...\developer-site-article-examples\rag-sample\resources\novel.prompt.txt query: 犯人が神楽坂さんを殺害した『動機』は何ですか? chunk scores: 1. 第2章:開かずの密室: score=0.7569 selected 2. 第1章:吹雪の夜: score=0.7171 selected 3. 第5章:決定的な証拠: score=0.6835 excluded 4. 第3章:証言とアリバイ: score=0.6819 excluded 5. 第4章:現場に残された違和感: score=0.6435 excluded 今回はInstructionおよび共通プロンプトからコンテキスト参照の指示を外した状態で複数回実行しても、動機を創作する回答は発生しませんでした。 モデルが、与えられた情報に動機がないことを認識して回答を保留したと考えられます。 今回確認した範囲では発生しなかっただけであり、ハルシネーションが発生しないことを保証できるわけではありません。 4. 同じ条件で再度質問してみる # 同じInstructionおよび質問を指定した挙動を確認してみます。 Instruction: 探偵の検埼索斗 質問: 密室トリックを解き明かす(伏線が存在する) LLMの回答とコンテキスト利用ログ やあ、検埼索斗だ。ちょいと軽い感じに見えるかもしれないが、謎解きにかけては一級品だぜ。それじゃあ、今回の事件の「開かずの密室」のからくりをサクッと暴いていこうか。 証拠品を並べて論理的に組み立てれば、答えは一つしかないのさ。仕掛けのヒントは以下の通りだ。 1. **氷の重りとピアノ線** 外からの吹雪で削られた氷を固めて「重り」を作り、それに「ピアノ線」を結びつけて使っていたのさ。細かく切断されたピアノ線の端切れや、保温ボトルの残痕が黒田の部屋からしっかり見つかっている。 2. **外排水管と窓の木枠** 外排水管には黒田のコートの繊維がしっかり残っていた。犯人は屋外から、この排水管を利用して窓の木枠越しにアプローチしたんだ。 3. **トリックの全貌** つまりこういうことだ。犯人は**「氷で作った重りとピアノ線、そして外排水管」**を巧妙に利用して、屋外にいながら窓の木枠越しに内側のボルトを閉めたのさ。時間が経てば氷の重りは自然に溶けて証拠も消える……はずが、鑑識の執念と俺の推理の前には隠し通せなかったってわけだ。 完璧なロジックだろ? 事件の真相は、いつだって証拠のなかに眠っているのさ。さて、次の依頼はどこかな? --- debug info --- embedding model: gemini-embedding-2 generated model: gemini-3.5-flash-lite retrieval: topN=2, top1 score margin=0.05 instruction: resources/novel.inspection.txt document: C:\...\developer-site-article-examples\rag-sample\resources\novel.context.txt prompt: C:\...\developer-site-article-examples\rag-sample\resources\novel.prompt.txt query: 密室はどのように作られましたか?仕掛けのヒントを教えてください。 chunk scores: 1. 第5章:決定的な証拠: score=0.7352 selected 2. 第2章:開かずの密室: score=0.7212 selected 3. 第4章:現場に残された違和感: score=0.6732 excluded 4. 第3章:証言とアリバイ: score=0.6551 excluded 5. 第1章:吹雪の夜: score=0.6432 excluded 口調は変わってますが、内容は概ね同じでした。 ただし、この回答に含まれている「氷の重りは自然に溶けて証拠も消える」は、実は小説本文にはない補足です。 根拠に基づく回答を厳密に求める場合は、事実と推測を区別する必要があります。 これも厳密にいえばハルシネーションに該当するのでしょうね。 5. Instructionによる変化を確認 # 今回のサンプルアプリではドキュメントとは別にInstructionを作成し、検索したコンテキストとあわせてLLMに渡しているので、これによる変化を確認してみます。 ロンドン出身の調査ジャーナリストのEleanor Reed LLMの回答とコンテキスト利用ログ Based on the evidence provided in the case files, here is the factual verification regarding the construction of the locked room, clearly separated from any speculation. ### Facts (Based on Evidence) * **The Mechanism:** The suspect, Kuroda, utilized an ice-weight, piano wire, and an exterior drainage pipe to manipulate and secure the heavy interior bolt from the outside through the window frame. * **Physical Evidence (Drainage Pipe):** Forensic analysis by the police detected fibers from Kuroda's coat on the abrasions of the exterior drainage pipe, placing him in direct physical contact with the mechanism used. * **Physical Evidence (Kuroda's Room):** A search of Kuroda's personal waste bin yielded a large quantity of finely cut piano wire ends, alongside residue from a thermal bottle. The latter was used to compact ice, which served as the temporary weight for the trick before melting away. * **Resulting Illusion:** Through this setup, the room—featuring a door bolted from the inside and windows secured by nailed wooden frames—was successfully converted into a closed room ("locked room") from the outside. ### Speculation * Any hypotheses regarding Kuroda's exact motive, his precise psychological state during the execution of the crime, or the timeline of events prior to the discovery of Kagurazaka's body at 8:00 AM remain outside the direct scope of the current forensic findings. Case noted. Moving on. --- debug info --- embedding model: gemini-embedding-2 generated model: gemini-3.5-flash-lite retrieval: topN=2, top1 score margin=0.05 instruction: resources/novel.inspection.txt document: C:\...\developer-site-article-examples\rag-sample\resources\novel.context.txt prompt: C:\...\developer-site-article-examples\rag-sample\resources\novel.prompt.txt query: 密室はどのように作られましたか?仕掛けのヒントを教えてください。 chunk scores: 1. 第5章:決定的な証拠: score=0.7352 selected 2. 第2章:開かずの密室: score=0.7212 selected 3. 第4章:現場に残された違和感: score=0.6732 excluded 4. 第3章:証言とアリバイ: score=0.6551 excluded 5. 第1章:吹雪の夜: score=0.6432 excluded Instruction通り、英語で整然と回答してくれました。 新米刑事の小室慎一 LLMの回答とコンテキスト利用ログ あ、あの……!事件のことで頭がいっぱいで、つい考え込んじゃっていました。僕、新米の小室慎一って言います。どうぞよろしくお願いしますね。 密室の謎についてですね……。みなさんすごく深刻に考えていらっしゃいますけど、僕、黒田さんとお話ししていて、なんだかすごく心が温かくなったんです。あんなに優しくて、丁寧にお茶を入れてくださる方が、冷たいトリックなんて使えるわけがないって、どうしても思ってしまうんですよね。 それに、黒田さんって本当に気配り上手で、僕のくだらない質問にも優しく耳を傾けてくださるんです。「寒いから風邪をひかないようにね」なんて声をかけてもらっちゃって……あ、すみません、つい脱線しちゃいました! ええと、仕掛けのヒントですよね? 鑑識の方たちの話だと、外の排水管のあたりに擦れ傷があって、黒田さんのコートの繊維がちょっとだけ付いていたみたいなんです。それに、お部屋のゴミ箱からピアノ線の細かい切れ端とか、なんだか冷たそうな保温ボトルが見つかったとか……。 でも、きっとこれって、黒田さんが音楽の趣味でピアノ線を大切に使っていただけですし、寒い日に温かい飲み物を持ち歩いていた優しい証拠だと思うんです。 外から窓の木枠越しに、その……氷の重りとピアノ線を使って、内側のボルトを器用にカチャッと閉めちゃったのかなぁなんて、ちょっとだけ推理してみたりして……。 うーん、でもやっぱり、あんなに穏やかな黒田さんが犯人だなんて、僕にはどうしても信じられないんです。もう少しだけ、みんなであたたかいコーヒーでも飲みながら、ゆっくりお話しして確かめてみませんか? きっと何か、別の温かい真相があるはずですよ。 --- debug info --- embedding model: gemini-embedding-2 generated model: gemini-3.5-flash-lite retrieval: topN=2, top1 score margin=0.05 instruction: resources/novel.inspection.txt document: C:\...\developer-site-article-examples\rag-sample\resources\novel.context.txt prompt: C:\...\developer-site-article-examples\rag-sample\resources\novel.prompt.txt query: 密室はどのように作られましたか?仕掛けのヒントを教えてください。 chunk scores: 1. 第5章:決定的な証拠: score=0.7352 selected 2. 第2章:開かずの密室: score=0.7212 selected 3. 第4章:現場に残された違和感: score=0.6732 excluded 4. 第3章:証言とアリバイ: score=0.6551 excluded 5. 第1章:吹雪の夜: score=0.6432 excluded Instruction通り、推理が迷走しました。 まとめ # 自前でRAGを組むことで、以下の原理が明確になりました。 RAGはLLMに知識を記憶させているのではなく、「関連するコンテキストを動的に作って渡しているだけ」。 今回は最小構成でRAGの仕組みを理解することに集中しましたが、実運用ではPostgreSQLのpgvectorやQdrantなどの仕組みを使うことで、より大規模なデータに対応できます。 実装が複雑化しても基本原理は変わらない。 今回は「本質を見えやすくする」ことを目的に、最小構成で作ってみました。 実際にシステムに落とし込むときには、適切なライブラリやストレージを選ぶことが重要です。 まずは素のコードでブラックボックスを排除し、RAGの基本構造を体感してみてはいかがでしょうか。
私はC#が好きなのですが、デリゲートを使ったことがないと気付きました。 昔やったプロジェクトでデリゲートを見たことはありますし、先輩が使っているのを見たこともあります。しかし自分自身が使ったことはなくて、食わず嫌いしていたかもと思えてきました。 そこでデリゲートをコードを書きながら学ぼうと考えたのが今回の記事です。私が書いて動かしたコードで解説していきます。 デリゲート(delegate)とは # デリゲートとは委譲という意味です。 委譲とはコトバンクによると「権利・権限などを他の人・機関に譲って任せること」となっています。つまり自分がやることを他人に任せることです。例えば上司が部下に仕事を振ることなどはいい例でしょう。 https://kotobank.jp/word/%E5%A7%94%E8%AD%B2-432326 ならばプログラミングにおいてはやることは処理です。処理の実行を他の機能に任せるということになります。 しかしこれだけだとなんのこっちゃか分かりません。そこでもう少し具体的に言うと、処理(メソッド)の参照を代入して、値ではなく処理(メソッド)を渡すことで、他の機能(クラス)から処理(メソッド)を実行できるようにするとなります。 例えばある処理が完了したらコールバック処理を呼び出してもらうときに使えます。 事前準備 # この記事のサンプルコードを動かすにはVisual StudioまたはVisual Studio CodeでC#開発ができるようセットアップを行ってください。Visual Studio Codeでのセットアップはこちらの記事を参照してください。 VS Codeで始める!わかる&できるC#開発環境の構築【2025年版マニュアル】 IDEのセットアップができたら、次はコンソールアプリのプロジェクトを作成してください。この記事のサンプルコードは手軽に動作確認するためにコンソールアプリで作っています。 デリゲートの基本をサンプルコードで解説 # デリゲートの基本的な書き方 # 早速ですがコードを書いて動きを確認していきます。 動かせるサンプルコードを掲載します。 DelegateSample というクラスを作り、以下のコードを書いてください。 using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading.Tasks; namespace DelegateApp { internal class DelegateSample { // デリゲートを宣言 // デリゲート型のメソッドを宣言しているようなもの delegate void SampleDelegate(string message); // サンプルの実行 public void ExecSample() { // 宣言したデリゲートを型としてメソッドを引数に渡す // 違和感ある書き方だが、メソッド型のインスタンスを作ってメソッドを代入しているようなイメージ SampleDelegate sampleDelegate = new SampleDelegate(Console.WriteLine); // 試しにメッセージを出してみる // Console.WriteLineを代入したので、デリゲートを実行するとConsole.WriteLineが実行される sampleDelegate("テストメッセージです"); // 次は独自に作ったメソッド(コンソールに文字列を出すだけだが)を代入してみる sampleDelegate = TestMessage; sampleDelegate("カレーとサラダ"); Console.WriteLine(); } void TestMessage(string message) { Console.WriteLine($"今夜の夕食は{message}の予定です。"); } } } そして Program.cs には以下のように書いてください。 using DelegateApp; using DelegateApp.Models; Console.WriteLine("デリゲートを開始します"); DelegateSample delegateSample = new DelegateSample(); delegateSample.ExecSample(); このコードを実行すると、次のようにメッセージが表示されます。 デリゲート初心者には分かりづらいとか違和感があると感じたかもしれません。 デリゲートの宣言は delegate 型のメソッドを定義することです。そして次に宣言したメソッドを型としたインスタンスを作成します。型がメソッドだなんて一瞬意味が分からなく感じますよね。 そして delegate 型のインスタンスにはメソッドを代入できます。メソッドを実行するための仕組みなので、このようになります。メソッドを代入するということ自体が慣れない行為ですよね。 でもここまで見てみると、引数さえ合っていれば、色んなメソッドを代入して実行可能だろうと分かりますね。 複数のメソッドをまとめるマルチキャストデリゲートというものもあります。例えば先ほどの DelegateSample クラスに次のようにコードを追加してみましょう。 using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading.Tasks; namespace DelegateApp { internal class DelegateSample { // デリゲートを宣言 // デリゲート型のメソッドを宣言しているようなもの delegate void SampleDelegate(string message); // サンプルの実行 public void ExecSample() { // 宣言したデリゲートを型としてメソッドを引数に渡す // 違和感ある書き方だが、メソッド型のインスタンスを作ってメソッドを代入しているようなイメージ SampleDelegate sampleDelegate = new SampleDelegate(Console.WriteLine); // 試しにメッセージを出してみる // Console.WriteLineを代入したので、デリゲートを実行するとConsole.WriteLineが実行される sampleDelegate("テストメッセージです"); // 次は独自に作ったメソッド(コンソールに文字列を出すだけだが)を代入してみる sampleDelegate = TestMessage; sampleDelegate("カレーとサラダ"); Console.WriteLine(); // ここから追加 // 実はメソッドの追加が可能 sampleDelegate += TestMessageLunch; sampleDelegate("カレーとサラダ"); Console.WriteLine(); // 追加済みメソッドの削除も可能 sampleDelegate -= TestMessage; sampleDelegate("カレーとサラダ"); // ここまで } void TestMessage(string message) { Console.WriteLine($"今夜の夕食は{message}の予定です。"); } // 昼食用のメソッドを追加 void TestMessageLunch(string message) { Console.WriteLine($"今夜の昼食は{message}でした。"); } } } Program.cs を実行すると以下のようになります。 このコードにはデリゲートに対してメソッドを加算及び減算しています。 なんと delegate 型のインスタンスには複数のメソッドの追加や追加したメソッドの削除ができるのです。 これをマルチキャストデリゲートと呼びます。 デリゲートの進化の歴史を知る # 実は先ほど書いたデリゲートのやり方は昔ながらのやり方でした。それがもう少し進化し、C# 2.0の時代には匿名メソッドが使えるようになりました。 匿名メソッドのサンプルコードを掲載します。 DelegateSampleAnonymous というクラスを作り、以下のコードを書いてください。 using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading.Tasks; namespace DelegateApp { internal class DelegateSampleAnonymous { // デリゲートを宣言 // デリゲート型のメソッドを宣言しているようなもの delegate void SampleDelegate(string message); // サンプルの実行 public void ExecSample() { // 次は独自に作ったメソッド(コンソールに文字列を出すだけだが)を代入してみる // 定義されたメソッドだけでなく、匿名メソッドも代入可能 SampleDelegate sampleDelegate = delegate(string message) { Console.WriteLine($"今夜の夕食は{message}の予定です。"); }; sampleDelegate("カレーとサラダ"); Console.WriteLine(); // 匿名メソッドを追加してみる sampleDelegate += delegate(string message) { Console.WriteLine($"今夜の昼食は{message}でした。"); }; sampleDelegate("カレーとサラダ"); Console.WriteLine(); // ちなみに匿名メソッドで追加すると削除できない // このように一度別のインスタンスに入れてから追加する必要がある SampleDelegate workDelegate = delegate(string message) { Console.WriteLine($"今夜の朝食は{message}でした。"); }; sampleDelegate += workDelegate; sampleDelegate("カレーとサラダ"); Console.WriteLine(); sampleDelegate -= workDelegate; sampleDelegate("カレーとサラダ"); } } } そして Program.cs には以下のように書いてください。 using DelegateApp; using DelegateApp.Models; Console.WriteLine("デリゲートを開始します"); Console.WriteLine(); Console.WriteLine("匿名メソッドのサンプルです"); Console.WriteLine(); DelegateSampleAnonymous delegateSampleAnonymous = new DelegateSampleAnonymous(); delegateSampleAnonymous.ExecSample(); 実行すると以下のようになります。 ここでちょっと厄介なことは、匿名メソッドを使った場合は追加したメソッドの削除ができないことです。どうしても削除したければ、一度インスタンスに入れてから追加する必要があるのです。 上記のサンプルで言うと workDelegate が該当します。 現在はラムダ式を使うのが主流となっています。ラムダ式を使うサンプルコードを掲載します。 DelegateSampleLambda というクラスを作り、以下のコードを書いてください。 using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading.Tasks; namespace DelegateApp { internal class DelegateSampleLambda { // デリゲートを宣言 // デリゲート型のメソッドを宣言しているようなもの delegate void SampleDelegate(string message); // サンプルの実行 public void ExecSample() { // 次は独自に作ったメソッド(コンソールに文字列を出すだけだが)を代入してみる // 定義されたメソッドだけでなく、ラムダ式も代入可能 SampleDelegate sampleDelegate = (string message) => { Console.WriteLine($"今夜の夕食は{message}の予定です。"); }; sampleDelegate("カレーとサラダ"); Console.WriteLine(); // ラムダ式を追加してみる sampleDelegate += (string message) => { Console.WriteLine($"今夜の昼食は{message}でした。"); }; sampleDelegate("カレーとサラダ"); Console.WriteLine(); // ちなみにラムダ式で追加すると削除できない // このように一度別のインスタンスに入れてから追加する必要がある SampleDelegate workDelegate = (string message) => { Console.WriteLine($"今夜の朝食は{message}でした。"); }; sampleDelegate += workDelegate; sampleDelegate("カレーとサラダ"); Console.WriteLine(); sampleDelegate -= workDelegate; sampleDelegate("カレーとサラダ"); } } } そして Program.cs には以下のように書いてください。 using DelegateApp; using DelegateApp.Models; Console.WriteLine("デリゲートを開始します"); Console.WriteLine(); Console.WriteLine("ラムダ式のサンプルです"); Console.WriteLine(); DelegateSampleLambda delegateSampleLambda = new DelegateSampleLambda(); delegateSampleLambda.ExecSample(); 実行すると以下のようになります。 ラムダ式になるともはや delegate という記述すらなくなります。一番シンプルな書き方です。 FuncとActionを使えば宣言不要 # 戻り値がない処理ならAction # ここまでのやり方は delegate 型のメソッドを宣言して、そのインスタンスにメソッドを代入するというものでした。わざわざ宣言しているので、用途別にデリゲートを作成する必要がありました。 そこで汎用型として戻り値がない場合は Action 、戻り値がある場合は Func というものが登場しました。 Action を使ったサンプルコードを掲載します。 DelegateSampleAction というクラスを作り、以下のコードを書いてください。 このようにジェネリックで引数の型を指定します。わざわざ delegate 型のメソッドを宣言する必要がないので、コードもシンプルになります。 using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading.Tasks; namespace DelegateApp { internal class DelegateSampleAction { public void ExecSample() { // Actionデリゲートを使用するサンプル // Actionデリゲートは戻り値がvoidで、引数の型を指定できる Action<string> actionDelegate = (message) => { Console.WriteLine($"今夜の夕食は{message}の予定です。"); }; actionDelegate("カレーとサラダ"); } } } そして Program.cs には以下のように書いてください。 using DelegateApp; using DelegateApp.Models; Console.WriteLine("デリゲートを開始します"); Console.WriteLine(); Console.WriteLine("Actionのサンプルです"); Console.WriteLine(); DelegateSampleAction delegateSampleAction = new DelegateSampleAction(); delegateSampleAction.ExecSample(); 実行すると以下のようになります。 ちなみにメソッドの追加や削除については delegate を使う場合と同様に+や-などの演算子を使ってください。また匿名メソッドやラムダ式では削除ができないことも同様ですので、削除したいときは一度インスタンスに入れてから追加してください。 戻り値がある処理ならFunc # Func を使ったサンプルコードを掲載します。 DelegateSampleFunc というクラスを作り、以下のコードを書いてください。ジェネリックの型指定で、<引数の型, 戻り値の型>というように指定します。 using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading.Tasks; namespace DelegateApp { internal class DelegateSampleFunc { public void ExecSample() { // Actionデリゲートを使用するサンプル // Actionデリゲートは戻り値がvoidで、引数の型を指定できる Func<int, string> funcDelegate = (num) => { return $"入力された値は{num}です。"; }; Console.WriteLine(funcDelegate(5)); } } } そして Program.cs には以下のように書いてください。 using DelegateApp; using DelegateApp.Models; Console.WriteLine("デリゲートを開始します"); Console.WriteLine(); Console.WriteLine("Funcのサンプルです"); Console.WriteLine(); DelegateSampleFunc delegateSampleFunc = new DelegateSampleFunc(); delegateSampleFunc.ExecSample(); 実行すると以下のようになります。 Func もメソッドの追加や削除については delegate を使う場合と同様です。 デリゲートの本当の使い道 # コールバック処理 # コールバック処理のサンプルコードを掲載します。 DelegateSampleAction というクラスを作り、以下のコードを書いてください。 using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading.Tasks; namespace DelegateApp { internal class CallbackSample { // コールバックのサンプル // ここでは、処理が終わった後に呼び出されるメソッドをデリゲートとして渡す // デリゲートを使うことで、処理が終わった後に呼び出されるメソッドを自由に変更できる public void ExecSample(string message, Action<string> callback) { Console.WriteLine($"処理中: {message}"); // 処理が終わった後にコールバックを呼び出す callback?.Invoke($"処理が完了しました: {message}"); } } } そして Program.cs には以下のように書いてください。 using DelegateApp; using DelegateApp.Models; Console.WriteLine("デリゲートを開始します"); Console.WriteLine(); Console.WriteLine("コールバックのサンプルです"); Console.WriteLine(); CallbackSample callbackSample = new CallbackSample(); callbackSample.ExecSample("カレーとサラダ", (result) => { Console.WriteLine(result); }); 実行すると以下のようになります。 このようにメソッドの引数に Func や Action を定義することで処理を受け取ります。そして Invoke メソッドで処理を実行します。 条件に応じた処理の差し替え # 実はLINQの Where は引数が Func になっています。つまり Where メソッドの中ではデリゲートを使っているのです。 では Func の内容次第で結果が異なる例を作ってみましょう。 DelegateSampleLinq というクラスを作り、以下のコードを書いてください。 using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading.Tasks; namespace DelegateApp { internal class DelegateSampleLinq { public void ExecSample() { List<int> numbers = new List<int> { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 }; // まずは偶数だけを抽出してみる var evenNumbers = FilterNumbers(numbers, (num) => num % 2 == 0); Console.WriteLine("偶数のリスト:"); Console.WriteLine(evenNumbers.Count > 0 ? string.Join(", ", evenNumbers) : "該当する値はありません。"); // 続いて奇数を抽出してみる var oddNumbers = FilterNumbers(numbers, (num) => num % 2 != 0); Console.WriteLine("奇数のリスト:"); Console.WriteLine(oddNumbers.Count > 0 ? string.Join(", ", oddNumbers) : "該当する値はありません。"); } /// <summary> /// 引数として渡されたFuncを実行し、条件に一致する値だけを返す /// </summary> /// <param name="numbers"></param> /// <param name="predicate"></param> /// <returns></returns> static List<int> FilterNumbers(List<int> numbers, Func<int, bool> predicate) { List<int> resultList = new List<int>(); foreach (var number in numbers) { if (predicate(number)) { resultList.Add(number); } } return resultList; } } } そして Program.cs には以下のように書いてください。 using DelegateApp; using DelegateApp.Models; Console.WriteLine("デリゲートを開始します"); Console.WriteLine(); Console.WriteLine("条件を切り替えるサンプルです"); Console.WriteLine(); DelegateSampleLinq delegateSampleLinq = new DelegateSampleLinq(); delegateSampleLinq.ExecSample(); 実行すると以下のようになります。 デリゲートの脆弱性を補うためのevent # ここまでデリゲートの使い方について色々と見てきました。 しかし便利な反面、危険も伴います。何せ代入次第で何とでも処理内容を変えられます。また Invoke メソッドによって好きなところで実行が可能です。脆弱な面があることは否めません。 下手すると不具合の元になるのはもちろん、セキュリティ面での脆弱性も怖いところです。 そこで event というキーワードを付けることで、外部クラスからの代入や実行ができなくなります。 event を使ったサンプルコードを掲載します。 DelegateSampleEvent というクラスを作り、以下のコードを書いてください。 using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading.Tasks; namespace DelegateApp { internal class DelegateSampleEvent { // パブリックだから他のクラスからアクセス可能 public event Func<int, string> SampleEvent; } internal class DelegateSampleExternal { public void ExecSample() { DelegateSampleEvent sample = new DelegateSampleEvent(); // 外部クラスからのデリゲートの代入は不可 sample.SampleEvent = (int x) => $"入力された値は{x}です。"; // 外部クラスからのデリゲートの追加と削除は可能 sample.SampleEvent += (int x) => $"入力された値は{x}です。"; Func<int, string> func = (int x) => $"今日はコーヒーを{x}杯飲みました。"; sample.SampleEvent += func; sample.SampleEvent -= func; // 外部クラスからの実行は不可 sample.SampleEvent.Invoke(99); } } } 上記のようにコードを書くと、以下のスクリーンショットのように代入と Invoke メソッドのところでコンパイルエラーが発生します。 ひとまず先ほどのコードでコンパイルエラーが出ている個所はコメントアウトしてください。 フードコートでの注文を例にデリゲートを試す # デリゲートのやり方が分かったところで、現実世界をイメージして練習してみたいと思います。そこでフードコードのスイーツ屋をテーマに扱います。ただ文字の表示や計算をするよりは楽しい内容になるし、委譲をイメージしやすいと考えたためです。 業務フローは以下の図のようになります。 ここではコードをかなり端折って解説します。本当は注文内容だって明細を持てるようにしたり、注文内容も動的に変えられるようにしたりする必要がありますが、今回はそこが主題でないため簡易的なメッセージで済ませます。 それではソースコードを掲載します。 Models というフォルダに作って、その中に以下の5つのクラスを作成してください。 using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading.Tasks; namespace DelegateApp.Models { internal class Buzzer { public int No { get; set; } public Buzzer(int no) { No = no; } public void Ring() { Console.WriteLine($"ピー!ピー!ピー!{No}番のブザーが鳴っています!"); } } } using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading.Tasks; namespace DelegateApp.Models { internal class Cook { public void CookOrder(Order order) { Console.WriteLine($"料理人が注文を受け取りました: {order.OrderContent}"); Console.WriteLine("料理を作っています..."); Console.WriteLine("料理が完成しました!"); } } } using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading.Tasks; namespace DelegateApp.Models { internal class Customer { private Buzzer Buzzer { get; set; } public Order MakeOrder() { Console.WriteLine("お客様が注文しました。"); return new Order("パンケーキとコーヒーのセット"); } public void Pay() { Console.WriteLine("お会計を済ませました。"); } public void PickUpBuzzer(Buzzer buzzer) { Buzzer = buzzer; Console.WriteLine($"お客様が{buzzer.No}番のブザーを受け取りました。"); } public void PickUpFood() { Console.WriteLine($"お客様が{Buzzer.No}番のブザーを渡しました。"); Console.WriteLine("お客様が料理を受け取りました。"); } } } using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading.Tasks; namespace DelegateApp.Models { internal class Order { public string? OrderContent { get; set; } public Order(string? orderContent) { OrderContent = orderContent; } } } using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading.Tasks; namespace DelegateApp.Models { internal class Staff { private delegate void BuzzerRingHandler(); private IDictionary<int, BuzzerRingHandler> _buzzerHandlers = new Dictionary<int, BuzzerRingHandler>(); public void TakeOrder(Order order) { Console.WriteLine($"スタッフが注文を受け取りました: {order.OrderContent}"); Console.WriteLine("お会計は1,500円になります。"); } public Buzzer HandOverBuzzer(int no) { Buzzer buzzer = new Buzzer(no); Console.WriteLine($"スタッフが{no}番のブザーを渡しました。"); _buzzerHandlers.Add(no, buzzer.Ring); return buzzer; } public void RingBuzzer(int no) { if (_buzzerHandlers.TryGetValue(no, out var buzzer)) { buzzer.Invoke(); _buzzerHandlers.Remove(no); } } } } そして Program.cs には以下のように書いてください。 using DelegateApp; using DelegateApp.Models; Console.WriteLine("デリゲートを開始します"); Console.WriteLine(); Console.WriteLine("フードコートで料理が出来たらブザーを鳴らすサンプルです"); Console.WriteLine(); Customer customer = new Customer(); Staff staff = new Staff(); Cook cook = new Cook(); Order order = customer.MakeOrder(); // 注文とお会計 staff.TakeOrder(order); customer.Pay(); // ブザーを渡す int no = 1; Buzzer buzzer = staff.HandOverBuzzer(no); customer.PickUpBuzzer(buzzer); // 調理 cook.CookOrder(order); // ブザーを鳴らして料理を渡す staff.RingBuzzer(no); customer.PickUpFood(); 実行すると以下のようになります。 ポイントとしては店員がどのお客さんに何番のブザーを渡したかを管理しているということです。 プログラムとしては Staff クラスが Dictionary で番号とブザーを鳴らすメソッドの組み合わせを持っています。 こうすることで、料理ができたときに店員が顧客の手元にあるブザーを鳴らせるようにできます。 おわりに # コードを書いて動かしてみることで、デリゲートとは何かが少し分かりました。やっぱり書いて動かしてみるのが一番ですね。 デリゲートが分からないという方はこの記事のフードコートみたいなものを作ってみてください。
はじめに # 本ページは「AIエージェントとシステムをつなぐMCP入門」の続編です。 今回は、認証/認可について説明します。 MCP仕様では認可機能は任意扱いですが、MCPを実運用する場合は必須機能と言えます。 本記事では、以下の内容を扱います。 Streamable HTTPにおけるMCPの認証・認可の流れ PRMと認可サーバーの発見 Keycloakとトークンイントロスペクションを使ったMCPサーバー側の検証 本記事で掲載しているコードは こちら で公開しています。 --> シリーズ目次 連載:AIエージェントとシステムをつなぐMCP入門 イントロダクション stdio実装編 StreamableHTTPステートレス実装編 StreamableHTTPステートフル実装編 プロンプト編 リソース編 認証/認可編(本ページ) 今回使用するライブラリなど # npm@12.0.2 node@26.6.0 @modelcontextprotocol/express@2.0.0 @modelcontextprotocol/node@2.0.0 @modelcontextprotocol/server@2.0.0 typescript@7.0.2 zod@4.4.3 Keycloak 26.3.4 Docker compose --> 今回使用するライブラリについて @modelcontextprotocolのv2がリリースされたので、本記事からはv2を使用します。 併せてNodeおよびTypeScriptも最新のものを使用します。 MCPモジュールについて v1では @modelcontextprotocol/sdk を使用していました。 v2でモジュールが分割されたため、 @modelcontextprotocol/server, express, node を使用します。 MCP仕様:認証/認可 # トランスポート別の認証アプローチ # トランスポート(通信方式)により、認証の実装指針が異なります。 StreamableHTTP 認可は任意(OPTIONAL)となっていますが、実装する場合はMCPの認可仕様に準拠しなければなりません。 stdio(標準入出力) MCP仕様で定義されている認証仕様の対象外です。 認証情報(APIキーなど)はサブプロセス起動時に実行環境から直接取得して利用する形になります。 その他 そのプロトコルで確立されたベストプラクティスに準拠します。 準拠規格 # セキュリティと相互運用性を確保しつつ簡素化を図るため、以下の標準規格のサブセットが採用されています。 OAuth 2.1 (IETF Draft) OAuth 2.0 Bearer Token Usage (RFC 6750) OAuth 2.0 Authorization Server Metadata (RFC 8414) OAuth 2.0 Dynamic Client Registration Protocol (RFC 7591) Resource Indicators for OAuth 2.0 (RFC 8707) OAuth 2.0 Protected Resource Metadata (RFC 9728) OAuth Client ID Metadata Documents (CIMD) ロール # 以下のロールが定義されています。 MCPサーバー(OAuth 2.1リソースサーバー): アクセストークンを受け入れ、保護されたリソースへのリクエストに応答する。 MCPクライアント(OAuth 2.1クライアント): リソース所有者に代わって保護されたリソースへリクエストを行う。 認可サーバー: ユーザーと対話し、アクセストークンを発行する。MCPサーバーと同一でも、独立した形で構成してもよい。 認証フロー # MCPの公式ドキュメントに掲載されている「Authorization Flow Steps」を一部和訳して転載。 --> Information MCP仕様では、クライアントがPRMと認可サーバーメタデータを取得し、PKCEを利用した認可コードフロー(Authorization Code Flow)でトークンを取得します。 今回の検証コードでは主にリソースサーバーとしての動作検証を目的とし、クライアント側のブラウザ認証フローは省略しています。 Keycloakで事前に取得したアクセストークンを使い、MCPサーバーがBearerトークンを受け取る。 Keycloakの Introspection endpoint でトークンの有効性、Audience、Scopeを検証する。 sequenceDiagram autonumber participant B as User-Agent (Browser) participant C as MCPクライアント participant M as MCPサーバー participant A as 認可サーバー Note over C: 認可サーバーの発見 C->>M: MCPリクエスト(トークンなし) M->>C: 401 Unauthorized(WWW-Authenticate header) Note over C: リソースメタデータの発見 C->>M: PRM(Protected Resource Metadata)リクエスト M->>C: PRM C->>A: 認可サーバーメタデータ取得 A-->>C: 認可サーバーメタデータ Note over C: クライアント登録 alt Client ID Metadata Documents A->>C: Fetch metadata from client_id URL C-->>A: JSON metadata document else Dynamic client registration C->>A: POST /register A->>C: Client Credentials else Pre-registered client Note over C: Use existing client_id end C->>B: Open browser with authorization URL + code_challenge + resource B->>A: Authorization request with resource parameter A->>B: Redirect to callback with authorization code + iss B->>C: Authorization code callback C->>A: Token request + code_verifier + resource A->>C: Access token (+ refresh token) C->>M: MCPへリクエスト(トークンあり) M-->>C: MCPからレスポンス 認可サーバーの発見 # MCPクライアントは、以下の手順で認可サーバーの場所と機能を特定する。 リソースメタデータの発見(RFC 9728) クライアントは 401 Unauthorized レスポンスを受け取った際、 resource_metadata (リソースメタデータ)を取得しなければなりません。 優先順位 WWW-Authenticate ヘッダーの resource_metadata パラメーターに示されたURL MCPサーバーエンドポイント配下のWell-Known URI(e.g. /.well-known/oauth-protected-resource/path/to/mcp ) ドメインルートのWell-Known URI(e.g. /.well-known/oauth-protected-resource ) 認可サーバーメタデータの取得(RFC 8414) クライアントは、リソースメタデータから得られたIssuer(発行者)URLの形式に応じて認可サーバーのエンドポイントを特定します。 優先順位 パスコンポーネントがある場合(例:/tenant1) OAuth 2.0方式(e.g. /.well-known/oauth-authorization-server/tenant1 ) OIDC方式パス挿入(e.g. /.well-known/openid-configuration/tenant1 ) OIDC方式パス付与(e.g. tenant1/.well-known/openid-configuration ) パスコンポーネントがない場合 OAuth 2.0方式(e.g. /.well-known/oauth-authorization-server ) OIDC方式(e.g. /.well-known/openid-configuration ) 厳格な検証ルール メタデータドキュメント内のissuer値は、Well-Known URLの構築に使用した発行者識別子と完全に一致しなければならない。 不一致の場合は、クライアントはそのメタデータを使用不可とする。 --> Information 検証コードでは、「パスコンポーネントあり」の「OAuth 2.0方式」を使用しています。 クライアント登録 # 認証フローを開始する前に、クライアントはクライアントIDを取得する必要があります。 登録メカニズムと優先順位 事前登録(Pre-registration): クライアントとサーバー間に既存の関係がある場合、静的な認証情報を使用する。 Client ID Metadata Documents (CIMD): クライアントとサーバー間に事前関係がない場合、HTTPS URLをクライアントIDとして使用する。 動的クライアント登録 (Dynamic Client Registration): CIMD未対応サーバーとの後方互換性のために維持されていますが非推奨なので、基本的に選択しない。 ユーザー入力: 他の手段がない場合、ユーザーに情報を入力させる。 --> Information 検証コードでは、Keycloakに事前登録した静的な認証情報を使用しています。(Pre-registrationに相当) 検証コードの解説 # 検証コード仕様 # 下記のような検証コードを使って説明します。 Keycloakに依存する処理は、keycloak.tsに集約する形で構成しています。 項目 内容 備考 トランスポート Streamable HTTP(Stateless) 認可サーバー Keycloak 8081ポートで公開 MCPエンドポイント POST /mcp Bearer認証必須 トークン検証 トークンイントロスペクション(Keycloakの Introspection endpoint ) 認証エラー 401 Unauthorized + WWW-Authenticate トークン無効または期待するAudienceと不一致の場合にスローします 認可エラー 403 Forbidden + WWW-Authenticate トークンは有効でも、スコープが不足している場合にスローします PRM GET /.well-known/oauth-protected-resource/mcp 今回の検証ではやらないこと ブラウザの認可画面 PKCEパラメーターの生成 認可コードを受け取るcallback 認可コードからトークンへの交換 CIMD Dynamic Client Registration Keycloakの設定 # Docker composeで起動する際、同梱しているRealm設定を使って自動生成しています。 これにより、後述する動作検証に必要なRealmはすべて設定された状態でKeycloakが起動されます。 realm: mcp-demo Client Scope: mcp:tools : aud= http://localhost:3000/mcp mcp:no-scope : aud= http://localhost:3000/mcp mcp:diff-audience : aud= http://localhost:3000/mcp-diff Client Introspection用: mcp-server 正常系トークン取得用: mcp-demo-client ( mcp:tools を付与) Scope不足検証用: mcp-demo-no-scope-client ( mcp:no-scope を付与) Audienceなし検証用: mcp-demo-no-audience-client Audience不一致検証用: mcp-demo-diff-audience-client ( mcp:diff-audience を付与) --> Information Audience: どのリソースサーバー向けのトークンか Scope: リソースサーバーで何を実行できるか PRMの公開 # MCPクライアントが認可サーバーを発見するためのPRMの公開。公開は mcpAuthMetadataRouter が担います。 クライアントが認証なしでMCPエンドポイントへアクセスした場合、 WWW-Authenticate ヘッダーにもPRMのURLが含まれます。クライアントはそのURLからPRMを取得し、認可サーバーの場所を知ることができます。 app.use( mcpAuthMetadataRouter({ oauthMetadata, resourceServerUrl: mcpServerUrl, scopesSupported: [REQUIRED_SCOPE], resourceName: "MCP Auth Streamable HTTP", }), ); PRM例 { "resource": "http://localhost:3000/mcp", "authorization_servers": ["http://localhost:8081/realms/mcp-demo"], "scopes_supported": ["mcp:tools"], "resource_name": "MCP Auth Streamable HTTP" } resource : 保護対象であるMCPサーバーの識別子 authorization_servers : 認可サーバーのIssuer URL scopes_supported : MCPサーバーが利用するScopeの候補 resource_name : リソースの表示名 Bearer認証ミドルウェア # MCPエンドポイントの前段でBearer認証しています。 requireBearerAuth は、MCPエンドポイント開始前に実行されるミドルウェアです。 authMiddleware でエラーが検出された場合は、そこで処理を中断するため、以降の処理は実行されません。 index.ts const authMiddleware = requireBearerAuth({ // ※1 verifier: tokenVerifier, // ※2 requiredScopes: [REQUIRED_SCOPE], resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl), }); //omit app.post(MCP_PATH, authMiddleware, async (req, res) => { const server = createServer(); // authMiddlewareでエラーが発生した場合、ここは実行されません // omit ※1: トークン検証(詳細な検証内容は後述します) ※2: スコープ検証 トークンが指定されたスコープを持っているか検証します。 トークンが持つスコープと、requiredScopesに設定したスコープを requireBearerAuth が比較します。 Keycloak Introspectionによるトークン検証 # MCPサーバーが受け取ったアクセストークンは、Keycloakの Introspection endpoint で検証しています。 ここで重要なのは、HTTP 200だけではトークンが有効だと判断できない点です。 keycloak.ts export function createTokenVerifier(mcpServerUrl: URL): OAuthTokenVerifier { return { verifyAccessToken: async (token) => { // Keycloakのイントロスペクションでトークン検証 ※1 const params = new URLSearchParams({token, client_id: OAUTH_CLIENT_ID}); if (OAUTH_CLIENT_SECRET) params.set("client_secret", OAUTH_CLIENT_SECRET); const response = await fetch(KEYCLOAK_INTROSPECTION_ENDPOINT, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: params.toString(), }); // 200 以外を返した場合 ※2 if (!response.ok) { const text = await response.text().catch(() => ""); throw new OAuthError(OAuthErrorCode.InvalidToken, `Invalid or expired token: ${text}`); } // omit // トークンが無効または期限切れの場合 ※3 if (!data.active) throw new OAuthError(OAuthErrorCode.InvalidToken, "Inactive token"); ※1 token: 検証対象のトークン OAUTH_CLIENT_ID , OAUTH_CLIENT_SECRET : MCPクライアントの情報ではなく、MCPサーバーがKeycloakにIntrospectionを依頼するための認証情報 ※2, 3 200以外が返された場合は、無効または期限切れとみなして InvalidToken を返します。 200が返されても active が false の場合、トークンが無効なため、同様に InvalidToken を返します。 Audienceの検証 # トークンに設定されている aud は「トークンが、どのリソースサーバー向けに発行されたか」を表します。 トークンが有効でも、別のリソース向けに発行されたトークンを受け入れないようにAudienceを検証しています。 keycloak.ts // Audience (aud) クレームの検証 if (OAUTH_STRICT) { // OAUTH_STRICTを有効にしている場合、検証を実行します // ※1 if (!data.aud) throw new OAuthError(OAuthErrorCode.InvalidToken, "Resource indicator (aud) missing"); const audiences = Array.isArray(data.aud) ? data.aud : [data.aud]; const allowed = audiences.some((audience) => checkResourceAllowed({ requestedResource: audience, configuredResource: mcpServerUrl }), ); // ※2 if (!allowed) throw new OAuthError(OAuthErrorCode.InvalidToken, `Expected audience compatible with ${mcpServerUrl}, got: ${audiences.join(",")}`); } ※1 audが存在しない場合は、リソースインジケーターが欠落している旨を返します ※2 audが許可されているものと一致しない場合は、期待するAudienceと一致しない旨を返します 検証コードを使った動作検証 # 基本フロー(認可フローの流れを検証します) Authorizationヘッダー未設定でリクエスト(MCP Inspector) Authorizationヘッダー未設定でリクエスト(curl) PRM(Protected Resource Metadata)リクエスト 有効なトークンを使って正常アクセス 例外フロー Authorizationヘッダーに形式違いの値を設定してリクエスト Authorizationヘッダーに無効なトークンを設定してリクエスト スコープ不足のクライアントでリクエスト Audience不足のクライアントでリクエスト 異なるAudienceが設定されているクライアントでリクエスト 1. Authorizationヘッダー未設定でリクエスト(MCP Inspector) # Authorizationヘッダー未設定の状態で、MCP InspectorからMCPサーバーへ接続します。 UI上のポップアップメッセージ OAuth Authorization Failed Policy 'Allowed Client Scopes' rejected request to client-registration service. Details: Not Permitted to use specified clientScope コンソールに出力されるログ Error from MCP server: StreamableHTTPError: Streamable HTTP error: Error POSTing to endpoint: {"error":"invalid_token","error_description":"Missing Authorization header"} at StreamableHTTPClientTransport.send (file:///xxx/node_modules/@modelcontextprotocol/sdk/dist/esm/client/streamableHttp.js:364:23) at process.processTicksAndRejections (node:internal/process/task_queues:104:5) { code: 401 } レスポンスヘッダーが確認できなかったので、curlで再検証。 2. Authorizationヘッダー未設定でリクエスト(curlで再検証) # 先ほどと同じくAuthorizationヘッダー未設定の状態で、curlを使って tools/list を呼び出します。 curl -i -X POST http://localhost:3000/mcp \ -H "Accept: application/json, text/event-stream" \ -H "Content-Type: application/json" \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' HTTP/1.1 401 Unauthorized WWW-Authenticate: Bearer error="invalid_token", error_description="Missing Authorization header", scope="mcp:tools", resource_metadata="http://localhost:3000/.well-known/oauth-protected-resource/mcp" 結果 HTTPステータスとして 401 Unauthorized が返されました。 error には、「トークン無効」と示されています。 error_description には、「Authorizationヘッダーが見つからない」と示されています。 resource_metadata には、PRMエンドポイントのURLが設定されています。 3. PRM(Protected Resource Metadata)の確認 # resource_metadata に設定されていたURLにアクセスし、認証に必要なメタデータを確認します。 curl -s http://localhost:3000/.well-known/oauth-protected-resource/mcp { "resource":"http://localhost:3000/mcp", "authorization_servers":["http://localhost:8081/realms/mcp-demo"], "scopes_supported":["mcp:tools"], "resource_name":"MCP Auth Streamable HTTP" } MCP Inspectorの場合 結果 PRMが返されました。 resource: 保護対象リソースを識別するURL authorization_servers: 認可サーバーのIssuer URL scopes_supported: 利用可能な認可スコープ 4. 有効なトークンを使って正常アクセス # 認証して有効なトークンを取得してから、 tools/list を呼び出します。 トークン取得 Keycloakからアクセストークンを取得します。 curl -s -X POST http://localhost:8081/realms/mcp-demo/protocol/openid-connect/token \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "grant_type=client_credentials" \ -d "client_id=mcp-demo-client" \ -d "client_secret=mcp-demo-client-secret" { "access_token":"<valid_token>", "expires_in":300, "refresh_expires_in":0, "token_type":"Bearer", "not-before-policy":0, "scope":"mcp:tools" } MCPサーバーへアクセス Keycloakから取得した access_token を使って、MCPへアクセスします。 curl -s -X POST http://localhost:3000/mcp \ -H "Accept: application/json, text/event-stream" \ -H "Authorization: Bearer <valid_token>" \ -H "Content-Type: application/json" \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' event: message data: {"result":{"tools":[{"name":"sum_numbers","title":"sum_numbers","description":"Sum two numbers","inputSchema":{"type":"object","$schema":"https://json-schema.org/draft/2020-12/schema","properties":{"a":{"type":"number","description":"first number"},"b":{"type":"number","description":"second number"}},"required":["a","b"]},"outputSchema":{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"result":{"type":"number","description":"sum result"}},"required":["result"],"additionalProperties":false}},{"name":"get_server_policy","title":"get_server_policy","description":"Return simple authorization policy for demo","inputSchema":{"type":"object","properties":{}},"outputSchema":{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"resource":{"type":"string","description":"resource server url"},"requiredScope":{"type":"string","description":"required scope"}},"required":["resource","requiredScope"],"additionalProperties":false}}]},"jsonrpc":"2.0","id":1} 結果 正常にアクセスでき、 tools/list の結果が確認できました。 --> Information このレスポンスには 2026-07-28 RC で対応したJSONスキーマ(2020-12)の $schema も含まれています。 RCの変更内容については、 MCP 2026-07-28 RC解説 で説明しています。 MCP Inspectorで接続した場合 5.1. Authorizationヘッダーに形式違いの値を設定してリクエスト # Authorizationヘッダーに「形式不正のトークン( Bearer から始まらない値)」を設定してリクエストしてきたケースを想定した例外処理の確認。 curl -i -X POST http://localhost:3000/mcp \ -H "Accept: application/json, text/event-stream" \ -H "Content-Type: application/json" \ -H "Authorization: bad_format_token" \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' HTTP/1.1 401 Unauthorized WWW-Authenticate: Bearer error="invalid_token", error_description="Invalid Authorization header format, expected 'Bearer TOKEN'", scope="mcp:tools", resource_metadata="http://localhost:3000/.well-known/oauth-protected-resource/mcp" { "error":"invalid_token", "error_description":"Invalid Authorization header format, expected 'Bearer TOKEN'" } 結果 HTTPステータスとして 401 Unauthorized が返されました。 error_description には、「無効な形式である」と示されています。 5.2. Authorizationヘッダーに無効なトークンを設定してリクエスト # Authorizationヘッダーに形式は正しいが「存在しないトークン」を指定してリクエストしてきたケースを想定した例外処理の確認。 curl -i -X POST http://localhost:3000/mcp \ -H "Accept: application/json, text/event-stream" \ -H "Content-Type: application/json" \ -H "Authorization: Bearer unknown_token" \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' HTTP/1.1 401 Unauthorized WWW-Authenticate: Bearer error="invalid_token", error_description="Inactive token", scope="mcp:tools", resource_metadata="http://localhost:3000/.well-known/oauth-protected-resource/mcp" { "error":"invalid_token", "error_description":"Inactive token" } 結果 HTTPステータスとして 401 Unauthorized が返されました。 error_description には、「トークンが無効である」と示されています。 5.3. スコープ不足のクライアントでリクエスト # 「権限不足のトークン(必要なクライアントスコープが設定されていないクライアントで認証)」を使ってリクエストしていたケースを想定した例外処理の確認。 トークン取得 curl -s -X POST http://localhost:8081/realms/mcp-demo/protocol/openid-connect/token \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "grant_type=client_credentials" \ -d "client_id=mcp-demo-no-scope-client" \ -d "client_secret=mcp-demo-no-scope-client-secret" {"access_token":"<valid_token>","expires_in":300,"refresh_expires_in":0,"token_type":"Bearer","not-before-policy":0,"scope":"mcp:no-scope"} MCPサーバーへアクセス curl -i -X POST http://localhost:3000/mcp \ -H "Accept: application/json, text/event-stream" \ -H "Authorization: Bearer <valid_token>" \ -H "Content-Type: application/json" \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' HTTP/1.1 403 Forbidden WWW-Authenticate: Bearer error="insufficient_scope", error_description="Insufficient scope", scope="mcp:tools", resource_metadata="http://localhost:3000/.well-known/oauth-protected-resource/mcp" {"error":"insufficient_scope","error_description":"Insufficient scope"} 結果 HTTPステータスとして 403 Forbidden が返されました。 error には、「スコープ不足」と示されています。 error_description には、「スコープ不足」と示されています。 5.4. Audience設定なしのクライアントでリクエスト # 「期待するAudienceが設定されてないトークン(必要なAudienceが設定されていないクライアントで認証)」を使ってリクエストしてきたケースを想定した例外処理の確認。 トークン取得 curl -s -X POST http://localhost:8081/realms/mcp-demo/protocol/openid-connect/token \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "grant_type=client_credentials" \ -d "client_id=mcp-demo-no-audience-client" \ -d "client_secret=mcp-demo-no-audience-client-secret" {"access_token":"<valid_token>","expires_in":300,"refresh_expires_in":0,"token_type":"Bearer","not-before-policy":0,"scope":""} MCPサーバーへアクセス curl -i -X POST http://localhost:3000/mcp \ -H "Accept: application/json, text/event-stream" \ -H "Authorization: Bearer <valid_token>" \ -H "Content-Type: application/json" \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' HTTP/1.1 401 Unauthorized WWW-Authenticate: Bearer error="invalid_token", error_description="Resource indicator (aud) missing", scope="mcp:tools", resource_metadata="http://localhost:3000/.well-known/oauth-protected-resource/mcp" {"error":"invalid_token","error_description":"Resource indicator (aud) missing"} 結果 HTTPステータスとして 401 Unauthorized が返されました。 error_description には、「リソースインジケーターが見つからない」旨が示されています。 5.5. 異なるAudienceが設定されているクライアントでリクエスト # 「別リソース向けのトークン(期待されるAudienceとは異なる値を設定したクライアントで認証)」を使ってリクエストしてきたケースを想定した例外処理の確認。 トークン取得 curl -s -X POST http://localhost:8081/realms/mcp-demo/protocol/openid-connect/token \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "grant_type=client_credentials" \ -d "client_id=mcp-demo-diff-audience-client" \ -d "client_secret=mcp-demo-diff-audience-client-secret" {"access_token":"<valid_token>","expires_in":300,"refresh_expires_in":0,"token_type":"Bearer","not-before-policy":0,"scope":"mcp:diff-audience"} MCPサーバーへアクセス curl -i -X POST http://localhost:3000/mcp \ -H "Accept: application/json, text/event-stream" \ -H "Authorization: Bearer <valid_token>" \ -H "Content-Type: application/json" \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' HTTP/1.1 401 Unauthorized WWW-Authenticate: Bearer error="invalid_token", error_description="Expected audience compatible with http://localhost:3000/mcp, got: http://localhost:3000/mcp-diff", scope="mcp:tools", resource_metadata="http://localhost:3000/.well-known/oauth-protected-resource/mcp" {"error":"invalid_token","error_description":"Expected audience compatible with http://localhost:3000/mcp, got: http://localhost:3000/mcp-diff"} 結果 HTTPステータスとして 401 Unauthorized が返されました。 error_description には、「期待しているAudienceが異なる」旨が示されています。 まとめ # Streamable HTTPでは、MCP仕様に基づく認証・認可の仕組みを利用できます。 MCPクライアントは、 401 レスポンスの WWW-Authenticate ヘッダーやPRMから認可サーバーを発見し、アクセストークンを取得します。 MCPサーバーは、受け取ったBearerトークンを検証し、保護対象リソースへのアクセスを許可または拒否します。 今回の検証コードでは、Keycloakとトークンイントロスペクションを使い、MCPサーバー側の認証・認可処理を確認しました。 Authorizationヘッダーの未設定や形式不正、無効なトークン、Audienceの不備を401として拒否し、Scope不足を403として拒否できることも確認しました。 実際にMCPサーバーを公開する際は、利用者やクライアントごとに必要なScopeを定義し、保護するリソースに応じたAudienceを検証することが重要です。
はじめに # GFXBenchというGPUベンチマークソフトをご存じでしょうか。 Windows, Linux, Android, iPhone等のマルチプラットフォームで実行出来るkishonti社のGPUベンチマークソフトです。 GFXBench - unified graphics benchmark based on DXBenchmark (DirectX) and GLBenchmark (OpenGL ES) 今まで新しいAndroid端末を買ってはAndroidのストアからダウンロードしてベンチマークを行ったものでした。 今回取り上げるきっかけとなった流れです。 久々に新しいAndroidタブレットを購入 ストアからGFXBenchを探すと見つからない? 過去Android端末にインストール済のGFXBenchもサーバに接続出来ず起動しない? たまたまメンテナンス等でタイミングが悪いのかと1か月位待っても状況変わらず 流石におかしいと思い調べたところサービス終了、GitHubでオープンソース化されていた事が発覚! オープンソース化は2025年末の出来事の様です。 KISHONTI Milestones - our History GFXBench and CompuBench are shutting down after 21 years, source code and toplist database move to GitHub 単なるベンチマークソフト以上に筆者には思い入れがありました。 無料のダウンロード版ではなく商用のソースコード版を自社製品の基板(not Android)に移植しベンチマークを行う、という業務を行った事があったからです。 GFXBenchの中にはいくつかベンチマークの種類がありますが、T-Rex/Manhattanあたりの時代の事でした。 (ベンチマーク種類については次回の実行編で触れます) アーリーアクセスのソースコードだと移植性も良くなく、かなり手を入れて移植しデバッグしたものでした...。 それがGitHubでオープンソース化です。 GitHub - Kishonti-Opensource-gfxbench 時代の流れを感じたり当時の事を思い出したりでしんみりしてしまいますが、今までの活動やオープンソース化された事に感謝しつつビルドを行い実行してみようと思います。 今回の目標としては以下とします。 GFXBenchをソースコードからビルドしてAndroid端末で実行出来るノウハウを修得する ビルド環境:Windows11 ビルド対象:Android用、かつ筆者のAndroid端末コレクションの関係上古いAndroidバージョンでも実行出来る事が望ましい それにより、既存の実行出来なくなった実行バイナリの代替としてだけではなく、実験でソースコードを修正したくなった時も気軽にローカルで試せる様にする 対象者は以下とします。 Androidアプリ開発経験あり Android Studio、sdkmanager、adbコマンドを扱える人 Android端末を開発者モードに出来る人 コマンドライン操作、パッチ、環境変数に戸惑わない人 Graphics API (今回は OpenGL ES) に詳しいとより楽しむ事が可能 ビルド環境準備 # GitHub GFXBench公式のAndroid用ビルド手順は以下にあります(以下"公式手順"として参照します)。 doc/gfxbench_gl_android_build.txt この内容に従ってビルド環境を構築していきます。 以下、公式手順を引用し、項目ごと見ていきます。 ./sdkmanager "ndk;28.0.12674087" "build-tools;35.0.0" "platforms;android-35" "platform-tools" "cmdline-tools;latest" 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 - Android NDK 28.0.12674087 (tested version), GCC is no longer supported http://developer.android.com/sdk/ndk/index.html - Android SDK, API Level 35 (recommended versions, store version built with API 35) http://developer.android.com/sdk/index.html この内容に従って、sdkmanagerで上記をダウンロードします。 今回はAndroidStudio上からsdkmanagerのGUIを起動して各バージョンを指定してダウンロードしました。 各インストール場所を後に環境変数で指定する事になります。 - On Windows suggested shell is Git Bash (Cygwin nor WSL is not supported) https://git-scm.com/downloads Git Bashをインストールします。ビルドの際のコマンドライン操作もGit Bash上で行います。 - CMake 3.5.0 or newer (tested with CMake 3.21) http://www.cmake.org/cmake/resources/software.html - Swig 3.0.12 or 4.0.2 (tested with 3.0.12 and 4.0.2) http://swig.org/download.html - Java Development Kit (e.g.: openjdk17) それぞれインストールします。今回使用したバージョンはそれぞれ以下です。3.21, 4.0.2, openjdk17 JDKはインストール場所を後に環境変数で指定する事になります。 - Add cmake and swigwin applications to `PATH` (on Windows) cmakeとswigwinは指定通り環境変数 PATH に加えます。 ここから 公式手順へのプラス分 です。 - pythonインストール 公式手順には記載ありませんが、pythonがビルドに必要となりますのでインストールします。 pythonへ言及のある他プラットフォーム用インストールにおいてもversionについては記載はなく不明ですが、今回は version 3.14.5 を使用しました。 また、Windows11ではpythonコマンドからWindowsストアのインストーラにエイリアスが貼られているため、それも外しておきます。 Windows11の  設定 > アプリ > アプリの詳細設定 > アプリ実行エイリアス から以下の2つをオフにします。 アプリインストーラー python.exe アプリインストーラー python3.exe - CUDA Toolkitインストール 同じく記載ありませんが、CUDA Toolkitがビルドに必要となりますのでインストールします。 Android版なのにCUDA?となりますが、 frameworks/cudaw というフレームワークでプラットフォーム共通に使用されているからの様です。 使われ方もベンチマーク本体ではなく情報収集のためだけの様です。dlopenでCUDAライブラリがロードされ直接リンクされない仕組みのため、Android版であってもビルドエラーや不正な実行になる事もありません(ヘッダファイルがあればいいレベル)。 であればビルド環境やソースコードを修正して外してしまってもいいのですが、今回は修正点を少なくしたかったのでそのままにしています。 (後述の様に若干の修正は必要にはなります) 今回はビルドに使用したPCインストール済の v10.2 を使用しました。 ソースコード準備 # 以降Git Bashにて操作します。 入手 # GitHubのリポジトリからgit cloneします。 git clone https://github.com/Kishonti-Opensource/gfxbench.git 必要なストレージサイズの目安は筆者の環境では以下でした。 ビルド前:6.5GB台 ビルド後:18GB台 修正 # 公式手順へのプラス分 です。 以下の様にCUDAヘッダパスの設定を修正します。 GitHub workflow手順 .github/workflows/main.yml ではビルド環境はLinuxの様で、今回のWindowsビルド環境のための修正です。 またWindows環境でコピーコマンドの影響なしにコピー出来る様にも修正しています。 CUDAインストールディレクトリ名は各自の環境に合わせて適宜変更してください。 --> Warning 以下のパッチは筆者の環境における一例であり、適用は 自己責任 にてお願いいたします。(ライセンス等の詳細は 記事末尾 に記載しています) 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) ビルド実行 # 再び公式手順に戻りビルドを行います。 環境変数設定 # git cloneしたソースのトップディレクトリで以下の環境変数をセットします。 公式手順へのプラス/修正分 分は以下です。 JAVA_HOMEの追加 NDKバージョンを前述の通り28.0.12674087に修正 各ディレクトリ名は各自の環境に合わせて適宜変更してください。 (sdkmanagerでインストールした分はデフォルトなら以下のディレクトリ名になっていると思います) export JAVA_HOME="/c/Tools/jdk-17.0.18+8" export ANDROID_NDK="$HOME/AppData/Local/Android/Sdk/ndk/28.0.12674087/" export ANDROID_HOME="$HOME/AppData/Local/Android/Sdk" export NG_CMAKE_TOOLCHAIN_FILE="$HOME/AppData/Local/Android/Sdk/ndk/28.0.12674087/build/cmake/android.toolchain.cmake" export CMAKE_MAKE_PROGRAM="$HOME/AppData/Local/Android/Sdk/ndk/28.0.12674087/prebuilt/windows-x86_64/bin/make.exe" export WORKSPACE=$PWD export PLATFORM=android-arm64-v8a export CONFIG=Release export APPLICATION_TYPE=gui なお、筆者の環境 Git BashでUTF-8使用 + openjdk17 の場合、JDKのコマンド出力が文字化けしたのですが、以下の設定で回避出来ました。 export JAVA_TOOL_OPTIONS=-Dfile.encoding=UTF-8 ビルドスクリプト実行 # 公式手順通り以下のスクリプトを実行します。 scripts/build-3rdparty.sh scripts/build.sh ビルドが成功すると、ビルドのコンソールログで表示されるディレクトリに gfxbench-5.1.5+corporate.apk ファイルが出来上がります。 サイズは筆者の環境では以下でした。 apkサイズ:1.2GB台 Android端末で起動後のサイズ:3.4GB台 ※インストール、起動方法については次回の実行編にて ※起動後のサイズが増えるのは、初回起動時apk内にバンドルされたデータがアプリデータ領域に展開されるため --> Information apk内にデータをバンドルさせない手順もあります(環境変数 BUNDLE_DATA=false)。 この場合のapkサイズは 21MB台 となります。 ただしその場合は手動でデータをアプリデータ領域に配置する必要があるのでバンドルさせる通常手順のままが便利です。 出来上がったapkファイルの各バージョン情報は以下です。 platformBuildVersionCode='35' compileSdkVersion='35' minSdkVersion:'21' targetSdkVersion:'35' minSdkVersion:'21' (Android5.0) なので古いAndroid端末でもインストール出来、動作する事も期待されます。 --> Information このminSdkVersionはGFXBenchが要求する一番リッチなGraphics、OpenGL ES 3.1 + AEP に対応が始まったversionとなります。 なのでこのminSdkVersionからフルにGFXBenchを楽しむ事が出来ると期待されます。 (このminSdkVersionの値の理由はこれでは?とも思います) 他のビルドバリエーション # 今回は一番少ない修正案、および公式の記載準拠でビルドを行いましたが、以下のビルドのバリエーションも考えられます。 次々回以降で取り上げたいと思います。 (筆者の目標としてはここからが本番とも言えます) ユニバーサルAPKビルド # 今回は PLATFORM=android-arm64-v8a でAndroidプラットフォームを固定する手順でしたが、複数プラットフォームを含むユニバーサルAPKを作成する手順も存在する様です。 scripts/build-multiarch-apk.sh 特に古いAndroid端末での実行を考えると android-armv7a も必要になってくるので両対応させたい場合に便利と思われます。 古いAndroid端末用ビルド # minSdkVersion:'21' (Android5.0) なので古いAndroid端末でもインストール出来、動作する事も期待されます。 と書いたのですが、実際に試すと修正が必要となりました。 実行時にAndroid端末上でエラーが発生します。 おそらくAndroid8より古い場合にNGと思われます。 developer版ビルド # APPLICATION_TYPE=gui としてビルドしましたが、 developer としてビルドする手順もあります。ただこれも実際に試すと修正が必要となりました。 ビルド時にエラーが発生します。 developer版らしく以下の恩恵があります。 apk内にデータをバンドルさせない形式必須で、データは別途adb pushする形式となる adb push分データのサイズは 2.2GB台 程度。更に必要ベンチマーク分(ディレクトリ単位)で絞れる可能性もあり アプリのGUIが gui 版とは変わりapkファイル自体も 4MB台 と小さくなる アプリを修正してはインストールし直すというサイクルが楽になる 思わぬ発見もあるかもしれません :-) なお、公式手順の最後に Legacy install process for developer app (not supported) から始まる特殊な手順があります。 その中で 4.4.4_r1(SdkVersion:'19')と更に古いAndroidバージョンに対応出来る様に見える旨があるのですが、これは対象外とします。 おわりに # 今回はGFXBenchというベンチマークについて取り上げるきっかけのお話とビルドまでを行いました。 次回は実際に実行しベンチマークスコアを測ってみます。 ライセンスおよび免責事項 # 本記事に掲載しているビルド修正パッチ、引用しているビルド手順は、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)のものであり、その正確性や安全性を保証するものではありません。 パッチの適用やビルドの実行により生じた直接的・間接的な損害について、筆者および株式会社豆蔵は一切の責任を負いません。内容を十分にご確認の上、ご自身の責任においてご利用ください。
はじめに # 先月、GitHub のスタック PRs (Stacked pull requests) がパブリックプレビューになりました。 https://github.blog/changelog/2026-07-30-stacked-pull-requests-are-now-in-public-preview/ これまでも PR のブランチからブランチを生やして、PR スタックさせることは可能でした。小さい PR をスタックすることで、1つの PR は小さくレビューしやすくなりますが、スタックのメンテナンスにコストがかかります。 スタック PRs でどこまで効率がアップするのか、見ていきたいと思います。 概念 # PR を作るとき、ある修正 A を前提に 修正 B を入れたいケースで、未マージの A ブランチから B ブランチを作成して作業するということはよくあります。 1つ目の PR は base を main へ 2つ目以降は、直前の PR ブランチを base にする gitGraph commit id: "Initial commit" branch feature-1/setup commit id: "Add skeleton" branch feature-2/logic commit id: "Add logic" branch feature-3/docs commit id: "Add docs" checkout main merge feature-1/setup merge feature-2/logic merge feature-3/docs 小さい PR をスタックさせることで、1つ1つの PR の規模が小さくなり、レビューも楽になるというメリットがあります。しかし、PR をスタックさせると、PR 説明にベースブランチを書いたり、中間のスタックで発生した変更を上位のスタックのブランチにリベースして反映するという作業が頻発します。 スタック PRs は「特定のブランチを起点にした一連の PR スタック」に纏わる煩雑な作業を大幅に削減してくれます。 検証環境とドキュメント # 本記事では、以下のバージョンで試しています。 GitHub CLI (gh): 2.98.0 gh-stack プラグイン: 0.1.0 GitHub CLI をインストールし、 gh auth login は済ませておきましょう。以下のコマンドでスタック PRs のプラグインがインストールされます。 gh extension install github/gh-stack 公式ドキュメントの概要説明とクイックスタートは以下のページです。 https://docs.github.com/en/pull-requests/get-started/about-stacked-prs https://docs.github.com/en/pull-requests/get-started/stacked-prs-quickstart スタック PR のコマンドリファレンスは以下のページにあります。 https://docs.github.com/en/pull-requests/reference/stacked-prs-cli-commands サンプルのリポジトリ構成 # 簡単なリポジトリを作って検証していきます。 main ブランチには README.md だけを置きました。この時点でリモートに push してしまって OK です。 . └── README.md feature/01-setup ブランチ。ソースコードやドキュメントを追加しました。 . ├── README.md ├── docs │ └── notes.md └── src └── hello.py feature/02-add-logic ブランチ。ドキュメントとソースコードを追加しています。 ├── README.md ├── docs │ ├── notes.md │ └── stacked-pr.md └── src ├── calculator.py └── hello.py feature/03-add-docs ブランチ。ドキュメントを追加しています。 . ├── README.md ├── docs │ ├── notes.md │ ├── stacked-pr.md │ └── usage.md └── src ├── calculator.py └── hello.py スタックの初期化 # 以下を実行して、3 本のブランチを 1 つのスタックとして初期化しました。 $ gh stack init --base main feature/01-setup feature/02-add-logic feature/03-add-docs スタックが作成されました。 ? Enable git rerere to remember conflict resolutions? (Y/n) ✓ Adopted 3 branches: main ← feature/01-setup ← feature/02-add-logic ← feature/03-add-docs You're on feature/03-add-docs (top of stack). What's next: • see the full stack: gh stack view • move between branches: gh stack switch • link these PRs into a Stack on GitHub: gh stack submit ローカルのブランチ関係は main を起点にした 3 段のスタックとして認識されました。 スタックを確認します。 gh stack view スタックを構成する各ブランチと変更内容が一覧できます。 ▶ feature/03-add-docs (current) +61 -12 │ ▾ 2 files changed │ README.md +58 -12 │ docs/usage.md +3 -0 │ ▸ 2 commits │ ├ feature/02-add-logic +6 -0 │ ▸ 2 files changed │ ▸ 1 commit │ ├ feature/01-setup +4 -0 │ ▸ 2 files changed │ ▸ 1 commit │ └ main --> Information この例では、3つのブランチを init で一括追加しましたが、単独のブランチを追加する場合は、 gh stack add でブランチをスタックに追加可能です。ただし、これはブランチ作成とスタックへの追加を同時にやるコマンドです。既存のブランチを追加する方法は現時点では提供されてないようです。 gh stack add <branch> --> Information 今回、まだ GitHub のリポジトリを作っていなかったので、作成後に以下のコマンドで、origin を設定しました。初回は、スタックの土台になる main ブランチを先に push しておくのがポイントです。既存のリポジトリからクローンしている場合は不要です。 git remote add origin https://github.com/kondoumh/stacked-pr-demo.git gh stack submit を使って、リモートにスタックの情報を反映していきます。TUI 画面が開いて各 PR の title と description を編集できます。あわせて ready for Review と Draft のどちらの状態で作成するのかも選べます。 スタックの最上位を選択すると、画面右下に SUBMIT 3 PRs というボタンが表示されます。 このボタンは、マウスでクリック可能です。クリックするとリモートリポジトリに対して反映が開始されます。 gh stack submit Checking stack state... Pushing to origin... ✓ Created PR #5 for feature/01-setup ✓ Created PR #6 for feature/02-add-logic ✓ Created PR #7 for feature/03-add-docs ✓ Stack created on GitHub with 3 PRs (stack #8) ✓ Pushed and synced 3 branches Web UI で確認すると、3つのブランチとそれに対応する PR が作られていることが分かります。 --> Information PR が #5 から始まっていますが、これは一度スタックが不整合になってやり直したからです。 stack のクリーンアップは以下のように unstack で可能です。 # ローカル gh stack unstack --local # origin 側 gh stack unstack スタックの2番目の PR の画面です。マージの UI が通常の PR と異なり、スタック構造が表示されています。 ブランチのリベース # PR をスタックして作業しているときよくあるのが、途中のブランチに変更を入れ、上位のブランチをリベースするという作業です。スタック内のブランチで作業してリベースを試してみましょう。 feature/02-add-logic で calculator.py にロジックを追加しました。(既存の add に multiply を追加) @@ -1,2 +1,5 @@ def add(a: int, b: int) -> int: return a + b + +def multiply(a: int, b: int) -> int: + return a * b git status On branch feature/02-add-logic Changes to be committed: (use "git restore --staged <file>..." to unstage) modified: src/calculator.py コミットします。 git commit -m 'add logic to calculator.py' スタックの上位である feature/03-add-docs にはこの変更がまだ反映されていません。従来ですと、feature/03-add-docs をチェックアウトし、git コマンドでリベースする必要がありました。スタック PRs の場合、作業したブランチの上位スタックに変更を反映させるには、以下のようにするだけです。 gh stack rebase --upstack feature/02-add-logic の内容が、リベースの連鎖により波及していきます。 ✓ Fetched latest main from origin ✓ Trunk main is already up to date Stack detected: (main) <- feature/01-setup <- feature/02-add-logic <- feature/03-add-docs Rebasing branches in order, starting from feature/02-add-logic to feature/03-add-docs ✓ Rebased feature/02-add-logic onto feature/01-setup ✓ Rebased feature/03-add-docs onto feature/02-add-logic All upstack branches from feature/02-add-logic rebased locally with main (c137e85) To push up your changes, run `gh stack push` feature/03-add-docs をチェックアウトして git log を見ると、先ほどの calculator.py の変更コミットの上に、変更がリベースされているのが分かります。 commit 8279783de1fb932c4754b59cb01ad69cf7f7c3da (HEAD -> feature/03-add-docs) Author: Copilot <copilot@example.com> Date: Sun Aug 23 12:51:14 2026 +0900 Add workflow commit 3b0f02f3523811819e0eaffda2c28f6443f8ff28 Author: Copilot <copilot@example.com> Date: Wed Aug 5 23:00:29 2026 +0900 Document stacked PR workflow commit 177241ca3fdd9c99462dc90b40f1072600e8fd3f (feature/02-add-logic) Author: Copilot <copilot@example.com> Date: Sun Aug 23 15:45:01 2026 +0900 add logic to calculator.py # 計算ロジック追加のコミット --> Information --upstack オプションをつけなければ、main からすべてのブランチに必要なリベースをやってくれます。 gh stack rebase また、波及の途中でコンフリクトが発生すると処理が一時停止します。対象のファイルを修正して git add した後、gh stack rebase --continue を打てば再開できます。gh stack rebase --abort でスタック全体を元の状態に巻き戻せます。 リベースを含む変更をリモートに反映するには、 gh stack push を実行します。 $ gh stack push Pushing 3 branches to origin... ✓ Pushed 3 branches Run `gh stack view` to see your stack of PRs PR にもちゃんと反映されています。 スタック PRs のマージ # スタックのマージは、任意の階層で可能です。2番目のスタックの PR 画面でマージボタンを押してみます。 スタックの順にマージされました。 最上位のスタックの PR 画面です。自動的にベースブランチが main に切り替わっていました。 --> Information main への切り替えは少しラグがあるようです。マージ済みのブランチのままマージボタンが押せる状態でした。試してはいませんが、万が一マージしてしまっても、revert して、ベースブランチを手動で main にすれば OK だと思います。 事故らないためには、マージ後にヘッドブランチを自動削除する(Automatically delete head branches)」を有効にしておいた方がいいでしょう。 マージキューとの組み合わせ # 今回試せていませんが、マージキューと組み合わせると、マージ作業自体からも解放されます。スタック PR(main ← PR1 ← PR2 ← PR3)で一番怖い事故は、順序の逆転や依存関係が壊れることです。 マージキューなし: 「まず PR1 が承認されてマージされたか確認して、次に PR2 の CI が通るのを待ってからマージボタンを押して…」と、人間が順番を見張る必要があります。 マージキューあり: 承認された PR をキューに放り込んでおけば、システム側が依存関係の順序を守って、自動的に検証・マージを順番に処理してくれます。 これにより、「途中の PR で main が壊れるリスク」を完全にゼロにできます。 開発体験としては、レビュー完了=即「手放し」ができ、レビューアから Approve をもらったら、「とりあえずキューに追加して自分は次のタスクに行く」というムーブが可能になります。 --> Information かなり前ですが、マージキューの紹介記事もあります。 /blogs/2023/02/15/github-pr-merge-queue/ さいごに # AI がコードを書いてくれることにより、PR 作成は爆速になり、1つの PR に含まれるファイルも多くなっています。人のレビューが滞留してしまうという現象があちこちの現場で起きていると思います。 PR が巨大になる理由として、開発者の心理的な側面もあります。あるタスクをやっていると、タスクに関係ない変更も入れちゃったりしがちです。スタックさせることは可能ですが、スタックに纏わる作業が面倒なので、まとめて作ってしまうためです。 スタック PRs が使える環境であれば、「あ、この修正は元の PR のスコープ超えてるな。」と思ったら gh stack add で新しいブランチをスタックに積めばいいのです。 システムがスタックの管理を肩代わりすることで、「小さく作って、小さくレビューしてもらう」というベストプラクティスを自然に維持できるようになります。 人力によるスタック PR と 公式スタック PRs の違いをまとめておきます。 比較項目 従来の手動スタック (main ← PR1 ← PR2) 公式 Stacked PR サポート スタックの可視化 説明欄に手動で「#100 に依存」などと書く必要があった PR 画面上でスタックの全体像・前後関係が UI として表示される 親 PR マージ時の挙動 親がマージされた後、子のベースブランチを手動で main に向け直す必要があった 親 PR がマージされると、子 PR のベースブランチが自動的に更新される 親 PR 修正時の追従 親 PR にレビュー指摘でコミット追加や rebase が入ると、子 PR での rebase --onto が地獄 スタック内の変更伝搬や差分管理がGitHub 上の導線で扱いやすくサポートされる レビューアの負担 「どこからどこまでがこの PR 自体の差分か」を見失いやすい スタックの文脈を保ったまま、PR ごとの純粋な差分だけに集中してレビューできる GA になって、いろんな現場に普及するのが待ち遠しい機能ですね。