背景#

homelab 里有 8 个 Terraform root,管着 PVE 上的 VM、两条 Cloudflare Tunnel 和 WAF、Tailscale ACL、Oracle Cloud 的实例、ZITADEL 的 SMTP 配置,还有一个 Aiven 的免费 PostgreSQL。它们的 state 一直是本地文件,被 .gitignore 挡在仓库外面,只在一台笔记本上有一份。笔记本出问题,每个 root 都只能对着控制台把资源逐个 terraform import 回来;换一台机器也没法接着跑,有 token 没 state 的 apply 会把这些资源当成不存在,试图再建一份。

仓库里早就有一段注释掉的 R2 backend 配置,注释写的是 commented out due to local TLS handshake issues with LibreSSL 3.3.6。这个理由站不住:LibreSSL 3.3.6 是 macOS 自带 curl 链接的库(curl --version 里能看到),terraform 是 Go 写的,用 Go 自己的 TLS 实现,不经过它。同一台机器上另一个仓库也早就把 state 放进了 R2。

单独建一个桶#

那段注释掉的配置指向一个已有的桶 terraform-backend。这个桶后来给另一个公开仓库用了,那个仓库在 GitHub Actions 里 apply,CI 的 secret 里有一枚能读写这个桶的 token。按 R2 的认证文档,R2 的 API token 权限范围最细只能选到桶,选不到桶里的前缀;能限到前缀的是从 token 派生的短期 temporary credentials,CI 里那枚是长期 token。homelab 的 state 里有 VM 的 cloud-init 密码、Tailscale 的预授权 key、Aiven 的数据库连接信息,都是明文,要是也放进这个桶,那枚 token 就能读到它们。

所以另建了一个私有桶 homelab-tfstate,jurisdiction 用默认值,不开公共访问。token 的权限是 Object Read & Write,只勾这一个桶。控制台建完会给出 Access Key ID、Secret Access Key 和 S3 端点,三样一起存进 Vault。

建完先用 aws CLI 确认这枚 token 的范围。端点和密钥从 Vault 取出来放进环境变量,再设 AWS_REQUEST_CHECKSUM_CALCULATION=when_required 和 AWS_RESPONSE_CHECKSUM_VALIDATION=when_required(跟后面 backend 里要开 skip_s3_checksum 是同一类问题),然后列两个桶:

aws s3 ls s3://homelab-tfstate/ --endpoint-url "$EP"
aws s3 ls s3://terraform-backend/ --endpoint-url "$EP"

第一条成功(空桶,没有输出),第二条被拒:

aws: [ERROR]: An error occurred (AccessDenied) when calling the ListObjectsV2 operation: Access Denied

不带桶名的 aws s3 ls 同样是 AccessDenied,账号下有哪些桶它也列不出来。读写权限也试了:往 homelab-tfstate 写一个探针对象、读回来、再删掉,都正常。

锁靠 R2 的条件写#

Terraform(这次用的是 1.17)的 S3 backend 设了 use_lockfile = true 之后,会在 state 对象旁边放一个 .tflock 对象当锁。按 Terraform 的文档,原来那种 DynamoDB 锁已经标成 deprecated。这把锁能不能互斥,要看对象存储支不支持条件写:带 If-None-Match: * 的 PutObject,在对象已经存在时必须失败。R2 的 S3 API 兼容表里 PutObject 的 If-None-Match 是支持的,并发写时最多一个成功,其余返回 412 PreconditionFailed。

另一个仓库用 R2 时没开锁,也没测过,所以迁移前先手动试了一次,对同一个 key 连发两次条件写:

aws s3api put-object --bucket homelab-tfstate --key "$k" --body ./lock \
  --if-none-match "*" --endpoint-url "$EP"

第一次返回 ETag,第二次:

aws: [ERROR]: An error occurred (PreconditionFailed) when calling the PutObject operation: At least one of the pre-conditions you specified did not hold.

迁移完以后又从 terraform 这边测了一次。两个并发的 plan 测不出来:zitadel 那个 root 的 plan 跑得太快,第二个起来时锁已经释放。所以改成手动在 R2 上放一个 .tflock 对象,内容是手写的锁信息 JSON(下面 Lock Info 里的 ID、Who、Operation 都来自它),再跑 plan:

Error: Error acquiring the state lock

