ESPNAlabama OC Grubb apologizes for calling out booing fansESPN Deportes¿Por qué el GP de Azerbaiyán será en sábado?The Jerusalem PostUN probe finds Iran committed crimes against humanity in crackdown, cites US and Israel for strikesRTP DesportoFresneda, a "locomotiva" que conquistou EspanhaInquirerMalacañang dismisses Sara Duterte’s criticism on school shootingsInquirer EntertainmentMaxene Magalona elated over dad Francis M’s song feature in ‘Forgotten Island’וואלהגבר בן 52 נפצע קשה במהלך עבודתו בנצרת: מצבו קשהAnime News NetworkGoodbye, Lara ‒ Episode 12The RegisterPrivacy group slams EU for changing the data rules to cater to AIVarietyHow NBCUniversal Television Thrives on Collaborative Energy Between Studios and Platforms: ‘We Cheer Each Other On’The Hollywood ReporterAs a ‘Dancing With the Stars’ Pro, Rylee Arnold Is Making Her MarkSportstarFIFA’s Gianno Infantino letter is ploy for re-election, says German FA chief
The Daily Newsstand · Free, Always
Tuesday, September 22, 2026

Как я переносил работающую Windows-ВМ с Hyper-V на Proxmox, не выключая исходную машину

Translate

Недавно переносил у нас рабочую Windows-ВМ с Hyper-V на Proxmox. Сама по себе задача довольно обычная, и если машину можно спокойно выключить на время миграции, рассказывать тут особо не о чем. Но в нашем случае исходная ВМ должна была продолжать работать, оригинальный VHDX нельзя было менять или отсоединять, а новую машину нельзя было подключать к рабочей сети, пока мы полностью не убедимся, что перенос прошел нормально.

Сам процесс я автоматизировал через AI-агента, и уже на первой попытке всплыл хороший пример того, почему такие задачи нельзя сводить к последовательному выполнению команд. Агент создал ВМ на Proxmox, запустил ее, получил от qm status состояние running и посчитал перенос завершенным, хотя сама Windows в этот момент вообще не загрузилась.

Дальше таких моментов всплыло еще больше. При переходе с SATA на VirtIO SCSI я получил INACCESSIBLE_BOOT_DEVICE, на другой машине пришлось отдельно восстанавливать BCD, а VHDX с 4K-секторами в итоге оказалось проще пересобрать на файловом уровне, чем пытаться дальше конвертировать поблочно.

В процессе первоначальный сценарий «скопировать диск → импортировать → запустить» постепенно превратился в нормальный регламент с чекпоинтами, отдельной проверкой загрузки Windows, изолированным первым стартом и откатом после неудачных попыток.

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

С чего начал

В голове все выглядело довольно просто. 

Нужно было найти системный VHDX на Hyper-V, скопировать его на хранилище, создать ВМ на Proxmox, импортировать диск и запустить Windows.

Проблема в том, что исходная машина продолжает работать, а значит, ее диск во время копирования меняется. Плюс перед переносом нужно учитывать тип виртуального контроллера, GPT и EFI-раздел, состояние загрузчика Windows, размер логического и физического сектора, существующие чекпоинты Hyper-V и драйверы внутри гостевой ОС.

Да, это довольно базовая история, но когда процесс выполняется автоматически, все такие проверки приходится прописывать прям дотошно. Если человек после запуска ВМ увидит в консоли BSOD, он поймет, что что-то пошло не так, а вот автоматизация вполне может увидеть успешно завершившийся qm start и пойти дальше.

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

  1. Исходную ВМ не выключаем.

  2. Оригинальный VHDX не изменяем и не отсоединяем.

  3. Сеть новой ВМ не включаем, пока отдельно не проверим результат миграции.

Последний пункт нужен в том числе из-за того, что копия Windows может подняться с тем же IP, именем компьютера и работающими фоновыми сервисами. Получить две такие машины в одном сегменте во время проверки миграции точно не хотелось.

Первый запуск показал проблему в самом сценарии

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

qm start 9001
qm status 9001

