惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

Stack Overflow Blog
Stack Overflow Blog
P
Privacy International News Feed
U
Unit 42
B
Blog
GbyAI
GbyAI
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
Forbes - Security
Forbes - Security
Engineering at Meta
Engineering at Meta
J
Java Code Geeks
Scott Helme
Scott Helme
MongoDB | Blog
MongoDB | Blog
H
Help Net Security
美团技术团队
T
Threat Research - Cisco Blogs
MyScale Blog
MyScale Blog
爱范儿
爱范儿
Schneier on Security
Schneier on Security
Y
Y Combinator Blog
C
Cisco Blogs
P
Proofpoint News Feed
T
Tenable Blog
TaoSecurity Blog
TaoSecurity Blog
aimingoo的专栏
aimingoo的专栏
Hugging Face - Blog
Hugging Face - Blog
H
Hackread – Cybersecurity News, Data Breaches, AI and More
H
Heimdal Security Blog
N
News and Events Feed by Topic
S
Security @ Cisco Blogs
N
News | PayPal Newsroom
W
WeLiveSecurity
SecWiki News
SecWiki News
小众软件
小众软件
I
InfoQ
Project Zero
Project Zero
Recent Announcements
Recent Announcements
Microsoft Azure Blog
Microsoft Azure Blog
Blog — PlanetScale
Blog — PlanetScale
Attack and Defense Labs
Attack and Defense Labs
Recent Commits to openclaw:main
Recent Commits to openclaw:main
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
Apple Machine Learning Research
Apple Machine Learning Research
S
Secure Thoughts
Martin Fowler
Martin Fowler
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
Cloudbric
Cloudbric
G
Google Developers Blog
Hacker News: Ask HN
Hacker News: Ask HN
Help Net Security
Help Net Security
C
Cybersecurity and Infrastructure Security Agency CISA
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报

Все публикации подряд на Хабре

Ловим музу за клавиатуру: как айтишнику стать автором Что умеет Midjourney в 2026? Мой немного грустный разбор этого шикарного инструмента Никто не любит писать тесты, но ИИ может исправить это IPv8 выглядит как мечта. Поэтому почти наверняка не взлетит Производители вернули в продажу материнки с DDR3. Что происходит? Управление агентом с телефона через Telegram теперь в KodaCode От координации к лидерству: как меняется роль руководителя разработки Я сделала родителям бизнес вместо пенсии: зарабатываем 70 тысяч, мама не даёт продать В три раза быстрее приемка товара и оптимизация трудозатрат на 73%: как «РСТ-Инвент» помог Gulliver Group ИИ-шечный мир победил? О влиянии искусственного интеллекта на игропром Кремль снижает давление на Телеграмм пока Европа строит интернет по паспорту Как CEO, CTO и CIO за 8 часов собрали ИИ-директора, который умеет держать позицию под давлением Как (не) потерять домен за выходные Вместо 8 разных VPS: как я организовал практику студентам на одном сервере Почему твой Open Source проект не замечают? R&D: искусство управления неопределенностью в разработке AI-дефляция: вакансий для разработчиков больше, а рост зарплат — худший за 15 лет Мы отдали управление роботами OpenClaw. Что из этого вышло Галактический ID: система идентификации для всех форм разумной жизни Шесть основ бизнес-анализа: начинаем с вопроса «Кто в игре?» Код-ревью, в котором дело не в коде Данные переехали. Команда — нет Системной подход к сдаче OSWE в 2025 Почему комната управления реактором покрашена в цвет морской пены 4 YAML-файла вместо PySpark: как аналитикам строить пайплайны без разработчиков LLM-агент для поиска свободных доменов: автоматизируем подбор Когда, зачем и как правильно начинать новую сессию в Claude Code? Как я заставил нейросеть писать макросы для FreeCAD Анатомия ИИ‑агента для подбора персонала. От тысячи резюме к топ‑10 за минуты Опыт разработчика как экономика внимания Автономность как точка невозврата: кто будет субъектом в цифровом будущем Обучение ИИ в «диких» условиях: как рутинные действия превращаются в датасеты Как измерить LLM для задач кибербеза: обзор открытых бенчмарков Где хранить код? Сравнение GitHub, GitLab и Bitbucket Математика объясняет, почему нормальное распределение встречается повсюду Почему ваш FinOps не работает: 12 тезисов от практиков Как подписать проектную документацию УКЭП с использованием бесплатных лицензий Pilot Адаптивное администрирование Sigla Vision Я грузил уран в бочки, а потом 20 лет строил ИТ в атомной отрасли Чем позвонить с Эвереста? История и обзор спутниковой связи. Часть 2 Как языковая модель помогает контролировать качество инструктажей по охране труда в металлургии Как не передать на desktop свой IP в РКН Анатомия SAP Privileges: как устроено управление правами в macOS MoneyDev: Сказка про три главных слова Обновлённый токенизатор видео K-VAE 2.0 от Сбера Как сделать диспетчеризацию дома на 1284 квартиры почти бесплатно Как мы разогнали железную дорогу Мы дали агентам рутину. Теперь надо решить — что делать с освободившимся временем Токсичный контент, промпт-хакинг и защита ИИ — всё о Guardrails для LLM Умный город начинается с точного взгляда: как «Фалькон Тех» меняет пространство к лучшему Навайбкодил приложение для анализа графов Почему Дюну так интересно читать? Упрощаем работу с рутиной или как стать Гендальфом Белым Деконструкция Go: CPU, RAM и что там происходит. Go Assembler база. Часть 1.1 Какие профессии исчезнут из-за ИИ, а какие появятся? И что с этим делать Как мы построили IT-отдел, где хочется расти: архитектурные встречи, прозрачные метрики и книжные подарки Rufler: Делаем из Claude Code автономный рой через один YAML-конфиг Sing-box и белый список приложений Как построить надёжный обмен сообщениями в микросервисах: лучшие практики для enterprise OpenAI строит MLM-пирамиду, а McKinsey и Accenture помогают ей в этом Дом, который не построил Фишер (Часть 2) «Сверхзвуковой математик» против «Вдумчивого логиста»: битва алгоритмов 3D-упаковки Мультимодальные модели – грубый и дорогой инструмент Разговоры ничего не стоят. Код тоже Проверки физических лиц: с кого начнет ФНС Топ-10 бесплатных нейросетей для создания видео в 2026 году Первые слои кода: как наши решения сегодня определяют архитектуру ИИ на десятилетия Разработка нового статического анализатора: PVS-Studio JavaScript Поиск уязвимостей ПО: базовый минимум или роскошный максимум Почему оценка персонала не работает как инструмент управления Как мы разработали ИИ-ассистента и сократили рутину продуктовой команды на 50% Как я ушел из найма, нажарил косточек и продал на маркетплейсах на 168 млн в год Когда 1С:ERP уже внедрена, а нормального производственного плана всё ещё нет Как я сделал Claude мультимодальным, подключив к нему Qwen Omni Как приглашение на вакансию мечты превращается в атаку Infrastructure as Code: философия и лучшие практики IaC Тестируем Yandex Code Assistant на задаче, в которой нужно хранить секреты nxs-universal-chart v3.0: новое поколение универсального Helm-чарта Callback Injection: Техника, которая отправила Microsoft Defender в глухой нокаут «Все идеи на стол»: митап как способ вывести проект из тупика Сегодня я узнал нечто новое о GPU благодаря багу в своей игре Как заставить LLM ̶ ̶г̶а̶л̶л̶ю̶ ̶ эволюционировать Карта событий как фундамент аналитики: практический кейс для E-commerce Что выбрать для AI: x86, ARM или RISC-V? Дайджест железа за март Роль соматических мутаций в развитии аутоиммунных заболеваний: путь к избирательной терапии Mythos от Anthropic — тревожный сигнал для всех, а не только для банков Guardrails для LLM на Java: как приручить промпт‑инъекции и токсичные ответы Green-VLA: как мы собрали VLA-модель для реального антропоморфного робота и не потеряли обобщение Финансовая гонка вооружений: почему умные люди добровольно в ней участвуют Эра ИИ-агентов наступила: выбираем лучшего цифрового сотрудника # Практический опыт внедрения WinCC Redundancy на производственном предприятии Сделал MVP за 3 дня, а потом неделю прикручивал оплату. Оно того стоило? Физика против Маска: почему Starship V3 может оказаться ещё одной катастрофой Нефть Венесуэлы: крупнейшие запасы в мире, но не крупнейшая нефтяная держава JPA 4. Переосмысление Hibernate Почему зеркальная фотокамера Nikon D5 десятилетней давности идеально подошла для миссии «Артемида-2» Проект «Уровень-Спутник» или как мы сделали платформу для гидрологов «Замедлиться, чтобы ускориться»: почему ИИ повышает цену ошибок в требованиях и архитектуре Как с нуля поднять трафик IT-компании на 1657% при бюджете 55 тыс. и выжить Pixel-perfect Downsampling — идеальная отрисовка 50 миллионов точек без потерь
Transfer 2.0, или Как я перестал бояться и полюбил миграции облачных серверов
Денис Котов · 2026-06-18 · via Все публикации подряд на Хабре

