TerraformでAWS S3に静的ウェブサイトをホスティングしてみた

## はじめに

ある時、Terraformの存在を知る機会がありました。
その時はGCPへのデプロイに用いましたが、仕事の中ではほぼAWSを利用しており、どれくらい便利なのだろうか試してみようと思いました。

# Terrafromとは？

[公式ページの説明文](https://www.terraform.io/intro/index.html)には次のように書かれています（DeepL翻訳より）

>TerraformはInfrastructure as Code（IaC）ツールで、インフラの構築、変更、バージョン管理を安全かつ効率的に行うことができます。これには、コンピュートインスタンス、ストレージ、ネットワークなどの低レベルのコンポーネントや、DNSエントリ、SaaS機能などの高レベルのコンポーネントが含まれます。Terraformは、既存のサービスプロバイダーとカスタムインハウスソリューションの両方を管理できます。

# インストール

公式のインストール手順に全部まとまっていました。
自分はWindows11のWSL2上で動いているUbuntu20.04にインストールしています。

https://learn.hashicorp.com/tutorials/terraform/install-cli

### バージョン情報

```shell
$ terraform -v
Terraform v1.0.11
on linux_amd64
```

# 1. 設定ファイルを用意する

Terraform用の設定ファイルを作成します。
名前は何でも良いですが、`.tf` という拡張子のファイルを用意します。
この中に設定を記述していきます。

また、ここではAWSのS3に静的ウェブサイトをホスティングするというシナリオで進めます。

# 1.1. プロバイダを定義

Terraformのプロバイダとは、各クラウドサービスやSaaSプロバイダ、その他APIとやり取するためのプラグインです。

この節で作成する内容は次のようになります。

```terraform:main.tf
terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 3.0"
    }
  }
}

# Configure the AWS Provider
provider "aws" {
  region   = "ap-northeast-1"
  profile = "myprofile"
}
```

### `required_providers`

```terraform:main.tf
terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 3.0"
    }
  }
}
```

Terraformがどのプロバイダを利用するか宣言する必要があります。

基本的には各プロバイダのドキュメントに書かれており、上記は[AWSプロバイダ](https://registry.terraform.io/providers/hashicorp/aws/latest/docs)を使用する際の例です。

では実際にプロバイダの設定を定義しましょう。

### `provider`

```terraform:main.tf
provider "aws" {
  region = "ap-northeast-1"
  profile = "myprofile"
}
```

ここではリージョンと、使用するAWSのプロファイル名を指定しています。

クレデンシャルの指定方法はいくつかあるのですが、これが一番良さそうに感じます。

### 【余談】クレデンシャルの指定方法

https://registry.terraform.io/providers/hashicorp/aws/latest/docs#authentication

１．ハードコード

```terraform
provider "aws" {
  region     = "ap-northeast-1"
  access_key = "my-access-key"
  secret_key = "my-secret-key"
}
```

２．環境変数を使用する

- `AWS_ACCESS_KEY_ID` & `AWS_SECRET_ACCESS_KEY`
- `AWS_PROFILE`

```terraform
provider "aws" {
  region = "ap-northeast-1"
  # ここには何も指定しない
}
```

例：

```shell
$ AWS_PROFILE=myprofile terraform apply
```

AWS CLIを使用する際に設定する環境変数を使用可能。リージョン指定も同様。

３．`profile` 引数を指定

```terraform:main.tf
provider "aws" {
  region = "ap-northeast-1"
  profile = "myprofile"
  # shared_credentials_file = "/path/to/credentials"
}
```

プロファイルを参照する場所は、Linuxではデフォルトが `~/.aws/credentials` となっている。
`shared_credentials_file` 引数を指定すると、参照先を変更可能。

閑話休題

# 1.2. リソースを定義

`resource` キーワードを使用して、リソースブロックを作成して定義します。

```terraform
resource "resource_type" "resource_name" {
  # ...
}
```

リソースブロックでは、リソースタイプの指定と名前付け、ブロックの中で様々な設定を行います。

### リソースタイプ

- リソースタイプは、常に`${provider_name}_`という接頭辞を持ちます。
  - 例：`uniquevision_beluga`というリソースタイプは、`uniquevision`というプロバイダに属する。
- ブロック内は、「引数」「属性」「メタ引数」を使用して設定を記述します。

### 引数(arguments)

- リソース固有の設定はこちらで行います。
- 設定が必須のものとそうでないものがあり、詳細は各リソースのドキュメントに記載されています。

### 属性(attributes)

- 既存のリソースに設定されている値のこと。
- `resource_type.resource_name.attribute_name`の形式で参照することができます。

### メタ引数(meta-arguments)

- リソースの動作を変更するような引数
  - 例：`count`メタ引数を使って複数のリソースを作成する
- これはリソースやプロバイダー固有の設定ではなくTerrafrom自体の機能

## S3バケット用のリソースを定義

https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/s3_bucket

```terraform
resource "aws_s3_bucket" "s3bucket" {
  bucket = "terraform-test-20211208"
}
```

記述する引数はリソースによるので、詳細は各リソースのドキュメントを参照されたし。
上記は最小の例。

# 2. 一旦デプロイしてみる

## 2.1. `terraform init`

```shell
$ terraform init
```

`terraform init` コマンドは、設定ファイルに記述されたプロバイダやリソースの定義に基づいて、リモートからモジュールをダウンロードするコマンドです。リソースを追加したらこれを行う必要があります。

## 2.2. `terraform plan`

```shell
$ terraform plan
```

デプロイを行った場合、定義したリソースについてどのような変化（作成・更新・削除）が起こるかを表示してくれます。

## 2.3. `terraform apply`

```shell
$ terraform apply
```

実際にデプロイを実行します。

実行の前に、`terraform plan` を実行したときと同じものが表示される。その後 `yes` の入力を求められ、入力することでデプロイが開始されます。

# 3. ファイルも一緒に置いてみる

上記ではバケットを作っただけだったのですが、今度は作成したバケットにファイルをアップロードするようにしてみます。

## 3.1. リソースを追加

先ほど作成した設定ファイルに以下の記述を追加

```terraform
# Put single file
resource "aws_s3_bucket_object" "single_object" {
  bucket = aws_s3_bucket.s3bucket.bucket
  key    = "index.html"
  source = "../dist/index.html"
}
```

`bucket` 引数では、同じ設定ファイル内のリソースの属性を参照しています。
`${リソース名}.${一意な名前}.${属性名}` で参照可能です。

アップロードするファイルを `source` 引数に指定し、再度 `terraform apply` コマンドを実行すると、無事S3にファイルがアップロードされています。

ただしこれでは問題があります。

## 3.2. ディレクトリを指定してアップロードする

上記の記述の仕方では、ディレクトリを一つ指定してその中のファイルを全てアップロードするといった処理が行えません。

ここで登場するのが、メタ引数の一つである `for_each` です。

先ほどのリソース定義を次のように修正します。

```terraform
# Put multiple files
resource "aws_s3_bucket_object" "multiple_objects" {
  for_each = fileset("../dist/")

  bucket   = aws_s3_bucket.s3bucket.bucket
  key      = each.value
  source   = "../dist/${each.value}"
}
```

`for_each` を使用すると、リソース内で `each` というメタ属性が使用可能になります。
上記の場合、組み込み関数である `fileset` に渡したディレクトリの中のファイルパスが順に `each` に渡されます。
これによって、１つのファイルではなく、１つのディレクトリを指定してアップロードすることが可能になりました。

実は問題はまだ解決していないのです。それを確認するためにまたデプロイしてみましょう。
今度は `../dist` の中に `index.js` と `style.css` も置いてみます（複数のファイルが増えていれば何でもよいです）。

`terraform apply` コマンドを実行すると、無事、全てのファイルがアップロードできたでしょうか？

試しにindex.htmlにアクセスしてみましょう。

## 3.3. MIMEタイプ

上記の方法でアップロードすると、HTMLファイルのMIMEタイプが `binary/octet-stream` となってしまいます。
`aws_s3_bucket_object` リソースでは `content_type` の指定もできますが、それではHTMLとそれ以外でリソースを分ける手間が発生してしまいます。

### Template Directory Module

これは既知の問題として知られており、Terraformの提供しているモジュールを利用することで対処可能です。

設定ファイルに以下の内容を追記し、モジュールを定義します。

```terraform
module "distribution_files" {
  source   = "hashicorp/dir/template" # 固定
  base_dir = "../dist" # ファイルを読み取るディレクトリ
}
```

また、先ほど作成した `aws_s3_bucket_object` リソースを以下のように修正します。

```terraform
resource "aws_s3_bucket_object" "multiple_objects" {
  for_each     = module.distribution_files.files

  bucket       = aws_s3_bucket.s3bucket.bucket
  key          = "dir2/${each.key}"
  source       = each.value.source_path
  content_type = each.value.content_type
}
```

[Template Directory Module](https://registry.terraform.io/modules/hashicorp/dir/template/latest) は、filesetと同じように `for_each` に渡して使用しますが、MIMEタイプの情報も持っているため、一緒に引数に渡すことで期待する結果になります。

# 4. ファイルの更新

Terraformは、設定ファイルに記述したリソースやその引数の値に変化が無いとデプロイが行われません。
例えば、ここまで用意した設定ファイルを用いて既にTerraformによってアップロードがされたファイルについて、その中身を修正しただけで対象ディレクトリ内のファイルの追加・削除やファイル名の更新が無い場合、リソースの持つ値が変らないため「No changes.」と出てしまいデプロイができません。
そのため、ファイルに何らかの更新がされた際に値が変化するような仕掛けをする必要があります。

よくある方法としては、ファイル内容からハッシュ値を生成して引数に渡すというものがあります。
以下のように記述してみます。

```terraform
resource "aws_s3_bucket_object" "multiple_objects" {
  for_each     = module.distribution_files.files

  bucket       = aws_s3_bucket.s3bucket.bucket
  key          = "dir2/${each.key}"
  source       = each.value.source_path
  content_type = each.value.content_type
  etag         = filemd5(each.value.source_path)
}
```

組み込み関数である `filemd5` とは、与えられたパスのファイルの内容からハッシュ値を生成します。これを `aws_s3_bucket_object` のリソースに渡すことで、内容に変更があった場合はハッシュ値が変化するためデプロイ対象になるというやり方です。

ここまでで、任意のファイルの追加・更新・削除をS3に反映させる仕組みが整いました。

# 5. CloudFront Distributionの作成

せっかくなので、作成されたバケットをオリジンとするCloudFront Distributionも管理してみましょう。

## 5.1. リソースの作成

設定ファイルに以下の記述を追加します。

`aws_cloudfront_distribution` リソースは、必須項目が多いので少し長くなっています。

```terraform
# Create a CloudFront Origin Access Identity
resource "aws_cloudfront_origin_access_identity" "oai" {
  comment = "Terraform Test"
}

# Create a CloudFront Distribution
resource "aws_cloudfront_distribution" "s3_distribution" {
  origin {
    domain_name = aws_s3_bucket.s3bucket.bucket_regional_domain_name
    origin_id   = aws_s3_bucket.s3bucket.bucket_regional_domain_name

    s3_origin_config {
      origin_access_identity = aws_cloudfront_origin_access_identity.oai.cloudfront_access_identity_path
    }
  }

  default_cache_behavior {
    allowed_methods = ["GET", "HEAD"]
    cached_methods  = ["GET", "HEAD"]
    cache_policy_id = "658327ea-f89d-4fab-a63d-7e88639e58f6" # CachingOptimized
    target_origin_id       = aws_s3_bucket.s3bucket.bucket_regional_domain_name
    viewer_protocol_policy = "redirect-to-https"
  }

  restrictions {
    geo_restriction {
      restriction_type = "none"
    }
  }

  viewer_certificate {
    cloudfront_default_certificate = true
  }

  comment             = "Terraform Test"
  default_root_object = "index.html"
  enabled             = true
  price_class         = "PriceClass_200"
  wait_for_deployment = false # これを設定しないと、完全に使用できるようになる（＝ステータスがDeploymentになる）まで処理が終わらなくなる
}
```

オリジンアクセスアイデンティティを作成するために、リソースを定義しています。
また、`aws_cloudfront_distribution` リソースからそれを参照しています。

## 5.2. Invalidation

せっかくなので、静的ファイルの更新時にキャッシュ削除もされるように設定しましょう。

Invalidationはリソースとしては用意されていないため、素直にAWS CLIコマンドを実行します。

一旦、まずは `terraform apply` の度にキャッシュ削除が行われるようにしてみましょう。
以下の記述を追加します。

```terraform
resource "null_resource" "invalidation" {
  triggers = {
    check_value = timestamp()
  }

  provisioner "local-exec" {
    command = "aws cloudfront create-invalidation --profile myprofile --distribution-id $DISTRIBUTION_ID --path '/*'"

    environment = {
      DISTRIBUTION_ID = aws_cloudfront_distribution.s3_distribution.id
    }
  }
}
```

色々新しい記述が含まれているので、１つずつ見ていきます。

### null_resource

通常のリソースと同じく、作成・更新・削除のライフサイクルを実装してはいますが、実際に何かを作成したり削除したりというアクションは行いません。
他のリソースと同様、初回追加時は `terraform init` コマンドが必要になります。

### triggers

`triggers` 引数で設定された値が変化したとき、このリソースを置き換えます。

### provisioner

これが（現状）`null_resource` を使う最大のメリットです。

provisionerブロックは、Terraformの設定ファイルの中で宣言するモデルでは直接表現できない、特定のアクションをモデル化するのに使用されます。
ただし、provisionerはあらゆるアクションができるという点で、Terraformの使用に不確実性をもたらしてしまいます。

### command

`command` 引数には、行いたいコマンドを記述します。
上記の例では、AWS CLIを用いて特性のディストリビューションでキャッシュの削除を行うようなコマンドを記述しています。

### envirionment

コマンド実行時に、環境変数としてTerraform内の値を渡すことが可能です。
上記の例では、Terraform設定ファイル内で定義されたリソースの値を参照しています。
コマンドが内部で環境変数を参照するなど、コマンド引数で渡せない場合は環境変数で指定するのがよさそうです。

## 5.3. Invalidationをファイル差分のある時に限定する

単にディストリビューションの設定を変えただけでキャッシュを無効化していては無駄が多いですしお金がもったいないです（$0.005／回）。
ファイルに差分があることを検証するにはどうしたらよいでしょうか？

ファイルの差分を検知する仕組みは、既にS3バケットへのファイルのアップロードの項で説明しました（`etag` にファイルのハッシュを渡す方法）。
ではどうやって複数のファイルを監視すればよいでしょうか？

### archive_file

Terrafromには、ZIPファイル等アーカイブを作成する便利な方法があります。

以下の記述を追加します。

```terraform
data "archive_file" "dist" {
  type        = "zip"
  source_dir  = "../dist"
  output_path = "/tmp/terraform/src.zip"
}
```

`data` キーワードを使用すると、Data Sourceの作成ができます。Data Sourceについては、ここでは深く説明はしません。

また、先ほど作成した `null_resource` の `trigger` を修正します。

```terraform
resource "null_resource" "invalidation" {
  triggers = {
    src_hash = "${data.archive_file.dist.output_sha}"
  }

  provisioner "local-exec" {
    command = "aws cloudfront create-invalidation --profile home --distribution-id $DISTRIBUTION_ID --path '/*'"

    environment = {
      DISTRIBUTION_ID = aws_cloudfront_distribution.s3_distribution.id
    }
  }
}
```

アップロード対象のディレクトリを指定してZIP化し、そのハッシュの変化をトリガーに設定することで、ファイルの追加・削除・内容の更新によって差分が発生するとキャッシュ削除が実行されるようになりました。

# 6. ログ出力

では、実際に自身のAWSコンソールでデプロイされたか確認しましょう。あなたはAWSコンソールからディストリビューションの一覧を確認します。
数が多い（100ページ）。そしてステータスがIn Progressのものが無い...。果たしてうまくいったのかわからない。探すのもこの数では時間が...。

ここではあるリソースの属性を参照しているのだが、どうにも上手く動かないようだ。
このパスにファイルがあるはず...、いや無い。しかし作成はされているようだ。いったいどこに作られてしまっている？

Terraformのエラーログはとても見やすいですが、だからと言ってデバッグが必要無くなることはないでしょう。

## 6.1. output

CloudFrontディストリビューションは、作成後ユニークなIDが割り当てられます。
`terraform apply` 実行後、すぐにAWSコンソールに確認に行きたいのですが、そのユニークな値がわかればすぐに探せそうですね。

設定ファイルに次の記述を追加してみましょう。

```terraform
output "cloudfront_distribution_id" {
  value       = aws_cloudfront_distribution.s3_distribution.id
  description = "Cloudfront Distribution ID"
}
```

`output` キーワードの直後にダブルクォートで囲った変数名が、valueに指定した値ですよという出力がされます。

例：

```shell
Apply complete! Resources: X added, Y changed, Z destroyed.

Outputs:

cloudfront_distribution_id = "EWL0CDW7YJZ9H"
```

# まとめと今後

- これまでは、必要な環境変数を全部最初にセットして、READMEに羅列されたコマンドをコピー＆ペーストで実行していただけだったが、Terraformでコマンド一発で実行できるようになったのが大きい
- また実行前に何が変化するのかわかるのは、ミス防止にも役立つと思われる。
- 今回は触れなかったが、ステージごとに異なる変数定義ファイルを用意できるので、より使い勝手は良さそう（曖昧）。
- アドベントカレンダーに間に合わなければ次回発表に回す予定だが、以下の内容を考えている。
  - GitLab CIのTerraform統合機能を試す
  - BelugaキャンペーンのECS周りを模倣して全部Terafformで管理できるか試してみる
  - LocalStackとの組み合わせ