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

Классический подход к решению подобной задачи — связка Sunshine и Moonlight, о которой в сообществе уже слагали легенды. Однако мне этот вариант категорически не подошёл, и далее я подробно объясню причины. В качестве альтернативы был выбран сервер Wolf из экосистемы Games on Whales, русскоязычных обзоров на который на просторах сети я не нашёл. Я поделюсь архитектурой этого решения, процессом развёртывания, а также расскажу, почему стандартный метод проброса драйверов NVIDIA здесь не работает и как мне удалось автоматизировать пересборку драйверного volume. Без этой автоматизации любое обновление драйверов гарантированно ломало весь стриминг до ручного вмешательства.
Почему Sunshine не подошёл под мои задачи
Sunshine представляет собой сервер протокола Moonlight, уходящего корнями в технологию NVIDIA GameStream. По сути, это сверхбыстрый инструмент удаленного рабочего стола, который перехватывает картинку с видеокарты, мгновенно кодирует её и передает клиенту. Если за ПК постоянно кто-то сидит с подключенным монитором — это идеальный выбор.
Но у моего сервера дисплея больше нет. Чтобы Sunshine вообще запустился, в видеокарту пришлось бы вонзить аппаратную заглушку — HDMI-эмулятор дисплея. При этом сессия всегда одна на всех, так что поиграть вдвоём не получится. Да и захламлять хост громоздким Steam со всеми его зависимостями на сервере совершенно не хотелось.
Wolf изящно решает все эти проблемы разом. Он не захватывает физический экран, а генерирует виртуальный специально под конкретного клиента: запросил Steam Deck разрешение 1280×800 при 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
Большая часть параметров взята из официального руководства, но на трёх аспектах стоит заострить внимание.
Имя образа жестко зафиксировано по дайджесту, а не по тегу: плавающий тег stable меня не устраивает, поскольку сервер в шкафу не должен обновляться спонтанно. Я предпочитаю контролировать этот процесс вручную.
Инструкция c 13: rmw открывает доступ к директории /dev/input/. Прописать эти устройства заранее в секции devices невозможно, поскольку виртуальные геймпады Wolf генерирует динамически через uinput уже после запуска контейнера. По этой же причине проброшен каталог /run/udev — без него сигналы о подключении девайсов попросту не дойдут до сессий. Разумеется, на самом хосте должны быть предварительно активированы модули ядра uinput и uhid, что решается одной строчкой в /etc/modules-load.d/.
И наконец, volume nvidia-driver-vol обозначен как внешний — docker-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, которая прокидывает внутрь как системные устройства, так и пользовательские библиотеки драйвера. В случае с Wolf этот подход терпит крах не потому, что тул подан некорректно, а из-за специфики требуемых библиотек. Стандартного набора Wolf недостаточно: его композиторам необходим полноценный стек EGL/GBM, а система кодирования завязана на zero-copy, когда кадры уходят из GPU напрямую в NVENC. При использовании инструментария от NVIDIA эта цепочка рассыпается: Wolf аварийно завершается при создании CUDA-буфера (в логах красуется ошибка Failed to create DMA buffer, переходящая в панику GsCUDABuf).
Разработчики осведомлены об этой особенности (соответствующий issue #379 закрыт без исправления), а в качестве обходного пути документация рекомендует задействовать специальный драйверный volume. У такого подхода есть два весомых плюса, недостижимых с toolkit: Wolf самостоятельно монтирует этот volume в динамические сессионные контейнеры, а внутри него уже присутствует compat32-слой для запуска старых 32-битных игр, которого на хосте без multilib может просто не оказаться.
Организация Driver-volume
Суть обходного манёвра сводится к следующему: пользовательская часть драйвера (библиотеки libnvidia-glcore, libGLX_nvidia, libnvcuvid, Vulkan-ICD и сопутствующие компоненты) упаковывается в отдельный Docker-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 весит почти три гигабайта, но после завершения сборки его можно безболезненно удалить.
Убедиться, что сессия успешно распознала графический чип, проще всего по логам Wolf: в момент запуска сеанса там должна зафиксироваться строка [nvidia] Add gbm backend. Если её нет — видеокарта сеансу недоступна, и копать глубже бессмысленно.
Проблемы со стримом после системных обновлений
Архитектура с driver-volume таит в себе очевидный изъян: файлы внутри хранилища жестко привязаны к текущей версии ядра. Стоит обновить пакет nvidia-utils на хосте — и volume необходимо пересобирать, в противном случае после перезагрузки версии разойдутся, а NVIDIA требует абсолютного соответствия.
Разумеется, в первый раз я благополучно упустил этот нюанс из виду. Обновил систему, перезагрузил сервер, закрыл дверь шкафа — а через пару дней при попытке поиграть со Steam Deck вместо интерфейса увидел чёрный экран и системную ошибку сессии. Причина лежала на поверхности, но неприятнее всего то, что сбой происходит тихо. Сам Wolf функционирует исправно, сопряжение проходит успешно, а ломается лишь этап генерации сеанса — сидя на диване с геймпадом, видишь лишь банальное «session error».
Для обычного десктопа это мелкая неприятность. Однако Arch Linux обновляется регулярно, и держать в голове необходимость ручной пересборки драйверов после каждого апдейта я точно не стану. Следовательно, эту рутину должна выполнять сама операционная система.
Автоматическое восстановление
Так родился компактный скрипт, оформленный в виде systemd-сервиса: во время каждой загрузки он сверяет версию активного модуля ядра с версией библиотек внутри тома. Если показатели идентичны — скрипт завершает работу за пару секунд. Если обнаружено расхождение — 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"
Несмотря на лаконичность, код учитывает множество скрытых нюансов, на которые я успел наступить.
Версия библиотек внутри тома извлекается через вспомогательный контейнер Alpine, хотя интуитивно хочется заглянуть в точку монтирования напрямую. На практике это невозможно: путь под /var/lib/docker доступен исключительно суперпользователю, а для обычного юзера из docker-группы папка будет выглядеть пустой. В таком случае скрипт не выдал бы ошибку, а упорно пересоздавал бы volume при каждом запуске.
Номер версии вытягивается прямо из наименования файла libnvidia-glcore.so.<версия>, так как отдельного файла с метаданными внутри тома нет, а версия вшита в имя библиотек.
Перед выполнением docker volume rm требуется удалить не только основной контейнер Wolf, но и следы давних завершенных сессий: они удерживают блокировку тома, и пока хотя бы один такой контейнер существует в остановленном состоянии, Docker не позволит удалить хранилище. Потерять там нечего — сеансы одноразовые, а настройки сопряжения хранятся в /etc/wolf.
Наполнение нового тома происходит вообще без запуска контейнеров. Здесь задействован документированный, но редко используемый механизм Docker: если пустой именованный volume смонтировать поверх непустой директории внутри образа при создании контейнера, Docker автоматически скопирует её содержимое. Перенос данных происходит на стадии docker create, запускать сам контейнер не требуется (что полезно, учитывая абсолютную пустоту базового образа scratch).
Этот же механизм успешно обрабатывает первый запуск системы. Пока volume отсутствует, версия считается «пустой», проверка не проходит — и срабатывает та же ветка сборки с нуля (поэтому команда docker volume rm обернута в конструкцию || true, ведь на первой итерации удалять ещё нечего). Никаких дополнительных ручных шагов по первичной инициализации драйверов выполнять не нужно.
Остаётся лишь автоматизировать запуск скрипта при старте системы. Соответствующий юнит предельно прост:
[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, перезагрузка, автоматическое определение расхождения версий скриптом, загрузка нужного инсталлятора, пересборка окружения и старт сервиса. С тех пор о проблемах с драйверами я вспоминаю лишь мельком по информативным строчкам в логах.
Клиентские устройства и личные впечатления
На стороне клиентских девайсов царит приятная рутина: используется стандартный клиент Moonlight. На Steam Deck и ноутбуке запущен moonlight-qt, а на телевизоре Samsung — клиент moonlight-tizen (сторонний форк brightcraft, устанавливаемый в режиме разработчика). Сопряжение по PIN-коду происходит стандартным для GameStream образом.
Сервер подключен к маршрутизатору по проводной сети 2.5GbE, трансляция на телевизор идет в разрешении 4K с битрейтом 80 Мбит/с, оставляя огромный запас по пропускной способности. Качество картинки впечатляет: Jedi Survivor в 4K с пресетом DLSS Performance стабильно выдает 70–80 кадров в секунду, а благодаря кодировщику NVENC до экрана доходят даже мельчайшие детали вроде плёночного зерна. Цветовые градиенты при 80 Мбит/с выглядят значительно чище, чем обычно ждешь от удаленного стриминга.

Не обошлось и без мелких шероховатостей. Первый запуск каждого нового приложения сопровождается загрузкой соответствующего docker-образа, что выражается в нескольких минутах чёрного экрана — пугаться этого не стоит. После некорректного прерывания сеансов иногда зависают фоновые процессы wine, что решается принудительным пересозданием сессии. Появление статуса exited (137) у остановленных контейнеров в выводе docker ps -a не связано с нехваткой памяти (OOM) — Wolf просто штатно гасит их сигналом KILL.
Планы на будущее
За рамками статьи остался целый пласт нюансов, связанных с геймпадами: например, проброс физического контроллера DualSense Edge в сессию и борьба со Steam Input, который норовит создать собственный виртуальный манипулятор через uinput хоста, из-за чего игра внутри контейнера перестает реагировать на ввод (issue #81). Проблема решилась написанием небольшого демона на Rust, и если эта тема вызывает интерес — она вполне потянет на отдельную публикацию.
Главный же вывод таков: проект Wolf оказался ровно тем решением, каким и должен быть качественный стриминг с безмониторного сервера — автономной службой, а не простым дублированием рабочего стола. Порог вхождения здесь выше, чем у Sunshine, и почти все сложности связаны исключительно с экосистемой NVIDIA. Надеюсь, готовые наработки и скрипты помогут вам сберечь пару вечеров личного времени.
Полезные ссылки:
-
Games on Whales: https://games-on-whales.github.io/
-
Документация по NVIDIA driver-volume: https://games-on-whales.github.io/wolf/stable/user/quickstart.html
-
Обсуждение проблемы с toolkit: https://github.com/games-on-whales/wolf/issues/379
Летний гейминг при нестабильном интернете
«Жизнь и страдания господина Бранте» вышла на Nintendo Switch
Запуск DOOM на процессоре собственной разработки
Эволюция футбольных симуляторов: от истоков до наших дней
Возвращение морских сражений в Battlefield 6: что нового в четвертом сезоне?
«Жизнь и страдания принца Джериана» вышла в Steam
Как купить Assassin’s Creed Black Flag Resynced на ПК, PS и Xbox в 2026 году
Разработка торгового мода для Avorion: от интерфейса и Lua до скрытых проблем