Docker镜像找不到排障实录-CRLF跨平台暗雷

0 阅读7分钟

Docker 镜像"找不到"排障实录:一个不可见字符引发的跨平台暗雷

一次「Windows 上改个版本号,Linux 上就起不来」的排障经历。报错指向镜像仓库,根因却藏在打包机的一个换行符里。这类问题症状具有极强误导性——它看起来像运维问题、像仓库问题、像镜像没推上去,唯独不像编码问题。整理成文,供同样跨 Windows / Linux 做发布的人参考。


背景

一套用 Docker Compose 编排的服务,镜像构建后推送到内网镜像仓库,配置通过 .env 注入:

1.env2├── REGISTRY=registry.example.com3├── IMAGE=demo/demo-app4└── TAG=1.4.2
1services:2  app:3    image: ${REGISTRY}/${IMAGE}:${TAG}

发布流程很简单:

  1. 在 Windows 上直接改 .env 里的 TAG
  2. 用发布工具打包、上传;
  3. 到 Linux 服务器上 docker compose up -d

这条链路跑了很多次都正常,直到某次改完版本之后,服务再也起不来了。


现象:镜像"找不到"

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

有时候换一种表现:

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'

第一反应和绝大多数人一样:版本号写错了 / 镜像没推上去 / 仓库有问题。


排查:三条路都走不通

排查动作结果
登仓库页面看 tag1.4.2 ,而且是半小时前推的
docker pull registry.example.com/demo/demo-app:1.4.2手敲这条——成功
但在部署目录 docker compose up -d依旧 not found

关键矛盾出现了:

同一条镜像地址,手敲成功,compose 展开失败。

这说明问题不在仓库、不在镜像、不在网络——在于 compose 展开出来的那个字符串,和手敲的不是同一个东西

顺着这个方向,去查变量来源 .env

1$ file .env2.env: ASCII text, with CRLF line terminators

with CRLF line terminators —— 元凶浮出水面。


定位:让不可见字符现形

第一招:cat -A 把行尾显出来

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 真正会看到的东西:

1$ docker compose config | grep -n 'image:'212:    image: registry.example.com/demo/demo-app:1.4.2

肉眼完全看不出问题,因为 \r 是个不可见字符。所以要再接一层:

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,于是:

1文件里写的:TAG=1.4.2\r\n2实际读到的:TAG = "1.4.2\r"

compose 展开后:

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 foundtag 里多了 \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:

1$ head -c 3 .env | od -An -tx12 ef bb bf

那么第一个变量名会变成 \uFEFFREGISTRY${REGISTRY} 直接取空,镜像地址前面少一截——报错和 CRLF 一模一样,但用 cat -A 看不出来(BOM 在行首,不在行尾)。查 BOM 要用 od 或 hexdump


为什么这个 bug 特别难查

复盘一下它为什么耗时间:

  1. 报错指向下游:daemon 说"镜像不存在",把你的注意力引向镜像仓库、网络、认证,而根因在上游的打包机;
  2. 字符不可见\r 打印不出来,catgrepdiff 都当它不存在,git diff 也常常看不出来;
  3. 手敲能复现成功,脚本失败:这个反差是唯一的破案线索,但也最容易被人忽略("我明明 pull 下来了啊")。

经验:当"手敲成功、脚本失败"时,第一件事不是重试,而是把脚本实际展开的那个字符串打印出来。


修复

应急:先把眼前的服务拉起来

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 的文件里,它不是应该先崩溃吗?

答案是:把这段自愈代码写成"整条压在一行、并且以 # 注释收尾"

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

为什么这样能成立,两点:

  1. \r 只出现在行尾。整条 if...fi 压成一行之后,行尾那个 \r 落在了 # 后面的注释里 —— 注释一直延伸到行尾,\r 只是注释内容的一部分,完全无害。如果把 then / fi 单独换行写,结尾就变成 then\rfi\r,立刻语法错误。
  2. shell 是边读边执行的sh 不会先把整个文件解析完再动手,而是解析一个完整命令、执行一个。第 2 行是一个完整命令,执行到 exec sh "$0" 时进程已经被干净的副本替换掉了,后面那些多行结构根本没机会被解析。等重入之后文件已经是纯 LF,一切正常。

