Aller au contenu
English
English
Conteneurs & Orchestration medium

10 patterns Helm pour des charts production-ready

70 min de lecture

logo helm

Ce guide présente les 10 gabarits essentiels pour transformer un chart basique en chart production-ready. Vous apprendrez à ajouter les sondes de santé, les limites de ressources, la sécurité des conteneurs, l'autoscaling, l'Ingress avec TLS et les hooks. À la fin, votre chart respectera les standards attendus en production.

Ces patterns s'appliquent à un chart existant : ils ajoutent des blocs aux templates et des clés aux values, ils ne créent pas la structure. Le chart de référence est celui du module précédent, mais n'importe quel chart généré par helm create convient.

Voici les 10 patterns que tout chart production-ready doit implémenter :

#PatternProblème résoluImpact production
1Health ProbesDétection des conteneurs défaillantsHaute dispo, auto-healing
2ResourcesConteneurs gourmands, évictionStabilité, QoS
3SecurityContextConteneurs root, faillesSécurité, compliance
4ServiceAccountPermissions excessivesPrincipe moindre privilège
5ConfigMapConfig en dur dans l'imageFlexibilité, 12-factor
6IngressExposition HTTP sans TLSAccès sécurisé
7Autoscaling (HPA)Charge variableÉlasticité, coûts
8SchedulingRépartition non optimaleHA, performance
9PodAnnotationsIntégrations manquantesObservabilité
10HooksMigrations, initialisationsOrchestration

Sans sondes, Kubernetes considère qu'un conteneur démarré est un conteneur en bonne santé, y compris quand l'application est figée. Ce gabarit rend les trois sondes configurables depuis les values et ajoute un drapeau enabled, indispensable pour installer le chart avec une application qui n'expose aucun endpoint de santé.

Les probes (sondes) permettent à Kubernetes de vérifier l'état de vos conteneurs :

ProbeQuestion poséeConséquence si échec
livenessProbe"Le conteneur est-il vivant ?"Kubernetes le redémarre
readinessProbe"Le conteneur est-il prêt à recevoir du trafic ?"Retiré du Service (plus de trafic)
startupProbe"Le conteneur a-t-il fini de démarrer ?"Les autres probes attendent

Chaque sonde porte son propre bloc, drapeau enabled en tête. Notez la startupProbe désactivée par défaut : son failureThreshold: 30 combiné à periodSeconds: 5 laisse jusqu'à 150 secondes à une application lente avant que la liveness ne commence à la redémarrer.

livenessProbe:
enabled: true
httpGet:
path: /healthz
port: http
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 3
readinessProbe:
enabled: true
httpGet:
path: /readyz
port: http
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 3
startupProbe:
enabled: false
httpGet:
path: /healthz
port: http
initialDelaySeconds: 10
periodSeconds: 5
failureThreshold: 30

Décryptage des paramètres :

ParamètreSignificationValeur typique
initialDelaySecondsAttente avant première vérification5-30s selon l'app
periodSecondsIntervalle entre vérifications10s
timeoutSecondsTimeout de la requête3s
failureThresholdÉchecs consécutifs avant action3

Le bloc if conditionne le rendu de la sonde entière : à false, aucun champ livenessProbe n'apparaît dans le Deployment. Chaque paramètre est repris explicitement, ce qui interdit d'ajouter une option sans modifier le template, au prix d'un peu de verbosité.

containers:
- name: {{ .Chart.Name }}
{{- range $nom := list "startupProbe" "livenessProbe" "readinessProbe" }}
{{- $p := index $.Values $nom }}
{{- if $p.enabled }}
{{ $nom }}:
httpGet:
path: {{ $p.httpGet.path }}
port: {{ $p.httpGet.port }}
initialDelaySeconds: {{ $p.initialDelaySeconds }}
periodSeconds: {{ $p.periodSeconds }}
{{- with $p.timeoutSeconds }}
timeoutSeconds: {{ . }}
{{- end }}
failureThreshold: {{ $p.failureThreshold }}
{{- end }}
{{- end }}

Les trois sondes partagent la même forme, donc une seule boucle les rend toutes. Écrire trois blocs quasi identiques marche aussi, mais chaque correction devrait alors être reportée trois fois, et c'est ainsi qu'une sonde finit par diverger des autres.

Trois détails méritent l'attention. $.Values et non .Values : à l'intérieur d'un range, le contexte courant est l'élément parcouru, et le dollar seul est le seul chemin de retour vers la racine. index va chercher une clé dont le nom est une variable, ce que la notation par point ne permet pas. Et le with autour de timeoutSeconds n'est pas décoratif : startupProbe n'en déclare pas dans les values ci-dessus, et sans cette garde la ligne sortirait vide.

L'ordre de la liste compte aussi, et il n'est pas alphabétique : startup d'abord, parce que c'est l'ordre dans lequel Kubernetes les met en œuvre, la sonde de démarrage suspendant les deux autres tant qu'elle n'a pas réussi.

