DevOps, CI/CD et conteneurs

Patterns Kubernetes : probes, ressources, scaling, stockage et sécurité

Avancé12 min de lectureMis à jour

Cette page a été traduite de l'anglais avec l'aide de l'IA, puis vérifiée par rapport à l'original. Lire la version anglaise

Un Deployment et un Service suffisent à faire tourner votre application ; ils ne l'aideront pas à survivre à une mauvaise release, à un voisin bruyant ou à la perte d'un nœud. Ce guide présente les patterns qui séparent un cluster de démo d'un cluster de production : des probes qui disent la vérité, des requests qui rendent l'ordonnancement prévisible, des déploiements sûrs, l'autoscaling, l'état persistant, le moindre privilège et une façon raisonnable de packager les manifestes par environnement.

Ce que vous allez apprendre

  • Configurer des probes liveness, readiness et startup qui reflètent l'état réel de l'application
  • Fixer requests et limits en connaissance de cause et prévoir la classe QoS et le comportement d'éviction qui en découlent
  • Maîtriser les rollouts grâce aux paramètres de surge, aux disruption budgets et au rollback
  • Faire du scaling horizontal sur le CPU et sur des métriques personnalisées
  • Exécuter des workloads stateful avec des StatefulSets, des PersistentVolumeClaims et des StorageClasses
  • Appliquer RBAC, security contexts et network policies, et packager les manifestes avec Helm ou Kustomize

Entre « ça tourne sur le cluster » et « ça tourne en production », il y a une série de décisions qu'on repousse facilement : comment le cluster décide que votre conteneur est en bonne santé, ce qui se passe quand il consomme plus de mémoire que prévu, comment on contient une mauvaise release, ce qui casse quand un nœud est drainé pour maintenance. Faites l'impasse dessus et les symptômes sont connus : redémarrages pendant les pics de trafic, déploiements qui perdent des requêtes, un service gourmand qui dégrade tout le reste du nœud.

Ce guide passe ces décisions en revue sous forme de patterns, chacun accompagné du fragment de manifeste qui le met en œuvre. Il suppose que vous connaissez les objets présentés dans le guide Les bases de Kubernetes et vise les clusters de la série 1.3x.

Des probes qui disent la vérité

Kubernetes pose trois questions distinctes à chaque conteneur, et les confondre cause plus d'incidents en production que n'importe quelle autre erreur de configuration.

La liveness demande : ce processus est-il bloqué sans espoir de s'en remettre ? En cas d'échec, le conteneur est redémarré. La readiness demande : cette instance peut-elle servir du trafic en ce moment ? En cas d'échec, le Pod est retiré des endpoints du Service mais continue de tourner. La startup demande : le processus a-t-il fini de démarrer ? Tant qu'elle échoue, liveness et readiness sont suspendues, ce qui permet à une application lente au démarrage d'éviter une boucle de redémarrages sans relâcher les délais de liveness pour tout le reste de sa vie.

startupProbe:
  httpGet: { path: /actuator/health/liveness, port: http }
  failureThreshold: 30
  periodSeconds: 5          # allows up to 150s to boot
livenessProbe:
  httpGet: { path: /actuator/health/liveness, port: http }
  periodSeconds: 10
  failureThreshold: 3
readinessProbe:
  httpGet: { path: /actuator/health/readiness, port: http }
  periodSeconds: 5
  failureThreshold: 2

Les règles qui découlent de ces définitions :

  • L'endpoint de liveness ne doit pas vérifier les dépendances. Il doit répondre 200 tant que le processus est encore capable de traiter des requêtes. Un endpoint qui interroge la base de données transforme le moindre hoquet de la base en tempête de redémarrages sur tout le cluster.
  • L'endpoint de readiness doit vérifier les dépendances dont l'instance a réellement besoin, et commencer à signaler un échec dès que l'application reçoit SIGTERM, pour que le trafic soit drainé avant l'arrêt du processus.
  • Les délais des probes doivent correspondre à l'application. Un service JVM qui met 90 secondes à chauffer a besoin d'une startup probe, pas d'un initialDelaySeconds de 120 secondes sur la liveness.

