Chapter 10

生产最佳实践:安全与团队协作

构建安全、可靠、可协作的 Terraform 工作流,将基础设施管理提升到生产级水准

安全最佳实践

最小权限 IAM(Least Privilege)

Terraform 执行时需要云账号的访问凭证,错误的做法是直接给 AdministratorAccess。最小权限原则要求:只赋予完成当前任务所需的最小权限集合

为什么不用 AdministratorAccess?
一旦 Terraform 执行账号的凭证泄露(如 CI 系统被攻击),攻击者会获得整个 AWS 账号的完全控制权。最小权限将泄露的影响范围限制在特定资源类型。
资源类型限制
只允许操作需要的资源类型(如只有 ec2:*、s3:*、rds:*),而不是所有资源。
区域限制
通过 aws:RequestedRegion Condition 限制只能在特定 AWS 区域操作,防止在未知区域创建资源。
OIDC 替代静态凭证
在 GitHub Actions、GitLab CI 中使用 OIDC(OpenID Connect)获取临时凭证,完全避免长期 Access Key 的存在,零凭证泄露风险。
// terraform-execution-role-policy.json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "TerraformComputeAndStorage",
      "Effect": "Allow",
      "Action": [
        "ec2:*",
        "s3:*",
        "rds:*",
        "elasticloadbalancing:*",
        "autoscaling:*"
      ],
      "Resource": "*",
      "Condition": {
        "StringEquals": {
          // 限制只能在这两个区域操作
          "aws:RequestedRegion": ["us-east-1", "us-west-2"]
        }
      }
    },
    {
      "Sid": "TerraformStateBackend",
      "Effect": "Allow",
      "Action": [
        "s3:GetObject",
        "s3:PutObject",
        "s3:DeleteObject",
        "s3:ListBucket",
        "dynamodb:GetItem",
        "dynamodb:PutItem",
        "dynamodb:DeleteItem"
      ],
      "Resource": [
        "arn:aws:s3:::my-company-terraform-state/*",
        "arn:aws:s3:::my-company-terraform-state",
        "arn:aws:dynamodb:us-east-1:123456789:table/terraform-locks"
      ]
    }
  ]
}
# GitHub Actions OIDC Trust Policy(允许 GitHub Actions 扮演此角色)
# 不需要任何静态凭证!
data "aws_iam_policy_document" "github_actions_assume_role" {
  statement {
    actions = ["sts:AssumeRoleWithWebIdentity"]

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

    condition {
      test     = "StringLike"
      variable = "token.actions.githubusercontent.com:sub"
      values   = ["repo:my-org/my-repo:*"]  # 限制只有此 repo 可以扮演
    }
  }
}

resource "aws_iam_role" "github_terraform" {
  name               = "GitHubTerraformRole"
  assume_role_policy = data.aws_iam_policy_document.github_actions_assume_role.json
}

Secrets 管理:绝不硬编码,绝不入 State

State 文件中的敏感数据问题

即使你的 .tf 文件中没有明文密码,某些资源(如 aws_db_instance 的 password、aws_iam_access_key 的 secret)的值也会明文存储在 terraform.tfstate 中。这是 Terraform 的已知问题。正确做法:启用 S3 Backend 的 KMS 加密,严格限制 State 文件的访问权限,并用 Secrets Manager/Vault 动态读取密码。

# ❌ 错误做法:密码硬编码在 .tf 或 .tfvars 文件中
# db_password = "my-super-secret-password"  ← 进 Git 历史,永远泄露

# ✅ 正确做法 1:从 AWS Secrets Manager 动态读取
data "aws_secretsmanager_secret_version" "db" {
  secret_id = "prod/database/credentials"
}

locals {
  # Secrets Manager 存储的是 JSON 字符串,解码取字段
  db_creds = jsondecode(data.aws_secretsmanager_secret_version.db.secret_string)
}

resource "aws_db_instance" "main" {
  engine   = "postgres"
  username = local.db_creds.username
  password = local.db_creds.password  # 运行时从 Secrets Manager 读取
}

# ✅ 正确做法 2:从 HashiCorp Vault 读取
data "vault_generic_secret" "db" {
  path = "secret/prod/database"  # Vault KV 路径
}

resource "aws_db_instance" "main" {
  password = data.vault_generic_secret.db.data["password"]
}

