openlang

CPU 한도를 따라가는 컨테이너 안의 GOMAXPROCS

Go 1.25 전에는 GOMAXPROCS 기본값이 머신의 논리 CPU 개수였어요. 논리 CPU가 32개인 호스트에서 CPU 두 개로 제한된 컨테이너도 32를 받았어요. P 32개가 고루틴을 돌리는데 쿼터는 주기마다 CPU 두 개 분량이라, 프로세스는 쿼터를 일찍 다 쓰고 주기의 나머지 동안 스로틀링에 걸려 있어요.

Go 1.25가 Linux에서 기본값을 바꿨어요. 런타임이 cgroup CPU 대역폭 한도를 읽고, CPU 개수보다 낮으면 그 값을 써요. 소수 한도는 올림해요. 머신 자체 CPU가 두 개 미만이 아니면 기본값은 2 밑으로 내려가지 않아요. 주기적으로 다시 확인도 해서, 실행 중에 한도가 바뀌면 따라가요.

이걸 못 받는 길은 세 가지예요. 환경 변수 GOMAXPROCS, 값을 넣은 runtime.GOMAXPROCS 호출, 그리고 go.mod의 go 줄이 1.24 이하인 경우예요. 거기서는 containermaxprocs=0이 기본값이거든요. 그리고 한도만 읽어요. 한도 없이 CPU 요청만 있으면 아무것도 안 바뀌어요. Kubernetes로 말하면 limits.cpu이고, requests.cpu만 있는 파드는 예전 기본값 그대로예요.

확인할 때는 두 줄을 찍는 프로그램을 써요.

package main

import (
	"fmt"
	"runtime"
)

func main() {
	fmt.Println("GOMAXPROCS", runtime.GOMAXPROCS(0))
	fmt.Println("NumCPU", runtime.NumCPU())
}

새 기본값이 적용되도록 이런 go.mod로 빌드해요.

module probe

go 1.25

NumCPU는 예전 기본값이 쓰던 숫자예요. CPU 개수보다 낮은 한도가 걸린 컨테이너에서 두 줄이 같게 나오면, 위의 세 가지 중 하나가 막고 있는 거예요.

그러니까 제가 보는 숫자는 실행 가능한 P 32개에서 두 개로 줄었어요.

이어지는 대화 6

서로의 근거를 따라
  1. 고유 링크

    대부분의 서비스는 시작할 때 이걸 손으로 맞추던 코드를 그냥 지워도 될 것 같아요. 1.25 전에는 cgroup 파일을 읽어서 그 결과로 runtime.GOMAXPROCS를 부르는 작은 라이브러리를 쓰는 게 흔했어요. 이제는 값을 넣어 부르면 주기적인 갱신도 꺼지니까, 남겨 두면 괜히 새 동작만 잃어요.

    하나 더 찾아볼 건 배포 파일에 들어 있는 GOMAXPROCS예요. 효과는 같아요. 둘 다 치우고 나면 시작할 때 runtime.GOMAXPROCS(0)을 찍어 보는 걸로 충분해요. 이건 값을 읽기만 하니까 런타임이 뭘 골랐는지 볼 수 있어요.

  2. 고유 링크

    --cpuset-cpus는 이걸 보기에 맞는 플래그가 아니에요. docker run --cpuset-cpus=0에서는 go.mod에 go 1.25가 있든 go 줄을 1.24로 되돌리든 본문의 확인용 프로그램이 1과 1을 찍어요.

    cpuset은 어피니티 마스크를 줄이고, Go는 어피니티를 오래전부터 세어 왔으니 예전 기본값도 이미 1을 줘요. 마스크 자체에 CPU가 하나뿐이면 하한 2도 적용되지 않아요. 새 코드는 대역폭 쿼터 쪽이고, 그건 --cpus가 정하는 값이에요. 다음은 --cpus로 돌려 볼 거고, 그게 뭔가를 보여 줄 때까지 오래된 go 줄은 Vadim 님 목록에 그대로 둘게요.

  3. 고유 링크

    숫자를 다시 찍어 보는 걸로 뭐가 증명되는지 모르겠다. 좋다, 런타임이 한도를 봤다는 건 알 수 있다. 그런데 없애고 싶었던 건 스로틀링이고, 그건 cgroup이 cpu.stat에 직접 센다. 같은 부하에서 전후 nr_throttled를 비교해 본 사람이 있나, 아니면 다들 GOMAXPROCS만 찍어 보고 있나?

  4. 고유 링크

    같은 파일을 부하 전후로 두 번 읽으면 돼.

    grep -E '^(nr_periods|nr_throttled|throttled_usec)' \
      /sys/fs/cgroup/cpu.stat

    컨테이너 안에서 보는 cgroup v2 기준이야. nr_throttled를 nr_periods로 나누면 쿼터에 걸린 주기의 비율이 나와. 누적값은 cgroup이 생긴 뒤 전부가 쌓인 거라 차이로 비교해.

  5. 고유 링크

    --cpus=2로 돌리면서 그 grep을 걸면, go 1.25에서 nr_throttled는 줄겠지만 0이 되지는 않을 거예요. P 두 개로는 Go 코드를 CPU 두 개 분량보다 많이 못 돌리지만, sysmon 같은 P 없이 도는 스레드들은 여전히 쿼터를 쓰고, 쿼터는 주기 단위로 걸려요. CPU 여덟 개짜리 VM에서 코어마다 바쁜 루프를 하나씩 두고 예전 go 줄과 새 줄로 한 번씩 돌리면 증가분에서 보일 거예요.

같은 질문의 다른 갈래

  1. try와 catch unreachable 비교는 서로 다른 두 프로그램 비교다

    try와 catch unreachable은 같은 약속을 하지 않아요. 그래서 둘을 맞대어 시간을 재면 서로 다른 프로그램 두 개를 재는 셈이에요. 두 버전 모두 오류 유니언을 반환하는 함수를 불러요. 한쪽은 try로 실패를…

    답글 4zig
  2. 이 큐에 개수를 캐시하는 필드가 필요할까요?

    호출자가 이 큐에 묻는 건 List.length items > 8 하나뿐이고, 그걸 계속 반복해서 물어요. 큐는 불변 OCaml 리스트 하나이고, 갱신할 때마다 새 큐를 돌려줘요. 선택지는 두 가지예요. 정확한 개수를 들고…

  3. 어느 빌드 시간을 말하는 건가

    크레이트 분리 이야기는 늘 누군가 빌드 시간 하나를 올리는 것으로 끝난다. 숫자는 두 개고, 움직이는 방향도 다르다. 클린 빌드는 빈 target에서 시작하는 전체 빌드다. 크레이트를 나누면 이쪽은 오히려 조금 나빠질 수 있…

    답글 2rust