Привет, Хабр! Меня зовут Денис, я тимлид инфраструктурной Core команды в Timeweb Cloud.

Итак... представьте обычную виртуальную машину клиента. Она принимает запросы, пишет в базу, держит файловый кэш, обновляет память, что-то постоянно меняет на диске. А теперь нам нужно перевезти её с одной физической ноды на другую так, чтобы клиент не заметил переезд.

Звучит просто, пока не вспоминаешь, что у виртуальной машины есть память, диски, снапшоты, сетевые настройки, разные формат хранилищ, отличающиеся модели и вендоры CPU и состояния в базе управления. Любая из этих деталей может превратить задачу типа «перенести сервер» в ручную операцию с окном обслуживания, тикетом в поддержку и нервным инженером у консоли.

Мы переписали модуль миграции VDS так, чтобы эти детали стали частью алгоритма, а не частью ночной операционной инструкции.

В этой статье расскажу, как мы устроили живые миграции на базе libvirt, зачем оставили rsync, почему перешли на NBD для активных дисков, как выбираем RDMA или TCP, что дают SYNC_WRITES, ZEROCOPY, DETECT_ZEROES, AUTO_CONVERGE и другие флаги, и почему всё это важно не только инженерам, но и бизнесу.

Кодовые фрагменты взяты из реального модуля управляющего трансферами. Они немного сокращены и упрощены для статьи, но отражают настоящую логику. В целом материал будет полезен как сетевым инженерам, так и пользователям, для понимания внутренних процессов — что и как устроено и почему работает так, как работает.

Осторожно, дальше айтишный хардкор (будет много кода), и если не интересно через него пробираться, можно сразу перейти к общим выводам и морали.

Содержание:
  • Коротко о терминах

  • Что мы хотели получить

  • Как было раньше и что изменилось

  • Общая схема переезда

  • Почему rsync остался, но перестал быть главным героем

  • Активные диски: почему NBD лучше файлового копирования

  • Память: самая шумная часть переезда

  • Switchover: 300 миллисекунд как контракт

  • RDMA, TCP и почему fallback важнее идеального канала

  • Bandwidth: скорость нужно измерять, а не угадывать

  • DETECT_ZEROES и BANDWIDTH_AVAIL_SWITCHOVER: маленькие флаги, большой эффект

  • Прогресс миграции: не просто ждать, а понимать

  • Массовая разгрузка: несколько миграций одновременно

  • Хранилища: локальные диски, NFS и NVMe-oF в одном алгоритме

  • Конвертация формата диска: qcow2 и raw без окна обслуживания

  • Снапшоты: больше никаких «пожалуйста, удалите перед работами»

  • Миграция между AMD и Intel: когда живая миграция невозможна

  • Роллбэк: самая важная часть миграции

  • Ещё несколько деталей, которые спасают в продакшене

  • Что это дает: клиентам, эксплуатации и бизнесу

  • Мораль


❯ Коротко о терминах

Если вы не проводите с гипервизором каждый день, вот небольшой словарь.

  • KVM — механизм виртуализации в Linux. Он дает ядру возможность запускать виртуальные машины почти как обычные процессы.

  • QEMU — процесс, который эмулирует железо виртуальной машины: диски, сетевые карты, CPU и другие устройства.

  • XML — текстовое описание конфигурации виртуальной машины в формате libvirt (domain XML). В нём заданы диски и их пути, модель и топология CPU, объем памяти, сетевые устройства, настройки VNC, storage pool и прочие параметры VM. Перед миграцией этот XML нельзя просто скопировать как есть: его приводят к виду, корректному для целевой ноды (другие пути дисков, имя пула, МТС listen, пин ядер CPU), и передают в migrate3 как желаемый конфиг VM на destination.

  • VDS — виртуальный сервер клиента.

  • NBD — Network Block Device протокол блочного доступа по сети: диск отдают не цельным файлом, а потоком секторов фиксированного размера. В migrate3 QEMU/libvirt используют его для переноса активных writable-дисков: гипервизор читает блоки с работающего тома, пишет их на destination, отслеживает, какие сектора гость (виртуальная машина на гипервизоре) перезаписал (dirty blocks), и догоняет их до switchover.

  • Source node — физическая нода, где VDS работает сейчас.

  • Destination node — физическая нода, куда VDS переезжает.

  • Live migration (живая миграция) — перенос работающей VM без полного выключения. Память и диски синхронизируются заранее, а потом происходит короткое переключение.

  • Switchover — момент, когда VM окончательно перестает работать на source и продолжает работу на destination.

  • Precopy— предварительное копирование данных до основной миграции. Мы используем его только для файлов, которые гость не меняет во время переезда. Это важно: если файл может принимать записи, обычное файловое копирование даст гонку между rsync и приложением внутри VM.

  • Активный слой диска — верхний слой диска, куда VM прямо сейчас пишет изменения. Именно его опасно копировать как обычный файл во время работы сервера.

  • Backing-файл — нижний слой в цепочке qcow2. Например, базовый образ или предыдущий слой снапшота. Активный слой хранит только отличия, а недостающие блоки читаются из backing-файла.

  • Readonly-слой — диск или слой диска, который подключен только на чтение. VM может читать из него блоки, но не может записывать туда новые данные, поэтому содержимое не «убегает» от копирования.

  • Writable-диск — диск или слой, куда гость может писать прямо сейчас. Его нельзя считать статичным файлом: пока копирование идет, данные внутри могут измениться.

  • Cloud-init — внутри нашей инфраструктуры, это небольшой служебный диск с первичной конфигурацией VM: hostname, сеть, SSH-ключи, пользовательские настройки. Он тоже должен оказаться на destination, иначе после переезда можно получить неприятные сюрпризы при старте или переинициализации.

  • libvirt — API и демон управления виртуализацией. Через него мы создаем, запускаем, останавливаем и мигрируем виртуальные машины, не вызывая qemu напрямую.

  • migrate3 — один из API-вызовов libvirt для миграции VM. В отличие от «просто выполни миграцию», он принимает набор флагов и параметров: какие диски мигрировать, какую пропускную способность сети использовать, какой XML должен быть у VM на целевой ноде, включать ли живые миграции, parallel, auto-converge и другие режимы.

  • qcow2 — формат диска QEMU с поддержкой снапшотов и тонкого выделения места. Такой диск может быть не одним монолитным файлом, а цепочкой слоёв.

  • vapi-server — сервис управления compute cloud, разработка Timeweb Cloud: принимает команды от внутренних инструментов и API, ставит задачи в распределенную очередь и оркестрирует пакетные операции вроде массовой разгрузки ноды.