# ✅ 正确做法 3:使用 AWS Parameter Store
data "aws_ssm_parameter" "db_password" {
  name            = "/prod/database/password"
  with_decryption = true  # SecureString 类型需要解密
}

resource "aws_db_instance" "main" {
  password = data.aws_ssm_parameter.db_password.value
}

生命周期保护:防止意外删除和替换

# lifecycle 块控制 Terraform 对资源的处理行为
resource "aws_db_instance" "production" {
  identifier        = "prod-database"
  engine            = "postgres"
  instance_class    = "db.r6g.large"
  deletion_protection = true  # AWS 层面的保护(需要先关闭才能删除)

  lifecycle {
    # prevent_destroy:Terraform 层面拒绝 destroy 操作
    # 如果 terraform destroy 或 plan 中包含删除此资源,直接报错
    prevent_destroy = true

    # ignore_changes:忽略指定属性的变化(手工修改这些属性不会触发 Terraform 覆盖)
    ignore_changes = [
      engine_version,    # 忽略自动小版本升级触发的 diff
      password,          # 密码在 Secrets Manager 中管理,Terraform 不管
    ]

    # create_before_destroy:先创建新资源再销毁旧资源
    # 避免先删再建导致的服务中断(适用于无法原地更新的资源)
    create_before_destroy = true

    # replace_triggered_by:当依赖资源变化时,强制替换此资源
    # 例如 TLS 证书轮换时强制重新部署 EC2
    replace_triggered_by = [aws_acm_certificate.main.id]
  }
}

resource "aws_s3_bucket" "state" {
  bucket = "my-terraform-state"

  lifecycle {
    prevent_destroy = true   # State bucket 绝对不允许删除
  }
}

团队工作流:Atlantis GitOps

Atlantis 的工作原理

Atlantis 是一个开源工具,在 Kubernetes 或 EC2 上运行,监听 GitHub/GitLab PR 事件,自动在 PR 中运行 terraform plan 并将输出作为评论展示,团队 Review 后通过评论触发 apply。

Atlantis GitOps 工作流 开发者推送分支,创建 PR ↓ Atlantis 收到 webhook ↓ 自动执行 terraform plan ↓ Plan 结果作为 PR 评论显示 ↓ 团队成员 Code Review + Approve ↓ 评论 "atlantis apply" ↓ Atlantis 执行 terraform apply ↓ Apply 结果作为评论显示 ↓ PR 自动合并(配置 apply_requirements: mergeable) ↓ main 分支 = 生产基础设施的真实状态
# atlantis.yaml(放在 Terraform 仓库根目录)
version: 3
automerge: true     # apply 成功后自动合并 PR

projects:
  - name: production
    dir: environments/prod
    workspace: default    # Terraform workspace

    # 自动触发 plan 的条件
    autoplan:
      enabled: true
      when_modified:
        - "**/*.tf"          # 任何 .tf 文件修改
        - "**/*.tfvars"      # tfvars 变化也触发
        - "modules/**/*.tf"  # 依赖的模块变化也触发

    # apply 的前置条件
    apply_requirements:
      - approved     # 必须有至少 1 个 Approve
      - mergeable    # PR 必须处于可合并状态(无冲突)
      # - undiverged  # 可选:分支不能落后于 main

    # 自定义工作流(可选)
    workflow: custom

workflows:
  custom:
    plan:
      steps:
        - init:
            extra_args: ["-backend-config=environments/prod/backend.hcl"]
        - run: tflint --recursive   # plan 前先 lint
        - plan:
            extra_args: ["-var-file=environments/prod/terraform.tfvars"]
    apply:
      steps:
        - apply

Atlantis 部署

# Kubernetes 部署 Atlantis(使用官方 Helm Chart)
helm repo add runatlantis https://runatlantis.github.io/helm-charts
helm repo update

