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
This commit is contained in:
xfy 2026-06-11 23:41:45 +08:00
parent 8224ae7ff3
commit f605ef3b44
4 changed files with 194 additions and 6 deletions

View File

@ -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 性能分析端点配置。

View File

@ -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
}
}

View File

@ -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])
}

View File

@ -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 应用中间件链、连接池包装和统计追踪。