现象
一套服务,生产和测试用同一份 docker-compose.yml,靠 .env 区分环境。测试环境的 .env 写得清清楚楚:
REKIT_SITE_BASE_URL=https://test.example.cn
REKIT_AUTH_URL=https://login.test.example.cn
docker compose up -d 起来,容器健康,端口通,/health 返回 200。看起来一切正常。
直到点了一下登录按钮——跳到了生产的登录页。
原因
compose 文件里是这么写的:
services:
site:
env_file:
- .env
environment:
HOST: 0.0.0.0
PORT_SITE: 5173
REKIT_SITE_BASE_URL: https://example.com # ← 写死的生产地址
REKIT_AUTH_URL: https://login.example.com # ← 写死的生产地址
Compose 的优先级规则是:environment 高于 env_file。同名变量,environment 赢。
所以 .env 里那两行从来没生效过。而这个覆盖是完全静默的——compose 不警告、不打日志、docker compose config 要你主动去看才看得到。容器照常启动,健康检查照常通过,因为健康检查只探端口。
为什么这类 bug 特别难发现
它有三个性质叠在一起:
- 不报错。 覆盖是设计行为,不是错误。
- 健康检查发现不了。
/health只证明进程活着,不证明它配置对。 - 只在跨环境时暴露。 生产环境里
environment写死的值恰好就是对的,所以生产一直好好的,没人会去动它。
也就是说,这个 bug 会一直潜伏到你第一次尝试把服务部署到别的环境——而那时候你会先怀疑自己的 .env 写错了、怀疑挂载路径、怀疑 compose 没重建容器,绕一大圈才想到去看优先级。
怎么确认
别猜,进容器看实际值:
docker compose exec <service> env | grep -E '^REKIT_'
或者不启动容器,直接看 compose 合并后的最终配置:
docker compose config
后者更好,因为它把 env_file、environment、.env 变量替换全部展开成最终结果,是「compose 究竟怎么理解你这份文件」的权威答案。
怎么改
原则很简单:environment 里只放跟容器本身绑定、不随环境变化的东西,其余一律交给 env_file。
services:
site:
env_file:
- .env
environment:
# 只留容器自身的运行参数
HOST: 0.0.0.0
PORT_SITE: 5173
HOST 和 PORT_SITE 是容器内部的监听配置,任何环境都一样,写死没问题。对外地址、密钥、数据库连接这些天然随环境变化的,全部走 .env。
如果确实想在 compose 里给个默认值,用变量替换而不是硬编码,这样 .env 仍然能覆盖:
environment:
REKIT_SITE_BASE_URL: ${REKIT_SITE_BASE_URL:-https://example.com}
注意这跟前面那种写法的区别:${VAR:-default} 是先取 .env 里的 VAR,没有才用默认值,方向正好反过来。
更一般的教训
这是「生产值写死在代码/配置里」的一个变种。同一个项目里,我在一天内撞到过四处同类问题:
- compose 少写
command,容器跑的是镜像默认 CMD,结果一个本该是官网的容器实际在跑后端进程; - compose 的
environment写死生产 URL(就是本文); - 后台的「对接文档」页把服务地址写死成生产域名,测试环境的后台照样显示生产地址,接入方照着抄就打到生产上了;
- 代码里把登录跳转的站点标识写死成生产站点的 slug。
它们的共同点是:在生产环境下全都是对的,所以能长期存活;一旦换环境就集体失效,而且失效方式都是静默的。
所以换环境部署之后,别只看 /health。进容器核对一遍实际生效的环境变量,再手动点一遍关键链路(登录、回调、支付跳转)。这几分钟能省掉后面几个小时。