Автоклонирование, AD, Guacamole, RDP, определение реальной активности пользователей и автоматическое освобождение ресурсов.
Мне нужен был довольно простой VDI.
Есть Proxmox и Hyper-V, есть несколько шаблонов Windows, есть десятки пользователей. Пользователю нужна виртуальная машина — пусть нажимает кнопку и получает её. Закончил работать — машина через какое-то время освобождается. Свободных машин мало — система сама создаёт новые. Ночью никто не работает — гипервизор не должен бессмысленно держать весь парк включённым.
Начиналось всё со скрипта по таймауту: никто не подключён N минут — гасим. Написал за вечер, и он даже работал. Закончилось self-hosted порталом, который сейчас умеет создавать виртуалки из шаблонов Proxmox и Hyper-V, держать пул готовых машин, выдавать их по кнопке, определять реальную активность внутри гостя и гасить простаивающие по лестнице правил. Плюс ввод в AD, авторизация по домену с TOTP, подключение через Guacamole или обычный RDP, раздача прав группами и журнал событий.
Исходники на GitHub, ссылка в конце. Стенд поднимается из исходников, на чистой Debian это минут десять.
Но самое интересное началось не с клонирования виртуалок. Самой неприятной частью оказался вопрос:
«Этой машиной сейчас кто-нибудь пользуется?»
Я думал, ответ будет true или false. Оказалось, это минимум четыре разных вопроса. Дальше — про них и про ещё пару граблей.
Да, по итогу я изобрёл VDI. Но я не искал готовое решение и не сравнивал — я просто решал задачу «пусть виртуалки сами гасятся», а всё остальное наросло само, вопрос за вопросом.
Почему не просто Guacamole
Guacamole решает задачу «как подключиться к уже существующей машине». Он умеет RDP в браузере, и умеет прекрасно — поэтому я его и взял, изобретать свой протокол доставки рабочего стола было бы странно.
Мой проект решает другую задачу: откуда эта машина возьмётся, кому её выдать, когда забрать обратно, когда удалить и когда создать следующую. Guacamole внутри используется только как средство подключения.
Замену Citrix или VMware Horizon я не писал. Это оркестратор жизненного цикла:
шаблон → клон → подготовка → пользователь → работа → простой → возврат или удаление
Как это устроено
Портал написан на Flask с Celery и Postgres. Он общается с гипервизорами через их API: Proxmox — через REST, Hyper-V — через PowerShell по SSH. Подключение к рабочему столу отдаётся через Apache Guacamole.
Внутри каждой выданной машины живёт гостевой агент — маленький сервис на Go. Он раз в несколько секунд отправляет бекенду heartbeat: кто залогинен, сколько бездействует, заблокирован ли экран. Дальше политика простоя решает, что делать с машиной.
Дальше — про то, почему этот простой на вид кусок оказался самым тяжёлым.
Один вопрос оказался четырьмя
Сначала я думал, что у машины есть состояние «занята», и его надо просто научиться определять. Оказалось, что «занята» — это четыре разных вопроса, и ответы у них расходятся.
Можно ли выдать эту машину новому пользователю? Это про ёмкость.
Идёт ли на ней работа прямо сейчас? Это про то, снимать ли бронь и считать ли машину простаивающей.
Залогинен ли на ней ещё хоть кто-нибудь? Не работает ли — а именно залогинен, пусть даже отключился и ушёл.
Подключён ли кто-то к ней в эту секунду? Тоже не то же самое.
Пока в пуле по одному пользователю на машину, все четыре ответа совпадают, и кажется, что вопрос один. Расходятся они на первом же нестандартном случае.
Самый показательный — заблокированный экран. Человек нажал Win+L и ушёл на обед. Работа не идёт: ввода нет, машину можно считать простаивающей и снимать бронь. Но сессия жива, и выключать машину нельзя — вернувшись, он потеряет всё несохранённое.
То есть на вопрос «работает ли он» ответ «нет», а на вопрос «можно ли гасить» — тоже «нет». Один критерий на оба вопроса не годится, и если это не развести, система будет либо гасить машины под людьми, либо не гасить их никогда.
В коде это четыре отдельные функции, и в докстринге у них честно написано «не сливайте обратно»:
# backend/app/services/activity.py "можно ли выдать машину НОВОМУ пользователю?" -> vm_is_occupied "идёт ли работа прямо сейчас?" -> vm_is_busy "залогинен ли на ней ещё кто-нибудь?" -> vm_has_logged_in_user "подключён ли кто-то прямо сейчас?" -> vm_has_connected_session
Разные вопросы опираются на разные признаки. «Идёт ли работа» смотрит на время бездействия ввода. «Подключён ли» — на наличие активной сессии независимо от мыши: человек может читать документ, не трогая ввод десять минут, и он подключён. Отключённая сессия — когда закрыл RDP-клиент, а сессия осталась, — это уже «залогинен», но не «подключён».
Спрашивать надо гостя, а не портал
Наивная версия смотрела на портал: есть открытая сессия — машина занята.
Ломается это на первом же человеке, который подключился по обычному RDP, мимо портала. Для портала он не существует. Машина числится свободной и гасится у него под руками.
Отсюда и взялся агент внутри гостя. И вот здесь я потратил день на то, чего не ожидал.
Первым делом я взял GetLastInputInfo — стандартный способ узнать, сколько не было ввода. Он возвращал ерунду.
Агент работает как служба Windows, а службы живут в сессии 0. У сессии 0 нет своего ввода. Спрашивать её, когда последний раз двигали мышью, бессмысленно — там никто никогда мышь не двигал.
Правильный путь — перечислить сессии Terminal Services и опросить каждую отдельно (WTSSessionInfoEx). Заодно становится видно, кто именно работает.
Второе оказалось тоньше. Заблокированная сессия честно отвечает «ввода не было секунду назад» — экран же заблокирован, откуда ввод. Технически правда, по смыслу ложь: машина не занята, а брошена. Если отдавать этот честный ноль, машина остаётся «занятой» навсегда и не гасится никогда.
Поэтому агент для заблокированных и отключённых сессий отдаёт заведомо большое значение — «не используется», а не «только что пользовались»:
// agent/activity.go type Activity struct { // Минимальное бездействие среди активных незаблокированных сессий, // либо заведомо большая заглушка, если все сессии заблокированы или // отключены. Но никогда не вводящий в заблуждение ноль. IdleSeconds int // Есть ли хоть одна интерактивная сессия — активная или отключённая. LoggedIn bool // Кто работает прямо сейчас. ActiveUser string // Другой вопрос: кто ВЛАДЕЕТ рабочим столом. Заблокированная // сессия — это всё ещё занятый слот. Users []string }
Минимум по активным незаблокированным сессиям, а не среднее и не максимум: машина простаивает, только если простаивают все.
Грабля с Windows 7
Агент собрался, поставился на Windows 10, заработал. Поставил на Windows 7 — служба стартует и мгновенно умирает. Без внятной ошибки.
Go 1.21 полностью выкинул поддержку Windows 7, 8, 8.1, Server 2008 R2 и 2012. Бинарь, собранный любым тулчейном от 1.21, падает на инициализации рантайма: рантайм дёргает функции kernel32, которых в этих версиях просто нет.
Для VDI это важно: старые гостевые ОС в парке — обычное дело, ради них половина таких проектов и существует. А без агента машина не умеет сообщать о своей занятости — то есть перестаёт работать главная функция.
Код агента при этом ни при чём, он не использует ничего нового. Нужен старый компилятор, последний с поддержкой — Go 1.20.14. Бинари получаются функционально идентичными, отличается только тулчейн.
Неприятно то, что отличить их по файлу нельзя: оба помечают минимальную версию ОС как «MS Windows 6.01», и file показывает их одинаково. Единственный надёжный признак — вшитая версия тулчейна.
Поэтому установщик перед регистрацией службы просто запускает бинарь и смотрит, взлетит ли он. Ошибка при установке гораздо лучше, чем служба со статусом «работает», которая умерла две секунды назад.
Одного таймаута оказалось мало
Дальше выяснилось, что правила «через N минут сделать X» не хватает. Реальное пожелание звучит как «погасить через полчаса, а если и через три дня никто не пришёл — удалить совсем».
Причём часы одни. Это не два независимых счётчика, а лестница: после того как на тридцатой минуте сработало выключение, тот же счётчик продолжает идти к удалению на третьи сутки.
Соблазнительно завести бухгалтерию — колонку «правило номер два уже применено». Не нужно. Достаточно спрашивать не «что уже сделали», а «что ещё можно сделать с машиной в её текущем состоянии»:
# backend/app/services/idle_policy.py _ACTION_APPLIES_TO = { 'stop': ('running',), 'pause': ('running',), # Погашенная машина всё ещё занимает диск и слот в max_size, # поэтому удаление осмысленно и после выключения. 'delete': ('running', 'stopped', 'paused'), }
Выключить уже выключенную машину — пустая операция, поэтому ступень «выключить» для неё просто перестаёт быть применимой, и лестница едет дальше сама. Никакого состояния синхронизировать не надо.
Выбор ступени идёт сверху вниз, от самой суровой:
def select_rule(rules, idle_minutes, vm_status): for rule in sorted(normalize_rules(rules), key=lambda r: r['minutes'], reverse=True): if idle_minutes < rule['minutes']: continue if vm_status in _ACTION_APPLIES_TO.get(rule['action'], ()): return rule return None
Сверху вниз — потому что машина, простоявшая трое суток, прошла обе отметки, и оператор имел в виду ту, что суровее. Сначала погасить, а удалить через цикл — это просто более медленный способ прийти туда же.
Как мой VDI однажды всю ночь создавал и удалял виртуалки
В портале есть min_size: я хочу всегда иметь пять готовых рабочих столов. И есть политика: неиспользуемые машины удалять.
По отдельности обе функции работали идеально. Вместе они устроили DoS моему же гипервизору.
Утром смотрю — он всю ночь клонировал и удалял виртуалки. Непрерывно. Пользователей за ночь не было ни одного.
Цикл замкнутый, и участников в нём ровно два. Пул клонирует машину, чтобы выполнить min_size. К ней никто не подключается. Срабатывает удаление по простою. Пул тут же клонирует замену, чтобы снова выполнить min_size. И так до утра.
Порвать можно любую сторону, но правильная — удаление. min_size — это явное требование оператора «держи столько машин тёплыми», выполнять его не ошибка.
Дальше важна точность формулировки, иначе лечение окажется хуже болезни. Удаление придерживается, только если верны оба условия сразу:
def should_skip_delete(*, ever_used, managed_vm_count, min_size, auto_provision): if ever_used: return False # машиной пользовались — удаляем, в этом весь смысл if not auto_provision: return False # замену никто не создаст, цикла не будет return managed_vm_count <= (min_size or 0)
Первое условие обязательно, потому что смысл удаления по простою — выдать следующему чистый стол, а не обжитый предыдущим. Машина, в которую никто не заходил, уже чистая: удалить её значит получить точно такую же.
Второе не даёт превратить это в «никогда не удалять неиспользованные». Выше min_size замену никто не клонирует, поэтому брошенный клон — созданный под пользователя, который передумал, — удаляется как обычно и освобождает слот.
А выключение и пауза не придерживаются вовсе: погашенная машина по-прежнему считается в min_size, и цикл рвётся сам собой.
Верить факту, а не ответу на команду
Клоны надо заводить в Active Directory. Скрипт ввода в домен заканчивается перезагрузкой, иначе членство не применится.
Здесь всё вставало намертво. Агент получает команду, выполняет, машина уходит в перезагрузку. Агент возвращается — уже без памяти о том, что выполнял. Ответ не приходит никогда. Запись в очереди висит в статусе «отправлено» пятнадцать минут, пока её не подберёт переотправка зависших команд. Меньше таймаут сделать нельзя: медленная загрузка Windows легко съедает и десять.
Всё это время машина числится не введённой в домен, и подготовка клона стоит.
agent_part_of_domain=True domain=EXAMPLE domain_joined=False post_provision_done=False #182 отправлено, создано 8 мин назад, ответ никогда, exit=None
Машина уже в домене. Она сообщала об этом в каждом heartbeat. Просто никто не смотрел: система ждала ответа на команду вместо того, чтобы посмотреть на результат.
Лечится тем, что перестаёшь ждать ответ и начинаешь верить тому, что гость рассказывает о себе сам. Говорит «я в домене» — значит, в домене, независимо от судьбы команды.
Кому это может пригодиться
Тестировщикам и разработчикам. Нужно тридцать одинаковых Windows-машин, но одновременно работают восемь человек. Остальные двадцать две не должны жечь ресурсы просто потому, что когда-нибудь понадобятся.
В учебном классе. Началась лабораторная — студент получил чистую машину с нужным софтом. Закончил — машина уничтожена, следующий получает такую же чистую. Между занятиями ничего делать не нужно.
В техническом отделе. Однотипные временные рабочие столы под конкретное ПО, которое не хочется ставить на рабочие ноутбуки.
В небольшой компании на Proxmox. Не хочется внедрять большую VDI-инфраструктуру, но хочется self-service: сотрудник зашёл, нажал кнопку, получил рабочий стол.
Чего пока нет
Честный список, чтобы никто не разворачивал стенд с неверными ожиданиями.
Гипервизоры только Proxmox VE и Hyper-V. В форме подключения виден пункт VMware vSphere — он помечен «скоро» и выбрать его нельзя.
Протокол подключения к рабочему столу только RDP. Linux-гости подключаются тем же RDP через xrdp.
Готовых образов нет: стек собирается из исходников на месте. Шаблоны ВМ тоже готовите сами — они всё равно специфичны для вашего гипервизора и гостевой ОС.
И главное: в чужом продакшене это не обкатано. Работало на моих стендах, контрольная установка с нуля на чистой Debian занимает минут десять, но за пределами моих стендов систему никто не проверял.
Попробовать
Проект сейчас ровно в той стадии, когда ему нужны чужие стенды. Особенно интересны кластеры Proxmox, Hyper-V, разные версии Windows в гостях, большие пулы, нестандартно устроенные AD и сценарии с десятками одновременно работающих пользователей.
Исходники и инструкция по установке: https://github.com/Pushkin-desu/VDI
Лицензия Business Source License 1.1: изучать, разворачивать для тестов и использовать некоммерчески можно свободно, для коммерческой эксплуатации нужна отдельная; через четыре года каждая версия автоматически переходит под Apache 2.0.
Есть тестовый Proxmox или Hyper-V — попробуйте поднять. Мне сейчас гораздо интереснее получить issue вида «на моей инфраструктуре сломалось вот здесь», чем ещё месяц тестировать проект на собственном стенде.
Если идея кажется полезной — поставьте звезду репозиторию. Так я хотя бы пойму, стоит ли развивать это дальше.

