文章背景图

Terraform 创建 AWS 生产架构实战:VPC、EC2、RDS、ALB、S3 与 GitHub Actions CI/CD

2026-09-24
3
-
- 分钟

Terraform 创建 AWS 生产架构实战:VPC、EC2、RDS、ALB、S3 与 GitHub Actions CI/CD

很多 Terraform 示例只演示如何创建一台 EC2,但生产架构真正困难的地方并不是“资源能不能创建”,而是网络如何分层、访问权限如何收紧、单点如何消除、密码如何管理,以及环境能否稳定复现。

本文从零搭建一套适合中小型 Web 系统的 AWS 生产基线,覆盖:

  • VPC 与双可用区;
  • 公网、应用、数据库三类子网;
  • Internet Gateway、NAT Gateway 和独立路由表;
  • Application Load Balancer;
  • Launch Template 与 EC2 Auto Scaling Group;
  • RDS MySQL Multi-AZ;
  • 私有 S3 Bucket 和 S3 Gateway Endpoint;
  • ALB、应用、数据库三层安全组;
  • Systems Manager、IMDSv2、EBS 加密、VPC Flow Logs;
  • 安全的 planapply、验证与清理流程。

完整代码位于:terraform-learning/05-aws-production

本案例会创建 NAT Gateway、EC2、ALB 和 RDS 等计费资源。学习时应先阅读计划,不要在不清楚账号、区域、资源数量和费用的情况下执行 terraform apply

1. 最终架构

Internet
   |
Route 53(后续可接入)
   |
公网 ALB:public-a + public-b
   |  sg-alb 只向 sg-app 的应用端口放行
   +-----------------------------+
   |                             |
私有应用子网 A                私有应用子网 B
EC2 / Auto Scaling            EC2 / Auto Scaling
   |                             |
   +------- sg-app -> sg-db ------+
                 |
        私有数据库子网 A/B
          RDS MySQL Multi-AZ

EC2 -> S3 Gateway Endpoint -> 私有 S3 Bucket
EC2 -> NAT Gateway -> SSM、软件仓库和外部 API

核心原则:

  1. ALB 是唯一公网业务入口。
  2. EC2 不分配公网 IP。
  3. RDS 不允许公网访问,也没有互联网默认路由。
  4. 安全组通过安全组 ID 互相引用,不依赖实例 IP。
  5. EC2 使用 Auto Scaling Group 跨两个可用区运行。
  6. 数据库密码不写进代码或 tfvars,由 RDS 生成并存入 Secrets Manager。

2. 工程目录

05-aws-production/
├─ versions.tf                 # Terraform 与 Provider 版本
├─ variables.tf                # 输入变量与校验
├─ main.tf                     # 数据源、局部变量、统一标签
├─ network.tf                  # VPC、子网、NAT、路由、Flow Logs
├─ security.tf                 # 三层安全组
├─ compute.tf                  # IAM、Launch Template、ALB、ASG
├─ database.tf                 # RDS 与 Enhanced Monitoring
├─ storage.tf                  # S3 与 EC2 最小权限
├─ outputs.tf                  # 访问地址和关键输出
├─ user_data.sh.tftpl          # EC2 初始化脚本
├─ terraform.tfvars.example    # 参数示例
└─ README.md                   # 使用和清理说明

不同职责拆到不同文件只是为了便于阅读。Terraform 会把同一目录内的所有 .tf 文件作为一个根模块共同加载,并不是按照文件名顺序执行。

3. 固定 Terraform 和 Provider 版本

versions.tf

