LabHub

블로그

containerd CRI 구현: Kubernetes 런타임 통합

한국어English日本語

containerd CRI 구현: Kubernetes 런타임 통합

containerd는 Kubernetes CRI(Container Runtime Interface)를 내장 플러그인으로 구현합니다. 이 글에서는 CRI gRPC 서비스의 구현 상세, Pod Sandbox 관리, 컨테이너 스펙 변환, 스트리밍 API, RuntimeClass, NRI를 분석합니다.


1. CRI gRPC 서비스

1.1 서비스 구조

CRI는 두 개의 gRPC 서비스로 구성됩니다:

CRI gRPC 서비스:

RuntimeService:
  +-- PodSandbox 관리
  |     RunPodSandbox
  |     StopPodSandbox
  |     RemovePodSandbox
  |     PodSandboxStatus
  |     ListPodSandbox
  |
  +-- Container 관리
  |     CreateContainer
  |     StartContainer
  |     StopContainer
  |     RemoveContainer
  |     ListContainers
  |     ContainerStatus
  |     UpdateContainerResources
  |
  +-- 스트리밍
  |     ExecSync
  |     Exec
  |     Attach
  |     PortForward
  |
  +-- 런타임 정보
        Status
        Version

ImageService:
  +-- PullImage
  +-- ListImages
  +-- ImageStatus
  +-- RemoveImage
  +-- ImageFsInfo

1.2 소켓 구성

CRI 소켓:

containerd는 동일한 gRPC 소켓에서 CRI를 제공:
  /run/containerd/containerd.sock

kubelet 설정:
  --container-runtime-endpoint=unix:///run/containerd/containerd.sock

CRI 플러그인이 containerd 서버에 CRI 서비스를 등록:
  플러그인 ID: io.containerd.grpc.v1.cri

2. Pod Sandbox

2.1 Pod Sandbox 개념

Pod Sandbox는 Pod의 격리 환경을 나타냅니다:

Pod Sandbox 구성:

Pod Sandbox = Pause 컨테이너 + 공유 네임스페이스

공유 리소스:
  - 네트워크 네임스페이스 (같은 IP, 포트 공간)
  - IPC 네임스페이스 (프로세스 간 통신)
  - UTS 네임스페이스 (호스트명)
  - PID 네임스페이스 (선택적)

격리 리소스:
  - 마운트 네임스페이스 (컨테이너별)
  - cgroup (컨테이너별 리소스 제한)

2.2 RunPodSandbox 흐름

RunPodSandbox 처리:

1. Sandbox 메타데이터 생성
   - ID 생성
   - 로그 디렉토리 생성
        |
        v
2. Pause 이미지 풀
   - sandbox_image 설정에서 이미지 결정
   - 기본값: 버전에 따라 다름 (문서 기준 registry.k8s.io/pause:3.10.2)
        |
        v
3. Pause 컨테이너 스냅샷 준비
        |
        v
4. OCI 스펙 생성
   - Pause 컨테이너용 최소 스펙
   - 호스트명, DNS 설정 포함
        |
        v
5. 네트워크 네임스페이스 생성
   - /var/run/netns/ 에 네임스페이스 파일 생성
        |
        v
6. CNI 플러그인 호출
   - 네트워크 인터페이스 생성
   - IP 할당
        |
        v
7. Pause 컨테이너 Task 생성 및 시작
        |
        v
8. Sandbox 상태를 SANDBOX_READY로 설정

2.3 Pause 컨테이너

Pause 컨테이너의 역할:

1. 네임스페이스 보유자:
   - 네트워크 네임스페이스의 첫 번째 프로세스
   - App 컨테이너가 종료되어도 네임스페이스 유지
   - 네임스페이스의 생명주기를 Pod에 바인딩

2. PID 1 역할:
   - Pod PID 네임스페이스의 init 프로세스
   - 좀비 프로세스 수거 (reap)
   - 최소 리소스 사용 (약 1MB)

