[AWS CDK] 一撃でCloudFrontとS3を使ったWebサイトを構築してみた

[AWS CDK] 一撃でCloudFrontとS3を使ったWebサイトを構築してみた

CloudFrontとS3を使った静的Webサイトが欲しい時に
Clock Icon2024.03.28

パッと静的Webサイトを用意したい

こんにちは、のんピ(@non____97)です。

皆さんはパッと静的Webサイトを用意したいなと思ったことはありますか? 私はあります。

AWS上で静的Webサイトを構築するとなると思いつくのは「CloudFront + S3」の構成です。しかし、OACの設定をしたりアクセスログの設定をしたりと意外と設定する項目が多く大変です。そのため、検証目的で用意する際には手間がかかります。

毎回都度用意するのも面倒なので、AWS CDKを使って一撃で構築できるようにしてみました。(Route 53 Public Hosted Zoneを作成する場合は二撃です)

AWS CDKのコードの紹介

やっていること

AWS CDKのコードは以下リポジトリに保存しています。

https://github.com/non-97/cloudfront-s3-website/tree/v1.0.0

やっていることは以下のとおりです。

  • Route 53 Public Hosted Zoneの作成 または インポート (Optional)
  • ACM証明書の作成 または インポート (Optional)
  • S3サーバーアクセスログ用S3バケットの作成 (Optional)
  • CloudFrontのアクセスログ用S3バケットの作成 (Optional)
  • Webサイトのコンテンツを保存するS3バケットの作成
  • ディレクトリインデックス用のCloudFront Functionsの作成 (Optional)
  • ディレクトリインデックス用のLambda@Edgeの作成 (Optional)
  • CloudFront ディストリビューションの作成
  • CloudFront OACの設定
  • Route 53 Public Hosted ZoneにCloudFront ディストリビューションのALIASレコードを作成 (Optional)
  • Webサイトのコンテンツを保存するS3バケットにコンテンツをアップロード (Optional)

ディレクトリインデックス用の仕組みはCloudFront FunctionsとLambda@Edgeの2種類を用意しました。CloudFront Functionsの方がシンプルではあるのですが、CloudFront Functionsのリクエストに対するコストが気になる人もいるかと思います。キャッシュのTTLが長いなどキャッシュヒット率も高いであれば、オリジンリクエストで実行が可能なLambda@Edgeの方がコストが安くなるケースがあります。詳細は以下記事をご覧ください。

https://dev.classmethod.jp/articles/sometimes-cf2-price-is-higher-than-lae/

「そもそもディレクトリインデックスとは?」という方は以下AWS Blogをご覧ください。

https://aws.amazon.com/jp/blogs/news/implementing-default-directory-indexes-in-amazon-s3-backed-amazon-cloudfront-origins-using-cloudfront-functions/

S3バケットへのコンテンツのアップロードはaws_s3_deployment.BucketDeploymentを使用しています。指定したディレクトリパスをzipで固めてからアップロードされます。指定したディレクトリ内のファイルを少しでも追加すると、全てのファイルがアップロードされ直されます。あまりに大量のコンテンツがある場合はエラーになるかもしれないので注意してください。

AWS WAFの設定はデプロイ後にお好みでどうぞ。今だとマネジメントコンソールから簡単にAWS WAFのWebACLの作成とディストリビューションへのアタッチができます。

https://dev.classmethod.jp/articles/amazon-cloudfront-aws-waf-one-click-update/

CloudFrontディストリビューションの設定

CloudFrontディストリビューション周りの設定は以下のとおりです。

./lib/construct/contents-delivery-construct.ts
    this.distribution = new cdk.aws_cloudfront.Distribution(this, "Default", {
      defaultRootObject: "index.html",
      errorResponses: [
        {
          ttl: cdk.Duration.minutes(1),
          httpStatus: 403,
          responseHttpStatus: 403,
          responsePagePath: "/error.html",
        },
        {
          ttl: cdk.Duration.minutes(1),
          httpStatus: 404,
          responseHttpStatus: 404,
          responsePagePath: "/error.html",
        },
      ],
      defaultBehavior: {
        origin: new cdk.aws_cloudfront_origins.S3Origin(
          props.websiteBucketConstruct.bucket
        ),
        allowedMethods: cdk.aws_cloudfront.AllowedMethods.ALLOW_GET_HEAD,
        cachedMethods: cdk.aws_cloudfront.CachedMethods.CACHE_GET_HEAD,
        cachePolicy: cdk.aws_cloudfront.CachePolicy.CACHING_OPTIMIZED,
        viewerProtocolPolicy:
          cdk.aws_cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
        responseHeadersPolicy:
          cdk.aws_cloudfront.ResponseHeadersPolicy.SECURITY_HEADERS,
        functionAssociations: directoryIndexCF2
          ? [
              {
                function: directoryIndexCF2,
                eventType: cdk.aws_cloudfront.FunctionEventType.VIEWER_REQUEST,
              },
            ]
          : undefined,
        edgeLambdas: directoryIndexLambdaEdge
          ? [
              {
                functionVersion: directoryIndexLambdaEdge.currentVersion,
                eventType:
                  cdk.aws_cloudfront.LambdaEdgeEventType.ORIGIN_REQUEST,
              },
            ]
          : undefined,
      },
      httpVersion: cdk.aws_cloudfront.HttpVersion.HTTP2_AND_3,
      priceClass: cdk.aws_cloudfront.PriceClass.PRICE_CLASS_ALL,
      domainNames: props.domainName ? [props.domainName] : undefined,
      certificate: props.domainName
        ? props.certificateConstruct?.certificate
        : undefined,
      logBucket: props.cloudFrontAccessLogBucketConstruct?.bucket,
      logFilePrefix: props.logFilePrefix,
    });

