Späť na blog

Go GOMAXPROCS v Kontajneroch: Problém Detekcie CPU

Raz som cely den hladal latency, a bol to len GOMAXPROCS v kontajneri. “Náš Go servis je throttlovaný 70% času napriek nízkemu CPU využitiu.” Pozeral som na Grafana dashboardy ukazujúce náš API gateway na 30% CPU utilizácii, ale p99 latencia bola katastrofálna a používatelia sa sťažovali na občasné spomalenia. Metriky nedávali zmysel—ako môže byť servis pomalý keď ledva využíva svoje pridelené zdroje?

Vinník sa ukázal byť jednou z najčastejších chýb Go-v-kontajneroch: nesprávna konfigurácia GOMAXPROCS. Go runtime detekoval všetkých 64 CPU na host stroji, kompletne ignorujúc náš 2 CPU limit kontajnera. Tento nesúlad vytvoril kaskádu problémov, ktoré nám trvalo dni diagnostikovať.

Tento problém je zvlášť zákerný pretože sa neobjaví v typických metrikách. CPU využitie vyzerá fajn. Pamäť je fajn. Všetko vyzerá fajn—okrem tých nevysvetliteľných latency spikov, ktoré sa dejú “náhodne” počas dňa.

Testované na: Go 1.21, Kubernetes 1.28, kontajner s 2 CPU limitom na 64-jadrovom hoste

Pochopenie Go Schedulera

Pred tým ako sa pustíme do riešenia, pochopme prečo sa Go takto správa. Go má vlastný scheduler, ktorý spravuje goroutines—ľahké vlákna, ktoré sú chrbtovou kosťou Go konkurenčného modelu. Go scheduler je sofistikovaný kus inžinierstva, ktorý multiplexuje potenciálne milióny goroutines na menší počet OS threadov.

Premenná GOMAXPROCS

GOMAXPROCS kontroluje koľko OS threadov môže súčasne vykonávať používateľský Go kód. Je to v podstate limit paralelizmu pre váš Go program. Keď váš program štartuje, Go sa opýta systému koľko CPU je dostupných a nastaví GOMAXPROCS podľa toho.

// Čo Go robí defaultne
runtime.GOMAXPROCS(0)  // Vracia počet CPU hosta

// Na 64-jadrovom hoste s 2 CPU limitom kontajnera:
// GOMAXPROCS = 64  (zle!)
// Go vytvorí 64 OS threadov pre scheduling goroutines
// Ale dostupná je len kvóta za 2 CPU

Problém je, že Go detekcia CPU používa Linux sysconf(_SC_NPROCESSORS_ONLN) alebo číta z /proc/cpuinfo. Obe tieto metódy vracajú počet CPU hosta, nie cgroup-limitovanú kvótu kontajnera. Go bolo navrhnuté pred tým ako sa kontajnery stali všadeprítomné, a tento detekčný mechanizmus dával dokonalý zmysel pre bare metal a VM nasadenia.

Nesúlad Kontajnera

Kontajnery v skutočnosti nelimitujú, ktoré CPU váš proces vidí—limitujú koľko CPU času váš proces dostane. Váš Go servis môže “vidieť” všetkých 64 CPU na hoste. Jednoducho nemôže použiť viac ako 2 CPU času za plánovaciu periódu. Toto je zásadne odlišné od VM, kde by ste skutočne mali 2-CPU virtuálny stroj.

Prečo To Spôsobuje Throttling

Linux Completely Fair Scheduler (CFS) vynucuje CPU limity cez mechanizmus kvót. S 2 CPU limitom v Kubernetes váš kontajner dostane 200ms CPU času každých 100ms (2 × 100ms = 200ms kvóta).

Host: 64 CPUs
Limit kontajnera: 2 CPUs (200ms kvóta za 100ms periódu)

