TT Lab
Blog

한국어 English 日本語 中文

Get started
Blog

Blog

Reading containerd and CRI: How a gRPC request from kubelet goes down to runc

Status: Draft (2026-10-17), partially measured. Reference versions: containerd/containerd v2.3.5 (commit 1294c24a7da8), kubernetes/cri-api v0.34.0 (commit 9b3ce5ec4798). Comparisons with 1.7 are limited to what was verified by opening containerd v1.7.27 (commit 05044ec0a9a7) together in the source code. Measurements were taken on 2026-10-17 in a small cluster by reading only (reading the node list, sshing into two worker nodes as a regular user, ps·mountand reading configuration files without sudo). Writing convention: Measured data is the default; unmeasured items are marked as "Unmeasured", and unverified items as "Unverified".

Table of Contents

1. One-line Summary: The Order Form and the Kitchen

Kubelet does not create containers directly. It simply sends orders like "Set up a sandbox for this pod," "Create a container with this image," and "Start it" in a standardized format called CRI (Container Runtime Interface) . Because the order format is standardized, the kitchen (runtime) can be swapped between containerd, CRI-O, etc. containerd receives the order, unpacks and prepares the ingredients (image layers) (snapshots), assigns a staff member (shim) to each table, and delegates the actual ignition to the chef (runc). The chef sets up the dish and leaves immediately, while the assigned staff member attends to the rest.

Component Role Where It Runs
kubelet Converts pod specs into CRI calls and sends them systemd service on each node
CRI (cri-api) Protocol for the two gRPC services: RuntimeService and ImageService api.proto A single file
containerd CRI plugin Receives CRI requests and calls core services and CNI Inside the containerd process
containerd core services Container metadata, tasks, images, content, snapshots, and leases Inside the containerd process
containerd-shim-runc-v2 Supervises a pod's container process and receives exit codes One process per pod
runc Creates namespaces and cgroups using an OCI bundle and spawns the process Exits immediately after execution
CNI plugin Attaches interfaces and IPs to the pod network namespace Binary executed by containerd

2. Architecture Diagram

kubelet 에서 runc 까지: 한 노드 안의 호출 사슬 kubelet CRI 클라이언트 CRI gRPC (api.proto) unix 소켓 containerd.sock containerd 데몬 (프로세스 1개) CRI 플러그인 io.containerd.grpc.v1.cri runtime images podsandbox RunPodSandbox·CreateContainer·PullImage CNI 바이너리 코어 서비스 (같은 프로세스 안에서 직접 호출) containers tasks images content snapshots leases meta.db blobs/sha256 snapshots/N runtime v2 태스크 관리자 · shim 관리자 ttrpc (소켓 /run/containerd/s/…) containerd-shim-runc-v2 파드마다 1개(sandbox-id 로 묶음) · rootfs 마운트 runc create·start 후 종료 pause 앱 컨테이너들 읽는 법 파랑: 코어 보라: 런타임 주황: 데몬 밖 초록: 프로세스
Figure: Call chain from kubelet to runc and containerd internal structure
이미지 레이어가 컨테이너 rootfs 가 되기까지 overlayfs 스냅샷터의 디렉터리 4/fs 쓰기 층 (active) 3/fs 레이어 3 (committed) 2/fs 레이어 2 (committed) 1/fs 레이어 1 (committed) 맨 위 active 층만 쓰기 가능 ① Pull 레이어 blob → content 저장소 ② Unpack Prepare→Apply→Commit 반복 ③ 컨테이너 생성 위에 새 active 스냅샷 ④ 태스크 생성 shim 이 overlay 로 마운트 결과: 컨테이너 번들의 rootfs 에 overlay 하나 overlay on /run/containerd/io.containerd.runtime.v2.task/ k8s.io/(컨테이너 ID)/rootfs type overlay lowerdir=3/fs:2/fs:1/fs (왼쪽이 위층) upperdir=4/fs, workdir=4/work 실측 2026-10-17 (워커 두 대, 읽기만) 1.7 워커: overlay 50개 중 1층 22개 = pause 22개 2.3 워커: overlay 74개, 가장 깊은 것 52층
Figure: Process of image layers turning into an overlay mount via snapshots and measurements

3. Code Map

The containerd 2.x repository is a single Go module (github.com/containerd/containerd/v2). Only the essential parts for an initial look are summarized below.

