diff --git a/VERSION b/VERSION index 22122db..92ee6ac 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.3.14 \ No newline at end of file +1.3.15 \ No newline at end of file diff --git a/internal/keepalived/keepalived.conf.tpl b/internal/keepalived/keepalived.conf.tpl index d23abad..9e0676f 100644 --- a/internal/keepalived/keepalived.conf.tpl +++ b/internal/keepalived/keepalived.conf.tpl @@ -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 }} } diff --git a/internal/keepalived/keepalived.go b/internal/keepalived/keepalived.go index f4052b4..efe6b76 100644 --- a/internal/keepalived/keepalived.go +++ b/internal/keepalived/keepalived.go @@ -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 } diff --git a/internal/keepalived/keepalived_test.go b/internal/keepalived/keepalived_test.go index 99c763e..77811d7 100644 --- a/internal/keepalived/keepalived_test.go +++ b/internal/keepalived/keepalived_test.go @@ -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) - } } }