AIエージェントが開発中のWebアプリをリアルタイムプレビューできるBedrock AgentCore Session endpointsを試してみた
:::note warn
この記事はAI(Codex)と一緒に作成しました。
内容は2026年11月時点の検証結果です。
:::
はじめに
2026年10月30日、AWSから以下の記事が公開されました。
> Preview web apps live as your coding agent builds them with Amazon Bedrock AgentCore runtime
AIエージェントがWebアプリを開発している最中に、
そのWebアプリをブラウザからリアルタイムにプレビューできるという内容です。
コーディングエージェントを使えば簡単にWebアプリを作れる時代になりましたが、
クラウド上で動くAIエージェントが作ったものを確認するには、
コードを取得してローカルで起動したりする手間がかかることもあります。
そこでAmazon Bedrock AgentCore RuntimeのSession endpointsという機能を使うと、
AIエージェントが動いている実行環境内のWebサーバーに、認証付きでアクセスできます。
つまり、AIエージェントがコードを編集した瞬間、その変更をブラウザで確認できるわけです。面白そうなので触ってみました。
!{348A9BEA-EF0D-4DCD-B86C-DBEFAC81422B}.png
> 赤枠がエージェントとのチャット画面、青枠がWebアプリのプレビュー画面です。
> 赤枠で相談した内容が青枠に即時反映されます。
今回はAWS公式サンプルを使って、日本向けの推論プロファイルで実際に動作を検証してみました。
実際に動かしてみた
今回やってみたことは、次の3つです。
1. AgentCore Runtime上でコーディングエージェントを動かす
2. エージェントが編集しているWebサイトをブラウザから表示する
3. 日本語で変更を指示し、Webサイトがリアルタイムに更新されることを確認する
実際には、コーヒーショップのサンプルWebサイトに対して、次の指示を送りました。
> Webサイト全体を日本語に翻訳してください。
> デザインやレイアウトは変更しないでください。
結果として、AIエージェントがindex.htmlとsrc/main.jsを編集し、
変更後のWebサイトをブラウザから確認できました。(翻訳は微妙だけど)
■変更前(英文)
!image.png
■変更後(日本語訳)
!image.png
セッション分離や認証なしアクセス、WebSocketの更新通知も気になったので、追加で検証してみました。モデルIDやRuntime更新で地味にハマったので、その対処もついでに紹介します。
Session endpointsとは?
Session endpointsは、AgentCore Runtime内で動いている
HTTPサーバーなどにアクセスできる仕組みです。
例えば、AIエージェントが同じ実行環境内でWebアプリの開発サーバーを起動していれば、
そのWebアプリを外部からプレビューできます。
通常のRuntime呼び出しとの違い
AgentCore Runtimeには、AIエージェントを呼び出すための仕組みがもともとあります。
一方、Session endpointsは、AIエージェント自身ではなく、エージェントと同じセッション内で動いている別のアプリケーションにアクセスするための機能です。
| 項目 | Runtimeの通常呼び出し | Session endpoints |
|---|---|---|
| 主な用途 | AIエージェントの実行 | セッション内のアプリへのアクセス |
| アクセス対象 | エージェントのAPI | 許可したポートのWebサーバーなど |
| 今回の用途 | Webサイトの編集を指示 | 編集中のWebサイトを表示 |
| 通信 | Runtime API | HTTP、WebSocketなど |
Session endpointsでは、あらかじめ許可したポートだけにアクセスできます。
また、URLを知っているだけで誰でもアクセスできるわけではなく、IAMなどによる認証が必要です。
今回の構成
flowchart TD
A["ローカルPCのブラウザ"] --> B["PreviewApp"]
B -->|"編集指示"| C["AgentCore Runtime API"]
C --> D["AIエージェント"]
D --> E["Amazon Bedrock<br/>Claude Haiku 5.5"]
D --> F["Webサイトのファイルを編集"]
F --> G["Vite開発サーバー<br/>Port 8090"]
G -->|"Session endpoints<br/>HTTP / WebSocket"| B
B -->|"プレビューを表示"| A
今回のサンプルでは、主に3つのポートを使用します。
| ポート | 用途 |
|---|---|
| 8080 | AIエージェントの呼び出し |
| 8090 | ViteによるWebサイトのプレビュー |
| 8081 | ステータス確認用API |
Viteはファイルの変更を検知してブラウザの表示を更新してくれるツールです。
今回の構成では、ViteのWebSocket通信もSession endpoints経由で転送されるので、
ブラウザを手動更新せずに変更内容を確認できます。
検証方法
AWS CLIの認証設定が完了していることを前提とします。
また、RuntimeやIAMロール、S3リソースを作成する権限に加え、
Runtimeの呼び出しとSession endpointsへのアクセス権限が必要です。
公式サンプルのPython依存関係は、bedrock-agentcore>=1.25.2、boto3>=1.43.122でした。
手順① 公式サンプルを取得する
まず、AWS公式のGitHubリポジトリを取得します。
(WSLのターミナルから実行しました。)
git clone https://github.com/awslabs/agentcore-samples.git
今回使用するディレクトリに移動します。
cd agentcore-samples/01-features/02-host-your-agent/01-runtime/03-advanced/16-session-endpoints
このディレクトリには、以下のようなファイルがあります。
| ファイル | 役割 |
|---|---|
| deploy.py | AgentCore Runtimeのデプロイ |
| cleanup.py | 作成したAWSリソースの削除 |
| preview_app.py | ローカルのプレビュー画面の起動 |
| session_endpoints_preview.ipynb | 公式の検証用Notebook |
| agent/app.ts | AIエージェントの実装 |
| site/ | サンプルWebサイト |
手順② Python環境を準備する
Pythonの仮想環境を作成します。
uv venv
仮想環境を有効化します。
source .venv/bin/activate
依存パッケージをインストールします。
uv pip install -r requirements.txt
これでPython側の準備は完了です。
手順③ 利用するモデルIDを確認する
公式サンプルでは、モデルIDのデフォルト値として
us.anthropic.claude-haiku-5-5が指定されています。
私は日本向け推論プロファイルを利用したかったので以下のモデルIDを使いました。
jp.anthropic.claude-haiku-5-5
なお、モデルIDと推論プロファイルのIDは別物で、一覧に表示されるモデルでも直接指定して呼べるとは限りません。利用できるモデルや推論プロファイルはリージョンやアカウントの権限によって変わるので、上記のIDがどの環境でもそのまま使えるとは限らない点だけ補足しておきます。
手順④ AgentCore Runtimeをデプロイする
公式サンプルのdeploy.pyを実行します。
日本向け推論プロファイル利用時はモデルIDを明示的に指定しましょう。
python deploy.py \
--model jp.anthropic.claude-haiku-5-5
デプロイが完了すると、RuntimeのステータスがREADYになります。
今回のサンプルは、Node.jsのアプリケーションをZIP形式でS3にアップロードし、
AgentCore Runtimeで実行する構成です。
(つまりDockerイメージやECRリポジトリを自分で準備する必要がないわけですね!)
Session endpoints用のポート設定
deploy.pyでは、次のような設定が使われています。
protocolConfiguration={
"serverProtocol": "HTTP",
"port": 8080,
"path": "/invocations"
},
allowedPorts=[
{"port": 8090},
{"port": 8081}
]
- protocolConfigurationは、AIエージェント本体の呼び出し先を設定しています。
- allowedPortsは、Session endpoints経由でアクセスを許可するポートです。
今回はWebサイトのプレビュー用に8090、ステータス確認用に8081を指定しています。
(allowedPortsを設定するだけでWebサーバーが起動するわけではありません)
手順⑤ プレビュー画面を起動する
Runtimeのデプロイが完了したら、プレビュー画面を起動します。
今回は次のPythonコードを実行しました。
python - <<'PY'
import time
from preview_app import PreviewApp
app = PreviewApp().start()
print(f"プレビューURL: {app.url}", flush=True)
try:
while True:
time.sleep(1)
except KeyboardInterrupt:
pass
finally:
app.stop()
print("セッションを終了しました")
PY
成功すると、プレビューURLが表示されます。
プレビューURL: http://localhost:8087
ブラウザからこのURLを開きます。
すると最初にお見せした、
左側にAIエージェントとのチャット画面、右側にコーヒーショップのサンプルWebサイト
が表示されます。
!image.png
右側のWebサイトはローカルPC上で動いているわけではなく、
AgentCore Runtimeのセッション内で動いていて、Session endpointsを通じて
ローカルブラウザに表示されています。
内部では何が起きているのか?
PreviewAppの起動処理を見てみると、こうなっています。
answer = self.invoke({"prompt": ""})
started = json.loads(answer["response"].read())
self.endpoint = self.client.session_endpoint_client(
session_endpoint_url(answer)
)
self.local = self.endpoint.open_local_endpoint(
port=self.config["preview_port"],
local_port=self.proxy_port
).start()
まずInvokeAgentRuntimeでセッションを起動し、
レスポンスからSession endpointのURLを取得して、ローカル側に接続口を作っています。
このローカル側の接続口がAWS認証情報を付けてリクエストを転送してくれるので、
ブラウザ自身がAWSのSigV4署名を実装する必要はありません。地味に便利な仕組みです。
手順⑥ AIエージェントに日本語で変更を指示する
いよいよWebサイトを変更してみます。
左側のチャット欄に、以下の指示を入力しました。
!{E42D5922-5C0B-4331-B6B9-9410A8750ACA}.png
するとコードを触っているログが表示され、
最終的には、変更が完了したことを示すメッセージが表示されました。
実際に、Webサイト内の英語が日本語に変更され、ブラウザ側にも反映されました。
!{CEE37FE4-6060-4EFC-9117-6FF7F0CD7AC5}.png
AIエージェントがコードを編集し、
Webサイトの変更内容をちゃんと確認できるところまで成功しました。
なぜリアルタイムに反映されるのか?
流れはこんな感じです。
flowchart TD
A["ユーザーが変更を指示"] --> B["AIエージェントがコードを編集"]
B --> C["ファイルを保存"]
C --> D["Viteが変更を検知"]
D --> E["WebSocketでブラウザに通知"]
E --> F["プレビュー画面が更新"]
Session endpointsは、通常のHTTPリクエストだけでなく
WebSocket通信も転送できるので、Viteのホットリロード機能をそのまま使えます。
エージェントがファイルを触るたびに、Webサイトの変更をブラウザ側で確認できる、というわけです。
(実際のWebSocket通信については、後述の「追加で気になったことを検証」で詳しく紹介します。)
手順⑦ 後片付けをする
プレビューを実行しているターミナルでCtrl + Cを押し、ローカルのプレビューとプロキシを停止します。
続いて、公式サンプルに含まれるクリーンアップスクリプトを実行します。
python cleanup.py
このスクリプトで、サンプルが作ったRuntime、S3上のコード、IAMロールなどが削除されます。
ただしS3バケット自体やCloudWatch Logsなどが残る場合があるので、他の用途で使っていないか確認してから整理してください。
追加で気になったことを検証
公式サンプルを動かしてみると、認証やセッション分離、WebSocket通信など、内部の仕組みも気になってきました。そこで、いくつか追加で検証してみました。
①認証なしでアクセスするとHTTP 403が返る
AWS側のSession endpoint URLに、認証署名なしのcurlでアクセスしてみました。
read -r -p "Session endpoint URL: " ENDPOINT_URL
curl -sS -o /dev/null \
-w 'HTTP Status: %{http_code}\n' \
--max-time 15 \
"${ENDPOINT_URL%/}/"
HTTP Status: 403
ちゃんと403で弾かれました。一方、AWS SDKを使うPreviewApp経由ではアクセスできています。
(http://localhost:8088はローカルのPreviewAppなので、こっちが開けるのは当然です。Session endpoint自体が匿名公開されているわけではありません。403の詳細理由やIAMロール違いまでは追っていませんが、「認証が必要」という説明どおりの挙動でした。)
②複数セッションはちゃんと独立していた
同じAgentCore Runtimeで2つのセッションを同時起動し、変更が混ざらないか確認しました。
ターミナルAとBで、それぞれ以下のコードを実行します。事前に同じサンプルディレクトリへ移動し、仮想環境を有効化しておきます。
ターミナルA:
python - <<'PY'
import time
from preview_app import PreviewApp
app = PreviewApp(ui_port=8087, proxy_port=8091).start()
try:
print(app.session_id, app.url, flush=True)
while True:
time.sleep(1)
except KeyboardInterrupt:
pass
finally:
app.stop()
PY
ターミナルB:
python - <<'PY'
import time
from preview_app import PreviewApp
app = PreviewApp(ui_port=8088, proxy_port=8092).start()
try:
print(app.session_id, app.url, flush=True)
while True:
time.sleep(1)
except KeyboardInterrupt:
pass
finally:
app.stop()
PY
ブラウザでhttp://localhost:8087とhttp://localhost:8088を開き、それぞれ次の指示を送ります。
- Session A:Webサイトの背景色を赤色に変更
- Session B:Webサイトの背景色を青色に変更
!image.png
結果、Session Aは赤、Session Bは青になり、お互いの変更は混ざりませんでした。同じRuntime上でもセッションごとに異なる編集結果が保持されていました(ファイルシステムの内部的な隔離方式や、IAMユーザー間の権限周りまでは今回は検証していません)。
③WebSocketでViteの更新通知がちゃんと転送された
Session endpointsが本当にWebSocket通信も転送してくれるのか気になったので、開発サーバーをブラウザで開いてEdgeの開発者ツールで「ネットワーク」→「Socket」を見てみました。
HTTP/1.1 101 Switching Protocols
Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Protocol: vite-hmr
101 Switching ProtocolsはWebSocketへの切り替え成功を示すレスポンスで、vite-hmrはViteのホットリロードのサブプロトコルです。ちゃんとつながっていますね。
「メッセージ」タブを開いた状態で、AIエージェントに「Webサイトのメインタイトルの文字色だけを緑色に変更してください」と指示してみると、まず接続完了のメッセージが来て、
!image.png
その後、こんな更新通知が届きました(主要項目を抜粋)。
{
"type": "update",
"updates": [
{
"type": "css-update",
"path": "/src/style.css?direct",
"acceptedPath": "/src/style.css?direct",
"timestamp": 1793593126711
}
]
}
css-updateは、ViteがCSSの変更を検知してブラウザへ送る更新通知です(ページ全体を再読み込みするfull-reloadとは別物)。
flowchart TD
A["ユーザーが日本語で変更を指示"] --> B["AIエージェントがCSSを編集"]
B --> C["Viteが変更を検知"]
C --> D["WebSocketでcss-updateを送信"]
D --> E["Session endpointsが通信を転送"]
E --> F["ローカルプロキシ経由でブラウザが受信"]
ファイル変更の検知と更新通知はVite側の仕事で、Session endpointsは
そのWebSocket通信を転送する役割を担っています。そのおかげでAIがCSSを編集した直後に
ブラウザ側へちゃんと通知が届くところまで確認できました
(今回確認できたのはCSSの更新だけなので、HTMLやJSの変更も必ず再読み込みなしで反映される、と断定はできません)。
④プレビューを止めるとどうなるか
PreviewAppをCtrl + Cで止めると、ローカルのプレビュー画面にはアクセスできなくなりました。新しいセッションを起動しても、前の編集内容は引き継がれません。
(あくまでローカルのPreviewAppとプロキシを止めた結果の観察です。AWS側のセッションがその瞬間に終了したかどうかまでは確認していません。)
ハマったポイント
モデルIDを後から変更するとSession endpointが取得できなくなった
手順どおりに進めるだけなら踏まなくて済む話ですが、
せっかくハマったので記録しておきます。
今回、モデルIDを変更する際に地味にハマりました。
最初はデフォルトのモデルIDでデプロイしてしまったので、
Runtimeの環境変数を後から更新しました。モデルIDの変更自体は無事成功しました。
{
"Status": "READY",
"Environment": {
"MODEL_ID": "jp.anthropic.claude-haiku-5-5"
}
}
ところが、その後プレビューを起動すると、次のエラーが出ました。
AttributeError:
'NoneType' object has no attribute 'rstrip'
エラーが発生した箇所はこちら。
self.endpoint = self.client.session_endpoint_client(
session_endpoint_url(answer)
)
つまり、Session endpointのURLが取れていなかったわけです。
調べてみると、
今回のRuntime更新処理では既存の設定を取得して更新APIにそのまま渡していたのですが、
get_agent_runtime()のレスポンスにはallowedPortsが含まれておらず、
更新リクエストでもallowedPortsを指定していませんでした。
更新のタイミングでSession endpoints用のポート設定が引き継がれずに消えてしまうのかもしれません(勘ですがallowedPortsを明示して再更新したら直ったので…)。
Boto3からupdate_agent_runtime()を呼び出し、必要なポートを明示的に指定したところ、解消しました。
import boto3
client = boto3.client(
"bedrock-agentcore-control",
region_name="ap-northeast-1"
)
runtime_id = "YOUR_RUNTIME_ID"
current = client.get_agent_runtime(
agentRuntimeId=runtime_id
)
params = {
"agentRuntimeId": runtime_id,
"agentRuntimeArtifact": current["agentRuntimeArtifact"],
"roleArn": current["roleArn"],
"networkConfiguration": current["networkConfiguration"],
"protocolConfiguration": current["protocolConfiguration"],
"lifecycleConfiguration": current["lifecycleConfiguration"],
"environmentVariables": {
**current.get("environmentVariables", {}),
"MODEL_ID": "jp.anthropic.claude-haiku-5-5"
},
"allowedPorts": [
{"port": 8090},
{"port": 8081}
]
}
result = client.update_agent_runtime(**params)
print(result["status"])
これでRuntimeが新しいバージョンに更新され、
再度PreviewAppを起動したら正常にSession endpointsが使えるようになりました。
使いどころと注意点
嬉しかったポイント
今回触ってみていいなと感じたのは、
1. AIエージェントが作ったWebアプリの変更を、その実行環境のまま確認できる
1. AIエージェントとWebアプリ(成果物)を同期する仕組みを別途用意しなくてよい
1. Webサイトに限らず管理画面や開発用APIなどSession endpointsが使える対象なら同じ仕組みを応用できる
1. セッション単位でアクセス先が分離されるため複数ユーザーが同時に使う構成にも展開できそう
使う際の注意点
今回触ってみて気をつけたいと感じたのは、
1. Session endpointsはあくまで開発中の確認用で、Webサイトを一般公開するホスティングとは別物という点(公開するならAmplify Hostingなどが必要)
1. AIエージェントが編集したファイルはRuntimeのセッション内にあるため、セッション終了や実行環境の置き換えで失われうる点(長期保存したいならS3などへの保存を検討)
1. AgentCore RuntimeやBedrockの呼び出しには料金が発生するため、検証後は不要なRuntimeやリソースを削除する必要がある
まとめ
今回は、Amazon Bedrock AgentCore RuntimeのSession endpointsを使って、
AIエージェントが開発しているWebアプリをリアルタイムにプレビューする機能を触ってみました。
変更指示、コード編集、ブラウザへ反映までを一連の流れで確認できました。
AIエージェントはコードの生成だけでなく、構築・実行・テストへと担当領域を
どんどん広げているので、こういう仕組みは重要になっていきそうです。
個人的には、PoC環境の構築に使えそうだなと感じました。
参考資料
- AWS公式ブログ:Preview web apps live as your coding agent builds them with Amazon Bedrock AgentCore runtime
- AWS公式GitHub:agentcore-samples
- 今回使用したサンプル:16-session-endpoints
- Amazon Bedrock AgentCore ドキュメント
1 like
> /aidlc-session-cost and /aidlc-replay print to the terminal and write nothing; /aidlc-outcomes-pack is the only one that writes a file (OUTCOMES.md at the workspace root).
github.com/awslabs/aidlc-workflows/blob/main/docs…
3 likes · 96 views
SOCI lazy loading: start Lambda containers faster
SOCI lazy loading lets a Lambda function on Lambda Managed Instances start before its container image has been fully downloaded. AWS announced it on October 28, 2026, and quotes startup times that are up to 70 percent shorter. I care less about that percentage than about two questions: who actually benefits from the extra build step, and where does it cause you trouble?
Container images are often the escape hatch on Lambda for anything that does not fit into a 250 MB ZIP package: ML libraries, models, PDF tools, entire runtimes. Those images quickly grow to several gigabytes. In this article, we build an example from scratch together: a small book recommendation service for a library, an image with four language models, the SOCI index, a CDK stack with a capacity provider and a script that lets you measure the effect yourself.
> In short
>
> SOCI places a table of contents next to your container image, so Lambda on Managed Instances only fetches the files your function actually reads. It pays off most for large images where each function needs only part of the content. The biggest pitfall is the format: Lambda accepts only a SOCI index v2 inside an image index with exactly two entries, and only single-architecture images.
What you will learn in this article
- What Lambda Managed Instances are and when they fit your workload
- How SOCI lazy loading fetches files on demand
- How to create a SOCI index with nerdctl and push it to ECR
- How to set up a capacity provider and function with AWS CDK in TypeScript
- How to measure startup times with and without SOCI yourself
What are Lambda Managed Instances?
Lambda Managed Instances (LMI for short) are a second way to run Lambda functions. Instead of the shared Lambda fleet, your functions run on EC2 instances in your own account. Lambda still takes care of launching, patching, routing and scaling those instances. You only decide where they may run and on which hardware.
The central building block is the capacity provider. It describes the VPC, subnets, security groups, architecture, allowed instance types and an upper limit for vCPUs. You then attach a function to that provider and publish a version. Only when you publish does Lambda launch instances, three by default for spreading across Availability Zones, plus three execution environments, before the version counts as ACTIVE.
How it differs from regular Lambda
| | Lambda (default) | Lambda Managed Instances |
| --- | --- | --- |
| Invocations per environment | one at a time | several at the same time |
| Isolation | Firecracker microVMs on a shared fleet | containers on EC2 Nitro instances in your account |
| Billing | per request and duration | EC2 price plus a 15% management fee |
| Scaling | on demand, down to zero | asynchronously based on CPU utilization, not to zero |
| Good fit for | irregular traffic | high, predictable traffic |
For Node.js, LMI uses worker threads, and initialization runs once per worker thread. That matters for our example: if you load a large model into memory, expect it to sit in memory once per thread. In addition, concurrent requests in the same thread share the global state. Anything you create outside the handler should therefore only be read and never modified.
If you want to refresh the regular Lambda basics first, my article Deploy a Lambda function with CDK and TypeScript covers the standard path with a ZIP package.
Why large images slow down Managed Instances
Without SOCI, Lambda has to download and unpack the complete image on every instance before an execution environment can start there. That does not happen just once when you publish. It also happens whenever the capacity provider launches new instances because load is rising. With a 5 GB image, you are mostly waiting on network and disk during this phase.
The catch: a large part of that data is often never read. An image with PyTorch, several models and half an operating system contains far more than a single invocation needs. This is where SOCI lazy loading helps.
How SOCI lazy loading works
SOCI stands for Seekable OCI. OCI is the open standard for container images. The project comes from AWS and is open source under Apache-2.0. SOCI does not change your image. Instead, it creates an additional index that records for each file in which layer and at which position it is stored. With this table of contents, Lambda can fetch individual files from ECR instead of downloading everything in one go.
On Lambda Managed Instances, the flow looks like this: you push the image and the SOCI index together to ECR under a separate tag. That tag points to an image index with two entries, the regular image manifest and the SOCI index. If your function points to this tag, Lambda detects the index when you publish the version and turns on lazy loading. There are no further settings.
> 🎬 Startup with and without SOCI – this animated diagram only runs on my site: watch it on sascha-ms.de
When the files are fetched
Files your init code reads are fetched by Lambda during initialization. Anything the handler touches only later gets pulled in during that invocation, which then takes correspondingly longer. According to the documentation, Lambda does not download the rest of the image in the background. That gives you the most important rule for your code: import every library and read every file you need in the init phase. Then your invocation durations stay consistent.
!Diagram: five steps from an arm64 image via nerdctl to a SOCI index and a Lambda function using tag v1-soci
From a plain image to an image index with a SOCI index: five steps to the function.
Prerequisites for SOCI lazy loading
- An AWS account in a Region where Lambda Managed Instances are available, and AWS CLI v2
- Node.js 22 or later, Docker with buildx and AWS CDK v2 (here aws-cdk-lib 2.275.0)
- For the SOCI step, a Linux machine with containerd, for example a small EC2 instance with Amazon Linux 2023 and a Graviton processor
- A function packaged as a container image for one architecture, here arm64
- The jq command line tool and basic knowledge of TypeScript and CDK
A quick glossary: ECR is the AWS container registry. nerdctl is a Docker-like command line tool for containerd, and the SOCI snapshotter is the containerd extension that can create and read SOCI indexes.
Setting up SOCI lazy loading step by step
Our example is a recommendation service for a library. For every book ID, it returns the five most similar books. It computes the similarity from vectors stored in one binary file per language. The image contains models for German, English, French and Spanish, but the function reads only one of them. I chose this on purpose, because exactly this pattern shows up in real images all the time.
Step 1: Create the project
mkdir lmi-soci-library && cd lmi-soci-library
mkdir -p src scripts lib bin test
npm init -y
Replace the generated package.json with this version. "type": "module" makes Node.js load the compiled files as ES modules, including inside the container.
{
"name": "lmi-soci-library",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"build": "tsc -p tsconfig.build.json",
"models": "node --experimental-strip-types scripts/make-models.ts",
"test": "node --experimental-strip-types --test test/*.test.ts",
"measure": "node --experimental-strip-types scripts/measure-start.ts"
},
"dependencies": {},
"devDependencies": {
"@aws-sdk/client-lambda": "3.1159.0",
"@types/node": "^22.19.0",
"aws-cdk": "2.1146.1",
"aws-cdk-lib": "2.275.0",
"constructs": "^10.4.2",
"typescript": "7.0.2"
}
}
You run the scripts under scripts/ and the tests directly from TypeScript, using the Node.js 22 option --experimental-strip-types. For the container, on the other hand, you compile to dist/. Two small configuration files handle this:
// tsconfig.json
{
"compilerOptions": {
"target": "es2023",
"module": "nodenext",
"moduleResolution": "nodenext",
"strict": true,
"allowImportingTsExtensions": true,
"rewriteRelativeImportExtensions": true,
"skipLibCheck": true,
"types": ["node"],
"outDir": "dist"
},
"include": ["src", "scripts", "lib", "bin", "test"]
}
// tsconfig.build.json
{
"extends": "./tsconfig.json",
"compilerOptions": { "rootDir": "src", "outDir": "dist" },
"include": ["src"]
}
npm install
Step 2: Load and compare vectors
The module reads a file in the format “magic bytes, count, dimensions, values” and compares books using cosine similarity. Cosine similarity measures how closely two vectors point in the same direction: 1 means very similar, 0 means unrelated.
// src/vectors.ts
import { readFileSync } from 'node:fs';
export interface BookVectors {
count: number;
dims: number;
data: Float32Array;
}
const MAGIC = 'BKV1';
const HEADER_BYTES = 12;
/** Writes vectors in a simple binary format: "BKV1", count, dims, float32 values. */
export function encodeVectors(v: BookVectors): Buffer {
const header = Buffer.alloc(HEADER_BYTES);
header.write(MAGIC, 0, 'ascii');
header.writeUInt32LE(v.count, 4);
header.writeUInt32LE(v.dims, 8);
const body = Buffer.from(v.data.buffer, v.data.byteOffset, v.data.byteLength);
return Buffer.concat([header, body]);
}
export function decodeVectors(buf: Buffer): BookVectors {
if (buf.subarray(0, 4).toString('ascii') !== MAGIC) {
throw new Error('Unknown file format: magic bytes missing');
}
const count = buf.readUInt32LE(4);
const dims = buf.readUInt32LE(8);
const expected = HEADER_BYTES + count * dims * 4;
if (buf.byteLength !== expected) {
throw new Error(`File size ${buf.byteLength} does not match ${expected} bytes`);
}
// Copy into an aligned buffer so Float32Array works on every offset
const data = new Float32Array(count * dims);
data.set(new Float32Array(buf.buffer.slice(buf.byteOffset + HEADER_BYTES, buf.byteOffset + expected)));
return { count, dims, data };
}
export function loadVectors(path: string): BookVectors {
return decodeVectors(readFileSync(path));
}
function cosine(v: BookVectors, a: number, b: number): number {
let dot = 0;
let na = 0;
let nb = 0;
const oa = a * v.dims;
const ob = b * v.dims;
for (let i = 0; i < v.dims; i++) {
const x = v.data[oa + i];
const y = v.data[ob + i];
dot += x * y;
na += x * x;
nb += y * y;
}
return na === 0 || nb === 0 ? 0 : dot / Math.sqrt(na * nb);
}
export interface Recommendation {
bookId: number;
score: number;
}
/** Returns the books most similar to bookId (without bookId itself). */
export function similarBooks(v: BookVectors, bookId: number, limit = 5): Recommendation[] {
if (!Number.isInteger(bookId) || bookId < 0 || bookId >= v.count) {
throw new RangeError(`bookId must be between 0 and ${v.count - 1}`);
}
const best: Recommendation[] = [];
for (let other = 0; other < v.count; other++) {
if (other === bookId) continue;
const score = cosine(v, bookId, other);
if (best.length < limit || score > best[best.length - 1].score) {
best.push({ bookId: other, score });
best.sort((x, y) => y.score - x.score);
if (best.length > limit) best.pop();
}
}
return best.map((r) => ({ bookId: r.bookId, score: Math.round(r.score * 1000) / 1000 }));
}
Step 3: A handler that loads in the init phase
The handler loads the model at the top level, which means during initialization. With SOCI, this is the moment Lambda fetches the file from ECR. If the call to loadVectors lived inside the handler, the first invocation of every environment would take noticeably longer.
// src/handler.ts
import { loadVectors, similarBooks, type Recommendation } from './vectors.ts';
// Load in the init phase: with SOCI, Lambda fetches the file here
// and not during the first invocation.
const MODEL_FILE = process.env.MODEL_FILE ?? '/opt/models/books-de.bin';
const started = Date.now();
const vectors = loadVectors(MODEL_FILE);
console.log(JSON.stringify({ msg: 'model loaded', file: MODEL_FILE, books: vectors.count, ms: Date.now() - started }));
export interface RecommendEvent {
bookId: number;
limit?: number;
}
export interface RecommendResult {
model: string;
recommendations: Recommendation[];
}
// Read-only shared state: safe even when an execution environment
// handles several invocations at the same time.
export async function handler(event: RecommendEvent): Promise<RecommendResult> {
const limit = Math.min(Math.max(event.limit ?? 5, 1), 20);
return { model: MODEL_FILE, recommendations: similarBooks(vectors, event.bookId, limit) };
}
Step 4: Generate test data and check locally
The script creates four model files with random values. With BOOKS=200000, every file grows to roughly 100 MB, and the difference at startup becomes clearly visible. Small files are enough for the first test.
// scripts/make-models.ts
// Generates one vector file per language. Only one of them is used per function –
// exactly the situation in which SOCI lazy loading pays off.
import { mkdirSync, writeFileSync } from 'node:fs';
import { encodeVectors } from '../src/vectors.ts';
const count = Number(process.env.BOOKS ?? 20000);
const dims = Number(process.env.DIMS ?? 128);
const languages = (process.env.LANGS ?? 'de,en,fr,es').split(',');
mkdirSync('models', { recursive: true });
let seed = 42;
const random = () => {
seed = (seed * 1664525 + 1013904223) % 2 ** 32;
return seed / 2 ** 32 - 0.5;
};
for (const lang of languages) {
const data = new Float32Array(count * dims);
for (let i = 0; i < data.length; i++) data[i] = random();
const file = `models/books-${lang}.bin`;
const buf = encodeVectors({ count, dims, data });
writeFileSync(file, buf);
console.log(`${file}: ${(buf.byteLength / 1024 / 1024).toFixed(1)} MB`);
}
BOOKS=5000 npm run models
npm test
npm run build
MODEL_FILE=models/books-de.bin node -e "import('./dist/handler.js').then(async (m) => console.log(await m.handler({ bookId: 7, limit: 3 })))"
Expected result: four files of 2.4 MB each, four passing tests and an output with three recommendations. Book IDs and scores are random; the structure looks like this:
{"msg":"model loaded","file":"models/books-de.bin","books":5000,"ms":4}
{
model: 'models/books-de.bin',
recommendations: [
{ bookId: 37, score: 0.337 },
{ bookId: 1965, score: 0.311 },
{ bookId: 3426, score: 0.274 }
]
}
Step 5: Build the image for arm64 and push it to ECR
The Dockerfile uses the official Lambda base image for Node.js 24. The models end up in /opt/models.
# Dockerfile
FROM public.ecr.aws/lambda/nodejs:24
# Compiled code plus package.json (for "type": "module")
COPY package.json ${LAMBDA_TASK_ROOT}/
COPY dist/ ${LAMBDA_TASK_ROOT}/
# All language models – each function only reads one of them
COPY models/ /opt/models/
CMD ["handler.handler"]
Two build options matter here. --platform linux/arm64, because Lambda accepts only single-architecture images. And --provenance=false, because buildx would otherwise attach an attestation to the image and turn it into an image index with several entries. For the SOCI step, you want a plain image without such extras.
BOOKS=200000 npm run models && npm run build
ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
REGION=eu-central-1
REPO=library-recommender
REGISTRY=$ACCOUNT_ID.dkr.ecr.$REGION.amazonaws.com
aws ecr create-repository --repository-name $REPO --region $REGION
aws ecr get-login-password --region $REGION | docker login -u AWS --password-stdin $REGISTRY
docker buildx build --platform linux/arm64 --provenance=false --sbom=false \
-t $REGISTRY/$REPO:v1 --push .
Step 6: Create the SOCI index
You run this step on a Linux machine with containerd. The easiest option is a small Graviton instance with Amazon Linux 2023, because its architecture already matches the image. The instance needs a role with read and write access to the ECR repository.
sudo yum install -y soci-snapshotter containerd nerdctl
sudo systemctl start soci-snapshotter
sudo systemctl restart containerd
ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
REGION=eu-central-1
REPO=library-recommender
REGISTRY=$ACCOUNT_ID.dkr.ecr.$REGION.amazonaws.com
aws ecr get-login-password --region $REGION | sudo nerdctl login -u AWS --password-stdin $REGISTRY
sudo nerdctl pull --platform linux/arm64 $REGISTRY/$REPO:v1
sudo nerdctl image convert --soci --platform linux/arm64 $REGISTRY/$REPO:v1 $REGISTRY/$REPO:v1-soci
sudo nerdctl push --platform linux/arm64 $REGISTRY/$REPO:v1-soci
nerdctl image convert --soci produces an index in format v2, and that is the only format Lambda accepts. When you follow older guides on the web, check which format they produce: Lambda does not support indexes in format v1.
Step 7: Check the result in ECR
Before you touch the function, take a look at the new tag. The manifest behind v1-soci must have exactly two entries, and one of them must be the SOCI index.
aws ecr batch-get-image --repository-name $REPO --region $REGION \
--image-ids imageTag=v1-soci \
--accepted-media-types application/vnd.oci.image.index.v1+json \
--query 'images[0].imageManifest' --output text | jq '.manifests | length, [.[] | .artifactType // .mediaType]'
Expected result: the first line shows 2, and the list contains an entry with soci in its name. If you see a higher number, attestations or additional platforms are still attached to the image. In that case, rebuild the image as in step 5.
Step 8: Capacity provider and function with CDK
Since version 2.235.0, aws-cdk-lib has a dedicated CapacityProvider construct. If you do not pass your own, it also creates the operator role with the managed policy AWSLambdaManagedEC2ResourceOperator. You attach a function to the provider with addFunction. The function is then published automatically as $LATEST.PUBLISHED, so you do not have to create a version by hand.
// lib/library-stack.ts
import { CfnOutput, Duration, Stack, type StackProps } from 'aws-cdk-lib';
import * as ec2 from 'aws-cdk-lib/aws-ec2';
import * as ecr from 'aws-cdk-lib/aws-ecr';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import type { Construct } from 'constructs';
export interface LibraryStackProps extends StackProps {
repositoryName: string;
/** Tag of the SOCI-indexed image, e.g. "v1-soci" */
imageTag: string;
/** Optional: original tag (e.g. "v1") for a comparison function without SOCI */
plainImageTag?: string;
}
export class LibraryStack extends Stack {
constructor(scope: Construct, id: string, props: LibraryStackProps) {
super(scope, id, props);
// Private subnets with a NAT gateway so the instances can reach ECR and CloudWatch
const vpc = new ec2.Vpc(this, 'LibraryVpc', { maxAzs: 3, natGateways: 1 });
const sg = new ec2.SecurityGroup(this, 'InstancesSg', { vpc, allowAllOutbound: true });
const capacity = new lambda.CapacityProvider(this, 'LibraryCapacity', {
subnets: vpc.privateSubnets,
securityGroups: [sg],
architectures: [lambda.Architecture.ARM_64],
maxVCpuCount: 12,
});
const repo = ecr.Repository.fromRepositoryName(this, 'Repo', props.repositoryName);
const addRecommender = (id: string, tag: string) => {
const fn = new lambda.DockerImageFunction(this, id, {
code: lambda.DockerImageCode.fromEcr(repo, { tagOrDigest: tag }),
architecture: lambda.Architecture.ARM_64,
memorySize: 4096,
timeout: Duration.seconds(30),
environment: { MODEL_FILE: '/opt/models/books-de.bin' },
});
capacity.addFunction(fn, {
perExecutionEnvironmentMaxConcurrency: 8,
latestPublishedScalingConfig: { minExecutionEnvironments: 3, maxExecutionEnvironments: 6 },
});
new CfnOutput(this, `${id}Name`, { value: fn.functionName });
return fn;
};
addRecommender('Recommender', props.imageTag);
if (props.plainImageTag) {
addRecommender('RecommenderPlain', props.plainImageTag);
}
}
}
// bin/app.ts
import { App } from 'aws-cdk-lib';
import { LibraryStack } from '../lib/library-stack.ts';
const app = new App();
new LibraryStack(app, 'LibraryRecommender', {
repositoryName: app.node.tryGetContext('repo') ?? 'library-recommender',
imageTag: app.node.tryGetContext('tag') ?? 'v1-soci',
plainImageTag: app.node.tryGetContext('plainTag'),
env: { account: process.env.CDK_DEFAULT_ACCOUNT, region: process.env.CDK_DEFAULT_REGION },
});
// cdk.json
{
"app": "node --experimental-strip-types bin/app.ts"
}
A few values in the stack are deliberate: according to CDK, maxVCpuCount must be between 12 and 15,000. perExecutionEnvironmentMaxConcurrency: 8 allows eight concurrent invocations per environment, which is rather generous for our compute-heavy handler. For pure CPU work, more concurrency per vCPU does not help much; more memory, and with it more vCPUs per function, helps more.
npx cdk bootstrap
npx cdk deploy -c repo=library-recommender -c tag=v1-soci -c plainTag=v1
With -c plainTag=v1, the stack creates a second function that points to the image without a SOCI index. You need it for the comparison in the next step. The deployment takes a few minutes, because Lambda launches the instances.
Step 9: Measure startup time with and without SOCI
The script publishes a new version for each function and measures the time until it is Active. It is a rough but honest measurement: it includes pulling the image and initializing the environments.
// scripts/measure-start.ts
// Publishes a new version per function and measures the time until it is "Active".
// Usage: npm run measure -- recommender-plain recommender-soci
import {
GetFunctionConfigurationCommand,
LambdaClient,
PublishVersionCommand,
} from '@aws-sdk/client-lambda';
import { setTimeout as sleep } from 'node:timers/promises';
const client = new LambdaClient({});
async function timeToActive(functionName: string): Promise<number> {
const start = Date.now();
const { Version } = await client.send(new PublishVersionCommand({ FunctionName: functionName }));
for (;;) {
const cfg = await client.send(
new GetFunctionConfigurationCommand({ FunctionName: functionName, Qualifier: Version }),
);
if (cfg.State === 'Active') return (Date.now() - start) / 1000;
if (cfg.State === 'Failed') throw new Error(`${functionName}:${Version} failed: ${cfg.StateReason}`);
await sleep(5000);
}
}
const names = process.argv.slice(2);
if (names.length === 0) {
console.error('Please pass at least one function name');
process.exit(1);
}
for (const name of names) {
const seconds = await timeToActive(name);
console.log(`${name}: active after ${seconds.toFixed(0)} s`);
}
npm run measure -- <name of the plain function> <name of the SOCI function>
You will find the function names in the outputs of cdk deploy. Measure several times and compare the averages, because network and placement vary. It is also worth checking CloudWatch Logs for the model loaded line and the duration of each first invocation. If you measure numbers with your own image, I would love to read about them in the comments.
Common mistakes with SOCI lazy loading
- The function points to the old tag. The image URI must point to v1-soci, not to v1. Otherwise everything works, just without lazy loading.
- Index in the wrong format. Lambda supports only SOCI Index Manifest v2. Check for the media type application/vnd.amazon.soci.index.v2+json.
- Too many entries in the image index. Attestations, SBOMs or a multi-arch image lead to more than two entries in the index.
- Files read only in the handler. Then the first invocation of every environment pays the loading time, and your latency metrics get outliers.
- Mismatched architecture. Capacity provider, function and image must share the same architecture, here arm64 everywhere.
- New image, old index. Every change to the image needs a new SOCI tag, for example v2-soci, and an updated image URI.
Costs and security
The announcement does not mention a separate price for SOCI itself. The index is stored as an additional artifact in ECR and is small compared to the image. The real costs come from Lambda Managed Instances: you pay the EC2 price of the instances plus a 15 percent management fee. Savings Plans and Reserved Instances apply only to the EC2 part. In this example, you also pay for a NAT gateway and the EC2 instance used for the SOCI step. Clean up after testing:
npx cdk destroy
aws ecr delete-repository --repository-name library-recommender --region eu-central-1 --force
On the security side, the capacity provider is the security boundary. Functions that share a provider run as containers on the same instances. Separate functions with different protection needs by giving them their own capacity providers. The operator role needs only the managed policy, and the role of the build instance needs access to just the one repository.
My take
SOCI lazy loading solves a real problem, but a narrow one. It helps only container image functions on Lambda Managed Instances, not the classic Lambda most of us use every day. If you run large images there, an extra build step gets you faster starts when publishing and when scaling out. That is mainly interesting for ML workloads, where an image ships many models or libraries.
Where I am cautious: the build process gets more complicated. You need containerd and nerdctl, a Linux machine with the matching architecture and a second tagging scheme. That belongs in the CI pipeline, not on someone’s laptop. And lazy loading is no substitute for lean images. If your function ends up reading almost everything anyway, you gain little. My advice: clean up the image first, then measure, then add SOCI. By the way, for Java functions on regular Lambda, SnapStart remains the tool of choice for startup times.
FAQ
Does SOCI lazy loading work with regular Lambda?
No. As of late October 2026, Lambda supports SOCI only for functions that are packaged as container images and run on Lambda Managed Instances.
Do I have to change my function code for SOCI?
Not necessarily. You should, however, load all libraries and files in the init phase, so the first invocation of an environment is not slowed down by on-demand fetching.
Can I create the SOCI index with Docker?
The AWS documentation shows the path with containerd, nerdctl and the SOCI snapshotter. Docker alone does not create a SOCI index, so you need an environment with containerd for this step.
How much faster does my function start with SOCI?
AWS quotes startup times that are up to 70 percent shorter. How much you gain depends on which share of the image is actually read. Measure it with and without the index.
Your next step
Take your largest container image and find out which share of it is really read at startup. If it is less than half, and you are considering Lambda Managed Instances anyway, SOCI is a good candidate for an experiment. What makes your Lambda images so large: models, libraries or something else entirely?
Sources
- AWS What’s New: AWS Lambda supports SOCI lazy loading for Lambda Managed Instances (Oct 28, 2026)
- AWS docs: Lazy-load container images with Seekable OCI (SOCI)
- AWS docs: Lambda Managed Instances
- AWS docs: Getting started with Lambda Managed Instances
- AWS docs: Node.js runtime for Lambda Managed Instances
- AWS docs: Lambda Managed Instances runtimes
- AWS Compute Blog: Build high-performance apps with AWS Lambda Managed Instances
- GitHub: awslabs/soci-snapshotter
- AWS CDK API Reference: aws_lambda.CapacityProvider
This post was created with AI assistance.
---
👉 Next step: Curious what your own Lambda functions cost? Try my Lambda cost calculator – it runs right in your browser.
Originally published on sascha-ms.de. More articles on serverless, AI in everyday development and leading tech teams: sascha-ms.de
1 reaction
Eyes, Not Hands: Adding AWS DevOps Agent, Bedrock AgentCore and Kiro to a GitOps Platform on EKS
__*Level 300* — Advanced | Amazon EKS, Argo CD, AWS DevOps Agent, Amazon Bedrock AgentCore, Kiro CLI, Amazon EventBridge
🎯 The Challenge
Scenario
Suppose you are the Platform Engineering lead of an enterprise that already runs Kubernetes the GitOps way: an Amazon EKS hub cluster with Argo CD manages a fleet of spoke clusters, and every team onboards its workloads through a single values file in Git. It works. Deployments are boring, in a good way.
Operations are not. On-call engineers keep getting paged for the same kind of incidents: a bad config is merged, pods start crash-looping, and someone spends 40 minutes reading logs and events before realizing the fix is "revert that commit". Then leadership asks the question every platform team is hearing:
> "Can AI agents take the first part of on-call?"
Your mission: add agentic investigation and remediation to the existing platform, under these constraints:
- ✅ No agent gets write access to production. Not to the cluster, not to the AWS account.
- ✅ Every fix is a reviewable pull request. Git stays the single source of truth; Argo CD stays the only thing that changes the cluster.
- ✅ Don't rebuild the platform. The hub-spoke GitOps setup stays as it is; agents are added on top.
- ✅ Multi-account by design. Workloads and CI/CD live in different AWS accounts.
- ✅ Everything is observable and auditable. Every agent action leaves a trace.
> This post is the architecture behind the TechLabVel video, Cap 2.
{% embed youtube.com %}
My Approach: Eyes, Not Hands
The fastest demo is always the same: connect an LLM to the cluster with an admin kubeconfig and let it "fix things". I've seen it in many conference talks, and it's also the one setup I would never take to a client's production. A model can be wrong, a prompt can be injected, a credential can leak. With write access, each of those becomes an outage.
So I split the problem the way with one load-bearing rule:
> Reasoning is decoupled from execution. The agents can read and propose anything, but can mutate nothing directly. Every production change routes through a separate control plane: a GitOps pull request.
The agent gets eyes (logs, events, metrics, code) but no hands. GitOps is the hands, and a human holds them. I also decided not to build a new platform for this: everything is added on top of the hub-spoke GitOps setup I already had.
The Architecture
!Agentic Operations on EKS GitOps Platform
The six numbered steps in the diagram match the list below. Agents (teal) only read; the only write path is the pull request (green) that Argo CD pulls (orange).
1. Detect: a CloudWatch alarm on a Container Insights metric fires when a pod is OOMKilled.
2. Wake up the agent: EventBridge invokes a small Lambda that sends a signed webhook to the AWS DevOps Agent.
3. Investigate: the agent reads Kubernetes state through a read-only MCP server hosted on Amazon Bedrock AgentCore, plus CloudWatch, CloudTrail and the GitHub repos.
4. Hand off: the alarm event is forwarded cross-account to the DevSecOps account.
5. Propose: CodeBuild runs Kiro CLI headless; it edits the GitOps repo and opens a pull request.
6. Approve and apply: a human merges; Argo CD reconciles the spoke cluster.
| Account | What lives there | Why |
|---------|------------------|-----|
| Workload account | EKS hub (Argo CD) + EKS spoke (workloads), CloudWatch, AWS DevOps Agent space, AgentCore Runtime + Gateway | The agent investigates where the signals are |
| DevSecOps account | GitHub runners, the Kiro remediation pipeline (EventBridge bus, Lambdas, SQS, CodeBuild) | The only component that writes code is isolated from the workloads |
| GitHub | gitops (platform + team values), petadoptions (app manifests) | Git is the source of truth and the approval gate |
The Starting Point: GitOps Hub-Spoke
This is the platform from the GitOps and IaC at Scale series. Argo CD runs on the hub and registers each spoke as a cluster secret. Labels on that secret decide which add-ons each cluster gets, through ApplicationSets:
kubectl --context hub -n argocd get secrets -l argocd.argoproj.io/secret-type=cluster -o json \
| jq -r '.items[] | "\(.data.name | @base64d)\t\(.metadata.labels.environment)"'
gitops-spoke-eks-dev dev
in-cluster control-plane
Teams onboard with one file in the platform repo. The workloads entry tells Argo CD which repo and which Kustomize overlay to deploy to the spoke:
# gitops/platform/teams/petadoptions/argoproject-values.yaml (excerpt)
workloads:
- name: app
repoURL: https://github.com/<org>/petadoptions
targetRevision: main
path: k8s/overlays/dev
namespace: petadoptions
Keep this file in mind.
Hands On
Prerequisites
| Tool / component | Version used |
|------------------|--------------|
| Amazon EKS (hub / spoke) | 1.37 / 1.36 |
| OpenTofu + Terragrunt | 1.1.3 / 1.13.2 versions |
| Terraform AWS provider | 6.69.1 |
| awslabs.eks-mcp-server | 0.2.1 |
| Kiro CLI | latest at build time (TODO(you): pin it, see review) |
| Region | us-east-2 |
Deploy order for the agent side: S3 (evaluation bucket) → ECR → IAM roles → MCP server (AgentCore) → Agent Space → tool allowlist. Build and push the arm64 MCP image before the MCP server stack.
Step 1: The Alarm (A Signal That Actually Fires)
The first version of the alarm watched a custom SLO burn-rate metric. Nothing ever published it, so the alarm could never fire. The lesson came early: an agent is only as good as the signal that wakes it up. The final alarm uses a metric that the CloudWatch Observability add-on (Container Insights) already publishes for the spoke:
# environments/dev/observability.tfvars (excerpt)
alarm_name = "petsearch-oomkilled"
metric_namespace = "ContainerInsights"
metric_name = "pod_container_status_terminated_reason_oom_killed"
statistic = "Sum"
threshold = 0 # any OOMKill in the period trips the alarm
period = 300
evaluation_periods = 1
comparison_operator = "GreaterThanThreshold"
treat_missing_data = "notBreaching" # no OOMKills reported = healthy
dimensions = {
ClusterName = "<spoke-cluster>"
Namespace = "petadoptions"
PodName = "petsearch"
}
Step 2: The Trigger (Why a Lambda Signs the Webhook)
EventBridge can't call the AWS DevOps Agent webhook directly: the generic webhook requires an HMAC signature over the timestamp and payload, and EventBridge can't compute it. A small Lambda (about 100 lines) does:
# trigger/src/handler.py (excerpt)
def _sign(timestamp: str, body: bytes, secret: str) -> str:
# AWS DevOps Agent HMAC: base64(HMAC-SHA256(secret, "{timestamp}:{payload}")).
# The timestamp is part of the signed material (replay protection) and the
# digest is base64 — NOT a bare hex of the body.
mac = hmac.new(
secret.encode("utf-8"),
f"{timestamp}:".encode("utf-8") + body,
hashlib.sha256,
)
return base64.b64encode(mac.digest()).decode("ascii")
The webhook URL and secret live in AWS Secrets Manager. The timestamp is part of the signed material, which protects against replays.
Step 3: The MCP Server on AgentCore (Eyes, Not Hands)
The AWS DevOps Agent needs to see inside the cluster: pods, events, logs. Instead of writing our own tools, we wrap the official awslabs.eks-mcp-server (pinned to 0.2.1) and host it on Amazon Bedrock AgentCore Runtime. AWS owns the tool logic; we own the posture.
The posture is read-only, enforced three times inside the server:
# src/mcp-server/server.py (excerpt)
# We NEVER enable write.
ALLOW_WRITE = False
# Defense in depth: refuse to start rather than silently expose mutating tools.
if os.environ.get("READ_ONLY", "true").lower() not in ("1", "true", "yes"):
raise SystemExit("EKS MCP host is read-only by design; set READ_ONLY=true.")
# Remove mutating tools from the registry, so the agent never even SEES them.
_WRITE_TOOLS = {
"manage_k8s_resource", # create/replace/patch/delete K8s objects
"apply_yaml", # apply manifests
"generate_app_manifest", # writes a manifest file
"add_inline_policy", # mutates IAM
"manage_eks_stacks", # CloudFormation create/update
}
Read-only is enforced at the listing layer, not only at call time: a tool the model never sees is a tool it can't be tricked into calling.
In front of the runtime sits an AgentCore Gateway with IAM (SigV4) authentication and semantic tool search:
# modules/agentcore-mcp/main.tf (simplified: count, JWT option and tags removed)
resource "aws_bedrockagentcore_gateway" "mcp" {
name = var.gateway_name
role_arn = var.execution_role_arn
authorizer_type = "AWS_IAM" # SigV4 — no shared secrets
protocol_type = "MCP"
protocol_configuration {
mcp {
instructions = "Read-only ops/K8s tools. Mutation is not exposed; actuation routes through the control plane."
search_type = "SEMANTIC"
}
}
}
Finally, the Agent Space only gets an allowlist of tools, managed as code (AWS::DevOpsAgent::Association, MCPServerSigV4):
mcp-runtime___list_k8s_resources
mcp-runtime___get_pod_logs
mcp-runtime___get_k8s_events
mcp-runtime___list_api_versions
x_amz_bedrock_agentcore_search
On the cluster side, the runtime's IAM role has an EKS access entry with the managed AmazonEKSViewPolicy: get, list and watch, nothing else.
Step 4: The Agent Space (What the Agent Can See)
The AWS DevOps Agent space is created with the native aws_devopsagent_agent_space resource, and its description states the contract:
# stacks/application/compute/devops-agent/main.tf (excerpt)
resource "aws_devopsagent_agent_space" "this" {
name = "${local.name_prefix}-space"
description = "DevOps Agent space for ${var.project} ${var.environment} (read-only investigate + plan; proposes, never mutates)."
locale = var.locale
kms_key_arn = var.kms_key_arn != "" ? var.kms_key_arn : null # optional customer-managed key
}
| Access | What the agent gets |
|--------|---------------------|
| Agent IAM role (read-only policy) | CloudWatch alarms, metrics and logs, X-Ray, CloudTrail LookupEvents, EKS describe |
| MCP server (SigV4, via AgentCore Gateway) | The 4 read-only Kubernetes tools above |
| GitHub: gitops, petadoptions, framework repo | The code and history to correlate "what changed" |
| Slack on-call channel | Where findings are posted for humans |
| Event channel (webhook) | The trigger from Step 2 |
Elevated actions are disabled in the space. The agent's IAM role trusts the aidevops.amazonaws.com service only for this account's agent spaces (aws:SourceAccount + aws:SourceArn), which protects against the confused-deputy problem.
Step 5: The Remediation Pipeline (Proposal to Pull Request)
The second EventBridge rule forwards the same alarm event cross-account to an event bus in the DevSecOps account. Its IAM role can do exactly one thing:
# stacks/application/integration/event-forwarder/main.tf (excerpt)
resource "aws_cloudwatch_event_target" "hub_bus" {
rule = aws_cloudwatch_event_rule.to_hub.name
target_id = "kiro-hub-bus"
arn = var.hub_kiro_event_bus_arn
role_arn = aws_iam_role.forwarder.arn # policy: events:PutEvents on that bus only
}
In the DevSecOps account, an extractor Lambda asks the AWS DevOps Agent API for the mitigation plan and queues it in SQS. A starter Lambda launches CodeBuild, which runs Kiro CLI headless with strict rules ("only edit files in this repo; do NOT run AWS CLI or kubectl"):
kiro-cli chat --no-interactive --trust-tools=read,write,grep,shell "$(cat /tmp/prompt.md)"
The buildspec wraps the agent with guardrails the agent can't change: no changes means no PR, more than 10 files means abort, and the branch is always a new kiro-fix/<timestamp> that goes through review. GitHub access uses an AWS CodeConnections GitHub App, with no personal access tokens.
Defense in Depth
No single control carries the safety. If one fails, the next one holds:
!Image description
When It Breaks: Gotchas I Hit
| Symptom | Cause | Fix |
|---------|-------|-----|
| Invalid STS role configuration for session sigv4McpServerSession | The agent role's trust statement had sts:TagSession | Use sts:AssumeRole only for aidevops.amazonaws.com |
| Role validation failed ... doesn't include either SourceArn or SourceAccount | No confused-deputy guard | Add aws:SourceAccount and aws:SourceArn = arn:aws:aidevops:<region>:<account>:service/* (not agentspace/*) |
| 403 ... Insufficient permissions from the gateway | Trust OK, but no invoke permission | Grant bedrock-agentcore:InvokeGateway on the gateway ARN |
| The MCP server shows zero tools | Gateway created without a target | Add the mcp_server target pointing at the runtime |
| SigV4 signature rejected | Service name set to execute-api | The SigV4 service for AgentCore is bedrock-agentcore |
| Read tools return forbidden | No EKS access entry for the runtime role on the spoke | Add the access entry with AmazonEKSViewPolicy |
| Alarm never fires | Alarm on a metric nothing publishes | Use a metric the Container Insights add-on already emits |
Conclusion
We didn't give an AI agent the keys to production. We gave it the best possible view of production, and one narrow, reviewable way to suggest a change. The existing GitOps platform did the rest: the pull request is the dry-run and the approval, and Argo CD is the deterministic executor.
In the next episode we put this architecture under a real incident: a pod that's OOMKilled under load, an agent that investigates, and the two things it got wrong before the platform got it right.
How are you giving AI agents access to your clusters today: read-only, through PRs, or with the keys? I'd love to hear what's working for you 👇
Happy building! 🚀
References
- AWS DevOps Agent documentation
- Amazon Bedrock AgentCore
- AWS Labs MCP servers (EKS MCP server)
- Amazon EKS access entries and access policies
- Container Insights metrics for Amazon EKS
- Kiro CLI documentation
- GitOps and IaC at Scale — Part 1
---
> The opinions expressed in this post are my own and do not necessarily reflect those of my employer or AWS.
✨ Alejandro Velez, Platform Engineering Latam Lead @ GFT | AWS Ambassador
AI-DLC Workflows v2 よい。はじめ「?」っとなったが、IntentsとCustom Scope使ったハンドリングに慣れてくると心地よくなってきた
github.com/awslabs/aidlc-workflows/tree/main
3 likes · 40 views
Hijacking a Refund Agent Through Poisoned Support Tickets: Cristian Leo and Daniel Begimher at Global AppSec USA 2026
Players in this hands-on pod attack a customer-returns AI agent that can read tickets, fetch URLs, look up orders, send email or issue refunds.
A player wins by making the agent issue a refund, email an address the player picks or post order data to a URL the player controls.
No payload is handed out. Players write the support ticket, the tracking page or the email that the agent is asked to summarize, then hide their instructions inside it.
In their Global AppSec USA 2026 hands-on pod, "Hijack the Agent", on Thursday, November 5 at 10:10 a.m. in Marina, Cristian Leo and Daniel Begimher let attendees attack an AI agent that defends itself a little better at each stage.
Speakers:
- Cristian Leo, Co-Founder and CTO, Armorer Labs
- Daniel Begimher, Co-Founder and CEO, Armorer Labs
The pod runs the same app through four stages, each one harder. At the beginner stage the tools are wide open, so even direct injection in the chat works.
At the intermediate stage a chat filter is on, so players must deliver their instructions through the web page or the email instead. That maps to ASI01, Agent Goal Hijack, in the OWASP Top 10 for Agentic Applications.
At the advanced stage a static deny-list guards the issue_refund and send_email tools, yet the same string passes through lookup_order. Players must find a tool call the list did not anticipate, then a new fetch_url tool makes the previous round's rule wrong. That maps to ASI02, Tool Misuse.
The final "Impossible" stage adds an in-process dispatcher hook that inspects every tool call. Attacks from the first three stages die there with a stated reason, which is the lesson.
Each stage takes under 15 minutes, and players need only a laptop with a browser. The pod repeats on Thursday at 3:00 p.m., then on Friday at 11:30 a.m. and 1:30 p.m.
Cristian Leo is Co-Founder and CTO of Armorer Labs, a Y Combinator Fall 2026 company that tests AI agents for exploitable failures. Previously at Amazon Web Services, he is corresponding author of ThreatForest, a multi-agent system that turns a source repository into attack trees mapped to MITRE ATT&CK or CAPEC.
Daniel Begimher is Co-Founder and CEO of Armorer Labs. At AWS he led development of AWS's first customer-facing AI security agent, built red-team agents and wrote the open-source scanner Automated Security Helper.
Leo and Begimher co-presented ThreatForest at Black Hat USA 2026. They also co-authored SIR-Bench, a 794-case benchmark that measures whether incident response agents actually investigate or merely triage.
Begimher also co-wrote the AWS Security Blog post "Balancing speed and safety: A control framework for AI coding agents," which pairs scoped tool access for AI coding agents with build-time gates.
To go deeper on the topic, start here:
- OWASP Top 10 for Agentic Applications for 2026
- ThreatForest Black Hat USA 2026 slides
- ThreatForest paper (arXiv)
- SIR-Bench paper (arXiv)
- ThreatForest code (aws-samples)
AppSec engineers who need to see why deny-lists fail against agent tool calls will get a fast, concrete demonstration here.
Which of your AI agent's tools would an attacker reach for first through a poisoned document?
2 points · 1 comment
SkyPilot v0.14.0 の解説
はじめに
クラウドの VM、Kubernetes、Slurm のどれにでも同じ書き方でジョブを投げる道具 SkyPilot の v0.14.0 が、2026 年 10 月 6 日に出ました。この記事では、SkyPilot が何をする道具かを押さえたうえで、v0.14.0 の機能と性能に関わる変更を整理します。
v0.14.0 の項目
何が変わったか
実務での意味
動的なジョブグループ
動いているグループの中から投げたジョブも、グループの一部になる
学習の途中で投げた評価のジョブも、グループを取り消すと一緒に止まる
AWS Neuron への対応
Kubernetes の上の Trainium のノードに、ジョブを置けるようになった
Trainium のノードを、GPU と同じ --gpus の書き方で頼める
EFA の改善
AWS の高速なノード間通信 EFA の数を、ノードの自動増減を SkyPilot に知らせたクラスタでは、アクセラレータのノードがまだ 0 台でも見積もれ、複数ノードの Pod が 1 つの AZ にまとまる
複数ノードの学習が、気づかないまま遅い TCP の通信で動いたり、通信を始められなかったりすることが減る
Kueue との連携の改善
Kubernetes の待ち行列 Kueue で待っている時間を、確保の時間に数えなくなった
待ち行列が混んでいるだけのジョブが、短く設定した確保のタイムアウトで打ち切られない
複数ユーザーでの実行
Slurm に、依頼した人それぞれの Unix アカウントでジョブを出せる
Slurm 側で設定した利用量の記録や公平な割り当てを、人ごとに効かせられる
コンテナクラスタの停止と再開
Slurm の上のコンテナのクラスタを止めて、あとで再開できる
ノードを手放しても、入れたパッケージやファイルを残したまま再開できる
SkyPilot とは何か
1 つの書き方で複数の基盤に投げる
SkyPilot は、Kubernetes、Slurm、クラウドの VM のどれに対しても、同じ 1 つのタスクの書き方で、計算資源の確保からジョブの実行と後片付けまでを受け持つ道具です。基盤ごとにジョブの書き方も、空きの探し方も、止まったときの立て直し方も違うので、その違いを SkyPilot が引き受けます。
組織で複数のプロジェクトを進めていると、複数のクラウド、複数の Kubernetes や Slurm のクラスタ、複数のリージョンを併用することがあります。こうしたクラスタは、それぞれ別々に構築して立ち上げます。SkyPilot は、こうして別々に立てたクラスタやクラウドの VM に、統一的にジョブを投げるための道具だと理解すればよいです。クラウドの VM の場合は、SkyPilot がインスタンスを自分で作ります。
タスクは、必要な資源、準備のコマンド、実行のコマンドを YAML に書いたものです。
resources:
accelerators: H100:8
workdir: .
setup: pip install -r requirements.txt
run: python train.py
これを requirements.txt と train.py のあるディレクトリに task.yaml として保存し、sky launch task.yaml を実行すると、SkyPilot は条件を満たす基盤と場所を探して計算資源を確保します。確保に失敗したら、別の場所でやり直します。README のとおり、計算資源はすべて利用者自身のクラウドのアカウントやクラスタの中に作られます。
sky launch は対話的に使う開発用のクラスタを、sky serve は推論のエンドポイントを作ります。sky jobs launch で投げたマネージドジョブは、スポットインスタンスの回収やノードの故障が起きると、資源を確保し直して実行し直します。途中から続けるには、アプリケーションがチェックポイントを保存して読み込む必要があります。sky コマンドは SkyPilot の API サーバーに依頼を送ります。1 人で使うときは手元に API サーバーが自動で立ち、チームで使うときはリモートに 1 つ立てて共有します。
AWS では、SageMaker HyperPod(AWS が管理する学習用のクラスタで、EKS 版と Slurm 版がある)、Amazon EKS、Slurm を使う AWS Parallel Computing Service(AWS PCS)などを構築するリファレンスのテンプレートが awsome-distributed-ai にあります。SkyPilot のドキュメントは、EFA を使える投げる先として、HyperPod の EKS のクラスタと Slurm のクラスタを挙げています。awsome-distributed-ai の NVIDIA NeMo 2.0 の学習の例でも、NeMo の学習を Python の設定から投げる NeMo-Run が、Amazon EKS と SageMaker HyperPod EKS にジョブを出す役として SkyPilot を使います。SkyPilot は、カリフォルニア大学バークレー校の研究室から生まれたオープンソースのソフトウェアで、ライセンスは Apache 2.0 です。
Kubernetes や Slurm との関係
SkyPilot は、どのクラスタにジョブを出すかを選び、そのクラスタが受け付ける形に直して渡します。Kubernetes と Slurm は、受け取ったジョブをクラスタの中のどのノードで動かすかを決めます。Kubernetes に出すときの形は、資源の要求と置き場所の条件を書いた Pod の定義で、Kubernetes の API サーバーに出します。Slurm に出すときは sbatch と srun を使います。そのため、SkyPilot は Kubernetes や Slurm を置き換えるものではありません。
Kueue との関係
Kueue は、Kubernetes のクラスタの中でジョブを待ち行列に並べ、割り当て枠や優先度に従って、受け入れるか、待たせるかを決める仕組みです。SkyPilot はどのクラスタやクラウドに置くかを選び、Kueue は選ばれたクラスタの中でいつ受け入れるかを決め、置くノードは Kubernetes のスケジューラが決めます。
決めることが違うので、1 つのクラスタの中では、SkyPilot と Kueue は置き換えではなく重ねて使います。Kueue の仕組みと、kube-scheduler や Volcano、KAI-Scheduler といった他のスケジューラとの関係は「Kubernetes の分散ジョブスケジューラ比較」で説明しています。v0.14.0 での連携の変更は、Kueue との連携の改善で説明します。
Kueue には、複数のクラスタにジョブを振り分ける MultiKueue もあります。MultiKueue もどのクラスタで動かすかに関わるので、SkyPilot とどう受け持ちを分け、どう組み合わせるかは、今後調べます。
ジョブグループのアップデート
動的なジョブグループ
ジョブグループは、学習と報酬の計算のように資源の違う複数のタスクを同時に動かし、1 つの単位として管理する仕組みです。v0.14.0 では、動いているグループの中から新しいマネージドジョブを投げると、そのジョブも既定でグループの一部になり、グループを取り消すと一緒に止まります。
この変更は、OS のプロセスグループに例えるとわかりやすいです。v0.13.0 までは、学習の途中でグループの中から評価のジョブを投げると、そのジョブは setsid で別のプロセスグループに切り離して起動したプロセスのようなもので、グループを取り消しても動き続けました(図の左)。v0.14.0 では、グループの中から投げたジョブは、既定で同じグループに加わります。プロセスグループ全体に kill を送ると中のプロセスがすべて止まるのと同じように、人がグループを取り消すと、途中で加わった評価のジョブも取り消されます(図の右)。
具体的な流れは次のとおりです。
学習のタスクが、チェックポイントを共有の場所に書きます。
見張り役のタスクが、新しいチェックポイントを見つけます。
見張り役が sky jobs launch で評価のジョブを投げます。
そのジョブは自動でグループの新しいタスクになり、sky jobs queue ではグループの下に並びます。
評価のジョブは、学習とは別の資源で動き、状態も別に持ちます。
グループを取り消すと、評価のジョブも一緒に止まります。
例えと違う点もあります。止める単位は、ジョブを投げたタスクではなくグループそのものです。伝わるのはグループから評価のジョブへの一方向だけで、評価のジョブが失敗してもグループは失敗しません。また、実際に伝わるのはシグナルではなく、SkyPilot によるマネージドジョブの取り消しです。
この動きは、リモートの API サーバーが、マネージドジョブを専用の管理役のクラスタではなく自分で管理する構成(統合モード)で、評価のジョブを投げるタスクからその API サーバーに接続できる場合です。統合モードでない環境では、グループの中から投げたジョブはグループに加わらず、普通のジョブとして動きます。
slime の例で見るジョブグループ
slime は、LLM を強化学習で鍛えるフレームワークで、重みを更新する学習の部分と、エージェントの応答を生成する推論の部分(LLM の推論サーバー SGLang)に分かれます。この例では、リポジトリの不具合を直すコーディングエージェントを鍛えます。SkyPilot の slime の例は、学習を 1 つのジョブ、SGLang エンジンを 1 つずつ別のジョブにしてジョブグループで束ね、SGLang エンジンの数と置く GPU を学習と別に決められるようにしたものです。図は、この例(Apache 2.0)から引用しました。
これは実行中にジョブを足す例ではなく、学習と推論のジョブを起動前に YAML に書き、ジョブグループでまとめて動かす例です。学習のジョブ(H100 が 4 枚)と SGLang エンジンのジョブ(H100 が 1 枚ずつ)は同じ Kubernetes のクラスタに置かれ、グループの中の通信でつながります。学習のジョブは、各 SGLang エンジンの /health が応答するのを待ってから、sglang-0.<グループ名>:30000 のようなグループのホスト名を slime に渡し、学習のジョブの中のプロセスを束ねる Ray に、slime の学習を投げます。
図の番号は、エージェントが課題を 1 回解いてから、重みが更新されて SGLang エンジンに届くまでの流れです。
学習のジョブの中の、エージェントに課題を解かせて rollout(1 回の試行の記録)を集める管理役(RolloutManager、CPU で動く)が、エージェントのループを回します。エージェントは毎ターン、生成の依頼を SGLang エンジンに配るルーターを通して生成を頼みます。RolloutManager は、生成したトークンと、その確率の対数である logprob を記録します。
エージェントは、リポジトリの取り出しやツールのコマンドを、信用できないコードを隔離して動かす Pod である SkyPilot Sandboxes の中で実行します。最後に、エージェントが作った差分を隠されたテストに当てて採点し、報酬を決めます。
trajectory と logprob と報酬が、学習側に渡ります。
学習側の学習ライブラリ Megatron が、強化学習の更新方法の 1 つ GRPO で重みを更新します。
新しい重みを、学習のジョブと SGLang エンジンのジョブの両方がマウントする共有のボリューム(/shared/policy)に書きます。
各 SGLang エンジンが、そのボリュームから新しい重みを読み込みます。
5 と 6 は既定の設定(WEIGHT_TRANSPORT=disk)の流れで、nccl を選ぶと、SGLang エンジンが学習のジョブと GPU 間の通信ライブラリ NCCL でつながって重みを受け取ります。
ジョブグループと Ray は、決めることが違います。
ジョブグループ(SkyPilot)
Ray(slime の中)
決めること
どのクラスタに、どのジョブを、いくつ立て、いつ一緒に止めるか
1 つのジョブの中で、どの GPU をどのプロセスに割り当てるか
この例での中身
学習のジョブ 1 つと SGLang エンジンのジョブを立て、互いをホスト名で見つけられるようにする
学習のジョブの中で、Megatron の学習プロセスに GPU を割り当てる。rollout の管理役は CPU で動かす
SGLang エンジンを外に出さず、学習と推論を SkyPilot の 1 つのタスクの中で動かす構成なら、ジョブグループを使わずに組めます。そのタスクの中では、slime が Ray に学習用と推論用の GPU を割り当てさせます。同じ GPU を共有させても、別々の GPU に分けても構いません。SGLang エンジンを学習と別に増やしたいときや、別の種類の GPU に置きたいときに、SGLang エンジンをジョブグループの別のジョブに出し、slime の外部エンジンの設定(--rollout-external-engine-addrs)にそのホスト名を渡します。このとき Ray が割り当てるのは、学習のジョブの GPU だけです。
Kubernetes 関連のアップデート
AWS Neuron への対応
v0.14.0 から、Kubernetes の上の Trainium のノードにも、GPU と同じ accelerators: の書き方でジョブを置けます。これまで SkyPilot が Kubernetes で扱えるアクセラレータは NVIDIA と AMD の GPU と Google の TPU だけで、Trainium のノードは空いていても使えませんでした。Neuron は、AWS のアクセラレータ Trainium を動かすソフトウェアの名前で、Kubernetes にはこの名前の資源として見えます。
受け持ちは次のとおりです。クラスタを運用する側は、Neuron 用の AMI で起動するノードと、Trainium を Kubernetes の資源として見せる Neuron のデバイスプラグインを用意します。SkyPilot は、Pod を Trainium のノードで動かすために、図の 1 から 3 を Pod の定義に書きます。1 つ目に、Pod が Trainium のチップを何枚使うかを Kubernetes に伝えます。2 つ目に、Pod が Trainium のノードだけに置かれるように条件を付けます。3 つ目に、Neuron のノードには Neuron を使わない Pod を入れないための印が付けられていることがあり、この Pod はその印があっても入れるようにします。ノードを自動で増減させる Karpenter を使い、そのことを SkyPilot の設定で知らせているクラスタでは、2 つ目の条件は Karpenter が付けるノードのラベルで書かれるので、ノードがまだ無くても Karpenter が Trainium のノードを作る手がかりになります。
Kubernetes の既定のコンテナイメージは、Trainium の Pod でも GPU 用のもので、Neuron SDK は入っていません。そのため、image_id で Neuron SDK の入ったイメージを指定します。
Trainium に投げるコードの側では、PyTorch を Trainium で動かす TorchNeuron が公開ベータになりました。ブログの学習の例では、変更は model.to('neuron') の 1 行です。TorchNeuron 向けに直した PyTorch の学習スクリプトを、SkyPilot のタスクの run から Trainium のノードで動かせるかは、今後検証してみようと思います。
EFA の改善
EFA は Elastic Fabric Adapter の略で、AWS のノードどうしを高速につなぐネットワークのインタフェースです。v0.14.0 では、複数ノードの学習が EFA を使えないまま動いてしまう 2 つの場合を、SkyPilot が防ぐようになりました(図)。1 つは、アクセラレータのノードがまだ 0 台で、Pod に書く EFA の数が決まらない場合です。もう 1 つは、Pod が別々の AZ に散って、EFA でつながらない場合です。前者では SkyPilot が EFA の数をカタログから決め、後者では Pod の置き場所を 1 つの AZ にそろえます。SkyPilot が受け持つのは Pod に書く EFA の要求の数と置き場所までで、割り当てられた EFA をアプリケーションが実際に使うかは、イメージの中の通信ライブラリの設定で決まります。
SkyPilot が EFA を要求するのは、タスクの resources: の下に、使える最速のネットワークを頼む設定 network_tier: best を書いたときです。頼んだ種類のアクセラレータを頼んだ数以上に持つノードが vpc.amazonaws.com/efa の資源を公開していれば、要求する数は次の式で決まります。
\text{要求する EFA の数} = \max\left(1,\ \left\lfloor \frac{\text{頼んだアクセラレータの数}}{\text{ノードのアクセラレータの数}} \times \text{ノードの EFA の数} \right\rfloor\right)
この計算は、頼んだアクセラレータの数で割合を出します。たとえば p5.48xlarge は GPU を 8 枚、EFA を 32 公開するので、H100:4 を頼めば EFA は 16 になります。
1 つ目の改善は、アクセラレータのノードが 0 台でも数を決めることです。これまでは動いているノードを調べて数を決めていたので、Karpenter などでノードを 0 台から増やすクラスタでは数が決まらず、EFA を要求しないまま Pod が作られることがありました。そうなると、GPU 間の通信ライブラリ NCCL の通信は、気づかないまま TCP に落ちます。v0.14.0 からは、ノードを自動で増減させる仕組みを使っていることを設定 kubernetes.autoscaler で SkyPilot に知らせたクラスタでは、AWS のノードが 1 台でもあれば、カタログから数を決めます。カタログは、SkyPilot が持つインスタンスの種類ごとの一覧表です。SkyPilot は、要求を載せられるインスタンスのうち、アクセラレータ 1 つあたりの EFA が最も少ないものに合わせて、同じ割合で数を決めます。カタログでも数が決まらなければ、これまでどおり EFA なしの Pod になります。
2 つ目の改善は、複数ノードの EFA のジョブを 1 つの AZ にまとめることです。EFA の通信は 1 つの AZ の中で組むので、ノードが別々の AZ に散ると EFA でつながらず、TCP に落ちるか、通信を始められません。v0.14.0 からは、AWS のノードでできた Kubernetes のクラスタで複数ノードの network_tier: best のジョブを投げると、SkyPilot は Pod の定義に、同じジョブの他の Pod と同じ AZ に置く条件(podAffinity)を足します。最初の Pod が置かれた AZ に、残りの Pod が続きます。この条件は必須なので、その AZ に空きが無ければ、Pod は TCP に落ちて動き出すのではなく、待ちの状態で止まります。
Kueue との連携の改善
これまでは、Kueue の待ち行列で順番を待っている時間も、SkyPilot が計算資源の確保の時間に数えていたので、確保のタイムアウトを短く書くと、割り当て枠が空くのを待っているだけのジョブまで、確保の失敗として捨てられていました。v0.14.0 からは、Kueue の受け入れを待っている間は確保の時計を止め、受け入れられてから数え始めます。
Slurm 関連のアップデート
複数ユーザーでの実行
v0.14.0 では、SkyPilot が Slurm にジョブを出すとき、依頼した人それぞれの Unix アカウントとして出せます。これまでは、1 つの SkyPilot の API サーバーで Slurm のクラスタを分け合うと、全員が設定ファイル ~/.slurm/config に書いた 1 つの SSH ユーザーとして出ていたので、Slurm の利用量の記録(accounting)や公平な割り当て(fairshare)、ファイルの権限が、全員を同じ 1 人として扱っていました。
有効にするには、API サーバーが読む SkyPilot の設定で slurm.submit_as_user を true にします。すると SkyPilot は、まず SSH ユーザーで接続し、そのあと確保、srun、ファイルの転送、取り消しを、依頼した人の Unix アカウントとして行います。アカウント名は、既定ではメールアドレスの @ より前の部分で、username_map で個別に対応を書けます。次の例は、Slurm のクラスタに jdoe の Unix アカウントがあることを前提にしています。
slurm:
submit_as_user: true
username_map:
jane.doe@example.com: jdoe
そのためには、SSH ユーザーが root であるか、対象のユーザーとして /bin/bash をパスワードなしの sudo で動かせる必要があります。SkyPilot はこのシェルの中でスクリプトの実行や rsync を行うので、コマンドごとには絞れず、切り替え先の権限がそのまま SSH ユーザーに渡ります。ドキュメントは、切り替え先を sudo や Slurm の管理権限を持たない専用グループのアカウントに限り、sudo に端末を要求させる設定 requiretty を、そのグループにだけ外す設定例を載せています。共通のアカウントで運用するなら、既定の submit_as_user: false のままで構いません。
あわせて、Slurm 側で利用量を付ける先(account)と、優先度や上限の枠(QoS)を、SkyPilot のワークスペース(ユーザーやプロジェクトを分ける単位)ごとに指定できるようになりました。この指定は slurm.quota に書き、SkyPilot が sbatch の --account と --qos に渡します。この account は Slurm の利用量を付ける先の単位で、Unix のアカウントとは別のものです。
コンテナクラスタの停止と再開
SkyPilot は Slurm に割り当てさせたノードの上に自分のクラスタを立て、タスクにコンテナイメージを書いた場合は、そのクラスタをコンテナの中に立てます。v0.14.0 では、このコンテナのクラスタを、入れたパッケージやファイルを残したまま止めて、あとで再開できます。止めている間は Slurm のノードの割り当てを手放すので、ノードを他の人に返せます。
sky stop と sky start で止めるときは、各ノードのコンテナのファイルシステムを共有のストレージに写してから割り当てを手放し、再開するときは新しい割り当てに書き戻します。残るのはファイルシステムで、動いていたプロセスは止めた時点で終わるので、計算を途中から続けるにはチェックポイントが要ります。対象は、Slurm でコンテナを動かすプラグイン Pyxis を使うクラスタです。あわせて、一定時間使われていないクラスタを同じ手順で止める autostop と、ホストのパスをマウントする設定も入りました。
まとめ
筆者は v0.14.0 の動きの多くをまだ実機で確かめておらず、実際に動かして確かめるしかないので、次の点を調べる予定です。
今後検証すること
確かめたいこと
AWS PCS、SageMaker HyperPod、Amazon EKS を登録して使う
それぞれを SkyPilot に登録し、同じタスクの YAML で投げ分けられるか。PCS で計算ノードを 0 台から増やす構成が通るか
slime(または同じ作りのフレームワーク miles)とジョブグループを組み合わせる
SGLang エンジンをジョブグループの別のジョブにし、外部エンジンとして学習側に渡したときに、学習が回り、重みの受け渡しが動くか
Neuron と GPU のノードにジョブを投げ分ける
同じ API サーバーから、Kubernetes の上の Trainium のノードと GPU のノードに、別々のタスクを投げ分けられるか
EFA の挙動
network_tier: best で、アクセラレータのノードが 0 台からでも EFA の数が決まるか、複数ノードの Pod が 1 つの AZ にまとまるか
MultiKueue との組み合わせ
複数のクラスタへの振り分けを、SkyPilot と MultiKueue がそれぞれどう受け持つか
参考
SkyPilot v0.14.0 のリリースノート
SkyPilot のドキュメント
ジョブグループのドキュメント
Kubernetes で AWS Neuron に対応させた PR #10169
Introducing TorchNeuron: Native PyTorch backend for AWS Trainium
Neuron を EKS で使う手順
Karpenter のインスタンスタイプの表
From Writing the Spec to Approving It: What AI-DLC Looks Like on a Real Build
!Image description
Most AI-assisted development still follows the old split. A human writes the spec, a human writes the code, and the AI helps with autocomplete or generates tests at the end. AI-DLC (the AI-Driven Development Life Cycle) reverses that. The AI drafts the requirements, the design, and the code. The human answers short questions and approves each stage before the next one starts.
To see what that looks like in practice, I built a small command-line tool with it and then read the record the workflow left behind. This post walks through that record, folder by folder.
> TL;DR
> - AI-DLC is public and open source: awslabs/aidlc-workflows. The method comes from this AWS DevOps blog post.
> - My only written input was one sentence. Everything else I did was pick options and approve gates.
> - The run produced requirements, four architecture decisions, a unit plan, a traceability file, and 12 tests that passed when I ran them, in about 44 minutes from start to the code approval.
> - The human role moves from author to decision-maker. The decisions are still yours, and the record shows where they changed the outcome.
The app
The tool is a Spooky Date Fact Generator. It picks one of 10 spooky dates (or takes --date MM-DD) and prints a spooky event for that day from Wikipedia's "On this day" feed. It is small on purpose.
The whole request was this sentence:
/aidlc build random date generator with fact around that number
The flip, in one table
| | Traditional with AI help | AI-DLC |
|---|---|---|
| Requirements | Human writes them | AI drafts them from multiple-choice answers |
| Design | Human sketches it | AI proposes, human picks between options |
| Code | Human writes, AI assists | AI writes against an approved plan |
| Tests | Human or AI at the end | AI writes them to the stated requirements |
| Human job | Author | Decide, validate, approve |
| Evidence | Pull request diff | Audit log, decisions, traceability file |
Where everything was written
Two locations matter. The generated app sits in its own folder, and the workflow record sits in an aidlc/ folder.
app/ <- the generated tool
src/spooky-facts.ts core: dates, fetch, keyword filter, nearest-date search
src/cli.ts command line: options, output, exit codes
test/ 12 tests, offline, with a fake fetch
package.json, biome.json, tsconfig.json, README.md
aidlc/
codekb/ <- 9 docs about the existing repo
intents/<date>-date-fact-generator/ <- the record for this build
aidlc-state.md stage checklist and settings
audit/ every event, decision, and approval
inception/
reverse-engineering/ scan of the existing repo
requirements-analysis/ questions + requirements.md
domain-design/ questions + components.md + decisions.md
units-generation/ unit-of-work files
construction/u1-spooky-date-cli/code-generation/
code-generation-plan.md, code-summary.md,
unit-test-instructions.md, traceability.json
Each stage writes a -questions.md file, and your answers sit in the same file as the AI's questions. That makes the decision trail readable without any tooling.
Stage by stage: what it did and why it helps
Initialization. The workflow scaffolded the record folder, scanned the workspace, and classified it as brownfield TypeScript on Bun. This is automatic. It helps because every later stage starts from facts about the repo instead of guesses.
Reverse Engineering. A developer agent scanned the existing repo and wrote nine knowledge-base documents (architecture, technology stack, code structure and others). This matters most in a brownfield repo. The later code followed the repo's Bun, TypeScript, and Biome conventions because the AI had read them first.
Requirements Analysis. The AI asked six questions, such as where facts come from, what to do when a date has no spooky event, and whether this should be a web page or a command line. Each had lettered options. I answered, and one answer included a free-text note: generate dates that have spooky facts, from 10 such scenarios. The result is requirements.md with 15 functional and 5 non-functional requirements, each with acceptance criteria and a tag pointing back to the answer that produced it, such as [Q5]. This stage turns one vague sentence into testable statements before any code exists.
Domain Design. Four more questions produced components.md and four architecture decision records. The AI recommended splitting the tool into four parts. I chose two. The decision record states that the four-part split was rejected as more structure than needed. This is the clearest example of the validator role, because the human overrode the AI's recommendation and the reason is recorded.
Units Generation. One question: build as one unit or two. I chose one, approved the plan, and got unit-of-work.md with boundaries, responsibilities, and constraints. It fixes the blast radius. The unit is limited to apps/date-fact-generator/, and nothing in the framework folders may change.
Code Generation. The AI wrote a plan, then the code, then the tests. It asked one thing here: Bun was not installed, so should it install Bun or switch to Node. I chose Bun. The code-summary.md lists files created, decisions made, and deviations from the plan. One deviation worth noting: the AI added word forms such as "dies" and "sank" to the keyword list, because whole-word matching would otherwise miss "Houdini dies". The summary also states that some planned checks were grouped into fewer tests to match the 12-test count.
Build and Test. In the record I read, this stage was still marked in progress, so its summary was not written yet. I ran the checks myself from the app folder: 12 tests passed with 93 assertions, and lint and typecheck were clean.
What the human actually did
Reading the audit log, the human work was this:
- Typed one sentence to start.
- Approved a recomposed plan. The workflow's planner found the default express plan was missing inputs for later stages, and proposed adding Domain Design and Units Generation while dropping deployment and monitoring.
- Answered 11 multiple-choice questions across three stages, plus two more decisions: build inside the existing repo, and install Bun.
- Approved 5 stage gates: reverse engineering, requirements, domain design, units, and code generation.
That is the flip. Nothing in that list is writing a spec or writing code. All of it is deciding and checking.
Why this holds up better than a prompt
Two details make the validation real instead of a rubber stamp.
First, traceability. traceability.json maps every requirement ID (FR1.1 through NFR5, 20 in total) to the file that satisfies it. If a requirement has no target, it shows up.
Second, the record is complete. The audit log holds the timestamp of each question, answer, and approval. A reviewer six months later can see why the design has two components and not four.
What this run does not show
This was an express-scope build of a 658-line app, with plan approval, sensors, and learnings switched off by the scope. It shows the shape of the method, not how it behaves on a large system with many units and teams. The Build and Test stage had not completed in the record, and the tests are offline, so only one manual live smoke run, recorded in the code summary, exercised the real Wikipedia feed.
The questions are only as good as the options offered. A weak option list can steer a hurried approver, so reading the alternatives still matters.
Takeaway
The useful change is not that the AI writes more code. It is that the human's time moves to the points where judgment counts: what the tool should do, which design to accept, and whether the result meets the requirements. If you want to try it, start with a small brownfield repo and read the -questions.md files first. They show the method faster than any description.
TelegrafをARMv6で使う
こんにちは。ナウア株式会社の柳です。
私たちは、測定装置とエッジAI端末を組み合わせた調査機器を屋外と屋内に置いて運用しています。
各端末の死活とシステムのメトリクスは、TelegrafでAWS IoT Coreに送っています。
手に入りやすいRaspberry Pi Zero W(以下、ラズパイ)も、軽いデータ取得などに使えるハードウェアです。
このラズパイに同じTelegrafを入れようとすると、公式の配布物が思いのほか大きいことが分かりました。
バイナリは260.3MBあり、ラズパイでの常駐メモリは121.4MBになるため、手軽には入れられません。
そこで、必要なプラグインだけを入れて自前でビルドしました。
ビルドには、Telegrafのリポジトリにあるcustom_builderを使います。
!
この記事のまとめ
送り直しや溜める量の上限といった障害時の動作を設定で指定できるため、端末からの送信にはTelegrafを使っています
custom_builderで必要なプラグインだけを入れると、バイナリは260.3MBから32.0MBに、常駐メモリは121.4MBから21.5MBに減りました
ARMv6向けには、custom_builder自体は手元のPC向けにビルドし、実行時にだけGOARCH=armとGOARMを渡します
背景
Telegrafは、InfluxDataが開発しているメトリクス収集のエージェントです。
CPUやディスクの値を集める入力と、InfluxDBやMQTTへ送る出力を、プラグインとして組み合わせて使います。
ラズパイのTelegrafにさせる仕事は、次の2つです。
ディスク、温度、稼働時間、ネットワークの送受信量、Telegraf自身のメモリを1分ごとに集める
集めた値を1つのJSONにまとめ、TLSで暗号化したMQTTでAWS IoT Coreに送る
一方、Raspberry Pi Zero Wのリソースは限られています。
項目
Raspberry Pi Zero W
CPU
ARMv6、1コア
メモリ
512MB(OSから使えるのは426MiB)
ストレージ
microSDカード
ラズパイにはデータ取得などの本来の仕事があるため、監視用の常駐プログラムにはリソースをなるべく使わせたくありません。
また、モバイル回線は速度制限がかかると下りの速度が大きく下がり、配布するファイルが大きいほど転送に時間がかかります。
この記事では、速度制限時の参考値として下り200kbpsで計算します。
Telegrafを使う理由
平常時に値を送るだけなら、MQTTのライブラリを使った数十行のスクリプトで十分です。
スクリプトとTelegrafの違いは、回線や送り先が不調なときの動作に表れます。
AWSのIoT Lensには、端末のソフトウェアが備えるべきこととして次の項目が挙がっています1。
切れたら自動でつなぎ直す。つなぎ直す間隔は指数的に延ばし、端末ごとに乱数でずらす
つながらない間は、送るべきデータを端末に溜める。使う容量に上限を設け、あふれたら古いものから上書きする
つながったら、溜めた分を送る
信頼性が試された標準のライブラリを使う
スクリプトで書く場合、つなぎ直しはMQTTのライブラリに任せられることが多いでしょう。
一方で、溜める量と捨て方や送れなかった分の扱いは、自分でコードを書いて組み立てます。
このコードは障害が起きたときにしか動かないため、試される機会が限られます。
Telegrafで対応する設定と動作は、次の表のとおりです。
障害時に必要なこと
Telegrafの設定と動作
切れたらつなぎ直す
MQTT出力が、初期設定で自動的につなぎ直す
送れなかった分を後で送る
書き込みに失敗したメトリクスをバッファに残し、次の送信で送り直す2
溜める量の上限と捨て方
metric_buffer_limitで上限を決める。あふれると古いものから上書きし、捨てた件数をログに出す32:1
電源が落ちても残す
実験的な機能のbuffer_strategy = "disk"で、ディスクに溜める3:1
多数の端末の送信を重ねない
flush_jitterで、送る時刻を乱数でずらす3:2
Telegrafはアプリとは別のプロセスで動くため、送信の仕組みをアプリの外に置けます。
アプリの中でMQTTの接続を持つと、再起動や更新のたびに接続が切れます。
retainedのメッセージやLWTのような終了時の後始末も、アプリ側の責任です。
送信を別のプロセスに分けておけば、アプリの不具合や再起動があっても送信は止まりません。
端末の種類が増えても、送り方を同じ書式の設定でそろえられます。
応答を待つ時間のような回線に合わせた調整も、どの端末でも同じ項目を直せば済みます。
送る値を足すときも、多くは入力プラグインを設定に1つ足すだけです。
送受信量の毎秒の値のように、前回の値との差を取る計算も集計プラグインで書けます。
弱点は、公式の配布物が大きく、小さな端末には入れにくいことです。
送る値が数個で増やす予定もなく、欠けても困らないのであれば、スクリプトで十分です。
ほかの選択肢との比較
ほかの選択肢も同じ条件で、2026年9月時点の公式の文書やソース、配布物を調べました4。
Zero Wの実機で動かしたのはTelegrafだけです。
候補
ARMv6向けの入手先
AWS IoT CoreへMQTTで送る
送れない間に溜める
Telegraf
公式のarmel版とarmhf版
MQTT出力
メモリに溜める。ディスクは実験的な機能。あふれると古い方から上書き
スクリプトとMosquittoのブリッジ
Raspberry Pi OSのapt
ブリッジで転送
local_cleansession falseのときだけ溜める。あふれると新しく来た方を捨てる
Vector
名前がARMv6向けの配布物はあるが、中身はARMv7向けで、本体は134.7MB
mqtt sink。beta版で、送達はbest effort
メモリかディスク。ディスクは最小約256MiB。あふれると待つか、新しく来た方を捨てる
Fluent Bit
公式のRaspberry Pi OS向けのリポジトリ。bookwormまで
MQTTの出力が無い
ディスクに溜め、上限を超えると古い方から捨てる
collectd
Raspberry Pi OSのapt
mqttプラグイン。リリース版は独自の形式で送り、JSONにできない
溜めない
AWS IoT Device Client
無い。対応の一覧にarmv6lが無い
できる。集めるのは通信量や接続の値だけ
文書に記述なし
あふれたときに捨てるデータも、ツールによって違いました。
IoT Lensが勧める「古いものから上書き」になるのは、この中ではTelegrafとFluent Bitです。
MosquittoとVectorは、新しく来た方を捨てます。
Mosquittoのブリッジは、local_cleansessionがtrueだと、切断中のメッセージを捨てます。
local_cleansessionを書かなければ、cleansessionと同じ値が使われる仕様です。
AWSのブログにあるブリッジの設定例は、cleansession trueです5。
この設定例のまま使うと、切断中のメッセージは届きません。
AWS IoT Greengrassは、Java版と軽量版のnucleus liteのどちらも、armv6lに対応していません6。
AWSの文書には、端末の値をAWS IoTにMQTTで送るエージェントとして、次の3つが挙がっています7。
AWS IoT Device Client
osqueryとMQTTクライアントの組み合わせ
TelegrafのMQTT出力
全部入りのTelegraf
Telegrafは、ARM向けにarmelとarmhf、arm64の版を配布しています。
まずarmel版のv1.35.1を、そのままラズパイに入れました。
動作はしますが、telegraf --versionを表示するだけで7.8秒かかりました。
大きさの理由は、起動時のログから分かります。
ログの時刻は省いています。
I! Starting Telegraf 1.35.1 brought to you by InfluxData the makers of InfluxDB
I! Available plugins: 238 inputs, 9 aggregators, 34 processors, 26 parsers, 65 outputs, 6 secret-stores
ログに出ているとおり、入力のプラグインだけでも238個です。
TelegrafはGoで書かれており、依存するライブラリも含めて静的にリンクされます。
そのため、Kafkaのクライアントや各クラウドのSDKのように、使わないプラグインが依存するものもバイナリに入ります。
項目
全部入り(公式のarmel版)
バイナリ
260.3MB(配布のtar.gzは69.9MB)
常駐メモリ(RSS)
121.4MB
常駐メモリは、OSから使えるメモリの27%にあたる量でした。
custom_builderの仕組み
Telegrafのリポジトリには、tools/custom_builderというツールが入っています8。
custom_builderに設定ファイルを渡すと、使っているプラグインだけでTelegrafをビルドできます。
プラグインの選び分けには、Goのビルドタグを使う仕組みです。
--dry-runと--tagsを付けると、ビルドせずに選ばれるプラグインだけを確認できます。
今回の設定ファイルでの結果は、次のとおりです。
ログの時刻は省いています。
$ /tmp/custom_builder --config build.conf --tags --dry-run
I! Importing configuration file(s)...
I! Found 1 configuration files...
-------------------------------------------------------------------------------
Enabled plugins:
-------------------------------------------------------------------------------
aggregators (1):
derivative plugins/aggregators/derivative
-------------------------------------------------------------------------------
inputs (5):
disk plugins/inputs/disk
net plugins/inputs/net
procstat plugins/inputs/procstat
system plugins/inputs/system
temp plugins/inputs/temp
-------------------------------------------------------------------------------
outputs (1):
mqtt plugins/outputs/mqtt
-------------------------------------------------------------------------------
parsers (0):
-------------------------------------------------------------------------------
processors (0):
-------------------------------------------------------------------------------
secretstores (0):
-------------------------------------------------------------------------------
serializers (1):
json plugins/serializers/json
-------------------------------------------------------------------------------
Build tags: custom,aggregators.derivative,inputs.disk,inputs.net,inputs.procstat,inputs.system,inputs.temp,outputs.mqtt,serializers.json
I! DRY-RUN: Skipping build.
設定ファイルのdata_format = "json"の行から、JSONのシリアライザーも選ばれています。
READMEには、使うパーサーとシリアライザーが一覧に入っているかを確かめるよう書かれています8:1。
ビルドの際、custom_builderはTelegrafのMakefileを呼び出します。
Makefileの中で実行されるgo buildは、次のとおりです。
CGO_ENABLED=0 go build -tags "custom,aggregators.derivative,inputs.disk,inputs.net,inputs.procstat,inputs.system,inputs.temp,outputs.mqtt,serializers.json" -ldflags " -X github.com/influxdata/telegraf/internal.Commit=f9ce32be -X github.com/influxdata/telegraf/internal.Branch=HEAD -X github.com/influxdata/telegraf/internal.Version=1.35.1" ./cmd/telegraf
設定ファイルの全体は、後半の「AWS IoT Coreに送る設定」に載せました。
ARMv6向けのクロスコンパイル
ビルドはx86_64の手元のPCで行い、できたバイナリをラズパイへコピーします。
このとき、ARM向けの環境変数を渡す場所に注意が必要です。
custom_builderは、手元のPCの上で動くGoのプログラムです。
GOARCH=armを設定したままcustom_builderをビルドすると、ARM向けのバイナリができます。
このバイナリは、手元のPCでは実行できません。
golang:1.24のコンテナで試した結果は、次のとおりです。
$ GOOS=linux GOARCH=arm GOARM=5 go build -o /tmp/custom_builder ./tools/custom_builder
$ /tmp/custom_builder --config build.conf
sh: 1: /tmp/custom_builder: Exec format error
そこで、ビルドを2段階に分けます。
custom_builderは、ARM向けの環境変数を設定せずに手元のPC向けにビルドする
custom_builderを実行するときにだけ、GOOSとGOARCH、GOARMを渡す
custom_builderは、自分の環境変数にBUILDTAGSを加えてmakeを呼び出します。
そのため、2段目で渡した値は中のgo buildまで届く仕組みです。
READMEの手順どおりmake build_toolsで作る場合は、この問題は起きません。
MakefileがGOOSとGOARCH、GOARMを外してからgo buildするためです9。
注意が必要なのは、custom_builderだけをgo buildで作る場合です。
GOARMは、ARMのどの世代向けにビルドするかを決める環境変数で、5から7のいずれかを指定します10。
クロスコンパイルで省くとARMv7向けの7になるため、ARMv6のラズパイ向けには値を明示します。
5は浮動小数点の計算をソフトウェアで行い、6と7はハードウェアで行います。
Telegrafの公式の配布物は、armel版がGOARM=5、armhf版がGOARM=6でビルドされています9:1。
今回は、ラズパイでの動作を確認したarmel版に合わせて5を選びました。
実際に使っているスクリプトは、次のとおりです。
Goは、公式のDockerイメージに含まれるものを使います。
build.sh
#!/bin/sh
使うプラグインだけの Telegraf を ARMv6(Raspberry Pi Zero と Zero W)向けに作る
set -eu
git clone --depth 1 --branch v1.35.1 influxdata/telegraf src
cp telegraf.conf src/build.conf
docker run --rm -v "$PWD/src:/src" -w /src -e HOME=/tmp \
--user "$(id -u):$(id -g)" golang:1.24 sh -ec '
1段目: custom_builder はこの PC 向けに作る
go build -o /tmp/custom_builder ./tools/custom_builder
2段目: custom_builder から呼ばれる Telegraf のビルドだけを ARMv6 向けにする
GOOS=linux GOARCH=arm GOARM=5 /tmp/custom_builder --config /src/build.conf'
ls -l src/telegraf
私たちのPCでは、cloneを含めて約20秒でビルドが終わりました。
$ file src/telegraf
src/telegraf: ELF 32-bit LSB executable, ARM, EABI5 version 1 (SYSV), statically linked, BuildID[sha1]=10cc34f12e15cb0226e198d2e2fd34f59e92ac71, with debug_info, not stripped
静的にリンクされているため、ラズパイ側にGoや追加のライブラリは不要です。
大きさとメモリ
必要なプラグインだけの版を起動したときのログは、次のとおりです。
このログは、手元のPC向けに同じ設定でビルドした版で取りました。
I! Starting Telegraf 1.35.1 (customized) brought to you by InfluxData the makers of InfluxDB
I! Available plugins: 5 inputs, 1 aggregators, 0 processors, 1 parsers, 1 outputs, 0 secret-stores
全部入りの版との比較は、次の表のとおりです。
項目
全部入り(公式のarmel版)
使うプラグインだけ
入っているプラグイン
入力238、集計9、加工34、パーサー26、出力65、シークレットストア6
入力5、集計1、パーサー1、出力1
バイナリ
260.3MB
32.0MB
圧縮したとき
69.9MB(tar.gz)
15.4MB(gzip -9)
下り200kbpsで送る時間(計算値)
約47分
約10分
常駐メモリ(RSS)
121.4MB
21.5MB
常駐メモリは、ラズパイで同じ設定ファイルを使い、systemdのサービスとして動かしたときにpsで見た値です11。
Goのプログラムは、リンクされたパッケージの初期化処理を起動時にすべて実行します。
そのため、使わないプラグインの分も初期化でメモリを使っていると考えられます。
公式の配布物は、リンカーのオプション-w -sでデバッグ情報とシンボル表を削除しています9:2。
一方、上のfileの出力のとおり、今回のバイナリにはデバッグ情報が残っています。
2段目の環境変数にLDFLAGS="-w -s"を加えると、Makefileがリンカーのオプションに反映します。
その結果、バイナリは22.3MBまで小さくなり、gzip -9で圧縮すると7.9MBでした。
パーサーが1つ入っているのは、依存関係で自動的に入ったCSVのパーサーのためです。
READMEにも、依存関係で一覧に出ないプラグインが入る場合があると書かれています8:2。
スクリプトとも比べました。
送る値をそろえるため、入力をdiskとtemp、systemの3つに絞ったTelegrafを用意しました。
比べたのは、同じ項目を同じ形のJSONで送る、MQTTのライブラリを使ったPythonのスクリプトです。
ラズパイでの常駐メモリはTelegrafが20.3MB、スクリプトが20.1MBで、ほぼ同じでした12。
常駐メモリの点ではTelegrafの弱点がなくなり、残るのは32.0MBある配布ファイルの大きさです。
AWS IoT Coreに送る設定
ラズパイで使っている設定を、記事用に整理したものを載せます。
送り先、証明書のパス、JSONのキーは置き換えました。
この1つのファイルを、custom_builderとラズパイのTelegrafの両方に渡します。
telegraf.conf
[agent]
interval = "60s"
round_interval = true
flush_interval = "60s"
omit_hostname = true
回線が戻ったときに、溜まった分を一度に送らない
metric_batch_size = 60
metric_buffer_limit = 120
[[inputs.disk]]
mount_points = ["/"]
[[inputs.temp]]
[[inputs.system]]
[[inputs.net]]
interfaces = ["eth0"]
ignore_protocol_stats = true
Telegraf 自身のメモリ(実行ファイルの名前で探す)
[[inputs.procstat]]
exe = "telegraf-slim"
net の累積バイト数から毎秒の値を作る
[[aggregators.derivative]]
namepass = ["net"]
period = "60s"
drop_original = false
suffix = "_per_sec"
[[outputs.mqtt]]
servers = ["ssl://xxxxxxxxxxxxxx-ats.iot.ap-northeast-1.amazonaws.com:8883"]
client_id = "device-0001-telegraf"
topic = "devices/device-0001/health"
qos = 1
keep_alive = 30
tls_ca = "/etc/telegraf/AmazonRootCA1.pem"
tls_cert = "/etc/telegraf/device.pem.crt"
tls_key = "/etc/telegraf/private.pem.key"
data_format = "json"
layout = "batch"
json_transformation = '''
(
$d := metrics[name="disk"];
$t := metrics[name="temp"];
$s := metrics[name="system"];
$p := metrics[name="procstat"];
$n := metrics[name="net" and $exists(fields.bytes_sent_per_sec)];
{
"ts": $max(metrics.timestamp),
"uptime_s": $s.fields.uptime,
"disk_free_gib": $round($d.fields.free / 1073741824, 2),
"disk_used_pct": $round($d.fields.used_percent, 1),
"temp_c": $max($t.fields.temp),
"rss_mib": $round($max($p.fields.memory_rss) / 1048576, 1),
"tx_kbps": $round($sum($n.fields.bytes_sent_per_sec) * 8 / 1000, 1),
"rx_kbps": $round($sum($n.fields.bytes_recv_per_sec) * 8 / 1000, 1)
}
)
'''
QoS
AWS IoT Coreが対応しているMQTTのQoSは0と1だけで、QoS 2には対応していません13。
TelegrafのMQTT出力のサンプル設定では、qosの行が# qos = 2になっています。
コードの初期値は0です。
サンプルのコメントを外すだけではAWS IoT Coreに合わないため、qos = 1を明示しました。
client_id
AWS IoT Coreでは、すでに使われているclient_idで新しく接続すると、先に接続していた側が切断されます14。
このときの切断理由はDUPLICATE_CLIENTIDです。
同じ端末の別のプログラムが同じclient_idを使うと、互いに相手の接続を切断します。
どちらも自動で再接続する作りなら、この切断が繰り返されます。
私たちは、Telegrafのclient_idの末尾に-telegrafを付けて区別しました。
メッセージのまとめ方
MQTT出力のlayoutの初期値はnon-batchです。
この設定では、メトリクス1つごとに1つのメッセージが送られます。
今回の設定は入力が5つあるため、1分ごとに少なくとも5つのメッセージが別々に届きます。
layout = "batch"では、同じトピックに送るメトリクスを1つのメッセージにまとめて送ります15。
メトリクスは、JSONのmetricsという配列に入る形です。
v1.25.2で非推奨になったbatch = trueは、v1.35.1では起動時のエラーになります。
E! DeprecationError: Option "batch" of plugin "outputs.mqtt" deprecated since version 1.25.2 and will be removed in 1.35.0: use 'layout = "batch"' instead
E! loading config file telegraf.conf failed: error parsing mqtt array, plugin options "batch" deprecated
JSONの形
TelegrafのJSONは、メトリクスごとの入れ子の形です。
1つのメトリクスは、nameとtagsとfieldsとtimestampのキーを持ちます。
受け手が平らなJSONを求める場合は、json_transformationにJSONataの式を書きます15:1。
layout = "batch"のときは、式に{"metrics": [...]}の形のJSONが渡されます。
そのため、metrics[name="disk"]のようにしてメトリクスを取り出します。
Telegrafが使っているJSONataは1.5.4です。
JSONataの試し場で式を試すときは、そのバージョンを選んでください15:2。
手元のPCで、送り先を手元のMQTTブローカーに替えて動かしました。
届いたメッセージは次のとおりで、値はPCのものです。
{"disk_free_gib":20.71,"disk_used_pct":97.7,"rss_mib":24.6,"rx_kbps":63.3,"temp_c":62,"ts":1790658710,"tx_kbps":40.9,"uptime_s":441385}
起動してしばらくの間は、tx_kbpsとrx_kbpsが含まれません。
aggregators.derivativeは2つの時点の値の差から毎秒の値を計算するため、最初のうちは値を出しません。
JSONataは値の無いキーを出力しないため、この2つのキーはメッセージに含まれません。
設定を変えるとき
この設定ファイルはビルドの設定も兼ねているため、プラグインを足したらビルドし直してください。
ビルドに入っていないプラグインを設定に書くと、Telegrafは起動しません。
outputs.fileの無いバイナリにoutputs.fileを書いた設定を渡すと、次のエラーで止まります。
E! loading config file telegraf.conf failed: error parsing file array, undefined but requested output: file
プラグインの種類を間違えた場合は、custom_builderがビルドの前に止まります。
たとえばderivativeはaggregatorsにあり、processorsにはありません。
I! filtering packages failed: configured but unknown packages "processors.derivative"
このメッセージには、情報を表すI!の印が付いています。
それでもcustom_builderは終了コード1で終わり、バイナリは作られません。
ビルドのスクリプトでは、set -eなどで終了コードを確認して止まるようにしてください。
JSONataの式を調整するときは、同じ設定ファイルで手元のPC向けのバイナリも作ると、すぐに試せます。
custom_builderをGOOSやGOARCHなしで実行すれば、手元のPCで動く版ができます。
送り先を手元のMQTTブローカーに替えれば、ビルドに入っているプラグインのまま、届くJSONの確認が可能です。
形が決まってからARM向けをビルドしてラズパイへ転送すれば、遅い回線での転送は1回で済みます。
まとめ
端末から送る仕組みで差が出るのは障害時の動作で、Telegrafは障害時の動作を設定で指定できます
custom_builderで必要なプラグインだけを入れると、バイナリは260.3MBから32.0MBに、常駐メモリは121.4MBから21.5MBに減りました
ARMv6向けには、custom_builderを手元のPC向けにビルドし、実行時にだけGOOS=linux GOARCH=arm GOARM=5を渡します
AWS IoT Coreに送るときは、QoSを1にし、client_idを端末のほかの接続と分けます
脚注
AWS Well-Architected Framework IoT LensのFailure managementのIOTREL10、IOTREL11、IOTREL12です。標準のライブラリの例として挙げられているのはFreeRTOSやAWSのSDKで、Telegrafは含まれていません。 ↩︎
Telegrafの出力を受け持つ処理(models/running_output.go)では、書き込みに失敗したメトリクスが次の書き込みのために残ります。バッファがあふれて捨てたときは、その件数が警告のログに出ます。 ↩︎ ↩︎
Telegrafの設定のドキュメントのagentの節です。 ↩︎ ↩︎ ↩︎
調べた版と資料は次のとおりです。Mosquittoは、mosquitto.confの説明と、v2.0.21のソースで確かめました。ソースはsrc/database.cとsrc/bridge.cです。溜める上限のmax_queued_messagesの初期値は1000件です。Vectorは、v0.58.0のmqtt sinkとバッファの説明を読みました。arm-unknown-linux-musleabi版の本体のELFの属性も見ています。属性はTag_CPU_archがv7で、NEONも含まれていました。Fluent Bitは、v5.1.2のプラグインの一覧でMQTTが入力だけであることを確認しました。バッファの扱いは、バッファの説明によります。collectdは、2020年9月の5.12.0のsrc/collectd.conf.podとsrc/mqtt.cです。AWS IoT Device Clientは、READMEと2025年5月のv1.10.1を参照しています。 ↩︎
How to Bridge Mosquitto MQTT Broker to AWS IoTの、2020年5月の更新版を参照しました。 ↩︎
Greengrass nucleusの説明と、nucleus liteのセットアップの文書です。Java版の文書では、一覧に無い環境について、動くかもしれないが試験はしていないとしています。 ↩︎
AWS IoT Device Defender detect feature availability changeです。Device DefenderのDetect機能は、2026年8月31日から新規の顧客に提供されなくなりました。この文書はそれに合わせたもので、想定しているのは待ち受けポートや通信量などのセキュリティ向けの値です。 ↩︎
custom_builderのREADMEを参照しました。 ↩︎ ↩︎ ↩︎
TelegrafのMakefileによると、build_toolsはenv -u GOOS -u GOARCH -u GOARM -- go buildで作られます。配布物はarmel版がGOARM := 5、armhf版がGOARM := 6で、リンカーのオプションは-w -sです。 ↩︎ ↩︎ ↩︎
go help environmentのGOARMの項に書かれています。goコマンドのドキュメントでも読めます。 ↩︎
常駐メモリは、確認用にoutputs.fileも加えてビルドした版で測りました。設定はJSONのキーなどが記事のものと少し異なりますが、入力と集計のプラグインは同じです。MBは1,000,000バイトとし、psの値(KiB)から換算しています。 ↩︎
スクリプトは起動から約20秒後、Telegrafは約80秒後にpsで見た値でした。 ↩︎
AWS IoT CoreのMQTTのドキュメントに、QoS 2が要求されてもブローカーはPUBACKやSUBACKを返さないと書かれています。 ↩︎
AWS IoT Coreのライフサイクルイベントのドキュメントにある、切断理由の表を参照しました。 ↩︎
JSONシリアライザーのREADMEです。バッチのときにmetricsという配列が付くことと、JSONataが1.5.4に限られることが書かれています。 ↩︎ ↩︎ ↩︎
【ハーネスエンジニアリング】AI-DLC v2で割り勘計算アプリを作ってみた
はじめに
最近はAIエージェントを使った開発がすっかり当たり前になってきています。AIエージェントに組み込んで使う、いわゆる「ハーネス」のツールやスキル、プラグインなども豊富にあって、どれを使えばいいのか迷ってしまうほどです。
本記事では、そうしたツールの一つであるAI-DLC v2を使って割り勘計算のシンプルなWebアプリを作り、AI駆動開発の流れを体験します。
結論からいうと、AIが質問し、人間が承認して進む流れは最後まで崩れず、アプリを完成させられました。ただし、承認時に表示される要約だけでは、回答で決めきれていなかった点に気づけず、レビュー結果のファイルを直接読む必要がありました。
!
本記事は、AI-DLC 2.10.0とGitHub Copilot CLI 1.0.88の組み合わせで、スコープpocを1回実行したときの記録です。
AI-DLCとは
AI-DLC(AI-Driven Development Life Cycle)は、AWS Labsが公開している、AIが主導して開発を進めるためのワークフローです。Claude Code、Kiro、Codex、Cursor、GitHub CopilotなどのAIコーディングツールに組み込んで使います。
AIからの質問に答えていくことで要件定義書や設計書、コードなどの成果物が生成され、人間がそれを承認していく流れで開発が進むのが特徴です。
開発は次の5つのフェーズを順に進みます。
フェーズ
やること
Initialization
作業フォルダの準備
Ideation
目的と範囲の整理
Inception
要件定義と設計
Construction
コード生成とテスト
Operation
デプロイと運用
各フェーズは、intent-capture(意図の把握)やrequirements-analysis(要件分析)といったステージ(全33種類)の組み合わせでできています。ステージの終わりには人間が承認する場面(承認ゲート)があり、承認するまで次のステージには進みません。
AI-DLC v1とv2の違い
AI-DLC v1は、InceptionとConstructionの2フェーズを、ステージごとに1回承認しながら進む流れでした。v2では、この流れが前後に広がり、作業の種類に合わせて実行するステージを選べるようになりました。
ステージの選び方のプリセットを、v2ではスコープと呼びます。新規プロダクトの最小版(MVP)を作るならmvp(23ステージ。Operationを省く)、新機能の開発ならfeature(33ステージすべて)、実現可能性の検証ならpoc(8ステージ)、バグ修正ならbugfix(9ステージ)というように、11種類が用意されています。v1と同じ流れはclassicとして残っています。どのスコープにも当てはまらない場合は、composerという仕組みがステージ構成を提案します。
ほかにもv2では、CopilotやClaude Codeなど複数のAIコーディングツールで同じエンジンが動くようになりました。成果物と進行状況は、監査ログとしてファイルに残ります。
導入方法
!
GitHub Copilot CLI(1.0.74以上)がインストール済みで、ログインできる状態を前提とします。Copilot CLI自体の導入手順は、GitHubの公式ドキュメントを参照してください。
1. AI-DLCをインストールする
macOS・Linux・WSLでは、次のコマンドでインストールします。ここではv2.10.0を指定しています。
ターミナル
curl -fsSL github.com/awslabs/aidlc-workflows/releases/downl… | sh -s -- --version 2.10.0
!
このコマンドはrootユーザーではなく一般ユーザーで実行してください。
WindowsのPowerShell向けの手順は、公式READMEを参照してください。
2. 作業フォルダに設定する
作業フォルダに移動し、AI-DLCをCopilotから使う設定をします。--harnessには、AI-DLCを組み込む先のAIコーディングツールを指定します。
ターミナル
mkdir warikan && cd warikan
aidlc config --harness copilot
対話で聞かれる項目は、次のように答えました。
項目
選択
Record settings
project
Models
Enter(Copilotのセッションモデルを使う)
Provider
1(keep current)
3. 動作を確認する
同じフォルダでCopilot CLIを起動します。
ターミナル
copilot
!
フォルダを信頼するかを聞かれたら、「常に信頼する」選択肢を選びます。信頼しないと、AI-DLCのフックが警告なしに無効になります。
起動したら、次を入力します。
Copilot内で入力
/aidlc --doctor
0 problemsと表示されれば準備完了です。
割り勘計算アプリを作る: 8ステージ・承認5回で完成
起動: 指示は「フロントのみ・保存なし」だけ
AI-DLCからの質問で仕様が固まっていく様子を見たかったので、起動時の指示は最小限にしました。技術スタックや細かい仕様は書いていません。
Copilot内で入力
/aidlc 割り勘計算アプリを作りたい。ブラウザだけで動くフロントエンドのみの Webアプリで、データは保存しない
既製のスコープがぴったり合わないとして、進め方を聞かれました。
Copilot内の表示
既製のプランがこのタスクにぴったり合いません。どう進めますか?
プラン:
❯ compose (recommended)
express
mvp
feature
Other (type your answer)
composeを選ぶと、composerがステージ構成を提案してきます。提案には、33ステージそれぞれを実行するか(EXECUTE)飛ばすか(SKIP)とその理由が並びます。今回は8ステージを実行、25ステージを飛ばす構成で、既製のpocと同じでした。飛ばされた理由は「単機能・静的フロントエンドのみで、外部API・インフラ・運用要件がないため」でした。
この提案をApproveすると、ワークフローが始まります。
各ステージの進み方
質問のあるステージは、どれも同じ順序で進みます。
AI-DLCが質問ファイルを作る。各質問にはA〜Dの選択肢と「Other(自由記述)」がある
人間が質問に答える。チャットで順に答える(Guide me)か、ファイルを直接編集する
AI-DLCが回答の要約を示し、合っているかを確認する
成果物が生成され、AIによるレビューが走る。判定(READY / NOT-READY)と指摘は、reviews/フォルダにファイルとして書き出される
人間がApprove(承認)かRequest Changes(修正依頼)を選ぶ
選択肢で迷ったときは、Otherで相談できます。AI-DLCが選択肢の違いと実装への影響を整理し、聞き直してくれます。
Ideation(目的と範囲の整理): 目的と範囲を5つの質問で決定
最初のステージintent-captureでは、課題と利用者、成功の基準、きっかけ、決定権、製品の範囲の5問を聞かれました。端数の扱いのような細かい仕様は、ここではまだ聞かれません。
一番迷ったのは、課題と利用者を聞くQ1でした。Otherで相談すると、「均等割りのみ」「傾斜配分まで」「品目ごとの分担まで」の3段階に整理して聞き直してくれました。今回は傾斜配分(人によって割合や金額を変える)まで含め、品目ごとの分担は範囲外にしました。
成果物は、意図の記述書とステークホルダーマップです。レビュー結果はREADYで、軽微な指摘が3件ありました。「成功の基準が測定できない」などで、次の要件分析で具体化するとされたため、そのまま承認しました。
Inception(要件定義と設計): レビューがNOT-READYで差し戻し
requirements-analysisでは、仕様の細部を7問聞かれました。Ideationの指摘を受けて、「結果が表示されるまでの速さ」のような測れる基準の質問も入っています。ここでは、主な2つの質問を紹介します。
requirements-analysis-questions.md(抜粋)
Q1. 端数の扱い
A. 1円単位で切り上げ、集める合計が少し多くなる分は「余り」として画面に明示する
B. 1円単位で切り捨て、足りない分を「幹事が負担する額」として画面に明示する
C. 1円単位に丸め、余りの 1円ずつを何人かに配分して、合計をちょうど一致させる
D. 100円単位など、切り上げる単位を利用者が選べる
Q2. 傾斜配分の指定方法
A. 人ごとに割合(重み)を指定する(例: 先輩1.5、後輩1、幹事0.5)
B. 人ごとに固定金額を指定し、残りを他の人で割る(例: Aさんは 2,000円、残りは均等)
C. Aと Bの両方に対応する
Q1はD、Q2はCと答えました。残りの5問では、日本円の整数のみ、参加人数は2〜50人、入力のたびに1秒以内で自動再計算、日本語のみ、技術はおまかせ、を選びました。
ところが、Q1のDには「切り上げで多く集まった分(余り)をどう扱うか」が含まれていませんでした。回答した時点では、余りの扱いを決めていないことに気づいていませんでした。回答の要約でも「矛盾なし」とされ、聞き直されることはありませんでした。
これに気づいたのは、要件定義書の生成後に走ったレビューです。判定はNOT-READY、指摘は6件(重大度は高2・中3・低1)でした。高の2件は「固定金額の人にも切り上げ単位を適用するか」と「切り上げ単位の選択肢の食い違い」で、どちらもQ1の考慮漏れが原因です。AIが自分で作った要件定義書をセルフレビューし、回答した筆者も見落としていた点を指摘できていたことには感心しました。
Request Changesを選ぶと、「指摘をすべて反映し、未解決の点はAIが妥当な案を決める」などの進め方が示されます。今回はAIに決めさせず、次のように具体的な修正を指示しました(FR・NFRは、要件定義書で機能要件・非機能要件に振られた番号です)。
Copilot内で入力
次のとおり修正してください。
1. 固定金額の人には切り上げ単位を適用せず、指定した金額のままとする。切り上げは割合で配分する人だけに適用する
2. 切り上げ単位の選択肢は 1円・10円・100円の 3つとし、初期値は 1円とする。
3. 割合配分で丸めが起きる場合の余りの定義と数値例を追加する。FR3.3は余りの定義に合わせて書き直す
4. 異常系を追加する:合計金額は 1円以上、割合は0より大きい数、全員が固定金額で合計金額と一致しない場合はエラー
5. 出典のない要件(NFR4〜6、FR2.3〜2.5)は、前提として扱うことを明記する
6. 1秒の計測条件と、スマホの画面幅(例: 360px)を明記する
修正後の再レビューはREADYでした。受け入れ基準の数値例4件も、レビューの中で検算されています。低の指摘が3件残りましたが、そのまま承認しました。
Construction(コード生成とテスト): 計画を承認し、コードとテストを生成
Constructionのcode-generationでは、質問の代わりに、コード生成の計画への承認を求められます。提示された計画の要点は次のとおりです。
項目
内容
形態
素のHTML/CSS/JavaScript。サーバー・ビルド・外部ライブラリなし
計算ロジック
src/calc.js。浮動小数点の誤差を避けるため、BigIntの整数計算のみ
画面
index.html・styles.css・src/app.js。入力のたびに自動で再計算
テスト
Node.js標準のnode:testで計算ロジックを単体テスト。画面は手動で確認
技術スタックは、Inceptionで「おまかせ」と答えていたため、ここで初めて決まりました。
計画には、要件にない前提が1つ入っていました。「割合は小数第2位まで、上限なし」です。これはInceptionの再レビューで残した低の指摘(割合の桁数と上限が未定義)への対応で、計画自身が「承認時に確認してほしい」と明記していました。検証用の小さなアプリなので、この前提のまま承認しました。
承認すると、作業フォルダにアプリ本体と単体テストが生成されます。
フォルダ構成
warikan/
├── index.html
├── styles.css
├── package.json
├── README.md
├── src/
│ ├── app.js # 画面
│ └── calc.js # 計算ロジック
└── tests/
└── calc.test.js # 単体テスト(13件)
承認時の要約は「重大な指摘なし」でした。ところが、reviews/review-01.mdを開くと、指摘が3件(低2・情報1)書かれていました。たとえば次の2件です。
index.htmlをダブルクリックで直接開くと動かず、静的サーバーが必要になる
画面を自動で確認するテストがない
どちらもpocの範囲としては許容できるので、修正せずに承認しました。ただ、要約だけを見ていたら、気づかずに通していたはずです。要約を鵜呑みにせず、ファイルを自分の目で確かめておいてよかったと思いました。
続くbuild-and-testでは、テストが実行されました。13件すべて成功し、50人分の計算は、計算ロジックだけで約0.5〜1.3msでした。画面の確認はAI-DLCの工程に含まれず、人に委ねられます。そこでブラウザで開き、次の4点を確かめてから最後の承認をしました。
幅360pxの画面でも操作できる
画面が日本語で表示される
入力のたびに自動で再計算される
通信が発生しない
できたアプリ
合計金額と人数を入れると、1人あたりの額が自動で表示されます。人ごとに割合か固定金額を指定でき、切り上げ単位(1円・10円・100円)で生じた余りも表示されます。
まとめ
AI-DLC v2で割り勘計算アプリを作り、8ステージと承認ゲート5回を経て完成させました。起動時に伝えたのは「フロントのみ・保存なし」だけでしたが、目的と範囲、仕様の細部、技術スタックの順に、段階を分けて聞いてくれました。端数の扱いのように自分でも詰めきれていなかった論点を、質問の形で突きつけてくれるのは心強いです。また、要件定義書等のドキュメント以外に、こうした質疑応答の内容もすべてファイルとして保存しておいてくれるのは、作業を中断して再開するときや、後から振り返るのに非常に役立つと思いました。
その一方で、承認時に表示される要約はあまり当てになりませんでした。Inceptionでは、要約が「矛盾なし」とした回答に、レビューがNOT-READYを出しました。Constructionでは、要約が「重大な指摘なし」だったのに、レビューファイルには指摘が3件ありました。AI-DLCを使うときは、承認の前にreviews/のファイルと計画の前提を開いて読むことをおすすめします。また、アプリの画面はAI-DLCの工程では確認されないので、人間が確かめる必要があります。
今回は小さなアプリの作成をpocで1回試しただけなので、ほかのスコープや題材では違う動きをするかもしれません。機会があればmvpやfeatureのような、ステージの多いスコープも試してみたいと思います。
Mentions
AIエージェントが開発中のWebアプリをリアルタイムプレビューできるBedrock AgentCore Session endpointsを試してみた :::note warn この記事はAI(Codex)と一緒に作成しました。 内容は2026年11月時点の検証結果です。 ::: はじめに 2026年10月30日、AWSから以下の記事が公開されました。 > Preview web apps live as your coding agent builds them with Amazon Bedrock AgentCore runtime AIエージェントがWebアプリを開発している最中に、 そのWebアプリをブラウザからリアルタイムにプレビューできるという内容です。 コーディングエージェントを使えば簡単にWebアプリを作れる時代になりましたが、 クラウド上で動くAIエージェントが作ったものを確認するには、 コードを取得してローカルで起動したりする手間がかかることもあります。 そこでAmazon Bedrock AgentCore RuntimeのSession endpointsという機能を使うと、 AIエージェントが動いている実行環境内のWebサーバーに、認証付きでアクセスできます。 つまり、AIエージェントがコードを編集した瞬間、その変更をブラウザで確認できるわけです。面白そうなので触ってみました。 !{348A9BEA-EF0D-4DCD-B86C-DBEFAC81422B}.png > 赤枠がエージェントとのチャット画面、青枠がWebアプリのプレビュー画面です。 > 赤枠で相談した内容が青枠に即時反映されます。 今回はAWS公式サンプルを使って、日本向けの推論プロファイルで実際に動作を検証してみました。 実際に動かしてみた 今回やってみたことは、次の3つです。 1. AgentCore Runtime上でコーディングエージェントを動かす 2. エージェントが編集しているWebサイトをブラウザから表示する 3. 日本語で変更を指示し、Webサイトがリアルタイムに更新されることを確認する 実際には、コーヒーショップのサンプルWebサイトに対して、次の指示を送りました。 > Webサイト全体を日本語に翻訳してください。 > デザインやレイアウトは変更しないでください。 結果として、AIエージェントがindex.htmlとsrc/main.jsを編集し、 変更後のWebサイトをブラウザから確認できました。(翻訳は微妙だけど) ■変更前(英文) !image.png ■変更後(日本語訳) !image.png セッション分離や認証なしアクセス、WebSocketの更新通知も気になったので、追加で検証してみました。モデルIDやRuntime更新で地味にハマったので、その対処もついでに紹介します。 Session endpointsとは? Session endpointsは、AgentCore Runtime内で動いている HTTPサーバーなどにアクセスできる仕組みです。 例えば、AIエージェントが同じ実行環境内でWebアプリの開発サーバーを起動していれば、 そのWebアプリを外部からプレビューできます。 通常のRuntime呼び出しとの違い AgentCore Runtimeには、AIエージェントを呼び出すための仕組みがもともとあります。 一方、Session endpointsは、AIエージェント自身ではなく、エージェントと同じセッション内で動いている別のアプリケーションにアクセスするための機能です。 | 項目 | Runtimeの通常呼び出し | Session endpoints | |---|---|---| | 主な用途 | AIエージェントの実行 | セッション内のアプリへのアクセス | | アクセス対象 | エージェントのAPI | 許可したポートのWebサーバーなど | | 今回の用途 | Webサイトの編集を指示 | 編集中のWebサイトを表示 | | 通信 | Runtime API | HTTP、WebSocketなど | Session endpointsでは、あらかじめ許可したポートだけにアクセスできます。 また、URLを知っているだけで誰でもアクセスできるわけではなく、IAMなどによる認証が必要です。 今回の構成 flowchart TD A["ローカルPCのブラウザ"] --> B["PreviewApp"] B -->|"編集指示"| C["AgentCore Runtime API"] C --> D["AIエージェント"] D --> E["Amazon Bedrock<br/>Claude Haiku 5.5"] D --> F["Webサイトのファイルを編集"] F --> G["Vite開発サーバー<br/>Port 8090"] G -->|"Session endpoints<br/>HTTP / WebSocket"| B B -->|"プレビューを表示"| A 今回のサンプルでは、主に3つのポートを使用します。 | ポート | 用途 | |---|---| | 8080 | AIエージェントの呼び出し | | 8090 | ViteによるWebサイトのプレビュー | | 8081 | ステータス確認用API | Viteはファイルの変更を検知してブラウザの表示を更新してくれるツールです。 今回の構成では、ViteのWebSocket通信もSession endpoints経由で転送されるので、 ブラウザを手動更新せずに変更内容を確認できます。 検証方法 AWS CLIの認証設定が完了していることを前提とします。 また、RuntimeやIAMロール、S3リソースを作成する権限に加え、 Runtimeの呼び出しとSession endpointsへのアクセス権限が必要です。 公式サンプルのPython依存関係は、bedrock-agentcore>=1.25.2、boto3>=1.43.122でした。 手順① 公式サンプルを取得する まず、AWS公式のGitHubリポジトリを取得します。 (WSLのターミナルから実行しました。) git clone https://github.com/awslabs/agentcore-samples.git 今回使用するディレクトリに移動します。 cd agentcore-samples/01-features/02-host-your-agent/01-runtime/03-advanced/16-session-endpoints このディレクトリには、以下のようなファイルがあります。 | ファイル | 役割 | |---|---| | deploy.py | AgentCore Runtimeのデプロイ | | cleanup.py | 作成したAWSリソースの削除 | | preview_app.py | ローカルのプレビュー画面の起動 | | session_endpoints_preview.ipynb | 公式の検証用Notebook | | agent/app.ts | AIエージェントの実装 | | site/ | サンプルWebサイト | 手順② Python環境を準備する Pythonの仮想環境を作成します。 uv venv 仮想環境を有効化します。 source .venv/bin/activate 依存パッケージをインストールします。 uv pip install -r requirements.txt これでPython側の準備は完了です。 手順③ 利用するモデルIDを確認する 公式サンプルでは、モデルIDのデフォルト値として us.anthropic.claude-haiku-5-5が指定されています。 私は日本向け推論プロファイルを利用したかったので以下のモデルIDを使いました。 jp.anthropic.claude-haiku-5-5 なお、モデルIDと推論プロファイルのIDは別物で、一覧に表示されるモデルでも直接指定して呼べるとは限りません。利用できるモデルや推論プロファイルはリージョンやアカウントの権限によって変わるので、上記のIDがどの環境でもそのまま使えるとは限らない点だけ補足しておきます。 手順④ AgentCore Runtimeをデプロイする 公式サンプルのdeploy.pyを実行します。 日本向け推論プロファイル利用時はモデルIDを明示的に指定しましょう。 python deploy.py \ --model jp.anthropic.claude-haiku-5-5 デプロイが完了すると、RuntimeのステータスがREADYになります。 今回のサンプルは、Node.jsのアプリケーションをZIP形式でS3にアップロードし、 AgentCore Runtimeで実行する構成です。 (つまりDockerイメージやECRリポジトリを自分で準備する必要がないわけですね!) Session endpoints用のポート設定 deploy.pyでは、次のような設定が使われています。 protocolConfiguration={ "serverProtocol": "HTTP", "port": 8080, "path": "/invocations" }, allowedPorts=[ {"port": 8090}, {"port": 8081} ] - protocolConfigurationは、AIエージェント本体の呼び出し先を設定しています。 - allowedPortsは、Session endpoints経由でアクセスを許可するポートです。 今回はWebサイトのプレビュー用に8090、ステータス確認用に8081を指定しています。 (allowedPortsを設定するだけでWebサーバーが起動するわけではありません) 手順⑤ プレビュー画面を起動する Runtimeのデプロイが完了したら、プレビュー画面を起動します。 今回は次のPythonコードを実行しました。 python - <<'PY' import time from preview_app import PreviewApp app = PreviewApp().start() print(f"プレビューURL: {app.url}", flush=True) try: while True: time.sleep(1) except KeyboardInterrupt: pass finally: app.stop() print("セッションを終了しました") PY 成功すると、プレビューURLが表示されます。 プレビューURL: http://localhost:8087 ブラウザからこのURLを開きます。 すると最初にお見せした、 左側にAIエージェントとのチャット画面、右側にコーヒーショップのサンプルWebサイト が表示されます。 !image.png 右側のWebサイトはローカルPC上で動いているわけではなく、 AgentCore Runtimeのセッション内で動いていて、Session endpointsを通じて ローカルブラウザに表示されています。 内部では何が起きているのか? PreviewAppの起動処理を見てみると、こうなっています。 answer = self.invoke({"prompt": ""}) started = json.loads(answer["response"].read()) self.endpoint = self.client.session_endpoint_client( session_endpoint_url(answer) ) self.local = self.endpoint.open_local_endpoint( port=self.config["preview_port"], local_port=self.proxy_port ).start() まずInvokeAgentRuntimeでセッションを起動し、 レスポンスからSession endpointのURLを取得して、ローカル側に接続口を作っています。 このローカル側の接続口がAWS認証情報を付けてリクエストを転送してくれるので、 ブラウザ自身がAWSのSigV4署名を実装する必要はありません。地味に便利な仕組みです。 手順⑥ AIエージェントに日本語で変更を指示する いよいよWebサイトを変更してみます。 左側のチャット欄に、以下の指示を入力しました。 !{E42D5922-5C0B-4331-B6B9-9410A8750ACA}.png するとコードを触っているログが表示され、 最終的には、変更が完了したことを示すメッセージが表示されました。 実際に、Webサイト内の英語が日本語に変更され、ブラウザ側にも反映されました。 !{CEE37FE4-6060-4EFC-9117-6FF7F0CD7AC5}.png AIエージェントがコードを編集し、 Webサイトの変更内容をちゃんと確認できるところまで成功しました。 なぜリアルタイムに反映されるのか? 流れはこんな感じです。 flowchart TD A["ユーザーが変更を指示"] --> B["AIエージェントがコードを編集"] B --> C["ファイルを保存"] C --> D["Viteが変更を検知"] D --> E["WebSocketでブラウザに通知"] E --> F["プレビュー画面が更新"] Session endpointsは、通常のHTTPリクエストだけでなく WebSocket通信も転送できるので、Viteのホットリロード機能をそのまま使えます。 エージェントがファイルを触るたびに、Webサイトの変更をブラウザ側で確認できる、というわけです。 (実際のWebSocket通信については、後述の「追加で気になったことを検証」で詳しく紹介します。) 手順⑦ 後片付けをする プレビューを実行しているターミナルでCtrl + Cを押し、ローカルのプレビューとプロキシを停止します。 続いて、公式サンプルに含まれるクリーンアップスクリプトを実行します。 python cleanup.py このスクリプトで、サンプルが作ったRuntime、S3上のコード、IAMロールなどが削除されます。 ただしS3バケット自体やCloudWatch Logsなどが残る場合があるので、他の用途で使っていないか確認してから整理してください。 追加で気になったことを検証 公式サンプルを動かしてみると、認証やセッション分離、WebSocket通信など、内部の仕組みも気になってきました。そこで、いくつか追加で検証してみました。 ①認証なしでアクセスするとHTTP 403が返る AWS側のSession endpoint URLに、認証署名なしのcurlでアクセスしてみました。 read -r -p "Session endpoint URL: " ENDPOINT_URL curl -sS -o /dev/null \ -w 'HTTP Status: %{http_code}\n' \ --max-time 15 \ "${ENDPOINT_URL%/}/" HTTP Status: 403 ちゃんと403で弾かれました。一方、AWS SDKを使うPreviewApp経由ではアクセスできています。 (http://localhost:8088はローカルのPreviewAppなので、こっちが開けるのは当然です。Session endpoint自体が匿名公開されているわけではありません。403の詳細理由やIAMロール違いまでは追っていませんが、「認証が必要」という説明どおりの挙動でした。) ②複数セッションはちゃんと独立していた 同じAgentCore Runtimeで2つのセッションを同時起動し、変更が混ざらないか確認しました。 ターミナルAとBで、それぞれ以下のコードを実行します。事前に同じサンプルディレクトリへ移動し、仮想環境を有効化しておきます。 ターミナルA: python - <<'PY' import time from preview_app import PreviewApp app = PreviewApp(ui_port=8087, proxy_port=8091).start() try: print(app.session_id, app.url, flush=True) while True: time.sleep(1) except KeyboardInterrupt: pass finally: app.stop() PY ターミナルB: python - <<'PY' import time from preview_app import PreviewApp app = PreviewApp(ui_port=8088, proxy_port=8092).start() try: print(app.session_id, app.url, flush=True) while True: time.sleep(1) except KeyboardInterrupt: pass finally: app.stop() PY ブラウザでhttp://localhost:8087とhttp://localhost:8088を開き、それぞれ次の指示を送ります。 - Session A:Webサイトの背景色を赤色に変更 - Session B:Webサイトの背景色を青色に変更 !image.png 結果、Session Aは赤、Session Bは青になり、お互いの変更は混ざりませんでした。同じRuntime上でもセッションごとに異なる編集結果が保持されていました(ファイルシステムの内部的な隔離方式や、IAMユーザー間の権限周りまでは今回は検証していません)。 ③WebSocketでViteの更新通知がちゃんと転送された Session endpointsが本当にWebSocket通信も転送してくれるのか気になったので、開発サーバーをブラウザで開いてEdgeの開発者ツールで「ネットワーク」→「Socket」を見てみました。 HTTP/1.1 101 Switching Protocols Connection: Upgrade Upgrade: websocket Sec-WebSocket-Protocol: vite-hmr 101 Switching ProtocolsはWebSocketへの切り替え成功を示すレスポンスで、vite-hmrはViteのホットリロードのサブプロトコルです。ちゃんとつながっていますね。 「メッセージ」タブを開いた状態で、AIエージェントに「Webサイトのメインタイトルの文字色だけを緑色に変更してください」と指示してみると、まず接続完了のメッセージが来て、 !image.png その後、こんな更新通知が届きました(主要項目を抜粋)。 { "type": "update", "updates": [ { "type": "css-update", "path": "/src/style.css?direct", "acceptedPath": "/src/style.css?direct", "timestamp": 1793593126711 } ] } css-updateは、ViteがCSSの変更を検知してブラウザへ送る更新通知です(ページ全体を再読み込みするfull-reloadとは別物)。 flowchart TD A["ユーザーが日本語で変更を指示"] --> B["AIエージェントがCSSを編集"] B --> C["Viteが変更を検知"] C --> D["WebSocketでcss-updateを送信"] D --> E["Session endpointsが通信を転送"] E --> F["ローカルプロキシ経由でブラウザが受信"] ファイル変更の検知と更新通知はVite側の仕事で、Session endpointsは そのWebSocket通信を転送する役割を担っています。そのおかげでAIがCSSを編集した直後に ブラウザ側へちゃんと通知が届くところまで確認できました (今回確認できたのはCSSの更新だけなので、HTMLやJSの変更も必ず再読み込みなしで反映される、と断定はできません)。 ④プレビューを止めるとどうなるか PreviewAppをCtrl + Cで止めると、ローカルのプレビュー画面にはアクセスできなくなりました。新しいセッションを起動しても、前の編集内容は引き継がれません。 (あくまでローカルのPreviewAppとプロキシを止めた結果の観察です。AWS側のセッションがその瞬間に終了したかどうかまでは確認していません。) ハマったポイント モデルIDを後から変更するとSession endpointが取得できなくなった 手順どおりに進めるだけなら踏まなくて済む話ですが、 せっかくハマったので記録しておきます。 今回、モデルIDを変更する際に地味にハマりました。 最初はデフォルトのモデルIDでデプロイしてしまったので、 Runtimeの環境変数を後から更新しました。モデルIDの変更自体は無事成功しました。 { "Status": "READY", "Environment": { "MODEL_ID": "jp.anthropic.claude-haiku-5-5" } } ところが、その後プレビューを起動すると、次のエラーが出ました。 AttributeError: 'NoneType' object has no attribute 'rstrip' エラーが発生した箇所はこちら。 self.endpoint = self.client.session_endpoint_client( session_endpoint_url(answer) ) つまり、Session endpointのURLが取れていなかったわけです。 調べてみると、 今回のRuntime更新処理では既存の設定を取得して更新APIにそのまま渡していたのですが、 get_agent_runtime()のレスポンスにはallowedPortsが含まれておらず、 更新リクエストでもallowedPortsを指定していませんでした。 更新のタイミングでSession endpoints用のポート設定が引き継がれずに消えてしまうのかもしれません(勘ですがallowedPortsを明示して再更新したら直ったので…)。 Boto3からupdate_agent_runtime()を呼び出し、必要なポートを明示的に指定したところ、解消しました。 import boto3 client = boto3.client( "bedrock-agentcore-control", region_name="ap-northeast-1" ) runtime_id = "YOUR_RUNTIME_ID" current = client.get_agent_runtime( agentRuntimeId=runtime_id ) params = { "agentRuntimeId": runtime_id, "agentRuntimeArtifact": current["agentRuntimeArtifact"], "roleArn": current["roleArn"], "networkConfiguration": current["networkConfiguration"], "protocolConfiguration": current["protocolConfiguration"], "lifecycleConfiguration": current["lifecycleConfiguration"], "environmentVariables": { **current.get("environmentVariables", {}), "MODEL_ID": "jp.anthropic.claude-haiku-5-5" }, "allowedPorts": [ {"port": 8090}, {"port": 8081} ] } result = client.update_agent_runtime(**params) print(result["status"]) これでRuntimeが新しいバージョンに更新され、 再度PreviewAppを起動したら正常にSession endpointsが使えるようになりました。 使いどころと注意点 嬉しかったポイント 今回触ってみていいなと感じたのは、 1. AIエージェントが作ったWebアプリの変更を、その実行環境のまま確認できる 1. AIエージェントとWebアプリ(成果物)を同期する仕組みを別途用意しなくてよい 1. Webサイトに限らず管理画面や開発用APIなどSession endpointsが使える対象なら同じ仕組みを応用できる 1. セッション単位でアクセス先が分離されるため複数ユーザーが同時に使う構成にも展開できそう 使う際の注意点 今回触ってみて気をつけたいと感じたのは、 1. Session endpointsはあくまで開発中の確認用で、Webサイトを一般公開するホスティングとは別物という点(公開するならAmplify Hostingなどが必要) 1. AIエージェントが編集したファイルはRuntimeのセッション内にあるため、セッション終了や実行環境の置き換えで失われうる点(長期保存したいならS3などへの保存を検討) 1. AgentCore RuntimeやBedrockの呼び出しには料金が発生するため、検証後は不要なRuntimeやリソースを削除する必要がある まとめ 今回は、Amazon Bedrock AgentCore RuntimeのSession endpointsを使って、 AIエージェントが開発しているWebアプリをリアルタイムにプレビューする機能を触ってみました。 変更指示、コード編集、ブラウザへ反映までを一連の流れで確認できました。 AIエージェントはコードの生成だけでなく、構築・実行・テストへと担当領域を どんどん広げているので、こういう仕組みは重要になっていきそうです。 個人的には、PoC環境の構築に使えそうだなと感じました。 参考資料 - AWS公式ブログ:Preview web apps live as your coding agent builds them with Amazon Bedrock AgentCore runtime - AWS公式GitHub:agentcore-samples - 今回使用したサンプル:16-session-endpoints - Amazon Bedrock AgentCore ドキュメント
> /aidlc-session-cost and /aidlc-replay print to the terminal and write nothing; /aidlc-outcomes-pack is the only one that writes a file (OUTCOMES.md at the workspace root). github.com/awslabs/aidlc-workflows/blob/main/docs…
SOCI lazy loading: start Lambda containers faster SOCI lazy loading lets a Lambda function on Lambda Managed Instances start before its container image has been fully downloaded. AWS announced it on October 28, 2026, and quotes startup times that are up to 70 percent shorter. I care less about that percentage than about two questions: who actually benefits from the extra build step, and where does it cause you trouble? Container images are often the escape hatch on Lambda for anything that does not fit into a 250 MB ZIP package: ML libraries, models, PDF tools, entire runtimes. Those images quickly grow to several gigabytes. In this article, we build an example from scratch together: a small book recommendation service for a library, an image with four language models, the SOCI index, a CDK stack with a capacity provider and a script that lets you measure the effect yourself. > In short > > SOCI places a table of contents next to your container image, so Lambda on Managed Instances only fetches the files your function actually reads. It pays off most for large images where each function needs only part of the content. The biggest pitfall is the format: Lambda accepts only a SOCI index v2 inside an image index with exactly two entries, and only single-architecture images. What you will learn in this article - What Lambda Managed Instances are and when they fit your workload - How SOCI lazy loading fetches files on demand - How to create a SOCI index with nerdctl and push it to ECR - How to set up a capacity provider and function with AWS CDK in TypeScript - How to measure startup times with and without SOCI yourself What are Lambda Managed Instances? Lambda Managed Instances (LMI for short) are a second way to run Lambda functions. Instead of the shared Lambda fleet, your functions run on EC2 instances in your own account. Lambda still takes care of launching, patching, routing and scaling those instances. You only decide where they may run and on which hardware. The central building block is the capacity provider. It describes the VPC, subnets, security groups, architecture, allowed instance types and an upper limit for vCPUs. You then attach a function to that provider and publish a version. Only when you publish does Lambda launch instances, three by default for spreading across Availability Zones, plus three execution environments, before the version counts as ACTIVE. How it differs from regular Lambda | | Lambda (default) | Lambda Managed Instances | | --- | --- | --- | | Invocations per environment | one at a time | several at the same time | | Isolation | Firecracker microVMs on a shared fleet | containers on EC2 Nitro instances in your account | | Billing | per request and duration | EC2 price plus a 15% management fee | | Scaling | on demand, down to zero | asynchronously based on CPU utilization, not to zero | | Good fit for | irregular traffic | high, predictable traffic | For Node.js, LMI uses worker threads, and initialization runs once per worker thread. That matters for our example: if you load a large model into memory, expect it to sit in memory once per thread. In addition, concurrent requests in the same thread share the global state. Anything you create outside the handler should therefore only be read and never modified. If you want to refresh the regular Lambda basics first, my article Deploy a Lambda function with CDK and TypeScript covers the standard path with a ZIP package. Why large images slow down Managed Instances Without SOCI, Lambda has to download and unpack the complete image on every instance before an execution environment can start there. That does not happen just once when you publish. It also happens whenever the capacity provider launches new instances because load is rising. With a 5 GB image, you are mostly waiting on network and disk during this phase. The catch: a large part of that data is often never read. An image with PyTorch, several models and half an operating system contains far more than a single invocation needs. This is where SOCI lazy loading helps. How SOCI lazy loading works SOCI stands for Seekable OCI. OCI is the open standard for container images. The project comes from AWS and is open source under Apache-2.0. SOCI does not change your image. Instead, it creates an additional index that records for each file in which layer and at which position it is stored. With this table of contents, Lambda can fetch individual files from ECR instead of downloading everything in one go. On Lambda Managed Instances, the flow looks like this: you push the image and the SOCI index together to ECR under a separate tag. That tag points to an image index with two entries, the regular image manifest and the SOCI index. If your function points to this tag, Lambda detects the index when you publish the version and turns on lazy loading. There are no further settings. > 🎬 Startup with and without SOCI – this animated diagram only runs on my site: watch it on sascha-ms.de When the files are fetched Files your init code reads are fetched by Lambda during initialization. Anything the handler touches only later gets pulled in during that invocation, which then takes correspondingly longer. According to the documentation, Lambda does not download the rest of the image in the background. That gives you the most important rule for your code: import every library and read every file you need in the init phase. Then your invocation durations stay consistent. !Diagram: five steps from an arm64 image via nerdctl to a SOCI index and a Lambda function using tag v1-soci From a plain image to an image index with a SOCI index: five steps to the function. Prerequisites for SOCI lazy loading - An AWS account in a Region where Lambda Managed Instances are available, and AWS CLI v2 - Node.js 22 or later, Docker with buildx and AWS CDK v2 (here aws-cdk-lib 2.275.0) - For the SOCI step, a Linux machine with containerd, for example a small EC2 instance with Amazon Linux 2023 and a Graviton processor - A function packaged as a container image for one architecture, here arm64 - The jq command line tool and basic knowledge of TypeScript and CDK A quick glossary: ECR is the AWS container registry. nerdctl is a Docker-like command line tool for containerd, and the SOCI snapshotter is the containerd extension that can create and read SOCI indexes. Setting up SOCI lazy loading step by step Our example is a recommendation service for a library. For every book ID, it returns the five most similar books. It computes the similarity from vectors stored in one binary file per language. The image contains models for German, English, French and Spanish, but the function reads only one of them. I chose this on purpose, because exactly this pattern shows up in real images all the time. Step 1: Create the project mkdir lmi-soci-library && cd lmi-soci-library mkdir -p src scripts lib bin test npm init -y Replace the generated package.json with this version. "type": "module" makes Node.js load the compiled files as ES modules, including inside the container. { "name": "lmi-soci-library", "version": "1.0.0", "private": true, "type": "module", "scripts": { "build": "tsc -p tsconfig.build.json", "models": "node --experimental-strip-types scripts/make-models.ts", "test": "node --experimental-strip-types --test test/*.test.ts", "measure": "node --experimental-strip-types scripts/measure-start.ts" }, "dependencies": {}, "devDependencies": { "@aws-sdk/client-lambda": "3.1159.0", "@types/node": "^22.19.0", "aws-cdk": "2.1146.1", "aws-cdk-lib": "2.275.0", "constructs": "^10.4.2", "typescript": "7.0.2" } } You run the scripts under scripts/ and the tests directly from TypeScript, using the Node.js 22 option --experimental-strip-types. For the container, on the other hand, you compile to dist/. Two small configuration files handle this: // tsconfig.json { "compilerOptions": { "target": "es2023", "module": "nodenext", "moduleResolution": "nodenext", "strict": true, "allowImportingTsExtensions": true, "rewriteRelativeImportExtensions": true, "skipLibCheck": true, "types": ["node"], "outDir": "dist" }, "include": ["src", "scripts", "lib", "bin", "test"] } // tsconfig.build.json { "extends": "./tsconfig.json", "compilerOptions": { "rootDir": "src", "outDir": "dist" }, "include": ["src"] } npm install Step 2: Load and compare vectors The module reads a file in the format “magic bytes, count, dimensions, values” and compares books using cosine similarity. Cosine similarity measures how closely two vectors point in the same direction: 1 means very similar, 0 means unrelated. // src/vectors.ts import { readFileSync } from 'node:fs'; export interface BookVectors { count: number; dims: number; data: Float32Array; } const MAGIC = 'BKV1'; const HEADER_BYTES = 12; /** Writes vectors in a simple binary format: "BKV1", count, dims, float32 values. */ export function encodeVectors(v: BookVectors): Buffer { const header = Buffer.alloc(HEADER_BYTES); header.write(MAGIC, 0, 'ascii'); header.writeUInt32LE(v.count, 4); header.writeUInt32LE(v.dims, 8); const body = Buffer.from(v.data.buffer, v.data.byteOffset, v.data.byteLength); return Buffer.concat([header, body]); } export function decodeVectors(buf: Buffer): BookVectors { if (buf.subarray(0, 4).toString('ascii') !== MAGIC) { throw new Error('Unknown file format: magic bytes missing'); } const count = buf.readUInt32LE(4); const dims = buf.readUInt32LE(8); const expected = HEADER_BYTES + count * dims * 4; if (buf.byteLength !== expected) { throw new Error(`File size ${buf.byteLength} does not match ${expected} bytes`); } // Copy into an aligned buffer so Float32Array works on every offset const data = new Float32Array(count * dims); data.set(new Float32Array(buf.buffer.slice(buf.byteOffset + HEADER_BYTES, buf.byteOffset + expected))); return { count, dims, data }; } export function loadVectors(path: string): BookVectors { return decodeVectors(readFileSync(path)); } function cosine(v: BookVectors, a: number, b: number): number { let dot = 0; let na = 0; let nb = 0; const oa = a * v.dims; const ob = b * v.dims; for (let i = 0; i < v.dims; i++) { const x = v.data[oa + i]; const y = v.data[ob + i]; dot += x * y; na += x * x; nb += y * y; } return na === 0 || nb === 0 ? 0 : dot / Math.sqrt(na * nb); } export interface Recommendation { bookId: number; score: number; } /** Returns the books most similar to bookId (without bookId itself). */ export function similarBooks(v: BookVectors, bookId: number, limit = 5): Recommendation[] { if (!Number.isInteger(bookId) || bookId < 0 || bookId >= v.count) { throw new RangeError(`bookId must be between 0 and ${v.count - 1}`); } const best: Recommendation[] = []; for (let other = 0; other < v.count; other++) { if (other === bookId) continue; const score = cosine(v, bookId, other); if (best.length < limit || score > best[best.length - 1].score) { best.push({ bookId: other, score }); best.sort((x, y) => y.score - x.score); if (best.length > limit) best.pop(); } } return best.map((r) => ({ bookId: r.bookId, score: Math.round(r.score * 1000) / 1000 })); } Step 3: A handler that loads in the init phase The handler loads the model at the top level, which means during initialization. With SOCI, this is the moment Lambda fetches the file from ECR. If the call to loadVectors lived inside the handler, the first invocation of every environment would take noticeably longer. // src/handler.ts import { loadVectors, similarBooks, type Recommendation } from './vectors.ts'; // Load in the init phase: with SOCI, Lambda fetches the file here // and not during the first invocation. const MODEL_FILE = process.env.MODEL_FILE ?? '/opt/models/books-de.bin'; const started = Date.now(); const vectors = loadVectors(MODEL_FILE); console.log(JSON.stringify({ msg: 'model loaded', file: MODEL_FILE, books: vectors.count, ms: Date.now() - started })); export interface RecommendEvent { bookId: number; limit?: number; } export interface RecommendResult { model: string; recommendations: Recommendation[]; } // Read-only shared state: safe even when an execution environment // handles several invocations at the same time. export async function handler(event: RecommendEvent): Promise<RecommendResult> { const limit = Math.min(Math.max(event.limit ?? 5, 1), 20); return { model: MODEL_FILE, recommendations: similarBooks(vectors, event.bookId, limit) }; } Step 4: Generate test data and check locally The script creates four model files with random values. With BOOKS=200000, every file grows to roughly 100 MB, and the difference at startup becomes clearly visible. Small files are enough for the first test. // scripts/make-models.ts // Generates one vector file per language. Only one of them is used per function – // exactly the situation in which SOCI lazy loading pays off. import { mkdirSync, writeFileSync } from 'node:fs'; import { encodeVectors } from '../src/vectors.ts'; const count = Number(process.env.BOOKS ?? 20000); const dims = Number(process.env.DIMS ?? 128); const languages = (process.env.LANGS ?? 'de,en,fr,es').split(','); mkdirSync('models', { recursive: true }); let seed = 42; const random = () => { seed = (seed * 1664525 + 1013904223) % 2 ** 32; return seed / 2 ** 32 - 0.5; }; for (const lang of languages) { const data = new Float32Array(count * dims); for (let i = 0; i < data.length; i++) data[i] = random(); const file = `models/books-${lang}.bin`; const buf = encodeVectors({ count, dims, data }); writeFileSync(file, buf); console.log(`${file}: ${(buf.byteLength / 1024 / 1024).toFixed(1)} MB`); } BOOKS=5000 npm run models npm test npm run build MODEL_FILE=models/books-de.bin node -e "import('./dist/handler.js').then(async (m) => console.log(await m.handler({ bookId: 7, limit: 3 })))" Expected result: four files of 2.4 MB each, four passing tests and an output with three recommendations. Book IDs and scores are random; the structure looks like this: {"msg":"model loaded","file":"models/books-de.bin","books":5000,"ms":4} { model: 'models/books-de.bin', recommendations: [ { bookId: 37, score: 0.337 }, { bookId: 1965, score: 0.311 }, { bookId: 3426, score: 0.274 } ] } Step 5: Build the image for arm64 and push it to ECR The Dockerfile uses the official Lambda base image for Node.js 24. The models end up in /opt/models. # Dockerfile FROM public.ecr.aws/lambda/nodejs:24 # Compiled code plus package.json (for "type": "module") COPY package.json ${LAMBDA_TASK_ROOT}/ COPY dist/ ${LAMBDA_TASK_ROOT}/ # All language models – each function only reads one of them COPY models/ /opt/models/ CMD ["handler.handler"] Two build options matter here. --platform linux/arm64, because Lambda accepts only single-architecture images. And --provenance=false, because buildx would otherwise attach an attestation to the image and turn it into an image index with several entries. For the SOCI step, you want a plain image without such extras. BOOKS=200000 npm run models && npm run build ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text) REGION=eu-central-1 REPO=library-recommender REGISTRY=$ACCOUNT_ID.dkr.ecr.$REGION.amazonaws.com aws ecr create-repository --repository-name $REPO --region $REGION aws ecr get-login-password --region $REGION | docker login -u AWS --password-stdin $REGISTRY docker buildx build --platform linux/arm64 --provenance=false --sbom=false \ -t $REGISTRY/$REPO:v1 --push . Step 6: Create the SOCI index You run this step on a Linux machine with containerd. The easiest option is a small Graviton instance with Amazon Linux 2023, because its architecture already matches the image. The instance needs a role with read and write access to the ECR repository. sudo yum install -y soci-snapshotter containerd nerdctl sudo systemctl start soci-snapshotter sudo systemctl restart containerd ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text) REGION=eu-central-1 REPO=library-recommender REGISTRY=$ACCOUNT_ID.dkr.ecr.$REGION.amazonaws.com aws ecr get-login-password --region $REGION | sudo nerdctl login -u AWS --password-stdin $REGISTRY sudo nerdctl pull --platform linux/arm64 $REGISTRY/$REPO:v1 sudo nerdctl image convert --soci --platform linux/arm64 $REGISTRY/$REPO:v1 $REGISTRY/$REPO:v1-soci sudo nerdctl push --platform linux/arm64 $REGISTRY/$REPO:v1-soci nerdctl image convert --soci produces an index in format v2, and that is the only format Lambda accepts. When you follow older guides on the web, check which format they produce: Lambda does not support indexes in format v1. Step 7: Check the result in ECR Before you touch the function, take a look at the new tag. The manifest behind v1-soci must have exactly two entries, and one of them must be the SOCI index. aws ecr batch-get-image --repository-name $REPO --region $REGION \ --image-ids imageTag=v1-soci \ --accepted-media-types application/vnd.oci.image.index.v1+json \ --query 'images[0].imageManifest' --output text | jq '.manifests | length, [.[] | .artifactType // .mediaType]' Expected result: the first line shows 2, and the list contains an entry with soci in its name. If you see a higher number, attestations or additional platforms are still attached to the image. In that case, rebuild the image as in step 5. Step 8: Capacity provider and function with CDK Since version 2.235.0, aws-cdk-lib has a dedicated CapacityProvider construct. If you do not pass your own, it also creates the operator role with the managed policy AWSLambdaManagedEC2ResourceOperator. You attach a function to the provider with addFunction. The function is then published automatically as $LATEST.PUBLISHED, so you do not have to create a version by hand. // lib/library-stack.ts import { CfnOutput, Duration, Stack, type StackProps } from 'aws-cdk-lib'; import * as ec2 from 'aws-cdk-lib/aws-ec2'; import * as ecr from 'aws-cdk-lib/aws-ecr'; import * as lambda from 'aws-cdk-lib/aws-lambda'; import type { Construct } from 'constructs'; export interface LibraryStackProps extends StackProps { repositoryName: string; /** Tag of the SOCI-indexed image, e.g. "v1-soci" */ imageTag: string; /** Optional: original tag (e.g. "v1") for a comparison function without SOCI */ plainImageTag?: string; } export class LibraryStack extends Stack { constructor(scope: Construct, id: string, props: LibraryStackProps) { super(scope, id, props); // Private subnets with a NAT gateway so the instances can reach ECR and CloudWatch const vpc = new ec2.Vpc(this, 'LibraryVpc', { maxAzs: 3, natGateways: 1 }); const sg = new ec2.SecurityGroup(this, 'InstancesSg', { vpc, allowAllOutbound: true }); const capacity = new lambda.CapacityProvider(this, 'LibraryCapacity', { subnets: vpc.privateSubnets, securityGroups: [sg], architectures: [lambda.Architecture.ARM_64], maxVCpuCount: 12, }); const repo = ecr.Repository.fromRepositoryName(this, 'Repo', props.repositoryName); const addRecommender = (id: string, tag: string) => { const fn = new lambda.DockerImageFunction(this, id, { code: lambda.DockerImageCode.fromEcr(repo, { tagOrDigest: tag }), architecture: lambda.Architecture.ARM_64, memorySize: 4096, timeout: Duration.seconds(30), environment: { MODEL_FILE: '/opt/models/books-de.bin' }, }); capacity.addFunction(fn, { perExecutionEnvironmentMaxConcurrency: 8, latestPublishedScalingConfig: { minExecutionEnvironments: 3, maxExecutionEnvironments: 6 }, }); new CfnOutput(this, `${id}Name`, { value: fn.functionName }); return fn; }; addRecommender('Recommender', props.imageTag); if (props.plainImageTag) { addRecommender('RecommenderPlain', props.plainImageTag); } } } // bin/app.ts import { App } from 'aws-cdk-lib'; import { LibraryStack } from '../lib/library-stack.ts'; const app = new App(); new LibraryStack(app, 'LibraryRecommender', { repositoryName: app.node.tryGetContext('repo') ?? 'library-recommender', imageTag: app.node.tryGetContext('tag') ?? 'v1-soci', plainImageTag: app.node.tryGetContext('plainTag'), env: { account: process.env.CDK_DEFAULT_ACCOUNT, region: process.env.CDK_DEFAULT_REGION }, }); // cdk.json { "app": "node --experimental-strip-types bin/app.ts" } A few values in the stack are deliberate: according to CDK, maxVCpuCount must be between 12 and 15,000. perExecutionEnvironmentMaxConcurrency: 8 allows eight concurrent invocations per environment, which is rather generous for our compute-heavy handler. For pure CPU work, more concurrency per vCPU does not help much; more memory, and with it more vCPUs per function, helps more. npx cdk bootstrap npx cdk deploy -c repo=library-recommender -c tag=v1-soci -c plainTag=v1 With -c plainTag=v1, the stack creates a second function that points to the image without a SOCI index. You need it for the comparison in the next step. The deployment takes a few minutes, because Lambda launches the instances. Step 9: Measure startup time with and without SOCI The script publishes a new version for each function and measures the time until it is Active. It is a rough but honest measurement: it includes pulling the image and initializing the environments. // scripts/measure-start.ts // Publishes a new version per function and measures the time until it is "Active". // Usage: npm run measure -- recommender-plain recommender-soci import { GetFunctionConfigurationCommand, LambdaClient, PublishVersionCommand, } from '@aws-sdk/client-lambda'; import { setTimeout as sleep } from 'node:timers/promises'; const client = new LambdaClient({}); async function timeToActive(functionName: string): Promise<number> { const start = Date.now(); const { Version } = await client.send(new PublishVersionCommand({ FunctionName: functionName })); for (;;) { const cfg = await client.send( new GetFunctionConfigurationCommand({ FunctionName: functionName, Qualifier: Version }), ); if (cfg.State === 'Active') return (Date.now() - start) / 1000; if (cfg.State === 'Failed') throw new Error(`${functionName}:${Version} failed: ${cfg.StateReason}`); await sleep(5000); } } const names = process.argv.slice(2); if (names.length === 0) { console.error('Please pass at least one function name'); process.exit(1); } for (const name of names) { const seconds = await timeToActive(name); console.log(`${name}: active after ${seconds.toFixed(0)} s`); } npm run measure -- <name of the plain function> <name of the SOCI function> You will find the function names in the outputs of cdk deploy. Measure several times and compare the averages, because network and placement vary. It is also worth checking CloudWatch Logs for the model loaded line and the duration of each first invocation. If you measure numbers with your own image, I would love to read about them in the comments. Common mistakes with SOCI lazy loading - The function points to the old tag. The image URI must point to v1-soci, not to v1. Otherwise everything works, just without lazy loading. - Index in the wrong format. Lambda supports only SOCI Index Manifest v2. Check for the media type application/vnd.amazon.soci.index.v2+json. - Too many entries in the image index. Attestations, SBOMs or a multi-arch image lead to more than two entries in the index. - Files read only in the handler. Then the first invocation of every environment pays the loading time, and your latency metrics get outliers. - Mismatched architecture. Capacity provider, function and image must share the same architecture, here arm64 everywhere. - New image, old index. Every change to the image needs a new SOCI tag, for example v2-soci, and an updated image URI. Costs and security The announcement does not mention a separate price for SOCI itself. The index is stored as an additional artifact in ECR and is small compared to the image. The real costs come from Lambda Managed Instances: you pay the EC2 price of the instances plus a 15 percent management fee. Savings Plans and Reserved Instances apply only to the EC2 part. In this example, you also pay for a NAT gateway and the EC2 instance used for the SOCI step. Clean up after testing: npx cdk destroy aws ecr delete-repository --repository-name library-recommender --region eu-central-1 --force On the security side, the capacity provider is the security boundary. Functions that share a provider run as containers on the same instances. Separate functions with different protection needs by giving them their own capacity providers. The operator role needs only the managed policy, and the role of the build instance needs access to just the one repository. My take SOCI lazy loading solves a real problem, but a narrow one. It helps only container image functions on Lambda Managed Instances, not the classic Lambda most of us use every day. If you run large images there, an extra build step gets you faster starts when publishing and when scaling out. That is mainly interesting for ML workloads, where an image ships many models or libraries. Where I am cautious: the build process gets more complicated. You need containerd and nerdctl, a Linux machine with the matching architecture and a second tagging scheme. That belongs in the CI pipeline, not on someone’s laptop. And lazy loading is no substitute for lean images. If your function ends up reading almost everything anyway, you gain little. My advice: clean up the image first, then measure, then add SOCI. By the way, for Java functions on regular Lambda, SnapStart remains the tool of choice for startup times. FAQ Does SOCI lazy loading work with regular Lambda? No. As of late October 2026, Lambda supports SOCI only for functions that are packaged as container images and run on Lambda Managed Instances. Do I have to change my function code for SOCI? Not necessarily. You should, however, load all libraries and files in the init phase, so the first invocation of an environment is not slowed down by on-demand fetching. Can I create the SOCI index with Docker? The AWS documentation shows the path with containerd, nerdctl and the SOCI snapshotter. Docker alone does not create a SOCI index, so you need an environment with containerd for this step. How much faster does my function start with SOCI? AWS quotes startup times that are up to 70 percent shorter. How much you gain depends on which share of the image is actually read. Measure it with and without the index. Your next step Take your largest container image and find out which share of it is really read at startup. If it is less than half, and you are considering Lambda Managed Instances anyway, SOCI is a good candidate for an experiment. What makes your Lambda images so large: models, libraries or something else entirely? Sources - AWS What’s New: AWS Lambda supports SOCI lazy loading for Lambda Managed Instances (Oct 28, 2026) - AWS docs: Lazy-load container images with Seekable OCI (SOCI) - AWS docs: Lambda Managed Instances - AWS docs: Getting started with Lambda Managed Instances - AWS docs: Node.js runtime for Lambda Managed Instances - AWS docs: Lambda Managed Instances runtimes - AWS Compute Blog: Build high-performance apps with AWS Lambda Managed Instances - GitHub: awslabs/soci-snapshotter - AWS CDK API Reference: aws_lambda.CapacityProvider This post was created with AI assistance. --- 👉 Next step: Curious what your own Lambda functions cost? Try my Lambda cost calculator – it runs right in your browser. Originally published on sascha-ms.de. More articles on serverless, AI in everyday development and leading tech teams: sascha-ms.de
Eyes, Not Hands: Adding AWS DevOps Agent, Bedrock AgentCore and Kiro to a GitOps Platform on EKS __*Level 300* — Advanced | Amazon EKS, Argo CD, AWS DevOps Agent, Amazon Bedrock AgentCore, Kiro CLI, Amazon EventBridge 🎯 The Challenge Scenario Suppose you are the Platform Engineering lead of an enterprise that already runs Kubernetes the GitOps way: an Amazon EKS hub cluster with Argo CD manages a fleet of spoke clusters, and every team onboards its workloads through a single values file in Git. It works. Deployments are boring, in a good way. Operations are not. On-call engineers keep getting paged for the same kind of incidents: a bad config is merged, pods start crash-looping, and someone spends 40 minutes reading logs and events before realizing the fix is "revert that commit". Then leadership asks the question every platform team is hearing: > "Can AI agents take the first part of on-call?" Your mission: add agentic investigation and remediation to the existing platform, under these constraints: - ✅ No agent gets write access to production. Not to the cluster, not to the AWS account. - ✅ Every fix is a reviewable pull request. Git stays the single source of truth; Argo CD stays the only thing that changes the cluster. - ✅ Don't rebuild the platform. The hub-spoke GitOps setup stays as it is; agents are added on top. - ✅ Multi-account by design. Workloads and CI/CD live in different AWS accounts. - ✅ Everything is observable and auditable. Every agent action leaves a trace. > This post is the architecture behind the TechLabVel video, Cap 2. {% embed youtube.com %} My Approach: Eyes, Not Hands The fastest demo is always the same: connect an LLM to the cluster with an admin kubeconfig and let it "fix things". I've seen it in many conference talks, and it's also the one setup I would never take to a client's production. A model can be wrong, a prompt can be injected, a credential can leak. With write access, each of those becomes an outage. So I split the problem the way with one load-bearing rule: > Reasoning is decoupled from execution. The agents can read and propose anything, but can mutate nothing directly. Every production change routes through a separate control plane: a GitOps pull request. The agent gets eyes (logs, events, metrics, code) but no hands. GitOps is the hands, and a human holds them. I also decided not to build a new platform for this: everything is added on top of the hub-spoke GitOps setup I already had. The Architecture !Agentic Operations on EKS GitOps Platform The six numbered steps in the diagram match the list below. Agents (teal) only read; the only write path is the pull request (green) that Argo CD pulls (orange). 1. Detect: a CloudWatch alarm on a Container Insights metric fires when a pod is OOMKilled. 2. Wake up the agent: EventBridge invokes a small Lambda that sends a signed webhook to the AWS DevOps Agent. 3. Investigate: the agent reads Kubernetes state through a read-only MCP server hosted on Amazon Bedrock AgentCore, plus CloudWatch, CloudTrail and the GitHub repos. 4. Hand off: the alarm event is forwarded cross-account to the DevSecOps account. 5. Propose: CodeBuild runs Kiro CLI headless; it edits the GitOps repo and opens a pull request. 6. Approve and apply: a human merges; Argo CD reconciles the spoke cluster. | Account | What lives there | Why | |---------|------------------|-----| | Workload account | EKS hub (Argo CD) + EKS spoke (workloads), CloudWatch, AWS DevOps Agent space, AgentCore Runtime + Gateway | The agent investigates where the signals are | | DevSecOps account | GitHub runners, the Kiro remediation pipeline (EventBridge bus, Lambdas, SQS, CodeBuild) | The only component that writes code is isolated from the workloads | | GitHub | gitops (platform + team values), petadoptions (app manifests) | Git is the source of truth and the approval gate | The Starting Point: GitOps Hub-Spoke This is the platform from the GitOps and IaC at Scale series. Argo CD runs on the hub and registers each spoke as a cluster secret. Labels on that secret decide which add-ons each cluster gets, through ApplicationSets: kubectl --context hub -n argocd get secrets -l argocd.argoproj.io/secret-type=cluster -o json \ | jq -r '.items[] | "\(.data.name | @base64d)\t\(.metadata.labels.environment)"' gitops-spoke-eks-dev dev in-cluster control-plane Teams onboard with one file in the platform repo. The workloads entry tells Argo CD which repo and which Kustomize overlay to deploy to the spoke: # gitops/platform/teams/petadoptions/argoproject-values.yaml (excerpt) workloads: - name: app repoURL: https://github.com/<org>/petadoptions targetRevision: main path: k8s/overlays/dev namespace: petadoptions Keep this file in mind. Hands On Prerequisites | Tool / component | Version used | |------------------|--------------| | Amazon EKS (hub / spoke) | 1.37 / 1.36 | | OpenTofu + Terragrunt | 1.1.3 / 1.13.2 versions | | Terraform AWS provider | 6.69.1 | | awslabs.eks-mcp-server | 0.2.1 | | Kiro CLI | latest at build time (TODO(you): pin it, see review) | | Region | us-east-2 | Deploy order for the agent side: S3 (evaluation bucket) → ECR → IAM roles → MCP server (AgentCore) → Agent Space → tool allowlist. Build and push the arm64 MCP image before the MCP server stack. Step 1: The Alarm (A Signal That Actually Fires) The first version of the alarm watched a custom SLO burn-rate metric. Nothing ever published it, so the alarm could never fire. The lesson came early: an agent is only as good as the signal that wakes it up. The final alarm uses a metric that the CloudWatch Observability add-on (Container Insights) already publishes for the spoke: # environments/dev/observability.tfvars (excerpt) alarm_name = "petsearch-oomkilled" metric_namespace = "ContainerInsights" metric_name = "pod_container_status_terminated_reason_oom_killed" statistic = "Sum" threshold = 0 # any OOMKill in the period trips the alarm period = 300 evaluation_periods = 1 comparison_operator = "GreaterThanThreshold" treat_missing_data = "notBreaching" # no OOMKills reported = healthy dimensions = { ClusterName = "<spoke-cluster>" Namespace = "petadoptions" PodName = "petsearch" } Step 2: The Trigger (Why a Lambda Signs the Webhook) EventBridge can't call the AWS DevOps Agent webhook directly: the generic webhook requires an HMAC signature over the timestamp and payload, and EventBridge can't compute it. A small Lambda (about 100 lines) does: # trigger/src/handler.py (excerpt) def _sign(timestamp: str, body: bytes, secret: str) -> str: # AWS DevOps Agent HMAC: base64(HMAC-SHA256(secret, "{timestamp}:{payload}")). # The timestamp is part of the signed material (replay protection) and the # digest is base64 — NOT a bare hex of the body. mac = hmac.new( secret.encode("utf-8"), f"{timestamp}:".encode("utf-8") + body, hashlib.sha256, ) return base64.b64encode(mac.digest()).decode("ascii") The webhook URL and secret live in AWS Secrets Manager. The timestamp is part of the signed material, which protects against replays. Step 3: The MCP Server on AgentCore (Eyes, Not Hands) The AWS DevOps Agent needs to see inside the cluster: pods, events, logs. Instead of writing our own tools, we wrap the official awslabs.eks-mcp-server (pinned to 0.2.1) and host it on Amazon Bedrock AgentCore Runtime. AWS owns the tool logic; we own the posture. The posture is read-only, enforced three times inside the server: # src/mcp-server/server.py (excerpt) # We NEVER enable write. ALLOW_WRITE = False # Defense in depth: refuse to start rather than silently expose mutating tools. if os.environ.get("READ_ONLY", "true").lower() not in ("1", "true", "yes"): raise SystemExit("EKS MCP host is read-only by design; set READ_ONLY=true.") # Remove mutating tools from the registry, so the agent never even SEES them. _WRITE_TOOLS = { "manage_k8s_resource", # create/replace/patch/delete K8s objects "apply_yaml", # apply manifests "generate_app_manifest", # writes a manifest file "add_inline_policy", # mutates IAM "manage_eks_stacks", # CloudFormation create/update } Read-only is enforced at the listing layer, not only at call time: a tool the model never sees is a tool it can't be tricked into calling. In front of the runtime sits an AgentCore Gateway with IAM (SigV4) authentication and semantic tool search: # modules/agentcore-mcp/main.tf (simplified: count, JWT option and tags removed) resource "aws_bedrockagentcore_gateway" "mcp" { name = var.gateway_name role_arn = var.execution_role_arn authorizer_type = "AWS_IAM" # SigV4 — no shared secrets protocol_type = "MCP" protocol_configuration { mcp { instructions = "Read-only ops/K8s tools. Mutation is not exposed; actuation routes through the control plane." search_type = "SEMANTIC" } } } Finally, the Agent Space only gets an allowlist of tools, managed as code (AWS::DevOpsAgent::Association, MCPServerSigV4): mcp-runtime___list_k8s_resources mcp-runtime___get_pod_logs mcp-runtime___get_k8s_events mcp-runtime___list_api_versions x_amz_bedrock_agentcore_search On the cluster side, the runtime's IAM role has an EKS access entry with the managed AmazonEKSViewPolicy: get, list and watch, nothing else. Step 4: The Agent Space (What the Agent Can See) The AWS DevOps Agent space is created with the native aws_devopsagent_agent_space resource, and its description states the contract: # stacks/application/compute/devops-agent/main.tf (excerpt) resource "aws_devopsagent_agent_space" "this" { name = "${local.name_prefix}-space" description = "DevOps Agent space for ${var.project} ${var.environment} (read-only investigate + plan; proposes, never mutates)." locale = var.locale kms_key_arn = var.kms_key_arn != "" ? var.kms_key_arn : null # optional customer-managed key } | Access | What the agent gets | |--------|---------------------| | Agent IAM role (read-only policy) | CloudWatch alarms, metrics and logs, X-Ray, CloudTrail LookupEvents, EKS describe | | MCP server (SigV4, via AgentCore Gateway) | The 4 read-only Kubernetes tools above | | GitHub: gitops, petadoptions, framework repo | The code and history to correlate "what changed" | | Slack on-call channel | Where findings are posted for humans | | Event channel (webhook) | The trigger from Step 2 | Elevated actions are disabled in the space. The agent's IAM role trusts the aidevops.amazonaws.com service only for this account's agent spaces (aws:SourceAccount + aws:SourceArn), which protects against the confused-deputy problem. Step 5: The Remediation Pipeline (Proposal to Pull Request) The second EventBridge rule forwards the same alarm event cross-account to an event bus in the DevSecOps account. Its IAM role can do exactly one thing: # stacks/application/integration/event-forwarder/main.tf (excerpt) resource "aws_cloudwatch_event_target" "hub_bus" { rule = aws_cloudwatch_event_rule.to_hub.name target_id = "kiro-hub-bus" arn = var.hub_kiro_event_bus_arn role_arn = aws_iam_role.forwarder.arn # policy: events:PutEvents on that bus only } In the DevSecOps account, an extractor Lambda asks the AWS DevOps Agent API for the mitigation plan and queues it in SQS. A starter Lambda launches CodeBuild, which runs Kiro CLI headless with strict rules ("only edit files in this repo; do NOT run AWS CLI or kubectl"): kiro-cli chat --no-interactive --trust-tools=read,write,grep,shell "$(cat /tmp/prompt.md)" The buildspec wraps the agent with guardrails the agent can't change: no changes means no PR, more than 10 files means abort, and the branch is always a new kiro-fix/<timestamp> that goes through review. GitHub access uses an AWS CodeConnections GitHub App, with no personal access tokens. Defense in Depth No single control carries the safety. If one fails, the next one holds: !Image description When It Breaks: Gotchas I Hit | Symptom | Cause | Fix | |---------|-------|-----| | Invalid STS role configuration for session sigv4McpServerSession | The agent role's trust statement had sts:TagSession | Use sts:AssumeRole only for aidevops.amazonaws.com | | Role validation failed ... doesn't include either SourceArn or SourceAccount | No confused-deputy guard | Add aws:SourceAccount and aws:SourceArn = arn:aws:aidevops:<region>:<account>:service/* (not agentspace/*) | | 403 ... Insufficient permissions from the gateway | Trust OK, but no invoke permission | Grant bedrock-agentcore:InvokeGateway on the gateway ARN | | The MCP server shows zero tools | Gateway created without a target | Add the mcp_server target pointing at the runtime | | SigV4 signature rejected | Service name set to execute-api | The SigV4 service for AgentCore is bedrock-agentcore | | Read tools return forbidden | No EKS access entry for the runtime role on the spoke | Add the access entry with AmazonEKSViewPolicy | | Alarm never fires | Alarm on a metric nothing publishes | Use a metric the Container Insights add-on already emits | Conclusion We didn't give an AI agent the keys to production. We gave it the best possible view of production, and one narrow, reviewable way to suggest a change. The existing GitOps platform did the rest: the pull request is the dry-run and the approval, and Argo CD is the deterministic executor. In the next episode we put this architecture under a real incident: a pod that's OOMKilled under load, an agent that investigates, and the two things it got wrong before the platform got it right. How are you giving AI agents access to your clusters today: read-only, through PRs, or with the keys? I'd love to hear what's working for you 👇 Happy building! 🚀 References - AWS DevOps Agent documentation - Amazon Bedrock AgentCore - AWS Labs MCP servers (EKS MCP server) - Amazon EKS access entries and access policies - Container Insights metrics for Amazon EKS - Kiro CLI documentation - GitOps and IaC at Scale — Part 1 --- > The opinions expressed in this post are my own and do not necessarily reflect those of my employer or AWS. ✨ Alejandro Velez, Platform Engineering Latam Lead @ GFT | AWS Ambassador
AI-DLC Workflows v2 よい。はじめ「?」っとなったが、IntentsとCustom Scope使ったハンドリングに慣れてくると心地よくなってきた github.com/awslabs/aidlc-workflows/tree/main
Hijacking a Refund Agent Through Poisoned Support Tickets: Cristian Leo and Daniel Begimher at Global AppSec USA 2026 Players in this hands-on pod attack a customer-returns AI agent that can read tickets, fetch URLs, look up orders, send email or issue refunds. A player wins by making the agent issue a refund, email an address the player picks or post order data to a URL the player controls. No payload is handed out. Players write the support ticket, the tracking page or the email that the agent is asked to summarize, then hide their instructions inside it. In their Global AppSec USA 2026 hands-on pod, "Hijack the Agent", on Thursday, November 5 at 10:10 a.m. in Marina, Cristian Leo and Daniel Begimher let attendees attack an AI agent that defends itself a little better at each stage. Speakers: - Cristian Leo, Co-Founder and CTO, Armorer Labs - Daniel Begimher, Co-Founder and CEO, Armorer Labs The pod runs the same app through four stages, each one harder. At the beginner stage the tools are wide open, so even direct injection in the chat works. At the intermediate stage a chat filter is on, so players must deliver their instructions through the web page or the email instead. That maps to ASI01, Agent Goal Hijack, in the OWASP Top 10 for Agentic Applications. At the advanced stage a static deny-list guards the issue_refund and send_email tools, yet the same string passes through lookup_order. Players must find a tool call the list did not anticipate, then a new fetch_url tool makes the previous round's rule wrong. That maps to ASI02, Tool Misuse. The final "Impossible" stage adds an in-process dispatcher hook that inspects every tool call. Attacks from the first three stages die there with a stated reason, which is the lesson. Each stage takes under 15 minutes, and players need only a laptop with a browser. The pod repeats on Thursday at 3:00 p.m., then on Friday at 11:30 a.m. and 1:30 p.m. Cristian Leo is Co-Founder and CTO of Armorer Labs, a Y Combinator Fall 2026 company that tests AI agents for exploitable failures. Previously at Amazon Web Services, he is corresponding author of ThreatForest, a multi-agent system that turns a source repository into attack trees mapped to MITRE ATT&CK or CAPEC. Daniel Begimher is Co-Founder and CEO of Armorer Labs. At AWS he led development of AWS's first customer-facing AI security agent, built red-team agents and wrote the open-source scanner Automated Security Helper. Leo and Begimher co-presented ThreatForest at Black Hat USA 2026. They also co-authored SIR-Bench, a 794-case benchmark that measures whether incident response agents actually investigate or merely triage. Begimher also co-wrote the AWS Security Blog post "Balancing speed and safety: A control framework for AI coding agents," which pairs scoped tool access for AI coding agents with build-time gates. To go deeper on the topic, start here: - OWASP Top 10 for Agentic Applications for 2026 - ThreatForest Black Hat USA 2026 slides - ThreatForest paper (arXiv) - SIR-Bench paper (arXiv) - ThreatForest code (aws-samples) AppSec engineers who need to see why deny-lists fail against agent tool calls will get a fast, concrete demonstration here. Which of your AI agent's tools would an attacker reach for first through a poisoned document?
SkyPilot v0.14.0 の解説 はじめに クラウドの VM、Kubernetes、Slurm のどれにでも同じ書き方でジョブを投げる道具 SkyPilot の v0.14.0 が、2026 年 10 月 6 日に出ました。この記事では、SkyPilot が何をする道具かを押さえたうえで、v0.14.0 の機能と性能に関わる変更を整理します。 v0.14.0 の項目 何が変わったか 実務での意味 動的なジョブグループ 動いているグループの中から投げたジョブも、グループの一部になる 学習の途中で投げた評価のジョブも、グループを取り消すと一緒に止まる AWS Neuron への対応 Kubernetes の上の Trainium のノードに、ジョブを置けるようになった Trainium のノードを、GPU と同じ --gpus の書き方で頼める EFA の改善 AWS の高速なノード間通信 EFA の数を、ノードの自動増減を SkyPilot に知らせたクラスタでは、アクセラレータのノードがまだ 0 台でも見積もれ、複数ノードの Pod が 1 つの AZ にまとまる 複数ノードの学習が、気づかないまま遅い TCP の通信で動いたり、通信を始められなかったりすることが減る Kueue との連携の改善 Kubernetes の待ち行列 Kueue で待っている時間を、確保の時間に数えなくなった 待ち行列が混んでいるだけのジョブが、短く設定した確保のタイムアウトで打ち切られない 複数ユーザーでの実行 Slurm に、依頼した人それぞれの Unix アカウントでジョブを出せる Slurm 側で設定した利用量の記録や公平な割り当てを、人ごとに効かせられる コンテナクラスタの停止と再開 Slurm の上のコンテナのクラスタを止めて、あとで再開できる ノードを手放しても、入れたパッケージやファイルを残したまま再開できる SkyPilot とは何か 1 つの書き方で複数の基盤に投げる SkyPilot は、Kubernetes、Slurm、クラウドの VM のどれに対しても、同じ 1 つのタスクの書き方で、計算資源の確保からジョブの実行と後片付けまでを受け持つ道具です。基盤ごとにジョブの書き方も、空きの探し方も、止まったときの立て直し方も違うので、その違いを SkyPilot が引き受けます。 組織で複数のプロジェクトを進めていると、複数のクラウド、複数の Kubernetes や Slurm のクラスタ、複数のリージョンを併用することがあります。こうしたクラスタは、それぞれ別々に構築して立ち上げます。SkyPilot は、こうして別々に立てたクラスタやクラウドの VM に、統一的にジョブを投げるための道具だと理解すればよいです。クラウドの VM の場合は、SkyPilot がインスタンスを自分で作ります。 タスクは、必要な資源、準備のコマンド、実行のコマンドを YAML に書いたものです。 resources: accelerators: H100:8 workdir: . setup: pip install -r requirements.txt run: python train.py これを requirements.txt と train.py のあるディレクトリに task.yaml として保存し、sky launch task.yaml を実行すると、SkyPilot は条件を満たす基盤と場所を探して計算資源を確保します。確保に失敗したら、別の場所でやり直します。README のとおり、計算資源はすべて利用者自身のクラウドのアカウントやクラスタの中に作られます。 sky launch は対話的に使う開発用のクラスタを、sky serve は推論のエンドポイントを作ります。sky jobs launch で投げたマネージドジョブは、スポットインスタンスの回収やノードの故障が起きると、資源を確保し直して実行し直します。途中から続けるには、アプリケーションがチェックポイントを保存して読み込む必要があります。sky コマンドは SkyPilot の API サーバーに依頼を送ります。1 人で使うときは手元に API サーバーが自動で立ち、チームで使うときはリモートに 1 つ立てて共有します。 AWS では、SageMaker HyperPod(AWS が管理する学習用のクラスタで、EKS 版と Slurm 版がある)、Amazon EKS、Slurm を使う AWS Parallel Computing Service(AWS PCS)などを構築するリファレンスのテンプレートが awsome-distributed-ai にあります。SkyPilot のドキュメントは、EFA を使える投げる先として、HyperPod の EKS のクラスタと Slurm のクラスタを挙げています。awsome-distributed-ai の NVIDIA NeMo 2.0 の学習の例でも、NeMo の学習を Python の設定から投げる NeMo-Run が、Amazon EKS と SageMaker HyperPod EKS にジョブを出す役として SkyPilot を使います。SkyPilot は、カリフォルニア大学バークレー校の研究室から生まれたオープンソースのソフトウェアで、ライセンスは Apache 2.0 です。 Kubernetes や Slurm との関係 SkyPilot は、どのクラスタにジョブを出すかを選び、そのクラスタが受け付ける形に直して渡します。Kubernetes と Slurm は、受け取ったジョブをクラスタの中のどのノードで動かすかを決めます。Kubernetes に出すときの形は、資源の要求と置き場所の条件を書いた Pod の定義で、Kubernetes の API サーバーに出します。Slurm に出すときは sbatch と srun を使います。そのため、SkyPilot は Kubernetes や Slurm を置き換えるものではありません。 Kueue との関係 Kueue は、Kubernetes のクラスタの中でジョブを待ち行列に並べ、割り当て枠や優先度に従って、受け入れるか、待たせるかを決める仕組みです。SkyPilot はどのクラスタやクラウドに置くかを選び、Kueue は選ばれたクラスタの中でいつ受け入れるかを決め、置くノードは Kubernetes のスケジューラが決めます。 決めることが違うので、1 つのクラスタの中では、SkyPilot と Kueue は置き換えではなく重ねて使います。Kueue の仕組みと、kube-scheduler や Volcano、KAI-Scheduler といった他のスケジューラとの関係は「Kubernetes の分散ジョブスケジューラ比較」で説明しています。v0.14.0 での連携の変更は、Kueue との連携の改善で説明します。 Kueue には、複数のクラスタにジョブを振り分ける MultiKueue もあります。MultiKueue もどのクラスタで動かすかに関わるので、SkyPilot とどう受け持ちを分け、どう組み合わせるかは、今後調べます。 ジョブグループのアップデート 動的なジョブグループ ジョブグループは、学習と報酬の計算のように資源の違う複数のタスクを同時に動かし、1 つの単位として管理する仕組みです。v0.14.0 では、動いているグループの中から新しいマネージドジョブを投げると、そのジョブも既定でグループの一部になり、グループを取り消すと一緒に止まります。 この変更は、OS のプロセスグループに例えるとわかりやすいです。v0.13.0 までは、学習の途中でグループの中から評価のジョブを投げると、そのジョブは setsid で別のプロセスグループに切り離して起動したプロセスのようなもので、グループを取り消しても動き続けました(図の左)。v0.14.0 では、グループの中から投げたジョブは、既定で同じグループに加わります。プロセスグループ全体に kill を送ると中のプロセスがすべて止まるのと同じように、人がグループを取り消すと、途中で加わった評価のジョブも取り消されます(図の右)。 具体的な流れは次のとおりです。 学習のタスクが、チェックポイントを共有の場所に書きます。 見張り役のタスクが、新しいチェックポイントを見つけます。 見張り役が sky jobs launch で評価のジョブを投げます。 そのジョブは自動でグループの新しいタスクになり、sky jobs queue ではグループの下に並びます。 評価のジョブは、学習とは別の資源で動き、状態も別に持ちます。 グループを取り消すと、評価のジョブも一緒に止まります。 例えと違う点もあります。止める単位は、ジョブを投げたタスクではなくグループそのものです。伝わるのはグループから評価のジョブへの一方向だけで、評価のジョブが失敗してもグループは失敗しません。また、実際に伝わるのはシグナルではなく、SkyPilot によるマネージドジョブの取り消しです。 この動きは、リモートの API サーバーが、マネージドジョブを専用の管理役のクラスタではなく自分で管理する構成(統合モード)で、評価のジョブを投げるタスクからその API サーバーに接続できる場合です。統合モードでない環境では、グループの中から投げたジョブはグループに加わらず、普通のジョブとして動きます。 slime の例で見るジョブグループ slime は、LLM を強化学習で鍛えるフレームワークで、重みを更新する学習の部分と、エージェントの応答を生成する推論の部分(LLM の推論サーバー SGLang)に分かれます。この例では、リポジトリの不具合を直すコーディングエージェントを鍛えます。SkyPilot の slime の例は、学習を 1 つのジョブ、SGLang エンジンを 1 つずつ別のジョブにしてジョブグループで束ね、SGLang エンジンの数と置く GPU を学習と別に決められるようにしたものです。図は、この例(Apache 2.0)から引用しました。 これは実行中にジョブを足す例ではなく、学習と推論のジョブを起動前に YAML に書き、ジョブグループでまとめて動かす例です。学習のジョブ(H100 が 4 枚)と SGLang エンジンのジョブ(H100 が 1 枚ずつ)は同じ Kubernetes のクラスタに置かれ、グループの中の通信でつながります。学習のジョブは、各 SGLang エンジンの /health が応答するのを待ってから、sglang-0.<グループ名>:30000 のようなグループのホスト名を slime に渡し、学習のジョブの中のプロセスを束ねる Ray に、slime の学習を投げます。 図の番号は、エージェントが課題を 1 回解いてから、重みが更新されて SGLang エンジンに届くまでの流れです。 学習のジョブの中の、エージェントに課題を解かせて rollout(1 回の試行の記録)を集める管理役(RolloutManager、CPU で動く)が、エージェントのループを回します。エージェントは毎ターン、生成の依頼を SGLang エンジンに配るルーターを通して生成を頼みます。RolloutManager は、生成したトークンと、その確率の対数である logprob を記録します。 エージェントは、リポジトリの取り出しやツールのコマンドを、信用できないコードを隔離して動かす Pod である SkyPilot Sandboxes の中で実行します。最後に、エージェントが作った差分を隠されたテストに当てて採点し、報酬を決めます。 trajectory と logprob と報酬が、学習側に渡ります。 学習側の学習ライブラリ Megatron が、強化学習の更新方法の 1 つ GRPO で重みを更新します。 新しい重みを、学習のジョブと SGLang エンジンのジョブの両方がマウントする共有のボリューム(/shared/policy)に書きます。 各 SGLang エンジンが、そのボリュームから新しい重みを読み込みます。 5 と 6 は既定の設定(WEIGHT_TRANSPORT=disk)の流れで、nccl を選ぶと、SGLang エンジンが学習のジョブと GPU 間の通信ライブラリ NCCL でつながって重みを受け取ります。 ジョブグループと Ray は、決めることが違います。 ジョブグループ(SkyPilot) Ray(slime の中) 決めること どのクラスタに、どのジョブを、いくつ立て、いつ一緒に止めるか 1 つのジョブの中で、どの GPU をどのプロセスに割り当てるか この例での中身 学習のジョブ 1 つと SGLang エンジンのジョブを立て、互いをホスト名で見つけられるようにする 学習のジョブの中で、Megatron の学習プロセスに GPU を割り当てる。rollout の管理役は CPU で動かす SGLang エンジンを外に出さず、学習と推論を SkyPilot の 1 つのタスクの中で動かす構成なら、ジョブグループを使わずに組めます。そのタスクの中では、slime が Ray に学習用と推論用の GPU を割り当てさせます。同じ GPU を共有させても、別々の GPU に分けても構いません。SGLang エンジンを学習と別に増やしたいときや、別の種類の GPU に置きたいときに、SGLang エンジンをジョブグループの別のジョブに出し、slime の外部エンジンの設定(--rollout-external-engine-addrs)にそのホスト名を渡します。このとき Ray が割り当てるのは、学習のジョブの GPU だけです。 Kubernetes 関連のアップデート AWS Neuron への対応 v0.14.0 から、Kubernetes の上の Trainium のノードにも、GPU と同じ accelerators: の書き方でジョブを置けます。これまで SkyPilot が Kubernetes で扱えるアクセラレータは NVIDIA と AMD の GPU と Google の TPU だけで、Trainium のノードは空いていても使えませんでした。Neuron は、AWS のアクセラレータ Trainium を動かすソフトウェアの名前で、Kubernetes にはこの名前の資源として見えます。 受け持ちは次のとおりです。クラスタを運用する側は、Neuron 用の AMI で起動するノードと、Trainium を Kubernetes の資源として見せる Neuron のデバイスプラグインを用意します。SkyPilot は、Pod を Trainium のノードで動かすために、図の 1 から 3 を Pod の定義に書きます。1 つ目に、Pod が Trainium のチップを何枚使うかを Kubernetes に伝えます。2 つ目に、Pod が Trainium のノードだけに置かれるように条件を付けます。3 つ目に、Neuron のノードには Neuron を使わない Pod を入れないための印が付けられていることがあり、この Pod はその印があっても入れるようにします。ノードを自動で増減させる Karpenter を使い、そのことを SkyPilot の設定で知らせているクラスタでは、2 つ目の条件は Karpenter が付けるノードのラベルで書かれるので、ノードがまだ無くても Karpenter が Trainium のノードを作る手がかりになります。 Kubernetes の既定のコンテナイメージは、Trainium の Pod でも GPU 用のもので、Neuron SDK は入っていません。そのため、image_id で Neuron SDK の入ったイメージを指定します。 Trainium に投げるコードの側では、PyTorch を Trainium で動かす TorchNeuron が公開ベータになりました。ブログの学習の例では、変更は model.to('neuron') の 1 行です。TorchNeuron 向けに直した PyTorch の学習スクリプトを、SkyPilot のタスクの run から Trainium のノードで動かせるかは、今後検証してみようと思います。 EFA の改善 EFA は Elastic Fabric Adapter の略で、AWS のノードどうしを高速につなぐネットワークのインタフェースです。v0.14.0 では、複数ノードの学習が EFA を使えないまま動いてしまう 2 つの場合を、SkyPilot が防ぐようになりました(図)。1 つは、アクセラレータのノードがまだ 0 台で、Pod に書く EFA の数が決まらない場合です。もう 1 つは、Pod が別々の AZ に散って、EFA でつながらない場合です。前者では SkyPilot が EFA の数をカタログから決め、後者では Pod の置き場所を 1 つの AZ にそろえます。SkyPilot が受け持つのは Pod に書く EFA の要求の数と置き場所までで、割り当てられた EFA をアプリケーションが実際に使うかは、イメージの中の通信ライブラリの設定で決まります。 SkyPilot が EFA を要求するのは、タスクの resources: の下に、使える最速のネットワークを頼む設定 network_tier: best を書いたときです。頼んだ種類のアクセラレータを頼んだ数以上に持つノードが vpc.amazonaws.com/efa の資源を公開していれば、要求する数は次の式で決まります。 \text{要求する EFA の数} = \max\left(1,\ \left\lfloor \frac{\text{頼んだアクセラレータの数}}{\text{ノードのアクセラレータの数}} \times \text{ノードの EFA の数} \right\rfloor\right) この計算は、頼んだアクセラレータの数で割合を出します。たとえば p5.48xlarge は GPU を 8 枚、EFA を 32 公開するので、H100:4 を頼めば EFA は 16 になります。 1 つ目の改善は、アクセラレータのノードが 0 台でも数を決めることです。これまでは動いているノードを調べて数を決めていたので、Karpenter などでノードを 0 台から増やすクラスタでは数が決まらず、EFA を要求しないまま Pod が作られることがありました。そうなると、GPU 間の通信ライブラリ NCCL の通信は、気づかないまま TCP に落ちます。v0.14.0 からは、ノードを自動で増減させる仕組みを使っていることを設定 kubernetes.autoscaler で SkyPilot に知らせたクラスタでは、AWS のノードが 1 台でもあれば、カタログから数を決めます。カタログは、SkyPilot が持つインスタンスの種類ごとの一覧表です。SkyPilot は、要求を載せられるインスタンスのうち、アクセラレータ 1 つあたりの EFA が最も少ないものに合わせて、同じ割合で数を決めます。カタログでも数が決まらなければ、これまでどおり EFA なしの Pod になります。 2 つ目の改善は、複数ノードの EFA のジョブを 1 つの AZ にまとめることです。EFA の通信は 1 つの AZ の中で組むので、ノードが別々の AZ に散ると EFA でつながらず、TCP に落ちるか、通信を始められません。v0.14.0 からは、AWS のノードでできた Kubernetes のクラスタで複数ノードの network_tier: best のジョブを投げると、SkyPilot は Pod の定義に、同じジョブの他の Pod と同じ AZ に置く条件(podAffinity)を足します。最初の Pod が置かれた AZ に、残りの Pod が続きます。この条件は必須なので、その AZ に空きが無ければ、Pod は TCP に落ちて動き出すのではなく、待ちの状態で止まります。 Kueue との連携の改善 これまでは、Kueue の待ち行列で順番を待っている時間も、SkyPilot が計算資源の確保の時間に数えていたので、確保のタイムアウトを短く書くと、割り当て枠が空くのを待っているだけのジョブまで、確保の失敗として捨てられていました。v0.14.0 からは、Kueue の受け入れを待っている間は確保の時計を止め、受け入れられてから数え始めます。 Slurm 関連のアップデート 複数ユーザーでの実行 v0.14.0 では、SkyPilot が Slurm にジョブを出すとき、依頼した人それぞれの Unix アカウントとして出せます。これまでは、1 つの SkyPilot の API サーバーで Slurm のクラスタを分け合うと、全員が設定ファイル ~/.slurm/config に書いた 1 つの SSH ユーザーとして出ていたので、Slurm の利用量の記録(accounting)や公平な割り当て(fairshare)、ファイルの権限が、全員を同じ 1 人として扱っていました。 有効にするには、API サーバーが読む SkyPilot の設定で slurm.submit_as_user を true にします。すると SkyPilot は、まず SSH ユーザーで接続し、そのあと確保、srun、ファイルの転送、取り消しを、依頼した人の Unix アカウントとして行います。アカウント名は、既定ではメールアドレスの @ より前の部分で、username_map で個別に対応を書けます。次の例は、Slurm のクラスタに jdoe の Unix アカウントがあることを前提にしています。 slurm: submit_as_user: true username_map: jane.doe@example.com: jdoe そのためには、SSH ユーザーが root であるか、対象のユーザーとして /bin/bash をパスワードなしの sudo で動かせる必要があります。SkyPilot はこのシェルの中でスクリプトの実行や rsync を行うので、コマンドごとには絞れず、切り替え先の権限がそのまま SSH ユーザーに渡ります。ドキュメントは、切り替え先を sudo や Slurm の管理権限を持たない専用グループのアカウントに限り、sudo に端末を要求させる設定 requiretty を、そのグループにだけ外す設定例を載せています。共通のアカウントで運用するなら、既定の submit_as_user: false のままで構いません。 あわせて、Slurm 側で利用量を付ける先(account)と、優先度や上限の枠(QoS)を、SkyPilot のワークスペース(ユーザーやプロジェクトを分ける単位)ごとに指定できるようになりました。この指定は slurm.quota に書き、SkyPilot が sbatch の --account と --qos に渡します。この account は Slurm の利用量を付ける先の単位で、Unix のアカウントとは別のものです。 コンテナクラスタの停止と再開 SkyPilot は Slurm に割り当てさせたノードの上に自分のクラスタを立て、タスクにコンテナイメージを書いた場合は、そのクラスタをコンテナの中に立てます。v0.14.0 では、このコンテナのクラスタを、入れたパッケージやファイルを残したまま止めて、あとで再開できます。止めている間は Slurm のノードの割り当てを手放すので、ノードを他の人に返せます。 sky stop と sky start で止めるときは、各ノードのコンテナのファイルシステムを共有のストレージに写してから割り当てを手放し、再開するときは新しい割り当てに書き戻します。残るのはファイルシステムで、動いていたプロセスは止めた時点で終わるので、計算を途中から続けるにはチェックポイントが要ります。対象は、Slurm でコンテナを動かすプラグイン Pyxis を使うクラスタです。あわせて、一定時間使われていないクラスタを同じ手順で止める autostop と、ホストのパスをマウントする設定も入りました。 まとめ 筆者は v0.14.0 の動きの多くをまだ実機で確かめておらず、実際に動かして確かめるしかないので、次の点を調べる予定です。 今後検証すること 確かめたいこと AWS PCS、SageMaker HyperPod、Amazon EKS を登録して使う それぞれを SkyPilot に登録し、同じタスクの YAML で投げ分けられるか。PCS で計算ノードを 0 台から増やす構成が通るか slime(または同じ作りのフレームワーク miles)とジョブグループを組み合わせる SGLang エンジンをジョブグループの別のジョブにし、外部エンジンとして学習側に渡したときに、学習が回り、重みの受け渡しが動くか Neuron と GPU のノードにジョブを投げ分ける 同じ API サーバーから、Kubernetes の上の Trainium のノードと GPU のノードに、別々のタスクを投げ分けられるか EFA の挙動 network_tier: best で、アクセラレータのノードが 0 台からでも EFA の数が決まるか、複数ノードの Pod が 1 つの AZ にまとまるか MultiKueue との組み合わせ 複数のクラスタへの振り分けを、SkyPilot と MultiKueue がそれぞれどう受け持つか 参考 SkyPilot v0.14.0 のリリースノート SkyPilot のドキュメント ジョブグループのドキュメント Kubernetes で AWS Neuron に対応させた PR #10169 Introducing TorchNeuron: Native PyTorch backend for AWS Trainium Neuron を EKS で使う手順 Karpenter のインスタンスタイプの表
From Writing the Spec to Approving It: What AI-DLC Looks Like on a Real Build !Image description Most AI-assisted development still follows the old split. A human writes the spec, a human writes the code, and the AI helps with autocomplete or generates tests at the end. AI-DLC (the AI-Driven Development Life Cycle) reverses that. The AI drafts the requirements, the design, and the code. The human answers short questions and approves each stage before the next one starts. To see what that looks like in practice, I built a small command-line tool with it and then read the record the workflow left behind. This post walks through that record, folder by folder. > TL;DR > - AI-DLC is public and open source: awslabs/aidlc-workflows. The method comes from this AWS DevOps blog post. > - My only written input was one sentence. Everything else I did was pick options and approve gates. > - The run produced requirements, four architecture decisions, a unit plan, a traceability file, and 12 tests that passed when I ran them, in about 44 minutes from start to the code approval. > - The human role moves from author to decision-maker. The decisions are still yours, and the record shows where they changed the outcome. The app The tool is a Spooky Date Fact Generator. It picks one of 10 spooky dates (or takes --date MM-DD) and prints a spooky event for that day from Wikipedia's "On this day" feed. It is small on purpose. The whole request was this sentence: /aidlc build random date generator with fact around that number The flip, in one table | | Traditional with AI help | AI-DLC | |---|---|---| | Requirements | Human writes them | AI drafts them from multiple-choice answers | | Design | Human sketches it | AI proposes, human picks between options | | Code | Human writes, AI assists | AI writes against an approved plan | | Tests | Human or AI at the end | AI writes them to the stated requirements | | Human job | Author | Decide, validate, approve | | Evidence | Pull request diff | Audit log, decisions, traceability file | Where everything was written Two locations matter. The generated app sits in its own folder, and the workflow record sits in an aidlc/ folder. app/ <- the generated tool src/spooky-facts.ts core: dates, fetch, keyword filter, nearest-date search src/cli.ts command line: options, output, exit codes test/ 12 tests, offline, with a fake fetch package.json, biome.json, tsconfig.json, README.md aidlc/ codekb/ <- 9 docs about the existing repo intents/<date>-date-fact-generator/ <- the record for this build aidlc-state.md stage checklist and settings audit/ every event, decision, and approval inception/ reverse-engineering/ scan of the existing repo requirements-analysis/ questions + requirements.md domain-design/ questions + components.md + decisions.md units-generation/ unit-of-work files construction/u1-spooky-date-cli/code-generation/ code-generation-plan.md, code-summary.md, unit-test-instructions.md, traceability.json Each stage writes a -questions.md file, and your answers sit in the same file as the AI's questions. That makes the decision trail readable without any tooling. Stage by stage: what it did and why it helps Initialization. The workflow scaffolded the record folder, scanned the workspace, and classified it as brownfield TypeScript on Bun. This is automatic. It helps because every later stage starts from facts about the repo instead of guesses. Reverse Engineering. A developer agent scanned the existing repo and wrote nine knowledge-base documents (architecture, technology stack, code structure and others). This matters most in a brownfield repo. The later code followed the repo's Bun, TypeScript, and Biome conventions because the AI had read them first. Requirements Analysis. The AI asked six questions, such as where facts come from, what to do when a date has no spooky event, and whether this should be a web page or a command line. Each had lettered options. I answered, and one answer included a free-text note: generate dates that have spooky facts, from 10 such scenarios. The result is requirements.md with 15 functional and 5 non-functional requirements, each with acceptance criteria and a tag pointing back to the answer that produced it, such as [Q5]. This stage turns one vague sentence into testable statements before any code exists. Domain Design. Four more questions produced components.md and four architecture decision records. The AI recommended splitting the tool into four parts. I chose two. The decision record states that the four-part split was rejected as more structure than needed. This is the clearest example of the validator role, because the human overrode the AI's recommendation and the reason is recorded. Units Generation. One question: build as one unit or two. I chose one, approved the plan, and got unit-of-work.md with boundaries, responsibilities, and constraints. It fixes the blast radius. The unit is limited to apps/date-fact-generator/, and nothing in the framework folders may change. Code Generation. The AI wrote a plan, then the code, then the tests. It asked one thing here: Bun was not installed, so should it install Bun or switch to Node. I chose Bun. The code-summary.md lists files created, decisions made, and deviations from the plan. One deviation worth noting: the AI added word forms such as "dies" and "sank" to the keyword list, because whole-word matching would otherwise miss "Houdini dies". The summary also states that some planned checks were grouped into fewer tests to match the 12-test count. Build and Test. In the record I read, this stage was still marked in progress, so its summary was not written yet. I ran the checks myself from the app folder: 12 tests passed with 93 assertions, and lint and typecheck were clean. What the human actually did Reading the audit log, the human work was this: - Typed one sentence to start. - Approved a recomposed plan. The workflow's planner found the default express plan was missing inputs for later stages, and proposed adding Domain Design and Units Generation while dropping deployment and monitoring. - Answered 11 multiple-choice questions across three stages, plus two more decisions: build inside the existing repo, and install Bun. - Approved 5 stage gates: reverse engineering, requirements, domain design, units, and code generation. That is the flip. Nothing in that list is writing a spec or writing code. All of it is deciding and checking. Why this holds up better than a prompt Two details make the validation real instead of a rubber stamp. First, traceability. traceability.json maps every requirement ID (FR1.1 through NFR5, 20 in total) to the file that satisfies it. If a requirement has no target, it shows up. Second, the record is complete. The audit log holds the timestamp of each question, answer, and approval. A reviewer six months later can see why the design has two components and not four. What this run does not show This was an express-scope build of a 658-line app, with plan approval, sensors, and learnings switched off by the scope. It shows the shape of the method, not how it behaves on a large system with many units and teams. The Build and Test stage had not completed in the record, and the tests are offline, so only one manual live smoke run, recorded in the code summary, exercised the real Wikipedia feed. The questions are only as good as the options offered. A weak option list can steer a hurried approver, so reading the alternatives still matters. Takeaway The useful change is not that the AI writes more code. It is that the human's time moves to the points where judgment counts: what the tool should do, which design to accept, and whether the result meets the requirements. If you want to try it, start with a small brownfield repo and read the -questions.md files first. They show the method faster than any description.
TelegrafをARMv6で使う こんにちは。ナウア株式会社の柳です。 私たちは、測定装置とエッジAI端末を組み合わせた調査機器を屋外と屋内に置いて運用しています。 各端末の死活とシステムのメトリクスは、TelegrafでAWS IoT Coreに送っています。 手に入りやすいRaspberry Pi Zero W(以下、ラズパイ)も、軽いデータ取得などに使えるハードウェアです。 このラズパイに同じTelegrafを入れようとすると、公式の配布物が思いのほか大きいことが分かりました。 バイナリは260.3MBあり、ラズパイでの常駐メモリは121.4MBになるため、手軽には入れられません。 そこで、必要なプラグインだけを入れて自前でビルドしました。 ビルドには、Telegrafのリポジトリにあるcustom_builderを使います。 ! この記事のまとめ 送り直しや溜める量の上限といった障害時の動作を設定で指定できるため、端末からの送信にはTelegrafを使っています custom_builderで必要なプラグインだけを入れると、バイナリは260.3MBから32.0MBに、常駐メモリは121.4MBから21.5MBに減りました ARMv6向けには、custom_builder自体は手元のPC向けにビルドし、実行時にだけGOARCH=armとGOARMを渡します 背景 Telegrafは、InfluxDataが開発しているメトリクス収集のエージェントです。 CPUやディスクの値を集める入力と、InfluxDBやMQTTへ送る出力を、プラグインとして組み合わせて使います。 ラズパイのTelegrafにさせる仕事は、次の2つです。 ディスク、温度、稼働時間、ネットワークの送受信量、Telegraf自身のメモリを1分ごとに集める 集めた値を1つのJSONにまとめ、TLSで暗号化したMQTTでAWS IoT Coreに送る 一方、Raspberry Pi Zero Wのリソースは限られています。 項目 Raspberry Pi Zero W CPU ARMv6、1コア メモリ 512MB(OSから使えるのは426MiB) ストレージ microSDカード ラズパイにはデータ取得などの本来の仕事があるため、監視用の常駐プログラムにはリソースをなるべく使わせたくありません。 また、モバイル回線は速度制限がかかると下りの速度が大きく下がり、配布するファイルが大きいほど転送に時間がかかります。 この記事では、速度制限時の参考値として下り200kbpsで計算します。 Telegrafを使う理由 平常時に値を送るだけなら、MQTTのライブラリを使った数十行のスクリプトで十分です。 スクリプトとTelegrafの違いは、回線や送り先が不調なときの動作に表れます。 AWSのIoT Lensには、端末のソフトウェアが備えるべきこととして次の項目が挙がっています1。 切れたら自動でつなぎ直す。つなぎ直す間隔は指数的に延ばし、端末ごとに乱数でずらす つながらない間は、送るべきデータを端末に溜める。使う容量に上限を設け、あふれたら古いものから上書きする つながったら、溜めた分を送る 信頼性が試された標準のライブラリを使う スクリプトで書く場合、つなぎ直しはMQTTのライブラリに任せられることが多いでしょう。 一方で、溜める量と捨て方や送れなかった分の扱いは、自分でコードを書いて組み立てます。 このコードは障害が起きたときにしか動かないため、試される機会が限られます。 Telegrafで対応する設定と動作は、次の表のとおりです。 障害時に必要なこと Telegrafの設定と動作 切れたらつなぎ直す MQTT出力が、初期設定で自動的につなぎ直す 送れなかった分を後で送る 書き込みに失敗したメトリクスをバッファに残し、次の送信で送り直す2 溜める量の上限と捨て方 metric_buffer_limitで上限を決める。あふれると古いものから上書きし、捨てた件数をログに出す32:1 電源が落ちても残す 実験的な機能のbuffer_strategy = "disk"で、ディスクに溜める3:1 多数の端末の送信を重ねない flush_jitterで、送る時刻を乱数でずらす3:2 Telegrafはアプリとは別のプロセスで動くため、送信の仕組みをアプリの外に置けます。 アプリの中でMQTTの接続を持つと、再起動や更新のたびに接続が切れます。 retainedのメッセージやLWTのような終了時の後始末も、アプリ側の責任です。 送信を別のプロセスに分けておけば、アプリの不具合や再起動があっても送信は止まりません。 端末の種類が増えても、送り方を同じ書式の設定でそろえられます。 応答を待つ時間のような回線に合わせた調整も、どの端末でも同じ項目を直せば済みます。 送る値を足すときも、多くは入力プラグインを設定に1つ足すだけです。 送受信量の毎秒の値のように、前回の値との差を取る計算も集計プラグインで書けます。 弱点は、公式の配布物が大きく、小さな端末には入れにくいことです。 送る値が数個で増やす予定もなく、欠けても困らないのであれば、スクリプトで十分です。 ほかの選択肢との比較 ほかの選択肢も同じ条件で、2026年9月時点の公式の文書やソース、配布物を調べました4。 Zero Wの実機で動かしたのはTelegrafだけです。 候補 ARMv6向けの入手先 AWS IoT CoreへMQTTで送る 送れない間に溜める Telegraf 公式のarmel版とarmhf版 MQTT出力 メモリに溜める。ディスクは実験的な機能。あふれると古い方から上書き スクリプトとMosquittoのブリッジ Raspberry Pi OSのapt ブリッジで転送 local_cleansession falseのときだけ溜める。あふれると新しく来た方を捨てる Vector 名前がARMv6向けの配布物はあるが、中身はARMv7向けで、本体は134.7MB mqtt sink。beta版で、送達はbest effort メモリかディスク。ディスクは最小約256MiB。あふれると待つか、新しく来た方を捨てる Fluent Bit 公式のRaspberry Pi OS向けのリポジトリ。bookwormまで MQTTの出力が無い ディスクに溜め、上限を超えると古い方から捨てる collectd Raspberry Pi OSのapt mqttプラグイン。リリース版は独自の形式で送り、JSONにできない 溜めない AWS IoT Device Client 無い。対応の一覧にarmv6lが無い できる。集めるのは通信量や接続の値だけ 文書に記述なし あふれたときに捨てるデータも、ツールによって違いました。 IoT Lensが勧める「古いものから上書き」になるのは、この中ではTelegrafとFluent Bitです。 MosquittoとVectorは、新しく来た方を捨てます。 Mosquittoのブリッジは、local_cleansessionがtrueだと、切断中のメッセージを捨てます。 local_cleansessionを書かなければ、cleansessionと同じ値が使われる仕様です。 AWSのブログにあるブリッジの設定例は、cleansession trueです5。 この設定例のまま使うと、切断中のメッセージは届きません。 AWS IoT Greengrassは、Java版と軽量版のnucleus liteのどちらも、armv6lに対応していません6。 AWSの文書には、端末の値をAWS IoTにMQTTで送るエージェントとして、次の3つが挙がっています7。 AWS IoT Device Client osqueryとMQTTクライアントの組み合わせ TelegrafのMQTT出力 全部入りのTelegraf Telegrafは、ARM向けにarmelとarmhf、arm64の版を配布しています。 まずarmel版のv1.35.1を、そのままラズパイに入れました。 動作はしますが、telegraf --versionを表示するだけで7.8秒かかりました。 大きさの理由は、起動時のログから分かります。 ログの時刻は省いています。 I! Starting Telegraf 1.35.1 brought to you by InfluxData the makers of InfluxDB I! Available plugins: 238 inputs, 9 aggregators, 34 processors, 26 parsers, 65 outputs, 6 secret-stores ログに出ているとおり、入力のプラグインだけでも238個です。 TelegrafはGoで書かれており、依存するライブラリも含めて静的にリンクされます。 そのため、Kafkaのクライアントや各クラウドのSDKのように、使わないプラグインが依存するものもバイナリに入ります。 項目 全部入り(公式のarmel版) バイナリ 260.3MB(配布のtar.gzは69.9MB) 常駐メモリ(RSS) 121.4MB 常駐メモリは、OSから使えるメモリの27%にあたる量でした。 custom_builderの仕組み Telegrafのリポジトリには、tools/custom_builderというツールが入っています8。 custom_builderに設定ファイルを渡すと、使っているプラグインだけでTelegrafをビルドできます。 プラグインの選び分けには、Goのビルドタグを使う仕組みです。 --dry-runと--tagsを付けると、ビルドせずに選ばれるプラグインだけを確認できます。 今回の設定ファイルでの結果は、次のとおりです。 ログの時刻は省いています。 $ /tmp/custom_builder --config build.conf --tags --dry-run I! Importing configuration file(s)... I! Found 1 configuration files... ------------------------------------------------------------------------------- Enabled plugins: ------------------------------------------------------------------------------- aggregators (1): derivative plugins/aggregators/derivative ------------------------------------------------------------------------------- inputs (5): disk plugins/inputs/disk net plugins/inputs/net procstat plugins/inputs/procstat system plugins/inputs/system temp plugins/inputs/temp ------------------------------------------------------------------------------- outputs (1): mqtt plugins/outputs/mqtt ------------------------------------------------------------------------------- parsers (0): ------------------------------------------------------------------------------- processors (0): ------------------------------------------------------------------------------- secretstores (0): ------------------------------------------------------------------------------- serializers (1): json plugins/serializers/json ------------------------------------------------------------------------------- Build tags: custom,aggregators.derivative,inputs.disk,inputs.net,inputs.procstat,inputs.system,inputs.temp,outputs.mqtt,serializers.json I! DRY-RUN: Skipping build. 設定ファイルのdata_format = "json"の行から、JSONのシリアライザーも選ばれています。 READMEには、使うパーサーとシリアライザーが一覧に入っているかを確かめるよう書かれています8:1。 ビルドの際、custom_builderはTelegrafのMakefileを呼び出します。 Makefileの中で実行されるgo buildは、次のとおりです。 CGO_ENABLED=0 go build -tags "custom,aggregators.derivative,inputs.disk,inputs.net,inputs.procstat,inputs.system,inputs.temp,outputs.mqtt,serializers.json" -ldflags " -X github.com/influxdata/telegraf/internal.Commit=f9ce32be -X github.com/influxdata/telegraf/internal.Branch=HEAD -X github.com/influxdata/telegraf/internal.Version=1.35.1" ./cmd/telegraf 設定ファイルの全体は、後半の「AWS IoT Coreに送る設定」に載せました。 ARMv6向けのクロスコンパイル ビルドはx86_64の手元のPCで行い、できたバイナリをラズパイへコピーします。 このとき、ARM向けの環境変数を渡す場所に注意が必要です。 custom_builderは、手元のPCの上で動くGoのプログラムです。 GOARCH=armを設定したままcustom_builderをビルドすると、ARM向けのバイナリができます。 このバイナリは、手元のPCでは実行できません。 golang:1.24のコンテナで試した結果は、次のとおりです。 $ GOOS=linux GOARCH=arm GOARM=5 go build -o /tmp/custom_builder ./tools/custom_builder $ /tmp/custom_builder --config build.conf sh: 1: /tmp/custom_builder: Exec format error そこで、ビルドを2段階に分けます。 custom_builderは、ARM向けの環境変数を設定せずに手元のPC向けにビルドする custom_builderを実行するときにだけ、GOOSとGOARCH、GOARMを渡す custom_builderは、自分の環境変数にBUILDTAGSを加えてmakeを呼び出します。 そのため、2段目で渡した値は中のgo buildまで届く仕組みです。 READMEの手順どおりmake build_toolsで作る場合は、この問題は起きません。 MakefileがGOOSとGOARCH、GOARMを外してからgo buildするためです9。 注意が必要なのは、custom_builderだけをgo buildで作る場合です。 GOARMは、ARMのどの世代向けにビルドするかを決める環境変数で、5から7のいずれかを指定します10。 クロスコンパイルで省くとARMv7向けの7になるため、ARMv6のラズパイ向けには値を明示します。 5は浮動小数点の計算をソフトウェアで行い、6と7はハードウェアで行います。 Telegrafの公式の配布物は、armel版がGOARM=5、armhf版がGOARM=6でビルドされています9:1。 今回は、ラズパイでの動作を確認したarmel版に合わせて5を選びました。 実際に使っているスクリプトは、次のとおりです。 Goは、公式のDockerイメージに含まれるものを使います。 build.sh #!/bin/sh 使うプラグインだけの Telegraf を ARMv6(Raspberry Pi Zero と Zero W)向けに作る set -eu git clone --depth 1 --branch v1.35.1 influxdata/telegraf src cp telegraf.conf src/build.conf docker run --rm -v "$PWD/src:/src" -w /src -e HOME=/tmp \ --user "$(id -u):$(id -g)" golang:1.24 sh -ec ' 1段目: custom_builder はこの PC 向けに作る go build -o /tmp/custom_builder ./tools/custom_builder 2段目: custom_builder から呼ばれる Telegraf のビルドだけを ARMv6 向けにする GOOS=linux GOARCH=arm GOARM=5 /tmp/custom_builder --config /src/build.conf' ls -l src/telegraf 私たちのPCでは、cloneを含めて約20秒でビルドが終わりました。 $ file src/telegraf src/telegraf: ELF 32-bit LSB executable, ARM, EABI5 version 1 (SYSV), statically linked, BuildID[sha1]=10cc34f12e15cb0226e198d2e2fd34f59e92ac71, with debug_info, not stripped 静的にリンクされているため、ラズパイ側にGoや追加のライブラリは不要です。 大きさとメモリ 必要なプラグインだけの版を起動したときのログは、次のとおりです。 このログは、手元のPC向けに同じ設定でビルドした版で取りました。 I! Starting Telegraf 1.35.1 (customized) brought to you by InfluxData the makers of InfluxDB I! Available plugins: 5 inputs, 1 aggregators, 0 processors, 1 parsers, 1 outputs, 0 secret-stores 全部入りの版との比較は、次の表のとおりです。 項目 全部入り(公式のarmel版) 使うプラグインだけ 入っているプラグイン 入力238、集計9、加工34、パーサー26、出力65、シークレットストア6 入力5、集計1、パーサー1、出力1 バイナリ 260.3MB 32.0MB 圧縮したとき 69.9MB(tar.gz) 15.4MB(gzip -9) 下り200kbpsで送る時間(計算値) 約47分 約10分 常駐メモリ(RSS) 121.4MB 21.5MB 常駐メモリは、ラズパイで同じ設定ファイルを使い、systemdのサービスとして動かしたときにpsで見た値です11。 Goのプログラムは、リンクされたパッケージの初期化処理を起動時にすべて実行します。 そのため、使わないプラグインの分も初期化でメモリを使っていると考えられます。 公式の配布物は、リンカーのオプション-w -sでデバッグ情報とシンボル表を削除しています9:2。 一方、上のfileの出力のとおり、今回のバイナリにはデバッグ情報が残っています。 2段目の環境変数にLDFLAGS="-w -s"を加えると、Makefileがリンカーのオプションに反映します。 その結果、バイナリは22.3MBまで小さくなり、gzip -9で圧縮すると7.9MBでした。 パーサーが1つ入っているのは、依存関係で自動的に入ったCSVのパーサーのためです。 READMEにも、依存関係で一覧に出ないプラグインが入る場合があると書かれています8:2。 スクリプトとも比べました。 送る値をそろえるため、入力をdiskとtemp、systemの3つに絞ったTelegrafを用意しました。 比べたのは、同じ項目を同じ形のJSONで送る、MQTTのライブラリを使ったPythonのスクリプトです。 ラズパイでの常駐メモリはTelegrafが20.3MB、スクリプトが20.1MBで、ほぼ同じでした12。 常駐メモリの点ではTelegrafの弱点がなくなり、残るのは32.0MBある配布ファイルの大きさです。 AWS IoT Coreに送る設定 ラズパイで使っている設定を、記事用に整理したものを載せます。 送り先、証明書のパス、JSONのキーは置き換えました。 この1つのファイルを、custom_builderとラズパイのTelegrafの両方に渡します。 telegraf.conf [agent] interval = "60s" round_interval = true flush_interval = "60s" omit_hostname = true 回線が戻ったときに、溜まった分を一度に送らない metric_batch_size = 60 metric_buffer_limit = 120 [[inputs.disk]] mount_points = ["/"] [[inputs.temp]] [[inputs.system]] [[inputs.net]] interfaces = ["eth0"] ignore_protocol_stats = true Telegraf 自身のメモリ(実行ファイルの名前で探す) [[inputs.procstat]] exe = "telegraf-slim" net の累積バイト数から毎秒の値を作る [[aggregators.derivative]] namepass = ["net"] period = "60s" drop_original = false suffix = "_per_sec" [[outputs.mqtt]] servers = ["ssl://xxxxxxxxxxxxxx-ats.iot.ap-northeast-1.amazonaws.com:8883"] client_id = "device-0001-telegraf" topic = "devices/device-0001/health" qos = 1 keep_alive = 30 tls_ca = "/etc/telegraf/AmazonRootCA1.pem" tls_cert = "/etc/telegraf/device.pem.crt" tls_key = "/etc/telegraf/private.pem.key" data_format = "json" layout = "batch" json_transformation = ''' ( $d := metrics[name="disk"]; $t := metrics[name="temp"]; $s := metrics[name="system"]; $p := metrics[name="procstat"]; $n := metrics[name="net" and $exists(fields.bytes_sent_per_sec)]; { "ts": $max(metrics.timestamp), "uptime_s": $s.fields.uptime, "disk_free_gib": $round($d.fields.free / 1073741824, 2), "disk_used_pct": $round($d.fields.used_percent, 1), "temp_c": $max($t.fields.temp), "rss_mib": $round($max($p.fields.memory_rss) / 1048576, 1), "tx_kbps": $round($sum($n.fields.bytes_sent_per_sec) * 8 / 1000, 1), "rx_kbps": $round($sum($n.fields.bytes_recv_per_sec) * 8 / 1000, 1) } ) ''' QoS AWS IoT Coreが対応しているMQTTのQoSは0と1だけで、QoS 2には対応していません13。 TelegrafのMQTT出力のサンプル設定では、qosの行が# qos = 2になっています。 コードの初期値は0です。 サンプルのコメントを外すだけではAWS IoT Coreに合わないため、qos = 1を明示しました。 client_id AWS IoT Coreでは、すでに使われているclient_idで新しく接続すると、先に接続していた側が切断されます14。 このときの切断理由はDUPLICATE_CLIENTIDです。 同じ端末の別のプログラムが同じclient_idを使うと、互いに相手の接続を切断します。 どちらも自動で再接続する作りなら、この切断が繰り返されます。 私たちは、Telegrafのclient_idの末尾に-telegrafを付けて区別しました。 メッセージのまとめ方 MQTT出力のlayoutの初期値はnon-batchです。 この設定では、メトリクス1つごとに1つのメッセージが送られます。 今回の設定は入力が5つあるため、1分ごとに少なくとも5つのメッセージが別々に届きます。 layout = "batch"では、同じトピックに送るメトリクスを1つのメッセージにまとめて送ります15。 メトリクスは、JSONのmetricsという配列に入る形です。 v1.25.2で非推奨になったbatch = trueは、v1.35.1では起動時のエラーになります。 E! DeprecationError: Option "batch" of plugin "outputs.mqtt" deprecated since version 1.25.2 and will be removed in 1.35.0: use 'layout = "batch"' instead E! loading config file telegraf.conf failed: error parsing mqtt array, plugin options "batch" deprecated JSONの形 TelegrafのJSONは、メトリクスごとの入れ子の形です。 1つのメトリクスは、nameとtagsとfieldsとtimestampのキーを持ちます。 受け手が平らなJSONを求める場合は、json_transformationにJSONataの式を書きます15:1。 layout = "batch"のときは、式に{"metrics": [...]}の形のJSONが渡されます。 そのため、metrics[name="disk"]のようにしてメトリクスを取り出します。 Telegrafが使っているJSONataは1.5.4です。 JSONataの試し場で式を試すときは、そのバージョンを選んでください15:2。 手元のPCで、送り先を手元のMQTTブローカーに替えて動かしました。 届いたメッセージは次のとおりで、値はPCのものです。 {"disk_free_gib":20.71,"disk_used_pct":97.7,"rss_mib":24.6,"rx_kbps":63.3,"temp_c":62,"ts":1790658710,"tx_kbps":40.9,"uptime_s":441385} 起動してしばらくの間は、tx_kbpsとrx_kbpsが含まれません。 aggregators.derivativeは2つの時点の値の差から毎秒の値を計算するため、最初のうちは値を出しません。 JSONataは値の無いキーを出力しないため、この2つのキーはメッセージに含まれません。 設定を変えるとき この設定ファイルはビルドの設定も兼ねているため、プラグインを足したらビルドし直してください。 ビルドに入っていないプラグインを設定に書くと、Telegrafは起動しません。 outputs.fileの無いバイナリにoutputs.fileを書いた設定を渡すと、次のエラーで止まります。 E! loading config file telegraf.conf failed: error parsing file array, undefined but requested output: file プラグインの種類を間違えた場合は、custom_builderがビルドの前に止まります。 たとえばderivativeはaggregatorsにあり、processorsにはありません。 I! filtering packages failed: configured but unknown packages "processors.derivative" このメッセージには、情報を表すI!の印が付いています。 それでもcustom_builderは終了コード1で終わり、バイナリは作られません。 ビルドのスクリプトでは、set -eなどで終了コードを確認して止まるようにしてください。 JSONataの式を調整するときは、同じ設定ファイルで手元のPC向けのバイナリも作ると、すぐに試せます。 custom_builderをGOOSやGOARCHなしで実行すれば、手元のPCで動く版ができます。 送り先を手元のMQTTブローカーに替えれば、ビルドに入っているプラグインのまま、届くJSONの確認が可能です。 形が決まってからARM向けをビルドしてラズパイへ転送すれば、遅い回線での転送は1回で済みます。 まとめ 端末から送る仕組みで差が出るのは障害時の動作で、Telegrafは障害時の動作を設定で指定できます custom_builderで必要なプラグインだけを入れると、バイナリは260.3MBから32.0MBに、常駐メモリは121.4MBから21.5MBに減りました ARMv6向けには、custom_builderを手元のPC向けにビルドし、実行時にだけGOOS=linux GOARCH=arm GOARM=5を渡します AWS IoT Coreに送るときは、QoSを1にし、client_idを端末のほかの接続と分けます 脚注 AWS Well-Architected Framework IoT LensのFailure managementのIOTREL10、IOTREL11、IOTREL12です。標準のライブラリの例として挙げられているのはFreeRTOSやAWSのSDKで、Telegrafは含まれていません。 ↩︎ Telegrafの出力を受け持つ処理(models/running_output.go)では、書き込みに失敗したメトリクスが次の書き込みのために残ります。バッファがあふれて捨てたときは、その件数が警告のログに出ます。 ↩︎ ↩︎ Telegrafの設定のドキュメントのagentの節です。 ↩︎ ↩︎ ↩︎ 調べた版と資料は次のとおりです。Mosquittoは、mosquitto.confの説明と、v2.0.21のソースで確かめました。ソースはsrc/database.cとsrc/bridge.cです。溜める上限のmax_queued_messagesの初期値は1000件です。Vectorは、v0.58.0のmqtt sinkとバッファの説明を読みました。arm-unknown-linux-musleabi版の本体のELFの属性も見ています。属性はTag_CPU_archがv7で、NEONも含まれていました。Fluent Bitは、v5.1.2のプラグインの一覧でMQTTが入力だけであることを確認しました。バッファの扱いは、バッファの説明によります。collectdは、2020年9月の5.12.0のsrc/collectd.conf.podとsrc/mqtt.cです。AWS IoT Device Clientは、READMEと2025年5月のv1.10.1を参照しています。 ↩︎ How to Bridge Mosquitto MQTT Broker to AWS IoTの、2020年5月の更新版を参照しました。 ↩︎ Greengrass nucleusの説明と、nucleus liteのセットアップの文書です。Java版の文書では、一覧に無い環境について、動くかもしれないが試験はしていないとしています。 ↩︎ AWS IoT Device Defender detect feature availability changeです。Device DefenderのDetect機能は、2026年8月31日から新規の顧客に提供されなくなりました。この文書はそれに合わせたもので、想定しているのは待ち受けポートや通信量などのセキュリティ向けの値です。 ↩︎ custom_builderのREADMEを参照しました。 ↩︎ ↩︎ ↩︎ TelegrafのMakefileによると、build_toolsはenv -u GOOS -u GOARCH -u GOARM -- go buildで作られます。配布物はarmel版がGOARM := 5、armhf版がGOARM := 6で、リンカーのオプションは-w -sです。 ↩︎ ↩︎ ↩︎ go help environmentのGOARMの項に書かれています。goコマンドのドキュメントでも読めます。 ↩︎ 常駐メモリは、確認用にoutputs.fileも加えてビルドした版で測りました。設定はJSONのキーなどが記事のものと少し異なりますが、入力と集計のプラグインは同じです。MBは1,000,000バイトとし、psの値(KiB)から換算しています。 ↩︎ スクリプトは起動から約20秒後、Telegrafは約80秒後にpsで見た値でした。 ↩︎ AWS IoT CoreのMQTTのドキュメントに、QoS 2が要求されてもブローカーはPUBACKやSUBACKを返さないと書かれています。 ↩︎ AWS IoT Coreのライフサイクルイベントのドキュメントにある、切断理由の表を参照しました。 ↩︎ JSONシリアライザーのREADMEです。バッチのときにmetricsという配列が付くことと、JSONataが1.5.4に限られることが書かれています。 ↩︎ ↩︎ ↩︎
【ハーネスエンジニアリング】AI-DLC v2で割り勘計算アプリを作ってみた はじめに 最近はAIエージェントを使った開発がすっかり当たり前になってきています。AIエージェントに組み込んで使う、いわゆる「ハーネス」のツールやスキル、プラグインなども豊富にあって、どれを使えばいいのか迷ってしまうほどです。 本記事では、そうしたツールの一つであるAI-DLC v2を使って割り勘計算のシンプルなWebアプリを作り、AI駆動開発の流れを体験します。 結論からいうと、AIが質問し、人間が承認して進む流れは最後まで崩れず、アプリを完成させられました。ただし、承認時に表示される要約だけでは、回答で決めきれていなかった点に気づけず、レビュー結果のファイルを直接読む必要がありました。 ! 本記事は、AI-DLC 2.10.0とGitHub Copilot CLI 1.0.88の組み合わせで、スコープpocを1回実行したときの記録です。 AI-DLCとは AI-DLC(AI-Driven Development Life Cycle)は、AWS Labsが公開している、AIが主導して開発を進めるためのワークフローです。Claude Code、Kiro、Codex、Cursor、GitHub CopilotなどのAIコーディングツールに組み込んで使います。 AIからの質問に答えていくことで要件定義書や設計書、コードなどの成果物が生成され、人間がそれを承認していく流れで開発が進むのが特徴です。 開発は次の5つのフェーズを順に進みます。 フェーズ やること Initialization 作業フォルダの準備 Ideation 目的と範囲の整理 Inception 要件定義と設計 Construction コード生成とテスト Operation デプロイと運用 各フェーズは、intent-capture(意図の把握)やrequirements-analysis(要件分析)といったステージ(全33種類)の組み合わせでできています。ステージの終わりには人間が承認する場面(承認ゲート)があり、承認するまで次のステージには進みません。 AI-DLC v1とv2の違い AI-DLC v1は、InceptionとConstructionの2フェーズを、ステージごとに1回承認しながら進む流れでした。v2では、この流れが前後に広がり、作業の種類に合わせて実行するステージを選べるようになりました。 ステージの選び方のプリセットを、v2ではスコープと呼びます。新規プロダクトの最小版(MVP)を作るならmvp(23ステージ。Operationを省く)、新機能の開発ならfeature(33ステージすべて)、実現可能性の検証ならpoc(8ステージ)、バグ修正ならbugfix(9ステージ)というように、11種類が用意されています。v1と同じ流れはclassicとして残っています。どのスコープにも当てはまらない場合は、composerという仕組みがステージ構成を提案します。 ほかにもv2では、CopilotやClaude Codeなど複数のAIコーディングツールで同じエンジンが動くようになりました。成果物と進行状況は、監査ログとしてファイルに残ります。 導入方法 ! GitHub Copilot CLI(1.0.74以上)がインストール済みで、ログインできる状態を前提とします。Copilot CLI自体の導入手順は、GitHubの公式ドキュメントを参照してください。 1. AI-DLCをインストールする macOS・Linux・WSLでは、次のコマンドでインストールします。ここではv2.10.0を指定しています。 ターミナル curl -fsSL github.com/awslabs/aidlc-workflows/releases/downl… | sh -s -- --version 2.10.0 ! このコマンドはrootユーザーではなく一般ユーザーで実行してください。 WindowsのPowerShell向けの手順は、公式READMEを参照してください。 2. 作業フォルダに設定する 作業フォルダに移動し、AI-DLCをCopilotから使う設定をします。--harnessには、AI-DLCを組み込む先のAIコーディングツールを指定します。 ターミナル mkdir warikan && cd warikan aidlc config --harness copilot 対話で聞かれる項目は、次のように答えました。 項目 選択 Record settings project Models Enter(Copilotのセッションモデルを使う) Provider 1(keep current) 3. 動作を確認する 同じフォルダでCopilot CLIを起動します。 ターミナル copilot ! フォルダを信頼するかを聞かれたら、「常に信頼する」選択肢を選びます。信頼しないと、AI-DLCのフックが警告なしに無効になります。 起動したら、次を入力します。 Copilot内で入力 /aidlc --doctor 0 problemsと表示されれば準備完了です。 割り勘計算アプリを作る: 8ステージ・承認5回で完成 起動: 指示は「フロントのみ・保存なし」だけ AI-DLCからの質問で仕様が固まっていく様子を見たかったので、起動時の指示は最小限にしました。技術スタックや細かい仕様は書いていません。 Copilot内で入力 /aidlc 割り勘計算アプリを作りたい。ブラウザだけで動くフロントエンドのみの Webアプリで、データは保存しない 既製のスコープがぴったり合わないとして、進め方を聞かれました。 Copilot内の表示 既製のプランがこのタスクにぴったり合いません。どう進めますか? プラン: ❯ compose (recommended) express mvp feature Other (type your answer) composeを選ぶと、composerがステージ構成を提案してきます。提案には、33ステージそれぞれを実行するか(EXECUTE)飛ばすか(SKIP)とその理由が並びます。今回は8ステージを実行、25ステージを飛ばす構成で、既製のpocと同じでした。飛ばされた理由は「単機能・静的フロントエンドのみで、外部API・インフラ・運用要件がないため」でした。 この提案をApproveすると、ワークフローが始まります。 各ステージの進み方 質問のあるステージは、どれも同じ順序で進みます。 AI-DLCが質問ファイルを作る。各質問にはA〜Dの選択肢と「Other(自由記述)」がある 人間が質問に答える。チャットで順に答える(Guide me)か、ファイルを直接編集する AI-DLCが回答の要約を示し、合っているかを確認する 成果物が生成され、AIによるレビューが走る。判定(READY / NOT-READY)と指摘は、reviews/フォルダにファイルとして書き出される 人間がApprove(承認)かRequest Changes(修正依頼)を選ぶ 選択肢で迷ったときは、Otherで相談できます。AI-DLCが選択肢の違いと実装への影響を整理し、聞き直してくれます。 Ideation(目的と範囲の整理): 目的と範囲を5つの質問で決定 最初のステージintent-captureでは、課題と利用者、成功の基準、きっかけ、決定権、製品の範囲の5問を聞かれました。端数の扱いのような細かい仕様は、ここではまだ聞かれません。 一番迷ったのは、課題と利用者を聞くQ1でした。Otherで相談すると、「均等割りのみ」「傾斜配分まで」「品目ごとの分担まで」の3段階に整理して聞き直してくれました。今回は傾斜配分(人によって割合や金額を変える)まで含め、品目ごとの分担は範囲外にしました。 成果物は、意図の記述書とステークホルダーマップです。レビュー結果はREADYで、軽微な指摘が3件ありました。「成功の基準が測定できない」などで、次の要件分析で具体化するとされたため、そのまま承認しました。 Inception(要件定義と設計): レビューがNOT-READYで差し戻し requirements-analysisでは、仕様の細部を7問聞かれました。Ideationの指摘を受けて、「結果が表示されるまでの速さ」のような測れる基準の質問も入っています。ここでは、主な2つの質問を紹介します。 requirements-analysis-questions.md(抜粋) Q1. 端数の扱い A. 1円単位で切り上げ、集める合計が少し多くなる分は「余り」として画面に明示する B. 1円単位で切り捨て、足りない分を「幹事が負担する額」として画面に明示する C. 1円単位に丸め、余りの 1円ずつを何人かに配分して、合計をちょうど一致させる D. 100円単位など、切り上げる単位を利用者が選べる Q2. 傾斜配分の指定方法 A. 人ごとに割合(重み)を指定する(例: 先輩1.5、後輩1、幹事0.5) B. 人ごとに固定金額を指定し、残りを他の人で割る(例: Aさんは 2,000円、残りは均等) C. Aと Bの両方に対応する Q1はD、Q2はCと答えました。残りの5問では、日本円の整数のみ、参加人数は2〜50人、入力のたびに1秒以内で自動再計算、日本語のみ、技術はおまかせ、を選びました。 ところが、Q1のDには「切り上げで多く集まった分(余り)をどう扱うか」が含まれていませんでした。回答した時点では、余りの扱いを決めていないことに気づいていませんでした。回答の要約でも「矛盾なし」とされ、聞き直されることはありませんでした。 これに気づいたのは、要件定義書の生成後に走ったレビューです。判定はNOT-READY、指摘は6件(重大度は高2・中3・低1)でした。高の2件は「固定金額の人にも切り上げ単位を適用するか」と「切り上げ単位の選択肢の食い違い」で、どちらもQ1の考慮漏れが原因です。AIが自分で作った要件定義書をセルフレビューし、回答した筆者も見落としていた点を指摘できていたことには感心しました。 Request Changesを選ぶと、「指摘をすべて反映し、未解決の点はAIが妥当な案を決める」などの進め方が示されます。今回はAIに決めさせず、次のように具体的な修正を指示しました(FR・NFRは、要件定義書で機能要件・非機能要件に振られた番号です)。 Copilot内で入力 次のとおり修正してください。 1. 固定金額の人には切り上げ単位を適用せず、指定した金額のままとする。切り上げは割合で配分する人だけに適用する 2. 切り上げ単位の選択肢は 1円・10円・100円の 3つとし、初期値は 1円とする。 3. 割合配分で丸めが起きる場合の余りの定義と数値例を追加する。FR3.3は余りの定義に合わせて書き直す 4. 異常系を追加する:合計金額は 1円以上、割合は0より大きい数、全員が固定金額で合計金額と一致しない場合はエラー 5. 出典のない要件(NFR4〜6、FR2.3〜2.5)は、前提として扱うことを明記する 6. 1秒の計測条件と、スマホの画面幅(例: 360px)を明記する 修正後の再レビューはREADYでした。受け入れ基準の数値例4件も、レビューの中で検算されています。低の指摘が3件残りましたが、そのまま承認しました。 Construction(コード生成とテスト): 計画を承認し、コードとテストを生成 Constructionのcode-generationでは、質問の代わりに、コード生成の計画への承認を求められます。提示された計画の要点は次のとおりです。 項目 内容 形態 素のHTML/CSS/JavaScript。サーバー・ビルド・外部ライブラリなし 計算ロジック src/calc.js。浮動小数点の誤差を避けるため、BigIntの整数計算のみ 画面 index.html・styles.css・src/app.js。入力のたびに自動で再計算 テスト Node.js標準のnode:testで計算ロジックを単体テスト。画面は手動で確認 技術スタックは、Inceptionで「おまかせ」と答えていたため、ここで初めて決まりました。 計画には、要件にない前提が1つ入っていました。「割合は小数第2位まで、上限なし」です。これはInceptionの再レビューで残した低の指摘(割合の桁数と上限が未定義)への対応で、計画自身が「承認時に確認してほしい」と明記していました。検証用の小さなアプリなので、この前提のまま承認しました。 承認すると、作業フォルダにアプリ本体と単体テストが生成されます。 フォルダ構成 warikan/ ├── index.html ├── styles.css ├── package.json ├── README.md ├── src/ │ ├── app.js # 画面 │ └── calc.js # 計算ロジック └── tests/ └── calc.test.js # 単体テスト(13件) 承認時の要約は「重大な指摘なし」でした。ところが、reviews/review-01.mdを開くと、指摘が3件(低2・情報1)書かれていました。たとえば次の2件です。 index.htmlをダブルクリックで直接開くと動かず、静的サーバーが必要になる 画面を自動で確認するテストがない どちらもpocの範囲としては許容できるので、修正せずに承認しました。ただ、要約だけを見ていたら、気づかずに通していたはずです。要約を鵜呑みにせず、ファイルを自分の目で確かめておいてよかったと思いました。 続くbuild-and-testでは、テストが実行されました。13件すべて成功し、50人分の計算は、計算ロジックだけで約0.5〜1.3msでした。画面の確認はAI-DLCの工程に含まれず、人に委ねられます。そこでブラウザで開き、次の4点を確かめてから最後の承認をしました。 幅360pxの画面でも操作できる 画面が日本語で表示される 入力のたびに自動で再計算される 通信が発生しない できたアプリ 合計金額と人数を入れると、1人あたりの額が自動で表示されます。人ごとに割合か固定金額を指定でき、切り上げ単位(1円・10円・100円)で生じた余りも表示されます。 まとめ AI-DLC v2で割り勘計算アプリを作り、8ステージと承認ゲート5回を経て完成させました。起動時に伝えたのは「フロントのみ・保存なし」だけでしたが、目的と範囲、仕様の細部、技術スタックの順に、段階を分けて聞いてくれました。端数の扱いのように自分でも詰めきれていなかった論点を、質問の形で突きつけてくれるのは心強いです。また、要件定義書等のドキュメント以外に、こうした質疑応答の内容もすべてファイルとして保存しておいてくれるのは、作業を中断して再開するときや、後から振り返るのに非常に役立つと思いました。 その一方で、承認時に表示される要約はあまり当てになりませんでした。Inceptionでは、要約が「矛盾なし」とした回答に、レビューがNOT-READYを出しました。Constructionでは、要約が「重大な指摘なし」だったのに、レビューファイルには指摘が3件ありました。AI-DLCを使うときは、承認の前にreviews/のファイルと計画の前提を開いて読むことをおすすめします。また、アプリの画面はAI-DLCの工程では確認されないので、人間が確かめる必要があります。 今回は小さなアプリの作成をpocで1回試しただけなので、ほかのスコープや題材では違う動きをするかもしれません。機会があればmvpやfeatureのような、ステージの多いスコープも試してみたいと思います。