实现 Phase 3 核心功能: - loadbalance: 轮询、加权轮询、最少连接、IP哈希四种算法 - proxy: HTTP 反向代理、健康检查、故障转移 - 所有实现均为并发安全,使用 atomic 操作 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
244 lines
6.5 KiB
Go
244 lines
6.5 KiB
Go
// Package proxy provides reverse proxy functionality for the Lolly HTTP server.
|
|
//
|
|
// This file implements health checking for backend targets, supporting both
|
|
// active health checks (periodic HTTP probes) and passive health checks
|
|
// (marking targets unhealthy based on observed failures).
|
|
//
|
|
//go:generate go test -v ./...
|
|
package proxy
|
|
|
|
import (
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/valyala/fasthttp"
|
|
|
|
"rua.plus/lolly/internal/config"
|
|
"rua.plus/lolly/internal/loadbalance"
|
|
)
|
|
|
|
// HealthChecker performs health checks on backend targets.
|
|
// It supports both active (periodic HTTP probes) and passive (failure-based)
|
|
// health checking modes.
|
|
//
|
|
// The checker runs in a background goroutine when started, periodically
|
|
// sending HTTP GET requests to each target's health check endpoint.
|
|
// Targets responding with 2xx status codes are marked as healthy;
|
|
// timeouts, connection failures, or non-2xx responses mark them as unhealthy.
|
|
//
|
|
// Example usage:
|
|
//
|
|
// targets := []*loadbalance.Target{
|
|
// {URL: "http://backend1:8080", Healthy: true},
|
|
// {URL: "http://backend2:8080", Healthy: true},
|
|
// }
|
|
//
|
|
// cfg := &config.HealthCheckConfig{
|
|
// Interval: 10 * time.Second,
|
|
// Path: "/health",
|
|
// Timeout: 5 * time.Second,
|
|
// }
|
|
//
|
|
// checker := New(targets, cfg)
|
|
// checker.Start()
|
|
// defer checker.Stop()
|
|
type HealthChecker struct {
|
|
targets []*loadbalance.Target
|
|
interval time.Duration
|
|
timeout time.Duration
|
|
path string
|
|
stopCh chan struct{}
|
|
running atomic.Bool
|
|
client *fasthttp.Client
|
|
mu sync.RWMutex
|
|
}
|
|
|
|
// NewHealthChecker creates a new HealthChecker with the specified targets and configuration.
|
|
// The configuration defines the check interval, timeout, and health check path.
|
|
//
|
|
// Default values are applied if not specified in the config:
|
|
// - Interval: 10 seconds
|
|
// - Timeout: 5 seconds
|
|
// - Path: "/health"
|
|
//
|
|
// The returned HealthChecker is not started; call Start() to begin health checks.
|
|
func NewHealthChecker(targets []*loadbalance.Target, cfg *config.HealthCheckConfig) *HealthChecker {
|
|
interval := cfg.Interval
|
|
if interval <= 0 {
|
|
interval = 10 * time.Second
|
|
}
|
|
|
|
timeout := cfg.Timeout
|
|
if timeout <= 0 {
|
|
timeout = 5 * time.Second
|
|
}
|
|
|
|
path := cfg.Path
|
|
if path == "" {
|
|
path = "/health"
|
|
}
|
|
|
|
return &HealthChecker{
|
|
targets: targets,
|
|
interval: interval,
|
|
timeout: timeout,
|
|
path: path,
|
|
stopCh: make(chan struct{}),
|
|
client: &fasthttp.Client{
|
|
ReadTimeout: timeout,
|
|
WriteTimeout: timeout,
|
|
},
|
|
}
|
|
}
|
|
|
|
// Start begins the background health check process.
|
|
// It launches a goroutine that periodically checks all targets at the configured interval.
|
|
// Start is idempotent; calling it on an already running checker has no effect.
|
|
//
|
|
// The health check process continues until Stop() is called.
|
|
func (h *HealthChecker) Start() {
|
|
if h.running.Load() {
|
|
return
|
|
}
|
|
|
|
h.running.Store(true)
|
|
go h.run()
|
|
}
|
|
|
|
// Stop halts the background health check process.
|
|
// It signals the background goroutine to stop and waits for it to complete.
|
|
// Stop is idempotent; calling it on a stopped checker has no effect.
|
|
func (h *HealthChecker) Stop() {
|
|
if !h.running.Load() {
|
|
return
|
|
}
|
|
|
|
h.running.Store(false)
|
|
close(h.stopCh)
|
|
}
|
|
|
|
// run is the main health check loop running in a background goroutine.
|
|
// It performs an initial check on all targets, then enters a loop that
|
|
// checks targets at regular intervals until stopped.
|
|
func (h *HealthChecker) run() {
|
|
// Perform initial health check
|
|
h.checkAll()
|
|
|
|
ticker := time.NewTicker(h.interval)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ticker.C:
|
|
h.checkAll()
|
|
case <-h.stopCh:
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// checkAll performs health checks on all configured targets.
|
|
// It checks each target concurrently using goroutines to minimize latency.
|
|
func (h *HealthChecker) checkAll() {
|
|
var wg sync.WaitGroup
|
|
|
|
for _, target := range h.targets {
|
|
wg.Add(1)
|
|
go func(t *loadbalance.Target) {
|
|
defer wg.Done()
|
|
h.checkTarget(t)
|
|
}(target)
|
|
}
|
|
|
|
wg.Wait()
|
|
}
|
|
|
|
// checkTarget performs a health check on a single target.
|
|
// It sends an HTTP GET request to the target's health check endpoint
|
|
// and updates the target's Healthy status based on the response.
|
|
//
|
|
// A target is considered healthy if:
|
|
// - The HTTP request succeeds
|
|
// - The response status code is between 200 and 299
|
|
//
|
|
// A target is marked unhealthy if:
|
|
// - The connection fails
|
|
// - The request times out
|
|
// - The response status code is not 2xx
|
|
func (h *HealthChecker) checkTarget(target *loadbalance.Target) {
|
|
// Build health check URL
|
|
url := target.URL + h.path
|
|
|
|
// Prepare request and response
|
|
req := fasthttp.AcquireRequest()
|
|
resp := fasthttp.AcquireResponse()
|
|
defer fasthttp.ReleaseRequest(req)
|
|
defer fasthttp.ReleaseResponse(resp)
|
|
|
|
req.SetRequestURI(url)
|
|
req.Header.SetMethod(fasthttp.MethodGet)
|
|
req.Header.Set("User-Agent", "Lolly-HealthChecker/1.0")
|
|
|
|
// Perform health check with timeout
|
|
err := h.client.DoTimeout(req, resp, h.timeout)
|
|
|
|
if err != nil {
|
|
// Connection failed or timeout - mark as unhealthy
|
|
loadbalance.SetHealthy(target, false)
|
|
return
|
|
}
|
|
|
|
// Check status code - 2xx is healthy
|
|
statusCode := resp.StatusCode()
|
|
if statusCode >= 200 && statusCode < 300 {
|
|
loadbalance.SetHealthy(target, true)
|
|
} else {
|
|
loadbalance.SetHealthy(target, false)
|
|
}
|
|
}
|
|
|
|
// MarkUnhealthy marks a target as unhealthy.
|
|
// This method is intended for passive health checking, where the proxy
|
|
// marks targets as unhealthy based on observed failures during request handling.
|
|
//
|
|
// Example usage in proxy error handling:
|
|
//
|
|
// if err := forwardRequest(target, req, resp); err != nil {
|
|
// healthChecker.MarkUnhealthy(target)
|
|
// // Try another target or return error
|
|
// }
|
|
//
|
|
// Note: To mark a target as healthy again, the active health check
|
|
// must succeed. There is no MarkHealthy method - health status can only
|
|
// be positively restored through successful health checks.
|
|
func (h *HealthChecker) MarkUnhealthy(target *loadbalance.Target) {
|
|
loadbalance.SetHealthy(target, false)
|
|
}
|
|
|
|
// IsRunning returns true if the health checker is currently running.
|
|
func (h *HealthChecker) IsRunning() bool {
|
|
return h.running.Load()
|
|
}
|
|
|
|
// GetInterval returns the configured check interval.
|
|
func (h *HealthChecker) GetInterval() time.Duration {
|
|
h.mu.RLock()
|
|
defer h.mu.RUnlock()
|
|
return h.interval
|
|
}
|
|
|
|
// GetTimeout returns the configured check timeout.
|
|
func (h *HealthChecker) GetTimeout() time.Duration {
|
|
h.mu.RLock()
|
|
defer h.mu.RUnlock()
|
|
return h.timeout
|
|
}
|
|
|
|
// GetPath returns the configured health check path.
|
|
func (h *HealthChecker) GetPath() string {
|
|
h.mu.RLock()
|
|
defer h.mu.RUnlock()
|
|
return h.path
|
|
}
|