现象

一套服务,生产和测试用同一份 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 特别难发现

它有三个性质叠在一起:

  1. 不报错。 覆盖是设计行为,不是错误。
  2. 健康检查发现不了。 /health 只证明进程活着,不证明它配置对。
  3. 只在跨环境时暴露。 生产环境里 environment 写死的值恰好就是对的,所以生产一直好好的,没人会去动它。

也就是说,这个 bug 会一直潜伏到你第一次尝试把服务部署到别的环境——而那时候你会先怀疑自己的 .env 写错了、怀疑挂载路径、怀疑 compose 没重建容器,绕一大圈才想到去看优先级。

怎么确认

别猜,进容器看实际值:

docker compose exec <service> env | grep -E '^REKIT_'

或者不启动容器,直接看 compose 合并后的最终配置:

docker compose config

后者更好,因为它把 env_fileenvironment.env 变量替换全部展开成最终结果,是「compose 究竟怎么理解你这份文件」的权威答案。

怎么改

原则很简单:environment 里只放跟容器本身绑定、不随环境变化的东西,其余一律交给 env_file

services:
  site:
    env_file:
      - .env
    environment:
      # 只留容器自身的运行参数
      HOST: 0.0.0.0
      PORT_SITE: 5173

HOSTPORT_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。进容器核对一遍实际生效的环境变量,再手动点一遍关键链路(登录、回调、支付跳转)。这几分钟能省掉后面几个小时。