サイトジェネレータと AWS CloudFront + S3

WordPress では、コンテンツをデータベースに格納している。 そのため、高速化するために、レンタルサーバ選びから始まり、画像を CDN に置いたり、キャッシュプラグインを入れたり、など、様々なテクニックが要求される。また、冗長構成で使う時、WordPress 本体、プラグインのアップグレードはどのようにしているのだろうか。

そんな悩みをかかえていた時、スタティックサイトジェネレータと呼ばれるツールを見つけた。Markdown でコンテンツを記述して、コマンド実行すると、サイト全体の HTML を生成するツールである。 生成したファイルを、WEB サイトにファイルを置くことで、超高速な WEB サイトを構築することができる。 安価なレンタルサーバでも十分な速度で表示される。 スタティックコンテンツなので、CDN で高速化する場合も、簡単だ。 昔風の WEB サイト構築法に戻ったような感はあるが、コンテンツ作成は格段に楽だ。

CloudFormation を使いたかった理由

CloudFormation を使いたかったのには次のような理由があった。

  • GUI で一発で設定できる(GUI だと手間がかかる)
  • サイトの削除も一発でできる
  • 1枚だけのサイトオ(ペラサイト、シングルページサイト)が検索において優位なのであれば、ペラサイトを量産したほうが得なのか実験したい

WEB サイトを一発で作る CloudFormation テンプレート

以下の cloudformation template で WEB サイトを作成する。

  • S3 バケットの作成
  • CloudFront の設定
  • SSL 証明書の設定
  • DNS レコードの作成

を実行する。

実際には、Route53 へのドメイン登録だけは AWS Console を使用している。

このテンプレートで作った WEB サイトにコンテンツをアップロードするには、S3 バケットにコンテンツをコピーする必要がある。

私が使うサイトジェネレータは hugo なのだが、AWS CloudFront + S3 の構成で hugo を使う場合、設定ファイルに記述しておけば、S3 へアップロードし、CloudFront のキャッシュのクリアまでやってくれる。

テキストエディタとコマンドラインに慣れているなら、この快適性を理解できることだろう。

cloudfronts3.yaml

# aws cloudformation create-stack --region=us-east-1 --stack-name YOUR_STACK_NAME --template-body file://YOUR_CONFIG_FILE --parameters ParameterKey=DomainName,ParameterValue=YOUR_HOST_FQDN ParameterKey=ZoneId,ParameterValue=YOUR_ZONE_ID
# CloudFront Distribution の証明書が us-east-1 にないといけないので、
# --region us-east-1 を指定

AWSTemplateFormatVersion: '2010-09-09'
Parameters:
  DomainName:
    Type: String
  ZoneId:
    Type: String

Resources:
  ContentBucket:
    Type: 'AWS::S3::Bucket'
    Properties:
      BucketName:
        Ref: DomainName
      OwnershipControls:
        Rules:
          - ObjectOwnership: BucketOwnerPreferred

  ContentBucketPolicy:
    Type: 'AWS::S3::BucketPolicy'
    Properties:
      Bucket: !Ref ContentBucket
      PolicyDocument:
        Version: 2012-10-17
        Statement:
          - Sid: AllowLegacyOAIReadOnly
            Action:
              - 's3:GetObject'
            Effect: Allow
            Resource: !Join
              - ''
              - - 'arn:aws:s3:::'
                - !Ref ContentBucket
                - /*
            Principal:
              AWS: !Join ['', ['arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity ', !GetAtt CFOAI.Id]]

  LogBucket:
    Type: 'AWS::S3::Bucket'
    Properties:
      BucketName: !Join ["", [Ref: DomainName, "-log"]]
      OwnershipControls:
        Rules:
          - ObjectOwnership: BucketOwnerPreferred

  CFOAI:
    Type: AWS::CloudFront::CloudFrontOriginAccessIdentity
    Properties:
      CloudFrontOriginAccessIdentityConfig:
        Comment: !Join [" ", ["CloudFront OAI for", !Ref DomainName]]

  # https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-certificatemanager-certificate.html
  AcmCertificate:
    Type: AWS::CertificateManager::Certificate  
    Properties:
      DomainName: !Ref DomainName
      ValidationMethod: DNS
      # automatically
      # 設定しておかないと、いつまでも CREATE_IN_PROGRESS
      # 強制終了させるにはスタックを削除?
      DomainValidationOptions:
        - DomainName: !Ref DomainName
          HostedZoneId: !Ref ZoneId

  # 末尾が / または 拡張子の無い名前の場合、自動的に index.html を付ける
  AddIndexFunction:
    Type: AWS::CloudFront::Function
    Properties:
      AutoPublish: true
      # https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/example-function-add-index.html
      FunctionCode: |
        function handler(event) {
            var request = event.request;
            var uri = request.uri;

            // Check whether the URI is missing a file name.
            if (uri.endsWith('/')) {
                request.uri += 'index.html';
            }
            // Check whether the URI is missing a file extension.
            else if (!uri.includes('.')) {
                request.uri += '/index.html';
            }

            return request;
        }
      FunctionConfig:
        Comment: "Automatically add index.html"
        Runtime: cloudfront-js-1.0
      Name: AddIndex

  CFDistribution:
    Type: AWS::CloudFront::Distribution
    Properties:
      DistributionConfig:
        Aliases:
          - !Ref DomainName
        CustomErrorResponses:
          - ErrorCode: 403
            ResponseCode: 404
            ResponsePagePath: /404.html
        Origins:
        - DomainName: !GetAtt ContentBucket.DomainName
          Id: s3ContentBucket
          S3OriginConfig:
            OriginAccessIdentity: !Join ["", [ "origin-access-identity/cloudfront/", !GetAtt CFOAI.Id ]]
        Enabled: 'true'
        DefaultRootObject: index.html
        Logging:
          Bucket: !GetAtt LogBucket.DomainName
        DefaultCacheBehavior:
          ForwardedValues:
            QueryString: true
          FunctionAssociations:
            - EventType: viewer-request
              FunctionARN: !GetAtt AddIndexFunction.FunctionARN
          TargetOriginId: s3ContentBucket
          ViewerProtocolPolicy: allow-all
        ViewerCertificate:
          AcmCertificateArn: !Ref AcmCertificate
          # 無いと、"Invalid request provided: IamCertificateId or AcmCertificateArn can be specified only if SslSupportMethod must also be specified and vice-versa." 
          SslSupportMethod: sni-only

  # https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-route53-recordset.html
  # https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/quickref-route53.html#w4ab1c23c21c84c11
  DNSRecord:
    Type: AWS::Route53::RecordSet
    Properties:
      HostedZoneId: !Ref ZoneId
      Name: !Ref DomainName
      Type: A
      AliasTarget:
        DNSName: !GetAtt CFDistribution.DomainName
        EvaluateTargetHealth: false # can't set true on cloudfront distribution
        HostedZoneId: Z2FDTNDATAQYW2 # always this value

Outputs:
  WebsiteURL:
    Value: !GetAtt CFDistribution.DomainName
    Description: URL for website hosted on S3
  CloudFrontDistributionId:
    Value: !Ref CFDistribution
    Description: CloudFront Distribution ID