Настроить GPU-экспортеры? Легче создать свой или «как подружить экспортер Nvidia-smi-exporter с Pod…
- суббота, 8 марта 2025 г. в 00:00:11
Привет! Меня зовут Настя Бережная, я – DevOps-инженер, и в этой статье я расскажу о том, как мы пробовали использовать для решения своей задачи экспортеры Nvidia DCGM-Exporter и nvidia-gpu-exporter. Но после некоторых скитаний по документациям, форумам и попытками настроить экспортер малой кровью, было решено создать свой.
Начнем с самого начала. К нашей команде пришел бизнес на первый взгляд с достаточно тривиальной задачей – реализовать отслеживание ресурсов видеопамяти в разрезе приложения. Важно было увидеть, сколько GPU-памяти потребляет каждый из Pod'ов Jupyter, которые, в свою очередь, развернуты у нас в Kubernetes.
Первым делом было решено искать уже готовые решения, потому что задача звучит достаточно ходовой – отслеживать ресурсы в рамках Pod'ов умеют многие экспортеры. У Nvidia есть официальный экспортер DCGM-Exporter (документация которого даже упоминает эту возможность) и неофициальный, но достаточно популярный – nvidia-gpu-exporter. В нем на первый взгляд нет упоминания того, что нам нужно, но мы решили попробовать копнуть немного глубже, если не получится с DCGM-Exporter, ну а вдруг?
Как я упомянула ранее, первым делом взор упал на DCGM-Exporter. Все-таки официальный софт от Nvidia с какой-никакой документацией и, судя по описанию, целым букетом возможностей. Но нас интересуют метрики потребления GPU-memory by Pod.
Что написано в документации по этой теме: в блоке DCGM-Exporter Customization есть табличка с переменными среды для настройки поведения DCGM-Exporter по умолчанию, в табличке можно увидеть переменную $DCGM_EXPORTER_KUBERNETES
, которая включает механизм сопоставления метрик k8s с Pod'ми. По умолчанию он выключен: Enable kubernetes mapping metrics to kubernetes pods. Default: false. Отмечу, хотя явно в документации этого не сказано, есть намек на то, что должен быть установлен nvidia-device-plugin, который позволяет взаимодействовать Pod'ам с GPU ресурсами. Приведу пример метрики с существующими label:
DCGM_FI_DEV_GPU_UTIL{gpu="",UUID="",device="",modelName="",Hostname="",DCGM_FI_DRIVER_VERSION="",container="",namespace="",pod=""}
Из примера видно, что есть label pod, но по документации нет однозначности, что должен содержать этот label: наименование Pod'а, генерирующего рабочую нагрузку или наименование Pod'а экспортера. Спойлер: по умолчанию – Pod'а экспортера. На момент написания статьи, информации о настройке вывода наименования Pod'а, генерирующего рабочую нагрузку, в документации не было. Оставалось обращаться к многочисленным форумам. Там действительно можно обнаружить эту проблему, но решения у нее достаточно разные и работают не у всех. Можно отметить самые популярные такие:
Добавить переменные среды в экспортер
extraEnv:
- name: "DCGM_EXPORTER_KUBERNETES"
value: "true"
- name: "DCGM_EXPORTER_KUBERNETES_GPU_ID_TYPE"
value: "device-name"
или
extraEnv:
- name: "DCGM_EXPORTER_KUBERNETES"
value: "true"
- name: "DCGM_EXPORTER_KUBERNETES_GPU_ID_TYPE"
value: "uid"
Не забыть про установку nvidia-device-plugin в k8s кластер
Не забыть про установку nvidia-device-plugin в k8s кластер
Убедиться, что kubelet, device-plugin, dcgm-exporter смотрят в одну директорию
Добавить в ServiceMonitor флаг honorLabels: true, дабы разрешить возможные конфликты (HonorLabels chooses the metric’s labels on collisions with target labels)
К сожалению, нам эти советы не помогли. Допускаю, что есть еще сценарии, подобные нашему, при которых возникают похожие проблемы. После того как мы поняли, что тут можно закопаться надолго и с большой вероятностью не найти решение проблемы, решили посмотреть в сторону собственного экспортера, предварительно заглянув в git к другим. Чаще всего быстрее и проще взять за основу уже что-то готовое и доработать под собственные нужды. К счастью, был найден проект Nvidia smi exporter, который уже выполняет часть задачи, а именно формирует метрики видеокарты, основываясь на данных инструмента nvidia-smi.
Экспортер Docker Prometheus Nvidia SMI Exporter (https://github.com/e7d/docker-prometheus-nvidiasmi/tree/master) формирует метрики, полученные инструментом nvidia-smi, а именно статистику по видеокарте и потребляемое процессами GPU. Простыми словами, экспортер выводит данные, формируемые инструментом nvidia-smi.
Инструмент nvidia-smi (The NVIDIA System Management Interface) – это утилита командной строки, основанная на библиотеке управления NVIDIA (NVML), предназначенная для управления и мониторинга устройств на базе графических процессоров NVIDIA.
Вывод команды nvidia-smi без аргументов, как быстрое знакомство:
[xxxxxxx@xxxxxxxx ~]$ nvidia-smi
+-----------------------------------------------------------------------------------------+
| NVIDIA-SMI 550.xx.xx Driver Version: 550.xx.xx CUDA Version: 12.x |
|-----------------------------------------+------------------------+----------------------+
| GPU Name Persistence-M | Bus-Id Disp.A | Volatile Uncorr. ECC |
| Fan Temp Perf Pwr:Usage/Cap | Memory-Usage | GPU-Util Compute M. |
| | | MIG M. |
|=========================================+========================+======================|
| 0 Tesla xxxxxxxxxxxxxx Off | 00000000:01:00.0 Off | 0 |
| N/A 29C P0 36W / 250W | 499MiB / xxxxxxxx | 0% Default |
| | | N/A |
+-----------------------------------------+------------------------+----------------------+
+-----------------------------------------------------------------------------------------+
| Processes: |
| GPU GI CI PID Type Process name GPU Memory |
| ID ID Usage |
|=========================================================================================|
| 0 N/A N/A 87068 C /opt/java/openjdk/bin/java 496MiB |
+-----------------------------------------------------------------------------------------+
Метрики выводятся в prometheus-формате; пример вывода возвращаемых показателей:
nvidiasmi_clock_policy_auto_boost{id="00000000:01:00.0",uuid="GPU-xxxxxxxxxxx",name="Tesla xxxxxxxxxxx"} 0
nvidiasmi_clock_policy_auto_boost_default{id="00000000:01:00.0",uuid="GPU-xxxxxxxxxxx",name="Tesla xxxxxxxxxxx"} 0
nvidiasmi_process_used_memory_bytes{id="00000000:01:00.0",uuid="GPU-xxxxxxxxxxx",name="Tesla xxxxxxxxxxx",process_pid="87068", process_name="/opt/java/openjdk/bin/java",process_type="C"} 5.20093696e+08
Можно заметить, что здесь нет label’a, который нам нужен – имя Pod'а, но есть process_pid
, который использует ресурсы видеокарты и через который в дальнейшем можно будет «вытащить» имя Pod'а. Это и нужно доработать.
Экспортер Nvidia-smi-exporter создан для просмотра метрик c помощью инструмента nvidia-smi. Экспортер выводит основные общие метрики по видеокарте + GPU memory в разрезе Pod'ов, создающих рабочую нагрузку.
Общий принцип работы доработанного Nvidia-smi-exporter
Экспортер работает как сервис на каждом worker-узле кластера, который содержит видеокарту. Он формирует xml-файл через утилиту nvidia-smi с основными характеристиками карты и выполняющимися на ней процессами на момент вызова.
nvidia-smi – полезный инструмент, он выводит нагрузку GPU-memory для каждого процесса, использующего видеокарту. Так как Pod тоже порождает процессы, мы можем «вытащить» его данные через cgroup.
Экспортер парсит полученный ранее от nvidia-smi XML-файл и формирует метрики в формате Prometheus.
При формировании метрик для каждого процесса из XML-файла вызывается новая функция LookupPod
, о которой далее будет сказано более подробно. Функция на вход получает ProcessId, просматривает cgroup процесса и вытаскивает из него containerID по заранее созданному паттерну. После этого функция ищет имя контейнера.
Поиск наименования контейнера зависит от системы контейнеризации в кластере. В нашем случае актуально было настроить поиск для docker и containerd, так как у нас имеются кластеры, работающие на этих двух системах. Соответственно, в первом случае поиск происходит через docker inspect
, а во втором – через runc
.
Настало время кода. Было решено писать на Go, так как исходный код тоже написан на Go :) Некоторые вещи тут можно оптимизировать, но, мне кажется, для лучшего восприятия и прослеживания логики действий стоит оставить более подробный код. А теперь по порядку:
Как я уже упоминала, первый шаг – это получить данные от nvidia-smi в формате xml. Для этого создана функция metrics
. Рассмотрим ее по частям, так как эта функция также парсит и формирует метрики в Prometheus-формате.
Формирование XML-файла происходит с помощью команды nvidia-smi -q -x
, в коде за это отвечает:
cmd = exec.Command(NVIDIA_SMI_PATH, "-q", "-x")
В исходном коде NVIDIA_SMI_PATH
задана как константа, что не выглядит правильным решением для динамического подхода. При изменении пути не хочется пересобирать код, поэтому вынесем путь в переменную среды NVIDIA_SMI_PATH
и будем брать оттуда
NVIDIA_SMI_PATH, exists_path := os.LookupEnv("NVIDIA_SMI_PATH")
if exists_path == false {
NVIDIA_SMI_PATH = "/usr/bin/nvidia-smi"
}
В случае если переменная среды не задана, остается путь по умолчанию. Соберем эту часть:
func metrics(w http.ResponseWriter, r *http.Request) {
log.Print("Serving /metrics")
var cmd *exec.Cmd
NVIDIA_SMI_PATH, exists_path := os.LookupEnv("NVIDIA_SMI_PATH")
if exists_path == false {
NVIDIA_SMI_PATH = "/usr/bin/nvidia-smi"
}
log.Print("Path to nvidia-smi: ", NVIDIA_SMI_PATH)
cmd = exec.Command(NVIDIA_SMI_PATH, "-q", "-x")
// Execute system command
stdout, err := cmd.Output()
if err != nil {
println(err.Error())
if testMode != "1" {
println("Something went wrong with the execution of nvidia-smi")
}
return
}
Парсинг. Структурируем полученную информацию. Код для Unmarshal выглядит следующим образом:
var xmlData NvidiaSmiLog
xml.Unmarshal(stdout, &xmlData)
Для парсинга используется пакет encoding/xml.
Go-структура, которая описывает полученный ранее XML-файл:
type NvidiaSmiLog struct {
DriverVersion string `xml:"driver_version"`
CudaVersion string `xml:"cuda_version"`
AttachedGPUs string `xml:"attached_gpus"`
GPU []struct {
Id string `xml:"id,attr"`
ProductName string `xml:"product_name"`
ProductBrand string `xml:"product_brand"`
DisplayMode string `xml:"display_mode"`
DisplayActive string `xml:"display_active"`
PersistenceMode string `xml:"persistence_mode"`
AccountingMode string `xml:"accounting_mode"`
AccountingModeBufferSize string `xml:"accounting_mode_buffer_size"`
DriverModel struct {
CurrentDM string `xml:"current_dm"`
PendingDM string `xml:"pending_dm"`
} `xml:"driver_model"`
Serial string `xml:"serial"`
UUID string `xml:"uuid"`
//more code…
PCI struct {
Bus string `xml:"pci_bus"`
Device string `xml:"pci_device"`
//more code…
} `xml:"pci"`
FanSpeed string `xml:"fan_speed"`
PerformanceState string `xml:"performance_state"`
FbMemoryUsage struct {
Total string `xml:"total"`
Used string `xml:"used"`
Free string `xml:"free"`
} `xml:"fb_memory_usage"`
//more code…
Processes struct {
ProcessInfo []struct {
Pid string `xml:"pid"`
Type string `xml:"type"`
ProcessName string `xml:"process_name"`
UsedMemory string `xml:"used_memory"`
} `xml:"process_info"`
} `xml:"processes"`
} `xml:"gpu"`
}
Далее формируем метрики на основе полученных ранее данных:
for _, GPU := range xmlData.GPU {
io.WriteString(w, formatVersion("nvidiasmi_driver_version", "id=\""+GPU.Id+"\",uuid=\""+GPU.UUID+"\",name=\""+GPU.ProductName+"\"", xmlData.DriverVersion))
io.WriteString(w, formatVersion("nvidiasmi_cuda_version", "id=\""+GPU.Id+"\",uuid=\""+GPU.UUID+"\",name=\""+GPU.ProductName+"\"", xmlData.CudaVersion))
//more code...
if GPU.PowerReadings.PowerState != "" { // older nvidia-smi versions
io.WriteString(w, formatValue("nvidiasmi_power_state_int", "id=\""+GPU.Id+"\",uuid=\""+GPU.UUID+"\",name=\""+GPU.ProductName+"\"", filterNumber(GPU.PowerReadings.PowerState)))
//more code...
if GPU.GpuPowerReadings.PowerState != "" { // newer nvidia-smi versions
// retrocompatible format
io.WriteString(w, formatValue("nvidiasmi_power_state_int", "id=\""+GPU.Id+"\",uuid=\""+GPU.UUID+"\",name=\""+GPU.ProductName+"\"", filterNumber(GPU.GpuPowerReadings.PowerState)))
//more code...
io.WriteString(w, formatValue("nvidiasmi_clock_policy_auto_boost", "id=\""+GPU.Id+"\",uuid=\""+GPU.UUID+"\",name=\""+GPU.ProductName+"\"", filterUnit(GPU.ClockPolicy.AutoBoost)))
io.WriteString(w, formatValue("nvidiasmi_clock_policy_auto_boost_default", "id=\""+GPU.Id+"\",uuid=\""+GPU.UUID+"\",name=\""+GPU.ProductName+"\"", filterUnit(GPU.ClockPolicy.AutoBoostDefault)))
for _, Process := range GPU.Processes.ProcessInfo {
podName := LookupPod(Process.Pid)
io.WriteString(w, formatValue("nvidiasmi_process_used_memory_bytes", "id=\""+GPU.Id+"\",uuid=\""+GPU.UUID+"\",name=\""+GPU.ProductName+"\",process_pid=\""+Process.Pid+"\", pod_name=\""+podName+"\",process_name=\""+Process.ProcessName+"\",process_type=\""+Process.Type+"\"", filterUnit(Process.UsedMemory)))
}
}
}
На этом завершается функция metrics
. Однако в этой функции нас еще интересуют последние строчки. Остановимся на них подробнее. Тут у нас запущен цикл, который формирует метрику nvidiasmi_process_used_memory_bytes
по каждому процессу.
for _, Process := range GPU.Processes.ProcessInfo {
podName := LookupPod(Process.Pid)
io.WriteString(w, formatValue("nvidiasmi_process_used_memory_bytes", "id=\""+GPU.Id+"\",uuid=\""+GPU.UUID+"\",name=\""+GPU.ProductName+"\",process_pid=\""+Process.Pid+"\", pod_name=\""+podName+"\",process_name=\""+Process.ProcessName+"\",process_type=\""+Process.Type+"\"",filterUnit(Process.UsedMemory)))
}
Вспоминаем нашу первоначальную цель: нужно вытащить имя Pod'а и получать данные GPU-memory относительно него. Внутри Pod'а крутится контейнер, которому соответствует ProcessId (PId). Для сопоставления PId и названия Pod'а в этом фрагменте для каждого процесса вызывается новая функция LookupPod podName := LookupPod(
Process.Pid
)
, которой на вход подается ProcessId, а на выходе мы получаем от нее имя Pod'а.
func LookupPod(pid string) (string) {
log.Print("Pid:", pid)
f, err := os.Open(fmt.Sprintf("/proc/%s/cgroup", pid))
if err != nil {
log.Print(err)
return ""
}
defer f.Close()
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := scanner.Text()
log.Print("line:" , line)
cId := kubePattern.FindStringSubmatch(line)
log.Print("cId-test ", cId)
if cId == nil {
cId := kubePatternConD.FindStringSubmatch(line)
if cId == nil { fmt.Fprintln(os.Stderr, "Something wrong with pattern")
return "" }
log.Print("cId[2] ", cId[2])
fmt.Fprintln(os.Stderr, "cgroup by kuber (containerd)")
argCmd = fmt.Sprintf(`runc --root /run/containerd/runc/k8s.io/ state %s | grep '"io.kubernetes.cri.sandbox-name":' | sed 's/.*"io.kubernetes.cri.sandbox-name": "\|".*//g' `, cId[2])
} else {
log.Print("cId[2] ", cId[2])
fmt.Fprintln(os.Stderr, "cgroup by kuber (docker)")
argCmd = fmt.Sprintf(`docker inspect --format '{{index .Config.Labels "io.kubernetes.pod.name"}}' %s | tr -d '\n' `, cId[2])
}
log.Print("argCmd: ", argCmd)
out, err := exec.Command("bash","-c", argCmd ).Output()
if err != nil {
log.Print( err)
}
log.Print("Pod name: ", string(out))
podN := string(out)
return podN
}
return ""
}
Что здесь происходит: функция смотрит в cgroup процесса f, err :=
os.Open
(fmt.Sprintf("/proc/%s/cgroup", pid))
, далее, по заранее созданному паттерну внутри cgroup, находит СontainerId cId[2]
cId := kubePattern.FindStringSubmatch(line) //паттерн для docker
cId := kubePatternConD.FindStringSubmatch(line) //паттерн для containerd
после этого, с помощью команд в зависимости от системы контейнеризации (docker или conteinerd), находит и возвращает название Pod'а
out, err := exec.Command("bash","-c", argCmd ).Output()
для docker: argCmd := fmt.Sprintf(docker inspect --format '{{index .Config.Labels "io.kubernetes.pod.name"}}' %s | tr -d '\n' , cId[2])
для containerd: argCmd = fmt.Sprintf(runc --root /run/containerd/runc/k8s.io/ state %s | grep '"io.kubernetes.cri.sandbox-name":' | sed 's/.*"io.kubernetes.cri.sandbox-name": "\|".*//g' , cId[2])
Возвращаемся к заранее созданным паттернам. В нашем случае они выглядят так:
kubePattern = regexp.MustCompile(`\d+:.+:.*/(pod[^/]+)/([0-9a-f]{64})`)
kubePatternConD = regexp.MustCompile(`.*/(kubepods-burstable-.*)/cri-containerd-(.*).scope`)
Исходный вид строчки из cgroup:
------- docker -------
11:blkio:/kubepods/pod387d67a0-2e0a-4418-bacb-435kk85a1b37/bbb083cc31864b731b273ab74628e7hdfg85d7cd1224391dee7c62b69201dbc6
-------containerd-----
0::/kubepods.slice/kubepods-burstable.slice/kubepods-burstable-pod6ee8558e_a273_417d_9a57_57f3c7773bd4.slice/cri-containerd-2cbbbe2a9f38fa2255006fd6faed661a55f92bf6f924aae08261634jbv5ff0f67.scope
В нашем случае containerId будут такими (берем примеры из приведенных выше строчек из cgroup):
Для docker cId[2]=bbb083cc31864b731b273ab74628e7hdfg85d7cd1224391dee7c62b69201dbc6
для containerdcId[2]= 2cbbbe2a9f38fa2255006fd6faed661a55f92bf6f924aae08261634jbv5ff0f67
Для поиска конечного нейминга Pod'а используется команда docker inspect
или, для случая с containerd, runc
с дальнейшими преобразованиями по строке:
------- docker -------
docker inspect --format '{{index .Config.Labels "io.kubernetes.pod.name"}}' cId[2] | tr -d '\n'
------- containerd -----
runc --root /run/containerd/runc/k8s.io/ state cId[2] | grep '"io.kubernetes.cri.sandbox-name":' | sed 's/.*"io.kubernetes.cri.sandbox-name":"\|".*//g'
Таким образом, функция вытаскивает имя Pod'а и присваивает его переменной podName
, которая используется для получения значения нового label pod_name
в метрике nvidiasmi_process_used_memory_bytes
. Простыми словами, мы добавляем новый label pod_name
в метрику nvidiasmi_process_used_memory_bytes
для каждого существующего процесса, который использует ресурсы видеокарты.
io.WriteString(w, formatValue("nvidiasmi_process_used_memory_bytes", "id=\""+GPU.Id+"\",uuid=\""+GPU.UUID+"\",name=\""+GPU.ProductName+"\",process_pid=\""+Process.Pid+"\", pod_name=\""+podName+"\",process_name=\""+Process.ProcessName+"\",process_type=\""+Process.Type+"\"", filterUnit(Process.UsedMemory)))
Схема взаимодействия функций:
Для общей картины стоит также выделить функцию main:
func main() {
testMode = os.Getenv("TEST_MODE")
if testMode == "1" {
log.Print("Test mode is enabled")
}
LISTEN_ADDRESS, exists_port := os.LookupEnv("NVIDIASMI_EXP_PORT")
if exists_port == false {
LISTEN_ADDRESS = ":9202"
}
log.Print("Port: ", LISTEN_ADDRESS)
log.Print("Nvidia SMI exporter listening on " + LISTEN_ADDRESS)
http.HandleFunc("/", index)
http.HandleFunc("/metrics", metrics)
http.ListenAndServe(LISTEN_ADDRESS, nil)
}
В этом фрагменте вызываются отмеченные ранее функции, указывается дефолтный порт. Порт теперь тоже можно поменять через переменную среды NVIDIASMI_EXP_PORT
.
Сборка кода осуществляется командой:
go build -o ./<name_exporter> ./<path to file.go>
Установить можно по-разному, но в нашем случае экспортер установлен как сервис на каждом worker-узле, который имеет видеокарту (для этого создан ansible-плейбук).
Необходимые компоненты для работы экспортера на сервере:
Docker / containerd
Плагин nvidia-smi
Сам сервис выглядит таким образом:
[Unit]
Description=Nvidia-smi-exporter
ConditionPathExists=/opt/nvidiasmi-exporter
After=network.target
[Service]
Type=simple
User=root
WorkingDirectory=/opt/nvidiasmi-exporter
ExecStart=/opt/nvidiasmi-exporter/nvidiasmi-exporter
Restart=on-failure
RestartSec=10
StartLimitInterval=60
[Install]
WantedBy=multi-user.target
---- для docker---------
After=docker.service
Requires=docker.service
---- для containerd-----
After= containerd.service
Requires= containerd.service
Несмотря на то что в получившемся экспортере достаточно много метрик, нас интересует лишь nvidiasmi_process_used_memory_bytes
. Она показывает сколько GPU-memories потребляется каждым процессом, который был вызван Pod'ом. Задача стояла так: добавить график с метрикой потребления ресурсов уже в существующий дашборд, который активно использует команда. В нашем случае это https://grafana.com/grafana/dashboards/14574-nvidia-gpu-metrics/, он работает в паре с экспортером https://github.com/utkuozdemir/nvidia_gpu_exporter?tab=readme-ov-file, который у нас тоже стоит на кластере. И также нужно организовать вывод метрик относительно машины (by Node).
Как можно заметить из метрики, label’ов с названием машины у нас нет, думаю его тоже можно было бы добавить, но нам хватает текущих данных, так как мы можем привязаться к label uuid
.
nvidiasmi_process_used_memory_bytes{id="00000000:01:00.0",uuid="GPU-7b85cfd6-ed01-4492-208b-b8b8xxxxxxx",name="Tesla xxxxx",process_pid="87068", pod_name="jupyter-xxxxx",process_name="/opt/java/openjdk/bin/java",process_type="C"} 5.20093696e+08
Дашборд ранее был немного изменен: добавлена переменная Node, которая формирует список машин, к которой, в свою очередь, привязана переменная GPU. Тем самым всплывающий список переменной GPU формируется относительно переменной Node (GPU by Node). На скриншоте можно видеть, что для GPU включена опция Include All option, можно выбрать все и сразу.
Добавляем на дашборд новую панель с графиком и формируем такой запрос:
sum(nvidiasmi_process_used_memory_bytes{uuid=~"GPU-$gpu"}) by (pod_name)
Почему uuid=~"GPU-$gpu"
? Переменная GPU отдается экспортером nvidia_gpu_exporter. При создании Query для графика мы используем наш nvidia-smi-exporter, из-за того, что они имеют разные форматы отображения gpu (label uuid
) – нужно приспособить выражение, как пример:
----------nvidia_gpu_exporter-------------
{uuid="2f76546a-404c-0563-5f49-815xxxxxxx"}
----------nvidia-smi-exporter-------------
{uuid="GPU-2f76546a-404c-0563-5f49-815xxxxxxx"}
Sum
используем, так как одному Pod'у могут соответствовать несколько процессов.
Схема взаимодействия параметров и их источники:
Итоговый график stacked line chart (график с накоплением) будет выглядеть таким образом:
Дашборд готов, метрики снимаются, бизнес радуется.
Доработанная версия экспортера позволяет просматривать метрики потребляемых ресурсов GPU в разрезе Pod'а, генерирующего рабочую нагрузку. Это дает пользователю прямой доступ к данным о потребляемых Pod'ами GPU-ресурсах, что в свою очередь упрощает контроль над ресурсами.