
Lambda Web Functions の VPC 接続を試してみた
はじめに
2026年10月に GA になった Lambda Web Functions は、HTTP リクエストを直接受け取って処理する新しいタイプの Lambda 関数です。
Lambda Web Functions から VPC 内のリソースに接続する場合は、Web Function とは別のリソースである Network Connector(以下コネクタ)を使います。
この記事では、複数の Web Function(CheckIP、STS、MySQL)に共通のコネクタを付けて cdkd でデプロイしました。NAT 経由の IP アドレスの確認、STS の呼び出し、VPC 内 MySQL への接続を確かめました。
Network Connector について
Network Connector は、Lambda MicroVMs の VPC Egress コネクタと同じリソース種別(AWS::Lambda::NetworkConnector)です。
従来の VPC Lambda は、関数自身の VpcConfig にサブネットとセキュリティグループを指定する方式でした。
Web Functions と MicroVMs は、それぞれに対応したコネクタを作成します。Web Functions のコネクタは、別の Web Function とも共用できます。
| Web Functions | Lambda MicroVMs | 従来の VPC Lambda | |
|---|---|---|---|
| 接続の設定 | Network Connector | Network Connector | 関数の VpcConfig |
| 付け方 | Revision の ServiceConfig.EgressNetworkConnectorArn にバージョン付き ARN を1つ | run-microvm の --egress-network-connectors にコネクタの ARN | 関数にサブネットとセキュリティグループを指定 |
| ENI の権限 | コネクタの OperatorRole | コネクタの OperatorRole | 関数の実行ロール(AWSLambdaVPCAccessExecutionRole) |
検証内容
構成

