浪白的博客の小站
首页项目归档照片墙音乐灵境说说杂谈友链关于
封面

用 Go 写了个日志清理小工具

写作时间:2026-06-18 21:10:00
# Go
# 工具
# 实战

服务器上有些服务日志不轮转,几个月下来单个文件几百 G。写个工具定期清理,顺便记录一下过程中的取舍。

需求

其实就三条:

  1. 找出超过 N 天的日志文件,删掉
  2. 磁盘使用超过阈值时,从最老的开始删,直到降到阈值以下
  3. 支持 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 本身,而在于凡是涉及删除的工具,都值得在"安全"上多花一半的代码量。功能部分可能一百行就写完了。

avatar

浪白

邪恶的浪白

RECOMMENDED

Composer 用了这么多年,几个我认为值得坚持的习惯

2026-04-27 19:15:00

context 传参这件事,我踩过的三个坑

2026-05-06 20:05:00

Go 的 error 处理,用久了反而觉得还行

2026-04-19 22:15:00

Table of Contents