feat(keepalived): VIP wandert zum PG-Primary zurück (preempt_delay) — v1.3.9

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 <noreply@anthropic.com>
This commit is contained in:
Debian
2026-07-31 17:56:53 +02:00
parent 4856779db8
commit 18ee243a44
4 changed files with 68 additions and 25 deletions

View File

@@ -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)
}
}
}