コネクタは1つ作成し、各 Web Function の Revision からコネクタのバージョン付き ARN(<コネクタの ARN>:<バージョン番号>)で参照します。1つの Revision が参照できるコネクタは1つです。コネクタを参照する Revision に対応するエンドポイントタイプは HomeRegion です。
インターネットへ出られるように、プライベートサブネットのデフォルトルートを NAT インスタンスに向けています。
MySQL(8.4)は同じ VPC 内のプライベートサブネット上の EC2 に置きます。MySQL のインストールと、データベース demo・ユーザー app の作成は、デプロイ後に EC2 上で別途行いました。MySQL のセキュリティグループは、コネクタのセキュリティグループからの 3306/TCP だけを許可します。
ネットワーク側(lib/net-stack.ts)の定義は末尾の「コード全文」を参照してください。
コネクタ付きの Web Function を CDK で定義する
コネクタは、OperatorRole と CfnNetworkConnector で定義します(lib/app-stack.ts)。OperatorRole の信頼ポリシーは、ドキュメントの例と同じく、Lambda サービスに引き受けを許可する形です。
信頼ポリシーに aws:SourceAccount 条件を足したロールでは、コネクタの作成が次のエラーで拒否されました。条件を外すと作成できました。
The service is unable to assume the provided NetworkConnectorOperatorRole. Please verify the trust policy on the role.
OperatorRole の定義
const operatorRole = new iam.Role(this, 'ConnectorOperatorRole', {
assumedBy: new iam.ServicePrincipal('lambda.amazonaws.com'),
inlinePolicies: {
eni: new iam.PolicyDocument({
statements: [
new iam.PolicyStatement({
sid: 'CreateENI',
actions: ['ec2:CreateNetworkInterface'],
resources: ['arn:aws:ec2:*:*:network-interface/*', 'arn:aws:ec2:*:*:subnet/*', 'arn:aws:ec2:*:*:security-group/*'],
}),
new iam.PolicyStatement({
sid: 'TagENI',
actions: ['ec2:CreateTags'],
resources: ['arn:aws:ec2:*:*:network-interface/*'],
conditions: { StringEquals: { 'ec2:ManagedResourceOperator': 'network-connectors.lambda.amazonaws.com' } },
}),
],
}),
},
});
コネクタ本体は次のように定義します。
const nc = new lambda.CfnNetworkConnector(this, 'Connector', {
operatorRole: operatorRole.roleArn,
configuration: {
vpcEgressConfiguration: {
associatedComputeResourceTypes: ['WebFunction'],
networkProtocol: 'IPv4',
securityGroupIds: [net.connectorSg.securityGroupId],
subnetIds: net.vpc.isolatedSubnets.map((s) => s.subnetId),
},
},
});
nc.node.addDependency(operatorRole);
Revision は、コネクタの状態が PENDING から ACTIVE になった後で参照できます。cdkd は ACTIVE になるまで待ってから Revision を作成しました。
3つの Web Function には、同じコネクタのバージョン付き ARN を渡します。aws-cdk-lib 2.273.0 の L1(CfnWebFunctionRevision)は、コネクタの ARN を指定するプロパティに未対応です。そのため addPropertyOverride で ServiceConfig.EgressNetworkConnectorArn に渡します。
revision.addPropertyOverride('ServiceConfig.EgressNetworkConnectorArn', nc.attrLatestVersionArn);
revision.node.addDependency(nc);
エンドポイントは、認証なしの検証用として authType を ApplicationManaged にしました。
const endpoint = new lambda.CfnWebFunctionEndpoint(this, `${key}Endpoint`, {
functionName: fn.ref,
endpointName: 'main',
endpointType: 'HomeRegion',
authType: 'ApplicationManaged',
revisionWeights: [{ revisionId: revision.attrRevisionId, weight: 100 }],
});
cdkd でデプロイする
VPC、コネクタ、Web Function を作成する CDK スタックを用意しました。スタックは、VPC と NAT インスタンスの WebFnVpcNet と、コネクタと Web Function の WebFnVpcApp の2つです。デプロイには cdkd 0.299.3 を利用しました。コマンドは末尾の「コード全文」にあります。
WebFnVpcNet のデプロイは 87.43 秒でした。コネクタを新規作成する WebFnVpcApp のデプロイは ACTIVE 待ちを含めて 290.01 秒でした。コネクタがある状態で3つの Web Function を追加したデプロイは 49.45 秒でした。
動作確認
3つの Web Function はそれぞれ別のエンドポイントを持ちます。各エンドポイントへ curl でリクエストしました。
curl https://<endpoint-domain>/
NAT 経由の IP アドレス(CheckIP)
CheckIP の関数が返した IP は、NAT インスタンスのパブリック IP と一致しました。
{
"function": "checkip",
"checkip": "203.0.113.10",
"elapsedMs": 259
}
STS
STS の関数が GetCallerIdentity を呼び、応答が返りました。この VPC にある VPC エンドポイントは S3 と DynamoDB のゲートウェイ型だけなので、STS へのリクエストは NAT 経由で届いています。
{
"function": "sts",
"Account": "<ACCOUNT>",
"Arn": "arn:aws:sts::<ACCOUNT>:assumed-role/WebFnVpcApp-ExecRole372216D6/webfn-sts",
"UserId": "<ROLEID>:webfn-sts",
"elapsedMs": 49
}
VPC 内の MySQL
プライベートサブネットの EC2 に MySQL をインストールして DB を初期設定し、同じコネクタ経由で接続できました。1回目の応答は次のとおりです。
{
"function": "db",
"visits": 1,
"mysqlVersion": "8.4.11",
"dbHost": "<db-host>",
"elapsedMs": 61
}
2回目の呼び出しでは visits が 2 になりました。
コネクタの共用
webfn-checkip、webfn-sts、webfn-db の各 Revision(状態は Active)は、同じコネクタのバージョン1の ARN を参照していました。
コード全文
全ファイルのコード
lib/app-stack.ts
import * as cdk from 'aws-cdk-lib';
import * as iam from 'aws-cdk-lib/aws-iam';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as s3 from 'aws-cdk-lib/aws-s3';
import * as s3deploy from 'aws-cdk-lib/aws-s3-deployment';
import { Construct } from 'constructs';
import { NetStack } from './net-stack';
interface AppStackProps extends cdk.StackProps {
net: NetStack;
}
// Three Web Functions (checkip / sts / db) share ONE Network Connector.
export class AppStack extends cdk.Stack {
constructor(scope: Construct, id: string, props: AppStackProps) {
super(scope, id, props);
const { net } = props;
const dbPassword = process.env.WEBFN_DB_PASSWORD;
// Code bucket: versioning + bucket policy for lambda.amazonaws.com (Web Functions requirement)
const codeBucket = new s3.Bucket(this, 'CodeBucket', {
versioned: true,
removalPolicy: cdk.RemovalPolicy.DESTROY,
autoDeleteObjects: true,
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
});
codeBucket.addToResourcePolicy(new iam.PolicyStatement({
principals: [new iam.ServicePrincipal('lambda.amazonaws.com')],
actions: ['s3:GetObject', 's3:GetObjectVersion'],
resources: [codeBucket.arnForObjects('*')],
conditions: { ArnLike: { 'aws:SourceArn': `arn:${this.partition}:lambda:${this.region}:${this.account}:web-function/*` } },
}));
const execRole = new iam.Role(this, 'ExecRole', {
assumedBy: new iam.ServicePrincipal('lambda.amazonaws.com'),
managedPolicies: [iam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSLambdaBasicExecutionRole')],
});
// The single shared connector (private subnets of both AZs; default route -> NAT instance)
const operatorRole = new iam.Role(this, 'ConnectorOperatorRole', {
assumedBy: new iam.ServicePrincipal('lambda.amazonaws.com'),
inlinePolicies: {
eni: new iam.PolicyDocument({
statements: [
new iam.PolicyStatement({
sid: 'CreateENI',
actions: ['ec2:CreateNetworkInterface'],
resources: ['arn:aws:ec2:*:*:network-interface/*', 'arn:aws:ec2:*:*:subnet/*', 'arn:aws:ec2:*:*:security-group/*'],
}),
new iam.PolicyStatement({
sid: 'TagENI',
actions: ['ec2:CreateTags'],
resources: ['arn:aws:ec2:*:*:network-interface/*'],
conditions: { StringEquals: { 'ec2:ManagedResourceOperator': 'network-connectors.lambda.amazonaws.com' } },
}),
],
}),
},
});
const nc = new lambda.CfnNetworkConnector(this, 'Connector', {
operatorRole: operatorRole.roleArn,
configuration: {
vpcEgressConfiguration: {
associatedComputeResourceTypes: ['WebFunction'],
networkProtocol: 'IPv4',
securityGroupIds: [net.connectorSg.securityGroupId],
subnetIds: net.vpc.isolatedSubnets.map((s) => s.subnetId),
},
},
});
nc.node.addDependency(operatorRole);
const makeWebFunction = (key: string, functionName: string, codeDir: string, env?: Record<string, string>) => {
const deployment = new s3deploy.BucketDeployment(this, `${key}Code`, {
sources: [s3deploy.Source.asset(codeDir)],
destinationBucket: codeBucket,
extract: false,
prune: false,
});
const fn = new lambda.CfnWebFunction(this, `${key}Fn`, { functionName });
const revision = new lambda.CfnWebFunctionRevision(this, `${key}Revision`, {
functionName: fn.ref,
buildConfig: {
codeConfig: {
s3Object: {
bucket: codeBucket.bucketName,
key: cdk.Fn.select(0, deployment.objectKeys),
versionId: cdk.Fn.select(0, deployment.objectVersionIds),
},
},
runtimeConfig: { runtime: 'nodejs24.x' },
},
serviceConfig: { executionRoleArn: execRole.roleArn, timeoutSeconds: 30, ...(env ? { environmentVariables: env } : {}) },
});
// The L1 in aws-cdk-lib 2.273.0 has no egressNetworkConnectorArn; use a property override.
// Same version-qualified ARN for all three functions.
revision.addPropertyOverride('ServiceConfig.EgressNetworkConnectorArn', nc.attrLatestVersionArn);
revision.node.addDependency(nc);
revision.node.addDependency(codeBucket.policy!);
const endpoint = new lambda.CfnWebFunctionEndpoint(this, `${key}Endpoint`, {
functionName: fn.ref,
endpointName: 'main',
endpointType: 'HomeRegion',
authType: 'ApplicationManaged',
revisionWeights: [{ revisionId: revision.attrRevisionId, weight: 100 }],
});
new cdk.CfnOutput(this, `${key}EndpointDomain`, { value: endpoint.attrDomainName });
};
makeWebFunction('CheckIp', 'webfn-checkip', 'app-checkip');
makeWebFunction('Sts', 'webfn-sts', 'app-sts');
if (dbPassword && net.dbPrivateIp) {
// Test-only: throw-away password passed at deploy time (env WEBFN_DB_PASSWORD), never committed
makeWebFunction('Db', 'webfn-db', 'app-db', { DB_HOST: net.dbPrivateIp, DB_PASSWORD: dbPassword });
}
new cdk.CfnOutput(this, 'ConnectorVersionArn', { value: nc.attrLatestVersionArn });
}
}
lib/net-stack.ts
import * as cdk from 'aws-cdk-lib';
import * as ec2 from 'aws-cdk-lib/aws-ec2';
import * as iam from 'aws-cdk-lib/aws-iam';
import { Construct } from 'constructs';
export class NetStack extends cdk.Stack {
public readonly vpc: ec2.Vpc;
public readonly connectorSg: ec2.SecurityGroup;
public dbPrivateIp?: string;
constructor(scope: Construct, id: string, props: cdk.StackProps) {
super(scope, id, props);
// 192.168.0.0/20, 2 AZ x (public /24 + private /24). No NAT Gateway: the NAT instance is the default route.
this.vpc = new ec2.Vpc(this, 'Vpc', {
ipAddresses: ec2.IpAddresses.cidr('192.168.0.0/20'),
maxAzs: 2,
natGateways: 0,
subnetConfiguration: [
{ name: 'public', subnetType: ec2.SubnetType.PUBLIC, cidrMask: 24 },
{ name: 'private', subnetType: ec2.SubnetType.PRIVATE_ISOLATED, cidrMask: 24 },
],
gatewayEndpoints: {
S3: { service: ec2.GatewayVpcEndpointAwsService.S3 },
DynamoDB: { service: ec2.GatewayVpcEndpointAwsService.DYNAMODB },
},
});
const natSg = new ec2.SecurityGroup(this, 'NatSg', { vpc: this.vpc, description: 'NAT instance', allowAllOutbound: true });
natSg.addIngressRule(ec2.Peer.ipv4(this.vpc.vpcCidrBlock), ec2.Port.allTraffic(), 'all from VPC');
const nat = new ec2.Instance(this, 'NatInstanceV2', {
vpc: this.vpc,
vpcSubnets: { subnetType: ec2.SubnetType.PUBLIC, availabilityZones: [this.vpc.availabilityZones[0]] },
instanceType: new ec2.InstanceType('t8i.nano'),
machineImage: ec2.MachineImage.latestAmazonLinux2023(),
securityGroup: natSg,
sourceDestCheck: false,
associatePublicIpAddress: true,
userData: ec2.UserData.custom([
'#!/bin/bash',
'exec > /var/log/nat-setup.log 2>&1',
'trap "cat /var/log/nat-setup.log > /dev/console" EXIT',
'set -x',
// t8i.nano has 0.5 GiB: dnf is OOM-killed without swap
'dd if=/dev/zero of=/swapfile bs=1M count=512',
'chmod 600 /swapfile && mkswap /swapfile && swapon /swapfile',
'free -m',
'dnf install -y iptables-nft',
'echo "net.ipv4.ip_forward = 1" > /etc/sysctl.d/99-nat.conf',
'sysctl --system',
'IF=$(ip -o -4 route show to default | awk \'{print $5}\' | head -1)',
'iptables -t nat -A POSTROUTING -o "$IF" -s 192.168.0.0/20 -j MASQUERADE',
'iptables -t nat -L -n -v',
].join('\n')),
});
// Default route of every private subnet -> NAT instance
this.vpc.isolatedSubnets.forEach((subnet, i) => {
new ec2.CfnRoute(this, `PrivateDefaultRoute${i}`, {
routeTableId: subnet.routeTable.routeTableId,
destinationCidrBlock: '0.0.0.0/0',
instanceId: nat.instanceId,
});
});
this.connectorSg = new ec2.SecurityGroup(this, 'ConnectorSg', { vpc: this.vpc, description: 'Network Connector ENIs', allowAllOutbound: true });
// MySQL host (only when WEBFN_DB_PASSWORD is set): private subnet, reached from the Network Connector ENIs only.
if (process.env.WEBFN_DB_PASSWORD) {
const dbSg = new ec2.SecurityGroup(this, 'DbSg', { vpc: this.vpc, description: 'MySQL', allowAllOutbound: true });
dbSg.addIngressRule(this.connectorSg, ec2.Port.tcp(3306), 'MySQL from Web Function connector');
const dbRole = new iam.Role(this, 'DbRole', {
assumedBy: new iam.ServicePrincipal('ec2.amazonaws.com'),
managedPolicies: [iam.ManagedPolicy.fromAwsManagedPolicyName('AmazonSSMManagedInstanceCore')],
});
const db = new ec2.Instance(this, 'DbInstance', {
vpc: this.vpc,
vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_ISOLATED, availabilityZones: [this.vpc.availabilityZones[0]] },
instanceType: new ec2.InstanceType('t3.small'),
machineImage: ec2.MachineImage.latestAmazonLinux2023(),
securityGroup: dbSg,
role: dbRole,
});
// SSM agent and dnf need the NAT instance route first
this.vpc.isolatedSubnets.forEach((_, i) => db.node.addDependency(this.node.findChild(`PrivateDefaultRoute${i}`)));
this.dbPrivateIp = db.instancePrivateIp;
new cdk.CfnOutput(this, 'DbInstanceId', { value: db.instanceId });
new cdk.CfnOutput(this, 'DbPrivateIp', { value: db.instancePrivateIp });
}
new cdk.CfnOutput(this, 'NatPublicIp', { value: nat.instancePublicIp });
new cdk.CfnOutput(this, 'NatInstanceId', { value: nat.instanceId });
}
}
app-src/checkip.ts
import http from 'node:http';
// Web Function 1: outbound IP as seen from the internet (CheckIP). Port 3000 is where Lambda sends requests.
const port = Number(process.env.LOCAL_PORT ?? 3000);
http.createServer(async (_req, res) => {
const started = Date.now();
res.setHeader('content-type', 'application/json');
try {
const r = await fetch('https://checkip.amazonaws.com', { signal: AbortSignal.timeout(8000) });
res.end(JSON.stringify({ function: 'checkip', checkip: (await r.text()).trim(), elapsedMs: Date.now() - started }, null, 2));
} catch (e) {
res.statusCode = 502;
res.end(JSON.stringify({ function: 'checkip', error: (e as Error).name, message: (e as Error).message, elapsedMs: Date.now() - started }, null, 2));
}
}).listen(port, () => console.log(`listening on port ${port}`));
app-src/sts.ts
import http from 'node:http';
import { STSClient, GetCallerIdentityCommand } from '@aws-sdk/client-sts';
// Web Function 2: STS GetCallerIdentity over the internet (no STS VPC endpoint exists).
const port = Number(process.env.LOCAL_PORT ?? 3000);
const sts = new STSClient({ region: process.env.AWS_REGION ?? 'ap-northeast-1' });
http.createServer(async (_req, res) => {
const started = Date.now();
res.setHeader('content-type', 'application/json');
try {
const id = await sts.send(new GetCallerIdentityCommand({}), { abortSignal: AbortSignal.timeout(8000) });
res.end(JSON.stringify({ function: 'sts', Account: id.Account, Arn: id.Arn, UserId: id.UserId, elapsedMs: Date.now() - started }, null, 2));
} catch (e) {
res.statusCode = 502;
res.end(JSON.stringify({ function: 'sts', error: (e as Error).name, message: (e as Error).message, elapsedMs: Date.now() - started }, null, 2));
}
}).listen(port, () => console.log(`listening on port ${port}`));
app-src/db.ts
import http from 'node:http';
import mysql from 'mysql2/promise';
// Web Function 3: MySQL on an EC2 instance in the private subnet (VPC-internal traffic only).
const port = Number(process.env.LOCAL_PORT ?? 3000);
http.createServer(async (req, res) => {
const started = Date.now();
res.setHeader('content-type', 'application/json');
try {
const conn = await mysql.createConnection({
host: process.env.DB_HOST, user: 'app', password: process.env.DB_PASSWORD, database: 'demo', connectTimeout: 5000,
});
try {
await conn.query('CREATE TABLE IF NOT EXISTS visits (id INT AUTO_INCREMENT PRIMARY KEY, at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, src VARCHAR(64))');
await conn.query('INSERT INTO visits (src) VALUES (?)', [String(req.headers['x-forwarded-for'] ?? 'unknown').slice(0, 64)]);
const [rows] = await conn.query('SELECT COUNT(*) AS visits, VERSION() AS mysqlVersion, @@hostname AS dbHost FROM visits');
res.end(JSON.stringify({ function: 'db', ...(rows as Record<string, unknown>[])[0], elapsedMs: Date.now() - started }, null, 2));
} finally {
await conn.end();
}
} catch (e) {
res.statusCode = 502;
res.end(JSON.stringify({ function: 'db', error: (e as Error).name, message: (e as Error).message, elapsedMs: Date.now() - started }, null, 2));
}
}).listen(port, () => console.log(`listening on port ${port}`));
bin/app.ts
import * as cdk from 'aws-cdk-lib';
import { NetStack } from '../lib/net-stack';
import { AppStack } from '../lib/app-stack';
const app = new cdk.App();
const env = { account: process.env.CDK_DEFAULT_ACCOUNT, region: 'ap-northeast-1' };
const net = new NetStack(app, 'WebFnVpcNet', { env });
new AppStack(app, 'WebFnVpcApp', { env, net });
build-app.sh
#!/bin/bash
# Bundle each app-src/<name>.ts into app-<name>/index.js (CJS, dependencies included) inside Docker.
set -euo pipefail
cd "$(dirname "$0")"
for n in checkip sts db; do
docker run --rm --user "$(id -u):$(id -g)" -e HOME=/tmp -v "$PWD:/app" -w /app node:22 \
npx esbuild "app-src/$n.ts" --bundle --platform=node --target=node22 --format=cjs --outfile="app-$n/index.js"
done
デプロイ
# 3つの関数のコードをバンドルする
./build-app.sh
WEBFN_DB_PASSWORD=<password> cdkd deploy --all --yes
まとめ
Lambda Web Function に、CDK(L1)で VPC 対応のコネクタを設定し、NAT 経由のインターネットへの通信と、VPC 内のデータベースへのアクセスを確認できました。
コネクタの作成には数分の待ち時間がありますが、用意済みのコネクタを複数の Web Function で共用すれば、デプロイの待ち時間の削減が期待できます。
Web Function から VPC 内のリソース(RDS、ElastiCache など)へアクセスしたい場合は、コネクタの利用をお試しください。








