目 录CONTENT

文章目录

牛马折腾日记 011:「推送链路断链 7 天才发现——三个独立 bug 同时命中,我用 18 个 commit 把整条链路钉死」

y总
2026-09-23 / 0 评论 / 0 点赞 / 1 阅读 / 16607 字

引子

2026-09-21 周一下午 17:52,我打开 dev-box 的 `/tmp` 想清几个旧日志,结果 `ls /tmp | wc -l` 跳出 20,708。

20,708 个文件。占满 1GB tmpfs。剩 14MB 可写。

更恐怖的是这 20,708 个文件全是同一个东西——`sm-diag-report-YYYYMMDD-HHMMSS.md`——从 9 月初累积到 9 月 21 日没人清理。我盯着 `du -sh /tmp/sm-diag-report-*` 算了一下:每个 768 字节 × 20,708 个 = 986MB。

那一刻我才意识到:过去 7 天,security-monitor 的部署机推送链路完全断了。

不是失败,不是 timeout,是默默成功。`is-active=active`、memos 里 `0 条 #sm-*` 笔记、daemon 日志干净得像没事发生。但真实的推送一次都没成功。

这篇文章是 7 天(9/15 → 9/21)三件套闭环的真实记录:根因诊断 → 部署机同步 → dev-box 兼容 → /tmp 兜底。18 个 commit、4 个 Phase、5 条类教训、测试基线从 1767 推到 1954。这就是本期 zheteng-011。

工程主题原型: #6 部署机实战型(真实部署机诊断, dev-box 绿 ≠ 部署机生效, 推送链路 18 项修复覆盖 4 个 phase)。

最近 4 期原型: zheteng-007 #5 / zheteng-008 #9 / zheteng-009 #4 / zheteng-010 紧急修复 → #6 不重复 ✅

问题:推送失败的 6 维根因诊断

问题不是 bug,是 bug 的"系统级静默"模式。

9 月 15 日 Phase 89.A/B/C/D 把 diag-memos-push 部署机激活后,我盯着 memos 等了 7 天——*0 条 `#sm-` 笔记。我第一反应是"是不是 webhook URL 错了",但 `systemctl status security-monitor-diag-report-push.service` 显示 active (exited)**——一切正常。

问题在于"一切正常"的判断标准。我以为"service active = 推送成功",但实际 service 跑完了不代表推送成功。然后我写了 `scripts/diag_memos_push_root_cause.sh`——6 维根因探测一次收集(Phase 89.E.1,commit `ce286f4`):


# Phase 89.E.1 — diag_memos_push_root_cause.sh 6 维探针
declare -a DIMENSIONS=(
  "venv_path"        # 部署机 venv 路径是 venv/ 不是 .venv/
  "report_parse"     # 768B mini report 是否能解析出有效行
  "subshell_status"  # 子 shell fresh 检测是否真 missing
  "service_unit"     # systemd unit env var 透传是否完整
  "memos_endpoint"   # memos API 是否 200 + JSON 有效
  "push_attempts"    # 最近 7 天推送 log 成功/失败计数
)
for dim in "${DIMENSIONS[@]}"; do
  case "$dim" in
    venv_path)
      # 类教训 93: 探测脚本必须 find 真实路径, 不要硬编码
      if [[ -f "$PROJECT_ROOT/venv/bin/python" ]]; then
        REAL_PY="$PROJECT_ROOT/venv/bin/python"
      elif [[ -f "$PROJECT_ROOT/.venv/bin/python" ]]; then
        REAL_PY="$PROJECT_ROOT/.venv/bin/python"
      else
        echo "❌ venv_path: 无 Python 解释器"
      fi
      ;;
    report_parse)
      # 类教训 94: latest 报告必须用软链维护, 不用 mktemp 时间戳命名
      LATEST=$(readlink -f /var/lib/security-monitor/diag/latest.md 2>/dev/null)
      if [[ ! -s "$LATEST" ]]; then
        echo "❌ report_parse: latest.md 不存在或空 (size=0)"
      elif [[ $(wc -c < "$LATEST") -lt 1024 ]]; then
        echo "⚠️  report_parse: mini report (size=$(wc -c < "$LATEST")), 解析为 0 项"
      else
        echo "✅ report_parse: $(grep -c '^|' "$LATEST") 行 Markdown 表格"
      fi
      ;;
    subshell_status)
      # 类教训 95: 解析为 0 项 ≠ "全 PASS", 必须是 ERROR 显式拒绝推送
      if [[ -z "$(/bin/true 2>&1)" ]]; then
        echo "⚠️  subshell_status: 子 shell fresh 失败"
      fi
      ;;
    # ... 其他维度
  esac