# values.yaml 配置
helm install atlantis runatlantis/atlantis \
  --set orgAllowlist=github.com/my-org/* \
  --set github.user=atlantis-bot \
  --set github.token=$GITHUB_TOKEN \
  --set github.secret=$WEBHOOK_SECRET \
  --set atlantisUrl=https://atlantis.my-company.com

# 配置 GitHub Webhook
# URL: https://atlantis.my-company.com/events
# Content-Type: application/json
# Events: Pull request, Issue comment, Pull request review

成本可视化:Infracost

为什么需要成本估算?

Terraform 代码一旦 apply,就会产生真实的云费用。一次不小心的配置变更(如把 RDS 实例从 db.t3.micro 改为 db.r6g.4xlarge)可能导致月费用从 $20 飙升到 $1000+。Infracost 在 PR 阶段就计算出费用变化,让团队在 merge 前看到成本影响。

# 安装 Infracost
brew install infracost

# 注册并获取 API Key(免费)
infracost auth login

# 查看当前配置的月度费用明细
infracost breakdown --path .

# 输出示例:
# Project: environments/prod
#
# Name                                        Monthly Qty  Unit   Monthly Cost
# aws_instance.web (t3.large)
#   Linux/UNIX usage (on-demand)                      730  hours        $60.00
# aws_db_instance.main (db.r6g.large, 100GB)
#   Database instance (on-demand)                     730  hours       $192.00
#   Storage (gp2)                                     100  GB           $11.50
#
# OVERALL TOTAL                                                         $263.50

# 对比两个版本的费用差异(用于 PR 对比)
infracost diff \
  --path . \
  --compare-to baseline_plan.json

# 生成 JSON 报告(供 CI 使用)
infracost breakdown --path . --format json --out-file infracost.json
# .github/workflows/infracost.yml — 在 PR 中显示费用变化
name: Infracost Cost Estimation

on:
  pull_request:
    paths: ['terraform/**']

jobs:
  infracost:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      pull-requests: write   # 允许写评论

    steps:
      - uses: actions/checkout@v4

      - name: Setup Infracost
        uses: infracost/actions/setup@v3
        with:
          api-key: ${{ secrets.INFRACOST_API_KEY }}

      # 计算 main 分支的基准费用
      - name: Checkout base branch
        uses: actions/checkout@v4
        with:
          ref: ${{ github.event.pull_request.base.sha }}

      - name: Generate Infracost baseline
        run: |
          infracost breakdown \
            --path terraform/environments/prod \
            --format json \
            --out-file /tmp/infracost-base.json

      # 计算 PR 分支的费用
      - name: Checkout PR branch
        uses: actions/checkout@v4

      - name: Generate Infracost diff
        run: |
          infracost diff \
            --path terraform/environments/prod \
            --format json \
            --compare-to /tmp/infracost-base.json \
            --out-file /tmp/infracost-diff.json

      # 在 PR 中发布费用变化评论
      - name: Post Infracost comment
        run: |
          infracost comment github \
            --path /tmp/infracost-diff.json \
            --repo $GITHUB_REPOSITORY \
            --github-token ${{ secrets.GITHUB_TOKEN }} \
            --pull-request ${{ github.event.pull_request.number }} \
            --behavior update   # 更新已有评论而非新增

生产级目录结构

推荐的项目结构

terraform/ ├── modules/ # 可复用模块(私有 Module Registry) │ ├── vpc/ │ │ ├── main.tf │ │ ├── variables.tf │ │ ├── outputs.tf │ │ └── README.md │ ├── eks-cluster/ │ ├── rds-postgres/ │ └── app-service/ # 封装应用部署(ALB + ECS + ASG) │ ├── environments/ # 各环境的配置入口 │ ├── dev/ │ │ ├── main.tf # 调用公共模块,传入 dev 参数 │ │ ├── variables.tf │ │ ├── terraform.tfvars # dev 环境的具体参数值 │ │ └── backend.tf # dev 环境的 S3 backend 配置 │ ├── staging/ │ │ └── ... # 与 dev 结构相同 │ └── prod/ │ ├── main.tf │ ├── variables.tf │ ├── terraform.tfvars │ └── backend.tf │ ├── global/ # 跨环境共享资源 │ ├── iam/ # IAM 角色/策略 │ ├── route53/ # DNS 区域 │ └── acm/ # TLS 证书 │ ├── .github/ │ └── workflows/ │ ├── terraform-ci.yml # PR 检查(fmt/validate/plan/test) │ ├── drift-detection.yml # 定期漂移检测 │ └── infracost.yml # 费用估算 │ ├── .tflint.hcl # TFLint 配置 ├── atlantis.yaml # Atlantis GitOps 配置 ├── .gitignore # 排除 .terraform/, *.tfstate └── Makefile # 封装常用命令

Makefile 封装常用命令

# Makefile — 封装 Terraform 常用操作,统一团队命令
ENV ?= dev
TF_DIR = environments/$(ENV)

.PHONY: init plan apply destroy fmt validate test

# 初始化(自动使用对应环境的 backend 配置)
init:
	cd $(TF_DIR) && terraform init \
		-backend-config=backend.tf

# 计划(默认 dev 环境,可用 make plan ENV=prod)
plan: init
	cd $(TF_DIR) && terraform plan \
		-var-file=terraform.tfvars \
		-out=tfplan

# 应用(需要手动确认,或加 AUTO=true 跳过)
apply: plan
	cd $(TF_DIR) && terraform apply tfplan

# 格式化所有 .tf 文件
fmt:
	terraform fmt -recursive

# 静态验证
validate: init
	terraform validate ./... && \
	tflint --recursive && \
	checkov -d .

# 运行内置测试
test: init
	terraform test -verbose

.gitignore 配置

# .gitignore — Terraform 项目必须忽略的文件

# Terraform 缓存目录(包含 provider 二进制文件,很大)
.terraform/

# State 文件(包含敏感数据,不能入 Git)
*.tfstate
*.tfstate.backup
.terraform.tfstate.lock.info

# Plan 文件(可能包含敏感数据)
*.tfplan

# 本地 override 文件(用于个人测试,不共享)
override.tf
override.tf.json
*_override.tf
*_override.tf.json

# 包含敏感数据的 tfvars(生产密码等)
*.auto.tfvars
# 如果 terraform.tfvars 包含密码,也要忽略
# terraform.tfvars

# Terragrunt 缓存
.terragrunt-cache/

# Crash 日志
crash.log

常见反模式与最佳实践对比

反模式:手动执行 apply
团队成员在本地随意执行 terraform apply,没有 review 流程,操作无法追溯。正确做法:通过 Atlantis 或 GitHub Actions CI/CD,所有 apply 都经过 PR review,有完整审计日志。
反模式:一个巨型 main.tf
所有资源堆在一个文件中,难以阅读和维护。正确做法:按资源类型拆分文件(network.tf、compute.tf、database.tf、iam.tf),并将可复用部分提取为模块。
反模式:直接使用 latest provider 版本
version = "~> latest" 会导致不同时间运行 init 下载不同版本的 provider,破坏可重现性。正确做法:在 required_providers 中固定小版本(如 ~> 5.50),并定期用 terraform providers upgrade 升级。
反模式:没有标签策略
资源没有统一的标签,无法按项目/环境/团队进行成本分配和资源盘点。正确做法:定义标签规范,通过 default_tags(AWS Provider 功能)自动给所有资源打标签。
反模式:State 文件入 Git
State 文件包含明文密码、私钥等敏感数据,入 Git 等于永久泄露(即使后来删除,历史记录仍保存)。正确做法:始终使用远程 Backend(S3 + KMS 加密),.gitignore 中排除所有 .tfstate 文件。

default_tags:自动给所有资源打标签

# AWS Provider 的 default_tags 功能
# 这些标签会自动应用到所有支持 tags 的 AWS 资源
provider "aws" {
  region = "us-east-1"

  default_tags {
    tags = {
      Project     = var.project_name           # 项目名称(成本中心)
      Environment = var.environment            # dev/staging/prod
      ManagedBy   = "terraform"              # 标明由 Terraform 管理
      Owner       = var.team_name              # 负责团队
      Repository  = "github.com/my-org/infra" # 代码来源
    }
  }
}

# 资源自动继承 default_tags,无需重复声明
# 单独的 tags 会与 default_tags 合并
resource "aws_instance" "web" {
  ami           = "ami-0c55b159cbfafe1f0"
  instance_type = "t3.medium"

  tags = {
    Name = "web-server"   # 仅需加资源特有的标签
    # Project/Environment/ManagedBy/Owner 会自动继承
  }
}

本章小结

本章核心要点
恭喜完成 Terraform IaC 教程!

你已掌握从 HCL 基础、Provider 原理、State 管理、模块化、远程协作到生产级安全工作流的完整知识体系。建议的下一步实践路径:

  1. 在个人 AWS 账号上用 Terraform 搭建一套三层架构(VPC + ECS/EC2 + RDS),使用 S3 Backend
  2. 为你的模块编写 terraform test,体验内置测试框架
  3. 尝试用 Atlantis 搭建 GitOps 工作流,体验 PR-based Infra 变更流程
  4. 接触 OpenTofu(Terraform 的开源社区 fork)了解 IaC 生态的最新动态