❯ storage · Running

Un seul disque mécanique dictait la latence de tout Longhorn : cloner à froid le HDD d’un Pi 5 vers un SSD, sans reconstruire une seule réplique One spinning disk set the latency for all of Longhorn: cold-cloning a Pi 5’s HDD to an SSD without rebuilding a single replica

Un disque dur USB à 5400 tr/min sur un worker prenait en otage toutes les écritures synchrones de Longhorn. Le remplacer par un SSD en le clonant bit à bit depuis un PC sous WSL2 — avec des garde-fous contre l’inversion source/cible — a fait passer la latence disque médiane de 20,17 à 0,264 ms, et les 15 volumes sont revenus sains sans une seule reconstruction. A 5400 rpm USB hard drive on one worker held every synchronous Longhorn write hostage. Swapping it for an SSD by cloning it bit for bit from a PC under WSL2 — with guardrails against a reversed source and target — took the median disk latency from 20.17 to 0.264 ms, and all 15 volumes came back healthy without a single rebuild.

· 11 min de lecture 11 min read ·
k3s-clusterlonghornstoragerpimigrationk8s
🇬🇧 Read this article in English

Contexte : l’architecture de stockage du cluster est décrite dans Stockage bloc distribué avec Longhorn, l’arrêt complet pendant lequel cette migration a eu lieu dans Éteindre et rallumer un cluster k3s GitOps entier.

Le symptôme : 47 IOPS dans un cluster de SSD

Le 20 septembre 2026, en marge du déménagement du rack, je mesure enfin les disques des nœuds un par un. Test direct en O_DIRECT, accès aléatoires de 4 Ko, 400 tirages par nœud. Rien de sophistiqué, juste de quoi comparer.

NœudLatence médianeLatence minimaleIOPS aléatoiresDébit séquentiel
worker-1 (SSD SanDisk)0,199 ms0,081 ms5 448379 Mo/s
worker-220,17 ms4,03 ms47102 Mo/s
master-1 (SSD Intenso)0,243 ms0,095 ms2 382219 Mo/s

Cent fois plus lent que son voisin. Et ce n’est pas un SSD fatigué : c’est la latence minimale qui trahit le coupable. Un SSD plancherait vers 0,08 ms, quoi qu’on lui demande. 4,03 ms, c’est le temps qu’il faut à une tête de lecture pour se déplacer. worker-2, un Pi 5 de 16 Go, démarrait depuis le début sur un Toshiba MQ01UBD050 : un disque dur 2,5 pouces de 500 Go, 5400 tr/min, en USB. Le seul disque mécanique du cluster.

Les métriques de production, moyennées sur sept jours par node_exporter, racontent la même histoire en plus discret : 3,753 ms de latence d’écriture sur worker-2 contre 0,643 ms sur worker-1, 5,097 ms en lecture contre 0,094 ms. Le chiffre le plus parlant est le pic : 1 742 IOPS demandées en pointe à un disque qui en encaisse 47. Environ 37 fois ses moyens.

On pourrait se dire : un nœud lent, ça ralentit les pods de ce nœud, point. Sauf qu’avec Longhorn, ce n’est pas comme ça que ça marche.

Le mécanisme : la réplique la plus lente fixe le tempo

Un volume Longhorn, c’est un engine qui reçoit les écritures du pod et les répercute sur chacune de ses répliques. L’écriture est synchrone : l’engine n’acquitte au pod qu’une fois toutes les répliques écrites. C’est ce qui permet de perdre un nœud sans perdre de données — et c’est aussi ce qui aligne tout le volume sur sa réplique la plus lente.

Or, dans ce cluster, les répliques ne vivent que sur deux nœuds : worker-1 et worker-2. Les masters ne portent rien, le troisième worker tourne sur une clé USB et reste hors du pool. Au 21 septembre, worker-1 portait 13 répliques et worker-2 en portait 15. Autrement dit, la quasi-totalité des volumes avaient un pied sur le disque mécanique.