--show-only restreint la sortie au fichier demandé plutôt que de rendre tout le chart. Le point à contrôler est que les deux sondes visent bien des chemins distincts, ce qu'un rendu complet noierait dans le reste.

Fenêtre de terminal
helm template test-release mon-api --show-only templates/deployment.yaml

Extrait du résultat :

livenessProbe:
httpGet:
path: /healthz
port: http
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 3
readinessProbe:
httpGet:
path: /readyz
port: http
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 3

Un conteneur sans plafond peut absorber toute la mémoire de son nœud et provoquer l'éviction de ses voisins. Ce gabarit expose un bloc resources unique, recopié tel quel dans le Deployment, avec un mécanisme qui l'omet entièrement quand l'utilisateur le laisse vide.

Sans limites, un conteneur peut consommer toutes les ressources du nœud et impacter les autres applications. Kubernetes utilise les resources pour :

  • Requests : réserver des ressources minimales (scheduling)
  • Limits : plafonner la consommation (protection)

Les valeurs proposées correspondent à une petite API : un dixième de cœur réservé, un plafond au double. La requests sert au scheduler pour placer le pod, la limits au kubelet pour le contraindre une fois lancé.

resources:
limits:
cpu: 200m
memory: 256Mi
requests:
cpu: 100m
memory: 128Mi

Notation des ressources :

NotationSignification
100m100 millicores = 0.1 CPU
11 CPU complet
128Mi128 Mébioctets
1Gi1 Gibioctet

with remplace le contexte courant par la valeur qu'il teste, d'où le point seul passé à toYaml. C'est aussi la source d'erreur habituelle : à l'intérieur du bloc, .Values n'est plus accessible sans repasser par $.

containers:
- name: {{ .Chart.Name }}
{{- with .Values.resources }}
resources:
{{- toYaml . | nindent 12 }}
{{- end }}

Pourquoi ce pattern ?

  • Si resources: {} (vide), le bloc n'est pas rendu
  • toYaml convertit l'objet YAML proprement
  • nindent 12 ajoute une nouvelle ligne et douze espaces d'indentation

Le nombre passé à nindent est une colonne absolue, pas un décalage. C'est le piège le plus coûteux du templating Helm, et la raison pour laquelle ce gabarit est montré dans son contexte plutôt que seul. Ici resources: se trouve en colonne 10, ses enfants doivent donc tomber en colonne 12. Recopier le même bloc avec nindent 2, ce qui semble naturel puisque les enfants sont « deux crans plus loin », produit ceci :

resources:
limits:
cpu: 200m

Et le rendu s'arrête sur mapping values are not allowed in this context, une erreur qui désigne la ligne fautive sans nommer la cause : elle parle du YAML produit, jamais du gabarit. La règle tient en une phrase : comptez la profondeur depuis le début de la ligne, jamais depuis la clé qui précède.

toYaml recopie le bloc des values sans en vérifier le contenu. Une unité mal écrite comme 256Mib traverserait donc le rendu sans erreur, et ne serait rejetée qu'à l'apply, par l'API server, bien plus tard dans la chaîne.

containers:
- name: mon-api
resources:
limits:
cpu: 200m
memory: 256Mi
requests:
cpu: 100m
memory: 128Mi

Par défaut, un conteneur démarre en root avec les capabilities Linux que lui laisse le runtime. Ce pattern expose deux blocs distincts, l'un au niveau du pod, l'autre au niveau du conteneur, et applique le moindre privilège sans rien changer à l'image.

Par défaut, un conteneur peut s'exécuter en root et avoir des privilèges excessifs. Le securityContext applique le principe du moindre privilège.

Les deux blocs portent le même nom dans le manifeste mais ne se placent pas au même endroit : podSecurityContext sous spec, securityContext sous chaque conteneur. Certains champs existent des deux côtés, et la valeur définie au niveau du conteneur l'emporte sur celle du pod.

NiveauScopeExemple d'usage
podSecurityContextTous les conteneurs du podfsGroup, runAsNonRoot
securityContextUn conteneur spécifiquecapabilities, readOnlyRootFilesystem

L'identifiant 65534 correspond au compte nobody, présent dans la plupart des images de base. fsGroup s'applique aux volumes montés, ce qui évite qu'un conteneur non root se retrouve devant des fichiers qu'il n'a pas le droit de lire.

podSecurityContext:
fsGroup: 65534
runAsNonRoot: true
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
readOnlyRootFilesystem: true
runAsNonRoot: true
runAsUser: 65534
runAsGroup: 65534

Décryptage :

ParamètreSignification
runAsNonRoot: trueRefuse de démarrer si le conteneur tente de tourner en root
runAsUser: 65534UID nobody (utilisateur sans privilèges)
allowPrivilegeEscalation: falseEmpêche d'obtenir plus de droits qu'au départ
capabilities.drop: [ALL]Supprime toutes les capabilities Linux
readOnlyRootFilesystem: trueSystème de fichiers en lecture seule

