Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,10 @@ jobs:
platforms: linux/amd64
load: true
tags: ${{ env.IMG }}:scan
# 아래 push 스텝과 반드시 동일해야 한다 — build-arg 가 다르면 스캔한
# 이미지와 발행하는 이미지가 다른 산출물이 된다.
build-args: |
VERSION=${{ steps.ver.outputs.app }}

- name: Trivy scan (HIGH/CRITICAL block)
if: steps.img.outputs.exists == 'false'
Expand All @@ -93,6 +97,8 @@ jobs:
provenance: true
sbom: true
tags: ${{ env.IMG }}:${{ steps.ver.outputs.app }}
build-args: |
VERSION=${{ steps.ver.outputs.app }}

- uses: sigstore/cosign-installer@v3
if: steps.img.outputs.exists == 'false'
Expand Down
6 changes: 5 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,12 @@ COPY go.mod go.sum* ./
RUN go mod download
COPY . .
ARG TARGETARCH=amd64
# VERSION becomes nodevitals_build_info{version=...}, the only way to ask a
# running node which build it is on. Left unset it stays "unknown" rather than
# naming a release the binary may not be.
ARG VERSION=""
RUN CGO_ENABLED=1 GOOS=linux GOARCH=${TARGETARCH} \
go build -trimpath -ldflags="-s -w" -tags gpu -o /out/nodevitals ./cmd/nodevitals
go build -trimpath -ldflags="-s -w -X main.version=${VERSION}" -tags gpu -o /out/nodevitals ./cmd/nodevitals

FROM gcr.io/distroless/cc-debian12:nonroot
# Links the ghcr package to this repository, so the image shows up under the
Expand Down
6 changes: 5 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,10 @@ fmt:
build:
# 이미지와 같은 CGO_ENABLED=1 — go-nvml 과 node_exporter 의 일부 collector 가
# cgo 를 요구한다. 0 으로 두면 로컬 게이트만 실패해 이미지와 어긋난다.
CGO_ENABLED=1 go build -trimpath -ldflags="-s -w" -tags gpu -o dist/nodevitals ./cmd/nodevitals
#
# -X main.version 은 Chart.yaml 의 appVersion 을 그대로 흘려보낸다. 릴리스
# 파이프라인도 같은 값을 진실로 삼으므로, 버전이 사는 곳은 Chart.yaml 하나다.
CGO_ENABLED=1 go build -trimpath -ldflags="-s -w -X main.version=$(VERSION)" -tags gpu -o dist/nodevitals ./cmd/nodevitals

docker:
docker build --platform=linux/amd64 -t ghcr.io/keiailab/nodevitals:dev .
Expand All @@ -35,6 +38,7 @@ chart-lint:
chart-test:
bash deploy/chart/tests/secret-isolation.sh
bash deploy/chart/tests/tier-runtime.sh
bash deploy/chart/tests/compatibility-check.sh

# Vuln-scan IMGREF, failing on HIGH/CRITICAL. Override IMGREF for the gpu image.
scan:
Expand Down
37 changes: 31 additions & 6 deletions cmd/nodevitals/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,13 @@ import (
"github.com/KeiaiLab/nodevitals/internal/smartctlcompat"
)

// version 은 빌드 시 -ldflags "-X main.version=..." 로 주입된다. 소스에 릴리스
// 번호를 적어 두면 bump 를 잊는 순간 이미지가 자기 버전을 틀리게 신고하고,
// 그것이 배포 검증의 유일한 자기신고 수단이라 확인할 방법 자체가 사라진다.
// 주입이 없으면 "unknown" 으로 남는다 — 모르는 것을 모른다고 말하는 편이,
// 아닐 수도 있는 릴리스를 자칭하는 것보다 낫다.
var version string

