CDKでALBとS3をOriginとするCloudFrontのDistributionを作成する

aws

Amazon CloudFront は CDN のサービスで、 エッジサーバーにレスポンスをキャッシュすることでレイテンシを改善させたりオリジンへの負荷を軽減させたりすることができる。 S3 などをオリジンとする静的ファイルの配信が主なユースケースではあるが、ALB などの前段に置くことで動的なレスポンスを返すこともできる。 その場合、キャッシュを無効にすることになるのでオリジンへのリクエスト数は減らないがコネクションを使い回せる分いくらか負荷が軽減されるのと、 エッジサーバーから先は AWS 内のネットワークを通るのでレイテンシの改善も期待できる。

料金 は基本的にリクエスト数とインターネットへの転送量に対してかかり、 DELETE, OPTIONS, PATCH, POST, PUT リクエストの場合はオリジンへの転送量に対してもかかる。

S3 をオリジンとする場合は OriginAccessIndentity で CloudFront にアクセスを許可することで、オリジンへ直接リクエストされることを防ぐことができる。 L2 construct が 実装されたら Origin Access Controll (OAC) に移行しようと思う。

import * as s3 from 'aws-cdk-lib/aws-s3'
import * as cloudfront from 'aws-cdk-lib/aws-cloudfront'

const bucket = new s3.Bucket(this, 'OriginBucket', {
  bucketName: '<bucket_name>',
})

const oai = new cloudfront.OriginAccessIdentity(this, 'OriginAccessIdentity');    
bucket.grantRead(oai);

ALB をオリジンとする場合は CloudFront のマネージドプレフィックスリストを inbound に指定した上で、 対象のポートを全開放するルールが自動で追加されないよう Listener の open を false にすることで直接リクエストされることを防ぐことができる。 なお internetFacing は true でないと CloudFront からアクセスできない。 マネージドプレフィックスリストに含まれる CIDR の数が多いため The maximum number of rules per security group has been reached になる場合があり、その場合は緩和申請すればよい。

import * as ec2 from 'aws-cdk-lib/aws-ec2'
import * as elbv2 from 'aws-cdk-lib/aws-elasticloadbalancingv2'
import * as elbtargets from 'aws-cdk-lib/aws-elasticloadbalancingv2-targets'
import * as lambda from 'aws-cdk-lib/aws-lambda'

const originALBSecurityGroup = new ec2.SecurityGroup(this, 'OriginALBSecurityGroup', { vpc });
originALBSecurityGroup.addIngressRule(
  // com.amazonaws.global.cloudfront.origin-facing in us-east-1
  ec2.Peer.prefixList('pl-3b927c52'), 
  ec2.Port.tcp(80)
)

const originALB = new elbv2.ApplicationLoadBalancer(this, 'OriginALB', {
  vpc,
  internetFacing: true,
  securityGroup: originALBSecurityGroup,
})

const albLambda = new lambda.Function(this, 'ALBLambda', {
  runtime: lambda.Runtime.NODEJS_16_X,
  handler: 'index.handler',
  code: lambda.Code.fromInline(`
    exports.handler = async (event) => {
      const response = {
        statusCode: 200,
        headers: {
          'content-type': 'application/json',
        },
        body: JSON.stringify({
          currentTime: new Date().toISOString(),
          requestHeaders: event.headers,
        }),
      }
      return response
    };
  `),
})

originALB.addListener('Listener', {
    port: 80,
    open: false,
    defaultTargetGroups: [
      new elbv2.ApplicationTargetGroup(this, 'DefaultTargetGroup', {
        targets: [new elbtargets.LambdaTarget(albLambda)],
      })
    ]
})

Distrubtion はユーザーから viewerProtocolPolicy のプロトコルのリクエストを受け、オリジンに protocolPolicy のプロトコルでリクエストする。 viewerProtocolPolicy を REDIRECT_TO_HTTPS や HTTPS_ONLY にして protocolPolicy を HTTP_ONLY にすることで SSL 終端として用いることができるが、 そうするとオリジンと HTTP/2 で通信することができない

originRequestPolicy でオリジンへのリクエストのヘッダーやクエリパラメータをフィルタできて、ALL_VIEWER_EXCEPT_HOST_HEADER にすると Host ヘッダーがオリジンのものに置き換わる。そうした場合、Host ヘッダーの値から生成した自身のURLは CloudFront を通さず、直接のリクエストを防いでいる場合はアクセスできないものになることに注意が必要。

import * as certificatemanager from 'aws-cdk-lib/aws-certificatemanager'
import * as origins from 'aws-cdk-lib/aws-cloudfront-origins'

const certificate = certificatemanager.Certificate.fromCertificateArn(this, 'Certificate', certificateArn);

const distribution = new cloudfront.Distribution(this, 'Distribution', {
  certificate,
  domainNames: [domainName],
  enableIpv6: true,
  defaultBehavior: {
    origin: new origins.LoadBalancerV2Origin(lb, { protocolPolicy: cloudfront.OriginProtocolPolicy.HTTP_ONLY }),
    viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
    cachePolicy: cloudfront.CachePolicy.CACHING_DISABLED,

    // All headers in the viewer request except for the Host header
    originRequestPolicy: cloudfront.OriginRequestPolicy.ALL_VIEWER_EXCEPT_HOST_HEADER,
  },
  additionalBehaviors: {
    '/static/*': {
      origin: new origins.S3Origin(bucket, { originAccessIdentity: oai }),
      viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,

      // doesn't include any query strings or cookies in the cache key, and only includes the normalized Accept-Encoding header.
      // https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/using-managed-cache-policies.html#managed-cache-caching-optimized
      cachePolicy: cloudfront.CachePolicy.CACHING_OPTIMIZED,

      // Headers included in origin requests: Origin, Access-Control-Request-Headers, Access-Control-Request-Method
      // https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/using-managed-origin-request-policies.html#managed-origin-request-policy-cors-s3
      originRequestPolicy: cloudfront.OriginRequestPolicy.CORS_S3_ORIGIN,
    },
  },
})

domainNames を指定する場合は証明書が必要で、併せて DNS の設定を行う必要がある。

import * as route53 from 'aws-cdk-lib/aws-route53'

const hostedZone = route53.HostedZone.fromLookup(this, 'HostedZone', {
  domainName: domainName.split('.').slice(1).join('.'),
})

new route53.ARecord(this, 'AliasRecord', {
  zone: hostedZone,
  target: route53.RecordTarget.fromAlias(new route53Targets.CloudFrontTarget(distribution)),
  recordName: domainName,
})

同じ domainName を複数の Distribution に指定できず、*.sambaiz.net のようなワイルドカードより aaa.sambaiz.net のような非ワイルドカードの Distribution が優先されるため、DNS レベルで一部のリクエストを流してテストしたりすることはできないが、continuous deployment によって staging distribution に 最大 15% のリクエストを流すことができる。