Les deux blocs with sont indépendants : l'un peut rester vide sans empêcher l'autre de se rendre. L'indentation diffère selon la profondeur, nindent 4 sous spec et nindent 8 sous le conteneur, et une erreur ici produit un YAML invalide.

spec:
{{- with .Values.podSecurityContext }}
securityContext:
{{- toYaml . | nindent 4 }}
{{- end }}
containers:
- name: {{ .Chart.Name }}
{{- with .Values.securityContext }}
securityContext:
{{- toYaml . | nindent 8 }}
{{- end }}

Les champs ressortent triés par ordre alphabétique, toYaml ne conservant pas l'ordre du fichier de values. Vérifiez la présence de drop: [ALL] et de readOnlyRootFilesystem, les deux réglages qui empêchent le plus souvent une image non préparée de démarrer.

spec:
securityContext:
fsGroup: 65534
runAsNonRoot: true
containers:
- name: mon-api
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
readOnlyRootFilesystem: true
runAsGroup: 65534
runAsNonRoot: true
runAsUser: 65534

Tout pod s'exécute sous un ServiceAccount, celui du namespace par défaut s'il n'en désigne aucun. Ce gabarit crée un compte dédié au chart, coupe le montage automatique de son jeton, et laisse malgré tout la possibilité de réutiliser un compte géré ailleurs.

Chaque pod Kubernetes a un ServiceAccount qui définit ses permissions d'accès à l'API. Le compte default est partagé par tous les pods sans compte explicite, ce qui rend ses droits difficiles à restreindre sans effet de bord.

Problème : si vous montez un token dans un pod compromis, l'attaquant hérite des permissions du SA.

Un champ name laissé vide déclenche la génération d'un nom à partir de celui de la release. Les annotations servent aux intégrations d'identité cloud, où l'annotation porte le rôle IAM que le compte est autorisé à endosser.

serviceAccount:
create: true
automount: false
annotations: {}
name: ""
ParamètreSignification
createCréer un SA dédié (vs utiliser default)
automountMonter automatiquement le token ? (false recommandé)
annotationsPour IAM (AWS IRSA, GCP Workload Identity)
nameNom personnalisé (sinon généré automatiquement)

Ce helper centralise la logique en un seul endroit, appelé à la fois par le Deployment et par le template du ServiceAccount. Sans lui, les deux fichiers dupliqueraient la même condition et finiraient par diverger au premier changement.

{{/*
Nom du ServiceAccount à utiliser
*/}}
{{- define "mon-api.serviceAccountName" -}}
{{- if .Values.serviceAccount.create }}
{{- default (include "mon-api.fullname" .) .Values.serviceAccount.name }}
{{- else }}
{{- default "default" .Values.serviceAccount.name }}
{{- end }}
{{- end }}

Logique :

  • Si create: true → utilise le nom personnalisé ou génère un nom
  • Si create: false → utilise le nom fourni ou default

Le if englobe le fichier entier : avec create: false, Helm produit un document vide, simplement ignoré à l'installation. automountServiceAccountToken est ici piloté depuis les values, alors que ce champ peut aussi être posé au niveau du pod.

{{- if .Values.serviceAccount.create -}}
apiVersion: v1
kind: ServiceAccount
metadata:
name: {{ include "mon-api.serviceAccountName" . }}
labels:
{{- include "mon-api.labels" . | nindent 4 }}
{{- with .Values.serviceAccount.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
automountServiceAccountToken: {{ .Values.serviceAccount.automount }}
{{- end }}

Le rendu ci-dessous montre les deux effets du gabarit : la création du ServiceAccount, et sa référence depuis le Deployment. C'est la paire à vérifier, car un ServiceAccount créé mais jamais référencé est une erreur silencieuse : les pods tournent, avec l'identité default.

Avec serviceAccount.create: true :

apiVersion: v1
kind: ServiceAccount
metadata:
name: test-release-mon-api
labels:
helm.sh/chart: mon-api-0.2.0
app.kubernetes.io/name: mon-api
app.kubernetes.io/instance: test-release
app.kubernetes.io/version: "1.0.0"
app.kubernetes.io/managed-by: Helm
automountServiceAccountToken: false

Avec serviceAccount.create: false :

Fenêtre de terminal
helm template test-release mon-api --set serviceAccount.create=false \
--show-only templates/deployment.yaml | grep serviceAccountName
serviceAccountName: default

Une configuration gravée dans l'image impose de la reconstruire à chaque changement de valeur. Ce gabarit génère une ConfigMap à partir d'un dictionnaire libre placé dans les values, puis l'injecte en bloc dans le conteneur avec envFrom.

Le 12-factor app recommande de stocker la configuration dans l'environnement, pas dans le code. Les ConfigMaps permettent de modifier la config sans rebuilder l'image.

Le sous-bloc data accepte n'importe quelle clé, le template les parcourant sans en connaître la liste. Les valeurs numériques sont écrites entre guillemets, une ConfigMap ne stockant que des chaînes de caractères.

config:
enabled: true
data:
LOG_LEVEL: "info"
APP_ENV: "production"
API_TIMEOUT: "30"

range sur un dictionnaire rend les clés triées, ce qui rend le manifeste stable d'un rendu à l'autre. Le filtre quote n'est pas décoratif : sans lui, une valeur comme on ou yes serait interprétée comme un booléen par le parseur YAML.

{{- if .Values.config.enabled }}
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "mon-api.fullname" . }}-config
labels:
{{- include "mon-api.labels" . | nindent 4 }}
data:
{{- range $key, $value := .Values.config.data }}
{{ $key }}: {{ $value | quote }}
{{- end }}
{{- end }}

