Ein Scheduler, der sich verzählt hat
Eine unserer GPUs betreibt zwei Dinge gleichzeitig. Ein Text-to-Speech-Dienst und ein Worker, die sich dieselbe physische Karte über MPS teilen. Das ist gewollt: Die beiden zusammen lasten eine Karte sauber aus, und getrennt hätte jeder eine eigene verschwendet.
Klingt gelöst. War es nicht. Der Scheduler hat die eine Karte doppelt gezählt, den Node auf used: 2, capacity: 1 gesetzt, und ihn damit für jeden weiteren Pod dichtgemacht — auch für welche, die gar keine GPU wollten. Ein Node mit einer freien Karte, der behauptet, er hätte keine.
Das ist die Geschichte, wie ich das gefunden, im Cluster bewiesen, bei uns produktiv gefixt und den Fix zurück ins Upstream-Projekt gebracht habe. Und die Stelle, an der ein KI-Review-Bot einen Fehler fand, den meine eigenen Tests durchgewunken hatten.
Warum sich überhaupt zwei Pods eine GPU teilen
Kurz zum Ausgangspunkt, weil das den Rest erklärt. Unsere Karten sind knapp und teuer — Blackwell fürs Training, eine Handvoll Pro-Karten für den Rest. Nicht jede Workload braucht eine ganze GPU. Der TTS-Dienst und sein Worker zusammen passen locker auf eine Karte, wenn man sie sich teilen lässt.
Das Werkzeug dafür ist die Dynamic Resource Allocation von Kubernetes plus der NVIDIA-DRA-Treiber. Statt “gib mir eine GPU” schreibt man einen ResourceClaim, und mehrere Pods können denselben Claim referenzieren — über MPS oder Time-Slicing teilen sie sich dann eine physische Karte. Im Claim-Status steht dann reservedFor mit beiden Consumern drin.
Den Cluster fährt bei uns ein GPU-affiner Scheduler: der KAI-Scheduler, ursprünglich von NVIDIA, für genau diese Art AI-Workloads gebaut. Er kennt Queues, Fair-Share, GPU-Topologie. Ein gutes Stück Software. Nur hatte es beim geteilten Claim einen blinden Fleck.
Der Bug
Der Scheduler führt pro Node einen Vektor darüber, wie viele GPUs benutzt sind. Wenn ein Pod eingeplant wird, addiert er dessen GPU-Bedarf auf diesen used-Vektor. Bei einem geteilten Claim trägt aber jeder consumierende Pod dieselbe physische Karte in seiner Allocation — und der Scheduler addierte sie einmal pro Pod.
Zwei Pods auf einer Karte macht in dieser Rechnung used: 2. Die Karte gibt es aber nur einmal, also capacity: 1. Der freie Rest wird als capacity - used gerechnet, also 1 - 2 = -1. Negativ.
Und ein Node mit negativem Idle-Wert wird von der Ressourcen-Prüfung für alles abgelehnt. Nicht nur für GPU-Pods — auch für einen Pod, der null GPUs anfragt, weil 0 > -1 schon die Prüfung reißt. Nach außen sieht das so aus:
<node>: Node didn't have enough resources: GPUs,
requested: 0, used: 2, capacity: 1
Ein Pod, der keine GPU will, wird abgewiesen, weil angeblich keine GPU da ist, auf einem Node, der eine hat. Das ist die Sorte Fehlermeldung, bei der man dreimal liest und dann anfängt, dem Log nicht mehr zu glauben.
reservedFor: 2] P2[Pod: voice-worker] --> C C --> G[1 physische GPU] G --> N["Node-Rechnung
used = 1 + 1 = 2
capacity = 1"]:::bad N --> R["idle = 1 − 2 = −1
Node für alles gesperrt"]:::bad
Erst beweisen, dann anfassen
Eine Regel bei uns: Am Produktiv-Scheduler wird nicht herumprobiert. Also musste der Beweis in eine Ecke, wo er nichts umwerfen kann.
Ich habe einen zweiten Scheduler danebengestellt — eigener Name, eigenes Deployment, nicht von unserem GitOps verwaltet. Der bindet nur Pods, die ihn explizit anfragen, und kann die echten Workloads gar nicht anfassen. Dann den Fehlerfall exakt nachgebaut: ein geteilter Claim auf einer freien Karte, zwei Consumer drauf, beide gebunden, reservedFor mit beiden Namen. Genau der Zustand der Voice-Pipeline.
Und dann der eigentliche A/B-Test: derselbe Scheduler, einmal mit dem Standard-Image, einmal mit meinem Fix, sonst nichts geändert.
- Standard: Ein GPU-loser Test-Pod bleibt
Pending, im Log wörtlich dieused: 2, capacity: 1-Zeile von oben. - Fix: Derselbe Pod läuft. Der Claim hat weiter zwei Consumer — die Karte wird jetzt einmal gezählt statt zweimal.
Der Weg dahin hatte ein paar Stolpersteine, die keiner dokumentiert, weil sie zu banal sind: Der Custom-Scheduler kriegt seine PodGroups nicht automatisch, die Test-Queue hat null Quota, der geteilte Claim braucht ein bestimmtes Label. Jede einzelne Hürde hat eine halbe Stunde gekostet und stand nirgends. Steht jetzt bei uns.
Der Fix
Im Kern eine einfache Sache: Der Node führt jetzt einen Referenzzähler pro physischem Gerät, adressiert über driver/pool/device aus der Allocation. Der erste Pod, der ein Gerät bringt, zählt es. Jeder weitere Pod, der dasselbe Gerät schon referenziert sieht, zählt es nicht nochmal. Beim Entfernen dasselbe rückwärts: Die Karte bleibt gezählt, solange noch ein Consumer da ist, und wird erst freigegeben, wenn der letzte geht.
Exklusive Claims und Nicht-DRA-GPUs bleiben unangetastet — der Dedup überspringt nur je ein Gerät, das ein anderer Pod auf demselben Node bereits beigetragen hat.
Bei uns produktiv ausgerollt haben wir das über ein eigenes Image, sauber über GitOps, mit einer Ein-Zeilen-Rückfahrkarte, falls es zickt. Seitdem läuft der Node, der vorher tot war, mit dem GPU-losen Probe-Pod durch, und die Voice-Pipeline merkt von alldem nichts. Genau so soll ein Fix sich anfühlen: unsichtbar.
Der Teil, der wehtat
Fertig heißt bei mir: auch upstream. Ein Fix, der nur in unserem Cluster lebt, ist ein Fork, den ich für immer pflege. Also Issue aufgemacht, Pull Request gestellt, sauber nach den Regeln des Projekts — eigener Commit, ordentliche Beschreibung, Tests, die den Bug ohne den Fix rot zeigen und mit ihm grün.
Und dann hat ein KI-Review-Bot, den das Projekt am PR laufen lässt, einen echten Fehler in meinem Fix gefunden. Einen, den ich nicht hatte.
Es gibt eine Sorte interner Pods — Reservierungs-Pods —, denen der Scheduler den GPU-Bedarf vorher auf null setzt. Wenn so ein Pod denselben geteilten Claim referenziert, hätte mein Dedup von dieser Null noch etwas abgezogen und den used-Wert ins Negative gedreht. Also derselbe Bug wie am Anfang, nur von der anderen Seite. Meine Tests hatten diesen Pfad nicht abgedeckt, weil ich gegen ein altes Release gebaut hatte und diesen Fall schlicht nicht auf dem Schirm.
Ich hab den Guard nachgezogen, einen Test dafür geschrieben — und der war beim ersten Versuch grün, ohne dass der Fix drin war. Was heißt: Der Test testet nichts. Das ist die gefährlichste Sorte Test, weil er ein gutes Gefühl gibt und leer ist. Ursache war ein Fehler in meinem Test-Aufbau, nicht im Fix: Ich hatte den Pod in einen anderen Namespace gelegt als seinen Claim, und die Zuordnung ist namespace-lokal — der getestete Codepfad lief nie an. Repariert, und dann ging der Test für den richtigen Grund rot und mit dem Fix grün.
Was ich mitnehme
Zwei Dinge, konkret.
Gegen den Branch bauen, gegen den man den PR stellt — nicht gegen das Release, das man zufällig ausgecheckt hat. Der ganze Reservierungs-Fall und noch ein Konventions-Fehler kamen daher, dass ich von einem alten Tag ausging und die Regeln sich seitdem geändert hatten.
Und: Ein grüner Rot-Test ist kein bestandener Test, sondern ein kaputter Aufbau. Ein Test, der nicht rot werden kann, ist schlimmer als kein Test — er ist Selbstbetrug mit grünem Häkchen.
Zur Frage, ob so ein Review-Bot sich lohnt, weil die naheliegt: Er hat einen echten Edge-Case gefangen, den ich übersehen hatte. Ein zweites Augenpaar, das nie müde wird, fängt genau die Fälle, gegen deren Annahmen man selbst blind ist. Aber er hätte den Konventions-Fehler nicht gesehen, der kam aus dem Lesen der Projekt-Doku, und einer seiner Hinweise war schlicht Rauschen. Als eine Stufe im Ablauf ist er was wert, als Ersatz fürs Handwerk davor nichts.
Für die, die’s genau wissen wollen
Bis hier reicht die Geschichte. Ab hier wird’s Code — wenn du wissen willst, wo genau der Zähler hängt und warum er so aussieht, wie er aussieht, lies weiter.
Der Einstiegspunkt ist addTaskResources in node_info.go. Die Funktion nimmt den Ressourcen-Bedarf eines Tasks und addiert ihn auf die Vektoren des Nodes — CPU, Memory, GPU. Für GPUs kam der Bedarf bis dahin aus der DRA-Allocation des Pods, und genau da lag der Fehler: Jeder Pod, der einen geteilten Claim consumiert, trägt in seiner Allocation dasselbe physische Gerät. Zwei Pods, zwei Allocations, zweimal dasselbe Gerät, zweimal addiert.
Mein Eingriff sitzt eine Zeile nach dem Aufbau des Bedarf-Vektors:
func (ni *NodeInfo) addTaskResources(task *pod_info.PodInfo) {
// ... Bedarf-Vektor aufbauen ...
ni.dedupSharedDRAGpus(task, resourcesToTrackVector)
}
Der Node führt eine neue Map, DRASharedDeviceRefCount, ein Referenzzähler pro physischem Gerät. Der Schlüssel ist das, worüber sich ein Gerät im DRA-Modell eindeutig identifizieren lässt:
func draDeviceKey(result resourceapi.DeviceRequestAllocationResult) string {
return result.Driver + "/" + result.Pool + "/" + result.Device
}
Driver/Pool/Device — der Treiber, aus dessen Pool das Gerät kommt, und der Gerätename darin. Zwei Pods, die sich dieselbe Karte teilen, produzieren denselben Key. Genau das will ich: einen stabilen Fingerabdruck der physischen Karte, unabhängig davon, wie viele Pods sie gerade halten.
Der Dedup selbst ist dann fast langweilig — der erste Pod, der ein Gerät bringt, zählt es, jeder weitere sieht die Referenz und lässt es sein:
alreadyCounted := 0.0
for _, key := range ni.allocatedGPUDeviceKeys(task) {
if ni.DRASharedDeviceRefCount[key] > 0 {
alreadyCounted++
}
ni.DRASharedDeviceRefCount[key]++
}
allocatedGPUDeviceKeys filtert dabei vorher auf GPUs — nur Geräte, deren Treiber der NVIDIA-DRA-Treiber ist. Exklusive Claims, CPU-Geräte, alles andere fasst der Dedup nicht an. Das war mir wichtig: Der Eingriff soll chirurgisch sein, nicht die halbe Buchhaltung umkrempeln.
Interessant wird’s an den Rändern, und einer davon ist genau der, den der Review-Bot gefunden hat. Vor dem Dedup steht eine Zeile, die aussieht wie eine Kleinigkeit:
current := resourcesToTrack.Get(resource_info.GPUIndex)
if current <= 0 {
return
}
Die ist nicht kosmetisch. Reservierungs-Pods tragen null GPUs — der Scheduler nullt ihren GPU-Index vorher aus. Ohne dieses return würde der Dedup von dieser Null noch etwas abziehen und den used-Wert ins Negative drehen. Derselbe Bug wie am Anfang, nur von hinten. Und selbst wenn der Wert positiv ist, deckelt eine zweite Zeile die Deduktion:
if alreadyCounted > current {
alreadyCounted = current
}
Nie mehr abziehen, als der Task selbst beigetragen hat. Zwei Zeilen, die im Normalfall nie greifen — und genau deshalb hatte ich sie beim ersten Wurf nicht. Der Freigabe-Pfad, releaseSharedDRAGpus, spiegelt das Ganze rückwärts: Referenz runter, und erst wenn der Zähler eines Geräts auf null fällt, wird die Karte im Vektor wieder freigegeben. Symmetrie, sonst driftet der Zustand über die Zeit auseinander.
Das ist der ganze Fix. Ein Zähler, ein Schlüssel aus drei Feldern, zwei Guards an den Rändern. Der schwierige Teil war nie der Code — der war, den Fall überhaupt zu sehen.
Der Node zählt jetzt richtig, der PR liegt beim Projekt, und der eine Test, der vorher hätte lügen können, kann es nicht mehr. Das reicht mir für heute.