背景:一次内存扩容引出的连锁恢复#

事情的起点很无聊:homelab 那台单节点 K3s 跑在 Proxmox VM 上,内存只给了 10G,kube-prometheus-stack 加上一堆有状态服务之后内存 requests 一直在 58% 上下,限制(limits)甚至超卖到 186%。我决定把 VM 从 10G 扩到 12G。

Terraform 改一行 vm_memoryapply,VM 重启——这一步本身顺利完成,节点回来了,kubectl top node 显示 12G 到位。但紧接着我发现一连串东西不对:

  • argocdpersonal-servicesSynced/Degraded
  • 12 个 ExternalSecret 全部 SecretSyncedError
  • calibre-webgrafana 等依赖 secret 的 pod 起不来

表面上看是 ExternalSecrets 集体挂掉,但直接触发点集中在一个地方:Vault 一重启就 sealed 了。

这篇文章把从"重启后 secret 全挂"到"固化成一条 just homelab-recover“的排查过程整理出来,重点是中间踩到的三个坑——其中第三个是我自己写的恢复脚本里的 bug。

Vault 为什么一重启就 sealed#

先说这个不是 bug,是设计。

HashiCorp Vault 启动时数据是加密的,主密钥(master key)本身又被一组 unseal key 保护。进程刚起来时处于 sealed 状态:它能响应健康检查,但任何读写密钥的请求都会被拒。需要用 unseal key 把 master key 解出来、加载进内存,Vault 才进入 unsealed 状态开始服务。

在我这套使用 Shamir unseal key、没有配置官方 Auto Unseal 的部署里,这意味着 Vault 的进程重启(节点重启、pod 重调度、OOM)后会回到 sealed。这是 Vault 的安全核心:哪怕磁盘被整个拷走,没有 unseal key 也打不开。

代价就是:在 K8s 里,Vault 不像无状态服务那样"重启即恢复”。它需要一个 unseal 动作。我的集群里所有 app secret 都走 External Secrets Operator(ESO)→ Vault 这条链,所以 Vault 一 sealed,整个集群的 secret 平面就停摆了。

确认现场很简单:

kubectl exec -n vault vault-0 -- vault status
Key             Value
---             -----
Sealed          true
Total Shares    1
Threshold       1
Unseal Progress 0/1
...

Sealed true —— 这就是 12 个 ExternalSecret 全红的源头。

第一个坑:postStart 解锁 hook 没生效(以及我一开始的误判)#

我本以为这台 Vault 早就配过自动解锁。翻 vault-values.yaml,确实有一段 postStart lifecycle hook,逻辑是:pod 起来后从挂进去的 K8s Secret 里读 unseal key,自己 vault operator unseal

这里说的"自动解锁"不是 Vault 官方的 Auto Unseal,而是我自己写在 Kubernetes lifecycle 里的自解锁 hook。名字如果混着叫,很容易把两套不同的机制说乱。

postStart:
  - /bin/sh
  - -c
  - |
    export VAULT_ADDR=http://127.0.0.1:8200
    # postStart 在 listener bind 之前就触发,所以要轮询等 Vault 起来
    for _ in $(seq 1 30); do
      status_json="$(vault status -format=json 2>/dev/null)"
      echo "$status_json" | grep -q '"initialized"' && break
      sleep 2
    done
    echo "$status_json" | grep -q '"initialized":true' || exit 0
    echo "$status_json" | grep -q '"sealed":true' || exit 0
    vault operator unseal "$(cat /vault/userconfig/vault-auto-unseal/unseal-key)" >/dev/null 2>&1 || true
    exit 0    

hook 明明在,Vault 却还是 sealed。我的第一反应是怪那个 seq 1 30 —— 轮询 30 次、每次 sleep 2,总共只等 60 秒,而这次是整机冷启动(VM 关机 → 改内存 → 开机 → K3s → Vault pod → 加载 NFS 上的 Raft),listener bind 慢,60 秒不够。听起来很合理,于是我把超时从 60 秒拉到 240 秒,准备 VM 重启验证。

