TerraformでCloudFront + S3静的サイトの構成テンプレート

CloudFront + S3での静的サイトをTerraformで実装するためのテンプレートです。

以前に似た記事を書きました。

https://qiita.com/suzuki-navi/items/cfd054b931ff3cb12cdf

https://qiita.com/suzuki-navi/items/6a896a6577deaa858210

CloudFrontのログのためのS3バケット作成も含めています。

# Terraformのファイル

```main.tf
variable aws_profile {}
variable aws_region {}
variable resource_prefix {}

provider "aws" {
   profile = var.aws_profile
   region = var.aws_region
}

data "aws_canonical_user_id" "current" {}

################################################################################
# S3 for static website
################################################################################

resource "aws_s3_bucket" "static_website" {
  bucket = "${var.resource_prefix}-static-website"
}

resource "aws_s3_bucket_policy" "static_website" {
    bucket = aws_s3_bucket.static_website.id
    policy = data.aws_iam_policy_document.static_website.json
}

data "aws_iam_policy_document" "static_website" {
  statement {
    sid = "Allow CloudFront"
    effect = "Allow"
    principals {
        type = "AWS"
        identifiers = [
          aws_cloudfront_origin_access_identity.static_website.iam_arn,
        ]
    }
    actions = [
        "s3:GetObject"
    ]

    resources = [
        "${aws_s3_bucket.static_website.arn}/*"
    ]
  }
}

################################################################################
# S3 for CloudFront logs
################################################################################

resource "aws_s3_bucket" "logs" {
  bucket = "${var.resource_prefix}-cloudfront-log"
}

# 今S3のIaCで「AccessControlListNotSupported: The bucket does not allow ACLs」というエラーが出たならそれは2023年4月に行われたS3の仕様変更が原因かもしれない | DevelopersIO
# https://dev.classmethod.jp/articles/s3-acl-error-from-202304/
resource "aws_s3_bucket_ownership_controls" "logs" {
  bucket = aws_s3_bucket.logs.id
  rule {
    object_ownership = "ObjectWriter"
  }
}

resource "aws_s3_bucket_acl" "logs" {
  bucket = aws_s3_bucket.logs.id

  access_control_policy {
    # This is the CloudFront log delivery group
    # https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/AccessLogs.html#access-logs-granting-permissions-to-cf-to-put-object-in-s3
    grant {
      grantee {
        id          = data.aws_canonical_user_id.current.id
        type        = "CanonicalUser"
      }
      permission = "FULL_CONTROL"
    }
    grant {
      grantee {
        id          = "c4c1ede66af53448b93c283ce9448c4ba468c9432aa01d700d3878632f77d2d0" # awslogsdelivery 
        type        = "CanonicalUser"
      }
      permission = "FULL_CONTROL"
    }
    owner {
      id = data.aws_canonical_user_id.current.id
    }
  }
}

################################################################################
# CloudFront
################################################################################

resource "aws_cloudfront_distribution" "static_website" {
  origin {
    domain_name = aws_s3_bucket.static_website.bucket_regional_domain_name
    origin_id = aws_s3_bucket.static_website.id
    s3_origin_config {
      origin_access_identity = aws_cloudfront_origin_access_identity.static_website.cloudfront_access_identity_path
    }
  }

  enabled =  true

  default_root_object = "index.html"

  default_cache_behavior {
    allowed_methods = [ "GET", "HEAD" ]
    cached_methods = [ "GET", "HEAD" ]
    target_origin_id = aws_s3_bucket.static_website.id

    forwarded_values {
      query_string = true
      cookies {
        forward = "none"
      }
    }

    viewer_protocol_policy = "redirect-to-https"
    min_ttl = 60
    default_ttl = 60
    max_ttl = 60
    compress = true

    # レスポンスのContent-Typeを書き換えるためのCloudFront Function
    function_association {
      event_type   = "viewer-response"
      function_arn = aws_cloudfront_function.response.arn
    }
  }

  restrictions {
    geo_restriction {
      restriction_type = "none"
      locations = []
    }
  }
  viewer_certificate {
    cloudfront_default_certificate = true
  }
  logging_config {
    bucket = aws_s3_bucket.logs.bucket_domain_name
  }
}

resource "aws_cloudfront_origin_access_identity" "static_website" {

}

# レスポンスのContent-Typeを書き換えるためのCloudFront Function
resource "aws_cloudfront_function" "response" {
  name    = "${var.resource_prefix}-response"
  runtime = "cloudfront-js-1.0"
  code    = file("response.js")
}

output "website_url" {
  value = "https://${aws_cloudfront_distribution.static_website.domain_name}/"
}

################################################################################
```

# CloudFront Functions

ファイルをローカルからS3にawscliなどでアップすると、S3オブジェクトの Content-Type が `binary/octet-stream` になってしまいます。それをCloudFrontで配信すると、ブラウザはHTMLファイルであってもバイナリファイルと認識してしまうダウンロードする挙動になります。

S3にあるオブジェクトの Content-Type を `text/html` など正しい値に設定するのが正しいやり方でしょうが、それが面倒です。

そこで、CloudFront Functionsでレスポンスを加工する構成にしています。

加工処理は `response.js` に書きます。Terraformを実行するディレクトリに置きます。CloudFront FunctionsではJavaScriptが動きます。

```response.js
function handler(event) {
  var response = event.response;
  var headers = response.headers;

  var path = event.request.uri;
  var p = path.lastIndexOf('.');
  var extension;
  if (p > 0) {
    extension = path.substring(p + 1);
  } else {
    extension = "";
  }
  if (path.endsWith("/") || extension == "html") {
    headers["content-type"] = {value: "text/html"};
  } else if (extension == "js") {
    headers["content-type"] = {value: "text/javascript"};
  } else if (extension == "css") {
    headers["content-type"] = {value: "text/css"};
  } else if (extension == "png") {
    headers["content-type"] = {value: "image/png"};
  } else if (extension == "jpg") {
    headers["content-type"] = {value: "image/jpeg"};
  } else if (extension == "gif") {
    headers["content-type"] = {value: "image/gif"};
  } else if (extension == "txt") {
    headers["content-type"] = {value: "text/plain"};
  }

  return response;
}
```

拡張子で Content-Type を判断してしまいます。すべての拡張子を網羅することはできませんので、場当たり的な解決策であることに注意してください。