feat(go): add capacity health check tool

This commit is contained in:
lingniu
2026-07-03 19:54:29 +08:00
parent 68b729fa7b
commit 3edd7462c7
6 changed files with 349 additions and 0 deletions

View File

@@ -29,6 +29,14 @@ curl -fsS http://127.0.0.1:20214/readyz
curl -fsS http://127.0.0.1:20200/readyz
```
本机容量健康摘要:
```bash
/opt/lingniu-go-native/current/capacity-check
```
它会抓取 Gateway、History writer、Stat writer、NATS bridge、Realtime API、NATS fast writer 的本地 `/metrics`,输出 JSON。退出码 `0` 表示当前关键 backlog 和拒绝计数正常,退出码 `2` 表示存在 pending、Kafka lag、连接拒绝或 metrics 抓取失败,适合接入 cron/告警。
## Core Counters
| Metric | Meaning |

View File

@@ -24,6 +24,8 @@ Go 版本车辆数据接入链路已经作为生产主链路运行在 ECS `115.2
TDengine writer 的 raw/location 子表创建有进程内单飞保护:同一子表 key 并发首次写入时,只有一个 goroutine 执行 `CREATE TABLE IF NOT EXISTS` 和 tag 更新,其他 goroutine 等待结果后继续 INSERT避免 10W 车辆启动或回放时对 TDengine 形成重复 DDL 风暴。
容量健康摘要工具:`/opt/lingniu-go-native/current/capacity-check` 会抓本机各 Go 服务 `/metrics` 并输出 JSON。关键 backlog、Kafka lag、连接拒绝或 metrics 抓取失败时退出码为 `2`,可以接 cron/告警。
## 服务和端口
ECS`115.29.187.205`
@@ -316,6 +318,7 @@ GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o /tm
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o /tmp/lingniu-go-deploy/stat-writer ./cmd/stat-writer
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o /tmp/lingniu-go-deploy/nats-fast-writer ./cmd/nats-fast-writer
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o /tmp/lingniu-go-deploy/nats-kafka-bridge ./cmd/nats-kafka-bridge
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o /tmp/lingniu-go-deploy/capacity-check ./cmd/capacity-check
```
上传部署:

View File

@@ -0,0 +1,109 @@
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"time"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/capacity"
)
const defaultEndpoints = "gateway=http://127.0.0.1:20211,history=http://127.0.0.1:20212,stat=http://127.0.0.1:20213,bridge=http://127.0.0.1:20214,realtime=http://127.0.0.1:20200,fast-writer=http://127.0.0.1:20215"
type endpoint struct {
Name string
URL string
}
func main() {
var rawEndpoints string
var timeout time.Duration
flag.StringVar(&rawEndpoints, "endpoints", defaultEndpoints, "comma separated name=url endpoints; /metrics is appended when no path is provided")
flag.DurationVar(&timeout, "timeout", 2*time.Second, "per endpoint timeout")
flag.Parse()
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
metricsByService, findings := collectMetrics(ctx, parseEndpoints(rawEndpoints), timeout)
report := capacity.Evaluate(metricsByService)
report.Findings = append(report.Findings, findings...)
if len(report.Findings) > 0 {
report.Status = capacity.StatusDegraded
}
encoder := json.NewEncoder(os.Stdout)
encoder.SetIndent("", " ")
_ = encoder.Encode(report)
if report.Status != capacity.StatusOK {
os.Exit(2)
}
}
func parseEndpoints(value string) []endpoint {
var endpoints []endpoint
for _, item := range strings.Split(value, ",") {
item = strings.TrimSpace(item)
if item == "" {
continue
}
name, url, ok := strings.Cut(item, "=")
if !ok {
continue
}
name = strings.TrimSpace(name)
url = withMetricsPath(strings.TrimSpace(url))
if name == "" || url == "" {
continue
}
endpoints = append(endpoints, endpoint{Name: name, URL: url})
}
return endpoints
}
func withMetricsPath(value string) string {
parsed, err := url.Parse(value)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return value
}
if parsed.Path == "" || parsed.Path == "/" {
parsed.Path = "/metrics"
}
return parsed.String()
}
func collectMetrics(ctx context.Context, endpoints []endpoint, timeout time.Duration) (map[string]string, []string) {
client := &http.Client{Timeout: timeout}
metricsByService := make(map[string]string, len(endpoints))
var findings []string
for _, ep := range endpoints {
request, err := http.NewRequestWithContext(ctx, http.MethodGet, ep.URL, nil)
if err != nil {
findings = append(findings, fmt.Sprintf("%s metrics request invalid: %v", ep.Name, err))
continue
}
response, err := client.Do(request)
if err != nil {
findings = append(findings, fmt.Sprintf("%s metrics fetch failed: %v", ep.Name, err))
continue
}
body, readErr := io.ReadAll(response.Body)
_ = response.Body.Close()
if readErr != nil {
findings = append(findings, fmt.Sprintf("%s metrics read failed: %v", ep.Name, readErr))
continue
}
if response.StatusCode < 200 || response.StatusCode >= 300 {
findings = append(findings, fmt.Sprintf("%s metrics status %d", ep.Name, response.StatusCode))
continue
}
metricsByService[ep.Name] = string(body)
}
return metricsByService, findings
}

View File

@@ -0,0 +1,31 @@
package main
import (
"strings"
"testing"
)
func TestParseEndpoints(t *testing.T) {
endpoints := parseEndpoints("gateway=http://127.0.0.1:20211/metrics, fast=http://127.0.0.1:20215/metrics")
if len(endpoints) != 2 {
t.Fatalf("len = %d, want 2: %#v", len(endpoints), endpoints)
}
if endpoints[0].Name != "gateway" || endpoints[0].URL != "http://127.0.0.1:20211/metrics" {
t.Fatalf("endpoint[0] = %#v", endpoints[0])
}
if endpoints[1].Name != "fast" || endpoints[1].URL != "http://127.0.0.1:20215/metrics" {
t.Fatalf("endpoint[1] = %#v", endpoints[1])
}
}
func TestParseEndpointsUsesDefaultMetricsURLWhenOnlyBaseURLIsProvided(t *testing.T) {
endpoints := parseEndpoints("gateway=http://127.0.0.1:20211")
if len(endpoints) != 1 {
t.Fatalf("len = %d, want 1", len(endpoints))
}
if !strings.HasSuffix(endpoints[0].URL, "/metrics") {
t.Fatalf("url = %q, want /metrics suffix", endpoints[0].URL)
}
}

View File

@@ -0,0 +1,132 @@
package capacity
import (
"bufio"
"fmt"
"sort"
"strconv"
"strings"
"time"
)
type Status string
const (
StatusOK Status = "ok"
StatusDegraded Status = "degraded"
)
type Report struct {
Status Status `json:"status"`
CheckedAt time.Time `json:"checked_at"`
Totals Totals `json:"totals"`
Services []ServiceCheck `json:"services"`
Findings []string `json:"findings"`
}
type Totals struct {
ActiveConnections int64 `json:"active_connections"`
KafkaLag float64 `json:"kafka_lag"`
}
type ServiceCheck struct {
Name string `json:"name"`
Metrics map[string]float64 `json:"metrics"`
}
func Evaluate(metricsByService map[string]string) Report {
report := Report{
Status: StatusOK,
CheckedAt: time.Now().UTC(),
Services: make([]ServiceCheck, 0, len(metricsByService)),
}
var names []string
for name := range metricsByService {
names = append(names, name)
}
sort.Strings(names)
for _, name := range names {
metrics := parseMetrics(metricsByService[name])
report.Services = append(report.Services, ServiceCheck{Name: name, Metrics: metrics})
report.Totals.ActiveConnections += int64(metrics["vehicle_gateway_active_connections"])
report.Totals.KafkaLag += metrics["vehicle_history_kafka_lag"]
report.Totals.KafkaLag += metrics["vehicle_stat_kafka_lag"]
report.Totals.KafkaLag += metrics["vehicle_realtime_kafka_lag"]
report.Findings = append(report.Findings, findingsForMetrics(metrics)...)
}
if len(report.Findings) > 0 {
report.Status = StatusDegraded
}
return report
}
func findingsForMetrics(metrics map[string]float64) []string {
var findings []string
if value := metrics["vehicle_gateway_connection_rejections_total"]; value > 0 {
findings = append(findings, fmt.Sprintf("gateway connection rejections %.0f", value))
}
if value := metrics["vehicle_async_sink_queue_depth"]; value > 10_000 {
findings = append(findings, fmt.Sprintf("async sink queue depth %.0f exceeds 10000", value))
}
if value := metrics["vehicle_bridge_nats_consumer_ack_pending"]; value > 100 {
findings = append(findings, fmt.Sprintf("bridge ack pending %.0f exceeds 100", value))
}
if value := metrics["vehicle_bridge_nats_consumer_pending"]; value > 10_000 {
findings = append(findings, fmt.Sprintf("bridge consumer pending %.0f exceeds 10000", value))
}
if value := metrics["vehicle_bridge_batch_pending_messages"]; value > 1000 {
findings = append(findings, fmt.Sprintf("bridge batch pending %.0f exceeds 1000", value))
}
if value := metrics["vehicle_fast_writer_nats_consumer_ack_pending"]; value > 10 {
findings = append(findings, fmt.Sprintf("fast writer ack pending %.0f exceeds 10", value))
}
if value := metrics["vehicle_fast_writer_nats_consumer_pending"]; value > 10_000 {
findings = append(findings, fmt.Sprintf("fast writer consumer pending %.0f exceeds 10000", value))
}
if value := metrics["vehicle_fast_writer_batch_pending_messages"]; value > 0 {
findings = append(findings, fmt.Sprintf("fast writer batch pending %.0f", value))
}
if value := metrics["vehicle_history_batch_pending_messages"]; value > 0 {
findings = append(findings, fmt.Sprintf("history batch pending %.0f", value))
}
if value := metrics["vehicle_history_batch_pending_rows"]; value > 0 {
findings = append(findings, fmt.Sprintf("history rows pending %.0f", value))
}
if value := metrics["vehicle_history_kafka_lag"] + metrics["vehicle_stat_kafka_lag"] + metrics["vehicle_realtime_kafka_lag"]; value > 0 {
findings = append(findings, fmt.Sprintf("kafka lag %.0f", value))
}
return findings
}
func parseMetrics(text string) map[string]float64 {
out := map[string]float64{}
scanner := bufio.NewScanner(strings.NewReader(text))
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
name, value, ok := parseMetricLine(line)
if !ok {
continue
}
out[name] += value
}
return out
}
func parseMetricLine(line string) (string, float64, bool) {
parts := strings.Fields(line)
if len(parts) < 2 {
return "", 0, false
}
name := parts[0]
if index := strings.IndexByte(name, '{'); index >= 0 {
name = name[:index]
}
value, err := strconv.ParseFloat(parts[1], 64)
if err != nil {
return "", 0, false
}
return name, value, true
}

View File

@@ -0,0 +1,66 @@
package capacity
import (
"strings"
"testing"
)
func TestEvaluateReportsOKWhenCriticalBacklogsAreZero(t *testing.T) {
report := Evaluate(map[string]string{
"gateway": `vehicle_gateway_active_connections{protocol="JT808"} 50000
vehicle_gateway_connection_rejections_total{protocol="JT808",reason="max_connections"} 0
vehicle_async_sink_queue_depth{sink="nats"} 0`,
"fast-writer": `vehicle_fast_writer_nats_consumer_ack_pending{consumer="vehicle-fast-writer",stream="VEHICLE_INGEST"} 0
vehicle_fast_writer_nats_consumer_pending{consumer="vehicle-fast-writer",stream="VEHICLE_INGEST"} 0
vehicle_fast_writer_batch_pending_messages 0`,
"history": `vehicle_history_batch_pending_messages 0
vehicle_history_batch_pending_rows 0
vehicle_history_kafka_lag{topic="vehicle.raw.go.jt808.v1",partition="0"} 0`,
})
if report.Status != StatusOK {
t.Fatalf("status = %s, want ok: %#v", report.Status, report)
}
if report.Totals.ActiveConnections != 50000 {
t.Fatalf("active connections = %d, want 50000", report.Totals.ActiveConnections)
}
if len(report.Findings) != 0 {
t.Fatalf("findings = %#v, want none", report.Findings)
}
}
func TestEvaluateReportsDegradedForPendingAndRejects(t *testing.T) {
report := Evaluate(map[string]string{
"gateway": `vehicle_gateway_active_connections{protocol="JT808"} 120000
vehicle_gateway_connection_rejections_total{protocol="JT808",reason="max_connections"} 3
vehicle_async_sink_queue_depth{sink="nats"} 25000`,
"bridge": `vehicle_bridge_nats_consumer_ack_pending{consumer="vehicle-kafka-bridge",stream="VEHICLE_INGEST"} 101
vehicle_bridge_nats_consumer_pending{consumer="vehicle-kafka-bridge",stream="VEHICLE_INGEST"} 12001
vehicle_bridge_batch_pending_messages 1001`,
"fast-writer": `vehicle_fast_writer_nats_consumer_ack_pending{consumer="vehicle-fast-writer",stream="VEHICLE_INGEST"} 11
vehicle_fast_writer_nats_consumer_pending{consumer="vehicle-fast-writer",stream="VEHICLE_INGEST"} 12001
vehicle_fast_writer_batch_pending_messages 9`,
"history": `vehicle_history_batch_pending_messages 6
vehicle_history_kafka_lag{topic="vehicle.raw.go.jt808.v1",partition="0"} 42`,
})
if report.Status != StatusDegraded {
t.Fatalf("status = %s, want degraded: %#v", report.Status, report)
}
joined := strings.Join(report.Findings, "\n")
for _, want := range []string{
"gateway connection rejections",
"async sink queue depth",
"bridge ack pending",
"bridge consumer pending",
"bridge batch pending",
"fast writer ack pending",
"fast writer consumer pending",
"history batch pending",
"kafka lag",
} {
if !strings.Contains(joined, want) {
t.Fatalf("finding missing %q in:\n%s", want, joined)
}
}
}