terraform {
  required_version = ">= 1.6.0"

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

provider "aws" {
  region = var.aws_region

  default_tags {
    tags = local.common_tags
  }
}

~> 6.0 允许安装 AWS Provider 6.x 的兼容更新,但不会自动跨到 7.x。首次 init 后生成的 .terraform.lock.hcl 应提交到版本库,确保其他环境使用相同 Provider 选择和校验信息。

统一标签用于成本、资产和责任人治理:

locals {
  name = "${var.project_name}-${var.environment}"

  common_tags = {
    Project     = var.project_name
    Environment = var.environment
    ManagedBy   = "Terraform"
  }
}

真实企业还应补充 OwnerCostCenterDataClassification 等标签。

4. 双可用区与六个子网

案例从当前 Region 自动选择两个可用区:

data "aws_availability_zones" "available" {
  state = "available"
}

locals {
  azs = slice(data.aws_availability_zones.available.names, 0, 2)
}

默认 VPC CIDR 为 10.20.0.0/16,使用 cidrsubnet 计算六个 /24 子网:

可用区子网用途示例 CIDR互联网路径
AZ-A公网入口10.20.0.0/24Internet Gateway
AZ-B公网入口10.20.1.0/24Internet Gateway
AZ-A私有应用10.20.10.0/24NAT Gateway
AZ-B私有应用10.20.11.0/24NAT Gateway
AZ-A私有数据库10.20.20.0/24无互联网默认路由
AZ-B私有数据库10.20.21.0/24无互联网默认路由

创建 VPC 时开启 DNS:

resource "aws_vpc" "main" {
  cidr_block           = var.vpc_cidr
  enable_dns_support   = true
  enable_dns_hostnames = true

  tags = { Name = local.name }
}

应用子网明确关闭公网 IP:

resource "aws_subnet" "app" {
  count = 2

  vpc_id                  = aws_vpc.main.id
  availability_zone       = local.azs[count.index]
  cidr_block              = local.app_subnet_cidrs[count.index]
  map_public_ip_on_launch = false
}

“公网子网”并不是由名称决定的。只有关联的路由表存在 0.0.0.0/0 -> Internet Gateway,并且资源拥有公网地址时,资源才具备直接互联网路径。

5. NAT Gateway 的成本与高可用取舍

案例提供变量:

variable "single_nat_gateway" {
  type    = bool
  default = true
}

资源数量根据变量变化:

resource "aws_nat_gateway" "main" {
  count = var.single_nat_gateway ? 1 : 2

  allocation_id = aws_eip.nat[count.index].id
  subnet_id     = aws_subnet.public[count.index].id
}
  • true:仅创建一个 NAT Gateway,适合控制学习成本,但两个应用子网共享一个可用区的 NAT,存在单可用区出网依赖。
  • false:每个可用区一个 NAT Gateway,应用子网走同可用区 NAT,更符合生产高可用设计,但费用更高。

数据库子网没有 NAT 或 Internet Gateway 默认路由。数据库补丁、备份和底层维护由 RDS 服务负责,不需要用户给数据库实例配置公网出网。

6. S3 Gateway Endpoint

应用访问 S3 时没有必要经过 NAT Gateway:

resource "aws_vpc_endpoint" "s3" {
  vpc_id            = aws_vpc.main.id
  service_name      = "com.amazonaws.${var.aws_region}.s3"
  vpc_endpoint_type = "Gateway"
  route_table_ids   = aws_route_table.app[*].id
}

Gateway Endpoint 会向应用路由表加入 S3 服务前缀列表路由。它可以减少 NAT 数据处理费用和公网路径依赖,但不能取代 IAM Policy、Bucket Policy 和 S3 Block Public Access。

7. 三层安全组

安全组不要写成一个允许所有资源互通的“大网段规则”。本案例将入口、应用和数据库拆开。

7.1 ALB 安全组

ALB 接收公网 80;配置 ACM 证书后同时接收 443:

resource "aws_vpc_security_group_ingress_rule" "alb_http" {
  for_each = toset(var.allowed_http_cidrs)

  security_group_id = aws_security_group.alb.id
  cidr_ipv4         = each.value
  from_port         = 80
  to_port           = 80
  ip_protocol       = "tcp"
}

ALB 出站只访问应用安全组的应用端口:

resource "aws_vpc_security_group_egress_rule" "alb_to_app" {
  security_group_id            = aws_security_group.alb.id
  referenced_security_group_id = aws_security_group.app.id
  from_port                    = var.app_port
  to_port                      = var.app_port
  ip_protocol                  = "tcp"
}

7.2 应用安全组

EC2 只接受来自 ALB 安全组的流量:

resource "aws_vpc_security_group_ingress_rule" "app_from_alb" {
  security_group_id            = aws_security_group.app.id
  referenced_security_group_id = aws_security_group.alb.id
  from_port                    = var.app_port
  to_port                      = var.app_port
  ip_protocol                  = "tcp"
}

没有开放 22 端口。实例使用 Systems Manager Session Manager 管理,避免公网 SSH、长期私钥和堡垒机入口。

7.3 数据库安全组

RDS 的 3306 只允许应用安全组访问:

resource "aws_vpc_security_group_ingress_rule" "database_from_app" {
  security_group_id            = aws_security_group.database.id
  referenced_security_group_id = aws_security_group.app.id
  from_port                    = 3306
  to_port                      = 3306
  ip_protocol                  = "tcp"
}

这形成清晰的访问链:

Internet -> sg-alb -> sg-app -> sg-database

ALB 不能直接访问数据库,互联网不能直接访问应用和数据库。

8. EC2 Launch Template 的安全基线

AMI 从 AWS 公共 SSM Parameter 获取,避免长期写死过期 AMI ID:

data "aws_ssm_parameter" "al2023_ami" {
  name = "/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64"
}

Launch Template 的关键安全设置:

resource "aws_launch_template" "app" {
  image_id      = data.aws_ssm_parameter.al2023_ami.value
  instance_type = var.instance_type

  metadata_options {
    http_endpoint               = "enabled"
    http_tokens                 = "required"
    http_put_response_hop_limit = 1
  }

  network_interfaces {
    associate_public_ip_address = false
    security_groups             = [aws_security_group.app.id]
  }

  block_device_mappings {
    device_name = "/dev/xvda"

    ebs {
      encrypted             = true
      volume_type           = "gp3"
      volume_size           = 20
      delete_on_termination = true
    }
  }
}

关键点:

  • http_tokens = "required" 强制 IMDSv2;
  • EC2 没有公网 IP;
  • 根卷使用 gp3 并加密;
  • 使用 IAM Instance Profile,不把 Access Key 写入主机;
  • 开启详细监控;
  • User Data 只安装示例 Nginx,不保存数据库密码。

实例角色附加 AmazonSSMManagedInstanceCore,用于 Session Manager。另一个自定义 Policy 只允许访问当前应用桶的 app/* 路径。

9. ALB、目标组和健康检查

ALB 跨两个公网子网创建:

resource "aws_lb" "app" {
  internal                   = false
  load_balancer_type         = "application"
  security_groups            = [aws_security_group.alb.id]
  subnets                    = aws_subnet.public[*].id
  drop_invalid_header_fields = true
}

目标组监听应用端口,并使用独立健康检查:

resource "aws_lb_target_group" "app" {
  port        = var.app_port
  protocol    = "HTTP"
  target_type = "instance"
  vpc_id      = aws_vpc.main.id

  health_check {
    path                = "/health"
    matcher             = "200"
    healthy_threshold   = 2
    unhealthy_threshold = 3
    interval            = 30
    timeout             = 5
  }
}

没有 ACM 证书时,案例保留 HTTP 监听,方便实验。配置 acm_certificate_arn 后:

  1. 80 端口重定向到 443;
  2. 443 使用 ACM 证书;
  3. ALB 使用 TLS 1.3/1.2 安全策略;
  4. HTTPS 流量转发到目标组。

生产环境不应把 HTTP-only 作为最终状态。

10. Auto Scaling Group

resource "aws_autoscaling_group" "app" {
  min_size            = var.asg_min_size
  desired_capacity    = var.asg_desired_capacity
  max_size            = var.asg_max_size
  vpc_zone_identifier = aws_subnet.app[*].id
  target_group_arns   = [aws_lb_target_group.app.arn]
  health_check_type   = "ELB"

  launch_template {
    id      = aws_launch_template.app.id
    version = aws_launch_template.app.latest_version
  }
}

默认最少和期望实例数均为 2,两个实例分布到私有应用子网。ASG 接入目标组,ELB 健康检查失败的实例可以被替换。

案例还包含:

  • Rolling Instance Refresh;
  • 50% CPU Target Tracking;
  • 标签传播到实例;
  • Launch Template 变更后的滚动替换。

生产参数不能只照抄。应根据压测结果、单实例容量和单可用区故障后的承载能力设置最小、期望和最大容量。

11. RDS MySQL

RDS 使用两个数据库子网组成 DB Subnet Group:

resource "aws_db_subnet_group" "main" {
  name       = "${local.name}-db"
  subnet_ids = aws_subnet.database[*].id
}

核心配置:

resource "aws_db_instance" "main" {
  engine         = "mysql"
  instance_class = var.db_instance_class

  db_name  = var.db_name
  username = var.db_username

  manage_master_user_password = true

  allocated_storage     = 20
  max_allocated_storage = 100
  storage_type          = "gp3"
  storage_encrypted     = true

  publicly_accessible    = false
  multi_az               = var.db_multi_az
  db_subnet_group_name   = aws_db_subnet_group.main.name
  vpc_security_group_ids = [aws_security_group.database.id]

  backup_retention_period = var.backup_retention_days
  deletion_protection      = var.db_deletion_protection
  copy_tags_to_snapshot    = true
}

manage_master_user_password = true 表示 RDS 生成主密码并存入 Secrets Manager。这样不用在 terraform.tfvars 中写明文密码,也减少敏感值进入命令历史或代码仓库的风险。

案例还启用了:

  • Multi-AZ;
  • 自动备份;
  • 最终快照;
  • 删除保护;
  • Enhanced Monitoring;
  • Performance Insights;
  • MySQL error、general、slow query 日志导出到 CloudWatch Logs;
  • 自动次版本升级。

Multi-AZ 解决的是高可用和故障切换,不是读流量扩展。读扩展应另外评估只读副本或 Aurora Reader。

12. 私有 S3 Bucket

桶名使用项目名、账号 ID 和 Region 组合,降低全局重名概率:

resource "aws_s3_bucket" "app" {
  bucket = "${substr(local.name, 0, 20)}-${data.aws_caller_identity.current.account_id}-${var.aws_region}"
}

四项公开访问阻止全部打开:

resource "aws_s3_bucket_public_access_block" "app" {
  bucket = aws_s3_bucket.app.id

  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

同时配置:

  • Bucket Owner Enforced,禁用 ACL;
  • SSE-S3 默认加密;
  • Versioning;
  • 7 天清理未完成的分段上传;
  • 90 天清理非当前版本;
  • Bucket Policy 拒绝非 HTTPS 请求;
  • EC2 Role 只能访问 app/* 路径。

需要客户托管密钥、独立审计或跨账号控制时,可把 SSE-S3 替换成 SSE-KMS,并同步设计 KMS Key Policy。

13. VPC Flow Logs

案例把所有 VPC 流量记录到 CloudWatch Logs:

resource "aws_flow_log" "vpc" {
  iam_role_arn    = aws_iam_role.flow_logs.arn
  log_destination = aws_cloudwatch_log_group.flow_logs.arn
  traffic_type    = "ALL"
  vpc_id          = aws_vpc.main.id
}

Flow Logs 可用于:

  • 判断安全组或网络 ACL 是否拒绝流量;
  • 分析异常扫描、横向访问和非预期出网;
  • 核对目标地址、端口和流量方向;
  • 为安全事件提供网络层证据。

它不是应用日志,也看不到 HTTP URL、SQL 内容或数据包正文。

14. 参数文件

复制示例文件:

Copy-Item terraform.tfvars.example terraform.tfvars

生产参数示例:

aws_region   = "ap-southeast-1"
project_name = "myapp"
environment  = "prod"

single_nat_gateway = false

instance_type        = "t3.micro"
asg_min_size         = 2
asg_desired_capacity = 2
asg_max_size         = 4

db_instance_class      = "db.t4g.micro"
db_multi_az            = true
db_deletion_protection = true
db_skip_final_snapshot = false
backup_retention_days  = 7

acm_certificate_arn = "arn:aws:acm:ap-southeast-1:123456789012:certificate/xxxx"

不要把密钥、数据库密码、Token 或私钥写进 terraform.tfvars。如果确实需要敏感输入,应结合 Secrets Manager、CI 密钥系统和远程 State 加密控制。

15. 执行流程

15.1 确认身份

aws sts get-caller-identity
aws configure get region

重点确认:

  • Account ID;
  • IAM Principal;
  • Region;
  • 是否是计划使用的开发、测试或生产账号。

15.2 初始化与检查

terraform init
terraform fmt -check -recursive
terraform validate

15.3 保存计划

terraform plan -out="production.tfplan"
terraform show -no-color production.tfplan

计划审查至少要回答:

  1. 会创建几个 NAT Gateway、EC2 和 RDS?
  2. EC2 是否意外获得公网 IP?
  3. RDS 是否为 publicly_accessible = false
  4. 是否存在 0.0.0.0/0:22 或数据库端口向全网开放?
  5. S3 四项 Block Public Access 是否全部为 true?
  6. 是否有意外的删除或替换?
  7. 当前规格与 Multi-AZ 是否符合预算?

15.4 执行已保存计划

terraform apply production.tfplan

执行已保存计划通常不会再次询问确认,因此必须在前一步完整审查。

16. 部署后验证

16.1 查看输出

terraform output
terraform output application_url
terraform output rds_endpoint
terraform output s3_bucket_name

16.2 验证 ALB

  1. 两个目标应进入 healthy
  2. 访问 application_url 应返回 Nginx 示例页面。
  3. /health 应返回 200ok
  4. 配置 ACM 后,HTTP 应重定向到 HTTPS。

16.3 验证 EC2

  1. 实例分布在两个应用子网。
  2. 实例没有公网 IPv4。
  3. Session Manager 可以建立会话。
  4. Metadata Options 显示 IMDSv2 required。
  5. EBS 根卷已加密。

16.4 验证 RDS

  1. Publicly accessible 为 No。
  2. 部署方式为 Multi-AZ。
  3. RDS 安全组只有来自应用安全组的 3306。
  4. 自动备份、删除保护和加密均已开启。
  5. Secrets Manager 中存在 RDS 托管的主凭证。

16.5 验证 S3

  1. 四项 Block Public Access 全部开启。
  2. Versioning 已启用。
  3. 默认加密已启用。
  4. 非 TLS 请求会被 Bucket Policy 拒绝。
  5. 应用角色只能访问指定桶和 app/* 前缀。

17. 常见故障

ALB 目标一直 unhealthy

依次检查:

  1. User Data 是否执行成功;
  2. Nginx 是否监听 app_port
  3. /health 是否返回 200;
  4. 应用安全组是否允许 ALB 安全组访问;
  5. ALB 安全组是否允许向应用端口出站;
  6. 实例是否有足够时间完成初始化。

EC2 无法通过 SSM 连接

检查:

  1. IAM Instance Profile 是否附加到实例;
  2. AmazonSSMManagedInstanceCore 是否存在;
  3. 应用子网是否能通过 NAT 访问 SSM API;
  4. 安全组是否允许 443 出站;
  5. DNS 是否正常;
  6. 后续是否需要创建 SSM Interface Endpoint,摆脱 NAT 依赖。

应用无法连接 RDS

检查:

  1. 使用的是 RDS Endpoint,而不是固定 IP;
  2. 端口是否为 3306;
  3. RDS 安全组来源是否为应用安全组;
  4. 应用安全组是否允许 3306 出站;
  5. 数据库凭证是否从 Secrets Manager 正确读取;
  6. 数据库用户权限和 TLS 要求是否匹配。

terraform destroy 删除不了 RDS

默认开启了删除保护。清理实验环境前要明确修改:

db_deletion_protection = false
db_skip_final_snapshot = true

先执行一次 plan/apply 关闭删除保护,再查看销毁计划。生产环境不要为了省事跳过最终快照。

18. 生产环境还应补充什么

当前案例是生产基线,不是完整企业平台。继续增强可加入:

  • Route 53 Alias 和 ACM 证书自动验证;
  • AWS WAF 与速率限制;
  • ALB Access Logs 与独立日志桶;
  • SSM、ECR、CloudWatch 等 Interface Endpoint;
  • CloudWatch Alarms、SNS 和值班通知;
  • RDS Proxy、只读副本和跨区域备份;
  • KMS 客户托管密钥;
  • AWS Config、Security Hub、GuardDuty;
  • 多账号结构和 SCP;
  • S3 + DynamoDB 或 Terraform Cloud 远程 State;
  • CI 中的 fmtvalidateplan、Checkov/tfsec 扫描和人工审批。

19. 接入 CI/CD 的目标

基础设施流水线不应简单等于“提交代码后自动执行 terraform apply -auto-approve”。生产流水线需要保证:

  1. Pull Request 阶段只检查和生成计划,不修改基础设施;
  2. 合并到 main 后重新生成一份针对当前生产 State 的计划;
  3. Apply 使用经过保存和审批的同一份 Plan;
  4. 同一生产环境一次只允许一个流水线运行;
  5. AWS 使用 OIDC 临时凭证,不保存长期 Access Key;
  6. Plan Role 与 Apply Role 分开;
  7. State 存放在加密、版本化、支持锁的远程 Backend;
  8. 生产 Apply 通过 GitHub Environment Required Reviewers 审批。

完整案例文件:

05-aws-production/
├─ backend.tf
├─ bootstrap/                    # State Bucket、OIDC 和 IAM Role
└─ github-actions/
   └─ terraform.yml              # GitHub Actions 流水线

20. 远程 State 与原生锁

主工程使用部分配置的 S3 Backend:

terraform {
  backend "s3" {}
}

Bucket、State Key 和 Region 不硬编码,而是在流水线初始化时传入:

terraform init -input=false \
  -backend-config="bucket=$TF_STATE_BUCKET" \
  -backend-config="key=tf-production-demo/prod/terraform.tfstate" \
  -backend-config="region=ap-southeast-1" \
  -backend-config="encrypt=true" \
  -backend-config="use_lockfile=true"

use_lockfile=true 使用 S3 原生锁文件,防止两个流水线同时修改同一 State。旧架构常使用 DynamoDB 锁表,但当前 Terraform 文档已经将 DynamoDB State Lock 标记为弃用,新项目应优先使用 S3 Lockfile。

State Bucket 由 bootstrap/ 单独创建,并启用:

  • Versioning;
  • 默认加密;
  • 四项 Block Public Access;
  • TLS-only Bucket Policy;
  • prevent_destroy
  • State 与 .tflock 的路径级 IAM 权限。

Bootstrap 必须先由受信任管理员运行一次,不能让主工程一边使用 State Bucket、一边创建同一个 State Bucket。

21. GitHub Actions 使用 AWS OIDC

不要在 GitHub Secrets 中长期保存 AWS_ACCESS_KEY_IDAWS_SECRET_ACCESS_KEY。Bootstrap 创建 GitHub OIDC Provider,流水线使用 AssumeRoleWithWebIdentity 获取短期凭证。

Plan Role 的 Trust Policy 只信任指定仓库的 Pull Request 和 main 分支:

condition {
  test     = "StringLike"
  variable = "token.actions.githubusercontent.com:sub"
  values = [
    "repo:OWNER/REPOSITORY:pull_request",
    "repo:OWNER/REPOSITORY:ref:refs/heads/main"
  ]
}

Apply Role 只信任名为 production 的 GitHub Environment:

condition {
  test     = "StringEquals"
  variable = "token.actions.githubusercontent.com:sub"
  values   = ["repo:OWNER/REPOSITORY:environment:production"]
}

这样即使其他分支修改了 Workflow,也不能直接取得生产 Apply Role。GitHub 的 production Environment 还应配置 Required Reviewers 和允许部署的分支。

22. 流水线三个阶段

22.1 Validate

- run: terraform fmt -check -recursive
- run: terraform init -backend=false -input=false
- run: terraform validate

Validate 不需要 AWS 凭证,也不读取生产 State。它负责尽早发现格式、语法和 Provider Schema 问题。

22.2 Plan

- uses: aws-actions/configure-aws-credentials@v5
  with:
    role-to-assume: ${{ vars.TF_PLAN_ROLE_ARN }}
    aws-region: ${{ vars.AWS_REGION }}

- run: terraform plan -input=false -lock-timeout=5m -out=tfplan -var-file=terraform.tfvars

Pull Request 会运行 Plan,但不会上传为可部署制品。合并到 main 后,流水线针对合并后的确切提交重新生成 Plan,并将 tfplan 作为短期 Artifact 保存一天。

Terraform Plan 文件可能包含配置、State 和敏感变量,不能提交 Git,也不能长期公开保存。

22.3 Apply

apply:
  needs: plan
  environment: production

  steps:
    - uses: aws-actions/configure-aws-credentials@v5
      with:
        role-to-assume: ${{ vars.TF_APPLY_ROLE_ARN }}
        aws-region: ${{ vars.AWS_REGION }}

    - run: terraform apply -input=false tfplan

Apply Job 在启动前触发生产 Environment 审批。审批后下载同一次流水线生成的 tfplan 并执行,不重新使用 terraform apply -auto-approve 生成另一份未经审查的计划。

23. CI/CD 配置步骤

第一步:创建 Bootstrap 参数

cd terraform-learning/05-aws-production/bootstrap
Copy-Item terraform.tfvars.example terraform.tfvars

填写 GitHub Owner 和 Repository:

aws_region        = "ap-southeast-1"
project_name      = "tf-production-demo"
github_owner      = "your-github-user-or-org"
github_repository = "your-repository"

第二步:由管理员创建 Backend 和 OIDC

terraform init
terraform fmt -check
terraform validate
terraform plan -out=bootstrap.tfplan
terraform apply bootstrap.tfplan
terraform output github_repository_variables

第三步:配置 GitHub Variables

在 Repository Settings → Secrets and variables → Actions 中添加:

  • AWS_REGION
  • TF_STATE_BUCKET
  • TF_PLAN_ROLE_ARN
  • TF_APPLY_ROLE_ARN

这些 ARN 和 Bucket Name 通常不是秘密,使用 Repository Variables 即可。敏感业务变量仍应使用 GitHub Environment Secrets、Secrets Manager 或其他密钥系统。

第四步:创建生产 Environment

创建 production Environment:

  1. 添加 Required Reviewers;
  2. 仅允许 main 分支部署;
  3. 根据组织策略决定是否允许管理员绕过;
  4. 对生产变量和密钥使用 Environment 级控制。

第五步:安装 Workflow

将:

github-actions/terraform.yml

复制到 Git 仓库根目录:

.github/workflows/terraform.yml

同时提交 .terraform.lock.hcl,不要提交 .terraform/*.tfstate*.tfplan 或含敏感值的 terraform.tfvars

第六步:验证流程

  1. 创建功能分支并修改 Terraform;
  2. 提交 Pull Request;
  3. 检查 Format、Validate 和 Plan;
  4. 由同事审查代码与计划;
  5. 合并到 main
  6. 主分支流水线重新生成 Plan;
  7. 生产审批人核对账号、区域和资源变更;
  8. 批准 Apply;
  9. 检查 Terraform 输出、CloudTrail 和资源健康状态。

24. CI/CD 安全注意事项

  • 不允许来自 Fork 的未受信任代码取得 AWS 写权限。
  • OIDC Trust Policy 必须限制到具体 Owner、Repository、分支或 Environment,不能只写通配符。
  • Plan Role 尽量只读;Apply Role 再授予写权限。
  • 教学案例的 Apply Policy 仍按服务范围授予了较宽权限,生产环境应继续增加资源 ARN、标签条件、Permission Boundary 和 SCP。
  • 生产 State 读权限同样敏感,因为 State 可能包含资源属性和敏感值。
  • 不要把 Plan 文件上传为长期制品或公开到 PR 评论。
  • 使用 Workflow concurrency 防止同一环境并行 Apply,但仍必须启用 Backend Lock。
  • 依赖的 GitHub Actions 应固定主版本,安全要求更高时固定到完整 Commit SHA。
  • Destroy 不应成为普通 Push 自动触发的流程,应使用单独的手工 Workflow、二次审批和明确目标。

25. 总结

这套 Terraform 案例的重点不是资源数量,而是边界:

  • 公网流量只能进入 ALB;
  • ALB 只能访问应用端口;
  • 应用只能访问数据库端口;
  • 数据库没有公网入口;
  • EC2 不依赖公网 SSH;
  • S3 默认私有、加密、版本化并拒绝明文传输;
  • RDS 密码不出现在普通变量文件中;
  • 计算跨可用区、数据库启用 Multi-AZ;
  • 所有变更先经过计划审查。

真正的生产最佳实践,不是简单复制一段 Terraform,而是让网络、权限、可用性、备份、监控、成本和变更流程形成一套可验证的整体。

官方参考资料

26. 完整代码附录

下面给出本案例的完整可复制代码。运行前请先修改 terraform.tfvars、GitHub 仓库名称和 AWS 区域,并先执行 terraform plan 审核资源与费用。生成目录 .terraform/、State、Plan 文件和真实凭证不应提交到仓库。

建议先运行 bootstrap/ 创建远程 State、OIDC Provider 和流水线角色,再配置 GitHub Variables,最后运行主工程。

backend.tf

terraform {
  backend "s3" {}
}

versions.tf

terraform {
  required_version = ">= 1.6.0"

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

provider "aws" {
  region = var.aws_region

  default_tags {
    tags = local.common_tags
  }
}

variables.tf

variable "aws_region" {
  description = "AWS Region used by this example."
  type        = string
  default     = "ap-southeast-1"
}

variable "project_name" {
  description = "Short lowercase project name used in resource names."
  type        = string
  default     = "tf-production-demo"

  validation {
    condition     = can(regex("^[a-z][a-z0-9-]{2,24}$", var.project_name))
    error_message = "project_name must be 3-25 lowercase letters, digits, or hyphens and start with a letter."
  }
}

variable "environment" {
  description = "Deployment environment name."
  type        = string
  default     = "prod"

  validation {
    condition     = can(regex("^[a-z][a-z0-9-]{1,11}$", var.environment))
    error_message = "environment must be 2-12 lowercase letters, digits, or hyphens and start with a letter."
  }
}

variable "vpc_cidr" {
  description = "CIDR of the VPC."
  type        = string
  default     = "10.20.0.0/16"
}

variable "single_nat_gateway" {
  description = "Use one NAT Gateway to reduce lab cost. Set false for one NAT Gateway per AZ in production."
  type        = bool
  default     = true
}

variable "instance_type" {
  description = "EC2 instance type used by the Auto Scaling Group."
  type        = string
  default     = "t3.micro"
}

variable "app_port" {
  description = "Port exposed by the application instances to the ALB."
  type        = number
  default     = 80

  validation {
    condition     = var.app_port >= 1 && var.app_port <= 65535
    error_message = "app_port must be between 1 and 65535."
  }
}

variable "asg_min_size" {
  description = "Minimum number of application instances."
  type        = number
  default     = 2
}

variable "asg_desired_capacity" {
  description = "Desired number of application instances."
  type        = number
  default     = 2
}

variable "asg_max_size" {
  description = "Maximum number of application instances."
  type        = number
  default     = 4
}

variable "acm_certificate_arn" {
  description = "ACM certificate ARN. When null, the example uses HTTP only; production should set this value."
  type        = string
  default     = null
  nullable    = true
}

variable "db_engine_version" {
  description = "Optional MySQL engine version. Null lets RDS choose its current default."
  type        = string
  default     = null
  nullable    = true
}

variable "db_instance_class" {
  description = "RDS instance class."
  type        = string
  default     = "db.t4g.micro"
}

variable "db_name" {
  description = "Initial MySQL database name."
  type        = string
  default     = "appdb"
}

variable "db_username" {
  description = "RDS master username. The password is generated and stored by RDS in Secrets Manager."
  type        = string
  default     = "appadmin"
}

variable "db_multi_az" {
  description = "Deploy RDS synchronously across Availability Zones. Keep true for production."
  type        = bool
  default     = true
}

variable "db_deletion_protection" {
  description = "Protect RDS from deletion. Keep true for production; set false only when intentionally cleaning up a lab."
  type        = bool
  default     = true
}

variable "db_skip_final_snapshot" {
  description = "Skip the final RDS snapshot on destroy. Keep false for production."
  type        = bool
  default     = false
}

variable "backup_retention_days" {
  description = "Number of days to retain automated RDS backups."
  type        = number
  default     = 7

  validation {
    condition     = var.backup_retention_days >= 1 && var.backup_retention_days <= 35
    error_message = "backup_retention_days must be between 1 and 35."
  }
}

variable "allowed_http_cidrs" {
  description = "IPv4 CIDRs allowed to reach the public ALB."
  type        = list(string)
  default     = ["0.0.0.0/0"]
}

main.tf

data "aws_availability_zones" "available" {
  state = "available"
}

data "aws_caller_identity" "current" {}

data "aws_partition" "current" {}

data "aws_ssm_parameter" "al2023_ami" {
  name = "/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64"
}

locals {
  name = "${var.project_name}-${var.environment}"
  azs  = slice(data.aws_availability_zones.available.names, 0, 2)

  public_subnet_cidrs   = [cidrsubnet(var.vpc_cidr, 8, 0), cidrsubnet(var.vpc_cidr, 8, 1)]
  app_subnet_cidrs      = [cidrsubnet(var.vpc_cidr, 8, 10), cidrsubnet(var.vpc_cidr, 8, 11)]
  database_subnet_cidrs = [cidrsubnet(var.vpc_cidr, 8, 20), cidrsubnet(var.vpc_cidr, 8, 21)]

  common_tags = {
    Project     = var.project_name
    Environment = var.environment
    ManagedBy   = "Terraform"
  }
}

network.tf

resource "aws_vpc" "main" {
  cidr_block           = var.vpc_cidr
  enable_dns_support   = true
  enable_dns_hostnames = true

  tags = { Name = local.name }
}

resource "aws_internet_gateway" "main" {
  vpc_id = aws_vpc.main.id
  tags   = { Name = "${local.name}-igw" }
}

resource "aws_subnet" "public" {
  count = 2

  vpc_id                  = aws_vpc.main.id
  availability_zone       = local.azs[count.index]
  cidr_block              = local.public_subnet_cidrs[count.index]
  map_public_ip_on_launch = false

  tags = {
    Name = "${local.name}-public-${count.index + 1}"
    Tier = "public"
  }
}

resource "aws_subnet" "app" {
  count = 2

  vpc_id                  = aws_vpc.main.id
  availability_zone       = local.azs[count.index]
  cidr_block              = local.app_subnet_cidrs[count.index]
  map_public_ip_on_launch = false

  tags = {
    Name = "${local.name}-app-${count.index + 1}"
    Tier = "application"
  }
}

resource "aws_subnet" "database" {
  count = 2

  vpc_id                  = aws_vpc.main.id
  availability_zone       = local.azs[count.index]
  cidr_block              = local.database_subnet_cidrs[count.index]
  map_public_ip_on_launch = false

  tags = {
    Name = "${local.name}-db-${count.index + 1}"
    Tier = "database"
  }
}

resource "aws_route_table" "public" {
  vpc_id = aws_vpc.main.id
  tags   = { Name = "${local.name}-public" }
}

resource "aws_route" "public_internet" {
  route_table_id         = aws_route_table.public.id
  destination_cidr_block = "0.0.0.0/0"
  gateway_id             = aws_internet_gateway.main.id
}

resource "aws_route_table_association" "public" {
  count = 2

  subnet_id      = aws_subnet.public[count.index].id
  route_table_id = aws_route_table.public.id
}

resource "aws_eip" "nat" {
  count = var.single_nat_gateway ? 1 : 2

  domain = "vpc"
  tags   = { Name = "${local.name}-nat-${count.index + 1}" }

  depends_on = [aws_internet_gateway.main]
}

resource "aws_nat_gateway" "main" {
  count = var.single_nat_gateway ? 1 : 2

  allocation_id = aws_eip.nat[count.index].id
  subnet_id     = aws_subnet.public[count.index].id
  tags          = { Name = "${local.name}-nat-${count.index + 1}" }

  depends_on = [aws_internet_gateway.main]
}

resource "aws_route_table" "app" {
  count = 2

  vpc_id = aws_vpc.main.id
  tags   = { Name = "${local.name}-app-${count.index + 1}" }
}

resource "aws_route" "app_internet" {
  count = 2

  route_table_id         = aws_route_table.app[count.index].id
  destination_cidr_block = "0.0.0.0/0"
  nat_gateway_id         = aws_nat_gateway.main[var.single_nat_gateway ? 0 : count.index].id
}

resource "aws_route_table_association" "app" {
  count = 2

  subnet_id      = aws_subnet.app[count.index].id
  route_table_id = aws_route_table.app[count.index].id
}

resource "aws_route_table" "database" {
  count = 2

  vpc_id = aws_vpc.main.id
  tags   = { Name = "${local.name}-db-${count.index + 1}" }
}

resource "aws_route_table_association" "database" {
  count = 2

  subnet_id      = aws_subnet.database[count.index].id
  route_table_id = aws_route_table.database[count.index].id
}

resource "aws_vpc_endpoint" "s3" {
  vpc_id            = aws_vpc.main.id
  service_name      = "com.amazonaws.${var.aws_region}.s3"
  vpc_endpoint_type = "Gateway"
  route_table_ids   = aws_route_table.app[*].id

  tags = { Name = "${local.name}-s3" }
}

resource "aws_flow_log" "vpc" {
  iam_role_arn    = aws_iam_role.flow_logs.arn
  log_destination = aws_cloudwatch_log_group.flow_logs.arn
  traffic_type    = "ALL"
  vpc_id          = aws_vpc.main.id
}

resource "aws_cloudwatch_log_group" "flow_logs" {
  name              = "/aws/vpc/${local.name}/flow-logs"
  retention_in_days = 30
}

resource "aws_iam_role" "flow_logs" {
  name = "${local.name}-vpc-flow-logs"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect    = "Allow"
      Principal = { Service = "vpc-flow-logs.amazonaws.com" }
      Action    = "sts:AssumeRole"
    }]
  })
}

resource "aws_iam_role_policy" "flow_logs" {
  name = "cloudwatch-logs"
  role = aws_iam_role.flow_logs.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect = "Allow"
      Action = [
        "logs:CreateLogStream",
        "logs:PutLogEvents",
        "logs:DescribeLogGroups",
        "logs:DescribeLogStreams"
      ]
      Resource = "${aws_cloudwatch_log_group.flow_logs.arn}:*"
    }]
  })
}

security.tf

resource "aws_security_group" "alb" {
  name        = "${local.name}-alb"
  description = "Public ALB ingress and application egress"
  vpc_id      = aws_vpc.main.id

  tags = { Name = "${local.name}-alb" }
}

resource "aws_vpc_security_group_ingress_rule" "alb_http" {
  for_each = toset(var.allowed_http_cidrs)

  security_group_id = aws_security_group.alb.id
  description       = "HTTP from approved clients; redirect to HTTPS when a certificate is configured"
  cidr_ipv4         = each.value
  from_port         = 80
  to_port           = 80
  ip_protocol       = "tcp"
}

resource "aws_vpc_security_group_ingress_rule" "alb_https" {
  for_each = var.acm_certificate_arn == null ? toset([]) : toset(var.allowed_http_cidrs)

  security_group_id = aws_security_group.alb.id
  description       = "HTTPS from approved clients"
  cidr_ipv4         = each.value
  from_port         = 443
  to_port           = 443
  ip_protocol       = "tcp"
}

resource "aws_security_group" "app" {
  name        = "${local.name}-app"
  description = "Application instances"
  vpc_id      = aws_vpc.main.id

  tags = { Name = "${local.name}-app" }
}

resource "aws_vpc_security_group_ingress_rule" "app_from_alb" {
  security_group_id            = aws_security_group.app.id
  description                  = "Application traffic only from the ALB"
  referenced_security_group_id = aws_security_group.alb.id
  from_port                    = var.app_port
  to_port                      = var.app_port
  ip_protocol                  = "tcp"
}

resource "aws_vpc_security_group_egress_rule" "alb_to_app" {
  security_group_id            = aws_security_group.alb.id
  description                  = "ALB to application targets"
  referenced_security_group_id = aws_security_group.app.id
  from_port                    = var.app_port
  to_port                      = var.app_port
  ip_protocol                  = "tcp"
}

resource "aws_security_group" "database" {
  name        = "${local.name}-database"
  description = "RDS accepts MySQL only from application instances"
  vpc_id      = aws_vpc.main.id

  tags = { Name = "${local.name}-database" }
}

resource "aws_vpc_security_group_ingress_rule" "database_from_app" {
  security_group_id            = aws_security_group.database.id
  description                  = "MySQL from application instances"
  referenced_security_group_id = aws_security_group.app.id
  from_port                    = 3306
  to_port                      = 3306
  ip_protocol                  = "tcp"
}

resource "aws_vpc_security_group_egress_rule" "app_to_database" {
  security_group_id            = aws_security_group.app.id
  description                  = "Application to RDS MySQL"
  referenced_security_group_id = aws_security_group.database.id
  from_port                    = 3306
  to_port                      = 3306
  ip_protocol                  = "tcp"
}

resource "aws_vpc_security_group_egress_rule" "app_https" {
  security_group_id = aws_security_group.app.id
  description       = "HTTPS for SSM, repositories, AWS APIs, and external dependencies"
  cidr_ipv4         = "0.0.0.0/0"
  from_port         = 443
  to_port           = 443
  ip_protocol       = "tcp"
}

resource "aws_vpc_security_group_egress_rule" "app_http" {
  security_group_id = aws_security_group.app.id
  description       = "HTTP for package repositories that do not support HTTPS-only access"
  cidr_ipv4         = "0.0.0.0/0"
  from_port         = 80
  to_port           = 80
  ip_protocol       = "tcp"
}

resource "aws_vpc_security_group_egress_rule" "app_dns_udp" {
  security_group_id = aws_security_group.app.id
  description       = "DNS to the VPC resolver"
  cidr_ipv4         = var.vpc_cidr
  from_port         = 53
  to_port           = 53
  ip_protocol       = "udp"
}

resource "aws_vpc_security_group_egress_rule" "app_dns_tcp" {
  security_group_id = aws_security_group.app.id
  description       = "TCP DNS fallback to the VPC resolver"
  cidr_ipv4         = var.vpc_cidr
  from_port         = 53
  to_port           = 53
  ip_protocol       = "tcp"
}

compute.tf

resource "aws_iam_role" "ec2" {
  name = "${local.name}-ec2"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect    = "Allow"
      Principal = { Service = "ec2.amazonaws.com" }
      Action    = "sts:AssumeRole"
    }]
  })
}

resource "aws_iam_role_policy_attachment" "ssm" {
  role       = aws_iam_role.ec2.name
  policy_arn = "arn:${data.aws_partition.current.partition}:iam::aws:policy/AmazonSSMManagedInstanceCore"
}

resource "aws_iam_instance_profile" "ec2" {
  name = "${local.name}-ec2"
  role = aws_iam_role.ec2.name
}

resource "aws_launch_template" "app" {
  name_prefix            = "${local.name}-"
  image_id               = data.aws_ssm_parameter.al2023_ami.value
  instance_type          = var.instance_type
  update_default_version = true

  iam_instance_profile {
    name = aws_iam_instance_profile.ec2.name
  }

  metadata_options {
    http_endpoint               = "enabled"
    http_tokens                 = "required"
    http_put_response_hop_limit = 1
    instance_metadata_tags      = "disabled"
  }

  network_interfaces {
    associate_public_ip_address = false
    security_groups             = [aws_security_group.app.id]
    delete_on_termination       = true
  }

  block_device_mappings {
    device_name = "/dev/xvda"

    ebs {
      encrypted             = true
      volume_type           = "gp3"
      volume_size           = 20
      delete_on_termination = true
    }
  }

  monitoring {
    enabled = true
  }

  user_data = base64encode(templatefile("${path.module}/user_data.sh.tftpl", {
    project_name = var.project_name
    environment  = var.environment
    app_port     = var.app_port
  }))

  tag_specifications {
    resource_type = "instance"
    tags = merge(local.common_tags, {
      Name = "${local.name}-app"
    })
  }

  tag_specifications {
    resource_type = "volume"
    tags = merge(local.common_tags, {
      Name = "${local.name}-app"
    })
  }

  lifecycle {
    create_before_destroy = true
  }
}

resource "aws_lb" "app" {
  name                       = substr(local.name, 0, 32)
  internal                   = false
  load_balancer_type         = "application"
  security_groups            = [aws_security_group.alb.id]
  subnets                    = aws_subnet.public[*].id
  drop_invalid_header_fields = true
  enable_deletion_protection = false

  tags = { Name = local.name }
}

resource "aws_lb_target_group" "app" {
  name_prefix = "${substr(var.project_name, 0, 5)}-"
  port        = var.app_port
  protocol    = "HTTP"
  target_type = "instance"
  vpc_id      = aws_vpc.main.id

  health_check {
    enabled             = true
    path                = "/health"
    port                = "traffic-port"
    protocol            = "HTTP"
    healthy_threshold   = 2
    unhealthy_threshold = 3
    timeout             = 5
    interval            = 30
    matcher             = "200"
  }

  deregistration_delay = 30

  lifecycle {
    create_before_destroy = true
  }
}

resource "aws_lb_listener" "http_forward" {
  count = var.acm_certificate_arn == null ? 1 : 0

  load_balancer_arn = aws_lb.app.arn
  port              = 80
  protocol          = "HTTP"

  default_action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.app.arn
  }
}

resource "aws_lb_listener" "http_redirect" {
  count = var.acm_certificate_arn == null ? 0 : 1

  load_balancer_arn = aws_lb.app.arn
  port              = 80
  protocol          = "HTTP"

  default_action {
    type = "redirect"

    redirect {
      port        = "443"
      protocol    = "HTTPS"
      status_code = "HTTP_301"
    }
  }
}

resource "aws_lb_listener" "https" {
  count = var.acm_certificate_arn == null ? 0 : 1

  load_balancer_arn = aws_lb.app.arn
  port              = 443
  protocol          = "HTTPS"
  certificate_arn   = var.acm_certificate_arn
  ssl_policy        = "ELBSecurityPolicy-TLS13-1-2-2021-06"

  default_action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.app.arn
  }
}

resource "aws_autoscaling_group" "app" {
  name                      = "${local.name}-app"
  min_size                  = var.asg_min_size
  desired_capacity          = var.asg_desired_capacity
  max_size                  = var.asg_max_size
  vpc_zone_identifier       = aws_subnet.app[*].id
  target_group_arns         = [aws_lb_target_group.app.arn]
  health_check_type         = "ELB"
  health_check_grace_period = 180

  launch_template {
    id      = aws_launch_template.app.id
    version = aws_launch_template.app.latest_version
  }

  instance_refresh {
    strategy = "Rolling"

    preferences {
      min_healthy_percentage = 50
      instance_warmup        = 120
    }

    triggers = ["tag"]
  }

  dynamic "tag" {
    for_each = merge(local.common_tags, { Name = "${local.name}-app" })

    content {
      key                 = tag.key
      value               = tag.value
      propagate_at_launch = true
    }
  }

  lifecycle {
    create_before_destroy = true
  }
}

resource "aws_autoscaling_policy" "cpu_target" {
  name                   = "${local.name}-cpu-50"
  autoscaling_group_name = aws_autoscaling_group.app.name
  policy_type            = "TargetTrackingScaling"

  target_tracking_configuration {
    predefined_metric_specification {
      predefined_metric_type = "ASGAverageCPUUtilization"
    }

    target_value = 50
  }
}

database.tf

resource "aws_db_subnet_group" "main" {
  name       = "${local.name}-db"
  subnet_ids = aws_subnet.database[*].id

  tags = { Name = "${local.name}-db" }
}

resource "aws_db_instance" "main" {
  identifier = substr("${local.name}-mysql", 0, 63)

  engine         = "mysql"
  engine_version = var.db_engine_version
  instance_class = var.db_instance_class

  db_name  = var.db_name
  username = var.db_username

  manage_master_user_password = true

  allocated_storage     = 20
  max_allocated_storage = 100
  storage_type          = "gp3"
  storage_encrypted     = true

  db_subnet_group_name   = aws_db_subnet_group.main.name
  vpc_security_group_ids = [aws_security_group.database.id]
  publicly_accessible    = false
  multi_az               = var.db_multi_az

  backup_retention_period = var.backup_retention_days
  backup_window           = "18:00-19:00"
  maintenance_window      = "sun:19:00-sun:20:00"

  auto_minor_version_upgrade   = true
  deletion_protection          = var.db_deletion_protection
  copy_tags_to_snapshot        = true
  performance_insights_enabled = true
  monitoring_interval          = 60
  monitoring_role_arn          = aws_iam_role.rds_monitoring.arn

  enabled_cloudwatch_logs_exports = ["error", "general", "slowquery"]

  skip_final_snapshot       = var.db_skip_final_snapshot
  final_snapshot_identifier = var.db_skip_final_snapshot ? null : "${local.name}-mysql-final"

  apply_immediately = false

  tags = { Name = "${local.name}-mysql" }
}

resource "aws_iam_role" "rds_monitoring" {
  name = "${local.name}-rds-monitoring"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect    = "Allow"
      Principal = { Service = "monitoring.rds.amazonaws.com" }
      Action    = "sts:AssumeRole"
    }]
  })
}

resource "aws_iam_role_policy_attachment" "rds_monitoring" {
  role       = aws_iam_role.rds_monitoring.name
  policy_arn = "arn:${data.aws_partition.current.partition}:iam::aws:policy/service-role/AmazonRDSEnhancedMonitoringRole"
}

storage.tf

resource "aws_s3_bucket" "app" {
  bucket = "${substr(local.name, 0, 20)}-${data.aws_caller_identity.current.account_id}-${var.aws_region}"

  tags = { Name = "${local.name}-app" }
}

resource "aws_s3_bucket_ownership_controls" "app" {
  bucket = aws_s3_bucket.app.id

  rule {
    object_ownership = "BucketOwnerEnforced"
  }
}

resource "aws_s3_bucket_public_access_block" "app" {
  bucket = aws_s3_bucket.app.id

  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

resource "aws_s3_bucket_versioning" "app" {
  bucket = aws_s3_bucket.app.id

  versioning_configuration {
    status = "Enabled"
  }
}

resource "aws_s3_bucket_server_side_encryption_configuration" "app" {
  bucket = aws_s3_bucket.app.id

  rule {
    apply_server_side_encryption_by_default {
      sse_algorithm = "AES256"
    }

  }
}

resource "aws_s3_bucket_lifecycle_configuration" "app" {
  bucket = aws_s3_bucket.app.id

  depends_on = [aws_s3_bucket_versioning.app]

  rule {
    id     = "data-hygiene"
    status = "Enabled"

    filter {}

    abort_incomplete_multipart_upload {
      days_after_initiation = 7
    }

    noncurrent_version_expiration {
      noncurrent_days = 90
    }
  }
}

resource "aws_s3_bucket_policy" "app" {
  bucket = aws_s3_bucket.app.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Sid       = "DenyInsecureTransport"
      Effect    = "Deny"
      Principal = "*"
      Action    = "s3:*"
      Resource = [
        aws_s3_bucket.app.arn,
        "${aws_s3_bucket.app.arn}/*"
      ]
      Condition = {
        Bool = { "aws:SecureTransport" = "false" }
      }
    }]
  })
}

resource "aws_iam_role_policy" "ec2_s3" {
  name = "app-bucket-access"
  role = aws_iam_role.ec2.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid      = "ListBucket"
        Effect   = "Allow"
        Action   = ["s3:ListBucket"]
        Resource = aws_s3_bucket.app.arn
      },
      {
        Sid      = "ObjectAccess"
        Effect   = "Allow"
        Action   = ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"]
        Resource = "${aws_s3_bucket.app.arn}/app/*"
      }
    ]
  })
}

outputs.tf

output "vpc_id" {
  description = "VPC ID."
  value       = aws_vpc.main.id
}

output "availability_zones" {
  description = "Availability Zones selected by the example."
  value       = local.azs
}

output "public_subnet_ids" {
  description = "Public ALB/NAT subnet IDs."
  value       = aws_subnet.public[*].id
}

output "app_subnet_ids" {
  description = "Private application subnet IDs."
  value       = aws_subnet.app[*].id
}

output "database_subnet_ids" {
  description = "Isolated database subnet IDs."
  value       = aws_subnet.database[*].id
}

output "alb_dns_name" {
  description = "Public DNS name of the Application Load Balancer."
  value       = aws_lb.app.dns_name
}

output "application_url" {
  description = "Application URL. Add a Route 53 alias for a real production domain."
  value       = var.acm_certificate_arn == null ? "http://${aws_lb.app.dns_name}" : "https://${aws_lb.app.dns_name}"
}

output "rds_endpoint" {
  description = "RDS endpoint. Access is limited to the application security group."
  value       = aws_db_instance.main.endpoint
}

output "rds_master_secret_arn" {
  description = "Secrets Manager ARN containing the generated RDS master credentials."
  value       = try(aws_db_instance.main.master_user_secret[0].secret_arn, null)
  sensitive   = true
}

output "s3_bucket_name" {
  description = "Private application data bucket."
  value       = aws_s3_bucket.app.id
}

user_data.sh.tftpl

#!/bin/bash
set -euo pipefail

dnf install -y nginx

sed -i 's/listen       80;/listen       ${app_port};/' /etc/nginx/nginx.conf

cat >/usr/share/nginx/html/index.html <<'HTML'
<!doctype html>
<html lang="zh-CN">
  <head><meta charset="utf-8"><title>${project_name}</title></head>
  <body>
    <h1>${project_name}</h1>
    <p>Environment: ${environment}</p>
    <p>Managed by Terraform behind an Application Load Balancer.</p>
  </body>
</html>
HTML

printf 'ok\n' >/usr/share/nginx/html/health
systemctl enable --now nginx

terraform.tfvars.example

aws_region  = "ap-southeast-1"
project_name = "myapp"
environment  = "prod"

# Lab cost mode: one NAT Gateway. Set false for one NAT Gateway per AZ.
single_nat_gateway = true

instance_type       = "t3.micro"
asg_min_size        = 2
asg_desired_capacity = 2
asg_max_size        = 4

db_instance_class       = "db.t4g.micro"
db_multi_az             = true
db_deletion_protection  = true
db_skip_final_snapshot  = false
backup_retention_days   = 7

# Production HTTPS: request/validate an ACM certificate first and place its ARN here.
# acm_certificate_arn = "arn:aws:acm:ap-southeast-1:123456789012:certificate/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"

allowed_http_cidrs = ["0.0.0.0/0"]

github-actions/terraform.yml

name: Terraform AWS Production

on:
  pull_request:
    paths:
      - "terraform-learning/05-aws-production/**"
      - ".github/workflows/terraform.yml"
  push:
    branches: ["main"]
    paths:
      - "terraform-learning/05-aws-production/**"
      - ".github/workflows/terraform.yml"
  workflow_dispatch:

concurrency:
  group: terraform-production
  cancel-in-progress: false

permissions:
  contents: read

env:
  TF_IN_AUTOMATION: "true"
  TF_INPUT: "false"
  TF_WORKING_DIR: terraform-learning/05-aws-production
  TF_STATE_KEY: tf-production-demo/prod/terraform.tfstate

jobs:
  validate:
    name: Format and validate
    runs-on: ubuntu-latest

    steps:
      - name: Checkout exact commit
        uses: actions/checkout@v4

      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: 1.15.8

      - name: Terraform format
        working-directory: ${{ env.TF_WORKING_DIR }}
        run: terraform fmt -check -recursive

      - name: Initialize without remote backend
        working-directory: ${{ env.TF_WORKING_DIR }}
        run: terraform init -backend=false -input=false

      - name: Terraform validate
        working-directory: ${{ env.TF_WORKING_DIR }}
        run: terraform validate

  plan:
    name: Plan
    needs: validate
    runs-on: ubuntu-latest
    permissions:
      contents: read
      id-token: write

    steps:
      - name: Checkout exact commit
        uses: actions/checkout@v4

      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: 1.15.8

      - name: Obtain short-lived AWS credentials
        uses: aws-actions/configure-aws-credentials@v5
        with:
          role-to-assume: ${{ vars.TF_PLAN_ROLE_ARN }}
          aws-region: ${{ vars.AWS_REGION }}

      - name: Verify AWS account
        run: aws sts get-caller-identity

      - name: Initialize remote state
        working-directory: ${{ env.TF_WORKING_DIR }}
        run: >-
          terraform init -input=false
          -backend-config="bucket=${{ vars.TF_STATE_BUCKET }}"
          -backend-config="key=${{ env.TF_STATE_KEY }}"
          -backend-config="region=${{ vars.AWS_REGION }}"
          -backend-config="encrypt=true"
          -backend-config="use_lockfile=true"

      - name: Create saved plan
        working-directory: ${{ env.TF_WORKING_DIR }}
        run: terraform plan -input=false -lock-timeout=5m -out=tfplan -var-file=terraform.tfvars

      - name: Upload reviewed plan
        if: github.event_name != 'pull_request'
        uses: actions/upload-artifact@v4
        with:
          name: terraform-production-plan
          path: |
            ${{ env.TF_WORKING_DIR }}/tfplan
            ${{ env.TF_WORKING_DIR }}/.terraform.lock.hcl
          if-no-files-found: error
          retention-days: 1

  apply:
    name: Apply approved plan
    if: github.event_name != 'pull_request'
    needs: plan
    runs-on: ubuntu-latest
    environment: production
    permissions:
      contents: read
      id-token: write

    steps:
      - name: Checkout exact commit
        uses: actions/checkout@v4

      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: 1.15.8

      - name: Obtain short-lived AWS credentials
        uses: aws-actions/configure-aws-credentials@v5
        with:
          role-to-assume: ${{ vars.TF_APPLY_ROLE_ARN }}
          aws-region: ${{ vars.AWS_REGION }}

      - name: Verify AWS account
        run: aws sts get-caller-identity

      - name: Initialize remote state
        working-directory: ${{ env.TF_WORKING_DIR }}
        run: >-
          terraform init -input=false
          -backend-config="bucket=${{ vars.TF_STATE_BUCKET }}"
          -backend-config="key=${{ env.TF_STATE_KEY }}"
          -backend-config="region=${{ vars.AWS_REGION }}"
          -backend-config="encrypt=true"
          -backend-config="use_lockfile=true"

      - name: Download approved plan
        uses: actions/download-artifact@v4
        with:
          name: terraform-production-plan
          path: ${{ env.TF_WORKING_DIR }}

      - name: Apply the exact saved plan
        working-directory: ${{ env.TF_WORKING_DIR }}
        run: terraform apply -input=false tfplan

bootstrap/versions.tf

terraform {
  required_version = ">= 1.6.0"

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

provider "aws" {
  region = var.aws_region
}

bootstrap/variables.tf

variable "aws_region" {
  type    = string
  default = "ap-southeast-1"
}

variable "project_name" {
  description = "Short lowercase project name used in the state bucket and IAM roles."
  type        = string
  default     = "tf-production-demo"
}

variable "github_owner" {
  description = "GitHub organization or user that owns the repository."
  type        = string
}

variable "github_repository" {
  description = "GitHub repository name without the owner prefix."
  type        = string
}

variable "state_key" {
  description = "S3 object key used by the production Terraform state."
  type        = string
  default     = "tf-production-demo/prod/terraform.tfstate"
}

bootstrap/main.tf

data "aws_caller_identity" "current" {}

data "aws_partition" "current" {}

locals {
  name         = substr(var.project_name, 0, 24)
  state_bucket = "${local.name}-tfstate-${data.aws_caller_identity.current.account_id}-${var.aws_region}"
  state_arn    = "arn:${data.aws_partition.current.partition}:s3:::${local.state_bucket}"
}

resource "aws_s3_bucket" "state" {
  bucket = local.state_bucket

  lifecycle {
    prevent_destroy = true
  }

  tags = {
    Name      = local.state_bucket
    ManagedBy = "TerraformBootstrap"
  }
}

resource "aws_s3_bucket_public_access_block" "state" {
  bucket = aws_s3_bucket.state.id

  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

resource "aws_s3_bucket_ownership_controls" "state" {
  bucket = aws_s3_bucket.state.id

  rule {
    object_ownership = "BucketOwnerEnforced"
  }
}

resource "aws_s3_bucket_versioning" "state" {
  bucket = aws_s3_bucket.state.id

  versioning_configuration {
    status = "Enabled"
  }
}

resource "aws_s3_bucket_server_side_encryption_configuration" "state" {
  bucket = aws_s3_bucket.state.id

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

resource "aws_s3_bucket_policy" "state" {
  bucket = aws_s3_bucket.state.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Sid       = "DenyInsecureTransport"
      Effect    = "Deny"
      Principal = "*"
      Action    = "s3:*"
      Resource = [
        aws_s3_bucket.state.arn,
        "${aws_s3_bucket.state.arn}/*"
      ]
      Condition = {
        Bool = { "aws:SecureTransport" = "false" }
      }
    }]
  })
}

resource "aws_iam_openid_connect_provider" "github" {
  url = "https://token.actions.githubusercontent.com"

  client_id_list = ["sts.amazonaws.com"]
}

data "aws_iam_policy_document" "github_plan_trust" {
  statement {
    effect  = "Allow"
    actions = ["sts:AssumeRoleWithWebIdentity"]

    principals {
      type        = "Federated"
      identifiers = [aws_iam_openid_connect_provider.github.arn]
    }

    condition {
      test     = "StringEquals"
      variable = "token.actions.githubusercontent.com:aud"
      values   = ["sts.amazonaws.com"]
    }

    condition {
      test     = "StringLike"
      variable = "token.actions.githubusercontent.com:sub"
      values = [
        "repo:${var.github_owner}/${var.github_repository}:pull_request",
        "repo:${var.github_owner}/${var.github_repository}:ref:refs/heads/main"
      ]
    }
  }
}

data "aws_iam_policy_document" "github_apply_trust" {
  statement {
    effect  = "Allow"
    actions = ["sts:AssumeRoleWithWebIdentity"]

    principals {
      type        = "Federated"
      identifiers = [aws_iam_openid_connect_provider.github.arn]
    }

    condition {
      test     = "StringEquals"
      variable = "token.actions.githubusercontent.com:aud"
      values   = ["sts.amazonaws.com"]
    }

    condition {
      test     = "StringEquals"
      variable = "token.actions.githubusercontent.com:sub"
      values   = ["repo:${var.github_owner}/${var.github_repository}:environment:production"]
    }
  }
}

resource "aws_iam_role" "github_plan" {
  name               = "${local.name}-github-plan"
  assume_role_policy = data.aws_iam_policy_document.github_plan_trust.json
}

resource "aws_iam_role" "github_apply" {
  name               = "${local.name}-github-apply"
  assume_role_policy = data.aws_iam_policy_document.github_apply_trust.json
}

resource "aws_iam_role_policy_attachment" "plan_read_only" {
  role       = aws_iam_role.github_plan.name
  policy_arn = "arn:${data.aws_partition.current.partition}:iam::aws:policy/ReadOnlyAccess"
}

data "aws_iam_policy_document" "state_access" {
  statement {
    sid       = "ListStatePrefix"
    effect    = "Allow"
    actions   = ["s3:ListBucket"]
    resources = [local.state_arn]

    condition {
      test     = "StringLike"
      variable = "s3:prefix"
      values   = [var.state_key, "${var.state_key}.tflock"]
    }
  }

  statement {
    sid    = "ReadWriteState"
    effect = "Allow"
    actions = [
      "s3:GetObject",
      "s3:PutObject"
    ]
    resources = ["${local.state_arn}/${var.state_key}"]
  }

  statement {
    sid    = "ManageStateLock"
    effect = "Allow"
    actions = [
      "s3:GetObject",
      "s3:PutObject",
      "s3:DeleteObject"
    ]
    resources = ["${local.state_arn}/${var.state_key}.tflock"]
  }
}

resource "aws_iam_policy" "state_access" {
  name   = "${local.name}-terraform-state"
  policy = data.aws_iam_policy_document.state_access.json
}

resource "aws_iam_role_policy_attachment" "plan_state" {
  role       = aws_iam_role.github_plan.name
  policy_arn = aws_iam_policy.state_access.arn
}

resource "aws_iam_role_policy_attachment" "apply_state" {
  role       = aws_iam_role.github_apply.name
  policy_arn = aws_iam_policy.state_access.arn
}

data "aws_iam_policy_document" "apply" {
  statement {
    sid    = "ManageCaseResources"
    effect = "Allow"
    actions = [
      "autoscaling:*",
      "cloudwatch:*",
      "ec2:*",
      "elasticloadbalancing:*",
      "logs:*",
      "rds:*",
      "s3:*",
      "secretsmanager:*",
      "ssm:GetParameter",
      "ssm:GetParameters",
      "ssm:GetParametersByPath"
    ]
    resources = ["*"]
  }

  statement {
    sid    = "ManageCaseRoles"
    effect = "Allow"
    actions = [
      "iam:AttachRolePolicy",
      "iam:CreateInstanceProfile",
      "iam:CreatePolicy",
      "iam:CreatePolicyVersion",
      "iam:CreateRole",
      "iam:DeleteInstanceProfile",
      "iam:DeletePolicy",
      "iam:DeletePolicyVersion",
      "iam:DeleteRole",
      "iam:DeleteRolePolicy",
      "iam:DetachRolePolicy",
      "iam:GetInstanceProfile",
      "iam:GetPolicy",
      "iam:GetPolicyVersion",
      "iam:GetRole",
      "iam:GetRolePolicy",
      "iam:ListAttachedRolePolicies",
      "iam:ListInstanceProfilesForRole",
      "iam:ListPolicyVersions",
      "iam:ListRolePolicies",
      "iam:PassRole",
      "iam:PutRolePolicy",
      "iam:RemoveRoleFromInstanceProfile",
      "iam:AddRoleToInstanceProfile",
      "iam:TagInstanceProfile",
      "iam:TagPolicy",
      "iam:TagRole",
      "iam:UntagInstanceProfile",
      "iam:UntagPolicy",
      "iam:UntagRole",
      "iam:UpdateAssumeRolePolicy"
    ]
    resources = [
      "arn:${data.aws_partition.current.partition}:iam::${data.aws_caller_identity.current.account_id}:role/${local.name}-*",
      "arn:${data.aws_partition.current.partition}:iam::${data.aws_caller_identity.current.account_id}:instance-profile/${local.name}-*",
      "arn:${data.aws_partition.current.partition}:iam::${data.aws_caller_identity.current.account_id}:policy/${local.name}-*"
    ]
  }
}

resource "aws_iam_policy" "apply" {
  name   = "${local.name}-terraform-apply"
  policy = data.aws_iam_policy_document.apply.json
}

resource "aws_iam_role_policy_attachment" "apply" {
  role       = aws_iam_role.github_apply.name
  policy_arn = aws_iam_policy.apply.arn
}

bootstrap/outputs.tf

output "state_bucket" {
  value = aws_s3_bucket.state.id
}

output "plan_role_arn" {
  value = aws_iam_role.github_plan.arn
}

output "apply_role_arn" {
  value = aws_iam_role.github_apply.arn
}

output "github_repository_variables" {
  value = {
    AWS_REGION        = var.aws_region
    TF_STATE_BUCKET   = aws_s3_bucket.state.id
    TF_PLAN_ROLE_ARN  = aws_iam_role.github_plan.arn
    TF_APPLY_ROLE_ARN = aws_iam_role.github_apply.arn
  }
}

bootstrap/terraform.tfvars.example

aws_region       = "ap-southeast-1"
project_name     = "tf-production-demo"
github_owner     = "your-github-user-or-org"
github_repository = "your-repository"
state_key         = "tf-production-demo/prod/terraform.tfstate"

27. 完整代码使用顺序

  1. 在受信任的管理员环境运行 bootstrap/,创建 State Bucket、GitHub OIDC Provider、Plan Role 和 Apply Role。
  2. 将 Bootstrap 输出写入 GitHub Repository Variables。
  3. 创建 GitHub production Environment,配置审批人并限制为 main 分支。
  4. github-actions/terraform.yml 复制到仓库的 .github/workflows/terraform.yml
  5. 复制 terraform.tfvars.exampleterraform.tfvars,按账号和预算修改参数。
  6. 在 Pull Request 检查 Format、Validate 和 Plan;合并后审批并执行保存的 Plan。
  7. 部署完成后验证 ALB、ASG、RDS、S3、Flow Logs 和 CloudTrail。
  8. 实验结束前先关闭 RDS 删除保护,再生成并审核销毁计划,避免遗留计费资源。
原创

Terraform 创建 AWS 生产架构实战:VPC、EC2、RDS、ALB、S3 与 GitHub Actions CI/CD

本文链接: Terraform 创建 AWS 生产架构实战:VPC、EC2、RDS、ALB、S3 与 GitHub Actions CI/CD

本文采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处。

评论交流

文章目录