Рабочие места в браузере на одном VPS: Selkies, Chrome и Docker

Как я собрал 11 удаленных рабочих мест с Chrome в браузере на одном сервере: сравнение Kasm и Selkies, Caddy с паролем на каждое место, расчет памяти и грабли продакшена

Недавно ко мне обратились с задачей: команде из десяти с лишним человек нужен рабочий браузер на сервере в Европе. Постоянные профили, чтобы не логиниться каждый день заново, загрузка и скачивание файлов, вход по ссылке с любого устройства, никаких клиентов и VPN на стороне сотрудника. Windows и 1С не нужны: вся работа идет в веб-сервисах. Расскажу, как я это собрал и на какие грабли наступил.

Сначала облако

Первой идеей было AWS AppStream 2.0: сервис отдает приложение в браузер, Chrome там есть из коробки. Но у нового аккаунта квота на AppStream по умолчанию нулевая, и пока поддержка рассматривала запрос, сроки ушли. Параллельно я поднял на EC2 демо-стенд с двумя движками для браузера в контейнере: Kasm Workspaces и Selkies из образов linuxserver.io.

Там же всплыла проблема, которую я не ожидал: часть сайтов, с которыми работала команда, встречала адреса AWS капчами или блокировкой. Крупные облака держат известные диапазоны IP, и антибот-системы относятся к ним с подозрением. В итоге от AWS отказались полностью и переехали на обычный VPS у европейского хостера.

Kasm или Selkies

До переезда я сравнил оба движка на одной машине (4 vCPU, 16 ГБ) при разрешении 1280x720. Сценарий: простой, прокрутка текста, видео 720p и полноэкранная анимация, по 90 секунд на каждую фазу. Цифры на одну активную сессию:

Kasm CPUKasm трафикSelkies CPUSelkies трафик
Простой0.02 ядра~00.10 ядра~0
Прокрутка0.56 ядра3.0 Мбит/с1.5 ядра2.4 Мбит/с
Видео 720p0.70 ядра2.5 Мбит/с1.4 ядра6.6 Мбит/с

Память одинаковая, около 0.5-0.6 ГБ на сессию, ее почти целиком съедает сам Chrome. Kasm в 2-3 раза легче по процессору и на видео отдает в 3-4 раза меньше трафика. Но экономит он за счет качества: KasmVNC кодирует только изменившиеся участки экрана и при нагрузке снижает качество и частоту кадров, на динамичном контенте это заметно как подтормаживание. Selkies кодирует поток в H.264 и держит ровные 30 кадров в секунду, картинка плавная, но платить за это приходится процессором.

Решающими оказались два момента: плавность работы и лицензия. Бесплатная версия Kasm ограничена пятью одновременными сессиями и только некоммерческим использованием, а Selkies распространяется под MPL-2.0. Выбрали Selkies.

Режимы кодирования Selkies

Selkies умеет передавать экран тремя способами, их задает переменная SELKIES_ENCODER. Первое значение в списке становится режимом по умолчанию, а если значений несколько, в боковой панели появляется переключатель, и пользователь выбирает сам.

  • x264enc - весь экран кодируется как видео H.264. Это самый плавный и экономный по трафику режим на видео и анимации, браузер декодирует поток аппаратно. Минус в том, что при фиксированном битрейте (CBR) на быстрой прокрутке кодеку не хватает бит, и текст на мгновение расплывается в “мыло” и квадраты.
  • x264enc-striped - тот же H.264, но экран нарезан на горизонтальные полосы, каждая кодируется отдельно и параллельно на разных ядрах, а передаются только изменившиеся полосы. Задержка ниже, на почти статичном экране трафика меньше. Одним этим режимом оставлять Selkies нельзя: однажды он стоял единственным в списке, и клиент при ошибке видео не мог переключиться на другой режим и бесконечно перезагружал страницу.
  • jpeg - изменившиеся участки экрана отправляются как обычные JPEG-картинки, без видеокодека. Текст всегда четкий, артефакты не копятся от кадра к кадру, процессор отдыхает, пока на экране ничего не происходит. Зато на видео и прокрутке трафик растет в разы, ведь каждый кадр - отдельная картинка.

Отдельно есть режим Turbo (SELKIES_H264_STREAMING_MODE): H.264 кодирует каждый кадр подряд, как видеопоток, а не только изменения. Движение становится ровнее, но процессора на каждую подключенную сессию уходит больше.

Начинали мы с H.264 и Turbo, но сотрудники жаловались, что при прокрутке текст плохо читается. Команда работает с текстом и таблицами, видео смотрит редко, поэтому в итоге всех один раз переключили на JPEG, а H.264 остался в переключателе для тех, кому важнее плавность.

