
MultiRegion な Lambda Web Functions を OAC 付きの CloudFront で公開する構成を試してみた
はじめに
Lambda Web Functions が 2026年10月に GA となりました。MultiRegion エンドポイントを使うと、複数リージョンにデプロイした関数のうち、遅延が最も小さいリージョンの関数へリクエストをルーティングできます。
この記事は上の記事の続編です。MultiRegion エンドポイントのグローバル URL を、OAC 付きの CloudFront のオリジンにして公開しました。さらに、東京リージョンの障害時に、日本国内からのリクエストを CloudFront のオリジンフェイルオーバーでバージニアのリージョナルエンドポイントへ切り替える方法を試しました。
MultiRegion エンドポイントを CloudFront で公開する
Lambda Web Functions
ホストリージョンを ap-northeast-1 として関数を作成し、複数リージョンを対象とする認証タイプ IamAuth の MultiRegion エンドポイントを設定しました。作成手順は前の記事と同じです。
最寄りのリージョンへのルーティングは、前の記事と同じ 5 リージョン(ap-northeast-1、us-east-1、us-east-2、us-west-2、eu-west-1)で確認しました。東京の障害を想定した検証では、対象を東京とバージニアの 2 リージョンにして作り直した関数を使いました。
OAC
IAM 認証(IamAuth)の Lambda Web Functions を OAC で保護する場合、PerRegion エンドポイントには SigV4、MultiRegion エンドポイントには SigV4a を指定します。
リソースポリシーの設定は、次の記事で紹介した Lambda Web Functions を CloudFront OAC の背後に置く構成と同じです。
この記事で異なるのは、MultiRegion エンドポイントに対応するため、OAC の signingProtocol に sigv4a を指定する点です。
const oac = (oid: string, proto: string) => new cloudfront.CfnOriginAccessControl(this, oid, {
originAccessControlConfig: {
name: `${this.stackName}-${proto}`, originAccessControlOriginType: 'lambda',
signingBehavior: 'always-amz-auth', signingProtocol: proto,
},
});
const oacV4a = oac('OacSigv4a', 'sigv4a');
CloudFront
グローバル URL を CloudFront のオリジンに指定し、sigv4a の OAC を付けました。ビヘイビアのキャッシュポリシーには CachingDisabled を設定しました。オリジンリクエストポリシーには、Host を転送しない AllViewerExceptHostHeader を設定しました。
TTFB の計測
このディストリビューションに、Route 53 ヘルスチェッカー 8 地点と CloudShell 3 地点からアクセスし、Lambda のログから応答したリージョンを特定しました。各地点のすべてのリクエストは、同じリージョンに届きました。
| ヘルスチェッカー | 応答したリージョン | TTFB 1 回目(ms) | TTFB 2 回目(ms) |
|---|---|---|---|
| ap-northeast-1 | ap-northeast-1 | 30.6 | 39.6 |
| ap-southeast-1 | ap-northeast-1 | 255.8 | 257.9 |
| ap-southeast-2 | ap-northeast-1 | 356.7 | 369.1 |
| us-east-1 | us-east-1 | 57.3 | 71.3 |
| sa-east-1 | us-east-1 | 328.1 | 386.9 |
| us-west-1 | us-west-2 | 101.4 | 93.4 |
| us-west-2 | us-west-2 | 121.8 | 114.7 |
| eu-west-1 | eu-west-1 | 60.0 | 56.1 |
ヘルスチェッカーは 30 秒間隔で、5 分間の測定を 2 回行いました。各回の TTFB は、60 秒ごとの平均値 5 点の平均です。
| CloudShell | 応答したリージョン | TTFB(ms) |
|---|---|---|
| 東京 | ap-northeast-1 | 27 |
| バージニア | us-east-1 | 45 |
| フランクフルト | eu-west-1 | 44 |
CloudShell では、1 秒間隔で curl を 10 回実行し、TTFB はその中央値です。
CloudFront 経由でも、11 地点すべてで最寄りのリージョンが応答していることを確認できました。
オリジンフェイルオーバーの設定
グローバル URL は 503 を返す東京へ振り分け続ける
MultiRegion エンドポイントは 1 つのグローバルドメインを持ち、リクエストを遅延が最も小さいリージョンに振り分けます。リージョンの健全性を考慮して振り分けるかどうかを確かめるため、東京の Lambda が 10 分間 503 を返す状態を作りました。日本国内の検証ホストから、CloudFront を通さず東京、グローバル、バージニアの各 URL へアクセスしました。
| URL | ステータス | 応答したリージョン |
|---|---|---|
| 東京のリージョン別 | 503 | ap-northeast-1 |
| グローバル | 503 | ap-northeast-1 |
| バージニアのリージョン別 | 200 | us-east-1 |
グローバル URL へのリクエスト 104 回は、すべて東京に届いて 503 が返りました。
CloudFront のオリジンフェイルオーバー設定
東京リージョンの Lambda Web Functions の障害に備え、バージニアの PerRegion エンドポイントへ退避するオリジンフェイルオーバーの設定を試しました。
- 東京とバージニアを対象にした PerRegion エンドポイントを同じ関数に追加し、バージニアのドメインをセカンダリのオリジンにする
- セカンダリのオリジン用に sigv4 の OAC を追加(プライマリは sigv4a の OAC)
- リソースポリシーの Resource に PerRegion エンドポイントの ARN を追加
- オリジングループを追加(プライマリはグローバル、セカンダリはバージニア、フェイルオーバー条件は 500/502/503/504)
- ビヘイビアの allowedMethods と cachedMethods に OPTIONS を追加
フェイルオーバーの対象は GET、HEAD、OPTIONS のリクエストのみです。cachedMethods に OPTIONS を含めないと、フェイルオーバーは機能しません。
東京リージョンの Lambda Web Functions が 503 を返す場合と、応答が遅れる場合に、日本国内の検証ホストからのリクエストがバージニアへ切り替わることを確認できました。
東京の 503 での切り替え
グローバル URL だけをオリジンにした構成と、バージニアをセカンダリに追加したオリジンフェイルオーバーありの構成を比較しました。
| 時間帯 | オリジンフェイルオーバー無効 | オリジンフェイルオーバー有効 |
|---|---|---|
| 停止前 | 200(ap-northeast-1)、62 ms | 200(ap-northeast-1)、67 ms |
| 停止中 | 503(ap-northeast-1)、63 ms | 200(us-east-1)、551 ms |
| 復旧後 | 200(ap-northeast-1)、60 ms | 200(ap-northeast-1)、65 ms |
東京の Lambda が 10 分間 503 を返した時間帯を「停止中」とし、その前後を含めて測定しました。停止中の測定回数は各 106 回で、TTFB は中央値です。
オリジンフェイルオーバー有効では、停止開始後の最初のリクエストからバージニアが 200 を返し、復旧後の最初のリクエストから東京に戻りました。停止中の TTFB が長いのは、CloudFront が東京の 503 を受け取った後、バージニアへリクエストし直すためです。
東京の応答遅延での切り替え
応答遅延時の切り替え時間は、おおむね「応答タイムアウト × 接続の試行回数」です。今回はフェイルオーバー条件に 504 を含め、応答タイムアウトを 3 秒、接続の試行回数を 1 回に設定しました。
| オリジンフェイルオーバー | 応答 | 合計時間(秒) |
|---|---|---|
| 無効 | 504 | 3.05 |
| 有効 | 200(us-east-1) | 3.53 |
東京の Lambda が 10 分間、リクエストごとに 5 秒待ってから 200 を返す状態にして、curl の合計時間を測定しました。表の値は遅延中の 52 回の中央値です。
プライマリオリジンの応答が CloudFront の応答タイムアウトを超えると、CloudFront はバージニアのセカンダリオリジンへフェイルオーバーして応答を返しました。
まとめ
OAC を設定した CloudFront のオリジンにグローバル URL を指定し、11 地点すべてで最寄りのリージョンの関数にルーティングされることを確認しました。
一方、グローバル URL はリージョンの健全性を考慮しません。東京の Lambda が 503 を返す間も、日本国内の検証ホストからのリクエストは東京に振り分けられ続けました。
この課題に対して、グローバル URL をプライマリ、バージニア(us-east-1)のリージョン別 URL をセカンダリとする CloudFront のオリジンフェイルオーバーを構成しました。東京の Lambda が 503 を返す場合と、応答遅延によって CloudFront が 504 を返す場合のどちらでも、バージニアへ切り替わることを確認しました。
ただし、フェイルオーバー時にはプライマリの応答を待つ時間が加わります。現構成では、セカンダリのバージニアに障害が起きても、別のリージョンへフェイルオーバーできません。待ち時間と可用性のトレードオフを踏まえ、より望ましい構成を今後も検討したいと思います。
検証に使ったコード
検証に使ったコード全文(CDK スタック、障害を与えるアプリ、ビルドスクリプト)
デプロイには CDKD 0.299.3、aws-cdk-lib 2.273.0 を使いました。
lib/focf-stack.ts
import * as cdk from 'aws-cdk-lib';
import * as cloudfront from 'aws-cdk-lib/aws-cloudfront';
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';
const CACHING_DISABLED = '4135ea2d-6df8-44a3-9df3-4b5a84be39ad'; // Managed-CachingDisabled
const ALL_VIEWER_EXCEPT_HOST = 'b689b0a8-53d0-40ab-baf2-68738e2966ac'; // Managed-AllViewerExceptHostHeader
// One Web Function (IamAuth) with a MultiRegion endpoint (Tokyo + Virginia) and a PerRegion endpoint, behind CloudFront (OAC).
// Origin group. primary = global endpoint (sigv4a OAC), secondary = Virginia regional endpoint (sigv4 OAC). Fails over on 500/502/503/504.
// Global only. global endpoint only (no origin group).
// The app (app-fail/) answers 503 in Tokyo during a time window baked in at build time, or waits and then answers 200 during the window.
export class FoCfStack extends cdk.Stack {
constructor(scope: Construct, id: string, props: cdk.StackProps) {
super(scope, id, props);
const regions = ['ap-northeast-1', 'us-east-1'];
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')],
});
const deployment = new s3deploy.BucketDeployment(this, 'Code', {
sources: [s3deploy.Source.asset('app-fail')], destinationBucket: codeBucket, extract: false, prune: false,
});
const fn = new lambda.CfnWebFunction(this, 'Fn', { functionName: 'webfn-fo-cf' });
const revision = new lambda.CfnWebFunctionRevision(this, '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 },
});
revision.node.addDependency(codeBucket.policy!);
const mk = (eid: string, name: string, type: 'MultiRegion' | 'PerRegion') => new lambda.CfnWebFunctionEndpoint(this, eid, {
functionName: fn.ref, endpointName: name, endpointType: type, regions, authType: 'IamAuth',
revisionWeights: [{ revisionId: revision.attrRevisionId, weight: 100 }],
});
const global = mk('EndpointGlobal', 'global', 'MultiRegion');
const regional = mk('EndpointRegional', 'regional', 'PerRegion');
regional.addDependency(global); // create one after the other
const virginiaDomain = cdk.Fn.getAtt(regional.logicalId, 'RegionalEndpoints.us-east-1.DomainName').toString();
const oac = (oid: string, proto: string) => new cloudfront.CfnOriginAccessControl(this, oid, {
originAccessControlConfig: {
name: `${this.stackName}-${proto}`, originAccessControlOriginType: 'lambda',
signingBehavior: 'always-amz-auth', signingProtocol: proto,
},
});
const oacV4a = oac('OacSigv4a', 'sigv4a');
const oacV4 = oac('OacSigv4', 'sigv4');
// connection attempts (1-3). For a custom origin it also sets how many times CloudFront tries to get a response after an origin response timeout. Default 3.
const ATTEMPTS = 1;
const ORIGIN_CONFIG = { httpPort: 80, httpsPort: 443, originProtocolPolicy: 'https-only', originSslProtocols: ['TLSv1.2'], originReadTimeout: 3 };
const globalOrigin = { id: 'global-origin', domainName: global.attrDomainName, originAccessControlId: oacV4a.attrId, customOriginConfig: ORIGIN_CONFIG, connectionAttempts: ATTEMPTS, connectionTimeout: 10 };
const virginiaOrigin = { id: 'virginia-origin', domainName: virginiaDomain, originAccessControlId: oacV4.attrId, customOriginConfig: ORIGIN_CONFIG, connectionAttempts: ATTEMPTS, connectionTimeout: 10 };
const common = { compress: false, cachePolicyId: CACHING_DISABLED, originRequestPolicyId: ALL_VIEWER_EXCEPT_HOST, viewerProtocolPolicy: 'redirect-to-https' };
// Origin group (OPTIONS must be a cached method, otherwise CloudFront does not fail over)
const distOriginGroup = new cloudfront.CfnDistribution(this, 'DistOriginGroup', {
distributionConfig: {
enabled: true, comment: `${this.stackName} Origin group (primary global, secondary Virginia)`,
origins: [globalOrigin, virginiaOrigin],
originGroups: { quantity: 1, items: [{
id: 'og-global-virginia',
failoverCriteria: { statusCodes: { quantity: 4, items: [500, 502, 503, 504] } },
members: { quantity: 2, items: [{ originId: 'global-origin' }, { originId: 'virginia-origin' }] },
}] },
defaultCacheBehavior: {
...common, targetOriginId: 'og-global-virginia',
allowedMethods: ['GET', 'HEAD', 'OPTIONS'], cachedMethods: ['GET', 'HEAD', 'OPTIONS'],
},
},
});
// Global only (no origin group)
const distGlobalOnly = new cloudfront.CfnDistribution(this, 'DistGlobalOnly', {
distributionConfig: {
enabled: true, comment: `${this.stackName} Global only`,
origins: [globalOrigin],
defaultCacheBehavior: { ...common, targetOriginId: 'global-origin', allowedMethods: ['GET', 'HEAD'] },
},
});
new lambda.CfnResourcePolicy(this, 'Policy', {
resourceArn: fn.attrFunctionArn,
policyDocument: {
Version: '2012-10-17',
Statement: [{
Sid: 'AllowCloudFront', Effect: 'Allow', Principal: { Service: 'cloudfront.amazonaws.com' },
Action: 'lambda:InvokeWebFunctionEndpoint',
Resource: [global.attrEndpointArn, regional.attrEndpointArn],
Condition: { StringEquals: { 'aws:SourceArn': [
`arn:${this.partition}:cloudfront::${this.account}:distribution/${distOriginGroup.ref}`,
`arn:${this.partition}:cloudfront::${this.account}:distribution/${distGlobalOnly.ref}`,
] } },
}],
},
});
new cdk.CfnOutput(this, 'DomainOriginGroup', { value: distOriginGroup.attrDomainName });
new cdk.CfnOutput(this, 'IdOriginGroup', { value: distOriginGroup.ref });
new cdk.CfnOutput(this, 'DomainGlobalOnly', { value: distGlobalOnly.attrDomainName });
new cdk.CfnOutput(this, 'IdGlobalOnly', { value: distGlobalOnly.ref });
new cdk.CfnOutput(this, 'GlobalDomain', { value: global.attrDomainName });
new cdk.CfnOutput(this, 'VirginiaDomain', { value: virginiaDomain });
}
}
app-src/fail.ts
import http from 'node:http';
// Failure-injection app. FAIL_REGION / FAIL_FROM_MS / FAIL_UNTIL_MS / FAIL_MODE / DELAY_MS are replaced at build time (esbuild --define).
// In FAIL_REGION and between FAIL_FROM_MS and FAIL_UNTIL_MS (epoch ms):
// FAIL_MODE "503" : answer 503 immediately
// FAIL_MODE "delay" : wait DELAY_MS, then answer 200 (so a client that does not give up sees a slow success)
// otherwise answer 200 immediately.
declare const FAIL_REGION: string;
declare const FAIL_FROM_MS: number;
declare const FAIL_UNTIL_MS: number;
declare const FAIL_MODE: string;
declare const DELAY_MS: number;
const port = Number(process.env.LOCAL_PORT ?? 3000);
http.createServer((req, res) => {
const region = process.env.AWS_REGION ?? '';
const now = Date.now();
const failing = region === FAIL_REGION && now >= FAIL_FROM_MS && now < FAIL_UNTIL_MS;
const body = { region, failing, requestId: String(req.headers['lambda-web-request-id'] ?? '') };
console.log(JSON.stringify({ ...body, mode: failing ? FAIL_MODE : 'normal', path: req.url, host: req.headers.host, userAgent: req.headers['user-agent'] }));
const respond = (status: number) => {
res.statusCode = status;
res.setHeader('content-type', 'application/json');
res.end(JSON.stringify(body));
};
if (failing && FAIL_MODE === 'delay') setTimeout(() => respond(200), DELAY_MS);
else respond(failing ? 503 : 200);
}).listen(port, () => console.log(`listening on port ${port}`));
build-fail.sh
#!/bin/bash
# usage: build-fail.sh <fail-region> <fail-from-epoch-ms> <fail-until-epoch-ms> [mode: 503|delay (default 503)] [delay-ms (default 0)]
# -> app-fail/index.js (CJS), built in Docker
set -euo pipefail
cd "$(dirname "$0")"
docker run --rm --user "$(id -u):$(id -g)" -e HOME=/tmp -v "$PWD:/app" -w /app node:22 \
npx esbuild app-src/fail.ts --bundle --platform=node --target=node22 --format=cjs --outfile=app-fail/index.js \
--define:FAIL_REGION="\"$1\"" --define:FAIL_FROM_MS="$2" --define:FAIL_UNTIL_MS="$3" \
--define:FAIL_MODE="\"${4:-503}\"" --define:DELAY_MS="${5:-0}"