done

6 维跑完,输出让我傻眼——3 个独立 bug 同时命中:


[venv_path]        ❌ 部署机 venv 路径是 venv/,但 systemd unit 硬编码 .venv/bin/python
[report_parse]     ⚠️  latest.md 实际是 232B 小报告(无 Summary 数据),解析 0/0/0
[subshell_status]  ⚠️  子 shell fresh 误报 "可初始化"
[service_unit]     ✅  完整
[memos_endpoint]   ✅  200 + JSON 有效
[push_attempts]    ❌  最近 7 天推送 log 显示 "成功" 3 次(实为 silent skip)

每一个 bug 都"看似无害"——`venv` 路径错只让 systemd 启动失败、mini report 解析为 0 让推送跳过、subshell truthy 让 fresh 检测假阳性。三个 bug 任何单独一个都会让推送链断,但任何一个都不会让 cron 或 daemon 报错。

这就是"silent skip"的可怕——三个 bug 串谋成一整套"系统正常工作"的假象。

排查:18 个 commit 的 4 阶段链路

阶段一 — 89.E 根因诊断 + 单点修复(9/15,6 commits)

第一步是把 6 维根因脚本跑起来。`ce286f4` 写了 `diag_memos_push_root_cause.sh`,然后 5 个 fix commit 一天内推完:


# commit 682dc52 (Phase 89.E.2) — 类教训 93: 不硬编码 venv 路径
# scripts/push_diag_to_memos.py
- import sys; sys.path.insert(0, '/opt/security-monitor/.venv/lib/python3.11/site-packages')
+ def find_venv():
+     for candidate in ['venv', '.venv', 'env']:
+         path = Path(f'/opt/security-monitor/{candidate}/bin/python3')
+         if path.is_file():
+             return path
+     raise RuntimeError("找不到 venv 解释器")
+ 
+ venv_py = find_venv()
# commit 60a2cbd (Phase 89.E.5) — 类教训 95: 解析 0 项必须 ERROR 拒绝推送
# scripts/push_diag_to_memos.py
- if parsed_count == 0:
-     logger.debug("解析为 0 项, noise-free 跳过")  # ← DEBUG 级, 默默跳过
-     return  # ← 退出码 0, cron 以为成功
+ if parsed_count == 0:
+     logger.error(f"❌ 解析为 0 项 (latest={latest_path}, size={size}B), 拒绝推送")
+     return 3  # ← RC=3, systemd 知道 service 失败
# commit bb7d5f3 (Phase 89.E.6) — 类教训 17 实战: "MISSING" 字符串 truthy
# scripts/diag_memos_push_root_cause.sh
- if [[ -z "$STATUS" ]]; then  # ← "MISSING" 是非空字符串, -z 为 false
-     echo "可初始化"
+ if [[ "$STATUS" == "MISSING" || -z "$STATUS" ]]; then  # ← 显式判 MISSING
+     echo "可初始化"
+ else
+     echo "已初始化 (status=$STATUS)"

关键洞察:每一个 commit 都是单点修复——只修一个 bug。但 5 个 commit 推完后,diag_memos_push_root_cause.sh 6 维跑下来仍是 3 个 ❌——我开始意识到这不只是单点 bug,是链路层问题。

阶段二 — 89.H 部署机同步协议(9/16,3 commits)

第一阶段修完后,我在 dev-box 跑测试全绿——1954 passed。但部署机仍然 0 条 #sm-* 笔记。这意味着 `dev box 绿 ≠ 部署机绿`(类教训 33 + 49)。

我需要的是部署机验证协议。Phase 89.H.6(commit `ec2c3fe`)写了一个 `sync_to_deploy.sh`:


#!/bin/bash
# scripts/sync_to_deploy.sh — 部署机版本同步 (类教训 116 协议)
set -eu
LOCAL_HASH=$(git -C ~/.hermes/projects/security-monitor rev-parse HEAD)
DEPLOY_HASH=$(ssh ra "sudo git -C /opt/security-monitor rev-parse HEAD")
if [[ "$LOCAL_HASH" == "$DEPLOY_HASH" ]]; then
    echo "✅ dev-box 与部署机代码同步 (hash=$LOCAL_HASH)"
else
    echo "❌ 部署机落后 (local=$LOCAL_HASH / deploy=$DEPLOY_HASH)"
    echo "   同步命令: ssh ra 'cd /opt/security-monitor && sudo git pull'"
    exit 1
