Migrer de DigitalOcean vers Hetzner
(isayeter.com)- Une infrastructure de production à 1 432 $/mois a été déplacée vers un serveur dédié à 233 $/mois tout en changeant aussi de système d’exploitation, sans interruption de service
- Les 30 bases de données MySQL et 34 hôtes virtuels Nginx, ainsi que GitLab EE, Neo4J, Supervisor et Gearman, ont été recréés à l’identique sur le nouveau serveur, puis migrés via réplication en temps réel et synchronisation incrémentale finale
- Le point clé de la migration des bases de données a été la combinaison de mydumper·myloader en parallèle et de la réplication MySQL, avec correction des problèmes de schéma
syset de permissions apparus lors du passage de MySQL 5.7 à 8.0 - Le basculement s’est déroulé dans l’ordre suivant : réduction du DNS TTL, conversion du Nginx de l’ancien serveur en reverse proxy, puis modification groupée des enregistrements A, de sorte que les requêtes arrivant sur l’ancienne IP pendant la propagation DNS étaient toujours transmises au nouveau serveur
- Résultat : 1 199 $ économisés par mois, 14 388 $ par an, avec plus de CPU, de mémoire et de stockage, ainsi que 0 minute d’interruption
Contexte de la migration
- Dans le contexte d’une entreprise logicielle opérant en Turquie, la forte inflation et la faiblesse de la livre turque ont considérablement accru le poids des coûts d’infrastructure libellés en dollars
- Le coût mensuel de l’ancien serveur DigitalOcean était de 1 432 $, pour une configuration comprenant 192 Go de RAM, 32 vCPU, 600 Go de SSD, 2 volumes bloc de 1 To, avec sauvegardes incluses
- La nouvelle cible était un serveur dédié Hetzner AX162-R, avec AMD EPYC 9454P 48 cœurs 96 threads, 256 Go de DDR5 et 1,92 To de NVMe Gen4 en RAID1
- Le coût mensuel est descendu à 233 $, soit une économie de 1 199 $ par mois et 14 388 $ par an
- Il n’y avait pas de plainte concernant la fiabilité de l’ancien serveur ni l’expérience développeur, mais pour une charge de travail steady-state, le rapport prix/performance n’était plus raisonnable
Environnement d’exploitation existant
- La stack en production n’était pas un simple environnement de test, mais une véritable configuration de production
- 30 bases de données MySQL, pour un total de 248 Go de données
- 34 hôtes virtuels Nginx répartis sur plusieurs domaines
- GitLab EE avec 42 Go de sauvegardes
- Neo4J Graph DB exploité à hauteur de 30 Go
- Supervisor pour gérer des dizaines de workers en arrière-plan
- Utilisation de la file de tâches Gearman
- Une application mobile en production pour plusieurs centaines de milliers d’utilisateurs
- Le système d’exploitation de l’ancien serveur était CentOS 7, déjà en fin de support
- Le système du nouveau serveur est AlmaLinux 9.7, une distribution compatible RHEL 9 et un successeur naturel à CentOS
- Cette migration n’était donc pas seulement motivée par les coûts, mais aussi par la sortie d’un système d’exploitation privé de mises à jour de sécurité depuis plusieurs années
Stratégie sans interruption
- Un simple changement DNS avec redémarrage des services n’était pas acceptable ; la migration a donc été menée en 6 étapes pour garantir l’absence d’interruption
-
Étape 1 : installation de la stack complète sur le nouveau serveur
- Installation de Nginx en le compilant depuis les sources avec les mêmes flags que l’ancien
- Installation de PHP via le repo Remi, avec reprise des mêmes fichiers de configuration
.inique sur l’ancien serveur - Installation de MySQL 8.0, Neo4J Graph DB, GitLab EE, Node.js, Supervisor et Gearman, configurés pour reproduire le comportement existant
- Tous les services ont été alignés sur le comportement de l’ancien serveur avant toute modification des enregistrements DNS
- Les certificats SSL ont été copiés via rsync en transférant tout le répertoire
/etc/letsencrypt/depuis l’ancien serveur - Une fois l’ensemble du trafic basculé vers le nouveau serveur, un renouvellement forcé global des certificats a été effectué avec
certbot renew --force-renewal
-
Étape 2 : réplication des fichiers web via rsync
- Environ 65 Go et 1,5 million de fichiers dans tout le répertoire
/var/www/htmlont été copiés viarsyncsur SSH - L’option
--checksuma servi à vérifier l’intégrité - Une synchronisation incrémentale finale a été effectuée juste avant le cutover pour intégrer les fichiers modifiés
- Environ 65 Go et 1,5 million de fichiers dans tout le répertoire
-
Étape 3 : réplication maître-esclave MySQL
- Plutôt que d’arrêter les bases pour faire un dump puis une restauration, une réplication en temps réel a été mise en place
- L’ancien serveur a été configuré comme maître, le nouveau comme esclave en lecture seule
- Le chargement initial massif s’est fait avec
mydumper, puis la réplication a démarré à partir de la position binlog exacte enregistrée dans les métadonnées du dump - Jusqu’au moment du basculement, les deux bases sont restées synchronisées en temps réel
-
Étape 4 : réduction du DNS TTL
- Un script a appelé l’API DNS de DigitalOcean pour réduire le TTL de tous les enregistrements A/AAAA de 3600 secondes à 300 secondes
- Les enregistrements MX et TXT n’ont pas été modifiés
- Ils ont été exclus afin d’éviter d’éventuels problèmes de délivrabilité liés au changement de TTL des enregistrements mail
- Après une heure d’attente pour laisser l’ancien TTL expirer partout, le basculement pouvait être effectué avec une propagation ramenée à 5 minutes
-
Étape 5 : conversion du Nginx de l’ancien serveur en reverse proxy
- Un script Python a analysé les blocs
server {}des 34 configurations de sites Nginx - Les configurations existantes ont été sauvegardées puis remplacées par des réglages de proxy pointant vers le nouveau serveur
- Ainsi, pendant la propagation DNS, les requêtes arrivant encore sur l’ancienne IP étaient silencieusement relayées vers le nouveau serveur
- Du point de vue des utilisateurs, il n’y avait aucune interruption visible
- Un script Python a analysé les blocs
-
Étape 6 : basculement DNS et arrêt de l’ancien serveur
- Un script Python a appelé l’API DigitalOcean pour changer en quelques secondes tous les enregistrements A vers l’IP du nouveau serveur
- L’ancien serveur a été conservé pendant une semaine en cold standby, puis arrêté
- Pendant tout le processus, le service continuait à répondre soit directement, soit via le proxy, sans aucune fenêtre d’indisponibilité
Migration MySQL
- La partie la plus complexe de l’ensemble de l’opération a été la migration MySQL
-
Dump des données
- mydumper a été utilisé à la place du
mysqldumpstandard - Grâce à l’export/import parallèle exploitant les 48 cœurs CPU du nouveau serveur, une opération qui aurait pris plusieurs jours avec
mysqldumpmono-thread a été ramenée à quelques heures - Les principales options utilisées comprenaient
--threads 32,--compress,--trx-consistency-only,--skip-definer,--chunk-filesize 256 - Le fichier
metadatadu dump principal enregistrait la position binlog au moment du snapshotFile: mysql-bin.000004Position: 21834307
- Ces valeurs ont ensuite servi de point de départ pour la réplication
- mydumper a été utilisé à la place du
-
Transfert du dump
- Une fois le dump terminé, il a été transféré vers le nouveau serveur via rsync sur SSH
- Au total, 248 Go de chunks compressés ont été envoyés
- L’option
--compressdemydumpera contribué à améliorer la vitesse de transfert réseau
-
Chargement des données
myloadera été utilisé- Les principales options étaient
--threads 32,--overwrite-tables,--ignore-errors 1062,--skip-definer
-
Problèmes lors du passage de MySQL 5.7 à 8.0
- En raison de l’environnement CentOS 7, l’ancien serveur était resté bloqué sur MySQL 5.7
- Avant la migration,
mysqlcheck --check-upgradea permis de vérifier la compatibilité des données avec MySQL 8.0, sans problème détecté - Le nouveau serveur a reçu la dernière version de MySQL 8.0 Community
- Les temps d’exécution des requêtes ont nettement baissé sur l’ensemble des projets, l’article original l’attribuant au meilleur optimizer et aux améliorations d’InnoDB dans MySQL 8.0
- Mais le saut de version a aussi causé des problèmes
- Après l’import, la table
mysql.userne présentait que 45 colonnes au lieu des 51 attendues - Résultat : absence de
mysql.infoschemaet dysfonctionnements de l’authentification utilisateur
- Après l’import, la table
- La première tentative de correction a consisté à exécuter les commandes suivantes
systemctl stop mysqldmysqld --upgrade=FORCE --user=mysql &
- La première tentative a échoué avec l’erreur
ERROR: 'sys.innodb_buffer_stats_by_schema' is not VIEW - La cause était que le schéma sys avait été importé comme une table ordinaire au lieu d’une vue
- La solution a consisté à exécuter
DROP DATABASE sys;puis relancer l’upgrade - Après cela, tout s’est terminé correctement
Configuration de la réplication MySQL
- Une fois le chargement du dump terminé sur les deux serveurs, le nouveau serveur a été configuré comme réplique de l’ancien
- La commande
CHANGE MASTER TOspécifiait l’IP de l’ancien serveur, l’utilisateur de réplication, le port 3306,MASTER_LOG_FILE='mysql-bin.000004',MASTER_LOG_POS=21834307 - Ensuite,
START SLAVE;a été exécuté - Presque immédiatement, la réplication s’est arrêtée avec une error 1062 Duplicate Key
- La cause était que le dump avait été réalisé en deux temps, et que des écritures étaient intervenues sur certaines tables entre les deux, si bien que le dump importé et la relecture du binlog tentaient d’insérer en double les mêmes lignes
- Pour corriger cela, la configuration suivante a été appliquée
SET GLOBAL slave_exec_mode = 'IDEMPOTENT';START SLAVE;
- Le mode IDEMPOTENT ignore silencieusement les erreurs de clé dupliquée et de ligne manquante
- Toutes les bases de données essentielles se sont synchronisées sans autre erreur, et la valeur
Seconds_Behind_Masterest retombée à 0 en quelques minutes
Vérification avant le cutover
- Avant toute modification des enregistrements DNS, il fallait vérifier que tous les services fonctionnaient correctement sur le nouveau serveur
- La méthode utilisée consistait à modifier temporairement le fichier
/etc/hostssur la machine locale pour faire pointer les domaines vers l’IP du nouveau serveur - Le navigateur et Postman envoyaient alors les requêtes vers le nouveau serveur, tandis que les utilisateurs externes continuaient d’accéder à l’ancien
- Les endpoints API, le panneau d’administration et l’état de réponse de chaque service ont été vérifiés
- Une fois tous les éléments validés, le cutover réel a été lancé
Problème de privilège SUPER
- Une fois la réplication maître-esclave totalement synchronisée, il a été constaté que des INSERT réussissaient sur le nouveau serveur alors que
read_only = 1 - La cause était que tous les utilisateurs PHP des applications disposaient du privilège SUPER
- Dans MySQL, le privilège SUPER contourne
read_only - Le résultat de
SHOW GRANTS FOR 'some_db_user'@'localhost';a confirmé la présence du privilègeSUPER - La commande
REVOKE SUPER ON *.* FROM 'some_db_user'@'localhost';a été répétée pour 24 utilisateurs applicatifs au total - Ensuite,
FLUSH PRIVILEGES;a été exécuté - À partir de là,
read_only = 1a correctement bloqué les écritures applicatives tout en continuant à autoriser la réplication
Préparation DNS
- Tous les domaines étaient gérés via DigitalOcean DNS, avec des serveurs de noms reliés chez GoDaddy
- La réduction du TTL a été scriptée contre l’API DigitalOcean
- Seuls les enregistrements A et AAAA étaient concernés
- Les enregistrements MX et TXT n’ont pas été modifiés
- En raison de possibles problèmes de délivrabilité avec Google Workspace, les TTL liés au mail ont été exclus des modifications
- Après une heure d’attente pour l’expiration de l’ancien TTL, le cutover pouvait être lancé
Conversion du Nginx de l’ancien serveur en reverse proxy
- Plutôt que d’éditer manuellement 34 fichiers de configuration, la transformation a été automatisée via un script Python
- Le script analysait les blocs
server {}de tous les fichiers de configuration, identifiait le bloc de contenu principal, puis le remplaçait par une configuration de proxy - Les configurations d’origine ont été sauvegardées dans des fichiers
.backup - L’exemple de configuration appliquait
proxy_pass https://NEW_SERVER_IP;,proxy_set_header Host $host;,proxy_set_header X-Real-IP $remote_addr;,proxy_read_timeout 150; - L’option clé était
proxy_ssl_verify off- Parce que les certificats SSL du nouveau serveur étaient valides pour les domaines, mais pas pour l’adresse IP
- Comme les deux extrémités étaient sous contrôle, la désactivation de la vérification était acceptable dans ce contexte
Procédure de cutover
- Juste avant le basculement, les conditions étaient un retard de réplication à
Seconds_Behind_Master: 0et un reverse proxy prêt à fonctionner - L’ordre d’exécution était le suivant
- Sur le nouveau serveur,
STOP SLAVE; - Sur le nouveau serveur,
SET GLOBAL read_only = 0; - Sur le nouveau serveur,
RESET SLAVE ALL; - Sur le nouveau serveur,
supervisorctl start all - Sur l’ancien serveur,
nginx -t && systemctl reload nginxpour activer le proxy - Sur l’ancien serveur,
supervisorctl stop all - Depuis le Mac local, exécution de
python3 do_cutover.pypour changer tous les enregistrements A du DNS vers l’IP du nouveau serveur - Attente d’environ 5 minutes pour la propagation
- Commentaire de toutes les entrées crontab sur l’ancien serveur
- Sur le nouveau serveur,
- Le script de cutover DNS appelait l’API DigitalOcean pour modifier tous les enregistrements A en environ 10 secondes
Travaux complémentaires après le cutover
- Une fois la migration terminée, il a été constaté que de nombreux webhooks de projets GitLab pointaient encore vers l’IP de l’ancien serveur
- Un script a été écrit et appliqué pour scanner tous les projets via l’API GitLab et mettre à jour en masse les webhooks
Résultat final
- Le coût mensuel est passé de 1 432 $ à 233 $
- L’économie annuelle atteint 14 388 $
- Un serveur plus puissant a aussi été obtenu
- Le CPU est passé de 32 vCPU à 96 CPU logiques
- La RAM est passée de 192 Go à 256 Go de DDR5
- Le stockage est passé d’une configuration mixte d’environ 2,6 To à 2 To de NVMe en RAID1
- Le temps d’interruption a été de 0 minute
- La migration complète a pris environ 24 heures
- Aucun impact utilisateur n’a été observé
Enseignements clés
- La réplication MySQL est l’outil central d’une migration sans interruption
- Il faut la mettre en place tôt, lui laisser le temps de rattraper son retard, puis effectuer le cutover
- Il faut absolument vérifier les privilèges des utilisateurs MySQL avant la migration
- Avec le privilège SUPER,
read_onlypeut être contourné, ce qui signifie qu’un esclave supposé en lecture seule ne l’est pas réellement
- Avec le privilège SUPER,
- Les mises à jour DNS, les modifications de configuration Nginx et les corrections de webhooks gagnent à être scriptées
- Traiter manuellement plus de 34 sites prend du temps et augmente le risque d’erreur
- La combinaison mydumper + myloader est bien plus rapide que
mysqldumpsur de gros jeux de données- Le dump et la restauration en parallèle sur 32 threads réduisent à quelques heures un travail qui aurait demandé plusieurs jours
- Sur des charges steady-state, les fournisseurs cloud peuvent coûter cher, et un serveur dédié peut offrir de meilleures performances à moindre coût
Scripts GitHub
- Tous les scripts Python utilisés pour la migration ont été publiés sur GitHub
- Liste des scripts inclus
do_list_domains_ttl.py- Liste les enregistrements A, les IP et les TTL de tous les domaines DigitalOcean
do_ttl_update.py- Réduit en masse le TTL de tous les enregistrements A/AAAA à 300 secondes
do_to_hetzner_bulk_dns_records_import.py- Migre toutes les zones DNS de DigitalOcean vers Hetzner DNS
do_cutover_to_new_ip.py- Bascule tous les enregistrements A de l’IP de l’ancien serveur vers celle du nouveau
nginx_reverse_proxy_update.py- Convertit toutes les configurations de sites nginx en configuration de reverse proxy
mysql_compare.py- Compare le nombre de lignes de toutes les tables entre deux serveurs MySQL
final_gitlab_webhook_update.py- Met à jour tous les webhooks des projets GitLab vers l’IP du nouveau serveur
mydumper- Bibliothèque mydumper
- Tous les scripts prennent en charge un mode
DRY_RUN = Truepour permettre une prévisualisation sûre avant application réelle
Aucun commentaire pour le moment.