Go runtime s GOMAXPROCS=64:
┌─────────────────────────────────────────────────────────────┐
│ 64 OS threadov súťaží o čas 2 CPU                          │
│                                                              │
│ Thread 1:  [run].....[run].....[run].....                   │
│ Thread 2:  .....[run].....[run].....[run]                   │
│ Thread 3:  ..[run].....[run].....[run]...                   │
│ ...                                                          │
│ Thread 64: [run].....[run].....[run].....                   │
│                                                              │
│ CFS vidí: 64 threadov × time slices = prekročí 200ms kvótu │
│ Výsledok: THROTTLED aj pri "nízkom" CPU využití            │
└─────────────────────────────────────────────────────────────┘

Symptómy:
- Vysoký CFS throttling (nr_throttled v cpu.stat)
- Latency spiky počas throttle periód
- CPU využitie vyzerá nízko v metrikách

Tu to začína byť kontraintuitívne. Keď CFS throttluje váš kontajner, zastaví VŠETKY thready v tom kontajneri až do začiatku ďalšej periódy. Toto vytvára koordinované latency spiky—každý request spracovávaný keď throttling kopne zažije oneskorenie.

So 64 threadmi každý zaberá malý time slice. Aj keď väčšina je nečinná, scheduler stále context-switchuje medzi nimi, konzumujúc CPU kvótu len pre overhead spravovania threadov. Pálite CPU čas nie na skutočnú prácu, ale na spravovanie threadov, ktoré by nemali existovať.

Daň za Context Switching

Context switche nie sú zadarmo. Každý switch vyžaduje uloženie a obnovenie stavu registrov, vyprázdnenie TLB položiek a potenciálne invalidáciu CPU cache. So 64 threadmi súťažiacimi o 2 CPU dostanete konštantné context switche. Videl som servisy kde overhead context switchingu spotreboval 20-30% ich celkového CPU rozpočtu.

Irónia je, že extra thready vám nepomôžu ísť rýchlejšie—aktívne vás spomaľujú. 2 CPU servis s GOMAXPROCS=2 prekoná rovnaký servis s GOMAXPROCS=64 o značný margin.

Meranie Dopadu

Pred tým ako niečo opravíte, zmerajte aktuálny stav. Tieto príkazy vám pomôžu pochopiť závažnosť problému:

# Skontroluj aktuálny GOMAXPROCS v bežiacom kontajneri
curl localhost:6060/debug/pprof/goroutine?debug=2 | head -20

# Skontroluj CFS throttling (cgroup v2)
cat /sys/fs/cgroup/cpu.stat
# nr_throttled by malo byť blízko 0, nie tisíce

# Skontroluj CFS throttling (cgroup v1)
cat /sys/fs/cgroup/cpu/cpu.stat

# Skontroluj context switches
cat /proc/self/status | grep ctxt
# voluntary_ctxt_switches: malo by byť rozumné
# nonvoluntary_ctxt_switches: vysoké = problém

# Skontroluj skutočný GOMAXPROCS zvnútra kontajnera
go run -e 'runtime.GOMAXPROCS(0)'

Ak vidíte tisíce alebo milióny nr_throttled udalostí a vysoké nonvoluntary_ctxt_switches, našli ste váš problém.

Riešenia

Existuje niekoľko prístupov ako toto opraviť, od úplne jednoduchých po trochu zložitejšie.

1. Použi automaxprocs (Uber Knižnica)

Toto je odporúčané riešenie pre väčšinu tímov. Uber automaxprocs knižnica automaticky detekuje CPU limity kontajnera a nastaví GOMAXPROCS správne. Číta z cgroup súborov aby zistila skutočnú CPU kvótu.

import _ "go.uber.org/automaxprocs"

func main() {
    // automaxprocs automaticky nastaví GOMAXPROCS
    // podľa CPU kvóty kontajnera
    // 2 CPU limit → GOMAXPROCS=2
}
# go.mod
require go.uber.org/automaxprocs v1.5.3

Krása automaxprocs je, že je to blank import. Pridáte jeden riadok do vášho main package a ono sa o všetko postará počas init. Tiež loguje detekovanú hodnotu, takže môžete overiť že to funguje:

maxprocs: Updating GOMAXPROCS=2: determined from CPU quota

Automaxprocs zvláda oba cgroup v1 aj v2, rieši zlomkové CPU limity (zaokrúhľuje správne) a vracia sa k počtu CPU hosta ak nedokáže detekovať limit (užitočné pre lokálny vývoj).

