TerraformでS3、CloudFrontを構築してみよう!

technologies

こんにちは株式会社ベンジャミンの市川です!

暦の上では秋分も過ぎましたが、皆様お変わりございませんでしょうか。

今回は、弊社の案件でも利用しているインフラをコード化できるIaCの1つであるTerraformを使って、Amazon S3 & Amazon CloudFrontを作成してみましたので、その方法について書いていきます。

※注意※ 作成した後は必要に応じて、リソースを削除してください。

目次

環境情報

  • Terraform v1.4.4
  • macOS Monterey 12.5

前提

  • Terraform v1.4.4以上 がインストール済みであること
  • AWSアカウントを持っていること
  • アクセスキー、シークレットアクセスキーを発行済みであること

ユースケース & AWS構成図

この記事で紹介するハンズオンのユースケースは、

  • 画像や動画のコンテンツ置き場としてのS3を作成し
  • CloudFrontでそれを配信する

を想定しています。

※Webサイトのホスティングをするユースケースであれば、今回紹介する手順のように自前でCloudFront+S3を構築するよりもAWS Amplify hostingを利用する方が便利です

ディレクトリ構成

最終的なディレクトリ構成は以下のとおりです。

root/
├ terraform.tfvars # 環境名、認証情報、リージョンを定義 ※必ずgit管理から外すこと
├ main.tf # 全体設定および/modulesのresourceに渡す変数の値を定義・呼び出し
└ modules/ # 各リソースのファイル
  ├ s3_cloudfront/ # リソースの性能・パラメータを定義したテンプレート。基本的にはいじらない(※1)
  │ ├ s3_cloudfront.tf # 呼び出した際に作成されるリソースを記述
  │ ├ variables.tf # s3_cloudfront.tfで使用する型情報、初期値などを定義
  │ └ outputs.tf # 他のリソースへ公開したい変数を定義
  └ policies/
     └ bucket_policies/
       └ bucket_policy.json.tpl # バケットポリシーのテンプレート

(※1 いじりたい値がある場合はvariableとして切り出し、main.tfから注入する)

完成形(コード)

今回のハンズオンで作成するコードです。

  • terraform.tfvars
environment = "test"
access_key  = "XXXXXXXXXXXXXXXXXXXX" # ご自分のアカウントのアクセスキーに置き換えてください
secret_key  = "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" # ご自分のアカウントのシークレットキーに置き換えてください
region = "ap-northeast-1"
bucket_name_prefix = "xxxxxxxxxxxxxxxxxxxxxx" # S3バケット名はグローバルに一意である必要があるため、他人と被らない値に置き換えてください
  • main.tf
variable "environment" { type = string }
variable "access_key" { type = string }
variable "secret_key" { type = string }
variable "region" { type = string }
variable "bucket_name_prefix" { type = string }

locals {
  service = "s3cloudfront-handson"
  service_codes = {
    kebabcase = "${var.environment}-${local.service}"
  }
}

terraform {
  required_version = ">=1.4.4"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "4.60.0"
    }
  }
}

provider "aws" {
  access_key = var.access_key
  secret_key = var.secret_key
  region     = var.region

  default_tags {
    tags = {
      Environment = var.environment
      Service     = local.service
    }
  }
}

module "s3_cloudfront" {
  source = "./modules/s3_cloudfront"

  bucket_name = "${var.bucket_name_prefix}-${local.service_codes.kebabcase}-s3-filestorage"
  oac_name    = "${local.service_codes.kebabcase}-oac-filestorage"
  acl         = "private"
  policy_file = "${path.module}/policies/bucket_policies/bucket_policy.json.tpl"
}

output "cloudfront_domain_name" { value = module.s3_cloudfront.cloudfront_domain_name }
  • /modules/s3_cloudfront/s3_cloudfront.tf
resource "aws_s3_bucket_policy" "s3_policy" {
  bucket = aws_s3_bucket.default.id
  policy = templatefile("${var.policy_file}", {
    bucket_name    = var.bucket_name,
    cloudfront_arn = aws_cloudfront_distribution.cf.arn
  })
}