Les frameworks aident : Spring Boot expose des groupes liveness et readiness distincts via Actuator, décrits dans le guide sur l'observabilité, et la plupart des écosystèmes ont un équivalent.

Associez la readiness à un arrêt propre (graceful shutdown). Quand un Pod est supprimé, Kubernetes le retire des endpoints et envoie SIGTERM à peu près au même moment ; des requêtes peuvent donc encore arriver pendant une seconde ou deux. Un sleep dans le hook preStop comble ce décalage :

lifecycle:
  preStop:
    exec: { command: ["sh", "-c", "sleep 5"] }
terminationGracePeriodSeconds: 45

Requests, limits et ce qu'en fait le scheduler

Une request est ce que le scheduler réserve ; une limit est ce que le runtime impose. Le scheduler ne place un Pod que sur un nœud dont la capacité non réservée couvre la somme des requests de ses conteneurs — la consommation réelle n'entre pas dans cette décision.

resources:
  requests:
    cpu: "250m"
    memory: "512Mi"
  limits:
    memory: "512Mi"

Les deux ressources se comportent de façon radicalement différente. Le CPU est compressible : dépasser la limit amène le noyau à brider le cgroup, ce qui ajoute de la latence par à-coups, sous la forme de pics mystérieux dans les percentiles élevés. La mémoire est incompressible : dépasser la limit entraîne la mort du conteneur, avec le code de sortie 137 et OOMKilled dans son statut. C'est cette asymétrie qui explique le conseil habituel : toujours définir des limits mémoire, fixer les requests mémoire à la même valeur pour tout ce qui compte, choisir soigneusement les requests CPU et se méfier des limits CPU.

Ensemble, requests et limits déterminent la classe QoS du Pod, qui décide qui meurt en premier quand un nœud manque de mémoire :

Classe QoSConditionOrdre d'éviction
GuaranteedChaque conteneur a des requests égales à ses limits, pour le CPU comme pour la mémoireEn dernier
BurstableAu moins une request définie, mais pas égale aux limitsEntre les deux
BestEffortAucune request ni limitEn premier

Donnez aux workloads de production la classe Guaranteed ou une classe Burstable bien réglée ; réservez BestEffort à ce que vous pouvez perdre sans regret.

Deux objets au niveau du namespace garantissent le respect de ces règles sur un cluster partagé. Un LimitRange fournit des valeurs par défaut et des plafonds pour qu'aucun Pod ne soit créé sans ressources, et un ResourceQuota borne la consommation totale du namespace :

apiVersion: v1
kind: ResourceQuota
metadata: { name: team-quota, namespace: staging }
spec:
  hard:
    requests.cpu: "20"
    requests.memory: 40Gi
    limits.memory: 60Gi
    pods: "80"

Pour les workloads JVM, n'oubliez pas que le conteneur doit connaître la limit. Les JVM modernes lisent automatiquement les limites du cgroup, mais définissez -XX:MaxRAMPercentage=75 pour que le heap laisse de la place au metaspace, aux piles des threads et aux buffers natifs : un heap dimensionné à 100 % de la limit mémoire, c'est un OOMKilled en puissance.

Des rollouts fiables

La strategy d'un Deployment détermine la façon dont il remplace les Pods :

spec:
  minReadySeconds: 15
  progressDeadlineSeconds: 600
  revisionHistoryLimit: 5
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1          # may create one extra Pod above replicas
      maxUnavailable: 0    # never drop below the desired count

maxUnavailable: 0 avec maxSurge: 1 est le réglage prudent : la capacité ne baisse jamais, au prix d'un rollout plus lent et des ressources d'un Pod supplémentaire. minReadySeconds empêche qu'un Pod qui passe la readiness puis plante soit compté comme un succès ; c'est ce qui fait qu'une mauvaise release se bloque au lieu de se propager.

progressDeadlineSeconds déclare en échec un rollout bloqué, mais ne le fait pas revenir en arrière. C'est à votre pipeline de s'en charger :

kubectl apply -f k8s/
if ! kubectl rollout status deploy/shop-api --timeout=10m; then
  kubectl rollout undo deploy/shop-api
  exit 1
fi

Un PodDisruptionBudget protège la disponibilité lors des interruptions volontaires : drain de nœuds, mises à jour du cluster, réduction par l'autoscaler. Il ne peut rien contre un nœud qui tombe en panne, mais il empêche un administrateur d'évincer tous vos réplicas d'un coup :

