Lambda Web Functions の VPC 接続を試してみた

Lambda Web Functions の VPC 接続を試してみた

cdkd で VPC、Lambda Web Functions、Network Connector をデプロイし、複数の Web Functions でコネクタを共用して VPC 内のリソースに接続できることを確かめました。
2026.10.14

はじめに

2026年10月に GA になった Lambda Web Functions は、HTTP リクエストを直接受け取って処理する新しいタイプの Lambda 関数です。

https://dev.classmethod.jp/articles/aws-lambda-web-functions-ga-express/

Lambda Web Functions から VPC 内のリソースに接続する場合は、Web Function とは別のリソースである Network Connector(以下コネクタ)を使います。

https://docs.aws.amazon.com/lambda/latest/dg/web-functions-networking.html

この記事では、複数の Web Function(CheckIP、STS、MySQL)に共通のコネクタを付けて cdkd でデプロイしました。NAT 経由の IP アドレスの確認、STS の呼び出し、VPC 内 MySQL への接続を確かめました。

Network Connector について

Network Connector は、Lambda MicroVMs の VPC Egress コネクタと同じリソース種別(AWS::Lambda::NetworkConnector)です。

https://dev.classmethod.jp/articles/lambda-microvms-vpc-egress-connector-private-resource/

従来の 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)

検証内容

構成

構成図: Web Function が Network Connector 経由で 2 AZ のプライベートサブネットの ENI に接続し、NAT インスタンス経由でインターネットへ、同じ VPC の EC2 (MySQL) へ通信する

コネクタは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 など)へアクセスしたい場合は、コネクタの利用をお試しください。

この記事をシェアする

AWSのお困り事はクラスメソッドへ

関連記事