Reproducing the GPU Incident — From Config Merge to Stalled Rollout
한국어 원문으로 표시합니다.
목표
GPU 클러스터를 한 번에 못 쓰게 만들었던 사고를 여덟 단계로 다시 밟습니다. containerd 설정이 합쳐지는 자리, 로드된 것을 확인하는 습관, 자원 광고와 런타임 핸들러라는 두 관문, 타임슬라이싱의 산술, 그리고 롤아웃이 멈춘 클러스터의 모양까지를 손으로 만듭니다.
왜 중요한가
GPU 운영에서 사람을 잡는 것은 드라이버가 아니라 설정이 반영됐다고 믿는 순간입니다. 드롭인 파일에 nvidia 런타임이 멀쩡히 적혀 있어도 로드된 런타임은 runc 하나뿐일 수 있고, toolkit 파드가 Ready 여도 containerd 를 재시작하지 않았으면 핸들러는 등록되지 않았으며, 이미 돌고 있는 파드는 이 모든 것이 깨져도 아무 말을 하지 않습니다. 고장이 잠복하다 노드 재부팅 때 한꺼번에 터지는 이유가 여기 있습니다. 그래서 이 실습은 "무엇을 썼는가" 가 아니라 "무엇이 실제로 로드됐고 무엇이 실제로 스케줄됐는가" 만 보게 만듭니다. 그 습관 하나가 몇 시간짜리 원인 규명을 3초로 줄입니다.
이 환경에는 진짜 GPU 도 containerd 도 GPU Operator 도 없습니다. 1–4단계는 설정 파일과 TOML 파서로 다루고, 5–8단계는 kwok 이 띄운 진짜 컨트롤 플레인과 가짜 노드 3대(lab-node-0/1/2) 위에서 다룹니다. 스케줄링·자원 광고·데몬셋 롤아웃은 진짜 컨트롤러가 처리하므로 그대로 재현되지만, 컨테이너는 실제로 실행되지 않고 노드에 꽂힌 GPU 는 노드 오브젝트의 숫자일 뿐입니다.
단계
/root/gpuop/etc/containerd/config.toml을 만드세요. 최상위에version = 2와imports배열이 있어야 하고imports의 한 항목은conf.d/*.toml로 끝나야 합니다.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"와options.SystemdCgroup = true를 넣으세요./root/gpuop/etc/containerd/conf.d/99-nvidia.toml을 만드세요.plugins."io.containerd.grpc.v1.cri".containerd.runtimes.nvidia테이블에runtime_type = "io.containerd.runc.v2"를, 그 아래options에BinaryName = "/usr/bin/nvidia-container-runtime"과SystemdCgroup = true를 넣으세요.- 두 파일을
tomllib로 읽어 판정한 결과를/root/gpuop/out/merge.txt에 다섯 줄로 적으세요.MAIN_RUNTIMES=는 주 설정이 직접 정의한 런타임 이름,DROPIN_RUNTIMES=는 드롭인이 정의한 이름,CONFLICT=는 양쪽이 같은 테이블을 정의하면yes,CONFLICT_TABLE=는 부딪히는 테이블 경로(plugins."io.containerd.grpc.v1.cri".containerd.runtimes), 그리고 마지막LOADED_RUNTIMES=입니다. 마지막 줄만은 계산값이 아니라 관측값입니다 — 사고 당일 그 노드에서containerd config dump로 확인한 로드된 런타임 핸들러는runc하나뿐이었습니다. /root/gpuop/bin/check-runtime.sh를 만드세요.containerd config dump를 절대 경로 없이 호출해 그 출력에서containerd.runtimes.nvidia를 찾고, 있으면0, 없으면1로 끝나야 합니다./etc/containerd/config.toml경로를 읽으면 안 됩니다 — 채점기가 그것을 오답으로 잡습니다./root/gpuop/k8s/runtimeclass.yaml에 이름과handler가 모두nvidia인 RuntimeClass 를 쓰고,/root/gpuop/k8s/gpu-pod.yaml에 네임스페이스gpu-lab, 이름cuda-probe,runtimeClassName: nvidia, 컨테이너 자원limits에nvidia.com/gpu: 1인 파드를 쓰세요. 네임스페이스를 먼저 만들고 둘 다 적용하세요. 파드는 뜨지 않습니다 —PodScheduled조건의reason과message를 두 줄로/root/gpuop/out/unscheduled.txt에 저장하세요.kubectl patch node lab-node-0 --subresource=status로capacity와allocatable양쪽에nvidia.com/gpu를"4"로 넣으세요. 파드가Running이 되면 세 노드의 이름과 광고량을/root/gpuop/out/node-gpu.txt에 두 칸짜리 표로 저장하세요./root/gpuop/k8s/time-slicing.yaml에 네임스페이스gpu-operator, 이름time-slicing-config인 ConfigMap 을 쓰세요.data.any는 YAML 문자열이고 그 안에sharing.timeSlicing.failRequestsGreaterThanOne: true와resources배열 첫 항목이name: nvidia.com/gpu,replicas: 5여야 합니다. 적용한 뒤lab-node-1의 광고량을"20"으로 올리고,/root/gpuop/out/slots.txt에 일곱 줄을 적으세요 —PHYSICAL_GPUSREPLICASADVERTISEDVRAM_PER_GPU_GIBVRAM_GUARANTEED_PER_SLOT_GIBVRAM_SHARED_PER_GPU_GIBISOLATION. 장비는 40GiB A100 4장 기준입니다./root/gpuop/k8s/toolkit-ds.yaml에 네임스페이스gpu-operator, 이름nvidia-container-toolkit,updateStrategy.rollingUpdate.maxUnavailable: 1, 이미지nvcr.io/nvidia/k8s/container-toolkit:v1.16.2, 메모리 요청128Mi인 데몬셋을 쓰고 적용하세요. 세 파드가 모두 Ready 가 되면 이미지를v1.17.0으로 올리면서 메모리 요청을64Gi로 함께 바꾸세요. 롤아웃이 멈추면/root/gpuop/out/rollout.txt에 여섯 줄을 적으세요 —DESIREDUPDATEDREADYOLD_IMAGE_PODSMAX_UNAVAILABLE, 그리고BLOCKER=insufficient-memory.
참고
- 4단계 채점은 정적 검사에서 끝나지 않습니다. 가짜
containerd를 PATH 앞에 세워 두고 여러분의 스크립트를 실제로 두 번 돌려 봅니다 — nvidia 가 없는 dump 를 줬을 때 실패로, 있는 dump 를 줬을 때 성공으로 끝나야 통과합니다. kubectl patch --subresource=status는 kubectl 1.24 이상에서 동작합니다. 노드 용량은 세 대 모두 CPU 8, 메모리 32Gi 입니다.- 흔한 실수 1: TOML 테이블 이름을
[plugins.io.containerd.grpc.v1.cri...]처럼 따옴표 없이 적는 것. 점이 들어간 키는 큰따옴표로 묶어야 한 덩어리로 읽히고, 그러지 않으면 파서가 전혀 다른 계층을 만듭니다. - 흔한 실수 2: 7단계에서
data.any를 YAML 매핑으로 적는 것. ConfigMap 의data값은 문자열이어야 하므로|-블록 스칼라로 넣어야 합니다. - 흔한 실수 3: 8단계에서 세 파드가 Ready 가 되기 전에 새 스펙을 미는 것. 처음부터 준비되지 않은 상태에서 밀면 무엇 때문에 멈춘 것인지 구분할 수 없습니다.
사고 노드의 주 containerd 설정 재현
/root/gpuop/etc/containerd/config.toml 을 만드세요. 최상위에 version = 2 와 imports 배열이 있어야 하고 imports 의 한 항목은 conf.d/*.toml 로 끝나야 합니다. 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" 와 options.SystemdCgroup = true 를 넣으세요.
TOML 은 들여쓰기가 아니라 테이블 이름으로 계층을 만듭니다. 점이 들어간 키는 큰따옴표로 묶어야 한 덩어리로 읽힙니다. 다 쓴 뒤에는 눈으로 보지 말고 파서로 한 번 읽어 확인하세요.
toolkit 이 떨어뜨리는 드롭인 재현
/root/gpuop/etc/containerd/conf.d/99-nvidia.toml 을 만드세요. plugins."io.containerd.grpc.v1.cri".containerd.runtimes.nvidia 테이블에 runtime_type = "io.containerd.runc.v2" 를, 그 아래 options 에 BinaryName = "/usr/bin/nvidia-container-runtime" 과 SystemdCgroup = true 를 넣으세요.
드롭인은 주 설정과 같은 테이블 경로 아래에 자기 런타임을 하나 더 선언합니다. 핵심은 옵션의 바이너리 이름입니다 — 그 껍데기가 컨테이너를 만들기 직전에 장치와 라이브러리를 끼워 넣습니다. cgroup 드라이버는 주 설정 쪽과 어긋나면 안 됩니다.
두 파일이 같은 테이블을 두고 부딪히는지 판정
두 파일을 tomllib 로 읽어 판정한 결과를 /root/gpuop/out/merge.txt 에 다섯 줄로 적으세요. MAIN_RUNTIMES= 는 주 설정이 직접 정의한 런타임 이름, DROPIN_RUNTIMES= 는 드롭인이 정의한 이름, CONFLICT= 는 양쪽이 같은 테이블을 정의하면 yes, CONFLICT_TABLE= 는 부딪히는 테이블 경로(plugins."io.containerd.grpc.v1.cri".containerd.runtimes), 그리고 마지막 LOADED_RUNTIMES= 입니다. 마지막 줄만은 계산값이 아니라 관측값입니다 — 사고 당일 그 노드에서 containerd config dump 로 확인한 로드된 런타임 핸들러는 runc 하나뿐이었습니다.
판정은 손이 아니라 파서에게 시킵니다. 주 설정의 임포트 목록을 읽어 글롭을 펼치고, 양쪽에서 런타임 테이블에 정의된 이름을 각각 모으세요. 마지막 줄만은 계산이 아니라 사고 당일의 관측값이며, 그 값은 안내문에 적혀 있습니다.
로드된 설정을 보는 점검 스크립트
/root/gpuop/bin/check-runtime.sh 를 만드세요. containerd config dump 를 절대 경로 없이 호출해 그 출력에서 containerd.runtimes.nvidia 를 찾고, 있으면 0, 없으면 1 로 끝나야 합니다. /etc/containerd/config.toml 경로를 읽으면 안 됩니다 — 채점기가 그것을 오답으로 잡습니다.
판정 기준은 파일이 아니라 실행 중인 데몬이 들고 있는 설정입니다. 명령은 절대 경로 없이 부르세요 — 채점기가 가짜 데몬을 앞에 세워 두고 여러분의 스크립트를 실제로 두 번 돌려 봅니다. 찾는 것이 없을 때 0 이 아닌 값으로 끝나야 점검이 점검 구실을 합니다.
RuntimeClass 와 GPU 파드, 그리고 Pending
/root/gpuop/k8s/runtimeclass.yaml 에 이름과 handler 가 모두 nvidia 인 RuntimeClass 를 쓰고, /root/gpuop/k8s/gpu-pod.yaml 에 네임스페이스 gpu-lab, 이름 cuda-probe, runtimeClassName: nvidia, 컨테이너 자원 limits 에 nvidia.com/gpu: 1 인 파드를 쓰세요. 네임스페이스를 먼저 만들고 둘 다 적용하세요. 파드는 뜨지 않습니다 — PodScheduled 조건의 reason 과 message 를 두 줄로 /root/gpuop/out/unscheduled.txt 에 저장하세요.
RuntimeClass 는 클러스터 스코프라 네임스페이스가 없고, 이름과 핸들러는 각각 다른 필드입니다. 파드는 뜨지 않는 것이 정상입니다 — 스케줄 조건에 적힌 이유를 파일로 남겨 두세요. 조건이 채워질 때까지 몇 초 걸리니 고정된 시간을 자지 말고 조건을 확인하며 기다리세요.
device plugin 이 하는 일을 노드 status 로
kubectl patch node lab-node-0 --subresource=status 로 capacity 와 allocatable 양쪽에 nvidia.com/gpu 를 "4" 로 넣으세요. 파드가 Running 이 되면 세 노드의 이름과 광고량을 /root/gpuop/out/node-gpu.txt 에 두 칸짜리 표로 저장하세요.
확장 자원은 노드 오브젝트의 status 아래에 있고, status 는 별도 서브리소스라 그냥 patch 하면 반영되지 않습니다. capacity 와 allocatable 두 곳을 함께 채워야 하며 값은 문자열로 씁니다. 자리가 생기면 대기하던 파드를 스케줄러가 다시 집습니다.
타임슬라이싱 설정을 읽고 슬롯과 메모리 계산
/root/gpuop/k8s/time-slicing.yaml 에 네임스페이스 gpu-operator, 이름 time-slicing-config 인 ConfigMap 을 쓰세요. data.any 는 YAML 문자열이고 그 안에 sharing.timeSlicing.failRequestsGreaterThanOne: true 와 resources 배열 첫 항목이 name: nvidia.com/gpu, replicas: 5 여야 합니다. 적용한 뒤 lab-node-1 의 광고량을 "20" 으로 올리고, /root/gpuop/out/slots.txt 에 일곱 줄을 적으세요 — PHYSICAL_GPUS REPLICAS ADVERTISED VRAM_PER_GPU_GIB VRAM_GUARANTEED_PER_SLOT_GIB VRAM_SHARED_PER_GPU_GIB ISOLATION. 장비는 40GiB A100 4장 기준입니다.
ConfigMap 의 data 값은 반드시 문자열이라 설정을 매핑으로 적으면 적용이 거부됩니다. 블록 스칼라로 넣으세요. 계산에서 함정은 메모리 줄입니다 — 자리가 다섯 배가 됐다고 메모리가 다섯으로 나뉘는 것이 아닙니다.
롤아웃을 멈춰 세우고 갈라진 상태를 보고
/root/gpuop/k8s/toolkit-ds.yaml 에 네임스페이스 gpu-operator, 이름 nvidia-container-toolkit, updateStrategy.rollingUpdate.maxUnavailable: 1, 이미지 nvcr.io/nvidia/k8s/container-toolkit:v1.16.2, 메모리 요청 128Mi 인 데몬셋을 쓰고 적용하세요. 세 파드가 모두 Ready 가 되면 이미지를 v1.17.0 으로 올리면서 메모리 요청을 64Gi 로 함께 바꾸세요. 롤아웃이 멈추면 /root/gpuop/out/rollout.txt 에 여섯 줄을 적으세요 — DESIRED UPDATED READY OLD_IMAGE_PODS MAX_UNAVAILABLE, 그리고 BLOCKER=insufficient-memory.
먼저 세 노드가 모두 준비될 때까지 기다린 뒤에 새 스펙을 미세요. 그러지 않으면 무엇이 멈춘 것인지 구분할 수 없습니다. 노드 용량은 32Gi 이므로 그보다 큰 메모리를 요청하면 어느 노드도 그 파드를 받지 못합니다. 보고서의 숫자는 데몬셋 status 에서 그대로 읽어 적으세요.