Ein Agent betreibt mein GPU-Cluster

- 8 Min. Lesezeit

Seit drei Wochen betreibt ein Agent mein GPU-Cluster. Er skaliert Blackwell-Karten hoch und runter, wählt Nodes aus, und gibt Silizium frei, wenn niemand rendert.

Kein autonomes Wunder. Er läuft nur die Schleife, die ich früher manuell überwacht habe. Hier ist die ehrliche Bilanz dessen, was das mir beigebracht hat, inklusive der Teile, die für LinkedIn zu lang waren.

Ein GPU-Rack, das von einem Agenten orchestriert wird

Mein Ausgangspunkt

Der Cluster besteht aus einigen Nodes mit sehr unterschiedlichen GPUs: eine Box mit Blackwell-Karten fürs Training, eine einzelne Workstation mit 5090, eine kleinere Pro-Karte und ein paar ARM-Dev-Boxen. Ein einziger Kubernetes-Cluster spannt sich über all das.

Das Problem, das jede der folgenden Entscheidungen angetrieben hat, ist Contention. Dieselben knappen GPUs müssen zwei Workloads bedienen, die in entgegengesetzte Richtungen ziehen:

  • Interaktive Render-UIs (Bild- und Video-Generierung), die ein Mensch öffnet, zehn Minuten benutzt, und dann wieder verlässt.
  • Batch-Training und lange Render-Jobs, die jede Karte, die sie bekommen, stundenlang aufbrauchen.

Wenn die interaktive Seite GPUs hält, wenn sie idle ist, verhungert die Batch-Warteschlange. Wenn die Batch-Seite niemals freigibt, ist die UI tot, wenn man sie öffnet. Der ganze Sinn, einen Agenten fahren zu lassen, ist, dieses Gleichgewicht zu halten, ohne dass ich dort sitze und Deployments manuell umschalte.

Der Stack dahinter, bewusst langweilig:

  • Kubernetes 1.36 als Substrat, weil ich echte GPU-Scheduling brauche, nicht einen Haufen docker run.
  • Cilium für Networking und die Gateway API (eBPF-Dataplane, kein separater Ingress-Controller).
  • KEDA HTTP Add-on für scale-from-zero, angetrieben von echtem HTTP-Verkehr.
  • NVIDIA DRA-Treiber (Dynamic Resource Allocation), damit ich nach spezifischen GPUs fragen kann, nicht nur nach “irgendeiner GPU”.
  • Flux für GitOps: Das Repo ist die Single Source of Truth, der Agent reconciliert dagegen.
  • ComfyUI und Wan2GP als Render-Engines, beide holen ihre Weights von einem gemeinsamen NFS-Volume.

Nichts davon ist exotisch. Das Interessante ist, wie die Teile zusammenpassen, und die drei Stellen, an denen ich es zuerst falsch gemacht habe.

Die erste Lektion: Idle-GPUs, die trotzdem blockieren

Eine Render-UI schnappt sich eine GPU, sobald ihr Prozess startet, nicht erst, wenn du auf “Render” klickst. torch.cuda nimmt das Device beim Import. Also besitzt eine Interface, das niemand benutzt, den ganzen Tag über eine Karte.

Klingt trivial, bis man nur so viele Karten hat und eine Warteschlange voller Trainings-Jobs hinter einer Web-UI wartet, die seit dem Mittagessen null Traffic hatte.

Der naive Fix ist “scale das Deployment auf null, wenn idle”. Das Problem: nichts weckt es wieder auf. Ein Nutzer öffnet die URL, landet auf einer 502, und man ist es, der angepingt wird.

KEDA scale-from-zero, verkabelt mit dem Ingress

Das Pattern, das tatsächlich funktioniert, ist das KEDA HTTP Add-on. Ein Interceptor sitzt vor dem Service. Die erste Anfrage nach der Idle-Phase macht Folgendes:

flowchart LR A[HTTP request] --> B[KEDA interceptor] B -->|pod at 0| C[scale 0 to 1] C --> D[DRA driver allocates GPU] D --> E[pod serves request] E -->|idle window elapses| F[scale back to 0] F -->|GPU released| G[card free for training]

Der Interceptor hält die Anfrage während des Cold Starts offen, damit der Nutzer wartet statt einen Fehler sieht. Cold Start dauert bei einem schweren Render-Image so zwei Minuten. Zwei Minuten Wartezeit statt einer Karte, die den ganzen Tag tot liegt, nehme ich jedes Mal.