Path What Files to read first
cmd/containerd/ Daemon entry point, built-in plugin list, plugin loading builtins/builtins.go, server/server.go
plugins/ Plugin registry: services, snapshotters, content, CRI, GC, etc. types.go, cri/cri.go
internal/cri/ CRI implementation body (server, sandbox controller, image service, config) server/sandbox_run.go
core/ Domain logic: metadata (bolt), runtime v2, unpack, transfer runtime/v2/shim_manager.go
client/ Go 클라이언트. CRI 플러그인도 이것을 「메모리 안」으로 씀 container.go
cmd/containerd-shim-runc-v2/ Shim for runc manager/manager_linux.go
api/ containerd's own gRPC/ttrpc protos services/tasks/v1/tasks.proto

kubernetes/cri-api consists essentially of a single file. pkg/apis/runtime/v1/api.proto contains all 35 RPCs and messages, and the rest is generated Go code.

Differences between 1.7 and 2.x (verified via source code only)

4. Following the Core Path

4.1 Daemons Are Assembled via a Plugin Graph

Almost all features in containerd are plugins. Each package registers itself in init() via registry.Register , and builtins.go simply pulls those packages in via blank imports (_). Whether they are included in the build is determined by a single import line.

  1. Plugins are identified by typeand ID . The type is a string like io.containerd.grpc.v1, io.containerd.snapshotter.v1, io.containerd.cri.v1 (aroundplugins/types.go:35 ). In configuration files, [plugins."io.containerd.cri.v1.images"] literally uses "type.ID".
  2. Registration involves Requires(a list of required types). The CRI gRPC plugin requires eleven types, including CRI service, sandbox controller, NRI, events, lease, transfer, and shim (plugins/cri/cri.go:46 init).
  3. The daemon sorts them in dependency order in LoadPlugins via registry.Graph(...) (line 386), and initializes them one by one in a loop in New (line 161) via p.Init (line 189). Graph sorting itself resides in a separate module, github.com/containerd/plugin v1.1.0, so it is not in this repository.
  4. Services are usually double-layered: a "local" implementation of the io.containerd.service.v1 type that performs the actual work (plugins/services/tasks/local.go:92), and a thin io.containerd.grpc.v1 wrapper that exposes it via gRPC (containers/service.go:34).

Why two layers? The CRI plugin is the answer. initCRIService creates a containerd client but passes containerd.WithInMemoryServices(ic)(line 121). External ctr and nerdctl call the gRPC wrapper over the socket, whereas CRI within the same process calls the local implementation directly. This design avoids an extra serialization step while keeping client code unified . The namespace used by CRI requests is also fixed to k8s.io at this time. Therefore, Kubernetes containers are visible only when viewed with ctr -n k8s.io .

4.2 RunPodSandbox: Network First, Pause Handled by the Controller

The first RPC in the CRI protocol is RunPodSandbox . The single line "The sandbox must be ready upon success" is the entirety of the contract, and the runtime decides what a sandbox actually is. containerd's answer is "Network namespace + pause container holding it."

  1. criService.RunPodSandbox first creates a lease with the sandbox ID (line 97). While this lease is active, content and snapshots used by the sandbox are excluded from GC.
  2. hostNetwork If not netns.NewNetNS , it creates a named network namespace under /var/run/netns/ using
  3. Next, setupPodNetwork calls CNI's Setup (line 496). Network attachment happens before any container is spawned . The acquired IP is cached in the sandbox state so that the namespace does not need to be inspected on every PodSandboxStatus .
  4. It then calls the sandbox service's CreateSandbox(line 281) and StartSandbox(line 299). Here, the actual work is performed by the sandbox controller.
  5. The default controller podsandbox 's Start locates the pause image (line 86), prepares a snapshot over that image to create a container (line 209), creates a task without standard I/O (line 253), sets up exit monitoring (Wait, line 270), and starts it (line 295).

Why was it split this way? To avoid locking down a sandbox strictly as a "pause container." Since the controller is a plugin, implementations where the sandbox is a virtual machine (like VM-based runtimes) can be plugged in under the same CRI server. The change of the pause image name from a single line in settings to the sandbox_image entry in the pinned_images table (in configuration struct sandbox ) follows the same rationale. Comments define these as "images that CRI clients must not delete," allowing other plugins to look them up by key. Pause is merely one of them. config.go:3074.3 CreateContainer·StartContainer → tasks → shim → runc