Le pattern range expliqué :

  • range $key, $value := .Values.config.data parcourt chaque paire clé-valeur
  • {{ $key }} et {{ $value }} sont des variables locales
  • | quote met la valeur entre guillemets (sécurité YAML)

envFrom importe toutes les clés de la ConfigMap comme variables d'environnement, sans avoir à les déclarer une par une. En contrepartie, une clé dont le nom n'est pas un identifiant valide est ignorée sans message.

{{- if .Values.config.enabled }}
envFrom:
- configMapRef:
name: {{ include "mon-api.fullname" . }}-config
{{- end }}

Le rendu ne contient que les clés déclarées dans les values. Piège classique de ce gabarit : mettre à jour la ConfigMap ne redémarre pas les pods, les variables d'environnement restant celles chargées au démarrage du conteneur.

apiVersion: v1
kind: ConfigMap
metadata:
name: test-release-mon-api-config
labels:
helm.sh/chart: mon-api-0.2.0
app.kubernetes.io/name: mon-api
app.kubernetes.io/instance: test-release
app.kubernetes.io/version: "1.0.0"
app.kubernetes.io/managed-by: Helm
data:
APP_ENV: "production"
LOG_LEVEL: "info"

Un Service ne sort pas du cluster de lui-même : il faut un Ingress et un controller pour l'exposer en HTTP. Ce pattern rend l'objet entièrement optionnel, avec des règles de routage et des certificats TLS pilotés depuis les values.

L'Ingress expose vos Services HTTP et HTTPS à l'extérieur du cluster. Il apporte trois choses qu'un Service seul ne fait pas : le routage par nom d'hôte, le routage par chemin, et la terminaison TLS.

L'Ingress est désactivé par défaut, choix habituel pour un chart réutilisable. La structure hosts est une liste de listes, chaque hôte portant ses propres chemins, et tls reste vide tant qu'aucun Secret de certificat n'existe dans le namespace.

ingress:
enabled: false
className: nginx
annotations: {}
hosts:
- host: api.example.local
paths:
- path: /
pathType: Prefix
tls: []

Les boucles imbriquées sont le point délicat : à l'intérieur d'un range, le point ne désigne plus la racine des values mais l'élément courant. D'où le $ devant .Values.service.port et devant l'appel au helper fullname, sans lequel le rendu échoue.

{{- if .Values.ingress.enabled -}}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "mon-api.fullname" . }}
labels:
{{- include "mon-api.labels" . | nindent 4 }}
{{- with .Values.ingress.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- if .Values.ingress.className }}
ingressClassName: {{ .Values.ingress.className }}
{{- end }}
{{- if .Values.ingress.tls }}
tls:
{{- range .Values.ingress.tls }}
- hosts:
{{- range .hosts }}
- {{ . | quote }}
{{- end }}
secretName: {{ .secretName }}
{{- end }}
{{- end }}
rules:
{{- range .Values.ingress.hosts }}
- host: {{ .host | quote }}
http:
paths:
{{- range .paths }}
- path: {{ .path }}
pathType: {{ .pathType }}
backend:
service:
name: {{ include "mon-api.fullname" $ }}
port:
number: {{ $.Values.service.port }}
{{- end }}
{{- end }}
{{- end }}

Points clés du template :

  • {{- if .Values.ingress.enabled -}} : tout le fichier est conditionnel
  • $ pour accéder au contexte global dans les boucles imbriquées
  • Double range : une boucle pour les hosts, une pour les paths

L'échappement des points est obligatoire dans --set : sans les antislashs, Helm lirait nginx.ingress.kubernetes.io/rewrite-target comme une arborescence de clés imbriquées, et non comme un nom d'annotation.

Fenêtre de terminal
helm template test-release mon-api \
--set ingress.enabled=true \
--set 'ingress.annotations.nginx\.ingress\.kubernetes\.io/rewrite-target=/' \
--set 'ingress.tls[0].secretName=api-tls' \
--set 'ingress.tls[0].hosts[0]=api.example.local'

