Terraform 进阶:模块化与远程 State
单文件 Terraform 跑 demo 够了,生产要模块化 + 远程 state。本文讲清 module 的输入/输出/版本、远程 state 后端(S3+DynamoDB 锁/Terraform Cloud)、state 漂移与 import、以及一份生产级 Terraform 项目结构。
本文要点
- 主题聚焦:DevOps
- 适合人群:独立开发者、全栈工程师、AI 应用开发者
- 核心价值:快速理解背景、实践路径、常见误区和工程建议
Terraform 进阶:模块化与远程 State
用一个 main.tf 把整个 AWS 生产环境写出来——VPC、子网、RDS、EKS、ALB、IAM、CloudWatch——大概率会写到 2000 行。它能跑,但等到第二个项目要用同样的网络骨架,你只能整段复制粘贴;等到团队 5 个人同时改这个文件,merge conflict 能让你改一下午;等到有人把 terraform.tfstate 提交进了 Git,你才发现整个生产环境的数据库密码已经在 Git 历史里躺了三个月。
单文件 Terraform 跑 demo 够用,上生产必须做两件事:模块化、远程 state。这两件事不是“锦上添花”,是“没做就迟早出事”的地基。这篇文章把 module 的输入/输出/版本、远程 state 后端、漂移检测与 import、敏感值保护,以及一份可直接抄的生产级项目结构一次讲透。我默认你已经会用 init / plan / apply,这一篇只讲进阶。
一、从单文件到模块化:为什么必须拆
先说清模块化解决什么。单文件 IaC 在生产里会撞上四堵墙:
- 不可复用。你给项目 A 写了一套“带 NAT 网关的 VPC + 公私子网 + 数据库子网组”,项目 B 要一模一样的,只能复制 300 行。下次发现这套子网有个 bug,你要改两个地方——然后是五个、十个。
- 不可组合。所有资源堆在一个文件,新人想看清“这套环境到底由哪几块组成”,得从第一行读到最后一行。没有边界,就没有抽象。
- 不可共享。你给公司内部沉淀的最佳实践(比如“符合等保的安全组基线”),没办法跨团队分发,最后每个团队各写一套,质量参差。
- 协作冲突。5 个人改一个 2000 行的文件,每次 PR 都在 rebase。
Module(模块)就是 Terraform 的“函数”。你把一组相关的资源封装起来,对外只暴露输入参数和输出值,内部实现细节藏起来。调用方不需要关心你内部建了几个路由表、几条 NACL,他只管传参:
module "vpc" {
source = "./modules/vpc"
cidr = "10.20.0.0/16"
azs = ["ap-northeast-1a", "ap-northeast-1c", "ap-northeast-1d"]
enable_nat = true
environment = "prod"
}
这段调用读起来就是一句话:“给我一个 /16 的 VPC、跨 3 个 AZ、开 NAT、环境标签是 prod”。内部建几个 subnet、怎么命名、怎么打 tag,都是 module 的事。好的 module 调用点,读起来像产品需求文档,不像实现细节。
模块化的收益其实就是软件工程里早就验证过的那些:复用、封装、分层、可测试。只是 IaC 圈子比应用工程晚了十年才认真对待这件事。
二、Module 的三要素:variable / output / 版本
写一个 module,本质就是写一个“有公开 API 的代码包”。三要素:输入(variable)、输出(output)、版本(source 的可复现引用)。
1. 输入:variable,但要带约束
一个生产可用的 variable,绝不能只写个名字就完事。没约束的输入,等于把 module 的正确性押在调用方的心情上。看一个反面教材:
# 反面教材:没有任何约束
variable "cidr" {
type = string
}
调用方传 "hello" 也不报错,直到 aws_vpc 资源在 apply 阶段炸掉。正确的写法要带类型、校验、默认值、描述:
# modules/vpc/variables.tf
variable "cidr" {
description = "VPC 的 CIDR 块,必须是合法的 /16~/28 私网网段"
type = string
default = "10.0.0.0/16"
validation {
condition = can(cidrhost(var.cidr, 0))
error_message = "cidr 必须是合法的 IPv4 CIDR,例如 10.0.0.0/16。"
}
}
variable "azs" {
description = "可用区列表,至少 2 个以保证高可用"
type = list(string)
}
variable "enable_nat" {
description = "是否开启 NAT 网关(生产强烈建议开)"
type = bool
default = true
}
variable "environment" {
description = "环境标签,用于所有资源的 tag"
type = string
validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "environment 只能是 dev / staging / prod。"
}
}
几个要点:description 不是装饰,terraform docs 和 IDE 悬浮提示都依赖它,写不写直接决定 module 能不能用;validation 块能把一大类低级错误从 apply 阶段提前到 plan 阶段(甚至 init),ROI 极高;敏感输入加 sensitive = true,避免在 plan 输出里被打到日志。
2. 输出:output,module 之间的通信管道
module 内部的资源对调用方是不可见的(除非显式 output)。调用方要拿到 module 创建的子网 ID、VPC ID、安全组 ID,必须靠 output 暴露:
# modules/vpc/outputs.tf
output "vpc_id" {
description = "创建的 VPC ID"
value = aws_vpc.this.id
}
output "private_subnet_ids" {
description = "私网子网 ID 列表,给 EKS / RDS 用"
value = aws_subnet.private[*].id
}
output "public_subnet_ids" {
description = "公网子网 ID 列表,给 ALB 用"
value = aws_subnet.public[*].id
}
调用方这样接:
module "vpc" { ... }
module "eks" {
source = "./modules/eks"
subnet_ids = module.vpc.private_subnet_ids # ← module 之间靠 output 串联
vpc_id = module.vpc.vpc_id
}
module 之间永远不要相互引用对方内部的 aws_xxx 资源,只通过 output 解耦。这样你以后把 EKS module 从自研换成社区版,只要 output 名字兼容,调用点一行都不用改。
3. 版本:source 决定了你到底在跑什么代码
source 是 module 的“依赖坐标”,它有几种形态,可复现性天差地别:
# 1. 本地路径:团队内部共享,跟主仓库一起版本化
source = "./modules/vpc"
# 2. Git:跨仓库共享,但 ref 不锁死就是定时炸弹
source = "git::ssh://[email protected]/myorg/tf-modules.git//vpc?ref=v1.2.0"
# 3. Terraform Registry / 私有 registry:语义化版本
source = "terraform-aws-modules/vpc/aws"
version = "5.1.0"
最大的坑是 Git source 不带 ref。我见过不止一个团队写 source = "git::...//vpc"(没 ?ref=),拉下来的是默认分支的最新代码。某天有人改了 module 的内部实现,所有用了这个 module 的环境,下次 apply 全部出 diff——而且是那种你看不懂的 diff。生产环境的基础设施代码必须可复现,?ref=v1.2.0(或 commit SHA)是底线。
Registry 的 version 用语义化版本,"5.1.0" 锁死,"~> 5.1" 允许 5.1.x 的 patch。生产我强烈建议锁死确切版本(version = "5.1.0"),升级走 PR、显式改版本号,把“依赖升级”变成一个可以被 review 的动作,而不是 terraform init 时偷偷摸摸发生。
一个
.terraform.lock.hcl文件(init 后生成)会记录 provider 的确切版本,这个文件要提交进 Git,它就是 provider 依赖的 lockfile,作用等同于 Node 的pnpm-lock.yaml。别 ignore 它。
三、远程 State:为什么本地 state 不能上生产
Terraform 靠 terraform.tfstate 这个 JSON 文件记住“我到底在云上建了什么”。这个文件是事实之源(source of truth),不是缓存、不是日志——Terraform 拿它和代码对比算 diff。
把 state 存在本地(terraform.tfstate 就在工作目录里),单兵作战没问题,一旦进入团队协作或 CI,就是一连串事故的起点:
- 协作覆盖。A 改了基础设施,state 在 A 的本地;B 在自己机器上 apply,用的还是旧 state,B 的变更会把 A 的变更覆盖掉,或者直接报错。你根本不知道谁手里的 state 是最新的。
- CI 跑不起来。流水线机器上没有你电脑里的
terraform.tfstate,plan 永远是“全量重建”,毫无意义。 - 进 Git 就是泄露。state 文件里明文存着所有资源的所有属性——RDS 密码、ALB 的 access log 桶名、IAM policy 的细节。一旦进了 Git,等于把生产环境的钥匙交出去了。
- 并发写炸 state。两个人同时 apply,两个进程同时写 state 文件,文件互相覆盖,state 损坏,从此 plan 报错、apply 报错,修复极其痛苦。
结论很硬:生产环境的 state 必须放远程后端(remote backend),统一存储、加锁、加密、控制访问。本地 state 只配跑五分钟的 demo。
方案 A:S3 + DynamoDB 锁(最主流的自托管方案)
AWS 上最常见、最便宜、最被验证的组合:S3 存 state 文件,DynamoDB 做分布式锁防并发写。这套后端本身也要用 Terraform 建一次(这叫“bootstrap”),建议放一个独立的 bootstrap/ 目录手动管,避免“后端还没建就想用后端”的鸡蛋问题。
先建好存储和锁表(一次性,可以手动 apply):
# bootstrap/main.tf —— 建后端的基础设施本身,state 可以临时存本地
resource "aws_s3_bucket" "tf_state" {
bucket = "myorg-tfstate-prod-9f3k2" # 全局唯一
}
resource "aws_s3_bucket_versioning" "tf_state" {
bucket = aws_s3_bucket.tf_state.id
versioning_configuration { status = "Enabled" } # 必开版本化,回滚全靠它
}
resource "aws_s3_bucket_server_side_encryption_configuration" "tf_state" {
bucket = aws_s3_bucket.tf_state.id
rule {
apply_server_side_encryption_by_default { sse_algorithm = "aws:kms" } # 强制 KMS 加密
}
}
resource "aws_s3_bucket_public_access_block" "tf_state" {
bucket = aws_s3_bucket.tf_state.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
resource "aws_dynamodb_table" "tf_locks" {
name = "tf-locks"
billing_mode = "PAY_PER_REQUEST"
hash_key = "LockID" # 固定这个键名,backend 约定
attribute {
name = "LockID"
type = "S"
}
}
然后业务项目里这样配置后端:
# envs/prod/backend.tf
terraform {
required_version = ">= 1.7.0"
backend "s3" {
bucket = "myorg-tfstate-prod-9f3k2"
key = "prod/web-app/terraform.tfstate" # 每个环境/每个 component 一个 key
region = "ap-northeast-1"
dynamodb_table = "tf-locks" # 指向锁表,apply 时自动获取/释放锁
encrypt = true # 强制加密(即使 S3 桶已加密,再声明一次)
}
}
几个不能省的细节:S3 必须开版本化(state 被写坏了能回滚到上一个版本,这是救命稻草);KMS 加密而不是默认 SSE-S3;public access block 全开(state 桶绝对不能被公开);key 按环境/按组件隔离,prod/web-app/、prod/db/、staging/web-app/ 各一个 key,不要把所有东西塞进一个 state 文件(炸的时候一损俱损)。
锁的工作原理:apply 前 Terraform 在 DynamoDB 写一条 LockID 记录,apply 结束删除。如果 B 这时也想 apply,发现锁被 A 占着,直接报错拒绝,从而避免并发写。这个锁只对 apply 这类写操作生效,plan 默认不抢锁——所以 CI 里跑 plan 是安全的,不会互相阻塞。
方案 B:Terraform Cloud / Enterprise
不想自己管 S3 和锁表,或者需要更完整的团队协作能力(审计日志、RBAC、VCS 联动、远程执行),就用 HashiCorp 的 Terraform Cloud(小团队免费额度够用)。后端配置换成 cloud:
terraform {
cloud {
organization = "myorg"
workspaces {
name = "prod-web-app" # 一个 workspace 对应一个 state
}
}
}
state 存在 HashiCorp 托管的加密存储里,锁、加密、版本化、审计全部内置,apply 还能在云端跑(远程执行),本地不需要 AWS 凭证。代价是依赖 SaaS、自由度低一些、组织大了要付费。我的建议:3 人以下小团队、对审计要求高、不想自己运维后端,选 Terraform Cloud;其余场景 S3 + DynamoDB 是稳妥的默认。
四、State 漂移:手改资源的代价与修复
漂移(drift) 指的是:云上资源的实际状态,和 state 文件记录的状态不一致。原因五花八门——最常见的是有人在 AWS 控制台手动改了点东西(“就加一条安全组规则,懒得走 PR 了”),也包含控制台里的紧急 hotfix、别的脚本动了资源、甚至是 AWS 那边的默认值变了。
漂移的可怕之处在于它是沉默的。代码没动、state 没动,但现实变了。等你下次 apply,Terraform 会“自信地”把现实改回代码描述的样子,把那个紧急 hotfix 干掉,然后凌晨两点告警炸了。
检测漂移:把 plan 当体检
terraform plan 是唯一的体检手段。它对比三件事——代码、state、云上现实——任何一边对不上都会出 diff。所以生产纪律是:
terraform plan -detailed-exitcode
# 退出码:0 = 无变更;1 = 出错;2 = 有变更(即检测到漂移或代码改动)
把 -detailed-exitcode 接进 CI,每天定时跑一次 plan(而不是只在 PR 上跑),退出码非 0 就发告警。这就是“漂移检测”——在你的用户和你的老板发现之前,先一步知道现实和代码对不上了。我见过团队用这套机制抓到过运维同事偷偷在控制台开端口、抓到过被攻破的 IAM key 被人加进了高危策略。漂移检测本质上也是一种安全监控。
修复漂移:两条路
检测到漂移之后,怎么修?看是“代码该迁就现实”还是“现实该迁就代码”。
路径一:现实是对的,代码该改。比如有人手动修了一个安全组规则救火,这个修是合理的。那就把这段变更反向写回 .tf,让 terraform plan 显示 No changes——这就表示代码、state、现实三者重新对齐。
路径二:代码是对的,现实该被拉回来。那个手改是个错误。直接 terraform apply,Terraform 会把云上资源改回代码描述的样子。
import:把已有资源纳管进 state
最麻烦的漂移是“云上有资源,但 state 里压根没有”——典型的“历史遗留资源要纳管”。直接 apply 会报“资源已存在”,因为 Terraform 想新建。
Terraform 1.5+ 引入了声明式 import block,这是现在推荐的做法,因为它能自动生成配置代码:
# 先声明要 import 哪些资源
import {
to = aws_s3_bucket.assets
id = "my-app-assets-prod-9f3k2"
}
import {
to = aws_db_instance.main
id = "prod-db-2024"
}
terraform plan -generate-config-out=generated.tf # 自动生成资源配置
terraform plan # 确认 No changes
# 然后把 import block 删掉,把 generated.tf 整理后并入正式 .tf
老式的命令式 terraform import aws_s3_bucket.assets my-app-assets-prod-9f3k2 也能用,但它不生成代码,你得手写配置直到 plan 显示 No changes,痛苦得多。import 成功的标志,是 terraform plan 输出 No changes. Your infrastructure matches the configuration.——在此之前都不算纳管完成。
经验法则:import 是一个“代码迁就现实”的过程,你拿到生成的配置后要逐行 review,确认它符合你的安全和命名规范,别无脑提交。自动生成的代码往往带着历史包袱(比如过时的加密配置、不必要的字段),正是整改的好时机。
五、敏感值与 State:那个文件就是密钥的金矿
这是新手最容易栽、栽了还不知道的坑:terraform.tfstate 里存着所有资源的所有属性,包括你以为“敏感”的那些。
你在 RDS 资源里写了 password = var.db_password,在 Lambda 环境变量里塞了 API_KEY = var.api_key——这些值在 state 文件里全是明文。Terraform 没法分辨哪个属性算敏感(不同 provider 的语义都不一样),它的设计就是“state 记录一切”。
很多人以为加了 sensitive = true 就安全了,这是个危险的误解:
variable "db_password" {
type = string
sensitive = true
}
sensitive = true 只影响 plan/apply 的终端输出(把值显示成 (sensitive value) 避免被打到终端日志),state 文件里依旧是明文。换句话说,它防的是“同事瞄你屏幕”,不防“state 文件被泄露”。这两件事差了一个安全等级。
真正保护敏感值的四层防线:
- state 后端强制加密。S3 的 KMS 加密、Terraform Cloud 的托管加密,保证静态存储的 state 文件不是明文。这是底线。
- state 后端严格访问控制。state 桶的 IAM 策略只放给有限的 CI 角色和少数 SRE,不是“全公司可读”。一个
Resource: arn:aws:s3:::myorg-tfstate-*/*的Allow就能把整个生产的家底泄给全公司。 - state 文件永远不进 Git。
.gitignore里加*.tfstate、*.tfstate.*、crash.log。这一行是血泪换来的,每个字都要有。 - 真正的密钥从 secret manager 读,别让 Terraform 经手。最稳的做法是 Terraform 只建“装密钥的容器”(比如
aws_secretsmanager_secret这条空壳资源),具体值由应用启动时从 Secrets Manager / Vault / SSM Parameter Store 自己拉取,Terraform 从不持有明文密钥。这样 state 里压根就没有密钥,泄露风险归零。
第四层是唯一能让你高枕无忧的方案。前三层是“降低概率”,第四层是“消除可能”。密钥不该出现在 IaC 的任何环节,这是架构原则,不是配置技巧。
六、生产级项目结构:modules/ + envs/
讲了一堆理论,最后给一份可以直接抄的生产级目录结构。这套结构的核心思想是:modules/ 是可复用的“库”,envs/ 是各环境的“组装现场”,两者职责分离。
infra/
├── modules/ # 可复用模块,纯抽象,不含任何环境信息
│ ├── vpc/
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ ├── outputs.tf
│ │ └── versions.tf
│ ├── eks/
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ └── outputs.tf
│ └── rds/
│ ├── main.tf
│ ├── variables.tf
│ └── outputs.tf
│
├── envs/ # 各环境的组装现场
│ ├── dev/
│ │ ├── backend.tf # dev 的远程 state 配置(独立 S3 key)
│ │ ├── main.tf # 组装各个 module
│ │ ├── variables.tf # dev 的具体参数
│ │ ├── providers.tf
│ │ └── terraform.tfvars # dev 的实际取值
│ │
│ └── prod/
│ ├── backend.tf # prod 的远程 state(独立 S3 key + 独立锁)
│ ├── main.tf
│ ├── variables.tf
│ ├── providers.tf
│ └── terraform.tfvars
│
├── bootstrap/ # 建 state 后端本身的代码(S3 桶 + DynamoDB 表)
│ └── main.tf
│
└── README.md
几个关键原则:
- module 里绝不写任何环境相关的东西。没有
prod这种字眼、没有硬编码的 CIDR、没有账号 ID。所有差异都通过 variable 注入,这是 module 能被复用的前提。 - 每个环境一个目录、一份独立 state。
envs/dev/backend.tf的key = "dev/terraform.tfstate",envs/prod/backend.tf的key = "prod/terraform.tfstate"。dev 和 prod 的 state 物理隔离,dev 出事炸不到 prod。 - 组装现场极薄。
envs/prod/main.tf应该几乎只有module "vpc" { ... }、module "eks" { ... }这种调用,加上 module 之间的 output 串联。真正的资源细节全在 module 里。
看一份典型的 envs/prod/main.tf:
# envs/prod/main.tf —— 只做组装,不写资源细节
module "vpc" {
source = "../../modules/vpc"
cidr = "10.20.0.0/16"
azs = ["ap-northeast-1a", "ap-northeast-1c", "ap-northeast-1d"]
environment = "prod"
}
module "eks" {
source = "../../modules/eks"
cluster_name = "prod-cluster"
k8s_version = "1.30"
vpc_id = module.vpc.vpc_id
subnet_ids = module.vpc.private_subnet_ids
node_min = 3
node_max = 20
node_desired = 5
}
module "rds" {
source = "../../modules/rds"
instance_class = "db.r6g.large" # prod 给真家伙
allocated_storage = 200
subnet_ids = module.vpc.database_subnet_ids
security_group_ids = [module.vpc.rds_sg_id]
}
这份文件读起来就是 prod 的“配置清单”:用多大的库、起几个节点、数据库什么规格。新人 10 分钟就能看懂这个环境长什么样,这是单文件时代不可想象的。要起一个新环境?复制 envs/dev/ 改名 staging/,换几个参数,apply 完事。
七、Workspace vs 目录分环境:该选哪个
最后聊一个长期争议:多环境到底用 Terraform Workspace,还是用目录分环境(上面那套 envs/dev、envs/prod)。
Workspace 的用法是这样的:
terraform workspace new dev
terraform workspace new prod
terraform workspace select prod
terraform apply # 在 prod workspace 里 apply
同一份 .tf 代码,用 terraform.workspace 这个内置变量在 workspace 之间切换。它的卖点是“一套代码跑多环境,DRY”。听起来很美,但我不推荐在生产里用它分环境,原因有三:
- state 共享同一份后端配置。workspace 把不同环境的 state 命名为
${workspace-name}/${key},但 backend 配置是同一份。这意味着 dev 和 prod 的 state 物理上可能存在同一个 S3 桶、甚至相近的 key,权限一放宽 dev 就能误读 prod。目录分环境则天然隔离。 - 配置差异被揉进三元表达式。prod 的 EKS 给
r6g.large,dev 给t3.medium,代码里就得写terraform.workspace == "prod" ? "r6g.large" : "t3.medium"。环境一多、差异一多,代码里全是if-else,可读性塌方。目录分环境则是envs/prod/terraform.tfvars一个值、envs/dev/terraform.tfvars另一个值,干干净净。 - 生产环境不该“轻易”切换。
terraform workspace select prod一条命令就能切到生产,误操作成本太低。目录分环境要cd envs/prod,至少有个物理动作的提醒,而且 prod 目录的 IAM 权限可以收紧到只有 CI 和少数 SRE 能 apply。
Workspace 真正适合的场景是临时环境、同构环境的横向复制——比如每个 PR 自动起一个 review 环境、每个开发人员一个 sandbox。这种“同一套配置、不同实例、生命周期短”的场景,workspace 的 DRY 优势才发挥出来。
结论:dev/staging/prod 这种长期存在、配置差异大、爆炸半径不同的环境,老老实实用目录分;短期、同构、海量的临时环境,用 workspace。这是 Terraform 社区这些年来收敛出来的共识,别再在这个问题上反复横跳了。
小结
- 模块化是 IaC 走向工程化的分水岭。module 把资源封装成带输入/输出/版本的“函数”,实现复用、组合、共享。一个合格的 module:variable 带约束和校验,output 是对外的稳定 API,source 用
?ref=或锁死的version保证可复现。 - 生产 state 必须上远程后端。本地 state 在团队协作和 CI 下必出事——协作覆盖、并发损坏、进 Git 泄露。S3 + DynamoDB 锁是自托管的主流方案(版本化 + KMS 加密 + public block 一个都不能少),Terraform Cloud 是免运维的托管方案。
- 漂移是沉默的杀手,plan 是唯一的体检。定期
terraform plan -detailed-exitcode做 CI 巡检,退出码非 0 就告警;发现漂移后,要么把现实反向写回代码,要么 apply 把现实拉回代码;已有资源纳管用声明式importblock,对齐到No changes才算完成。 - state 文件是密钥的金矿,必须多层防护。
sensitive = true只防终端输出、不防 state 泄露;真正的防线是后端加密 + 严格 IAM + 永不进 Git + 密钥走 secret manager 不经手 Terraform。最后一层是唯一能让你高枕无忧的。 - 项目结构:modules/ 当库、envs/ 当组装现场,每环境一份独立 state。dev/staging/prod 用目录分环境,别用 workspace——workspace 留给临时、同构、海量的 review/sandbox 环境。
一句话收尾:Terraform 上生产的成熟度,不取决于你写了多少
.tf,而取决于你把 state 藏得多深、把模块封得多干净、把环境隔得多彻底。单文件能跑只是起点,模块化、远程 state、漂移纪律、项目结构,才是把 IaC 从“能跑”推到“敢上线”的真正工程化。
Related
相关推荐
文章
Docker 部署 Next.js 社区项目的注意事项
围绕 DevOps、Next.js 的技术趋势、工程实践和独立开发视角。
文章
OpenTelemetry 如何帮助你观察 AI 应用?
围绕 DevOps、AI 的技术趋势、工程实践和独立开发视角。
文章
长运行工具调用:Operation、Poll 与 Cancel 的终态协议
初始请求超时后,工具可能仍在服务端运行;发出取消也不等于副作用已停止。本文为单次长运行工具调用建立 operation 身份、轮询水位、取消竞态和唯一终态接受点。
文章
Agent 记忆删除传播:从用户遗忘请求到可验证清除
删除主表一行不会清掉向量、摘要、缓存和恢复后的备份。本文用删除状态机、代际墓碑、派生血缘与端到端 canary 验证真正遗忘。