企福兔
← 返回新闻动态
官方动态

一条 Linux 流水线在 Windows 上的排障实录

TECH INSIGHT 一条 Linux 流水线在 Windows 上的排障实录 第一次跑,12 个步骤

TECH INSIGHT

一条 Linux 流水线在 Windows 上的排障实录

第一次跑,12 个步骤红了 8 个。

科技 · 产业 · 人

深度观察

合成种子稿(POC 夹具):本文件不是客户稿件,是 R10-b 为"本次成稿"补的产出方占位内容,

用于验证"判据跑在本次产物上"。正文取自 POC 已通过的示范稿。

3 天,11 小时,43 次提交。到最后我才发现:8 个红里,有 3 个其实是同一个原因。

---

01

KEY INSIGHT

一、先看现场

项目叫 data-etl,一个日增 20 万行的报表流水线。它在 Linux 上绿了 8 个月,CI 平均 187 秒跑完。上个月要求变了——本机必须能跑全流程。

我的机器:Windows 11 23H2,WSL2 里是 Ubuntu 22.04,Docker Desktop 4.28。

第一次在 PowerShell 里敲 make all,第 4 步就断了。日志末尾是 make: *** [Makefile:31: etl] Error 1;往上看,Python 抛的是 UnicodeDecodeError: 'gbk' codec can't decode byte 0xe5 in position 214。

0xe5 是汉字 UTF-8 的首字节。这台机器的 locale 是 zh_CN.GBK,脚本写死了 encoding='utf-8' 读文件,可 subprocess 抓子进程输出时用的是系统默认编码。同一份代码,在 Linux 上读 UTF-8,到这里就变成了 GBK。这不是脚本有 bug,是脚本少写了一句环境约定。

一行修完:subprocess.run(cmd, capture_output=True, encoding="utf-8", errors="replace")。

注意 errors="replace"。为什么不加就出事?因为缓冲区恰好切到半个汉字时,解码器没有退路。这种"少写一句"的毛病,后面还会换着面孔出现三次。

这一步花我 40 分钟。我当时以为后面七个坑也这么便宜——事实证明,这只是最客气的一个。

---

02

KEY INSIGHT

二、同一个报错,三种原因

第二步报 ModuleNotFoundError: No module named 'Utils'。

Utils 是大写,仓库里的目录名叫 utils。Linux 文件系统区分大小写,找不到就是找不到;可 Windows 的 NTFS 不区分,import Utils 历史上一直"能跑"。同一行代码,在 Linux 的 CI 上从来没炸过,原因不是它没问题,而是 CI 一直在 Linux 上跑,它压根没机会出现。

第三步报 ImportError: cannot import name 'sub_mod' from partially initialized module,听起来像循环导入。实际是 utils/__init__.py 用 glob 扫目录自动加载,而 Windows 上 glob 的返回顺序和 Linux 不同,先加载了子模块,再加载父模块。容器里复现不出来,因为容器里的目录顺序是稳定的。

第三个红更离谱:diff: /tmp/expected.csv: No such file or directory。diff 在 PowerShell 里根本不存在,它是 Compare-Object;而 /tmp 在原生 Windows 上也不存在,最接近的是 %TEMP%。

三个看起来毫无关系的错误,根子上是同一件事:脚本里所有"环境约定"都被省略了。 那怎么办?我的第一步不是改代码,而是先把被省略的约定一条条找出来。可"找约定"这件事本身也有个陷阱:报错只告诉你"在哪炸",从不告诉你"为什么在 Linux 上不炸"。

---

03

KEY INSIGHT

三、第二个大坑:换行符

第 6 步跑 ./scripts/check.sh,报的是 ./scripts/check.sh: line 2: $'\r': command not found 和 bad interpreter: /bin/bash^M。^M 就是 \r。

Windows 上 Git 默认 core.autocrlf=true,checkout 时把 LF 换成 CRLF;bash 读到行尾的 \r,把它当成命令的一部分。想确认也简单,file scripts/check.sh 会告诉你 with CRLF line terminators。

修法有两层。一是 .gitattributes 里写 * text=auto eol=lf、*.sh text eol=lf、*.bat text eol=crlf;二是本机 git config --global core.autocrlf input,顺手再加 core.fileMode false。

为什么要加后者?Windows 不管文件执行位,不加这句,chmod +x 的结果会被 git 当成"全体文件都被改过"。仓库 312 个文件,一次 git status 刷出 291 条 modified,看着像灾难,其实全是这一个开关。

---

04

KEY INSIGHT

四、最坑的一个:文件锁

第 9 步打包,报 PermissionError: [WinError 32] ... being used by another process: 'build\\report.zip'。

这跟 Linux 的直觉完全相反。在 Linux 上,删除一个正在被读取的文件完全合法——inode 引用还在,系统等最后一个句柄关闭才回收。Windows 不行:只要有一个句柄开着,os.remove、os.rename 就全部报 WinError 32,或者 WinError 5。

我这里的元凶是杀毒软件。它扫新建的 build\ 目录,句柄没释放,我的清理步骤就打不中。谁能想到,卡住流水线的不是代码,是杀软?

