
Привет, Хабр! Меня зовут Денис, я тимлид инфраструктурной 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-миграция разбита на несколько фаз.
Мы определяем, какие диски можно не трогать, какие нужно предварительно скопировать, а какие должны ехать через NBD.
Readonly-файлы, backing-файлы и диски для cloud-init при необходимости копируются заранее.
Для активных writable-дисков готовятся тома на destination.
Запускается migrate3: QEMU/libvirt переносит память и активные блоки дисков.
Если включен RDMA и он доступен, пробуем RDMA. Если нет — безопасно падаем обратно на TCP.
После успешного switchover обновляем БД, восстанавливаем снапшоты, чистим source и выполняем постмиграционные действия.
Если что-то пошло не так, откатываемся так, чтобы рабочая 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_paramsPARALLEL включает несколько 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 — не магия. Он зависит от железа, драйверов, сети, маршрутизации и состояния интерфейсов. Поэтому логика такая:
Проверяем, есть ли IB/RDMA-интерфейсы на обеих нодах.
Если готовы — пробуем RDMA.
Если RDMA не стартовал безопасно — откатываем job и запускаем TCP.
Если уже упал 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 TrueRDMA_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 тоже не считает такую живую миграцию штатной.
Мы сделали этот сценарий управляемым:
На source временно переводим VM на безопасную модель
qemu64.Добавляем набор инструкций уровня x86-64-v2, без которого некоторые ОС не загружаются.
Перезапускаем VM, если это нужно для применения CPU.
Мигрируем.
На destination ставим CPU-модель под нового вендора.
Делаем контролируемый ребут, если он нужен.
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-канале ↩




























