TT Lab
요금제

한국어

시작하기
블로그

블로그

containerd 와 CRI 읽기: kubelet 의 gRPC 한 통이 runc 까지 내려가는 길

상태: 초안 (2026-10-17), 일부 실측. 기준 판 containerd/containerd v2.3.5(커밋 1294c24a7da8), kubernetes/cri-api v0.34.0(커밋 9b3ce5ec4798). 1.7 과의 비교는 containerd v1.7.27(커밋 05044ec0a9a7)을 함께 열어 소스로 확인한 것만 적었습니다. 실측은 2026-10-17 작은 클러스터에서 읽기만 했습니다(노드 목록 읽기, 워커 두 대에 일반 사용자로 ssh 해 ps·mount·설정 파일 읽기, sudo 없음). 작성 규칙: 실측 데이터가 기본이고, 못 잰 것은 「미실측」, 확인하지 못한 것은 「미확인」으로 표시합니다.

목차

1. 한 줄 요약: 주문서 양식과 주방

kubelet 은 컨테이너를 직접 만들지 않습니다. 「이 파드의 샌드박스를 차려라」, 「이 이미지로 컨테이너를 만들어라」, 「켜라」 같은 주문을 CRI(Container Runtime Interface) 라는 정해진 양식으로 보낼 뿐입니다. 주문서 양식이 정해져 있으니 주방(런타임)은 containerd 든 CRI-O 든 바꿀 수 있습니다. containerd 는 그 주문을 받아 재료(이미지 레이어)를 꺼내 손질하고(스냅샷), 테이블마다 담당 직원(shim)을 붙이고, 실제로 불을 켜는 일은 조리사(runc)에게 맡깁니다. 조리사는 요리를 앉히고 바로 퇴근하고, 남은 시중은 담당 직원이 듭니다.

구성요소 하는 일 어디서 도나
kubelet 파드 명세를 CRI 호출로 바꿔 보냄 노드마다 systemd 서비스
CRI(cri-api) RuntimeService·ImageService 두 gRPC 서비스의 규약 api.proto 한 파일
containerd CRI 플러그인 CRI 요청을 받아 코어 서비스·CNI 를 부름 containerd 프로세스 안
containerd 코어 서비스 컨테이너 메타데이터·태스크·이미지·콘텐츠·스냅샷·임대(lease) containerd 프로세스 안
containerd-shim-runc-v2 파드 하나의 컨테이너 프로세스를 지키고 종료 코드를 받음 파드마다 프로세스 1개
runc OCI 번들로 네임스페이스·cgroup 을 만들고 프로세스를 띄움 실행 후 바로 종료
CNI 플러그인 파드 네트워크 네임스페이스에 인터페이스·IP 를 붙임 containerd 가 실행하는 바이너리

2. 구조도

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 앱 컨테이너들 읽는 법 파랑: 코어 보라: 런타임 주황: 데몬 밖 초록: 프로세스
그림: kubelet 에서 runc 까지 호출 사슬과 containerd 내부 구성
이미지 레이어가 컨테이너 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층
그림: 이미지 레이어가 스냅샷을 거쳐 overlay 마운트가 되는 과정과 실측

3. 코드 지도

containerd 2.x 저장소는 Go 모듈 하나(github.com/containerd/containerd/v2)입니다. 처음 열 때 중요한 것만 추렸습니다.

경로 무엇 처음 읽을 파일
cmd/containerd/ 데몬 진입점, 내장 플러그인 목록, 플러그인 적재 builtins/builtins.go, server/server.go
plugins/ 플러그인 등록부. 서비스·스냅샷터·콘텐츠·CRI·GC 등 types.go, cri/cri.go
internal/cri/ CRI 구현 본체(서버·샌드박스 컨트롤러·이미지 서비스·설정) server/sandbox_run.go
core/ 도메인 로직: 메타데이터(bolt), runtime v2, unpack, transfer runtime/v2/shim_manager.go
client/ Go 클라이언트. CRI 플러그인도 이것을 「메모리 안」으로 씀 container.go
cmd/containerd-shim-runc-v2/ runc 용 shim manager/manager_linux.go
api/ containerd 자체 gRPC·ttrpc proto services/tasks/v1/tasks.proto

kubernetes/cri-api 는 거의 한 파일입니다. pkg/apis/runtime/v1/api.proto 에 RPC 35개와 메시지가 다 있고, 나머지는 생성된 Go 코드입니다.

1.7 과 2.x 의 차이 (소스로 확인한 것만)

4. 핵심 경로 따라가기

4.1 데몬은 플러그인 그래프로 조립된다

