Disclaimer

The goal of this article is to understand how volumes are created and mounted into a container's filesystem. We focus on two of the simplest volume types: EmptyDir, a true ephemeral volume tied to the pod's lifecycle, and HostPath, which points directly at a path on the node's filesystem and can outlive the pod if it's rescheduled onto the same node. They aren't the same category, but they're a useful pair to compare because the kubelet handles them so differently.

We'll briefly touch on how the manifest passes through the API server, etcd, controller manager, and scheduler, then spend most of the article on what the kubelet and the container runtime actually do.

Preparations

We start with a manifest that has two volumes of different types: HostPath and EmptyDir.

# pod.yaml
apiVersion: v1
kind: Pod
metadata:
  name: ephemeral-pod
spec:
  containers:
  - name: app
    image: nginx
    volumeMounts:
    - name: temp-storage
      mountPath: /tmp/data
    - name: host-logs
      mountPath: /var/log/host
  volumes:
  - name: temp-storage
    emptyDir:
      sizeLimit: 1Gi
  - name: host-logs
    hostPath:
      path: /var/log/app
      type: DirectoryOrCreate

K8s Handling the Manifest

kubectl (client side)

When we hit enter, kubectl does several things before anything touches the cluster:

API server (kube-apiserver)

The API server is the only component that talks to etcd, so everything funnels through it:

Once it passes all of that, the API server writes the Pod object into etcd.

etcd

etcd is the cluster's consistent key-value store — the single source of truth. The Pod spec is persisted here. At this moment the Pod exists as desired state, but spec.nodeName is still empty and no container is running anywhere. The API server acknowledges the write, and kubectl prints something like pod/ephemeral-pod created.

Controllers and the scheduler (watching, not polling)

Kubernetes components don't poll etcd directly. Instead they establish watches through the API server and react to events.

Having chosen a node, the scheduler doesn't launch anything itself. It performs a binding: it tells the API server to set spec.nodeName on the Pod, and the API server writes that update back to etcd.

So far, the kubelet has learned via the Watch API that the pod has been assigned to its node. Here's where it gets interesting.

Kubelet and Runtime

Stage 1: Kubelet Receives the Pod and Reconciles Volumes

Once the scheduler binds ephemeral-pod to a node, the kubelet on that node picks it up via its pod watch. The kubelet's Volume Manager runs a reconciliation loop comparing the "desired state of world" (volumes this pod needs) against the "actual state of world" (what's currently mounted). It computes that two volumes must be set up before any container can start.

Volume setup in the kubelet happens through the volume plugin interface. Each volume type has a plugin implementing operations conceptually named SetUp (and TearDown). Some volume types also go through MountDevice (attach/format for block devices), but neither emptyDir nor hostPath needs that — they're node-local and require no attachment.

Stage 2: Preparing Each Volume on the Host

The EmptyDir (temp-storage, 1Gi limit)

The kubelet's emptyDir plugin creates a directory on the host under the kubelet's pod directory, following this pattern:

/var/lib/kubelet/pods/<pod-UID>/volumes/kubernetes.io~empty-dir/temp-storage/

Because we didn't specify a medium, the default medium is disk (the node's filesystem), so this is just a plain directory created with os.MkdirAll — no special mount happens for the storage itself at this point.

The interesting part is sizeLimit: 1Gi. How this is enforced depends on the kubelet's configuration for local storage capacity isolation: without filesystem-quota-based monitoring enabled, the limit is enforced reactively — the kubelet periodically checks disk usage on the directory via its eviction/monitoring machinery, and evicts the pod if usage exceeds 1Gi. With project quotas enabled (XFS/ext4 prjquota), the kernel enforces the limit at write time instead, giving a hard cap. (Worth double-checking the exact feature-gate names against the current Kubernetes version before you rely on this in production — they've shifted between releases.)

If we had specified medium: Memory, the kubelet would instead mount a tmpfs:

mount -t tmpfs -o size=1073741824 tmpfs /var/lib/kubelet/pods/<UID>/volumes/kubernetes.io~empty-dir/temp-storage

and the kernel would enforce the size limit directly.

The HostPath (host-logs, /var/log/app, DirectoryOrCreate)

The hostPath plugin is much simpler and, notably, does not create any new directory under /var/lib/kubelet. It uses the host path directly. Because we set type: DirectoryOrCreate, the plugin checks whether /var/log/app exists on the node:

No mount is performed here either. The host path is used as-is and passed straight to the runtime.

After Stage 2, the kubelet has two absolute host paths ready:

/var/lib/kubelet/pods/<UID>/volumes/kubernetes.io~empty-dir/temp-storage
/var/log/app

Stage 3: Kubelet Builds the CRI Request

Now the kubelet talks to the container runtime over the CRI gRPC API, typically over a Unix socket like /run/containerd/containerd.sock. Container creation happens in two conceptual steps:

  1. RunPodSandbox creates the pod's sandbox — the "pause" container that holds the shared namespaces (network, IPC, etc.).
  2. CreateContainer is then called for your app container.

The crucial part is the CreateContainerRequest's ContainerConfig, which contains a list of mounts. The kubelet translates each volumeMount into a CRI Mount message that references the host path it prepared in Stage 2:

mounts: [
  {
    host_path:      "/var/lib/kubelet/pods/<UID>/volumes/kubernetes.io~empty-dir/temp-storage",
    container_path: "/tmp/data",
    readonly:       false,
    propagation:    PROPAGATION_PRIVATE
  },
  {
    host_path:      "/var/log/app",
    container_path: "/var/log/host",
    readonly:       false,
    propagation:    PROPAGATION_PRIVATE
  }
]

Note that from the CRI's perspective, both volumes are now identical — just a host path that needs to appear at a container path. The runtime has no idea one was an emptyDir and the other a hostPath. That abstraction was fully resolved by the kubelet.

Stage 4: CRI Runtime Generates the OCI Spec

The CRI runtime (containerd via its CRI plugin, or CRI-O) takes this request and produces an OCI runtime specification (config.json). Each CRI mount becomes an entry in the OCI spec's mounts array, expressed as a Linux bind mount:

{
  "destination": "/tmp/data",
  "type": "bind",
  "source": "/var/lib/kubelet/pods/<UID>/volumes/kubernetes.io~empty-dir/temp-storage",
  "options": ["rbind", "rprivate", "rw"]
},
{
  "destination": "/var/log/host",
  "type": "bind",
  "source": "/var/log/app",
  "options": ["rbind", "rprivate", "rw"]
}

PROPAGATION_PRIVATE from CRI maps to the rprivate mount propagation option, meaning mount events don't propagate between host and container.

Stage 5: runc and the Kernel Perform the Actual Mounts

containerd then invokes the OCI runtime (runc) to create and start the container. This is where the real Linux work happens: runc calls clone() to create a new process with the CLONE_NEWNS flag (along with other namespace flags), giving the container a fresh mount namespace. This isolates the container's view of the filesystem tree from the host.

Inside this new namespace, runc sets up the root filesystem. It typically uses pivot_root to switch the process's root to the container's rootfs (the unpacked nginx image layers, usually assembled via an overlayfs mount). Before or during this, it processes each mount in the OCI spec.

For each bind mount, runc issues the mount(2) syscall. Conceptually, for our two volumes:

// emptyDir → /tmp/data
mount("/var/lib/kubelet/pods/<UID>/volumes/kubernetes.io~empty-dir/temp-storage",
      "<container-rootfs>/tmp/data",
      NULL, MS_BIND | MS_REC, NULL);

// hostPath → /var/log/host
mount("/var/log/app",
      "<container-rootfs>/var/log/host",
      NULL, MS_BIND | MS_REC, NULL);

A bind mount makes an existing directory tree appear at a second location — it doesn't copy data; both paths point at the same underlying inodes/dentries. This is why writes inside the container at /tmp/data instantly appear in the host's kubelet pod directory, and why anything in /var/log/app on the node is immediately visible at /var/log/host inside the container.

runc then applies the propagation flag with a second call — e.g. mount(NULL, target, NULL, MS_PRIVATE | MS_REC, NULL) for rprivate — and remounts read-only if requested, with MS_REMOUNT | MS_RDONLY. If the container path (/tmp/data) doesn't already exist in the image's rootfs, runc creates it first.

Because all of this happens inside the container's mount namespace, these mounts are invisible in the host's default namespace mount table — the host only sees the plain kubelet directory and /var/log/app, not the bind targets.

Summary

Neither emptyDir nor hostPath involves anything exotic under the hood — no CSI driver, no attach/detach cycle, no block device. emptyDir just gets a fresh directory (or a tmpfs) prepared by the kubelet; hostPath reuses a directory that already exists on the node. Both end up as plain Linux bind mounts, set up by runc inside the container's own mount namespace, right before the application process starts. Everything Kubernetes-specific — the volume type, the size limit, the DirectoryOrCreate check — is resolved before the runtime ever sees it; from containerd downward, it's all just host paths and mount(2) calls.