概要
以下のような構造にするときに参考になるCDKがあまり見つからなかったのでメモ。
- CloudFront
- S3
- Api Gateway - S3
ソース
APIGateway側
ハマリポイント
- エンドポイントタイプを「エッジ最適化」にしていたら失敗した。
- 「リージョン」に変更することで解決
今回はステージv1にデプロイしている。
- 「リージョン」に変更することで解決
cdk/lib/rest-stack.ts
import * as core from 'aws-cdk-lib'
import * as apigateway from 'aws-cdk-lib/aws-apigateway'
import * as iam from 'aws-cdk-lib/aws-iam'
import * as s3 from 'aws-cdk-lib/aws-s3'
import { Construct } from 'constructs'
interface Props extends core.StackProps {
bucketStackName: string
bucketName: string
projectId: string
}
export class AWSCartaGraphRESTAPIStack extends core.Stack {
constructor(scope: Construct, id: string, props: Props) {
super(scope, id, props)
// バケットへのアクセス権限を持ったIAMを作成
const bucket = s3.Bucket.fromBucketName(
this,
props.bucketStackName,
props.bucketName,
)
const restApiRole = this.createRestAPIRole(bucket)
// APIGateway作成
const apiName = `${props.projectId}-rest-api`
const restApi = this.createRestAPIGateway(apiName)
this.createUsagePlan(restApi, apiName)
// JDON格納用 integration
const jsonIntegration = this.createAwsIntegrationToUploadBucket({
toUploadBucketPath: `${bucket.bucketName}/logs/{requestTime}_{object}.json`,
restApiRole,
})
const methodOptions = this.createMethodOptions()
const apiRoot = restApi.root.addResource('api')
const paragraphMoveLog = apiRoot
.addResource('logs')
.addResource('{uid}')
paragraphMoveLog.addMethod('PUT', jsonIntegration, methodOptions)
}
private createRestAPIRole(bucket: s3.IBucket) {
const restApiRole = new iam.Role(this, 'Role', {
assumedBy: new iam.ServicePrincipal('apigateway.amazonaws.com'),
path: '/',
})
bucket.grantReadWrite(restApiRole)
return restApiRole
}
private createRestAPIGateway(restApiName: string) {
const restApi = new apigateway.RestApi(this, restApiName, {
description: 'バックエンドRESTAPI',
restApiName,
endpointTypes: [apigateway.EndpointType.REGIONAL],
deployOptions: {
stageName: 'v1',
},
})
return restApi
}
private createUsagePlan(restApi: apigateway.RestApi, apiName: string) {
const apiKey = restApi.addApiKey('defaultKeys')
const usagePlan = restApi.addUsagePlan(`${apiName}-usage-plan`, {
quota: { limit: 30, period: apigateway.Period.DAY },
throttle: { burstLimit: 2, rateLimit: 1 },
})
usagePlan.addApiKey(apiKey)
usagePlan.addApiStage({ stage: restApi.deploymentStage })
new core.CfnOutput(this, 'APIKey', {
value: apiKey.keyId,
})
}
private createAwsIntegrationToUploadBucket(prop: {
toUploadBucketPath: string
restApiRole: iam.Role
}) {
const integration = new apigateway.AwsIntegration({
service: 's3',
integrationHttpMethod: 'PUT',
path: prop.toUploadBucketPath,
options: {
credentialsRole: prop.restApiRole,
passthroughBehavior: apigateway.PassthroughBehavior.WHEN_NO_MATCH,
requestParameters: {
'integration.request.header.Content-Type': 'method.request.header.Content-Type',
'integration.request.path.object': 'method.request.path.uid',
'integration.request.path.requestTime': 'context.requestTimeEpoch',
},
integrationResponses: [
this.createOkResponse(),
this.createNotFoundResponse(),
this.createServerErrorResponse(),
],
},
})
return integration
}
private createOkResponse(): apigateway.IntegrationResponse {
return {
statusCode: '200',
responseParameters: {
'method.response.header.Timestamp': 'integration.response.header.Date',
'method.response.header.Content-Length':
'integration.response.header.Content-Length',
'method.response.header.Content-Type':
'integration.response.header.Content-Type',
...this.createServerErrorResponse().responseParameters,
},
}
}
private createNotFoundResponse(): apigateway.IntegrationResponse {
return {
statusCode: '400',
selectionPattern: '4\\d{2}',
responseParameters: this.createServerErrorResponse().responseParameters,
}
}
private createServerErrorResponse(): apigateway.IntegrationResponse {
return {
statusCode: '500',
selectionPattern: '5\\d{2}',
responseParameters: {
'method.response.header.Access-Control-Allow-Headers':
"'Content-Type,Authorization'",
'method.response.header.Access-Control-Allow-Methods':
"'OPTIONS,POST,PUT,GET,DELETE'",
'method.response.header.Access-Control-Allow-Origin': "'*'",
},
}
}
private createMethodOptions(): apigateway.MethodOptions {
const responseParameters = {
'method.response.header.Access-Control-Allow-Headers': true,
'method.response.header.Access-Control-Allow-Methods': true,
'method.response.header.Access-Control-Allow-Origin': true,
}
return {
apiKeyRequired: true,
requestParameters: {
'method.request.header.Content-Type': true,
'method.request.path.uid': true
},
methodResponses: [
{
statusCode: '200',
responseParameters: {
'method.response.header.Timestamp': true,
'method.response.header.Content-Length': true,
'method.response.header.Content-Type': true,
...responseParameters,
},
},
{
statusCode: '400',
responseParameters,
},
{
statusCode: '500',
responseParameters,
},
],
}
}
}
作成後、URLを控えておく。
CloudFront側
- 今回はapiのステージがv1になっている前提。
-
additionalBehaviors
の設定が特にポイント
-
cdk/lib/cf-stack.ts
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 { basePath } from '../constants/paths'
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
imageCachePolicyName: string
functionName: string
distributionName: string
rootDomain: string
deployDomain: string
projectNameTag: string
restApiUrl: string
}
export class AWSCarTaGraphClientStack 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.imageCachePolicyName,
props.distributionName,
props.deployDomain,
props.restApiUrl,
)
// 指定したディレクトリをデプロイ
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,
autoDeleteObjects: true,
cors: [
{
allowedMethods: [s3.HttpMethods.GET],
allowedOrigins: ['*'],
allowedHeaders: ['*'],
},
],
})
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,
imageCachePolicyName: string,
distributionName: string,
deployDomain: string,
restApiUrl: string,
) {
const defaultPolicyOption = {
cachePolicyName: defaultCachePolicyName,
comment: 'ポリシー',
enableAcceptEncodingGzip: true,
enableAcceptEncodingBrotli: true,
}
const myCachePolicy = new cf.CachePolicy(
this,
defaultCachePolicyName,
defaultPolicyOption,
)
const imgCachePolicy = new cf.CachePolicy(this, imageCachePolicyName, {
headerBehavior: cf.CacheHeaderBehavior.allowList(
'Access-Control-Request-Headers',
'Access-Control-Request-Method',
'Origin',
),
})
const origin = new origins.S3Origin(bucket, {
originAccessIdentity: identity,
})
const spaRoutingFunction = new cf.Function(this, 'SpaRoutingFunction', {
functionName: 'SpaRoutingFunction',
// 拡張子が含まれないURLはSPAファイルにリダイレクト
code: cf.FunctionCode.fromInline(`
function handler(event) {
var request = event.request;
if(request.uri.startsWith('/cartagraph-editor') && !request.uri.includes('.')) {
request.uri = '/cartagraph-editor/index.html';
} else if(request.uri.startsWith('/cartagraph-gamebook') && !request.uri.includes('.')) {
request.uri = '/cartagraph-gamebook/index.html';
} else if (!request.uri.includes('.')){
request.uri = '/cartagraph/index.html';
}
return request;
}
`),
})
core.Tags.of(spaRoutingFunction).add('Service', 'Cloud Front Function')
const apiEndPointUrlWithoutProtocol = core.Fn.select(
1,
core.Fn.split('://', restApiUrl),
)
const apiEndPointDomainName = core.Fn.select(
0,
core.Fn.split('/', apiEndPointUrlWithoutProtocol),
)
const d = new cf.Distribution(this, distributionName, {
comment: '',
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,
},
],
},
additionalBehaviors: {
'v1/*': {
origin: new origins.HttpOrigin(apiEndPointDomainName, {}),
allowedMethods: cf.AllowedMethods.ALLOW_ALL,
viewerProtocolPolicy: cf.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
cachePolicy: new cf.CachePolicy(
this,
`${distributionName}-rest-api-cache-policy`,
{
cachePolicyName: `${distributionName}-rest-api-cache-policy`,
comment: 'CloudFront + ApiGateway用ポリシー',
headerBehavior: cf.CacheHeaderBehavior.allowList(
'x-api-key',
'content-type',
),
},
),
},
},
minimumProtocolVersion: cf.SecurityPolicyProtocol.TLS_V1_2_2021,
// 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,
) {
// Deploy site contents to S3 bucket
new s3deploy.BucketDeployment(
this,
`${bucketName}-deploy-with-invalidation`,
{
sources: [s3deploy.Source.asset(sourcePath)],
destinationBucket: siteBucket,
distribution,
distributionPaths: ['/*'],
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)
}
}
cdk/.env
PROJECT_ID=hogefuga
ROOT_DOMAIN=hoge.example.com
DEPLOY_DOMAIN=www.hogexxx.example.com
TAG_PROJECT_NAME=hogehoge
REST_API_URL=https://piyo.execute-api.ap-northeast-1.amazonaws.com
追加:CloudFrontからAPIGatewayへのアクセスにx-api-keyを追加。
cdk/lib/cdk-stack.ts
additionalBehaviors: {
'v1/*': {
origin: new origins.HttpOrigin(apiEndPointDomainName, {
+ customHeaders: {
+ 'x-api-key': apiKey,
+ },
}),
.env
PROJECT_ID=hogefuga
ROOT_DOMAIN=hoge.example.com
DEPLOY_DOMAIN=www.hogexxx.example.com
TAG_PROJECT_NAME=hogehoge
REST_API_URL=https://piyo.execute-api.ap-northeast-1.amazonaws.com
+ X_API_KEY=piyopiyo
cdk/bin/cdk.ts
#!/usr/bin/env node
import 'source-map-support/register'
import * as cdk from 'aws-cdk-lib'
import { AWSCarTaGraphClientStack } from '../lib/cdk-stack'
import * as dotenv from 'dotenv'
dotenv.config()
const envList = [
'PROJECT_ID',
'ROOT_DOMAIN',
'DEPLOY_DOMAIN',
'TAG_PROJECT_NAME',
'REST_API_URL',
'X_API_KEY',
] 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 AWSCarTaGraphClientStack(app, `${projectId}-stack`, {
bucketName: `${projectId}-s3-bucket`,
identityName: `${projectId}-origin-access-identity-to-s3-bucket`,
defaultCachePolicyName: `${projectId}-cache-policy-default`,
imageCachePolicyName: `${projectId}-cache-policy-image`,
functionName: `${projectId}-lambda-edge-ogp`,
distributionName: `${projectId}-distribution-cloudfront`,
rootDomain: processEnv.ROOT_DOMAIN,
deployDomain: processEnv.DEPLOY_DOMAIN,
projectNameTag: processEnv.TAG_PROJECT_NAME,
restApiUrl: processEnv.REST_API_URL,
apiKey: processEnv.X_API_KEY,
env,
})
参考
CloudFront・S3・API GatewayでマルチオリジンなSPAサイトを作ってみる