一次「Windows 上改个版本号,Linux 上就起不来」的排障经历。报错指向镜像仓库,根因却藏在打包机的一个换行符里。这类问题症状具有极强误导性——它看起来像运维问题、像仓库问题、像镜像没推上去,唯独不像编码问题。整理成文,供同样跨 Windows / Linux 做发布的人参考。
背景
一套用 Docker Compose 编排的服务,镜像构建后推送到内网镜像仓库,配置通过 .env 注入:
1.env2├── <span>REGISTRY</span>=registry.example.com3├── IMAGE=demo/demo-app4└── TAG=<span>1.4</span>.<span>2</span>
1services:2 app:3 image: <span>${REGISTRY}</span>/<span>${IMAGE}</span>:<span>${TAG}</span>
发布流程很简单:
- 在 Windows 上直接改
.env里的TAG; - 用发布工具打包、上传;
- 到 Linux 服务器上
docker compose up -d。
这条链路跑了很多次都正常,直到某次改完版本之后,服务再也起不来了。
现象:镜像"找不到"
<span>1</span>$ docker compose up <span>-</span>d2[<span>+</span>] <span>Running</span> <span>0</span><span>/</span><span>13</span> ⠿ app Error4Error response <span>from</span> daemon: manifest <span>for</span> registry.example.com<span>/</span>demo<span>/</span>demo<span>-</span>app:<span>1.4</span><span>.25</span><span>not</span> found: manifest <span>unknown</span>: manifest <span>unknown</span>
有时候换一种表现:
1<span>Error</span> response <span>from</span> <span>daemon</span>: invalid reference format2# 或3<span>Error</span> response <span>from</span> <span>daemon</span>: pull access denied <span>for</span> demo/demo-app,4repository does not exist or may <span>require</span> <span>'docker login'</span>
第一反应和绝大多数人一样:版本号写错了 / 镜像没推上去 / 仓库有问题。
排查:三条路都走不通
| 排查动作 | 结果 |
|---|---|
| 登仓库页面看 tag | `1.4.2` **在**,而且是半小时前推的 |
| `docker pull registry.example.com/demo/demo-app:1.4.2` | 手敲这条——**成功** |
| 但在部署目录 `docker compose up -d` | 依旧 `not found` |
关键矛盾出现了:
同一条镜像地址,手敲成功,compose 展开失败。
这说明问题不在仓库、不在镜像、不在网络——在于 compose 展开出来的那个字符串,和手敲的不是同一个东西。
顺着这个方向,去查变量来源 .env:
<span>1</span>$ file .env2.env: ASCII text, with CRLF line terminators
with CRLF line terminators —— 元凶浮出水面。
定位:让不可见字符现形
第一招:cat -A 把行尾显出来
<span>1$ </span><span><span>cat</span> -A .<span>env</span> | <span>head</span> -32REGISTRY=registry.example.com^M<span>$3IMAGE</span>=demo/demo-app^M<span>$4TAG</span>=1.4.2^M$</span>
^M$ 就是 \r\n。正常的 LF 文件这里只显示 $。
第二招(杀手锏):docker compose config
这是排查这类问题最好用的一条命令——它输出的是变量已经展开、合并、解析完成的最终配置,也就是 daemon 真正会看到的东西:
<span>1</span>$ docker compose config | grep -n <span>'image:'</span><span>212</span>: image: registry.example.com/demo/demo-app:<span>1.4</span><span>.2</span>
肉眼完全看不出问题,因为 \r 是个不可见字符。所以要再接一层:
<span>1$ </span><span>docker compose config | <span>cat</span> -A | grep -n <span>'^M'</span>212: image: registry.example.com/demo/demo-app:1.4.2^M$</span>
到这里实锤:compose 展开出的镜像引用,末尾带了一个 \r。
根因:\r 被当成了变量值的一部分
.env 是逐行解析的,解析器按换行切分,把 = 右边到行尾之间的内容都当作值。Windows 换行是 \r\n,于是:
1文件里写的:<span>TAG</span>=<span>1.4</span>.<span>2</span>\r\n2实际读到的:TAG = <span>"1.4.2\r"</span>
compose 展开后:
1image: <span>${REGISTRY}</span>/<span>${IMAGE}</span>:<span>${TAG}</span>2<span># ↓ 实际变成3# registry.example.com/demo/demo-app:1.4.2\r</span>
daemon 拿到这个引用,就老老实实去拉一个名叫 1.4.2\r 的 tag —— 仓库里当然没有,于是回一句 manifest not found。
不同报错对应的其实是同一个原因:
| 报错 | 真实原因 |
|---|---|
| `manifest for xxx:1.4.2 not found` | tag 里多了 `\r`,拉了个不存在的 tag |
| `invalid reference format` | 引用里有非法字符(`\r` 不是合法字符) |
| `pull access denied` / `repository does not exist` | 仓库名被 `\r` 污染,被当成另一个私有仓库 |
| 本地 `docker run` 报 `no such image` | 同上 |
三个让这个坑更隐蔽的事实
① 不同版本 docker compose 对 .env 里 \r 的处理并不一致。 有的版本会顺手 trim 掉,有的不会。所以这个 bug 的表现是「换台机器就复现 / 换个人 clone 就好了」,非常像玄学,千万不要赌版本行为。
② compose 文件本身带 CRLF,往往不报错。 YAML 规范允许 \r\n 作为换行,解析器多数能容忍。所以经常出现「.env 干净、compose 文件脏」或者反过来的一半一半情况——出问题时两边都得查,别只盯一处。
③ UTF-8 BOM 是同一类坑,而且更隐蔽。 如果 .env 首行被 Windows 工具加上了 BOM:
<span>1$ </span><span><span>head</span> -c 3 .<span>env</span> | <span>od</span> -An -tx12 ef bb bf</span>
那么第一个变量名会变成 \uFEFFREGISTRY,${REGISTRY} 直接取空,镜像地址前面少一截——报错和 CRLF 一模一样,但用 cat -A 看不出来(BOM 在行首,不在行尾)。查 BOM 要用 od 或 hexdump。
为什么这个 bug 特别难查
复盘一下它为什么耗时间:
- 报错指向下游:daemon 说"镜像不存在",把你的注意力引向镜像仓库、网络、认证,而根因在上游的打包机;
- 字符不可见:
\r打印不出来,cat、grep、diff都当它不存在,git diff也常常看不出来; - 手敲能复现成功,脚本失败:这个反差是唯一的破案线索,但也最容易被人忽略("我明明 pull 下来了啊")。
经验:当"手敲成功、脚本失败"时,第一件事不是重试,而是把脚本实际展开的那个字符串打印出来。
修复
应急:先把眼前的服务拉起来
<span>1# </span><span>方式一:清掉目录里所有相关文件的 CR2find . -<span>type</span> f ( -name <span>'.env'</span> -o -name <span>'.env.*'</span> -o -name <span>'*.env'</span> ) -print0 \3 | xargs -0 sed -i <span>'s/\r$//'</span>45<span># 方式二:临时用一份去 CR 的 env,不动原文件6docker compose --env-file <(tr -d '\r' < .env) up -d</span></span>
根治思路:把清理动作收口到一个入口脚本
每次都手工 sed 显然不现实。既然服务的启停本来就该有统一入口,那就把「规范化」塞进这个入口里——所有 docker compose 动作之前,先把 .env / compose 文件 / *.sh 洗一遍。
但这里立刻撞上一个次生坑:
你写的
run.sh自己,也是从 Windows 打包出来的。
也就是说,run.sh 很可能同样带着 CRLF。而带 CRLF 的 shell 脚本会在 then\r / fi\r / do\r 这些地方直接语法崩溃——清理逻辑还没跑到,脚本自己先死了。
一个让脚本自我修复的小技巧
思路是:脚本开头先检查自己有没有 CRLF / BOM,有就修好再 exec 重入一次。
难点在于——「检查 + 修复」这段代码本身也在这个可能带 CRLF 的文件里,它不是应该先崩溃吗?
答案是:把这段自愈代码写成"整条压在一行、并且以 # 注释收尾" 。
<span>1</span>#!<span>/bin/</span>sh2# 自愈:自身若为 <span>CRLF</span> / <span>BOM</span>,先修好自己再重入。<span>3</span># 【重要】下面两行必须保持「单行 + 以注释结尾」的写法,格式化换行即失效。4<span>if</span> grep -q <span>"$(printf '\r')"</span> <span>"$0"</span> <span>2</span>><span>/dev/</span><span>null</span>; then sed -i <span>"s/$(printf '\r')$//"</span> <span>"$0"</span>; exec sh <span>"$0"</span> <span>"$@"</span>; fi # 去行尾 <span>CR5</span><span>if</span> [ <span>"$(head -c 3 "</span>$0<span>" | od -An -tx1 | tr -d ' \n')"</span> = <span>"efbbbf"</span> ]; then sed -i <span>"1s/^\357\273\277//"</span> <span>"$0"</span>; exec sh <span>"$0"</span> <span>"$@"</span>; fi # 去 <span>BOM</span>
为什么这样能成立,两点:
\r只出现在行尾。整条if...fi压成一行之后,行尾那个\r落在了#后面的注释里 —— 注释一直延伸到行尾,\r只是注释内容的一部分,完全无害。如果把then/fi单独换行写,结尾就变成then\r、fi\r,立刻语法错误。- shell 是边读边执行的。
sh不会先把整个文件解析完再动手,而是解析一个完整命令、执行一个。第 2 行是一个完整命令,执行到exec sh "$0"时进程已经被干净的副本替换掉了,后面那些多行结构根本没机会被解析。等重入之后文件已经是纯 LF,一切正常。
这段逻辑对自己是幂等的:修完重入,再检查就干净了,正常往下走。
一个必须遵守的调用约定
用 sh run.sh start,不要用 ./run.sh start。
因为 ./run.sh 直接执行时,是内核先读 shebang 那一行。如果它是 #!/bin/sh\r,内核会直接报:
1bad <span>interpreter</span>: <span>/bin/</span>sh^M
自愈代码在第 2 行,根本来不及运行。 而 sh run.sh 是把文件交给解释器,第 1 行退化成普通注释,第 2 行的自愈立刻生效。
入口脚本的骨架
<span>1</span>#!/bin/sh2# (上面那两行自愈代码放这里)<span>34</span>CR=$(printf <span>'\r'</span>)<span>56</span># 规范化:去 BOM + 去行尾 CR,sed -i 原地改,保留文件权限位<span>7</span>normalize_all() {<span>8</span> find <span>"<span>$APP_DIR</span>"</span> -maxdepth <span>"<span>$NORMALIZE_MAXDEPTH</span>"</span> -type f \<span>9</span> ( -name <span>'.env'</span> -o -name <span>'.env.*'</span> -o -name <span>'*.env'</span> \<span>10</span> -o -name <span>'docker-compose*.yml'</span> -o -name <span>'docker-compose*.yaml'</span> \<span>11</span> -o -name <span>'compose*.yml'</span> -o -name <span>'compose*.yaml'</span> -o -name <span>'*.sh'</span> ) \<span>12</span> -not -path <span>'*/.git/*'</span> <span>2</span>>/dev/<span>null</span> > <span>"<span>$_tmp</span>"</span><span>1314</span> <span>while</span> IFS= read -r f; do15 # 只有确实脏了才写盘,干净文件不动 mtime16 has_cr <span>"<span>$f</span>"</span> || has_bom <span>"<span>$f</span>"</span> || continue17 sed -i -e <span>"1s/^\357\273\277//"</span> -e <span>"s/<span>$CR</span>$//"</span> <span>"<span>$f</span>"</span><span>18</span> done < <span>"<span>$_tmp</span>"</span><span>19</span>}<span>2021</span><span>case</span> <span>"<span>$1</span>"</span> in22 start) pre; dc up -d <span>"$@"</span> ;;<span>23</span> stop) pre; dc stop <span>"$@"</span> ;;<span>24</span> restart) pre; dc restart <span>"$@"</span> ;;<span>25</span> check) pre check;; # 只检测不修改,有问题 exit <span>1</span> —— 给流水线用<span>26</span> config) pre; dc config;; # 打印展开后的最终配置 —— 排错首选<span>27</span>esac
几个值得注意的实现细节:
- 用
sed -i原地改,而不是「读出来 → 重写文件」:后者会把脚本的权限位(可执行位)洗掉,sed -i不会。 - 干净文件不写盘:避免每次启动都刷新所有文件 mtime。
- 顺手一起去掉 BOM:BOM 和 CRLF 往往是同一个 Windows 工具一起带进来的,一次处理干净。
- 加一个
check子命令:只检测不修改、有问题退出码非零,直接就能接进 CI,把问题挡在部署之前。
更彻底一点:让 start 失败时自动给诊断
在部署目录里排查的人往往不知道有这么回事。让入口脚本在镜像拉取失败时主动提示:
<span>1</span><span>if</span> <span>grep</span> -Eq <span>'manifest .* not found|invalid reference format|pull access denied|no such image'</span> <span>"$log"</span>; then2 err <span>"镜像「找不到 / 引用非法」,这是 CRLF 混进镜像引用的典型症状:"</span><span>3</span> err <span>" 1) sh run.sh check # 看还有没有 CRLF/BOM 文件"</span><span>4</span> err <span>" 2) sh run.sh config # 看变量展开后的最终 image 值"</span><span>5</span>fi
下一次再有人踩,报错信息会直接告诉他去哪儿看。
三道防线:从"能跑"到"不再复发"
运行时兜底只能救急。要让它不再复发,得从源头把住。
第一道:仓库约定(.gitattributes)
放到仓库根目录。核心是显式把关键文件钉死为 LF——默认行为在不同平台、不同 git 配置下是不一致的,不能依赖:
<span>1</span># 默认全部以 LF 入库、以 LF 检出<span>2</span>* text=auto eol=lf34# 一旦 CRLF 就会直接导致服务起不来的文件,显式钉死<span>5</span><span>.env</span> text eol=lf6<span>.env</span>.* text eol=lf7*<span>.env</span> text eol=lf8*<span>.yml</span> text eol=lf9*<span>.yaml</span> text eol=lf10Dockerfile* text eol=lf11*<span>.sh</span> text eol=lf1213# 必须保留 CRLF 的,单独声明(否则 Windows 下会坏)<span>14</span>*<span>.bat</span> text eol=crlf15*<span>.cmd</span> text eol=crlf1617# 二进制,别让 git 乱转<span>18</span>*<span>.png</span> binary19*<span>.pdf</span> binary20*<span>.zip</span> binary
已经提交过的 CRLF 文件不会自动回正,要重新归一化一次:
<span>1</span>git <span>add</span> --renormalize <span>.2</span>git commit -m <span>"chore: 统一换行为 LF"</span>
配套再加一个 .editorconfig,让 IDE 保存时就写 LF,从源头不产生 CRLF:
<span>1root</span> = <span>true</span>2[*]<span>3</span>end_of_line = lf4insert_final_newline = <span>true</span>5charset = utf-<span>8</span>
Windows 开发机上的 git 配置也建议改掉默认值(Git for Windows 默认 core.autocrlf=true,正是它把 LF 转成了 CRLF):
<span>1</span>git config <span>--global</span> core<span>.autocrlf</span> <span>input</span>
含义是:提交时把 CRLF 转成 LF,检出时不转(保持仓库里的 LF)。
第二道:发布侧闸门
真正的源头在打包机。在发布工具的「打包」步骤之前插一条检查,不干净就中断发布:
<span>1# </span><span>只检查不修改,发现问题 <span>exit</span> 12.\发布前清理CRLF.ps1 -Path . -Recurse -Check</span>
这条闸门加上之后,带 CRLF 的版本根本出不了打包机,比事后在服务器上修靠谱得多。
第三道:流水线兜底
<span>1# </span><span>纯 git 版本,不依赖任何脚本2if git grep -lI $<span>'\r'</span> -- <span>'*.env'</span> <span>'.env*'</span> <span>'*.yml'</span> <span>'*.yaml'</span> <span>'Dockerfile*'</span> <span>'*.sh'</span>; then3 <span>echo</span> <span>"::error::发现 CRLF 文件,请修正后重提"</span>4 <span>exit</span> 15fi</span>
自查清单
下次再遇到「镜像找不到」,按这个顺序走,能省掉大部分弯路:
| \# | 检查项 | 命令 | 要点 | ||
|---|---|---|---|---|---|
| 1 | 手敲镜像地址能否 pull | `docker pull <完整地址>` | 手敲成功 = 问题在展开的字符串里 | ||
| 2 | 变量展开后的真实值 | `docker compose config | grep 'image:'` | **排错首选**,daemon 看到的就是它 | |
| 3 | 展开结果里有无不可见字符 | `docker compose config | cat -A | grep '^M'` | `^M` = `\r` |
| 4 | 文件换行符 | `file .env`、`cat -A .env | head` | `with CRLF line terminators` / `^M$` | |
| 5 | 文件有没有 BOM | `head -c 3 .env | od -An -tx1` | `ef bb bf` = BOM,`cat -A` 看不出来 | |
| 6 | 目录里还有哪些文件脏 | `grep -rlU $'\r' . --include='.env*' --include='*.yml'` | 全量扫一遍,别只查 `.env` | ||
| 7 | 防复发是否到位 | 看 `.gitattributes` | `*.env text eol=lf` 这条是核心 |
三条核心认知
- 报错位置 ≠ 故障位置。 daemon 说"镜像不存在",真正的问题在几小时前的打包机上。顺着报错往下游查,只会撞墙;要往上游追问「这个字符串是怎么来的」。
- "手敲成功、脚本失败"是跨平台问题的典型指纹。 一旦出现这个反差,不要重试、不要怀疑网络,直接把脚本实际展开的那个字符串打印出来对比 —— 差异一定在肉眼看不见的地方。
cat -A、od -c、hexdump就是干这个的。 - 跨平台发布链路上的第一嫌疑犯永远是换行符和编码。 CRLF、BOM、GBK/UTF-8 混用,这三样吃掉的排查时间,大概比所有网络问题加起来还多。与其每次事后救火,不如一开始就把
.gitattributes和发布侧闸门立起来——这类问题的正确解法不是"查得出来",而是"不允许发生" 。
跨平台发布的“玄学故障”多源于换行符与 BOM。本文把定位命令、自愈脚本和防复发三道防线讲得很透,适合 Windows 开发、Linux 部署的团队直接照抄落地。