В ответ Proxmox возвращал:
status: running

На этом агент считал этап успешно завершенным.

Формально то все правильно - running означает что процесс виртуальной машины работает, но про состояние гостевой ОС он ничего не говорит. QEMU может быть запущен, пока Windows не видит загрузочный диск, висит на Boot Manager или уже получила BSOD.

После этого я разделил запуск виртуальной машины и загрузку Windows на два разных этапа и постепенно весь процесс миграции стал выглядеть так:

Для переходов между этапами появились отдельные условия:

checkpoint error → STOP

check failed → STOP

Windows BSOD → ROLLBACK

network approval → HUMAN

На первых итерациях загрузку Windows я проверял через скриншот консоли ВМ:

qm start 9001
sleep 120
qm screenshot 9001 /tmp/boot_check.ppm

sleep 120 здесь, конечно, довольно примитивное решение - в нормальном сценарии ожидание лучше заменить проверкой через guest-agent или состоянием внутри Windows, но мне на этом этапе было важнее перестать считать запуск процесса QEMU доказательством того, что гостевая ОС действительно загрузилась.

Как получить стабильную копию работающего диска

Обычное копирование активного VHDX здесь не подходило. Пока файл читается, работающая Windows продолжает менять его содержимое, поэтому разные части получившегося образа могут относиться к разным моментам времени.

Чтобы зафиксировать базовый диск и при этом не останавливать машину я использовал временный production checkpoint Hyper-V.

Перед его созданием агент сначала собирал текущее состояние:

Get-VM -Name "vm-billing-01" |
    Select Name, State, CheckpointType

Get-VHD "D:\Hyper-V\vm-billing-01\Disks\vm-billing-01-sys.vhdx" |
    Select Path, Size, LogicalSectorSize, ParentPath

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

После сохранения исходного состояния я временно включал Production checkpoint и создавал их с понятным именем:

Set-VM `
    -Name "vm-billing-01" `
    -CheckpointType ProductionOnly

Checkpoint-VM `
    -Name "vm-billing-01" `
    -SnapshotName "MIG_vm-billing-01_C_tmp"

После создания чекпоинтов Hyper-V продолжает работу через дочерний .avhdx, поэтому базовый VHDX в этот момент перестает меняться и его можно скопировать, не выключая исходную машину. Я использовал именно Production checkpoint, чтобы перед созданием точки Hyper-V задействовал VSS внутри Windows, а не просто снимал состояние диска. 

Для копирования я использовал robocopy:

robocopy `
    "D:\Hyper-V\vm-billing-01\Disks" `
    "\\nas-backup\vm-archive\vm-billing-01" `
    vm-billing-01-sys.vhdx `
    /MT:16 `
    /LOG+:C:\Temp\mig_copy.log

Сам выбор именно robocopy здесь не особенно принципиален. Мне было важно получать лог, понятный exit code, возможность повторить операцию и нормально проверить результат.

Тут есть небольшой нюанс с exit code самого robocopy. Его нельзя проверять по обычной логике, где любой ненулевой код считается ошибкой. Значения ниже 8 могут означать успешно выполненное копирование с дополнительными условиями, а ошибкой считается код 8 и выше. 

Еще один небольшой момент связан с динамическими VHDX. Диск с виртуальной емкостью 70 ГБ на файловой системе может занимать заметно меньше, поэтому я сравнивал копию с исходником по фактическому размеру файла и параметрам VHDX, а не пытался сопоставлять размер файла с виртуальной емкостью диска.

Только после проверки полученной копии временный чекпоинт удалялся, Hyper-V выполнял слияние изменений, а исходная настройка чекпоинтов возвращалась:

Remove-VMSnapshot `
    -VMName "vm-billing-01" `
    -Name "MIG_vm-billing-01_C_tmp"

Set-VM `
    -Name "vm-billing-01" `
    -CheckpointType Disabled

Get-VM "vm-billing-01" | Select State
Get-VMSnapshot -VMName "vm-billing-01"

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

Импортируем диск и пока не трогаем VirtIO

На стороне Proxmox я сначала проверял, что скопированный VHDX вообще нормально читается:

qemu-img info \
  -f vhdx \
  /mnt/pve/nas-archive/vm-billing-01/target_512.vhdx

qemu-img check \
  -f vhdx \
  /mnt/pve/nas-archive/vm-billing-01/target_512.vhdx

Это еще ничего не говорит о загрузке Windows, проверка подтверждает только то, что файл читается и структура VHDX не развалилась при копировании. Поэтому разницу между “образ корректный” и “Windows загрузится” дальше пришлось учитывать отдельно.

Если базовые проверки проходили, диск импортировался в хранилище Proxmox:

qm importdisk \
    9001 \
    /mnt/pve/nas-archive/vm-billing-01/target_512.vhdx \
    rpool

Первый запуск я делал через SATA:

qm set 9001 \
    --sata0 rpool:vm-9001-disk-1,size=65G

qm set 9001 --boot order=sata0

Да, SATA здесь выглядит довольно консервативно, но на первом запуске мне была важнее совместимость, чем производительность, поэтому я пошел таким путем. Windows обычно может загрузиться с таким контроллером без предварительной подготовки, а уже после успешного старта можно спокойно заниматься VirtIO.

Сетевую карту я добавлял сразу, но виртуальный линк оставлял выключенным:

qm set 9001 \
    --net0 virtio=AA:BB:CC:DD:EE:01,bridge=vmbr0,link_down=1

До этого момента новая ВМ вообще не должна общаться с рабочей сетью. Сначала нужно убедиться, что Windows нормально загружается в изоляции и только потом разбираться с сетевой конфигурацией.

Почему нельзя сразу перевести системный диск на VirtIO SCSI

После успешной загрузки на SATA я попробовал подключить системный диск через VirtIO SCSI и получил INACCESSIBLE_BOOT_DEVICE.

Причина оказалась довольно простой - само наличие файлов VirtIO-драйвера внутри Windows еще не означает, что система подготовлена к загрузке с нового контроллера, поэтому сначала я дал уже загруженной Windows увидеть VirtIO SCSI на отдельном временном диске. После того как контроллер появился в системе и драйвер нормально подхватился, уже переключал на него системный диск. 

Рабочая последовательность в итоге получилась такой:

  1. Загрузить Windows с системным диском на SATA.

  2. Подключить virtio-win ISO.

  3. Установить и проверить vioscsi.

  4. Подключить небольшой временный диск через VirtIO SCSI.

  5. Загрузить Windows и убедиться, что контроллер и новое устройство определились.

  6. Штатно выключить ВМ.

  7. Удалить временный диск и SATA-подключение.

  8. Подключить системный диск уже через SCSI.

  9. Еще раз загрузить Windows и проверить результат.

В командах это выглядело так:

# Временный SCSI-диск, чтобы Windows увидела контроллер
qm set 9001 --scsi0 rpool:vm-9001-tmp,size=1G
qm start 9001

# Здесь в рабочем сценарии проверяем,
# что контроллер определился внутри Windows

qm shutdown 9001

# Только после этого переключаем системный диск
qm set 9001 --delete sata0 --delete scsi0
qm set 9001 --scsi0 rpool:vm-9001-disk-1,size=65G
qm set 9001 --boot order=scsi0

qm start 9001
qm screenshot 9001 /tmp/boot_check.ppm

В одной из первых попыток этот промежуточный этап был пропущен, после чего Windows и упала с INACCESSIBLE_BOOT_DEVICE. Конфигурацию пришлось откатить из заранее сохраненного бэкапа, а переход через временный SCSI-диск после этого стал обязательной частью сценария.

На другой ВМ пришлось восстанавливать загрузчик

Следующая проблема появилась уже на другой машине. Сам диск импортировался без ошибок, qemu-img тоже ничего подозрительного не показывал, но Windows до нормальной загрузки не доходила.

Можно было начать вручную редактировать BCD, но на подключенном диске легко запутаться в буквах томов и EFI-разделах, поэтому я сначала решил восстановить загрузочные записи штатными инструментами Windows.

VHDX подключил к Windows-хосту, назначил буквы разделам и выполнил:

bcdboot `
    W:\Windows `
    /s S: `
    /f UEFI `
    /l ru-RU

В этом примере W: - раздел с Windows, а S: - EFI-раздел.

После этого результат можно проверить через хранилище BCD:

bcdedit `
    /store S:\EFI\Microsoft\Boot\BCD

