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:
@@ -45,8 +45,9 @@ vrrp_instance VI_1 {
|
|||||||
virtual_router_id {{ .RouterID }}
|
virtual_router_id {{ .RouterID }}
|
||||||
priority {{ .Priority }}
|
priority {{ .Priority }}
|
||||||
advert_int 2
|
advert_int 2
|
||||||
nopreempt
|
{{ if .PreemptDelay }} preempt_delay {{ .PreemptDelay }}
|
||||||
{{ if .SrcIP }} unicast_src_ip {{ .SrcIP }}
|
{{ else }} nopreempt
|
||||||
|
{{ end }}{{ if .SrcIP }} unicast_src_ip {{ .SrcIP }}
|
||||||
unicast_peer {
|
unicast_peer {
|
||||||
{{ .PeerIP }}
|
{{ .PeerIP }}
|
||||||
}
|
}
|
||||||
@@ -72,8 +73,9 @@ vrrp_instance VI_HB {
|
|||||||
virtual_router_id {{ .HBRouterID }}
|
virtual_router_id {{ .HBRouterID }}
|
||||||
priority {{ .Priority }}
|
priority {{ .Priority }}
|
||||||
advert_int 2
|
advert_int 2
|
||||||
nopreempt
|
{{ if .PreemptDelay }} preempt_delay {{ .PreemptDelay }}
|
||||||
{{ if .HBSrcIP }} unicast_src_ip {{ .HBSrcIP }}
|
{{ else }} nopreempt
|
||||||
|
{{ end }}{{ if .HBSrcIP }} unicast_src_ip {{ .HBSrcIP }}
|
||||||
unicast_peer {
|
unicast_peer {
|
||||||
{{ .HBPeerIP }}
|
{{ .HBPeerIP }}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,12 @@ var cfgTpl string
|
|||||||
|
|
||||||
var tpl = template.Must(template.New("keepalived").Parse(cfgTpl))
|
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.
|
// VIPEntry ist eine einzelne VIP-Adresse die keepalived verwaltet.
|
||||||
type VIPEntry struct {
|
type VIPEntry struct {
|
||||||
Address string // z.B. 89.163.205.100
|
Address string // z.B. 89.163.205.100
|
||||||
@@ -47,6 +53,10 @@ type View struct {
|
|||||||
PeerIP string // Peer-Public-IP (unicast_peer)
|
PeerIP string // Peer-Public-IP (unicast_peer)
|
||||||
AuthPass string
|
AuthPass string
|
||||||
VIPs []VIPEntry // alle is_vip=true Einträge aus ip_addresses
|
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)
|
// Dual-path VRRP (Split-Brain-Schutz, Migration 0033)
|
||||||
HBInterface string
|
HBInterface string
|
||||||
HBSrcIP string
|
HBSrcIP string
|
||||||
@@ -167,18 +177,25 @@ func (g *generator) buildView(cs *models.ClusterSettings, vips []VIPEntry, local
|
|||||||
v.HBRouterID = 52
|
v.HBRouterID = 52
|
||||||
}
|
}
|
||||||
|
|
||||||
// State IMMER BACKUP: das Template setzt `nopreempt`, und nopreempt wirkt
|
// State IMMER BACKUP (kein Node startet als MASTER — sonst würde
|
||||||
// in keepalived NUR, wenn die Instanz im BACKUP-Zustand startet (bei state
|
// nopreempt/preempt_delay ignoriert). Die Priorität entscheidet die
|
||||||
// MASTER wird nopreempt ignoriert). Die Priorität entscheidet weiterhin die
|
// Election: der PG-Primary (200) ist der bevorzugte VIP-Home-Node.
|
||||||
// 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
|
// Preemption-Strategie:
|
||||||
// "kein Auto-Promote"-Philosophie: Promotion bleibt manuell.
|
// - Bevorzugter Node (Prio 200, PG-Primary): `preempt_delay` — holt die
|
||||||
// pg_role=standby ist das härtere Signal (Standby ist nie bevorzugter Node).
|
// 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"
|
v.State = "BACKUP"
|
||||||
if local.PGRole == "standby" {
|
if local.PGRole == "standby" {
|
||||||
v.Priority = 100
|
v.Priority = 100
|
||||||
} else if local.PGRole == "primary" || local.Role == "primary" {
|
} else if local.PGRole == "primary" || local.Role == "primary" {
|
||||||
v.Priority = 200
|
v.Priority = 200
|
||||||
|
v.PreemptDelay = preemptDelaySeconds
|
||||||
} else {
|
} else {
|
||||||
v.Priority = 100
|
v.Priority = 100
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,32 +17,52 @@ func render(t *testing.T, v View) string {
|
|||||||
return buf.String()
|
return buf.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Split-Brain-Schutz: jede vrrp_instance MUSS `nopreempt` tragen, sonst reißt
|
// testView repräsentiert den bevorzugten Node (PG-Primary, Prio 200):
|
||||||
// ein erholter Node die VIP zurück → Flapping. nopreempt wirkt nur bei state
|
// preempt_delay statt nopreempt (holt die VIP nach stabiler Erholung heim).
|
||||||
// BACKUP — also muss auch der bevorzugte Node BACKUP starten.
|
// Kein Node startet MASTER (sonst würde preempt_delay/nopreempt ignoriert).
|
||||||
func testView() View {
|
func testView() View {
|
||||||
return View{
|
return View{
|
||||||
State: "BACKUP", Interface: "eth0", RouterID: 51, Priority: 200,
|
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"}},
|
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,
|
HBInterface: "ens19", HBSrcIP: "169.254.0.1", HBPeerIP: "169.254.0.2", HBRouterID: 52,
|
||||||
GWCheckIP: "89.163.205.1",
|
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())
|
out := render(t, testView())
|
||||||
if n := strings.Count(out, "nopreempt"); n != 2 {
|
if n := strings.Count(out, "preempt_delay"); n != 2 {
|
||||||
t.Fatalf("erwarte nopreempt in VI_1 UND VI_HB (2×), gefunden: %d\n%s", n, out)
|
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") {
|
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 {
|
if c := strings.Count(out, "state BACKUP"); c != 2 {
|
||||||
t.Fatalf("erwarte state BACKUP in beiden Instanzen, gefunden: %d", c)
|
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
|
// GARP muss forciert + periodic aufgefrischt werden, sonst altert die
|
||||||
// VIP-MAC am Upstream-Switch und die Failover-IP wird unerreichbar.
|
// VIP-MAC am Upstream-Switch und die Failover-IP wird unerreichbar.
|
||||||
func TestTemplateGARPRefresh(t *testing.T) {
|
func TestTemplateGARPRefresh(t *testing.T) {
|
||||||
@@ -93,20 +113,24 @@ func TestBuildViewStateAlwaysBackup(t *testing.T) {
|
|||||||
cases := []struct {
|
cases := []struct {
|
||||||
pgRole, role string
|
pgRole, role string
|
||||||
wantPrio int
|
wantPrio int
|
||||||
|
wantPreempt int // preempt_delay (bevorzugt) bzw. 0 (=nopreempt)
|
||||||
}{
|
}{
|
||||||
{"primary", "primary", 200},
|
{"primary", "primary", 200, preemptDelaySeconds},
|
||||||
{"standby", "primary", 100},
|
{"standby", "primary", 100, 0},
|
||||||
{"", "primary", 200},
|
{"", "primary", 200, preemptDelaySeconds},
|
||||||
{"", "", 100},
|
{"", "", 100, 0},
|
||||||
}
|
}
|
||||||
for _, c := range cases {
|
for _, c := range cases {
|
||||||
local := &models.HANode{ID: "n1", PGRole: c.pgRole, Role: c.role, PublicIP: &pub}
|
local := &models.HANode{ID: "n1", PGRole: c.pgRole, Role: c.role, PublicIP: &pub}
|
||||||
v := g.buildView(cs, nil, local, nil)
|
v := g.buildView(cs, nil, local, nil)
|
||||||
if v.State != "BACKUP" {
|
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 {
|
if v.Priority != c.wantPrio {
|
||||||
t.Errorf("pg_role=%q role=%q: Priority=%d, erwarte %d", c.pgRole, c.role, 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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user