寒 DATA VAULT数据仓
EN / 中
CAP VI · № 011
DATA WAREHOUSE · INFRA · SECRETS
— · — UTC
DATA VAULT
CAPITULUM · VI

On the Coverage of a Rotation密码不是一个值 · 是一组合约

№ 011 · 2026.04.28 · INFRA · SECRETS · 2,800 字 · 11 MIN

2026 年 4 月 15 日, 周二下午, 我做了一次例行的密码轮换. 五分钟搞完, 心想 "完事了". 半小时后, Discord 开始连环响.

第一条: data-services 里的一个容器健康检查变红, "auth failed". 我以为单点抖动, 重启. 第二条: Umami 网站 analytics 全断, "Cannot connect to database". 第三条: Infisical 自己的 admin panel 也开始 401. 第四条: 我自己 SSH 到 VM103 想 docker logs 看看, 发现一个老 docker-run 启的容器也在 reconnect loop.

原因很简单, 而且令人沮丧: 我改的不是 "一个" 密码, 是 一份合约 — 而合约的另一方有 28 个签字人, 我只通知了其中一个.

密码不是配置, 是订阅关系

我以前对密码的心智模型是这样的:

密码是一个 string. 存在 .env 里 (或者 secrets manager 里), 应用启动时读一下, 拿来连数据库. 改密码就是改 string + 重启应用. 完.

这个模型对单体应用是对的. 一个 .env, 一个 process, 一对一关系.

但对一个稍微长出过支线的系统, 它 错得离谱. 因为现实是这样:

· 主 backend 用的密码: 在 infra/.env, docker-compose 启动时注入

· data-services 25 个容器各自的 docker-compose: 各自从 data-services/.env 读

· Umami analytics: 跑在另一个 stack, 自己的 .env

· Infisical: 自己存了一份, 因为它用 same-DB 做 audit log

· 一个 2024 年用 docker-run 起的老脚本: 密码硬编码在启动命令里, 不在任何 .env 里

· lib/db_config.py 用 getenv, 但 getenv 的 default 也硬编码了一份, 用来 dev 环境

· 我自己的 ~/.pgpass

· DBeaver 的连接配置

这是 8 个独立的 "消费者". 每一个都缓存了密码的一个副本. 改密码 = 跟这 8 方各自重新签合约.

我那次只改了 infra/.env + 重启了 backend. 剩下的 7 方都还在用旧密码. 旧密码失效那一刻, 7 方同时崩.

"找全消费者" 是 rotation 的真问题

事故之后我意识到, 密码轮换的难点根本不在 怎么改. 改是 1 行 SQL.

难点在 怎么找全所有消费者. 这是个分布式审计问题.

我列了一个清单, 系统里所有可能藏密码的地方:

· 显式配置: .env / .env.production / docker-compose.yml 的 environment / Kubernetes secrets / Infisical / Vault — 这一类好找, grep 关键词 + 列文件即可

· 隐式配置: docker-run 启动命令里 -e DB_PASSWORD=xxx / systemd unit 的 Environment= / cron 任务的内嵌字符串 — 这一类要 ps aux | grep + systemctl show + crontab -l 才能挖出来

· 代码硬编码: 字面字符串 / dict 默认值 / argparse default / 测试用 fixture — 这一类要 grep 整个仓库

· 客户端缓存: SDK 启动时拿一份, 进程不重启就一直用. SDK 没用 lazy reload 的话, 改了 .env 也没用 — 必须重启进程

· 人脑里的副本: 我的 ~/.pgpass / 队友的 DBeaver / 临时 SSH tunnel 的会话 — 这一类只能口头通知

五个类别, 每一类都要 audit. 漏一个, rotation 就不完整.

rotate-passwords.sh 的演化

我的 rotate-passwords.sh 一开始是这样:

#!/bin/bash
NEW_PW=$(openssl rand -hex 32)
psql -c "ALTER USER ggboys WITH PASSWORD '$NEW_PW'"
sed -i "s/DB_PASSWORD=.*/DB_PASSWORD=$NEW_PW/" /infra/.env
docker-compose restart backend
echo "done"

四行. 看起来很专业. 这个版本就是导致那次事故的版本.

事故之后我重写了一遍, 现在的版本是 200 多行, 干这些事:

1. 生成新密码, 但先 不改 DB.

