密码散落在四处:一次凭据漂移事故的治理
最典型的“修好了,重跑一次部署又坏了” —— 密码散在 Nacos 三处与 .env 里共四处,改一处就被下一次推送覆盖回去。本篇给出唯一真源方案:Nacos 存占位符、容器注入真值。
一、事故:修好了,重跑一次部署又坏了
这是一个特别有代表性的故障模式。
背景
数据库密码曾经散落在四个地方:
| 位置 | 内容 |
|---|---|
Nacos 上的 user-service.yml | password: api_cloud123456(硬编码) |
Nacos 上的 business-service.yml | password: api_cloud123456(硬编码) |
Nacos 上的 order-service.yml | password: api_cloud123456(硬编码) |
deploy/.env | MYSQL_PASSWORD=<实际值> |
事故过程
健康检查发现三个服务 unhealthy,打开 show-details 看到 db 组件 DOWN。定位到是密码不一致,于是在 Nacos 控制台上把密码改对了。
重启,服务恢复 healthy。问题解决。
——直到下一次部署。
用户按流程跑了 deploy/setup-nacos-config.sh(把本地 nacos-config/*.yml 推送到 Nacos),三个服务立刻重新 unhealthy。
因为本地 deploy/nacos-config/*.yml 里还是旧密码。 推送脚本忠实地把正确值覆盖回了错的。
故障的本质特征
「明明修好了,重跑一次部署又坏了」
这句话是凭据漂移的典型症状。它的危险之处在于:你修复的动作本身,会在下一次执行时把修复撤销掉。
而且发现的时候往往已经过了很久——因为"修好之后的一段时间里它是正常的"。
二、根因:没有"唯一真源"
问题的本质不是"密码写错了",而是同一个密码有四个独立副本,改任意一处都会造成漂移:
┌─────────────────────┐
│ .env │ ← 实际运行时用的值
│ MYSQL_PASSWORD=X │
└─────────────────────┘
↕ 必须手动保持一致
┌──────────────┼──────────────┐
▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌─────────┐
│ user.yml│ │biz.yml │ │order.yml│ ← 会被脚本推送到 Nacos
│ 旧密码 │ │ 旧密码 │ │ 旧密码 │
└─────────┘ └─────────┘ └─────────┘
四份副本,三次同步机会,任意一次忘记都会炸。
三、治本方案:占位符 + 单一注入点
核心思路
让 Nacos 上的配置不含密码,只含占位符。密码只存在于
.env一处。
改动 1:Nacos 配置改用占位符
deploy/nacos-config/user-service.yml(business / order 同理):
spring:
datasource:
url: jdbc:mysql://mysql:3306/api_lfzx_wang?...
# 占位符,值由应用启动时从环境变量解析
username: ${MYSQL_USER:api_cloud}
password: ${MYSQL_PASSWORD:}
关键点:Nacos 只存配置文本,不解析占位符。 它原样存 "${MYSQL_PASSWORD:}" 这串字符串,真正的替换发生在应用侧启动时。
改动 2:compose 注入环境变量
x-common-env: &common-env
# 数据源凭据。Nacos 上的 *-service.yml 里写的是 ${MYSQL_USER:...} / ${MYSQL_PASSWORD:...}
# 占位符(Nacos 只存配置文本,占位符由应用侧在启动时解析),值从这里注入 ——
# 于是密码只保留在 .env 一处,杜绝「Nacos 配置 × 3 + .env」四处漂移。
MYSQL_USER: ${MYSQL_USER:-api_cloud}
MYSQL_PASSWORD: ${MYSQL_PASSWORD}
四个服务通过 <<: *common-env 全部继承。
改动后的数据流
.env (唯一真源)
MYSQL_PASSWORD=X
│
│ docker compose 读取
▼
容器环境变量 MYSQL_PASSWORD=X
│
│ 应用启动时解析 Nacos 拉下来的配置里的 ${MYSQL_PASSWORD}
▼
DataSource 拿到 password=X
密码的副本从 4 份降到 1 份。
附带好处:密码里的特殊字符不会再破坏配置
这是当初没预料到的一个收益。
原来密码是硬编码进 YAML 的,如果密码含 $、:、# 等字符,YAML 可能需要转义,甚至直接把配置结构搞坏。
改成占位符之后,密码永远不出现在配置文本里——${MYSQL_PASSWORD:} 这串字符是固定的,跟密码内容无关。
四、方案成立的前提(关键!)
这套方案能成立,依赖一个具体的实现细节,必须写下来,因为它很容易在后续改动中被破坏。
前提:推送脚本必须用 content@file,不能让内容经过 shell
# setup-nacos-config.sh:249
--data-urlencode "content@${file}"
curl 的 --data-urlencode name@filename 语法表示:直接从文件读取内容并做 URL 编码。
内容不经过 shell 展开。 所以 ${MYSQL_PASSWORD:} 会被原样推送到 Nacos,不会被 shell 替换成真实密码(或空串)。
⚠️ 如果哪天改成 shell 变量拼接,这条立刻失效
# ❌ 危险写法:content 经过 shell 展开
content=$(cat "$file") # 此时 ${MYSQL_PASSWORD:} 被展开
curl -X POST ... --data-urlencode "content=${content}"
这样 ${MYSQL_PASSWORD:} 会在推送时就被 shell 解析成实际值(或者空串),Nacos 上存的就是明文密码了——方案退化回原来的状态,而且更隐蔽。
判据:只要看到推送脚本里出现
$(cat file)或content="${var}"这类模式,占位符方案就已经被破坏了。另外补充一点:脚本开头有
set -a; . ./.env; set +a,会把MYSQL_PASSWORDexport 成 shell 变量。因为推送走的是content@file,这个 export 不影响占位符——但如果改成变量拼接,这个 export 恰好会让占位符被展开成真实密码。
验证方法
本地跑一遍推送,然后从 Nacos 读回来比对:
# 推送后回读,确认 Nacos 上存的是占位符而不是明文
curl -s "http://127.0.0.1:8848/nacos/v3/client/cs/config?dataId=user-service.yml&groupName=LFZXW&namespaceId=$NS" \
-H "accessToken: $TOKEN" \
| python3 -c "import sys,json; c=json.load(sys.stdin)['data']['content']; print(c)"
# 期望看到:password: \${MYSQL_PASSWORD:}
五、执行顺序:搞错会直接启动失败
改动涉及"新增环境变量",所以顺序很重要:
cd /opt/api-cloud-Java && tar -xzf fix-status-and-creds.tar.gz
cd deploy
# 1) 先把占位符版的配置推到 Nacos
./setup-nacos-config.sh
# 2) 再用 up -d 重建容器(【必须】up -d,不能 restart)
docker compose up -d user-service business-service order-service
为什么第 2 步必须是 up -d
docker compose restart不会重新读取environment。
新的 MYSQL_USER / MYSQL_PASSWORD 是这次改动新增的环境变量。restart 只是把现有容器重启一遍,沿用旧的容器配置——新变量根本不会注入。
后果:
restart 之后
→ 容器里没有 MYSQL_PASSWORD 这个环境变量
→ 应用解析 ${MYSQL_PASSWORD:} 得到空串(冒号后的默认值)
→ 连不上数据库
→ 服务 unhealthy
这条在本项目里踩了不止一次,是本项目最高频的"改完没生效"原因。
判据:只要改动涉及
.env或 compose 的environment,一律用docker compose up -d(它会重建有变化的容器),不要用restart。
六、另一个隐蔽的坑:mysql/init/*.sql 只在数据卷为空时执行
治理凭据时顺带踩到的一个点,值得单独记:
mysql:
volumes:
- mysql-data:/var/lib/mysql # 数据卷
- ./mysql/init:/docker-entrypoint-initdb.d:ro # 初始化脚本
MySQL 官方镜像只在【数据卷为空】时执行 /docker-entrypoint-initdb.d/ 下的脚本。
也就是说:改了 init/ 下的 SQL 之后,已经初始化过的服务器不会重跑。
两种应对方式
# 方式 A:彻底重来(⚠️ 会清空业务库数据)
docker compose down -v
# 方式 B:只补某个库(推荐)
docker exec -i api-cloud-mysql mysql -uroot -p"$MYSQL_ROOT_PASSWORD" < mysql/init/02-init-api-cloud.sql
这是最容易漏的一条:你改完 SQL、重启容器、认为"配置已经生效了",而实际上那条 SQL 从未执行过。而且它不会有任何报错——MySQL 只是"看数据卷非空,跳过初始化"。
七、验证清单
# 1. 全文搜索旧密码残留(应为 0 次)
grep -rn "api_cloud123456" deploy/ || echo "无残留 ✅"
# 2. 确认 compose 里注入了变量
docker compose config | grep -A2 MYSQL_PASSWORD
# 3. 确认容器里真的有这个环境变量
docker exec api-cloud-user env | grep MYSQL_
# 4. 确认 Nacos 上存的是占位符而非明文
# (见上文的回读命令)
# 5. 确认服务真的连上了库
docker exec api-cloud-user curl -s http://127.0.0.1:8081/actuator/health
# 期望:{"status":"UP"}
第 1 条和第 5 条是必做的。 前者防止残留,后者证明端到端生效。
八、复盘
| 现象 | 修好之后重跑部署脚本,又坏了 |
| 根因 | 同一个密码有 4 份副本,改一处造成漂移,脚本会把旧值推回去 |
| 治本 | 占位符 + .env 单一注入点,密码副本从 4 份降到 1 份 |
三条可复用经验
-
凭据只能有一处真源。 任何"同一个秘密存在多处、要求你手动同步"的设计,都注定会漂移。不要靠纪律,要靠结构——删掉多余的副本。
-
"修好了又坏"优先怀疑覆盖。 如果一个故障在"重跑某个流程"之后复现,先问:这个流程会不会把某个正确值覆盖掉?
-
改了
environment必须up -d,不能restart。 这是本项目最高频的"改完没生效"原因,没有之一。
原创文章,作者:ECHO陈文,如若转载,请注明出处:https://www.luweipai.cn/ops/1791424857/