单二进制三角色 + Dock 适配器 + Swarm stack 达到可部署态;mgr1 实测订阅经 central-proxy bootstrap,探活 alive=41/52。 Co-authored-by: Cursor <cursoragent@cursor.com>
311 lines
8.2 KiB
Go
311 lines
8.2 KiB
Go
// health.go:探活状态机(design §5.2 / proxy-probe §6.2–6.4)。
|
||
//
|
||
// 调度:活跃出口 30s/次、全池 5min/轮(并发 6、单节点 8s 超时,
|
||
// 经 mihomo delay API 逐节点测);连续 2 次失败摘除、1 次成功回候选;
|
||
// EWMA 延迟按区域组维护(design §5.2「按域名分」首版降为按区域组,
|
||
// 回执注明降级)。
|
||
//
|
||
// 状态机:candidate →(连续2败)→ removed →(1胜)→ candidate;
|
||
// active 出口失败立即切热备(由 manager 层处理重选)。
|
||
package proxymanager
|
||
|
||
import (
|
||
"context"
|
||
"io"
|
||
"log"
|
||
"math"
|
||
"sort"
|
||
"sync"
|
||
"time"
|
||
|
||
"onesvm.com/onesvm/browser-server/internal/config"
|
||
)
|
||
|
||
// 状态机参数(design §5.2 锁定值)。
|
||
const (
|
||
activeInterval = 30 * time.Second // 活跃出口探活频率
|
||
fullPoolInterval = 5 * time.Minute // 全池轮询频率
|
||
probeConcurrency = 6 // 全池并发
|
||
failThreshold = 2 // 连续失败摘除阈值
|
||
ewmaAlpha = 0.3 // EWMA 平滑系数(新样本权重)
|
||
ewmaInitSamples = 2 // 初始样本数后 EWMA 收敛
|
||
)
|
||
|
||
// 节点状态。
|
||
const (
|
||
stateCandidate = "candidate" // 候选(可被 P2C 选中)
|
||
stateRemoved = "removed" // 摘除(连续 2 败)
|
||
)
|
||
|
||
// nodeState 单节点探活状态。
|
||
type nodeState struct {
|
||
Name string
|
||
Region string
|
||
Pool string
|
||
State string // candidate | removed
|
||
ConsecFails int // 连续失败计数
|
||
LastDelayMs int // 最近一次延迟(0=从未成功)
|
||
LastProbeAt time.Time
|
||
}
|
||
|
||
// healthStat EWMA 统计(按区域组聚合维度之一)。
|
||
type healthStat struct {
|
||
ewma float64 // 区域组内平均延迟 EWMA
|
||
samples int
|
||
}
|
||
|
||
// HealthEngine 探活状态机(并发安全)。
|
||
type HealthEngine struct {
|
||
mu sync.RWMutex
|
||
nodes map[string]*nodeState // key=node name
|
||
byRegion map[string]*healthStat // EWMA 按区域组(首版降级,回执注明)
|
||
prober Prober
|
||
logger *log.Logger
|
||
}
|
||
|
||
// Prober 探针接口(生产= mihomo delay API;测试= stub)。
|
||
type Prober interface {
|
||
ProbeNode(ctx context.Context, name string) (delayMs int, err error)
|
||
}
|
||
|
||
// NewHealthEngine 构造探活引擎。
|
||
func NewHealthEngine(prober Prober, logger *log.Logger) *HealthEngine {
|
||
if logger == nil {
|
||
logger = log.New(io.Discard, "proxymanager/health ", 0)
|
||
}
|
||
return &HealthEngine{
|
||
nodes: map[string]*nodeState{},
|
||
byRegion: map[string]*healthStat{},
|
||
prober: prober,
|
||
logger: logger,
|
||
}
|
||
}
|
||
|
||
// ReplacePool 全量替换节点集(订阅重载后调用;保留同名节点既有状态机进度)。
|
||
func (h *HealthEngine) ReplacePool(metas []ProxyMeta) {
|
||
h.mu.Lock()
|
||
defer h.mu.Unlock()
|
||
next := make(map[string]*nodeState, len(metas))
|
||
for _, m := range metas {
|
||
if m.Pool != poolVless {
|
||
continue // 探活只覆盖可调度池
|
||
}
|
||
if old, ok := h.nodes[m.Name]; ok {
|
||
old.Region, old.Pool = m.Region, m.Pool
|
||
next[m.Name] = old
|
||
continue
|
||
}
|
||
next[m.Name] = &nodeState{Name: m.Name, Region: m.Region, Pool: m.Pool, State: stateCandidate}
|
||
}
|
||
h.nodes = next
|
||
// 区域统计重建(保留仍存在区域的历史 EWMA)。
|
||
nextRegion := map[string]*healthStat{}
|
||
for r := range h.byRegion {
|
||
for _, n := range next {
|
||
if n.Region == r {
|
||
nextRegion[r] = h.byRegion[r]
|
||
break
|
||
}
|
||
}
|
||
}
|
||
h.byRegion = nextRegion
|
||
}
|
||
|
||
// probeOne 探测单节点并推进状态机。返回本次是否成功。
|
||
func (h *HealthEngine) probeOne(ctx context.Context, n *nodeState) bool {
|
||
if h.prober == nil || n == nil {
|
||
return false
|
||
}
|
||
delay, err := h.prober.ProbeNode(ctx, n.Name)
|
||
h.mu.Lock()
|
||
defer h.mu.Unlock()
|
||
n.LastProbeAt = config.Now()
|
||
if err != nil {
|
||
n.ConsecFails++
|
||
if n.ConsecFails >= failThreshold && n.State == stateCandidate {
|
||
n.State = stateRemoved
|
||
h.logger.Printf("[health] 节点摘除 name=%s region=%s consec_fails=%d", n.Name, n.Region, n.ConsecFails)
|
||
}
|
||
return false
|
||
}
|
||
wasRemoved := n.State == stateRemoved
|
||
n.ConsecFails = 0
|
||
n.LastDelayMs = delay
|
||
n.State = stateCandidate // 1 次成功即回候选
|
||
if wasRemoved {
|
||
h.logger.Printf("[health] 节点恢复 name=%s region=%s delay_ms=%d", n.Name, n.Region, delay)
|
||
}
|
||
// EWMA 按区域组更新。
|
||
st := h.byRegion[n.Region]
|
||
if st == nil {
|
||
st = &healthStat{}
|
||
h.byRegion[n.Region] = st
|
||
}
|
||
if st.samples == 0 {
|
||
st.ewma = float64(delay)
|
||
} else {
|
||
st.ewma = ewmaAlpha*float64(delay) + (1-ewmaAlpha)*st.ewma
|
||
}
|
||
st.samples++
|
||
return true
|
||
}
|
||
|
||
// ProbeActive 探活活跃出口集合(30s 周期调用;单并发,延迟敏感)。
|
||
// 返回 (全部成功?, 失败节点名列表)。
|
||
func (h *HealthEngine) ProbeActive(ctx context.Context, activeNames []string) (bool, []string) {
|
||
var failed []string
|
||
for _, name := range activeNames {
|
||
h.mu.RLock()
|
||
n := h.nodes[name]
|
||
h.mu.RUnlock()
|
||
if n == nil {
|
||
continue
|
||
}
|
||
if !h.probeOne(ctx, n) {
|
||
failed = append(failed, name)
|
||
}
|
||
}
|
||
return len(failed) == 0, failed
|
||
}
|
||
|
||
// ProbeFullPool 全池轮询(5min 周期,并发 6)。返回 (候选数, 摘除数)。
|
||
func (h *HealthEngine) ProbeFullPool(ctx context.Context) (int, int) {
|
||
h.mu.RLock()
|
||
names := make([]*nodeState, 0, len(h.nodes))
|
||
for _, n := range h.nodes {
|
||
names = append(names, n)
|
||
}
|
||
h.mu.RUnlock()
|
||
var (
|
||
wg sync.WaitGroup
|
||
sem = make(chan struct{}, probeConcurrency)
|
||
mu sync.Mutex
|
||
alive int
|
||
removed int
|
||
)
|
||
for _, n := range names {
|
||
wg.Add(1)
|
||
go func(n *nodeState) {
|
||
defer wg.Done()
|
||
sem <- struct{}{}
|
||
defer func() { <-sem }()
|
||
if h.probeOne(ctx, n) {
|
||
mu.Lock()
|
||
alive++
|
||
mu.Unlock()
|
||
} else {
|
||
mu.Lock()
|
||
if h.snapshotState(n.Name) == stateRemoved {
|
||
removed++
|
||
}
|
||
mu.Unlock()
|
||
}
|
||
}(n)
|
||
}
|
||
wg.Wait()
|
||
return alive, removed
|
||
}
|
||
|
||
// snapshotState 读取单节点状态(ProbeFullPool 内部用)。
|
||
func (h *HealthEngine) snapshotState(name string) string {
|
||
h.mu.RLock()
|
||
defer h.mu.RUnlock()
|
||
if n := h.nodes[name]; n != nil {
|
||
return n.State
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// Candidates 返回候选节点名(candidate 状态,按区域+延迟排序稳定输出)。
|
||
func (h *HealthEngine) Candidates() []string {
|
||
h.mu.RLock()
|
||
defer h.mu.RUnlock()
|
||
var out []string
|
||
for _, n := range h.nodes {
|
||
if n.State == stateCandidate {
|
||
out = append(out, n.Name)
|
||
}
|
||
}
|
||
sort.Strings(out)
|
||
return out
|
||
}
|
||
|
||
// RegionEWMA 返回区域组 EWMA(毫秒;无样本区域不在 map 中)。
|
||
func (h *HealthEngine) RegionEWMA() map[string]float64 {
|
||
h.mu.RLock()
|
||
defer h.mu.RUnlock()
|
||
out := make(map[string]float64, len(h.byRegion))
|
||
for r, st := range h.byRegion {
|
||
out[r] = math.Round(st.ewma*100) / 100
|
||
}
|
||
return out
|
||
}
|
||
|
||
// NodeDelay 单节点最近延迟(无记录给 -1)。
|
||
func (h *HealthEngine) NodeDelay(name string) int {
|
||
h.mu.RLock()
|
||
defer h.mu.RUnlock()
|
||
if n := h.nodes[name]; n != nil {
|
||
return n.LastDelayMs
|
||
}
|
||
return -1
|
||
}
|
||
|
||
// AliveCount 池内存活(candidate)节点数。
|
||
func (h *HealthEngine) AliveCount() int {
|
||
h.mu.RLock()
|
||
defer h.mu.RUnlock()
|
||
c := 0
|
||
for _, n := range h.nodes {
|
||
if n.State == stateCandidate {
|
||
c++
|
||
}
|
||
}
|
||
return c
|
||
}
|
||
|
||
// TotalCount 池内节点总数(可调度池)。
|
||
func (h *HealthEngine) TotalCount() int {
|
||
h.mu.RLock()
|
||
defer h.mu.RUnlock()
|
||
return len(h.nodes)
|
||
}
|
||
|
||
// RegionOf 节点区域查询。
|
||
func (h *HealthEngine) RegionOf(name string) string {
|
||
h.mu.RLock()
|
||
defer h.mu.RUnlock()
|
||
if n := h.nodes[name]; n != nil {
|
||
return n.Region
|
||
}
|
||
return unknownRegion
|
||
}
|
||
|
||
// PoolAlive 池是否存活(至少 1 候选)。
|
||
func (h *HealthEngine) PoolAlive() bool { return h.AliveCount() > 0 }
|
||
|
||
// Snapshots 节点状态快照(/api/proxies 输出,脱敏)。
|
||
type NodeSnapshot struct {
|
||
Name string `json:"name"`
|
||
Type string `json:"type"`
|
||
Region string `json:"region"`
|
||
Alive bool `json:"alive"`
|
||
DelayMs int `json:"delay_ms"`
|
||
ConsecFails int `json:"consec_fails"`
|
||
State string `json:"state"`
|
||
}
|
||
|
||
// Snapshot 全量快照(按名称排序)。
|
||
func (h *HealthEngine) Snapshot() []NodeSnapshot {
|
||
h.mu.RLock()
|
||
defer h.mu.RUnlock()
|
||
out := make([]NodeSnapshot, 0, len(h.nodes))
|
||
for _, n := range h.nodes {
|
||
out = append(out, NodeSnapshot{
|
||
Name: n.Name, Region: n.Region, Alive: n.State == stateCandidate,
|
||
DelayMs: n.LastDelayMs, ConsecFails: n.ConsecFails, State: n.State,
|
||
})
|
||
}
|
||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||
return out
|
||
}
|