1. Terraform 在 CI/CD 中的挑战
一句话总结: Terraform 的 apply 是「有副作用」的部署动作,流水线必须把「只读的 plan」与「可写入的 apply」严格分离,并在二者之间插入人工/自动门禁。
普通 CI 流水线(构建 → 测试 → 部署)对 Terraform 并不直接适用:terraform plan 只读安全,而 terraform apply 会真实修改云资源,二者绝不能混在一个无门禁的步骤里。
安全模式:
PR 提交 ──► plan(只读,产出 diff)──► 人工 Review ──► apply(写入,需批准)
▲
门禁:plan 无破坏性变更才允许合并
1.1 破坏性操作识别
| 操作 | 风险 | 门禁 |
|---|---|---|
create | 低 | 常规 Review |
update | 中 | 关注强制替换 |
delete | 高 | 必须人工确认 |
replace | 高 | 有停机风险 |
import | 低 | 审计 |
# plan 输出中的关键标记
# Terraform will perform the following actions:
# # aws_instance.web will be updated in-place
# # aws_db_instance.db will be replaced ← 高风险
2. 远程 state 与凭据注入
一句话总结: 流水线里的 Terraform 必须读远程 state,且云凭据与 state 访问权限都要通过 CI 平台的 Secret 注入,绝不落盘到仓库或镜像。
CI runner 每次都是全新环境,本地 state 无从谈起。标准做法是把 backend 指向远程(S3/GCS),并让 runner 通过环境变量拿到临时凭据。
# backend 配置(凭据由 CI 环境注入,不在代码里)
terraform {
backend "s3" {
bucket = "my-infra-tfstate"
key = "prod/network/terraform.tfstate"
region = "ap-northeast-1"
dynamodb_table = "tf-state-lock"
}
}
2.1 CI 侧注入
# GitHub Actions / GitLab CI 通过 Secret 注入
export AWS_ACCESS_KEY_ID="${AWS_ACCESS_KEY_ID}"
export AWS_SECRET_ACCESS_KEY="${AWS_SECRET_ACCESS_KEY}"
export AWS_REGION="ap-northeast-1"
terraform init -backend-config="key=${ENV}/network/terraform.tfstate"
| 凭据类型 | 存放位置 | 生命周期 |
|---|---|---|
| 云厂商 AK/SK | CI Secret | 定期轮换 |
| OIDC 联邦 | CI 平台颁发 | 每次运行临时 |
| state 桶访问 | CI Role | 最小权限 |
一句话:能用 OIDC/AssumeRole 就不要用静态 AK/SK;即便用 AK/SK,也只进 CI Secret,绝不进仓库。
3. Plan 阶段设计
一句话总结: Plan 阶段产出「可审查的 diff 产物」:用
-out把 plan 存档为二进制,把摘要贴到 PR 评论,让审查者不用本地跑命令就能看懂变更。
3.1 Plan 存档与评论
# 1. 初始化并获取最新 state
terraform init
terraform workspace select prod || terraform workspace new prod
# 2. 生成 plan 产物(二进制,apply 时复用)
terraform plan -out=tfplan.binary
# 3. 生成人类可读摘要
terraform show -json tfplan.binary | jq -r '.resource_changes[] | "\(.change.actions | join(",")) \(.address)"'
3.2 plan 评论模板
Terraform Plan 评论模板(prod/network)
| 动作 | 数量 |
|------|------|
| create | 2 |
| update | 1 |
| replace | 0 |
| delete | 1 |
- aws_vpc.main 将被 delete(请确认)
- aws_subnet.azs["a"] 将被 create
# GitHub Actions 示例:PR 上评论 plan 结果
- name: Post plan comment
uses: actions/github-script@v7
with:
script: |
const body = process.env.PLAN_SUMMARY;
await github.rest.issues.createComment({
owner, repo, issue_number, body
});
3.3 Plan 阶段的职责划分
| 步骤 | 是否允许写 | 说明 |
|---|---|---|
init | 写 .terraform 缓存 | 只读远程 state |
workspace select | 是 | 选择环境 |
plan | 否(只读) | 计算 diff |
fmt / validate | 否 | 静态检查 |
一句话:plan 阶段的产物(二进制 plan + 文本摘要)要当作「构建产物」存档或上传到 CI 平台,供 apply 阶段复用或人工审查。
4. Apply 阶段设计
一句话总结: Apply 是「有副作用」的唯一写入点,必须设计批准门禁、自动/手动模式选择、锁与超时控制,并把输出写入审计日志。
4.1 门禁设计
# 手动批准:需要人工 approval 后才执行
terraform apply tfplan.binary
| 门禁类型 | 适用 | 说明 |
|---|---|---|
| 人工批准 | 生产环境 | CI 平台 approval 按钮 |
| 自动批准 | 非生产 | 合并即 apply |
| 定时窗口 | 变更窗口 | 业务维护期 |
| 变更单绑定 | 合规要求 | 关联 ticket |
4.2 Apply 步骤清单
# 1. 复用 plan 产物(保证与审查的完全一致)
terraform apply tfplan.binary
# 2. 记录输出
terraform output -json > output.json
# 3. 失败时保留锁信息
# Error: Error acquiring the state lock
# → 检查是否有并行流水线
4.3 锁与并发
# GitHub Actions:限制并发,避免多人同时 apply
concurrency:
group: tf-apply-${{ github.ref }}
cancel-in-progress: false
| 控制点 | 配置 |
|---|---|
| 并发限制 | concurrency group |
| 锁等待 | DynamoDB 锁 + 超时 |
| 失败重试 | 有限次重试,保留现场 |
| 告警 | 失败通知到 on-call |
一句话:apply 必须「只 apply 审查过的 plan」,所以
-out产物要从 plan 阶段一路传到 apply 阶段,不能重新 plan。
5. GitOps 工作流
一句话总结: GitOps 让 Git 成为 IaC 的唯一事实来源,PR 即变更请求,合并即 apply,配合 Atlantis/Terraform Cloud 实现「评论驱动」的自动闭环。
5.1 两种主流形态
| 形态 | 机制 | 代表 |
|---|---|---|
| PR 驱动(Comment-driven) | 在 PR 里 @ 机器人跑 plan/apply | Atlantis |
| 云托管 | 配置托管在平台,平台负责执行 | Terraform Cloud / HCP |
5.2 Atlantis 工作流
开发者提交 PR
└─► Atlantis 检测到变更,跑 plan,评论到 PR
└─► 人工 Review + 评论 "atlantis apply"
└─► Atlantis 执行 apply,更新 PR 状态
# atlantis.yaml
version: 3
projects:
- dir: prod/network
workspace: prod
terraform_version: v1.7.0
apply_requirements: [mergeable, approved]
- dir: staging/app
workspace: staging
apply_requirements: []
5.3 使用 PR 的评论规范
atlantis plan # 触发 plan
atlantis apply # 触发 apply(需权限)
atlantis plan -p prod/network
一句话:GitOps 的关键不是工具,而是「Git 是唯一入口」:任何云资源变更都必须以 PR 形式提交,审查与审计都在 Git 里留痕。
6. 多环境流水线
一句话总结: 多环境用「同一套模块 + 每环境一个 state + promotion 链路」组织流水线,dev 自动 apply、staging 半自动、prod 全人工。
6.1 目录与 state 对应
prod/network/
main.tf
backend.tf
tfplan.binary(存档)
staging/network/
main.tf
prod/app/
main.tf
6.2 环境流水线矩阵
| 环境 | plan 触发 | apply 触发 | 门禁 |
|---|---|---|---|
| dev | PR 提交 | 合并即自动 | 无 |
| staging | PR 提交 | 合并 + 半自动 | 1 人批准 |
| prod | PR 提交 | 合并 + 完全人工 | 2 人批准 + 窗口 |
# 同一工作流,按环境分支不同配置
env:
ENV: ${{ github.ref_name == 'refs/heads/main' && 'prod' || 'staging' }}
6.3 Promotion 原则
| 原则 | 说明 |
|---|---|
| 一套模块 | 环境间只差变量,不差代码 |
| 变量驱动 | tfvars / -backend-config 区分环境 |
| 先跑通 dev | 验证模块与计划逻辑 |
| 变更逐级上 | 不跳级直接改 prod |
一句话:多环境的本质是「一份模块 + 多份 state + 多套变量」,流水线按环境分支配置触发方式与门禁强度即可。
7. 常见 CI/CD 避坑
一句话总结: 流水线集成的大坑集中在「凭据泄露」「plan/apply 不一致」「并发锁死」「破坏性变更无人把关」四类。
| 坑 | 现象 | 对策 |
|---|---|---|
| AK/SK 入库 | 凭据泄露 | OIDC + Secret 注入 |
| apply 前重新 plan | 与审查的不一致 | -out 产物跨阶段传递 |
| 未限并发 | 锁冲突、互相覆盖 | concurrency group |
| delete 无门禁 | 资源被误删 | 高权限环境人工批准 |
| 权限过大 | runner 能删一切 | 最小权限 IAM |
| plan 无存档 | 无法审计 | 上传 plan 产物 |
# 生产 apply 前的双重检查脚本(示例)
#!/bin/bash
set -euo pipefail
# 检查 plan 中是否存在 delete 且未经批准
DELETES=$(terraform show -json tfplan.binary | jq '.resource_changes[] | select(.change.actions[0] == "delete")')
if [ -n "$DELETES" ] && [ "${AUTO_APPROVE:-false}" != "true" ]; then
echo "检测到删除操作,需要人工批准"
exit 1
fi
一句话:把「plan 即审查、apply 即批准、Git 即审计」三者写进流水线,Terraform 才能安全地跑在 CI/CD 上。
8. 总结
Terraform 流水线的核心是把「只读计划」与「写入执行」彻底分离:
| 环节 | 要点 |
|---|---|
| 原则 | plan 只读、apply 写入,严格分离 |
| state | 远程 backend + CI 凭据注入 |
| plan | -out 产物 + PR 评论摘要 |
| apply | 批准门禁 + 复用 plan 产物 |
| GitOps | PR 驱动,Git 为唯一事实来源 |
| 多环境 | 一份模块 + 每环境独立 state |
| 避坑 | 凭据不落盘、并发受限、delete 把关 |
一句话收尾:流水线把「谁都能改云」变成「改云必须有痕迹、有门禁」。下一篇「最佳实践与安全加固」将从敏感数据、加密、权限最小化与策略即代码角度,把整条链路的安全性兜住。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。