Утренний шторм в инфраструктуре не начинается с громких взрывов. Он стартует с тихого шёпота в алертах – с одного предупреждения, на которое глаз скользит мимо. Этот разбор про то, как рядовой 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.159580.173обновились сразу
Пересобранный модуль на диске (.ko)580.159580.173лежит, готов
Работающий модуль в ядре580.159580.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:09apt обновляет драйвер 580.159 → 580.173 (userspace + .ko); модуль в ядре остаётся 580.159мина взведена
06:10nvidia-cdi-refresh не может в NVMLпадает → алерт №1 (тот самый "мелкий")
06:20ollama не может в 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. Простое чинит сегодня, правильное – закрывает класс проблемы.