磁盘满是最经典的运维事故:涨得慢、没人盯、爆发时整个服务跟着殉葬(日志写不进去、数据库落不了盘)。01 篇的监控负责「发现磁盘快满了」,这一篇负责「安全地清理」——重点是那个词:安全。删除操作没有后悔药,所以本篇的铁律是:先 dry-run 出报告,确认无误再动手。
一、先回答「空间去哪儿了」:写个 du 等价物
清理之前得先定位。Linux 上有 du,Windows 用户则常常两手一摊。自己写一个只要十行:
from pathlib import Path
ROOT = Path(__file__).parent.parent
def dir_size(path: Path) -> int:
return sum(f.stat().st_size for f in path.rglob("*") if f.is_file())
if __name__ == "__main__":
entries = []
for child in sorted(ROOT.iterdir()):
if child.is_dir():
entries.append((child.name + "/", dir_size(child)))
else:
entries.append((child.name, child.stat().st_size))
total = sum(size for _, size in entries)
print(f"项目根目录: {ROOT.name} 总计 {total / 1024**2:.1f} MB\n")
for name, size in sorted(entries, key=lambda x: -x[1]):
if size == 0:
continue
bar = "█" * max(1, round(size / total * 30))
print(f" {name:<26} {size / 1024**2:8.2f} MB {bar}")
真实运行输出(就是我写这个系列的工作目录):
项目根目录: 新项目 (3) 总计 53.5 MB
demos/ 53.46 MB ██████████████████████████████
logs/ 0.02 MB █
python-ops-monitoring-tutorial.md 0.02 MB █
python-ops-series-00-index.md 0.01 MB █
monitor.py 0.01 MB █
.zwork/ 0.00 MB █
tests/ 0.00 MB █
.pytest_cache/ 0.00 MB █
pytest.ini 0.00 MB █
demos/ 一家占了 53.46 MB——因为 03 篇的演示马上要在里面造一批「过期垃圾文件」。谁占空间,一目了然;把这个脚本指向 C:\Users 或 /var/log,它就是你的空间排查起点。
二、清理规则:什么叫「垃圾文件」
拍脑袋删文件是事故之源,规则必须写清楚且可解释。本篇的规则:
后缀命中
{.tmp, .bak, .old},且修改时间早于 30 天前,才算垃圾。
两条缺一不可:只有后缀,刚下载的 .tmp 安装包会误伤;只有时间,数据库的 .sql 备份根本不该碰。规则越保守越好——垃圾文件晚删一周没损失,正常文件误删一次就够你加班到天亮。
三、架构关键:扫描器与删除器分离
┌──────────┐ ┌─────────────┐ ┌──────────┐
│ 扫描器 │ ───→ │ dry-run 报告 │ ───→ │ 删除器 │
│ 只读不删 │ │ 人先看一眼 │ │ 只删报告里的│
└──────────┘ └─────────────┘ └──────────┘
扫描器只返回「命中列表」,删除器只处理「列表里的文件」。这个分离带来两个好处:
- dry-run 天然免费:只跑扫描器就是完整预演;
- 可测试:给 09 篇的测试章节留了口子——扫描逻辑是纯函数,不碰真文件就能测。
四、完整代码(demos/disk_cleanup_demo.py)
import os
import time
from datetime import datetime
from pathlib import Path
SANDBOX = Path(__file__).parent / "fixtures" / "junkyard"
NOW = time.time()
DAYS_30 = 30 * 86400
JUNK_SUFFIXES = {".tmp", ".bak", ".old"}
def make_sandbox() -> None:
"""造一个沙箱:正常文件 + 过期垃圾混在一起。"""
SANDBOX.mkdir(parents=True, exist_ok=True)
files = [
("report_final.docx", 120_000, NOW - 3 * 86400), # 3 天前,正常文件
("invoice_2026Q3.pdf", 300_000, NOW - 10 * 86400), # 10 天前,正常文件
("export_001.tmp", 8 * 1024**2, NOW - 90 * 86400), # 90 天前的垃圾
("export_002.tmp", 5 * 1024**2, NOW - 45 * 86400), # 45 天前的垃圾
("db_snapshot.bak", 40 * 1024**2, NOW - 60 * 86400), # 60 天前的垃圾
("config.yaml.old", 4_000, NOW - 120 * 86400), # 120 天前的垃圾
("fresh_download.tmp", 2_000, NOW - 3600), # 1 小时前,不算垃圾
]
for name, size, mtime in files:
p = SANDBOX / name
with p.open("wb") as f:
f.write(b"\0" * size)
os.utime(p, (mtime, mtime)) # 把修改时间拨回过去
def scan(sandbox: Path, older_than_days: int = 30) -> list[Path]:
"""返回「命中清理规则」的文件列表。只扫描,不删除。"""
cutoff = NOW - older_than_days * 86400
hits = []
for p in sandbox.rglob("*"):
if p.is_file() and p.suffix.lower() in JUNK_SUFFIXES and p.stat().st_mtime < cutoff:
hits.append(p)
return hits
if __name__ == "__main__":
make_sandbox()
print(f"沙箱目录: {SANDBOX}")
print(f"目录总占用: {sum(p.stat().st_size for p in SANDBOX.rglob('*') if p.is_file()) / 1024**2:.1f} MB\n")
hits = scan(SANDBOX)
print(f"== dry-run 清理报告(生成于 {datetime.now():%Y-%m-%d %H:%M:%S},未删除任何文件)==")
reclaim = 0
for p in sorted(hits, key=lambda x: -x.stat().st_size):
size = p.stat().st_size
age_days = (NOW - p.stat().st_mtime) / 86400
reclaim += size
print(f" [将删除] {p.name:<22} {size / 1024**2:6.1f} MB {age_days:4.0f} 天未访问")
print(f"\n共 {len(hits)} 个文件命中,可释放 {reclaim / 1024**2:.1f} MB")
keep = [p.name for p in SANDBOX.rglob("*") if p.is_file() and p not in hits]
print(f"以下文件未命中规则,保持不动: {keep}")
五、真实运行输出
沙箱目录: demos\fixtures\junkyard
目录总占用: 53.4 MB
== dry-run 清理报告(生成于 2026-09-28 20:55:51,未删除任何文件)==
[将删除] db_snapshot.bak 40.0 MB 60 天未访问
[将删除] export_001.tmp 8.0 MB 90 天未访问
[将删除] export_002.tmp 5.0 MB 45 天未访问
[将删除] config.yaml.old 0.0 MB 120 天未访问
共 4 个文件命中,可释放 53.0 MB
以下文件未命中规则,保持不动: ['fresh_download.tmp', 'invoice_2026Q3.pdf', 'report_final.docx']
验证一下规则的三个边界,全部正确:
fresh_download.tmp——后缀命中但只有 1 小时,保留 ✓report_final.docx/invoice_2026Q3.pdf——时间长但后缀不命中,保留 ✓- 4 个既旧又是垃圾后缀的文件,全部命中,共 53.0 MB ✓
注意演示到这里为止一个文件都没有删。真实删除只需在人工确认报告后加一步:
def delete(hits: list[Path]) -> None:
for p in hits:
p.unlink()
但请把它放进和 dry-run 不同的命令分支(比如 python cleanup.py --execute),默认永远是 dry-run。凡是「不删就没事,删错就完蛋」的操作,都要做成两段式。
六、三个可以直接抄走的工程细节
os.utime是测试磁盘逻辑的时光机。清理规则看的是 mtime,测试「90 天前的文件」不可能真等 90 天——用os.utime(p, (past, past))把文件时间拨回去,逻辑立刻可测。09 篇的测试会再用到这个思路。p.suffix.lower()。.TMP、.Bak在 Windows 上很常见,后缀比较永远先归一化大小写。- 报告里要有「没删什么」。只列「将删除」不够——把保留清单也打出来,人扫一眼就知道规则有没有跑偏。dry-run 报告是给人看的,完整性就是安全性。
七、往生产走:两条注意
- 别急着删,先挪:更保守的做法是「移动到回收目录,30 天后再真正删除」,一步到位变成两步走,误删还有救。
shutil.move一行就够。 - 和 06 篇联动:这个脚本被 APScheduler 每周接管后,「磁盘治理」就从人肉动作变成了背景噪音。
下一篇解决另一个半夜问题:服务进程悄悄挂了怎么办。
本篇代码:demos/dir_size_demo.py、demos/disk_cleanup_demo.py,均已在 Python 3.13.15 / Windows 真实运行,输出原样收录(dry-run 未删除任何文件)。