Kubernetes est devenue un des incontournable, bien que la solution ne répond clairement pas à tous les besoins, l’abstraction du provider d’hébergement et la scalabilité horizontale qui peut être établi grâce à ce genre d’outils sont non négligeable.
Vers 2020, la cellule Claranet dans laquelle je faisais partie démarre son offre de service Kubernetes. Plusieurs petits services web non critiques avaient été déployés en amont pour mettre en place la solution. De mon côté j’étais très intéressé par l’utilisation, et principalement pour avoir une abstraction totale de la couche OS qui nous ajoute une complexité difficile à suivre sur plusieurs toolings. En effet, nous utilisions beaucoup d’outils communautaires, chacun avec ses propres restrictions et qui évoluaient parfois plus vite que nous ne pouvions les suivre. À cette période plusieurs de nos outils internes reposent d’ailleurs sur des OS en fin de vie et la question de migration et/ou d’évolution se fait pressante.
Bien que nous ayons commencé à mettre nous outils dans des containers et qu’un grand nombre d’outils sont devenues des outils groupe (et donc plus de notre ressort), tout n’étais pas disponnible, en particulier l’infrastructure qui nous servais à installer nos machines. Il nous restait, en partie, les outils suivants à migrer:
Plusieurs de ses outils semblaient intéresser d’autres cellules et la montée en compétences sur la partie k8s était à faire pour la notre. Afin de capitaliser au mieux tous ces efforts, il semblait intéressant de rejoindre ces objectifs et d’établir la possibilité d’un cluster duplicable le plus facilement possible. On garde ainsi tous la même base mais chaque cellule reste autonome sur la gestion du contenu de son cluster.
Dans cet objectif il a fallu établir deux règles importantes:
Évidemment sur une solution on premise nous n’avons pas les mêmes possibilités et contraintes que sur un cloud provider.
Dans notre solution k8s on utilisera deux manières de faire du stockage.
StatefulSet
.De notre côté on va limiter, voir supprimer, toute notion de StatefulSet
et basculer les rares points de stockage nécessaire dans du Trident.
Côté load balancer on est là aussi dans un cas spécifique. Les LB en front sont des F5, mais il seront utilisés que pour des accès via interface publique et la configuration sera en déclaratif dans le déploiement du cluster fait par terraform. Il est de toute façon conseillé de passer par un ingress pour chaque connexion.
Ce cluster sera néanmoins assez particulier car il y aura un accès public pour les outils dédié aux opérations et un accès privé pour les outils utilisé par les machines clientes. Pour solutionner le problème nous avons fait 2 ingress nginx différent, un répondant sur le f5 en public et un autre répondant sur MetalLB. Cette solution nous permet de porter des IP via des services k8s de type LoadBalancer.
Évidemment il était difficilement acceptable de pousser les images de nos outils internes sur du contenu public, une registry Harbor toute neuve était en place au sein du groupe. Il restait à re-passer un coup sur la gestion du contenu et industrialisé certaines tâches comme l’attribution des ACL basées sur l’AD en fonction des projets et la création de comptes robots. Afin de contribuer au projet j’ai pris en charge cette étape ce qui m’a permis de monter en compétences dans le sujet et de voir qu’il manquait deux prérequis dans Gitlab pour faciliter la consommation d’Harbor. J’ai donc proposé mes deux premières contributions au projet Gitlab:
$
qui est utilisé dans le nom de robot Harbor par défault ainsi qu’une erreure de documentation.HARBOR_HOST
, qui nous donne le nom de domaine du repo harbor afin de pouvoir s’y authentifier et y publier une image docker.HARBOR_OCI
, qui fournit le contenue oci://<HARBOR_HOST>
pour y publier un chart helm.Dans la deuxième merge request j’y ai également ajouté des exemples d’utilisation qui correspond tout simplement au standard qui sera utilisé pour notre projet, à savoir:
# Create and publish a docker image to an Harbor registry on tag event
docker:
stage: docker
image:
name: gcr.io/kaniko-project/executor:debug
entrypoint: ['']
script:
- mkdir -p /kaniko/.docker
- echo "{\"auths\":{\"${HARBOR_HOST}\":{\"auth\":\"$(echo -n ${HARBOR_USERNAME}:${HARBOR_PASSWORD} | base64)\"}}}" > /kaniko/.docker/config.json
- >-
/kaniko/executor
--context "${CI_PROJECT_DIR}"
--dockerfile "${CI_PROJECT_DIR}/Dockerfile"
--destination "${HARBOR_HOST}/${HARBOR_PROJECT}/${CI_PROJECT_NAME}:${CI_COMMIT_TAG}"
rules:
- if: $CI_COMMIT_TAG
# Create and publish an Helm chart to an Harbor registry with keyword Helm:
helm:
stage: helm
image:
name: dtzar/helm-kubectl:latest
entrypoint: ['']
variables:
# Enable OCI support (not required since Helm v3.8.0)
HELM_EXPERIMENTAL_OCI: 1
script:
# Log in to the Helm registry
- helm registry login "${HARBOR_URL}" -u "${HARBOR_USERNAME}" -p "${HARBOR_PASSWORD}"
# Package your Helm chart, which is in the `test` directory
- helm package test
# Your helm chart is created with <chart name>-<chart release>.tgz
# You can push all building charts to your Harbor repository
- helm push test-*.tgz ${HARBOR_OCI}/${HARBOR_PROJECT}
rules:
- if: $CI_COMMIT_MESSAGE =~ /Helm:/
Pour la CD nous utilisons un Argo CD interne mutualisé entre plusieurs clusters. Nous avons déjà l’habitude de fonctionner presque intégralement en GitOPS il était donc très naturel de mettre du contenu git pour déclarer nos applications.
Nous sommes partis sur un modèle très simple à gérer, un seul repo avec chaque sous dossiers contenant un namespace et en dossier enfant une application ArgoCD. La création de namespace sera faite automatiquement par argocd, mais l’application du contenu des applications ArgoCD sera faite elle manuellement par le bouton sync
de la WebUI. Nous avons pris ce choix car beaucoup d’applications était en phase de dev, il était donc tolérable de devoir appuyer sur un bouton pour appliquer des changements.
Pour faciliter la création des applications je suis passé par un ApplicationSet
---
apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
name: my-cluster-app
spec:
generators:
- git:
repoURL: >-
https://monrepogit.git
revision: HEAD
directories:
- path: "**/*"
template:
metadata:
name: 'my-cluster-{{ path.basenameNormalized }}'
spec:
project: my-cluster
source:
repoURL: >-
https://monrepogit.git
targetRevision: HEAD
path: '{{ path }}'
destination:
name: my-cluster
namespace: '{{ path[0] }}'
syncPolicy:
syncOptions:
- ApplyOutOfSyncOnly=true
- CreateNamespace=true
Une fois tous les prérequis en place, il était temps de commencer la migration des outils. Pour les API ruby et python déjà en place il n’y avait rien de particulier. La création d’une chart helm dédié pour chacun des projet avec la commande helm create nous facilite grandement la tâche.
Grafana offre une chart helm très complète, mais il est important d’exporter ses dashboards afin de le mettre dans la déclaration de sa Chart si vous souhtaiez faciliter le re-déploiement.
Nous disposions déjà de plusieurs proxy Zabbix sur le parc, mais le rebuild de cette zone de tooling coïncidait avait la mise en production d’un nouveau Zabbix server 6 et avec des agent2. On en a donc profité pour mettre en place une nouvelle architecture de proxy zabbix.
En plus de servir de référence aux agent pour envoyer les métriques, les proxy servent aussi à aller chercher des informations directement sur les noeuds concernés. Un proxy est attribué pour chaque agent sur le serveurs et ce sera ce dernier qui sera utilisé pour aller chercher les informations, sans avoir besoin du serveur, puis envoyer les informations au serveur Zabbix dès qu’il sera joignable.
Ça peut paraitre un détail, mais ce fonctionnement fait que les proxy ne sont pas scalable horizontalement, ce qui est un point important dans k8s.
Dans le monde du communautaire il existe deux principales solutions pour avoir une chart helm Zabbix Proxy.
De notre côté nous avons choisi de prendre la solution de la chart helm maintenu par Zabbix et nous partons sur un proxy avec une base SQLite dans le container afin de ne pas maintenir de base de données. Cette solution a été prise en connaissance de cause, car si le prod crash et que certaines informations n’étaient pas encore envoyées au serveur elles étaient perdues. Mais avec l’expérience nous savions que ce sont des incidents assez rares et très tolérable.
Le seul point de contention est que le proxy à certaines limites déjà éprouvé dès qu’il a plus de 2.000 noeuds à gérer. Afin d’anticiper ça nous avons mis deux fois la chart helm sur le cluster pour avoir deux proxy séparés qui écouteraient sur des ports différents. Le tout sera équilibré par notre outil de configuration qui prendra aléatoirement dans le range des deux ports lors de la configuration de l’agent.
Bon la première fois que j’en ai parlé à mes collègues, ils ont pensé que je faisais un troll gratuit, mais non. J’avais très envie de mettre tout et n’importe quoi dans du kub et voir si ça fonctionnait.
Évidemment je ne partais pas les mains vides, bien que notre infra DHCP était très vieille et assez mal maintenue, le collègue qui était mainteneur de notre Application Python (qui porte quasiment toute l’intelligence du déploiement et l’installation des machines) avait déjà en tête de refaire la partie DHCP.
Nos besoins étaient très spécifiques et il s’est tout simplement dit “Pourquoi je ne ferais pas mon propre DHCP avec exactement ce dont j’ai besoin”. Quelques temps plus tard une version du DHCP était prête, il ne restait plus qu’à le déployer dans Kubernetes.
Étrangement rien de particulier sur cette étape, j’avais déjà fait la chart helm pour l’API il a suffi de faire un déploiement séparé en définissant une nouvelle commande et en exposant le port sur un service dédié.
C’est finalement l’exposition du service par l’ingress controller nginx qui a posé problème. Contrairement aux autres services qui sont en TCP, celui-ci est en UDP et il était nécessaire d’activer le MixedProtocolLBService
, feature gates désactivé par default jusqu’en 1.23, pour pouvoir avoir les deux protocoles sur l’ingress controller porté par MetalLB.
Le TFTP est un élément qui a peu évolué depuis sa création et qui reste essentiel pour notre stack de déploiement. C’est ce dernier qui va donner le endpoint de ce qui est à déployer pour l’installation. La communauté ne fournit pas grand chose sur cette partie en terme d’image Docker et encore moins en terme de Chart helm. Et les besoins étaient tellement spécifiques que j’ai dû tout faire.
L’image docker est relativement simple, notre conf tftp fait un peu plus de 10Mo, et il suffit d’installer dnsmasq pour avoir un tftp fonctionnel.
FROM alpine:3.17
COPY ./tftp /var/tftpboot
RUN apk add --no-cache dnsmasq && \
chmod -w -R /var/tftpboot
CMD ["/usr/sbin/dnsmasq", "--tftp-root=/var/tftpboot", "--enable-tftp", "--no-daemon", "--tftp-secure", "--tftp-single-port"]
En terme de chart helm ça sera du très basic également, un deployement
avec un seul réplicat sur un service exposé en UDP. Néamoins le tftp est incompatible avec un reverse proxy, j’ai donc oublié l’ingress nginx et j’ai utilisé une exposition sur un service de type loadBalancer
MetalLB dédié.
C’est à partir de ce moment que les choses se compliquent. Il existe une application en php qui nous servait à saisir la descrition des machines à créer. Cet outils est principalement interfacé avec plusieurs autres outils de référencement pour créer des informations consommable par le dhcp/pxe.
Dans un premier temps j’étais assez confiant. L’application est assez simple et petite et j’avais déjà fait le portage php 5.3 vers 5.6.
La complexité est vite arrivé car je ne me suis pas rendu compte que l’application dépendait de resources local à la machine virtuelle, et que certains besoin dépdnat d’autres application était directement récupéré via le disque. De plus php est pas vraiment le meilleur language à mettre dans des containers. Ne disposant pas de moteur web il est forcément nécessaire d’avoir un serveur web en amont et de faire du fastcgi, et uniquement pour le contenue en php. Et enfin il serait bien de mettre à jour php en 8.1 histoire d’être à jour.
J’ai donc découpé le travail en 4 objectifs relativement simples et avec leur propre problématique.
Comme déjà dis mon image est très simple et le poid est vraiment très léger, une fois les dépedances php et javascript installé on était juste sur du 15Mo.
Je me suis pas mal penché sur ce que faisait les clients et la communauté en terme d’image docker php pour me rendre compte que tout se faisait au cas par cas. J’ai fais de mon côté deux image docker distinctes:
Enfin je modifie mon template de stage de CI pour builder et publier mes deux images avec un suffix spécifique.
.build:
stage: build
image:
name: gcr.io/kaniko-project/executor:debug
entrypoint: [""]
variables:
DOCKER_TAG_NAME: "${CI_COMMIT_SHORT_SHA}"
script:
- mkdir -p /kaniko/.docker
- echo "{\"auths\":{\"${HARBOR_HOST}\":{\"auth\":\"$(echo -n ${HARBOR_USERNAME}:${HARBOR_PASSWORD} | base64)\"}}}" > /kaniko/.docker/config.json
- >-
/kaniko/executor
--context "${CI_PROJECT_DIR}"
--dockerfile "${CI_PROJECT_DIR}/docker/Dockerfile-php"
--destination "${HARBOR_HOST}/${HARBOR_PROJECT}/${CI_PROJECT_NAME}:${DOCKER_TAG_NAME}-php-fpm"
- >-
/kaniko/executor
--context "${CI_PROJECT_DIR}"
--dockerfile "${CI_PROJECT_DIR}/docker/Dockerfile-nginx"
--destination "${HARBOR_HOST}/${HARBOR_PROJECT}/${CI_PROJECT_NAME}:${DOCKER_TAG_NAME}-nginx"
Une fois ma chaine d’image prête il était grandement plus simple de pouvoir travailler sur la mise à jour de PHP. Bien que je ne sois pas un dévellopeur php il était assez simple de faire la mise à jour vus la simplicité du contenue, un coup de stack overflow plus tard seulement deux fix ont été effectué:
ob_clean();
corrigé en if (ob_get_contents()) ob_end_clean();
count($var)
corrigé en count((array)$var)
Enfin il s’agit de la partie la plus complexe que je n’avais pas anticipé correctement. Ici il n’y a pas de secret, j’ai déporté les ressources consommé en API et inversement sur les ressources que mon application consommait.
Sur les plus ou moins 2.000 lignes de code ça m’a vallu une revue de 428 insertions(+), 337 deletions(-)
sur l’application php et un peu plus du double sur d’autres applications tiers. Et je me suis permis par la suite de rajouter deux trois features manquantes ou non fonctionnelles depuis quelques années.
Enfin il faut mettre le service dans k8s, pour ça j’ai fais une charte helm relativement simple. Il y aura un seul déploiement mais avec deux containers. Chacun des contenairs auront leur propre service, un nginx et un php-fpm. Ensuite j’ajoute deux petites spécificités:
---
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "app.fullname" . }}
labels:
{{- include "app.labels" . | nindent 4 }}
data:
backend.conf: |
upstream avih {
server {{ include "app.fullname" . }}:9000;
}
{{- if .Values.cache.enabled }}
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ include "app.fullname" . | trunc 58 }}-cache
labels:
{{- include "app.labels" . | nindent 4 }}
spec:
accessModes:
- ReadWriteMany
resources:
requests:
{{- toYaml .Values.cache.requests | nindent 6 }}
storageClassName: {{ .Values.cache.storageClassName }}
{{- end }}
Après près de 3 mois de travail tout était près pour faire une grosse migration. Le déploiement de tout ces outils avec pour chacun leurs propres contraintes était très enrichissant. Ça m’a permis de monter en compétances rapidement dans une multitude de sujets tout en offrant une approche k8s à de nombreux OPS et sans impact direct sur le buisness.
La centralisation des outils sur un même cluster à également eu un effet bénéfique sur la simplicité des fluxs réseaux, il n’y avait plus qu’une IP à ouvrir avec plusieurs protocoles et non plus plusieurs IP sur plusieurs vlan/firewall différents.
Je regrètes malheursement de pas pourvoir maintenir le cluste dans le temps suite à mon changement de poste, et j’aurais également aimé testé une sécurisation réseau dans le cluster avec des NetworkPolicy.