文章背景图

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

2026-09-24
2
-
- 分钟

Terraform 创建 AWS 生产架构实战:VPC、EC2、RDS、ALB、S3 与 GitLab 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. 接入 GitLab CI/CD 的目标

生产基础设施流水线不应在每次提交后直接执行 terraform apply -auto-approve。本案例把流程拆成 Validate、Plan 和 Apply 三个阶段:

  1. Merge Request 和分支流水线只检查格式、配置并生成 Plan;
  2. 只有 main 分支可以出现生产 Apply Job;
  3. Apply 是手工任务,并绑定受保护的 production Environment;
  4. Apply 下载并执行同一条流水线生成的 tfplan,不重新生成计划;
  5. GitLab 通过 OIDC ID Token 换取 AWS 临时凭证,不保存长期 Access Key;
  6. Plan Role 与 Apply Role 分离;
  7. S3 Backend 开启加密、版本控制与原生 Lockfile;
  8. resource_group 保证同一生产环境不会并行执行变更。

完整案例增加以下目录:

05-aws-production/
├─ backend.tf
├─ bootstrap-gitlab/             # State Bucket、GitLab OIDC 和 IAM Role
└─ gitlab-ci/
   └─ terraform.gitlab-ci.yml    # 可复制为仓库根目录 .gitlab-ci.yml

20. 远程 State 与锁

主工程保持部分配置的 S3 Backend:

terraform {
  backend "s3" {}
}

流水线初始化时传入 Bucket、Key 和 Region:

terraform init -input=false \
  -backend-config="bucket=$TF_STATE_BUCKET" \
  -backend-config="key=$TF_STATE_KEY" \
  -backend-config="region=$AWS_REGION" \
  -backend-config="encrypt=true" \
  -backend-config="use_lockfile=true"

use_lockfile=true 使用 S3 原生锁文件。State Bucket 同时开启 Versioning、默认加密、Block Public Access、TLS-only Bucket Policy 和 prevent_destroy。Bootstrap 必须先由受信任管理员单独运行一次。

21. GitLab OIDC 与 AWS IAM

GitLab Job 申请受众为 sts.amazonaws.com 的 ID Token:

id_tokens:
  AWS_ID_TOKEN:
    aud: sts.amazonaws.com

流水线用该 Token 调用 AssumeRoleWithWebIdentity,取得最长一小时的临时凭证。AWS 中的 OIDC Provider 使用:

resource "aws_iam_openid_connect_provider" "gitlab" {
  url            = "https://gitlab.com"
  client_id_list = ["sts.amazonaws.com"]
}

Plan Role 允许指定项目的分支流水线,Apply Role 只允许 main 分支:

Plan:  project_path:GROUP/PROJECT:ref_type:branch:ref:*
Apply: project_path:GROUP/PROJECT:ref_type:branch:ref:main

如果使用自建 GitLab,需要把 Issuer URL、IAM 条件键和 JWKS 可访问性改为自己的实例域名,不能照抄 gitlab.com

22. GitLab 流水线三个阶段

22.1 Validate

Validate 不需要 AWS 凭证,也不访问生产 State:

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

22.2 Plan

Plan Job 使用只读 Plan Role 和 State 读写权限,生成保存的计划:

variables:
  AWS_ROLE_ARN: "$TF_PLAN_ROLE_ARN"
script:
  - terraform plan -input=false -lock-timeout=5m -out=tfplan -var-file=terraform.tfvars
  - terraform show -json tfplan | jq -r '.' > tfplan.json

tfplan.json 作为 GitLab Terraform Report 展示;二进制 tfplan 作为短期 Artifact 保存一天。Plan 文件可能包含资源属性和敏感值,不能公开,也不能长期保存。

22.3 Apply

Apply Job 只在 main 分支出现,要求人工点击,并绑定生产环境:

environment:
  name: production
rules:
  - if: '$CI_COMMIT_BRANCH == "main"'
    when: manual
  - when: never

Apply 下载 Plan Job 的 Artifact,使用 Apply Role 执行:

terraform apply -input=false tfplan

它不会重新计划,因此审批人看到的计划与最终执行的是同一份二进制 Plan。

23. GitLab 配置步骤

第一步:创建 Bootstrap 参数

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

填写项目命名空间:

aws_region      = "ap-southeast-1"
project_name    = "tf-production-demo"
gitlab_namespace = "your-group-or-user"
gitlab_project   = "your-project"

第二步:创建 Backend、OIDC Provider 和 IAM Role

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

第三步:配置 GitLab CI/CD Variables

在 Settings → CI/CD → Variables 中添加:

  • AWS_REGION
  • TF_STATE_BUCKET
  • TF_PLAN_ROLE_ARN
  • TF_APPLY_ROLE_ARN

这些值通常不是秘密,但生产项目仍建议设为 Protected。业务密钥应放入 AWS Secrets Manager 或受保护的 Masked Variable,不要写进 Terraform 文件。

