第 2 章 基础设施即代码与部署自动化(进阶:Terraform与IaC)

学习目标

  • 阐明手动控制台操作(ClickOps)的局限性,并解释Terraform的声明式(Declarative)模型以及状态文件(terraform.tfstate)的底层运作原理。
  • 配置S3远程后端(Remote Backend)与状态锁定(State Locking:use_lockfile及DynamoDB),防止并发执行导致状态文件损坏。
  • 设计可复用的模块(Module),对比基于目录的环境隔离与CLI工作区(Workspace)之间的权衡取舍,选出适合生产环境的隔离架构。
  • 在terraform plan输出中准确区分原地修改(~)与销毁重建(-/+,Replace),并运用lifecycle { prevent_destroy = true }以及moved、import声明块实现零数据丢失的安全重构。
  • 解释为什么sensitive = true或临时(Ephemeral)资源不能替代状态文件本身的加密保护,设计严密的状态文件访问控制与机密注入架构。
  • 排查引发基础设施配置漂移(Drift)的根源,并执行回滚未经授权的手动变更或将紧急热修复(Hotfix)纳入代码管控的标准流程。
  • 无需长期IAM访问密钥,利用GitHub Actions OIDC(AssumeRoleWithWebIdentity)配合最小化约束条件(sub、aud)构建安全的Terraform自动化部署流水线。

引言

在云服务起步阶段,几乎所有人都会通过Web浏览器登录AWS、Cloudflare或Supabase控制台,用鼠标点击按钮来创建云资源。在安全组里开放一个端口、创建一个S3存储桶、添加一条DNS解析记录,往往只需5分钟就能搞定。这种工作方式被称为点击运维(ClickOps)。

然而,一旦业务规模扩大,需要一模一样地复制出一套预发(Staging)环境,或者某天凌晨排障时值班人员急忙修改了一条防火墙规则,结果导致第二天另一项核心功能瘫痪,ClickOps就会立刻演变成一场灾难。“上个月小金在控制台里勾选了一个复选框,到底在哪个子菜单里?”“为什么在Staging环境一切正常,一上Production环境就报权限错误?”“我不小心把路由表删掉了,谁还记得原来的配置参数是什么?”因为鼠标点击既不会留下Git提交记录,也没有代码审查(PR),更无法实现一键自动化回滚(Rollback)。

基础设施即代码(Infrastructure as Code,简称IaC)及其行业标准工具Terraform,将云基础设施从鼠标点击的脆弱产物转变为受版本控制管理的声明式代码(.tf)。不过,如果不理解Terraform的内部工作机制就盲目上手,它引发的事故甚至可能比手动点击更加惨烈:一行粗心的terraform apply就可能把正在运行的生产数据库直接销毁并重建(-/+),导致全量数据瞬间蒸发;两名工程师同时执行命令可能造成状态文件冲突损坏;而误将terraform.tfstate文件提交到Git仓库,则会让数据库主密码以明文形式彻底暴露。本章将系统讲解安全、可预期地驾驭Terraform所需的十项核心概念与实战CI/CD自动化架构设计。

核心概念

声明式(Declarative)模型与Terraform核心工作流

实现基础设施自动化的方式主要分为两大流派:

  • 命令式(Imperative,过程式)方式:就像编写Shell脚本或直接调用AWS CLI命令一样,按先后顺序一步步指示计算机“如何做(How)”。例如依次写下:“创建安全组 → 开放443端口 → 启动2台服务器”。这种方式的最大痛点在于当脚本被重复执行第二次时:要么因为安全组已存在而报错退出,要么又多启动了2台服务器,导致原本只需要2台服务器却变成了4台。
  • 声明式(Declarative)方式:以Terraform(使用HCL,即HashiCorp Configuration Language)为代表,只负责在代码中声明**“最终期望达到怎样的目标状态(What)”**。当你声明“应当存在1个开放443端口的安全组和2台服务器”时,如果当前云端有0台服务器,Terraform就会新建2台;如果已经存在2台,它就什么也不做(即具备幂等性Idempotency);如果有3台,它就会销毁多余的1台;如果你把代码里的端口号改成了8443,它只会精准计算出差异(Diff)并仅更新该端口规则。

Terraform的标准运行生命周期由以下三个核心阶段组成:

  1. terraform init:初始化当前工作目录,自动下载代码中声明的云厂商提供程序插件(Provider,如AWS、Cloudflare、Supabase等)与复用的模块,并连接用于存储状态文件的后端存储(Backend)。
  2. terraform plan:在不触碰任何真实云端资源的前提下,将(甲)你编写的.tf代码(期望状态)、(乙)状态文件(terraform.tfstate,上次记录的已知状态)、以及(丙)通过调用云端API实时查询到的基础设施当前实际状态进行三方比对,生成一份详尽的执行计划书,明确告知接下来将新增(+)、原地修改(~)、销毁(-)或销毁重建(-/+)哪些资源。
  3. terraform apply:在人工或流水线审查确认执行计划无误后,正式调用云厂商API执行变更操作,并将变更后的最新资源ID与属性快照写入状态文件。

状态文件(terraform.tfstate)的作用与内部结构