三个动作解决。第一,把 build\、.venv\、node_modules\ 加进杀软排除目录。第二,清理函数加重试:for i in range(5): try: os.remove(path); break except PermissionError: time.sleep(0.1 * 2 ** i)。

第三,也是最重要的,所有中间产物先写临时文件再原子重命名:tmp = path.with_suffix(".tmp"); tmp.write_bytes(data); os.replace(tmp, path)。os.replace 在 Windows 上是原子的,比 os.rename 稳。

---

05

KEY INSIGHT

五、还有一个我自己挖的坑

调好前四个问题后,我想"在 Python 里统一调子进程",把编排层重写成 Python。新脚本一跑,[WinError 5] Access is denied。

我以为是权限问题,加 shell=True,报别的;查了两小时。真实原因是:在受限沙箱下,程序不能打开命名管道。 Python 的 subprocess 默认 stdio='pipe',靠命名管道抓子进程输出;管道开不了,就是 WinError 5。

规律很清楚:stdio='pipe' 会 EPERM;换成 stdio='inherit'、stdio='ignore',或者把输出重定向到文件再读,都可以。改法就是把管道换成文件:with open(log_path, "w", encoding="utf-8") as f: subprocess.run(cmd, stdout=f, stderr=subprocess.STDOUT)。

这件事教我一件事:报错说的"Access is denied",未必是权限。你在这一层查权限,它可能在另一层等你。

---

06

KEY INSIGHT

六、稳定下来的方案

三层,按代价从低到高。

第一层,脚本显式化:全部写死编码,全用 pathlib。跨平台拼路径别用字符串加 "\\",pathlib 会自己判断分隔符,p.as_posix() 保证输出正斜杠。

第二层,统一入口:新增 run.ps1 和 run.sh,两者只做一件事——调同一个 python -m pipeline,业务逻辑不再散在 shell 里。环境变量前置 PYTHONUTF8=1、PYTHONIOENCODING=utf-8,让它别再去猜 locale。两个入口看着多余,其实是在把平台差异收敛到一个地方。

第三层,能用容器就用容器:docker run --rm -v "$PWD:/w" -w /w python:3.11-slim bash -lc "make all"。第 6 步那个 check.sh 直接扔进容器跑,换行符问题一次性消失。代价是首次拉镜像 380MB,每跑一次多 20 到 40 秒。值不值?看你的脚本里有多少 shell:只有三行调用,容器是奢侈品;通篇都是 shell,容器就是最便宜的保险。

但最关键的一条不是这三层,而是 CI 必须跑在目标系统上。我的矩阵本来只有 ubuntu-latest,现在加了 windows-latest 和 macos-14。听起来慢了三倍,其实不是——本地第一次就红了,就不会等到合并到主干再红。

---

07

CONCLUSION

七、结果

第 43 次提交,12 步全绿。耗时 214 秒,Linux 上是 187 秒,差 14.4%。兼容代码一共 87 行,占 4200 行的 2%。

换来了什么?换来的是在 Windows 上改完一行,不用等推送就知道会不会炸。

而这 11 小时里,真正难的只有 2 小时——认清"命名管道不能开"和"三个红是同一件事"。剩下 9 小时,全在被表面症状牵着走。

有人问我这 11 小时值不值。我的答案是:买的不是"能跑",是一份能交给下一个人的确定性。

---

08

KEY INSIGHT

八、带走这几条

如果你也要把 Linux 流水线搬到 Windows,下面五条可以直接抄,顺序按踩坑概率从高到低排:

1. 见到 UnicodeDecodeError,先查 locale 和 PYTHONUTF8,再给所有 subprocess 补上 encoding="utf-8", errors="replace"。 2. 报 ModuleNotFoundError 但目录明明在,先查大小写,再查 __init__.py 的加载顺序,别急着装包。

3. 报 $'\r' 或 bad interpreter: ^M,去补 .gitattributes 和 core.autocrlf input,顺手关掉 core.fileMode。 4. 报 WinError 32 或 WinError 5,先排查文件锁(杀软、编辑器、遗留句柄),把删除改成"临时文件 + os.replace",并加指数退避重试。

5. 子进程输出抓不到时,先换 stdio(inherit、ignore 或重定向到文件),别把时间花在查权限上。

---

09

KEY INSIGHT

九、回落

我一直以为"跨平台"是个写作风格问题——有人爱用 os.path.join,有人爱用 + "/"。这次明白不是。

跨平台的本质是:把省略掉的环境约定,一条一条写回来。 编码是约定,换行符是约定,大小写是约定,文件锁是约定;路径分隔符、locale、退出码语义,全是约定。

Linux 和 Windows 能跑同一份脚本,不是因为它们一样,是因为脚本里每一处"我以为"都被换成了明确的写法。

那句话怎么说来着。"在我机器上能跑"——下句不该是"你机器有问题",该是"那我把机器差异写进代码里"。

写在最后

看到这儿,说明你把它读完了。这类文章能走多远,只取决于两件事:你的在看和转发。

顺手关注并设为星标,下一篇更新第一时间见。

END

本文来源:企福兔官方服务号 · 同步于 2026-10-15待发布
电话咨询洽谈合作