背景 & 问题
Windows 弹了磁盘告警,打开一看,1.4TB 的 C 盘只剩 20MB。这个数字已经低到会影响系统正常运行了,一次系统更新的临时文件都放不下。
先看 WSL 内部的使用情况:
$ df -h /
Filesystem Size Used Avail Use% Mounted on
/dev/sdd 1007G 272G 684G 29% /
272GB,用了不到三成。再看 Windows 这边对应的虚拟磁盘文件:
> (Get-Item $vhdx).Length / 1GB
769.1
769GB 的文件,里面只装了 272GB 的东西,中间差出来的 497GB 是空的。
这是 WSL2 的机制决定的:ext4.vhdx 是动态扩展的虚拟磁盘,用多少涨多少,但它不会自动收缩。我历史上某次 cargo build 把它撑到了 769GB,构建完删掉产物,Linux 里的可用空间确实回来了,但 Windows 这边一个字节都没释放。
所以有一个容易忽略的结论:vhdx 的文件大小等于历史峰值,而不是当前用量。在 Linux 里删文件,对 Windows 的可用空间没有任何影响。
顺带看了下这 272GB 的构成,也算是给自己提个醒:
| 项目 | 大小 |
|---|---|
Rust target/ 共 10 个 | 168 GB |
| node_modules | 24 GB |
.npm 缓存 | 21 GB |
.local/share(pnpm + mise) | 21 GB |
.cache(pip / playwright / go-build) | 12 GB |
问题分析 & 解决思路
首先想到的是开启稀疏 VHD
WSL 有个 sparse VHD 特性,能在块被释放时自动打洞回收空间。查了下我的 .wslconfig,发现早就配上了:
[experimental]
sparseVhd=true
但实际查询这个文件的稀疏标志,结果是没生效:
> fsutil sparse queryflag $vhdx
此文件没有设为稀疏
查了下资料,这个配置项只对之后新建的发行版生效,我的 Ubuntu-24.04 是在配置之前就创建的,所以没吃到。那手动转换一次就好。
转换之前需要先在 Linux 里做一次 fstrim。原因是 ext4 删文件只是在文件系统层面标记为空闲,块层并不知道,需要 TRIM 把”这些块已经不用了”这个信息传下去,虚拟磁盘才知道哪些区域可以回收:
$ sudo fstrim -v /
/: 676.6 GiB (726502834176 bytes) trimmed
676GB,和前面算出来的空洞量对得上。接着关掉 WSL 执行转换:
> wsl --manage Ubuntu-24.04 --set-sparse true
正在进行转换,可能需要一定的时间。
由于潜在的数据损坏,目前已禁用稀疏 VHD 支持。
若要强制分发使用稀疏 vhd,请运行:
wsl.exe --manage <DistributionName> --set-sparse --allow-unsafe
错误代码: Wsl/Service/E_INVALIDARG
微软在 WSL 2.6 之后把这个功能禁用了,原因是存在数据损坏的问题。虽然提供了 --allow-unsafe 可以强制开启,但那正是被禁用的原因,磁盘里都是代码和构建产物,不值得冒这个风险。
这里有两个结论,第二个影响更大:
.wslconfig里的sparseVhd=true目前是一行无效配置,写了也不生效,而且不会给任何警告。- WSL 目前没有自动回收空间的机制。网上大量教程还停留在”开启稀疏就一劳永逸”的说法,在新版本上已经不成立了,只能周期性手动压缩。
改用 diskpart 压缩
稀疏这条路走不通,换 diskpart 的 compact vdisk。它是一次性的物理压缩,不设置稀疏标志,不涉及上面那个数据损坏的问题。
思路很直接:关掉 WSL,然后压缩。写了个脚本,wsl --shutdown 之后等 12 秒再执行 diskpart,结果报错:
DiskPart 已成功选择虚拟磁盘文件。
DiskPart 出现错误: 另一个程序正在使用此文件,进程无法访问。
首先怀疑是关闭得不够彻底,或者等待时间不够长。把等待时间加长后重试,依然是同样的报错,所以这个想法作罢。
接着去查到底是谁持有这个文件的句柄,看了下和 WSL 相关的几个 Windows 服务:
> Get-Service | Where-Object { $_.Name -match 'wsl|Lxss|vmcompute' }
Name Status StartType
---- ------ ---------
LxssManager Stopped Manual
vmcompute Running Manual
WSLService Running Automatic
WSLService 一直是 Running 状态。
紧接着我就有一个疑问:wsl --shutdown 不是已经把 WSL 关掉了吗,为什么服务还活着?
查下来是这样:wsl --shutdown 停的是虚拟机和里面的发行版,而 WSLService 是宿主机上常驻的 Windows 服务,它不在关闭范围内,vhdx 的文件句柄正是攥在这个服务手里。所以只要它还在跑,diskpart 就永远拿不到独占访问。
另外还注意到一个共犯。Docker Desktop 的 docker-desktop 和 docker-desktop-data 本身也是 WSL 发行版,wsl --shutdown 会把它们一并停掉,但 Docker Desktop 检测到之后会立刻重新拉起来,WSL 虚拟机于是又活了。我第一版脚本还在结尾自动重启了 Docker Desktop,相当于自己给自己制造了这个问题。
至此顺序就明确了:
杀掉 Docker Desktop 进程
→ wsl --shutdown
→ Stop-Service WSLService -Force
→ diskpart compact
把盲等换成轮询
顺序对了,但还剩一个问题:中间到底该等多久?
这个量是不确定的,等短了拿不到锁,等长了纯粹浪费时间,而且失败的时候还是同一个报错,根本分不清是”顺序错了”还是”等得不够”。
与其猜,不如直接问操作系统:能不能以独占模式打开这个文件。打得开就说明已经没有别的进程持有句柄了。
function Test-Exclusive([string]$path) {
try {
# FileShare 'None' = 独占。只要还有别人持有句柄,这里必然抛异常
$fs = [IO.File]::Open($path, 'Open', 'ReadWrite', 'None')
$fs.Close(); $fs.Dispose(); $true
} catch { $false }
}
for ($t = 0; $t -lt $LockWait; $t++) {
if (Test-Exclusive $vhdx) { $acquired = $true; break }
Start-Sleep -Seconds 1
}
这样改还有个附带好处:超时之后可以干净地放弃——恢复服务、原样退出、什么都不动,而不是硬着头皮往下跑。
实际执行的结果印证了前面的判断:
[4/7] Waiting for exclusive access to the vhdx (up to 180 s)...
exclusive access acquired after 0s
0 秒。停掉 WSLService 之后句柄立刻就释放了,之前两次失败和等待时长完全无关。
结果
[5/7] diskpart compact - 769.1 GB file...
100 百分比已完成
DiskPart 已成功压缩虚拟磁盘文件。
| 之前 | 之后 | |
|---|---|---|
| ext4.vhdx | 769 GB | 303 GB |
| C 盘可用 | 20 MB | 483 GB |
| WSL 内部用量 | 263 GB | 263 GB |
回收了 466GB,没有删除任何文件。
脚本
把整个流程写成了一个通用脚本,从注册表读取发行版列表和 vhdx 路径(不用手填 GUID),自动请求管理员权限,包含上面的独占轮询和失败回滚:
compact-wsl-vhdx.ps1(GitHub Gist)
# 列出所有发行版和各自占用(只读,不改任何东西)
.\compact-wsl-vhdx.ps1 -List
# 只输出报告,不执行压缩
.\compact-wsl-vhdx.ps1 -ReportOnly
# 执行压缩,会自动请求管理员权限
.\compact-wsl-vhdx.ps1
.\compact-wsl-vhdx.ps1 -Distro Ubuntu-24.04
-List 的输出:
WSL distributions:
302.3 GB Ubuntu-24.04 (default)
C:\Users\...\AppData\Local\wsl\{...}\ext4.vhdx
31.9 GB docker-desktop-data
D:\docker\DockerDesktopWSL\data\ext4.vhdx
1.3 GB Ubuntu-20.04
有一点需要注意:diskpart 挂载磁盘的瞬间,Windows 可能会弹出「你需要先格式化驱动器中的磁盘,然后才能使用」。这里一定要点取消,那是 ext4 分区,Windows 只是不认识它的格式。脚本里加了 automount disable 来降低弹窗概率,但不能保证一定不弹。
小结
稀疏 VHD 被禁用之后,WSL 实际上退回到了没有任何自动空间回收的状态,所以这个压缩操作需要周期性地做,C 盘紧张的时候跑一次就行。
不过压缩终究是事后补救。既然 vhdx 的大小取决于历史峰值,那更划算的做法是别让峰值涨上去——我这台机器上 168GB 的 Rust target/ 才是真正的病根,用 cargo-sweep 定期清理掉长期没碰过的构建产物,比事后反复压缩要省事得多。