1021 lines
33 KiB
Markdown
1021 lines
33 KiB
Markdown
# Incus Clustering Guide for IncusOS (via Remotes)
|
|
|
|
This guide covers forming an Incus cluster from IncusOS nodes using only the
|
|
`incus` CLI and remotes. No SSH access to the nodes is needed (IncusOS is
|
|
immutable and has no shell).
|
|
|
|
Tested with Incus client 6.21 and IncusOS nodes deployed via `incusos-proxmox`.
|
|
|
|
---
|
|
|
|
## Overview
|
|
|
|
Incus clustering does **not** require a virtual IP (VIP), load balancer, or
|
|
shared storage. Each node advertises its own IP address. Clients can connect
|
|
to any cluster member -- requests are forwarded internally via the cluster's
|
|
internal communication channel.
|
|
|
|
**Cluster roles:**
|
|
- **database-leader**: holds the primary Dqlite database
|
|
- **database**: database replica (for HA, need 3 database members)
|
|
- **database-standby**: standby replica (promoted automatically if a database
|
|
member goes down)
|
|
|
|
With 3 nodes, you get a fully HA database quorum.
|
|
|
|
---
|
|
|
|
## Prerequisites
|
|
|
|
- Incus client version **6.20+** (required for `incus cluster join` via remotes)
|
|
- All nodes deployed with `apply_defaults: true` (matching pool/network names)
|
|
- Remotes configured for all nodes (`incus remote add <name> https://<ip>:8443`)
|
|
- Client certificate trusted on all nodes (injected via seed or added manually)
|
|
|
|
```bash
|
|
incus version # Verify 6.20+
|
|
incus info incus-lab-01: | head -5 # Verify remote connectivity
|
|
incus info incus-lab-02: | head -5
|
|
incus info incus-lab-03: | head -5
|
|
```
|
|
|
|
---
|
|
|
|
## Step 1: Set Specific IP Addresses
|
|
|
|
### Why this is needed
|
|
|
|
IncusOS nodes listen on `:8443` by default (all interfaces). Clustering
|
|
requires each node to have a specific routable IP so other members can
|
|
address it. Without this, `incus cluster enable` either fails or prompts
|
|
for an address.
|
|
|
|
### How to discover node IPs
|
|
|
|
```bash
|
|
for node in incus-lab-01 incus-lab-02 incus-lab-03; do
|
|
echo "=== $node ==="
|
|
incus query "$node:/1.0" | python3 -c "
|
|
import sys, json
|
|
d = json.load(sys.stdin)
|
|
for a in d['environment']['addresses']:
|
|
if not a.startswith('10.') and not a.startswith('fd42:') and not a.startswith('['):
|
|
print(a)
|
|
"
|
|
done
|
|
```
|
|
|
|
This filters out:
|
|
- `10.x.x.x` -- internal bridge addresses (`incusbr0`)
|
|
- `fd42:` -- ULA IPv6 bridge addresses
|
|
- `[...]` -- IPv6 addresses in bracket notation
|
|
|
|
### Set the addresses
|
|
|
|
```bash
|
|
incus config set incus-lab-01: core.https_address 192.168.1.250:8443
|
|
incus config set incus-lab-02: core.https_address 192.168.1.159:8443
|
|
incus config set incus-lab-03: core.https_address 192.168.1.78:8443
|
|
```
|
|
|
|
### Verify connectivity after each change
|
|
|
|
```bash
|
|
incus config get incus-lab-01: core.https_address # Should show IP:8443
|
|
incus info incus-lab-01: | head -3 # Should still connect
|
|
```
|
|
|
|
### What happens under the hood
|
|
|
|
The Incus daemon rebinds its HTTPS listener from `0.0.0.0:8443` to the
|
|
specific IP. Since the client remote was already connecting to that IP, the
|
|
connection continues to work seamlessly. Certificate trust is stored by
|
|
fingerprint in the Incus database, independent of the listen address.
|
|
|
|
---
|
|
|
|
## Step 2: Enable Clustering on the Init Node
|
|
|
|
```bash
|
|
incus cluster enable incus-lab-01: incus-lab-01
|
|
```
|
|
|
|
**Syntax note**: this is TWO arguments -- `remote:` (trailing colon, targeting
|
|
the server) and `member-name` (the cluster member name). The help text shows
|
|
`[<remote>:] <name>`. Using `remote:name` as a single arg causes "The incus
|
|
daemon doesn't appear to be started" on client-only systems.
|
|
|
|
### The TLS certificate problem
|
|
|
|
Enabling clustering causes the server to generate a **new TLS certificate**
|
|
(the cluster certificate). This new cert typically only has SANs (Subject
|
|
Alternative Names) for `127.0.0.1` and `::1`, not the node's actual IP.
|
|
|
|
Your existing remote has the old (pre-clustering) certificate pinned, so
|
|
the next connection fails:
|
|
|
|
```
|
|
Error: tls: failed to verify certificate: x509: certificate is valid for
|
|
127.0.0.1, ::1, not 192.168.1.250
|
|
```
|
|
|
|
### The fix: re-add the remote
|
|
|
|
```bash
|
|
# Switch default remote first (can't remove the current default)
|
|
incus remote switch local
|
|
|
|
# Remove and re-add with the new certificate
|
|
incus remote remove incus-lab-01
|
|
incus remote add incus-lab-01 https://192.168.1.250:8443 --accept-certificate
|
|
```
|
|
|
|
Incus remotes use **fingerprint pinning** (not CA-based SAN validation), so
|
|
`--accept-certificate` pins the new cluster cert and future connections work
|
|
despite the SAN mismatch.
|
|
|
|
### Verify
|
|
|
|
```bash
|
|
incus cluster list incus-lab-01:
|
|
```
|
|
|
|
Expected output: one member (`incus-lab-01`), status `ONLINE`, role
|
|
`database-leader` + `database`.
|
|
|
|
---
|
|
|
|
## Step 3: Prepare Joining Nodes
|
|
|
|
### The apply_defaults storage pool conflict
|
|
|
|
Nodes deployed with `apply_defaults: true` already have:
|
|
- A `local` ZFS storage pool (source: `local/incus`)
|
|
- An `incusbr0` network bridge
|
|
- A `default` profile referencing both
|
|
|
|
When joining a cluster that already has a `local` pool defined, the join
|
|
wizard asks for `source` and `zfs.pool_name` properties. However, it
|
|
attempts to set these as **cluster-wide** config, which Incus rejects because
|
|
they are **member-specific** keys:
|
|
|
|
```
|
|
Error: Failed to join cluster: Failed to initialize member: Failed to
|
|
initialize storage pools and networks: Failed to update storage pool
|
|
"local": Config key "zfs.pool_name" is cluster member specific
|
|
```
|
|
|
|
### The fix: delete pool and network before joining
|
|
|
|
This must be done for **each joining node** (not the init node).
|
|
|
|
```bash
|
|
NODE=incus-lab-02 # repeat with incus-lab-03
|
|
|
|
# 1. Remove server config references to the pool
|
|
incus config unset "$NODE": storage.backups_volume
|
|
incus config unset "$NODE": storage.images_volume
|
|
|
|
# 2. Delete the backup and image custom volumes
|
|
incus storage volume delete "$NODE":local backups
|
|
incus storage volume delete "$NODE":local images
|
|
|
|
# 3. Remove default profile device references (pool shows "in use" otherwise)
|
|
incus profile device remove "$NODE":default root
|
|
incus profile device remove "$NODE":default eth0
|
|
|
|
# 4. Now delete the pool and network
|
|
incus storage delete "$NODE":local
|
|
incus network delete "$NODE":incusbr0
|
|
```
|
|
|
|
The underlying ZFS dataset (`local/incus`) still exists on disk -- only the
|
|
Incus metadata is removed. The join process will re-register it.
|
|
|
|
---
|
|
|
|
## Step 4: Join Nodes
|
|
|
|
### Interactive method
|
|
|
|
```bash
|
|
# Generate a join token (on the cluster, for the new member name)
|
|
incus cluster add incus-lab-01:incus-lab-02
|
|
|
|
# Join (prompts for 5 values)
|
|
incus cluster join incus-lab-01: incus-lab-02:
|
|
```
|
|
|
|
The interactive prompts and correct answers:
|
|
|
|
| # | Prompt | Answer |
|
|
|---|--------|--------|
|
|
| 1 | IP address or DNS name [default=192.168.1.159] | Press Enter (accept default) |
|
|
| 2 | Member name [default=incus-lab-02] | Press Enter (accept default) |
|
|
| 3 | All existing data is lost, continue? [default=no] | `yes` |
|
|
| 4 | Choose "source" property for storage pool "local" | `local/incus` |
|
|
| 5 | Choose "zfs.pool_name" property for storage pool "local" | `local/incus` |
|
|
|
|
### Automated (non-interactive) method
|
|
|
|
```bash
|
|
# Generate token
|
|
incus cluster add incus-lab-01:incus-lab-02
|
|
|
|
# Join with piped answers (two empty lines = accept defaults for IP + name)
|
|
printf '\n\nyes\nlocal/incus\nlocal/incus\n' | incus cluster join incus-lab-01: incus-lab-02:
|
|
```
|
|
|
|
### Fix the remote after join
|
|
|
|
Same certificate issue as the init node -- the joining node now uses the
|
|
cluster certificate:
|
|
|
|
```bash
|
|
incus remote remove incus-lab-02
|
|
incus remote add incus-lab-02 https://192.168.1.159:8443 --accept-certificate
|
|
```
|
|
|
|
### Verify after each join
|
|
|
|
```bash
|
|
incus cluster list incus-lab-01:
|
|
incus info incus-lab-02: | head -5
|
|
```
|
|
|
|
### Repeat for remaining nodes
|
|
|
|
```bash
|
|
# Prepare node 3 (same cleanup as step 3)
|
|
# ...
|
|
|
|
# Generate token + join
|
|
incus cluster add incus-lab-01:incus-lab-03
|
|
printf '\n\nyes\nlocal/incus\nlocal/incus\n' | incus cluster join incus-lab-01: incus-lab-03:
|
|
|
|
# Fix remote
|
|
incus remote remove incus-lab-03
|
|
incus remote add incus-lab-03 https://192.168.1.78:8443 --accept-certificate
|
|
```
|
|
|
|
---
|
|
|
|
## Step 5: Verify the Cluster
|
|
|
|
```bash
|
|
# All 3 members should be ONLINE
|
|
incus cluster list incus-lab-01:
|
|
|
|
# Check storage pool exists on all members
|
|
incus storage show incus-lab-01:local
|
|
incus storage show incus-lab-01:local --target incus-lab-01
|
|
incus storage show incus-lab-01:local --target incus-lab-02
|
|
incus storage show incus-lab-01:local --target incus-lab-03
|
|
|
|
# Network should be on all members
|
|
incus network list incus-lab-01:
|
|
|
|
# All individual remotes should work
|
|
incus info incus-lab-01: | head -3
|
|
incus info incus-lab-02: | head -3
|
|
incus info incus-lab-03: | head -3
|
|
```
|
|
|
|
---
|
|
|
|
## Quick Reference: Full Automated Script
|
|
|
|
For copy-paste clustering of a 3-node lab (replace IPs with your values):
|
|
|
|
```bash
|
|
IP1=192.168.1.250
|
|
IP2=192.168.1.159
|
|
IP3=192.168.1.78
|
|
|
|
# --- Set specific IPs ---
|
|
incus config set incus-lab-01: core.https_address "$IP1":8443
|
|
incus config set incus-lab-02: core.https_address "$IP2":8443
|
|
incus config set incus-lab-03: core.https_address "$IP3":8443
|
|
|
|
# --- Enable clustering on init node ---
|
|
incus cluster enable incus-lab-01: incus-lab-01
|
|
|
|
# Fix remote (switch default first if needed)
|
|
incus remote switch local
|
|
incus remote remove incus-lab-01
|
|
incus remote add incus-lab-01 https://"$IP1":8443 --accept-certificate
|
|
|
|
# --- Prepare and join node 2 ---
|
|
incus config unset incus-lab-02: storage.backups_volume
|
|
incus config unset incus-lab-02: storage.images_volume
|
|
incus storage volume delete incus-lab-02:local backups
|
|
incus storage volume delete incus-lab-02:local images
|
|
incus profile device remove incus-lab-02:default root
|
|
incus profile device remove incus-lab-02:default eth0
|
|
incus storage delete incus-lab-02:local
|
|
incus network delete incus-lab-02:incusbr0
|
|
|
|
incus cluster add incus-lab-01:incus-lab-02
|
|
printf '\n\nyes\nlocal/incus\nlocal/incus\n' | incus cluster join incus-lab-01: incus-lab-02:
|
|
|
|
incus remote remove incus-lab-02
|
|
incus remote add incus-lab-02 https://"$IP2":8443 --accept-certificate
|
|
|
|
# --- Prepare and join node 3 ---
|
|
incus config unset incus-lab-03: storage.backups_volume
|
|
incus config unset incus-lab-03: storage.images_volume
|
|
incus storage volume delete incus-lab-03:local backups
|
|
incus storage volume delete incus-lab-03:local images
|
|
incus profile device remove incus-lab-03:default root
|
|
incus profile device remove incus-lab-03:default eth0
|
|
incus storage delete incus-lab-03:local
|
|
incus network delete incus-lab-03:incusbr0
|
|
|
|
incus cluster add incus-lab-01:incus-lab-03
|
|
printf '\n\nyes\nlocal/incus\nlocal/incus\n' | incus cluster join incus-lab-01: incus-lab-03:
|
|
|
|
incus remote remove incus-lab-03
|
|
incus remote add incus-lab-03 https://"$IP3":8443 --accept-certificate
|
|
|
|
# --- Verify ---
|
|
incus cluster list incus-lab-01:
|
|
```
|
|
|
|
---
|
|
|
|
## Command Syntax Reference
|
|
|
|
| Command | Arguments | Notes |
|
|
|---------|-----------|-------|
|
|
| `incus cluster enable` | `remote: member-name` | TWO args: remote: + name |
|
|
| `incus cluster add` | `remote:member-name` | ONE arg, no space after colon |
|
|
| `incus cluster join` | `init-remote: joining-remote:` | TWO args, space between |
|
|
| `incus cluster list` | `remote:` | Trailing colon = target server |
|
|
| `incus config set` | `remote: key value` | Trailing colon + space + key |
|
|
| `incus storage show` | `remote:pool` | ONE arg, no space after colon |
|
|
| `incus storage show` | `remote:pool --target member` | Member-specific config |
|
|
| `incus profile device remove` | `remote:profile device` | ONE arg + device name |
|
|
| `incus cluster remove` | `remote:member-name --force` | ONE arg; prompts "yes/no" even with --force |
|
|
| `incus cluster evacuate` | `remote:member-name` | ONE arg, no space after colon |
|
|
| `incus cluster restore` | `remote:member-name` | ONE arg, no space after colon |
|
|
|
|
**General rule**: `remote:resource` targets a resource on the remote.
|
|
`remote:` (trailing colon, no resource) targets the server itself.
|
|
|
|
---
|
|
|
|
## Troubleshooting
|
|
|
|
| Symptom | Cause | Fix |
|
|
|---------|-------|-----|
|
|
| `certificate is valid for 127.0.0.1, ::1, not <IP>` | Cluster cert regenerated | Remove and re-add remote with `--accept-certificate` |
|
|
| `Config key "zfs.pool_name" is cluster member specific` | Pool already exists on joining node | Delete pool/network before joining (step 3) |
|
|
| `Config key "source" is cluster member specific` | Same as above | Delete pool/network before joining (step 3) |
|
|
| `The storage pool is currently in use` | Default profile still references pool | Remove `root` and `eth0` from default profile first |
|
|
| `Invalid number of arguments` on `cluster add` | Space between remote and member name | Use `remote:member` not `remote: member` |
|
|
| Join hangs / EOF errors | Interactive prompts in non-interactive shell | Use `printf` pipe method |
|
|
| Token expired | Tokens are time-limited | Generate a fresh token immediately before joining |
|
|
| Node shows in cluster with wrong URL | `core.https_address` was wildcard | Set specific IP before enabling clustering |
|
|
|
|
---
|
|
|
|
## What Happens Under the Hood
|
|
|
|
### Certificate lifecycle
|
|
|
|
1. **Pre-clustering**: each node has its own self-signed server certificate,
|
|
generated at install time. Client remotes pin this cert's fingerprint.
|
|
2. **Cluster enable**: the init node generates a new **cluster certificate**
|
|
(shared across all members). The old server cert is replaced. Client
|
|
remotes must re-pin.
|
|
3. **Node join**: the joining node receives the cluster certificate from the
|
|
init node. Its old server cert is replaced. Client remotes must re-pin.
|
|
|
|
### Storage pool lifecycle during join
|
|
|
|
1. **Pre-join**: each node has its own standalone `local` ZFS pool with
|
|
source `local/incus` (a ZFS dataset on the IncusOS system disk).
|
|
2. **Delete before join**: we remove the Incus metadata (pool definition,
|
|
volumes, profile references). The underlying ZFS dataset remains on disk.
|
|
3. **During join**: the cluster tells the new member "you need a `local` pool".
|
|
The join wizard asks for the source (`local/incus`) and creates a
|
|
**member-specific** pool entry pointing to the existing ZFS dataset.
|
|
4. **Post-join**: the pool shows as `Created` on all members with matching
|
|
names but potentially different underlying sources (member-specific config).
|
|
|
|
### Why we delete the network too
|
|
|
|
The `incusbr0` network bridge follows the same pattern as the storage pool.
|
|
If it already exists on the joining node, the join process may conflict.
|
|
Deleting it lets the cluster recreate it with the correct member-specific
|
|
configuration.
|
|
|
|
---
|
|
|
|
## Cluster Workload Testing
|
|
|
|
After forming the cluster, validate it by launching workloads, verifying
|
|
cluster-wide visibility, and testing migration.
|
|
|
|
### Launch a container on a specific node
|
|
|
|
```bash
|
|
# Launch Debian 12 container on node 2
|
|
incus launch images:debian/12 incus-lab-01:test-container --target incus-lab-02
|
|
|
|
# Verify it's running and check placement
|
|
incus list incus-lab-01: --columns ntsLl4
|
|
|
|
# Exec into it
|
|
incus exec incus-lab-01:test-container -- cat /etc/os-release | head -2
|
|
```
|
|
|
|
### Verify cluster-wide visibility
|
|
|
|
All cluster members should see the same instance list, regardless of which
|
|
remote you query:
|
|
|
|
```bash
|
|
incus list incus-lab-01: --columns nLs
|
|
incus list incus-lab-02: --columns nLs
|
|
incus list incus-lab-03: --columns nLs
|
|
```
|
|
|
|
### Container migration (stop/move/start)
|
|
|
|
Containers do **not** support live migration (CRIU is unreliable). Use
|
|
stop/move/start:
|
|
|
|
```bash
|
|
# Check current location
|
|
incus list incus-lab-01: --columns nLs
|
|
|
|
# Migrate: stop, move to new node, start
|
|
incus stop incus-lab-01:test-container
|
|
incus move incus-lab-01:test-container --target incus-lab-01
|
|
incus start incus-lab-01:test-container
|
|
|
|
# Verify new location
|
|
incus list incus-lab-01: --columns nLs
|
|
```
|
|
|
|
**What survives migration:**
|
|
- All persistent filesystem data (root disk contents)
|
|
- Instance configuration
|
|
|
|
**What does NOT survive:**
|
|
- Running processes (stopped with the container)
|
|
- tmpfs contents (`/tmp`, `/run`)
|
|
- In-memory state
|
|
|
|
### Launch a VM on a specific node
|
|
|
|
```bash
|
|
# Launch Debian 12 VM on node 2
|
|
incus launch images:debian/12 incus-lab-01:test-vm --vm --target incus-lab-02
|
|
|
|
# VMs take ~10-15s to boot — wait for agent
|
|
incus exec incus-lab-01:test-vm -- hostname # retry until it works
|
|
|
|
# Verify
|
|
incus list incus-lab-01: --columns ntsLl4
|
|
incus exec incus-lab-01:test-vm -- cat /etc/os-release | head -2
|
|
```
|
|
|
|
### VM migration
|
|
|
|
#### Live migration
|
|
|
|
Live migration preserves running state (no downtime). It works across
|
|
heterogeneous hosts (different core counts) as long as `limits.cpu` is
|
|
set correctly — see "VM Live Migration: The `limits.cpu` Fix" below.
|
|
|
|
```bash
|
|
# 1. Enable stateful migration (must be set while VM is stopped)
|
|
incus stop incus-lab-01:test-vm
|
|
incus config set incus-lab-01:test-vm migration.stateful=true
|
|
|
|
# 2. Set limits.cpu as a RANGE (critical for migration!)
|
|
incus config set incus-lab-01:test-vm limits.cpu=0-1
|
|
|
|
# 3. Add size.state to root disk (required for stateful operations)
|
|
incus config device add incus-lab-01:test-vm root disk path=/ pool=local size.state=2GiB
|
|
|
|
# 4. Start the VM
|
|
incus start incus-lab-01:test-vm
|
|
|
|
# 5. Live-migrate (no stop needed -- running state is preserved!)
|
|
incus move incus-lab-01:test-vm --target incus-lab-03
|
|
```
|
|
|
|
**What survives live migration** (unlike stop/move/start):
|
|
- All persistent filesystem data
|
|
- Running processes (PID state preserved)
|
|
- tmpfs contents (`/tmp`, `/run`)
|
|
- Network connections
|
|
- In-memory state (full RAM checkpoint)
|
|
|
|
#### Stop/move/start migration (always works)
|
|
|
|
```bash
|
|
incus stop incus-lab-01:test-vm
|
|
incus move incus-lab-01:test-vm --target incus-lab-01
|
|
incus start incus-lab-01:test-vm
|
|
```
|
|
|
|
Same persistence rules as containers: filesystem data survives, running
|
|
processes and tmpfs do not.
|
|
|
|
### Cluster evacuation and restore
|
|
|
|
Evacuation drains all workloads from a node (e.g., for maintenance). Incus
|
|
automatically selects the right strategy per workload type:
|
|
|
|
```bash
|
|
# Evacuate a node (--force skips confirmation prompt)
|
|
incus cluster evacuate incus-lab-01:incus-lab-02 --force
|
|
|
|
# Check: node shows EVACUATED, instances moved to other nodes
|
|
incus cluster list incus-lab-01:
|
|
incus list incus-lab-01: --columns nLs
|
|
|
|
# Restore: moves workloads back to original node
|
|
incus cluster restore incus-lab-01:incus-lab-02 --force
|
|
|
|
# Verify: node back ONLINE, instances restored
|
|
incus cluster list incus-lab-01:
|
|
incus list incus-lab-01: --columns nLs
|
|
```
|
|
|
|
**Syntax note**: `incus cluster evacuate remote:member` is ONE argument (like
|
|
`cluster enable` and `cluster add`).
|
|
|
|
**What happens during evacuation:**
|
|
- VMs with `migration.stateful=true`: live migration (state preserved)
|
|
- VMs without stateful: stop/move/start
|
|
- Containers: stop/move/start
|
|
- Use `--action stop` to force stop/move for all workloads (useful on
|
|
heterogeneous clusters without `limits.cpu` range fix)
|
|
|
|
### Cleanup test instances
|
|
|
|
```bash
|
|
incus delete incus-lab-01:test-container --force
|
|
incus delete incus-lab-01:test-vm --force
|
|
incus list incus-lab-01: # should be empty
|
|
```
|
|
|
|
---
|
|
|
|
## VM Live Migration: The `limits.cpu` Fix
|
|
|
|
### The problem
|
|
|
|
By default, live migration fails between cluster nodes with different host
|
|
CPU counts:
|
|
|
|
```
|
|
qemu-system-x86_64: Missing section footer for 0000:00:1f.0/ICH9LPC
|
|
qemu-system-x86_64: load of migration failed: Invalid argument
|
|
```
|
|
|
|
Or, with `limits.cpu` set as an integer:
|
|
|
|
```
|
|
qemu-system-x86_64: Unknown section or instance 'apic' 3
|
|
```
|
|
|
|
### Root cause: Incus sets QEMU `maxcpus` from host CPU count
|
|
|
|
In the Incus source (`driver_qemu_templates.go`), the `qemuCPU` function
|
|
generates the QEMU `-smp` configuration. When `limits.cpu` is unset or set
|
|
to a plain integer, it does:
|
|
|
|
```go
|
|
maxCpus := 64
|
|
if int(cpu.Total) < maxCpus {
|
|
maxCpus = int(cpu.Total) // <-- host CPU count
|
|
}
|
|
```
|
|
|
|
This means:
|
|
- On a 4-core host: QEMU gets `maxcpus=4` → `cpu possible: 0-3`
|
|
- On a 2-core host: QEMU gets `maxcpus=2` → `cpu possible: 0-1`
|
|
|
|
The ICH9 ACPI CPU hotplug state (`hw/acpi/cpu.c`) is a variable-length
|
|
array sized by `maxcpus`. When source and destination have different
|
|
`maxcpus`, the vmstate sizes don't match and QEMU can't deserialize the
|
|
migration stream.
|
|
|
|
### How we discovered this
|
|
|
|
We observed the VM's `cpu possible` differing by host:
|
|
|
|
```bash
|
|
# On 4-core host:
|
|
cat /sys/devices/system/cpu/possible → 0-3
|
|
|
|
# On 2-core host:
|
|
cat /sys/devices/system/cpu/possible → 0-1
|
|
```
|
|
|
|
Even though the VM only uses 1 vCPU, QEMU allocates hotplug slots for all
|
|
"possible" CPUs.
|
|
|
|
### The fix: use `limits.cpu` as a range
|
|
|
|
When `limits.cpu` is set to a **range** (e.g., `0-1`), Incus takes the
|
|
**CPU pinning** code path, which generates a fixed topology with no
|
|
`maxcpus`:
|
|
|
|
```ini
|
|
# Integer (broken): maxcpus varies by host
|
|
[smp-opts]
|
|
cpus = 2
|
|
maxcpus = 4 # on 4-core host
|
|
|
|
# Range (fixed): no maxcpus, deterministic topology
|
|
[smp-opts]
|
|
cpus = 2
|
|
sockets = 1
|
|
cores = 2
|
|
threads = 1
|
|
```
|
|
|
|
**The one-line fix:**
|
|
|
|
```bash
|
|
# Set limits.cpu as a range (while VM is stopped)
|
|
incus config set <instance> limits.cpu=0-1 # for 2 vCPUs
|
|
incus config set <instance> limits.cpu=0-3 # for 4 vCPUs
|
|
```
|
|
|
|
### Configuration summary
|
|
|
|
| `limits.cpu` value | QEMU `-smp` behavior | Migration across heterogeneous hosts |
|
|
|---|---|---|
|
|
| (not set) | `maxcpus = host_cores` | **Fails** |
|
|
| `2` (integer) | `maxcpus = host_cores` | **Fails** |
|
|
| `0-1` (range) | Fixed `sockets/cores/threads`, no `maxcpus` | **Works** |
|
|
|
|
### Alternative: `raw.qemu.conf` override
|
|
|
|
If you need `maxcpus` (e.g., for CPU hotplug), force it to a fixed value:
|
|
|
|
```bash
|
|
incus config set <instance> raw.qemu.conf='[smp-opts]
|
|
maxcpus = 2'
|
|
```
|
|
|
|
Do **not** combine this with the range syntax — they are mutually exclusive.
|
|
|
|
### Complete setup for a live-migratable VM
|
|
|
|
```bash
|
|
# Create and configure (all while stopped)
|
|
incus launch images:debian/12 incus-lab-01:test-vm --vm --target incus-lab-02
|
|
incus stop incus-lab-01:test-vm
|
|
|
|
incus config set incus-lab-01:test-vm migration.stateful=true
|
|
incus config set incus-lab-01:test-vm limits.cpu=0-1
|
|
incus config device add incus-lab-01:test-vm root disk \
|
|
path=/ pool=local size.state=2GiB
|
|
|
|
incus start incus-lab-01:test-vm
|
|
|
|
# Now live migration works across any cluster node
|
|
incus move incus-lab-01:test-vm --target incus-lab-03
|
|
```
|
|
|
|
---
|
|
|
|
## Migration Test Results
|
|
|
|
Tested with a 3-node cluster on Proxmox VE (Intel i9-13900HK, nested VT-x):
|
|
- **incus-lab-01**: 4 vCPUs, 8192 MB
|
|
- **incus-lab-02**: 2 vCPUs, 4096 MB
|
|
- **incus-lab-03**: 2 vCPUs, 4096 MB
|
|
|
|
Test VM: Debian 12, `limits.cpu=0-1`, `migration.stateful=true`, `size.state=2GiB`.
|
|
Heartbeat process (1/s counter to tmpfs) verifies full state preservation.
|
|
|
|
| # | Test | Time | Result |
|
|
|---|------|------|--------|
|
|
| 1 | Live: node1→node2 (4-core host → 2-core host) | 7.4s | PASS |
|
|
| 2 | Live: node2→node3 (2-core host → 2-core host) | 7.2s | PASS |
|
|
| 3 | Live: node3→node1 (2-core host → 4-core host) | 7.5s | PASS |
|
|
| 4 | Rapid round-trip: 1→2→3→1 (back-to-back) | ~22s | PASS |
|
|
| 5 | Stateful stop/restore (same node) | 7.6s | PASS |
|
|
| 6 | Stateful stop/move/restore (node1→node2, cross-topology) | 10.0s | PASS |
|
|
| 7a | Cluster evacuate node2 (container + VM) | 9.0s | PASS |
|
|
| 7b | Cluster restore node2 (moves workloads back) | 9.7s | PASS |
|
|
|
|
**Key observations:**
|
|
- Transfer speed: ~140 MB/s for ~1 GB VM
|
|
- Heartbeat counter continuous through all migrations (no lost seconds)
|
|
- tmpfs, systemd services, and VM uptime all preserved through live migration
|
|
- VM agent (`incus exec`) needs ~3-4 seconds to reconnect after migration
|
|
- Cluster evacuation auto-selects strategy: live migration for VMs, stop/move/start for containers
|
|
- Cluster restore returns workloads to original node
|
|
|
|
### Automated migration test
|
|
|
|
Run this from your workstation to test all migration paths:
|
|
|
|
```bash
|
|
REMOTE=incus-lab-01 # any cluster member
|
|
VM=test-vm
|
|
|
|
# Setup: create heartbeat inside VM
|
|
incus exec "$REMOTE:$VM" -- mkdir -p /tmp/migration-test
|
|
incus exec "$REMOTE:$VM" -- bash -c \
|
|
'echo "created-$(hostname)-$(date +%s)" > /tmp/migration-test/marker'
|
|
incus exec "$REMOTE:$VM" -- bash -c \
|
|
'cat > /tmp/migration-test/heartbeat.sh << "EOF"
|
|
#!/bin/bash
|
|
i=0; while true; do echo $i > /tmp/migration-test/heartbeat; i=$((i+1)); sleep 1; done
|
|
EOF
|
|
chmod +x /tmp/migration-test/heartbeat.sh'
|
|
incus exec "$REMOTE:$VM" -- \
|
|
systemd-run --unit=migration-heartbeat /tmp/migration-test/heartbeat.sh
|
|
|
|
sleep 3
|
|
|
|
# Test: live-migrate through all nodes
|
|
PASS=0; FAIL=0
|
|
for TARGET in incus-lab-02 incus-lab-03 incus-lab-01; do
|
|
PRE=$(incus exec "$REMOTE:$VM" -- cat /tmp/migration-test/heartbeat)
|
|
LOC=$(incus list "$REMOTE:" --format csv -c nL | grep "$VM" | cut -d, -f2)
|
|
echo -n "[$LOC → $TARGET] heartbeat=$PRE ... "
|
|
|
|
incus move "$REMOTE:$VM" --target "$TARGET" 2>&1 | tail -1
|
|
sleep 2 # wait for agent reconnect
|
|
|
|
POST=$(incus exec "$REMOTE:$VM" -- cat /tmp/migration-test/heartbeat 2>&1)
|
|
SVC=$(incus exec "$REMOTE:$VM" -- systemctl is-active migration-heartbeat 2>&1)
|
|
if [[ "$SVC" == "active" && "$POST" -gt "$PRE" ]]; then
|
|
echo "PASS (heartbeat=$POST, service=$SVC)"
|
|
PASS=$((PASS + 1))
|
|
else
|
|
echo "FAIL (heartbeat=$POST, service=$SVC)"
|
|
FAIL=$((FAIL + 1))
|
|
fi
|
|
done
|
|
|
|
# Cleanup heartbeat
|
|
incus exec "$REMOTE:$VM" -- systemctl stop migration-heartbeat 2>/dev/null
|
|
|
|
echo ""
|
|
echo "Results: $PASS passed, $FAIL failed"
|
|
```
|
|
|
|
---
|
|
|
|
### The vnmi warning is cosmetic
|
|
|
|
The `CPUID[eax=8000000Ah].EDX.vnmi` warning appears because QEMU's CPU
|
|
feature dependency checker fires before KVM filters out unsupported
|
|
features. It does not cause migration failures and can be safely ignored.
|
|
|
|
### Nested virtualization works
|
|
|
|
Live migration works fine in nested virtualization (IncusOS inside Proxmox
|
|
on Intel VT-x). Tested with QEMU 10.2.1 on Intel i9-13900HK with
|
|
heterogeneous host core counts (4 vs 2). The `limits.cpu` range fix makes
|
|
host topology irrelevant.
|
|
|
|
### Debugging tips
|
|
|
|
- **`incus start --stateless`** discards a saved state file if a restore
|
|
fails, allowing a clean boot.
|
|
- **Check `cpu possible`** inside the VM: `cat /sys/devices/system/cpu/possible`.
|
|
If this differs across nodes for the same VM config, migration will fail.
|
|
- **`incus monitor --type=logging`** on the destination shows QEMU errors.
|
|
|
|
### References
|
|
|
|
- [Incus source: driver_qemu_templates.go](https://github.com/lxc/incus/blob/main/internal/server/instance/drivers/driver_qemu_templates.go) — `maxcpus` calculation
|
|
- [QEMU source: hw/acpi/cpu.c](https://github.com/qemu/qemu/blob/master/hw/acpi/cpu.c) — CPU hotplug state sizing
|
|
- [QEMU source: hw/isa/lpc_ich9.c](https://github.com/qemu/qemu/blob/master/hw/isa/lpc_ich9.c) — ICH9 vmstate subsections
|
|
- [Incus Instance Options](https://linuxcontainers.org/incus/docs/main/reference/instance_options/) — `limits.cpu`, `migration.stateful`
|
|
- [Incus issue #486](https://github.com/lxc/incus/issues/486) — cluster-wide CPU baseline
|
|
|
|
---
|
|
|
|
## Multi-vCPU Migration Test Results
|
|
|
|
Tested with a 3-node cluster (6/4/4 cores, 8 GiB RAM each) to validate
|
|
migration across different vCPU counts and heterogeneous host topologies.
|
|
|
|
### Test environment
|
|
|
|
- **incus-adv-01**: 6 vCPUs, 8192 MB
|
|
- **incus-adv-02**: 4 vCPUs, 8192 MB
|
|
- **incus-adv-03/04**: 4 vCPUs, 8192 MB
|
|
|
|
Three test VMs, all Debian 12, `migration.stateful=true`, `size.state=4GiB`:
|
|
|
|
| VM | `limits.cpu` | vCPUs | `cpu possible` |
|
|
|----|-------------|-------|----------------|
|
|
| test-vm-2cpu | `0-1` | 2 | `0-1` |
|
|
| test-vm-3cpu | `0-2` | 3 | `0-2` |
|
|
| test-vm-4cpu | `0-3` | 4 | `0-3` |
|
|
|
|
### Results
|
|
|
|
| # | Test | Time | Result |
|
|
|---|------|------|--------|
|
|
| 1 | 2-vCPU full ring (3 migrations) | ~7s each | PASS |
|
|
| 2 | 3-vCPU full ring (3 migrations) | ~7.5s each | PASS |
|
|
| 3 | 4-vCPU full ring (3 migrations) | ~7.3s each | PASS |
|
|
| 4 | Concurrent: 2 VMs from different nodes | 8s total | PASS |
|
|
| 5 | 4-vCPU VM to 4-core host (100% core usage) | 7.3s | PASS |
|
|
| 6 | Rapid round-trip: 3 hops back-to-back | ~31s total | PASS |
|
|
| 7 | Active disk I/O (dd 100 MiB) during migration | 8.2s | PASS |
|
|
| 8 | Active network (ping) during migration | 8.5s | PASS |
|
|
| 9 | Stateful stop/move/start (cross-node) | 10s | PASS |
|
|
| 10 | Memory-loaded VM (512 MiB allocated) | 7.7s | PASS |
|
|
| 11 | Move to same node (no-op) | instant | Clean error |
|
|
| 12 | Mixed evacuation (2 containers + 2 VMs) | 18s | PASS |
|
|
| 13 | Restore after evacuation | 18s | PASS |
|
|
|
|
### Key findings
|
|
|
|
- **Odd vCPU counts work**: `limits.cpu=0-2` (3 vCPUs) migrates just as
|
|
reliably as even counts. The range syntax always produces deterministic
|
|
QEMU topology regardless of count.
|
|
- **4-vCPU on 4-core host works**: a VM using all host cores migrates
|
|
successfully and remains responsive. No performance degradation observed.
|
|
- **Concurrent migrations**: migrating 2 VMs simultaneously from different
|
|
source nodes completes without interference. Transfer speed is maintained
|
|
at ~140 MB/s per migration.
|
|
- **Active I/O survives**: both disk writes (dd) and network activity (ping)
|
|
continue uninterrupted through live migration. File integrity verified.
|
|
- **Memory-loaded migration**: 512 MiB of allocated memory does not
|
|
significantly increase migration time (QEMU's iterative pre-copy handles
|
|
dirty pages efficiently).
|
|
- **Agent reconnect time is ~3-4 seconds**, not 1-2s as initially measured.
|
|
May vary with cluster size and vCPU count. Scripts should `sleep 4` after
|
|
migration before running `incus exec`.
|
|
- **Same-node move**: Incus returns a clear error ("Requested target server
|
|
is the same as current server") — no crash or corruption.
|
|
- **`size.state=4GiB`** recommended for VMs with 3-4 vCPUs to ensure
|
|
sufficient room for state dumps.
|
|
|
|
---
|
|
|
|
## Cluster Lifecycle: Node Replacement
|
|
|
|
Full procedure for evacuating, removing, destroying, and replacing a cluster
|
|
node. Tested on the 3-node advanced cluster.
|
|
|
|
### Overview
|
|
|
|
1. Evacuate the target node (drains workloads)
|
|
2. Remove the node from the cluster
|
|
3. Destroy the VM in Proxmox
|
|
4. Deploy a fresh replacement node
|
|
5. Join the replacement to the cluster
|
|
6. Verify workload rebalancing
|
|
|
|
### Step 1: Evacuate
|
|
|
|
```bash
|
|
REMOTE=incus-adv-01 # init node (not the target)
|
|
incus cluster evacuate $REMOTE:incus-adv-03 --force
|
|
```
|
|
|
|
This live-migrates VMs (with `migration.stateful=true`) and stop/move/starts
|
|
containers. Verify with `incus cluster list` — target shows `EVACUATED`.
|
|
|
|
### Step 2: Remove from cluster
|
|
|
|
```bash
|
|
printf "yes\n" | incus cluster remove $REMOTE:incus-adv-03 --force
|
|
```
|
|
|
|
**Note**: `--force` requires interactive confirmation ("yes"). Pipe it in for
|
|
automation. The `--force` flag is needed because the node is still technically
|
|
reachable (just evacuated).
|
|
|
|
After removal, `incus cluster list` shows only the remaining members.
|
|
|
|
### Step 3: Destroy the VM
|
|
|
|
Use `incusos-proxmox --cleanup` with a config targeting just that VM, or
|
|
use the Proxmox API directly. Do NOT try raw `curl` with API tokens
|
|
containing `!` characters — bash history expansion will silently mangle the
|
|
token. Always use single quotes or the `incusos-proxmox` script.
|
|
|
|
```bash
|
|
# Method 1: incusos-proxmox (recommended)
|
|
incusos-proxmox --cleanup --yes <config-with-target-vm.yaml>
|
|
|
|
# Method 2: Proxmox API (note: single quotes around the token!)
|
|
TOKEN='automation@pve!deploy=<secret>'
|
|
curl -s -k -X POST -H "Authorization: PVEAPIToken=$TOKEN" \
|
|
"https://<host>:8006/api2/json/nodes/<node>/qemu/<vmid>/status/stop"
|
|
```
|
|
|
|
Also remove the incus remote:
|
|
|
|
```bash
|
|
incus remote switch local # if target is current default
|
|
incus remote remove incus-adv-03
|
|
```
|
|
|
|
### Step 4: Deploy replacement
|
|
|
|
Create a config for the replacement node (e.g., `lab-replace.yaml`):
|
|
|
|
```yaml
|
|
defaults:
|
|
memory: 8192
|
|
disk: 50
|
|
start_vmid: 913
|
|
|
|
vms:
|
|
- name: incus-adv-04
|
|
app: incus
|
|
apply_defaults: true
|
|
cores: 4
|
|
```
|
|
|
|
```bash
|
|
incusos-proxmox --yes lab-replace.yaml
|
|
```
|
|
|
|
### Step 5: Join to cluster
|
|
|
|
Same procedure as initial cluster formation:
|
|
|
|
```bash
|
|
NEW_IP=<replacement-node-ip>
|
|
|
|
# Set specific address
|
|
incus config set incus-adv-04: core.https_address $NEW_IP:8443
|
|
|
|
# Prepare (delete pre-existing pool/network)
|
|
incus config unset incus-adv-04: storage.backups_volume
|
|
incus config unset incus-adv-04: storage.images_volume
|
|
incus storage volume delete incus-adv-04:local backups
|
|
incus storage volume delete incus-adv-04:local images
|
|
incus profile device remove incus-adv-04:default root
|
|
incus profile device remove incus-adv-04:default eth0
|
|
incus storage delete incus-adv-04:local
|
|
incus network delete incus-adv-04:incusbr0
|
|
|
|
# Generate token and join
|
|
incus cluster add $REMOTE:incus-adv-04
|
|
printf '\n\nyes\nlocal/incus\nlocal/incus\n' | incus cluster join $REMOTE: incus-adv-04:
|
|
|
|
# Fix remote (new cluster cert)
|
|
incus remote remove incus-adv-04
|
|
incus remote add incus-adv-04 https://$NEW_IP:8443 --accept-certificate
|
|
```
|
|
|
|
### Step 6: Verify rebalancing
|
|
|
|
With `cluster.rebalance.interval` configured, Incus automatically migrates
|
|
VMs to the new (empty) node:
|
|
|
|
```bash
|
|
# Check after 1-2 minutes
|
|
incus list $REMOTE: --columns ntsL
|
|
```
|
|
|
|
In testing, 2 VMs were automatically live-migrated to the replacement node
|
|
within 90 seconds. Containers are NOT auto-rebalanced (Incus limitation) —
|
|
move them manually with stop/move/start.
|
|
|
|
### Rebalancing configuration
|
|
|
|
```bash
|
|
REMOTE=incus-adv-01
|
|
incus config set $REMOTE: cluster.rebalance.interval=1 # check every N minutes
|
|
incus config set $REMOTE: cluster.rebalance.threshold=10 # imbalance % to trigger
|
|
incus config set $REMOTE: cluster.rebalance.batch=2 # max VMs per rebalance run
|
|
incus config set $REMOTE: cluster.rebalance.cooldown=5m # wait between runs
|
|
```
|
|
|
|
| Key | Description | Tested value |
|
|
|-----|-------------|-------------|
|
|
| `cluster.rebalance.interval` | Check interval in minutes | `1` (aggressive for testing) |
|
|
| `cluster.rebalance.threshold` | Imbalance percentage to trigger | `10` |
|
|
| `cluster.rebalance.batch` | Max VMs moved per run | `2` |
|
|
| `cluster.rebalance.cooldown` | Minimum time between runs | `5m` |
|
|
|
|
**Important**: rebalancing only moves VMs with `migration.stateful=true`.
|
|
Containers and non-migratable VMs are not touched.
|
|
|
|
### Command syntax: `cluster remove`
|
|
|
|
```bash
|
|
# Works (pipe the confirmation)
|
|
printf "yes\n" | incus cluster remove remote:member --force
|
|
|
|
# Also note: cluster enable syntax is TWO args, not ONE
|
|
incus cluster enable remote: member-name # correct
|
|
incus cluster enable remote:member-name # WRONG (single arg)
|
|
```
|