D'un cluster nu à un environnement de staging réconcilié depuis git
Comment un cluster Talos nu est devenu un staging qu'Argo CD réconcilie depuis git, avec ses secrets, ses certificats et ses deux origines web.
Le 19 septembre, le staging servait le site public et l'application derrière un certificat reconnu par les navigateurs, et aucun pipeline ne détenait d'identifiant pour le cluster. Une fois le cluster lui-même amorcé, OpenTofu installe Argo CD et une seule Application racine, et s'arrête là. Tout le reste est un fichier commité dans un répertoire, si bien que ce qui tourne est une fonction de ce qui est commité.
Neuf jours plus tôt, le cluster savait planifier un pod et rien d'autre. Voici comment l'écart entre les deux a été comblé, dans l'ordre où il fallait le faire.
Une troisième racine, et l'endroit où OpenTofu s'arrête#
Argo CD a besoin de providers Helm et Kubernetes configurés à partir du kubeconfig que produit la couche cluster. La configuration des providers est évaluée au moment du plan, donc elle ne pouvait pas vivre dans la racine qui crée le cluster : elle a reçu une troisième racine, qui lit l'état du cluster.
Cette racine installe Argo CD et une seule Application racine pointée sur le répertoire de l'environnement dans git. Chaque composant suivant est une Application enfant commitée à cet endroit. J'ai tenu cette ligne volontairement, parce que chaque composant ajouté à OpenTofu à la place serait une chose de plus que personne ne corrige quand elle dérive.
L'Application racine passe par un petit chart Helm plutôt que par la ressource de manifeste générique d'OpenTofu, parce que cette ressource fait un dry-run contre le serveur au moment du plan, et la définition de ressource personnalisée de l'Application n'existe pas tant que le même apply n'a pas installé Argo CD.
Le secret du webhook arrive avec l'installation. Sans secret, le handler de webhook d'Argo CD ne valide aucune signature, et ajouter le secret plus tard laisserait cette porte ouverte le jour où une route apparaîtrait. Le serveur Argo CD lui-même n'a toujours pas de route publique : elle permettrait à la périphérie de lire les identifiants du repo dans son namespace. Le projet par défaut est restreint à ce repo, pour qu'une Application destinée au projet clôturé des clients ne puisse pas atterrir dans celui par défaut et contourner la clôture. Prune et self-heal démarrent désactivés, et une Application ne les reçoit qu'une fois son arbre vu rendre le cluster réel à l'identique, ou, comme celle du site, quand elle est seule dans son namespace.
Des secrets qui ne touchent jamais l'état#
L'identifiant d'une charge de travail n'avait qu'un chemin vers le cluster : une racine OpenTofu qui le plante, ce qui met du matériel secret dans l'état et fait de chaque rotation un apply. External Secrets lit désormais le Secret Manager de Scaleway à travers un seul store à l'échelle du cluster, et Reloader redémarre tout ce qui consomme un Secret modifié.
Quelques secrets ne peuvent pas arriver par ce chemin, parce qu'ils doivent exister avant External Secrets : la clé avec laquelle Argo CD lit git, par exemple. Le runbook des secrets nomme chacune de ces exceptions, si bien qu'une nouvelle doit être argumentée plutôt qu'ajoutée.
Pour prouver le chemin complet plutôt que lui faire confiance, un déploiement canari journalise l'empreinte d'un secret qui ne protège rien. Augmenter la version de ce secret dans Secret Manager doit changer l'empreinte que journalise le pod en marche, sans toucher à rien d'important.
Un seul certificat wildcard, et un registrar que le cluster ne peut pas toucher#
Un certificat wildcard demande le challenge DNS-01, et cert-manager n'a pas de solveur pour le registrar qui détient la zone. L'alternative par webhook aurait donné au cluster un jeton capable de réécrire une zone qui porte aussi le courrier de l'entreprise. Le challenge est plutôt délégué à un acme-dns qui tourne dans le cluster, ce qui cantonne le cluster aux enregistrements TXT d'une seule inscription. Les inscriptions sont désactivées, son API de mise à jour n'est joignable que depuis le namespace de cert-manager, et seul le DNS lui-même est public.
Le staging est mis en veille quand il ne sert pas, démonté jusqu'à un socle qui survit, et ça a façonné le travail sur les certificats. L'inscription acme-dns est générée une fois et gardée dans Secret Manager, et un conteneur d'initialisation l'injecte dans une base jetable à chaque démarrage, si bien qu'une remise en route ne demande jamais de modification chez le registrar. Les deux points d'accès de Let's Encrypt sont déclarés, et un nouvel environnement démarre sur celui de test : une mise en veille détruit le certificat, et le point d'accès de production accorde cinq certificats par semaine pour un même ensemble de noms. Le staging est passé au point d'accès reconnu une fois la chaîne non reconnue émise et servie pour l'hôte et son wildcard, et la surveillance de l'expiration tourne tous les jours depuis.
En périphérie, les définitions de ressources propres à Traefik restent désactivées, donc rien ne peut déclarer un certificat par défaut de la manière habituelle. Une Ingress dans le namespace de la périphérie nomme plutôt le Secret du certificat. Traefik le charge dans son unique magasin avant de résoudre le moindre backend, puis y choisit par nom de serveur, si bien que chaque Ingress est servie avec lui sans qu'aucun Secret soit copié nulle part.
Les deux origines, chacune depuis son propre chart#
Le site public et l'application sont des origines distinctes aux besoins distincts, donc chacun a son chart. Le site est une image nginx derrière un Service et une Ingress : deux réplicas qui se renouvellent sans jamais descendre sous deux, un système de fichiers racine en lecture seule, et une politique réseau qui n'admet que la périphérie.
Chaque chart refuse de rendre une image sans digest, ou avec un tag qui ne nomme pas l'environnement. Les images web intègrent les origines de leur environnement au build, donc un tag flottant ou étranger servirait les hôtes d'un autre environnement sans que rien ne le signale. Avant ça, aucune vérification ne lisait le moindre chart. Désormais, la CI passe chacun au lint et compare son rendu, pour chaque environnement, à un fichier de référence commité, et un test aligne chaque Application de chart sur le rendu que cette vérification a contrôlé.
L'application a apporté une contrainte de plus. La périphérie peut lire tous les Secrets d'un namespace qu'elle sert, et le namespace de l'application contient le mot de passe de la base et ses clés de signature et de chiffrement. Les charges tournent donc dans un namespace que la périphérie ne sert pas, et les routes vivent dans un second, sans aucun Secret, qui pointe de l'autre côté par des Services ExternalName. Le scanner de configuration signale tout Service ExternalName, donc l'exception est limitée aux rendus de ce chart, et un Service de ce type ailleurs échoue toujours. Le test gitops refuse tout secret synchronisé dans un namespace servi, hormis une clé de pull du registre, pour que ce découpage ne s'érode pas en silence.
Les migrations étaient déjà sorties du démarrage de l'application. Un Job de hook provisionne la base à chaque synchronisation, après le Secret et Redis et avant les Deployments, si bien qu'une migration ratée arrête la synchronisation avec les anciens pods toujours en service.
Ce que la première synchronisation a trouvé#
Deux choses ont échoué le premier jour, et aucune n'était visible avant qu'un vrai navigateur ou une vraie base ne les rencontre.
Le Job de provisionnement ne parvenait pas à se connecter : l'utilisateur propriétaire de l'instance PostgreSQL managée n'est pas superutilisateur, et une base créée via l'API de Scaleway retire CONNECT à tout le monde, si bien que l'utilisateur de l'instance ne pouvait pas ouvrir la seule base pour laquelle il existe. Un droit explicite a réglé la question.
Tous les envois de fichiers échouaient aussi. Le navigateur envoie directement vers une URL présignée, la requête est donc cross-origin, et le bucket refusait son preflight faute de règle CORS. La règle admet désormais exactement cette requête, depuis l'origine de l'application. Cette origine est la troisième copie d'une valeur que l'image web intègre et que le chart nomme, et un décalage entre elles ne se voit que sous la forme d'un preflight refusé dans un navigateur : un test aligne désormais les trois sur la même entrée de build.
La suite#
Un moniteur ne peut pas signaler sa propre mort, donc la télémétrie et les alertes étaient la pièce suivante, placées hors du cluster à dessein, avec l'historique conservé hors de sa région. C'est arrivé le 27 septembre, et c'est une autre histoire.