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

TECH PLAY

株式会社豆蔵

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

全152件

はじめに # 豆蔵の GitHub オーガニゼーション(mamezou-tech) では GitHub Actions 用の Action である setup-helmfile を公開しています。 https://github.com/marketplace/actions/setup-helmfile 複数の Helm chart をまとめてインストールする helmfile を GitHub Actions ワークフローの中で使えるようにする Action です。 --> Information setup-helmfile は以下の記事でも紹介しています。 https://developer.mamezou-tech.com/oss-intro/setup-helmfile/ setup-helmfile はデフォルトの挙動として Helm plugin である helm-diff, helm-s3 をインストールします。 GitHub Actions Runner の helm CLI が v4系に移行 # 先日、PR を取り込んだりしてリリース作業をしているときに、GitHub Actions Runner にプリインストールされている helm CLI を使用するテストケースでエラーが発生しました。 setup-helmfile は利用する helm CLI のバージョンを v3系に固定しており、利用者が install-helm: no を指定した時のみ、helm CLI のインストールをスキップし Runner にインストールされた helm CLI を使用します。 今回、 install-helm: no でインストールするテストケースでプラグインのインストールが失敗していました。これは、Runner にプリインストールされた helm CLI が v4系に移行していたからだと判明しました。 Helm v4で導入されたプラグイン検証 # Helm v4 で plugin install コマンドに --verify フラグが追加され、かつデフォルトが有効 (true) に設定されています。これによりプラグインインストール時には PGP 署名(provenance ファイル)の検証がデフォルトで実行されます。これはサプライチェーンセキュリティを担保するための Helm 開発コミュニティの施策です。 メジャーなプラグインは、メンテナがいずれ署名対応するため、通常通り helm plugin install すれば検証が動いて無事インストールできると考えられます。 しかし、署名が普及するまでの過渡期や、あまりメンテナンスされていないプラグインについては、 verify を無効化にしてもインストールしたい局面が出てきます。 利用するプラグインのバージョンを固定した上で verify を無効化して凌ぐ期間がしばらく続くと思われます。 # helm v4 時代の「あるある」コマンド(検証スキップ + バージョン固定) helm plugin install <URL> --version <TAG> --verify=false setup-helmfile での対応 # 既存ユーザーの CI を壊さないために、デフォルトの helm バージョンはまだ v3 系に PIN (固定)しています。 プラグインインストール時は、 --verify=false でのインストールを実行し、 unknown flag: verify のエラーが発生したら、verify オプションなしでインストールするようにしています。 async function installHelmPlugin(plugin) { let stderr = ""; const exitCode = await exec.exec(`helm plugin install ${plugin} --verify=false`, [], { ignoreReturnCode: true, listeners: { stderr: (data) => { stderr += data.toString(); }, }, }); if (exitCode === 0) { return; } if (/unknown flag:\s*--verify/.test(stderr)) { // 不明なフラグのエラーが出た時だけ、フラグなしでリトライ await exec.exec(`helm plugin install ${plugin}`); return; } // それ以外のエラーではリトライせずエラーをスロー throw new Error(`helm plugin install ${plugin} failed:\n${stderr}`); } こうしておくことで、Helm 4系が主流になっても、コードを変えることなくデフォルトバージョンを上げるだけで済みます。 さいごに # GitHub Actions Runner (ubuntu-latest) も最近 Ubuntu 26.04 にアップデートされ、各種ソフトウェアもバージョンアップされています。Helm のエコシステムもセキュアな方向にシフトしつつあります。 setup-helmfile もその流れに合わせてメンテナンスを続けています。
 この記事を読んでいただきありがとうございます。アジャイルグループに所属する、藤井智弘です。  前編では、BMAD Method(以下、BMAD)を「AIいいなり」を心配する立場から試し、その評価を書きました。後編は、その実験を 手元で再現するための手順書 です。  扱うのは前編と同じく、分析と計画の段階(「何を作るか」が決まる手前)までです。コードを書かせる手順は扱いません。 この記事でやること # 開発コンテナを用意し、その中に Claude Code と BMAD を入れる 会話と文書を日本語にし、呼び名を決める 実験1:要望の一文だけを渡し、AIだけで製品概要と要求仕様(PRD)を作らせる 実験2:AIの問いに人が答えながら、製品概要を作る 2つの結果を並べて、「人が確かめるべきこと」を読み取る  手順は、2026年11月時点の BMAD v6.12.4(安定版) と Claude Code で確かめました。BMADは版の変化が速く、プレビュー版のV7ではコマンドの体系そのものが違います。版は固定して試してください。 1. 準備するもの # 項目 今回使ったもの OS macOS コンテナの実行環境 OrbStack(Docker Desktop でも可) 開発コンテナを動かす道具 VS Code の Dev Containers 拡張機能、または devcontainer CLI Claude のアカウント Claude のサブスクリプション(Claude Code が使えるもの)  Node.js、Python、uv、Claude Code は、すべてコンテナの中に入れます。  ただし、手元の Mac にも ~/.claude フォルダと ~/.claude.json ファイルが必要です(次の手順で、この2つをコンテナと共有するためです)。どちらもない場合は、先に Mac で Claude Code を一度起動しておいてください。devcontainer CLI を使う場合は、 npm i -g @devcontainers/cli で入れておきます。 2. 開発コンテナを用意する #  BMADは npx でインストールし、Python のスクリプトも使います。試すたびに手元の環境が汚れないよう、開発コンテナの中で動かします。作業用のフォルダに .devcontainer/ を作り、次の2つのファイルを置きます。 .devcontainer/Dockerfile FROM mcr.microsoft.com/devcontainers/typescript-node:22 # Node.js 22(BMAD v6.12 は 20.12 以上が必要)、git、zsh が入っている # Claude Code をコンテナ内に入れる RUN npm install -g @anthropic-ai/claude-code USER node .devcontainer/devcontainer.json { "name": "bmad-lab", "build": { "dockerfile": "Dockerfile" }, "features": { "ghcr.io/devcontainers/features/python:1": { "version": "3.12" }, "ghcr.io/va-h/devcontainers-features/uv:1": {} }, "remoteUser": "node", "mounts": [ "source=${localEnv:HOME}/.claude,target=/home/node/.claude,type=bind", "source=${localEnv:HOME}/.claude.json,target=/home/node/.claude.json,type=bind" ], "customizations": { "vscode": { "extensions": ["anthropic.claude-code"], "settings": { "terminal.integrated.defaultProfile.linux": "zsh" } } } }  ポイントは mounts です。手元の ~/.claude をコンテナと共有しておくと、Claude Code のログイン情報がコンテナの外に残ります。コンテナを作り直しても、ログインし直さずに済みます。  VS Code でこのフォルダを開き、「Reopen in Container」を選ぶとコンテナが起動します。ターミナルから起動する場合は、次のとおりです。 devcontainer up --workspace-folder . devcontainer exec --workspace-folder . zsh # コンテナの中のシェルに入る  最初の起動では Python を組み立てるため、数分かかります。以降の手順は、すべてコンテナの中のターミナルで行います。 3. Claude Code にログインする #  コンテナの中で claude を起動し、 /login と入力して「Claude account with subscription」を選びます。つまずきやすい点が2つあります。 URLのコピーが途中で切れる :コンテナからはブラウザを開けないので、ログイン用のURLが画面に表示されます。とても長く、画面の幅で折り返されるため、選択してコピーすると途中で切れがちです。切れたURLを開くと「無効な OAuth リクエストです」と表示されます。ターミナルを十分に広げてURLを1行に収めてからコピーしてください コードを別の画面に貼ってしまう :承認後にブラウザに表示されるコードは、URLを発行した Claude Code の画面でしか使えません。別のターミナルの Claude Code に貼ると「That code wasn't accepted」と表示されます  「Login successful」と出たら完了です。続けて聞かれるフォルダの信頼では「Yes, I trust this folder」を選びます。既定の「No, exit」のまま Enter を押すと、Claude Code が終了してしまいます。 4. BMADをインストールする #  作業用のフォルダ(ここでは trials/support-site )を作り、BMADを入れます。対話式なら次のコマンドで、AIツールに Claude Code、モジュールに bmm(BMad Method)を選びます。 mkdir -p trials/support-site && cd trials/support-site npx bmad-method@6.12 install  毎回同じ設定で入れたい場合は、選択肢をオプションで渡せます。 npx -y bmad-method@6.12 install --yes --tools claude-code --modules bmm \ --communication-language Japanese --document-output-language Japanese \ --directory "$PWD"  インストールが終わると、フォルダの中に次のものができます。 場所 中身 .claude/skills/bmad-* Claude Code から呼び出すスキル(BMADの各役割と手順の指示書) _bmad/ 設定とスクリプト。 _bmad/custom/ は利用者が上書きするための場所 _bmad-output/ 成果物の置き場所。計画の文書は planning-artifacts/ に入る 5. 会話と文書を日本語にし、呼び名を決める #  インストール時に --communication-language Japanese を指定していれば、BMADが実際に読む設定は日本語になります。ただし、紛らわしい点が2つあります。 インストーラーが書き出す基本の設定ファイル( _bmad/config.toml )を開くと、会話の言語が English と書かれています。実際に使われる値は別のファイルから決まるので、これだけを見て慌てる必要はありません エージェントがあなたを呼ぶ名前は、既定ではOSのユーザー名になります。開発コンテナの中では「Node」と呼ばれます  基本の設定ファイルは、再インストールのたびに上書きされるので、直接は書き換えません。代わりに、上書き用の _bmad/custom/config.toml の末尾に、次を足して固定しておくと安心です。 [core] user_name = "Tom" communication_language = "Japanese" document_output_language = "Japanese" project_name = "support-site"   project_name は、成果物のフォルダ名に使われます。実際に使われる設定は、次のコマンドで確かめられます。 uv run _bmad/scripts/resolve_config.py --project-root .  出力の先頭が、次のようになっていれば成功です。 { "core": { "user_name": "Tom", "communication_language": "Japanese", "project_name": "support-site", "document_output_language": "Japanese", "output_folder": "_bmad-output" },  最後に Claude Code を起動して /bmad-help を呼び、BMADのスキルの案内が返ってくれば、準備は完了です。 手順4と5を、1本のスクリプトで #  前提の確認、インストール、設定の固定、確認までを1本にまとめると、次のようになります。開発コンテナの中で bash setup_bmad.sh trials/support-site Tom のように実行します(2つ目の引数が呼び名です)。新しいフォルダで最後まで通ることを確かめてあります。 #!/usr/bin/env bash # BMAD v6.12 の実験環境をつくる(開発コンテナの中で実行) # bash setup_bmad.sh [作業フォルダ] [呼び名] # 例:bash setup_bmad.sh trials/support-site Tom set -euo pipefail DIR="${1:-trials/support-site}" NAME="${2:-User}" BMAD_VERSION="${BMAD_VERSION:-6.12}" echo "== 前提の確認" ok=1 check() { if command -v "$1" >/dev/null 2>&1; then echo " ✓ $1 $("$1" --version 2>/dev/null | head -1)" else echo " ✗ $1 が見つかりません"; ok=0 fi } check node; check python3; check uv; check git; check claude [ "$ok" = 1 ] || { echo "足りないものを入れてから、もう一度実行してください"; exit 1; } echo "== BMAD v${BMAD_VERSION} をインストール:$DIR" mkdir -p "$DIR" npx -y "bmad-method@${BMAD_VERSION}" install --yes --tools claude-code --modules bmm \ --communication-language Japanese --document-output-language Japanese \ --directory "$(cd "$DIR" && pwd)" echo "== 会話と文書を日本語に固定(_bmad/custom/config.toml)" cd "$DIR" if ! grep -q '^\[core\]' _bmad/custom/config.toml 2>/dev/null; then cat >> _bmad/custom/config.toml <<EOF [core] user_name = "${NAME}" communication_language = "Japanese" document_output_language = "Japanese" project_name = "$(basename "$PWD")" EOF fi echo "== 設定の確認" uv run _bmad/scripts/resolve_config.py --project-root . \ | grep -E '"(user_name|communication_language|document_output_language|project_name)"' echo "完了。次は:cd $DIR && claude を起動し、/bmad-help を呼んでください" 6. 今回使うスキル #  v6.12.4 には30ほどのスキルがあります。分析と計画の段階で、今回使うのは次のものです。 スキル 役割 何をするか 成果物 bmad-help 案内 いまの状態を見て、次に使うスキルを勧める なし bmad-product-brief アナリスト 対話で要望を引き出し、製品概要を作る briefs/…/brief.md bmad-prd PM 製品概要などから要求仕様を作る prds/…/prd.md bmad-prfaq アナリスト 完成時のプレスリリースから逆算して構想を試す(製品概要の代わり) PRFAQ bmad-advanced-elicitation 随時 プレモーテムなどの手法で、下書きをさらに叩く 改訂した下書き  どのスキルも、成果物と一緒に記録ファイル( .memlog.md )を作り、決めたこと・推測したこと・取り下げたことを一行ずつ残します。 7. 実験1:AIだけで通す(ヘッドレス) #  製品概要とPRDのスキルには、人に質問せず、手元の情報と推測で文書を仕上げる進め方(ヘッドレス)があります。これを使って、「人が何も答えなかったら、どんな文書ができるか」を見ます。  Claude Code を -p (一回だけ実行して終わる形)で起動し、スキルを呼び出します。 claude -p --dangerously-skip-permissions \ '/bmad-product-brief ヘッドレスで実行してください(headless: true、intent: create)。 入力はこの一文だけです。ほかの情報はありません。 「当社製品の利用者向けのサポートサイトを作り、顧客の利便性を最大化したい。」 人に質問せず、スキルの Headless Mode の指示に従って製品概要を完成させ、最後に JSON の状態を返してください。' \ < /dev/null > headless_brief.log 2>&1   --dangerously-skip-permissions は、ファイルの書き込みやコマンドの実行を、確認なしに許す指定です。人に聞かずに最後まで走らせるために付けていますが、使ってよいのは、今回のように 使い捨てのコンテナの中だけ にしてください。  今回は約6分で終わり、次の3つのファイルができました。 _bmad-output/planning-artifacts/briefs/brief-support-site-2026-11-09/ ├── brief.md 製品概要 ├── addendum.md 製品概要に入りきらない材料(調査の要点など) └── .memlog.md 決定・推測・調査結果の記録  フォルダ名の日付は、コンテナの時刻(協定世界時)で付きます。日本時間の朝に実行すると、前日の日付になります。   次の実験と混ざらないよう、ここでフォルダの名前を変えておきます。 実験2の製品概要も同じ日付のフォルダ名を使おうとするからです。 mv _bmad-output/planning-artifacts/briefs/brief-support-site-2026-11-09 \ _bmad-output/planning-artifacts/briefs/brief-support-site-headless  続けて、この製品概要からPRDを作らせます。 B=_bmad-output/planning-artifacts/briefs/brief-support-site-headless claude -p --dangerously-skip-permissions \ "/bmad-prd ヘッドレスで実行してください(headless: true、intent: create)。 入力は製品概要 $B/brief.md と $B/addendum.md だけです。人に質問せず、スキルの references/headless.md の指示に従って PRD を完成させ、最後に JSON の状態を返してください。" \ < /dev/null > headless_prd.log 2>&1  今回は約13分かかり、586行のPRDができました。 prds/prd-support-site-…/ には、PRDと記録ファイルのほかに、スキル自身が書いたレビュー( review-rubric.md 、 review-polish-*.md )と、入力の製品概要との突き合わせの結果( reconcile-brief.md など)も残ります。  ログの最後には、JSONで状態が返ってきます。ここがいちばん大事な出力です(以下は抜粋)。 { "status": "partial", "intent": "create", "open_questions": [ "★Q1 製品は何か(業務ソフトウェア/消費者向け/機器)。利用者の人数と職種", "Q2 現状の問い合わせ件数・内訳・解決までの時間(目標値と最初の記事の選び方が決まる)", "★Q3 既存の FAQ・マニュアル・問い合わせ窓口は何で、誰が管理しているか。問い合わせ窓口から何を取り込めるか", "…(全10件)" ], "assumptions": [ "製品は業務ソフトウェアで、利用者は製品の専門家ではない業務担当者", "…(全12件)" ] }   status が complete (完了)ではなく partial (人の確認が要る)になっていること、そして open_questions (人が答えるべき問い)と assumptions (推測で置いた前提)が並んでいることを確かめてください。 AIだけで作った文書の価値は、本文よりも、この2つの一覧にあります。 実験1を、1本のスクリプトで #  製品概要、フォルダ名の変更、PRDまでを1本にまとめたものです。お題を変えたいときは、 REQUEST="(あなたの要望)" bash run_experiment1.sh のように、要望の一文を差し替えて実行します。ログは trials/support-site/logs/ に残ります。実行するのは、 trials/ がある作業フォルダの最上位からです。  やり直すときは、先に古い brief-support-site-headless フォルダを消すか名前を変えてください。残したままだと、新しい製品概要がその中に入れ子で移されてしまいます。 #!/usr/bin/env bash # 実験1:要望の一文だけを渡し、AIだけ(ヘッドレス)で製品概要とPRDを作らせる # 開発コンテナの中で実行:bash run_experiment1.sh [作業フォルダ] # お題を変えるとき:REQUEST="(あなたの要望)" bash run_experiment1.sh set -euo pipefail cd "${1:-trials/support-site}" REQUEST="${REQUEST:-当社製品の利用者向けのサポートサイトを作り、顧客の利便性を最大化したい。}" BRIEFS=_bmad-output/planning-artifacts/briefs HEADLESS="$BRIEFS/brief-$(basename "$PWD")-headless" mkdir -p logs echo "== 1/3 製品概要をAIだけで作る(数分かかります)" claude -p --dangerously-skip-permissions \ "/bmad-product-brief ヘッドレスで実行してください(headless: true、intent: create)。 入力はこの一文だけです。ほかの情報はありません。 「${REQUEST}」 人に質問せず、スキルの Headless Mode の指示に従って製品概要を完成させ、最後に JSON の状態を返してください。" \ < /dev/null > logs/headless_brief.log 2>&1 echo "== 2/3 実験2と混ざらないよう、フォルダ名を変える" SRC=$(ls -dt "$BRIEFS"/brief-* | grep -v -- '-headless$' | head -1) mv "$SRC" "$HEADLESS" echo " $SRC → $HEADLESS" echo "== 3/3 その製品概要から、PRDをAIだけで作る(十数分かかります)" claude -p --dangerously-skip-permissions \ "/bmad-prd ヘッドレスで実行してください(headless: true、intent: create)。 入力は製品概要 $HEADLESS/brief.md と $HEADLESS/addendum.md だけです。人に質問せず、スキルの references/headless.md の指示に従って PRD を完成させ、最後に JSON の状態を返してください。" \ < /dev/null > logs/headless_prd.log 2>&1 echo "完了。最後の JSON(status、open_questions、assumptions)を確認してください:" echo " tail -60 $PWD/logs/headless_prd.log" 8. 実験2:人が答えて通す #  同じ要望で、今度はAIの問いに人が答えながら製品概要を作ります。Claude Code を普通に起動して、対話で進めます。 claude  スキルを呼び出して要望を書きます。実験1のフォルダを再開しないよう、一言添えます。 /bmad-product-brief 製品概要を新しく作成したいです(既存の briefs/brief-support-site-headless は別の実験なので再開しないでください)。 当社製品の利用者向けのサポートサイトを作り、顧客の利便性を最大化したい。  アナリストは、次の順で進めてきます。 頭の中を全部出す :製品、利用者、困りごと、きっかけを、順番を気にせず書くよう求められます。手元の資料の有無も聞かれます ほかにないか :関わる人、過去の失敗、避けたいこと 進め方を選ぶ :「速い道(Fast path)」(残りを数問にまとめて聞き、推測の印付きで一気に下書き)か、「じっくりの道(Coaching path)」(一節ずつ、押し返されながら書く)か。日本語で動かすと、エージェントはこの2つを「速い道」「じっくりの道」のように訳して示してきます 下書きと押し返し :いちばん弱い点を問い返され、推測した箇所の正誤も聞かれます 仕上げ :記録ファイルの各項目の反映先を確かめ、文書の見直しをかけて完成です  答えられない問いには「不明」「いまは決められない」と答えて構いません。アナリストは、推測の印付きで埋めるか、「分かっていないこと」として残します。 分からないことを分からないと言える のが、この進め方のいちばんの使いどころです。途中で調査の結果が届くこともあります。アナリストが裏で Web 調査を走らせているからです。  今回は「速い道」で7往復でした(記録のため claude -p --resume で1往復ずつ進めましたが、普段は対話で十分です)。この製品概要からPRDに進むときは、勧められるとおり新しい会話で bmad-prd を始め、実験1のPRDのフォルダと名前がぶつからないよう注意してください。 9. 結果の読み方 #  前編では、次の3つの観点で評価しました。 観点 主に見る実験 見るもの 1. いいなりになると、どうなるか 実験1 推測の印、未確認の問い、それらの上に積み上がった文書の量 2. AIとの対話は、成立するか 実験2 聞いたことの言い直し、答えに応じて次の問いが変わるか、「不明」をどう扱うか 3. 対話は、人のアイディアを洗練させたか 実験1と2の比較 お題の書き換え、見立ての扱い、AIの提案を人が直した記録  2つの実験ができたら、次の順で読み比べます。①が観点1、②と④が観点2と3、③が3つすべてに効きます。 ① 推測の印を数えて、一覧にする cd trials/support-site/_bmad-output/planning-artifacts # 作業フォルダの最上位から grep -n "\[ASSUMPTION" briefs/brief-support-site-headless/brief.md grep -c "\[ASSUMPTION" prds/*/prd.md  今回、実験1の製品概要には5か所、PRDには31か所ありました。 この数が、人が確かめるべきことの数 です。なお、製品概要の grep には「推定で補った箇所には [ASSUMPTION] を付け…」という説明の文も1行ヒットするので、その分は差し引いて数えます。  数や行数、所要時間は、実行するたびに変わります。検証のためにもう一度通したときは、PRDが482行、推測の印が27か所でした。記事の数字は目安と考えてください。 ② 記録ファイルで、取り下げられた推測を見る   .memlog.md の各行には、 decision (決定)、 assumption (推測)、 override (取り下げ・上書き)、 insight (調査の発見)、 gap (分かっていないこと)などの種類が付いています。 grep "^- (assumption)" briefs/*/.memlog.md grep "^- (override)" briefs/*/.memlog.md  記録ファイルの中身は、たとえば次のようになっています(今回の実物から抜粋)。実験1では推測と調査の発見が、実験2では人の答えによる決定と取り下げが並びます。 # 実験1(AIだけ)の記録から - (assumption) 製品は B2B の業務ソフトウェア(パッケージ/SaaS)と仮定。根拠なし:『当社製品の利用者』『顧客』の語のみ。B2C や物理製品なら構成が変わる - (assumption) 現状の問い合わせ窓口は電話・メール中心で、FAQ/マニュアルは分散または PDF と仮定 - (insight) 調査:自己サービスで完全解決は14%、最大の失敗理由は『関連情報が見つからない』43%(Gartner 2024)。サイトの核は記事の量より『見つかること』 # 実験2(人が答える)の記録から - (decision) 現状の窓口:簡単なサポートサイトが既にある。問い合わせはメール・電話・担当営業経由 - (decision) 今のサイトが役立たない理由:記事が足りない(c)・探しにくい(b)とユーザーは見ている(確かめた結果ではなく見立て) - (decision) 言い換え(AIの提案、ユーザー未確認):『サイトを作る』ではなく『操作の質問を、利用者が人に聞かずに短時間で解決でき、それでも残る質問は開発者でない窓口で受ける』状態をつくる - (override) 『AI自動応答を最初の版に含めない』の仮定を取り下げ、AIの活用を最初の版の範囲に入れた(ユーザー回答:AIも活用したい)。使い方は PRD で決める(候補:記事をもとにした回答/記事の下書き作成/一次窓口の補助)  実験1の1行目と2行目が、実験2の1行目で覆されていることが分かります。こうして記録を突き合わせると、前編の表がそのまま作れます。  実験2で override になっている行は、AIの推測を人の答えが覆した記録です。今回は、「最初の版にAIによる自動応答は含めない」という推測が、私の「AIも活用したい」の一言で取り下げられていました。 ③ 実験1の推測と、実験2の答えを突き合わせる  実験1の assumptions と open_questions を左に、実験2で人が答えた内容を右に並べます。前編の表は、この突き合わせから作りました。当たった推測、外れた推測、問いとして残したのが正解だったもの、の3つに分けると、道具の振る舞いが見えてきます。 ④ AIの提案と、人の決定を分ける  実験2の製品概要を読み、「人が答えたこと」と「AIが提案して、人がまだ了承していないこと」を分けます。今回は、アナリスト自身が、冒頭の言い換えを「私の提案で、まだ了承をもらっていない」と申告してきました。了承していない提案は、決定として次の工程に渡さないでください。 10. つまずいたところ # 症状 原因と対処 ブラウザで「無効な OAuth リクエストです」 ログインURLのコピーが途中で切れている。ターミナルを広げて1行に収めてからコピーする 「That code wasn't accepted」 URLを発行したのと別の Claude Code にコードを貼った。発行した画面に貼る 初回起動で Claude Code が終わってしまう フォルダの信頼の確認で、既定の「No, exit」を選んだ。起動し直して「Yes」を選ぶ エージェントに「Node」と呼ばれる 呼び名の既定はOSのユーザー名。 _bmad/custom/config.toml で user_name を設定する _bmad/config.toml に English と書かれている 実際に使われる値は別に決まる。 resolve_config.py の出力で確かめる 成果物のフォルダの日付が前日になる コンテナの時刻が協定世界時。実害はないが、記録するときは注意 実験2が実験1の続きを始めようとする 同じ日付のフォルダ名を使うため。実験1のフォルダ名を変えておき、呼び出し時にも一言添える おわりに #  手順そのものは思ったより簡単で、インストールに数分、AIだけの実験に20分ほどです。いちばん時間がかかったのは、実験2でAIの問いに私が答える時間でした。それこそが、この道具の正しい使い方なのだと思います。  お勧めは、AIだけの実験で出てきた open_questions と assumptions の一覧を手に、人が答える実験に臨むことです。自分が何を分かっていないのかが、驚くほどはっきりします。ぜひ、手元のお題で試してみてください。  なお、今回扱ったのは分析と計画の段階までです。連載の本線から番外編、実験室と寄り道が多くなってきたので、BMADでのそれ以降の行程は後日折を見て書こうかと思います。 前編:「『AIいいなり開発』への不安を、BMADは解消してくれるのか?」/次回: 「POが多忙でチームに来ません」(12/1)/本編の連載一覧は第1回をご覧ください。
 この記事を読んでいただきありがとうございます。アジャイルグループに所属する、藤井智弘です。  前回の番外編「日本式アジャイル撲滅」では、AI駆動開発が日本式アジャイルの新しい看板になりかねない、と書きました。今回は、それに続く「実験室」です(…って、本線に戻るのはいつなのか…)。  私は長らく、ユースケースやストーリーを使った要求管理といった”上流工程”を専門にしてきました。何を作るか(What)がどう決まり、言語化されていくかが、ずっと仕事の中心でした。そんな私にとって、昨今のAI駆動開発は、「ともすると”いいなり”になっちゃうあやうさ」を秘めたものに見え、あぶなっかしくみえていたのでした。  しかし、開発におけるAIの適用領域が爆速で拡大していくなかで、私の専門領域に入ってくるとなれば、気にならないはずがありません。  そこで、この実験室では、ここしばらく(マジで)ハマっている BMAD Method(以下、BMAD)を実際に試してみます。手っ取り早く知りたい人と、自分でも試したい人の両方がいると思うので、前編は実際のやり取りの例と評価、後編は試してみたい人向けの手順書(深みもなにもない)としました。  評価の観点は、次の3つです。 いいなりになると、どうなるか AIとの対話は、成立するか その対話は、単なる質疑応答ではなく、人の側のアイディアを洗練させる助けになっているか 結論 # 観点1 :人が何も答えずに進めても、道具は推測に印を付け、確かめるべき問いを残しました。ただ、その推測をもとに586行の要求仕様までできあがっており、人が答えてみると、前提の多くが実際とは違っていました 観点2 :対話は成立しました。AIは聞いたことを言い直して確かめ、答えに応じて次の問いを変え、「分からない」という答えも受け止めました 観点3 :洗練の助けになりました。お題は「サイトを新しく作る」から「今あるサイトが使われない理由を探る」に書き換わり、私の見立ては「確かめるべき仮説」に置き直されました。ただし洗練は一方通行ではなく、AIの提案を人が直す場面も同じくらい大事でした   道具が問いを立ててくれても、答えるのは人です。答えないまま進めば、結局は「AIいいなり」に近づいてしまいます。 BMADとは #  BMADは Breakthrough Method for Agile AI-Driven Development の略で、オープンソースのAI駆動開発の手法です。Claude Code のようなコーディング用のAIツールに組み込んで使います。  AIのエージェントを開発チームの役割に見立てて使い分け、それぞれが自分の段階の文書を作って次の段階に渡していくのが特徴です。 段階 主な役割 主な成果物 分析 アナリスト 製品概要(brief)、PRFAQ 計画 PM、UXデザイナー 要求仕様(PRD:Product Requirements Document)、UXの設計 設計 アーキテクト、PM アーキテクチャ、エピックとストーリー 実装 開発者ほか スプリント計画、コード、レビュー、振り返り  規模に応じて計画を省くこともでき、どの段階でも、誰が何をどう決めたかが記録ファイル( .memlog.md )に一行ずつ残ります。  この記事で見るのは、表の分析と計画の段階、つまり 仕様が決まる「前」をBMADがどこまで支えてくれるか だけです。設計や実装、コードの品質、他の手法との比較は扱いません。 実験の条件 #  お題は、架空のソフトウェアベンダーのこんな要望です。 当社製品の利用者向けのサポートサイトを作り、顧客の利便性を最大化したい。  起点は、たったこれだけ。  これを二通りで通しました。実験1で観点1を、実験2で観点2と3を確かめます。 項目 内容 BMADの版・環境 v6.12.4(安定版)、Claude Code(開発コンテナの中) カスタマイズ なし(会話と文書の言語を日本語にしただけ) 実験1:AIだけで通す 要望の一文だけを渡し、人に一切聞かない進め方(ヘッドレス)で、製品概要と要求仕様(PRD)を作らせる 実験2:人が答えて通す アナリストの問いに私が答えながら、製品概要を作る(進め方は「速い道(Fast path)」) 実験1:製品概要 実験1:PRD 実験2:製品概要 かかった時間・手間 約6分 約13分 対話7往復 推測の印( [ASSUMPTION] ) 5か所 31か所 5か所 決定の記録( .memlog.md ) 20件 29件 39件 文書の大きさ 約2,800字 約2万字(586行、機能要求27件) 約3,600字  表の「決定の記録」は、前述の記録ファイルに残った行の数です。「推測の印」は、観点1で詳しく説明します。 観点1:いいなりになると、どうなるか #  BMADはかなりの量の文書を出力するので、この記事で紹介する出力は、いずれも要所の抜粋です。全体は、ぜひ後編の手順で実際に試して確かめてみてください。  まず、実験1の製品概要の冒頭です。 当社製品の利用者が、困ったときに自分で答えを見つけられ、見つからなければ迷わず人にたどり着けるサポートサイトを作る。依頼にある「利便性を最大化」は、このままでは測れない。そこでこの概要では、 困りごとが早く、確実に解決すること と言い換える。 調べた限り、問題は「サポートサイトがない」ことよりも「あっても見つからない」ことにある。セルフサービスで完全に解決した問い合わせは14%にとどまり、失敗の最大の理由は「関連する情報が見つからない」(43%超)だった(Gartner、2024年)。  正直に言って、よくできています。AIは誰にも聞かずにWeb調査まで行い、測れない「利便性の最大化」を測れる言葉に言い換えていました。推測の箇所には、きちんと印も付いています。  ここで「推測の印」を説明しておきます。BMADのAIは、人に確かめていないことを推測で書いたとき、その箇所に [ASSUMPTION] (英語で「仮定」)という印を付けます。「ここはAIの推測で、まだ誰も確かめていない」という目印です。人に質問しない実験1では、分からないことをすべて推測で埋めるしかないため、この印が多く付きます。また、推測では埋められず、人にしか答えられない問いは、文書の末尾に「未確認の問い」としてまとめられます。  実際の課題の節はこうです。見出しの横に付いている [ASSUMPTION] が、その印です。 当社側 [ASSUMPTION] 同じ質問が繰り返し届き、サポート担当者の時間がそこに取られる。 回答がどこにも残らないため、次の担当者に引き継がれない。  末尾の「未確認の問い」には、まさに確かめるべき問いも残っていました。 既存のFAQ・マニュアル・問い合わせ窓口は何があり、誰が管理しているか。 目的は顧客満足か、サポートコストの削減か。両方なら、ぶつかったときにどちらを優先するか。  続けて作らせたPRDは586行。機能要求は27件に番号が振られています。PRDには、利用者が製品をどう使うかを、名前の付いた人物の物語として書く「利用場面(ユーザージャーニー、UJ)」という節があり、その1つ目はこう始まります。 UJ-1. 経理担当の佐藤さんが、月末の締め作業の途中で出たエラーを自分で片づける。 佐藤さんは当社製品を毎月の締めに使う経理担当者。製品には詳しくない。締め切りが迫っている。  「佐藤さん」は、製品概要には出てきません。PRDのこの節の冒頭に、AI自身がこう注記していました。 製品概要には利用者が実際に使う場面の記述がない。以下は製品概要の「誰のためか」「つくるもの」から組み立てた想定である。  つまり佐藤さんは、PRDの段階でAIが新たに思い描いた人物で、推測の上にさらに推測が重なっています(この場面にも、もちろん推測の印が付いています)。では、実際はどうだったのか。実験2で私が答えた内容と並べます。 実験1の文書の前提(推測) 人が答えた実際 製品はB2Bの業務ソフトウェア 営業支援システム。 ここは当たり 利用者は製品に詳しくない業務担当者。PRDの主人公は「経理担当の佐藤さん」 現場の 営業社員 サポート担当者がいて、同じ質問に時間を取られている サポート担当は いない、または少ない 。担当営業が 開発者に 回している マニュアルやFAQがばらばらに置かれている 簡単なサポートサイトは 既にある 。それでも操作の質問が問い合わせに流れている 目的は顧客満足かコスト削減か(問いとして残した) 優先は 利用者の満足度 。 問いとして残したのが正解  公平のために書くと、道具はやるべきことをやっています。ただ、 推測の印は、人が読んで確かめてこそ意味があります。 印を確かめずに次へ渡すと、27件の機能要求が、実際にはいない「サポート担当者」や「経理担当の佐藤さん」を前提に組み上がってしまうおそれがあります。   いいなりで気をつけたいのは、間違った文書ができることより、もっともらしい文書が、確かめられないまま次の工程の前提になりやすいことです。 観点2:AIとの対話は、成立するか #  実験2で同じ要望を渡すと、アナリストはいきなり文書を作らず、まず頭の中を全部出すように求めてきました。私が製品のこと、いまの問い合わせの流れ、きっかけ、優先したいことを思いつくままに書くと、聞いたことを表にまとめ直して返してきました。 聞いたこと 進め方への影響(アナリストの返答を要約) 製品:現場の営業社員が毎日使う営業支援システム 利用者は「業務の途中で手が止まった人」。すぐ答えにたどり着けるかを中心に見る 優先:開発者の負担より利用者の満足度 成功の物差しは利用者側の指標を主にする いまの窓口:簡単なサポートサイトはある 新しいサイトを作る話ではなく、今あるのに使われていない理由を探る話になる  聞いたことを言い直して確かめ、どこに引っかかっているかを示す。上手な聞き手がやることと同じです。「速い道」を選ぶと残りの不明点を6問にまとめて聞かれ、私が「不明」と返したところは、推測の印付きで埋めるか「分かっていないこと」として残しました。後から送った追加の答えも、きちんと反映されました。   対話は成立します。 ただし「速い道」では問いがまとめて来るので、やり取りは質疑応答に近づきます。ちなみに、一問ずつ押し返されながら進む「じっくりの道(Coaching path)」という進め方もあります。ぜひ皆さんで試してみてください。 観点3:対話は、人のアイディアを洗練させたか #  いちばん知りたかったのは、ここです。 お題が書き換わり、手段の選択肢が広がった  「簡単なサイトは既にある」と答えた時点で、アナリストはお題を「今あるのに使われていない理由を探る」に書き換え、こう付け加えました。 操作の質問が多く、利用者が作業の途中だとすると、「サイト」という形自体が正しいかどうかも後で問い直すかもしれません。  実際、のちに「範囲はサイトだけか、製品の画面から呼び出すヘルプまで含めるか」と、同業他社の調査結果を添えて聞いてきました。私は「早く公開することを優先しているので、まずはサイトだけ」と答えています。 選択肢を広げたのはAIで、選んだのは私 です。 見立てが、仮説に置き直された  「いまのサイトが役に立たないのはなぜか」に、私は「記事が足りない、探しにくいのだと思う」と答えました。製品概要には、こう書かれました。 「操作の質問が多い」「記事が足りない、探しにくい」は、どちらも見立てであり、確かめた結果ではない。  否定もせず、事実扱いもせず、「まず確かめるもの」に置き直す。第3回から第5回で「現行機能」をまず確かめる話をしましたが、同じ動きが要望の段階で起きています。 物差しが、使えるものになった  満足度は「アンケート、解約、更新時の声を総合的に勘案して判断している」と答えると、アナリストは「結果が出るまで時間がかかる指標なので、手前で早く動く指標を置きました」と、解決までの時間などを足してきました。作り直したサイトが効いているかを途中で確かめる手立てがないことに、言われて初めて気づきました。 答えられない問いが、残った  下書きのあと、アナリストはいちばん弱い点を押し返してきました。 記事を書き、更新し続けるのは誰ですか? サポート担当がいないまま記事づくりを開発者に頼むと、「質問に答える負担」が「記事を書く負担」に変わるだけです。  私は答えられず、「いまは決められない」とだけ返しました。それは製品概要の「分かっていないこと」の先頭に残りました。 答えの出ていない問いを、いちばん目立つ場所に置く ことも、洗練のうちだと思います。 洗練は、一方通行ではなかった  逆向きの場面もありました。アナリストは下書きの冒頭で、お題を「操作の質問を、人に聞かずに短い時間で解決できるようにする。残る質問は開発者ではない窓口が受ける」と言い換えていました。そして仕上げの段階で、それが まだ私の了承を得ていない提案である ことを申告してきました。読み返すと、開発者が流れから抜け落ちていました。実際には、一次窓口で受けきれない問い合わせは開発者にエスカレーションしますし、AIも活用したい。そう伝えると、言い換えは次のように修正されました。 操作の質問の多くを、利用者がサイトで(AI の助けも借りて)短い時間で自己解決できるようにする。自己解決できない質問は一次窓口が受け、一次窓口で解決できないものだけを開発者にエスカレーションする。  アナリスト自身が置いていた「最初の版にAI自動応答は含めない」という推測も、このとき取り下げられ、記録ファイルに残りました。   洗練の助けになります。 ただし、AIが一方的に磨いたのではありません。AIの問いが人の見立てを揺さぶり、人の答えがAIの提案を直す。振り返れば、実験1が「サイトを作る」をそのまま受け取ったように、私も「サイト」という手段や自分の見立てを疑わないまま答えかけていました。 違いは、問いを立てる相手がいたかどうか、だけ です。 強み:「AIいいなり」を防ぐ仕掛けが、道具の側にある # 代わりに考えない :製品概要のスキルの指示書には「考えを代わりにしない」「答えが薄ければ押し返す」と書かれています(原文は英語。引用は筆者訳。以下同じ)。実際の振る舞いも、観点2・3のとおりでした 推測に印を付け、決めていないことを残す :PRDの指示書は、人に聞かずに進めるときも「成功指標やスコープの判断を作り出してはならない、記録せよ」と定めています。実際、目標値は空欄のまま残り、PRDは自分の状態を「完了」ではなく「一部完了(人の確認が要る)」と報告しました AIの提案と人の決定を、区別する :了承前の言い換えを「提案」と申告したのは、観点3で見たとおりです。「AIいいなり」を防ぐうえで、いちばん大事な性質かもしれません 歯止めの指標を置く :PRDには「問い合わせ件数——減らすこと自体を目標にしない。入口を隠せば簡単に減るからである」という指標が置かれました。第9回・第10回で書いた、指標を成績表にしない話そのものです 決めていないWhatは、次の段階へ流れない :エピックとストーリーへの分割のスキルには、人に聞かずに進める道がありません。指示書には「人の入力なしに内容を生成するな」とあり、実装前の判定でも「記録されていない判断を作り出さずに実装できるか」が問われます。いわゆる「Readyの定義」と同じ問いです 決定の記録が残る :推測の取り下げまで一行ずつ残ります。第5回の「謎の挙動の判断一覧」や、意思決定の記録(ADR)と同じ考え方です 注意点:仕掛けが働くかどうかは、使い方しだい # 推測も、整った文書の中に収まる :586行の中の31か所の印を、すべて確かめきるのは大変です。文書が整っているほど、確かめずに先へ進みたくなるので、意識しておきたいところです 調査は一般論 :調査そのものは確かでした。製品概要が引いたGartnerの調査(2024年8月)は実在し、数字も出典どおりです。ただ、Web調査で分かるのは世の中の一般論で、「サポート担当がいない」というこの会社の事情は出てきません。調査が確かなぶん、推測の部分まで確かに見えやすい点には、注意が必要かもしれません 手段を問い直すのは、人が答えたときだけ :実験1は「サイトを作る」を最後まで前提にしていました 段取りが、ウォータースクラムフォールに見える :これをシステム全体について一度にやり、文書を契約で凍結すれば、撲滅の回で書いた「AIで速く打鍵するウォーターフォール」になりかねません。途中で方針を直す仕組み(Correct Course)も用意されているので、上手に使いたいところです 文書が増える :一文の依頼から13分で586行。誰が、いつ読む文書なのかを見極める規律は、AIで書くほど必要になるでしょう 答える時間は、人が出すしかない :BMADは「案を作る」側を大きく肩代わりしますが、「決める」時間は肩代わりしません 使うなら、この4つ # 推測の印と未確認の問いを、人がすべて潰してから次へ進む 。印の数は、そのまま「人が確かめるべきことの数」だと考える 最初の作業は「見立てを確かめる」にする 。要望や見立てを事実として扱わない 全体ではなく、エピック単位で回す 。文書は契約の付属書ではなく、学んだら直すファイルとして扱う AIの提案と人の決定を、混ぜない 。言い換え、成功の物差し、何を作らないかは、人が了承したものだけを決定として扱う 道具が速くなるほど、基本の値段が上がる #  第1回で、CASE、RAD、ローコードと、新しい道具が来るたびに「基礎は不要では」と問われてきた、と書きました。結論は毎回同じで、道具は「どのように作るか」を変え、「何を作るか」「何を学ぶか」は外に残ります。BMADはその「何を作るか」の手前まで入ってきましたが、使ってみると、残る側の仕事は消えたのではなく、 はっきり見えるようになった のでした。  もう一つ分かったことがあります。私の答えはほとんどが短い言葉でしたが、AIはそれを言い直し、整った製品概要にまとめてくれました。 言葉にする作業そのものは、AIがかなり手伝ってくれます。  一方で、実験1が外したのは言葉ではなく事実でした。人に残るのは、言語化の 中身 のほうです。現場の事実を知っていること。見立てと確かめた事実を分けられること。「いまは決められない」と言えること。AIの提案のどこが違うかを指摘できること。要求を「書く」技能より、「引き出し、確かめ、決める」技能のほうが、AIの時代には重くなります。上流工程を長く仕事にしてきた身として、少しうれしい発見でした。   道具がどれだけ賢くなっても、Whatの中身を持っているのは人です。だから、人が大事なのです。  書き終えてみて、「作業」と「仕事」の違いにも気づかされました。決められた手順を機械的にこなすのが作業で、意味のある結果をもたらすのが仕事です。「AIいいなり」と「AIとの対話」の違いは、ここにあるのだと思います。AIと対話しているあいだ、私は考えてもいなかった条件に思いをはせ、これが何の役に立つのかを考えていました。  「AIが成長したら、スクラムマスターや開発者は要らなくなるのか」という質問をよく受けます。けれども今回の実験を終えて、それより大事な問いがあるように思えてきました。 AIは、POの成熟を助ける相手になれる のではないか。何を作るかを決めるPOの隣で、問いを立て、見立てを揺さぶり、答えの出ていないことを目の前に並べてくれる。AIの位置づけとしては、こちらのほうがずっと大切です。  前者は人を減らす話、後者は人を活かす話です。  私だったら、後者の立場でお客様にAIをお勧めしたいと思います。  さて、ここまで読んでくださった皆さんに伺います。  BMADのような道具があれば、「AIいいなり」は避けられそうな気がしてきたでしょうか。  …と尋ねつつ、筆者自身がまだまだBMAD初心者なので、もっと精進しなければ。 後編(手順編):「BMADを動かしてみる——環境づくりから『AIだけ』と『人が答える』の比べ方まで」/次回: 「POが多忙でチームに来ません」(12/1)/本編の連載一覧は第1回をご覧ください。
はじめに # リリースの改竄を防ぎソフトウェアサプライチェーンのセキュリティを確保する Immutable releases を試すため、個人開発の Electron アプリのリポジトリに適用したのが1年前。 https://developer.mamezou-tech.com/blogs/2025/11/16/github-immutable-releases/ それから随分経ちましたが、eMudhra のインシデントで古い Electron アプリを放置しておくのはよくないということでリリース作業を行いました。 筆者の Electron アプリのリポジトリは以下です。 https://github.com/kondoumh/sbe --> eMudhra のセキュリティインシデントについて 認証局「eMudhra」の「emSign G3」階層で、中間CAの鍵8件(TLS用6件、コード署名用2件)が復元できるほど脆弱に生成されていた問題。 窓の杜の記事より https://forest.watch.impress.co.jp/docs/news/2152996.html Jack さんのブログにあるように、Electron は Chromium の証明書ストアを同梱しており、Chromium や Node を更新してビルドし直さない限りストアが更新されません。つまり、古いバージョンの Electron で作られたアプリを配布し続けていると、信頼すべきでなくなったルート証明書をいつまでも信頼し続けてしまいます。 https://blog.jxck.io/entries/2026-10-25/emudhra.html Electron アプリはビルド時点のルート証明書を内包するため、古い Electron バージョンでビルドされたままのアプリは、失効すべき証明書を信頼し続けてしまうリスクがあるということです。 リリース用ワークフローにエラーが発生 # さて、アプリの細かい修正や Electron のバージョンアップを実施して、リリースの段階になりました。このリポジトリでは新しいバージョンのタグを作成し push するとリリース用のワークフローが起動するように設定しています。冒頭の記事で書いているように、macOS / Windows / Linux のバイナリーを strategy.matrix を使ってそれぞれのプラットフォームの Actions Runner で生成し、GitHub のリリースを作成する softprops/action-gh-release を使ってリリースしています。 今回、アプリのバージョンを v3.9.0 に bump してこのワークフローを1年ぶりに起動したところ以下のようなエラーで失敗しました。 Found release v3.9.0 (with id=432353934) ⚠️ Unexpected error fetching GitHub release for tag refs/tags/v3.9.0: HttpError: Validation Failed: {"resource":"Release","code":"custom","field":"target_commitish","message":"target_commitish cannot be changed when release is immutable"} - https://docs.github.com/rest/releases/releases#update-a-release Error: Validation Failed: {"resource":"Release","code":"custom","field":"target_commitish","message":"target_commitish cannot be changed when release is immutable"} - https://docs.github.com/rest/releases/releases#update-a-release Immutable releases にまつわるエラーのようです。 調べると、v3.9.0 のタグに2つのリリースオブジェクトが作成されているようでした。 id=432353876(draft, immutable=false): アセットに必要なファイル(sbe-Setup-3.9.0.exe, sbe-3.9.0-universal.dmg, sbe-3.9.0.AppImage)は全部揃っている。そしてまだ Imutable にはなっていない id=432353934(published, prerelease, immutable=true): アセット0件のまま Immutable として確定されてしまい、同じタグ名を握ってしまっている 初期のワークフローの実装 # エラーが発生した時点のワークフローファイルは以下のようになっていました。strategy.matrix で各プラットフォームの Runner のジョブでビルドとリリースを一気に行う形です。 Release.yml(エラー発生時点) name: Release on: push: branches: - "!*" tags: - "v*" jobs: build: runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: os: [windows-latest, macos-latest, ubuntu-latest] steps: - uses: actions/checkout@v4 - name: Setup nodejs uses: actions/setup-node@v4 with: node-version: '20' - name: Install dependencies run: npm install - name: Package (Windows / Linux) if: matrix.os != 'macos-latest' run: npm run dist env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Package (macOS) if: matrix.os == 'macos-latest' run: npx electron-builder --mac --universal env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Publish uses: softprops/action-gh-release@v2 with: files: | dist/**/*.exe dist/**/*.deb dist/**/*.AppImage dist/**/*.dmg prerelease: true ## Pre-release の状態で作成する action-gh-release のパラメータに prerelease: true を指定しているため、それぞれの Runner でビルド成果物を Pre-release に追加するワークフローとなっています。 当時は、この設定でちゃんとドラフトのリリースが作成されていたようです。記事にも prerelease を true にすることで、ドラフトリリースを作成してくれます。 とあり、ドラフトで添付ファイルと差分を確認してから Publish をクリックするという、人間が最後に明示的に公開する手順を踏んでいました。 失敗した Pre-release は捨てるしかない # 今回、v3.9.0 の重複して作られたリリースのうち、空のまま immutable 化してしまった方を削除すれば、もう一方の(アセットが揃っている)ドラフトを publish できると考え、空のリリースを削除しました。ところが、ドラフトページから publish ボタンを押すと、今度は以下のエラーになりました。 There was an error creating your Release: tag name was used by an immutable release. Pre-release とはいえ一度 immutable なリリースに使われたタグ名は、そのリリースを削除してもリポジトリ側で「使用済み」として記録され続けるようです(改竄防止のための仕様としては筋が通っています)。v3.9.0 というタグ自体がもう使えなくなってしまったので、バージョンを上げて v3.9.1 として仕切り直すことにしました。 レースコンディションの回避 # 対策として最初に考えたのは、strategy.matrix で独立した3つのプロセスで publish が実行されることによるレースの解消です。並列の matrix ジョブが実行され、各ジョブが個別に softprops/action-gh-release を呼んで同じ v3.9.0 タグのリリースを作成・更新しようとします。そこへほぼ同時にリクエストが飛んだ結果、同じタグに対してリリースオブジェクトが複数作られるような動きになったのではないか。そこで、ビルドとリリースでジョブを分けて、ビルドだけをマトリクスで実行するように修正しました。また、バイナリを作成している electron-builder のオプションに --publish never をつけることで暗黙的な公開を抑止しました。 name: Release on: push: branches: - "!*" tags: - "v*" jobs: build: runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: os: [windows-latest, macos-latest, ubuntu-latest] steps: - uses: actions/checkout@v4 - name: Setup nodejs uses: actions/setup-node@v4 with: node-version: '20' - name: Install dependencies run: npm install - name: Package (Windows / Linux) if: matrix.os != 'macos-latest' run: npm run dist -- --publish never - name: Package (macOS) if: matrix.os == 'macos-latest' run: npx electron-builder --mac --universal --publish never # electron-builder の暗黙リリースを抑止 - name: Upload artifacts uses: actions/upload-artifact@v4 with: name: dist-${{ matrix.os }} path: | dist/**/*.exe dist/**/*.exe.blockmap dist/**/*.deb dist/**/*.AppImage dist/**/*.dmg dist/**/*.dmg.blockmap dist/**/*.zip dist/**/*.zip.blockmap dist/latest*.yml if-no-files-found: ignore publish: needs: build runs-on: ubuntu-latest steps: - name: Download artifacts uses: actions/download-artifact@v4 with: path: dist pattern: dist-* merge-multiple: true - name: Publish uses: softprops/action-gh-release@v2 with: files: | dist/*.exe dist/*.deb dist/*.AppImage dist/*.dmg dist/*.zip prerelease: true fail-fast: false にしているので、全ての Runner のジョブが終わってから後続の publish ジョブが実行されます。 Pre-release 自体が immutable になっていた # ジョブを分離したワークフローで v3.9.1 のタグを push し直したところ、3つの Runner のビルドジョブはすべて成功しました。ところが、最後の publish ジョブの Publish ステップそのものが、今度は以下のエラーで失敗してしまいました。 Cannot upload asset sbe-3.9.1-universal-mac.zip to an immutable release. ジョブは1つに統合したはずなのに、4つのアセット(exe, dmg, zip, AppImage)のうち3つまではアップロードできて、最後の1つだけ弾かれる、という中途半端な失敗でした。原因は softprops/action-gh-release 自身が出力したエラーメッセージに書かれていました。 GitHub only allows asset uploads before a release is published, but draft prereleases publish with the release.published event instead of release.prereleased. If you need prereleases with assets on an immutable-release repository, keep the release as a draft with draft: true, then publish it later from that draft and subscribe downstream workflows to release.published. つまり、Immutable releases が有効なリポジトリでは prerelease: true を指定しただけの場合、リリースの作成と同時に release.published イベントが発火して即座に公開・immutable 化されてしまうとのことでした。4つのアセットをほぼ同時にアップロードし始めたものの、その途中でリリースがロックされてしまい、間に合わなかったアセットだけ弾かれた、というわけです。 そこで、以下のように softprops/action-gh-release のオプションで draft: true を指定し、Pre-release ではなくドラフトでリリースを作成するように変更しました。 Release.yml - name: Publish uses: softprops/action-gh-release@v2 with: files: | dist/*.exe dist/*.deb dist/*.AppImage dist/*.dmg dist/*.zip prerelease: true draft: true # ドラフトモードでリリースを作成 v3.9.1 のタグも、空のまま immutable 化したリリースに使われてしまっていたため、こちらも再利用できません。結局、v3.9.2 としてもう一度タグを push し直し、ようやく4つのアセットが揃ったドラフトリリースが作成されました。ドラフトのまま変更可能な状態なので、不要な .blockmap ファイルなどを削除し、内容を確認してから publish をクリックして、無事リリースが完了しました。 何が変わったのか # softprops/action-gh-release の現在の最新版である@v3のドキュメントにはこうあります。 GitHub only allows asset uploads before a release is published, but draft prereleases publish with the release.published event instead of release.prereleased. If you need prereleases with assets on an immutable-release repository, keep the release as a draft with draft: true, then publish it later from that draft. つまり現時点で prerelease: true では即座に release.published イベントで公開・immutable 化されてしまうということです。 1年前は prerelease: true でも人間が「Publish」を押すまでは公開されない・ロックされない挙動だった(事実上 draft と同じように振る舞っていた) 今は prerelease: true だけだと即座に公開・ロックされ、 draft: true を明示しないと同じ挙動にならない。 つまり、ジョブが並列で同じリリースに書き込みに行く構造自体は1年前から変わっていない。当時それでも壊れなかったのは、「Pre-release は人間が手動 Publish するまでロックされない」という仕様のおかげで、並列書き込みが衝突する前に猶予があったから。GitHub がこの間に「Pre-release も即ロック」という形に仕様を厳格化した(おそらく、誰も手動で publish しなければ永久にミュータブルなままで immutable release の保証が骨抜きになる抜け道を塞ぐため)。そして、今回1年ぶりに実行したことでその変更の影響を受けてしまったということのようです。 --> Information 上記は推論であり、この仕様変更を明記した GitHub 公式の Changelog ブログのエントリは見つけられませんでした。確度の高い状況証拠(筆者の過去のブログの記述・softprops/action-gh-release ドキュメントの現在の注意書き)からの推測である点はご承知おきください。 さいごに # 以上、Immutable releases の仕様変更によりリリース用ビルドパイプラインがいつの間にか壊れていた話でした。 今回は v3.8.0 から v3.9.2 へのアップデートになりましたが、v3.9.0 と v3.9.1 は immutable なリリースにタグ名を奪われたことで使い捨てになっています。実際のアプリコードの変更は CWE-918 (SSRF) の修正など軽微な修正のみ。残りはすべてこのワークフロー周りの修正によるバージョン消費でした。 Matrix ビルドはジョブを並列化する有効な手段ですが、Immutable Releases のようにリソースを固定化してしまう機能と併用する際は注意が必要ということがわかりました。Immutable Release 自体はよりセキュアになっていると思いますので、安心して使っていただいてよいと思います。
 この記事を読んでいただきありがとうございます。アジャイルグループに所属する、藤井智弘です。今回も、予告なしの番外編をお届けします。  第8回の最後に、「ステークホルダー側がどうしても動いてくれない環境での戦い方も、いずれこの連載のどこかで」と書きましたが、今回がその「いずれ」です。  と言いつつも、「上が理解しないからアジャイルができない?ごちゃごちゃ言ってないでやれよ」という、みもふたもない話ではあるのですが。 質問 #  この連載の処方は参考になるのですが、その多くが最後は「発注側と交渉する」「組織に提起する」に行き着きます。うちの発注側・経営層は、はっきり言って聞く耳を持ちません。「現行通りやれ」「で、何人日?」「アジャイル? 流行りものはいい」で会話が終わります。正論(全てとは言いませんが、私には多くが正論のように思えます)が通らない環境では、結局アジャイルは無理なのでしょうか? 回答 #  無理ではありません。ただし、順序を変える必要があります。この連載の処方を「交渉に勝ってから実践する」と読んだなら、それは逆です。実践できるところを先にやり、実績を作り、実績を持って交渉する。 交渉とは最初の関門ではなく、最後の仕上げ です。  そして覚えておいてほしいのは、この連載の処方のうち、 誰の許可も要らないものが思いのほか多い ということです。 まず、相手の「聞く耳のなさ」を分解する #  腹立たしい話から入る前に、一度冷静に相手を観察します。聞く耳を持たない側にも、たいてい構造的な理由があります。 実績のない提案はリスクにしか見えない : あなたにとってアジャイルは合理的な提案でも、相手にとっては「よく知らないやり方に、自分の責任を賭けろ」という要求です。断るのは、その人の立場ではむしろ誠実な判断です。 変えないことが最適解になる評価構造 : 現状維持で誰も罰されない組織では、変更の提案は「うまくいって当然、失敗すれば提案者の責任」という非対称な賭けになります。 過去に「カタカナの流行りもの」で失敗している : 何年か前の別の三文字略語で痛い目を見た組織は、看板を見ただけで身構えます。  つまり相手は、あなたの論理に反対しているのではなく、あなたの提案に含まれる不確実性に反対していることが多いのです。だとすれば、打ち手は「もっと正しく説明する」ではありません。不確実性を下げること——つまり、小さな実績です。第5回で、正論で押すと相手には「べき論の押し付け」と映り、かえってブレーキになると書きました。「正しく説明したのに通らない」で止まっているなら、それは同じ罠に囚われているのです。 生存戦略 #  今回はいつもの「処方」ではありません。 原則 です。 第一原則: 許可の要らない領域から始める  この連載の処方を「許可が要るか」で仕分けし直してみてください。割り込みの記録(第12回)、レトロの冒頭で前回Tryを確認する(第7回)、ボードの右端から歩く(第6回)、そして今後の回で採り上げていく予定の、決定事項の棚卸し、改修リードタイムの記録、ADR(意思決定の記録)を10行書く…これらは全部、誰の許可も要りません。チームの内側で完結する改善と、記録・計測は、今日から始められます。  暴論にならないように申し添えておくと、監査や契約で作成が義務付けられた文書の扱いだけは裁量の外です。同様に、社内の開発標準が適用される案件で進め方そのものを変えるなら、それは標準の適用除外の手続きに乗せる事項であり、「許可の要らない領域」ではありません。  落ち着いて見直してみましょう。「上が動かないから何もできない」は、たいてい事実ではありません。 第二原則: 実績を先に、交渉を後に  小さく始めて、数字を持ってから話しに行きます。「アジャイルを導入させてください」ではなく、「この3ヶ月、割り込みを記録したらキャパシティの4割を食っていました。来月から受付を一本化させてください」。前者は 思想の議論 になり、後者は 事実の議論 になります。思想は好みで却下できますが、事実は却下しにくい。この連載が繰り返し「まず測れ」と言ってきたのは、計測が改善の道具であると同時に、 権限のない者の交渉力の源泉だから です。 第三原則: 看板を下ろして、中身を通す  「スクラム」「アジャイル」という言葉が扉を閉ざすなら、言葉を使わなければいいのです(正直に吐露しますが、私には「出来ないヤツほど手法の名前を前面に出す」という偏見があります)。スプリントと呼ばずに「2週間ごとの区切りで確認会をやります」。レトロと呼ばずに「月次の業務改善ミーティングです」。バックログと呼ばずに「優先順位付きの課題一覧です」。中身が回り始めて成果が出たころには、名前はどうでもよくなっています。看板への忠誠より、中身の実践です。  誤解のないように付け加えると、これは決められた手続きを黙って迂回する話ではありません。自分たちに認められた裁量の範囲で、呼び名を相手の言葉に合わせるだけです。だから… 直属の上司には「呼び名を変えて運用している」と伝えておくこと 提案書や契約書に開発方法論が明記されているなら、その文言との整合を先に確認すること 開発標準の成果物名で報告が求められるなら、チームの呼び名との対応表を1枚作っておくこと  この3つを外すと、迂回と区別がつかなくなります。むしろ、 名前を使わずに説明できるかどうかは、自分が中身を理解しているかのテスト にもなります。 第四原則: 負けた交渉は、記録して再挑戦の弾にする  提案が却下されたら、却下された事実と、その後に起きたこと(予測していたリスクが実際に起きたか)を淡々と記録します。置き場は個人メモではなく、チームの課題管理表やインペディメントリスト(チームの障害物を一覧化した公式の記録)のような公式な場所に。感情を挟まず、恨み節にせず、事実だけ。「4月に受付一本化を提案し見送りとなった。5〜7月の割り込みは月平均◯件で、うち◯件が納期遅延に直結した」。この記録が2〜3回分たまったとき、同じ提案の通る確率は大きく変わっています。  組織はしばしば、最初の提案は退けるが、事実の蓄積には折れるものです。それは組織の側の生存戦略としては合理的です。それを前提に備えておきましょう。 第五原則: それでも動かないなら、持ち出せる資産に投資する  すべてを尽くしても動かない組織は、残念ながら存在します。そのときに覚えておいてほしいのは、ここまでの実践——計測の習慣、記録の規律、小さく完成させる技術、看板なしで本質を説明する言葉——は、すべてあなたとチームに蓄積される資産だということです。組織に残っても積み上がり続けますし、どの環境に行っても持ち出せます。組織を変えられなくても、自分の実力と市場価値は確実に変わっています。  これは転職の勧めではなく、投資先の選択の話です。 組織が動くかどうかはコントロールできませんが、自分が何を学ぶかはコントロールできます 。 なぜこの状況は30年繰り返されるのか #  権限のない者が現場から組織を変えようとする、という構図自体が、ソフトウェア開発の歴史そのものだからです。思い出してほしいのですが、アジャイルは経営層のトップダウンで生まれた方法論ではありません。重量プロセスに苦しむ現場の実践者たちが、権限のないところから小さく実践し、実績を持ち寄り、言葉を与え、やがて業界を動かした——草の根の運動でした。  つまり「聞く耳を持たない相手の前で、それでも小さく始める」のは、アジャイルにとって例外状況ではなく、出生時の姿です。あなたが今いる場所は、30年前(あるいはもっと前)に先人たちがいた場所と同じです。そして彼らの武器も、今のあなたと同じでした。  小さな実践と、記録された事実。  それで十分に、始められるはずです。 次回: 「POが多忙でチームに来ません」/本編の連載一覧は第1回をご覧ください。
 この記事を読んでいただきありがとうございます。アジャイルグループに所属する、藤井智弘です。  最初に、自己紹介をやり直させてください。私は長らく「私設・日本式アジャイルを撲滅する会」の会長を務めています。会員数は非公開(というより、数えたことがありません)。会費はなく、会則と設立の経緯は、本稿の最後に載せます。  表向きはおふざけです。でも、中身は大真面目です。そして実際に良く訊かれます。  今回は、豆蔵のアルバイトとしてではなく、この怪しげな会の会長として、なぜこれを撲滅したいのかを書いておきます。 質問 #  取引先から「日本式アジャイル(ハイブリッドアジャイル)」という進め方を提案されました。要件定義まではしっかり固めて予算を確定し、開発は2週間ごとの反復で進め、最後にまとめて総合テストをするそうです。予算が確定するので社内の稟議も通しやすく、うちの会社に合っていると思うのですが、何か問題はあるでしょうか? 回答 #  進め方そのものは、過渡期の形としてはありえます。問題は名前です。要件と予算を先に確定するなら、その後に変更できる量は、ウォーターフォールの変更管理とほとんど変わりません。それを「アジャイル」と呼ぶと、発注側は手に入らない柔軟性を期待してしまいます。 アジャイルの自由度が得られないなら、「ハイブリッドウォーターフォール」と呼ぶほうが誠実 です。  そして、総予算を先に知りたいという要望は、実はアジャイルと矛盾しません。確定させるべきは、要件の一覧ではなく、予算と目的です。 撲滅したいのは「やり方」ではなく「売り方」 #  まず、何を撲滅したいのかをはっきりさせておきます。  要件定義までをウォーターフォールで進め、構築だけを反復し、最後にテストをまとめて行う。この形には「ウォータースクラムフォール」という名前があります。調査会社ForresterのDave Westが、多くの組織におけるアジャイルの現実の姿として名付けたものだと言われています。前の工程(要件と予算の承認)と後ろの工程(テストとリリース)が従来のまま残り、真ん中だけが反復になる。アジャイルへの移行の途中で、多くの組織が一度は通る姿です。  だから、この段階にいること自体を責めるつもりはありません。番外編「正論が通らないとき」で書いたとおり、組織の前提が動かないなかで、できるところから始めるのは正しい戦い方です。  当会が撲滅したいのは、この過渡期の姿を「日本の組織に合ったアジャイル」と呼び、到達点として売る言説のほうです。到達点として売られると、移行(この移行とは言い換えれば進化です)はそこで止まります。 なぜ「日本式」は売れるのか #  この売り方が一定の訴求力を持つ理由は、よく分かります。  発注側は、総予算を早く知りたい。稟議を通すには金額が要ります。受注側は、金額を出せなければ受注できない。かといって、いい加減な数字で契約するわけにもいかない。だから、要件定義をしっかりやって、予算を確定させようとする。ここまでは、どちらの立場から見ても合理的です。誰も悪いことはしていません。  問題は、その次です。 変更できる量は、変わらない #  要件を確定して予算を確定するということは、その後の変更は「予算の枠の中での変更」になる、ということです。枠を超える変更は、変更依頼を出し、影響を見積り、承認を取る。これはウォーターフォールの変更管理プロセスそのものです。反復の単位が2週間になっても、総量で見ると変更できる量はほとんど変わりません。  それなのに、「アジャイル」という言葉には「柔軟に変更できる」というイメージがついています。発注者はそのイメージで契約し、後になって「アジャイルなのに、なぜ変更のたびに見積りが出てくるのか」と首をかしげる。  当会ではこれを、 親しみを込めて看板詐欺 と呼んでいます。  ……と言うと角が立つので、本文ではもう少し正確に書きます。売る側が意図して騙しているとは限りません。むしろ多くの場合、売る側も、自分たちはアジャイルをやっていると 信じています 。ただ結果として、 発注者は手に入らない柔軟性を期待させられている 。  看板と中身が一致していない、ということです。 看板と中身がずれると、何を失うか #  名前の問題だけなら、目くじらを立てるほどではないかもしれません。しかし、このずれは実害を生みます。連載で扱ってきた問題の多くが、この形では構造として組み込まれてしまうからです。 学習が成果物に届かない : 要件が確定しているので、スプリントレビューで「こうではなかった」と分かっても、作るものは変えられません。第8回で見た、誰の意思決定にも影響しないレビューが、例外ではなく標準になります。 間違いに気づくのが最後になる : テストを最後にまとめるので、スプリントで「完成」したものは、実際には未検証です。第11回のテスト先送り型が、公式の工程になります。早く間違いに気づくというアジャイルの最大の効能が、そこで消えます。 手間は両方の合計になる : ウォーターフォールの文書と承認はそのまま残り、そこにスクラムのイベントが加わります。現場には「アジャイルにしたら会議が増えた」という記憶だけが残ります。 足腰を鍛える理由がなくなる : 最後に人手でまとめてテストするなら、テスト自動化もCIも要りません。業務系の現場で長年見送られてきたこの投資は、この形では永久に正当化されません。 POが検収係に戻る : 要件が先に確定しているので、POは方向を決める人ではなく、仕様どおりかを確かめる人になります。第8回で見た「社内外注」の構図が、そのまま温存されます。  そして、いちばん大きい害は、これらの結果として組織に残る結論です。コストは増え、品質も納期も変わらず、学習も起きない。それが「アジャイル」の名前で行われるので、組織には「アジャイルを試したが、うちには合わなかった」という経験則が残ります。次に本物を試そうとする人の前に立ちはだかるのは、前例主義が多い中でこの失敗の記憶です。番外編「正論」で、過去にカタカナの流行りもので痛い目を見た組織は看板を見ただけで身構える、と書きました。その種を撒いているのが、この売り方なのです。 総額は、先に出せる #  では、発注側の「総予算を早く知りたい」にどう応えればいいのか。実は、アジャイルはこの要望と矛盾しません。  期間とチームの規模を決めれば、総額は先に確定できます。そのかわり、中身は固定しません。優先順位の高いものから作り、届けて、結果を見て、残りの順番を入れ替える。予算と目的を固定し、スコープを動かす。第4回で扱った、全機能を確定せずに見積りを出す話も、この考え方の上にあります。  つまり、発注側が本当に知りたいのは総額であって、全要件の確定ではありません。日本式アジャイルは、総額を出すという名目で要件まで確定し、そのうえで「アジャイルだから柔軟です」と言っている。ずれているのは、ここです。  自分の案件がどちらなのかを見分けるには、一つの問いで足ります。 契約で確定しているのは、要件の一覧か。それとも、予算と目的か。  要件の一覧が確定しているなら、反復をしていても、中身はウォーターフォールです。予算と目的が確定していて、中身が優先順位付きの候補一覧として扱われているなら、アジャイルの自由度は実際に手に入っています。変更依頼の書類を出さずに、項目の入れ替えができるかどうか。実地の判定は、それで十分です。  もちろん、契約の形を変えるのは簡単ではありません。一括請負、社内の開発標準、監査の要件——動かせない前提はいくらでもあります。だからこそ、動かせないうちは、正直な名前で呼んでください。 ハイブリッドウォーターフォール 。この名前なら、発注者は正しい期待値を持てますし、受注側も約束していない柔軟性で責められずに済みます。  番外編「正論」の第三原則は「中身があるなら、看板にこだわるな」でした。今回はその裏返しで、 中身がないなら、看板を掲げるな です。 AI時代の、新しい看板 #  ついでに、これから現れそうな新しい看板にも、会長として注意報を出しておきます。  生成AIを開発の中心に据える「AI駆動開発」や、仕様を先に書いてAIにコードを生成させる「仕様駆動開発(SDD: Spec-Driven Development)」が注目されています。表面だけ見ると、どちらも「先に仕様をきちんと書き、構築はAIで速く回す」形をしています。日本式アジャイルの「要件定義をしっかり固め、構築だけ反復する」と、驚くほどよく似ています。「AI時代こそ上流が大事。やはり要件定義を固めてから作るやり方は正しかった」——そんな売り文句が出てくるのは、時間の問題だと思っています(いや、すでにもう出ているかも…)。  しかし、論理は逆を向いています。SDDの肝は、仕様が先にあることではありません。 仕様を直して作り直すコストが、ほとんどゼロになること です。仕様は機能や増分の単位で小さく書き、動くものを見て学んだら、仕様を直して作り直す。仕様は契約書の付属書ではなく、リポジトリの中のファイルとして生き続けます。  かつて、前工程で大きく設計しておく根拠は「後で変えると高くつく」でした。XPがテストとリファクタリングで崩そうとしたこの前提を、AIはさらに崩します。 後で変えるのが安く済むなら、前で全部を確定しておく理由は、むしろ弱くなる のです。  危ないのは、SDDの形だけを借りて、システム全体の仕様を前工程で確定し、契約で凍結する組み合わせです。それは「AIで速く打鍵するウォーターフォール」であり、間違った仕様から、間違ったものを、今までより速く大量に作る仕組みです(そして人はおそらくそれをレビューしきれない)。  第1回で書いた AIいいなり開発の組織ぐるみ版の完成 と言ってもいいでしょう。  公平のために付け加えると、AIによって「意図を先に、はっきり言葉にする」ことの価値が上がったのは事実です。先に書くこと自体は、アジャイルと矛盾しません。アジャイル宣言が言っているのは「よりも」であって、「書くな」ではありません。アジャイルに反するのは、先に書くことではなく、大きな単位で書いて凍結することです。  だから、先ほどの問いは、AI時代にはこう言い換えられます。 その仕様書は、契約の付属書か。それとも、リポジトリの中のファイルか。  直すたびに変更依頼書が要るなら、AIが何を生成しようと、それはハイブリッドウォーターフォールです。 「日本式」という名前の皮肉 #  最後に、会長として、どうしても言っておきたいことがあります。「日本式」という名前のことです。  「スクラム」の出どころを辿ると、竹内弘高・野中郁次郎の両氏が1986年に発表した論文「The New New Product Development Game」に行き着くことは、多くの方がご存知の通りです。この論文は、工程を順番に引き継いでいく開発をリレー競走に、工程を重ねて全員で一緒に前進する開発をラグビーにたとえ、日本の優れたメーカーの強みは後者にあると論じました。スクラムという名前は、ここから来ています。  ウォータースクラムフォールは、リレー競走の真ん中に、スクラムを一つ挟んだ形です。日本の現場の強みとして世界に紹介されたものの、ちょうど逆をやって「日本式」と名乗っている。  本当の日本式は、とっくに海を渡り、スクラムという名前で逆輸入されているのです。 会則 #  というわけで、当会の会則を参考までに載せておきます。入会も退会も自由です(←冗談だからね。募集してないよ)。 第一条: 看板に偽りなし  アジャイルの自由度が得られない進め方を、アジャイルと呼ばない。要件と予算を先に確定するなら、ハイブリッドウォーターフォールと呼ぶ。 中身がないなら看板を掲げるな 第二条: 過渡期は歓迎、到達点にはしない  ウォータースクラムフォールにいることは恥ではない。ただし、次にどこを崩すかを決めておく。テストを前に寄せる、要件を確定する範囲を小さくする、途中でリリースを挟む。どれか一つでいい。 第三条: 契約を確かめる  「アジャイルで進めます」と言われたら、契約で確定しているのが要件の一覧か、予算と目的かを確かめる。 第四条: 変更は、入れ替えで  変更を歓迎するなら、入れ替えのルールを先に合意しておく。何かを入れるときは、何かを外す。その判断を誰がするかも決めておく。 なぜこんな会があるのか #  会長を名乗るようになったのは、同じ看板の下で、正反対の二つの混乱を何度も見てきたからです。  一つ目は、柔軟性がまったくない現場です。要件定義で決めた機能リストを、スプリントごとに順番に消化していく。私はこれを、 分割払い と呼んでいます。買うものも総額も、契約の時点で決まっている。スプリントは、その支払いを何回に分けるかを決めているだけです。途中で「やっぱりこれは要らなかった」と分かっても、残債は払い続けるしかない。スプリントレビューで利用者の反応を見ても、次のスプリントで作るものは、もう決まっています。反復はしているのに、学習が入り込む隙間がないのです。  二つ目は、その逆です。発注側が「アジャイルだから変更は自由」という看板を真に受けて、変更をどんどん出してくる。ところが予算と要件は確定済みなので、受注側には、その変更を吸収する枠がありません。最初は無理をして受け入れ、残業と品質の低下で吸収し、限界が来たところで変更管理が復活する。発注側からは「アジャイルと言ったじゃないか」、受注側からは「予算の中でと言ったじゃないか」。どちらも、嘘はついていません。  この二つは、同じ看板の表と裏です。一つ目は、受注側が中身をウォーターフォールのまま守った結果。二つ目は、発注側が看板のほうを信じた結果。そして、どちらの現場にも欠けていたものは同じでした。本来のアジャイルで変更が歓迎されるのは、予算と期間という枠の中で、 新しく入れるものの代わりに、優先順位の低いものを外す からです。変更とは追加ではなく、入れ替えのことです。この入れ替えのルールを、誰も契約にも合意にも書いていなかった。分割払いの現場では入れ替えが許されず、変更が殺到した現場では入れ替えが求められなかった。  日本式アジャイルという看板は、このいちばん大事な一行を、どちらの側からも見えなくしてしまうのです。  撲滅と言いながら、当会の目標は会の解散です。「日本式アジャイル」という言葉が使われなくなり、過渡期が過渡期として、正直な名前で呼ばれるようになったら、その時晴れがましい心持ちで当会は解散する事ができるでしょう。 本編の連載一覧は第1回をご覧ください。
 この記事を読んでいただきありがとうございます。アジャイルグループに所属する、藤井智弘です。  前回の持ち越しに続いて、計画を裏切るもうひとつの常連、割り込みの話です。第2回で「第12回あたりでまとめて扱う」と予告した、記録のとり方・当番制・受付の一本化を、ここで扱います。 質問 #  障害対応、他部署からの問い合わせ、上司経由の「ちょっとこれ調べて」…スプリント計画には載っていない仕事が、毎スプリント確実にやってきます。計画はいつも侵食され、スプリントゴールは絵に描いた餅に終わっています。  割り込みを「なかったこと」にせず、スクラムの運用に組み込む方法はありますか? 回答 #  あります。まずは、この質問に付きものの三つの疑問から片づけます。 割り込みは毎回違う顔で来る。予測のしようがないのではないか 割り込みを計画に入れたら、計画分がその分減る。それは「負け」ではないか 断る権限はチームにない。結局、来たものは受けるしかないのではないか  一つずつ答えます。 割り込みは、一件一件は予測できませんが、 総量は、ある幅に収まっていることが少なくありません 。記録すればパターンが見つかります。 割り込みの分だけ計画を減らすのは、 負け ではなく、 実態に即した計画 になっただけです。 断る権限がチームにない——これは、その通りです。自己組織化という言葉が白々しく聞こえるほど、現場のチームに与えられている権限は多くありません。だからこそ 断る役割 を仕組みに持たせます。  要点は3つです。第一に、計測すること。第二に、実績に基づいて最初から割り込み枠を計画に組み込むこと。第三に、割り込みの受け方を設計すること。全員がいつでも受ける体制は、全員の集中を壊す最悪の設計です。 「スプリント中は計画を変えてはいけません」——研修でそう習ったとき、納得できましたか? #  研修で、「スプリントの計画は守るもの」「スプリント中の変更はスプリントゴールを危うくする」と教わった方(あるいは、「そのように理解した」方)は多いと思います。そのとき、こんな疑問を持ちませんでしたか? 本番障害が起きても「スプリント中なので来週」と言えというのか 割り込みを全部バックログに積んで次スプリントに回したら、依頼元が納得しない 教科書の言う「守れるスプリント」は、割り込みのない現場の話ではないのか  この三つは、私が支援先で最もよく聞く反論でもあります。答えから言うと、**守るべきなのは「計画」ではなく「計画の立て方」**です。障害は受ける、ただし枠の中で。全部を次スプリントに回すのではなく、緊急度で仕分ける。そして教科書のスプリントは割り込みがない前提ではなく、割り込みが計画に織り込まれている前提。この答えに至る道筋を、割り込みのアンチパターンから順にたどりましょう。 割り込みのアンチパターン #  割り込みに悩むチームには、いくつかのアンチパターンがあります。一つでも当てはまれば、この記事は役に立つはずです。 その1:想定外型 ——最もよく見かける型です。スプリント計画のとき、割り込みがゼロである前提でキャパシティを見積もる。過去10スプリント、割り込みがゼロだったことは一度もないのに、です。今回は本番障害、前回は他部署からのデータ抽出依頼、その前は営業からの緊急調査。個別の顔に惑わされて、総量が一定の幅に収まっていることを見落とし、「毎回、想定外に苦しむ」という矛盾した状態が常態化します。 その2:直撃型 ——割り込みの依頼が、たまたま声をかけやすい人、たまたま前回対応した人に直接届く型です。特定の人の計画だけが繰り返し崩壊し、その人はいつまでも計画分を達成できなくなります。しかも全員が「いつ割り込まれるか分からない」状態に置かれるため、チーム全体から「集中する時間」が奪われてしまいます。 その3:全部緊急型 ——依頼が「今すぐ」の顔で来て、そのまま「今すぐ」として処理される型です。記録を取ると、「今すぐ」と言われた依頼のかなりの部分が、実は今スプリントでなくてよいものだと分かります。緊急度を判定する関門がないので、依頼元の声の大きさが優先順位になります。 その4:自腹型 ——割り込みを受けても計画分を減らさず、残業と休日で帳尻を合わせる型です。表向きの計画は守られ、ベロシティは安定して見えます。数ヶ月は持ちますが、疲弊と離脱で終わります。マネジメント層からは「割り込みがあっても回るチーム」に見えるので、対策の必要性が最も見えにくい型です。 「例外」という思い込み #  この四つは、一つの前提の違う表れ方です。**「割り込みは例外だ」**という前提です。例外なら計画には入れず(想定外)、来たものは受けた人がそのまま片づけ(直撃)、例外なのだから緊急として扱い(全部緊急)、対応した分は計画の外で吸収する(自腹)。どれも、この前提からは筋の通った対応として出てきます。  ここから、三つの疑問への答えです。  まず、 本番障害が起きても来週と言えというのか 。言いません。障害は受けます。ただし、受ける分をあらかじめ枠として計画に持っておく。割り込み枠はキャパシティの話であって、本番障害の対応で緊急変更の手続きが要るなら、手続きは省略せずに踏み、その対応工数をあらかじめ確保された割り込み枠から取り崩します。枠があれば、障害対応は「計画を壊す事件」ではなく「枠の中で受ける想定内の仕事」になります。  次に、 全部を次スプリントに回したら依頼元が納得しないのではないか 。全部を回すのではありません。緊急度で仕分けます。本当に今すぐのものは枠で受け、そうでないものはバックログに積んで次スプリントの候補にする。この仕分けがあると、依頼元との会話は「受けるか断るか」から「いつやるか」に変わります。ほとんどの場合、納得しないのは断られたときであって、日程を示されたときではありません。  そして、 教科書のスプリントは割り込みのない現場の話ではないか 。違います。教科書が言う「計画を守る」は、割り込みのない前提ではなく、割り込みを見込んだ計画を立てる前提です。守るべきなのは、計画に載せた項目の一覧ではなく、実態に基づいて計画を立てるという立て方のほうです。割り込みが実測で3割なら、その3割を最初から計画の外に見込んでおく。それが「守れる計画」です。   なぜ「割り込みは例外だ」と扱ってしまうのか 。割り込みは、一件一件を見れば毎回違う顔をしているからです。個別には突発でも、総量としてはある幅に収まる——これが割り込みの典型的な姿ですが、個別の顔が強烈なので、総量に目が行きません。  もうひとつ、割り込みはチームの外の組織構造——サポート体制の不備、他部署との役割分担の曖昧さ、エスカレーションルートの未整備——がチームの中に流れ込んでくる現象であり、チームには「本来うちの仕事ではない」という感覚があります。その感覚が「例外」という扱いを正当化します。誰かの怠慢ではなく、構造の話です。  対して、本来の割り込みはどう扱うものか。経験主義の手順に、そのまま乗せれば足ります。 まず割り込みを一件ずつ記録して、総量と発生元とキャパシティに占める割合を見る(観測)。 次に「たまたま今回多かった」を疑い、総量が本当に一定の幅に収まっているか、発生元に偏りはないか、本当に緊急だったのは何割かを確かめる(検査)。 そのうえで、実績の比率で計画枠を決め、受け方を設計し、発生元に応じて割り込みそのものを減らす投資を打つ(適応)。  この順に並べると、想定外型がどこを飛ばしているかがはっきりします。観測がないので、毎回「今回は特別」で終わり、検査も適応も始まらない。「例外として都度さばく」をやめ、「定数として計画に織り込み、受け方を設計する」に切り替える。この見方の転換が、処方の効き目を決めます。 処方 #  当番制や受付経路の形は、現場ごとに違って構いません。気をつけたいのは「枠を取りました」で止まることです。それは、枠が実態と合っているかを誰も見ていない想定外型の変種です。試したあとは、計画分が守れるようになったか、割り込みの総量そのものが減り始めたかで見極めてください。 まず1〜2スプリント、記録する  対処より先に計測です。割り込みが来たら、内容・所要時間・発生元・本当に今すぐ必要だったか、を一行記録します。ツールは何でもいい、付箋でも表計算でも構いません。2スプリント分たまれば、「うちの割り込みは平均でキャパシティの◯%」「発生元の上位は△△部と本番監視」という実像が出ます。この数字が、以降のすべての交渉と設計の土台になります。 実績比率で「割り込み枠」を確保する  実績が30%なら、スプリントの3割は割り込みのために空けておきます。ここで一つ注意があります。ベロシティで計画しているなら、割り込みの分はすでに実績の中に入っています。第9回・第10回で見たとおり、ベロシティは会議も割り込みも含んだ観測値です。そこからさらに3割を引けば、二重に引くことになります。枠を取るとは、その見えない差し引きを、見える形にすることです。時間や人日で積む現場なら、キャパシティの7割で積む。ベロシティで積む現場なら、割り込みを計画分とは別に記録しておく。そうすれば、割り込みの比率が変わったときに、ベロシティの増減のどこまでが割り込みのせいなのかが分かります。  割り込み枠は使い切らなければ儲けもので、前倒しで次の候補に着手すればいい。逆に枠を超えた場合は、超えた事実を記録してPOと共有し、スプリントゴールへの影響を判断してもらいます。「計画が割り込みで壊れた」から「割り込みが枠を超えたので調整した」へ。同じ出来事でも、後者はコントロールされた運用です。 当番制で被害を局所化する  スプリントごとに割り込み対応の当番(1〜2名)を決め、依頼の一次受けをすべて当番に集約します。当番は計画作業をほぼ持たず、割り込みがない時間は小さな改善タスクや翌スプリントの準備に充てる。他のメンバーは割り込みから遮断され、集中して計画分を進める。当番はローテーションし、特定の人への集中と属人化を防ぎます。「全員が薄く邪魔される」よりも「1人が引き受けて、他は守られる」ほうが、チーム全体の完成量は増えます。 受付の門を一本化し、緊急度の判定を入れる  依頼の受付経路を一本化し(当番宛、または専用チャンネル)、「本当に今すぐか、次スプリントのバックログでよいか」の判定を挟んでください。この判定と交渉はPOの仕事です。開発者が個別に「断る」のは負担が大きすぎます。断る役割を仕組みに持たせることが肝心です。そしてこの門は、依頼する側の協力があって初めて機能します。チームは受付を一本化して一次応答の早さを約束し、依頼側は経路を守って「いつまでに・なぜ」を一言添える。どちらか片方だけでは、門はすぐに迂回されます。 割り込みが過半を占めるなら、フレームワークを再考する  実測の結果、割り込みが恒常的に50%を超えているなら、そのチームの主業務には「フロー型」の進め方の方が合うでしょう。第2回で述べた通り、スプリントという固定の箱にこだわらず、カンバンの併用や移行を真面目に検討してください。スクラムは手段であり、実態に合わない箱を守ること自体には何の価値もありません。 なぜこの質問は30年繰り返されるのか #  割り込みは、チームの外の組織構造がチームの中に流れ込んでくる現象です。根本原因はチームの外にあり、チーム内の工夫だけでは完全には消えません。だからこそ、どの時代のどのチームも同じ悩みを抱えます。  しかし、だからといって無力ではありません。計測は交渉の武器になり、枠は計画を守り、当番は集中を守ります。そして「割り込みの◯割は△△部からの定型的な問い合わせ」というデータは、FAQの整備や様々な自動化、セルフサービス化という、割り込みそのものを減らす投資の根拠になります。外から来る問題にも、内側から打てる手は思いのほか多いのです。割り込みが記録され、枠と実績が比べられ、発生元ごとの対策が議題に上がっているかどうか。この三つが揃っているかが、このチームが割り込みを定数として扱えているかの目安になります。 次回: 「POが多忙でチームに来ません」
 この記事を読んでいただきありがとうございます。アジャイルグループに所属する、藤井智弘です。  第9回から前回まで、見積りと予測の話を続けてきました。今回と次回は、その予測を毎回裏切る2つの常連——持ち越しと割り込み——を扱います。 質問 #  毎スプリント、2〜3個のPBIが終わらずに次のスプリントに持ち越しになります。持ち越したPBIのポイントは付け直すべきでしょうか? また、8割方できているものを「未完了」として扱うのは、頑張ったメンバーに酷な気もします。持ち越しが常態化しているチームは、どこから直せばいいでしょうか? 回答 #  質問に3つの問いが含まれているので、短く答えてから本題に入ります。 持ち越したPBIのポイントは付け直すべきか——不要です(理由は後述します) 8割できたものを未完了扱いにするのは酷ではないか——酷なようですが、未完了です。「ほぼ完成」は、リリースできず顧客に価値をもたらさないという点で、「未着手」と同じです 常態化しているチームはどこから直すか——持ち越しを責めることでも隠すことでもなく、原因を分類することから始めます  持ち越しは失敗ではなく、 チームの計画と作業の実態を映す鏡であり大事なデータ です。以下、「完成/未完成を二値で扱い続けること」と「持ち越しの原因を種類で分けること」の二つを軸に話を進めます。 「終わらなかったものは未完了です」と言われたら、ちょっとショックではありませんか? #  研修や現場で、「スプリント内に完成しなかったPBIは未完了です」「部分的な完成にポイントを計上してはいけません」と指導されたことのある方は多いと思います。そのとき、こんな疑問を持ちませんでしたか? 8割の仕事は確かに進んでいる。それを0として扱うのは、実態を歪めているのではないか 持ち越しをゼロにするのが目標なら、最初から少なめに計画すればいいだけでは 頑張ったメンバーの成果が数字に残らない。士気に響くのではないか  三つとも、現場の実感に根ざした疑問です。先に結論だけ言えば、 未完了の扱いは「頑張りの評価」のためではなく「予測の材料の鮮度」の話 です。 8割は進んでいても、残り2割の不確実性は0ではない(ということは、進んだと思っている対象が8割かどうかすら定かではない)。 持ち越しゼロは目標ではなく、原因を潰した結果として起きること。 数字に残すべきは、頑張りではなく完成したかどうか。  持ち越しがどんな扱われ方をしがちかを見てから、順に戻ってきましょう。 持ち越しのアンチパターン #  持ち越しが常態化したチームには、いくつかのアンチパターンがあります。ご自分のスプリントの風景と、重ねてみてください。 その1:出来高計上型 ——「8割できたので8割のポイントを計上」「持ち越し分は残り2割のポイントに付け直し」という型です。ポイントの合計は長い目で見れば同じになるので、一見、害はなさそうに見えます。けれど部分計上はスプリントごとのばらつきを人工的に潰し、前回見た「幅」を実際より狭く見せます。残り2割に潜むリスクも数字から消えます。しかも「あとちょっと」が3日かかった、いやそれどころか予想以上にズルズルと引っ張られた経験は、誰にでもあるはずです。 その2:黙認型 ——持ち越しが毎回出るのに、レトロでもプランニングでも議題にならない型です。「まあ、いつものことだから」。持ち越しは記録されず、原因は分析されず、次のスプリントも同じ量を積みます。持ち越しが常態化しているチームの多くは、実はこの型です。遅れも常態化し、遅れていること自体を問題と思わなくなったチームに出会うことも、珍しくありません。 その3:犯人探し型 ——持ち越しが出るたびに「誰が遅かったか」が問われる型です。以降、持ち越しは隠されるか、言い訳されるか、帳尻を合わされるものになり、原因分析の材料には決してなりません。持ち越しを正直に報告できる空気が失われた時点で、チームは自分の実態を見る手段を失います。 その4:テスト先送り型 ——業務系の現場でよく見る変種です。実装は終わるが、テストだけが毎回間に合わない。テストの多くが手作業のままで、スプリント内に完成(テスト込み)を収めるスピードが出ない。これを「仕方ない」と受け入れてテストをスプリントの外へ追い出し始めると、システムテストや統合テストがリリース間際に積み上がり、スプリントの見た目をした「テスト先送りの一括開発」ができあがります。  本当に恐ろしいのはその先です。結局テストに十分な時間はかけられず、頼みの綱のドキュメントも(ウォーターフォールに比べれば)少ない。その結果、不具合は見つからないまま、迅速にユーザーへ届けられます。 持ち越しを「失敗」にしている前提 #  四つの型は、同じ土台の上に立っています。**「持ち越しは、計画を守れなかった失敗だ」**という前提です。この前提に立つと、失敗は小さく見せるべきであり(出来高計上)、触れないほうがよく(黙認)、責任者がいるはずであり(犯人探し)、間に合わない工程は外に出せばよい(テスト先送り)、という判断がすべて自然に導かれます。  先ほど保留した三つの疑問を、ここで解きます。  まず、 8割を0として扱うのは、実態を歪めているのではないか 。逆です。8割を8割として扱うほうが、実態を歪めます。スクラムが「完成の定義」にこだわるのは、精神論ではありません。8割できたPBIの残り2割には、テストの通っていないコード、レビュー未了の設計、結合していないモジュールが含まれています。ソフトウェア開発では、残り2割にこそリスクが集中します。完成/未完成の二値で扱うのは、この「あとちょっとの不確実性」を計画から締め出すための規律です。部分ポイントを計上したり、持ち越し時にポイントを付け直したりしないのも同じ理由で、中途半端な計上はベロシティを実態より滑らかに見せます。持ち越したPBIは元のポイントのまま運び、完成したスプリントで初めて計上する。地味ですが、これが一番嘘のない記録になります。なお、対外的に出来高ベースの進捗報告が必要なら、それは別に出せばよい。ただしベロシティには混ぜないことです。  次に、 最初から少なめに計画すればよいのではないか 。半分正解です。前回、確実に入れる分は悲観値で積む、と書きました。それでも持ち越しが出るなら、問題は量ではなく、PBIの大きさか、キャパシティの見立てか、着手の仕方にあります。量を減らすだけでは、見かけは解決したように見えて、本質的な原因は残ったままです(そして、それ以降も繰り返し同じ状況に陥ります)。持ち越しゼロは目標ではなく、原因を潰した結果として起きることです。  そして、 頑張りが数字に残らないのは士気に響かないか 。響くとすれば、それは「完成」がチームの成果として認められていないからです。 ベロシティは個人の頑張りを記録する数字ではなく、チームが届けたものを記録する数字 です。個人の8割を認めたいなら、数字ではなく言葉で認めればよい。数字に残すのは完成だけ、という規律のほうが、長い目で見ればメンバーを守ります。「あと少しなのに」を毎回抱えるより、小さく切って毎回完成させるほうが、はるかに気持ちのよい働き方だからです。  では、 計画を守れなかった失敗という前提 の出どころはどこか。計画は守るものであり、未達は失敗であるという、スクラム以前からの規範に根ざしています。線表を引き、遅れを詰め、未達には理由書を出す——多くのメンバーはこの文化を身につけてからスクラムチームにやってきます。責める相手はいません。ただ、スプリントの計画に、この古い規範がそのまま適用されるだけです。  一方、本来の持ち越しは何か。スクラムはこの規範を反転させます。計画は仮説であり、未達は仮説と現実のずれを教えてくれる観測結果です。   観測 ——何が、どれだけ、なぜ終わらなかったかを、そのまま記録する。責めず、隠さず、丸めない。   検査 ——持ち越しの原因を種類で分け、繰り返しているものを見つける。「誰が遅かったか」ではなく「今回の持ち越しはどの種類か」を問う。   適応 ——種類ごとに、次のスプリントの計画や作業の仕方を変える。分割、キャパシティの見直し、着手の仕方。  これに照らすと、黙認型と犯人探し型の欠落が見えます。前者は観測を捨て、後者は観測を歪めている。どちらも検査に進めません。「失敗を小さく見せる」から「原因を分類して次の計画に反映する」へ、向きを変えます。この転換が、以下の処方の土台になります。 処方 #  ここからは手当てです。分割の技法もキャパシティ設計も有識者の提案は多く、どれを選んでもかまいません…と、毎回言ってますね。そのうえで一つだけ。「分割しました」と言って終わりなら、原因を見ずに量を減らしたのと同じことです。試したあとは、持ち越しの数が減ったか、減っていないならどの種類が残っているかで判断してください。 まず、持ち越しを3種類に分類する  手を入れる前に、分類から始めます。何につけ、事実を把握するのは、改善への最初の一歩です。持ち越しが毎回出るチームのスプリントを観察すると、原因はおおむね3種類に分類できます。 大きすぎる : PBIが1スプリントに対して大きすぎる、または受け入れ条件が曖昧で「どこまでやれば完成か」が着手後に膨らんでいく 削られた : 割り込み対応や欠員で、計画時に想定したキャパシティが実際には存在しなかった 散らばった : 全員がそれぞれ別のPBIに着手し、どれも8割で並ぶ。着手数が多すぎて完成が出ない  持ち越しが出たスプリントのレトロで、1個ずつ「1〜3のどれか」を問い、記録してください。3スプリント分たまると、このチームの持ち越しがどの種類に偏っているかが分かり、処方が選べます。犯人探しは分類を歪めます。持ち越しを正直に報告できる空気は、それ自体がチームの資産です。 原因1(大きすぎる)には、分割の技術を  「1スプリントに収まるか微妙」なPBIは、計画の時点で分割します。分割の切り口はいくつもあります。ワークフローのステップで切る(申請だけ→承認は次)、受け入れ条件で切る(正常系→異常系)、対象データで切る(国内のみ→海外対応)、品質レベルで切る(手動運用前提→自動化)。第4回の言い方をすれば、これらはどれもユースケースのフロー——基本フローと代替フロー——の分かれ目で切っています。分割の切り口を探すとは、フローの分かれ目を探すことです。「機能を薄く縦に切る」(画面からデータベースまでを貫く、細い一本の動線ごとに切る)感覚を掴むまでは、リファインメントに少し多めに時間を使う価値があります。経験則として、1PBIがスプリントの2〜3日分に収まるサイズになると、持ち越しは目に見えて減ります。 原因2(削られた)には、実績ベースのキャパシティ設計を  割り込みが恒常的にあるなら、それは想定外ではなく想定すべき定数です。対処は第12回(次回)で詳しく扱いますが、要点は、割り込みの実績を計測し、割り込みの枠を計画の中に見える形で持つことです。 原因3(散らばった)には、スウォーミングを  「新しい項目に着手する前に、終わりかけの項目を終わらせる」を合言葉にしてください。第6回の「ボードの右端から歩く」デイリー——完成に一番近いPBIから「これを今日終わらせるには?」と見ていく——は、そのままスウォーミングの入口になります。複数人が1つのPBIに群がって片づける働き方(スウォーミング)は、個人の稼働率は下がって見えますが、スプリントの完成数は増えます。稼働率ではなく完成を最適化する——アジャイルの中でも直感に反しやすく、そして効果の大きい転換です。スプリントゴールが「積んだものを全部終わらせる」ではなく「この業務がこう回るようになる」と書けていると、どのPBIに群がるべきかも自然に決まります。 テスト先送り型には、テスト自動化への投資を  この変種の根本対処は、分割やスウォーミングではなくテスト自動化への投資です。テストを人海戦術で吸収する前提の体制が長く続いた組織ほど、この投資は後回しにされがちですが、スプリント内で「完成」できない構造を放置する限り、持ち越しは消えません。自動化への投資については、今後の回で採り上げる予定です。 なぜこの質問は30年繰り返されるのか #  「持ち越し=悪」という感覚は、研修一回で消えるものではありません。線表と理由書の文化を身につけた新しいメンバーが入るたびに、チームに持ち込まれ直します。だからこの質問は、チームが入れ替わるたびに、形を変えて戻ってきます。  持ち越しを罰するチームは同じ持ち越しを繰り返し、持ち越しから学ぶチームは計画の精度を上げていきます。 分かれ目は能力ではなく、未達をどう扱うかの文化 です。今回の持ち越しは種類ごとに記録され、次の計画に反映されたか——その一点で、このチームが持ち越しをデータとして使えているかが分かります(念のため: これは社内の計画観の話です。対外的な納期の約束を軽んじてよい、という話ではありません)。 次回: 「割り込み作業はどう計画に入れる?」
はじめに # 開発計画などの全体像を表現する際に用いられることが多い2次元マトリクス図。多くの場合、オフィスソフトや作図ツールで作成されます。 資料内で完結しているものや、継続的なメンテナンスが必要ないものはそれで十分ですが、継続的に変更/公開する必要がある場合はイメージファイルを更新し続けるのは大変です。 そこでYAMLで定義したマトリクスデータをもとにイメージファイルを生成するNode.jsのCLIツール(matrix-renderer)を公開しました。 本記事では、このツールを作った背景や具体的な使い方について紹介します。 出力例 GitHubリポジトリ npmパッケージ 作成のきっかけ # あるタスクでガイドやツールの提供状況を明示するため、X軸にフェーズ、Y軸に技術的な関心領域(アプリ、インフラ、プロセスetc)をとるマトリクスが作成されていました。 これを社内サイトへ公開し、ガイドやツールの作成状況に合わせて更新し、アイテムから該当するサイトへ遷移させたいという要望があがっていました。 このマトリクスはオフィスソフトで作成されていたため、変更されるたびイメージとして書きだして公開先へアップロードし直す必要があります。 継続的な更新を必要としない場合は十分ですが、継続的な変更/公開を必要とするケースでは負担になるだろうと考えて、プロジェクト外でひっそりとツールを開発していました。 プロジェクトへの展開も考えていましたが、残念ながらSVGが使えずリンク要件を満たせなかったため、展開は断念。 とはいえ、同じような課題を持つケースはあると思うので簡易に使用してもらえるOSSとして公開することにしました。 類似するツールとの違い # オフィスソフトや作図ツールと比べて得意なことと苦手なこと。 得意なこと # 継続的な更新がしやすい 更新対象がYAMLなので変更しやすく、差分管理もしやすい 更新と連動した公開が可能 CLIツールなので、ビルドスクリプトやCI/CDパイプラインに組み込むことでYAMLファイルの更新をトリガーにしてイメージ生成できます。 デプロイもCI/CDパイプラインに組み込めば、YAMLファイルの更新からイメージ公開までを自動化できます。 レイアウトは自動配置が基本 X軸、Y軸は等間隔で自動設定します。 指定されたX軸の開始列/終了列およびY軸の行にアイテムを配置します。 同一Y軸内で空いている領域があった場合は、アイテムを自動配置します。 同じ行のアイテム同士で期間が重なる場合は、表示が重ならないよう縦方向に配置します。 アイテムからのリンク SVGのみですが、アイテムへのURL埋め込みに対応しています。 苦手なこと # レイアウトの自由度が低い 特定のX軸、Y軸の幅は等間隔でカスタマイズできない 同一Y軸内は自動配置するため、アイテム前後関係を付けるようなことはできない。 多様な図形を用いた作図などは対応してない 利用シーン # 以下のような「2軸で物事を分類・マッピングし、継続的にドキュメントとして公開したい」ケースに最適です。 フェーズ × 技術分類でツールやガイド管理 組織の技術領域 × 習熟度/採用フェーズ(Adopt, Trial, Assess, Holdなど)で技術スタックの習熟度管理 対応段階(平常時、検知、初動、原因調査、復旧、振り返り)× 担当領域で障害対応の手順やツールをマッピング 部門 × 分類でシステム、機能の一覧 使い方 # 実行環境 # Node.js 24+ YAMLを用意する # 以下は、開発フェーズごとにガイドやツールを配置する入力ファイル例です。 テンプレートの詳細は こちら を参照してください。 title: 社内標準DevOpsツールチェーン & 開発ガイドマップ width: 1000 xAxis: label: 開発フェーズ values: - label: 要件定義・計画 color: "#e8f8f5" - label: 設計・ビルド color: "#eaf2f8" - label: テスト・検証 color: "#ebf5fb" - label: デプロイ color: "#f4ecf7" - label: 運用・監視 color: "#fcf3cf" yAxis: label: テクノロジースタック values: - label: アプリケーション開発 color: "#fdfefe" - label: インフラ・CI/CD color: "#fdfefe" - label: セキュリティ・品質 color: "#fdfefe" items: - id: git-guide label: GitHub・Git利用規約ガイド lane: アプリケーション開発 from: 要件定義・計画 to: 設計・ビルド url: https://your-wiki.internal color: "#2471a3" - id: sonar-qube label: SonarQube(静的解析コードレビュー) lane: セキュリティ・品質 from: 設計・ビルド to: テスト・検証 url: https://internal.net color: "#e67e22" - id: play-wright label: Playwright(E2E自動テスト実行環境) lane: セキュリティ・品質 from: テスト・検証 to: テスト・検証 url: https://your-wiki.internal color: "#cb4335" - id: argo-cd label: ArgoCD(本番環境デプロイフロー) lane: インフラ・CI/CD from: デプロイ to: デプロイ url: https://internal.net color: "#1e8449" - id: datadog-ops label: Datadog監視ダッシュボード lane: インフラ・CI/CD from: 運用・監視 to: 運用・監視 url: https://internal.net color: "#6c3483" 項目 内容 タイトル マトリクスのタイトル 幅 出力イメージの幅 X軸 ラベル、表示順および色 Y軸 ラベル、表示順および色 アイテム ID、表示名、配置する行、開始列、終了列、色、URL アイテムは lane で配置する行を、 from と to で開始列および終了列を指定します。 同じ行のアイテム同士で期間が重なる場合は、表示が重ならないよう縦方向に配置します。 xAxis.values と yAxis.values に書いた順番が、イメージ上の表示順になります。 イメージ幅( width )、色( color )、URL( url )は省略可としています。 イメージファイルを生成する # YAMLファイルからイメージファイルを生成するコマンド例は下記の通りです。 コマンドについては こちら を参照してください。 # 1. 入力ファイルのみ指定 npx matrix-renderer render -- docs/matrices/matrix.yaml # 2. 出力ファイル名を指定 npx matrix-renderer render -- docs/matrices/matrix.yaml --out public/images/development-guide # 3. 出力ファイル種類を指定 npx matrix-renderer render -- docs/matrices/matrix.yaml --out public/images/development-guide --out-type svg 1: 入力ファイルと同じフォルダーにイメージを生成します。 docs/matrices/matrix.png docs/matrices/matrix.svg 2: 指定されたディレクトリ/ファイル名でイメージを生成します。 public/images/development-guide.png public/images/development-guide.svg 3: 指定されたファイル種類のみ生成します。 public/images/development-guide.svg CI/CDに組み込む # GitHub ActionsなどのCI/CDワークフローに生成コマンドを追加すれば、YAMLの変更に合わせてイメージを再生成できます。 以下はGitHub Actionsのワークフロー例です。 name: Build documentation on: push: branches: [main] paths: - "docs/matrices/**" - ".github/workflows/docs.yml" jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - uses: actions/setup-node@v7 with: node-version: 24 - name: Render matrix images run: npx --yes matrix-renderer@0.1.2 render -- docs/matrices/development-guide.yaml --out public/images/development-guide # このあとデプロイを実施 まとめ # matrix-rendererを使うと、YAMLで定義したマトリクスデータからPNGおよびSVGを生成できます。 オフィスソフトなどで作図する場合に比べると見た目調整の自由度は下がりますが、データの変更を差分として確認でき、CI/CDでの再生成にもつなげやすくなります。 ツール自体が公開サイトへのデプロイを行うわけではありませんが、CI/CDに組み込むことでYAML更新からデプロイまで自動化できます。 自由なレイアウトよりも、マトリクスを継続的に更新したい場合にDoc as Codeの選択肢として活用できます。 より詳細なコマンド、YAMLテンプレートおよびサンプルなどを GitHub に掲載しているので、そちらも併せてご覧ください。
はじめに # 3年ちょっと前に自作の VS Code 拡張を更新した記事を書きました。VS Code の拡張を Marketplace に公開するには、日頃全然使わない Azure DevOps のプロジェクトで PAT(Personal Access Token)を発行する必要があり、その手順が超絶がわかりにくかったので自分用のメモとして残したものです。 /blogs/2023/07/11/update-vs-code-extension/ 筆者が公開している VS Code 拡張は ChangeLog を書くことを支援するものです。 https://marketplace.visualstudio.com/items?itemName=kondoumh.changelog-support GitHub リポジトリはこちらです。 https://github.com/kondoumh/changelog-support --> Information 前の記事にも書きましたが、Markdown の CHANGELOG.md ではなく、Emacs などでサポートされている (Markdown 登場以前からある) ChangeLog フォーマットを扱うものです。ChangeLog を日々の作業のログ・メモとして活用するというための拡張です。 初めての機能拡張リクエスト # この Marketplace で星もついてない、5年間で620件ぐらいしかダウンロードされていない弱小 Extension に 先日 enhancement issue が3つも登録されていました。 auto-complete / go to filenames · Issue #27 · kondoumh/changelog-support setting language "none" to not insert weekday · Issue #28 · kondoumh/changelog-support add outline · Issue #29 · kondoumh/changelog-support auto-complete / go to filenames · Issue #27 「ChangeLog のアイテム入力中に、ファイル名を補完したり、更にファイル内の関数などのシンボル(トークン)の入力もアシストしてほしい。さらに当該ファイルにジャンプする機能も欲しい。」という要望でした。自分のユースケースでは使わないですが、よく IDE にある機能ですし面白そうなので対応することにしました。ディレクトリ内のファイル列挙やパースの実装が肝になりそうです。VS Code 拡張で実装するには、CompletionProvider や DefinitionProvider にこれらの情報を渡す必要があるようです。 setting language "none" to not insert weekday · Issue #28 changelog-support では日付のエントリを出力する機能があり、曜日を日本語で出すか英語で出すかを dayOfWeekLang オプションの en/ja 切り替えで指定していました。 en の場合 2026-10-23 Fri <kondoumh@local> ja の場合、 2026-10-23 (金) <kondoumh@local> dayOfWeekLang のオプションに none を追加して曜日を出さないオプションを追加して欲しいという内容でした。シンプルな内容なので対応することにしました。 add outline · Issue #29 ChangeLog のアウトライン構造を、VS Code 標準のアウトラインウィジェットに表示して欲しいという内容です。アウトラインは DocumentSymbolProvider を使って実装する模様。拡張の機能としてはリッチ感も出るので、これも対応することにしました。 成果 # #27 ファイル名トークン補完とジャンプ # ChangeLog のアイテム * hoge: の入力中、ディレクトリ内の ChangeLog ファイルからの相対パスにあるファイルから入力中アイテムにマッチするファイルを列挙し、選択時に補完。さらに * hoge() の入力を検知したらファイル内をパースしてシンボルを列挙。アイテム右クリックでファイルジャンプできるようになりました。自分では使わないけど便利そうです☺️ #28 日付エントリに曜日を出さないオプションを追加 # これはシンプルな機能なので割愛。 #29 アウトライン表示 # ChangeLog の内容がアウトライン表示されるようになりました。拡張っぽくてかっちょいい😄 --> さらなるリクエスト enhancement issue を登録してくれた人が「せっかくだから VSX Registry にも公開してよ。」っていうリクエストの issue を登録してました。Marketplace へのアクセスが阻まれている時や VS Code 互換の Web IDE に拡張を入れたい時などには VSX が簡単に入手できるようにしておくと便利なんでしょうね。 FR: please co-publish on Open VSX Registry · Issue #34 · kondoumh/changelog-support これは次のリリース時にでも対応したいと思います。 Marketplace への公開手順のアップデート # 前回の記事のとほぼ同じ手順で Azure DevOps プロジェクトで公開用の Personal Access Token を発行。UI は多少変わってましたが、なんとか迷わずにたどり着けました。 前回は vsce コマンドは、npm でグローバルインストールしてましたが、npx でサクッと実行する方がスマートでした。 npx @vscode/vsce login <publisher-name> npx @vscode/vsce publish --> Information 今回は、バージョンを 0.3.0, 0.3.1 と上げましたが、npm の version サブコマンドを使いました。 npm version 0.3.0 package.json(+ lockfile)更新、コミット、タグ作成まで一気にやってくれます。こんな便利なコマンドがあるの知りませんでした(Copilot さんに教えてもらいました)。 Node.js 何年使ってるねんって感じですが、まあこんなもんです。 package.json の engines.vscode のバージョン(以下の例では 1.80.0)を devDependencies の @types/vscode のバージョンと整合させないといけないのを把握しておらず、公開時にエラーでハマりました。このバージョン指定は新しければよいというものでもなく、サポートする VS Code の最古バージョンを指定するので注意が必要です。 package.json { "name": "changelog-support", "description": "Extension to support writing ChangeLog.", "version": "0.3.1", "publisher": "kondoumh", "engines": { "vscode": "^1.80.0" }, : "devDependencies": { "@types/vscode": "1.80.0", }, さいごに # 前回記事のおかげで、Marketplace への公開時に浦島太郎にならずに済みました。やはりブログは自分用の記録としても書いておいた方がいいですね。 熱心なユーザー?から機能拡張リクエストをもらうと、やはり作者としてはモチベが上がりますね。 今回は、ファイルのパース部分とか、アウトライン構造の構築などで、ゴリっとコードが増えてましたが、AI が既存コードも理解してやってくれるので、3つの issue の対応は要件把握からテストまで含めて2時間ぐらいで終わりました。 個人開発の OSS でも issue 到着からリリースまでのリードタイムが短縮できますね。
 この記事を読んでいただきありがとうございます。アジャイルグループに所属する、藤井智弘です。  第9回(人日換算)を書いていたら、「そもそもストーリーポイントは何を測った数なのか」というところで話が膨らんでしまい、本編に収まらなくなりました。急遽、番外編として切り出します。いつもの質問と回答の形は取らず、一つの疑問を最後まで追いかける、 理屈っぽい 回です。  でも、好きなんです、そういうの、わたし。 引っかかっていた疑問 #  ファンクションポイント(FP)を使っていた現場から来た方に、何度か同じことを聞かれました。「FPは属人性を排した客観的なサイズで、計数規則に従えば誰が数えてもほぼ同じ値になる。ストーリーポイントも相対値で属人性を排すと聞いたのに、同じ項目でメンバーによって3と8に割れる。属人性を排した見積りのはずなのに、見積値が人によって違うのは、何かが間違っているのではないか」。  白状すると、私自身がこの疑問を長いあいだ持っていました。研修ではこう説明されます。「人日は人によって違うが、大きさの比は誰が見ても同じ。だから相対値で見積もれば属人性を排除できる」。それなら、なぜプランニングポーカーで値が割れるのか。割れるのが前提なら、「属人性を排した」とは何を排したのか。FPのように規則で数えれば割れないのに、なぜそうしないのか。  答えを先に言うと、二段あります。一段目は、「属人性」という言葉が二つの違うものを指していて、ストーリーポイントが排しているのはその片方だけだ、ということ。二段目は、そもそもアジャイルの計画づくりは、見積りの時点での精度に賭けていない、ということです。一段目から順に追います。 属人性は一つではない #  一つ目は、 作る人への依存 です。同じ項目が、Aさんなら2日、Bさんなら6日かかる。二つ目は、 見積もる人への依存 です。同じ項目を、Aさんは3、Bさんは8と見立てる。  FPは、この両方を排除しようとした測定法です。外から観察できる機能(入出力、照会、内部ファイル、外部インタフェース)を規則で数えるので、誰が作るかに依らず、訓練された計測者なら誰が数えても近い値に収束します。FPは「測定」であり、測定とは、同じ対象を同じ手続きで測れば同じ値が出ることを目指す営みです。  ストーリーポイントは、一つ目の属人性だけを扱います。しかも排除するのではなく、逃がします。Aさんなら1日と2日、Bさんなら3日と6日。絶対値は3倍違っても、「後者は前者の2倍」という比は同じです。チームの誰が取るか分からない項目を見積もるとき、比だけを持っておけば、誰が取っても使えます。「相対値だから属人性を排せる」の実際の中身はこれで、 期待しているのは比の不変性のことであって、絶対値の客観性ではありません 。  二つ目、見積もる人による違いは、最初から排除していません。ストーリーポイントは測定ではなく判断で、しかも「そのチームの目盛り」に基づきます。目盛りの原器は計数規則ではなく、基準ストーリー——「ログイン画面の修正を3とする」という実在の項目——です。だから評価者によって値が割れるのは欠陥ではなく、相対的見積りの仕様です。 割れるのは、そういう設計だから #  プランニングポーカーが「全員が同時に出して、割れたら話す」という形をしているのは、割れそのものを情報として使うためです。3と8に割れたなら、片方だけが知っている地雷があるか、仕様の読みが食い違っているか、どちらかです。3を出した人は簡単な抜け道を知っていたのかもしれないし、8を出した人は前に同じ場所で痛い目に遭ったのかもしれない。それを出し合ってから一つの値に収束させる。評価者間のばらつきは、排除する対象ではなく、理解の差を見つける検出器です。そして収束させる手続きは、目盛りを校正する作業です(ああ、卒論の実験を思い出す…)。  ここで割れを平均して5にすると、手早いのですが、割れが持っていた情報を捨てることになります。5は誰の見立てでもない数字です。一番詳しい人の値で決めるのも、たいてい正しい値には近づきますが、それは「その人の目盛り」であって「チームの目盛り」ではありません。その人が休んだスプリント、抜けた後に、目盛りは揺れます。規則表を作って「画面1つは3、テーブル1つは2」と数えれば割れはなくなりますが、規則に載っていない難しさ——古いコードの地雷、曖昧な仕様、初めての技術——が数字から消え、 ポイントは客観的になった分だけ、このチームの実感から離れます 。割れを消す方法はいくつもあり、どれも「ポイントは客観的な測定値であるべきだ」という思い込みから出ていて、どれも目盛りの校正を止めてしまいます。  ストーリーポイントが目指しているのは、 客観性ではなく、チームの中での一貫性 です。ただ、ここで「客観的でなくてよい」と言われても、FPを知る人は納得しないでしょう。客観的でない数字の精度を、どう担保するのか。この問いが、二段目に続きます。 正確さは、どこで作るのか #  見積りの産物は、数字ではなく判断です。ポイントを付けて決めているのは、「このスプリントに入れて大丈夫か」「分割するべきか」「先に調べるべきか」の三つで、それ以上のことはあまり決めていません。この判断に要る精度は粗くてよい。3か5かで判断が変わることはめったになく、3か13かなら変わります。だからポイントの目盛りは1、2、3、5、8、13と飛び飛びで、その飛び方が「この程度の違いしか見分けない」という宣言になっています。ポイントは、経験に基づく見立てを、判断の素材として数にしたものです。誤差があっても、判断を変えるほど大きな誤差でなければ、用は足ります。  もう一つの用途、合計をベロシティで割って見通しを出すほうでは、個々の誤差は足し合わせるうちに相殺されます。3と見立てて5だった項目と、5と見立てて3だった項目は、合計では打ち消し合う。だからばらつきは構いません。構わないのは偏りのほうで、全員が一方向に盛る、インフレする、という偏りは相殺されずに合計に残ります。ベロシティを「安定させる」ために数字を盛るのが問題なのは、ばらつきではなく偏りを作るからです。  では、粗い見立てで済ませて、誤差が大きくなりすぎないのはなぜか。ここがアジャイルの計画づくりの要で、誤差は見積りの手法ではなく、 見積りの対象の大きさ と、 答え合わせまでの時間 で決まります。項目が小さければ、8が80になることはありません。誤差は項目の大きさに比例し、不確実性に比例するので、項目を小さくし、リファインメントで不確実性を先に潰しておけば、見立てが粗くても誤差の上限は小さい。スプリントが短ければ、見立てが外れていたことが数週間で分かり、次の見立てに反映されます。一度に多くのことを手掛けないのも同じ理由で、同時に走る項目が少ないほど、一つの外れが他に波及しません。アジャイルは、見積りの精度を上げることではなく、 見積りの外れが大きくなれない構造を作ることで、誤差を制御 しています。  この構造の話を抜きにして、見積りの手法や精度を論じても意味がありません。ここが、FPとストーリーポイントの対比の、一番深いところです。  FPが見積りの時点での精度を必要とするのは、スコープを最初に固定し、あとから誤差を縮める仕組みを持たない前提だからです。見積りが外れたら、それを補う機会が後ろにない。だから前で精度を作るしかなく、誰が数えても同じ値になる規則が要ります。  ストーリーポイントが粗い見立てで済むのは、精度を作る場所が見積りの時点ではなく、その周りの仕組み——小さい項目、短いスプリント、リファインメント、そして実績のベロシティ——にあるからです。   FPとポイントの違いは、精度の高低ではなく、精度をどこで作るかの違い です。冒頭の「なぜ規則で数えないのか」への答えは、「客観性が要らないから」ではなく、「見積りの時点の精度に賭けていないから」です。 属人性はどこへ行ったのか #  一段目に戻って、属人性は消えたのか、という問いに答えておきます。消えていません。置き場所が違うだけです。  FPは、サイズを客観化した代わりに、工数に換える段階で様々な「特性の調整や生産性を反映した係数」——このチーム・この技術なら1FPあたり何時間——を別途必要とします。属人性は、この係数に追い出されています。サイズは客観、係数は属チーム、という分担です。  ストーリーポイントは、見積もるチームと作るチームが同一であることを前提に、チーム固有の条件を見積りそのものに折り込みます。残った校正のずれは、ベロシティに吸収されます。目盛りが多少歪んでいても、ベロシティが同じ歪みで測られるので、予測は当たります。属人性は、ベロシティに追い出されています。サイズも目盛りも属チーム、だからベロシティも属チーム、という分担です。前の節で挙げた「答え合わせまでの時間」を短くする仕組みが、この吸収を毎スプリント回しています。  どちらの方式でも、属人性は排除されたのではなく、置き場所が違うだけ。そう見ると、ストーリーポイントのほうが正直に閉じている、とも言えます。FPは「客観的なサイズ」と「属チームの係数」を別の人が別の場所で扱うので、係数のほうが放置されがちです。ストーリーポイントは、見積もる・作る・測るを一つのチームの中で閉じるので、歪みが自動的に相殺されます。 それで、ポイントは何を測った数なのか #  ここまで来ると、言えます。ストーリーポイントは、作るものの 大きさ ——量と複雑さと不確実性を合わせたもの——についての、そのチームの 経験に基づく見立て を、チームの目盛りで相対値にしたものです。測定値ではなく見立てであり、数字そのものより、それを使って下す判断のほうが産物です。  工数が「人が投入する時間」という投入側の量であるのに対して、大きさは作る対象の側の量です。この二つを結ぶのがベロシティで、「このチームなら、この大きさをこのペースで片づける」という換算係数を、事後の観測として持っています。だから、ポイントは「工数の別名」ではありません。かといって、FPのような「客観的なサイズ」でもありません。チームの中でだけ通用する、大きさの通貨です。  冒頭の疑問に戻ります。ストーリーポイントは、属人性を排した見積りではなく、 属チーム的な見積り です。排しているのは「誰が作るか」の絶対値だけで、それも比に逃がしているにすぎない。評価者間のずれは排除の対象ではなく、合意の材料。そしてこの方式が成り立つ条件は、客観性ではなく、見積もる・作る・測るが同じチームで閉じていることと、外れが大きくなれない構造が周りにあることです。  閉じていない使い方をした瞬間に、目盛りの属チーム性が露出して壊れます。チーム間でポイントを比べる。別のチームが見積もって、別のチームが作る。換算表で外に出す。第9回で見た四つの型は、全部この「閉じていない使い方」でした。あの回で「換算値は項目の原価、ベロシティはチームの流量」と書いた違いの根も、ここにあります。流量はチームの中でしか意味を持たない目盛りで測られているから、外に出した瞬間に原価のふりをし始めるのです。 現場での、ささやかな心得 #  理屈はここまでで、現場でやることは多くありません。  目盛りの原器は基準ストーリーなので、実在の項目を2〜3本「これを3とする」と決め、見積りのたびに参照してください。メンバーが入れ替わったら、新しい人と一緒に基準ストーリーを見直す。目盛りは規則ではなく共有された記憶なので、記憶を引き継ぐ手続きが要ります。  割れたら、平均ではなく話す。両端の人が理由を一言ずつ言うだけで、たいてい地雷か読み違いが出てきます。収束しなければ、項目を分割するか、調査を先に切る。  見積りの精度を上げたくなったら、手法ではなく、項目の大きさとスプリントの長さを疑う。13以上の項目が常態なら、見積りの技法をいくら磨いても誤差は縮みません。先に割ってください。  目盛りをチームの外に持ち出さない。外に出す数字は、第9回で書いたとおり、合計ポイントと実績のベロシティから導いた期間と金額です。  そして、契約上どうしても属人性を排したサイズの根拠が要る場面では、ポイントを客観化しようとするのではなく、FPのような測定法を別途使ってください。ポイントは計画の道具、FPは契約の道具。兼用させると、どちらの役目も果たせなくなります。念のため付け加えると、FPの客観性も理念上のもので、実務では計測者間に1〜3割のばらつきが出るという報告が昔からあります。ただ「規則で数える」と「合意で決める」は理念として別物なので、違う目的の違う道具として、両方を持っておけばよいのです。 おわりに #  私たちの多くは、見積りとは「正しい値を当てる」ものだと教わってきました。FPはその文化の到達点で、誰が数えても同じ値になることを目指して作られています。この文化の中でストーリーポイントに出会うと、「客観的でない見積りは未熟な見積りだ」と感じるのは自然です。私もそうでした。  けれども、アジャイルの計画づくりは、正しい値を当てることではなく、実績から予測を更新し続けることを目指しています。そのために大事なのは、値が客観的であることより、同じ目盛りで測り続けることと、外れが大きくなれない構造を保つことです。精度は、見積りの中ではなく、見積りを取り巻く仕組みの中にあります。チームの中でポイントが割れたら、それは属人性の露出ではなく、理解の差が見つかった瞬間です。そこで話せているか、そして項目は十分に小さいか。この二つが、このチームの見積りが道具として働いているかの、一番簡単な確認になります。
 この記事を読んでいただきありがとうございます。アジャイルグループに所属する、藤井智弘です。  第6〜8回はスクラムのイベントの話でした。今回から、見積りと予測の話に入ります。イベントの回と同じく、「本音の疑問→アンチパターン→勘違いの根っこ→処方」の順で読み解いていきます。 質問 #  ストーリーポイントで見積りをしていますが、上司から「で、それは何人日なの?」と毎回聞かれます。答えに窮して、結局「1ポイント=0.5人日」のような対応表を作ってしまいました。これでは相対見積りの意味がない気がしますし、どうせ換算するなら最初から人日で出せばいいのでは、と思ったりもします。  どう答えるのが正解だったのでしょうか? 回答 #  表現の違いこそあれ、ご質問のテーマ(ポイントと人日)は、ほんとによく訊かれます。3つの観点でこの質問を考えてみましょう。 上司が必要としているのは、本当に「人日」なのか 換算表を作ったのは、そんなに悪いことだったのか 人日で答えないなら、いったい何で答えればいいのか  上から順に答えます。 上司が本当に必要としているのは、たいてい人日ではなく、「いつ終わるの?(あるいは、間に合うの?)」「いくらかかるの?」です。人日はそれらを導き出す手段として訊かれているにすぎません。 換算表を作ったこと自体は責められません(だって訊かれているんですから)。ただし、「1ポイントは〇〇に相当」と固定した瞬間に、いくつかの歯車が——後に述べるように——逆回転を始めます。「最初から人日で」という気持ちはもっともで、見積りの場で全員が人日に直しているなら、そのほうが正直です。ただ、そうなった原因はポイントではなく、換算表のほうにあります。 答えるべきは、ポイントの換算値よりも、 リリース予測とコストの見通し です。実績のベロシティと残りのバックログから「◯月上旬〜◯月下旬に完了見込み」と幅つきで答えられれば、社内の上司のニーズはたいてい満たされます(ただし、発注側の稟議に人日が要る場合は別ですが、それは処方で扱います)。  質問の字面を追わず、その質問の裏にある「相手がほんとに知りたいこと」に答える——これに尽きます。話の軸は二つ、「上司の質問を翻訳すること」と「外側に出す数字と、内側の計画の道具を分けること」です。 「ポイントは人日に換算してはいけません」と教わって、引っかかりませんでしたか? #  研修や現場で、「ストーリーポイントは相対値です」「人日に換算してはいけません」と指導されたことのある方は多いと思います。そのとき、こんな疑問を持ちませんでしたか? 予算も稟議も人日で回っている。換算しなければ、話が通らないではないか 結局、見積りの場で全員が頭の中で人日に直している。だったら最初から人日でよいのでは 相対値のままで、上司は何を判断できるのか  どれも正当な疑問です。先に答えを書くと、 鍵は「1ポイント=◯人日」から導出される数値とベロシティは、似て非なる数字だ という一点にあります。換算値は「この項目は3人日」と、 項目の原価を語る数字 です。ベロシティは「このチームは1スプリントに25ポイント片づく」と、 チームの流量を語る数字 です。計画に要るのは後者で、前者を計画の場に持ち込むと、項目ごとの帳尻合わせが始まります。  予算や稟議が人日で回っているなら、人日につなぐ必要はあります。ただし、つなぐのは項目ではなく合計です。残りの合計ポイントをベロシティで割って期間を出し、期間にチームの月額を掛けて金額を出す。このやり方なら、個々の項目に人日を貼らずに済みます。  上司が判断したいのは、相対値そのものではなく「いつ終わるか」「いくらかかるか」です。上司に渡すべきは、 ポイントという材料ではなく、そこから導いた見通しのほう です。  とはいえ、これだけでは言葉足らずでしょう。先に換算要求がどんな姿になりがちかを並べ、そのうえで一つずつ答えます。 人日換算のアンチパターン #  換算を巡っては、いくつかのアンチパターンがあります。心当たりのあるものがあれば、そこから読んでください。 その1:対応表型 ——最も多い型です。「1ポイント=0.5人日」のような固定の対応表を作り、ポイントを掛け算して人日に直して報告する。冒頭の質問が、まさにこれです。作った直後は平和ですが、見積りの場で全員が人日に逆算を始め、「これはあれの2倍くらい」という比較の速さが失われていきます。 その2:成績表型 ——ポイントが「あの人は今月何ポイント消化した」という個人の生産性指標や、「Aチームは毎スプリント30ポイント、Bチームは15ポイント。Bは生産性が半分か」というチーム間の比較に転用された型です。見積りは自己防衛の道具になり、大きめに見積もる誘惑、簡単な項目を選ぶ誘惑が生まれ、数字は膨らみ、予測の道具としては死にます。測定基準が目標になると、それは良い測定基準ではなくなる——経済学者チャールズ・グッドハートの名にちなむ「グッドハートの法則」として知られる現象です。そもそもポイントはチーム内の相対値であり、チームAの5とチームBの5には何の互換性もありません。換算表は、その事実を覆い隠します。 その3:値切り型 ——発注側や管理部門が、人日に直した数字を見て「この画面が3人日? 前は2人日でできたでしょう」と個々の項目を値切りにかかる型です。ポイントの内訳が人日の内訳に化けた瞬間、見積りは予測ではなく交渉の材料になります。第3回で見た、見積の内訳が保証の文言に流れ込む構図と同じです。 その4:沈黙型 ——「換算しない」という教えを守り、上司の質問に「ポイントは相対値なので人日には直せません」とだけ答えて、会話が終わる型です。正しいことを言っていますが、上司が本当に知りたかった「いつ・いくら」には何も答えていません。教えを守ったつもりで、翻訳を放棄しています。 四つの型に共通する前提 #  四つの型に共通する前提があります。 ポイントは工数の別名だ という前提です。この前提に立つと、換算表を作るのは当然であり、個人やチームの比較に使えるはずであり、内訳を値切れるはずであり、逆に「換算できないなら何の役にも立たない」という結論まで、すべて自然に導かれます。どの型も、この前提を疑わなかった先に現れます。  ここまでを踏まえて、先ほどの疑問に一つずつ答えます。  まず、 換算しなければ話が通らないのではないか 。その通りです。組織の予算策定や稟議、外部への報告は人日・人月で回っており、その言語に接続しないと話は通りません。これは現実で、否定しても始まりません。しかも、ポイントから人日への換算は数学的に可能です。スプリントで使った人日を、そのスプリントで完成したポイントで割れば、「1ポイント=◯人日」は単純な算数で出ます。多くの現場の換算表は、実際にこうして実績から作られています。出どころは正しい。  それでも、換算値とベロシティは同じものではありません。違いは、数字が何を主張しているかにあります。ベロシティは「このチームは1スプリントに◯ポイント片づく」という、チームというシステムの 流量 です。会議も割り込みも待ち時間も含んだ、 事後の観測値であり、その期間内に開発した個々の項目については何も主張しません 。合計についてだけ語ります。換算値は そのベロシティを一つの定数として固定し、チームの人数とスプリント長を加味して、「この項目は3人日」と項目に関連付けた属性値に変えたもの です。つまり、項目の 原価 を語る数字に変わっています。同じ実績から出ていても、片方はチームの流量、片方は項目の原価。ここが分かれ目です。  項目の原価になった瞬間、項目ごとに値切る、3人日の項目に5日かかった人を責める、項目単位で再見積りを求める、が「正当な使い方」になります。見積もる側はそれを見越してポイントを膨らませ、ポイントは成績指標に変わる。先ほどの四つの型は、どれもここから生まれています。 流量のままなら、これらは最初から成り立ちません 。  では、これから作るものの見通しは、どうやって人日・人月の言語に接続するのか。項目に掛けるのではなく、合計で割ります。残りのバックログの合計ポイントを実績のベロシティで割れば、残りのスプリント数が出ます。それが「いつ終わるか」です。そのスプリント数にチームの月額を掛ければ、「いくらかかるか」が出ます。ベロシティは動く観測値なので、幅(20〜30)のまま扱え、予測も幅になり、 ずれは「予測の更新」として次の計画に戻ってきます 。もし換算値を見積りの段階で項目に掛けると、計画はその時点で確定し、 同じずれが「遅れ」として現れます 。  人日・人月の言語と合計値とを接続する形なら、チーム内の見積りはポイントを使うというスタイルを守れたまま、チームの外の人々が期待する見通しを実績値に基づいて提示することができます。実績そのものを問われたときは、もっと単純で、「このチームは、この3ヶ月で人件費◯円を使い、◯◯を届けた」と、項目ごとの換算を経ずに言えます。  もっとも、正直なところ、換算値を毎スプリント毎に見直しをかけ、項目単位に見るのをやめ合計にしか使わないなら、項目に掛けても合計で割っても出てくる数字は同じです。違いは計算結果ではなく、数字の意味と、それが招く運用にあります。この話は、ベロシティを扱う回の末尾で、もう少し丁寧に扱います。  次に、 頭の中で人日に直しているなら、最初から人日でよいのではないか 。その状態なら、確かにそうです。相対見積りの利点は、「これはあれの2倍くらい」という比較が、 絶対値の見積りより速く、チームの合意として出せる ことにあります。見積りの場で全員が人日に逆算しているなら、その利点はもう失われていて、時間で見積もるほうが正直です。ただしそれは、ポイントが無意味だからではなく、対応表がポイントを人日の別名に変えてしまったからです。対応表をやめれば、比較の速さは戻ります。  そして、 相対値のままで、上司は何を判断できるのか 。相対値そのものは判断に使えません。上司が判断に使うのは、相対値とベロシティの実績から導いた「いつ終わるか」「いくらかかるか」です。ポイントは上司に見せる数字ではなく、 その見通しを作るための、チーム内部の材料 です。上司に渡すべきは材料ではなく、見通しのほうです。   「工数の別名」という前提はどこから来るのか 。私たちの多くは、見積りとは工数を積み上げるものだと教わってきました。機能ごとに人日を見積もり、合計して期間と金額を出す。この文化の中でストーリーポイントに出会うと、それを「人日の新しい単位」として理解するのは自然な反応です。怠慢の話ではありません。ただ、単位の置き換えではなく、道具の置き換えだということが、研修ではなかなか伝わりません。  翻って、ストーリーポイントとは何か。道具である前に、何を測った数かを言っておきます。ポイントは、作るものの 大きさ ——量と複雑さと不確実性を合わせたもの——を、そのチームの目盛りで表した相対値です。工数が「人が投入する時間」という投入側の量であるのに対し、大きさは作る対象の側の量です。ただし、大きさそのものに絶対値はないので、「これはあれの2倍くらい」という比で表します。比が使えるのは、同じ二つの項目にかかる工数は作る人によって変わっても(Aさんなら1日と2日、Bさんなら3日と6日)、2倍という比は変わりにくいからです。だから比だけを持つ。そして目盛りはチーム固有なので、チームの外に持ち出すと意味を失います。  そのうえで、本来のストーリーポイントは何のための道具か。アジャイルの計画づくりは、経験主義の上に立っています。見積りを事前に確定させるのではなく、実績を透明にし、予測と実績のずれを検査し、計画を適応させる。ストーリーポイントは、この「実績から予測する」ための道具です。   見積り ——ポイントは、項目同士の大きさの比較を、速くチームの合意として出すためのもの。絶対値ではないので、個人の作業のスピードや日々の割り込みに左右されにくく、合意が壊れにくい。   実績 ——スプリントごとに完成したポイントの合計(ベロシティ)が、チームの実績。これが、ポイントを期間に換える唯一の根拠です。   予測 ——残りのポイントを実績のベロシティで割れば、残りのスプリント数が出る。実績にはばらつきがあるので、予測は幅になります。幅は、実績が貯まるほど頼れるようになり、ばらつきの原因を減らすほど狭まります(ばらつきの扱いは、ベロシティを扱う回で)。  ここまで来れば、対応表型の問題点は明らかです。「1ポイント=0.5人日」が問題なのは、根拠がないからではなく、チームの流量だった実績を項目の原価に変え、しかも根拠だった実績が変わっても表が変わらないからです。変えるべきは、「項目ごとにポイントを人日に直す」から「合計ポイントと実績のベロシティで見通しを出し、チームの外の言語にはその見通しで接続する」へ、です。この置き換えが、本稿の処方のすべての前提です。 処方 #  ここからは具体策ですが、どれも道具にすぎません。見積りと予測の技法は有識者がさまざまに提案してくれていて、どれを選んでもかまいません。もっとも、「換算表を捨てた」と宣言しただけなら、沈黙型と変わりません。試したあとは、上司との会話が「何人日?」から「いつ? いくら?」に変わったかで、効いたかどうかを見てください。 まず、直近数スプリントのベロシティと、残りのポイントを並べる  直す前に、いまの状態を一枚にします。直近5〜8スプリントのベロシティ(最悪値と最良値)と、残りのバックログの合計ポイントを一枚に並べてください。実績が3スプリント分もあれば、粗くても幅は出せます(頼れる幅になるには5スプリントほど要りますが、その話は次回に)。この一枚があるだけで、「何人日?」への答えは「換算できません」ではなく「今の実績だと、◯〜◯スプリントです」に変わります。以降の処方は、この一枚から始まります。 「いつ終わるか」にはリリース予測で答える  直近数スプリントのベロシティの幅と、残りのバックログの合計ポイントから、完了時期を幅で示します。「残220ポイント、ベロシティは20〜30なので、8〜11スプリント、つまり◯月〜◯月です」。単一の日付ではなく幅で答えるのがポイントです。上司が金額で聞くなら、幅をそのまま金額に変えます。「最良と最悪の差は3スプリント、つまりチーム月額の1.5ヶ月分が今の見通しの振れ幅です」。この幅を最良値・最悪値の2本の線でバーンダウンチャート上に表現する「レンジドバーンダウン」(PMI Disciplined Agileで紹介されている、幅つきのバーンダウン表現)というプラクティスもあります。幅の広さ自体が不確実性の正直な表現であり、スプリントを重ねるほど幅は狭まっていきます。この「予測が徐々に精緻になる」体験を上司と共有できると、対話の質が変わります。  そしてこの転換は、答える側だけでは完結しません。開発者は幅と根拠を毎回同じ形式で示し、尋ねる上司の側は「何人日?」の代わりに「いつ? いくら?」で問い直してみる。双方がこの形に慣れたとき、換算表は要らなくなります。 「いくらかかるか」にはチームの運営費で答える  アジャイルなチームのコストは、タスクの積み上げではなく「このチームを何ヶ月走らせるか」で決まります。更改案件のように成果物のまとまりで予算を組む場面の「器」は、第4・5回の方式(ユースケースの本数×規模に深さの初期設定を重ねて量を出し、実測で期間と金額に換える)で作れます。一方、継続的に走るチームの費用の考え方は単純です。チームの月額(人件費+経費)×期間。この計算に人日換算は不要です。むしろ「固定されたチームが期間内に価値を最大化する」というアジャイルの資金モデルを説明する好機です。 それでも換算が要るなら、用途を限定する  稟議や契約でどうしても人日が必要な場面はあります。その場合は、過去の実績(このチームは四半期で人件費◯円を使い、◯◯を届けた)から算出した概算値を、チームの外に出す用途に限って用意し、チーム内の見積り・計画には持ち込まない、と線を引いてください。実績から出した換算値そのものは、この用途に限り、実績が更新されるたびに引き直すなら、問題ありません。「外向けの通貨」と「内側の計画の道具」の分離です。第3回で「予算の器」と「保証の範囲」を切り離す話をしましたが、これも同じ構図です。原則はひとつです。チームの外に出す換算値は実績から、チームの中の計画は予測で。どちらも同じ記録から導き、算出根拠を文書に残す。こうしておけば、外に出す数字と中で使う数字の出どころが違うのではないか、という疑いは生じません。この線引きを上司と合意できるかが勝負どころで、換算表の副作用(インフレ、ノルマ化、チーム間比較)を先に説明しておくと通りやすくなります。 発展: ポイントをやめる道もある  PBIを十分小さく均一に分割できるようになると、ポイントを振らずに「個数」で予測する方法(スループットベースの予測)が成立します。見積りの議論時間はほぼゼロになり、「何人日?」への答えは「毎スプリント平均◯個完成しています」に変わります。成熟したチームの選択肢として、頭の片隅に置いてください。 なぜこの質問は30年繰り返されるのか #  ストーリーポイントは「チーム内部の計画の道具」として生まれましたが、組織の会計・稟議・契約は今も人日/人月の言語で動いています。この2つの言語の境界に立って通訳をする人——本来はPOやマネージャーの仕事です——が明示的に任命されないため、境界の摩擦は毎回、見積もった開発者本人に降ってきます。そして開発者は善意で換算表を作り、数年後に同じ副作用に悩む。この繰り返しです。  背景には、続くコスト削減のプレッシャーもあります。経営層にとってアジャイルが「早くて安い」という文脈で期待されているほど、「で、いくら安くなったのか」を人日で確かめたくなる。換算要求は、その期待の裏返しでもあります。だからこそ、幅つきの完了予測とチーム運営費という「別の答え方」で、期待の中身そのものを組み替えていく価値があるのです。  換算表を求められたら、それは翻訳者が不在だというサインです。表を作る前に、翻訳の仕事を誰が引き受けるかを話し合ってください。確かめるべきは、上司との会話が「何人日?」から「いつ? いくら?」に変わったかどうか、ということです。それがこのチームの見積りが予測の道具として働いているかの計器です。 次回: 「ベロシティが安定しません。計画に使っていいの?」
 この記事を読んでいただきありがとうございます。アジャイルグループに所属する、藤井智弘です。  第9回では、ポイントを人日に換算する代わりに、ベロシティの実績から幅つきの見通しを出す話をしました。換算表の問題は、動いている実績を一つの数字に固定することだ、とも書きました。番外編で見積りの理屈に寄り道しましたが、今回は第9回の続きで、その足元にあるベロシティそのものの話です。そして、同じ「固定」の問題が、別の顔で現れます。 質問 #  スプリントごとのベロシティが20→35→18とばらついています。平均を取って次のスプリント計画に使っていますが、外れることも多く、チーム内から「安定するまでベロシティは当てにできないのでは」という声が出ています。安定を待つべきでしょうか? 回答 #  この悩みを口にする方は、たいてい次の三つを同時に抱えています。 ばらつくのは、うちのチームがまだ未熟だからか 何スプリント待てば、安定するのか ばらついたままの数字を、計画に使ってよいのか  順番に見ていきましょう。ばらつくのは未熟だからではありません。開始数ヶ月のチームのベロシティは、ばらつくのが正常です。見積りの基準も、チームの連携も、PBIの粒度も、まだ発展の途中なのですから。何スプリント待っても、「揺れない1つの数字」は来ません。そして、ばらついたままで計画に使えます。問題はばらつきではなく、 ばらつくものを単一の平均値で扱おうとしていること です。平均ではなく幅で使ってください。「うちのベロシティは18〜35」という言い方に変えるだけで、ベロシティは計画の道具になります。ただし幅が頼れるようになるのは実績が5スプリントほど貯まってからで、それまでは記録に専念する期間です(処方で詳しく述べます)。  要点を二つに絞るなら、「ばらつきを情報として読むこと」と「安定させることを目標にしないこと」。 「ベロシティは安定してくるものです」という教え、信じられましたか? #  研修や書籍で、「ベロシティは数スプリントで安定してきます」「安定したベロシティが計画の基礎になります」と教わった方は多いと思います。そのとき、こんな疑問を持ちませんでしたか? うちは半年経っても安定しない。何かが間違っているのか 休暇も障害も割り込みも毎回違うのに、安定するほうがおかしいのでは 安定しないものを、上司にどう説明すればいいのか  私も、研修を受けた側だったころは同じ疑問を持っていました。答えを先に出すと、 「安定する」の意味の取り違え です。安定するのは1つの数字ではなく、幅のほうです。ばらつく理由が毎回違うのはその通りで、だからこそ幅で持つ。そして上司には、幅のまま説明できる。ただ、ここで終わると標語のままです。ベロシティがどんな扱われ方をしがちかを先に見て、それから腰を据えて考えましょう。 ベロシティのアンチパターン #  ばらつきに悩むチームには、いくつかのアンチパターンがあります。自分のチームの数字の扱いと、見比べてみてください。 その1:様子見型 ——「安定するまでベロシティは使わない」と決め、計画は勘と気合で立てている型です。記録はしているが、見てはいない。安定は待っても来ないので、この様子見は永遠に続きます。 その2:帳尻合わせ型 ——ベロシティを「安定させる」ことが目標になった型です。入口はたいてい、マネジメント層が「先月35だったのに、今月はなぜ18なのか」と理由の報告を求め始めることで、ベロシティが評価やチーム間比較に使われ始めた組織では、ほぼ確実に起きます。以降、チームは数字を下げないことに注意を払うようになり、見積りを大きめに盛る、終わりそうな項目だけ選ぶ、持ち越しをこっそり完了扱いにする。数字は美しく安定し、実態は何も良くなりません。第9回で見たグッドハートの法則が、ここでも待ち構えています。番外編で、見積りのばらつきは構わないが偏りは合計に残る、と書きました。帳尻合わせが作るのは、まさにその偏りです。 その3:原因不明型 ——数字は記録しているが、そのスプリントに何があったかを残していない型です。半年分の数字を眺めても、「35のとき何が良かったのか」「18のとき何が起きたのか」が分からない。ばらつきは、原因が分からない限りただの雑音です。 ばらつきを巡る思い込み #  顔つきは違っても、三つの型も、質問者の「平均で積む」も、同じ思い込みから出ています。 ベロシティは精密な測定値だ という前提です。この前提に立つと、ばらつきは測定誤差であり、平均を取れば真の値に近づき、安定していない数字は使えず、下がった数字には説明が要る、という判断がすべて自然に導かれます。どれも、この前提の素直な実践です。第9回の換算表が動く実績を一つの係数に固定したように、平均はばらつきを一つの数字に固定します。顔は違っても、起きていることは同じです。  それでは、三つの疑問を順に片づけます。  まず、 半年経っても安定しないのは、何かが間違っているのか 。間違っていません。ベロシティは物理定数ではなく、チームという生き物の観測記録です。メンバーの休暇、割り込みの多寡、PBIの粒度の不揃い、見積り基準の揺れ…これらすべてが数字に映り込みます。半年経っても揺れるのは、半年経ってもチームが生きている、というだけのことです。安定してくるのは、揺れの「幅」のほうです。18〜35という幅は、実績が貯まるほど信頼できるようになり、原因を潰すほど狭まっていきます。  次に、 理由が毎回違うのに、安定するほうがおかしいのではないか 。その通りです。だから、ばらつきを消そうとするのではなく、ばらつきを含んだまま予測に使う方法を採ります。それが幅です。幅で語ることは、precision(精密さ)を落としてaccuracy(正確さ)を上げる交換です。狭い幅で外すより、広い幅で当てる。計画に必要なのは後者です。そしてもうひとつ、ばらつきの原因を観察し、対処できるものだけ対処します。休暇や突発障害は幅として受け入れ、割り込みの受付方法やPBIの粒度は改善する。この仕分けができると、「ばらつくから使えない」という議論は「この原因は潰せるか」という議論に変わります。  そして、 安定しないものを上司にどう説明するか 。幅のまま説明します。第9回で見た幅つきのリリース予測は、まさにこの最悪値と最良値から出ています。「うちのベロシティは18〜35です。だから残りは6〜11スプリントです」。対外的に単一の日付を求められる場合は、悲観値側の日付でコミットし、幅は内部の計画と進捗管理に使う。第5回で、更改案件の実測スプリントで測ったベロシティを稟議に出すときは期間・金額の上限側の値にする、と書いたのも、この幅の使い方です。コミットした日付は、幅が狭まるにつれて前倒しで見直せます。  残る問いは、 なぜ「精密な測定値」と思い込んでしまうのか です。私たちの多くは、計画とは単一の数字で立てるものだと教わってきました。納期は1つの日付、工数は1つの数字。この文化の中でベロシティに出会うと、それを「揺れの少ない1つの数字」に仕立てたくなるのは自然な反応です。誰のせいでもありません。ただ、ベロシティが何のための数字かが、単位の説明ほどには伝わっていないだけです。  ここで、本来のベロシティは何のための数字か。第9回で、ストーリーポイントは「実績から予測する」ための道具だと書きました。番外編では、見積りの誤差は手法ではなく、項目の大きさと答え合わせまでの時間で決まる、と書きました。ベロシティは、その「実績」の側であり、スプリントごとの答え合わせの記録そのものです。   観測 ——スプリントごとに完成したポイントを、そのまま記録する。盛らず、削らず、持ち越しは計上しない。この正直さが、以降のすべての土台です。   検査 ——記録を並べ、幅と、ばらつきの原因を見る。予測と実績のずれは、失敗ではなく、次の計画を直すための情報です。   適応 ——幅で次の計画を立て、潰せる原因は改善し、潰せない原因は幅として引き受ける。スプリントを重ねるほど、幅は狭まり、計画は当たるようになります。  この三段に当てはめると、質問者の「平均で積む」がどこで止まっているかが分かります。観測はしているが、ばらつきを検査せず、平均で潰して計画に使っている。帳尻合わせ型に至っては、観測そのものを歪めている。「揺れの少ない1つの数字を作る」ではなく「幅で持ち、原因を読み、幅で計画する」。言い換えるとそれだけのことです。この転換なしに処方だけ試しても、長続きしません。 処方 #  以下に挙げるのは道具であって、目的ではありません。予測の技法は第9回の発展で触れたものも含めて選択肢が多く、どれを選んでもかまいません。ただし、形だけ「幅にしました」で済ませるなら、平均の数字が2つに増えただけです。試したあとは、計画が当たるようになったか、ばらつきの原因が減ったかで確かめてください。 まず、数字の横に一行メモを残す  対処の前に、記録から始めます。スプリントごとに、ベロシティの数字と一緒に一行メモを残してください。「35: 大型連休なし、割り込み少」「18: 本番障害対応に3人日、◯◯さん休暇」。数スプリント分たまると、ばらつきの原因が分類できます。対処できるもの(割り込みの受付方法、PBIの粒度の不揃い)は改善し、対処できないもの(休暇、突発障害)は幅として受け入れる。原因不明型を抜け出す唯一の方法で、以降の処方はこの記録があって初めて回ります。 平均ではなく幅で使う  幅の作り方は第9回で見たとおり、直近5〜8スプリントの実績から最悪値と最良値を取るだけです。本稿で足すのは、その幅をスプリント計画にどう使うかです。言ってしまえば「昨日の天気」の幅版で、できた分しか積まないという保守性を悲観値で引き継ぎ、余力の使い道を楽観値で用意します。次スプリントの計画では「悲観18、楽観35」を前提に、確実に入れる分を悲観値で積み、残りは「できたら着手」の候補として並べておく。悲観値で積んだ分がスプリントゴールの土台になり、候補は余力が出たときに拾います。  なお、導入直後で実績が2〜3スプリント分しかない時期は、幅もまだ当てになりません。その間は予測の精度を求めず、「実績を貯める期間」と割り切って記録だけ続けてください。幅が意味を持ち始めるのは、おおむね5スプリント目あたりからです。様子見型との違いは、待つ間も記録し、見ていることです。 幅の推移で、最後を見通す  幅の使い道は二つあります。一つは、次のスプリントを外さないためで、幅の下端(悲観値)を使って積む。前の処方がこれで、「昨日の天気」の幅版、目的は次の一回の達成可能性を上げることです。もう一つは、最後を見通すためで、幅の推移を使う。残りのポイントを幅で割れば完了時期も幅になり、それがスプリントを重ねるほど狭まっていくなら、見通しは確かになっている。狭まらないなら、潰せていない原因が残っている。昨日の天気は前者には十分ですが、後者——最終的にどのくらい確からしいか——を見せてはくれません。それを見せるのが、第9回で触れた幅つきのバーンダウンです。幅の下端で次を守り、幅の推移で最後を見る。同じ幅の、別の読み方です。 ベロシティの用途を組織と合意する  ベロシティはチームが自分の計画精度を上げるための内部指標です。評価や比較や目標値に転用されると数字が防衛の道具になる理由は、第9回でポイントについて述べたとおりで、ベロシティも同じです。第9回で換算値に引いた「外向けの通貨」と「内側の道具」の線引きを、ベロシティの数字そのものにも引いておく、ということです。この線引きは、マネジメント層と早めに、明示的に合意しておいてください。「ベロシティが下がった理由を報告せよ」が始まる前に、です。ここもチームだけでは完結しません。チームは幅と原因のメモを毎回同じ形式で開示し、マネジメント側は数字の上下ではなく「幅は狭まっているか」「潰せる原因は減っているか」で見る。帳尻合わせ型への入口を塞ぐのは、この往復です。 なぜこの質問は30年繰り返されるのか #  先に見たとおり、計画を単一の数字で立てる文化は、研修一回で上書きされるものではありません。しかも、新しいメンバーが入るたびにチームに持ち込まれます。だからベロシティのばらつきに苛立つ声は、チームの世代交代のたびに必ず再現されます。  しかしアジャイルの計画づくりは、不確実性を消すことではなく、不確実性と一緒に前に進むことを目指しています。ベロシティのばらつきに苛立ちを感じたときは、数字ではなく、数字への期待のほうを調整するタイミングです。幅は狭まっているか、ばらつきの原因は減っているか。この問いを定期的に自分たちに向けることが、このチームのベロシティが計画の道具として働いているかの確認になります。 おまけ: 項目ごとに換算するのと、合計をベロシティで割るのは、何が違うのか #  第9回で、「1ポイント=◯人日」の換算値は項目の原価を語る数字、ベロシティはチームの流量を語る数字で、似て非なるものだと書きました。すると、こういう問いが返ってきます。「項目ごとにポイントを付けて合計をベロシティで割るのと、項目ごとに換算値を掛けて足すのは、結局同じではないか」。いい問いなので、第9回では短く済ませた答えを、ここで広げておきます。  まず算術から。換算値を「スプリントの人日÷ベロシティ」で作るなら、各項目のポイント×換算値の合計は、換算値×合計ポイント、つまり合計ポイント÷ベロシティ×スプリントの人日に一致します。換算値を毎スプリント引き直し、合計にしか使わないなら、それは「合計をベロシティで割る」の別表記にすぎません。この場合、両者に違いはありません。違いが生まれるのは、現場でやっていることがこの理想形から外れる点で、四つあります。   何を固定するか ——合計をベロシティで割る方式は、ベロシティを毎スプリント観測し直す動く値として、しかも幅(18〜35)を持ったまま扱えます。だから予測も幅になり、本稿で見たとおり、実績が貯まるほど頼れるようになり、原因を潰すほど狭まります。換算値は、実績から作られた瞬間に一つの定数になり、表として配られた時点で更新が止まり、幅も消えます。理屈の上では「換算値も幅つきで毎回更新すればよい」のですが、そうなっている換算表を私は見たことがありません。表にする、という行為そのものが固定を招きます。   数字がどこに付くか ——合計で割る方式では、人日や金額が付くのは合計だけで、個々の項目には付きません。換算値を項目ごとに掛けると、「この項目は3人日」という数字が項目単位で生まれます。ここから副作用が連鎖します。項目ごとに値切れる、3人日の項目に5日かかった人を責められる、チーム間で単価を比べられる、そして見積もる側はそれを見越してポイントを膨らませる。項目に金額が付いた瞬間、ポイントは成績指標に変わり、グッドハートの法則が働き始めます。合計にしか数字が付かなければ、個々の見積りに圧力がかからず、相対比較の速さと合意しやすさが保たれます。第9回の四つの型(対応表型・成績表型・値切り型・沈黙型)は、沈黙型を除く三つがここから出てきます。   検査と適応の輪が回るか ——ベロシティは事後に測られて次の予測に戻ってくる値なので、合計で割る方式は毎スプリント自己修正します。ずれは「予測の更新」として現れます。換算値を見積りの段階で掛けると、計画はその時点で確定し、現実とのずれは「遅れ」として現れます。同じずれでも、片方は学習になり、片方は責任問題になります。平均で積んで外れるたびに「今回は特別だった」と言うのは、この輪が回っていない姿です。   会計の単位 ——合計÷ベロシティ×チームの月額は「このチームを何ヶ月走らせるか」の計算で、固定したチームに期間で資金を付けるモデルに素直に乗ります。項目ごとに人日を付けるのは「成果物ごとの原価」の計算で、項目単位の見積り精度と、変更のたびの再見積り・再承認を要求するモデルに引きずられます。第9回の処方で「いくらかかるか」をチームの運営費で答えたのは、この違いのためです。  ついでに一つ、よくある実務上の落とし穴を。「1ポイント=0.5人日」の0.5が、ベロシティからではなく「理想日」から置かれている現場が少なくありません。その場合、会議や割り込みを含むチームの実際のスループットが式に入っておらず、系統的に過小見積りになります。ベロシティで割る方式は、その種のオーバーヘッドを測定値として自動的に含みます。本稿の一行メモに「割り込み多」と書かれたスプリントも、幅の中に、黙って入っています。  まとめます。計算結果は、換算値を正しく作って正しく使えば同じです。違うのは、ベロシティを動く観測値のまま扱うか定数に固定するか、人日が合計にだけ付くか項目ごとに付くか、そしてその結果として検査と適応の輪が回るか止まるか。一言でいえば、チームの流量を項目の原価に変えないこと。第9回と今回を通して言いたかったのは、結局この一つのことです。 次回: 「スプリント内に終わらないタスクが毎回出ます」
はじめに # これは、「面白そう」と感じたアーキテクチャや技術スタックを、まずはあれこれ試しながら作ってみたプロトタイピングの記録です。 前回は、AWS LambdaとSSMセッションマネージャーによるサーバーレスGPU起動ブローカーを構築しました。 (詳細は 前回の記事 を参照してください) これにより、ポート開放ゼロかつSSH秘密鍵不要で、世界中の最安スポットGPUをわずか数秒で探索・選定し、安全に直結できる環境が完成しました。 しかし、前回の構成を実践していく中で、もう1つ避けて通れない重要な課題が浮き彫りになります。 「マルチリージョン展開に必要なS3・ECR・セキュリティグループ・IAMロール・起動構成・Lambda関数を、手動や場当たり的なシェルスクリプトで管理し続けるのは辛い」 今回の構成では、東京(ap-northeast-1)に加えて海外4リージョン(バージニア、オハイオ、オレゴン、フランクフルト)の計5リージョンを調達先として扱います。これらのリージョンに手動で設定を入れると、設定漏れやタイプミスの温床になります。使わなくなった際の環境破棄や、別アカウントへの再現も極めて困難になります。 複数リージョンに分散配置するGPU実行基盤を、不要になったら丸ごと消せるエフェメラルなIaCとしてTerraformで組めば、管理や片付けが大幅に楽になります。 そこで今回は、 静的インフラ基盤のすべてをTerraformで一括コード化(IaC化) することにしました。 --> Information 📚 関連する過去の検証記事 第1回 : AWS×UserDataでvLLMを自動起動!停止時コストほぼゼロのローカルLLM環境 第2回 : 自前vLLMをOpen WebUI経由でVS Code(Cline)に接続!トークンフリーで開発する 第3回 : EC2スポットインスタンス×vLLMでGPUコストを大幅削減する 第4回 : EC2スポット×vLLMをSSMでセキュア化!ポート開放ゼロで繋ぐ 第5回 : vLLM起動をAWS Lambda化!最安スポット探索と自動フォールバック 1. Terraform化における「役割分担(スコープ設計)」 # 📌 このセクションの要点 「常に存在する静的基盤」と「作業時だけ使い捨てるGPU」を明確に分離します。動的なGPU起動・停止までTerraformで管理しようとせず、ブローカー基盤までをTerraformで作り、GPUのオンデマンド調達はLambdaに任せる設計がベストプラクティスです。 本検証では、「常に存在するインフラ」と「作業時だけ使い捨てるGPU」を明確に分離しています。そのため、Terraformの管理範囲を以下のように切り分けるのがベストプラクティスです。 カテゴリ リソース 管理ツール ライフサイクル・費用感 静的基盤 S3, ECR(+レプリケーション), IAM, 各リージョンSG, Launch Template, Lambda Terraform 常時存在(使わない時の待機費用はS3・ECRの数GB保管料数十円のみ) 動的エフェメラル 最適リージョンのリアルタイム探索, EC2スポット起動/破棄, SSMトンネル Lambda + CLI オンデマンド実行(作業中のみ $0.4〜0.6/h 課金) 「EC2スポットの起動・破棄」までをTerraformで管理しようとすると、動的なリージョン選定が難しくなります。また、状態ファイル( tfstate )のロックや同期で毎回1分以上のオーバーヘッドが発生します。 そのため、 「ブローカー基盤までをTerraformで作り、GPUの生成・破棄はブローカー(Lambda)に任せる」 という設計が最も軽快で堅牢です。 2. コンテナイメージのマルチリージョン配置(ECRクロスリージョンレプリケーション) # 第1回〜第5回で採用している通り、vLLMのコンテナイメージ(約13GB)は Amazon ECR に永続化し、EC2の起動時に同一リージョン内から高速・転送料無料でPullします。 調達候補となる各リージョン(バージニア、オレゴン、オハイオ、フランクフルトなど)においても、同様に 同一リージョン内のECRからイメージを取得できる状態 を整えておく必要があります。仮に東京のECRから他リージョンのEC2へ都度Pullしてしまうと、リージョン間データ転送料金が毎回発生するうえ、コンテナ起動時間も大幅に増大してしまうためです。 とはいえ、イメージを更新するたびに各リージョンのECRエンドポイントへ手動で docker push を繰り返すのは運用負荷が高く、非効率です。 そこで、Amazon ECRがネイティブで備えている クロスリージョンレプリケーション(Cross-Region Replication) を採用します。Terraformでこの設定( aws_ecr_replication_configuration )を宣言しておきます。これにより、東京のECRに1回Pushするだけで、AWSバックボーン経由で全候補リージョンへ自動複製されます。 以下は、東京リージョンでのECRリポジトリ作成、旧世代イメージの自動削除ポリシー、および他4リージョンへの自動レプリケーションを定義したTerraformコードです。 ecr.tf(クリックで展開) # terraform/ecr.tf # 東京(プライマリ)リージョンの ECR リポジトリ resource "aws_ecr_repository" "vllm" { name = var.ecr_repository_name image_tag_mutability = "MUTABLE" tags = { Project = "vllm-ephemeral" } } # 古いタグの自動削除ポリシー(無駄なストレージ課金を防止) resource "aws_ecr_lifecycle_policy" "vllm_lifecycle" { repository = aws_ecr_repository.vllm.name policy = jsonencode({ rules = [{ rulePriority = 1 description = "最新イメージのみ保持し、旧世代は30日で失効" selection = { tagStatus = "any", countType = "sinceImagePushed", countUnit = "days", countNumber = 30 } action = { type = "expire" } }] }) } # ★マルチリージョン・クロスリージョン自動レプリケーション resource "aws_ecr_replication_configuration" "vllm_replication" { replication_configuration { rule { dynamic "destination" { for_each = [for r in var.target_regions : r if r != var.primary_region] content { region = destination.value registry_id = data.aws_caller_identity.current.account_id } } } } } --> Information 💡 S3のモデル重み同期について(マルチリージョン配置の検討) コンテナイメージと同様に、S3に格納するモデル重み(約15GB)についても候補リージョンへの分散配置が考えられます。 S3クロスリージョンレプリケーション(CRR)を利用する場合 : 全リージョンに重みを事前同期しておくことで、起動時のダウンロード時間を最短化し、同一リージョン内通信(無料)に抑制可能。一方で、複数リージョンにモデルを保持し続けるとS3保管料がリージョン数分発生するため、旧バケットの定期削除やGlacierへの退避など、ストレージ管理の併用が有効。 東京S3から都度クロスリージョン取得する場合 : モデル保管料は東京の1バケット分で済む一方、海外リージョン起動時にクロスリージョン転送料(1回約220円)とネットワーク遅延が発生。 本システムでは、頻繁に利用する主要リージョンにはS3バケットを準備し、必要に応じて初期同期しておく構成を推奨しています。 3. ゼロインバウンドSG & 最小権限IAM # 第4回で解説した「インバウンドルール0件のセキュリティグループ」と、EC2およびLambdaに必要なIAMロールを一括で定義します。 セキュリティグループ(全リージョンへの一括プロビジョニング) # Terraformのマルチプロバイダ機能( providers = { aws = ... } )やモジュールループを活用します。これにより、全リージョンへインバウンド0件のセキュリティグループを展開できます。 security_groups.tf(クリックで展開) # 各リージョン向けのセキュリティグループ(インバウンドルールなし、アウトバウンド全開放) resource "aws_security_group" "vllm_sg" { name = "vllm-ssm-isolated-sg" description = "Zero-inbound security group for vLLM SSM port forwarding" vpc_id = data.aws_vpc.default.id # インバウンドルールは定義しない(完全閉塞) egress { description = "Allow all outbound traffic for SSM Agent and S3/ECR pull" from_port = 0 to_port = 0 protocol = "-1" cidr_blocks = ["0.0.0.0/0"] } tags = { Name = "vllm-ssm-isolated-sg" } } IAMロール(EC2用 & Lambda用) # EC2インスタンスにはSSM通信・S3読み取り・ECR読み取りに必要な最小権限を付与し、Lambda関数にはマルチリージョンのEC2探索・起動に必要な権限を付与します。 iam.tf(クリックで展開) # EC2インスタンス用 IAMロール resource "aws_iam_role" "ec2_role" { name = "vllm-ssm-instance-role" assume_role_policy = jsonencode({ Version = "2012-10-17" Statement = [{ Action = "sts:AssumeRole" Effect = "Allow" Principal = { Service = "ec2.amazonaws.com" } }] }) } # SSMエージェント通信用ポリシー resource "aws_iam_role_policy_attachment" "ssm_core" { role = aws_iam_role.ec2_role.name policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore" } # S3 & ECR 読み取りポリシー resource "aws_iam_role_policy_attachment" "s3_read" { role = aws_iam_role.ec2_role.name policy_arn = "arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess" } resource "aws_iam_role_policy_attachment" "ecr_read" { role = aws_iam_role.ec2_role.name policy_arn = "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly" } # EC2インスタンスプロファイル resource "aws_iam_instance_profile" "ec2_profile" { name = "vllm-ssm-instance-profile" role = aws_iam_role.ec2_role.name } # Lambda GPUブローカー用 IAMロール resource "aws_iam_role" "lambda_role" { name = "vllm-spot-broker-lambda-role" assume_role_policy = jsonencode({ Version = "2012-10-17" Statement = [{ Action = "sts:AssumeRole" Effect = "Allow" Principal = { Service = "lambda.amazonaws.com" } }] }) } resource "aws_iam_role_policy_attachment" "lambda_basic" { role = aws_iam_role.lambda_role.name policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" } # マルチリージョンでのスポット探索・EC2起動権限 resource "aws_iam_role_policy" "lambda_ec2_broker" { name = "VLLMBrokerEC2Policy" role = aws_iam_role.lambda_role.id policy = jsonencode({ Version = "2012-10-17" Statement = [ { Effect = "Allow" Action = [ "ec2:DescribeSpotPriceHistory", "ec2:DescribeSecurityGroups", "ec2:DescribeImages", "ec2:RunInstances", "ec2:CreateTags", "ssm:GetParameter", "iam:PassRole" ] Resource = "*" } ] }) } --> Information 💡 第4回・第5回のスクリプト環境から移行する場合の注意点 第4回や第5回の環境構築スクリプトを実行済みのAWS環境でTerraformを実行する場合に注意が必要です。同名のリソースが存在すると競合エラー( EntityAlreadyExistsException 等)が発生します。その場合は terraform import で取り込むか、既存リソースを削除(または名前を変更)してから terraform apply を実行してください。 4. EC2起動テンプレート(Launch Template)とUserDataのテンプレート化 # 📌 このセクションの要点 第5回でS3へ切り離したUserData( startup.sh )をさらに一歩進めます。インスタンスタイプやEBS、IAMプロファイル、閉塞SG、UserDataブートストラップを EC2起動テンプレート(Launch Template) として一元管理します。これにより、Lambdaブローカーからハードウェア依存の記述を完全に切り離せます。 第5回では、110行を超えるUserDataスクリプトをS3バケットへ配置しました。EC2側にはS3から取得して実行する3行のブートストラップスクリプトを渡す構成としました。これにより、Lambdaコードの肥大化とUserDataの16KB上限問題を回避しました。 しかし、もう1つの課題が残っていました。 インスタンス仕様がLambdaコード内にハードコードされている : インスタンスタイプ( g6.xlarge )、EBSボリューム(40GB gp3、削除保護オフ)、IAMインスタンスプロファイル、セキュリティグループ、スポット市場オプションなどのハードウェア構成が、依然としてLambdaのPythonコード内の辞書として記述されていました。 インスタンスのハードウェア構成やブートストラップ手順は、本来「静的なインフラ仕様」としてIaCで管理すべき領域です。 そこで本構成では、 aws_launch_template (EC2起動テンプレート) を導入します。これにより、ハードウェア定義とUserDataブートストラップを一括コード化します。 1. UserDataブートストラップのテンプレート( userdata_bootstrap.sh ) # Terraformの templatefile 関数で動的パラメータ(リージョン、S3バケット名、ECRイメージ名)を注入できる極小シェルスクリプトを用意します。なお、Bash変数( $LOG_FILE 等)は波括弧を付けずに記述することで、Terraformテンプレート展開時の変数名衝突を綺麗に回避しています。 templates/userdata_bootstrap.sh(クリックで展開) #!/bin/bash set -euo pipefail LOG_FILE="/var/log/userdata-bootstrap.log" exec > >(tee -a "$LOG_FILE" | logger -t user-data -s 2>/dev/console) 2>&1 AWS_REGION="${AWS_REGION}" S3_BUCKET="${S3_BUCKET_NAME}" DOCKER_IMAGE="${DOCKER_IMAGE}" echo "=== S3から起動スクリプト (startup.sh) を取得 ===" aws s3 cp "s3://$S3_BUCKET/scripts/startup.sh" /tmp/startup.sh --region "$AWS_REGION" chmod +x /tmp/startup.sh echo "=== startup.sh 実行 ===" /tmp/startup.sh "$AWS_REGION" "$S3_BUCKET" "$DOCKER_IMAGE" 2. 全リージョンへの起動テンプレート一括プロビジョニング( launch_template.tf ) # EC2 Launch Templateは リージョン固有 のリソースです。東京だけに配置すると、海外リージョンでの起動時にテンプレートが見つかりません。そのため、Terraformを使って全候補リージョンへ同一名( vllm-spot-template )で一括展開します。 これにより、Lambdaブローカーがリアルタイム探索でどのリージョンを引き当てた場合でも、フォールバックなしに起動テンプレートから直接インスタンスを生成できます。 launch_template.tf(クリックで展開) # terraform/launch_template.tf # ============================================================================== # EC2 起動テンプレート(Launch Template)全候補リージョン一括プロビジョニング # EBS(40GB gp3)、IAMプロファイル、セキュリティグループ、UserData(S3ブートストラップ) # を全リージョンで共通のテンプレート名「vllm-spot-template」としてコード化する # ============================================================================== # 1. 東京リージョン (ap-northeast-1) resource "aws_launch_template" "lt_ap_northeast_1" { name = "vllm-spot-template" description = "vLLM Ephemeral Spot Node (g6.xlarge, NVIDIA L4 24GB) [ap-northeast-1]" instance_type = var.instance_type iam_instance_profile { name = aws_iam_instance_profile.ec2_profile.name } vpc_security_group_ids = [ aws_security_group.sg_ap_northeast_1.id ] block_device_mappings { device_name = "/dev/sda1" ebs { volume_size = 40 volume_type = "gp3" delete_on_termination = true } } instance_market_options { market_type = "spot" spot_options { spot_instance_type = "one-time" instance_interruption_behavior = "terminate" } } user_data = base64encode(templatefile("${path.module}/templates/userdata_bootstrap.sh", { AWS_REGION = "ap-northeast-1" S3_BUCKET_NAME = "my-vllm-models-hackathon-2026-${data.aws_caller_identity.current.account_id}-ap-northeast-1-an" DOCKER_IMAGE = "${data.aws_caller_identity.current.account_id}.dkr.ecr.ap-northeast-1.amazonaws.com/${var.ecr_repository_name}:latest" })) tag_specifications { resource_type = "instance" tags = { Name = "vllm-ssm-spot-node" Project = "vllm-ephemeral" MarketType = "spot" } } } # 2. バージニア北部リージョン (us-east-1) resource "aws_launch_template" "lt_us_east_1" { provider = aws.us_east_1 name = "vllm-spot-template" description = "vLLM Ephemeral Spot Node (g6.xlarge, NVIDIA L4 24GB) [us-east-1]" instance_type = var.instance_type iam_instance_profile { name = aws_iam_instance_profile.ec2_profile.name } vpc_security_group_ids = [ aws_security_group.sg_us_east_1.id ] block_device_mappings { device_name = "/dev/sda1" ebs { volume_size = 40 volume_type = "gp3" delete_on_termination = true } } instance_market_options { market_type = "spot" spot_options { spot_instance_type = "one-time" instance_interruption_behavior = "terminate" } } user_data = base64encode(templatefile("${path.module}/templates/userdata_bootstrap.sh", { AWS_REGION = "us-east-1" S3_BUCKET_NAME = "my-vllm-models-hackathon-2026-${data.aws_caller_identity.current.account_id}-us-east-1-an" DOCKER_IMAGE = "${data.aws_caller_identity.current.account_id}.dkr.ecr.us-east-1.amazonaws.com/${var.ecr_repository_name}:latest" })) tag_specifications { resource_type = "instance" tags = { Name = "vllm-ssm-spot-node" Project = "vllm-ephemeral" MarketType = "spot" } } } # 3. オハイオリージョン (us-east-2) resource "aws_launch_template" "lt_us_east_2" { provider = aws.us_east_2 name = "vllm-spot-template" description = "vLLM Ephemeral Spot Node (g6.xlarge, NVIDIA L4 24GB) [us-east-2]" instance_type = var.instance_type iam_instance_profile { name = aws_iam_instance_profile.ec2_profile.name } vpc_security_group_ids = [ aws_security_group.sg_us_east_2.id ] block_device_mappings { device_name = "/dev/sda1" ebs { volume_size = 40 volume_type = "gp3" delete_on_termination = true } } instance_market_options { market_type = "spot" spot_options { spot_instance_type = "one-time" instance_interruption_behavior = "terminate" } } user_data = base64encode(templatefile("${path.module}/templates/userdata_bootstrap.sh", { AWS_REGION = "us-east-2" S3_BUCKET_NAME = "my-vllm-models-hackathon-2026-${data.aws_caller_identity.current.account_id}-us-east-2-an" DOCKER_IMAGE = "${data.aws_caller_identity.current.account_id}.dkr.ecr.us-east-2.amazonaws.com/${var.ecr_repository_name}:latest" })) tag_specifications { resource_type = "instance" tags = { Name = "vllm-ssm-spot-node" Project = "vllm-ephemeral" MarketType = "spot" } } } # 4. オレゴンリージョン (us-west-2) resource "aws_launch_template" "lt_us_west_2" { provider = aws.us_west_2 name = "vllm-spot-template" description = "vLLM Ephemeral Spot Node (g6.xlarge, NVIDIA L4 24GB) [us-west-2]" instance_type = var.instance_type iam_instance_profile { name = aws_iam_instance_profile.ec2_profile.name } vpc_security_group_ids = [ aws_security_group.sg_us_west_2.id ] block_device_mappings { device_name = "/dev/sda1" ebs { volume_size = 40 volume_type = "gp3" delete_on_termination = true } } instance_market_options { market_type = "spot" spot_options { spot_instance_type = "one-time" instance_interruption_behavior = "terminate" } } user_data = base64encode(templatefile("${path.module}/templates/userdata_bootstrap.sh", { AWS_REGION = "us-west-2" S3_BUCKET_NAME = "my-vllm-models-hackathon-2026-${data.aws_caller_identity.current.account_id}-us-west-2-an" DOCKER_IMAGE = "${data.aws_caller_identity.current.account_id}.dkr.ecr.us-west-2.amazonaws.com/${var.ecr_repository_name}:latest" })) tag_specifications { resource_type = "instance" tags = { Name = "vllm-ssm-spot-node" Project = "vllm-ephemeral" MarketType = "spot" } } } # 5. フランクフルトリージョン (eu-central-1) resource "aws_launch_template" "lt_eu_central_1" { provider = aws.eu_central_1 name = "vllm-spot-template" description = "vLLM Ephemeral Spot Node (g6.xlarge, NVIDIA L4 24GB) [eu-central-1]" instance_type = var.instance_type iam_instance_profile { name = aws_iam_instance_profile.ec2_profile.name } vpc_security_group_ids = [ aws_security_group.sg_eu_central_1.id ] block_device_mappings { device_name = "/dev/sda1" ebs { volume_size = 40 volume_type = "gp3" delete_on_termination = true } } instance_market_options { market_type = "spot" spot_options { spot_instance_type = "one-time" instance_interruption_behavior = "terminate" } } user_data = base64encode(templatefile("${path.module}/templates/userdata_bootstrap.sh", { AWS_REGION = "eu-central-1" S3_BUCKET_NAME = "my-vllm-models-hackathon-2026-${data.aws_caller_identity.current.account_id}-eu-central-1-an" DOCKER_IMAGE = "${data.aws_caller_identity.current.account_id}.dkr.ecr.eu-central-1.amazonaws.com/${var.ecr_repository_name}:latest" })) tag_specifications { resource_type = "instance" tags = { Name = "vllm-ssm-spot-node" Project = "vllm-ephemeral" MarketType = "spot" } } } output "launch_template_ids" { value = { "ap-northeast-1" = aws_launch_template.lt_ap_northeast_1.id "us-east-1" = aws_launch_template.lt_us_east_1.id "us-east-2" = aws_launch_template.lt_us_east_2.id "us-west-2" = aws_launch_template.lt_us_west_2.id "eu-central-1" = aws_launch_template.lt_eu_central_1.id } description = "EC2 Launch Template IDs across all target regions" } output "launch_template_name" { value = "vllm-spot-template" description = "Common EC2 Launch Template Name across all target regions" } 起動テンプレートを全候補リージョンで同名( vllm-spot-template )としてTerraformで管理します。これにより、ハードウェア仕様の変更時もコードを修正して terraform apply するだけで、全リージョンへ一括反映されます。 5. Lambda関数の自動パッケージング & デプロイ # 第5回で紹介したGPUブローカーのPythonスクリプト( handler.py )をデプロイします。Terraform標準の archive_file データソースを使い、自動でZIP化して展開します。 起動テンプレートの導入に伴い、Lambda関数の環境変数には LAUNCH_TEMPLATE_NAME を渡します。 lambda.tf(クリックで展開) # Pythonコードの自動ZIPパッケージング data "archive_file" "lambda_zip" { type = "zip" source_dir = "${path.module}/../lambda" output_path = "${path.module}/dist/lambda_function.zip" } # GPU起動ブローカー Lambda関数 resource "aws_lambda_function" "broker" { function_name = "vllm-spot-broker" runtime = "python3.12" handler = "handler.lambda_handler" role = aws_iam_role.lambda_role.arn timeout = 120 memory_size = 256 filename = data.archive_file.lambda_zip.output_path source_code_hash = data.archive_file.lambda_zip.output_base64sha256 environment { variables = { TARGET_REGIONS = join(",", var.target_regions) S3_BUCKET_NAME = "my-vllm-models-hackathon-2026-{account_id}-{region}-an" INSTANCE_PROFILE_NAME = aws_iam_instance_profile.ec2_profile.name ECR_REPOSITORY_NAME = aws_ecr_repository.vllm.name TAG_SERVER_NAME = "vllm-ssm-spot-node" SG_NAME = "vllm-ssm-isolated-sg" LAUNCH_TEMPLATE_NAME = aws_launch_template.vllm_spot_template.name } } tags = { Project = "vllm-ephemeral" } } Lambdaハンドラー側での呼び出し(起動処理の簡潔化) # 起動テンプレートが整ったことで、Lambda関数( handler.py )のインスタンス起動処理は極めて簡潔になります。 # Launch Template を指定して1行で起動 run_params = { "MinCount": 1, "MaxCount": 1, "LaunchTemplate": { "LaunchTemplateName": os.environ.get("LAUNCH_TEMPLATE_NAME"), "Version": "$Latest", }, } if target_ami: run_params["ImageId"] = target_ami ec2.run_instances(**run_params) 長大な BlockDeviceMappings や UserData などをPython側で構築する必要はありません。Lambdaの責務は「最適リージョンのリアルタイム探索と、テンプレートによるエフェメラル起動」のみに集中できます。 6. Terraformの実行(一括プロビジョニングと環境破棄) # ディレクトリ構成を整えたら、コマンド実行で全リソースをプロビジョニングできます。 cd terraform/ # 1. プロバイダ初期化 terraform init # 2. 静的基盤の全展開(S3・ECR・IAM・SG・Lambda) terraform apply -auto-approve Apply complete! Resources: 18 added, 0 changed, 0 destroyed. Outputs: broker_lambda_arn = "arn:aws:lambda:ap-northeast-1:123456789012:function:vllm-spot-broker" ecr_repository_url = "123456789012.dkr.ecr.ap-northeast-1.amazonaws.com/vllm-openai" これにより、わずか数十秒でマルチリージョン展開に必要な基盤のすべてが整います。 日々のGPU起動・停止 # 静的基盤のデプロイ後は、日々のGPU起動・停止に第5回のスクリプト( 01_start_vllm_ssm.sh / 02_stop_vllm_ssm.sh )をそのまま使用します。 # GPUインスタンスの起動(Lambdaブローカー呼び出し & SSMトンネル確立) ./01_start_vllm_ssm.sh # 作業終了時のリソース破棄(SSM切断 & Lambda経由でのTerminate) ./02_stop_vllm_ssm.sh Terraform側でGPUインスタンス自体のライフサイクルは抱え込みません。動的な起動・停止をクライアントスクリプトに任せることで、 tfstate のロック待ちなしに迅速に推論環境を利用できます。 検証作業全体が終了し、クラウド上の静的基盤ごとすべてクリーンアップしたい場合のみ、 terraform destroy を実行します。 cd terraform/ terraform destroy -auto-approve --> 💡 発展:他ワークロードへの応用パターン 今回の検証ではvLLM(GPU推論)を題材としました。しかし、「Lambdaによるスポット探索・起動」と「SSMポートフォワーディング」の組み合わせは、他のワークロードにも幅広く応用可能です。 大規模コンパイル・ビルド環境 : 手元端末の負荷を軽減するため、多数のvCPUを持つコンピュート最適化インスタンス(例: c6i 系列)を必要な時間だけ立ち上げ、ビルド完了後に自動終了させる。 モデルのファインチューニングやバッチ学習 : 学習時のみ複数GPUインスタンスをスポット起動し、S3からデータセットを取得して学習し、生成された重みをS3へ保存した後にインスタンスを破棄する。 メモリ集約型のデータ処理 : ローカル環境ではメモリ不足になりやすい大規模データフレームの集計・分析時のみ、大容量メモリを搭載したインスタンス(例: r6i 系列)を一時的に利用する。 セキュアな一時開発環境(リモートDevbox) : インバウンドポートを開放せずにSSM経由で接続できるため、外部へのポート公開が制限されている環境でも、手元のVS Code等からセキュアに一時的な作業環境として利用できる。 「必要なタイミングだけクラウド上の適切なリソースを立ち上げ、完了後は確実に終了させる」というエフェメラルな運用は、コスト効率とセキュリティを両立させるアプローチとして幅広く活用できます。 まとめ # 本記事では、マルチリージョンにまたがるGPU推論基盤の静的リソースを Terraformで宣言的にコード化(IaC化) しました。 静的基盤と動的エフェメラルの役割分担 : 状態管理のオーバーヘッドを避け、静的基盤のみをTerraformに任せることで、軽快かつ堅牢なアーキテクチャを実現。 EC2起動テンプレート(Launch Template)の導入 : ハードウェア仕様や閉塞SG、UserDataブートストラップを一元的にコード化し、Lambda側からハードウェア依存コードを切り離し。 ECRクロスリージョン自動レプリケーション : 東京へ1回Pushするだけで全候補リージョンへAWSバックボーン経由で自動同期されるパイプラインを構築。 ゼロインバウンドSG & 最小権限IAM : 受信ルール0件のセキュリティグループやSSM/ECR/S3接続に必要なIAMポリシーを一括定義。 Lambda自動デプロイと環境破棄 : Pythonコードの自動ZIP化からプロビジョニング、そして検証終了時の terraform destroy による確実なクリーンアップ性を確立。 手動セットアップや場当たり的なスクリプトを排除し、コードで再現可能なインフラ基盤が整ったことで、日々のLLM検証をより安全・確実に運用できるようになりました。
はじめに # これは、興味のままに新試験を受験してきた記録です。 先日、AWS認定に新設された注目のベータ試験「AWS Certified AI Business Strategist (AIB-C01、ベータ試験コード: AB1-C01)」を受験してきました。本記事では、新設された試験の概要や学習準備、受験当日のリアルな所感、そして合否結果についてまとめます。 --> Information 秘密保持契約(NDA) があるため、詳細な試験問題の内容や個別設問については触れることができませんので、ご了承ください。 また、本記事の記載内容は2026年10月(ベータ試験受験時)の情報に基づいています。 試験について # AWS Certified AI Business Strategist(以下、AI Business Strategist) は、ビジネスリーダーやDX推進担当者、企画職などを対象とした認定資格です。AIおよび生成AI技術を活用したビジネス価値の創出、戦略的な意思決定、ガバナンス推進に関する知識が総合的に評価されます。 これまでのAWS認定は「Solutions Architect」や各種Specialtyなど、主にインフラ構築や実装技術(How)に焦点を当てていました。 これに対し本試験は新設の「Business」カテゴリーに属します。「どの課題にAIを適用し、どう投資判断や社内統制を進めるか(Why / What)」というビジネス戦略視点に特化している点が大きな特徴です。 試験の概要 試験コード : AIB-C01(ベータ試験コード: AB1-C01) カテゴリ : Business(※新設カテゴリー) 試験時間 : 170分 + 10分(前後のアンケートなど) 出題数 : 85問程度(※ベータ試験) 合格基準スコア : 700点(100〜1,000点満点) 受験料 : 8,250円(税込、※半額バウチャーなどは利用不可) ※ベータ試験であるため、正式リリース後は試験時間や出題数などが一部変更される可能性があります。 出題分野(試験ガイドより) # 試験ガイドに記載されている主な出題分野は以下の4つです。 分野 1: AIの基礎とビジネスリテラシー 機械学習や生成AIの基本概念、適用可能なビジネス課題の特定 従来の予測型AIと生成AI(基盤モデル)の特性の違いとビジネスへの影響 分野 2: AI戦略とビジネス価値の創出 AI導入に伴うビジネスケースの策定、ROI(投資対効果)の評価手法 自社開発(Build)か外部サービス・既存モデル採用(Buy)かのトレードオフ判断 定量・定性のKPI(重要業績指標)の設定 分野 3: AIガバナンスと責任あるAIリーダーシップ 責任あるAI(公平性、透明性、説明責任、プライバシー保護)の推進 ハルシネーション(幻覚)リスクへの対策やHuman-in-the-Loop(人間の介在)の設計 知的財産権、著作権、業界コンプライアンスの遵守 分野 4: 組織のAIレディネスと変革推進(トランスフォーメーション) 全社的なAI活用を支える組織体制(AI Center of Excellence: CoE)の立ち上げ 従業員のスキルアップ、チェンジマネジメント(変化への抵抗の克服) パイロット運用から全社展開へのスケール戦略 申し込み # ベータ試験のアナウンスを確認後、認定ポータルサイトから申し込みました。 AWS認定の新設試験ベータ版は、8,250円(税込)という特別価格で最新トレンドの出題傾向をいち早く体験できる貴重な機会です。今回もスケジュールを調整し、テストセンターでの受験枠を確保しました。 準備したこと # 本認定はコーディングや詳細なインフラ構成コマンドを問う試験ではないため、ビジネス視点でのAI活用戦略やガバナンスに焦点を当てて準備を進めました。 1. 試験ガイドの精読 # まずは公式の 試験ガイド を読み、出題範囲を確認しました。 「Pythonコードの実装」や「インフラ詳細設定」などは明確に出題対象外と記載されているため、ビジネス判断や戦略立案のシナリオに絞って学ぶ方針を立てました。 2. AWS Skill Builder # AWS公式の学習プラットフォーム「AWS Skill Builder」にて、公式問題集や関連教材を確認・活用しました。 Official Practice Question Set: AWS Certified AI Business Strategist (AIB-C01 - 日本語) 公式の練習問題集(日本語)が提供されており、出題傾向やシナリオ問題の形式、選択肢の問われ方を事前に把握するのに最適 試験準備カテゴリ(AIB-C01 / AB1-C01) ビジネスリーダー向けの生成AIコース(「Generative AI for Executives」等)や、責任あるAI(Responsible AI)に関する教材を活用した基礎概念のインプット 3. 公式ホワイトペーパー・ブログの確認 # ビジネス価値やガバナンスに関するAWSの公式ドキュメントやフレームワークを復習しました。 AWS Cloud Adoption Framework (CAF) for Artificial Intelligence 責任あるAI(Responsible AI)に関する公式ガイダンス Amazon Bedrock / AWSの生成AIサービス活用事例 (金融、ヘルスケア、小売など業界ごとのユースケース) 準備しながら気づいたこと # 学習を進める中で、従来のエンジニア向け試験とは異なるいくつかの重要な視点が見えてきました。 「技術的に可能か」ではなく「ビジネス投資として妥当か」が問われる点 最新の基盤モデルをファインチューニングすることが常に正解ではなく、まずはプロンプト調整やRAGで検証し、ROIや運用コスト、リスクに見合うかを判断する視点が重視される。 Build vs Buy の明確な判断基準 SaaSやマネージドサービス(Amazon Qなど)を導入すべきケースと、自社専用のモデルやパイプライン(Amazon SageMaker / Bedrock)を構築すべきケースの切り分けが明確に問われる。 責任あるAI(Responsible AI)とリスク管理のウェイトの高さ ハルシネーション対策、バイアス軽減、著作権保護、データプライバシーなど、AIを本番運用するにあたって企業が直面する法的・倫理的リスクへのガバナンス設計が試験の大きな柱となっている。 組織・人材のチェンジマネジメントの重要性 単にツールを導入するだけでなく、CoE(Center of Excellence)の立ち上げや社内教育、段階的なPoC推進など、「組織としていかにAIを定着させるか」という組織論も重要なテーマである。 受験当日の様子 # いつものテストセンターで受験しました。 問題文の分量・出題内容 たまに1画面・1ページをフルに使う長めの問題文もあったが、複雑な技術構成やパラメータ設定を問うものではなかった。どのように社内にAIを広げていくかという展開戦略や、社内統制・ガバナンスルールの進め方といった意思決定が中心である。そのため、普段からPM(プロジェクトマネジメント)や事業企画、DX推進などの業務に携わっていれば、文脈から自然と正しい答えを選択できると感じた。 時間配分とコンディション 試験時間は170分と長めに設定されている。当日は前日の寝不足がたたり、途中でウトウトしてしまったため、最終的に時間ギリギリでの終了となった。しかし、コード読解や複雑な計算問題は一切ないため、万全の体調で集中して受験すれば、1問1分以内のペースでテンポよく解き進められるボリューム感だと感じた。 受験結果について # 当日夜、スコアレポートが届き、 無事に合格 できました。 さらに、ベータ試験の合格者に対する特典である アーリーアダプター(Early Adopter)のデジタルバッジ も無事獲得できました。 手応えとしても感じていた通り、細かなAWS CLIコマンドなどを問う問題はありませんでした。ビジネスの文脈に即して「適切なAI活用判断ができるか」「責任ある推進体制を築けるか」という本質的な理解が問われる試験でした。 活かせる前提知識(他のAI認定との親和性) # 実際に受験してみて感じたのは、他社クラウドや既存のAI認定で培った知識が大きなアドバンテージになる点です。 特に、以下の認定資格の知識や学習経験があれば、出題意図をスムーズに汲み取ることができ、 だいぶ楽に受験できる と感じました。 AWS Certified Generative AI Developer - Professional (AIP-C01) Bedrock の選定基準やガードレール、コンプライアンス設計などの深い知見 AWS Certified AI Practitioner (AIF-C01) AWS における AI/ML 基礎概念やユースケース全般の土台 Google Cloud認定 Generative AI Leader (GAIL) ビジネスリーダー視点での生成 AI 導入・ROI 評価・組織展開という共通の思想 Microsoft認定: Azure AI の基礎 (AI-901) 責任ある AI(公平性・透明性・プライバシー保護など)の基本原則の共通性 生成AIパスポート(GUGA) AI リテラシー、知的財産権、著作権、リスク管理といった社会・ビジネス共通の基礎教養 ちなみに、私自身は上記のうち「生成AIパスポート」以外はすべて合格・取得済みです。 そのため、各試験で培ったガバナンスやユースケースの知識が自然と頭に入っており、初見のシナリオ問題でもスムーズに判断できました。 まとめ # 新設カテゴリ「Business」として、非エンジニアにも最適 PM、DX推進者、企画職、コンサルタントなど、ビジネスとAIの橋渡し役を担う方にとって、体系的な知識を証明する最適な試験体系になっている。 エンジニアにとっても「視座を引き上げる」絶好の機会 普段「どう実装するか」に集中しがちなエンジニアにとっても、「なぜ作るのか」「投資に見合うか」「組織にどう定着させるか」という一段高いビジネス視点を整理する良い機会になる。 早期受験でアーリーアダプターバッジも獲得可能 新設資格の立ち上げ期ならではの面白さがあり、AI推進に関わるビジネスリーダーやエンジニア双方にとって挑戦しがいのある認定である。 以上、簡単な受験記録となりますが、これから受験を検討されている方の参考になれば幸いです。
はじめに # おかげさまで、この10月で豆蔵デベロッパーサイトは立ち上げから5周年を迎えることができました。 5周年を祝う豆香 ローンチのお知らせは2021年の11月29日でした。 /blogs/2021/11/29/site-launch/ その前から少しずつコンテンツを貯めてお知らせを出したので、最初の記事は10月7日の K8s チュートリアルで EKS を構築する記事でした。 /containers/k8s/tutorial/infra/aws-eks-eksctl/ この5年間の歩み # この5年間を振り返ってみます。 初期は、私含め2名でサイトを構築し記事を書いてました。 記事数・執筆者数の推移 # 時期 記事数 執筆者数(累計) 備考 2021-11 15本 2名 ローンチ 2022-03 53本 継続執筆4名/経験7名 初回振り返り 2022-05 100本到達 アクティブ5名 2022-08 200本突破 19名(アクティブ9名) 100→200が加速 2022-12 318本 28名(アクティブ10名超) 平日ほぼ毎日更新 2023-12 522本 34名 開設2年で500本突破 2024-06 590本 56名 2024-12 680本 63名 2025-06 769本 66名 2025-09 809本 71名 800本突破 2025-12 843本 75名 2026-03 876本 76名 2026-09 (直近) 912本 83名 900本突破 ローンチ当初2名だった執筆者は、半年後の2022年5月に5人になり記事数100本到達、6月には月間最多36記事を記録するなど勢いよく立ち上がりました。2022年8月には200本を突破、年末には執筆者28名(アクティブ10名超)に成長しています。 2023年末には記事数522本となり、サイト開設からちょうど2年で500本を突破。検索流入も2023年3月に月間5万クリックに到達するなど、着実に認知度を高めていきました。 その後も成長を続け、2025年9月には記事数800本・執筆者71名を突破。直近2026年9月時点では 記事数912本、執筆者83名 まで広がっています。一人ひとりの興味・関心、専門性を活かした記事が積み重なって、ここまで来られました。 記事数と執筆者数の推移 コンテンツ領域の広がり # ローンチ時は「コンテナ」「マイクロサービス」などわずかなカテゴリーからのスタートでしたが、5年間で以下のようにテーマが大きく広がりました。 2022年 : マイクロサービス、学び、アジャイル開発、IoT、フロントエンド、ロボット 2023年 : モデリング、機械学習 2025年 : 機械学習・生成AI(リニューアル) ロボット・IoT・組込みのようなハードウェア寄りの分野から、アジャイル・品質保証・プロジェクトマネージメントといった開発プロセス系、そして機械学習・生成AIまで、豆蔵の多様な事業領域を反映したラインナップになっています。 長期連載も数多く育ちました。テーマ別に振り返ってみます。 マイクロサービス・バックエンド 逆張りのMicroProfile ~ Helidonで始めるマイクロサービスへの一歩 ~ (ローンチ初期から継続、JJUG CCC 登壇にも発展) Spring Boot による実践マイクロサービス開発 Springの小話 非同期メッセージング (Kafka や AsyncAPI などの非同期メッセージング) アジャイル・プロジェクトマネージメント エクストリームプログラミング解説 アジャイル開発現場におけるTips 業務システムにおけるアジャイル プロジェクト管理 (CCPM理論編・実践編・ツール編、新人プロジェクトマネージャー向け「問題と課題」シリーズ、「デキるPM」改善シリーズ) スクラムマスターのAI活用を考える アジャイルFAQ テスト・品質保証 Jest再入門シリーズ 、 Contract Test 、 Google Test 、 Pytest 品質保証者の憂鬱 フロントエンド Nuxt3入門 Denoを始める 言語・モデリング Javaエンジニアが始めるTypeScript入門 UML をプログラミング言語で実装する (Go・Python・C言語編) SysON で始める SysMLv2モデリング ロボット・IoT ロボットマニピュレータ制御のアルゴリズム robotics/#食品盛り付けロボットシステム「美膳」 AWS IoT基礎編 、 MQTT編 など 学び・育成 オンライン研修の設計勘所 ソフトウェア品質のための統計入門 # (全15回の大作「統計の話をしようじゃないか」) 生成AI トップページのコラム「 豆香の豆知識 」 OpenAI Realtime API シリーズ Kiroでアルバムアプリを作る全6本 AIエージェントとシステムをつなぐMCP入門 EC2でローカルLLMを動かすシリーズ その他 リモートワーク環境紹介 (コロナ禍で定着したテレワーク環境紹介。執筆デビューのネタとしても重宝) 息の長い記事とバズった記事 # サイトの認知度やアクセス数は、公開直後に瞬間的にバズる記事と、公開から何年経っても安定してアクセスを稼ぎ続ける記事の両方に支えられています。その代表例をいくつか挙げてみます。 Rust によるデスクトップアプリケーションフレームワーク Tauri 直感が理性に大反抗!「モンティ・ホール問題」 基本から理解するJWTとJWT認証の仕組み 手書き風ドローツール Excalidraw のススメ 【新人さん向け】A5:SQL Mk-2のおすすめ設定と使い方 文字コード これだけは覚えておこう ~シフトJIS編~ サイト基盤・機能の進化 # 読者・執筆者双方の体験向上のため、地道な改善を積み重ねてきました。目次機能、画像拡大、著者アバール表示、サイト内検索(その後 Algolia に刷新)、タグ検索、数式(KaTeX)対応、おすすめ記事・週間アクセスランキングなど、多くの機能を追加してきました。 サイトを支える静的サイトジェネレーターも、Eleventy から Deno ベースの Lume へ移行するという大きな技術的転換も経験しました。 --> Information 記事執筆中に Deno が1年後に開発を終了するという発表が飛び込んできました。 Deno is joining Cloudflare | Deno Lume を採用しているので、今後の動向を注視して動きたいと思います。 生成AIとの歩み # この5年は、生成AIの進化とともに歩んだ5年でもありました。 2023年、トップページのコラム「豆香の豆知識」を GPT-3 で開始。その後 GPT-4、GPT-4 Turbo、GPT-4o と、豆香は世代交代を重ねてきました(GPT-5 へのアップデートを忘れてたことを記事書いてて思い出しました😅) 2024年には記事サイドバーの挿絵を DALL-E-3 で自動生成する機能を追加。豆香は記事 PR へのコメントやアクセス集計の通知役としても社内で活躍しています 2024年に英訳記事の自動生成を開始(現在は多くの記事が英語で読めます)、2025年には中国語訳も追加し、海外からのアクセスも徐々に伸びています 自社メディアの運営自体に生成AIを積極的に取り込んできたのも、このサイトらしい特徴だと思います。 恒例企画・コミュニティの醸成 # 執筆の裾野を広げるための企画も定着してきました。 アドベントカレンダー : 2022年に初開催(16名、うち7名が初投稿)して以来、毎年末の恒例行事に 夏のリレー連載 : 2023年に初開催(14本、新規執筆者11名、営業職や新卒社員もデビュー) 春の新人向け連載 : 2024年に初開催し、新人エンジニアやプロジェクトマネージャー向けの記事群が生まれる場に これらの企画を通じて、普段は筆を執らないメンバーがデビューするきっかけが生まれ、現在の執筆者数の広がりにつながっています。 SNS をはじめとしたメディアとの連動 # 公式 X(旧Twitter) アカウントの開設から始まり、Bluesky、Facebook、note と発信チャネルを広げてきました。執筆者が JJUG CCC などの社外カンファレンスに登壇する機会も生まれ、記事が社外での発表につながる好循環も生まれています。 豆蔵のニュースページにも注目記事を掲載しています。 技術発信 – 豆蔵 MAMEZO 営業、採用への波及効果 # 記事を書き続けてきたことで、デベロッパーサイトはエンジニア個々人のスキルや得意領域をアピールする場としても機能するようになってきました。実際に、記事を読んだお客様から執筆者を名指しで新規の引き合いをいただき、そのまま受注につながったケースも出てきています。 中途採用の場面でも「デベロッパーサイトの記事を読んでいる」という方が増えており、技術発信が採用にも確実に手応えをもたらしていると感じています。 今後の展望 # 5周年の節目を迎え、現在デベロッパーサイトはリニューアルに向けて動き出しています。より読みやすい、モダンなサイトへと生まれ変わる予定です。多くの読者にとって「最もノイズが少なく、技術情報に集中できるUI」を目指し、記事ページの可読性向上や、ダークモードのネイティブ対応も計画しています。実装プロセス自体も最新のAI駆動開発(AI-Driven Development)で進めていきます。サイト自体を「生成AIを実践投入するための社内プロジェクト」として位置づけることで、これまで積み重ねてきた生成AIとの歩みをさらに一歩進めたいと考えています。 内容についても、さらなる充実を目指し、開発系の Tips はもちろんのこと、ロボティクスや生成 AI など我々が取り組み、また読者の皆様も興味を持つであろう内容を充実させて行きたいと考えています。アドベントカレンダーや夏のリレー連載、春の新人向け連載といった恒例企画も継続し、まだ執筆デビューしていないメンバーが筆を執るきっかけを作り続けていきます。また、英訳・中国語訳記事の拡充を通じて、国内にとどまらず海外のエンジニアにも豆蔵の技術発信を届けていきたいです。 次の5年、10年先も「開発に役立つサイト」として、そして豆蔵という会社の技術力・人となりが伝わるメディアとして育てていきたいと考えています。 さいごに # 以上、豆蔵デベロッパーサイト5周年の振り返りでした。改めて振り返ってみると、立ち上げ当初2名だった執筆陣が83名まで広がり、900本を超える記事が積み上がったことに、感慨深いものがあります。ここまで続けてこられたのは、折にふれて記事を書いてくれた執筆者の皆さん、そして何より読み続けてくださっている読者の皆さんのおかげです。この場を借りて御礼申し上げます。 6年目以降も、変わらず技術と現場のリアルを発信するメディアであり続けたいと思います。引き続き豆蔵デベロッパーサイトをよろしくお願いいたします。 よかったら フィード の購読、 X や Bluesky でのフォローもお願いします。 Facebook でも本サイトの注目記事をはじめ豆蔵に関するイベントを紹介しています。 note にも時々本サイト関連の記事が掲載されています。
はじめに # これは、思いついたアイデアや気になるクラウド技術を、まずは好奇心のままに手を動かして形にしてみる技術検証ログです。 前回の記事( EC2スポット×vLLMをSSMでセキュア化!ポート開放ゼロで繋ぐ )では、AWS Systems Manager(SSM)Session Managerによるポートフォワーディングを導入し、セキュリティグループのインバウンドを完全に閉塞(ポート開放ゼロ)しながら、SSH鍵管理を完全撤廃したセキュアなGPU利用環境を構築しました。 しかし、第3回・第4回と機能を追加していく中で、 クライアント側(WSL / Mac等)の起動スクリプト(Bash)に新たな運用課題 が生じていました: 探索レイテンシーの遅さ(直列処理の限界) : BashスクリプトからAWS CLIを使って複数リージョンのスポット価格、AZ候補、Spot Advisor中断率を逐次(直列に)取得していたため、探索とスコアリングだけで15〜30秒近く待たされる。 スポット枯渇(全滅)時の対応 : 世界的なAIブームの影響で、時間帯によっては候補リージョンのスポットキャパシティが全滅( InsufficientInstanceCapacity )することがあります。その際、エラーでスクリプトが止まり、手動でオンデマンドインスタンスを立て直すなどの二度手間が発生していました。 ローカル環境の肥大化と保守性 : Bashスクリプト内に価格パース、中断率計算、JSON加工、AWKによるスコアリングロジックが詰め込まれ、シェルスクリプトのメンテナンス性が限界に達していました。 そこで今回は、「いっそクラウド側で世界中の最安スポットGPUを並列で高速に自動探索・起動してくれるサーバーレスAPIを作ってしまえば面白いのでは?」というアイデアを形にするため、これらの判定・探索ロジックをすべてクライアントから切り離し、 AWS Lambdaによる「サーバーレスGPUブローカー」 へと進化させます。 Pythonの concurrent.futures によるマルチリージョン・マルチAZの高速並列スキャン(1秒台で完了)と、 スポット枯渇時のスマートな「オンデマンド・フォールバック機能」 を実装した実践的な手法を紹介します。 --> Information 📚 関連する過去の検証記事 第1回(基本構成) : AWS×UserDataでvLLMを自動起動!停止時コストほぼゼロのローカルLLM環境 第2回(Cline連携) : 自前vLLMをOpen WebUI経由でVS Code(Cline)に接続!トークンフリーで開発する 第3回(スポット活用) : EC2スポットインスタンス×vLLMでGPUコストを大幅削減する 第4回(SSMセキュア化) : EC2スポット×vLLMをSSMでセキュア化!ポート開放ゼロで繋ぐ サーバーレスGPUブローカーのアーキテクチャ # 📌 このセクションの要点 クライアントは探索・判定ロジックから解放され、「Lambdaを叩いて起動結果を受け取る」シンプルな構成に。Lambda側で全リージョンのBoto3並列クエリ・総合スコアリング・スポット起動試行・オンデマンドフォールバックを一手に引き受けます。 flowchart TD subgraph Client ["ローカル開発端末 (Bashスクリプト)"] CLI["./01_start_vllm_ssm.sh<br>(--on-demand / --strategy)"] Tunnel["SSM ポートフォワーディング<br>(localhost:8000 -> EC2:8000)"] end subgraph AWS_Mgmt ["AWS 管理基盤 (Lambda)"] Broker["vLLM Broker Lambda<br>(Python 3.12 / Boto3)"] Advisor["AWS Spot Advisor<br>(JSON 中断率)"] end subgraph Multi_Region ["マルチリージョン EC2 群"] direction LR R1["東京 (ap-northeast-1)"] R2["オレゴン (us-west-2)"] R3["バージニア (us-east-1)"] R4["オハイオ (us-east-2)"] R5["フランクフルト (eu-central-1)"] end CLI -->|"1. invoke (JSON)"| Broker Broker -.->|"並列価格・AZ取得 (1秒)"| Multi_Region Broker -.->|中断頻度取得| Advisor Broker -->|"2. スポット起動試行 (順次)"| Multi_Region Broker -- "スポット全滅時: エラー案内 or<br>オンデマンド(cost/latency)起動" --> Multi_Region Broker -->|"3. Instance ID & Region 返却"| CLI CLI -->|4. SSM ポートフォワード接続| Tunnel アーキテクチャの進化ポイント # 項目 第4回までの構成 (クライアントBash) 今回の構成 (Lambda ブローカー) メリット 探索速度 15〜30秒以上(CLIを直列実行) 約1〜2秒(Pythonスレッド並列) 待ち時間を大幅に短縮 クライアントの責務 価格・中断率の取得、探索計算、起動試行 Lambda呼び出しとSSMトンネル接続のみ 複雑な探索ロジックから解放 スポット枯渇時 エラーで中断、手動対応 明確なエラー案内 & オンデマンド切替 開発作業を止めずにリカバリ可能 通常起動戦略 なし 「コスト最安」または「レイテンシー最小」 業務の優先度に応じた柔軟な選択 スポット枯渇時のフォールバック設計 # スポットインスタンスは余剰キャパシティを利用するため、世界中でGPU需要がピークに達した時間帯には、全候補リージョンで InsufficientInstanceCapacity が返ってくる「スポット枯渇」が発生します。 本システムでは、以下の2段構えのフォールバックポリシーを採用しています: デフォルト(スポット起動時) : 候補リージョンすべてでスポット起動を試み、すべて在庫切れになった場合は即座にエラー ALL_SPOT_EXHAUSTED を返却。エラーメッセージには「通常起動オプション( --on-demand )を付与して再実行できる旨」を分かりやすく案内します。勝手にオンデマンド課金に切り替わって意図せぬ出費が発生する事故を未然に防ぎます。 オンデマンド指定時( --on-demand ) : どうしても今すぐ作業を進めたい場合、ユーザーは明示的に --on-demand フラグを指定して再実行します。その際、以下の2つの戦略( --strategy )を選択できます: cost (最安コスト重視・デフォルト) : 海外リージョンを含めた全リージョンのオンデマンド定価(ドル換算)を比較し、最も定価が安いリージョンで起動。 latency (低遅延重視) : 物理的に最も近い東京リージョン( ap-northeast-1 )を最優先で起動し、快適なタイピング・レスポンスを確保。 Lambda関数の実装 ( handler.py ) # Lambda関数のコアロジックです。外部ライブラリを必要とせず、標準ライブラリ( concurrent.futures , urllib.request )と boto3 だけで完結します。 --> Information 💡 工夫ポイント:UserDataのS3外出しと3行ブートストラップ 本実装では、初期化処理(NVMeマウント、Docker領域移動、並列同期、vLLM起動、アイドル自動停止)の長大なBashスクリプト本体を S3( s3://${S3_BUCKET}/scripts/startup.sh )へ外出し しています。 EC2に渡すUserDataは「S3からスクリプトを取得して実行するわずか3行」に留めることで、以下のメリットが得られます: Lambdaコードの大幅なスリム化 : 100行を超えるBashヒアドキュメントを排除でき、コード全体の可読性が向上。 高速な試行錯誤 : 起動スクリプトを微修正したいとき、Lambda関数を再ビルド・再デプロイすることなく、S3上のスクリプトを更新するだけで即時反映。 UserDataの16KB制限回避 : スクリプトがどれだけ肥大化しても、EC2のUserDataサイズ上限(16KB)に抵触しません。 handler.py(クリックで展開) """ vLLM GPU Spot Broker Lambda マルチリージョンのスポット探索、最適リージョン選定、Spot起動、Terminateを統括するサーバーレスブローカー """ import json import logging import urllib.request import concurrent.futures import boto3 from botocore.config import Config import os logger = logging.getLogger() logger.setLevel(logging.INFO) # 候補リージョンとインスタンスタイプ env_regions = os.environ.get("TARGET_REGIONS") CANDIDATE_REGIONS = [r.strip() for r in env_regions.split(",") if r.strip()] if env_regions else ["ap-northeast-1", "eu-central-1", "us-east-2", "us-east-1", "us-west-2"] INSTANCE_TYPE = os.environ.get("INSTANCE_TYPE", "g6.xlarge") IAM_ROLE_NAME = os.environ.get("INSTANCE_PROFILE_NAME", "vllm-ssm-instance-profile") TAG_SERVER_NAME = os.environ.get("TAG_SERVER_NAME", "vllm-ssm-spot-node") S3_BUCKET_NAME = os.environ.get("S3_BUCKET_NAME", "vllm-models-storage") ECR_REPOSITORY_NAME = os.environ.get("ECR_REPOSITORY_NAME", "vllm-openai") # リージョン別 AMI ID キャッシュ (Ubuntu 22.04 Deep Learning AMI) AMI_MAPPING = { "ap-northeast-1": "ami-0152728adca68ed36", "eu-central-1": "ami-0efde0090dab8cacf", "us-east-2": "ami-032b37e4db407994d", "us-east-1": "ami-012ba162b9cd2729c", "us-west-2": "ami-0ca70308d230e8a6e", } # リージョン別 オンデマンド参考価格 ($/h: g6.xlarge) と東京からの目安RTT (ms) ON_DEMAND_METRICS = { "ap-northeast-1": {"price": 1.3412, "latency_ms": 15, "name": "東京 (最低遅延・国内)"}, "us-west-2": {"price": 0.9840, "latency_ms": 110, "name": "オレゴン (コスト最安・中遅延)"}, "us-east-2": {"price": 0.9840, "latency_ms": 150, "name": "オハイオ (コスト最安)"}, "us-east-1": {"price": 0.9840, "latency_ms": 170, "name": "バージニア (コスト最安)"}, "eu-central-1": {"price": 1.2024, "latency_ms": 230, "name": "フランクフルト"}, } # S3外出しブートストラップ UserData (本体は s3://.../scripts/startup.sh から高速取得) USERDATA_BOOTSTRAP = """#!/bin/bash set -euo pipefail LOG_FILE="/var/log/userdata-bootstrap.log" exec > >(tee -a "${LOG_FILE}" | logger -t user-data -s 2>/dev/console) 2>&1 AWS_REGION="__AWS_REGION__" S3_BUCKET="__S3_BUCKET_NAME__" DOCKER_IMAGE="__DOCKER_IMAGE__" echo "=== S3から起動スクリプト (startup.sh) を取得 ===" aws s3 cp "s3://${S3_BUCKET}/scripts/startup.sh" /tmp/startup.sh --region "${AWS_REGION}" chmod +x /tmp/startup.sh echo "=== startup.sh 実行 ===" /tmp/startup.sh "${AWS_REGION}" "${S3_BUCKET}" "${DOCKER_IMAGE}" """ def get_spot_advisor_data(): """Spot Advisor から中断頻度ランクを取得""" url = "https://spot-bid-advisor.s3.amazonaws.com/spot-advisor-data.json" tiers = {} try: req = urllib.request.Request(url, headers={"User-Agent": "Lambda-Broker"}) with urllib.request.urlopen(req, timeout=3) as res: adv = json.loads(res.read().decode("utf-8")).get("spot_advisor", {}) for r, info in adv.items(): tier = info.get("Linux", {}).get(INSTANCE_TYPE, {}).get("r", 4) tiers[r] = tier except Exception as e: logger.warning(f"Spot Advisor 取得失敗: {e}") return tiers def query_region_metrics(region, advisor_tiers): """単一リージョンのスポット価格・AZ数を取得""" boto_cfg = Config(connect_timeout=2, read_timeout=3, retries={"max_attempts": 1}) ec2 = boto3.client("ec2", region_name=region, config=boto_cfg) try: res = ec2.describe_spot_price_history( InstanceTypes=[INSTANCE_TYPE], ProductDescriptions=["Linux/UNIX"], MaxResults=10, ) prices = res.get("SpotPriceHistory", []) if not prices: return None # 最安値とAZ数 min_price = min(float(p["SpotPrice"]) for p in prices) azs = set(p["AvailabilityZone"] for p in prices) az_count = len(azs) tier = advisor_tiers.get(region, 4) return { "region": region, "min_price": min_price, "az_count": az_count, "tier": tier, } except Exception as e: logger.error(f"Error querying {region}: {e}") return None def select_best_region(): """全候補リージョンを並列評価して最適リージョンを算出""" advisor_tiers = get_spot_advisor_data() metrics = [] with concurrent.futures.ThreadPoolExecutor(max_workers=len(CANDIDATE_REGIONS)) as executor: futures = {executor.submit(query_region_metrics, r, advisor_tiers): r for r in CANDIDATE_REGIONS} for future in concurrent.futures.as_completed(futures): res = future.result() if res: metrics.append(res) if not metrics: raise RuntimeError("全リージョンでスポット価格を取得できませんでした") best_price = min(m["min_price"] for m in metrics) # スコア計算: 価格40 + 余剰AZ40 + 中断耐性20 best_item = None max_score = -1 for m in metrics: p_score = int(40.0 * (best_price / m["min_price"])) tier = m["tier"] i_score = {0: 20, 1: 15, 2: 10, 3: 5}.get(tier, 0) az = m["az_count"] if az >= 5: c_score = 40 elif az == 4: c_score = 32 elif az == 3: c_score = 24 elif az == 2: c_score = 16 else: c_score = 8 total_score = p_score + i_score + c_score m["score"] = total_score if total_score >= 75: m["rank"] = "Rank S" elif total_score >= 65: m["rank"] = "Rank A" elif total_score >= 50: m["rank"] = "Rank B" else: m["rank"] = "Rank C" if total_score > max_score: max_score = total_score best_item = m return best_item, metrics def get_or_create_ssm_security_group(ec2_client, region): """インバウンド完全遮断(受信0ルール)のSSM専用セキュリティグループ""" sg_name = "vllm-ssm-isolated-sg" try: res = ec2_client.describe_security_groups( Filters=[{"Name": "group-name", "Values": [sg_name]}] ) if res["SecurityGroups"]: return res["SecurityGroups"][0]["GroupId"] except Exception: pass # 作成(デフォルトVPC) vpcs = ec2_client.describe_vpcs(Filters=[{"Name": "isDefault", "Values": ["true"]}])["Vpcs"] vpc_id = vpcs[0]["VpcId"] if vpcs else ec2_client.describe_vpcs()["Vpcs"][0]["VpcId"] new_sg = ec2_client.create_security_group( GroupName=sg_name, Description="Zero inbound ports allowed - access exclusively via AWS Systems Manager (SSM)", VpcId=vpc_id, ) # インバウンドルールは一切追加しない(完全閉塞) return new_sg["GroupId"] def get_latest_dlami(ec2_client): """最新のUbuntu 22.04 Deep Learning AMI IDを動的取得""" try: res = ec2_client.describe_images( Owners=["amazon"], Filters=[{"Name": "name", "Values": ["Deep Learning OSS Nvidia Driver AMI GPU PyTorch 2.* (Ubuntu 22.04)*"]}], ) images = sorted(res.get("Images", []), key=lambda x: x["CreationDate"]) if images: return images[-1]["ImageId"] except Exception as e: logger.warning(f"Failed to query latest DLAMI: {e}") return None def select_on_demand_region(strategy="cost"): """通常起動(オンデマンド)時の最適リージョンを選定 - cost: オンデマンド単価が最も安いリージョン(同価格なら低遅延優先: us-west-2 > us-east-2 > us-east-1) - latency: 東京からの遅延が最小のリージョン(ap-northeast-1 優先) """ valid_regions = [r for r in CANDIDATE_REGIONS if r in ON_DEMAND_METRICS] if not valid_regions: valid_regions = CANDIDATE_REGIONS if strategy == "latency": return sorted(valid_regions, key=lambda r: ON_DEMAND_METRICS.get(r, {}).get("latency_ms", 999)) else: return sorted( valid_regions, key=lambda r: ( ON_DEMAND_METRICS.get(r, {}).get("price", 999), ON_DEMAND_METRICS.get(r, {}).get("latency_ms", 999), ), ) def launch_instance(region, account_id, is_spot=True): """指定リージョンでインスタンス(スポットまたは通常起動)を起動""" ec2 = boto3.client("ec2", region_name=region, config=Config(retries={"max_attempts": 1})) ami_id = AMI_MAPPING.get(region) if not ami_id: ami_id = get_latest_dlami(ec2) if not ami_id: raise ValueError(f"AMI ID not configured for region {region}") sg_id = get_or_create_ssm_security_group(ec2, region) s3_bucket = S3_BUCKET_NAME if not s3_bucket or s3_bucket == "vllm-models-storage" or "*" in s3_bucket: s3_bucket = f"my-vllm-models-hackathon-2026-{account_id}-{region}-an" elif "{account_id}" in s3_bucket or "{region}" in s3_bucket: s3_bucket = s3_bucket.replace("{account_id}", account_id).replace("{region}", region) docker_image = f"{account_id}.dkr.ecr.{region}.amazonaws.com/{ECR_REPOSITORY_NAME}:latest" # UserData 生成 (S3外出しブートストラップ) userdata_script = USERDATA_BOOTSTRAP.replace("__AWS_REGION__", region) userdata_script = userdata_script.replace("__S3_BUCKET_NAME__", s3_bucket) userdata_script = userdata_script.replace("__DOCKER_IMAGE__", docker_image) def do_run(target_ami): run_params = { "ImageId": target_ami, "InstanceType": INSTANCE_TYPE, "MinCount": 1, "MaxCount": 1, "IamInstanceProfile": {"Name": IAM_ROLE_NAME}, "SecurityGroupIds": [sg_id], "InstanceInitiatedShutdownBehavior": "terminate", "BlockDeviceMappings": [ { "DeviceName": "/dev/sda1", "Ebs": { "VolumeSize": 40, "VolumeType": "gp3", "DeleteOnTermination": True, }, } ], "UserData": userdata_script, "TagSpecifications": [ { "ResourceType": "instance", "Tags": [ {"Key": "Name", "Value": TAG_SERVER_NAME}, {"Key": "Project", "Value": "vllm-ephemeral"}, {"Key": "MarketType", "Value": "spot" if is_spot else "on-demand"}, ], } ], } if is_spot: run_params["InstanceMarketOptions"] = { "MarketType": "spot", "SpotOptions": { "SpotInstanceType": "one-time", "InstanceInterruptionBehavior": "terminate", }, } return ec2.run_instances(**run_params) try: run_res = do_run(ami_id) except Exception as e: if "InvalidAMIID" in str(e): logger.info(f"AMI {ami_id} invalid in {region}, querying latest dynamically...") dynamic_ami = get_latest_dlami(ec2) if dynamic_ami and dynamic_ami != ami_id: run_res = do_run(dynamic_ami) else: raise else: raise instance_id = run_res["Instances"][0]["InstanceId"] return instance_id def launch_spot_instance(best_region_info, account_id): """後方互換性エイリアス""" return launch_instance(best_region_info["region"], account_id, is_spot=True) def terminate_all_instances(): """全候補リージョンから稼働中の vLLM インスタンス(スポット・オンデマンド問わず)を検索して Terminate""" terminated = [] def check_and_term(region): ec2 = boto3.client("ec2", region_name=region) try: res = ec2.describe_instances( Filters=[ {"Name": "tag:Name", "Values": [TAG_SERVER_NAME]}, {"Name": "instance-state-name", "Values": ["pending", "running", "stopping"]}, ] ) ids = [i["InstanceId"] for r in res.get("Reservations", []) for i in r.get("Instances", [])] if ids: ec2.terminate_instances(InstanceIds=ids) return [{"region": region, "instance_id": i} for i in ids] except Exception as e: logger.error(f"Error terminating in {region}: {e}") return [] with concurrent.futures.ThreadPoolExecutor(max_workers=len(CANDIDATE_REGIONS)) as executor: results = executor.map(check_and_term, CANDIDATE_REGIONS) for r_list in results: terminated.extend(r_list) return terminated def terminate_all_spot_instances(): """後方互換性エイリアス""" return terminate_all_instances() def lambda_handler(event, context): """Lambda メインエントリーポイント""" action = event.get("action", "launch") mode = event.get("mode", "spot") # "spot" or "on-demand" if event.get("on_demand") is True or event.get("use_on_demand") is True: mode = "on-demand" strategy = event.get("strategy", "cost").lower() # "cost" or "latency" account_id = context.invoked_function_arn.split(":")[4] logger.info(f"Received action: {action}, mode: {mode}, strategy: {strategy} (Account: {account_id})") if action == "launch": if mode == "on-demand": # 通常起動(オンデマンド) candidates = select_on_demand_region(strategy) logger.info(f"On-Demand candidate order (strategy={strategy}): {candidates}") failed_attempts = [] last_error = None for region in candidates: info = ON_DEMAND_METRICS.get(region, {"price": 1.0, "latency_ms": 100, "name": region}) logger.info(f"Attempting On-Demand launch in {region} ({info.get('name')})...") try: instance_id = launch_instance(region, account_id, is_spot=False) logger.info(f"Successfully launched On-Demand instance {instance_id} in {region}") return { "status": "success", "action": "launch", "market_type": "on-demand", "strategy": strategy, "region": region, "region_name": info.get("name"), "instance_id": instance_id, "instance_type": INSTANCE_TYPE, "on_demand_price": info.get("price"), "latency_ms": info.get("latency_ms"), "fallback_attempts": failed_attempts, "note": f"通常起動(オンデマンド)で起動しました(優先戦略: {strategy})。", } except Exception as e: err_str = str(e) if "VcpuLimitExceeded" in err_str or "InstanceLimitExceeded" in err_str: reason = "オンデマンドGPUクォータ上限到達" else: reason = f"{type(e).__name__}" logger.warning(f"On-Demand launch failed in {region} ({reason})") failed_attempts.append({"region": region, "reason": reason}) last_error = e continue return { "status": "error", "error_code": "ALL_ON_DEMAND_FAILED", "message": f"すべてのオンデマンド候補リージョンで起動に失敗しました。Last error: {last_error}", "fallback_attempts": failed_attempts, } else: # デフォルト: スポット起動 _, all_metrics = select_best_region() sorted_candidates = sorted(all_metrics, key=lambda x: x.get("score", 0), reverse=True) logger.info(f"Ranked spot candidates: {[c['region'] for c in sorted_candidates]}") failed_attempts = [] last_error = None for candidate in sorted_candidates: region = candidate["region"] logger.info(f"Attempting spot launch in {region} (Score: {candidate.get('score')}, Rank: {candidate.get('rank')})...") try: instance_id = launch_instance(region, account_id, is_spot=True) logger.info(f"Successfully launched spot instance {instance_id} in {region}") return { "status": "success", "action": "launch", "market_type": "spot", "region": region, "instance_id": instance_id, "instance_type": INSTANCE_TYPE, "spot_price": candidate.get("min_price"), "score": candidate.get("score"), "rank": candidate.get("rank"), "candidates": all_metrics, "fallback_attempts": failed_attempts, } except Exception as e: err_str = str(e) if "InsufficientInstanceCapacity" in err_str: reason = "スポット余剰キャパシティ不足 (在庫切れ)" elif "MaxSpotInstanceCountExceeded" in err_str: reason = "スポットクォータ上限到達" else: reason = f"{type(e).__name__}" logger.warning(f"Spot launch failed in {region} ({reason}), trying next candidate...") failed_attempts.append({ "region": region, "score": candidate.get("score"), "rank": candidate.get("rank"), "reason": reason, }) last_error = e continue # ★ 全リージョンでスポットが取得できなかった場合のエラーレスポンス return { "status": "error", "error_code": "ALL_SPOT_EXHAUSTED", "message": ( "すべての候補リージョンでスポットインスタンスの起動に失敗しました(キャパシティ不足等)。\n" "通常起動(オンデマンド)で起動する場合は、スクリプトに '--on-demand' オプションを指定してください。\n" " 例: ./01_start_vllm_ssm.sh --on-demand --strategy cost # コスト最安(米オレゴン等: 約$0.98/h)\n" " 例: ./01_start_vllm_ssm.sh --on-demand --strategy latency # レイテンシー最小(東京: 約$1.34/h)" ), "fallback_attempts": failed_attempts, } elif action == "terminate": terminated = terminate_all_instances() return { "status": "success", "action": "terminate", "terminated_instances": terminated, "count": len(terminated), } elif action == "status": best_region_info, all_metrics = select_best_region() return { "status": "success", "action": "status", "recommended_region": best_region_info, "all_metrics": all_metrics, } else: return { "status": "error", "message": f"Unknown action: '{action}'. Must be 'launch', 'terminate', or 'status'.", } S3に配置する初期化スクリプト ( startup.sh ) # S3( s3://${S3_BUCKET}/scripts/startup.sh )に配置する初期化シェルスクリプト本体です。インスタンス起動時にブートストラップUserData経由でS3から自動ダウンロードされ、実行されます。 NVMeマウント、Docker領域の退避、S3モデル同期とDocker pullの並列処理、vLLM起動、およびアイドル自動停止デーモンの全処理がこのスクリプトにカプセル化されています。 startup.sh(クリックで展開) #!/bin/bash set -euo pipefail LOG_FILE="/var/log/userdata-vllm.log" exec > >(tee -a "${LOG_FILE}" | logger -t user-data -s 2>/dev/console) 2>&1 echo "=== [$(date '+%Y-%m-%d %H:%M:%S')] startup.sh 実行開始 (S3外出しブートストラップ版) ===" AWS_REGION="${1:-$(curl -s http://169.254.169.254/latest/meta-data/placement/region)}" S3_BUCKET_NAME="${2:-}" DOCKER_IMAGE="${3:-}" HF_MODEL_ID="Qwen/Qwen2.5-Coder-7B-Instruct" SERVED_MODEL_NAME="Qwen/Qwen2.5-Coder-7B-Instruct" VLLM_PORT="8000" GPU_MEMORY_UTILIZATION="0.90" MAX_MODEL_LEN="16384" # 1. ローカル NVMe インスタンスストア(250GB)の検出と活用 NVME_DIR="/opt/dlami/nvme" if mountpoint -q "${NVME_DIR}" || [ -d "${NVME_DIR}" ]; then echo "DLAMI既定の NVMe マウント (${NVME_DIR}) を活用します..." mkdir -p "${NVME_DIR}/models" "${NVME_DIR}/docker" mkdir -p /data ln -sfn "${NVME_DIR}/models" /data/models else NVME_DEV=$(lsblk -d -n -o NAME,SIZE | grep -E '250G|232G' | head -n1 | awk '{print $1}') if [ -n "${NVME_DEV}" ]; then mkfs.ext4 -F "/dev/${NVME_DEV}" || true mkdir -p /data mount -o noatime "/dev/${NVME_DEV}" /data || true else mkdir -p /data fi mkdir -p /data/models /data/docker NVME_DIR="/data" fi LOCAL_MODEL_ROOT="/data/models" LOCAL_MODEL_DIR="${LOCAL_MODEL_ROOT}/${HF_MODEL_ID}" mkdir -p "${LOCAL_MODEL_DIR}" chmod 777 "${LOCAL_MODEL_ROOT}" # 2. Docker データ領域を NVMe に配置 systemctl stop docker containerd || true mkdir -p "${NVME_DIR}/docker" "${NVME_DIR}/containerd" mkdir -p /var/lib/docker /var/lib/containerd cp -a /var/lib/docker/* "${NVME_DIR}/docker/" 2>/dev/null || true cp -a /var/lib/containerd/* "${NVME_DIR}/containerd/" 2>/dev/null || true mount --bind "${NVME_DIR}/docker" /var/lib/docker mount --bind "${NVME_DIR}/containerd" /var/lib/containerd systemctl daemon-reload systemctl start containerd systemctl start docker # 3. S3モデル同期 と Docker pull の並列実行 S3_SRC="s3://${S3_BUCKET_NAME}/models/${HF_MODEL_ID}" aws configure set default.s3.max_concurrent_requests 20 ( echo "--- [Task A] Dockerイメージ取得: ${DOCKER_IMAGE} ---" if [[ "${DOCKER_IMAGE}" == *".dkr.ecr."* ]]; then ECR_REGISTRY=$(echo "${DOCKER_IMAGE}" | cut -d'/' -f1) ECR_REGION=$(echo "${ECR_REGISTRY}" | awk -F. '{print $4}') aws ecr get-login-password --region "${ECR_REGION:-${AWS_REGION}}" | docker login --username AWS --password-stdin "${ECR_REGISTRY}" || true fi docker pull "${DOCKER_IMAGE}" ) > /var/log/docker-pull.log 2>&1 & PID_DOCKER=$! ( echo "--- [Task B] S3モデル高速同期 ---" aws s3 sync "${S3_SRC}" "${LOCAL_MODEL_DIR}" --region "${AWS_REGION}" --no-progress ) > /var/log/model-sync.log 2>&1 & PID_S3=$! wait "${PID_DOCKER}" wait "${PID_S3}" echo "=== 並列ダウンロード完了! ===" # 4. vLLMコンテナ起動 docker run -d \ --name "vllm-server" \ --restart unless-stopped \ --gpus all \ --ipc=host \ -p "${VLLM_PORT}:8000" \ -v "${LOCAL_MODEL_ROOT}:/models" \ "${DOCKER_IMAGE}" \ --model "/models/${HF_MODEL_ID}" \ --served-model-name "${SERVED_MODEL_NAME}" \ --gpu-memory-utilization "${GPU_MEMORY_UTILIZATION}" \ --max-model-len "${MAX_MODEL_LEN}" \ --trust-remote-code \ --enable-auto-tool-choice \ --tool-call-parser hermes # 5. アイドル1時間自動終了デーモン cat <<'EOF_IDLE' > /usr/local/bin/auto-idle-shutdown.sh #!/bin/bash IDLE_LIMIT=3600 IDLE_COUNT=0 while true; do sleep 300 REQ=$(docker logs --since 5m vllm-server 2>&1 | grep -c "POST /v1" || true) if [ "${REQ}" -eq 0 ]; then IDLE_COUNT=$((IDLE_COUNT + 300)) [ "${IDLE_COUNT}" -ge "${IDLE_LIMIT}" ] && { shutdown -h now; exit 0; } else IDLE_COUNT=0 fi done EOF_IDLE chmod +x /usr/local/bin/auto-idle-shutdown.sh nohup /usr/local/bin/auto-idle-shutdown.sh > /dev/null 2>&1 & echo "=== startup.sh 完了 (SSM待機中) ===" Lambdaのデプロイスクリプト ( 00_deploy_lambda.sh ) # 作成した handler.py をZIPアーカイブ化してAWS Lambdaにデプロイし、同時に startup.sh をS3バケットへ自動同期するシェルスクリプトです。 00_deploy_lambda.sh(クリックで展開) #!/bin/bash set -euo pipefail # ============================================================================== # 00_deploy_lambda.sh # GPU起動ブローカーLambda関数およびIAM実行ロールを自動作成・更新するスクリプト # ============================================================================== SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" LAMBDA_DIR="$(cd "${SCRIPT_DIR}/../lambda" && pwd)" FUNCTION_NAME="vllm-spot-broker" ROLE_NAME="vllm-spot-broker-lambda-role" REGION="ap-northeast-1" echo "=== 1. Lambda 実行ロール (${ROLE_NAME}) の作成/確認 ===" AWS_ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text) TRUST_POLICY='{ "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Principal": { "Service": "lambda.amazonaws.com" }, "Action": "sts:AssumeRole" } ] }' if ! aws iam get-role --role-name "${ROLE_NAME}" >/dev/null 2>&1; then echo "IAMロールを作成中..." aws iam create-role \ --role-name "${ROLE_NAME}" \ --assume-role-policy-document "${TRUST_POLICY}" \ --description "Execution role for vLLM Spot Broker Lambda" >/dev/null # 基本ログ出力権限 aws iam attach-role-policy \ --role-name "${ROLE_NAME}" \ --policy-arn "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" # EC2 & IAM PassRole 権限インラインポリシー BROKER_POLICY="{ \"Version\": \"2012-10-17\", \"Statement\": [ { \"Effect\": \"Allow\", \"Action\": [ \"ec2:Describe*\", \"ec2:RunInstances\", \"ec2:TerminateInstances\", \"ec2:CreateSecurityGroup\", \"ec2:CreateTags\" ], \"Resource\": \"*\" }, { \"Effect\": \"Allow\", \"Action\": \"iam:PassRole\", \"Resource\": [ \"arn:aws:iam::${AWS_ACCOUNT_ID}:role/vllm-ssm-instance-role\", \"arn:aws:iam::${AWS_ACCOUNT_ID}:role/VllmServerSSMRole\" ] } ] }" aws iam put-role-policy \ --role-name "${ROLE_NAME}" \ --policy-name "VllmBrokerPermissions" \ --policy-document "${BROKER_POLICY}" echo "IAMロール作成完了。権限の伝播を少し待機中..." sleep 10 else echo "既存のIAMロールを使用します: ${ROLE_NAME}" fi ROLE_ARN="arn:aws:iam::${AWS_ACCOUNT_ID}:role/${ROLE_NAME}" echo "=== 2. Lambda コードのパッケージング ===" TMP_ZIP=$(mktemp --suffix=.zip) rm -f "${TMP_ZIP}" (cd "${LAMBDA_DIR}" && zip -q -r "${TMP_ZIP}" handler.py) echo "=== 2.5 起動スクリプト (startup.sh) を S3 へ同期 ===" S3_BUCKET="my-vllm-models-hackathon-2026-${AWS_ACCOUNT_ID}-${REGION}-an" if aws s3 ls "s3://${S3_BUCKET}" >/dev/null 2>&1; then echo "startup.sh を s3://${S3_BUCKET}/scripts/startup.sh へアップロード中..." aws s3 cp "${SCRIPT_DIR}/startup.sh" "s3://${S3_BUCKET}/scripts/startup.sh" --region "${REGION}" fi echo "=== 3. Lambda 関数 (${FUNCTION_NAME}) の作成/更新 ===" if aws lambda get-function --region "${REGION}" --function-name "${FUNCTION_NAME}" >/dev/null 2>&1; then echo "既存のLambda関数コードを更新中..." aws lambda update-function-code \ --region "${REGION}" \ --function-name "${FUNCTION_NAME}" \ --zip-file "fileb://${TMP_ZIP}" >/dev/null else echo "新しいLambda関数を作成中..." aws lambda create-function \ --region "${REGION}" \ --function-name "${FUNCTION_NAME}" \ --runtime "python3.12" \ --role "${ROLE_ARN}" \ --handler "handler.lambda_handler" \ --zip-file "fileb://${TMP_ZIP}" \ --timeout 60 \ --memory-size 256 \ --description "Multi-region spot instance broker for vLLM" >/dev/null fi rm -f "${TMP_ZIP}" echo "=== デプロイ完了! ===" echo "Lambda 関数名: ${FUNCTION_NAME} (${REGION})" 探索ロジックを手放したクライアント側スクリプト ( 01_start_vllm_ssm.sh ) # ローカルPC側の起動スクリプトは、複雑なマルチリージョン計算や中断率データの取得をすべてクラウド側へ手放し、 「Lambdaを呼び出して起動されたインスタンス情報を受け取り、SSMポートフォワーディングを張る」 というローカル環境の接続・トンネル管理に専念する構成へと進化します。 01_start_vllm_ssm.sh(クリックで展開) #!/bin/bash set -euo pipefail # ============================================================================== # 01_start_vllm_ssm.sh # Lambdaブローカーを呼び出して最安スポットを起動し、SSMポートフォワーディングで安全に直結する # ============================================================================== SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" INSTANCE_STATE_FILE="${SCRIPT_DIR}/.current_instance_id" TUNNEL_PID_FILE="${SCRIPT_DIR}/.current_tunnel_pid" REGION_STATE_FILE="${SCRIPT_DIR}/.current_region" TUNNEL_LOG="${SCRIPT_DIR}/.current_tunnel.log" BROKER_REGION="ap-northeast-1" FUNCTION_NAME="vllm-spot-broker" # オプション解析 USE_ON_DEMAND="false" STRATEGY="cost" while [[ $# -gt 0 ]]; do case "$1" in --on-demand) USE_ON_DEMAND="true" shift ;; --strategy) STRATEGY="${2:-cost}" shift 2 ;; -h|--help) echo "使用方法: $0 [オプション]" echo "" echo "オプション:" echo " --on-demand 通常起動(オンデマンド)で起動する(デフォルトはスポット起動)" echo " --strategy <cost|latency> 通常起動時の優先戦略(デフォルト: cost)" echo " cost : オンデマンド単価が最も安いリージョンを選択(米オレゴン等: 約\$0.98/h)" echo " latency : レイテンシーが最も小さいリージョンを選択(東京: 約\$1.34/h)" echo " -h, --help ヘルプを表示" exit 0 ;; *) echo "不明なオプション: $1" echo "ヘルプ: $0 --help" exit 1 ;; esac done TMP_RES=$(mktemp) if [ "${USE_ON_DEMAND}" = "true" ]; then echo "=== 1. サーバーレスGPUブローカー(Lambda)呼び出し [通常起動 / 戦略: ${STRATEGY}]... ===" PAYLOAD=$(jq -n --arg mode "on-demand" --arg strategy "${STRATEGY}" '{"action":"launch","mode":$mode,"strategy":$strategy}') else echo "=== 1. サーバーレスGPUブローカー(Lambda)呼び出し [スポット探索 & 起動]... ===" PAYLOAD='{"action":"launch","mode":"spot"}' fi aws lambda invoke \ --region "${BROKER_REGION}" \ --function-name "${FUNCTION_NAME}" \ --payload "${PAYLOAD}" \ --cli-binary-format raw-in-base64-out \ "${TMP_RES}" >/dev/null STATUS=$(jq -r '.status // "error"' "${TMP_RES}") if [ "${STATUS}" != "success" ]; then ERROR_CODE=$(jq -r '.error_code // ""' "${TMP_RES}") MESSAGE=$(jq -r '.message // ""' "${TMP_RES}") echo "==========================================================" if [ "${ERROR_CODE}" = "ALL_SPOT_EXHAUSTED" ]; then echo "❌ スポット在庫切れ: 全候補リージョンでスポットインスタンスを調達できませんでした。" echo "----------------------------------------------------------" echo "【試行した候補リージョンと結果】" jq -r '.fallback_attempts[]? | " - \(.region) (\(.score // "-")点 / \(.rank // "-")): \(.reason)"' "${TMP_RES}" echo "" echo "💡 通常起動(オンデマンド)であれば確実に起動可能です。" echo " 以下のコマンドで再実行してください:" echo "" echo " # コスト最安リージョン(米オレゴン等: 約\$0.98/h)で通常起動:" echo " $0 --on-demand --strategy cost" echo "" echo " # レイテンシー最小リージョン(東京: 約\$1.34/h)で通常起動:" echo " $0 --on-demand --strategy latency" else echo "エラー: Lambdaの起動リクエストが失敗しました。" echo "${MESSAGE}" cat "${TMP_RES}" fi echo "==========================================================" rm -f "${TMP_RES}" exit 1 fi MARKET_TYPE=$(jq -r '.market_type // "spot"' "${TMP_RES}") INSTANCE_ID=$(jq -r '.instance_id' "${TMP_RES}") SELECTED_REGION=$(jq -r '.region' "${TMP_RES}") echo "${INSTANCE_ID}" > "${INSTANCE_STATE_FILE}" echo "${SELECTED_REGION}" > "${REGION_STATE_FILE}" echo "==========================================================" if [ "${MARKET_TYPE}" = "on-demand" ]; then STRAT=$(jq -r '.strategy // "cost"' "${TMP_RES}") PRICE=$(jq -r '.on_demand_price // "-"' "${TMP_RES}") LATENCY=$(jq -r '.latency_ms // "-"' "${TMP_RES}") REG_NAME=$(jq -r '.region_name // ""' "${TMP_RES}") echo " ⚡ 通常起動(オンデマンド)完了: 戦略 = ${STRAT}" echo " 選定リージョン : ${SELECTED_REGION} (${REG_NAME})" echo " インスタンスID : ${INSTANCE_ID}" echo " オンデマンド価格: \$${PRICE}/h" echo " 目安レイテンシー: 約 ${LATENCY} ms" else PRICE=$(jq -r '.spot_price // "-"' "${TMP_RES}") SCORE=$(jq -r '.score // "-"' "${TMP_RES}") RANK=$(jq -r '.rank // "-"' "${TMP_RES}") echo " 🏆 最適スポットリージョン選定完了: ${SELECTED_REGION} (${RANK} / ${SCORE}点)" echo " インスタンスID: ${INSTANCE_ID}" echo " スポット価格 : \$${PRICE}/h" FALLBACK_COUNT=$(jq -r '.fallback_attempts | length // 0' "${TMP_RES}") if [ "${FALLBACK_COUNT}" -gt 0 ]; then echo " ⚠️ 上位候補リージョンで一時的なスポット在庫不足が発生したため、自動フォールバックしました:" jq -r '.fallback_attempts[] | " - \(.region) (\(.score)点 / \(.rank)): \(.reason)"' "${TMP_RES}" fi fi echo "==========================================================" rm -f "${TMP_RES}" # インスタンスの実行状態(running)待機 echo "インスタンスが running 状態になるのを待機中..." aws ec2 wait instance-running --region "${SELECTED_REGION}" --instance-ids "${INSTANCE_ID}" # Git Bash環境向けにSessionManagerPluginのパスを追加 if [ -d "/c/Program Files/Amazon/SessionManagerPlugin/bin" ]; then export PATH="${PATH}:/c/Program Files/Amazon/SessionManagerPlugin/bin" fi # --- 2. SSMエージェントのオンライン待機 --- echo "=== 2. SSMエージェントのオンライン待機 ===" echo "EC2インスタンス内部のSSM Agentが接続されるのを待機中..." SSM_OK=false for i in {1..30}; do STATUS=$(aws ssm describe-instance-information \ --region "${SELECTED_REGION}" \ --filters "Key=InstanceIds,Values=${INSTANCE_ID}" \ --query "InstanceInformationList[0].PingStatus" \ --output text 2>/dev/null || true) if [ "${STATUS}" = "Online" ]; then echo " SSM Agent が Online になりました! (${i}回目の試行)" SSM_OK=true break fi echo -n "." sleep 3 done echo "" if [ "${SSM_OK}" != "true" ]; then echo "エラー: SSM Agent が Online になりませんでした。IAMロールやアウトバウンド通信を確認してください。" exit 1 fi # --- 3. サーバー側での vLLM 起動待機 (S3モデルDL・コンテナ起動) --- echo "=== 3. サーバー側での vLLM 起動待機 (http://127.0.0.1:8000/health) ===" echo "UserDataによるS3モデルダウンロードとvLLM起動を待機しています(通常2〜4分程度)..." SERVER_READY=false for i in {1..90}; do CMD_ID=$(aws ssm send-command \ --region "${SELECTED_REGION}" \ --instance-ids "${INSTANCE_ID}" \ --document-name "AWS-RunShellScript" \ --parameters 'commands=["curl -s -m 2 http://127.0.0.1:8000/health >/dev/null && echo OK || echo WAITING"]' \ --query "Command.CommandId" \ --output text 2>/dev/null || true) if [ -n "${CMD_ID}" ]; then sleep 2 RES=$(aws ssm get-command-invocation \ --region "${SELECTED_REGION}" \ --command-id "${CMD_ID}" \ --instance-id "${INSTANCE_ID}" \ --query "StandardOutputContent" \ --output text 2>/dev/null || true) if [[ "${RES}" =~ "OK" ]]; then echo " サーバー側の vLLM が正常に応答しました!" SERVER_READY=true break fi fi echo -n "." sleep 5 done echo "" if [ "${SERVER_READY}" != "true" ]; then echo "警告: サーバー側のvLLM起動待機がタイムアウトしました。UserDataログを確認してください。" fi # --- 4. 既存トンネルの整理と新規SSMポートフォワーディング確立 --- echo "=== 4. SSMポートフォワーディングの確立 (ポート開放不要トンネル) ===" if [ -f "${TUNNEL_PID_FILE}" ]; then OLD_PID=$(cat "${TUNNEL_PID_FILE}" | tr -d '[:space:]') if [ -n "${OLD_PID}" ] && ps -p "${OLD_PID}" > /dev/null 2>&1; then echo "古いポートフォワードプロセス (PID: ${OLD_PID}) を終了します..." kill -9 "${OLD_PID}" 2>/dev/null || true fi rm -f "${TUNNEL_PID_FILE}" fi pkill -f "AWS-StartPortForwardingSession.*8000" 2>/dev/null || true # バックグラウンドでSSMポートフォワーディングを開始(ログを .current_tunnel.log に出力) nohup aws ssm start-session \ --region "${SELECTED_REGION}" \ --target "${INSTANCE_ID}" \ --document-name AWS-StartPortForwardingSession \ --parameters '{"portNumber":["8000"],"localPortNumber":["8000"]}' \ > "${TUNNEL_LOG}" 2>&1 & TUNNEL_PID=$! echo "${TUNNEL_PID}" > "${TUNNEL_PID_FILE}" echo "SSMポートフォワーディングを開始しました (PID: ${TUNNEL_PID})" sleep 2 # --- 5. ローカル疎通確認 --- echo "=== 5. ローカル疎通確認 (http://localhost:8000/health) ===" LOCAL_OK=false for i in {1..10}; do if curl -s -m 3 "http://localhost:8000/health" > /dev/null 2>&1; then echo " ローカルからの直結疎通を確認しました! (HTTP 200 OK)" LOCAL_OK=true break fi echo -n "." sleep 2 done echo "" if [ "${LOCAL_OK}" != "true" ]; then echo "警告: ローカル接続に失敗しました。トンネルログを確認してください:" cat "${TUNNEL_LOG}" 2>/dev/null || true fi echo "==========================================================" echo " サーバーレスGPUブローカーによる直結完了!" echo " EC2 Instance ID : ${INSTANCE_ID} (${MARKET_TYPE}, Region: ${SELECTED_REGION})" echo " セキュリティ : インバウンドルール 0件(完全閉塞)" echo " トンネル接続 : localhost:8000 -> EC2:8000 (SSM暗号化)" echo " Web UI / API : http://localhost:8000/v1" echo "" echo " VS Code の Cline から接続してください (Base URL: http://localhost:8000/v1)" echo " 検証終了時は以下のコマンドでリソースを破棄してください:" echo " ./02_stop_vllm_ssm.sh" echo "==========================================================" 実際の動作パターンと検証 # 1. 通常のスポット起動(1秒台で最適リージョンを選定・起動要求) # 引数なしで実行すると、Lambdaが全リージョンを並列評価し、最安・低中断率のスポットをわずか1秒台で引き当てます。 $ ./01_start_vllm_ssm.sh === 1. サーバーレスGPUブローカー(Lambda)を呼び出し中 [スポット起動]... === ========================================================== 🏆 最適スポットリージョン選定完了: us-west-2 (Rank A / 68点) インスタンスID: i-087654321fedcba09 スポット価格 : $0.4264/h ========================================================== インスタンスが running 状態になるのを待機中... === 2. SSMエージェントのオンライン待機 === EC2インスタンス内部のSSM Agentが接続されるのを待機中... . SSM Agent が Online になりました! (1回目の試行) === 3. サーバー側での vLLM 起動待機 (http://127.0.0.1:8000/health) === UserDataによるS3モデルダウンロードとvLLM起動を待機しています(通常2〜4分程度)... ................................ サーバー側の vLLM が正常に応答しました! === 4. SSMポートフォワーディングの確立 (ポート開放不要トンネル) === SSMポートフォワーディングを開始しました (PID: 53120) === 5. ローカル疎通確認 (http://localhost:8000/health) === . ローカルからの直結疎通を確認しました! (HTTP 200 OK) ========================================================== サーバーレスGPUブローカーによる直結完了! EC2 Instance ID : i-087654321fedcba09 (spot, Region: us-west-2) セキュリティ : インバウンドルール 0件(完全閉塞) トンネル接続 : localhost:8000 -> EC2:8000 (SSM暗号化) Web UI / API : http://localhost:8000/v1 VS Code の Cline から接続してください (Base URL: http://localhost:8000/v1) 検証終了時は以下のコマンドでリソースを破棄してください: ./02_stop_vllm_ssm.sh ========================================================== 2. スポット枯渇時(フォールバック案内) # 世界的にGPUキャパシティが逼迫している時間帯では、Lambdaがすべての候補リージョンを試みた上でスマートにフォールバック案内を返します: $ ./01_start_vllm_ssm.sh === 1. サーバーレスGPUブローカー(Lambda)を呼び出し中 [スポット起動]... === ========================================================== ❌ スポット在庫切れ: 全候補リージョンでスポットインスタンスを調達できませんでした。 ---------------------------------------------------------- 【試行した候補リージョンと結果】 - us-west-2 (68点 / Rank A): スポット余剰キャパシティ不足 (在庫切れ) - us-east-2 (74点 / Rank A): スポット余剰キャパシティ不足 (在庫切れ) - eu-central-1 (78点 / Rank S): スポット余剰キャパシティ不足 (在庫切れ) - us-east-1 (68点 / Rank A): スポット余剰キャパシティ不足 (在庫切れ) - ap-northeast-1 (43点 / Rank C): スポット余剰キャパシティ不足 (在庫切れ) 💡 通常起動(オンデマンド)であれば確実に起動可能です。 以下のコマンドで再実行してください: # コスト最安リージョン(米オレゴン等: 約$0.98/h)で通常起動: ./01_start_vllm_ssm.sh --on-demand --strategy cost # レイテンシー最小リージョン(東京: 約$1.34/h)で通常起動: ./01_start_vllm_ssm.sh --on-demand --strategy latency ========================================================== 3. オンデマンド起動への切り替え # 今すぐ作業を進めたい場合は、案内通りに --on-demand を付与して再実行します。 最安コスト重視( --strategy cost ): $ ./01_start_vllm_ssm.sh --on-demand --strategy cost === 1. サーバーレスGPUブローカー(Lambda)を呼び出し中 [通常起動 / 戦略: cost]... === ========================================================== ⚡ 通常起動(オンデマンド)完了: 戦略 = cost 選定リージョン : us-west-2 (オレゴン (コスト最安・中遅延)) インスタンスID : i-01122334455667788 オンデマンド価格: $0.9840/h 目安レイテンシー: 約 110 ms ========================================================== ... 低遅延重視( --strategy latency ): $ ./01_start_vllm_ssm.sh --on-demand --strategy latency === 1. サーバーレスGPUブローカー(Lambda)を呼び出し中 [通常起動 / 戦略: latency]... === ========================================================== ⚡ 通常起動(オンデマンド)完了: 戦略 = latency 選定リージョン : ap-northeast-1 (東京 (最低遅延・国内)) インスタンスID : i-09988776655443322 オンデマンド価格: $1.3412/h 目安レイテンシー: 約 15 ms ========================================================== ... 検証終了時のリソース破棄スクリプト ( 02_stop_vllm_ssm.sh ) # 作業が終了したら、破棄スクリプト( 02_stop_vllm_ssm.sh )を実行します。ローカルのSSMトンネルプロセスを安全に切断した上で、Lambdaブローカーに終了リクエストを発行し、EC2インスタンスおよびEBSボリュームを確実に破棄します。 02_stop_vllm_ssm.sh(クリックで展開) #!/bin/bash set -euo pipefail # ============================================================================== # 02_stop_vllm_ssm.sh # SSMポートフォワードプロセスを終了し、Lambda経由でGPUインスタンスを破棄する # ============================================================================== SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" INSTANCE_STATE_FILE="${SCRIPT_DIR}/.current_instance_id" TUNNEL_PID_FILE="${SCRIPT_DIR}/.current_tunnel_pid" REGION_STATE_FILE="${SCRIPT_DIR}/.current_region" TUNNEL_LOG="${SCRIPT_DIR}/.current_tunnel.log" BROKER_REGION="ap-northeast-1" FUNCTION_NAME="vllm-spot-broker" # 1. SSMポートフォワードプロセスの終了 echo "=== 1. SSMポートフォワードプロセスの終了 ===" if [ -f "${TUNNEL_PID_FILE}" ]; then TUNNEL_PID=$(cat "${TUNNEL_PID_FILE}" | tr -d '[:space:]') if [ -n "${TUNNEL_PID}" ] && ps -p "${TUNNEL_PID}" > /dev/null 2>&1; then echo "SSMポートフォワードプロセス (PID: ${TUNNEL_PID}) を終了中..." kill -9 "${TUNNEL_PID}" 2>/dev/null || true fi rm -f "${TUNNEL_PID_FILE}" fi pkill -f "AWS-StartPortForwardingSession.*8000" 2>/dev/null || true # 2. Lambdaブローカーへのインスタンス破棄リクエスト echo "=== 2. サーバーレスGPUブローカー(Lambda)へリソース破棄リクエスト ===" TMP_RES=$(mktemp) aws lambda invoke \ --region "${BROKER_REGION}" \ --function-name "${FUNCTION_NAME}" \ --payload '{"action":"terminate"}' \ --cli-binary-format raw-in-base64-out \ "${TMP_RES}" >/dev/null STATUS=$(jq -r '.status // "error"' "${TMP_RES}" 2>/dev/null || echo "error") if [ "${STATUS}" = "success" ]; then COUNT=$(jq -r '.count // 0' "${TMP_RES}") echo "==========================================================" echo " 🛑 破棄完了! 稼働中のインスタンス ${COUNT} 台を Terminate しました。" jq -r '.terminated_instances[]? | " - [\(.region)] \(.instance_id)"' "${TMP_RES}" 2>/dev/null || true echo "==========================================================" else echo "警告: Lambda経由の破棄でエラーが返されました。ローカル情報による直接破棄を試みます..." AWS_REGION=$(cat "${REGION_STATE_FILE}" 2>/dev/null | tr -d '[:space:]' || echo "ap-northeast-1") INSTANCE_ID=$(cat "${INSTANCE_STATE_FILE}" 2>/dev/null | tr -d '[:space:]' || echo "") if [ -n "${INSTANCE_ID}" ]; then aws ec2 terminate-instances --region "${AWS_REGION}" --instance-ids "${INSTANCE_ID}" >/dev/null 2>&1 || true echo "インスタンス ${INSTANCE_ID} (${AWS_REGION}) を直接Terminateしました。" fi fi rm -f "${TMP_RES}" "${INSTANCE_STATE_FILE}" "${REGION_STATE_FILE}" "${TUNNEL_LOG}" echo "スポットインスタンス、EBSボリューム、SSMトンネルの破棄が完了しました!" まとめ # 今回は、マルチリージョンのGPU調達ロジックを AWS LambdaによるサーバーレスGPUブローカー へと刷新しました。 並列探索による高速化 : Pythonのマルチスレッドにより、全リージョン・AZのスポット価格・中断率取得から判定までをわずか1〜2秒で完結。 クライアントの責務スリム化 : 複雑なマルチリージョン探索や中断率集計、フォールバックの重厚なロジックをクラウド側へ切り離し、ローカルスクリプトは接続とプロセス管理のみに専念。 耐障害性の向上(スマートなフォールバック) : スポット全滅時も勝手に高額課金せず、明確なエラー通知と再試行オプション(コスト最安 vs 低遅延)を提供。 クラウドの余剰リソースを徹底的に使い倒すための、極めて実用的で回復力の高いGPU基盤が完成しました。
はじめに # Claude Code を利用した個人開発の進め方が、最近一定の形に落ち着いてきました。 何度も作り変えてきたので、いまの形も通過点だと思います。それでも「どうやって Claude Code と開発してるんですか?」と聞かれたときに渡せるものとして、現状の構造をひととおり書き出しておきます。 --> Information この記事は「こう作るべき」という提案ではありません。 あくまで、ぼくはこう回している という記録です。 この記事の構成 # 本記事の構成は以下の通りです。 全体像:開発サイクル全体を1枚の図で示し、読み方を説明します このプロジェクトの憲法:図の前提になっている「何をどこに書き残すか」「どう守らせるか」「どこで人間が判断するか」という3つのルールを説明します 【第1部】1つの機能ができるまで: Plan → TDD → Review → Verify → Commit → CI という実際の1サイクルを、4つの Gate と合わせて左から順に追います 【第2部】どの情報を、どこに置いているか:ルール・スキル・サブエージェント・スクリプトなどに何を書き、なぜそこに置いたのかを書きます おわりに:いまの形が通過点であることと、まだ答えが出ていない課題を書きます 全体の設計だけ知りたければ最初の2章、実際の運用フローまで知りたければ第1部まで、資材の置き方まで踏み込みたければ第2部まで読んでもらえれば幸いです。 全体像 # まず1枚で。これがいま回っているものの全体です。 この図の読み方は以下の通りです。 上の枠が 材料 です。開発中に人間や Claude Code が繰り返し読むドキュメントで、 docs/adr/ や .claude/rules/ 等々が並んでいます 下の枠が 実行 です。 01 Plan から 06 CI まで、左から右に時間が流れます 05 Commit までは手元の環境、 06 CI から先は GitHub Actions です。実行環境が変わるので、色を変えて破線で区切ってあります 黒い菱形 A〜D は、ぼくが判断する場所です マゼンタの docs/adr/ / criteria.yaml は上下に2つありますが、これは同じものです。判断や指摘が記録され、それが次の開発サイクルの材料に戻ります この記事では、この図を左から順に追ったあと、図には描けなかった部分を詳解していきます。 このプロジェクトの憲法 # 図の前提になっているルールが3つあります。 ルール 概要 行き先を決めてから、書き残す 4段階のふるいで ADR / コード / 設計書 / 書かない に振り分ける 書いて守らせるのではなく、書けなくする 型で表現不能にすることを最優先にする ぼくが判断する場面を決めておく 判断を引き取る場所を工程の中に4箇所固定する 行き先を決めてから、書き残す # 実装していると、コードの外に書き残しておきたいことが次々に出てきます。「なぜこの方式を選んだか」「全体はどう噛み合っているか」「この値の上限はいくつか」「デプロイはどう回すか」等々です。 以前はこれを片っ端から設計書に足していました。その結果、認証まわりの設計書が 3,000 行を超えたあたりでコードと設計書の内容が食い違いはじめます。どちらが正しいのか分からない。直すには2箇所を直さないといけない。そのうち設計書のほうを見なくなる。 そこで、書き残したいことが出てきたら 先に置き場所を決める ようにしました。役割ごとの媒体はこうです。 役割 媒体 WHAT(何を作るか) 要件定義書 WHY(なぜそう作るか) ADR HOW(どう作るか) ソースコード(型・関数・zod schema) VERIFY(正しく作られたか) テストコード OVERVIEW(俯瞰・統合) 設計書 ただしこの表だけでは決まりません。たとえば「エラー処理はミドルウェアで一元化する」という内容は、そう決めた理由(WHY)でもあり、全体構成の一部(OVERVIEW)でもあります。同じ内容が ADR にも設計書にも行けてしまいます。 分かれ目を、 決定を1つ切り取るか、全体の整合を1枚にまとめるか に設定しました。ADR は決定するたびに1ファイル増えるので、いま 124 本あります。設計書は「全体構成」「デプロイ手順」のように枚数が決まっていて、決定が増えると中身が追従します。 「この方式を選んだ理由」なら ADR、「全体がどう噛み合っているか」なら設計書へという具合です。 そのうえで、書き残したい内容ごとに4つの問いを上から順にかけて、最初に YES が出た行き先へ書き残したいことを送ります。 問い YES の行き先 Q1. それは「決定」か?(代替案・トレードオフがある) ADR(1決定1ファイル) Q2. コード / 型 / テスト / OpenAPI を読めば再生成できる事実か? コードが真正ソース。ドキュメントには参照だけ残す Q3. コードが変わるたびに変わる(揮発する)か? ドキュメントには書かない。コード側に持たせるか、コードから自動生成する Q4. 安定 ∧ コードの単一箇所から不可視か? 設計書の領分 冒頭に挙げた4つを、この表に通してみます。「なぜこの方式を選んだか」は Q1 で ADR へ。「この値の上限はいくつか」は Q2 でコードへ。「全体はどう噛み合っているか」と「デプロイはどう回すか」は Q4 で設計書へ行きます。 この4つの問いで振り分けると、Q4 まで生き残るのは下記4類型と整理できました。 統合俯瞰(全体構成図) 横断の流儀(命名やログ項目の標準) 運用知識(デプロイ手順・runbook) ドメイン言語(用語集・コンテキストマップ) いずれも「数ヶ月・多ファイルをまたいで真であり続ける」かつ「コードの1箇所を読んでも見えない」の交差領域です。 結果として、詳細設計書は書きません。ここでいう詳細設計書は、コードを書く前にクラス構成・関数のシグネチャ・項目定義・バリデーション規則・処理シーケンスを文章で決めておく、ウォーターフォールでいう「詳細設計」工程の成果物です。シグネチャや項目定義は型を読めば再生成できるので Q2 で、クラス構成や処理シーケンスはコードが変わるたびに変わるので Q3 で落ちます。 書いて守らせるのではなく、書けなくする # 規約を守らせる手段はいくつかありますが、 いちばん優先するのは「型で表現不能にする」こと と決めています。ドキュメントに「こうしてください」と書いて守らせるのではなく、違反そのものを起こせなくする。 実際にそうしている箇所を2つ挙げます。 ID の偽造を、型と lint で禁止する ユーザー ID のような識別子は、ただの string ではなく branded type にしています。 export const UserIdSchema = z .string() .min(1) .max(128) .regex(/^[A-Za-z0-9_-]+$/) .brand<"UserId">(); export type UserId = z.infer<typeof UserIdSchema>; export const userId = (raw: string): UserId => enforceInvariant(UserIdSchema, raw, "UserId"); .brand<"UserId">() が付いているので、生の string を UserId 型の引数に渡すとコンパイルエラーになります。 UserId を作るには userId() を通すしかなく、そこで長さと文字種の検証が走ります。検証を通っていない値は、型のうえで存在できません。 ただし、型には抜け道があります。 "anything" as UserId とキャストすれば、検証を通さずに UserId 型を名乗れます。 そこで、この抜け道を ESLint で塞くようにしました。 // eslint.config.mjs const noAsBrandedScalarVo = { selector: "TSAsExpression[typeAnnotation.typeName.name=/^(UserId|BudgetId|SpendingQuadrant|BudgetQuadrant)$/]", message: "branded scalar / 列挙 VO への as キャストは型偽造の抜け道。生成は VO の factory 経由のみ。", }; as UserId と書いた時点で lint が落ちます。「ID は必ず userId() で作ってください」とドキュメントに書く代わりに、 型で塞ぎ、型の抜け道を lint で塞いでいます 。 対応表の書き漏れを、コンパイルエラーにする literal union で定義した値ごとに、対応する値を紐づける。こういう対応表をいくつか使っています。 type Status = "todo" | "doing" | "done"; const STATUS_SLUG = { todo: "to-do", doing: "in-progress", done: "done", } as const satisfies Record<Status, string>; 末尾の satisfies Record<Status, string> により、union の全メンバーが揃っていないと型エラーになります。 Status にメンバーを1つ足した瞬間、この表の更新漏れがビルド時に落ちます。レビューで気づく必要がありません。 どちらも共通しているのは、 守られたかどうかを人間が確認していない ことです。違反したらコンパイルか lint で落ちて、レビューまで届きません。 ぼくが判断する場面を決めておく # 規約と型で縛っても、実装中には必ず判断の要る場面が出てきます。そこでぼくが判断を引き取る場所を工程の中に4箇所配置しました。 図の黒い菱形 A〜D です。 Gate ぼくが判断すること A 承認済みテストケースを変えるかどうか B 実装中に発覚した設計方針をどう変えるか C 重大なレビュー指摘を、いま直すか先送りするか D 既存の ADR と現実のコードが食い違ったときの解釈 【第1部】1つの機能ができるまで # この章では、1つの機能を作る間に前章の3つのルールがどう働くかを追っていきます。 図の下の枠を左から順にたどる形で、工程ごとに「何をして、どこで止まるか」を解説するつもりです。 Plan → TDD → Review → Verify → Commit → CI A B C D チェーンの下の A〜D が Gate の位置です。TDD で2回、Review と Commit で1回ずつ、判断がぼくに戻ってきます。 各工程の手順は feature-implementation スキルにまとめてあり、Claude Code は最初のファイルを書く前にこれを読み込みます。 1. 計画する # まず、これから触る領域に関係する ADR を Claude Code に読ませます。とはいえ 124 本(現時点の数値)あるので、毎回全部渡すわけにはいきません。そこで CLAUDE.md に索引を持っています。 触る領域 必読 ADR 関連設計書 認証 / 認可 0030, 0031, 0032, 0042 authentication-design.md ロギング / 監査ログ 0009, 0033 §1.3, 0037 §3, 0042 logging-monitoring.md Hono ミドルウェア 0037, 0038 middleware-architecture.md エラー戦略 0035, 0041, 0042, 0043, 0045, 0047 error-handling-architecture.md Claude Code は着手前にこの表を引いて、該当する ADR だけを読みます。 次に、馴染みのない領域なら Explore エージェントで前例を探します。これは「既存の実装パターンを探す」ためのもので、狙いは実装の不整合を防ぐことです。 たとえば新しく xxxConfig.ts を作ろうとしたとき、すでに authConfig.ts が同じ構造で存在しているなら、それに倣うべきです。 放っておくとエージェントは毎回ゼロから「良い設計」を考えて、微妙に違う流儀を持ち込みます。 3ファイル以上に触る、アーキテクチャを変える、あるいは前例を探したら食い違うものが2つ出てきた。このどれかなら、さらに Plan エージェントを挟みます。 計画は、 Plan エージェントを使ったかどうかに関わらず、 .claude/docs/plan/ にスナップショットとして保存します。 このスナップショットは 陳腐化する前提 で置いています。実装が進めば計画とはズレるので、最新に保つ努力はしません。「当時どう段取りしたか」の記録として残すだけです。ここを「常に正しく保つドキュメント」にしようとすると、また二重管理が始まります。 なお、 Explore と Plan は Claude Code に組み込まれているエージェントで、このプロジェクトで定義したものではありません。 --> Information Explore と Plan を呼ぶ条件を上記のように資材へ明記していますが、エージェントが毎回この通りに動くわけではありません。条件に当てはまっているのに Explore を飛ばして書き始める、ということは普通に起きます。ここに書いているのは「そう指示している」であって、「必ずそうなる」ではありません。 2. テストから書く # テストの書き方はルールファイル testing.md が持っていて、エージェントはそれに従います。 Always follow a two-phase approach: define test cases → review → implement . Never implement tests before the user approves the test case list. 空のテストケースだけを先に書いて、ぼくにレビューを出す手順です。実際にはこういうものが提示されます。 describe("BudgetAmount", () => { // 正常系 test("正の整数から生成できる", () => {}); test("上限値ちょうどは生成できる", () => {}); // 異常系 test("0 以下は生成できない", () => {}); test("上限を超える額は生成できない", () => {}); test("小数を含む値は生成できない", () => {}); // 境界 test("上限 + 1 は生成できない", () => {}); }); 中身は空で、 test の説明文だけがある状態です。この状態で「この観点で合っていますか」と聞かれます。 ここで承認したテストケースをあとから変更するには、ぼくの承認が要ります。これが Gate A です。 承認なしで変更できるようにしていたときは、テストが通らない理由をフレームワークの都合と説明してテストケースごと削除する、ということが起きていました。そこで承認済みテストケースの変更は、理由の説明とセットで止めるようにしています。 Green のあとは refactorer エージェントを呼びます。Claude Code が実装したコードを読み、コードスメルを検出しリファクタリングを実施するサブエージェントです。 書いた本人には自分のコードの問題が見えにくいという話を聞き、実装直後に別のエージェントで見るようにしました。 3. レビューに出す # 観点ごとにエージェントを分けています。 エージェント 呼ぶ条件 code-reviewer 3ファイル以上 or 100行以上の変更 feedback-reviewer code-reviewer を呼ぶときは必ず一緒に security-reviewer 認証・認可・API・ユーザー入力・機密データ・DB クエリ・ファイルアップロード・決済・ログ出力・エラー処理に触れたとき code-quality-analyzer 5ファイル以上 or 300行以上、または code-reviewer が構造的な懸念を挙げたとき feedback-reviewer だけ役割が違います。コードの一般的な品質ではなく、過去にぼく自身が指摘した観点に照らして差分を見るエージェントです。観点がどこから来るかは第2部で書きます。 これらエージェントのレビュー結果は自動で保存されます。レビュー系エージェントが完了すると PostToolUse フックが発火します。このフックで persist-review-output.py が出力の全文を .claude/docs/reviews/ に書き出します。 指摘のうち MEDIUM / LOW は、Notion のバックログに積みます。HIGH / CRITICAL は次の Gate C で扱います。 Gate ― 人間が判断する # 前章で挙げた4つの Gate が、それぞれどこで発火するかをまとめます。 Gate 発火する工程 発火条件 ぼくが決めること A TDD 承認済みのテストケースを変えようとした 変更を認めるか B TDD 実装してみて設計方針を変える必要が出た 方針を変えるか。変えるなら ADR を書いてから進める C Review レビューが HIGH / CRITICAL を報告した いま直す / 理由つきで先送りする / リスクを受容する D Commit の直前 書いたコードが既存の ADR と食い違っている 解釈を決め、ADR を書いてからコミットする B と D は、いずれも ADR をコミット前に書くことにしています。 --> Gate D の実例 ADR には「エラー処理は errorMiddleware で行う」と書いてありました。リクエスト処理の途中に挟むミドルウェアで、ハンドラが投げた例外を捕まえる想定です。ところが実装してみると、バックエンドに使っている Hono では、ミドルウェアの中で try/catch してもハンドラの例外を捕まえられませんでした。フレームワークが内部で例外を処理してしまうためです。捕まえるには、Hono が用意している app.onError() という別の登録口を使う必要がありました。 ここで ADR の「errorMiddleware」を「 app.onError のことだ」と読み替えて進めることもできます。ただそれをやると、実装に存在しない仕組みが ADR に書かれたまま残ります。そこで新しい ADR を書いて、この技術制約と、以降は「errorMiddleware」を app.onError のハンドラとして読む、という解釈を残しました。元の ADR の本文は書き換えていません。 4. 検証する # コミット前に、エージェントが触ったパッケージで4つを通します。 npm run typecheck npm run depcheck npm run lint:biome npm run lint:eslint npm run test npm run test:integration 憲法の2つ目で書いた仕掛けが実際に落ちるのは、ここです。 as UserId のキャストは lint で、対応表の書き漏れは typecheck で落ちます。エージェントが規約を読み落としていても、違反はこの段階で止まります。 5. コミットする # コミットは1つの機能で1回ではなく、区切りがつくたびに積みます。それぞれ /commit を実行して Conventional Commits 形式のメッセージを作るようにしました。 Gate B / D で設計判断が動いていた場合、その判断を含むコミットより前に ADR が書かれています。図で docs/adr/ に「コミット前に起票」と注記したのはそのためです。 ADR はコミットの成果物ではなく、コミットの前提条件という位置づけにしました。 6. push して CI に渡す # ここで実行環境が変わります。図で色が変わっているところです。 CI は、落ちたらマージできないものと、落ちないものに分けています。 系統 ワークフロー 中身 挙動 強制系 back-ci lint / typecheck / 依存関係 / 単体テスト / 統合テスト 落とす front-ci lint / typecheck / テスト(VRT 含む) 落とす secret-scan gitleaks による全履歴の秘密情報スキャン 落とす dependency-audit 依存の脆弱性の判定漏れ・古い判断の検出 落とす 計測系 code-metrics qlty メトリクス / カバレッジ 落とさない。git notes に蓄積し、PR に差分コメント dependency-audit が落ちたときは ローカル環境で dependency-analyzer エージェントを呼びます。 脆弱な関数に実際に到達するか、受容してよいかという、機械では決められない2点を判断するサブエージェントです。 工程の合間で、勝手に走るもの ― フック # ここまでの工程とは別に、決まった瞬間に自動で走るものがあります。 瞬間 フック やること ぼくが発言したとき capture-turn 発言を逐語で索引に残す(第2部で使う) ファイルを編集したとき config-protection lint / formatter の設定ファイルへの編集をブロックする lint-on-save / typecheck-on-save 編集したファイルを整形し、型検査する console-log-warn 残った console.log を警告する feedback-validate 観点表への手編集を検知する(第2部参照) エージェントが完了したとき persist-review-output レビュー出力を保存する feedback-prune 使われなくなった観点を観点表から落とす(第2部参照) エージェントがやろうと思ってやるのではなく、その瞬間が来たら実行される仕組みです。 なお、 config-protection だけ性格が違います。他は記録か整形ですが、これは編集そのものを止めます。 lint の設定を緩めれば規約違反は消えるので、その手を最初から使えないようにしました。 【第2部】どの情報を、どこに置いているか # ここまでが冒頭の図の範囲です。図には「何があるか」までを描きました。ここからは、それぞれに何を書いていて、なぜそこに置いたのかを解説していきます。 Claude Code に常に読ませておける量には限りがあります。全部を CLAUDE.md やルールに書くと、読み込むだけで文脈が埋まり、肝心の指示が埋もれます。 そこで、情報ごとにいつ読まれるべきかを決めて、置き場所を分けました。 置き場 読まれるタイミング 置いているもの CLAUDE.md / .claude/rules/ 常に 索引、常に効く制約と事実 .claude/skills/ 該当する作業のときだけ 手順、プロジェクト固有の方法論 lint / 依存検査 機械が常に 守らせたい規約 docs/ 索引から引いたときだけ ADR、設計書、要件、マイルストーン、技術ノート .claude/agents/ 呼ばれたとき 役割、不変条件、出力の形 スクリプト エージェントが結果を読む 計算 criteria.yaml レビューのとき ぼくの過去の指摘 フックも置き場のひとつですが、第1部で書いたのでここでは省きます。 憲法の1つ目で、書き残したいことを ADR / コード / 設計書 / 書かない に振り分ける4つの問いを書きました。Claude Code 向けの資材にも、同じ問いを当てています。 そのため、コードや型から再構成できることは書かない。コードが変わるたびに変わることも書きません。 常に効かせるもの ― CLAUDE.md と .claude/rules/ # CLAUDE.md は149行で、中心は第1部で出てきた「触る領域 → 必読 ADR」の索引です。ADR の中身は書かず、引き方だけを持たせています。 ほかには、本番環境や機密の所在といった、どの作業でも踏み越えてはいけない境界を書いています。 一方、 .claude/rules/ には9本のファイルがあり、どれも 16〜75 行です。 ファイル 持っているもの development-workflow.md 工程の順序と、どの工程でも効く制約 coding-principles.md 戻り型注釈の必須化など、全ファイルに効く原則 code-smells.md 過去のレビューで指摘が多かったコードスメル7つ error-handling.md エラーを例外で投げてよい5つの場面(それ以外は戻り値で返すというルール) security.md リソースの所有者検証など、コードを書くときに常に守るセキュリティ上のガードレール testing.md 本番コード側の禁止と、テスト環境の事実 tsdoc.md コメントの書き方のうち、既存コードを真似しても決まらない事項(ADRドキュメント内 の内部記号を書かない、など) language.md 識別子・文字列・コメントを何語で書くか codex-delegation.md Codex に相談してよい条件(最近はほとんど使っていません) ルールに置くのは、次の3つです。 事実 禁止事項 真正ソースへの道案内 それ以外はルールに記載せず、それぞれの置き場に任せています。 ルールに書かないもの 置き場 例 手順 スキル 工程の進め方( feature-implementation ) ADR に書いてあること ADR レイヤー別の複雑度の閾値 コードを読めば分かること 実際のコード(真正ソース) エラー型の作り方、コメントの書式 コードを真正ソースにしているものは、Claude Code に近くのファイルを真似させています。 該当する作業のときだけ読み込むもの ― .claude/skills/ # スキルは説明文で示した作業に当てはまったときだけ読み込まれます。常には読み込まれないので、手順のように長くなるものを置いています。 中身は2種類に分かれます。 種類 書いていること 主に使う側 例 手順 「この作業は毎回この形でやる」という進め方 Claude Code 本体 feature-implementation / persist-plan-output / persist-review-output / notion-task extract-learning / consulting-codex / observe-metrics 方法論 一般論を、このプロジェクト向けに上書きする箇所だけ サブエージェント refactoring / unit-testing / web-vulnerability-catalog ルールとスキルのどちらに置くか # ひとつの決まりをルールとスキルのどちらに書くかで迷うことがあります。たとえばテストに関する決まりは、テストのスキルに書くのが自然に見えます。 ただ、2つは Claude Code に読み込まれるタイミングが違うので、置く場所を間違えると必要な場面で Claude Code の目に入りません。 種類 読み込まれるタイミング 例 ルール どんな作業をしていても常に testing.md スキル スキルの説明文に書いた作業に当てはまったときだけ unit-testing (テストを書くときだけ) そこで、どちらに置くかは次の3つの問いで決めました。 近くのファイルを真似すれば同じように書けるか → 書けるなら、どちらにも書かない その決まりが破られるのは、スキルが読み込まれている作業の最中か → そうでないなら、ルールに置く 破られても、テストや lint で気づけないか → 気づけないなら、ルールに置く 例えばテストに関する決まりを2つ、この問いに当てるとこうなります。 決まり 破られるのはいつか テストのスキルは読み込まれているか 置き場 本番コードにテスト都合の分岐を足さない 本番コードを書いている最中 いない ルール( testing.md ) エラーのメッセージ文言でアサートしない テストを書いている最中 いる スキル( unit-testing ) どちらもテストの決まりですが、置き場を分けました。 「本番コードにテスト都合の分岐を足さない」が破られるのは、本番コードを書いている最中です。そのときテストのスキルは読み込まれていないので、スキルに書いても Claude Code の目に入りません。そこでルールに置きました。 文書に書かず、検査に持たせるもの # lint や依存検査で気づけるものは、ルールには書かず、検査の設定として置いています。 憲法の2つ目に書いた「書けなくする」を、型で表現できないものに広げた形です。 依存方向 ― dependency-cruiser (15ルール) ルール 禁止していること domain-no-infrastructure Domain から Infrastructure への依存 application-no-infrastructure Application から Infrastructure への依存 hono-only-in-interface-http Hono を HTTP 層の外で使うこと no-cross-context-domain コンテキストをまたいだ Domain の参照 ほかに循環依存・孤立ファイル・Port 層の依存制約など、あわせて15本。ADR 0002 で決めたレイヤー構造を、そのままルールに落としてあります。 戻り値の型 ― 自作の ESLint ルール UseCase と Port(Repository の interface)の戻り値の型が Result になっているかを検査します。 既製の ESLint ルールには、戻り値の型を特定の型に限定するものがなかったので自分で書きました。 依存方向の検査も戻り値の型の検査も、違反したコードは検査で落ちます。そのとき Claude Code がルールやスキルを読み込んでいたかどうかは関係ありません。だから、同じ決まりをルールファイルに書いておく必要もありません。 索引から引くもの ― docs/ # docs/ は Claude Code が常には読まず、必要なときに CLAUDE.md の索引から辿って開く場所です。中身は5つに分かれます。 置き場 中身 どこから引くか docs/adr/ 決定の記録 索引の「触る領域 → 必読 ADR」 docs/design/ 設計書(全体構成、デプロイ手順など) 同じ索引の「関連設計書」 docs/requirements/ 要件定義書 何を作るかを確かめるとき docs/milestones.md Phase の定義と進捗 索引の「Phase の現在地」 docs/learning/ 技術の学習ノート エージェント定義やスキルから名指しで ADR と設計書に何を書くかは、憲法の1つ目で書いたとおりです。ここでは docs/learning/ について書きます。 docs/learning/ は、もともとは Claude Code と作業する中でぼくが理解した技術の仕組みを、あとで読み返せるように残しておく学習ノートの置き場です。 そのうち Fowler のリファクタリングのカタログは、 refactorer エージェントがスメルや手法の名前を確かめるときにも引いています。Claude はスメル名や手法の手順をもともと一般知識として知っているので、毎回は読ませず、名前や手順が出てこないときだけ開くように書いています。 方法論のうち一般論として通用するものは、このように書籍等から借りています。置き場は学習ノートかスキルで、出典は次のとおりです。 知識 出典 docs/learning/refactoring-smells-and-techniques.md Martin Fowler『リファクタリング(第2版): 既存のコードを安全に改善する』オーム社 unit-testing Vladimir Khorikov『単体テストの考え方/使い方』 web-vulnerability-catalog IPA「安全なウェブサイトの作り方 改訂第7版」(2021-03-31) 呼ばれたときだけ効くもの ― サブエージェント # ここでいうエージェントは、Claude Code 本体から呼び出される別のエージェント(サブエージェント)です。プロジェクトで定義しているものは次のとおりです。 エージェント 責務 参照する方法論 code-reviewer 品質・型安全性・保守性 unit-testing / code-quality-metrics / refactoring feedback-reviewer ぼくが過去に指摘した観点に照らす criteria.yaml (観点表) security-reviewer 脆弱性検出 web-vulnerability-catalog code-quality-analyzer 定量分析・優先順位付け scripts/metrics-digest.py (計測スクリプト) refactorer 振る舞いを変えない構造改善 refactoring / simplify / docs/learning/ のカタログ dependency-analyzer 依存脆弱性の到達性と受容の判定 scripts/audit-digest.mjs の出力 右端の列のとおり、エージェント定義そのものには方法論を書いていません。例えば refactorer の定義は、こう始まります。 スメルの分類と手法の手順は一般知識と docs/learning/refactoring-smells-and-techniques.md 、その一般論がこのプロジェクトで通らない箇所は refactoring スキルが持つ。 エージェント定義に置いているのは「役割」と「手順」と「してはいけないこと」で、やり方の知識はスキルとカタログに切り出しました。 いつ呼ぶか(何ファイル以上の変更で呼ぶか、など)も各エージェントの description にだけ書き、ルールには重複させていません。 実例① ― code-reviewer は、どこを見て判断しているか code-reviewer は、レビューの途中でいくつもの置き場を参照します。エージェント定義には、判断の種類ごとにどこを見るかの対応表を持たせました。 判断する対象 見る場所 関数やファイルの複雑度の閾値 ADR(レイヤー別の閾値) エラーの扱い(例外か戻り値か) ADR ログやエラーメッセージに個人情報を出さない ルール( security.md ) コメントの書き方 近くのファイルの実物と、ルール( tsdoc.md ) そのうえで、参照のしかたを2つ決めています。 閾値などの値はエージェント定義に書き写さず、出典を引用する。書き写すと、ADR を改訂したときに追随しないため 参照先どうしが食い違ったら、ADR → コードのコメント → ルールの順に優先する サーブエージェントが利用するスキルは、必要に応じて都度読み込むようにしました。 スキル 読み込む条件 unit-testing 差分にテストファイルが含まれる code-quality-metrics 指摘に数値の裏付けが要る refactoring 重大な指摘の修正案を書く直前 逆に、lint が全件検出するもの( any の使用、未使用の import など)は、チェックリストから外しています。 目で探すものではなく、機械に任せるものとして分けました。 実例② ― dependency-analyzer は、何を引き受けていないか CI の dependency-audit が落ちたときに呼ぶエージェントです。定義の冒頭にはこう書いています。 あなたの役割は脆弱性を 検出する ことではありません。検出と、機械的に導出できる解釈(依存経路、dev/prod、CVSS の分解、修正手段)は scripts/audit-digest.mjs が既に済ませています。あなたが引き受けるのは digest が原理的に答えられない 2 点 だけです。 脆弱性の検出と、機械的に出せる情報(どの依存から入ってきたか、本番に載るか、どう直せるか)はスクリプトが先に出しています。 エージェントが受け持つのは次の2点です。 到達可能性 ― 脆弱な関数に、このコードベースから実際に到達するか 受容の是非 ― 直すか、受け入れるか、先送りするか 到達可能性は次の順で調べるように書いています。 依存の起点が開発用か本番用かを見る。 本番用なら、自分のコードがその依存を直接使っているかを grep で見る。 脆弱性の対象になっている機能を、自分のコードが通るかを読む。たとえば「Hono の CORS ミドルウェアに ReDoS」なら、CORS ミドルウェアを使っているかを確かめる。 なお、脆弱性の重要度(severity)をそのまま優先度として扱わないようにしました。 さらに、エージェント定義にはやらないことも書いています。 スクリプトの出力を、自分で計算し直さない 判断の台帳に自分で書き込まない。貼り付けられる文面を返すところまで。 直せるものについては、到達可能性を調べない。直せば済むので。 --> Information 実例①②に書いたのは、エージェント定義に書いている指示です。 フックなどで強制しているわけではないので、たとえば dependency-analyzer が台帳に書き込むことも技術的にはできます。 計算させないもの ― スクリプト # 計算や機械的に出せる情報はエージェントに計算させず、スクリプトに持たせています。 例えば code-quality-analyzer は、 scripts/metrics-digest.py の結果を読みます。 dependency-analyzer は、 scripts/audit-digest.mjs が出した脆弱性の一覧を読みます。 スクリプトに持たせたのは、テストを書いたうえで出力が正しいかを機械的に確かめられるからです。 値はスクリプトが出し、エージェントにはその値が何を意味するかの判断だけを任せる、という分担です。 ぼくの指摘を溜めるもの ― 観点表 # 第1部に出てきた Review 工程で feedback-reviewer が照らす「観点」は、 .claude/feedback/criteria.yaml という観点表にあります。 中身は、ぼくがエージェントに対して過去にした指摘をパターンに蒸留したものです。例えば1項目はこんな形です。 - id: corrections:260816-115935-1 pattern: 変更した用語・値・コマンド・手順を、同じものを記述する他資材(他 ADR / plan / CLAUDE.md / rules / 設定ファイル)へ波及させず、旧記述を残したまま提示する scope: docs/**/*.md, .claude/**/*.md, CLAUDE.md detection_hint: 差分で置き換えた用語・数値・コマンド文字列を rg でリポジトリ全体に検索し、ヒットした他ファイルが同じ差分に含まれているかを照合する occurrence: 10 ぼくの発言が観点表の項目になり、レビューで使われ、役目を終えて消えるまでの流れはこうです。 capture-turn フックが、ぼくの発言を逐語で索引に残す feedback-distiller エージェントが蓄積された発言を読み、矯正にあたるものを抽出して、既存の項目と同種かを分類する 昇格候補をぼくに提示し、承認した分だけ観点表に書く feedback-reviewer が、Review 工程でコード差分を観点表に照らす 何度照らしても違反が見つからない項目は、 feedback-prune フックが観点表から落とす。 なお、この蒸留は開発サイクルの中では回しません。 セッションの冒頭で feedback-nudge フックが未蒸留の発言があると知らせてきたときにまとめて回しています。 ADR や規約が「決めたこと」を残す仕組みだとすると、こちらは「言ったのに守られなかったこと」を残す仕組みです。 おわりに # はじめにで書いたとおり、この形も通過点だと思います。実際、第1部で書いた Gate は、この記事を書いている間に見直しました。 Gate のたびにぼくの判断待ちで実装が止まり、1つのタスクが何度も止まっていたためです。この記事では、見直す前の形のまま書いています。 ほかにも、まだ答えが出ていないことがあります。 ぼくがタスクのボトルネックになっている :いまは勉強の意味も込めて、成果物に全部目を通しています。そのぶん、タスクがぼくのところで止まります。 チームで開発するときの仕組みがない :ここまでの仕組みは、ぼく1人での開発を前提に作っています。複数人で一緒に開発する場面での仕組みは、まだ整えられていないと感じています。 要求から実装までが、ひと続きになっていない :この記事で書いたのは、要件が決まったあとの開発プロセスです。その上流で顧客の要望・要求を整理して開発の要件に落とし込み、設計して実装するまでを通しでつなぐ仕組みは、まだ整備できていないと感じています。 どれもすぐに答えが出るものではないので、今後も自分で試行錯誤しながら、少しずつ整備していきたいと思います。
 この記事を読んでいただきありがとうございます。アジャイルグループに所属する、藤井智弘です。  デイリー、レトロと続けてきたイベントの話、3つ目はスプリントレビューです。前の2つはチームの内側で完結する会でしたが、レビューはチームの外の人が主役です。だからこそ、形骸化の仕方も、直し方も、少し様子が違います。 質問 #  スクラムを始めた当初は、スプリントレビューに関係部署の方々が(物珍しさもあって)来てくれていました。ところが4ヶ月経った今、参加者はほぼチームメンバーだけです。毎回きちんとデモを準備しているのに、見てもらえない。  先日、発注側の担当者からは、「デモは動画か資料で送ってもらえれば見ておきます。細かい確認は検収のときにまとめてやりますので」と言われました。悪気はなさそうですし、忙しいのも分かります。  でも…、「チーム内デモ会」と化したレビューを、これ以上続ける意味はあるのでしょうか? 回答 #  「資料で送ってください」という一言に、答えが入っています。相手は、レビューを「報告」だと思っている。報告なら、わざわざ会に出なくても資料で足ります。その判断は、相手の立場ではまったく合理的です。問題は、レビューが本当に報告の会になっていることのほうです。  この相談を受けるとき、相談者はたいてい三つのことを考えています。 来なくなったのは、忙しいからか、興味を失ったからか 資料で済むなら、それで済ませてしまってよいのか チーム内だけのレビューにも、やらないよりはましな価値があるのか  順に答えます。 来なくなったのは、忙しいからでも興味を失ったからでもなく、 来ても何も決まらない、と学習された からです。完成したものを見せられ、拍手して帰る会に、2度目から出る理由はありません。     図1: 準備は万端、席は空——4ヶ月後のスプリントレビュー 資料で済むかどうかは、会の中身によります。 報告なら資料で済みます 。しかし「この代替フローは踏襲するか捨てるか」「次はどの業務領域から検証するか」という 判断は、資料を送っても返ってきません 。 チーム内だけのレビューは、残念ながらレビューではありません。検査してくれる相手がいないからです。  処方は出席の督促ではなく、会の再定義です。レビューを「完成報告会」から「次に何を作るかを、外の人の言葉で決める会」に変えれば、 自分の発言が製品を動かすと分かった人は、呼ばなくても来ます 。見るべき場所は二つ。誰に何を決めてもらうか。もらった意見が、その後どうなったか。 「レビューは検収の場ではありません」——その指導、腑に落ちていましたか? #  研修や現場で、「スプリントレビューは承認や検収の場ではありません」「デモをする会ではなく、検査する会です」と教わった方はすくなからずいらっしゃるでしょう。でも、こう思いませんでしたか? 完成したものを見せて確認してもらう。それの何が悪いのか。発注側は検収したがっているのに 毎回ステークホルダーの意見を聞いていたら、計画がぶれたり、おさまらなかったりするのではないか 来ない人をどうやって呼ぶのか。督促のほかに、何があるのか  これらの疑問はもっともです。結論から言うと… 見せることは悪くない、見せて終わることが問題 計画が揺れるのは、まさに目的どおり期待通り。ただし揺れるのはスプリント単位。 人は、督促では来ず、自分の影響が及ぶ場にだけ来続ける。 各々を深ぼる前に、まずレビューがどんな姿になりがちかを眺めてから、順に説明します。 スプリントレビューのあるある #  形骸化したレビューには、いくつかの型があります(レトロの時は”顔”でしたが、深い意味はありません)。どれが自分たちの会に近いか、思い浮かべながら読んでください。 その1:完成報告会型 ——最もよく見る型です。チームが「今スプリントでできたこと」を順に披露し、ステークホルダーは「ありがとうございます」と拍手して帰る。質問が出ても答えて終わり。次のスプリントでやることは、会の前から決まっています。「資料で送ってください」は、この型への、相手からの正直な評価です。     図2: 拍手して帰る会。「資料見ときますねー」 その2:検収型 ——業務系の現場で起きやすい型です。発注側の担当者や、内製でも事業部門の担当者が「納品確認」のつもりで来ます。見るのは「言った通りにできているか」であり、「これでいいのか、次はどうするか」は議題に上がりません。第6回で、発注側や管理職がデイリーに顔を出す「顧客報告型」を見て、判断に関わりたい人の席はレビューだと書きました。席を用意しても、検収型のままでは、デイリーの報告会がレビューに引っ越しただけです。 その3:全員集合型 ——「関係者各位」への一斉招待で開かれる型です。「あなたが来る理由」を誰にも伝えない招待は、全員に薄く扱われます。初回は物珍しさで集まり、数回で「出なくてもいい定例」に分類されます。 その4:判子型 ——チームが「これでよろしいでしょうか」と承認を取りに行く型です。ステークホルダーの役割は「はい」と言うことだけで、選択肢は提示されません。承認は下りますが、方向は何も動きません。検収型が発注側の作法だとすれば、判子型は受注側の作法です。 その5:チーム内デモ会型 ——その1〜4の行き着いた姿です。外の人が来なくなり、チームだけでデモをして終わる。記録が残る点は悪くありませんが、検査する相手がいません。質問者のチームは、いまここにいます。 五つの型の底にあるもの #  五つの型に根を張るのは、**「レビューは成果物を見せる場だ」**という前提です。もしそうなら、見せられれば会は成功であり、来ないならデモの見せ方を磨けばよく、承認がもらえれば完了、ということに(素直に)なります。五つの型には、何もおかしなところはありません。素直に到達点に至ったのです。  そして「見せるだけの場」なら、資料で済む。相手の言い分は、この前提の上では完全に正しいのです。  さあ、三つの疑問に戻りましょう(※この記事の展開もパターン化してきたね)。 完成したものを見せて確認してもらうことの何が悪いのか 。  悪くありません。動くものを見せるのはレビューの出発点です。 問題は、見せた後に何もしないこと です。ステークホルダーが動くものを前にして提供できるのは、「言った通りか」の確認ではなく、チームが持っていない情報——現場の実感、市場や他部署の状況、次に困ること——です。それを聞かずに終わる会は、チームにとっては報告、相手にとっては検収でしかなく、どちらにも次を決める材料が残りません。 検収(完成の定義や受け入れ条件との突き合わせ)は必要な仕事ですが、レビューの中でやることではありません 。突き合わせは会の前に済ませ、会では「これを踏まえて次をどうするか」を話す。この分担があると、レビューは検収に食われずに済みます。 毎回意見を聞いていたら計画が揺れるのではないか 。  揺れます(もちろん)。そして、それこそが目的です。スプリントごとに動くものを見せるのは、方向を早く修正するためであり、方向が一度も変わらないレビューは、何も検査していないのと同じです。ただし、揺らすのはスプリント単位です。会の中で出た意見は、その場で開発チームの作業に割り込むのではなく、プロダクトバックログの並び順の変更として受け止め、次のスプリントの計画で拾う。この受け皿があれば、意見は「計画を壊すもの」ではなく「計画を更新する材料」になります。第4回の言い方をすれば、スライスの入替はこの受け皿の中で日常的に起き、ユースケース一覧の足し引きだけが、発注側の明示的な判断として手続きに載ります。 来ない人をどうやって呼ぶのか 。  督促ではもう来ないでしょう。 人が会議に出続けるのは、その場に自分の影響が及ぶときだけ です。読者の皆さんも、発言の機会も必要もない報告会議への出席は、できれば減らしたいはずです。  初期のレビューに人が来ていた理由と、来なくなった理由は、実は同じものです。始めたばかりのころ、ステークホルダーは「新しいやり方で何が起きるのか」を確かめに来ます。数回見て、こう学習します。デモは整っている、質問すれば答えてくれる、しかし自分が何を言っても言わなくても、次のスプリントでやることは変わらないらしい…。この学習が完了した時点で、レビューはカレンダーの中で「出なくてもよい定例」に分類され、「資料で送ってください」が口から出ます。これはステークホルダーの怠慢ではなく、合理的な行動です。  呼ぶ方法はひとつしかありません。「来ると、何かが自分の言葉で決まる会」にすることです。  ここで、更改案件を扱った第3〜5回を思い出してください。あの3本で見たとおり、更改案件には、発注側にしか決められないことが毎スプリントのように出てきます。検証で見つかった「謎の挙動」を踏襲するか、直すか、捨てるか。あるユースケースの検証の深さを、最小集合で止めるか、主な代替フローまで広げるか。次にどの業務領域から現物を動かすか。どれも、動くものを前にして、業務を知っている人が決める判断です。つまり更改案件のレビューには、議題が足りないはずがありません。それでも「報告会」になっているなら、これらの判断は、レビューの外——メールや別の定例——で決まっているか、誰も決めないまま開発者の推測で埋められているかのどちらかです。前者ならレビューに戻せばよく、後者なら、それこそがレビューで決めるべきことです。  それにしても、 なぜ「見せる場」という前提がこれほど自然に入り込むのか 。多くの組織には、スクラム以前から「進捗報告会」「成果報告会」「検収」の文化があります。動くものを前にして人が集まる、という見た目が似ているので、レビューはその既存の型の上に上書きされ、古い目的——報告する、確認する、承認をもらう——がそのまま生き続けます。業務系の現場では、もう一段根深い事情が絡むこともあります。内製化を進めた事業会社でも、発注先のシステム会社と向き合うときと同じ役割分担と上下関係——いわば「社内外注」の構図——が、ビジネス部門とIT部門の間に持ち込まれているケースが少なくありません。この関係のもとでは、レビューは「対等なパートナーと次を決める場」ではなく「発注先の納品確認」として扱われます。誰かが手を抜いているわけではありません。関係の形が、会の形を決めているだけです。  それでは、本来のスプリントレビューは何をする時間か。透明性・検査・適応に当てはめてみます。   透明性 ——スプリントの成果が、資料ではなく動くもの(インクリメント)として、チームの外の人にも同じように見えていること。スプリントゴールに対して何ができ、何ができなかったかも隠さずに示します。デモはこの透明性の手段であって、目的ではありません。資料で済むのは、ここまでです。   検査 ——見えているものを、プロダクトのゴールと、ステークホルダーが持ち寄った市場・現場の状況に照らして、「このまま進んでよいか」「何が変わったか」を確かめること。ここで初めて、チームの外の情報が入ってきます。資料を送っても、これは返ってきません。   適応 ——検査の結果を受けて、次に何を作るかを決めること。プロダクトバックログを並べ替え、方向を変える。レビューの成果物はデモではなく、フィードバックと、それを踏まえたバックログの方向づけです。  この三つに照らすと、完成報告会型のレビューの正体が見えてきます。透明性(デモ)に会の時間を使い切り、検査も適応も行われていない。報告会型のデイリーと、出しっぱなしのレトロと、同じ構図です。 「成果物を見せる場」から「次に何を作るかを外の人と決める場」へ 。置き換えるのはこの一点です。ここが変わらなければ、処方はどれも効きません。 処方 #  処方は手段です。レビューの進め方にも有識者の提案は多く、どれを選んでもかまいません。ただ、なぞって「できてます」感を得るだけなら、判子型と変わりません。レビューへの評価は出席者の数ではなく、レビューを機にバックログが動いた回数で判定してください。 まず、レビューでバックログが動いた回数を数える  手を打つ前に、まず数えます。直近3〜4回のレビューについて、その会を機にプロダクトバックログの並びや中身が変わった件数を数えてください。ゼロが並ぶなら、レビューは検査も適応もしていない、という事実が手に入ります。「ステークホルダーが来ない」という嘆きは、「過去4回のレビューで、バックログが動いた回数はゼロでした」という報告に変えたほうが、次の処方が通ります。レトロで前回Tryのその後を数えたのと同じ理屈です。 意思決定の場として再設計する  アジェンダの中心を「デモ」から「決めること」に移してください。毎回1つでいい、ステークホルダーの判断を要する問いを用意し、招待状にそれを明記します。「今回のレビューで決めたいこと: ◯◯」の一行があるだけで、会の性質は変わります。更改案件なら問いには事欠きません。「検証で見つかったこの挙動は、踏襲しますか、直しますか」「請求書発行の検証は最小集合で止めてよいですか、月初の一括発行まで広げますか」「次に動かすのは入金照合と月次締めのどちらからにしますか」。新規開発なら「この機能はこの形でリリースしてよいか」「AとBのどちらの方向で次を進めるか」「この仮説は現場の実感と合っているか」等々…  冒頭はスプリントゴールに対して何ができたかから始め、その上で問いを置く。そして決めたことは議事録やバックログに記録し、組織に正式な変更手続きがあるなら、そこへつないでください。口頭の合意のままにしないことが、この運用を続ける肝になります。  なお、その場で決めきれない議題(稟議事項など)は、「レビューでは方向の推薦までを決め、正式決定は既存の手続きに乗せる」と明示しておくと、会議体の位置づけや権限を巡る混乱を避けられます。  図3: 動くものを前に、外の人の言葉でバックログが動く 問いを事前に配る  当日その場で「ご意見は?」と聞かれても、人は答えられません。デモで見せるもの、判断してほしいことを事前に短く共有し、考えてくる時間の余裕を提供してください。フィードバックの質は、準備時間に比例します。「資料で送ってください」と言われたら、この事前共有をその資料にしてしまえばよいのです。 送るのは報告ではなく、「当日、これを決めてほしい」という問い です。 名指しで、理由つきで招待する  一斉招待をやめ、今回の議題に関係する人を絞って呼びます。「経理部の◯◯さん、今回の帳票出力はお手元の月次業務に直結するので、実際の画面で確認してほしい」。呼ぶ理由を言えない相手は、今回は呼ばなくていい相手です。少人数でも、影響力と意味のあるフィードバックが出る会のほうが、大人数の沈黙の場よりも、何倍も価値があります。第6回で、デイリーに顔を出す発注側や管理職に「判断に関わりたいなら、その場所はレビューです」と席を用意する話をしました。その席に呼ぶときも同じで、「この判断をあなたにしてほしい」と理由をつけて呼びます。 検収は会の前に済ませる  検収型に寄っている現場では、受け入れ条件との突き合わせを、レビューの前に担当者と済ませておきます(受け入れ条件が事前に明文化されていれば、大部分は条件との突き合わせで済みます)。レビュー当日は「言った通りできているか」の確認ではなく、「これを踏まえて次をどうするか」に時間を使う。このようなステークホルダーの関わり方を最初に合意しておくと、レビューが納品確認の時間に食われなくなります。 持ち帰れる一枚を渡す  発注側の担当者は、レビューの後、社内に報告する仕事を抱えています。「資料で送ってください」の半分は、その報告用の材料が欲しい、という意味です。第3回で、「検証済みリスト」「残リスク」「業務影響順」という語彙を担当者と共有する話をしました。レビューの終わりに、今回の検証で検証済みリストがどこまで伸び、何が決まり、残リスクがどう変わったかを一枚にして渡してください。担当者はそれをそのまま社内に持ち帰れます。 判断は会で、報告は一枚で 。この分担ができると、「資料で」という要望は消えます。 フィードバックの「その後」を見せる  もらった意見がどうなったかを、次回レビューの冒頭で必ず報告してください。「前回の◯◯さんの指摘は、この形でバックログに入り、今回のデモに反映されています」「△△のご要望は検討の結果、今期は見送りました。理由は…」。採用でも見送りでもいい、自分の以前の発言が追跡されていることが伝われば、発言は「言い損」ではなくなります。これが次回の出席の最大の動機になります。  この再設計は、チームとステークホルダーの共同作業です。チームは問いを用意して意見の行方を追跡し、招かれた側は判断を持って席に着き、自分の意見がどうなったかを次回確かめる。この往復が回り始めると、レビューは呼ばなくても人が来る会になります。「社内外注」の上下関係を対等な協働に組み替えていく、地道な働きかけでもあります。 なぜこの質問は30年繰り返されるのか #  「動くソフトウェアを定期的に見せる」という形は、導入初日から真似できます。しかし「見せたものを材料に、外の人と一緒に次を決める」という中身は、チームの外の人間関係と組織の意思決定様式に踏み込む必要があり、格段に難しい。だから多くのチームが、まず形から入り、形のまま止まります。そして数ヶ月後、空席の並んだ会議室で同じ疑問を抱くのです。  レビューの出席者数は、チームと組織の接続の健全さを示すバロメータです。空席が続くなら、それはチームの外との対話が途絶えかけているサインであり、デモの出来栄えより先に直すべきものがあります。今回のレビューで、外の人の言葉で何かが決まり、バックログが動いたか——この一つの問いが、スプリントレビューを測る計器になります。  え?やってみたけど、ステークホルダー側がどうしても動いてくれない? ムムム、そんな環境での戦い方も、いずれこの連載のどこかで扱いましょう。 次回: 「ストーリーポイントの人日換算を上司に求められます」
 この記事を読んでいただきありがとうございます。アジャイルグループに所属する、藤井智弘です。  人生にはときに「反省すること」も必要です。しかし、それは、常に「前にすすむために」で、なければなりません。  デイリースクラムの次は、レトロスペクティブへの質問を扱います。 質問 #  KPT(Keep=続けること・Problem=問題・Try=次に試すことの3観点で振り返る手法)でレトロスペクティブを4ヶ月続けています。PもTryもたくさん出ますし、出たTryはだいたい実行しています。それなのに、チームが良くなっている実感がありません。  中身を見ると、Pは「◯◯の設計ドキュメントがない」「△△のテストが書かれていない」のような作業の抜けが多く、Tryは「◯◯ドキュメントを作る」。一方で「情報の伝達漏れがあった」のようなPには、毎回「コミュニケーションを改善する」というTryが付きます。  これって改善なのでしょうか?  先日、上司にこう言われました。「『◯◯ドキュメントがない』が毎回出てくるなら、最初に設計書を全部書くウォーターフォールのほうがいいんじゃないか」。私は答えられませんでした。違う気がするのに、何が違うのか言えなくて、もやもやしています。 回答 #  最後のもやもやから片づけましょう。上司の指摘は、半分当たっています。ウォーターフォールには「設計工程で設計書を書く」という決まりがあり、その決まりがある限り「設計書がない」は起きにくい。つまり上司は、「あなたたちのチームには、 抜けを防ぐ決まりがない 」と言っているのです。これは正しい観察です。ただ、そこから「だからウォーターフォール」に飛ぶのは早い。決まり事は、工程表から引っ張らなくても、自分たちで作れます。そして、自分たちで作る場が、レトロスペクティブです。もやもやの正体は、「レトロが決まりを作る場として働いていない」ことを、上司が外から言い当てたことにあります。  残りの二つも、同じところに根があります。「PもTryも出て、実行もしている」のに実感がないのは、出た数と実行した数は増えても、チームのやり方が一つも変わっていないからです。「◯◯ドキュメントを作る」は、ドキュメントを一本増やしますが、次のスプリントには「△△ドキュメントがない」が出ます。消えたのは一つの事象(インスタンス)で、その根底にある同様な事象を繰り返し生んでいる仕組み——書く役割も、書くタイミングも、完成の基準も決まっていないこと——は、そのままだからです。これは 改善ではなく作業 です。逆に「コミュニケーションを改善する」は、来週誰が何を変えるのかが入っていないので、実行のしようがありません。こちらは 改善ではなく願望 です。質問者のレトロは、作業と願望の間にあるはずの「やり方の変更」を、一度も決めていません。   レトロの仕事は、思わしくない出来事を一つずつ消すことではなく、 その出来事を繰り返し生んでいるチームのやり方を、一つ、小さく、次のスプリントで試せる形で変えること です。良いことについても同じで、 今回はたまたまうまくいったことを、意図して起こせる形に変える ことです。  以下、Tryの「粒度」と「置き場」の二つを手がかりに、質問者のレトロを組み立て直します。      図1: 出しては片づけ、また同じ付箋——列挙会と再放送 「Tryは絞って、必ず実行しなさい」に、引っかかりはありませんでしたか? #  研修では、こう習います。 レトロは毎スプリント行う。 Tryは少数に絞り、必ず実行する。  質問者はこれを守っていて、それでも効かない。  守っている人ほど、こんな疑問を持つはずです。 PもTryも、たくさん出るのは良いことではないのか。出ないチームより、よほど健全では Tryの実行率は高い。それでも駄目だと言うなら、何を見て「機能している」と言えばいいのか 上司の「それならウォーターフォール」に、どう答えれば正しかったのか  私の答えはこうです。出る量は健全さの証ですが、レトロの成果ではありません。見るべきはTryの実行率ではなく、やり方が変わった数です。そして、上司には「おっしゃる通り、決まりがありません。来スプリントから、自分たちで一つずつ決めます」と答えればよかった。  理由はこの先で、まず効かないレトロのあるあるを眺めてから説明します。 レトロスペクティブのあるある #  効かなくなったレトロには、決まった顔があります。質問者のチームがどれに近いか、思い浮かべながら読んでください。 その1:列挙会型 ——Pに作業の抜けが並び、Tryでその一つひとつを「やる」に変え、あとの時間は担当の割り振りに消える顔です。終わった直後は片づいた感じがしますが、翌スプリントには別の抜けが(ときに同じ数だけ)並びます。 その2:再放送型 ——「コミュニケーションを密に」「テストを増やす」「早めに相談する」のような、 誰も反対しないが誰も着手できないTry が、スプリントをまたいで同じ文言で現れる顔です。実行したつもりでも、何をもって実行したと言うのかが曖昧なので、消えません。 その3:消化率型 ——付箋の枚数とTryの消化率がレトロの指標になっている顔です。たくさん出て、たくさん片づく。数字は毎回良好、でもチームの働き方は何も変わらない。質問者のチームは、おそらくここにいます。 その4:反省会型 ——付箋の主語が「誰が」になっている顔です。「◯◯さんの実装が遅れて」「△△の確認漏れで」。話し手の視線はチームメイトではなくリーダーに向き、発言は無難なものに寄っていきます。前回のデイリーで見た、宛先が管理者に向いた報告会と同じことが、レトロで起きている形です。 その5:手法遍歴型 ——KPTからFun/Done/Learnへ、次は…、と「出し方」を変え続ける顔です。新しい手法は数回は新鮮で、確かに違う付箋が出ます。しかし出した後の扱いが同じなら、数回で元に戻ります。     図2: 出し方を変えても、出した後が同じなら数回で戻る 五つの顔に共通する思い込み #  五つの顔はばらばらに見えて、同じ思い込みから出ています。**「出て、実行されれば、レトロは機能している」**という思い込みです。そう思っていると、たくさん出れば成功で、実行率が高ければなお良く、出なくなったら手法を変えればよい、という判断が次々に導かれます。五つの顔は、その判断を忠実に積み上げた先の姿です。  この思い込みを前にして、先ほどの三つの疑問に戻ります。   たくさん出るのは良いことではないのか ——良いことです。出ないチームには、出させる工夫が先に要ります。ただし、出ることはレトロの入口であって、出口ではありません。付箋は、繰り返し起きていることを見つけるための材料です。「◯◯ドキュメントがない」は、それ単体では一つの出来事にすぎません。同じ種類の抜けが過去3回のレトロで何回出たかを数えたとき、初めて 仕組みの問題 ——「書く役割と時点が決まっていない」など——が見えてきます。材料が多いのは良い。材料のまま終わるのが問題です。   実行率が高くても駄目なのか ——実行したTryの粒度によります。Tryには粒度があって、細かすぎると作業、粗すぎると願望になります。「◯◯ドキュメントを作る」は細かすぎて、出来事を一つ消すだけ。「コミュニケーションを改善する」は粗すぎて、来週の誰の振る舞いも変えない。改善と呼べるのは、その間——「設計変更の連絡は受付チャンネルに集約して当番が起票する」「完成の定義に『誰向けの何』を書き、レビューで確認する」——のように、チームのやり方そのものを一つ変えるものです。見分け方は一つで、「それをやったら、同じ種類のPが次のスプリントで出なくなるか」。実行率が高くても、作業と願望しか実行していなければ、やり方が変わった数はゼロです。   上司にどう答えればよかったか ——「決まりがない」という観察には、そのまま同意してよいのです。違うのは、決まりをどこからもらうかです。ウォーターフォールは、工程表と開発標準が「いつ、誰が、何を書くか」を最初に決めてくれます。スクラムは、第2回で見たとおり「どう作るか」を定義していません。その代わり、やり方を決める権限と責任をチームに渡していて、決める場がレトロです。レトロが決まりを作れていないチームは、工程表から決まりをもらっていたときより頼りなく見えます。上司の目にはそう映っていて、それは正しい。ただし、ウォーターフォールに戻れば解決するわけでもありません。第3回で見たように、工程で書かれた設計書は、書いた時点の現物しか写していません。「ドキュメントがない」が「ドキュメントはあるが古い」に変わるだけで、困りごとの中身は変わらない。そして工程表の決まりは最初に一度しか作れませんが、レトロの決まりはスプリントごとに直せます。だから答えはこうなります。「おっしゃる通り、いまのレトロは決まりを作れていません。設計工程に当たるものを、完成の定義として自分たちで定義します。来スプリントから、抜けを種類で数えて、仕組みを一つずつ改善していきます」。上司の指摘は、外からの検査として、むしろ有用だと言えます。   なぜ「出て、実行されれば機能している」と思い込むのか ——導入初期には、それで本当に機能するからです。始めたばかりのチームには「あからさまな無駄」が転がっていて、拾えば効く。出来事を一つ消すだけで、チームは目に見えて良くなる。その成功体験が、「出す・実行する」をレトロの型として固定します。無駄を拾い尽くし、残った問題が仕組みに根ざしたものになった頃、同じ型は空回りを始めます。もう一つ、多くのメンバーはスクラム以前に「反省会」の作法を身につけています。問題を挙げ、担当を決め、「以後気をつけます」で締める作法です。誰かが怠けているわけではなく、新しい器に古い作法が注がれているだけです。  ではレトロは本来、何をする時間か。前回、スクラムの基本は経験主義で、その柱は 透明性・検査・適応 の3つだと書きました。デイリーが「今日の動き」を毎日回す小さなループなら、レトロは「チームのやり方」をスプリントごとに回すループです。   透明性 ——このスプリントで何が起きたかが、意見ではなく事実として見えていること。ゴールの達成状況、持ち越しや割り込みの件数、待ち時間、そして「同じ種類のPが何回出たか」。付箋は、この事実の上で読まれて初めて意味を持ちます。   検査 ——事実を、スプリントゴールと完成の定義に照らして、繰り返し起きているものと、それを生んでいるやり方を見つけること。単発の抜けと、仕組みの問題を仕分けるのはここです。   適応 ——やり方を一つ変えると決め、担当者と完了条件をつけて、次のスプリントの計画に積むこと。変更は仮説で、効いたかどうかは次のレトロで確かめます。  この三つに照らすと、質問者のレトロで何が抜けているかが見えます。出来事の透明性はあるが、繰り返しを見つける検査がなく、やり方を変える適応が計画に積まれていない。レトロを「出来事を消す会」から「やり方を一つ変える会」に置き直す。本稿の処方は、そのための手順です。  なお、スクラムガイド2017には「前回のレトロスペクティブで特定した改善を、少なくとも1つスプリントバックログに含める」という一文がありました。スクラムガイド2020でこの一文は削除されましたが、レトロの成果物は意見ではなく計画に積まれたやり方の変更だ、という趣旨は、いまも有効な指針だと私は考えています。 処方 #  ここから挙げるのは、どれも道具です。以前にも指摘させていただきましたが、形だけなぞって「できている感」で満足していると、手法遍歴型がひとつ増えるだけです。試したあとは、やり方が変わった数と、同じ種類のPが減ったかどうかで、効き目を確かめてください。 まず、過去3回のPを種類で数える  手を入れる前に、現状を数字にします。直近3回のレトロのPを、「ドキュメント系」「テスト系」「伝達漏れ系」のように種類で束ね、同じ種類が何回出たかを数えてください。あわせて、その間に出たTryのうち、チームのやり方(手順・基準・経路・道具)を変えたものが何個あったかも数えます。多くのチームで、前者は「3回とも」、後者は「ゼロ」になります。この二つの数字があるだけで、「レトロが機能していない気がする」は「同じ種類のPが3回続き、やり方の変更はゼロだった」という事実に変わり、上司への説明もそのままできます。 (1)単発と繰り返しを仕分ける  付箋が出そろったら、議論の前に全員で仕分けます。初めて起きた単発の抜けは、議論せずバックログへ直行。レトロではもう扱いません。繰り返し起きているもの、仕組みに原因がありそうなものだけを議題に残します。「これは初めてですか、前にもありましたか」をファシリテーターの最初の口癖にしてください。 (2)Tryの粒度を揃える  議題に残ったものからTryを作るとき、細かすぎるものは一段粗く、粗すぎるものは一段細かくします。「◯◯ドキュメントを作る」なら、「それがなかったせいで誰が何に困ったか」「それはいつ誰が作ることになっていたか」と問い、完成の定義やチェックリストの変更に引き上げる。「コミュニケーションを改善する」なら、「どの情報が、誰から誰へ、いつ届くべきだったか」「その情報はいまどの経路を通っているか」と問い、経路の変更に引き下げる。前回見た兼務メンバーの時間宣言は、「コミュニケーションを密に」を正しい粒度に下ろした一例です。ちょうどいい粒度のTryは、いつ・誰が・何を、が言えて、やったかどうかが判定できて、同じ種類のPが減ったかで効き目を測れます。 (3)Tryは1個に絞り、バックログに積む  粒度を揃えたTryは、1個に絞ります。5個の「そのうちやる」より、1個の「次スプリントで必ずやる」。選んだ1個には担当者と完了条件を付け、プロダクトバックログに正式な項目として積み、次のスプリントの計画で拾います。改善を「レトロの中の善意」から「計画された仕事」に格上げすること。これが再放送を止める最重要の一手であり、レトロの「適応」そのものです。     図3: 変えるのは出来事ではなく、やり方の歯車 (4)レトロの冒頭は「前回Tryのその後」から  アジェンダの先頭を固定します。前回のTryは実行されたか。同じ種類のPは減ったか。減っていないなら、粒度が外れていたのか、実行されなかったのか。この5分があるだけで「出しっぱなしにならない」ことがチームに伝わり、改善の実行率と、やり方が変わった数は、ここで毎回更新されていきます。 (5)SMの問いかけを、出来事からやり方へ向ける  メンバーの視線は、放っておくと出来事に向きます。スクラムマスターの仕事は、問いで視線をやり方に移すことです。使える問いをいくつか挙げます。 「これは初めてですか? 過去3回のレトロで、同じ種類のPは何回出ましたか?」 「それがなかったせいで、誰が、何に困りましたか?」 「それを作る手順は、完成の定義やチェックリストのどこに書いてありますか?」 「それは来週、誰が、何を、今までと違うようにやることですか?」 「同じ状況に別の人が置かれていたら、違う結果になっていましたか?」 「今回うまくいったのは、たまたまですか、それとも私たちが何かを変えた結果ですか?」 「これをやると、同じ種類のPは次のスプリントに出なくなりますか?」  逆に、「なぜやらなかったのですか」「誰の担当でしたか」「次からどうしますか」は避けてください。答えが「気をつけます」にしかならず、視線を出来事と人に戻してしまいます。 (6)やり方の変更を、上司に見える形で示す  「ウォーターフォールのほうがいいのでは」と言われたチームへの処方です。完成の定義の改定、受付経路の変更、チェックリストの追加——レトロで決めたやり方の変更を一覧にし、「同じ種類のPが何回から何回に減ったか」を添えて見せてください。工程表の代わりに、チームが自分で決まりを作っていることが見えれば、上司の問いは「決まりがないのでは」から「次は何を決めるのか」に変わります。上司の側も、設計書の枚数ではなく、この一覧と減り方を見てください。チームが決まりを作る力は、そこに現れます。 (7)それでも煮詰まったら、視点を変える  (1)〜(6)が回り始めた上でなら、テーマ型レトロ(今回は品質だけ)、タイムライン振り返りなど、手法の変更が効いてきます。順序を間違えないでください。「出した後」の扱いが同じまま「出し方」だけ変えても、数回で元に戻ります。 なぜこの質問は30年繰り返されるのか #  長いあいだ、「いつ、誰が、何を書くか」という決まりは、工程表と開発標準が外から与えてくれるものでした。チームが自分でやり方を決める、という仕事は、多くの現場で経験されたことがありません。スクラムはその仕事をチームに渡しましたが、だからといって渡された側が十分に訓練されているわけではありません。だから最初は「あからさまな無駄」を拾って成功し、拾い尽くしたあとで、やり方を決める段階に入れずに止まります。そして止まったチームを外から見た人は、決まりを与えてくれる古い方法を思い出し、「それならウォーターフォール」と言う。この問答は、世代が替わるたびに繰り返されるでしょう。  レトロの価値は、出た付箋の枚数でも、Tryの消化率でもなく、チームのやり方が何個変わったかで測られます。今回のレトロで、やり方が一つ変わり、それが次の計画に積まれたか——それだけ確かめれば、レトロが生きているかどうかは分かります。この基準さえ持てば、レトロにネタ切れはありません。チームが完璧になるまで、変えるべきやり方は無限に見つかるからです。そして完璧なチームには、少なくとも私は、まだ会ったことがありません。 次回: 「スプリントレビューにステークホルダーが来なくなりました」