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
| Objet | Rôle | Ce qui concerne spécifiquement un développeur Java |
|---|---|---|
| Pod | Unité de déploiement, une ou plusieurs conteneurs | Généralement un seul conteneur JVM par pod, parfois un sidecar (agent de monitoring, proxy) |
| Deployment | Gè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 |
| Service | Expose un ensemble de pods sous une IP/nom stable | Découplé du cycle de vie des pods, y compris pendant un redémarrage JVM |
| ConfigMap / Secret | Configuration et secrets externalisés | Concurrence directe avec les profils Spring (application-{profil}.yml) |
| Ingress | Routage HTTP externe vers les Services | Le 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
| Aspect | GKE (GCP) | AKS (Azure) | EKS (AWS) |
|---|---|---|---|
| Identité pod → service cloud | Workload Identity | Microsoft Entra Workload ID | IAM Roles for Service Accounts (IRSA), ou EKS Pod Identity |
| Registre de conteneurs managé | Artifact Registry | Azure Container Registry | Elastic Container Registry |
| Autoscaling de nœuds | Autopilot (géré) ou node pools classiques | Cluster Autoscaler | Cluster Autoscaler ou Karpenter |
| Logs par défaut | Cloud Logging, intégré sans agent | Container 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.