密码散落在四处:一次凭据漂移事故的治理

最典型的“修好了,重跑一次部署又坏了” —— 密码散在 Nacos 三处与 .env 里共四处,改一处就被下一次推送覆盖回去。本篇给出唯一真源方案:Nacos 存占位符、容器注入真值。

一、事故:修好了,重跑一次部署又坏了

这是一个特别有代表性的故障模式。

背景

数据库密码曾经散落在四个地方:

位置内容
Nacos 上的 user-service.ymlpassword: api_cloud123456(硬编码)
Nacos 上的 business-service.ymlpassword: api_cloud123456(硬编码)
Nacos 上的 order-service.ymlpassword: api_cloud123456(硬编码)
deploy/.envMYSQL_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_PASSWORD export 成 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 份

三条可复用经验

  1. 凭据只能有一处真源。 任何"同一个秘密存在多处、要求你手动同步"的设计,都注定会漂移。不要靠纪律,要靠结构——删掉多余的副本。

  2. "修好了又坏"优先怀疑覆盖。 如果一个故障在"重跑某个流程"之后复现,先问:这个流程会不会把某个正确值覆盖掉?

  3. 改了 environment 必须 up -d,不能 restart。 这是本项目最高频的"改完没生效"原因,没有之一。

原创文章,作者:ECHO陈文,如若转载,请注明出处:https://www.luweipai.cn/ops/1791424857/

  • 0 赞