Leçon 3 — Le cluster et le chart

Le chart Helm, écrit de zéro

8 min de lecture

Le chart Helm, écrit de zéro

helm create génère 11 fichiers et une centaine de lignes de conditions que personne dans l'équipe n'a écrites ni ne relira. Six mois plus tard, quelqu'un active serviceAccount.create: false sans savoir ce que ça fait, et le pod perd ses droits. Un chart écrit à la main, limité à ce que le service utilise, se relit en dix minutes et se teste. Ici : 8 objets Kubernetes, 1 fichier de valeurs, 9 tests de rendu.

Le mécanisme

Helm combine des templates Go et des valeurs pour produire des manifestes, les envoie à l'API Kubernetes et garde chaque version rendue dans un Secret de type helm.sh/release.v1 du namespace. C'est ce stockage qui rend possible helm rollback (leçon 6).

flowchart LR
  V["values.yaml + --set"] --> T["templates/*.yaml + _helpers.tpl"]
  T -->|"helm template / lint"| R["8 manifestes rendus"]
  R -->|"helm upgrade --install --wait"| API["API Kubernetes"]
  API --> REL["Révision N stockée dans le namespace"]
  KC["kubectl create secret (hors chart)"] --> S["Secret inference-gemini"]
  S -.->|"secretKeyRef"| R
FichierObjetCe qu'il garantit
deployment.yamlDeploymentsondes, ressources, non-root, arrêt propre, mise à jour sans perte
service.yamlServiceadresse stable devant les pods
ingress.yamlIngressentrée HTTP par nom d'hôte, délai de lecture de 60 s pour les réponses lentes du modèle
configmap.yamlConfigMapmodèle, limites, backend ; aucune donnée secrète
serviceaccount.yamlServiceAccountidentité dédiée, sans jeton monté
hpa.yamlHorizontalPodAutoscaler2 à 6 pods (leçon 5)
pdb.yamlPodDisruptionBudgetau moins 1 pod pendant un drain (leçon 4)
networkpolicy.yamlNetworkPolicyseul l'Ingress entre, seuls DNS et HTTPS sortent (leçon 4)

La mise en œuvre

La clé n'entre jamais dans le chart

Le chart référence un Secret par son nom et refuse de se rendre sans lui quand le backend est Gemini :

files/chart/inference/templates/deployment.yaml
{{- if and (eq .Values.config.LLM_BACKEND "gemini") (not .Values.existingSecret) }}
{{- fail "existingSecret est obligatoire avec LLM_BACKEND=gemini (créez le Secret avec kubectl, jamais dans le chart)" }}
{{- end }}
...
            - name: GEMINI_API_KEY
              valueFrom:
                secretKeyRef: { name: {{ .Values.existingSecret }}, key: GEMINI_API_KEY, optional: {{ ne .Values.config.LLM_BACKEND "gemini" }} }

Le Secret est créé par make secret, qui lit la clé depuis .env et la passe par l'entrée standard : elle n'apparaît ni dans l'historique du shell, ni dans la liste des processus, ni dans une révision Helm.

files/Makefile
secret:
	@KEY="$${GEMINI_API_KEY:-$$(sed -n 's/^GEMINI_API_KEY=//p' .env 2>/dev/null)}"; \
	 test -n "$$KEY" && test "$$KEY" != "remplacez-moi" || { echo "GEMINI_API_KEY absente de .env"; exit 1; }; \
	 printf '%s' "$$KEY" | $(K) -n $(NS) create secret generic inference-gemini \
	  --from-file=GEMINI_API_KEY=/dev/stdin --dry-run=client -o yaml | $(K) apply -f - >/dev/null

Le Makefile n'inclut ni n'exporte .env : la clé n'est lue que dans cette cible, et une variable passée en ligne de commande (GEMINI_API_KEY=... make secret) l'emporte toujours sur le fichier.

Un changement de configuration redémarre les pods

Une variable d'environnement lue depuis une ConfigMap n'est relue qu'au démarrage du conteneur. Sans précaution, helm upgrade --set config.MODEL=... met à jour la ConfigMap et les pods gardent l'ancien modèle. L'empreinte de la ConfigMap dans les annotations du pod force un nouveau ReplicaSet :

files/chart/inference/templates/deployment.yaml
      annotations:
        checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}

L'HPA possède le nombre de répliques

Si le Deployment déclare replicas: 2 et que l'HPA en a créé 5, chaque helm upgrade remet 2, puis l'HPA remonte : trois pods tués pour rien en pleine charge. Le template omet replicas quand l'autoscaling est actif :

files/chart/inference/templates/deployment.yaml
spec:
  {{- if not .Values.autoscaling.enabled }}
  replicas: {{ .Values.replicaCount }}
  {{- end }}

Tester le rendu sans cluster

tests/test_chart.py appelle helm template et vérifie les garanties, pas la syntaxe :

