★ 初級

04. S3 とストレージ

S3 はオブジェクトストレージ。中身は「ファイル + メタデータ」の集合体です。Terraform の S3 リソースは 2022 年以降「分離型」に進化しており、書き方が以前と大きく変わっているので注意。

バケット ─ 何が変わったか

かつての aws_s3_bucket は「バージョニング・暗号化・公開設定・ポリシー・ロギング・通知」など、何でもかんでも 1 ブロックの中に書く設計でした。AWS Provider 5.x からは 各設定が独立したリソース に切り出されています。

分離型のメリット

aws_s3_bucket(最小)

# aws_s3_bucket = バケットを作るだけの最小構成
# bucket 名は全世界一意(重複していると作成失敗)
resource "aws_s3_bucket" "data" {
  bucket = "my-app-data-20260510"
}

これだけ。本当にバケットを作るだけ。中身の動作(暗号化、バージョニング、公開可否)は別リソースで設定します。

推奨セット(暗号化+公開遮断+バージョニング)

2026 年現在、S3 を作ったら必ずこの 3 つは付ける のが既定のセキュリティ姿勢です。

# 本番運用の S3 で「最低限これは入れる」3 点セット
# 各設定は別リソースとして分離されている(モダンスタイル)

# バケット本体
resource "aws_s3_bucket" "data" {
  bucket = "my-app-data-20260510"
}

# 1. パブリックアクセス完全遮断(4 つすべて true が推奨)
# 過去のバグ・設定ミスでバケットが公開される事故を物理的に防ぐ
resource "aws_s3_bucket_public_access_block" "data" {
  bucket = aws_s3_bucket.data.id

  block_public_acls       = true   # 新規 public ACL の作成を拒否
  block_public_policy     = true   # public bucket policy の作成を拒否
  ignore_public_acls      = true   # 既存の public ACL を無視
  restrict_public_buckets = true   # public bucket policy を無効化
}

# 2. サーバーサイド暗号化(保存時の自動暗号化)
# AES256 = AWS 管理キーで暗号化(無料、追加設定不要)
resource "aws_s3_bucket_server_side_encryption_configuration" "data" {
  bucket = aws_s3_bucket.data.id

  rule {
    apply_server_side_encryption_by_default {
      sse_algorithm = "AES256"
    }
  }
}

# 3. バージョニング(削除・上書きから保護)
# 旧バージョンは「論理削除」状態で保持されるので、誤削除から復旧可能
resource "aws_s3_bucket_versioning" "data" {
  bucket = aws_s3_bucket.data.id

  versioning_configuration {
    status = "Enabled"
  }
}

2024 年以降、AWS は 新規バケットはデフォルトで暗号化+ public 遮断 ですが、明示的に書いておく のが正攻法。設定が変わっても Terraform の state 上で管理されているので安心。

バケットポリシー

ポリシーは「このバケットに誰がアクセスできるか」のルール。aws_s3_bucket_policy リソースとして書きます。文字列の JSON より、data "aws_iam_policy_document" で組み立てるほうが読みやすい(05 章)。

# バケットポリシーを HCL で組み立て(JSON 直書きより読みやすい)
# 「CloudFront からのみ S3 オブジェクトを読める」設定
data "aws_iam_policy_document" "data" {
  statement {
    sid     = "AllowCloudFrontOAC"
    effect  = "Allow"
    actions = ["s3:GetObject"]

    # principals = 誰に許可するか(Service = AWS サービス自体)
    principals {
      type        = "Service"
      identifiers = ["cloudfront.amazonaws.com"]
    }

    # resources = どのリソース対象か(バケット内の全オブジェクト)
    resources = ["${aws_s3_bucket.data.arn}/*"]

    # condition = 追加条件(「この CloudFront distribution からのリクエストのみ」)
    condition {
      test     = "StringEquals"
      variable = "AWS:SourceArn"
      values   = [aws_cloudfront_distribution.site.arn]
    }
  }
}