Error message: operation error S3: PutObject, https response error
StatusCode: 412, RequestID: , HostID: , api error PreconditionFailed: At
least one of the pre-conditions you specified did not hold.
Lock Info:
  ID:        6f1c2a9e-0000-4000-8000-000000000001
  Path:      homelab-tfstate/zitadel/terraform/terraform.tfstate
  Operation: OperationTypeApply
  Who:       locktest@manual

报错里是一次 PutObject 收到 412 PreconditionFailed,说明 terraform 加锁时发的就是条件写。把这个对象删掉之后 plan 恢复正常。以后真遇到进程崩溃留下的锁,用 just tf force-unlock <ID> 解。

backend 配置#

每个 root 的 provider.tf 里加同一段,只有 key 不同,取 root 在仓库里的相对路径:

backend "s3" {
  bucket       = "homelab-tfstate"
  key          = "cloudflare/terraform/terraform.tfstate"
  region       = "auto"
  use_lockfile = true

  skip_credentials_validation = true
  skip_region_validation      = true
  skip_requesting_account_id  = true
  skip_metadata_api_check     = true
  skip_s3_checksum            = true
  use_path_style              = true
}

这 6 个开关跟 Cloudflare 的 R2 remote backend 示例 一模一样(示例没开 use_lockfile)。skip_metadata_api_check 是不去查 EC2 实例元数据,skip_s3_checksum 按文档的说法就是给部分 S3 兼容 API 准备的。

端点没写进配置。backend 块里不能引用变量,而端点里带着 Cloudflare 的 account ID,没必要放进公开仓库。S3 backend 能从环境变量 AWS_ENDPOINT_URL_S3 读端点(顶层的 endpoint 参数已经 deprecated),所以端点和两个密钥一样走环境变量。

凭据由包装脚本从 Vault 取#

这三个环境变量由一个包装脚本(wrapper)scripts/tf-r2.sh 注入:从 Vault 读出来,export,再 exec terraform。8 个 root 的 justfile 里所有 terraform 调用都改成经过它,另外每个 root 加了一个透传任意子命令的配方:

terraform := source_directory() / "../../scripts/tf-r2.sh"

[positional-arguments]
tf *args:
    @{{terraform}} "$@"

日常的 just plan、just apply 照旧,其他子命令写成 just tf state list、just tf output -raw ...。直接敲 terraform 会因为拿不到凭据失败:

Error: No valid credential sources found

本机 ~/.aws 里有有效的 AWS 登录时,请求会发到真 AWS 而不是 R2,报的是 AWS 那边的错。脚本里取凭据的部分:

if [[ -z "${AWS_ACCESS_KEY_ID:-}" || -z "${AWS_SECRET_ACCESS_KEY:-}" || -z "${AWS_ENDPOINT_URL_S3:-}" ]]; then
  if ! creds="$(vault kv get -format=json "$vault_path")"; then
    echo "tf-r2: 读不到 Vault $vault_path(见上方报错)。Vault 不可用时的 break-glass 见 docs/reference/terraform-state.md" >&2
    exit 1
  fi
  AWS_ACCESS_KEY_ID="$(jq -er '.data.data.access_key_id' <<<"$creds")"
  AWS_SECRET_ACCESS_KEY="$(jq -er '.data.data.secret_access_key' <<<"$creds")"
  AWS_ENDPOINT_URL_S3="$(jq -er '.data.data.endpoint' <<<"$creds")"
  export AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_ENDPOINT_URL_S3
fi
# 本机若配过真 AWS 的 profile / 会话 token,别让它们混进对 R2 的签名。
unset AWS_PROFILE AWS_SESSION_TOKEN

第一行的判断有个用处:三个变量都已经在环境里时,脚本不碰 Vault。Vault 跑在 homelab 集群上,而 proxmox/terraform 管的正是这个集群控制面所在的 VM。重建这台 VM 时,VM 一删 Vault 也跟着没了,后面的 just plan 就取不到凭据。所以重建前先趁 Vault 还在,把三个变量导进当前 shell:

eval "$(vault kv get -format=json <path> | jq -r '.data.data | @sh "export AWS_ACCESS_KEY_ID=\(.access_key_id) AWS_SECRET_ACCESS_KEY=\(.secret_access_key) AWS_ENDPOINT_URL_S3=\(.endpoint)"')"

导完之后把 VAULT_ADDR 改成一个连不上的地址试过,这个 shell 里的 just tf state list 照样能读出 state。Vault 已经不在的话,只能去 Cloudflare 控制台临时建一枚同样范围的 token 手动 export,这条路还没演练过。

R2 没有对象版本,改 state 前先拍快照#