3. 동작:
   - pause() 시스템 콜로 무한 대기
   - SIGTERM 수신 시 종료

3. 컨테이너 스펙 변환

3.1 CRI 요청에서 OCI 스펙으로

스펙 변환 과정:

CRI ContainerConfig:
  - Image
  - Command, Args
  - Envs
  - Mounts
  - Devices
  - SecurityContext
  - Resources
        |
        v
containerd CRI 플러그인이 변환
        |
        v
OCI Runtime Spec:
  - root (이미지 스냅샷 경로)
  - process (커맨드, env, capabilities)
  - mounts (볼륨, 특수 파일시스템)
  - linux.resources (cgroup 설정)
  - linux.namespaces (Sandbox와 공유)
  - hooks (OCI 훅)

3.2 리소스 변환

Kubernetes 리소스 -> OCI 리소스 변환:

CPU:
  requests.cpu: 250m
    -> linux.resources.cpu.shares = 256
       (1000m = 1024 shares 기준)

  limits.cpu: 500m
    -> linux.resources.cpu.quota = 50000
       linux.resources.cpu.period = 100000
       (500m/1000m * 100000us)

Memory:
  limits.memory: 512Mi
    -> linux.resources.memory.limit = 536870912
       (바이트 단위)

  requests.memory:
    -> 스케줄링에만 사용, OCI 스펙에 반영하지 않음

Hugepages:
  limits.hugepages-2Mi: 100Mi
    -> linux.resources.hugepageLimits:
         pageSize: "2MB"
         limit: 104857600

3.3 보안 컨텍스트 변환

SecurityContext -> OCI 스펙 변환:

runAsUser: 1000
  -> process.user.uid = 1000

runAsGroup: 1000
  -> process.user.gid = 1000

readOnlyRootFilesystem: true
  -> root.readonly = true

privileged: true
  -> 모든 capabilities 부여
  -> 모든 디바이스 접근 허용
  -> AppArmor/SELinux/Seccomp 비활성화

capabilities:
  add: ["NET_ADMIN"]
  drop: ["ALL"]
  -> process.capabilities 설정

seccompProfile:
  type: RuntimeDefault
  -> linux.seccomp 프로파일 적용

4. 스트리밍 API

4.1 ExecSync

ExecSync 동작:

동기적으로 컨테이너에서 명령 실행:

1. kubelet이 ExecSync(containerID, cmd, timeout) 호출
        |
        v
2. containerd가 shim에 Exec 요청
        |
        v
3. shim이 runc exec 실행
   - 컨테이너 네임스페이스에 새 프로세스 생성
        |
        v
4. stdout/stderr 캡처
        |
        v
5. 프로세스 종료 대기
        |
        v
6. exit code + stdout + stderr 반환

사용 사례: liveness/readiness probe, kubectl exec (동기)

4.2 Exec (비동기 스트리밍)

Exec 스트리밍 동작:

1. kubelet이 Exec(containerID, cmd, stdin, stdout, stderr) 호출
        |
        v
2. containerd가 스트리밍 URL 반환
   - 스트리밍 서버 주소: https://node:10250/exec/...
        |
        v
3. kubelet이 클라이언트에 URL 전달
        |
        v
4. 클라이언트가 WebSocket/SPDY로 스트리밍 서버 연결
        |
        v
5. 스트리밍 서버가 containerd에 실제 Exec 수행
        |
        v
6. stdin/stdout/stderr 양방향 스트리밍

스트리밍 프로토콜:
  - SPDY (레거시)
  - WebSocket (최신)

4.3 Attach

Attach 동작:

실행 중인 컨테이너의 메인 프로세스에 연결:

1. 스트리밍 URL 생성 (Exec와 유사)
        |
        v
2. 컨테이너의 stdin/stdout/stderr에 연결
   - 새 프로세스를 생성하지 않음
   - 기존 프로세스의 I/O에 직접 연결
        |
        v
3. 양방향 스트리밍

사용 사례: kubectl attach

4.4 PortForward

