はじめてのCloudFormation!! S3,CloudFront,Route53を使って静的Webサイトをスピーディに構築する


こんにちはsekitakaです。

AWSはとても便利ですが管理コンソールの操作が属人的になりやすい面もあります。

よくあるケースとして以下のような状況が考えられます。

- 実験好きのエンジニアがトライアンドエラーで環境を構築してノウハウを獲得します
- 同じような環境が必要になったが、別の担当者が環境構築を担当することになります
- すると1度社内の誰かが経験したハマりや苦労を別の担当者も経験しないと環境を構築することが出来ないという状況になります

しかし環境構築はシステム開発の本質的な部分ではないので、できるだけ時間をかけたくない部分です。
今回はこの問題を解決すべくCloudFormationを使った環境構築の方法を紹介します。
作成したテンプレートは[gist][gist]に公開してありますので、自由に使用してください。

# CloudFormationって？
CloudFormationとはjsonまたはyaml形式の設定ファイルでさまざまなAWSリソースを作成することができる仕組みです。
一度作成して終わりでなく既存のスタックを更新することもできます。
スタックとはCloudFormationで生成されるAWSリソースの集合の事を言います。

例えばS3のバケットを作成し、そのバケット名を使ってCloudFrontのオリジンに設定するといった事が可能です。

# 例題
この記事ではランディングページを作ることを想定して、次のような条件で静的Webサイトを構築するCloudFormationのテンプレートを作成します。

- ドメイン名を変数として入力できる
- S3にhtmlなどのリソースを保存する
- CloudFrontでキャッシュする
- Route53でドメイン名とCloudFrontをエリアスとして関連付ける


入力したドメインのネームサーバーをRoute53のHosted Zoneに向かせる設定を行えば、サイトにアクセス出来るようになります。

# テンプレートを作成する
テンプレートはjsonもしくはyamlで記述することが出来ます。このテンプレートファイルがCloudFormationのキモです。
マネジメントコンソールからデザイナーで編集することもできます。
ただファイルサイズが大きくなってくると使い慣れたエディタで編集するほうが楽だと思います。どうせテンプレートの殆どの部分は手打ちする必要があるので。
この記事ではjsonでテンプレートを作成します。

## 全体像
先に最終的に出来上がるテンプレートの全体像を説明しておきます。
今回作成するテンプレートは以下の構成になります。

```json
{
  "AWSTemplateFormatVersion": "2010-09-09",
  "Mappings": {
  },
  "Parameters": {
  },
  "Resources": {
  }
}
```

- `Mappings` は定数データのテーブルのようなものになります。`Fn::FindInMap`という組み込み関数によって、値をテンプレート内から参照できます。
- `Parameters`は テンプレートからスタックを作成するときに指定できる入力値です。今回の例ではサイトのドメインを入力します。
- `Resources`は使用するAWSリソースの設定を行います。今回の例ではS3やCloudFront,Route53の設定などです。テンプレートの殆どはリソースの定義に費やされます。

