朕喜技术(ZhenxiJS)
2026.09.26
1 分钟阅读

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 link errno 18、EXDEV、 os.replace 原子替换、os.rename 不原子、copyfile open(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'

两个必须记住的事实:

  1. rename/replace 跨盘直接报错 EXDEV → 跨盘搬不了,只能「复制 + 换名」
  2. 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 字节 → 再写了一半 → 最后完整。

再叠加两个"顺便的坑":

  1. verified 是假的。原 commit() 只是把回读到的值填进报告, 从不比对、不抛错 —— 自检等于没有。验收的 B4 断言把它抓了出来: commit(): 回读=True 比对=False 不匹配时失败=False。 这里有个陷阱:这份报告内容全对、结构完整、verified=True, 而它恰恰在最需要报警的时候(内容被写坏)也报 True。
  2. 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 一样 EXDEV
  • os.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 —— 契约违规为 0
  • distinct_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

六、经验与避坑

可复用的思路

  1. 跨文件系统没有原子的"搬运"。唯一正确姿势: 临时文件建在目标盘 → 写完 fsync → 在目标盘内 os.replace。 记住判据:os.replace 的原子性完全依赖两个名字在同一个文件系统内。
  2. 「报成功」的比「报失败」的更危险。跨盘 rename 直接抛 EXDEV 是好事(当场暴露);shutil.move 把这个错误吃掉、降级、然后返回成功 —— 失败模式从"启动即报错"变成"线上随机读到坏数据"。 凡是自动降级的库函数,都要问一句"降级后语义还成立吗"。
  3. 自检必须有判别力。verified = True 这种"报什么算什么"的写法, 在内容全对时全绿、在内容被写坏时也全绿,等于没有自检。 判据必须是「回读 vs 期望」的比较,不匹配就 raise。
  4. 观测指标要选对统计对象。本题 A2 用「发布过程中 live 的长度集合」 来判定原子性 —— 这个外部可观测量既精确又直观: 原子替换时长度只会从旧值跳到新值,出现第三个值(尤其 0)就是违规。

避坑清单

#现象原因解决
1nas-mountd: FATAL mount not visibleFUSE 缺 /dev/fuse、SYS_ADMIN 或 apparmor:unconfined三样都要给;挂载必须与使用方同容器
2改完代码现象不变__pycache__ 里的旧 .pycfind /srv/app -name __pycache__ -exec rm -rf {} +
3探针测不出非原子新旧内容长度相同时中间态长度也相同让新内容明显大于旧内容
4误判「commit() 没判别力」我一开始"先把 live 改坏再 commit",但 commit() 是用 stage 覆盖 live,stage 才是真值源,不报错是对的改成模拟「记录摘要后内容又变了」才测到 PublishError
5os.link 莫名失败网络存储网关语义不支持硬链接(st_nlink 恒 1、link 直接 EPERM)留档一律用独立副本
6mkstemp 的文件下游读不到mkstemp 默认 0600os.fchmod(fd, mode) 显式设 0644 / 沿用原权限
7reader 的 missing 非 0FUSE 属性缓存在 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"), 把"看起来成功"变成最危险的失败模式 —— 发布器报成功,下游在读空气。

版权声明

本文为博主原创文章,遵循 CC BY-NC-SA 4.0 许可协议。 转载请注明出处:http://localhost/blogs/2026-09-26-0000-shutil-move-json

分享这篇文章