运维实战:6 台机器全链路巡检,和那个删不掉的 12 GB
手里这套自建环境一共 6 台机器:一台公网小 VPS、两台内网 Windows、一台内网 Linux、一台 macOS,还有一台专职跑备份的虚拟机。平时各跑各的,只有出事的时候才想起它们之间是用隧道连起来的。这次花了一个下午做了一遍纯只读的全链路巡检,顺手清掉了一个藏了半个月的坑——一块 40 GB 的盘眼看要满,而占掉 12 GB 的是服务自己删不掉的垃圾。
一、先看全貌
| 角色 | 规模 | 主要负载 |
|---|---|---|
| 公网入口 VPS | 1 核 2 GB,单卷 40 GB | nginx 前门、隧道服务端、Prometheus + Grafana 监控栈、采集器 |
| 内网 Windows 工作站 | 双推理卡,128 GB 内存 | 这个博客本体(Apache + PHP + MySQL)、本地模型推理、Hyper-V 里跑着两台子机 |
| 内网 Linux | 8 GB 内存 | 以 Docker 为主:告警中继、探针、若干自用工具站 |
| macOS | Apple Silicon | Xcode 构建、屏幕共享(未对公网开放) |
| 备份 VM | 挂了 4 TB 数据盘 | 手机增量备份 |
| 搜索 VM | 2 GB 内存 | 自建元搜索引擎,只绑回环 |
它们之间靠隧道串成一个网:公网 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
清不掉的原因和上面一样:只要服务在跑,那些文件就删不掉。清理脚本本身没有任何问题,它只是在一个不可能成功的时刻反复尝试。回顾历史日志,它偶尔真的成功过几次,那是因为碰巧服务自己释放了那几个块的映射——这是运气,不是机制。
结论:这个坑的解法只有一个方向——停下来,删掉,再起来。清理任务留作兜底可以,但别指望它当主力。
回收配方
- 先停掉监控守护任务。这一步最容易被忽略,但它是必须的:守护任务每分钟探一次服务端口,发现没在监听就会把服务重新拉起来,而那个启动脚本没有单实例保护。不放倒它,清理跑到一半服务就被拉回来了——删除重新开始失败,甚至可能出现两个实例抢同一份数据目录。
- 确认要停的进程身份:按进程名、PID 和命令行三者一起核对,不按命令行片段模糊匹配(这种匹配有可能命中你自己正在执行的命令)。
- 只删符合判据的目录:位置必须直接位于数据目录之下,且名字必须是「26 位 ID +
.tmp-for-deletion」。先干跑统计,确认没有误伤。 - 把服务拉回来,等它健康检查通过。
- 恢复监控守护任务,并把第 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 的一次内网巡检。为安全起见,文中所有地址、端口、主机名、路径与账号信息均已隐去或替换为占位符。