feat(go): add capacity health check tool
This commit is contained in:
@@ -29,6 +29,14 @@ curl -fsS http://127.0.0.1:20214/readyz
|
|||||||
curl -fsS http://127.0.0.1:20200/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
|
## Core Counters
|
||||||
|
|
||||||
| Metric | Meaning |
|
| Metric | Meaning |
|
||||||
|
|||||||
@@ -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 风暴。
|
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`
|
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/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-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/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
|
||||||
```
|
```
|
||||||
|
|
||||||
上传部署:
|
上传部署:
|
||||||
|
|||||||
109
go/vehicle-gateway/cmd/capacity-check/main.go
Normal file
109
go/vehicle-gateway/cmd/capacity-check/main.go
Normal 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
|
||||||
|
}
|
||||||
31
go/vehicle-gateway/cmd/capacity-check/main_test.go
Normal file
31
go/vehicle-gateway/cmd/capacity-check/main_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
132
go/vehicle-gateway/internal/capacity/check.go
Normal file
132
go/vehicle-gateway/internal/capacity/check.go
Normal 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
|
||||||
|
}
|
||||||
66
go/vehicle-gateway/internal/capacity/check_test.go
Normal file
66
go/vehicle-gateway/internal/capacity/check_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user