13 Jun 2023

Rebuild d'une infra tooling vers Kubernetes

Table of contents


Contexte

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:

  • Une API python (déjà dans du docker)
  • Une application web php 5.6
  • Des proxy zabbix
  • Un DHCP
  • Un pxe tftp
  • Un grafana (déjà une partie dans k8s)
  • Une API ruby (déjà dans du docker)

Mise en place d’une solution

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:

  • Disposer d’une image récupérable par toutes les cellules, que ce soit une image communautaire ou bien publié sur une registry en interne.
  • Avoir une chart helm à disposition, communautaire ou bien publié sur une registry en interne.

Les spécificités du k8s on premise

Évidemment sur une solution on premise nous n’avons pas les mêmes possibilités et contraintes que sur un cloud provider.

Le stockage

Dans notre solution k8s on utilisera deux manières de faire du stockage.

  1. OpenEBS qui va nous donner la possibilité de faire du stockage local au worker. Très utils pour les applications en StatefulSet.
  2. Trident est une solution de NetApp pour effectuer des points de montage NFS dans des pods. Très performant et très utile pour porter des applications qui ont besoin de stockage persistant sans avoir la possibilité de faire du stockage de type s3 par exemple.

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.

Les Load balancers

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.

Intégration de la CI

É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:

  1. #89070; Sur cette feature j’ai tout simplement corrigé un escape du caratère $ qui est utilisé dans le nom de robot Harbor par défault ainsi qu’une erreure de documentation.
  2. #90380; Cette feature était un peu plus importante. Depuis les dernières release d’Harbor (et sur le support de la registry helm), le protocole https n’était plus activé tout se basait sur la standardisation du protocol OCI. Il n’était pas possible de consommer facilement ce protocole, j’ai donc ajouté deux nouvelles variables d’environment en CI.
    • 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:/

Intégration de la CD

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

Migration des outils

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

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.

Proxy zabbix

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.

Mise en place dans k8s

Dans le monde du communautaire il existe deux principales solutions pour avoir une chart helm Zabbix Proxy.

  • zabbix-community/helm-zabbix qui est un projet porté par la communauté. Le gros problème de cette chart est que le proxy est en StatefulSet, ce qui pose des problèmes de répartition du pod en cas de crash d’un worker.
  • Zabbix Tools/Kubernetes Helm, qui est un projet de Zabbix. Bien que cette chart ne soit pas parfaite elle est intéressante pour deux points principaux
    1. C’est maintenu activement par Zabbix
    2. Il s’agit d’un Deployment avec un seul replica, ce qui nous offre plus de résilience.

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.

Le DHCP

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.

Mise en place dans k8s

É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

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.

Mise en place dans k8s

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é.

Application web en php 5.6

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.

Créer l’image docker

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:

  1. Une image nginx contenant la conf nginx ainsi que le site et les dépendance js, et je prend soin de déplacer la configuration de mon backend dans un fichier séparé afin de pouvoir le sur-charger facilement.
  2. Une image php-fpm très simple avec le contenue de mon site et ses dépendance php.

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"

Mise à jour en PHP 8.1

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)

Enlever les dépendances locale

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.

Porter le service dans Kubernetes

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:

  1. La mise en place d’un configmap qui serivira à définir la config de l’uptream nginx à utiliser.
---
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;
    }
  1. Ajout d’un PVC désactivé par default et qui vas servir pour du cache
{{- 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 }}

Conclusion

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.


Tags: