--- title: "Chef's special: Proxmox, SDS (linstor) & Cluster" date: 2025-11-14 slug: proxmox-linstor-sds-cluster status: tags: - proxmox - linstor - sds - network - cluster - homelab - blog - linux - ceph - zfs - high-availability - storage description: "Cooking up a 3-node Proxmox cluster with shared storage: I weighed Ceph, ZFS and iSCSI, picked LINSTOR, and ran it over a 10G ring. Benchmarks included." category: homelab ---

Cooking lessons 101: Proxmox, LINSTOR, and 10G cards

Homelab-ing should be fun, right? Right.

Until you decided to go down the path of what I call : the not-so-home-but-more-like-prod-lab-ing

Well, I recently had the joy (read: chaos) of upgrading from a single Proxmox node to a beautiful, powerful and shiny 3-node cluster. Sounds great on paper until it's 3 AM and i'm wondering why the third node won't do the exact same thing as the first two.

So today, let’s unpack the mess together. We’ll go over:

Current node, peony :

Before migrating, i had a single proxmox node that we'll call pve01 or peony, both works.

This beauty has been running on :

I chose this build for a few reasons : I needed 2 nvme Gen4 ports to be able to run a zfs-pool with the 980 pro at full speed. And I also wanted to have an iGPU because I had no needs for an entire GPU and it's power consumption. I also chose an old Mellanox X-3 for an LACP bond to fully use my USW-Aggregation.

The two newborn nodes, petunia and dahlia :

Those two nodes, pve02 and pve03 aka petunia and dahlia have the same hardware :

And I added to all three nodes a dual 1G NIC for a ring-network dedicated to the proxmox cluster & the corosync service.

As for what a ring-network is, it's a basic network topology where each nodes is connected to two other nodes, without using a switch.

So it should look like this :

pve01 :

pve01.nic1 <=> pve02.nic1
pve01.nic2 <=> pve03.nic1

pve02 :

pve02.nic1 <=> pve01.nic1
pve02.nic2 <=> pve03.nic2

pve03 :

pve03.nic1 <=> pve01.nic1
pve03.nic2 <=> pve02.nic2

The SDS rabbit-hole :

As this homelab is changing, updating and upgrading into more like a production-ready setup, I decided to go down the HA (High Availability) path since I had 3 nodes now.

For simple HA in a Hypervisor-OS cluster (let it be Proxmox, HyperV, ESXI, OpenNebula, etc) you'll need one thing :

For a more efficient HA in a Hypervisor-OS cluster (let it be Proxmox, HyperV, ESXI, OpenNebula, etc) you'll need two things :

In PVE (Proxmox Virtual Environnement), there's a few ways to achieve this (there may be more, I only know and used those):

ZFS Replication :

If you are planning to use ZFS for your storage pool, ZFS replication may be a solution. It'll replicate the snapshot of the disk every x period of time (could be every minute, every 15 minutes; whatsoever). So if HA (or yourself) decided to migrate the disk, you'll only lose the data that hasn't been synced by the ZFS replication. This works like a charm, without a lot of configuration, but it wasn't what I wanted. I wanted something more... sophisticated, I'm an homelabber after all, I have no need but greed.

     [pve01]                          [pve02]
   ┌──────────┐                   ┌─────────────┐
   │  VM Disk │── Snapshot --->   │  VM Disk    │
   │  Pool A  │                   │  Pool B     │
   └──────────┘                   └─────────────┘
         ↑                               ↑
       Writes                           Replicated every X min

CephFS :

CephFS is actually full integrated within the PVE ecosystem and could have been a solution. It uses OSD and CephMON (Monitor) that are lightweight nodes that keep track of the cluster's health, metadata and where the OSD are so that it can recover automatically if a node fails. The hardware requirements are quite heavy :

This could have been a way for me, easy to setup. But the hardware requirements were too heavy for me and since I already had a 10G switch, I feared it would be very slow and be a nightmare to optimize it.

            +----------------+
            |     MONITOR    |
            |    (Ceph MON)  |
            +----------------+
                    ▲
      ┌─────────────┼─────────────┐
      ▼             ▼             ▼
 +---------+   +---------+   +---------+
 |  OSD 1  |   |  OSD 2  |   |  OSD 3  |
 | NVMe+HDD|   | NVMe+HDD|   | NVMe+HDD|
 +---------+   +---------+   +---------+
      ▲             ▲             ▲
      │             │             │
      └─────────────┴─────────────┘
                    │
            Clients (PVE Nodes)

iSCSI (Internet Small Computer System Iinterface) :

In easy term, let's say I have a NAS with disks. I create an iSCSI share, which will be mounted to each PVE nodes of the cluster. I can now create VM/LXC on this storage, and since it'll be shared across the cluster, I could migrate it easily.

For the PVE nodes, it would look like they each have the same big disk mounted.

       +--------------------+
       |       NAS          |
       |   iSCSI Target     |
       +--------------------+
         │       │       │
         │       │       │
   +-----+       |       +-----+
   |             |             |
[pve01]       [pve02]       [pve03]
   │             │             │
   └─────────────┴─────────────┘
            Shared Disk
(appears as the same big local disk)

The chosen one : Linstor :

