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;
- 安全的
plan、apply、验证与清理流程。
完整代码位于: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
核心原则:
- ALB 是唯一公网业务入口。
- EC2 不分配公网 IP。
- RDS 不允许公网访问,也没有互联网默认路由。
- 安全组通过安全组 ID 互相引用,不依赖实例 IP。
- EC2 使用 Auto Scaling Group 跨两个可用区运行。
- 数据库密码不写进代码或
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"
}
}
真实企业还应补充 Owner、CostCenter、DataClassification 等标签。
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/24 | Internet Gateway |
| AZ-B | 公网入口 | 10.20.1.0/24 | Internet Gateway |
| AZ-A | 私有应用 | 10.20.10.0/24 | NAT Gateway |
| AZ-B | 私有应用 | 10.20.11.0/24 | NAT 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 后:
- 80 端口重定向到 443;
- 443 使用 ACM 证书;
- ALB 使用 TLS 1.3/1.2 安全策略;
- 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
计划审查至少要回答:
- 会创建几个 NAT Gateway、EC2 和 RDS?
- EC2 是否意外获得公网 IP?
- RDS 是否为
publicly_accessible = false? - 是否存在
0.0.0.0/0:22或数据库端口向全网开放? - S3 四项 Block Public Access 是否全部为 true?
- 是否有意外的删除或替换?
- 当前规格与 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
- 两个目标应进入
healthy。 - 访问
application_url应返回 Nginx 示例页面。 /health应返回200和ok。- 配置 ACM 后,HTTP 应重定向到 HTTPS。
16.3 验证 EC2
- 实例分布在两个应用子网。
- 实例没有公网 IPv4。
- Session Manager 可以建立会话。
- Metadata Options 显示 IMDSv2 required。
- EBS 根卷已加密。
16.4 验证 RDS
Publicly accessible为 No。- 部署方式为 Multi-AZ。
- RDS 安全组只有来自应用安全组的 3306。
- 自动备份、删除保护和加密均已开启。
- Secrets Manager 中存在 RDS 托管的主凭证。
16.5 验证 S3
- 四项 Block Public Access 全部开启。
- Versioning 已启用。
- 默认加密已启用。
- 非 TLS 请求会被 Bucket Policy 拒绝。
- 应用角色只能访问指定桶和
app/*前缀。
17. 常见故障
ALB 目标一直 unhealthy
依次检查:
- User Data 是否执行成功;
- Nginx 是否监听
app_port; /health是否返回 200;- 应用安全组是否允许 ALB 安全组访问;
- ALB 安全组是否允许向应用端口出站;
- 实例是否有足够时间完成初始化。
EC2 无法通过 SSM 连接
检查:
- IAM Instance Profile 是否附加到实例;
AmazonSSMManagedInstanceCore是否存在;- 应用子网是否能通过 NAT 访问 SSM API;
- 安全组是否允许 443 出站;
- DNS 是否正常;
- 后续是否需要创建 SSM Interface Endpoint,摆脱 NAT 依赖。
应用无法连接 RDS
检查:
- 使用的是 RDS Endpoint,而不是固定 IP;
- 端口是否为 3306;
- RDS 安全组来源是否为应用安全组;
- 应用安全组是否允许 3306 出站;
- 数据库凭证是否从 Secrets Manager 正确读取;
- 数据库用户权限和 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 中的
fmt、validate、plan、Checkov/tfsec 扫描和人工审批。
19. 接入 CI/CD 的目标
基础设施流水线不应简单等于“提交代码后自动执行 terraform apply -auto-approve”。生产流水线需要保证:
- Pull Request 阶段只检查和生成计划,不修改基础设施;
- 合并到
main后重新生成一份针对当前生产 State 的计划; - Apply 使用经过保存和审批的同一份 Plan;
- 同一生产环境一次只允许一个流水线运行;
- AWS 使用 OIDC 临时凭证,不保存长期 Access Key;
- Plan Role 与 Apply Role 分开;
- State 存放在加密、版本化、支持锁的远程 Backend;
- 生产 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_ID 和 AWS_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:
- 添加 Required Reviewers;
- 仅允许
main分支部署; - 根据组织策略决定是否允许管理员绕过;
- 对生产变量和密钥使用 Environment 级控制。
第五步:安装 Workflow
将:
github-actions/terraform.yml
复制到 Git 仓库根目录:
.github/workflows/terraform.yml
同时提交 .terraform.lock.hcl,不要提交 .terraform/、*.tfstate、*.tfplan 或含敏感值的 terraform.tfvars。
第六步:验证流程
- 创建功能分支并修改 Terraform;
- 提交 Pull Request;
- 检查 Format、Validate 和 Plan;
- 由同事审查代码与计划;
- 合并到
main; - 主分支流水线重新生成 Plan;
- 生产审批人核对账号、区域和资源变更;
- 批准 Apply;
- 检查 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,而是让网络、权限、可用性、备份、监控、成本和变更流程形成一套可验证的整体。
官方参考资料
- Terraform AWS Provider
- Terraform Auto Scaling Group
- AWS VPC 私有子网与 NAT 示例
- ALB 安全组推荐规则
- RDS 在 VPC 中的要求
- S3 安全最佳实践
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. 完整代码使用顺序
- 在受信任的管理员环境运行
bootstrap/,创建 State Bucket、GitHub OIDC Provider、Plan Role 和 Apply Role。 - 将 Bootstrap 输出写入 GitHub Repository Variables。
- 创建 GitHub
productionEnvironment,配置审批人并限制为main分支。 - 把
github-actions/terraform.yml复制到仓库的.github/workflows/terraform.yml。 - 复制
terraform.tfvars.example为terraform.tfvars,按账号和预算修改参数。 - 在 Pull Request 检查 Format、Validate 和 Plan;合并后审批并执行保存的 Plan。
- 部署完成后验证 ALB、ASG、RDS、S3、Flow Logs 和 CloudTrail。
- 实验结束前先关闭 RDS 删除保护,再生成并审核销毁计划,避免遗留计费资源。