From 0d2cab9a040732aa39a1ba275c7460843fdb5740 Mon Sep 17 00:00:00 2001 From: phil Date: Wed, 12 Aug 2026 20:18:15 +0900 Subject: [PATCH] =?UTF-8?q?fix(nodecompat):=20procs=20=EC=88=98=EC=A7=91?= =?UTF-8?q?=EA=B8=B0=20=EC=A0=9C=EA=B1=B0=20=E2=80=94=20stat=20=EC=9D=84?= =?UTF-8?q?=20=EB=81=84=EB=A9=B4=EC=84=9C=20=EB=A9=94=ED=8A=B8=EB=A6=AD=20?= =?UTF-8?q?4=EC=A2=85=EC=9D=84=20=EC=9E=83=EA=B3=A0=20=EC=9E=88=EC=97=88?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 0.9.2 카나리(e106, 실노드)에서 잡혔다. node_* family 가 301 → 297 로 줄었고, 사라진 넷은 전부 upstream "stat" collector 소관이다: node_boot_time_seconds node_context_switches_total node_forks_total node_intr_total nodecompat 의 procs 수집기는 node_procs_running / node_procs_blocked 둘만 내면서 Supersedes() 로 "stat" 전체를 끈다. 대체는 collector 단위라 전부-아니면-전무인데 일부만 낸 것이다. 결과는 직전에 고친 중복과 똑같은 실패 모드다 — 파드는 Ready, /metrics 는 200, 시리즈만 조용히 사라진다. ## 가드가 이걸 못 잡았던 이유 기존 회귀 가드는 "중복이 없는가"만 봤다. 손실은 반대 방향이라 그대로 통과했다. 게다가 이름만 보고는 어느 collector 소관인지 알 수 없다 — node_boot_time_seconds 가 "stat" 것이라는 사실은 upstream 코드를 열어야 나온다. parity 테스트로 교체한다. NodeCollector.Collectors 로 대체 대상 collector 를 **개별 수집**해 그것이 실제로 내는 이름을 얻고, 자체 수집기가 내는 집합과 정확히 일치하는지 본다: upstream 에만 있는 이름 → 손실 (그 메트릭은 아무데서도 나오지 않는다) native 에만 있는 이름 → 중복 (끄지 못한 collector 와 충돌한다) 한 테스트가 양방향을 모두 막으므로 기존 collision 테스트는 여기에 흡수됐다. "대체 대상 collector 가 아무것도 안 냈으면 실패" 단언도 함께 둔다 — 그 경우 비교가 공허하게 통과한다. ## 조치 procs 수집기를 제거하고 upstream "stat" 에 맡긴다. 같은 /proc/stat 을 읽으면서 일곱을 온전히 내므로 자체 구현의 실익이 없다. 없는 이유를 소스에 남겼다 — 빠진 것은 이유가 보이지 않으면 "빠뜨린 것" 으로 읽힌다. 남는 자체 수집기는 loadavg·uname·entropy·filefd·vmstat·osrelease 이고, 전부 대체 대상과 메트릭 집합이 일치한다(Linux 실측). --- deploy/chart/Chart.yaml | 6 +- deploy/chart/values.yaml | 10 +- docs/COMPATIBILITY.md | 2 +- internal/nodecompat/collision_linux_test.go | 79 -------------- internal/nodecompat/nodecompat.go | 6 +- internal/nodecompat/parity_linux_test.go | 114 ++++++++++++++++++++ internal/nodecompat/procs.go | 70 ------------ internal/nodecompat/procs_test.go | 31 ------ internal/nodecompat/superseded_test.go | 1 - 9 files changed, 131 insertions(+), 188 deletions(-) delete mode 100644 internal/nodecompat/collision_linux_test.go create mode 100644 internal/nodecompat/parity_linux_test.go delete mode 100644 internal/nodecompat/procs.go delete mode 100644 internal/nodecompat/procs_test.go diff --git a/deploy/chart/Chart.yaml b/deploy/chart/Chart.yaml index 7ea8850..4df3fb6 100644 --- a/deploy/chart/Chart.yaml +++ b/deploy/chart/Chart.yaml @@ -2,8 +2,8 @@ apiVersion: v2 name: nodevitals description: Unified hardware telemetry agent for Kubernetes nodes type: application -version: 0.9.2 -appVersion: "0.9.2" +version: 0.9.3 +appVersion: "0.9.3" kubeVersion: ">=1.26.0-0" home: https://github.com/keiailab/nodevitals @@ -47,4 +47,4 @@ annotations: url: https://raw.githubusercontent.com/KeiaiLab/nodevitals/main/docs/branding/symbol.png artifacthub.io/images: | - name: nodevitals - image: ghcr.io/keiailab/nodevitals:0.9.2 + image: ghcr.io/keiailab/nodevitals:0.9.3 diff --git a/deploy/chart/values.yaml b/deploy/chart/values.yaml index 9a26c0d..a22fa3d 100644 --- a/deploy/chart/values.yaml +++ b/deploy/chart/values.yaml @@ -37,8 +37,14 @@ updateStrategy: nodeExporter: enabled: false # nativeCollectors 가 true 면 nodevitals 의 자체 Go 수집기(internal/nodecompat)가 - # /proc 기반 node_* 지표(loadavg, filefd, entropy, procs, vmstat, uname, osrelease)를 - # 직접 방출한다. + # /proc 기반 node_* 지표(loadavg, filefd, entropy, vmstat, uname, osrelease)를 + # 직접 방출하고, 그것들이 대체하는 upstream collector 는 자동으로 꺼진다. + # + # 대체는 collector 단위라 전부-아니면-전무다: 자체 수집기가 upstream collector 의 + # 메트릭 일부만 내면 나머지는 통째로 사라진다(2026-08-12 카나리 실측 — procs 가 + # node_procs_running/blocked 만 내면서 stat 을 끄는 바람에 boot_time· + # context_switches·forks·intr 넷이 소실됐다). parity 테스트가 두 집합이 정확히 + # 일치하는지 CI 에서 강제한다. nativeCollectors: true # filesystem collector 는 호스트의 모든 마운트를 statfs 해야 하므로 호스트 루트를 # 읽기전용으로 마운트한다(upstream node_exporter 차트와 동일). 이는 컨테이너에 diff --git a/docs/COMPATIBILITY.md b/docs/COMPATIBILITY.md index f6cbf35..69dfe38 100644 --- a/docs/COMPATIBILITY.md +++ b/docs/COMPATIBILITY.md @@ -1,7 +1,7 @@ # nodevitals — 서비스 전수 호환성 및 연동 명세서 (Compatibility Matrix) > 저장소: [`github.com/KeiaiLab/nodevitals`](https://github.com/KeiaiLab/nodevitals) -> 기준 버전: `v0.9.2` (Chart v0.9.2) +> 기준 버전: `v0.9.3` (Chart v0.9.3) > 최종 검증 일시: 2026년 8월 12일 본 문서는 `nodevitals`가 연동되는 주요 인프라 서비스, 관측 플랫폼, GPU 오퍼레이터, 가상머신(VM) 환경 간의 명시적 호환성 계약(Compatibility Contract)과 실측 검증 결과를 제공합니다. diff --git a/internal/nodecompat/collision_linux_test.go b/internal/nodecompat/collision_linux_test.go deleted file mode 100644 index 0621182..0000000 --- a/internal/nodecompat/collision_linux_test.go +++ /dev/null @@ -1,79 +0,0 @@ -//go:build linux - -// 이 검사는 Linux 에서만 의미가 있다. entropy·filefd·stat·vmstat 은 upstream 에 -// Linux 전용 구현뿐이라, darwin 에서 돌리면 upstream 쪽 집합이 애초에 비어 -// 교집합도 비고 "충돌 없음"으로 통과해버린다 — 실제로는 아무것도 검사하지 않은 채. -package nodecompat_test - -import ( - "io" - "log/slog" - "testing" - - "github.com/prometheus/client_golang/prometheus" - - "github.com/KeiaiLab/nodevitals/internal/nodecompat" - "github.com/KeiaiLab/nodevitals/internal/nodeexporter" -) - -// 자체 수집기와 임베드 node_exporter 가 같은 메트릭 이름을 내면 client_golang 이 -// 충돌한 family 를 스크레이프에서 빼면서도 200 을 준다. 앞선 단위 테스트는 -// "선언과 플래그가 서로 일관적"인지만 보므로, 선언 자체가 틀렸을 때 — -// 예를 들어 procs 가 upstream "stat" 이 아니라 존재하지도 않는 "procs" 를 -// 대체한다고 선언했을 때 — 는 잡지 못한다. 실제로 양쪽을 수집해 대조한다. -func TestNativeCollectorsDoNotCollideWithEmbeddedNodeExporter(t *testing.T) { - quiet := slog.New(slog.NewTextHandler(io.Discard, nil)) - - native := names(t, nodecompat.New("/proc", "/sys", "/", quiet)) - - // node_exporter 의 collector 들은 init() 에서 전역 kingpin 에 플래그를 등록하고 - // nodeexporter.New 가 그것을 딱 한 번 파싱한다. 한 프로세스에서 구성을 바꿔 - // 두 번 만들 수 없으므로 이 테스트가 유일한 호출자여야 한다. - ne, err := nodeexporter.New(nodeexporter.Config{ - ProcPath: "/proc", - SysPath: "/sys", - ExtraFlags: nodecompat.NoCollectorFlags(), - }, quiet) - if err != nil { - t.Fatalf("build embedded node_exporter: %v", err) - } - upstream := names(t, ne) - - // 이 단언이 없으면, upstream 수집이 통째로 실패했을 때도 교집합이 비어 - // 통과한다 — 검사한 게 없는데 초록불이 켜지는 바로 그 상태. - if len(upstream) < 20 { - t.Fatalf("embedded node_exporter yielded only %d metric families; "+ - "too few to prove anything about collisions", len(upstream)) - } - if len(native) == 0 { - t.Fatal("native collectors yielded no metric families") - } - - for name := range native { - if upstream[name] { - t.Errorf("%q is emitted by both the native collector and the embedded "+ - "node_exporter; the collided family gets dropped from every scrape "+ - "while /metrics still answers 200", name) - } - } -} - -// names 는 collector 를 실제로 수집해 방출된 메트릭 이름을 모은다. 수집 오류는 -// 무시한다 — 컨테이너에 없는 하드웨어를 읽는 collector 는 정상적으로 실패하고, -// 여기서 필요한 건 "어떤 이름을 쓰는가"뿐이다. -func names(t *testing.T, c prometheus.Collector) map[string]bool { - t.Helper() - reg := prometheus.NewRegistry() - if err := reg.Register(c); err != nil { - t.Fatalf("register collector: %v", err) - } - families, err := reg.Gather() - if err != nil { - t.Logf("gather reported errors (expected for absent hardware): %v", err) - } - out := make(map[string]bool, len(families)) - for _, f := range families { - out[f.GetName()] = true - } - return out -} diff --git a/internal/nodecompat/nodecompat.go b/internal/nodecompat/nodecompat.go index 066ae57..02bf279 100644 --- a/internal/nodecompat/nodecompat.go +++ b/internal/nodecompat/nodecompat.go @@ -40,8 +40,12 @@ func New(procRoot, sysRoot, rootFS string, log *slog.Logger) *Exporter { newLoadAvg(procRoot), newFileFD(procRoot), newEntropy(procRoot), - newProcs(procRoot), newVMStat(procRoot), + // procs(node_procs_running/blocked)는 의도적으로 없다. upstream 의 + // "stat" collector 가 같은 /proc/stat 에서 그 둘에 더해 + // boot_time_seconds·context_switches_total·forks_total·intr_total 까지 + // 내므로, 둘만 내면서 stat 을 끄면 나머지 넷이 통째로 사라진다 + // (2026-08-12 카나리 실측). parity 테스트가 이 조건을 강제한다. newUname(), newOSRelease(rootFS), }, diff --git a/internal/nodecompat/parity_linux_test.go b/internal/nodecompat/parity_linux_test.go new file mode 100644 index 0000000..0107503 --- /dev/null +++ b/internal/nodecompat/parity_linux_test.go @@ -0,0 +1,114 @@ +//go:build linux + +// 이 검사는 Linux 에서만 의미가 있다. entropy·filefd·stat·vmstat 은 upstream 에 +// Linux 전용 구현뿐이라, darwin 에서는 비교 대상 자체가 비어 통과해버린다. +package nodecompat_test + +import ( + "io" + "log/slog" + "regexp" + "testing" + + "github.com/prometheus/client_golang/prometheus" + necollector "github.com/prometheus/node_exporter/collector" + + "github.com/KeiaiLab/nodevitals/internal/nodecompat" + "github.com/KeiaiLab/nodevitals/internal/nodeexporter" +) + +var fqName = regexp.MustCompile(`fqName: "([^"]+)"`) + +// 자체 수집기가 어떤 upstream collector 를 대체한다고 선언하면 그 collector 는 +// 통째로 꺼진다. 따라서 두 집합이 정확히 같아야 한다. +// +// upstream 에만 있는 이름 → 그 메트릭은 아무데서도 나오지 않는다(손실) +// native 에만 있는 이름 → 끄지 못한 다른 collector 와 충돌한다(중복) +// +// 손실 쪽이 특히 잡기 어렵다. 이름만 보고는 어느 collector 소관인지 알 수 없기 +// 때문이다 — upstream 의 "stat" 은 node_procs_running 뿐 아니라 +// node_boot_time_seconds·node_context_switches_total·node_forks_total· +// node_intr_total 도 낸다. 실측으로 대조하지 않으면 넷이 조용히 사라진다. +func TestNativeCollectorsMatchTheUpstreamCollectorsTheyReplace(t *testing.T) { + quiet := slog.New(slog.NewTextHandler(io.Discard, nil)) + + // 차단 플래그 없이 만든다 — 대체 대상 collector 를 개별로 수집해야 하므로. + // node_exporter 의 collector 들은 init() 에서 전역 kingpin 에 플래그를 등록하고 + // nodeexporter.New 가 그것을 딱 한 번 파싱하므로, 이 테스트가 이 패키지의 + // 유일한 호출자여야 한다. + c, err := nodeexporter.New(nodeexporter.Config{ProcPath: "/proc", SysPath: "/sys"}, quiet) + if err != nil { + t.Fatalf("build embedded node_exporter: %v", err) + } + node, ok := c.(*necollector.NodeCollector) + if !ok { + t.Fatalf("embedded collector is %T, not a *NodeCollector", c) + } + + upstream := map[string]bool{} + for _, name := range nodecompat.SupersededCollectors() { + sub, ok := node.Collectors[name] + if !ok { + t.Errorf("nodecompat claims to supersede upstream collector %q, which is not enabled "+ + "(a typo here means the upstream collector keeps running and duplicates the native one)", name) + continue + } + for n := range namesFromUpdate(t, sub) { + upstream[n] = true + } + } + if len(upstream) == 0 { + t.Fatal("superseded upstream collectors emitted nothing; this comparison would pass vacuously") + } + + native := namesFromCollector(t, nodecompat.New("/proc", "/sys", "/", quiet)) + + for n := range upstream { + if !native[n] { + t.Errorf("%q is emitted by a superseded upstream collector but not by the native one; "+ + "disabling that collector deletes the metric outright", n) + } + } + for n := range native { + if !upstream[n] { + t.Errorf("%q is emitted natively but by none of the superseded upstream collectors; "+ + "whichever upstream collector owns it is still enabled and will collide", n) + } + } +} + +// namesFromUpdate collects one embedded collector on its own, which is the only +// way to learn which metrics that particular collector owns. +func namesFromUpdate(t *testing.T, c necollector.Collector) map[string]bool { + t.Helper() + ch := make(chan prometheus.Metric, 4096) + if err := c.Update(ch); err != nil { + // 컨테이너에 없는 하드웨어를 읽는 collector 는 정상적으로 실패한다. + t.Logf("collector update reported (may be expected): %v", err) + } + close(ch) + out := map[string]bool{} + for m := range ch { + if g := fqName.FindStringSubmatch(m.Desc().String()); g != nil { + out[g[1]] = true + } + } + return out +} + +func namesFromCollector(t *testing.T, c prometheus.Collector) map[string]bool { + t.Helper() + reg := prometheus.NewRegistry() + if err := reg.Register(c); err != nil { + t.Fatalf("register collector: %v", err) + } + families, err := reg.Gather() + if err != nil { + t.Logf("gather reported: %v", err) + } + out := map[string]bool{} + for _, f := range families { + out[f.GetName()] = true + } + return out +} diff --git a/internal/nodecompat/procs.go b/internal/nodecompat/procs.go deleted file mode 100644 index 3a54274..0000000 --- a/internal/nodecompat/procs.go +++ /dev/null @@ -1,70 +0,0 @@ -package nodecompat - -import ( - "bufio" - "fmt" - "os" - "path/filepath" - "strconv" - "strings" - - "github.com/prometheus/client_golang/prometheus" -) - -var ( - procsRunningDesc = prometheus.NewDesc( - "node_procs_running", - "Number of processes in runnable state.", - nil, nil, - ) - procsBlockedDesc = prometheus.NewDesc( - "node_procs_blocked", - "Number of processes blocked waiting for I/O to complete.", - nil, nil, - ) -) - -type procsCollector struct { - procRoot string -} - -func newProcs(procRoot string) subCollector { - return &procsCollector{procRoot: procRoot} -} - -func (c *procsCollector) Name() string { return "procs" } - -// node_procs_running / node_procs_blocked belong to upstream's "stat" -// collector, not to a "procs" one — that collector does not exist. -func (c *procsCollector) Supersedes() string { return "stat" } - -func (c *procsCollector) Collect(ch chan<- prometheus.Metric) error { - path := filepath.Join(c.procRoot, "stat") - file, err := os.Open(path) - if err != nil { - return fmt.Errorf("open proc stat: %w", err) - } - defer file.Close() - - scanner := bufio.NewScanner(file) - for scanner.Scan() { - line := scanner.Text() - if strings.HasPrefix(line, "procs_running ") { - fields := strings.Fields(line) - if len(fields) >= 2 { - if val, err := strconv.ParseFloat(fields[1], 64); err == nil { - ch <- prometheus.MustNewConstMetric(procsRunningDesc, prometheus.GaugeValue, val) - } - } - } else if strings.HasPrefix(line, "procs_blocked ") { - fields := strings.Fields(line) - if len(fields) >= 2 { - if val, err := strconv.ParseFloat(fields[1], 64); err == nil { - ch <- prometheus.MustNewConstMetric(procsBlockedDesc, prometheus.GaugeValue, val) - } - } - } - } - - return scanner.Err() -} diff --git a/internal/nodecompat/procs_test.go b/internal/nodecompat/procs_test.go deleted file mode 100644 index 91e05b5..0000000 --- a/internal/nodecompat/procs_test.go +++ /dev/null @@ -1,31 +0,0 @@ -package nodecompat - -import ( - "strings" - "testing" - - "github.com/prometheus/client_golang/prometheus/testutil" -) - -func TestProcsCollector(t *testing.T) { - procRoot := t.TempDir() - content := `cpu 123 456 789 -procs_running 5 -procs_blocked 2 -` - writeProcFile(t, procRoot, "stat", content) - - exp := exporterWith(newProcs(procRoot)) - expected := ` - # HELP node_procs_blocked Number of processes blocked waiting for I/O to complete. - # TYPE node_procs_blocked gauge - node_procs_blocked 2 - # HELP node_procs_running Number of processes in runnable state. - # TYPE node_procs_running gauge - node_procs_running 5 - ` - - if err := testutil.CollectAndCompare(exp, strings.NewReader(expected)); err != nil { - t.Fatalf("unexpected metrics: %v", err) - } -} diff --git a/internal/nodecompat/superseded_test.go b/internal/nodecompat/superseded_test.go index 2496d4a..6e74096 100644 --- a/internal/nodecompat/superseded_test.go +++ b/internal/nodecompat/superseded_test.go @@ -56,7 +56,6 @@ func TestNoCollectorFlagsCoverEverySupersededCollector(t *testing.T) { func TestSupersededNamesAreUpstreamNamesNotLocalNames(t *testing.T) { e := New("/proc", "/sys", "/", nil) want := map[string]string{ - "procs": "stat", "osrelease": "os", } for _, sub := range e.subs {