はじめに
本記事では、CloudFront + S3 + CRR + Origin Group + OAC を使って、静的サイトの DR(Disaster Recovery)構成 を AWS CDK v2 で作成する方法を紹介します。
今回の構成では、Route 53 は使用せず、CloudFront の Origin Group によるフェイルオーバーと、S3 の CRR(Cross-Region Replication) によるデータ同期を組み合わせます。
また、実装に加えて、デプロイ後の確認手順、フェイルオーバー試験手順、コスト感 もまとめます。
本記事は実際にデプロイして動作確認したうえで書いています。検証環境は aws-cdk-lib 2.266.0 / aws-cdk CLI 2.1138.0 です。料金は 2026年8月時点の公開情報をもとにしています。
対象読者
- CloudFront + S3 で静的サイトを配信している方
- AWS で DR 構成を検討している方
- Route 53 を使わずにフェイルオーバーを実現したい方
- AWS CDK v2 でインフラを構築したい方
- S3 CRR と CloudFront Origin Group の関係を整理したい方
この構成でできること / できないこと
先に前提を整理します。ここを誤解すると「DR できているつもり」になりがちです。
| 観点 | この構成の実力 |
|---|---|
| 対象 | 参照系のみの DR(静的コンテンツの配信継続) |
| RPO | CRR は非同期。S3 RTC(Replication Time Control)を使っていないため 15分 SLA の対象外。数分程度のデータロスを許容する設計 |
| RTO | フェイルオーバー自体は自動・即時(リクエスト単位)。ただし CloudFront のキャッシュ TTL の影響を受ける |
| 切り替え方向 | CloudFront は毎回まず primary に向かい、失敗したときだけ secondary に向かう。一度フェイルオーバーしても secondary に固定されるわけではない |
| データの流れ | CRR は primary → secondary の一方向。secondary に直接書いたものは primary に戻らない |
| 守れないもの | フェイルオーバー中のコンテンツ更新、CloudFront の設定ミス(両系統に効く) |
目指す構成
-
Primary bucket を
ap-northeast-1に配置 -
Secondary bucket を
us-east-1に配置 - CloudFront distribution を配置
- Origin Group によるフェイルオーバー
- OAC による S3 の非公開化
- S3 CRR によるリージョン間複製
- CloudFront の originPath で
public_html/を配信
通常時は primary bucket の public_html/index.html を配信し、ユーザーは https://<CLOUDFRONT_DOMAIN_NAME>/(defaultRootObject により /index.html が補われます)でアクセスできます。
構成図
Origin Path と OAC は Distribution やバケットの属性ではなく、Origin の属性です。 図でも各 Origin にぶら下げています。
採用した AWS サービスの役割
CloudFront
Origin Group を使うことで、primary origin が失敗したときに secondary origin へ切り替えます。切り替えはリクエスト単位で、常に primary が先に試されます。
なお Origin failover が動作するのは GET / HEAD / OPTIONS のみです。そのため後述のコードでは allowedMethods / cachedMethods をこの 3 メソッドに揃えています。OPTIONS をキャッシュ対象メソッドに含めないとフェイルオーバーしません。
S3
静的ファイルの保管先です。primary bucket と secondary bucket を分けて配置します。
CRR
primary bucket のオブジェクトを secondary bucket に複製します。
重要な制約が 2 つあります。
- CRR はルール作成後に PUT されたオブジェクトのみを複製します。 既存オブジェクトを複製するには S3 Batch Replication が別途必要です。既存サイトへ後付けする場合は必ず必要になります。
- 一方向です。 secondary に直接置いたオブジェクトは primary には戻りません。また primary 復旧後に primary へ再 PUT すると、その複製が secondary の手動配置分を上書きします。
OAC
CloudFront から S3 へのアクセスを制限します。S3 を直接公開せず、CloudFront 経由のみにします。
CDK の実装方針
-
3 スタック構成
- primary bucket stack
- secondary bucket stack
- CloudFront distribution stack
.envでバケット名を管理- CloudFront の
originPathでpublic_html/を配信する - スタック間は「バケット名とリージョン」という静的な値だけで接続する
3 つ目がポイントです。CloudFront スタックからバケットスタックの属性を直接参照すると、リージョンをまたぐクロススタック参照になり、Custom::CrossRegionExportWriter / Reader の Lambda ベースのカスタムリソースまで生成されてスタックが密結合になります。.env の値だけで繋げば、この一式が不要になります(実測で 6 リソース削減できました)。
ディレクトリ構成
cloudfront-s3-dr/
├── bin/
│ └── cloudfront-s3-dr.ts
├── lib/
│ ├── config.ts
│ ├── primary-bucket-stack.ts
│ ├── secondary-bucket-stack.ts
│ └── cloudfront-distribution-stack.ts
├── cdk.json
├── package.json
├── tsconfig.json
└── .env.example
実装コード
{
"app": "npx ts-node --prefer-ts-exts bin/cloudfront-s3-dr.ts"
}
最小構成の cdk.json です。cdk init で生成される context の feature flag 群を持たないため、cdk synth 時に「N feature flags are not configured」という案内が出ます。動作に支障はありませんが、新規プロジェクトでは cdk init の cdk.json をベースにするのが無難です。
{
"name": "cloudfront-s3-dr",
"version": "1.0.0",
"private": true,
"scripts": {
"build": "tsc",
"synth": "cdk synth",
"deploy": "cdk deploy --all",
"destroy": "cdk destroy --all"
},
"dependencies": {
"aws-cdk-lib": "^2.266.0",
"constructs": "^10.0.0",
"dotenv": "^16.4.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"aws-cdk": "^2.1138.0",
"typescript": "^5.0.0",
"ts-node": "^10.0.0"
}
}
バージョンは ^2.0.0 ではなく実際に動作確認したバージョンを指定してください。 本記事で使う s3.Bucket の replicationRules(L2 サポート)や Stack.addStackDependency() は比較的新しい API です。^2.0.0 のままだと、既存の package-lock.json や社内ミラーで古い 2.x に固定されている環境で型エラーになり synth できません。
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"moduleResolution": "Node",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "dist"
},
"include": [
"bin/**/*.ts",
"lib/**/*.ts"
]
}
AWS_ACCOUNT_ID=123456789012
PRIMARY_REGION=ap-northeast-1
SECONDARY_REGION=us-east-1
PRIMARY_BUCKET_NAME=my-cloudfront-s3-dr-primary-apne1
SECONDARY_BUCKET_NAME=my-cloudfront-s3-dr-secondary-use1
REPLICATION_PREFIX=public_html/
REPLICATION_PREFIX は public_html/(末尾スラッシュあり)、CloudFront の originPath は /public_html(先頭スラッシュあり・末尾なし)です。形式が違うのは意図的で、S3 のプレフィックスと CloudFront の originPath の仕様がそれぞれ異なるためです。
import * as cdk from 'aws-cdk-lib';
import { config } from '../lib/config';
import { PrimaryBucketStack } from '../lib/primary-bucket-stack';
import { SecondaryBucketStack } from '../lib/secondary-bucket-stack';
import { CloudFrontDistributionStack } from '../lib/cloudfront-distribution-stack';
const app = new cdk.App();
const secondaryStack = new SecondaryBucketStack(app, 'SecondaryBucketStack', {
env: {
account: config.accountId,
region: config.secondaryRegion,
},
bucketName: config.secondaryBucketName,
});
const primaryStack = new PrimaryBucketStack(app, 'PrimaryBucketStack', {
env: {
account: config.accountId,
region: config.primaryRegion,
},
bucketName: config.primaryBucketName,
replicationPrefix: config.replicationPrefix,
destinationBucketName: config.secondaryBucketName,
});
const distributionStack = new CloudFrontDistributionStack(app, 'CloudFrontDistributionStack', {
env: {
account: config.accountId,
region: config.primaryRegion,
},
primaryBucketName: config.primaryBucketName,
secondaryBucketName: config.secondaryBucketName,
primaryRegion: config.primaryRegion,
secondaryRegion: config.secondaryRegion,
});
primaryStack.addStackDependency(secondaryStack);
distributionStack.addStackDependency(primaryStack);
distributionStack.addStackDependency(secondaryStack);
スタック間に CloudFormation の参照は発生しないため、デプロイ順序だけを明示しています。CRR の複製先バケットは primary より先に存在しなければならないので、primaryStack が secondaryStack に依存します。
addDependency() は現行の aws-cdk-lib で非推奨です。addStackDependency() を使います。
import 'dotenv/config';
function requiredEnv(key: string): string {
const value = process.env[key];
if (!value) {
throw new Error(`${key} is required in .env`);
}
return value;
}
export const config = {
accountId: requiredEnv('AWS_ACCOUNT_ID'),
primaryRegion: process.env.PRIMARY_REGION ?? 'ap-northeast-1',
secondaryRegion: process.env.SECONDARY_REGION ?? 'us-east-1',
primaryBucketName: requiredEnv('PRIMARY_BUCKET_NAME'),
secondaryBucketName: requiredEnv('SECONDARY_BUCKET_NAME'),
replicationPrefix: process.env.REPLICATION_PREFIX ?? 'public_html/',
};
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as iam from 'aws-cdk-lib/aws-iam';
import * as s3 from 'aws-cdk-lib/aws-s3';
export interface PrimaryBucketStackProps extends cdk.StackProps {
bucketName: string;
replicationPrefix: string;
destinationBucketName: string;
}
export class PrimaryBucketStack extends cdk.Stack {
public readonly bucket: s3.Bucket;
constructor(scope: Construct, id: string, props: PrimaryBucketStackProps) {
super(scope, id, props);
this.bucket = new s3.Bucket(this, 'PrimaryBucket', {
bucketName: props.bucketName,
versioned: true,
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
encryption: s3.BucketEncryption.S3_MANAGED,
enforceSSL: true,
objectOwnership: s3.ObjectOwnership.BUCKET_OWNER_ENFORCED,
// 検証用途。既定の RETAIN だと cdk destroy 後もバケットが残り、
// 同じ bucketName で作り直そうとすると名前の重複で失敗する。
// なお autoDeleteObjects と OAC の既知の相性問題 (aws-cdk#31360) は
// バケットと Distribution が同一スタックの場合の話なので、この構成では該当しない。
removalPolicy: cdk.RemovalPolicy.DESTROY,
autoDeleteObjects: true,
// CRR はバージョニングが必須なので旧バージョンが溜まり続ける。放置するとストレージ費用が増え続ける
lifecycleRules: [
{
id: 'ExpireNoncurrentVersions',
noncurrentVersionExpiration: cdk.Duration.days(30),
abortIncompleteMultipartUploadAfter: cdk.Duration.days(7),
},
],
replicationRules: [
{
destination: s3.Bucket.fromBucketName(
this,
'DestinationBucketImport',
props.destinationBucketName,
),
filter: {
prefix: props.replicationPrefix,
},
deleteMarkerReplication: true,
priority: 1,
},
],
});
// OAC 用のバケットポリシーは、公式ドキュメント (Restrict access to an Amazon S3 origin)
// のとおり AWS:SourceArn に distribution の ARN を指定する形式にする。
// distribution ID はこのスタックの時点では未確定なのでワイルドカードで受け、
// アカウント内の CloudFront に限定する。本番でさらに絞るなら distribution ID を
// 受け取って StringEquals + 完全な ARN にする(初回は 2 段デプロイになる)。
this.bucket.addToResourcePolicy(new iam.PolicyStatement({
sid: 'AllowCloudFrontRead',
effect: iam.Effect.ALLOW,
principals: [new iam.ServicePrincipal('cloudfront.amazonaws.com')],
actions: ['s3:GetObject'],
resources: [this.bucket.arnForObjects('*')],
conditions: {
StringLike: {
'AWS:SourceArn': `arn:${this.partition}:cloudfront::${this.account}:distribution/*`,
},
},
}));
new cdk.CfnOutput(this, 'PrimaryBucketName', {
value: this.bucket.bucketName,
});
new cdk.CfnOutput(this, 'PrimaryBucketRegionalDomainName', {
value: this.bucket.bucketRegionalDomainName,
});
}
}
ポイントを 3 つ補足します。
1. 条件キーは AWS:SourceArn を使う
OAC 用のバケットポリシーとして AWS 公式ドキュメント(Restrict access to an Amazon S3 origin)が提示しているのは AWS:SourceArn にディストリビューション ARN を指定する形式です。AWS:SourceAccount は OAC × S3 の文脈では公式に記載がなく、動作保証がありません。
ただしこの構成ではバケットスタックがディストリビューションスタックより先にデプロイされるため、ARN が確定していません。そこで StringLike + ワイルドカード ARN で「自アカウントの CloudFront ディストリビューション全部」にスコープしています。confused deputy 対策としては 1 段ゆるいので、本番で絞るならディストリビューション ID を .env / SSM 経由で渡し、2 回目のデプロイで StringEquals + 完全な ARN に切り替えます。
2. removalPolicy は明示する
s3.Bucket の removalPolicy の既定値は RETAIN です。cdk destroy --all を用意していても、これを明示しないとバケットは残り、同じ bucketName で作り直そうとするとバケット名重複で失敗します。本番では RETAIN が正解なので、その場合は「破棄時は手動でバケットを空にして削除する」手順を残しておきます。
3. ライフサイクルルールを入れる
CRR はバージョニングが必須です。つまり旧バージョンが単調に溜まります。noncurrentVersionExpiration を入れないとストレージ費用が増え続けます。
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as iam from 'aws-cdk-lib/aws-iam';
import * as s3 from 'aws-cdk-lib/aws-s3';
export interface SecondaryBucketStackProps extends cdk.StackProps {
bucketName: string;
}
export class SecondaryBucketStack extends cdk.Stack {
public readonly bucket: s3.Bucket;
constructor(scope: Construct, id: string, props: SecondaryBucketStackProps) {
super(scope, id, props);
this.bucket = new s3.Bucket(this, 'SecondaryBucket', {
bucketName: props.bucketName,
versioned: true,
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
encryption: s3.BucketEncryption.S3_MANAGED,
enforceSSL: true,
objectOwnership: s3.ObjectOwnership.BUCKET_OWNER_ENFORCED,
// 検証用途。既定の RETAIN だと cdk destroy 後もバケットが残り、
// 同じ bucketName で作り直そうとすると名前の重複で失敗する。
removalPolicy: cdk.RemovalPolicy.DESTROY,
autoDeleteObjects: true,
// レプリカ側も旧バージョンと削除マーカーが溜まるので同様に失効させる
lifecycleRules: [
{
id: 'ExpireNoncurrentVersions',
noncurrentVersionExpiration: cdk.Duration.days(30),
abortIncompleteMultipartUploadAfter: cdk.Duration.days(7),
},
],
});
// 条件キーは公式ドキュメントに合わせて AWS:SourceArn を使う(詳細は PrimaryBucketStack のコメント参照)
this.bucket.addToResourcePolicy(new iam.PolicyStatement({
sid: 'AllowCloudFrontRead',
effect: iam.Effect.ALLOW,
principals: [new iam.ServicePrincipal('cloudfront.amazonaws.com')],
actions: ['s3:GetObject'],
resources: [this.bucket.arnForObjects('*')],
conditions: {
StringLike: {
'AWS:SourceArn': `arn:${this.partition}:cloudfront::${this.account}:distribution/*`,
},
},
}));
new cdk.CfnOutput(this, 'SecondaryBucketName', {
value: this.bucket.bucketName,
});
new cdk.CfnOutput(this, 'SecondaryBucketRegionalDomainName', {
value: this.bucket.bucketRegionalDomainName,
});
}
}
IAM のレプリケーションロールは replicationRules を書けば L2 が自動生成します。自分でロールを作って replicationRole に渡さないなら、手書きのロールや複製用のバケットポリシーは不要です(渡し忘れると、使われないロールと噛み合わない権限付与が残ります)。
同様に、enforceSSL: true は aws:SecureTransport の Deny ステートメントを自動生成します。DenyInsecureTransport を手書きすると二重定義になります。
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as cloudfront from 'aws-cdk-lib/aws-cloudfront';
import * as origins from 'aws-cdk-lib/aws-cloudfront-origins';
import * as s3 from 'aws-cdk-lib/aws-s3';
export interface CloudFrontDistributionStackProps extends cdk.StackProps {
primaryBucketName: string;
secondaryBucketName: string;
primaryRegion: string;
secondaryRegion: string;
}
export class CloudFrontDistributionStack extends cdk.Stack {
public readonly distributionId: string;
constructor(scope: Construct, id: string, props: CloudFrontDistributionStackProps) {
super(scope, id, props);
// bucketRegionalDomainName は渡さない。
// 渡すとバケットスタックへのクロス(リージョン)スタック参照が発生し、
// ExportWriter/ExportReader のカスタムリソースまで生成されてスタックが密結合になる。
// 省略すれば CDK が bucketName と region から `<name>.s3.<region>.<urlSuffix>` を組み立てる。
const primaryBucket = s3.Bucket.fromBucketAttributes(this, 'PrimaryBucketImport', {
bucketName: props.primaryBucketName,
region: props.primaryRegion,
});
const secondaryBucket = s3.Bucket.fromBucketAttributes(this, 'SecondaryBucketImport', {
bucketName: props.secondaryBucketName,
region: props.secondaryRegion,
});
const primaryOrigin = origins.S3BucketOrigin.withOriginAccessControl(primaryBucket, {
originPath: '/public_html',
});
const secondaryOrigin = origins.S3BucketOrigin.withOriginAccessControl(secondaryBucket, {
originPath: '/public_html',
});
const originGroup = new origins.OriginGroup({
primaryOrigin,
fallbackOrigin: secondaryOrigin,
// 403 は必須。OAC で許可しているのは s3:GetObject だけで s3:ListBucket は付いていないため、
// オブジェクトが存在しない場合 S3 は 404 ではなく 403 AccessDenied を返す。
// 403 を外すと「オブジェクト欠損」でのフェイルオーバーが働かなくなる。
// 実際のリージョン障害では 5xx(503: 接続失敗 / 504: タイムアウト)が契機になる。
fallbackStatusCodes: [403, 404, 500, 502, 503, 504],
});
const distribution = new cloudfront.Distribution(this, 'Distribution', {
defaultBehavior: {
origin: originGroup,
viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
allowedMethods: cloudfront.AllowedMethods.ALLOW_GET_HEAD_OPTIONS,
cachedMethods: cloudfront.CachedMethods.CACHE_GET_HEAD_OPTIONS,
cachePolicy: cloudfront.CachePolicy.CACHING_OPTIMIZED,
},
defaultRootObject: 'index.html',
// アクセスログは無効。enableLogging を有効にすると CDK がログ用バケットを自動生成するが、
// そのバケットの RemovalPolicy は RETAIN 固定なので cdk destroy でも消えず、
// スタックを作り直すたびに孤立バケットが増えていく。
// ログが必要になったら logBucket に自前のバケット(RemovalPolicy: DESTROY)を渡す。
});
this.distributionId = distribution.distributionId;
// OAC 用のバケットポリシーはインポート元のバケットスタック側で定義しているため、
// 「インポートしたバケットのポリシーは更新できない」警告は想定どおり。
cdk.Annotations.of(this).acknowledgeWarning(
'@aws-cdk/aws-cloudfront-origins:updateImportedBucketPolicyOac',
'OAC 許可は PrimaryBucketStack / SecondaryBucketStack のバケットポリシーで定義済み',
);
new cdk.CfnOutput(this, 'CloudFrontDomainName', {
value: distribution.distributionDomainName,
});
new cdk.CfnOutput(this, 'CloudFrontDistributionId', {
value: distribution.distributionId,
});
}
}
fallbackStatusCodes に 403 が必須な理由
ここが一番はまりやすいところです。
OAC でバケットポリシーに付与しているのは s3:GetObject のみです(CDK の originAccessLevels の既定値が [AccessLevel.READ])。この状態で存在しないキーを要求すると、S3 は 404 ではなく 403 AccessDenied を返します。s3:ListBucket 権限がないと「無い」ことすら教えてもらえないためです。
したがって「オブジェクトが無い」ことを契機にフェイルオーバーさせたいなら、fallbackStatusCodes に 403 を含めることが必須です。404 だけでは効きません。
404 を返すようにしたい場合は、バケットポリシーに s3:ListBucket(リソースはバケット ARN)を追加します。CDK でバケットを同一スタックで作っているなら originAccessLevels: [cloudfront.AccessLevel.READ, cloudfront.AccessLevel.LIST] を渡す手もありますが、今回のようにインポートしたバケットに対しては効かないので、バケットスタック側のポリシーに自分で書くことになります。
トレードオフとして、403 を fallback に含めると「本当のアクセス拒否」でも secondary への再試行が走り、その分レスポンスが遅くなります。
なぜバケットポリシーを手書きしているのか
S3BucketOrigin.withOriginAccessControl() は、同一スタックで作ったバケットであれば OAC 許可のバケットポリシーを自動で追加してくれます。しかし今回は Bucket.fromBucketAttributes() でインポートしているため、CDK はインポートしたバケットのポリシーを変更できません。addToResourcePolicy() は黙って no-op になり、synth 時に警告だけが出ます。
これに気付かないと、デプロイは成功するのに CloudFront が AccessDenied を返します(後述のトラブルシューティング参照)。そのため各バケットスタックで明示的にポリシーを書き、警告は acknowledgeWarning() で「想定どおり」であることを記録して抑止しています。
デプロイ手順
npm install
# 対象リージョンで bootstrap 実行済みの場合、このステップは省略可能
cdk bootstrap aws://<AWS_ACCOUNT_ID>/ap-northeast-1
cdk bootstrap aws://<AWS_ACCOUNT_ID>/us-east-1
cdk deploy --all
CloudFront スタックを ap-northeast-1 に置いていますが問題ありません。ACM 証明書(カスタムドメイン)や WAF を使う場合は us-east-1 が必須ですが、今回はどちらも使っていません。
デプロイ後の確認手順
1. Primary bucket にファイルを配置します
aws s3 cp ./index.html s3://<PRIMARY_BUCKET_NAME>/public_html/index.html \
--content-type "text/html; charset=utf-8"
2. Primary bucket にオブジェクトが存在するか確認します
aws s3api head-object \
--bucket <PRIMARY_BUCKET_NAME> \
--key public_html/index.html
3. CRR の複製状況を確認します
送信元のレプリケーションステータスを見るのが最も正確です。
# PENDING → COMPLETED になれば複製完了
aws s3api head-object \
--bucket <PRIMARY_BUCKET_NAME> \
--key public_html/index.html \
--query ReplicationStatus
ReplicationStatus は S3 が付与する正式なレプリケーション状態です(PENDING / COMPLETED / FAILED / REPLICA)。
4. Secondary bucket に複製されたことを確認します
aws s3api head-object \
--bucket <SECONDARY_BUCKET_NAME> \
--key public_html/index.html
CRR は非同期です。すぐに 404 が返っても異常ではありません。少し待って再実行してください(筆者の環境では約 20 秒で複製されました)。
5. CloudFront 経由で確認します
curl -sI https://<CLOUDFRONT_DOMAIN_NAME>/ | head -n 1
# HTTP/2 200
6. 必要に応じて CloudFront のキャッシュを無効化します
aws cloudfront create-invalidation \
--distribution-id <CLOUDFRONT_DISTRIBUTION_ID> \
--paths /index.html
フェイルオーバー試験手順
これは 「オブジェクトが読めない状態でのフェイルオーバー確認」 です。実際のリージョン障害では S3 は 5xx を返す(到達不能になる)ため、契機となるステータスコードが異なります(503 は接続失敗、504 はタイムアウトに対応)。リージョン障害そのものの試験ではありません。
目的
Primary 側が読めなくなったとき、CloudFront が Secondary 側へフェイルオーバーすることを確認します。
方式の選択
もっとも安全なのは、primary バケットポリシーで CloudFront からの読み取りを一時的に Deny する方式です。
「primary のオブジェクトを削除する」方式は避けてください。deleteMarkerReplication: true を設定しているため、削除マーカーが非同期で secondary にも複製されます。secondary へ手動 PUT した後にこの削除マーカーが到着すると、それが secondary の最新バージョンになり、オブジェクトが再び見えなくなります。CRR は同期完了時刻を保証しないため、この競合はタイミング次第で発生します。
Deny 方式なら、データを一切触らずに済み、.env の値もオブジェクトもそのままです。CloudFront は 403 を受けて即フェイルオーバーします。本番の障害挙動にも近いです。
手順
1. 現在のバケットポリシーを退避します
aws s3api get-bucket-policy --bucket <PRIMARY_BUCKET_NAME> \
--query Policy --output text > primary-policy.backup.json
2. CloudFront からの読み取りを Deny するステートメントを追加します
明示的 Deny は Allow に必ず勝ちます。既存のポリシー(enforceSSL の Deny を含む)を壊さないよう、置き換えではなく追記します。
python - <<'EOF'
import json
BUCKET = "<PRIMARY_BUCKET_NAME>"
policy = json.load(open("primary-policy.backup.json"))
policy["Statement"].append({
"Sid": "DrTestDenyCloudFrontRead",
"Effect": "Deny",
"Principal": {"Service": "cloudfront.amazonaws.com"},
"Action": "s3:GetObject",
"Resource": f"arn:aws:s3:::{BUCKET}/*",
})
json.dump(policy, open("primary-policy.drtest.json", "w"))
EOF
aws s3api put-bucket-policy --bucket <PRIMARY_BUCKET_NAME> \
--policy file://primary-policy.drtest.json
3. CloudFront のキャッシュを無効化します
CACHING_OPTIMIZED はクエリ文字列をキャッシュキーに含めないため、クエリでのキャッシュバスターは効きません。無効化が必要です。
aws cloudfront create-invalidation \
--distribution-id <CLOUDFRONT_DISTRIBUTION_ID> \
--paths "/index.html" "/"
4. CloudFront 経由でアクセスします
curl -sI https://<CLOUDFRONT_DOMAIN_NAME>/ | head -n 1
# HTTP/2 200
primary が 403 を返す状態で 200 が返るということは、secondary origin が応答したということです。 これがフェイルオーバーの証拠になります。どちらの origin が応答したかをログで確認したい場合は、CloudFront 標準ログの x-edge-detailed-result-type を見ます。
5. ポリシーを元に戻します
aws s3api put-bucket-policy --bucket <PRIMARY_BUCKET_NAME> \
--policy file://primary-policy.backup.json
aws cloudfront create-invalidation \
--distribution-id <CLOUDFRONT_DISTRIBUTION_ID> \
--paths "/index.html" "/"
期待結果
- primary が 403 を返す間、CloudFront は secondary origin から応答する
- ページが表示され、配信が継続される
- ポリシーを戻すと、次のリクエストから再び primary が使われる(CloudFront は毎回まず primary を試すため、secondary に固定されることはありません)
参考: オブジェクト削除方式で試験する場合
どうしても削除方式で試したい場合は、以下のいずれかを守ってください。
- 試験の間だけ
deleteMarkerReplicationを無効にする - secondary へ PUT する前に、secondary 側で
head-objectが 404 を返すこと(= 削除マーカー到着済み)を確認する
また、secondary へ直接 PUT したオブジェクトは primary には戻りません。primary 復旧後に primary へ再 PUT すると、その複製が secondary の手動配置分を上書きします。この構成は「参照系のみの DR」であり、フェイルオーバー中に更新した内容は復旧時に失われる前提の設計です。
障害復旧後の戻し確認(フェイルバック)
1. Primary 側の状態を戻します
Deny 方式で試験した場合はポリシーを戻すだけです。オブジェクトを削除した場合は再配置します。
aws s3 cp ./index.html s3://<PRIMARY_BUCKET_NAME>/public_html/index.html \
--content-type "text/html; charset=utf-8"
2. CRR の反映を確認します
確認するのは secondary 側、または primary 側のレプリケーションステータスです。
# primary 側でレプリケーションステータスを見る(推奨)
aws s3api head-object \
--bucket <PRIMARY_BUCKET_NAME> \
--key public_html/index.html \
--query ReplicationStatus
# → COMPLETED
# secondary 側の実体を見る
aws s3api head-object \
--bucket <SECONDARY_BUCKET_NAME> \
--key public_html/index.html
3. 必要に応じて CloudFront のキャッシュを無効化します
aws cloudfront create-invalidation \
--distribution-id <CLOUDFRONT_DISTRIBUTION_ID> \
--paths "/index.html" "/"
4. CloudFront 経由で primary 側に戻っているか確認します
curl -sI https://<CLOUDFRONT_DOMAIN_NAME>/ | head -n 1
5. 運用上の取り決めを決めておきます
- フェイルオーバー中、secondary は読み取り専用として扱う
- コンテンツ更新は primary 復旧後に primary 側へ行う
- 双方向に反映したい場合は S3 の双方向レプリケーション(
replicaModificationsを含む相互ルール)が必要。静的サイト DR では通常不要
監視
フェイルオーバーは自動なので、起きたことに気付く仕組みがないと「いつの間にか secondary で配信していた」状態になります。
- CloudFront の CloudWatch メトリクス:
5xxErrorRate/4xxErrorRate/OriginLatency - CloudFront 標準ログの
x-edge-detailed-result-type - S3 レプリケーション失敗の EventBridge 通知(
s3:Replication:OperationFailedReplication)
SPA として使う場合
今回は単一の index.html を配信するだけなので不要ですが、/about のようなパスを扱う SPA では errorResponses によるマッピングが必要です。
errorResponses: [
{ httpStatus: 403, responseHttpStatus: 200, responsePagePath: '/index.html' },
{ httpStatus: 404, responseHttpStatus: 200, responsePagePath: '/index.html' },
],
この構成では 403 と 404 の両方を対象にしてください。 前述のとおり OAC 構成では欠損オブジェクトが 403 になるためです。ただし fallbackStatusCodes と組み合わせると「フェイルオーバーを試したうえで最終的に index.html を返す」挙動になるので、レイテンシへの影響を確認してください。
月額費用の目安
前提を分けて考える必要があります。 「100GB/月」が配信量なのか保存量なのか複製量なのかで金額はまったく変わります。以下では 3 つを独立した軸として扱います。
- 配信量: CloudFront からビューワーへ出るデータ量
- 保存量: S3 に置くサイトの容量(旧バージョン込み)
- 更新量: 月に primary へ PUT する量 = CRR で複製される量
CloudFront: 定額プランと従量課金
AWS は 2025年11月に CloudFront の定額プランを導入しました。従量課金(pay-as-you-go)も引き続き選択できます。本記事の見積りは従量課金前提です。
| プラン | 月額 | 含まれるデータ転送 | 含まれるリクエスト |
|---|---|---|---|
| Free | $0 | 100 GB | 100万 |
| Pro | $15 | 50 TB | 1,000万 |
| Business | $200 | 50 TB | 1億2,500万 |
| Premium | $1,000 | 50 TB(最大 600 TB まで設定可) | 5億(最大 60億 まで設定可) |
いずれも超過課金なしで、WAF / DDoS 保護 / Route 53 / TLS 証書 / S3 ストレージクレジットなどを含むバンドルです。
この構成の規模(100GB〜1TB/月の静的サイト DR)なら、Pro プラン $15/月 のほうが従量課金より安いケースが多いです。 従量課金の実額を見ると理由がわかります。
CloudFront 従量課金の実額(無料枠を使い切っている場合)
| 項目 | 日本 | 米国 | 欧州 |
|---|---|---|---|
| データ転送アウト(最初の 10TB/月) | $0.114 /GB | $0.085 /GB | $0.085 /GB |
| HTTPS リクエスト | $0.0120 /1万 | $0.0100 /1万 | $0.0120 /1万 |
| 配信量 | 無料枠内 | 無料枠なし(日本) | 無料枠なし(米国/欧州) |
|---|---|---|---|
| 100 GB/月 | $0 | 約 $11.4 | 約 $8.5 |
| 500 GB/月 | $0 | 約 $57 | 約 $42.5 |
| 1 TB/月(1,024GB) | $0 | 約 $117 | 約 $87 |
CloudFront には常時無料枠(データ転送アウト 1TB/月 + 1,000万リクエスト/月 + CloudFront Functions 200万実行/月)があります。これを前提にすれば 1TB まで $0 ですが、無料枠はアカウント単位で他用途と共有です。「常に $0」と読める書き方は楽観的すぎるので、両方を併記するのが正確です。
S3 + CRR の実額
| 項目 | 東京 | バージニア北部 |
|---|---|---|
| S3 Standard ストレージ(最初の 50TB) | $0.025 /GB・月 | $0.023 /GB・月 |
| PUT / COPY / POST / LIST | $0.0047 /1,000 | $0.005 /1,000 |
| GET | $0.00037 /1,000 | $0.0004 /1,000 |
| 他リージョンへのデータ転送アウト | $0.09 /GB | $0.02 /GB |
CRR で追加でかかるのは、複製先のストレージ + レプリケーション PUT リクエスト + リージョン間データ転送アウトです。転送は複製したデータ量に比例します(東京発は $0.09/GB)。
| 月間の複製量 | リージョン間転送(東京発) |
|---|---|
| 1 GB | 約 $0.09 |
| 10 GB | 約 $0.90 |
| 100 GB | 約 $9 |
| 1 TB | 約 $92 |
RPO を縮めたい場合は S3 RTC(Replication Time Control)を使えますが、+$0.015/GB の転送料がかかります(全リージョン共通)。
典型的な静的サイトでの試算
サイト容量 1GB、月間更新 1GB、配信 100GB(日本、無料枠なし)の場合。
| 項目 | 月額 |
|---|---|
| CloudFront データ転送アウト(100GB) | 約 $11.4 |
| CloudFront リクエスト(100万) | 約 $1.2 |
| S3 primary ストレージ(1GB) | 約 $0.03 |
| S3 secondary ストレージ(1GB + 旧バージョン分) | 約 $0.03〜 |
| CRR リージョン間転送(1GB) | 約 $0.09 |
| CRR レプリケーション PUT | 数セント |
| CloudFront 無効化(月 1,000 パスまで無料、以降 $0.005/パス) | $0 |
| OAC | $0 |
| 合計 | 約 $13 |
DR 化による追加コスト(S3 secondary + CRR)は月 $1 未満で、支配的なのは CloudFront の配信量です。静的サイトなら DR 化そのものは非常に安く済みます。
secondary のストレージは「primary と同量」にはなりません。バージョニングが有効なので、削除マーカーや旧バージョンの分だけ primary より増えることがあります。本記事のライフサイクルルール(noncurrentVersionExpiration: 30日)はこれを抑えるためのものです。
料金は変動します。必ず AWS Pricing Calculator と各サービスの料金ページで最新の値を確認してください。
まとめ
本記事では、CloudFront の Origin Group、S3 の CRR、OAC を組み合わせて、静的サイトの DR 構成を AWS CDK v2 で構築しました。
Route 53 を使わず、Primary を ap-northeast-1、Secondary を us-east-1 に分けることで、配信経路とデータ同期を分離しつつ、primary が読めなくなったときには CloudFront から Secondary へ自動で切り替わる構成になっています。
一方で、この構成が守ってくれる範囲は限定的です。
- 参照系のみの DR であり、フェイルオーバー中の更新は保護されない
- RPO / RTO は保証されない(CRR は非同期、S3 RTC 未使用)
- CloudFront の設定ミスは両系統に効く(CloudFront 自体は SPOF ではないが、設定は単一障害点になり得る)
- 既存サイトへ後付けするなら S3 Batch Replication が別途必要
この範囲を理解したうえでなら、静的サイトの可用性を低コストで一段上げる、実装量の少ない選択肢だと思います。特に OAC 構成では欠損オブジェクトが 403 になるため fallbackStatusCodes に 403 を含めること、インポートしたバケットには CDK がポリシーを追加できないことは、実際にデプロイして初めて気付きやすいポイントでした。
この記事が、誰かのお役に立てば幸いです。
参考サイト
- Optimize high availability with CloudFront origin failover
- Restrict access to an Amazon S3 origin
- Amazon CloudFront Pricing
- Amazon CloudFront Pay-as-you-go Pricing
- Amazon S3 Pricing
- Amazon S3 Replication
- Amazon S3 Replication Status
- Replicating existing objects with S3 Batch Replication
- AWS CDK API Reference: aws-cdk-lib.aws_s3.Bucket
- AWS CDK API Reference: aws-cdk-lib.aws_cloudfront_origins.S3BucketOrigin