はじめに
CloudFront + S3 の静的サイトをTerraformで組むと、OACのバケットポリシー、キャッシュポリシーの新旧、圧縮の判断、ログの出し方と、細かい設定で迷う場面が多くあります。
この記事は、CloudFront + S3 のコード一式を載せて一通り構築し、最後にリリース前チェックリストでまとめます。WAF・Lambda@Edge・Origin Shieldといった有料アドオンは使わず、OAC/デフォルト証明書で構成します。本編(ステップ1〜2:最小構成+セキュリティヘッダー)はほぼ無料枠の範囲で動かせます。
アクセスログ(標準ログv2 → CloudWatch Logs)だけは、おまけ①に分けました。こちらは vended logs(AWSがログ配信に課す従量料金)の取り込み・保管料が別途かかります(保持日数で調整できます)。
コードにしておけば、適用はCLIで流すだけで済み、設定の意味で迷ってもAIに聞きながら直せます。
環境
| 項目 | バージョン |
|---|---|
| Terraform | 1.15.6 |
| AWS Provider | 6.50.0 |
| メインリージョン | ap-northeast-1 |
構成図
(HTTPS) (OAC / SigV4署名)
ユーザー ──────────▶ CloudFront ──────────▶ S3バケット(非公開)
│
├─ Response Headers Policy(セキュリティヘッダー)
└─ 標準ログv2 → CloudWatch Logs(us-east-1)※おまけ①で追加
-
Functions(CloudFront Functions)… サブディレクトリの
index.html補完(おまけ②)/IP制限(おまけ③) - AWS Certificate Manager(ACM)… 独自ドメイン+証明書(おまけ④)
- Amazon CloudWatch(CloudWatch Logs)… アクセスログ(おまけ①)
構築する(コード一式)
ステップ1:最小構成
最小構成のディレクトリはこうです。module/ 配下に2モジュール、ルートに main.tf / providers.tf。
.
├── providers.tf
├── main.tf
└── module/
├── s3-static-site/
│ ├── main.tf
│ ├── variables.tf
│ ├── bucket.tf
│ └── outputs.tf
└── cloudfront-static-site/
├── main.tf
├── variables.tf
├── origin_access_control.tf
├── distribution.tf
├── bucket_policy.tf
└── outputs.tf
providers.tf — メインの東京リージョンに加えて、CloudFrontのログ用の us-east-1(virginia エイリアス)を最初から用意しておきます。本編では us-east-1 にリソースを作りませんが、あとからログ(おまけ①)を足すときにモジュール側を書き換えずに済むよう、プロバイダだけ先に通しておきます。
# providers.tf
terraform {
required_version = ">= 1.15.6"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 6.50.0"
}
}
}
provider "aws" {
region = "ap-northeast-1"
}
# CloudFrontのログ(標準ログv2)は us-east-1 で作るため、別名で用意しておく
provider "aws" {
alias = "virginia"
region = "us-east-1"
}
S3モジュール(module/s3-static-site/) — ここでは非公開バケットを作ります。
# main.tf
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 6.50.0"
}
}
}
# variables.tf
variable "env_value_environment" {}
variable "name" {
default = "static-site"
}
# bucket.tf
# 静的コンテンツ配信用のS3バケット。公開はせず、CloudFront(OAC)経由でのみ読み取らせる。
resource "aws_s3_bucket" "this" {
bucket = "${var.name}-${var.env_value_environment}"
}
resource "aws_s3_bucket_public_access_block" "this" {
bucket = aws_s3_bucket.this.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
# OAC構成ではACLを使わないので、所有者強制(ACL無効)にしておく。
resource "aws_s3_bucket_ownership_controls" "this" {
bucket = aws_s3_bucket.this.id
rule {
object_ownership = "BucketOwnerEnforced"
}
}
resource "aws_s3_bucket_server_side_encryption_configuration" "this" {
bucket = aws_s3_bucket.this.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
}
}
# outputs.tf
output "bucket_id" {
value = aws_s3_bucket.this.id
}
output "bucket_arn" {
value = aws_s3_bucket.this.arn
}
output "bucket_regional_domain_name" {
value = aws_s3_bucket.this.bucket_regional_domain_name
}
CloudFrontモジュール(module/cloudfront-static-site/) — configuration_aliases で us-east-1(aws.virginia)を受け取れるようにしておきます。本編ではこのプロバイダを使うリソースはありませんが、こうしておくとログ(おまけ①)を足すときに main.tf を触らずに済みます。
# main.tf
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 6.50.0"
# ログ(おまけ①)で us-east-1 を使うため、最初から受け取れるようにしておく
configuration_aliases = [aws.virginia]
}
}
}
# variables.tf
variable "env_value_environment" {}
variable "name" {
default = "static-site"
}
# S3オリジン(s3-static-siteの出力を渡す)
variable "s3_bucket_id" {}
variable "s3_bucket_arn" {}
variable "s3_bucket_regional_domain_name" {}
variable "default_root_object" {
default = "index.html"
}
variable "spa_mode" {
default = true
}
# PriceClass_100(最安) / 200(日本含む) / All
variable "price_class" {
default = "PriceClass_200"
}
# 独自ドメイン(おまけ④参照。未設定なら cloudfront.net のデフォルト証明書)
variable "acm_certificate_arn" {
default = ""
}
variable "aliases" {
type = list(string)
default = []
}
# origin_access_control.tf
resource "aws_cloudfront_origin_access_control" "this" {
name = "${var.name}-oac-${var.env_value_environment}"
description = "OAC for ${var.name}-${var.env_value_environment}"
origin_access_control_origin_type = "s3"
signing_behavior = "always"
signing_protocol = "sigv4"
}
# distribution.tf
locals {
origin_id = "s3-${var.s3_bucket_id}"
use_default_cert = var.acm_certificate_arn == ""
}
data "aws_cloudfront_cache_policy" "caching_optimized" {
name = "Managed-CachingOptimized"
}
resource "aws_cloudfront_distribution" "this" {
enabled = true
is_ipv6_enabled = true
comment = "${var.name}-${var.env_value_environment}"
default_root_object = var.default_root_object
price_class = var.price_class
http_version = "http2and3"
aliases = var.aliases
origin {
domain_name = var.s3_bucket_regional_domain_name
origin_id = local.origin_id
origin_access_control_id = aws_cloudfront_origin_access_control.this.id
connection_attempts = 3 # 接続試行回数(1〜3、デフォルト3)
connection_timeout = 10 # 接続確立を待つ秒数(1〜10、デフォルト10)
# response_completion_timeout = 60 # 応答全体の完了を待つ最大秒数(必要時)
}
default_cache_behavior {
target_origin_id = local.origin_id
viewer_protocol_policy = "redirect-to-https"
allowed_methods = ["GET", "HEAD"]
cached_methods = ["GET", "HEAD"]
compress = true
cache_policy_id = data.aws_cloudfront_cache_policy.caching_optimized.id
}
# SPA: 403/404をindex.htmlに流して200で返す
dynamic "custom_error_response" {
for_each = var.spa_mode ? [403, 404] : []
content {
error_code = custom_error_response.value
response_code = 200
response_page_path = "/${var.default_root_object}"
error_caching_min_ttl = 0
}
}
restrictions {
geo_restriction {
restriction_type = "none"
}
}
# 証明書: 独自ドメイン未指定なら cloudfront.net のデフォルト証明書(無料)
dynamic "viewer_certificate" {
for_each = local.use_default_cert ? [1] : []
content {
cloudfront_default_certificate = true
}
}
# 独自ドメイン指定時はACM(us-east-1)をSNIで使う(おまけ④参照)
dynamic "viewer_certificate" {
for_each = local.use_default_cert ? [] : [1]
content {
acm_certificate_arn = var.acm_certificate_arn
ssl_support_method = "sni-only"
minimum_protocol_version = "TLSv1.2_2021"
}
}
tags = {
Name = "${var.name}-${var.env_value_environment}"
}
}
# bucket_policy.tf
# OAC経由のCloudFrontにだけS3読み取りを許可。SourceArnで自ディストリビューションに限定。
data "aws_iam_policy_document" "s3_oac" {
statement {
sid = "AllowCloudFrontServicePrincipalReadOnly"
effect = "Allow"
actions = ["s3:GetObject"]
resources = ["${var.s3_bucket_arn}/*"]
principals {
type = "Service"
identifiers = ["cloudfront.amazonaws.com"]
}
condition {
test = "StringEquals"
variable = "AWS:SourceArn"
values = [aws_cloudfront_distribution.this.arn]
}
}
}
resource "aws_s3_bucket_policy" "s3_oac" {
bucket = var.s3_bucket_id
policy = data.aws_iam_policy_document.s3_oac.json
}
# outputs.tf
output "distribution_id" {
value = aws_cloudfront_distribution.this.id
}
output "distribution_arn" {
value = aws_cloudfront_distribution.this.arn
}
output "distribution_domain_name" {
value = aws_cloudfront_distribution.this.domain_name
}
main.tf(呼び出し) — CloudFrontモジュールに aws.virginia プロバイダを渡します(account_id はログを足すおまけ①から必要になります)。
# main.tf
module "static_site_s3" {
source = "./module/s3-static-site"
env_value_environment = "develop"
name = "project-x"
}
module "static_site_cloudfront" {
source = "./module/cloudfront-static-site"
providers = {
aws.virginia = aws.virginia # ログ(おまけ①)で us-east-1 を使うため最初から渡す
}
env_value_environment = "develop"
name = "project-x"
s3_bucket_id = module.static_site_s3.bucket_id
s3_bucket_arn = module.static_site_s3.bucket_arn
s3_bucket_regional_domain_name = module.static_site_s3.bucket_regional_domain_name
price_class = "PriceClass_200"
spa_mode = true
}
output "cloudfront_url" {
value = "https://${module.static_site_cloudfront.distribution_domain_name}"
}
apply して動作確認
terraform init
terraform apply # CloudFrontの作成は10〜15分かかる
echo '<h1>Hello CloudFront</h1>' > index.html
aws s3 cp index.html s3://project-x-develop/index.html
# 配信URLは output から取れる
URL=$(terraform output -raw cloudfront_url)
curl -I "$URL" # 200
curl -I "${URL/https/http}" # 301 → https
apply 直後はディストリビューションの状態が Deployed になるまで(数分〜十数分)かかります。すぐ叩いて応答がおかしいときは、少し待ってから再度確認してください。また、コンテンツを差し替えたのに古いままなら、エッジキャッシュが残っているので /* の無効化を実行してください。
ステップ2:セキュリティヘッダーを足す
HSTSなどのセキュリティヘッダーを付けます。response_headers_policy.tf を追加:
# response_headers_policy.tf
resource "aws_cloudfront_response_headers_policy" "this" {
name = "${var.name}-secure-headers-${var.env_value_environment}"
security_headers_config {
strict_transport_security {
access_control_max_age_sec = 31536000
include_subdomains = true
preload = true
override = true
}
content_type_options {
override = true
}
frame_options {
frame_option = "DENY"
override = true
}
referrer_policy {
referrer_policy = "strict-origin-when-cross-origin"
override = true
}
}
}
distribution.tf の default_cache_behavior に1行足して、ビヘイビアに紐付けます:
default_cache_behavior {
# ... 既存の設定 ...
cache_policy_id = data.aws_cloudfront_cache_policy.caching_optimized.id
response_headers_policy_id = aws_cloudfront_response_headers_policy.this.id # ← 追加
}
apply し直すと、レスポンスに strict-transport-security などが付きます(冒頭のcurl出力の状態)。
ここまでが、無料枠の範囲で動かせる本編です。次に、自分の環境に合わせて変える値を整理しておきます。アクセスログ(CloudWatch Logs)を出したい場合は、本編のあとの おまけ① を追加してください。
自分の環境に合わせて変える値
本編(ステップ1〜2)で使う値
| 変数 | 決め方 |
|---|---|
name / env_value_environment
|
リソース名の接頭辞。この2つから S3バケット名 {name}-{env}(例: project-x-develop)が決まる。バケット名は世界中で一意なので、他と被らない名前にする。 |
price_class |
日本のユーザーが中心なら PriceClass_200(東京エッジを含む)。コスト優先で日本のレイテンシを問わないなら PriceClass_100、世界中のユーザーに広く配信したいなら全エッジを使う PriceClass_All(その分割高)。 |
viewer_protocol_policy |
Webサイトとして見せるなら redirect-to-https。APIでHTTPを完全に拒否したいなら https-only。 |
compress |
HTML/CSS/JSなどテキスト中心なら true。配信物が画像・動画ばかり、またはオリジンで圧縮済みなら false。 |
spa_mode |
React/VueなどのSPAなら true(存在しないパスを index.html に返す)。ページごとにファイルがある通常サイトなら false。 |
アクセスログ(おまけ①)を入れる場合の値
| 変数 | 決め方 |
|---|---|
account_id |
ログ配信を許可するポリシーで参照しているため、自分のAWSアカウントID(12桁)をそのまま入れる。 |
enable_cloudwatch_logging |
アクセスログが要るなら true、不要なら false。true のときの保持日数は cloudwatch_log_retention_in_days で決める。 |
(aws.virginia プロバイダは本編の providers.tf と呼び出しで配線済みなので、ログを足すときに追加の配線は不要です。)
Q&A:設定の意味
Q. CloudFront経由でアクセスすると 403 Access Denied になる
A. OAC + S3 構成でよく見られる事象です。主な原因として、次の3つが考えられます。
-
バケットポリシーが無い/
AWS:SourceArnが別のディストリビューションのまま。作り直してARNが変わったのにポリシーが古いままだと起こります。まずここを確認してください。 -
default_root_objectが未設定。/(ルート)へのアクセスがオブジェクト指定なし扱いになり403になります。default_root_object = "index.html"を設定します。 -
サブパスにS3キーが無い。
/aboutのようなパスは、S3に該当キーが無いと404ではなく403が返ります(OAC構成での注意点)。SPAの場合は後述のcustom_error_responseで対処します。
Q. OAIとOAC、どっちを使えばいい?
A. これから作るなら OAC。OAI(Origin Access Identity)は旧方式で、AWSもOACへの移行を案内しています。OACはSigV4署名でアクセスし、SSE-KMS暗号化のバケットや全リージョンにも対応します。既存のOAI構成を急いで移す必要はないですが、新規でOAIを選ぶ理由はありません。
Q. forwarded_values を使った設定例を見かけますが、今もこの書き方でよいですか?
A. 今は非推奨です。forwarded_values は旧方式(Legacy)で、AWSは Cache policy / Origin request policy への移行を案内しています。Terraformでもこの2系統は排他で、両方は書けません。
Legacy(forwarded_values) |
推奨(policy方式) | |
|---|---|---|
| キャッシュキーと転送 | 一体 | 別ポリシーに分離 |
| TTL | ビヘイビアに直書き | Cache policy 側 |
| 再利用 | 不可 | 複数で共有可 |
| 圧縮(gzip/br)のキー | 自動考慮 | Cache policyで明示(CachingOptimizedは考慮済み) |
古い記事で forwarded_values を見かけても、新規では使わないことをおすすめします。静的配信には Managed-CachingOptimized、キャッシュさせたくない箇所には Managed-CachingDisabled を使うとよいでしょう。
Q. AWSがデフォルトで用意しているマネージドポリシーには、どんなものがある?
A. キャッシュ/オリジンリクエスト/レスポンスヘッダーの3種類それぞれに、AWS管理のポリシーがあります。自分で作らず、まずはこれらを使うのが早いです(Terraformでは data "aws_cloudfront_*_policy" に Managed-... の名前で参照)。代表的なものは次のとおり。
キャッシュポリシー(何をキャッシュキーにするか)
| 名前 | どんなときに使うか |
|---|---|
Managed-CachingOptimized |
静的配信の定番。Cookie/クエリ/ヘッダーをキーから外し、gzip/brotliを考慮 |
Managed-CachingDisabled |
キャッシュしない(TTL=0)。動的・API向け |
Managed-CachingOptimizedForUncompressedObjects |
既に圧縮済みの大きめオブジェクト向け |
オリジンリクエストポリシー(オリジンへ何を転送するか)
| 名前 | どんなときに使うか |
|---|---|
Managed-AllViewer |
ビューワーのヘッダー・Cookie・クエリを全部オリジンへ転送 |
Managed-AllViewerExceptHostHeader |
Hostヘッダー以外を全部転送。ALB/API Gateway/Lambdaなどのカスタムオリジン向け |
Managed-CORS-S3Origin |
S3のCORSに必要なヘッダー(Origin等)を転送 |
Managed-UserAgentRefererHeaders |
User-AgentとRefererだけ転送 |
レスポンスヘッダーポリシー(任意・返却ヘッダーを付与)
| 名前 | どんなときに使うか |
|---|---|
Managed-SecurityHeadersPolicy |
HSTS / X-Content-Type-Options / X-Frame-Options / Referrer-Policy などをまとめて付与 |
Managed-SimpleCORS |
単純なCORSヘッダーを付与 |
Managed-CORS-with-preflight |
プリフライト対応のCORSヘッダーを付与 |
Managed-CORS-and-SecurityHeadersPolicy |
CORS+セキュリティヘッダーをまとめて付与 |
今回のモジュールは、キャッシュは Managed-CachingOptimized、オリジンリクエストは使わず(S3オリジンなので不要)、レスポンスヘッダーは自前で作っています。手早く済ませるなら、自前ポリシーの代わりに Managed-SecurityHeadersPolicy を指定するだけでもセキュリティヘッダーは付きます。
Q. SPAで /about などのURLに直接アクセスすると403/404になる。どう直すのか?
A. React RouterなどのSPAは /about をJS側で描画するので、S3にはそのキーが存在しません。そのままアクセスするとS3が「キーが無い」と判断し、404、もしくは403を返します。これを custom_error_response で受け取り、index.html(200)を返すようにします。
なお、OACの非公開バケットは存在しないキーに対して404ではなく403を返すことがあります。とくにこのモジュールのように s3:ListBucket を許可していない構成では、無いキーへのアクセスは404ではなく403になります。404だけ拾う設定だと403のときに素通りしてしまうため、403と404の両方を index.html に向けています。
Q. http_version に http2and3 を指定すると何が変わるのか?
A. HTTP/2に加えてHTTP/3(QUIC)まで有効化します。対応ブラウザで接続確立が速くなり、モバイル回線などで効果が出やすくなります。
Q. オリジンのタイムアウト設定が4つあるが、それぞれ何が違う?
A. 役割が次のように分かれています。
| 設定 | 意味 | 範囲/既定 |
|---|---|---|
| Connection attempts | 接続試行回数(リトライ) | 1〜3 / 3 |
| Connection timeout | 1試行あたりの接続確立(TCP)待ち秒数 | 1〜10 / 10 |
| Response timeout | オリジンからの応答(各パケット)を待つ秒数(origin_read_timeout) |
既定30秒(最小1秒。上限はサービスクォータで変わる) |
| Response completion timeout | 応答全体の完了を待つ最大秒数 | 任意(未設定なら上限なし) |
Response timeout(個別パケットを待つ時間、リトライの起点)と Response completion timeout(応答全体の上限)は別物で、後者を設定する場合は前者以上の値にする必要があります。上限を引き上げたいときはサービスクォータの引き上げ申請が必要ですが、静的配信であればどちらも既定のままで十分です。
Q. デプロイ後にキャッシュを消す(Invalidation)と料金はかかる?
A. /* のInvalidationで即時反映できますが、毎月1,000パスまで無料です。/* は1パス換算なので、通常の更新頻度なら無料枠に収まります。
おまけ①:アクセスログをCloudWatch Logsに出す
アクセスログ(標準ログv2)をCloudWatch Logsに出す設定です。本編から分けたのはvended logs の取り込み・保管料がかかるためです(保持日数で抑えられます)。ログv2は us-east-1 で作りますが、virginia(us-east-1)プロバイダは本編で配線済みなので、ここではログ用の変数とリソースの設定が必要です
variables.tf にログ用の変数を追加:
variable "enable_cloudwatch_logging" {
default = true
}
variable "cloudwatch_log_retention_in_days" {
default = 90
}
variable "account_id" {}
logging.tf を追加。配信設定(source/destination/delivery)はすべて aws.virginia(us-east-1)で作ります:
# logging.tf
locals {
enable_logging = var.enable_cloudwatch_logging
}
resource "aws_cloudwatch_log_group" "cloudfront" {
provider = aws.virginia
count = local.enable_logging ? 1 : 0
name = "/aws/cloudfront/${var.name}-${var.env_value_environment}"
retention_in_days = var.cloudwatch_log_retention_in_days
}
# delivery.logs.amazonaws.com にロググループへの書き込みを許可
data "aws_iam_policy_document" "log_delivery" {
count = local.enable_logging ? 1 : 0
statement {
effect = "Allow"
principals {
type = "Service"
identifiers = ["delivery.logs.amazonaws.com"]
}
actions = ["logs:CreateLogStream", "logs:PutLogEvents"]
resources = ["${aws_cloudwatch_log_group.cloudfront[0].arn}:log-stream:*"]
condition {
test = "StringEquals"
variable = "aws:SourceAccount"
values = [var.account_id]
}
condition {
test = "ArnLike"
variable = "aws:SourceArn"
values = ["arn:aws:logs:us-east-1:${var.account_id}:delivery-source:*"]
}
}
}
resource "aws_cloudwatch_log_resource_policy" "log_delivery" {
provider = aws.virginia
count = local.enable_logging ? 1 : 0
policy_name = "${var.name}-cloudfront-log-delivery-${var.env_value_environment}"
policy_document = data.aws_iam_policy_document.log_delivery[0].json
}
resource "aws_cloudwatch_log_delivery_source" "cloudfront" {
provider = aws.virginia
count = local.enable_logging ? 1 : 0
name = "${var.name}-cloudfront-${var.env_value_environment}"
log_type = "ACCESS_LOGS"
resource_arn = aws_cloudfront_distribution.this.arn
}
resource "aws_cloudwatch_log_delivery_destination" "cloudwatch" {
provider = aws.virginia
count = local.enable_logging ? 1 : 0
name = "${var.name}-cloudfront-cwl-${var.env_value_environment}"
output_format = "json" # CloudWatch宛てはjson(parquetはS3専用)
delivery_destination_configuration {
destination_resource_arn = aws_cloudwatch_log_group.cloudfront[0].arn
}
}
resource "aws_cloudwatch_log_delivery" "cloudwatch" {
provider = aws.virginia
count = local.enable_logging ? 1 : 0
delivery_source_name = aws_cloudwatch_log_delivery_source.cloudfront[0].name
delivery_destination_arn = aws_cloudwatch_log_delivery_destination.cloudwatch[0].arn
record_fields = [
"date", "time", "x-edge-location", "x-edge-result-type",
"c-ip", "c-country", "cs-method", "cs-protocol", "cs-uri-stem",
"cs-uri-query", "cs(Host)", "cs(Referer)", "cs(User-Agent)",
"sc-status", "sc-bytes", "sc-content-type", "time-taken",
"time-to-first-byte", "ssl-protocol", "x-forwarded-for",
"cache-behavior-path-pattern",
]
depends_on = [aws_cloudwatch_log_resource_policy.log_delivery]
}
最後に main.tf の呼び出しに、account_id と有効化フラグを足します(aws.virginia プロバイダは本編で渡し済みです):
module "static_site_cloudfront" {
source = "./module/cloudfront-static-site"
# ... 既存の引数(providers = { aws.virginia = aws.virginia } は本編で設定済み)...
account_id = "123456789012" # ← 自分のAWSアカウントID
enable_cloudwatch_logging = true
}
apply すると、CloudWatch Logs(us-east-1)にアクセスログが流れ始めます。
ログが出ないときの確認ポイント
ログが流れてこないときに一番多い原因は、配信設定(delivery source/destination/delivery)を東京リージョンで作ってしまっていることです。CloudFrontはグローバルサービスのため、ログv2の配信設定だけは必ず us-east-1 で作る必要があります(distribution本体は他リージョンでも構いません)。上のコードで aws.virginia を渡しているのはこのためです。他に確認したい点は次のとおりです。
- ロググループに
delivery.logs.amazonaws.comを許可するリソースポリシーが必要 - CloudWatch Logs宛ては
output_format = "json"(parquetはS3専用) - 1ディストリビューションにつき配信元(delivery source)は1つに限られる
- 料金: vended logs の取り込み・保管料がかかる(
retention_in_daysで抑制)
おまけ②:サブディレクトリの index.html を補う(URI書き換え)
OAC + S3(RESTエンドポイント)では、default_root_object が効くのはルート / に限られます。/foo/ のようなサブディレクトリにアクセスしても /foo/index.html は自動では返らず、403になります。SPA(spa_mode = true)なら custom_error_response で拾えますが、通常の複数ページサイトでサブディレクトリのindexを返したい場合は、viewer-request のCloudFront FunctionでURIを書き換えます。
# module/cloudfront-static-site/function_rewrite_index.tf
resource "aws_cloudfront_function" "rewrite_index" {
name = "${var.name}-rewrite-index-${var.env_value_environment}"
runtime = "cloudfront-js-2.0"
code = <<EOF
function handler(event) {
var request = event.request;
var uri = request.uri;
if (uri.endsWith('/')) {
// 例: /foo/ -> /foo/index.html
request.uri += 'index.html';
} else if (!uri.includes('.')) {
// 例: /foo -> /foo/index.html(拡張子が無いパス)
request.uri += '/index.html';
}
return request;
}
EOF
}
distribution.tf の default_cache_behavior で viewer-request に関連付けます。この書き換えは常に効かせたいので、常時アタッチします。
function_association {
event_type = "viewer-request"
function_arn = aws_cloudfront_function.rewrite_index.arn
}
注意:viewer-request に関連付けられる関数は、1ビヘイビアにつき1つに限られます。このあとのおまけ③でIP制限関数も viewer-request に付けたい場合、両方を別々の関数として付けることはできません。その場合は1つの関数にまとめ、先にIP判定(不許可なら403)、許可ならURI書き換え、という順で書きます。
function handler(event) {
var request = event.request;
var clientIP = event.viewer.ip;
// ① IP制限(おまけ③のロジックをここに)
// if (!isPermittedIp(clientIP, IP_WHITE_LIST)) { return { statusCode: 403, ... }; }
// ② URI書き換え
if (request.uri.endsWith('/')) {
request.uri += 'index.html';
} else if (!request.uri.includes('.')) {
request.uri += '/index.html';
}
return request;
}
おまけ③:IP制限を足す(CloudFront Functions)
検証環境を特定のIPアドレスからだけアクセスできるようにしたい、という場合の方法です。ここではCloudFront Functionsを使います。月200万呼び出しまで無料枠があり、簡単なIP判定であればLambda@Edgeを使わずに実装できます。
方針は、関数は常に作っておき、ip_restriction_enabled のON/OFFで viewer-request へのアタッチだけを切り替える。OFFにするとアタッチだけが外れて素通りになります(関数自体は残るので、切り替えが軽い)。
まず関数ファイルを追加します。許可リストにいないIPは403で弾くだけの内容です(有効/無効はアタッチの有無で決まるので、JS内に判定フラグは持ちません)。
# module/cloudfront-static-site/function_ip_restriction.tf
# 関数は常に作成。アタッチするかどうかは distribution.tf 側で切り替える。
# 許可リストはデプロイ時に埋め込む。※このロジックはIPv4専用。
resource "aws_cloudfront_function" "ip_restriction" {
name = "${var.name}-ip-restrictions-${var.env_value_environment}"
runtime = "cloudfront-js-2.0"
code = <<EOF
function handler(event) {
var request = event.request;
var clientIP = event.viewer.ip;
var IP_WHITE_LIST = ${jsonencode(var.ip_white_list)};
function ipToInt(ip) {
var parts = ip.split('.');
return ((parseInt(parts[0], 10) << 24) |
(parseInt(parts[1], 10) << 16) |
(parseInt(parts[2], 10) << 8) |
parseInt(parts[3], 10)) >>> 0;
}
function isIpInCidr(ip, cidr) {
if (cidr.indexOf('/') === -1) { return ip === cidr; }
var parts = cidr.split('/');
var networkIp = parts[0];
var prefixLength = parseInt(parts[1], 10);
if (prefixLength === 32) { return ip === networkIp; }
var ipInt = ipToInt(ip);
var networkInt = ipToInt(networkIp);
var mask = (0xFFFFFFFF << (32 - prefixLength)) >>> 0;
return (ipInt & mask) === (networkInt & mask);
}
function isPermittedIp(ip, whitelist) {
for (var i = 0; i < whitelist.length; i++) {
if (isIpInCidr(ip, whitelist[i])) { return true; }
}
return false;
}
if (!isPermittedIp(clientIP, IP_WHITE_LIST)) {
return { statusCode: 403, statusDescription: 'Forbidden' };
}
return request;
}
EOF
}
変数を追加(variables.tf):
variable "ip_restriction_enabled" {
default = false
}
variable "ip_white_list" {
type = list(string)
default = []
}
distribution.tf の default_cache_behavior に、有効時だけアタッチする関連付けを追加:
dynamic "function_association" {
for_each = var.ip_restriction_enabled ? [1] : []
content {
event_type = "viewer-request"
function_arn = aws_cloudfront_function.ip_restriction.arn
}
}
最後に main.tf で有効化:
ip_restriction_enabled = true
ip_white_list = ["203.0.113.10/32"]
ip_restriction_enabled = false に戻すと、素通り(誰でもアクセス可)に戻ります。
注意点として、このディストリビューションは is_ipv6_enabled = true なので、IPv6で来たアクセスはこの関数(IPv4専用ロジック)をすり抜けます。検証環境のゆるいガードなら許容範囲ですが、本気で塞ぐなら IPv6 も判定するか、is_ipv6_enabled = false にするか、WAFのIPセットを使ってください。
イベントタイプ(viewer / origin)の話
function_association の event_type は、関数がライフサイクルのどこで動くかを決めます。
ビューワー ──(1)──▶ CloudFront ──(2)──▶ オリジン
│ (キャッシュ) │
ビューワー ◀──(4)──── CloudFront ◀──(3)──── オリジン
(1) viewer-request : 受信直後・キャッシュ参照前(毎回)
(2) origin-request : キャッシュミス時、オリジン転送の直前
(3) origin-response : キャッシュミス時、オリジン応答受信後・キャッシュ前
(4) viewer-response : 返却直前(ヒット/ミス問わず毎回)
| イベントタイプ | タイミング | CloudFront Functions | Lambda@Edge |
|---|---|---|---|
viewer-request |
キャッシュ参照前・全リクエスト | ✓ | ✓ |
origin-request |
キャッシュミス時・転送前 | ✗ | ✓ |
origin-response |
キャッシュミス時・格納前 | ✗ | ✓ |
viewer-response |
返却直前・全レスポンス | ✓ | ✓ |
-
CloudFront Functionsが扱えるのは
viewer-requestとviewer-responseだけ。origin-*はLambda@Edge(有料)が必要。 -
IP制限は
viewer-requestに付けています。キャッシュ参照より前に走るので、弾くべきリクエストをオリジンにもキャッシュにも触れさせずに止められます。
おまけ④:独自ドメイン+証明書(2段階必要)
デフォルトの xxxx.cloudfront.net から独自ドメインに変えるには、2段階の作業が必要です。ここを混同すると「証明書が ISSUED にならない」「CloudFrontにaliasを足したらエラーになる」といった状態になりがちです。
段階1:ACM証明書を us-east-1 で発行し、DNS検証を通す
CloudFront用の証明書は必ず us-east-1。aws.virginia プロバイダで作り、Route53でCNAME検証レコードを立て、aws_acm_certificate_validation で ISSUED になるまで待ちます。
resource "aws_acm_certificate" "this" {
provider = aws.virginia # CloudFront用は必ず us-east-1
domain_name = "www.example.com"
validation_method = "DNS"
}
# 検証用CNAMEをRoute53に作成
resource "aws_route53_record" "cert_validation" {
for_each = {
for dvo in aws_acm_certificate.this.domain_validation_options : dvo.domain_name => {
name = dvo.resource_record_name
type = dvo.resource_record_type
record = dvo.resource_record_value
}
}
zone_id = var.hosted_zone_id
name = each.value.name
type = each.value.type
records = [each.value.record]
ttl = 60
}
# 検証完了(ISSUED)を待つ
resource "aws_acm_certificate_validation" "this" {
provider = aws.virginia
certificate_arn = aws_acm_certificate.this.arn
validation_record_fqdns = [for r in aws_route53_record.cert_validation : r.fqdn]
}
段階2:CloudFrontに証明書とドメインを紐付け、DNSを向ける
証明書が通ったら、モジュールに acm_certificate_arn と aliases を渡します(distribution.tf の viewer_certificate は出し分け済み)。さらにRoute53でドメインをCloudFrontへ向けるAレコード(alias)を作ります。
module "static_site_cloudfront" {
source = "./module/cloudfront-static-site"
providers = { aws.virginia = aws.virginia }
# ... 既存の引数 ...
acm_certificate_arn = aws_acm_certificate_validation.this.certificate_arn
aliases = ["www.example.com"]
}
# ドメインをCloudFrontへ
resource "aws_route53_record" "alias" {
zone_id = var.hosted_zone_id
name = "www.example.com"
type = "A"
alias {
name = module.static_site_cloudfront.distribution_domain_name
zone_id = "Z2FDTNDATAQYW2" # CloudFront固定のHosted Zone ID
evaluate_target_health = false
}
}
なお、デフォルト証明書の間は minimum_protocol_version が TLSv1 固定で引き上げられませんが、独自ドメイン+ACMにすると TLSv1.2_2021 を効かせられます(モジュール側で設定済み)。
おまけ⑤:Terraformを使わずAWS CLIで作る
「Terraformを入れずに、まず手元で1回作って試したい」というときのために、ステップ1(最小構成)と同じものをAWS CLIで作るスクリプトを載せておきます。やっていることはTerraformと同じで、S3バケット作成 → OAC作成 → ディストリビューション作成 → バケットポリシー付与、の順です。
なお、繰り返し作る・チームで共有する・後から変更を追いたい、という用途ではTerraformの方が向いています(差分が見え、消すのも destroy 一発)。CLIは「一度きりの確認用」と考えてください。
以下のスクリプトを create-cloudfront-s3.sh として保存し、BUCKET と REGION を書き換えてから実行します。ACCOUNT_ID は aws sts get-caller-identity で自動取得するので手入力不要です。AWS名前付きプロファイルを使う場合は AWS="aws --profile your-profile" に変えてください。
注意:一度きりの確認用です。再実行すると create-bucket がエラーで止まります。作り直す場合はCloudFront無効化→削除→OAC削除→バケットを空にして削除してから実行してください。
#!/usr/bin/env bash
# おまけ⑤:ステップ1(最小構成)をAWS CLIで作るスクリプト
set -euo pipefail
# --- この環境の値を書き換える -----------------------------------------------
BUCKET=project-x-develop
REGION=ap-northeast-1
# AWS名前付きプロファイルを使う場合: AWS="aws --profile your-profile"
AWS="aws"
# ----------------------------------------------------------------------------
ACCOUNT_ID=$($AWS sts get-caller-identity --query Account --output text)
# 作業用ファイルをテンポラリディレクトリに置く(実行後に自動削除)
WORK_DIR=$(mktemp -d)
trap 'rm -rf "$WORK_DIR"' EXIT
# 1) S3バケットを作成(東京リージョン)
$AWS s3api create-bucket \
--bucket "$BUCKET" \
--region "$REGION" \
--create-bucket-configuration LocationConstraint="$REGION"
# 2) パブリックアクセスを4つとも全ブロック
$AWS s3api put-public-access-block \
--bucket "$BUCKET" \
--public-access-block-configuration \
BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true
# 3) ACLを無効化(所有者強制)
$AWS s3api put-bucket-ownership-controls \
--bucket "$BUCKET" \
--ownership-controls 'Rules=[{ObjectOwnership=BucketOwnerEnforced}]'
# 4) デフォルト暗号化(AES256)
$AWS s3api put-bucket-encryption \
--bucket "$BUCKET" \
--server-side-encryption-configuration \
'{"Rules":[{"ApplyServerSideEncryptionByDefault":{"SSEAlgorithm":"AES256"}}]}'
# 5) OACを作成し、IDを取得
OAC_ID=$($AWS cloudfront create-origin-access-control \
--origin-access-control-config \
"Name=${BUCKET}-oac,Description=OAC for ${BUCKET},SigningProtocol=sigv4,SigningBehavior=always,OriginAccessControlOriginType=s3" \
--query 'OriginAccessControl.Id' --output text)
echo "OAC_ID=$OAC_ID"
# 6) ディストリビューション設定を書き出す
# CachePolicyId は AWS管理の Managed-CachingOptimized の固定ID
ORIGIN_DOMAIN="${BUCKET}.s3.${REGION}.amazonaws.com"
cat > "${WORK_DIR}/dist-config.json" <<JSON
{
"CallerReference": "${BUCKET}-1",
"Comment": "${BUCKET}",
"Enabled": true,
"DefaultRootObject": "index.html",
"PriceClass": "PriceClass_200",
"HttpVersion": "http2and3",
"IsIPV6Enabled": true,
"Origins": {
"Quantity": 1,
"Items": [
{
"Id": "s3-${BUCKET}",
"DomainName": "${ORIGIN_DOMAIN}",
"OriginAccessControlId": "${OAC_ID}",
"S3OriginConfig": { "OriginAccessIdentity": "" }
}
]
},
"DefaultCacheBehavior": {
"TargetOriginId": "s3-${BUCKET}",
"ViewerProtocolPolicy": "redirect-to-https",
"Compress": true,
"CachePolicyId": "658327ea-f89d-4fab-a63d-7e88639e58f6",
"AllowedMethods": {
"Quantity": 2,
"Items": ["GET", "HEAD"],
"CachedMethods": { "Quantity": 2, "Items": ["GET", "HEAD"] }
}
},
"CustomErrorResponses": {
"Quantity": 2,
"Items": [
{ "ErrorCode": 403, "ResponseCode": "200", "ResponsePagePath": "/index.html", "ErrorCachingMinTTL": 0 },
{ "ErrorCode": 404, "ResponseCode": "200", "ResponsePagePath": "/index.html", "ErrorCachingMinTTL": 0 }
]
},
"ViewerCertificate": { "CloudFrontDefaultCertificate": true }
}
JSON
# 7) ディストリビューションを作成し、IDとドメイン名を自動取得
CREATE_OUT=$($AWS cloudfront create-distribution \
--distribution-config "file://${WORK_DIR}/dist-config.json" \
--query '[Distribution.Id, Distribution.DomainName]' --output text)
DIST_ID=$(echo "$CREATE_OUT" | awk '{print $1}')
DIST_DOMAIN=$(echo "$CREATE_OUT" | awk '{print $2}')
echo "DIST_ID=$DIST_ID"
echo "DIST_DOMAIN=$DIST_DOMAIN"
# 8) OAC経由のCloudFrontにだけ s3:GetObject を許可(SourceArnで自分のdistに限定)
cat > "${WORK_DIR}/bucket-policy.json" <<JSON
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowCloudFrontServicePrincipalReadOnly",
"Effect": "Allow",
"Principal": { "Service": "cloudfront.amazonaws.com" },
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::${BUCKET}/*",
"Condition": {
"StringEquals": {
"AWS:SourceArn": "arn:aws:cloudfront::${ACCOUNT_ID}:distribution/${DIST_ID}"
}
}
}
]
}
JSON
$AWS s3api put-bucket-policy --bucket "$BUCKET" --policy "file://${WORK_DIR}/bucket-policy.json"
# 9) コンテンツを置く
echo '<h1>Hello CloudFront</h1>' > "${WORK_DIR}/index.html"
$AWS s3 cp "${WORK_DIR}/index.html" "s3://${BUCKET}/index.html" --content-type "text/html; charset=utf-8"
# 10) Deployed になるまで待機してから確認(数分〜十数分かかる)
echo "Deployed になるまで待機中..."
$AWS cloudfront wait distribution-deployed --id "$DIST_ID"
echo "=== 配信確認 ==="
curl -sI "https://${DIST_DOMAIN}" | head -n 6
echo "完了。ブラウザで https://${DIST_DOMAIN} を開いてください。"
スクリプトを実行すると、S3バケット作成からCloudFrontのDeployed待ちまで自動で進み、最後にHTTPレスポンスヘッダーが表示されます。手順7でDIST_IDを手でコピーする必要がないため、IDミスによる403を防げます。dist-config.json / bucket-policy.json などの中間ファイルはテンポラリディレクトリに書き出され、終了時に自動削除されます。
後始末について。 Terraformは terraform destroy 一発ですが、CLIで作った場合は削除の手順が多くなります。ディストリビューションを無効化(Enabled: false で update-distribution)→ 削除(delete-distribution)→ OAC削除(delete-origin-access-control)→ バケット内オブジェクトを削除してからバケット削除(rb --force)の順で行います。「試したら消す」前提で使うなら、削除コストを念頭に置いてください。
セキュリティヘッダー(ステップ2)やアクセスログ(おまけ①)もCLIで追加できますが、create-response-headers-policy や標準ログv2の cloudwatch-logs 系コマンドが増え、ディストリビューションの更新(get-distribution-config でETagを取り、update-distribution で適用)も挟むため手数が一気に増えます。ここまで来たらTerraformに寄せた方が楽です。
リリース前チェックリスト
最後に、リリース前に確認することをまとめます。
-
S3へのアクセスはOAC経由だけになっているか(OAIは使わない)。バケットポリシーは
SourceArnで自分のディストリビューションに限定しているか(複数のディストリビューションで共有する場合は、その全ARNをSourceArnに含める) -
S3のパブリックアクセスブロックは4つすべて有効か。ACLは無効(
BucketOwnerEnforced)になっているか -
HTTPでのアクセスがHTTPSに転送されるか(
viewer_protocol_policy = "redirect-to-https"。allow-allは使わない) -
キャッシュは新方式のCache policyで書いているか(旧方式の
forwarded_valuesを新規で使っていないか) -
SPAの場合、403と404の両方を
index.htmlに流す設定が入っているか -
compressを配信物に合わせて設定したか(テキストはtrue、画像・動画はfalse) -
(任意)
price_classを配信対象に合わせたか。これは正誤ではなくコスト/カバレッジの選択で、未指定なら全エッジを使うPriceClass_All(デフォルト)でも動く。PriceClass_200はPriceClass_Allから南米・オーストラリア/ニュージーランドのエッジを除いたもので、日本中心なら200で十分 - オリジンのタイムアウト値を把握しているか(静的配信なら既定のままでよい)
- アクセスログ(おまけ①)を使う場合、標準ログv2を us-east-1 で作成し、ロググループにリソースポリシーを付けているか
-
サブディレクトリの
index.htmlを返したい複数ページサイトの場合、URI書き換え関数をviewer-requestに常時アタッチしているか -
IP制限を使う場合、
ip_restriction_enabled = trueで関数がviewer-requestにアタッチされ、本番で意図どおりに弾けるか確認したか -
URI書き換えとIP制限を併用する場合、
viewer-requestは1ビヘイビアにつき1関数に限られるので、両方を1つの関数にまとめたか - 独自ドメインを使う場合、ACMを us-east-1 で発行・DNS検証してからCloudFrontに紐付けたか
CloudFrontは設定項目が多く、コンソールだと「とりあえずデフォルト」で流しがちです。一度モジュールに落としておけば、このチェックリストがそのままレビュー観点として使えます。
参考(公式ドキュメント)
- Restrict access to an Amazon S3 origin(OAC) … OACでS3を非公開にしたままCloudFrontから読ませる
-
Use managed cache policies …
Managed-CachingOptimizedなどのマネージドキャッシュポリシー - Use managed origin request policies … マネージドオリジンリクエストポリシー
- Use managed response headers policies … セキュリティヘッダーなどのマネージドレスポンスヘッダーポリシー
- Origin settings(Connection / Response timeout など) … オリジンのタイムアウト各種
- Configure standard logging (v2) … 標準ログv2(CloudWatch Logs / S3 / Firehose)
- Customize at the edge with CloudFront Functions … URI書き換えやIP制限に使うCloudFront Functions
- Requirements for using SSL/TLS certificates with CloudFront … 独自ドメイン+ACM(us-east-1)の要件
- Terraform: aws_cloudfront_distribution … ディストリビューションのリソース定義
- Terraform: aws_cloudfront_function … CloudFront Functionsのリソース定義
