Files
hpnn-proto/hc/main.go
T
ayurishchevandClaude Opus 5 fa389f119b Шаг 5: динамическое изучение MAC/ARP для бэкендов
MAC каждого члена пула больше не статическая константа в topology.env,
а резолвится демоном hcd через обычный ARP ядра — в реальном
окружении MAC бэкенда заранее не известен (сервер ещё не подключён,
NIC может замениться), топология не описывается статически, в отличие
от стенда.

hcif-порты (единственные адреса узла в ядре) уже были настоящими
L3-интерфейсами в тех же сегментах, что и бэкенды — единственное, что
мешало обычному ARP, это permanent-записи ip neigh в entrypoint.sh.
Убрав их и добавив hc/neigh.go (читает ip -json neigh show, точечно
заливает бандл в таблицу 21 на том же тикере, что и health-пробы),
получили резолвер без нового OpenFlow-контроллера.

Таблица 21 стала единственным источником MAC для обоих путей —
маршрутизируемого и коммутируемого (шаг 3): таблица 12 больше не
дублирует MAC инлайново, ct(commit) ведёт сразу в таблицу 21.

Исправлен попутно найденный баг: после apply.sh (replace-flows)
таблица 21 не восстанавливалась, поскольку syncNeighbors сравнивал
MAC с памятью демона, а не с датапасом. Добавлен force-режим по
аналогии с Agent.apply(), плюс регрессионная проверка в verify.sh.

Проверено на живом стенде: MAC всех четырёх членов резолвлен и
совпадает с реальными интерфейсами; смена MAC "железа" обнаружена и
применена без вмешательства за счёт штатного старения ARP ядра.
Регрессия: 94 из 94 проверок.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 12:33:48 +03:00

