Réponse courte : pour qu'une application Next.js monte en charge correctement sur Kubernetes, le HPA n'est que la dernière pièce. Il faut d'abord fixer des requests CPU mesurées (typiquement 100 à 250 m par pod pour du rendu serveur léger), une limit mémoire sans limit CPU, des probes readiness/startup séparées, un terminationGracePeriodSeconds cohérent avec le drain de l'ingress, et un PodDisruptionBudget. Ensuite seulement un HorizontalPodAutoscaler sur CPU à 60-70 %, complété par KEDA quand la charge est une file d'attente ou un trafic planifié. Sans ce travail préalable, le HPA oscille, double la facture et ne corrige rien.
Nous reprenons régulièrement des clusters où le HPA existe depuis des mois sans avoir jamais servi. Soit il affiche <unknown>/70% parce que les pods n'ont pas de requests, soit il grimpe à maxReplicas à la moindre compilation d'images et redescend vingt minutes plus tard, soit il scale pendant que les utilisateurs prennent des 502 parce que les anciens pods sont tués avant d'être retirés du backend de l'ingress. Cet article décrit le réglage complet, dans l'ordre où il faut le faire.

Pourquoi le HPA ne marche pas tant que les requests sont fausses
Le HorizontalPodAutoscaler en mode Resource ne raisonne pas en CPU absolu. Il calcule un ratio : consommation observée divisée par la requests déclarée du conteneur. La documentation Kubernetes sur le HPA est explicite — sans resources.requests.cpu, la métrique d'utilisation est indisponible et le contrôleur ne décide rien.
Les conséquences pratiques se déclinent en trois cas que l'on retrouve partout :
| Situation | Effet sur le HPA |
|---|---|
Pas de requests.cpu | targetCPUUtilizationPercentage inexploitable, HPA inerte |
requests.cpu trop haute (1 CPU « pour être tranquille ») | utilisation réelle à 8 %, le HPA ne scale jamais, et chaque pod réserve un cœur sur le nœud |
requests.cpu trop basse (10 m) | utilisation à 400 %, HPA à maxReplicas en permanence |
La première étape est donc une mesure, pas une estimation. Sur un déploiement existant, avec metrics-server installé :
# consommation instantanée par pod
kubectl top pod -n prod -l app=web --containers
# sur une période, si vous avez Prometheus
# rate(container_cpu_usage_seconds_total{namespace="prod",container="web"}[5m])
Pour une application Next.js en output: 'standalone' servant majoritairement des pages prérendues et quelques routes dynamiques, nous mesurons couramment 40 à 120 m de CPU par pod au repos sous trafic modéré, avec des pointes brèves à 400-600 m sur du rendu serveur ou de la revalidation. D'où le réglage que nous appliquons par défaut :
resources:
requests:
cpu: 150m
memory: 256Mi
limits:
memory: 512Mi
Deux choix méritent justification.
Pas de limits.cpu. Le CPU est une ressource compressible : au-delà de la requests, le pod prend ce qui est libre sur le nœud. Poser une limit active le throttling du CFS (cpu.max en cgroup v2), et sur un runtime Node.js mono-thread qui fait des pointes courtes, le throttling se traduit directement en latence p95. Le phénomène est documenté depuis des années et le correctif du burst CFS n'est pas activé partout. Nous laissons le CPU sans plafond et nous protégeons le nœud par les requests des autres charges.
Une limits.memory, elle, est nécessaire. La mémoire est incompressible : sans plafond, une fuite dans un pod fait partir le nœud en pression mémoire et déclenche des évictions ailleurs. Avec Node.js 22, pensez à aligner le tas : NODE_OPTIONS=--max-old-space-size=384 dans un conteneur limité à 512 Mi évite l'OOMKill brutal au profit d'une erreur JavaScript lisible.
Vérifier la classe de QoS
kubectl get pod -n prod -l app=web \
-o custom-columns=NAME:.metadata.name,QOS:.status.qosClass
Avec requests.memory égale à limits.memory mais pas de limits.cpu, vous obtenez Burstable. C'est le bon compromis : le pod n'est pas le premier candidat à l'éviction, sans réserver un cœur entier.
Probes : la condition pour que le scaling ne casse rien
Un HPA qui crée trois pods pendant que l'ingress envoie du trafic à des pods pas encore prêts n'améliore rien. Le trio de probes à distinguer :
startupProbe:
httpGet: { path: /api/health, port: 3000 }
periodSeconds: 2
failureThreshold: 30 # 60 s pour démarrer, puis échec
readinessProbe:
httpGet: { path: /api/health, port: 3000 }
periodSeconds: 5
failureThreshold: 2
livenessProbe:
httpGet: { path: /api/health, port: 3000 }
periodSeconds: 10
failureThreshold: 3
La startupProbe est la plus souvent oubliée. Elle suspend liveness et readiness pendant le démarrage, ce qui évite le cercle vicieux classique : un nœud chargé, un démarrage lent, une livenessProbe qui tue le pod, un redémarrage, une charge encore plus forte. Un serveur Next.js en mode standalone démarre généralement en 1 à 3 secondes, mais un pod qui attend son volume ou une connexion PostgreSQL peut dépasser 20 secondes.
Côté route de santé, gardez-la triviale et sans dépendance externe :
// app/api/health/route.ts
export const dynamic = 'force-dynamic'
export function GET() {
return new Response('ok', {
status: 200,
headers: { 'cache-control': 'no-store' },
})
}
Une readinessProbe qui interroge la base de données transforme un incident PostgreSQL en indisponibilité totale du front : tous les pods passent NotReady en même temps et l'ingress n'a plus aucun backend. Si vous voulez vérifier la base, faites-le dans une route distincte consommée par le monitoring, pas par le kubelet. Le même raisonnement s'applique aux bases que nous décrivons dans notre comparaison PostgreSQL sur Kubernetes ou base managée.
L'arrêt propre, souvent la vraie cause des 502
Quand le HPA réduit le nombre de répliques, ou quand un rolling update remplace les pods, Kubernetes envoie SIGTERM et retire l'IP du pod des Endpoints — les deux en parallèle, pas dans l'ordre. L'ingress (NGINX, Traefik, Kong) peut continuer à router vers le pod pendant une poignée de secondes.
terminationGracePeriodSeconds: 40
lifecycle:
preStop:
exec:
command: ["sleep", "10"]
Le preStop de 10 secondes laisse le temps à la propagation des Endpoints. Côté application, il faut fermer le serveur HTTP sur SIGTERM au lieu de laisser le process mourir au milieu d'une requête :
// server.ts (custom server) ou wrapper autour de server.js standalone
const shutdown = () => {
server.close(() => process.exit(0))
setTimeout(() => process.exit(1), 25_000).unref()
}
process.on('SIGTERM', shutdown)
process.on('SIGINT', shutdown)
Si vous déployez via le pipeline décrit dans notre article sur la CI/CD GitLab pour Next.js sur Kubernetes, ces champs vivent dans le même chart Helm que le HPA : ils doivent évoluer ensemble.

