Riadh Mnasri
← Retour au blog
6 min de lecture

Kubernetes au quotidien pour un développeur Java : ce qui compte vraiment

La plupart des introductions à Kubernetes parlent de Pods et de Deployments comme s'il s'agissait d'un simple outil de déploiement de plus. Pour un développeur Java, le vrai sujet est ailleurs : Kubernetes impose des contraintes (limites mémoire, sondes de santé, configuration externalisée) qui rentrent en friction directe avec la façon dont la JVM a toujours géré ses propres ressources. C'est cette friction qu'il faut comprendre pour l'utiliser au quotidien, pas la liste des objets de l'API.

Les objets qu'on touche vraiment, au quotidien

ObjetRôleCe qui concerne spécifiquement un développeur Java
PodUnité de déploiement, une ou plusieurs conteneursGénéralement un seul conteneur JVM par pod, parfois un sidecar (agent de monitoring, proxy)
DeploymentGère les réplicas et les mises à jour progressives (rolling updates)Le temps de démarrage de la JVM détermine la vitesse réelle d'un rolling update
ServiceExpose un ensemble de pods sous une IP/nom stableDécouplé du cycle de vie des pods, y compris pendant un redémarrage JVM
ConfigMap / SecretConfiguration et secrets externalisésConcurrence directe avec les profils Spring (application-{profil}.yml)
IngressRoutage HTTP externe vers les ServicesLe contrôleur d'Ingress diffère selon le cloud (voir plus bas)

Le piège le plus classique : la JVM ne sait pas qu'elle est dans un conteneur (ou le sait mal)

Avant Java 10, la JVM lisait la mémoire et le nombre de CPU de la machine hôte, pas les limites du conteneur. Sur un pod limité à 512 Mi mais tournant sur un nœud de 16 Gi, la JVM calculait un tas par défaut basé sur les 16 Gi, et le conteneur se faisait tuer (OOMKilled) bien avant que la JVM ne pense être en difficulté mémoire.

Depuis Java 10, UseContainerSupport est activé par défaut, avec un rétroport sur Java 8 (disponible dès la 8u191, mais réellement activé par défaut à partir de la 8u251) : la JVM lit correctement les limites cgroup. Le piège n'a pas disparu pour autant, il a changé de forme : un -Xmx fixe, copié d'un environnement à l'autre, ne s'adapte pas si la limite mémoire du pod change entre le dev, la préproduction et la production.

resources:
  requests:
    memory: "512Mi"
    cpu: "250m"
  limits:
    memory: "512Mi"
    cpu: "500m"
# Pas ça : figé, ne s'adapte pas si la limite change
-Xmx400m

# Plutôt ça : proportionnel à la limite réelle du conteneur
-XX:MaxRAMPercentage=75.0

MaxRAMPercentage calcule le tas comme un pourcentage de la mémoire réellement allouée au conteneur, pas de la machine hôte : le même artefact Docker se comporte correctement, que la limite soit 512 Mi en dev ou 2 Gi en production, sans reconstruire l'image.

Les sondes de santé, pas de simples pings

Kubernetes distingue liveness (le pod doit-il être redémarré ?) et readiness (le pod doit-il recevoir du trafic ?), une distinction que beaucoup de développeurs Java ignorent en configurant les deux sondes sur le même endpoint. Depuis Spring Boot 2.3, Actuator expose nativement les deux notions séparément (activé par défaut à partir de Spring Boot 4 ; avant cette version, il faut explicitement positionner management.health.probes.enabled=true) :

livenessProbe:
  httpGet:
    path: /actuator/health/liveness
    port: 8080
  initialDelaySeconds: 30
readinessProbe:
  httpGet:
    path: /actuator/health/readiness
    port: 8080
  initialDelaySeconds: 20

La confusion la plus coûteuse : brancher la liveness sur un healthcheck qui vérifie une dépendance externe (base de données, service tiers). Si cette dépendance est indisponible, Kubernetes redémarre le pod en boucle, aggravant la situation au lieu de la stabiliser. La liveness doit répondre à une seule question, « ce processus JVM est-il bloqué ? », pas « toutes ses dépendances sont-elles saines ? » : c'est le rôle de la readiness, qui retire simplement le pod du trafic sans le tuer.

Configuration externalisée : ConfigMap vs profils Spring

Un ConfigMap monté en fichier peut remplacer un profil Spring, mais les deux mécanismes ont des règles de précédence différentes, et les combiner sans y réfléchir crée des configurations qui varient silencieusement entre environnements. La pratique la plus robuste : garder les profils Spring pour la structure de configuration (quelles clés existent), et utiliser le ConfigMap uniquement pour les valeurs qui changent réellement par environnement, montées comme variables d'environnement plutôt que comme fichier, pour que @Value et @ConfigurationProperties les résolvent de la même façon quel que soit l'environnement.

Ce qui change vraiment entre GCP, Azure et AWS

AspectGKE (GCP)AKS (Azure)EKS (AWS)
Identité pod → service cloudWorkload IdentityMicrosoft Entra Workload IDIAM Roles for Service Accounts (IRSA), ou EKS Pod Identity
Registre de conteneurs managéArtifact RegistryAzure Container RegistryElastic Container Registry
Autoscaling de nœudsAutopilot (géré) ou node pools classiquesCluster AutoscalerCluster Autoscaler ou Karpenter
Logs par défautCloud Logging, intégré sans agentContainer Insights (Log Analytics)CloudWatch Container Insights

Le point commun aux trois, plus important que leurs différences de nom : le mécanisme d'identité fédérée (un pod obtient un jeton cloud à courte durée de vie via son ServiceAccount Kubernetes, sans clé statique stockée en secret) remplace progressivement les identifiants longue durée dans les trois clouds. C'est la partie qui mérite d'être bien comprise, pas les noms de produits, qui évoluent plus vite que le concept sous-jacent.

Le vrai coût caché : le démarrage JVM face à l'autoscaling

Un Horizontal Pod Autoscaler qui réagit à un pic de charge en ajoutant des réplicas suppose implicitement que ces réplicas deviennent utiles vite. Une JVM Spring Boot classique met souvent plusieurs secondes à devenir ready (chargement du contexte Spring, connexions de pool, warmup JIT), largement plus qu'un runtime plus léger. Sur un pic de trafic soudain, l'autoscaling réagit, mais les nouveaux pods n'absorbent la charge qu'après ce délai, pendant lequel les pods existants encaissent seuls la surcharge. Les réponses courantes (image native GraalVM, pré-scaling basé sur des métriques prédictives plutôt que réactives, ou simplement des réplicas minimums plus généreux) ont chacune leur propre coût, mais le problème lui-même mérite d'être anticipé dès la conception, pas découvert le jour du premier pic réel.

Ce que je ne recommande pas par défaut : Kubernetes partout

Sur MissionMatch, les services tournent sur AWS ECS, pas sur EKS. Le choix n'est pas une esquive : ECS retire la responsabilité d'opérer le plan de contrôle Kubernetes, pour un besoin qui ne justifie pas cette complexité supplémentaire (un nombre modéré de services, sans exigence multi-cloud ni d'écosystème d'opérateurs Kubernetes spécifique). Kubernetes se justifie quand la portabilité multi-cloud, l'écosystème d'outils (service mesh, opérateurs), ou l'échelle réelle du nombre de services l'exigent, pas comme choix par défaut parce que c'est devenu le standard perçu de l'industrie.