2. Nastav cez Environment Variable

Ak preferujete nepridávať dependenciu, Go rešpektuje GOMAXPROCS environment variable. Môžete ju explicitne nastaviť v Kubernetes deploymente:

# deployment.yaml
apiVersion: apps/v1
kind: Deployment
spec:
  template:
    spec:
      containers:
        - name: app
          resources:
            limits:
              cpu: "2"
          env:
            - name: GOMAXPROCS
              value: "2"

Nevýhoda je, že si musíte pamätať udržiavať environment variable v synchronizácii s CPU limitom. Zmeňte jedno bez druhého a ste späť pri problémoch. Tento prístup funguje ale vyžaduje disciplínu.

3. Downward API pre Dynamické Nastavenie

Robustnejší prístup používa Kubernetes Downward API na injektovanie CPU limitu ako environment variable, ktorú váš kód potom číta:

# deployment.yaml - dynamický GOMAXPROCS
apiVersion: apps/v1
kind: Deployment
spec:
  template:
    spec:
      containers:
        - name: app
          resources:
            limits:
              cpu: "2"
          env:
            - name: CPU_LIMIT
              valueFrom:
                resourceFieldRef:
                  resource: limits.cpu
package main

import (
    "os"
    "runtime"
    "strconv"
)

func init() {
    if cpuLimit := os.Getenv("CPU_LIMIT"); cpuLimit != "" {
        if n, err := strconv.Atoi(cpuLimit); err == nil {
            runtime.GOMAXPROCS(n)
        }
    }
}

Toto udržuje váš deployment DRY—CPU limit je definovaný raz a GOMAXPROCS ho automaticky nasleduje. Avšak stále to vyžaduje zmeny kódu a nezvláda dobre zlomkové limity.

4. Programatická Detekcia

Pre maximálnu kontrolu môžete čítať cgroup súbory priamo. Toto je v podstate to čo automaxprocs robí interne:

package main

import (
    "os"
    "runtime"
    "strconv"
    "strings"
)

func setCPULimit() {
    // Čítaj cgroup v2 CPU max
    data, err := os.ReadFile("/sys/fs/cgroup/cpu.max")
    if err != nil {
        return // Návrat k defaultu
    }

    parts := strings.Fields(string(data))
    if len(parts) >= 2 && parts[0] != "max" {
        quota, _ := strconv.Atoi(parts[0])
        period, _ := strconv.Atoi(parts[1])
        if period > 0 {
            cpus := quota / period
            if cpus > 0 {
                runtime.GOMAXPROCS(cpus)
            }
        }
    }
}

Tento prístup vám dáva plnú kontrolu a nulové dependencie, ale musíte zvládnuť oba cgroup v1 aj v2, edge cases a testovanie. Pokiaľ nemáte špecifické požiadavky, automaxprocs je jednoduchšie.

Benchmarky

Spustil som benchmarky na reálnom Kubernetes clusteri aby som kvantifikoval dopad. Výsledky boli dramatické:

Scenár: HTTP server spracováva 1000 req/s
Kontajner: 2 CPU limit na 64-jadrovom hoste

GOMAXPROCS=64 (default):
  p50 latencia: 45ms
  p99 latencia: 890ms
  Throttle rate: 72%
  Context switches/s: 45,000

GOMAXPROCS=2 (správne):
  p50 latencia: 12ms
  p99 latencia: 35ms
  Throttle rate: 3%
  Context switches/s: 2,100

Zlepšenie: 4x lepšie p50, 25x lepšie p99

Zlepšenie p99 je najvýraznejšie. Tie 890ms tail latencie boli úplne umelé—spôsobené throttling pauzami, nie skutočnou prácou trvajúcou tak dlho. So správnym GOMAXPROCS tail latencie klesli na 35ms, blízko tomu čo by ste očakávali pre skutočný čas spracovania requestu.

Redukcia context switchov zo 45,000/s na 2,100/s má tiež downstream efekty. Menej cache thrashingu znamená lepšiu CPU efektivitu. Menej scheduler overheadu znamená viac času na skutočnú prácu.

