April 15, 2023

Kubernetes et manifests YAML: trop bas niveau pour les dev ?

Un débat fait rage depuis longtemps dans la communauté Kubernetes: devons-nous construire des abstractions au dessus des primitives de Kubernetes, notamment pour générer les manifests ? Je pense que oui et j’expliquerai dans cet article pourquoi.

La boîte à outil Kubernetes

Kubernetes est une formidable boîte à outil pouvant s’adapter à n’importe quelle application à déployer. C’est ce qui fait sa force: contrairement à de nombreuses autres plateformes, toutes les options imaginables sont présentes pour définir comment votre application doit se comporter: ressources (cpu et mémoires), probes et cycle de vie, gestion des rolling upgrades, autoscaling, gestion du réseau, du load balancing et du stockage…​ Tout est possible grâce à l’API de Kubernetes.

Cette puissante flexibilité est une des raisons du succès de Kubernetes: ça tourne partout, pour tout.

Une question se pose pourtant immédiatement en entreprise lorsque Kubernetes commence à être utilisé: qui doit écrire les manifests Kubernetes (ces fameux fichiers YAML servant à décrire les ressources à déployer), et sous quel format ?

Pour la première question, je pense que les utilisateurs (donc les développeurs) doivent pouvoir déployer de nouvelles applications sur un cluster Kubernetes en totale autonomie. Répondons maintenant à la seconde question.

Le bon niveau d’abstraction

Prenons le manifest potentiel d’une application fictive:

---
kind: ConfigMap
apiVersion: v1
metadata:
  name: cabourotte
data:
  cabourotte.yaml: |
    http:
      host: "0.0.0.0"
      port: 7000
    dns-checks:
      - name: "dns-check"
        description: "example"
        domain: "appclacks.com"
        timeout: 5s
        interval: 20s
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: cabourotte
  namespace: default
  labels:
    app.kubernetes.io/name: cabourotte
spec:
  replicas: 3
  selector:
    matchLabels:
      app.kubernetes.io/name: cabourotte
  template:
    metadata:
      labels:
        app.kubernetes.io/name: cabourotte
    spec:
      affinity:
        podAntiAffinity:
          requiredDuringSchedulingIgnoredDuringExecution:
          - labelSelector:
              matchExpressions:
                - key: app.kubernetes.io/name
                  operator: In
                  values:
                  - cabourotte
            topologyKey: kubernetes.io/hostname
      containers:
      - name: cabourotte
        image: appclacks/cabourotte:v1.13.0
        imagePullPolicy: IfNotPresent
        args:
          - daemon
          - --config
          - /config/cabourotte.yaml
        resources:
          limits:
            memory: 150Mi
          requests:
            cpu: 100m
            memory: 50Mi
        ports:
        - containerPort: 7000
          name: http
        securityContext:
            readOnlyRootFilesystem: true
            runAsNonRoot: true
            runAsUser: 1664
        livenessProbe:
          httpGet:
            path: /healthz
            port: http
          timeoutSeconds: 5
          successThreshold: 1
          failureThreshold: 3
          periodSeconds: 10
        startupProbe:
          httpGet:
            path: /healthz
            port: http
          timeoutSeconds: 5
          successThreshold: 1
          failureThreshold: 30
          periodSeconds: 10
        volumeMounts:
        - name: cabourotte
          mountPath: /config
          readOnly: true
      volumes:
      - name: cabourotte
        configMap:
          name: cabourotte
---
apiVersion: v1
kind: Service
metadata:
  namespace: default
  name: cabourotte
spec:
  selector:
    app.kubernetes.io/name: cabourotte
  ports:
  - protocol: TCP
    name: content
    targetPort: 7000
    port: 7000
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  annotations:
    kubernetes.io/ingress.class: "nginx"
  namespace: default
  name: cabourotte
spec:
  rules:
  - host: www.cabourotte.test-domain.com
    http:
      paths:
      - path: /
        pathType: "Prefix"
        backend:
          service:
            name: cabourotte
            port:
              number: 7000

Il y a beaucoup de choses dans ce manifest: on crée une configmap, un deployment avec 3 replicas, un service, un ingress. Le but de cet article n’est pas de présenter toutes les options utilisées dans ce manifest (on aurait pu en ajouter d’autres, comme par exemple la configuration des capabilities ou du rolling update), mais on voit avec cet exemple simple que la configuration de Kubernetes est verbeuse.