resource "aws_s3_bucket" "default" {
  bucket        = var.bucket_name
  force_destroy = true
}

resource "aws_cloudfront_origin_access_control" "default" {
  name                              = var.oac_name
  description                       = "S3のオブジェクトをインターネットへ配信するためのOAC"
  origin_access_control_origin_type = "s3"
  signing_behavior                  = "always"
  signing_protocol                  = "sigv4"
}

resource "aws_cloudfront_distribution" "cf" {
  comment = var.bucket_name

  enabled          = "true"
  http_version     = "http2"
  is_ipv6_enabled  = "true"
  price_class      = "PriceClass_All"
  retain_on_delete = "false"

  aliases = var.own_domain_name.aliases != null ? var.own_domain_name.aliases : []
  origin {
    domain_name              = aws_s3_bucket.default.bucket_regional_domain_name
    origin_id                = var.bucket_name
    origin_access_control_id = aws_cloudfront_origin_access_control.default.id
    connection_attempts      = "3"
    connection_timeout       = "10"
  }

  viewer_certificate {
    # 独自ドメインのACMを使用しない場合、デフォルトの証明書を使用する
    cloudfront_default_certificate = var.own_domain_name.acm_certificate_arn == null ? true : false

    # 独自ドメインのACMを使用する場合、証明書を指定する
    acm_certificate_arn = var.own_domain_name.acm_certificate_arn == null ? null : var.own_domain_name.acm_certificate_arn

    ssl_support_method       = "sni-only"
    minimum_protocol_version = "TLSv1"
  }

  default_cache_behavior {
    allowed_methods        = ["GET", "HEAD"]
    cached_methods         = ["GET", "HEAD"]
    compress               = "true"
    default_ttl            = "60"
    max_ttl                = "60"
    min_ttl                = "60"
    smooth_streaming       = "false"
    target_origin_id       = aws_s3_bucket.default.id
    viewer_protocol_policy = "allow-all"

    forwarded_values {
      query_string = false

      cookies {
        forward = "none"
      }
    }
  }

  restrictions {
    geo_restriction { // 地理的制限
      restriction_type = "none"
    }
  }
}
  • /modules/s3_cloudfront/variables.tf
variable "own_domain_name" {
  type = object({
    acm_certificate_arn = optional(string)
    aliases             = optional(list(string))
  })
  default = {
    acm_certificate_arn = null
    aliases             = null
  }
}

variable "bucket_name" {
  type    = string
  default = "test-example-s3"
}

variable "oac_name" {
  type    = string
  default = "test-example-oac"
}

variable "acl" {
  type    = string
  default = "private"
}

variable "policy_file" {
  type = string
}
  • /modules/s3_cloudfront/outputs.tf
output "s3_bucket_id" { value = aws_s3_bucket.default.id }
output "s3_bucket_regional_domain_name" { value = aws_s3_bucket.default.bucket_regional_domain_name }
output "cloudfront_domain_name" { value = aws_cloudfront_distribution.cf.domain_name }
output "cloudfront_aliases" { value = aws_cloudfront_distribution.cf.aliases }
output "cloudfront_arn" { value = aws_cloudfront_distribution.cf.arn }
output "cloudfront_hosted_zone_id" { value = aws_cloudfront_distribution.cf.hosted_zone_id }
  • /policies/bucket_policies/bucket_policy.json.tpl
{
  "Version": "2008-10-17",
  "Id": "PolicyForCloudFrontPrivateContent",
  "Statement": [
    {
      "Sid": "AllowCloudFrontServicePrincipal",
      "Effect": "Allow",
      "Principal": {
        "Service": "cloudfront.amazonaws.com"
      },
      "Action": "s3:GetObject",
      "Resource": "arn:aws:s3:::${bucket_name}/*",
      "Condition": {
        "StringEquals": {
          "AWS:SourceArn": "${cloudfront_arn}"
        }
      }
    }
  ]
}

1.Terraformのバージョンを確認する

まず、Terraformがインストール済みであるか、かつバージョンがv1.4.4以上であるかを確認しておきます。次のコマンドを実行し、確認してみましょう!

