Filesystem Fundamentals
Inodes, journaling, VFS, and how files are actually stored
What a Filesystem Actually Is
A filesystem is a data structure laid out on top of a block device. The block device exposes a flat array of fixed-size sectors (typically 512 B or 4 KiB); the filesystem adds meaning on top of that — a mapping from human-readable names to ranges of blocks, plus metadata describing ownership, timestamps, permissions, and integrity.
The Core Idea
A filesystem turns a block device into a namespace. Without it, a disk is just 240+ numbered sectors. With it, you get /etc/passwd, directory trees, permissions, and the ability to ask "is this file bigger than that one?" without walking every sector.
Path
/var/log/syslog
Dentry
name → inode #
Inode
metadata + block ptrs
Data blocks
actual bytes on disk
The Core On-Disk Structures
Superblock
A single structure (with backup copies) describing the filesystem as a whole. Located at a well-known offset so the kernel can find it during mount().
- Magic number — identifies the filesystem type (e.g.
0xEF53for ext2/3/4). Without it, the kernel doesn't know which driver to invoke. - Total size, block size, block count — geometry of the filesystem.
- Free block / free inode counters — quick stats.
- Mount count, last mount time, last fsck time, state flag — used to decide when to force an fsck.
- UUID and label — stable identifiers independent of device path.
Inode (Index Node)
One inode per file. It holds everything about the file except its name. Names live in directory entries, not in the inode itself — this is why hard links are cheap (multiple dentries pointing to the same inode).
What's Inside an Inode
| Field | Purpose |
|---|---|
i_mode | File type (regular, dir, symlink, block dev…) + permissions |
i_uid, i_gid | Owner user/group |
i_size | File size in bytes |
i_atime, i_mtime, i_ctime | Access / modify / change (inode-change) timestamps |
i_links_count | Hard link count — file is freed when this hits zero |
i_blocks | Number of 512 B sectors actually allocated |
i_block[] / extent tree | Pointers to the data blocks |
i_flags | Immutable, append-only, no-dump, encrypted, etc. |
Crucially, the filename is not in the inode. An inode doesn't know what it's called. If you ls -li, the leftmost column is the inode number — and two directory entries can point at the same inode (hard link).
Directory Entry (dentry)
A directory is just a special file whose "contents" are a list of (name, inode number) pairs. The Linux kernel also caches these in memory as the dentry cache to accelerate path lookups — walking /a/b/c/d would otherwise require four disk reads per lookup.
Block Groups / Allocation Groups
Rather than keeping all metadata in one corner of the disk (disastrous if that region fails, and terrible for locality), modern filesystems split the device into independent regions:
Fixed-size regions (typically 128 MiB). Each contains: a superblock copy (optional), group descriptor, block bitmap, inode bitmap, inode table, data blocks.
Goal: keep an inode and its data blocks in the same group to minimize seeks.
Larger, fewer, independent regions. Each AG maintains its own free-space B+trees and inode B+tree. Allocations in different AGs proceed in parallel — core to XFS's concurrency story.
Inodes in Depth
Extent Trees vs Indirect Block Pointers
How does an inode point to a potentially huge file's data?
The inode holds 12 direct pointers, 1 single-indirect, 1 double-indirect, 1 triple-indirect. Large files require traversing up to three levels of pointer blocks. Wastes space for big sequential files.
# 1 TB file = millions of pointer entries
# each indirect lookup = extra readAn extent describes a contiguous range: (logical offset, physical block, length). One extent can cover up to 128 MiB in ext4. Massively reduces metadata overhead for large, contiguous files.
# 1 TB file, unfragmented:
# ~8 extent records totalInode Exhaustion
On ext filesystems, inodes are pre-allocated at mkfs time. The default is roughly one inode per 16 KiB of filesystem capacity. On a workload full of tiny files (Maildir, node_modules, container image layers), you can run out of inodes while still having free blocks.
$ df -i /var
Filesystem Inodes IUsed IFree IUse% Mounted on
/dev/sda1 6553600 6553600 0 100% /var
$ # ENOSPC despite "df" showing free space
Warning
Gotcha: df shows free blocks; df -i shows free inodes. Running out of either produces ENOSPC. XFS dynamically allocates inodes, so this class of bug effectively doesn't exist there.
Data Blocks, Allocation & Fragmentation
When a write extends a file, the allocator must pick new blocks. Strategies:
- Block bitmaps (ext) — a bit per block, scanned linearly with locality hints.
- Free-space B+trees (XFS, btrfs) — scale to petabytes; O(log n) lookups.
- Delayed allocation (ext4, XFS) — don't pick blocks until
fsyncor writeback; lets the allocator see the full write size and place it contiguously.
Fragmentation is when a file's blocks are scattered rather than contiguous. On spinning rust, this hurts sequential read throughput badly. On flash it matters far less — SSDs have no seek penalty — but extent maps still grow and consume cache.
Journaling: Crash Consistency
A write typically touches multiple structures: inode, block bitmap, data blocks, directory entry. If the machine crashes mid-write, the filesystem can end up inconsistent — e.g. a block marked allocated in the bitmap but not referenced by any inode.
A journal (also called a log) solves this by writing intended changes to a dedicated region first, then applying them to the main filesystem. On crash recovery, unfinished transactions are either replayed or discarded.
Begin transaction
Write changes to journal
Commit record (barrier)
Checkpoint to main FS
Free journal space
ext4 Journal Modes
| Mode | What's journaled | Trade-off |
|---|---|---|
data=journal |
Both metadata and file data | Strongest consistency. Every block written twice — slow. Used for database-like workloads that demand full durability. |
data=ordered (default) |
Metadata only; data is forced to disk before the metadata commit that references it | No "old metadata sees new data" windows. Balanced performance. The sane default. |
data=writeback |
Metadata only; data can be written after the metadata commit | Fastest. Post-crash, a file's metadata may point at blocks that haven't been written yet — returning stale or garbage contents. Rarely worth the risk. |
Note
Note: A journal protects filesystem structure, not necessarily your application data. If you need an atomic rename or a durable write, use rename() + fsync() — the filesystem will not do it for you implicitly.
VFS — The Virtual Filesystem
Linux supports dozens of filesystem types. User-space code doesn't care: open(), read(), write(), stat() all behave the same whether the backing store is ext4, XFS, NFS, FUSE, tmpfs, or procfs. This is the VFS layer — a kernel abstraction that presents a uniform object-oriented interface over every concrete filesystem.
Each concrete filesystem registers a struct file_operations (and inode/super/address-space op tables). When you call read(fd, …), the kernel resolves fd to a struct file, finds its f_op->read_iter, and dispatches into the filesystem driver. It's vtables, all the way down.
Mounting
Mounting attaches a filesystem to a point in the VFS namespace. The mount(2) syscall takes a source (device or spec string), a target directory, a filesystem type, flags, and type-specific options.
# mount -t ext4 -o noatime,discard /dev/sda1 /mnt/data
# mount -t xfs -o noatime,logbsize=256k /dev/sdb1 /srv
# mount -t tmpfs -o size=2G tmpfs /run/cache
Bind Mounts
A bind mount exposes an existing directory subtree at another location, without copying. Essential for chroot/container setups.
# mount --bind /var/log /mnt/chroot/var/log # flat bind
# mount --rbind /dev /mnt/chroot/dev # recursive: includes /dev/pts, /dev/shm...
Mount Propagation Types
Mounts live inside mount namespaces. When a namespace is cloned, each mount carries a propagation flag controlling whether subsequent mount/umount events cross the namespace boundary.
| Type | Behavior |
|---|---|
shared | Mount events propagate in both directions between peers. Default for systemd's root. |
slave | Receives events from the master, doesn't send them back. Used for container runtimes that want to see host mounts but not leak their own. |
private | No propagation in either direction. Isolated. |
unbindable | Private + cannot be bind-mounted elsewhere. Rare. |
Note
Why this matters: Docker's default rshared on / is why a mount inside a privileged container can leak onto the host. runc/containerd carefully set propagation to avoid this for unprivileged containers.
/etc/fstab
The mount manifest, read at boot by systemd (via systemd-fstab-generator). Six whitespace-separated fields per line:
# <source> <mountpoint> <type> <options> <dump> <pass>
UUID=3f9a1b6e-...-01 / ext4 defaults,errors=remount-ro 0 1
UUID=6eba52a0-...-02 /home xfs defaults,noatime 0 2
LABEL=swap none swap sw 0 0
/dev/vg_data/lv_backup /backup xfs noauto,x-systemd.automount 0 0
tmpfs /tmp tmpfs size=4G,noexec,nosuid,nodev 0 0
192.0.2.10:/export/share /mnt/nfs nfs rw,_netdev,nofail,x-systemd.after=network-online.target 0 0
Source Identifier — Pick Wisely
| Form | Stability | When to use |
|---|---|---|
/dev/sda1 | Low — kernel enumeration order changes when you add/remove disks | Never, for production |
PARTUUID= | High — GPT partition GUID; stable across FS re-creation | Boot partitions; when you may mkfs again |
UUID= (preferred) | High — filesystem-level UUID; stable until reformat | The default for most fstab entries |
LABEL= | Medium — human-set, collision-prone | Rescue USBs, disposable volumes |
Important Option Flags
nofail— don't block boot if the source is missing. Essential for non-critical mounts.noauto— don't mount at boot; mount explicitly later.x-systemd.automount— lazy-mount on first access (fuse-style behavior via systemd).x-systemd.after=,_netdev— ordering hints, especially for networked filesystems.noatime,relatime,strictatime— control atime updates. Most modern distros userelatimeby default.dumpfield — historical, used by thedump(8)backup tool. Almost always0.passfield — fsck order at boot.1for root,2for other disks,0to skip.
Durability: fsync, fdatasync, O_DIRECT, Barriers
A successful write(2) means the kernel accepted your bytes into the page cache — not that they hit stable storage. Between you and the platter sit: page cache, filesystem journal, block layer queues, device write caches. Databases and message brokers care intensely about the details.
| Call | Guarantee |
|---|---|
write() | Data in page cache. Visible to other readers. Could evaporate on crash. |
fsync(fd) | File data and metadata flushed to stable storage. Slow but durable. |
fdatasync(fd) | Data + metadata needed for retrieval (size, but not mtime). Avoids one metadata round-trip. Preferred for databases appending to a log. |
sync | Flushes everything system-wide. Returns after dirty pages are queued (not necessarily stable). |
O_DIRECT | Bypass the page cache entirely. You own alignment. Used by Oracle, PostgreSQL with direct_io, and big DBs to avoid double-buffering. |
O_SYNC | Every write is effectively followed by fsync. Very slow. |
Write Barriers
A barrier instructs the storage stack: "don't reorder any write after this point ahead of writes before it." Modern kernels implement this with FLUSH and FUA (Force Unit Access) SCSI/NVMe commands. Barriers are on by default on journaled filesystems. Turning them off (nobarrier) buys throughput at the cost of possibly corrupting your journal on power loss — only safe on battery-backed RAID controllers.
Warning
Cheap SSDs and consumer disks frequently lie about FLUSH — they acknowledge the command before data is actually on non-volatile storage. This is the root cause of many "my database corrupted after an unclean shutdown" post-mortems. Enterprise drives with power-loss protection don't lie.
fsck: The Filesystem Checker
fsck walks the filesystem structures, detects inconsistencies, and repairs them. It's both a safety net and a footgun.
- Runs at boot via the
passcolumn of fstab. - Triggered every N mounts or T days on ext filesystems — tune with
tune2fs -c <count> -i <interval>. - Force a check:
fsck -f /dev/sdaX. - For XFS, the userspace tool is
xfs_repair, and XFS also does metadata-only log replay at mount.
Warning
Never run fsck on a mounted filesystem. It reads and writes metadata structures out from under the live kernel driver — guaranteed corruption. Unmount or boot into single-user/rescue mode first. Read-only mounts still risk correct diagnosis because in-flight metadata may not match disk.
Pseudo-Filesystems
Not every filesystem is backed by a block device. Several kernel filesystems exist purely to present kernel state or RAM through the familiar file API.
| FS | Backed by | Purpose |
|---|---|---|
tmpfs | RAM (swappable) | Fast scratch space. /run, /tmp on many distros, Kubernetes emptyDir medium: Memory. |
proc | Kernel accessor functions | Process-level info: /proc/<pid>/maps, /proc/cpuinfo, /proc/mounts. |
sys | Kernel object model (kobjects) | Device / driver / subsystem attributes. Tuning lives here: /sys/block/sda/queue/scheduler. |
devtmpfs | Kernel-managed RAM | Device nodes under /dev. Created automatically as hardware appears. Replaces the old static /dev. |
cgroup / cgroup2 | Kernel cgroup subsystem | Resource control tree. cgroup v2 is the unified hierarchy — /sys/fs/cgroup/ on modern systems. |
overlay | Two or more underlying directories | Stacked read-only lower + read-write upper. The backbone of container images (Docker, containerd). |
fusefs | User-space daemon | Implement filesystems without kernel code. sshfs, rclone-mount, encfs. |
nfs, cifs, 9p | Network | Remote filesystems exposed via the VFS. Same read()/write(), different backend. |
Tip
Why this design is elegant: the VFS lets "everything is a file" scale from /etc/hostname on ext4 to /sys/fs/cgroup/cpu.max in kernel memory to /mnt/s3/object served by a FUSE daemon — all reachable with the same syscalls.
Takeaways
- Filesystems impose structure on a flat block device: superblock → block groups → inodes → dentries → data.
- An inode is metadata + pointers; the filename is stored in the parent directory's dentry list.
- Extents replaced indirect blocks — big win for large contiguous files.
- ext pre-allocates inodes; XFS allocates them dynamically. Plan accordingly on small-file workloads.
- Journaling protects filesystem structure. For application durability, use
fsync/fdatasync. - VFS is the kernel abstraction that makes ext4, NFS, and procfs look identical to user space.
- Mount namespaces + propagation types are what make container runtimes possible.
- Prefer
UUID=in fstab. Addnofailto optional mounts. - Never run
fsckon a mounted filesystem.