Terraform で SPA を配信する HTTPS 静的サイトのインフラを構築する[Route53 + ACM + CloudFront + S3 + CloudFront Functions(Basic 認証 + α)]

Terraform で Vue.js や Rect などで作られた SPA サイトを独自ドメインで HTTPS 配信するためのインフラを構築していきます。
サンプルソースをリポジトリにまとめてみたのでこれを参考に紹介していきます。

  • terafform のバージョンを 1.0.11 から 1.1.6 に変更
  • aws provider のバージョンを 3.68.0 から 4.2.0 に変更
  • aws provider のバージョンが 3.68.0 の場合のソースはサンプルリポジトリにて tag(aws-provider-3.68.0) 付けしてるのでそちらを参照してください。

サンプルリポジトリ

サンプルとなるソースをリポジトリにまとめてみたのでこれを参考に紹介していきます。

※本記事の内容と基本的に一緒ですが、一気に作るのでなく Route53 と ACM、CloudFront の3ディレクトリに分けてます。
その為 remote state を使用した構成になっています。因みに リソース名の定義が下手だったり、access_key などの扱いが雑ですので使ってみる場合は使いやすい形に直してください。

前提

  • 独自ドメインでのアクセスを行うために、お名前.com などでドメインを購入してください。
    • この記事ではお名前.com で example.com というドメインを購入していると想定でいます
  • アクセス URL は example.com になります。www.example.com でのアクセスは想定していません。

Terraform

Terraform の Provider 定義

基本的には regionap-northeast-1 ですが、一部のリソース(ACM, CloudFunctions)の作成は us-east-1 で行うので、以下のように定義している想定です。

Copied!
provider "aws" {
  access_key = var.aws_access_key
  secret_key = var.aws_secret_key
  region     = var.region
}

provider "aws" {
  alias      = "us_east"
  access_key = var.aws_access_key
  secret_key = var.aws_secret_key
  region     = "us-east-1"
}

Route53 に Terraform で ホストゾーンを作成

まず Route53 でホストゾーンを作成します。
作成するホストゾーン名はパラメータで指定します。

Copied!
resource "aws_route53_zone" "main" {
  name = var.zone_name
}

terraform apply を実行し、AWS マネジメントコンソールなどでホストゾーンが作成されていることと、NS レコードが作成されていることを確認しましょう。
もし、ドメインを Route53 で購入または Route 53 に移管していないようであれば、この NS レコードをお名前.com などのドメインを購入したところで、Route53 に追加された NS レコードの値を使い、そのドメインに対する NS(ネームサーバー)の変更をしましょう。

Terraform で ACM による SSL 証明書を発行

Route53 へのホストゾーンの作成と必要であれば NS 変更が終わったら、ACM で SSL 証明書を発行します。

Copied!
resource "aws_acm_certificate" "main_cert" {
  provider                  = aws.us_east
  domain_name               = data.terraform_remote_state.route53.outputs.main_zone_name
  # wwwありのドメインも対象にしたい場合は以下を追加、使用予定はないけどリダイレクトしやすい環境に移行などした時のことを考え一応入れとく
  subject_alternative_names = ["www.${data.terraform_remote_state.route53.outputs.main_zone_name}"]
  # DNSを使ったドメイン認証
  validation_method         = "DNS"
}

# DNSを使ったドメイン認証のためにRoute53にcnameレコードを作成
resource "aws_route53_record" "main_cnames" {
  for_each = {
    for dvo in aws_acm_certificate.main_cert.domain_validation_options : dvo.domain_name => {
      name   = dvo.resource_record_name
      record = dvo.resource_record_value
      type   = dvo.resource_record_type
    }
  }
  zone_id = data.terraform_remote_state.route53.outputs.main_zone_id
  name    = each.value.name
  records = [each.value.record]
  type    = each.value.type
  ttl     = 60
}

resource "aws_acm_certificate_validation" "main_cert_validation" {
  provider                = aws.us_east
  certificate_arn         = aws_acm_certificate.main_cert.arn
  validation_record_fqdns = [for record in aws_route53_record.main_cnames : record.fqdn]
}

Terraform で CloudFront と OAI と S3  を作成

ここから一気に色々作成していきます。

Terraform で S3 を作成