但这个判断是错的。 幸好在真重启之前我留了个心眼:先做一次 warm restartkubectl delete pod vault-0,pod 重建很快,不涉及冷启动),看看 240 秒的 hook 到底好不好使。结果——pod 起来了、API 也通了,整整 4 分钟还是 sealed。warm restart 这条路径里看不出"60 秒不够"的问题,所以超时压根不是真因。

把 hook 拆开一行行手动跑,问题立刻现形。vault status -format=json 的真实输出是带空格的 pretty JSON:

  "initialized": true,
  "sealed": true,

而 hook 里 grep 的是 '"sealed":true'冒号后没空格)。这个 pattern 永远匹配不到,于是 grep -q '"sealed":true' || exit 0 这一行直接 exit 0 退出——hook 从来没走到 vault operator unseal

也就是说:这个 postStart 解锁 hook 从写出来那天起就没成功解锁过一次。 我之前每次重启都得手动 unseal,一直以为是"Vault 设计就这样",其实是 hook 里一个 JSON 空格的 grep bug 把它废掉了。我那个"拉长超时"的修复,治的不是这次真正的问题。

真正的修复是别再用文本匹配,改用 vault status 的 exit code(这一点后面第三个坑会展开,因为我在另一个地方犯了同一个错):

# 用 exit code 判定:0=unsealed, 2=sealed,拿不到明确状态就继续等
sealed=""
for _ in $(seq 1 120); do
  vault status >/dev/null 2>&1; rc=$?
  [ "$rc" -eq 0 ] && exit 0   # 已解锁,啥也不用做
  [ "$rc" -eq 2 ] && { sealed=true; break; } # initialized + sealed → 去解锁
  sleep 2                     # 先继续等,拿到明确状态再说
done
[ "$sealed" = "true" ] || { echo "Vault status never became conclusive; skip unseal"; exit 0; }
vault operator unseal "$(cat /vault/userconfig/vault-auto-unseal/unseal-key)" >/dev/null 2>&1 || true

改完再 warm restart 一次:vault-0 删掉重建,11 秒后自动解锁,零人工干预。这才是 hook 该有的样子。240 秒的窗口我保留了(冷启动 bind 慢确实需要重试预算),但它从来不是那个让解锁失败的原因。

这里有个值得记下来的教训:当一个"修复"让你觉得"应该能好了吧"但你没验证,它八成没真修。 我差点带着这个假修复直接去重启 VM——那样的话冷启动后 Vault 照样 sealed,我还会以为是别的新问题。一次成本很低的 warm restart 把假因揪了出来。

但 hook 改完不会立刻生效——postStart 只在 pod 重建时跑,而且这台 StatefulSet 用的是 OnDelete 更新策略(Helm 改了模板也不会自动滚 pod)。所以我还需要一个能立刻把集群从 sealed 状态拉回来的东西。这就引出了第二个坑。

第二个坑:这次 ESO 没有及时自动重验#

先手动解锁 Vault:

key=$(jq -r '.unseal_keys_b64[0]' vault-keys.json)
kubectl exec -n vault vault-0 -- vault operator unseal "$key"

vault status 这下显示 Sealed false、HA Mode 几秒后从 standbyactive。我以为 secret 会自己恢复——结果 12 个 ExternalSecret 还是全红。

去看 ESO controller 的日志:

level=error msg="unable to validate store"
  clustersecretstore="vault-backend"
  error="could not validate provider: invalid vault credentials:
    ... Code: 503 ... * Vault is sealed"
level=error msg="Reconciler error"
  ExternalSecret="gotify-secret"
  error="... ClusterSecretStore \"vault-backend\" is not ready"

ClusterSecretStore 本身的状态:

kubectl get clustersecretstore vault-backend \
  -o jsonpath='{.status.conditions[0]}'
{
  "reason": "InvalidProviderConfig",
  "status": "False",
  "message": "unable to validate store",
  "lastTransitionTime": "...T17:47:05Z"
}