PortForward 동작:

Pod의 포트에 로컬 트래픽 전달:

1. 스트리밍 URL 생성
        |
        v
2. Pod의 네트워크 네임스페이스에서 socat/nsenter 실행
   - 지정 포트에 TCP 연결
        |
        v
3. 로컬 포트와 Pod 포트 간 양방향 데이터 전달

구현:
  containerd는 Pod의 네트워크 네임스페이스에 진입하여
  대상 포트에 TCP 연결을 생성합니다.

사용 사례: kubectl port-forward

5. RuntimeClass

5.1 RuntimeClass 매핑

RuntimeClass 처리:

1. Kubernetes RuntimeClass 리소스:
   apiVersion: node.k8s.io/v1
   kind: RuntimeClass
   metadata:
     name: kata
   handler: kata

2. kubelet이 CRI RunPodSandbox 호출 시
   runtime_handler = "kata" 전달

3. containerd가 handler를 런타임 설정에 매핑:
   [plugins."io.containerd.grpc.v1.cri".containerd.runtimes.kata]
     runtime_type = "io.containerd.kata.v2"

4. 해당 shim 바이너리로 Task 생성:
   containerd-shim-kata-v2

5.2 기본 런타임

기본 런타임 설정:

[plugins."io.containerd.grpc.v1.cri".containerd]
  default_runtime_name = "runc"

[plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runc]
  runtime_type = "io.containerd.runc.v2"

[plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runc.options]
  SystemdCgroup = true

Pod에 runtimeClassName이 없으면 기본 런타임(runc) 사용

5.3 RuntimeClass별 오버헤드

RuntimeClass 리소스 오버헤드:

apiVersion: node.k8s.io/v1
kind: RuntimeClass
metadata:
  name: kata
handler: kata
overhead:
  podFixed:
    memory: "160Mi"
    cpu: "250m"

오버헤드 처리:
  - kubelet이 Pod 리소스에 오버헤드를 추가
  - 스케줄러가 오버헤드를 포함하여 노드 선택
  - VM 기반 런타임의 고정 비용 반영

6. NRI (Node Resource Interface)

6.1 NRI 개요

NRI는 containerd의 플러그인 확장 메커니즘으로, 컨테이너 생명주기 이벤트에 훅을 등록할 수 있습니다:

NRI 아키텍처:

kubelet -> containerd
               |
               +-- NRI Plugin 1 (리소스 할당)
               +-- NRI Plugin 2 (토폴로지 인식)
               +-- NRI Plugin 3 (모니터링)

NRI 플러그인은 컨테이너 생명주기 이벤트를 수신하고
OCI 스펙을 수정할 수 있습니다.

6.2 NRI 훅 포인트

NRI 훅 포인트:

1. RunPodSandbox:
   - Pod 생성 시 호출
   - Pod 레벨 리소스 할당

2. CreateContainer:
   - 컨테이너 생성 시 호출
   - OCI 스펙 수정 가능
   - CPU 핀닝, 메모리 NUMA 할당 등

3. StartContainer:
   - 컨테이너 시작 시 호출

4. UpdateContainer:
   - 리소스 업데이트 시 호출

5. StopContainer:
   - 컨테이너 중지 시 호출
   - 리소스 해제

6. RemoveContainer:
   - 컨테이너 삭제 시 호출

6.3 NRI 사용 사례

NRI 활용 사례:

1. CPU/메모리 토폴로지 인식 할당:
   - NUMA 노드 인식 CPU 핀닝
   - 메모리를 특정 NUMA 노드에 할당
   - 토폴로지 매니저와 연동

2. 디바이스 리소스 관리:
   - GPU 할당 최적화
   - RDMA 리소스 관리
   - 디바이스 플러그인 보완

3. 보안 정책 적용:
   - 동적 Seccomp 프로파일
   - 런타임 보안 규칙 주입

4. 모니터링/감사:
   - 컨테이너 시작/종료 이벤트 로깅
   - 리소스 사용 추적

