Le pod Go tourne en production. CPU limit à 2, métriques qui semblent normales. Mais sous charge, les P99 partent en vrille par intermittence, sans raison évidente. Pas d'erreur, pas de leak de goroutines, juste une latence qui s'envole sur les pics.
La cause est souvent là, invisible : GOMAXPROCS vaut le nombre de CPU
du nœud physique, pas la limite du container. Votre app Go pense avoir 32 CPU alors
qu'elle n'en a que 2. Le kernel s'en occupe à sa façon, et ça s'appelle du throttling.
Ce que GOMAXPROCS lit (et ce qu'il ne lit pas)
Par défaut, le runtime Go calcule GOMAXPROCS avec runtime.NumCPU(),
qui lit le nombre de CPU disponibles au niveau du système d'exploitation. Sur un nœud k8s
à 32 cœurs, NumCPU() retourne 32, peu importe ce que dit le resources.limits.cpu
du pod.
Les limites CPU dans Kubernetes sont implémentées via les cgroups Linux
(v1 ou v2). Ce mécanisme est transparent pour les processus : un pod avec
limits.cpu: "2" ne voit pas deux CPU virtuels, il voit tous les CPU
du nœud et se fait couper quand il en consomme trop.
Le runtime Go ne lit pas les cgroups par défaut. Il s'est toujours fié au nombre
de cœurs physiques.
package main
import (
"fmt"
"runtime"
)
func main() {
// Dans un pod avec limits.cpu: "2" sur un nœud à 32 cœurs
fmt.Println(runtime.NumCPU()) // → 32
fmt.Println(runtime.GOMAXPROCS(0)) // → 32 aussi
}
Le throttling CFS : comment le kernel vous ralentit
Le planificateur Linux CFS (Completely Fair Scheduler) impose les limites CPU via
deux paramètres cgroup : cpu.cfs_quota_us (temps CPU autorisé)
et cpu.cfs_period_us (fenêtre de mesure, 100 ms par défaut).
Un pod limité à 2 CPU peut consommer au maximum 200 ms de CPU par fenêtre de 100 ms.
Quand le runtime Go crée 32 threads OS pour 32 goroutines parallèles, ces threads se font des aller-retours sur les CPU physiques. Si l'ensemble dépasse le quota dans la fenêtre courante, le kernel suspend tous les threads du cgroup jusqu'à la prochaine fenêtre. C'est le throttling : un gel complet de l'application, de quelques millisecondes à plusieurs dizaines. Sur une fenêtre de 100 ms, une poignée de gels suffit à faire exploser les P99.
Le problème se voit dans les métriques Prometheus de Kubernetes :
container_cpu_cfs_throttled_periods_total
container_cpu_cfs_periods_total
# Ratio de throttling (idéalement proche de 0)
rate(container_cpu_cfs_throttled_periods_total[5m]) /
rate(container_cpu_cfs_periods_total[5m])
La solution : automaxprocs (et Go 1.25)
Uber a publié go.uber.org/automaxprocs précisément pour ce problème.
Le package lit le cgroup au démarrage (v1 ou v2), calcule le quota effectif
en divisant quota_us / period_us, et appelle runtime.GOMAXPROCS(n)
avec la valeur correcte.
L'intégration se résume à un import avec effet de bord :
package main
import (
"fmt"
"runtime"
_ "go.uber.org/automaxprocs" // lit le cgroup au init()
)
func main() {
// Dans un pod avec limits.cpu: "2"
fmt.Println(runtime.GOMAXPROCS(0)) // → 2
}
Puis ajouter la dépendance :
go get go.uber.org/automaxprocs
La variante manuelle convient si vous connaissez le nombre de CPU à l'avance ou si vous préférez éviter la dépendance :
import "runtime"
func init() {
runtime.GOMAXPROCS(2) // à ajuster selon le limits.cpu du pod
}
La limite de l'approche manuelle : si vous changez le manifest Kubernetes,
il faut penser à mettre à jour le code. Avec automaxprocs,
la valeur suit automatiquement.
Go 1.25 : le problème résolu nativement
Depuis Go 1.25 (août 2025), le runtime Go lit les cgroups par défaut sur Linux.
Si le processus tourne dans un container avec un quota CPU inférieur au nombre
de cœurs physiques, GOMAXPROCS est ajusté automatiquement, sans
aucune dépendance externe. Go 1.25 surveille aussi les changements de quota
en cours d'exécution (utile si Kubernetes ajuste les limites à chaud).
Pour désactiver ce comportement (cas rare, migration), deux options godebug existent :
# Ignorer le quota cgroup (comportement pré-1.25)
GODEBUG=containermaxprocs=0
# Garder la valeur initiale sans mise à jour dynamique
GODEBUG=updatemaxprocs=0
Si vous êtes sur Go 1.25 ou supérieur, pas besoin d'automaxprocs.
Sur Go 1.24 et inférieur, le package d'Uber reste la solution de référence.
Vérifier que ça marche
Pour confirmer la valeur effective dans un pod en cours d'exécution :
# Ouvrir un shell dans le pod
kubectl exec -it <nom-du-pod> -- /bin/sh
# Lire le quota cgroup v2
cat /sys/fs/cgroup/cpu.max
# → 200000 100000 (= 2 CPU : 200ms quota / 100ms period)
# Lire le quota cgroup v1
cat /sys/fs/cgroup/cpu/cpu.cfs_quota_us
cat /sys/fs/cgroup/cpu/cpu.cfs_period_us
Si vous exposez les métriques Go runtime via Prometheus, la jauge
go_sched_gomaxprocs_threads (disponible avec le collecteur
runtime/metrics depuis Go 1.17) donne la valeur en cours.
Elle devrait correspondre à votre quota, pas au nombre de cœurs du nœud.
Conclusion
Le throttling CFS sur des apps Go dans Kubernetes est un problème silencieux :
aucun log, aucune erreur, juste des P99 qui s'envolent sous charge. La cause
est presque toujours la même, un GOMAXPROCS calé sur les CPU
du nœud plutôt que sur le quota du container.
La correction est à la hauteur du problème : un import ou une ligne de config.
Sur Go 1.25, c'est réglé en mettant à jour le compilateur. Sur les versions
antérieures, automaxprocs d'Uber fait le travail depuis des années
et a survécu au test de la production à grande échelle.