apiVersion: policy/v1
kind: PodDisruptionBudget
metadata: { name: shop-api }
spec:
  minAvailable: 2
  selector:
    matchLabels: { app: shop-api }

Fixez minAvailable en dessous de votre nombre de réplicas, sinon le budget bloque complètement les drains et quelqu'un finira par le supprimer à 3 heures du matin. Les stratégies blue-green et canary, et leurs compromis respectifs, sont traitées dans le guide sur les pipelines de livraison ; sur Kubernetes, on les met généralement en œuvre avec un contrôleur de progressive delivery plutôt qu'à la main.

Le scaling

Le HorizontalPodAutoscaler ajuste le nombre de réplicas d'un Deployment pour atteindre une valeur cible de métrique. Il lui faut metrics-server installé et des requests définies : l'utilisation CPU s'exprime en pourcentage de la request, donc un Pod sans request n'a pas de dénominateur.

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata: { name: shop-api }
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: shop-api
  minReplicas: 3
  maxReplicas: 20
  metrics:
    - type: Resource
      resource:
        name: cpu
        target: { type: Utilization, averageUtilization: 70 }
  behavior:
    scaleDown:
      stabilizationWindowSeconds: 300
      policies:
        - { type: Percent, value: 25, periodSeconds: 60 }
    scaleUp:
      policies:
        - { type: Percent, value: 100, periodSeconds: 30 }

C'est le bloc behavior qui rend l'autoscaling serein : monter vite quand la charge arrive, redescendre lentement avec une fenêtre de stabilisation de cinq minutes, pour qu'un creux passager ne retire pas une capacité dont vous aurez besoin une minute plus tard. Pour les workloads dont le goulot d'étranglement n'est pas le CPU — les consommateurs de files de messages, par exemple —, scalez sur une métrique personnalisée ou externe comme la profondeur de la file ; KEDA est la solution habituelle pour brancher ce type de métriques.

Deux compagnons : le VerticalPodAutoscaler recommande ou applique de meilleures requests et limits, et reste utile en mode recommandation même si vous ne le laissez jamais agir ; le Cluster Autoscaler (ou Karpenter sur AWS) ajoute et retire des nœuds quand des Pods ne peuvent pas être ordonnancés. Scaler horizontalement les Pods ne sert à rien si le cluster n'a pas de place pour les accueillir.

Faire entrer le trafic

Un Service LoadBalancer par application, c'est un load balancer cloud et une IP publique par application. L'Ingress regroupe tout cela : un seul contrôleur — ingress-nginx, Traefik, HAProxy ou celui de votre cloud — derrière un unique load balancer, qui route selon l'hôte et le chemin et assure la terminaison TLS.

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: shop
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt-prod
    nginx.ingress.kubernetes.io/proxy-body-size: "10m"
spec:
  ingressClassName: nginx
  tls:
    - hosts: [shop.example.com]
      secretName: shop-tls
  rules:
    - host: shop.example.com
      http:
        paths:
          - path: /api
            pathType: Prefix
            backend:
              service:
                name: shop-api
                port: { name: http }

Le point faible de l'Ingress, c'est que tout ce qui dépasse le routage par hôte et chemin passe par des annotations propres à chaque contrôleur, si bien que les manifestes cessent d'être portables. La Gateway API lui succède : GatewayClass, Gateway et HTTPRoute sont des ressources typées à part entière, qui gèrent le routage sur les en-têtes, la répartition pondérée du trafic et la délégation entre namespaces, avec une séparation nette entre l'équipe plateforme propriétaire de la Gateway et les équipes applicatives propriétaires des routes. C'est la direction à prendre pour les nouveaux clusters ; l'Ingress reste supporté et omniprésent.

Les workloads stateful

Le stockage s'exprime à travers trois objets. Un PersistentVolumeClaim est une demande de stockage ; une StorageClass décrit un type de stockage et le provisioner qui le crée à la demande ; un PersistentVolume est l'espace de stockage obtenu. Sur un cluster cloud, vous écrivez des claims qui référencent une classe, et le reste est automatique.