converts CRI's

  1. CreateContainer into an OCI runtime spec, prepares the writable layer on top of the image via ContainerConfig (line 378), and calls WithNewSnapshot(line 462). This phase creates client.NewContainermetadata only . No process exists yet. If the sandbox holds a task API address,
  2. StartContainer attaches it as an option (line 197) and calls container.NewTask(line 206) → task.Start(line 247).
  3. The client's NewTask calls the snapshotter's handleMounts(line 328). Mounts to include the rootfs mount list in the request. The tasks service's local.Create receives this and passes it to runtime v2's TaskManager.Create .
  4. ShimManager.Start and binary.Start execute the shim binary as a start subcommand. The shim returns its ttrpc socket address and detaches from the daemon.
  5. On the shim side, manager.Start contains a few lines that are the most important in this post.
grouping := id
spec, err := readSpec()
...
for _, group := range groupLabels {
	if groupID, ok := spec.Annotations[group]; ok {
		grouping = groupID
		break
	}
}
...
s, err := newShimSocket(ctx, socketDir, opts.GetContainerdGrpcAddress(), grouping, false)
if err != nil {
	if errdefs.IsAlreadyExists(err) {
		params.Address = s.addr
		return &params, nil

Source: containerd v2.3.5 cmd/containerd-shim-runc-v2/manager/manager_linux.go, Apache-2.0

groupLabels has io.kubernetes.cri.sandbox-id (line 67). Containers in the same pod get the same socket name with the same sandbox ID, and if the socket already exists, it returns the existing address instead of launching a new shim. Therefore, there is one shim per pod, not per container. The measurements in Section 6 demonstrate this exact behavior. 6. The shim's service.Create is runc.NewContainer in rootfs under the bundle, where the shim directly mounts overlay onto the shim directly mounts(line 120), and Init.Create executes runc create (line 149). Start is start in runc start .

Why do we have a separate shim? If containerd were the direct parent of the container process, restarting or upgrading containerd would disrupt all running containers. By having the shim act as the parent (collecting exit codes and maintaining standard I/O), the daemon can go down and come back up at any time to reattach via sockets. Keeping runc non-resident is for the same reason. The fewer long-lived processes there are, the easier upgrades become.

4.4 Image Pull and Snapshots: How Layers Become rootfs

  1. CRI's PullImage is received as CRIImageService.PullImage . Since version 2.1, the default is the transfer service, and if use_local_image_pull is enabled, it uses the legacy client.Pull path (line 184). The transfer path is pullImageWithTransferService → Transfer(Line 326) → localTransferService.pull .
  2. The downloaded manifests, configurations, and layers all go into the content-addressable storage. The path is based directly on the digest blobs/sha256/<hex> is(blobPath). Even if multiple images use the same layer, it exists only once on disk.
  3. Unpacking is Unpacker.unpack done per layer by the snapshotter Prepare(line 415) → diff service Apply(extracting tar, line 542) → Commit(Line 494). The name of the committed snapshot is the chainID up to that layer, so identical layer chains are pulled only once even if the images differ.
  4. When creating a container, WithNewSnapshot uses this topmost chainID as the parent Prepare to create an empty active snapshot (line 276). If the image has not been pulled yet, the CRI-side wrapper unpackImage pulls it and retries.
  5. Finally, the overlayfs snapshotter's mounts generates the mount options.
if s.Kind == snapshots.KindActive {
	options = append(options,
		fmt.Sprintf("workdir=%s", o.workPath(s.ID)),
		fmt.Sprintf("upperdir=%s", o.upperPath(s.ID)),
	)
}
...
parentPaths := make([]string, len(s.ParentIDs))
for i := range s.ParentIDs {
	parentPaths[i] = o.upperPath(s.ParentIDs[i])
}
options = append(options, fmt.Sprintf("lowerdir=%s", strings.Join(parentPaths, ":")))

Source: containerd v2.3.5 plugins/snapshots/overlay/overlay.go, Apache-2.0

The fs directories of the parent snapshots become the lowerdir as they are, and its own fs·work becomes upperdir·workdir . Since all files used by the container are accumulated only in the upper layer, a hundred containers using the same image share the lower layers. The story of applying the same idea to VM disks is found in Until Student VMs Spin Up 4.

The ledger that ties all of this together is bolt DB. Under the namespace, there are image, container, snapshot, content, and lease buckets (buckets.go:60 comment diagram), and GarbageCollect deletes contents and snapshots that are not reached by any image, container, or lease. The reason layers are not deleted during a pull is that the operation holds a lease. It is designed around the principle of "delete if unreferenced," with ongoing operations being the only exceptions handled via leases.

5. Connection to User Contributions

As of October 17, 2026, the author has no PRs submitted to containerd or kubernetes/cri-api. The contribution ledger lists "Container runtime low-level (containerd, CRI-O, runc, etc.)" as an area under investigation, and this post is a preparatory exercise for that. The candidates in Section 8 were found directly from the source while writing this post.

One thing to note in advance: kubernetes/cri-api is a repository where bots mirror staging/src/k8s.io/cri-api from kubernetes/kubernetes, so PRs sent directly to this repository are ignored (repository CONTRIBUTING). CRI-related modifications must be sent to kubernetes/kubernetes, in which case Kubernetes' AI usage rules (notification in the PR body, prohibition of AI-written commit messages and co-author attribution) apply.

6. Measurement: Read-Only Inspection of 1.7 and 2.3 Workers without Privileges

On October 17, 2026, read-only checks were performed on a small cluster (8 nodes). The node runtime distribution is kubectl get nodes -o custom-columns=...containerRuntimeVersion , and inside the nodes, only ps·mount·/etc/containerd/config.toml was read using regular user ssh. /run/containerd·/var/lib/containerd could not read the list due to lack of privileges (sudo was not used), and crictl was not installed.

Item Worker A (containerd 1.7.27) Worker B (containerd 2.3.5)
Number of nodes with the same version 4 4
runc 1.2.5 1.5.1
Running pods (including hostNetwork among them) 22 (10) 31 (6)
Number of app containers 28 42
containerd-shim-runc-v2 process 22 31
pause process 22 31
Number of task directories (directory link count - 2) 50 = 22 + 28 74
overlay mount 50 74
Among them, 1 layer (one lowerdir) 22 33
deepest lowerdir 42nd floor 52nd floor
/var/run/netns item 12 = 22 − 10 25 = 31 − 6
Average shim RSS / containerd RSS 15.7 MiB / 74 MiB 12.6 MiB / 153 MiB
configuration edition version = 2 version = 4
CRI configuration block io.containerd.grpc.v1.cri one io.containerd.cri.v1.images·.runtime divided into
pause image sandbox_image = pause:3.8 pinned_images.sandbox = pause:3.10.2
snapshotter / cgroup overlayfs / SystemdCgroup = true overlayfs / SystemdCgroup = true

A shim line looks like this (ID is masked).

containerd-shim /usr/bin/containerd-shim-runc-v2 -namespace k8s.io -id 0c1e…(64자) -address /run/containerd/containerd.sock
overlay on /run/containerd/io.containerd.runtime.v2.task/k8s.io/0c1e…/rootfs type overlay (rw,relatime,lowerdir=/var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/1/fs,upperdir=…/snapshots/3774/fs,workdir=…)

Read:

On the kubelet side, both editions were containerRuntimeEndpoint: unix:///run/containerd/containerd.sock(2.x nodes are denoted as /var/run/... ), cgroupDriver: systemd . This matches the runtime's SystemdCgroup = true .

7. Where to start reading

  1. api.proto of RuntimeService·ImageService Just glance at the comments. You can see what the contract requires and what it delegates to the runtime.
  2. plugins/cri/cri.go and server.go to see how plugins are tied together.
  3. sandbox_run.go → podsandbox/sandbox_run.go to spin up a single pod.
  4. container_start.go → shim_manager.go → manager_linux.go down to the process level.
  5. unpacker.go and overlay.go to look at the disk side. docs/snapshotters/·docs/content-flow.md is a great complement.

You can continue reading about how distributions bundling containerd, such as k3s and RKE2, wrap this architecture in Comparing k3s, RKE2, and k0s architectures.

8. Good First Contributions

9. Limitations

A word from Titi. kubelet only places the order, and a dedicated staff member (shim) is assigned to each table. So even if the chef (containerd) takes a short break, the customer's plate stays right there!

References

All materials were viewed on 2026-10-17.

Sign in to like

Comments

No comments yet.

Sign in to leave a comment