7. 이미지 서비스

7.1 이미지 Pull

CRI PullImage 처리:

1. kubelet이 PullImage(imageSpec, authConfig) 호출
        |
        v
2. containerd가 이미지 참조 해석
   - 태그 또는 다이제스트
   - 레지스트리 인증 정보 적용
        |
        v
3. 이미지 다운로드
   - Manifest, Config, Layers
   - k8s.io 네임스페이스에 저장
        |
        v
4. 레이어 언패킹
   - Snapshotter로 스냅샷 체인 생성
        |
        v
5. 이미지 참조(imageRef) 반환

7.2 이미지 캐싱

이미지 캐싱:

containerd의 이미지 캐싱:
  - Content Store에 레이어가 이미 존재하면 다운로드 건너뜀
  - Snapshotter에 스냅샷이 이미 존재하면 언패킹 건너뜀
  - 다이제스트 기반 정확한 중복 제거

kubelet의 이미지 정책:
  imagePullPolicy: Always
    -> 항상 레지스트리 매니페스트 확인 (레이어는 캐시 활용)
  imagePullPolicy: IfNotPresent
    -> 로컬에 없을 때만 Pull
  imagePullPolicy: Never
    -> 로컬 이미지만 사용

8. 모니터링과 디버깅

8.1 CRI 메트릭

containerd CRI 관련 메트릭:

container_runtime_cri_operations_total:    CRI 연산 수
container_runtime_cri_operations_errors_total: CRI 연산 오류 수
container_runtime_cri_operations_latency_seconds: CRI 연산 지연

containerd 내부 메트릭:
  containerd_task_count:                   실행 중인 Task 수
  containerd_container_count:              컨테이너 수
  containerd_image_pull_duration_seconds:   이미지 Pull 소요 시간

8.2 디버깅 도구

디버깅 도구:

1. crictl (CRI CLI):
   crictl ps              # 컨테이너 목록
   crictl pods            # Pod 목록
   crictl images          # 이미지 목록
   crictl inspect CONTAINER_ID  # 컨테이너 상세
   crictl logs CONTAINER_ID     # 컨테이너 로그
   crictl exec -it CONTAINER_ID /bin/sh  # exec

2. ctr (containerd CLI):
   ctr -n k8s.io containers list
   ctr -n k8s.io tasks list
   ctr -n k8s.io images list

3. containerd 로그:
   journalctl -u containerd -f

9. crictl로 실제로 하는 작업

앞의 여덟 장이 CRI가 무엇인지를 설명했다면, 노드에서 CRI를 실제로 만지는 통로는 crictl 하나입니다. 그런데 crictl은 첫 실행부터 사람을 헷갈리게 만드는 구석이 있습니다. cri-tools 문서는 기본 엔드포인트가 더 이상 권장되지 않으며 런타임 엔드포인트를 항상 명시하라고 못박습니다. 명시하지 않으면 crictl은 알려진 소켓 후보를 순서대로 시도하고, 실패한 후보마다 연결이 끊길 때까지 몇 초씩 소모합니다. 아무 일도 하지 않는 crictl 명령 하나가 십수 초씩 걸린다면 대개 원인은 이 탐색입니다. 설정 파일은 /etc/crictl.yaml이고, 문서가 보여주는 키는 다음과 같습니다.

# /etc/crictl.yaml
runtime-endpoint: unix:///run/containerd/containerd.sock
image-endpoint: unix:///run/containerd/containerd.sock
timeout: 2
debug: true
pull-image-on-create: false
max-retries: 3

같은 값을 플래그로 그때그때 줄 수도 있습니다. -r, --runtime-endpoint가 런타임 서비스, -i, --image-endpoint가 이미지 서비스이고 후자의 기본값은 runtime-endpoint 설정을 그대로 따릅니다. -t, --timeout의 기본값은 2초, --max-retries의 기본값은 3이며, 명시적으로 설정된 엔드포인트에 대해 지수 백오프로 재시도합니다. 응답이 이상할 때는 -D, --debug를 붙여 요청과 응답을 그대로 보는 것이 로그를 뒤지는 것보다 빠릅니다.

