SysOps

inode Exhaustion: The Disk Is Not Full but Writes Still Fail

The classic symptom is "No space left on device" while df -h shows only 20% used. The filesystem has run out of inodes — every file, directory, and symlink consumes one, regardless of how much data it holds. This guide explains the two independent ledgers, why small-file workloads are the danger, how to count files per directory, and how to clean up safely.

By LaoHand Team·9 min read·Updated 2026-09-30

Two independent ledgers: data blocks and inodes

Every POSIX-permission Linux filesystem maintains two resource classes: **data blocks**, which store file contents, and **inodes**, which store metadata — permissions, ownership, timestamps, size, and pointers to data blocks. The inodes themselves live on disk but their total count is **fixed at format time**.

Because the inode total is fixed, every inode can be consumed while not a single data block is used. That is the entire explanation behind "No space left on device at 20% usage": the capacity ledger is fine, the file-count ledger is exhausted.

A further trap: inode exhaustion reports the **exact same** errno as space exhaustion (ENOSPC). The kernel does not distinguish the cause, so the only reliable habit is to always read `df -h` and `df -i` together. Looking at one alone will mislead you.

Symlinks, hard links, empty files, and directories each consume one inode, including zero-byte files. That is why a 0-byte file can be the culprit: it occupies no data blocks yet still burns an inode. Enough of them will exhaust an entire filesystem.

df -h          # 数据块水位
df -i          # inode 水位,IUse% 达到 100% 即耗尽
# 两条一起看,缺一不可

Which workloads are most dangerous

① Massive small-file counts: static asset directories, build artifacts (`node_modules`, `.venv`, `target/`), backup extraction dirs. Each file burns an inode while using very few blocks — the archetypal "capacity empty, inodes full". With a 4 KB block and 1 KB of content, three quarters of the block is wasted per inode.

② Session and cache directories: PHP/Java session dirs, Redis persistence shards, application local caches (`~/.cache`), Nginx `proxy_cache`. Short file lifetimes plus fast growth mean a single batch job can write hundreds of thousands of tiny files overnight.

③ Mail and queue spools: `/var/spool/postfix`, mail queues, unarchived log segments. One file per message, several per message with attachments — steep growth under bursts.

④ Container overlay layers: every image layer and every container writable layer counts against the overlay inode budget, so a laptop running dozens of local images can hit this easily. It is easy to miss because containers are intuitively assumed to live elsewhere.

df -i /
# 典型输出:iused 100%,avail 为 0,而 df -h 仍有大量剩余容量

Locating it: counting files layer by layer

The approach mirrors big-directory hunting, replacing `du` with `find | wc -l`. Keep the "partition first, drill down later" strategy — a full-disk find on an inode-starved machine can take tens of minutes of IO.

Start with a one-level count inside the full mount. Expect a small discrepancy against the used count from `df -i` because directories themselves consume inodes; do not be confused when the numbers do not line up exactly.

Step into the largest directory and repeat; two or three rounds usually pin it down. The fastest signal is a large ratio of "file count versus apparent data size": a directory with 500,000 files occupying 200 MB must be dominated by its inode usage.

Often you do not need precise location — running `ls | wc -l` on a known suspect (session dir, cache dir, `node_modules`) confirms it immediately. Before doing this on production, make sure `find` will not trigger heavy directory reads; add `-xdev` to stay within the mount.

find /var -xdev -type f -o -type d 2>/dev/null | wc -l
# 逐层下钻,对比每个子目录的文件数
for d in /var/*; do printf "%s " "$(find "$d" -xdev 2>/dev/null | wc -l)"; echo "$d"; done | sort -rn | head

Cleaning up safely and expanding

The first rule of cleanup is "delete regenerable small files, never touch data". Caches, sessions, temp files, and build artifacts are regenerable: removing them lets the app rebuild at the cost of a brief slowdown. Database files, user uploads, and archived logs are not — even if they are small files, deleting them is a data incident.

Prefer the application cleanup command over `rm -rf`. Redis has `SCAN` for incremental non-blocking deletion, Nginx has `proxy_cache_purge`, and package managers have `apt clean` / `yum clean all`. A raw `rm -rf` over hundreds of thousands of files generates heavy directory operations that can hang or fail outright on an already inode-starved filesystem.

Confirm up front that cleanup actually frees resources: inodes are reclaimed immediately on unlink, with no restart or sync required — the same as disk blocks. So you can clean incrementally and watch `df -i` as you go.

If cleanup cannot free enough, or the file count is genuinely required by the business, the only real options are expansion or a different filesystem. ext4 fixes its inode count at format time and cannot change it online; XFS can grow online with `xfs_growfs` but its inode ceiling is likewise fixed at format time. Note that filesystems like `tmpfs` take their size from the mount option, so remounting with a larger `size=` is a fast mitigation.

du -sh /var/cache/* 2>/dev/null | sort -rh | head
apt clean
rm -rf /var/lib/app/sessions/*   # 可再生内容,可安全清理
df -i /                            # 清理后立即复查
mount -o remount,size=4G /dev/sdb1 # tmpfs 类可直接放大

Prevention: monitoring and limits

Make inode usage a first-class alert item rather than folding it into disk usage. Typical thresholds are 80% warning and 90% critical. Note that inode consumption is **step-wise**: once you are alerted, you are often close to 100%, which leaves far less reaction time than capacity exhaustion does.

Give small-file-heavy directories their own quota or mount point. Putting sessions, caches, and log spools on a separate ext4/xfs mount means exhausting them cannot take down the root partition. systemd also offers `TemporaryFileSystem=/var/cache/app`, which keeps such files in an in-memory filesystem that vanishes when the process exits.

The application owns part of the fix: change the "one file per record" pattern into batched files (merged log segments, a database instead of a file tree) or enforce a minimum file size — many exports can simply ship as a tarball. Such changes remove inode pressure structurally rather than marginally.

Finally, discipline: after every "No space left on device" incident, leave behind a one-line conclusion — capacity or inodes? The next engineer on call should be able to answer in ten seconds instead of spending an hour re-deriving it.

# 告警表达式思路:inode 使用率独立成一条规则
# node_filesystem_files_free / node_filesystem_files  < 0.1  即已耗尽
systemctl show app.service -p TemporaryFileSystem

Official References

Each command links to its official documentation below, so you can verify the latest usage and read deeper.