Мой рабочий компьютер собирался как игровой: 5800X3D, приличная видеокарта. Ирония в том, что играю я на нём всё меньше, а с недавних пор он и вовсе переехал в шкаф в прихожую и стал домашним сервером — кино, файлы, всё как у всех. Смотреть, как игровое железо вечерами раздаёт сериалы, было обидно, и я решил: пусть оно стримит игры туда, где я в них действительно играю — на телевизор, Steam Deck и ноутбук.

AAA игры даже на парковке
AAA игры даже на парковке

Стандартное решение этой задачи — связка Sunshine и Moonlight, о ней на Хабре писали не раз. Мне она не подошла, и ниже я объясню почему. Вместо неё сервер собран на Wolf из проекта Games on Whales, о котором на русском, насколько я вижу, ещё не писали. Расскажу, как он устроен и как его поднять, а заодно — почему с ним не работает официальный способ проброса NVIDIA-драйвера и как я автоматизировал пересборку драйверного volume. Без этой автоматизации после каждого обновления драйвера стриминг лежал, пока я не пересоберу volume руками.

Чем не подошёл Sunshine

Sunshine — это сервер протокола Moonlight, выросшего из NVIDIA GameStream. Работает он как очень быстрый удалённый рабочий стол: захватывает то, что выводит видеокарта, кодирует и отправляет клиенту. Пока за компьютером кто-то сидит с монитором, это ровно то, что нужно.

Но у моего сервера монитора больше нет. Чтобы Sunshine было что захватывать, в видеокарту пришлось бы воткнуть dummy-заглушку — HDMI-обманку, изображающую дисплей. Сессия при этом одна на всех, так что двое одновременно не поиграют. А Steam со всем зоопарком игровых зависимостей пришлось бы держать прямо на хосте, чего на сервере совсем не хочется.

Wolf снимает все три пункта разом. Дисплей он не захватывает, а создаёт — виртуальный, под конкретного клиента: пришёл Steam Deck со своими 1280x800@90 — Wayland-композитор поднимется ровно такого размера, никаких заглушек. Сессий может быть несколько, у каждого клиента своя, видеокарту они делят. И каждая сессия — это Docker-контейнер: появился при подключении, исчез после отключения, на хосте не осталось ничего.

С точки зрения клиента при этом ничего не меняется: для Moonlight Wolf выглядит обычным GameStream-хостом, и паринг тот же, по PIN-коду.

Как Wolf устроен

Games on Whales — небольшой опенсорсный проект: сам Wolf и набор готовых образов для сессий — Steam, Lutris, RetroArch, просто рабочий стол. Пересказывать документацию не буду, для дальнейшего важно понимать одно. Сессионные контейнеры Wolf создаёт сам, обращаясь к проброшенному внутрь docker.sock, а устройства ввода для них — виртуальные геймпады, мышь, клавиатуру — заводит через uinput хоста.

Схема получившейся архитектуры
Схема получившейся архитектуры

У этого удобства есть цена. Сервису, который сам создаёт контейнеры и устройства, нужны docker.sock и весь /dev на запись — фактически полный доступ к хосту, об изоляции говорить не приходится. Для домашней машины, чей API не смотрит в интернет, меня такой размен устраивает, но соглашаться на него стоит осознанно.

Установка

Хост — Arch Linux, Ryzen 7 5800X3D, RTX 4070 Ti, Docker. Весь стек описывается одним compose-файлом:

services:
  wolf:
    image: ghcr.io/games-on-whales/wolf:stable@sha256:ff82c125c9b79b2e9443de2b0eaec40c904edb03291680d408cccd57c1d59c76
    environment:
      - NVIDIA_DRIVER_VOLUME_NAME=nvidia-driver-vol
    volumes:
      - /etc/wolf/:/etc/wolf:rw
      - /var/run/docker.sock:/var/run/docker.sock:rw
      - /dev/:/dev/:rw
      - /run/udev:/run/udev:rw
      - nvidia-driver-vol:/usr/nvidia:rw
    devices:
      - /dev/dri
      - /dev/uinput
      - /dev/uhid
      - /dev/nvidia-uvm
      - /dev/nvidia-uvm-tools
      - /dev/nvidia-caps/nvidia-cap1
      - /dev/nvidia-caps/nvidia-cap2
      - /dev/nvidiactl
      - /dev/nvidia0
      - /dev/nvidia-modeset
    device_cgroup_rules:
      - "c 13:* rmw"
    network_mode: host
    restart: unless-stopped

