Files
hpnn-proto/hc/main.go
T

506 lines
16 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"`
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"`
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
}
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"`
}
// --- пробы -------------------------------------------------------------------
// 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,
}
if !m.lastChange.IsZero() {
v.Since = m.lastChange.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)
}
}
})
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: перезаливка всего пайплайна стирает таблицу
// слотов, поэтому её нужно восстановить в актуальном составе.
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.mu.Lock()
digest := a.digest
a.mu.Unlock()
fmt.Fprintf(w, "таблица слотов перезалита, дайджест %s\n", digest)
})
log.Printf("API на http://%s (/status, /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()
for _, c := range changed {
if c {
if err := agent.apply(false); err != nil {
log.Printf("ошибка применения: %v", err)
}
break
}
}
}
}
}