Le manifeste HPA que nous déployons
L'API autoscaling/v2 est stable depuis Kubernetes 1.23 et apporte le champ décisif : behavior.
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: web
namespace: prod
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: web
minReplicas: 2
maxReplicas: 10
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 65
behavior:
scaleUp:
stabilizationWindowSeconds: 0
policies:
- type: Percent
value: 100
periodSeconds: 30
- type: Pods
value: 4
periodSeconds: 30
selectPolicy: Max
scaleDown:
stabilizationWindowSeconds: 300
policies:
- type: Percent
value: 50
periodSeconds: 60
Les paramètres qui comptent :
minReplicas: 2et jamais 1. Avec une seule réplique, toute éviction, tout drain de nœud, toute mise à jour coupe le service. Deux répliques sur deux nœuds différents, c'est le minimum pour un site de production.averageUtilization: 65plutôt que la valeur par défaut de 80. Le temps de réaction complet — collecte de métriques (15 s par défaut côtémetrics-server), boucle du HPA (15 s), planification, démarrage du pod — se compte en dizaines de secondes. À 80 % de larequests, la marge est consommée avant l'arrivée des nouveaux pods.scaleUp.stabilizationWindowSeconds: 0: monter vite.scaleDownà 300 s : descendre lentement. L'asymétrie est l'essentiel du réglage, c'est elle qui supprime le battement (flapping) que l'on voit sur les HPA laissés par défaut.
Pour vérifier que tout est branché :
kubectl get hpa web -n prod
# NAME REFERENCE TARGETS MINPODS MAXPODS REPLICAS
# web Deployment/web cpu: 41%/65% 2 10 3
kubectl describe hpa web -n prod | tail -20
Si TARGETS affiche <unknown>/65%, le problème est en amont : metrics-server absent, ou requests.cpu manquante. Reprenez la première section.
Le PodDisruptionBudget, indissociable
Sans PDB, un kubectl drain lors d'une mise à jour de nœud peut évacuer toutes les répliques en même temps.
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: web
namespace: prod
spec:
minAvailable: 1
selector:
matchLabels:
app: web
Avec minReplicas: 2, minAvailable: 1 suffit. Au-delà de 4 répliques, nous passons à maxUnavailable: 25%. Ajoutez aussi un topologySpreadConstraints sur kubernetes.io/hostname avec whenUnsatisfiable: ScheduleAnyway, pour que les répliques ne se retrouvent pas toutes sur le même nœud.
Quand le CPU ne suffit plus : KEDA
Le CPU est un bon proxy pour du rendu serveur. Il est inutile pour trois charges courantes.
Les workers de file d'attente. Un worker qui consomme une file Redis ou RabbitMQ passe son temps à attendre : son CPU reste à 5 % pendant que 8 000 messages s'empilent. KEDA (projet gradué de la CNCF depuis août 2023) résout exactement ce cas en exposant des scalers externes.
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: worker-emails
namespace: prod
spec:
scaleTargetRef:
name: worker-emails
minReplicaCount: 0
maxReplicaCount: 20
pollingInterval: 15
cooldownPeriod: 120
triggers:
- type: redis
metadata:
addressFromEnv: REDIS_ADDRESS
listName: queue:emails
listLength: "50"
Un pod par tranche de 50 messages en attente, et minReplicaCount: 0 — KEDA sait descendre à zéro, ce que le HPA seul ne fait pas. En pratique, KEDA crée et pilote un HPA sous le capot ; vous ne gérez que le ScaledObject.
Le trafic prévisible. Un site de PME dont l'audience est franco-européenne ne reçoit presque rien entre 1 h et 6 h du matin. Le scaler cron évite de payer des répliques inutiles :
triggers:
- type: cron
metadata:
timezone: Europe/Paris
start: "45 7 * * 1-5"
end: "30 20 * * 1-5"
desiredReplicas: "4"
Les métriques métier. Requêtes par seconde, longueur de file HTTP, latence p95 : le scaler prometheus branche n'importe quelle requête PromQL sur l'autoscaling. C'est souvent plus pertinent que le CPU pour du Next.js, où une requête lourde en I/O (appel API, base) bloque un slot sans consommer de cycles.
- type: prometheus
metadata:
serverAddress: http://prometheus-server.monitoring:80
query: sum(rate(http_requests_total{service="web"}[2m]))
threshold: "60"
Règle que nous appliquons : un seul autoscaler par Deployment. Un HPA et un
ScaledObjectqui visent la même cible se battent et produisent des cycles de création/suppression. Si vous passez à KEDA, supprimez le HPA que vous aviez écrit à la main.