Linstor is kinda a mix of CephFS and iSCSI. It's an open-source storage management solution for block storage (like iSCSI do). It works off 3 Core Components :

          +------------------+
          |LINSTOR Controller|
          +------------------+
                    │
   ┌────────────────┴─────────────────┐
   ▼                                  ▼
+-------+                         +-------+
| Satel |                         | Satel |
| Disk1 |                         | Node2 |
| Disk1 |       <-- Rep -->       | Disk1 |
+-------+                         +-------+
   │                                  │
[pve01]                            [pve02]
   │                                  │
   └── Volumes shared across nodes. ──┘

Wait, this could work, this could be it. This could be challenging and fun. So I decided to spin up a few VMs, install PVE on it and tried thing. After a few days of trial and error, I finally obtained a virtual cluster with a distributed storage across it, working like a charm !

How I actually set up the cluster :

As I said before, I used a ring network and Distributed Storage for a better HA.

The ring network :

First thing first, how to setup the ring-network :

Connect each nodes to each other. You'll need 3 networks (pve01 to pve02, pve01 to pve03 and pve02 to pve03), I went with three /29, which gives me plenty of room with 6 IP Adresses.

You may be wondering, where the hell is the 10.10.1.1/29 and why did he chose the 10.10.3.1/29 network. Great answer.

Somehow, against all logic, it just wouldn't work.

Trust me. I was there, staring at my computer screen until 3 AM, wondering why the hell 10.10.1.1/29 & 10.10.1.2/29 were not working. Until I simply tried the 10.10.3.1/29 and it worked top notch, so I went with it. Goodbye my 10.10.1.1/29 and 10.10.1.2/29, I truly loved you (f*ck you I hate you).

You'll also need to enable routing on each of your proxmox nodes so that pve01 can talk with pve03 via the pve02 link. Quite a headache isn't it ?

On pve01 :

interlope@pivoine:~$ ip -br -c a | grep 10.10
enp7s0           UP             10.10.0.1/29 fe80::1efd:8ff:fe7a:e2b8/64
enp8s0           UP             10.10.3.1/29 fe80::1efd:8ff:fe7a:e08c/64
interlope@pivoine:~$ cat /etc/network/interfaces | grep route
        post-up ip route add 10.10.2.0/29 via 10.10.0.2 dev enp7s0
root@pivoine:/home/interlope# traceroute 10.10.2.2
traceroute to 10.10.2.2 (10.10.2.2), 30 hops max, 60 byte packets
 1  10.10.0.2 (10.10.0.2)  0.095 ms  0.076 ms  0.057 ms
 2  10.10.2.2 (10.10.2.2)  0.120 ms  0.105 ms  0.119 ms

It can join the pve03 while passing by the pve02 node.

On pve02 :

interlope@petunia:~$ ip -br -c a | grep 10.10
enp8s0           UP             10.10.0.2/29 fe80::1efd:8ff:fe7a:e239/64
enp9s0           UP             10.10.2.1/29 fe80::1efd:8ff:fe7a:e23a/64
interlope@petunia:~$ cat /etc/network/interfaces | grep route
        post-up ip route add 10.10.3.0/29 via 10.10.2.2 dev enp9s0
root@petunia:/home/interlope# traceroute 10.10.3.1
traceroute to 10.10.3.1 (10.10.3.1), 30 hops max, 60 byte packets
 1  10.10.2.2 (10.10.2.2)  0.091 ms  0.063 ms  0.088 ms
 2  10.10.3.1 (10.10.3.1)  0.140 ms  0.130 ms  0.121 ms

It can join the pve01 while passing by the pve03 node.

On pve03 :

interlope@dalia:~$ ip -br -c a | grep 10.10
enp8s0           UP             10.10.3.2/29 fe80::1efd:8ff:fe7a:e497/64
enp9s0           UP             10.10.2.2/29 fe80::2e0:4cff:fe68:f52/64
interlope@dalia:~$ cat /etc/network/interfaces | grep route
        post-up ip route add 10.10.0.0/29 via 10.10.3.1 dev enp8s0
root@dalia:/home/interlope# traceroute 10.10.0.2
traceroute to 10.10.0.2 (10.10.0.2), 30 hops max, 60 byte packets
 1  10.10.3.1 (10.10.3.1)  0.106 ms * *
 2  10.10.0.2 (10.10.0.2)  0.135 ms  0.119 ms  0.152 ms

It can join the pve02 while passing by the pve01 node.

Now I can simply create my cluster homegarden on pve01, add the two NIC links and join it with pve02 and pve03, wonderful.

As for the SDS, that's a whole other ball game.

Linstor :

This tutorial will be written this way : each step will tell you on which node to execute the commands :

wget -O /tmp/linbit-keyring.deb https://packages.linbit.com/public/linbit-keyring.deb
dpkg -i /tmp/linbit-keyring.deb
PVERS=9 && echo "deb [signed-by=/etc/apt/trusted.gpg.d/linbit-keyring.gpg] http://packages.linbit.com/public/ proxmox-$PVERS drbd-9" > /etc/apt/sources.list.d/linbit.list

apt update
apt -y install drbd-dkms drbd-utils drbd-reactor proxmox-default-headers linstor-proxmox linstor-controller linstor-satellite linstor-client linstor-proxmox linstor-gui vim
modprobe drbd
echo "drbd" >> /etc/modules
reboot
systemctl enable --now linstor-controller
cat <<EOF > /etc/linstor/linstor-client.conf
[global]
controllers=ipnode1,ipnode2,ipnode3
EOF
systemctl edit linstor-satellite.service 
[Service]
Type=notify
TimeoutStartSec=infinity
Environment=LS_KEEP_RES=linstor_db
linstor node create hostnamenode01 ipnode1
linstor node create hostnamenode02 ipnode2 
linstor node create hostnamenode03 ipnode3
linstor node set-property hostnamenode01 DrbdOptions/AutoEvictAllowEviction false
linstor node set-property hostnamenode02 DrbdOptions/AutoEvictAllowEviction false
linstor node set-property hostnamenode03 DrbdOptions/AutoEvictAllowEviction false

Note : the DrbdOptions/AutoEvictAllowEviction false is to avoid your nodes to be auto-removed from the linstor cluster during temporary issues and to also avoid split-brain scenarios (data still being written on a node that has lost communication with the cluster), creating data corruption and divergent data.

Which should looks like this :

interlope@pivoine:~$ linstor node list
╭──────────────────────────────────────────────────────────╮
┊ Node    ┊ NodeType  ┊ Addresses                 ┊ State  ┊
╞══════════════════════════════════════════════════════════╡
┊ dalia   ┊ SATELLITE ┊ 10.100.4.103:3366 (PLAIN) ┊ Online ┊
┊ petunia ┊ SATELLITE ┊ 10.100.4.102:3366 (PLAIN) ┊ Online ┊
┊ pivoine ┊ SATELLITE ┊ 10.100.4.101:3366 (PLAIN) ┊ Online ┊
╰──────────────────────────────────────────────────────────╯

I decided to go down the path of a ZFS pool on each node with raid1 (mirroring) between the two nvmes, do what suits yourself and your cluster :

zpool create pool-nvme-01 mirror /dev/nvme0n1 /dev/nvme1n1
linstor storage-pool create zfsthin pve01 sto-pool_zfs-01 pool-nvme-01
linstor storage-pool create zfsthin pve02 sto-pool_zfs-01 pool-nvme-01
linstor storage-pool create zfsthin pve03 sto-pool_zfs-01 pool-nvme-01
linstor resource-group create res-gr_pve --storage-pool sto-pool_zfs-01 --place-count 2 --diskless-on-remaining true
linstor resource-group create --storage-pool sto-pool_zfs-01 --place-count 3 --diskless-on-remaining true linstor-db-grp
linstor resource-group drbd-options --auto-promote=no --quorum=majority --on-suspended-primary-outdated=force-secondary --on-no-quorum=io-error --on-no-data-accessible=io-error linstor-db-grp
linstor resource-group spawn-resources linstor-db-grp linstor_db 200M
systemctl disable --now linstor-controller
systemctl disable linstor-controller

This is needed to achieve HA-controller since normally, the linstor-controller stores all the metadata storage on a local disk. But if another node becomes the linstor-controller instead, it'll need to have those metadata, so we share it via a resource-group we created earlier.

cat > /etc/systemd/system/var-lib-linstor.mount << 'EOF'
[Unit]
Description=Filesystem for the LINSTOR controller

[Mount]
What=/dev/drbd/by-res/linstor_db/0
Where=/var/lib/linstor

[Install]
WantedBy=multi-user.target
EOF

