Skip to content

Commit 40f4d06

Browse files
Merge pull request #63 from randomizedcoder/feat/listener-ip-ttl
feat(health): /healthz + /readyz + gRPC health (land on main)
2 parents c5deda4 + 5a8bb87 commit 40f4d06

8 files changed

Lines changed: 129 additions & 1 deletion

File tree

cmd/xtcp2/xtcp2.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import (
3131
"github.com/pkg/profile"
3232
"github.com/prometheus/client_golang/prometheus"
3333
"github.com/prometheus/client_golang/prometheus/promhttp"
34+
"github.com/randomizedcoder/xtcp2/pkg/health"
3435
"github.com/randomizedcoder/xtcp2/pkg/ipsockopt"
3536
"github.com/randomizedcoder/xtcp2/pkg/misc"
3637
"github.com/randomizedcoder/xtcp2/pkg/xtcp"
@@ -689,6 +690,9 @@ func initPromHandler(promPath string, promListen string, ipv4TTL, ipv6HopLimit u
689690
MaxRequestsInFlight: promMaxRequestsInFlight,
690691
},
691692
))
693+
// Liveness + readiness for container/k8s deployment, on the same listener.
694+
http.HandleFunc("/healthz", health.Healthz)
695+
http.HandleFunc("/readyz", health.Readyz)
692696
go servePromHandler(promListen, ipv4TTL, ipv6HopLimit)
693697
}
694698

docs/observability.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ xtcp2 is built to run as a long-lived daemon, so it ships first-class observabil
55
## Table of contents
66