La conséquence est contre-intuitive : un pod qui tourne sur worker-1, avec une réplique locale sur un SSD parfaitement sain, paie quand même la latence du Toshiba à chaque fsync. Ce n’était pas « un nœud lent ». C’était le seul goulot d’étranglement matériel mesuré du cluster, et il s’appliquait à tout le monde.

Le remède tombait sous le sens : remplacer le disque. Un SSD Intenso 512 Go dormait dans le NAS, jamais monté, en bonne santé SMART. Restait la manière.

La décision : cloner plutôt que laisser Longhorn reconstruire

Deux options classiques pour changer le disque racine d’un nœud Kubernetes.

Réinstaller et laisser Longhorn faire. Nouveau système sur le SSD, le nœud rejoint le cluster, Longhorn constate que 15 répliques ont disparu et les reconstruit depuis worker-1. Ça marche, mais c’est une quarantaine de gigaoctets de répliques à recopier par le réseau, pendant une fenêtre où chaque volume concerné n’a plus qu’une copie saine. Plus la réinstallation de k3s, l’identité du nœud, et quelque 68 Go de cache d’images à retélécharger.

Cloner bit à bit. Copier le disque entier, secteur par secteur, vers le SSD. La copie emporte tout : système, k3s, identité du nœud, cache d’images, et les répliques Longhorn telles quelles. Surtout, elle reproduit la signature MBR du disque. Or /etc/fstab et cmdline.txt désignent les partitions par PARTUUID, dérivé de cette signature : sur la copie, ils sont identiques. Aucun fichier à modifier. Le Pi démarre sur le SSD comme s’il n’avait jamais rien vu.

Le clonage a été retenu, avec trois règles posées avant de toucher au matériel :

  1. La source n’est jamais écrite. Retour arrière, à n’importe quelle étape : la rebrancher seule.
  2. Jamais source et copie branchées ensemble sur le Pi au démarrage. Mêmes PARTUUID : le système choisirait au hasard.
  3. La source reste intacte 14 jours après la bascule.

Un prérequis côté cluster, aussi. En inventoriant les volumes, deux d’entre eux — une base MySQL et le stockage de VictoriaLogs — n’avaient qu’une seule réplique, sur worker-2 justement. Les deux sont passés à 2 répliques, et on attend robustness=healthy, pas seulement running : une réplique en reconstruction est déjà running, ce n’est pas un signal suffisant. Ce détail allait servir plus tôt que prévu.

Le plan A : cloner sur le Pi lui-même (raté)

Le premier plan était simple : brancher le SSD sur le second port USB 3 de worker-2 et lancer la copie depuis le Pi. Il éprouvait le câble et le pont USB-SATA en conditions réelles, et évitait de sortir le disque.

Le 26 septembre vers 15 h 57, le SSD est branché à chaud sur worker-2, système en marche. Le Toshiba décroche aussitôt. Le disque système disparaît sous les pieds du nœud : moteur de conteneurs arrêté, SSH coupé.

La cause est électrique. Les ports USB d’un Pi 5 partagent un budget 5 V commun. Un disque dur 2,5 pouces en USB tire déjà pas mal sur ce budget ; un second périphérique alimenté par le bus au moment du branchement suffit à faire passer l’ensemble sous le seuil. Pas de message d’erreur explicite, juste un disque qui n’est plus là.

Le symptôme côté réseau mérite d’être connu, parce qu’il égare : le Pi répond au ping, mais SSH coupe la connexion à l’échange de bannière. Un noyau vivant, un disque mort. Depuis OpenSSH 9.8, le démon relit l’exécutable sshd-session sur le disque à chaque nouvelle connexion ; sans disque, pas de session. Seule issue : couper l’alimentation et redémarrer sur un seul disque.