terraform --version
  • 実行後、以下の例のように出力されていればインストールできています。
xxxxxxxxx@xxxxxxxxMacBook-Pro handson % terraform --version
Terraform v1.4.4
on darwin_arm64

2.作成手順

あらかじめ、完成形で説明した各ファイル(コード)を作成しておいてください ※※

TerraformからAWSリソースにアクセスするためのクレデンシャル情報(リソース作成・削除の実行権限があるアカウントの)を設定していきましょう。以下のファイルを編集し、アクセスキーとシークレットキーをご自分のものに置き換えてください。

また、S3バケット名はグローバルに一意である必要がある(複数人がそのまま実行した場合、二人目以降はS3のバケット名の衝突でエラーになる)ため、`bucket_name_prefix` も他人と被らない値に置き換えてください。
(※ 使用できる文字種には制限があります。こちらの公式を参考にしてください。)

  • terraform.tfvars
environment = "test"
access_key  = "XXXXXXXXXXXXXXXXXXXX" # ご自分のアカウントのアクセスキーに置き換えてください
secret_key  = "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" # ご自分のアカウントのシークレットキーに置き換えてください
region = "ap-northeast-1"
bucket_name_prefix = "xxxxxxxxxxxxxxxxxxxxxx" # S3バケット名はグローバルに一意である必要があるため、他人と被らない値に置き換えてください

作業ディレクトリの初期化、必要なプラグインのインストールするため、ターミナルから以下のコマンドを実行しましょう。

terraform init

リソースを作成する前の確認作業として、.tf ファイルに記載された情報から、どのようなリソースが作成 / 修正 / 削除されるかを確認しておきましょう。次のコマンドを実行します。

terraform plan
  • 実行後、以下のように出力されているはずです。「Plan: 4 to add」で分かる通り、main.tfに記載されたリソースの作成が計画されていることが分かります。
# 出力結果
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:

~~~省略~~~

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

実行計画を確認できたところで、実際にAWS環境にリソースを作成してみましょう。
次のコマンドを実行してください。
-auto-approve オプションを付けることで、コマンド実行前の確認「yes」の入力が不要になります

terraform apply -auto-approve
  • 実行後、以下のように出力されていれば無事にリソースの作成ができています。
Plan: 4 to add, 0 to change, 0 to destroy.
module.s3_cloudfront.aws_cloudfront_origin_access_control.default: Creating...

~~~省略~~~

Apply complete! Resources: 4 added, 0 changed, 0 destroyed.

Outputs:

cloudfront_domain_name = "xxxxxxxxxxxxxx.cloudfront.net"

作成後、ブラウザからAWSコンソールにアクセスしてCloudFrontとS3が作成されていることを確認しましょう。

S3のページを開き、適当な画像ファイルをドラッグ&ドロップします。
(例:image.png)

「アップロード」をクリックします。

アップロードに成功したことを確認し、「閉じる」をクリックします。

ターミナルに戻り、以下のコマンドを実行します。

terraform output
  • main.tfに記述したoutput変数(ドメイン名)が出力されるので、それをコピーします。
xxxxxxxxx@xxxxxxxxMacBook-Pro handson % terraform output
cloudfront_domain_name = "xxxxxxxxxxxxxx.cloudfront.net" # ← これをコピー

ブラウザに戻り、新しいタブを開き「コピーしたドメイン名/アップロードした画像ファイル名」とURLを入力しアクセスします。
(例: xxxxxxxxxxxxxx.cloudfront.net/image.png)

無事に表示されていれば、S3のオブジェクトをCloudFrontで配信できています!

3.リソースを削除する

Terraformでリソースを作成しました。必要がなければ、稼働料金がかかってしまわないように、全て削除してしまいましょう。以下のコマンドを実行してください。

terraform destroy -auto-approve

4.コード補足 – 柔軟なポリシーを生成する

柔軟に変更できるバケットポリシーを作成するため、Terraformのテンプレート機能(templatefile関数)を使っています。これを使えば、bucket_policy.jsonをテンプレート化し、変更したい箇所をTerraformから変数で渡して作成することができます。