Un StatefulSet se distingue d'un Deployment sur des points essentiels pour les bases de données et les brokers : les Pods reçoivent des noms ordinaux stables (kafka-0, kafka-1), un nom DNS stable via un Service headless, leur propre PersistentVolumeClaim créé à partir d'un template et conservé d'un redémarrage à l'autre, et des rollouts ordonnés, un Pod à la fois.

apiVersion: apps/v1
kind: StatefulSet
metadata: { name: postgres }
spec:
  serviceName: postgres-headless
  replicas: 3
  selector: { matchLabels: { app: postgres } }
  template:
    metadata: { labels: { app: postgres } }
    spec:
      terminationGracePeriodSeconds: 60
      containers:
        - name: postgres
          image: postgres:16
          volumeMounts:
            - { name: data, mountPath: /var/lib/postgresql/data }
  volumeClaimTemplates:
    - metadata: { name: data }
      spec:
        accessModes: ["ReadWriteOnce"]
        storageClassName: fast-ssd
        resources: { requests: { storage: 100Gi } }

Remarquez ReadWriteOnce : la plupart des stockages en mode bloc ne peuvent être montés que par un seul nœud à la fois, donc deux Pods ne peuvent pas partager un tel volume. Un accès partagé en lecture-écriture nécessite une classe de stockage de type fichier qui supporte ReadWriteMany.

Un StatefulSet vous donne une identité et du stockage. Il ne vous donne ni réplication, ni failover, ni sauvegardes : tout cela vient du logiciel de base de données et, en pratique, d'un operator, un contrôleur qui encode le savoir-faire d'exploitation d'un système donné, surveille des ressources personnalisées et se charge des sauvegardes, des failovers et des montées de version. Faire tourner sérieusement une base de données sur Kubernetes, c'est faire tourner son operator — ou utiliser un service managé et y connecter le cluster.

Les traitements batch

Un Job exécute des Pods jusqu'à ce qu'un nombre donné d'entre eux se termine avec succès. Un CronJob crée des Jobs selon un planning.

apiVersion: batch/v1
kind: CronJob
metadata: { name: nightly-reconcile }
spec:
  schedule: "17 2 * * *"
  timeZone: "Europe/Paris"
  concurrencyPolicy: Forbid
  startingDeadlineSeconds: 300
  successfulJobsHistoryLimit: 3
  failedJobsHistoryLimit: 3
  jobTemplate:
    spec:
      backoffLimit: 2
      activeDeadlineSeconds: 3600
      ttlSecondsAfterFinished: 86400
      template:
        spec:
          restartPolicy: OnFailure
          containers:
            - name: job
              image: registry.example.com/acme/reconcile:1.4.2

concurrencyPolicy: Forbid empêche une exécution lente de chevaucher la suivante — la valeur par défaut, Allow, est à l'origine de bien des traitements en double. ttlSecondsAfterFinished nettoie les Jobs terminés ; sans lui, le namespace accumule des milliers de Pods finis. Enfin, activeDeadlineSeconds empêche un job bloqué de tourner indéfiniment.

Init containers et sidecars

Les init containers s'exécutent jusqu'au bout, dans l'ordre, avant le démarrage des conteneurs applicatifs. C'est le bon endroit pour attendre une dépendance, lancer une migration de schéma ou récupérer un fichier dans un emptyDir partagé.

Les sidecars tournent à côté de l'application pendant toute la vie du Pod. Depuis la 1.29, ils sont pris en charge nativement : déclarez-les comme init containers avec restartPolicy: Always, et le kubelet les démarre avant les conteneurs applicatifs et les arrête après eux — ce qui règle enfin le vieux problème du log shipper qui meurt avant d'avoir tout envoyé, ou du sidecar qui maintient indéfiniment en vie le Pod d'un Job.

initContainers:
  - name: wait-for-db
    image: busybox:1.36
    command: ["sh", "-c", "until nc -z postgres 5432; do sleep 2; done"]
  - name: log-shipper           # a native sidecar
    image: fluent-bit:3.1
    restartPolicy: Always
    volumeMounts:
      - { name: logs, mountPath: /var/log/app }

Les variantes adapter et ambassador de la même idée — un conteneur qui remet en forme la sortie de l'application, ou qui sert de proxy à ses appels sortants — sont ce que les service meshes installent automatiquement.

