← 返回文章选择

DOMAIN 写进 .env 了,为什么 Caddy 还是读不到

一个 DOMAIN 要经过两次展开才到 Caddy:宿主机上一次,容器里一次。沿两次提交拆开看,顺带讲怎么验证。

5 月 11 日先搭 VPS 入口。Caddyfile 里用 {$DOMAIN},仓库的环境文件也准备了域名,服务却拿不到它。回看当天提交,修复落在两个地方,正好把变量经过的路径说明白。

第一处:部署脚本要选对环境文件

部署脚本调用 Compose 时,先要选对环境文件。当天 deploy.sh 的 af995ef 版本给 edge 的调用补了:

Terminal window
--env-file "$REPO_ROOT/.env"

它告诉 Compose 从哪里取替换配置用的值。这一步在宿主机上完成,展开好的配置再交给 Docker。Docker 文档说得很直白,从 .env 文件替换变量是 Compose CLI 的功能,不是引擎做的,更不是容器里做的。

Compose 取值有三层,优先级从高到低:当前 shell 的环境变量、--env-file 指定的文件、项目目录下默认的 .env。传了 --env-file,默认路径就被换掉了;给多个文件时按顺序读,后面的覆盖前面的(同一页的 --env-file 一节)。所以选环境文件,换掉的是整条取值链的中间一层。

更麻烦的是失败方式。变量没设值时,Compose 只打印一条警告,再替换成空字符串,不会停下。少了 DOMAIN,docker compose up 照样跑起来,只是配置里那一格是空的。这类故障只有一条容易被刷过去的警告,只能逐层去验。

第二处:值进了 Compose,不等于进了容器

值进入 Compose,并不会自动变成容器内进程的环境变量,只补上一处还不够。几分钟后 be35d40 在 docker-compose.edge.yml 里改了 Caddy 的服务定义:

environment:
DOMAIN: ${DOMAIN}

${DOMAIN} 先由 Compose 展开,结果传入容器;Caddy 解析配置时,{$DOMAIN} 再从自己的环境里取值。两次展开一前一后:一次在宿主机上,配置还没进容器;一次在容器里,进程已经起来。两种写法很像,读取者却不同。

最容易漏的是中间这一步,看见 .env 有值,便以为服务也该有了。Compose 文档对 environment 属性的定义就是“设置在容器里的环境变量”,.env 只喂插值,不进容器。顺带一提,environment 里只写键不写值时,值要由使用者自己保证;解析不到的话,这个变量会直接从容器环境里删掉,而不是留一个空串。

DOMAIN 经宿主机插值与容器传入,由 Caddy 再展开

文字版
仓库 .env
→ 部署命令指定 env-file
→ Compose 展开 ${DOMAIN}
→ environment 传入 Caddy 容器
→ Caddy 展开 {$DOMAIN}

Compose 插值和Caddy 环境变量各有说明。排查时沿这张顺序逐层看,别在最上面的文件里反复改同一个值。

两次展开,各由谁来读

把上面的流程按“谁在读、什么时候读”拆开,界线就是容器创建那一刻:

${DOMAIN} {$DOMAIN}
写在哪 docker-compose.edge.yml Caddyfile
谁来展开 宿主机上的 Compose CLI 容器里的 Caddy
什么时候 建容器之前 Caddyfile 解析之前
取值来源 shell → --env-file → .env 容器自己的环境变量
没值时 只给一条警告、照样起来,值为空串 展开成空值

Caddy 这一侧也值得多看一眼。按 Caddyfile 概念页,{$ENV_VAR} 在解析开始之前就被替换,所以它可以展开成空值、半个 token,甚至多行。站点地址那一格变成空,Caddy 不会替你察觉。

同一页还给了两种别的写法:{$DOMAIN:localhost} 在变量缺失时用默认值;{env.DOMAIN} 则把替换推迟到运行时。三者看着差不多,生效时机完全不同。

改了 environment: 之后,必须让容器重建才拿得到新值。docker compose up 的说明写着,服务配置变了,它会停掉并重建容器(保留挂载卷);需要强制时用 --force-recreate。环境变量是建容器时定下的,在容器里 reload 多少次也拿不到。

文件对了,进程还可能是旧的

Authelia 的配置要从模板生成运行文件,这部分用 envsubst 固定输入输出:改动落在模板上,生成的运行文件只是产物。

后续新增入口时,也遇到过宿主机 Caddyfile 已更新、容器仍读旧内容的问题。容器看到的文件是不是新版,和运行中的进程有没有加载它,是两件事:

  • 文件不对,reload 也没用,重新加载的还是容器里现有的内容;
  • 文件对了,进程还带着旧配置,才轮到 reload。

“文件不对”有个常见的上游原因。官方 Caddy 镜像的说明明确提醒,不要把单个 Caddyfile 直接挂到 /etc/caddy/Caddyfile:用 vim 这类会换 inode 的编辑器改文件,容器里要等重建才看得到改动,优雅 reload 也可能不按预期工作。宿主机上的文件换了,容器手里还攥着旧 inode。

reload 本身管的只是第二种情况。按 Caddy 命令行文档,caddy reload 把 Caddyfile 重新 adapt 成配置,交给正在运行的实例,所以它会重读文件、重做变量替换。前提是容器里那份文件已经是新的。

所以部署除了更新文件,还要处理运行中的入口:先确认容器读到正确文件,再 reload 或重建。如果现在再整理这套入口,我会把 Caddyfile 改成挂目录,绕开单文件挂载的坑。

评分服务那时还没准备好,先放进 Compose profile,不默认启动。入口本来就在排错,没必要让未完成服务的报错挤满日志。按 Compose profile 文档,没写 profiles 的服务总会启动,写了的只在 profile 激活时才起,比把服务定义注释掉干净。

别只看提交,逐层验一遍

当天这两处 DOMAIN 修改有代码可对照,但提交只说明文件里写了什么,说明不了运行中的容器读到了什么。要确认某次发布确实恢复,还得请求目标域名,看实际运行的入口有没有加载它。

沿着前面那张表,每一层都有一条现成命令可查:

  1. Compose 展开成什么:docker compose config 会解析变量并渲染出最终配置,加 --environment 能打印插值用到的环境。DOMAIN 是不是空的,这里就看得见。
  2. 进程有没有加载:在运行中的容器里执行 caddy reload,这是官方镜像推荐的 reload 方式;镜像工作目录已经是 /etc/caddy,不用再给路径。
  3. 入口通没通:请求目标域名。

最后一个建议:把“警告后变空”改成“当场失败”。Compose 侧写成 ${DOMAIN:?error},未设置或为空时都会直接报错退出;Caddy 侧写 {$DOMAIN:localhost},至少不会起一个地址为空的站点。前者我更推荐,入口配错就该在部署时失败。

留下几句话 读者来信

读到这里,有什么想法?欢迎留下你的看法。