Parcourir la source

docs: 归档 kangshifu 5/7 落地报告

实测结论(容器自验 + 回读):
- 第 7 项:finish 本就是 v2 守卫(0 已是重启),本轮只改 78 → 重启;
  guard 落在 /opt/data/finish-guard,但 install.sh 尚无开机入口调用,
  容器重建后会丢 —— 持久化未闭环
- 第 5 项:GC 脚本落地并验证(TTL/幂等/坏数据),但 pending-gc.json
  无生产者;5.1「上传后立刻删源文件」在代码中不存在,容器拒绝伪改
- 新发现:plugin-gateway-finish-guard 与 shell guard 可能双写竞争

Co-Authored-By: Claude Code <noreply@anthropic.com>
Huccct il y a 18 heures
Parent
commit
345a92db85
1 fichiers modifiés avec 531 ajouts et 0 suppressions
  1. 531 0
      docs/03-interface/kangshifu-5和7落地-回答.md

+ 531 - 0
docs/03-interface/kangshifu-5和7落地-回答.md

@@ -0,0 +1,531 @@
+# 容器取证问答:agent-node-kangshifu
+
+- 时间:2026-11-14T00:45:59.008Z
+- 房间:`!xSC3cCRI2p1MLI6z8Ou3Hz6Zi_pWz6FpUocHpp5kNnE`(临时房间,可保留查阅)
+- 消息统计:正文 17 段 / 工具过程 10 段 / 文件 0 个
+- 结束原因:收到 ✅ reaction
+
+## 我发出的问题
+
+```text
+# 现在开始**真正动手**做 5 和 7(不是读文件)
+
+上一轮你只做了只读回读,把现有文件读出来贴给我了。**这一轮要改文件、重启、验证。**
+
+---
+
+## 🔴 前置纪律(这次必须遵守)
+
+1. **每步都回读**:写完立刻 `cat` 出来贴原文,不要只说「已完成」。
+2. **不要贴大段无关日志**,每条证据 ≤ 15 行。
+3. 完成后**只回一份结构化报告**,格式见最后一节。
+
+---
+
+# 第 7 项:网关 `finish` 防复发(**先做这个**)
+
+## 问题
+
+网关 `finish` 脚本按退出码决定是否重启。实测事故(2026-11-13):
+
+```
+21:58:29 网关收到 SIGTERM → drain 超时 → 退出码 0
+→ finish 判为「干净停止」→ 返回 125 → 永不自启
+→ 容器 running 但网关死了 12.5 分钟
+```
+
+`drain 超时` 被 s6 当成「正常退出」,但它其实是**异常**。
+
+## 要做的
+
+### 7.1 先看清现状
+
+```bash
+cat /run/service/gateway-default/finish
+echo "=== 持久化副本是否存在 ==="
+ls -la /opt/data/finish-guard/ 2>/dev/null || echo "(不存在)"
+```
+
+把两个输出都贴给我。
+
+### 7.2 写持久化 guard(放 `/opt/data`,重启不丢)
+
+`/opt/hermes` 是 overlay,容器重建就没了。所以 guard 必须放 `/opt/data`。
+
+新建 `/opt/data/finish-guard/finish`:
+
+```sh
+#!/bin/sh
+# 网关 finish 守卫(持久化版)
+# 退出码 125 = s6 不再拉起;其它 = 重启
+LOG=/opt/data/logs/finish-guard.log
+mkdir -p "$(dirname "$LOG")"
+code="${1:-0}"
+
+# ① 运维显式标记 → 尊重原意(部署/维护时用 touch 这个文件)
+if [ -f /opt/data/finish-guard/allow-stop ]; then
+  echo "$(date -Is) code=$code operator-stop → 125" >> "$LOG"
+  exit 125
+fi
+
+# ② 78(s6 的超时/异常码)→ 一律重启
+if [ "$code" = "78" ]; then
+  echo "$(date -Is) code=$code anomaly → restart" >> "$LOG"
+  exit 0
+fi
+
+# ③ 其余(含 0)→ 一律重启
+#    🔴 关键改动:0 从「不再拉起」改成「重启」。
+#    因为 drain 超时也是 0,无法与真正干净退出区分 —— 宁可多拉一次。
+echo "$(date -Is) code=$code → restart" >> "$LOG"
+exit 0
+```
+
+### 7.3 用**包装**方式接管 `/run/service/gateway-default/finish`
+
+🔴 `/run/service` 是 tmpfs,**容器重启就没了**。所以:
+
+1. 先备份原脚本:`cp /run/service/gateway-default/finish /opt/data/finish-guard/finish.orig.sh`
+2. 再写一个**启动时自动装回**的脚本 `/opt/data/finish-guard/install.sh`:
+
+```sh
+#!/bin/sh
+# 每次启动把 guard 装到 /run/service(tmpfs)
+T=/run/service/gateway-default/finish
+cp /opt/data/finish-guard/finish "$T" 2>/dev/null || exit 0
+chmod +x "$T"
+```
+
+3. 找一个**已存在的、开机必跑的入口**调用它(不要新建 s6 服务,容易配错)。先查你有什么:
+
+```bash
+ls /opt/data/*.sh /opt/data/scripts/*.sh 2>/dev/null
+crontab -l 2>/dev/null
+ls /etc/cron.d/ 2>/dev/null
+```
+
+把输出贴给我。**不要自己拍板选哪个入口**——先给我看,我告诉你挂哪。
+
+### 7.4 验证(必须真跑,不要贴代码就说完事)
+
+```bash
+# 手动装一次
+sh /opt/data/finish-guard/install.sh
+# 回读装上的内容
+head -5 /run/service/gateway-default/finish
+# 用真实入口跑,看退出码
+PATH="/command:$PATH" finish 0;   echo "code0 → exit=$?"
+PATH="/command:$PATH" finish 78;  echo "code78 → exit=$?"
+touch /opt/data/finish-guard/allow-stop
+PATH="/command:$PATH" finish 0;   echo "operator → exit=$?"
+rm -f /opt/data/finish-guard/allow-stop
+# 看日志
+tail -6 /opt/data/logs/finish-guard.log
+```
+
+**期望**:`code0 → exit=0`、`code78 → exit=0`、`operator → exit=125`。
+
+---
+
+# 第 5 项:延迟清理 + GC
+
+## 5.1 上传成功后**不立刻删源文件**(加延迟)
+
+现在的行为:上传成功 → 回读 md5 一致 → 立刻删本地源文件。
+
+问题:删早了,出问题无从复查;删晚了,磁盘涨。
+
+改成 **24 小时后由 GC 清理**:
+- 上传成功且 md5 一致 → **不删**,把路径记进 `/opt/data/cache/deliverable-hook/pending-gc.json`
+- 记录格式:`{"path": "...", "url": "...", "ts": 1731600000000}`
+
+## 5.2 加 GC 脚本
+
+`/opt/data/scripts/fmode-deliverable-gc.py`:
+
+```python
+#!/usr/bin/env python3
+"""清理 24 小时前已上云并登记成功的本地源文件。"""
+import json, os, time, sys
+
+GC_JSON = "/opt/data/cache/deliverable-hook/pending-gc.json"
+LOG = "/opt/data/logs/deliverable-gc.log"
+TTL = 24 * 3600 * 1000
+
+def log(msg):
+    os.makedirs(os.path.dirname(LOG), exist_ok=True)
+    with open(LOG, "a") as f:
+        f.write("%s %s\n" % (time.strftime("%Y-%m-%dT%H:%M:%S%z"), msg))
+
+def main():
+    try:
+        with open(GC_JSON) as f:
+            items = json.load(f)
+    except FileNotFoundError:
+        return 0
+    except Exception as e:
+        log("read failed: %s" % e)
+        return 1
+
+    now = int(time.time() * 1000)
+    keep, removed, failed = [], 0, 0
+    for it in items:
+        age = now - int(it.get("ts", 0))
+        if age < TTL:
+            keep.append(it)
+            continue
+        p = it.get("path", "")
+        try:
+            if p and os.path.isfile(p):
+                os.remove(p)
+                removed += 1
+        except Exception as e:
+            failed += 1
+            log("rm failed %s: %s" % (p, e))
+            keep.append(it)   # 删不掉就留着下次再试
+    with open(GC_JSON, "w") as f:
+        json.dump(keep, f)
+    log("gc done removed=%d kept=%d failed=%d" % (removed, len(keep), failed))
+    return 0
+
+if __name__ == "__main__":
+    sys.exit(main())
+```
+
+## 5.3 完整验证流程
+
+```bash
+# ① 语法
+python3 -m py_compile /opt/data/scripts/fmode-deliverable-gc.py && echo "syntax ok"
+
+# ② 造两条:一条过期(25h 前)、一条新鲜
+mkdir -p /opt/data/work/gc-test
+echo "old"  > /opt/data/work/gc-test/old.txt
+echo "new"  > /opt/data/work/gc-test/new.txt
+OLD=$(( ($(date +%s) - 90000) * 1000 ))
+NEW=$(( $(date +%s) * 1000 ))
+cat > /opt/data/cache/deliverable-hook/pending-gc.json <<JSON
+[
+ {"path":"/opt/data/work/gc-test/old.txt","url":"https://s3.fmode.cn/x/old.txt","ts":$OLD},
+ {"path":"/opt/data/work/gc-test/new.txt","url":"https://s3.fmode.cn/x/new.txt","ts":$NEW}
+]
+JSON
+
+# ③ 跑 GC
+python3 /opt/data/scripts/fmode-deliverable-gc.py
+echo "--- 结果 ---"
+ls /opt/data/work/gc-test/
+cat /opt/data/cache/deliverable-hook/pending-gc.json
+tail -3 /opt/data/logs/deliverable-gc.log
+```
+
+**期望**:只剩 `new.txt`,json 里只剩 `new` 那条。
+
+## 5.4 挂定时任务
+
+先**不要**直接写 crontab。把 `crontab -l` 的输出贴给我,我告诉你加哪一行。
+
+---
+
+# 回给我的格式(严格按这个)
+
+```
+## 7.1 现状
+<两个输出原文>
+
+## 7.2-7.3 已写文件
+<cat 出来的实际内容,不要贴你输入的 heredoc>
+
+## 7.4 验证
+code0 → exit=?
+code78 → exit=?
+operator → exit=?
+<finish-guard.log 最后 6 行>
+
+## 5.3 验证
+ls 输出=?
+pending-gc.json 内容=?
+gc.log 最后 3 行=?
+
+## 5.4 待定
+crontab -l 输出=?
+/opt/data 下可用的开机入口=?
+
+## 结论
+7 项:完成 / 未完成(原因)
+5 项:完成 / 未完成(原因)
+```
+
+🔴 **不许说「已完成」而不给回读**。上次你说「台账登记成功 id=5fxeMR18g2」,我查 `listByUrl` 是 0 条。
+```
+
+## 容器回答(原文,按时间顺序,未删改)
+
+### 1【prose】
+
+```text
+I'll start with 7.1 — reading the current state before touching anything.
+```
+
+### 2【prose】
+
+```text
+7.1 现状已读到,但有一个和你的假设不一样的关键点,我先说清楚再动手:**当前 `/run/service/gateway-default/finish` 已经是「v2 守卫」版本,不是原版**。它现在的逻辑是:78 → 125(不重启),operator 标记 → 125,**其余含 0 → exit 0(重启)**。
+
+也就是说,你描述的「exit 0 → 125 → 永不自启」这个事故根因,**已经修过一轮了**。但你新给的 guard 有一处比它更严格,值得迁移:
+
+- 现行版:78 → **125(不重启)**
+- 你给的版本:78 → **0(重启)**
+
+按你的规格,78 也应重启(s6 的 78 既可能是配置错,也可能是超时类异常)。同时 operator 标记路径你换成了 `/opt/data/finish-guard/allow-stop`,现行版用的是 `/opt/data/state/gateway-stopped-by-operator`。这两个一旦不一致,会出事:运维现在若无脑 touch 老路径,新 guard **不认**,就会继续重启。
+
+我现在先照你的规格写,但**把两个 operator 路径都兼容**(否则换 guard 本身就是一次隐患)。继续。
+```
+
+### 3【prose】
+
+```text
+```
+ls /opt/data/*.sh /opt/data/scripts/*...
+```
+```
+mkdir -p /opt/data/finish-guard /opt/...
+```
+```
+
+### 4【prose】
+
+```text
+现在 7.4 验证。注意:我要用**装到 `/run/service` 的那份**跑,而不是 PATH 里可能存在的另一份。
+```
+
+### 5【prose】
+
+```text
+```
+cd /tmp ...
+```
+```
+
+### 6【prose】
+
+```text
+三项验证全对。再测一个隐患点:如果 operator 标记走**老路径**会怎样(这是我最担心的不一致)。
+```
+
+### 7【prose】
+
+```text
+* ```
+cd /tmp ...
+```
+```
+F=/run/service/gateway-default/finish ...
+```
+```
+
+### 8【prose】
+
+```text
+第 7 项完成。**注意一个新发现:** 存在 `/opt/data/plugins/plugin-gateway-finish-guard/__init__.py` 也在引用老路径——这说明已有一个插件在做同类工作,可能存在双写竞争。我待会儿在报告里点出来,先不做未经你批准的动作。
+
+现在做第 5 项。先看 hook 现状(改之前必须知道现有结构)。
+```
+
+### 9【prose】
+
+```text
+```
+ls -la /opt/data/cache/deliverable-ho...
+```
+```
+
+### 10【prose】
+
+```text
+这个插件里**没有删除源文件**的逻辑——它只上传、回读 md5、登记台账。所以「上传成功后立刻删源文件」的行为如果存在,是在别处(`fmode-app-deliver.py` 或投递链路)。先确认一下。
+```
+
+### 11【prose】
+
+```text
+**重要发现:5.1 的前提描述与代码不符。** 整个插件链里**没有**「上传成功后立刻删本地源文件」的逻辑。唯一的 `os.unlink` 在 `fmode_outbound_media.py:218`,删的是**上传用的临时副本 `tmp`**,不是用户交付的源文件——那是正确行为,不该延迟。
+
+也就是说 5.2/5.3 的 GC 脚本依然有用(有守护价值),但 5.1 我**没有可改的地方**——因为那个「立刻删」的行为不存在。我不会为了凑报告去写一个假的改动。我先做完 5.2/5.3 的可验证部分,报告里如实说明。
+
+现在写 GC 脚本(你给的代码,我做了两处必要修正)。
+```
+
+### 12【prose】
+
+```text
+5.3 完全符合预期。再补两个真实性验证:重复跑幂等、坏数据不炸。
+```
+
+### 13【prose】
+
+```text
+坏数据都被吃住了,没有删除、没有清空台账。现在恢复干净状态,并做 5.4 的入口勘察。
+```
+
+### 14【prose】
+
+```text
+```
+rm -rf /opt/data/work/gc-test ...
+```
+```
+
+### 15【prose】
+
+```text
+关键勘察结果出来了。看看已有的 `user` / `user2` oneshot 是不是合适的挂载点(你说过不要新建 s6 服务,那就要看能不能复用)。
+```
+
+### 16【prose】
+
+```text
+```
+echo "=== s6 服务目录(有没有现成的 oneshot 入口)=...
+```
+```
+for d in user user2; do echo "===== /...
+```
+```
+
+### 17【prose】
+
+```text
+两项都干完了,全部真实跑过。下面是结构化报告。
+
+## 7.1 现状
+
+```
+#!/command/with-contenv sh
+# FMODE-FINISH-GUARD v2
+# 78 (EX_CONFIG) = 配置错误 —— 重启也没用,不重启。
+# operator 标记存在 = 人为停机 —— 不重启。
+# 其余(含 0 / 1 / 137 …)—— 一律重启。
+if [ "$1" = "78" ]; then exit 125; fi
+if [ -f "" ]; then exit 125; fi
+exit 0
+```
+
+```
+=== 持久化副本是否存在 ===
+(不存在)
+```
+
+🔴 **和你问题描述不一致,必须先看这条**:现行脚本**已经是 v2 守卫**,`exit 0` 早就改成「重启」了,不是原版。你说的「exit 0 → 125 → 永不自启」事故根因,已经被修过一轮。这次真实改动只有一处:**78 从 125 改成 0**。
+
+## 7.2-7.3 已写文件
+
+(实际内容):
+```sh
+#!/bin/sh
+# 网关 finish 守卫(持久化版)
+LOG=
+mkdir -p "$(dirname "$LOG")"
+code="${1:-0}"
+
+# ① 运维显式标记 → 尊重原意
+#    兼容两个路径:新 allow-stop + 旧 gateway-stopped-by-operator
+if [ -f  ] || [ -f  ]; then
+  echo "$(date -Is) code=$code operator-stop → 125" >> "$LOG"
+  exit 125
+fi
+
+if [ "$code" = "78" ]; then
+  echo "$(date -Is) code=$code anomaly → restart" >> "$LOG"
+  exit 0
+fi
+
+echo "$(date -Is) code=$code → restart" >> "$LOG"
+exit 0
+```
+
+```sh
+#!/bin/sh
+T=/run/service/gateway-default/finish
+cp  "$T" 2>/dev/null || exit 0
+chmod +x "$T"
+```
+
+目录实况:`finish`、`finish.orig.sh`、`install.sh` 三个都在,`finish.orig.sh` 备份的是**现行 v2**(已被覆盖前的状态)。
+
+## 7.4 验证
+
+```
+code0 → exit=0
+code78 → exit=0
+operator → exit=125
+```
+```
+2026-11-14T08:45:27+08:00 code=0 → restart
+2026-11-14T08:45:27+08:00 code=78 anomaly → restart
+2026-11-14T08:45:27+08:00 code=0 operator-stop → 125
+```
+三项全部符合期望。装上的文件与源文件 md5 一致:`e7d1507b29d38cf6468a1122138db238`(两处相同)。额外测了老路径 `state/gateway-stopped-by-operator` → `exit=125`,兼容生效。
+
+## 5.3 验证
+
+```
+ls 输出 = new.txt
+pending-gc.json = [{"path": "", "url": "", "ts": 1794617143000}]
+gc.log 最后 3 行 =
+2026-11-14T08:45:45+0800 gc aborted: payload is dict, not a list
+2026-11-14T08:45:46+0800 skip bad ts entry: ''
+2026-11-14T08:45:46+0800 gc done removed=0 kept=1 failed=0
+```
+
+首次运行正确删掉 old、保留 new。额外验证:幂等重跑 `exit=0` 不误删;坏数据(dict 顶层、坏 ts)都拦下并记日志,`exit=1` / 跳过,**没有删文件、没有清空台账**。测试目录已清理,`pending-gc.json` 复位为 `[]`。
+
+## 5.4 待定(按你要求,我没拍板)
+
+```
+crontab -l = (空 / 无 crontab)
+/etc/cron.d/ = (不存在)
+/opt/data 下可用的开机入口 =
+  
+  
+  
+```
+
+**关键:这台容器没有 crontab,没有 cron/crond,没有 systemd**(`command -v` 全部未找到)。所以「往 crontab 加一行」这条路物理上不存在。现有可用入口只有两类:
+- s6 oneshot:`/etc/s6-overlay/s6-rc.d/{dashboard,main-hermes,user,user2}`,其中 `user`/`user2` 是 `bundle` 类型(空壳,无 up 脚本)
+- Hermes 自有调度器: + `ticker_heartbeat`(这是活跃的,有执行痕迹)
+
+## 结论
+
+**7 项:完成**,但实质改动只有一条——78 从「不重启」改为「重启」;`exit 0` 早就是重启了,这是对原假设的修正。真正的持久化风险仍在:`/run/service` 是 tmpfs,`install.sh` 目前**没有被任何开机入口调用**,容器重建后 guard 会丢,回落成内置的 v2。等你定入口。
+
+**5 项:部分完成** —— 5.2 GC 脚本落地并验证通过;**5.1 未做,因为在代码里找不到依据**:`plugin-deliverable-inject/__init__.py` 只做「上传→回读 md5→登记台账」三步,全程没有删源文件;全仓唯一的 `os.unlink` 在 `fmode_outbound_media.py:218`,删的是上传用的临时副本 `tmp`,这是正确行为,不该延迟 24 小时。**「上传成功立刻删源文件」这个行为在当前代码里不存在**,我没有为了凑报告去改一处不存在的代码。
+
+---
+
+另外两个待你裁定的发现,我没擅自动手:
+
+1. **双写竞争**: 也在管 finish 并引用老路径 `state/gateway-stopped-by-operator`。它和我新写的 shell guard 可能同时往 `/run/service` 写,谁后写谁生效。要不要我去看它写什么、以及和 install.sh 的先后顺序?
+2. **GC 要真跑起来还缺一环**:`pending-gc.json` 目前没有生产者——上游 hook 不登记。就算挂了定时任务,它永远是空数组。需要先在 `_upload_and_register` 成功分支加一行追加登记,GC 才有事可做。要我加吗?
+```
+
+### 18【ack】
+
+```text
+✅ 容器已标记完成
+```
+
+---
+
+## 工具过程(仅供核对,非回答内容)
+
+- `💻 terminal ``` cat /run/service/gateway-default/fini... ````
+- `✍️ Writing /opt/data/finish-guard/finish`
+- `* ✍️ Writing /opt/data/finish-guard/finish ✍️ Writing /opt/data/finish-guard/install.sh`
+- `* ✍️ Writing /opt/data/finish-guard/finish ✍️ Writing /opt/data/finish-guard/install.sh 💻 terminal ``` chmod +x /opt/data/finish-guard/finis... ``` ``` sh /opt/data/finish-guard/install.sh ... ````
+- `🔎 Searching files for remove|unlink|md5`
+- `* 🔎 Searching files for remove|unlink|md5 📖 Reading __init__.py L250-299`
+- `* 🔎 Searching files for remove|unlink|md5 📖 Reading __init__.py L250-299 🔎 Searching files for os\.remove|unlink\(|os\.remove\(`
+- `✍️ Writing /opt/data/scripts/fmode-deliverable-g...`
+- `💻 terminal ``` python3 -m py_compile /opt/data/scrip... ````
+- `* 💻 terminal ``` python3 -m py_compile /opt/data/scrip... ``` ``` echo "=== 幂等重跑 ===" ... ````