volumes:
  nvidia-driver-vol:
    external: true

Большая часть здесь прямо из документации, но на трёх местах остановлюсь.

Образ запинен по digest, а не по тегу: тег stable у проекта плавающий, а мне не хочется, чтобы сервер в шкафу однажды обновился сам. Обновляю руками, когда готов проверять.

Правило c 13:* rmw открывает доступ к устройствам /dev/input/*. Перечислить их в devices заранее нельзя: виртуальные геймпады Wolf создаёт через uinput уже после старта контейнера. По той же причине проброшен /run/udev — без него события подключения устройств не доезжают до сессий. На хосте при этом должны быть загружены модули uinput и uhid, это одна строчка в /etc/modules-load.d/.

Наконец, volume nvidia-driver-vol объявлен внешним — compose ждёт, что он уже существует. Откуда он берётся, разберём в следующем разделе.

Конфиг

При первом запуске Wolf генерирует /etc/wolf/cfg/config.toml, и он рабочий как есть. Я поменял две вещи. Для Steam-сессии включил RUN_GAMESCOPE=1 — по умолчанию она работает в оконном менеджере sway, а в gamescope полноэкранные игры ведут себя предсказуемее. И примонтировал в сессию библиотеку игр с хоста, чтобы скачанное переживало пересоздание контейнера:

mounts = ['/data/games:/mnt/games:rw']

Проблема с NVIDIA-драйвером

Владельцам AMD и Intel дальше можно почти не читать: у них всё работает из коробки через /dev/dri, листайте к впечатлениям. С NVIDIA у меня ушло несколько вечеров.

Почему не работает официальный путь

Стандартный способ отдать контейнеру видеокарту NVIDIA — nvidia-container-toolkit: он пробрасывает внутрь и устройства, и userland-библиотеки драйвера. С Wolf этот способ не работает, но не потому, что toolkit «не дотягивается» до контейнеров. Дело в том, что именно он пробрасывает: инжектируемого набора библиотек Wolf’у мало. Его композиторам нужен полный EGL/GBM-стек драйвера, а кодирование построено на zero-copy — кадр уходит из GPU прямо в NVENC. С toolkit-путём эта цепочка не собирается: Wolf падает при создании CUDA-буфера (в логе — Failed to create DMA buffer, затем паника на GsCUDABuf). В апстриме проблема известна, issue #379 закрыт без фикса, а документация предлагает обходной путь — драйверный volume. У volume есть и два бонуса, которых toolkit не дал бы: Wolf сам монтирует его в сессионные контейнеры, которые создаёт на лету через docker.sock, и внутри лежит compat32-слой для 32-битных игровых библиотек, которого на хосте без multilib просто нет.

Driver-volume

Суть обхода: userland-часть драйвера — libnvidia-glcore, libGLX_nvidia, libnvcuvid, Vulkan-ICD и остальное — складывается в отдельный Docker-volume, и этот volume монтируется в /usr/nvidia всем: и контейнеру Wolf, и каждой сессии. Единственное жёсткое требование — версия библиотек обязана в точности совпадать с версией модуля ядра на хосте. Поэтому собирается volume из официального .run-инсталлятора ровно той версии, что стоит на хосте:

FROM ubuntu:22.04 AS nvidia-installer

ARG NV_VERSION
RUN apt-get update -y && \
    apt-get install -y --no-install-recommends curl ca-certificates kmod pkg-config libglvnd-dev vulkan-tools && \
    curl -LO https://download.nvidia.com/XFree86/Linux-x86_64/$NV_VERSION/NVIDIA-Linux-x86_64-$NV_VERSION.run && \
    chmod +x NVIDIA-Linux-x86_64-$NV_VERSION.run && mkdir -p /usr/nvidia && \
    ./NVIDIA-Linux-x86_64-$NV_VERSION.run --silent -z --skip-depmod --skip-module-unload \
        --no-nvidia-modprobe --no-kernel-modules --no-kernel-module-source \
        --opengl-prefix=/usr/nvidia --utility-prefix=/usr/nvidia --utility-libdir=lib \
        --compat32-prefix=/usr/nvidia --compat32-libdir=lib32 --wine-prefix=/usr/nvidia \
        --egl-external-platform-config-path=/usr/nvidia/share/egl/egl_external_platform.d \
        --glvnd-egl-config-path=/usr/nvidia/share/glvnd/egl_vendor.d --no-distro-scripts && \
    rm ./NVIDIA-Linux-x86_64-$NV_VERSION.run

FROM scratch

COPY --from=nvidia-installer /usr/nvidia/ /usr/nvidia
COPY --from=nvidia-installer /etc/vulkan/icd.d/nvidia_icd.json /usr/nvidia/share/vulkan/icd.d/
COPY --from=nvidia-installer /bin/sh /bin/sh

Инсталлятор здесь запускается в режиме «только userland»: без модулей ядра, без depmod, с установкой всего в префикс /usr/nvidia. На выходе — образ на базе scratch, то есть вообще без операционной системы внутри: одни библиотеки, гигабайта на два. Промежуточная ubuntu-стадия весит ещё почти три, но после сборки её можно спокойно удалить.

Проверить, что сессия действительно увидела GPU, проще всего по логам Wolf: при старте сессии там должна появиться строчка [nvidia] Add gbm backend. Если её нет — видеокарты у сессии нет, и искать проблему дальше по стеку бессмысленно.

Стрим умирает после pacman -Syu

У схемы с driver-volume есть встроенный недостаток: библиотеки в volume привязаны к версии модуля ядра. Обновил на хосте nvidia-utils — пересобери volume, иначе после ребута версии разойдутся, а точного совпадения NVIDIA требует всегда.

Первый раз я про этот шаг, конечно, забыл. Обновил систему, перезагрузился, закрыл шкаф — а через пару дней Deck вместо игры показал чёрный экран с ошибкой сессии. Что случилось, было понятно сразу, неприятно другое: умирает стриминг тихо. Wolf работает, паринг проходит, ломается только создание сессии — с дивана, с геймпадом в руках, видно лишь «session error».

На десктопе это была бы разовая досада. Но Arch обновляется часто, и держать в голове лишнее ручное действие после каждого обновления драйвера я гарантированно не буду. Значит, вспоминать должен не я, а машина.

Самопочинка

Так появился скрипт на пару десятков строк под systemd-юнитом: на каждой загрузке он сверяет версию загруженного модуля ядра с версией библиотек в volume. Совпали — вышел, работы на пару секунд. Разошлись — пересобрал volume и перезапустил Wolf:

#!/bin/sh
# Держит nvidia-driver-vol в синхроне с модулем ядра nvidia.
# Запускается systemd-юнитом на каждом буте.
set -eu

WOLF_DIR=/srv/wolf
VOL=nvidia-driver-vol

WANT=$(cat /sys/module/nvidia/version 2>/dev/null) || {
    echo "nvidia module not loaded, skipping"; exit 0; }

HAVE=""
if docker volume inspect "$VOL" >/dev/null 2>&1; then
    HAVE=$(docker run --rm -v "$VOL":/nv:ro alpine:3.24 \
        sh -c 'ls /nv/lib/libnvidia-glcore.so.* 2>/dev/null' \
        | sed 's/.*libnvidia-glcore\.so\.//' | head -1) || HAVE=""
fi

if [ "$WANT" = "$HAVE" ]; then
    echo "driver volume in sync ($WANT)"; exit 0
fi

echo "rebuilding $VOL: volume='$HAVE' host='$WANT'"
docker build -t gow/nvidia-driver:latest --build-arg NV_VERSION="$WANT" \
    -f "$WOLF_DIR/nvidia-driver.Dockerfile" "$WOLF_DIR"
docker compose -f "$WOLF_DIR/docker-compose.yml" down
docker ps -aq --filter volume="$VOL" | xargs -r docker rm -f
docker volume rm "$VOL" >/dev/null 2>&1 || true
docker volume create "$VOL"
CTR=$(docker create -v "$VOL":/usr/nvidia gow/nvidia-driver:latest /bin/sh)
docker rm "$CTR" >/dev/null
docker compose -f "$WOLF_DIR/docker-compose.yml" up -d
docker image prune -f >/dev/null
echo "rebuilt $VOL to $WANT, wolf restarted"

Скрипт выглядит просто, но почти за каждой строчкой — грабли, на которые я успел наступить.

Версию библиотек в volume я читаю через одноразовый alpine-контейнер, хотя казалось бы, можно заглянуть в mountpoint напрямую. Нельзя: путь под /var/lib/docker доступен только root, и для обычного пользователя из docker-группы каталог молча выглядел бы пустым. Скрипт при этом не упал бы, а исправно пересобирал бы volume на каждой загрузке — и никто бы этого не заметил.

Сама версия достаётся из имени файла libnvidia-glcore.so.<версия>: отдельного version-файла в volume нет, а в имя библиотеки версия зашита всегда.

Перед docker volume rm нужно удалить не только контейнер Wolf, но и контейнеры давно завершившихся сессий: они тоже держат ссылку на volume, и пока живы хотя бы в остановленном виде, удалить его не даст сам Docker. Терять там нечего — сессии одноразовые, а паринг живёт в /etc/wolf.

Наполняется же новый volume вообще без запуска контейнера. Здесь работает документированное, но редко используемое поведение Docker: если при создании контейнера пустой именованный volume монтируется поверх непустой директории образа, Docker сам копирует её содержимое в volume. Копирование происходит уже на этапе docker create, стартовать контейнер не нужно — что кстати, потому что в scratch-образе запускаться всё равно нечему.

Этот же скрипт закрывает и первый запуск. Пока volume не существует, версия в нём «пустая», сравнение не сходится — и та же ветка пересборки создаёт его с нуля (поэтому docker volume rm обёрнут в || true: на первом прогоне удалять ещё нечего). Отдельного шага «установить драйвер в volume» в инструкции нет.

Осталось запускать это на каждой загрузке. Юнит тривиальный:

[Unit]
Description=Sync nvidia-driver-vol with host driver version for Wolf
Requires=docker.service
After=docker.service

[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/local/bin/wolf-driver-sync.sh

[Install]
WantedBy=multi-user.target

Собирается всё воедино так: в /srv/wolf лежат docker-compose.yml и nvidia-driver.Dockerfile, скрипт — в /usr/local/bin, юнит включается через systemctl enable --now wolf-driver-sync.service. Первый же его запуск соберёт volume и поднимет Wolf. Дальше цикл замкнут: pacman -Syu, ребут, скрипт замечает рассинхрон, скачивает нужный .run, пересобирает volume и поднимает Wolf. С тех пор о драйверном volume я вспоминаю только по строчке в логах.

Клиенты и впечатления

Со стороны клиентов всё скучно, и это комплимент: обычный Moonlight. Steam Deck и ноутбук — moonlight-qt, телевизор Samsung — moonlight-tizen (форк brightcraft, ставится сайдлоадом в developer-режиме телевизора). Паринг по PIN, как с любым GameStream-хостом.

Сервер подключён к роутеру по 2.5GbE, стрим на телевизор идёт в 4K с битрейтом 80 Мбит, и по сети остаётся большой запас. О качестве: Jedi Survivor в 4K с DLSS Performance держит 70-80 fps, и через NVENC до экрана доживает даже плёночное зерно. Градиенты на 80 Мбит заметно чище, чем ждёшь от стрима.

Скриншот с ноута — 1920х1080@60 на 40 Мбит, настройки графики те же, DLSS не отключал
Скриншот с ноута — 1920х1080@60 на 40 Мбит, настройки графики те же, DLSS не отключал

Мелкие грабли тоже есть. Первый запуск каждого приложения — это скачивание его docker-образа: несколько минут чёрного экрана, пугаться не надо. После жёстко оборванных сессий иногда остаются wine-процессы-зомби, лечится пересозданием сессии. А exited (137) у остановленных сессионных контейнеров в docker ps -a — не OOM: Wolf штатно гасит их сигналом KILL.

Что дальше

За кадром осталась целая история с геймпадами: проброс физического DualSense Edge в сессии и борьба со Steam Input, который создаёт виртуальный геймпад через uinput хоста, отчего игра в контейнере его не видит (issue #81). Закончилась она небольшим демоном на Rust, и если тема интересна — это материал на отдельную статью.

Главный же итог такой: Wolf оказался тем, чем и должен быть стриминг с headless-сервера, — сервисом, а не зеркалом рабочего стола. Порог входа у него выше, чем у Sunshine, и почти весь этот порог — NVIDIA. Надеюсь, с готовым скриптом он станет для вас на пару вечеров ниже.

Ссылки:

Комментарии (0)