シンプルかつセキュアなCloudFront+S3のホスティング環境をCloudFormationで作成する

はじめに

最近は静的サイトジェネレーター+静的サイトホスティングで安価に高性能なサイトを運営することが多いのではないかと思います。本ブログもHugoで生成したファイルをAWS CloudFront + Amazon S3でホスティングしています(2024年4月現在)。

本記事ではCloudFrontとS3を使ったインフラをAWS CloudFormationで記述します。なるべくシンプルに、かつなるべく現状で推奨されるセキュアな設定での構築を意識しています。

調査

まずは既存の記事を検索していろいろと調べてみます。

検索してまず目に留まるのがクラスメソッドさんによる記事。さすが全体がうまく整理されていて図も多くてわかりやすいですね。

今回私が書く記事はこの中の「2. S3 REST APIを使うパターン」に相当します。やはりアクセス経路がCloudFrontとS3の両方ある、かつHTTP (not SSL) の経路があるというのは気持ち悪さがあります。

以下の記事でもオリジンへのアクセスを禁止する構成です。

ですがどちらのパターンにおいても本記事のものとは少し違いがあり、本記事ではOAIではなくOACという仕組みを使っています。AWSの公式ドキュメントでも以下のように書かれています(2024年1月現在)。

OAC は以下をサポートしているため、OAC の使用をお勧めします。

  • すべての AWS リージョンのすべての Amazon S3 バケット (2022 年 12 月以降に開始されたオプトインリージョンを含む)
  • AWS KMS による Amazon S3 サーバー側の暗号化 (SSE-KMS)
  • Amazon S3 に対する動的なリクエスト (PUT と DELETE)

Amazon S3 オリジンへのアクセスの制限

続いてもクラスメソッドさんの記事。こちらも非常にわかりやすいです。

こちらはS3オブジェクトの暗号化にKMSを用いています。しかしS3バケットはデフォルトでKMSキーによる暗号化がされるようになったため、自前の鍵を使う場合などを除けばあえて自身でKMSキーのリソースを管理する必要はありません。

Amazon S3 では、Amazon S3 のすべてのバケットに対する基本レベルの暗号化として、Amazon S3 マネージドキーによるサーバー側の暗号化 (SSE-S3) が適用されるようになりました。2023 年 1 月 5 日以降、Amazon S3 にアップロードされるすべての新しいオブジェクトは、追加費用なしで、パフォーマンスに影響を与えずに自動的に暗号化されます。

AWS KMS キーによるサーバー側の暗号化 (SSE-KMS) の使用

また本記事では個人ブログのデプロイを想定しているため独自ドメインの設定まで行うようにしています。S3からCloudFrontまですべて1つのテンプレートで済むようにも意識しています。

設計

構成図は以下の通りです。

今回の構成図

Hugoなどで生成したコンテンツはS3バケットに配置し、それをCloudFront経由で配信します。

以下に今回の構成のポイントを記載します。

  • 独自ドメインを使うためにAWS Certificate Manager (ACM) で証明書を発行する
  • ドメインはRoute53に限らない
    • ただし、サブドメインを使わない場合はトップでCNAMEを指定できるサービスである必要があります(本サイトではCloudflareで登録しています)
  • S3バケットはap-northeast-1リージョンに配置する
  • CloudFrontの設定は現時点(2023年12月)で推奨とされるものを優先的に利用
    • Origin access identity ではなく Origin access control を利用
    • Legacy cache setting ではなく Cache policy and origin request policy を利用
  • ディレクトリ名でアクセスしたときにindex.htmlの内容を表示(Hugoではこの設定が必要)

AWSを利用する上で気になるのは料金かもしれません。CloudFrontやS3には毎月適用される無料枠があるため、よっぽど大ヒットしない限りは無料で運用できるかと思います。

  • データ転送: 1TBまで
  • リクエスト件数: 1千万件まで
  • CloudFront Functions 呼び出し: 200万件まで

参考: Amazon CloudFront の料金

注意

Certificate Manager で発行する証明書はリージョンをus-east-1にする必要がありますが、今回の場合はS3バケットをなるべくエンドユーザーの近くに配置しておきたいため、ap-northeast-1にデプロイしたいです。1つのCloudFormationテンプレートでは1つのリージョンにしかデプロイできないため、今回は証明書を無理にコード管理する必要は無いと考え、事前にコンソールなどで作成しておくものとします。

もし複数リージョンにまたがって一度にデプロイしたい場合は以下の記事が参考になるかもしれません。

コード

以下のパラメータを受け取るテンプレートを作成しました。

  • DomainName: サイトのドメイン。本サイトでは rkd3.dev となる。
  • StaticContentBucketName: 静的ファイルを置くS3バケット名。
  • CertificateArn: 事前にACMで発行した証明書のARN。

別サイトを同じテンプレートで作成しようとするとリソース名の重複が発生するため、別AWSアカウントを発行するか、名前を変更する必要がありますのでご注意ください。

