Der neue Cluster fuhr 110 Pods pro Node. Production hing bei 58 fest.

kubernetes aws eks terraform devops

Zwei EKS-Cluster, gleicher Instance-Typ. Der dieses Jahr gebaute schedult 110 Pods pro Node. Der ältere Production-Cluster ist bei 58 gedeckelt und lief genau gegen diese Decke: CPU und Memory reichlich übrig, aber kein Platz mehr für Pods. Das Ziel klang nach einer Ein-Zeilen-Änderung: Production dieselbe Dichte geben. Daraus wurden eine neue Node Group, eine CNI-Voraussetzung und ein Terraform-Plan, der deutlich mehr wollte, als ich verlangt hatte.

Woher die 58 kommen

Per Default leitet EKS max pods aus dem Netzwerk ab, nicht aus der Rechenleistung. Das VPC CNI gibt jedem Pod eine echte VPC-IP von den ENIs des Nodes, die Obergrenze ist also ENI-Arithmetik: Eine m6g.xlarge bekommt 4 ENIs mit je 15 Adressen, und nach dem Reservieren von einer pro ENI plus zwei für das System landet man bei 58. Der Node hat 4 vCPUs und 16 GB für 58 Pods; für dicke Workloads in Ordnung, für eine Plattform voller kleiner Services miserabel.

Prefix Delegation ändert die Arithmetik. Statt einzelner IPs pro ENI-Slot hängt das CNI /28-Präfixe an, 16 Adressen pro Slot, und die praktische Grenze ist nicht mehr die ENI-Tabelle. Dem Kubelet muss die neue Decke trotzdem mitgeteilt werden, und AWS empfiehlt, sie für alles unter 30 vCPUs bei 110 zu kappen. Genau daher kam die Dichte des Greenfield-Clusters: Seine Node Groups wurden mit aktivierter Prefix Delegation und max_pods = 110 im Launch Template geboren.

Der podsPerCore-Umweg

Mein erster Instinkt auf einem Karpenter-verwalteten Cluster war podsPerCore: 10 gewesen, das klingt adaptiv: kleine Nodes bekommen weniger, große mehr. In diesem Setup ist es eine Falle, gleich doppelt. Das Kubelet wendet podsPerCore als zusätzlichen Deckel auf max pods an, eine xlarge mit 4 Cores bekommt also 40, und das liegt unter den 58, die sie ohnehin hatte. Und Karpenter berechnet die Node-Kapazität aus den ENI-Limits und kennt Prefix Delegation nicht, die Sicht des Schedulers und die des Kubelets driften also auseinander. Ein flaches maxPods: 110 mit aktivierter Prefix Delegation ist langweilig und korrekt. Der Beweis auf einem frischen Node macht Freude:

$ kubectl get node <old-node> -o jsonpath='{.status.capacity.pods}'
8
$ kubectl get node <new-node> -o jsonpath='{.status.capacity.pods}'
110

Das war eine m6g.medium, die von 8 Pods, ein fast komischer Default für einen 4-GB-Node, auf 110 ging.

Einer laufenden Node Group kann man kein Launch Template geben

Und hier unterscheidet sich Production von Greenfield. max_pods auf einer Managed Node Group zu setzen heißt, dass das Modul ein Launch Template für sie rendert, weil das Kubelet-Flag über User Data reisen muss. Aber eine Managed Node Group, die ohne Launch Template erstellt wurde, kann nie eines bekommen. Die EKS-API lehnt das Update rundheraus ab:

ValidationException: Cannot update launchTemplate

Terraform weiß das und modelliert die Änderung als ForceNew: Node Group zerstören, Ersatz erstellen. Auf einer Sandbox ist das ein Schulterzucken. Auf einem Production-Cluster heißt es, dass jeder Node der Gruppe nach Terraforms Zeitplan rotiert statt nach deinem.

Der richtige Zug ist also, nicht dagegen anzukämpfen: eine neue Node Group neben der alten erstellen, geboren mit dem Launch Template, das sie braucht.

general_arm_v2 = {
  instance_types = ["m6g.xlarge"]
  desired_size   = 3
  min_size       = 2
  max_size       = 20
  max_pods       = 110
  max_unavailable = 1
}

Die alte Gruppe bedient unangetastet weiter Traffic. Workloads wandern hinüber, während die neue Gruppe skaliert und die alte nach einem Zeitplan deiner Wahl cordoned und gedraint wird, mit einem Rollback, das schlicht „hör auf zu drainen” lautet. Die neue Gruppe über Availability Zones zu verteilen kostet auf der Instance-Seite übrigens nichts; die einzige Rechnung ist Inter-AZ-Datentransfer, derselbe Posten, der die Rechnung VPC Peering gegen Transit Gateway dominiert.