Zéro perte de données. Les deux volumes passés à 2 répliques une heure plus tôt avaient désormais une copie sur worker-1. Sans cette précaution, l’histoire aurait été moins courte.

La règle qui en sort figure désormais en tête de la documentation du cluster : jamais deux disques USB sur un même Pi 5.

Le plan B : le PC, WSL2, et un arrêt complet du cluster

Si le Pi ne peut pas porter deux disques, le PC le peut : une alimentation de 850 W ne remarque même pas deux disques USB. Et la copie se fait à froid : aucun des deux disques n’est monté, aucun système ne tourne dessus pendant qu’on le lit.

Ça tombait bien : un arrêt complet du cluster était déjà prévu ce jour-là pour poser deux ventilateurs. La migration s’est greffée dessus, avec le runbook d’extinction corrigé après le déménagement du rack. Pas de drain, pas de question de capacité sur worker-1 : tous les volumes sont detached, tous les nœuds éteints.

Côté PC, WSL2 sait confier un disque physique entier à la VM Linux avec wsl --mount --bare. Trois détails ont coûté du temps :

  • wsl --mount refuse un disque « en cours d’utilisation » (0x8007006c) dès que Windows a donné une lettre à la partition FAT32 de démarrage du Pi. Il faut d’abord mettre les deux disques hors ligne dans Windows. Le script de montage le fait, et refuse au passage tout disque de démarrage ou système du PC.
  • WSL relâche les disques s’il s’endort. Un sleep infinity dans la distribution, en tâche de fond, la garde éveillée. Même logique pour le PC : la mise en veille doit être désactivée sur secteur pour toute la durée de l’opération.
  • Sous WSL, un disque transmis s’appelle « Virtual Disk ». Modèle et numéro de série ont disparu. Pour distinguer la source de la cible, il ne reste que deux choses : la taille exacte en octets, et la signature du disque. C’est précisément là-dessus que reposent les garde-fous.

Les garde-fous : rendre l’inversion impossible

Un dd dans le mauvais sens, c’est le Toshiba écrasé par le contenu d’un SSD vide, et plus de retour arrière. Avec deux disques qui s’appellent tous les deux « Virtual Disk », l’erreur n’a rien d’exotique. Le script de clonage ne fait donc confiance à personne, et surtout pas à l’opérateur :

[ "$s" = "$SRC_SIZE" ] || die "taille de la source $s octets, attendu $SRC_SIZE"
[ "$d" = "$DST_SIZE" ] || die "taille de la cible $d octets, attendu $DST_SIZE"
[ "$d" -ge "$s" ]      || die "la cible est plus petite que la source"

pt_src=$(blkid -p -s PTUUID -o value "$SRC" 2>/dev/null || true)
pt_dst=$(blkid -p -s PTUUID -o value "$DST" 2>/dev/null || true)
[ "$pt_src" = "$SIG" ] || die "signature de la source [$pt_src], attendu $SIG"
[ "$pt_dst" != "$SIG" ] || die "la cible porte déjà $SIG : inversion, ou clonage déjà effectué"

La source doit avoir exactement la taille et la signature attendues. La cible doit avoir exactement sa taille à elle, et ne pas porter la signature de la source. Inverser les deux arguments échoue donc deux fois. Relancer le script après un clonage réussi échoue aussi, puisque la cible porte désormais la signature. Les valeurs par défaut sont celles de cette migration ; pour un autre disque, faute de les passer en variables d’environnement, le script refuse — sans risque. S’ajoutent un refus si l’un des disques est monté, et un mode DRY_RUN=1 qui passe tous les contrôles sans rien écrire.

Ces garde-fous ont été éprouvés avant de toucher aux vrais disques, sur de faux disques : des fichiers creux aux tailles réelles, attachés en périphériques loop, avec une table MBR minimale portant la bonne ou la mauvaise signature. Sept cas : la bonne configuration, source et cible inversées, source égale à la cible, cible portant déjà la signature, cible de mauvaise taille, source de mauvaise taille, périphérique inexistant. Résultat attendu : un OK, six refus. Obtenu : 7 sur 7.

