1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

aws cdk v2 で cloud front に S3とApiGatewayのオリジンを紐づけたメモ

Last updated at Posted at 2022-03-31

概要

以下のような構造にするときに参考になる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サイトを作ってみる

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?