現在はキャッシュポリシーとレスポンスヘッダーポリシーのどちらもマネージドのものを使用しています。

キャッシュの設定を変更したい場合は、以下AWS公式ドキュメントを参考にしてキャッシュポリシーを作成ください。

https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/controlling-the-cache-key.html

また、レスポンスヘッダーからserverを削除したい場合は、以下記事を参考に設定してください。

https://dev.classmethod.jp/articles/cloudfront-removal-response-headers/

各種パラメーター

各種パラメーターの設定は以下ファイルで行います。

./parameter/index.ts
import * as cdk from "aws-cdk-lib";
import * as path from "path";

export interface LifecycleRule {
  prefix?: string;                                      // ライフサイクルルールを適用するオブジェクトのプレフィックス
  expirationDays: number;                               // オブジェクトの保持期間
  ruleNameSuffix?: string;                              // ライフサイクルルールに付与するサフィックス
  abortIncompleteMultipartUploadAfter?: cdk.Duration;   // 不完全なマルチパートアップロードを削除するまでの期間
}

export interface AccessLog {
  enableAccessLog?: boolean;                            // アクセスログを有効化するか
  logFilePrefix?: string;                               // 出力するアクセスログのプレフィックス
  lifecycleRules?: LifecycleRule[];                     // 適用するライフサイクルルール
}

export interface HostZoneProperty {
  zoneName?: string;                                    // Public Hosted Zoneのゾーン名
  hostedZoneId?: string;                                // 既存のPublic Hosted ZoneのID
}

export interface CertificateProperty {
  certificateArn?: string;                              // 既存のACM証明書のID
  certificateDomainName?: string;                       // ACM証明書のドメイン名
}

export interface ContentsDeliveryProperty {
  domainName?: string;                                  // CloudFrontディストリビューションに設定するドメイン名
  contentsPath?: string;                                // Webサイト用のS3バケットにPUTするコンテンツのローカルパス
  enableDirectoryIndex?: "cf2" | "lambdaEdge" | false;  // ディレクトリインデックス機能の実装方法
  enableS3ListBucket?: boolean;                         // CloudFrontディストリビューションからS3バケットに対して s3:ListBucket を許可するか 存在しないオブジェクトにアクセスした場合に404で返したい場合は有効化
}

export interface WebsiteProperty {
  hostedZone?: HostZoneProperty;
  certificate?: CertificateProperty;
  contentsDelivery?: ContentsDeliveryProperty;
  allowDeleteBucketAndObjects?: boolean;                // S3バケットの削除 および S3バケット内のオブジェクトを削除するか
  s3ServerAccessLog?: AccessLog;
  cloudFrontAccessLog?: AccessLog;
}

export interface WebsiteStackProperty {
  env?: cdk.Environment;
  props: WebsiteProperty;
}

export const websiteStackProperty: WebsiteStackProperty = {
  env: {
    account: process.env.CDK_DEFAULT_ACCOUNT,
    region: process.env.CDK_DEFAULT_REGION,
  },
  props: {
    hostedZone: {
      zoneName: "www.non-97.net",
    },
    certificate: {
      certificateDomainName: "www.non-97.net",
    },
    contentsDelivery: {
      domainName: "www.non-97.net",
      contentsPath: path.join(__dirname, "../lib/src/contents"),
      enableDirectoryIndex: "cf2",
      enableS3ListBucket: true,
    },
    allowDeleteBucketAndObjects: true,
    s3ServerAccessLog: {
      enableAccessLog: true,
      lifecycleRules: [{ expirationDays: 365 }],
    },
    cloudFrontAccessLog: {
      enableAccessLog: true,
      lifecycleRules: [{ expirationDays: 365 }],
    },
  },
};

デプロイ (Ver. CloudFront Functions)

デプロイ

実際にデプロイして試してみます。

設定は以下のようにしています。

./parameter/index.ts
export const websiteStackProperty: WebsiteStackProperty = {
  env: {
    account: process.env.CDK_DEFAULT_ACCOUNT,
    region: process.env.CDK_DEFAULT_REGION,
  },
  props: {
    hostedZone: {
      zoneName: "www.non-97.net",
    },
    certificate: {
      certificateDomainName: "www.non-97.net",
    },
    contentsDelivery: {
      domainName: "www.non-97.net",
      contentsPath: path.join(__dirname, "../lib/src/contents"),
      enableDirectoryIndex: "cf2",
      enableS3ListBucket: true,
    },
    allowDeleteBucketAndObjects: true,
    s3ServerAccessLog: {
      enableAccessLog: true,
      lifecycleRules: [{ expirationDays: 365 }],
    },
    cloudFrontAccessLog: {
      enableAccessLog: true,
      lifecycleRules: [{ expirationDays: 365 }],
    },
  },
};

