Как я научил пул виртуалок гаситься самому — и почему «занята ли машина» оказалось четырьмя разными вопросами

—

от автора

Речь пойдёт про VDI-портал: систему, которая клонирует виртуалки из шаблона, выдаёт их пользователям и гасит простаивающие. Но статья не о нём как о продукте, а об одном вопросе, который на входе выглядит тривиальным: этой машиной ещё пользуются? Ссылка на код будет в конце, одна.

Нужен был пул виртуалок под технический отдел. Много. Держать их все включёнными не на чем: одновременно работает хорошо если четверть, а ресурсы гипервизора кончаются сразу и у всех.

Первое решение было очевидным — скрипт по таймауту. Никто не подключён N минут — машина гасится. Понадобилась — поднимается.

Скрипт я написал за вечер, и он даже работал, но по итогу я изобрёл VDI. Только не так, что посмотрел готовые решения и они меня не устроили, — я вообще не формулировал свою задачу как «нужен VDI» и потому не искал. Я решал «пусть виртуалки сами гасятся», а всё остальное наросло само, вопрос за вопросом. Когда стало очевидно, во что это превратилось, переезжать на готовое было уже и поздно, и незачем.

Дальше разбираю вопросы, которые нарастали.


Один вопрос оказался четырьмя

Сначала я думал, что у машины есть состояние «занята», и его надо просто научиться определять. Оказалось, что «занята» — это четыре разных вопроса, и ответы у них расходятся.

  • Можно ли выдать эту машину новому пользователю? Это про ёмкость.

  • Идёт ли на ней работа прямо сейчас? Это про то, снимать ли бронь и можно ли считать машину простаивающей.

  • Залогинен ли на ней ещё хоть кто-нибудь? Не работает ли — а именно залогинен, пусть даже отключился и ушёл.

  • Подключён ли кто-то к ней в эту секунду? Тоже не то же самое.

Пока в пуле по одному пользователю на машину, все четыре ответа совпадают, и кажется, что вопрос один. Расходятся они на первом же нестандартном случае.

Самый показательный — заблокированный экран. Человек нажал Win+L и ушёл на обед. Работа не идёт: ввода нет, машину можно считать простаивающей и снимать бронь. Но сессия жива, и выключать машину нельзя — вернувшись, он потеряет всё несохранённое.

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

В коде это четыре отдельные функции, и в докстринге у них честно написано «не сливайте обратно»:

# backend/app/services/activity.py"можно ли выдать машину НОВОМУ пользователю?" -> vm_is_occupied"идёт ли работа прямо сейчас?"                -> vm_is_busy"залогинен ли на ней ещё кто-нибудь?"         -> vm_has_logged_in_user"подключён ли кто-то прямо сейчас?"           -> vm_has_connected_session

Разные вопросы опираются на разные признаки. «Идёт ли работа» смотрит на время бездействия ввода. «Подключён ли» — на наличие активной сессии, независимо от мыши: человек может читать документ, не трогая ввод десять минут, и он подключён. А отключённая сессия (закрыл RDP-клиент, сессия осталась) — это уже «залогинен», но не «подключён».

Кто отвечает на вопрос — гость, а не портал

Наивная версия смотрела на портал: есть открытая сессия — машина занята.

Ломается это на первом же человеке, который подключился по обычному RDP, мимо портала. Для портала он не существует. Машина числится свободной и гасится у него под руками.

Значит, спрашивать надо саму машину. Внутрь гостя ставится агент — небольшой сервис на Go, который раз в несколько секунд отправляет бекенду снимок происходящего.

Дальше я потратил день на то, чего не ожидал.

Первым делом я взял GetLastInputInfo — стандартный способ узнать, сколько не было ввода. Он возвращал ерунду.

Агент работает как служба Windows, а службы живут в сессии 0. У сессии 0 нет своего ввода. Спрашивать её, когда последний раз двигали мышью, бессмысленно — там никто никогда мышь не двигал.

