tuowei2这个代号,第一次听的人都会问一句:啥意思?其实它是我本地维护的一套服务器磁盘巡检与日志清理工具,tuowei是“拓位”的拼音,第二版。工具本身不复杂,但迭代到这个版本的过程中踩了不少值得记录的坑,也让我把“能跑的脚本”和“扛得住的生产级工具”之间的差距彻底想明白了。
这套东西解决的是所有后端都会遇到的痛:日志文件只增不减,磁盘总有写满的一天。tuowei2会自动扫描各个目录的占用,按策略清理过期日志,生成报表,并在磁盘水位超过阈值时发出告警。如果你是那种被磁盘告警在凌晨叫醒的人,或者你的服务经常因为日志把磁盘写满而卡死,这篇文章应该能帮上忙。我会把tuowei2从设计思路、核心代码、上线前后踩坑的完整过程摊开来说,既有可以直接抄走的片段,也有只有跑过生产环境才懂的细节。
1. 两个版本的分水岭:v1从能用到难用的全过程
1.1 一切的起点:凌晨两点的磁盘告警
做v1的动机非常朴素。有一年春天,我维护的一个运营后台开始频繁打磁盘告警,查下来是业务日志和Nginx access log疯涨,单台机器每天新增几个GB,根分区只剩不到10%。当时图省事,我写了大概50行shell脚本:find指定目录按mtime删除超过7天的日志,再用du统计剩余空间,如果超过80%就给运维群发一条curl通知。脚本丢进crontab里,每天凌晨1点跑一次。
当时觉得这事已经“解决”了。但一个工具能跑和能用是两码事,v1坚持了不到半年,就暴露出一堆只在生产环境才显现的问题。最典型的场景是:你明明每天在删日志,磁盘占用却还在涨,因为业务临时文件、导出文件、备份文件都不在清理规则里,它们会悄悄把磁盘吃满。
1.2 v1的三条致命短板
第一个短板是清理策略写死在代码里。目录列表、保留天数、触发阈值全是一堆变量塞在脚本顶部,每次要调整都得ssh登录服务器改脚本,改完还要手工重跑一遍验证。岗位交接的时候,新同事看那脚本一头雾水,注释只有一行:“删除7天前的日志”。
第二个短板是没有任何前置检查。有一次日志目录下出现了大量临时导出文件,占了好几十GB,但它们不是日志,mtime只有3天,find命令根本不碰它们。结果磁盘还是飙到95%,服务开始报no space left on device,用户那边直接看到白屏。事后排查才发现,清理逻辑只认文件年龄,根本不看磁盘真实水位,也不看目录里到底堆了什么。
第三个短板是误删风险不可控。find + rm的组合在路径写错的时候非常致命,尤其当变量拼接路径时如果某个目录为空字符串,rm -rf可能会落在意想不到的位置。虽然v1没有真正删错东西,但那种“不知道自己会不会删错”的感觉,比磁盘告警还让人焦虑。每天凌晨cron跑完之后,我都要爬起来瞄一眼日志,确认没有把不该删的东西删掉。
1.3 为什么选择重写而不是接着打补丁
其实在v1后期,我已经往里面塞了各种补丁:加了磁盘水位预判、加了排除目录、加了日志输出。但每次改完都觉得脚本越来越像一堆临时方案叠在一起。当时给了我自己两个选择:继续在shell脚本上打补丁,或者用Python重写一版,把配置、执行、报表、告警拆开。
我选了重写,理由有三条。第一,shell做简单的find和rm够用,但要处理遍历性能、并发锁、幂等重放、结构化报表,成本会越来越高,Python在这些方面有现成的标准库。第二,配置外置是刚需,我不希望下一次调整策略还要进服务器改脚本。第三,v1最欠缺的不是功能,而是可观测性——跑完之后发生了什么、删了什么、还剩多少,这些信息必须能追溯,而这正是shell脚本最薄弱的地方。工具一旦不可观测,出了问题就只能靠猜。
2. tuowei2的核心设计:从脚本思维到任务编排思维
2.1 先把职责拆清楚:四个模块各管一段
tuowei2的整体结构非常简单,简单到再复杂的场景也是围绕同一套骨架扩展:
- collector:扫描指定目录,统计占用,产出结构化数据。
- cleaner:基于策略执行清理,但所有删除操作前先进入“演练模式”。
- reporter:把扫描和清理结果渲染成报表。
- notifier:通过Webhook把报表和告警发出去。
四个模块通过一个主流程串起来,各自只依赖配置,不互相调用内部函数。这样做的直接好处是:想单独跑一遍扫描不清理,或者只重发一次报表,都不需要动逻辑,命令行指定模块即可。我曾经把collector单独接进一个数据看板,让它每10分钟跑一次,清理还是按天跑,两个流程互不干扰。这种“模块边界清晰”带来的自由度,是v1那种一个脚本从头跑到尾的结构没法给的。
2.2 配置外置:改策略不用碰代码
我选择用YAML做配置,因为它的可读性对不写代码的人(比如接班的同事)最友好。配置片段大致是这样:
scan: - path: /var/log/app name: app_logs clean: enabled: true max_age_days: 7 max_dir_size_mb: 4096 - path: /data/export name: exports clean: enabled: false global: disk_threshold_percent: 80 dry_run: true pid_lock: /var/run/tuowei2.pid timezone: Asia/Shanghai关键点是:每条扫描路径都可以独立设置保留天数、大小上限、是否参与删除。比如导出目录只统计不删除,应用日志既按天数又按大小双条件清理。所有策略改动都只发生在YAML里,脚本本身不碰业务逻辑。之前v1要改一个阈值需要进服务器操作,现在只需要在一个公共的配置文件里改值,然后reload一下就生效,这对我这种同时维护多台机器的人来说,体验是质的飞跃。
2.3 任务状态机与失败重试
主流程我设计成一个小状态机:pending、scanning、cleaning、reporting、done,以及两个失败态cleaning_failed和reporting_failed。整个流程是顺序的,但clean失败不会让report和notify中断——宁可先把问题暴露出来,也不能让一次清理故障吞掉告警信息。这句话说起来容易,实际设计时我反复纠结过:cleaning失败之后到底要不要继续report?后来想明白了,必须继续,因为report里会带上cleaning失败的明细,这本身就是最有价值的告警内容。
重试只针对notifier。Webhook偶发超时很常见,所以发送带三次重试,每次间隔指数退避。扫描和清理不自动重试,因为重复扫描代价高、重复清理可能出问题,这类任务应该让人去看日志,而不是让机器反复尝试。如果你在做类似的运维工具,我建议你也遵循这个原则:凡是有副作用的操作,失败了一定要停下来等人处理,不要自作主张重试。
2.4 幂等设计:重复执行也要安全
这是我这次重写最看重的一点。v1最大的心理负担就是手误重跑一遍会不会出问题。tuowei2对每个清理动作都做记录:删除前先把目标文件的path、size、mtime写入历史库(一个本地SQLite文件),再执行删除。万一action日志和实际删除不同步,也能通过SQLite完整回溯。
加上dry_run参数,默认首次部署时强制开启。dry_run模式下collector完整执行,cleaner只打印“将要删除”清单,不真正删文件。确认清单没问题之后,把dry_run改成false跑一遍,再检查一次实际结果,整个过程都是可回滚的。这套逻辑说白了就是把“手抖重跑”从“事故”变成“无害操作”,因为每次删除都有记录,重复执行时能通过历史库判断这个文件是不是已经处理过了,已经处理过的就直接跳过。
3. 三个直接可抄的代码模块:扫描、清理、告警
3.1 模块一:目录占用扫描器
真正扫描的时候,用os.scandir比os.walk和pathlib.rglob要更稳,因为rglob会先构建整个目录树,遇到海量小文件时内存飙升;os.scandir是边遍历边统计,而且可以follow_symlinks=False安全跳过软链接。我第一版用的是rglob,在单目录十万个文件的情况下内存占用涨了快一个GB,后来改成os.scandir才降下来。
# collector.py 目录占用扫描(核心简化版) import os def scan_dir_size(root: str, max_depth: int = 5) -> dict: """递归统计目录占用,超过max_depth不再下探。""" result = {"path": root, "size": 0, "child": []} try: with os.scandir(root) as it: for entry in it: if entry.is_symlink(): continue if entry.is_file(follow_symlinks=False): try: result["size"] += entry.stat(follow_symlinks=False).st_size except OSError: continue elif entry.is_dir(follow_symlinks=False): if max_depth > 0: sub = scan_dir_size(entry.path, max_depth - 1) result["size"] += sub["size"] result["child"].append(sub) except PermissionError: pass return result两点说明:一是max_depth必须有限制,否则即使跳过软链接,遇到嵌套很深的目录也会白白消耗大量时间;二是PermissionError要吞掉并继续,很多系统目录对普通用户就是只读的,工具没必要因为扫不到某个目录就整体失败。扫描结果里的size单位是字节,后面报表里统一转成GB或MB展示。
3.2 模块二:带安全护栏的清理器
清理器的核心不是“能删文件”,而是“知道哪些能删哪些不能删”。我把它拆成三层检查:
# cleaner.py 清理动作的安全检查链路(核心简化版) from pathlib import Path def safe_delete(path: Path, allowed_roots: set) -> bool: # 第一道护栏:软链接直接拒绝 if path.is_symlink(): return False # 第二道护栏:目录必须落在允许清理的根路径之下 try: abs_path = path.resolve() except OSError: return False if not any(abs_path.is_relative_to(Path(root)) for root in allowed_roots): return False return True这里allowed_roots来自配置里clean.enabled的目录,本质上是一张白名单。任何不在白名单内的路径,即使因为配置写错了被传给清理器,也会被挡下来。另外还有一个容易被忽略的细节:resolve()之后的路径是绝对路径,配置里的路径最好也统一转成绝对路径再比较,不然“相对路径能不能删”这种问题会让你debug到怀疑人生。
清理策略部分,我同时支持“保留天数”和“目录大小上限”。当天数条件命中但目录总大小仍然很大时,按文件mtime从旧到新继续删,直到目录大小降到目标值以下。这条逻辑用一句话概括:先保证数据不过期,再保证磁盘不爆满。实际执行时,我会先按mtime排序取最旧的一批文件,模拟删除看会释放多少空间,如果不够再扩大范围,避免一次性删太多影响业务侧的数据追溯需求。
3.3 模块三:报表生成与告警触发
报表我直接生成Markdown格式的表格,因为运维群普遍支持Markdown渲染,贴进去就能看:
# report.py 报表生成(核心逻辑示意) def render_inventory(data: dict) -> str: rows = [] rows.append("| 目录 | 占用大小 | 删除文件数 | 释放空间 |") rows.append("| --- | --- | --- | --- |") for item in data: rows.append(f"| {item['path']} | {item['size']} | {item['deleted']} | {item['released']} |") return "\n".join(rows)告警触发条件是全局磁盘使用率超过阈值,或者某一个被标记为critical的目录剩余空间不足。通知走Webhook,发送函数带超时和重试,我在使用中给Webhook加了5秒超时,避免notifier线程卡死影响主流程。这里有个经验之谈:告警文案一定要带上这台机器的主机名和具体目录路径,否则收到告警的人还要去查“是哪台机器在报警”,这一步非常影响响应速度。
4. 上线前的应激测试与三个坑的完整排查链路
4.1 坑一:软链接目录差点清空根分区
第一次在预发布环境做演练时,我把/var/log挂进扫描列表,然后发现扫描结果里出现了一个异常大的目录:/var/log/oldlogs。我第一反应是“业务还留了这么一大坨日志?”,正准备把清理策略加上去,结果同事提醒我看看那是不是软链接。
一查,果然是:/var/log/oldlogs -> /。也就是说扫描器如果没跳过软链接,会把整个根文件系统的内容都算进/var/log/oldlogs的占用里;如果当时我把清理策略误加到这条路径上,后果就是cleaner认为根目录下所有文件都“过期”了,整个删下去,运维事故直接上演。这就是为什么我说一定要用独立的环境先跑演练,这种级别的错误在预发布环境暴露出来是幸运,暴露在生产环境就是事故。
排查链路其实不算复杂,但暴露了一个设计缺口:我在collector里跳过了软链接,但在cleaner的第一版里没有对软链接做防御。也就是说,即使collector不统计软链接目录,如果有人在配置里手动加了一条指向软链接的路径,cleaner照样会傻乎乎地执行删除。修复就是在safe_delete里加第一道护栏:path.is_symlink()直接拒绝,任何情况下都不允许删除软链接本身,也不允许跟随软链接去删目标。
4.2 坑二:清理任务互相打架引发的文件竞争
上线一段时间后,我注意到一个奇怪现象:偶尔会有几条告警说“清理任务异常退出”,但看日志却找不到明显的报错。后来我把cron的执行时间拉长来看,发现是任务运行时超过了预期,下一个周期的任务又启动了,两个进程同时扫同一个目录,同时去删同一批文件,结果一个进程删除成功后,另一个进程在stat文件时拿到了None,直接抛了异常。
这个坑的本质是任务没有防重入。修复方式非常传统但非常有效:文件锁。我在启动时用fcntl.flock拿一个排他锁,如果锁拿不到,说明已经有一个tuowei2在跑,直接退出并输出“previous instance still running”。
# lock.py 单实例锁(Linux环境) import fcntl class SingleInstanceLock: def __init__(self, lock_path): self.lock_file = open(lock_path, "w") try: fcntl.flock(self.lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB) self.acquired = True except OSError: self.acquired = False def release(self): if self.acquired: fcntl.flock(self.lock_file, fcntl.LOCK_UN) self.lock_file.close()从此之后,cron纵使配置成每5分钟跑一次,也不会出现两个实例互相踩踏的情况。这个坑也让我意识到:任何定时任务,只要执行时间不稳定,就必须假设它会和下一个实例重叠,防重入不是可选项,是必选项。
4.3 坑三:报表时间凭空少了8小时
有段时间群里收到的报表,完成时间总是显示的凌晨,可cron明明配的上午9点跑。第一反应是cron时区变了,查了一圈发现系统时区根本没问题。后来才想起,Python的datetime.now()返回的是系统本地时间,而系统本地时间是UTC,报表渲染又没做时区转换,所以显示的是UTC时间,反映过来就是比预期少了8小时。
修复方式不复杂,但能体现工具的成熟度:配置里加了一个timezone字段,所有时间字符串渲染和日志记录统一走zoneinfo,不用默认的本地时区。并且在报表末尾附带一行“时区:Asia/Shanghai”,杜绝以后再出现类似误判。如果你也在这类工具里格式化时间,我建议直接把时间统一成带时区的ISO格式,比如2025-05-21T09:30:00+08:00,这样无论谁看到都不会产生歧义。
4.4 止血与自检清单
踩完这三个坑,我把“上线前自检清单”固化到了项目README里:
- dry_run模式必须开启,至少完整跑三天演练,看清理清单是否符合预期。
- 把所有配置路径过一遍safe_delete白名单,确认没有软链接目录进入可删除集合。
- 人工触发两次并发实例,确认单实例锁生效。
- 检查报表中的时间字样是否带时区说明。
- 在测试环境故意放一个超大文件,验证大小阈值清理确实能把目录压到目标水位。
这条清单后来成了我部署到其他机器的标准操作流程。有了它,新环境的上线时间从半天压缩到半小时。为了更直观,我把三个核心问题的排查结果整理成了下表:
| 坑 | 根因 | 修复 |
|---|---|---|
| 软链接目录误入清理范围 | cleaner缺少软链接防御 | safe_delete首道护栏is_symlink拒绝 |
| 并发实例互相踩踏 | 缺少单实例锁 | fcntl.flock排他锁 |
| 报表时间偏差8小时 | 使用默认UTC本地时间 | 配置timezone字段统一渲染 |
5. 运行半年后的效果复盘与下一步改造方向
5.1 半年来的实际运行数据
我给tuowei2做了简单的统计:部署覆盖了6台服务器,平均每天清理掉大约20GB的过期日志,磁盘使用率从长期90%以上回落到60%-70%波动。最直观的变化是磁盘告警从以前每周至少一次,变成了半年几乎没有误报级别的告警,再也不用半夜爬起来处理“磁盘满了”这种问题。
人工介入次数这个指标更能说明问题:v1时代差不多每个月都要登服务器看一次日志,确认脚本有没有异常;tuowei2上线后,我只需要每周看一眼群里的报表,确认数值在合理区间内。有一次业务方做活动,日志量翻了3倍,tuowei2自动把清理量也翻了3倍,磁盘水位稳定在75%以下,全程没有人工干预。对我来说,这就是一个自动化工具该有的状态——平时感觉不到它的存在,但它在默默兜底。
5.2 哪些设计经受住了考验
回看这半年,有三件事被证明是对的。
第一是白名单护栏。即便有一次我在新服务器上配置路径时手滑写错了根目录,safe_delete依然把所有删除请求挡下了,只报了“路径不在白名单”的警告,没有造成任何损失。那一刻我非常庆幸自己把护栏放在cleaner的最前面,而不是依赖外部调用方的自觉。
第二是dry_run机制。它不只是第一次上线的保险,后来每次调整保留天数或新增扫描路径,我都会先开着dry_run跑一天,看清单和预期一致再切正式模式。这个习惯帮我避开了至少三次配置错误,其中一次是把保留天数从30改成3,dry_run跑出来的清单显示会一次性删掉半个月的日志,我赶紧把配置改了回去。
第三是SQLite历史记录。有一次同事问“上周清理了多少文件”,我直接查库给出一份精确清单,他当时还挺意外。操作的可追溯性,在多人协作环境里比其他所有特性都值钱。工具可以出错,但不能让人查不到是哪里出的错。
5.3 下一步:从清理工具走向容量预测
tuowei2目前的定位还是一个“事后处理”工具——等到磁盘快满了再去清。但日志增长是有规律可循的,我接下来想给collector加一个简单的时间序列分析:记录每个目录每天的增量,用滑动平均预测未来N天的占用,当预测值超过阈值时提前告警,把“等它满了再清”变成“提前规划容量”。
另外一个是清理策略的细化:现在的双条件(天数+大小)对大多数场景够用,但有些业务文件要求按版本保留最新几个,有些要求按月份保留,这需要引入更灵活的文件保留模式。我计划在配置里给cleaner增加一个rule字段,按glob模式做更细粒度的匹配。比如data/backup/*.tar.gz这条规则可以配置成“保留最近3个版本”,而不是单纯按天数切。刚才提到的SQLite历史记录,也可以顺势扩展成趋势分析的底表,这样每个目录的占用变化就变成可查询的历史序列了。
这些改造不一定都落地,但tuowei2这个项目让我深刻意识到一件事:很多运维问题不是靠一个绝妙的脚本解决的,而是靠一次次踩坑、一版版迭代,把防御性的逻辑一层层加进去才变稳的。我现在写完这类工具的代码,第一件事永远不是测业务逻辑通不通,而是先想“它出错的时候会怎样”——出错时是否安全、是否可控、是否能被快速定位。工具真正成熟的标准不是功能多,而是出错时它的反应依然可控。