Performance Optimierung: PreBid Server (2-Node Cluster)
Übersicht
Dieses Dokument fasst die vollständige Untersuchung und alle durchgeführten Maßnahmen zur Behebung der Latenz-Probleme im PreBid-Server-Cluster zusammen.
Scope
November - März
Ausgangssituation
Cluster-Setup
- 2 Nodes:
kube-master(5.199.128.26) +kube-worker(5.199.140.38) - k3s v1.30.6, flannel mit WireGuard-Backend (
--flannel-backend wireguard-native) - 24 Pods (activeagent): 12 auf master, 12 auf worker
- Externe IPs statisch vom Upstream-Router auf die Node-IPs geroutet (kein ARP/BGP)
Symptome
Benchmark-Ergebnisse zeigten eine starke Tail-Latenz unter Last (100k RPS, 100 Connections):
| Percentile | 2-Node (problematisch) | 1-Node Referenz (200k RPS) |
|---|---|---|
| p50 | ~3 ms | < 1 ms |
| p90 | ~17 ms | < 3 ms |
| p95 | ~245 ms | < 3 ms |
| p99 | ~531 ms | < 3 ms |
| p999 | ~676 ms | < 3 ms |
| >30 ms | ~8 % | < 0.1 % |
Schlechte Performance Active Agent Client
Active Agent betreibt einen internen Schwellwert: Liegt die PBS-Antwortzeit über 3 ms, drosselt der AA-Client die Query-Rate. Das ist by design und bleibt aktiv. Durch die hohe PBS-Latenz (NIC-Queue-Bottleneck, siehe Maßnahme 1) griff dieses Throttling dauerhaft — AA schickte deshalb nur ca. ~25 % der eigentlichen Anfragen an den PBS.
Zwei Client-seitige Konfigurationsprobleme verschärften die Situation zusätzlich:
| Problem | Auswirkung |
|---|---|
| Nur 24 gleichzeitige Verbindungen | Zu wenig Parallelität; die geringe Verbindungsanzahl konzentrierte die Last auf zu wenige Sockets |
| Feste IP statt DNS-Round-Robin | Effektiv wurde nur ein Node adressiert — der gesamte Traffic lief über eine VM, was den NIC-Queue-Bottleneck auf genau dieser VM noch weiter verschärfte |
Durch die Kombination aus PBS-seitigem Bottleneck und ungünstiger Client-Konfiguration war die beobachtete 25%-Rate ein Symptom — nicht die Ursache. Mit Behebung der PBS-Latenz (Maßnahme 1) griff das Throttling deutlich seltener, und AA passte parallel den Client an.
Befund: Single-Ingress-Asymmetrie
Messungen in einem 60-Sekunden-Fenster auf beiden VMs zeigten, dass der gesamte externe Live-Traffic effektiv nur über einen Host lief:
| Host | VIP1 (5.199.128.26) | VIP2 (5.199.140.38) |
|---|---|---|
| pve / kube-master | 3.275.478 Pakete | 0 |
| MH-19003Y / kube-worker | 0 | 4 |
DNS prebid.sdk-cloud.de liefert zwei A-Records, wurde jedoch client-seitig auf eine feste IP reduziert. Damit konzentrierte sich der gesamte Ingress auf eine VM und verschärfte den NIC-Queue-Bottleneck massiv.
Maßnahme 1: NIC Multi-Queue (beide VMs)
Problem
Beide VMs nutzten standardmäßig nur eine NIC-Queue. Bei hohem Durchsatz (50k–100k RPS) führte das zu IRQ-Contention auf einem einzelnen CPU-Kern — alle Netzwerk-Interrupts wurden von einem Kern verarbeitet.
Lösung
Drei Teilschritte auf beiden VMs:
1. Hypervisor (Proxmox): NIC-Queues auf 8 erhöhen
# kube-master (VMID 100); für kube-worker entsprechend anpassen
VMID=100
CUR_NET0="$(qm config $VMID | awk -F': ' '/^net0:/{print $2}')"
if echo "$CUR_NET0" | grep -q 'queues='; then
NEW_NET0="$(echo "$CUR_NET0" | sed -E 's/queues=[0-9]+/queues=8/')"
else
NEW_NET0="${CUR_NET0},queues=8"
fi
qm set "$VMID" --net0 "$NEW_NET0"
qm reboot "$VMID"2. In der VM: Multi-Queue aktivieren und verifizieren
IFACE=ens18
ethtool -L "$IFACE" combined 8 || true
# Verifizierung:
ethtool -l "$IFACE"
# Combined: 83. RPS (Receive Packet Steering) auf alle RX-Queues
Verteilt eingehende Pakete softwarebasiert auf alle CPU-Kerne — entscheidend, da irqbalance allein nicht ausreicht:
IFACE=ens18
NCPU="$(nproc)"
MASK="$(printf '%x' $(( (1<<NCPU)-1 )))"
RXQS="$(ls -d /sys/class/net/$IFACE/queues/rx-* | wc -l)"
FLOW_PER_Q=$((65536 / RXQS))
sysctl -w net.core.rps_sock_flow_entries=65536
for q in /sys/class/net/$IFACE/queues/rx-*; do
echo "$MASK" > "$q/rps_cpus"
[ -w "$q/rps_flow_cnt" ] && echo "$FLOW_PER_Q" > "$q/rps_flow_cnt"
done4. irqbalance aktivieren
apt install -y irqbalance
systemctl enable --now irqbalanceNachweis
Messung per mpstat / pidstat auf kube-master vor der Maßnahme:
CPU13 avg softirq: 97.47 % — praktisch vollständig durch Softirq gesättigtksoftirqd/13 avg CPU: 83–85 % — klarer Haupttreiber- Zusatzlast durch
kworker/13:x-wg-crypt-flannel-wg: ~3–4 % (WireGuard-Krypto des Cross-Node-Traffics)
| Metrik | Vorher | Nachher |
|---|---|---|
| CPU13 avg softirq | ~97 % | 3.57 % |
| ksoftirqd/13 avg CPU | ~85 % | 0.12 % |
| CPU13 avg idle | ~1 % | 83 % |
Auswirkung
- Netzwerk-Interrupts werden über 8 CPU-Kerne verteilt
- RPS verteilt Paketverarbeitung zusätzlich softwarebasiert über alle Kerne
- Reduziert IRQ-Bottleneck bei hohem RPS
- Voraussetzung für skalierbare Durchsatz-Performance
Maßnahme 2: klipper-lb → NodePort (Kubernetes)
Root Cause: k3s klipper-lb Cross-Node Forwarding
Untersuchung
Ausgeschlossen
| Hypothese | Beweis |
|---|---|
| Cross-node Traffic auf ens18 (Port 6969/6970) | tcpdump -i ens18 'port 6969 or 6970' → kein 10.10.10.x ↔ 10.10.10.x Traffic |
| Pod-Verteilung ungleichmäßig | Endpoints: 12 Pods auf master, 12 auf worker — gleichmäßig |
| Ressourcen-Sättigung (CPU/RAM) | Vom Betreiber ausgeschlossen |
| Azure LB / externes LB | Kein Azure — bare-metal k3s |
| TCP Buffer-Größen (512 Bytes) | Wäre auch bei 200k RPS auf 1 Node aufgefallen |
Gefunden
tcpdump -i any -n 'net 10.42.1.0/24' # auf kube-master
tcpdump -i any -n 'net 10.42.0.0/24' # auf kube-worker
Kube-worker’s svclb-Pod (10.42.1.249) leitet Verbindungen zu Master-Pods weiter:
10.42.1.249 → 10.42.0.56:6970 flannel-wg
10.42.1.249 → 10.42.0.50:6970 flannel-wg
10.42.1.249 → 10.42.0.57:6970 flannel-wg
Kube-master’s svclb-Pod (10.42.0.140) leitet Verbindungen zu Worker-Pods weiter:
10.42.0.140 → 10.42.1.33:6970 flannel-wg
10.42.0.140 → 10.42.1.34:6970 flannel-wg
Interface: flannel-wg — WireGuard-verschlüsselter Overlay-Tunnel zwischen den Nodes.
Root Cause
k3s ServiceLB (klipper-lb) ist ein Userspace-Proxy
k3s klipper-lb funktioniert nicht als reines iptables-DNAT. Es ist ein Userspace-TCP-Proxy:
- klipper-lb akzeptiert eingehende Verbindungen auf dem Host-Port
- Es erstellt eine neue TCP-Verbindung zum Backend
- Dabei verbindet es sich über die ClusterIP des internen Service
- kube-proxy wählt für ClusterIP-Traffic ein zufälliges Endpoint aus — auf beiden Nodes
Extern → Node-IP:6970
└─► klipper-lb (Userspace, svclb-Pod)
└─► ClusterIP:6970
└─► kube-proxy DNAT (Cluster-Policy, nicht Local!)
├─► lokaler Pod (10.42.0.x) ~50 %
└─► remote Pod (10.42.1.x) ~50 % ← flannel-wg (WireGuard)
Warum externalTrafficPolicy: Local nicht hilft
externalTrafficPolicy: Local steuert in k3s nur, ob klipper-lb auf einem Node startet (nur wenn lokale Pods vorhanden). Die Backend-Auswahl erfolgt über die ClusterIP, die immer Cluster-Policy verwendet — und damit zu jedem Pod auf beiden Nodes routen kann.
Warum es auf 1 Node funktioniert hat
Mit einem einzigen Node gibt es keine remote Pods. kube-proxy wählt zwangsläufig immer einen lokalen Pod. Kein WireGuard-Hop, keine zusätzliche Latenz.
Auswirkung
- ~50 % aller Verbindungen, die klipper-lb aufbaut, gehen cross-node
- Cross-node Traffic traversiert den flannel-wg WireGuard-Tunnel
- WireGuard-Overhead: Verschlüsselung, Encapsulation, zusätzlicher RTT
- Erklärt den harten Latenz-Cliff ab p90 und den langen Tail bis p999
Ergebnis nach beiden Maßnahmen
| Percentile | Vorher | Nachher |
|---|---|---|
| p50 | ~3 ms | < 1 ms |
| p90 | ~17 ms | ~1.2 ms |
| p95 | ~245 ms | ~1.5 ms |
| p99 | ~531 ms | < 5 ms |
| >30 ms | ~8 % | 0.06 % |
Ergebnisse (Rückmeldung Active Agent)
- Peaks bis 350k RPS
- stabile timeout rate von 1.2%
=> Problem gelöst.
Prävention / Runbook für künftige Vorfälle
Bei Latenz-Spikes (starke p95/p99-Degradation unter Last) in dieser Reihenfolge prüfen:
- Softirq/ksoftirqd-Hotspot prüfen:
mpstat -P ALL 1 60 pidstat -t -p ALL 1 30 | egrep 'ksoftirqd/[0-9]+' - Hypervisor-Queues: Läuft die VM mit
queues=8?qm config <VMID> | grep net0 - Guest-Queues: Hat das Interface mehrere RX-Queues?
ethtool -l ens18 ls /sys/class/net/ens18/queues/ - RPS aktiv?
cat /sys/class/net/ens18/queues/rx-0/rps_cpus # darf nicht '00' sein - irqbalance aktiv?
systemctl status irqbalance - Traffic-Asymmetrie: Läuft Live-Traffic auf beide VMs? (60s tcpdump-Count auf beiden Hosts, siehe HOWTO Cheatsheet)
- Danach erneute Latenz-Messung mit
prebid-bench(p95/p99)
Learnings
- Ein Netzwerkpfad-Hotspot kann wie ein Applikationsproblem aussehen — die Ursache lag im Kernel-Paketpfad, nicht in der PBS-Anwendungslogik.
- Mehr CPU-Kerne allein helfen nicht, wenn eingehende Pakete nicht über RX-Queues verteilt werden.
- Multi-Queue + RPS war der entscheidende Hebel.
irqbalanceallein reicht für diesen Lasttyp nicht aus. - Client-seitige Verbindungsasymmetrie (feste IP statt DNS-Round-Robin) kann einen infrastrukturseitigen Bottleneck erheblich verstärken.
externalTrafficPolicy: Clusterin k3s klipper-lb führt zu ~50 % Cross-Node-Traffic über WireGuard — eine strukturelle Latenz-Falle in 2-Node-Setups.