terraform で静的ファイル配信サーバーのインフラをコピペできる状態にする

## 背景
- 個人開発アプリを作る時に、LP等の用途で静的ファイルを単純に保存・配信するだけのサーバーが必要になることが多い。
- 毎回 AWS Management Console から同じ手順で作っていたが、環境ごとに設定したりするので地味に面倒くさい
- terraform で「コードをコピペするだけで S3, CloudFront, ACM, Route53 周りの設定を完了できる」ような状態にしたい
- [相手の本棚を覗けるマッチングサービス「MatchLab」](https://match-lab.com)の LP を terraform で管理できるようにしてみた。

## 目次
- terraform とは
- 使い方
- 既存のインフラを terraform 管理に移行する
- GitLab CI/CD で変更を検知して自動化

## terraform とは
- https://www.terraform.io
- AWS 等のインフラの設定を宣言的に書いておくことで、コードに応じてリソースを新規作成したり設定変更したり良い感じに管理してくれるIaCツール
- コードを書いて CLI 上で `terraform plan` `terraform apply` などと唱えると現在のインフラの状態と設定の変更点を確認して適用してくれる

## 使い方
### 前提
- 作業用の IAM ユーザーを発行して、 Route53, S3, CloudFront, ACM の権限を与えておく
- 作業スペースに AWS_ACCESS_KEY, AWS_SECRET_ACCESS_KEY をそれぞれ指定しておく
- terraform の設定ファイルを置く用の S3 を作っておく
- CDN のホスト名にするために Route53 に Hosted Zone を作っておく
- ACM で上記のホスト名および配下のサブドメインの暗号化に対応できる SSL Certificate を発行しておく

### terraform のインストール
Mac なら homebrew 経由でインストールできる。それ以外のケースは https://www.terraform.io/downloads を参照。

```sh
$ brew tap hashicorp/tap
$ brew install hashicorp/tap/terraform
```

僕の場合は PC を買い替えた時などに再設定するのが面倒だったので Docker 化してコンテナ内で作業した

```dockerfile:dockerfile
FROM hashicorp/terraform:1.1.7

WORKDIR /app
```

これから新しく始める人は [DockerHub](https://hub.docker.com/r/hashicorp/terraform/tags) で hashicorp/terraform の最新バージョンを確認して入れると良さそう。

```yaml:docker-compose.yml
version: "3"
services:
  terraform:
    build: ./terraform
    volumes:
      - ./terraform:/app
    env_file:
      - ./env/terraform.env
```

なお Docker の場合は hashicorp/terraform のイメージが entrypoint を指定しているので、コンテナに入る時は entrypoint を上書きする必要がある。

```sh
$ docker-compose run --rm --entrypoint sh terraform
```

最後に `env/terraform.env` に terraform 用の IAM の AWS_ACCESS_KEY_ID と AWS_SECRET_ACCESS_KEY を設定すれば、開発環境は完成になる。

### 初期設定
まずは現在のインフラの状態を記録するための tfstate ファイルを S3 上に保持しますよという設定を書く。

```tf:backend.tf
terraform {
  backend "s3" {
    bucket = "terraform.match-lab.com"
    key    = "staging/terraform.tfstate"
    region = "ap-northeast-1"
  }
}
```

次に terraform が接続するプラットフォームを指定する。

```tf:provider.tf
provider "aws" {
  region = "ap-northeast-1"
}

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

AWS の場合は region ごとに provider を指定する必要がある。
通常は ap-northeast-1 だけ指定すれば済むのだが、今回は ACM のリソースを us-east-1 に作ってあった関係で、 us-east-1 にもアクセスできるように2つ追加している。

次に terraform のバージョンや terraform が AWS のサービスや設定項目を知るための provider のバージョンを指定する。

```tf
terraform {
  required_version = "1.1.7"

  required_providers {
    aws = "4.4.0"
  }
}
```

provider のバージョンが古いと AWS の最新の設定項目にアクセスできなかったりするので、これから始める人は [terraform registry](https://registry.terraform.io/providers/hashicorp/aws/latest) を見て最新版を指定すると良さそう。

以上のファイルを全て同じディレクトリに配置できたら、そのディレクトリ　で下記コマンドを実行する。

```sh
$ terraform init
```

![スクリーンショット 2022-03-13 16.47.24.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/131942/851345ab-5f5e-8b28-bda4-959788106562.png)

こんな感じで良い感じに終了すると `.terraform.lock.hcl` というファイルと `.terraform` というディレクトリができる。
git で管理する場合は `.terraform` ディレクトリは ignore すると良さそう。

ここまでで初期設定は完了。

### 設定ファイルを書く
#### 環境ごとに値が異なる変数を設定する
環境ごとにコードをコピペする際にできるだけ何も考えたくないので、環境ごとに異なる値は全て1つのファイルに集約して管理できるようにしたい。そのため下記のようなファイルを作った。

```tf:variables.tf
locals {
  domain_name      = "match-lab.com"
  bucket_name      = "lp.match-lab.com"
  static_host_name = "match-lab.com"
}
```

- domain_name: Route53 で取得した Hosted Zone 名
- bucket_name: 静的ファイルを保存する S3 のバケット名
- static_host_name: CloudFront から配信する際のホスト名（サブドメイン可）

ここで定義した変数は各設定ファイルから参照できるようになる。

#### 必要なサービスを立てていく

```tf:static.tf
# S3 bucket
resource "aws_s3_bucket" "static" {
  bucket = local.bucket_name

  force_destroy = false
}

# CloudFront が S3 にファイルを取りに行く時の identity
resource "aws_cloudfront_origin_access_identity" "static" {
  comment = "access-identity-${aws_s3_bucket.static.bucket_regional_domain_name}"
}

# S3 bucket policy で上記の identity に GetObject の権限を付与する
resource "aws_s3_bucket_policy" "static" {
  bucket = aws_s3_bucket.static.id

  policy = jsonencode(
    {
      Id = "PolicyForCloudFrontPrivateContent"
      Statement = [
        {
          Action = "s3:GetObject"
          Effect = "Allow"
          Principal = {
            AWS = "arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity ${aws_cloudfront_origin_access_identity.static.id}"
          }
          Resource = "arn:aws:s3:::${local.bucket_name}/*"
          Sid      = "1"
        }
      ]
      Version = "2008-10-17"
    }
  )
}

# bucket 上の object へのアクセス権限を管理するためのリソース
# ref: https://docs.aws.amazon.com/AmazonS3/latest/userguide/about-object-ownership.html
resource "aws_s3_bucket_ownership_controls" "static" {
  bucket = aws_s3_bucket.static.id

  rule {
    object_ownership = "BucketOwnerEnforced"
  }
}

# CloudFront distribution
resource "aws_cloudfront_distribution" "static_cdn" {
  enabled = true

  origin {
    domain_name = aws_s3_bucket.static.bucket_regional_domain_name
    origin_id   = aws_s3_bucket.static.bucket_regional_domain_name

    s3_origin_config {
      origin_access_identity = "origin-access-identity/cloudfront/${aws_cloudfront_origin_access_identity.static.id}"
    }
  }

  aliases             = [local.static_host_name]
  default_root_object = "index.html"
  is_ipv6_enabled     = true

  default_cache_behavior {
    allowed_methods        = ["GET", "HEAD"]
    cached_methods         = ["GET", "HEAD"]
    target_origin_id       = aws_s3_bucket.static.bucket_regional_domain_name
    viewer_protocol_policy = "redirect-to-https"
    cache_policy_id        = "658327ea-f89d-4fab-a63d-7e88639e58f6" # CloudFront にデフォルトで用意されている Cache Policy の ID
    compress               = true
  }

  viewer_certificate {
    acm_certificate_arn            = data.aws_acm_certificate.certificate.id
    cloudfront_default_certificate = false
    minimum_protocol_version       = "TLSv1.2_2021"
    ssl_support_method             = "sni-only"
  }

  restrictions {
    geo_restriction {
      restriction_type = "none"
      locations        = []
    }
  }
}

# CDN 配信時に利用する Route53 Record
# CloudFront distribution への alias になっている
resource "aws_route53_record" "static" {
  zone_id = data.aws_route53_zone.primary.zone_id
  name    = local.static_host_name
  type    = "A"

  alias {
    evaluate_target_health = false
    name                   = aws_cloudfront_distribution.static_cdn.domain_name
    zone_id                = aws_cloudfront_distribution.static_cdn.hosted_zone_id
  }
}
```

#### 複数環境から利用するリソースを外部リソースとして読み込む

今回は環境ごとに静的ファイル配信サーバーを立てるようなユースケースを考えている。

この時、例えば本番環境は https://match-lab.com で、開発環境が https://dev.match-lab.com だとすると、 AWS Route53 の Hosted Zone や ACM の certificate は環境が複数あっても1つを共有することになる。
これを愚直に terraform で管理しようとすると、片方の環境で変更を適用した時に他方の環境でも共有されているリソースが同時に変更されてしまう。

そこで、どうせ変更頻度も少ないリソースなので terraform 管理外ということで予め手動で作っておいて、それを terraform 上で外部リソースとして読み込むという形式を取ることにした。

```tf:hosted_zone.tf
data "aws_route53_zone" "primary" {
  name = local.domain_name
}
```

```tf:certificate.tf
data "aws_acm_certificate" "certificate" {
  provider = aws.us-east-1
  domain   = local.domain_name
}
```

これで `data.aws_route53_zone.primary` と `data.aws_acm_certificate.certificate` という名前で既存リソースにアクセスできるようになる。

### 設定の変更点を確認する
以上で必要なファイルは全て揃ったので、いま変更を適用した場合どうなるのかシミュレーションして確認する。

```sh
$ terraform plan
Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the
following symbols:
  + create

Terraform will perform the following actions:

  # aws_cloudfront_distribution.static_cdn will be created
  + resource "aws_cloudfront_distribution" "static_cdn" {
      + aliases                        = [
          + "match-lab.com",
        ]
      + arn                            = (known after apply)
      + caller_reference               = (known after apply)
      + default_root_object            = "index.html"
      + domain_name                    = (known after apply)
      + enabled                        = true
      + etag                           = (known after apply)
      + hosted_zone_id                 = (known after apply)
      + http_version                   = "http2"
      + id                             = (known after apply)
      + in_progress_validation_batches = (known after apply)
      + is_ipv6_enabled                = true
      + last_modified_time             = (known after apply)
      + price_class                    = "PriceClass_All"
      + retain_on_delete               = false
      + status                         = (known after apply)
      + tags_all                       = (known after apply)
      + trusted_key_groups             = (known after apply)
      + trusted_signers                = (known after apply)
      + wait_for_deployment            = true

      + default_cache_behavior {
          + allowed_methods        = [
              + "GET",
              + "HEAD",
            ]
          + cache_policy_id        = "658327ea-f89d-4fab-a63d-7e88639e58f6"
          + cached_methods         = [
              + "GET",
              + "HEAD",
            ]
          + compress               = true
          + default_ttl            = (known after apply)
          + max_ttl                = (known after apply)
          + min_ttl                = 0
          + target_origin_id       = (known after apply)
          + trusted_key_groups     = (known after apply)
          + trusted_signers        = (known after apply)
          + viewer_protocol_policy = "redirect-to-https"
        }

      + origin {
          + connection_attempts = 3
          + connection_timeout  = 10
          + domain_name         = (known after apply)
          + origin_id           = (known after apply)

          + s3_origin_config {
              + origin_access_identity = (known after apply)
            }
        }

      + restrictions {
          + geo_restriction {
              + locations        = (known after apply)
              + restriction_type = "none"
            }
        }

      + viewer_certificate {
          + acm_certificate_arn            = "arn:aws:acm:us-east-1:270422322105:certificate/bfa4a6f0-3487-460b-84fb-fb845b9440ef"
          + cloudfront_default_certificate = false
          + minimum_protocol_version       = "TLSv1.2_2021"
          + ssl_support_method             = "sni-only"
        }
    }

  # aws_cloudfront_origin_access_identity.static will be created
  + resource "aws_cloudfront_origin_access_identity" "static" {
      + caller_reference                = (known after apply)
      + cloudfront_access_identity_path = (known after apply)
      + comment                         = (known after apply)
      + etag                            = (known after apply)
      + iam_arn                         = (known after apply)
      + id                              = (known after apply)
      + s3_canonical_user_id            = (known after apply)
    }

  # aws_route53_record.static will be created
  + resource "aws_route53_record" "static" {
      + allow_overwrite = (known after apply)
      + fqdn            = (known after apply)
      + id              = (known after apply)
      + name            = "match-lab.com"
      + type            = "A"
      + zone_id         = "Z28PJVS3TJX39E"

      + alias {
          + evaluate_target_health = false
          + name                   = (known after apply)
          + zone_id                = (known after apply)
        }
    }

  # aws_s3_bucket.static will be created
  + resource "aws_s3_bucket" "static" {
      + acceleration_status                  = (known after apply)
      + acl                                  = (known after apply)
      + arn                                  = (known after apply)
      + bucket                               = "lp.match-lab.com"
      + bucket_domain_name                   = (known after apply)
      + bucket_regional_domain_name          = (known after apply)
      + cors_rule                            = (known after apply)
      + force_destroy                        = false
      + grant                                = (known after apply)
      + hosted_zone_id                       = (known after apply)
      + id                                   = (known after apply)
      + lifecycle_rule                       = (known after apply)
      + logging                              = (known after apply)
      + object_lock_enabled                  = (known after apply)
      + policy                               = (known after apply)
      + region                               = (known after apply)
      + replication_configuration            = (known after apply)
      + request_payer                        = (known after apply)
      + server_side_encryption_configuration = (known after apply)
      + tags_all                             = (known after apply)
      + versioning                           = (known after apply)
      + website                              = (known after apply)
      + website_domain                       = (known after apply)
      + website_endpoint                     = (known after apply)

      + object_lock_configuration {
          + object_lock_enabled = (known after apply)
          + rule                = (known after apply)
        }
    }

  # aws_s3_bucket_ownership_controls.static will be created
  + resource "aws_s3_bucket_ownership_controls" "static" {
      + bucket = (known after apply)
      + id     = (known after apply)

      + rule {
          + object_ownership = "BucketOwnerEnforced"
        }
    }

  # aws_s3_bucket_policy.static will be created
  + resource "aws_s3_bucket_policy" "static" {
      + bucket = (known after apply)
      + id     = (known after apply)
      + policy = (known after apply)
    }

Plan: 6 to add, 0 to change, 0 to destroy.

───────────────────────────────────────────────────────────────────────────────────
Note: You didn't use the -out option to save this plan, so Terraform can't guarantee to take exactly these actions if you
run "terraform apply" now.
```

上記のように、どんなリソースが追加/変更/削除されるのかが事前に分かるので、適用する前に必ずこまめに確認すると安心できる。

### 設定の変更を適用する
```sh
$ terraform apply
```

本当に実行してよいか確認されるので `yes` と回答すると実行される。
みるみるうちに S3 bucket, CloudFront Distribution, Route53 Record などを新規作成してくれる。

動作確認のため新しく作られた S3 に画像ファイルを1つアップロードしてみて、指定したドメインを叩いてみると、無事に画像が表示される。

### 不要になった環境を処分する
環境ごと削除したい時もコマンド1つで削除することができる。
（S3 にファイルが存在すると bucket を削除できないので、ファイルは予め削除しておくこと。）

まず削除コマンドを実行した時に実際に何が削除されるのか事前に確認する。

```sh
$ terraform plan -destroy
```

問題なければ削除コマンドを実行する。

```sh
$ terraform destroy
```

これでさっぱり削除することができる。

## 既存のインフラを terraform 管理に移行する
既に AWS Management Console から作成済みのリソースを後から terraform 管理に移行したい場合もある。
その場合は、既存のリソースを tfstate に反映する → tfstate と差分が出ないように設定ファイルを変更する、という手順で移行する。

### 既存のリソースを tfstate に反映する
[terraform import](https://www.terraform.io/cli/commands/import) コマンドを利用する。
例えば既存の S3 bucket を tfstate に反映するには、設定ファイルに resource ブロックだけ先に追加してからコマンドを実行する。

```tf
resource "aws_s3_bucket" "static" {
}
```

```sh
$ terraform import aws_s3_bucket.static match-lab.com
```

import したいリソースの名前を調べたい時は [docs](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/s3_bucket) で検索するとすぐ出てくる。

ちなみに間違ったリソースを import してしまった場合、そのまま resource ブロックを消してしまうと次の apply 時に import したリソースが削除されてしまうので tfstate の管理から外しておく必要がある。

```sh
$ terraform state rm aws_s3_bucket.static
```

### tfstate と差分が出ないように設定ファイルを変更する
`terraform plan` コマンドを使うと、不足している設定項目やこのまま実行した場合に出る差分などが表示されるので、差分を埋めるように設定ファイルを埋めていく。

```sh
$ terraform plan
```

最終的に差分がなくなれば apply しても何も起きないので、晴れて terraform の管理下に移行できたことになる。やったね！

## GitLab CI/CD で変更を検知して自動化
IaC の大きなメリットの1つは変更をバージョン管理できることなので、 PR をマージした時点で CI で自動 apply したかった。
今回のプロジェクトは GitLab を使っていたので GitLab CI/CD を利用して下記のように設定した。

```yaml:.gitlab-ci.yml
cache:
  paths:
    - ./terraform/.terraform
terraform-apply-staging:
  image:
    name: hashicorp/terraform:1.1.7
    entrypoint: [""]
  stage: deploy
  environment:
    name: terraform
  rules:
    - if: '$CI_COMMIT_BRANCH == "master"'
      changes:
        - terraform/**/*
  script:
    - cd ${CI_PROJECT_DIR}/terraform
    - terraform init
    - terraform apply -auto-approve
```

- 開発環境と同様に Docker の hashicorp/terraform イメージを利用した
- entrypoint を `[""]` で上書きすることで script を実行できるようにした
- master ブランチにマージされた時点で apply を実行するようにした
- CI 上で `yes` と回答することができずエラーになるので `-auto-approve` オプションを指定した
- `.terraform` ディレクトリはバージョンを変更しない限り毎回同じなので cache した

## まとめ
既存のリソースを import している過程で、環境ごとに設定に差分があることに気付いて統一することができたり、 terraform の docs を見ながら知らない設定項目に気づけたりして色々と勉強になった。

GitLab CI については [GitLab 公式が terraform 用に作った設定集](https://gitlab.com/gitlab-org/gitlab/-/blob/463cafc8ee85872c24e9032b0a4710134e847307/lib/gitlab/ci/templates/Terraform/Base.gitlab-ci.yml)みたいなものがあるがあるので、これを眺めていると新たな設定項目を知ることができたりして便利。

これで今後は静的ファイル配信サーバーを立てようと思った時にコピペするだけで済むのでだいぶ楽になりそう。