# 上で組み立てた JSON をバケットに適用
resource "aws_s3_bucket_policy" "data" {
  bucket = aws_s3_bucket.data.id
  policy = data.aws_iam_policy_document.data.json
}

ライフサイクル(古いオブジェクトの自動削除)

ログを長く貯めると S3 もタダではないので、自動で消すかストレージクラスを下げます。

# ライフサイクルルール: 経過日数に応じてオブジェクトを自動移行・削除
# 「ログを 30 日アクセスしなければ IA、90 日で Glacier、365 日で削除」のような階段運用
resource "aws_s3_bucket_lifecycle_configuration" "logs" {
  bucket = aws_s3_bucket.logs.id

  rule {
    id     = "expire-old-logs"
    status = "Enabled"

    # この rule の対象を絞る(access/ プレフィックスのオブジェクトだけ)
    filter { prefix = "access/" }

    # 30 日後 → STANDARD_IA (Infrequent Access、低頻度アクセス用、安価)
    transition {
      days          = 30
      storage_class = "STANDARD_IA"
    }
    # 90 日後 → GLACIER (取り出しに時間かかるアーカイブ用、最安級)
    transition {
      days          = 90
      storage_class = "GLACIER"
    }
    # 365 日後 → 完全削除
    expiration {
      days = 365
    }

    # バージョニング ON のとき、旧版(noncurrent version)も 30 日で削除
    noncurrent_version_expiration {
      noncurrent_days = 30
    }
  }
}

aws_s3_object でファイルを置く

Terraform から S3 にファイルをアップロードできます。fileset() でディレクトリ全体を一括アップロードするパターンが定番。

# 拡張子 → Content-Type のマッピングを local で定義
locals {
  content_types = {
    "html" = "text/html; charset=utf-8"
    "css"  = "text/css"
    "js"   = "application/javascript"
    "png"  = "image/png"
    "svg"  = "image/svg+xml"
    "json" = "application/json"
    "txt"  = "text/plain"
  }
}

# fileset() で「public/ 配下の全ファイル」を取得 → for_each で各ファイルを 1 オブジェクトに
# このサイト自体も同じパターンで Terraform から S3 に静的サイトをアップロードしている
resource "aws_s3_object" "site_files" {
  for_each = fileset("${path.module}/../public", "**/*")

  bucket = aws_s3_bucket.site.id
  key    = each.value   # S3 でのパス(ファイル名)
  source = "${path.module}/../public/${each.value}"   # ローカルのファイルパス

  # ファイルの MD5 をハッシュとして etag に渡す
  # → 中身が変わると etag が変わり、Terraform が再アップロードを検知
  etag = filemd5("${path.module}/../public/${each.value}")

  # 拡張子から content_type を決定(未登録の拡張子は octet-stream フォールバック)
  # reverse(split(".", "a/b/c.html"))[0] = "html" を取り出すテクニック
  content_type = lookup(
    local.content_types,
    reverse(split(".", each.value))[0],
    "application/octet-stream"
  )
}
このサイトもこの形 hcl-guide.com の HTML / CSS / JS は、まさにこのパターンで Terraform から S3 にデプロイされています。08 章 で全体構成を見ます。

静的ウェブホスティング

S3 単独で公開もできますが、HTTPS が使えないCDN がない ので、独自ドメインの本番運用には CloudFront 経由が標準です。

# S3 バケットを静的 Web サイトとして公開する設定
# index_document: ディレクトリアクセス時に返すファイル名(例: /foo/ → /foo/index.html)
# error_document: 404 等のエラー時に返すファイル
resource "aws_s3_bucket_website_configuration" "site" {
  bucket = aws_s3_bucket.site.id

  index_document { suffix = "index.html" }
  error_document { key    = "404.html" }
}

推奨: バケットはプライベート(public_access_block 全 ON)のまま、CloudFront + OAC 経由で公開。SPA や静的サイトでも HTTPS は必須。