Résultat :

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: test-release-mon-api
labels:
helm.sh/chart: mon-api-0.2.0
app.kubernetes.io/name: mon-api
app.kubernetes.io/instance: test-release
app.kubernetes.io/version: "1.0.0"
app.kubernetes.io/managed-by: Helm
annotations:
nginx.ingress.kubernetes.io/rewrite-target: /
spec:
ingressClassName: nginx
tls:
- hosts:
- "api.example.local"
secretName: api-tls
rules:
- host: "api.example.local"
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: test-release-mon-api
port:
number: 9898

Le HPA ajuste le nombre de réplicas selon les métriques observées. Le point sensible se situe ailleurs que dans l'objet lui-même : dès que l'autoscaling est actif, le champ replicas du Deployment doit disparaître, sans quoi Helm et le HPA se contredisent à chaque déploiement.

L'Horizontal Pod Autoscaler ajuste automatiquement le nombre de réplicas selon la charge. Il apporte trois choses : absorber un pic de trafic sans intervention, réduire le coût en période creuse, et éviter le dimensionnement au pire cas qui gaspille des ressources en permanence.

Les deux seuils sont facultatifs et traités séparément dans le template : n'en renseigner qu'un produit un HPA à une seule métrique. Le minReplicas: 2 empêche l'application de retomber à un pod unique pendant les périodes creuses, donc de perdre toute redondance.

autoscaling:
enabled: false
minReplicas: 2
maxReplicas: 10
targetCPUUtilizationPercentage: 70
targetMemoryUtilizationPercentage: 80

Quand l'HPA est actif, c'est lui qui gère le nombre de réplicas. Le Deployment ne doit pas définir replicas :

spec:
{{- if not .Values.autoscaling.enabled }}
replicas: {{ .Values.replicaCount }}
{{- end }}

L'apiVersion autoscaling/v2 est celle qui accepte plusieurs métriques dans un même objet. scaleTargetRef doit désigner le Deployment par son nom rendu, d'où l'appel au même helper fullname que dans le template du Deployment : une divergence ici et le HPA ne trouve pas sa cible.

{{- if .Values.autoscaling.enabled }}
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: {{ include "mon-api.fullname" . }}
labels:
{{- include "mon-api.labels" . | nindent 4 }}
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: {{ include "mon-api.fullname" . }}
minReplicas: {{ .Values.autoscaling.minReplicas }}
maxReplicas: {{ .Values.autoscaling.maxReplicas }}
metrics:
{{- if .Values.autoscaling.targetCPUUtilizationPercentage }}
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }}
{{- end }}
{{- if .Values.autoscaling.targetMemoryUtilizationPercentage }}
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }}
{{- end }}
{{- end }}

Le contrôle est négatif du côté du Deployment : l'extrait rendu ne doit plus contenir de champ replicas. Le HPA produit à la suite reprend les deux métriques déclarées dans les values, ce qui ferme la vérification.

Fenêtre de terminal
helm template test-release mon-api --set autoscaling.enabled=true

Extrait deployment.yaml (sans replicas) :

spec:
selector:
matchLabels:
app.kubernetes.io/name: mon-api

hpa.yaml :

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: test-release-mon-api
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: test-release-mon-api
minReplicas: 2
maxReplicas: 10
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: 80

Pattern 8, Scheduling (affinity, tolerations, nodeSelector)

Section intitulée « Pattern 8, Scheduling (affinity, tolerations, nodeSelector) »

Trois champs distincts pilotent le placement des pods et se combinent dans le même Deployment. Le chart les expose vides, ce qui les fait disparaître du manifeste rendu : c'est à l'installation, par un fichier de values dédié, qu'on impose une répartition.

Par défaut, Kubernetes place les pods sur n'importe quel nœud disponible. En production, vous voulez :

BesoinSolution
Pods sur des nœuds spécifiques (SSD, GPU)nodeSelector
Pods sur des nœuds taintéstolerations
Pods répartis sur plusieurs zonespodAntiAffinity
Pods co-localisés avec d'autrespodAffinity

Ces trois clés sont déclarées vides pour documenter leur existence : l'utilisateur du chart sait ainsi qu'il peut les remplir, sans avoir à lire les templates. C'est une forme de documentation exécutable, qui ne dérive jamais du code.

nodeSelector: {}
tolerations: []
affinity: {}

Les trois blocs with suivent le même modèle et partagent la même indentation nindent 8, puisqu'ils se placent tous sous spec.template.spec du Deployment.

{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}

Exemple : haute disponibilité avec podAntiAffinity

Section intitulée « Exemple : haute disponibilité avec podAntiAffinity »

L'anti-affinité répond à une question précise : que se passe-t-il si le nœud qui porte mes pods tombe ? Sans elle, rien n'empêche le planificateur de placer les trois répliques au même endroit. Le choix décisif est entre la forme stricte, qui refuse de placer un pod faute de nœud libre, et la forme souple, qui préfère répartir mais accepte de concentrer.

Créez un fichier values-ha.yaml :