548 lines
18 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// hcd — подсистема health-check узла балансировщика (§7 дизайн-концепции
// hpnn_v1, адаптированная к одноузловому прототипу).
//
// Демон проверяет живость членов пула, ведёт их состояние с гистерезисом и
// отражает состав пула в датапасе: пересчитывает Maglev-раскладку слотов и
// заливает таблицу слотов OVS одной атомарной транзакцией.
//
// Пробы уходят с уникального адреса узла в сегменте бэкенда, а не с VIP —
// иначе ответ вернулся бы не тому, кто проверял (§7.1).
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"log"
"net"
"net/http"
"os"
"os/exec"
"os/signal"
"sort"
"sync"
"syscall"
"time"
)
// --- конфигурация ------------------------------------------------------------
type Config struct {
Bridge string `json:"bridge"`
OFVersion string `json:"of_version"`
PoolID uint32 `json:"pool_id"`
Slots int `json:"slots"`
SlotTable int `json:"slot_table"`
DNATTable int `json:"dnat_table"`
AdjTable int `json:"adj_table"` // таблица 21 — next-hop MAC членов (шаг 5)
Probe string `json:"probe"`
HTTPPath string `json:"http_path"`
Interval string `json:"interval"`
Timeout string `json:"timeout"`
Rise int `json:"rise"`
Fall int `json:"fall"`
API string `json:"api"`
ApplyScript string `json:"apply_script"`
Members []*Member `json:"members"`
interval time.Duration
timeout time.Duration
}
type Member struct {
ID int `json:"id"`
Name string `json:"name"`
Address string `json:"address"`
Port int `json:"port"`
Weight int `json:"weight"`
Source string `json:"source"`
Iface string `json:"iface"` // hcif-порт, на котором резолвится MAC (шаг 5)
OFPort int `json:"ofport"` // выходной порт члена в OpenFlow
mu sync.Mutex
state string // up | down
admin string // enabled | drain
okStreak int
failStreak int
lastChange time.Time
lastErr string
lastLatency time.Duration
probes uint64
failures uint64
transitions uint64
mac string // резолвлен hcd через neigh-таблицу ядра, пусто = не резолвлен
macSince time.Time
}
func (m *Member) Key() string { return fmt.Sprintf("%s:%d", m.Address, m.Port) }
func (m *Member) WeightOrDefault() int {
if m.Weight <= 0 {
return 1
}
return m.Weight
}
// Active — член участвует в раскладке слотов: жив и не выведен на дренаж.
func (m *Member) Active() bool {
m.mu.Lock()
defer m.mu.Unlock()
return m.state == "up" && m.admin == "enabled"
}
type memberView struct {
Name string `json:"name"`
Address string `json:"address"`
Port int `json:"port"`
State string `json:"state"`
Admin string `json:"admin"`
Active bool `json:"active"`
Since string `json:"since"`
LastError string `json:"last_error,omitempty"`
LatencyMS int64 `json:"latency_ms"`
Probes uint64 `json:"probes"`
Failures uint64 `json:"failures"`
Transitions uint64 `json:"transitions"`
Slots int `json:"slots"`
Source string `json:"probe_source"`
Iface string `json:"iface"`
MAC string `json:"mac"`
MACSince string `json:"mac_since,omitempty"`
}
// --- пробы -------------------------------------------------------------------
// probe возвращает nil, если член ответил. Источник соединения жёстко
// привязан к адресу узла в сегменте этого члена.
func probe(ctx context.Context, cfg *Config, m *Member) error {
dialer := &net.Dialer{
Timeout: cfg.timeout,
LocalAddr: &net.TCPAddr{IP: net.ParseIP(m.Source)},
}
target := fmt.Sprintf("%s:%d", m.Address, m.Port)
if cfg.Probe == "tcp" {
conn, err := dialer.DialContext(ctx, "tcp", target)
if err != nil {
return err
}
return conn.Close()
}
client := &http.Client{
Timeout: cfg.timeout,
Transport: &http.Transport{
DialContext: dialer.DialContext,
DisableKeepAlives: true, // каждая проба — новое соединение
},
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
fmt.Sprintf("http://%s%s", target, cfg.HTTPPath), nil)
if err != nil {
return err
}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode > 299 {
return fmt.Errorf("код ответа %d", resp.StatusCode)
}
return nil
}
// observe применяет результат пробы с гистерезисом и возвращает true, если
// состояние члена изменилось.
func observe(m *Member, cfg *Config, err error, latency time.Duration) bool {
m.mu.Lock()
defer m.mu.Unlock()
m.probes++
m.lastLatency = latency
changed := false
if err == nil {
m.lastErr = ""
m.failStreak = 0
m.okStreak++
if m.state != "up" && m.okStreak >= cfg.Rise {
m.state = "up"
m.lastChange = time.Now()
m.transitions++
changed = true
log.Printf("член %s (%s) -> up после %d успешных проб", m.Name, m.Key(), m.okStreak)
}
} else {
m.failures++
m.lastErr = err.Error()
m.okStreak = 0
m.failStreak++
if m.state != "down" && m.failStreak >= cfg.Fall {
m.state = "down"
m.lastChange = time.Now()
m.transitions++
changed = true
log.Printf("член %s (%s) -> down после %d неудач: %v", m.Name, m.Key(), m.failStreak, err)
}
}
return changed
}
// --- применение раскладки ----------------------------------------------------
type Agent struct {
cfg *Config
mu sync.Mutex
table []int
digest string
applies uint64
errors uint64
}
// apply пересчитывает раскладку и, если она изменилась, заливает таблицу
// слотов атомарным бандлом. force заставляет залить даже неизменившуюся —
// нужно после перезаливки всего пайплайна, которая стирает слоты.
func (a *Agent) apply(force bool) error {
table := BuildSlotTable(a.cfg.PoolID, a.cfg.Members, a.cfg.Slots)
digest := Digest(table)
a.mu.Lock()
unchanged := digest == a.digest && !force
a.mu.Unlock()
if unchanged {
return nil
}
bundle := FlowBundle(table, a.cfg.SlotTable, a.cfg.DNATTable)
path := "/var/run/openvswitch/slots.bundle"
if err := os.WriteFile(path, []byte(bundle), 0o644); err != nil {
return err
}
// ovs-ofctl bundle выполняет delete+add как одну транзакцию: датапас не
// проходит через состояние с полупустой таблицей слотов.
out, err := exec.Command("ovs-ofctl", "-O", a.cfg.OFVersion, "bundle", a.cfg.Bridge, path).CombinedOutput()
a.mu.Lock()
defer a.mu.Unlock()
if err != nil {
a.errors++
return fmt.Errorf("ovs-ofctl bundle: %v: %s", err, out)
}
a.table = table
a.digest = digest
a.applies++
counts := SlotCounts(table)
parts := make([]string, 0, len(counts))
for _, m := range a.cfg.Members {
if n, ok := counts[m.ID]; ok {
parts = append(parts, fmt.Sprintf("%s=%d", m.Name, n))
}
}
sort.Strings(parts)
if len(table) == 0 {
log.Printf("раскладка применена: живых членов нет, fail-close (трафик на VIP отбрасывается)")
} else {
log.Printf("раскладка применена: слоты %v, дайджест %s", parts, digest)
}
return nil
}
func (a *Agent) slotsOf(id int) int {
a.mu.Lock()
defer a.mu.Unlock()
return SlotCounts(a.table)[id]
}
// --- HTTP API ----------------------------------------------------------------
func (a *Agent) memberByName(name string) *Member {
for _, m := range a.cfg.Members {
if m.Name == name {
return m
}
}
return nil
}
func (a *Agent) setAdmin(w http.ResponseWriter, r *http.Request, admin string) {
name := r.URL.Query().Get("member")
m := a.memberByName(name)
if m == nil {
http.Error(w, fmt.Sprintf("член %q не найден\n", name), http.StatusNotFound)
return
}
m.mu.Lock()
prev := m.admin
m.admin = admin
m.mu.Unlock()
if prev != admin {
log.Printf("член %s: admin %s -> %s", m.Name, prev, admin)
}
if err := a.apply(false); err != nil {
log.Printf("ошибка применения: %v", err)
}
fmt.Fprintf(w, "член %s: admin=%s\n", m.Name, admin)
}
func (a *Agent) status() []memberView {
out := make([]memberView, 0, len(a.cfg.Members))
for _, m := range a.cfg.Members {
m.mu.Lock()
v := memberView{
Name: m.Name, Address: m.Address, Port: m.Port,
State: m.state, Admin: m.admin,
Active: m.state == "up" && m.admin == "enabled",
LastError: m.lastErr, LatencyMS: m.lastLatency.Milliseconds(),
Probes: m.probes, Failures: m.failures, Transitions: m.transitions,
Source: m.Source, Iface: m.Iface, MAC: m.mac,
}
if !m.lastChange.IsZero() {
v.Since = m.lastChange.Format(time.RFC3339)
}
if !m.macSince.IsZero() {
v.MACSince = m.macSince.Format(time.RFC3339)
}
m.mu.Unlock()
v.Slots = a.slotsOf(m.ID)
out = append(out, v)
}
return out
}
func (a *Agent) serve() {
mux := http.NewServeMux()
mux.HandleFunc("/status", func(w http.ResponseWriter, r *http.Request) {
a.mu.Lock()
digest := a.digest
a.mu.Unlock()
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"pool_id": a.cfg.PoolID,
"slots": a.cfg.Slots,
"slot_table_digest": digest,
"probe": a.cfg.Probe,
"interval": a.cfg.Interval,
"rise": a.cfg.Rise,
"fall": a.cfg.Fall,
"members": a.status(),
})
})
// Человекочитаемый вид для lbctl health: форматирование здесь избавляет
// образ балансировщика от зависимости на jq или python.
mux.HandleFunc("/status.txt", func(w http.ResponseWriter, r *http.Request) {
a.mu.Lock()
digest := a.digest
a.mu.Unlock()
if digest == "" {
digest = "-"
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
fmt.Fprintf(w, "пул %d: слотов %d, проба %s каждые %s (rise=%d fall=%d)\n",
a.cfg.PoolID, a.cfg.Slots, a.cfg.Probe, a.cfg.Interval, a.cfg.Rise, a.cfg.Fall)
fmt.Fprintf(w, "дайджест раскладки: %s\n\n", digest)
fmt.Fprintf(w, "%-6s %-17s %-10s %-9s %-7s %7s %7s %7s %5s %s\n",
"ЧЛЕН", "АДРЕС", "СОСТОЯНИЕ", "ADMIN", "В ПУЛЕ", "СЛОТОВ", "ПРОБ", "НЕУДАЧ", "МС", "ИСТОЧНИК ПРОБ")
for _, v := range a.status() {
inPool := "нет"
if v.Active {
inPool = "да"
}
fmt.Fprintf(w, "%-6s %-17s %-10s %-9s %-7s %7d %7d %7d %5d %s\n",
v.Name, fmt.Sprintf("%s:%d", v.Address, v.Port), v.State, v.Admin, inPool,
v.Slots, v.Probes, v.Failures, v.LatencyMS, v.Source)
if v.LastError != "" {
fmt.Fprintf(w, " последняя ошибка: %s\n", v.LastError)
}
}
})
// Резолв MAC членов пула (шаг 5): человекочитаемая таблица для lbctl neigh.
mux.HandleFunc("/neigh.txt", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
fmt.Fprintf(w, "%-6s %-17s %-10s %-17s %-8s %s\n",
"ЧЛЕН", "АДРЕС", "ИНТЕРФЕЙС", "MAC", "РЕЗОЛВЛЕН", "С МОМЕНТА")
for _, v := range a.status() {
mac := v.MAC
resolved := "нет"
if mac == "" {
mac = "-"
} else {
resolved = "да"
}
since := v.MACSince
if since == "" {
since = "-"
}
fmt.Fprintf(w, "%-6s %-17s %-10s %-17s %-8s %s\n",
v.Name, v.Address, v.Iface, mac, resolved, since)
}
})
mux.HandleFunc("/metrics", func(w http.ResponseWriter, r *http.Request) {
a.mu.Lock()
applies, errs := a.applies, a.errors
a.mu.Unlock()
w.Header().Set("Content-Type", "text/plain; version=0.0.4")
fmt.Fprintf(w, "# HELP hpnn_member_up Член пула жив по данным проб\n# TYPE hpnn_member_up gauge\n")
for _, v := range a.status() {
up := 0
if v.State == "up" {
up = 1
}
fmt.Fprintf(w, "hpnn_member_up{member=%q,address=%q} %d\n", v.Name, v.Address, up)
}
fmt.Fprintf(w, "# HELP hpnn_member_active Член участвует в раскладке слотов\n# TYPE hpnn_member_active gauge\n")
for _, v := range a.status() {
act := 0
if v.Active {
act = 1
}
fmt.Fprintf(w, "hpnn_member_active{member=%q} %d\n", v.Name, act)
}
fmt.Fprintf(w, "# HELP hpnn_member_slots Слотов у члена пула\n# TYPE hpnn_member_slots gauge\n")
for _, v := range a.status() {
fmt.Fprintf(w, "hpnn_member_slots{member=%q} %d\n", v.Name, v.Slots)
}
fmt.Fprintf(w, "# HELP hpnn_probes_total Всего проб\n# TYPE hpnn_probes_total counter\n")
for _, v := range a.status() {
fmt.Fprintf(w, "hpnn_probes_total{member=%q} %d\n", v.Name, v.Probes)
}
fmt.Fprintf(w, "# HELP hpnn_probe_failures_total Неудачных проб\n# TYPE hpnn_probe_failures_total counter\n")
for _, v := range a.status() {
fmt.Fprintf(w, "hpnn_probe_failures_total{member=%q} %d\n", v.Name, v.Failures)
}
fmt.Fprintf(w, "# HELP hpnn_probe_latency_ms Задержка последней пробы\n# TYPE hpnn_probe_latency_ms gauge\n")
for _, v := range a.status() {
fmt.Fprintf(w, "hpnn_probe_latency_ms{member=%q} %d\n", v.Name, v.LatencyMS)
}
fmt.Fprintf(w, "# HELP hpnn_slot_table_applies_total Заливок таблицы слотов\n# TYPE hpnn_slot_table_applies_total counter\nhpnn_slot_table_applies_total %d\n", applies)
fmt.Fprintf(w, "# HELP hpnn_slot_table_errors_total Ошибок заливки\n# TYPE hpnn_slot_table_errors_total counter\nhpnn_slot_table_errors_total %d\n", errs)
})
mux.HandleFunc("/drain", func(w http.ResponseWriter, r *http.Request) { a.setAdmin(w, r, "drain") })
mux.HandleFunc("/enable", func(w http.ResponseWriter, r *http.Request) { a.setAdmin(w, r, "enabled") })
// Вызывается из apply.sh: перезаливка всего пайплайна (replace-flows)
// стирает и таблицу слотов, и правила adjacency (таблица 21) — обе нужно
// восстановить в актуальном составе. force=true у обеих: MAC/раскладка в
// памяти демона могли не измениться, но датапас сброшен и нуждается в
// повторной заливке уже известных значений.
mux.HandleFunc("/reapply", func(w http.ResponseWriter, r *http.Request) {
if err := a.apply(true); err != nil {
http.Error(w, err.Error()+"\n", http.StatusInternalServerError)
return
}
a.syncNeighbors(true)
a.mu.Lock()
digest := a.digest
a.mu.Unlock()
fmt.Fprintf(w, "таблица слотов перезалита, дайджест %s\n", digest)
})
log.Printf("API на http://%s (/status, /neigh.txt, /metrics, /drain, /enable, /reapply)", a.cfg.API)
if err := http.ListenAndServe(a.cfg.API, mux); err != nil {
log.Fatalf("API: %v", err)
}
}
// --- запуск ------------------------------------------------------------------
func loadConfig(path string) (*Config, error) {
raw, err := os.ReadFile(path)
if err != nil {
return nil, err
}
cfg := &Config{}
if err := json.Unmarshal(raw, cfg); err != nil {
return nil, err
}
if cfg.interval, err = time.ParseDuration(cfg.Interval); err != nil {
return nil, fmt.Errorf("interval: %w", err)
}
if cfg.timeout, err = time.ParseDuration(cfg.Timeout); err != nil {
return nil, fmt.Errorf("timeout: %w", err)
}
if len(cfg.Members) == 0 {
return nil, fmt.Errorf("пустой список членов пула")
}
for _, m := range cfg.Members {
// Стартуем с down: член войдёт в раскладку только после Rise
// успешных проб. Балансировать на непроверенный бэкенд нельзя.
m.state = "down"
m.admin = "enabled"
}
return cfg, nil
}
func main() {
path := flag.String("config", "/var/run/openvswitch/hc.json", "путь к конфигурации")
flag.Parse()
log.SetFlags(log.LstdFlags)
log.SetPrefix("[hcd] ")
cfg, err := loadConfig(*path)
if err != nil {
log.Fatalf("конфигурация: %v", err)
}
log.Printf("пул %d: %d членов, %d слотов, проба %s каждые %s (rise=%d fall=%d)",
cfg.PoolID, len(cfg.Members), cfg.Slots, cfg.Probe, cfg.Interval, cfg.Rise, cfg.Fall)
agent := &Agent{cfg: cfg}
// Стартовое состояние — пустой пул: до первых успешных проб датапас
// работает в fail-close.
if err := agent.apply(true); err != nil {
log.Printf("ошибка стартового применения: %v", err)
}
go agent.serve()
stop := make(chan os.Signal, 1)
signal.Notify(stop, syscall.SIGTERM, syscall.SIGINT)
ticker := time.NewTicker(cfg.interval)
defer ticker.Stop()
for {
select {
case <-stop:
log.Printf("остановка")
return
case <-ticker.C:
var wg sync.WaitGroup
changed := make([]bool, len(cfg.Members))
for i, m := range cfg.Members {
wg.Add(1)
go func(i int, m *Member) {
defer wg.Done()
ctx, cancel := context.WithTimeout(context.Background(), cfg.timeout)
defer cancel()
start := time.Now()
err := probe(ctx, cfg, m)
changed[i] = observe(m, cfg, err, time.Since(start))
}(i, m)
}
wg.Wait()
// Резолв next-hop MAC членов пула — на том же тикере, что и пробы:
// именно проба вызывает первый исходящий пакет с hcif-порта и тем
// самым запускает ARP ядра для ещё не резолвленных адресов.
agent.syncNeighbors(false)
for _, c := range changed {
if c {
if err := agent.apply(false); err != nil {
log.Printf("ошибка применения: %v", err)
}
break
}
}
}
}
}