付録 A:マルチリージョン設計を Cross-Region Inference Profile で実装する
本編 ch14(セキュリティ)と ch15(コスト)で 「Cross-Region Inference Profile(以下 CRIS)を使え」 とは何度か書いた。だがそこで踏み込まなかった疑問が 2 つ残っているはずだ。
- Geographic と Global、どっちを選べばいいのか。データ主権要件があるとき・ないとき、で何がどう変わるのか
- CRIS でリージョン分散が透明にできるなら、helpdesk-ai を本当の意味で active-active マルチリージョン化するには、ほかに何を組み合わせる必要があるのか
この付録はこの 2 つに答えるためのものだ。helpdesk-ai を 東京(ap-northeast-1)・大阪(ap-northeast-3)・ソウル(ap-northeast-2)の 3 リージョン active-active で動かす設計を、CRIS・Route53 レイテンシベースルーティング・DynamoDB Global Tables・CloudWatch のリージョン別メトリクスを組み合わせて示す。
CDK と AWS SDK v3 のサンプルコードは「実装する人がコピペして使える」ことを優先して書いた。
A.1 CRIS の 2 タイプを正しく使い分ける
A.1.1 Geographic CRIS と Global CRIS
CRIS には大きく 2 タイプある。モデル ID のプレフィックスで見分ける。
// Geographic CRIS(地理境界内ルーティング)
"us.anthropic.claude-sonnet-4-5"; // 米国内のみ
"eu.anthropic.claude-sonnet-4-5"; // EU 内のみ
"apac.anthropic.claude-sonnet-4-5"; // APAC 内のみ
"jp.anthropic.claude-sonnet-4-5"; // 日本国内のみ
// Global CRIS(全世界の商用リージョン横断)
"global.anthropic.claude-sonnet-4-5";
挙動と料金の違いを表にまとめる。
| 観点 | Geographic CRIS | Global CRIS |
|---|---|---|
| ルーティング範囲 | 同じ地理境界内のみ(us. / eu. / apac. / jp. 等) | 全世界の商用リージョン横断 |
| データ主権 | 境界外に出ない(GDPR・日本住民データ等の要件と相性◎) | 境界をまたぐ可能性あり |
| 単価 | 呼び出し元リージョンの単価がそのまま適用 | Geographic より約 10 % 安価 |
| スループット | 境界内分散で大幅増 | 全世界分散で 最大 |
| ルーティング自体の追加料金 | なし(CRIS のルーティング自体は無料) | なし |
| クォータプール | リージョンごとに独立 | リージョンごとに独立 |
ここでよく誤解される 2 点を先に潰しておく。
- CRIS を経由するルーティング自体に追加料金はかからない。課金はあくまで「呼び出し元リージョン」の単価で計算される
- クォータは「実質倍化」する。CRIS で n 個のリージョンに分散されれば、各リージョンの On-Demand クォータプールが独立して使えるため、
ThrottlingExceptionを激減させられる
graph LR
subgraph "Geographic CRIS(apac.)"
AppApac[helpdesk-ai] --> Profile1[apac.anthropic.claude-sonnet-4-5]
Profile1 --> Tokyo[(ap-northeast-1)]
Profile1 --> Osaka[(ap-northeast-3)]
Profile1 --> Seoul[(ap-northeast-2)]
end
subgraph "Global CRIS(global.)"
AppGlobal[helpdesk-ai] --> Profile2[global.anthropic.claude-sonnet-4-5]
Profile2 --> NA[(US リージョン群)]
Profile2 --> EU[(EU リージョン群)]
Profile2 --> AP[(APAC リージョン群)]
end
A.1.2 選定フロー
実務で迷ったら次のフローで決める。
flowchart TD
Start[CRIS を選ぶ] --> Q1{データ主権<br/>要件があるか?}
Q1 -->|あり<br/>例: GDPR| EU[eu. を選ぶ]
Q1 -->|あり<br/>例: 日本住民データ| JP[jp. または apac. を選ぶ]
Q1 -->|なし| Q2{スループットと<br/>10%コスト削減を<br/>取りに行くか?}
Q2 -->|Yes| Global[global. を選ぶ]
Q2 -->|No<br/>地理的に閉じたい| Geo[us. / eu. / apac. を選ぶ]
helpdesk-ai は 「日本国内の社員データ・人事 API を扱う」 ので APAC Geographic(apac.)を選ぶ。
A.1.3 実処理リージョンを CloudTrail で確認する
CRIS は「呼び出し元から見ると 1 つのプロファイル」だが、実際には複数のリージョンに分散される。どのリージョンで処理されたかは CloudTrail に記録される。
CloudTrail のイベントに additionalEventData.inferenceRegion というキーがあり、そこに実処理リージョンが入る。Athena から検索する SQL の例を示す。
-- 直近 1 日の helpdesk-ai 呼び出しが実際にどのリージョンで処理されたかを集計
SELECT
json_extract_scalar(additionaleventdata, '$.inferenceRegion') AS exec_region,
COUNT(*) AS invocations
FROM cloudtrail_logs_bedrock
WHERE eventname = 'InvokeModel'
AND useridentity.sessioncontext.sessionissuer.username LIKE 'helpdesk-ai-%'
AND eventtime > date_format(current_timestamp - interval '1' day, '%Y-%m-%dT%H:%i:%sZ')
GROUP BY 1
ORDER BY 2 DESC;
「CRIS を入れたつもりで実は同一リージョンに偏っている」「Geographic を設定したつもりが Global の API キーを叩いていた」のような事故を、このクエリで早期に検知できる。
A.2 helpdesk-ai のマルチリージョン構成
A.2.1 全体図
設計の全体像を先に示す。
graph TB
User((社員<br/>Slack / Web)) --> R53[Route53<br/>レイテンシベースルーティング<br/>+ ヘルスチェック]
R53 -->|最寄り or 健全| ALB1[ALB<br/>ap-northeast-1]
R53 -->|最寄り or 健全| ALB2[ALB<br/>ap-northeast-3]
R53 -->|最寄り or 健全| ALB3[ALB<br/>ap-northeast-2]
subgraph "東京 ap-northeast-1"
ALB1 --> L1[Lambda<br/>helpdesk-ai]
L1 --> VPCE1[VPC Endpoint<br/>bedrock-runtime]
L1 -.->|セッション読み書き| DDB1[(DynamoDB<br/>Global Tables<br/>Replica)]
VPCE1 --> CRIS1[apac.anthropic.<br/>claude-sonnet-4-5]
end
subgraph "大阪 ap-northeast-3"
ALB2 --> L2[Lambda<br/>helpdesk-ai]
L2 --> VPCE2[VPC Endpoint<br/>bedrock-runtime]
L2 -.->|セッション読み書き| DDB2[(DynamoDB<br/>Global Tables<br/>Replica)]
VPCE2 --> CRIS2[apac.anthropic.<br/>claude-sonnet-4-5]
end
subgraph "ソウル ap-northeast-2"
ALB3 --> L3[Lambda<br/>helpdesk-ai]
L3 --> VPCE3[VPC Endpoint<br/>bedrock-runtime]
L3 -.->|セッション読み書き| DDB3[(DynamoDB<br/>Global Tables<br/>Replica)]
VPCE3 --> CRIS3[apac.anthropic.<br/>claude-sonnet-4-5]
end
DDB1 <-.->|双方向レプリケーション| DDB2
DDB2 <-.->|双方向レプリケーション| DDB3
DDB1 <-.->|双方向レプリケーション| DDB3
CRIS1 -.->|内部で分散| Pool[(APAC リージョンの<br/>Bedrock 推論プール)]
CRIS2 -.->|内部で分散| Pool
CRIS3 -.->|内部で分散| Pool
A.2.2 設計判断のサマリ
- CRIS は APAC Geographic 固定。日本住民データを境界外に出さない
- Route53 はレイテンシベースルーティング+ヘルスチェック。健全なリージョンの中から最寄りを返す
- DynamoDB Global Tables でセッション状態(会話履歴・ユーザー設定)を 3 リージョン双方向レプリケーション
- 各リージョン独立の VPC Endpoint(PrivateLink)経由で Bedrock を呼ぶ。VPC 内に閉じた通信を維持
- CloudWatch メトリクスにリージョン Dimension を付け、リージョン別の挙動を可視化
- 障害時は Route53 ヘルスチェックで自動フェイルオーバー
A.3 CDK でマルチリージョン VPC Endpoint を組む
3 リージョン同じスタックを展開する。Stage で region を渡し、Stack 側は region に依存しない実装にする。
// infra/multi-region-stage.ts
import * as cdk from "aws-cdk-lib";
import { Construct } from "constructs";
import { HelpdeskAiStack } from "./cdk-stack.js";
const REGIONS = ["ap-northeast-1", "ap-northeast-3", "ap-northeast-2"] as const;
export class HelpdeskAiStage extends cdk.Stage {
constructor(scope: Construct, id: string, props: cdk.StageProps) {
super(scope, id, props);
for (const region of REGIONS) {
new HelpdeskAiStack(this, `HelpdeskAi-${region}`, {
env: { account: props.env?.account, region },
crisModelArn:
`arn:aws:bedrock:${region}:${props.env?.account}:` +
`inference-profile/apac.anthropic.claude-sonnet-4-5`,
sessionTableName: "helpdesk-ai-sessions",
});
}
}
}
Stack 側で VPC・VPC Endpoint・Lambda を作る。VPC Endpoint で Bedrock を呼ぶことで、egress を VPC 内に閉じる。
// infra/cdk-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 * as lambda from "aws-cdk-lib/aws-lambda";
import { Construct } from "constructs";
interface Props extends cdk.StackProps {
crisModelArn: string;
sessionTableName: string;
}
export class HelpdeskAiStack extends cdk.Stack {
constructor(scope: Construct, id: string, props: Props) {
super(scope, id, props);
const vpc = new ec2.Vpc(this, "Vpc", { maxAzs: 3, natGateways: 0 });
// Bedrock Runtime の VPC Endpoint
// ch14 に揃え、CDK の enum 経由で書く(直書きの
// `com.amazonaws.${region}.bedrock-runtime` よりも誤記が起きにくい)
vpc.addInterfaceEndpoint("BedrockRuntimeVpce", {
service: ec2.InterfaceVpcEndpointAwsService.BEDROCK_RUNTIME,
privateDnsEnabled: true,
});
// helpdesk-ai Lambda
const fn = new lambda.Function(this, "Handler", {
runtime: lambda.Runtime.NODEJS_22_X,
handler: "chat.handler",
code: lambda.Code.fromAsset("dist"),
vpc,
timeout: cdk.Duration.seconds(30),
environment: {
BEDROCK_MODEL_ID: props.crisModelArn, // CRIS の inference-profile ARN
SESSION_TABLE: props.sessionTableName,
},
});
// CRIS は profile ARN 自体と、ルーティング先となる FM ARN の両方が必要
fn.addToRolePolicy(
new iam.PolicyStatement({
actions: ["bedrock:InvokeModel", "bedrock:InvokeModelWithResponseStream"],
resources: [
props.crisModelArn,
// CRIS が裏でルーティングする FM 群(APAC 各リージョン)
`arn:aws:bedrock:ap-northeast-1::foundation-model/anthropic.claude-sonnet-4-5`,
`arn:aws:bedrock:ap-northeast-2::foundation-model/anthropic.claude-sonnet-4-5`,
`arn:aws:bedrock:ap-northeast-3::foundation-model/anthropic.claude-sonnet-4-5`,
],
}),
);
}
}
ここで重要なのは IAM ポリシーの resources に「inference profile の ARN」と「ルーティング先 FM の ARN」両方を書く こと。CRIS の呼び出しはこの 2 つに対する権限が同時に必要で、片方だけだと AccessDeniedException で落ちる。本編 ch14 で書いた IAM 強制パターンの拡張として覚えておきたい。
A.4 DynamoDB Global Tables でセッションを同期する
helpdesk-ai は会話履歴・ユーザー設定をセッションとして保持する。マルチリージョン active-active では「東京で書いた直後にソウルから読まれる」が普通に起きるため、Global Tables(マルチリージョン・マルチアクティブ)で 3 リージョン双方向レプリケーションする。
// infra/dynamo-stack.ts(プライマリリージョンのみで実行)
import * as cdk from "aws-cdk-lib";
import * as ddb from "aws-cdk-lib/aws-dynamodb";
import { Construct } from "constructs";
export class HelpdeskAiSessionsStack extends cdk.Stack {
constructor(scope: Construct, id: string, props: cdk.StackProps) {
super(scope, id, props);
new ddb.TableV2(this, "Sessions", {
tableName: "helpdesk-ai-sessions",
partitionKey: { name: "sessionId", type: ddb.AttributeType.STRING },
billing: ddb.Billing.onDemand(),
replicas: [
{ region: "ap-northeast-3" }, // 大阪
{ region: "ap-northeast-2" }, // ソウル
],
timeToLiveAttribute: "expiresAt",
});
}
}
Lambda 側はリージョン情報を環境変数から読み、自リージョンの DynamoDB エンドポイント を叩くだけでよい。Global Tables の双方向レプリケーションは AWS 側がやってくれる。
// src/handlers/chat.ts(マルチリージョン対応版)
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, GetCommand, PutCommand } from "@aws-sdk/lib-dynamodb";
import { ConverseCommand } from "@aws-sdk/client-bedrock-runtime";
import { bedrock, MODEL_ID } from "../client.js";
const region = process.env.AWS_REGION!;
const ddb = DynamoDBDocumentClient.from(
new DynamoDBClient({ region, maxAttempts: 5 }),
);
const TABLE = process.env.SESSION_TABLE!;
export const handler = async (event: { sessionId: string; message: string }) => {
// 1. 同一リージョンの Global Tables レプリカからセッション取得
const session = await ddb.send(
new GetCommand({ TableName: TABLE, Key: { sessionId: event.sessionId } }),
);
const history = (session.Item?.history as { role: string; content: string }[]) ?? [];
// 2. CRIS 経由で Bedrock 呼び出し
const res = await bedrock.send(
new ConverseCommand({
modelId: MODEL_ID, // apac.anthropic.claude-sonnet-4-5 の ARN
messages: [
...history.map((h) => ({ role: h.role as "user" | "assistant", content: [{ text: h.content }] })),
{ role: "user", content: [{ text: event.message }] },
],
inferenceConfig: { maxTokens: 1024 }, // ch06 から徹底する max_tokens 明示
}),
);
const reply = res.output?.message?.content?.[0]?.text ?? "";
// 3. 履歴更新(Global Tables が他リージョンへ自動レプリケーション)
await ddb.send(
new PutCommand({
TableName: TABLE,
Item: {
sessionId: event.sessionId,
history: [...history, { role: "user", content: event.message }, { role: "assistant", content: reply }],
expiresAt: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 7,
lastRegion: region, // どのリージョンで最後に処理したかを記録(観測用)
},
}),
);
return { reply, region };
};
lastRegion をセッションに付けておくと、「直前は東京で処理されたのに今回はソウルに来た(=Route53 がフェイルオーバーした)」のような遷移を後追いできる。観測の役に立つ。
A.5 Route53 でレイテンシベースルーティングとフェイルオーバー
Route53 では レイテンシベース + ヘルスチェック を組み合わせる。
// infra/dns-stack.ts
import * as cdk from "aws-cdk-lib";
import * as route53 from "aws-cdk-lib/aws-route53";
import { Construct } from "constructs";
interface Props extends cdk.StackProps {
hostedZoneId: string;
zoneName: string;
endpoints: { region: string; albDns: string; albHostedZoneId: string }[];
}
export class HelpdeskDnsStack extends cdk.Stack {
constructor(scope: Construct, id: string, props: Props) {
super(scope, id, props);
const zone = route53.HostedZone.fromHostedZoneAttributes(this, "Zone", {
hostedZoneId: props.hostedZoneId,
zoneName: props.zoneName,
});
for (const ep of props.endpoints) {
// 各リージョンの ALB に対してヘルスチェック
const hc = new route53.CfnHealthCheck(this, `Hc-${ep.region}`, {
healthCheckConfig: {
type: "HTTPS",
fullyQualifiedDomainName: ep.albDns,
resourcePath: "/healthz",
requestInterval: 30,
failureThreshold: 3,
},
});
new route53.CfnRecordSet(this, `Rec-${ep.region}`, {
hostedZoneId: zone.hostedZoneId,
name: `chat.${props.zoneName}`,
type: "A",
setIdentifier: ep.region,
region: ep.region, // レイテンシベースルーティング
healthCheckId: hc.attrHealthCheckId,
aliasTarget: {
dnsName: ep.albDns,
hostedZoneId: ep.albHostedZoneId,
evaluateTargetHealth: true,
},
});
}
}
}
これで chat.example.com に対するリクエストは、
- 健全なリージョン群の中から
- レイテンシ最小のリージョンを Route53 が選んで返す
- ヘルスチェックが落ちたリージョンは自動的に候補から外れる
という挙動になる。
A.5.1 フェイルオーバーの流れ
ある時点で東京リージョンに障害が起きた場合のフローを示す。
sequenceDiagram
participant U as 社員
participant R as Route53
participant T as 東京 ALB+Lambda
participant S as ソウル ALB+Lambda
participant D as DynamoDB Global Tables
participant B as Bedrock CRIS (apac.)
Note over T: t=0 東京で正常稼働中
U->>R: chat.example.com を名前解決
R-->>U: 東京 ALB の IP(レイテンシ最小)
U->>T: POST /chat
T->>D: GetItem(sessionId)
T->>B: ConverseCommand(apac.claude-sonnet-4-5)
B-->>T: 応答
T->>D: PutItem(history, lastRegion=東京)
T-->>U: 応答
Note over T: t=1 東京リージョンで障害
R->>T: ヘルスチェック /healthz
T--xR: タイムアウト x3
R->>R: 東京を候補から除外
Note over S: 以降ソウルへフェイルオーバー
U->>R: chat.example.com を名前解決
R-->>U: ソウル ALB の IP(次にレイテンシが近い健全リージョン)
U->>S: POST /chat
S->>D: GetItem(sessionId)<br/>※レプリカから読める
S->>B: ConverseCommand(apac.claude-sonnet-4-5)<br/>※CRIS はそのまま使える
B-->>S: 応答
S->>D: PutItem(history, lastRegion=ソウル)
S-->>U: 応答(会話履歴も切れ目なし)
セッション状態が Global Tables で同期されているため、ユーザーから見れば 会話が切れずに続く。CRIS は inference profile という抽象を経由しているので、東京から呼ぼうがソウルから呼ぼうが同じプロファイル ARN(apac.anthropic.claude-sonnet-4-5)で動く。
A.6 CloudWatch にリージョン Dimension を入れる
リージョン別の挙動を観測しないと「片肺で動いていることに気付かない」事故が起きる。本編 ch13 で出した PutMetricData をマルチリージョン用に拡張する。
// src/observability/metrics.ts
import { CloudWatchClient, PutMetricDataCommand } from "@aws-sdk/client-cloudwatch";
const cw = new CloudWatchClient({ region: process.env.AWS_REGION!, maxAttempts: 5 });
const REGION = process.env.AWS_REGION!;
export async function recordInvocation(params: {
latencyMs: number;
inputTokens: number;
outputTokens: number;
inferenceRegion?: string; // CloudTrail から後追いで埋める or X-Amzn-Trace-Id 経由で取得
}) {
await cw.send(
new PutMetricDataCommand({
Namespace: "HelpdeskAi/Bedrock",
MetricData: [
{
MetricName: "InvocationLatency",
Value: params.latencyMs,
Unit: "Milliseconds",
Dimensions: [
{ Name: "SourceRegion", Value: REGION },
{ Name: "InferenceRegion", Value: params.inferenceRegion ?? "unknown" },
],
},
{
MetricName: "InputTokens",
Value: params.inputTokens,
Unit: "Count",
Dimensions: [{ Name: "SourceRegion", Value: REGION }],
},
{
MetricName: "OutputTokens",
Value: params.outputTokens,
Unit: "Count",
Dimensions: [{ Name: "SourceRegion", Value: REGION }],
},
],
}),
);
}
SourceRegion(呼び出し元)と InferenceRegion(CRIS が実際にルーティングした先)を別 dimension として持たせるのがコツ。「呼び出し元はソウルだが推論は東京で実行された」のような分布が見えるようになり、CRIS のリージョン分散が想定通り効いているかを継続的に検証できる。
A.6.1 データレジデンシーとロギングの落とし穴
CRIS で実処理が 他リージョン に飛んだとしても、CloudWatch メトリクス・CloudTrail・Model Invocation Logs はすべて呼び出し元(source)リージョンに書かれる。これは見落としやすい仕様だ。
- 強い residency 要件(「ログも含めて日本国内に留める」)があるなら、source region 自体を日本国内(東京・大阪)に限定 する必要がある
- GDPR 対応なら呼び出し元を EU リージョンに置き、
eu.Geographic CRIS を使う - 日本住民データなら呼び出し元を
ap-northeast-1/ap-northeast-3に置き、jp.またはapac.を使う
「Global CRIS で 10 % 安くなるから」と無自覚に切り替えると、ログだけ日本に残ってもプロンプト本文は海外で処理されてしまう、という事態になりかねない。本編 ch14 のセキュリティ章で書いた「データ主権」の話と接続している。
A.7 ハマりどころ集
最後に、CRIS マルチリージョン化で実際に踏まれている地雷を 3 つ挙げておく。
A.7.1 Provisioned Throughput と CRIS は併用不可
PT は「特定リージョンに特定モデルの容量を予約する」ものなので、リージョンを抽象化する CRIS とは設計思想が衝突する。両方使いたい場合は、ユースケースを分けて、PT 必須ワークロードだけ素のモデル ID(リージョン直叩き)にする。helpdesk-ai のような トラフィックの読みづらいリクエスト/レスポンス型 は On-Demand + CRIS、夜間バッチでスループット保証が要る ような系統だけ別系統で PT を持つ、という分け方が現実的。
A.7.2 一部の古いモデルは CRIS 非対応
CRIS は 比較的新しいモデルにしか提供されていない。「legacy. 系列の古いモデルを CRIS で呼びたい」と思っても profile が存在しないことがある。新規実装では Claude Sonnet 4.5 / Nova Pro / Llama 4 などの「2025〜2026 世代モデル」を選んでおくのが安全。既存システムの移行時は、移行先モデルの CRIS 対応状況を必ず確認する。
A.7.3 ロギングは source region に固定される
A.6.1 で書いた通り、Model Invocation Logs を含むすべての観測情報は 呼び出し元リージョン に集約される。本編 ch11 で扱った Guardrails Mask モードの「Mask されても原文が Logs に残る」という落とし穴と組み合わさると、「Global CRIS で実推論は EU、ログは東京に PII 原文が残る」 という設計事故も起きうる。Mask + Logs + CRIS は 3 点セットで設計レビューする。
章末まとめ
- CRIS は Geographic と Global の 2 タイプ。データ主権要件があれば Geographic、なければ Global で約 10 % 削減+スループット最大化
- 課金は呼び出し元リージョン基準で、CRIS のルーティング自体に追加料金はかからない。各リージョン独立クォータでスループットは実質倍化する
- helpdesk-ai を東京・大阪・ソウルの 3 リージョン active-active で動かすには、CRIS(
apac.Geographic)+ Route53 レイテンシベースルーティング+ DynamoDB Global Tables + VPC Endpoint の組み合わせが標準解- CloudWatch には
SourceRegionとInferenceRegionを別 dimension として持たせ、CRIS のルーティング結果まで観測する- CRIS と Provisioned Throughput は併用不可。一部の古いモデルは CRIS 非対応。ロギングは source region に固定される ── この 3 つは必ず設計初期に確認する