- 컨테이너에 CPU 제한을 걸어도 Go 런타임은 기본적으로 이를 알지 못해, 호스트 전체 코어 기준으로 스레드를 만들고 지연 시간을 키울 수 있음
- Go GC는 대부분 애플리케이션과 동시에 돌지만, Sweep Termination과 Mark Termination에서 모든 고루틴을 멈추는 stop-the-world(STW) 구간이 필요함
- Linux CFS는 코어 수를 초당 CPU 시간으로 나눠 배분하며,
--cpus=4는 컨테이너에 매초 4초 분량의 CPU 시간을 주는 방식임
- 16코어 호스트에서 4코어 제한 컨테이너를 실행하면 Go가 16개 OS 스레드에 고루틴을 올릴 수 있어, CPU quota 소진 뒤 STW가 길어질 수 있음
GOMAXPROCS를 컨테이너 CPU 제한에 맞추면 예시에서 GC 사이클은 2.5ms 미만에서 1ms 미만으로 줄고, STW는 약 26μs까지 감소함
컨테이너 CPU 제한과 Go 런타임의 불일치
- 컨테이너에서 Go 애플리케이션을 실행할 때 CPU 제한은 호스트 CPU를 모두 소비하지 않도록 막는 장치임
- 문제는 Go 런타임이 컨테이너의 CPU 제한을 기본적으로 인식하지 못한다는 점임
- 이 불일치로 런타임은 실제 quota보다 많은 CPU를 쓸 수 있다고 보고, 높은 지연 시간으로 이어질 수 있음
Go GC에서 STW가 발생하는 지점
- Go 가비지 컬렉터는 대부분의 시간 동안 애플리케이션과 동시 실행됨
- 다만 GC 과정에는 모든 고루틴을 멈춰야 하는 두 구간이 있음
- Mark Phase 전에 write barrier를 적용하기 위해 멈추는 단계가 Sweep Termination임
- Mark Phase 뒤 write barrier를 제거하기 위해 다시 멈추는 단계가 Mark Termination임
- STW 구간은 보통 수십 마이크로초 수준임
- 예시 애플리케이션은 메모리를 많이 할당하는 단순 웹 애플리케이션이며, 소스 코드는 go-cfs-blog에 있음
- 컨테이너는 4 CPU 제한으로 실행됨
docker run --cpus=4 -p 8080:8080 $(ko build -L main.go)
runtime/trace 패키지로 trace를 수집하고 go tool trace로 분석할 수 있음
- 이 실행에서 GC 사이클은 2.5ms 미만이었지만, 그중 거의 10%가 STW 구간이었음
- 지연 시간에 민감한 애플리케이션에서는 이 정도 비중도 문제가 될 수 있음
Docker CPU 제한과 Linux CFS 동작 방식
- Docker의
--cpus CPU 제한은 hard limit임
--cpu-shares도 설정할 수 있지만, 호스트가 CPU 제약을 받을 때만 강제됨
- 호스트에 여유가 있으면 컨테이너는 할당된 CPU 코어보다 더 많이 사용할 수 있음
- 호스트가 제약 상태가 되면 애플리케이션은 제한을 받음
- Linux Completely Fair Scheduler(CFS)는 Linux 2.6.23에서 도입됐고 Linux 6.6 전까지 기본 스케줄러였음
- CFS는 proportional share scheduler로, 프로세스의 weight를 사용할 수 있는 CPU 코어 수에 비례해 둠
- 4개 CPU 코어를 사용할 수 있는 프로세스의 weight는 4임
- 2개 CPU 코어를 사용할 수 있는 프로세스의 weight는 2임
- CFS는 CPU 시간을 쪼개 배분함
- 4코어 시스템은 매초 4초 분량의 CPU 시간을 배분할 수 있음
- 컨테이너에 CPU 코어 수를 할당한다는 것은 Linux 스케줄러에
n CPU만큼의 시간을 요청하는 것과 같음
--cpus=4는 컨테이너가 매초 4초 분량의 CPU 시간을 받는다는 뜻임
왜 STW가 길어지는가
- Go 런타임은 시작할 때 CPU 코어마다 하나의 OS 스레드를 만듦
- 16코어 머신에서는 CGroup CPU 제한과 관계없이 16개 OS 스레드를 만들 수 있음
- 런타임은 이 OS 스레드들 위에 고루틴을 스케줄함
- 컨테이너 CPU 제한이 4코어여도 Go는 16개 OS 스레드 전체에 고루틴을 올릴 수 있음
- 이 상태에서 런타임은 매초 16초 분량의 CPU 시간을 쓸 수 있다고 기대하게 됨
- 긴 STW 시간은 Linux 스케줄러가 다시 실행해주기를 기다리는 스레드 위의 고루틴까지 모두 멈춰야 해서 발생함
- 컨테이너가 CPU quota를 이미 사용한 뒤에는 해당 스레드들이 스케줄되지 않음
GOMAXPROCS를 CPU quota에 맞추기
- Go는
GOMAXPROCS 환경 변수로 런타임이 사용할 CPU 스레드 수를 제한할 수 있음
- CPU quota가 4인 컨테이너에서는
GOMAXPROCS=4를 함께 지정함
docker run --cpus=4 -e GOMAXPROCS=4 -p 8080:8080 $(ko build -L main.go)
- 같은 애플리케이션과 같은 부하에서
GOMAXPROCS를 CPU quota와 맞추자 GC 시간이 짧아짐
- trace에서 GC 사이클은 1ms 미만으로 줄었고, STW 구간은 26μs였음
- 이는
GOMAXPROCS 제한이 없을 때의 STW 시간과 비교해 약 1/10 수준임
GOMAXPROCS는 컨테이너가 사용할 수 있는 CPU 코어 수로 설정해야 함
- fractional CPU를 할당할 때는 내림함
- 1 CPU 미만을 할당할 때는 올림함
- 계산식은
GOMAXPROCS=max(1, floor(CPUs))임
- Uber의 automaxprocs는 컨테이너 cgroups에서 이 값을 자동 계산하는 오픈소스 라이브러리임
- Go 런타임에서 이를 기본 지원하기 위한 GitHub Issue가 열려 있음
컨테이너화된 Go 서비스에서 확인할 점
- CPU 제한만 설정하면 충분하지 않고, Go 런타임이 그 제한을 반영하도록 GOMAXPROCS도 맞춰야 함
- 직접 계산하기 어렵다면 automaxprocs 같은 라이브러리로 cgroups 기반 값을 자동 설정할 수 있음
- 지연 시간에 민감한 Go 서비스는 GC trace에서 STW 시간을 확인해 CPU quota와 런타임 설정이 어긋나지 않는지 점검해야 함