containerd 의 거의 모든 기능은 플러그인입니다. 각 패키지가 init() 에서 registry.Register 로 자신을 등록하고, builtins.go 는 그 패키지들을 빈 import(_)로 끌어들이기만 합니다. 빌드에 넣을지 말지가 import 한 줄로 정해집니다.

  1. 플러그인은 타입과 ID 로 불립니다. 타입은 io.containerd.grpc.v1, io.containerd.snapshotter.v1, io.containerd.cri.v1 같은 문자열입니다(plugins/types.go:35 부근). 설정 파일의 [plugins."io.containerd.cri.v1.images"] 는 「타입.ID」 를 그대로 쓴 것입니다.
  2. 등록에는 Requires(필요한 타입 목록)가 있습니다. CRI gRPC 플러그인은 CRI 서비스·샌드박스 컨트롤러·NRI·이벤트·lease·transfer·shim 등 열한 가지 타입을 요구합니다(plugins/cri/cri.go:46 init).
  3. 데몬은 LoadPlugins 에서 registry.Graph(...) 로 의존 순서대로 줄을 세우고(386행), New 의 반복문(161행)에서 하나씩 p.Init 합니다(189행). 그래프 정렬 자체는 별도 모듈 github.com/containerd/plugin v1.1.0 에 있어 이 저장소에는 없습니다.
  4. 서비스는 대개 두 겹입니다. 실제 일을 하는 io.containerd.service.v1 타입의 「local」 구현 (plugins/services/tasks/local.go:92)과, 그것을 gRPC 로 내보내는 얇은 io.containerd.grpc.v1 포장(containers/service.go:34)입니다.

왜 두 겹일까요? CRI 플러그인이 답입니다. initCRIService 는 containerd 클라이언트를 만들되 containerd.WithInMemoryServices(ic)(121행)를 넘깁니다. 바깥 ctr·nerdctl 은 소켓을 거쳐 gRPC 포장을 부르고, 같은 프로세스 안의 CRI 는 local 구현을 직접 부릅니다. 직렬화 한 번을 아끼면서도 클라이언트 코드는 하나로 유지하려는 설계입니다. CRI 요청이 쓰는 이름공간도 이때 k8s.io 로 고정됩니다. 그래서 ctr -n k8s.io 로 봐야 쿠버네티스 컨테이너가 보입니다.

4.2 RunPodSandbox: 네트워크가 먼저, pause 는 컨트롤러가

CRI 규약의 첫 RPC 는 RunPodSandbox 입니다. 「성공하면 샌드박스가 ready 여야 한다」는 한 줄이 계약의 전부이고, 샌드박스가 무엇인지는 런타임이 정합니다. containerd 의 답은 「네트워크 네임스페이스 + 그것을 붙잡고 있는 pause 컨테이너」입니다.

  1. criService.RunPodSandbox 는 먼저 샌드박스 ID 로 lease 를 만듭니다(97행). 이 lease 가 살아 있는 동안 샌드박스가 쓰는 콘텐츠·스냅샷은 GC 대상에서 빠집니다.
  2. hostNetwork 가 아니면 netns.NewNetNS 로 /var/run/netns/ 아래에 이름 있는 네트워크 네임스페이스를 만듭니다(209행).
  3. 이어서 setupPodNetwork 가 CNI 의 Setup 을 부릅니다(496행). 컨테이너가 하나도 뜨기 전에 네트워크부터 붙는 셈입니다. 받은 IP 는 샌드박스 상태에 캐시해 두고, PodSandboxStatus 때마다 네임스페이스를 뒤지지 않게 합니다.
  4. 그다음 샌드박스 서비스의 CreateSandbox(281행)와 StartSandbox(299행)를 부릅니다. 여기서 실제 일은 샌드박스 컨트롤러가 합니다.
  5. 기본 컨트롤러 podsandbox 의 Start 가 pause 이미지를 찾고(86행), 그 이미지 위에 스냅샷을 준비해 컨테이너를 만들고(209행), 표준 입출력 없이 태스크를 만들어(253행) 종료 감시(Wait, 270행)를 건 뒤 시작합니다(295행).

왜 이렇게 나눴을까요? 샌드박스를 「pause 컨테이너」로 못 박지 않기 위해서입니다. 컨트롤러가 플러그인이므로 VM 기반 런타임처럼 샌드박스가 가상 머신인 구현도 같은 CRI 서버 아래에 끼울 수 있습니다. pause 이미지 이름이 설정에서 sandbox_image 한 줄이 아니라 pinned_images 표의 sandbox 항목으로 바뀐 것도(설정 구조체 config.go:307) 같은 흐름입니다. 주석은 이것을 「CRI 클라이언트가 지우면 안 되는 이미지들」로 정의하고, 키로 다른 플러그인이 찾아 쓰게 합니다. pause 는 그중 하나일 뿐입니다.