デプロイが走ると、Route 53 Public Hosted Zoneが作成されます。NSレコードを上位のゾーン(私の場合はnon-97.net)に登録しましょう。登録が完了してしばらくすると、ACMでの証明書の発行など後続の処理が完了します。

全体で8分ほどでデプロイが完了しました。

動作確認

実際にアクセスしてみましょう。

$ curl https://www.non-97.net -IL
HTTP/2 200
content-type: text/html
content-length: 12
date: Thu, 28 Mar 2024 07:07:52 GMT
last-modified: Thu, 28 Mar 2024 06:45:06 GMT
etag: "56aec8b7843df637b3fb2ec0b027e5b6"
x-amz-server-side-encryption: AES256
accept-ranges: bytes
server: AmazonS3
x-cache: Miss from cloudfront
via: 1.1 d1fa9409a9380374423ca786990631ba.cloudfront.net (CloudFront)
x-amz-cf-pop: NRT57-P2
alt-svc: h3=":443"; ma=86400
x-amz-cf-id: oPZk8a0JVb2HagQLZyIjmmYNEql7pU7Dt6pd6fPbZ9BVZEOtBTSB7Q==
x-xss-protection: 1; mode=block
x-frame-options: SAMEORIGIN
referrer-policy: strict-origin-when-cross-origin
x-content-type-options: nosniff
strict-transport-security: max-age=31536000

$ curl https://www.non-97.net -IL
HTTP/2 200
content-type: text/html
content-length: 12
date: Thu, 28 Mar 2024 07:07:52 GMT
last-modified: Thu, 28 Mar 2024 06:45:06 GMT
etag: "56aec8b7843df637b3fb2ec0b027e5b6"
x-amz-server-side-encryption: AES256
accept-ranges: bytes
server: AmazonS3
x-cache: Hit from cloudfront
via: 1.1 180bb14f3969a5383ec3b52ad1ce5ad6.cloudfront.net (CloudFront)
x-amz-cf-pop: NRT57-P2
alt-svc: h3=":443"; ma=86400
x-amz-cf-id: mOimDjaGRT4Jsml8gthlfSl2TCYuO1qYFSKR-XNbBdqizL_2HDytWQ==
age: 9
x-xss-protection: 1; mode=block
x-frame-options: SAMEORIGIN
referrer-policy: strict-origin-when-cross-origin
x-content-type-options: nosniff
strict-transport-security: max-age=31536000

$ curl https://www.non-97.net/index.html -IL
HTTP/2 200
content-type: text/html
content-length: 12
date: Thu, 28 Mar 2024 07:07:52 GMT
last-modified: Thu, 28 Mar 2024 06:45:06 GMT
etag: "56aec8b7843df637b3fb2ec0b027e5b6"
x-amz-server-side-encryption: AES256
accept-ranges: bytes
server: AmazonS3
x-cache: Hit from cloudfront
via: 1.1 6a4098eaf995c1e965d6434534971664.cloudfront.net (CloudFront)
x-amz-cf-pop: NRT57-P2
alt-svc: h3=":443"; ma=86400
x-amz-cf-id: Aer0EiYUTtwi0u3cFT2qXVAUc18N22GIS0YornuBzbjvjmHGmU41cg==
age: 16
x-xss-protection: 1; mode=block
x-frame-options: SAMEORIGIN
referrer-policy: strict-origin-when-cross-origin
x-content-type-options: nosniff
strict-transport-security: max-age=31536000

CloudFrontのキャッシュが効いていそうです。HSTSのヘッダーも追加されていますね。

HTTPでアクセスした場合にHTTPSにリダイレクトするようにもしています。

$ curl http://www.non-97.net/test.html -IL
HTTP/1.1 301 Moved Permanently
Server: CloudFront
Date: Thu, 28 Mar 2024 07:10:14 GMT
Content-Type: text/html
Content-Length: 167
Connection: close
Location: https://www.non-97.net/test.html
X-Cache: Redirect from cloudfront
Via: 1.1 b93822242d240fe957b16155421ce866.cloudfront.net (CloudFront)
X-Amz-Cf-Pop: NRT57-P2
Alt-Svc: h3=":443"; ma=86400
X-Amz-Cf-Id: UuGHMYX2UCWQJswF-E7YwhQMWiHjHmGZBAeehOv3EsP7xdDklBuUWw==
X-XSS-Protection: 1; mode=block
X-Frame-Options: SAMEORIGIN
Referrer-Policy: strict-origin-when-cross-origin
X-Content-Type-Options: nosniff

