※2020/03/05投稿のQiita記事をエクスポートしたものです。

S3 + CloudFront + Route 53の構成で静的ウェブサイトの配信を行っている。

レポジトリには静的ファイルだけを置いて、AWSリソースはだいぶ昔に手作業で構築したものが動き続けている。以下の記事などにおいてこの構成に太鼓判が押されているとおり、事実として非常に安定して稼働してくれている。

 AWSにおける静的コンテンツ配信パターンカタログ(アンチパターン含む) | Developers.IO 

しかし久しぶりに触ろうとしてみると、おおよその見取り図は頭に入っているとはいえ、細かい設定についてかなりうろ覚えになっている。動いているのだから問題はないはずなのだが、わからないのに動き続けているというのはどうも気持ちが悪い。

とはいえいくら頭で覚えようとしてもどうせ数ヶ月後には忘れているだろうし、それならばいったん手を動かして書き起こしてしまおうと、既存の S3 + CloudFront + Route 53 の配信パターンを CloudFormation でコード化することを試みてみた。

なぜ CloudFormation ?

Terraform にせよ CloudFormation にせよ、いずれも触った経験はあるが、いわゆる「職人」を自称できるほどではない。つまり特にこだわりがあっての選択ではない。

ちょうど数ヶ月前、2019年11月にこちらのアナウンスがあったらしいことを遅れて発見したため、せっかくなら試してみようと CloudFormation を選択した。

 AWS CloudFormation でリソースのインポートが可能に   CloudFormationがリソースのインポートに対応しました! | Developers.IO 

想定する読者

tl;dr

最終的なテンプレートがこちらになる。

website-template.yml

登場するリソース

この3つのリソースのセットで、都元ダイスケさんの言うところの「横綱」パターンを実現している。

 AWSにおける静的コンテンツ配信パターンカタログ(アンチパターン含む) | Developers.IO (再掲)

私の場合はこの構成のウェブサイトをapexドメインで運用しており、wwwサブドメインをこれにリダイレクトするためにもうひとつほぼ同じ構成を作成している。とはいえバケットの設定が多少異なるだけであるので、テンプレートもほぼ使い回しである。

なおCertificate ManagerによるSSL証明書については、ワイルドカード証明書を利用している関係で、静的ウェブホスティングと一緒に管理してはレポジトリの責務がいたずらに大きくなりすぎてしまうため今回のコード化においては対象外としている。

ゆえに証明書については、ARNをパラメータとしてCloudFormationに渡してスタックを作成させるという形を取っているが、これについては読んでいくうちにわかるはずと思う。

インポート可能なリソースの制約

元記事を斜め読みしただけだったので見落としてしまっていたが、CloudFormationにはインポート可能なリソースとそうでないリソースがある。

 インポートオペレーションをサポートするリソース 

今回定義したいスタックのうち、S3バケットは既存のリソースをインポート可能だが、S3バケットポリシー、CloudFrontディストリビューション、そしてRoute 53レコードはインポートできるようになっていない。

つまりこれらは新しく作り直して置き換えてやる必要があるということになる。

作業の見取り図

前項までの要件を踏まえて、次のような二段構えで作業していく。

  1. 既存のS3バケットをインポートしてスタックを作成する
  2. 作成したスタックに残りのリソースを定義する
  3. (置き換えられたリソースを手作業により削除する)

またこれらの作業は、ローカルでテンプレートファイルを編集、コンソールでそれを反映させる、というやり方で行う。

コード化する、というところが目下のゴールであり、CloudFormationテンプレートさえ完成すればそう頻繁に変更が生じることもないという見立てであるため、テンプレートのデプロイを継続的に行うことまではしない。

S3バケットをインポートする

テンプレートを用意する

一度に全てを記述するのは望まないミスを乱発しかねない、というのは私自身そうなりかけたためだ。

そこでまずはインポート対象のS3バケットだけを記述したシンプルなCloudFormationテンプレートを定義して、それを実行する、という方針をとる。

次のようなテンプレートを用意する。