Ce que ça coûte vraiment
L'autoscaling horizontal ne réduit la facture que si les nœuds suivent. Sinon vous payez en permanence la capacité du pic. Deux leviers :
- Autoscaling de nœuds. OVHcloud Managed Kubernetes et Scaleway Kapsule proposent tous deux des pools à taille variable, avec un minimum et un maximum par pool. Le délai d'ajout d'un nœud se compte en minutes, pas en secondes : gardez une marge de pods au chaud.
- Pods « pause » de sur-provisionnement. Un Deployment de pods à priorité négative qui réservent de la place et sont expulsés dès qu'un vrai pod arrive. Coût : une fraction de nœud, pour un gain de plusieurs minutes sur la montée en charge.
Ordre de grandeur sur une configuration que nous exploitons : trois nœuds de 4 vCPU / 8 Go en pool fixe, plus un pool élastique de 0 à 3 nœuds identiques. Avec des requests de 150 m, un nœud de 4 vCPU accueille théoriquement une vingtaine de pods web, en pratique une douzaine une fois retirés le système, l'agent CSI et le monitoring. Les tarifs publics d'OVHcloud et de Scaleway évoluent : prenez les grilles du jour sur leurs pages d'offres managées, et comptez le stockage et la bande passante sortante séparément. Nous détaillons cette approche sur notre page services d'infrastructure.
Le piège de facturation le plus fréquent reste le même : un maxReplicas à 50 posé « au cas où », une boucle de revalidation qui s'emballe, et une nuit à 40 répliques sur un pool élastique. Fixez maxReplicas à ce que votre budget tolère, pas à ce que le cluster peut absorber, et mettez une alerte sur kube_horizontalpodautoscaler_status_current_replicas proche du maximum.
Questions fréquentes
Faut-il mettre une limite CPU sur un pod Next.js ?
Non dans la grande majorité des cas. Le throttling CFS dégrade la latence p95 sur un runtime Node.js qui fait des pointes courtes, et la requests suffit à garantir l'équité entre pods. L'exception : un cluster mutualisé entre plusieurs clients où un ResourceQuota ou une politique interne impose une limit. Dans ce cas, posez-la à au moins 4 fois la requests et surveillez container_cpu_cfs_throttled_seconds_total.
Pourquoi mon HPA affiche-t-il <unknown> ?
Trois causes, par ordre de fréquence : metrics-server n'est pas installé ou ne répond pas (kubectl top node échoue), le conteneur n'a pas de resources.requests.cpu, ou le scaleTargetRef pointe vers une ressource qui n'existe pas. kubectl describe hpa donne la raison exacte dans les conditions ScalingActive et AbleToScale.
HPA ou VPA ?
Le VerticalPodAutoscaler ajuste les requests d'un pod, le HPA ajuste le nombre de pods. Les deux sur la même métrique entrent en conflit. Pour un front web sans état, le HPA est le bon outil. Le VPA est utile en mode recommender seul, pour mesurer les requests correctes sans rien appliquer automatiquement — c'est ainsi que nous l'utilisons pendant les deux premières semaines d'un nouveau déploiement.
Et le cache ISR de Next.js quand les pods se multiplient ?
C'est le vrai point d'attention. En mode standalone, le cache de revalidation incrémentale est stocké sur le système de fichiers du pod : dix répliques, dix caches, dix régénérations de la même page. Il faut un cache partagé via un CacheHandler personnalisé (Redis, S3) ou accepter la duplication. Sans cela, l'autoscaling multiplie la charge sur la base de données au lieu de la répartir.
Comment tester que l'autoscaling fonctionne ?
Avec un générateur de charge, pas en production un vendredi. k6, vegeta ou hey lancés depuis un pod du cluster, sur une route dynamique représentative, pendant 10 minutes, et un kubectl get hpa -w en parallèle. Vous validez trois choses : le HPA scale, les nouveaux pods passent Ready dans le temps attendu, et le scale-down ne génère pas d'erreurs côté client. Le même protocole sert à vérifier les restaurations, comme dans notre guide sur les sauvegardes Kubernetes avec Velero.
En résumé
- Le HPA ne fonctionne que si
resources.requests.cpuest mesurée, pas estimée : 100 à 250 m pour un pod Next.js standalone typique. - Pas de
limits.cpu(throttling CFS et latence p95), mais unelimits.memoryalignée avec--max-old-space-size. startupProbedistincte, route de santé sans dépendance à la base,preStop: sleep 10etterminationGracePeriodSeconds: 40pour éliminer les 502 au scale-down.- HPA
autoscaling/v2à 65 % d'utilisation,minReplicas: 2, montée immédiate et descente sur 300 s viabehavior. PodDisruptionBudgetettopologySpreadConstraintssystématiques, sinon un drain de nœud annule tout le bénéfice.- KEDA pour les files d'attente (scale à zéro), le trafic planifié (
cron) et les métriques Prometheus — mais un seul autoscaler par Deployment. - Prévoir l'autoscaling des nœuds et un
maxReplicascalé sur le budget, pas sur la capacité du cluster. - Côté Next.js, un cache ISR partagé est indispensable dès que le nombre de répliques varie.
Vous préparez un pic de trafic ou vous constatez des 502 à chaque déploiement ? Décrivez-nous votre cluster via la page contact : nous auditons les requests, les probes et le HPA avant de toucher à l'infrastructure.