关键在那个时间戳:17:47:05 正好是 Vault 还 sealed 的时间窗。至少在我这次观察里,ESO 在那一刻验证了一次 vault-backend、失败、把它标成 InvalidProviderConfig;Vault 后来解开了,但 store 状态没有立刻自己翻回去。

我这次排查下来,更像是要等 controller 重启 / store 资源变更 / 显式 refresh 这类重新触发 validate 的动作,ESO 才会再去确认上游 provider 已经恢复。至少在我这套部署里,我没有等到它及时捕获"Vault 中途从坏变好"这件事。

解法就是重启 ESO controller,强制它重新 validate 所有 store:

kubectl rollout restart deployment external-secrets -n external-secrets

重启完几秒钟,ClusterSecretStoreValid,12 个 ExternalSecret 全部 SecretSynced

到这里手动恢复链路已经清楚了,一共三步:解锁 Vault → 重启 ESO → 顺手重启一下 argocd-repo-server(它在 VM 重启后经常卡在 DNS bootstrap,这是另一篇的故事)。

把恢复流程固化成 just homelab-recover#

踩过一次就不想再手敲。我把这三步写成一个 just target,幂等、可重复跑:

homelab-recover:
    #!/usr/bin/env bash
    set -euo pipefail
    ctx="k3s-homelab"

    echo "▶ [1/4] checking Vault seal status..."
    if kubectl --context "$ctx" exec -n vault vault-0 -- vault status 2>/dev/null | grep -q "Sealed.*true"; then
        echo "  Vault is sealed, unsealing from vault-keys.json"
        key=$(jq -r ".unseal_keys_b64[0]" vault-keys.json)
        kubectl --context "$ctx" exec -n vault vault-0 -- vault operator unseal "$key" >/dev/null
        echo "  ✓ unsealed"
    else
        echo "  ✓ Vault already unsealed"
    fi

    # [2/4] 若 ClusterSecretStore 失效则重启 ESO
    # [3/4] 重启 argocd-repo-server
    # [4/4] 等所有 ExternalSecret SecretSynced

跑一遍,集群恢复,所有 app Synced/Healthy。当时我以为这事就结了。

直到我盯着 step 1 那段 if 多看了两眼——它有个 bug,而且恰好会在这个脚本最该工作的场景下触发。

第三个坑:恢复脚本自己的假阴性#

homelab-recover 的设计场景,就是"VM 重启后跑"。但 step 1 的判定是这样的:

if kubectl ... exec vault-0 -- vault status 2>/dev/null | grep -q "Sealed.*true"; then
    # unseal
else
    echo "  ✓ Vault already unsealed"
fi

问题:VM 刚重启时 Vault 大概率还没起来(我们前面刚算过,冷启动 bind 8200 要 60 秒以上)。这时候 vault status 会怎么样?

  • 如果 vault-0 pod 还在 ContainerCreatingkubectl exec 报 “container not found”,错误进 stderr 被 2>/dev/null 吞掉,stdout 是空的。
  • 如果 pod Running 但 Vault 进程没监听 8200vault status 返回 connection refused,同样 stderr 被吞,stdout 空。

两种情况下 grep -q "Sealed.*true" 都匹配不到,if 判 false,走 else,打印 “✓ Vault already unsealed”

这就是假阴性:Vault 明明是 sealed(只是还没起来连不上),脚本却报告它已经解锁了,然后跳过 unseal、继续往下重启 ESO——而 ESO 重启完发现 Vault 还是 sealed,又一次 InvalidProviderConfig。最坑的是 step 1 那句绿色的 给了你错误的安心感。

为什么 grep 判 sealed 不可靠#

问题在于 grep "Sealed.*true" 把两件事混为一谈了:

  • “Vault 起来了,并且是 sealed”
  • “Vault 还没起来,我连不上”

这两种都让 grep 匹配不到,但含义刚好相反。一个该 unseal,一个该继续等。

在我这条恢复链里,vault statusexit code 比文本匹配更可靠:

exit code 含义
0 unsealed
2 sealed
1(或 126/137 等) 还没拿到可用状态:可能是 kubectl exec 失败,也可能是 API 暂时不可达

用 exit code 判定,sealedgrep 什么都没读到 就分开了。对我这个脚本来说,只有 02 是终态,其它都意味着"再等等"。

为什么不能用 kubectl wait –for=condition=Ready#

我最初想偷懒,直接 kubectl wait --for=condition=Ready pod/vault-0 等它就绪再判断。这是错的。

sealed 的 Vault 故意不是 Ready。 Vault 的 readiness probe 默认把 sealed 当成 not-ready(这样 Service 不会把流量打到一个还不能服务的实例)。所以如果我等 condition=Ready,一个 sealed 但等待解锁的 Vault 会让我永远等不到 Ready——而它恰恰是最需要我去 unseal 的状态。

我现在的做法是只等 pod phase=Running(容器跑起来了),然后用 vault status 的 exit code 去探真实状态。

用 exit code 做三态判定#

重写后的 step 1 是这样:

echo "▶ [1/4] Vault: wait for pod + API, then unseal only if sealed..."
[ -f vault-keys.json ] || { echo "  ❌ vault-keys.json not found in $(pwd)"; exit 1; }

# 1) 等 pod Running(不能用 --for=condition=Ready,sealed Vault 故意 NOT Ready)
phase=""
for _ in $(seq 1 30); do        # 最多 150s
    phase=$(kubectl --context "$ctx" get pod vault-0 -n vault \
        -o jsonpath='{.status.phase}' 2>/dev/null || true)
    [ "$phase" = "Running" ] && break
    sleep 5
done
[ "$phase" = "Running" ] || { echo "  ❌ vault-0 not Running after 150s"; exit 1; }

# 2) 轮询 vault status,直到拿到确定的 exit code
#    0=unsealed, 2=sealed, 其它=还没拿到可用状态,继续等
sealed=""
for _ in $(seq 1 30); do        # 最多 150s
    set +e
    kubectl --context "$ctx" exec -n vault vault-0 -- vault status >/dev/null 2>&1
    rc=$?
    set -e
    [ "$rc" -eq 0 ] && { sealed=false; break; }
    [ "$rc" -eq 2 ] && { sealed=true;  break; }
    sleep 5
done

# 3) 按三态动作——绝不再静默假设"already unsealed"
if [ "$sealed" = "true" ]; then
    key=$(jq -r ".unseal_keys_b64[0]" vault-keys.json)
    kubectl --context "$ctx" exec -n vault vault-0 -- vault operator unseal "$key" >/dev/null
    echo "  ✓ unsealed"
elif [ "$sealed" = "false" ]; then
    echo "  ✓ Vault already unsealed"
else
    echo "  ❌ Vault API never became reachable — aborting"; exit 1
fi

几个关键点:

  • phase=Running 而非 Ready,避开 sealed Vault 永远不 Ready 的陷阱。
  • exit code 判定,彻底区分 sealed 和连不上。
  • sealed 三态(true/false/空字符串):空字符串意味着轮询超时、API 始终连不上,这时明确报错退出,而不是继续往下假装一切正常。
  • 局部 set +e / set -e 包住那次 exec,避免非零退出码被 set -euo pipefail 误伤。
  • 前置检查 vault-keys.json 是否存在,避免在错误目录下跑时 set -e 静默 abort。

固化与收尾#

最后回写仓库的有两处,而且都指向同一个修法——用 exit code,不要 grep 文本

  1. vault-values.yaml 里 postStart hook 改用 exit code 判定(真正的治本:让 lifecycle 解锁 hook 第一次真的能工作;顺带保留 240 秒重试窗口给冷启动 bind 慢兜底)。
  2. homelab-recover 的 step 1 重写(治标兜底:哪怕自动解锁因为某种原因还是没成,一条命令能确定性地把集群拉回来,而且不会再假阴性)。

