From 18ee243a44e6b0ae5c1f943eb09232a6743b18f4 Mon Sep 17 00:00:00 2001 From: Debian Date: Fri, 31 Jul 2026 17:56:53 +0200 Subject: [PATCH] =?UTF-8?q?feat(keepalived):=20VIP=20wandert=20zum=20PG-Pr?= =?UTF-8?q?imary=20zur=C3=BCck=20(preempt=5Fdelay)=20=E2=80=94=20v1.3.9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bisher trugen beide VRRP-Instanzen `nopreempt` → ein erholter Primary holte die VIP NICHT zurück; nach einem Deploy-/VM-Blip blieb sie auf dem Standby kleben (genau die Situation: VIP auf utm-2 obwohl utm-1 der PG-Primary ist). Jetzt: der bevorzugte Node (PG-Primary, Prio 200) rendert `preempt_delay 120` statt nopreempt → er holt die VIP nach 120s STABILER Erholung heim. Der Standby (Prio 100) behält nopreempt (reißt die VIP nie an sich → Split-Brain-Schutz). Der 120s-Delay + gw-check + Heartbeat-Sync-Group verhindern Flap-Back bei kurzen Hicks. State bleibt immer BACKUP. Co-Authored-By: Claude Opus 4.8 --- VERSION | 2 +- internal/keepalived/keepalived.conf.tpl | 10 +++-- internal/keepalived/keepalived.go | 31 +++++++++++---- internal/keepalived/keepalived_test.go | 50 ++++++++++++++++++------- 4 files changed, 68 insertions(+), 25 deletions(-) diff --git a/VERSION b/VERSION index 06c7347..2fe9589 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.3.8 \ No newline at end of file +1.3.9 \ No newline at end of file diff --git a/internal/keepalived/keepalived.conf.tpl b/internal/keepalived/keepalived.conf.tpl index 9e0676f..d23abad 100644 --- a/internal/keepalived/keepalived.conf.tpl +++ b/internal/keepalived/keepalived.conf.tpl @@ -45,8 +45,9 @@ vrrp_instance VI_1 { virtual_router_id {{ .RouterID }} priority {{ .Priority }} advert_int 2 - nopreempt -{{ if .SrcIP }} unicast_src_ip {{ .SrcIP }} +{{ if .PreemptDelay }} preempt_delay {{ .PreemptDelay }} +{{ else }} nopreempt +{{ end }}{{ if .SrcIP }} unicast_src_ip {{ .SrcIP }} unicast_peer { {{ .PeerIP }} } @@ -72,8 +73,9 @@ vrrp_instance VI_HB { virtual_router_id {{ .HBRouterID }} priority {{ .Priority }} advert_int 2 - nopreempt -{{ if .HBSrcIP }} unicast_src_ip {{ .HBSrcIP }} +{{ if .PreemptDelay }} preempt_delay {{ .PreemptDelay }} +{{ else }} nopreempt +{{ end }}{{ if .HBSrcIP }} unicast_src_ip {{ .HBSrcIP }} unicast_peer { {{ .HBPeerIP }} } diff --git a/internal/keepalived/keepalived.go b/internal/keepalived/keepalived.go index 6b49ac8..f4052b4 100644 --- a/internal/keepalived/keepalived.go +++ b/internal/keepalived/keepalived.go @@ -30,6 +30,12 @@ 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 @@ -47,6 +53,10 @@ 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 @@ -167,18 +177,25 @@ func (g *generator) buildView(cs *models.ClusterSettings, vips []VIPEntry, local v.HBRouterID = 52 } - // State IMMER BACKUP: das Template setzt `nopreempt`, und nopreempt wirkt - // in keepalived NUR, wenn die Instanz im BACKUP-Zustand startet (bei state - // MASTER wird nopreempt ignoriert). Die Priorität entscheidet weiterhin die - // Initial-Election (primary=200 gewinnt), aber ein erholter Node reißt die - // VIP NICHT mehr zurück → kein Flap-Back / Split-Brain. Deckt sich mit der - // "kein Auto-Promote"-Philosophie: Promotion bleibt manuell. - // pg_role=standby ist das härtere Signal (Standby ist nie bevorzugter Node). + // 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. + // + // 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). 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 4561528..99c763e 100644 --- a/internal/keepalived/keepalived_test.go +++ b/internal/keepalived/keepalived_test.go @@ -17,32 +17,52 @@ func render(t *testing.T, v View) string { return buf.String() } -// Split-Brain-Schutz: jede vrrp_instance MUSS `nopreempt` tragen, sonst reißt -// ein erholter Node die VIP zurück → Flapping. nopreempt wirkt nur bei state -// BACKUP — also muss auch der bevorzugte Node BACKUP starten. +// 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, - SrcIP: "89.163.205.6", PeerIP: "89.163.205.8", AuthPass: "edgeguard", + PreemptDelay: preemptDelaySeconds, + 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", } } -func TestTemplateNopreemptOnBothInstances(t *testing.T) { +// Bevorzugter Node: preempt_delay auf beiden Instanzen, KEIN nopreempt. +func TestTemplatePreemptDelayOnPreferred(t *testing.T) { out := render(t, testView()) - 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 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 strings.Contains(out, "nopreempt") { + t.Fatalf("bevorzugter Node (Prio 200) darf KEIN nopreempt tragen:\n%s", out) } if strings.Contains(out, "state MASTER") { - t.Fatalf("kein Node darf state MASTER starten (nopreempt würde ignoriert):\n%s", out) + t.Fatalf("kein Node darf state MASTER starten:\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) { @@ -93,20 +113,24 @@ func TestBuildViewStateAlwaysBackup(t *testing.T) { cases := []struct { pgRole, role string wantPrio int + wantPreempt int // preempt_delay (bevorzugt) bzw. 0 (=nopreempt) }{ - {"primary", "primary", 200}, - {"standby", "primary", 100}, - {"", "primary", 200}, - {"", "", 100}, + {"primary", "primary", 200, preemptDelaySeconds}, + {"standby", "primary", 100, 0}, + {"", "primary", 200, preemptDelaySeconds}, + {"", "", 100, 0}, } for _, c := range cases { local := &models.HANode{ID: "n1", PGRole: c.pgRole, Role: c.role, PublicIP: &pub} v := g.buildView(cs, nil, local, nil) if v.State != "BACKUP" { - t.Errorf("pg_role=%q role=%q: State=%q, erwarte immer BACKUP (nopreempt)", c.pgRole, c.role, v.State) + t.Errorf("pg_role=%q role=%q: State=%q, erwarte immer BACKUP", c.pgRole, c.role, v.State) } 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) + } } }