Pourtant, les besoins sont 99 % du temps les mêmes: déployer une application, l’exposer sur un port, créer un service puis éventuellement un ingress pour l’exposer sur un domaine spécifique. Il est donc intéressant de se demander quelle est la part d’informations "utiles" dans l’exemple présenté précédemment. Personnellement, ce que j’aimerai pouvoir définir dans ces situations classiques serait quelque chose comme ça:

name: cabourotte
replicas: 3
image: appclacks/cabourotte:v1.13.0
args:
  - daemon
  - --config
  - /config/cabourotte.yaml
resources:
  limits:
    memory: 150Mi
  requests:
    cpu: 100m
    memory: 50Mi
port: 7000
host: www.cabourotte.test-domain.com
config:
  - path: /config/cabourotte.yaml
    content: |
      http:
        host: "0.0.0.0"
        port: 7000
      dns-checks:
        - name: "dns-check"
          description: "example"
          domain: "appclacks.com"
          timeout: 5s
          interval: 20s

C’est tout de suite beaucoup plus lisible.

les différentes ressources n’apparaissent plus ici, l’application apparaissant comme un tout unifié. Pourquoi devrais-je avoir à définir explicitement une configmap, puis utiliser volumeMounts et volumes alors que je souhaite juste pouvoir exprimer le besoin "je veux ce contenu dans le fichier à cet emplacement" ? Pourquoi avoir plusieurs ressources à lier ensemble pour l’ingress alors que j’ai une relation directe entre mon application et le domaine sur laquelle je veux l’exposer ?

Comme vous pouvez le deviner, je suis dans le camp de ceux pensant que l’abstraction de Kubernetes est trop bas niveau pour les développeurs. Je travaille avec Kubernetes depuis 2017 et ait eu l’occasion de travailler de très nombreux développeurs sur ce sujet au cours de ces années.

Je n’ai jamais (vraiment !) rencontré un développeur content de devoir écrire des centaines de lignes de YAML, c’est même complètement l’opposé: Les gens (cela inclut les meilleurs développeurs avec qui j’ai travaillé) demandent une abstraction permettant d’aller à l’essentiel. Je suis sûr que c’est comme ça aussi chez vous. Pourquoi ?

Réduire la charge cognitive

Personne n’aime écrire et maintenir des centaines, voir milliers de lignes de YAML, alors que l’information vraiment utile peut être exprimée beaucoup plus simplement. C’est comme en programmation, où il existe parfois un facteur 10 en nombre de ligne de code entre deux langages plus ou moins expressifs (Clojure et Java par exemple): un programme court est très souvent plus maintenable car il "tient dans la ram" de votre cerveau.

Réduire la définition d’une application Kubernetes à seulement ses parties essentielles nous fait gagner du temps de cerveau que l’on peut consacrer à autre chose.

Prenons d’autres exemples.

  • Il est courant dans Kubernetes d’ajouter des comportements à des ressources via des annotations: configuration des ingress (tls, entrypoint…​), du monitoring (blackbox exporter), ou autre choses de ce type.
    Est ce que les développeurs ont besoin de savoir que pour exposer son application via un ingress sur un entrypoint Traefik public, il faut rajouter l’annotation spécifique traefik.ingress.kubernetes.io/router.entrypoints: "public" ? Ou bien, pour activer le TLS, traefik.ingress.kubernetes.io/router.tls: "true" ?
    Je veux permettre d’exprimer cela simplement, sans que l’infrastructure interne "leak" côté dev. Je ne veux pas qu’ils aient à connaitre des dizaines d’annotations à rallonge pour exprimer des besoins simples, je préfère des public: true, tls: true, monitoring: enabled…​

  • Je dois l’avouer: je n’arrive jamais à définir des Network Policies qui fonctionnent du premier coup. Pourtant, la majorité des besoins sont identiques: autoriser du traffic entre pods. Je n’ai pas envie que les gens aient à écrire des fichiers YAML complexes, se trompant 9 fois sur 10, en mode "trial and error", pour exprimer un besoin qui se résume en une ligne: "je veux que mon pod A puisse communiquer avec mon pod B sur le port 9876".

  • La gestion des secrets, se résumant souvent (si vous utilisez par exemple external secrets) à "j’ai cette valeur dans le parameter store AWS et je souhaite la retrouver dans cette variable d’environnement ou dans ce fichier". Pourquoi devoir créer ou modifier de multiples ressources alors que le besoin s’exprime en une ligne ?

Je pourrai continuer comme ça encore longtemps. On se rend compte rapidement qu’une application "prod ready" sur Kubernetes nécessite énormément de configuration, beaucoup plus que mon exemple précédent. Je le répète, c’est ce qui fait la force de Kubernetes. Mais les utilisateurs finaux ne sont pas intéressés par le "bruit", seulement par l’information utile à leurs niveaux.

