fix(keepalived): REVERT preempt_delay → nopreempt (Prod-Ausfall 2026-08-03) — v1.3.15

v1.3.9 hatte auf dem PG-Primary (Prio 200) nopreempt durch preempt_delay ersetzt,
damit die VIP zum Primary heimwandert. Das reaktivierte GENAU den Fehlermodus,
den nopreempt verhindert: der Prio-200-Node holt die VIP zurück, sobald
keepalived ihn für gesund hält — aber die Track-Scripts können "gesund" melden,
während der Dienst kaputt ist. Am 2026-08-03 entriss so ein halb-kaputtes utm-1
dem funktionierenden utm-2 die VIP (Log: "Master received advert from .6 with
higher priority 200 → Entering BACKUP") und hielt sie fest → Ausfall, bis utm-1
hart abgeschaltet wurde.

Zurück auf nopreempt (beide Nodes, wie vor v1.3.9). VIP-Affinität zum Primary
erst wieder, wenn der Health-Check "Prozess up aber Dienst kaputt" als FAULT
erkennt. Bis dahin: Stabilität > Affinität.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Debian
2026-08-03 12:24:16 +02:00
parent f0be5be496
commit d395e3ea68
4 changed files with 32 additions and 65 deletions

View File

@@ -1 +1 @@
1.3.14
1.3.15

View File

@@ -45,9 +45,8 @@ vrrp_instance VI_1 {
virtual_router_id {{ .RouterID }}
priority {{ .Priority }}
advert_int 2
{{ if .PreemptDelay }} preempt_delay {{ .PreemptDelay }}
{{ else }} nopreempt
{{ end }}{{ if .SrcIP }} unicast_src_ip {{ .SrcIP }}
nopreempt
{{ if .SrcIP }} unicast_src_ip {{ .SrcIP }}
unicast_peer {
{{ .PeerIP }}
}
@@ -73,9 +72,8 @@ vrrp_instance VI_HB {
virtual_router_id {{ .HBRouterID }}
priority {{ .Priority }}
advert_int 2
{{ if .PreemptDelay }} preempt_delay {{ .PreemptDelay }}
{{ else }} nopreempt
{{ end }}{{ if .HBSrcIP }} unicast_src_ip {{ .HBSrcIP }}
nopreempt
{{ if .HBSrcIP }} unicast_src_ip {{ .HBSrcIP }}
unicast_peer {
{{ .HBPeerIP }}
}

View File

@@ -30,12 +30,6 @@ var cfgTpl string
var tpl = template.Must(template.New("keepalived").Parse(cfgTpl))
// preemptDelaySeconds: wie lange der bevorzugte Node (PG-Primary, Prio 200)
// nach seiner Erholung STABIL sein muss, bevor er die VIP zurückholt. Lang
// genug, dass ein kurzer Deploy-/VM-Hick keinen sofortigen Flap-Back auslöst,
// kurz genug, dass die VIP zeitnah zum Primary heimwandert.
const preemptDelaySeconds = 120
// VIPEntry ist eine einzelne VIP-Adresse die keepalived verwaltet.
type VIPEntry struct {
Address string // z.B. 89.163.205.100
@@ -53,10 +47,6 @@ type View struct {
PeerIP string // Peer-Public-IP (unicast_peer)
AuthPass string
VIPs []VIPEntry // alle is_vip=true Einträge aus ip_addresses
// PreemptDelay > 0: dieser Node holt die VIP nach `preempt_delay`
// Sekunden stabiler Erholung zurück (nur der bevorzugte PG-Primary,
// Prio 200). 0 = nopreempt (Standby reißt die VIP nie an sich).
PreemptDelay int
// Dual-path VRRP (Split-Brain-Schutz, Migration 0033)
HBInterface string
HBSrcIP string
@@ -177,25 +167,25 @@ func (g *generator) buildView(cs *models.ClusterSettings, vips []VIPEntry, local
v.HBRouterID = 52
}
// State IMMER BACKUP (kein Node startet als MASTER — sonst würde
// nopreempt/preempt_delay ignoriert). Die Priorität entscheidet die
// Election: der PG-Primary (200) ist der bevorzugte VIP-Home-Node.
// State IMMER BACKUP + `nopreempt` auf BEIDEN Nodes. Die Priorität
// entscheidet die INITIAL-Election (primary=200 gewinnt), aber ein
// erholter Node reißt die VIP NIEMALS zurück.
//
// Preemption-Strategie:
// - Bevorzugter Node (Prio 200, PG-Primary): `preempt_delay` — holt die
// VIP nach preemptDelaySeconds STABILER Erholung zurück. So „wandert"
// die VIP nach einem Failover/Deploy-Blip von selbst wieder heim zum
// Primary (gewünschtes Verhalten), aber der Delay + gw-check +
// Heartbeat verhindern Flap-Back bei kurzen Hicks.
// - Standby (Prio 100): `nopreempt` — reißt die VIP NIE an sich
// (Split-Brain-Schutz; Promotion zum Primary bleibt manuell).
// pg_role=standby ist das härtere Signal (Standby ist nie bevorzugt).
// WARUM kein Preempt (Incident 2026-08-03): eine frühere `preempt_delay`-
// Variante ließ den Prio-200-Node die VIP zurückholen, sobald keepalived
// ihn für „gesund" hielt. Die Track-Scripts (chk_edgeguard/chk_gateway)
// können aber „gesund" melden, während der eigentliche Dienst noch kaputt
// ist → der halb-kaputte Primary entriss dem funktionierenden Standby die
// VIP und hielt sie fest (Ausfall, bis der Primary hart abgeschaltet
// wurde). nopreempt verhindert genau das. VIP-Affinität zum Primary ist
// erst wieder vertretbar, wenn der Health-Check „Prozess up aber Dienst
// kaputt" zuverlässig als FAULT erkennt. Bis dahin gilt: Stabilität >
// Affinität. Promotion/PG-Failover bleibt manuell (edgeguard-ctl promote).
v.State = "BACKUP"
if local.PGRole == "standby" {
v.Priority = 100
} else if local.PGRole == "primary" || local.Role == "primary" {
v.Priority = 200
v.PreemptDelay = preemptDelaySeconds
} else {
v.Priority = 100
}

View File

@@ -17,52 +17,35 @@ func render(t *testing.T, v View) string {
return buf.String()
}
// testView repräsentiert den bevorzugten Node (PG-Primary, Prio 200):
// preempt_delay statt nopreempt (holt die VIP nach stabiler Erholung heim).
// Kein Node startet MASTER (sonst würde preempt_delay/nopreempt ignoriert).
func testView() View {
return View{
State: "BACKUP", Interface: "eth0", RouterID: 51, Priority: 200,
PreemptDelay: preemptDelaySeconds,
SrcIP: "89.163.205.6", PeerIP: "89.163.205.8", AuthPass: "edgeguard",
SrcIP: "89.163.205.6", PeerIP: "89.163.205.8", AuthPass: "edgeguard",
VIPs: []VIPEntry{{Address: "89.163.205.100", Prefix: 24, Device: "eth0"}},
HBInterface: "ens19", HBSrcIP: "169.254.0.1", HBPeerIP: "169.254.0.2", HBRouterID: 52,
GWCheckIP: "89.163.205.1",
}
}
// Bevorzugter Node: preempt_delay auf beiden Instanzen, KEIN nopreempt.
func TestTemplatePreemptDelayOnPreferred(t *testing.T) {
// Split-Brain-Schutz (Incident 2026-08-03): BEIDE Instanzen MÜSSEN `nopreempt`
// tragen, sonst reißt ein erholter-aber-evtl-kaputter Node die VIP zurück.
// nopreempt wirkt nur bei state BACKUP — also darf kein Node MASTER starten.
func TestTemplateNopreemptOnBothInstances(t *testing.T) {
out := render(t, testView())
if n := strings.Count(out, "preempt_delay"); n != 2 {
t.Fatalf("erwarte preempt_delay in VI_1 UND VI_HB (2×), gefunden: %d\n%s", n, out)
if n := strings.Count(out, "nopreempt"); n != 2 {
t.Fatalf("erwarte nopreempt in VI_1 UND VI_HB (2×), gefunden: %d\n%s", n, out)
}
if strings.Contains(out, "nopreempt") {
t.Fatalf("bevorzugter Node (Prio 200) darf KEIN nopreempt tragen:\n%s", out)
if strings.Contains(out, "preempt_delay") {
t.Fatalf("KEIN preempt_delay erlaubt (Incident 2026-08-03):\n%s", out)
}
if strings.Contains(out, "state MASTER") {
t.Fatalf("kein Node darf state MASTER starten:\n%s", out)
t.Fatalf("kein Node darf state MASTER starten (nopreempt würde ignoriert):\n%s", out)
}
if c := strings.Count(out, "state BACKUP"); c != 2 {
t.Fatalf("erwarte state BACKUP in beiden Instanzen, gefunden: %d", c)
}
}
// Standby (Prio 100, PreemptDelay=0): nopreempt auf beiden Instanzen —
// reißt die VIP nie an sich (Split-Brain-Schutz).
func TestTemplateNopreemptOnStandby(t *testing.T) {
v := testView()
v.Priority = 100
v.PreemptDelay = 0
out := render(t, v)
if n := strings.Count(out, "nopreempt"); n != 2 {
t.Fatalf("Standby: erwarte nopreempt 2×, gefunden: %d\n%s", n, out)
}
if strings.Contains(out, "preempt_delay") {
t.Fatalf("Standby darf KEIN preempt_delay tragen:\n%s", out)
}
}
// GARP muss forciert + periodic aufgefrischt werden, sonst altert die
// VIP-MAC am Upstream-Switch und die Failover-IP wird unerreichbar.
func TestTemplateGARPRefresh(t *testing.T) {
@@ -113,12 +96,11 @@ func TestBuildViewStateAlwaysBackup(t *testing.T) {
cases := []struct {
pgRole, role string
wantPrio int
wantPreempt int // preempt_delay (bevorzugt) bzw. 0 (=nopreempt)
}{
{"primary", "primary", 200, preemptDelaySeconds},
{"standby", "primary", 100, 0},
{"", "primary", 200, preemptDelaySeconds},
{"", "", 100, 0},
{"primary", "primary", 200},
{"standby", "primary", 100},
{"", "primary", 200},
{"", "", 100},
}
for _, c := range cases {
local := &models.HANode{ID: "n1", PGRole: c.pgRole, Role: c.role, PublicIP: &pub}
@@ -129,8 +111,5 @@ func TestBuildViewStateAlwaysBackup(t *testing.T) {
if v.Priority != c.wantPrio {
t.Errorf("pg_role=%q role=%q: Priority=%d, erwarte %d", c.pgRole, c.role, v.Priority, c.wantPrio)
}
if v.PreemptDelay != c.wantPreempt {
t.Errorf("pg_role=%q role=%q: PreemptDelay=%d, erwarte %d", c.pgRole, c.role, v.PreemptDelay, c.wantPreempt)
}
}
}