AWSTemplateFormatVersion: '2010-09-09'
Description: CloudFormation template for serving static content using S3 and CloudFront.

Parameters:
  DomainName:
    Type: String
    Description: The custom domain name for the CloudFront distribution.
  StaticContentBucketName:
    Type: String
    Description: The backet name for put static contents.
  CertificateArn:
    Type: String
    Description: The certificate ARN.

Resources:
  StaticContentBucket:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: !Ref StaticContentBucketName

  S3BucketPolicy:
    Type: AWS::S3::BucketPolicy
    Properties:
      Bucket: !Ref StaticContentBucket
      PolicyDocument:
        Statement:
          - Sid: AllowCloudFrontServicePrincipal
            Action:
              - s3:GetObject
              - s3:ListBucket
            Effect: Allow
            Resource:
              - !Sub 'arn:aws:s3:::${StaticContentBucket}/*'
              - !Sub 'arn:aws:s3:::${StaticContentBucket}'
            Principal:
              Service: cloudfront.amazonaws.com
            Condition:
              StringEquals:
                AWS:SourceArn: !Sub 'arn:aws:cloudfront::${AWS::AccountId}:distribution/${CloudFrontDistribution.Id}'

  CloudFrontOAC:
    Type: AWS::CloudFront::OriginAccessControl
    Properties:
      OriginAccessControlConfig:
        Name: 'OAC'
        OriginAccessControlOriginType: s3
        SigningBehavior: always
        SigningProtocol: sigv4

  CachePolicy:
    Type: AWS::CloudFront::CachePolicy
    Properties:
      CachePolicyConfig:
        Name: !Sub 'caching-optimized-policy'
        Comment: Custom cache policy similar to CachingOptimized
        DefaultTTL: 86400
        MaxTTL: 31536000
        MinTTL: 1
        ParametersInCacheKeyAndForwardedToOrigin:
          CookiesConfig:
            CookieBehavior: none
          EnableAcceptEncodingBrotli: true
          EnableAcceptEncodingGzip: true
          HeadersConfig:
            HeaderBehavior: none
          QueryStringsConfig:
            QueryStringBehavior: none

  CloudFrontDistribution:
    Type: AWS::CloudFront::Distribution
    Properties:
      DistributionConfig:
        Origins:
          - DomainName: !GetAtt StaticContentBucket.RegionalDomainName
            Id: S3Origin
            OriginAccessControlId: !Ref CloudFrontOAC
            S3OriginConfig: {}
        Aliases:
          - !Ref DomainName
        Enabled: true
        DefaultCacheBehavior:
          Compress: true
          CachePolicyId: !Ref CachePolicy
          TargetOriginId: S3Origin
          ViewerProtocolPolicy: redirect-to-https
          ForwardedValues:
            QueryString: false
            Cookies:
              Forward: none
          FunctionAssociations:
            - EventType: viewer-request
              FunctionARN: !GetAtt IndexHtmlRedirectFunction.FunctionARN
        DefaultRootObject: index.html
        ViewerCertificate:
          AcmCertificateArn: !Ref CertificateArn
          SslSupportMethod: sni-only
          MinimumProtocolVersion: TLSv1.2_2018

  IndexHtmlRedirectFunction:
    Type: AWS::CloudFront::Function
    Properties:
      Name: !Sub 'redirect-to-index-html'
      AutoPublish: true
      FunctionCode: |
        function handler(event) {
            var request = event.request;
            var uri = request.uri;
            if (uri.endsWith('/')) {
                request.uri += 'index.html';
            } else if (!uri.includes('.')) {
                request.uri += '/index.html';
            }
            return request;
        }        
      FunctionConfig:
        Comment: Redirect to index.html for subdirectories
        Runtime: cloudfront-js-2.0

Outputs:
  S3BucketName:
    Value: !Ref StaticContentBucket
    Description: Name of the S3 bucket for static content.

  CloudFrontDistributionDomainName:
    Value: !GetAtt CloudFrontDistribution.DomainName
    Description: Domain name of the CloudFront distribution.

デプロイ後の処理

デプロイ後にはOutputsで指定している CloudFrontDistributionDomainName を、CNAMEレコードの値として登録する必要があります。

CNAMEレコードの設定イメージ

さいごに

これでS3に保存した静的サイトをCloudFront経由で配信することができるようになりました。AWSでのインフラ構築は一見取っつきづらいですが、それに見合うだけの柔軟性を持ったシステムが構築できます。ぜひ本記事をベースとしてより発展的なシステム構築に挑戦してみていただければと思います。

最終更新 2024-04-21

広告

本記事はお役に立てたでしょうか。本ブログでは匿名でのコメントや少額から(15円~)の寄付などを受け付けております。もしお役に立てたのであればご支援いただけると大変励みになります。

Built with Hugo
テーマ StackJimmy によって設計されています。