With no label, nothing happens — and nothing complains
한국어 원문으로 표시합니다.
목표
GPU 노드를 만드는 라벨 세 벌(NFD·GFD·오퍼레이터)을 출처별로 세우고, 라벨 값 규약을 API 서버에게 직접 거절당하며 배우고, In·NotIn·Exists·Gt 와 term 목록으로 파드의 자리를 정하고, 발견 결과를 되돌리는 동기화기와 라벨 규약 점검기를 손으로 만듭니다.
왜 중요한가
GPU Operator 는 노드에 꽂힌 카드를 직접 보지 않습니다. 라벨을 봅니다. nfd-worker 가 PCI 벤더 ID 0x10de 를 찾아 feature.node.kubernetes.io/pci-10de.present=true 를 붙이고, 오퍼레이터는 그 라벨이 붙은 노드에만 오퍼랜드를 올리고, gpu-feature-discovery 가 모델·장수·메모리·드라이버 판을 라벨로 더 붙이면 그때부터 사람과 스케줄러가 그 라벨로 자리를 고릅니다. 이 사슬의 무서운 점은 끊어져도 오류가 나지 않는다는 것입니다. 라벨이 없으면 오퍼랜드가 안 뜨고, 안 뜨니 자원이 광고되지 않고, 광고가 없으니 파드는 그냥 Pending 입니다. 어디에도 '라벨이 없어서' 라고 적히지 않습니다. 그래서 GPU 클러스터를 다루는 사람의 첫 손버릇은 kubectl get node -o json 으로 라벨부터 읽는 것이 되고, 두 번째 손버릇은 라벨 규약을 검사하는 작은 도구를 갖고 다니는 것이 됩니다. 이 실습이 그 둘을 만듭니다.
단계
/root/gpunfd에서 작업합니다(export KUBECONFIG=/root/.kube/config). 세 노드에 라벨을 붙여 '발견이 끝난 클러스터' 를 세우세요. lab-node-0 에는feature.node.kubernetes.io/pci-10de.present=true,feature.node.kubernetes.io/kernel-version.major=5,nvidia.com/gpu.present=true,nvidia.com/gpu.product=NVIDIA-A100-SXM4-40GB,nvidia.com/gpu.count=4,nvidia.com/gpu.memory=40537,nvidia.com/cuda.driver.major=550을 붙입니다. lab-node-1 에는 같은 키로pci-10de.present=true,kernel-version.major=5,gpu.present=true,gpu.product=NVIDIA-A10,gpu.count=2,gpu.memory=22731,cuda.driver.major=535를 붙입니다. lab-node-2 에는feature.node.kubernetes.io/kernel-version.major=5하나만 붙이고pci-10de.present도nvidia.com/로 시작하는 라벨도 붙이지 마세요(GPU 가 없는 노드입니다). 그리고/root/gpunfd/out/sources.txt에 세 줄을 적으세요 — 어느 라벨을 어느 컴포넌트가 붙이는지입니다.feature.node.kubernetes.io/pci-10de.present=nfd-worker,nvidia.com/gpu.product=gpu-feature-discovery,nvidia.com/gpu.deploy.driver=gpu-operator를 한 줄씩 적습니다.- 장치 이름
NVIDIA A100-SXM4-40GB를 그대로 라벨 값으로 넣어 보세요 —kubectl patch node lab-node-1 -p '{"metadata":{"labels":{"nvidia.com/gpu.product":"NVIDIA A100-SXM4-40GB"}}}'를 실행하고 그 출력을 표준 오류까지 함께/root/gpunfd/out/reject.txt에 저장하세요(거절당해야 정상이며 라벨은 바뀌지 않습니다). 그리고/root/gpunfd/sanitize.sh를 만드세요 — 인자로 받은 문자열을 라벨 값으로 쓸 수 있게 다듬어 한 줄로 출력합니다. 규칙은 셋입니다. (1) 영숫자와-_.이 아닌 문자는 모두-로 바꾼다, (2) 63자를 넘으면 63자로 자른다, (3) 양 끝이 영숫자가 아니면 영숫자가 나올 때까지 떼어 낸다. 만든 뒤bash /root/gpunfd/sanitize.sh "NVIDIA A100-SXM4-40GB"가NVIDIA-A100-SXM4-40GB를 내는지 확인하세요. - 네임스페이스
nfd-lab를 만들고/root/gpunfd/k8s/a100-job.yaml에 파드a100-job을 쓰세요. 컨테이너 이름은trainer, 이미지는nvcr.io/nvidia/pytorch:24.07-py3입니다.spec.affinity.nodeAffinity.requiredDuringSchedulingIgnoredDuringExecution하나로nvidia.com/gpu.product가NVIDIA-A100-SXM4-40GB인 노드만 고르게 하세요.nodeSelector나nodeName은 쓰지 않습니다. 적용한 뒤 파드가 어느 노드로 갔는지 확인하세요. /root/gpunfd/k8s/any-gpu-job.yaml에 파드any-gpu-job을 쓰세요(컨테이너trainer, 같은 이미지). 조건은 한 개의 term 안에 matchExpressions 두 개입니다 —nvidia.com/gpu.present가Exists이고, 동시에nvidia.com/gpu.product가NVIDIA-A10이 아닌(NotIn) 노드. 적용한 뒤 파드가 lab-node-0 으로 가는 것을 확인하세요.Exists에는values를 적지 않는다는 점에 주의하세요.- 파드 둘을 더 만드세요.
/root/gpunfd/k8s/gt-job.yaml의gt-job은 term 하나에 조건 하나 —nvidia.com/gpu.count가Gt3인 노드입니다./root/gpunfd/k8s/or-job.yaml의or-job은 term 두 개입니다 — 첫째는gpu.count Gt 3, 둘째는gpu.product In [NVIDIA-A10]. 둘 다 컨테이너는trainer, 이미지는 같습니다. 적용한 뒤gt-job은 lab-node-0 으로만 갈 수 있고or-job은 GPU 노드 둘 중 하나로 간다는 것을 확인하고,/root/gpunfd/out/placement.txt에 두 줄을<파드이름> <노드이름>꼴로 적으세요. /root/gpunfd/features/아래에 노드마다 파일 하나씩lab-node-0.env·lab-node-1.env를 만드세요. 각 줄은<라벨키>=<값>이고 1단계에서 붙인nvidia.com/라벨들을 그대로 담습니다(워커가 보고한 사실입니다). 그리고/root/gpunfd/nfd-sync.sh를 만드세요 — 각 파일의 줄과 실제 노드 라벨을 대조해 다르면 파일 쪽 값으로 되돌리고, 되돌린 것마다<노드> <키> <값>한 줄을 출력합니다. 모두 같으면 아무것도 출력하지 않고 0 으로 끝납니다. 만든 뒤kubectl label node lab-node-1 nvidia.com/gpu.count=9 --overwrite로 값을 하나 망가뜨리고bash /root/gpunfd/nfd-sync.sh를 돌려 그 출력을/root/gpunfd/out/sync.txt에 저장하세요. 끝난 뒤 라벨은 원래 값으로 돌아와 있어야 합니다.- lab-node-1 에
feature.node.kubernetes.io/custom-gpu-training=true를 붙이세요(NFD 의 사용자 정의 규칙이 붙였다고 칩니다)./root/gpunfd/k8s/train-a.yaml에 파드train-a를 쓰고(컨테이너trainer, 같은 이미지) 그 라벨이true인 노드를 required nodeAffinity 로 요구하게 한 뒤 적용해 lab-node-1 에서 뜨는 것을 확인하세요. 그 다음 그 라벨을 lab-node-1 에서 지우고, 같은 내용에 이름만train-b로 바꾼 매니페스트를/root/gpunfd/k8s/train-b.yaml로 만들어 적용하세요. 마지막으로/root/gpunfd/out/ignored.txt에 정확히 두 줄을 적으세요 —train-a=Running:lab-node-1과train-b=Pending. /root/gpunfd/label-audit.sh를 만드세요. 클러스터의 모든 노드를 돌며nvidia.com/gpu.present가true인 노드에 대해 네 가지를 봅니다 — (1)nvidia.com/gpu.product·nvidia.com/gpu.count·nvidia.com/gpu.memory가 모두 있는가, (2)gpu.count와gpu.memory가 숫자로만 되어 있는가, (3)gpu.product가 63자 이하인가, (4)gpu.product가 영숫자로 시작해 영숫자와-_.로만 이루어졌는가. 어긋난 것마다<노드> <라벨키> <이유>를 한 줄씩 내고 하나라도 있으면 종료 코드 1 로 끝냅니다. 아무 문제가 없으면OK한 줄만 내고 0 으로 끝냅니다. 이유는missing·not-a-number·too-long·bad-format중 하나를 씁니다. 만든 뒤 지금 클러스터에 돌려 그 출력을/root/gpunfd/out/audit.txt에 저장하세요. 노드 목록을 스크립트 안에 적어 두지 마세요 — 채점기가 노드를 하나 더 만들어 놓고 부릅니다.
참고
export KUBECONFIG=/root/.kube/config로 시작합니다. kwok 이 띄운 진짜 kube-apiserver 이고 노드는 lab-node-0/1/2 셋입니다. 모든 산출물은/root/gpunfd아래에 둡니다.- 이 환경에는 GPU 도 NFD 도 GFD 도 없습니다. 그래서 '발견' 은 1단계에서 여러분이 세웁니다. 대신 라벨 값 검증·nodeAffinity·스케줄러 판정·노드 추가는 전부 진짜 API 서버가 합니다.
- 파드는 실제로 실행되지 않고 Ready 로 위조됩니다. 이 실습이 보는 것은 컨테이너 안이 아니라
.spec.nodeName·.status.phase·.status.conditions같은 API 오브젝트입니다. - 흔한 실수:
Exists·DoesNotExist에values를 적는 것. 값을 보지 않는 연산자라 API 서버가 거절합니다. - 흔한 실수:
NotIn만으로 GPU 노드를 고르는 것. 그 키가 아예 없는 노드도NotIn을 통과합니다. - 흔한 실수: 점검기 안에 노드 이름을 적어 두는 것. 노드가 늘면 그 도구는 그날부터 거짓말을 합니다.
- Assigning Pods to Nodes · Labels and Selectors · Node Feature Discovery: Feature labels
발견 결과를 세운다 — 라벨 세 벌은 출처가 다르다
/root/gpunfd 에서 작업합니다(export KUBECONFIG=/root/.kube/config). 세 노드에 라벨을 붙여 '발견이 끝난 클러스터' 를 세우세요. lab-node-0 에는 feature.node.kubernetes.io/pci-10de.present=true, feature.node.kubernetes.io/kernel-version.major=5, nvidia.com/gpu.present=true, nvidia.com/gpu.product=NVIDIA-A100-SXM4-40GB, nvidia.com/gpu.count=4, nvidia.com/gpu.memory=40537, nvidia.com/cuda.driver.major=550 을 붙입니다. lab-node-1 에는 같은 키로 pci-10de.present=true, kernel-version.major=5, gpu.present=true, gpu.product=NVIDIA-A10, gpu.count=2, gpu.memory=22731, cuda.driver.major=535 를 붙입니다. lab-node-2 에는 feature.node.kubernetes.io/kernel-version.major=5 하나만 붙이고 pci-10de.present 도 nvidia.com/ 로 시작하는 라벨도 붙이지 마세요(GPU 가 없는 노드입니다). 그리고 /root/gpunfd/out/sources.txt 에 세 줄을 적으세요 — 어느 라벨을 어느 컴포넌트가 붙이는지입니다. feature.node.kubernetes.io/pci-10de.present=nfd-worker, nvidia.com/gpu.product=gpu-feature-discovery, nvidia.com/gpu.deploy.driver=gpu-operator 를 한 줄씩 적습니다.
kubectl label node <이름> <키>=<값> --overwrite 로 여러 개를 한 번에 붙일 수 있습니다. 라벨 키의 접두사가 곧 출처입니다 — feature.node.kubernetes.io/ 는 Node Feature Discovery 가, nvidia.com/gpu. 로 시작하는 발견 라벨은 GPU Feature Discovery 가, nvidia.com/gpu.deploy. 는 GPU Operator 가 자기 오퍼랜드를 게이팅하려고 붙입니다. 0x10de 는 NVIDIA 에 배정된 PCI 벤더 ID 입니다.
벤더가 주는 이름은 그대로 라벨이 되지 못한다
장치 이름 NVIDIA A100-SXM4-40GB 를 그대로 라벨 값으로 넣어 보세요 — kubectl patch node lab-node-1 -p '{"metadata":{"labels":{"nvidia.com/gpu.product":"NVIDIA A100-SXM4-40GB"}}}' 를 실행하고 그 출력을 표준 오류까지 함께 /root/gpunfd/out/reject.txt 에 저장하세요(거절당해야 정상이며 라벨은 바뀌지 않습니다). 그리고 /root/gpunfd/sanitize.sh 를 만드세요 — 인자로 받은 문자열을 라벨 값으로 쓸 수 있게 다듬어 한 줄로 출력합니다. 규칙은 셋입니다. (1) 영숫자와 - _ . 이 아닌 문자는 모두 - 로 바꾼다, (2) 63자를 넘으면 63자로 자른다, (3) 양 끝이 영숫자가 아니면 영숫자가 나올 때까지 떼어 낸다. 만든 뒤 bash /root/gpunfd/sanitize.sh "NVIDIA A100-SXM4-40GB" 가 NVIDIA-A100-SXM4-40GB 를 내는지 확인하세요.
라벨 값 규약은 쿠버네티스가 정합니다 — 63자 이하, 영숫자로 시작하고 끝나며, 가운데에는 - _ . 만 올 수 있습니다. 그래서 gpu-feature-discovery 는 드라이버가 주는 장치 이름을 그대로 쓰지 않고 다듬어서 붙입니다. sed 's/[^A-Za-z0-9_.-]/-/g' 로 한 번에 바꿀 수 있고, 앞뒤 정리는 sed -E 's/^[^A-Za-z0-9]+//' 처럼 두 번 하면 됩니다. 자르기를 먼저 하고 양 끝 정리를 나중에 해야 63번째 글자가 하이픈일 때도 규약을 지킵니다.
모델 이름으로 자리를 고른다
네임스페이스 nfd-lab 를 만들고 /root/gpunfd/k8s/a100-job.yaml 에 파드 a100-job 을 쓰세요. 컨테이너 이름은 trainer, 이미지는 nvcr.io/nvidia/pytorch:24.07-py3 입니다. spec.affinity.nodeAffinity.requiredDuringSchedulingIgnoredDuringExecution 하나로 nvidia.com/gpu.product 가 NVIDIA-A100-SXM4-40GB 인 노드만 고르게 하세요. nodeSelector 나 nodeName 은 쓰지 않습니다. 적용한 뒤 파드가 어느 노드로 갔는지 확인하세요.
nodeSelector 는 키와 값이 정확히 같은지만 봅니다. nodeAffinity 는 같은 일을 하면서 In·NotIn·Exists·DoesNotExist·Gt·Lt 를 쓸 수 있어 '이 모델들 중 하나' 같은 조건을 적을 수 있습니다. requiredDuringScheduling 은 스케줄 시점의 조건이고, 뒤에 붙은 IgnoredDuringExecution 은 이미 뜬 파드에는 다시 적용되지 않는다는 뜻입니다. 파드가 어디로 갔는지는 -o wide 나 .spec.nodeName 으로 봅니다.
한 term 안의 조건들은 모두 참이어야 한다
/root/gpunfd/k8s/any-gpu-job.yaml 에 파드 any-gpu-job 을 쓰세요(컨테이너 trainer, 같은 이미지). 조건은 한 개의 term 안에 matchExpressions 두 개입니다 — nvidia.com/gpu.present 가 Exists 이고, 동시에 nvidia.com/gpu.product 가 NVIDIA-A10 이 아닌(NotIn) 노드. 적용한 뒤 파드가 lab-node-0 으로 가는 것을 확인하세요. Exists 에는 values 를 적지 않는다는 점에 주의하세요.
한 nodeSelectorTerms 항목(term) 안의 matchExpressions 는 모두 만족해야 합니다(AND). 그래서 'GPU 가 있는 노드 중에서 이 모델만 빼고' 같은 조건이 한 term 으로 적힙니다. Exists 와 DoesNotExist 는 값을 보지 않으므로 values 를 적으면 API 서버가 거절합니다. NotIn 은 그 키가 아예 없는 노드도 통과시킨다는 점이 함정입니다 — 그래서 Exists 를 함께 걸어 GPU 노드로 좁힙니다.
term 목록은 OR 이고, 숫자 라벨은 크기로 견줄 수 있다
파드 둘을 더 만드세요. /root/gpunfd/k8s/gt-job.yaml 의 gt-job 은 term 하나에 조건 하나 — nvidia.com/gpu.count 가 Gt 3 인 노드입니다. /root/gpunfd/k8s/or-job.yaml 의 or-job 은 term 두 개입니다 — 첫째는 gpu.count Gt 3, 둘째는 gpu.product In [NVIDIA-A10]. 둘 다 컨테이너는 trainer, 이미지는 같습니다. 적용한 뒤 gt-job 은 lab-node-0 으로만 갈 수 있고 or-job 은 GPU 노드 둘 중 하나로 간다는 것을 확인하고, /root/gpunfd/out/placement.txt 에 두 줄을 <파드이름> <노드이름> 꼴로 적으세요.
nodeSelectorTerms 는 목록이고 항목들 사이는 OR 입니다 — 하나만 맞아도 그 노드는 후보가 됩니다. Gt 와 Lt 는 값이 정수로 읽힐 때만 쓸 수 있고 values 에는 값 하나만 적습니다. 라벨 값은 문자열인데 이 두 연산자만 정수로 해석한다는 점이 자주 잊히는 부분입니다. OR 로 후보가 둘이 되면 어느 쪽으로 갈지는 스케줄러의 점수가 정합니다 — 그래서 '둘 중 하나' 가 정상입니다.
손으로 바꾼 라벨은 왜 되돌아오는가
/root/gpunfd/features/ 아래에 노드마다 파일 하나씩 lab-node-0.env·lab-node-1.env 를 만드세요. 각 줄은 <라벨키>=<값> 이고 1단계에서 붙인 nvidia.com/ 라벨들을 그대로 담습니다(워커가 보고한 사실입니다). 그리고 /root/gpunfd/nfd-sync.sh 를 만드세요 — 각 파일의 줄과 실제 노드 라벨을 대조해 다르면 파일 쪽 값으로 되돌리고, 되돌린 것마다 <노드> <키> <값> 한 줄을 출력합니다. 모두 같으면 아무것도 출력하지 않고 0 으로 끝납니다. 만든 뒤 kubectl label node lab-node-1 nvidia.com/gpu.count=9 --overwrite 로 값을 하나 망가뜨리고 bash /root/gpunfd/nfd-sync.sh 를 돌려 그 출력을 /root/gpunfd/out/sync.txt 에 저장하세요. 끝난 뒤 라벨은 원래 값으로 돌아와 있어야 합니다.
NFD 는 자기가 붙인 라벨의 주인이라, 워커가 주기적으로 보고하고 마스터가 그 보고와 노드를 맞춥니다. 그래서 사람이 손으로 고친 값은 다음 주기에 조용히 사라집니다 — 원인을 모르면 '라벨이 자꾸 돌아온다' 는 유령을 쫓게 됩니다. 라벨을 읽을 때 jq 의 // 는 값이 false 일 때도 기본값으로 바꿔 버리므로 if . == null 로 갈라야 안전합니다. 되돌릴 때는 kubectl label --overwrite 입니다. 두 번째 실행에서 아무 출력이 없어야 멱등한 것입니다.
라벨을 지워도 이미 뜬 파드는 쫓겨나지 않는다
lab-node-1 에 feature.node.kubernetes.io/custom-gpu-training=true 를 붙이세요(NFD 의 사용자 정의 규칙이 붙였다고 칩니다). /root/gpunfd/k8s/train-a.yaml 에 파드 train-a 를 쓰고(컨테이너 trainer, 같은 이미지) 그 라벨이 true 인 노드를 required nodeAffinity 로 요구하게 한 뒤 적용해 lab-node-1 에서 뜨는 것을 확인하세요. 그 다음 그 라벨을 lab-node-1 에서 지우고, 같은 내용에 이름만 train-b 로 바꾼 매니페스트를 /root/gpunfd/k8s/train-b.yaml 로 만들어 적용하세요. 마지막으로 /root/gpunfd/out/ignored.txt 에 정확히 두 줄을 적으세요 — train-a=Running:lab-node-1 과 train-b=Pending.
조건의 이름이 답을 말해 줍니다. requiredDuringSchedulingIgnoredDuringExecution 은 스케줄할 때만 요구하고, 실행 중에는 조건이 깨져도 아무 일도 하지 않습니다. 그래서 라벨을 잘못 지워도 기존 파드는 멀쩡하고, 다음에 새로 뜨는 파드부터 조용히 Pending 이 됩니다 — 사고가 몇 시간 뒤에 드러나는 이유입니다. 라벨을 지우는 것은 kubectl label node <이름> <키>- 입니다. Pending 의 이유는 .status.conditions 의 PodScheduled 메시지에 적혀 있습니다.
규약을 지키는지 검사하는 도구를 만든다
/root/gpunfd/label-audit.sh 를 만드세요. 클러스터의 모든 노드를 돌며 nvidia.com/gpu.present 가 true 인 노드에 대해 네 가지를 봅니다 — (1) nvidia.com/gpu.product·nvidia.com/gpu.count·nvidia.com/gpu.memory 가 모두 있는가, (2) gpu.count 와 gpu.memory 가 숫자로만 되어 있는가, (3) gpu.product 가 63자 이하인가, (4) gpu.product 가 영숫자로 시작해 영숫자와 - _ . 로만 이루어졌는가. 어긋난 것마다 <노드> <라벨키> <이유> 를 한 줄씩 내고 하나라도 있으면 종료 코드 1 로 끝냅니다. 아무 문제가 없으면 OK 한 줄만 내고 0 으로 끝냅니다. 이유는 missing·not-a-number·too-long·bad-format 중 하나를 씁니다. 만든 뒤 지금 클러스터에 돌려 그 출력을 /root/gpunfd/out/audit.txt 에 저장하세요. 노드 목록을 스크립트 안에 적어 두지 마세요 — 채점기가 노드를 하나 더 만들어 놓고 부릅니다.
노드 목록은 kubectl get nodes -o jsonpath='{range .items[*]}{.metadata.name}{"\n"}{end}' 로 그때그때 받아 옵니다. 노드 하나마다 kubectl get node <이름> -o json 을 한 번만 받아 두고 jq 로 여러 번 꺼내면 60초 예산 안에 넉넉히 들어옵니다. '라벨이 없다' 와 '라벨 값이 빈 문자열이다' 는 다른 사건이라 jq 의 // 로 뭉뚱그리면 안 됩니다. 문자열 길이는 셸에서 ${#변수} 로 셉니다. 종료 코드는 마지막 echo 가 아니라 exit 로 못 박으세요.