用 Python 清理旧日志:先预览、再删除,跳过链接与子目录
日志归档目录越积越大,直接写一条删除命令,又担心把正在使用的日志或软链接目标删掉。本文面向初学者到中级读者,用 Python 标准库做一个默认只预览、显式 --apply 才删除的小工具,先在隔离样例上验证,再用于自己管理的归档目录。
这里清理的是已写完、已确认不再使用的独立 .log 文件,不是活动日志轮转。如果还不知道哪些文件占空间,先看 Python 大文件排行;不要把教程命令直接指向业务日志目录。
1. 方法:明确范围,再计算保留期限
- 只扫描指定目录的直接子项,不递归;只匹配区分大小写的
.log后缀,跳过隐藏名称、目录、软链接和多硬链接文件。 - 启动时固定一次当前时间,以 1 天 = 86400 秒计算截止点;只有
mtime严格早于截止点的文件才入选。它不是“早于某个本地日历日期”,也不是按创建时间判断。 - 先完成扫描,再预览或执行。
--days必填,范围 1–36500;没有零天清理或默认删除。 - 删除前重新读取元数据,发现文件身份或状态变化就停止;执行成功的每个文件分别记录,最后输出汇总。
Python os.scandir 文档介绍了目录项扫描;entry.stat(follow_symlinks=False)读取链接本身的信息,代码用普通文件类型和链接数继续筛选。扫描只读元数据,不加载日志正文,但候选清单会占用与入选数量相关的内存。
**使用边界:**目录及其父目录必须可信,扫描和删除期间停止写入、轮转、移动和替换文件的任务。二次检查不是原子操作,仍存在检查与删除之间的竞态,不能用于其他用户可随意修改的目录。删除不可撤销;真实使用前先完成独立备份,预览报告不是备份。
2. 准备:创建可丢弃的样例
需要已有的 Python 3.11 或更新版本,无第三方依赖。示例在 Linux、Python 3.11.4 上验证,命令适用于 bash 和 zsh。新建目录后,后续操作保持在同一个终端、同一个目录;重复实验时换一个目录名。
- 创建 9 天前的日志、新日志、普通文本、软链接和一个名称以
.log结尾的目录:
python3 --version
mkdir old-logs-demo
cd old-logs-demo
mkdir logs
python3 - <<'PY_DATA'
import os
from pathlib import Path
import time
now = time.time_ns()
old = now - 9 * 86400 * 1_000_000_000
for name in ("old.log", "fresh.log", "keep.txt"):
path = Path("logs") / name
path.write_bytes(b"old\n")
stamp = now if name == "fresh.log" else old
os.utime(path, ns=(stamp, stamp))
Path("logs/link.log").symlink_to("keep.txt")
Path("logs/broken.log").symlink_to("absent")
Path("logs/nested.log").mkdir()
PY_DATA
- 将下一节保存为
cleanup_logs.py,也可下载 完整清理脚本。不要在当前目录里创建叫stat.py、json.py或time.py的文件,以免遮蔽标准库。
3. 实现:预览与执行使用同一套筛选规则
"""Preview or delete old completed .log files in one trusted directory."""
import argparse
import json
import os
from pathlib import Path
import stat
import sys
import time
DAY_NS = 86400 * 1_000_000_000
def positive_days(text):
try:
value = int(text)
except ValueError:
raise argparse.ArgumentTypeError("days must be an integer") from None
if not 1 <= value <= 36500:
raise argparse.ArgumentTypeError("days must be 1..36500")
return value
def signature(info):
return (info.st_dev, info.st_ino, info.st_size,
info.st_mtime_ns, info.st_ctime_ns, info.st_nlink)
def select_logs(root, days, now_ns):
cutoff = now_ns - days * DAY_NS
selected = []
with os.scandir(root) as entries:
for entry in entries:
if entry.name.startswith(".") or not entry.name.endswith(".log"):
continue
info = entry.stat(follow_symlinks=False)
if (stat.S_ISREG(info.st_mode) and info.st_nlink == 1
and info.st_mtime_ns < cutoff):
selected.append((root / entry.name, info))
return sorted(selected, key=lambda item: item[0].name)
def emit(record):
print(json.dumps(record, ensure_ascii=True), flush=True)
def apply_one(path, previous):
current = path.lstat()
if (not stat.S_ISREG(current.st_mode)
or signature(current) != signature(previous)):
raise ValueError(f"changed since scan: {path.name!a}")
path.unlink()
def main():
parser = argparse.ArgumentParser(description=__doc__, allow_abbrev=False)
parser.add_argument("directory", type=Path)
parser.add_argument("--days", required=True, type=positive_days)
parser.add_argument("--apply", action="store_true",
help="delete selected files; default only previews")
args = parser.parse_args()
deleted = 0
try:
root = args.directory.expanduser().resolve(strict=True)
if not root.is_dir():
raise ValueError("directory must be a directory")
selected = select_logs(root, args.days, time.time_ns())
for path, info in selected:
if args.apply:
apply_one(path, info)
deleted += 1
emit({"action": "deleted" if args.apply else "would_delete",
"name": path.name, "bytes": info.st_size})
emit({"mode": "apply" if args.apply else "preview",
"selected": len(selected), "deleted": deleted})
return 0
except (OSError, ValueError) as exc:
print(f"ERROR: deleted={deleted}; {str(exc)!a}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())
time.time_ns()提供整数形式的系统时间,适合与文件修改时间比较;它不是单调时钟,仍需保证主机时钟正确。未来时间戳不会入选,不能靠这个工具修复时钟偏差。
Path.unlink()移除文件名,不会把文件送进回收站。本工具会在扫描出错时停止且不开始删除;执行阶段若中途失败,之前已经删除的文件不会自动恢复。
4. 验证:预览没有修改原文件
- 显示帮助,把预览报告写到
logs/外面,再断言内容:
python3 cleanup_logs.py --help
python3 cleanup_logs.py logs --days 7 > preview.jsonl
cat preview.jsonl
python3 - <<'PY_CHECK'
import json
from pathlib import Path
rows = [json.loads(line) for line in Path("preview.jsonl").read_text().splitlines()]
assert rows == [
{"action": "would_delete", "name": "old.log", "bytes": 4},
{"mode": "preview", "selected": 1, "deleted": 0},
]
assert Path("logs/old.log").read_bytes() == b"old\n"
print("OK: preview changed no log files")
PY_CHECK
cat preview.jsonl 应输出:
{"action": "would_delete", "name": "old.log", "bytes": 4}
{"mode": "preview", "selected": 1, "deleted": 0}
最后的断言输出 OK: preview changed no log files。bytes 是文件的逻辑长度,不是承诺可释放的磁盘空间;复制保留的旧时间戳也会影响筛选,执行前要核对文件用途。
- 仔细检查名称与目录后,只对这个可丢弃样例启用删除:
python3 cleanup_logs.py logs --days 7 --apply > applied.jsonl
cat applied.jsonl
python3 - <<'PY_CHECK'
import json
from pathlib import Path
rows = [json.loads(line) for line in Path("applied.jsonl").read_text().splitlines()]
assert rows == [
{"action": "deleted", "name": "old.log", "bytes": 4},
{"mode": "apply", "selected": 1, "deleted": 1},
]
assert not Path("logs/old.log").exists()
for name in ("fresh.log", "keep.txt"):
assert (Path("logs") / name).read_bytes() == b"old\n"
assert Path("logs/link.log").is_symlink()
assert Path("logs/broken.log").is_symlink()
assert Path("logs/nested.log").is_dir()
print("OK: only old.log was deleted")
PY_CHECK
python3 cleanup_logs.py logs --days 7 --apply
验证脚本输出 OK: only old.log was deleted。最后一次重复运行应只输出:
{"mode": "apply", "selected": 0, "deleted": 0}
5. 自动化检查:退出码与边界测试
退出码 0 表示本次扫描及所选操作完成,空清单也是成功;1 表示文件系统或状态检查失败;2 表示参数错误。执行失败时,stderr 中的 deleted=N 表示本次已经完成的删除数量,不表示事务回滚。
先把预览作为检查步骤,不要把“成功生成报告”直接当成“允许删除”:
if python3 cleanup_logs.py logs --days 7 > next-preview.jsonl; then
printf 'Preview complete; review next-preview.jsonl before any deletion.\n'
else
printf 'Preview failed; do not continue.\n' >&2
fi
下载 隔离测试脚本,和 cleanup_logs.py 放在同一目录,运行:
python3 test_cleanup.py
测试只创建和清理自己的临时目录。7 项测试覆盖预览、执行、重复执行、截止点相等、未来时间、隐藏文件、大小写后缀、目录、软链接、硬链接、参数错误、文件变化、部分删除后失败和模拟权限失败;同时断言退出码、输出与保留文件内容。模拟失败不等于验证过每种真实文件系统权限组合。
6. 排错:别靠放宽规则解决问题
| 现象 | 原因与处理 |
|---|---|
| 预览为零 | 检查是否为直接子项、后缀是否恰好为 .log,以及最后修改时间;.log.1 和 .log.gz 本来就不匹配。 |
changed since scan |
立即停止;找到写入或轮转任务并暂停,然后重新预览,不要绕过二次检查。 |
Permission denied |
核对目录所有者及父目录搜索、目标目录写入权限;不要直接改成 sudo 删除。 |
| 删除后磁盘空间没明显增加 | 检查打开的文件描述符、其他硬链接及文件系统计量,参考 Linux 删除后空间不释放排查。 |
| 想限制活动日志增长 | 改用 Python 日志轮转,不要用修改时间猜测文件是否已经关闭。 |
7. 总结
把清理范围、时间规则和执行开关写清楚,先在独立样例里证明预览不改文件、执行只处理入选文件。真实归档目录必须先备份并停止并发修改,再逐项审查预览。这个工具不提供回收站、事务恢复或活动日志轮转。
- 原文作者:春江暮客
- 原文链接:https://www.bobobk.com/python-old-logs-dry-run-cleanup.html
- 版权声明:本作品采用 知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议 进行许可,非商业转载请注明出处(作者,原文链接),商业转载请联系作者获得授权。