files/tests/test_chart.py
def test_never_renders_a_secret():
    docs = render()
    assert "Secret" not in docs
    env = docs["Deployment"]["spec"]["template"]["spec"]["containers"][0]["env"]
    key = next(e for e in env if e["name"] == "GEMINI_API_KEY")
    assert key["valueFrom"]["secretKeyRef"]["name"] == "inference-gemini"

def test_hpa_owns_replicas():
    docs = render()
    assert "replicas" not in docs["Deployment"]["spec"]

Puis le déploiement réel et le test de bout en bout (scripts/e2e.py) :

terminal
make up       # cluster + addons + image + Secret + helm upgrade --install --wait
make e2e

Ce qui casse en production

SymptômeCauseParade
Après helm upgrade, les pods utilisent l'ancien modèleConfigMap modifiée, pods non redémarrésannotation checksum/config
À chaque déploiement, le nombre de pods retombe à 2 puis remontereplicas rendu alors que l'HPA est actifomettre replicas si autoscaling.enabled
Clé d'API retrouvée dans helm get values d'une ancienne révisionclé passée en --setSecret créé hors chart, référencé par nom
helm upgrade réussit mais le service est en panneupgrade sans --wait : Helm n'attend pas la readiness--wait --timeout (et --atomic en CI)
Noms d'objets inference-inferencemodèle release-chart appliqué sans testréutiliser le nom de release s'il contient déjà le nom du chart (_helpers.tpl)

La règle de décision

  • Écrivez le chart à la main tant qu'il sert un seul service ; un chart « bibliothèque » partagé se justifie à partir de trois services qui ont la même forme.
  • Aucun secret dans values.yaml, ni dans --set, ni dans Git : le chart référence, il ne contient pas.
  • Chaque garantie de production (non-root, sondes, pas de Secret, HPA propriétaire des répliques) a un test de rendu qui échoue si quelqu'un la retire.

Pratique

Un développeur propose d'ajouter templates/secret.yaml avec la clé passée par --set gemini.apiKey=$KEY dans la CI, « parce que c'est plus simple qu'un kubectl à part ». Quel argument tranche ?

Vous passez MODEL de gemini-3.5-flash à gemini-3.5-flash-lite avec helm upgrade. La ConfigMap est à jour, mais les réponses indiquent toujours l'ancien modèle une heure plus tard. Que manque-t-il ?

Exercice

Ajoutez au chart une valeur ingress.tlsSecret qui, si elle est renseignée, ajoute une section tls à l'Ingress. Écrivez le test de rendu correspondant (avec et sans la valeur).

Voir la solution
files/chart/inference/templates/ingress.yaml (dans spec)
  {{- with .Values.ingress.tlsSecret }}
  tls:
    - hosts: [{{ $.Values.ingress.host | quote }}]
      secretName: {{ . }}
  {{- end }}
files/tests/test_chart.py
def test_tls_is_optional():
    assert "tls" not in render()["Ingress"]["spec"]
    tls = render("ingress.tlsSecret=assistant-tls")["Ingress"]["spec"]["tls"]
    assert tls[0]["secretName"] == "assistant-tls"

Ajoutez tlsSecret: "" dans values.yaml pour documenter la valeur. Le certificat lui-même est créé hors chart (cert-manager), pour la même raison que la clé Gemini.

Résultat obtenu

Exécution du 2026-10-15 :

$ helm lint --strict chart/inference
==> Linting chart/inference
[INFO] Chart.yaml: icon is recommended
1 chart(s) linted, 0 chart(s) failed

$ helm template inference chart/inference | grep "^kind:"
kind: NetworkPolicy
kind: PodDisruptionBudget
kind: ServiceAccount
kind: ConfigMap
kind: Service
kind: Deployment
kind: HorizontalPodAutoscaler
kind: Ingress

$ make e2e TAG=0.2.0
[OK] pods prêts : 2 : inference-6ddbf6f4ff-2b6g6, inference-6ddbf6f4ff-sz8b2
[OK] répartis sur plusieurs nœuds : lab-k8s-ia-worker, lab-k8s-ia-worker2
[OK] conteneur non-root : uid=10001
[OK] aucun Secret dans la release Helm
[OK] réponse Gemini via l'Ingress : HTTP 200
    pod=inference-6ddbf6f4ff-2b6g6 version=0.2.0 modèle=gemini-3.5-flash latence=3953 ms
    Assurez-vous d'abord que vous êtes bien connecté au Wi-Fi de l'hôtel et que vous avez validé
    la page d'authentification (portail captif) dans votre navigateur internet. Certains réseaux
    d'hôtel bloquent les protocoles VPN ; essayez de tester votre connexion en utilisant le partage
    de données de votre smartphone. [...]
[OK] backend réel
[OK] NetworkPolicy bloque un pod hors Ingress : connexion expirée après 3 s
e2e : tout est vert

Je sais faire

  • Écrire un chart sans helm create, limité aux objets utilisés
  • Référencer un Secret créé hors chart et faire échouer le rendu s'il manque
  • Redémarrer les pods à chaque changement de configuration
  • Tester les garanties du chart avec helm template et pytest
Suivant