静的なWebサイトをAWSで公開するとき、S3の「静的ウェブサイトホスティング」を使う方法がよく紹介されます。手軽ですが、バケットを一般公開する必要があり、HTTPSにも対応していません。
この記事では、S3を非公開のままにし、CloudFrontのOAC(オリジンアクセスコントロール)経由でだけ配信する構成をTerraformで作ります。独自ドメインとHTTPSにも対応させます。
S3の静的ウェブサイトホスティングの問題点
- バケットを公開する必要がある:誰でもS3に直接アクセスできてしまう。CloudFrontを前に置いても、S3側への直接アクセスは防げない
- HTTPSに対応していない:ウェブサイトエンドポイントはHTTPのみ
- キャッシュや配信の最適化がない:利用者から遠いリージョンのS3から毎回配信される
CloudFrontとOACを組み合わせると、S3は完全に非公開のまま、CloudFrontからの読み取りだけを許可できます。
全体の構成
- S3バケット:サイトのファイルを置く。パブリックアクセスはすべてブロックする
- CloudFront:利用者からのHTTPSのリクエストを受け、S3から取得したファイルを配信・キャッシュする
- OAC:CloudFrontがS3にアクセスするときに署名を付け、S3側で「このCloudFrontからだけ」許可できるようにする
- ACM証明書:独自ドメインのHTTPS用の証明書
- Route 53:独自ドメインをCloudFrontに向ける
プロバイダーの設定
CloudFrontで使うACM証明書は、us-east-1(バージニア北部)リージョンで作る必要があります。そのため、東京リージョンのプロバイダーとは別に、us-east-1用のプロバイダーを用意します。
provider "aws" {
region = "ap-northeast-1"
}
provider "aws" {
alias = "us_east_1"
region = "us-east-1"
}
variable "domain_name" {
type = string
description = "例:www.example.com"
}
variable "hosted_zone_name" {
type = string
description = "例:example.com"
}
variable "bucket_name" {
type = string
}
東京リージョンで証明書を作ってしまうと、CloudFrontに設定する段階でエラーになります。初めての人がつまずきやすい点です。
S3バケット
resource "aws_s3_bucket" "site" {
bucket = var.bucket_name
}
resource "aws_s3_bucket_public_access_block" "site" {
bucket = aws_s3_bucket.site.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
パブリックアクセスのブロックをすべて有効にします。静的ウェブサイトホスティングの設定(aws_s3_bucket_website_configuration)は作りません。
ACM証明書とDNS検証
証明書はus-east-1のプロバイダーで作り、Route 53のレコードでドメインの所有を検証します。
data "aws_route53_zone" "this" {
name = var.hosted_zone_name
}
resource "aws_acm_certificate" "site" {
provider = aws.us_east_1
domain_name = var.domain_name
validation_method = "DNS"
lifecycle {
create_before_destroy = true
}
}
resource "aws_route53_record" "cert_validation" {
for_each = {
for dvo in aws_acm_certificate.site.domain_validation_options : dvo.domain_name => {
name = dvo.resource_record_name
type = dvo.resource_record_type
record = dvo.resource_record_value
}
}
zone_id = data.aws_route53_zone.this.zone_id
name = each.value.name
type = each.value.type
records = [each.value.record]
ttl = 300
}
resource "aws_acm_certificate_validation" "site" {
provider = aws.us_east_1
certificate_arn = aws_acm_certificate.site.arn
validation_record_fqdns = [for r in aws_route53_record.cert_validation : r.fqdn]
}
aws_acm_certificate_validationは、検証が完了するまでapplyを待ってくれるリソースです。CloudFrontはこのリソースの証明書ARNを参照することで、検証済みの証明書ができてから作られる順序になります。
OACとCloudFrontのディストリビューション
resource "aws_cloudfront_origin_access_control" "site" {
name = "${var.bucket_name}-oac"
origin_access_control_origin_type = "s3"
signing_behavior = "always"
signing_protocol = "sigv4"
}
data "aws_cloudfront_cache_policy" "optimized" {
name = "Managed-CachingOptimized"
}
resource "aws_cloudfront_distribution" "site" {
enabled = true
default_root_object = "index.html"
aliases = [var.domain_name]
origin {
domain_name = aws_s3_bucket.site.bucket_regional_domain_name
origin_id = "s3-site"
origin_access_control_id = aws_cloudfront_origin_access_control.site.id
}
default_cache_behavior {
target_origin_id = "s3-site"
viewer_protocol_policy = "redirect-to-https"
allowed_methods = ["GET", "HEAD"]
cached_methods = ["GET", "HEAD"]
cache_policy_id = data.aws_cloudfront_cache_policy.optimized.id
compress = true
}
restrictions {
geo_restriction {
restriction_type = "none"
}
}
viewer_certificate {
acm_certificate_arn = aws_acm_certificate_validation.site.certificate_arn
ssl_support_method = "sni-only"
minimum_protocol_version = "TLSv1.2_2021"
}
}
ポイントは次のとおりです。
- origin.domain_name:bucket_regional_domain_name(S3のREST APIのエンドポイント)を使う。ウェブサイトエンドポイントではOACが使えない
- signing_behavior = "always":CloudFrontからS3へのリクエストに、常に署名を付ける
- viewer_protocol_policy = "redirect-to-https":HTTPでアクセスされたら、HTTPSにリダイレクトする
- cache_policy_id:AWSが用意しているキャッシュポリシー(Managed-CachingOptimized)を使う。独自に細かく設定するより、まずはこれで十分
S3のバケットポリシー
最後に、S3側で「このCloudFrontディストリビューションからの読み取りだけ」を許可します。
data "aws_iam_policy_document" "site" {
statement {
actions = ["s3:GetObject"]
resources = ["${aws_s3_bucket.site.arn}/*"]
principals {
type = "Service"
identifiers = ["cloudfront.amazonaws.com"]
}
condition {
test = "StringEquals"
variable = "AWS:SourceArn"
values = [aws_cloudfront_distribution.site.arn]
}
}
}
resource "aws_s3_bucket_policy" "site" {
bucket = aws_s3_bucket.site.id
policy = data.aws_iam_policy_document.site.json
}
Principalは「CloudFrontサービス全体」ですが、conditionでAWS:SourceArnを自分のディストリビューションに限定しています。この条件がないと、他人のCloudFrontからでもバケットを読めてしまいます。
独自ドメインをCloudFrontに向ける
resource "aws_route53_record" "site" {
zone_id = data.aws_route53_zone.this.zone_id
name = var.domain_name
type = "A"
alias {
name = aws_cloudfront_distribution.site.domain_name
zone_id = aws_cloudfront_distribution.site.hosted_zone_id
evaluate_target_health = false
}
}
CloudFrontには固定のIPアドレスがないため、CNAMEではなくRoute 53のエイリアスレコードを使います。
つまずきやすい点:サブディレクトリのindex.html
default_root_objectが効くのは、ドメイン直下(/)へのアクセスだけです。/about/ のようなサブディレクトリにアクセスしても、about/index.html は自動では返されず、エラーになります。
これを解決するには、CloudFront Functionsでリクエストのパスの末尾に index.html を付け足すか、ファイルの置き方を「about.html」のようにURLと一致させます。Next.jsなどの静的書き出しを使う場合は、出力の形式を確認しておきましょう。
ファイルのデプロイ
ファイルをS3に同期したあと、CloudFrontのキャッシュを削除します。
aws s3 sync ./dist s3://my-site-bucket --delete
aws cloudfront create-invalidation \
--distribution-id E1234567890ABC \
--paths "/*"
キャッシュの削除(Invalidation)は、月1,000パスまでは無料です。「/*」は1パスとして数えられるので、デプロイのたびに全体を削除しても、通常は料金がかかりません。
まとめ
- S3の静的ウェブサイトホスティングではなく、非公開のS3とCloudFront+OACで配信する
- CloudFront用のACM証明書はus-east-1で作る
- バケットポリシーはAWS:SourceArnで自分のディストリビューションに限定する
- サブディレクトリのindex.htmlは自動では返らないので、CloudFront Functionsなどで対応する
S3を一切公開しない構成にしておけば、「バケットの公開設定を間違えて情報が漏れた」という事故そのものが起こらなくなります。