Речь пойдёт про 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/