第四步:保护生产分支和环境

  1. main 配置为 Protected Branch;
  2. production 配置为 Protected Environment;
  3. 限制谁能触发生产 Apply;
  4. 对 Merge Request 启用 Code Owner 或审批规则;
  5. 禁止普通开发分支直接获得 Apply Role。

第五步:安装流水线

gitlab-ci/terraform.gitlab-ci.yml 复制为仓库根目录的 .gitlab-ci.yml。同时提交 .terraform.lock.hcl,不要提交 .terraform/*.tfstate*.tfplan 或真实 terraform.tfvars

第六步:验证流程

  1. 功能分支修改 Terraform;
  2. 创建 Merge Request;
  3. 检查 Format、Validate 和 Terraform Plan Report;
  4. 同事审查代码、资源替换、账号、区域和费用;
  5. 合并到 main
  6. 主分支流水线重新生成 Plan;
  7. 有权限的生产审批人手工触发 Apply;
  8. 验证 ALB、ASG、RDS、S3、Flow Logs 和 CloudTrail。

24. GitLab CI/CD 安全注意事项

  • OIDC Trust Policy 必须限制到具体 Namespace、Project、分支,不能使用覆盖所有项目的通配符。
  • Apply Role 只信任 main,同时依赖 Protected Branch 和 Protected Environment。
  • Plan Role 尽量只读;教学案例中的 Apply Policy 按服务范围授权,正式环境还应增加资源 ARN、标签条件、Permission Boundary 和 SCP。
  • resource_group 只能串行化 GitLab Job,S3 Backend Lock 仍然必须开启。
  • Plan Artifact 设置短生命周期,并限制项目成员和 Job Token 的下载权限。
  • Fork 或外部贡献者的流水线不能取得生产 AWS 写权限。
  • Destroy 应使用独立的手工流水线、二次审批和明确目标,不能跟随普通 Push 自动执行。
  • 自托管 Runner 应隔离执行环境,不在 Runner 磁盘长期保存 State、Plan 或 AWS 临时凭证。

25. 总结

这套 GitLab 版本将完整 AWS Terraform 架构与可审计的交付流程结合起来:

  • 网络、应用和数据库分层;
  • EC2、RDS、S3 默认采用私有和加密配置;
  • GitLab OIDC 消除长期 AWS Access Key;
  • Plan 与 Apply 使用不同角色;
  • 主分支、生产环境、人工任务和 Artifact 共同形成审批边界;
  • Apply 执行经过审查的同一份 Plan;
  • S3 Lockfile 与 GitLab resource_group 双重避免并发修改。

官方参考资料

26. 完整代码附录

下面给出可复制的基础设施、GitLab 流水线和 Bootstrap 代码。运行前请修改项目路径、AWS 区域和预算参数,并先审核 terraform plan。生成目录、State、Plan 文件和真实凭证不得提交到仓库。

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"]

.gitlab-ci.yml

stages:
  - validate
  - plan
  - apply

default:
  image:
    name: hashicorp/terraform:1.15.8
    entrypoint: [""]
  interruptible: false
  before_script:
    - terraform version

variables:
  TF_IN_AUTOMATION: "true"
  TF_INPUT: "false"
  TF_ROOT: "terraform-learning/05-aws-production"
  TF_STATE_KEY: "tf-production-demo/prod/terraform.tfstate"
  TF_PLUGIN_CACHE_DIR: "$CI_PROJECT_DIR/.terraform.d/plugin-cache"

cache:
  key: terraform-providers
  paths:
    - .terraform.d/plugin-cache/

.terraform_changed:
  rules:
    - changes:
        - terraform-learning/05-aws-production/**/*
        - .gitlab-ci.yml

.aws_oidc:
  id_tokens:
    AWS_ID_TOKEN:
      aud: sts.amazonaws.com
  before_script:
    - apk add --no-cache aws-cli jq
    - >-
      CREDS=$(aws sts assume-role-with-web-identity
      --role-arn "$AWS_ROLE_ARN"
      --role-session-name "gitlab-${CI_PROJECT_ID}-${CI_PIPELINE_ID}"
      --web-identity-token "$AWS_ID_TOKEN"
      --duration-seconds 3600
      --query 'Credentials.[AccessKeyId,SecretAccessKey,SessionToken]'
      --output text)
    - export AWS_ACCESS_KEY_ID=$(echo "$CREDS" | awk '{print $1}')
    - export AWS_SECRET_ACCESS_KEY=$(echo "$CREDS" | awk '{print $2}')
    - export AWS_SESSION_TOKEN=$(echo "$CREDS" | awk '{print $3}')
    - aws sts get-caller-identity

validate:
  extends: .terraform_changed
  stage: validate
  script:
    - cd "$TF_ROOT"
    - terraform fmt -check -recursive
    - terraform init -backend=false -input=false
    - terraform validate