systemctl daemon-reload
mv /var/lib/linstor{,.orig} 
mkdir /var/lib/linstor 
chattr +i /var/lib/linstor 
drbdadm primary linstor_db 
mkfs.ext4 /dev/drbd/by-res/linstor_db/0 
systemctl start var-lib-linstor.mount 
cp -r /var/lib/linstor.orig/* /var/lib/linstor 
systemctl start linstor-controller
systemctl stop linstor-controller
chattr +i /var/lib/linstor

This will allow the drdb-reactor to chose who's the linstor-controller.

cat <<EOF > /etc/drbd-reactor.d/linstor_db.toml
[[promoter]]
[promoter.resources.linstor_db]
start = ["var-lib-linstor.mount", "linstor-controller.service"]
EOF
systemctl restart drbd-reactor
systemctl enable drbd-reactor
systemctl restart linstor-satellite

Check the logs with drbd-reactorctl status linstor_db and systemctl status drbd-reactor, it should look like this :

root@pivoine:/home/interlope# drbd-reactorctl status linstor_db
/etc/drbd-reactor.d/linstor_db.toml:
Promoter: Currently active on node 'petunia'
○ drbd-services@linstor_db.target
○ ├─ drbd-promote@linstor_db.service
○ ├─ var-lib-linstor.mount 
○ └─ linstor-controller.service 

pve02 aka petunia is currently the linstor-controller.

Now, i've tuned this a little bit since i've created a VIP (Virtual IP) via keepalived to access :

But how does this works ? Each node has a keepalived configuration that basically says :

If you have the linstor-controller running, TAKE THAT IP and don't asks questions

We saw earlier that pve02 was the current controller. If we look at his IP adresses, the VIP should be there :

interlope@petunia:~$ ip -br -c a | grep .100
enp1s0f1np1      UP             10.100.4.102/24 10.100.4.100/24 fe80::3efd:feff:fee5:5535/64 
vmbr1.3@vmbr1    UP             10.100.3.102/24 10.100.3.100/24 fe80::3efd:feff:fee5:5534/64

We see that on the linstor NIC and the proxmox NIC, it got a second ip, the .100, which is shared between all three nodes.

This is also needed for our linstor-controller-HA goal.

apt install keepalived -y
cat <<'EOF' > /usr/local/bin/check_linstor.sh
#!/bin/bash
# Checks if LINSTOR controller service is active
if systemctl is-active --quiet linstor-controller.service; then
  exit 0
else
  exit 1
fi
EOF

chmod +x /usr/local/bin/check_linstor.sh
cat <<EOF > /etc/keepalived/keepalived.conf
vrrp_script chk_linstor {
    script "/usr/local/bin/check_linstor.sh"
    interval 2
    timeout 2
    fall 2
    rise 1
}

vrrp_instance VI_1 {
    state BACKUP
    interface vmbr0
    virtual_router_id 51
    priority 150
    advert_int 1
    authentication {
        auth_type PASS
        auth_pass mySecret123
    }
    virtual_ipaddress {
        10.100.4.100/24 dev ens19 # LINSTOR NIC
        10.100.3.100/24 dev vmbr1.3 #PROXMOX PUBLIC NIC
    }
    track_script {
        chk_linstor
    }
}
EOF

systemctl enable keepalived
systemctl start keepalived

On the node who has the linstor-controller service up and running, once you've started the keepalived service, you should see a secondary ip on it. Now that the linstor VIP is configured, that the linstor-controller-HA is working, let's use our storage !


drbd: linstor
    content images,rootdir
    controller VIPADRESS
    resourcegroup res-gr_pve

systemctl restart pve-cluster pvedaemon pvestatd pveproxy pve-ha-lrm

How I use the VIP :

I've DNS entries poiting the 3.100 and 4.100 to my nginx reverse-proxy which then redirect the 3.100 to pve.local.ndd and 4.100 to linstor.local.ndd so that I can access both the cluster and the lisntor-gui on what-ever node it is running on.

Monitoring via prometheus :

If you ever want to do some dashboards via grafana and prometheus, on all nodes:

cat << EOF > /etc/drbd-reactor.d/prometheus.toml
[[prometheus]]
enums = true
address = "0.0.0.0:9942"
EOF

systemctl restart drbd-reactor.service

Benchmarks :

Now that everything is done, joined, clustered and linstored, let's see what we have there : Image description

Each node having a ZFS pool created with default options, it should be on standard.

interlope@pivoine:~$ zfs get sync pool-nvme-01
NAME          PROPERTY  VALUE     SOURCE
pool-nvme-01  sync      standard  local

There's 3 possibles values :

Let's benchmark it on a VM to get some results :

root@guinea-pig:/mnt/fio# fio --name=sync-write --filename=/mnt/fio/testfile --size=1G --bs=4k \
    --rw=write --iodepth=1 --direct=1 --sync=1 --runtime=60 --time_based --group_reporting
sync-write: (g=0): rw=write, bs=(R) 4096B-4096B, (W) 4096B-4096B, (T) 4096B-4096B, ioengine=psync, iodepth=1
fio-3.39
Starting 1 process
sync-write: Laying out IO file (1 file / 1024MiB)
Jobs: 1 (f=1): [W(1)][100.0%][w=604KiB/s][w=151 IOPS][eta 00m:00s]
sync-write: (groupid=0, jobs=1): err= 0: pid=2112: Fri Nov 14 20:20:26 2025
  write: IOPS=137, BW=552KiB/s (565kB/s)(32.3MiB/60005msec); 0 zone resets
    clat (msec): min=5, max=277, avg= 7.24, stdev= 3.55
     lat (msec): min=5, max=277, avg= 7.24, stdev= 3.55
    clat percentiles (msec):
     |  1.00th=[    6],  5.00th=[    6], 10.00th=[    6], 20.00th=[    6],
     | 30.00th=[    7], 40.00th=[    7], 50.00th=[    7], 60.00th=[    7],
     | 70.00th=[    8], 80.00th=[    9], 90.00th=[   10], 95.00th=[   11],
     | 99.00th=[   16], 99.50th=[   18], 99.90th=[   23], 99.95th=[   26],
     | 99.99th=[  279]
   bw (  KiB/s): min=  192, max=  656, per=99.83%, avg=551.92, stdev=95.14, samples=119
   iops        : min=   48, max=  164, avg=137.97, stdev=23.78, samples=119
  lat (msec)   : 10=94.14%, 20=5.69%, 50=0.16%, 500=0.01%
  cpu          : usr=0.08%, sys=0.85%, ctx=16563, majf=0, minf=8
  IO depths    : 1=100.0%, 2=0.0%, 4=0.0%, 8=0.0%, 16=0.0%, 32=0.0%, >=64=0.0%
     submit    : 0=0.0%, 4=100.0%, 8=0.0%, 16=0.0%, 32=0.0%, 64=0.0%, >=64=0.0%
     complete  : 0=0.0%, 4=100.0%, 8=0.0%, 16=0.0%, 32=0.0%, 64=0.0%, >=64=0.0%
     issued rwts: total=0,8280,0,0 short=0,0,0,0 dropped=0,0,0,0
     latency   : target=0, window=0, percentile=100.00%, depth=1

Run status group 0 (all jobs):
  WRITE: bw=552KiB/s (565kB/s), 552KiB/s-552KiB/s (565kB/s-565kB/s), io=32.3MiB (33.9MB), run=60005-60005msec

Disk stats (read/write):
    dm-1: ios=3/50255, sectors=24/430144, merge=0/0, ticks=0/94740, in_queue=94740, util=97.46%, aggrios=3/33377, aggsectors=24/430816, aggrmerge=0/17008, aggrticks=1/63005, aggrin_queue=81082, aggrutil=97.32%
  sda: ios=3/33377, sectors=24/430816, merge=0/17008, ticks=1/63005, in_queue=81082, util=97.32%
root@guinea-pig:/mnt/fio# fio --name=async-write --filename=/mnt/fio/testfile --size=1G --bs=4k \
    --rw=write --iodepth=16 --direct=1 --runtime=60 --time_based --group_reporting
async-write: (g=0): rw=write, bs=(R) 4096B-4096B, (W) 4096B-4096B, (T) 4096B-4096B, ioengine=psync, iodepth=16
fio-3.39
Starting 1 process
note: both iodepth >= 1 and synchronous I/O engine are selected, queue depth will be capped at 1
Jobs: 1 (f=1): [W(1)][100.0%][w=2242KiB/s][w=560 IOPS][eta 00m:00s]
async-write: (groupid=0, jobs=1): err= 0: pid=2115: Fri Nov 14 20:21:28 2025
  write: IOPS=569, BW=2280KiB/s (2334kB/s)(134MiB/60002msec); 0 zone resets
    clat (usec): min=603, max=16360, avg=1752.90, stdev=679.44
     lat (usec): min=604, max=16360, avg=1753.14, stdev=679.43
    clat percentiles (usec):
     |  1.00th=[ 1156],  5.00th=[ 1188], 10.00th=[ 1205], 20.00th=[ 1237],
     | 30.00th=[ 1270], 40.00th=[ 1303], 50.00th=[ 1401], 60.00th=[ 2114],
     | 70.00th=[ 2180], 80.00th=[ 2212], 90.00th=[ 2311], 95.00th=[ 2442],
     | 99.00th=[ 3589], 99.50th=[ 4146], 99.90th=[ 8717], 99.95th=[11731],
     | 99.99th=[15664]
   bw (  KiB/s): min= 1632, max= 2664, per=100.00%, avg=2281.08, stdev=181.23, samples=119
   iops        : min=  408, max=  666, avg=570.27, stdev=45.31, samples=119
  lat (usec)   : 750=0.01%, 1000=0.02%
  lat (msec)   : 2=55.05%, 4=44.28%, 10=0.57%, 20=0.07%
  cpu          : usr=0.27%, sys=1.97%, ctx=34203, majf=0, minf=8
  IO depths    : 1=100.0%, 2=0.0%, 4=0.0%, 8=0.0%, 16=0.0%, 32=0.0%, >=64=0.0%
     submit    : 0=0.0%, 4=100.0%, 8=0.0%, 16=0.0%, 32=0.0%, 64=0.0%, >=64=0.0%
     complete  : 0=0.0%, 4=100.0%, 8=0.0%, 16=0.0%, 32=0.0%, 64=0.0%, >=64=0.0%
     issued rwts: total=0,34195,0,0 short=0,0,0,0 dropped=0,0,0,0
     latency   : target=0, window=0, percentile=100.00%, depth=16

Run status group 0 (all jobs):
  WRITE: bw=2280KiB/s (2334kB/s), 2280KiB/s-2280KiB/s (2334kB/s-2334kB/s), io=134MiB (140MB), run=60002-60002msec

Disk stats (read/write):
    dm-1: ios=3/34301, sectors=24/274320, merge=0/0, ticks=0/58832, in_queue=58832, util=97.84%, aggrios=3/34256, aggsectors=24/274800, aggrmerge=0/105, aggrticks=0/59357, aggrin_queue=59414, aggrutil=97.70%
  sda: ios=3/34256, sectors=24/274800, merge=0/105, ticks=0/59357, in_queue=59414, util=97.70%
root@guinea-pig:/mnt/fio# fio --name=sync-write --filename=/mnt/fio/testfile --size=1G --bs=4k     --rw=write --iodepth=1 --direct=1 --sync=1 --runtime=60 --time_based --group_reporting
sync-write: (g=0): rw=write, bs=(R) 4096B-4096B, (W) 4096B-4096B, (T) 4096B-4096B, ioengine=psync, iodepth=1
fio-3.39
Starting 1 process
Jobs: 1 (f=1): [W(1)][100.0%][w=4580KiB/s][w=1145 IOPS][eta 00m:00s]
sync-write: (groupid=0, jobs=1): err= 0: pid=2129: Fri Nov 14 20:24:56 2025
  write: IOPS=1527, BW=6112KiB/s (6259kB/s)(358MiB/60001msec); 0 zone resets
    clat (usec): min=131, max=9992, avg=653.09, stdev=344.29
     lat (usec): min=132, max=9992, avg=653.29, stdev=344.30
    clat percentiles (usec):
     |  1.00th=[  182],  5.00th=[  210], 10.00th=[  237], 20.00th=[  285],
     | 30.00th=[  359], 40.00th=[  660], 50.00th=[  725], 60.00th=[  783],
     | 70.00th=[  832], 80.00th=[  889], 90.00th=[  979], 95.00th=[ 1045],
     | 99.00th=[ 1369], 99.50th=[ 1942], 99.90th=[ 3130], 99.95th=[ 4228],
     | 99.99th=[ 8455]
   bw (  KiB/s): min= 4128, max=12736, per=100.00%, avg=6129.87, stdev=2924.21, samples=119
   iops        : min= 1032, max= 3184, avg=1532.46, stdev=731.06, samples=119
  lat (usec)   : 250=13.32%, 500=20.58%, 750=20.45%, 1000=37.72%
  lat (msec)   : 2=7.46%, 4=0.40%, 10=0.06%
  cpu          : usr=0.66%, sys=5.32%, ctx=211682, majf=0, minf=9
  IO depths    : 1=100.0%, 2=0.0%, 4=0.0%, 8=0.0%, 16=0.0%, 32=0.0%, >=64=0.0%
     submit    : 0=0.0%, 4=100.0%, 8=0.0%, 16=0.0%, 32=0.0%, 64=0.0%, >=64=0.0%
     complete  : 0=0.0%, 4=100.0%, 8=0.0%, 16=0.0%, 32=0.0%, 64=0.0%, >=64=0.0%
     issued rwts: total=0,91681,0,0 short=0,0,0,0 dropped=0,0,0,0
     latency   : target=0, window=0, percentile=100.00%, depth=1

Run status group 0 (all jobs):
  WRITE: bw=6112KiB/s (6259kB/s), 6112KiB/s-6112KiB/s (6259kB/s-6259kB/s), io=358MiB (376MB), run=60001-60001msec

Disk stats (read/write):
    dm-1: ios=0/421324, sectors=0/2637864, merge=0/0, ticks=0/81948, in_queue=81948, util=88.37%, aggrios=0/304112, aggsectors=0/2642352, aggrmerge=0/117885, aggrticks=0/54648, aggrin_queue=58947, aggrutil=87.88%
  sda: ios=0/304112, sectors=0/2642352, merge=0/117885, ticks=0/54648, in_queue=58947, util=87.88%
root@guinea-pig:/mnt/fio# fio --name=async-write --filename=/mnt/fio/testfile --size=1G --bs=4k \
    --rw=write --iodepth=16 --direct=1 --runtime=60 --time_based --group_reporting
async-write: (g=0): rw=write, bs=(R) 4096B-4096B, (W) 4096B-4096B, (T) 4096B-4096B, ioengine=psync, iodepth=16
fio-3.39
Starting 1 process
note: both iodepth >= 1 and synchronous I/O engine are selected, queue depth will be capped at 1
Jobs: 1 (f=1): [W(1)][100.0%][w=16.5MiB/s][w=4230 IOPS][eta 00m:00s]
async-write: (groupid=0, jobs=1): err= 0: pid=2132: Fri Nov 14 20:25:58 2025
  write: IOPS=4169, BW=16.3MiB/s (17.1MB/s)(977MiB/60001msec); 0 zone resets
    clat (usec): min=95, max=11141, avg=238.70, stdev=128.18
     lat (usec): min=95, max=11142, avg=238.87, stdev=128.19
    clat percentiles (usec):
     |  1.00th=[  135],  5.00th=[  155], 10.00th=[  174], 20.00th=[  186],
     | 30.00th=[  196], 40.00th=[  206], 50.00th=[  225], 60.00th=[  241],
     | 70.00th=[  255], 80.00th=[  277], 90.00th=[  310], 95.00th=[  347],
     | 99.00th=[  474], 99.50th=[  693], 99.90th=[ 1762], 99.95th=[ 2245],
     | 99.99th=[ 4883]
   bw (  KiB/s): min=14352, max=18576, per=100.00%, avg=16689.14, stdev=751.16, samples=119
   iops        : min= 3588, max= 4644, avg=4172.29, stdev=187.79, samples=119
  lat (usec)   : 100=0.01%, 250=66.85%, 500=32.25%, 750=0.46%, 1000=0.14%
  lat (msec)   : 2=0.23%, 4=0.06%, 10=0.02%, 20=0.01%
  cpu          : usr=1.02%, sys=9.82%, ctx=250210, majf=0, minf=11
  IO depths    : 1=100.0%, 2=0.0%, 4=0.0%, 8=0.0%, 16=0.0%, 32=0.0%, >=64=0.0%
     submit    : 0=0.0%, 4=100.0%, 8=0.0%, 16=0.0%, 32=0.0%, 64=0.0%, >=64=0.0%
     complete  : 0=0.0%, 4=100.0%, 8=0.0%, 16=0.0%, 32=0.0%, 64=0.0%, >=64=0.0%
     issued rwts: total=0,250199,0,0 short=0,0,0,0 dropped=0,0,0,0
     latency   : target=0, window=0, percentile=100.00%, depth=16

Run status group 0 (all jobs):
  WRITE: bw=16.3MiB/s (17.1MB/s), 16.3MiB/s-16.3MiB/s (17.1MB/s-17.1MB/s), io=977MiB (1025MB), run=60001-60001msec

Disk stats (read/write):
    dm-1: ios=0/249989, sectors=0/1999824, merge=0/0, ticks=0/54784, in_queue=54784, util=91.31%, aggrios=0/250291, aggsectors=0/2003080, aggrmerge=0/105, aggrticks=0/56227, aggrin_queue=56228, aggrutil=91.21%
  sda: ios=0/250291, sectors=0/2003080, merge=0/105, ticks=0/56227, in_queue=56228, util=91.21%

Those speeds are kinda slow, I'm not gonna lie to you. However, it can be explained because of a few factors : Linstor Sync, ZFS Sync + VM overhead. But it works like a charm, i've no issues with speed at all nor needs.

pve01 to pve03 :

root@pivoine:/home/interlope# iperf3 -c 10.100.3.103
Connecting to host 10.100.3.103, port 5201
[  5] local 10.100.3.101 port 54678 connected to 10.100.3.103 port 5201
[ ID] Interval           Transfer     Bitrate         Retr  Cwnd
[  5]   0.00-1.00   sec  1.09 GBytes  9.38 Gbits/sec   33   1.21 MBytes
[  5]   1.00-2.00   sec  1.09 GBytes  9.38 Gbits/sec    0   1.22 MBytes
[  5]   2.00-3.00   sec  1.09 GBytes  9.37 Gbits/sec    0   1.22 MBytes
[  5]   3.00-4.00   sec  1.09 GBytes  9.37 Gbits/sec    0   1.23 MBytes
[  5]   4.00-5.00   sec  1.09 GBytes  9.38 Gbits/sec    1   1.23 MBytes
[  5]   5.00-6.00   sec  1.09 GBytes  9.37 Gbits/sec    0   1.23 MBytes
[  5]   6.00-7.00   sec  1.09 GBytes  9.37 Gbits/sec    0   1.24 MBytes
[  5]   7.00-8.00   sec  1.09 GBytes  9.37 Gbits/sec    0   1.24 MBytes
[  5]   8.00-9.00   sec  1.09 GBytes  9.36 Gbits/sec    0   1.27 MBytes
[  5]   9.00-10.00  sec  1.09 GBytes  9.38 Gbits/sec    0   1.29 MBytes
- - - - - - - - - - - - - - - - - - - - - - - - -
[ ID] Interval           Transfer     Bitrate         Retr
[  5]   0.00-10.00  sec  10.9 GBytes  9.37 Gbits/sec   34            sender
[  5]   0.00-10.00  sec  10.9 GBytes  9.37 Gbits/sec                  receiver

iperf Done.

pve02 to pve01 :

root@petunia:/home/interlope# iperf3 -c 10.100.3.101
Connecting to host 10.100.3.101, port 5201
[  5] local 10.100.3.102 port 54198 connected to 10.100.3.101 port 5201
[ ID] Interval           Transfer     Bitrate         Retr  Cwnd
[  5]   0.00-1.00   sec  1.09 GBytes  9.39 Gbits/sec    0   1.89 MBytes
[  5]   1.00-2.00   sec  1.09 GBytes  9.39 Gbits/sec    0   1.89 MBytes
[  5]   2.00-3.00   sec  1.09 GBytes  9.39 Gbits/sec    0   1.89 MBytes
[  5]   3.00-4.00   sec  1.09 GBytes  9.39 Gbits/sec    0   1.89 MBytes
[  5]   4.00-5.00   sec  1.09 GBytes  9.38 Gbits/sec    0   1.98 MBytes
[  5]   5.00-6.00   sec  1.09 GBytes  9.39 Gbits/sec    0   1.98 MBytes
[  5]   6.00-7.00   sec  1.09 GBytes  9.40 Gbits/sec    0   1.98 MBytes
[  5]   7.00-8.00   sec  1.09 GBytes  9.38 Gbits/sec    0   1.98 MBytes
[  5]   8.00-9.00   sec  1.09 GBytes  9.39 Gbits/sec    0   1.98 MBytes
[  5]   9.00-10.00  sec  1.09 GBytes  9.40 Gbits/sec    0   1.98 MBytes
- - - - - - - - - - - - - - - - - - - - - - - - -
[ ID] Interval           Transfer     Bitrate         Retr
[  5]   0.00-10.00  sec  10.9 GBytes  9.39 Gbits/sec    0            sender
[  5]   0.00-10.00  sec  10.9 GBytes  9.39 Gbits/sec                  receiver

iperf Done.

pve02 to pve03 :

root@petunia:/home/interlope# iperf3 -c 10.100.3.103
Connecting to host 10.100.3.103, port 5201
[  5] local 10.100.3.102 port 49102 connected to 10.100.3.103 port 5201
[ ID] Interval           Transfer     Bitrate         Retr  Cwnd
[  5]   0.00-1.00   sec  1.07 GBytes  9.17 Gbits/sec   34   1.21 MBytes
[  5]   1.00-2.00   sec  1.09 GBytes  9.39 Gbits/sec    0   1.31 MBytes
[  5]   2.00-3.00   sec  1.09 GBytes  9.39 Gbits/sec    0   1.31 MBytes
[  5]   3.00-4.00   sec  1.09 GBytes  9.38 Gbits/sec    0   1.31 MBytes
[  5]   4.00-5.00   sec  1.09 GBytes  9.40 Gbits/sec    0   1.32 MBytes
[  5]   5.00-6.00   sec  1.09 GBytes  9.39 Gbits/sec    0   1.32 MBytes
[  5]   6.00-7.00   sec  1.09 GBytes  9.39 Gbits/sec    0   1.32 MBytes
[  5]   7.00-8.00   sec  1.09 GBytes  9.38 Gbits/sec    0   1.32 MBytes
[  5]   8.00-9.00   sec  1.09 GBytes  9.39 Gbits/sec    0   1.35 MBytes
[  5]   9.00-10.00  sec  1.09 GBytes  9.38 Gbits/sec    0   1.35 MBytes
- - - - - - - - - - - - - - - - - - - - - - - - -
[ ID] Interval           Transfer     Bitrate         Retr
[  5]   0.00-10.00  sec  10.9 GBytes  9.37 Gbits/sec   34            sender
[  5]   0.00-10.00  sec  10.9 GBytes  9.36 Gbits/sec                  receiver

iperf Done.
task started by HA resource agent
2025-11-14 21:47:40 conntrack state migration not supported or disabled, active connections might get dropped
2025-11-14 21:47:40 use dedicated network address for sending migration traffic (10.100.3.103)
2025-11-14 21:47:40 starting migration of VM 20057 to node 'dalia' (10.100.3.103)
2025-11-14 21:47:40 starting VM 20057 on remote node 'dalia'
2025-11-14 21:47:41 start remote tunnel
2025-11-14 21:47:42 ssh tunnel ver 1
2025-11-14 21:47:42 starting online/live migration on unix:/run/qemu-server/20057.migrate
2025-11-14 21:47:42 set migration capabilities
2025-11-14 21:47:42 migration downtime limit: 100 ms
2025-11-14 21:47:42 migration cachesize: 2.0 GiB
2025-11-14 21:47:42 set migration parameters
2025-11-14 21:47:42 start migrate command to unix:/run/qemu-server/20057.migrate
2025-11-14 21:47:43 migration active, transferred 515.4 MiB of 12.6 GiB VM-state, 428.3 MiB/s
2025-11-14 21:47:44 migration active, transferred 1.1 GiB of 12.6 GiB VM-state, 1014.6 MiB/s
2025-11-14 21:47:45 migration active, transferred 2.0 GiB of 12.6 GiB VM-state, 1.1 GiB/s
2025-11-14 21:47:46 migration active, transferred 2.8 GiB of 12.6 GiB VM-state, 812.0 MiB/s
2025-11-14 21:47:47 migration active, transferred 3.3 GiB of 12.6 GiB VM-state, 823.0 MiB/s
2025-11-14 21:47:48 migration active, transferred 3.9 GiB of 12.6 GiB VM-state, 669.9 MiB/s
2025-11-14 21:47:49 migration active, transferred 4.5 GiB of 12.6 GiB VM-state, 564.6 MiB/s
2025-11-14 21:47:50 migration active, transferred 5.0 GiB of 12.6 GiB VM-state, 507.2 MiB/s
2025-11-14 21:47:51 migration active, transferred 5.5 GiB of 12.6 GiB VM-state, 484.9 MiB/s
2025-11-14 21:47:52 migration active, transferred 6.0 GiB of 12.6 GiB VM-state, 727.3 MiB/s
2025-11-14 21:47:53 migration active, transferred 6.9 GiB of 12.6 GiB VM-state, 949.1 MiB/s
2025-11-14 21:47:54 migration active, transferred 7.8 GiB of 12.6 GiB VM-state, 1.0 GiB/s
2025-11-14 21:47:55 migration active, transferred 8.8 GiB of 12.6 GiB VM-state, 1.0 GiB/s
2025-11-14 21:47:56 migration active, transferred 9.7 GiB of 12.6 GiB VM-state, 966.1 MiB/s
2025-11-14 21:47:57 migration active, transferred 10.6 GiB of 12.6 GiB VM-state, 1.0 GiB/s
2025-11-14 21:47:58 average migration speed: 806.6 MiB/s - downtime 59 ms
2025-11-14 21:47:58 migration completed, transferred 11.7 GiB VM-state
2025-11-14 21:47:58 migration status: completed

NOTICE
  Intentionally removing diskless assignment (pm-299b3873) on (pivoine).
  It will be re-created when the resource is actually used on this node.
2025-11-14 21:48:00 migration finished successfully (duration 00:00:20)
TASK OK

The VM is migrated in about 20 seconds with 59 freaking millisecond downtime. And since it's a live migration, there's absolutely no down time. The ping still goes on, and I'm still ssh'd in. This is wonderful. ( 1.0 GiB/s = roughly 8.59Gbps)

Conclusion :

To end this article/tutorial, i'll simply say that it works. It migrate quickly, i have no IO delay. It may not be the best setup nor the most money/performance ratio I could get, but I like it. That's what a production-ready homelab is : having fun trying things.

Thanks for reading me,

spleenftw