Filesystem & Storage
Demystify the Linux storage stack by tracing pathnames through the Virtual File System (VFS), directory entry caches, and inode tables down to physical block devices.
What this chapter builds
This chapter turns the “everything is a file” shortcut into a precise model of names, open handles, cached pages, filesystems, mounts, and block devices.
By the end, you can
- Trace pathname lookup through dentries and inodes
- Distinguish a directory entry from the open file it names
- Diagnose block, inode, and open-unlinked exhaustion
- Expand a cloud volume through every required layer
Objects to keep straight
dentryinodestruct filepage cachemountblock deviceApplied task: Explain why df reports a full filesystem after a deleted log disappears from du.
Before looking at the mechanics, what actually is a File or an Inode?
What are the foundational primitives of Linux Storage?
File Descriptor (FD)
When a process opens a file, the kernel hands it an integer (like 3 or 4) called a File Descriptor. The process uses this ticket to read or write data. When it closes the file, the ticket is destroyed.
VFS (Virtual File System)
Linux supports many filesystems (ext4, XFS, NFS). The VFS is a unified abstraction layer. User programs just call read() or write(), and the VFS translates it for the specific underlying disk format.
Inode
A file name is just a string in a directory. The ACTUAL file is an Inode—a data structure storing permissions, ownership, timestamps, and pointers to where the actual data blocks live on the hard drive.
Mount
Unlike Windows (C:\, D:\), Linux has one single directory tree starting at "/". Disks and network drives are "mounted" (attached) as subdirectories (like /mnt/usb) onto that single tree.
Storage in Linux is heavily abstracted to make everything look like a unified tree. A file path (like /var/log/syslog) is just a human-friendly pointer to an Inode. Processes never interact with Inodes or disk blocks directly; they interact with File Descriptors. The kernel handles all the complex translation in the background.
Model scope. “Everything is a file” is shorthand for a broad descriptor-based interface. Device nodes and procfs entries participate in VFS pathname operations, while sockets usually enter a process through socket()/accept() rather than a filesystem pathname.
Inode: 131075
lrwx------ 1 root root 64 0 -> /dev/pts/0 lrwx------ 1 root root 64 1 -> /dev/pts/0
Directories are in-memory lookup maps; metadata lives in Inodes.
How does the Linux kernel translate a path string into disk sectors?
All steps are visible. Advance to inspect one transition.
When a process invokes open(), the kernel Virtual File System (VFS) resolves the path by walking directory entries (dentries) component by component. Dentries are cached in RAM (dcache). The leaf dentry points to an Inode number. The Inode contains all file metadata (permissions, owner, size, timestamps) and an extent tree mapping logical file offsets to physical block sectors on the underlying block device.
Model scope. The dentry cache (dcache) and inode cache are dynamically shrunk by kernel kswapd under memory pressure, which can cause sudden latency spikes if uncached lookups hit disk.
File: /srv/app/config Size: 4096 Blocks: 8 IO Block: 4096 regular file Device: 259,1 Inode: 262145 Links: 1 Access: (0644/-rw-r--r--) Uid: (33/www-data) Gid: (33/www-data)
131072 /srv 262144 /srv/app 262145 /srv/app/config
Stitching independent filesystems into a single unified directory tree.
How do different disks and virtual filesystems connect under a single / root?
Linux exposes all storage devices under a single unified root directory (/). When mount() is invoked, the kernel creates a struct mount linking a target directory dentry to the root dentry of the mounted filesystem. Any previous files residing in the target directory become shadowed (hidden) until unmounted. The kernel VFS intercepts lookups crossing mount points and transparently switches to the child filesystem driver.
Model scope. Containers use mount namespaces and bind mounts (mount --bind) to expose specific host directories or volumes inside container root filesystems.
TARGET SOURCE FSTYPE OPTIONS /srv /dev/vdb1 ext4 rw,relatime,errors=remount-ro
NAME FSTYPE FSVER LABEL UUID FSAVAIL FSUSE% MOUNTPOINTS vdb └─vdb1 ext4 1.0 9b31d87e-45a1-432e-9d21-4f1122334455 48.2G 4% /srv
Inodes, hard links, and symlinks: understanding POSIX file semantics.
What is the structural difference between a hard link and a symbolic link?
Direct inode holding data block pointers via ext4 extent trees.
regular file 262145 1
A second directory entry pointing to the EXACT same Inode number. Deleting one leaves data intact until nlink drops to 0.
262145 2 262145 2 # Same inode!
An independent inode containing the target path string as data. Breaks if target is moved or deleted.
lrwxrwxrwx 1 root root 10 current -> index.html
Kernel interface providing buffered random access to fixed-size blocks (NVMe, SSD).
brw-rw---- 1 root disk 259, 1 /dev/vdb1
Kernel interface providing unbuffered byte-stream access (TTYs, /dev/urandom).
crw-rw-rw- 1 root root 1, 9 /dev/urandom
The first character of ls -l indicates the file type. A hard link creates an additional directory entry pointing to an existing inode, incrementing its link counter (nlink). Hard links cannot span across different filesystems because inode numbers are only unique within a single filesystem. In contrast, a symbolic link (symlink) is an independent file whose data payload is simply a path string pointing to another target.
Model scope. Directories cannot have hard links created by user processes to prevent directory loops in the filesystem DAG.
drwxrwxrwt 15 root root 4096 Sep 18 19:40 . drwxr-xr-x 19 root root 4096 Sep 18 12:00 .. srwxrwxrwx 1 mysql mysql 0 Sep 18 12:05 mysql.sock -rw------- 1 root root 123 Sep 18 12:10 test.log
regular file links:1 inode:262145
Two independent exhaustion vectors: Data Blocks vs Inode Slots.
Why does a write fail with "No space left on device" when df -h shows 50% free?
Default fixture: Filesystems like ext4 preallocate a fixed number of inodes during mkfs. Every file, directory, symlink, or socket consumes exactly one inode, regardless of file size. When an application generates millions of zero-byte or tiny files (e.g., PHP sessions, cache tokens, or build artifacts), all inode slots become consumed long before disk storage is full. The kernel returns ENOSPC (No space left on device) for any new touch or mkdir.
Filesystems like ext4 preallocate a fixed number of inodes during mkfs. Every file, directory, symlink, or socket consumes exactly one inode, regardless of file size. When an application generates millions of zero-byte or tiny files (e.g., PHP sessions, cache tokens, or build artifacts), all inode slots become consumed long before disk storage is full. The kernel returns ENOSPC (No space left on device) for any new touch or mkdir.
Model scope. Modern filesystems like XFS and Btrfs dynamically allocate inodes as needed and do not suffer from the rigid fixed-inode limitation of classic ext4.
Filesystem Size Used Avail Use% Mounted on /dev/vdb1 50G 25G 25G 50% /srv
Filesystem Inodes IUsed IFree IUse% Mounted on /dev/vdb1 3276800 3276800 0 100% /srv
The phantom disk leak: unlinked files held open by running processes.
Why does df still show 100% full after deleting a huge 20GB log file?
| Path | Points to Inode |
|---|---|
/srv/log/access.log | 262150 |
| Process | FD | Points to Inode |
|---|---|---|
| nginx (421) | 14 (w) | 262150 |
Click rm access.log to see why space isn't freed when a file is deleted while still open.
Linux separates directory entries from physical storage blocks using two distinct counters: the link count (i_nlink in the inode) and the open file descriptor count (f_count in the kernel). Running rm only unlinks the directory entry (setting nlink to 0). du inspects the directory hierarchy, so it will no longer find the file. However, because the daemon still has the file open, the kernel preserves the inode and disk blocks until the process closes the file descriptor or terminates.
Model scope. If you already deleted the file path, you can reclaim space without restarting by truncating via procfs: : > /proc/<pid>/fd/<fd>.
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NLINK NODE NAME nginx 421 www-data 14w REG 259,1 21474836480 0 262150 /srv/log/access.log (deleted)
# Space reclaimed instantly without restarting service: 20GB freed on /srv
From raw NVMe block device to persistent production mount.
How do you safely partition, format, and mount storage across host reboots?
Action 01: Inspect raw block device topology
vdb 50G disk └─vdb1 50G part /srv
Field Note: Verify the device name and ensure it does not already contain an active filesystem.
# Partition 1 created spanning entire 50GB volume
Field Note: GPT is standard for modern drives; parted is scriptable compared to interactive fdisk.
Creating filesystem with 13107200 4k blocks Allocating group tables: done Writing inode tables: done
Field Note: Allocates superblock, inode tables, and block groups.
/dev/vdb1: UUID="9b31d87e-45a1-432e-9d21-4f1122334455" BLOCK_SIZE="4096" TYPE="ext4"
Field Note: Always mount by UUID. Device names (/dev/vdb) can change across reboots or PCI reordering.
# Persistent mount entry appended to /etc/fstab
Field Note: The nofail option ensures the machine boots even if an external cloud volume fails to attach.
# Zero output indicates syntax and target directories are valid
Field Note: ALWAYS run mount -a before rebooting. A broken /etc/fstab will drop the server into emergency mode.
Storage provisioning follows five deterministic layers: raw block device → partition table (GPT) → filesystem structures (ext4/XFS) → target mount point dentry → /etc/fstab automation. Mounting by filesystem UUID eliminates catastrophic data corruption caused by non-deterministic device enumeration (e.g. /dev/sda becoming /dev/sdb).
Model scope. Filesystem check order (the 6th field in fstab): 1 for root (/), 2 for other local drives, 0 to skip fsck (e.g. XFS or network mounts).
TARGET SOURCE FSTYPE OPTIONS /srv UUID=9b31d87e-45a1-432e-9d21-4f1122334455 ext4 defaults,nofail
9b31d87e-45a1-432e-9d21-4f1122334455
Online volume expansion: hypervisor, partition table, and filesystem.
Why does df still show the old size after expanding an EBS/cloud volume?
Action 01: Expand virtual disk in cloud console/API
{
"VolumeModification": { "TargetSize": 100, "Progress": 0, "ModificationState": "modifying" }
}Field Note: The cloud hypervisor expands the backing virtual block device without unmounting.
NAME MAJ:MIN RM SIZE RO TYPE MOUNTPOINTS vdb 259:0 0 100G 0 disk └─vdb1 259:1 0 50G 0 part /srv
Field Note: Notice: disk is 100G, but partition vdb1 is still 50G. The OS does not auto-grow partitions.
CHANGED: partition=1 start=2048 old: size=104855552 end=104857600 new: size=209713119 end=209715167
Field Note: Rewrites GPT/MBR partition table in memory and on disk to claim remaining contiguous sectors.
resize2fs 1.46.5 Filesystem at /dev/vdb1 is mounted on /srv; on-line resizing required old_desc_blocks = 7, new_desc_blocks = 13 The filesystem on /dev/vdb1 is now 26214139 (4k) blocks long.
Field Note: Expands filesystem superblock, block groups, and allocation bitmaps live while mounted (use xfs_growfs for XFS).
Filesystem Size Used Avail Use% Mounted on /dev/vdb1 99G 25G 74G 25% /srv
Field Note: All newly provisioned 50GB storage is now immediately available to applications.
Expanding storage requires notifying three distinct layers in order: 1. Cloud hypervisor increases block device size; 2. Partition table is expanded via growpart to encompass the new sector range; 3. Filesystem driver is instructed via resize2fs or xfs_growfs to extend its block groups and superblocks across the newly available partition space.
Model scope. NVMe devices on newer EC2 instances use names like /dev/nvme1n1p1. growpart syntax requires a space before partition number: sudo growpart /dev/nvme1n1 1.
Filesystem Size Used Avail Use% Mounted on /dev/vdb1 99G 25G 74G 25% /srv
NAME SIZE MOUNTPOINT vdb 100G └─vdb1 100G /srv