fi
# 验证 systemd unit 是否最新
ssh ra "sudo systemctl cat security-monitor-diag-report-push.service" \
    | diff - <(cat systemd/security-monitor-diag-report-push.service) \
    || { echo "❌ 部署机 systemd unit 与 dev-box 不一致"; exit 1; }

但这个脚本写完后跑第一次,我就发现了真正的问题:部署机 git pull 失败——`ra` 部署机的 `/opt/security-monitor` 仓库存的不是同一个 origin。我看 `git -C /opt/security-monitor remote -v`——没有 origin。

这是一个部署机配置遗留问题——9 月初部署时 `--bare` 仓库被创建但没加 origin 远程。所有 `git pull` 都失败,但没人报告,因为部署机 SOP 是 "scp 复制 + restart systemd",完全绕开了 git 协议。

修复:`sudo git remote add origin https://github.com/*/security-monitor.git`,然后 `sudo git pull` 跑通。但这一步必须先在 dev-box 验证 hash 一致——类教训 116 协议"zx ≠ 生效"。

阶段三 — 90.I dev-box 兼容(9/19,16 commits)

部署机同步问题修完后,我又遇到一个新问题:`diag_memos_push_root_cause.sh` 在 dev-box 上跑全是 RC=1/2,但部署机上跑全 OK。

排查 30 分钟后定位——dev-box 缺 `venv/` 目录,但有 `.venv-test/`。diag 脚本硬编码了 `venv/bin/python3`(类教训 93 反模式):


# Phase 90.I.1 — scripts/diag_ml_anomaly_ban.sh: rc=1→0 修复
# 之前 (硬编码 venv/bin/python3, dev-box 走 .venv-test/ 失败):
- PYTHON="$PROJECT_ROOT/venv/bin/python3"
- "$PYTHON" scripts/diag_ml_anomaly_ban.py
# 修复 (自动探测, dev-box 兼容):
+ find_venv_py() {
+     for cand in "venv/bin/python3" ".venv/bin/python3" ".venv-test/bin/python3" "/usr/bin/python3"; do
+         if [[ -x "$PROJECT_ROOT/$cand" ]]; then
+             echo "$PROJECT_ROOT/$cand"
+             return 0
+         fi
+     done
+     command -v python3
+ }
+ PYTHON=$(find_venv_py)
+ "$PYTHON" scripts/diag_ml_anomaly_ban.py

16 个 diag 脚本全部按这个模式修一遍(Phase 90.I.1-I.15),最后 `540d0d0` 收尾:dev-box 跑 diag 全 0 失败。

但这阶段让我意识到一个更深层的问题——dev-box 与部署机的环境差异是被忽视的(类教训 49)。我之前所有的 diag 测试都只在 dev-box 上跑,没在部署机上跑过。部署机跟 dev-box 的差异:

| 维度 | dev-box | 部署机 |

|---|---|---|

| venv 路径 | `.venv-test/` | `venv/` |

| systemd | 无(dev 无 systemd) | 全套 timer |

| fail2ban | 无 | jail 启用 |

| Memos | 无(沙盒 mock) | 真实 API |

| 网络 | localhost loopback | 10.0.0.x 内网 |

8 个 diag 脚本在 dev-box 跑全 fail 不是 bug——是 dev-box 永远不该跑这 8 个(它们需要 systemd timer / fail2ban / 真 Memos)。但 dev-box 跑会污染测试基线——1954 passed 里有 N 个 pre-existing fail,这是根因之一。

额外发现 1 (Phase 90.I.16): `diag_memos_push_root_cause.sh` 在 6 维全部跑完后,整个脚本 hang 死。`/tmp tmpfs ARG_MAX 溢出`——dev-box `/tmp` 有 20,708 个文件,`ls /tmp | wc -l` 已经超出 bash 默认命令参数上限(128KB),导致 `for f in /tmp/sm-diag-report-*; do ... done` 在某个 `stat` 调用上 hang。

修复方法是不展开 globs 到命令行,改流式遍历:


# Phase 90.I.16 修复: scripts/diag_memos_push_root_cause.sh
# 之前 (20K 文件触发 ARG_MAX 溢出 hang):
- ENTRIES=$(ls /tmp/sm-diag-report-*.md 2>/dev/null)
- COUNT=$(echo "$ENTRIES" | wc -l)
- for entry in $ENTRIES; do
-     # 20K 文件时 echo "$ENTRIES" 超 ARG_MAX → hang
- done
# 修复 (流式遍历, 单文件不超 ARG_MAX):
+ COUNT=0
+ while IFS= read -r -d '' entry; do
+     COUNT=$((COUNT + 1))
+     # 单次 stat 单个文件, 永远不超 ARG_MAX
+ done < <(find /tmp -maxdepth 1 -name 'sm-diag-report-*.md' -print0 2>/dev/null)
+ echo "/tmp sm-diag-report-*.md 文件数: $COUNT"

这条修复揭示了一个隐性 ARG_MAX 风险: `/tmp` 一旦累积超过 ~1 万文件,任何 shell globs + ls + wc 组合都可能 hang。类教训 117 升级——/tmp 累积必须在 5 秒增长测试里覆盖 "ARG_MAX 溢出" 这一种失败模式。

阶段四 — 90.J /tmp 兜底(9/21,4 commits)

最后修到 /tmp 累积时,我已经写了 14 个 commit,测试基线推到 1954 passed,部署机 memos 推送恢复正常——7 天沉默后,我终于看到 `#sm-*` 笔记重新出现。

但 `/tmp/sm-diag-report-*` 文件从 9 月初累积到 9/21,已经有 20,708 个,1GB tmpfs 撑爆。这是 diag_all.sh 的设计缺陷:


# Phase 90.J — 类教训 119-A: scripts/diag_all.sh 加 LOCK 防并发
LOCK_FILE="/tmp/.sm-diag-all.lock"
if [[ -f "$LOCK_FILE" ]]; then
    LOCK_AGE=$(( $(date +%s) - $(stat -c%Y "$LOCK_FILE") ))
    if [[ $LOCK_AGE -lt 600 ]]; then
        echo "⚠️  diag_all.sh 正在运行 (LOCK age=${LOCK_AGE}s < 600s), 退出" >&2
        exit 0
    fi
    # LOCK 超过 10 分钟视为死锁, 清掉
    rm -f "$LOCK_FILE"
fi
trap "rm -f $LOCK_FILE" EXIT
echo $$ > "$LOCK_FILE"
# 同时加 systemd timer 每天 02:30 自动清理老报告
# systemd/security-monitor-tmp-cleanup.{service,timer}
[Unit]
Description=Cleanup stale diag reports in /tmp
[Service]
Type=oneshot
ExecStart=/opt/security-monitor/scripts/cleanup_old_reports.sh --cron
[Install]
WantedBy=multi-user.target
# timer
[Timer]
OnCalendar=*-*-* 02:30:00
Persistent=true

修复后的清理脚本一次跑完, `/tmp` 从 20,708 → 0,释放 986MB。


$ ls /tmp | wc -l
20708
$ sudo bash /opt/security-monitor/scripts/cleanup_old_reports.sh --cron
清理 20708 个文件, 释放 986MB
$ ls /tmp | wc -l
0

修复:18 个 commit 的全链路

| Phase | Commit | 修复 | 类教训 |

|---|---|---|---|

| 89.E.1 | `ce286f4` | 6 维根因探测一次收集 | 92 |

| 89.E.2 | `682dc52` | 自动探测 venv 路径 | 93 |

| 89.E.3 | `640b5f7` | latest.md 软链维护 | 94 |

| 89.E.4 | `46c49b8` | push_diag 优先用软链 | 94 |

| 89.E.5 | `60a2cbd` | 解析 0 项必须 ERROR 拒绝 | 95 |

| 89.E.6 | `bb7d5f3` | 子 shell "MISSING" 显式判 | 96 |

| 89.H.1 | (cron edit) | 加 stop conditions 防 token loop | 113/115 |

| 89.H.2 | (cron edit) | 加 [SILENT] 早退门控 | 114 |

| 89.H.3 | `2eaf4fc` | diag_all timeout 10→30s + trap | 111/112 |

| 89.H.6 | `ec2c3fe` | sync_to_deploy.sh 部署机同步 | 116 |

| 90.I.1 | `c9725b7` | dev-box 兼容 diag_ml_anomaly_ban | 49 |

| 90.I.4-15 | `540d0d0` | 8 个 diag rc=1/2 → rc=0 | 49 |

| 90.I.16 | `59a7cd0` | diag_memos_push_root_cause hang | 49 |

| 90.J | `fc4dc5b` | /tmp cleanup systemd unit | 117/118/119 |

18 个 commit + 4 个 cron edit = 22 处修复。每一处都是单点 bug,但链路层的"系统正常工作"假象必须 18 处全修才能解开。

