From ff5511a7b394e1e9456748be06f1baed0edc79b0 Mon Sep 17 00:00:00 2001 From: Daniil Antoshin Date: Mon, 18 May 2026 18:53:11 +0200 Subject: [PATCH 1/7] docs: add inbound migration limit ADR Signed-off-by: Daniil Antoshin --- .../adr_inbound_migration_limit.ru.md | 431 ++++++++++++++++++ 1 file changed, 431 insertions(+) create mode 100644 docs/internal/adr_inbound_migration_limit.ru.md diff --git a/docs/internal/adr_inbound_migration_limit.ru.md b/docs/internal/adr_inbound_migration_limit.ru.md new file mode 100644 index 0000000000..5d59ea495e --- /dev/null +++ b/docs/internal/adr_inbound_migration_limit.ru.md @@ -0,0 +1,431 @@ +# ADR: ограничение входящих live migrations на target node + +## Статус + +Предложено. + +## Контекст + +В модуле virtualization live migration выполняется через KubeVirt `VirtualMachineInstanceMigration`. +Пользовательский и автоматический сценарии миграции в Deckhouse проходят через несколько уровней: + +1. `VirtualMachineOperation` (`VMOP`) создаётся пользователем, контроллером эвакуации, workload-updater или другим компонентом. +2. `vmop-migration-controller` создаёт KubeVirt-ресурс `VirtualMachineInstanceMigration`. +3. KubeVirt `virt-controller` планирует target pod и управляет жизненным циклом live migration. +4. Контроллеры virtualization синхронизируют статус KubeVirt migration обратно в `VMOP` и `VirtualMachine`. + +Сейчас ограничение параллелизма задаётся через KubeVirt `MigrationConfiguration`: + +```yaml +parallelMigrationsPerCluster: +parallelOutboundMigrationsPerNode: +``` + +В проекте это значение прокидывается из Helm/templates и hooks: + +- `templates/kubevirt/_kubevirt_helpers.tpl` +- `images/hooks/pkg/hooks/migration-config/hook.go` +- `images/virtualization-artifact/pkg/livemigration/migration_configuration.go` + +При этом KubeVirt API не содержит симметричной настройки вида: + +```yaml +parallelInboundMigrationsPerNode: +``` + +Из-за этого платформа умеет ограничивать количество исходящих миграций с source node, но не умеет ограничивать количество входящих миграций на target node. На практике несколько VM могут одновременно мигрировать на одну и ту же target node, даже если для source nodes ограничение уже работает. + +Требование: контролировать, что входящих миграций на target node не более одной. Остальные миграции должны ожидать в `Pending` или другом подходящем состоянии, а не завершаться ошибкой. + +## Проблема + +Ограничение нельзя надёжно реализовать только в `vmop-migration-controller`, потому что target node обычно становится известна после создания `VirtualMachineInstanceMigration`, когда KubeVirt уже начал планировать target pod. + +Если пытаться решить задачу до создания KubeVirt migration, придётся повторять часть логики Kubernetes scheduler и KubeVirt placement: + +- учитывать `nodeSelector` из `VMOP.spec.migrate.nodeSelector`; +- учитывать placement самого `VirtualMachine`; +- учитывать taints/tolerations, affinities, resources, devices, storage constraints; +- учитывать динамические изменения node и pod scheduling state. + +Такой подход будет неполным и не даст строгой гарантии, что KubeVirt выберет именно ту node, которую предварительно проверил controller. + +Также ограничение должно применяться не только к миграциям, созданным через пользовательский `VMOP`, но и к другим источникам миграций: + +- eviction; +- node drain; +- workload updater; +- автоматические системные миграции; +- миграции, созданные напрямую через KubeVirt API. + +Поэтому правильная точка контроля — KubeVirt migration control loop, где уже известен target node и где принимается решение о продвижении миграции по фазам. + +## Решение + +Добавить в KubeVirt `virt-controller` внутренний limiter входящих миграций на target node. + +На первом этапе лимит фиксированный: + +```text +maxIncomingMigrationsPerNode = 1 +``` + +Миграция может перейти к активной фазе только если на её target node нет другой активной входящей миграции. + +Если target node уже занята другой active incoming migration, текущая миграция остаётся в ожидающем состоянии и повторно reconcile-ится позже. + +## Определения + +### Target node + +Target node — node, на которую KubeVirt планирует перенести VMI. + +Источник target node зависит от текущей фазы миграции: + +- `VirtualMachineInstanceMigration.Status.MigrationState.TargetNode`, если уже заполнено; +- target pod `spec.nodeName`, если target pod уже создан и назначен scheduler-ом; +- для более ранних фаз target node может быть ещё неизвестна, и limiter не должен блокировать миграцию до появления target node. + +### Active incoming migration + +Active incoming migration — миграция, которая: + +1. не находится в terminal phase; +2. имеет target node; +3. уже потребляет или скоро начнёт потреблять ресурсы target node как live migration target. + +Рекомендуемый набор фаз, которые считать активными: + +```text +MigrationScheduled +MigrationPreparingTarget +MigrationTargetReady +MigrationWaitingForSync +MigrationSynchronizing +MigrationRunning +``` + +Фазы, которые не считаются активными: + +```text +MigrationPhaseUnset +MigrationPending +MigrationSucceeded +MigrationFailed +``` + +`MigrationScheduling` можно не считать активной, если target pod ещё не назначен на node. Если target pod уже имеет `spec.nodeName`, миграция может участвовать в inbound limiting даже на фазе `MigrationScheduling`. + +## Алгоритм + +### 1. До появления target node + +Если target node неизвестна, миграция продолжает обычный KubeVirt flow. + +Limiter не должен пытаться выбирать target node самостоятельно. + +### 2. После назначения target node + +Перед переходом миграции в активную фазу controller проверяет inbound capacity target node. + +Псевдокод: + +```go +func reconcileMigration(migration *VirtualMachineInstanceMigration) error { + targetNode := resolveTargetNode(migration) + if targetNode == "" { + return continueDefaultMigrationFlow(migration) + } + + if !isEnteringActiveIncomingPhase(migration) { + return continueDefaultMigrationFlow(migration) + } + + acquired, err := incomingLimiter.TryAcquire(ctx, migration, targetNode) + if err != nil { + return err + } + + if !acquired { + setMigrationPending(migration, "TargetNodeIncomingMigrationLimitExceeded") + return requeueAfter(defaultMigrationRequeueDelay) + } + + return continueDefaultMigrationFlow(migration) +} +``` + +### 3. Завершение миграции + +При переходе миграции в terminal phase limiter освобождает занятый slot: + +```go +if migration.IsFinal() { + incomingLimiter.Release(ctx, migration, targetNode) +} +``` + +Также release должен быть идемпотентным и безопасным при повторном reconcile. + +## Синхронизация и защита от race condition + +Простой подсчёт активных миграций по списку `VirtualMachineInstanceMigration` недостаточен для строгой гарантии. При нескольких workers возможна гонка: + +1. две миграции одновременно проверяют target node; +2. обе видят, что активных входящих миграций нет; +3. обе продолжают выполнение. + +Чтобы гарантировать `<= 1`, limiter должен использовать атомарный механизм захвата slot. + +Рекомендуемая реализация — Kubernetes `Lease` из `coordination.k8s.io/v1`. + +### Lease model + +Для каждой target node создаётся lease: + +```text +namespace: d8-virtualization +name: incoming-migration- +holderIdentity: +``` + +Правила: + +- если lease отсутствует, миграция создаёт его со своим `UID`; +- если lease существует и `holderIdentity` равен `UID` текущей миграции, миграция продолжает выполнение; +- если lease существует и принадлежит другой non-final миграции, текущая миграция остаётся pending; +- если lease существует, но владелец уже terminal или отсутствует, lease можно перехватить; +- release удаляет lease или очищает `holderIdentity`, только если lease принадлежит текущей миграции. + +### Обработка stale lease + +Lease может остаться после аварийного завершения controller-а или удаления migration resource. + +При обнаружении занятого lease controller должен проверить владельца: + +1. найти `VirtualMachineInstanceMigration` по UID владельца; +2. если владелец отсутствует или terminal, считать lease stale; +3. перехватить lease через optimistic update с `resourceVersion`. + +Дополнительно можно использовать `renewTime` и `leaseDurationSeconds`, но основной критерий освобождения — состояние migration owner. + +## Статусы и условия + +Ожидающая из-за inbound limit миграция не должна считаться failed. + +Рекомендуемая модель статуса KubeVirt migration: + +```text +phase: Pending +condition/reason: TargetNodeIncomingMigrationLimitExceeded +message: Target node already has an active incoming migration. +``` + +На уровне `VirtualMachineOperation` можно использовать существующий pending mapping: + +```text +VMOP.status.phase: Pending +Completed condition: + status: False + reason: MigrationPending + message: The VirtualMachineOperation for migrating the virtual machine has been queued. Waiting for the queue to be processed and for this operation to be executed. +``` + +Для лучшей диагностики можно добавить новый reason в API virtualization: + +```text +TargetNodeIncomingMigrationLimitExceeded +``` + +Но это потребует изменения API, CRD и документации. Для первого этапа достаточно сохранить `ReasonMigrationPending`, но заменить message на более точный, если KubeVirt condition содержит причину inbound limit. + +## Конфигурация + +### Первый этап + +Лимит фиксированный: + +```text +parallelInboundMigrationsPerNode = 1 +``` + +Преимущества: + +- минимальные изменения публичного API; +- не требует новых ModuleConfig параметров; +- закрывает исходное требование. + +### Возможное развитие + +Позже можно сделать настройку конфигурируемой через ModuleConfig annotation и Helm values: + +```yaml +virtualization.deckhouse.io/parallel-inbound-migrations-per-node: "1" +``` + +Внутренний values path: + +```text +virtualization.internal.virtConfig.parallelInboundMigrationsPerNode +``` + +Но так как upstream KubeVirt `MigrationConfiguration` не содержит такого поля, эта настройка будет Deckhouse-specific и должна применяться только в patched `virt-controller`. + +## Альтернативы + +### Альтернатива 1: реализовать ограничение в `vmop-migration-controller` + +Суть: перед созданием `VirtualMachineInstanceMigration` проверить target node и не создавать migration, если node занята. + +Недостатки: + +- target node чаще всего ещё неизвестна; +- controller должен повторить scheduler logic; +- нет гарантии, что KubeVirt выберет проверенную node; +- не покрывает миграции, созданные не через `VMOP`; +- возможны гонки между несколькими VMOP. + +Решение отклонено. + +### Альтернатива 2: ограничить `parallelMigrationsPerCluster` до 1 + +Суть: разрешить только одну live migration во всём кластере. + +Преимущества: + +- уже поддерживается KubeVirt; +- не требует патчей. + +Недостатки: + +- слишком сильное ограничение; +- блокирует независимые миграции между разными node; +- ухудшает drain, evacuation и обновления. + +Решение отклонено. + +### Альтернатива 3: использовать только Kubernetes scheduler constraints + +Суть: добавить anti-affinity/topology spread для target pods, чтобы на node не попадало больше одного migration target pod. + +Недостатки: + +- scheduler constraints плохо выражают состояние active migration; +- pod может остаться pending, но KubeVirt migration status будет зависеть от scheduler timeout; +- сложно корректно связать target pods разных миграций; +- не даёт явной очереди и понятной причины ожидания. + +Решение отклонено. + +### Альтернатива 4: простой подсчёт активных миграций без Lease + +Суть: перед продолжением миграции list-ить все migrations и считать active incoming на target node. + +Преимущества: + +- проще реализации; +- не требует дополнительных ресурсов. + +Недостатки: + +- нет строгой гарантии при concurrent reconcile; +- возможны race conditions; +- поведение зависит от cache freshness. + +Можно использовать как дополнительную проверку, но не как основной механизм гарантии. + +Решение отклонено как основной вариант. + +## Последствия + +### Положительные + +- На target node будет не более одной активной входящей live migration. +- Остальные миграции будут ждать, а не падать. +- Ограничение будет работать независимо от источника миграции. +- Снижается риск перегрузки target node сетью, CPU, памятью и storage attach операциями. +- Поведение становится симметричнее текущему outbound limit. + +### Отрицательные + +- Требуется patch KubeVirt `virt-controller`. +- Появляется Deckhouse-specific поведение, которое нужно учитывать при обновлении KubeVirt. +- Появляется новый служебный ресурс `Lease` и логика очистки stale leases. +- Возможна меньшая скорость массовой эвакуации, если много VM мигрируют на одну target node. + +## План реализации + +### Шаг 1. Найти точку интеграции в KubeVirt + +В patched `virt-controller` найти control loop, который продвигает `VirtualMachineInstanceMigration` по фазам и создаёт/контролирует target pod. + +Нужно вставить limiter после того, как target node известна, но до начала активной live migration синхронизации. + +### Шаг 2. Добавить incoming limiter + +Добавить компонент примерно такого вида: + +```go +type IncomingMigrationLimiter interface { + TryAcquire(ctx context.Context, migration *virtv1.VirtualMachineInstanceMigration, targetNode string) (bool, error) + Release(ctx context.Context, migration *virtv1.VirtualMachineInstanceMigration, targetNode string) error +} +``` + +Реализация должна использовать `coordination.k8s.io/v1 Lease`. + +### Шаг 3. Интегрировать limiter в migration reconcile + +Логика: + +1. определить target node; +2. если миграция входит в active incoming phase — вызвать `TryAcquire`; +3. если slot занят — оставить migration pending и requeue; +4. если slot получен — продолжить стандартный flow; +5. на terminal phase вызвать `Release`. + +### Шаг 4. Синхронизировать диагностику в virtualization-controller + +Если KubeVirt migration получила reason `TargetNodeIncomingMigrationLimitExceeded`, `vmop-migration-controller` должен отображать это как pending состояние. + +Минимальный вариант: + +- `VMOP.status.phase = Pending`; +- `Completed.reason = MigrationPending`; +- message содержит информацию про занятый target node. + +Расширенный вариант: + +- добавить новый `vmopcondition.ReasonCompleted`; +- обновить CRD и документацию. + +### Шаг 5. Тесты + +Нужны unit/integration тесты для patched KubeVirt logic: + +1. одна миграция на target node получает lease и продолжается; +2. вторая миграция на ту же target node остаётся pending; +3. миграция на другую target node продолжается; +4. после завершения первой миграции вторая получает lease; +5. stale lease от отсутствующей migration перехватывается; +6. lease, принадлежащий текущей migration, не блокирует повторный reconcile; +7. concurrent `TryAcquire` не выдаёт slot двум migration одновременно. + +Для virtualization-controller нужны тесты mapping-а статуса: + +1. KubeVirt migration pending из-за inbound limit отображается в `VMOP.status.phase = Pending`; +2. migration не переводится в failed; +3. message понятен пользователю. + +## Нерешённые вопросы + +1. Нужно ли считать `MigrationPreparingTarget` активной входящей миграцией или блокировать только начиная с `MigrationTargetReady`? +2. Делать ли `parallelInboundMigrationsPerNode` публичной настройкой сразу или оставить фиксированным `1`? +3. Нужно ли добавлять новый API reason в `VMOP`, или достаточно существующего `MigrationPending` с уточнённым message? +4. Где хранить lease: в namespace KubeVirt (`d8-virtualization`) или рядом с migration namespace? + +## Рекомендация + +Реализовать limiter в patched KubeVirt `virt-controller` через Kubernetes Lease. + +На первом этапе использовать фиксированный лимит `1`, без изменения публичного API. В `VMOP` отображать ожидание как `Pending`, не переводя операцию в `Failed`. From 93383c0618943c19435495a2f5422f4161ea0195 Mon Sep 17 00:00:00 2001 From: Daniil Antoshin Date: Mon, 18 May 2026 18:58:47 +0200 Subject: [PATCH 2/7] docs: simplify inbound migration limit alternatives Signed-off-by: Daniil Antoshin --- .../adr_inbound_migration_limit.ru.md | 32 +------------------ 1 file changed, 1 insertion(+), 31 deletions(-) diff --git a/docs/internal/adr_inbound_migration_limit.ru.md b/docs/internal/adr_inbound_migration_limit.ru.md index 5d59ea495e..a0eb3dfb62 100644 --- a/docs/internal/adr_inbound_migration_limit.ru.md +++ b/docs/internal/adr_inbound_migration_limit.ru.md @@ -287,37 +287,7 @@ virtualization.internal.virtConfig.parallelInboundMigrationsPerNode Решение отклонено. -### Альтернатива 2: ограничить `parallelMigrationsPerCluster` до 1 - -Суть: разрешить только одну live migration во всём кластере. - -Преимущества: - -- уже поддерживается KubeVirt; -- не требует патчей. - -Недостатки: - -- слишком сильное ограничение; -- блокирует независимые миграции между разными node; -- ухудшает drain, evacuation и обновления. - -Решение отклонено. - -### Альтернатива 3: использовать только Kubernetes scheduler constraints - -Суть: добавить anti-affinity/topology spread для target pods, чтобы на node не попадало больше одного migration target pod. - -Недостатки: - -- scheduler constraints плохо выражают состояние active migration; -- pod может остаться pending, но KubeVirt migration status будет зависеть от scheduler timeout; -- сложно корректно связать target pods разных миграций; -- не даёт явной очереди и понятной причины ожидания. - -Решение отклонено. - -### Альтернатива 4: простой подсчёт активных миграций без Lease +### Альтернатива 2: простой подсчёт активных миграций без Lease Суть: перед продолжением миграции list-ить все migrations и считать active incoming на target node. From 05ad985681701ff9fa1787124f5997578353762d Mon Sep 17 00:00:00 2001 From: Daniil Antoshin Date: Mon, 18 May 2026 19:00:17 +0200 Subject: [PATCH 3/7] docs: detail inbound migration lease design Signed-off-by: Daniil Antoshin --- .../adr_inbound_migration_limit.ru.md | 153 +++++++++++++++++- 1 file changed, 152 insertions(+), 1 deletion(-) diff --git a/docs/internal/adr_inbound_migration_limit.ru.md b/docs/internal/adr_inbound_migration_limit.ru.md index a0eb3dfb62..341f88b8e5 100644 --- a/docs/internal/adr_inbound_migration_limit.ru.md +++ b/docs/internal/adr_inbound_migration_limit.ru.md @@ -197,13 +197,164 @@ holderIdentity: - если lease существует, но владелец уже terminal или отсутствует, lease можно перехватить; - release удаляет lease или очищает `holderIdentity`, только если lease принадлежит текущей миграции. +### Детали реализации Lease + +Lease должен быть отдельным служебным объектом, который представляет один inbound slot конкретной target node. + +Рекомендуемый формат имени: + +```text +incoming-migration- +``` + +Использовать только raw node name в имени нежелательно: имя node может быть длинным или содержать символы, которые потребуют нормализации. Поэтому безопаснее формировать имя из стабильного hash, а исходное имя node хранить в label или annotation. + +Рекомендуемый объект: + +```yaml +apiVersion: coordination.k8s.io/v1 +kind: Lease +metadata: + namespace: d8-virtualization + name: incoming-migration- + labels: + virtualization.deckhouse.io/component: inbound-migration-limiter + virtualization.deckhouse.io/target-node-hash: + annotations: + virtualization.deckhouse.io/target-node: + virtualization.deckhouse.io/migration-namespace: + virtualization.deckhouse.io/migration-name: + virtualization.deckhouse.io/migration-uid: +spec: + holderIdentity: // + leaseDurationSeconds: 300 + acquireTime: + renewTime: +``` + +`holderIdentity` должен содержать не только UID, но и namespace/name. Это упрощает проверку владельца без list-а всех migrations во всех namespaces. + +OwnerReference на `VirtualMachineInstanceMigration` добавлять не нужно, потому что migration namespaced, а lease хранится в namespace control plane. Cross-namespace owner reference для namespaced объектов некорректен. Очистка должна выполняться явно через `Release` и через stale lease recovery. + +### TryAcquire + +`TryAcquire(ctx, migration, targetNode)` должен работать так: + +1. Построить lease name по `targetNode`. +2. Выполнить `Get` lease. +3. Если lease не найден: + - создать lease с holder текущей migration; + - если create завершился conflict/already exists, повторить `Get` и перейти к обычной проверке владельца. +4. Если lease найден и принадлежит текущей migration: + - обновить `renewTime`; + - вернуть `true`. +5. Если lease найден и принадлежит другой migration: + - проверить, жива ли migration-владелец; + - если владелец существует и не terminal, вернуть `false`; + - если владелец отсутствует или terminal, попытаться перехватить lease через `Update` с текущим `resourceVersion`. +6. Если update завершился conflict, вернуть retryable error или повторить короткий цикл reread/update. + +Псевдокод: + +```go +func (l *LeaseIncomingMigrationLimiter) TryAcquire(ctx context.Context, mig *virtv1.VirtualMachineInstanceMigration, targetNode string) (bool, error) { + lease, err := l.getLease(ctx, targetNode) + if apierrors.IsNotFound(err) { + return l.createLease(ctx, mig, targetNode) + } + if err != nil { + return false, err + } + + if isHeldBy(lease, mig) { + return true, l.renewLease(ctx, lease, mig) + } + + alive, err := l.holderMigrationIsActive(ctx, lease) + if err != nil { + return false, err + } + if alive { + return false, nil + } + + return l.stealLease(ctx, lease, mig, targetNode) +} +``` + +### Проверка владельца + +Проверка владельца lease должна использовать annotations: + +```text +virtualization.deckhouse.io/migration-namespace +virtualization.deckhouse.io/migration-name +virtualization.deckhouse.io/migration-uid +``` + +Алгоритм: + +1. Если annotations неполные — считать lease stale. +2. Сделать `Get` `VirtualMachineInstanceMigration` по namespace/name из annotations. +3. Если объект не найден — lease stale. +4. Если UID объекта отличается от UID в annotation — lease stale. +5. Если migration находится в terminal phase — lease stale. +6. Иначе lease занят активной migration. + +Terminal phases: + +```text +MigrationSucceeded +MigrationFailed +``` + +### Release + +`Release(ctx, migration, targetNode)` должен быть идемпотентным: + +1. Получить lease по target node. +2. Если lease отсутствует — успешно завершить. +3. Если lease принадлежит другой migration — ничего не делать. +4. Если lease принадлежит текущей migration — удалить lease. +5. Если delete получил `NotFound` — успешно завершить. + +Удаление lease предпочтительнее очистки `holderIdentity`, потому что отсутствие lease проще обрабатывать в `TryAcquire`, а stale пустые lease не будут накапливаться. + +### Renew + +Так как lease используется не для leader election, а как атомарный slot, постоянный renew не обязателен. Достаточно обновлять `renewTime` при каждом reconcile migration, которая уже владеет lease. + +`leaseDurationSeconds` нужен только как дополнительная диагностическая и safety-информация. Нельзя освобождать lease только по истечению времени, если migration-владелец всё ещё существует и не terminal: долгие live migrations допустимы. + +### Требования к client/cache + +Операции `Get/Create/Update/Delete` для Lease желательно выполнять через non-cached client или APIReader, если это доступно в месте интеграции. Это снижает риск решений на устаревшем cache. + +Даже при cached read корректность должна обеспечиваться optimistic concurrency Kubernetes API: + +- создать lease сможет только одна migration; +- перехват stale lease выполняется через `resourceVersion`; +- conflict приводит к повторному reconcile. + +### RBAC + +`virt-controller` должен получить права на leases в namespace `d8-virtualization`: + +```text +apiGroups: ["coordination.k8s.io"] +resources: ["leases"] +verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] +``` + +`list/watch` нужны только если реализация использует informer/cache или периодический cleanup. Для минимальной реализации достаточно `get/create/update/delete`, но в controller-runtime окружении часто проще выдать полный набор read/write verbs для leases. + ### Обработка stale lease Lease может остаться после аварийного завершения controller-а или удаления migration resource. При обнаружении занятого lease controller должен проверить владельца: -1. найти `VirtualMachineInstanceMigration` по UID владельца; +1. найти `VirtualMachineInstanceMigration` по namespace/name и сверить UID владельца; 2. если владелец отсутствует или terminal, считать lease stale; 3. перехватить lease через optimistic update с `resourceVersion`. From 97943cf83b2a915dd596825c09e05673e86ac341 Mon Sep 17 00:00:00 2001 From: Daniil Antoshin Date: Mon, 18 May 2026 19:08:58 +0200 Subject: [PATCH 4/7] docs: describe slot-based migration leases Signed-off-by: Daniil Antoshin --- .../adr_inbound_migration_limit.ru.md | 174 ++++++++++++------ 1 file changed, 119 insertions(+), 55 deletions(-) diff --git a/docs/internal/adr_inbound_migration_limit.ru.md b/docs/internal/adr_inbound_migration_limit.ru.md index 341f88b8e5..aabb8b7ee6 100644 --- a/docs/internal/adr_inbound_migration_limit.ru.md +++ b/docs/internal/adr_inbound_migration_limit.ru.md @@ -70,9 +70,11 @@ parallelInboundMigrationsPerNode: maxIncomingMigrationsPerNode = 1 ``` -Миграция может перейти к активной фазе только если на её target node нет другой активной входящей миграции. +При этом механизм должен проектироваться не как single-lock, а как slot-based limiter: один `Lease` соответствует одному inbound slot на target node. Лимит `1` является частным случаем с одним slot. Если в будущем потребуется разрешить, например, `5` одновременных входящих миграций на target node, controller будет использовать пять lease-slots для этой node. -Если target node уже занята другой active incoming migration, текущая миграция остаётся в ожидающем состоянии и повторно reconcile-ится позже. +Миграция может перейти к активной фазе только если на её target node есть свободный inbound slot или slot уже принадлежит этой миграции. + +Если все inbound slots target node заняты другими active incoming migrations, текущая миграция остаётся в ожидающем состоянии и повторно reconcile-ится позже. ## Определения @@ -141,7 +143,7 @@ func reconcileMigration(migration *VirtualMachineInstanceMigration) error { return continueDefaultMigrationFlow(migration) } - acquired, err := incomingLimiter.TryAcquire(ctx, migration, targetNode) + acquired, err := incomingLimiter.TryAcquire(ctx, migration, targetNode, parallelInboundMigrationsPerNode) if err != nil { return err } @@ -175,27 +177,40 @@ if migration.IsFinal() { 2. обе видят, что активных входящих миграций нет; 3. обе продолжают выполнение. -Чтобы гарантировать `<= 1`, limiter должен использовать атомарный механизм захвата slot. +Чтобы гарантировать соблюдение лимита, limiter должен использовать атомарный механизм захвата slot. Рекомендуемая реализация — Kubernetes `Lease` из `coordination.k8s.io/v1`. ### Lease model -Для каждой target node создаётся lease: +Один `Lease` представляет один inbound slot target node. + +При лимите `1` для target node доступен один slot: ```text namespace: d8-virtualization -name: incoming-migration- -holderIdentity: +name: incoming-migration--0 +holderIdentity: // +``` + +При лимите `5` для той же target node доступны пять независимых slots: + +```text +incoming-migration--0 +incoming-migration--1 +incoming-migration--2 +incoming-migration--3 +incoming-migration--4 ``` Правила: -- если lease отсутствует, миграция создаёт его со своим `UID`; -- если lease существует и `holderIdentity` равен `UID` текущей миграции, миграция продолжает выполнение; -- если lease существует и принадлежит другой non-final миграции, текущая миграция остаётся pending; -- если lease существует, но владелец уже terminal или отсутствует, lease можно перехватить; -- release удаляет lease или очищает `holderIdentity`, только если lease принадлежит текущей миграции. +- если один из slot leases отсутствует, миграция может создать его со своим holder; +- если один из slot leases уже принадлежит текущей миграции, миграция продолжает выполнение и обновляет `renewTime`; +- если slot lease принадлежит другой non-final миграции, этот slot считается занятым; +- если slot lease существует, но владелец уже terminal или отсутствует, slot можно перехватить; +- если все slots заняты другими active migrations, текущая миграция остаётся pending; +- release удаляет только тот slot lease, который принадлежит текущей миграции. ### Детали реализации Lease @@ -204,9 +219,11 @@ Lease должен быть отдельным служебным объекто Рекомендуемый формат имени: ```text -incoming-migration- +incoming-migration-- ``` +`slot-index` — число от `0` до `parallelInboundMigrationsPerNode - 1`. + Использовать только raw node name в имени нежелательно: имя node может быть длинным или содержать символы, которые потребуют нормализации. Поэтому безопаснее формировать имя из стабильного hash, а исходное имя node хранить в label или annotation. Рекомендуемый объект: @@ -216,10 +233,11 @@ apiVersion: coordination.k8s.io/v1 kind: Lease metadata: namespace: d8-virtualization - name: incoming-migration- + name: incoming-migration-- labels: virtualization.deckhouse.io/component: inbound-migration-limiter virtualization.deckhouse.io/target-node-hash: + virtualization.deckhouse.io/slot-index: "" annotations: virtualization.deckhouse.io/target-node: virtualization.deckhouse.io/migration-namespace: @@ -238,34 +256,70 @@ OwnerReference на `VirtualMachineInstanceMigration` добавлять не н ### TryAcquire -`TryAcquire(ctx, migration, targetNode)` должен работать так: +`TryAcquire(ctx, migration, targetNode, limit)` должен работать так: -1. Построить lease name по `targetNode`. -2. Выполнить `Get` lease. -3. Если lease не найден: - - создать lease с holder текущей migration; - - если create завершился conflict/already exists, повторить `Get` и перейти к обычной проверке владельца. -4. Если lease найден и принадлежит текущей migration: +1. Построить список lease names по `targetNode` и текущему лимиту: `0..parallelInboundMigrationsPerNode-1`. +2. Сначала проверить все slots и найти lease, который уже принадлежит текущей migration. +3. Если такой lease найден: - обновить `renewTime`; - вернуть `true`. -5. Если lease найден и принадлежит другой migration: - - проверить, жива ли migration-владелец; - - если владелец существует и не terminal, вернуть `false`; - - если владелец отсутствует или terminal, попытаться перехватить lease через `Update` с текущим `resourceVersion`. -6. Если update завершился conflict, вернуть retryable error или повторить короткий цикл reread/update. +4. Если текущая migration ещё не владеет slot-ом, пройти по всем slots и попытаться занять первый доступный: + - если lease не найден — создать lease с holder текущей migration; + - если create завершился conflict/already exists — перейти к следующему reread/retry; + - если lease принадлежит другой migration — проверить владельца; + - если владелец существует и не terminal — считать slot занятым и перейти к следующему; + - если владелец отсутствует или terminal — попытаться перехватить slot через `Update` с текущим `resourceVersion`. +5. Если один из slots успешно создан или перехвачен — вернуть `true`. +6. Если все slots заняты активными владельцами — вернуть `false`. +7. Если update завершился conflict, повторить короткий цикл reread/update или вернуть retryable error. Псевдокод: ```go -func (l *LeaseIncomingMigrationLimiter) TryAcquire(ctx context.Context, mig *virtv1.VirtualMachineInstanceMigration, targetNode string) (bool, error) { - lease, err := l.getLease(ctx, targetNode) +func (l *LeaseIncomingMigrationLimiter) TryAcquire(ctx context.Context, mig *virtv1.VirtualMachineInstanceMigration, targetNode string, limit int) (bool, error) { + slots := l.slotNames(targetNode, limit) + + for _, slot := range slots { + lease, err := l.getLease(ctx, slot) + if apierrors.IsNotFound(err) { + continue + } + if err != nil { + return false, err + } + if isHeldBy(lease, mig) { + return true, l.renewLease(ctx, lease, mig) + } + } + + for _, slot := range slots { + acquired, err := l.tryAcquireSlot(ctx, mig, targetNode, slot) + if err != nil { + if apierrors.IsConflict(err) || apierrors.IsAlreadyExists(err) { + continue + } + return false, err + } + if acquired { + return true, nil + } + } + + return false, nil +} +``` + +`tryAcquireSlot` внутри выполняет create, проверку владельца и steal stale slot для одного конкретного lease name. + +```go +func (l *LeaseIncomingMigrationLimiter) tryAcquireSlot(ctx context.Context, mig *virtv1.VirtualMachineInstanceMigration, targetNode string, slot string) (bool, error) { + lease, err := l.getLease(ctx, slot) if apierrors.IsNotFound(err) { - return l.createLease(ctx, mig, targetNode) + return l.createLease(ctx, mig, targetNode, slot) } if err != nil { return false, err } - if isHeldBy(lease, mig) { return true, l.renewLease(ctx, lease, mig) } @@ -312,12 +366,14 @@ MigrationFailed `Release(ctx, migration, targetNode)` должен быть идемпотентным: -1. Получить lease по target node. -2. Если lease отсутствует — успешно завершить. -3. Если lease принадлежит другой migration — ничего не делать. +1. Построить список lease names по target node и текущему лимиту. +2. Найти slot lease, принадлежащий текущей migration. +3. Если такой lease отсутствует — успешно завершить. 4. Если lease принадлежит текущей migration — удалить lease. 5. Если delete получил `NotFound` — успешно завершить. +Если лимит был уменьшен после того, как migration заняла slot с индексом за пределами нового лимита, `Release` всё равно должен уметь найти и удалить её lease. Для этого release может дополнительно list-ить leases по labels `component=inbound-migration-limiter` и `target-node-hash=`, а затем фильтровать holder текущей migration. + Удаление lease предпочтительнее очистки `holderIdentity`, потому что отсутствие lease проще обрабатывать в `TryAcquire`, а stale пустые lease не будут накапливаться. ### Renew @@ -332,9 +388,10 @@ MigrationFailed Даже при cached read корректность должна обеспечиваться optimistic concurrency Kubernetes API: -- создать lease сможет только одна migration; +- конкретный slot lease сможет создать только одна migration; +- разные migrations могут одновременно занять разные slot leases в пределах лимита; - перехват stale lease выполняется через `resourceVersion`; -- conflict приводит к повторному reconcile. +- conflict приводит к проверке следующего slot или повторному reconcile. ### RBAC @@ -369,7 +426,7 @@ Lease может остаться после аварийного заверше ```text phase: Pending condition/reason: TargetNodeIncomingMigrationLimitExceeded -message: Target node already has an active incoming migration. +message: Target node has no free inbound migration slots. ``` На уровне `VirtualMachineOperation` можно использовать существующий pending mapping: @@ -400,18 +457,21 @@ TargetNodeIncomingMigrationLimitExceeded parallelInboundMigrationsPerNode = 1 ``` +Даже при фиксированном значении реализация должна использовать slot-based модель, чтобы изменение лимита до `5` или другого значения не требовало переделки алгоритма. + Преимущества: - минимальные изменения публичного API; - не требует новых ModuleConfig параметров; -- закрывает исходное требование. +- закрывает исходное требование; +- оставляет простой путь к будущему конфигурируемому лимиту. ### Возможное развитие Позже можно сделать настройку конфигурируемой через ModuleConfig annotation и Helm values: ```yaml -virtualization.deckhouse.io/parallel-inbound-migrations-per-node: "1" +virtualization.deckhouse.io/parallel-inbound-migrations-per-node: "5" ``` Внутренний values path: @@ -461,7 +521,7 @@ virtualization.internal.virtConfig.parallelInboundMigrationsPerNode ### Положительные -- На target node будет не более одной активной входящей live migration. +- На target node будет не более настроенного числа активных входящих live migrations; на первом этапе — не более одной. - Остальные миграции будут ждать, а не падать. - Ограничение будет работать независимо от источника миграции. - Снижается риск перегрузки target node сетью, CPU, памятью и storage attach операциями. @@ -488,22 +548,23 @@ virtualization.internal.virtConfig.parallelInboundMigrationsPerNode ```go type IncomingMigrationLimiter interface { - TryAcquire(ctx context.Context, migration *virtv1.VirtualMachineInstanceMigration, targetNode string) (bool, error) - Release(ctx context.Context, migration *virtv1.VirtualMachineInstanceMigration, targetNode string) error + TryAcquire(ctx context.Context, migration *virtv1.VirtualMachineInstanceMigration, targetNode string, limit int) (bool, error) + Release(ctx context.Context, migration *virtv1.VirtualMachineInstanceMigration, targetNode string, limit int) error } ``` -Реализация должна использовать `coordination.k8s.io/v1 Lease`. +Реализация должна использовать `coordination.k8s.io/v1 Lease`. Один Lease соответствует одному inbound slot; количество slots равно `limit`. ### Шаг 3. Интегрировать limiter в migration reconcile Логика: 1. определить target node; -2. если миграция входит в active incoming phase — вызвать `TryAcquire`; -3. если slot занят — оставить migration pending и requeue; -4. если slot получен — продолжить стандартный flow; -5. на terminal phase вызвать `Release`. +2. определить текущий inbound limit; +3. если миграция входит в active incoming phase — вызвать `TryAcquire`; +4. если все slots заняты — оставить migration pending и requeue; +5. если slot получен — продолжить стандартный flow; +6. на terminal phase вызвать `Release`. ### Шаг 4. Синхронизировать диагностику в virtualization-controller @@ -524,13 +585,16 @@ type IncomingMigrationLimiter interface { Нужны unit/integration тесты для patched KubeVirt logic: -1. одна миграция на target node получает lease и продолжается; -2. вторая миграция на ту же target node остаётся pending; -3. миграция на другую target node продолжается; -4. после завершения первой миграции вторая получает lease; -5. stale lease от отсутствующей migration перехватывается; -6. lease, принадлежащий текущей migration, не блокирует повторный reconcile; -7. concurrent `TryAcquire` не выдаёт slot двум migration одновременно. +1. одна миграция на target node получает slot lease и продолжается; +2. при лимите `1` вторая миграция на ту же target node остаётся pending; +3. при лимите `5` пять миграций на одну target node получают разные slot leases; +4. при лимите `5` шестая миграция на ту же target node остаётся pending; +5. миграция на другую target node продолжается; +6. после завершения первой миграции ожидающая миграция получает освободившийся slot; +7. stale lease от отсутствующей migration перехватывается; +8. lease, принадлежащий текущей migration, не блокирует повторный reconcile; +9. concurrent `TryAcquire` не выдаёт один и тот же slot двум migration одновременно; +10. уменьшение лимита не мешает `Release` удалить slot lease, уже занятый текущей migration. Для virtualization-controller нужны тесты mapping-а статуса: @@ -547,6 +611,6 @@ type IncomingMigrationLimiter interface { ## Рекомендация -Реализовать limiter в patched KubeVirt `virt-controller` через Kubernetes Lease. +Реализовать slot-based limiter в patched KubeVirt `virt-controller` через Kubernetes Lease: один Lease соответствует одному inbound slot target node. -На первом этапе использовать фиксированный лимит `1`, без изменения публичного API. В `VMOP` отображать ожидание как `Pending`, не переводя операцию в `Failed`. +На первом этапе использовать фиксированный лимит `1`, без изменения публичного API. При будущем переходе на лимит `5` или другое значение достаточно изменить количество доступных slots. В `VMOP` отображать ожидание как `Pending`, не переводя операцию в `Failed`. From 72d9b2755d472216013a125085317face96c45ab Mon Sep 17 00:00:00 2001 From: Daniil Antoshin Date: Mon, 18 May 2026 19:22:42 +0200 Subject: [PATCH 5/7] fix(vmop): keep inbound-limited migrations pending Signed-off-by: Daniil Antoshin --- .../migration/internal/handler/lifecycle.go | 6 ++++++ .../internal/handler/lifecycle_test.go | 20 +++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/images/virtualization-artifact/pkg/controller/vmop/migration/internal/handler/lifecycle.go b/images/virtualization-artifact/pkg/controller/vmop/migration/internal/handler/lifecycle.go index 9bb21ce859..14f4f7fc28 100644 --- a/images/virtualization-artifact/pkg/controller/vmop/migration/internal/handler/lifecycle.go +++ b/images/virtualization-artifact/pkg/controller/vmop/migration/internal/handler/lifecycle.go @@ -68,6 +68,9 @@ const ( const ( reasonFailedAttachVolume = "FailedAttachVolume" reasonFailedMount = "FailedMount" + + reasonTargetNodeIncomingMigrationLimitExceeded = "TargetNodeIncomingMigrationLimitExceeded" + messageTargetNodeIncomingMigrationLimitExceeded = "Target node has no free inbound migration slots." ) type Base interface { @@ -578,6 +581,9 @@ func (h LifecycleHandler) getInProgressReasonAndMessage( case virtv1.MigrationPhaseUnset, virtv1.MigrationPending: reason = vmopcondition.ReasonMigrationPending message = messageMigrationPending + if _, found := conditions.GetKVVMIMCondition(virtv1.VirtualMachineInstanceMigrationConditionType(reasonTargetNodeIncomingMigrationLimitExceeded), mig.Status.Conditions); found { + message = messageTargetNodeIncomingMigrationLimitExceeded + } case virtv1.MigrationScheduling: reason = vmopcondition.ReasonTargetScheduling message = messageTargetPodScheduling diff --git a/images/virtualization-artifact/pkg/controller/vmop/migration/internal/handler/lifecycle_test.go b/images/virtualization-artifact/pkg/controller/vmop/migration/internal/handler/lifecycle_test.go index e821efab49..45328e651d 100644 --- a/images/virtualization-artifact/pkg/controller/vmop/migration/internal/handler/lifecycle_test.go +++ b/images/virtualization-artifact/pkg/controller/vmop/migration/internal/handler/lifecycle_test.go @@ -368,6 +368,26 @@ var _ = Describe("LifecycleHandler", func() { ), ) + It("should keep migration pending for inbound target node limit", func() { + mig := newSimpleMigration("vmop-test", name) + mig.Status.Phase = virtv1.MigrationPending + mig.Status.Conditions = []virtv1.VirtualMachineInstanceMigrationCondition{{ + Type: virtv1.VirtualMachineInstanceMigrationConditionType(reasonTargetNodeIncomingMigrationLimitExceeded), + Status: corev1.ConditionTrue, + Reason: reasonTargetNodeIncomingMigrationLimitExceeded, + Message: messageTargetNodeIncomingMigrationLimitExceeded, + }} + + fakeClient, err := testutil.NewFakeClientWithObjects(mig) + Expect(err).NotTo(HaveOccurred()) + + h := LifecycleHandler{client: fakeClient} + reason, msg, err := h.getInProgressReasonAndMessage(ctx, mig) + Expect(err).NotTo(HaveOccurred()) + Expect(reason).To(Equal(vmopcondition.ReasonMigrationPending)) + Expect(msg).To(Equal(messageTargetNodeIncomingMigrationLimitExceeded)) + }) + DescribeTable("should build in-progress reason and message", func( phase virtv1.VirtualMachineInstanceMigrationPhase, state *virtv1.VirtualMachineInstanceMigrationState, From fe1247d42e8dd629b944e3d95d8df3533cf544cc Mon Sep 17 00:00:00 2001 From: Daniil Antoshin Date: Tue, 26 May 2026 12:34:06 +0200 Subject: [PATCH 6/7] docs: update inbound migration limit ADR Signed-off-by: Daniil Antoshin --- .../adr_inbound_migration_limit.ru.md | 632 ++++++++---------- 1 file changed, 278 insertions(+), 354 deletions(-) diff --git a/docs/internal/adr_inbound_migration_limit.ru.md b/docs/internal/adr_inbound_migration_limit.ru.md index aabb8b7ee6..2d1c5fbf11 100644 --- a/docs/internal/adr_inbound_migration_limit.ru.md +++ b/docs/internal/adr_inbound_migration_limit.ru.md @@ -7,12 +7,14 @@ ## Контекст В модуле virtualization live migration выполняется через KubeVirt `VirtualMachineInstanceMigration`. -Пользовательский и автоматический сценарии миграции в Deckhouse проходят через несколько уровней: +Пользовательские и автоматические сценарии миграции в Deckhouse проходят через несколько уровней: 1. `VirtualMachineOperation` (`VMOP`) создаётся пользователем, контроллером эвакуации, workload-updater или другим компонентом. 2. `vmop-migration-controller` создаёт KubeVirt-ресурс `VirtualMachineInstanceMigration`. -3. KubeVirt `virt-controller` планирует target pod и управляет жизненным циклом live migration. -4. Контроллеры virtualization синхронизируют статус KubeVirt migration обратно в `VMOP` и `VirtualMachine`. +3. KubeVirt создаёт target pod для миграции. +4. Kubernetes scheduler назначает target pod на node. +5. KubeVirt выполняет live migration. +6. Контроллеры virtualization синхронизируют статус KubeVirt migration обратно в `VMOP` и `VirtualMachine`. Сейчас ограничение параллелизма задаётся через KubeVirt `MigrationConfiguration`: @@ -21,13 +23,7 @@ parallelMigrationsPerCluster: parallelOutboundMigrationsPerNode: ``` -В проекте это значение прокидывается из Helm/templates и hooks: - -- `templates/kubevirt/_kubevirt_helpers.tpl` -- `images/hooks/pkg/hooks/migration-config/hook.go` -- `images/virtualization-artifact/pkg/livemigration/migration_configuration.go` - -При этом KubeVirt API не содержит симметричной настройки вида: +В KubeVirt нет симметричной настройки: ```yaml parallelInboundMigrationsPerNode: @@ -35,20 +31,43 @@ parallelInboundMigrationsPerNode: Из-за этого платформа умеет ограничивать количество исходящих миграций с source node, но не умеет ограничивать количество входящих миграций на target node. На практике несколько VM могут одновременно мигрировать на одну и ту же target node, даже если для source nodes ограничение уже работает. -Требование: контролировать, что входящих миграций на target node не более одной. Остальные миграции должны ожидать в `Pending` или другом подходящем состоянии, а не завершаться ошибкой. +Требование: контролировать, что входящих миграций на target node не более одной. Остальные миграции должны штатно ожидать свободный inbound slot, а не завершаться ошибкой. + +## Новые вводные + +В проекте уже есть механизм ожидания динамических параметров миграции: + +- `virtualization-controller` вычисляет параметры через `images/virtualization-artifact/pkg/livemigration/migration_configuration.go`; +- `livemigration-controller` патчит `KVVMI.status.migrationState.migrationConfiguration`; +- KubeVirt/virt-launcher ждёт `migrationConfiguration` перед продолжением миграции. + +Основная точка подключения: + +```text +images/virtualization-artifact/pkg/controller/livemigration/internal/dynamic_settings_handler.go +DynamicSettingsHandler.Handle(ctx, kvvmi) +``` + +Сейчас handler выставляет: + +```go +kvvmi.Status.MigrationState.MigrationConfiguration = conf +``` + +Эту точку нужно использовать как gate для inbound migration limit: не выставлять `MigrationConfiguration`, пока target node не получила inbound slot. ## Проблема -Ограничение нельзя надёжно реализовать только в `vmop-migration-controller`, потому что target node обычно становится известна после создания `VirtualMachineInstanceMigration`, когда KubeVirt уже начал планировать target pod. +Ограничение нельзя надёжно реализовать до создания `VirtualMachineInstanceMigration`, потому что target node становится известна только после создания target pod и его назначения scheduler-ом. -Если пытаться решить задачу до создания KubeVirt migration, придётся повторять часть логики Kubernetes scheduler и KubeVirt placement: +Наш контроллер не должен заранее выбирать target node. Иначе ему пришлось бы повторять часть логики Kubernetes scheduler и KubeVirt placement: - учитывать `nodeSelector` из `VMOP.spec.migrate.nodeSelector`; -- учитывать placement самого `VirtualMachine`; +- учитывать placement самой `VirtualMachine`; - учитывать taints/tolerations, affinities, resources, devices, storage constraints; - учитывать динамические изменения node и pod scheduling state. -Такой подход будет неполным и не даст строгой гарантии, что KubeVirt выберет именно ту node, которую предварительно проверил controller. +Такой подход будет неполным и не даст гарантии, что KubeVirt и scheduler выберут именно ту node, которую предварительно проверил controller. Также ограничение должно применяться не только к миграциям, созданным через пользовательский `VMOP`, но и к другим источникам миграций: @@ -58,130 +77,106 @@ parallelInboundMigrationsPerNode: - автоматические системные миграции; - миграции, созданные напрямую через KubeVirt API. -Поэтому правильная точка контроля — KubeVirt migration control loop, где уже известен target node и где принимается решение о продвижении миграции по фазам. - ## Решение -Добавить в KubeVirt `virt-controller` внутренний limiter входящих миграций на target node. +Реализовать inbound migration limit в `virtualization-controller` через задержку выдачи `MigrationConfiguration` до получения `Lease` на target node. + +Общий flow: + +1. `VirtualMachineInstanceMigration` создаётся как сейчас. +2. KubeVirt создаёт target pod. +3. Kubernetes scheduler назначает target pod на target node. +4. `livemigration-controller` определяет target node. +5. Перед patch-ем `KVVMI.status.migrationState.migrationConfiguration` controller пытается получить inbound slot через Kubernetes `Lease`. +6. Если slot получен, controller выставляет `MigrationConfiguration`, и миграция продолжается. +7. Если slot занят, controller не выставляет `MigrationConfiguration`, помечает VMI annotation-ом ожидания и requeue-ит reconcile. +8. KubeVirt patch в месте ожидания `migrationConfiguration` не должен считать timeout, пока VMI явно помечена как ожидающая inbound lease. +9. Lease освобождается при завершении миграции или через stale recovery. На первом этапе лимит фиксированный: ```text -maxIncomingMigrationsPerNode = 1 +parallelInboundMigrationsPerNode = 1 ``` -При этом механизм должен проектироваться не как single-lock, а как slot-based limiter: один `Lease` соответствует одному inbound slot на target node. Лимит `1` является частным случаем с одним slot. Если в будущем потребуется разрешить, например, `5` одновременных входящих миграций на target node, controller будет использовать пять lease-slots для этой node. +При этом механизм должен проектироваться как slot-based limiter: один `Lease` соответствует одному inbound slot на target node. Лимит `1` является частным случаем с одним slot. -Миграция может перейти к активной фазе только если на её target node есть свободный inbound slot или slot уже принадлежит этой миграции. +## Target node -Если все inbound slots target node заняты другими active incoming migrations, текущая миграция остаётся в ожидающем состоянии и повторно reconcile-ится позже. +Target node выбирает Kubernetes scheduler, а не `virtualization-controller`. -## Определения +`livemigration-controller` определяет target node из доступного состояния KubeVirt: -### Target node +1. `kvvmi.Status.MigrationState.TargetNode`, если поле заполнено; +2. `kvvmi.Status.MigrationState.TargetPod` → `pod.spec.nodeName`, если target pod уже создан и назначен scheduler-ом. -Target node — node, на которую KubeVirt планирует перенести VMI. +Если target node ещё неизвестна, inbound limiter не должен блокировать миграцию и не должен пытаться выбрать node самостоятельно. Controller ждёт следующего reconcile, когда KubeVirt/scheduler продвинут scheduling target pod. -Источник target node зависит от текущей фазы миграции: +## Gate через MigrationConfiguration -- `VirtualMachineInstanceMigration.Status.MigrationState.TargetNode`, если уже заполнено; -- target pod `spec.nodeName`, если target pod уже создан и назначен scheduler-ом; -- для более ранних фаз target node может быть ещё неизвестна, и limiter не должен блокировать миграцию до появления target node. +`MigrationConfiguration` становится точкой допуска миграции к активной фазе. -### Active incoming migration +Логика в `DynamicSettingsHandler.Handle`: -Active incoming migration — миграция, которая: - -1. не находится в terminal phase; -2. имеет target node; -3. уже потребляет или скоро начнёт потреблять ресурсы target node как live migration target. - -Рекомендуемый набор фаз, которые считать активными: +```go +targetNode := resolveTargetNode(kvvmi) +if targetNode == "" { + return requeue +} -```text -MigrationScheduled -MigrationPreparingTarget -MigrationTargetReady -MigrationWaitingForSync -MigrationSynchronizing -MigrationRunning -``` +acquired, err := inboundLimiter.TryAcquire(ctx, kvvmi, targetNode, limit) +if err != nil { + return err +} -Фазы, которые не считаются активными: +if !acquired { + markInboundSlotWaiting(kvvmi, targetNode) + return requeue +} -```text -MigrationPhaseUnset -MigrationPending -MigrationSucceeded -MigrationFailed +clearInboundSlotWaiting(kvvmi) +kvvmi.Status.MigrationState.MigrationConfiguration = conf ``` -`MigrationScheduling` можно не считать активной, если target pod ещё не назначен на node. Если target pod уже имеет `spec.nodeName`, миграция может участвовать в inbound limiting даже на фазе `MigrationScheduling`. +Если lease занята, `MigrationConfiguration` не выставляется. Это удерживает миграцию в уже существующем KubeVirt flow ожидания параметров миграции. -## Алгоритм +## Annotation model -### 1. До появления target node +Для явного состояния ожидания используются annotations на VMI: -Если target node неизвестна, миграция продолжает обычный KubeVirt flow. +```yaml +virtualization.deckhouse.io/inbound-migration-slot: waiting +virtualization.deckhouse.io/inbound-migration-target-node: +``` -Limiter не должен пытаться выбирать target node самостоятельно. +Правила: -### 2. После назначения target node +- если inbound slot не получен, controller выставляет `inbound-migration-slot=waiting` и target node; +- пока annotation имеет значение `waiting`, `MigrationConfiguration` не выставляется; +- когда lease получена, controller удаляет waiting annotations и выставляет `MigrationConfiguration`; +- отдельное состояние `acquired` не требуется: наличие `MigrationConfiguration` и отсутствие `waiting` достаточно для продолжения миграции. -Перед переходом миграции в активную фазу controller проверяет inbound capacity target node. +Эти annotations нужны не только для диагностики, но и для корректной работы timeout-а ожидания migration parameters в KubeVirt. -Псевдокод: +## Timeout ожидания migration parameters -```go -func reconcileMigration(migration *VirtualMachineInstanceMigration) error { - targetNode := resolveTargetNode(migration) - if targetNode == "" { - return continueDefaultMigrationFlow(migration) - } - - if !isEnteringActiveIncomingPhase(migration) { - return continueDefaultMigrationFlow(migration) - } - - acquired, err := incomingLimiter.TryAcquire(ctx, migration, targetNode, parallelInboundMigrationsPerNode) - if err != nil { - return err - } - - if !acquired { - setMigrationPending(migration, "TargetNodeIncomingMigrationLimitExceeded") - return requeueAfter(defaultMigrationRequeueDelay) - } - - return continueDefaultMigrationFlow(migration) -} -``` - -### 3. Завершение миграции +В KubeVirt есть ожидание `migrationConfiguration`. Если параметры не появились за заданное время, миграция может быть завершена ошибкой. -При переходе миграции в terminal phase limiter освобождает занятый slot: +Для inbound limiter это поведение нужно изменить: -```go -if migration.IsFinal() { - incomingLimiter.Release(ctx, migration, targetNode) -} +```text +Если MigrationConfiguration == nil +и VMI имеет annotation virtualization.deckhouse.io/inbound-migration-slot=waiting, +то timeout ожидания migration parameters не должен тикать или не должен приводить к failed migration. ``` -Также release должен быть идемпотентным и безопасным при повторном reconcile. - -## Синхронизация и защита от race condition - -Простой подсчёт активных миграций по списку `VirtualMachineInstanceMigration` недостаточен для строгой гарантии. При нескольких workers возможна гонка: +Иначе вторая и последующие миграции на ту же target node будут падать по timeout, хотя они штатно стоят в очереди на inbound slot. -1. две миграции одновременно проверяют target node; -2. обе видят, что активных входящих миграций нет; -3. обе продолжают выполнение. +Если annotation `waiting` отсутствует, существующее поведение timeout-а сохраняется. Это важно, чтобы реальные проблемы с выдачей migration parameters не маскировались inbound limiter-ом. -Чтобы гарантировать соблюдение лимита, limiter должен использовать атомарный механизм захвата slot. +## Lease model -Рекомендуемая реализация — Kubernetes `Lease` из `coordination.k8s.io/v1`. - -### Lease model +Рекомендуемая реализация limiter-а — Kubernetes `Lease` из `coordination.k8s.io/v1`. Один `Lease` представляет один inbound slot target node. @@ -189,42 +184,28 @@ if migration.IsFinal() { ```text namespace: d8-virtualization -name: incoming-migration--0 -holderIdentity: // +name: inbound-migration--0 +holderIdentity: // ``` -При лимите `5` для той же target node доступны пять независимых slots: +При будущем лимите `5` для той же target node доступны пять независимых slots: ```text -incoming-migration--0 -incoming-migration--1 -incoming-migration--2 -incoming-migration--3 -incoming-migration--4 +inbound-migration--0 +inbound-migration--1 +inbound-migration--2 +inbound-migration--3 +inbound-migration--4 ``` Правила: -- если один из slot leases отсутствует, миграция может создать его со своим holder; -- если один из slot leases уже принадлежит текущей миграции, миграция продолжает выполнение и обновляет `renewTime`; -- если slot lease принадлежит другой non-final миграции, этот slot считается занятым; -- если slot lease существует, но владелец уже terminal или отсутствует, slot можно перехватить; -- если все slots заняты другими active migrations, текущая миграция остаётся pending; -- release удаляет только тот slot lease, который принадлежит текущей миграции. - -### Детали реализации Lease - -Lease должен быть отдельным служебным объектом, который представляет один inbound slot конкретной target node. - -Рекомендуемый формат имени: - -```text -incoming-migration-- -``` - -`slot-index` — число от `0` до `parallelInboundMigrationsPerNode - 1`. - -Использовать только raw node name в имени нежелательно: имя node может быть длинным или содержать символы, которые потребуют нормализации. Поэтому безопаснее формировать имя из стабильного hash, а исходное имя node хранить в label или annotation. +- если slot lease отсутствует, миграция может создать его со своим holder; +- если slot lease уже принадлежит текущей миграции/VMI, reconcile идемпотентно продолжает выполнение и обновляет `renewTime`; +- если slot lease принадлежит другой active migration, slot считается занятым; +- если владелец slot lease отсутствует, terminal или stale, slot можно перехватить через optimistic update; +- если все slots заняты другими active migrations, текущая миграция остаётся в ожидании `MigrationConfiguration`; +- release удаляет только lease, принадлежащий текущей миграции/VMI. Рекомендуемый объект: @@ -233,7 +214,7 @@ apiVersion: coordination.k8s.io/v1 kind: Lease metadata: namespace: d8-virtualization - name: incoming-migration-- + name: inbound-migration-- labels: virtualization.deckhouse.io/component: inbound-migration-limiter virtualization.deckhouse.io/target-node-hash: @@ -250,183 +231,62 @@ spec: renewTime: ``` -`holderIdentity` должен содержать не только UID, но и namespace/name. Это упрощает проверку владельца без list-а всех migrations во всех namespaces. - -OwnerReference на `VirtualMachineInstanceMigration` добавлять не нужно, потому что migration namespaced, а lease хранится в namespace control plane. Cross-namespace owner reference для namespaced объектов некорректен. Очистка должна выполняться явно через `Release` и через stale lease recovery. - -### TryAcquire - -`TryAcquire(ctx, migration, targetNode, limit)` должен работать так: - -1. Построить список lease names по `targetNode` и текущему лимиту: `0..parallelInboundMigrationsPerNode-1`. -2. Сначала проверить все slots и найти lease, который уже принадлежит текущей migration. -3. Если такой lease найден: - - обновить `renewTime`; - - вернуть `true`. -4. Если текущая migration ещё не владеет slot-ом, пройти по всем slots и попытаться занять первый доступный: - - если lease не найден — создать lease с holder текущей migration; - - если create завершился conflict/already exists — перейти к следующему reread/retry; - - если lease принадлежит другой migration — проверить владельца; - - если владелец существует и не terminal — считать slot занятым и перейти к следующему; - - если владелец отсутствует или terminal — попытаться перехватить slot через `Update` с текущим `resourceVersion`. -5. Если один из slots успешно создан или перехвачен — вернуть `true`. -6. Если все slots заняты активными владельцами — вернуть `false`. -7. Если update завершился conflict, повторить короткий цикл reread/update или вернуть retryable error. - -Псевдокод: - -```go -func (l *LeaseIncomingMigrationLimiter) TryAcquire(ctx context.Context, mig *virtv1.VirtualMachineInstanceMigration, targetNode string, limit int) (bool, error) { - slots := l.slotNames(targetNode, limit) - - for _, slot := range slots { - lease, err := l.getLease(ctx, slot) - if apierrors.IsNotFound(err) { - continue - } - if err != nil { - return false, err - } - if isHeldBy(lease, mig) { - return true, l.renewLease(ctx, lease, mig) - } - } - - for _, slot := range slots { - acquired, err := l.tryAcquireSlot(ctx, mig, targetNode, slot) - if err != nil { - if apierrors.IsConflict(err) || apierrors.IsAlreadyExists(err) { - continue - } - return false, err - } - if acquired { - return true, nil - } - } - - return false, nil -} -``` - -`tryAcquireSlot` внутри выполняет create, проверку владельца и steal stale slot для одного конкретного lease name. - -```go -func (l *LeaseIncomingMigrationLimiter) tryAcquireSlot(ctx context.Context, mig *virtv1.VirtualMachineInstanceMigration, targetNode string, slot string) (bool, error) { - lease, err := l.getLease(ctx, slot) - if apierrors.IsNotFound(err) { - return l.createLease(ctx, mig, targetNode, slot) - } - if err != nil { - return false, err - } - if isHeldBy(lease, mig) { - return true, l.renewLease(ctx, lease, mig) - } - - alive, err := l.holderMigrationIsActive(ctx, lease) - if err != nil { - return false, err - } - if alive { - return false, nil - } - - return l.stealLease(ctx, lease, mig, targetNode) -} -``` - -### Проверка владельца +OwnerReference на `VirtualMachineInstanceMigration` добавлять не нужно, потому что migration namespaced, а lease хранится в namespace control plane. Cross-namespace owner reference для namespaced объектов некорректен. -Проверка владельца lease должна использовать annotations: - -```text -virtualization.deckhouse.io/migration-namespace -virtualization.deckhouse.io/migration-name -virtualization.deckhouse.io/migration-uid -``` - -Алгоритм: - -1. Если annotations неполные — считать lease stale. -2. Сделать `Get` `VirtualMachineInstanceMigration` по namespace/name из annotations. -3. Если объект не найден — lease stale. -4. Если UID объекта отличается от UID в annotation — lease stale. -5. Если migration находится в terminal phase — lease stale. -6. Иначе lease занят активной migration. - -Terminal phases: - -```text -MigrationSucceeded -MigrationFailed -``` +## TryAcquire -### Release +`TryAcquire(ctx, kvvmi, targetNode, limit)` должен работать так: -`Release(ctx, migration, targetNode)` должен быть идемпотентным: +1. Построить список lease names по `targetNode` и текущему лимиту. +2. Сначала найти lease, который уже принадлежит текущей migration/VMI. +3. Если такой lease найден, обновить `renewTime` и вернуть `true`. +4. Если текущая migration ещё не владеет slot-ом, пройти по всем slots и попытаться занять первый доступный. +5. Если lease не найден, создать lease с holder текущей migration/VMI. +6. Если create завершился conflict/already exists, перечитать slot или перейти к следующему. +7. Если lease принадлежит другой migration, проверить владельца. +8. Если владелец существует и не terminal, считать slot занятым. +9. Если владелец отсутствует или terminal, перехватить slot через `Update` с текущим `resourceVersion`. +10. Если один из slots успешно создан или перехвачен, вернуть `true`. +11. Если все slots заняты активными владельцами, вернуть `false`. -1. Построить список lease names по target node и текущему лимиту. -2. Найти slot lease, принадлежащий текущей migration. -3. Если такой lease отсутствует — успешно завершить. -4. Если lease принадлежит текущей migration — удалить lease. -5. Если delete получил `NotFound` — успешно завершить. +Операции `Get/Create/Update/Delete` для Lease желательно выполнять через non-cached client или APIReader, если это доступно в месте интеграции. Корректность должна опираться на optimistic concurrency Kubernetes API. -Если лимит был уменьшен после того, как migration заняла slot с индексом за пределами нового лимита, `Release` всё равно должен уметь найти и удалить её lease. Для этого release может дополнительно list-ить leases по labels `component=inbound-migration-limiter` и `target-node-hash=`, а затем фильтровать holder текущей migration. +## Release и stale recovery -Удаление lease предпочтительнее очистки `holderIdentity`, потому что отсутствие lease проще обрабатывать в `TryAcquire`, а stale пустые lease не будут накапливаться. +Lease должен освобождаться при завершении миграции: -### Renew +- `VirtualMachineInstanceMigration` перешла в terminal phase; +- migration завершилась `Completed`/`Failed` на уровне отслеживаемого состояния; +- VMI больше не находится в live migration state; +- владелец lease удалён или его UID не совпадает с UID в annotations. -Так как lease используется не для leader election, а как атомарный slot, постоянный renew не обязателен. Достаточно обновлять `renewTime` при каждом reconcile migration, которая уже владеет lease. +`Release(ctx, owner, targetNode)` должен быть идемпотентным: -`leaseDurationSeconds` нужен только как дополнительная диагностическая и safety-информация. Нельзя освобождать lease только по истечению времени, если migration-владелец всё ещё существует и не terminal: долгие live migrations допустимы. +1. Найти lease, принадлежащий текущей migration/VMI. +2. Если lease отсутствует, завершиться успешно. +3. Если lease принадлежит текущему owner, удалить lease. +4. Если delete получил `NotFound`, завершиться успешно. -### Требования к client/cache +Если лимит был уменьшен после того, как migration заняла slot с индексом за пределами нового лимита, release всё равно должен уметь найти и удалить её lease. Для этого release может дополнительно list-ить leases по labels `component=inbound-migration-limiter` и `target-node-hash=`, а затем фильтровать holder текущей migration/VMI. -Операции `Get/Create/Update/Delete` для Lease желательно выполнять через non-cached client или APIReader, если это доступно в месте интеграции. Это снижает риск решений на устаревшем cache. +Stale recovery выполняется при `TryAcquire`: -Даже при cached read корректность должна обеспечиваться optimistic concurrency Kubernetes API: +1. прочитать holder из annotations/`holderIdentity`; +2. найти соответствующую `VirtualMachineInstanceMigration` или VMI; +3. если owner отсутствует, UID отличается или migration terminal, считать lease stale; +4. перехватить lease через optimistic update с `resourceVersion`. -- конкретный slot lease сможет создать только одна migration; -- разные migrations могут одновременно занять разные slot leases в пределах лимита; -- перехват stale lease выполняется через `resourceVersion`; -- conflict приводит к проверке следующего slot или повторному reconcile. - -### RBAC - -`virt-controller` должен получить права на leases в namespace `d8-virtualization`: - -```text -apiGroups: ["coordination.k8s.io"] -resources: ["leases"] -verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] -``` - -`list/watch` нужны только если реализация использует informer/cache или периодический cleanup. Для минимальной реализации достаточно `get/create/update/delete`, но в controller-runtime окружении часто проще выдать полный набор read/write verbs для leases. - -### Обработка stale lease - -Lease может остаться после аварийного завершения controller-а или удаления migration resource. - -При обнаружении занятого lease controller должен проверить владельца: - -1. найти `VirtualMachineInstanceMigration` по namespace/name и сверить UID владельца; -2. если владелец отсутствует или terminal, считать lease stale; -3. перехватить lease через optimistic update с `resourceVersion`. - -Дополнительно можно использовать `renewTime` и `leaseDurationSeconds`, но основной критерий освобождения — состояние migration owner. +`leaseDurationSeconds` и `renewTime` используются как диагностическая и safety-информация. Нельзя освобождать lease только по истечению времени, если migration-владелец всё ещё существует и не terminal: долгие live migrations допустимы. ## Статусы и условия -Ожидающая из-за inbound limit миграция не должна считаться failed. +Ожидающая inbound slot миграция не должна считаться failed. -Рекомендуемая модель статуса KubeVirt migration: +На уровне KubeVirt migration и VMI желательно отражать ожидание через annotation: ```text -phase: Pending -condition/reason: TargetNodeIncomingMigrationLimitExceeded -message: Target node has no free inbound migration slots. +virtualization.deckhouse.io/inbound-migration-slot=waiting +virtualization.deckhouse.io/inbound-migration-target-node= ``` На уровне `VirtualMachineOperation` можно использовать существующий pending mapping: @@ -439,13 +299,7 @@ Completed condition: message: The VirtualMachineOperation for migrating the virtual machine has been queued. Waiting for the queue to be processed and for this operation to be executed. ``` -Для лучшей диагностики можно добавить новый reason в API virtualization: - -```text -TargetNodeIncomingMigrationLimitExceeded -``` - -Но это потребует изменения API, CRD и документации. Для первого этапа достаточно сохранить `ReasonMigrationPending`, но заменить message на более точный, если KubeVirt condition содержит причину inbound limit. +Для лучшей диагностики можно уточнить message, если VMI помечена как ожидающая inbound slot. Добавление нового API reason возможно позже, но для первого этапа не обязательно. ## Конфигурация @@ -457,8 +311,6 @@ TargetNodeIncomingMigrationLimitExceeded parallelInboundMigrationsPerNode = 1 ``` -Даже при фиксированном значении реализация должна использовать slot-based модель, чтобы изменение лимита до `5` или другого значения не требовало переделки алгоритма. - Преимущества: - минимальные изменения публичного API; @@ -480,17 +332,40 @@ virtualization.deckhouse.io/parallel-inbound-migrations-per-node: "5" virtualization.internal.virtConfig.parallelInboundMigrationsPerNode ``` -Но так как upstream KubeVirt `MigrationConfiguration` не содержит такого поля, эта настройка будет Deckhouse-specific и должна применяться только в patched `virt-controller`. +Так как upstream KubeVirt `MigrationConfiguration` не содержит такого поля, эта настройка будет Deckhouse-specific и должна применяться в логике `virtualization-controller`, а не как поле upstream `MigrationConfiguration`. + +## RBAC + +`virtualization-controller` должен получить права на leases в namespace `d8-virtualization`: + +```text +apiGroups: ["coordination.k8s.io"] +resources: ["leases"] +verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] +``` + +`list/watch` нужны для cleanup/stale recovery и для случаев, когда release должен найти slot за пределами текущего лимита. + +## Изменяемые компоненты + +Основные места реализации: + +- `images/virtualization-artifact/pkg/controller/livemigration/internal/dynamic_settings_handler.go` — gate перед выставлением `MigrationConfiguration`; +- `images/virtualization-artifact/pkg/controller/livemigration/live_migration_reconciler.go` — release/stale cleanup на завершении миграции; +- `images/virtualization-artifact/pkg/livemigration/migration_configuration.go` — существующая генерация `MigrationConfiguration`; +- `images/virtualization-artifact/pkg/controller/vmop/migration/internal/handler/lifecycle.go` — отображение ожидания в статус `VMOP`, если потребуется; +- KubeVirt patch в месте ожидания `migrationConfiguration` — исключить период `inbound-migration-slot=waiting` из timeout-а ожидания parameters; +- `images/hooks/pkg/hooks/migration-config/hook.go` и `templates/kubevirt/_kubevirt_helpers.tpl` — существующие места конфигурации миграций, без добавления upstream-поля `parallelInboundMigrationsPerNode`. ## Альтернативы -### Альтернатива 1: реализовать ограничение в `vmop-migration-controller` +### Альтернатива 1: предварительно выбирать target node в `vmop-migration-controller` -Суть: перед созданием `VirtualMachineInstanceMigration` проверить target node и не создавать migration, если node занята. +Суть: до создания `VirtualMachineInstanceMigration` выбрать target node и не создавать migration, если node занята. Недостатки: -- target node чаще всего ещё неизвестна; +- target node должен выбирать Kubernetes scheduler; - controller должен повторить scheduler logic; - нет гарантии, что KubeVirt выберет проверенную node; - не покрывает миграции, созданные не через `VMOP`; @@ -498,9 +373,26 @@ virtualization.internal.virtConfig.parallelInboundMigrationsPerNode Решение отклонено. -### Альтернатива 2: простой подсчёт активных миграций без Lease +### Альтернатива 2: limiter внутри patched KubeVirt `virt-controller` -Суть: перед продолжением миграции list-ить все migrations и считать active incoming на target node. +Суть: встроить Lease limiter непосредственно в KubeVirt migration control loop. + +Преимущества: + +- близко к месту управления lifecycle KubeVirt migration; +- можно блокировать продвижение фаз напрямую. + +Недостатки: + +- больше patch surface в KubeVirt; +- сложнее поддерживать при обновлениях upstream; +- в проекте уже есть Deckhouse-specific gate ожидания `MigrationConfiguration`, который решает задачу меньшим изменением. + +Решение отклонено в пользу gate через `MigrationConfiguration`. + +### Альтернатива 3: простой подсчёт активных миграций без Lease + +Суть: перед выдачей `MigrationConfiguration` list-ить все migrations и считать active incoming на target node. Преимущества: @@ -513,104 +405,136 @@ virtualization.internal.virtConfig.parallelInboundMigrationsPerNode - возможны race conditions; - поведение зависит от cache freshness. -Можно использовать как дополнительную проверку, но не как основной механизм гарантии. +Можно использовать как дополнительную диагностику, но не как основной механизм гарантии. Решение отклонено как основной вариант. +### Альтернатива 4: mutating webhook и init container gate + +Суть: модифицировать target pod через webhook и удерживать его через init container до получения inbound slot. + +Недостатки: + +- сложнее операционно; +- требует вмешательства в pod lifecycle; +- уже есть более подходящая точка ожидания `MigrationConfiguration`; +- возможны побочные эффекты для KubeVirt target pod lifecycle. + +Решение отклонено. + +### Альтернатива 5: reactive abort/retry + +Суть: разрешить KubeVirt начать миграцию, а при превышении inbound limit abort-ить или retry-ить лишние миграции. + +Недостатки: + +- на короткое время лимит может быть превышен; +- создаёт лишние abort/retry циклы; +- хуже пользовательский опыт; +- сложнее отличать штатную очередь от ошибки. + +Решение отклонено. + ## Последствия ### Положительные - На target node будет не более настроенного числа активных входящих live migrations; на первом этапе — не более одной. +- Target node по-прежнему выбирает Kubernetes scheduler. +- Не нужно реализовывать собственную scheduler logic в `virtualization-controller`. - Остальные миграции будут ждать, а не падать. +- Ожидание использует уже существующий gate `MigrationConfiguration`. - Ограничение будет работать независимо от источника миграции. +- Lease даёт строгую защиту от race condition между несколькими reconcile workers. - Снижается риск перегрузки target node сетью, CPU, памятью и storage attach операциями. -- Поведение становится симметричнее текущему outbound limit. ### Отрицательные -- Требуется patch KubeVirt `virt-controller`. +- Требуется новый служебный ресурс `Lease` и логика очистки stale leases. +- Требуется patch KubeVirt timeout-а ожидания `migrationConfiguration`. - Появляется Deckhouse-specific поведение, которое нужно учитывать при обновлении KubeVirt. -- Появляется новый служебный ресурс `Lease` и логика очистки stale leases. - Возможна меньшая скорость массовой эвакуации, если много VM мигрируют на одну target node. +- Нужно аккуратно синхронизировать annotations, lease ownership и status patch VMI. ## План реализации -### Шаг 1. Найти точку интеграции в KubeVirt - -В patched `virt-controller` найти control loop, который продвигает `VirtualMachineInstanceMigration` по фазам и создаёт/контролирует target pod. - -Нужно вставить limiter после того, как target node известна, но до начала активной live migration синхронизации. - -### Шаг 2. Добавить incoming limiter +### Шаг 1. Добавить inbound limiter в `virtualization-controller` Добавить компонент примерно такого вида: ```go -type IncomingMigrationLimiter interface { - TryAcquire(ctx context.Context, migration *virtv1.VirtualMachineInstanceMigration, targetNode string, limit int) (bool, error) - Release(ctx context.Context, migration *virtv1.VirtualMachineInstanceMigration, targetNode string, limit int) error +type InboundMigrationLimiter interface { + TryAcquire(ctx context.Context, kvvmi *virtv1.VirtualMachineInstance, targetNode string, limit int) (bool, error) + Release(ctx context.Context, kvvmi *virtv1.VirtualMachineInstance, targetNode string, limit int) error } ``` -Реализация должна использовать `coordination.k8s.io/v1 Lease`. Один Lease соответствует одному inbound slot; количество slots равно `limit`. +Реализация должна использовать `coordination.k8s.io/v1 Lease`. -### Шаг 3. Интегрировать limiter в migration reconcile +### Шаг 2. Интегрировать limiter в `DynamicSettingsHandler.Handle` Логика: -1. определить target node; -2. определить текущий inbound limit; -3. если миграция входит в active incoming phase — вызвать `TryAcquire`; -4. если все slots заняты — оставить migration pending и requeue; -5. если slot получен — продолжить стандартный flow; -6. на terminal phase вызвать `Release`. +1. определить target node из `kvvmi.Status.MigrationState.TargetNode` или target pod `spec.nodeName`; +2. если target node неизвестна, requeue без выбора node; +3. вызвать `TryAcquire`; +4. если slot не получен, выставить waiting annotations и не выставлять `MigrationConfiguration`; +5. если slot получен, удалить waiting annotations и выставить `MigrationConfiguration`. -### Шаг 4. Синхронизировать диагностику в virtualization-controller +### Шаг 3. Изменить KubeVirt timeout ожидания parameters -Если KubeVirt migration получила reason `TargetNodeIncomingMigrationLimitExceeded`, `vmop-migration-controller` должен отображать это как pending состояние. +В KubeVirt patch-е ожидания `migrationConfiguration` добавить правило: + +```text +VMI with virtualization.deckhouse.io/inbound-migration-slot=waiting is waiting for inbound lease and must not fail by migration parameters timeout. +``` + +### Шаг 4. Добавить release/stale recovery + +Логика: + +1. при terminal migration удалить lease owner-а; +2. при failed/completed состояниях удалить lease; +3. при обнаружении stale holder-а во время `TryAcquire` перехватить slot; +4. сделать release идемпотентным. + +### Шаг 5. Синхронизировать диагностику в VMOP + +Если VMI имеет `inbound-migration-slot=waiting`, `vmop-migration-controller` должен отображать операцию как pending, а не failed. Минимальный вариант: - `VMOP.status.phase = Pending`; - `Completed.reason = MigrationPending`; -- message содержит информацию про занятый target node. - -Расширенный вариант: +- message содержит информацию про ожидание inbound slot на target node. -- добавить новый `vmopcondition.ReasonCompleted`; -- обновить CRD и документацию. +### Шаг 6. Тесты -### Шаг 5. Тесты +Нужны unit/integration тесты: -Нужны unit/integration тесты для patched KubeVirt logic: - -1. одна миграция на target node получает slot lease и продолжается; -2. при лимите `1` вторая миграция на ту же target node остаётся pending; -3. при лимите `5` пять миграций на одну target node получают разные slot leases; -4. при лимите `5` шестая миграция на ту же target node остаётся pending; -5. миграция на другую target node продолжается; +1. одна миграция на target node получает slot lease и получает `MigrationConfiguration`; +2. при лимите `1` вторая миграция на ту же target node получает waiting annotations и не получает `MigrationConfiguration`; +3. KubeVirt timeout ожидания parameters не fail-ит VMI с `inbound-migration-slot=waiting`; +4. migration без waiting annotation сохраняет существующее timeout-поведение; +5. миграция на другую target node получает свой lease и продолжается; 6. после завершения первой миграции ожидающая миграция получает освободившийся slot; -7. stale lease от отсутствующей migration перехватывается; +7. stale lease от отсутствующей или terminal migration перехватывается; 8. lease, принадлежащий текущей migration, не блокирует повторный reconcile; -9. concurrent `TryAcquire` не выдаёт один и тот же slot двум migration одновременно; -10. уменьшение лимита не мешает `Release` удалить slot lease, уже занятый текущей migration. - -Для virtualization-controller нужны тесты mapping-а статуса: - -1. KubeVirt migration pending из-за inbound limit отображается в `VMOP.status.phase = Pending`; -2. migration не переводится в failed; -3. message понятен пользователю. +9. concurrent `TryAcquire` не выдаёт один и тот же slot двум migrations одновременно; +10. release идемпотентен; +11. VMOP для ожидающей inbound slot миграции остаётся в `Pending`, а не переходит в `Failed`. ## Нерешённые вопросы -1. Нужно ли считать `MigrationPreparingTarget` активной входящей миграцией или блокировать только начиная с `MigrationTargetReady`? +1. Достаточно ли хранить holder по `VirtualMachineInstanceMigration`, или удобнее привязывать lease к VMI и текущему migration UID из `MigrationState`? 2. Делать ли `parallelInboundMigrationsPerNode` публичной настройкой сразу или оставить фиксированным `1`? 3. Нужно ли добавлять новый API reason в `VMOP`, или достаточно существующего `MigrationPending` с уточнённым message? -4. Где хранить lease: в namespace KubeVirt (`d8-virtualization`) или рядом с migration namespace? +4. Где именно в KubeVirt ожидании `migrationConfiguration` лучше исключить waiting period из timeout-а: останавливать timer или игнорировать timeout result при наличии annotation? ## Рекомендация -Реализовать slot-based limiter в patched KubeVirt `virt-controller` через Kubernetes Lease: один Lease соответствует одному inbound slot target node. +Реализовать inbound migration limit через задержку выдачи `MigrationConfiguration` в `virtualization-controller` до получения Lease на target node. + +Target node должен выбирать Kubernetes scheduler. `virtualization-controller` только читает результат scheduling-а из KubeVirt/VMI state и использует его для Lease-based limiter-а. -На первом этапе использовать фиксированный лимит `1`, без изменения публичного API. При будущем переходе на лимит `5` или другое значение достаточно изменить количество доступных slots. В `VMOP` отображать ожидание как `Pending`, не переводя операцию в `Failed`. +На первом этапе использовать фиксированный лимит `1`, waiting annotations и patch KubeVirt timeout-а ожидания migration parameters. В `VMOP` отображать ожидание как `Pending`, не переводя операцию в `Failed`. From a4f2cf0b383f1ab5045e4f6adaf0adb22896c3ba Mon Sep 17 00:00:00 2001 From: Daniil Antoshin Date: Tue, 26 May 2026 13:53:36 +0200 Subject: [PATCH 7/7] feat: gate inbound migrations by target node lease Signed-off-by: Daniil Antoshin --- .../internal/dynamic_settings_handler.go | 84 ++++- .../internal/dynamic_settings_handler_test.go | 42 ++- .../migration/internal/handler/lifecycle.go | 18 + .../pkg/livemigration/inbound_limiter.go | 311 ++++++++++++++++++ .../pkg/livemigration/inbound_limiter_test.go | 115 +++++++ .../livemigration/migration_configuration.go | 34 +- 6 files changed, 584 insertions(+), 20 deletions(-) create mode 100644 images/virtualization-artifact/pkg/livemigration/inbound_limiter.go create mode 100644 images/virtualization-artifact/pkg/livemigration/inbound_limiter_test.go diff --git a/images/virtualization-artifact/pkg/controller/livemigration/internal/dynamic_settings_handler.go b/images/virtualization-artifact/pkg/controller/livemigration/internal/dynamic_settings_handler.go index 6b98be0751..c11e2cd2ad 100644 --- a/images/virtualization-artifact/pkg/controller/livemigration/internal/dynamic_settings_handler.go +++ b/images/virtualization-artifact/pkg/controller/livemigration/internal/dynamic_settings_handler.go @@ -18,7 +18,9 @@ package internal import ( "context" + "time" + corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/types" virtv1 "kubevirt.io/api/core/v1" "sigs.k8s.io/controller-runtime/pkg/client" @@ -29,28 +31,76 @@ import ( "github.com/deckhouse/virtualization/api/core/v1alpha2" ) -const dynamicSettingsHandlerName = "DynamicSettingsHandler" +const ( + dynamicSettingsHandlerName = "DynamicSettingsHandler" + inboundSlotRequeueDelay = 5 * time.Second +) + +type InboundMigrationLimiter interface { + TryAcquire(ctx context.Context, kvvmi *virtv1.VirtualMachineInstance, targetNode string, limit int) (bool, error) + Release(ctx context.Context, kvvmi *virtv1.VirtualMachineInstance, targetNode string, limit int) error +} func NewDynamicSettingsHandler(client client.Client) *DynamicSettingsHandler { + return NewDynamicSettingsHandlerWithLimiter(client, livemigration.NewInboundMigrationLimiter(client)) +} + +func NewDynamicSettingsHandlerWithLimiter(client client.Client, limiter InboundMigrationLimiter) *DynamicSettingsHandler { return &DynamicSettingsHandler{ - client: client, + client: client, + limiter: limiter, } } type DynamicSettingsHandler struct { - client client.Client + client client.Client + limiter InboundMigrationLimiter } func (h *DynamicSettingsHandler) Handle(ctx context.Context, kvvmi *virtv1.VirtualMachineInstance) (reconcile.Result, error) { log := logger.FromContext(ctx).With(logger.SlogHandler(dynamicSettingsHandlerName)) + if kvvmi.Status.MigrationState != nil && (kvvmi.Status.MigrationState.Completed || kvvmi.Status.MigrationState.Failed) { + targetNode, err := h.resolveTargetNode(ctx, kvvmi) + if err != nil { + return reconcile.Result{}, err + } + if err := h.limiter.Release(ctx, kvvmi, targetNode, livemigration.ParallelInboundMigrationsPerNodeDefault); err != nil { + return reconcile.Result{}, err + } + livemigration.ClearInboundMigrationSlotWaiting(kvvmi) + return reconcile.Result{}, nil + } + if !h.shouldUpdateMigrationConfiguration(kvvmi) { return reconcile.Result{}, nil } + targetNode, err := h.resolveTargetNode(ctx, kvvmi) + if err != nil { + return reconcile.Result{}, err + } + if targetNode == "" { + log.Debug("Target node is not resolved yet, waiting before setting migrationConfiguration") + return reconcile.Result{RequeueAfter: inboundSlotRequeueDelay}, nil + } + + acquired, err := h.limiter.TryAcquire(ctx, kvvmi, targetNode, livemigration.ParallelInboundMigrationsPerNodeDefault) + if err != nil { + return reconcile.Result{}, err + } + if !acquired { + livemigration.MarkInboundMigrationSlotWaiting(kvvmi, targetNode) + log.Debug("Inbound migration slot is not acquired, waiting before setting migrationConfiguration", + "targetNode", targetNode, + ) + return reconcile.Result{RequeueAfter: inboundSlotRequeueDelay}, nil + } + livemigration.ClearInboundMigrationSlotWaiting(kvvmi) + var vm v1alpha2.VirtualMachine vmKey := client.ObjectKeyFromObject(kvvmi) - err := h.client.Get(ctx, vmKey, &vm) + err = h.client.Get(ctx, vmKey, &vm) if err != nil { return reconcile.Result{}, err } @@ -99,10 +149,32 @@ func (h *DynamicSettingsHandler) Name() string { // shouldUpdateMigrationConfiguration indicates if live migration controller should inject // migration configuration into KVVMI status: // 1. status.migrationState is created by the virt-controller. -// 2. migration is not in a Completed state. +// 2. migration is not in a terminal state and has no migration configuration yet. func (h *DynamicSettingsHandler) shouldUpdateMigrationConfiguration(kvvmi *virtv1.VirtualMachineInstance) bool { return kvvmi.Status.MigrationState != nil && - !kvvmi.Status.MigrationState.Completed + !kvvmi.Status.MigrationState.Completed && + !kvvmi.Status.MigrationState.Failed && + kvvmi.Status.MigrationState.MigrationConfiguration == nil +} + +func (h *DynamicSettingsHandler) resolveTargetNode(ctx context.Context, kvvmi *virtv1.VirtualMachineInstance) (string, error) { + if kvvmi.Status.MigrationState == nil { + return "", nil + } + if kvvmi.Status.MigrationState.TargetNode != "" { + return kvvmi.Status.MigrationState.TargetNode, nil + } + if kvvmi.Status.MigrationState.TargetPod == "" { + return "", nil + } + + var pod corev1.Pod + err := h.client.Get(ctx, types.NamespacedName{Namespace: kvvmi.Namespace, Name: kvvmi.Status.MigrationState.TargetPod}, &pod) + if err != nil { + return "", client.IgnoreNotFound(err) + } + + return pod.Spec.NodeName, nil } // getVMOPInProgressForVM check if there is at least one VMOP for the same VM in progress phase. diff --git a/images/virtualization-artifact/pkg/controller/livemigration/internal/dynamic_settings_handler_test.go b/images/virtualization-artifact/pkg/controller/livemigration/internal/dynamic_settings_handler_test.go index 1652108564..7a380b280d 100644 --- a/images/virtualization-artifact/pkg/controller/livemigration/internal/dynamic_settings_handler_test.go +++ b/images/virtualization-artifact/pkg/controller/livemigration/internal/dynamic_settings_handler_test.go @@ -20,11 +20,13 @@ import ( . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" "k8s.io/utils/ptr" virtv1 "kubevirt.io/api/core/v1" vmbuilder "github.com/deckhouse/virtualization-controller/pkg/builder/vm" "github.com/deckhouse/virtualization-controller/pkg/common/testutil" + "github.com/deckhouse/virtualization-controller/pkg/livemigration" "github.com/deckhouse/virtualization/api/core/v1alpha2" ) @@ -57,6 +59,13 @@ var _ = Describe("TestDynamicSettingsHandler", func() { return vmi } + withMigrationState := func(kvvmi *virtv1.VirtualMachineInstance, migrationUID string) { + kvvmi.Status.MigrationState = &virtv1.VirtualMachineInstanceMigrationState{ + TargetNode: "node-a", + MigrationUID: types.UID(migrationUID), + } + } + newVMOPEvict := func(force *bool) *v1alpha2.VirtualMachineOperation { vmop := &v1alpha2.VirtualMachineOperation{ TypeMeta: metav1.TypeMeta{ @@ -98,8 +107,7 @@ var _ = Describe("TestDynamicSettingsHandler", func() { It("Should set migrationConfiguration", func() { vm := newVM() kvvmi := newKVVMI() - - kvvmi.Status.MigrationState = &virtv1.VirtualMachineInstanceMigrationState{} + withMigrationState(kvvmi, "migration-uid") fakeClient := setupEnvironment(kvvmi, vm, newKVConfig()) h := NewDynamicSettingsHandler(fakeClient) @@ -107,12 +115,38 @@ var _ = Describe("TestDynamicSettingsHandler", func() { Expect(err).NotTo(HaveOccurred()) Expect(kvvmi.Status.MigrationState.MigrationConfiguration).ShouldNot(BeNil(), "Should set migrationConfiguration") + Expect(kvvmi.Annotations).NotTo(HaveKey(livemigration.InboundMigrationSlotAnnotation)) + }) + + It("Should wait without migrationConfiguration when inbound slot is busy", func() { + vm := newVM() + kvvmi := newKVVMI() + withMigrationState(kvvmi, "migration-uid") + + otherKVVMI := newKVVMI() + otherKVVMI.Name = "other-vm" + withMigrationState(otherKVVMI, "other-migration-uid") + + fakeClient := setupEnvironment(kvvmi, vm, otherKVVMI, newKVConfig()) + limiter := livemigration.NewInboundMigrationLimiter(fakeClient) + acquired, err := limiter.TryAcquire(ctx, otherKVVMI, "node-a", livemigration.ParallelInboundMigrationsPerNodeDefault) + Expect(err).NotTo(HaveOccurred()) + Expect(acquired).To(BeTrue()) + + h := NewDynamicSettingsHandler(fakeClient) + res, err := h.Handle(ctx, kvvmi) + Expect(err).NotTo(HaveOccurred()) + + Expect(res.RequeueAfter).To(BeNumerically(">", 0)) + Expect(kvvmi.Status.MigrationState.MigrationConfiguration).Should(BeNil(), "Should not set migrationConfiguration") + Expect(kvvmi.Annotations).To(HaveKeyWithValue(livemigration.InboundMigrationSlotAnnotation, livemigration.InboundMigrationSlotWaiting)) + Expect(kvvmi.Annotations).To(HaveKeyWithValue(livemigration.InboundMigrationTargetNodeAnnotation, "node-a")) }) It("Should propagate DisableTLS from KubeVirt config", func() { vm := newVM() kvvmi := newKVVMI() - kvvmi.Status.MigrationState = &virtv1.VirtualMachineInstanceMigrationState{} + withMigrationState(kvvmi, "migration-uid") kvConfig := newKVConfig() kvConfig.Spec.Configuration.MigrationConfiguration = &virtv1.MigrationConfiguration{ @@ -154,7 +188,7 @@ var _ = Describe("TestDynamicSettingsHandler", func() { vm.Spec.LiveMigrationPolicy = policy kvvmi := newKVVMI() - kvvmi.Status.MigrationState = &virtv1.VirtualMachineInstanceMigrationState{} + withMigrationState(kvvmi, "migration-uid") vmop := newVMOPEvict(force) diff --git a/images/virtualization-artifact/pkg/controller/vmop/migration/internal/handler/lifecycle.go b/images/virtualization-artifact/pkg/controller/vmop/migration/internal/handler/lifecycle.go index 14f4f7fc28..af03fc10c2 100644 --- a/images/virtualization-artifact/pkg/controller/vmop/migration/internal/handler/lifecycle.go +++ b/images/virtualization-artifact/pkg/controller/vmop/migration/internal/handler/lifecycle.go @@ -583,6 +583,10 @@ func (h LifecycleHandler) getInProgressReasonAndMessage( message = messageMigrationPending if _, found := conditions.GetKVVMIMCondition(virtv1.VirtualMachineInstanceMigrationConditionType(reasonTargetNodeIncomingMigrationLimitExceeded), mig.Status.Conditions); found { message = messageTargetNodeIncomingMigrationLimitExceeded + } else if waiting, err := h.isWaitingForInboundMigrationSlot(ctx, mig); err != nil { + return reason, message, err + } else if waiting { + message = messageTargetNodeIncomingMigrationLimitExceeded } case virtv1.MigrationScheduling: reason = vmopcondition.ReasonTargetScheduling @@ -699,6 +703,20 @@ func humanizeMigrationFailedMessage(message string) string { return message } +func (h LifecycleHandler) isWaitingForInboundMigrationSlot(ctx context.Context, mig *virtv1.VirtualMachineInstanceMigration) (bool, error) { + if mig == nil || mig.Spec.VMIName == "" { + return false, nil + } + + var kvvmi virtv1.VirtualMachineInstance + err := h.client.Get(ctx, types.NamespacedName{Namespace: mig.Namespace, Name: mig.Spec.VMIName}, &kvvmi) + if err != nil { + return false, client.IgnoreNotFound(err) + } + + return livemigration.IsInboundMigrationSlotWaiting(&kvvmi), nil +} + func (h LifecycleHandler) getTargetPod(ctx context.Context, mig *virtv1.VirtualMachineInstanceMigration) (*corev1.Pod, error) { selector, err := metav1.LabelSelectorAsSelector(&metav1.LabelSelector{ MatchLabels: map[string]string{ diff --git a/images/virtualization-artifact/pkg/livemigration/inbound_limiter.go b/images/virtualization-artifact/pkg/livemigration/inbound_limiter.go new file mode 100644 index 0000000000..3b3312c784 --- /dev/null +++ b/images/virtualization-artifact/pkg/livemigration/inbound_limiter.go @@ -0,0 +1,311 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package livemigration + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "strconv" + "strings" + "time" + + coordinationv1 "k8s.io/api/coordination/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + virtv1 "kubevirt.io/api/core/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +const ( + InboundMigrationSlotAnnotation = "virtualization.deckhouse.io/inbound-migration-slot" + InboundMigrationTargetNodeAnnotation = "virtualization.deckhouse.io/inbound-migration-target-node" + InboundMigrationSlotWaiting = "waiting" + + InboundMigrationLimiterComponentLabel = "virtualization.deckhouse.io/component" + InboundMigrationTargetNodeHashLabel = "virtualization.deckhouse.io/target-node-hash" + InboundMigrationSlotIndexLabel = "virtualization.deckhouse.io/slot-index" + + InboundMigrationLimiterComponent = "inbound-migration-limiter" + + InboundMigrationLeaseTargetNodeAnnotation = "virtualization.deckhouse.io/target-node" + InboundMigrationVMINamespaceAnnotation = "virtualization.deckhouse.io/vmi-namespace" + InboundMigrationVMINameAnnotation = "virtualization.deckhouse.io/vmi-name" + InboundMigrationMigrationUIDAnnotation = "virtualization.deckhouse.io/migration-uid" + InboundMigrationLeaseNamespace = "d8-virtualization" + ParallelInboundMigrationsPerNodeDefault = 1 + InboundMigrationLeaseDurationSeconds int32 = 300 +) + +type InboundMigrationLimiter struct { + client client.Client +} + +func NewInboundMigrationLimiter(client client.Client) *InboundMigrationLimiter { + return &InboundMigrationLimiter{client: client} +} + +func (l *InboundMigrationLimiter) TryAcquire(ctx context.Context, kvvmi *virtv1.VirtualMachineInstance, targetNode string, limit int) (bool, error) { + if limit < 1 { + limit = 1 + } + + slots := slotNames(targetNode, limit) + for _, slot := range slots { + lease, err := l.getLease(ctx, slot) + if apierrors.IsNotFound(err) { + continue + } + if err != nil { + return false, err + } + if leaseHeldByVMI(lease, kvvmi) { + return true, l.renewLease(ctx, lease) + } + } + + for slotIndex, slot := range slots { + acquired, err := l.tryAcquireSlot(ctx, kvvmi, targetNode, slot, slotIndex) + if apierrors.IsConflict(err) || apierrors.IsAlreadyExists(err) { + continue + } + if err != nil { + return false, err + } + if acquired { + return true, nil + } + } + + return false, nil +} + +func (l *InboundMigrationLimiter) Release(ctx context.Context, kvvmi *virtv1.VirtualMachineInstance, targetNode string, limit int) error { + if targetNode == "" { + return nil + } + if limit < 1 { + limit = 1 + } + + for _, slot := range slotNames(targetNode, limit) { + lease, err := l.getLease(ctx, slot) + if apierrors.IsNotFound(err) { + continue + } + if err != nil { + return err + } + if leaseHeldByVMI(lease, kvvmi) { + return client.IgnoreNotFound(l.client.Delete(ctx, lease)) + } + } + + var leases coordinationv1.LeaseList + err := l.client.List(ctx, &leases, + client.InNamespace(InboundMigrationLeaseNamespace), + client.MatchingLabels{ + InboundMigrationLimiterComponentLabel: InboundMigrationLimiterComponent, + InboundMigrationTargetNodeHashLabel: targetNodeHash(targetNode), + }, + ) + if err != nil { + return err + } + + for i := range leases.Items { + lease := &leases.Items[i] + if leaseHeldByVMI(lease, kvvmi) { + return client.IgnoreNotFound(l.client.Delete(ctx, lease)) + } + } + + return nil +} + +func (l *InboundMigrationLimiter) tryAcquireSlot(ctx context.Context, kvvmi *virtv1.VirtualMachineInstance, targetNode, slot string, slotIndex int) (bool, error) { + lease, err := l.getLease(ctx, slot) + if apierrors.IsNotFound(err) { + return true, l.client.Create(ctx, newInboundMigrationLease(kvvmi, targetNode, slot, slotIndex)) + } + if err != nil { + return false, err + } + if leaseHeldByVMI(lease, kvvmi) { + return true, l.renewLease(ctx, lease) + } + + active, err := l.leaseHolderIsActive(ctx, lease) + if err != nil { + return false, err + } + if active { + return false, nil + } + + updateLeaseHolder(lease, kvvmi, targetNode, slotIndex) + return true, l.client.Update(ctx, lease) +} + +func (l *InboundMigrationLimiter) getLease(ctx context.Context, name string) (*coordinationv1.Lease, error) { + lease := &coordinationv1.Lease{} + err := l.client.Get(ctx, types.NamespacedName{Namespace: InboundMigrationLeaseNamespace, Name: name}, lease) + return lease, err +} + +func (l *InboundMigrationLimiter) renewLease(ctx context.Context, lease *coordinationv1.Lease) error { + now := metav1.NewMicroTime(time.Now()) + lease.Spec.RenewTime = &now + return l.client.Update(ctx, lease) +} + +func (l *InboundMigrationLimiter) leaseHolderIsActive(ctx context.Context, lease *coordinationv1.Lease) (bool, error) { + vmiNamespace := lease.Annotations[InboundMigrationVMINamespaceAnnotation] + vmiName := lease.Annotations[InboundMigrationVMINameAnnotation] + migrationUID := lease.Annotations[InboundMigrationMigrationUIDAnnotation] + if vmiNamespace == "" || vmiName == "" || migrationUID == "" { + return false, nil + } + + var kvvmi virtv1.VirtualMachineInstance + err := l.client.Get(ctx, types.NamespacedName{Namespace: vmiNamespace, Name: vmiName}, &kvvmi) + if apierrors.IsNotFound(err) { + return false, nil + } + if err != nil { + return false, err + } + if kvvmi.Status.MigrationState == nil || kvvmi.Status.MigrationState.Completed || kvvmi.Status.MigrationState.Failed { + return false, nil + } + if string(kvvmi.Status.MigrationState.MigrationUID) != migrationUID { + return false, nil + } + + return true, nil +} + +func newInboundMigrationLease(kvvmi *virtv1.VirtualMachineInstance, targetNode, name string, slotIndex int) *coordinationv1.Lease { + now := metav1.NewMicroTime(time.Now()) + lease := &coordinationv1.Lease{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: InboundMigrationLeaseNamespace, + Name: name, + Labels: map[string]string{}, + Annotations: map[string]string{}, + }, + Spec: coordinationv1.LeaseSpec{ + LeaseDurationSeconds: ptrInt32(InboundMigrationLeaseDurationSeconds), + AcquireTime: &now, + RenewTime: &now, + }, + } + updateLeaseHolder(lease, kvvmi, targetNode, slotIndex) + return lease +} + +func updateLeaseHolder(lease *coordinationv1.Lease, kvvmi *virtv1.VirtualMachineInstance, targetNode string, slotIndex int) { + if lease.Labels == nil { + lease.Labels = map[string]string{} + } + if lease.Annotations == nil { + lease.Annotations = map[string]string{} + } + + migrationUID := string(kvvmi.Status.MigrationState.MigrationUID) + lease.Labels[InboundMigrationLimiterComponentLabel] = InboundMigrationLimiterComponent + lease.Labels[InboundMigrationTargetNodeHashLabel] = targetNodeHash(targetNode) + lease.Labels[InboundMigrationSlotIndexLabel] = strconv.Itoa(slotIndex) + lease.Annotations[InboundMigrationLeaseTargetNodeAnnotation] = targetNode + lease.Annotations[InboundMigrationVMINamespaceAnnotation] = kvvmi.Namespace + lease.Annotations[InboundMigrationVMINameAnnotation] = kvvmi.Name + lease.Annotations[InboundMigrationMigrationUIDAnnotation] = migrationUID + lease.Spec.HolderIdentity = ptrString(fmt.Sprintf("%s/%s/%s", kvvmi.Namespace, kvvmi.Name, migrationUID)) + now := metav1.NewMicroTime(time.Now()) + lease.Spec.RenewTime = &now + if lease.Spec.AcquireTime == nil { + lease.Spec.AcquireTime = &now + } +} + +func leaseHeldByVMI(lease *coordinationv1.Lease, kvvmi *virtv1.VirtualMachineInstance) bool { + if lease.Annotations == nil || kvvmi.Status.MigrationState == nil { + return false + } + + return lease.Annotations[InboundMigrationVMINamespaceAnnotation] == kvvmi.Namespace && + lease.Annotations[InboundMigrationVMINameAnnotation] == kvvmi.Name && + lease.Annotations[InboundMigrationMigrationUIDAnnotation] == string(kvvmi.Status.MigrationState.MigrationUID) +} + +func slotNames(targetNode string, limit int) []string { + result := make([]string, 0, limit) + hash := targetNodeHash(targetNode) + for i := range limit { + result = append(result, fmt.Sprintf("inbound-migration-%s-%d", hash, i)) + } + return result +} + +func targetNodeHash(targetNode string) string { + sum := sha256.Sum256([]byte(targetNode)) + return hex.EncodeToString(sum[:])[:16] +} + +func ptrString(v string) *string { + return &v +} + +func ptrInt32(v int32) *int32 { + return &v +} + +func MarkInboundMigrationSlotWaiting(kvvmi *virtv1.VirtualMachineInstance, targetNode string) { + if kvvmi.Annotations == nil { + kvvmi.Annotations = map[string]string{} + } + kvvmi.Annotations[InboundMigrationSlotAnnotation] = InboundMigrationSlotWaiting + kvvmi.Annotations[InboundMigrationTargetNodeAnnotation] = targetNode +} + +func ClearInboundMigrationSlotWaiting(kvvmi *virtv1.VirtualMachineInstance) { + if kvvmi.Annotations == nil { + return + } + delete(kvvmi.Annotations, InboundMigrationSlotAnnotation) + delete(kvvmi.Annotations, InboundMigrationTargetNodeAnnotation) + if len(kvvmi.Annotations) == 0 { + kvvmi.Annotations = nil + } +} + +func IsInboundMigrationSlotWaiting(kvvmi *virtv1.VirtualMachineInstance) bool { + return kvvmi.Annotations[InboundMigrationSlotAnnotation] == InboundMigrationSlotWaiting +} + +func InboundMigrationWaitingTargetNode(kvvmi *virtv1.VirtualMachineInstance) string { + return kvvmi.Annotations[InboundMigrationTargetNodeAnnotation] +} + +func DumpInboundMigrationSlot(kvvmi *virtv1.VirtualMachineInstance) string { + if !IsInboundMigrationSlotWaiting(kvvmi) { + return "not waiting" + } + return strings.Join([]string{InboundMigrationSlotWaiting, InboundMigrationWaitingTargetNode(kvvmi)}, ":") +} diff --git a/images/virtualization-artifact/pkg/livemigration/inbound_limiter_test.go b/images/virtualization-artifact/pkg/livemigration/inbound_limiter_test.go new file mode 100644 index 0000000000..b68e287273 --- /dev/null +++ b/images/virtualization-artifact/pkg/livemigration/inbound_limiter_test.go @@ -0,0 +1,115 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package livemigration + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + coordinationv1 "k8s.io/api/coordination/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + virtv1 "kubevirt.io/api/core/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/deckhouse/virtualization-controller/pkg/common/testutil" +) + +var _ = Describe("InboundMigrationLimiter", func() { + const ( + namespace = "default" + targetNode = "node-a" + ) + + ctx := testutil.ContextBackgroundWithNoOpLogger() + + newKVVMI := func(name, migrationUID string) *virtv1.VirtualMachineInstance { + return &virtv1.VirtualMachineInstance{ + TypeMeta: metav1.TypeMeta{ + APIVersion: virtv1.SchemeGroupVersion.String(), + Kind: virtv1.VirtualMachineInstanceGroupVersionKind.Kind, + }, + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + Status: virtv1.VirtualMachineInstanceStatus{ + MigrationState: &virtv1.VirtualMachineInstanceMigrationState{ + TargetNode: targetNode, + MigrationUID: types.UID(migrationUID), + }, + }, + } + } + + It("Should acquire one slot only for the same target node", func() { + first := newKVVMI("first", "first-migration") + second := newKVVMI("second", "second-migration") + fakeClient, err := testutil.NewFakeClientWithObjects(first, second) + Expect(err).NotTo(HaveOccurred()) + + limiter := NewInboundMigrationLimiter(fakeClient) + acquired, err := limiter.TryAcquire(ctx, first, targetNode, 1) + Expect(err).NotTo(HaveOccurred()) + Expect(acquired).To(BeTrue()) + + acquired, err = limiter.TryAcquire(ctx, second, targetNode, 1) + Expect(err).NotTo(HaveOccurred()) + Expect(acquired).To(BeFalse()) + }) + + It("Should release acquired slot", func() { + first := newKVVMI("first", "first-migration") + second := newKVVMI("second", "second-migration") + fakeClient, err := testutil.NewFakeClientWithObjects(first, second) + Expect(err).NotTo(HaveOccurred()) + + limiter := NewInboundMigrationLimiter(fakeClient) + acquired, err := limiter.TryAcquire(ctx, first, targetNode, 1) + Expect(err).NotTo(HaveOccurred()) + Expect(acquired).To(BeTrue()) + + Expect(limiter.Release(ctx, first, targetNode, 1)).To(Succeed()) + + acquired, err = limiter.TryAcquire(ctx, second, targetNode, 1) + Expect(err).NotTo(HaveOccurred()) + Expect(acquired).To(BeTrue()) + }) + + It("Should steal stale slot", func() { + first := newKVVMI("first", "first-migration") + second := newKVVMI("second", "second-migration") + fakeClient, err := testutil.NewFakeClientWithObjects(first, second) + Expect(err).NotTo(HaveOccurred()) + + limiter := NewInboundMigrationLimiter(fakeClient) + acquired, err := limiter.TryAcquire(ctx, first, targetNode, 1) + Expect(err).NotTo(HaveOccurred()) + Expect(acquired).To(BeTrue()) + + first.Status.MigrationState.Completed = true + Expect(fakeClient.Status().Update(ctx, first)).To(Succeed()) + + acquired, err = limiter.TryAcquire(ctx, second, targetNode, 1) + Expect(err).NotTo(HaveOccurred()) + Expect(acquired).To(BeTrue()) + + var leases coordinationv1.LeaseList + Expect(fakeClient.List(ctx, &leases, client.InNamespace(InboundMigrationLeaseNamespace))).To(Succeed()) + Expect(leases.Items).To(HaveLen(1)) + Expect(leases.Items[0].Annotations).To(HaveKeyWithValue(InboundMigrationVMINameAnnotation, second.Name)) + }) +}) diff --git a/images/virtualization-artifact/pkg/livemigration/migration_configuration.go b/images/virtualization-artifact/pkg/livemigration/migration_configuration.go index 1b6618d0b9..7fce43e88f 100644 --- a/images/virtualization-artifact/pkg/livemigration/migration_configuration.go +++ b/images/virtualization-artifact/pkg/livemigration/migration_configuration.go @@ -103,20 +103,34 @@ func DumpKVVMIMigrationConfiguration(kvvmi *virtv1.VirtualMachineInstance) strin } func GenerateMigrationConfigurationPatch(current, changed *virtv1.VirtualMachineInstance) ([]byte, error) { - if current.Status.MigrationState == nil || changed.Status.MigrationState == nil { - return nil, nil + jsonPatch := patch.NewJSONPatch() + + if current.Status.MigrationState != nil && changed.Status.MigrationState != nil { + currentConf := current.Status.MigrationState.MigrationConfiguration + changedConf := changed.Status.MigrationState.MigrationConfiguration + if !equality.Semantic.DeepEqual(currentConf, changedConf) { + op := patch.PatchReplaceOp + if currentConf == nil { + op = patch.PatchAddOp + } + jsonPatch.Append(patch.NewJSONPatchOperation(op, "/status/migrationState/migrationConfiguration", changedConf)) + } } - currentConf := current.Status.MigrationState.MigrationConfiguration - changedConf := changed.Status.MigrationState.MigrationConfiguration - if equality.Semantic.DeepEqual(currentConf, changedConf) { - return nil, nil + if !equality.Semantic.DeepEqual(current.Annotations, changed.Annotations) { + switch { + case changed.Annotations == nil: + jsonPatch.Append(patch.NewJSONPatchOperation(patch.PatchRemoveOp, "/metadata/annotations", nil)) + case current.Annotations == nil: + jsonPatch.Append(patch.NewJSONPatchOperation(patch.PatchAddOp, "/metadata/annotations", changed.Annotations)) + default: + jsonPatch.Append(patch.NewJSONPatchOperation(patch.PatchReplaceOp, "/metadata/annotations", changed.Annotations)) + } } - op := patch.PatchReplaceOp - if currentConf == nil { - op = patch.PatchAddOp + if jsonPatch.Len() == 0 { + return nil, nil } - return patch.NewJSONPatch(patch.NewJSONPatchOperation(op, "/status/migrationState/migrationConfiguration", changedConf)).Bytes() + return jsonPatch.Bytes() }