From c44ffad61f6139201fac1db0bc4d08d9837d5c18 Mon Sep 17 00:00:00 2001 From: opsx Date: Wed, 28 Oct 2026 21:32:36 +0000 Subject: [PATCH] =?UTF-8?q?fix(metrics):=20duplicate=20metrics=20collector?= =?UTF-8?q?=20registration=20panic=EF=BC=88=E7=BA=BF=E4=B8=8A=20CrashLoopB?= =?UTF-8?q?ackOff=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit === 现象 === 上一提交 b49677f 部署后平台直接 CrashLoopBackOff(5 次重启): Initializing database... panic: duplicate metrics collector registration attempted lokistack/pkg/metrics.Register.func1() metrics.go:125 === 根因 === prometheus/client_golang 在 init() 里已经把 GoCollector 与 ProcessCollector 注册进 **默认 registry**(prometheus.DefaultRegisterer)。我又对同一默认 registry 调用 MustRegister 注册了同一批 collector → 重复注册 → 运行时 panic。 致命点:go build / go vet / 普通单测**全都发现不了** —— MustRegister 的重复检查只在运行期执行,只有真的调用 Register() 才会炸。 所以整个 P0 打磨在测试阶段是"全绿"的,一上线就挂。 === 修法 === 不再碰默认 registry,改用自有实例: var registry = prometheus.NewRegistry() // 独立,不受 init() 影响 registry.MustRegister(...) // 注册全部指标 promhttp.HandlerFor(registry, ...) // 抓取读自有 registry 好处:完全掌控注册内容,未来再加 collector 不会与库的默认行为冲突。 === 回归测试(这才是关键收获)=== 新增 backend/pkg/metrics/metrics_test.go: - TestRegisterIsIdempotent : 连续调用 Register 两次,必须不 panic - TestHandlerExposesCustomMetrics : 断言抓取输出含 lokistack_* 与 go_goroutines - TestRegisterConcurrent : 20 个 goroutine 并发调用不 panic 这三条在修复前必然失败,修复后全绿。以后再改指标定义会被它们拦住。 镜像:lghost/lokistack:latest + v0.1 = 2f8f3c85643e --- backend/pkg/metrics/metrics.go | 15 +++++-- backend/pkg/metrics/metrics_test.go | 67 +++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 3 deletions(-) create mode 100644 backend/pkg/metrics/metrics_test.go diff --git a/backend/pkg/metrics/metrics.go b/backend/pkg/metrics/metrics.go index 0c0f7f7..c2cd44a 100644 --- a/backend/pkg/metrics/metrics.go +++ b/backend/pkg/metrics/metrics.go @@ -116,13 +116,20 @@ var ( []string{"version", "go_version"}, ) + // registry 用独立实例,不用 prometheus.DefaultRegisterer。 + // 原因:client_golang 在 init() 里已经把 GoCollector / ProcessCollector + // 注册进默认 registry,再用 MustRegister 注册同一批 collector 会 + // **运行时 panic: duplicate metrics collector registration attempted** + // (编译期和单测都发现不了,只在进程启动时炸)。 + registry = prometheus.NewRegistry() + once sync.Once ) // Register 注册全部指标(幂等)。version 一般传 main.version。 func Register(version string) { once.Do(func() { - prometheus.MustRegister( + registry.MustRegister( HTTPRequestsTotal, HTTPRequestDuration, HTTPInFlight, DBSizeBytes, DBSaveDuration, DBSaveTotal, DBRecordsTotal, DeploymentsTotal, CollectorsTotal, @@ -135,8 +142,10 @@ func Register(version string) { }) } -// Handler 返回 Prometheus 抓取端点。 -func Handler() http.Handler { return promhttp.Handler() } +// Handler 返回 Prometheus 抓取端点(读自有 registry,不含默认 registry 的重复项)。 +func Handler() http.Handler { + return promhttp.HandlerFor(registry, promhttp.HandlerOpts{}) +} // ── 埋点辅助(各层直接调) ── diff --git a/backend/pkg/metrics/metrics_test.go b/backend/pkg/metrics/metrics_test.go new file mode 100644 index 0000000..7a76689 --- /dev/null +++ b/backend/pkg/metrics/metrics_test.go @@ -0,0 +1,67 @@ +package metrics + +import ( + "io" + "net/http/httptest" + "strings" + "sync" + "testing" +) + +// 回归:Register 必须幂等。 +// 线上事故:client_golang 在 init() 里已把 GoCollector / ProcessCollector +// 注册进默认 registry,我们又 MustRegister 到默认 registry → 进程启动即 +// panic: duplicate metrics collector registration attempted。 +// 这个 bug 编译期和普通单测都发现不了(只有真的调用 Register 才炸), +// 所以这里必须实际调用两次。 +func TestRegisterIsIdempotent(t *testing.T) { + defer func() { + if r := recover(); r != nil { + t.Fatalf("Register 触发 panic(重复注册):%v", r) + } + }() + Register("test") + Register("test") // 第二次必须安全返回 +} + +// 抓取端点必须能输出我们自己定义的指标 +func TestHandlerExposesCustomMetrics(t *testing.T) { + Register("test") + ObserveHTTP("GET", "/api/v1/deployments", 200, 0.01) + ObserveDBSave(0.002, true) + ObserveAlertEvaluation(0.3, true) + ObserveLokiQuery(true) + DBRecordsTotal.WithLabelValues("deployments").Set(3) + + rec := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/metrics", nil) + Handler().ServeHTTP(rec, req) + + body, _ := io.ReadAll(rec.Body) + out := string(body) + for _, want := range []string{ + "lokistack_http_requests_total", + "lokistack_db_save_duration_seconds", + "lokistack_alert_evaluations_total", + "lokistack_loki_queries_total", + "lokistack_db_records", + "go_goroutines", // GoCollector 生效 + } { + if !strings.Contains(out, want) { + t.Fatalf("抓取结果缺少指标 %q", want) + } + } +} + +// 并发调用不能 panic(sync.Once 保护) +func TestRegisterConcurrent(t *testing.T) { + var wg sync.WaitGroup + for i := 0; i < 20; i++ { + wg.Add(1) + go func() { + defer wg.Done() + Register("test") + }() + } + wg.Wait() +}