概要
cdk v2で、S3 + cloud frontの構成でSPAをデプロイしたのでメモ。
v1のときと、デフォルト値が変わったものがちらほら存在したのでまとめる。
エラーのリダイレクトを使ってSPAのルーティングを以前は実現していたが、Functions
を使ってルーティングを行うように変更した。
2023/05/20 追記 deprecatedのクラスを使っている。修正版はこちら
環境
- Windows 10
- node v16.14.0
- aws-cdk: 2.15.0
- aws-cdk-lib: 2.15.0
- constructs: 10.0.79
フォルダ構造
- cdk
- cdk.json
- package.json
- client
+ build
+ src
CDKの設定確認
S3
CloudFront用ファイル置き場。
設定項目 | 設定値 | 解説 |
---|---|---|
blockPublicAccess | BLOCK_ALL |
パブリックアクセスはできないようにしておく |
removalPolicy | DESTROY |
スタック削除したときにゴミが残らないようにしておく |
autoDeleteObjects | true |
スタック削除したときにゴミが残らないようにしておく(removalPolicy のみだと空でないときにBucketNotEmpty; のエラーが発生 |
CloudFront経由でのみアクセス可能にする設定
オリジンアクセスアイデンティティ(OAI)
cdk-docs
S3への制限
ポリシー
オリジンアクセスアイデンティティ (OAI) を使用して Amazon S3 コンテンツへのアクセスを制限する
OAI
プロパティ | 設定値 | 説明 |
---|---|---|
effect | Allow |
|
actions | ['s3:GetObject', 's3:ListBucket'] |
|
principals | [CanonicalUserPrincipal(作成したOAIのUserId)] |
|
resources | [bucket.bucketArn + '/*', bucket.bucketArn] |
CloudFront
CloudFrontDistribution設定
プロパティ | 設定値 | デフォルト値 | 説明 |
---|---|---|---|
defaultRootObject | /index.html |
オリジンのルート | |
priceClass | 200 | 100 | 日本は200~。* |
minimumProtocolVersion | - | TLS_V1_2_2021 |
TLSバージョン |
domainNames | ドメイン` | ランダム値 | 今回はfreenomで取得したドメイン |
certificate | 後述の証明書 | ||
defaultBehavior | 後述 |
証明書
プロパティ | 設定値 | デフォルト値 | 説明 |
---|---|---|---|
domainName | ドメイン |
ドメイン | |
hostedZone | route53(ルートドメイン ) |
ルートドメインを指定 | |
region | us-east-1 |
固定値 | |
validation | fromDns | fromEmail |
デフォルトビヘイビア設定
プロパティ | 設定値 | デフォルト値 | 説明 |
---|---|---|---|
origin | OAIを指定 | S3オリジン | |
allowedMethods | - | ALLOW_GET_HEAD |
|
cachedMethods | - | CACHE_GET_HEAD |
|
comporess | - | true | gzip圧縮3点セット |
viewerProtocolPolicy | REDIRECT_TO_HTTPS |
||
cachePolicy | 後述 | ||
functionAssociations |
VIEWER_REQUEST に以下JSを設定 |
SPA用のパス書き換え。index.html へのリダイレクトを行う |
// ルーティング用Function
// 拡張子が含まれないURLはSPAファイルにリダイレクト
function handler(event) {
var request = event.request;
if(request.method === 'GET' && !request.uri.includes('.')){
request.uri = '/index.html';
}
return request;
}
SPA用ルーティング設定にFunctionを利用する。*
参考
公式
SPA を AWS CloudFront で動かすときにカスタムエラーレスポンスを使わない
AWS CloudFront FunctionsでSPAのルーティング処理
デフォルトキャッシュポリシー
プロパティ | 設定値 | デフォルト値 | 説明 |
---|---|---|---|
defaultTtl | - | 1日 | クライアント レスポンスで未指定の場合のTTL |
minTtl | - | 0秒 | クライアントレスポンスでno-cache のときに上書きするTTL |
maxTtl | - | 1年 | クライアント レスポンスでmaxTtl以上のExpires が指定されたときに上書きする |
cookiBehavior | - | none |
自動でcookieを含めるか。取りうる値 |
headerBehavior | - | none | なし。ヘッダーをオリジンに転送し、ヘッダの値に基づいてデバイスの種類などでキャッシュを切り替える設定。 |
queryStringBehavior | - | none |
?param=value に応じてキャッシュを切り替える設定。 |
enableAcceptEncodingGzip | true | false | gzip圧縮3点セット |
enableAcceptEncodingBrotli | true | false | gzip圧縮3点セット |
参考
参考
qiita - CloudFront の Cache Policy と Origin Request Policy を理解する
aws - 署名付き Cookie の使用
aws - リクエストヘッダーに基づくコンテンツのキャッシュ
aws - コンテンツがキャッシュに保持される期間 (有効期限) の管理
aws-圧縮ファイルの供給
ファイル配置設定
ファイルデプロイ設定
設定項目 | 設定値 | 解説 |
---|---|---|
sources | ../client/build |
デプロイ元フォルダ |
destinationBucket | 作成したS3 | |
distribution | 作成したCloudFront | |
distributionPaths | /デプロイ先フォルダ/* |
有効期限前にキャッシュの削除。月ごとに一定数まで無料。* |
destinationKeyPrefix | デプロイ先フォルダ |
デプロイ先フォルダ |
フォルダ構成(再)
- cdk
- package.json
- client
+ build
+ src
ルーティング設定
route53でAレコードとAaaaレコードを指定。
プロパティ | 設定値 | デフォルト値 | 説明 |
---|---|---|---|
recordName | ドメイン |
||
target | 今回作成のCloudFront |
ソースコード
{
"name": "cdk",
"version": "0.1.0",
"bin": {
"cdk": "bin/cdk.js"
},
"scripts": {
"build": "tsc",
"watch": "tsc -w",
"test": "jest",
"cdk": "cdk",
"pipeline-deploy": "cdk deploy --require-approval never --all",
"deploy": "cdk deploy"
},
"devDependencies": {
"@types/jest": "^27.4.1",
"@types/node": "17.0.21",
"aws-cdk": "2.15.0",
"jest": "^27.5.1",
"ts-jest": "^27.1.3",
"ts-node": "^10.6.0",
"typescript": "~4.6.2"
},
"dependencies": {
"aws-cdk-lib": "2.15.0",
"constructs": "^10.0.79",
"dotenv": "^16.0.0",
"source-map-support": "^0.5.21"
}
}
{
"app": "npx ts-node --prefer-ts-exts bin/cdk.ts",
"watch": {
"include": [
"**"
],
"exclude": [
"README.md",
"cdk*.json",
"**/*.d.ts",
"**/*.js",
"tsconfig.json",
"package*.json",
"yarn.lock",
"node_modules",
"test"
]
},
"context": {
"@aws-cdk/aws-apigateway:usagePlanKeyOrderInsensitiveId": true,
"@aws-cdk/core:stackRelativeExports": true,
"@aws-cdk/aws-rds:lowercaseDbIdentifier": true,
"@aws-cdk/aws-lambda:recognizeVersionProps": true,
"@aws-cdk/aws-cloudfront:defaultSecurityPolicyTLSv1.2_2021": true,
"@aws-cdk-containers/ecs-service-extensions:enableDefaultLogDriver": true,
"@aws-cdk/aws-ec2:uniqueImdsv2TemplateName": true,
"@aws-cdk/core:target-partitions": [
"aws",
"aws-cn"
]
}
}
PROJECT_ID=hogefuga
ROOT_DOMAIN=hoge.example.com
DEPLOY_DOMAIN=www.hogexxx.example.com
TAG_PROJECT_NAME=hogehoge
#!/usr/bin/env node
import 'source-map-support/register'
import * as cdk from 'aws-cdk-lib'
import { SPAClientStack } from '../lib/cdk-stack'
import * as dotenv from 'dotenv'
dotenv.config()
const envList = [
'PROJECT_ID',
'ROOT_DOMAIN',
'DEPLOY_DOMAIN',
'TAG_PROJECT_NAME',
] as const
for (const key of envList) {
if (!process.env[key]) throw new Error(`please add ${key} to .env`)
}
const processEnv = process.env as Record<typeof envList[number], string>
const app = new cdk.App()
const env = {
account: process.env.CDK_DEFAULT_ACCOUNT,
region: process.env.CDK_DEFAULT_REGION,
}
const projectId = processEnv.PROJECT_ID
new SPAClientStack (app, `${projectId}-stack`, {
bucketName: `${projectId}-s3-bucket`,
identityName: `${projectId}-origin-access-identity-to-s3-bucket`,
defaultCachePolicyName: `${projectId}-cache-policy-default`,
routingFunctionName: `${projectId}-spa-routing-function`,
distributionName: `${projectId}-distribution-cloudfront`,
rootDomain: processEnv.ROOT_DOMAIN,
deployDomain: processEnv.DEPLOY_DOMAIN,
projectNameTag: processEnv.TAG_PROJECT_NAME,
env,
})
export const basePath = 'deploy_dist_hogehoge'
import * as core from 'aws-cdk-lib'
import * as s3 from 'aws-cdk-lib/aws-s3'
import * as cf from 'aws-cdk-lib/aws-cloudfront'
import * as iam from 'aws-cdk-lib/aws-iam'
import * as s3deploy from 'aws-cdk-lib/aws-s3-deployment'
import * as origins from 'aws-cdk-lib/aws-cloudfront-origins'
import * as route53 from 'aws-cdk-lib/aws-route53'
import * as route53Targets from 'aws-cdk-lib/aws-route53-targets'
import * as certManager from 'aws-cdk-lib/aws-certificatemanager'
import { Construct } from 'constructs'
interface Props extends core.StackProps {
bucketName: string
identityName: string
defaultCachePolicyName: string
distributionName: string
routingFunctionName: string
rootDomain: string
deployDomain: string
projectNameTag: string
}
import { basePath } from '../constants/paths'
export class SPAClientStack extends core.Stack {
constructor(scope: Construct, id: string, props: Props) {
super(scope, id, props)
// CloudFront オリジン用のS3バケットを作成
const bucket = this.createS3(props.bucketName)
// CloudFront で設定する オリジンアクセスアイデンティティ を作成
const identity = this.createIdentity(bucket, props.identityName)
// S3バケットポリシーで、CloudFrontのオリジンアクセスアイデンティティを許可
this.createPolicy(bucket, identity)
const zone = this.findRoute53HostedZone(props.rootDomain)
const cert = this.createTLSCertificate(props.deployDomain, zone)
// CloudFrontディストリビューションを作成
const distribution = this.createCloudFront(
bucket,
identity,
cert,
props.defaultCachePolicyName,
props.distributionName,
props.deployDomain,
props.routingFunctionName,
)
// 指定したディレクトリをデプロイ
this.deployS3(bucket, distribution, '../client/build', props.bucketName)
// route53 の CloudFrontに紐づくレコード作成
this.addRoute53Records(zone, props.deployDomain, distribution)
// 確認用にCloudFrontのURLに整形して出力
new core.CfnOutput(this, `${props.distributionName}-top-url`, {
value: `https://${distribution.distributionDomainName}/`,
})
core.Tags.of(this).add('Project', props.projectNameTag)
}
private createS3(bucketName: string) {
const bucket = new s3.Bucket(this, bucketName, {
bucketName,
accessControl: s3.BucketAccessControl.PRIVATE,
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
removalPolicy: core.RemovalPolicy.DESTROY,
})
return bucket
}
private createIdentity(bucket: s3.Bucket, identityName: string) {
const identity = new cf.OriginAccessIdentity(this, identityName, {
comment: `${bucket.bucketName} access identity`,
})
return identity
}
private createPolicy(bucket: s3.Bucket, identity: cf.OriginAccessIdentity) {
const myBucketPolicy = new iam.PolicyStatement({
effect: iam.Effect.ALLOW,
actions: ['s3:GetObject', 's3:ListBucket'],
principals: [
new iam.CanonicalUserPrincipal(
identity.cloudFrontOriginAccessIdentityS3CanonicalUserId,
),
],
resources: [bucket.bucketArn + '/*', bucket.bucketArn],
})
bucket.addToResourcePolicy(myBucketPolicy)
}
private createCloudFront(
bucket: s3.Bucket,
identity: cf.OriginAccessIdentity,
cert: certManager.DnsValidatedCertificate,
defaultCachePolicyName: string,
distributionName: string,
deployDomain: string,
routingFunctionName: string,
) {
const defaultPolicyOption = {
cachePolicyName: defaultCachePolicyName,
comment: 'gzip有効化ポリシー',
// defaultTtl: core.Duration.days(2),
// cookieBehavior: cf.CacheCookieBehavior.all(),
enableAcceptEncodingGzip: true,
enableAcceptEncodingBrotli: true,
}
const myCachePolicy = new cf.CachePolicy(
this,
defaultCachePolicyName,
defaultPolicyOption,
)
const origin = new origins.S3Origin(bucket, {
originAccessIdentity: identity,
})
const spaRoutingFunction = new cf.Function(this, 'SpaRoutingFunction', {
functionName: routingFunctionName,
// 拡張子が含まれないURLはSPAファイルにリダイレクト
code: cf.FunctionCode.fromInline(`
function handler(event) {
var request = event.request;
if(request.method === 'GET' && !request.uri.includes('.')){
request.uri = '/index.html';
}
return request;
}
`),
})
const d = new cf.Distribution(this, distributionName, {
defaultRootObject: '/index.html',
priceClass: cf.PriceClass.PRICE_CLASS_200,
defaultBehavior: {
origin,
cachePolicy: myCachePolicy,
viewerProtocolPolicy: cf.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
functionAssociations: [
{
eventType: cf.FunctionEventType.VIEWER_REQUEST,
function: spaRoutingFunction,
},
],
},
// SPAの向け先をオリジンがエラーレスポンスを返すときに変更していたが、Functionで出来るようになったので移行
// errorResponses: [
// {
// httpStatus: 404,
// responseHttpStatus: 200,
// responsePagePath: '/index.html',
// ttl: core.Duration.seconds(0),
// },
// {
// httpStatus: 403,
// responseHttpStatus: 200,
// responsePagePath: '/index.html',
// ttl: core.Duration.seconds(0),
// },
// ],
// Route53と連携するためのカスタムドメイン
certificate: cert,
domainNames: [deployDomain],
})
core.Tags.of(d).add('Service', 'Cloud Front')
return d
}
private deployS3(
siteBucket: s3.Bucket,
distribution: cf.Distribution,
sourcePath: string,
bucketName: string,
) {
new s3deploy.BucketDeployment(
this,
`${bucketName}-deploy-with-invalidation`,
{
sources: [s3deploy.Source.asset(sourcePath)],
destinationBucket: siteBucket,
distribution,
distributionPaths: [`/${basePath}/*`],
destinationKeyPrefix: basePath,
},
)
}
private findRoute53HostedZone(rootDomain: string) {
return route53.HostedZone.fromLookup(this, `${rootDomain}-hosted-zone`, {
domainName: rootDomain,
})
}
private createTLSCertificate(
deployDomain: string,
hostedZone: route53.IHostedZone,
) {
return new certManager.DnsValidatedCertificate(
this,
`${deployDomain}-certificate`,
{
domainName: deployDomain,
hostedZone, // DNS 認証に Route 53 のホストゾーンを使う
region: 'us-east-1', // 必ず us-east-1 リージョン
validation: certManager.CertificateValidation.fromDns(),
},
)
}
private addRoute53Records(
zone: route53.IHostedZone,
deployDomain: string,
cf: cf.Distribution,
) {
const propsForRoute53Records = {
zone,
recordName: deployDomain,
target: route53.RecordTarget.fromAlias(
new route53Targets.CloudFrontTarget(cf),
),
}
new route53.ARecord(this, 'ARecord', propsForRoute53Records)
new route53.AaaaRecord(this, 'AaaaRecord', propsForRoute53Records)
}
}
トラブルシューティング
503 エラー
今回の場合、Functionで起きている可能性が高い。
コンソールから簡単に治せるので、修正して実行してみるとよい。(なお、es5のみ対応のため、let や const
を使うとエラーになるので注意。)
修正は発行しないと意味がないので注意。反映までは数分時間がかかる。
this xml file does not appear to have any style information associated with it.
ファイルのない場所を指してしまう状態。ルーティング設定のFunctionsが失敗して別のアドレスになっている、または、FunctionsとCloudFrontが紐づいておらず、ルーティングされていないなど。
参考
aws-cdkのv2が来たのでドキュメントを読んでみたメモ
Route53とCloudFrontの紐づけとSSL証明書の発行をCDKを使って行ったメモ
VueのSPAでの動的OGPをS3+Cloudfront+Lambda@Edgeの構成でcdkを使って実現したメモ
[aws] CDKでS3のバケットを削除時にBucketNotEmptyエラーが出る時