运维实战:6 台机器全链路巡检,和那个删不掉的 12 GB

踩坑日记 约 3.8 千字

手里这套自建环境一共 6 台机器:一台公网小 VPS、两台内网 Windows、一台内网 Linux、一台 macOS,还有一台专职跑备份的虚拟机。平时各跑各的,只有出事的时候才想起它们之间是用隧道连起来的。这次花了一个下午做了一遍纯只读的全链路巡检,顺手清掉了一个藏了半个月的坑——一块 40 GB 的盘眼看要满,而占掉 12 GB 的是服务自己删不掉的垃圾。

一、先看全貌

角色规模主要负载
公网入口 VPS1 核 2 GB,单卷 40 GBnginx 前门、隧道服务端、Prometheus + Grafana 监控栈、采集器
内网 Windows 工作站双推理卡,128 GB 内存这个博客本体(Apache + PHP + MySQL)、本地模型推理、Hyper-V 里跑着两台子机
内网 Linux8 GB 内存以 Docker 为主:告警中继、探针、若干自用工具站
macOSApple SiliconXcode 构建、屏幕共享(未对公网开放)
备份 VM挂了 4 TB 数据盘手机增量备份
搜索 VM2 GB 内存自建元搜索引擎,只绑回环
6 台机器的角色(地址、端口、主机名均已隐去)

它们之间靠隧道串成一个网:公网 VPS 是唯一的入口,内网机器主动往外连,所以内网不需要任何公网入站规则。这个拓扑的代价是——VPS 成了单点,它一满、一挂,所有对外服务一起消失。

二、巡检的原则:能「量」就别「看」

「服务还在跑吗」这种问题,肉眼和直觉都不可靠。这次坚持每一条结论都得有一个可复现的数字,而且要从服务自己的视角去取,不要从外面猜:

  • 健康检查打服务自己的接口,而不是只看进程在不在。进程活着不代表它还在正常工作。
  • 磁盘按目录量,一层层算到「谁占了空间」,不要只看总量的百分比。
  • 备份看「最新对象的创建时间」,而不是看任务有没有报错。任务成功但没产出数据,等于没备份。
  • 证书看到期时间戳,不看「续期任务上次跑成功了吗」。任务成功但证书没换,照样到期。
  • 告警链路要端到端验:规则触发 → 告警管理器 → 推送通道 → 手机真的响了。中间任何一环断掉,前面全绿也没用。

按这套量下来,公网入口那台机器的监控抓取是全绿的——25 个抓取目标,25 个 up,没有任何一个掉线。但磁盘这一项,数字很难看。

三、主案:12 GB 就在那儿,删不掉

现象

40 GB 的单卷,可用只剩 5.7 GB,使用率 85.5%,而且小时级的磁盘告警已经在响了。有意思的是,在这之前同一块盘我清过一次,十几天就长回来了。

把监控数据的目录一层层量下去,空间去向很清楚:数据目录一共 16.5 GB,其中 213 个名字以 .tmp-for-deletion 结尾的目录,合计 12.1 GB——占了整块盘可用空间的四分之三还多。

名字已经把话说完了:这是 Prometheus 已经判定该删除的历史数据块(TSDB block),它自己改了名,然后……没删掉。日志里配着一行:

level=error component=tsdb msg="delete blocks" err="openfdat ...: The parameter is incorrect"
level=error component=tsdb msg="delete blocks" err="... access is denied"

为什么删不掉

关键在内存映射(mmap)。Prometheus 为了高效读历史数据,把数据块里的文件直接映射进自己的地址空间。在类 Unix 系统上,删掉一个已经被映射的文件名是完全合法的——文件在最后一个引用释放时才真正消失。但 Windows 不允许删除仍被映射的文件,返回的就是上面那两句「参数不正确 / 访问被拒绝」。

更微妙的是:改名可以成功,删除不行。所以流程走成了「改名成功 → 删除失败 → 剩下的半个状态永远留在盘上」。这不是权限问题,不是路径长度问题,也不是文件系统问题——我把这几条都排除过,最后才定位到「只要那个进程还在跑、只要它还映射着这几个文件,就删不动」。

为什么「每小时自动清理」治不了它

这类环境里通常都会配一个自动清理任务,本意是兜底。但它的日志长这样,日复一日:

removed=0 failed=213 freedMB=0 remaining=213
removed=0 failed=213 freedMB=0 remaining=213
removed=0 failed=213 freedMB=0 remaining=213

清不掉的原因和上面一样:只要服务在跑,那些文件就删不掉。清理脚本本身没有任何问题,它只是在一个不可能成功的时刻反复尝试。回顾历史日志,它偶尔真的成功过几次,那是因为碰巧服务自己释放了那几个块的映射——这是运气,不是机制。

结论:这个坑的解法只有一个方向——停下来,删掉,再起来。清理任务留作兜底可以,但别指望它当主力。

回收配方

  1. 先停掉监控守护任务。这一步最容易被忽略,但它是必须的:守护任务每分钟探一次服务端口,发现没在监听就会把服务重新拉起来,而那个启动脚本没有单实例保护。不放倒它,清理跑到一半服务就被拉回来了——删除重新开始失败,甚至可能出现两个实例抢同一份数据目录。
  2. 确认要停的进程身份:按进程名、PID 和命令行三者一起核对,不按命令行片段模糊匹配(这种匹配有可能命中你自己正在执行的命令)。
  3. 只删符合判据的目录:位置必须直接位于数据目录之下,且名字必须是「26 位 ID + .tmp-for-deletion」。先干跑统计,确认没有误伤。
  4. 把服务拉回来,等它健康检查通过。
  5. 恢复监控守护任务,并把第 1、5 步放进 try/finally——中途任何一步失败,服务也必须被拉回来。