Monitoring

Nastavte monitoring aby ste zachytili tento problém predtým ako ovplyvní používateľov. Metrika go_gomaxprocs je exponovaná defaultným Prometheus client library:

# Prometheus alert na GOMAXPROCS mismatch
- alert: GoMaxProcsNotSet
  expr: |
    go_gomaxprocs >
    (container_spec_cpu_quota / container_spec_cpu_period) * 2
  for: 5m
  labels:
    severity: warning
  annotations:
    summary: "GOMAXPROCS ({{ $value }}) prekračuje CPU limit"

# Alert na vysoký throttling
- alert: ContainerCPUThrottling
  expr: |
    rate(container_cpu_cfs_throttled_periods_total[5m]) /
    rate(container_cpu_cfs_periods_total[5m]) > 0.25
  for: 10m
  annotations:
    summary: "Kontajner {{ $labels.container }} throttlovaný >25%"

Prvý alert zachytáva servisy kde GOMAXPROCS prekračuje CPU limit. Multiplikátor 2 poskytuje nejaký slack—GOMAXPROCS=4 na 2 CPU limite je suboptimálne ale nie katastrofálne.

Druhý alert zachytáva throttling bez ohľadu na príčinu. Aj so správnym GOMAXPROCS, ak je váš servis skutočne CPU-bound a prekračuje svoj limit, uvidíte throttling. To je odlišné od patologického throttlingu spôsobeného zlou konfiguráciou GOMAXPROCS.

Dashboard Panely

Pridajte tieto do vášho Grafana dashboardu:

# GOMAXPROCS vs CPU limit pomer
go_gomaxprocs / on(pod)
(container_spec_cpu_quota / container_spec_cpu_period)

# Throttle percentá
rate(container_cpu_cfs_throttled_periods_total[5m]) /
rate(container_cpu_cfs_periods_total[5m]) * 100

# Context switches za sekundu
rate(container_context_switches_total[5m])

Checklist

## Go Kontajner CPU Konfigurácia

### Setup
- [ ] Pridaj `go.uber.org/automaxprocs` do všetkých Go servisov
- [ ] Alebo nastav GOMAXPROCS env var rovný CPU limitu
- [ ] Over cez /debug/pprof endpoint

### Monitoring
- [ ] Exportuj go_gomaxprocs metriku
- [ ] Alert na GOMAXPROCS > CPU limit
- [ ] Sleduj CFS throttling rate

### Verifikácia
- [ ] Skontroluj nr_throttled v cpu.stat
- [ ] Porovnaj context switch rates pred/po
- [ ] Load testuj aby si overil zlepšenie latencie

Záver

Problém detekcie CPU kontajnera v Go je jeden z tých problémov, ktorý sa zdá byť obscúrny kým ho nenarazíte—a potom vysvetlí mysteriózne performance problémy, ktoré ste týždne riešili. Oprava je jednoduchá: pridajte go.uber.org/automaxprocs do vašich servisov.

Kľúčové poznatky:

  1. Go detekuje CPU hosta, nie limity kontajnera—toto je by design, nie bug
  2. GOMAXPROCS=64 na 2 CPU kontajneri vytvára masívny overhead z context switchingu a spravovania threadov
  3. Použite automaxprocs pre automatickú, správnu detekciu s nulovou konfiguráciou
  4. Monitorujte throttling aby ste zachytili tento a podobné problémy včas
  5. Oprava trvá 5 minút a môže zlepšiť p99 latenciu 25x

Skontrolujte vaše Go servisy dnes. Spustite curl localhost:6060/debug/pprof/goroutine?debug=2 | head -5 a pozrite sa na GOMAXPROCS hodnotu. Ak je vyššia ako váš CPU limit, máte čo robiť.


Súvisiace články

Súvisiace články

Citujte tento článok

Ak na článok odkazujete, pridajte pôvodnú URL a uveďte autora.

Michal Drozd. "Go GOMAXPROCS v Kontajneroch: Problém Detekcie CPU". https://www.michal-drozd.com/sk/blog/go-gomaxprocs-kontajnery/ (Publikované 5. novembra 2025).