
先交代一下背景。我平时主要做 Java 后端工作机是一台 512G 的 MacBook Pro刚开始觉得这个容量绰绰有余。结果两年不到磁盘就频繁亮红灯每次都靠清理微信缓存、卸载 Docker 镜像强行续命。直到有一天我打开磁盘分析工具发现~/.m2/repository这个目录占了 60 多 G~/Library/Caches下各种包管理器缓存加起来又是 30 多 G那一刻我才意识到真正吃满磁盘的不是照片和电影而是开发工具链里那些平时根本注意不到的仓库包缓存。这才有了我后来写的一个开源仓库包缓存清理工具专门针对 maven、npm、pip、docker 这类开发缓存做精准扫描和清理。现在已经开源到了 Gitee 和 GitHub 上文章里我会把这个工具的设计思路、核心实现、遇到过的坑全部倒出来尤其是那些文档里不会写的细节希望给同样被磁盘告急困扰的开发者一点切实可用的参考。1. 这个工具到底解决什么问题1.1 开发者的磁盘是如何被“吃”掉的很多人以为 C 盘或系统盘满了删掉几个大视频、卸载几个不用的软件就行。但作为开发者我们真正该盯的是各种包管理器的缓存目录。这类缓存有一个共同点名字里都带 cache 或 repository但只会无限增长、不会自动清理。拿最常见的 Java 项目来说~/.m2/repository是 Maven 的本地仓库每引入一个依赖Maven 就会把 jar 包、pom 文件、sources 包都下载到这个目录。当你频繁切换项目分支、升级依赖版本、换 Spring Boot 版本时旧版本的 jar 包并不会被自动删除。我做了一个统计一个中型的微服务项目只要迭代一年.m2目录超过 30G 是非常正常的事情。如果你平时还喜欢在 IDEA 里无脑地mvn clean package这个数字还会翻倍。Node 生态也没好到哪里去。~/.npm/_cacache是 npm 的内容寻址存储目录下载过的每个 tarball 包都以二进制块的形式永久留着。pnpm 稍微好一点但它的全局 store 目录同样只增不减。更别提 Docker/var/lib/docker下面的镜像层和构建缓存那简直就是磁盘黑洞。我见过最夸张的一台 CI 机器docker 目录占了 200 多 G大部分还是悬空镜像dangling images和已经失效的 Build Cache。这个开源工具的核心目标就是把这些开发缓存的体积摊开给你看然后按需清理而不是像某些一键清理软件那样盲目删除。1.2 关键词盘点开源、仓库、包缓存、清理工具、开发者我把标题拆成几个关键词说一下每个词背后的实际含义因为这决定了工具的设计边界。“开源”意味着整个项目从核心代码到使用文档全部公开任何人都能下载源码自己编译甚至提交 PR。我不会把工具做成一个黑盒因为清理缓存本质上是一件需要安全感的事情用户必须能看穿它删除的每一个文件。“仓库”在这里不是代码仓库的意思而是仓库缓存包括 Maven 的本地仓库、Docker 的镜像仓库本地缓存、npm 的缓存仓库等。“包缓存”是更精确的说法指的是包管理器为了加速重复下载而保存的本地副本。“清理工具”则意味着它必须有自己的判定逻辑搞清楚哪些缓存是安全的垃圾、哪些是当前项目正在使用的依赖绝对不能一刀切。这也是这个项目里最难的部分。“开发者”是目标用户。工具不仅要能用还要好用至少要比手动敲docker system prune和rm -rf ~/.m2/repository更安全、更智能。1.3 为什么我不建议直接手动删除缓存做这个工具之前我也试过手动清理。最粗暴的方式就是直接删~/.m2/repository但后果很严重下次构建时所有依赖重新下载等于把几个 G 的流量和时间白白扔出去。Maven 的中央仓库慢的时候一个项目可能要拉十分钟以上团队里多人同时构建还会互相影响。Docker 那边更危险。直接rm -rf /var/lib/docker确实能释放巨量空间但你的所有镜像、容器、卷全部没了一些没有推到远程仓库的本地镜像直接永久丢失。Docker 官方其实提供了安全的清理命令但它们藏在文档深处很多人根本不知道。所以这个工具的核心价值就在这里通过分析缓存的使用状态判断哪些是悬空的、无引用的、可以放心清理的哪些是项目正在用的、不能动的。在此基础上再提供一键清理的能力。2. 整体设计与方案选型2.1 技术栈为什么选了 Rust选择 Rust 不是因为追新而是因为这类系统级清理工具对性能和体积都有要求。Rust 编译出来的单文件二进制只有 3M 左右不需要用户安装任何 Runtime拷贝到服务器上就能跑。而像 Python 写的工具你还要考虑目标机器上的 Python 版本、pip 依赖、虚拟环境这对一个面向开发者和 CI 场景的清理工具来说太不友好了。另外Rust 的内存安全特性在遍历文件系统、处理符号链接、并发扫描目录时非常有价值。清理工具需要读取大量的文件元数据如果某个目录出现权限错误或者损坏的软链接C 语言写很容易段错误而 Rust 用Result类型就能优雅地处理这些异常情况不会一遇到坏路径就崩溃。我用tokio做异步扫描、rayon做并行计算、clap做命令行参数解析再加上serde做配置文件的序列化。这几样组合起来一个单机的命令行工具完全够用不需要引入 Web 框架或者数据库。2.2 核心设计原则先扫描后清理永远不自动删这个工具的第一版其实踩过一个坑我让工具扫描完成后直接弹出一个交互式列表让用户按回车确认清理。但后来发现很多用户在 CI 环境或者 SSH 远程终端里使用没有交互式操作的条件。于是第二代设计改成了现在这样扫描模式scan只扫描、只展示、绝不动文件。输出哪些缓存目录体积大、哪些文件属于无用缓存。预览模式preview基于扫描结果模拟计算“如果执行清理会释放多少空间、会删除哪些文件”。清理模式clean用户显式传入--yes或--config指定清理策略后工具才会真正执行删除。任何情况下工具不会在没有明确参数的情况下自动删除文件。宁可让用户觉得“麻烦”也不能让用户觉得“这工具偷偷删了我的东西”。在清理工具这个领域信任比效率重要得多。2.3 支持的目标缓存类型与判定逻辑针对不同的包管理器扫描和清理的判定逻辑完全不同我用一张表来说明缓存类型默认路径可清理依据清理方式Maven 本地仓库~/.m2/repository*.lastUpdated文件、_remote.repositories中无引用的旧版本、超过 N 天未使用的 SNAPSHOT按文件删除npm 缓存~/.npm/_cacachenpm cache verify检测出的垃圾文件建议调用系统 npm 命令pip 缓存~/.cache/pip超过 30 天未被访问的 wheel 缓存按目录删除Docker 悬空镜像/var/lib/docker无标签且无容器引用的镜像层调用docker image pruneDocker Build Cache/var/lib/docker未被引用的构建缓存调用docker builder pruneGo Module 缓存$(go env GOMODCACHE)未被当前项目依赖的旧版本模块按模块版本删除这里的核心难点在于工具自身并不希望去重写 npm 或 docker 的清理逻辑因为包管理器内部的索引机制非常复杂直接删文件会导致无法预料的错误。所以我设计的策略是对 Maven 这类纯文件型缓存直接操作文件对 npm、docker 这类有自己的状态数据库的缓存尽量代理调用官方命令只负责统计和执行不越俎代庖。3. 核心实现与实操过程3.1 扫描缓存目录大小的实现原理看似简单的“扫描目录大小”实际上有 3 个容易踩坑的地方。第一是硬链接。Maven 仓库里的某些包可能通过硬链接复用相同的内容在计算大小时如果只看目录项而不识别 inode会导致重复计算。正确做法是记录每个文件的 inode相同 inode 只统计一次。第二是符号链接。有些缓存目录里面会有指向其他目录的符号链接比如 node_modules 里的一些.bin链接。遍历时必须跳过符号链接本身防止无限递归。第三是权限不足。/var/lib/docker需要 root 权限才能访问普通用户执行扫描时只能看到部分信息这时工具必须给出明确提示而不是报一个冰冷的Permission denied。我用一个并行遍历器来处理这些情况核心逻辑大致如下fn walk_and_sum(path: Path, seen_inodes: mut HashSet(u64, u64)) - io::Resultu64 { let mut total 0u64; let mut entries Vec::new(); // 先读取当前目录的所有条目 for entry in fs::read_dir(path)? { let entry entry?; let ft entry.file_type()?; if ft.is_symlink() { // 符号链接不追踪直接忽略 continue; } if ft.is_file() { let meta entry.metadata()?; // 硬链接去重通过设备号和 inode 唯一标识文件 let key (meta.dev(), meta.ino()); if seen_inodes.insert(key) { total meta.len(); } } else if ft.is_dir() { entries.push(entry.path()); } } // 并行递归子目录 let sub_totals: Vecu64 entries.par_iter() .map(|sub| walk_and_sum(sub, seen_inodes).unwrap_or(0)) .collect(); total sub_totals.iter().sum::u64(); Ok(total) }这段代码看起来简单但你仔细看有两个关键点seen_inodes集合必须跨目录传递才能避免硬链接重复计数使用par_iter并行遍历子目录大幅提升扫描速度。实测下来一个 50G 的.m2目录用单线程扫描需要 15 秒用这个并行版本只要 3 秒。3.2 安全清理的回收站机制直接删除文件有一个巨大的风险万一手滑删错了文件就永远找不回来了。所以我给清理工具加了一个回收站机制默认情况下所有被删除的文件先移动到一个临时目录比如~/.cache/repo-cleaner/trash/而不是直接rm。# 清理前先移动到回收站 mv ~/.m2/repository/org/example/old-version/ ~/.cache/repo-cleaner/trash/2024-06-01/org/example/old-version/ # 确认无误后清空回收站 repo-cleaner trash --empty这个设计在实际使用中救过我一次。有一次在清理一个老项目的 Maven 缓存时误删了一个自定义私服上的内部依赖导致另一个正在开发的项目构建失败。我赶紧从回收站里恢复了那个 jar 包整个过程不到一分钟当时心里真是一万只羊驼奔过——如果没有回收站机制重新从私服下载这个依赖需要联系运维同学至少折腾半小时。当然回收站机制也有自己的问题就是清理之后磁盘空间并没有立即释放文件只是换了位置。所以工具的clean命令执行后会打印一行提示“文件已移入回收站如确认无误请执行repo-cleaner trash --empty彻底释放空间”。把选择权交给用户。3.3 实操从安装到释放 20G 磁盘空间的完整流程下面用一条真实的操作路径带大家感受一下这个工具的使用流程。假设当前环境是 Ubuntu 20.04用户是普通开发者账户。第一步安装工具。因为项目发布到了 crates.io所以可以直接用 cargo 安装cargo install repo-cleaner如果你不想装 Rust 工具链GitHub Releases 里也提供了预编译的二进制包下载解压后直接放到/usr/local/bin即可。第二步执行扫描repo-cleaner scan --all输出结果大概是这样的[Scan Result] 2024-06-01 10:32:15 ----------------------------------------------------------------------------- | Cache Type | Path | Total Size | Cleanable Size | ----------------------------------------------------------------------------- | Maven Local Repository | ~/.m2/repository | 25.3 GB | 12.8 GB | | npm Cache | ~/.npm | 8.1 GB | 3.2 GB | | Docker Images | /var/lib/docker| 78.5 GB | 45.2 GB | | Go Module Cache | ~/go/pkg/mod | 6.7 GB | 2.1 GB | -----------------------------------------------------------------------------这里最关键的是让你看到每个缓存的“可清理大小”这个数值是工具基于文件最后访问时间、依赖引用关系和官方清理规则计算出来的。比如 Maven 仓库里的*.lastUpdated文件任何情况下都没有保留价值直接列为“可清理”而某些 SNAPSHOT 版本的 jar 包如果最后修改时间超过 90 天也列入可清理范围。第三步预览清理策略repo-cleaner preview --targets maven,npm --older-than 90d这个命令会展示所有将被删除的文件列表总数、总大小、涉及哪些项目。第四步执行清理repo-cleaner clean --targets maven,npm --older-than 90d --yes执行完成之后再跑一次scan就能看到磁盘空间的变化。我自己在真实环境里跑过一次清理前.m2是 25.3G清理后变成了 12.5Gnpm 缓存从 8.1G 清理到了 4.9G加起来释放了 16G 空间。虽然 Docker 那边因为权限问题没有自动清理但结果已经很让人欣慰了。3.4 配置文件按自己的节奏定制策略很多人用命令行工具喜欢把所有参数都写在命令里。但清理缓存这件事周期性的需求很强更适合写进配置文件里。我支持一个 YAML 格式的配置文件放在~/.config/repo-cleaner/config.yaml# 扫描哪些缓存默认全部 targets: - maven - npm - docker - go # 清理阈值只有可清理空间超过这个值时才会执行 threshold_gb: 5 # Maven 清理规则 maven: delete_lastUpdated: true delete_incomplete: true older_than_days: 90 # npm 清理规则 npm: use_system_command: true # 优先调用 npm cache verify # Docker 清理规则 docker: prune_images: true prune_build_cache: true prune_volumes: false # 不要动 volumes # 回收站开关 trash: enabled: true max_size_gb: 10 # 回收站最大容量配置文件的玩法是你可以在自己的开发机上定义一套保守的策略比如older_than_days: 180一个月才清理一次在 CI 服务器上定义一套激进的策略比如older_than_days: 3每次构建完成后自动清理。这套配置方案比单纯的命令行参数灵活很多。4. 常见问题与排查技巧实录4.1 清理完磁盘空间并没有立即释放很多用户反馈执行完clean命令后用df -h查看磁盘空间发现变化不大。这个问题在回收站机制开启时尤其常见因为文件只是被移动到了回收站本质上还是在同一块磁盘上。如果你的清理目标就是立即释放空间有两个选择一是执行repo-cleaner trash --empty清空回收站二是直接在配置里把trash.enabled设置为false。但我的建议是始终保留回收站等到确认整个项目周期没有出现问题再清空这个习惯能帮你在关键时刻保住重要文件。另一个原因可能是文件被进程占用。Linux 系统上如果一个文件被某个进程打开并且该文件的文件描述符没有关闭即使你删除了文件磁盘空间也不会被释放。最典型的场景是 Java 应用还在运行JVM 加载了一个 jar 包你又把这个 jar 包删了磁盘上的 block 仍然会被标记为“已占用”。这时候只能重启相关服务空间才会真正释放。4.2 如何避免误删当前项目正在使用的依赖这是清理工具最核心的矛盾你怎么知道某个缓存文件是否正在被当前项目使用Maven 的本地仓库是纯文件式的理论上一个项目重新构建时会自动下载缺失的依赖所以即使误删也只是影响构建速度。但如果是私服内网 Nexus上独有的依赖丢失后可能会卡住整个团队。我的策略是在清理 Maven 仓库时自动读取当前目录下的pom.xml解析出dependency列表把这些依赖对应的本地路径加入白名单清理时跳过。多个项目可以用--project-dir参数指定多个目录。npm 缓存这边则完全不同因为 npm 的缓存目录是按照内容寻址方式组织的直接跳过单个包没有任何意义。所以我选择了最稳妥的方式调用npm cache verify让 npm 自己判断哪些缓存是垃圾。这个命令本身是官方支持的不会导致任何问题。4.3 扫描和清理 Docker 缓存总提示权限不足由于/var/lib/docker的访问权限通常限定在 root 用户和 docker 组普通用户扫描时经常会遇到权限错误。我的工具做了两层处理第一层在扫描前检查当前用户是否有权限读取目标目录如果没有提示用户使用sudo执行或者将当前用户加入 docker 组sudo usermod -aG docker $USER第二层对于已经安装 docker 命令的机器直接使用 docker 的 API 获取信息docker system df这个命令输出的RECLAIMABLE列就是可清理的空间。工具把这个结果映射到扫描结果里展示给用户。如果用户选择了清理 docker 缓存工具就执行docker image prune -a --filter until168h或者docker builder prune剥离开出最安全的清理方案。4.4 CI 环境里的定时清理在实际的 CI/CD 流程里缓存目录膨胀的速度比本机更夸张。一台每天跑几十次构建的 Jenkins 机器.m2和 Docker 镜像可能在两周内从 50G 涨到 200G。我在项目文档里专门写了 CI 环境的使用建议# Jenkins pipeline 示例 stage(Clean Cache) { steps { sh repo-cleaner clean --targets maven,docker --older-than 7d --yes --no-trash } }这里需要注意的是CI 环境不建议开启回收站机制--no-trash因为回收站里的文件不可能有人手动去恢复只会白白占据磁盘。另外CI 环境的清理阈值要设置得比较激进比如只要缓存超过 7 天没被访问就直接清掉因为构建机器上根本不会有人关心历史版本的依赖是否还在。4.5 Maven 私服镜像场景下的缓存策略有段时间我在公司内部搭了一个 Nexus 私服所有 Maven 依赖都走私服代理到中央仓库本地仓库的缓存就变得非常关键。最初发现自己动手写清理脚本时踩了一个坑我直接把.m2里的 SNAPSHOT 版本全删了结果第二天开发同事集体反馈构建失败因为他们在用旧版本的 SNAPSHOT而这些版本的 jar 在私服上已经因为自动清理策略被删掉了。后来我把工具的策略调整成三档safe只清理*.lastUpdated和下载不完整的.part文件任何完整 jar 都不动。normal清理超过 90 天未被访问的版本包括 release 和 SNAPSHOT但会先读取项目 pom 确认未被引用。aggressive清理一切非当前分支正在引用的缓存适合 CI 从零构建的场景。这个分档机制也是从那次事故中学到的。如果你在公司内部使用私服建议始终保持在normal档位以下除非你确认私服上保留了所有历史版本。4.6 扫描时发现某些目录体积夸张但清理后没什么影响有些开发者会扫描出一个几十 G 的目录例如~/Library/Developer/Xcode/DerivedData或者~/go/pkg/mod然后问我为什么清理后空间释放不明显。其实这两个目录的处理方式不一样。Go Module 缓存有一个官方命令go clean -modcache直接调用就能清空但它会强制删除所有模块缓存后果是下次构建时得全部重新下载。我的工具对这个目录的策略是先运行go list -m all获取当前项目需要的模块列表再对比GOMODCACHE里的目录删除那些不在列表里的旧版本。这个逻辑比直接清空要温和得多。Xcode 的 DerivedData 则属于“构建产物”而不是“依赖缓存”我的核心工具并不会把它纳入默认扫描目标但如果你显式指定--extra-dir ~/Library/Developer/Xcode/DerivedData工具也能按目录大小和最后访问时间来做清理。苹果的 SDK 和模拟器缓存有另外一套管理机制我个人不建议用第三方工具去动出了问题很难恢复。5. 写在最后的个人实战心得这个工具开发到现在也有半年多了我自己在四台不同的开发机、两台 CI 服务器上跑过上百次扫描和清理。印象最深的一次是帮一位同事清理他的 Windows 工作机他那个 256G 的 C 盘已经红到只剩 3G打开磁盘分析一看.m2占了 45Gnpm 缓存占了 20GDocker Desktop 的磁盘镜像文件占了 60 多 G。用这个工具清完C 盘直接多出 80G 空间他当时在工位上差点跳起来。我知道市面上有很多商业化的“电脑清理工具”它们也能做到类似的事但对开发者缓存的理解远没有这么细。很多工具只会按键面清理垃圾文件根本不知道 Maven 的.lastUpdated文件、pip 的_internal缓存和 Docker 的 build cache 有什么区别。这个开源项目最大的意义是提供了一个让开发者自己掌控缓存清理的框架。你可以通过配置文件定义适合自己团队的清理策略也可以把扫描结果集成到 CI 流水线里甚至可以复用核心库去做更定制化的磁盘分析。仓库里的 issue 也一直开着如果你在清理某些冷门包管理器缓存时遇到了问题欢迎直接提 PR毕竟缓存生态还在不断增长这工具也得跟着一起长。