Docker容器内执行Makefile的sed规则行为异常排查
根本原因
问题核心是你写的管道命令存在同文件读写竞态,属于未定义行为,和Docker本身没有直接关系,只是容器环境把这个问题的触发概率拉高了:
- 最后一段命令逻辑是:用
cat读取目标.tex文件作为管道输入,经过多段sed替换后,用>重定向把结果写回同一个目标文件 - Shell处理
>重定向的逻辑是:启动管道内所有进程之前,先打开目标文件并截断为0长度。这个步骤和cat打开文件读取内容的先后顺序完全是随机的,没有任何时序保障:- 如果
cat在文件被截断前就已经打开文件并把全部内容读入缓冲区,最终就能生成正常文件 - 如果
cat打开文件时重定向已经完成截断,读到的就是空内容,最终输出空白文件 - Debian Buster默认的overlay2存储驱动、GNU sed 4.7版本存在页缓存交互bug:当读取刚被截断、还没写入新内容的文件时,会直接返回填充了0x00(也就是NUL字符)的内存页,sed读到这些内容就会写出全是NUL的损坏文件
- 如果
- 你在宿主机上运行时很少出问题,只是因为宿主机的磁盘IO调度、进程启动速度刚好让
cat先读完文件的概率更高,本质上这段代码在任何环境下都有概率出问题,只是容器环境下进程启动更快、overlay2的缓存逻辑让竞态触发概率大幅提升,才会表现为不稳定。
稳定修复方案
核心原则:永远不要在同一条管道中同时读取、用>覆盖写入同一个文件。最通用、无额外依赖的修复方式是先把所有处理结果写到临时文件,全部处理完成后再原子替换目标文件,彻底消除竞态。
重写后的Makefile规则如下:
.PHONY : timetracking timetracking: # 提前计算所有日期变量,避免在shell片段中反复调用date生成子进程 $(eval TODAY := $(if $(PAGE),$(PAGE),$(shell TZ=$(TIMEZ) date +%Y-%m-%d))) $(eval DATE_FULL := $(shell TZ=$(TIMEZ) date +'%d %B')) $(eval DATE_D0 := $(shell TZ=$(TIMEZ) date +'%b %d')) $(eval DATE_D1 := $(shell TZ=$(TIMEZ) date +'%b %d' -d '+1 days')) $(eval DATE_D2 := $(shell TZ=$(TIMEZ) date +'%b %d' -d '+2 days')) $(eval DATE_D3 := $(shell TZ=$(TIMEZ) date +'%b %d' -d '+3 days')) $(eval DATE_D4 := $(shell TZ=$(TIMEZ) date +'%b %d' -d '+4 days')) $(eval TARGET := $(PAGES)/$(WEEKLY)/$(TODAY).tex) $(eval TMP := $(PAGES)/$(WEEKLY)/.$(TODAY).tex.tmp) # 拼接所有模板内容到临时文件,全程不碰最终目标文件 cat template/page-header-footer/head.tex > $(TMP) cat template/page-header-footer/pagestart.tex >> $(TMP) echo "{Week of $(TODAY)}" >> $(TMP) cat template/page-header-footer/timetracking.tex >> $(TMP) cat template/page-header-footer/tail.tex >> $(TMP) # 单次sed完成所有替换,输出到第二个临时文件,避免多次管道开销 sed -e 's/1 January/$(DATE_FULL)/g' \ -e 's/Jan 1/$(DATE_D0)/g' \ -e 's/Jan 2/$(DATE_D1)/g' \ -e 's/Jan 3/$(DATE_D2)/g' \ -e 's/Jan 4/$(DATE_D3)/g' \ -e 's/Jan 5/$(DATE_D4)/g' \ $(TMP) > $(TMP).processed # 所有处理完成后原子替换目标文件,自动清理临时文件 mv $(TMP).processed $(TARGET) rm -f $(TMP)
这个写法的优势:
- 全程在处理完成前不会读写最终目标文件,完全消除同文件读写竞态,在任何环境下运行结果都是稳定的
- 最后用
mv做替换是文件系统原子操作,哪怕命令中途被Ctrl+C中断、进程崩溃,也不会留下半写的损坏目标文件 - 合并了多段sed调用为单次执行,减少不必要的子进程开销,运行速度更快
- 不需要在Docker镜像里安装任何额外依赖,用你现有镜像里的基础工具就能跑
如果你不想用临时文件,也可以安装
moreutils包提供的sponge工具,它会先把管道输入全部读入内存再写目标文件,但需要在Dockerfile里加一行RUN apt -y install moreutils,通用性不如临时文件方案。
内容的提问来源于stack exchange,提问作者Aaron
相关产品推荐
相关产品推荐