crictl info                     # 런타임 정보
crictl pods                     # ID / Created / State / Name / Namespace / Attempt / Runtime
crictl ps -a                    # CONTAINER / IMAGE / CREATED / STATE / NAME / ATTEMPT / POD ID / POD / NAMESPACE
crictl ps -p POD_ID             # 특정 Pod의 컨테이너만
crictl inspectp POD_ID          # Pod Sandbox 상태
crictl inspect CONTAINER_ID     # 컨테이너 상태
crictl logs -f --tail 100 CONTAINER_ID
crictl logs -p CONTAINER_ID     # 직전 인스턴스의 로그
crictl stats                    # 컨테이너 리소스 사용량
crictl statsp                   # Pod 단위 통계
crictl imagefsinfo              # 이미지 파일시스템 사용량

출력에서 무엇을 읽어야 하는지가 실제 실력 차이를 만듭니다. crictl pods의 Runtime 컬럼은 그 Pod가 어느 런타임 핸들러로 내려갔는지를 보여주므로, RuntimeClass가 의도대로 적용됐는지를 매니페스트가 아니라 결과로 확인할 수 있습니다. crictl ps-a가 없으면 실행 중인 것만 보여주기 때문에, 방금 죽어서 재시작을 기다리는 컨테이너를 통째로 놓칩니다. CrashLoop을 쫓을 때는 -a가 사실상 필수입니다. ATTEMPT 컬럼은 kubelet이 같은 컨테이너를 몇 번째로 다시 만들고 있는지를 알려주고, POD ID 컬럼은 컨테이너와 샌드박스를 잇는 유일한 연결고리입니다. crictl inspect가 돌려주는 구조에는 3장에서 설명한 변환의 결과, 즉 최종적으로 적용된 스펙이 담겨 있으므로 요청한 SecurityContext가 정말로 반영됐는지를 눈으로 확인할 수 있습니다. 필드 이름은 버전에 따라 달라지므로 정확한 필드는 사용 중인 버전의 문서에서 확인하세요.


10. 동작을 좌우하는 설정 키

CRI 쪽 설정도 이미지 쪽과 마찬가지로 버전 분기부터 봐야 합니다. containerd 1.x의 version 2에서는 모든 것이 [plugins."io.containerd.grpc.v1.cri"] 아래에 있었고, containerd 2.x의 version 3은 이를 런타임 쪽 [plugins.'io.containerd.cri.v1.runtime']과 이미지 쪽 [plugins.'io.containerd.cri.v1.images']로 나눕니다. 섹션 이름을 틀리면 containerd는 오류를 내지 않고 그 블록을 무시한 채 기본값으로 뜹니다. 설정을 바꿨는데 아무 변화가 없다면 값이 아니라 섹션 이름을 먼저 의심해야 하는 이유입니다.

# containerd 1.x
version = 2

[plugins."io.containerd.grpc.v1.cri"]
  sandbox_image = "registry.k8s.io/pause:3.10.2"
  max_container_log_line_size = 16384
  enable_unprivileged_ports = true

[plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runc.options]
  SystemdCgroup = false
# containerd 2.x
version = 3

[plugins.'io.containerd.cri.v1.images'.pinned_images]
  sandbox = 'registry.k8s.io/pause:3.10.2'

[plugins.'io.containerd.cri.v1.runtime']
  max_container_log_line_size = 16384
  enable_unprivileged_ports = true
  device_ownership_from_security_context = false

[plugins.'io.containerd.cri.v1.runtime'.containerd.runtimes.runc.options]
  SystemdCgroup = true