HTTP/2 200
content-type: text/html
content-length: 11
date: Thu, 28 Mar 2024 07:10:15 GMT
last-modified: Thu, 28 Mar 2024 06:45:06 GMT
etag: "aa83444f341b53601faa67868d57abd6"
x-amz-server-side-encryption: AES256
accept-ranges: bytes
server: AmazonS3
x-cache: Miss from cloudfront
via: 1.1 aaaa38f6638fefc2221f20ff18eceef2.cloudfront.net (CloudFront)
x-amz-cf-pop: NRT57-P2
alt-svc: h3=":443"; ma=86400
x-amz-cf-id: gBa6aohc9hBViovBo_1EDDcYEJH57q_TqALrOG0AuHn1D4pnSsb95A==
x-xss-protection: 1; mode=block
x-frame-options: SAMEORIGIN
referrer-policy: strict-origin-when-cross-origin
x-content-type-options: nosniff
strict-transport-security: max-age=31536000

ディレクトリインデックスが動作していることも確認しておきます。

$ curl https://www.non-97.net/dir -IL
HTTP/2 200
content-type: text/html
content-length: 16
date: Thu, 28 Mar 2024 07:10:48 GMT
last-modified: Thu, 28 Mar 2024 06:45:05 GMT
etag: "64f1d28c08f68bb7a25dd16598eed1d2"
x-amz-server-side-encryption: AES256
accept-ranges: bytes
server: AmazonS3
x-cache: Miss from cloudfront
via: 1.1 c9203ba15af2ae82294719bd8bb5fcce.cloudfront.net (CloudFront)
x-amz-cf-pop: NRT57-P2
alt-svc: h3=":443"; ma=86400
x-amz-cf-id: j0a9BgZiU2SAop_jWqmixYDUQPqihmz8_GFL5JvZPqIN6utiyBOlyg==
x-xss-protection: 1; mode=block
x-frame-options: SAMEORIGIN
referrer-policy: strict-origin-when-cross-origin
x-content-type-options: nosniff
strict-transport-security: max-age=31536000

$ curl https://www.non-97.net/dir/ -IL
HTTP/2 200
content-type: text/html
content-length: 16
date: Thu, 28 Mar 2024 07:10:48 GMT
last-modified: Thu, 28 Mar 2024 06:45:05 GMT
etag: "64f1d28c08f68bb7a25dd16598eed1d2"
x-amz-server-side-encryption: AES256
accept-ranges: bytes
server: AmazonS3
x-cache: Hit from cloudfront
via: 1.1 3bc9fc5ff5b1c7e58ac789581c13d0e4.cloudfront.net (CloudFront)
x-amz-cf-pop: NRT57-P2
alt-svc: h3=":443"; ma=86400
x-amz-cf-id: rhoeQsW7atFnVL4Az4xIxkkIFNg3AMj6piy725hSqCc7D6RXOm4ZfA==
age: 7
x-xss-protection: 1; mode=block
x-frame-options: SAMEORIGIN
referrer-policy: strict-origin-when-cross-origin
x-content-type-options: nosniff
strict-transport-security: max-age=31536000

問題なく動作していますね。

CloudFrontアクセスログ、S3サーバーアクセスログも問題なく出力されています。

CloudFrontアクセスログ

CloudFrontアクセスログ

サーバーアクセスログ

S3サーバーアクセスログ

大量アクセス時のCloudFront Functionsの挙動確認

試しにApache Benchで大量にアクセスしてみます。事前にキャッシュは削除しておきます。

$ ab -n 10000 -c 100  https://www.non-97.net/dir/
This is ApacheBench, Version 2.3 <$Revision: 1903618 $>
Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/
Licensed to The Apache Software Foundation, http://www.apache.org/

Benchmarking www.non-97.net (be patient)
Completed 1000 requests
Completed 2000 requests
Completed 3000 requests
Completed 4000 requests
Completed 5000 requests
Completed 6000 requests
Completed 7000 requests
Completed 8000 requests
Completed 9000 requests
Completed 10000 requests
Finished 10000 requests

Server Software:        AmazonS3
Server Hostname:        www.non-97.net
Server Port:            443
SSL/TLS Protocol:       TLSv1.2,ECDHE-RSA-AES128-GCM-SHA256,2048,128
Server Temp Key:        ECDH X25519 253 bits
TLS Server Name:        www.non-97.net

Document Path:          /dir/
Document Length:        16 bytes