Если проблема действительно в BCD или EFI-записях, мне такой подход кажется надежнее ручной правки. Сначала даем самой Windows пересоздать загрузочную конфигурацию штатной утилитой, а уже если это не помогает, идем разбираться дальше.

Больше всего времени забрал VHDX с 4K-секторами

С отдельной ВМ стандартный сценарий снова перестал работать из-за диска с логическими секторами по 4 КБ. Здесь пришлось попробовать три разных подхода и как раз на этом кейсе хорошо видно, почему успешный qemu-img check сам по себе почти ничего не говорит о том, загрузится ли Windows.

Попытка №1. Поблочная копия в диск с секторами 512 байт

Первым вариантом была поблочная копия PhysicalDrive в новый диск с секторами 512 байт с последующей перезаписью GPT.

После этого qemu-img check завершался без ошибок, но Windows останавливалась с UNMOUNTABLE_BOOT_VOLUME.

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

Поэтому этот вариант дальше использовать не стал.

Попытка №2. Передать параметры 4K через QEMU

Следующим вариантом я попробовал передать QEMU параметры, описывающие 4K-секторы. Windows с ними загрузилась, поэтому для диагностики способ оказался полезным.

Оставлять такую конфигурацию для постоянной эксплуатации я не хотел. Нестандартное подключение диска усложняет сопровождение, резервное копирование, повторное развертывание ВМ и перенос конфигурации между узлами. Если можно получить обычный диск, который Proxmox будет использовать без дополнительных параметров, для меня это предпочтительнее.

Попытка №3. Пересобрать диск на файловом уровне

В итоге я отказался от попыток исправить диск поблочно и создал новый фиксированный VHDX с логическими и физическими секторами по 512 байт:

New-VHD `

    -Path "E:\work\target_512.vhdx" `

    -Fixed `

    -SizeBytes 70GB `

    -LogicalSectorSizeBytes 512 `

    -PhysicalSectorSizeBytes 512

Mount-VHD "E:\work\source.vhdx" -ReadOnly

Mount-VHD "E:\work\target_512.vhdx"

На новом диске заново создал разделы в том же порядке, что и на исходном:

  1. Recovery.

  2. EFI.

  3. MSR.

  4. Windows.

В рабочем скрипте используются полные GUID типов GPT-разделов. Здесь сокращаю их только для компактности:

$dn = 7

New-Partition -DiskNumber $dn -Size 500MB -GptType '{de94bba4-…}' # Recovery
New-Partition -DiskNumber $dn -Size 100MB -GptType '{c12a7328-…}' # EFI
New-Partition -DiskNumber $dn -Size 128MB -GptType '{e3c9e316-…}' # MSR

$os = New-Partition -DiskNumber $dn -UseMaximumSize

Дальше назначил буквы разделам:

$SourceOS = 'G:'
$TargetOS = 'W:'
$TargetEFI = 'S:'

Set-Partition -DiskNumber $dn `
    -PartitionNumber $os.PartitionNumber `
    -NewDriveLetter $TargetOS

Format-Volume -DriveLetter $TargetOS -NewFileSystemLabel OS

EFI-раздел подключался отдельно:

$efi = Get-Partition -DiskNumber $dn |
    Where-Object GptType -eq '{c12a7328-…}'

Set-Partition -DiskNumber $dn `
    -PartitionNumber $efi.PartitionNumber `
    -NewDriveLetter $TargetEFI

После этого файлы Windows переносились с сохранением атрибутов и ACL:

robocopy "$SourceOS\" "$TargetOS\" `
    /MIR /COPYALL /DCOPY:DAT /B /XJ `
    /R:1 /W:1 /MT:32

После переноса оставалось заново создать загрузчик, а затем отключить оба VHDX: 

bcdboot "$TargetOS\Windows" /s $TargetEFI /f UEFI /l ru-RU

Dismount-VHD "E:\work\source.vhdx"
Dismount-VHD "E:\work\target_512.vhdx"

Этот вариант в итоге и подошел для эксплуатации. Он требует больше времени, чем поблочная конвертация, зато на выходе получается обычный VHDX с понятной для Proxmox геометрией, без дополнительных параметров QEMU. Его проще проверять, резервировать и при необходимости подключать повторно.

После этого кейса я бы уже не рассчитывал, что поблочная конвертация 4K-диска в 512 автоматически решит проблему. Если образ формально исправен, а Windows все равно не загружается, иногда проще пересобрать разделы и перенести файлы, чем дальше пытаться исправлять сам контейнер.

Что еще всплыло уже во время автоматизации

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

  • В интерактивной PowerShell-сессии удобно передавать между командами объекты MSFT_Partition, но в scheduled task это оказалось менее надежно. Объект не всегда нормально сериализовался и возвращался на следующем шаге, поэтому в автоматизированном сценарии я перешел на обычные числовые значения:

-DiskNumber 7
-PartitionNumber 4
  • Для временных томов я использовал буквы ближе к концу алфавита.
    Тут ничего необычного нет, просто так меньше шанс пересечься с буквами, которые Windows уже назначила исходному диску или служебным разделам.

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

Когда все этапы были пройдены, я еще раз проверял конфигурацию целевой ВМ и отдельно смотрел, что сеть по-прежнему остается отключенной: 

qm config 9001 | grep -E 'boot|scsi0|link_down'

После этого отдельно проверял исходную машину на Hyper-V: 

Get-VM "vm-billing-01" | Select State

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

Во что в итоге превратился первоначальный сценарий

Первая версия скилла описывала в основном идеальный путь. Нужно было получить VHDX, импортировать его, запустить новую ВМ и проверить результат.

После нескольких реальных попыток в сценарии появились отдельные ветки для восстановления загрузчика, работы с 4K-секторами, перехода с SATA на VirtIO SCSI, проверки сети и отката конфигурации после неудачного запуска.

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

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

файл скопирован

образ читается

ВМ запущена

Windows загрузилась

Для человека разница очевидна и в этом как раз кроется проблема. Мы часто не описываем такие проверки, потому что сами выполняем их почти автоматически. Если на экране BSOD, никто не станет считать миграцию успешной только из-за того, что qm start вернул нормальный exit code.

С агентом эти очевидные проверки приходится превращать в конкретные условия перехода к следующему шагу.

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

Этап

Что проверяю

Что происходит при ошибке

Исходное состояние

Настройки сохранены, исходная ВМ продолжает работать

STOP

Копирование

Production checkpoint создан, VHDX скопирован и проверен

STOP

Образ

qemu-img info и qemu-img check проходят

STOP

Первый запуск

Windows загрузилась на SATA без сети

ROLLBACK

SCSI / 4K

Контроллер подготовлен, Windows снова загрузилась

ROLLBACK

Сеть

Гостевая ОС полностью проверена

HUMAN

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

Что я вынес из этой миграции

Если придется еще раз переносить работающую Windows-ВМ с Hyper-V на Proxmox, я бы уже не начинал непосредственно с импорта диска. Сначала стоит посмотреть на GPT/UEFI, текущий контроллер, logical и physical sector size, цепочку чекпоинтов, наличие VirtIO-драйверов и заранее решить, как именно будет подтверждаться загрузка Windows.

Звучит довольно очевидно, но именно такие очевидные вещи чаще всего теряются, когда длинный процесс начинают автоматизировать. Первая версия моего сценария тоже умела скопировать диск, импортировать его и запустить ВМ, просто этого оказалось недостаточно.

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

У меня весь этот разбор как раз начался с одного вполне корректного status: running

На этом все, спасибо за внимание, рад буду комментариям или вопросам. 

View the original on Хабр

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.