AWSTemplateFormatVersion: 2010-09-09
Description: |
  A stack to manage an S3 bucket for hosting a static website,
  a Route 53 DNS record for using a custom domain,
  and a CloudFront Distribution for high availability.

Parameters:
  DomainName:
    Type: String
    Description: The domain on which you want your website to be hosted.
    AllowedPattern: (?!-)[a-zA-Z0-9-.]{1,63}(?<!-)
    ConstraintDescription: must be a valid DNS zone name.

Resources:
  WebsiteBucket:
    Type: AWS::S3::Bucket
    DeletionPolicy: Retain
    Properties:
      AccessControl: PublicRead
      BucketName: !Ref DomainName
      WebsiteConfiguration:
        IndexDocument: index.html
        ErrorDocument: error.html

以下に簡単な注釈をつける。

ドメイン名をパラメータとして管理する

テンプレート完成の暁には別のプロジェクトでも再利用できるに越したことはないので、一般化できる値は適宜パラメータとして切り分けていく。

Parameters:
  DomainName:
    Type: String
    Description: The domain on which you want your website to be hosted.
    AllowedPattern: (?!-)[a-zA-Z0-9-.]{1,63}(?<!-)
    ConstraintDescription: must be a valid DNS zone name.
DeletionPolicy プロパティを指定する

インポート時の要件となっている DeletionPolicy プロパティを指定する。

インポートする各リソースには、DeletionPolicy 属性が必要です。 https://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/resource-import.html

このようになる。

 Resources:
   WebsiteBucket:
     Type: AWS::S3::Bucket
+    DeletionPolicy: Retain

なお、インポートが通ってひとたびスタックが作成されてしまえばこのプロパティは不要になるようである。

インポートオペレーションを成功させるには、インポートする各リソースに DeletionPolicy 属性が必要です。DeletionPolicy は任意の使用できる値に設定できます。ターゲットリソースのみに DeletionPolicy が必要です。スタックにすでに含まれているリソースは、DeletionPolicy を必要としません https://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/resource-import.html

とはいえスタックを誤って削除したときにバケットまで消滅してしまうリスクは避けるに越したことはないので、DeletionPolicy: Retainによって削除されないように設定しておくのが穏当である。

バケットのプロパティ

レファレンスには次のようにある。

AWS CloudFormation は、テンプレート設定がリソースプロパティの実際の設定と一致しているかどうかをチェックしません。 https://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/resource-import.html

つまりAccessControl属性やWebsiteConfigurationブロックについては必ずしも実際の状態をテンプレートに正確に再現せずともインポートは成功しうるということのようである。

しかし遅かれ早かれ正しい設定をテンプレートに記述しなければならないのは変わらないため、この時点で正確に記述しておいて悪いことはないはずだ。

 Resources:
   WebsiteBucket:
     Type: AWS::S3::Bucket
     DeletionPolicy: Retain
     Properties:
+      AccessControl: PublicRead
       BucketName: !Ref DomainName
+      WebsiteConfiguration:
+        IndexDocument: index.html
+        ErrorDocument: error.html

テンプレートを実行する

作成したテンプレートをコンソールから実行していく。

この時点でのテンプレートを再掲する。

AWSTemplateFormatVersion: 2010-09-09
Description: |
  A stack to manage an S3 bucket for hosting a static website,
  a Route 53 DNS record for using a custom domain,
  and a CloudFront Distribution for high availability.

Parameters:
  DomainName:
    Type: String
    Description: The domain on which you want your website to be hosted.
    AllowedPattern: (?!-)[a-zA-Z0-9-.]{1,63}(?<!-)
    ConstraintDescription: must be a valid DNS zone name.

Resources:
  WebsiteBucket:
    Type: AWS::S3::Bucket
    DeletionPolicy: Retain
    Properties:
      AccessControl: PublicRead
      BucketName: !Ref DomainName
      WebsiteConfiguration:
        IndexDocument: index.html
        ErrorDocument: error.html

コンソールから、「既存のリソースを使用(リソースをインポート)」を選択する。