Concurrency Level:      100
Time taken for tests:   36.458 seconds
Complete requests:      10000
Failed requests:        0
Total transferred:      7267060 bytes
HTML transferred:       160000 bytes
Requests per second:    274.29 [#/sec] (mean)
Time per request:       364.582 [ms] (mean)
Time per request:       3.646 [ms] (mean, across all concurrent requests)
Transfer rate:          194.65 [Kbytes/sec] received

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:       56  298 110.3    296    1132
Processing:     9   54  64.3     29    1008
Waiting:        8   32  33.6     23    1000
Total:         71  352 131.1    333    1225

Percentage of the requests served within a certain time (ms)
  50%    333
  66%    359
  75%    383
  80%    403
  90%    487
  95%    563
  98%    693
  99%    872
 100%   1225 (longest request)

CloudFront Functionsのメトリクスを確認すると、1万回実行されていました。

CloudFront Functionsが1万回実行されていることを確認

また、Compute Utilizationも一時的に跳ねていました。

Compute Utilization

デプロイ (Ver. Lambda@Edge)

デプロイ

次にディレクトリインデックスの機能をLambda@Edgeで動かしてみましょう。

./parameter/index.ts
export const websiteStackProperty: WebsiteStackProperty = {
  env: {
    account: process.env.CDK_DEFAULT_ACCOUNT,
    region: process.env.CDK_DEFAULT_REGION,
  },
  props: {
    hostedZone: {
      zoneName: "www.non-97.net",
    },
    certificate: {
      certificateDomainName: "www.non-97.net",
    },
    contentsDelivery: {
      domainName: "www.non-97.net",
      contentsPath: path.join(__dirname, "../lib/src/contents"),
      enableDirectoryIndex: "lambdaEdge",
      enableS3ListBucket: true,
    },
    allowDeleteBucketAndObjects: true,
    s3ServerAccessLog: {
      enableAccessLog: true,
      lifecycleRules: [{ expirationDays: 365 }],
    },
    cloudFrontAccessLog: {
      enableAccessLog: true,
      lifecycleRules: [{ expirationDays: 365 }],
    },
  },
};

npx cdk diffの結果は以下のとおりです。

$ npx cdk diff
Bundling asset WebsiteStack/ContentsDeliveryConstruct/DirectoryIndexLambdaEdge/Code/Stage...

  cdk.out/bundling-temp-a88b3b0269470979bc0b8d1f8ed8a4f028c4b7f1fe42067392775b7b09619397/index.mjs  163b 

⚡ Done in 6ms
Stack WebsiteStack
Hold on while we create a read-only change set to get a diff with accurate replacement information (use --no-change-set to use a less accurate but faster template-only diff)
IAM Statement Changes
┌───┬──────────────────────────────────────────────────────────┬────────┬────────────────┬──────────────────────────────────────────────────────────────┬───────────┐
│   │ Resource                                                 │ Effect │ Action         │ Principal                                                    │ Condition │
├───┼──────────────────────────────────────────────────────────┼────────┼────────────────┼──────────────────────────────────────────────────────────────┼───────────┤
│ + │ ${ContentsDeliveryConstruct/LambdaEdgeExecutionRole.Arn} │ Allow  │ sts:AssumeRole │ Service:edgelambda.amazonaws.com                             │           │
│   │                                                          │        │                │ Service:lambda.amazonaws.com                                 │           │
└───┴──────────────────────────────────────────────────────────┴────────┴────────────────┴──────────────────────────────────────────────────────────────┴───────────┘
IAM Policy Changes
┌───┬──────────────────────────────────────────────────────┬────────────────────────────────────────────────────────────────────────────────┐
│   │ Resource                                             │ Managed Policy ARN                                                             │
├───┼──────────────────────────────────────────────────────┼────────────────────────────────────────────────────────────────────────────────┤
│ + │ ${ContentsDeliveryConstruct/LambdaEdgeExecutionRole} │ arn:${AWS::Partition}:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole │
└───┴──────────────────────────────────────────────────────┴────────────────────────────────────────────────────────────────────────────────┘
(NOTE: There may be security-related changes not in this list. See https://github.com/aws/aws-cdk/issues/1299)

Resources
[-] AWS::CloudFront::Function ContentsDeliveryConstruct/DirectoryIndexCF2 ContentsDeliveryConstructDirectoryIndexCF2F9EC47B1 destroy
[+] AWS::IAM::Role ContentsDeliveryConstruct/LambdaEdgeExecutionRole ContentsDeliveryConstructLambdaEdgeExecutionRoleF34170E4 
[+] AWS::Lambda::Function ContentsDeliveryConstruct/DirectoryIndexLambdaEdge ContentsDeliveryConstructDirectoryIndexLambdaEdgeDD789DA4 
[+] AWS::Lambda::Version ContentsDeliveryConstruct/DirectoryIndexLambdaEdge/CurrentVersion ContentsDeliveryConstructDirectoryIndexLambdaEdgeCurrentVersion93C358E81c9380644828904ade854fd138db7b43 
[~] AWS::CloudFront::Distribution ContentsDeliveryConstruct/Default ContentsDeliveryConstructE854BE87 
 └─ [~] DistributionConfig
     └─ [~] .DefaultCacheBehavior:
         ├─ [-] Removed: .FunctionAssociations
         └─ [+] Added: .LambdaFunctionAssociations

✨  Number of stacks with differences: 1

npx cdk deployでデプロイします。デプロイは3分ほどで完了しました。

動作確認

ディレクトリインデックスが効いているか確認します。

$ curl https://www.non-97.net/dir/ -IL
HTTP/2 200
content-type: text/html
content-length: 16
date: Thu, 28 Mar 2024 07:52:14 GMT
last-modified: Thu, 28 Mar 2024 06:45:05 GMT
etag: "64f1d28c08f68bb7a25dd16598eed1d2"
x-amz-server-side-encryption: AES256
accept-ranges: bytes
server: AmazonS3
x-cache: Miss from cloudfront
via: 1.1 49b964f897a5e1c9f9d0e182630ef7ca.cloudfront.net (CloudFront)
x-amz-cf-pop: NRT57-P2
alt-svc: h3=":443"; ma=86400
x-amz-cf-id: JntntZB9DIsrt0RJQkND0oCneZhrAN3B36Wk-u_dUvWl0OeyTdN4tg==
x-xss-protection: 1; mode=block
x-frame-options: SAMEORIGIN
referrer-policy: strict-origin-when-cross-origin
x-content-type-options: nosniff
strict-transport-security: max-age=31536000

$ curl https://www.non-97.net/dir/ -IL
HTTP/2 200
content-type: text/html
content-length: 16
date: Thu, 28 Mar 2024 07:52:14 GMT
last-modified: Thu, 28 Mar 2024 06:45:05 GMT
etag: "64f1d28c08f68bb7a25dd16598eed1d2"
x-amz-server-side-encryption: AES256
accept-ranges: bytes
server: AmazonS3
x-cache: Hit from cloudfront
via: 1.1 b93822242d240fe957b16155421ce866.cloudfront.net (CloudFront)
x-amz-cf-pop: NRT57-P2
alt-svc: h3=":443"; ma=86400
x-amz-cf-id: Ks4bMLN58XaZ_wp4pq2pp_7BZBBvno71I0k3u9iknrE67mfbmoJVVw==
age: 12
x-xss-protection: 1; mode=block
x-frame-options: SAMEORIGIN
referrer-policy: strict-origin-when-cross-origin
x-content-type-options: nosniff
strict-transport-security: max-age=31536000

効いていますね。

大量アクセス時のLambda@Edgeの挙動確認

Lambda@EdgeでもApache Benchで10,000回アクセスしてみます。

 ab -n 10000 -c 100  https://www.non-97.net/dir/
This is ApacheBench, Version 2.3 <$Revision: 1903618 $>
Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/
Licensed to The Apache Software Foundation, http://www.apache.org/

Benchmarking www.non-97.net (be patient)
Completed 1000 requests
Completed 2000 requests
Completed 3000 requests
Completed 4000 requests
Completed 5000 requests
Completed 6000 requests
Completed 7000 requests
Completed 8000 requests
Completed 9000 requests
Completed 10000 requests
Finished 10000 requests

Server Software:        AmazonS3
Server Hostname:        www.non-97.net
Server Port:            443
SSL/TLS Protocol:       TLSv1.2,ECDHE-RSA-AES128-GCM-SHA256,2048,128
Server Temp Key:        ECDH X25519 253 bits
TLS Server Name:        www.non-97.net

Document Path:          /dir/
Document Length:        16 bytes

Concurrency Level:      100
Time taken for tests:   35.434 seconds
Complete requests:      10000
Failed requests:        0
Total transferred:      7266592 bytes
HTML transferred:       160000 bytes
Requests per second:    282.22 [#/sec] (mean)
Time per request:       354.338 [ms] (mean)
Time per request:       3.543 [ms] (mean, across all concurrent requests)
Transfer rate:          200.27 [Kbytes/sec] received

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:       47  289  85.3    293     646
Processing:     8   52  45.6     37    1017
Waiting:        7   34  28.3     25    1010
Total:         79  342 102.2    339    1119

Percentage of the requests served within a certain time (ms)
  50%    339
  66%    373
  75%    398
  80%    417
  90%    464
  95%    528
  98%    594
  99%    636
 100%   1119 (longest request)

CloudWatchメトリクスでLambda@Edgeが呼びされた回数を見ると、該当の時間に東京リージョンで呼び出された回数は1回だけでした。

LambdaEdgeは1回しか実行されていない

Lambda@EdgeがCloudWatch Logsに出力したログを見ても、1回しか実行されていないことが分かります。キャッシュヒット率が高い場合はLambda@Edgeの方がコストが安くなるかもしれませんね。

ログを見ても1回しか実行されていない

キャッシュヒットされていることも確認しましょう。分かりづらいですが、CloudFront Functionsの場合もLambda@Edgeの場合もどちらもほぼ100%キャッシュヒットしていることが分かります。キャッシュヒットしていないのは最初のアクセスのみです。

キャッシュヒットしていることを確認

用途に応じてCloudFront Functionsか、Lambda@Edgeを使うか判断しましょう。使い分けは以下記事が参考になります。

https://dev.classmethod.jp/articles/amazon-cloudfront-functions-release/

https://dev.classmethod.jp/articles/cloudfront-functions-usecases/

なお、Lambda@Edgeの関数を削除する際には、以下のように失敗を繰り返します。(最終的には正常に削除される)

LambdaEdgeの関数を削除する場合

これは以下記事でも紹介されているとおり、Lambdaがレプリカを持っているためです。

https://dev.classmethod.jp/articles/deleting-lambda-edge-built-with-aws-cdk/

補足 : L2 ConstructでS3をオリジンに設定すると、問答無用でOAIが作成される

デプロイ後に気になる方がいるかもしれないので補足です。

CloudFrontとS3バケット間のアクセス制御はOACで行っています。OACの説明は以下記事をご覧ください。

https://dev.classmethod.jp/articles/amazon-cloudfront-origin-access-control/

OACを使うため、OAIは使用しません。AWS CDK上でもOAIの設定はしていません。しかし、OAIは自動で作成されます。

OAIが作成される

このOAIはCloudFormationのコンソールからコンストラクトツリーを表示すると、CloudFrontディストリビューションDefaultの子コンストラクトであることが分かります。

OAIのコンストラクトツリー

「じゃあthis.distribution.node.tryRemoveChild("Origin1");で、このコンストラクトを削除すれば良いじゃん!」と思われるかもしれません。しかし、これはできません。やろうとすると、以下のように怒られます。

WebsiteStack: creating CloudFormation changeset...

 ❌  WebsiteStack failed: Error [ValidationError]: Template error: instance of Fn::GetAtt references undefined resource ContentsDeliveryConstructOrigin1S3Origin9C471993
    at Request.extractError (/<ディレクトリパス>/node_modules/aws-cdk/lib/index.js:382:46692)
    at Request.callListeners (/<ディレクトリパス>/node_modules/aws-cdk/lib/index.js:382:91452)
    at Request.emit (/<ディレクトリパス>/node_modules/aws-cdk/lib/index.js:382:90900)
    at Request.emit (/<ディレクトリパス>/node_modules/aws-cdk/lib/index.js:382:199296)
    at Request.transition (/<ディレクトリパス>/node_modules/aws-cdk/lib/index.js:382:192848)
    at AcceptorStateMachine.runTo (/<ディレクトリパス>/node_modules/aws-cdk/lib/index.js:382:157720)
    at /<ディレクトリパス>/node_modules/aws-cdk/lib/index.js:382:158050
    at Request.<anonymous> (/<ディレクトリパス>/node_modules/aws-cdk/lib/index.js:382:193140)
    at Request.<anonymous> (/<ディレクトリパス>/node_modules/aws-cdk/lib/index.js:382:199371)
    at Request.callListeners (/<ディレクトリパス>/node_modules/aws-cdk/lib/index.js:382:91620) {
  code: 'ValidationError',
  time: 2024-03-27T11:24:11.495Z,
  requestId: '6761120e-8a9b-4871-b0a0-7ee1cd9e5545',
  statusCode: 400,
  retryable: false,
  retryDelay: 60.263640837447284
}

これは自動で作成されたOAIを参照して、オリジンのS3バケットのバケットポリシーを設定しているためです。

オリジンのS3バケットのバケットポリシー
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Deny",
            "Principal": {
                "AWS": "*"
            },
            "Action": "s3:*",
            "Resource": [
                "arn:aws:s3:::websitestack-websitebucketconstruct04d5d64f-6jt680fmu9eu",
                "arn:aws:s3:::websitestack-websitebucketconstruct04d5d64f-6jt680fmu9eu/*"
            ],
            "Condition": {
                "Bool": {
                    "aws:SecureTransport": "false"
                }
            }
        },
        {
            "Effect": "Allow",
            "Principal": {
                "AWS": "arn:aws:iam::<AWSアカウントID>:role/WebsiteStack-CustomS3AutoDeleteObjectsCustomResourc-nHlT8dYG9tuj"
            },
            "Action": [
                "s3:DeleteObject*",
                "s3:GetBucket*",
                "s3:List*",
                "s3:PutBucketPolicy"
            ],
            "Resource": [
                "arn:aws:s3:::websitestack-websitebucketconstruct04d5d64f-6jt680fmu9eu",
                "arn:aws:s3:::websitestack-websitebucketconstruct04d5d64f-6jt680fmu9eu/*"
            ]
        },
        {
            "Effect": "Allow",
            "Principal": {
                "AWS": "arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity E243GPZLTPBOD"
            },
            "Action": "s3:GetObject",
            "Resource": "arn:aws:s3:::websitestack-websitebucketconstruct04d5d64f-6jt680fmu9eu/*"
        },
        {
            "Effect": "Allow",
            "Principal": {
                "Service": "cloudfront.amazonaws.com"
            },
            "Action": [
                "s3:GetObject",
                "s3:ListBucket"
            ],
            "Resource": [
                "arn:aws:s3:::websitestack-websitebucketconstruct04d5d64f-6jt680fmu9eu",
                "arn:aws:s3:::websitestack-websitebucketconstruct04d5d64f-6jt680fmu9eu/*"
            ],
            "Condition": {
                "StringEquals": {
                    "AWS:SourceArn": "arn:aws:cloudfront::<AWSアカウントID>:distribution/E8PZWTYQZP0PV"
                }
            }
        }
    ]
}

