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>
230 lines
7.1 KiB
Go
230 lines
7.1 KiB
Go
// Package keepalived rendert /etc/keepalived/keepalived.conf aus
|
|
// cluster_settings (VIP/VRRP-Config) und ha_nodes (local vs. peer).
|
|
//
|
|
// Split-Brain-Strategie: kein Auto-Promote. notify_master loggt nur
|
|
// und sendet einen internen Alert. Promotion ist immer manuell via
|
|
// "edgeguard-ctl promote" — das ist die einzig sichere Option ohne
|
|
// externes Quorum in einem 2-Node-Cluster.
|
|
package keepalived
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
_ "embed"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
"text/template"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"git.netcell-it.de/projekte/edgeguard-native/internal/configgen"
|
|
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
|
|
)
|
|
|
|
const ConfPath = "/etc/keepalived/keepalived.conf"
|
|
|
|
//go:embed keepalived.conf.tpl
|
|
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
|
|
Prefix int // z.B. 24
|
|
Device string // z.B. eth0
|
|
}
|
|
|
|
// View ist der Template-Kontext.
|
|
type View struct {
|
|
State string // MASTER | BACKUP
|
|
Interface string // Interface für VRRP-Advertisements (VI_1)
|
|
RouterID int
|
|
Priority int // MASTER=200, BACKUP=100
|
|
SrcIP string // eigene Public-IP (unicast_src_ip)
|
|
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
|
|
HBPeerIP string
|
|
HBRouterID int
|
|
// GW-Tracking
|
|
GWCheckIP string
|
|
}
|
|
|
|
type generator struct {
|
|
pool *pgxpool.Pool
|
|
localID string
|
|
}
|
|
|
|
func New(pool *pgxpool.Pool, localID string) configgen.Generator {
|
|
return &generator{pool: pool, localID: localID}
|
|
}
|
|
|
|
func (g *generator) Name() string { return "keepalived" }
|
|
|
|
func (g *generator) Render(ctx context.Context) error {
|
|
cs, vips, local, peer, err := g.loadData(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("keepalived: load: %w", err)
|
|
}
|
|
if len(vips) == 0 {
|
|
// Keine VIPs konfiguriert → keepalived.conf nicht schreiben.
|
|
return nil
|
|
}
|
|
v := g.buildView(cs, vips, local, peer)
|
|
var buf bytes.Buffer
|
|
if err := tpl.Execute(&buf, v); err != nil {
|
|
return fmt.Errorf("keepalived: template: %w", err)
|
|
}
|
|
if err := configgen.AtomicWrite(ConfPath, buf.Bytes(), 0o640); err != nil {
|
|
return fmt.Errorf("keepalived: write: %w", err)
|
|
}
|
|
if err := reloadKeepalived(); err != nil {
|
|
return fmt.Errorf("keepalived: reload: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (g *generator) loadData(ctx context.Context) (*models.ClusterSettings, []VIPEntry, *models.HANode, *models.HANode, error) {
|
|
var cs models.ClusterSettings
|
|
row := g.pool.QueryRow(ctx, `
|
|
SELECT id, vip_address, vip_interface, vip_auth_pass, vrrp_router_id,
|
|
hb_interface, hb_src_ip, hb_peer_ip, hb_router_id, gw_check_ip
|
|
FROM cluster_settings WHERE id = 1`)
|
|
if err := row.Scan(&cs.ID, &cs.VIPAddress, &cs.VIPInterface, &cs.VIPAuthPass, &cs.VRRPRouterID,
|
|
&cs.HBInterface, &cs.HBSrcIP, &cs.HBPeerIP, &cs.HBRouterID, &cs.GWCheckIP); err != nil {
|
|
return nil, nil, nil, nil, fmt.Errorf("cluster_settings: %w", err)
|
|
}
|
|
|
|
// Alle VIPs aus ip_addresses (is_vip=true, active=true) inkl. Interface-Name.
|
|
vipRows, err := g.pool.Query(ctx, `
|
|
SELECT ia.address, ia.prefix, ni.name
|
|
FROM ip_addresses ia
|
|
JOIN network_interfaces ni ON ia.interface_id = ni.id
|
|
WHERE ia.is_vip = true AND ia.active = true
|
|
ORDER BY ni.name, ia.address`)
|
|
if err != nil {
|
|
return nil, nil, nil, nil, fmt.Errorf("ip_addresses: %w", err)
|
|
}
|
|
defer vipRows.Close()
|
|
var vips []VIPEntry
|
|
for vipRows.Next() {
|
|
var v VIPEntry
|
|
if err := vipRows.Scan(&v.Address, &v.Prefix, &v.Device); err != nil {
|
|
continue
|
|
}
|
|
vips = append(vips, v)
|
|
}
|
|
|
|
nodeRows, err := g.pool.Query(ctx, `SELECT id, fqdn, role, pg_role, public_ip, status FROM ha_nodes ORDER BY joined_at`)
|
|
if err != nil {
|
|
return nil, nil, nil, nil, fmt.Errorf("ha_nodes: %w", err)
|
|
}
|
|
defer nodeRows.Close()
|
|
|
|
var local, peer *models.HANode
|
|
for nodeRows.Next() {
|
|
n := &models.HANode{}
|
|
if err := nodeRows.Scan(&n.ID, &n.FQDN, &n.Role, &n.PGRole, &n.PublicIP, &n.Status); err != nil {
|
|
continue
|
|
}
|
|
if n.ID == g.localID {
|
|
local = n
|
|
} else {
|
|
peer = n
|
|
}
|
|
}
|
|
if local == nil {
|
|
return nil, nil, nil, nil, fmt.Errorf("local node %s not in ha_nodes", g.localID)
|
|
}
|
|
return &cs, vips, local, peer, nil
|
|
}
|
|
|
|
func (g *generator) buildView(cs *models.ClusterSettings, vips []VIPEntry, local, peer *models.HANode) View {
|
|
v := View{
|
|
RouterID: cs.VRRPRouterID,
|
|
VIPs: vips,
|
|
Interface: deref(cs.VIPInterface),
|
|
AuthPass: deref(cs.VIPAuthPass),
|
|
HBInterface: deref(cs.HBInterface),
|
|
HBSrcIP: deref(cs.HBSrcIP),
|
|
HBPeerIP: deref(cs.HBPeerIP),
|
|
HBRouterID: cs.HBRouterID,
|
|
GWCheckIP: deref(cs.GWCheckIP),
|
|
}
|
|
if v.Interface == "" {
|
|
v.Interface = "eth0"
|
|
}
|
|
if v.AuthPass == "" {
|
|
v.AuthPass = "edgeguard"
|
|
}
|
|
if v.HBRouterID == 0 {
|
|
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.
|
|
//
|
|
// 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
|
|
}
|
|
|
|
if local.PublicIP != nil {
|
|
v.SrcIP = *local.PublicIP
|
|
}
|
|
if peer != nil && peer.PublicIP != nil {
|
|
v.PeerIP = *peer.PublicIP
|
|
}
|
|
return v
|
|
}
|
|
|
|
func reloadKeepalived() error {
|
|
if _, err := os.Stat("/run/keepalived.pid"); os.IsNotExist(err) {
|
|
// keepalived läuft noch nicht — erster Render beim Start.
|
|
return nil
|
|
}
|
|
cmd := exec.Command("sudo", "-n", "/usr/bin/systemctl", "reload-or-restart", "keepalived.service")
|
|
if out, err := cmd.CombinedOutput(); err != nil {
|
|
return fmt.Errorf("sudo systemctl reload-or-restart keepalived.service: %w (output: %s)", err, strings.TrimSpace(string(out)))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func deref(s *string) string {
|
|
if s == nil {
|
|
return ""
|
|
}
|
|
return *s
|
|
}
|