Skip to content

Commit a671117

Browse files
committed
Add LCOW live migration support across the controller stacks
Implement end-to-end live migration support for LCOW pods so a running guest and its workloads can be moved between hosts. Every controller in the stack — VM, pod, Linux container, process, network, and SCSI/Plan9/VPCI devices — gains a save/import lifecycle plus the destination-side patch and resume plumbing needed to rebind local resources and bring the workload back online. Migration lifecycle: - Source: Save serializes a controller's state into a self-describing protobuf envelope and freezes the controller (StateSourceMigrating) so no mutating ops race the transfer; Resume rolls the freeze back, and a finalize Stop or VM teardown terminates it. - Destination: Import rehydrates a controller into a migrating state, Patch repoints saved resources (layer VHDs, process IO/bundle, network namespace) at the destination host, and Resume binds the live VM, guest, and devices and republishes events so containerd treats the task as locally running. AbortMigrated discards an imported-but-never-resumed controller and emits synthetic exits so Delete can proceed. VM controller: - Add the source/destination migrating states and the full HCS migration lifecycle: InitializeLiveMigrationOnSource, StartLiveMigrationOnSource, StartLiveMigrationTransfer, FinalizeLiveMigration, plus StartWithMigrationOptions on the destination. - Exchange the opaque compatibility blob, retain the final HCS document so the destination can recreate an identical VM, and recover the GCS port/bridge-id allocator floors so reissued ids cannot collide. - Make SCSI initialization lazy (built on first use from the HCS document) and handle never-started/destination teardown paths, including the already-stopped HCS error. Controller-specific changes: - SCSI controller switches to an RWMutex and rejects all ops while migrating; ReserveForRootfs now carries the full disk config. - Process, network, container, and VM state machines document and enforce the new migrating states and transitions. - Pod gains a migrating guard, AbortMigrated fan-out, and routes new containers through lazy SCSI init. Includes accompanying unit tests for the new save/import/patch/resume paths across all controllers. Signed-off-by: Harsh Rawat <harshrawat@microsoft.com>
1 parent 41c7620 commit a671117

64 files changed

Lines changed: 5692 additions & 206 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

cmd/containerd-shim-lcow-v2/service/mocks/mock_service.go

Lines changed: 21 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