GitHubのソースコードaws-cdk/packages/aws-cdk-lib/aws-cloudfront-origins/lib/s3-origin.tsを確認すると、addToResourcePolicy()でポリシーを追加していることが分かります。

packages/aws-cdk-lib/aws-cloudfront-origins/lib/s3-origin.ts
/**
 * An Origin specific to a S3 bucket (not configured for website hosting).
 *
 * Contains additional logic around bucket permissions and origin access identities.
 */
class S3BucketOrigin extends cloudfront.OriginBase {
  private originAccessIdentity!: cloudfront.IOriginAccessIdentity;

  constructor(private readonly bucket: s3.IBucket, { originAccessIdentity, ...props }: S3OriginProps) {
    super(bucket.bucketRegionalDomainName, props);
    if (originAccessIdentity) {
      this.originAccessIdentity = originAccessIdentity;
    }
  }

  public bind(scope: Construct, options: cloudfront.OriginBindOptions): cloudfront.OriginBindConfig {
    if (!this.originAccessIdentity) {
      // Using a bucket from another stack creates a cyclic reference with
      // the bucket taking a dependency on the generated S3CanonicalUserId for the grant principal,
      // and the distribution having a dependency on the bucket's domain name.
      // Fix this by parenting the OAI in the bucket's stack when cross-stack usage is detected.
      const bucketStack = cdk.Stack.of(this.bucket);
      const bucketInDifferentStack = bucketStack !== cdk.Stack.of(scope);
      const oaiScope = bucketInDifferentStack ? bucketStack : scope;
      const oaiId = bucketInDifferentStack ? `${cdk.Names.uniqueId(scope)}S3Origin` : 'S3Origin';

      this.originAccessIdentity = new cloudfront.OriginAccessIdentity(oaiScope, oaiId, {
        comment: `Identity for ${options.originId}`,
      });
    }
    // Used rather than `grantRead` because `grantRead` will grant overly-permissive policies.
    // Only GetObject is needed to retrieve objects for the distribution.
    // This also excludes KMS permissions; currently, OAI only supports SSE-S3 for buckets.
    // Source: https://aws.amazon.com/blogs/networking-and-content-delivery/serving-sse-kms-encrypted-content-from-s3-using-cloudfront/
    this.bucket.addToResourcePolicy(new iam.PolicyStatement({
      resources: [this.bucket.arnForObjects('*')],
      actions: ['s3:GetObject'],
      principals: [this.originAccessIdentity.grantPrincipal],
    }));
    return super.bind(scope, options);
  }