Ein minimales HTTPScaledObject sieht so aus:

apiVersion: http.keda.sh/v1alpha1
kind: HTTPScaledObject
metadata:
  name: render-ui
spec:
  hosts:
    - render.example.internal
  scaleTargetRef:
    name: render-ui
    kind: Deployment
    apiVersion: apps/v1
    service: render-ui
    port: 8080
  replicas:
    min: 0          # der ganze Punkt: null wenn idle
    max: 1
  scaledownPeriod: 1800   # 30 min idle, dann GPU freigeben

Das Deployment selbst hat kein eigenes Replica-Management. KEDA entscheidet über 0-zu-1 basierend darauf, ob Traffic durch den Interceptor fließt.

Der Teil, der wirklich interessant wurde

Hier ist der Fehler, den ich früh gemacht habe, und der mir die Hälfte meines Throughputs gekostet hat, ohne eine einzige Error-Meldung in den Logs.

Ich habe dem Scheduler überlassen, “erste verfügbare” GPU auszusuchen. Auf einer Box mit mehreren Karten auf verschiedenen PCIe-Lanes, liefert er dir eine gemischte Menge. Tensor-parallel Workloads verteilen sich dann über Karten, die keinen sauberen, breiten Link teilen, und dein NCCL all-reduce ersticht leise auf einer x4-Lane, während die anderen Karten warten.

Kein Crash. Keine Warnung. Nur Decode-Throughput auf dem halben Wert dessen, was das Hardware leisten kann, und du starrst darauf und wunderst dich warum.

Der Fix ist, GPUs aufzuhören als austauschbare Einheiten zu behandeln. Kubernetes 1.36’s Dynamic Resource Allocation lässt dich formulieren, was du tatsächlich brauchst: nicht “eine GPU”, sondern “ein kohärentes Set auf einem breiten Link”. Mit dem NVIDIA DRA-Treiber schreibst du eine ResourceClaim, die nach Link-Width selektiert, damit tensor-parallel Pods auf einer x16-Quartette landen statt auf dem, was grade zuerst frei war.

apiVersion: resource.k8s.io/v1
kind: ResourceClaimTemplate
metadata:
  name: tp4-coherent-x16
spec:
  spec:
    devices:
      requests:
        - name: gpus
          deviceClassName: gpu.nvidia.com
          count: 4
          selectors:
            - cel:
                expression: "device.attributes['pci.nvidia.com'].linkWidth == 16"

Sauberer NCCL all-reduce, kein x4-Bottleneck, zurück bei vollem Decode-Throughput. Dasselbe Hardware. Das Einzige, was sich geändert hat, ist, dass ich dem Scheduler die Wahrheit über die Workload gesagt habe.

PCIe-Lanes, eine saubere x16 hervorgehoben

Das Modell, das sich jedes Mal neu lädt

Das nächste Ding, das den Throughput bluten ließ, war leiser als eine schlechte Lane, weil es sich auch nie als Error gezeigt hat. Es hat sich als Latenz gezeigt, mit der ich angefangen hatte, normal zu leben.

Meine Render-Checkpoints liegen auf einem gemeinsamen NFS-Volume, read-only in jeden Render-Pod gemountet. Das ist bewusst so: Weights gehören auf Storage, das der ganze Cluster erreichen kann, niemals in einen Host oder ein Image gebackt. ComfyUI und Wan2GP mounten beide denselben Model-Share, also gibt es eine Kopie eines multi-Gigabyte-Checkpoints und keinen Drift zwischen den Engines.

Die Falle ist, was auf einem kalten Pod passiert. Der erste Render streamt zig Gigabyte Checkpoint von NFS in den GPU-Speicher, bevor er ein einziges Frame zeichnet. Mach das bei jeder Anfrage, und dein “zwei-Minuten-Cold-Start” wird zu zwei Minuten Disk- und PCIe-Traffic, den man immer und immer wieder bezahlt.

Der Fix ist, das Neuladen aufzuhören. Sobald ein Pod warm ist, halte ich das Modell die ganze Idle-Window über in VRAM, nicht nur für die eine Anfrage, die es geweckt hat. Die erste Anfrage nach einem Scale-up lädt den Checkpoint eagerly; jeder Render danach hotloads gegen Weights, die schon im GPU-Speicher sitzen.