Terraform之所以能够施展声明式管理的魔法,其全部秘密都藏在**状态文件(terraform.tfstate)**之中。

当你在代码里写下resource "aws_instance" "web" { ... }时,面对AWS账号下正在运行的数百台EC2实例,Terraform究竟是如何知道这段代码块具体对应哪一台物理实例(比如i-0a1b2c3d4e5f)的呢?原因就在于,当第一次执行apply成功创建资源时,Terraform会将云端返回的真实资源物理ID及其各项属性值,像记账本一样以JSON格式完整记录在terraform.tfstate文件中。

状态文件承担着三项决定性的核心职责。

核心职责 详细说明 当状态文件损坏或丢失时引发的后果
代码与真实云资源的映射 将.tf代码中的逻辑地址(如aws_db_instance.main)与云端的物理ID(如arn:aws:rds:...)进行1:1绑定 Terraform不知道生产数据库已经存在,试图重新创建一个同名实例,导致命名冲突报错或产生重复计费资源
维护资源依赖关系图 记录哪些资源引用了哪些其他资源,从而在销毁或替换时精准计算出先后执行顺序 当从代码中删除某个代码块时,无法追踪应当按什么安全顺序清理底层关联资源
性能优化与变更检测基线 保存上一次从云端API同步到的资源属性快照 每次执行计划都不得不全量扫描云账号下的所有资源,且失去判断配置漂移(Drift)的基准线

正因为状态文件是整个基础设施的心脏账本,绝对禁止用文本编辑器手动打开并修改其中的JSON内容,也绝对不能仅将其保存在个人笔记本电脑的本地磁盘上、靠聊天软件在团队成员之间传来传去。

远程后端(Remote Backend)与状态锁定(State Locking)

一个人做实验练习时,让terraform.tfstate生成在本地文件夹里尚且可行;但只要团队有两人以上协作,或者接入了CI/CD自动化流水线,**远程后端(Remote Backend)与状态锁定(State Locking)**就不再是可选项,而是强制性的安全底线。

试想一下,如果工程师A和工程师B在各自的电脑上针对同一套基础设施同时敲下了terraform apply,会发生什么?进程A读取到的状态与进程B读取到的状态会在写入时发生相互覆盖(竞态条件Race Condition),导致其中一方创建的云资源从状态文件中凭空消失(变成无人纳管的孤儿资源),甚至直接把状态JSON文件写坏。

为了彻底杜绝并发冲突,必须将状态文件统一存放在开启了版本控制与静态加密的中央远程存储(如AWS S3、Terraform Cloud、GCS)中,并且强制规定:只要有任何一个人或流水线正在执行plan/apply,就立即对状态文件施加状态锁定(State Locking),阻止其他并发操作介入。

terraform {
  backend "s3" {
    bucket       = "company-prod-tfstate-apne2"
    key          = "network/prod/terraform.tfstate"
    region       = "ap-northeast-2"
    encrypt      = true
    use_lockfile = true
  }
}

对于AWS S3后端而言,有新旧两种状态锁定实现方式,在考试与实际工作中都需要熟练掌握:

  1. 传统方式(dynamodb_table):在S3存储桶之外,额外创建一张以LockID(字符串分区键)为主键的DynamoDB表,利用DynamoDB的条件写入(Conditional Write)特性来管理分布式锁。这一方式在存量基础设施与老版本代码中依然广泛使用。
  2. 现代S3原生锁定方式(use_lockfile = true):随着AWS S3原生支持条件写入(通过If-None-Match请求头实现对象的原子级创建),在最新版Terraform中无需再额外部署和维护DynamoDB表,只需开启use_lockfile = true选项,即可直接在S3存储桶内原子化地生成.tflock锁文件来安全地锁定状态。

与此同时,存放状态文件的S3存储桶必须强制开启版本控制(Bucket Versioning)、服务端加密(SSE-S3或SSE-KMS)以及全面屏蔽公共访问(Block Public Access)。这样即便因为极端意外导致状态文件被错误覆盖,也能立刻利用S3对象的历史版本一键恢复。

注意

有时因为CI流水线异常中断导致状态锁未能自动释放,有些工程师既不向同事核实当前是否有人正在执行apply,就盲目加上-lock=false参数强行运行,或者随手执行terraform force-unlock <LOCK_ID>。要知道,-lock=false等于直接拆除并发安全护栏,而force-unlock则必须且只能在100%确认没有任何其他进程正在运行的前提下作为最后手段谨慎使用。

模块(Module)设计与版本锁定

当基础设施代码膨胀到数千行时,相同的资源组合模式(例如:VPC + 子网 + 安全组,或者标准S3存储桶 + KMS加密 + 访问日志配置)会在多个地方反复出现。Terraform的**模块(Module)**正是将一组强相关的资源封装为如同编程语言函数一般可复用包的抽象单元。

  • 输入变量(variables.tf,variable块):由模块外部调用方传入的参数值。可以通过声明type与validation校验块,在输入了不允许的地域代码或错误的CIDR格式时,直接在plan阶段拦截报错。
  • 局部值(locals块):仅在模块内部计算并复用的常量或组合表达式,外部无法直接覆盖。
  • 输出值(outputs.tf,output块):模块创建完资源后,向顶层(Root)模块或其他下游模块暴露的返回结果(如生成的资源ID或域名地址)。

