一个 DOMAIN 要经过两次展开才到 Caddy:宿主机上一次,容器里一次。沿两次提交拆开看,顺带讲怎么验证。
5 月 11 日先搭 VPS 入口。Caddyfile 里用 {$DOMAIN},仓库的环境文件也准备了域名,服务却拿不到它。回看当天提交,修复落在两个地方,正好把变量经过的路径说明白。
第一处:部署脚本要选对环境文件
部署脚本调用 Compose 时,先要选对环境文件。当天 deploy.sh 的 af995ef 版本给 edge 的调用补了:
--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 里只写键不写值时,值要由使用者自己保证;解析不到的话,这个变量会直接从容器环境里删掉,而不是留一个空串。
文字版
仓库 .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 修改有代码可对照,但提交只说明文件里写了什么,说明不了运行中的容器读到了什么。要确认某次发布确实恢复,还得请求目标域名,看实际运行的入口有没有加载它。
沿着前面那张表,每一层都有一条现成命令可查:
- Compose 展开成什么:
docker compose config会解析变量并渲染出最终配置,加--environment能打印插值用到的环境。DOMAIN 是不是空的,这里就看得见。 - 进程有没有加载:在运行中的容器里执行
caddy reload,这是官方镜像推荐的 reload 方式;镜像工作目录已经是/etc/caddy,不用再给路径。 - 入口通没通:请求目标域名。
最后一个建议:把“警告后变空”改成“当场失败”。Compose 侧写成 ${DOMAIN:?error},未设置或为空时都会直接报错退出;Caddy 侧写 {$DOMAIN:localhost},至少不会起一个地址为空的站点。前者我更推荐,入口配错就该在部署时失败。
留下几句话 读者来信
读到这里,有什么想法?欢迎留下你的看法。