干跑统计和执行结果(脱敏后):

guard  root=<TSDB 路径> targets=213 kept=25 outside=0

watchdog disabled
stopping service pid=5000
service stopped
deleted ok=213 failed=0 freedMB=12113.9 freeBeforeGB=6.03
started service
service healthy=True instances=1
freeAfterGB=17.86 remainingTmpDirs=0
watchdog re-enabled

全部 238 个目录里命中 213 个,落在判据之外的 0 个,保留 25 个(23 个在用数据块 + 两个索引/预写日志目录)。服务停机 36 秒,可用空间 5.7 GB → 17.7 GB,使用率 85.5% → 55.3%,磁盘告警随之解除。

一个值得提前想清楚的问题:停监控服务本身会不会触发告警?这里要分两种情况。如果监控系统会抓取自己,那它一停,抓取目标就会掉线,告警反而在「修故障」的时候响。这次确认过它不自刮,而同机另一条规则只对其他目标生效,所以这 36 秒是安静的。如果你的监控会自刮,就要么提前静默,要么接受一次误报。

四、其余四处发现

  • 备份链已经 4 天没有产出新对象。备份服务在跑、数据库在跑、API 也正常返回,但最新一条备份记录的时间停在 4 天前。这正是「看任务有没有报错」会漏掉的那类问题——这次是监控指标里「最近一次成功备份距今多久」把它抓出来的。顺带发现备份机的时区是 UTC 而不是本地时区,对时间线时要留意这个偏移。
  • 一条备用隧道被主动拒绝连接。监控里唯一一个探测失败的目标:备用入口的端口连接不是超时,而是 connection refused——对端在,但没有服务在监听。这种「拒绝」比「超时」信息量大得多,能直接区分「链路不通」和「服务没起来」。
  • 告警在反复抖动,噪音快把信号淹了。统计下来一天十几次推送,大部分是同一批告警在 firing / resolved 之间来回跳。其中一条的表达式依赖一个会周期性抓取失败的指标——指标一断,表达式算不出结果,告警就被判为「已恢复」,几分钟后又重新判为「触发」。「无数据」被当成「恢复正常」是这类抖动的经典成因,修法是让表达式显式处理空值,或者给告警加一个更长的持续时间。
  • 自监控把自己认成了攻击。macOS 那台的屏幕共享日志里,24 小时内出现了几百条「认证失败」,间隔非常规律。一开始像是被爆破,但对上节奏之后发现:那是它自己的巡检脚本在按固定周期探本机端口,而屏幕共享服务把「探测到端口即断开」也记成了认证失败。规律性本身就是证据——真爆破不会这么整齐。这类噪音会把真正的攻击淹掉,值得把自探活从服务的访问日志里排除。

五、一个只有踩过才知道的坑

清理脚本第一次跑,直接在第一步就失败了,日志里只有一句:

ERROR: 无法使用指定的命名参数解析参数集。

原因是我用了一个在「新版本 PowerShell」上完全合法的写法——Split-Path -LiteralPath <路径> -Parent——但在目标机器上的旧版本 Windows PowerShell 里,这两个参数不在同一个参数集,不能同时给。

真正值得记下来的不是这个具体写法,而是我为什么没提前发现:我在本机的新版本上做了一次语法解析检查,结果显示通过,于是就放心了。「解析通过」不等于「在目标运行时能跑」——两者的参数集定义不一样。给远端写脚本时,语法检查必须在远端那个解释器上做。改法很简单:换成访问目录对象的父路径属性,两个版本都支持。

还有一点值得庆幸:这次失败是干净的。因为第 1 步和第 5 步(停守护、拉回服务)写在了 try/finally 里,脚本在守卫阶段就挂掉之后,finally 照常执行并确认「服务已经在跑,不要再起第二个」。事后核对:服务没受影响、守护任务仍是启用状态、可用空间一个字节没变、213 个目录原封不动。一个会在出错时把自己收拾干净的脚本,比一个从不出错的脚本更有价值。

六、怎么确认「真的修好了」

改完不验证,等于没改。「服务能起来」是不够的,这次的验收集是:

  • 服务健康检查接口返回 200;
  • 抓取目标 25/25 全绿(不是「大部分绿」);
  • 重启后的日志里,新增 0 行之前那两类删除失败报错;
  • 保留的 25 个目录一个没少,判据之外的目录 0 个被动过;
  • 磁盘可用空间与使用率回到阈值以内,并且告警真的从监控和告警管理器里消失了(不是只在指标里变好);
  • 下游依赖(Grafana、采集器)仍然正常;
  • 隧道侧所有监听端口数量不变——证明这次操作没有波及无关服务;
  • 守护任务回到启用状态,且这类一次性任务已经从计划任务里注销掉。

七、小结

这次最值钱的一条经验不是某个具体命令,而是一个判断:当「自动清理」连续多日报告「清理了 0 个」时,它不是在正常工作,而是在告诉你这件事根本没人做。失败计数一直在涨,只是没有人看它。同样地,备份任务没有报错不等于有备份,端口拒绝连接比超时更有信息量,几百条规律整齐的「认证失败」可能是自己人。

以及那个 12 GB:它会在两三周后重新长回来。Prometheus 的保留策略决定了它必然持续产生被判死的块,而 Windows 的内存映射语义决定了这些块在服务运行期间删不掉。所以这不是一次性的维修,而是一个需要定期执行的维护动作——既然如此,就把它做成一个可重跑的脚本、写清判据和前置条件,而不是每次重新摸索一遍。


本文数据来自 2026-10-08 的一次内网巡检。为安全起见,文中所有地址、端口、主机名、路径与账号信息均已隐去或替换为占位符。