在调用外部公开注册表(Registry)或企业内部Git仓库中的模块时,最重要的一条安全与稳定性原则是版本锁定(Version Pinning)。

module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "5.16.0"

  name = "prod-vpc"
  cidr = "10.20.0.0/16"
}

如果不指定版本号或使用了过于宽泛的版本范围,某天当你仅仅为了修改一行无关紧要的参数而执行terraform init -upgrade时,模块可能会自动升级到大版本(Major Version),进而改变底层资源的命名与结构,导致线上生产资源被整批销毁重建(-/+)。因此,在生产环境中必须将模块固定到确切的版本号(或经过验证的内部Git提交SHA与Tag),并且务必将提供程序依赖锁文件.terraform.lock.hcl提交到Git仓库中,从而强制所有开发人员与CI运行器均使用经过完全相同加密哈希校验的Provider二进制插件。

环境隔离:按目录分离状态对比工作区(Workspace)

当使用Terraform同时管理开发(dev)、预发(staging)与生产(prod)三套环境时,应当如何组织代码与状态结构?主要有两种模式,必须准确辨析二者的优劣与适用边界。

对比维度 按目录与后端物理分离(envs/dev、envs/prod) 单一目录 + CLI工作区(terraform workspace)
状态文件隔离程度 完全独立的S3存储桶或路径,可按环境配置相互隔离的IAM权限 共用同一个后端存储桶,仅在内部按env:/dev/...、env:/prod/...分子路径存放
对代码与版本差异的包容度 可先在staging目录升级测试新版模块,观察数日稳定后再同步升级prod目录 所有工作区强制共享完全相同的.tf代码与模块版本
人为误操作风险 身处envs/dev目录下时,从物理机制上绝无可能误触prod环境的资源 极易因忘记检查当前激活的Workspace,误把prod当成dev而执行destroy或apply
推荐使用场景 生产环境与非生产环境之间的永久性安全隔离(标准最佳实践) 单个开发者按PR临时拉起并销毁短生命周期测试环境

结论非常明确:生产环境(prod)必须采用独立的目录、独立的状态文件后端,并尽可能使用独立的云账号(AWS Account / 项目)与专属IAM角色,从物理与逻辑两个层面彻底划清安全边界。将通用架构抽取为可复用模块(如modules/app-stack),再由envs/stage/main.tf与envs/prod/main.tf各自调用该模块并传入专属的后端配置与锁定的版本号,才是最稳妥的企业级目录架构。

精准解读terraform plan与阻断破坏性变更(prevent_destroy)

在基础设施即代码领域,90%以上的严重生产事故都源于工程师没有仔细阅读terraform plan输出的警告符号,就习惯性地敲下yes或加上-auto-approve强行推进。在plan输出报告中,资源名称前方出现的以下四种符号必须烂熟于心。

符号 含义 云端实际发生的操作 风险等级
+(Create) 新建资源 根据代码中新增的定义,在云端创建新资源 低(需核对费用与配额限制)
-(Destroy) 彻底销毁 将已从代码中移除的资源从云端永久删除 极高(导致数据永久灭失)
~(Update in-place) 原地修改 不删除现有资源,仅通过API直接修改其配置属性(如标签、安全组规则、扩容) 中(需确认是否会引起短暂闪断)
-/+ 或 +/-(Replace) 销毁后重建(替换) 先将现有资源彻底删除(-),再创建一个全新资源(+)。资源ID与内部存储的数据将全部归零! 致命(导致数据库、磁盘卷、固定IP丢失)

为什么我明明只在代码里修改了一行属性,出来的却不是~(原地修改),而是可怕的-/+(销毁后重建)呢?这是因为云厂商的底层API不支持在现有实例上直接修改该项属性(即不可变属性Immutable Attribute)。例如,修改AWS RDS数据库的实例标识符或静态加密开关、修改EC2实例所属的子网(subnet_id)或镜像AMI、或者修改S3存储桶的名称(bucket),都无法在保留原资源的前提下原地完成,因此Terraform会标注# forces replacement(强制替换)注释并亮出-/+符号。

为了在代码层面从根源上防止有状态的核心资产(生产数据库、持久化存储桶、加密KMS主密钥、核心VPC)因人为疏忽被删除或替换(-/+),必须为其配置**lifecycle生命周期块中的prevent_destroy = true**保护锁。

resource "aws_db_instance" "production" {
  identifier     = "aidan-prod-postgres"
  engine         = "postgres"
  instance_class = "db.r6g.large"

  lifecycle {
    prevent_destroy = true
  }
}

一旦写上这段声明,当有人不小心修改了数据库的不可变属性触发替换(-/+),或者误删了该资源代码块时,Terraform会在plan阶段立刻抛出致命错误(Instance cannot be destroyed)并强制中止流程。此外,对于无状态Web服务器或TLS证书这类需要实现零停机无缝替换的资源,可以配置create_before_destroy = true(先创建好新资源并挂载成功后,再销毁旧资源,输出符号显示为+/-);而对于会被外部自动扩缩容组件(Auto Scaler)动态调整的属性(例如当前容器实例数量desired_capacity),则应配置ignore_changes = [desired_capacity],避免Terraform在每次发布时把动态扩容出的实例又强行缩回去。

