A file is not its name

A guided walk through pathnames, inodes, the page cache, sparse files, durability, and why a microVM root disk is a filesystem inside a file.

You type:

cat /srv/app/config.json

No disk contains a thing called /srv/app/config.json. Storage holds numbered blocks. Between the string and those blocks, Linux has to decide which root / you mean, what each name refers to, whether a symlink or mount changes the walk, which filesystem should answer, whether the bytes are already in memory, and where the file’s logical offsets live on the device.

The useful mental model is not “folders contain files.” It is a pipeline:

pathname → dentry → inode → page cache → logical extent → block device

Each arrow is an indirection. Confusing them is how deleted files keep consuming space, renames vanish after a crash, sparse images look larger than their disks, and a microVM root gets mistaken for a host directory.

This lesson walks that pipeline top to bottom. Terms show up before the terminal uses them. Every session has Copy and Replay. On macOS some /proc tools differ — use the macOS toggle where you see one.

By the end you should be able to say, in plain language: what a pathname does, what an inode is, why an open-but-unlinked file still uses space, why ls -l and du disagree, when write() is not durable, and why a guest root disk is a filesystem nested inside a host file.

1. A pathname is an instruction to search

A pathname is not a permanent identity. It tells the kernel where to begin and which sequence of names to resolve.

An absolute path begins with /, but even / is contextual. A process has a filesystem root, a current working directory, and a mount namespace. Containers can share a kernel and still see different trees.

A relative path starts from the cwd — or from a directory file descriptor with the *at() family:

int dir = open("/srv/app", O_RDONLY | O_DIRECTORY);
int fd = openat(dir, "config.json", O_RDONLY);

That is more than sugar. A directory fd is a stable starting point that another thread cannot silently move out from under you by calling chdir.

Resolve one component at a time

For /srv/app/config.json, Linux starts at this process’s root and looks up srv, then app, then config.json. Along the way it can hit:

  • a symlink — contents replace part of the remaining path
  • a mountpoint — the walk crosses into another filesystem’s root
  • . / .., permission failures, or a missing name

The final object need not be a regular file. The same walk can reach a directory, socket, device node, FIFO, or the symlink itself.

Session · pathname lookup

Resolve one name at a time

A pathname is a search program. Start at a root or cwd, walk components, follow links, maybe cross a mount.

absolute path

Words you just saw

inode number
Filesystem-local identity for an object. Same number ⇒ same inode *within that filesystem*.
dentry
Cached “name in this parent → inode (or not found)” result from a previous lookup.
mountpoint
Directory where another filesystem’s root is attached; the walk crosses here.

What to notice

  • stat -c shows type + inode. Two hard-linked names share an inode number.
  • findmnt /path shows whether a component sits on a different filesystem.

Checkpoint: one openat() call. The VFS ran a small program — choose a start, resolve a component, maybe rewrite the rest, maybe cross a mount, repeat.

Dentries and the VFS (short)

Walking storage for every open() would be slow. Linux caches name→object lookups as dentries. A positive dentry ties a name in one parent to an inode; a negative dentry remembers “not found.”

So “the file is cached” is ambiguous: name lookup, inode metadata, data pages, or all three.

The VFS (Virtual Filesystem Switch) is the shared language. ext4, XFS, tmpfs, procfs all implement the same operations — look up, open, read, rename. Applications do not need a separate open() per filesystem. Semantics still differ; the interface is shared, not the corner cases.

2. A directory stores names; an inode stores identity

It is tempting to imagine a directory as a box of complete files. A sharper model:

A directory maps names to inode numbers within one filesystem.

The inode is the filesystem’s identity for an object: type, permissions, owner, timestamps, size, link count, and the map from logical ranges to stored data. The filename is normally not in the inode. That is why one inode can have several names.

Hard links add names, not copies

ln report.txt archive.txt

creates another directory entry pointing at the same inode. Both names reach the same bytes. Hard links do not cross filesystems (inode numbers are local). A symlink is different: its own inode holds a pathname; the kernel continues resolution from there.

Opening a file creates another reference

After lookup, Linux creates or reuses an open file description and puts a reference in the process’s file-descriptor table. Later read(fd, …) does not re-resolve the pathname. The fd reaches the open description (offset, mode), which reaches the inode.

That is why unlinking an open file looks strange — and useful.

Session · names and identity

Remove every name while an open file stays alive

Directory entries are names. The inode is identity. An open descriptor is a third kind of reference.

links + unlink

Words you just saw

