服务器上有些服务日志不轮转,几个月下来单个文件几百 G。写个工具定期清理,顺便记录一下过程中的取舍。
需求
其实就三条:
- 找出超过 N 天的日志文件,删掉
- 磁盘使用超过阈值时,从最老的开始删,直到降到阈值以下
- 支持 dry-run,先看看会删什么
看着简单,真写起来有不少要想清楚的地方。
第一版:直接遍历删除
func clean(dir string, days int) error {
cutoff := time.Now().AddDate(0, 0, -days)
return filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
return nil
}
if info.ModTime().Before(cutoff) {
return os.Remove(path)
}
return nil
})
}
问题立刻来了:
一、跟着符号链接跑出去了。 filepath.Walk 不会跟随符号链接(这点是安全的),但如果目录本身是链接,还是会进去。而且我没有限制边界,万一传错目录(比如 /)就麻烦了。
二、删到一半出错整个中断。 权限不够的文件会让 Walk 返回错误,后面全都不处理了。应该跳过继续。
三、只按修改时间判断不可靠。 有些日志是当前正在写入的文件,它的 mtime 是刚才,不会被删——这个是好的。但如果服务停了,文件 mtime 停在很久以前,而它又恰好是当前日志文件(比如 app.log 没有日期后缀),删了之后服务重启就写不进去了。
第三条最危险。我加了个白名单,文件名匹配"当前日志"模式的直接跳过:
var activePatterns = []*regexp.Regexp{
regexp.MustCompile(`^[a-z-]+\.log$`), // app.log
regexp.MustCompile(`\.log\.(json|txt)$`), // 其它无日期的
}
func isActiveLog(name string) bool {
for _, re := range activePatterns {
if re.MatchString(name) {
return true
}
}
return false
}
第二版:加磁盘水位控制
按时间删只是兜底,真正需要的是"磁盘满了要腾空间"。
type fileEntry struct {
path string
size int64
modTime time.Time
}
// 收集候选文件,按时间从旧到新排序
func collectCandidates(dir string, minAge time.Duration) ([]fileEntry, error) {
var files []fileEntry
err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
// 单个文件出错不影响整体
log.Printf("跳过 %s: %v", path, err)
return nil
}
if d.IsDir() || isActiveLog(d.Name()) {
return nil
}
info, err := d.Info()
if err != nil {
return nil
}
if time.Since(info.ModTime()) < minAge {
return nil
}
files = append(files, fileEntry{path, info.Size(), info.ModTime()})
return nil
})
if err != nil {
return nil, err
}
sort.Slice(files, func(i, j int) bool {
return files[i].modTime.Before(files[j].modTime)
})
return files, nil
}
然后按水位删:
func cleanUntilBelow(mount string, targetPercent float64, candidates []fileEntry) error {
var freed int64
for _, f := range candidates {
usage, err := diskUsage(mount)
if err != nil {
return err
}
if usage < targetPercent {
log.Printf("已降到 %.1f%%,停止清理(本次释放 %s)",
usage, humanSize(freed))
return nil
}
if dryRun {
log.Printf("[dry-run] 将删除 %s (%s)", f.path, humanSize(f.size))
freed += f.size
continue
}
if err := os.Remove(f.path); err != nil {
log.Printf("删除 %s 失败: %v", f.path, err)
continue
}
freed += f.size
}
return nil
}
注意这里每删一个文件就重新查一次磁盘用量。看着低效,但比"估算一下删几个够"安全得多——因为可能有别的进程同时在写。
磁盘用量怎么算
Go 标准库没有直接获取磁盘使用率的函数。syscall.Statfs 可以:
func diskUsage(path string) (float64, error) {
var st syscall.Statfs_t
if err := syscall.Statfs(path, &st); err != nil {
return 0, err
}
total := st.Blocks * uint64(st.Bsize)
free := st.Bavail * uint64(st.Bsize)
if total == 0 {
return 0, nil
}
used := total - free
return float64(used) / float64(total) * 100, nil
}
Bavail 是非 root 用户可用的块数,Bfree 是总共空闲的。用 Bavail 更贴近实际(预留块不给普通用户),但对 root 进程来说两者一样。这里我用 Bavail,保守一点。
注意这个函数在 Linux/macOS 上没问题,Windows 上 syscall.Statfs_t 不一样,得分开写。我这个工具只在 Linux 跑,就没管。
安全措施
工具涉及删除文件,我加了几层保护:
一、路径必须显式白名单。 不接收任意路径,只在配置里指定几个目录,而且检查解析后的绝对路径确实在允许列表里:
func isAllowed(path string) bool {
abs, err := filepath.Abs(path)
if err != nil {
return false
}
for _, root := range allowedRoots {
rootAbs, _ := filepath.Abs(root)
// 用 filepath.Rel 判断包含关系,避免 /data/logs2 被 /data/logs 匹配
if rel, err := filepath.Rel(rootAbs, abs); err == nil {
if !strings.HasPrefix(rel, "..") && !filepath.IsAbs(rel) {
return true
}
}
}
return false
}
二、扩展名白名单。 只删 .log、.log.gz、.log.1 这类,别的东西一律不碰。
三、--dry-run 默认开启。 不显式加 --execute 就只打印不删。这个设计救过我一次——第一次跑发现规则写错了,差点删掉不该删的。
四、删之前把清单写到文件。 便于事后追溯。
定时执行
用 systemd timer 比 cron 好一点,日志统一进 journal:
# /etc/systemd/system/logclean.timer
[Unit]
Description=日志清理
[Timer]
OnCalendar=*-*-* 04:30:00
Persistent=true
[Install]
WantedBy=timers.target
Persistent=true 让错过的执行在开机后补跑。
一些小细节
删除时用 os.Remove 会真的释放空间吗? 如果文件正被进程打开(进程还持有 fd),os.Remove 只是删掉目录项,空间要等进程关闭才释放。这就是为什么上面要跳过"当前日志"。对于已经 rotate 过的文件,一般没有进程持有了。
日志压缩。 有些日志是 .gz 的,删之前可以看看压缩比,如果压缩后还是很大说明内容多,可以优先删。
并发问题。 我一开始想用 goroutine 并发删除加快速度,后来放弃了——删除本身是 IO 操作,并发对机械盘没好处,而且让"每删一个查一次用量"的逻辑变复杂。单线程完全够用,一万个文件也就几秒。
计数和统计。 输出里带上删了多少个、释放了多少空间,方便写进监控。
现在的样子
最终大概 400 行,包含参数解析、配置读取、dry-run、日志、统计。跑在生产上每天执行一次,一年多了没出过事。
回头看,这个工具的价值不在 Go 本身,而在于凡是涉及删除的工具,都值得在"安全"上多花一半的代码量。功能部分可能一百行就写完了。