Deux autres détails dans le script, chacun issu d’un piège réel :

wipefs -a "$DST"
dd if="$SRC" bs=4M iflag=fullblock status=progress \
  | tee >(sha256sum | awk '{print $1}' > "$SHA") \
  | dd of="$DST" bs=4M iflag=fullblock oflag=direct

Le wipefs d’abord. Si la cible a porté une table GPT, celle-ci a une copie de secours à la fin du disque. Le SSD étant plus grand que le Toshiba, cette fin n’est pas couverte par la copie : on se retrouve avec une table MBR toute neuve au début et une GPT fantôme à la fin, que certains outils prennent au sérieux. On efface tout avant.

Le tee ensuite : l’empreinte SHA-256 de la source est calculée pendant la copie. Pas besoin de relire 500 Go sur un disque à 5400 tr/min juste pour obtenir une somme de contrôle.

La vérification : SHA-256 d’abord, e2fsck ensuite

Le vérificateur enchaîne quatre contrôles : même signature des deux côtés, tables de partitions identiques lues sur les disques, SHA-256 des premiers octets de la cible (sur la taille exacte de la source) comparé à celui calculé pendant la copie, puis e2fsck -fn de la partition racine en lecture seule. Il ne conclut CLONE VALIDE que si les quatre passent.

Le quatrième a échoué.

e2fsck: unsupported feature(s): FEATURE_C12

Fausse alerte. L’ext4 du Pi a été créé par Debian 13, qui active la fonction orphan_file. L’e2fsck 1.46 de l’Ubuntu 22.04 sous WSL ne la connaît pas, et refuse d’aller plus loin. La preuve que le clone n’y est pour rien : le même e2fsck sur le Toshiba, parfaitement sain, donne exactement la même erreur.

Le vérificateur a été corrigé dans la foulée : quand e2fsck se déclare trop ancien, repli sur dumpe2fs -h, qui lit l’en-tête sans rien interpréter. Il faut un état clean et l’absence de needs_recovery dans les fonctions — autrement dit, un journal vide. Mais soyons honnêtes : la preuve décisive, c’est le SHA-256. Si les deux empreintes sont identiques, la cible est la source, bit pour bit ; le contrôle du système de fichiers ne vérifie au fond que l’état dans lequel la source a été éteinte.

Au passage, deux finesses du vérificateur. La partition racine est lue dans la table de la source plutôt que codée en dur. Et on y accède par un périphérique loop à décalage, parce qu’après un dd, la vue qu’a le noyau des partitions de la cible est périmée.

Le déroulé : 1 h 49 de copie, 41 minutes de preuve

ÉtapeDurée
Copie (17 h 24 → 19 h 13)1 h 49, à 76 Mo/s de moyenne — 112 Mo/s en début de disque, moins en fin de plateau
Vérification SHA-25641 min — relecture du SSD à ~200 Mo/s à travers l’USB et WSL
Relance du cluster, masters allumés → 32/32 applications10 min

La courbe de débit de la copie est celle d’un disque dur, pas d’un câble : les pistes extérieures des plateaux défilent plus vite sous la tête que les pistes intérieures, d’où 112 Mo/s au début et une moyenne à 76.

Bascule : le SSD seul sur worker-2, port USB 3. Au démarrage, trois vérifications : lsblk, findmnt / pour s’assurer que la racine est bien sur la copie, blkid pour constater le même PARTUUID. Puis la relance du cluster, avec le runbook corrigé.

Le résultat : ×76 en latence, zéro reconstruction

À 22 h 36, la migration est close. Au retour, Longhorn retrouve ses répliques là où il les avait laissées : 15 volumes sur 15 healthy, sans aucune reconstruction. Pas un octet recopié par le réseau. C’était tout l’intérêt du clone.

