
凌晨 2 点 17 分线上 Nacos 连不上数据库我在服务器上反复敲docker compose config和docker compose up -d看着同一个环境变量一会儿被替换成空串、一会儿带上了转义字符血压直线拉升。这种场景我猜很多读者都经历过Docker Compose 的环境变量平时看着特别简单无非就是写个.env文件再在 compose 文件里用${VAR}引用它。可真到出问题的时候变量没替换、变量没进容器、优先级不对、特殊字符被吃掉、宿主机和容器内变量互相干扰……每一个坑都能耗掉你大半个通宵。我带过几个团队自己也栽过不少次发现环境变量相关的问题翻来覆去就那么几类。这篇文章就把我从.env路径、变量替换、优先级、特殊字符到 systemd 纠缠、多环境切换、中间件部署连锁反应中总结出的 8 个致命坑一次性说透。不管你是刚接触容器还是正在用 Docker Compose 部署 Nacos、Redis、OpenKM 这类有状态服务照着这份清单排坑完全可以把凌晨三小时的排查压缩到十分钟以内。1. 先分清两套入口变量替换与运行时注入排坑前必须画在脑子里很多人排环境变量排到崩溃根本原因是没分清 Compose 里的两套完全不同机制。这两套机制虽然都叫“环境变量”但发生在不同阶段出问题的表现也完全不一样。1.1 变量替换发生在 Compose 解析阶段第一套是Compose 文件解析阶段的变量替换。你在docker-compose.yml里写image: ${IMAGE_NAME}执行docker compose up -d的时候Compose 会先从来源里读取环境变量把${IMAGE_NAME}替换成实际值然后再生成最终的运行配置。这个过程发生在容器启动之前你可以把它类比成 C 语言的预处理宏——真正编译之前宏就已经被替换完了。这套机制关心的是“Compose 文件里的变量从哪来”。来源包括当前 shell 导出的环境变量、项目目录下的.env文件以及命令行里用--env-file指定的文件。如果这个阶段出了问题最常见的表现是docker compose up能跑但跑起来的配置根本不是你想的那样比如镜像版本不对、端口不对、启动参数缺东西。1.2 运行时注入发生在容器创建阶段第二套是容器的运行时环境变量。你在 service 下面写environment:字段或者用env_file:引用一个文件这些变量会写进容器的进程环境里。容器里的应用启动时通过getenv()这类接口就能读到它们。这套机制关心的是“应用进程能看到什么”跟宿主机 shell 里的变量没有直接关系。很多人的误区就在这里在.env里写了个变量以为容器里自动就能读到了。实际上.env只参与第一套机制变量替换不会自动注入容器。想让变量进容器要么在environment:里显式写KEY: ${KEY}要么用env_file: .env。这两套通路要是没画清楚后面所有排查都是盲人摸象。我先把这个基础立住接下来这 8 个坑每一个都能对应到这两套机制中的某一环。2. 第 1 个致命坑.env 文件路径错位Compose 直接拿空值跑起来这是我觉得最阴的一个坑因为它完全不报错。2.1 项目目录与执行目录不一致时的静默失败默认情况下Compose 会从项目目录读取.env文件而项目目录通常就是docker-compose.yml所在的位置。但如果你用了-f指定了其他目录的 compose 文件或者用--project-directory改变了工作目录事情就变了。举一个我真实遇到过的例子项目放在/opt/project/里面有个docker-compose.yml和.env。有一天需要临时在/opt目录下执行命令我图方便写了docker compose -f /opt/project/docker-compose.yml up -d这条命令跑起来完全没问题容器也启动了。但问题是 Compose 默认去/opt/.env找环境变量文件而不是/opt/project/.env。文件找不到所有MYSQL_ROOT_PASSWORD这类变量就全部变成空值容器拿空密码去初始化数据库MySQL 直接初始化失败。更坑的是Compose 并不会因为你少了.env文件而停止它只会安静地继续执行。这就导致你看到的现象是“服务起不来”但根本猜不到是环境变量文件没加载。2.2 文件编码与隐藏字符也要背锅还有一次同事排了一整晚的 “配置没生效” 问题。最后发现是他用 Windows 的记事本编辑.env文件保存成了带 BOM 的 UTF-8 编码。BOM 字符被当成变量名的一部分了比如MYSQL_ROOT_PASSWORD实际变成了\ufeffMYSQL_ROOT_PASSWORDCompose 当然匹配不到。这个问题的隐蔽性在于你用cat .env看文件内容时BOM 字符通常不会直接显示出来肉眼根本发现不了。所以我的建议是.env文件统一用 UTF-8 无 BOM 编码编辑工具尽量用 VSCode、Vim 或者直接命令行写入。2.3 用 docker compose config 把问题定到替换阶段遇到“服务起来但行为不对”的情况第一步永远是先跑一遍docker compose config这个命令不会启动任何容器它只把最终解析后的配置打印出来。你一眼就能看到MYSQL_ROOT_PASSWORD到底被替换成了什么值。如果这里显示的值就是空的那说明问题出在变量替换阶段也就是.env没被正确读取如果这里显示的值正确但容器里的应用还是读不到那才是运行时注入阶段的问题。这一步能把问题一下子缩小一半范围省掉大量瞎折腾的时间。3. 第 2 个致命坑$、#、引号——特殊字符让密码和 Token 在眼皮底下丢失这类坑特别容易发生在密码、Token、私钥这类配置上因为人眼是不太容易察觉“密码的一部分被吃掉了”的。3.1 $ 符号被当成变量引用.env文件的解析规则和 shell 脚本类似但不完全一样。在.env里写DB_PASSWORDabc$defCompose 会把$def当作一个变量引用来处理如果def这个变量不存在最终得到的DB_PASSWORD就变成了abc。注意这里它不会报错只会静默替换成空串。如果你真的需要在值里保留$字符正确写法是DB_PASSWORDabc$$def或者加上引号让 Compose 不做展开DB_PASSWORDabc$def但这里又有另一个细节单引号和双引号在.env里的行为跟 shell 不完全一样。双引号内部的$仍然可能被展开单引号内部的$才比较安全。如果你需要保留一个字面量$我的建议是能加单引号就加单引号省心。3.2 # 被当成注释起始符这也是我自己的血泪史。之前部署一个服务数据库密码里带了个#字符我在.env里写了MYSQL_ROOT_PASSWORDabc#123Compose 把#123当成了注释实际传给数据库的密码只有abc。数据库连接一直报认证失败我一开始还怀疑是网络问题、权限问题怎么都没想到是密码被注释截断了。更麻烦的是容器里某些应用启动日志里不会打印密码明文你甚至无法确认它拿到的到底是什么值。这种“悄悄丢失”的体验真的很糟心。3.3 可靠写法建议后来我给自己定了一条死规矩凡是值里可能包含特殊字符的一律给整个值加双引号MYSQL_ROOT_PASSWORDabc#123$def如果还不行就单独抽出来测试。在docker-compose.yml里再想引用它就写environment: - MYSQL_ROOT_PASSWORD${MYSQL_ROOT_PASSWORD}这样至少保证从.env到 Compose 解析这段路上特殊字符不会在第一个弯道就阵亡。4. 第 3 个致命坑environment 与 env_file 的优先级你可能一直理解反了如果同一个服务里environment和env_file定义了同一个变量谁说了算很多在坑里爬过的同事第一反应是“后定义的那个覆盖先定义的”。但 Compose 的规则不是这样。4.1 environment 永远赢官方文档写得很明确environment中的条目会覆盖env_file中所有同名条目无论它们在文件中的顺序如何。也就是说environment的优先级是绝对的。举个例子services: app: env_file: - .env environment: - DB_HOSTdb-internal即使在.env里写了DB_HOSTdb-public容器里最终生效的也一定是db-internal。你要是把两个来源的顺序倒过来写services: app: environment: - DB_HOSTdb-internal env_file: - .env结果还是db-internal。environment永远赢。这个规则你要是记反了排查的时候会在两个文件之间来回改浪费大量时间。4.2 .env 不会自动进容器这是另一个常见误区这里还需要再强调一次项目根目录的.env文件只参与 Compose 文件中的${VAR}替换不会自动注入到容器里。很多人以为写在.env里的变量容器里就能读到结果 MySQL 初始化脚本里发现$MYSQL_ROOT_PASSWORD是空的服务起不来还以为是自己镜像配置错了。正确做法有两种要么在environment里显式映射environment: - MYSQL_ROOT_PASSWORD${MYSQL_ROOT_PASSWORD}要么直接用env_file把整个文件交给容器env_file: - .env两条路二选一别混着用。你在同一个 service 里两个都写了理解上又少一步复杂度就上来了。4.3 多个 env_file 的覆盖顺序如果同一个 service 下声明了多个env_fileenv_file: - .env.base - .env.override那么后面的文件会覆盖前面文件中的同名变量。也就是.env.override的优先级更高。这可以作为一个“环境覆盖”的小技巧但要注意所有文件里的变量都要齐全否则缺失的变量会变成空值而不是回退到前一个文件。这点和前一段讲的路径坑一样很容易让人误判。5. 第 4 个致命坑变量替换语法默认值、强制校验、$$ 转义写法缺一不可Compose 的变量替换语法看起来很简单就是${VAR}但实际操作中出现频率最高的三个细节每一个我都见过有人写错。5.1 默认值语法和强制校验语法${VAR:-default}是“没设置或为空时用默认值”。这个比较常见问题不大。${VAR:?error message}是“变量未设置时就报错退出”。这个语法在 CI/CD 里特别好用可以在部署前拦截明显的配置遗漏。比如environment: - NACOS_AUTH_TOKEN${NACOS_AUTH_TOKEN:?NACOS_AUTH_TOKEN must be set}一旦某个环境忘了注入这个变量docker compose up会在启动前直接报错把错误信息直接打出来。这比“服务起来了但行为不对”要好处理太多了。但是注意这个语法对 Compose 版本有要求。如果你还在用老版本的 docker-compose V1${VAR:?}可能直接解析报错你还会一头雾水地以为是 YAML 格式写错了。所以遇到这类问题先确认版本。5.2 $$ 转义让变量留到容器内部再解析$$是另一个高频知识点。在docker-compose.yml里写$$HOMECompose 会在解析阶段把它先转义成$HOME然后原样传进容器。容器里的应用或脚本再去解析$HOME的时候拿到的是容器内部的 HOME 路径而不是宿主机的。这个特性在写command、entrypoint或者挂载脚本时特别常用。举个例子services: app: image: alpine command: [sh, -c, echo $$HOME]如果没有$$Compose 会在宿主机解析阶段就把$HOME替换成宿主机上的路径容器里看到的是完全不对的值。我见过有人在这里卡了半天因为输出结果莫名其妙是宿主机路径怎么都想不通容器里哪来的这个路径。5.3 容易混淆的 $VAR 和 ${VAR}在 Compose 文件里$VAR和${VAR}都可以用来做变量替换。但在 YAML 解析时$VAR这种写法在某些场景下有歧义风险。比如你在环境变量值里写了FOO$BAR-baz解析器可能把$BAR-baz整体当作变量名也可能只把$BAR当变量。为了避免这种奇怪的边界情况我个人的习惯是一律写${VAR}括号形式虽然没有硬性要求但读代码的人不容易误解。6. 第 5 个致命坑systemd 拉起 Compose 时宿主机环境变量的来路与灭失这个话题在热搜词里占了不小比重像“systemd 从文件加载环境变量”“linux环境变量配置文件”“linux 新安装的服务器如何设置 jdk21 环境变量”都指向同一个问题你在终端里配置好的环境变量换到 systemd 服务里怎么就没了。6.1 经典现象交互终端正常开机自启失败我遇到过好几次这种问题在服务器上手动跑docker compose up -d一切正常服务访问也没问题。但只要一设置开机自启或者用 systemd 管理 Compose 服务启动后的容器行为就完全不对了。根因往往不是 Docker Compose 的配置而是 systemd 的进程环境。systemd 启动的进程不会加载~/.bashrc、~/.profile这套登录 shell 配置。你在终端里 export 过得变量比如export IMAGE_TAGlatestsystemd 根本不知道。它的环境变量来源只有Environment指令、EnvironmentFile指定的文件以及它自己继承的全局环境。6.2 一个典型的错误做法有人会在 systemd service 文件里写[Service] ExecStart/usr/local/bin/docker compose up -d然后靠系统级/etc/environment里配置的变量来“碰运气”。但要注意/etc/environment只被 PAM 登录会话读取systemd 是否读取取决于发行版和具体配置跟你预想的完全不一样。所以如果你是靠 systemd 管理 Compose 服务最稳妥的方式是显式指定EnvironmentFile[Service] EnvironmentFile/opt/project/.env ExecStart/usr/local/bin/docker compose up -d WorkingDirectory/opt/project同时注意不要在 systemd 服务里依赖 shell 变量展开因为 systemd 的服务配置不是 shell 脚本写$FOO不会按你的直觉展开。6.3 推荐的做法让 Compose 自己管变量我个人更推荐的做法是尽量别在 systemd 层维护变量而是把这些变量写进env_file让 Compose 自己负责注入。这样整个链路只有一条数据来源不会出现“终端里能跑、systemd 里跑不了”的分离场景。宿主机层面的 JDK、Node.js、Python 环境变量归宿主机管容器编排的环境变量归 Compose 管两者职责分开否则叠加在一起排查难度直接翻倍。7. 第 6 个致命坑多环境切换把 .env 维护成泥潭项目多了以后开发、测试、生产环境往往需要不同的配置。最朴素的做法是维护多份.env文件但这其中暗藏了不少细节。7.1 --env-file 的路径和顺序问题Compose 支持用--env-file指定环境变量文件。这个选项是一个全局选项也就是说它应该放在docker compose后、子命令前docker compose --env-file .env.prod up -d如果你写成docker compose up -d --env-file .env.prod很多版本会直接报错或者直接忽略这个参数。这个顺序问题在脚本里特别容易踩到尤其是从网上复制命令时。还有个坑--env-file指定的路径是相对于当前工作目录的不是相对于 compose 文件目录。如果你用-f指定了 compose 文件路径然后又用--env-file指定了环境变量文件两个路径基准不同一旦目录切换就容易找不到文件。7.2 变量来源优先级shell 环境变量为什么会覆盖你的配置文件多环境切换时最容易迷糊的是变量来源优先级。Compose 读取变量替换的来源时优先级大致是这样的优先级来源高shell 环境变量进程环境中--env-file 指定的文件低项目目录下的 .env 文件这就意味着假设你在 shell 里 export 了DB_HOST192.168.1.10然后又在.env.prod里写了DB_HOST10.0.0.10最后 Compose 用的一定是 shell 里那个值。很多人觉得“我指定了.env.prod里面的变量肯定生效”结果实际情况是被 shell 里的同名变量覆盖了自己在那边怎么改.env都没用白白折腾大半天。7.3 .env 入仓库的隐患与 .env.example 规范另外千万别把真实.env文件提交到 Git 仓库。这已经 2025 年了依然有人在仓库里把数据库密码带上出安全事故后才连夜改。经验做法是仓库里放一个.env.example变量名写完整敏感值留空或写占位符真实环境变量通过 CI Secret 或者部署机上的文件管理系统下发。如果项目里谁把.envpush 上去了该加的检查还是加一个gitignore拦截比较稳妥。多环境切换的终极状态应该是“启动命令本身就能区分环境”而不是在多个.env文件之间手工修改。我见过有的团队在docker-compose.prod.yml和docker-compose.dev.yml里各自维护变量这种做法反而更清晰因为配置和代码是粘在一起的不容易被忽略。8. 第 7 个致命坑部署 Nacos 3.x / Redis / OpenKM 这类中间件变量名错的连锁反应热搜词里能看到很多具体场景“docker compose 部署 naxos 3.x”“docker compose 中如何部署 openkm”“redis 安装 docker compose”。这些场景看着不同但踩坑模式高度一致第三方镜像的环境变量名有固定约定而且很多是静默默认值。8.1 镜像启动脚本的强约定和静默默认值一些成熟镜像比如 Redis、MySQL、Nacos在容器启动时会读取环境变量来生成配置或初始化数据。比如 Redis 的REDIS_PASSWORD、MySQL 的MYSQL_ROOT_PASSWORD。这些变量不是什么标准而是镜像作者写的启动脚本约定的。问题来了如果你把变量名拼错一个字母比如MYSQL_ROOT_PASSWRD镜像的启动脚本不会报错它检查不到这个变量就干脆不设置密码或者采用默认配置。这样服务照样能起来但行为和你的预期完全不一致。更麻烦的是很多中间件的启动日志里不会直接告诉你“密码未设置”你只能看到“认证失败”“连接被拒”之类的表象。8.2 Nacos 3.x 的典型变量问题Nacos 这个例子特别明显。老版本的 Nacos 镜像里数据库连接相关变量通常是MYSQL_SERVICE_HOST、MYSQL_SERVICE_PORT、MYSQL_SERVICE_USER、MYSQL_SERVICE_PASSWORD这种风格。但 Nacos 3.x 的镜像可能已经改用了新的变量命名网上搜出来的教程很多还是 1.x 时代的照着旧教程配一遍服务能起来但控制台登录不上、服务注册地址不对、鉴权失效排查起来比直接启动失败还费时间。这种时候别盲目相信搜索引擎正确做法是先docker compose config看自己最终渲染的变量对不对再去镜像仓库的官方文档里核对一遍变量名。很多官方镜像的 README 里都有完整的环境变量列表照着列表逐项检查比自己瞎猜变量名高效得多。8.3 中间件部署前必做的验证部署这类中间件之前我强烈建议在正式拉起之前做一次docker compose config把最终解析后的配置从头到尾看一遍。重点看三类东西环境变量名是否和官方文档一致变量值里有没有被特殊字符截断是否出现了没人定义的空值变量另外对于关键配置项我会在 compose 文件里主动加上强制校验environment: - NACOS_AUTH_TOKEN${NACOS_AUTH_TOKEN:?NACOS_AUTH_TOKEN required}宁可启动失败把问题暴露出来也不要让它带着错误配置悄悄运行起来。因为启动失败是最好排的行为异常才是最难排的。9. 第 8 个致命坑没有验证手段凌晨排坑全靠一遍遍 up 起服务最后这个坑我觉得是元凶中的元凶大多数人排环境变量问题靠的是一遍遍docker compose up -d加看报错。但环境变量层面的错误很少会在启动时报出来。正确做法是建立一套验证手段把“猜测”变成“对照”。9.1 docker compose config 是排错第一入口docker compose config的作用前面已经提过它会把最终的配置解析结果打印出来包括环境变量、卷、端口、镜像名。我在排查环境变量问题时第一动作永远是跑它看看变量到底解析成了什么。这里你会发现很多问题变量值变成了空串变量名拼写错了比如.env里的DB_PASSWORDcompose 文件里写的却是DB_PASSWRD同一个变量在多个来源里值不一致这一步能直接定位“变量替换”阶段的问题区分它和“容器运行时注入”阶段的问题。9.2 docker compose exec 和 docker inspect 验证运行时状态如果docker compose config显示变量没问题但容器内的应用表现不对那就需要验证运行时注入阶段。docker compose exec app sh -c echo $FOO注意这里进口的 shell 可能是sh而不是bash有些精简镜像甚至没有 shell。你可以直接用printenv命令来看全部环境变量docker compose exec app printenv如果容器已经退出了或者你想看更底层的信息用docker inspectdocker inspect 容器ID输出结果里的Env数组就是容器真正接收到的运行时环境变量。这一步不受容器内 shell 影响是最可靠的“运行时真相”。我个人排到最后通常是docker compose config和docker inspect两个命令对着看一个管“替换阶段”一个管“注入阶段”两边一对比问题立刻就能锁定。9.3 十分钟定位环境变量问题的排错菜单这套排错菜单我现在已经形成肌肉记忆了分享给读者先跑docker compose config确认解析结果是否符合预期再用docker compose exec app printenv确认容器内实际变量必要时用docker inspect 容器ID | grep -A 50 Env看运行时最终值如果结果不对回头查.env路径、变量优先级、特殊字符转义如果全对但行为不对才去考虑应用本身的配置和依赖按照这个顺序走环境变量相关的问题基本十分钟内能定位。最怕的就是没有验证手段靠感觉改配置改一次 up 一次把问题越试越乱。10. 兜底习惯把这套排错菜单刻进肌肉记忆文章写了 8 个坑但说到底真正值钱的是那套排错思路。工具和语法可以查文档思路得靠平时练。总结成几个我个人的习惯不一定适合所有人但你可以参考所有 Compose 项目里.env文件保持 UTF-8 无 BOM避免 Windows 编辑工具制造隐藏字符。启动脚本里先cd $(dirname $0)固定工作目录再执行docker compose命令从源头杜绝项目目录和当前目录不一致的问题。关键变量一律用${VAR:?error message}做强制校验把隐患提前到启动阶段暴露。需要在容器内保留的$一律写$$并且所有可能含特殊字符的值都加引号包裹。每次修改环境变量后跑一次docker compose config再做其他操作。环境变量本身不难难的是在深夜两点保持清醒把两套机制、五六个来源、七八个细节同时想清楚。有了这套方法至少能让你少熬几个通宵。