4.3 CreateContainer·StartContainer → tasks → shim → runc

  1. CreateContainer 는 CRI 의 ContainerConfig 를 OCI 런타임 명세로 바꾸고, WithNewSnapshot(378행)으로 이미지 위 쓰기 층을 준비해 client.NewContainer(462행)를 부릅니다. 이 단계는 메타데이터만 만듭니다. 프로세스는 아직 없습니다.
  2. StartContainer 는 샌드박스가 태스크 API 주소를 갖고 있으면 그것을 옵션으로 붙여(197행) container.NewTask(206행) → task.Start(247행)를 부릅니다.
  3. 클라이언트의 NewTask 는 handleMounts(328행)에서 스냅샷터의 Mounts 를 받아 rootfs 마운트 목록을 요청에 싣습니다. tasks 서비스의 local.Create 가 이를 받아 runtime v2 의 TaskManager.Create 로 넘깁니다.
  4. ShimManager.Start 와 binary.Start 가 shim 바이너리를 start 하위 명령으로 실행합니다. shim 은 자기 ttrpc 소켓 주소를 돌려주고 데몬에서 떨어져 나갑니다.
  5. shim 쪽 manager.Start 에 이 글에서 가장 중요한 몇 줄이 있습니다.
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

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

groupLabels 에는 io.kubernetes.cri.sandbox-id 가 있습니다(67행). 같은 파드의 컨테이너는 같은 샌드박스 ID 로 같은 소켓 이름을 얻고, 소켓이 이미 있으면 새 shim 을 띄우지 않고 기존 주소를 돌려줍니다. 그래서 shim 은 컨테이너마다가 아니라 파드마다 하나입니다. 6절의 실측이 이것을 그대로 보여 줍니다. 6. shim 의 service.Create 는 runc.NewContainer 에서 번들 아래 rootfs 에 overlay 를 shim 이 직접 마운트하고(120행), Init.Create 가 runc create 를 실행합니다(149행). Start 는 start 에서 runc start 입니다.

왜 shim 을 따로 둘까요? 컨테이너 프로세스의 부모가 containerd 이면, containerd 를 재시작하거나 올려 받을 때 모든 컨테이너가 함께 흔들립니다. shim 이 부모 역할(종료 코드 수거, 표준 입출력 유지)을 맡으면 데몬은 언제든 내려갔다 올라와 소켓으로 다시 붙을 수 있습니다. runc 를 상주시키지 않는 것도 같은 이유입니다. 오래 사는 프로세스가 적을수록 업그레이드가 쉽습니다.

4.4 이미지 pull 과 스냅샷: 레이어가 rootfs 가 되기까지

  1. CRI 의 PullImage 는 CRIImageService.PullImage 로 옵니다. 2.1 부터 기본은 transfer 서비스이고, use_local_image_pull 을 켜면 예전 client.Pull 경로를 씁니다(184행). transfer 경로는 pullImageWithTransferService → Transfer(326행) → localTransferService.pull 입니다.
  2. 내려받은 매니페스트·설정·레이어는 모두 콘텐츠 주소 저장소에 들어갑니다. 경로는 다이제스트 그대로 blobs/sha256/<hex> 입니다(blobPath). 같은 레이어를 쓰는 이미지가 여럿이어도 디스크에는 한 번만 있습니다.
  3. 풀기는 Unpacker.unpack 이 레이어마다 스냅샷터 Prepare(415행) → diff 서비스 Apply(tar 를 풀어 넣기, 542행) → Commit(494행)을 반복합니다. 커밋된 스냅샷의 이름은 그 레이어까지의 chainID 라서, 같은 레이어 사슬은 이미지가 달라도 한 번만 풀립니다.
  4. 컨테이너를 만들 때 WithNewSnapshot 이 맨 위 chainID 를 부모로 Prepare 해 비어 있는 active 스냅샷을 만듭니다(276행). 이미지가 아직 안 풀려 있으면 CRI 쪽 래퍼가 unpackImage 로 풀고 다시 시도합니다.
  5. 마지막으로 overlayfs 스냅샷터의 mounts 가 마운트 옵션을 만듭니다.
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, ":")))

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

부모 스냅샷들의 fs 디렉터리가 그대로 lowerdir 가 되고, 자기 fs·work 가 upperdir·workdir 가 됩니다. 컨테이너가 쓰는 파일은 모두 upper 에만 쌓이므로 같은 이미지를 쓰는 컨테이너 백 개도 아래층을 공유합니다. 같은 발상을 VM 디스크에 쓴 이야기는 학생 VM 이 뜨기까지 4에 있습니다.

이 모든 것을 이어 주는 장부가 bolt DB 입니다. 이름공간 아래에 이미지·컨테이너·스냅샷·콘텐츠·lease 버킷이 있고 (buckets.go:60 의 주석 도면), GarbageCollect 는 어떤 이미지·컨테이너·lease 에서도 닿지 않는 콘텐츠와 스냅샷을 지웁니다. pull 도중의 레이어가 지워지지 않는 것은 그 작업이 lease 를 쥐고 있기 때문입니다. 「참조가 없으면 지운다」를 기본으로 두고, 진행 중인 작업만 lease 로 예외를 거는 설계입니다.