hard link
Another directory entry pointing at the same inode. No data copy.
link count
How many directory entries currently name this inode.
open-but-unlinked
Link count 0, but an open fd (or mapping) still holds the inode alive.

What to notice

  • After unlink, ls cannot see the file, but the writer’s fd still works.
  • lsof +L1 lists open files with link count < 1 — classic “disk full but du is small” clue.

unlink() removes a directory entry. It does not invalidate every descriptor. The inode and data can be reclaimed only when:

  1. no directory entries name it, and
  2. no live reference (open description, mapping, …) keeps it.

A log rotator can rename/unlink while the writer keeps going. It is also a disk mystery: du walking names cannot see the file, but the open fd still holds blocks. lsof +L1 finds those open-but-unlinked objects.

Rename changes the namespace

Within one filesystem, rename() can replace a destination atomically for concurrent lookup — readers see old or new, not a half-written entry. That is live visibility, not crash durability. Cross-filesystem “rename” is usually copy + delete.

3. Reads and writes usually meet the page cache first

read() often comes from the page cache. Missing pages are filled from storage into that cache. File-backed mmap normally uses the same pages.

write() commonly copies into cached pages, marks them dirty, and returns before the device has persisted them. Writeback happens later.

Wins: reuse for nearby reads; batch and reorder small writes. Cost: “the syscall returned” ≠ “power loss cannot erase this.”

Direct I/O and sync flags change the path, but the buffered story is the one to learn first. File offsets are still logical — they need not sit in adjacent physical blocks. Modern filesystems record extents: contiguous logical ranges mapped to contiguous physical ranges.

4. Logical size ≠ allocated storage

Consider:

int fd = open("disk.img", O_CREAT | O_WRONLY, 0644);
lseek(fd, 1LL << 30, SEEK_SET);
write(fd, "x", 1);

Apparent size is a bit over 1 GiB. The gap can be a hole: reading returns zeros; the filesystem need not store a physical block for every zero. That is a sparse file. ls -l shows logical size; du is closer to allocated storage. A naive copy can inflate holes into full allocation — painful for VM disk images.

Reflinks share extents until a write

A reflink goes further than “preserve holes”: the new file initially points at the same physical extents. A write to a shared range allocates new storage for that range only — filesystem-level copy-on-write.

  • memory COW changes page-table → RAM frame mappings
  • reflink COW changes file-offset → storage extent mappings

Three quantities to keep separate:

  • apparent size — logical length through the file API
  • allocated size — extents charged to one file
  • unique physical data — extents the group actually needs

Session · sparse files and reflinks

Big logical size, small physical cost — until you write

Holes have length without stored blocks. A reflink shares extents until a write forces a private copy of that range.

1 · Sparse base

Logical length 32 MiB. Only four regions hold data; the rest are holes.

base.img
E1 E2 · · · · E3 E4
clone.img
(not created)
disk
four extents

Unique physical: 16 MiB

2 · After reflink

clone.img points at the same extents. Apparent size doubles; unique data does not.

base.img
E1 E2 · · · · E3 E4
clone.img
E1 E2 · · · · E3 E4
disk
still four extents

Unique physical: still 16 MiB

Both files “own” the data logically. Storage is shared.

3 · Write in clone

Only the touched region becomes private. Holes and untouched extents stay shared.

base.img
E1 E2 · · · · E3 E4
clone.img
C1 E2 · · · · E3 E4
disk
E1 C1 E2 E3 E4

Unique physical: 20 MiB (+4)

One write ≠ copy the whole image. Same idea as memory COW.

Three numbers

  • Apparent size — what ls -l reports (logical length, including holes).
  • Allocated — what du is closer to (stored extents for that file).
  • Unique physical — extents the group actually needs after sharing.

sparse + du

Words you just saw

hole
Logical range with no stored data blocks. Reads as zeros.
cp --reflink=always
Ask for a clone that shares extents (needs filesystem support).

Checkpoint: reflink is not content-hash dedup, and not a backup. Shared extents share fate if the underlying device fails.

XFS is a common host choice for many large sparse images (extents, allocation groups, optional reflink, project quotas). That does not make every guest root “an XFS problem” — the host might store images on XFS while each image contains ext4 for the guest /.

5. A successful write is not necessarily durable

Filesystems update data blocks, allocation metadata, inodes, directories, maybe a journal. A crash can interrupt the sequence. Journal replay recovering a structurally valid filesystem is not the same as “my latest app transaction survived.”