77
- [Prometheus metrics](#prometheus-metrics)
8+
- [Health & readiness](#health--readiness)
89
- [pprof](#pprof)
910
- [Pyroscope continuous profiling](#pyroscope-continuous-profiling)
1011
- [Capability checks](#capability-checks)
@@ -15,6 +16,21 @@ xtcp2 is built to run as a long-lived daemon, so it ships first-class observabil
1516

1617
`pkg/xtcp/prometheus.go` registers the daemon's metrics and serves them over HTTP. By default they are exposed at `:9088/metrics` (`-promListen`, `-promPath`). Metrics cover the collection pipeline — netlink reads, deserialization, envelope rows flushed, destination sends, and namespace counts — which is what you scrape to alarm on a stalled collector or a destination backpressure problem. The `metrics-audit` tool/check (`nix build .#test-tools-metrics-audit`) guards metric registration.
1718

19+
## Health & readiness
20+
21+
For container / Kubernetes deployment the metrics HTTP server also serves two
22+
probe endpoints (same `-promListen` address):
23+
24+
- **`/healthz`** — liveness. Returns `200` as soon as the HTTP server is up. Use
25+
it for a Docker `HEALTHCHECK` or a k8s `livenessProbe`.
26+
- **`/readyz`** — readiness. Returns `200` only once the daemon has initialised
27+
its destination and netlinkers and started polling; `503` until then and again
28+
during shutdown. Use it for a k8s `readinessProbe` / `startupProbe` so traffic
29+
and rollouts wait until xtcp2 is actually collecting.
30+
31+
The gRPC port additionally serves the standard `grpc.health.v1` service, which
32+
reports `SERVING` on the same readiness condition (for native k8s gRPC probes).
33+
1834
## pprof
1935

2036
The standard Go `net/http/pprof` endpoints are mounted on the metrics HTTP server, so `/debug/pprof/*` is available on the same `-promListen` address for live CPU, heap, goroutine, mutex, and block profiles. For one-shot file-based profiling, `-profile.mode` enables a profiling session of mode `cpu`, `mem`, `mutex`, or `block`.

nix/versions.nix

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,5 +99,5 @@
9999
# Go vendor hash. Update by running `nix build .#xtcp2` and pasting the
100100
# `got:` value from the hash mismatch error. Used by every Nix check that
101101
# needs deps in the sandbox (see nix/lib/goModules.nix).
102-
goVendorHash = "sha256-pP+rYZgVijaF6sF8DLyW997ZH3+ype0iylkYtapEJnY=";
102+
goVendorHash = "sha256-KpZrd1NhcLMEFrNehiGBwt7sKFZlwa+uankR1itYizw=";
103103
}

pkg/health/health.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
// Package health exposes liveness/readiness for containerised deployment
2+
// (Docker healthcheck, Kubernetes httpGet probes). Readiness is a single
3+
// process-wide flag — there is one xtcp2 daemon per process — that the daemon
4+
// flips true once it has initialised its destination and netlinkers and started
5+
// polling, and false on shutdown. The gRPC health service (see grpc_server.go)
6+
// is driven from the same flag.
7+
package health
8+
9+
import (
10+
"net/http"
11+
"sync/atomic"
12+
)
13+
14+
var ready atomic.Bool
15+
16+
// SetReady sets the process readiness state reported by Readyz.
17+
func SetReady(r bool) { ready.Store(r) }
18+
19+
// Ready reports the current readiness state.
20+
func Ready() bool { return ready.Load() }
21+
22+
// Healthz is a liveness handler: 200 as soon as the HTTP server is serving. It
23+
// says nothing about whether the daemon is polling yet — that is Readyz.
24+
func Healthz(w http.ResponseWriter, _ *http.Request) {
25+
w.WriteHeader(http.StatusOK)
26+
_, _ = w.Write([]byte("ok\n"))
27+
}
28+
29+
// Readyz is a readiness handler: 200 once the daemon has initialised its
30+
// destination + netlinkers and started polling, else 503 (and again on
31+
// shutdown) — so an orchestrator holds traffic/rollout until xtcp2 is live.
32+
func Readyz(w http.ResponseWriter, _ *http.Request) {
33+
if !ready.Load() {
34+
w.WriteHeader(http.StatusServiceUnavailable)
35+
_, _ = w.Write([]byte("not ready\n"))
36+
return
37+
}
38+
w.WriteHeader(http.StatusOK)
39+
_, _ = w.Write([]byte("ready\n"))
40+
}

pkg/health/health_test.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package health
2+
3+
import (
4+
"net/http"
5+
"net/http/httptest"
6+
"testing"
7+
)
8+
9+
func TestHealthz_alwaysOK(t *testing.T) {
10+
rr := httptest.NewRecorder()
11+
Healthz(rr, httptest.NewRequest(http.MethodGet, "/healthz", nil))
12+
if rr.Code != http.StatusOK {
13+
t.Fatalf("Healthz = %d, want 200", rr.Code)
14+
}
15+
}
16+
17+
func TestReadyz_reflectsState(t *testing.T) {
18+
t.Cleanup(func() { SetReady(false) })
19+
20+
SetReady(false)
21+
rr := httptest.NewRecorder()
22+
Readyz(rr, httptest.NewRequest(http.MethodGet, "/readyz", nil))
23+
if rr.Code != http.StatusServiceUnavailable {
24+
t.Fatalf("Readyz(not ready) = %d, want 503", rr.Code)
25+
}
26+
27+
SetReady(true)
28+
if !Ready() {
29+
t.Fatal("Ready() = false after SetReady(true)")
30+
}
31+
rr = httptest.NewRecorder()
32+
Readyz(rr, httptest.NewRequest(http.MethodGet, "/readyz", nil))
33+
if rr.Code != http.StatusOK {
34+
t.Fatalf("Readyz(ready) = %d, want 200", rr.Code)
35+
}
36+
}

pkg/xtcp/grpc_server.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,14 @@ import (
88
"net"
99
"time"
1010

11+
"github.com/randomizedcoder/xtcp2/pkg/health"
1112
"github.com/randomizedcoder/xtcp2/pkg/ipsockopt"
1213
"github.com/randomizedcoder/xtcp2/pkg/xtcp_config"
1314
"github.com/randomizedcoder/xtcp2/pkg/xtcp_flat_record"
1415
"google.golang.org/grpc"
1516
_ "google.golang.org/grpc/encoding/gzip"
17+
grpchealth "google.golang.org/grpc/health"
18+
healthpb "google.golang.org/grpc/health/grpc_health_v1"
1619
"google.golang.org/grpc/keepalive"
1720
"google.golang.org/grpc/reflection"
1821
)
@@ -82,6 +85,12 @@ func (x *XTCP) startGRPCflatRecordService(ctx context.Context) {
8285
x.configService = NewXtcpConfigService(ctx, x.registry, x.config, &x.changePollFrequencyCh, x.debugLevel)
8386
xtcp_config.RegisterConfigServiceServer(grpcServer, x.configService)
8487

88+
// Standard gRPC health service (grpc.health.v1) so k8s gRPC probes work.
89+
// Starts NOT_SERVING; setReady flips it to SERVING once the daemon polls.
90+
x.grpcHealth = grpchealth.NewServer()
91+
x.grpcHealth.SetServingStatus("", healthpb.HealthCheckResponse_NOT_SERVING)
92+
healthpb.RegisterHealthServer(grpcServer, x.grpcHealth)
93+
8594
// Stop the gRPC server when ctx fires. grpcServer.Serve blocks
8695
// indefinitely on lis.Accept and is NOT ctx-aware on its own —
8796
// without this goroutine the gRPC server outlives Run() and would
@@ -101,3 +110,17 @@ func (x *XTCP) startGRPCflatRecordService(ctx context.Context) {
101110
log.Printf("startGRPCflatRecordService grpcServer.Serve err:%v", serveErr)
102111
}
103112
}
113+
114+
// setReady flips the process readiness in one place: the HTTP /readyz flag and
115+
// the gRPC health status move together. The daemon calls setReady(true) once it
116+
// starts polling and setReady(false) on shutdown.
117+
func (x *XTCP) setReady(r bool) {
118+
health.SetReady(r)
119+
if x.grpcHealth != nil {
120+
status := healthpb.HealthCheckResponse_NOT_SERVING
121+
if r {
122+
status = healthpb.HealthCheckResponse_SERVING
123+
}
124+
x.grpcHealth.SetServingStatus("", status)
125+
}
126+
}

pkg/xtcp/poller.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,12 @@ func (x *XTCP) Poller(ctx context.Context, wg *sync.WaitGroup) {
3131
log.Printf("Poller DestinationReady")
3232
}
3333

34+
// Destination is up and netlinkers have started — the daemon is now
35+
// operational, so flip liveness/readiness (HTTP /readyz + gRPC health) to
36+
// ready, and back to not-ready when the poller returns (shutdown).
37+
x.setReady(true)
38+
defer x.setReady(false)
39+
3440
ticker := time.NewTicker(x.config.PollFrequency.AsDuration())
3541
defer ticker.Stop()
3642
x.pollTimeoutTimer = time.NewTimer(x.config.PollTimeout.AsDuration())

pkg/xtcp/xtcp.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import (
1414
"time"
1515

1616
"github.com/prometheus/client_golang/prometheus"
17+
grpchealth "google.golang.org/grpc/health"
18+
1719
"github.com/randomizedcoder/xtcp2/pkg/cgroupid"
1820
"github.com/randomizedcoder/xtcp2/pkg/xsync"
1921
"github.com/randomizedcoder/xtcp2/pkg/xtcp_config"
@@ -118,6 +120,7 @@ type XTCP struct {
118120

119121
flatRecordService *xtcpFlatRecordService
120122
configService *xtcpConfigService
123+
grpcHealth *grpchealth.Server
121124

122125
pC *prometheus.CounterVec
123126
pH *prometheus.SummaryVec

0 commit comments

Comments
 (0)