feat(gpu): новый коллектор GPU для Linux (nvidia-smi/rocm-smi); добавлен в сборку и config

This commit is contained in:
Sergey Antropoff 2025-09-08 17:41:45 +03:00
parent 2e87580e84
commit 10b79a14bb
5 changed files with 258 additions and 2 deletions

View File

@ -46,7 +46,7 @@ collectors:
else \ else \
docker run --rm -v $$PWD:/workspace -w /workspace \ docker run --rm -v $$PWD:/workspace -w /workspace \
-e GOOS=linux -e GOARCH=amd64 -e GOCACHE=/workspace/.cache/go-build -e GOMODCACHE=/workspace/.cache/go-mod golang:1.22 \ -e GOOS=linux -e GOARCH=amd64 -e GOCACHE=/workspace/.cache/go-build -e GOMODCACHE=/workspace/.cache/go-mod golang:1.22 \
sh -c "go mod tidy >/dev/null 2>&1 && CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/uptime ./src/collectors/uptime && CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/macos ./src/collectors/macos && CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/system ./src/collectors/system && CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/hba ./src/collectors/hba && CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/sensors ./src/collectors/sensors && CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/docker ./src/collectors/docker"; \ sh -c "go mod tidy >/dev/null 2>&1 && CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/uptime ./src/collectors/uptime && CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/macos ./src/collectors/macos && CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/system ./src/collectors/system && CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/hba ./src/collectors/hba && CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/sensors ./src/collectors/sensors && CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/docker ./src/collectors/docker && CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/gpu ./src/collectors/gpu"; \
fi fi
@# Убедимся, что скрипты исполняемые @# Убедимся, что скрипты исполняемые
@chmod +x ./bin/agent/collectors/*.sh 2>/dev/null || true @chmod +x ./bin/agent/collectors/*.sh 2>/dev/null || true
@ -61,7 +61,7 @@ collectors-linux:
# Кросс-сборка коллекторов для Linux # Кросс-сборка коллекторов для Linux
@mkdir -p ./bin/agent/collectors .cache/go-build .cache/go-mod; \ @mkdir -p ./bin/agent/collectors .cache/go-build .cache/go-mod; \
docker run --rm -v $$PWD:/workspace -w /workspace -e GOOS=linux -e GOARCH=amd64 -e GOCACHE=/workspace/.cache/go-build -e GOMODCACHE=/workspace/.cache/go-mod golang:1.22 \ docker run --rm -v $$PWD:/workspace -w /workspace -e GOOS=linux -e GOARCH=amd64 -e GOCACHE=/workspace/.cache/go-build -e GOMODCACHE=/workspace/.cache/go-mod golang:1.22 \
sh -c "go mod tidy >/dev/null 2>&1 && CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/uptime ./src/collectors/uptime && CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/macos ./src/collectors/macos && CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/system ./src/collectors/system && CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/hba ./src/collectors/hba && CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/sensors ./src/collectors/sensors && CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/docker ./src/collectors/docker" sh -c "go mod tidy >/dev/null 2>&1 && CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/uptime ./src/collectors/uptime && CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/macos ./src/collectors/macos && CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/system ./src/collectors/system && CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/hba ./src/collectors/hba && CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/sensors ./src/collectors/sensors && CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/docker ./src/collectors/docker && CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/gpu ./src/collectors/gpu"
collectors-windows: collectors-windows:
# Кросс-сборка коллекторов для Windows # Кросс-сборка коллекторов для Windows

View File

@ -69,6 +69,14 @@ collectors:
timeout: "20s" timeout: "20s"
exec: "./collectors/docker" exec: "./collectors/docker"
platforms: [darwin, linux] platforms: [darwin, linux]
gpu:
enabled: true
type: exec
key: gpu
interval: "30s"
timeout: "8s"
exec: "./collectors/gpu"
platforms: [linux]

View File

@ -0,0 +1,184 @@
//go:build linux
package main
// Автор: Сергей Антропов, сайт: https://devops.org.ru
// Linux-реализация gpu: NVIDIA через nvidia-smi, AMD через rocm-smi.
import (
"context"
"encoding/json"
"os/exec"
"fmt"
"strconv"
"strings"
)
// collectGPU возвращает данные в формате {"gpu": [ {...}, ... ]}
func collectGPU(ctx context.Context) (map[string]any, error) {
// Сначала пробуем NVIDIA
if exists("nvidia-smi") {
if arr := collectNvidia(ctx); len(arr) > 0 {
return map[string]any{"gpu": arr}, nil
}
}
// Затем AMD ROCm
if exists("rocm-smi") {
if arr := collectRocm(ctx); len(arr) > 0 {
return map[string]any{"gpu": arr}, nil
}
}
return map[string]any{"gpu": []any{}}, nil
}
// collectNvidia парсит вывод nvidia-smi в csv,noheader,nounits
func collectNvidia(ctx context.Context) []map[string]any {
q := "nvidia-smi --query-gpu=index,name,driver_version,memory.total,memory.used,utilization.gpu,utilization.memory,temperature.gpu,power.draw --format=csv,noheader,nounits"
out, err := run(ctx, "sh", "-c", q+" 2>/dev/null")
if err != nil || strings.TrimSpace(out) == "" { return nil }
lines := strings.Split(strings.TrimSpace(out), "\n")
res := make([]map[string]any, 0, len(lines))
for _, line := range lines {
parts := splitCSV(line)
if len(parts) < 9 { continue }
item := map[string]any{}
item["id"] = atoi(parts[0])
item["name"] = parts[1]
item["driver_version"] = parts[2]
item["mem_total_mb"] = atoi(parts[3])
item["mem_used_mb"] = atoi(parts[4])
item["gpu_util_pct"] = atoi(parts[5])
item["mem_util_pct"] = atoi(parts[6])
item["temperature_c"] = atoi(parts[7])
item["power_watt"] = atof(parts[8])
res = append(res, item)
}
return res
}
// collectRocm пытается получить данные через rocm-smi в формате JSON
func collectRocm(ctx context.Context) []map[string]any {
// Предпочитаем JSON-вывод, чтобы избежать парсинга неструктурированного текста
// Набор метрик: id, name, driver_version, vram total/used, gpu/mem util, temp, power
out, err := run(ctx, "sh", "-c", "rocm-smi --showid --showproductname --showdriverversion --showmeminfo vram --showuse --showtemp --showpower --json 2>/dev/null")
if err != nil || strings.TrimSpace(out) == "" { return nil }
// Ответ обычно представляет собой JSON-объект с ключами по картам (card0, card1 ... или "card": {"0": {...}})
// Используем динамический парсинг
var m map[string]any
if e := json.Unmarshal([]byte(out), &m); e != nil { return nil }
// Попробуем разные формы структуры
cards := []map[string]any{}
if cardMap, ok := m["card"].(map[string]any); ok {
for k, v := range cardMap {
if obj, ok2 := v.(map[string]any); ok2 { cards = append(cards, normalizeRocmCard(k, obj)) }
}
} else {
// некоторые версии отдают плоские ключи card0, card1
for k, v := range m {
if strings.HasPrefix(strings.ToLower(k), "card") {
if obj, ok2 := v.(map[string]any); ok2 { cards = append(cards, normalizeRocmCard(strings.TrimPrefix(strings.ToLower(k), "card"), obj)) }
}
}
}
// Удаляем пустые
res := []map[string]any{}
for _, c := range cards {
if len(c) > 0 { res = append(res, c) }
}
return res
}
// normalizeRocmCard приводит карту полей rocm-smi к нашей схеме
func normalizeRocmCard(idx string, obj map[string]any) map[string]any {
item := map[string]any{}
if i := atoi(strings.TrimSpace(idx)); i >= 0 { item["id"] = i }
// Возможные ключи в разных версиях rocm-smi
// Имя
if v, ok := firstString(obj,
"Card series", "Device SKU", "Product", "Card model", "card_model", "Card Model"); ok { item["name"] = v }
// Версия драйвера
if v, ok := firstString(obj, "Driver version", "driver_version"); ok { item["driver_version"] = v }
// Память
// Некоторые версии дают байты: "VRAM Total Memory (B)", "VRAM Used Memory (B)"
if tStr, ok := firstString(obj, "VRAM Total Memory (B)", "vram_total"); ok {
t := atoui64(tStr)
if t > 0 { item["mem_total_mb"] = t / (1024 * 1024) }
}
if uStr, ok := firstString(obj, "VRAM Used Memory (B)", "vram_used"); ok {
u := atoui64(uStr)
if u > 0 { item["mem_used_mb"] = u / (1024 * 1024) }
}
// Загрузка ядра/памяти
if v, ok := firstString(obj, "GPU use (%)", "GPU use", "gpu_use"); ok { item["gpu_util_pct"] = atoi(v) }
if v, ok := firstString(obj, "GPU memory use (%)", "GPU memory use", "mem_use"); ok { item["mem_util_pct"] = atoi(v) }
// Температура
if v, ok := firstString(obj, "Temperature (Sensor die) (C)", "Temperature (Sensor edge) (C)", "temperature"); ok { item["temperature_c"] = atof(v) }
// Питание
if v, ok := firstString(obj, "Power (W)", "Average Graphics Package Power (W)", "power"); ok { item["power_watt"] = atof(v) }
return item
}
// exists проверяет наличие бинаря в $PATH
func exists(bin string) bool {
_, err := exec.LookPath(bin)
return err == nil
}
// splitCSV аккуратно разделяет CSV без кавычек (nvidia-smi выводит простой csv)
func splitCSV(line string) []string {
parts := strings.Split(line, ",")
for i := range parts { parts[i] = strings.TrimSpace(parts[i]) }
return parts
}
func atoi(s string) int {
s = strings.TrimSpace(s)
i, _ := strconv.Atoi(s)
return i
}
func atoui64(s string) uint64 {
s = strings.TrimSpace(s)
// удаляем возможные единицы измерения
s = strings.TrimSuffix(s, "B")
v, _ := strconv.ParseUint(s, 10, 64)
return v
}
func atof(s string) float64 {
s = strings.TrimSpace(s)
f, _ := strconv.ParseFloat(s, 64)
return f
}
func run(ctx context.Context, bin string, args ...string) (string, error) {
cmd := exec.CommandContext(ctx, bin, args...)
b, err := cmd.Output()
if err != nil { return "", err }
return string(b), nil
}
// firstString возвращает первое непустое строковое представление значения по перечню ключей
func firstString(m map[string]any, keys ...string) (string, bool) {
for _, k := range keys {
if v, ok := m[k]; ok {
switch t := v.(type) {
case string:
s := strings.TrimSpace(t)
if s != "" { return s, true }
case float64:
// JSON числа парсятся в float64
return strings.TrimSpace(fmt.Sprintf("%v", t)), true
case int, int32, int64, uint64, uint32, uint8:
return strings.TrimSpace(fmt.Sprintf("%v", t)), true
case map[string]any:
// Иногда значение вложено; попытаемся взять common поле value
if s, ok2 := firstString(t, "value", "Value"); ok2 { return s, true }
}
}
}
return "", false
}

View File

@ -0,0 +1,16 @@
//go:build !linux
package main
// Автор: Сергей Антропов, сайт: https://devops.org.ru
// Заглушка для неподдерживаемых платформ: возвращает пустой массив gpu.
import (
"context"
)
func collectGPU(ctx context.Context) (map[string]any, error) {
return map[string]any{"gpu": []any{}}, nil
}

View File

@ -0,0 +1,48 @@
package main
// Автор: Сергей Антропов, сайт: https://devops.org.ru
// Коллектор gpu. Собирает информацию о видеокартах NVIDIA (через nvidia-smi)
// и AMD (через rocm-smi). Возвращает массив видеокарт под ключом "gpu".
import (
"context"
"encoding/json"
"fmt"
"os"
"strings"
"time"
)
// collectGPU реализуется платформенно.
func main() {
// Таймаут можно переопределить окружением COLLECTOR_TIMEOUT
timeout := parseDurationOr("COLLECTOR_TIMEOUT", 8*time.Second)
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
data, err := collectGPU(ctx)
if err != nil || data == nil {
// Всегда возвращаем пустой блок gpu, если нет данных
fmt.Println("{\"gpu\":[]}")
return
}
// Если ключ gpu отсутствует, нормализуем к пустому массиву
if _, ok := data["gpu"]; !ok {
data["gpu"] = []any{}
}
enc := json.NewEncoder(os.Stdout)
enc.SetEscapeHTML(false)
_ = enc.Encode(data)
}
// parseDurationOr парсит длительность из переменной окружения или возвращает дефолт
func parseDurationOr(env string, def time.Duration) time.Duration {
v := strings.TrimSpace(os.Getenv(env))
if v == "" { return def }
d, err := time.ParseDuration(v)
if err != nil { return def }
return d
}