Fournir de bons défaults

securityContext, anti affinity, imagePullPolicy…​ de nombreuses options dans Kubernetes sont souvent identiques dans la majorité des applications. Pourquoi les répéter ad nauseam alors qu’on peut les ajouter par défaut une bonne fois pour toute ?
On peut même imaginer des valeurs par défaut dans les ingress pour les domaines (<app>.<main_company_domain>) par exemple.

Il est certe possible (et très fortement conseillé) de faire de l’audit avec des outils comme Kyverno sur les configurations des manifests. Mais pourquoi s’embêter à définir ces options manuellement si elles sont de toute façon obligatoires ?

Eviter les erreurs et gagner en vélocité

Ce point est un peu similaire à celui de la charge cognitive. Pouvoir définir une application classique prête à être déployée sur Kubernetes ne devrait pas prendre plus que quelques minutes pour n’importe qui.

Utiliser une abstraction permet cela, tout en évitant les heures de debugging à chaque nouveau microservice à base de "je me suis trompé dans mon volume", "j’ai une erreur dans mon label selector mon service marche pas", "je comprends pas mon secret n’est pas injecté"…​ Et c’est ce qui arrivera si les développeurs (et pas que, la même chez les SRE) doivent écrire des manifests complets, 100 % garantie.
Pire, ça les saoulera (à raison) et les gens commenceront à copier/coller des manifests d’autres projets (bugs inclus) jusqu’à ce que ça "marche" (à première vue).

Les critiques de cette approche

Commençons par sûrement la plus évidente, que je partage également en partie: comment doit être définie l’abstraction ?

J’ai dans mon exemple précédent volontairement omis de définir les probes dans mon manifest "simplifié". Que doit faire l’abstraction: définir des probes par défaut au risque à ce qu’elles ne soient pas adaptées à l’application (par exemple, pas de configuration d’une startup probe, ou mauvais type de probe) ?

L’abstraction doit-elle également définir par défaut de l’anti-affinity au niveau des pods ?

On voit qu’une connaissance des mécanismes de Kubernetes est quand même nécessaire, même avec une abstraction, comme par exemple sur le fonctionnement du cycle de vie des pods. Est ce qu’il n’est pas risqué de ne pas exposer cela ?

Si l’option est essentielle (comme par exemple le nombre de réplicas ou la définition des ressources), il faut la rendre obligatoire. Cela doit sûrement être également le cas pour les probes, même si il peut quand même être intéressant d’avoir des probes "prédéfinies" en fonction du type de service.

Egalement, rien de plus frustrant que de ne pas pouvoir utiliser une option spécifique de Kubernetes car l’abstraction ne l’expose pas. Comme dit en début d’article, la force de Kubernetes est de s’adapter à tout, et chaque option a son utilité, même si elle n’est utilisée que sur une application sur 100. Si les gens commencent à lutter contre l’abstraction, c’est perdu.

Au pire, rien n’empêche de rebasculer sur du pure YAML pour des cas très spécifiques. Mais mon expérience me montre qu’il sont rares.

Ouin Ouin c’est pas DevOps on cache Kubernetes aux dev

Ici, Kubernetes est en partie masqué aux équipes de développement. Ce n’est pas un problème.
Comme dit précédemment, il faut quand même comprendre les concepts de Kubernetes même dans ce cas. De plus, le YAML final est toujours récupérable, donc rien n’est caché: on simplifie juste sa génération.

Fournir une abstraction ET former les gens à Kubernetes est possible (et souhaitable), mais il ne sert à rien de perdre en productivité pour aucune raison valable autre que "j’aime bien écrire 1500 lignes de YAML et 10 ressources quand je bootstrap une application".

Je me considère pas trop mauvais avec Kubernetes et je suis le premier heureux lorsque j’utilise ce type d’abstractions. Est ce que j’ai l’impression de perdre en compétence ? Non. Par contre, je pourrai définir mes manifests beaucoup plus rapidement et avec plus de confiance.

Et c’est aussi grâce à ça que l’on arrive à démocratiser Kubernetes au sein d’une entreprise: en fournissant l’outillage permettant d’exprimer clairement, simplement et précisément son besoin.

A vous de choisir ou concevoir les bons outils (Helm ? Kustomize ? Typescript ? Une CRD ?) pour construire votre abstraction.

Tags: devops cloud

Add a comment








If you have a bug/issue with the commenting system, please send me an email (my email is in the "About" section).

Top of page