※ Template Provider (`data “template_file” “foo” {…}`) という書き方もありますが、M1・M2 Macに対応していない(?)、かつ公式非推奨のため使っていません
(参考:https://registry.terraform.io/providers/hashicorp/template/latest/docs)

  • /modules/s3_cloudfront/s3_cloudfront.tf (一部抜粋)
resource "aws_s3_bucket_policy" "s3_policy" {
  bucket = aws_s3_bucket.default.id
  policy = templatefile("${var.policy_file}", { # ← ココ
    bucket_name    = var.bucket_name,
    cloudfront_arn = aws_cloudfront_distribution.cf.arn
  })
}

5.応用編 – 独自ドメインを使用する

【前提】
・ACMを使用している かつ ワイルドカード証明書(例: *.xxxxxxxxxxxxxx.com)をus-east1リージョンに発行済み
・Route53を使用している かつ ホストゾーンを作成済み

独自ドメインを使用する場合は、以下を参考にコードを追加したあと、`terraform apply` を実行してください。今回は、サブドメイン `filestorage.` を付加しています。

  • main.tf
~~~省略~~~

module "s3_cloudfront" {
  source = "./modules/s3_cloudfront"

  bucket_name = "${var.bucket_name_prefix}-${local.service_codes.kebabcase}-s3-filestorage"
  oai_name    = "${local.service_codes.kebabcase}-oai-filestorage"
  oac_name    = "${local.service_codes.kebabcase}-oac-filestorage"
  acl         = "private"
  policy_file = "${path.module}/policies/bucket_policies/bucket_policy.json.tpl"

  // ★追加部分1
  own_domain_name = {
    acm_certificate_arn = data.aws_acm_certificate.host_domain_wc_acm_cloudfront.arn
    aliases             = [local.domain_names.filestorage]
  }
}

output "cloudfront_domain_name" { value = module.s3_cloudfront.cloudfront_domain_name }

// ★追加部分2(以下全て)
locals {
  host_domain = "xxxxxxxxxxxxxx.com" // ← ご自分のドメインに置き換えてください
  domain_names = {
    filestorage = "filestorage.${local.host_domain}"
  }
}
# ACM証明書用のプロバイダーを作成 ※ CloudFrontはus-east1の証明書が必要
provider "aws" {
  alias  = "virginia"
  region = "us-east-1"
}
# 発行済みのACMワイルドカード証明書をTerraformにインポートする
data "aws_acm_certificate" "host_domain_wc_acm_cloudfront" {
  provider = aws.virginia
  domain   = "*.${local.host_domain}"
}
# 作成済みのRoute53ホストゾーンをTerraformにインポートする
data "aws_route53_zone" "host_domain" {
  name = local.host_domain
}
# Route53にAレコードを作成する
resource "aws_route53_record" "filestorage" {
  zone_id = data.aws_route53_zone.host_domain.zone_id
  name    = local.domain_names.filestorage
  type    = "A"

  alias {
    name                   = module.s3_cloudfront.cloudfront_domain_name
    zone_id                = module.s3_cloudfront.cloudfront_hosted_zone_id
    evaluate_target_health = false
  }
}

実行後、少し待ちます(およそ5分〜20分以上)
ブラウザで「filestorage.独自ドメイン名/アップロードした画像ファイル名」とURLを入力しアクセスし、画像が表示されていれば完了です。
(例: filestorage.xxxxxxxxxxxxxx.com/image.png)

あとがき – WebサイトのS3 & CloudFrontにおけるユースケース

今回のハンズオンはコンテンツ置き場としてのS3 & CloudFrontでした。

Webサイトのユースケースの場合、SPA・SSGごとに合わせたリライト設定、Basic認証、独自ドメインを指定する際のACM証明書発行、Route53ホストゾーン作成など手作業で行う必要があるため、それらをコンソールぽちぽちするだけでよしなにやってくれるAmplifyで対応するのがベストだと感じました!

以上、この記事が皆様のお役に立てれば幸いです。それでは。

関連記事一覧