# Guide — Migration LXD vers Incus sur nas-host _Guide construit pas à pas le 31 août 2026, sur une Ubuntu 24.04 (noyau 6.8) hébergeant 5 conteneurs LXD. Chaque étape n'est consignée qu'après avoir réellement fonctionné sur la machine._ **Les noms d'hôtes, de domaines et de comptes de cette page sont des exemples à transposer.** Les adresses `192.168.0.x` appartiennent à l'espace privé RFC 1918 et sont conservées telles quelles : elles illustrent la topologie sans identifier personne. **Le fichier `.md` de cette page :** [ouvrir le `.md`](https://blog.infolaf.ca/wp-content/uploads/fichiers/guide-migration-lxd-incus-public.md) ## Contexte - **Hôte** : nas-host (`nas-host.lan.local`, 192.168.0.11), Supermicro X11SAE-M, Xeon E3-1230 v5, 31 Gio de RAM - **Départ** : LXD installé par snap, pool ZFS `default` dans un fichier de 27,5 Go - **Arrivée** : Incus **LTS 6.0** (dépôt Zabbly) en paquet Debian, pool ZFS `local` de 100 Go - **Méthode retenue** : migration conteneur par conteneur, l'original LXD conservé arrêté comme filet - **Conteneurs** : `navidrome`, `squid-proxy`, `languagetool`, `pi-hole`, `web-srv` ### Décisions prises avant de commencer | Question | Choix | Raison | |---|---|---| | Branche du dépôt | `lts-6.0` | Deux raisons. D'abord, `stable` n'est supporté que jusqu'à la sortie suivante (~1 mois) alors qu'une LTS tient 5 ans. Ensuite et surtout : **Incus 7.0 exige un noyau ≥ 6.12**, nas-host est en 6.8. | | Zabbly plutôt que le paquet Ubuntu | Zabbly `lts-6.0` | Ubuntu fige la base en 6.0.0 ; Zabbly suit les correctives amont — **6.0.6 au moment de l'installation**. | | Nom du pool Incus | **`local`**, pas `default` | Le zpool de LXD s'appelle déjà `default` — ZFS refuserait un doublon. | | Emplacement du pool | Fichier sur le NVMe, 100 Go | Le NVMe n'a qu'une partition ; le pool porte le rootfs des conteneurs, donc MySQL — pas de disque mécanique. | ### Prérequis vérifiés - Export complet des 5 conteneurs, archives testées au `tar tzf` - 335 Go libres sur `/`, conteneurs totalisant ~9 Gio - IP statiques dans le netplan de chaque conteneur (elles voyagent avec lui) - Résolveur de secours ajouté partout, pour que la migration du Pi-hole ne coupe rien - Configuration complète relevée : profils `default` et `macvlan`, périphériques disque, drapeaux --- ## Étape 1 — Vérifier la clé du dépôt Cette commande ne modifie rien : elle affiche la clé sans l'importer. ``` curl -fsSL https://pkgs.zabbly.com/key.asc | gpg --show-keys --fingerprint ``` **Résultat attendu** ``` pub rsa3072 2023-08-23 [SC] [expires: 2030-08-17] 4EFC 5906 96CB 15B8 7C73 A3AD 82CC 8797 C838 DCFD uid Zabbly Kernel Builds ``` **Pourquoi.** On s'apprête à autoriser un dépôt tiers dont les paquets s'installeront en root. Cette clé est ce qui garantit leur provenance. La vérification n'a de valeur que parce que l'empreinte de référence vient d'une **source indépendante du téléchargement** — elle est publiée dans le README de `github.com/zabbly/incus`. Comparer l'empreinte à elle-même ne vérifierait rien. **Notes** - Les messages `directory '/home/hostadmin/.gnupg' created` sont normaux à la première utilisation de `gpg` par cet utilisateur. - Options de `curl` : `-f` échoue sur erreur HTTP, `-s` silencieux, `-S` affiche quand même les erreurs, `-L` suit les redirections. - Si `gpg` manque : `sudo apt install gnupg`. **En cas d'écart** — s'arrêter et vérifier sur le dépôt GitHub de Zabbly avant toute chose. --- ## Étape 2 — Ajouter le dépôt Aucune de ces commandes n'installe quoi que ce soit. **2.1 — Créer le répertoire des trousseaux apt** ``` sudo mkdir -p /etc/apt/keyrings/ ``` Emplacement standard depuis Debian 11 pour les clés de dépôts tiers. Aucune sortie. **2.2 — Y déposer la clé vérifiée à l'étape 1** ``` sudo curl -fsSL https://pkgs.zabbly.com/key.asc -o /etc/apt/keyrings/zabbly.asc ``` Même URL qu'à l'étape 1 : on télécharge maintenant ce qu'on vient de valider. Aucune sortie. **2.3 — Relever les deux valeurs dont le dépôt a besoin** ``` . /etc/os-release && echo $VERSION_CODENAME ``` ``` dpkg --print-architecture ``` Résultat obtenu sur nas-host : `noble` et `amd64`. Les afficher plutôt que de les glisser dans une substitution de commande permet de voir ce qu'on écrit réellement dans le fichier. **2.4 — Écrire le fichier de dépôt** ``` sudo nano /etc/apt/sources.list.d/zabbly-incus-lts-6.0.sources ``` Contenu : ``` Enabled: yes Types: deb URIs: https://pkgs.zabbly.com/incus/lts-6.0 Suites: noble Components: main Architectures: amd64 Signed-By: /etc/apt/keyrings/zabbly.asc ``` Format « deb822 », plus lisible que l'ancienne ligne `deb [signed-by=…] URL suite composant`. Chaque champ est explicite : provenance, version d'Ubuntu, architecture, et **quelle clé authentifie les paquets**. `lts-6.0` dans l'URL est le choix de branche. C'est cette seule ligne à modifier en 2029, en fin de support. **Vérification** ``` cat /etc/apt/sources.list.d/zabbly-incus-lts-6.0.sources ``` ### Pourquoi 6.0 et non 7.0 — le piège du noyau La 7.0 LTS apporte les conteneurs applicatifs OCI, les pilotes Linstor et TrueNAS, un écouteur S3 intégré, les sauvegardes incrémentales de VM, des ensembles d'adresses réseau et diverses fonctions de grappe. **Rien de tout cela ne concerne un hôte unique portant cinq conteneurs système en macvlan sur ZFS.** Et surtout, la documentation est catégorique : *« The minimum supported kernel version is 6.12. »* nas-host tourne sur 6.8. Obtenir un noyau conforme imposerait `linux-generic-hwe-24.04` — un changement de noyau sur un serveur en production dont le `/boot` est sur une clé USB, pour des fonctionnalités inutilisées. La 7.0 retire aussi CGroupV1 et xtables. Sans conséquence ici (cgroup v2 déjà actif, macvlan sans pare-feu géré par Incus), mais confirme qu'elle vise des systèmes plus récents. **Leçon à retenir : croiser les prérequis de la version visée avec le noyau en place, avant de choisir la branche.** --- ## Étape 3 — Rafraîchir apt et vérifier d'où viendra le paquet **3.1 — Mettre à jour la liste des paquets** ``` sudo apt update ``` Une ligne doit mentionner `pkgs.zabbly.com`. Ce qui compte, c'est **l'absence** d'un message `NO_PUBKEY` ou « signatures couldn't be verified », qui signalerait un `Signed-By` mal dirigé. **3.2 — Regarder d'où viendra le paquet, avant de l'installer** ``` apt policy incus ``` ⚠️ **C'est l'étape qui a tout changé.** Premier résultat obtenu : ``` Candidat : 6.0.0-1ubuntu0.3+esm3 1:7.0.1-ubuntu24.04-... 500 pkgs.zabbly.com 6.0.0-1ubuntu0.3+esm3 510 esm.ubuntu.com/apps/ubuntu ``` **Ubuntu distribue son propre paquet `incus`**, et l'abonnement Ubuntu Pro donne au dépôt ESM une priorité de **510**, supérieure au 500 par défaut d'un dépôt tiers. Un `apt install incus` aurait installé le paquet Ubuntu — silencieusement, sans erreur. Apt choisit **d'abord par priorité, ensuite seulement par numéro de version**. L'« epoch » `1:` en tête de la version Zabbly la placerait pourtant devant, mais la priorité tranche avant. **Prendre l'habitude de `apt policy` avant toute installation depuis un dépôt tiers.** **3.3 — Donner la priorité à Zabbly** ``` sudo nano /etc/apt/preferences.d/zabbly-incus ``` Contenu : ``` Package: * Pin: origin pkgs.zabbly.com Pin-Priority: 600 ``` - `Pin: origin` désigne le **nom d'hôte** du dépôt, pas son champ « Origin » — confusion classique dans la documentation d'apt. - `600` dépasse le 510 d'ESM. **Ne pas dépasser 1000** : au-delà, apt s'autorise à *rétrograder* des paquets déjà installés. - `Package: *` couvre tout le dépôt sans énumérer `incus`, `incus-client`, `incus-ui-canonical`. Le dépôt ne contenant qu'Incus, la portée reste étroite. **3.4 — Revérifier** ``` sudo apt update ``` ``` apt policy incus ``` Résultat attendu : ``` Candidat : 1:6.0.6-ubuntu24.04-202603272003 1:6.0.6-ubuntu24.04-... 600 pkgs.zabbly.com/incus/lts-6.0 6.0.0-1ubuntu0.3+esm3 510 esm.ubuntu.com/apps/ubuntu ``` **6.0.6 contre 6.0.0** : six versions correctives d'écart avec le paquet Ubuntu. C'est le bénéfice concret du dépôt Zabbly sur la même ligne LTS. --- ## Étape 4 — Installer **4.1 — Vérifier l'origine de chaque paquet** ``` apt policy incus-ui-canonical ``` Même réflexe que pour `incus` : candidat attendu depuis `pkgs.zabbly.com` à 600. **4.2 — Simuler avant d'installer** ``` sudo apt install --dry-run incus incus-ui-canonical ``` Le `--dry-run` ne modifie rien : apt calcule et affiche ce qu'il ferait. Sur une machine en production, c'est le réflexe qui évite les surprises. Trois choses à regarder dans la sortie : - **Ce qui sera installé** — 4 paquets Zabbly (`incus`, `incus-base`, `incus-client`, `incus-ui-canonical`) et une trentaine de dépendances Ubuntu. - **`0 à enlever`** — la ligne capitale. Si apt proposait de retirer un paquet, s'arrêter. - **Absence de `qemu-system-x86`** — le support des VM n'est pas installé. À ajouter le jour où une VM sera nécessaire. À propos de `dnsmasq-base` : c'est la bibliothèque et le binaire, **pas** le paquet `dnsmasq` qui lance un service écoutant sur le port 53. Incus ne s'en sert que pour ses réseaux gérés — inutilisé en macvlan. Aucun conflit avec un Pi-hole sur le réseau. **4.3 — Installer** ``` sudo apt install incus incus-ui-canonical ``` **4.4 — Vérifier le démon** ``` systemctl status incus.service --no-pager ``` Résultat attendu, et il surprend : ``` Active: inactive (dead) TriggeredBy: ● incus.socket ``` **C'est normal.** Incus utilise l'**activation par socket** : systemd écoute sur `/var/lib/incus/unix.socket` et ne démarre le démon qu'à la première connexion d'un client. Au repos, il ne consomme rien. LXD, lui, garde son démon en permanence — différence de conception, pas défaut. ``` systemctl status incus.socket --no-pager ``` Celui-ci doit être `active (listening)`. **4.5 — Vérifier que LXD n'a pas bougé** ``` lxc list -c ns ``` Les conteneurs LXD tournent toujours. Deux démons indépendants cohabitent sans se connaître. --- ## Étape 5 — Autoriser son utilisateur **5.1 — Ajouter l'utilisateur au groupe d'administration** ``` sudo usermod -aG incus-admin hostadmin ``` Le paquet crée deux groupes : `incus` (accès restreint) et `incus-admin` (contrôle complet). C'est le second qu'il faut. ⚠️ Le `-a` est essentiel. Sans lui, `usermod -G` **remplace** tous les groupes de l'utilisateur au lieu d'en ajouter un. **5.2 — Se déconnecter et se reconnecter** ``` exit ``` Le changement de groupe n'est lu qu'à l'ouverture de session. Dans le shell courant, `incus` répondrait `permission denied` sur le socket. **5.3 — Confirmer** ``` id ``` `incus-admin` doit figurer dans la liste des groupes. **5.4 — Première commande Incus** ``` incus version ``` Sans `sudo`. Elle réveille le démon par activation de socket. ``` Version du client : 6.0.6 Server version: 6.0.6 ``` Les deux lignes doivent afficher la même version. --- ## Topologie réseau — à comprendre avant d'initialiser ``` eno1 192.168.0.2/24 parent du macvlan, porte la route par défaut eno2 192.168.0.11/24 adresse d'administration de l'hôte (SSH) lxdbr0 10.248.34.1/24 DOWN — pont LXD créé par défaut, jamais utilisé ``` **Deux cartes physiques sur le même sous-réseau, et c'est délibéré.** Un hôte ne peut normalement **pas** joindre ses propres conteneurs macvlan à travers l'interface parente : les paquets ne remontent jamais la pile réseau locale. Ici, les conteneurs sont enfants macvlan d'`eno1`, et l'hôte leur parle depuis `eno2`. Le trafic sort par une carte, traverse le commutateur, revient par l'autre. **C'est ce montage qui fait fonctionner les sauvegardes rsync** de l'hôte vers `192.168.0.6`. Sans la seconde carte, elles seraient impossibles. ⚠️ **Conséquence** : la seconde carte n'est *pas* disponible pour un passthrough PCI vers une VM. La céder supprimerait le 192.168.0.11 de l'hôte et couperait la voie vers les conteneurs. Deux règles qui en découlent pour l'initialisation : 1. Le profil macvlan d'Incus doit pointer vers **`parent: eno1`**, comme celui de LXD. 2. **Refuser la création d'un pont réseau**, sous peine de reproduire le `lxdbr0` inutile. --- ## Étape 6 — Tester la compatibilité LXD ↔ Incus Étape décisive : son résultat détermine la stratégie de migration. **6.1 — Exposer l'API de LXD sur la boucle locale** ``` lxc config set core.https_address 127.0.0.1:8443 ``` `127.0.0.1` uniquement : rien n'est exposé sur le LAN. **6.2 — Créer un jeton de confiance** ``` lxc config trust add --name incus-migration ``` LXD affiche un jeton base64 à usage unique, valable deux semaines. **6.3 — Déclarer LXD comme dépôt distant auprès d'Incus** ``` incus remote add lxd-local https://127.0.0.1:8443 --token=LE_JETON ``` Incus génère un certificat client, affiche son empreinte et demande confirmation. Répondre `o`. **6.4 — Le verdict** ``` incus list lxd-local: ``` **Résultat obtenu** : les cinq conteneurs listés avec leurs IP, leur type et leur nombre d'instantanés. Les API LXD et Incus sont donc restées compatibles, malgré la divergence des deux projets depuis 2023 — et malgré le fait que `incus remote add` ne documente plus de protocole `lxd`. La migration progressive est possible. **Si ce test avait échoué**, la solution de repli était `lxd-to-incus`, qui migre tout l'hôte d'un coup — acceptable uniquement parce que les exports complets avaient été faits et vérifiés au préalable. **À ne pas oublier en fin de migration** : refermer l'API de LXD avec `lxc config unset core.https_address`. --- ## Étape 7 — Initialiser Incus ``` incus admin init ``` Sans `sudo` (l'utilisateur est dans `incus-admin`). Réponses données : | Question | Réponse | Pourquoi | |---|---|---| | Would you like to use clustering? | `no` | Hôte unique. | | Configurer un nouveau pool de stockage ? | `yes` (défaut) | | | Name of the new storage pool [default=default] | **`local`** | ⚠️ **Surtout pas `default`** : le zpool de LXD porte déjà ce nom, ZFS refuserait le doublon. | | Storage backend | `zfs` (défaut) | Même moteur que LXD : instantanés et transferts optimisés. | | Créer un nouvel agrégat ZFS ? | `yes` (défaut) | | | Utiliser un périphérique bloc vide existant ? | `no` (défaut) | Le NVMe n'a qu'une partition ; pas de repartitionnement en production. | | Size in GiB of the new loop device [default=30GiB] | **`100`** | Fichier creux : ne consomme que ce qui est écrit. | | Créer un pont réseau local ? | **`no`** | ⚠️ Sinon Incus fabrique un `incusbr0` inutile, jumeau du `lxdbr0` qu'on veut voir disparaître. | | Utiliser un pont ou une interface existante ? | `no` (défaut) | Le profil `macvlan` sera créé à part, pour reproduire la structure de LXD. | | Serveur disponible sur le réseau ? | `no` (défaut) | Sera ouvert plus tard, lié explicitement à l'IP d'administration. | | Mettre à jour les images en cache ? | `yes` (défaut) | | | Imprimer un preseed YAML ? | `yes` | Trace exacte, et fichier de réinitialisation réutilisable. | **Preseed obtenu** ```yaml config: {} networks: [] storage_pools: - config: size: 100GiB name: local driver: zfs profiles: - devices: root: path: / pool: local type: disk name: default ``` Un `incus admin init --preseed < fichier.yaml` reproduirait cette configuration sans aucune question — utile pour un second hôte. **Vérifications** ``` incus storage list ``` ``` sudo zpool list ``` C'est ici que la cohabitation devient visible : ``` default 27.5G 11.9G 15.6G ← LXD local 99.5G 622K 99.5G ← Incus ``` ``` incus profile show default ``` Ne doit contenir que le périphérique `root` pointant vers `pool: local`. Pas de carte réseau. --- ## Étape 8 — Recréer le profil `macvlan` **8.1 — Créer le profil** ``` incus profile create macvlan ``` **8.2 — Y ajouter la carte réseau** ``` incus profile device add macvlan eth0 nic nictype=macvlan parent=eno1 ``` Décomposition, car cette commande porte toute la topologie réseau du parc : | Élément | Rôle | |---|---| | `macvlan` | le profil auquel on ajoute | | `eth0` | le nom du périphérique **tel que le conteneur le verra** — celui que les netplan configurent | | `nic` | le type de périphérique Incus | | `nictype=macvlan` | le conteneur obtient sa propre MAC directement sur le réseau physique | | `parent=eno1` | **l'interface hôte à laquelle il s'accroche** | ⚠️ `parent=eno1` est le point critique. Avec `eno2`, les conteneurs resteraient joignables depuis le LAN, mais **l'hôte ne pourrait plus les atteindre** — et les sauvegardes rsync tomberaient sans erreur visible. **8.3 — Comparer avec l'original** ``` incus profile show macvlan ``` ``` lxc profile show macvlan ``` Les deux blocs `devices` doivent être identiques. C'est ce contrôle qui garantit qu'un conteneur copié retrouvera exactement le même réseau. --- ## Étape 9 — Migrer un conteneur (modèle) Séquence appliquée à `navidrome` en premier, puis répétée pour les autres. Commencer par le conteneur le moins critique. **9.1 — Lire la configuration source avant de copier** ``` lxc config show navidrome ``` Chercher trois choses en particulier : - **Les périphériques disque** montés depuis l'hôte — leurs *définitions* suivent la copie, les *données* ne bougent pas. Chemins identiques puisque c'est le même hôte. - **`raw.idmap`** — la projection d'identifiants qui permet à un conteneur non privilégié d'accéder aux fichiers de l'hôte avec les bonnes permissions. C'est l'alternative propre au `security.privileged`. - **`boot.autostart`** — s'il vaut `true`, il faudra le désactiver côté LXD après validation (étape 9.7). **9.2 — Arrêter l'original** ``` lxc stop navidrome ``` On copie à l'arrêt : la copie est alors cohérente au niveau du système de fichiers, sans base de données saisie en pleine écriture. **9.3 — Copier** ``` incus copy lxd-local:navidrome navidrome ``` `lxd-local:navidrome` = le conteneur sur le dépôt distant. Le second, sans préfixe, est la destination locale dans Incus. ⚠️ **Ne pas ajouter `--storage local`.** La racine vient déjà du profil `default` d'Incus. L'option crée un périphérique `root` explicite sur l'instance, divergence inutile avec la structure d'origine. (Réparable après coup : `incus config device remove root`.) **9.4 — Comparer** ``` incus config show navidrome ``` Doivent être identiques : les périphériques, `raw.idmap`, les profils, les limites. Différences **normales** : | Clé | Observation | |---|---| | `volatile.eth0.hwaddr` | **MAC régénérée** — préfixe `10:66:6a` (Incus) au lieu de `00:16:3e` (LXD/Xen). Sans conséquence si l'IP est statique dans le netplan ; à corriger si des règles au routeur s'indexent sur la MAC. | | `volatile.cloud-init.instance-id` | **Nouvel identifiant** — cloud-init voit une instance neuve et *peut régénérer sa configuration réseau*. D'où l'importance de l'avoir neutralisé au préalable. | | `volatile.apply_template: copy` | Normal sur une copie. | **9.5 — Démarrer la copie** ``` incus start navidrome ``` ⚠️ **Règle absolue** : l'original est arrêté depuis 9.2, la copie démarre maintenant. **Jamais les deux ensemble** — même IP sur le LAN, et corruption si un disque hôte est partagé entre les deux. **9.6 — Vérifier** ``` incus exec navidrome -- ip -br addr ``` ``` incus exec navidrome -- ls -la /mnt/musique ``` Le second est le contrôle décisif quand `raw.idmap` est en jeu : des propriétaires normaux confirment que la projection a suivi. Des `nobody` ou `65534` signifieraient le contraire. Puis **tester le service lui-même** dans un navigateur, pas seulement le réseau. **9.7 — Désactiver le démarrage automatique de l'original** ``` lxc config set navidrome boot.autostart false ``` ⚠️ **À ne jamais sauter.** Sans cette commande, au prochain redémarrage de l'hôte, LXD et Incus lanceraient chacun leur exemplaire — même IP, conflit garanti, et diagnostic pénible puisque tout aura fonctionné jusque-là. Le conteneur LXD reste en place, arrêté : c'est le filet de retour immédiat. --- ## Étape 10 — Les conteneurs suivants La séquence de l'étape 9 se répète. Deux cas rencontrés méritent d'être notés. ### `squid-proxy` — le cas nu ``` devices: {} ``` Aucun disque monté, aucun `raw.idmap`. La migration est purement mécanique. Vérifier que le service **écoute** ne suffit pas ; vérifier qu'il **fonctionne** de bout en bout, depuis une autre machine : ``` curl -x 192.168.0.7:3128 -sI https://www.google.com | head -1 ``` → `HTTP/1.1 200 Connection established` ### `languagetool` — la clé cloud-init côté instance Sa configuration LXD portait : ```yaml cloud-init.network-config: |- version: 2 ethernets: eth0: addresses: [192.168.0.10/24] nameservers: addresses: - 192.168.0.5 ``` **C'est la source du `50-cloud-init.yaml` trouvé dans le conteneur.** Le travail de neutralisation avait traité le symptôme à l'intérieur ; voici la cause, un étage au-dessus. Elle ne contenait d'ailleurs que le Pi-hole, sans résolveur de secours. Inerte une fois cloud-init désactivé dans le conteneur, mais trompeuse. À retirer **après** la copie, côté Incus seulement — l'original LXD garde son état, c'est le principe du filet : ``` incus config unset languagetool cloud-init.network-config ``` Contrôle spécifique après démarrage : c'est bien **notre** netplan qui est en place, pas un `50-cloud-init.yaml` ressuscité par le nouvel `instance-id`. ``` incus exec languagetool -- cat /etc/netplan/50-static-public-ip.yaml ``` --- ## Deux leçons sur la vérification des services ### Ne pas tester depuis l'hôte Un `curl` lancé depuis nas-host vers un conteneur macvlan peut **geler sans erreur** : selon la route empruntée, les paquets partent par `eno1`, l'interface parente, où le macvlan interdit précisément le dialogue hôte↔conteneur. Ils disparaissent sans RST, donc sans « connection refused ». **Tester les services depuis une autre machine du LAN**, jamais depuis l'hôte — sous peine de diagnostiquer un problème réseau qui n'existe pas. ### Tester le service tel qu'on l'utilise réellement L'accès direct `IP:port` peut n'avoir jamais fonctionné, indépendamment de la migration. Ce qui compte est le chemin réel : pour LanguageTool, `https://lt.example.com/v2/languages` à travers le proxy inverse d'Apache sur `web-srv`. Avant de chercher une panne, se demander : **est-ce que ça marchait comme ça avant ?** ### Lire les journaux du service plutôt que sonder La preuve la plus solide de la réussite est venue du journal de LanguageTool : ``` Handling POST /v2/check ... Handled request in 159ms; sending code 200 ``` Un vrai client servi après la migration vaut mieux que n'importe quelle sonde artificielle. ### `pi-hole` — le service dont tout dépend Mécaniquement le plus simple : `devices: {}`, aucun `raw.idmap`, aucune clé cloud-init. La difficulté est purement opérationnelle : **le DNS de toute la maison est coupé pendant la copie** (~4 min pour 2,67 Gio). À faire à une heure creuse — 6 h 24 s'est avéré idéal. Les autres conteneurs ayant un résolveur de secours, seuls les appareils du foyer qui reçoivent le DNS par DHCP sont concernés. **Particularité relevée** : `pi-hole` n'avait **aucune clé `boot.autostart`**. Il revenait au démarrage par le mécanisme de restauration d'état (`volatile.last_state.power`). Pour un service dont tout dépend, mieux vaut l'écrire explicitement du côté Incus : ``` incus config set pi-hole boot.autostart true ``` **Vérification en trois temps** — et le troisième est celui qu'on oublie : ``` incus exec pi-hole -- ss -lntup | grep :53 ``` → `pihole-FTL` en écoute, UDP et TCP, IPv4 et IPv6. ``` dig @192.168.0.5 www.google.com +short ``` → des adresses : la **résolution** fonctionne. ``` dig @192.168.0.5 doubleclick.net +short ``` → `0.0.0.0` : le **blocage** fonctionne. ⚠️ Sans ce dernier test, un Pi-hole qui résout mais ne filtre plus passerait tous les contrôles de connectivité. On s'en apercevrait des semaines plus tard, en voyant la publicité revenir. **Tester la fonction du service, pas seulement sa présence.** Le tableau de bord confirme ensuite que la base de FTL a suivi : historique des requêtes, compteurs et listes intacts. ### `web-srv` — le conteneur critique Le dernier, et le seul qui demande une préparation particulière : conteneur **privilégié**, disque hôte de 1,7 To, 7 instantanés, cible du 443 public, et **proxy inverse pour trois autres conteneurs**. **Décision sur les instantanés.** Les sept dataient de décembre 2022 – janvier 2023 : `postfix`, `phpmyadmin`, `ISPconfig_clean`, `fullinstall`, `fullinstallwscripts`, `installationcomplete`. Des jalons de construction, pas des points de restauration — et rien des trois années suivantes. Ils restent conservés à deux endroits : sur l'original LXD gardé en filet, et dans l'archive d'export (`lxc export` les emporte par défaut). Donc : ``` incus copy lxd-local:web-srv web-srv --instance-only ``` Copie plus rapide, pool Incus qui démarre propre. **Vérifier AVANT de démarrer** — c'est le seul conteneur où ça compte vraiment : ``` incus config show web-srv ``` Deux clés à confirmer impérativement : ```yaml security.privileged: "true" devices: web: path: /var/www source: /dev/disk/by-id/scsi-SATA_...-part2 ``` Si le périphérique `web` manquait, Apache démarrerait sur un `/var/www` vide. **Vérification en couches, du plus bas au plus haut :** ``` incus exec web-srv -- ip -br addr ``` ``` incus exec web-srv -- df -h /var/www ``` → doit annoncer **1,7 To** : preuve que le disque hôte est monté dans la copie. ``` incus exec web-srv -- systemctl is-active apache2 mysql radicale bind9 ``` **Puis le démon rsync, avant tout le reste** — c'est lui qui sert les sauvegardes nocturnes : ``` incus exec web-srv -- ss -lntp | grep 873 ``` ``` rsync --list-only rsync://192.168.0.6/ ``` Cette dernière commande, lancée **depuis l'hôte** (la machine qui fait les sauvegardes), teste exactement le chemin de production. Elle a révélé 14 modules là où la crontab n'en exploite que 6 — dont `courriel`, `radicale_users` et `radicale_data`, qui mériteraient d'être sauvegardés eux aussi. **Enfin le navigateur**, en n'oubliant pas ce que ce conteneur publie pour les autres : | À vérifier | Ce que ça teste | |---|---| | `https://blog.example.com`, `https://photos.example.com` | les sites eux-mêmes | | ISPConfig | l'administration | | `https://musique.example.com` | le **proxy inverse** vers Navidrome | | `https://lt.example.com/v2/languages` | le **proxy inverse** vers LanguageTool | | `https://routeur.example.com` | le **proxy inverse** vers l'admin du routeur | | Le cadenas | le certificat wildcard `*.example.com` | Si `musique.example.com` échoue alors que `192.168.0.8` répond, c'est Apache qu'il faut regarder, pas Navidrome. --- ## Étape 13 — Interface web d'Incus ``` incus config set core.https_address 192.168.0.11:8443 ``` ⚠️ Lier à l'IP d'administration, **jamais `:8443` tout court** sur une machine dont le 443 est redirigé depuis Internet. L'authentification se fait par **certificat client**, pas par mot de passe — l'API donne un contrôle équivalent à root. Le CLI le fait sans qu'on s'en aperçoive (`~/.config/incus/`) ; le navigateur exige une manipulation manuelle. **Ordre obligatoire, et il n'est pas évident :** 1. Onglet **« Browser certificate »** → **Generate**. C'est cette page qui *crée* le certificat. 2. Télécharger le **`.pfx`**, l'importer dans Firefox : `about:preferences#privacy` → Certificats → Afficher les certificats → **Vos certificats** → Importer. **Mot de passe vide** si aucun n'a été défini. 3. **Redémarrer Firefox** complètement. 4. Rouvrir l'URL. Deux dialogues apparaissent, il faut répondre aux deux : - la boîte « X vous demande de vous identifier » → choisir le certificat, **De façon permanente**, OK - la page `SELF_SIGNED_CERT` → **Continuer** (c'est le certificat *du serveur*, auto-signé) 5. Seulement maintenant, l'onglet **« Identity trust token »** devient utilisable — son avertissement jaune disparaît. Générer un jeton et le coller : ``` incus config trust add incus-ui ``` **Pièges rencontrés :** - `incus config trust add --name X` n'existe pas. Le nom est **positionnel** : `incus config trust add X`. Divergence de syntaxe avec LXD. - Un jeton est **à usage unique** : la première soumission consomme l'opération en attente, qu'elle aboutisse ou non. Un échec suivi d'une nouvelle tentative donne `No matching certificate add operation found`. Regénérer plutôt que réessayer. - Les jetons LXD et Incus ont un format identique. Vérifier le champ `addresses` : `127.0.0.1:8443` = LXD, `192.168.0.11:8443` = Incus. - Le certificat du client CLI n'apparaît **pas** dans `incus config trust list` : il passe par le socket Unix, autorisé par le groupe `incus-admin`. Seuls les clients réseau ont besoin d'un certificat. --- ## Étape 14 — Nettoyage final _(à faire après quelques jours de stabilité)_ Ne rien retirer avant d'avoir vécu au moins une nuit de sauvegardes et un redémarrage complet de l'hôte. **Retirer LXD Mosaic**, devenu inutile : ``` sudo snap remove lxdmosaic ``` **Retirer le remote et refermer l'API de LXD :** ``` incus remote remove lxd-local ``` ``` lxc config unset core.https_address ``` **Retirer LXD**, une fois la confiance acquise : ``` sudo snap remove lxd ``` ⚠️ Cette commande **détruit les conteneurs LXD originaux** — le filet de retour — ainsi que le pool `default.img` de 27,5 Go et le pont `lxdbr0`. Les archives d'export sur `/media/nas1/Backups/lxd-images_pre-migration/` restent, elles, la dernière ligne de défense. Vérifier ensuite que l'espace est bien rendu : ``` df -h / ``` ``` zpool list ``` Seul `local` doit subsister. --- ## Étape 15 — Le redémarrage de validation **Ne rien retirer avant d'avoir redémarré l'hôte au moins une fois.** C'est ce test qui révèle ce que la migration a laissé passer — et il a effectivement révélé quelque chose. ### ⚠️ Ce qui s'est produit Au redémarrage, `incus list` montrait les cinq conteneurs Incus corrects. Mais `lxc list` montrait : ``` | web-srv | RUNNING | 192.168.0.6 (eth0) | ``` **Deux conteneurs sur la même IP, montant le même disque hôte.** La commande `lxc config set web-srv boot.autostart false` avait été donnée dans la procédure mais jamais exécutée — et personne ne l'avait vérifiée avant de déclarer la migration terminée. **Correction immédiate :** ``` lxc stop web-srv ``` ``` lxc config set web-srv boot.autostart false ``` **Bilan des dégâts : aucun.** Un `find /var/www -newermt '-1 hour' -type f` n'a révélé que des journaux d'accès et un fichier de cache Matomo. Rien des données de Radicale ni du courriel. Sur le disque partagé : Linux détecte qu'un périphérique bloc est déjà monté et **partage le superbloc existant** plutôt que d'en créer un second — le deuxième montage se comporte comme un montage lié. La corruption survient quand deux noyaux indépendants écrivent, pas dans ce cas. Le conflit d'adresse IP, lui, était bien réel. **Leçon :** après chaque migration, **vérifier** que `boot.autostart false` a bien été appliqué à l'original, plutôt que de supposer que la commande a été lancée. ``` lxc list ``` Les cinq doivent être `STOPPED`. C'est le seul contrôle qui compte au premier redémarrage. ### Le reste du bilan post-redémarrage ``` sudo zpool list ``` Les **deux** pools doivent être importés. `default` compte encore : c'est le filet de retour. ``` systemctl --failed ``` Ce tamis a révélé deux dettes anciennes, **sans rapport avec la migration** : - **`lxdui.service`** — une interface LXD abandonnée depuis 2022, en échec à chaque démarrage. `sudo systemctl disable --now lxdui.service` - **`postfix@-.service`** — `postconf: fatal: open /etc/postfix/main.cf: No such file or directory`, **à chaque démarrage depuis au moins juillet**. Le `master.cf` daté de 2022 prouve qu'il avait été configuré ; `main.cf` a disparu depuis. ⚠️ **Conséquence de ce second point :** l'hôte ne pouvait plus envoyer de courriel. Or les scripts de sauvegarde commencent par un `echo` que cron expédie par courriel. **Les rapports de sauvegarde se perdaient depuis des mois.** ### Vérifier que les sauvegardes ont bien tourné ⚠️ **Ne pas se fier aux dates des fichiers sauvegardés.** `rsync -a` préserve la date de la source : le fichier le plus récent dans la sauvegarde porte la date qu'il avait sur l'origine, pas celle de la copie. Même piège que la date d'un répertoire, qui ne change que si on y ajoute ou retire une entrée. La question se pose au **cron** : ``` journalctl -u cron.service --since "2026-08-31 23:00" --no-pager | grep -i backup ``` (`journalctl` n'analyse pas les dates en français — utiliser le format ISO plutôt que « hier ».) ### Remplacer le courriel par un journal Plus robuste qu'un MTA : rediriger la sortie des tâches vers des fichiers datés. ``` sudo mkdir -p /var/log/backup ``` ``` sudo chown hostadmin:hostadmin /var/log/backup ``` Le `chown` est indispensable — les tâches tournent sous `hostadmin`, qui ne peut pas écrire dans `/var/log`. Puis dans `crontab -e` : ``` MAILTO="" 15 00 * * * sh /home/hostadmin/scripts/rsync/auto/backup_mysql-ns2.sh >> /var/log/backup/$(date +\%F).log 2>&1 … 30 03 1 * * find /var/log/backup/ -name '*.log' -mtime +180 -delete ``` ⚠️ **Une ligne de cron ne se coupe pas.** Cron n'accepte aucune continuation par barre oblique inverse : chaque tâche tient sur une seule ligne. Dans le PDF elle est enroulée sans signe visible — la recopier depuis le fichier `.md`, jamais depuis le PDF. ⚠️ **Le `\%` est obligatoire.** Dans une crontab, `%` marque une fin de ligne et bascule le reste vers l'entrée standard de la commande. Un `date +%F` non échappé casse la ligne de façon parfaitement déroutante. C'est l'un des pièges les plus classiques de cron. - `2>&1` capture aussi les erreurs — sans lui, seuls les succès seraient journalisés. - `MAILTO=""` dit à cron de ne rien tenter d'envoyer. Enfin, faire taire le MTA devenu inutile : ``` sudo systemctl disable --now postfix@-.service ``` ``` sudo systemctl disable --now postfix.service ``` **Tester sans attendre le lendemain** — dans le shell, `%F` s'écrit sans échappement : ``` sh /home/hostadmin/scripts/rsync/auto/backup_scripts.sh \ >> /var/log/backup/$(date +%F).log 2>&1 ``` ``` cat /var/log/backup/$(date +%F).log ```