
containerd 2.0이 나오면서 NRI(Node Resource Interface)가 기본 enable 상태가 됐다. 그동안 "kubelet의 device plugin이나 CDI랑 뭐가 다르냐"는 질문을 꽤 많이 받았는데, 막상 코드를 따라가 보면 NRI는 컨테이너 라이프사이클 hook을 user-space 프로세스로 빼낸 RPC 인터페이스에 가깝다. CPU pinning, NUMA-aware allocation, GPU annotation 같은 걸 runtime을 건드리지 않고 끼워넣을 수 있는 곳. 우리 팀에서도 최근 AI 워크로드 노드에 NRI 기반 토폴로지 플러그인을 깔면서 처음으로 코드를 깊이 봤다. 이 글에서는 NRI가 어디서 호출되고, 플러그인이 어떻게 컨테이너 스펙을 조작하며, 왜 sync/async 두 가지 모드가 존재하는지를 정리한다.
NRI가 끼어드는 지점
containerd의 컨테이너 라이프사이클은 CRI request → containerd task service → runc로 흐른다. NRI는 이 흐름의 중간, 정확히는 컨테이너 스펙(OCI runtime config.json)이 완성되기 직전과 직후에 hook point를 박는다. 호출되는 시점은 크게 여섯 가지다.
RunPodSandbox직전/직후CreateContainer직전 (이 시점에서만 컨테이너 스펙 조작 가능)StartContainer직후UpdateContainer직전/직후StopContainer직전/직후RemovePodSandbox직후
여기서 핵심은 CreateContainer 직전 단계다. 이 시점에서 플러그인이 반환하는 ContainerAdjustment 메시지가 OCI spec에 그대로 머지된다. CPU mask, memory limit, mount, env, annotation, linux resources 같은 거의 모든 필드를 바꿀 수 있다. 그리고 컨테이너가 만들어진 뒤에는 ContainerUpdate로 cgroup 값을 갈아끼우는 것만 가능하고 mount나 env는 못 건드린다. 이건 OCI runtime spec 자체가 immutable하기 때문이다.
프로토콜: ttRPC over Unix socket
NRI는 gRPC가 아니라 ttRPC를 쓴다. containerd 진영에서 만든 경량 RPC인데, 컨테이너 shim과 host 간 통신처럼 latency가 critical한 경로에 쓰려고 만든 거다. NRI 데몬은 /var/run/nri/nri.sock에서 listen하고, 플러그인은 거기에 접속해서 자기 자신을 등록한다.
[containerd] ──ttRPC──> [nri plugin daemon]
│ │
│ RunPodSandbox │
│ ───────────────────> │ (Synchronize)
│ │
│ CreateContainer │ ContainerAdjustment
│ ───────────────────> │ ──────────────────>
│ <─────────────────── │
플러그인은 두 종류로 동작한다. Pre-connected 플러그인은 /opt/nri/plugins/ 같은 디렉토리에 바이너리로 떨어져 있어 NRI 데몬이 직접 fork/exec한다. 반면 external 플러그인은 DaemonSet으로 떠서 자기 발로 소켓에 붙는다. 운영해 보면 후자가 압도적으로 편하다. 업데이트할 때 노드 데몬 재시작이 필요 없고, RBAC도 일반 Pod처럼 다룬다.
ContainerAdjustment 머지 규칙
여기서 한 번 사고가 났다. 우리 팀이 처음 NRI 플러그인을 만들 때 "annotation 추가만 하려고" adjustment.AddAnnotation을 호출했는데, 동시에 다른 플러그인이 같은 annotation key에 값을 넣고 있었다. 결과는 컨테이너 create 실패. NRI 데몬은 여러 플러그인의 adjustment를 순서대로 머지하는데, 같은 key에 다른 value가 오면 conflict로 reject한다.
정확히는 머지 정책이 필드마다 다르다.
- Annotation, env: key-level conflict 검출. 같은 key에 다른 value면 reject.
- Mount: source+destination이 같으면 last-write-wins. 다르면 모두 추가.
- Linux resources (cpu, memory, hugepages): 단순 덮어쓰기. 마지막 플러그인이 이긴다.
- Device: name 기준으로 dedup.
따라서 여러 플러그인이 같은 자원을 만지는 환경이라면 순서가 critical하다. NRI 데몬은 플러그인 등록 시 index 값을 받는데, 이걸로 invocation 순서를 정한다. 10-topology 같은 prefix를 붙이는 게 일반적이다. 이 순서를 잘못 잡으면 plugin A가 CPU를 0-3에 pinning하자마자 plugin B가 0-7로 덮어쓰는 일이 벌어진다.
Synchronize 단계: 왜 필요한가
플러그인이 처음 붙을 때 NRI 데몬은 Synchronize 메시지를 보낸다. 이 안에는 지금 노드에서 돌고 있는 모든 Pod와 컨테이너의 현재 상태가 들어있다. 플러그인은 이걸 받아서 자기 state를 복원한 뒤에야 본격적으로 hook을 받기 시작한다.
이게 왜 중요하냐면 — 플러그인은 죽을 수 있다. DaemonSet pod가 재시작되면 메모리에 들고 있던 "어떤 컨테이너에 어떤 자원을 할당했는지" 같은 매핑이 다 날아간다. Synchronize가 없으면 플러그인은 노드 상태와 영영 sync가 안 맞는다. 토폴로지 플러그인 같은 경우 이 시점에 "현재 CPU 0-15는 누가 쓰고 있다"를 다 계산해서 internal state를 채운 다음에야 다음 CreateContainer를 처리할 수 있다.
여기서 함정 하나. Synchronize는 snapshot이다. 동기화 중에 새 컨테이너가 생기면 NRI 데몬은 그 이벤트를 잠시 큐잉했다가 Synchronize 응답이 끝난 뒤 차례로 보낸다. 우리 팀에서는 처음에 이걸 모르고 Synchronize 핸들러를 30초씩 걸리게 짰다가 노드 부팅 직후 pod create가 줄줄이 막혔다. NRI 호출은 본질적으로 컨테이너 create path를 블록하기 때문에 핸들러가 느리면 그대로 kubelet 쪽 PodCreating 지연으로 이어진다.
Timeout과 실패 정책
NRI 데몬 설정에서 가장 자주 건드리는 게 plugin_request_timeout이다. 기본 2초. 그리고 disable_connections 옵션이 있어서 외부 플러그인 접속 자체를 막을 수도 있다.
문제는 플러그인이 timeout 되면 컨테이너 create가 실패한다는 것이다. NRI는 기본적으로 fail-closed다. 이건 보안 플러그인(unverified container 차단 같은 것)을 우회당하지 않으려는 설계인데, 단순 annotation 플러그인까지 같은 정책을 따른다. 그래서 토폴로지 플러그인 같은 무거운 놈은 timeout을 5초 정도로 늘려두는 게 일반적이다. 너무 늘리면 노드 전체 컨테이너 생성 latency가 끌려간다는 trade-off가 있다.
containerd 2.0 이후 옵션이 좀 늘었는데, permissive 모드에서는 timeout 시 plugin을 skip하고 진행한다. 우리 환경에서는 production 노드에는 strict, dev 노드에는 permissive를 쓴다.
디버깅할 때 보는 것
NRI 자체는 로그를 containerd 로그에 남긴다. nri_plugin prefix로 grep하면 어느 플러그인이 어떤 hook에서 뭘 반환했는지 보인다. 그리고 플러그인 쪽 디버깅은 진짜 verbose log를 켜야 한다 — 무엇을 adjust했는지 직접 찍지 않으면 노드에 들어가서 crictl inspect로 최종 스펙을 봐야 한다.
가장 헷갈리는 건 "내 플러그인이 호출은 됐는데 adjustment가 안 먹는" 경우다. 십중팔구 다른 플러그인의 adjustment에 덮어쓰여진 거다. 이때는 NRI 데몬의 debug log에서 머지 순서와 최종 결과를 보면 된다.
결론은
NRI는 kubelet device plugin과 비교하면 훨씬 일찍, 훨씬 깊게 컨테이너 스펙을 만질 수 있다. 그만큼 잘못 짜면 컨테이너 create path 전체를 망가뜨릴 수 있는 위험한 도구이기도 하다. 노드 단위 자원 정책을 정교하게 통제해야 하는 환경(GPU sharing, NUMA pinning, multi-tenant isolation)이라면 가장 깔끔한 답인데, 단순 라벨링/관찰 정도 목적이면 mutating webhook 같은 cluster-level 솔루션이 더 안전하다.
containerd 2.x에서 NRI가 default-on이 됐다는 건, 앞으로 이 인터페이스 위에 쌓이는 plugin 생태계가 더 빠르게 늘어난다는 뜻이다. 우리 팀도 직접 만든 플러그인 한두 개를 더 production에 올려보고 있는 중이다. 다음에는 NRI 플러그인을 Go로 직접 구현하면서 만난 sandbox annotation 동기화 문제를 다뤄볼까 한다.
'IT > 컨테이너' 카테고리의 다른 글
| BuildKit cache mount 제대로 쓰는 법 — Rust/Node CI 빌드 시간을 절반으로 (0) | 2026.06.08 |
|---|---|
| cgroup v2 전환 후 OOMKill 동작이 바뀐 이유 (0) | 2026.06.04 |
| Spegel로 in-cluster 이미지 미러 만들기 (0) | 2026.05.27 |
| Kaniko가 archived된 뒤, 우리는 어떻게 컨테이너 빌드 도구를 골랐나 (0) | 2026.05.24 |
| containerd image pull 흐름 — snapshotter와 unpack 단계 파헤치기 (0) | 2026.05.08 |