On the Coverage of a Rotation密码不是一个值 · 是一组合约
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. 数据库事务给你的原子性, 在跨服务边界停止生效.
我们一直以为自己在做 "工程", 但其实大部分时间在做 合约管理 — 谁跟谁约定了什么, 哪一方的状态变化要怎么通知到所有相关方. 密码轮换只是这种合约里最小的一种.
「一 发 不 可 牵, 牵 之 动 全 身」
龚 自 珍 · 自 春 徂 秋 偶 有 所 触