12.4 GB of duplicates, 3.4 GB on disk
Contents
$ compsize -x model.bin model-copy.bin
model.bin: SEARCH_V2: Operation not permitted
That is compsize, the tool that reports what a file actually costs on btrfs, refusing to talk to a normal user. It reads each file's extent records through a btrfs ioctl, and the kernel lets only CAP_SYS_ADMIN call it. I did the measuring on the workstation I was clearing out without sudo.
What it did have was rootful Docker, through the docker group. So I ran the host's own compsize through a read-only chroot:
docker run --rm --cap-add SYS_ADMIN -v /:/host:ro alpine chroot /host /usr/bin/compsize -x <path>
Docker's own docs say the docker group "grants root-level privileges", and this is that fact used on purpose. The bind is read-only, the binary is the host's, and the container is gone when the report prints. btrfs filesystem du would run without any of this, but it counts compressed extents at their uncompressed length, so it can't show what compression saves. Step by step, with the risk spelled out: Measure real btrfs disk use without sudo.
What compsize reported kept contradicting the other tools.
du counts bytes btrfs never wrote#
/home here is btrfs with transparent compress=zstd:1, and du reports the uncompressed size. On a scratch file of repetitive log lines:
$ du -h app.log
355M app.log
$ docker run --rm --cap-add SYS_ADMIN -v /:/host:ro alpine chroot /host /usr/bin/compsize -x "$PWD"/app.log
Processed 1 file, 2838 regular extents (2838 refs), 0 inline, 1822 fragments.
Type Perc Disk Usage Uncompressed Referenced
TOTAL 3% 11M 354M 354M
zstd 3% 11M 354M 354M
Log lines are a best case. Across the trees measured on the real disk, compsize counted 144 GB of data in 94 GB of disk. 78 GB of that was stored uncompressed: zips, ROMs, maps, model weights, the kind of data btrfs's compression heuristic tries, finds incompressible, and leaves alone.
Recompressing harder bought about 5%#
The obvious next move was a heavier zstd level. btrfs filesystem defragment -czstd -L 9 runs without root on files you can write (the -L level option needs kernel 6.15 or newer), and on this data it gained about 5%. Each compressed extent holds at most 128 KiB, so even level 9 only ever sees 128 KiB at a time, and already-compressed data like zips and ROMs gains little at any level. Defragmenting also breaks up reflinks, which on a disk full of cp copies is the opposite of the goal. Not worth it. One live SQLite database was set to skip compression on purpose, with the m attribute.
The 15.8 GB du couldn't read#
du also misses whatever it can't open. A Postgres data directory written by a container belonged to the container's uid, with mode 700. du printed Permission denied for it, kept going, and came up 15.8 GB short.
It was a synthetic benchmark database, filled by a seed script with generate_series and random(), so it could be rebuilt at any time. It was deleted from inside a container, since only something running as that uid, or as root, could touch it. It sat in a stale copy of another machine's files, which got its own checksum comparison before deletion.
268 duplicate sets, 3.4 GB of waste#
A duplicate finder then reported 268 sets of identical files of 5 MB or more. Summed, they looked like 12.4 GB of waste.
Then compsize measured each set twice: all copies together, and one copy alone. The difference is what deduplication could still win. Across all 268 sets it came to 3.4 GB.
Most of the rest had been shared all along. GNU cp has made reflinks by default since coreutils 9.0: on btrfs the copy points at the same extents, and nothing new is written until one side changes. A finder that hashes contents sees two identical files. So does du. The disk holds one. Reproduced on a 300M random file and its cp, measured one file alone and then both together:
$ du -h model.bin model-copy.bin
300M model.bin
300M model-copy.bin
$ du -ch model.bin model-copy.bin | tail -1
600M total
$ docker run --rm --cap-add SYS_ADMIN -v /:/host:ro alpine chroot /host /usr/bin/compsize -x "$PWD"/model.bin
Processed 1 file, 3 regular extents (3 refs), 0 inline, 2 fragments.
Type Perc Disk Usage Uncompressed Referenced
TOTAL 100% 300M 300M 300M
none 100% 300M 300M 300M
$ docker run --rm --cap-add SYS_ADMIN -v /:/host:ro alpine chroot /host /usr/bin/compsize -x "$PWD"/model.bin "$PWD"/model-copy.bin
Processed 2 files, 3 regular extents (6 refs), 0 inline, 4 fragments.
Type Perc Disk Usage Uncompressed Referenced
TOTAL 100% 300M 300M 600M
none 100% 300M 300M 600M
One file costs 300M. Both files cost 300M. Three extents, six references. On the real disk, a 2.9 GB speech model and its "copy" occupied 2.8 GB between them. Deleting the copy would have freed next to nothing.
The real 3.4 GB went to duperemove --fdupes -d, which dedupes in place. The kernel compares the bytes itself before it shares anything, and paths and contents stay as they were. Most of it was identical libraries across three JetBrains IDEs.
The rest, safest first#
The rest of the reclaim was ordinary, and here it is from the safest end. Build and dependency residue: 160 gitignored directories untouched for 30 days or more, 5.8 GB, never bin or out, never a scratch directory. Then Zig caches (4.6 GB), the Go build cache, the npm cache and its separate _npx store, IDE caches, data directories of old IDE versions, and an Arduino download cache.
Flatpak runtimes yielded nothing: every one was pinned by some app. A 7.7 GB font folder turned out to have three families actually configured; I kept the fonts and later removed two big families myself. /usr/share/locale held 1.1 GB against 61 MB for the two languages in use, trimmed with %_install_langs and a glibc langpack swap. Optional rpm apps, regional phone ROMs (only after checking that the vendor still serves them, at the same byte sizes), and two IDEs plus an AI IDE went last, the AI IDE's conversations archived first.
One number on this page won't stay put. Each IDE update writes those libraries out again as new files with extents of their own, and they stay duplicates until the next duperemove run.
Filed under: btrfs, linux, fedora, storage, docker
Read next#
- Measure real btrfs disk use without sudo: the compsize-through-Docker procedure, with the risk spelled out.
- Docker's image age is somebody else's clock: the Docker side of the same cleanup, where a build date stood in for last use.
- I kept measuring the thing next door: what I misread during the same cleanup, and the pattern behind it.
Sources#
- compsize: README ("The ioctl used requires root") and
compsize.c(die("%s: SEARCH_V2: %m\n", filename)). - compsize(8): what Disk Usage, Uncompressed and Referenced mean.
- Linux fs/btrfs/ioctl.c at v7.2:
btrfs_ioctl_tree_search_v2returns-EPERMwithoutCAP_SYS_ADMIN; defragment needs write permission on the file. - Docker post-install steps: "The docker group grants root-level privileges to the user."
- btrfs Compression: 128 KiB chunks, the incompressibility heuristic, zstd levels.
- btrfs-filesystem(8):
defragment -c/-Land the warning that defragmenting breaks reflinks;filesystem du. - GNU coreutils NEWS, release 9.0 (2021-09-24): "cp now uses --reflink=auto mode by default."
- duperemove(8):
-dand--fdupes. - ioctl_fideduperange(2): the kernel's "compare and share if identical" operation.
- chattr(1): the
mattribute excludes a file from compression. - rpm macros.in and Fedora glibc locale subpackaging:
%_install_langsand glibc langpacks.