Утренний шторм в инфраструктуре не начинается с громких взрывов. Он стартует с тихого шёпота в алертах – с одного предупреждения, на которое глаз скользит мимо. Этот разбор про то, как рядовой warning про упавший systemd-юнит оказался единственным свидетелем зарождающейся катастрофы, которая через несколько минут уронила GPU-сервер в софтверный нокаут с перегревом процессора до 96 C.
Всё, что ниже – реальный инцидент. Стек обезличен, но версии драйвера, команды и логика – настоящие и воспроизводимые на любом хосте с NVIDIA.
06:15 – шёпот
Мониторинг фиксирует рядовой сбой:
SystemdServiceFailed: nvidia-cdi-refresh.service в состоянии failed
nvidia-cdi-refresh – служба, которая пересобирает спецификацию CDI (Container Device Interface) для доступа контейнеров к GPU. Падает – ну упала, перезапустится. На этот писк легко махнуть рукой.
Через 6 минут прилетает второй алерт (тот же юнит, теперь "висит в failed больше 10 минут"). Ещё через минуту – третий, и вот он уже не писк, а сирена:
CPU CRITICAL: 96 C. Sensor выше 90 C – тепловой троттлинг неминуем (Tjmax=95 C).
Три разных алерта. Один корень. И корень – не тот, на кого показывал первый алерт.
Что произошло на самом деле
Ночью unattended-upgrades накатил новую минорную версию драйвера NVIDIA. Здесь и кроется мина хоста с активным GPU:
| Компонент | До | После apt | Статус |
|---|---|---|---|
Userspace-библиотеки (NVML, libnvidia-ml) | 580.159 | 580.173 | обновились сразу |
Пересобранный модуль на диске (.ko) | 580.159 | 580.173 | лежит, готов |
| Работающий модуль в ядре | 580.159 | 580.159 | не сменился |
Модуль ядра нельзя заменить на живую, пока GPU держат активные процессы – его нельзя выгрузить (rmmod), у него ненулевой счётчик ссылок. Апгрейд обновил всё, кроме единственного, что реально исполняется. Получаем рассинхрон версий (version mismatch): userspace 580.173 разговаривает с ядром 580.159.
Диагностический якорь занимает две команды – сравнить работающий модуль с установленным:
cat /proc/driver/nvidia/version | head -1
# NVRM version: NVIDIA UNIX x86_64 Kernel Module 580.159.03 ...
modinfo -F version nvidia
# 580.173.02
Разные числа – это и есть мина. nvidia-smi в таком состоянии не запускается вовсе:
Failed to initialize NVML: Driver/library version mismatch
Ключевая мысль: апгрейд драйвера на работающем GPU-хосте – это отложенный отказ (deferred failure). Ломается не в момент apt, а когда до GPU дотянется первый новый процесс. Ночью система выглядела здоровой. Пожар начался утром, с первым запросом.
Почему три алерта, а корень один
Домино от одного рассинхрона:
| Время | Что происходит | Итог |
|---|---|---|
| 06:09 | apt обновляет драйвер 580.159 → 580.173 (userspace + .ko); модуль в ядре остаётся 580.159 | мина взведена |
| 06:10 | nvidia-cdi-refresh не может в NVML | падает → алерт №1 (тот самый "мелкий") |
| 06:20 | ollama не может в CUDA, уходит на CPU: offloaded 0/37 layers to GPU, 16 потоков, 1579% | 96 C → алерт №3 |
| 06:21 | юнит в failed больше 10 минут | → алерт №2 |
Локальный LLM-раннер (в нашем случае ollama) при загрузке модели не смог инициализировать CUDA против рассинхронённого ядра и сделал то, что для инференса хуже всего, – тихо свалился на CPU. Модель в 37 слоёв, посчитанная на 16 потоках процессора, – вот кто раскалил CPU почти до Tjmax.
Симптомы кричали про systemd и температуру. Причина – рассинхрон драйвера, про который не алертил никто. Три алерта из одного корня – это не три проблемы, это отсутствие корреляции.
Ловушка расследования: рантайм врёт
Первый инстинкт – "загасить всё, что держит GPU, и перезагрузить модуль". Спрашиваем Docker, кто использует девайс:
docker inspect -f '{{.HostConfig.Runtime}}' <container>
И получаем ответ "nvidia" у всех контейнеров на хосте. По этой "правде" под снос идёт пол-сервера.
Реальность – в ядре, а не в манифесте. На этом хосте default-runtime в Docker был выставлен в nvidia, поэтому поле честно возвращало "nvidia" для каждого контейнера, независимо от того, трогает он GPU или нет. Кто держит девайс на самом деле, показывает файловая система процессов:
# кто реально открыл /dev/nvidia*
for p in /proc/[0-9]*; do
grep -qE '/dev/nvidia' "$p/maps" 2>/dev/null && echo "$(cat $p/comm) $(basename $p)"
done
Из сорока контейнеров устройство держали два. Декларация конфига – не то же самое, что факт на диске. Универсальный вывод SRE: смотри, кто реально открыл файл девайса (/proc/*/fd, /proc/*/maps, fuser), а не что написано в поле рантайма. Задекларированное поведение и реально работающее – разные вещи, и в инциденте доверять можно только второму.
Лечение без ребута
Ребут всё бы починил, но это общий сервер с десятками контейнеров и живым инференсом. Рассинхрон снимается перезагрузкой стека модулей на месте:
# 1. Снять держателей GPU (это же гасит и CPU-пожар)
systemctl stop ollama
docker stop <два-реальных-GPU-контейнера>
# 2. Выгрузить старый стек 580.159 в порядке зависимостей
rmmod nvidia_uvm nvidia_drm nvidia_modeset nvidia
# 3. Загрузить свежий 580.173
modprobe nvidia nvidia_uvm
# 4. Проверить, что работающий модуль совпал с установленным
cat /proc/driver/nvidia/version # → 580.173.02
nvidia-smi # → OK
Как только держатели сняты – температура сразу падает (в нашем случае 96 C → 72 C за секунды, ещё до перезагрузки модулей): CPU-пожар был не причиной, а следствием, и гаснет вместе с ним.
Отдельная засада на шаге восстановления: контейнер с GPU, созданный до апгрейда, отказался стартовать:
open /usr/lib/x86_64-linux-gnu/libEGL_nvidia.so.580.159.03: no such file or directory
Его OCI-спека запекла пути монтирования библиотек с версией 580.159 ещё в момент создания. Файла больше нет – он теперь 580.173. docker start проигрывает старые монтирования и падает. Лечится пересозданием (docker compose up -d --force-recreate), при котором рантайм заново собирает список монтирований под текущий драйвер. И вот эта деталь – прямой мост к тому, как сделать правильно.
Лестница профилактики: простое → правильное
Инцидент закрыт, но без профилактики он повторится на следующем бампе драйвера. Три ступени – от быстрого костыля до архитектурного решения.
1. Простое: убрать драйвер из автообновления
Пока unattended-upgrades трогает драйвер, мина будет взводиться в 06:00 каждый раз. Забираем драйвер из-под автоапдейта – его версия должна меняться только руками, в окне обслуживания, с последующим релоадом модулей:
# /etc/apt/apt.conf.d/50unattended-upgrades → Package-Blacklist
"nvidia-";
"libnvidia-";
"nvidia-dkms-";
Проверять надо не глазами по файлу, а по тому, как apt реально распарсил список:
apt-config dump | grep -A4 Package-Blacklist
Это костыль (security-обновления драйвера теперь тоже ручные), но он останавливает молчаливое кровотечение.
2. Правильное для наблюдаемости: детектор рассинхрона
Костыль не спасёт, если драйвер обновят руками и забудут перезагрузить модули. Нужен алерт на саму причину, а не на её симптомы. Причина выражается ровно тем сравнением из начала статьи. Заворачиваем его в скрипт, который пишет метрику для node_exporter (textfile collector):
#!/usr/bin/env bash
# nvidia_driver_kmod_mismatch: работающий модуль != установленный .ko
# nvidia_smi_up: NVML инициализируется
set -uo pipefail
OUT=/var/lib/node_exporter/textfile_collector/nvidia_driver.prom
TMP=$(mktemp -p "$(dirname "$OUT")" nvidia_driver.XXXXXX)
running=$(sed -n 's/.*Kernel Module *\([0-9][0-9.]*\).*/\1/p' /proc/driver/nvidia/version | head -1)
ondisk=$(modinfo -F version nvidia 2>/dev/null | head -1)
mismatch=0; [ -n "$running" ] && [ -n "$ondisk" ] && [ "$running" != "$ondisk" ] && mismatch=1
smi_up=0; nvidia-smi -L >/dev/null 2>&1 && smi_up=1
{
echo "# TYPE nvidia_driver_kmod_mismatch gauge"
echo "nvidia_driver_kmod_mismatch{running=\"${running:-none}\",ondisk=\"${ondisk:-none}\"} ${mismatch}"
echo "# TYPE nvidia_smi_up gauge"
echo "nvidia_smi_up ${smi_up}"
} > "$TMP"
mv -f "$TMP" "$OUT" # атомарная подмена: один fs, textfile-коллектор не прочитает половину
Запускаем systemd-таймером раз в 2 минуты и вешаем правило (пример под vmalert/Prometheus):
- alert: NvidiaDriverKmodMismatch
expr: nvidia_driver_kmod_mismatch == 1
for: 3m
labels: {severity: critical, component: gpu}
annotations:
summary: "Драйвер NVIDIA рассинхронён: ядро {{ $labels.running }} != диск {{ $labels.ondisk }}"
description: "Обычно после apt-апгрейда до релоада модулей. Новые CUDA-процессы падают и могут уйти на CPU. Фикс: перезагрузить модули или ребут."
- alert: NvidiaSmiDown
expr: nvidia_smi_up == 0
for: 5m
labels: {severity: critical, component: gpu}
В инциденте таймлайн был такой: бамп в 06:09, CPU закипел в 06:21. Детектор поймал бы mismatch == 1 в районе 06:12 – за 9 минут до перегрева. Мы ловим причину раньше, чем она успевает притвориться температурой.
3. Правильное для архитектуры: контейнеры на CDI
Помните запечённый путь libEGL...580.159.03 при пересоздании? Это симптом легаси-способа отдавать GPU в контейнер (deploy.resources.reservations.devices в compose или флаг рантайма). Он собирает список монтирований библиотек – с их версией – в момент создания контейнера. Обновили драйвер – список протух, контейнер сломан до пересоздания.
CDI (Container Device Interface) решает это by design: контейнер ссылается на абстрактный девайс nvidia.com/gpu=0, а конкретные монтирования резолвятся при старте из спеки, которую генерирует и поддерживает в актуальном состоянии… nvidia-cdi-refresh – тот самый юнит, чьё падение и было алертом №1. Круг замыкается: служба, с падения которой начался инцидент, существует ровно для того, чтобы этого инцидента не было.
Docker 25+ понимает CDI нативно. Миграция сервиса – это замена блока резервации на ссылку на CDI-девайс:
# было (легаси: запекает версию либ при create):
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1
capabilities: [gpu]
# стало (CDI: резолв при старте из /var/run/cdi/nvidia.yaml):
runtime: runc # не даём default-nvidia-рантайму инжектить второй раз
devices:
- "nvidia.com/gpu=0"
После пересоздания в инспекте контейнера – ноль запечённых version-pinned монтирований, девайс подтягивается при старте. Тот же апгрейд драйвера его больше не сломает. Проверить механику до миграции можно на одноразовом контейнере:
docker run --rm --runtime=runc --device=nvidia.com/gpu=0 alpine ls -la /dev/nvidia0
Вывод для прод-мозга
Три вещи, которые этот инцидент кладёт на полку памяти:
- Алерт называет того, кто упал последним, а не того, кто толкнул. Самый тихий
warningпро "какой-то cdi-refresh" был единственной стрелкой, указывавшей прямо в корень, – а два громких алерта показывали на симптомы. - In-place апгрейд драйвера на stateful-хосте – это отложенный отказ. Всё зелёное ночью и мёртвое утром. Автообновление ядрозависимых пакетов (драйверы, DKMS-модули) на серверах с постоянной нагрузкой должно быть осознанным решением, а не фоновым процессом.
- Задекларированное поведение – не то же, что работающее. Поле рантайма сказало "GPU у всех", ядро сказало "у двоих". В инциденте истина лежит в
/proc, а не в манифесте.
И последнее, инженерно-философское. Верхний уровень (CDI) существует именно для того, чтобы развязать хрупкую зависимость нижнего – список монтирований от момента создания контейнера. Когда падает служба, чья работа – держать эту развязку в актуальности, ты получаешь не одну поломку, а целый парад: список протухает, процессы валятся, температура растёт. Прочность цепочки – по слабейшему звену, а слабейшее звено любит притворяться чем-то громким и посторонним.
Профилактика в этом разборе – реальная лестница, которую мы прошли за один сеанс после инцидента: бан драйвера в автоапдейте, детектор рассинхрона с алертом за 9 минут до перегрева, и миграция GPU-контейнеров на CDI. Простое чинит сегодня, правильное – закрывает класс проблемы.