5. 사용자 기여와의 연결

글쓴이는 2026-10-17 현재 containerd 와 kubernetes/cri-api 에 낸 PR 이 없습니다. 기여 원장에는 「컨테이너 런타임 저수준(containerd·CRI-O·runc 등)」을 조사 중인 분야로 적어 두었고, 이 글은 그 준비 운동입니다. 8절의 후보는 이 글을 쓰며 소스에서 직접 찾은 것입니다.

한 가지 미리 적어 둡니다. kubernetes/cri-api 는 kubernetes/kubernetes 의 staging/src/k8s.io/cri-api 를 봇이 옮겨 싣는 저장소라서, 이 저장소에 직접 보낸 PR 은 무시됩니다(저장소 CONTRIBUTING). CRI 쪽 수정은 kubernetes/kubernetes 로 보내야 하고, 그러면 쿠버네티스의 AI 사용 규칙(PR 본문 고지, AI 가 쓴 커밋 메시지·공동 저자 표기 금지)을 따릅니다.

6. 실측: 1.7 워커와 2.3 워커를 권한 없이 읽기

2026-10-17, 작은 클러스터(노드 8대)에서 읽기만 했습니다. 노드 런타임 분포는 kubectl get nodes -o custom-columns=...containerRuntimeVersion 으로, 노드 안은 일반 사용자 ssh 로 ps·mount·/etc/containerd/config.toml 읽기만 했습니다. /run/containerd·/var/lib/containerd 는 권한이 없어 목록을 못 읽었고(sudo 는 쓰지 않음), crictl 은 설치돼 있지 않았습니다.

항목 워커 A (containerd 1.7.27) 워커 B (containerd 2.3.5)
같은 판 노드 수 4대 4대
runc 1.2.5 1.5.1
실행 중 파드(그중 hostNetwork) 22 (10) 31 (6)
앱 컨테이너 수 28 42
containerd-shim-runc-v2 프로세스 22 31
pause 프로세스 22 31
태스크 디렉터리 수(디렉터리 링크 수 − 2) 50 = 22 + 28 74
overlay 마운트 50 74
그중 1층(lowerdir 하나) 22 33
가장 깊은 lowerdir 42층 52층
/var/run/netns 항목 12 = 22 − 10 25 = 31 − 6
shim RSS 평균 / containerd RSS 15.7 MiB / 74 MiB 12.6 MiB / 153 MiB
설정 판 version = 2 version = 4
CRI 설정 블록 io.containerd.grpc.v1.cri 하나 io.containerd.cri.v1.images·.runtime 으로 나뉨
pause 이미지 sandbox_image = pause:3.8 pinned_images.sandbox = pause:3.10.2
snapshotter / cgroup overlayfs / SystemdCgroup = true overlayfs / SystemdCgroup = true

shim 한 줄은 이렇게 생겼습니다(ID 는 가림).

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=…)

읽은 것:

kubelet 쪽은 두 판 모두 containerRuntimeEndpoint: unix:///run/containerd/containerd.sock(2.x 노드는 /var/run/... 표기), cgroupDriver: systemd 였습니다. 런타임의 SystemdCgroup = true 와 짝이 맞습니다.

7. 어디부터 읽을까

  1. api.proto 의 RuntimeService·ImageService 주석만 훑습니다. 계약이 무엇을 요구하고 무엇을 런타임에 맡기는지가 보입니다.
  2. plugins/cri/cri.go 와 server.go 로 플러그인이 어떻게 엮이는지 봅니다.
  3. sandbox_run.go → podsandbox/sandbox_run.go 순서로 파드 하나를 띄웁니다.
  4. container_start.go → shim_manager.go → manager_linux.go 로 프로세스까지 내려갑니다.
  5. unpacker.go 와 overlay.go 로 디스크 쪽을 봅니다. docs/snapshotters/·docs/content-flow.md 를 곁들이면 좋습니다.

k3s·RKE2 처럼 containerd 를 품고 배포하는 배포판이 이 구조를 어떻게 감싸는지는 k3s·RKE2·k0s 구조 비교에서 이어 볼 수 있습니다.

8. 첫 기여하기 좋은 곳

9. 한계

티티 한마디. kubelet 은 주문서만 내고, 테이블마다 담당 직원(shim)이 붙어요. 그래서 셰프(containerd)가 잠깐 쉬어도 손님 접시는 그대로예요!

참고

모든 자료는 2026-10-17 에 열람했습니다.

로그인하면 좋아요를 누를 수 있습니다

댓글

아직 댓글이 없습니다.

로그인하면 댓글을 쓸 수 있습니다