Terraform 的 S3 backend 文档建议给放 state 的桶开 Bucket Versioning,出事时能找回旧版本。R2 的兼容表里 GetBucketVersioning 和 PutBucketVersioning 都是未实现。本地 backend 每次写 state 都会留一份 terraform.tfstate.backup,换成远程 backend 之后这一份也没有了。

所以包装脚本遇到会改 state 的子命令,先 terraform state pull 一份到本地再执行:

mutates=false
case "${1:-}" in
  apply | destroy | import | taint | untaint) mutates=true ;;
  state)
    case "${2:-}" in
      rm | mv | push | replace-provider) mutates=true ;;
    esac
    ;;
esac

if [[ "$mutates" == true ]]; then
  umask 077
  mkdir -p "$snapshot_dir"
  # 带 PID:同一秒内连跑两次(如 oracle 的 import 配方连跑 6 次)不会互相覆盖。
  snap="$snapshot_dir/$(date +%Y%m%dT%H%M%S)-$$.tfstate"
  if ! terraform state pull >"$snap"; then
    rm -f "$snap"
    echo "tf-r2: 改 state 之前拉快照失败,已中止(没有执行 terraform $*)" >&2
    exit 1
  fi
  echo "tf-r2: 改动前的 state 快照 → $PWD/$snap" >&2
  # 文件名以定长时间戳开头,glob 的字典序就是时间序:删掉最旧的那些,只留 snapshot_keep 份。
  snaps=("$snapshot_dir"/*.tfstate)
  if ((${#snaps[@]} > snapshot_keep)); then
    for old in "${snaps[@]:0:${#snaps[@]}-snapshot_keep}"; do rm -f -- "$old"; done
  fi
fi

快照放在各 root 的 .tfstate-snapshots/ 里,权限 0600,被 gitignore,只留最近 20 份;拉不下来就中止,不执行原命令。恢复的命令是 just tf state push -force <快照文件>,还没演练过。按 state push 的文档,目标 state 的 serial 更高时会拒绝推送,所以要加 -force。

逐个 root 迁移和核对#

每个 root 的步骤一样。先把 terraform.tfstate 另存为 terraform.tfstate.pre-r2.backup,原有的 .backup 另存为 .pre-r2-prev.backup,然后:

just tf init -migrate-state -force-copy -input=false

-force-copy 跳过「是否把现有 state 复制到新 backend」那一步交互确认。迁完把远端那份拉回来比对,.outputs 也同样比一遍:

jq -S '.resources' terraform.tfstate.pre-r2.backup > before.json
just tf state pull | jq -S '.resources' > after.json
diff before.json after.json && echo identical

7 个有资源的 root,resources 和 outputs 全部一致。迁移本身有几处跟预想的不一样:

  • R2 上那份的 serial 变成了 1,lineage 也换了新的。tailscale 那个 root 迁移前是 serial 39。判断迁没迁成要看 resources,看 serial 会以为出了问题。
  • 本地的 terraform.tfstate 被清空成 0 字节,terraform.tfstate.backup 被改写成迁移前的内容,原来那份 .backup 就没了。第一个有资源的 root(tailscale)没有提前另存,丢了上一个版本的备份,当前版本是完整的;后面几个 root 都先另存了。
  • 空的 state 不会被迁移。zitadel 那个 root 一直是 0 个资源,init 的输出里没有复制 state 的提示,R2 上也没有它的对象,要等第一次 apply 才会创建。

最后每个 root 跑一次 just plan,6 个是 No changes,aiven 是 1 to change,那是迁移前就挂着、一直没 apply 的一项改动;zitadel 是 1 to add,它的 state 本来就是空的。

小结#

8 个 root 都切到了 R2 的私有桶(zitadel 的对象要等第一次 apply 才出现),token 只授权这一个桶。换一台机器不用再拷 state,能读 Vault、带上各 root 的 .env 和 tfvars,just init 之后就能接着跑,多台机器之间也有了锁。凭据由包装脚本现取,裸敲 terraform 会直接报错;Vault 停掉之前先把三个变量导进 shell,就不依赖它。

还有几件事没做。快照只在执行命令的那台机器上,没有进 restic。迁移前另存的 backup 文件还在笔记本上,里面是明文凭据,用一段时间确认没问题再删。桶本身是控制台手工建的,不在 Terraform 里。Vault 已经不在时临时建 token、用快照 state push -force 恢复,这两条都还没演练过。

相关文章#