零停机代码重构与存量资源纳管:moved块与import块

在维护整理基础设施代码的过程中,我们经常需要将某个资源的名称从不够规范的aws_s3_bucket.old_name重命名为aws_s3_bucket.assets,或者把原本散落在根目录下的资源迁移到子模块(如module.storage.aws_s3_bucket.assets)内部。

此时,如果你只是在.tf文件里直接改掉资源名字然后运行terraform plan,会看到令人冷汗直流的输出:Terraform根本不知道你是在“改名”,它会机械地认为**“旧名字(old_name)的资源不要了,应当彻底删除(-);同时应当新建(+)一个叫新名字(assets)的资源”**!如果直接执行apply,正在线上运行的S3存储桶或生产数据库就会当场灰飞烟灭。

在早期版本中,工程师不得不打开终端手动输入terraform state mv命令来修改状态文件,但这种命令行操作无法通过Git PR进行代码审查,也很难与其他协作者及CI流水线保持同步。在现代Terraform中,标准做法是在代码里直接声明**moved块**来实现零停机安全重构。

moved {
  from = aws_s3_bucket.old_name
  to   = module.storage.aws_s3_bucket.assets
}

将这段moved块随代码一同提交后,运行terraform plan时Terraform就会明白这只是一次逻辑地址搬迁,仅在状态文件内部安全地更新映射路径,而云端的真实物理资源连1毫秒的闪断都不会发生。

同样地,当需要将过去在云控制台里用鼠标手动创建的存量云资源纳入Terraform代码管控时,应当使用声明式的**import块**。

import {
  to = aws_s3_bucket.legacy_uploads
  id = "company-legacy-uploads-prod"
}

写出import块后,可以配合terraform plan -generate-config-out=generated.tf自动生成资源配置骨架,并仔细调整.tf代码直至运行plan显示属性差异(~或-/+)为0项(No changes),最后执行apply,即可在不触碰线上存量资源一分一毫的前提下将其平稳写入状态文件。

状态文件的明文机密暴露风险与敏感变量管理

在Terraform安全领域,存在着一个流传极广却极其危险的认识误区: 许多人以为“只要给变量或输出加上sensitive = true,密码在terraform.tfstate状态文件里也会被自动加密或隐藏”——这一想法完全错误!

sensitive = true所做的唯一一件事,仅仅是在你运行terraform plan与terraform apply时,将终端屏幕(控制台标准输出日志)上的具体值替换为(sensitive value)掩码字样。由于Terraform在下一次运行plan时必须知道密码的原始值才能比对属性是否发生改变,所有作为普通资源属性传入的数据库密码、TLS私钥、API令牌,在**terraform.tfstate JSON文件内部全部都是以一丝不挂的明文(Plaintext)形式存储的**。

因此,为了守住状态文件的机密安全,必须同时落实以下四项防御原则:

  1. 在任何情况下都绝对禁止将terraform.tfstate、terraform.tfstate.backup以及.terraform/目录提交到Git仓库中(必须写入.gitignore)。
  2. 对远程后端(如S3)存储桶本身强制开启KMS等服务端静态加密(encrypt = true),并将读取状态文件的IAM权限严格收紧至仅供部署流水线与极少数核心管理员角色使用。必须清醒地认识到:拥有状态文件的读取权限,就等于拥有了整套基础设施内部所有机密值的明文读取权限。
  3. 避免在Terraform代码中直接生成明文密码或通过普通变量传递密码,而是优先启用云厂商托管的主密码管理功能(例如AWS RDS的manage_master_user_password = true,由RDS直接在Secrets Manager中托管轮换),或者让应用程序在运行时直接从Secrets Manager、Parameter Store或HashiCorp Vault中动态拉取机密,从源头上避免密码进入状态文件。
  4. 充分利用新版Terraform(1.10+)支持的临时(Ephemeral)变量与资源(ephemeral = true、ephemeral块)以及只写属性(Write-only attributes,如password_wo)。这类值仅在命令执行的内存生命周期内读取并传递给目标API,绝对不会被写入状态文件(.tfstate)或执行计划文件(tfplan)中。

配置漂移(Drift)检测与修复流程

所谓配置漂移(Configuration Drift),是指代码(.tf)与状态文件所记录的基础设施状态,同云端正在运行的真实基础设施状态之间出现了不一致的偏差。

为什么会发生配置漂移?最常见的诱因是在凌晨突发故障抢修时,值班工程师为了尽快恢复服务,直接登录AWS或Cloudflare控制台,用鼠标手动修改了安全组端口、升级了实例规格、调整了DNS记录或新增了WAF拦截规则,但在故障平息后却忘记将这些改动同步回写到Terraform代码中。