pause 이미지의 위치가 버전 사이에서 바뀌었다는 점이 실무에서 가장 자주 걸립니다. 1.x에서는 CRI 섹션의 sandbox_image 한 줄이었지만, 2.x에서는 이미지 플러그인의 pinned_images 아래 sandbox 키로 옮겨갔습니다. 폐쇄망에서 pause 이미지를 사내 레지스트리로 바꿔 놓은 클러스터가 containerd 2.x로 올라가면 옛 키가 무시되고 기본값인 공개 레지스트리 주소로 돌아가는데, 그 결과는 노드 전체에서 Pod가 하나도 뜨지 않는 것입니다.

SystemdCgroup의 기본값은 두 버전 모두 false입니다. 이 값은 kubelet의 cgroup 드라이버 설정과 반드시 일치해야 합니다. kubelet이 systemd 드라이버로 도는데 런타임이 cgroupfs로 돌면, 같은 Pod의 cgroup 경로를 서로 다르게 계산하기 때문에 컨테이너가 뜨긴 뜨지만 리소스 회계가 어긋나고 상태가 불안정해집니다. 증상은 특정 Pod의 문제로 보이지 않고 노드 전반의 재시작으로 나타나기 때문에 원인을 짚기까지 시간이 오래 걸립니다. kubeadm으로 설치한 클러스터의 기본값이 systemd이므로, 설정 파일을 직접 만든 노드에서 이 값을 true로 바꾸는 것을 잊는 것이 전형적인 실수입니다.

max_container_log_line_size의 기본값은 16384바이트이고, 문서의 설명은 한도를 넘는 로그 줄이 여러 줄로 분할된다는 것입니다. 애플리케이션이 한 줄짜리 JSON 로그를 뱉는데 그 줄이 16KB를 넘으면 로그 수집기는 깨진 JSON 조각 두 개를 받게 됩니다. 파싱 실패가 간헐적으로만 생기고 특정 요청에서만 재현된다면 이 값을 먼저 의심하는 것이 좋습니다. 페이로드를 로그에 통째로 찍는 서비스에서 특히 잘 걸립니다.

enable_unprivileged_ports는 기본값 true이며, 문서의 설명은 호스트 네트워크를 쓰지 않는 모든 컨테이너에 대해 net.ipv4.ip_unprivileged_port_start=0을 설정한다는 것입니다. 즉 컨테이너 안에서 root가 아니어도 1024 미만 포트를 열 수 있습니다. device_ownership_from_security_context는 version 3 샘플에서 기본값 false로 나옵니다.


11. NRI를 운영 관점에서 보기

6장은 NRI가 무엇인지를 설명했고, 여기서는 그것을 실제로 켜는 데 필요한 것만 봅니다. containerd 문서가 보여주는 설정 블록은 다음과 같습니다.

[plugins."io.containerd.nri.v1.nri"]
  disable = true
  disable_connections = false
  plugin_config_path = "/etc/nri/conf.d"
  plugin_path = "/opt/nri/plugins"
  plugin_registration_timeout = "5s"
  plugin_request_timeout = "2s"
  socket_path = "/var/run/nri/nri.sock"

가장 먼저 볼 것은 disable = true, 즉 기본적으로 꺼져 있다는 사실입니다. NRI 플러그인을 배포했는데 아무 일도 일어나지 않는다면 플러그인이 아니라 이 한 줄을 확인해야 합니다. socket_path의 기본값은 /var/run/nri/nri.sock이고, 외부에서 도는 플러그인은 이 소켓으로 붙습니다. 플러그인을 DaemonSet으로 띄운다면 이 경로를 hostPath로 마운트해 주어야 하며, 경로가 다르면 조용히 연결이 되지 않을 뿐 오류가 나지 않습니다. plugin_request_timeout의 기본값이 2초라는 점도 기억해 둘 만합니다. NRI 훅은 컨테이너 생성 경로 위에 있으므로, 플러그인이 느려지면 그만큼 Pod 시작이 느려지고 타임아웃이 나면 그 요청의 처리 방식이 플러그인 구현과 설정에 좌우됩니다. 운영 클러스터에 NRI 플러그인을 넣기 전에 이 지연을 예산에 넣어야 합니다.

