Lambda Web Functions の L2 マージされた!
aws-cdk#39210
7 likes · 5 reposts · 305 views
aws login + assumeRole + terraform のエラー回避
問題の概要
条件
aws login を使って認証する。
そこからさらに assumeRole して別ロールを使う
その状態で terraform を実行する
発生する現状: 以下のエラーが発生する
╷
│ Error: failed to load assume role arn:aws:iam::123456789012:role/MY-ROLE, of profile LOGIN-PROFILE, <nil>
│
│
╵
つまり、aws login と assumeRole を併用して terraform を使えない。
対処方法
!
本対処は aws-sdk-go-v2 の現時点の実装を利用したワークアラウンドであり、正しい設定ではなく今後の aws-sdk-go-v2 の更新で使えなくなる可能性があります。
以下のように assumeRole 用のプロファイルを作成する:
aws-sdk-go-v2 で aws login + assumeRole が失敗する問題回避用のプロファイル
[profile ROLE-PROFILE-go]
login_session = arn:aws:iam::123456789012:user/MY-USER
region = ap-northeast-1
role_arn = arn:aws:iam::123456789012:role/MY-ROLE
role_session_name = MY-USER
通常の aws login + assumeRole 用のプロファイル (参考)
[profile ROLE-PROFILE]
region = ap-northeast-1
role_arn = arn:aws:iam::123456789012:role/MY-ROLE
role_session_name = MY-USER
source_profile = LOGIN-PROFILE
aws login で作成されるプロファイル
[profile LOGIN-PROFILE]
login_session = arn:aws:iam::123456789012:user/MY-USER
region = ap-northeast-1
terraform 利用時にはこの ROLE-PROFILE-go を使う:
AWS_PROFILE=ROLE-PROFILE-go terraform init
事象の詳細
再現手順
前提条件
コンソールログイン用のユーザーもしくはロールがある (IAM ユーザー MY-USER とする)
そこから assumeRole して使うユーザーがある (IAM ロール MY-ROLE とする)
MY-USER でコンソールログインする。
以下コマンドで awscli の認証を行う。
aws login --profile LOGIN-PROFILE
~/.aws/config に以下のようなエントリーが作成される:
[profile LOGIN-PROFILE]
login_session = arn:aws:iam::123456789012:user/MY-USER
region = ap-northeast-1
~/.aws/config に以下のようなエントリーを作成する:
[profile ROLE-PROFILE]
region = ap-northeast-1
role_arn = arn:aws:iam::123456789012:role/MY-ROLE
role_session_name = MY-USER
source_profile = LOGIN-PROFILE
awscli でこのプロファイルで MY-ROLE として認証できることを確認する。
AWS_PROFILE=ROLE-PROFILE aws sts get-caller-identity
aws へのアクセスが発生する Terraform テンプレートで terraform を実行する。バックエンドに S3 バケットを使用するなど。
AWS_PROFILE=ROLE-PROFILE terraform init
バックエンドに S3 を使用しておらず AWS プロバイダーで AWS リソースにアクセスしている場合は init ではエラーにならず plan などでエラーになる。
以下のようなエラーが発生する。
╷
│ Error: failed to load assume role arn:aws:iam::123456789012:role/MY-ROLE, of profile LOGIN-PROFILE, <nil>
│
│
╵
原因
aws-sdk-go-v2 のバグによる問題です。
このため実際は terraform 固有の事象ではなく、 aws-sdk-go-v2 を使う golang アプリケーション共通の事象です。aws-sdk-go-v2 は AWS 公式のライブラリーのため、事実上、 golang 実装の AWS を扱うアプリケーションはすべて同じ問題に遭遇すると考えてよいでしょう。
正確にはバグなのか仕様なのかは不明です。本事象は 2026 年 6 月に
Login session (aws login) credentials rejected when used as source_profile for an assume-role profile #3446
で報告されており、#3447 で修正案のプルリクエストも提出されているのですが、2026 年 11 月現在もマージされていません。
原因は github.com/aws/aws-sdk-go-v2/config のプロファイルが認証情報を作成できるか判定する処理 で、 「login_session があるときは認証情報を作成する」という判定がないことによるものです。
その結果、source_profile に login_session (aws login 由来) によって認証するプロファイルを指定すると、認証情報が生成できないとして「failed to load assume role (元のプロファイルで role_arn に指定しているプロファイル), of profile (source_profile に指定したプロファイル), (エラー情報。error が生成されていないので常に nil)」というエラーになります。
回避方法
ワークアラウンドプロファイルによる回避
aws-sdk-go-v2 の実装上、同一プロファイル内に login_session と role_arn の両方を書くと login_session (aws login 由来) を使って認証情報を解決した後、 assumeRole する動作をします。
golang アプリケーション (aws-sdk-go-v2) 用の設定
login_session を書いて aws login の認証情報を使った後、 role_arn で assumeRole で認証情報を作成する
[profile ROLE-PROFILE-go]
login_session = arn:aws:iam::123456789012:user/MY-USER
region = ap-northeast-1
role_arn = arn:aws:iam::123456789012:role/MY-ROLE
role_session_name = MY-USER
コード上の該当箇所:
login_session があると aws login 由来の認証情報を解決してきて、そのまま認証情報の解決処理を続行する: config/resolve_credentials.go
role_arn があるとその時点の認証情報を使って assumeRole を行う: config/resolve_credentials.go
なお、このプロファイルは botocore では role_arn があるのに source_profile がないため不適切な設定と判定されるため、 awscli などで使うとエラーになります: github.com/boto/botocore/blob/1.43.123/botocore/c…
$ AWS_PROFILE=ROLE-PROFILE-go aws sts get-caller-identity
aws: [ERROR]: Partial credentials found in assume-role, missing: source_profile or credential_source
一方、今回のワークアラウンドプロファイルにさらに source_profile を追加すると、aws-sdk-go-v2 側でワークアラウンドが機能するよりも先に今回の問題となっているチェック処理に引っかかるため、元の通りのエラーになってしまいます。
このため、このワークアラウンドプロファイルは golang アプリケーション専用に、awscli 等で使うプロファイルとは別に作っておく必要があります。
switch role してから aws login する
AWS コンソール上で「他のロールに切り替える」の操作を行い、 MY-ROLE に切り替えてから aws login することで、直接 MY-ROLE の状態の認証情報を得ることが出来ます。
正規の方法で安全ですが、使用するロールが多い場合は切り替えのたびに aws login が必要なので煩わしいという問題があります。
また、aws login 用のトークン発行を許可するためにロールに SignInLocalDevelopmentAccess ポリシーを割り当てておく必要があります。
参考: コンソール認証情報を使用して AWS ローカル開発にログインする - AWS Command Line Interface
Appendix: terraform / opentofu の aws login 対応バージョン
terraform や opentofu で aws login を使うためには、一定以上のバージョンである必要があります。
また、S3 バックエンドを使う場合は terraform / opentofu 本体のバージョンが影響し、 AWS 上のリソースの作成については AWS プロバイダー (terraform-aws-provider) のバージョンが影響します。
必要な aws-sdk-v2-go のバージョン
github.com/aws/aws-sdk-go-v2/credentials v1.19.0 以上
github.com/aws/aws-sdk-go-v2/config v1.32.0 以上
already reviewed add logincreds provider - #3230 がマージされたバージョンを使う必要がある。
Terraform のバージョン: v1.15.0 以上、または v1.14.9 以上
v1.15.0 での更新: 12276b8
v1.14.9 での更新: c78dd8d
OpenTofu のバージョン: v1.12.0 以上、または v1.11.10 以上
v1.12.0 での更新: 5b6ca0a
v1.11.10 での更新: 5e94874
AWS プロバイダー (terraform-aws-provider) のバージョン: v6.23.0 以上、または v6.22.1 以上、
v6.23.0 / v6.22.1 での更新: bead7e4
v5 系は現時点の最新の v5.100.0 でもサポートなし
Node.js 26 LTS: planning the Lambda upgrade
Node.js 26 LTS has been official since October 28, 2026, under the codename “Lithium”. For me, an LTS switch like this is always the moment to stop eyeing the next release and write an upgrade plan instead. If you only touch your Lambda functions once the deprecation email from AWS lands in your inbox, you work under time pressure. And time pressure is a pretty bad advisor for runtime upgrades.
On AWS Lambda, the nodejs26.x runtime is already available as a public preview, and according to the documentation AWS is targeting general availability for November 2026. That window is valuable: you can test today without having to switch production today. I will show you what Node.js 26 brings, what breaks when you move from Node.js 22 or 24, and how a small probe function for a fictional bike rental tells you what the new Lambda runtime can really do.
> In short
>
> Node.js 26 became LTS on October 28, 2026 and receives updates until the end of April 2029, while nodejs26.x on Lambda is still a preview with a planned launch in November 2026. The most useful addition for everyday work is the Temporal API for dates and times. The biggest pitfall: what works under Node 26 on your laptop is not necessarily enabled the same way in the Lambda runtime, so check features there directly instead of assuming them.
What you will learn in this article
- What Node.js 26 LTS means for your support timeline on Lambda
- Which additions such as Temporal and Map.getOrInsert you can use right away
- Which changes can break when you upgrade from Node.js 22 or 24
- How to test nodejs26.x next to nodejs24.x with AWS CDK
- How to find every Node runtime in your AWS account
What Node.js 26 LTS means for your timeline
LTS stands for Long Term Support: a version receives security and bug fixes over a long period, and new features only arrive cautiously. Release 26.13.0, which officially starts Node.js 26 LTS, is purely formal. According to the release notes, it only switches metadata such as process.release to LTS and contains nothing else beyond 26.12.1. Updates for Node.js 26 LTS continue until the end of April 2029.
The Node.js project is also changing its rhythm. Until now, only even-numbered versions became LTS, while odd ones such as Node.js 25 were done after six months (25 reached EOL, or “end of life”, at the end of March 2026). Starting with Node.js 27, there will be one major version per year, and each of them becomes LTS after its six-month Current phase. For teams, that means fewer versions and a predictable cadence.
Here is where Lambda stands at the end of October 2026:
| Runtime | Node.js line | Status on Lambda |
| --- | --- | --- |
| nodejs22.x | LTS “Jod” | Deprecated on Apr 30, 2027, updates blocked from Aug 31, 2027 |
| nodejs24.x | LTS “Krypton” | Deprecated on Apr 30, 2028 |
| nodejs26.x | LTS “Lithium” | Public preview, general availability planned for November 2026 |
!Timeline of Node.js runtimes on AWS Lambda: nodejs22.x until April 2027, nodejs24.x until April 2028, nodejs26.x from November 2026
For many teams, the date that matters most is the end of nodejs22.x in April 2027.
My take: for many teams, the real deadline is not the launch of Node.js 26 but the deprecation of nodejs22.x. That leaves roughly six months. If you plan now, you can fit the upgrade into your normal sprint cadence.
Node.js 26 LTS features you will notice day to day
Coming from Node.js 24, you get not only the changes in Node.js 26 LTS but also everything from Node.js 25. That intermediate version was never LTS, so many teams skipped it. A few points stand out for daily work.
Temporal: dates and times without the headache
Node.js 26 LTS ships the Temporal API enabled by default, no flag required. Temporal is the new standard for dates and times in JavaScript and fixes the weaknesses of Date: months that start at 0, no real time zone arithmetic, and objects you can mutate by accident. Instead, you get clear types. Temporal.Instant is an exact point in time, Temporal.ZonedDateTime is a point in time in a specific time zone, and Temporal.PlainDate is a plain calendar date.
const pickup = Temporal.ZonedDateTime.from("2026-10-24T10:00[Europe/Berlin]");
const due = pickup.add({ days: 2 });
console.log(due.toString());
// 2026-10-26T10:00:00+01:00[Europe/Berlin] (same wall-clock time, even though the DST change happens in between)
We will use exactly this example in the probe function later. With Date and “plus 48 hours”, you end up at 9 a.m. instead of 10 a.m. on October 26, because the clocks go back during the night before October 25. I cover how to use Temporal day to day for deadlines, time zones and DynamoDB in detail in Temporal API in TypeScript: deadlines and time zones.
Small helpers: getOrInsert, Iterator.concat and Base64
V8 14.6 adds two small but pleasant functions. Map.prototype.getOrInsert() and getOrInsertComputed() read a value or create it if it is missing. The classic “does this key exist yet?” pattern for grouping shrinks to a single line. Iterator.concat() chains several iterators without building intermediate arrays.
const rentals = [
{ station: "Central Station", bikeId: "city-bike-17" },
{ station: "Old Town", bikeId: "e-bike-03" },
{ station: "Central Station", bikeId: "cargo-bike-02" },
];
const byStation = new Map<string, string[]>();
for (const rental of rentals) {
byStation.getOrInsert(rental.station, []).push(rental.bikeId);
}
// Map { "Central Station" => ["city-bike-17", "cargo-bike-02"], "Old Town" => ["e-bike-03"] }
Node.js 25 contributed, for example, Uint8Array.fromBase64() and toBase64() (plus hex variants) and a noticeably faster JSON.stringify. In Lambda functions that build lots of JSON responses or encode binary data for API Gateway, that is a nice bonus for free. One observation from my test: with "lib": ["esnext"], TypeScript 7.0.2 already knows the types for Temporal, getOrInsert and fromBase64, but still reports an error for Iterator.concat.
Platform: Undici 8, Web Storage and permissions
Undici, the built-in HTTP client behind fetch, moves to version 8. Since Node.js 25, the Web Storage API is enabled by default, but in Node.js 26 localStorage returns undefined as long as no storage file is configured. Since Node.js 25, the permission model also knows --allow-net, which lets you restrict a process’s network access. On Lambda, that matters more for local tools and CI scripts, because isolation there is handled by IAM and VPC anyway.
What breaks when you move to Node.js 26 LTS
Most projects keep running after the upgrade to Node.js 26 LTS. Still, there are a handful of changes worth checking deliberately before you switch the runtime:
- The old internal modules _stream_readable, _stream_writable and their siblings are removed in Node.js 26. Older libraries sometimes still import them directly.
- http.Server.prototype.writeHeader() is gone, use writeHead().
- Since Node.js 25, fs.F_OK, fs.R_OK, fs.W_OK and fs.X_OK are no longer exported directly from the module. The constants live under fs.constants.
- SlowBuffer and assert.CallTracker have reached end of life since Node.js 25.
- Corepack is no longer bundled since Node.js 25. If you enable pnpm or Yarn via Corepack, install it separately in CI.
- module.register() emits a runtime deprecation warning in Node.js 26, and --experimental-transform-types was removed.
- Native addons need a rebuild, because NODE_MODULE_VERSION changes to 147.
- Since Node.js 25, the official Linux binaries are linked against libatomic. Slim container images for CI may be missing that package.
On top of that on Lambda: what has applied since nodejs24.x
If you jump from nodejs22.x straight to 26, you also pick up the Lambda-specific changes AWS introduced with nodejs24.x. Callback handlers are no longer supported, context.callbackWaitsForEmptyEventLoop is removed, and so are context.succeed, fail and done. In addition, Lambda no longer waits for pending promises once your handler returns. Anything you still want to finish has to be completed with await first.
// Before: callback handler (no longer works from nodejs24.x)
export const handler = (event, context, callback) => {
loadBike(event.bikeId)
.then((bike) => callback(null, bike))
.catch((err) => callback(err));
};
// After: async handler, every piece of work is awaited
export const handler = async (event: { bikeId: string }) => {
const bike = await loadBike(event.bikeId);
await auditLog.write({ action: "lookup", bikeId: bike.id }); // without await this would get lost
return bike;
};
One more point is easy to miss: Lambda disables experimental Node.js features by default, such as require() of ES modules and automatic module detection. You can turn them back on via NODE_OPTIONS, but AWS covers such cases with neither support nor the SLA.
nodejs26.x on Lambda: the state at the end of October 2026
The nodejs26.x runtime is available as a public preview, both as a ZIP runtime and as the container base image public.ecr.aws/lambda/nodejs:26-preview. Preview means no SLA, no technical support, and breaking changes are possible. It is explicitly not meant for production workloads. AWS names November 2026 as the launch of the stable version, but only as a non-binding estimate.
The feedback issue for the preview on GitHub is revealing. Users report, among other things, that Temporal is missing in the preview and that process.features.require_module is false. These are community observations, not an official AWS statement, and a lot can change before the stable release. I still think the lesson matters: a Lambda runtime is not automatically identical to the Node.js on your machine. So let us check directly in Lambda.
> 🎬 Probe function on two Lambda runtimes – this animated diagram only runs on my site: watch it on sascha-ms.de
Step by step: testing nodejs26.x next to nodejs24.x
Our example is a fictional bike rental. A function calculates when a rented bike has to be back: same time of day, a given number of days later, in the time zone of the rental station. It also returns a small report on which features the runtime really provides. If Temporal is built in, the function uses it, otherwise a polyfill steps in. A polyfill is a retrofitted implementation of a standard API for environments that do not support it yet.
Prerequisites
- Node.js 22 or newer locally (thanks to the polyfill, the tests run without Node.js 26 too)
- AWS CLI v2 with credentials for a test or sandbox account
- An account bootstrapped with npx cdk bootstrap in your region
- A region where nodejs26.x is already selectable. You can see that in the Lambda console when creating a function.
Step 1: create the project
mkdir bike-rental-runtime-check && cd bike-rental-runtime-check
npm init -y
npm install @js-temporal/polyfill@0.5.1
npm install -D typescript@7.0.2 @types/node@26 @types/aws-lambda tsx esbuild aws-cdk-lib@2.275.0 constructs aws-cdk@2.1146.1
mkdir -p bin lib src test
Switch package.json to ES modules and add a few scripts:
{
"name": "node26-lambda-upgrade",
"version": "1.0.0",
"scripts": {
"typecheck": "tsc --noEmit -p .",
"test": "node --import tsx --test test/*.test.ts",
"synth": "cdk synth",
"deploy": "cdk deploy"
},
"license": "MIT",
"devDependencies": {
"@types/aws-lambda": "^8.10.164",
"@types/node": "^26.6.6",
"aws-cdk": "^2.1146.1",
"aws-cdk-lib": "^2.275.0",
"constructs": "^10.8.1",
"esbuild": "^0.28.2",
"tsx": "^4.23.15",
"typescript": "^7.0.2"
},
"dependencies": {
"@js-temporal/polyfill": "^0.5.1"
},
"private": true,
"type": "module"
}
Step 2: configure TypeScript
With "lib": ["esnext"], TypeScript knows the new APIs such as Temporal. noEmit is enough, because esbuild bundles during deployment and tsx runs the tests directly.
// tsconfig.json
{
"compilerOptions": {
"target": "es2024",
"lib": ["esnext"],
"module": "nodenext",
"moduleResolution": "nodenext",
"strict": true,
"types": ["node"],
"noEmit": true,
"skipLibCheck": true
},
"include": ["bin", "lib", "src", "test"]
}
Step 3: write the runtime report
The function checks every feature directly instead of inferring it from the version number. That is more reliable, because a runtime can have a feature disabled despite a matching version.
// src/runtime-report.ts
// Reports which runtime features are actually available in the current Node.js environment.
export interface RuntimeReport {
nodeVersion: string;
ltsName: string | null;
temporal: boolean;
mapGetOrInsert: boolean;
iteratorConcat: boolean;
uint8ArrayBase64: boolean;
requireModule: boolean;
lambdaRuntime: string | null;
}
export function collectRuntimeReport(env: NodeJS.ProcessEnv = process.env): RuntimeReport {
const g = globalThis as Record<string, unknown>;
const iteratorCtor = g.Iterator as { concat?: unknown } | undefined;
return {
nodeVersion: process.version,
ltsName: process.release.lts ?? null,
temporal: typeof g.Temporal === "object" && g.Temporal !== null,
mapGetOrInsert: typeof (Map.prototype as { getOrInsert?: unknown }).getOrInsert === "function",
iteratorConcat: typeof iteratorCtor?.concat === "function",
uint8ArrayBase64: typeof (Uint8Array as { fromBase64?: unknown }).fromBase64 === "function",
requireModule: process.features.require_module === true,
// In Lambda, AWS_EXECUTION_ENV looks like this, for example: AWS_Lambda_nodejs24.x
lambdaRuntime: env.AWS_EXECUTION_ENV?.replace("AWS_Lambda_", "") ?? null,
};
}
Step 4: load Temporal with a fallback
// src/temporal.ts
// Uses the built-in Temporal if the runtime provides it, otherwise the polyfill.
export type TemporalApi = typeof Temporal;
export async function loadTemporal(): Promise<{ api: TemporalApi; source: "runtime" | "polyfill" }> {
const builtIn = (globalThis as { Temporal?: TemporalApi }).Temporal;
if (builtIn) {
return { api: builtIn, source: "runtime" };
}
const polyfill = await import("@js-temporal/polyfill");
return { api: polyfill.Temporal as unknown as TemporalApi, source: "polyfill" };
}
The dynamic import ensures that the polyfill is only loaded when it is needed. esbuild still bundles it, which makes the package about 150 KB larger. Once Temporal is reliably available in the stable runtime, you can remove the fallback.
Step 5: the rental logic
// src/rental.ts
// Fictional bike rental: when does a bike have to be back?
import type { TemporalApi } from "./temporal.js";
export interface RentalRequest {
bikeId: string;
pickupAt: string; // pickup time as an instant, e.g. "2026-10-24T08:00:00Z"
days: number; // number of rental days
timeZone: string; // time zone of the rental station, e.g. "Europe/Berlin"
}
export function validateRental(input: Partial<RentalRequest>): RentalRequest {
const { bikeId, pickupAt, days, timeZone } = input;
if (!bikeId || !pickupAt || !timeZone) {
throw new Error("bikeId, pickupAt and timeZone are required");
}
if (typeof days !== "number" || !Number.isInteger(days) || days < 1 || days > 30) {
throw new Error("days must be an integer between 1 and 30");
}
return { bikeId, pickupAt, days, timeZone };
}
// With Temporal, "2 days" means the same wall-clock time two calendar days later, even across a DST change.
export function returnDeadline(T: TemporalApi, req: RentalRequest): string {
return T.Instant.from(req.pickupAt)
.toZonedDateTimeISO(req.timeZone)
.add({ days: req.days })
.toString({ smallestUnit: "minute" });
}
// The classic Date approach just adds milliseconds. Across a DST change it is off by one hour.
export function naiveReturnDeadline(req: RentalRequest): string {
const start = new Date(req.pickupAt).getTime();
return new Date(start + req.days * 24 * 60 * 60 * 1000).toISOString();
}
Step 6: the Lambda handler
// src/handler.ts
// Lambda handler: returns the runtime report and the return deadline for a rental.
import { collectRuntimeReport } from "./runtime-report.js";
import { loadTemporal } from "./temporal.js";
import { naiveReturnDeadline, returnDeadline, validateRental, type RentalRequest } from "./rental.js";
const DEMO_RENTAL: RentalRequest = {
bikeId: "city-bike-17",
pickupAt: "2026-10-24T08:00:00Z",
days: 2,
timeZone: "Europe/Berlin",
};
// Deliberately async without a callback: since nodejs24.x, Lambda no longer supports callback handlers.
export const handler = async (event: Partial<RentalRequest> = {}) => {
const rental = validateRental({ ...DEMO_RENTAL, ...event });
const { api, source } = await loadTemporal();
return {
runtime: collectRuntimeReport(),
temporalSource: source,
rental: {
bikeId: rental.bikeId,
returnAt: returnDeadline(api, rental),
naiveReturnAtUtc: naiveReturnDeadline(rental),
},
};
};
Step 7: tests with the Node test runner
The tests cover the interesting case: a rental across the DST change on October 25, 2026. This shows in black and white why Temporal is worth the switch.
// test/rental.test.ts
import { test } from "node:test";
import assert from "node:assert/strict";
import { loadTemporal } from "../src/temporal.js";
import { naiveReturnDeadline, returnDeadline, validateRental } from "../src/rental.js";
const overDstChange = validateRental({
bikeId: "city-bike-17",
pickupAt: "2026-10-24T08:00:00Z", // 10:00 in Berlin (summer time)
days: 2,
timeZone: "Europe/Berlin",
});
test("Temporal keeps the wall-clock time across the DST change", async () => {
const { api } = await loadTemporal();
assert.equal(returnDeadline(api, overDstChange), "2026-10-26T10:00+01:00[Europe/Berlin]");
});
test("Date arithmetic ends up one hour early after the DST change", () => {
// 08:00 UTC on Oct 26 is 09:00 winter time in Berlin, not 10:00
assert.equal(naiveReturnDeadline(overDstChange), "2026-10-26T08:00:00.000Z");
});
test("without a DST change both approaches agree", async () => {
const { api } = await loadTemporal();
const normalWeek = { ...overDstChange, pickupAt: "2026-11-02T09:00:00Z" };
assert.equal(returnDeadline(api, normalWeek), "2026-11-04T10:00+01:00[Europe/Berlin]");
assert.equal(naiveReturnDeadline(normalWeek), "2026-11-04T09:00:00.000Z");
});
test("invalid rental duration is rejected", () => {
assert.throws(() => validateRental({ ...overDstChange, days: 0 }), /days must/);
assert.throws(() => validateRental({ ...overDstChange, days: 1.5 }), /days must/);
});
// test/runtime-report.test.ts
import { test } from "node:test";
import assert from "node:assert/strict";
import { collectRuntimeReport } from "../src/runtime-report.js";
import { handler } from "../src/handler.js";
test("report contains the Node version and detects the Lambda runtime", () => {
const report = collectRuntimeReport({ AWS_EXECUTION_ENV: "AWS_Lambda_nodejs26.x" });
assert.match(report.nodeVersion, /^v\d+\./);
assert.equal(report.lambdaRuntime, "nodejs26.x");
});
test("outside of Lambda, lambdaRuntime is null", () => {
assert.equal(collectRuntimeReport({}).lambdaRuntime, null);
});
test("handler returns report and return deadline", async () => {
const result = await handler({ days: 3 });
assert.equal(result.rental.bikeId, "city-bike-17");
assert.equal(result.rental.returnAt, "2026-10-27T10:00+01:00[Europe/Berlin]");
assert.ok(["runtime", "polyfill"].includes(result.temporalSource));
});
Step 8: the CDK stack with two runtimes
The installed version of aws-cdk-lib (2.275.0) does not ship nodejs26.x as a ready-made constant yet. That is not a problem, because new Runtime(...) lets you define any runtime yourself. The stack creates the same function twice. If you have not used AWS CDK with TypeScript before, my introduction Lambda function with CDK and TypeScript will help.
// lib/runtime-check-stack.ts
import * as path from "node:path";
import { CfnOutput, Duration, RemovalPolicy, Stack, type StackProps } from "aws-cdk-lib";
import { Architecture, Runtime, RuntimeFamily } from "aws-cdk-lib/aws-lambda";
import { NodejsFunction, OutputFormat } from "aws-cdk-lib/aws-lambda-nodejs";
import { LogGroup, RetentionDays } from "aws-cdk-lib/aws-logs";
import type { Construct } from "constructs";
// aws-cdk-lib does not ship a constant for nodejs26.x yet, so we define it ourselves.
const NODEJS_26_PREVIEW = new Runtime("nodejs26.x", RuntimeFamily.NODEJS, { supportsInlineCode: true });
export class RuntimeCheckStack extends Stack {
constructor(scope: Construct, id: string, props?: StackProps) {
super(scope, id, props);
const variants = [
{ name: "Node24", runtime: Runtime.NODEJS_24_X, target: "node24" },
{ name: "Node26", runtime: NODEJS_26_PREVIEW, target: "node26" },
];
for (const variant of variants) {
const logGroup = new LogGroup(this, `${variant.name}Logs`, {
retention: RetentionDays.ONE_WEEK,
removalPolicy: RemovalPolicy.DESTROY,
});
const fn = new NodejsFunction(this, `RentalCheck${variant.name}`, {
entry: path.join(import.meta.dirname, "../src/handler.ts"),
handler: "handler",
runtime: variant.runtime,
architecture: Architecture.ARM_64,
memorySize: 256,
timeout: Duration.seconds(10),
logGroup,
bundling: {
format: OutputFormat.ESM,
target: variant.target,
minify: true,
sourceMap: true,
// esbuild needs a require function in the ESM bundle for CommonJS dependencies
banner: "import { createRequire } from 'module'; const require = createRequire(import.meta.url);",
},
environment: {
NODE_OPTIONS: "--enable-source-maps",
},
});
new CfnOutput(this, `${variant.name}FunctionName`, { value: fn.functionName });
}
}
}
// bin/app.ts
import { App } from "aws-cdk-lib";
import { RuntimeCheckStack } from "../lib/runtime-check-stack.js";
const app = new App();
new RuntimeCheckStack(app, "BikeRentalRuntimeCheck", {
env: { region: process.env.CDK_DEFAULT_REGION ?? "eu-central-1" },
});
// cdk.json
{
"app": "npx tsx bin/app.ts"
}
Step 9: check, deploy and compare
npm run typecheck
npm test
npx cdk synth
npx cdk deploy
After the deployment, CDK prints both function names. Call both with the same request and compare the responses:
aws lambda invoke --function-name <Node24FunctionName> \
--cli-binary-format raw-in-base64-out --payload '{"days":2}' node24.json
aws lambda invoke --function-name <Node26FunctionName> \
--cli-binary-format raw-in-base64-out --payload '{"days":2}' node26.json
diff <(jq .runtime node24.json) <(jq .runtime node26.json)
This is the response when I call the bundled package locally under Node.js 22. Under nodejs26.x you expect a 26.x version, and exactly this report tells you which features are true there:
{
"runtime": {
"nodeVersion": "v22.23.4",
"ltsName": "Jod",
"temporal": false,
"mapGetOrInsert": false,
"iteratorConcat": false,
"uint8ArrayBase64": false,
"requireModule": true,
"lambdaRuntime": "nodejs24.x"
},
"temporalSource": "polyfill",
"rental": {
"bikeId": "city-bike-17",
"returnAt": "2026-10-26T10:00+01:00[Europe/Berlin]",
"naiveReturnAtUtc": "2026-10-26T08:00:00.000Z"
}
}
The rental field is what matters: the Temporal calculation returns 10 a.m., while the naive Date calculation lands at 9 a.m. local time. On both runtimes, returnAt has to be identical, no matter whether Temporal is built in or the polyfill does the math.
Common mistakes
- Runtime rejected: if your region does not offer the preview yet, the deployment fails. For the test, switch to a region where nodejs26.x is selectable in the console.
- “Dynamic require … is not supported”: this message appears when an ESM bundle contains a CommonJS dependency. The banner with createRequire in the stack prevents it.
- Callback handlers: old code with (event, context, callback) no longer works from nodejs24.x. Switch to async first.
- Green locally, different in Lambda: your tests may run with the polyfill locally and with built-in Temporal in Lambda, or the other way round. That is exactly why the report checks every feature in the real runtime.
Cost and security
Two small 256 MB functions that you call a few times cost practically nothing and usually stay within the free tier. With the Lambda cost calculator you can do the math for your real load profiles. Logs are deleted after one week, and npx cdk destroy removes everything again. Do not process real customer data in the preview runtime, since there is neither an SLA nor support for it.
Upgrade checklist for your team
Find the Node runtimes in your account
Before you plan, you need a list. This command shows every function with a Node runtime per region:
for region in eu-central-1 eu-west-1; do
aws lambda list-functions --region "$region" \
--query "Functions[?starts_with(Runtime, 'nodejs')].[FunctionName, Runtime]" \
--output text | sed "s/^/$region\t/"
done
Update TypeScript, bundler and CI
Bump @types/node to version 26, set the matching target in esbuild or on NodejsFunction, and add a Node.js 26 entry to your CI test matrix. Rebuild native addons. And if you use pnpm or Yarn via Corepack, install Corepack explicitly in the pipeline, since it is no longer bundled.
Roll out gradually
Once nodejs26.x is generally available, do not switch all functions at once. I would start with non-critical functions and use an alias with weighted routing to send only part of the traffic to the new version. New runtimes often have somewhat longer cold starts at launch, so keep an eye on error rate and init duration. How to build alarms and dashboards for that is covered in Monitoring Lambda with CDK.
My take
Testing: yes, now. Production on the preview: no. That is my position in a nutshell. The preview is a gift, because you find the surprises while nobody is rushing you yet. If you still run on nodejs22.x today, though, I would not wait for November. The move to 24 brings the biggest Lambda-specific breaks, namely callback handlers and promises that are not awaited. Those affect 26 just the same. Once that is done, the step from 24 to Node.js 26 LTS is small in most projects.
Temporal is what I am looking forward to most. I would rather not count how many time zone and daylight saving bugs I have seen in my career. At the same time, the preview shows that Lambda runtimes are deliberately more conservative than upstream Node.js. For stability, I think that is right, but it can catch you off guard. So my advice: check features in the runtime where your code will actually run. And while you are at it, take a look at your patch routine, because security updates like the one for the Node.js vulnerability CVE-2026-111497 arrive regularly for LTS versions too.
By the way, I like the new annual cadence starting with Node.js 27. A team can schedule one LTS upgrade per year instead of debating every time which version to skip.
FAQ
When will Node.js 26 be generally available on AWS Lambda?
AWS names November 2026 as the planned launch for nodejs26.x, explicitly as a non-binding estimate. Until then, the runtime is a public preview without SLA or support.
How long is Node.js 26 LTS supported?
According to the Node.js project, the 26.x line receives updates until the end of April 2029. When AWS will deprecate the Lambda runtime has not been announced yet.
Can I already use Temporal in Lambda?
Yes, via a polyfill such as @js-temporal/polyfill on any Node runtime. Whether Temporal is built in and enabled in nodejs26.x is best checked with feature detection as in the example.
Do I have to upgrade from Node.js 22 to 24 first?
No, you can go straight to 26. But then you have to handle all changes from 24 and 26 at once. Since nodejs22.x is deprecated in April 2027, going through 24 now is often the more relaxed path.
Your next step
Run the command from the checklist today and count how many functions still run on nodejs22.x. Then deploy the probe function to a sandbox account and check in the report what Node.js 26 LTS really brings in nodejs26.x. I would like to know: which Node runtime are your Lambdas on right now, and what slows you down most when upgrading? Let me know in the comments.
Sources
- Node.js blog: Node.js 26.13.0 (LTS), Oct 28, 2026
- Node.js blog: Node.js 26.0.0 (Current), May 5, 2026
- Node.js blog: Node.js 25.0.0 (Current), Oct 15, 2025
- Node.js: releases and release schedule
- AWS Lambda Developer Guide: Lambda runtimes
- GitHub: feedback issue for the Node.js 26 preview runtime
- AWS Compute Blog: Node.js 24 runtime now available in AWS Lambda
- Tested with Node.js 22.23, TypeScript 7.0.2 (tsc --noEmit), aws-cdk-lib 2.275.0 (cdk synth), @js-temporal/polyfill 0.5.1 and the Node test runner (7 tests). A deployment to AWS and a run under Node.js 26 were not possible in my test environment.
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
Monitoring durable functions: telemetry in TypeScript
Monitoring durable functions used to feel like a puzzle. A workflow that waits two days for a book to be shipped is spread across several Lambda invocations, and every invocation writes its own log lines. Answering “Which executions got stuck in the order-copy step yesterday?” took real effort.
Since October 28, 2026, there is the Durable Execution Telemetry Exporter for exactly this. It turns every execution into a single record and sends it to a destination of your choice, for example CloudWatch Logs or DynamoDB. I took a closer look because it fills the gap that bothered me most after writing my article about Lambda durable functions. In this article you build a small interlibrary loan workflow for a city library step by step, redact personal data, deploy everything with the AWS CDK and finally query the data in plain English through an MCP server.
> In short
>
> The telemetry exporter hooks into the Durable Execution SDK as a plugin. For each execution it builds a complete JSON record with status, steps, duration and errors and sends it to up to 13 destinations. The plugin itself costs nothing extra; you only pay for the destination. The biggest pitfall: executions that Lambda stops or times out on its own do not show up unless you add them via EventBridge.
What you will learn in this article
- How the telemetry exporter builds records and when it sends them
- Redacting input and output before they leave the function
- Testing the exporter locally, without an AWS account, using your own in-memory exporter
- Backfilling STOPPED and TIMED_OUT with an EventBridge rule
- Querying telemetry with Claude Code and the MCP server
Why monitoring durable functions is harder
A durable function is a Lambda function that saves its progress in checkpoints. A checkpoint is a stored intermediate result: once a step has finished, Lambda does not run it again on the next invocation but reads the result from storage instead. When the workflow waits, Lambda ends the invocation and starts a new one later. This resuming is called replay.
For business logic that is great. For monitoring, however, it means that one business execution consists of many technical invocations. Classic Lambda metrics such as duration or error rate describe the single invocation, not the workflow. In addition, Lambda keeps the execution history only for the configured retention period, at most 90 days. So if you want to ask later how long loans take on average, you need the data in a place of your own.
That is exactly what the exporter takes care of. It is therefore worth looking at how it works before we write code.
What the telemetry exporter does
The exporter is a plugin that runs in the same process as your handler. It hooks into the SDK lifecycle but does not sit in the request path. On every invocation it builds a cumulative snapshot of the entire execution from the checkpoints, so it is not a delta. A later record therefore always replaces an earlier one.
AWS offers the plugin for TypeScript, Python, Java and Go. According to the announcement, it comes at no additional charge and is available in all Regions that support durable functions. Costs only arise at the destination, such as log ingestion or DynamoDB writes.
One record instead of a hundred log lines
Here is a record from our example (shortened). All fields come from the package’s DurableExecutionTelemetryRecord type:
{
"recordType": "DurableExecutionTelemetry",
"schemaVersion": "1.0",
"executionArn": "arn:aws:lambda:eu-central-1:111122223333:function:LoanWorkflow:live/durable-execution/…",
"functionName": "LoanWorkflow",
"functionQualifier": "live",
"status": "SUCCEEDED",
"startTime": "2026-10-29T08:00:00.000Z",
"endTime": "2026-10-31T08:00:02.410Z",
"input": { "requestId": "FL-1001", "isbn": "9783161484100" },
"output": { "status": "READY_FOR_PICKUP", "partnerLibrary": "North City Library" },
"operations": [
{ "name": "find-partner-library", "type": "STEP", "status": "SUCCEEDED", "durationMs": 41, "result": "North City Library" },
{ "name": "order-copy", "type": "STEP", "status": "SUCCEEDED", "durationMs": 63 },
{ "name": "shipping", "type": "WAIT", "status": "SUCCEEDED" },
{ "name": "notify-reader", "type": "STEP", "status": "SUCCEEDED", "durationMs": 88 }
]
}
The operations field is the interesting part. Every step and every wait appears there with status and duration. Results of individual steps are left out by default unless you explicitly allow them. That is a good default, because step results often contain exactly the data that does not belong in monitoring.
When a record is created
With emitMode you decide how often the exporter sends. Your choice directly affects cost and freshness:
| emitMode | When it sends | Good for |
| --- | --- | --- |
| on-complete (default) | once on SUCCEEDED or FAILED | analysis, dashboards |
| on-failure | only on FAILED | error analysis, lowest overhead |
| on-change | on every status change, including RUNNING | live progress, highest load |
On top of that there is samplingRate between 0 and 1. The decision is based on a hash of the execution ARN. An execution is therefore either recorded completely or not at all, never halfway. Each of the 13 destinations has its own class, from LambdaLogExporter (the function’s own log, no extra IAM permissions) through S3Exporter for Athena to OTelExporter for any OpenTelemetry backend. You can combine several destinations, and the exporter serves them in parallel.
!Diagram: durable function with telemetry plugin sends to CloudWatch Logs and DynamoDB, EventBridge adds stops, MCP server reads
The path of a telemetry record: from the plugin inside the function to querying it via MCP.
Step by step: monitoring durable functions for an interlibrary loan
Our example: a reader orders a book that her own library does not have. The workflow looks for a partner library, orders a copy there, waits for shipping and notifies the reader. The workflow needs her name and email address, but they have no business in monitoring.
Prerequisites
- Node.js 22 or later (the exporter requires at least 22)
- AWS CLI v2 with credentials for a test account
- A basic understanding of durable functions, for example from my introductory article
- About 30 minutes
Step 1: Create the project
Create the project and install the SDK, the exporter and the CDK. You only need the testing package locally:
mkdir library-loan && cd library-loan
npm init -y
npm pkg set type=module
npm install @aws/durable-execution-sdk-js @aws/durable-execution-sdk-js-telemetry-exporter \
@aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb aws-cdk-lib constructs
npm install -D typescript @types/node @types/aws-lambda tsx esbuild aws-cdk \
@aws/durable-execution-sdk-js-testing
mkdir src lib bin test
For tests to run outside Lambda, you also need the optional peer dependencies. At startup the exporter loads every AWS client, including the ones you do not use. In Lambda the Node.js runtime provides them; locally it does not:
npm install -D @aws-sdk/client-s3 @aws-sdk/util-dynamodb @aws-sdk/client-rds-data \
@aws-sdk/client-cloudwatch-logs @aws-sdk/client-firehose @aws-sdk/client-eventbridge \
@aws-sdk/client-redshift-data @aws-sdk/client-sqs @aws-sdk/credential-provider-node \
"@smithy/signature-v4@^4" @aws-crypto/sha256-js
The tsconfig.json is deliberately strict. The cdk.json starts the app with tsx:
{
"compilerOptions": {
"target": "ES2022",
"module": "nodenext",
"moduleResolution": "nodenext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"noEmit": true,
"types": ["node"]
},
"include": ["src", "lib", "bin", "test"]
}
{
"app": "npx tsx bin/app.ts"
}
Step 2: Configure telemetry and redact data
The configuration lives in its own file. Two small functions decide what is left of input and output. I also allow the result of a single step, because it only contains the partner library’s name and helps with troubleshooting:
// src/telemetry.ts
import {
DynamoDBExporter,
LambdaLogExporter,
durableExecutionTelemetry,
type TelemetryExporter,
} from "@aws/durable-execution-sdk-js-telemetry-exporter";
export interface LoanRequest {
requestId: string;
isbn: string;
readerName: string;
readerEmail: string;
}
export interface LoanResult {
status: "READY_FOR_PICKUP" | "NOT_AVAILABLE";
pickupCode?: string;
partnerLibrary?: string;
}
// Only fields needed for troubleshooting – no personal data.
export function redactInput(input: unknown): { requestId?: string; isbn?: string } {
const req = (input ?? {}) as Partial<LoanRequest>;
return { requestId: req.requestId, isbn: req.isbn };
}
// Nobody needs the pickup code in monitoring.
export function redactOutput(output: unknown): { status?: string; partnerLibrary?: string } {
const res = (output ?? {}) as Partial<LoanResult>;
return { status: res.status, partnerLibrary: res.partnerLibrary };
}
export function parseSamplingRate(raw: string | undefined): number {
const value = Number(raw ?? "1");
if (Number.isNaN(value)) return 1;
return Math.min(1, Math.max(0, value));
}
export function defaultExporters(): TelemetryExporter[] {
const exporters: TelemetryExporter[] = [new LambdaLogExporter()];
const tableName = process.env.TELEMETRY_TABLE;
if (tableName) {
exporters.push(new DynamoDBExporter({ tableName }));
}
return exporters;
}
export function createTelemetry(exporters: TelemetryExporter[]) {
return durableExecutionTelemetry({
emitMode: "on-complete",
samplingRate: parseSamplingRate(process.env.TELEMETRY_SAMPLING_RATE),
exporters,
content: {
input: redactInput,
output: redactOutput,
operations: {
includeErrors: true,
overrides: [
// Deliberately export this step's result (it only contains the library name)
{ operationName: "find-partner-library", result: (r) => r },
],
},
},
});
}
Two details matter. First, you create the plugin once at module scope, not inside the handler. Second, the sampling rate does not throw on nonsense like “7” but gets clamped by my helper. The exporter itself would also correct invalid values and log a warning.
Step 3: Write the workflow
The workflow consists of three steps and one wait. The business logic is intentionally just a placeholder. To make it testable, the file also exports a factory function that accepts any exporters:
// src/loan-workflow.ts
import {
type DurableContext,
withDurableExecution,
} from "@aws/durable-execution-sdk-js";
import type { TelemetryExporter } from "@aws/durable-execution-sdk-js-telemetry-exporter";
import {
createTelemetry,
defaultExporters,
type LoanRequest,
type LoanResult,
} from "./telemetry.js";
// Placeholders for real business logic (catalog, partner library, email)
async function findPartnerLibrary(isbn: string): Promise<string | null> {
return isbn.startsWith("978") ? "North City Library" : null;
}
async function orderCopy(library: string, requestId: string): Promise<string> {
return `${library.slice(0, 3).toUpperCase()}-${requestId}`;
}
async function notifyReader(email: string, pickupCode: string): Promise<void> {
// This is where the email with the pickup code would be sent – the log only shows the masked address.
console.log(JSON.stringify({ msg: "reader notified", to: email.replace(/.+@/, "***@"), codeLength: pickupCode.length }));
}
async function loanWorkflow(
event: LoanRequest,
context: DurableContext,
): Promise<LoanResult> {
const library = await context.step("find-partner-library", async () =>
findPartnerLibrary(event.isbn),
);
if (!library) {
return { status: "NOT_AVAILABLE" };
}
const pickupCode = await context.step("order-copy", async () =>
orderCopy(library, event.requestId),
);
// Shipping between libraries: the function sleeps without using compute time.
await context.wait("shipping", { seconds: Number(process.env.SHIPPING_SECONDS ?? "172800") });
await context.step("notify-reader", async () =>
notifyReader(event.readerEmail, pickupCode),
);
return { status: "READY_FOR_PICKUP", pickupCode, partnerLibrary: library };
}
// Factory so tests can plug in their own exporters
export function createHandler(exporters: TelemetryExporter[]) {
return withDurableExecution(loanWorkflow, {
plugins: [createTelemetry(exporters)],
});
}
export const handler = createHandler(defaultExporters());
Pay attention to the operation names. They end up unchanged as keys in the records. In CloudWatch Logs Insights and OpenSearch a dot in a name works as a path separator, which is why I only use lowercase letters and hyphens.
Step 4: Test locally, without an AWS account
The testing package simulates the checkpoint backend in-process. Instead of a real destination, I plug in an exporter that just collects records in an array. The TelemetryExporter interface needs exactly one method for that:
// test/telemetry.test.ts
import { after, before, test } from "node:test";
import assert from "node:assert/strict";
import { LocalDurableTestRunner } from "@aws/durable-execution-sdk-js-testing";
import type {
DurableExecutionTelemetryRecord,
TelemetryExporter,
} from "@aws/durable-execution-sdk-js-telemetry-exporter";
import { parseSamplingRate } from "../src/telemetry.js";
process.env.SHIPPING_SECONDS = "1";
const { createHandler } = await import("../src/loan-workflow.js");
// Collects all records in memory instead of sending them
class MemoryExporter implements TelemetryExporter {
records: DurableExecutionTelemetryRecord[] = [];
async export(record: DurableExecutionTelemetryRecord): Promise<void> {
this.records.push(record);
}
}
before(() => LocalDurableTestRunner.setupTestEnvironment({ skipTime: false }));
after(() => LocalDurableTestRunner.teardownTestEnvironment());
test("exports one redacted record per completed execution", async () => {
const memory = new MemoryExporter();
const runner = new LocalDurableTestRunner({ handlerFunction: createHandler([memory]) });
const execution = await runner.run({
payload: {
requestId: "FL-1001",
isbn: "9783161484100",
readerName: "Jane Doe",
readerEmail: "jane@example.org",
},
});
assert.equal(execution.getStatus(), "SUCCEEDED");
assert.equal(memory.records.length, 1);
const record = memory.records[0];
assert.equal(record.status, "SUCCEEDED");
assert.deepEqual(record.input, { requestId: "FL-1001", isbn: "9783161484100" });
assert.deepEqual(record.output, { status: "READY_FOR_PICKUP", partnerLibrary: "North City Library" });
const json = JSON.stringify(record);
assert.ok(!json.includes("jane@example.org"), "email must not be exported");
assert.ok(!json.includes("NOR-FL-1001"), "pickup code must not be exported");
const names = record.operations.map((op) => op.name);
assert.deepEqual(names, ["find-partner-library", "order-copy", "shipping", "notify-reader"]);
const partner = record.operations.find((op) => op.name === "find-partner-library");
assert.equal(partner?.result, "North City Library");
});
test("parseSamplingRate clamps invalid values", () => {
assert.equal(parseSamplingRate(undefined), 1);
assert.equal(parseSamplingRate("0.1"), 0.1);
assert.equal(parseSamplingRate("7"), 1);
assert.equal(parseSamplingRate("abc"), 1);
});
Add the script "test": "tsx --test test/*.test.ts" to your package.json and run the tests. The wait is set to one second in the test:
npx tsc --noEmit
npm test
Expected result:
{"msg":"reader notified","to":"***@example.org","codeLength":11}
✔ exports one redacted record per completed execution
✔ parseSamplingRate clamps invalid values
ℹ tests 2
ℹ pass 2
ℹ fail 0
The first test is the real win. It makes sure that neither the email address nor the pickup code ever lands in monitoring. If someone changes the redaction later, the test fails before any data reaches the cloud.
Step 5: Deploy with the AWS CDK
The stack contains the telemetry table, the workflow with durableConfig, an alias and the function that will close the gap later. For functions with durableConfig, the CDK automatically attaches the managed policy AWSLambdaBasicDurableExecutionRolePolicy; you grant write access to the table yourself:
// lib/loan-stack.ts
import * as path from "node:path";
import { fileURLToPath } from "node:url";
import { CfnOutput, Duration, RemovalPolicy, Stack, type StackProps } from "aws-cdk-lib";
import * as dynamodb from "aws-cdk-lib/aws-dynamodb";
import * as events from "aws-cdk-lib/aws-events";
import * as targets from "aws-cdk-lib/aws-events-targets";
import * as lambda from "aws-cdk-lib/aws-lambda";
import { NodejsFunction } from "aws-cdk-lib/aws-lambda-nodejs";
import * as logs from "aws-cdk-lib/aws-logs";
import type { Construct } from "constructs";
const here = path.dirname(fileURLToPath(import.meta.url));
export class LoanStack extends Stack {
constructor(scope: Construct, id: string, props?: StackProps) {
super(scope, id, props);
// Target for the DynamoDBExporter: pk = execution ARN, sk = timestamp
const telemetryTable = new dynamodb.TableV2(this, "TelemetryTable", {
partitionKey: { name: "pk", type: dynamodb.AttributeType.STRING },
sortKey: { name: "sk", type: dynamodb.AttributeType.STRING },
billing: dynamodb.Billing.onDemand(),
removalPolicy: RemovalPolicy.DESTROY, // demo only
});
const workflow = new NodejsFunction(this, "LoanWorkflow", {
entry: path.join(here, "../src/loan-workflow.ts"),
handler: "handler",
runtime: lambda.Runtime.NODEJS_22_X,
architecture: lambda.Architecture.ARM_64,
timeout: Duration.minutes(1),
memorySize: 512,
durableConfig: {
executionTimeout: Duration.days(7),
retentionPeriod: Duration.days(14),
},
environment: {
TELEMETRY_TABLE: telemetryTable.tableName,
TELEMETRY_SAMPLING_RATE: "1",
SHIPPING_SECONDS: "60",
},
logGroup: new logs.LogGroup(this, "LoanWorkflowLogs", {
retention: logs.RetentionDays.ONE_MONTH,
removalPolicy: RemovalPolicy.DESTROY,
}),
});
telemetryTable.grantWriteData(workflow);
// Durable functions need a qualified ARN (version or alias)
const live = new lambda.Alias(this, "LiveAlias", {
aliasName: "live",
version: workflow.currentVersion,
});
// Close the gap: only Lambda itself reports STOPPED and TIMED_OUT
const closeGaps = new NodejsFunction(this, "CloseGaps", {
entry: path.join(here, "../src/close-gaps.ts"),
handler: "handler",
runtime: lambda.Runtime.NODEJS_22_X,
architecture: lambda.Architecture.ARM_64,
environment: { TELEMETRY_TABLE: telemetryTable.tableName },
});
telemetryTable.grantWriteData(closeGaps);
new events.Rule(this, "StoppedOrTimedOut", {
eventPattern: {
source: ["aws.lambda"],
detailType: ["Durable Execution Status Change"],
detail: { status: ["STOPPED", "TIMED_OUT"] },
},
targets: [new targets.LambdaFunction(closeGaps)],
});
new CfnOutput(this, "WorkflowAliasArn", { value: live.functionArn });
new CfnOutput(this, "TelemetryTableName", { value: telemetryTable.tableName });
}
}
// bin/app.ts
import { App } from "aws-cdk-lib";
import { LoanStack } from "../lib/loan-stack.js";
const app = new App();
new LoanStack(app, "LibraryLoanStack");
When bundling, esbuild leaves out the @aws-sdk/* packages because the runtime ships them. Check, bootstrap and deploy:
npx tsc --noEmit
npx cdk bootstrap # only once per account and region
npx cdk deploy
After the deployment the console shows two outputs: WorkflowAliasArn and TelemetryTableName. You will need both in a moment.
Step 6: Run it and look at the records
You always invoke durable functions through a qualified ARN, meaning a version or an alias. For long workflows, use the asynchronous invocation:
aws lambda invoke \
--function-name "<WorkflowAliasArn from the output>" \
--invocation-type Event \
--cli-binary-format raw-in-base64-out \
--payload '{"requestId":"FL-1001","isbn":"9783161484100","readerName":"Jane Doe","readerEmail":"jane@example.org"}' \
response.json
In the stack the shipping time is set to 60 seconds. After a little over a minute the record shows up in the function’s log. In CloudWatch Logs Insights this query finds all telemetry records:
fields executionName, status, durationMs, input.requestId
| filter recordType = "DurableExecutionTelemetry"
| sort @timestamp desc
| limit 20
At the same time, the same record sits in DynamoDB, with the execution ARN as pk and the emit time as sk:
aws dynamodb scan \
--table-name "<TelemetryTableName from the output>" \
--projection-expression "pk, sk, #s" \
--expression-attribute-names '{"#s":"status"}' \
--max-items 5
> 🎬 Interlibrary loan flow with telemetry – this animated diagram only runs on my site: watch it on sascha-ms.de
Closing the gap for STOPPED and TIMED_OUT
One point is somewhat hidden in the AWS blog post: the plugin only sends when the function is invoked. If you stop an execution through the API or the execution timeout expires, Lambda sets the final status itself without calling your code. Telemetry then keeps the last state, which with on-complete means nothing at all.
Lambda reports every status change as an event on the EventBridge default bus, with the source aws.lambda. The EventBridge rule in the stack filters for the two critical statuses and calls a small function that writes a substitute record into the same table:
// src/close-gaps.ts
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, PutCommand } from "@aws-sdk/lib-dynamodb";
import type { EventBridgeEvent } from "aws-lambda";
interface StatusChangeDetail {
durableExecutionArn: string;
durableExecutionName?: string;
functionArn: string;
status: string;
startTimestamp: string;
endTimestamp?: string;
}
const db = DynamoDBDocumentClient.from(new DynamoDBClient({}));
// Writes STOPPED and TIMED_OUT into the same table as the exporter.
export const handler = async (
event: EventBridgeEvent<string, StatusChangeDetail>,
): Promise<void> => {
const d = event.detail;
await db.send(
new PutCommand({
TableName: process.env.TELEMETRY_TABLE,
Item: {
pk: d.durableExecutionArn,
sk: d.endTimestamp ?? event.time,
recordType: "DurableExecutionTelemetry",
source: "eventbridge-gap-filler",
executionArn: d.durableExecutionArn,
executionName: d.durableExecutionName,
functionArn: d.functionArn,
status: d.status,
startTime: d.startTimestamp,
endTime: d.endTimestamp,
},
}),
);
};
A detail you notice while reading: the Lambda documentation calls the event type Durable Execution Status Change, while the announcement blog post uses a different spelling. I went with the Lambda documentation. After deploying, trigger a timeout once and check that the rule fires. Also check that the ARN in the event matches the telemetry’s executionArn, so both entries end up under the same pk.
Monitoring durable functions with AI: the MCP server
Together with the exporter, AWS released an MCP server. MCP (Model Context Protocol) is an open standard that lets AI assistants call tools. The server runs locally via npx, reads its configuration only from environment variables and uses the regular AWS credential chain. This is how you add it to Claude Code:
claude mcp add durable-telemetry \
--scope project \
--env DURABLE_TELEMETRY_DESTINATION_TYPE=dynamodb \
--env DURABLE_TELEMETRY_DYNAMODB_TABLE_NAME=<TelemetryTableName> \
--env DURABLE_TELEMETRY_REGION=eu-central-1 \
-- npx -y @aws/durable-execution-sdk-js-telemetry-mcp
After that, five tools are available: test_destination checks the connection, describe_schema returns the schema, list_executions and get_execution filter without a query language, and query handles everything else. The assistant can then answer questions like “Which loans failed this week, and in which step?” straight from the table.
The server takes security seriously. SQL destinations accept only SELECT queries, and before the first query it asks for confirmation, because it cannot prove that your credentials are read-only. You should make that true by giving the server its own role with read permissions only. For DynamoDB, this policy is enough:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"dynamodb:DescribeTable",
"dynamodb:GetItem",
"dynamodb:BatchGetItem",
"dynamodb:Query",
"dynamodb:Scan",
"dynamodb:PartiQLSelect"
],
"Resource": "arn:aws:dynamodb:eu-central-1:111122223333:table/<TelemetryTableName>"
}
]
}
If you want to build an MCP server yourself, you will find the basics in my guide to MCP servers with TypeScript.
Costs and data protection
The plugin is free; the destinations are not. With on-complete you write one record per execution, with on-change considerably more. As a rough estimate, multiply the number of executions by the number of status changes. With many executions, a lower sampling rate and on-failure for less important workflows help.
On data protection my recommendation is clear: redact too much rather than too little. By default the exporter sends input and output in full. That is convenient, but the wrong starting point for workflows with customer data. Keep in mind, too, that your telemetry destination now holds a copy of the data. The DynamoDB table therefore needs a retention rule, for example a cleanup job or a custom exporter that sets a TTL attribute. I show how to add alarms and budgets in Lambda in Monitoring Lambda with CDK.
Common mistakes
- ERR_MODULE_NOT_FOUND for @aws-sdk/client-s3: The peer dependencies are missing locally. Install the complete list from step 1, even if you do not use S3.
- No records for stopped executions: That is by design. No invocation, no plugin, hence the EventBridge rule.
- Step results are missing: Results are excluded by default. Allow them per operation via content.operations.overrides.
- Encrypted results in the record: Transforms receive the stored, serialized result. If a step uses custom Serdes, you will not see plain text there.
- Incomplete tree for map and parallel: After a resume, full-tree only shows child operations with pluginsConfig.childOperationsDepth, which costs extra replays.
- Slow invocations under load: Exports run one after another. A slow exporter delays every execution that ends at the same time, not just one.
My take
I consider the exporter the most important addition since durable functions were released. Until now, the candid answer to “How do I monitor this?” was something like “with patience”. Now there is a well-designed data model, sensible defaults and a clean interface for plugging in your own destinations. I particularly like that sampling decides per execution and never records half a workflow.
I see two things critically. The STOPPED and TIMED_OUT gap is documented but easy to miss, and those are exactly the cases you want to see in operations. Second, the default configuration sends all input and output. For an example that is handy; for production I would wish for the opposite. The MCP server, on the other hand, strikes me as a good example of AI helping in operations without doing harm: read-only, with a confirmation prompt and clear limits.
Do you still need Step Functions for visibility? For workflows that business teams should follow graphically, the Step Functions console keeps its edge. For teams that prefer writing their flows in TypeScript, visibility is no longer an argument against durable functions.
FAQ
Does the telemetry exporter for durable functions cost extra?
No. According to AWS, the plugin is available at no additional charge. You only pay for the destination, such as CloudWatch Logs ingestion, DynamoDB writes or S3 storage.
Which destinations does the exporter support?
There are 13 built-in destinations, including CloudWatch Logs, S3, DynamoDB, Aurora, Redshift, OpenSearch, EventBridge, Firehose, SQS, OpenTelemetry, HTTPS endpoints and files. You build your own destinations with the TelemetryExporter interface.
Do I need additional IAM permissions?
Not for the default LambdaLogExporter, because the function is already allowed to write its own log. For every other destination, the function needs write permissions on that destination.
Does the exporter work with Python too?
Yes. The plugin exists for TypeScript, Python, Java and Go, each in the repository of the matching Durable Execution SDK.
Your next step
Take an existing durable function, attach the plugin with the LambdaLogExporter and write the test from step 4 for your redaction first. It takes half an hour, and afterwards you will see your workflows as a whole for the first time. I am curious: which question would you ask your telemetry first?
Sources
- AWS What’s New: Lambda durable functions now exports live telemetry (Oct 28, 2026)
- AWS Compute Blog: Observing AWS Lambda durable functions with the Durable Execution Telemetry Exporter
- AWS docs: Durable Execution Telemetry Exporter
- AWS docs: Durable Execution Telemetry MCP server
- AWS docs: Telemetry overview
- AWS docs: Monitoring durable functions (EventBridge events)
- AWS docs: Deploy and invoke Lambda durable functions with the AWS CLI
- GitHub: aws/aws-durable-execution-sdk-js
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
EKS Hybrid Nodes & Capabilities による BYOI デプロイ管理
はじめに
EKS Hybrid Nodes を使うと、EKS の外で自分で用意するマシンを EKS クラスタのノードとして参加させ、AWS 側のコントロールプレーンから管理できます。AWS の文書は、この考え方を bring your own infrastructure と呼んでいます。この記事でも、このように自分で用意するマシンを BYOI (bring your own infrastructure、持ち込みのインフラ) と呼びます。例えばオンプレミスのサーバーを管理下に置けます。EKS には EKS Capabilities という機能もあり、その 1 つとして Argo CD を AWS が運用する形で使えます。この記事では、AWS が運用するこの Argo CD を「Managed Argo CD」と呼びます。Hybrid ノードも EKS クラスタのノードなので、Managed Argo CD から EKS クラスタにデプロイすれば、原理的には Hybrid ノードにも届くはずです。これが実際に問題なく動くのかを、環境を作って確かめました。あわせて、AKS や GKE、自分で建てたクラスタのような、EKS ではない Kubernetes にもデプロイできるかを確かめました。
結論を先に表にします。Managed Argo CD にデプロイ先として登録できるのは EKS クラスタだけで、BYOI には、BYOI が参加した EKS クラスタへのデプロイとして届きます。自分で用意した環境は、マシンを EKS のノードとして参加させる場合と、すでに動いている Kubernetes クラスタにデプロイする場合とで、行が分かれます。
デプロイ先
Managed Argo CD からの直接デプロイ
方法
EKS クラスタ
可
クラスタの ARN (AWS のリソースを一意に指す名前) で登録
BYOI
可
EKS Hybrid Nodes で EKS クラスタのノードとして参加。今回は EC2 で確認
EKS ではない Kubernetes (AKS、GKE、自分で建てたクラスタ)
不可
EKS クラスタに置いた中継用の Argo CD を経由。今回は k3s で確認
この記事は、Argo CD と EKS の基本を知っている読者に向けて、それぞれの方法がなぜ成り立つのかと、確かめた結果を書きます。
Managed Argo CD がデプロイできる相手
まず、Managed Argo CD の部品がどこにあるかを押さえます。
Managed Argo CD の部品は、2 か所に分かれています。
実際にデプロイの仕事をするコントローラーは、利用者の EKS クラスタの中ではなく、AWS が運用する場所で動きます。利用者のノードの上には、コントローラーの Pod はありません。
何をどこにデプロイするかを書いた Application と、デプロイ先を書いた Secret である cluster secret は、今回の構成では、Managed Argo CD を作った EKS クラスタの argocd namespace に、普通の Kubernetes のオブジェクトとして置きます。
利用者は Application を自分のクラスタに kubectl apply するだけです。コントローラーはクラスタの API サーバーを通してそれを読み、Helm chart や Git のリポジトリから中身を取ってきて、デプロイ先の API サーバーにマニフェストを適用します。今回はデプロイ先も同じクラスタなので、同じ API サーバーです。Pod がどのノードに置かれるかは、クラスタのスケジューラが決めます。
次に、デプロイ先をどう指定するかです。
Managed Argo CD がデプロイできるのは EKS クラスタだけで、AWS の比較の文書にもデプロイ先は EKS クラスタの ARN だけと書かれています。理由は、Managed Argo CD がデプロイ先を見つける方法と、デプロイ先に入る方法の両方が EKS の仕組みでできているからです。上の図の上の段がその流れです。
デプロイ先は cluster secret で登録します。Managed Argo CD では、その server に EKS クラスタの ARN を書きます。Managed Argo CD を作った EKS クラスタ自身も、自動ではデプロイ先に登録されないので、同じように ARN で登録します。
AWS 側のコントローラーは、ARN でデプロイ先のクラスタを特定し、Managed Argo CD を作るときに渡した IAM ロールでそのクラスタの API サーバーに入ります。
デプロイ先の EKS クラスタでは、アクセスエントリ (IAM ロールに Kubernetes の権限を結び付ける EKS の設定) が、その IAM ロールに権限を与えます。
権限をもらった Managed Argo CD が、マニフェストを適用します。
図の下の段は、通常の Argo CD と同じように、API サーバーの URL とトークンで登録した場合です。cluster secret はエラーなく作れます。ところが、その cluster secret を使う Application は、condition に次のエラーを出して止まり、相手のクラスタには何も届きませんでした。
Failed to load live state: failed to get cluster info for "10.90.0.48": error getting cluster: error getting cluster RESTConfig: server should be specified as a cluster ARN
エラーの文言からは、Managed Argo CD が server に ARN を求め、URL を受け付けずに止まっていると読めます。Managed Argo CD のデプロイ先は EKS クラスタの ARN で指定するので、EKS ではないクラスタはこの流れに乗れません。EKS ではないクラスタにデプロイする方法は、後の節で説明します。
Hybrid Nodes による BYOI の参加
BYOI を EKS クラスタのノードにすれば、Managed Argo CD は、EKS クラスタへのデプロイと同じやり方で、そのマシンにもアプリを届けられます。そのための機能が、はじめにで触れた EKS Hybrid Nodes です。コントロールプレーンは AWS 側にあり、BYOI はノードとして働くだけです。
Managed Argo CD は、AWS 側にある EKS の API サーバーにマニフェストを適用するだけで、BYOI のネットワークに入る必要はありません。Pod をどのノードに置くかは Kubernetes のスケジューラが決めます。Hybrid ノードには既定で eks.amazonaws.com/compute-type: hybrid というラベルが付くので、Pod の nodeSelector にこのラベルを指定すれば、Pod の置き場所を BYOI に限れます。ただし、今回は Hybrid ノードに taint を付けていないので、置き場所を指定しない Pod は BYOI にも置かれることがあります。クラウドで動かしたい Pod には、このラベルを避ける affinity を付けるか、Hybrid ノードに taint を付けます。今回は、後の節で説明する中継用の Argo CD を、この affinity でクラウドのノードに限りました。今回デプロイした podinfo の chart は、Helm の値の nodeSelector を Pod に渡すので、podinfo-onprem という Application の値に次の 2 行を書くだけで済みました。
nodeSelector:
eks.amazonaws.com/compute-type: hybrid
次に、BYOI と AWS の間の通信です。
BYOI のマシンは、AWS 側にある EKS のコントロールプレーンと通信できなければ、ノードとして働けません。kubelet が API サーバーに接続してノードとして登録し、API サーバーもログの取得などのために kubelet に接続するからです。そのため、BYOI のネットワークとクラスタの VPC をつなぎ、BYOI のノードと Pod の IP の範囲を EKS に登録して、VPC のルートテーブルと BYOI 側のネットワークに経路を、セキュリティグループに必要なポートの許可を入れます。今回は、BYOI の Pod の IP にも VPC から届く構成にしました。必要なポートと設定の詳細は、AWS の文書にまとまっています。
出典: Amazon EKS ユーザーガイド「ハイブリッドノード用のネットワークを準備する」、CC BY-SA 4.0
クラスタの API エンドポイントは、インターネットに出さず private にしておくのが安全です。今回も API エンドポイントを private だけにし、BYOI との経路もプライベートな接続にした構成で検証し、Managed Argo CD からのデプロイまで動きました。BYOI の置き場所との VPN の代わりには、VPC 同士をプライベートにつなぐ Transit Gateway を使いました。private だけのクラスタでも、Managed Argo CD はデプロイできます。
Hybrid ノードは、Pod のネットワークを作る部品である CNI が動くまで Ready になりません。Hybrid ノードには VPC CNI を使えないので、Cilium などの CNI を自分で入れます。今回は、AWS が ECR Public に置いている Cilium の Helm chart を使いました。対応する CNI と版、OS ごとの制約は CNI のページにあります。
他社クラウドのマシンと Hybrid Nodes
他社のクラウドのマシンを Hybrid Nodes にする構成は、AWS の文書でサポートの対象外とされています。本番で使うなら、AWS のサポートを受けられないことと、障害の切り分けや運用を自分たちで統制する必要があることを受け入れたうえで使うべきです。
Running EKS Hybrid Nodes on cloud infrastructure, including AWS Regions, AWS Local Zones, AWS Outposts, or in other clouds, is not supported. You will be charged the hybrid nodes fee if you run hybrid nodes on Amazon EC2 instances.
この線引きは、VM かベアメタルかでは分かれていません。同じページは、Hybrid ノードを物理マシンと仮想マシンのどちらでも動かせるとしています。文書が区別しているのは、オンプレミスか、AWS を含むクラウドかだけです。Azure や Google Cloud が提供するベアメタルのサーバーも「in other clouds」に含まれると読むのが自然で、ベアメタルならサポートされるという記述はありません。
技術的に動くかどうかについては、Hybrid ノードを参加させるツールである nodeadm のソースを見た範囲で、他社のクラウドを見分けて参加を拒否するような処理は見つかりませんでした。今回、EC2 では参加からデプロイまで動いたので、Azure や Google Cloud のマシンも、技術的にはノードとして追加できそうです。ただし、Azure と Google Cloud のマシンでは試していないので、確実に動くとは言えません。
中継用の Argo CD
EKS ではない Kubernetes にデプロイする方法の 1 つとして、中継用の Argo CD を経由する構成が考えられます。今回は、この構成を実際に試しました。まず、考え方の全体を示します。
Managed Argo CD とは別に、オープンソースの通常の Argo CD を 1 つ用意し、EKS クラスタの中で動かします。AWS が運用するのではなく、利用者が自分で運用するセルフホストの Argo CD です。この記事では、これを「中継用の Argo CD」と呼びます。デプロイ先を API サーバーの URL とトークンで登録できるので、EKS ではないクラスタにもデプロイできます。
中継用の Argo CD そのものも、Helm chart として配られている普通のアプリです。そのため、中継用の Argo CD を EKS クラスタに入れる作業も、Managed Argo CD のデプロイとして行えます。
その結果、Managed Argo CD が EKS クラスタに中継用の Argo CD を入れ、中継用の Argo CD が EKS ではないクラスタにデプロイする、という 2 段の流れになります。中継用の Argo CD が何をどこにデプロイするかも Managed Argo CD の Application に書けるので、デプロイする内容を変える場所は、トークンを除いて Managed Argo CD の 1 か所にまとまります。
この構成が要るのは、EKS ではないクラスタのノードが、すでにそのクラスタのコントロールプレーンに属しているからです。そのままでは EKS の Hybrid ノードになれません。Managed Argo CD から直接デプロイしたいなら、マシンを元のクラスタから外し、Hybrid ノードとして EKS クラスタに参加させ直すことになります。元のクラスタをそのまま使う場合に、中継用の Argo CD が使えます。
ここからは、中継構成を組むときに大事なネットワークと認証認可の 2 点を説明します。
ネットワークでは、中継用の Argo CD から、EKS ではないクラスタの API サーバーに届く必要があります。中継用の Argo CD は、EKS クラスタの中から相手の API サーバーを呼んでデプロイするからです。相手の API サーバーをインターネットに開くと攻撃される入口が増えるので、BYOI と同じく、VPN などのプライベートな接続でつなぐのが安全です。今回は、k3s に Transit Gateway を通る private なアドレスでつなぎました。
認証認可では、誰がどの認証情報でどこに入るかを 3 つに分けて押さえます。
Managed Argo CD は、IAM ロールとアクセスエントリで EKS クラスタに入ります。中継用の Argo CD の chart は ClusterRole を作るので、Managed Argo CD のアクセスエントリには、ClusterRole を作れる権限が要ります。
中継用の Argo CD は、EKS クラスタの中で、自分の ServiceAccount と ClusterRole で動きます。chart の既定のままでは、この ClusterRole で EKS クラスタ全体を読み書きできます。そこで、cluster.inClusterEnabled: "false" で既定のデプロイ先である自分の EKS クラスタを外し、AppProject で Application が指定できるデプロイ先と取得元を限ります。ただし、これらは ServiceAccount の権限そのものは狭めないので、狭めるなら ClusterRole を絞ります。Managed Argo CD の画面には IAM Identity Center で入りますが、中継用の Argo CD の画面にはローカルの admin で入ります。
中継用の Argo CD は、EKS ではないクラスタに、相手の ServiceAccount のトークンで入ります。トークンは cluster secret に入れ、Git には置かずに中継用の Argo CD の namespace に直接置きます。相手のクラスタで何ができるかは、そのトークンの ServiceAccount の RBAC で決まります。今回は検証を短く済ませるため cluster-admin を与えました。実運用で namespace の中だけに絞るなら、デプロイ先の namespace を先に作り、Role と RoleBinding で権限を与え、Application の CreateNamespace=true も外します。
設定としては、Managed Argo CD に 2 つの Application を置きます。tier2-argocd は Helm chart argo-cd で中継用の Argo CD を入れ、tier2-apps は Helm chart argocd-apps で、中継用の Argo CD の AppProject と Application を作ります。具体的な値は、リポジトリの設定にあります。
版の更新では、中継用の Argo CD のマイナー版を Managed Argo CD にそろえます。2 つの Argo CD は、同じクラスタの CRD を共有するからです。その CRD は Managed Argo CD を作るときに EKS が入れ、版の更新も AWS が行うので、中継用の Argo CD の chart は crds.install: false で CRD を入れません。aws eks describe-capability で Managed Argo CD の版を定期的に確かめ、マイナー版が上がったら、中継用の Argo CD をあとから同じマイナー版に上げます。Helm chart argo-cd の版は Argo CD の版と別なので、chart の appVersion を見て選びます。中継用の Argo CD を先に上げると、古い CRD の上で新しいコントローラーが動くことになります。マイナー版をそろえても互換性が保証されるわけではないので、上げる前に Argo CD のリリースノートで CRD の変更を確かめます。
まとめ
EKS Capabilities の Managed Argo CD は EKS クラスタにしかデプロイできませんが、BYOI を Hybrid Nodes で EKS クラスタのノードにすれば、BYOI にも直接デプロイできます。今回はこれを、AWS のサポート対象外である EC2 を BYOI に見立てて確かめました。EKS ではない Kubernetes には、Managed Argo CD が入れた中継用の Argo CD を経由してデプロイできることを k3s で確かめ、デプロイする内容は、トークンを含む cluster secret を除いて、Managed Argo CD の Application だけで変えられました。構築、検証、削除のコードと結果のログは、littlemex/distributed-ai の investigations にあります。
AMA: Building on AWS with the coding agent you already use (10/29)
A lot of you are building on AWS with a coding agent now: Claude Code, Codex, Kiro, Cursor or something you wired up yourself. Most of the time that works. Sometimes the agent uses an API the way it worked when the model was trained, or skips the one permission that lets API Gateway invoke your Lambda function.
My colleagues who work on making that better are here to answer your questions, whichever agent you use. Some of them build the Agent Toolkit for AWS (a managed MCP server plus skills and plugins for your agent), and others work with developers and customers building this way every day. You don't need to use the toolkit, or even like it, to ask.
- Bidesh Thapaliya ( u/bthapaws ), on the software engineering team dedicated to thinking about and building the things that make agents do better when working with AWS.
- James Ward ( u/jlward4th ) and Rohini Gaonkar ( u/techwithrohini ), developer advocates focused on building with agents .
You can ask about:
- Setting up your agent to build, deploy and debug on AWS
- Where agents go wrong on AWS, and how to steer them
- Letting an agent into a real account safely (IAM policies that apply only to agent calls, read-only setups, CloudTrail)
- Token and context costs
- The Agent Toolkit for AWS, and how it compares to the AWS Labs MCP servers
- What's frustrating today, any wishlist items you have, and where building on AWS with agents should go next
- and more!
The team will be answering for four hours, from 6:00 to 10:00 AM PT (9:00 AM to 1:00 PM ET, 13:00 to 17:00 UTC) on October 29th. Post your questions and upvote the ones you want answered first. Ask away!
28 points · 15 comments
NCCL Roadmap Nov 2026 - Jan 2027 の解説
はじめに
!
NCCL 奥がふかすぎる。。。。にゃ。。。
NVIDIA が公開した NCCL Roadmap: Nov 2026 - Jan 2027 (GitHub Issue #2486) から、大規模分散学習・推論に携わるエンジニアが押さえるべきポイントを解説します。前回のロードマップの解説は「NCCL Roadmap Aug-Oct 2026 の解説」、そこで挙げた項目の現在までの実装状況は「NCCL 2.32.3 でロードマップはどう実装されたか」にあります。GIN の基本は前回の解説を前提にします。
この記事では、新しい概念の GENK と Flow、AWS EFA と GIN の現状、注目機能 2 つを扱います。調査は 2026 年 10 月 29 日に行い、まだリリースされていないコードは、staging ブランチを次のリリースの候補とみなして読んでいます。
実装状況の確かめ方
NVIDIA/nccl、aws-ofi-nccl、amazon-contributing/DeepEP、NVIDIA/nccl-extensions の公開ブランチを手元に取得し、commit の履歴とソースを読んで確かめました。PR と Issue の状態 (open か、マージされたか、閉じた日) は GitHub で確かめています。記事のソースへのリンクは、確かめたときの commit に固定しています。
NCCL は master、staging、dev の 3 本のブランチを公開していて、staging には 2.32.5-1 の版番号を付けた commit があり、その後に 2.32.6a0 に上がっています。ロードマップは「Coming Soon: NCCL 2.32 Update 1」の版番号を書いていませんが、この記事では staging のコードをその候補として読みます。
今回のロードマップの項目のうち、この記事で扱うものと、前回のロードマップの近い項目は次のとおりです。対応は筆者の読みです。
今回のロードマップの項目
前回のロードマップの近い項目
General Kernels (GENK)
Unify legacy kernels using Device API
Flow Device API
More Device API primitives
GIN Backend Improvements
GIN backend improvements
CFT を使う階層型カーネル
Compute Fabric Transport (CFT) through Device API
New Enqueue Pipeline
Legacy and symmetric-kernel host-side unification
ロードマップには、内部のメモリの削減、RAS の進み具合の監視、RMA の profiling など、この記事で扱わない項目もあります。
Device API とは何か
NCCL の API の全体像
ロードマップの項目を説明する前に、まず背景知識として NCCL の API を整理します。NCCL の API は、Host API、Device API、plugin API の 3 つに分けられます。Host API と Device API の仕様は NCCL の公式文書にあり、この記事の表はその章立てに沿っています。plugin API は公式文書の API の章には無く、リポジトリの plugins ディレクトリに plugin の種類ごとの説明があります。
API
何をするか
例
Host API
CPU から呼んで、communicator (通信に参加する GPU の集まり) の作成や集団通信を NCCL に頼みます。集団通信では、NCCL がアルゴリズムを選び、NCCL のカーネルを GPU で起動します。利用者の計算は別のカーネルです
ncclCommInitRank、ncclAllReduce
Device API
利用者が書いた CUDA カーネルの中から、ほかの GPU との通信を直接呼びます (2.28 から)。計算と通信を 1 つのカーネルに入れられます。使う前の準備には Host API を使います
GIN、LSA
plugin API
NCCL のほうから呼ぶ、下の部品の差し替え口です。ネットワークへの送受信や、アルゴリズムの選択の調整などを、NCCL の外の実装に任せます
net、GIN。説明は plugins にあります。aws-ofi-nccl は EFA 用の plugin
誰が NCCL を呼ぶか
NCCL を呼ぶのは、多くの場合アプリ自身ではなく、間に入るフレームワークやライブラリです。主な呼び手を、ソースで確かめた範囲で並べます。
呼び手
どう呼ぶか
使う API
PyTorch の torch.distributed (backend "nccl")
C++ の ProcessGroupNCCL が、ncclAllReduce などを直接呼びます
Host API
PyTorch の backend "nccl4py" (torch.distributed._nccl4py)
Python で書いた backend が、nccl4py を通して呼びます。init_process_group("nccl4py") で選びます
Host API
nccl4py
NVIDIA の公式の Python の bindings。Cython で libnccl を呼び、PyTorch の tensor や CuPy の配列をそのまま渡せます。実験的な CuTe DSL の部品もあります
主に Host API
vLLM の PyNccl
Python の ctypes で libnccl を読み込んで呼びます
Host API
PyTorch の symmetric memory の演算、DeepEP、NCCL EP
自分の CUDA カーネルの中から通信を呼びます。準備には Host API で window (次の節で説明します) を登録し、device communicator を作ります
Device API と Host API
aws-ofi-nccl など
NCCL のほうから呼ばれます。NCCL が EFA などのネットワークに送るときの下の部品です。InfiniBand には NCCL に組み込みの経路もあります
plugin API
Device API は何のためにあるか
Host API で通信を頼むと、通信は NCCL のカーネル 1 回分の単位になり、利用者の計算とは別のカーネルになります。計算の途中で決まった相手に少しずつ送りたい場合や、計算と通信を細かく重ねたい場合には、この単位が粗すぎます。たとえば MoE のトークンの振り分けでは、どのトークンをどの GPU に送るかが計算の途中で決まります。
Device API は、利用者のカーネルの中から、ほかの GPU のメモリへ直接データを動かせるようにするための API です。NCCL の Device API の例は、計算と通信を分けて順に行う従来のやり方に対して、カーネルの中で両方を細かく混ぜることで、待ち時間を減らし処理量を上げられると説明しています。
GPU のカーネルからほかの GPU に届く経路はいくつかあり、Device API は経路ごとに部品を用意しています。同じノードで NVLink や PCIe の P2P でつながった相手には、相手のメモリを普通の読み書きの命令で触れます (LSA)。NVLink のスイッチが複数の宛先への書き込みや足し合わせを引き受けられるシステムでは、Multimem を使います。ノードをまたぐ相手には、NIC を通して put (相手のメモリへの書き込み) や get (相手のメモリからの読み出し) を出します (GIN)。どちらも、相手が何も呼ばない片側通信です。CFT は、相手のメモリを番地 (ポインタ) で触る代わりに、CUDA が用意する宛先の番号 (logical endpoint) とオフセットを指定して put や get を出す部品で、Blackwell 以降の GPU で使えます。これらと組み合わせて使う、足し合わせやコピーの部品 (reduce と copy) も用意されています。
GIN の仕組みは、前回の解説の「GIN とは何か」で説明しました。LSA は後の GENK と Flow の節で、CFT は注目機能の節で扱います。Multimem と reduce と copy の部品は、この記事では詳しく扱いません。
どの部品を使う場合も、準備は利用者が CPU で Host API を呼んで行います。通信に使うバッファを、全ランクで同じ形に対応付けた領域 (symmetric memory window) として登録し、カーネルが使う資源を指定して device communicator を作り、それを渡して自分のカーネルを起動します。手順の例は NCCL の文書にあります。GIN を使うカーネルは、NCCL を更新したら作り直す必要があると文書は書いています。次の節からは、この Device API の上に加わる新しい部品とカーネルを見ていきます。
GENK と Flow とは何か
!
GENK と Flow は、まだリリースされておらず staging と dev にだけあるコードです。この節は staging の実装から読み取った内容で、リリースまでに変わる可能性があります。staging で試すには、ビルドのときに SYM_GENK=1 を指定し、実行のときに NCCL_SYM_GENK_ENABLE=1 を設定する必要があります。
概要
Flow は、前の節の LSA と GIN の上に作られた、集団通信の部品を提供する Device API です。ロードマップは Flow を「LSA と GIN の両方で動く、高水準の Device API」と説明しています。LSA は前の節で触れた、相手の GPU のメモリを普通の読み書きの命令で触る部品です。
GENK (General Kernels) は、NCCL が自分の Ring と Tree の集団通信を、この Flow の上で作り直したカーネルの一群です。Ring では AllGather、ReduceScatter、Broadcast、Reduce、AllReduce を、Tree では AllReduce を実装しています。
前回のロードマップには「Unify legacy kernels using Device API」という項目がありました。筆者は、GENK をこの項目につながる取り組みと読んでいます。今回のロードマップは、選んだプラットフォームで GENK を既定にし、古いカーネルを置き換えるとしています。あわせて性能モデルを 1 つにまとめ、どの実装を選ぶかを同じ物差しで決めるとしています。
staging では、NCCL 自身が同じ Flow で集団通信を組んでいます。Flow がリリースに入れば、利用者も NCCL の内部と同じ部品で通信カーネルを書けます。
NCCL の集団通信には、GENK の前から 2 種類のカーネルがあります。
legacy kernels: NCCL に最初からある Ring や Tree のカーネルです。経路の違いは、CPU の側が準備する transport (NVLink や PCIe の P2P、ネットワークなど) が受け持ち、ネットワークでは CPU の proxy スレッドが NIC に渡します。
symmetric カーネル: 全ランクが同じ形で登録したバッファ (symmetric memory window) を集団通信に渡したときに使える、新しいカーネルです。相手のバッファの番地が分かるので、Device API の LSA などで相手のメモリを直接読み書きします。いまあるものは、NVLink の直結を前提に、場面ごとに作り込まれています (表の「特化した symmetric カーネル」)。
GENK は、symmetric カーネルと同じ Device API の作りで、legacy kernels と同じくらい広い経路で動くものです。Ring で隣の GPU にデータを渡す場面で比べると、次のようになります。
隣の GPU との経路
legacy kernels
特化した symmetric カーネル
GENK
NVLink
動く
動く
動く (LSA)
PCIe の P2P
動く
使えない
動く (LSA)
別のノード (ネットワーク)
動く
一部の種類だけ動く (GIN)
動く (GIN)
表は、Hopper 以降の GPU で GENK を有効にした場合についてソースから読んだもので、動かして確かめたものではありません。PCIe の行は CUDA P2P が使える場合、GENK のネットワークの行は GIN Proxy (GPU の代わりに CPU のスレッドが NIC に送る GIN のバックエンド) が使える場合です。
特化した symmetric カーネルは、PCIe だけでつながったノードでは候補から外れます。GENK では、隣の GPU ごとに Flow が LSA か GIN かを実行時に選ぶので、同じカーネルのまま NVLink でも PCIe でもネットワークでも動きます。staging のカーネルの選択にも「特化したカーネルは NVLink の直結を要するが、GENK は CUDA P2P の LSA や GIN も使える」というコメントがあります。
利用者の側の例もあります。NCCL の Device API の例には、Transformer の RMSNorm の計算を ReduceScatter と AllGather の通信と 1 つのカーネルにまとめるものがあり、いまは LSA、Multimem、GIN を直接使って、受け取り、足し合わせ、送り出しを書いています。Flow は、NCCL 自身が集団通信に使うこの受け取り、足し合わせ、送り出しの部分を、部品として提供します。
NCCL の文書の Device API の章には、Flow と GENK の説明はまだありません。Flow の使い方は、ヘッダの説明にまとまっていて、Flow processor を足した commit が入口になります。GENK は、カーネルを足した commit と、NCCL の選択に組み込んだ commit が出発点です。
図で [API] のタグを付けた Flow processor、LSA connection、GIN connection は、アプリのカーネルからも呼べる Device API です。cost model、legacy kernels、specialized symmetric kernels、GENK は NCCL の中の部品です。
GIN の signal とは
GIN の put は片側通信で、受け手は何も呼ばないので、データがいつ届いたかを自分では知れません。signal は、それを知らせるための、受け手の側に置かれた数です。送り手が put に signal を付けると、データが届いたあとに受け手の数が増え (たとえば 1 増え)、受け手はその数が目標に達するまで待ってから読みます。GIN の基本は、前回の解説の「GIN とは何か」で説明しました。
signal には strong と weak の 2 種類があります。weak signal が「読んでよい」と約束するのは、その signal を付けた put のデータだけです。strong signal は、同じ相手に同じ context (GIN の通信の通り道の 1 本) で先に出した put のデータもすべて読めると約束します。そのため、最後の put にだけ signal を付ければ済みます。
EFA の送り方の SRD は、書き込みが届く順番を保ちません。そのため、最後の put に付けた signal が届いても、前の put のデータが届いたとは限りません。EFA の上の経路のうち、GPU から直接送る EFA GDA が提供するのは weak signal だけです (次の節で説明します)。一方、Flow の GIN の接続は strong signal を必要とします。
AWS EFA 関連アップデートと現在の課題
EFA の 2 つの GIN の経路と signal の種類
!
GENK と Flow の GIN の接続は strong signal を必要とします。EFA の上で strong signal を提供するのは GIN Proxy だけで、GPU から直接送る EFA GDA は weak signal しか提供しません。そのため、EFA の複数ノードで GENK を使うなら Proxy の経路になります。
EFA で GIN を使うときは、使う経路 (GIN のバックエンド) が、カーネルの必要とする種類の signal を提供するかを先に確かめます。NCCL は、カーネルが求める種類の signal を提供できないバックエンドを使わず、提供できる別のバックエンドがあればそちらに切り替え、無ければエラーにすると説明しています。EFA で使う GIN の経路として、ここでは 2 つを比べます。1 つは NCCL の GIN Proxy で、CPU のスレッドが aws-ofi-nccl の RMA の機能を通して EFA に送ります。もう 1 つは EFA GDA で、GPU のカーネルが EFA の送信キューに直接依頼を書きます。aws-ofi-nccl の文書によると、EFA GDA は NCCL_GIN_TYPE=5 で明示したときに使われます。同じ文書は、明示しなければ NCCL 2.31 は EFA で Proxy を使うと書いています。インスタンスやドライバなどの要件も同じ文書にあります。
2 つの経路は、提供する signal が違います。NCCL の Proxy は、strong signal と VA signal (登録した仮想アドレスに置く signal) を使えると報告します。EFA GDA については、aws-ofi-nccl が strong signal も VA signal も使えないと報告し、NCCL の側も番号で指す indexed signal だけを受け付けます。EFA GDA の signal は、EFA の NIC が届いた書き込みを数えるカウンタで作られています。aws-ofi-nccl の文書は、signal と counter の合計が EFA のデバイスあたり 512 個までであることも、制限に挙げています。
図の矢印は、使い手が必要とする signal をバックエンドが提供するかを示します。矢印はソースと文書から描いたもので、どの経路が選ばれるかや、動かした結果を示すものではありません。図の DeepEP の ordered は届く順番を前提にする上流のカーネル、unordered は順番を前提にしない amazon-contributing のフォークのカーネルです。
GENK はノードをまたぐ通信に使う GIN のバックエンドとして Proxy を指定しているので、staging のコードを読む限り、GENK が EFA の上で複数ノードの通信をするなら Proxy の経路を通ります。一方、aws-ofi-nccl の文書は、EFA GDA を使うときに symmetric の GIN カーネルを無効にするよう書いていて、この設定 (NCCL_SYM_GIN_KERNELS_ENABLE=0) にすると複数ノードの GENK は候補から外れます。つまり、EFA GDA を使う設定 (NCCL_GIN_TYPE=5 と NCCL_SYM_GIN_KERNELS_ENABLE=0) では複数ノードの GENK は使われず、複数ノードで GENK を試すときは、この 2 つの環境変数を設定せずに Proxy の経路で動かします。
aws-ofi-nccl v1.22.0 で、EFA GDA の Get がタグの付いた版で使えるようになった
Get は、GPU のカーネルが、ほかの GPU のメモリの中身を、持ち主に何もさせずに読み出す操作です。たとえば、手元の番号を見て、ほかの GPU が持つ表の行を取り出すような使い方ができます。Get の意義は「NCCL 2.32.3 でロードマップはどう実装されたか」で説明しました。
その記事の時点では、NCCL 2.32.3 には Get が入っていましたが、aws-ofi-nccl の側の対応はタグの無い master にしかありませんでした。2026 年 10 月 22 日 (日本時間) の v1.22.0 は、NCCL 2.32 が使うキューの形への対応と、Get の完了を数える修正を含む最初のタグです。これで、NCCL 2.32 と aws-ofi-nccl v1.22.0 という、タグの付いた版どうしに、EFA GDA の Get に要る変更がそろいました。v1.22.0 のリリースノートは Get に触れておらず、筆者は動かして確かめていません。
ただし、EFA GDA の Get には、完了を知らせる signal や counter を付けられません。読み出しが終わったかは、出した依頼をまとめて待つ Flush で確かめます。
MoE の通信ライブラリ (DeepEP と NCCL EP) の動き
MoE のトークンを担当の expert に配り (dispatch)、計算結果を集める (combine) 通信のライブラリにも動きがありました。DeepEP と NCCL EP はこの同じ役割の候補で、どちらも下で NCCL を使います。DeepEP は 2026 年 9 月 29 日の V2.5 で NVSHMEM への依存を取り除き、ノードをまたぐ通信は NCCL の GIN だけになりました。要件は NCCL 2.32.3 以降です。EFA で DeepEP を動かす取り組みは amazon-contributing/DeepEP のフォークにあり、その詳細は別の記事で整理してみます。
NVIDIA の NCCL EP は NVIDIA/nccl-extensions に移り、NCCL の staging からは、nccl_ep と nccl_m2n のディレクトリが消されました。
注目機能 2 選
CFT を使う階層型カーネル
CFT (Compute Fabric Transport) は NCCL 2.31 で Device API に加わった部品で、嬉しさは 2 つあります。1 つ目は、カーネルから直接届く相手を、LSA の範囲より広げられる見込みがあることです。LSA で届く相手は、CUDA P2P で相手のメモリを普通の読み書きの命令で触れる相手 (LSA team) に限られます。CFT は相手を番地ではなく CUDA が配る宛先の番号で指すので、届く範囲は、CUDA が「この番号で届く」と報告する範囲で決まります。前回のロードマップも、CFT を、logical endpoint で届く範囲の相手を宛先の番号で指す通信の形と説明していました。ただし、リリースされた版では、CFT で届く相手の集まり (CFT team) の大きさは既定で LSA team と同じです。
2 つ目は、受け手に届いたことを早く知らせる書き込みがあることです。CFT の普通の put や reduce で受け手に知らせるときは、データを書いたあと、書き込みの完了を確定させ (fence)、合図のフラグを書きます。これが counted な書き込みで、フラグの代わりに、NCCL_WIN_CFT_COUNTED を付けて登録した window の中のカウンタを使います。カウンタはデータより後に進むことが保証されているので、送り手は fence とフラグの書き込みを省けます。staging の文書は、これを低遅延のための操作と説明しています。受け手は、カウンタを見てからデータを読む間に fence を入れる必要があります。
仕組みは次のとおりです。CFT の宛先の番号は、CUDA 13.3 で加わった logical endpoint という仕組みです。NCCL は CPU の側で、宛先の番号を確保し、そこに各 GPU のバッファを結び付けておきます。カーネルは、送りたいデータを shared memory に置き、宛先の番号とオフセットを指定して put を出し、そのあと完了を確かめます。
dev では、CUDA 13.4 以降で CUDA が「logical endpoint で届く範囲」を報告すると、CFT team をその範囲から作ります。その範囲は LSA の範囲の倍数でなければならず、LSA より広い範囲を 1 つの CFT team にできる作りです。NCCL の文書は、CFT がどの物理的な経路を通るかを書いていません。NCCL EP の CFT を使う経路は、1 つの NVLink のドメインの中だけを対象にしています。
ロードマップは、この CFT を使って、AllGather、ReduceScatter、AlltoAll の階層型カーネルを作るとしています。階層型は、近い GPU の間と遠い GPU の間で通信を分ける作りで、LSA の範囲と、それより広い CFT team の範囲を使い分けることになると筆者は読んでいます。master、staging、dev を調べた範囲では、CFT の宛先の集まりの階層の種類は 2.32.3 のコードに定義されていますが、それを使う集団通信のカーネルは見当たりませんでした。
CFT とは
CFT が使えるのは、ハードウェア、ドライバ、CUDA が logical endpoint と fabric 用の命令に対応したシステムだけで、例の barrier のプログラムは CUDA 13.3 と SM_100 を要件に挙げています。CFT team を CUDA の報告する範囲 (clique) から作る変更は、dev の 2026 年 9 月 28 日の commit で入り、master と staging にはまだありません。
NCCL EP には、CFT の counted な書き込みで低遅延の dispatch を送る経路が、NCCL_EP_LL_CFT=1 で選んだときだけ使われる形で、2026 年 9 月 16 日の commit から main に入っています。
New Enqueue Pipeline: 呼び出しから起動までを 3 つの層に分ける
enqueue は、アプリが呼んだ集団通信や put を、カーネルやコピーの起動に変える NCCL の中の流れです。ロードマップは、これを管理 (Management)、準備 (Preparation)、スケジューリング (Scheduling) の 3 層に分け、制御の流れを簡単にし、カーネルの最適化をしやすくするとしています。
管理と準備の層を含む基本の枠組みは 2.31.2 から入っていて、その時点のスケジューリングの層は既存の経路に戻すだけでした。いまの staging と dev でも、新しい流れは既定で無効です。dev では、Preparation の tuning ですべての集団通信のアルゴリズムやプロトコルを先に選び、classification でその結果だけを使って起動の経路を振り分ける形にしました。経路ごとのスケジューラは、それぞれの仕事を stream ごとに 1 つのキューにまとめます。dev の commit では、新しい流れの symmetric のスケジューラが起動するのは特化した symmetric カーネルだけで、GENK は自分用の device communicator の準備が要るので、従来の流れの symmetric カーネルのスケジューラから起動されます。
まとめ
staging の GENK は、Hopper 以降の GPU でビルドと環境変数で有効にすると NVLink、PCIe、ネットワークのどの経路でも同じカーネルが候補になり、EFA の複数ノードでは strong signal を提供する GIN Proxy の経路を使います。CFT を使う階層型カーネルは部品だけが公開コードにあり、New Enqueue Pipeline は枠組みが既定で無効のまま dev で進んでいます。
Ship a Lambda Web Function with CDK
So, AWS Lambda Web Functions just came out, and I'm super excited about them.
What are they? Well, you can now ship Express, Hono, or React Router with no Lambda-specific adapters or wrappers.
Let me show you how I got this demo set up with CDK.
λ: cxrj1w1j65n0k7ss.p7v.lambda-web.us-east-2.on.aws (this may not live forever but the code should)
> ⚠️ DISCLAIMER: The code we are about to see is written with L1 CDK constructs. The L2 constructs are being tracked via this PR.
First, let's look at the server code I have in Express. I've omitted the bulk of its logic for brevity.
Here, you can see that it's an Express.js server binding to a port.
import express from "express";
const [hostname, rawPort] = (
process.env.AWS_LAMBDA_HTTP_ENDPOINT ??
`127.0.0.1:${process.env.PORT ?? "3000"}`
).split(":");
const port = Number(rawPort);
export const app = express();
export const server = app.listen(port, hostname, () => {
console.log(`Spinning Lambda is listening on http://${hostname}:${port}`);
});
I can run the code locally with pnpm dev.
!Image description
Now, let's look at the CDK stack used to deploy this function.
export class SpinningLambdaStack extends Stack {
constructor(scope: Construct, id: string, props?: StackProps) {
super(scope, id, props);
// code bucket stuff omitted
const webFunction = new CfnWebFunction(this, "WebFunction", {
functionName: `${this.stackName}-web`,
// many props omitted for brevity
});
const revision = new CfnWebFunctionRevision(this, "WebFunctionRevision", {
functionName: webFunction.ref,
// many props omitted for brevity
});
const endpoint = new CfnWebFunctionEndpoint(this, "WebFunctionEndpoint", {
functionName: webFunction.ref,
// many props omitted for brevity
});
}
}
Now for deployment. I'm using the new AWS experience, so I have a project dedicated to this demo.
I grab some credentials for my account with aws login. I need to do a one-time setup to bootstrap CDK with npx cdk bootstrap.
Once that's done, I can run pnpm deploy to ship it.
Then it's live 🚀!
!Image description
Share this on X/Twitter, and it should render as an embed:
https://cxrj1w1j65n0k7ss.p7v.lambda-web.us-east-2.on.aws/embed
Full repo:
sboult/lwf-three-js
---
As always, happy coding 😉!
Follow AWS for more articles like this.
{% organization aws %}
Follow me for all things tech!
{% user hacksore %}
5 reactions · 3 comments
AAIF に加わった Open Responses を Amazon Bedrock で試す
先端技術開発グループ(WAND)の伊賀です。
2026年10月22日、Linux Foundation 傘下の Agentic AI Foundation(AAIF)に、Open Responses が新しいプロジェクトとして加わりました(Linux Foundation のプレスリリース)。
Open Responses は複数の LLM プロバイダーを同じ形で呼び出すためのオープンな仕様です。
同じ日に AWS も Open Responses への関わりと Amazon Bedrock での対応を紹介しています(Open Responses Joins AAIF with AWS Support)。
内容は 2026年10月時点のものです。
Open Responses とは
Open Responses は OpenAI の Responses API をもとにした LLM API の共通仕様です(openresponses.org)。
主に次の 3 つを定めています(README)。
リクエストとレスポンスの共通スキーマ
ストリーミングで流れるイベントの種類と順序
ツール呼び出しの手順
仕様は日付つきのバージョンとして公開されていて、最新は 2026-04-24 です。
GitHub リポジトリには仕様の本文と OpenAPI 定義に加えて、API が仕様に沿っているかを確かめる準拠テストが入っています。
プロジェクトは OpenAI が寄贈したもので、今後は AAIF の下で運営されます。
同じ日に Anthropic の Agent Skills、Hugging Face の OpenEnv、Alibaba の OpenSandbox も加わり、AAIF のプロジェクトは MCP や A2A、AGENTS.md などとあわせて 10 個になりました。
共通の仕様が必要な理由
LLM の API はメッセージ、関数呼び出し、ツール利用、マルチモーダル入力といった似た概念に収束してきました。
しかし OpenAI の Responses API、Anthropic の Messages API、Google の Gemini API では、同じことをするにもプロバイダーごとに書き方が異なります。
そのため、複数のモデルを比べたり切り替えたりするには、プロバイダーごとの変換コードを書いて保守する必要がありました。
Open Responses に沿ったエンドポイントであれば、クライアントのコードはそのままで、接続先とモデル名を変えるだけで済みます。
AAIF のブログではこれを「エンドポイントとモデル識別子が、コードの分岐ではなく設定値になる」と表現しています(What Open Responses Changes for AI Developers)。
この形式のエンドポイントは Bedrock のほかにもあり、AWS のブログは Ollama、vLLM、OpenRouter、Hugging Face を、AAIF のブログは Databricks Model Serving と Vercel AI Gateway を挙げています。
各社が独自の機能を出す余地も残されています。
仕様にないツールやアイテムは、openai:web_search_call のようにプロバイダー名を前に付けて定義します。
クライアントは知らない種類のアイテムやイベントを無視しても、標準の部分はそのまま処理できます。
仕様の考え方
仕様の本文は次の 4 つの考え方を軸にしています(Specification)。
エージェントループ:モデルがツール呼び出しを出力し、結果を受け取って推論を続ける流れを、共通の形で扱います
アイテム:メッセージ、関数呼び出し、その実行結果、推論などを、type で区別するアイテムとして表します。アイテムは入力にも出力にも使えます
状態機械:レスポンスやアイテムは in_progress、completed、incomplete などの決まった状態を持ちます
意味のあるイベント:ストリーミングではテキストの差分をそのまま流さず、response.output_item.added や response.completed のように何が起きたかを表すイベントを流します
ツールには関数や MCP サーバーのように実装がプロバイダーの外にあるものと、プロバイダーの中で実行が完結するものの 2 種類があります。
2026-04-24 バージョンでは接続を張ったまま使う WebSocket での呼び出しと、長い会話の文脈を圧縮する /responses/compact エンドポイントが追加されました(Changelog)。
AWS の関わり
AWS は AAIF の Governing Board に加わっています。
Amazon Bedrock は 2025年12月から Responses API に対応していて、AWS のブログではその実装を「仕様が大規模な環境でどう動くかを示す、本番品質のリファレンス」とも紹介しています。
Bedrock で Responses API を使えるエンドポイントは 2 つあります(Responses API - Amazon Bedrock User Guide)。
エンドポイント
ベース URL
位置づけ
bedrock-runtime
bedrock-runtime.{region}.amazonaws.com
推奨
bedrock-mantle
bedrock-mantle.{region}.api.aws
GPT OSS の Responses API など、mantle でしか使えないモデルや機能向け
Responses API に対応しているのは、OpenAI の GPT 系と GPT OSS、xAI の Grok、Moonshot AI の Kimi K3、Google の Gemma 4、Z.AI の GLM 5.3 です(API compatibility)1。
Amazon Bedrock で動かしてみる
us-east-1 の bedrock-runtime に、Open Responses の形でリクエストを送ってみます。
認証には Bedrock の API キーを使います。有効期限の短いキーは AWS の認証情報から aws-bedrock-token-generator で発行できます。
基本のリクエストとレスポンス
curl で 1 回呼び出します。
store: false は会話を Bedrock 側に保存しないための指定です。
export OPENAI_BASE_URL=bedrock-runtime.us-east-1.amazonaws.com
export OPENAI_API_KEY=<Bedrock の API キー>
curl -sS "$OPENAI_BASE_URL/responses" \
-H "Authorization: Bearer $OPENAI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "global.openai.gpt-5.6-sol",
"input": "Open Responses を一文で説明してください。",
"store": false
}'
レスポンスから主要な部分を抜き出すと、次のようになります。
{
"id": "resp_fjc2ydhllu3zscoaqtqem3wpley2fas4u7dghhguugzipayo3pcq",
"object": "response",
"status": "completed",
"model": "global.openai.gpt-5.6-sol",
"output": [
{
"type": "reasoning",
"id": "rs_8c6287cb8f975b00bebcc6c9a47a5487",
"encrypted_content": "(省略)",
"content": [],
"summary": []
},
{
"type": "message",
"id": "msg_4629e516851f545db7fd68af8e29b3fd",
"role": "assistant",
"status": "completed",
"phase": "final_answer",
"content": [
{
"type": "output_text",
"text": "Open Responsesは、異なるAIモデルやエージェントの応答APIを共通形式で扱えるようにするオープンな標準仕様です。",
"annotations": []
}
]
}
],
"usage": {
"input_tokens": 15,
"output_tokens": 219,
"output_tokens_details": { "reasoning_tokens": 177 },
"total_tokens": 234
}
}
output はアイテムの配列で、推論の reasoning アイテムと、回答の message アイテムが順に並んでいます。
回答の本文は message アイテムの content にある output_text に入ります。
phase は 2026-04-24 バージョンで追加された任意のフィールドで、最終回答(final_answer)か途中の説明(commentary)かを表します。
レスポンス全体{
"background": false,
"billing": {
"payer": "developer"
},
"completed_at": 1792717922,
"created_at": 1792717919,
"error": null,
"frequency_penalty": 0.0,
"id": "resp_fjc2ydhllu3zscoaqtqem3wpley2fas4u7dghhguugzipayo3pcq",
"incomplete_details": null,
"instructions": null,
"max_output_tokens": null,
"max_tool_calls": null,
"metadata": {},
"model": "global.openai.gpt-5.6-sol",
"object": "response",
"output": [
{
"content": [],
"encrypted_content": "(省略)",
"id": "rs_8c6287cb8f975b00bebcc6c9a47a5487",
"summary": [],
"type": "reasoning"
},
{
"content": [
{
"annotations": [],
"logprobs": [],
"text": "Open Responsesは、異なるAIモデルやエージェントの応答APIを共通形式で扱えるようにするオープンな標準仕様です。",
"type": "output_text"
}
],
"id": "msg_4629e516851f545db7fd68af8e29b3fd",
"phase": "final_answer",
"role": "assistant",
"status": "completed",
"type": "message"
}
],
"parallel_tool_calls": true,
"presence_penalty": 0.0,
"previous_response_id": null,
"prompt_cache_key": null,
"prompt_cache_retention": "in_memory",
"reasoning": {
"summary": null,
"effort": "medium",
"context": "all_turns"
},
"safety_identifier": null,
"service_tier": "default",
"status": "completed",
"store": false,
"temperature": 1.0,
"text": {
"format": {
"type": "text"
},
"verbosity": "medium"
},
"tool_choice": "auto",
"tools": [],
"top_logprobs": 0,
"top_p": 0.98,
"truncation": "disabled",
"usage": {
"input_tokens": 15,
"input_tokens_details": {
"cache_write_tokens": 0,
"cached_tokens": 0
},
"output_tokens": 219,
"output_tokens_details": {
"reasoning_tokens": 177
},
"total_tokens": 234
},
"user": null,
"moderation": null
}
ストリーミングのイベント
"stream": true を付けると、Server-Sent Events でイベントが順に届きます。
「Say hello in three words.」と送ったときのイベントの並びは次のとおりでした。
response.created
response.in_progress
response.output_item.added reasoning アイテムの開始
response.output_item.done reasoning アイテムの完了
response.output_item.added message アイテムの開始
response.content_part.added
response.output_text.delta "Hello"
response.output_text.delta " there"
response.output_text.delta ","
response.output_text.delta " friend"
response.output_text.delta "!"
response.output_text.done
response.content_part.done
response.output_item.done message アイテムの完了
response.completed
レスポンス全体の状態の変化(response.created → response.in_progress → response.completed)の中に、アイテムごとの「追加 → 差分 → 完了」が入れ子になっています。
文字を順に画面へ表示したいだけなら、response.output_text.delta の delta をつなげれば足ります。
受信したイベント(一部のフィールドを省略)data: {"type": "response.created", "response": {"id": "resp_f4wjs5jc4gfuomhyckvwc2o7zlrmxx4e2ohxyyupkph7imdpjhaq", "status": "in_progress", "output": []}, "sequence_number": 0}
data: {"type": "response.in_progress", "response": {"id": "resp_f4wjs5jc4gfuomhyckvwc2o7zlrmxx4e2ohxyyupkph7imdpjhaq", "status": "in_progress", "output": []}, "sequence_number": 1}
data: {"type": "response.output_item.added", "item": {"content": [], "encrypted_content": "(省略)", "id": "rs_949298e3722a55969bf6fb415bcc857e", "summary": [], "type": "reasoning"}, "output_index": 0, "sequence_number": 2}
data: {"type": "response.output_item.done", "item": {"content": [], "encrypted_content": "(省略)", "id": "rs_949298e3722a55969bf6fb415bcc857e", "summary": [], "type": "reasoning"}, "output_index": 0, "sequence_number": 3}
data: {"type": "response.output_item.added", "item": {"content": [], "id": "msg_d2cdd7ec87f75d3bbf1bb4ae2effb0b6", "phase": "final_answer", "role": "assistant", "status": "in_progress", "type": "message"}, "output_index": 1, "sequence_number": 4}
data: {"type": "response.content_part.added", "content_index": 0, "item_id": "msg_d2cdd7ec87f75d3bbf1bb4ae2effb0b6", "output_index": 1, "part": {"annotations": [], "text": "", "type": "output_text"}, "sequence_number": 5}
data: {"type": "response.output_text.delta", "content_index": 0, "delta": "Hello", "item_id": "msg_d2cdd7ec87f75d3bbf1bb4ae2effb0b6", "output_index": 1, "sequence_number": 6}
data: {"type": "response.output_text.delta", "content_index": 0, "delta": " there", "item_id": "msg_d2cdd7ec87f75d3bbf1bb4ae2effb0b6", "output_index": 1, "sequence_number": 7}
data: {"type": "response.output_text.delta", "content_index": 0, "delta": ",", "item_id": "msg_d2cdd7ec87f75d3bbf1bb4ae2effb0b6", "output_index": 1, "sequence_number": 8}
data: {"type": "response.output_text.delta", "content_index": 0, "delta": " friend", "item_id": "msg_d2cdd7ec87f75d3bbf1bb4ae2effb0b6", "output_index": 1, "sequence_number": 9}
data: {"type": "response.output_text.delta", "content_index": 0, "delta": "!", "item_id": "msg_d2cdd7ec87f75d3bbf1bb4ae2effb0b6", "output_index": 1, "sequence_number": 10}
data: {"type": "response.output_text.done", "content_index": 0, "item_id": "msg_d2cdd7ec87f75d3bbf1bb4ae2effb0b6", "output_index": 1, "sequence_number": 11, "text": "Hello there, friend!"}
data: {"type": "response.content_part.done", "content_index": 0, "item_id": "msg_d2cdd7ec87f75d3bbf1bb4ae2effb0b6", "output_index": 1, "part": {"annotations": [], "text": "Hello there, friend!", "type": "output_text"}, "sequence_number": 12}
data: {"type": "response.output_item.done", "item": {"content": [{"annotations": [], "text": "Hello there, friend!", "type": "output_text"}], "id": "msg_d2cdd7ec87f75d3bbf1bb4ae2effb0b6", "phase": "final_answer", "role": "assistant", "status": "completed", "type": "message"}, "output_index": 1, "sequence_number": 13}
data: {"type": "response.completed", "response": {"id": "resp_f4wjs5jc4gfuomhyckvwc2o7zlrmxx4e2ohxyyupkph7imdpjhaq", "status": "completed", "output": "(省略)"}, "sequence_number": 14}
data: [DONE]
ツール呼び出しとエージェントループ
関数ツールを渡すと、モデルはツールが必要と判断したときに、回答の代わりに function_call アイテムを返します。
開発者側で関数を実行し、その結果を function_call_output アイテムとして返すと、モデルは結果を踏まえて回答します。
この流れが仕様でいうエージェントループです。
東京の天気を聞いたときのやり取りは、次のようになりました。
1 回目のリクエスト input : message(ユーザーの質問)
1 回目のレスポンス output: function_call get_weather {"city":"東京"}
2 回目のリクエスト input : message, function_call, function_call_output
2 回目のレスポンス output: message 「東京の現在の天気は晴れ、気温は18℃です。」
1 回目に出力された function_call アイテムは、そのまま 2 回目の入力に積んでいます。
アイテムが入力にも出力にも使えるので、クライアントは出力を加工せずに会話を続けられます。
今回は store: false にしているため、2 回目では 1 回目の入出力をすべて送り直しています。
store: true にすれば、previous_response_id で前のレスポンスを指定して、新しい入力だけを送る形にもできます。
Python と httpx で書いたコードは次のとおりです。
エージェントループのコード(Python)import json
import os
import httpx
BASE_URL = os.environ["OPENAI_BASE_URL"]
API_KEY = os.environ["OPENAI_API_KEY"]
MODEL = os.environ.get("MODEL", "global.openai.gpt-5.6-sol")
TOOLS = [
{
"type": "function",
"name": "get_weather",
"description": "指定した都市の現在の天気を返す",
"parameters": {
"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"],
},
}
]
def get_weather(city: str) -> dict:
return {"city": city, "weather": "晴れ", "temperature_c": 18} # 検証用の固定値
items = [{"type": "message", "role": "user", "content": "東京の天気を教えて"}]
while True:
r = httpx.post(
f"{BASE_URL}/responses",
headers={"Authorization": f"Bearer {API_KEY}"},
json={"model": MODEL, "input": items, "tools": TOOLS, "store": False},
timeout=120,
)
r.raise_for_status()
output = r.json()["output"]
items += output # 出力されたアイテムを、そのまま次の入力に積む
calls = [i for i in output if i["type"] == "function_call"]
if not calls:
break
for call in calls:
result = get_weather(**json.loads(call["arguments"]))
items.append(
{
"type": "function_call_output",
"call_id": call["call_id"],
"output": json.dumps(result, ensure_ascii=False),
}
)
message = next(i for i in output if i["type"] == "message")
print(message["content"][0]["text"])
ループの最後に積み上がったアイテム[
{
"type": "message",
"role": "user",
"content": "東京の天気を教えて"
},
{
"arguments": "{\"city\":\"東京\"}",
"call_id": "call_899965767e1351f0ad9eedeee141713c",
"id": "fc_8bae4d13c8af5eeabe698af52b86aafd",
"name": "get_weather",
"status": "completed",
"type": "function_call"
},
{
"type": "function_call_output",
"call_id": "call_899965767e1351f0ad9eedeee141713c",
"output": "{\"city\": \"東京\", \"weather\": \"晴れ\", \"temperature_c\": 18}"
},
{
"content": [
{
"annotations": [],
"logprobs": [],
"text": "東京の現在の天気は晴れ、気温は18℃です。",
"type": "output_text"
}
],
"id": "msg_67235d5875da5839bc139e6e43cdecb5",
"phase": "final_answer",
"role": "assistant",
"status": "completed",
"type": "message"
}
]
モデルを差し替える
上のコードの MODEL だけを変えて、ベンダーの違うモデルで同じループを動かしました。
GPT OSS は bedrock-mantle でしか Responses API を使えないため、接続先も切り替えています。
モデル
ベンダー
エンドポイント
1 回目の output
2 回目の output
global.openai.gpt-5.6-sol
OpenAI
bedrock-runtime
function_call
message
global.xai.grok-4.7
xAI
bedrock-runtime
reasoning, function_call
reasoning, message
global.moonshotai.kimi-k3
Moonshot AI
bedrock-runtime
reasoning, function_call
reasoning, message
openai.gpt-oss-120b
OpenAI
bedrock-mantle
reasoning, function_call
message
4 モデルとも、コードを変えずに get_weather を呼び、その結果から晴れで気温は 18℃ と答えました。
推論の返し方はモデルによって違いました。
Grok 4.7 は推論を暗号化した encrypted_content として返し、Kimi K3 は推論のテキストを content に入れて返しました。
基本のリクエストで GPT-5.6 Sol が返した reasoning アイテムも、Grok 4.7 と同じ暗号化した形でした。
仕様では、生の推論、暗号化した推論、推論の要約のどれを返すかはプロバイダーが選べます。
どのモデルでもアイテムの種類は reasoning なので、クライアントは中身を表示するかどうかだけを決めればよく、次の入力にはアイテムをそのまま積み直せます。
reasoning アイテムの違い(Grok 4.7 と Kimi K3)
Grok 4.7 の reasoning アイテムです。
{
"content": [],
"encrypted_content": "(省略)",
"id": "rs_ce42a11eac045f66a050ac8795cd2dee",
"summary": [],
"type": "reasoning"
}
Kimi K3 の reasoning アイテムです。
{
"content": [
{
"text": "The weather tool returned:\n- City: 東京 (Tokyo)\n- Weather: 晴れ (Sunny/Clear)\n- Temperature: 18°C\n\nI should respond in Japanese since the user asked in Japanese. I'll give a natural, friendly response with the weather information.",
"type": "reasoning_text"
}
],
"id": "rs_2709a15cc20a5e899c4d2d98d2c61d68",
"summary": [],
"type": "reasoning"
}
準拠テストを Bedrock に向けて動かす
リポジトリの準拠テストは Web 版(openresponses.org)と同じもので、CLI からも実行できます。
ただし WebSocket のテストは、ブラウザから認証ヘッダーを付けられないため、CLI でしか動きません。
実行には Bun が必要で、ベース URL、API キー、モデル名を渡せば動きます。
git clone openresponses/openresponses
cd openresponses
bun install
bun run test:compliance \
--base-url "$OPENAI_BASE_URL" \
--api-key "$OPENAI_API_KEY" \
--model global.openai.gpt-5.6-sol
テストは 17 件あり、基本の応答、ストリーミング、システムプロンプト、ツール呼び出し、画像入力、複数ターンの会話に加えて、2026-04-24 バージョンで追加された WebSocket、compact、phase も対象です。
GPT-5.6 Sol では 17 件中 15 件が通り、WebSocket での応答や会話の継続も確認できました。
実行結果(GPT-5.6 Sol、途中経過の表示を省略)Running compliance tests against: bedrock-runtime.us-east-1.amazonaws.com
Model: global.openai.gpt-5.6-sol
✓ Response Output Phase Schema
✓ Compaction Missing Required Model (676ms)
✗ WebSocket Missing Previous Response (992ms) [1 events]
✗ Expected previous_response_not_found but got model_not_found
✓ Basic Text Response (1139ms)
✓ Image Input (1150ms)
✓ Multi-turn Conversation (1156ms)
✓ Tool Calling (1158ms)
✓ Streaming Response (1290ms) [22 events]
✓ System Prompt (1283ms)
✓ Assistant Message Phase (1311ms)
✓ Compaction Endpoint (1435ms)
✓ WebSocket Continuation (2336ms) [19 events]
✓ WebSocket Response (2359ms) [13 events]
✓ WebSocket Sequential Responses (2397ms) [18 events]
✗ WebSocket Failed Continuation Evicts Cache (2637ms) [22 events]
✗ Expected previous_response_not_found after failed continuation eviction but got no error code
✓ WebSocket Compact New Chain (3024ms) [10 events]
✓ WebSocket Store False Reconnect Recovery (4157ms) [20 events]
==================================================
Results: 15 passed, 2 failed, 0 skipped, 17 total
結果はモデルによって変わり、たとえば Grok 4.7 と Kimi K3 は compact に対応していませんでした。
準拠テストは、使いたいモデルが仕様のどこまでに対応しているかを手元で手軽に確かめる道具として使えます。
まとめ
Open Responses は OpenAI の Responses API をもとにした LLM API の共通仕様で、2026年10月22日に AAIF のプロジェクトになりました。
アイテム、状態、イベントの形が決まっているので、接続先とモデル名を変えるだけで、同じクライアントのコードを使い回せます。
Amazon Bedrock では bedrock-runtime と bedrock-mantle の 2 つのエンドポイントで使え、今回は 3 ベンダー・4 モデルで同じエージェントループが動きました。
リポジトリの準拠テストを使えば、モデルごとの対応範囲も手元で確かめられます。
複数のモデルを比べたり切り替えたりする場面で、まず試してみる価値のある仕様だと感じました。
脚注
AWS のブログには Amazon、Anthropic、Meta、Mistral、DeepSeek などのモデルも Responses API で使えると書かれています。ただし Bedrock のドキュメントの対応表では、これらのモデルは Responses API に対応していません。今回の検証でも、Claude Sonnet 5.5、Llama 4 Maverick、Nova Pro、Mistral Large 3、DeepSeek V3.2 への Responses API のリクエストは受け付けられませんでした。Claude Sonnet 5.5 は Messages API で、Mistral Large 3 と DeepSeek V3.2 は Chat Completions で呼び出せます。 ↩︎
AWS Lambda now runs your HTTP server. Do you still need API Gateway?
AWS recently announced AWS Lambda Web Functions, a new kind of Lambda function that runs your code as a Node.js HTTP server listening on a port, rather than as a handler receiving an event. To tell the two apart, the Lambda functions you already know are now called Event Functions. AWS says Web Functions suit web applications, API backends and other HTTP workloads with many concurrent requests.
Until now, the usual ways to run an API on Lambda have been these:
- One function per route behind API Gateway.
- The whole app in one function behind an adapter, the Lambdalith.
- A function URL in front of one function, with no API Gateway at all.
The adapters made that work by turning every event back into a request your framework understands, but you were left with one request at a time per environment, billing for the time spent waiting on I/O and a 6 MB payload limit, which is why Aidan Steele, AWS Hero, calls the approach "always a hack". Even AWS's own Lambda Web Adapter now recommends Web Functions for new Node.js web apps.
The one Web Functions-specific detail is the address your server listens on, which Lambda passes as a host and port in the AWS_LAMBDA_HTTP_ENDPOINT environment variable, as in the Express example from the announcement:
import express from 'express';
const app = express();
const [host, port] = process.env.AWS_LAMBDA_HTTP_ENDPOINT.split(':');
app.get('/', (req, res) => {
res.json({ message: 'Hello from Lambda Web Functions!' });
});
app.listen(port, host, () => {
console.log(`Server running on port ${port}`);
});
<figcaption>
From <a href="aws.amazon.com">Announcing AWS Lambda Web Functions</a>, AWS News Blog.
</figcaption>
Because a Web Function is a plain Node.js server, local development and testing are simpler than with an Event Function behind API Gateway, where local testing can take an emulator such as sam local start-api, running your functions in a local Docker container. On your laptop nothing sets the variable, so you set it yourself and run the same index.js with Node:
AWS_LAMBDA_HTTP_ENDPOINT=127.0.0.1:3000 node index.js
What changes in practice is that one environment handles up to 128 requests at once, 64 by default, so a burst of traffic shares warm environments instead of starting one per request. Lambda bills compute for the CPU time your code actually uses, so a request waiting on a database or another API uses little of the CPU you pay for.
One caveat: anything your code keeps in a module-level variable is shared by every request in that environment, so a value meant for one request can end up in another. The docs are clear that your code must be safe for concurrent use.
Web Functions make me wonder whether it's time to rethink the Lambdalith, which we've long treated as a pattern to avoid. AWS's own Lambda guide lists it among anti-patterns that "work but can increase costs and complexity", and Yan Cui, AWS Serverless Hero, covered its trade-offs and adopted it "where it makes sense". On his podcast, in a conversation recorded before Web Functions existed, he weighed a tailored IAM role on each function and lighter cold starts against how useful one function was for bringing an existing Express app across. His guest, Heitor Lessa, who created Powertools for AWS Lambda while at AWS, said he usually started from one function and split it as the boundaries and the permissions became clear.
Web Functions soften the cold-start side of the case for splitting, since environments start less often, but not the permissions side, because every route runs under the same execution role. Still, a Node.js server we'd have put in a container on Fargate could now run as a single Web Function, which makes the Lambdalith look less like an anti-pattern and more like a choice with its own trade-offs.
An API Gateway REST API checks API keys, holds each customer to a usage plan and throttles a noisy neighbour without slowing everyone else. A Web Function comes with its own HTTPS endpoint, which does none of that, and its rate limit applies to the whole endpoint in each Region. That leaves the question of whether you still need API Gateway.
What goes in front
If you need a custom domain, AWS WAF or caching, AWS recommends putting CloudFront or API Gateway in front of the endpoint, since the endpoint itself doesn't support custom domain names or direct AWS WAF association. The endpoint's domain stays reachable from the internet, so clients can go around whatever you put in front unless the endpoint refuses them:
!Requests reaching a Web Function endpoint directly, through CloudFront or through API Gateway
Nothing in front
Each endpoint has an authentication type, a setting that controls which requests reach your code. The default, ApplicationManaged, lets every request through to your code, while IamAuth accepts only requests signed with SigV4 by an IAM identity allowed to invoke the endpoint. An IamAuth endpoint suits calls between services inside AWS, where every client already has an IAM identity, as long as they can share the endpoint's rate limit. Lambda doesn't add CORS headers for Web Functions, so your app answers preflight requests itself.
CloudFront
CloudFront is another option that lets you make the endpoint refuse requests that don't come through your distribution, using origin access control on an IamAuth endpoint. CloudFront signs every request it sends, though requests signed by any IAM identity your policies allow still get through. To let CloudFront in, you attach this resource-based policy from AWS's CloudFront guide, with aws lambda-web put-resource-policy on the function's ARN, not the endpoint's:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowCloudFrontOACAccess",
"Effect": "Allow",
"Principal": {
"Service": "cloudfront.amazonaws.com"
},
"Action": "lambda:InvokeWebFunctionEndpoint",
"Resource": "arn:aws:lambda:us-east-1:111122223333:web-function/my-function/endpoint/prod",
"Condition": {
"StringEquals": {
"aws:SourceArn": "arn:aws:cloudfront::111122223333:distribution/EDFDVBD6EXAMPLE"
}
}
}
]
}
<figcaption>
From <a href="docs.aws.amazon.com">Restrict access to an AWS Lambda Web Function origin</a>, Amazon CloudFront Developer Guide.
</figcaption>
Your app can still check its own tokens, such as a JWT, because CloudFront adds its signature in a dedicated header and passes the viewer's Authorization header to the origin unmodified, as long as the header is in the cache policy. That also puts the header in the cache key, so with caching on, CloudFront keeps a separate copy of each response per token.
AWS WAF on the distribution can limit how often each client calls, by IP address or by a header value over a window of one to ten minutes, though AWS says it's not intended for precise request-rate limiting. A request that leaves the header out isn't evaluated or rate limited.
You give up Lambda@Edge origin request and origin response triggers on any cache behaviour that reaches the endpoint through origin access control. CloudFront's signature doesn't cover the body of PUT, POST and PATCH requests either, which the HTTPS connection to the endpoint protects in transit.
API Gateway
API Gateway reaches the endpoint through a plain HTTP proxy integration, since there's no Web Functions integration. Both REST and HTTP APIs have authorisers, but API keys, usage plans with a quota per day, week or month, request validation and AWS WAF need a REST API. Usage plan throttling and quotas are applied on a best-effort basis, so clients can sometimes go over them.
Neither the Lambda nor the API Gateway docs describe a way for API Gateway to sign requests to an IamAuth endpoint, so the endpoint stays ApplicationManaged. Lambda's best practices tell you to verify a shared secret or token in your app instead, and API Gateway can add the secret as a header on every request through parameter mapping.
Before you move your API
- Node.js 24 is the only runtime.
- Web Functions run exclusively on Arm64 and deploy from a .zip file, not a container image.
- Each environment gets 2 vCPUs and 2 GB, which you can't change.
- Endpoints accept HTTP/1.1 requests only, with no WebSocket upgrades.
Running it has a few gotchas:
- Lambda doesn't check that your server listens when you deploy, so a revision can show Active while every request fails.
- Web Function metrics don't include a revision dimension, so during a canary you can't compare the two revisions in those metrics.
- Outbound requests come from an address that isn't stable, so a partner's allow list needs an egress network connector into your VPC and a NAT gateway.
- On top of CPU time, you pay for memory over the life of each execution environment, idle time between requests included.
If your API isn't on Express, Eric Johnson, Principal Developer Advocate at AWS, has recently written a pattern-driven tour of seven protocols and frameworks on Web Functions, including server-sent events, gRPC-Web and signed webhooks, with working, deployable code for every pattern.
5 reactions · 2 comments
Mentions
Lambda Web Functions の L2 マージされた! aws-cdk#39210
aws login + assumeRole + terraform のエラー回避 問題の概要 条件 aws login を使って認証する。 そこからさらに assumeRole して別ロールを使う その状態で terraform を実行する 発生する現状: 以下のエラーが発生する ╷ │ Error: failed to load assume role arn:aws:iam::123456789012:role/MY-ROLE, of profile LOGIN-PROFILE, <nil> │ │ ╵ つまり、aws login と assumeRole を併用して terraform を使えない。 対処方法 ! 本対処は aws-sdk-go-v2 の現時点の実装を利用したワークアラウンドであり、正しい設定ではなく今後の aws-sdk-go-v2 の更新で使えなくなる可能性があります。 以下のように assumeRole 用のプロファイルを作成する: aws-sdk-go-v2 で aws login + assumeRole が失敗する問題回避用のプロファイル [profile ROLE-PROFILE-go] login_session = arn:aws:iam::123456789012:user/MY-USER region = ap-northeast-1 role_arn = arn:aws:iam::123456789012:role/MY-ROLE role_session_name = MY-USER 通常の aws login + assumeRole 用のプロファイル (参考) [profile ROLE-PROFILE] region = ap-northeast-1 role_arn = arn:aws:iam::123456789012:role/MY-ROLE role_session_name = MY-USER source_profile = LOGIN-PROFILE aws login で作成されるプロファイル [profile LOGIN-PROFILE] login_session = arn:aws:iam::123456789012:user/MY-USER region = ap-northeast-1 terraform 利用時にはこの ROLE-PROFILE-go を使う: AWS_PROFILE=ROLE-PROFILE-go terraform init 事象の詳細 再現手順 前提条件 コンソールログイン用のユーザーもしくはロールがある (IAM ユーザー MY-USER とする) そこから assumeRole して使うユーザーがある (IAM ロール MY-ROLE とする) MY-USER でコンソールログインする。 以下コマンドで awscli の認証を行う。 aws login --profile LOGIN-PROFILE ~/.aws/config に以下のようなエントリーが作成される: [profile LOGIN-PROFILE] login_session = arn:aws:iam::123456789012:user/MY-USER region = ap-northeast-1 ~/.aws/config に以下のようなエントリーを作成する: [profile ROLE-PROFILE] region = ap-northeast-1 role_arn = arn:aws:iam::123456789012:role/MY-ROLE role_session_name = MY-USER source_profile = LOGIN-PROFILE awscli でこのプロファイルで MY-ROLE として認証できることを確認する。 AWS_PROFILE=ROLE-PROFILE aws sts get-caller-identity aws へのアクセスが発生する Terraform テンプレートで terraform を実行する。バックエンドに S3 バケットを使用するなど。 AWS_PROFILE=ROLE-PROFILE terraform init バックエンドに S3 を使用しておらず AWS プロバイダーで AWS リソースにアクセスしている場合は init ではエラーにならず plan などでエラーになる。 以下のようなエラーが発生する。 ╷ │ Error: failed to load assume role arn:aws:iam::123456789012:role/MY-ROLE, of profile LOGIN-PROFILE, <nil> │ │ ╵ 原因 aws-sdk-go-v2 のバグによる問題です。 このため実際は terraform 固有の事象ではなく、 aws-sdk-go-v2 を使う golang アプリケーション共通の事象です。aws-sdk-go-v2 は AWS 公式のライブラリーのため、事実上、 golang 実装の AWS を扱うアプリケーションはすべて同じ問題に遭遇すると考えてよいでしょう。 正確にはバグなのか仕様なのかは不明です。本事象は 2026 年 6 月に Login session (aws login) credentials rejected when used as source_profile for an assume-role profile #3446 で報告されており、#3447 で修正案のプルリクエストも提出されているのですが、2026 年 11 月現在もマージされていません。 原因は github.com/aws/aws-sdk-go-v2/config のプロファイルが認証情報を作成できるか判定する処理 で、 「login_session があるときは認証情報を作成する」という判定がないことによるものです。 その結果、source_profile に login_session (aws login 由来) によって認証するプロファイルを指定すると、認証情報が生成できないとして「failed to load assume role (元のプロファイルで role_arn に指定しているプロファイル), of profile (source_profile に指定したプロファイル), (エラー情報。error が生成されていないので常に nil)」というエラーになります。 回避方法 ワークアラウンドプロファイルによる回避 aws-sdk-go-v2 の実装上、同一プロファイル内に login_session と role_arn の両方を書くと login_session (aws login 由来) を使って認証情報を解決した後、 assumeRole する動作をします。 golang アプリケーション (aws-sdk-go-v2) 用の設定 login_session を書いて aws login の認証情報を使った後、 role_arn で assumeRole で認証情報を作成する [profile ROLE-PROFILE-go] login_session = arn:aws:iam::123456789012:user/MY-USER region = ap-northeast-1 role_arn = arn:aws:iam::123456789012:role/MY-ROLE role_session_name = MY-USER コード上の該当箇所: login_session があると aws login 由来の認証情報を解決してきて、そのまま認証情報の解決処理を続行する: config/resolve_credentials.go role_arn があるとその時点の認証情報を使って assumeRole を行う: config/resolve_credentials.go なお、このプロファイルは botocore では role_arn があるのに source_profile がないため不適切な設定と判定されるため、 awscli などで使うとエラーになります: github.com/boto/botocore/blob/1.43.123/botocore/c… $ AWS_PROFILE=ROLE-PROFILE-go aws sts get-caller-identity aws: [ERROR]: Partial credentials found in assume-role, missing: source_profile or credential_source 一方、今回のワークアラウンドプロファイルにさらに source_profile を追加すると、aws-sdk-go-v2 側でワークアラウンドが機能するよりも先に今回の問題となっているチェック処理に引っかかるため、元の通りのエラーになってしまいます。 このため、このワークアラウンドプロファイルは golang アプリケーション専用に、awscli 等で使うプロファイルとは別に作っておく必要があります。 switch role してから aws login する AWS コンソール上で「他のロールに切り替える」の操作を行い、 MY-ROLE に切り替えてから aws login することで、直接 MY-ROLE の状態の認証情報を得ることが出来ます。 正規の方法で安全ですが、使用するロールが多い場合は切り替えのたびに aws login が必要なので煩わしいという問題があります。 また、aws login 用のトークン発行を許可するためにロールに SignInLocalDevelopmentAccess ポリシーを割り当てておく必要があります。 参考: コンソール認証情報を使用して AWS ローカル開発にログインする - AWS Command Line Interface Appendix: terraform / opentofu の aws login 対応バージョン terraform や opentofu で aws login を使うためには、一定以上のバージョンである必要があります。 また、S3 バックエンドを使う場合は terraform / opentofu 本体のバージョンが影響し、 AWS 上のリソースの作成については AWS プロバイダー (terraform-aws-provider) のバージョンが影響します。 必要な aws-sdk-v2-go のバージョン github.com/aws/aws-sdk-go-v2/credentials v1.19.0 以上 github.com/aws/aws-sdk-go-v2/config v1.32.0 以上 already reviewed add logincreds provider - #3230 がマージされたバージョンを使う必要がある。 Terraform のバージョン: v1.15.0 以上、または v1.14.9 以上 v1.15.0 での更新: 12276b8 v1.14.9 での更新: c78dd8d OpenTofu のバージョン: v1.12.0 以上、または v1.11.10 以上 v1.12.0 での更新: 5b6ca0a v1.11.10 での更新: 5e94874 AWS プロバイダー (terraform-aws-provider) のバージョン: v6.23.0 以上、または v6.22.1 以上、 v6.23.0 / v6.22.1 での更新: bead7e4 v5 系は現時点の最新の v5.100.0 でもサポートなし
Node.js 26 LTS: planning the Lambda upgrade Node.js 26 LTS has been official since October 28, 2026, under the codename “Lithium”. For me, an LTS switch like this is always the moment to stop eyeing the next release and write an upgrade plan instead. If you only touch your Lambda functions once the deprecation email from AWS lands in your inbox, you work under time pressure. And time pressure is a pretty bad advisor for runtime upgrades. On AWS Lambda, the nodejs26.x runtime is already available as a public preview, and according to the documentation AWS is targeting general availability for November 2026. That window is valuable: you can test today without having to switch production today. I will show you what Node.js 26 brings, what breaks when you move from Node.js 22 or 24, and how a small probe function for a fictional bike rental tells you what the new Lambda runtime can really do. > In short > > Node.js 26 became LTS on October 28, 2026 and receives updates until the end of April 2029, while nodejs26.x on Lambda is still a preview with a planned launch in November 2026. The most useful addition for everyday work is the Temporal API for dates and times. The biggest pitfall: what works under Node 26 on your laptop is not necessarily enabled the same way in the Lambda runtime, so check features there directly instead of assuming them. What you will learn in this article - What Node.js 26 LTS means for your support timeline on Lambda - Which additions such as Temporal and Map.getOrInsert you can use right away - Which changes can break when you upgrade from Node.js 22 or 24 - How to test nodejs26.x next to nodejs24.x with AWS CDK - How to find every Node runtime in your AWS account What Node.js 26 LTS means for your timeline LTS stands for Long Term Support: a version receives security and bug fixes over a long period, and new features only arrive cautiously. Release 26.13.0, which officially starts Node.js 26 LTS, is purely formal. According to the release notes, it only switches metadata such as process.release to LTS and contains nothing else beyond 26.12.1. Updates for Node.js 26 LTS continue until the end of April 2029. The Node.js project is also changing its rhythm. Until now, only even-numbered versions became LTS, while odd ones such as Node.js 25 were done after six months (25 reached EOL, or “end of life”, at the end of March 2026). Starting with Node.js 27, there will be one major version per year, and each of them becomes LTS after its six-month Current phase. For teams, that means fewer versions and a predictable cadence. Here is where Lambda stands at the end of October 2026: | Runtime | Node.js line | Status on Lambda | | --- | --- | --- | | nodejs22.x | LTS “Jod” | Deprecated on Apr 30, 2027, updates blocked from Aug 31, 2027 | | nodejs24.x | LTS “Krypton” | Deprecated on Apr 30, 2028 | | nodejs26.x | LTS “Lithium” | Public preview, general availability planned for November 2026 | !Timeline of Node.js runtimes on AWS Lambda: nodejs22.x until April 2027, nodejs24.x until April 2028, nodejs26.x from November 2026 For many teams, the date that matters most is the end of nodejs22.x in April 2027. My take: for many teams, the real deadline is not the launch of Node.js 26 but the deprecation of nodejs22.x. That leaves roughly six months. If you plan now, you can fit the upgrade into your normal sprint cadence. Node.js 26 LTS features you will notice day to day Coming from Node.js 24, you get not only the changes in Node.js 26 LTS but also everything from Node.js 25. That intermediate version was never LTS, so many teams skipped it. A few points stand out for daily work. Temporal: dates and times without the headache Node.js 26 LTS ships the Temporal API enabled by default, no flag required. Temporal is the new standard for dates and times in JavaScript and fixes the weaknesses of Date: months that start at 0, no real time zone arithmetic, and objects you can mutate by accident. Instead, you get clear types. Temporal.Instant is an exact point in time, Temporal.ZonedDateTime is a point in time in a specific time zone, and Temporal.PlainDate is a plain calendar date. const pickup = Temporal.ZonedDateTime.from("2026-10-24T10:00[Europe/Berlin]"); const due = pickup.add({ days: 2 }); console.log(due.toString()); // 2026-10-26T10:00:00+01:00[Europe/Berlin] (same wall-clock time, even though the DST change happens in between) We will use exactly this example in the probe function later. With Date and “plus 48 hours”, you end up at 9 a.m. instead of 10 a.m. on October 26, because the clocks go back during the night before October 25. I cover how to use Temporal day to day for deadlines, time zones and DynamoDB in detail in Temporal API in TypeScript: deadlines and time zones. Small helpers: getOrInsert, Iterator.concat and Base64 V8 14.6 adds two small but pleasant functions. Map.prototype.getOrInsert() and getOrInsertComputed() read a value or create it if it is missing. The classic “does this key exist yet?” pattern for grouping shrinks to a single line. Iterator.concat() chains several iterators without building intermediate arrays. const rentals = [ { station: "Central Station", bikeId: "city-bike-17" }, { station: "Old Town", bikeId: "e-bike-03" }, { station: "Central Station", bikeId: "cargo-bike-02" }, ]; const byStation = new Map<string, string[]>(); for (const rental of rentals) { byStation.getOrInsert(rental.station, []).push(rental.bikeId); } // Map { "Central Station" => ["city-bike-17", "cargo-bike-02"], "Old Town" => ["e-bike-03"] } Node.js 25 contributed, for example, Uint8Array.fromBase64() and toBase64() (plus hex variants) and a noticeably faster JSON.stringify. In Lambda functions that build lots of JSON responses or encode binary data for API Gateway, that is a nice bonus for free. One observation from my test: with "lib": ["esnext"], TypeScript 7.0.2 already knows the types for Temporal, getOrInsert and fromBase64, but still reports an error for Iterator.concat. Platform: Undici 8, Web Storage and permissions Undici, the built-in HTTP client behind fetch, moves to version 8. Since Node.js 25, the Web Storage API is enabled by default, but in Node.js 26 localStorage returns undefined as long as no storage file is configured. Since Node.js 25, the permission model also knows --allow-net, which lets you restrict a process’s network access. On Lambda, that matters more for local tools and CI scripts, because isolation there is handled by IAM and VPC anyway. What breaks when you move to Node.js 26 LTS Most projects keep running after the upgrade to Node.js 26 LTS. Still, there are a handful of changes worth checking deliberately before you switch the runtime: - The old internal modules _stream_readable, _stream_writable and their siblings are removed in Node.js 26. Older libraries sometimes still import them directly. - http.Server.prototype.writeHeader() is gone, use writeHead(). - Since Node.js 25, fs.F_OK, fs.R_OK, fs.W_OK and fs.X_OK are no longer exported directly from the module. The constants live under fs.constants. - SlowBuffer and assert.CallTracker have reached end of life since Node.js 25. - Corepack is no longer bundled since Node.js 25. If you enable pnpm or Yarn via Corepack, install it separately in CI. - module.register() emits a runtime deprecation warning in Node.js 26, and --experimental-transform-types was removed. - Native addons need a rebuild, because NODE_MODULE_VERSION changes to 147. - Since Node.js 25, the official Linux binaries are linked against libatomic. Slim container images for CI may be missing that package. On top of that on Lambda: what has applied since nodejs24.x If you jump from nodejs22.x straight to 26, you also pick up the Lambda-specific changes AWS introduced with nodejs24.x. Callback handlers are no longer supported, context.callbackWaitsForEmptyEventLoop is removed, and so are context.succeed, fail and done. In addition, Lambda no longer waits for pending promises once your handler returns. Anything you still want to finish has to be completed with await first. // Before: callback handler (no longer works from nodejs24.x) export const handler = (event, context, callback) => { loadBike(event.bikeId) .then((bike) => callback(null, bike)) .catch((err) => callback(err)); }; // After: async handler, every piece of work is awaited export const handler = async (event: { bikeId: string }) => { const bike = await loadBike(event.bikeId); await auditLog.write({ action: "lookup", bikeId: bike.id }); // without await this would get lost return bike; }; One more point is easy to miss: Lambda disables experimental Node.js features by default, such as require() of ES modules and automatic module detection. You can turn them back on via NODE_OPTIONS, but AWS covers such cases with neither support nor the SLA. nodejs26.x on Lambda: the state at the end of October 2026 The nodejs26.x runtime is available as a public preview, both as a ZIP runtime and as the container base image public.ecr.aws/lambda/nodejs:26-preview. Preview means no SLA, no technical support, and breaking changes are possible. It is explicitly not meant for production workloads. AWS names November 2026 as the launch of the stable version, but only as a non-binding estimate. The feedback issue for the preview on GitHub is revealing. Users report, among other things, that Temporal is missing in the preview and that process.features.require_module is false. These are community observations, not an official AWS statement, and a lot can change before the stable release. I still think the lesson matters: a Lambda runtime is not automatically identical to the Node.js on your machine. So let us check directly in Lambda. > 🎬 Probe function on two Lambda runtimes – this animated diagram only runs on my site: watch it on sascha-ms.de Step by step: testing nodejs26.x next to nodejs24.x Our example is a fictional bike rental. A function calculates when a rented bike has to be back: same time of day, a given number of days later, in the time zone of the rental station. It also returns a small report on which features the runtime really provides. If Temporal is built in, the function uses it, otherwise a polyfill steps in. A polyfill is a retrofitted implementation of a standard API for environments that do not support it yet. Prerequisites - Node.js 22 or newer locally (thanks to the polyfill, the tests run without Node.js 26 too) - AWS CLI v2 with credentials for a test or sandbox account - An account bootstrapped with npx cdk bootstrap in your region - A region where nodejs26.x is already selectable. You can see that in the Lambda console when creating a function. Step 1: create the project mkdir bike-rental-runtime-check && cd bike-rental-runtime-check npm init -y npm install @js-temporal/polyfill@0.5.1 npm install -D typescript@7.0.2 @types/node@26 @types/aws-lambda tsx esbuild aws-cdk-lib@2.275.0 constructs aws-cdk@2.1146.1 mkdir -p bin lib src test Switch package.json to ES modules and add a few scripts: { "name": "node26-lambda-upgrade", "version": "1.0.0", "scripts": { "typecheck": "tsc --noEmit -p .", "test": "node --import tsx --test test/*.test.ts", "synth": "cdk synth", "deploy": "cdk deploy" }, "license": "MIT", "devDependencies": { "@types/aws-lambda": "^8.10.164", "@types/node": "^26.6.6", "aws-cdk": "^2.1146.1", "aws-cdk-lib": "^2.275.0", "constructs": "^10.8.1", "esbuild": "^0.28.2", "tsx": "^4.23.15", "typescript": "^7.0.2" }, "dependencies": { "@js-temporal/polyfill": "^0.5.1" }, "private": true, "type": "module" } Step 2: configure TypeScript With "lib": ["esnext"], TypeScript knows the new APIs such as Temporal. noEmit is enough, because esbuild bundles during deployment and tsx runs the tests directly. // tsconfig.json { "compilerOptions": { "target": "es2024", "lib": ["esnext"], "module": "nodenext", "moduleResolution": "nodenext", "strict": true, "types": ["node"], "noEmit": true, "skipLibCheck": true }, "include": ["bin", "lib", "src", "test"] } Step 3: write the runtime report The function checks every feature directly instead of inferring it from the version number. That is more reliable, because a runtime can have a feature disabled despite a matching version. // src/runtime-report.ts // Reports which runtime features are actually available in the current Node.js environment. export interface RuntimeReport { nodeVersion: string; ltsName: string | null; temporal: boolean; mapGetOrInsert: boolean; iteratorConcat: boolean; uint8ArrayBase64: boolean; requireModule: boolean; lambdaRuntime: string | null; } export function collectRuntimeReport(env: NodeJS.ProcessEnv = process.env): RuntimeReport { const g = globalThis as Record<string, unknown>; const iteratorCtor = g.Iterator as { concat?: unknown } | undefined; return { nodeVersion: process.version, ltsName: process.release.lts ?? null, temporal: typeof g.Temporal === "object" && g.Temporal !== null, mapGetOrInsert: typeof (Map.prototype as { getOrInsert?: unknown }).getOrInsert === "function", iteratorConcat: typeof iteratorCtor?.concat === "function", uint8ArrayBase64: typeof (Uint8Array as { fromBase64?: unknown }).fromBase64 === "function", requireModule: process.features.require_module === true, // In Lambda, AWS_EXECUTION_ENV looks like this, for example: AWS_Lambda_nodejs24.x lambdaRuntime: env.AWS_EXECUTION_ENV?.replace("AWS_Lambda_", "") ?? null, }; } Step 4: load Temporal with a fallback // src/temporal.ts // Uses the built-in Temporal if the runtime provides it, otherwise the polyfill. export type TemporalApi = typeof Temporal; export async function loadTemporal(): Promise<{ api: TemporalApi; source: "runtime" | "polyfill" }> { const builtIn = (globalThis as { Temporal?: TemporalApi }).Temporal; if (builtIn) { return { api: builtIn, source: "runtime" }; } const polyfill = await import("@js-temporal/polyfill"); return { api: polyfill.Temporal as unknown as TemporalApi, source: "polyfill" }; } The dynamic import ensures that the polyfill is only loaded when it is needed. esbuild still bundles it, which makes the package about 150 KB larger. Once Temporal is reliably available in the stable runtime, you can remove the fallback. Step 5: the rental logic // src/rental.ts // Fictional bike rental: when does a bike have to be back? import type { TemporalApi } from "./temporal.js"; export interface RentalRequest { bikeId: string; pickupAt: string; // pickup time as an instant, e.g. "2026-10-24T08:00:00Z" days: number; // number of rental days timeZone: string; // time zone of the rental station, e.g. "Europe/Berlin" } export function validateRental(input: Partial<RentalRequest>): RentalRequest { const { bikeId, pickupAt, days, timeZone } = input; if (!bikeId || !pickupAt || !timeZone) { throw new Error("bikeId, pickupAt and timeZone are required"); } if (typeof days !== "number" || !Number.isInteger(days) || days < 1 || days > 30) { throw new Error("days must be an integer between 1 and 30"); } return { bikeId, pickupAt, days, timeZone }; } // With Temporal, "2 days" means the same wall-clock time two calendar days later, even across a DST change. export function returnDeadline(T: TemporalApi, req: RentalRequest): string { return T.Instant.from(req.pickupAt) .toZonedDateTimeISO(req.timeZone) .add({ days: req.days }) .toString({ smallestUnit: "minute" }); } // The classic Date approach just adds milliseconds. Across a DST change it is off by one hour. export function naiveReturnDeadline(req: RentalRequest): string { const start = new Date(req.pickupAt).getTime(); return new Date(start + req.days * 24 * 60 * 60 * 1000).toISOString(); } Step 6: the Lambda handler // src/handler.ts // Lambda handler: returns the runtime report and the return deadline for a rental. import { collectRuntimeReport } from "./runtime-report.js"; import { loadTemporal } from "./temporal.js"; import { naiveReturnDeadline, returnDeadline, validateRental, type RentalRequest } from "./rental.js"; const DEMO_RENTAL: RentalRequest = { bikeId: "city-bike-17", pickupAt: "2026-10-24T08:00:00Z", days: 2, timeZone: "Europe/Berlin", }; // Deliberately async without a callback: since nodejs24.x, Lambda no longer supports callback handlers. export const handler = async (event: Partial<RentalRequest> = {}) => { const rental = validateRental({ ...DEMO_RENTAL, ...event }); const { api, source } = await loadTemporal(); return { runtime: collectRuntimeReport(), temporalSource: source, rental: { bikeId: rental.bikeId, returnAt: returnDeadline(api, rental), naiveReturnAtUtc: naiveReturnDeadline(rental), }, }; }; Step 7: tests with the Node test runner The tests cover the interesting case: a rental across the DST change on October 25, 2026. This shows in black and white why Temporal is worth the switch. // test/rental.test.ts import { test } from "node:test"; import assert from "node:assert/strict"; import { loadTemporal } from "../src/temporal.js"; import { naiveReturnDeadline, returnDeadline, validateRental } from "../src/rental.js"; const overDstChange = validateRental({ bikeId: "city-bike-17", pickupAt: "2026-10-24T08:00:00Z", // 10:00 in Berlin (summer time) days: 2, timeZone: "Europe/Berlin", }); test("Temporal keeps the wall-clock time across the DST change", async () => { const { api } = await loadTemporal(); assert.equal(returnDeadline(api, overDstChange), "2026-10-26T10:00+01:00[Europe/Berlin]"); }); test("Date arithmetic ends up one hour early after the DST change", () => { // 08:00 UTC on Oct 26 is 09:00 winter time in Berlin, not 10:00 assert.equal(naiveReturnDeadline(overDstChange), "2026-10-26T08:00:00.000Z"); }); test("without a DST change both approaches agree", async () => { const { api } = await loadTemporal(); const normalWeek = { ...overDstChange, pickupAt: "2026-11-02T09:00:00Z" }; assert.equal(returnDeadline(api, normalWeek), "2026-11-04T10:00+01:00[Europe/Berlin]"); assert.equal(naiveReturnDeadline(normalWeek), "2026-11-04T09:00:00.000Z"); }); test("invalid rental duration is rejected", () => { assert.throws(() => validateRental({ ...overDstChange, days: 0 }), /days must/); assert.throws(() => validateRental({ ...overDstChange, days: 1.5 }), /days must/); }); // test/runtime-report.test.ts import { test } from "node:test"; import assert from "node:assert/strict"; import { collectRuntimeReport } from "../src/runtime-report.js"; import { handler } from "../src/handler.js"; test("report contains the Node version and detects the Lambda runtime", () => { const report = collectRuntimeReport({ AWS_EXECUTION_ENV: "AWS_Lambda_nodejs26.x" }); assert.match(report.nodeVersion, /^v\d+\./); assert.equal(report.lambdaRuntime, "nodejs26.x"); }); test("outside of Lambda, lambdaRuntime is null", () => { assert.equal(collectRuntimeReport({}).lambdaRuntime, null); }); test("handler returns report and return deadline", async () => { const result = await handler({ days: 3 }); assert.equal(result.rental.bikeId, "city-bike-17"); assert.equal(result.rental.returnAt, "2026-10-27T10:00+01:00[Europe/Berlin]"); assert.ok(["runtime", "polyfill"].includes(result.temporalSource)); }); Step 8: the CDK stack with two runtimes The installed version of aws-cdk-lib (2.275.0) does not ship nodejs26.x as a ready-made constant yet. That is not a problem, because new Runtime(...) lets you define any runtime yourself. The stack creates the same function twice. If you have not used AWS CDK with TypeScript before, my introduction Lambda function with CDK and TypeScript will help. // lib/runtime-check-stack.ts import * as path from "node:path"; import { CfnOutput, Duration, RemovalPolicy, Stack, type StackProps } from "aws-cdk-lib"; import { Architecture, Runtime, RuntimeFamily } from "aws-cdk-lib/aws-lambda"; import { NodejsFunction, OutputFormat } from "aws-cdk-lib/aws-lambda-nodejs"; import { LogGroup, RetentionDays } from "aws-cdk-lib/aws-logs"; import type { Construct } from "constructs"; // aws-cdk-lib does not ship a constant for nodejs26.x yet, so we define it ourselves. const NODEJS_26_PREVIEW = new Runtime("nodejs26.x", RuntimeFamily.NODEJS, { supportsInlineCode: true }); export class RuntimeCheckStack extends Stack { constructor(scope: Construct, id: string, props?: StackProps) { super(scope, id, props); const variants = [ { name: "Node24", runtime: Runtime.NODEJS_24_X, target: "node24" }, { name: "Node26", runtime: NODEJS_26_PREVIEW, target: "node26" }, ]; for (const variant of variants) { const logGroup = new LogGroup(this, `${variant.name}Logs`, { retention: RetentionDays.ONE_WEEK, removalPolicy: RemovalPolicy.DESTROY, }); const fn = new NodejsFunction(this, `RentalCheck${variant.name}`, { entry: path.join(import.meta.dirname, "../src/handler.ts"), handler: "handler", runtime: variant.runtime, architecture: Architecture.ARM_64, memorySize: 256, timeout: Duration.seconds(10), logGroup, bundling: { format: OutputFormat.ESM, target: variant.target, minify: true, sourceMap: true, // esbuild needs a require function in the ESM bundle for CommonJS dependencies banner: "import { createRequire } from 'module'; const require = createRequire(import.meta.url);", }, environment: { NODE_OPTIONS: "--enable-source-maps", }, }); new CfnOutput(this, `${variant.name}FunctionName`, { value: fn.functionName }); } } } // bin/app.ts import { App } from "aws-cdk-lib"; import { RuntimeCheckStack } from "../lib/runtime-check-stack.js"; const app = new App(); new RuntimeCheckStack(app, "BikeRentalRuntimeCheck", { env: { region: process.env.CDK_DEFAULT_REGION ?? "eu-central-1" }, }); // cdk.json { "app": "npx tsx bin/app.ts" } Step 9: check, deploy and compare npm run typecheck npm test npx cdk synth npx cdk deploy After the deployment, CDK prints both function names. Call both with the same request and compare the responses: aws lambda invoke --function-name <Node24FunctionName> \ --cli-binary-format raw-in-base64-out --payload '{"days":2}' node24.json aws lambda invoke --function-name <Node26FunctionName> \ --cli-binary-format raw-in-base64-out --payload '{"days":2}' node26.json diff <(jq .runtime node24.json) <(jq .runtime node26.json) This is the response when I call the bundled package locally under Node.js 22. Under nodejs26.x you expect a 26.x version, and exactly this report tells you which features are true there: { "runtime": { "nodeVersion": "v22.23.4", "ltsName": "Jod", "temporal": false, "mapGetOrInsert": false, "iteratorConcat": false, "uint8ArrayBase64": false, "requireModule": true, "lambdaRuntime": "nodejs24.x" }, "temporalSource": "polyfill", "rental": { "bikeId": "city-bike-17", "returnAt": "2026-10-26T10:00+01:00[Europe/Berlin]", "naiveReturnAtUtc": "2026-10-26T08:00:00.000Z" } } The rental field is what matters: the Temporal calculation returns 10 a.m., while the naive Date calculation lands at 9 a.m. local time. On both runtimes, returnAt has to be identical, no matter whether Temporal is built in or the polyfill does the math. Common mistakes - Runtime rejected: if your region does not offer the preview yet, the deployment fails. For the test, switch to a region where nodejs26.x is selectable in the console. - “Dynamic require … is not supported”: this message appears when an ESM bundle contains a CommonJS dependency. The banner with createRequire in the stack prevents it. - Callback handlers: old code with (event, context, callback) no longer works from nodejs24.x. Switch to async first. - Green locally, different in Lambda: your tests may run with the polyfill locally and with built-in Temporal in Lambda, or the other way round. That is exactly why the report checks every feature in the real runtime. Cost and security Two small 256 MB functions that you call a few times cost practically nothing and usually stay within the free tier. With the Lambda cost calculator you can do the math for your real load profiles. Logs are deleted after one week, and npx cdk destroy removes everything again. Do not process real customer data in the preview runtime, since there is neither an SLA nor support for it. Upgrade checklist for your team Find the Node runtimes in your account Before you plan, you need a list. This command shows every function with a Node runtime per region: for region in eu-central-1 eu-west-1; do aws lambda list-functions --region "$region" \ --query "Functions[?starts_with(Runtime, 'nodejs')].[FunctionName, Runtime]" \ --output text | sed "s/^/$region\t/" done Update TypeScript, bundler and CI Bump @types/node to version 26, set the matching target in esbuild or on NodejsFunction, and add a Node.js 26 entry to your CI test matrix. Rebuild native addons. And if you use pnpm or Yarn via Corepack, install Corepack explicitly in the pipeline, since it is no longer bundled. Roll out gradually Once nodejs26.x is generally available, do not switch all functions at once. I would start with non-critical functions and use an alias with weighted routing to send only part of the traffic to the new version. New runtimes often have somewhat longer cold starts at launch, so keep an eye on error rate and init duration. How to build alarms and dashboards for that is covered in Monitoring Lambda with CDK. My take Testing: yes, now. Production on the preview: no. That is my position in a nutshell. The preview is a gift, because you find the surprises while nobody is rushing you yet. If you still run on nodejs22.x today, though, I would not wait for November. The move to 24 brings the biggest Lambda-specific breaks, namely callback handlers and promises that are not awaited. Those affect 26 just the same. Once that is done, the step from 24 to Node.js 26 LTS is small in most projects. Temporal is what I am looking forward to most. I would rather not count how many time zone and daylight saving bugs I have seen in my career. At the same time, the preview shows that Lambda runtimes are deliberately more conservative than upstream Node.js. For stability, I think that is right, but it can catch you off guard. So my advice: check features in the runtime where your code will actually run. And while you are at it, take a look at your patch routine, because security updates like the one for the Node.js vulnerability CVE-2026-111497 arrive regularly for LTS versions too. By the way, I like the new annual cadence starting with Node.js 27. A team can schedule one LTS upgrade per year instead of debating every time which version to skip. FAQ When will Node.js 26 be generally available on AWS Lambda? AWS names November 2026 as the planned launch for nodejs26.x, explicitly as a non-binding estimate. Until then, the runtime is a public preview without SLA or support. How long is Node.js 26 LTS supported? According to the Node.js project, the 26.x line receives updates until the end of April 2029. When AWS will deprecate the Lambda runtime has not been announced yet. Can I already use Temporal in Lambda? Yes, via a polyfill such as @js-temporal/polyfill on any Node runtime. Whether Temporal is built in and enabled in nodejs26.x is best checked with feature detection as in the example. Do I have to upgrade from Node.js 22 to 24 first? No, you can go straight to 26. But then you have to handle all changes from 24 and 26 at once. Since nodejs22.x is deprecated in April 2027, going through 24 now is often the more relaxed path. Your next step Run the command from the checklist today and count how many functions still run on nodejs22.x. Then deploy the probe function to a sandbox account and check in the report what Node.js 26 LTS really brings in nodejs26.x. I would like to know: which Node runtime are your Lambdas on right now, and what slows you down most when upgrading? Let me know in the comments. Sources - Node.js blog: Node.js 26.13.0 (LTS), Oct 28, 2026 - Node.js blog: Node.js 26.0.0 (Current), May 5, 2026 - Node.js blog: Node.js 25.0.0 (Current), Oct 15, 2025 - Node.js: releases and release schedule - AWS Lambda Developer Guide: Lambda runtimes - GitHub: feedback issue for the Node.js 26 preview runtime - AWS Compute Blog: Node.js 24 runtime now available in AWS Lambda - Tested with Node.js 22.23, TypeScript 7.0.2 (tsc --noEmit), aws-cdk-lib 2.275.0 (cdk synth), @js-temporal/polyfill 0.5.1 and the Node test runner (7 tests). A deployment to AWS and a run under Node.js 26 were not possible in my test environment. 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
Monitoring durable functions: telemetry in TypeScript Monitoring durable functions used to feel like a puzzle. A workflow that waits two days for a book to be shipped is spread across several Lambda invocations, and every invocation writes its own log lines. Answering “Which executions got stuck in the order-copy step yesterday?” took real effort. Since October 28, 2026, there is the Durable Execution Telemetry Exporter for exactly this. It turns every execution into a single record and sends it to a destination of your choice, for example CloudWatch Logs or DynamoDB. I took a closer look because it fills the gap that bothered me most after writing my article about Lambda durable functions. In this article you build a small interlibrary loan workflow for a city library step by step, redact personal data, deploy everything with the AWS CDK and finally query the data in plain English through an MCP server. > In short > > The telemetry exporter hooks into the Durable Execution SDK as a plugin. For each execution it builds a complete JSON record with status, steps, duration and errors and sends it to up to 13 destinations. The plugin itself costs nothing extra; you only pay for the destination. The biggest pitfall: executions that Lambda stops or times out on its own do not show up unless you add them via EventBridge. What you will learn in this article - How the telemetry exporter builds records and when it sends them - Redacting input and output before they leave the function - Testing the exporter locally, without an AWS account, using your own in-memory exporter - Backfilling STOPPED and TIMED_OUT with an EventBridge rule - Querying telemetry with Claude Code and the MCP server Why monitoring durable functions is harder A durable function is a Lambda function that saves its progress in checkpoints. A checkpoint is a stored intermediate result: once a step has finished, Lambda does not run it again on the next invocation but reads the result from storage instead. When the workflow waits, Lambda ends the invocation and starts a new one later. This resuming is called replay. For business logic that is great. For monitoring, however, it means that one business execution consists of many technical invocations. Classic Lambda metrics such as duration or error rate describe the single invocation, not the workflow. In addition, Lambda keeps the execution history only for the configured retention period, at most 90 days. So if you want to ask later how long loans take on average, you need the data in a place of your own. That is exactly what the exporter takes care of. It is therefore worth looking at how it works before we write code. What the telemetry exporter does The exporter is a plugin that runs in the same process as your handler. It hooks into the SDK lifecycle but does not sit in the request path. On every invocation it builds a cumulative snapshot of the entire execution from the checkpoints, so it is not a delta. A later record therefore always replaces an earlier one. AWS offers the plugin for TypeScript, Python, Java and Go. According to the announcement, it comes at no additional charge and is available in all Regions that support durable functions. Costs only arise at the destination, such as log ingestion or DynamoDB writes. One record instead of a hundred log lines Here is a record from our example (shortened). All fields come from the package’s DurableExecutionTelemetryRecord type: { "recordType": "DurableExecutionTelemetry", "schemaVersion": "1.0", "executionArn": "arn:aws:lambda:eu-central-1:111122223333:function:LoanWorkflow:live/durable-execution/…", "functionName": "LoanWorkflow", "functionQualifier": "live", "status": "SUCCEEDED", "startTime": "2026-10-29T08:00:00.000Z", "endTime": "2026-10-31T08:00:02.410Z", "input": { "requestId": "FL-1001", "isbn": "9783161484100" }, "output": { "status": "READY_FOR_PICKUP", "partnerLibrary": "North City Library" }, "operations": [ { "name": "find-partner-library", "type": "STEP", "status": "SUCCEEDED", "durationMs": 41, "result": "North City Library" }, { "name": "order-copy", "type": "STEP", "status": "SUCCEEDED", "durationMs": 63 }, { "name": "shipping", "type": "WAIT", "status": "SUCCEEDED" }, { "name": "notify-reader", "type": "STEP", "status": "SUCCEEDED", "durationMs": 88 } ] } The operations field is the interesting part. Every step and every wait appears there with status and duration. Results of individual steps are left out by default unless you explicitly allow them. That is a good default, because step results often contain exactly the data that does not belong in monitoring. When a record is created With emitMode you decide how often the exporter sends. Your choice directly affects cost and freshness: | emitMode | When it sends | Good for | | --- | --- | --- | | on-complete (default) | once on SUCCEEDED or FAILED | analysis, dashboards | | on-failure | only on FAILED | error analysis, lowest overhead | | on-change | on every status change, including RUNNING | live progress, highest load | On top of that there is samplingRate between 0 and 1. The decision is based on a hash of the execution ARN. An execution is therefore either recorded completely or not at all, never halfway. Each of the 13 destinations has its own class, from LambdaLogExporter (the function’s own log, no extra IAM permissions) through S3Exporter for Athena to OTelExporter for any OpenTelemetry backend. You can combine several destinations, and the exporter serves them in parallel. !Diagram: durable function with telemetry plugin sends to CloudWatch Logs and DynamoDB, EventBridge adds stops, MCP server reads The path of a telemetry record: from the plugin inside the function to querying it via MCP. Step by step: monitoring durable functions for an interlibrary loan Our example: a reader orders a book that her own library does not have. The workflow looks for a partner library, orders a copy there, waits for shipping and notifies the reader. The workflow needs her name and email address, but they have no business in monitoring. Prerequisites - Node.js 22 or later (the exporter requires at least 22) - AWS CLI v2 with credentials for a test account - A basic understanding of durable functions, for example from my introductory article - About 30 minutes Step 1: Create the project Create the project and install the SDK, the exporter and the CDK. You only need the testing package locally: mkdir library-loan && cd library-loan npm init -y npm pkg set type=module npm install @aws/durable-execution-sdk-js @aws/durable-execution-sdk-js-telemetry-exporter \ @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb aws-cdk-lib constructs npm install -D typescript @types/node @types/aws-lambda tsx esbuild aws-cdk \ @aws/durable-execution-sdk-js-testing mkdir src lib bin test For tests to run outside Lambda, you also need the optional peer dependencies. At startup the exporter loads every AWS client, including the ones you do not use. In Lambda the Node.js runtime provides them; locally it does not: npm install -D @aws-sdk/client-s3 @aws-sdk/util-dynamodb @aws-sdk/client-rds-data \ @aws-sdk/client-cloudwatch-logs @aws-sdk/client-firehose @aws-sdk/client-eventbridge \ @aws-sdk/client-redshift-data @aws-sdk/client-sqs @aws-sdk/credential-provider-node \ "@smithy/signature-v4@^4" @aws-crypto/sha256-js The tsconfig.json is deliberately strict. The cdk.json starts the app with tsx: { "compilerOptions": { "target": "ES2022", "module": "nodenext", "moduleResolution": "nodenext", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "noEmit": true, "types": ["node"] }, "include": ["src", "lib", "bin", "test"] } { "app": "npx tsx bin/app.ts" } Step 2: Configure telemetry and redact data The configuration lives in its own file. Two small functions decide what is left of input and output. I also allow the result of a single step, because it only contains the partner library’s name and helps with troubleshooting: // src/telemetry.ts import { DynamoDBExporter, LambdaLogExporter, durableExecutionTelemetry, type TelemetryExporter, } from "@aws/durable-execution-sdk-js-telemetry-exporter"; export interface LoanRequest { requestId: string; isbn: string; readerName: string; readerEmail: string; } export interface LoanResult { status: "READY_FOR_PICKUP" | "NOT_AVAILABLE"; pickupCode?: string; partnerLibrary?: string; } // Only fields needed for troubleshooting – no personal data. export function redactInput(input: unknown): { requestId?: string; isbn?: string } { const req = (input ?? {}) as Partial<LoanRequest>; return { requestId: req.requestId, isbn: req.isbn }; } // Nobody needs the pickup code in monitoring. export function redactOutput(output: unknown): { status?: string; partnerLibrary?: string } { const res = (output ?? {}) as Partial<LoanResult>; return { status: res.status, partnerLibrary: res.partnerLibrary }; } export function parseSamplingRate(raw: string | undefined): number { const value = Number(raw ?? "1"); if (Number.isNaN(value)) return 1; return Math.min(1, Math.max(0, value)); } export function defaultExporters(): TelemetryExporter[] { const exporters: TelemetryExporter[] = [new LambdaLogExporter()]; const tableName = process.env.TELEMETRY_TABLE; if (tableName) { exporters.push(new DynamoDBExporter({ tableName })); } return exporters; } export function createTelemetry(exporters: TelemetryExporter[]) { return durableExecutionTelemetry({ emitMode: "on-complete", samplingRate: parseSamplingRate(process.env.TELEMETRY_SAMPLING_RATE), exporters, content: { input: redactInput, output: redactOutput, operations: { includeErrors: true, overrides: [ // Deliberately export this step's result (it only contains the library name) { operationName: "find-partner-library", result: (r) => r }, ], }, }, }); } Two details matter. First, you create the plugin once at module scope, not inside the handler. Second, the sampling rate does not throw on nonsense like “7” but gets clamped by my helper. The exporter itself would also correct invalid values and log a warning. Step 3: Write the workflow The workflow consists of three steps and one wait. The business logic is intentionally just a placeholder. To make it testable, the file also exports a factory function that accepts any exporters: // src/loan-workflow.ts import { type DurableContext, withDurableExecution, } from "@aws/durable-execution-sdk-js"; import type { TelemetryExporter } from "@aws/durable-execution-sdk-js-telemetry-exporter"; import { createTelemetry, defaultExporters, type LoanRequest, type LoanResult, } from "./telemetry.js"; // Placeholders for real business logic (catalog, partner library, email) async function findPartnerLibrary(isbn: string): Promise<string | null> { return isbn.startsWith("978") ? "North City Library" : null; } async function orderCopy(library: string, requestId: string): Promise<string> { return `${library.slice(0, 3).toUpperCase()}-${requestId}`; } async function notifyReader(email: string, pickupCode: string): Promise<void> { // This is where the email with the pickup code would be sent – the log only shows the masked address. console.log(JSON.stringify({ msg: "reader notified", to: email.replace(/.+@/, "***@"), codeLength: pickupCode.length })); } async function loanWorkflow( event: LoanRequest, context: DurableContext, ): Promise<LoanResult> { const library = await context.step("find-partner-library", async () => findPartnerLibrary(event.isbn), ); if (!library) { return { status: "NOT_AVAILABLE" }; } const pickupCode = await context.step("order-copy", async () => orderCopy(library, event.requestId), ); // Shipping between libraries: the function sleeps without using compute time. await context.wait("shipping", { seconds: Number(process.env.SHIPPING_SECONDS ?? "172800") }); await context.step("notify-reader", async () => notifyReader(event.readerEmail, pickupCode), ); return { status: "READY_FOR_PICKUP", pickupCode, partnerLibrary: library }; } // Factory so tests can plug in their own exporters export function createHandler(exporters: TelemetryExporter[]) { return withDurableExecution(loanWorkflow, { plugins: [createTelemetry(exporters)], }); } export const handler = createHandler(defaultExporters()); Pay attention to the operation names. They end up unchanged as keys in the records. In CloudWatch Logs Insights and OpenSearch a dot in a name works as a path separator, which is why I only use lowercase letters and hyphens. Step 4: Test locally, without an AWS account The testing package simulates the checkpoint backend in-process. Instead of a real destination, I plug in an exporter that just collects records in an array. The TelemetryExporter interface needs exactly one method for that: // test/telemetry.test.ts import { after, before, test } from "node:test"; import assert from "node:assert/strict"; import { LocalDurableTestRunner } from "@aws/durable-execution-sdk-js-testing"; import type { DurableExecutionTelemetryRecord, TelemetryExporter, } from "@aws/durable-execution-sdk-js-telemetry-exporter"; import { parseSamplingRate } from "../src/telemetry.js"; process.env.SHIPPING_SECONDS = "1"; const { createHandler } = await import("../src/loan-workflow.js"); // Collects all records in memory instead of sending them class MemoryExporter implements TelemetryExporter { records: DurableExecutionTelemetryRecord[] = []; async export(record: DurableExecutionTelemetryRecord): Promise<void> { this.records.push(record); } } before(() => LocalDurableTestRunner.setupTestEnvironment({ skipTime: false })); after(() => LocalDurableTestRunner.teardownTestEnvironment()); test("exports one redacted record per completed execution", async () => { const memory = new MemoryExporter(); const runner = new LocalDurableTestRunner({ handlerFunction: createHandler([memory]) }); const execution = await runner.run({ payload: { requestId: "FL-1001", isbn: "9783161484100", readerName: "Jane Doe", readerEmail: "jane@example.org", }, }); assert.equal(execution.getStatus(), "SUCCEEDED"); assert.equal(memory.records.length, 1); const record = memory.records[0]; assert.equal(record.status, "SUCCEEDED"); assert.deepEqual(record.input, { requestId: "FL-1001", isbn: "9783161484100" }); assert.deepEqual(record.output, { status: "READY_FOR_PICKUP", partnerLibrary: "North City Library" }); const json = JSON.stringify(record); assert.ok(!json.includes("jane@example.org"), "email must not be exported"); assert.ok(!json.includes("NOR-FL-1001"), "pickup code must not be exported"); const names = record.operations.map((op) => op.name); assert.deepEqual(names, ["find-partner-library", "order-copy", "shipping", "notify-reader"]); const partner = record.operations.find((op) => op.name === "find-partner-library"); assert.equal(partner?.result, "North City Library"); }); test("parseSamplingRate clamps invalid values", () => { assert.equal(parseSamplingRate(undefined), 1); assert.equal(parseSamplingRate("0.1"), 0.1); assert.equal(parseSamplingRate("7"), 1); assert.equal(parseSamplingRate("abc"), 1); }); Add the script "test": "tsx --test test/*.test.ts" to your package.json and run the tests. The wait is set to one second in the test: npx tsc --noEmit npm test Expected result: {"msg":"reader notified","to":"***@example.org","codeLength":11} ✔ exports one redacted record per completed execution ✔ parseSamplingRate clamps invalid values ℹ tests 2 ℹ pass 2 ℹ fail 0 The first test is the real win. It makes sure that neither the email address nor the pickup code ever lands in monitoring. If someone changes the redaction later, the test fails before any data reaches the cloud. Step 5: Deploy with the AWS CDK The stack contains the telemetry table, the workflow with durableConfig, an alias and the function that will close the gap later. For functions with durableConfig, the CDK automatically attaches the managed policy AWSLambdaBasicDurableExecutionRolePolicy; you grant write access to the table yourself: // lib/loan-stack.ts import * as path from "node:path"; import { fileURLToPath } from "node:url"; import { CfnOutput, Duration, RemovalPolicy, Stack, type StackProps } from "aws-cdk-lib"; import * as dynamodb from "aws-cdk-lib/aws-dynamodb"; import * as events from "aws-cdk-lib/aws-events"; import * as targets from "aws-cdk-lib/aws-events-targets"; import * as lambda from "aws-cdk-lib/aws-lambda"; import { NodejsFunction } from "aws-cdk-lib/aws-lambda-nodejs"; import * as logs from "aws-cdk-lib/aws-logs"; import type { Construct } from "constructs"; const here = path.dirname(fileURLToPath(import.meta.url)); export class LoanStack extends Stack { constructor(scope: Construct, id: string, props?: StackProps) { super(scope, id, props); // Target for the DynamoDBExporter: pk = execution ARN, sk = timestamp const telemetryTable = new dynamodb.TableV2(this, "TelemetryTable", { partitionKey: { name: "pk", type: dynamodb.AttributeType.STRING }, sortKey: { name: "sk", type: dynamodb.AttributeType.STRING }, billing: dynamodb.Billing.onDemand(), removalPolicy: RemovalPolicy.DESTROY, // demo only }); const workflow = new NodejsFunction(this, "LoanWorkflow", { entry: path.join(here, "../src/loan-workflow.ts"), handler: "handler", runtime: lambda.Runtime.NODEJS_22_X, architecture: lambda.Architecture.ARM_64, timeout: Duration.minutes(1), memorySize: 512, durableConfig: { executionTimeout: Duration.days(7), retentionPeriod: Duration.days(14), }, environment: { TELEMETRY_TABLE: telemetryTable.tableName, TELEMETRY_SAMPLING_RATE: "1", SHIPPING_SECONDS: "60", }, logGroup: new logs.LogGroup(this, "LoanWorkflowLogs", { retention: logs.RetentionDays.ONE_MONTH, removalPolicy: RemovalPolicy.DESTROY, }), }); telemetryTable.grantWriteData(workflow); // Durable functions need a qualified ARN (version or alias) const live = new lambda.Alias(this, "LiveAlias", { aliasName: "live", version: workflow.currentVersion, }); // Close the gap: only Lambda itself reports STOPPED and TIMED_OUT const closeGaps = new NodejsFunction(this, "CloseGaps", { entry: path.join(here, "../src/close-gaps.ts"), handler: "handler", runtime: lambda.Runtime.NODEJS_22_X, architecture: lambda.Architecture.ARM_64, environment: { TELEMETRY_TABLE: telemetryTable.tableName }, }); telemetryTable.grantWriteData(closeGaps); new events.Rule(this, "StoppedOrTimedOut", { eventPattern: { source: ["aws.lambda"], detailType: ["Durable Execution Status Change"], detail: { status: ["STOPPED", "TIMED_OUT"] }, }, targets: [new targets.LambdaFunction(closeGaps)], }); new CfnOutput(this, "WorkflowAliasArn", { value: live.functionArn }); new CfnOutput(this, "TelemetryTableName", { value: telemetryTable.tableName }); } } // bin/app.ts import { App } from "aws-cdk-lib"; import { LoanStack } from "../lib/loan-stack.js"; const app = new App(); new LoanStack(app, "LibraryLoanStack"); When bundling, esbuild leaves out the @aws-sdk/* packages because the runtime ships them. Check, bootstrap and deploy: npx tsc --noEmit npx cdk bootstrap # only once per account and region npx cdk deploy After the deployment the console shows two outputs: WorkflowAliasArn and TelemetryTableName. You will need both in a moment. Step 6: Run it and look at the records You always invoke durable functions through a qualified ARN, meaning a version or an alias. For long workflows, use the asynchronous invocation: aws lambda invoke \ --function-name "<WorkflowAliasArn from the output>" \ --invocation-type Event \ --cli-binary-format raw-in-base64-out \ --payload '{"requestId":"FL-1001","isbn":"9783161484100","readerName":"Jane Doe","readerEmail":"jane@example.org"}' \ response.json In the stack the shipping time is set to 60 seconds. After a little over a minute the record shows up in the function’s log. In CloudWatch Logs Insights this query finds all telemetry records: fields executionName, status, durationMs, input.requestId | filter recordType = "DurableExecutionTelemetry" | sort @timestamp desc | limit 20 At the same time, the same record sits in DynamoDB, with the execution ARN as pk and the emit time as sk: aws dynamodb scan \ --table-name "<TelemetryTableName from the output>" \ --projection-expression "pk, sk, #s" \ --expression-attribute-names '{"#s":"status"}' \ --max-items 5 > 🎬 Interlibrary loan flow with telemetry – this animated diagram only runs on my site: watch it on sascha-ms.de Closing the gap for STOPPED and TIMED_OUT One point is somewhat hidden in the AWS blog post: the plugin only sends when the function is invoked. If you stop an execution through the API or the execution timeout expires, Lambda sets the final status itself without calling your code. Telemetry then keeps the last state, which with on-complete means nothing at all. Lambda reports every status change as an event on the EventBridge default bus, with the source aws.lambda. The EventBridge rule in the stack filters for the two critical statuses and calls a small function that writes a substitute record into the same table: // src/close-gaps.ts import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; import { DynamoDBDocumentClient, PutCommand } from "@aws-sdk/lib-dynamodb"; import type { EventBridgeEvent } from "aws-lambda"; interface StatusChangeDetail { durableExecutionArn: string; durableExecutionName?: string; functionArn: string; status: string; startTimestamp: string; endTimestamp?: string; } const db = DynamoDBDocumentClient.from(new DynamoDBClient({})); // Writes STOPPED and TIMED_OUT into the same table as the exporter. export const handler = async ( event: EventBridgeEvent<string, StatusChangeDetail>, ): Promise<void> => { const d = event.detail; await db.send( new PutCommand({ TableName: process.env.TELEMETRY_TABLE, Item: { pk: d.durableExecutionArn, sk: d.endTimestamp ?? event.time, recordType: "DurableExecutionTelemetry", source: "eventbridge-gap-filler", executionArn: d.durableExecutionArn, executionName: d.durableExecutionName, functionArn: d.functionArn, status: d.status, startTime: d.startTimestamp, endTime: d.endTimestamp, }, }), ); }; A detail you notice while reading: the Lambda documentation calls the event type Durable Execution Status Change, while the announcement blog post uses a different spelling. I went with the Lambda documentation. After deploying, trigger a timeout once and check that the rule fires. Also check that the ARN in the event matches the telemetry’s executionArn, so both entries end up under the same pk. Monitoring durable functions with AI: the MCP server Together with the exporter, AWS released an MCP server. MCP (Model Context Protocol) is an open standard that lets AI assistants call tools. The server runs locally via npx, reads its configuration only from environment variables and uses the regular AWS credential chain. This is how you add it to Claude Code: claude mcp add durable-telemetry \ --scope project \ --env DURABLE_TELEMETRY_DESTINATION_TYPE=dynamodb \ --env DURABLE_TELEMETRY_DYNAMODB_TABLE_NAME=<TelemetryTableName> \ --env DURABLE_TELEMETRY_REGION=eu-central-1 \ -- npx -y @aws/durable-execution-sdk-js-telemetry-mcp After that, five tools are available: test_destination checks the connection, describe_schema returns the schema, list_executions and get_execution filter without a query language, and query handles everything else. The assistant can then answer questions like “Which loans failed this week, and in which step?” straight from the table. The server takes security seriously. SQL destinations accept only SELECT queries, and before the first query it asks for confirmation, because it cannot prove that your credentials are read-only. You should make that true by giving the server its own role with read permissions only. For DynamoDB, this policy is enough: { "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": [ "dynamodb:DescribeTable", "dynamodb:GetItem", "dynamodb:BatchGetItem", "dynamodb:Query", "dynamodb:Scan", "dynamodb:PartiQLSelect" ], "Resource": "arn:aws:dynamodb:eu-central-1:111122223333:table/<TelemetryTableName>" } ] } If you want to build an MCP server yourself, you will find the basics in my guide to MCP servers with TypeScript. Costs and data protection The plugin is free; the destinations are not. With on-complete you write one record per execution, with on-change considerably more. As a rough estimate, multiply the number of executions by the number of status changes. With many executions, a lower sampling rate and on-failure for less important workflows help. On data protection my recommendation is clear: redact too much rather than too little. By default the exporter sends input and output in full. That is convenient, but the wrong starting point for workflows with customer data. Keep in mind, too, that your telemetry destination now holds a copy of the data. The DynamoDB table therefore needs a retention rule, for example a cleanup job or a custom exporter that sets a TTL attribute. I show how to add alarms and budgets in Lambda in Monitoring Lambda with CDK. Common mistakes - ERR_MODULE_NOT_FOUND for @aws-sdk/client-s3: The peer dependencies are missing locally. Install the complete list from step 1, even if you do not use S3. - No records for stopped executions: That is by design. No invocation, no plugin, hence the EventBridge rule. - Step results are missing: Results are excluded by default. Allow them per operation via content.operations.overrides. - Encrypted results in the record: Transforms receive the stored, serialized result. If a step uses custom Serdes, you will not see plain text there. - Incomplete tree for map and parallel: After a resume, full-tree only shows child operations with pluginsConfig.childOperationsDepth, which costs extra replays. - Slow invocations under load: Exports run one after another. A slow exporter delays every execution that ends at the same time, not just one. My take I consider the exporter the most important addition since durable functions were released. Until now, the candid answer to “How do I monitor this?” was something like “with patience”. Now there is a well-designed data model, sensible defaults and a clean interface for plugging in your own destinations. I particularly like that sampling decides per execution and never records half a workflow. I see two things critically. The STOPPED and TIMED_OUT gap is documented but easy to miss, and those are exactly the cases you want to see in operations. Second, the default configuration sends all input and output. For an example that is handy; for production I would wish for the opposite. The MCP server, on the other hand, strikes me as a good example of AI helping in operations without doing harm: read-only, with a confirmation prompt and clear limits. Do you still need Step Functions for visibility? For workflows that business teams should follow graphically, the Step Functions console keeps its edge. For teams that prefer writing their flows in TypeScript, visibility is no longer an argument against durable functions. FAQ Does the telemetry exporter for durable functions cost extra? No. According to AWS, the plugin is available at no additional charge. You only pay for the destination, such as CloudWatch Logs ingestion, DynamoDB writes or S3 storage. Which destinations does the exporter support? There are 13 built-in destinations, including CloudWatch Logs, S3, DynamoDB, Aurora, Redshift, OpenSearch, EventBridge, Firehose, SQS, OpenTelemetry, HTTPS endpoints and files. You build your own destinations with the TelemetryExporter interface. Do I need additional IAM permissions? Not for the default LambdaLogExporter, because the function is already allowed to write its own log. For every other destination, the function needs write permissions on that destination. Does the exporter work with Python too? Yes. The plugin exists for TypeScript, Python, Java and Go, each in the repository of the matching Durable Execution SDK. Your next step Take an existing durable function, attach the plugin with the LambdaLogExporter and write the test from step 4 for your redaction first. It takes half an hour, and afterwards you will see your workflows as a whole for the first time. I am curious: which question would you ask your telemetry first? Sources - AWS What’s New: Lambda durable functions now exports live telemetry (Oct 28, 2026) - AWS Compute Blog: Observing AWS Lambda durable functions with the Durable Execution Telemetry Exporter - AWS docs: Durable Execution Telemetry Exporter - AWS docs: Durable Execution Telemetry MCP server - AWS docs: Telemetry overview - AWS docs: Monitoring durable functions (EventBridge events) - AWS docs: Deploy and invoke Lambda durable functions with the AWS CLI - GitHub: aws/aws-durable-execution-sdk-js 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
EKS Hybrid Nodes & Capabilities による BYOI デプロイ管理 はじめに EKS Hybrid Nodes を使うと、EKS の外で自分で用意するマシンを EKS クラスタのノードとして参加させ、AWS 側のコントロールプレーンから管理できます。AWS の文書は、この考え方を bring your own infrastructure と呼んでいます。この記事でも、このように自分で用意するマシンを BYOI (bring your own infrastructure、持ち込みのインフラ) と呼びます。例えばオンプレミスのサーバーを管理下に置けます。EKS には EKS Capabilities という機能もあり、その 1 つとして Argo CD を AWS が運用する形で使えます。この記事では、AWS が運用するこの Argo CD を「Managed Argo CD」と呼びます。Hybrid ノードも EKS クラスタのノードなので、Managed Argo CD から EKS クラスタにデプロイすれば、原理的には Hybrid ノードにも届くはずです。これが実際に問題なく動くのかを、環境を作って確かめました。あわせて、AKS や GKE、自分で建てたクラスタのような、EKS ではない Kubernetes にもデプロイできるかを確かめました。 結論を先に表にします。Managed Argo CD にデプロイ先として登録できるのは EKS クラスタだけで、BYOI には、BYOI が参加した EKS クラスタへのデプロイとして届きます。自分で用意した環境は、マシンを EKS のノードとして参加させる場合と、すでに動いている Kubernetes クラスタにデプロイする場合とで、行が分かれます。 デプロイ先 Managed Argo CD からの直接デプロイ 方法 EKS クラスタ 可 クラスタの ARN (AWS のリソースを一意に指す名前) で登録 BYOI 可 EKS Hybrid Nodes で EKS クラスタのノードとして参加。今回は EC2 で確認 EKS ではない Kubernetes (AKS、GKE、自分で建てたクラスタ) 不可 EKS クラスタに置いた中継用の Argo CD を経由。今回は k3s で確認 この記事は、Argo CD と EKS の基本を知っている読者に向けて、それぞれの方法がなぜ成り立つのかと、確かめた結果を書きます。 Managed Argo CD がデプロイできる相手 まず、Managed Argo CD の部品がどこにあるかを押さえます。 Managed Argo CD の部品は、2 か所に分かれています。 実際にデプロイの仕事をするコントローラーは、利用者の EKS クラスタの中ではなく、AWS が運用する場所で動きます。利用者のノードの上には、コントローラーの Pod はありません。 何をどこにデプロイするかを書いた Application と、デプロイ先を書いた Secret である cluster secret は、今回の構成では、Managed Argo CD を作った EKS クラスタの argocd namespace に、普通の Kubernetes のオブジェクトとして置きます。 利用者は Application を自分のクラスタに kubectl apply するだけです。コントローラーはクラスタの API サーバーを通してそれを読み、Helm chart や Git のリポジトリから中身を取ってきて、デプロイ先の API サーバーにマニフェストを適用します。今回はデプロイ先も同じクラスタなので、同じ API サーバーです。Pod がどのノードに置かれるかは、クラスタのスケジューラが決めます。 次に、デプロイ先をどう指定するかです。 Managed Argo CD がデプロイできるのは EKS クラスタだけで、AWS の比較の文書にもデプロイ先は EKS クラスタの ARN だけと書かれています。理由は、Managed Argo CD がデプロイ先を見つける方法と、デプロイ先に入る方法の両方が EKS の仕組みでできているからです。上の図の上の段がその流れです。 デプロイ先は cluster secret で登録します。Managed Argo CD では、その server に EKS クラスタの ARN を書きます。Managed Argo CD を作った EKS クラスタ自身も、自動ではデプロイ先に登録されないので、同じように ARN で登録します。 AWS 側のコントローラーは、ARN でデプロイ先のクラスタを特定し、Managed Argo CD を作るときに渡した IAM ロールでそのクラスタの API サーバーに入ります。 デプロイ先の EKS クラスタでは、アクセスエントリ (IAM ロールに Kubernetes の権限を結び付ける EKS の設定) が、その IAM ロールに権限を与えます。 権限をもらった Managed Argo CD が、マニフェストを適用します。 図の下の段は、通常の Argo CD と同じように、API サーバーの URL とトークンで登録した場合です。cluster secret はエラーなく作れます。ところが、その cluster secret を使う Application は、condition に次のエラーを出して止まり、相手のクラスタには何も届きませんでした。 Failed to load live state: failed to get cluster info for "10.90.0.48": error getting cluster: error getting cluster RESTConfig: server should be specified as a cluster ARN エラーの文言からは、Managed Argo CD が server に ARN を求め、URL を受け付けずに止まっていると読めます。Managed Argo CD のデプロイ先は EKS クラスタの ARN で指定するので、EKS ではないクラスタはこの流れに乗れません。EKS ではないクラスタにデプロイする方法は、後の節で説明します。 Hybrid Nodes による BYOI の参加 BYOI を EKS クラスタのノードにすれば、Managed Argo CD は、EKS クラスタへのデプロイと同じやり方で、そのマシンにもアプリを届けられます。そのための機能が、はじめにで触れた EKS Hybrid Nodes です。コントロールプレーンは AWS 側にあり、BYOI はノードとして働くだけです。 Managed Argo CD は、AWS 側にある EKS の API サーバーにマニフェストを適用するだけで、BYOI のネットワークに入る必要はありません。Pod をどのノードに置くかは Kubernetes のスケジューラが決めます。Hybrid ノードには既定で eks.amazonaws.com/compute-type: hybrid というラベルが付くので、Pod の nodeSelector にこのラベルを指定すれば、Pod の置き場所を BYOI に限れます。ただし、今回は Hybrid ノードに taint を付けていないので、置き場所を指定しない Pod は BYOI にも置かれることがあります。クラウドで動かしたい Pod には、このラベルを避ける affinity を付けるか、Hybrid ノードに taint を付けます。今回は、後の節で説明する中継用の Argo CD を、この affinity でクラウドのノードに限りました。今回デプロイした podinfo の chart は、Helm の値の nodeSelector を Pod に渡すので、podinfo-onprem という Application の値に次の 2 行を書くだけで済みました。 nodeSelector: eks.amazonaws.com/compute-type: hybrid 次に、BYOI と AWS の間の通信です。 BYOI のマシンは、AWS 側にある EKS のコントロールプレーンと通信できなければ、ノードとして働けません。kubelet が API サーバーに接続してノードとして登録し、API サーバーもログの取得などのために kubelet に接続するからです。そのため、BYOI のネットワークとクラスタの VPC をつなぎ、BYOI のノードと Pod の IP の範囲を EKS に登録して、VPC のルートテーブルと BYOI 側のネットワークに経路を、セキュリティグループに必要なポートの許可を入れます。今回は、BYOI の Pod の IP にも VPC から届く構成にしました。必要なポートと設定の詳細は、AWS の文書にまとまっています。 出典: Amazon EKS ユーザーガイド「ハイブリッドノード用のネットワークを準備する」、CC BY-SA 4.0 クラスタの API エンドポイントは、インターネットに出さず private にしておくのが安全です。今回も API エンドポイントを private だけにし、BYOI との経路もプライベートな接続にした構成で検証し、Managed Argo CD からのデプロイまで動きました。BYOI の置き場所との VPN の代わりには、VPC 同士をプライベートにつなぐ Transit Gateway を使いました。private だけのクラスタでも、Managed Argo CD はデプロイできます。 Hybrid ノードは、Pod のネットワークを作る部品である CNI が動くまで Ready になりません。Hybrid ノードには VPC CNI を使えないので、Cilium などの CNI を自分で入れます。今回は、AWS が ECR Public に置いている Cilium の Helm chart を使いました。対応する CNI と版、OS ごとの制約は CNI のページにあります。 他社クラウドのマシンと Hybrid Nodes 他社のクラウドのマシンを Hybrid Nodes にする構成は、AWS の文書でサポートの対象外とされています。本番で使うなら、AWS のサポートを受けられないことと、障害の切り分けや運用を自分たちで統制する必要があることを受け入れたうえで使うべきです。 Running EKS Hybrid Nodes on cloud infrastructure, including AWS Regions, AWS Local Zones, AWS Outposts, or in other clouds, is not supported. You will be charged the hybrid nodes fee if you run hybrid nodes on Amazon EC2 instances. この線引きは、VM かベアメタルかでは分かれていません。同じページは、Hybrid ノードを物理マシンと仮想マシンのどちらでも動かせるとしています。文書が区別しているのは、オンプレミスか、AWS を含むクラウドかだけです。Azure や Google Cloud が提供するベアメタルのサーバーも「in other clouds」に含まれると読むのが自然で、ベアメタルならサポートされるという記述はありません。 技術的に動くかどうかについては、Hybrid ノードを参加させるツールである nodeadm のソースを見た範囲で、他社のクラウドを見分けて参加を拒否するような処理は見つかりませんでした。今回、EC2 では参加からデプロイまで動いたので、Azure や Google Cloud のマシンも、技術的にはノードとして追加できそうです。ただし、Azure と Google Cloud のマシンでは試していないので、確実に動くとは言えません。 中継用の Argo CD EKS ではない Kubernetes にデプロイする方法の 1 つとして、中継用の Argo CD を経由する構成が考えられます。今回は、この構成を実際に試しました。まず、考え方の全体を示します。 Managed Argo CD とは別に、オープンソースの通常の Argo CD を 1 つ用意し、EKS クラスタの中で動かします。AWS が運用するのではなく、利用者が自分で運用するセルフホストの Argo CD です。この記事では、これを「中継用の Argo CD」と呼びます。デプロイ先を API サーバーの URL とトークンで登録できるので、EKS ではないクラスタにもデプロイできます。 中継用の Argo CD そのものも、Helm chart として配られている普通のアプリです。そのため、中継用の Argo CD を EKS クラスタに入れる作業も、Managed Argo CD のデプロイとして行えます。 その結果、Managed Argo CD が EKS クラスタに中継用の Argo CD を入れ、中継用の Argo CD が EKS ではないクラスタにデプロイする、という 2 段の流れになります。中継用の Argo CD が何をどこにデプロイするかも Managed Argo CD の Application に書けるので、デプロイする内容を変える場所は、トークンを除いて Managed Argo CD の 1 か所にまとまります。 この構成が要るのは、EKS ではないクラスタのノードが、すでにそのクラスタのコントロールプレーンに属しているからです。そのままでは EKS の Hybrid ノードになれません。Managed Argo CD から直接デプロイしたいなら、マシンを元のクラスタから外し、Hybrid ノードとして EKS クラスタに参加させ直すことになります。元のクラスタをそのまま使う場合に、中継用の Argo CD が使えます。 ここからは、中継構成を組むときに大事なネットワークと認証認可の 2 点を説明します。 ネットワークでは、中継用の Argo CD から、EKS ではないクラスタの API サーバーに届く必要があります。中継用の Argo CD は、EKS クラスタの中から相手の API サーバーを呼んでデプロイするからです。相手の API サーバーをインターネットに開くと攻撃される入口が増えるので、BYOI と同じく、VPN などのプライベートな接続でつなぐのが安全です。今回は、k3s に Transit Gateway を通る private なアドレスでつなぎました。 認証認可では、誰がどの認証情報でどこに入るかを 3 つに分けて押さえます。 Managed Argo CD は、IAM ロールとアクセスエントリで EKS クラスタに入ります。中継用の Argo CD の chart は ClusterRole を作るので、Managed Argo CD のアクセスエントリには、ClusterRole を作れる権限が要ります。 中継用の Argo CD は、EKS クラスタの中で、自分の ServiceAccount と ClusterRole で動きます。chart の既定のままでは、この ClusterRole で EKS クラスタ全体を読み書きできます。そこで、cluster.inClusterEnabled: "false" で既定のデプロイ先である自分の EKS クラスタを外し、AppProject で Application が指定できるデプロイ先と取得元を限ります。ただし、これらは ServiceAccount の権限そのものは狭めないので、狭めるなら ClusterRole を絞ります。Managed Argo CD の画面には IAM Identity Center で入りますが、中継用の Argo CD の画面にはローカルの admin で入ります。 中継用の Argo CD は、EKS ではないクラスタに、相手の ServiceAccount のトークンで入ります。トークンは cluster secret に入れ、Git には置かずに中継用の Argo CD の namespace に直接置きます。相手のクラスタで何ができるかは、そのトークンの ServiceAccount の RBAC で決まります。今回は検証を短く済ませるため cluster-admin を与えました。実運用で namespace の中だけに絞るなら、デプロイ先の namespace を先に作り、Role と RoleBinding で権限を与え、Application の CreateNamespace=true も外します。 設定としては、Managed Argo CD に 2 つの Application を置きます。tier2-argocd は Helm chart argo-cd で中継用の Argo CD を入れ、tier2-apps は Helm chart argocd-apps で、中継用の Argo CD の AppProject と Application を作ります。具体的な値は、リポジトリの設定にあります。 版の更新では、中継用の Argo CD のマイナー版を Managed Argo CD にそろえます。2 つの Argo CD は、同じクラスタの CRD を共有するからです。その CRD は Managed Argo CD を作るときに EKS が入れ、版の更新も AWS が行うので、中継用の Argo CD の chart は crds.install: false で CRD を入れません。aws eks describe-capability で Managed Argo CD の版を定期的に確かめ、マイナー版が上がったら、中継用の Argo CD をあとから同じマイナー版に上げます。Helm chart argo-cd の版は Argo CD の版と別なので、chart の appVersion を見て選びます。中継用の Argo CD を先に上げると、古い CRD の上で新しいコントローラーが動くことになります。マイナー版をそろえても互換性が保証されるわけではないので、上げる前に Argo CD のリリースノートで CRD の変更を確かめます。 まとめ EKS Capabilities の Managed Argo CD は EKS クラスタにしかデプロイできませんが、BYOI を Hybrid Nodes で EKS クラスタのノードにすれば、BYOI にも直接デプロイできます。今回はこれを、AWS のサポート対象外である EC2 を BYOI に見立てて確かめました。EKS ではない Kubernetes には、Managed Argo CD が入れた中継用の Argo CD を経由してデプロイできることを k3s で確かめ、デプロイする内容は、トークンを含む cluster secret を除いて、Managed Argo CD の Application だけで変えられました。構築、検証、削除のコードと結果のログは、littlemex/distributed-ai の investigations にあります。
AMA: Building on AWS with the coding agent you already use (10/29) A lot of you are building on AWS with a coding agent now: Claude Code, Codex, Kiro, Cursor or something you wired up yourself. Most of the time that works. Sometimes the agent uses an API the way it worked when the model was trained, or skips the one permission that lets API Gateway invoke your Lambda function. My colleagues who work on making that better are here to answer your questions, whichever agent you use. Some of them build the Agent Toolkit for AWS (a managed MCP server plus skills and plugins for your agent), and others work with developers and customers building this way every day. You don't need to use the toolkit, or even like it, to ask. - Bidesh Thapaliya ( u/bthapaws ), on the software engineering team dedicated to thinking about and building the things that make agents do better when working with AWS. - James Ward ( u/jlward4th ) and Rohini Gaonkar ( u/techwithrohini ), developer advocates focused on building with agents . You can ask about: - Setting up your agent to build, deploy and debug on AWS - Where agents go wrong on AWS, and how to steer them - Letting an agent into a real account safely (IAM policies that apply only to agent calls, read-only setups, CloudTrail) - Token and context costs - The Agent Toolkit for AWS, and how it compares to the AWS Labs MCP servers - What's frustrating today, any wishlist items you have, and where building on AWS with agents should go next - and more! The team will be answering for four hours, from 6:00 to 10:00 AM PT (9:00 AM to 1:00 PM ET, 13:00 to 17:00 UTC) on October 29th. Post your questions and upvote the ones you want answered first. Ask away!
NCCL Roadmap Nov 2026 - Jan 2027 の解説 はじめに ! NCCL 奥がふかすぎる。。。。にゃ。。。 NVIDIA が公開した NCCL Roadmap: Nov 2026 - Jan 2027 (GitHub Issue #2486) から、大規模分散学習・推論に携わるエンジニアが押さえるべきポイントを解説します。前回のロードマップの解説は「NCCL Roadmap Aug-Oct 2026 の解説」、そこで挙げた項目の現在までの実装状況は「NCCL 2.32.3 でロードマップはどう実装されたか」にあります。GIN の基本は前回の解説を前提にします。 この記事では、新しい概念の GENK と Flow、AWS EFA と GIN の現状、注目機能 2 つを扱います。調査は 2026 年 10 月 29 日に行い、まだリリースされていないコードは、staging ブランチを次のリリースの候補とみなして読んでいます。 実装状況の確かめ方 NVIDIA/nccl、aws-ofi-nccl、amazon-contributing/DeepEP、NVIDIA/nccl-extensions の公開ブランチを手元に取得し、commit の履歴とソースを読んで確かめました。PR と Issue の状態 (open か、マージされたか、閉じた日) は GitHub で確かめています。記事のソースへのリンクは、確かめたときの commit に固定しています。 NCCL は master、staging、dev の 3 本のブランチを公開していて、staging には 2.32.5-1 の版番号を付けた commit があり、その後に 2.32.6a0 に上がっています。ロードマップは「Coming Soon: NCCL 2.32 Update 1」の版番号を書いていませんが、この記事では staging のコードをその候補として読みます。 今回のロードマップの項目のうち、この記事で扱うものと、前回のロードマップの近い項目は次のとおりです。対応は筆者の読みです。 今回のロードマップの項目 前回のロードマップの近い項目 General Kernels (GENK) Unify legacy kernels using Device API Flow Device API More Device API primitives GIN Backend Improvements GIN backend improvements CFT を使う階層型カーネル Compute Fabric Transport (CFT) through Device API New Enqueue Pipeline Legacy and symmetric-kernel host-side unification ロードマップには、内部のメモリの削減、RAS の進み具合の監視、RMA の profiling など、この記事で扱わない項目もあります。 Device API とは何か NCCL の API の全体像 ロードマップの項目を説明する前に、まず背景知識として NCCL の API を整理します。NCCL の API は、Host API、Device API、plugin API の 3 つに分けられます。Host API と Device API の仕様は NCCL の公式文書にあり、この記事の表はその章立てに沿っています。plugin API は公式文書の API の章には無く、リポジトリの plugins ディレクトリに plugin の種類ごとの説明があります。 API 何をするか 例 Host API CPU から呼んで、communicator (通信に参加する GPU の集まり) の作成や集団通信を NCCL に頼みます。集団通信では、NCCL がアルゴリズムを選び、NCCL のカーネルを GPU で起動します。利用者の計算は別のカーネルです ncclCommInitRank、ncclAllReduce Device API 利用者が書いた CUDA カーネルの中から、ほかの GPU との通信を直接呼びます (2.28 から)。計算と通信を 1 つのカーネルに入れられます。使う前の準備には Host API を使います GIN、LSA plugin API NCCL のほうから呼ぶ、下の部品の差し替え口です。ネットワークへの送受信や、アルゴリズムの選択の調整などを、NCCL の外の実装に任せます net、GIN。説明は plugins にあります。aws-ofi-nccl は EFA 用の plugin 誰が NCCL を呼ぶか NCCL を呼ぶのは、多くの場合アプリ自身ではなく、間に入るフレームワークやライブラリです。主な呼び手を、ソースで確かめた範囲で並べます。 呼び手 どう呼ぶか 使う API PyTorch の torch.distributed (backend "nccl") C++ の ProcessGroupNCCL が、ncclAllReduce などを直接呼びます Host API PyTorch の backend "nccl4py" (torch.distributed._nccl4py) Python で書いた backend が、nccl4py を通して呼びます。init_process_group("nccl4py") で選びます Host API nccl4py NVIDIA の公式の Python の bindings。Cython で libnccl を呼び、PyTorch の tensor や CuPy の配列をそのまま渡せます。実験的な CuTe DSL の部品もあります 主に Host API vLLM の PyNccl Python の ctypes で libnccl を読み込んで呼びます Host API PyTorch の symmetric memory の演算、DeepEP、NCCL EP 自分の CUDA カーネルの中から通信を呼びます。準備には Host API で window (次の節で説明します) を登録し、device communicator を作ります Device API と Host API aws-ofi-nccl など NCCL のほうから呼ばれます。NCCL が EFA などのネットワークに送るときの下の部品です。InfiniBand には NCCL に組み込みの経路もあります plugin API Device API は何のためにあるか Host API で通信を頼むと、通信は NCCL のカーネル 1 回分の単位になり、利用者の計算とは別のカーネルになります。計算の途中で決まった相手に少しずつ送りたい場合や、計算と通信を細かく重ねたい場合には、この単位が粗すぎます。たとえば MoE のトークンの振り分けでは、どのトークンをどの GPU に送るかが計算の途中で決まります。 Device API は、利用者のカーネルの中から、ほかの GPU のメモリへ直接データを動かせるようにするための API です。NCCL の Device API の例は、計算と通信を分けて順に行う従来のやり方に対して、カーネルの中で両方を細かく混ぜることで、待ち時間を減らし処理量を上げられると説明しています。 GPU のカーネルからほかの GPU に届く経路はいくつかあり、Device API は経路ごとに部品を用意しています。同じノードで NVLink や PCIe の P2P でつながった相手には、相手のメモリを普通の読み書きの命令で触れます (LSA)。NVLink のスイッチが複数の宛先への書き込みや足し合わせを引き受けられるシステムでは、Multimem を使います。ノードをまたぐ相手には、NIC を通して put (相手のメモリへの書き込み) や get (相手のメモリからの読み出し) を出します (GIN)。どちらも、相手が何も呼ばない片側通信です。CFT は、相手のメモリを番地 (ポインタ) で触る代わりに、CUDA が用意する宛先の番号 (logical endpoint) とオフセットを指定して put や get を出す部品で、Blackwell 以降の GPU で使えます。これらと組み合わせて使う、足し合わせやコピーの部品 (reduce と copy) も用意されています。 GIN の仕組みは、前回の解説の「GIN とは何か」で説明しました。LSA は後の GENK と Flow の節で、CFT は注目機能の節で扱います。Multimem と reduce と copy の部品は、この記事では詳しく扱いません。 どの部品を使う場合も、準備は利用者が CPU で Host API を呼んで行います。通信に使うバッファを、全ランクで同じ形に対応付けた領域 (symmetric memory window) として登録し、カーネルが使う資源を指定して device communicator を作り、それを渡して自分のカーネルを起動します。手順の例は NCCL の文書にあります。GIN を使うカーネルは、NCCL を更新したら作り直す必要があると文書は書いています。次の節からは、この Device API の上に加わる新しい部品とカーネルを見ていきます。 GENK と Flow とは何か ! GENK と Flow は、まだリリースされておらず staging と dev にだけあるコードです。この節は staging の実装から読み取った内容で、リリースまでに変わる可能性があります。staging で試すには、ビルドのときに SYM_GENK=1 を指定し、実行のときに NCCL_SYM_GENK_ENABLE=1 を設定する必要があります。 概要 Flow は、前の節の LSA と GIN の上に作られた、集団通信の部品を提供する Device API です。ロードマップは Flow を「LSA と GIN の両方で動く、高水準の Device API」と説明しています。LSA は前の節で触れた、相手の GPU のメモリを普通の読み書きの命令で触る部品です。 GENK (General Kernels) は、NCCL が自分の Ring と Tree の集団通信を、この Flow の上で作り直したカーネルの一群です。Ring では AllGather、ReduceScatter、Broadcast、Reduce、AllReduce を、Tree では AllReduce を実装しています。 前回のロードマップには「Unify legacy kernels using Device API」という項目がありました。筆者は、GENK をこの項目につながる取り組みと読んでいます。今回のロードマップは、選んだプラットフォームで GENK を既定にし、古いカーネルを置き換えるとしています。あわせて性能モデルを 1 つにまとめ、どの実装を選ぶかを同じ物差しで決めるとしています。 staging では、NCCL 自身が同じ Flow で集団通信を組んでいます。Flow がリリースに入れば、利用者も NCCL の内部と同じ部品で通信カーネルを書けます。 NCCL の集団通信には、GENK の前から 2 種類のカーネルがあります。 legacy kernels: NCCL に最初からある Ring や Tree のカーネルです。経路の違いは、CPU の側が準備する transport (NVLink や PCIe の P2P、ネットワークなど) が受け持ち、ネットワークでは CPU の proxy スレッドが NIC に渡します。 symmetric カーネル: 全ランクが同じ形で登録したバッファ (symmetric memory window) を集団通信に渡したときに使える、新しいカーネルです。相手のバッファの番地が分かるので、Device API の LSA などで相手のメモリを直接読み書きします。いまあるものは、NVLink の直結を前提に、場面ごとに作り込まれています (表の「特化した symmetric カーネル」)。 GENK は、symmetric カーネルと同じ Device API の作りで、legacy kernels と同じくらい広い経路で動くものです。Ring で隣の GPU にデータを渡す場面で比べると、次のようになります。 隣の GPU との経路 legacy kernels 特化した symmetric カーネル GENK NVLink 動く 動く 動く (LSA) PCIe の P2P 動く 使えない 動く (LSA) 別のノード (ネットワーク) 動く 一部の種類だけ動く (GIN) 動く (GIN) 表は、Hopper 以降の GPU で GENK を有効にした場合についてソースから読んだもので、動かして確かめたものではありません。PCIe の行は CUDA P2P が使える場合、GENK のネットワークの行は GIN Proxy (GPU の代わりに CPU のスレッドが NIC に送る GIN のバックエンド) が使える場合です。 特化した symmetric カーネルは、PCIe だけでつながったノードでは候補から外れます。GENK では、隣の GPU ごとに Flow が LSA か GIN かを実行時に選ぶので、同じカーネルのまま NVLink でも PCIe でもネットワークでも動きます。staging のカーネルの選択にも「特化したカーネルは NVLink の直結を要するが、GENK は CUDA P2P の LSA や GIN も使える」というコメントがあります。 利用者の側の例もあります。NCCL の Device API の例には、Transformer の RMSNorm の計算を ReduceScatter と AllGather の通信と 1 つのカーネルにまとめるものがあり、いまは LSA、Multimem、GIN を直接使って、受け取り、足し合わせ、送り出しを書いています。Flow は、NCCL 自身が集団通信に使うこの受け取り、足し合わせ、送り出しの部分を、部品として提供します。 NCCL の文書の Device API の章には、Flow と GENK の説明はまだありません。Flow の使い方は、ヘッダの説明にまとまっていて、Flow processor を足した commit が入口になります。GENK は、カーネルを足した commit と、NCCL の選択に組み込んだ commit が出発点です。 図で [API] のタグを付けた Flow processor、LSA connection、GIN connection は、アプリのカーネルからも呼べる Device API です。cost model、legacy kernels、specialized symmetric kernels、GENK は NCCL の中の部品です。 GIN の signal とは GIN の put は片側通信で、受け手は何も呼ばないので、データがいつ届いたかを自分では知れません。signal は、それを知らせるための、受け手の側に置かれた数です。送り手が put に signal を付けると、データが届いたあとに受け手の数が増え (たとえば 1 増え)、受け手はその数が目標に達するまで待ってから読みます。GIN の基本は、前回の解説の「GIN とは何か」で説明しました。 signal には strong と weak の 2 種類があります。weak signal が「読んでよい」と約束するのは、その signal を付けた put のデータだけです。strong signal は、同じ相手に同じ context (GIN の通信の通り道の 1 本) で先に出した put のデータもすべて読めると約束します。そのため、最後の put にだけ signal を付ければ済みます。 EFA の送り方の SRD は、書き込みが届く順番を保ちません。そのため、最後の put に付けた signal が届いても、前の put のデータが届いたとは限りません。EFA の上の経路のうち、GPU から直接送る EFA GDA が提供するのは weak signal だけです (次の節で説明します)。一方、Flow の GIN の接続は strong signal を必要とします。 AWS EFA 関連アップデートと現在の課題 EFA の 2 つの GIN の経路と signal の種類 ! GENK と Flow の GIN の接続は strong signal を必要とします。EFA の上で strong signal を提供するのは GIN Proxy だけで、GPU から直接送る EFA GDA は weak signal しか提供しません。そのため、EFA の複数ノードで GENK を使うなら Proxy の経路になります。 EFA で GIN を使うときは、使う経路 (GIN のバックエンド) が、カーネルの必要とする種類の signal を提供するかを先に確かめます。NCCL は、カーネルが求める種類の signal を提供できないバックエンドを使わず、提供できる別のバックエンドがあればそちらに切り替え、無ければエラーにすると説明しています。EFA で使う GIN の経路として、ここでは 2 つを比べます。1 つは NCCL の GIN Proxy で、CPU のスレッドが aws-ofi-nccl の RMA の機能を通して EFA に送ります。もう 1 つは EFA GDA で、GPU のカーネルが EFA の送信キューに直接依頼を書きます。aws-ofi-nccl の文書によると、EFA GDA は NCCL_GIN_TYPE=5 で明示したときに使われます。同じ文書は、明示しなければ NCCL 2.31 は EFA で Proxy を使うと書いています。インスタンスやドライバなどの要件も同じ文書にあります。 2 つの経路は、提供する signal が違います。NCCL の Proxy は、strong signal と VA signal (登録した仮想アドレスに置く signal) を使えると報告します。EFA GDA については、aws-ofi-nccl が strong signal も VA signal も使えないと報告し、NCCL の側も番号で指す indexed signal だけを受け付けます。EFA GDA の signal は、EFA の NIC が届いた書き込みを数えるカウンタで作られています。aws-ofi-nccl の文書は、signal と counter の合計が EFA のデバイスあたり 512 個までであることも、制限に挙げています。 図の矢印は、使い手が必要とする signal をバックエンドが提供するかを示します。矢印はソースと文書から描いたもので、どの経路が選ばれるかや、動かした結果を示すものではありません。図の DeepEP の ordered は届く順番を前提にする上流のカーネル、unordered は順番を前提にしない amazon-contributing のフォークのカーネルです。 GENK はノードをまたぐ通信に使う GIN のバックエンドとして Proxy を指定しているので、staging のコードを読む限り、GENK が EFA の上で複数ノードの通信をするなら Proxy の経路を通ります。一方、aws-ofi-nccl の文書は、EFA GDA を使うときに symmetric の GIN カーネルを無効にするよう書いていて、この設定 (NCCL_SYM_GIN_KERNELS_ENABLE=0) にすると複数ノードの GENK は候補から外れます。つまり、EFA GDA を使う設定 (NCCL_GIN_TYPE=5 と NCCL_SYM_GIN_KERNELS_ENABLE=0) では複数ノードの GENK は使われず、複数ノードで GENK を試すときは、この 2 つの環境変数を設定せずに Proxy の経路で動かします。 aws-ofi-nccl v1.22.0 で、EFA GDA の Get がタグの付いた版で使えるようになった Get は、GPU のカーネルが、ほかの GPU のメモリの中身を、持ち主に何もさせずに読み出す操作です。たとえば、手元の番号を見て、ほかの GPU が持つ表の行を取り出すような使い方ができます。Get の意義は「NCCL 2.32.3 でロードマップはどう実装されたか」で説明しました。 その記事の時点では、NCCL 2.32.3 には Get が入っていましたが、aws-ofi-nccl の側の対応はタグの無い master にしかありませんでした。2026 年 10 月 22 日 (日本時間) の v1.22.0 は、NCCL 2.32 が使うキューの形への対応と、Get の完了を数える修正を含む最初のタグです。これで、NCCL 2.32 と aws-ofi-nccl v1.22.0 という、タグの付いた版どうしに、EFA GDA の Get に要る変更がそろいました。v1.22.0 のリリースノートは Get に触れておらず、筆者は動かして確かめていません。 ただし、EFA GDA の Get には、完了を知らせる signal や counter を付けられません。読み出しが終わったかは、出した依頼をまとめて待つ Flush で確かめます。 MoE の通信ライブラリ (DeepEP と NCCL EP) の動き MoE のトークンを担当の expert に配り (dispatch)、計算結果を集める (combine) 通信のライブラリにも動きがありました。DeepEP と NCCL EP はこの同じ役割の候補で、どちらも下で NCCL を使います。DeepEP は 2026 年 9 月 29 日の V2.5 で NVSHMEM への依存を取り除き、ノードをまたぐ通信は NCCL の GIN だけになりました。要件は NCCL 2.32.3 以降です。EFA で DeepEP を動かす取り組みは amazon-contributing/DeepEP のフォークにあり、その詳細は別の記事で整理してみます。 NVIDIA の NCCL EP は NVIDIA/nccl-extensions に移り、NCCL の staging からは、nccl_ep と nccl_m2n のディレクトリが消されました。 注目機能 2 選 CFT を使う階層型カーネル CFT (Compute Fabric Transport) は NCCL 2.31 で Device API に加わった部品で、嬉しさは 2 つあります。1 つ目は、カーネルから直接届く相手を、LSA の範囲より広げられる見込みがあることです。LSA で届く相手は、CUDA P2P で相手のメモリを普通の読み書きの命令で触れる相手 (LSA team) に限られます。CFT は相手を番地ではなく CUDA が配る宛先の番号で指すので、届く範囲は、CUDA が「この番号で届く」と報告する範囲で決まります。前回のロードマップも、CFT を、logical endpoint で届く範囲の相手を宛先の番号で指す通信の形と説明していました。ただし、リリースされた版では、CFT で届く相手の集まり (CFT team) の大きさは既定で LSA team と同じです。 2 つ目は、受け手に届いたことを早く知らせる書き込みがあることです。CFT の普通の put や reduce で受け手に知らせるときは、データを書いたあと、書き込みの完了を確定させ (fence)、合図のフラグを書きます。これが counted な書き込みで、フラグの代わりに、NCCL_WIN_CFT_COUNTED を付けて登録した window の中のカウンタを使います。カウンタはデータより後に進むことが保証されているので、送り手は fence とフラグの書き込みを省けます。staging の文書は、これを低遅延のための操作と説明しています。受け手は、カウンタを見てからデータを読む間に fence を入れる必要があります。 仕組みは次のとおりです。CFT の宛先の番号は、CUDA 13.3 で加わった logical endpoint という仕組みです。NCCL は CPU の側で、宛先の番号を確保し、そこに各 GPU のバッファを結び付けておきます。カーネルは、送りたいデータを shared memory に置き、宛先の番号とオフセットを指定して put を出し、そのあと完了を確かめます。 dev では、CUDA 13.4 以降で CUDA が「logical endpoint で届く範囲」を報告すると、CFT team をその範囲から作ります。その範囲は LSA の範囲の倍数でなければならず、LSA より広い範囲を 1 つの CFT team にできる作りです。NCCL の文書は、CFT がどの物理的な経路を通るかを書いていません。NCCL EP の CFT を使う経路は、1 つの NVLink のドメインの中だけを対象にしています。 ロードマップは、この CFT を使って、AllGather、ReduceScatter、AlltoAll の階層型カーネルを作るとしています。階層型は、近い GPU の間と遠い GPU の間で通信を分ける作りで、LSA の範囲と、それより広い CFT team の範囲を使い分けることになると筆者は読んでいます。master、staging、dev を調べた範囲では、CFT の宛先の集まりの階層の種類は 2.32.3 のコードに定義されていますが、それを使う集団通信のカーネルは見当たりませんでした。 CFT とは CFT が使えるのは、ハードウェア、ドライバ、CUDA が logical endpoint と fabric 用の命令に対応したシステムだけで、例の barrier のプログラムは CUDA 13.3 と SM_100 を要件に挙げています。CFT team を CUDA の報告する範囲 (clique) から作る変更は、dev の 2026 年 9 月 28 日の commit で入り、master と staging にはまだありません。 NCCL EP には、CFT の counted な書き込みで低遅延の dispatch を送る経路が、NCCL_EP_LL_CFT=1 で選んだときだけ使われる形で、2026 年 9 月 16 日の commit から main に入っています。 New Enqueue Pipeline: 呼び出しから起動までを 3 つの層に分ける enqueue は、アプリが呼んだ集団通信や put を、カーネルやコピーの起動に変える NCCL の中の流れです。ロードマップは、これを管理 (Management)、準備 (Preparation)、スケジューリング (Scheduling) の 3 層に分け、制御の流れを簡単にし、カーネルの最適化をしやすくするとしています。 管理と準備の層を含む基本の枠組みは 2.31.2 から入っていて、その時点のスケジューリングの層は既存の経路に戻すだけでした。いまの staging と dev でも、新しい流れは既定で無効です。dev では、Preparation の tuning ですべての集団通信のアルゴリズムやプロトコルを先に選び、classification でその結果だけを使って起動の経路を振り分ける形にしました。経路ごとのスケジューラは、それぞれの仕事を stream ごとに 1 つのキューにまとめます。dev の commit では、新しい流れの symmetric のスケジューラが起動するのは特化した symmetric カーネルだけで、GENK は自分用の device communicator の準備が要るので、従来の流れの symmetric カーネルのスケジューラから起動されます。 まとめ staging の GENK は、Hopper 以降の GPU でビルドと環境変数で有効にすると NVLink、PCIe、ネットワークのどの経路でも同じカーネルが候補になり、EFA の複数ノードでは strong signal を提供する GIN Proxy の経路を使います。CFT を使う階層型カーネルは部品だけが公開コードにあり、New Enqueue Pipeline は枠組みが既定で無効のまま dev で進んでいます。
Ship a Lambda Web Function with CDK So, AWS Lambda Web Functions just came out, and I'm super excited about them. What are they? Well, you can now ship Express, Hono, or React Router with no Lambda-specific adapters or wrappers. Let me show you how I got this demo set up with CDK. λ: cxrj1w1j65n0k7ss.p7v.lambda-web.us-east-2.on.aws (this may not live forever but the code should) > ⚠️ DISCLAIMER: The code we are about to see is written with L1 CDK constructs. The L2 constructs are being tracked via this PR. First, let's look at the server code I have in Express. I've omitted the bulk of its logic for brevity. Here, you can see that it's an Express.js server binding to a port. import express from "express"; const [hostname, rawPort] = ( process.env.AWS_LAMBDA_HTTP_ENDPOINT ?? `127.0.0.1:${process.env.PORT ?? "3000"}` ).split(":"); const port = Number(rawPort); export const app = express(); export const server = app.listen(port, hostname, () => { console.log(`Spinning Lambda is listening on http://${hostname}:${port}`); }); I can run the code locally with pnpm dev. !Image description Now, let's look at the CDK stack used to deploy this function. export class SpinningLambdaStack extends Stack { constructor(scope: Construct, id: string, props?: StackProps) { super(scope, id, props); // code bucket stuff omitted const webFunction = new CfnWebFunction(this, "WebFunction", { functionName: `${this.stackName}-web`, // many props omitted for brevity }); const revision = new CfnWebFunctionRevision(this, "WebFunctionRevision", { functionName: webFunction.ref, // many props omitted for brevity }); const endpoint = new CfnWebFunctionEndpoint(this, "WebFunctionEndpoint", { functionName: webFunction.ref, // many props omitted for brevity }); } } Now for deployment. I'm using the new AWS experience, so I have a project dedicated to this demo. I grab some credentials for my account with aws login. I need to do a one-time setup to bootstrap CDK with npx cdk bootstrap. Once that's done, I can run pnpm deploy to ship it. Then it's live 🚀! !Image description Share this on X/Twitter, and it should render as an embed: https://cxrj1w1j65n0k7ss.p7v.lambda-web.us-east-2.on.aws/embed Full repo: sboult/lwf-three-js --- As always, happy coding 😉! Follow AWS for more articles like this. {% organization aws %} Follow me for all things tech! {% user hacksore %}
AAIF に加わった Open Responses を Amazon Bedrock で試す 先端技術開発グループ(WAND)の伊賀です。 2026年10月22日、Linux Foundation 傘下の Agentic AI Foundation(AAIF)に、Open Responses が新しいプロジェクトとして加わりました(Linux Foundation のプレスリリース)。 Open Responses は複数の LLM プロバイダーを同じ形で呼び出すためのオープンな仕様です。 同じ日に AWS も Open Responses への関わりと Amazon Bedrock での対応を紹介しています(Open Responses Joins AAIF with AWS Support)。 内容は 2026年10月時点のものです。 Open Responses とは Open Responses は OpenAI の Responses API をもとにした LLM API の共通仕様です(openresponses.org)。 主に次の 3 つを定めています(README)。 リクエストとレスポンスの共通スキーマ ストリーミングで流れるイベントの種類と順序 ツール呼び出しの手順 仕様は日付つきのバージョンとして公開されていて、最新は 2026-04-24 です。 GitHub リポジトリには仕様の本文と OpenAPI 定義に加えて、API が仕様に沿っているかを確かめる準拠テストが入っています。 プロジェクトは OpenAI が寄贈したもので、今後は AAIF の下で運営されます。 同じ日に Anthropic の Agent Skills、Hugging Face の OpenEnv、Alibaba の OpenSandbox も加わり、AAIF のプロジェクトは MCP や A2A、AGENTS.md などとあわせて 10 個になりました。 共通の仕様が必要な理由 LLM の API はメッセージ、関数呼び出し、ツール利用、マルチモーダル入力といった似た概念に収束してきました。 しかし OpenAI の Responses API、Anthropic の Messages API、Google の Gemini API では、同じことをするにもプロバイダーごとに書き方が異なります。 そのため、複数のモデルを比べたり切り替えたりするには、プロバイダーごとの変換コードを書いて保守する必要がありました。 Open Responses に沿ったエンドポイントであれば、クライアントのコードはそのままで、接続先とモデル名を変えるだけで済みます。 AAIF のブログではこれを「エンドポイントとモデル識別子が、コードの分岐ではなく設定値になる」と表現しています(What Open Responses Changes for AI Developers)。 この形式のエンドポイントは Bedrock のほかにもあり、AWS のブログは Ollama、vLLM、OpenRouter、Hugging Face を、AAIF のブログは Databricks Model Serving と Vercel AI Gateway を挙げています。 各社が独自の機能を出す余地も残されています。 仕様にないツールやアイテムは、openai:web_search_call のようにプロバイダー名を前に付けて定義します。 クライアントは知らない種類のアイテムやイベントを無視しても、標準の部分はそのまま処理できます。 仕様の考え方 仕様の本文は次の 4 つの考え方を軸にしています(Specification)。 エージェントループ:モデルがツール呼び出しを出力し、結果を受け取って推論を続ける流れを、共通の形で扱います アイテム:メッセージ、関数呼び出し、その実行結果、推論などを、type で区別するアイテムとして表します。アイテムは入力にも出力にも使えます 状態機械:レスポンスやアイテムは in_progress、completed、incomplete などの決まった状態を持ちます 意味のあるイベント:ストリーミングではテキストの差分をそのまま流さず、response.output_item.added や response.completed のように何が起きたかを表すイベントを流します ツールには関数や MCP サーバーのように実装がプロバイダーの外にあるものと、プロバイダーの中で実行が完結するものの 2 種類があります。 2026-04-24 バージョンでは接続を張ったまま使う WebSocket での呼び出しと、長い会話の文脈を圧縮する /responses/compact エンドポイントが追加されました(Changelog)。 AWS の関わり AWS は AAIF の Governing Board に加わっています。 Amazon Bedrock は 2025年12月から Responses API に対応していて、AWS のブログではその実装を「仕様が大規模な環境でどう動くかを示す、本番品質のリファレンス」とも紹介しています。 Bedrock で Responses API を使えるエンドポイントは 2 つあります(Responses API - Amazon Bedrock User Guide)。 エンドポイント ベース URL 位置づけ bedrock-runtime bedrock-runtime.{region}.amazonaws.com 推奨 bedrock-mantle bedrock-mantle.{region}.api.aws GPT OSS の Responses API など、mantle でしか使えないモデルや機能向け Responses API に対応しているのは、OpenAI の GPT 系と GPT OSS、xAI の Grok、Moonshot AI の Kimi K3、Google の Gemma 4、Z.AI の GLM 5.3 です(API compatibility)1。 Amazon Bedrock で動かしてみる us-east-1 の bedrock-runtime に、Open Responses の形でリクエストを送ってみます。 認証には Bedrock の API キーを使います。有効期限の短いキーは AWS の認証情報から aws-bedrock-token-generator で発行できます。 基本のリクエストとレスポンス curl で 1 回呼び出します。 store: false は会話を Bedrock 側に保存しないための指定です。 export OPENAI_BASE_URL=bedrock-runtime.us-east-1.amazonaws.com export OPENAI_API_KEY=<Bedrock の API キー> curl -sS "$OPENAI_BASE_URL/responses" \ -H "Authorization: Bearer $OPENAI_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "global.openai.gpt-5.6-sol", "input": "Open Responses を一文で説明してください。", "store": false }' レスポンスから主要な部分を抜き出すと、次のようになります。 { "id": "resp_fjc2ydhllu3zscoaqtqem3wpley2fas4u7dghhguugzipayo3pcq", "object": "response", "status": "completed", "model": "global.openai.gpt-5.6-sol", "output": [ { "type": "reasoning", "id": "rs_8c6287cb8f975b00bebcc6c9a47a5487", "encrypted_content": "(省略)", "content": [], "summary": [] }, { "type": "message", "id": "msg_4629e516851f545db7fd68af8e29b3fd", "role": "assistant", "status": "completed", "phase": "final_answer", "content": [ { "type": "output_text", "text": "Open Responsesは、異なるAIモデルやエージェントの応答APIを共通形式で扱えるようにするオープンな標準仕様です。", "annotations": [] } ] } ], "usage": { "input_tokens": 15, "output_tokens": 219, "output_tokens_details": { "reasoning_tokens": 177 }, "total_tokens": 234 } } output はアイテムの配列で、推論の reasoning アイテムと、回答の message アイテムが順に並んでいます。 回答の本文は message アイテムの content にある output_text に入ります。 phase は 2026-04-24 バージョンで追加された任意のフィールドで、最終回答(final_answer)か途中の説明(commentary)かを表します。 レスポンス全体{ "background": false, "billing": { "payer": "developer" }, "completed_at": 1792717922, "created_at": 1792717919, "error": null, "frequency_penalty": 0.0, "id": "resp_fjc2ydhllu3zscoaqtqem3wpley2fas4u7dghhguugzipayo3pcq", "incomplete_details": null, "instructions": null, "max_output_tokens": null, "max_tool_calls": null, "metadata": {}, "model": "global.openai.gpt-5.6-sol", "object": "response", "output": [ { "content": [], "encrypted_content": "(省略)", "id": "rs_8c6287cb8f975b00bebcc6c9a47a5487", "summary": [], "type": "reasoning" }, { "content": [ { "annotations": [], "logprobs": [], "text": "Open Responsesは、異なるAIモデルやエージェントの応答APIを共通形式で扱えるようにするオープンな標準仕様です。", "type": "output_text" } ], "id": "msg_4629e516851f545db7fd68af8e29b3fd", "phase": "final_answer", "role": "assistant", "status": "completed", "type": "message" } ], "parallel_tool_calls": true, "presence_penalty": 0.0, "previous_response_id": null, "prompt_cache_key": null, "prompt_cache_retention": "in_memory", "reasoning": { "summary": null, "effort": "medium", "context": "all_turns" }, "safety_identifier": null, "service_tier": "default", "status": "completed", "store": false, "temperature": 1.0, "text": { "format": { "type": "text" }, "verbosity": "medium" }, "tool_choice": "auto", "tools": [], "top_logprobs": 0, "top_p": 0.98, "truncation": "disabled", "usage": { "input_tokens": 15, "input_tokens_details": { "cache_write_tokens": 0, "cached_tokens": 0 }, "output_tokens": 219, "output_tokens_details": { "reasoning_tokens": 177 }, "total_tokens": 234 }, "user": null, "moderation": null } ストリーミングのイベント "stream": true を付けると、Server-Sent Events でイベントが順に届きます。 「Say hello in three words.」と送ったときのイベントの並びは次のとおりでした。 response.created response.in_progress response.output_item.added reasoning アイテムの開始 response.output_item.done reasoning アイテムの完了 response.output_item.added message アイテムの開始 response.content_part.added response.output_text.delta "Hello" response.output_text.delta " there" response.output_text.delta "," response.output_text.delta " friend" response.output_text.delta "!" response.output_text.done response.content_part.done response.output_item.done message アイテムの完了 response.completed レスポンス全体の状態の変化(response.created → response.in_progress → response.completed)の中に、アイテムごとの「追加 → 差分 → 完了」が入れ子になっています。 文字を順に画面へ表示したいだけなら、response.output_text.delta の delta をつなげれば足ります。 受信したイベント(一部のフィールドを省略)data: {"type": "response.created", "response": {"id": "resp_f4wjs5jc4gfuomhyckvwc2o7zlrmxx4e2ohxyyupkph7imdpjhaq", "status": "in_progress", "output": []}, "sequence_number": 0} data: {"type": "response.in_progress", "response": {"id": "resp_f4wjs5jc4gfuomhyckvwc2o7zlrmxx4e2ohxyyupkph7imdpjhaq", "status": "in_progress", "output": []}, "sequence_number": 1} data: {"type": "response.output_item.added", "item": {"content": [], "encrypted_content": "(省略)", "id": "rs_949298e3722a55969bf6fb415bcc857e", "summary": [], "type": "reasoning"}, "output_index": 0, "sequence_number": 2} data: {"type": "response.output_item.done", "item": {"content": [], "encrypted_content": "(省略)", "id": "rs_949298e3722a55969bf6fb415bcc857e", "summary": [], "type": "reasoning"}, "output_index": 0, "sequence_number": 3} data: {"type": "response.output_item.added", "item": {"content": [], "id": "msg_d2cdd7ec87f75d3bbf1bb4ae2effb0b6", "phase": "final_answer", "role": "assistant", "status": "in_progress", "type": "message"}, "output_index": 1, "sequence_number": 4} data: {"type": "response.content_part.added", "content_index": 0, "item_id": "msg_d2cdd7ec87f75d3bbf1bb4ae2effb0b6", "output_index": 1, "part": {"annotations": [], "text": "", "type": "output_text"}, "sequence_number": 5} data: {"type": "response.output_text.delta", "content_index": 0, "delta": "Hello", "item_id": "msg_d2cdd7ec87f75d3bbf1bb4ae2effb0b6", "output_index": 1, "sequence_number": 6} data: {"type": "response.output_text.delta", "content_index": 0, "delta": " there", "item_id": "msg_d2cdd7ec87f75d3bbf1bb4ae2effb0b6", "output_index": 1, "sequence_number": 7} data: {"type": "response.output_text.delta", "content_index": 0, "delta": ",", "item_id": "msg_d2cdd7ec87f75d3bbf1bb4ae2effb0b6", "output_index": 1, "sequence_number": 8} data: {"type": "response.output_text.delta", "content_index": 0, "delta": " friend", "item_id": "msg_d2cdd7ec87f75d3bbf1bb4ae2effb0b6", "output_index": 1, "sequence_number": 9} data: {"type": "response.output_text.delta", "content_index": 0, "delta": "!", "item_id": "msg_d2cdd7ec87f75d3bbf1bb4ae2effb0b6", "output_index": 1, "sequence_number": 10} data: {"type": "response.output_text.done", "content_index": 0, "item_id": "msg_d2cdd7ec87f75d3bbf1bb4ae2effb0b6", "output_index": 1, "sequence_number": 11, "text": "Hello there, friend!"} data: {"type": "response.content_part.done", "content_index": 0, "item_id": "msg_d2cdd7ec87f75d3bbf1bb4ae2effb0b6", "output_index": 1, "part": {"annotations": [], "text": "Hello there, friend!", "type": "output_text"}, "sequence_number": 12} data: {"type": "response.output_item.done", "item": {"content": [{"annotations": [], "text": "Hello there, friend!", "type": "output_text"}], "id": "msg_d2cdd7ec87f75d3bbf1bb4ae2effb0b6", "phase": "final_answer", "role": "assistant", "status": "completed", "type": "message"}, "output_index": 1, "sequence_number": 13} data: {"type": "response.completed", "response": {"id": "resp_f4wjs5jc4gfuomhyckvwc2o7zlrmxx4e2ohxyyupkph7imdpjhaq", "status": "completed", "output": "(省略)"}, "sequence_number": 14} data: [DONE] ツール呼び出しとエージェントループ 関数ツールを渡すと、モデルはツールが必要と判断したときに、回答の代わりに function_call アイテムを返します。 開発者側で関数を実行し、その結果を function_call_output アイテムとして返すと、モデルは結果を踏まえて回答します。 この流れが仕様でいうエージェントループです。 東京の天気を聞いたときのやり取りは、次のようになりました。 1 回目のリクエスト input : message(ユーザーの質問) 1 回目のレスポンス output: function_call get_weather {"city":"東京"} 2 回目のリクエスト input : message, function_call, function_call_output 2 回目のレスポンス output: message 「東京の現在の天気は晴れ、気温は18℃です。」 1 回目に出力された function_call アイテムは、そのまま 2 回目の入力に積んでいます。 アイテムが入力にも出力にも使えるので、クライアントは出力を加工せずに会話を続けられます。 今回は store: false にしているため、2 回目では 1 回目の入出力をすべて送り直しています。 store: true にすれば、previous_response_id で前のレスポンスを指定して、新しい入力だけを送る形にもできます。 Python と httpx で書いたコードは次のとおりです。 エージェントループのコード(Python)import json import os import httpx BASE_URL = os.environ["OPENAI_BASE_URL"] API_KEY = os.environ["OPENAI_API_KEY"] MODEL = os.environ.get("MODEL", "global.openai.gpt-5.6-sol") TOOLS = [ { "type": "function", "name": "get_weather", "description": "指定した都市の現在の天気を返す", "parameters": { "type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"], }, } ] def get_weather(city: str) -> dict: return {"city": city, "weather": "晴れ", "temperature_c": 18} # 検証用の固定値 items = [{"type": "message", "role": "user", "content": "東京の天気を教えて"}] while True: r = httpx.post( f"{BASE_URL}/responses", headers={"Authorization": f"Bearer {API_KEY}"}, json={"model": MODEL, "input": items, "tools": TOOLS, "store": False}, timeout=120, ) r.raise_for_status() output = r.json()["output"] items += output # 出力されたアイテムを、そのまま次の入力に積む calls = [i for i in output if i["type"] == "function_call"] if not calls: break for call in calls: result = get_weather(**json.loads(call["arguments"])) items.append( { "type": "function_call_output", "call_id": call["call_id"], "output": json.dumps(result, ensure_ascii=False), } ) message = next(i for i in output if i["type"] == "message") print(message["content"][0]["text"]) ループの最後に積み上がったアイテム[ { "type": "message", "role": "user", "content": "東京の天気を教えて" }, { "arguments": "{\"city\":\"東京\"}", "call_id": "call_899965767e1351f0ad9eedeee141713c", "id": "fc_8bae4d13c8af5eeabe698af52b86aafd", "name": "get_weather", "status": "completed", "type": "function_call" }, { "type": "function_call_output", "call_id": "call_899965767e1351f0ad9eedeee141713c", "output": "{\"city\": \"東京\", \"weather\": \"晴れ\", \"temperature_c\": 18}" }, { "content": [ { "annotations": [], "logprobs": [], "text": "東京の現在の天気は晴れ、気温は18℃です。", "type": "output_text" } ], "id": "msg_67235d5875da5839bc139e6e43cdecb5", "phase": "final_answer", "role": "assistant", "status": "completed", "type": "message" } ] モデルを差し替える 上のコードの MODEL だけを変えて、ベンダーの違うモデルで同じループを動かしました。 GPT OSS は bedrock-mantle でしか Responses API を使えないため、接続先も切り替えています。 モデル ベンダー エンドポイント 1 回目の output 2 回目の output global.openai.gpt-5.6-sol OpenAI bedrock-runtime function_call message global.xai.grok-4.7 xAI bedrock-runtime reasoning, function_call reasoning, message global.moonshotai.kimi-k3 Moonshot AI bedrock-runtime reasoning, function_call reasoning, message openai.gpt-oss-120b OpenAI bedrock-mantle reasoning, function_call message 4 モデルとも、コードを変えずに get_weather を呼び、その結果から晴れで気温は 18℃ と答えました。 推論の返し方はモデルによって違いました。 Grok 4.7 は推論を暗号化した encrypted_content として返し、Kimi K3 は推論のテキストを content に入れて返しました。 基本のリクエストで GPT-5.6 Sol が返した reasoning アイテムも、Grok 4.7 と同じ暗号化した形でした。 仕様では、生の推論、暗号化した推論、推論の要約のどれを返すかはプロバイダーが選べます。 どのモデルでもアイテムの種類は reasoning なので、クライアントは中身を表示するかどうかだけを決めればよく、次の入力にはアイテムをそのまま積み直せます。 reasoning アイテムの違い(Grok 4.7 と Kimi K3) Grok 4.7 の reasoning アイテムです。 { "content": [], "encrypted_content": "(省略)", "id": "rs_ce42a11eac045f66a050ac8795cd2dee", "summary": [], "type": "reasoning" } Kimi K3 の reasoning アイテムです。 { "content": [ { "text": "The weather tool returned:\n- City: 東京 (Tokyo)\n- Weather: 晴れ (Sunny/Clear)\n- Temperature: 18°C\n\nI should respond in Japanese since the user asked in Japanese. I'll give a natural, friendly response with the weather information.", "type": "reasoning_text" } ], "id": "rs_2709a15cc20a5e899c4d2d98d2c61d68", "summary": [], "type": "reasoning" } 準拠テストを Bedrock に向けて動かす リポジトリの準拠テストは Web 版(openresponses.org)と同じもので、CLI からも実行できます。 ただし WebSocket のテストは、ブラウザから認証ヘッダーを付けられないため、CLI でしか動きません。 実行には Bun が必要で、ベース URL、API キー、モデル名を渡せば動きます。 git clone openresponses/openresponses cd openresponses bun install bun run test:compliance \ --base-url "$OPENAI_BASE_URL" \ --api-key "$OPENAI_API_KEY" \ --model global.openai.gpt-5.6-sol テストは 17 件あり、基本の応答、ストリーミング、システムプロンプト、ツール呼び出し、画像入力、複数ターンの会話に加えて、2026-04-24 バージョンで追加された WebSocket、compact、phase も対象です。 GPT-5.6 Sol では 17 件中 15 件が通り、WebSocket での応答や会話の継続も確認できました。 実行結果(GPT-5.6 Sol、途中経過の表示を省略)Running compliance tests against: bedrock-runtime.us-east-1.amazonaws.com Model: global.openai.gpt-5.6-sol ✓ Response Output Phase Schema ✓ Compaction Missing Required Model (676ms) ✗ WebSocket Missing Previous Response (992ms) [1 events] ✗ Expected previous_response_not_found but got model_not_found ✓ Basic Text Response (1139ms) ✓ Image Input (1150ms) ✓ Multi-turn Conversation (1156ms) ✓ Tool Calling (1158ms) ✓ Streaming Response (1290ms) [22 events] ✓ System Prompt (1283ms) ✓ Assistant Message Phase (1311ms) ✓ Compaction Endpoint (1435ms) ✓ WebSocket Continuation (2336ms) [19 events] ✓ WebSocket Response (2359ms) [13 events] ✓ WebSocket Sequential Responses (2397ms) [18 events] ✗ WebSocket Failed Continuation Evicts Cache (2637ms) [22 events] ✗ Expected previous_response_not_found after failed continuation eviction but got no error code ✓ WebSocket Compact New Chain (3024ms) [10 events] ✓ WebSocket Store False Reconnect Recovery (4157ms) [20 events] ================================================== Results: 15 passed, 2 failed, 0 skipped, 17 total 結果はモデルによって変わり、たとえば Grok 4.7 と Kimi K3 は compact に対応していませんでした。 準拠テストは、使いたいモデルが仕様のどこまでに対応しているかを手元で手軽に確かめる道具として使えます。 まとめ Open Responses は OpenAI の Responses API をもとにした LLM API の共通仕様で、2026年10月22日に AAIF のプロジェクトになりました。 アイテム、状態、イベントの形が決まっているので、接続先とモデル名を変えるだけで、同じクライアントのコードを使い回せます。 Amazon Bedrock では bedrock-runtime と bedrock-mantle の 2 つのエンドポイントで使え、今回は 3 ベンダー・4 モデルで同じエージェントループが動きました。 リポジトリの準拠テストを使えば、モデルごとの対応範囲も手元で確かめられます。 複数のモデルを比べたり切り替えたりする場面で、まず試してみる価値のある仕様だと感じました。 脚注 AWS のブログには Amazon、Anthropic、Meta、Mistral、DeepSeek などのモデルも Responses API で使えると書かれています。ただし Bedrock のドキュメントの対応表では、これらのモデルは Responses API に対応していません。今回の検証でも、Claude Sonnet 5.5、Llama 4 Maverick、Nova Pro、Mistral Large 3、DeepSeek V3.2 への Responses API のリクエストは受け付けられませんでした。Claude Sonnet 5.5 は Messages API で、Mistral Large 3 と DeepSeek V3.2 は Chat Completions で呼び出せます。 ↩︎
AWS Lambda now runs your HTTP server. Do you still need API Gateway? AWS recently announced AWS Lambda Web Functions, a new kind of Lambda function that runs your code as a Node.js HTTP server listening on a port, rather than as a handler receiving an event. To tell the two apart, the Lambda functions you already know are now called Event Functions. AWS says Web Functions suit web applications, API backends and other HTTP workloads with many concurrent requests. Until now, the usual ways to run an API on Lambda have been these: - One function per route behind API Gateway. - The whole app in one function behind an adapter, the Lambdalith. - A function URL in front of one function, with no API Gateway at all. The adapters made that work by turning every event back into a request your framework understands, but you were left with one request at a time per environment, billing for the time spent waiting on I/O and a 6 MB payload limit, which is why Aidan Steele, AWS Hero, calls the approach "always a hack". Even AWS's own Lambda Web Adapter now recommends Web Functions for new Node.js web apps. The one Web Functions-specific detail is the address your server listens on, which Lambda passes as a host and port in the AWS_LAMBDA_HTTP_ENDPOINT environment variable, as in the Express example from the announcement: import express from 'express'; const app = express(); const [host, port] = process.env.AWS_LAMBDA_HTTP_ENDPOINT.split(':'); app.get('/', (req, res) => { res.json({ message: 'Hello from Lambda Web Functions!' }); }); app.listen(port, host, () => { console.log(`Server running on port ${port}`); }); <figcaption> From <a href="aws.amazon.com">Announcing AWS Lambda Web Functions</a>, AWS News Blog. </figcaption> Because a Web Function is a plain Node.js server, local development and testing are simpler than with an Event Function behind API Gateway, where local testing can take an emulator such as sam local start-api, running your functions in a local Docker container. On your laptop nothing sets the variable, so you set it yourself and run the same index.js with Node: AWS_LAMBDA_HTTP_ENDPOINT=127.0.0.1:3000 node index.js What changes in practice is that one environment handles up to 128 requests at once, 64 by default, so a burst of traffic shares warm environments instead of starting one per request. Lambda bills compute for the CPU time your code actually uses, so a request waiting on a database or another API uses little of the CPU you pay for. One caveat: anything your code keeps in a module-level variable is shared by every request in that environment, so a value meant for one request can end up in another. The docs are clear that your code must be safe for concurrent use. Web Functions make me wonder whether it's time to rethink the Lambdalith, which we've long treated as a pattern to avoid. AWS's own Lambda guide lists it among anti-patterns that "work but can increase costs and complexity", and Yan Cui, AWS Serverless Hero, covered its trade-offs and adopted it "where it makes sense". On his podcast, in a conversation recorded before Web Functions existed, he weighed a tailored IAM role on each function and lighter cold starts against how useful one function was for bringing an existing Express app across. His guest, Heitor Lessa, who created Powertools for AWS Lambda while at AWS, said he usually started from one function and split it as the boundaries and the permissions became clear. Web Functions soften the cold-start side of the case for splitting, since environments start less often, but not the permissions side, because every route runs under the same execution role. Still, a Node.js server we'd have put in a container on Fargate could now run as a single Web Function, which makes the Lambdalith look less like an anti-pattern and more like a choice with its own trade-offs. An API Gateway REST API checks API keys, holds each customer to a usage plan and throttles a noisy neighbour without slowing everyone else. A Web Function comes with its own HTTPS endpoint, which does none of that, and its rate limit applies to the whole endpoint in each Region. That leaves the question of whether you still need API Gateway. What goes in front If you need a custom domain, AWS WAF or caching, AWS recommends putting CloudFront or API Gateway in front of the endpoint, since the endpoint itself doesn't support custom domain names or direct AWS WAF association. The endpoint's domain stays reachable from the internet, so clients can go around whatever you put in front unless the endpoint refuses them: !Requests reaching a Web Function endpoint directly, through CloudFront or through API Gateway Nothing in front Each endpoint has an authentication type, a setting that controls which requests reach your code. The default, ApplicationManaged, lets every request through to your code, while IamAuth accepts only requests signed with SigV4 by an IAM identity allowed to invoke the endpoint. An IamAuth endpoint suits calls between services inside AWS, where every client already has an IAM identity, as long as they can share the endpoint's rate limit. Lambda doesn't add CORS headers for Web Functions, so your app answers preflight requests itself. CloudFront CloudFront is another option that lets you make the endpoint refuse requests that don't come through your distribution, using origin access control on an IamAuth endpoint. CloudFront signs every request it sends, though requests signed by any IAM identity your policies allow still get through. To let CloudFront in, you attach this resource-based policy from AWS's CloudFront guide, with aws lambda-web put-resource-policy on the function's ARN, not the endpoint's: { "Version": "2012-10-17", "Statement": [ { "Sid": "AllowCloudFrontOACAccess", "Effect": "Allow", "Principal": { "Service": "cloudfront.amazonaws.com" }, "Action": "lambda:InvokeWebFunctionEndpoint", "Resource": "arn:aws:lambda:us-east-1:111122223333:web-function/my-function/endpoint/prod", "Condition": { "StringEquals": { "aws:SourceArn": "arn:aws:cloudfront::111122223333:distribution/EDFDVBD6EXAMPLE" } } } ] } <figcaption> From <a href="docs.aws.amazon.com">Restrict access to an AWS Lambda Web Function origin</a>, Amazon CloudFront Developer Guide. </figcaption> Your app can still check its own tokens, such as a JWT, because CloudFront adds its signature in a dedicated header and passes the viewer's Authorization header to the origin unmodified, as long as the header is in the cache policy. That also puts the header in the cache key, so with caching on, CloudFront keeps a separate copy of each response per token. AWS WAF on the distribution can limit how often each client calls, by IP address or by a header value over a window of one to ten minutes, though AWS says it's not intended for precise request-rate limiting. A request that leaves the header out isn't evaluated or rate limited. You give up Lambda@Edge origin request and origin response triggers on any cache behaviour that reaches the endpoint through origin access control. CloudFront's signature doesn't cover the body of PUT, POST and PATCH requests either, which the HTTPS connection to the endpoint protects in transit. API Gateway API Gateway reaches the endpoint through a plain HTTP proxy integration, since there's no Web Functions integration. Both REST and HTTP APIs have authorisers, but API keys, usage plans with a quota per day, week or month, request validation and AWS WAF need a REST API. Usage plan throttling and quotas are applied on a best-effort basis, so clients can sometimes go over them. Neither the Lambda nor the API Gateway docs describe a way for API Gateway to sign requests to an IamAuth endpoint, so the endpoint stays ApplicationManaged. Lambda's best practices tell you to verify a shared secret or token in your app instead, and API Gateway can add the secret as a header on every request through parameter mapping. Before you move your API - Node.js 24 is the only runtime. - Web Functions run exclusively on Arm64 and deploy from a .zip file, not a container image. - Each environment gets 2 vCPUs and 2 GB, which you can't change. - Endpoints accept HTTP/1.1 requests only, with no WebSocket upgrades. Running it has a few gotchas: - Lambda doesn't check that your server listens when you deploy, so a revision can show Active while every request fails. - Web Function metrics don't include a revision dimension, so during a canary you can't compare the two revisions in those metrics. - Outbound requests come from an address that isn't stable, so a partner's allow list needs an egress network connector into your VPC and a NAT gateway. - On top of CPU time, you pay for memory over the life of each execution environment, idle time between requests included. If your API isn't on Express, Eric Johnson, Principal Developer Advocate at AWS, has recently written a pattern-driven tour of seven protocols and frameworks on Web Functions, including server-sent events, gRPC-Web and signed webhooks, with working, deployable code for every pattern.