この先についてはウィザードに沿っていけばおのずと完了するだろう。

リダイレクト設定のバケットのインポートがうまくできない

蛇足になりかねないが触れておく。

同じ要領で、リダイレクトを設定したバケットを次のようなテンプレートでインポートしようと試みた。

AWSTemplateFormatVersion: 2010-09-09
Description: |
  A stack to manage an S3 bucket for redirecting to the apex domain,
  a Route 53 DNS record for using a custom domain,
  and a CloudFront Distribution for high availability.

Parameters:
  DomainName:
    Type: String
    Description: The domain on which you want your website to be hosted.
    AllowedPattern: (?!-)[a-zA-Z0-9-.]{1,63}(?<!-)
    ConstraintDescription: must be a valid DNS zone name.
  WebsiteBucket:
    Type: String
    Description: The name of the target bucket.

Resources:
  RedirectBucket:
    Type: AWS::S3::Bucket
    DeletionPolicy: Retain
    Properties:
      BucketName: !Ref DomainName
      WebsiteConfiguration:
        RedirectAllRequestsTo:
          HostName: !Ref WebsiteBucket
          Protocol: https

しかしこれは通らず、次のような曖昧模糊としたメッセージが返されるだけだ。

この変更セットの作成中にエラーが発生しました Internal Error

どうもWebsiteConfigurationのブロックが悪さをしているらしい、というのはわかるのだが、正直に言ってどうにもしがたく、乱暴ではあるが次のように回避する。

 AWSTemplateFormatVersion: 2010-09-09
 Description: ...
 
 Parameters:
   ...
 Resources:
   RedirectBucket:
     Type: AWS::S3::Bucket
     DeletionPolicy: Retain
     Properties:
       BucketName: !Ref DomainName
-      WebsiteConfiguration:
-        RedirectAllRequestsTo:
-          HostName: !Ref WebsiteBucket
-          Protocol: https

このようにプロパティを削減して、まずはインポートを成功させ、スタックを作成する。無事に作成できたら、削減した記述を元に戻して再度実行させる。

気持ち悪さはあるが、ひとまずこれでリダイレクト用バケットのインポートもできたことになる。

作成されたスタックに残りのリソースを追加する

これにて既存リソースのうちインポート可能なものの処理は終わったので、残りのリソースを新しく作成するための記述を追加していく。

引き続きコンソールでの作業になる。変更を反映させていくにあたっては、「更新する」ボタンからではなく、「既存スタックの変更セットを作成」ボタンから実行していくのが望ましい。

ウィザードに沿って実行していくと、次に示すように実行前に変更内容をレビューすることができる

釈迦に説法とも思いつつ書く。yamlファイルの編集になんらかのミスがあってもここにワンクッションを設けておくことでいくらか安心できる。ひと手間にはなるがその手間には値する。

変更セットを使用したスタックの更新

バケットポリシーの追加

先に述べたとおりバケットポリシーは既存のリソースとしてスタックに取り込むことができない

そこで新規リソースとして作り直さないといけないわけだが、バケットポリシー自体はすでに存在しており、先ほどインポートしたバケットにアタッチされている。

つまり単に新しく作成するのではなく、既存のバケットポリシーをデタッチないし削除した上で、新しいバケットポリシーを作成し、インポート済みのバケットにアタッチしなければならない訳である。

テンプレートとしては次のようになる。

 AWSTemplateFormatVersion: 2010-09-09
 Description: |
   A stack to manage an S3 bucket for hosting a static website,
   a Route 53 DNS record for using a custom domain,
   and a CloudFront Distribution for high availability.

 Parameters:
   DomainName:
     Type: String
     Description: The domain on which you want your website to be hosted.
     AllowedPattern: (?!-)[a-zA-Z0-9-.]{1,63}(?<!-)
     ConstraintDescription: must be a valid DNS zone name.

 Resources:
   WebsiteBucket:
     Type: AWS::S3::Bucket
     DeletionPolicy: Retain
     Properties:
       AccessControl: PublicRead
       BucketName: !Ref DomainName
       WebsiteConfiguration:
         IndexDocument: index.html
         ErrorDocument: error.html