如果放任配置漂移不管,哪怕过了一个月,当另一名同事为了发布一项毫不相干的小功能而运行terraform apply时,Terraform就会发现控制台里的手动热修复配置“与代码定义不符”,从而毫不留情地将其覆盖回旧状态(Revert),导致一个月前的线上故障当场复发。

应对配置漂移的标准闭环流程如下:

  1. 定期自动化漂移检测:在CI定时调度任务(例如每天早上9点运行的GitHub Actions Cron)中执行terraform plan -detailed-exitcode(返回码0代表无任何变更,1代表执行出错,2代表检测到漂移或存在待应用的差异)或terraform plan -refresh-only。一旦发现云端存在未经代码提交的变动,立即通过Slack或工单系统发出告警。
  2. 甄别变更原因并执行双轨修复:
    • 属于未经授权的误操作或临时测试残留:直接以当前.tf代码为准执行terraform apply,强制将云端状态回滚收敛至代码所定义的标准状态。
    • 属于必须保留的合理紧急热修复(Hotfix):首先修改.tf代码(若手动新建了独立资源则配合import块),使其与控制台中的真实配置完全一致;随后运行terraform plan,**确认输出显示没有任何额外变更(No changes. Your infrastructure matches the configuration.)**后,通过PR提交合并至主分支。

基于GitHub Actions OIDC的无静态密钥(Keyless)CI/CD流水线

在过去,为了让GitHub Actions自动执行Terraform向AWS部署基础设施,通常的做法是在AWS IAM中创建一个专用的IAM用户(User),生成长期有效的静态访问密钥(AWS_ACCESS_KEY_ID与AWS_SECRET_ACCESS_KEY),然后将其复制粘贴到GitHub Secrets中。这种传统做法存在致命的安全隐患:一旦生成的静态密钥永不过期,只要因为CI日志打印失误或第三方Action供应链投毒导致密钥泄露哪怕一次,攻击者就能在任何时间、任何地点彻底接管你的整个云账号;而且定期手动轮换这些密钥也极其繁琐。

现代云安全的行业标准是彻底消灭长期静态密钥,全面改用基于OIDC(OpenID Connect)的短期令牌交换机制(AssumeRoleWithWebIdentity)。

其完整运作流程如下:

  1. 在AWS IAM中将GitHub的OIDC令牌签发方(https://token.actions.githubusercontent.com)注册为受信任的身份提供方(Identity Provider)。
  2. 为GitHub Actions工作流授予permissions: { id-token: write, contents: read }权限。当工作流运行时,GitHub会自动签发一枚带有数字签名的短期OIDC JWT令牌,其中明确记录了“本次请求确实来自org/repo仓库的main分支下正在运行的工作流”。
  3. 工作流携带这枚JWT向AWS STS发起AssumeRoleWithWebIdentity请求;AWS在严格核对IAM角色的信任策略(Trust Policy)条件无误后,当场签发一组有效期仅约1小时的临时安全凭证。整个过程中,根本不存在任何需要静态保存的长期密钥!

在考试与生产实践中,最关键的考点在于如何将IAM角色信任策略中的Condition(条件)子句收紧到最小权限范围。

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
      },
      "Action": "sts:AssumeRoleWithWebIdentity",
      "Condition": {
        "StringEquals": {
          "token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
          "token.actions.githubusercontent.com:sub": "repo:acme-corp/infra-live:ref:refs/heads/main"
        }
      }
    }
  ]
}

如果在编写信任策略时漏掉了对sub(Subject,主体声明)条件的校验,那么全世界任何GitHub用户的任何仓库工作流都能冒领并使用你公司的AWS IAM角色!不仅如此,如果图省事用StringLike配置成了"repo:acme-corp/infra-live:*"这样允许所有分支扮演拥有生产环境apply权限的角色,那么任何一名普通开发者只需在特性分支(Feature Branch)里偷偷修改工作流文件(.github/workflows/deploy.yml)并提一个PR,就能在无需任何人审批的情况下直接拿到生产环境的最高管理员权限。因此,必须严格执行以下PR阶段plan与合并main后apply的角色分离原则。

流水线阶段 触发时机 扮演的IAM角色与sub约束条件 授予的权限范围
PR审查验证(plan) 创建或更新Pull Request时 terraform-planner角色(限定为pull_request或专用的plan环境) 仅限只读权限(Read-Only) + 状态文件锁定与读取权限;仅将plan结果发布至PR评论区
生产环境变更(apply) 通过代码审查并合并至main主分支后 terraform-applier角色(严格限定为ref:refs/heads/main或受审批保护的environment:production) 实际创建、修改、销毁云资源以及写入更新状态文件的完整权限

深入阅读

基于PR的基础设施自动化(Atlantis模式)的攻击面与计划文件完整性

“只要提交PR就自动运行terraform plan并把结果贴到评论区,审查员点击批准后就自动执行apply”——这种GitOps工作流虽然极为顺畅,但如果站在攻击者的视角审视,其中却潜藏着极其凶险的安全陷阱。

请务必记住:terraform plan绝不仅仅是一个静态语法检查器! 在执行plan的过程中,Terraform会实际下载并运行外部模块与Provider插件二进制代码、执行data "external"外部数据源脚本、求值本地函数表达式,并将远程状态文件(包含全部明文机密)完整加载到内存中。