spec:
  replicas: 0            # idle: Karte ist frei für Training
  template:
    spec:
      containers:
        - name: render
          env:
            - name: EAGER_LOAD_MODEL   # beim Start laden, nicht beim ersten Frame
              value: "1"
          # readiness wartet den einmaligen NFS->VRAM-Load ab,
          # damit der Pod erst Ready wird, wenn die Weights resident sind
          readinessProbe:
            httpGet: { path: /readyz, port: 8080 }
            failureThreshold: 40       # großzügig: Model-Load ist langsam, einmalig

Also ob Wan2GP einen LTX-Checkpoint aussucht oder ComfyUI einen Graphen abarbeitet, beide bezahlen den NFS-Read genau einmal pro warmen Window. Danach sind die Weights hot. Die Karte streamt aus VRAM, nicht von einem Network-Share. Wenn der Idle-Timer finally losgeht, skaliert der Pod auf null und die ganze Karte geht zurück in die Training-Warteschlange.

Das Pattern dahinter ist dasselbe wie bei der idle UI: Sag dem System die Wahrheit über Kosten. Einen Checkpoint zu laden ist teuer und sollte einmal passieren. Eine Web-UI ohne Traffic sollte nichts besitzen. Keines davon ist clever. Beides war einfach nur ich, die zu irgendeinem Zeitpunkt nicht laut genug gesagt hat, dass das so ist.

Was der Agent macht, und was er nicht macht

Das ist die Grenze, die für mich zählt, also will ich da präzise sein.

Der Agent führt aus. Er reconciliert den gewünschten Zustand, weckt Pods, gibt GPUs frei, retryt ein fehlgeschlagenes Rollout, und sagt mir Bescheid, wenn etwas vom Deklarierten abgewichen ist. Er läuft die langweilige Schleife, die ich sonst manuell überwachen würde.

Konkret: Der Cluster-Zustand lebt in einem Git-Repo. Flux beobachtet das Repo und applyt es. Der Agent sitzt oben auf dieser Schleife. Wenn ein Render-Image neu gebaut wird, fließt der neue Tag von CI in das Repo und Flux rollt es aus. Wenn ein Node eine GPU verliert oder ein Pod feststeckt, merkt der Agent die Lücke zwischen deklariert und tatsächlich, und reagiert darauf. Ein typischer Zug sieht so aus, und ich habe ihn nie getippt:

drift detected: deploy/render-ui image
  declared: render:20260630.133130
  running:  render:20260630.103700   (CI pushed a newer tag)
action: roll deploy/render-ui -> 20260630.133130
verify:  rollout complete, /readyz 200, GPU resident

Das ist der ganze Job: beobachte deklariert-gegen-aktuell, schließe die Lücke, beweise dass sie geschlossen ist. Unglamourös, und genau die Sorte Ding, die ein Mensch nicht manuell machen sollte.

Der Agent entscheidet nicht die Architektur. Das mache ich.

Er sind die Hände, nicht das Gehirn. Wer dir ein vollständig autonomes Rechenzentrum zeigt, das kein menschliches Urteil braucht, zeigt dir ein Demo, kein System, dem er echtes Geld anvertrauen würde. Das interessante Engineering steckt darin, genau diese Grenze gut zu ziehen: was ist wirklich Toil, und was ist eine Entscheidung, die einen Menschen verdient, der die Konsequenzen trägt.

Ein Pod, der von null auf eins und zurück skaliert

Lokal und Cloud, nicht lokal versus Cloud

Noch ein ehrlicher Punkt, weil die Timeline gerade voll mit Absolutismus ist.

Das läuft auf meiner eigenen Hardware und durch die Cloud. Lokal, wo Latenz, Kosten und Privacy gewinnen. Cloud, wo ich ein Frontier-Modell oder Kapazitäts-Spitzen brauche, die ich nicht besitze. Unser starkstes Modell läuft über einen managed Cloud Service, und das schämt mich nicht.

Das Setup, das tatsächlich funktioniert, beides. Die, die dir “weg aus der Cloud” oder “nur Cloud” verkaufen, verkaufen dir einen halben Stack. Der harte Teil war nie die GPU. Es ist das Routing: pro Anfrage entscheiden, was wo läuft.

Was es tatsächlich geändert hat

Einen Agenten den Cluster betreiben zu lassen, hat mich nicht faul gemacht. Es hat mich schärfer gemacht darüber, was sich lohnt, selbst zu entscheiden, und was einfach nur Toil ist.

Der Toil gehört jetzt dem Agenten. Das Urteil bleibt mein.

Das ist der Teil, den der Hype immer verkehrt herum macht.