❯ Что мы хотели получить

Цели были довольно простыми на бумаге:

  • мигрировать VDS без простоя или с минимальным, контролируемым переключением;

  • поддержать разные типы хранилищ: локальные диски, шаренный по NFS маунт, NVMe-oF;

  • убрать ручные решения вроде «сначала отключите снапшоты»;

  • дать инженеру понятный прогресс и понятную причину остановки;

  • сделать выгрузку ноды массовой операцией, а не квестом на несколько дней.

На практике это означало: модуль должен не просто «запустить миграцию», а построить план под конкретную VM и конкретную пару нод.


❯ Как было раньше и что изменилось

Снаружи миграция всегда выглядит одинаково: сервер был на одной ноде, потом оказался на другой. 

В старой реализации активные диски во многом воспринимались как файлы: часть данных копировалась через rsync, на destination заранее создавались пустые тома, а дальше миграция должна была «догнать» изменения. Для простых случаев этого хватало. Но чем сложнее становилась инфраструктура, тем больше появлялось оговорок: снапшоты, разные типы хранилищ, общий NFS, локальные диски, смена вендора CPU, большие диски, высокая скорость записи внутри гостя.

Теперь миграция строится вокруг состояния VM, а не вокруг файлов на диске. rsync остался только для подготовительной фазы, где он действительно хорош: перенести неизменяемые слои диска и диск с конфигами для cloud-init. Активные writable-диски едут через NBD внутри migrate3, память синхронизируется средствами QEMU/libvirt, а каждый нестандартный случай сначала классифицируется и только потом попадает в план миграции.

Изменился и масштаб операции. Раньше массовая разгрузка ноды была последовательностью отдельных миграций: выбрать VDS, запустить миграцию, дождаться результата, перейти к следующей. Теперь vapi-server умеет запускать несколько независимых миграций параллельно: считает эффективную ширину канала, определяет количество in-flight переносов и делит полезную пропускную способность сети между ними. Это позволяет освобождать ноду или целый сегмент быстрее, но без хаотичного «запустить всё сразу и забить сеть».

Коротко контраст такой:

Раньше перенос сложной VDS мог требовать ручных проверок, окон обслуживания или просьб отключить снапшоты.
Теперь снапшоты и точки восстановления сохраняются автоматически.

Раньше разные типы стораджа были источником исключений и ручных решений.
Теперь модуль сам понимает, нужно ли физически переносить диск или достаточно перерегистрировать VM на новой ноде.

Раньше активная запись на диск усложняла безопасное копирование.
Теперь синхронные записи и NBD-поток помогают довести destination до консистентного состояния.

Раньше миграция между AMD и Intel была отдельной головной болью.
Теперь это штатный сценарий с контролируемой перезагрузкой.

Раньше инженер часто видел только факт ошибки.
Теперь в логах есть прогресс, скорость, время отсутствия прогресса и причина остановки.

Раньше массовая разгрузка шла почти как ручная очередь переносов.
Теперь пакетный перенос держит несколько задач одновременно и ограничивает их по фактической пропускной способности линка.

Раньше механизм миграции чаще платил временем за повторные подключения и однотипные libvirt-запросы.
Теперь сессии переиспользуются, ноды пробуются параллельно, а XML, pool, volume и версии складываются в кеши на время миграции.

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


❯ Общая схема переезда

Сегодня online-миграция разбита на несколько фаз.

  1. Мы определяем, какие диски можно не трогать, какие нужно предварительно скопировать, а какие должны ехать через NBD.

  2. Readonly-файлы, backing-файлы и диски для cloud-init при необходимости копируются заранее.

  3. Для активных writable-дисков готовятся тома на destination.

  4. Запускается migrate3: QEMU/libvirt переносит память и активные блоки дисков.

  5. Если включен RDMA и он доступен, пробуем RDMA. Если нет — безопасно падаем обратно на TCP.

  6. После успешного switchover обновляем БД, восстанавливаем снапшоты, чистим source и выполняем постмиграционные действия.

  7. Если что-то пошло не так, откатываемся так, чтобы рабочая VM осталась на source.

В коде подготовка плана выглядит так:

classified = self._classify_disks(vds_xml)

precopied_files = self._precopy_files(classified)

writable_devices, nbd_paths = self._collect_nbd_targets(
    vds_xml,
    classified,
)

flags = self._build_live_migrate_flags(
    copy_storage=bool(writable_devices),
    has_backing=bool(classified['backing_files']),
)

if classified['backing_files'] and nbd_paths:
    precopied_files |= self._precreate_dst_volumes(nbd_paths)

params = self._build_migrate_params(writable_devices)
params[libvirt.VIR_MIGRATE_PARAM_DEST_XML] = self._prepare_dest_xml(...)

Здесь важно не количество строк, а принцип: миграция стала не одной командой, а управляемым пайплайном.


❯ Почему rsync остался, но перестал быть главным героем

На первый взгляд может показаться: если мы перешли на NBD, rsync больше не нужен. На практике он нужен, но не для всего.

У диска VM может быть цепочка qcow2: активный слой, backing-файлы, снапшоты, readonly-образы. Важно не то, как файл называется, а может ли гость менять его прямо сейчас. Если слой подключен на запись, он живой: пока мы копируем начало файла, приложение внутри VM уже может изменить блоки в середине или в конце. Такой файл нельзя безопасно «просто скопировать» и считать задачу решенной. Для него нужен механизм, который понимает состояние VM и синхронизирует изменения вместе с миграцией.

А вот backing-файлы и readonly-диски обычно не являются writable-девайсами (блочные устройства клиента из пула его дисков, на которые идет активный i/o) для гостя. VM может читать из них данные, но не пишет туда новые блоки во время работы. Значит, у нас нет гонки «копируем файл, а гость в этот же момент меняет его содержимое». Такие слои можно перенести заранее обычным файловым копированием: спокойно, с лимитом полосы пропускания, без участия QEMU в основной горячей фазе.

Это и есть precopy в нашем сценарии:

def _precopy_files(self, classified: dict) -> Set[str]:
    files_to_precopy = (
        classified['local_readonly']
        | classified['backing_files']
    )

    cloud_init_path = _cloud_init_path_for_vds(self.vds_id)
    if cloud_init_path in classified.get('all_local', set()):
        files_to_precopy.add(cloud_init_path)

    pairs = [
        (src, self._translate_disk_path_for_dest(src))
        for src in files_to_precopy
    ]

    self._magical_ssh_move(pairs)
    return {dst for _, dst in pairs}

rsync здесь не «старый способ миграции», а инструмент для подготовительной фазы. Он копирует только те файлы, которые в момент миграции считаются стабильными: backing-слои, readonly-диски и диски cloud-init. Если файл может принимать записи от гостя, он не попадает в эту фазу и уезжает через NBD как часть живой миграции.

Чтобы не открывать отдельный SSH-сеанс на каждый файл, копирование идёт батчами:

def copy_files_batched(self, files, bandwidth):
    pairs = [self._normalize_pair(item) for item in files]
    max_p = max(1, int(const.TRANSFER_PRECOPY_PARALLEL))

    for i in range(0, len(pairs), max_p):
        self._copy_batch_parallel(pairs[i:i + max_p], bandwidth)

А пропускная способность сети делится между параллельными потоками:

stream_bw_mib = max(bandwidth // 8 // n, 1)

А дальше начинается маленькая бытовая магия: мы проверяем версию rsync на source-ноде и включаем zstd-сжатие только если оно реально поддерживается. У rsync поддержка --zc=zstd появилась не сразу и не везде, поэтому без проверки такая оптимизация превращается в мину: на старой ноде копирование просто упадёт до старта полезной работы.

version_line = self.src_node.execute_command(
    'rsync --version | head -1'
).strip()

m = re.search(r'(\d+)\.(\d+)\.(\d+)', version_line)
major, minor, patch = (
    int(m.group(1)),
    int(m.group(2)),
    int(m.group(3)),
)

self._rsync_zstd_supported = (major, minor, patch) >= (3, 2, 0)

Если zstd доступен, добавляем лёгкое сжатие:

compress_opts = (
    '--zc=zstd --zl=1 '
    if self._supports_rsync_zstd()
    else ''
)

return (
    f'rsync --bwlimit={bw_mib}MiB -a --whole-file --sparse '
    f'{compress_opts}'
    ...
)

Если версию определить не удалось, ничего страшного не происходит: мы просто выключаем zstd-опции и копируем без сжатия. Это ровно тот тип оптимизации, который должен ускорять хороший сценарий, но не ломать плохой.

Здесь важно различать --sparse и zstd. --sparse помогает на стороне destination: если в образе есть большие нулевые области, rsync может восстановить их как дырки в sparse-файле, а не занять реальное место на диске. Но сетевой поток от этого сам по себе не становится бесплатным. Сжатие zstd работает именно на передаче: длинные последовательности нулей и однотипных данных превращаются в маленький сжатый кусок, а не едут по сети как полноценные мегабайты нулей.

Так мы ускоряем precopy (перечень мероприятий, который формирует структуру неизменяемых дисков на принимающей ноде, до того как начнется миграция клиента), экономим сетевой трафик на разреженных образах, но не превращаем перенос в DDoS соседних клиентов на той же ноде и не делаем успех миграции зависимым от конкретной версии rsync.


❯ Активные диски: почему NBD лучше файлового копирования

Главная проблема live-миграции диска в том, что диск не стоит на месте. Пока мы копируем первый гигабайт, гость уже мог изменить блоки в середине и в конце.

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

Мы отдаем эту задачу libvirt и QEMU через migrate3. Для дисков есть два основных режима:

  • NON_SHARED_DISK — диск не общий, backing-цепочки нет; libvirt может создать образ на destination и перенести его целиком;

  • NON_SHARED_INC — есть backing-файлы или снапшоты; backing мы готовим сами, а активные изменения едут инкрементально.

def _build_live_migrate_flags(self, *, copy_storage, has_backing=False) -> int:
    flags = self._BASE_MIGRATE_FLAGS | _lv('VIR_MIGRATE_UNSAFE')

    if copy_storage:
        if has_backing:
            flags |= libvirt.VIR_MIGRATE_NON_SHARED_INC
        else:
            flags |= libvirt.VIR_MIGRATE_NON_SHARED_DISK

        if self._can_use_sync_writes():
            flags |= _lv('VIR_MIGRATE_NON_SHARED_SYNCHRONOUS_WRITES')

    return flags

Самый важный флаг здесь — VIR_MIGRATE_NON_SHARED_SYNCHRONOUS_WRITES, или коротко SYNC_WRITES.

Обычная миграция старается догнать изменения: скопировали блок, гость изменил его снова, скопировали ещё раз. SYNC_WRITES делает поведение строже: во время миграции записи синхронно попадают в обе стороны. Это дороже, но дает более надежную сходимость и меньше риска, что перед switchover активный диск на destination будет отставать.

Именно это превращает сценарий из «мы надеемся, что всё догонится» в «мы контролируем консистентность до момента переключения».


❯ Память: самая шумная часть переезда

Диски хотя бы можно разложить по типам. С памятью сложнее: VM продолжает работать, приложения пишут в RAM, копятся грязные страницы памяти, и миграции нужно снова и снова передавать изменившиеся страницы.

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

Базовые флаги:

_BASE_MIGRATE_FLAGS = (
    libvirt.VIR_MIGRATE_LIVE
    | libvirt.VIR_MIGRATE_PERSIST_DEST
    | libvirt.VIR_MIGRATE_UNDEFINE_SOURCE
    | libvirt.VIR_MIGRATE_CHANGE_PROTECTION
    | libvirt.VIR_MIGRATE_AUTO_CONVERGE
)

Что они дают:

  • LIVE — переносим ротающую VM, а не выключенную;

  • PERSIST_DEST — после миграции домен остается определенным на destination;

  • UNDEFINE_SOURCE — после успешного переезда домен убирается с source;

  • CHANGE_PROTECTION — libvirt защищает домен от конкурентных изменений во время миграции;

  • AUTO_CONVERGE — если гость слишком активно пачкает память, QEMU постепенно притормаживает vCPU, чтобы миграция смогла догнать изменения.

Мы также задаем параметры auto-converge:

params = {
    libvirt.VIR_MIGRATE_PARAM_BANDWIDTH: bandwidth_mbs,
    _lv('VIR_MIGRATE_PARAM_AUTO_CONVERGE_INITIAL'): 30,
    _lv('VIR_MIGRATE_PARAM_AUTO_CONVERGE_INCREMENT'): 15,
}

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

Для TCP-миграции добавляем ещё два ускорителя:

def _tcp_flags_and_params(self, flags: int, params: dict) -> Tuple[int, dict]:
    tcp_flags = flags
    tcp_params = dict(params)
    min_ver = min(self.src_libvirt_ver, self.dst_libvirt_ver)

    if min_ver >= _LIBVIRT_VERSION_PARALLEL:
        tcp_flags |= _lv('VIR_MIGRATE_PARALLEL')
        tcp_params[_lv('VIR_MIGRATE_PARAM_PARALLEL_CONNECTIONS')] = (
            self.parallel_connections
        )

    if min_ver >= _LIBVIRT_VERSION_ZEROCOPY:
        tcp_flags |= _lv('VIR_MIGRATE_ZEROCOPY')

    return tcp_flags, tcp_params

PARALLEL включает несколько TCP-соединений для миграции. Это полезно на широких каналах: один поток не всегда способен утилизировать 25/40/100 Gbit/s.

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


❯ Switchover: 300 миллисекунд как контракт

Живая миграция не означает «вообще ноль паузы». В конце всё равно есть момент, когда source останавливается, остаток состояния передается, а destination становится активным.

Мы ограничиваем этот момент:

TRANSFER_MAX_DOWNTIME_MS = 300

self.vds.migrateSetMaxDowntime(
    const.TRANSFER_MAX_DOWNTIME_MS,
    0,
)

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


❯ RDMA, TCP и почему fallback важнее идеального канала

Если на нодах есть RDMA, мы хотим использовать его. RDMA позволяет передавать данные между машинами с меньшей нагрузкой на CPU и стабильной задержкой. Для миграции памяти это особенно ценно: поток большой, чувствительный к latency, а CPU на гипервизоре и так занят клиентскими VM.

Но RDMA — не магия. Он зависит от железа, драйверов, сети, маршрутизации и состояния интерфейсов. Поэтому логика такая:

  1. Проверяем, есть ли IB/RDMA-интерфейсы на обеих нодах.

  2. Если готовы — пробуем RDMA.

  3. Если RDMA не стартовал безопасно — откатываем job и запускаем TCP.

  4. Если уже упал TCP-путь, это настоящая ошибка миграции, и мы чистим destination.

def _do_live_migrate(self, flags, params, precopied_files, classified):
    rdma_attempted = self.use_rdma and self._rdma_ready

    if rdma_attempted and self._try_rdma_migrate(flags, params):
        return

    tcp_flags, tcp_params = self._tcp_flags_and_params(flags, params)
    self._migrate3_with_progress(
        self.dst_node_session,
        flags=tcp_flags,
        params=tcp_params,
    )

RDMA-попытка выглядит так:

def _try_rdma_migrate(self, flags: int, params: dict) -> bool:
    rdma_uri = f'rdma://{self.dst_node}{const.LOCAL_FULL_DOMAIN}'
    rdma_params = {
        **params,
        libvirt.VIR_MIGRATE_PARAM_URI: rdma_uri,
    }
    rdma_flags = flags | _lv('VIR_MIGRATE_RDMA_PIN_ALL')

    try:
        self._migrate3_with_progress(
            self.dst_node_session,
            flags=rdma_flags,
            params=rdma_params,
        )
    except (libvirt.libvirtError, KVMError):
        self._safe_abort_job()
        return False

    return True

RDMA_PIN_ALL заранее закрепляет память, чтобы RDMA мог безопасно работать с ней напрямую. Это полезно для производительности, но несовместимо с частью TCP-ускорителей, поэтому PARALLEL и ZEROCOPY мы включаем только для TCP.

Главная ценность здесь не в том, что «мы умеем в RDMA». Главная ценность в том, что сбой RDMA не превращается в инцидент для клиента.


❯ Bandwidth: скорость нужно измерять, а не угадывать

У миграции есть неприятная особенность: она может занять весь канал. Если дать ей слишком мало полосы пропускания, нода освобождается медленно. Если дать слишком много, страдают соседние VM.

Поэтому мы не полагаемся только на дефолт. Модуль пробует определить скорость линка через libvirt:

def _parse_net_device_xml(xml_desc: str) -> Tuple[List[int], List[str]]:
    lan_speeds = []
    ib_names = []

    for cap in root.findall(".//capability[@type='net']"):
        name = cap.find("interface").text.strip()

        if _is_lan_numbered_interface(name):
            raw = cap.find("link").get("speed")
            lan_speeds.append(int(raw))
        elif name.startswith("ib"):
            ib_names.append(name)

    return lan_speeds, ib_names

Затем берем эффективную скорость:

raw_sum = sum(lan_list)
eff = (raw_sum // 2) if lan_list else None

Почему делим пополам? Это консервативный запас. Нам важнее не выжать красивую цифру на графике, а не устроить сетевой пожар на ноде.

Количество параллельных TCP-соединений тоже зависит от канала:

def calc_parallel_connections(speed_mbps: int) -> int:
    return max(2, speed_mbps // 5_000)

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


❯ DETECT_ZEROES и BANDWIDTH_AVAIL_SWITCHOVER: маленькие флаги, большой эффект

Часть фич доступна только на новых версиях libvirt. Мы проверяем версии обеих нод и включаем возможности только когда они реально поддерживаются.

if disk_devices:
    params[libvirt.VIR_MIGRATE_PARAM_MIGRATE_DISKS] = disk_devices

    if min_ver >= _LIBVIRT_VERSION_DETECT_ZEROES:
        params[_lv('VIR_MIGRATE_PARAM_MIGRATE_DISKS_DETECT_ZEROES')] = (
            disk_devices
        )

if min_ver >= _LIBVIRT_VERSION_BW_SWITCHOVER:
    params[_lv('VIR_MIGRATE_PARAM_BANDWIDTH_AVAIL_SWITCHOVER')] = (
        bandwidth_mbs
    )

DETECT_ZEROES помогает не передавать пустые области диска как полезные данные. Это особенно заметно на больших разреженных дисках, где фактических данных меньше, чем логический размер тома.

BANDWIDTH_AVAIL_SWITCHOVER подсказывает libvirt, какая пропускная способность доступна на финальном переключении. Это помогает точнее выбрать момент switchover и удержать время переключения в заданном окне.

У нас есть и техническая защита для старых биндингов Python:

def _lv(name: str):
    return getattr(libvirt, name, _LV_RAW[name])

Эта маленькая функция закрывает очень практичную проблему большого парка: версии libvirt состоят не из одного компонента. На ноде может быть свежий libvirtd, который уже умеет ZEROCOPY, DETECT_ZEROES или BANDWIDTH_AVAIL_SWITCHOVER, но в Python-окружении стоит более старый пакет python-libvirt, где соответствующего имени константы ещё нет.

Если в коде просто написать libvirt.VIR_MIGRATE_ZEROCOPY, старые биндинги упадут ещё до попытки миграции: Python не найдёт атрибут. Хотя сам гипервизор на ноде эту возможность уже поддерживает.

Поэтому для новых флагов мы храним ABI-стабильные значения:

_LV_RAW = {
    'VIR_MIGRATE_PARALLEL': 131072,
    'VIR_MIGRATE_NON_SHARED_SYNCHRONOUS_WRITES': 262144,
    'VIR_MIGRATE_ZEROCOPY': 1048576,
    'VIR_MIGRATE_PARAM_MIGRATE_DISKS_DETECT_ZEROES': (
        'migrate_disks_detect_zeroes'
    ),
    'VIR_MIGRATE_PARAM_BANDWIDTH_AVAIL_SWITCHOVER': (
        'bandwidth.avail.switchover'
    ),
}

Для флагов это числовые значения из ABI libvirt, для типизированных параметров — строковые ключи, которые libvirt передает в migrate3. Смысл в том, чтобы не блокировать новую функциональность из-за старой Python-обёртки.

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


❯ Прогресс миграции: не просто ждать, а понимать

migrate3 — блокирующий вызов. Если просто вызвать его и ждать, инженер видит тишину: работает оно, зависло или уже надо вмешиваться?

Мы запускаем миграцию в отдельном потоке и периодически читаем jobStats():

thread = threading.Thread(target=_run, daemon=True)
thread.start()

while not done.wait(timeout=_MIGRATION_POLL_INTERVAL):
    stats = self.vds.jobStats()

    total_proc = (
        stats.get('data_processed', 0)
        + stats.get('disk_processed', 0)
    )

    if prev_processed is None or total_proc > prev_processed:
        last_progress = time.monotonic()
        prev_processed = total_proc

В лог уходит процент, оставшиеся данные, скорость диска, скорость памяти и время без прогресса:

logger.info(
    'migration progress: %.1f%%, processed=%s, '
    'remaining: data=%s disk=%s, bps: disk=%s mem=%s, '
    'stall=%.0fs, elapsed=%.0fs',
    pct,
    total_proc,
    stats.get('data_remaining', 0),
    stats.get('disk_remaining', 0),
    stats.get('disk_bps', 0),
    stats.get('memory_bps', 0),
    stall,
    time.monotonic() - start,
)

Если прогресса нет слишком долго, мы не ждём бесконечно:

if stall > _MIGRATION_STALL_TIMEOUT:
    migrate_result['error'] = KVMError(
        f'Migration stalled: no progress for '
        f'{_MIGRATION_STALL_TIMEOUT}s'
    )
    self._safe_abort_job()
    break

Важная деталь: прогресс считаем по processed, а не по падению remaining. На больших дисках во время pre-copy remaining может временно расти: гость продолжает писать, грязные страницы появляются быстрее, чем исчезают. Если смотреть только на remaining, можно принять живую миграцию за зависшую.


❯ Массовая разгрузка: несколько миграций одновременно

Одна быстрая миграция полезна, когда нужно перевезти конкретную VDS. Но в реальной эксплуатации часто задача звучит иначе: «освободить ноду», «вывести стойку из обслуживания», «разгрузить сегмент перед работами», «быстро увести клиентов с железа, которое начинает деградировать».

Если переносить серверы строго по одному, мы снова упираемся во время. Если запустить все миграции сразу, можно забить сеть, диски и получить обратный эффект: каждая отдельная миграция станет медленнее, а соседние VM почувствуют нагрузку. Поэтому в vapi-server появилась отдельная оркестрация пакетного переноса.

Идея простая: Внутри vapi-server теперь есть два модуля - первый отвечает за последовательную миграцию, второй — за очередь из нескольких VDS. Перед стартом пакета мы оцениваем эффективную скорость пути между source и destination через тот же libvirt-probe, берём минимум между сторонами и считаем, сколько одновременных переносов можно держать в полёте.

_MBIT_PER_CONCURRENT_TRANSFER = 5_000

slots = max(1, eff // _MBIT_PER_CONCURRENT_TRANSFER)

То есть правило такое: примерно один параллельный перенос на каждые 5 Gbit/s эффективного канала. Если канал маленький, будет хотя бы один слот. Если канал широкий, можно держать несколько миграций одновременно.

Но важна вторая половина алгоритма: мало ограничить количество задач, нужно ещё не дать каждой задаче решить, что весь канал принадлежит только ей. Поэтому для пакетного запуска vapi-server делит полосы пропускания между активными слотами и передает этот потолок в каждую задачу.

eff = cls._effective_path_mbit(db, vds_id, destination)
cap = max(1, eff // _MBIT_PER_CONCURRENT_TRANSFER)
share = max(1, eff // cap)

return int(share)

Если упростить: есть канал, есть рассчитанное количество параллельных миграций, и каждая миграция получает свою долю. При полной загрузке слотов сумма примерно остаётся в пределах эффективного линка.

Дальше пакетный цикл поддерживает очередь: пока есть свободные слоты, стартует следующая VDS; завершившиеся задачи убираются из pending; ошибки собираются в отдельный список, но не обязательно валят весь пакет.

while queue or pending:
    while queue:
        cap = cls._max_concurrent_for_head(
            db, queue, destination, failed,
        )
        if len(pending) >= cap:
            break
        start_next()

    time.sleep(_BATCH_POLL_INTERVAL_SEC)

    for vid, ar in list(pending):
        if ar.ready():
            pending.remove((vid, ar))
            ar.get()
            break

Под капотом каждый перенос — обычная распределенная задача:

ar = TransferVDS().apply_async(
    kwargs=kwargs,
    soft_time_limit=_TRANSFER_TASK_SOFT_SEC,
    time_limit=_TRANSFER_TASK_HARD_SEC,
)

Для инженера это меняет саму механику работ. Раньше массовая разгрузка была последовательностью ручных переносов: запустить, дождаться, проверить, перейти к следующей VDS. Теперь это управляемая очередь: можно передать список серверов, destination-ноду и получить параллельную миграцию, которая уважает физику канала.

Для клиента это тоже важно. При инциденте или регламентных работах время реакции уменьшается не только потому, что одна миграция стала быстрее, но и потому, что несколько независимых VDS можно перевозить одновременно. Нода освобождается быстрее, риск длительного окна обслуживания ниже, а платформа меньше зависит от скорости рук инженера.


❯ Хранилища: локальные диски, NFS и NVMe-oF в одном алгоритме

Самая неприятная часть инфраструктуры — смешанные хранилища.

У нас могут быть:

  • локальные диски на ноде;

  • общий NetApp по NFS, где обе ноды видят один и тот же файл;

  • per-node NetApp/NVMe-oF, где имя пула похоже, но физически это разные устройства;

  • диски с конфигами cloud-init;

  • цепочки снапшотов и restore points.

Для клиента всё это должно выглядеть одинаково: «мой сервер переехал».

В коде сначала определяем, являются ли сторадж общей NFS-шарой:

both_netapp = self._src_has_netapp and self._dst_has_netapp
uses_local_netapp = both_netapp and self._uses_local_netapp()

self.ignore_disks = both_netapp and not uses_local_netapp

Если обе ноды смотрят на один NFS, диск не нужно физически переносить. Достаточно правильно перерегистрировать VM на destination.

Если это NVMe-oF или другой per-node backend, путь может выглядеть похожим, но диск нужно переносить как локальный:

def _node_has_local_netapp(self, node, kvm_session, pool_name) -> bool:
    fs_type = self._node_netapp_fs_type(node, kvm_session, pool_name)
    if fs_type is not None:
        return not fs_type.startswith('nfs')

    return self._node_uses_nvme_tcp(node, kvm_session)

Потом классифицируем диски:

def _classify_disks(self, vds_xml: KVMxml) -> dict:
    both_netapp = self._src_has_netapp and self._dst_has_netapp
    netapp_per_node = self._uses_local_netapp()

    local_paths = set(vds_xml.get_local_disks())
    netapp_disks = set(vds_xml.get_netapp_disks())

    if both_netapp and not netapp_per_node:
        shared_paths = set(netapp_disks)
    else:
        shared_paths = set()
        local_paths |= netapp_disks

Эта развилка закрывает важный бизнес-сценарий: мы можем балансировать парк с разными типами хранилищ без ручного решения для каждой VDS.


❯ Конвертация формата диска: qcow2 и raw без окна обслуживания

На локальных дисках нам удобен qcow2: снапшоты, sparse-области, метаданные. На сетевых блочных хранилищах часто нужен raw: проще, предсказуемее, меньше накладных расходов.

Из-за этого миграция между нодой с локальным и нодой с сетевым хранилищем — не просто «перенести файл». Иногда нужно ещё поменять формат диска.

Раньше это легко превращалось в отдельную ручную процедуру. Теперь модуль включает конвертацию автоматически, если source и destination отличаются по типу хранилища:

self.convert_storage_format = (
    self._src_has_netapp != self._dst_has_netapp
)

После успешной live-миграции запускается blockCopy уже на destination:

def _convert_storage_format_after_migrate(self, vm) -> None:
    if self._dst_has_netapp:
        candidates = {
            p for p in vds_xml.get_netapp_disks()
            if not vds_xml.get_disk_is_readonly(p)
            and Path(p).suffix == '.qcow2'
        }
        target_format = 'raw'
    else:
        candidates = {
            p for p in self._get_writable_local_disks(vds_xml)
            if Path(p).suffix == '.raw'
        }
        target_format = 'qcow2'

blockCopy создает новый диск нужного формата, копирует данные и делает pivot: VM начинает использовать новый файл.

self._do_block_copy(
    vm,
    dev_name,
    new_path.as_posix(),
    target_format,
)

_billing_update_disk_opaqueref(
    self.db,
    disk['id'],
    new_path.as_posix(),
)

Клиент продолжает работать, а инфраструктура получает диск в правильном формате для нового backend.


❯ Снапшоты: больше никаких «пожалуйста, удалите перед работами»

Снапшоты — один из тех случаев, где «почти работает» хуже, чем «не работает». Если перенести дисковые файлы, но потерять metadata снапшотов, VM может запуститься, но клиент потеряет возможность отката.

Мы разделили две вещи:

  • дисковая цепочка qcow2 переносится через precopy и NBD;

  • metadata снапшотов сохраняется отдельно и восстанавливается после миграции.

Перед миграцией снимаем описание дерева снапшотов:

def _save_snapshot_metadata(self) -> List[dict]:
    snap_data = []

    for snap in all_snaps:
        snap_data.append({
            'name': snap.getName(),
            'xml': snap.getXMLDesc(),
            'parent': parent_name,
            'is_current': snap.getName() == current_name,
        })

    return self._topo_sort_snapshot_data(snap_data)

Топологическая сортировка нужна, чтобы восстановить «родителей» раньше «детей»:

queue = collections.deque(children.get(None, []))
while queue:
    snap = queue.popleft()
    sorted_result.append(snap)
    queue.extend(children.get(snap['name'], []))

После миграции metadata переопределяется на destination:

def _restore_snapshot_metadata(vm, snapshots: List[dict]) -> None:
    for snap_info in snapshots:
        flags = libvirt.VIR_DOMAIN_SNAPSHOT_CREATE_REDEFINE

        if snap_info['is_current']:
            flags |= libvirt.VIR_DOMAIN_SNAPSHOT_CREATE_CURRENT

        vm.snapshotCreateXML(snap_info['xml'], flags)

Если migrate3 упал после того, как metadata уже сняли с source, мы восстанавливаем его обратно на source. Это не про аккуратность или артефакты — это защита операционной модели клиента.


❯ Миграция между AMD и Intel: когда живая миграция невозможна

Миграция между процессорами одного семейства обычно проходит вживую. Но AMD → Intel или Intel → AMD — другой разговор.

У VM в XML задана модель CPU. Гость видит набор инструкций, флаги, топологию. Если во время работы подменить CPU на CPU другого вендора, можно получить kernel panic. libvirt тоже не считает такую живую миграцию штатной.

Мы сделали этот сценарий управляемым:

  1. На source временно переводим VM на безопасную модель qemu64.

  2. Добавляем набор инструкций уровня x86-64-v2, без которого некоторые ОС не загружаются.

  3. Перезапускаем VM, если это нужно для применения CPU.

  4. Мигрируем.

  5. На destination ставим CPU-модель под нового вендора.

  6. Делаем контролируемый ребут, если он нужен.

def _build_qemu64_cpu_xml(vcpu: int):
    cpu = E.cpu(mode='custom', match='exact', check='none')
    cpu.append(E.model('qemu64', fallback='forbid'))
    cpu.append(E.feature(policy='disable', name='svm'))

    for feature in ('aes', 'ssse3', 'sse4.1', 'sse4.2', 'popcnt'):
        cpu.append(E.feature(policy='require', name=feature))

    cpu.append(E.topology(
        sockets='1',
        dies='1',
        cores=str(vcpu),
        threads='1',
    ))

    return cpu

Для клиента это уже не «сложная миграция между несовместимыми нодами», а обычная перезагрузка в рамках технических работ.

Есть и оптимизация сценария: если диски маленькие или хранилище общее, иногда выгоднее идти offline-путем. Мы всё равно не можем избежать reboot из-за CPU, поэтому нет смысла платить за полноценную RAM-фазу там, где она не даёт выигрыша.

offline_cpu_path = (
    migrate
    and cpu_source != cpu_target
    and not self.convert_storage_format
    and (
        self.ignore_disks
        or self.disk_total_mib < _CPU_CHANGE_OFFLINE_MAX_DISK_MIB
    )
)

❯ Роллбэк: самая важная часть миграции

Хорошая миграция определяется не только успешным happy path. Важнее то, что происходит при ошибке.

Мы исходим из правила: если switchover не подтверждён, клиент должен остаться на source. Destination можно пересоздать, почистить, попробовать снова. Данные клиента — нельзя.

Для offline-сценария rollback сначала проверяет, жив ли домен на source:

def _offline_rollback(self, disks_for_deletion: Set[str]) -> None:
    try:
        self.src_node_session.lookupByName(str(self.vds_id))
        src_still_alive = True
    except Exception:
        src_still_alive = False

    if not src_still_alive:
        return

    dst_dom = self.dst_node_session.lookupByName(str(self.vds_id))
    dst_dom.undefineFlags(self._build_undefine_flags())
    self._remove_volumes(
        self.dst_node_session,
        list(disks_for_deletion),
    )

Почему нельзя просто всегда чистить destination? Потому что бывают поздние ошибки: libvirt мог уже фактически переключить VM, но вернуть ошибку на сохранении metadata или undefine. В таком случае грубая очистка destination может удалить активные диски клиента.

Поэтому очистка после неудачного вызова migrate3 проверяет состояние VM на destination:

dst_active = self._probe_domain_active(
    self.dst_node_session,
    self.dst_node,
)

if dst_active is True:
    logger.critical(
        'dst cleanup: VDS %s is ACTIVE on dst %s after migrate3 raised. '
        'Skipping volume cleanup and undefine to protect client data.',
        self.vds_id,
        self.dst_node,
    )
    return

Обновление базы тоже защищено. Нельзя просто выполнить UPDATE xenpool = dst, потому что это может спрятать split-brain или zombie-домен.

def _ensure_db_matches_reality(self):
    dst_active = self._probe_domain_active(
        self.dst_node_session,
        self.dst_node,
    )

    src_active = self._probe_domain_active(
        self.src_node_session,
        self.src_node,
    )

    if dst_active and not src_active:
        self._billing_set_xenpool_to_dst()
        self._db_updated = True

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


❯ Ещё несколько деталей, которые спасают в продакшене

В больших инфраструктурных изменениях часто выигрывают не только крупные решения вроде NBD или RDMA, но и десятки маленьких защитных механизмов.

Миграция не зависит от qemu guest agent. QEMU guest agent полезен, когда нужно выполнить команду внутри VM или аккуратно заморозить файловую систему. Но для самой миграции это плохая обязательная зависимость: агент может быть не установлен, выключен, сломан или недоступен из-за состояния гостевой ОС. Модуль переносит VM на уровне гипервизора, поэтому клиентский сервер может мигрировать даже без рабочего агента внутри.

XML домена готовится под destination. Конфиг VM нельзя просто скопировать как текстовое описание. На destination могут отличаться IP для VNC, количество CPU-ядер, путь к storage pool, имя пула сетевых дисков. Поэтому перед миграцией XML домена приводится к виду, который корректно стартует на новой ноде: меняются пути дисков, убираются привязки CPU pinning, обновляется VNC listen.

Удаление дисков ограничено allowlist. Cleanup после ошибки или успешного переезда не должен быть «rm -rf по всему, что похоже на диск». Модуль очищает только ожидаемые пулы (images, snapshots, restore_points, cloud_init) и отдельно обрабатывает сценарии с сетевыми дисками. Это скучная защита от очень дорогой ошибки.

Post-migration действия не ломают сам факт переезда. После switchover нужно обновить БД, сбросить ARP, закрыть старые VNC-сессии, обновить сетевую привязку. Часть этих операций выполняется по мере возможности: если, например, не удалось сбросить ARP, это не должно превращать уже успешную миграцию в потерянную VM. Важные действия отделены от вспомогательных.

Сессии и SSH-подключения переиспользуются. На одном переносе мы стараемся не открывать лишние libvirt- и SSH-сессии. Это не так эффектно, как RDMA, но на массовой выгрузке ноды такие мелочи превращаются в меньшее количество handshake, меньше шума и более предсказуемое время выполнения.

Подключаемся параллельно и кешируем все, что можно кешировать. Миграция состоит из десятков маленьких вопросов к source и destination: версия libvirt, список storage pools, путь pool, наличие volume, XML домена, тип сетевого бэкенда, скорость сетевого линка. Если каждый раз ходить на ноды заново, перенос начинает платить налог на TLS/SSH-handshake и RPC round-trip там, где полезной работы ещё не началось.

Поэтому в новом transfer-пути destination-сессия открывается один раз в начале и переиспользуется во всех фазах. Скорость линка и наличие IB/RDMA проверяются на source и destination параллельно. SSH-агент живет один на ноду на всю миграцию. А результаты libvirt-запросов складываются в кеши: pool lookup, target path, volume lookup, XML домена, версии libvirt, CPU вендор, тип NFS/NVMe-oF.

# Кеши libvirt и SSH-агентов на жизнь одного KVMTransfer.
self._pool_cache = {}
self._pool_path_cache = {}
self._active_pool_names_cache = {}
self._vol_cache = {}
self._domain_xml_cache = {}
self._node_agent_cache = {}

# dst_node_session открываем здесь же и переиспользуем дальше,
# чтобы не платить за лишний TLS-handshake.
self.dst_node_session = kvmcore.create_kvmsession(self.dst_node)

Проба сети тоже не идёт последовательно «сначала source, потом destination»:

ta = threading.Thread(target=work, args=(src_node, src_conn))
tb = threading.Thread(target=work, args=(dst_node, dst_conn))
ta.start()
tb.start()
ta.join(60)
tb.join(60)

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


❯ Что это дает: клиентам, эксплуатации и бизнесу

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

Клиентам

Клиенту не нужен libvirt, migrate3 или NBD как таковые. Ему нужна предсказуемость: сервер работает, данные целы, поддержка не просит вручную подготовиться к внутренним работам провайдера.

Что меняется для клиента:

  • плановые работы на стороне провайдера реже требуют окна обслуживания и участия клиента;

  • VDS можно переносить между нодами без заметного простоя в большинстве штатных сценариев;

  • если миграция не дошла до безопасного switchover, VM остаётся на source, а данные не оказываются в подвешенном состоянии;

  • снапшоты и точки восстановления переезжают вместе с VM, а не превращаются в причину тикета;

  • смена типа хранилища или вендора CPU становится инфраструктурной задачей платформы, а не ручным проектом для клиента;

  • при инцидентах клиентов можно быстрее увести с проблемной ноды, потому что пакетный перенос мигрирует несколько независимых VDS параллельно и не забирает весь канал под одну задачу.

Эксплуатации

Для эксплуатации ценность в том, что миграция стала наблюдаемой и управляемой процедурой, а не «запустили и ждем».

Что получает инженер:

  • прогресс в логах: процент, скорость диска, скорость памяти, stall, elapsed;

  • понятные причины остановки: RDMA fallback, stalled migration, split-brain guard, inactive domain on destination;

  • автоматический подбор пропускной способности и количества parallel connections по фактическому линку;

  • пакетную разгрузку через vapi-server: несколько задач в очереди одновременно, но с лимитом in-flight переносов и долей пропускной способности на каждую;

  • меньше лишних handshake и RPC: libvirt/SSH-сессии переиспользуются, ноды пробуются параллельно, XML/pool/volume/версии кешируются на время миграции;

  • rollback-логику, которая защищает source/destination от опасного cleanup и не обновляет БД при сомнительном состоянии.

Бизнесу и платформе

Для бизнеса результат не в том, что «внутренний скрипт стал быстрее». Результат в том, что инфраструктура становится подвижнее и спокойнее.

Что меняется на уровне платформы:

  • балансировка нагрузки выполняется без ручного переезда клиентов и долгих согласований;

  • регламентные работы по гипервизорам, железу и сети проще проводить без клиентского downtime;

  • при деградации ноды скорость реакции выше: можно разгрузить проблемный сегмент до того, как он станет аварией для клиентов;

  • новые backend-хранилища можно внедрять постепенно, потому что миграция умеет жить между локальными дисками, NFS и NVMe-oF;

  • саппорт получает меньше тикетов класса «отключите снапшот», «выключите сервер», «подождите ручной перенос»;

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

Цифры

По нашим замерам:

  • на нодах с общими сетевыми дисками время миграции VDS сократилось примерно до 15 секунд;

  • на нодах с локальными дисками — примерно до 45 секунд;

  • окно switchover удерживается в пределах 300 мс;

  • выгрузка ноды перестала быть задачей на дни и стала задачей на часы или десятки минут, в зависимости от нагрузки и канала.


❯ Мораль

Главный вывод простой: живая миграция — это не одна команда migrate3. Это набор архитектурных консенсусов.

rsync хорош для precopy, но не должен отвечать за активные writable-диски. NBD хорош для активных блоков, но требует правильной подготовки backing-файлов. RDMA ускоряет перенос, но fallback на TCP важнее красивого fast path. AUTO_CONVERGE, PARALLEL, ZEROCOPY, SYNC_WRITES, DETECT_ZEROES и BANDWIDTH_AVAIL_SWITCHOVER не просто флаги: каждый закрывает конкретный класс проблем.

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

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


Может быть интересно:
Перейти ↩

Перейти

Новости, обзоры продуктов и конкурсы от команды Timeweb.Cloud — в нашем Telegram-канале