한 가지 정직하게 말해 둘 것이 있습니다. containerd의 NRI 문서는 설정 블록과 동작 개요만 다루고 이벤트 목록을 열거하지 않습니다. 어떤 시점에 어떤 훅이 호출되고 각 훅에서 무엇을 바꿀 수 있는지는 NRI 저장소 자체를 봐야 합니다: https://github.com/containerd/nri


12. 워크드 예제: 뜨지 않는 Pod 추적하기

Pod가 Pending이나 ContainerCreating에서 멈췄을 때, kubelet이 CRI로 내리는 호출은 순서가 정해져 있습니다. RunPodSandbox로 샌드박스를 만들고, PullImage로 이미지를 받고, CreateContainer로 컨테이너를 만들고, StartContainer로 시작합니다. 어느 단계에서 멈췄는지를 알면 볼 곳이 하나로 좁혀지므로, 이 순서를 따라 내려가는 것이 가장 빠른 진단 경로입니다.

# 1) 샌드박스가 만들어졌는가
crictl pods --namespace my-ns

# 2) 이미지가 노드에 있는가
crictl images | grep my-app

# 3) 컨테이너 레코드가 생겼는가 (죽은 것 포함)
crictl ps -a -p POD_ID

# 4) 시작 실패의 이유
crictl inspect CONTAINER_ID
crictl logs -p CONTAINER_ID

# 5) 런타임 자체의 관점
journalctl -u containerd -f

crictl pods에 해당 Pod가 아예 없다면 RunPodSandbox 단계에서 실패한 것입니다. 이 단계의 실패 원인은 대개 pause 이미지 pull 실패이거나 CNI 플러그인 실패 둘 중 하나이고, 어느 쪽인지는 containerd 로그에서 갈립니다. 샌드박스는 있는데 crictl ps -a에 컨테이너가 없다면 PullImage에서 멈춘 것이고, crictl images에 이미지가 없다는 사실이 이를 뒷받침합니다. 컨테이너 레코드는 있는데 상태가 Created에서 더 나아가지 않으면 CreateContainer는 성공했지만 StartContainer가 실패한 것으로, 이때가 crictl inspect의 결과를 읽어야 할 순간입니다. 마운트 경로가 없다거나 요청한 사용자가 이미지에 존재하지 않는다거나 하는 이유가 여기서 드러납니다. 상태가 Exited라면 컨테이너는 시작됐다가 죽은 것이므로 이제부터는 런타임 문제가 아니라 애플리케이션 문제이고, crictl logs -p로 직전 인스턴스의 출력을 봐야 합니다.


13. 실패 사례와 진단 순서

폐쇄망 클러스터에서 pause 이미지를 받지 못하는 것이 가장 파괴적인 실패입니다. 애플리케이션 이미지를 아무리 사내 레지스트리에 잘 올려놓아도, 샌드박스가 만들어지지 않으면 그 노드에서는 Pod가 하나도 뜨지 않습니다. 증상이 특정 워크로드가 아니라 노드 전체에 걸쳐 나타나기 때문에 네트워크 장애로 오인하기 쉽습니다. 확인 순서는 crictl pods에 새 샌드박스가 없다는 것, containerd 로그의 이미지 pull 오류, 그리고 설정의 pause 이미지 주소입니다. 앞서 말한 대로 이 키는 1.x와 2.x에서 위치가 다릅니다.

cgroup 드라이버 불일치는 증상이 애매해서 오래 끄는 편입니다. kubelet과 containerd 중 한쪽만 systemd를 쓰면 Pod가 뜨긴 뜨는데 불안정하게 재시작하고, 노드를 갈아도 재현됩니다. crictl info로 런타임 쪽 설정을 보고 kubelet 설정과 맞춰보는 것이 가장 빠릅니다.

