WSL2 的 VHDX 为什么只增不减

太阳作者太阳
原创内容采用 CC-4.0 协议发布,转载请注明出处
WSL2WindowsVHDXDockerDiskPart稀疏磁盘

清理 WSL 时,我遇到了一个很直观的问题:Ubuntu 里的文件从约 29 GB 清到 5.1 GB,Windows 上的 ext4.vhdx 却还占着约 31.7 GB。缓存明明删了,C 盘空间却没有回来。

删除文件后,Linux 会把对应空间标记为空闲,动态 VHDX 的文件长度却未必随之缩小。微软的 compact vdisk 文档也说明了这个行为。清理盘内文件和把空间还给 Windows,需要分别检查。

这次我先清理盘内文件,再导出 VHDX 备份,压缩后把原磁盘转换成稀疏文件。最后磁盘降到了约 8.46 GB,Ubuntu 和里面的数据都保留下来。操作环境是 WSL 3.0.3,Docker Engine 直接安装在 Ubuntu 中。

默认安装目录也不好找

旧的商店 Appx 发行版通常把虚拟磁盘放在类似下面的位置:

C:\Users\USER\AppData\Local\Packages\CanonicalGroupLimited...\LocalState\ext4.vhdx

新版 WSL 使用新的发行版架构后,也可能看到这种路径:

C:\Users\USER\AppData\Local\wsl\{GUID}\ext4.vhdx

无论是带发布者后缀的包目录,还是只有一串 GUID 的目录,都不适合靠记忆寻找。PowerShell 可以从 WSL 的注册信息中取出准确路径:

$distroName = 'Ubuntu'
(Get-ChildItem -Path 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Lxss' |
  Where-Object { $_.GetValue('DistributionName') -eq $distroName }
).GetValue('BasePath') + '\ext4.vhdx'

我把 Ubuntu 放在了 C:\app\wsl\Ubuntu,后面的备份和压缩命令都使用这个路径。实际操作时,以注册信息查到的位置为准。不要直接剪切正在使用的 VHDX,移动发行版可以使用文末的 --move 命令。

先看 Linux 里到底占了多少空间

wsl --version
Get-Item -LiteralPath 'C:\app\wsl\Ubuntu\ext4.vhdx' |
  Select-Object FullName, Length, Attributes
fsutil sparse queryflag 'C:\app\wsl\Ubuntu\ext4.vhdx'

这次检查时,Windows 文件长度约 31.74 GB,fsutil 返回:

This file is NOT set as sparse

再进入 Ubuntu 看实际占用。下面这些命令在 Linux 终端执行;du -x 会留在当前文件系统内,避免把 /mnt/c 等挂载目录也算进来:

df -h /
sudo du -xhd1 / 2>/dev/null
sudo du -xhd2 "$HOME" /var/lib /var/cache /var/log 2>/dev/null | sort -h | tail -40
docker system df
sudo journalctl --disk-usage

最初最大的占用来自 Docker 构建缓存,合计 19.38 GB,其中约 17.76 GB 被 Docker 标记为可回收。清掉它以后,Linux 使用量降到了约 11 GB,但还有几项值得继续查:

  • 项目编译产生的构建目录约 2.5 GB,Debug 输出约 718 MB。
  • Docker 镜像约 1.6 GB,当时没有容器引用它们。
  • systemd journal 约 524 MB。
  • APT 下载缓存约 113 MB,软件包索引约 202 MB。
  • 用户 .cache 只有约 72 MB。

/var/lib/containerd 也一度有约 20 GB,占用与 Docker 的镜像和构建缓存有关。我通过 Docker 命令清理,没有直接删除这个目录。

检查过程中还翻出了数据库卷和旧备份。这些文件我都保留了。Docker 把未被容器引用的卷显示为“可回收”,只能说明当前没有容器在用它,不能据此判断数据已经没用。

清掉确认不用的缓存和构建产物

Docker 构建缓存和镜像分别清理:

docker builder prune -a
docker image prune -a

两条命令都会要求确认。第一条删除未使用的构建缓存,第二条删除没有容器引用的镜像;以后需要时得重新构建或拉取。我没有执行卷清理,也没有使用 docker system prune --volumes。

项目编译产物也占了不少空间。我先用 du 找出大目录,确认内容可以重新生成、也没有编译任务在运行,再删除对应输出。不同项目的目录结构差别很大,有些 bin 目录还混放着配置文件,不能只看名字就整目录清空。

APT 缓存、旧日志和两项用户缓存也做了清理:

