Автоклонирование, 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 вида «на моей инфраструктуре сломалось вот здесь», чем ещё месяц тестировать проект на собственном стенде.

Если идея кажется полезной — поставьте звезду репозиторию. Так я хотя бы пойму, стоит ли развивать это дальше.