+  WebsiteBucketPolicy:
+    Type: AWS::S3::BucketPolicy
+    Properties:
+      Bucket: !Ref WebsiteBucket
+      PolicyDocument:
+        Id: WebsiteBucketPolicy
+        Version: 2012-10-17
+        Statement:
+          - Sid: PublicReadForGetBucketObjects
+            Effect: Allow
+            Principal: '*'
+            Action: 's3:GetObject'
+            Resource: !Sub 'arn:aws:s3:::${WebsiteBucket}/*'

どうやってリプレイスするかであるが、いったん手動で既存のバケットポリシーを削除して、すぐさまCloudFormationで再作成する、という方式をとった。

趣味の範囲で動かしているウェブサイトであり、特にミッションクリティカルな場面ではないため、不器用なやり方ではあるが許容する。

もしこれが要件として譲れないのであれば、対応としてはDNSの切り替えなどより手前において制御するべきかなと思う。

CloudFrontディストリビューションとRoute 53レコードセットの追加

同じようにCloudFrontディストリビューションとRoute 53レコードセットを新規作成して置き換えていく。

両者のつなぎこみが肝心で、同時に作成するのが望ましいため、追記分は大きくなる。テンプレートは次のようになる。

 AWSTemplateFormatVersion: 2010-09-09
 Description: |
   A stack to manage an S3 bucket for hosting a static website,
   a Route 53 DNS record for using a custom domain,
   and a CloudFront Distribution for high availability.

 Parameters:
+  AcmCertificateArn:
+    Type: String
+    Description: The ARN of ACM certificate.
+    AllowedPattern: arn:aws:acm:.*
   DomainName:
     Type: String
     Description: The domain on which you want your website to be hosted.
     AllowedPattern: (?!-)[a-zA-Z0-9-.]{1,63}(?<!-)
     ConstraintDescription: must be a valid DNS zone name.
+  HostedZone:
+    Type: String
+    Description: The name of an existing Amazon Route 53 hosted zone.
+    AllowedPattern: (?!-)[a-zA-Z0-9-.]{1,63}(?<!-)
+    ConstraintDescription: must be a valid DNS zone name.

 Resources:
   WebsiteBucket:
     Type: AWS::S3::Bucket
     DeletionPolicy: Retain
     Properties:
       AccessControl: PublicRead
       BucketName: !Ref DomainName
       WebsiteConfiguration:
         IndexDocument: index.html
   WebsiteBucketPolicy:
     Type: AWS::S3::BucketPolicy
     Properties:
       Bucket: !Ref WebsiteBucket
       PolicyDocument:
         Id: WebsiteBucketPolicy
         Version: 2012-10-17
         Statement:
           - Sid: PublicReadForGetBucketObjects
             Effect: Allow
             Principal: '*'
             Action: 's3:GetObject'
             Resource: !Sub 'arn:aws:s3:::${WebsiteBucket}/*'
+  WebsiteCloudfront:
+    Type: AWS::CloudFront::Distribution
+    DependsOn:
+      - WebsiteBucket
+    Properties:
+      DistributionConfig:
+        Aliases:
+          - !Ref DomainName
+        Comment: !Sub ${DomainName} static website bucket
+        DefaultCacheBehavior:
+          AllowedMethods:
+            - GET
+            - HEAD
+          Compress: true
+          ForwardedValues:
+            Cookies:
+              Forward: none
+            QueryString: true
+          TargetOriginId: S3Origin
+          ViewerProtocolPolicy: redirect-to-https
+        DefaultRootObject: index.html
+        Enabled: true
+        HttpVersion: 'http2'
+        Origins:
+          - DomainName: !Select [2, !Split ['/', !GetAtt WebsiteBucket.WebsiteURL]]
+            Id: S3Origin
+            CustomOriginConfig:
+              HTTPPort: '80'
+              HTTPSPort: '443'
+              OriginProtocolPolicy: http-only
+        PriceClass: PriceClass_All
+        ViewerCertificate:
+          AcmCertificateArn: !Ref AcmCertificateArn
+          SslSupportMethod: sni-only
+  WebsiteDNSName:
+    Type: AWS::Route53::RecordSetGroup
+    Properties:
+      HostedZoneName: !Sub '${HostedZone}.'
+      RecordSets:
+        - Name: !Ref DomainName
+          Type: A
+          AliasTarget:
+            HostedZoneId: Z2FDTNDATAQYW2
+            DNSName: !GetAtt [WebsiteCloudfront, DomainName]