replicaCount: 3
affinity:
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
podAffinityTerm:
labelSelector:
matchLabels:
app.kubernetes.io/name: mon-api
topologyKey: kubernetes.io/hostname

Rendu :

Fenêtre de terminal
helm template test-release mon-api -f values-ha.yaml --show-only templates/deployment.yaml | tail -15
affinity:
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- podAffinityTerm:
labelSelector:
matchLabels:
app.kubernetes.io/name: mon-api
topologyKey: kubernetes.io/hostname
weight: 100

Une toleration n'attire pas le pod vers un nœud : elle l'autorise seulement à y aller malgré son taint. C'est le nodeSelector qui l'y envoie, d'où l'emploi des deux ensemble dans cet exemple. Les confondre produit des pods qui restent en Pending sans raison apparente.

Fenêtre de terminal
helm template test-release mon-api \
--set 'nodeSelector.disktype=ssd' \
--set 'tolerations[0].key=dedicated' \
--set 'tolerations[0].operator=Equal' \
--set 'tolerations[0].value=api' \
--set 'tolerations[0].effect=NoSchedule'
nodeSelector:
disktype: ssd
tolerations:
- effect: NoSchedule
key: dedicated
operator: Equal
value: api

Ces métadonnées ne changent rien au fonctionnement de l'application, mais elles commandent le comportement des outils qui l'entourent : collecte de métriques, injection de secrets, service mesh. Un chart qui ne les expose pas oblige à modifier ses templates pour chaque nouvelle intégration.

Les annotations permettent d'intégrer vos pods avec d'autres outils :

OutilAnnotationUsage
Prometheusprometheus.io/scrape: "true"Découverte des métriques
Vaultvault.hashicorp.com/agent-inject: "true"Injection de secrets
Istiosidecar.istio.io/inject: "true"Service mesh
Datadogad.datadoghq.com/...APM et logs

Les deux dictionnaires restent vides par défaut : leur contenu dépend entièrement des outils déployés dans le cluster de destination.

podAnnotations: {}
podLabels: {}

L'ordre compte dans le bloc labels : les selectorLabels sont rendus en premier, les podLabels de l'utilisateur ensuite. Un label personnalisé portant le même nom écraserait un label de sélection et détacherait le pod de son Deployment.

template:
metadata:
{{- with .Values.podAnnotations }}
annotations:
{{- toYaml . | nindent 8 }}
{{- end }}
labels:
{{- include "mon-api.selectorLabels" . | nindent 8 }}
{{- with .Values.podLabels }}
{{- toYaml . | nindent 8 }}
{{- end }}

Les points des clés d'annotation s'échappent comme pour l'Ingress. Le rendu montre que 9898 ressort entre guillemets : une annotation Kubernetes ne peut contenir qu'une chaîne de caractères, jamais un nombre.

Fenêtre de terminal
helm template test-release mon-api \
--set 'podAnnotations.prometheus\.io/scrape=true' \
--set 'podAnnotations.prometheus\.io/port=9898' \
--set 'podAnnotations.prometheus\.io/path=/metrics'
metadata:
annotations:
prometheus.io/path: /metrics
prometheus.io/port: "9898"
prometheus.io/scrape: "true"

Un hook est un objet Kubernetes ordinaire, marqué par des annotations qui indiquent à Helm de le créer à un moment précis, en dehors du cycle de vie normal de la release. C'est ce qui permet d'exécuter une migration de base de données avant que les nouveaux pods ne démarrent.

Les hooks exécutent des Jobs à des moments précis du cycle de vie :

HookMoment d'exécutionCas d'usage
pre-installAvant l'installationVérifications préalables
post-installAprès l'installationSeed de données, notifications
pre-upgradeAvant la mise à jourMigrations de BDD
post-upgradeAprès la mise à jourTests de smoke
pre-deleteAvant la suppressionBackup, nettoyage
post-deleteAprès la suppressionNotification

Le hook est désactivé par défaut, la commande servant de gabarit. L'image est épinglée sur une version précise : un hook lancé depuis un tag flottant peut changer de comportement entre deux déploiements sans qu'aucun fichier du chart n'ait bougé.

hooks:
preInstall:
enabled: false
image: busybox:1.36@sha256:73aaf090f3d85aa34ee199857f03fa3a95c8ede2ffd4cc2cdb5b94e566b11662
command: "echo 'Running pre-install hook'"

Trois annotations pilotent tout : le moment d'exécution, l'ordre relatif par un poids, et le sort du Job une fois terminé. Sans hook-delete-policy, les Jobs s'accumulent à chaque déploiement et finissent par bloquer le suivant sur un conflit de nom.

