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:
- no directory entries name it, and
- 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 -lreports (logical length, including holes). - Allocated — what
duis 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:
- create a temp file in the destination directory
- write the complete new contents
fsync()the temp file (and check the result)rename()over the destinationfsync()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 observerename()— 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
Guest process
open("/etc/hostname")
Pathname in the guest namespace
2
Guest VFS + ext4
inode → logical offset → block
Guest filesystem maps file bytes to disk blocks
3
virtio-blk
read sectors 15874–15875
Virtual disk request — no host pathnames
4
VMM
sectors → offset in rootfs.ext4
Host file offset = sector × size
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:
- guest resolves
/etc/hostnamethrough guest VFS + ext4 - ext4 turns the offset into a guest disk block
- virtio-blk requests those sectors
- the VMM maps sectors → offsets in the host image file
- 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
- A pathname is a lookup instruction, not an identity.
- A dentry caches a name in one parent; an inode is the object.
- A file descriptor can keep an unlinked inode alive with no pathname.
- Rename atomicity ≠ crash durability — sync closes that gap.
- The page cache holds file data in RAM; an extent maps logical ranges to storage.
- A sparse hole has length without allocated data; a reflink shares extents without merging identities.
- The host filesystem storing an image ≠ the guest filesystem inside it.
- 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.