これにてテンプレートは完成ということになる。

こちらも前項のバケットポリシーと同じく、稼働中のものを差し置いて新規作成することはできない。次の3つの下準備が必要になる。

  1. 稼働中のCloudFrontディストリビューションを停止する
  2. 同じディストリビューションのCNAMEの項目を削除する
  3. Route 53にある既存のAレコードを削除する

この3つの手順を踏まえていれば、リソースの重複によるCloudFormationの実行エラーは起こらないはずである。

こちらも先ほどと同様、個人の趣味で配信しているウェブサイトのため、ダウンタイムは考慮に入れていない。

無事に実行が完了すると、予定した作業は晴れて完了である。

まとめ

最終的に出来上がった二つのテンプレートファイルが以下になる。

website-template.yml
redirect-template.yml

冒頭で述べたとおり、私はapexドメインで運用するウェブサイト用のバケットと、wwwサブドメインをapexドメインにリダイレクトするバケットの二つを作成しており、その二つになる。リダイレクトバケットの方は不要というケースも多いだろうから、その場合は一方は無視してほしい。

インフラのコード化というのは得てして、頻繁に変更されるものから順に着手しがちであるが、今回のケースでは、そう頻繁に変更されないものをコード化しておくことによって、将来的な混乱を軽減できるのではないかという仮定に立っている。いずれの場合においても、コード化の恩恵にあずかれないということはないはず。

今回扱った範囲の記憶が薄れたころにこれを読んでどう思うか、それは未来の自分の判断に委ねるほかないが、少なくともこれらの作業によって、いつでも手軽に全体像の確認が行えるようになった、ということがなにより肝心である。例えば気軽な静的サイトを構築しよう、となったときに、このテンプレートを流用して手短に済ませる、という選択肢も取れるわけで、その自由を獲得できるのは素晴らしいことと思う。

あとがき

この記事を冒頭で掲げた。  AWSにおける静的コンテンツ配信パターンカタログ(アンチパターン含む) | Developers.IO 

執筆者としてクレジットされている、都元ダイスケさん、という名前は、不勉強にしてつい先週、悪い知らせにて初めて聞き知ったところであった。

この業界ではまだ駆け出しに毛が生えた程度の部類でしかない私は、彼がどこか遠い別の世界の住人であるかのようにその知らせを読んでいたが、思わぬ巡り合わせにより、遺されたこの文章に出会えたということになる。そしてその文章を繰り返し読むにつれて、この方はもういらっしゃらないのだ、というショックがじわじわと溢れるようになった。

技術者たるもの記事を書くべし!とさかんに言われているが、たいしてそこには興味を持たずに働いてきた。つい数週間前、特に理由もない心境の変化があり、こうして久しぶりの投稿を用意していたわけであるが、まさにその心境の変化があったのと同じ頃に、都元さんは旅立たれていった。

このたび、都元さんの記事を読むという偶然があって、これは明らかな誇大妄想となるが、私がこうしてなにかを書くことを、都元さんが応援してくれているように思えてならない。会ったことすらないにも関わらず、なにかスピリチュアルにもならざるをえない。そんな不思議を感じているし、その感慨をこうして書き残しておくことで、僭越ながら冥福を祈る言葉とさせていただきたい。

これこそ蛇足ですがこんなところまで読んでいただきありがとうございました。

https://classmethod.jp/news/farewell-miyamoto/