worker-2Toshiba (20.09)SSD Intenso (26.09)Gain
Latence médiane20,17 ms0,264 ms×76
Latence minimale4,03 ms0,081 msplancher SSD, comme worker-1
IOPS aléatoires473 727×79
Débit séquentiel102 Mo/s376 Mo/s×3,7, plafond de l’USB 3

Un détail cocasse pour finir : lsblk affiche toujours ROTA=1 pour le SSD. Pour le noyau, c’est encore un disque rotatif. L’adaptateur USB-SATA ne transmet ni la vitesse de rotation ni SMART, et Linux suppose le pire. La latence minimale de 0,081 ms, elle, ne ment pas. Une règle udev forçant queue/rotational=0 remettrait les choses d’aplomb.

Les autres pièges, en vrac

  • fsck.fat « Writing changes » au premier démarrage. La cible, revenue en ligne sous Windows avant d’être débranchée, avait reçu une lettre de lecteur — et la partition de boot en garde la trace. Rien de grave, mais c’est exactement ce qu’on voulait éviter : laisser les disques hors ligne jusqu’au débranchement.
  • Un contrôle « ABSENT » alors que le fichier existe. set -o pipefail plus grep -q en bout de tube : grep s’arrête à la première correspondance, ferme le tube, la commande amont reçoit SIGPIPE, et pipefail transforme ce succès en échec. Correctif : grep -q … <<< "$var", ou passer par un fichier.
  • Des scripts shell en CRLF. Le poste Windows convertissait les fins de ligne, ce qui est fatal sous WSL. Un .gitattributes force désormais les *.sh en LF.
  • Les variables mangées par wsl.exe -- bash -c '…'. Les scripts WSL se lancent depuis des fichiers, jamais en ligne.

Ce qu’on retient

  1. Une écriture Longhorn synchrone est aussi lente que sa réplique la plus lente. Un seul disque médiocre dans le pool se répercute sur tous les volumes qui y ont une réplique, y compris pour les pods des autres nœuds. Mesurer la latence minimale de chaque disque suffit à repérer l’intrus.
  2. Cloner plutôt que reconstruire. Un clone bit à bit conserve répliques, identité du nœud, cache d’images et PARTUUID. Longhorn retrouve tout tel quel ; aucune fenêtre où un volume n’a plus qu’une copie.
  3. Avant de toucher au matériel, aucun volume ne doit avoir sa seule réplique sur le nœud concerné, et on attend healthy, pas running.
  4. Jamais deux disques USB sur un Pi 5. Cloner ailleurs, à froid.
  5. Identifier les disques par taille exacte et signature, et écrire des garde-fous qui rendent l’inversion impossible. Les tester sur de faux disques avant les vrais.
  6. wipefs de la cible quand elle est plus grande que la source : la GPT de secours vit à la fin.
  7. La preuve, c’est l’empreinte. SHA-256 calculé pendant la copie, comparé sur la cible. Un e2fsck trop ancien peut crier au loup.
  8. Garder la source intacte pendant deux semaines. Le retour arrière tient en un câble.

Les limites, pour être complet : tout ceci suppose une cible au moins aussi grande que la source, et une fenêtre d’arrêt du nœud — ici, celle d’un arrêt complet déjà prévu. Les quelque 11 Gio libres en fin de SSD ne sont pas encore récupérés. Et la question du NVMe reste ouverte, mais sans urgence : avec une seule ligne PCIe 2.0 sur le Pi 5, le gain en débit plafonnerait autour de 19 %. Le seul vrai argument serait de supprimer la couche USB : un gain de fiabilité, pas de performance.

Il aura fallu deux disques, un PC et un après-midi pour qu’un fsync sur worker-1 cesse d’attendre une tête de lecture sur worker-2.

k3s-clusterlonghornstoragerpimigrationk8s