  protected renderS3OriginConfig(): cloudfront.CfnDistribution.S3OriginConfigProperty | undefined {
    return { originAccessIdentity: `origin-access-identity/cloudfront/${this.originAccessIdentity.originAccessIdentityId}` };
  }
}

AWS CDKでは指定したポリシーステートメントをピンポイントで削除する処理はできない認識です。そのため、OAIは削除せずにそのままにしています。

コスト試算

この環境のコストを試算してみましょう。

条件は以下のとおりです。

  • コンテンツ量 : 20GB
  • ログ出力量 : 1TB
  • アクセス数 : 10,000,000回/month
  • 1アクセス当たりの平均転送量 : 1MB
  • 転送量 : 20TB/month
  • キャッシュヒット率 : 90%
  • Public Hosted Zone : 1つ
  • ディレクトリインデックス : CloudFront Functionsで実装

試算結果は以下のとおりです。

  • S3
    • データサイズ料金 : 26.10 USD
    • GETリクエスト料金 : 0.37 USD
  • CloudFrontの
    • インターネットへのデータ転送料金 : 2,078.72 USD
    • オリジンへのデータ転送料金 : 122.88 USD
    • HTTPSリクエスト料金 : 12.00 USD
    • CloudFront Functions実行料金 : 1.00 USD
  • Route 53 Public Hosted Zone
    • Hosted Zone料金 : 0.5 USD
  • トータル料金
    • 2,240.57 USD/month (= 336,086円) ※ 1ドル150円

