现象:
docker compose up -d一切正常,所有容器 healthy,没有任何报错。 但是应用启动后连不上 Gmail ------ 因为数据库里的 API Key 表,是空的。 罪魁祸首是一个你在编辑器里永远看不见 的字符:\r。
一、事故现场
我的项目 Linux-Kernel-Email-List-Analyzer 用 Docker Compose 编排了 MySQL、Redis、RabbitMQ、MinIO 和两个 Java 服务。
其中 MySQL 容器需要在启动时做两件事:建表,以及往 application_api_keys 表里插入各种 API Key(Gmail 的应用专用密码、DeepSeek 的 Key 等等)。这些密钥不能硬编码进 SQL 文件提交到 GitHub,所以走的是环境变量:
yaml
mysql:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
MYSQL_DATABASE: ${MYSQL_DATABASE}
# API Keys 传给初始化脚本
API_KEY_EMAIL: ${API_KEY_EMAIL}
API_KEY_EMAIL_VALUE: ${API_KEY_EMAIL_VALUE}
API_KEY_DEEPSEEK_ANALYZER: ${API_KEY_DEEPSEEK_ANALYZER}
# ...
volumes:
# 容器启动时将冒号左边的文件
# 映射到容器内名为 /docker-entrypoint-initdb.d 的特殊文件中,
# 然后按顺序执行该目录下的所有 .sql .sh .sql.gz 文件(仅启动时执行一次)
- ./docker-scripts/mysql/table-init.sql:/docker-entrypoint-initdb.d/01-table-init.sql
- ./docker-scripts/mysql/init-api-keys.sh:/docker-entrypoint-initdb.d/02-init-api-keys.sh
脚本本身平平无奇:
bash
#!/bin/bash
# 在 MySQL 容器就绪后,往 application_api_keys 表插入隐私数据的脚本
set -e
# 等待 MySQL 就绪
until mysqladmin ping -h localhost -uroot -p"${MYSQL_ROOT_PASSWORD}" --silent; do
echo "Waiting for MySQL start..."
sleep 2
done
# 插入 API Keys
mysql -uroot -p"${MYSQL_ROOT_PASSWORD}" --default-character-set=utf8mb4 "${MYSQL_DATABASE}" <<EOF
INSERT INTO `application_api_keys`(`application_name`, `api_key`) VALUES
('${API_KEY_EMAIL}', '${API_KEY_EMAIL_VALUE}'),
('${API_KEY_DEEPSEEK_ANALYZER}', '${API_KEY_DEEPSEEK_ANALYZER_VALUE}'),
('${API_KEY_DEEPSEEK_CHAT}', '${API_KEY_DEEPSEEK_CHAT_VALUE}'),
('${API_KEY_DEEPSEEK_ABSTRACT}', '${API_KEY_DEEPSEEK_ABSTRACT_VALUE}');
EOF
echo "API Keys init complete."
在 Windows 上写完、提交、docker compose up。
结果:01-table-init.sql 执行了 ,表建得好好的。02-init-api-keys.sh 像不存在一样 ------表是空的,日志里连一行 Waiting for MySQL start... 都没有,也没有 API Keys init complete.,更没有任何报错。
一个脚本,既没成功也没失败,就这么消失了。
二、结论先行
用 git ls-files --eol 一查,真相大白:
shell
$ git ls-files --eol -- "*.sh" "*.sql" "*.yml"
i/lf w/crlf attr/ .env.example
i/lf w/crlf attr/ Dockerfile.analyzer
i/lf w/crlf attr/ docker-compose.yml
i/lf w/crlf attr/ docker-scripts/mysql/init-api-keys.sh
i/lf w/crlf attr/ docker-scripts/mysql/table-init.sql
读法:i/ 是 index(git 仓库里) 的换行符,w/ 是 worktree(磁盘上) 的换行符。
仓库里全是干净的 LF,但我磁盘上的每一个文件都是 CRLF。
再看 git 配置:
shell
$ git config core.autocrlf
true
以及查看脚本文件的前 13 个字节:
shell
# Windows 系统可以使用 Format-Hex 命令查询
> Format-Hex -Path .\init-api-keys.sh -Count 13
Label: F:\Linux-Kernel-Email-List-Analyzer\docker-scripts\mysql\init-api-keys.sh
Offset Bytes Ascii
00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F
------ ----------------------------------------------- -----
0000000000000000 23 21 2F 62 69 6E 2F 62 61 73 68 0D 0A #!/bin/bash\r\n
统计整个文件:20 个换行,20 个都是 CRLF,0 个是纯 LF。
所以容器里那个脚本的第一行,实际内容不是 #!/bin/bash,而是 #!/bin/bash\r。
三、为什么一个 \r 就能让脚本"消失"
3.1 shebang 的解析规则
当 Linux 内核执行一个脚本文件时,会读它的前两个字节。如果是 #!,就进入 shebang 处理流程:把 #! 之后、直到行尾(\n)之前的所有内容,当作解释器路径和参数。
如果想知道 Linux 是如何选择二进制格式处理器以及如何解析 shebang 的, 请参阅下面的源代码:
内核不认识 \r。在 Unix 世界里,\r(0x0D,回车符)就是一个普普通通的可打印控制字符,和字母 x 没有本质区别。
于是内核解析出来的解释器路径是:
txt
/bin/bash\r
它老老实实地去找一个文件名末尾带回车符 的可执行文件。这个文件当然不存在,于是 execve() 返回 ENOENT。
如果你直接执行,会看到那句经典的、极具误导性的报错:
shell
$ ./init-api-keys.sh
bash: ./init-api-keys.sh: /bin/bash^M: bad interpreter: No such file or directory
这里的 ^M 就是 \r 的可视化表示(Ctrl+M,ASCII 13)。
为什么这个报错这么坑? 因为它说的是 "No such file or directory",而
/bin/bash明明就在那儿,你甚至可以ls -l /bin/bash确认。找不到的不是/bin/bash,是/bin/bash\r------而终端渲染时\r会把光标拉回行首,很多时候你根本看不到那个^M,只看到 "找不到 /bin/bash",然后陷入怀疑人生的循环。
3.2 为什么我连报错都没看到
上面那个报错至少还是个线索。而我的情况更隐蔽------日志里干干净净,什么都没有。
这要看 MySQL 官方镜像的 entrypoint 是怎么处理 /docker-entrypoint-initdb.d/ 的。其核心逻辑(docker-entrypoint.sh)大致是:
bash
for f in /docker-entrypoint-initdb.d/*; do
case "$f" in
*.sh)
if [ -x "$f" ]; then
echo "$0: running $f"
"$f" # ← 有执行权限:直接执行(走 shebang)
else
echo "$0: sourcing $f"
. "$f" # ← 无执行权限:用当前 shell source
fi
;;
*.sql) echo "$0: running $f"; docker_process_sql < "$f" ;;
*.sql.gz) ... ;;
*) echo "$0: ignoring $f" ;;
esac
done
这里有两条完全不同的路径,而且哪条都能被 CRLF 搞坏:
路径 A:文件有执行权限 → 直接执行 "$f"
走 shebang,触发上面说的 bad interpreter。而脚本里有 set -e......不对,set -e 都还没机会执行呢,脚本压根没启动。
路径 B:文件没有执行权限 → . "$f"(source)
这条路径没有 shebang 什么事 ------source 是让当前 bash 逐行读取并执行文件内容。这时 \r 的破坏方式完全不同,而且更阴险:
bash
set -e\r
bash 会把 \r 当作 set 命令的一个参数 。set -e\r 这个选项无效,报错。
bash
until mysqladmin ping -h localhost -uroot -p"${MYSQL_ROOT_PASSWORD}" --silent; do\r
do\r 不是合法的关键字,bash 报语法错误:syntax error near unexpected token。
最经典的是这种:
bash
sleep 2\r
bash 试图执行 sleep,参数是 2\r------sleep: invalid time interval '2\r'。
而通过 Windows 的 bind mount 挂载进容器的文件,权限位往往是宿主机文件系统模拟出来的 (Docker Desktop 的 WSL2 / gRPC-FUSE 层通常把 bind mount 的文件标记为 0755 或直接给全部权限),所以到底走 A 还是 B 依赖于你的 Docker Desktop 版本和文件共享后端------这也是为什么同一份代码在不同人机器上表现不一样。
无论走哪条,最终结果都是一样的:INSERT 语句从来没被执行过,但 MySQL 容器本身启动得好好的,健康检查也是通过的。
3.3 为什么故障被推迟到了很久以后
这是整件事最让人难受的地方------故障点和表现点隔了十万八千里。
csharp
MySQL 容器启动 → 建表成功 → healthcheck 通过 ✅
↓
init-api-keys.sh 静默失败 ❌(无日志)
↓
depends_on: condition: service_healthy 判定通过 ✅
↓
Java 应用容器启动 → Spring 上下文加载成功 ✅
↓
定时任务触发 → 查 application_api_keys 表 → 拿到 null
↓
store.connect(username, null) → 认证失败 ❌
你看到的第一个报错是 IMAP 认证失败 。于是你会去查 Gmail 应用专用密码对不对、代理通不通、.env 文件写没写错------方向全错,因为真正的故障发生在几分钟前的另一个容器里,而且没留下任何痕迹。
四、为什么是 .sh,而不是别的文件
回到那份 git ls-files --eol 的输出------注意一个诡异的事实:
这个仓库里 21 个文本文件,全部都是 i/lf w/crlf。 docker-compose.yml、Dockerfile.analyzer、table-init.sql、所有的 Spring application.yml......全都是 CRLF。
但只有那一个 .sh 炸了。
为什么?因为绝大多数格式的解析器都容忍 尾随的 \r:
| 文件 | 谁在解析 | CRLF 会怎样 |
|---|---|---|
docker-compose.yml |
Docker Compose 的 YAML 解析器 | ✅ YAML 规范明确把 CRLF 当作合法换行 |
Dockerfile.analyzer |
Docker BuildKit | ✅ 会主动 strip 掉 \r |
table-init.sql |
MySQL 客户端 | ✅ 语句以 ; 分隔,\r 落在空白处被忽略 |
application.yml |
SnakeYAML | ✅ 同 YAML 规范 |
*.java |
javac | ✅ JLS 明确把 CR、LF、CRLF 都算行终止符 |
init-api-keys.sh |
Linux 内核 + bash | ❌ \r 是普通字符,进入 token |
这就是这个坑最隐蔽的地方:你的整个项目都是 CRLF,而且一直工作得好好的,这会给你一种强烈的心理暗示------"换行符肯定不是问题,不然早就全崩了"。
恰恰相反:正因为别的都能容忍,唯一不能容忍的那个才格外难被发现。
shell 脚本在这方面是"格式洁癖":它是行导向 的语言,每一行的最后一个 token 会把 \r 吸进去。sleep 2 变成 sleep "2\r",fi 变成 fi\r,EOF 变成 EOF\r------连 heredoc 的结束标记都会因此匹配不上,导致整个 heredoc 读到文件末尾。
五、core.autocrlf 到底做了什么
很多人以为 CRLF 是"我用记事本编辑的时候存错了"。不是。是 git 主动帮你转的。
console
$ git config core.autocrlf
true
Git for Windows 安装时,默认就会把 core.autocrlf 设成 true。它的行为是:
sql
git add / commit git checkout
文件 ──────────────► CRLF 转成 LF ──► [仓库] ──────────────► LF 转成 CRLF ──► 文件
(工作区 CRLF) (index LF) (工作区 CRLF)
提交时把 CRLF 转成 LF,检出时把 LF 转回 CRLF。
这个设计在"只有 Windows 开发者、只写 Windows 程序"的年代是善意的。但在容器化时代它就是个陷阱:
- 你在 GitHub 网页上看这个文件,是完美的 LF,看不出任何问题;
- 你的同事在 Linux 上 clone 下来(
autocrlf=input或false),拿到的是 LF,跑得好好的; - 只有 Windows 上 clone 的那份磁盘文件是 CRLF;
- 而 Docker bind mount 挂载的正是磁盘上那份。
于是就有了那句经典的甩锅台词------"在我机器上是好的"。这次它字面意义上成立:在 Linux 同事的机器上,它真的是好的。
关键认知:
docker-compose.yml里的volumes:bind mount 挂的是工作区文件,不是 git 仓库里的文件。 你的 CI 在 Linux 上跑得再绿,也证明不了 Windows 开发者本地能跑起来。
六、怎么修
6.1 应急:转换现有文件
bash
# Linux / macOS / Git Bash / WSL
dos2unix docker-scripts/mysql/init-api-keys.sh
# 没有 dos2unix 就用 sed
sed -i 's/\r$//' docker-scripts/mysql/init-api-keys.sh
PowerShell:
powershell
$path = "docker-scripts\mysql\init-api-keys.sh"
$text = [System.IO.File]::ReadAllText($path)
$text = $text -replace "`r`n", "`n"
# 注意用 WriteAllText 而不是 Set-Content,后者会再把 LF 转回 CRLF
[System.IO.File]::WriteAllText($path, $text)
⚠️ PowerShell 里千万别用
Get-Content | Set-Content。Set-Content默认会重新写入 CRLF,你会发现忙活半天文件纹丝不动。
IDE 里改也行------IntelliJ IDEA / VS Code 右下角都有换行符指示(显示 CRLF 或 LF),点一下切成 LF 再保存即可。
6.2 根治:.gitattributes
只转换一次是不够的------下次 git checkout 或者别人重新 clone,autocrlf 会再把它转回 CRLF。
正确做法是在仓库根目录加一个 .gitattributes。这个文件会跟着仓库走 ,对所有 clone 的人生效,且优先级高于本机的 core.autocrlf 配置:
gitattributes
# 默认:让 git 自动识别文本文件,仓库内统一存 LF
* text=auto
# 以下文件类型无论在什么平台,工作区都强制 LF
*.sh text eol=lf
*.bash text eol=lf
Dockerfile* text eol=lf
*.yml text eol=lf
*.yaml text eol=lf
*.sql text eol=lf
.env* text eol=lf
# Windows 专用脚本保持 CRLF
*.bat text eol=crlf
*.cmd text eol=crlf
*.ps1 text eol=crlf
# 二进制文件不做任何转换
*.png binary
*.jpg binary
*.jar binary
*.gz binary
eol=lf 的含义是:不管你的 core.autocrlf 设成什么,检出到工作区时一律用 LF。 这才是能横跨团队生效的解法。
加完之后要强制刷新已有文件,否则已经在工作区的 CRLF 文件不会自动变:
bash
git add --renormalize .
git status # 看看哪些文件的换行符被改了
git commit -m "chore: 通过 .gitattributes 统一换行符为 LF"
改完再验证一次:
shell
$ git ls-files --eol -- "*.sh"
i/lf w/lf attr/text eol=lf docker-scripts/mysql/init-api-keys.sh
w/ 变成 lf 了,attr/ 也显示规则生效了。
6.4 CI 里加个检查
想彻底杜绝复发,在 CI 里加一步(这个项目用的是 GitHub Actions):
yaml
- name: 检查 shell 脚本换行符
run: |
if git ls-files --eol -- '*.sh' | grep -q 'w/crlf'; then
echo "::error::发现 CRLF 换行的 shell 脚本,请检查 .gitattributes"
git ls-files --eol -- '*.sh' | grep 'w/crlf'
exit 1
fi
七、如何快速诊断这类问题
下次再遇到"脚本在容器里莫名其妙不执行",按这个顺序查:
1. 看 shebang 那一行的字节
bash
head -c 20 script.sh | xxd
看到 0d 0a 就是 CRLF,只有 0a 才对。
2. cat -A 显示所有不可见字符
bash
cat -A script.sh | head -5
CRLF 会显示成 ^M$,正常的 LF 只有 $:
bash
#!/bin/bash^M$ ← 有问题
#!/bin/bash$ ← 正常
3. file 命令一句话判定
console
$ file script.sh
script.sh: Bourne-Again shell script, ASCII text executable, with CRLF line terminators
^^^^^^^^^^^^^^^^^^^^^^^^^ 铁证
4. 在容器里直接验证
bash
docker run --rm -v "$(pwd)/docker-scripts:/s" alpine sh -c "head -c 20 /s/mysql/init-api-keys.sh | od -c"
5. 别忘了 git ls-files --eol
这是唯一能同时告诉你"仓库里是什么"和"磁盘上是什么"的命令,也是定位 autocrlf 类问题最快的手段。
八、总结
一个 \r,2 个字节,导致:
- ❌ 初始化脚本静默失败,无任何日志
- ❌ 健康检查照常通过,
depends_on照常放行 - ❌ 故障在几分钟后以"IMAP 认证失败"的形式出现在另一个容器里
- ❌ Linux 同事完全无法复现
- ❌ CI 全绿
几条能带走的经验:
1. .gitattributes 应该是每个跨平台项目的标配。 它比 core.autocrlf 更可靠,因为它跟着仓库走,对所有协作者一视同仁。在创建仓库的第一天就加上它 ,比出事后再来 --renormalize 舒服得多。
2. shell 脚本对格式的容忍度远低于你的直觉。 YAML、SQL、Java、Dockerfile 都能忍 \r,唯独 shell 不能。所以"项目里其他文件都是 CRLF 也没事"完全不能作为排除依据------反而正是这种局部容忍,让唯一不容忍的那个格外难查。
3. bind mount 挂的是工作区,不是仓库。 GitHub 上看着完美的 LF 文件,落到 Windows 磁盘上可能就是 CRLF,而 Docker 挂进容器的正是后者。
4. 静默失败比崩溃可怕得多。 如果 MySQL 的 entrypoint 在脚本执行失败时能 exit 1,我可能十分钟就定位了。所以自己写初始化脚本时,务必让失败可见 ------加上 set -euo pipefail,关键步骤后面加校验和日志。
5. 排查思路要往"故障点"回溯,而不是盯着"表现点"。 报错说 IMAP 认证失败,真凶却在 MySQL 容器的初始化阶段。容器编排里的依赖链越长,这种时空错位就越常见------沿着数据的来路一站站往回查,比对着报错信息猜要快得多。
完整项目见 Linux-Kernel-Email-List-Analyzer。如果这篇文章帮你省下了几个小时,那它就值了。