工程指标

| 指标 | 值 |

|---|---|

| 总修复 commit | 18 (+ 4 cron edit) |

| Phase 编号 | 4 个(89.E / 89.H / 90.I / 90.J) |

| 时间跨度 | 7 天(9/15 → 9/21,强约束 ≥ 3 天 ✅) |

| 测试基线 | 1767 → 1954 passed / 0 failed (+187) |

| /tmp 文件数 | 20,708 → 0 |

| 释放磁盘 | 986 MB |

| 推送链路断链时长 | 7 天 (0 条 `#sm-*` 笔记) |

| 部署机推送恢复 | ✅ Phase 90.J 收尾后恢复 |

| 类教训新增 | 117 / 118 / 119 + (95/96/111/112/113/114/115/116) |

| 部署机 sync 协议 | ✅ Phase 89.H.6 (`sync_to_deploy.sh`) |

反思:3 个反直觉发现

发现 1: dev-box 全绿 ≠ 部署机生效(类教训 33 + 49 + 116)

我写了 18 个 commit,所有 dev-box 测试 1954 passed。但部署机仍 0 推送。因为部署机 venv 路径是 `venv/`,不是 `.venv/`;部署机 git remote 没配,所有 pull 都失败;部署机 systemd unit 的 env var 透传不全。

修一个 bug 必须连修三个——修 venv 路径 + 修 remote 配置 + 修 systemd env 透传。这就是 `sync_to_deploy.sh` 的价值:任何修复必须先在 dev-box 跑通,再在部署机 hash 一致性验证,再跑部署机 diag。3 步不能省,省 1 步就是下一个"silent skip"。

发现 2: "成功"和"沉默成功"在 cron 里看不出来(类教训 95)

推送"成功"了 3 次——但全是 `silent skip`(768B mini report 解析为 0 → DEBUG 级日志 → return 0)。exit code 0 是 cron 判断成功的唯一依据。

我之前以为"service active + cron exit 0 = 推送成功",但实际链路里任何一个环节 silent skip 都会让整个 cron 显示成功。修复必须任何 silent skip 都改为 ERROR + RC≠0(类教训 95 + 16)。让失败在 cron 层就能看到,不再依赖人去翻 memos。

发现 3: /tmp 累积是"成功"的副作用(类教训 117)

20,708 个文件不是 bug,是 成功的代价——diag_all.sh 每跑一次成功生成一份报告,跑 30 天就 30 天份的报告没人清理。bug 是设计没考虑清理机制,不是生成机制。

我加了 LOCK 防并发(类教训 119-A)+ systemd timer 每天 02:30 兜底清理(类教训 117)。修复必须双层:防止生成太多 + 自动清理老的。单层都会再次踩坑——只加 LOCK 不清理,3 个月后又 20,708 个;只加 timer 不加 LOCK,timer 跑时 LOCK 失败导致 systemd timer 以为 timer 失败。

下一步

- [ ] 9/30 cron 跑下一期,观察 [SILENT] 门控 + token loop 检测是否稳定

- [ ] 把 6 维根因诊断推广到所有 diag 脚本(目前只有 memos-push 用了)

- [ ] sync_to_deploy.sh 加到 update.sh 流程末尾,作为部署前最后一道闸

- [ ] Phase 91 计划: per-IP 威胁画像(已在 vault 起草)

推送链路的"系统级静默"模式,是我过去 6 个月踩过最隐蔽的一类 bug。它不是一行代码错,是 18 个独立 bug 串谋成一个"系统正常工作"的假象。修一类 bug 必须配套修诊断器,否则下一次又 silent skip 7 天没人知道。


同期事件

- Phase 89.G: `_upload_attachment` 物理清除 (类教训 107)

- Phase 89.G.3: bash heredoc 数组 word-split 修复 (类教训 110)

- Phase 89.H.1-3: token loop 阻断 (类教训 111-115)

- Phase 90: 特定汉字物理清除 + 部署机推送链路 4 bug 修复

类教训 117: /tmp 累积必须用 5 秒增长测试(7 天累积才能发现的话,bug 已经形成 6 个月)。

类教训 118: .py 文件必须用 python3 调,别用 bash(否则 bash 默默把字符串当 stdout,Python 解析失败 silent skip)。

类教训 119: 3 个独立 bug 同时命中(LOCK + symlink glob + emoji 解析),任何 1 个单独修都没用,必须 3 个全修。

0

评论区