feat(ha): VIP-Preempt zurück zum PG-Primary — mit gehärtetem Health-Gate — v1.3.16
Preempt-Rückkehr (preempt_delay 120) wieder aktiv: der bevorzugte Node (PG-Primary, Prio 200) holt die VIP nach Erholung zurück. Der Incident 2026-08-03 (halb-kaputter Node riss die VIP an sich) wird verhindert, weil keepalived-check.sh jetzt zusätzlich fordert: - haproxy-Prozess aktiv - :443 gebunden (bedient wirklich Traffic) Ein nicht-bedienender Node geht damit in FAULT und kann NICHT (mehr) preempten. Außerdem: CrowdSec-Management-Whitelist (Backend api_backend) fest ins postinst gebacken (Admin-SPA-Traffic wird nie mehr als http-crawl gebannt, IP-unabhängig, Incident-Root-Fix) + Altlast netcell-mgmt-whitelist.yaml wird aufgeräumt. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -45,7 +45,7 @@ vrrp_instance VI_1 {
|
||||
virtual_router_id {{ .RouterID }}
|
||||
priority {{ .Priority }}
|
||||
advert_int 2
|
||||
nopreempt
|
||||
{{ if .PreemptDelay }}preempt_delay {{ .PreemptDelay }}{{ else }}nopreempt{{ end }}
|
||||
{{ if .SrcIP }} unicast_src_ip {{ .SrcIP }}
|
||||
unicast_peer {
|
||||
{{ .PeerIP }}
|
||||
@@ -72,7 +72,7 @@ vrrp_instance VI_HB {
|
||||
virtual_router_id {{ .HBRouterID }}
|
||||
priority {{ .Priority }}
|
||||
advert_int 2
|
||||
nopreempt
|
||||
{{ if .PreemptDelay }}preempt_delay {{ .PreemptDelay }}{{ else }}nopreempt{{ end }}
|
||||
{{ if .HBSrcIP }} unicast_src_ip {{ .HBSrcIP }}
|
||||
unicast_peer {
|
||||
{{ .HBPeerIP }}
|
||||
|
||||
@@ -54,8 +54,19 @@ type View struct {
|
||||
HBRouterID int
|
||||
// GW-Tracking
|
||||
GWCheckIP string
|
||||
// PreemptDelay > 0 → Node holt die VIP nach Erholung zurück (nach N Sekunden
|
||||
// Stabilität). 0 → nopreempt (bleibt Backup). Siehe buildView.
|
||||
PreemptDelay int
|
||||
}
|
||||
|
||||
// preemptDelaySeconds: der bevorzugte Node (höhere Priorität = PG-Primary) holt
|
||||
// die VIP erst nach dieser Wartezeit zurück — lange genug, dass ein frisch
|
||||
// gebooteter/deployter Node erst wirklich bereit ist (Boot + Service-Start),
|
||||
// bevor er überhaupt preempten darf. Zusammen mit dem gehärteten Health-Check
|
||||
// (haproxy aktiv + :443 gebunden, keepalived-check.sh) verhindert das den
|
||||
// Incident 2026-08-03 (halb-kaputter Node riss die VIP an sich).
|
||||
const preemptDelaySeconds = 120
|
||||
|
||||
type generator struct {
|
||||
pool *pgxpool.Pool
|
||||
localID string
|
||||
@@ -167,21 +178,21 @@ func (g *generator) buildView(cs *models.ClusterSettings, vips []VIPEntry, local
|
||||
v.HBRouterID = 52
|
||||
}
|
||||
|
||||
// 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.
|
||||
// State IMMER BACKUP; die Priorität entscheidet, welcher Node die VIP
|
||||
// bevorzugt hält (PG-Primary=200 > Standby=100). Mit preempt_delay holt
|
||||
// der bevorzugte Node die VIP nach Erholung zurück (VIP-Affinität zum
|
||||
// PG-Primary), aber erst nach preemptDelaySeconds Stabilität.
|
||||
//
|
||||
// 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).
|
||||
// Incident 2026-08-03 & Fix: eine frühere preempt_delay-Variante ließ den
|
||||
// Prio-200-Node die VIP zurückholen, sobald der Health-Check ihn für
|
||||
// „gesund" hielt — der prüfte aber NUR die edgeguard-api, nicht ob der Node
|
||||
// wirklich Traffic bedient. Ein halb-kaputter Primary (api up, haproxy/Netz
|
||||
// down) riss so die VIP an sich → Ausfall. Preempt ist wieder aktiv, WEIL
|
||||
// keepalived-check.sh jetzt zusätzlich haproxy-aktiv + :443-gebunden fordert:
|
||||
// ein nicht-bedienender Node geht in FAULT und kann NICHT preempten.
|
||||
// Promotion/PG-Failover bleibt manuell (edgeguard-ctl promote).
|
||||
v.State = "BACKUP"
|
||||
v.PreemptDelay = preemptDelaySeconds
|
||||
if local.PGRole == "standby" {
|
||||
v.Priority = 100
|
||||
} else if local.PGRole == "primary" || local.Role == "primary" {
|
||||
|
||||
@@ -23,29 +23,46 @@ func testView() View {
|
||||
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",
|
||||
GWCheckIP: "89.163.205.1", PreemptDelay: 120,
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// VIP-Affinität zum PG-Primary (Incident-2026-08-03-Fix): mit PreemptDelay
|
||||
// tragen BEIDE Instanzen `preempt_delay N` statt nopreempt, damit der
|
||||
// bevorzugte Node die VIP nach Erholung zurückholt — aber erst nach N Sekunden
|
||||
// Stabilität. Kein Node darf `state MASTER` starten (sonst kein sauberes
|
||||
// Election). Preempt ist nur sicher, WEIL keepalived-check.sh haproxy-Bereit-
|
||||
// schaft (aktiv + :443) mitprüft (siehe dortiger Kommentar).
|
||||
func TestTemplatePreemptDelayOnBothInstances(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 c := strings.Count(out, "preempt_delay 120"); c != 2 {
|
||||
t.Fatalf("erwarte preempt_delay 120 in VI_1 UND VI_HB (2×), gefunden: %d\n%s", c, out)
|
||||
}
|
||||
if strings.Contains(out, "preempt_delay") {
|
||||
t.Fatalf("KEIN preempt_delay erlaubt (Incident 2026-08-03):\n%s", out)
|
||||
if strings.Contains(out, "nopreempt") {
|
||||
t.Fatalf("bei PreemptDelay>0 darf KEIN nopreempt gerendert werden:\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)
|
||||
}
|
||||
}
|
||||
|
||||
// Ohne PreemptDelay (==0) fällt das Template auf nopreempt zurück (Node bleibt
|
||||
// Backup, keine VIP-Rückkehr) — der sichere Default, falls Preempt je aus soll.
|
||||
func TestTemplateFallsBackToNopreempt(t *testing.T) {
|
||||
v := testView()
|
||||
v.PreemptDelay = 0
|
||||
out := render(t, v)
|
||||
if c := strings.Count(out, "nopreempt"); c != 2 {
|
||||
t.Fatalf("erwarte nopreempt in beiden Instanzen (2×) bei PreemptDelay=0, gefunden: %d\n%s", c, out)
|
||||
}
|
||||
if strings.Contains(out, "preempt_delay") {
|
||||
t.Fatalf("bei PreemptDelay=0 darf KEIN preempt_delay gerendert werden:\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) {
|
||||
@@ -111,5 +128,8 @@ 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 != 120 {
|
||||
t.Errorf("pg_role=%q role=%q: PreemptDelay=%d, erwarte 120", c.pgRole, c.role, v.PreemptDelay)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user