云端部署

用 Terraform 在 AWS 上搭一个静态官网

用 Terraform 在 AWS 上搭一个静态官网:S3 存储桶完全私有,CloudFront 通过 OAC(Origin Access Control)读取并强制 HTTPS,自定义 404 页,一条脚本上传文件并刷新缓存。

BINBHO · 图文教程

用 Terraform 在 AWS 上搭一个静态官网:S3 存储桶完全私有,CloudFront 通过 OAC(Origin Access Control)读取并强制 HTTPS,自定义 404 页,一条脚本上传文件并刷新缓存。

先看清成品

Terraform 1.16.6 + AWS provider 6.69.1 在本机执行了 fmt -check、init、validate,并用 terraform test 的 mock provider 跑了 2 个测试(私有桶 + HTTPS + 首页;非法桶名被拒绝),全部通过;deploy.sh 通过 bash -n 语法检查。没有用真实 AWS 账号执行 apply 和上传,CloudFront 实际生效时间、访问效果以你的账号为准。

注意:AWS 按用量计费,价格以 AWS 官方价格页和账单为准,本文不列具体金额;做完实验记得删除资源。不要共享 root 账号或访问密钥,不要把密钥提交到仓库。中国大陆的 AWS 中国区(amazonaws.cn)是独立运营的分区,账号不通用,部分服务和 ARN 前缀(arn:aws-cn)不同,本文按全球区域编写。

做完之后你会得到什么?

成果 说明
私有 S3 存储桶 四项公开访问阻止全开
CloudFront 分发 HTTPS 访问,https://xxxx.cloudfront.net
桶策略 只允许这一个分发读取
部署脚本 aws s3 sync + 缓存失效
测试 不连 AWS 也能检查关键安全设置

需要准备哪些环境?

项目 要求 说明
AWS 账号 用 IAM Identity Center 或 IAM 用户,不用 root 新账号的免费额度以 AWS Free Tier 页面为准
Terraform 1.7 以上(mock 测试需要) 本文用 1.16.6
AWS CLI v2 上传和刷新缓存

文件结构

20-aws-A-src/
├── versions.tf
├── variables.tf
├── main.tf
├── outputs.tf
├── deploy.sh
├── site/{index.html,404.html}
└── tests/site.tftest.hcl

第 1 步:provider 和变量

versions.tf:

terraform {
  required_version = ">= 1.7"
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
  }
}

provider "aws" {
  region = var.region
}

variables.tf:

variable "region" {
  type    = string
  default = "ap-northeast-1"
}

variable "bucket_name" {
  type        = string
  description = "全局唯一的存储桶名,例如 binbho-site-20261021"
  validation {
    condition     = can(regex("^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$", var.bucket_name))
    error_message = "存储桶名只能用小写字母、数字、点和连字符,长度 3-63。"
  }
}

variable "price_class" {
  type    = string
  default = "PriceClass_100"
}

PriceClass_100 只用北美和欧洲的边缘节点,费用较低;面向亚洲访客可以改成 PriceClass_200 或 PriceClass_All,价格差异看 CloudFront 价格页。

第 2 步:主配置

main.tf:

resource "aws_s3_bucket" "site" {
  bucket = var.bucket_name
}

# 存储桶完全私有,只允许 CloudFront 通过 OAC 读取
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
}

resource "aws_cloudfront_origin_access_control" "site" {
  name                              = "${var.bucket_name}-oac"
  origin_access_control_origin_type = "s3"
  signing_behavior                  = "always"
  signing_protocol                  = "sigv4"
}

# AWS 托管缓存策略 CachingOptimized
data "aws_cloudfront_cache_policy" "optimized" {
  name = "Managed-CachingOptimized"
}

resource "aws_cloudfront_distribution" "site" {
  enabled             = true
  default_root_object = "index.html"
  price_class         = var.price_class
  comment             = "static site ${var.bucket_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
  }

  custom_error_response {
    error_code         = 403
    response_code      = 404
    response_page_path = "/404.html"
  }

  restrictions {
    geo_restriction {
      restriction_type = "none"
    }
  }

  viewer_certificate {
    cloudfront_default_certificate = true
  }
}

