containerd 네트워킹과 스토리지
containerd는 네트워킹과 스토리지를 직접 구현하지 않고 표준 인터페이스를 통해 외부 플러그인과 통합합니다. 이 글에서는 CNI를 통한 네트워크 구성, 네임스페이스 관리, 볼륨 마운트, 디바이스 접근, 보안 모듈 통합을 분석합니다.
1. CNI 통합
1.1 CNI 개요
Container Network Interface(CNI)는 컨테이너 네트워킹의 표준 인터페이스입니다. containerd는 CNI 플러그인을 호출하여 네트워크를 구성합니다.
CNI 호출 흐름:
kubelet -> containerd (CRI RunPodSandbox)
|
v
네트워크 네임스페이스 생성
|
v
CNI 플러그인 호출
(ADD 명령)
|
v
IP 할당, 라우팅 설정, 인터페이스 생성
|
v
결과를 containerd에 반환
1.2 CNI 설정
CNI 설정 파일 위치:
설정 디렉토리: /etc/cni/net.d/
바이너리 디렉토리: /opt/cni/bin/
containerd CNI 설정 (config.toml):
[plugins."io.containerd.grpc.v1.cri".cni]
bin_dir = "/opt/cni/bin"
conf_dir = "/etc/cni/net.d"
max_conf_num = 1
1.3 CNI 플러그인 체인
CNI 설정 예시 (10-calico.conflist):
네트워크 구성은 플러그인 체인으로 정의:
1. 메인 플러그인 (calico, cilium, flannel 등):
- 네트워크 인터페이스 생성
- IP 할당 (IPAM)
- 라우팅 규칙 설정
2. 메타 플러그인 (bandwidth, portmap 등):
- 대역폭 제한
- 포트 매핑
- 방화벽 규칙
실행 순서:
ADD: 메인 -> 메타 플러그인 (순방향)
DEL: 메타 -> 메인 플러그인 (역방향)
1.4 CNI 호출 과정
CNI ADD 실행 상세:
1. containerd가 네트워크 네임스페이스 경로 결정
/var/run/netns/cni-abc123
2. CNI 환경 변수 설정:
CNI_COMMAND=ADD
CNI_CONTAINERID=abc123
CNI_NETNS=/var/run/netns/cni-abc123
CNI_IFNAME=eth0
CNI_PATH=/opt/cni/bin
3. CNI 플러그인 바이너리 실행
stdin으로 설정 JSON 전달
4. 플러그인이 stdout으로 결과 반환:
- 할당된 IP 주소
- 게이트웨이 주소
- DNS 설정
- 라우팅 정보
5. containerd가 결과를 저장
2. 네트워크 네임스페이스
2.1 네임스페이스 생성
Pod 네트워크 네임스페이스:
Pod Sandbox 생성 시:
1. unshare(CLONE_NEWNET)으로 새 네트워크 네임스페이스 생성
2. /var/run/netns/ 에 바인드 마운트로 영구화
3. 해당 네임스페이스에서 CNI 플러그인 실행
4. Pod의 모든 컨테이너가 이 네임스페이스를 공유
네임스페이스 공유:
Pause 컨테이너가 네트워크 네임스페이스를 보유
App 컨테이너가 같은 네임스페이스에 참여
-> Pod 내 컨테이너가 localhost로 통신 가능
2.2 네임스페이스 정리
네임스페이스 정리:
Pod 삭제 시:
1. CNI DEL 명령으로 네트워크 리소스 해제
- IP 주소 반환
- 인터페이스 삭제
- 라우팅 규칙 제거
2. /var/run/netns/ 에서 바인드 마운트 해제
3. 네트워크 네임스페이스 자동 삭제
3. 볼륨 마운트
3.1 마운트 타입
containerd는 OCI 스펙의 마운트 구성을 통해 볼륨을 관리합니다:
마운트 타입:
1. bind 마운트:
- 호스트 파일/디렉토리를 컨테이너에 마운트
- 호스트와 컨테이너가 동일 데이터 공유
- ConfigMap, Secret, emptyDir 등에 사용
2. tmpfs 마운트:
- 메모리 기반 파일시스템
- 컨테이너 종료 시 데이터 소멸
- /dev/shm, /run 등에 사용
3. 특수 파일시스템:
- proc: /proc
- sysfs: /sys
- cgroup: /sys/fs/cgroup
- devpts: /dev/pts
3.2 마운트 전파
마운트 전파(Propagation) 옵션:
1. private:
- 마운트 이벤트 전파 없음
- 기본값
2. rprivate:
- 재귀적 private
3. shared:
- 마운트 이벤트를 양방향 전파
- 호스트에서 마운트 -> 컨테이너에서도 보임
- 컨테이너에서 마운트 -> 호스트에서도 보임
4. rshared:
- 재귀적 shared
5. slave:
- 호스트 -> 컨테이너 단방향 전파
- 볼륨 플러그인에 유용
6. rslave:
- 재귀적 slave
Kubernetes에서의 사용:
- MountPropagation 필드로 제어
- CSI 드라이버는 주로 Bidirectional (shared) 사용
3.3 CRI 볼륨 처리
CRI를 통한 볼륨 처리:
kubelet이 OCI 스펙에 마운트를 추가:
1. emptyDir:
- kubelet이 호스트에 디렉토리 생성
- bind 마운트로 컨테이너에 전달
2. hostPath:
- 호스트 경로를 직접 bind 마운트
3. ConfigMap/Secret:
- kubelet이 tmpfs에 데이터 생성
- bind 마운트로 컨테이너에 전달
4. PersistentVolumeClaim:
- kubelet이 CSI 드라이버를 통해 볼륨 마운트
- 마운트된 경로를 bind 마운트로 전달
containerd의 역할:
- kubelet이 준비한 마운트 정보를 OCI 스펙에 반영
- runc가 실제 마운트 수행
4. 디바이스 접근
4.1 디바이스 매핑
디바이스 접근 메커니즘:
OCI 스펙의 devices 섹션:
linux:
devices:
- path: "/dev/nvidia0"
type: "c"
major: 195
minor: 0
fileMode: 438
uid: 0
gid: 0
cgroup 디바이스 접근 제어:
linux:
resources:
devices:
- allow: true
type: "c"
major: 195
access: "rwm"
4.2 GPU 지원
GPU 접근 (NVIDIA):
NVIDIA Container Toolkit 통합:
1. nvidia-container-runtime-hook:
- OCI 런타임 훅으로 동작
- 컨테이너 시작 전에 실행
- NVIDIA 드라이버 라이브러리를 컨테이너에 마운트
- GPU 디바이스 노드를 컨테이너에 추가
2. CDI (Container Device Interface):
- 디바이스 벤더 중립적 표준
- /etc/cdi/ 에 디바이스 스펙 정의
- containerd가 CDI 스펙을 읽어 OCI 스펙에 반영
CDI 스펙 예시:
cdiVersion: "0.5.0"
kind: "nvidia.com/gpu"
devices:
- name: "0"
containerEdits:
deviceNodes:
- path: "/dev/nvidia0"
mounts:
- hostPath: "/usr/lib/x86_64-linux-gnu/libnvidia-ml.so"
containerPath: "/usr/lib/x86_64-linux-gnu/libnvidia-ml.so"
4.3 기타 디바이스
기타 디바이스 접근:
1. FPGA:
- CDI 스펙으로 FPGA 디바이스 노출
- 벤더별 디바이스 플러그인
2. InfiniBand/RDMA:
- /dev/infiniband/* 디바이스 매핑
- 네트워크 디바이스 네임스페이스 공유
3. 시리얼/USB:
- 호스트 디바이스 직접 매핑
- privileged 모드 또는 명시적 디바이스 허용
5. SELinux 통합
5.1 SELinux 컨텍스트
SELinux 컨테이너 보안:
OCI 스펙의 SELinux 설정:
linux:
mountLabel: "system_u:object_r:container_file_t:s0:c1,c2"
processLabel: "system_u:system_r:container_t:s0:c1,c2"
구성 요소:
- user: system_u
- role: system_r (프로세스) / object_r (파일)
- type: container_t (프로세스) / container_file_t (파일)
- level: s0:c1,c2 (MCS 카테고리)
MCS (Multi-Category Security):
- 각 컨테이너에 고유 카테고리 할당
- 다른 컨테이너의 파일에 접근 불가
- 호스트와 컨테이너 간 격리
5.2 SELinux 처리 흐름
SELinux 적용:
1. kubelet이 Pod의 SELinux 옵션 결정
- securityContext.seLinuxOptions
- 자동 MCS 라벨 할당
2. CRI를 통해 containerd에 전달
- processLabel: 프로세스 보안 컨텍스트
- mountLabel: 파일 보안 컨텍스트
3. containerd가 OCI 스펙에 반영
4. runc가 실행 시:
- 프로세스에 SELinux 라벨 적용
- rootfs에 SELinux 라벨 적용
- 마운트에 SELinux 라벨 적용
6. AppArmor 통합
6.1 AppArmor 프로파일
AppArmor 컨테이너 보안:
기본 프로파일: cri-containerd.apparmor.d
주요 규칙:
- 파일시스템 접근 제한
deny /proc/kcore r,
deny /sys/firmware/** r,
- 네트워크 접근 제어
- 능력(capability) 제한
- 마운트 연산 제한
프로파일 적용:
OCI 스펙:
process:
apparmorProfile: "cri-containerd.apparmor.d"
6.2 커스텀 프로파일
커스텀 AppArmor 프로파일:
1. 호스트에 프로파일 설치:
/etc/apparmor.d/ 에 프로파일 파일 배치
apparmor_parser -r /etc/apparmor.d/my-profile
2. Pod에서 지정:
annotations:
container.apparmor.security.beta.kubernetes.io/app: localhost/my-profile
3. containerd가 OCI 스펙에 반영:
process:
apparmorProfile: "my-profile"
7. Seccomp 통합
7.1 Seccomp 프로파일
Seccomp (Secure Computing):
허용/차단할 시스템 콜을 정의:
기본 동작: SCMP_ACT_ERRNO (거부)
허용 시스템 콜 예시:
- read, write, open, close
- mmap, mprotect, munmap
- socket, connect, accept
- ...
차단 시스템 콜 예시:
- mount, umount (컨테이너 탈출 방지)
- reboot
- kexec_load
- ptrace (일부 환경)
7.2 Seccomp 적용
Seccomp 프로파일 적용:
1. Kubernetes SecurityContext:
securityContext:
seccompProfile:
type: RuntimeDefault
2. RuntimeDefault 프로파일:
- containerd/runc 기본 Seccomp 프로파일
- 위험한 시스템 콜 차단
- 대부분의 워크로드에 적합
3. 커스텀 프로파일:
securityContext:
seccompProfile:
type: Localhost
localhostProfile: "profiles/my-seccomp.json"
8. 노드에서 직접 확인하기
노드에서 쓰는 도구는 두 개이고 층이 다릅니다. crictl은 CRI API를 호출하므로 kubelet이 보는 것과 같은 뷰를 주고, ctr은 containerd의 네이티브 API를 호출하므로 스냅샷 키나 런타임 타입처럼 CRI에는 없는 필드까지 보여줍니다.
8.1 crictl 연결 설정
crictl을 처음 쓰면 소켓을 못 찾아 몇 초씩 멈춥니다. cri-tools 문서는 기본 연결 시도가 타임아웃될 때까지 시간이 걸리므로 엔드포인트를 명시하라고 권합니다. 설정 파일에 박아두거나 CONTAINER_RUNTIME_ENDPOINT와 IMAGE_SERVICE_ENDPOINT 환경 변수로 지정합니다.
# /etc/crictl.yaml
runtime-endpoint: unix:///run/containerd/containerd.sock
image-endpoint: unix:///run/containerd/containerd.sock
timeout: 10
debug: false
8.2 샌드박스 찾기와 네트워크 네임스페이스 들어가기
Pod의 네트워크는 앱 컨테이너가 아니라 샌드박스가 들고 있으므로 항상 샌드박스부터 찾습니다.
crictl pods
crictl pods --name nginx-65899c769f-wv2gp
crictl inspectp 4dccb216c4adb
crictl pods는 POD ID, CREATED, STATE, NAME, NAMESPACE, ATTEMPT 여섯 칼럼을 출력합니다. STATE가 Ready가 아니면 그 아래 컨테이너는 볼 필요도 없습니다. 샌드박스가 서지 않았다는 뜻이고 원인은 거의 항상 CNI입니다. ATTEMPT가 0이 아니면 kubelet이 같은 Pod의 샌드박스를 이미 여러 번 다시 만들었다는 뜻이라, 그 숫자가 곧 반복 실패의 신호입니다.
crictl inspectp가 돌려주는 것은 CRI의 PodSandboxStatusResponse입니다. status 아래에는 id, metadata, state, created_at, network, linux, labels, annotations, runtime_handler가 들어 있고, network 안에는 ip와 additional_ips가 들어 있습니다. 여기서 ip가 비어 있으면 CNI ADD가 성공하지 못했거나 그 결과가 저장되지 못한 것입니다. runtime_handler는 이 샌드박스가 어떤 런타임 핸들러로 떴는지 알려주므로, RuntimeClass를 쓰는 클러스터에서 의도한 런타임으로 갔는지 여기서 확인합니다.
응답의 나머지 절반인 info는 맵 타입이고, CRI 정의는 키가 임의의 문자열이며 값이 JSON 형식이어야 한다고만 규정합니다. 내용은 런타임이 정하고, containerd는 런타임 스펙과 샌드박스 설정을 여기 넣습니다. 네트워크 네임스페이스 경로도 이 안에 실려 오는데, 키 이름과 중첩 구조는 버전에 따라 달라지므로 정확한 필드는 사용 중인 버전의 문서에서 확인하세요.
경로를 알아냈으면 그 안으로 들어가 실제 인터페이스를 봅니다.
ip netns list
ip netns exec cni-1f2e3d4c-5a6b-7c8d-9e0f-a1b2c3d4e5f6 ip addr
ip netns exec cni-1f2e3d4c-5a6b-7c8d-9e0f-a1b2c3d4e5f6 ip route
ip addr에서 볼 것은 두 가지입니다. eth0이 존재하는가, 거기에 Pod CIDR 범위의 주소가 붙었는가. eth0이 없다면 CNI 메인 플러그인이 인터페이스를 만들지 못한 것이고, 인터페이스는 있는데 주소가 없다면 IPAM 단계에서 끝난 것입니다. 이 구분 하나로 원인이 플러그인 쪽인지 IPAM 쪽인지 갈립니다. ip route에서는 기본 경로가 CNI가 준 게이트웨이를 가리키는지 봅니다. 비어 있으면 밖으로 나가는 트래픽이 전부 죽습니다.
netns 파일은 이미 지워졌는데 프로세스는 살아 있다면 PID로 들어가는 방법만 통합니다.
nsenter -t 12345 -n ip addr
lsns -t net
8.3 CNI 설정이 실제로 로드됐는지
CNI 설정 파일이 디스크에 있다는 것과 containerd가 그것을 읽었다는 것은 다른 이야기입니다. containerd의 CRI Status 응답에는 RuntimeReady와 NetworkReady 두 조건이 있고, 네트워크가 준비되지 않았을 때의 reason은 NetworkPluginNotReady, 메시지는 Network plugin returns error 뒤에 실제 원인이 붙는 형식입니다. 응답의 info 맵에는 config, golang, cniconfig, lastCNILoadStatus, 그리고 네트워크 이름이 붙은 lastCNILoadStatus 키가 실려 옵니다.
crictl info
cniconfig를 보면 containerd가 실제로 파싱해서 들고 있는 conflist가 나옵니다. 디스크에 있는 파일과 이 내용이 다르다면, 파일을 고친 뒤 containerd가 다시 읽지 않았거나 JSON이 깨져서 파싱에 실패한 것입니다. lastCNILoadStatus에 에러가 남아 있다면 그 문자열이 사실상 답입니다.
8.4 컨테이너가 받은 스냅샷과 마운트
스토리지 쪽은 반대로 앱 컨테이너를 봅니다.
crictl ps
crictl ps -a
crictl inspect 1f73f2d81bf98
ctr -n k8s.io containers list
ctr -n k8s.io containers info 1f73f2d81bf98
crictl ps는 CONTAINER ID, IMAGE, CREATED, STATE, NAME, ATTEMPT를 출력하고, -a를 붙이면 종료된 컨테이너까지 나옵니다. ctr은 네임스페이스를 반드시 지정해야 합니다. CRI 플러그인은 k8s.io containerd 네임스페이스를 쓰므로, 플래그를 빼면 아무것도 안 나오고 조회 대상이 없다는 결론을 잘못 내리게 됩니다. ctr containers list는 CONTAINER, IMAGE, RUNTIME 세 칼럼을 출력하는데, RUNTIME 칼럼이 어떤 shim으로 떴는지 알려주므로 RuntimeClass 문제를 여기서도 잡습니다.
ctr containers info는 컨테이너 레코드 전체를 JSON으로 뱉습니다. 여기에 스냅샷터 이름과 스냅샷 키가 들어 있고, spec 플래그를 붙이면 OCI 런타임 스펙만 볼 수 있습니다. 이 컨테이너에 SELinux 라벨이 붙었는가, 이 마운트의 전파 옵션이 무엇인가 같은 질문은 mounts 배열과 linux 아래 devices, mountLabel, processLabel에서 읽으면 됩니다. 쓸 수 있는 스냅샷터 목록은 플러그인 목록으로 확인합니다.
호스트 쪽 마운트는 findmnt가 가장 빠릅니다. 전파 속성까지 찍어주므로 OCI 스펙에 적힌 것과 커널이 실제로 만든 것의 어긋남을 눈으로 확인할 수 있습니다.
ctr plugins ls
findmnt -o TARGET,SOURCE,FSTYPE,PROPAGATION
findmnt -T /var/lib/kubelet/pods
9. config.toml 버전 2와 버전 3
주워온 스니펫을 config.toml에 붙였는데 아무 효과가 없는 일이 흔합니다. 대부분 섹션 헤더가 containerd 메이저 버전에 따라 통째로 바뀌기 때문입니다. 버전 2는 1.3에서 도입됐고 CRI 설정이 전부 한 섹션에 모여 있습니다. 버전 3은 2.0에서 도입되면서 그 섹션을 런타임과 이미지 둘로 쪼갰습니다. 버전 2 파일은 2.x에서도 지원되며 자동으로 버전 3으로 변환되지만, 버전 1은 2.0부터 지원되지 않습니다.
# containerd 1.x — config version 2
version = 2
[plugins."io.containerd.grpc.v1.cri".cni]
bin_dir = "/opt/cni/bin"
conf_dir = "/etc/cni/net.d"
max_conf_num = 1
[plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runc.options]
SystemdCgroup = true
# containerd 2.x — config version 3
version = 3
[plugins.'io.containerd.cri.v1.runtime'.cni]
bin_dir = '/opt/cni/bin'
conf_dir = '/etc/cni/net.d'
max_conf_num = 1
[plugins.'io.containerd.cri.v1.runtime'.containerd.runtimes.runc.options]
SystemdCgroup = true
[plugins.'io.containerd.cri.v1.images']
snapshotter = 'overlayfs'
손으로 헤더를 조립하지 말고 기본 설정을 뽑아서 시작하세요. 이 명령은 지금 설치된 containerd 바이너리가 실제로 이해하는 스키마를 그대로 출력하므로, 버전을 잘못 고를 여지가 없습니다.
containerd config default > /etc/containerd/config.toml
systemctl daemon-reload
systemctl restart containerd
CNI 쪽 키의 의미는 문서에 이렇게 적혀 있습니다. conf_dir은 관리자가 CNI conf를 두는 디렉토리, bin_dir은 플러그인 바이너리가 있는 디렉토리이며 bin_dir은 2.1에서 deprecated로 표시되고 bin_dirs로 대체됐습니다. max_conf_num은 CNI 설정 디렉토리에서 읽어들일 설정 파일의 최대 개수이며 기본값은 1입니다. conf_template은 CNI 설정을 생성할 golang 템플릿 파일의 경로이고, 설정되면 containerd가 템플릿으로부터 CNI 설정 파일을 만들어냅니다. ip_pref는 Pod의 대표 IP를 고르는 전략이며 기본은 첫 번째 ipv4 주소입니다.
스냅샷터의 기본값은 overlayfs이고 문서는 이것을 Docker의 overlay2 스토리지 드라이버에 해당한다고 설명합니다. SystemdCgroup의 기본값은 버전 2와 버전 3 모두 false이지만 systemd 기반 호스트에서는 true가 권장됩니다. 기본값이 false라는 점이 중요한데, kubelet만 systemd로 바꾸고 containerd를 그대로 두면 두 관리자가 서로 다른 cgroup 뷰를 갖게 되기 때문입니다.
10. 워크스루: ContainerCreating에서 멈춘 Pod
Pod가 ContainerCreating에서 나오지 않고, 이벤트에 CNI 관련 에러가 찍혀 있습니다. 순서를 지키면 대부분 몇 분 안에 끝나고, 안 지키면 config.toml부터 뜯어보다가 한 시간을 씁니다.
첫째, kubelet 이벤트를 읽습니다. 여기 찍힌 문자열이 나머지 조사의 방향을 정합니다. containerd가 네트워크 플러그인이 준비되지 않았다고 보고하면 reason은 NetworkPluginNotReady이고 메시지는 Network plugin returns error로 시작합니다. 이 문자열이 보이면 개별 Pod 문제가 아니라 노드 전체의 CNI가 죽은 것이므로 같은 노드의 Pod를 함께 보면 범위가 즉시 갈립니다.
kubectl describe pod my-app-7d9f6c8b4-xk2wp
kubectl get pods -A -o wide --field-selector spec.nodeName=node-3
둘째, 샌드박스가 아예 안 만들어졌는지, 만들어졌는데 IP가 없는지를 가릅니다. 목록에 없다면 RunPodSandbox 자체가 실패한 것이고, 목록에 있는데 network 아래 ip가 비었다면 CNI ADD가 실패한 것입니다. 앞쪽은 런타임 문제일 수 있고 뒤쪽은 거의 확실히 네트워크 문제입니다.
crictl pods --name my-app-7d9f6c8b4-xk2wp
crictl inspectp 4dccb216c4adb
셋째, containerd가 CNI 설정을 로드했는지 봅니다. 8.3절의 cniconfig와 lastCNILoadStatus를 읽고, 비었으면 디스크의 conf_dir을 확인합니다. 파일이 분명히 있는데도 비었다면 JSON이 깨진 것이므로 파싱을 직접 시켜봅니다. conflist를 손으로 고치다 쉼표를 하나 남긴 경우가 여기서 잡힙니다.
crictl info
ls -l /etc/cni/net.d/
python3 -m json.tool /etc/cni/net.d/10-calico.conflist > /dev/null
넷째, conflist가 참조하는 플러그인 바이너리가 bin_dir에 있는지 봅니다. plugins 배열의 type 값이 곧 실행될 바이너리 이름이고, 하나라도 없으면 CNI 실행 자체가 안 됩니다. 노드 이미지를 바꾸거나 CNI를 업그레이드한 직후에 이 형태로 깨집니다. bandwidth나 portmap 같은 메타 플러그인은 다른 패키지에서 오기 때문에 조용히 빠지기 쉽습니다.
ls -l /opt/cni/bin/
grep -o '"type"[^,]*' /etc/cni/net.d/10-calico.conflist
다섯째, 여기까지 다 정상이면 IPAM 고갈을 의심합니다. 노드에 할당된 대역을 전부 써버리면 CNI ADD는 호출되지만 주소를 받지 못해 실패합니다. 증상이 특정 노드에서, Pod를 새로 띄울 때만 나타나 노드 단위로 뭉쳐 보입니다. 확인 방법은 CNI 구현마다 다르므로 쓰고 있는 플러그인의 IPAM 저장소를 봐야 합니다.
여섯째, 그래도 안 풀리면 죽은 네임스페이스가 남았는지 봅니다. kubelet이나 노드가 비정상 종료하면 CNI DEL이 호출되지 못한 채 바인드 마운트가 남고, IPAM 입장에서는 그 주소가 계속 사용 중입니다. 네임스페이스 개수와 Ready 샌드박스 개수를 비교하면 격차가 드러납니다.
ip netns list | wc -l
crictl pods
11. 실패 사례와 진단 순서
11.1 kubelet 크래시 후 남은 네트워크 네임스페이스
증상은 특정 노드에서만 새 Pod가 IP를 못 받는 것입니다. 노드를 재부팅하면 사라졌다가 며칠 뒤 다시 나타납니다. 진단은 네임스페이스 개수와 샌드박스 개수의 비교에서 시작합니다. 격차가 있으면 CNI DEL이 호출되지 못한 채 사라진 샌드박스가 있고 그만큼의 IP가 회수되지 않은 것이므로, 다음은 노드 저널에서 kubelet이나 containerd가 정리 경로를 거치지 못하고 죽은 시점을 찾습니다. 손으로 치우지는 마세요. 살아 있는 Pod의 네임스페이스를 잘못 지우면 네트워크가 즉시 끊깁니다. 노드를 비우고 교체하는 쪽이 빠르고 안전합니다.
11.2 max_conf_num이 엉뚱한 conflist를 고르는 경우
증상은 CNI를 교체했는데 예전 네트워크로 붙거나, conflist를 새로 넣었는데 반영되지 않는 것입니다. 원인은 max_conf_num의 기본값 1입니다. conf_dir에 파일이 여러 개여도 하나만 로드되고 이름 순서가 승패를 정하므로, 설치 스크립트가 남긴 예전 파일이 앞서면 그대로 이깁니다. 진단은 8.3절의 cniconfig가 어느 파일의 내용인지 확인하는 것으로 끝납니다. 정답은 디렉토리를 청소하는 것이지 max_conf_num을 늘리는 것이 아닙니다.
11.3 마운트 전파가 private이라 CSI 드라이버가 깨지는 경우
증상은 CSI 드라이버 Pod는 정상인데 그 드라이버를 쓰는 워크로드에서 볼륨이 비어 보이는 것입니다. 드라이버는 마운트했다고 보고하는데 앱 컨테이너 안에는 아무것도 없습니다. 3.2절의 기본값 private이 원인이라, 드라이버 컨테이너에서 새로 만든 마운트가 호스트로 올라가지 못하고 다른 컨테이너로도 내려가지 못합니다. 이 값을 정하는 것은 MountPropagation 필드이고 CSI 드라이버는 주로 Bidirectional을 씁니다. 진단은 호스트에서 findmnt로 전파 속성을 읽는 것이고, 앱이 아니라 드라이버 Pod의 매니페스트를 봐야 한다는 점이 함정입니다.
11.4 SELinux MCS 거부가 권한 오류로 보이는 경우
증상은 컨테이너 안에서 파일을 여는데 permission denied가 나는 것입니다. 파일 소유자와 모드를 몇 번을 확인해도 맞습니다. 5.1절의 MCS가 원인이라 라벨이 다른 컨테이너가 만든 파일은 모드가 아무리 열려 있어도 거부됩니다. hostPath를 두 Pod가 공유할 때, 재시작으로 카테고리가 바뀌었을 때 특히 잘 납니다. 결정적 단서는 호스트의 감사 로그입니다. 라벨이 원인이면 거부가 호스트 쪽에 기록되고 그렇지 않으면 아무것도 남지 않으므로, 이 한 번의 확인으로 권한 문제인지 라벨 문제인지가 갈립니다.
11.5 seccomp RuntimeDefault가 만드는 설명되지 않는 EPERM
증상은 특정 라이브러리나 런타임이 컨테이너 안에서만 실패하는 것입니다. 에러는 EPERM인데 아무 권한도 부족해 보이지 않습니다. 7.1절의 기본 동작이 SCMP_ACT_ERRNO, 즉 거부라는 점이 답입니다. 차단된 콜은 커널에 도달하지 않고 에러로 돌아오는데, 애플리케이션은 이것을 권한 문제로 보고하므로 로그만으로는 구분이 안 됩니다. 진단은 프로파일을 잠깐 Unconfined로 바꿔 증상이 사라지는지 보고, 사라진다면 어떤 콜이 막혔는지 찾아 커스텀 프로파일로 그것만 허용하는 것입니다. Unconfined는 진단 도구이지 해결책이 아닙니다.
12. 언제 이 계층을 직접 건드리지 않나
첫째, 이 글에 나온 동작의 대부분은 containerd가 아니라 CNI 플러그인과 kubelet의 일입니다. IP를 주는 것도, 라우팅을 넣는 것도, 네트워크 정책을 거는 것도 전부 플러그인입니다. Pod에 IP가 안 붙는다는 문제의 해결책은 거의 항상 Calico나 Cilium 쪽 설정이지 containerd 쪽이 아닙니다.
둘째, 관리형 노드 그룹에서 config.toml을 손으로 고치면 되돌아옵니다. 노드를 교체 가능한 자원으로 다루는 환경에서는 직접 넣은 변경이 다음 스케일 아웃이나 업그레이드에서 사라집니다. 살아남는 변경은 노드 이미지, 부트스트랩 스크립트, 또는 그 플랫폼이 제공하는 노드 설정 메커니즘을 통한 것뿐입니다. 노드에 들어가서 고친 것은 진단이지 배포가 아닙니다.
셋째, 이 계층을 읽는 것과 쓰는 것은 다릅니다. crictl과 ctr로 상태를 읽는 것은 안전하지만, 컨테이너나 샌드박스를 이 도구로 지우는 것은 kubelet이 모르는 사이에 세상을 바꾸는 일이라 kubelet이 예상 밖의 복구를 시작할 수 있습니다. 죽이고 싶으면 kubectl 쪽에서 Pod을 지우세요.
넷째, 표준 플러그인 조합으로 충분한데 conf_template을 쓰면 디버깅 난이도가 한 단계 올라갑니다. 템플릿은 노드마다 다른 설정을 생성해야 할 때를 위한 도구이고, 그런 요구가 없다면 정적 conflist 하나가 낫습니다. 문제가 생겼을 때 파일을 열어 읽으면 끝나기 때문입니다.
13. 참고 자료
- CRI 플러그인 설정: https://github.com/containerd/containerd/blob/main/docs/cri/config.md (2026-08-16 확인)
- Getting Started: https://github.com/containerd/containerd/blob/main/docs/getting-started.md (2026-08-16 확인)
- 스냅샷터 목록: https://github.com/containerd/containerd/blob/main/docs/snapshotters/README.md (2026-08-16 확인)
- crictl 문서: https://github.com/kubernetes-sigs/cri-tools/blob/master/docs/crictl.md (2026-08-16 확인)
- crictl로 노드 디버깅: https://kubernetes.io/docs/tasks/debug/debug-cluster/crictl/ (2026-08-16 확인)
- 컨테이너 런타임 설정: https://kubernetes.io/docs/setup/production-environment/container-runtimes/ (2026-08-16 확인)
- CRI API 정의: https://github.com/kubernetes/cri-api/blob/master/pkg/apis/runtime/v1/api.proto (2026-08-16 확인)
14. 정리
containerd의 네트워킹과 스토리지는 표준 인터페이스를 통한 위임 모델을 따릅니다. CNI를 통한 네트워크 구성, OCI 스펙을 통한 마운트 관리, CDI를 통한 디바이스 접근, SELinux/AppArmor/Seccomp를 통한 보안 격리가 핵심입니다. 이러한 표준 기반 설계로 containerd는 다양한 네트워킹 솔루션과 보안 모듈을 유연하게 통합합니다. 다음 글에서는 containerd의 CRI 구현과 Kubernetes 런타임 통합을 분석합니다.