如果允许来自外部人员派生的Fork仓库PR,或者尚未经过代码审查的任意分支代码,在提交PR的瞬间就自动挂载生产环境凭证去运行terraform plan,会发生什么后果?恶意提交者甚至根本不需要等到apply阶段——他只需要在PR里修改一行.tf代码,添加一个读取状态文件机密并将其通过HTTP请求发送至外部Webhook服务器的数据源(Data Source),在自动运行plan的那一刻,生产环境状态文件里的全部机密就已经被洗劫一空了!

因此,安全的PR自动化流水线必须设立三道严密防线:

  1. 针对来自外部Fork仓库的PR,严禁自动触发任何携带云端凭证的工作流。
  2. 供PR阶段使用的plan角色仅授予不可修改资源的最小只读权限;同时,凡是修改了工作流定义文件(.github/workflows/*)、Provider配置或后端设置本身的PR,必须通过GitHub Environments的必选审查员(Required Reviewers)人工审批后才允许执行。
  3. 为了防止在审查完plan输出之后、到真正执行apply之前的间隙里混入其他未受审变更(即检查时与使用时不一致TOCTOU漏洞),应当使用terraform plan -out=tfplan.binary生成确定性的二进制执行计划产物(Plan Artifact),并在校验其加密哈希无误后,通过terraform apply tfplan.binary确保云端只执行刚刚审查通过的那份精确计划(注意:tfplan.binary文件内部同样包含明文机密值,绝对禁止上传至公开的构建产物存储区)。

在大规模基础设施中缩小状态文件的爆炸半径(Blast Radius)

在项目初期,团队很容易图省事把网络(VPC)、数据库(RDS)、容器或应用集群以及DNS/Cloudflare边缘配置统统塞进同一个文件夹、共用唯一一个terraform.tfstate状态文件(即单体式状态Monolithic State)。

然而,一旦纳管的云资源数量增长到数百个,单一状态文件就会引发三大严重顽疾:

  • plan执行极度缓慢且频繁撞上API速率限制:每次运行plan,Terraform都不得不调用云厂商API逐一刷新数百个资源的当前状态,动辄耗时10分钟以上,甚至直接触发云API的限流报错(Rate Limit Exceeded)。
  • 状态锁争用瓶颈:当前端工程师仅仅为了微调一条CDN缓存规则而运行plan时,数据库工程师就会被状态锁死死挡在门外,无法进行任何操作。
  • 爆炸半径(Blast Radius)失控:一旦某人改错了应用层的一行配置导致状态异常,底层核心VPC与生产数据库也会被绑在同一条船上同时面临风险。

破局之道在于按照变更频率、生命周期(Lifecycle)以及归属团队,将状态文件按架构层级拆分开来(State Segmentation)。例如,将其拆分为三个独立的目录与状态文件:(1)一年到头几乎不变的01-network(VPC、子网、ZTNA连接器);(2)以数据绝对安全为第一要务的02-data(RDS、S3、KMS);以及(3)每天都要发布变更数次的03-app-edge(Cloudflare规则、应用路由、Workers)。当下层配置需要引用上层创建的VPC ID或子网ID时,切忌硬编码字符串,而是通过terraform_remote_state数据源(或通过SSM Parameter Store / 基于标签的data查询)建立只读跨层引用,从而将任何失误的爆炸半径死死限制在各自独立的层级内部。

工作场景

松光出行:资源重命名险些引发生产数据库被删与moved及prevent_destroy救场

运营AI物流调度平台的“松光出行”安排一名DevOps工程师对早期匆忙编写的Terraform代码进行规范化重构。该工程师将根模块下原本命名随意的生产数据库资源aws_db_instance.db1重命名为更加规范的aws_db_instance.logistics_primary,同时将其磁盘存储容量从100GB扩容至200GB,随后提交了PR。

CI流水线输出的terraform plan汇总行赫然写着:Plan: 1 to add, 0 to change, 1 to destroy.。负责代码审查的同事以为这只是一次普通的磁盘扩容,没有仔细展开查看明细日志便直接点击了批准。万幸的是,在执行最终apply前的一刻,值班资深架构师顺手翻看了一眼计划详情,瞬间惊出一身冷汗:Terraform竟将这次资源重命名解析成了“先将存有数十万条实时调度记录的现有生产数据库(db1)彻底销毁删除(-),再新建(+)一个空空如也的新数据库(logistics_primary)”!如果刚才直接点击了应用,整座生产数据库将在几分钟内化为乌有。

团队立即紧急中止发布,并连夜落实了三项加固措施:

  1. 利用moved块实现零停机逻辑地址迁移:在代码中补充声明了moved { from = aws_db_instance.db1, to = aws_db_instance.logistics_primary }。再次运行terraform plan后,可怕的1 to destroy, 1 to add彻底消失,取而代之的是在状态文件内部安全更新资源地址,并仅对磁盘容量执行原地修改(~,1 to change)的安全执行计划。
  2. 强制开启prevent_destroy保护锁:为所有生产数据库、核心S3存储桶以及KMS密钥资源统一加上了lifecycle { prevent_destroy = true }。从此以后,哪怕有人再次因误改资源名或触碰不可变属性而触发替换(-/+),流水线都会在plan阶段直接报错拦截。
  3. CI PR评论区高危变更醒目告警:升级了PR评论机器人逻辑,只要检测到计划中包含哪怕1项to destroy或must be replaced(-/+),就自动在PR顶部拉起红色高危警告横幅,并强制要求基础设施负责人进行二次人工签批。

多温金融科技:用GitHub OIDC终结离职员工静态AWS密钥隐患并抚平配置漂移

金融数据分析企业“多温金融科技”在年度安全合规审计中被通报了两项高危整改项。第一,GitHub Actions中配置的AWS_SECRET_ACCESS_KEY竟然是两年前就已离职的一名工程师名下的长期IAM用户静态密钥,且两年来从未轮换过。第二,上个季度在抵御DDoS攻击期间,安全团队直接登录AWS控制台手动修改了12处WAF与安全组规则,事后却一直没有同步回Terraform代码中,导致研发团队因害怕覆盖掉安全规则,整整两个月不敢执行terraform apply。

基础设施团队用一周时间按部就班地彻底根治了这两大顽疾。

整改课题 实施步骤 验证结果
1. 吸收抚平配置漂移 运行terraform plan将控制台手动修改的12处差异全部列出 → 会同安全团队逐一评审,将需要长期保留的10处规则原样补写进.tf代码(手动新建的独立资源则通过import块纳管),并清理掉2处已过期的临时规则 再次运行terraform plan实现No changes零差异对齐;随后配置每日09:00定时工作流运行plan -detailed-exitcode持续巡检
2. 规范S3状态锁定 将原先分散的状态文件统一迁移至开启了版本控制与KMS加密的S3后端(配置use_lockfile = true),并将状态文件读取权限收紧至最小专属角色 模拟双人并发执行时,通过.tflock锁文件实现即时阻塞与排队保护
3. 切换至OIDC无密钥体系 在AWS IAM中注册GitHub OIDC身份提供方 → 分别创建供PR使用的只读角色(plan)与严格绑定ref:refs/heads/main的生产变更角色(apply) → 停用并彻底删除旧的长期IAM用户静态密钥 GitHub Secrets中存储的云端长期静态密钥数量降为0个,所有部署均通过有效期仅1小时的动态短期令牌安全执行

易错点

  1. “我已经给密码变量加上了sensitive = true,所以把terraform.tfstate文件放到公司共享网盘或提交到Git仓库里别人也看不到密码。” 这是最危险的致命误区。sensitive = true仅仅遮蔽终端屏幕上的标准输出,而在状态文件(.tfstate)内部,所有敏感属性值依然是以纯明文形式记录的。状态文件严禁提交至Git,必须存放在加密的远程后端中并实施严格的IAM访问控制。
  2. “在terraform plan输出里看到-/+符号,只是代表稍微修改一下配置属性,可以放心执行apply。” 原地修改的符号是~。而-/+代表的是销毁后重建(Replace,即先把现有资源删光再新建一个),一旦出现在数据库或存储桶上,就意味着数据将被彻底清空。
  3. “想给资源改个名字时,只要在.tf文件里直接把资源标识符改掉再apply,Terraform就会自动帮我把名字改过来。” 如果只在代码里修改资源标识符,Terraform会将其视为删除旧资源(-)并创建新资源(+)。重构改名或跨模块移动资源时,必须在代码中配合声明moved块来迁移状态映射。
  4. “只要用terraform workspace new prod新建一个工作区,就能把开发环境和生产环境完美隔离开来。” CLI工作区共享完全相同的代码文件与同一个后端存储桶配置,且操作人员极易因看错当前所处的工作区而在生产环境误执行破坏性命令。生产环境应当采用独立的目录、独立的状态后端与独立的IAM角色进行物理隔离。
  5. “配置GitHub Actions OIDC时,只要在AWS IAM信任策略里写上aud: sts.amazonaws.com条件就已经足够安全了。” 如果不校验sub(即具体的GitHub组织/仓库名称以及分支或环境名称)声明,全世界任何其他GitHub仓库的工作流都可以冒用并扮演(AssumeRole)你的AWS IAM角色。
  6. “遇到紧急故障时在云控制台里手动改掉的配置,Terraform在下一次发布时会自动识别并帮我保留下来。” Terraform始终以代码(.tf)作为唯一事实来源(Source of Truth)。在控制台中手动修改产生的配置漂移(Drift)如果不及时回写到代码中,在下一次执行apply时就会被无情地覆盖抹除。

要点速览

核心主题 考试与工程实践中必须牢记的核心准则
声明式模型与State .tf代码声明期望达到的最终状态,terraform.tfstate则负责维护代码逻辑地址与真实云资源物理ID之间的映射
远程后端与状态锁定 状态文件必须存放在开启了版本控制与静态加密的S3等远程后端中,并通过use_lockfile = true(或DynamoDB)锁定并发执行
plan符号辨析 +代表新建,-代表删除,~代表原地修改(In-place),-/+代表销毁后重建(Replace,存在极高的数据丢失风险)
防误删保护锁 对核心数据库、存储桶与密钥必须配置lifecycle { prevent_destroy = true },在plan阶段直接拦截任何删除或替换操作
零停机重构与纳管 代码内的资源改名或模块路径迁移使用moved块,将控制台手动创建的存量资源纳入代码管控则使用声明式import块
状态文件机密安全 sensitive = true仅遮蔽终端日志,状态文件内仍为明文;严禁提交Git,需启用后端KMS加密并利用Ephemeral/只写属性避免密码落盘
环境隔离架构 生产环境切勿依赖共享Workspace,而应通过独立目录、独立状态文件以及独立的云账号与IAM角色来严格限制爆炸半径
配置漂移(Drift)应对 利用定时plan -detailed-exitcode自动巡检;未经授权的改动通过apply回滚,合理的热修复则先同步至代码直至No changes
CI/CD OIDC无密钥化 摒弃长期静态密钥,改用AssumeRoleWithWebIdentity,并在信任策略中同时严格校验aud与精确到仓库及main分支/环境的sub声明

查阅官方文档

自测

2-1. 请解释当两名团队成员在各自的笔记本电脑上针对同一套基础设施同时运行terraform apply时会引发什么问题;在使用AWS S3作为远程后端时,为了防范此类并发事故并支持事后恢复,必须开启哪两项核心功能?

2-2. 在terraform plan输出报告中,资源名称前方的~符号与-/+符号分别代表什么含义?为了防止正在运行的生产数据库资源因人为失误被执行-/+或-操作,应当在HCL资源代码中添加怎样的配置块?

2-3. 如果直接在.tf代码中将某台线上资源的名称从aws_instance.app修改为aws_instance.api_server并立即执行apply,会发生什么后果?为了在不中断线上服务的前提下安全地完成这次改名,应当在代码中补充什么声明块?

2-4. 为什么即便已经为密码变量设置了sensitive = true,一旦将terraform.tfstate文件提交到公开的Git代码仓库,依然会导致全部机密值彻底泄露?请列出至少两项防止状态文件泄露机密的安全对策。

2-5. 在某次凌晨突发故障抢修期间,值班工程师直接登录AWS控制台在安全组中手动添加了一条端口放行规则(紧急热修复),且团队事后决定长期保留这条规则。为了防止在下一次正常运行Terraform发布时这条规则被自动删掉,应当按什么顺序处理Terraform代码并确认什么输出状态?

2-6. 架构设计题:某团队希望在GitHub Actions中彻底摒弃长期静态AWS访问密钥来运行Terraform。(1)请解释为什么必须将“提交PR时触发的工作流”与“合并至main主分支时触发的工作流”所扮演的IAM角色拆分开来;(2)在生产环境执行变更(apply)的角色的OIDC信任策略(Condition)中,必须强制校验哪两个核心声明(Claim)字段?

实操练习

练习

判读terraform plan高危变更并编写零停机重构代码(45分钟)

  1. 假设当前根模块中声明了正在运行的生产数据库resource "aws_db_instance" "legacy_db"与S3存储桶resource "aws_s3_bucket" "legacy_bucket"。
  2. 编写两个moved块,在不销毁或重建(-/+)这两个真实云资源的前提下,将其逻辑地址分别迁移至aws_db_instance.prod_primary与module.storage.aws_s3_bucket.prod_assets。
  3. 为这两个资源定义补充防止误删的lifecycle保护设置,并写出配套的S3远程后端配置块(包含encrypt = true与use_lockfile = true)。
  4. 如果在审查同事提交的PR时,看到计划输出中出现了aws_db_instance.prod_primary must be replaced (-/+)字样,请列出你必须逐一核查的3项检查清单。

完成标准: moved块中的from与to逻辑地址完全准确;完整包含prevent_destroy = true保护锁与S3原生锁定配置;明确写出了排查触发替换(-/+)不可变属性的审查要点。

练习

设计GitHub Actions OIDC最小权限信任策略(Trust Policy)(45分钟)

  1. 面向企业内部代码仓库trappist-org/cloud-infra,分别为两个AWS IAM角色(供PR使用的tf-pr-plan-role与供主分支使用的tf-main-apply-role)编写完整的信任策略JSON文档。
  2. 两个角色的Action均指定为sts:AssumeRoleWithWebIdentity,并在条件中校验aud声明是否严格等于sts.amazonaws.com。
  3. 将tf-main-apply-role的sub声明条件严格限制为仅当工作流运行在main分支(repo:trappist-org/cloud-infra:ref:refs/heads/main)或受审批保护的production环境(repo:trappist-org/cloud-infra:environment:production)时才允许匹配。
  4. 用三句话简述:为什么绝对不能在tf-main-apply-role的信任策略中使用诸如"repo:trappist-org/cloud-infra:*"这样的通配符?写出攻击者利用特性分支PR窃取生产权限的攻击路径。

完成标准: 两份IAM信任策略JSON中均准确包含aud与sub约束条件;清晰阐明了通过特性分支PR篡改工作流越权获取生产权限的攻击场景及其防御原理。