配置原子发布(os.replace)后 inotify 热重载永久失效:监听的是文件而非目录,watch 挂在被换掉的旧 inode 上,healthz 恒绿
日期:2026-10-01 环境:Linux(inotify)/ Python 3.12 / Docker Compose 分支:
2026-10-01-0000关键词:inotify 热重载失效、监听文件不生效、os.replace 原子发布、IN_MOVED_TO、IN_IGNORED、IN_DELETE_SELF、inotify_add_watch 目录 vs 文件、配置热重载不生效 重启才好、healthz 假绿、self_reported_ok 恒真、Python ctypes inotify、rename 换 inode、watch 失效、K8s ConfigMap 热更新不生效
一、问题背景
网关有一个配置热重载守护进程 reloadd:
- 监听业务配置
/srv/conf/service.json,文件一变就把新配置加载进内存生效; - 周期把「当前生效配置」+「自检报告」写到
/srv/state/; - 运维侧配置发布走标准原子发布姿势:写临时文件 →
fsync→os.replace换名, 保证读者永远不会读到半截 JSON。
上线后运维反馈:
- 改了配置、发了配置,发布器打印
[publisher] published revision=r2; - 但网关行为一点没变,
max_conn还是老值 200; - 更奇怪的是重启守护进程立刻就好了,跑一段时间又坏;
- 而所有「健康」信号全是绿的:
[healthz] OK: 守护进程存活,配置文件新鲜,热重载链路正常 # rc=0
[reloadd] pid=19 watching /srv/conf/service.json (mode=file, mask=IN_MODIFY|IN_CLOSE_WRITE) wd=1
守护进程自己的 report.json 甚至把真相都写出来了,只是没人当回事:
{
"watch_mode": "file", // ← 契约要求的是 dir
"config_revision_on_disk": "r2", // ← 磁盘上已经是 r2
"effective_revision": "r1", // ← 生效的还是 r1
"reloads": 0, // ← 一次都没重载过
"self_reported_ok": true // ← 却说自己一切正常
}
一句话现象:配置文件变了,进程却永远读不到新配置;而且没有任何警报。
二、复现步骤
场景目录 scenario/2026-10-01-0000/,一键起环境(无外网依赖):
cd scenario/2026-10-01-0000
docker compose up -d --build
docker compose exec app bash # 进容器(也可用 exec -T 直接跑脚本)
复现问题(问题态代码下):
docker compose exec -T app bash /srv/ops/repro.sh
关键输出:
[1] 守护进程启动日志:
[reloadd] pid=19 watching /srv/conf/service.json (mode=file, mask=IN_MODIFY|IN_CLOSE_WRITE) wd=1 revision=r1
[2] 原子发布 revision=r2:
[publisher] published revision=r2 -> /srv/conf/service.json
[3] 磁盘上的配置(publisher 的产物):
revision=r2 max_conn=512
[4] 对外声称「当前生效」的配置:
[consumer] 当前生效 revision=r1 source=boot max_conn=200 features=['alpha', 'beta']
[5] 守护进程自检报告:
watch_mode = file
config_revision_on_disk = r2
effective_revision = r1
reloads = 0
[6] 健康检查:
[healthz] OK: 守护进程存活,配置文件新鲜,热重载链路正常 ← rc=0,假绿
[7] 外部探针:
[check_reload] RESULT: BAD(最新配置 r2 未生效,进程仍在用 r1)
一键验收:
docker compose exec -T app bash /srv/ops/verify.sh
# 问题态:RESULT: PASS=3 FAIL=6 / RESULT: BAD
决定性实验:一场对照,看清 inotify 到底收到什么
这是整个排查里最有价值的一步 —— 同一时刻,分别以「文件」和「目录」为监听目标,
做一次 os.replace 原子发布,各自收到什么事件:
import os, sys, time
sys.path.insert(0, "/srv/app")
import inotify
TARGET = "/srv/conf/service.json"
def trial(mode):
fd = inotify.init()
path = os.path.dirname(TARGET) if mode == "dir" else TARGET
mask = (inotify.IN_CREATE | inotify.IN_MOVED_TO | inotify.IN_MODIFY |
inotify.IN_CLOSE_WRITE | inotify.IN_DELETE | inotify.IN_DELETE_SELF |
inotify.IN_MOVE_SELF)
inotify.add_watch(fd, path, mask)
st = os.stat(TARGET)
open("/srv/conf/.experiment.tmp", "w").write('{"revision": "exp"}')
os.replace("/srv/conf/.experiment.tmp", TARGET) # ← 原子发布,换名
time.sleep(0.5)
try:
evs = inotify.read_events(fd)
except BlockingIOError:
evs = []
ns = os.stat(TARGET)
print("mode=%-4s watch=%-28s inode: %d -> %d" % (mode, path, st.st_ino, ns.st_ino))
print(" events=%d %s" % (len(evs),
["|".join(inotify.parse_mask(e[1])) + " name=" + e[3] for e in evs]))
trial("file")
trial("dir")
实测输出(本机容器内):
mode=file watch=/srv/conf/service.json inode: 25770861361 -> 25770894243
events=2 ['IN_DELETE_SELF name=', 'IN_IGNORED name=']
mode=dir watch=/srv/conf inode: 25770894243 -> 25770894225
events=4 ['IN_CREATE name=.experiment.tmp', 'IN_MODIFY name=.experiment.tmp',
'IN_CLOSE_WRITE name=.experiment.tmp', 'IN_MOVED_TO name=service.json']
结论清清楚楚:
file模式在os.replace后只收到IN_DELETE_SELF+IN_IGNORED—— 旧 inode 被换掉,内核直接把 watch 摘除了,IN_MODIFY/IN_CLOSE_WRITE一次都没有;dir模式能收到IN_MOVED_TO name=service.json—— 这正是「新文件被换到位」的信号。
三、根本原因
os.replace 是换名字(换目录项),不是改内容:
发布前: /srv/conf/service.json -> inode 1001 (r1)
发布中: /srv/conf/.service-xx -> inode 1002 (r2,已 fsync)
发布后: /srv/conf/service.json -> inode 1002 (r2) ← 换的是目录项
inode 1001 从此无名字(孤儿,等最后一个 fd 关闭才回收)
而守护进程监听的是 service.json 这个路径当前对应的 inode(1001)。
发布之后新文件是 inode 1002,旧 watch 还挂在 1001 上,永不触发;
内核同时发 IN_IGNORED 明确告知「这个 watch 已经废了」,
而问题态代码只打了一行 [WARN] watch 失效 就 continue ——
从此事件源永久断开,还不报错。
一共三处缺陷:
| # | 位置 | 问题 | 后果 |
|---|---|---|---|
| 1 | reloadd.py WATCH_MODE="file" | 监听文件而非目录 | os.replace 后 watch 被 IN_IGNORED 摘除,事件源永久断开 |
| 2 | 掩码只有 IN_MODIFY|IN_CLOSE_WRITE | 没有 IN_MOVED_TO | 即使监听了目录也识别不出「换名」发布 |
| 3 | reloadd.py / healthz.py 判据 | self_reported_ok 是写死的 True;healthz 只看「进程活着 + 文件 mtime 新鲜」 | 「配置没生效」这种致命故障恒绿,运维无感 |
附带:代码把 WATCH_MODE 写死,从不读取契约 conf/reloadd.env 的 RELOAD_WATCH_MODE=dir,
契约形同虚设。
四、解决办法
只改 app/ 下两个文件,conf/、ops/、client/ 一字未动(判据不许改题,md5 基线校验通过)。
1)app/reloadd.py:从契约读模式,改为监听目录并识别换名
DIR_MASK = inotify.IN_MOVED_TO | inotify.IN_CREATE | inotify.IN_DELETE | inotify.IN_MOVED_FROM
FILE_MASK = inotify.IN_MODIFY | inotify.IN_CLOSE_WRITE
contract = load_contract()
watch_mode = contract.get("RELOAD_WATCH_MODE", "dir") # 契约说了算,不写死
on_overflow = contract.get("ON_QUEUE_OVERFLOW", "rescan")
on_watch_lost = contract.get("ON_WATCH_LOST", "rewatch")
if watch_mode == "dir":
watch_target = os.path.dirname(WATCH_PATH) or "."
watch_mask = DIR_MASK
else:
watch_target = WATCH_PATH
watch_mask = FILE_MASK
inotify.add_watch(fd, watch_target, watch_mask) # 监听目录!
事件处理里按文件名过滤(发布器的 .service-*.tmp 也会产生事件,不过滤会有噪音/重复重载):
if watch_mode == "dir":
if name != os.path.basename(WATCH_PATH):
continue # 忽略临时文件等无关事件
triggered = True # IN_MOVED_TO / IN_CREATE 即「配置换了」
队列溢出 / watch 失效时按契约兜底(原来只打 WARN 就继续睡):
if emask & inotify.IN_Q_OVERFLOW:
if on_overflow == "rescan": rescan = True
if emask & (IN_DELETE_SELF | IN_MOVE_SELF | IN_IGNORED):
if on_watch_lost == "rewatch":
wd = arm() # 重建 watch
rescan = True # 并全量重扫,保证收敛到最终值
...
if triggered or rescan: ... # rescan 分支保证「突发发布」「删后重建」能收敛
自检不再是常量:
"self_reported_ok": (_revision_on_disk() == revision),
2)app/healthz.py:补上真正的判据
# ★ 磁盘上的配置是否真的装进了内存
if disk_rev is not None and disk_rev != eff_rev:
problems.append("最新配置未生效:磁盘 revision=%s,生效 revision=%s" % (disk_rev, eff_rev))
# ★ 代码实际用的监听模式必须与契约一致
if c_mode and r_mode and c_mode != r_mode:
problems.append("监听模式与契约不一致:契约=%s,实际=%s" % (c_mode, r_mode))
3)改完必须重建镜像
app/ 是 COPY 进镜像的,restart / exec 跑的还是旧代码:
docker compose up -d --build
五、验证
docker compose up -d --build
docker compose exec -T app bash /srv/ops/verify.sh
修复后:
[PASS] A1 守护进程拉起后生效配置 = 磁盘配置 (r1)
[PASS] A2 健康检查通过 (0)
[check_reload] RESULT: OK(最新配置 r2 已生效)
[PASS] B1 原子发布后生效配置跟进到 r2 (r2)
[PASS] B2 生效配置的 max_conn 跟进到 512 (512)
[PASS] C1 连续 3 次发布每次都跟进 (yes)
[PASS] D1 突发发布后生效配置 = 最终值 r-final (r-final)
契约 RELOAD_WATCH_MODE=dir
[PASS] E1 代码实际使用的监听模式与契约一致 (dir)
[PASS] F1 删除后重建配置仍能生效 (r-again)
[PASS] G1 conf/ 契约 md5 与基线一致 (0)
RESULT: PASS=9 FAIL=0
RESULT: OK
repro.sh 也转为正常:
[5] watch_mode=dir watch_target=/srv/conf effective_revision=r2 reloads=1
[7] [check_reload] RESULT: OK(最新配置 r2 已生效)
结论:最新配置已生效 —— 热重载链路正常
反向验证(证明 healthz 真的会报警了):把 daemon 换回问题态逻辑再跑 healthz:
report.json: {'watch_mode': 'file', 'config_revision_on_disk': 'r9',
'effective_revision': 'r2', 'reloads': 0, 'self_reported_ok': True}
[healthz] BAD: 最新配置未生效:磁盘 revision=r9,生效 revision=r2;
监听模式与契约不一致:契约=dir,实际=file
healthz rc=1
同一种故障:修复前 OK rc=0,修复后 BAD rc=1。
六、经验与避坑
核心认知
os.replace/rename是「换目录项」,不是「改内容」。 监听文件本身时,换名之后 watch 会被内核IN_IGNORED摘除。 典型指纹:reloads=0、日志一次 ERROR 都没有、重启就恢复。- 发布方式决定监听方式:原子发布 ⇒ 必须监听目录,
靠
IN_MOVED_TO感知「新文件换到位」,IN_CREATE/IN_DELETE兜底。 - 监听目录必须按文件名过滤:同目录的临时文件(
.service-*.tmp)也会产生事件, 不过滤会造成重复重载与噪音。 self_reported_ok = True这种「手写常量」等于没有自检。 自检结果必须是算出来的(磁盘 revision == 生效 revision)。- 不要用「文件变了」证明「进程读到了」。 唯一可信的判据是外部探针 直接比对「磁盘 revision」vs「生效 revision」。
IN_Q_OVERFLOW/IN_IGNORED不能只打日志:溢出要全量重扫(rescan), watch 失效要重建(rewatch),否则遇到突发发布就静默停更。
踩坑记录
pkill -f 'python3 /srv/app/reloadd.py'会杀掉自己的排查 shell —— 当前 shell 的 cmdline 里也含这个字符串,表现为任务执行失败,返回码:143。 稳妥写法是按/proc/*/cmdline精确匹配并跳过$$:
for p in $(ls /proc | grep -E "^[0-9]+$"); do
[ "$p" = "$$" ] && continue
cmd=$(tr "\0" " " < /proc/$p/cmdline 2>/dev/null)
case "$cmd" in *"reloadd.py"*) kill -9 "$p" 2>/dev/null;; esac
done
- 本内核的
/proc/<pid>/fdinfo/<inotify_fd>不打印inotify wd:行, 「watch 挂在哪个 inode」没法直接读。改用「file/dir 两种模式对照实验」拿到更硬的证据 (见第二节的决定性实验)。 - 别只看
reloadd.log:事件压根没来,日志里当然没有 ERROR。 - 别拿
md5sum conf/service.json当「已生效」的证据:文件内容变了 ≠ 进程读到了。 - 别调大
sleep/ 加长--interval:这不是「反应慢」,是事件源已经断了。 - 别以为把
IN_MODIFY掩码加全就行:换名(os.replace)根本不走IN_MODIFY。 - 别删掉契约里的
RELOAD_WATCH_MODE让校验变绿 —— 要改的是代码有没有按契约执行。 app/是COPY进镜像的,改完必须docker compose up -d --build。conf/service.json是发布器写入的运行期产物,state/是生成目录, 提交前记得git checkout -- conf/service.json并清掉state/*.json,别把运行时状态提交上去。
同类影响面
K8s ConfigMap/Secret 挂载热更新(..data symlink 切换 —— 本质也是换名)、
Nginx reload 监听 include 目录、证书/日志按目录监听热加载、
任何「临时文件 + rename 原子替换」的发布链路 + inotify 监听端。
自查一句话:你 watch 的是目录还是文件?发布用的是就地写还是 rename?
两者不匹配,就一定会有「配置发了却不生效、重启才好」的幽灵故障。
