Python shutil.move 跨文件系统「原子发布」静默失效:os.rename EXDEV 退化成 copyfile 的 O_TRUNC 截断+重写,下游读到 0 字节空文件和半截 JSON
日期:2026-09-26 | 分支:
2026-09-26-0000| 第 83 题 环境:Linux / Python 3.12 / Docker Compose / FUSE / 文件系统语义 关键词:shutil.move跨设备、Invalid cross-device linkerrno 18、EXDEV、os.replace原子替换、os.rename不原子、copyfileopen(dst,'wb')O_TRUNC、json.decoder.JSONDecodeError半截 JSON、读到 0 字节空文件、原子发布、 staged publish、tempfile.mkstemp 0600 权限、FUSE st_nlink、网络存储不支持硬链接
一、问题背景
搜索服务的发布机(publisher)把新版本索引「原子地」推给线上:
- 暂存目录
STAGE_DIR=/srv/stage—— 本地盘,发布机自己写 - 生效目录
STORE_DIR=/srv/store—— 网络存储(NAS),下游只读
设计契约写得很明确:
下游(reader)不感知发布过程,任意时刻
cat $STORE_DIR/current.json都必须拿到「某一次完整发布」的内容 —— 要么全是旧的,要么全是新的。
运维侧看一切正常:
- 发布器每轮都返回
updated: true、verified: true - 发布报告里
stage_bytes == live_bytes、stage_sha256 == live_sha256 - 发布 200 轮,退出码 0,没有任何一条 ERROR 日志
但下游偶发报错:
json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)
有时还会读到 0 字节的文件。低频、随机、复现不了
(FUSE 的 missing 属性抖动干扰了判断),被当成「网络抖动」压了下去。
问题态验收:
PASS=12 FAIL=4
RESULT: BAD
修复后:
PASS=16 FAIL=0
RESULT: OK
二、复现步骤
2.1 起环境(FUSE 造出「两块盘」)
cd scenario/2026-09-26-0000
docker compose up -d --build
docker compose ps
docker compose logs --tail=20
启动成功的关键日志:
nas-gateway: backing=/var/lib/nas mount=/srv/store expose_nlink=False
nas-mountd: store mounted at /srv/store
FUSE 挂载需要
devices: /dev/fuse+cap_add: SYS_ADMIN+security_opt: apparmor:unconfined,缺一挂不上。 且挂载必须与使用方同一个容器(Linux 挂载命名空间是 per-container 的)。
2.2 只读探针:看清跨文件系统语义
docker compose exec publisher python3 /srv/ops/probe_syscalls.py
== 1) 两块盘的 st_dev ==
/srv/store st_dev=2097315
/srv/stage st_dev=2304
same_filesystem=False
== 2) 跨盘 os.rename(原子搬运的正确手段?) ==
rename FAILED errno=18 Invalid cross-device link
== 3) 跨盘 os.replace ==
replace FAILED errno=18 Invalid cross-device link
== 4) 跨盘 shutil.move(会退化成什么?) ==
shutil.move 返回 '/srv/store/_probe3.json'(看起来成功) ← 危险信号
dst 内容 '{"v": "moved", "items": [1, 2, 3]}'
== 5) 硬链接语义 ==
store 上文件的 st_nlink=1
挂载内 os.link FAILED errno=1 Operation not permitted
跨盘 os.link FAILED errno=18 Invalid cross-device link
== 6) 挂载内 rename 仍应可用(发布器要能靠它做原子替换) ==
挂载内 rename OK, 读到 'in-fs\n'
两个必须记住的事实:
rename/replace跨盘直接报错 EXDEV → 跨盘搬不了,只能「复制 + 换名」shutil.move却**"成功"**了 → 报了成功的那个才是坑:它偷偷换了实现
2.3 跑验收
docker compose exec publisher bash -c "/srv/ops/seed.sh && bash /srv/ops/verify.sh"
=== A 组:发布的原子性 ===
rename(local->nas) EXDEV errno=18 Cross-device link
[PASS] A1 暂存盘与生效目录不同文件系统(rename EXDEV)——跨盘 rename 不可能原子
旧长度=17002 新长度=121943 期间观测到的长度集合=[0, 17002, 40960, 90112, 121943]
出现了不属于任何一次发布的长度: [0, 40960, 90112]
[FAIL] A2 发布过程中 live 出现过 0 或中间长度 —— 下游能读到空文件/半截内容
commit(): 回读=True 比对=False 不匹配时失败=False
[FAIL] B4 commit() 只赋值不比对:verified 恒定,等于没有自检
=== C 组:并发发布 + 下游读,是否读到半截 ===
READER reads=976 ok=969 torn=0 empty=7 missing=0 distinct_digests=113 RESULT: BAD
bad sample: {"bytes": 0, "reason": "empty_read (O_TRUNC 清空窗口)", ...}
[FAIL] C2 下游读到了半截/空内容:发布不是原子的
[FAIL] C3 下游继续读到半截/空内容(torn/empty > 0):发布仍不是原子的
───────────────────────────────────────────
PASS=12 FAIL=4
RESULT: BAD
A2 的 长度集合=[0, 17002, 40960, 90112, 121943] 是最硬的证据:
下游能观测到 0 字节 和 三个不属于任何一次发布的中间长度。
2.4 并发复现
docker compose exec publisher bash -lc '
python3 /srv/client/reader.py --seconds 10 --interval 0.01 &
python3 /srv/client/publish_burst.py --rounds 200 --pause 0.003
wait'
READER reads=979 ok=964 torn=3 empty=15 missing=0 RESULT: BAD
bad sample: {"bytes": 0, "reason": "empty_read (O_TRUNC 清空窗口)"}
bad sample: {"bytes": 1302, "tail": "\u0000\u0000\u0000...", "reason": "json_parse_failed (正被写一半)"}
2.5 【关键实验】直接证明 O_TRUNC 窗口
问题态那一行就是 shutil.move(stage, live)。写个探针,
高频轮询 live 长度、同时执行 shutil.move:
docker compose exec publisher python3 - <<'PY'
import os, shutil, time, threading, json
live, stage = "/srv/store/current.json", "/srv/stage/demo.next"
old = {"v": "old", "items": [{"id": i, "t": "o"*1000} for i in range(10)]} # 小
new = {"v": "new", "items": [{"id": i, "t": "n"*4000} for i in range(60)]} # 大
open(live, "w").write(json.dumps(old)); old_len = os.path.getsize(live)
open(stage, "w").write(json.dumps(new)); new_len = os.path.getsize(stage)
print("old_len=%s new_len=%s" % (old_len, new_len))
obs, stop = [], threading.Event()
def watch():
while not stop.is_set():
try: obs.append(os.path.getsize(live))
except OSError: obs.append(-1)
threading.Thread(target=watch).start()
time.sleep(0.02)
shutil.move(stage, live) # ← 问题态原句
time.sleep(0.02); stop.set()
u = sorted(set(obs))
print("长度集合(前12) =", u[:12], "共 %d 种" % len(u))
print("出现 0 字节窗口 (O_TRUNC):", 0 in u)
print("出现不属于任何一次发布的中间长度:", [x for x in u if x not in (old_len, new_len)][:8])
PY
实测:
old_len=10223 new_len=241273
长度集合(前12) = [0, 8192, 10223, 12288, 16384, 20480, 24576, 28672, 32768, 36864, 40960, 45056] 共 60 种
出现 0 字节窗口 (O_TRUNC): True
出现不属于任何一次发布的中间长度: [0, 8192, 12288, 16384, 20480, 24576, ...]
一次 shutil.move 能看到 60 种长度,其中包含 0。
这就是下游读到空文件和半截 JSON 的物理来源。
探针设计要点:新旧内容长度必须不同(新的明显更大)。 若两者等长,中间态的字节数也等于同一个值,就观察不到异常。
2.6 从 CPython 源码确认降级路径
docker compose exec publisher python3 -c "import inspect, shutil; print(inspect.getsource(shutil.move))"
try:
os.rename(src, real_dst)
except OSError: # ← EXDEV 走这里
...
else:
copy_function(src, real_dst) # ← 默认 copy2 → copyfile
os.unlink(src)
docker compose exec publisher python3 -c "import inspect, shutil; print(inspect.getsource(shutil.copyfile))"
with open(src, 'rb') as fsrc:
with open(dst, 'wb') as fdst: # ← O_TRUNC!目标当场变 0 字节
...
copyfileobj(fsrc, fdst)
三、根本原因
三层叠加,缺一不可:
| 层 | 事实 | 后果 |
|---|---|---|
| 存储层 | STAGE_DIR 与 STORE_DIR 不同文件系统(st_dev 不同) | 跨盘 rename(2) 必然 EXDEV(18) |
| 库层 | shutil.move 捕获 OSError 后静默降级为 copy2 | 调用方看到"成功",无从察觉降级 |
| 实现层 | copyfile 用 open(dst, "wb") 打开目标 | 目标先被截成 0 字节,再逐步写入 |
结果:「发布」在下游眼里变成三段:先 0 字节 → 再写了一半 → 最后完整。
再叠加两个"顺便的坑":
verified是假的。原commit()只是把回读到的值填进报告, 从不比对、不抛错 —— 自检等于没有。验收的B4断言把它抓了出来:commit(): 回读=True 比对=False 不匹配时失败=False。 这里有个陷阱:这份报告内容全对、结构完整、verified=True, 而它恰恰在最需要报警的时候(内容被写坏)也报True。tempfile.mkstemp默认权限 0600。一旦用 mkstemp 做临时文件, 直接发布出去下游就permission denied—— 必须显式fchmod。
核心认知:原子替换只在「临时文件和目标文件在同一个文件系统内」这个前提下成立。 跨文件系统不存在原子的"搬运",只有「先复制到目标盘、再在目标盘内换名」。
四、解决办法
4.1 只改一个文件:app/publisher.py
4.2 核心改动一:同文件系统内原子写入
def atomic_publish(src: str, dst: str, *, tmp_prefix: str = ".publish-") -> None:
store = os.path.dirname(dst) or "."
os.makedirs(store, exist_ok=True)
mode = _target_mode(dst) # 新建 0644;已有则沿用,避免 0600 收窄
fd, tmp = tempfile.mkstemp(prefix=tmp_prefix, suffix=".tmp", dir=store) # ← 关键:dir=store
try:
os.fchmod(fd, mode) # ← 绕开 mkstemp 的 0600
with os.fdopen(fd, "wb") as out, open(src, "rb") as fh:
shutil.copyfileobj(fh, out, 1 << 16)
out.flush()
os.fsync(out.fileno()) # 数据先落地
os.replace(tmp, dst) # ← 同文件系统内换名 = 原子
tmp = None
finally:
if tmp is not None:
try: os.unlink(tmp)
except FileNotFoundError: pass
# 目录项也 fsync 一下,"新名字生效"才是持久的
...
三个要点,缺一不可:
dir=store—— 临时文件必须落在目标盘,否则os.replace一样 EXDEVos.fchmod(fd, mode)—— 修掉 mkstemp 的 0600 坑- 先
fsync再os.replace—— 保证换名后读到的是完整数据
4.3 核心改动二:commit() 真正回读自证
report["readback_matches"] = (
report["live_sha256"] == report["stage_sha256"]
and report["live_bytes"] == report["stage_bytes"]
)
report["verified"] = bool(report["readback_matches"])
if not report["verified"]:
raise PublishError("publish content mismatch: ...") # ← 大声失败
报告里每个字段都必须来自再读一遍磁盘,不能拿计划值当事实。
4.4 其余两点
- 留档用独立副本,不用硬链接(网络存储
st_nlink恒为 1、os.link直接 EPERM) write_manifest()也改成原子写,避免下游读到半截清单
4.5 应用到容器
docker cp app/publisher.py 3day-83-publisher:/srv/app/publisher.py
docker compose exec publisher bash -c \
"find /srv/app -name __pycache__ -exec rm -rf {} + 2>/dev/null"
改完必须清 __pycache__,否则读到的还是旧的 .pyc,现象会非常迷惑。
五、验证
5.1 验收脚本:PASS=16 FAIL=0 / RESULT: OK
docker compose exec publisher bash -c "/srv/ops/seed.sh && bash /srv/ops/verify.sh"
旧长度=17002 新长度=121943 期间观测到的长度集合=[17002, 121943]
长度只在两个稳定值之间跳变 —— 下游不会看到中间态
[PASS] A2 发布过程中 live 的长度只在「旧长度/新长度」间跳变(真原子替换)
commit(): 回读=True 比对=True 不匹配时失败=True
[PASS] B4 commit() 会回读并比对 stage/live,不匹配时大声失败
READER reads=977 ok=975 torn=0 empty=0 missing=2 distinct_digests=109 RESULT: OK
[PASS] C2 并发发布期间下游读到的内容全程完整(torn=0 且 empty=0)
[PASS] C3 下游全程读到完整内容(torn=0 且 empty=0):发布已经是原子的
───────────────────────────────────────────
PASS=16 FAIL=0
RESULT: OK
对比要记住的两行:
修复前 A2 长度集合 = [0, 17002, 40960, 90112, 121943] ← 0 与中间值都可见
修复后 A2 长度集合 = [17002, 121943] ← 只有两个稳定值
5.2 加压并发:400 轮发布、6947 次读,零违规
docker compose exec publisher bash -lc '
python3 /srv/client/reader.py --seconds 15 --interval 0.002 --out /srv/state/r_long.json > /srv/state/r_long.log 2>&1 &
sleep 0.3
python3 /srv/client/publish_burst.py --rounds 400 --pause 0.001 > /srv/state/b_long.log 2>&1
wait; cat /srv/state/r_long.log; cat /srv/state/b_long.log'
READER reads=6947 ok=6937 torn=0 empty=0 missing=10 distinct_digests=400 RESULT: OK
BURST rounds=400 updated=400 verified=400
LAST stage_bytes=358 live_bytes=358 sha_match=True
torn=0 empty=0—— 契约违规为 0distinct_digests=400—— 400 轮发布的内容全部被下游完整观测到missing=10是 FUSE 属性缓存在os.replace瞬间的观测抖动,脚本单列、不计入违规- 残留临时文件检查:
ls -la /srv/store/ | grep -E '\.(publish|release|manifest)-'→ 空
5.3 权限没被收窄成 0600
走「新建文件」路径(先删掉 live):
新建 live 的 mode = 0o644 其他用户可读 = True
报告 live_mode = 0o644 atomic = True
5.4 自检真的有判别力
模拟「记录完 stage 摘要之后内容又变了」:
正确:抛出 PublishError -> publish content mismatch: stage(sha=ffffffffffffffff bytes=151) != live(sha=ca02a3b1266297b0 bytes=151)
5.5 判定脚本"有牙":问题态/修复态可来回切换
/srv/ops/apply_fix.sh --restore && bash /srv/ops/verify.sh # PASS=12 FAIL=4 RESULT: BAD
/srv/ops/apply_fix.sh && bash /srv/ops/verify.sh # PASS=16 FAIL=0 RESULT: OK
5.6 从零重建仍 OK
docker compose down -v && docker compose up -d --build && sleep 6
docker compose exec publisher bash -c "/srv/ops/seed.sh && bash /srv/ops/verify.sh 2>&1 | tail -4"
# → PASS=16 FAIL=0 / RESULT: OK
六、经验与避坑
可复用的思路
- 跨文件系统没有原子的"搬运"。唯一正确姿势:
临时文件建在目标盘 → 写完
fsync→ 在目标盘内os.replace。 记住判据:os.replace的原子性完全依赖两个名字在同一个文件系统内。 - 「报成功」的比「报失败」的更危险。跨盘
rename直接抛EXDEV是好事(当场暴露);shutil.move把这个错误吃掉、降级、然后返回成功 —— 失败模式从"启动即报错"变成"线上随机读到坏数据"。 凡是自动降级的库函数,都要问一句"降级后语义还成立吗"。 - 自检必须有判别力。
verified = True这种"报什么算什么"的写法, 在内容全对时全绿、在内容被写坏时也全绿,等于没有自检。 判据必须是「回读 vs 期望」的比较,不匹配就raise。 - 观测指标要选对统计对象。本题
A2用「发布过程中 live 的长度集合」 来判定原子性 —— 这个外部可观测量既精确又直观: 原子替换时长度只会从旧值跳到新值,出现第三个值(尤其 0)就是违规。
避坑清单
| # | 现象 | 原因 | 解决 |
|---|---|---|---|
| 1 | nas-mountd: FATAL mount not visible | FUSE 缺 /dev/fuse、SYS_ADMIN 或 apparmor:unconfined | 三样都要给;挂载必须与使用方同容器 |
| 2 | 改完代码现象不变 | __pycache__ 里的旧 .pyc | find /srv/app -name __pycache__ -exec rm -rf {} + |
| 3 | 探针测不出非原子 | 新旧内容长度相同时中间态长度也相同 | 让新内容明显大于旧内容 |
| 4 | 误判「commit() 没判别力」 | 我一开始"先把 live 改坏再 commit",但 commit() 是用 stage 覆盖 live,stage 才是真值源,不报错是对的 | 改成模拟「记录摘要后内容又变了」才测到 PublishError |
| 5 | os.link 莫名失败 | 网络存储网关语义不支持硬链接(st_nlink 恒 1、link 直接 EPERM) | 留档一律用独立副本 |
| 6 | mkstemp 的文件下游读不到 | mkstemp 默认 0600 | os.fchmod(fd, mode) 显式设 0644 / 沿用原权限 |
| 7 | reader 的 missing 非 0 | FUSE 属性缓存在 os.replace 瞬间短暂 ENOENT,是观测抖动 | 单列 missing、不计入违规;只看 torn/empty |
同类影响面(线上怎么找这类 bug)
- 任何「先写临时文件、再 move/replace 到最终位置」的部署/发布脚本
- 挂载在独立卷/网络盘/对象存储 FUSE(s3fs、gcsfuse、JuiceFS、NFS、CIFS)上的目录
os.rename/os.replace返回errno 18 Invalid cross-device link- 日志/配置文件的热更新、证书轮换、
nginx -s reload前的配置落盘 - Python 的
shutil.move、os.replace;Go 的os.Rename(跨设备同样EXDEV) - 症状关键词:
json.decoder.JSONDecodeError、读配置读到空文件、 「偶发、随机、复现不了」、下游在高峰期报错 - 最省事的自查:
stat -c %d $STAGE_DIR $STORE_DIR(或os.stat().st_dev), 两个数字不同 → 该路径上的"原子发布"全部可疑
一句话带走
跨文件系统的「原子发布」不存在。
shutil.move会在跨盘时静默退化成open(dst, "wb"), 把"看起来成功"变成最危险的失败模式 —— 发布器报成功,下游在读空气。