这段逻辑对自己是幂等的:修完重入,再检查就干净了,正常往下走。

一个必须遵守的调用约定

用 sh run.sh start,不要用 ./run.sh start

因为 ./run.sh 直接执行时,是内核先读 shebang 那一行。如果它是 #!/bin/sh\r,内核会直接报:

1bad interpreter: /bin/sh^M

自愈代码在第 2 行,根本来不及运行。  而 sh run.sh 是把文件交给解释器,第 1 行退化成普通注释,第 2 行的自愈立刻生效。

入口脚本的骨架
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 失败时自动给诊断

在部署目录里排查的人往往不知道有这么回事。让入口脚本在镜像拉取失败时主动提示:

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 配置下是不一致的,不能依赖:

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 文件不会自动回正,要重新归一化一次:

1git add --renormalize .2git commit -m "chore: 统一换行为 LF"

配套再加一个 .editorconfig,让 IDE 保存时就写 LF,从源头不产生 CRLF:

1root = true2[*]3end_of_line = lf4insert_final_newline = true5charset = utf-8

Windows 开发机上的 git 配置也建议改掉默认值(Git for Windows 默认 core.autocrlf=true,正是它把 LF 转成了 CRLF):

1git config --global core.autocrlf input

含义是:提交时把 CRLF 转成 LF,检出时不转(保持仓库里的 LF)。

第二道:发布侧闸门

真正的源头在打包机。在发布工具的「打包」步骤之前插一条检查,不干净就中断发布:

1# 只检查不修改,发现问题 exit 12.\发布前清理CRLF.ps1 -Path . -Recurse -Check

这条闸门加上之后,带 CRLF 的版本根本出不了打包机,比事后在服务器上修靠谱得多。

第三道:流水线兜底

1# 纯 git 版本,不依赖任何脚本2if git grep -lI $'\r' -- '*.env' '.env*' '*.yml' '*.yaml' 'Dockerfile*' '*.sh'; then3  echo "::error::发现 CRLF 文件,请修正后重提"4  exit 15fi

自查清单

下次再遇到「镜像找不到」,按这个顺序走,能省掉大部分弯路:

#检查项命令要点
1手敲镜像地址能否 pulldocker pull <完整地址>手敲成功 = 问题在展开的字符串里
2变量展开后的真实值`docker compose configgrep 'image:'`排错首选,daemon 看到的就是它
3展开结果里有无不可见字符`docker compose configcat -Agrep '^M'`^M = \r
4文件换行符file .env、`cat -A .envhead`with CRLF line terminators / ^M$
5文件有没有 BOM`head -c 3 .envod -An -tx1`ef bb bf = BOM,cat -A 看不出来
6目录里还有哪些文件脏grep -rlU $'\r' . --include='.env*' --include='*.yml'全量扫一遍,别只查 .env
7防复发是否到位看 .gitattributes*.env text eol=lf 这条是核心

三条核心认知

  1. 报错位置 ≠ 故障位置。  daemon 说"镜像不存在",真正的问题在几小时前的打包机上。顺着报错往下游查,只会撞墙;要往上游追问「这个字符串是怎么来的」。
  2. "手敲成功、脚本失败"是跨平台问题的典型指纹。  一旦出现这个反差,不要重试、不要怀疑网络,直接把脚本实际展开的那个字符串打印出来对比 —— 差异一定在肉眼看不见的地方。cat -Aod -chexdump 就是干这个的。
  3. 跨平台发布链路上的第一嫌疑犯永远是换行符和编码。  CRLF、BOM、GBK/UTF-8 混用,这三样吃掉的排查时间,大概比所有网络问题加起来还多。与其每次事后救火,不如一开始就把 .gitattributes 和发布侧闸门立起来——这类问题的正确解法不是"查得出来",而是"不允许发生"