Skip to content
Menu

Linux Storage11 min read

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.

  1. Path

    /var/log/syslog

  2. Dentry

    name → inode #

  3. Inode

    metadata + block ptrs

  4. 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. 0xEF53 for 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

FieldPurpose
i_modeFile type (regular, dir, symlink, block dev…) + permissions
i_uid, i_gidOwner user/group
i_sizeFile size in bytes
i_atime, i_mtime, i_ctimeAccess / modify / change (inode-change) timestamps
i_links_countHard link count — file is freed when this hits zero
i_blocksNumber of 512 B sectors actually allocated
i_block[] / extent treePointers to the data blocks
i_flagsImmutable, 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:

ext2/3/4 — Block Groups

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.

XFS — Allocation Groups (AGs)

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?

Indirect blocks (ext2/3)

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.

console
# 1 TB file = millions of pointer entries
# each indirect lookup = extra read
Extents (ext4, XFS, btrfs)

An 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.

console
# 1 TB file, unfragmented:
# ~8 extent records total

Inode 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.

console
console

$ 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 fsync or 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.

  1. Begin transaction

  2. Write changes to journal

  3. Commit record (barrier)

  4. Checkpoint to main FS

  5. Free journal space

ext4 Journal Modes

ModeWhat's journaledTrade-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.

User-space open() read() write() stat() unlink() — libc → syscalls
VFS struct inode / struct file / struct dentry / struct super_block
Concrete FS ext4 / xfs / btrfs / nfs / fuse / tmpfs / overlayfs
Page cache + block layer bio submission, I/O scheduler, device-mapper
Block device NVMe / SATA / virtio-blk / iSCSI LUN

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.

console
console

# 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.

console
console

# 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.

TypeBehavior
sharedMount events propagate in both directions between peers. Default for systemd's root.
slaveReceives events from the master, doesn't send them back. Used for container runtimes that want to see host mounts but not leak their own.
privateNo propagation in either direction. Isolated.
unbindablePrivate + 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:

console
console

# <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

FormStabilityWhen to use
/dev/sda1Low — kernel enumeration order changes when you add/remove disksNever, for production
PARTUUID=High — GPT partition GUID; stable across FS re-creationBoot partitions; when you may mkfs again
UUID= (preferred)High — filesystem-level UUID; stable until reformatThe default for most fstab entries
LABEL=Medium — human-set, collision-proneRescue 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 use relatime by default.
  • dump field — historical, used by the dump(8) backup tool. Almost always 0.
  • pass field — fsck order at boot. 1 for root, 2 for other disks, 0 to 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.

CallGuarantee
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.
syncFlushes everything system-wide. Returns after dirty pages are queued (not necessarily stable).
O_DIRECTBypass the page cache entirely. You own alignment. Used by Oracle, PostgreSQL with direct_io, and big DBs to avoid double-buffering.
O_SYNCEvery 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 pass column 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.

FSBacked byPurpose
tmpfsRAM (swappable)Fast scratch space. /run, /tmp on many distros, Kubernetes emptyDir medium: Memory.
procKernel accessor functionsProcess-level info: /proc/<pid>/maps, /proc/cpuinfo, /proc/mounts.
sysKernel object model (kobjects)Device / driver / subsystem attributes. Tuning lives here: /sys/block/sda/queue/scheduler.
devtmpfsKernel-managed RAMDevice nodes under /dev. Created automatically as hardware appears. Replaces the old static /dev.
cgroup / cgroup2Kernel cgroup subsystemResource control tree. cgroup v2 is the unified hierarchy — /sys/fs/cgroup/ on modern systems.
overlayTwo or more underlying directoriesStacked read-only lower + read-write upper. The backbone of container images (Docker, containerd).
fusefsUser-space daemonImplement filesystems without kernel code. sshfs, rclone-mount, encfs.
nfs, cifs, 9pNetworkRemote 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. Add nofail to optional mounts.
  • Never run fsck on a mounted filesystem.
Solidnines — solidnines.com