Le moindre privilège

Quatre mécanismes, chacun fermant une porte différente.

Le security context contraint le conteneur lui-même :

securityContext:
  runAsNonRoot: true
  runAsUser: 10001
  allowPrivilegeEscalation: false
  readOnlyRootFilesystem: true
  capabilities: { drop: ["ALL"] }
  seccompProfile: { type: RuntimeDefault }

Un système de fichiers racine en lecture seule vous oblige à monter des volumes emptyDir partout où l'application écrit réellement, ce qui constitue en soi un audit utile. La Pod Security Admission impose ces réglages à l'échelle d'un namespace via un label comme pod-security.kubernetes.io/enforce: restricted, de sorte qu'un manifeste qui les omet est rejeté au lieu d'être silencieusement non sécurisé.

RBAC régit ce qu'un sujet peut faire via l'API. Donnez à chaque workload son propre ServiceAccount et n'accordez que le strict nécessaire :

apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata: { namespace: staging, name: config-reader }
rules:
  - apiGroups: [""]
    resources: ["configmaps"]
    verbs: ["get", "list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata: { namespace: staging, name: shop-api-config-reader }
subjects:
  - kind: ServiceAccount
    name: shop-api
roleRef: { kind: Role, name: config-reader, apiGroup: rbac.authorization.k8s.io }

Role et RoleBinding sont limités à un namespace ; ClusterRole et ClusterRoleBinding s'appliquent à tout le cluster. Définissez automountServiceAccountToken: false sur les workloads qui n'appellent jamais l'API. kubectl auth can-i --list --as=system:serviceaccount:staging:shop-api vous indique ce qu'un service account peut réellement faire, ce qui dépasse généralement ce qu'imaginait son propriétaire.

La NetworkPolicy restreint le trafic entre Pods, qui est sinon entièrement ouvert. Le pattern consiste à tout refuser par défaut dans un namespace, puis à autoriser des flux précis :

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: { name: default-deny-ingress, namespace: staging }
spec:
  podSelector: {}
  policyTypes: ["Ingress"]
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: { name: api-from-ingress, namespace: staging }
spec:
  podSelector: { matchLabels: { app: shop-api } }
  policyTypes: ["Ingress"]
  ingress:
    - from:
        - namespaceSelector: { matchLabels: { kubernetes.io/metadata.name: ingress-nginx } }
      ports:
        - { protocol: TCP, port: 8080 }

Les policies sont appliquées par le plugin CNI : vérifiez donc que le vôtre les prend en charge. Avec certains plugins, les objets sont acceptés puis ignorés en silence, ce qui est pire que de ne pas en avoir.

Packager les manifestes

Copier le YAML pour chaque environnement ne résiste pas à l'arrivée d'un troisième environnement. Deux outils dominent.

Kustomize est intégré à kubectl. Un répertoire de base contient des manifestes bruts ; des overlays leur appliquent des patches :

# overlays/prod/kustomization.yaml
resources:
  - ../../base
namespace: prod
images:
  - name: registry.example.com/acme/shop-api
    newTag: "1.4.2"
replicas:
  - { name: shop-api, count: 6 }
patches:
  - path: resources-patch.yaml
    target: { kind: Deployment, name: shop-api }
configMapGenerator:
  - name: shop-api-config
    files: [application.yaml]

Rien n'est templaté : chaque fichier est du YAML valide et lisible, et kubectl kustomize overlays/prod affiche exactement ce qui sera appliqué. Les générateurs ajoutent un hash du contenu au nom des ConfigMaps, si bien qu'un changement de configuration déclenche automatiquement un rollout — le problème que l'annotation de checksum résout à la main.

Helm package les manifestes sous forme de chart versionné, avec des templates Go et un fichier values.yaml. C'est le format de distribution de la quasi-totalité des composants tiers — helm install ingress-nginx ingress-nginx/ingress-nginx -f values.yaml — et il garde la trace des releases, si bien que helm rollback restaure les manifestes précédents. Le revers, c'est que le YAML templaté est pénible à lire et à déboguer : lancer helm template avant helm install doit devenir un réflexe.

Quel que soit votre choix, les manifestes ont leur place dans Git et doivent être appliqués par un processus automatisé, pas depuis un portable. Les contrôleurs GitOps comme Argo CD et Flux poussent cette logique jusqu'au bout : le dépôt est l'état souhaité, le contrôleur réconcilie en continu le cluster avec lui, et les dérives sont signalées au lieu d'être découvertes par hasard.

Points clés à retenir

  • La liveness redémarre, la readiness draine, la startup protège les démarrages lents : ne laissez jamais une liveness probe vérifier une dépendance, et ajoutez un délai preStop pour que les connexions en cours puissent se terminer.
  • Les requests pilotent l'ordonnancement, les limits contraignent l'exécution ; définissez toujours des limits mémoire, méfiez-vous des limits CPU et sachez dans quelle classe QoS tombent vos Pods.
  • Les rolling updates avec maxUnavailable: 0 et minReadySeconds échouent sans casse, mais le rollback est l'affaire de votre pipeline ; un PodDisruptionBudget vous protège pendant les drains.
  • Faites de l'autoscaling sur le CPU rapporté aux requests, réglez le bloc behavior pour que la réduction soit lente, et n'oubliez pas que c'est le cluster autoscaler qui doit fournir les nœuds.
  • Regroupez dès aujourd'hui les points d'entrée derrière un contrôleur Ingress et préparez le passage à la Gateway API ; gardez le comportement propre au contrôleur dans des annotations faciles à retrouver.
  • Les StatefulSets apportent l'identité et un stockage par Pod ; réplication, failover et sauvegardes viennent du logiciel ou de son operator, pas de Kubernetes.
  • Verrouillez les workloads avec un security context restrictif, un ServiceAccount par workload doté d'un RBAC minimal, et une NetworkPolicy de refus par défaut réellement appliquée par votre CNI.
  • Utilisez des overlays Kustomize pour vos propres services et des charts Helm pour les logiciels tiers, versionnez les deux dans Git et laissez un contrôleur GitOps les appliquer.

Sources et lectures complémentaires

Questions fréquentes

Faut-il définir des limits CPU ?
Souvent, non. Le CPU est compressible : dépasser la limit ne tue pas le conteneur, il est bridé (throttling), et un throttling agressif provoque des pics de latence difficiles à diagnostiquer. Une request CPU garantit une part de CPU et pilote l'ordonnancement, et c'est ce qui compte. La mémoire, c'est autre chose : elle n'est pas compressible, donc une limit mémoire est indispensable pour empêcher un conteneur de faire tomber tout un nœud, et elle doit être égale à la request pour les workloads critiques.
Pourquoi mon Pod redémarre-t-il alors que l'application va bien ?
En général, à cause d'une liveness probe trop stricte. Si l'endpoint vérifie la connexion à la base de données, une requête lente ou une brève indisponibilité suffit à déclarer le conteneur mort, et Kubernetes le redémarre — ce qui ne répare pas la base et fait perdre les requêtes en cours. La liveness doit seulement vérifier que le processus n'est pas bloqué. L'état des dépendances relève de la readiness probe, qui retire le Pod du load balancing sans le tuer.
Que se passe-t-il réellement pendant un rolling update ?
Le Deployment crée un nouveau ReplicaSet et le fait monter en charge tout en réduisant l'ancien, dans le respect de maxSurge et maxUnavailable. Un nouveau Pod n'est considéré comme disponible qu'une fois sa readiness probe passée et minReadySeconds écoulé. Si les nouveaux Pods ne deviennent jamais prêts, progressDeadlineSeconds finit par déclarer le rollout en échec — sans rollback automatique : surveillez donc le statut du rollout dans votre pipeline, ou utilisez un contrôleur qui s'en charge.
Helm ou Kustomize ?
Kustomize convient à vos propres applications : vous gardez du YAML brut et superposez des patches propres à chaque environnement sur une base, sans langage de templating à déboguer. Helm convient aux logiciels que vous distribuez ou consommez, car un chart est un package versionné avec ses values, ses dépendances et son historique de releases. Beaucoup d'équipes utilisent les deux — Helm pour les composants tiers, Kustomize pour leurs propres services — et cette combinaison est parfaitement raisonnable.