一个看不见的字符——CRLF 如何让我的 Docker 初始化脚本"静默失踪"

现象: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.ymlDockerfile.analyzertable-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\rEOF 变成 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 程序"的年代是善意的。但在容器化时代它就是个陷阱:

  1. 你在 GitHub 网页上看这个文件,是完美的 LF,看不出任何问题;
  2. 你的同事在 Linux 上 clone 下来(autocrlf=inputfalse),拿到的是 LF,跑得好好的
  3. 只有 Windows 上 clone 的那份磁盘文件是 CRLF
  4. 而 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-ContentSet-Content 默认会重新写入 CRLF,你会发现忙活半天文件纹丝不动。

IDE 里改也行------IntelliJ IDEA / VS Code 右下角都有换行符指示(显示 CRLFLF),点一下切成 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。如果这篇文章帮你省下了几个小时,那它就值了。

相关推荐
深念Y11 分钟前
Nuxt 项目 Docker 构建:从 pnpm+node 迁移到全 bun
java·前端·docker
学长毕业设计15 分钟前
基于SpringBoot的社区鲜奶订购系统的设计与实现(源码+文档+讲解视频)
java·spring boot·后端
2601_9621741715 分钟前
小试牛刀-SpringBoot集成SOL链
数据库·spring boot·后端
郝学胜_神的一滴19 分钟前
C++11 工程级应用 06:自己造一个类似Python的Range迭代器
c++·后端
张炯炯19 分钟前
个人RAG上线翻车实录-记一次 API 延迟排查
后端
呆呆敲代码的小Y20 分钟前
【游戏开发】C# 中的迭代器
java·开发语言·c#·迭代器
TinyMemory21 分钟前
Java 面向对象核心入门(六):this 关键字完全解析
java·笔记·面向对象·java新手·this关键字
萧瑟余晖23 分钟前
Java深入解析篇九之JavaStream API详解
java
一只QAQ28 分钟前
c++项目
java·c++·算法