RuntimeClass의 handler 이름과 containerd 설정의 runtimes 항목 이름이 어긋나는 경우도 흔합니다. Kubernetes 쪽 RuntimeClass는 유효성 검사를 통과하지만 kubelet이 그 handler를 CRI로 넘기는 순간 containerd가 대응하는 런타임을 찾지 못해 샌드박스 생성이 실패합니다. RuntimeClass의 handler와 containerd.runtimes 아래 키 이름은 문자 단위로 같아야 하며, 대응하는 shim 바이너리가 PATH에 있어야 합니다. 확인은 crictl pods의 Runtime 컬럼이 기대한 핸들러를 보여주는지로 합니다.

crictl이 엉뚱한 엔드포인트를 보고 있는 경우는 진단을 통째로 무의미하게 만듭니다. 노드에 다른 런타임 소켓이 남아 있으면 crictl이 그쪽에 붙어서 텅 빈 목록을 보여주고, 그러면 "컨테이너가 하나도 없다"는 잘못된 결론에 도달합니다. crictl info로 지금 붙은 대상을 먼저 확인하고, /etc/crictl.yaml의 runtime-endpoint가 kubelet의 --container-runtime-endpoint와 같은지 대조하는 것이 첫 단계입니다.

마지막은 exec와 attach만 실패하는 경우입니다. 4장에서 본 대로 이 두 기능은 kubelet이 반환한 URL로 API 서버가 스트리밍 서버에 다시 접속해야 성립합니다. 즉 일반적인 Pod 동작이 멀쩡해도 API 서버에서 노드의 스트리밍 주소로 가는 경로가 막혀 있으면 exec만 타임아웃납니다. 방화벽, 노드의 광고 주소, 프록시 설정이 후보이고, 노드에서 직접 crictl exec가 되는지 먼저 확인하면 문제 구간이 런타임인지 네트워크 경로인지 즉시 갈립니다.


14. crictl 대신 kubectl을 써야 하는 경계

crictl은 API 서버를 우회합니다. 그래서 진단에는 강력하지만, 상태를 바꾸는 용도로 쓰면 위험합니다. kubelet은 자신이 만든 컨테이너와 샌드박스를 계속 감시하고 있고, 그 상태를 API 서버에 보고합니다. 사람이 crictl rm이나 crictl rmp로 kubelet이 소유한 객체를 지우면 kubelet의 인식과 실제 상태가 어긋나고, 그 결과로 다시 만들어지거나 이상한 중간 상태에 머물게 됩니다. 컨테이너를 다시 띄우고 싶다면 Pod를 지우거나 워크로드를 롤아웃하는 것, 즉 kubectl로 하는 것이 맞습니다.

경계를 단순하게 정하면 이렇습니다. Kubernetes가 무엇을 하려 하는지를 알고 싶으면 kubectl을, 런타임이 실제로 무엇을 했는지를 알고 싶으면 crictl을 씁니다. 이벤트, 스케줄링 결정, Pod 스펙, 컨트롤러 상태는 kubectl 쪽에만 있습니다. 반대로 샌드박스가 만들어졌는지, 이미지가 노드에 정말 있는지, 컨테이너가 몇 번 재시도됐는지는 crictl 쪽에서 더 정확합니다. 두 관점이 어긋나는 지점이 곧 문제의 위치입니다. 그리고 crictl로 무엇을 지우는 일은 kubelet이 이미 죽어 있어서 kubectl이 도달하지 못하는 상황으로 한정하는 것이 안전합니다.


15. 참고 자료


16. 정리

containerd의 CRI 구현은 Kubernetes와 컨테이너 런타임 사이의 핵심 인터페이스입니다. Pod Sandbox를 통한 Pod 수준의 격리, CRI 요청에서 OCI 스펙으로의 정확한 변환, WebSocket/SPDY 기반 스트리밍, RuntimeClass를 통한 다중 런타임 지원, NRI를 통한 유연한 확장이 주요 특징입니다. 이러한 계층화된 설계로 containerd는 Kubernetes의 안정적인 컨테이너 런타임으로 자리매김하고 있습니다.

댓글

아직 댓글이 없습니다.

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