{{- if .Values.hooks.preInstall.enabled }}
apiVersion: batch/v1
kind: Job
metadata:
name: {{ include "mon-api.fullname" . }}-pre-install
labels:
{{- include "mon-api.labels" . | nindent 4 }}
annotations:
"helm.sh/hook": pre-install,pre-upgrade
"helm.sh/hook-weight": "-5"
"helm.sh/hook-delete-policy": hook-succeeded
spec:
template:
spec:
restartPolicy: Never
containers:
- name: pre-install
image: {{ .Values.hooks.preInstall.image | default "busybox:1.36" }}
command: ["sh", "-c", {{ .Values.hooks.preInstall.command | quote }}]
{{- end }}

Annotations expliquées :

AnnotationSignification
helm.sh/hookQuand exécuter le Job
helm.sh/hook-weightOrdre d'exécution (négatif = plus tôt)
helm.sh/hook-delete-policyQuand supprimer le Job (hook-succeeded, before-hook-creation, hook-failed)

helm template produit le Job comme n'importe quel manifeste : ce n'est qu'à l'installation que Helm lit les annotations et décale son exécution. Un hook ne se valide donc jamais complètement par un simple rendu, ce qui en fait la partie du chart la moins testable hors cluster.

Fenêtre de terminal
helm template test-release mon-api \
--set hooks.preInstall.enabled=true \
--set 'hooks.preInstall.command=echo "Running migrations"'
apiVersion: batch/v1
kind: Job
metadata:
name: test-release-mon-api-pre-install
annotations:
"helm.sh/hook": pre-install,pre-upgrade
"helm.sh/hook-weight": "-5"
"helm.sh/hook-delete-policy": hook-succeeded
spec:
template:
spec:
restartPolicy: Never
containers:
- name: pre-install
image: busybox:1.36@sha256:73aaf090f3d85aa34ee199857f03fa3a95c8ede2ffd4cc2cdb5b94e566b11662
command: ["sh", "-c", "echo \"Running migrations\""]

La fonction lookup interroge le cluster pendant le rendu : elle répond « cette ressource existe-t-elle, et que contient-elle ». C'est le seul moyen, dans un template, de tenir compte de ce qui est déjà déployé au lieu de raisonner uniquement sur les values.

Le besoin le plus courant est un mot de passe qu'il ne faut générer qu'une fois. Un chart qui écrit randAlphaNum 24 dans un Secret produit une valeur différente à chaque rendu : le premier helm upgrade change le mot de passe sous les pieds de l'application, qui ne peut plus se connecter à sa base. Mesuré sur deux releases identiques, l'une avec lookup et l'autre sans :

ChartAprès installAprès upgrade
Sans lookupYXdFOVFSclVl…WWdkUWpZRjJ2…, régénéré
Avec lookupbFNOTmpPYXVU…bFNOTmpPYXVU…, conservé

Le template lit d'abord le Secret existant, et ne tire une valeur au sort que s'il ne le trouve pas :

{{- $existant := lookup "v1" "Secret" .Release.Namespace (printf "%s-auth" .Release.Name) }}
apiVersion: v1
kind: Secret
metadata:
name: {{ .Release.Name }}-auth
type: Opaque
data:
motdepasse: {{ if $existant }}{{ index $existant.data "motdepasse" }}{{ else }}{{ randAlphaNum 24 | b64enc | quote }}{{ end }}

lookup ne renvoie jamais d'erreur quand il ne peut pas interroger le cluster : il renvoie une map vide. C'est ce qui le rend piégeux, parce que le template continue son rendu comme si de rien n'était.

RégimekindOfemptyValeur lue
helm templatemaptruerien
helm install --dry-run=servermapfalsekube-system

Déréférencer ce résultat vide fait échouer le rendu entier, avec un message qui ne mentionne ni lookup ni le cluster :

Error: c/templates/s.yaml:6:39
executing "c/templates/s.yaml" at <"kube-system">:
nil pointer evaluating interface {}.name

Testez donc toujours le résultat avec if ou default avant de le lire, comme dans le gabarit ci-dessus. Un lookup non protégé transforme une simple prévisualisation hors ligne en échec de rendu.

Les deux conséquences à connaître avant de s'en servir

Section intitulée « Les deux conséquences à connaître avant de s'en servir »

Le rendu local devient mensonger. Le même chart rendu deux fois de suite par helm template propose deux mots de passe différents, puisque lookup ne trouve rien et que la branche else tire au sort à chaque appel. Un helm diff exécuté sans cluster annoncera donc une modification du Secret qui n'aura pas lieu. La revue de ces charts-là passe par --dry-run=server.

Les droits nécessaires sont plus larges qu'il n'y paraît. Même pour chercher une seule ressource par son nom, lookup demande le verbe list sur le type concerné. Un compte qui ne l'a pas fait échouer l'upgrade au rendu :

Error: UPGRADE FAILED: query: failed to query with labels: secrets is forbidden:
User "system:serviceaccount:lk-lab:aveugle" cannot list resource "secrets"
in API group "" in the namespace "lk-lab"

Autrement dit, un chart qui utilise lookup sur des Secrets impose un droit de lecture sur tous les Secrets du namespace à qui l'installe. C'est un arbitrage de sécurité à faire consciemment, pas un détail d'implémentation : la personne qui déploie hérite d'un privilège qu'elle n'a pas demandé.


