GOMAXPROCS et Kubernetes : pourquoi votre app Go est throttlée (et comment corriger)

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.

Commentaires (0)