Архитектура

                    Интернет :443
                         │
   ┌─────────────────────▼──────────────────────┐
   │ Caddy: HTTPS (Let's Encrypt), пароль на     │
   │ каждый путь /1/ ... /11/                    │
   └───┬──────────────┬─────────────────┬───────┘
       │ /1/          │ /1/files/       │ ...
       ▼              ▼                 ▼
  chrome-1        filebrowser-1      chrome-2..11
  127.0.0.1:8081  127.0.0.1:8091     127.0.0.1:...
  • Каждое рабочее место - отдельный контейнер linuxserver/chrome с Selkies: Chrome в режиме киоска, без рабочего стола, терминала и sudo.
  • Рядом с каждым - файловый менеджер filebrowser на той же папке, куда Chrome сохраняет загрузки. Файлы можно передавать и через боковую панель Selkies, но отдельная страница с файлами пользователям удобнее.
  • Все контейнеры слушают только 127.0.0.1, наружу открыты лишь 22, 80 и 443. Это важно: Docker сам пишет правила iptables и при публикации порта на 0.0.0.0 обходит ufw.
  • Мониторинг: node_exporter, cAdvisor, Prometheus и Alertmanager с алертами в Telegram, плюс внешняя проверка доступности с другого сервера на случай, если упадет весь хост.

Общие настройки вынесены в YAML-якоря, сервис рабочего места получается коротким:

docker-compose.yml

 1x-selkies-common: &common
 2  PUID: "1000"
 3  PGID: "1000"
 4  # X11 вместо Wayland: в Wayland не работал диалог выбора файла
 5  PIXELFLUX_WAYLAND: "false"
 6  SELKIES_FRAMERATE: "30"
 7  # первый - по умолчанию, несколько значений включают переключатель в панели
 8  SELKIES_ENCODER: "x264enc,x264enc-striped,jpeg"
 9  SELKIES_ENABLE_RATE_CONTROL: "true"
10  SELKIES_RATE_CONTROL_MODE: "cbr"
11  SELKIES_VIDEO_BITRATE: "4"
12  SELKIES_MICROPHONE_ENABLED: "false"
13
14x-hardening: &hardening
15  NO_DECOR: "true"
16  HARDEN_OPENBOX: "true"
17  DISABLE_SUDO: "true"
18  DISABLE_TERMINALS: "true"
19  SELKIES_COMMAND_ENABLED: "false"
20
21x-svc: &svc
22  restart: "no"
23  shm_size: 512m
24  mem_limit: 2560m
25  memswap_limit: 4096m
26  logging:
27    driver: json-file
28    options: { max-size: "10m", max-file: "3" }
29
30services:
31  chrome-1:
32    <<: *svc
33    # образ закреплен по digest, чтобы pull не обновил его без спроса
34    image: lscr.io/linuxserver/chrome@sha256:...
35    ports:
36      - "127.0.0.1:8081:3000"
37    volumes:
38      - /opt/chrome/policies:/etc/opt/chrome/policies/managed:ro
39      - /opt/data/1/Desktop:/config/Desktop
40      - /opt/data/1/Desktop:/config/Downloads
41    environment:
42      <<: [*common, *hardening]
43      TITLE: "Workspace 1"

Политика Chrome в /opt/chrome/policies принудительно ставит блокировщик рекламы: меньше рекламы на страницах - меньше процессора на кодирование видео.

Аутентификация живет целиком в Caddy, внутри контейнеров ее нет. На каждое место свой путь и свой пароль, хеши лежат в файле окружения, а не в конфиге:

Caddyfile

work.example.com {
	encode zstd gzip
	header X-Robots-Tag "noindex, nofollow"

	redir /1 /1/ 308
	handle_path /1/* {
		basic_auth {
			user1 {env.WS1_HASH}
		}
		reverse_proxy 127.0.0.1:8081 {
			# не рвать WebSocket-поток при reload Caddy
			stream_close_delay 5m
		}
	}
}

Хеш генерируется командой caddy hash-password. От перебора паролей защищает fail2ban: он читает JSON-лог Caddy и банит IP после 10 неудачных попыток за 10 минут.

Грабли

Сервер умер от собственного рестарта. Изначально у контейнеров стоял restart: unless-stopped. После перезагрузки хоста Docker стартовал все десять Chrome в одну секунду, память и swap кончились за минуту, kswapd занял оба ядра, перестали отвечать dockerd и даже sshd. Спасла только перезагрузка из панели хостера, и то со второго раза, потому что после нее все повторилось. Решение: Docker больше ничего не перезапускает сам, запуском владеет systemd-юнит, который поднимает контейнеры по два с паузой и останавливается, если свободной памяти мало:

 1for i in $(seq 1 "$WORKSPACE_COUNT"); do
 2  is_running "$i" && continue
 3  # не стартовать новый Chrome, если памяти почти не осталось
 4  if [ "$(avail_mb)" -lt "$MIN_AVAIL_MB" ]; then
 5    log "ABORT at workspace $i"
 6    return 0
 7  fi
 8  docker start "chrome-$i"
 9  n=$((n + 1))
10  [ $((n % BATCH_SIZE)) -eq 0 ] && sleep "$BATCH_PAUSE"
11done

Отдельный таймер раз в три минуты поднимает не больше одного упавшего контейнера. Заодно выяснилось, что docker kill с политикой unless-stopped не считается остановкой, и убитый контейнер тут же возвращался.

Одна вкладка съедает весь сервер. Без лимитов тяжелая страница в одном браузере вытесняла всех остальных. С mem_limit на контейнер OOM killer убивает только самый большой процесс внутри него, обычно одну вкладку Chrome, а сам браузер и остальные пользователи продолжают работать. Лимит поднимали несколько раз по алертам: у разных людей очень разная привычка держать открытые вкладки.

Память надо мерить, а не считать по документации. Пустое рабочее место занимает около 750 МБ: примерно 300 МБ сам Chrome и около 350 МБ разделяемой памяти под кадровый буфер X-сервера и Selkies. Система, Docker и мониторинг забирают еще около 2.2 ГБ. Отсюда простая формула:

N = (RAM - 2.2 ГБ - запас) / 0.75 ГБ

Сервер за два месяца вырос с 2 vCPU и 4 ГБ до 8 vCPU и 16 ГБ: сначала по памяти, потом по процессору, потому что H.264 кодируется программно и при активной работе 1-1.5 ядра на сессию - нормально. На текущей конфигурации 11 мест работают с запасом.

zram вместо swap на диске. Разделяемая память X-сервера вытесняется только в swap. Swap на диске VPS медленный, а в zram эти страницы сжимаются примерно 23:1, так что сжатый swap в памяти почти бесплатен. Еще одна находка: если не задать MAX_RES, X-сервер выделяет кадровый буфер под 16K, и эти нули занимают весь лимит swap контейнера.

Терялись русские буквы. При быстром наборе на кириллице пропадали или менялись местами символы, английский текст печатался нормально. Причина: Selkies вводит русские символы через xdotool, а при раскладке только us тот на каждое нажатие временно переназначает свободную клавишу, и при быстром наборе не успевает. Помогла фиксированная раскладка us,ru в X-сервере каждого контейнера, без переключения по Alt+Shift, иначе сочетание пользователя переключало бы раскладку и на сервере.

Обновление Chrome сломало выбор файлов. Начиная с версии 145 в собственном диалоге Chrome кнопкой по умолчанию стала “Отмена”, и двойной клик по файлу закрывал диалог, ничего не загрузив. Лечится установкой xdg-desktop-portal-gtk в образ: тогда Chrome использует системный диалог, где двойной клик и Enter работают как положено.

Окно, которое нельзя вернуть. В киоске нет панели задач, и пользователь, свернувший Chrome, оставался перед черным экраном. Кнопки окна убрала настройка Chrome “использовать системную рамку”, а небольшой скрипт раз в секунду разворачивает окно обратно, если его свернули двойным кликом по панели вкладок.

Обновления только руками. Образы закреплены по digest, а не по тегу latest. Новая версия Chrome проверяется на одном рабочем месте, потом раскатывается партиями по 2-3 контейнера. Случайный docker compose pull больше ничего не сломает.

О минусах

HTTP Basic Auth - компромисс: в браузере нет кнопки “выйти”, пароль кешируется до закрытия браузера, второго фактора нет. Готовый портал входа на плагине Caddy я пробовал, но он навязывал TOTP и ломал редирект после входа, и я откатился. Для более широкой аудитории стоит поставить перед рабочими местами Forward Auth с Authelia или сделать собственную страницу входа под задачи команды.

Рабочее место - это общий экран, а не персональная сессия: если два человека зайдут под одним паролем, они будут управлять одним браузером. Поэтому у каждого сотрудника свое место и свой пароль.

И это администрирование, а не готовый сервис: мониторинг памяти, алерты, обновления образов и резервное копирование профилей остаются на вас. Для управления местами я написал небольшую утилиту, которая включает, отключает и сбрасывает место и меняет пароль, и инструкцию для администратора клиента, чтобы рутина не требовала моего участия.

Итог

Браузер в контейнере на обычном VPS оказался отличной заменой VDI для команды, которая работает в вебе: каждое место обходится в сотни рублей, а не в тысячи, вход по ссылке, никаких лицензий Windows. Но Selkies - это конструктор, а не продукт: память, запуск, раскладка, диалоги и обновления пришлось доводить руками, и без мониторинга такая система долго не проживет. Если вам нужен похожий сервер для команды, напишите мне: я настраиваю такое в рамках администрирования серверов и мониторинга.

Нужна помощь с инфраструктурой?

Проведу аудит серверов, деплоя и мониторинга, предложу план и возьму поддержку на себя.

Другие статьи