data "aws_iam_policy_document" "site" {
  statement {
    sid       = "AllowCloudFrontRead"
    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
  depends_on = [aws_s3_bucket_public_access_block.site]
}

要点:

  • 存储桶不开“静态网站托管”,也不公开;CloudFront 用 OAC 签名请求读取。
  • 桶策略的 AWS:SourceArn 条件把读取权限限定到这一个分发。
  • S3 对不存在的对象在没有 ListBucket 权限时返回 403,所以把 403 映射成 404 页面。

outputs.tf:

output "bucket" {
  value = aws_s3_bucket.site.bucket
}

output "distribution_id" {
  value = aws_cloudfront_distribution.site.id
}

output "site_url" {
  value = "https://${aws_cloudfront_distribution.site.domain_name}"
}

第 3 步:离线测试

tests/site.tftest.hcl:

mock_provider "aws" {
  mock_data "aws_cloudfront_cache_policy" {
    defaults = { id = "658327ea-f89d-4fab-a63d-7e88639e58f6" }
  }
  mock_data "aws_iam_policy_document" {
    defaults = { json = "{\"Version\":\"2012-10-17\",\"Statement\":[]}" }
  }
}

variables {
  bucket_name = "binbho-site-test"
}

run "private_bucket_and_https" {
  command = plan
  assert {
    condition     = aws_s3_bucket_public_access_block.site.block_public_policy && aws_s3_bucket_public_access_block.site.restrict_public_buckets
    error_message = "存储桶必须阻止公开访问"
  }
  assert {
    condition     = aws_cloudfront_distribution.site.default_cache_behavior[0].viewer_protocol_policy == "redirect-to-https"
    error_message = "必须强制 HTTPS"
  }
  assert {
    condition     = aws_cloudfront_distribution.site.default_root_object == "index.html"
    error_message = "首页应为 index.html"
  }
}

run "bad_bucket_name" {
  command = plan
  variables {
    bucket_name = "Bad_Name"
  }
  expect_failures = [var.bucket_name]
}
terraform init
terraform validate
terraform test

检查结果(本机实际输出):

Success! The configuration is valid.
  run "private_bucket_and_https"... pass
  run "bad_bucket_name"... pass
Success! 2 passed, 0 failed.

mock provider 不调用 AWS,数据源的返回值是测试里写的假值,只用于检查配置逻辑。

第 4 步:创建资源(本文未执行)

aws sts get-caller-identity          # 确认当前账号和身份
terraform plan -var bucket_name=你的桶名
terraform apply -var bucket_name=你的桶名

CloudFront 分发创建后需要一段时间部署完成,状态变为 Deployed 后再访问。

第 5 步:上传网站

deploy.sh:

#!/usr/bin/env bash
# 上传站点并刷新 CloudFront 缓存。需要已配置好 AWS 凭证(aws configure 或 SSO)。
set -euo pipefail
BUCKET=$(terraform output -raw bucket)
DIST=$(terraform output -raw distribution_id)
aws s3 sync site/ "s3://$BUCKET/" --delete
aws cloudfront create-invalidation --distribution-id "$DIST" --paths "/*" --query 'Invalidation.Id' --output text
./deploy.sh
curl -I "$(terraform output -raw site_url)"
curl -I "$(terraform output -raw site_url)/not-exist"

预期结果:首页 HTTP/2 200;不存在的路径返回 404 并显示自定义页面;直接访问 S3 对象地址返回 403。

调试示例

把桶名写成 Bad_Name 时,terraform plan 会在变量校验阶段停止并显示:

存储桶名只能用小写字母、数字、点和连字符,长度 3-63。

测试 bad_bucket_name 用 expect_failures 确认了这条校验确实生效。

出错了怎么办?

现象 原因 解决
BucketAlreadyExists 桶名全球唯一,已被占用 换一个带日期或前缀的名字
访问 CloudFront 返回 403 AccessDenied 桶策略没生效或 SourceArn 不对 检查 aws_s3_bucket_policy 是否已创建
更新了文件但看到旧页面 CloudFront 缓存 运行 deploy.sh 里的失效命令
所有子页面都 404 链接写成了 /about/ 但没有 about/index.html OAC 方式下目录不会自动补 index.html,链接写完整文件名
terraform test 报 mock 不支持 Terraform 低于 1.7 升级

清理

aws s3 rm "s3://$(terraform output -raw bucket)" --recursive
terraform destroy -var bucket_name=你的桶名

模板规则

  • 存储桶永远私有,只通过 CloudFront 访问。
  • 改配置先 terraform test,再 plan、apply。
  • 实验完成当天 destroy。

项目资源

用途 地址
CloudFront 限制访问 S3(OAC) https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/private-content-restricting-access-to-s3.html
Terraform AWS provider https://registry.terraform.io/providers/hashicorp/aws/latest/docs
Terraform 测试中的 mock https://developer.hashicorp.com/terraform/language/tests/mocking
AWS 价格 https://aws.amazon.com/pricing/
AWS 免费套餐 https://aws.amazon.com/free/
AWS 中国区 https://www.amazonaws.cn/

静态网站在 AWS 上最常见的问题不是搭不起来,而是存储桶被公开。这套配置把“私有桶 + OAC + 限定分发”写进代码并用测试锁住,以后谁改了都能马上发现。