その他の使用できる項目については[こちら](http://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/template-anatomy.html)で確認できます。

### Mappings
今回の例ではMappingsは次のようになります。
これはS3を静的Webサイトホスティングした場合の、リージョンとエンドポイントの対応表です。
例えばリージョンがus-east-1の場合、静的Webサイトホスティングのドメインは`<バケット名>.s3-website-us-east-1.amazonaws.com`となります。
CloudFrontのオリジンの設定に必要なのでMappingsに定義しておきます。

```json
{
  "RegionMap": {
    "us-east-1": {
      "S3hostedzoneID": "Z3AQBSTGFYJSTF",
      "websiteendpoint": "s3-website-us-east-1.amazonaws.com"
    },
    "us-west-1": {
      "S3hostedzoneID": "Z2F56UZL2M1ACD",
      "websiteendpoint": "s3-website-us-west-1.amazonaws.com"
    },
    "us-west-2": {
      "S3hostedzoneID": "Z3BJ6K6RIION7M",
      "websiteendpoint": "s3-website-us-west-2.amazonaws.com"
    },
    "eu-west-1": {
      "S3hostedzoneID": "Z1BKCTXD74EZPE",
      "websiteendpoint": "s3-website-eu-west-1.amazonaws.com"
    },
    "ap-southeast-1": {
      "S3hostedzoneID": "Z3O0J2DXBE1FTB",
      "websiteendpoint": "s3-website-ap-southeast-1.amazonaws.com"
    },
    "ap-southeast-2": {
      "S3hostedzoneID": "Z1WCIGYICN2BYD",
      "websiteendpoint": "s3-website-ap-southeast-2.amazonaws.com"
    },
    "ap-northeast-1": {
      "S3hostedzoneID": "Z2M4EHUR26P7ZW",
      "websiteendpoint": "s3-website-ap-northeast-1.amazonaws.com"
    },
    "sa-east-1": {
      "S3hostedzoneID": "Z31GFT0UA1I2HV",
      "websiteendpoint": "s3-website-sa-east-1.amazonaws.com"
    }
  }
}
```

このように定数を定義しておきテンプレートの別の場所から参照することができます。

### Parameters
今回の例ではParametersは次のようになります。
作成したい静的サイトのドメインを`RootDomainName`という変数名でテンプレート内の別の場所から参照できるように宣言しています。


```json
{
  "RootDomainName": {
    "Description": "Domain name for your website (example.com)",
    "Type": "String"
  }
}
```

このようにスタック作成時に指定できるパラメタがあることによって、1つのテンプレートで幾つもの静的Webサイトを構築することができます。
変数の値はテンプレートの別の場所から組み込み関数である`Ref`関数を使用することで参照できます。

### Resources
AWSリソースの設定です。S3やCloudFrontなどの設定をここに記述します。
CloudFrontのテンプレートのメインがこの部分になります。

今回の例ではResourcesの設定の構造は次のようになります。

```json
{
  "MainBucket": {
  },
  "MainBucketPolicy": {
  },
  "CloudFront": {
  },
  "Route53HostedZone": {
  },
  "Route53RecordSet": {
  }
}
```

このオブジェクトのキーは任意の名前で付けることができます。別のリソースから参照するときに必要になるので、それなりに識別し易い値がよいでしょう。
簡単に各リソースの説明をします。

- `MainBucket` WebサイトホスティングするS3のバケットの設定です
- `MainBukcetPolicy` はそのバケットのバケットポリシーです
- `CloudFront` CloudFrontのディストリビューションの設定です
- `Route53HostedZone`はHostedZoneの設定です
- `Route53RecordSet`はAレコードの設定です(CloudFrontに向けるのでAWSではエリアス設定と言われます)

では順にリソースの設定を見ていきます。

#### MainBucket
`MainBucket`の設定は次のようになります。
全公開で静的Webサイトホスティングの設定をしています。

```json
{
  "Type": "AWS::S3::Bucket",
  "Properties": {
    "AccessControl": "PublicRead",
    "WebsiteConfiguration": {
      "ErrorDocument": "error.html",
      "IndexDocument": "index.html"
    }
  }
}
```

バケット名は明示的に指定せず、自動で生成されるバケット名を使用します。
理由は[こちら](http://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/aws-properties-s3-bucket.html)のBucketNameの説明にある通りです。

#### MainBucketPolicy
バケットポリシーの設定は次の通りです。

```json
{
  "Type": "AWS::S3::BucketPolicy",
  "Properties": {
    "Bucket": {
      "Ref": "MainBucket"
    },
    "PolicyDocument": {
      "Statement": [
        {
          "Action": [
            "s3:GetObject"
          ],
          "Effect": "Allow",
          "Resource": {
            "Fn::Join": [
              "",
              [
                "arn:aws:s3:::",
                {
                  "Ref": "MainBucket"
                },
                "/*"
              ]
            ]
          },
          "Principal": "*"
        },
        {
          "Sid": "AddCannedAcl",
          "Effect": "Allow",
          "Principal": {
            "AWS": {
              "Fn::Join": [
                "",
                [
                  "arn:aws:iam::",
                  {
                    "Ref": "AWS::AccountId"
                  },
                  ":root"
                ]
              ]
            }
          },
          "Action": [
            "s3:PutObject",
            "s3:PutObjectAcl"
          ],
          "Resource": {
            "Fn::Join": [
              "",
              [
                "arn:aws:s3:::",
                {
                  "Ref": "MainBucket"
                },
                "/*"
              ]
            ]
          },
          "Condition": {
            "StringEquals": {
              "s3:x-amz-acl": "public-read"
            }
          }
        }
      ]
    }
  }
}

```

##### Ref関数
`Properties.Bucket`で`Ref`関数を使っています。 このように記述することで,`MainBucket`のバケット名を参照することができます。
`Ref`関数は参照先のリソースタイプによって返される値が異なります。

詳しくは[ドキュメント](http://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference-ref.html)を確認しましょう。

##### Ref関数(疑似パラメータ参照)
Principalでは`{"Ref": "AWS::AccountId"}`と組み込みの`Ref`関数を使用しています。
`AWS::AccountId`というのはテンプレート内にでてきませんが、CloudFromation内で事前定義されている疑似パラメータと呼ばれるものです。
使用できる疑似パラメーターの一覧は[ドキュメント](http://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/pseudo-parameter-reference.html)で確認することができます。


##### 設定内容
ここで設定したポリシーは以下の2つになります。

- 全ユーザーにGetを許可
- 同じAWSアカウントのユーザーにPutObjectを許可し、PutされたObjectをpublic-readにする

##### Fn::Join関数
`Fn::Join`関数で文字列の連結を行い`Resource`や`Principal`の値を生成しています。
書式などは[こちら](http://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference-join.html)で確認できます。


#### CloudFront
`CloudFront`の設定は次のようになります。

```json
{
  "Type": "AWS::CloudFront::Distribution",
  "DependsOn": [
    "MainBucket"
  ],
  "Properties": {
    "DistributionConfig": {
      "Aliases": [
        {
          "Ref": "RootDomainName"
        }
      ],
      "DefaultCacheBehavior": {
        "DefaultTTL": 1,
        "MaxTTL": 1,
        "ForwardedValues": {
          "QueryString": true
        },
        "TargetOriginId": "s3bucket",
        "ViewerProtocolPolicy": "allow-all"
      },
      "Origins": [
        {
          "CustomOriginConfig": {
            "OriginProtocolPolicy": "match-viewer"
          },
          "DomainName": {
            "Fn::Join": [
              "",
              [
                {
                  "Ref": "MainBucket"
                },
                ".",
                {
                  "Fn::FindInMap": [
                    "RegionMap",
                    {
                      "Ref": "AWS::Region"
                    },
                    "websiteendpoint"
                  ]
                }
              ]
            ]
          },
          "Id": "s3bucket"
        }
      ],
      "Enabled": true
    }
  }
}
```

##### DependsOnプロパティ
`MainBucket`作成後でないと設定ができないので、`DependsOn`に`MainBucket`リソースを指定しています。
このようにして、リソースの依存関係を解決することができます。

##### Fn::FindInMap関数
DomainNameの指定で`Fn::FindInMap`関数を使っています。この関数で`Mappings`に定義したリージョンごとのS3静的WebサイトのエンドポイントのURLを取得しています。
スタックを作成したリージョンにS3バケットも作成され、CloudFrontからはそのS3バケットのリージョンの静的Webサイトエンドポイントをオリジンとして利用するようになっています。

#### Route53HostedZone
HostedZoneの設定は次のようになります。
HostedZoneが作成されていない場合に作成されます。

```json
{
  "Type": "AWS::Route53::HostedZone",
  "Properties": {
    "Name": {
      "Fn::Join": [
        "",
        [
          {
            "Ref": "RootDomainName"
          },
          "."
        ]
      ]
    }
  }
}
```

#### Route53RecordSet
Aレコードの設定は次のようになります。
`"HostedZoneId": "Z2FDTNDATAQYW2"`のように固定になっているのはCloudFrontのDistributionに向けるなら、この値を使いなさいとドキュメントで指定されていためです。

```json
{
  "Type": "AWS::Route53::RecordSet",
  "Properties": {
    "AliasTarget": {
      "DNSName": {
        "Fn::GetAtt": [
          "CloudFront",
          "DomainName"
        ]
      },
      "HostedZoneId": "Z2FDTNDATAQYW2"
    },
    "HostedZoneId": {
      "Ref": "Route53HostedZone"
    },
    "Name": {
      "Fn::Join": [
        "",
        [
          {
            "Ref": "RootDomainName"
          }
        ]
      ]
    },
    "Type": "A"
  }
}
```

# まとめ
いかがでしたでしょうか。CloudFrontのテンプレート例として静的Webサイトを構築するテンプレートを作ってみました。
CloudFrontのテンプレートを作成しておくと環境の複製が用意になり、テスト環境の構築などにも使えるのが良いですね。
また設定をテキスト化していく過程で各種AWSリソースの設定値についても改めて理解を深めることが出来たようにも思えます。
今後は積極的にCloudFormationを使った環境構築をしていこうと思います。

作成したテンプレートは[gist][gist]に公開してありますので、自由に使用してください。


[gist]: https://gist.github.com/sekitaka/f2451232d35ca677f0f18b7c0841813e