这两层是互补的:postStart hook 是第一道防线,让大多数重启通常不需要人工介入homelab-recover 是 hook 万一没兜住时那条看得见结果的恢复路径。

然后我做了真正的端到端验证——直接把 VM 冷重启了一次

节点 boot time 确认变化(真冷启动,不是 warm restart)
-> K3s API ~100s 回来
-> vault-0 pod 重建,postStart hook 自己跑
-> 66 秒后 Sealed=false —— 全程零人工干预
-> 12/12 ExternalSecret 自动 SecretSynced(解锁够早,ESO 没被拖住)
-> 10/10 ArgoCD App Synced/Healthy

事后再跑一遍 homelab-recover,它对 Vault 这步是干净的 no-op:✓ Vault already unsealed✓ ClusterSecretStore already valid——基于真实的 vault status exit 0 判定,而不再是旧版那种"连不上也当成解锁了"的假阴性。两层防线第一次都名副其实了。

几个我记住的点#

1. || exit 0 是双刃剑#

自动化里用 || exit 0 / || true 防止脚本因为预期内的情况崩掉,是对的。但它同时也吞掉了预期外的失败。那个 postStart 解锁 hook 之所以坏了几个月没人发现,就是因为 grep 没匹配上之后 || exit 0,失败和成功都返回 exit 0、都没有日志。在关键路径上,至少要让"我放弃了"和"我成功了"留下不同的退出码或日志,否则一个静默失败可以潜伏很久。

2. 状态探测要用 exit code,不要 grep 文本#

grep "Sealed.*true" 看着直观,但它分不清"sealed"和"我没读到任何输出"。凡是工具提供了结构化的 exit code(Vault 这里就是 0/1/2),我更倾向优先用 exit code。文本匹配更像最后的退路,不是首选。

3. condition=Ready 不总是你想等的那个条件#

Readiness 的语义是"能不能接流量",不一定等于"能不能被操作"。sealed Vault 故意 not-Ready,但它恰恰是你需要去操作(unseal)的对象。等错了条件,会在最需要动作的时候死等。想清楚你到底在等什么——是"能服务"还是"进程起来了"。

4. 上游恢复 ≠ 依赖方自动恢复#

Vault 解锁了,不代表 ESO 马上就好了。很多 operator 的上游验证更偏"事件驱动 + 缓存失败态",未必会按你期待的节奏主动轮询上游是否康复。跨组件的恢复链里,显式地把每个依赖方踢一脚(重启 / 触发 refresh)往往比期待它们自愈更可靠。

5. 恢复脚本本身也要假设"被恢复对象还没起来"#

这是最反直觉的一点。恢复脚本的设计前提就是"系统处于不健康状态",所以它不能假设依赖已经就绪。我第一版脚本的 bug,本质上是把"稳态下能跑通"的逻辑直接拿来当"灾后恢复"用了。灾后恢复脚本要从第一行就考虑:pod 可能还没调度、API 可能还没监听、文件可能还没挂上。

结语#

一次 2G 内存扩容,最后变成了一条恢复链路的体检:Vault 重启即 sealed(在我这套未配置官方 Auto Unseal 的部署里是设计)、postStart 解锁 hook 因为一个 JSON 空格的 grep bug 从来没工作过(我还先误判成超时)、ESO 没有及时自动重验(依赖恢复)、以及我自己恢复脚本里同样的假阴性(在同一个坑里摔了第二次)。

最值得记的不是某个具体修复,而是那次"差点跳过"的 warm restart:它在我带着假修复去重启 VM 之前,把真因揪了出来。最后真做了一次冷重启验证,Vault 第一次靠自己解开了——零人工干预。

现在这套东西第一次名副其实地结实了:冷启动 hook 自己兜底,真出问题还有一条 just homelab-recover 可以确定性地拉回来,而且它不会再骗我说"Vault 已经解锁了"。

homelab 的价值大概就在这里——用很低的代价,把这些只有在生产里才学得到的失败模式,提前在自己的机器上踩一遍。