plan:
  extends:
    - .terraform_changed
    - .aws_oidc
  stage: plan
  variables:
    AWS_ROLE_ARN: "$TF_PLAN_ROLE_ARN"
  resource_group: terraform-production
  script:
    - cd "$TF_ROOT"
    - >-
      terraform init -input=false
      -backend-config="bucket=$TF_STATE_BUCKET"
      -backend-config="key=$TF_STATE_KEY"
      -backend-config="region=$AWS_REGION"
      -backend-config="encrypt=true"
      -backend-config="use_lockfile=true"
    - terraform plan -input=false -lock-timeout=5m -out=tfplan -var-file=terraform.tfvars
    - terraform show -json tfplan | jq -r '.' > tfplan.json
  artifacts:
    name: "terraform-plan-$CI_COMMIT_SHA"
    expire_in: 1 day
    paths:
      - "$TF_ROOT/tfplan"
      - "$TF_ROOT/.terraform.lock.hcl"
    reports:
      terraform: "$TF_ROOT/tfplan.json"

apply:
  extends: .aws_oidc
  stage: apply
  variables:
    AWS_ROLE_ARN: "$TF_APPLY_ROLE_ARN"
  needs:
    - job: plan
      artifacts: true
  resource_group: terraform-production
  environment:
    name: production
  rules:
    - if: '$CI_COMMIT_BRANCH == "main"'
      when: manual
    - when: never
  allow_failure: false
  script:
    - cd "$TF_ROOT"
    - >-
      terraform init -input=false
      -backend-config="bucket=$TF_STATE_BUCKET"
      -backend-config="key=$TF_STATE_KEY"
      -backend-config="region=$AWS_REGION"
      -backend-config="encrypt=true"
      -backend-config="use_lockfile=true"
    - terraform apply -input=false tfplan

bootstrap-gitlab/versions.tf

terraform {
  required_version = ">= 1.6.0"

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

provider "aws" {
  region = var.aws_region
}

bootstrap-gitlab/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 "gitlab_namespace" {
  description = "GitLab group, subgroup, or user namespace that owns the project."
  type        = string
}

variable "gitlab_project" {
  description = "GitLab project name without the namespace 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-gitlab/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" "gitlab" {
  url = "https://gitlab.com"

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

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

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

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

    condition {
      test     = "StringLike"
      variable = "gitlab.com:sub"
      values   = ["project_path:${var.gitlab_namespace}/${var.gitlab_project}:ref_type:branch:ref:*"]
    }
  }
}

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

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

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

    condition {
      test     = "StringEquals"
      variable = "gitlab.com:sub"
      values   = ["project_path:${var.gitlab_namespace}/${var.gitlab_project}:ref_type:branch:ref:main"]
    }
  }
}

resource "aws_iam_role" "gitlab_plan" {
  name               = "${local.name}-gitlab-plan"
  assume_role_policy = data.aws_iam_policy_document.gitlab_plan_trust.json
}

resource "aws_iam_role" "gitlab_apply" {
  name               = "${local.name}-gitlab-apply"
  assume_role_policy = data.aws_iam_policy_document.gitlab_apply_trust.json
}

resource "aws_iam_role_policy_attachment" "plan_read_only" {
  role       = aws_iam_role.gitlab_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.gitlab_plan.name
  policy_arn = aws_iam_policy.state_access.arn
}

resource "aws_iam_role_policy_attachment" "apply_state" {
  role       = aws_iam_role.gitlab_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.gitlab_apply.name
  policy_arn = aws_iam_policy.apply.arn
}

bootstrap-gitlab/outputs.tf

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

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

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

output "gitlab_cicd_variables" {
  value = {
    AWS_REGION        = var.aws_region
    TF_STATE_BUCKET   = aws_s3_bucket.state.id
    TF_PLAN_ROLE_ARN  = aws_iam_role.gitlab_plan.arn
    TF_APPLY_ROLE_ARN = aws_iam_role.gitlab_apply.arn
  }
}

bootstrap-gitlab/terraform.tfvars.example

aws_region       = "ap-southeast-1"
project_name     = "tf-production-demo"
gitlab_namespace     = "your-group-or-user"
gitlab_project = "your-project"
state_key         = "tf-production-demo/prod/terraform.tfstate"

27. 完整代码使用顺序

  1. 在受信任的管理员环境运行 bootstrap-gitlab/
  2. 将输出写入 GitLab CI/CD Variables,并设为 Protected。
  3. 保护 main 分支和 production Environment。
  4. gitlab-ci/terraform.gitlab-ci.yml 复制为仓库根目录 .gitlab-ci.yml
  5. 按账号、区域和预算修改 terraform.tfvars
  6. 在 Merge Request 审查 Plan,合并后由生产审批人手工触发 Apply。
  7. 部署后验证网络、负载均衡、伸缩、数据库、存储、日志和 CloudTrail。
  8. 实验清理前先关闭 RDS 删除保护,再生成并审核销毁计划。
原创

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

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

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

评论交流

文章目录