每次 apply 服务都会重启
目标
用触发器来控制附加在声明式工具中的副作用(重启、一次性任务)何时发生,确认失败的任务所留下的 tainted 状态和替换顺序,再用两次应用测试证明幂等性。
为什么重要
写文件无论做多少次,结果都相同,但重启服务或数据迁移每运行一次就会留下痕迹。因此“只有某些内容变化时才运行”这个条件是幂等性的核心,在声明式工具中,这个条件用触发器的值来表达。如果在触发器中放入每次都会变化的值,计划就永远不会为空,CI 的两次应用测试就会失败;失败的任务会被标记为 tainted,在下一次应用时再次运行,所以任务本身必须对重试是安全的。替换按“先停止、再启动”的顺序发生,这一点也是零停机设计的起点。
步骤
- 在
/root/iac-prov/noisy/main.tf中放置port变量(默认 8080)、向out/app.conf写入port=<값>(占位符为值)的local_file.conf,以及triggers_replace = timestamp()的terraform_data.restart。restart 在创建时通过 local-exec 向out/restarts.log追加一行restart。执行 init 之后,把 apply 执行两次。评分器会在该目录中重新生成计划,检查是否每次都出现变更。 - 让
/root/iac-prov/app/main.tf与第 1 步相同,但把 restart 的触发器改为配置文件内容的哈希(local_file.conf的content_sha256)。执行 init 后,执行两次 apply,并确认计划是干净的。评分器会在副本中以另一个 port 值生成计划,检查 restart 是否被替换;如果是相同的值,则检查是否什么都不会改变。 - 统计
/root/iac-prov/app/out/restarts.log的行数,再执行两次 apply 并重新统计,用一行before=<수> after=<수>(占位符均为数量)写入/root/iac-prov/app/proof.txt。评分器还会在副本中运行两次 apply,确认记录确实没有增加。 - 在
/root/iac-prov/app/terraform.tfvars中写入port = 8081并 apply。out/app.conf必须变成port=8081,并且 restarts.log 必须比 proof.txt 中的 after 恰好多一行。 - 在
app/main.tf中加入terraform_data.migrate。创建时通过 local-exec 检查是否存在${path.module}/ready.flag,如果存在,就向out/migrate.log追加一行migrated(不存在则命令失败)。在没有 ready.flag 的状态下 apply,把输出保存到/root/iac-prov/app/taint.txt,接着把tofu plan的输出保存到/root/iac-prov/app/taint-plan.txt。评分器还会在副本中不提供 ready.flag 重新创建 migrate,检查它是否变成 tainted。 - 创建
/root/iac-prov/app/ready.flag并 apply。migrate 会被替换,这次必须成功,之后的计划必须是干净的。restart 在此过程中不应再次运行(restarts.log 的行数与第 4 步之后相同)。 - 在
app/main.tf中加入worker_version变量(默认v1)和terraform_data.worker。input 和 triggers_replace 都是该变量;创建时向out/worker.log追加start <버전>(占位符为版本),并通过when = destroy的 provisioner 追加stop <버전>(占位符为版本)。用 v1 应用之后,在 terraform.tfvars 中加入worker_version = "v2"再次应用。worker.log 的顺序必须是start v1、stop v1、start v2。 - 创建
/root/iac-prov/twice.sh <작업디렉터리>(占位符为工作目录)。apply 之后用plan -detailed-exitcode检查第二次计划:为空则输出idempotent并以 0 结束;仍有变更则输出以not-idempotent开头的一行并以 2 结束;apply 或 plan 失败则输出以error开头的一行并以 1 结束。评分器会用/root/iac-prov/app和/root/iac-prov/noisy的副本,以及配置损坏的临时目录来检查。
参考
- Pod 中有 OpenTofu 1.9.0 和 local provider 镜像源,无需互联网即可运行。terraform_data 是内置资源,不需要 provider。
- provisioner 有创建时(默认)和
when = destroy两种,destroy provisioner 只能引用self。 - 常见错误:删除状态,或把副本与原目录混在一起,导致 restarts.log 行数的比较对不上。评分用的副本由评分器另行创建。
- 官方文档也建议把 provisioner 作为最后的手段。这里使用它,是为了亲眼看到副作用何时运行。
- Provisioners · lifecycle · tofu plan · Resource Behavior
每次应用都会运行重启
在 /root/iac-prov/noisy/main.tf 中放置 port 变量(默认 8080)、向 out/app.conf 写入 port=<값>(占位符为值)的 local_file.conf,以及 triggers_replace = timestamp() 的 terraform_data.restart。restart 在创建时通过 local-exec 向 out/restarts.log 追加一行 restart。执行 init 之后,把 apply 执行两次。评分器会在该目录中重新生成计划,检查是否每次都出现变更。
timestamp() 每次执行都是不同的值,所以触发器总是在变化。只要 triggers_replace 变化,terraform_data 就会被替换,而替换就是创建,所以创建时的 provisioner 会再次运行。
设置触发器,使其只在配置变化时才重启
让 /root/iac-prov/app/main.tf 与第 1 步相同,但把 restart 的触发器改为配置文件内容的哈希(local_file.conf 的 content_sha256)。执行 init 后,执行两次 apply,并确认计划是干净的。评分器会在副本中以另一个 port 值生成计划,检查 restart 是否被替换;如果是相同的值,则检查是否什么都不会改变。
local_file 会输出 content_sha256 这类计算出来的属性。用于触发器的值,就是对“什么发生变化时才需要副作用”的定义。
留下证据:再应用两次,重启记录也不会增加
统计 /root/iac-prov/app/out/restarts.log 的行数,再执行两次 apply 并重新统计,用一行 before=<수> after=<수>(占位符均为数量)写入 /root/iac-prov/app/proof.txt。评分器还会在副本中运行两次 apply,确认记录确实没有增加。
在没有变更的应用中不会创建任何资源,所以创建时的 provisioner 也不会运行。行数可以用 wc -l < 파일(占位符为文件)只得到数字。
配置真的变化时,只重启一次
在 /root/iac-prov/app/terraform.tfvars 中写入 port = 8081 并 apply。out/app.conf 必须变成 port=8081,并且 restarts.log 必须比 proof.txt 中的 after 恰好多一行。
terraform.tfvars 会被自动读取。在计划中确认 local_file.conf 与 terraform_data.restart 会一起被替换。
失败的 provisioner 会把资源标记为 tainted
在 app/main.tf 中加入 terraform_data.migrate。创建时通过 local-exec 检查是否存在 ${path.module}/ready.flag,如果存在,就向 out/migrate.log 追加一行 migrated(不存在则命令失败)。在没有 ready.flag 的状态下 apply,把输出保存到 /root/iac-prov/app/taint.txt,接着把 tofu plan 的输出保存到 /root/iac-prov/app/taint-plan.txt。评分器还会在副本中不提供 ready.flag 重新创建 migrate,检查它是否变成 tainted。
创建时的 provisioner 一旦失败,资源本身即使已被创建,也会被标记为“没有正常完成”,下一次计划会尝试替换它。用 tofu state show 或状态 JSON 中的 instances[].status 也可以看到。
排除原因后再次应用,只会替换一次
创建 /root/iac-prov/app/ready.flag 并 apply。migrate 会被替换,这次必须成功,之后的计划必须是干净的。restart 在此过程中不应再次运行(restarts.log 的行数与第 4 步之后相同)。
tainted 资源会在下一次应用时被替换。要让重试是安全的,该任务运行多次也必须没有问题——通过 migrate.log 确认,这个 migrate 运行两次会留下什么。
替换是先停止旧的,再启动新的
在 app/main.tf 中加入 worker_version 变量(默认 v1)和 terraform_data.worker。input 和 triggers_replace 都是该变量;创建时向 out/worker.log 追加 start <버전>(占位符为版本),并通过 when = destroy 的 provisioner 追加 stop <버전>(占位符为版本)。用 v1 应用之后,在 terraform.tfvars 中加入 worker_version = "v2" 再次应用。worker.log 的顺序必须是 start v1、stop v1、start v2。
destroy provisioner 只能引用它自己(self)——直接使用变量会报错。在默认值 create_before_destroy=false 下,会先删除旧对象,再创建新对象。
放进 CI 的两次应用测试
创建 /root/iac-prov/twice.sh <작업디렉터리>(占位符为工作目录)。apply 之后用 plan -detailed-exitcode 检查第二次计划:为空则输出 idempotent 并以 0 结束;仍有变更则输出以 not-idempotent 开头的一行并以 2 结束;apply 或 plan 失败则输出以 error 开头的一行并以 1 结束。评分器会用 /root/iac-prov/app 和 /root/iac-prov/noisy 的副本,以及配置损坏的临时目录来检查。
这是把阅读中提到的“在 CI 中把 playbook 运行两次的团队”的做法,移植到声明式工具上。如果使用 set -e,脚本会在遇到 2 时先退出,所以接住退出码再做判断。