cmd/containerd-shim-lcow-v2/service/types.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import (
1919
"github.com/Microsoft/hcsshim/internal/protocol/guestresource"
2020
"github.com/Microsoft/hcsshim/internal/shimdiag"
2121
"github.com/Microsoft/hcsshim/internal/vm/guestmanager"
22+
"github.com/Microsoft/hcsshim/internal/vm/vmmanager"
2223
"github.com/containerd/containerd/api/runtime/task/v3"
2324
containerdtypes "github.com/containerd/containerd/api/types/task"
2425
"github.com/opencontainers/runtime-spec/specs-go"
@@ -83,8 +84,11 @@ type vmController interface {
8384
// Guest returns the guest manager used for guest-side operations.
8485
Guest() *guestmanager.Guest
8586

87+
// VM returns the vm manager used for UVM host side operations.
88+
VM() *vmmanager.UtilityVM
89+
8690
// SCSIController returns the SCSI device controller for the VM.
87-
SCSIController() *scsi.Controller
91+
SCSIController(ctx context.Context) (*scsi.Controller, error)
8892

8993
// VPCIController returns the vPCI device controller for the VM.
9094
VPCIController() *vpci.Controller

cmd/containerd-shim-runhcs-v1/service.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -522,7 +522,7 @@ func (s *service) DiagPid(ctx context.Context, req *shimdiag.PidRequest) (*shimd
522522
if s == nil {
523523
return nil, nil
524524
}
525-
ctx, span := oc.StartSpan(ctx, "DiagPid") //nolint:ineffassign,staticcheck
525+
_, span := oc.StartSpan(ctx, "DiagPid")
526526
defer span.End()
527527

528528
span.AddAttributes(trace.StringAttribute("tid", s.tid))

cmd/containerd-shim-runhcs-v1/service_internal_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import (
1717

1818
func verifyExpectedError(t *testing.T, resp interface{}, actual, expected error) {
1919
t.Helper()
20-
if actual == nil || errors.Cause(actual) != expected || !errors.Is(actual, expected) { //nolint:errorlint
20+
if actual == nil || !errors.Is(actual, expected) {
2121
t.Fatalf("expected error: %v, got: %v", expected, actual)
2222
}
2323

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
//go:build windows && lcow
2+
3+
package plan9
4+
5+
import (
6+
"fmt"
7+
)
8+
9+
// Save is not yet supported for the Plan9 sub-controller; any tracked state
10+
// indicates a live-migration scenario the controller cannot represent.
11+
func (c *Controller) Save() error {
12+
c.mu.Lock()
13+
defer c.mu.Unlock()
14+
15+
if len(c.sharesByHostPath) > 0 || len(c.reservations) > 0 {
16+
return fmt.Errorf("plan9 controller save not supported: %d shares, %d reservations", len(c.sharesByHostPath), len(c.reservations))
17+
}
18+
19+
return nil
20+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
//go:build windows && lcow
2+
3+
package plan9
4+
5+
import (
6+
"testing"
7+
8+
"github.com/Microsoft/go-winio/pkg/guid"
9+
10+
"github.com/Microsoft/hcsshim/internal/controller/device/plan9/share"
11+
)
12+
13+
func TestSave_EmptyOK(t *testing.T) {
14+
c := &Controller{
15+
reservations: map[guid.GUID]*reservation{},
16+
sharesByHostPath: map[string]*share.Share{},
17+
}
18+
19+
if err := c.Save(); err != nil {
20+
t.Fatalf("Save on empty controller: %v", err)
21+
}
22+
}
23+
24+
func TestSave_NonEmptyErrors(t *testing.T) {
25+
c := &Controller{
26+
reservations: map[guid.GUID]*reservation{{}: {hostPath: "/h"}},
27+
sharesByHostPath: map[string]*share.Share{},
28+
}
29+
30+
if err := c.Save(); err == nil {
31+
t.Fatal("expected Save to error when reservations are present")
32+
}
33+
}

internal/controller/device/scsi/controller.go

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ import (
3434
// it succeeds to release the reservation and all resources.
3535
type Controller struct {
3636
// mu serializes all public operations on the Controller.
37-
mu sync.Mutex
37+
mu sync.RWMutex
3838

3939
// vm is the host-side interface for adding and removing SCSI disks.
4040
// Immutable after construction.
@@ -58,6 +58,10 @@ type Controller struct {
5858
// ControllerID = index / numLUNsPerController
5959
// LUN = index % numLUNsPerController
6060
controllerSlots []*disk.Disk
61+
62+
// isMigrating rejects all public ops while set: true once a snapshot has
63+
// been taken or imported, until migration is resumed. Guarded by mu.
64+
isMigrating bool
6165
}
6266

6367
// New creates a new [Controller] for the given number of SCSI controllers and
@@ -78,18 +82,22 @@ func New(numControllers int, vm VMSCSIOps, guest GuestSCSIOps) *Controller {
7882
// once per controller and lun location, and must be called before any calls to
7983
// Reserve() to ensure the rootfs reservation is not evicted by a dynamic
8084
// reservation.
81-
func (c *Controller) ReserveForRootfs(ctx context.Context, controller, lun uint) error {
85+
func (c *Controller) ReserveForRootfs(ctx context.Context, controller, lun uint, cfg disk.Config) error {
8286
c.mu.Lock()
8387
defer c.mu.Unlock()
8488

89+
if c.isMigrating {
90+
return fmt.Errorf("SCSI controller is migrating; call Resume first")
91+
}
92+
8593
slot := int(controller*numLUNsPerController + lun)
8694
if slot >= len(c.controllerSlots) {
8795
return fmt.Errorf("invalid controller %d or lun %d", controller, lun)
8896
}
8997
if c.controllerSlots[slot] != nil {
9098
return fmt.Errorf("slot for controller %d and lun %d is already reserved", controller, lun)
9199
}
92-
c.controllerSlots[slot] = disk.NewReserved(controller, lun, disk.Config{})
100+
c.controllerSlots[slot] = disk.NewReserved(controller, lun, cfg)
93101
return nil
94102
}
95103

@@ -103,6 +111,10 @@ func (c *Controller) Reserve(ctx context.Context, diskConfig disk.Config, mountC
103111
c.mu.Lock()
104112
defer c.mu.Unlock()
105113

114+
if c.isMigrating {
115+
return guid.GUID{}, fmt.Errorf("SCSI controller is migrating; call Resume first")
116+
}
117+
106118
ctx, _ = log.WithContext(ctx, logrus.WithFields(logrus.Fields{
107119
logfields.HostPath: diskConfig.HostPath,
108120
logfields.Partition: mountConfig.Partition,
@@ -178,6 +190,10 @@ func (c *Controller) MapToGuest(ctx context.Context, id guid.GUID) (string, erro
178190
c.mu.Lock()
179191
defer c.mu.Unlock()
180192

193+
if c.isMigrating {
194+
return "", fmt.Errorf("SCSI controller is migrating; call Resume first")
195+
}
196+
181197
r, ok := c.reservations[id]
182198
if !ok {
183199
return "", fmt.Errorf("reservation %s not found", id)
@@ -212,6 +228,10 @@ func (c *Controller) UnmapFromGuest(ctx context.Context, id guid.GUID) error {
212228
c.mu.Lock()
213229
defer c.mu.Unlock()
214230

231+
if c.isMigrating {
232+
return fmt.Errorf("SCSI controller is migrating; call Resume first")
233+
}
234+
215235
ctx, _ = log.WithContext(ctx, logrus.WithField("reservation", id.String()))
216236

217237
r, ok := c.reservations[id]

internal/controller/device/scsi/controller_test.go

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"context"
77
"errors"
88
"fmt"
9+
"strings"
910
"testing"
1011

1112
"github.com/Microsoft/hcsshim/internal/controller/device/scsi/disk"
@@ -70,6 +71,17 @@ func mappedController(t *testing.T) (*Controller, guid.GUID) {
7071
return c, id
7172
}
7273

74+
func attachmentsContainPath(att map[string]hcsschema.Scsi, path string) bool {
75+
for _, s := range att {
76+
for _, a := range s.Attachments {
77+
if a.Path == path {
78+
return true
79+
}
80+
}
81+
}
82+
return false
83+
}
84+
7385
// --- Tests: New ---
7486

7587
func TestNew(t *testing.T) {
@@ -397,3 +409,111 @@ func TestUnmapFromGuest_RetryAfterDetachFailure(t *testing.T) {
397409
t.Fatalf("re-reserve after retry: %v", err)
398410
}
399411
}
412+
413+
// --- Tests: ReserveForRootfs ---
414+
415+
func TestReserveForRootfs_Success(t *testing.T) {
416+
c := newController(&mockVMOps{}, newMockGuestOps())
417+
cfg := defaultDiskConfig()
418+
if err := c.ReserveForRootfs(context.Background(), 0, 0, cfg); err != nil {
419+
t.Fatalf("unexpected error: %v", err)
420+
}
421+
// The reserved rootfs disk surfaces in the VM topology with its config.
422+
if !attachmentsContainPath(c.HCSAttachments(), cfg.HostPath) {
423+
t.Errorf("expected rootfs path %q in HCS attachments", cfg.HostPath)
424+
}
425+
}
426+
427+
func TestReserveForRootfs_InvalidLocation(t *testing.T) {
428+
c := newController(&mockVMOps{}, newMockGuestOps())
429+
// Controller index beyond the single configured controller.
430+
if err := c.ReserveForRootfs(context.Background(), 1, 0, defaultDiskConfig()); err == nil {
431+
t.Fatal("expected error for out-of-range location")
432+
}
433+
}
434+
435+
func TestReserveForRootfs_AlreadyReserved(t *testing.T) {
436+
c := newController(&mockVMOps{}, newMockGuestOps())
437+
if err := c.ReserveForRootfs(context.Background(), 0, 0, defaultDiskConfig()); err != nil {
438+
t.Fatalf("first reserve: %v", err)
439+
}
440+
if err := c.ReserveForRootfs(context.Background(), 0, 0, defaultDiskConfig()); err == nil {
441+
t.Fatal("expected error reserving an occupied location")
442+
}
443+
}
444+
445+
// --- Tests: migration guard ---
446+
447+
func TestPublicOps_RejectedWhileMigrating(t *testing.T) {
448+
ctx := t.Context()
449+
ops := []struct {
450+
name string
451+
call func(*Controller) error
452+
}{
453+
{"ReserveForRootfs", func(c *Controller) error {
454+
return c.ReserveForRootfs(ctx, 0, 0, defaultDiskConfig())
455+
}},
456+
{"Reserve", func(c *Controller) error {
457+
_, err := c.Reserve(ctx, defaultDiskConfig(), defaultMountConfig())
458+
return err
459+
}},
460+
{"MapToGuest", func(c *Controller) error {
461+
_, err := c.MapToGuest(ctx, guid.GUID{})
462+
return err
463+
}},
464+
{"UnmapFromGuest", func(c *Controller) error {
465+
return c.UnmapFromGuest(ctx, guid.GUID{})
466+
}},
467+
}
468+
for _, op := range ops {
469+
t.Run(op.name, func(t *testing.T) {
470+
// Saving the source blocks further operations until migration resumes.
471+
src := New(1, &mockVMOps{}, newMockGuestOps())
472+
env, err := src.Save(ctx)
473+
if err != nil {
474+
t.Fatalf("Save: %v", err)
475+
}
476+
if err := op.call(src); err == nil || !strings.Contains(err.Error(), "migrating") {
477+
t.Fatalf("source: expected migrating error, got %v", err)
478+
}
479+
480+
// A freshly imported controller is also mid-migration until resumed.
481+
c, err := Import(ctx, env)
482+
if err != nil {
483+
t.Fatalf("Import: %v", err)
484+
}
485+
if err := op.call(c); err == nil || !strings.Contains(err.Error(), "migrating") {
486+
t.Fatalf("imported: expected migrating error, got %v", err)
487+
}
488+
})
489+
}
490+
}
491+
492+
func TestResume_LiftsMigrationGuard(t *testing.T) {
493+
ctx := t.Context()
494+
// Snapshot a controller holding a reservation, then import it.
495+
src := newController(&mockVMOps{}, newMockGuestOps())
496+
if _, err := src.Reserve(ctx, defaultDiskConfig(), defaultMountConfig()); err != nil {
497+
t.Fatalf("setup Reserve: %v", err)
498+
}
499+
env, err := src.Save(ctx)
500+
if err != nil {
501+
t.Fatalf("Save: %v", err)
502+
}
503+
c, err := Import(ctx, env)
504+
if err != nil {
505+
t.Fatalf("Import: %v", err)
506+
}
507+
508+
// Rejected while migrating.
509+
if _, err := c.Reserve(ctx, defaultDiskConfig(), defaultMountConfig()); err == nil {
510+
t.Fatal("expected migrating error before Resume")
511+
}
512+
513+
// Resuming binds live interfaces and lifts the guard.
514+
c.Resume(ctx, &mockVMOps{}, newMockGuestOps())
515+
dc := disk.Config{HostPath: `C:\other.vhdx`, Type: disk.TypeVirtualDisk}
516+
if _, err := c.Reserve(ctx, dc, defaultMountConfig()); err != nil {
517+
t.Fatalf("Reserve after Resume: %v", err)
518+
}
519+
}

0 commit comments

Comments
 (0)