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