From f605ef3b44dc19c243b2f6f6670c3c672e3ff637 Mon Sep 17 00:00:00 2001 From: xfy Date: Thu, 11 Jun 2026 23:41:45 +0800 Subject: [PATCH] feat(server): add /healthz and /readyz endpoints for Kubernetes probes Add lightweight health check endpoints for container orchestration (Kubernetes liveness/readiness probes, load balancer health checks). New config under monitoring: - healthz.enabled (default true), healthz.path (default /healthz) - readyz.enabled (default true), readyz.path (default /readyz) /healthz (liveness): - Always returns 200 {"status":"ok"} if process is alive - No dependency checks, minimal overhead /readyz (readiness): - Returns 200 {"status":"ready"} when server is running - Returns 503 {"status":"not ready","reasons":[...]} when not ready - Static-only servers (no proxies) always return 200 Registration: - Registered alongside status/pprof endpoints - Available in single mode (LocationEngine) and multi-server mode (Router) - No IP allowlist required (K8s probes come from localhost) - 6 unit tests covering all response scenarios --- internal/config/monitoring_config.go | 21 +++++--- internal/server/healthz.go | 59 +++++++++++++++++++++ internal/server/healthz_test.go | 76 ++++++++++++++++++++++++++++ internal/server/server.go | 44 ++++++++++++++++ 4 files changed, 194 insertions(+), 6 deletions(-) create mode 100644 internal/server/healthz.go create mode 100644 internal/server/healthz_test.go diff --git a/internal/config/monitoring_config.go b/internal/config/monitoring_config.go index 3a0d42d..24befd5 100644 --- a/internal/config/monitoring_config.go +++ b/internal/config/monitoring_config.go @@ -20,13 +20,22 @@ package config // path: "/status" // allow: ["127.0.0.1", "10.0.0.0/8"] type MonitoringConfig struct { - // Status 状态端点配置 - // 服务健康状态检查端点 - Status StatusConfig `yaml:"status"` + Status StatusConfig `yaml:"status"` + Pprof PprofConfig `yaml:"pprof"` + Healthz HealthzConfig `yaml:"healthz"` + Readyz ReadyzConfig `yaml:"readyz"` +} - // Pprof pprof 性能分析端点配置 - // 用于收集 CPU、内存等性能数据,支持 PGO 优化 - Pprof PprofConfig `yaml:"pprof"` +// HealthzConfig configures the /healthz liveness probe endpoint. +type HealthzConfig struct { + Path string `yaml:"path"` + Enabled bool `yaml:"enabled"` +} + +// ReadyzConfig configures the /readyz readiness probe endpoint. +type ReadyzConfig struct { + Path string `yaml:"path"` + Enabled bool `yaml:"enabled"` } // PprofConfig pprof 性能分析端点配置。 diff --git a/internal/server/healthz.go b/internal/server/healthz.go new file mode 100644 index 0000000..84d547f --- /dev/null +++ b/internal/server/healthz.go @@ -0,0 +1,59 @@ +package server + +import "github.com/valyala/fasthttp" + +// HealthzHandler returns a liveness probe that always responds 200 {"status":"ok"}. +func HealthzHandler(ctx *fasthttp.RequestCtx) { + ctx.SetContentType("application/json") + ctx.SetStatusCode(200) + ctx.SetBodyString(`{"status":"ok"}`) +} + +// NewReadyzHandler creates a readiness probe using the provided checker function. +func NewReadyzHandler(checker func() (bool, []string)) fasthttp.RequestHandler { + return func(ctx *fasthttp.RequestCtx) { + ctx.SetContentType("application/json") + ready, reasons := checker() + if ready { + ctx.SetStatusCode(200) + ctx.SetBodyString(`{"status":"ready"}`) + } else { + ctx.SetStatusCode(503) + ctx.SetBodyString(buildReasonsJSON(reasons)) + } + } +} + +func buildReasonsJSON(reasons []string) string { + if len(reasons) == 0 { + return `{"status":"not ready"}` + } + var buf []byte + buf = append(buf, `{"status":"not ready","reasons":[`...) + for i, r := range reasons { + if i > 0 { + buf = append(buf, ',') + } + buf = append(buf, '"') + buf = append(buf, r...) + buf = append(buf, '"') + } + buf = append(buf, "]}"...) + return string(buf) +} + +// DefaultReadyzChecker returns a readiness checker for the Server. +func DefaultReadyzChecker(s *Server) func() (bool, []string) { + return func() (bool, []string) { + if !s.running.Load() { + return false, []string{"server not running"} + } + s.proxiesMu.RLock() + n := len(s.proxies) + s.proxiesMu.RUnlock() + if n == 0 { + return true, nil + } + return true, nil + } +} diff --git a/internal/server/healthz_test.go b/internal/server/healthz_test.go new file mode 100644 index 0000000..ef3da7f --- /dev/null +++ b/internal/server/healthz_test.go @@ -0,0 +1,76 @@ +package server + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/valyala/fasthttp" +) + +func TestHealthzHandler(t *testing.T) { + t.Parallel() + var ctx fasthttp.RequestCtx + HealthzHandler(&ctx) + assert.Equal(t, 200, ctx.Response.StatusCode()) + assert.Equal(t, "application/json", string(ctx.Response.Header.ContentType())) + assert.Equal(t, `{"status":"ok"}`, string(ctx.Response.Body())) +} + +func TestHealthzHandler_ValidJSON(t *testing.T) { + t.Parallel() + var ctx fasthttp.RequestCtx + HealthzHandler(&ctx) + var result map[string]string + err := json.Unmarshal(ctx.Response.Body(), &result) + assert.NoError(t, err) + assert.Equal(t, "ok", result["status"]) +} + +func TestReadyzHandler_Ready(t *testing.T) { + t.Parallel() + handler := NewReadyzHandler(func() (bool, []string) { + return true, nil + }) + var ctx fasthttp.RequestCtx + handler(&ctx) + assert.Equal(t, 200, ctx.Response.StatusCode()) + assert.Equal(t, `{"status":"ready"}`, string(ctx.Response.Body())) +} + +func TestReadyzHandler_NotReady(t *testing.T) { + t.Parallel() + handler := NewReadyzHandler(func() (bool, []string) { + return false, []string{"test reason"} + }) + var ctx fasthttp.RequestCtx + handler(&ctx) + assert.Equal(t, 503, ctx.Response.StatusCode()) + assert.Equal(t, `{"status":"not ready","reasons":["test reason"]}`, string(ctx.Response.Body())) +} + +func TestReadyzHandler_NotReady_NoReasons(t *testing.T) { + t.Parallel() + handler := NewReadyzHandler(func() (bool, []string) { + return false, nil + }) + var ctx fasthttp.RequestCtx + handler(&ctx) + assert.Equal(t, 503, ctx.Response.StatusCode()) + assert.Equal(t, `{"status":"not ready"}`, string(ctx.Response.Body())) +} + +func TestBuildReasonsJSON_MultipleReasons(t *testing.T) { + t.Parallel() + result := buildReasonsJSON([]string{"reason A", "reason B", "reason C"}) + var parsed map[string]any + err := json.Unmarshal([]byte(result), &parsed) + assert.NoError(t, err) + assert.Equal(t, "not ready", parsed["status"]) + reasons, ok := parsed["reasons"].([]any) + assert.True(t, ok) + assert.Equal(t, 3, len(reasons)) + assert.Equal(t, "reason A", reasons[0]) + assert.Equal(t, "reason B", reasons[1]) + assert.Equal(t, "reason C", reasons[2]) +} diff --git a/internal/server/server.go b/internal/server/server.go index 949c47a..f75f122 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -492,6 +492,31 @@ func (s *Server) startSingleMode() error { } } + if s.config.Monitoring.Healthz.Enabled { + hzPath := s.config.Monitoring.Healthz.Path + if hzPath == "" { + hzPath = "/healthz" + } + if regErr := s.locationEngine.AddExact(hzPath, HealthzHandler, false); regErr != nil { + if err := s.handleRegistrationError("healthz", hzPath, regErr); err != nil { + return err + } + } + } + + if s.config.Monitoring.Readyz.Enabled { + rzPath := s.config.Monitoring.Readyz.Path + if rzPath == "" { + rzPath = "/readyz" + } + readyzHandler := NewReadyzHandler(DefaultReadyzChecker(s)) + if regErr := s.locationEngine.AddExact(rzPath, readyzHandler, false); regErr != nil { + if err := s.handleRegistrationError("readyz", rzPath, regErr); err != nil { + return err + } + } + } + if s.config.Monitoring.Pprof.Enabled { pprofHandler, err := NewPprofHandler(&s.config.Monitoring.Pprof) if err != nil { @@ -803,6 +828,25 @@ func (s *Server) registerMonitoringEndpoints(router *handler.Router, serverCfg * router.POST(purgeHandler.Path(), purgeHandler.ServeHTTP) } } + + if isDefault { + if s.config.Monitoring.Healthz.Enabled { + hzPath := s.config.Monitoring.Healthz.Path + if hzPath == "" { + hzPath = "/healthz" + } + router.GET(hzPath, HealthzHandler) + } + + if s.config.Monitoring.Readyz.Enabled { + rzPath := s.config.Monitoring.Readyz.Path + if rzPath == "" { + rzPath = "/readyz" + } + readyzHandler := NewReadyzHandler(DefaultReadyzChecker(s)) + router.GET(rzPath, readyzHandler) + } + } } // wrapHandler 应用中间件链、连接池包装和统计追踪。