Prefix Delegation vor den Nodes aktivieren, nicht danach

Das Launch Template ist nur die halbe Änderung. Das vpc-cni-Addon muss ENABLE_PREFIX_DELEGATION=true gesetzt haben, bevor die neuen Nodes hochkommen:

vpc-cni = {
  configuration_values = jsonencode({
    env = { ENABLE_PREFIX_DELEGATION = "true" }
  })
}

Vertauschst du die Reihenfolge, baust du einen Lügner: Das Kubelet meldet 110 Slots, der Scheduler füllt sie fröhlich, und das CNI kann tatsächlich nur die IPs des alten ENI-Limits vergeben. Jeder Pod jenseits dieser Linie sitzt in ContainerCreating und wartet auf eine Adresse, die nie kommt. Nichts in der Deploy-Pipeline fängt das ab; es ist dieselbe Fehlerklasse wie ein ConfigMap-Update, das den laufenden Pod nie erreicht: alles grün außer der Realität zur Laufzeit.

Eine weitere Einschränkung versteckt sich in der Instance-Liste: Prefix Delegation ist Nitro-only. Productions Spot-Gruppe führte noch m4.large, einen Prä-Nitro-Typ aus einer anderen Ära, und jeder darauf gestartete Node wäre stillschweigend auf die alten Limits zurückgefallen. Alte Instance-Typen in einer Mixed-Instances-Policy sind genau die Art Rückstand, den niemand auditiert, bis ein Feature sie leise ausschließt.

Der Plan enthielt Änderungen, die ich nie geschrieben habe

terraform plan kam mit 6 add, 1 change, 2 destroy zurück. Meine Änderung erklärte drei davon: Node Group, Launch Template, CNI-Einstellung. Der Rest war ein Wochen zuvor gemergtes, nie angewendetes Modul-Upgrade, und es bündelte eine Migration von der aws-auth ConfigMap zu EKS Access Entries. Das hatte heute niemand bestellt, aber mein Apply hätte es mit angewendet.

Das Durchlesen war die Stunde wert. Die zwei Destroys sahen gefährlich aus und waren harmlos: null_resource-Provisioner, deren Config-Blöcke aus dem Modul gelöscht worden waren, und ein Destroy-Time-Provisioner läuft nur, wenn sein Block noch existiert. Terraform würde sie also aus dem State werfen, ohne irgendetwas auszuführen. Die ConfigMap überlebt, und mit dem Cluster im Modus API_AND_CONFIG_MAP verliert niemand den Zugriff.

Die Creates waren die eigentliche Mine. Der Plan wollte einen Access Entry für eine SSO-Admin-Rolle erstellen, und ein Blick auf den Live-Cluster zeigte: Genau dieser Entry existierte bereits, Monate zuvor von Hand über die Console angelegt. CreateAccessEntry von EKS ist nicht idempotent:

ResourceInUseException: The specified access entry resource is already in use on this cluster

Der Apply wäre mittendrin gescheitert, mit womöglich erstellter Node Group und halb angewendeter Auth-Migration. Die Auflösung war, den Wirkungsradius zu teilen: die Dichte-Änderung mit -target auf exakt die drei Ressourcen anwenden, die mir gehören, und die Auth-Migration zu ihrer eigenen Änderung machen, einer, die mit dem Import der handgemachten Entries beginnt, statt mit ihnen zu kollidieren.

Was man daraus mitnimmt

Pod-Dichte auf EKS ist eine Geburtseigenschaft. Launch Template, CNI-Modus und Kubelet-Decke werden beim Erstellen einer Node Group fixiert; der saubere Weg, sie auf einem laufenden Cluster zu ändern, ist, die nächste Generation neben die aktuelle zu stellen, nicht in place zu mutieren. Wie der enableServiceLinks-Default ist das ENI-basierte Pod-Limit etwas, das die meisten an dem Tag kennenlernen, an dem es ein Rollout blockiert.

Und behandle jede Plan-Zeile, die du nicht geschrieben hast, als Frage, nicht als Formalität. Das nie angewendete Modul-Upgrade lag für den Nächsten, der plan ausführt, im Hinterhalt, und der Live-Cluster trug Zustand, handgemachte Access Entries, den kein noch so gründliches HCL-Lesen offenbart hätte. Der Plan sagt dir, was Terraform tun will; nur der Cluster sagt dir, ob es das kann.