A common durable atomic replacement pattern:

  1. create a temp file in the destination directory
  2. write the complete new contents
  3. fsync() the temp file (and check the result)
  4. rename() over the destination
  5. fsync() the containing directory (and check)

File sync makes contents durable. Rename makes the new name visible atomically while the system is up. Directory sync asks for that namespace change to survive power loss.

Session · durability

Visible now ≠ safe after power loss

write and rename change what running readers see. fsync asks storage to make a boundary durable.

safe replace

Words you just saw

write()
Usually updates page cache. May return before the device has the bytes.
fsync(fd)
Push this file’s dirty data + needed metadata toward stable storage.
fsync(dir)
Ask that directory entries (e.g. the rename) become durable.

What to notice

  • Crash after write-but-before-fsync: temp may vanish; old config should still be there.
  • Crash after rename-but-before-dir-fsync: either name may come back — do not assume.

Layer distinction:

  • write() — what later readers normally observe
  • rename() — which name they observe (atomically, live)
  • sync ops — the durability ordering you asked for

fsync() is not a backup. It is a persistence boundary under the storage contract.

6. A microVM root is a filesystem inside a file

A root disk image can be an ordinary host file whose bytes contain an entire guest filesystem:

host pathname: /var/lib/vms/7f3a/rootfs.ext4
host object:   a sparse or reflinked regular file
guest view:    a block device containing an ext4 filesystem
guest mount:   /

The VMM opens the host file and exposes byte ranges through virtio-blk (or similar). The guest kernel does not see the host pathname or host inode. It sees sectors. Its ext4 driver interprets those sectors as its own superblock, journal, inodes, and data.

Session · nested filesystems

One read, two filesystems

The guest mounts ext4 from a virtual block device. The host stores that device as a regular file on another filesystem.

  1. 1

    Guest process

    open("/etc/hostname")

    Pathname in the guest namespace

  2. 2

    Guest VFS + ext4

    inode → logical offset → block

    Guest filesystem maps file bytes to disk blocks

  3. 3

    virtio-blk

    read sectors 15874–15875

    Virtual disk request — no host pathnames

  4. 4

    VMM

    sectors → offset in rootfs.ext4

    Host file offset = sector × size

  5. 5

    Host VFS + XFS

    file offset → host extents

    Sparse/reflinked image on the host filesystem

Do not collapse these

  • Host pathname …/rootfs.ext4 ≠ guest pathname /etc/hostname.
  • Sharing a host directory is a different protocol — not a weaker block device.
  • Two guests must not write the same ordinary ext4 image. Reflink separate images from one base instead.

host view

Words you just saw

image file
Host regular file whose bytes encode a guest disk (often sparse or reflinked).
virtio-blk
Paravirtual block device — guest sees sectors; VMM maps them into the image.

One guest read can involve two independent mapping systems:

  1. guest resolves /etc/hostname through guest VFS + ext4
  2. ext4 turns the offset into a guest disk block
  3. virtio-blk requests those sectors
  4. the VMM maps sectors → offsets in the host image file
  5. host VFS + host filesystem map those offsets → storage extents

Why not just share a host directory?

A directory is a host VFS namespace. A block device is a sector array the guest kernel can own. Shared-folder protocols are useful for deliberate exchange; they are not “the same thing, weaker.” Mount namespaces change which host mounts a process sees — they do not create a second kernel’s filesystem.

Never mount one ordinary writable ext4 image in two guests. Each assumes exclusive ownership of allocation and journal. Reflink many separate writable images from one base instead: host extents start shared; guest filesystems diverge independently.

Disk snapshots need a consistency point too — pause, drain I/O, quiesce when needed. Speed of a reflink clone is not consistency.

What to keep

  1. A pathname is a lookup instruction, not an identity.
  2. A dentry caches a name in one parent; an inode is the object.
  3. A file descriptor can keep an unlinked inode alive with no pathname.
  4. Rename atomicity ≠ crash durability — sync closes that gap.
  5. The page cache holds file data in RAM; an extent maps logical ranges to storage.
  6. A sparse hole has length without allocated data; a reflink shares extents without merging identities.
  7. The host filesystem storing an image ≠ the guest filesystem inside it.
  8. A directory namespace is not a block device.

When a root disk is “10 GiB,” ask: guest capacity? Host apparent size? Allocated extents? Unique extents after sharing? Dirty cache still waiting for storage?

Next: isolation — credentials, capabilities, namespaces, cgroups, seccomp, and the exact boundary a microVM adds beyond a container.