func main() {
cfgPath := flag.String("config", "/etc/nodevitals/config.yaml", "config file path")
flag.Parse()
Expand All @@ -48,7 +55,7 @@ func main() {
for _, tier := range tiers {
switch tier {
case "core":
reg.Add(collector.NewHeartbeat(cfg.Node, "0.8.5"))
reg.Add(collector.NewHeartbeat(cfg.Node, version))
reg.Add(collector.NewLoadAvg(cfg.Node, cfg.ProcRoot))
reg.Add(collector.NewCPU(cfg.Node, cfg.ProcRoot))
reg.Add(collector.NewMem(cfg.Node, cfg.ProcRoot))
Expand Down Expand Up @@ -118,18 +125,14 @@ func main() {
// dashboards and alert rules built on node_* keep working untouched.
neCount := 0
if cfg.NodeExporter.Enabled {
extraFlags := cfg.NodeExporter.ExtraFlags
extraFlags := nodeExporterFlags(cfg.NodeExporter)
if cfg.NodeExporter.NativeCollectors {
nc := nodecompat.New(cfg.ProcRoot, cfg.SysRoot, cfg.NodeExporter.RootFSPath, slog.Default())
if err := metrics.Register(nc); err != nil {
slog.Error("register native nodecompat exporter", "err", err)
os.Exit(1)
}
slog.Info("native nodecompat collectors registered")
extraFlags = append(extraFlags,
"--no-collector.loadavg",
"--no-collector.uname",
)
}
c, err := nodeexporter.New(nodeexporter.Config{
ProcPath: cfg.ProcRoot,
Expand Down Expand Up @@ -218,3 +221,25 @@ func main() {
slog.Error("http shutdown", "err", err)
}
}

// nodeExporterFlags 는 임베드 node_exporter 에 넘길 collector 플래그를 만든다.
//
// 자체 수집기가 켜지면 그것이 대체하는 upstream collector 를 **전부** 꺼야 한다.
// 하나라도 남으면 같은 메트릭 이름이 두 곳에서 등록되고, client_golang 은 충돌한
// family 를 스크레이프 결과에서 빼면서도 200 을 계속 준다 — 파드는 Ready, /metrics
// 는 정상, 그 시리즈만 조용히 사라진다.
//
// 차단 목록은 nodecompat 이 자기 수집기 집합에서 파생시킨다. 여기에 이름을 다시
// 적으면 nodecompat 에 수집기가 추가될 때마다 두 목록이 어긋난다 — 실제로 0.9.0 이
// loadavg·uname 둘만 적어 나머지 다섯(entropy·filefd·stat·vmstat·os)이 중복됐다.
func nodeExporterFlags(cfg config.NodeExporterConfig) []string {
if !cfg.NativeCollectors {
return cfg.ExtraFlags
}
// cfg.ExtraFlags 에 그대로 append 하면 cap 여유가 있을 때 호출자의 배열에
// 써 들어간다. config 는 한 번 읽어 계속 쓰이므로 복사해서 시작한다.
native := nodecompat.NoCollectorFlags()
flags := make([]string, 0, len(cfg.ExtraFlags)+len(native))
flags = append(flags, cfg.ExtraFlags...)
return append(flags, native...)
}
68 changes: 68 additions & 0 deletions cmd/nodevitals/main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package main

import (
"slices"
"testing"

"github.com/KeiaiLab/nodevitals/internal/config"
"github.com/KeiaiLab/nodevitals/internal/nodecompat"
)

// With the native collectors on, every upstream collector they replace has to
// be switched off. Leaving one enabled makes both register the same metric
// names, and client_golang drops the collided family from the scrape while
// still answering 200 — the loss never surfaces as an error.
func TestNodeExporterFlagsDisableEveryNativelyServedCollector(t *testing.T) {
flags := nodeExporterFlags(config.NodeExporterConfig{NativeCollectors: true})
for _, name := range nodecompat.SupersededCollectors() {
want := "--no-collector." + name
if !slices.Contains(flags, want) {
t.Errorf("missing %q: upstream %q stays enabled alongside its native replacement (got %v)",
want, name, flags)
}
}
}

// Without the native collectors the upstream ones are the only source, so
// disabling them would delete the metrics outright rather than deduplicate.
func TestNodeExporterFlagsLeaveUpstreamAloneWhenNativeIsOff(t *testing.T) {
flags := nodeExporterFlags(config.NodeExporterConfig{
NativeCollectors: false,
ExtraFlags: []string{"--collector.systemd"},
})
for _, f := range flags {
if f != "--collector.systemd" {
t.Errorf("unexpected flag %q with nativeCollectors off; want only the operator's own flags", f)
}
}
}

func TestNodeExporterFlagsKeepOperatorSuppliedFlags(t *testing.T) {
flags := nodeExporterFlags(config.NodeExporterConfig{
NativeCollectors: true,
ExtraFlags: []string{"--collector.processes", "--collector.systemd"},
})
for _, want := range []string{"--collector.processes", "--collector.systemd"} {
if !slices.Contains(flags, want) {
t.Errorf("operator flag %q was dropped (got %v)", want, flags)
}
}
}

// append onto a caller-owned slice can write through to its backing array when
// there is spare capacity. The config is read once and reused, so a mutation
// here would leak into anything else reading ExtraFlags.
func TestNodeExporterFlagsDoNotMutateConfig(t *testing.T) {
extra := make([]string, 1, 8) // 여유 cap — aliasing 이 드러나는 조건
extra[0] = "--collector.systemd"
cfg := config.NodeExporterConfig{NativeCollectors: true, ExtraFlags: extra}

nodeExporterFlags(cfg)

if got := cfg.ExtraFlags; len(got) != 1 || got[0] != "--collector.systemd" {
t.Errorf("config.ExtraFlags was mutated: %v", got)
}
if got := extra[:cap(extra)]; got[1] != "" {
t.Errorf("wrote past the caller's slice into its backing array: %v", got)
}
}
6 changes: 3 additions & 3 deletions deploy/chart/Chart.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ apiVersion: v2
name: nodevitals
description: Unified hardware telemetry agent for Kubernetes nodes
type: application
version: 0.9.0
appVersion: "0.9.0"
version: 0.9.1
appVersion: "0.9.1"
kubeVersion: ">=1.26.0-0"

home: https://github.com/keiailab/nodevitals
Expand Down Expand Up @@ -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.0
image: ghcr.io/keiailab/nodevitals:0.9.1
25 changes: 22 additions & 3 deletions deploy/chart/templates/_helpers.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -66,13 +66,32 @@ Call with (dict "ctx" . "tier" "<core|smart|gpu>").
{{- define "nodevitals.configChecksums" -}}
{{- $ctx := .ctx -}}
{{- $suffix := ternary "" (printf "-%s" .tier) (eq .tier "core") -}}
prometheus.io/scrape: "true"
prometheus.io/port: {{ $ctx.Values.metrics.port | default "9847" | quote }}
prometheus.io/path: "/metrics"
checksum/config: {{ include (print $ctx.Template.BasePath "/configmap" $suffix ".yaml") $ctx | sha256sum }}
checksum/webhook-secret: {{ include (print $ctx.Template.BasePath "/secret.yaml") $ctx | sha256sum }}
{{- end -}}

{{/*
Prometheus pod-discovery annotations, for clusters whose scrape config picks
targets up by pod annotation (a `role: pod` job) rather than by Service or
ServiceMonitor.

Off by default, like serviceMonitor.enabled, because a discovery mechanism that
turns itself on is the one that hurts: a cluster already scraping this chart
through a Service keeps doing so, and the pod job starts scraping the very same
pods as well. Every series then exists twice under two job labels — no error
anywhere, just doubled cardinality and storage.

These belong in the *pod* template. An annotation on the DaemonSet object is
not propagated to its pods, so `role: pod` discovery would never see it.
*/}}
{{- define "nodevitals.scrapeAnnotations" -}}
{{- if .Values.scrapeAnnotations.enabled -}}
prometheus.io/scrape: "true"
prometheus.io/port: {{ .Values.metrics.port | default "9847" | quote }}
prometheus.io/path: "/metrics"
{{- end -}}
{{- end -}}

{{/*
hostNetwork for a pod spec. /proc/net resolves against the *reading task's*
network namespace, not the mounted path — so a pod-network container reading
Expand Down
1 change: 1 addition & 0 deletions deploy/chart/templates/daemonset-gpu.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ spec:
app.kubernetes.io/component: gpu
annotations:
{{- include "nodevitals.configChecksums" (dict "ctx" . "tier" "gpu") | nindent 8 }}
{{- include "nodevitals.scrapeAnnotations" . | nindent 8 }}
spec:
automountServiceAccountToken: false
{{- with .Values.tiers.gpu.runtimeClassName }}
Expand Down
1 change: 1 addition & 0 deletions deploy/chart/templates/daemonset-single.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ spec:
app.kubernetes.io/component: single
annotations:
{{- include "nodevitals.configChecksums" (dict "ctx" . "tier" "single") | nindent 8 }}
{{- include "nodevitals.scrapeAnnotations" . | nindent 8 }}
spec:
automountServiceAccountToken: false
{{- include "nodevitals.hostNetwork" . | nindent 6 }}
Expand Down
1 change: 1 addition & 0 deletions deploy/chart/templates/daemonset-smart.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ spec:
app.kubernetes.io/component: smart
annotations:
{{- include "nodevitals.configChecksums" (dict "ctx" . "tier" "smart") | nindent 8 }}
{{- include "nodevitals.scrapeAnnotations" . | nindent 8 }}
spec:
automountServiceAccountToken: false
containers:
Expand Down
1 change: 1 addition & 0 deletions deploy/chart/templates/daemonset.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ spec:
app.kubernetes.io/component: core
annotations:
{{- include "nodevitals.configChecksums" (dict "ctx" . "tier" "core") | nindent 8 }}
{{- include "nodevitals.scrapeAnnotations" . | nindent 8 }}
spec:
automountServiceAccountToken: false
{{- include "nodevitals.hostNetwork" . | nindent 6 }}
Expand Down
21 changes: 17 additions & 4 deletions deploy/chart/tests/compatibility-check.sh
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,23 @@ CHART_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
echo "=== 1. Checking default template rendering ==="
rendered="$(helm template nodevitals "$CHART_DIR")"

echo "=== 2. Checking vmagent auto-discovery annotations ==="
echo "$rendered" | grep -q 'prometheus.io/scrape: "true"' || { echo "FAIL: missing prometheus.io/scrape annotation"; exit 1; }
echo "$rendered" | grep -q 'prometheus.io/port: "9847"' || { echo "FAIL: missing prometheus.io/port annotation"; exit 1; }
echo "PASS: vmagent annotations present"
echo "=== 2. Checking vmagent auto-discovery annotations are opt-in ==="
# 기본 렌더에는 없어야 한다. 이 차트를 이미 Service/ServiceMonitor 로 수집하던
# 클러스터에서는, 업그레이드만으로 role:pod 잡이 같은 파드를 한 벌 더 긁기
# 시작해 모든 시리즈가 2벌이 된다 — 에러 없이, 청구서와 카디널리티로만 드러난다.
if echo "$rendered" | grep -q 'prometheus.io/scrape'; then
echo "FAIL: scrape annotations render by default; a chart user already scraping via Service would silently double-collect after an upgrade"
exit 1
fi
echo "PASS: no scrape annotations unless asked for"

# 켠 경우에는 **파드 템플릿 안**이어야 한다. DaemonSet 객체에 붙은 annotation 은
# 파드로 전파되지 않으므로 role:pod 발견은 그것을 영영 보지 못한다.
disc_rendered="$(helm template nodevitals "$CHART_DIR" --set scrapeAnnotations.enabled=true)"
pod_meta="$(echo "$disc_rendered" | awk '/^ template:/,/^ spec:/')"
echo "$pod_meta" | grep -q 'prometheus.io/scrape: "true"' || { echo "FAIL: scrape annotation is not inside the pod template; role:pod discovery cannot see it"; exit 1; }
echo "$pod_meta" | grep -q 'prometheus.io/port: "9847"' || { echo "FAIL: port annotation is not inside the pod template"; exit 1; }
echo "PASS: scrape annotations land in the pod template when enabled"

echo "=== 3. Checking gpu-operator & dcgmCompat rendering ==="
gpu_rendered="$(helm template nodevitals "$CHART_DIR" --set tiers.gpu.enabled=true --set tiers.gpu.runtimeClassName=nvidia --set dcgmCompat.enabled=true)"
Expand Down
11 changes: 11 additions & 0 deletions deploy/chart/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,17 @@ webhooks: []
metrics:
port: 9847

# Prometheus 파드 어노테이션 발견(prometheus.io/scrape 등). ServiceMonitor CRD 가
# 없고 스크레이프 설정이 `role: pod` 잡으로 대상을 잡는 클러스터(vmagent 의
# kubernetes-pods 등)에서 켠다. 어노테이션은 파드 템플릿에 렌더된다 — DaemonSet
# 객체에 붙이면 파드로 전파되지 않아 role:pod 발견이 보지 못한다.
#
# serviceMonitor 와 마찬가지로 기본 off 다. 발견 경로가 스스로 켜지면, 이미 Service
# 로 수집하던 클러스터가 업그레이드만으로 같은 파드를 두 잡에서 긁게 되고, 모든
# 시리즈가 job 라벨만 다른 2벌이 된다 — 에러는 어디에도 나지 않는다.
scrapeAnnotations:
enabled: false

# Prometheus Operator discovery. Off by default (requires the ServiceMonitor
# CRD). When enabled, renders a headless Service + ServiceMonitor so the
# /metrics endpoint of every nodevitals pod is scraped out of the box.
Expand Down
32 changes: 22 additions & 10 deletions docs/COMPATIBILITY.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# nodevitals — 서비스 전수 호환성 및 연동 명세서 (Compatibility Matrix)

> 저장소: [`github.com/KeiaiLab/nodevitals`](https://github.com/KeiaiLab/nodevitals)
> 기준 버전: `v0.8.5` (Chart v0.8.6)
> 기준 버전: `v0.9.1` (Chart v0.9.1)
> 최종 검증 일시: 2026년 8월 12일

본 문서는 `nodevitals`가 연동되는 주요 인프라 서비스, 관측 플랫폼, GPU 오퍼레이터, 가상머신(VM) 환경 간의 명시적 호환성 계약(Compatibility Contract)과 실측 검증 결과를 제공합니다.
Expand All @@ -13,7 +13,7 @@
| 연동 대상 서비스 / 솔루션 | 호환성 상태 | 연동 메커니즘 & 수집 방식 | 비고 / 주요 구성 |
|---|---|---|---|
| **NVIDIA GPU-Operator** | **100% 호환 (DCGM 대체)** | `dcgmCompat.enabled: true` | `dcgmExporter.enabled: false` 설정 후 `DCGM_FI_*` 18개 메트릭 승계 |
| **VictoriaMetrics (vmagent)** | **100% 호환 (자동 탐지)** | Pod Annotation (`prometheus.io/scrape: "true"`) | `vmagent` kubernetes-pods 잡 자동 수집 (`port: 9847`) |
| **VictoriaMetrics (vmagent)** | **호환 (opt-in 자동 탐지)** | Pod Annotation — `scrapeAnnotations.enabled: true` 필요 (기본 `false`) | `vmagent` kubernetes-pods 잡 자동 수집 (`port: 9847`). **Service/ServiceMonitor 로 이미 수집 중이면 켜지 말 것 — 이중 수집** (§2.2) |
| **VictoriaMetrics (vmsingle/cluster)** | **100% 호환** | TSDB Scrape & Remote Write | Prometheus TSDB 1.0 표준 데이터 100% 수용 |
| **Prometheus Operator / Alertmanager** | **100% 호환** | `/metrics` + Service/PodMonitor | `node_*`, `DCGM_FI_*`, `smartctl_*` 기존 알림 룰 그대로 동작 |
| **Grafana Dashboard Stack** | **100% 호환** | PromQL 드롭인 쿼리 | 기존 `node_exporter`, `dcgm`, `smartctl` 전용 대시보드 변경 0 |
Expand Down Expand Up @@ -48,16 +48,28 @@
### 2.2 VictoriaMetrics (`vmagent`) 연동
`keiailab-platform`과 같이 Prometheus Operator CRD 대신 `vmagent` 정적 수집 스택을 사용하는 환경의 호환성입니다.

- **자동 발견 어노테이션 (Auto-Discovery Pod Annotations)**:
`nodevitals` 파드 템플릿에 아래 어노테이션이 기본 렌더링됩니다:
- **자동 발견 어노테이션 (Auto-Discovery Pod Annotations)** — **opt-in 입니다**:
```yaml
metadata:
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: "9847"
prometheus.io/path: "/metrics"
scrapeAnnotations:
enabled: true # 기본값 false
```
- **`vmagent` 수집 동기화**: `vmagent`의 `kubernetes-pods` 메트릭 수집 작업이 해당 어노테이션을 감지하여 별도의 CRD 등록 없이 즉시 `/metrics` 수집을 시작합니다.
켜면 파드 템플릿(`spec.template.metadata.annotations`)에 아래가 렌더됩니다:
```yaml
prometheus.io/scrape: "true"
prometheus.io/port: "9847"
prometheus.io/path: "/metrics"
```
DaemonSet 객체가 아니라 **파드 템플릿**이어야 합니다 — 객체의 어노테이션은 파드로 전파되지 않아
`role: pod` 발견이 영영 보지 못합니다.

- **`vmagent` 수집 동기화**: 켜면 `vmagent`의 `kubernetes-pods`(`role: pod`) 작업이 어노테이션을 감지해
CRD 등록 없이 즉시 `/metrics` 를 수집합니다.

> [!WARNING]
> **이미 Service / ServiceMonitor 로 수집 중이라면 켜지 마십시오.** `kubernetes-service-endpoints`
> 계열 작업이 같은 파드를 이미 긁고 있는 상태에서 이것을 켜면, 동일한 시리즈가 `job` 라벨만 다른
> **2벌**로 저장됩니다. 오류는 어디에도 나지 않고 카디널리티와 저장량만 두 배가 되므로,
> 수집 경로는 **하나만** 켜 두십시오. 기본값이 `false` 인 이유가 이것입니다.

### 2.3 Standalone Linux VM / 베어메탈 호스트 연동
Kubernetes 클러스터 외부의 독립 Linux 가상머신(VM) 또는 베어메탈 전용 장비에서의 기동 가이드입니다.
Expand Down
7 changes: 6 additions & 1 deletion internal/collector/heartbeat.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,14 @@ type heartbeatCollector struct {
}

// NewHeartbeat returns a collector that emits nodevitals_up and nodevitals_build_info.
//
// An un-injected version becomes "unknown", never a release number. This metric
// is the only thing that can answer "which build is actually running on this
// node", so a plausible-looking default would take that answer away: the 0.9.0
// image reported version="0.8.5" for exactly this reason.
func NewHeartbeat(node, version string) Collector {
if version == "" {
version = "0.8.5"
version = "unknown"
}
return &heartbeatCollector{node: node, version: version}
}
Expand Down
Loading
Loading