Ces symptômes viennent presque tous d'un décalage entre le chart et son environnement : une sonde trop stricte pour le temps de démarrage réel, un securityContext incompatible avec l'image, ou un composant de cluster absent. Le rendu est correct dans tous ces cas : l'erreur n'apparaît qu'à l'exécution.

SymptômeCause probableSolution
Pod en CrashLoopBackOffProbe échoue, app pas prêteAugmenter initialDelaySeconds
Pod OOMKilledLimite memory dépasséeAugmenter limits.memory
Pod ne démarre pas (security)Image incompatible securityContextVérifier UID de l'image
HPA <unknown>metrics-server absentInstaller metrics-server
Ingress sans effetIngressController absentInstaller nginx-ingress ou traefik
Hook ne s'exécute pasMauvaise annotationVérifier helm.sh/hook

L'exercice reprend les gabarits un par un sur une copie du chart précédent, avec helm lint --strict et helm template comme garde-fous avant toute installation. La vérification finale lit le securityContext réellement appliqué au conteneur en cours d'exécution : seule preuve que le réglage a traversé le rendu puis l'admission.

  1. Partir du chart du Lab B1

    Fenêtre de terminal
    cd /tmp && cp -r mon-api mon-api-prod
    cd mon-api-prod
  2. Mettre à jour values.yaml avec les patterns

    Ajoutez les sections : probes, resources, securityContext, serviceAccount, autoscaling.

  3. Valider avec helm lint

    Fenêtre de terminal
    helm lint . --strict
  4. Prévisualiser le rendu complet

    Fenêtre de terminal
    helm template my-release . > rendered.yaml
    cat rendered.yaml
  5. Tester avec autoscaling activé

    Fenêtre de terminal
    helm template my-release . --set autoscaling.enabled=true | grep -A5 "kind: HorizontalPodAutoscaler"
  6. Tester avec Ingress et TLS

    Fenêtre de terminal
    helm template my-release . \
    --set ingress.enabled=true \
    --set 'ingress.tls[0].secretName=my-tls' \
    --set 'ingress.tls[0].hosts[0]=api.example.local'
  7. Installer et vérifier sur le cluster

    Fenêtre de terminal
    kubectl create namespace lab-b2
    helm install my-api . -n lab-b2
    kubectl get all,sa,cm -n lab-b2
  8. Vérifier le securityContext du pod

    Fenêtre de terminal
    kubectl get pod -n lab-b2 -o jsonpath='{.items[0].spec.containers[0].securityContext}' | jq
  9. Nettoyer

    Fenêtre de terminal
    helm uninstall my-api -n lab-b2
    kubectl delete namespace lab-b2

Critères de réussite :

  • Probes configurées et activées
  • Resources limits/requests présents
  • SecurityContext appliqué (runAsNonRoot, drop ALL capabilities)
  • ServiceAccount créé avec automount: false
  • HPA généré quand autoscaling.enabled=true
  • Ingress avec TLS quand activé
  • Pod Running avec securityContext vérifié

Les six premiers points portent sur les objets Kubernetes, les deux derniers sur le templating lui-même. Ce sont ces deux-là qui séparent un chart robuste d'un chart approximatif : with évite de rendre des blocs vides, et $ sauve les boucles imbriquées.

  • Les probes garantissent que Kubernetes détecte les conteneurs défaillants
  • Les resources protègent le cluster contre les conteneurs gourmands
  • Le securityContext applique le principe du moindre privilège
  • L'Ingress expose vos services avec TLS et routage intelligent
  • Le HPA adapte automatiquement le nombre de réplicas à la charge
  • Les hooks permettent d'orchestrer des actions au bon moment
  • Le pattern {{- with .Values.xxx }} évite de rendre des blocs vides
  • Utilisez $ pour accéder au contexte global dans les boucles range

Cinq questions sur les gabarits de production : ce que le squelette laisse vide, les deux régimes de lookup, et pourquoi un hook post-install ne protège de rien.

Contrôle de connaissances

Validez vos connaissances avec ce quiz interactif

7 questions
5 min.
80% requis

Informations

  • Le chronomètre démarre au clic sur Démarrer
  • Questions à choix multiples, vrai/faux et réponses courtes
  • Vous pouvez naviguer entre les questions
  • Les résultats détaillés sont affichés à la fin

Lance le quiz et démarre le chronomètre

Ce site vous est utile ?

Sachez que moins de 1% des lecteurs soutiennent ce site.

Je maintiens ce site gratuitement, sans publicité, sans profilage et sans compte à créer. Un soutien, même symbolique, m'aide à couvrir l'hébergement et à garder ces ressources gratuites. Merci pour votre appui.

Le formulaire ne s'affiche pas ? Ouvrir Ko-fi dans un onglet.

Abonnez-vous et suivez mon actualité DevSecOps sur LinkedIn