Lorsqu'un site Web nécessite une haute disponibilité et que toute coupure de service, même de courte durée et planifiée est problématique, la mise en production d'une nouvelle version du code est toujours délicate.
C'est en particulier le cas sur la plateforme du quotidien Libération, où toute coupure de service, que ce soit pour les visiteurs du site en quête d'information ou pour la rédaction du journal, est à éviter.
Cet article explique les différents problèmes auxquels nous avons été confrontés pour obtenir un processus de mise en production simple, automatisé le plus possible, mais n'entraînant aucune coupure de service. Ainsi que, bien sûr, les solutions que nous avons retenues.
Une architecture Web de haute disponibilité possède en général au minium trois composants :
La manière de redonder la base de données et le load-balancer n'entrent pas en compte lorsqu'il s'agit de la mise en production.
Cependant, le load-balancer lui-même importe, nous verrons exactement comment. Le load-balancer que nous utilisons est haproxy.
Le mode d'opération pour une mise en production manuelle est le suivant :
Ce fonctionnement semble simple, mais il y a beaucoup de détails à prendre en compte. Par exemple, bien attendre que les requêtes en cours sur un frontal soient traitées avant de commencer à effectuer des mises en production dessus. Ou alors, ne pas oublier de couper les éventuels cron (tâches programmées de manière régulière) pendant la mise en production.
Effectuer toutes les opérations à la main est fastidieux, même avec notre deuxième meilleur ami historique du shell (le meilleur ami étant bien sûr grep). Et comme dans toute opération manuelle, il existe un risque d'erreur ou d'oubli. Il nous a donc fallu réaliser des scripts pour ces opérations.
Afin de garder une possibilité d'effectuer des mises en production de manière partiellement manuelle si nécessaire, de simplifier les tests des scripts et de manière générale de garder une grande flexibilité, nous avons opté pour une architecture très modulaire :
- Vérifier qu'il n'est plus en train de répondre à des requêtes.
- Arrêter les cron et autre démons.
- Arrêter le service lui-même.
- Nettoyer les fichiers .pyc par mesure de sécurité.
- Lancer le premier script.
- Relancer le service et les cron/démons.
La première consiste à pouvoir contrôler le load-balancer simplement, sans créer de coupure. Le load-balancer que nous utilisons, haproxy, possède un moyen de contrôle simple et efficace : une socket Unix qui permet de recevoir des commandes.
Il est possible d'activer/de désactiver des frontaux applicatifs avec des commandes simples du type :
echo "disable server webfarm/djazfront1" | socat stdio /var/run/haproxy.stat
Lorsqu'un frontal est désactivé ainsi, haproxy n'enverra plus de nouvelles requêtes vers le serveur en question. Les requêtes déjà envoyées vers ce frontal mais non encore traitées ne sont pas affectées, ce qui correspond parfaitement à nos besoins d'éviter toute coupure, mais nécessite une précaution supplémentaire.
Puisque les requêtes déjà en cours ne sont pas affectées, il ne faut pas commencer la mise en production sur un frontal qui vient d'être retiré du load-balancer avant que toutes les requêtes en cours ne soient traitées.
La solution que nous avons retenue consiste à utiliser le programme netstat qui donne des informations sur la couche réseau (processus en écoute, socket ouvertes, etc.), et de vérifier qu'il n'y a plus de connexions actives (en état ESTABLISHED) vers le processus Apache, avec une commande du type :
netstat -anp | grep -q ESTABLISHED.*apache
Par mesure de sécurité, nous attendons que pendant trois secondes consécutives aucune connexion ne soit active vers le processus Apache, avant de considérer le frontal comme disponible.
Afin de gérer l'accès à distance pour le meta-meta-script, nous utilisons les outils ssh et sudo. En particulier, nous utilisons des clés ssh pour permettre de se connecter sans mot de passe, et une configuration qui permet de lancer les autres scripts en root lorsque nécessaire, depuis un compte dédié aux mises en production, le tout sans effectuer de demande de mot de passe, mais uniquement pour ces commandes là.
Parmi les nombreux amis de l'administrateur Unix, il en est un qui nous est critique pour ces mises en production : l'outil GNU screen.
Cet outil rend énormément de services, mais dans notre cas précis, il permet de s'assurer que même si la connexion Internet entre nos bureaux (ou éventuellement le domicile, voire la gare ou l'aéroport...) d'où nous effectuons la mise en production et le datacenter où se trouvent les serveurs venait à sauter, la mise en production se termine sans encombre.
L'astuce consiste à lancer le script à l'intérieur d'un screen, sur l'une des machines du parc de serveurs. Ainsi, si la connexion venait à sauter, les opérations continueraient, et il nous serait possible de récupérer le screen par la suite pour en vérifier le bon déroulement.
L'implémentation du méta-méta-script (celui qui se connecte partout et lance les opérations) possède quelques subtilités techniques qui sont intéressantes à évoquer.
Pour minimiser le temps de la mise en production (pendant lequel la plateforme, ne fonctionnant que sur la moitié de ses frontaux, n'est plus aussi robuste en cas de pic de charge), les opérations sont effectuées en parallèle sur plusieurs frontaux.
Il est donc nécessaire de multiplexer les flux, c'est à dire d'afficher sur le terminal les sorties des commandes sur plusieurs frontaux à la fois, en les préfixant du nom du frontal considéré.
La solution la plus simple pour effectuer ceci en Python est de passer par les threads : au lieu de lancer directement une commande, nous lançons un thread, qui lui exécute la commande, récupère la sortie, et l'écrit, préfixée du nom de la machine, à l'écran.
Une subtilité de Python est à noter ici : utiliser la syntaxe for line in file pose de problèmes de buffering, et provoque l'arrivée des lignes par bloc, au lieu de leur arrivée au fur et à mesure. Il est nécessaire de les lire plus manuellement, en utilisant la méthode readline des objets fichiers.
« Des erreurs ? Quelles erreurs ? Il n'y pas d'erreurs dans mon code ! » est hélas rarement applicable. Il faut donc gérer les erreurs. Qui peuvent survenir à tous les niveaux.
Il y a deux types d'erreur, au niveau système :
Notre solution est de considérer les erreurs de type 1 (l'une des commandes se termine avec un code différent de 0) comme une erreur fatale, qui interrompt immédiatement le processus de mise en production, et demande une intervention manuelle. Sur un script shell, nous utilisons un set -x qui implémente ce comportement, sur un script Python, nous effectuons le test et l'arrêt nous-même.
Pour les erreurs de type 2, il peut s'agir d'un simple avertissement. Nous avons donc opté pour le mode de fonctionnement suivant : les erreurs affichées par les différents scripts et programmes sur l'erreur standard sont remontées comme les messages normaux, et n'interrompent pas le fonctionnement du programme.
En plus de les afficher au fur et à mesure, nous conservons les messages d'avertissement à part, et les affichons sous forme de récapitulatif à la fin de l'ensemble des opérations, pour être sûr que l'utilisateur du script les ait prises en compte.
Et c'est là que tout se complique. Tant qu'on gère la sortie d'erreur comme la sortie standard, on peut fusionner les deux flux au niveau du module subprocess de Python, et les traiter de notre côté comme un seul fichier. Le thread est alors simple : il lit ce fichier, préfixe, écrit sur le terminal.
Gérer la sortie d'erreur séparément nous oblige à utiliser le module select de Python, qui est une très fine surcouche à la fonction select du C. Cette fonction prend en paramètre une liste de fichiers. Elle attend ensuite qu'au moins un de ces fichiers soit disponible et nous informe duquel (ou desquels) il s'agit.
En réalité elle prend trois listes de fichiers en paramètre pour attendre des conditions différentes (lecture, écriture et exceptions), mais nous n'utiliserons ici que la première liste : en lecture.
Nous devons donc effectuer un appel du type :
res, _, _ = select.select([child.stdout, child.stderr], [], [])
Et ensuite regarder ce qui se trouve dans res. Si nous y trouvons child.stdout, un message normal est disponible. Si nous y trouvons child.stderr un message d'erreur est disponible. Il est possible d'avoir les deux à la fois, si deux messages ont été mis très rapidement.
Nous avons évoqué les principaux problèmes et leurs solutions pour effectuer des scripts de mise en production sur une plateforme Web comme celle de Libération.
Mais nous sommes restés ici dans les cas simples : lorsqu'il n'y a aucune modification à effectuer sur le schéma de données. Gérer ces cas étant beaucoup plus délicat, nous n'aborderons pas le problème ici. Ils sont cependant rares, et peuvent donc être traités manuellement au cas par cas.
Actions sur le document