それなりです。

こんな時にありがたいのがクラスメソッドメンバーズのEC2・CDN割引プランです。

クラスメソッドメンバーズのEC2・CDN割引プランを使うと、なんとCloudFrontのアウトバウンド通信費が従来$0.114/GBのところ$0.0456/GBと、60%オフの料金で使用できたり、GETリクエストの料金が無料になるなどの割引があります。

EC2・CDN割引プラン

抜粋 : AWS請求代行・請求書払い(リセール) | クラスメソッド株式会社

これにより、トータルの料金はから、2,240.57 USD/month (= 336,086円)から870.96 USD/month (= 130,644円)と毎月約20万円のコスト削減になります。やったぜ。

CloudFrontとS3を使った静的Webサイトが欲しい時に

AWS CDKを使って一撃でCloudFrontとS3を使ったWebサイトを構築してみました。

CloudFrontとS3を使った静的Webサイトが欲しい時にご利用ください。上述のコードをベースにキャッシュポリシーをカスタムしたり、ログ分析用のAthenaを追加したり、コンテンツデプロイのCI/CDパイプラインを作っても良いと思います。

この記事が誰かの助けになれば幸いです。

以上、AWS事業本部 コンサルティング部の のんピ(@non____97)でした!

Share this article

facebook logohatena logotwitter logo

© Classmethod, Inc. All rights reserved.