2. 扫描整个文件系统找 .env / .env.* / docker-compose*.yml / 任何包含 DB_PASSWORD 关键词的配置文件. 列出来给我确认.

3. 扫描 ps aux 找所有 docker-run / systemd / cron 任务里的 DB_PASSWORD= 内嵌字符串.

4. 扫描 git 仓库找硬编码密码 (有限的, 主要是历史遗留).

5. 把所有 "待更新位置" 列出来确认. 我看一遍, 确认无遗漏.

6. 同时更新 DB 密码 + 所有配置文件 + Infisical + 重启所有受影响的容器/服务.

7. 跑一次连接测试 — 用新密码连一遍 DB, 确认 rotation 成功.

8. 5 分钟内 monitor 所有受影响服务的健康检查, 任何一个变红立刻 rollback.

这个流程把 "改密码" 从一个动作变成了一个 原子事件. 要么所有消费者一起切, 要么一个都不切.

一个 rotation 是一个 saga

写到这儿你大概会发现 — 密码轮换的形状跟 saga 是一样的:

· 多个独立服务/系统都要更新

· 任何一个失败都需要补偿 (rollback 全部)

· 中间态是不安全的 (一半新密码一半旧密码 = 7 个服务掉线)

· 需要一个 orchestrator 来主动推进, 不能靠 "反正最后一致" 的 cron 兜底

这就是为什么 rotate-passwords.sh 越写越像一个 saga 引擎. 它的每一步都需要明确的 forward + compensating action. 它不能有 "哎呀这个改完那个忘了" 的状态.

更深一层 — 密码不是配置, 密码是分布式合约. 它的本质和 "一个跨服务订单" 没区别. 只是合约的对象是 service 之间的认证, 不是用户之间的支付.

三个反例 + 三个正例

反例 1: 改完密码, 只重启了主 backend. 副服务还在用旧密码, 30 分钟后掉线.

反例 2: 用 sed 改 .env, 但忘了 docker-compose 启动时 inline 传过的 environment 变量优先级更高. .env 改了, 容器读的还是 inline 的旧值.

反例 3: 改了 .env 但容器没重启 (因为 SDK 进程内缓存). 接下来某次容器自然重启时才 "突然" 出问题, 而那时已经过了几天, 没人记得跟密码轮换有关.

正例 1: 用 secrets manager (Infisical / Vault) 做 动态拉取, SDK 支持 lazy reload, 密码改了不需要重启进程. 这是终极方案, 但接入成本高.

正例 2: 把所有密码消费方收敛到 一个 lib, 比如 lib/db_config.py. 改密码只需要改 secrets 源, 所有消费者通过 lib 自动 pickup. 这是中间方案, 我们现在在用.

正例 3: rotate-passwords.sh 内置一个 "消费者发现 + 全量更新 + 全量 verify" 的 pipeline. 这是兜底方案, 适合还没收敛到 secrets manager 的系统.

收尾 — 任何 "操作" 都先看它的 blast radius

这次事故让我学到的不只是 "密码轮换要小心". 是一个更普遍的纪律:

任何一个看起来 "局部" 的操作, 在分布式系统里都可能是全局的.

· 改一个 schema 列? — 看哪些服务读这个表 / 哪些 ORM 模型有这个字段 / 哪些 cache 缓存了这个 row.

· 删一个 API endpoint? — 看哪些客户端 (web / app / 第三方插件) 在调.

· 关一个容器? — 看谁还在写它的 mount volume / 谁在轮询它的 health.

· 改一个密码? — 看上面那 8 类消费者.

每一个 "操作" 之前, 先问 blast radius 是多少. 如果你回答不上来, 这个操作就还没准备好执行.

这件事跟 saga (№ 006)、跟异常分级 (№ 010) 是同一件事的不同切面 — 分布式系统里没有 "原子操作", 只有 需要 orchestrator 推进的 saga. 数据库事务给你的原子性, 在跨服务边界停止生效.

我们一直以为自己在做 "工程", 但其实大部分时间在做 合约管理 — 谁跟谁约定了什么, 哪一方的状态变化要怎么通知到所有相关方. 密码轮换只是这种合约里最小的一种.

「一 发 不 可 牵, 牵 之 动 全 身」

龚 自 珍 · 自 春 徂 秋 偶 有 所 触
Xinghan's Memory · Written by CC/Codex
— end of cap. VI —