Правильный путь — перечислить сессии Terminal Services и опросить каждую отдельно (WTSSessionInfoEx). Заодно становится видно, кто именно работает.

Второе оказалось тоньше. Заблокированная сессия честно отвечает «ввода не было секунду назад» — экран же заблокирован, откуда ввод. Технически правда, по смыслу ложь: машина не занята, а брошена. Если отдавать этот честный ноль, машина остаётся «занятой» навсегда и не гасится никогда.

Поэтому агент для заблокированных и отключённых сессий отдаёт заведомо большое значение — «не используется», а не «только что пользовались»:

// agent/activity.gotype Activity struct {    // Минимальное бездействие среди активных незаблокированных сессий,    // либо заведомо большая заглушка, если все сессии заблокированы или    // отключены. Но никогда не вводящий в заблуждение ноль.    IdleSeconds int    // Есть ли хоть одна интерактивная сессия — активная или отключённая.    LoggedIn bool    // Кто работает прямо сейчас.    ActiveUser string    // Другой вопрос: кто ВЛАДЕЕТ рабочим столом. Заблокированная    // сессия — это всё ещё занятый слот.    Users []string}

Минимум по активным незаблокированным сессиям, а не среднее и не максимум: машина простаивает, только если простаивают все.

Грабля с Windows 7 и Go 1.21

Агент собрался, поставился на Windows 10, заработал. Поставил на Windows 7 — служба стартует и мгновенно умирает. Без внятной ошибки.

Go 1.21 полностью выкинул поддержку Windows 7, 8, 8.1, Server 2008 R2 и 2012. Бинарь, собранный любым тулчейном от 1.21, падает на инициализации рантайма: рантайм дёргает функции kernel32, которых в этих версиях просто нет.

Код агента ни при чём, он не использует ничего нового. Нужен старый компилятор, последний с поддержкой — 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

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

Карусель

Пул настроен держать пять машин наготове и удалять простаивающие. Утром смотрю на гипервизор — он всю ночь клонировал и удалял виртуалки. Непрерывно. Пользователей за ночь не было ни одного.

Цикл замкнутый, и участников в нём ровно два. Пул клонирует машину, чтобы выполнить 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=EXAMPLEdomain_joined=False        post_provision_done=False#182 отправлено, создано 8 мин назад, ответ никогда, exit=None

Машина уже в домене. Она сообщала об этом в каждом heartbeat. Просто никто не смотрел: система ждала ответа на команду вместо того, чтобы посмотреть на результат.

Лечится тем, что перестаёшь ждать ответ и начинаешь верить тому, что гость рассказывает о себе сам. Говорит «я в домене» — значит, в домене, независимо от судьбы команды.

Что в итоге

Почти все ошибки здесь — не в коде, а в формулировке вопроса.

«Занята ли машина» оказалось четырьмя вопросами. «Сколько правил простоя» — лестницей с одними часами. «Удалять ли неиспользованную машину» — вопросом о том, кто её потом создаст заново. «Вошла ли машина в домен» — вопросом о факте, а не об ответе на команду.

Сам VDI при этом оказался наименее интересной частью. Подключение к рабочему столу я отдал Apache Guacamole и обычному RDP — изобретать там нечего.

Если по итогу интересно, что там кроме описанного: пулы с автоклонированием из шаблона и выдачей по требованию, гипервизоры Proxmox и Hyper-V, вход через Active Directory с автоматическим вводом в домен и двухфакторкой, подключение через Guacamole или обычным RDP. Права раздаются группам.

Код лежит здесь: https://github.com/Pushkin-desu/VDI — собирается из исходников, готовых образов нет. Лицензия Business Source License 1.1: изучать, разворачивать для тестов и использовать некоммерчески можно свободно, для коммерческой эксплуатации нужна отдельная; через четыре года каждая версия автоматически переходит под Apache 2.0.

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

ссылка на оригинал статьи https://habr.com/ru/articles/1070176/