Copied!
# vueなど配信コンテンツ用のバケット
resource "aws_s3_bucket" "origin_bucket" {
  bucket = local.domain_name
  force_destroy = true
}

#  CloudFront 以外からアクセスできる必要はないので、ACL をプライベートにします
resource "aws_s3_bucket_acl" "origin_bucket_acl" {
  bucket = aws_s3_bucket.origin_bucket.id
  acl    = "private"
}

# CloudFront 以外からアクセスできる必要はないので、パブリックアクセスを全てオフにします
resource "aws_s3_bucket_public_access_block" "origin_bucket" {
  bucket                  = aws_s3_bucket.origin_bucket.id
  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

# CloudFrontのアクセスログ用のバケット
resource "aws_s3_bucket" "logging_bucket" {
  bucket = "cloudfront-logs.${local.domain_name}"
  force_destroy = true
}

# パブリックアクセスを全てオフにします
resource "aws_s3_bucket_public_access_block" "logging_bucket" {
  bucket                  = aws_s3_bucket.logging_bucket.id
  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

# ログのバケットのライフサイクルを設定します
# この辺はお好みで変更してください
resource "aws_s3_bucket_lifecycle_configuration" "logging_bucket_lifecycle_config" {
  bucket = aws_s3_bucket.logging_bucket.bucket

  rule {
    id      = "s3-cloudfront-logs-transitions"
    status = "Enabled"

    expiration {
      days = 365
    }

    transition {
      days          = 90
      storage_class = "STANDARD_IA"
    }

    transition {
      days          = 180
      storage_class = "GLACIER"
    }
  }
}

Terraform で OAI (Origin Access Identity) を作成

パブリックアクセスでない S3 に CloudFront がアクセスできるようにするために、OAI を作成します

Copied!
# OAIの作成
resource "aws_cloudfront_origin_access_identity" "oai" {
  comment = "access-identity-ap-northeast-1-cloudfront-resource-periosprint-dev"
}

data "aws_iam_policy_document" "origin_access_identity_policy" {
  statement {
    sid       = "OriginAccessIdentity"
    actions   = ["s3:GetObject"]
    resources = ["${aws_s3_bucket.origin_bucket.arn}/*"]

    principals {
      type        = "AWS"
      identifiers = [aws_cloudfront_origin_access_identity.oai.iam_arn]
    }
  }
}

# OAIを利用したCloudFrontからのアクセスを許可するポリシーをS3のバケットに設定
resource "aws_s3_bucket_policy" "origin_bucket_policy" {
  bucket = aws_s3_bucket.origin_bucket.id
  policy = data.aws_iam_policy_document.origin_access_identity_policy.json
}

Terraform で CloudFront を作成

Copied!
# キャッシュポリシーの作成
resource "aws_cloudfront_cache_policy" "this" {
  name        = local.uppercase_domain_name
  comment     = local.uppercase_domain_name
  # default_ttl と max_ttl min_ttl は開発環境なら0にしてキャッシュしない方が開発しやすいです
  default_ttl = 86400
  max_ttl     = 31536000
  min_ttl     = 1
  parameters_in_cache_key_and_forwarded_to_origin {
    cookies_config {
      cookie_behavior = "none"
    }
    headers_config {
      header_behavior = "none"
    }
    query_strings_config {
      query_string_behavior = "none"
    }
    enable_accept_encoding_brotli = true
    enable_accept_encoding_gzip = true
  }
}

# オリジンリクエストポリシーの作成
resource "aws_cloudfront_origin_request_policy" "this" {
  name    = local.uppercase_domain_name
  comment = local.uppercase_domain_name
  cookies_config {
    cookie_behavior = "none"
  }
  headers_config {
    header_behavior = "none"
  }
  query_strings_config {
    query_string_behavior = "none"
  }
}

resource "aws_cloudfront_distribution" "cfd" {
  aliases = [local.domain_name]

  comment             = local.domain_name
  enabled             = true
  is_ipv6_enabled     = true
  price_class         = "PriceClass_200"
  default_root_object = "index.html"

  origin {
    domain_name = aws_s3_bucket.origin_bucket.bucket_regional_domain_name
    origin_id   = local.domain_name

    s3_origin_config {
      origin_access_identity = aws_cloudfront_origin_access_identity.oai.cloudfront_access_identity_path
    }
  }

  logging_config {
    include_cookies = false
    bucket          = aws_s3_bucket.logging_bucket.bucket_domain_name
  }

  default_cache_behavior {
    allowed_methods  = ["GET", "HEAD"]
    cached_methods   = ["GET", "HEAD"]
    target_origin_id = local.domain_name
    # httpでアクセスされた場合、httpsにリダイレクト
    viewer_protocol_policy = "redirect-to-https"
    compress               = true
    cache_policy_id = aws_cloudfront_cache_policy.this.id
    origin_request_policy_id = aws_cloudfront_origin_request_policy.this.id
  }

  custom_error_response {
    error_caching_min_ttl = "300"
    error_code            = "403"
    response_code         = "200"
    # Nuxt や Next.js で SSG を用いていて 404.html など専用ページがあれば置き換えてください。
    response_page_path    = "/index.html"
  }

  custom_error_response {
    error_caching_min_ttl = "300"
    error_code            = "404"
    response_code         = "200"
    # Nuxt や Next.js で SSG を用いていて 404.html など専用ページがあれば置き換えてください。
    response_page_path    = "/index.html"
  }

  restrictions {
    geo_restriction {
      restriction_type = "none"
    }
  }

  viewer_certificate {
    acm_certificate_arn      = data.terraform_remote_state.acm.outputs.acm_main_cert_arn
    ssl_support_method       = "sni-only"
    minimum_protocol_version = "TLSv1.2_2019"
  }
}

Terraform で Route53 (A レコード) を作成

Route53 に A レコードを追加して、独自ドメインにアクセスされた場合、CloudFront にアクセスされるようにします

Copied!
resource "aws_route53_record" "www" {
  zone_id = local.zone_id
  name    = local.domain_name
  type    = "A"

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

おまけ (Basic 認証と URL の書き換え)

開発環境などアクセスされたくない場合に便利

Terraform で CloudFront Functions (Basic 認証用の処理) を作成

basic_auth/function.js
Copied!
function handler(event) {
  var request = event.request
  var uri = request.uri
  var headers = request.headers

  // Basic 認証に用いる文字列(dXNlcjpwYXNzd29yZA==)の部分は shell などで以下のコマンドで生成します。
  // echo -n user:password | base64
  var basicAuth = 'Basic dXNlcjpwYXNzd29yZA=='

  if (
    typeof headers.authorization === 'undefined' ||
    headers.authorization.value !== basicAuth
  ) {
    return {
      statusCode: 401,
      statusDescription: 'Unauthorized',
      headers: { 'www-authenticate': { value: 'Basic' } },
    }
  }

  if (uri === '/') {
    return request
  }

  if (uri.endsWith('/')) {
    // リクエスト URL が https://hogehoge.com/foo/ の様に "/" で終わる場合は、この分岐に入ります。
    // その際の扱いは要件によって変えてください。

    // パターン1: Nuxt の例ですが、pages/bar/index.vue のような構成の場合は、
    // リクエスト URL が https://hogehoge.com/bar/ であれば、
    // https://hogehoge.com/bar/index.html にルーティングして欲しいので request.uri の末尾に index.html を付与します。
    request.uri = request.uri.concat('index.html')
    return request

    // パターン2: "/" で終わる場合は "/" を削ってリダイレクト
    return {
      statusCode: 302,
      statusDescription: 'Found',
      headers: {
        location: {
          value: request.uri.slice(0, -1),
        },
      },
    }
  } else if (!uri.includes('.')) {
    // ファイル名に "." が含まれていない = 拡張子がない場合、request.uri の末尾に  ".html" をつける
    request.uri = request.uri.concat('.html')
  }

  return request
}

Terraform で CloudFront Functions を CloudFront に設定

Copied!
resource "aws_cloudfront_function" "basic_auth" {
  name    = "basic_auth"
  runtime = "cloudfront-js-1.0"
  comment = "basic_auth function"
  publish = true
  code    = file("${path.module}/basic_auth/function.js")
}

resource "aws_cloudfront_distribution" "cfd" {
  default_cache_behavior {
    function_association {
      event_type = "viewer-request"
      function_arn = aws_cloudfront_function.basic_auth.arn
    }
  }
}