sudo apt-get clean
sudo find /var/lib/apt/lists -maxdepth 1 -type f ! -name lock -delete
sudo journalctl --rotate
sudo journalctl --vacuum-size=100M
rm -rf -- ~/.cache/node-gyp ~/.cache/typescript

清掉 APT 索引后,下次安装软件前先执行 sudo apt-get update。日志保留最近约 100 MB;运行中的日志还会继续写入,检查时不一定恰好是这个数值。

清理完再看 df -h /,Ubuntu 已经只用了约 5.1 GB。Docker 缓存和不用的镜像都清掉了,数据卷仍然保留,Windows 上的 VHDX 文件长度却没有变化。接下来才轮到磁盘本身。

用 VHDX 格式备份

这次用磁盘格式导出,省掉逐文件打 tar 的过程。微软的 WSL FAQ给出的磁盘备份命令就是 --export ... --format vhd,我本机 wsl --help 也列出了这个格式。

先保存工作、停止构建和数据库写入,再在 PowerShell 执行:

wsl -d Ubuntu -u root -- sync
wsl -d Ubuntu -u root -- fstrim -v /
wsl --shutdown

$backupPath = 'C:\app\wsl\Ubuntu-backup-20261030.vhdx'
if (Test-Path -LiteralPath $backupPath) {
  throw "备份文件已存在:$backupPath"
}

wsl --export Ubuntu $backupPath --format vhd
if ($LASTEXITCODE -ne 0) {
  throw '备份失败,停止后续操作'
}
Get-Item -LiteralPath $backupPath | Select-Object FullName, Length, LastWriteTime

wsl --shutdown 会停止所有 WSL 实例和里面的服务。备份目录要有足够空间;本次导出的文件约 31.72 GB,备份本身仍占用这些空间。

导出成功后,我检查了文件长度和 VHDX 文件头,才继续压缩。备份仍然有三十多 GB,也不奇怪:VHDX 导出保留的是磁盘,换一种备份格式不会顺便清理盘内数据和空闲块。这次没有做完整的恢复演练,文末另放了恢复命令。

fstrim 当时报告约 1001 GiB 已修剪。这个数字是提交给底层的空闲范围,不能当成 Windows 真正释放了 1001 GiB;这块虚拟磁盘的容量上限本来就接近 1 TiB。

用 WSL 自带命令压缩原磁盘

我当前的 WSL 3.0.3 在 wsl --help 中提供了 --manage <Distro> --compact。备份成功、发行版停止后,直接执行:

wsl --manage Ubuntu --compact
if ($LASTEXITCODE -ne 0) {
  throw '压缩失败,停止后续操作'
}

Get-Item -LiteralPath 'C:\app\wsl\Ubuntu\ext4.vhdx' |
  Select-Object Length, @{Name='文件长度GiB'; Expression={[math]::Round($_.Length / 1GB, 2)}}

本次压缩后是 8.43 GiB。启动 Ubuntu 并完成验证后,文件长度约 8.46 GiB。本文记录的 VHDX 大小按 PowerShell 的 1GB 换算,使用二进制口径。

Linux 文件使用量约 5.2 GB,VHDX 文件长度约 8.46 GB,两者不必相等。虚拟磁盘、文件系统元数据和未完全收紧的块分配都会占用空间。

把现有磁盘转换成稀疏文件

我的 .wslconfig 早就有下面这项设置:

[experimental]
sparseVhd=true

它只影响新建的发行版磁盘,不会转换已经存在的 VHDX。微软的 WSL 配置文档对此有明确说明。已有磁盘还需要单独转换:

wsl --manage Ubuntu --set-sparse true
fsutil sparse queryflag 'C:\app\wsl\Ubuntu\ext4.vhdx'

本机返回:

This file is set as sparse

WSL 同时显示了稀疏 VHD 仍处于实验阶段的提示,所以前面的备份继续保留。旧教程中常见的 --allow-unsafe 在本次操作里没有使用;WSL 的稀疏 VHD 变更 #41726已允许直接执行 --set-sparse true,并保留实验性提示。

稀疏文件可以把已释放的块归还给 Windows。查看文件属性时要区分“大小”和“占用空间”:前者是文件逻辑长度,后者才更接近它实际占用的磁盘空间。不要只盯着资源管理器里的文件长度判断回收是否生效。

fstrim.timer 显示 enabled,却一直不运行

转换后 Ubuntu 能正常启动,手动执行 fstrim -v / 也成功了,但定时任务仍然是 inactive:

systemctl status fstrim.timer --no-pager
systemctl cat fstrim.timer
systemctl cat fstrim.service
systemd-detect-virt

检查输出后才发现,Ubuntu 自带的 timer 和 service 都有这项条件:

ConditionVirtualization=!container

本机 systemd-detect-virt 返回 wsl,启动 timer 时的日志是:

ConditionVirtualization=!container was not met

所以重复执行 systemctl enable --now fstrim.timer 没用。任务已经启用,只是每次启动都被条件跳过。

我给 timer 和 service 各加了一个 drop-in,只在 WSL 环境运行,不修改 /usr/lib/systemd/system/ 中的软件包文件。systemd.unit 文档说明,空的条件赋值会清空前面累计的条件列表;随后再设置 wsl,将运行环境限定为 WSL。

下面的命令在 Ubuntu 内执行:

for unit in fstrim.timer fstrim.service; do
  directory="/etc/systemd/system/$unit.d"
  sudo mkdir -p "$directory"
  printf '[Unit]\nConditionVirtualization=\nConditionVirtualization=wsl\n' |
    sudo tee "$directory/wsl-reclaim.conf" > /dev/null
done

sudo systemctl daemon-reload
sudo systemctl enable --now fstrim.timer
sudo systemctl start fstrim.service

这段覆盖配置适用于本次“WSL 中手动 TRIM 成功,但虚拟化条件阻止定时任务”的情况。如果本机 timer 已经正常运行,就不需要添加;已有同名 drop-in 时也先检查内容,不要直接覆盖。

验证结果:

systemctl is-active fstrim.timer
systemctl show fstrim.service -p Result -p ExecMainStatus
systemctl list-timers fstrim.timer --no-pager
findmnt -no OPTIONS /
active
Result=success
ExecMainStatus=0

这时 timer 终于是 active,手动运行的 service 也成功退出。根文件系统的挂载选项中还有 discard,后续删除文件时可以向底层提交释放请求,每周 TRIM 再定期处理空闲范围。定时任务依赖 Ubuntu 中的 systemd,WSL 完全停止时不会在后台唤醒它执行;删除文件后也不保证文件长度立即缩小。

如果要从 VHDX 备份恢复

备份文件先保留在原处。如果需要验证恢复,可以导入一个新名字的发行版,让 WSL 在新目录创建工作副本:

wsl --import Ubuntu-Recovered C:\app\wsl\Ubuntu-Recovered C:\app\wsl\Ubuntu-backup-20261030.vhdx --vhd
wsl --manage Ubuntu-Recovered --set-default-user dex
wsl -d Ubuntu-Recovered

这里的 dex 是备份中已有的 Linux 用户,其他机器需要换成自己的用户名。WSL 基本命令文档说明,--import --vhd 会复制提供的磁盘文件;--import-in-place 则直接注册那份文件。要继续保留一份未使用的备份,应选择前者。

这组恢复命令是备用流程,本次没有执行,也没有删除原来的 Ubuntu。等新实例中的文件、权限和服务都确认正常,再决定是否替换旧实例。

旧版 WSL 的 DiskPart 压缩方法

如果本机帮助中没有 --compact,可以更新 WSL,或在完成备份后沿用 DiskPart。下面这条备用路径用于未启用稀疏的动态 VHDX;已经启用稀疏时,先停机并执行 wsl --manage Ubuntu --set-sparse false,再压缩。

在管理员 PowerShell 中关闭 WSL,随后进入 DiskPart:

wsl --shutdown
diskpart

在 DiskPart 中执行:

select vdisk file="C:\app\wsl\Ubuntu\ext4.vhdx"
attach vdisk readonly
compact vdisk
detach vdisk
exit

compact vdisk 文档要求磁盘处于分离状态或只读挂载状态。遇到文件占用时,先确认 WSL 已停止;使用 Docker Desktop 的机器也要退出 Docker Desktop。不要在运行中的 VHDX 上手动搬文件。

把安装位置固定下来

我仍然习惯把发行版放在 C:\app\wsl。以后检查、备份和压缩时,路径不用再从 AppData 里翻。

新版 WSL 可以在安装时指定目录,也能移动现有发行版;具体参数见 WSL 基本命令文档。先检查本机 wsl --help 是否支持对应选项:

wsl --install --distribution Ubuntu --location C:\app\wsl\Ubuntu

已有发行版需要移动时,使用 WSL 提供的命令:

wsl --shutdown
wsl --manage Ubuntu --move C:\app\wsl\Ubuntu

现在 Ubuntu 的原磁盘约 8.46 GB,31.72 GB 的 VHDX 备份还单独留着。以后再遇到 C 盘空间减少,我会先看 df -h / 和 Docker 缓存;Linux 里已经空出来、Windows 却没收回的部分,再查稀疏状态和 fstrim.timer。