【Terraform】S3+Cloudfront+独自ドメインで手軽にサイトをホスティングするためのインフラを作る

:::note info
とても遅刻してしまいましたが、この記事は[DeNA 25新卒 Advent Calendar 2024](https://qiita.com/advent-calendar/2024/dena-25-newgrad)の4日目の記事です。他の方々の記事もぜひご覧ください！
:::

Webサイトをホスティングする場合、いろいろな手段が考えられます。例えばFirebase HostingやGitHub Pages、Vercelなどなど。そのうちの1つに**AWSのCloudFrontとS3を使った方法**が考えられます。

:::note warn
S3のパブリックアクセスでファイルを配信する方法もありますが、この記事ではCloudFront経由でアクセスする方法にします。

CloudFrontを使う分、追加で料金がかかる可能性があります。詳細な料金体系は、[AWS公式ドキュメント](https://aws.amazon.com/jp/cloudfront/)などをご確認ください。
:::

この記事では、その構成で手軽にWebサイトを量産する（サイト数を増やす）ために、IaCツールであるTerraformで構成を用意していきたいと思います。

## 先にまとめ
コードの全文を、以下のリポジトリに公開しています。

https://github.com/mochi-yu/qiita-material/tree/main/cloudfront-static-website-terraform

## 実装
### 実装方針
まず、今回の構成では独自ドメインはAWS以外（CloudFlareやお名前.comなど）で管理しているものと想定しています。

また、配信するWebサイトはClient Side Rendering(CSR)やStatic Site Generation(SSG)のような形式を想定しており、記事内の設定はいわゆるSPAを想定して記述しています。他の構成などで利用する際は、適宜該当箇所を修正してください。

実装の説明は以下の手順で進めます。

- Terraformの基本設定
- HTTPS配信ためのSSL証明書の設定（ACM）
- S3の設定
- CloudFrontの設定
- applyの手順

### Terraformの基本設定
まずはトップディレクトリに`main.tf`を作成して以下を記述します。

```terraform:main.tf
module "example_site" {
  source = "./modules"
  domain = "example.mochi-yu.com"
  env = "prod"
}

terraform {
  backend "s3" {
    region = "ap-northeast-1"
    encrypt = false
    bucket  = "example"
    key     = "static-site/prod"
  }
}
```

普通、ここにproviderの設定なども書くかと思いますが、今回は後述のとおりリージョンを複数使う関係で、ここには書かずモジュール内にproviderの設定を書くようにしました。

それから実装の本体を置く`modules`フォルダを作成しておき、`variables.tf`に以下を記述しておきます。

```terraform:modules/variables.tf
variable "domain" {
  type = string
}

variable "env" {
  type = string
}
```

### SSL証明書の設定（ACM）
配信するサイトはHTTPSでの通信に対応したいため、まずはSSL証明書を発行します。CloudFrontでACMのSSL証明書を使用する場合には、**証明書をバージニア北部のリージョンで発行する必要があります**。

https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/cnames-and-https-requirements.html#https-requirements-aws-region

ただ、S3などは東京リージョンで作ったほうが、他のリソースの管理とも同じで済むので個人的には嬉しいです。そこで、**ACMのSSL証明書発行だけはバージニア北部のリージョンで行い、他のリソースは東京リージョンで作成する**ように設定します。

リージョンの設定は、`modules/main.tf`に以下を記述します。`use1`というエイリアスでバージニア北部のリージョンを用意しました。

```terraform:modules/main.tf
terraform {
  required_version = ">=1.7"

  required_providers {
    aws = {
      source = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

provider "aws" {
  region = "ap-northeast-1"
}

provider "aws" {
  region = "us-east-1"
  alias  = "use1"
}
```

ACMの証明書の発行は以下の内容で記述します。`provider = aws.use1`のところで、先ほどのバージニア北部のリージョンに作成するよう指定しています。

```terraform:modules/acm.tf
resource "aws_acm_certificate" "acm" {
  domain_name = var.domain
  validation_method = "DNS"
  provider = aws.use1
}
```

また、そのドメインの持ち主であることを確認する方法として`validation_method = "DNS"`を設定しています。後ほども書きますが、**証明書発行のリクエストを実施後、各自のDNS上で設定が必要になります**。

### S3の設定
続いて、ビルドしたファイルなどを格納するS3のバケットを作っていきます。ここでは、バケット名をドメイン名と同じにしています。

```terraform:modules/s3.tf
resource "aws_s3_bucket" "frontend" {
  bucket = var.domain
}

resource "aws_s3_bucket_policy" "frontend_policy" {
  bucket = aws_s3_bucket.frontend.id
  policy = data.aws_iam_policy_document.frontend_policy_doc.json
}

data "aws_iam_policy_document" "frontend_policy_doc" {
  statement {
    sid = "AllowCloudFrontServicePrincipal_${var.domain}"
    principals {
      type        = "AWS"
      identifiers = [aws_cloudfront_origin_access_identity.frontend.iam_arn]
    }
    actions   = ["s3:GetObject"]
    resources = ["${aws_s3_bucket.frontend.arn}/*"]
  }
}
```

バケットポリシーとして、この後作成するCloudFrontに`"s3:GetObject"`の権限を渡しておきます。

### CloudFrontの設定
次にCloudFrontを作成していきます。以下に全体のコードを載せて、特徴的な部分のみ抜粋して以下で説明します。

<details>
<summary>コードの全文（modules/cloudfront.tf）</summary>

```terraform:modules/cloudfront.tf
resource "aws_cloudfront_origin_access_identity" "frontend" {}

resource "aws_cloudfront_distribution" "frontend" {
  aliases = [ var.domain ]
  enabled = true

  default_root_object = "index.html"
  custom_error_response {
    error_code = 403
    response_code = 200
    response_page_path = "/"
  }
  custom_error_response {
    error_code = 404
    response_code = 200
    response_page_path = "/"
  }

  origin {
    origin_id = aws_s3_bucket.frontend.id
    domain_name = aws_s3_bucket.frontend.bucket_regional_domain_name

    s3_origin_config {
      origin_access_identity = aws_cloudfront_origin_access_identity.frontend.cloudfront_access_identity_path
    }
  }

  default_cache_behavior {
    target_origin_id = aws_s3_bucket.frontend.id
    viewer_protocol_policy = "redirect-to-https"
    allowed_methods = [ "GET", "HEAD", "OPTIONS" ]
    cached_methods = [ "GET", "HEAD", "OPTIONS" ]

    // FIXME: 何もキャッシュしない設定になっているので注意
    cache_policy_id = "4135ea2d-6df8-44a3-9df3-4b5a84be39ad" // CachingDisabled
  }

  viewer_certificate {
    cloudfront_default_certificate = false
    acm_certificate_arn = aws_acm_certificate.acm.arn
    minimum_protocol_version = "TLSv1.2_2021"
    ssl_support_method = "sni-only"
  }

  restrictions {
    geo_restriction {
      restriction_type = "none"
    }
  }

  is_ipv6_enabled = true
  wait_for_deployment = false
}
```
</details>

#### SPA用のパスの設定
以下では、SPA（Single Page Application）用にパスの設定をしています。

SPAでは特定のパス（例：`/top`）にアクセスした時も`/index.html`を読み込む必要があります。ただ、デフォルトの設定では、`/top`にアクセスすると、`/top`と言うパスのファイルにアクセスしようとしますので、403エラーなどになってしまいます。

そこで、その403などのエラー時にはトップディレクトリにあるindex.htmlを返すように設定しました。

```terraform:modules/cloudfront.tf（抜粋）
default_root_object = "index.html"
custom_error_response {
  error_code = 403
  response_code = 200
  response_page_path = "/"
}
custom_error_response {
  error_code = 404
  response_code = 200
  response_page_path = "/"
}
```

#### キャッシュの設定
以下のブロックではキャッシュの設定を記述しています。この例では一旦、**キャッシュを一切しない設定になっていますので、必要に応じて皆さんの要件で修正するようにしてください**。

キャッシュの設定は考慮することや仕組みが複雑で、私自身まだあまり理解ができていないため、一旦無視している形です。（Cloudfrontの良さを活かせていない感がありますが…）

```terraform:modules/cloudfront.tf（抜粋）
default_cache_behavior {
  target_origin_id = aws_s3_bucket.frontend.id
  viewer_protocol_policy = "redirect-to-https"
  allowed_methods = [ "GET", "HEAD", "OPTIONS" ]
  cached_methods = [ "GET", "HEAD", "OPTIONS" ]

  // FIXME: 何もキャッシュしない設定になっているので注意
  cache_policy_id = "4135ea2d-6df8-44a3-9df3-4b5a84be39ad" // CachingDisabled
}
```

### applyの手順
そのまま`terraform apply`を実行してしまうと、おそらくCloudFrontの作成が終わらず以下のようなメッセージが続くかと思います。

```
module.example_site.aws_cloudfront_distribution.frontend: Still creating... [20s elapsed]
```

これは、ACMの証明書の発行に先立って、そのドメインの持ち主が本当に自分であることを確認する必要があり、その確認が済むまで処理を待っているためと考えられます。

ですので、**一度にCloudFrontまで全てをapplyしてしまうのではなく、先にACMだけapplyし、ドメインの確認を実施してから全体をapplyする**という手順を踏みたいと思います。

まずは以下のコマンドでACMだけをデプロイします。モジュール名は適宜修正してください。複数サイトを同時に新規デプロイする際は、サイトの分だけこのコマンドを実行します。

```shell
# terraform apply -target=module.{モジュール名}.aws_acm_certificate.acm
terraform apply -target=module.example_site.aws_acm_certificate.acm
```

これでACMだけが作成されました。AWSのWebコンソールからACMのページにアクセスします。このACMは**バージニア北部**のリージョンで作成していますので、**Webコンソール上でも右上がバージニア北部になっていることをご確認ください**。

すると、証明書の一覧に該当のドメインのものがあり、ステータスが「保留中の検証」となっているかと思います。

![証明書のステータスが保留中の検証になっている](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/351104/683c3abb-f503-eda5-bb56-1cb70dbe3462.png)

その証明書の証明書IDをクリックし、「ドメイン」という部分からCNAME名とCNAME値を確認します。この内容を、**お使いのドメインの管理ページでDNSに設定**することで、このドメインの管理者が自分であることを確認できます。

![証明書のステータスが保留中の検証になっている](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/351104/0047b34f-ec7c-e25c-e57a-b958dcd96417.png)

DNSに設定し少し待てば、先ほどの証明書一覧のステータスが「発行済み」に変わるかと思います。

![証明書のステータスが発行済みになっている](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/351104/d858572f-f38b-d329-d1d8-150a3c54083b.png)

これでACM証明書の準備が完了しました。あとは、全体をapplyするだけですので、`terraform apply`をそのまま実行します。

### DNSでのCNAMEの設定
ここまでで、AWS側の環境をすべて作ることができました。最後に、独自ドメインの名前解決ができるように、DNSへの設定をします。こちらも、一旦はTerraformを使わずに進めます。

DNSでは今回設定したドメイン名にアクセスすることで、CloudFrontの「ディストリビューションドメイン名」のサイトを開くように設定します。したがって、ドメインの別名を設定できる**CNAMEレコード**を使って、`example.mochi-yu.com`から「ディストリビューションドメイン名」へのレコードを設定します。

![CloudFrontのディストリビューションドメイン名](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/351104/38e6d334-3045-ff75-d89a-d9d5f9f9a33a.png)

CloudFlareでの設定例はこのような形です。

![CloudFlareでの設定例](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/351104/0237c175-7727-67a2-d97b-9a84dc501926.png)

ここまで来れば、あとは該当のS3バケットにビルド後のファイルを配置し、該当ドメインへアクセスすればWebサイトが閲覧できるようになったかと思います。

## 複数サイトのデプロイ
複数のドメインをデプロイする際は、トップディレクトリの`main.tf`に書いたモジュールを増やすだけです。

```diff_terraform:main.tf
module "example_site" {
  source = "./modules"
  domain = "example.mochi-yu.com"
  env = "prod"
}

+ module "example1_site" {
+   source = "./modules"
+   domain = "example1.mochi-yu.com"
+   env = "prod"
+ }

terraform {
  backend "s3" {
    region = "ap-northeast-1"
    encrypt = false
    bucket  = "example"
    key     = "static-site/prod"
  }
}
```

増やした時のapplyも、まず最初にACMだけをapplyする上記手順で実施してください。

これで、簡単に独自ドメインのサイトを増やすことができるようになりました！