Linux · lesson 7 of 7
Beginner
2Intermediate
5- 2 Permission Bits, Ownership, and Why passwd Needs setuid
- 3 A Zombie Process That Never Gets Reaped — and the PID 1 That Fixes It
- 4 systemd Service Supervision: Restart Limits and Why They Trip
- 5 Linux Networking: Routes, Interfaces, and Bind Addresses
- 7 Inodes: The Filesystem Is Full, but df -h Says 1% Used
Also part of Linux
7- + Bash Text Processing from Bash
- + Bash Process Management from Bash
- + Bash System Information from Bash
- + Cron and Scheduling in Bash from Bash
- + Bash File Operations from Bash
- + Reading Linux SSH Auth Logs: Failed Logins You Generate Yourself from Defensive Security
- + Host Firewalls with nftables: DROP Times Out, REJECT Refuses from Networking
Inodes: The Filesystem Is Full, but df -h Says 1% Used
A real No space left on device error on a filesystem with 11MB free, caused by exhausting inodes rather than disk blocks — plus what hard links and symlinks reveal about what an inode actually is.
By SkillByExample · Published
df -h reporting 99% free space says nothing about whether the next touch will succeed —
a filesystem tracks two separate resources, and only one of them shows up in that command.
Building a filesystem with almost no inodes
docker run -d --name fs-lesson --privileged ubuntu:22.04 sleep infinity
docker exec fs-lesson bash -c "dd if=/dev/zero of=/tmp/inodefs.img bs=1M count=16 && mkfs.ext4 -N 64 -F /tmp/inodefs.img"
docker exec fs-lesson bash -c "mkdir -p /mnt/tiny && mount -o loop /tmp/inodefs.img /mnt/tiny"
docker exec fs-lesson df -i /mnt/tiny
Filesystem Inodes IUsed IFree IUse% Mounted on
/dev/loop2 64 11 53 18% /mnt/tiny
mkfs -N 64 forced this 16MB filesystem to have only 64 inodes total, instead of the
thousands ext4 would normally allocate for that size — 11 are already claimed by the
filesystem’s own root directory and reserved structures.
The bug: out of inodes, not out of space
docker exec fs-lesson df -h /mnt/tiny
docker exec fs-lesson bash -c "for i in \$(seq 1 60); do touch /mnt/tiny/file\$i || break; done"
Filesystem Size Used Avail Use% Mounted on
/dev/loop2 12M 24K 11M 1% /mnt/tiny
touch: cannot touch '/mnt/tiny/file54': No space left on device
1% Use on the space check, and yet the 54th empty file — each using essentially zero bytes
of actual data — fails with the same error text a genuinely full disk would give.
docker exec fs-lesson df -h /mnt/tiny
docker exec fs-lesson df -i /mnt/tiny
Filesystem Size Used Avail Use% Mounted on
/dev/loop2 12M 24K 11M 1% /mnt/tiny
Filesystem Inodes IUsed IFree IUse% Mounted on
/dev/loop2 64 64 0 100% /mnt/tiny
Same filesystem, two different pictures: df -h still shows 1%, while df -i shows
100%. Every file — no matter how small — consumes exactly one inode, and this filesystem
was built with only 64 of them to begin with.
The fix: free inodes, not space
docker exec fs-lesson bash -c "rm /mnt/tiny/file1 /mnt/tiny/file2 /mnt/tiny/file3 /mnt/tiny/file4 /mnt/tiny/file5"
docker exec fs-lesson df -i /mnt/tiny
docker exec fs-lesson bash -c "touch /mnt/tiny/newfile && echo TOUCH_OK"
Filesystem Inodes IUsed IFree IUse% Mounted on
/dev/loop2 64 59 5 93% /mnt/tiny
TOUCH_OK
Deleting 5 files freed 5 inodes regardless of how little data they held — the fix for
“disk full” and the fix for “inodes full” look identical (rm), but only one of them
actually helps here; deleting a single large file on this same filesystem would have freed
disk blocks while leaving IUse% at 100%.
What an inode actually is: hard links vs symlinks
docker exec fs-lesson bash -c "cd /tmp && echo 'original content' > original.txt && ln original.txt hardlink.txt && ln -s original.txt symlink.txt && ls -li original.txt hardlink.txt symlink.txt"
367367 -rw-r--r-- 2 root root 17 Sep 14 06:30 hardlink.txt
367367 -rw-r--r-- 2 root root 17 Sep 14 06:30 original.txt
367405 lrwxrwxrwx 1 root root 12 Sep 14 06:30 symlink.txt -> original.txt
original.txt and hardlink.txt share inode 367367 and both show link count 2 — they
are two directory entries pointing at the same underlying file, not two copies. symlink.txt
has its own separate inode (367405) that just stores the text original.txt.
docker exec fs-lesson bash -c "cd /tmp && rm original.txt && cat hardlink.txt; echo ---; cat symlink.txt"
original content
---
cat: symlink.txt: No such file or directory
rm only removed one directory entry and dropped inode 367367’s link count from 2 to 1 —
the data stays until the count hits zero, so hardlink.txt reads fine. symlink.txt never
referenced that inode at all, only the now-gone name original.txt, so it’s left dangling.
Practice
1. Confirm the hard link's count dropped after the original was removed.
docker exec fs-lesson bash -c "ls -li /tmp/hardlink.txt"
367367 -rw-r--r-- 1 root root 17 Sep 14 06:30 /tmp/hardlink.txt
Link count is now 1, down from 2 — one more rm /tmp/hardlink.txt would finally drop it
to 0 and actually free the inode and its data, which is the real rule rm follows: delete
a name, and free the data only when nothing names it anymore.
2. Check the total inode capacity a normal-sized ext4 filesystem gets, without forcing it low.
docker exec fs-lesson bash -c "dd if=/dev/zero of=/tmp/normalfs.img bs=1M count=16 2>/dev/null && mkfs.ext4 -F /tmp/normalfs.img 2>&1 | grep inodes"
Creating filesystem with 4096 4k blocks and 4096 inodes
The exact same 16MB size, formatted without -N, gets 4096 inodes by default — 64 times more
than this lesson’s deliberately constrained filesystem. Hitting real inode exhaustion in
practice almost always means either millions of tiny files (a cache or session-file directory
that never cleans up) rather than a filesystem this artificially small.
3. Find which directory is actually consuming the exhausted inodes.
docker exec fs-lesson df -i /mnt/tiny
docker exec fs-lesson bash -c "find /mnt/tiny -printf '.' | wc -c"
Filesystem Inodes IUsed IFree IUse% Mounted on
/dev/loop2 64 60 4 94% /mnt/tiny
51
IUsed (60) and the raw find walk (51) don’t match, and that gap is itself informative:
ext4 reserves a handful of low inode numbers for internal bookkeeping — the journal, bad-block
list, ACL support — that df -i counts but that never appear as a directory entry find can
walk. On a large filesystem, running this same find -printf '.' | wc -c per top-level
subdirectory (instead of a single count for the whole mount) is the real move to localize
which directory is actually responsible for exhausted inodes.
Next: back to the DevOps track’s automation tools, or revisit bash for script-level Linux
administration patterns this topic deliberately left to it.