정의서 밖의 GPU — daemon-reload 한 번에 컨테이너가 장치를 잃는 구조와 CDI 전환
GPU 노드는 멀쩡한데 컨테이너 안에서만 NVML: Unknown Error 가 났고, 그 상태로 7주를 갔습니다. 장치가 컨테이너 정의서 밖에서 훅으로 주입되고 있어 systemd 가 cgroup 허용 목록을 다시 쓰는 순간 지워지는 구조였습니다. runc 가 실제로 읽은 config.json 으로 확인하고 CDI 로 옮겨 같은 조작에서 안 깨지는 것을 본 과정, 그리고 조사 중 틀렸던 네 가지를 적었습니다.
홈랩 클러스터의 GPU 노드가 nvidia.com/gpu: 0 을 광고하고 있었습니다. 노드 라벨에는 nvidia.com/gpu.present=true 가 붙어 있고, device plugin 파드는 250일째 재시작 없이 Running 이었고, 기동 로그에는 Registered device plugin for 'nvidia.com/gpu' with Kubelet 이 찍혀 있었습니다. 그런데 그 파드 안에서 nvidia-smi 를 치면 이렇게 나왔습니다.
Failed to initialize NVML: Unknown Error호스트는 멀쩡했습니다. 노드에 직접 들어가 친 nvidia-smi 는 RTX 3060 을 유휴 상태(52MiB / 12288MiB, 38°C)로 보여 줬고, nvidia·nvidia_uvm·nvidia_drm·nvidia_modeset 모듈이 전부 올라와 있었습니다. 드라이버는 살아 있고, 실행 중인 컨테이너만 장치 접근을 잃은 상태였습니다.
이 상태가 7주 갔습니다. dcgm-exporter 의 마지막 로그가 7월 11일이었고, 그 사이 GPU 알림은 하나도 울리지 않았습니다.
이 글은 왜 컨테이너만 장치를 잃었는지를 runc 가 실제로 읽은 파일로 확인한 과정, 그 구조를 CDI 로 바꿔 같은 조작에서 안 깨지는 것을 본 과정, 그리고 조사 중 제가 틀렸던 네 가지를 적습니다. 촉발자는 9월 2일 건만 저널에서 잡았고 7월 건은 로그가 없어 확정하지 못했습니다. 그 경계는 본문에서 그대로 지킵니다.
| 클러스터 | kubespray 2.28.0 · Kubernetes v1.32.0 · containerd 1.7.24 · 베어메탈 3노드 |
| GPU 노드 | node3-gpu · Ubuntu 24.04 · 커널 6.11 · RTX 3060 12GB |
| 드라이버 / toolkit | 535.230.02 (CUDA 12.2) / nvidia-container-toolkit 1.17.5 |
| device plugin | helm chart 0.17.0, 값은 runtimeClassName: nvidia 하나 |
| cgroup | containerd SystemdCgroup = true |
| 부팅 | 2025-12-25 이후 재부팅 없음 |
보이는데 열리지 않는 장치
처음 발견했을 때는 파드를 재시작해 증상을 없앤 뒤에야 원인을 파기 시작했습니다. 컨테이너가 새로 만들어지면 장치가 돌아오니 증상은 사라지지만, 원인을 보여 주던 상태도 같이 사라집니다. 저녁에 같은 증상이 재발해서 다시 잡을 수 있었고, 이번에는 파드를 지우기 전에 컨테이너 안에서 먼저 쟀습니다. 이것이 이 조사의 첫 증거입니다.
$ kubectl exec <plugin-pod> -- ls -la /dev/nvidia*crw-rw-rw- 1 root root 195, 0 /dev/nvidia0crw-rw-rw- 1 root root 195, 255 /dev/nvidiactlcrw-rw-rw- 1 root root 234, 0 /dev/nvidia-uvm
$ kubectl exec <plugin-pod> -- cat /dev/nvidiactlcat: /dev/nvidiactl: Operation not permitted장치 파일은 있는데 열리지 않습니다. 컨테이너 안에서 장치 접근을 정하는 층은 둘입니다. 장치 노드가 컨테이너의 /dev 에 있는가, 그리고 cgroup 의 장치 허용 목록이 그 장치를 허용하는가. 첫째는 통과했고 둘째에서 막혔습니다. Operation not permitted(EPERM) 는 허용 목록이 거부할 때 나는 에러입니다. NVML 은 이 실패를 Unknown Error 로 뭉뚱그리기 때문에 에러 문자열만으로는 드라이버 문제인지 권한 문제인지 알 수 없고, 컨테이너 안에서 장치 파일을 직접 열어 봐야 갈립니다.
runc 가 실제로 읽은 정의서
cgroup 허용 목록은 컨테이너 런타임이 OCI 런타임 스펙의 정의서 config.json 을 보고 씁니다. 그러면 다음 질문은 정의서에 GPU 가 어떻게 적혀 있는가입니다.
처음에는 crictl inspect 로 봤습니다. 훅이 안 보였고, “nvidia 런타임 래퍼가 돌지 않았다” 고 결론 냈습니다. 틀린 결론이었습니다. crictl inspect 가 보여 주는 spec 은 containerd 가 생성한 것이라 런타임 래퍼가 손대기 전 상태입니다. runc 가 실제로 읽는 파일은 디스크에 따로 있습니다.
/run/containerd/io.containerd.runtime.v2.task/k8s.io/<컨테이너ID>/config.json고치기 전 그 파일의 내용입니다.
hooks: prestart /usr/bin/nvidia-container-runtime-hook prestart createContainer /usr/bin/nvidia-ctk hook enable-cuda-compat createContainer /usr/bin/nvidia-ctk hook update-ldcachelinux.devices (없음)linux.resources.devices 1건 — {allow: false, access: rwm}정의서에 GPU 가 한 줄도 없습니다. OCI 스펙에서 장치는 두 자리에 적힙니다. linux.devices 는 컨테이너에 있어야 할 장치 노드이고, linux.resources.devices 는 cgroup 허용 목록입니다. 둘 다 비어 있고, 허용 목록에는 모든 장치를 거부하는 규칙 하나뿐입니다. 대신 prestart 훅이 있습니다. 장치는 전부 이 훅이 컨테이너 생성 뒤에 정의서 밖에서 밀어 넣고 있었습니다. prestart 는 OCI 스펙에서 deprecated 된 훅이기도 합니다.
훅의 설정 위치
훅이 어디에 설정돼 있는지 찾는 데도 헛걸음을 했습니다. OCI 훅 디렉터리 세 곳(/usr/share/containers/oci/hooks.d, /etc/containers/oci/hooks.d, /usr/libexec/oci/hooks.d)이 전부 존재하지 않았고, 설정 파일에서 hooks 를 grep 해도 나오지 않습니다. 훅은 어느 파일에도 적혀 있지 않고, 아래 체인의 끝에서 생깁니다.
/etc/containerd/config.toml runtimes.nvidia.options.BinaryName = "/usr/bin/nvidia-container-runtime" ↓ RuntimeClass/nvidia + 파드의 runtimeClassName: nvidia/usr/bin/nvidia-container-runtime (패키지 nvidia-container-toolkit-base, runc 래퍼) ↓ /etc/nvidia-container-runtime/config.toml 의 mode = "auto" → legacy 선택config.json 에 prestart → /usr/bin/nvidia-container-runtime-hook 을 써 넣고 runc 호출첫 번째 헛걸음이 여기서 나왔습니다. /etc/containerd/config.toml 은 root:root 0640 인데 일반 사용자로 grep 했습니다. grep 은 권한 거부를 stderr 로 내고 stdout 에는 아무것도 안 내니 결과는 0건이고, 그 0건을 “nvidia 항목이 없다” 의 근거로 썼습니다. 읽지 못한 것과 없는 것은 같은 출력으로 나옵니다.
같은 목록을 쓰는 두 주체
이 클러스터는 containerd 가 SystemdCgroup = true 로 돌아서 컨테이너의 cgroup 을 systemd 가 관리합니다. systemd 는 정의서에서 변환된 장치 규칙(DeviceAllow=)을 보고 허용 목록을 씁니다. nvidia 훅은 정의서 밖에서 같은 목록에 GPU 를 직접 써 넣습니다. 둘은 서로의 존재를 모릅니다.
평소에는 부딪히지 않습니다. systemd 는 정의서에 GPU 가 없으니 GPU 를 건드릴 일이 없고, 훅은 systemd 가 모르는 자리에 GPU 를 넣습니다. 프로세스는 훅이 넣은 항목 덕에 장치를 엽니다.
systemctl daemon-reload 가 돌면 systemd 는 자기가 관리하는 cgroup 의 장치 허용 목록을 정의서 기준으로 다시 씁니다. 정의서에 GPU 가 없으니 다시 쓴 목록에도 GPU 가 없습니다. 훅이 넣은 항목만 사라집니다. 훅은 컨테이너 생성 때 한 번만 돌기 때문에 다시 넣어 줄 주체가 없습니다.
프로세스는 죽지 않습니다. 허용 목록은 장치 파일을 열 때 검사되므로 새로 열 때만 EPERM 이 납니다. 그래서 파드는 Running 이고 재시작 횟수는 0이고, 증상은 NVML: Unknown Error 로만 나타납니다.
NVIDIA 문서의 트러블슈팅 절이 이 현상을 그대로 적고 있습니다. 훅으로 장치를 주입하면 하위 런타임이 모르는 채로 cgroup 접근이 설정되고, systemd 로 cgroup 을 관리하는 시스템에서 daemon-reload 가 그 접근을 걷어 갈 수 있으며, 권장 대응은 cgroupfs 드라이버 · --device 명시 · CDI 라고 합니다. runc 저장소의 이슈 #3708 도 같은 증상을 다룹니다.
촉발자
9월 2일 오후 건은 저널에서 잡았습니다.
2026-09-02T13:05:13+09:00 node3-gpu systemd[1]: Reloading requested from client PID … ('systemctl') (unit snapd.service)...snapd 자동 갱신입니다. 사람이 한 것도 배포가 한 것도 아닙니다. 그 직후 컨테이너 안의 open(/dev/nvidiactl) 이 EPERM 을 냈습니다.
세 번째 헛걸음이 여기 있습니다. 저널을 보기 전에 daemon-reload 를 원인으로 적었습니다. 메커니즘상 유력했고 나중에 실제로 잡혔지만, 적는 시점에는 정황뿐이었습니다. 결과가 맞은 것과 조사 순서가 틀린 것은 별개입니다.
7월 건은 확정하지 못했습니다. 조사한 것은 이렇습니다.
- 호스트 재부팅 없음 (2025-12-25 이후 단일 부팅)
- 패키지 변경은 nvidia 2025-09-09, systemd 2026-03-20 이 마지막이고 2026-06 이후 apt 작업 0건
- dcgm 이 2026-07-11 12:34 까지 메트릭 14개를 내고 있었음
- 저널 최초 엔트리가 2026-07-17 이라 추정 구간이 rotate 로 사라짐
- Prometheus 보존 30일, 2026-08-02 시점에 이미 capacity 0
사건은 2026-07-11 과 08-02 사이에 있었고, 패키지 변경이 없었으니 apt 가 부른 daemon-reload 는 아닙니다. 같은 메커니즘일 개연성이 높지만 확정은 아닙니다.
CDI 전환
고친 것은 하나입니다. GPU 를 정의서 안에 적었습니다. systemd 가 목록을 몇 번을 다시 써도 살아남으려면 systemd 가 읽는 그 정의서에 GPU 가 있어야 합니다. 훅이 뒤에서 넣는 구조를 걷고 컨테이너를 만드는 시점에 정의서에 넣는 방식이 CDI(Container Device Interface) 입니다. CDI 명세 파일이 “이 장치 이름은 이 장치 노드·마운트·환경변수다” 를 선언하고, 런타임이 컨테이너를 만들 때 그것을 정의서에 합칩니다.
파드가 정의서에 장치를 넣는 길이 두 갈래라 손댈 곳이 넷입니다. 워크로드 파드는 device plugin 을 거쳐 kubelet 과 containerd 로 가고, 플러그인 자신과 dcgm-exporter 같은 인프라 파드는 runtimeClassName: nvidia 로 nvidia 런타임 래퍼를 거칩니다. 한쪽만 바꾸면 반만 고쳐집니다.
| 층 | 변경 | 파일 |
|---|---|---|
| CDI 명세 | nvidia-ctk cdi generate --output=/etc/cdi/nvidia.yaml (장치 3개) | 호스트 |
| toolkit | mode = "auto" → "cdi" | /etc/nvidia-container-runtime/config.toml |
| containerd | enable_cdi = true · cdi_spec_dirs 추가 후 재시작 | /etc/containerd/config.toml |
| device plugin | deviceListStrategy: cdi-cri · nvidiaDriverRoot: "/" | helm values |
- 명세는
nvidia-ctk cdi generate로 만듭니다. “nvidia.com/gpu 는 이 장치 파일들과 이 라이브러리들이다” 가 여기서 나옵니다 enable_cdi는 containerd 가 컨테이너를 만들 때 CDI 명세를 읽어 정의서에 합치는 기능을 켭니다. 꺼져 있으면 명세가 있어도 안 씁니다cdi-cri는 device plugin 이 kubelet 에 “이 파드에 GPU 줘” 라고 알릴 때 환경변수 대신 CDI 장치 이름으로 넘기게 합니다. 그래야 containerd 가 위 기능으로 정의서에 넣습니다. 전에는NVIDIA_VISIBLE_DEVICES만 넘겼고 그것을 읽는 쪽이 훅이었습니다mode = "cdi"는 nvidia 런타임 래퍼를 타는 파드용입니다. 래퍼도 훅 대신 CDI 로 정의서를 고치게 합니다
containerd 재시작은 컨테이너를 죽이지 않습니다. 유닛이 KillMode=process 라 재시작 시 containerd 프로세스만 죽고, 컨테이너는 독립된 containerd-shim-runc-v2 프로세스가 들고 있습니다. 재시작 뒤 running 컨테이너 36개가 그대로였습니다.
nvidiaDriverRoot 함정
cdi-cri 로 바꾸자마자 플러그인이 크래시 루프에 빠졌습니다.
error starting plugins: ... failed to get libraries for driver version: failed to locate libcuda.so.535.230.02: pattern libcuda.so.535.230.02 not found플러그인 바이너리의 NVIDIA_DRIVER_ROOT 기본값은 "/" 인데 차트의 nvidiaDriverRoot 기본값은 null 이고, 차트는 이 값이 문자열일 때만 호스트 드라이버를 /driver-root 에 마운트해 줍니다. 템플릿 주석에 “This is required for CDI detection to work correctly” 라고 적혀 있습니다. cdi-cri 는 플러그인이 기동마다 CDI 명세를 만들어야 하고, 그 명세에 넣을 드라이버 라이브러리를 이 마운트에서 찾습니다. "/" 를 넣으니 올라왔습니다.
같은 조작으로 한 증명
깨뜨린 조작과 확인한 조작이 같아야 “고쳐서 안 깨진다” 와 “아직 안 깨졌다” 를 가를 수 있습니다.
red 13:05 snapd 가 daemon-reload → open(/dev/nvidiactl) = EPERM config.json: linux.devices 없음 · cgroup 규칙 1건(deny-all) · prestart 훅 있음fix CDI 전환green 21:18 직접 daemon-reload → probe 파드 · 플러그인 · dcgm 셋 다 GPU 유지 config.json: linux.devices 7개(nvidia 5) · cgroup 규칙 8건(nvidia 허용 5) · prestart 훅 없음probe 파드는 runtimeClassName 없이 nvidia.com/gpu: 1 만 요청했고, 스케줄·기동·nvidia-smi 까지 됐습니다. 워크로드가 더는 nvidia 런타임 래퍼에 의존하지 않는다는 뜻입니다.
스펙이 둘, 소비자가 둘
전환하고 나니 CDI 명세가 두 곳에 있고 읽는 쪽이 다릅니다.
| 명세 | 만드는 주체 | 읽는 쪽 | 드라이버 버전 하드코딩 |
|---|---|---|---|
/var/run/cdi/k8s.device-plugin.nvidia.com-gpu.json | device plugin 이 기동마다 재생성 | 워크로드 파드 | 1건 |
/etc/cdi/nvidia.yaml | 사람이 1회 생성 | runtimeClassName: nvidia 파드 (플러그인 자신 · dcgm) | 66건 |
드라이버를 올리면 아래쪽만 낡습니다. 워크로드 쪽 명세는 플러그인이 다시 만들지만, 플러그인 자신이 낡은 명세로 뜨지 못하면 결과적으로 전부 GPU 를 못 받습니다. 드라이버 업그레이드가 연 1회 수준이라 자동화하지 않고 절차와 알림으로 뒀습니다.
오늘의 기본값
이것이 낡은 클러스터의 잔재인지 확인했습니다. 오늘 기준 기본값 셋이 전부 legacy 를 가리킵니다.
| 층 | 기본값 | 결과 |
|---|---|---|
| kubespray 2.28.0 | enable_cdi: false | containerd 가 CDI 명세를 안 읽음 |
| device plugin 0.17.0 | deviceListStrategy: envvar | 환경변수로 넘기고 훅이 읽음 |
| toolkit 1.17.5 | mode = "auto" | CDI 요청이 없으니 legacy |
같은 조합으로 오늘 새로 깔아도 재현됩니다. 방향은 CDI 쪽입니다. kubespray 에 enable_cdi 스위치가 생겼고, containerd 는 1.7 에서 false 이던 기본값이 2.0 에서 true 로 바뀌었습니다. 다만 위 세 층을 한꺼번에 CDI 로 맞춰 주는 곳은 아직 없습니다.
이 조합이 흔히 안 보이는 이유도 있습니다. 터지려면 daemon-reload 가 돌고, 그 뒤로 파드가 재시작 없이 오래 살아야 합니다. GPU Operator 를 쓰거나 파드가 자주 재시작되는 환경에서는 훅이 다시 돌아 저절로 낫습니다. 여기는 250일 무재시작이라 굳었고, 알림이 없어 7주를 갔습니다.
고장보다 침묵
cgroup 이 지워진 것보다 7주 동안 물어볼 방법이 없었다는 것이 더 큰 구멍입니다. GPUHighTemperature 와 GPUHighMemoryUsage 규칙은 있었습니다. 둘 다 dcgm 메트릭이 있어야 발화합니다. dcgm 이 GPU 를 잃자 메트릭이 끊겼고, 그 규칙들은 조용해졌고, 침묵이 정상으로 읽혔습니다.
그래서 두 규칙을 넣었습니다.
- alert: GPUCapacityZero # critical expr: kube_node_status_capacity{resource="nvidia_com_gpu"} == 0 for: 10m- alert: GPUMetricsAbsent # warning expr: absent(DCGM_FI_DEV_GPU_TEMP) for: 10m첫째는 노드가 GPU 를 광고하는데 0개인 상태를 kube-state-metrics 쪽에서 잡습니다. dcgm 과 무관한 경로라 dcgm 이 죽어도 울립니다. 둘째는 dcgm 메트릭 자체의 실종을 잡습니다. 이 알림이 떠 있는 동안 GPUHigh* 의 침묵은 정상이 아니라 관측 불가라는 뜻이고, 그 구분이 이번에 없었습니다.
핵심 정리
- 컨테이너 안에서
NVML: Unknown Error가 나면ls /dev/nvidia*와cat /dev/nvidiactl을 먼저 봅니다. 장치 노드는 있는데 EPERM 이면 cgroup 허용 목록 쪽입니다 - 정의서는
/run/containerd/io.containerd.runtime.v2.task/k8s.io/<ID>/config.json으로 봅니다.crictl inspect는 런타임 래퍼가 손대기 전 상태입니다 linux.devices가 비어 있고prestart훅만 있으면 장치가 정의서 밖에 있는 것이고,SystemdCgroup = true에서는daemon-reload한 번에 사라집니다. 훅은 컨테이너 생성 때 한 번만 돕니다daemon-reload는 사람이 아니어도 돕니다. 이번 촉발자는 snapd 자동 갱신이었습니다- CDI 로 옮길 때 손댈 곳은 넷입니다. 명세 생성 · toolkit
mode = "cdi"· containerdenable_cdi· device plugincdi-cri+nvidiaDriverRoot: "/". 파드가 정의서에 장치를 넣는 길이 두 갈래라 한쪽만 바꾸면 반만 고쳐집니다 - 고쳤다는 증명은 깨뜨린 조작을 다시 거는 것입니다.
daemon-reload를 직접 걸어config.json에 장치가 남아 있는지 봅니다 - kubespray · device plugin 차트 · toolkit 의 기본값은 지금도 전부 legacy 입니다. 새로 깔아도 같습니다
- 메트릭이 있어야 발화하는 알림은 관측 대상이 죽으면 함께 침묵합니다.
absent()와 capacity 0 알림처럼 다른 경로로 오는 규칙이 하나는 있어야 침묵과 정상을 가를 수 있습니다 - 조사에서 틀렸던 것 넷 — 권한 거부의 0건을 부재로 읽은 것,
crictl inspect를 최종 spec 으로 읽은 것, 저널을 보기 전에 원인을 적은 것, 원인을 보기 전에 파드를 재시작해 증거를 지운 것
관련 콘텐츠
OCI를 Always Free로 운영할 때 생기는 이슈 — Block Volume VPU 0
Always Free 한도를 맞추려고 Block Volume 성능 등급을 0으로 내렸습니다. 173일 동안 아무 일도 없다가, 클러스터 업그레이드로 파드가 재생성되는 순간 컨테이너 생성이 kubelet 타임아웃을 넘겼습니다. 원인에 도달하기까지 세운 가설 5개와 전부 틀린 이유를 정리했습니다.
DevOpsLinkedIn에서 발견한 Tencent WeKnora, GraphRAG PoC하고 PR까지 Merged
LinkedIn에서 발견한 Tencent WeKnora를 홈 Kubernetes 클러스터에서 PoC하고, Helm Chart PR까지 Merge한 여정
KubernetesGateway API 전환기 (1) - Cilium을 Kubespray에서 Helm으로
Kubespray로 설치한 Cilium을 Helm 관리로 전환하는 과정에서 겪은 트러블슈팅과 교훈을 공유합니다.