GopherCon Korea 2023 발표 중 당근에서 컨테이너 환경에서 발생했던 이슈에 대한 내용이 흥미로워 확인을 해봤다.
회사에서도 Go 프로그램이 컨테이너 환경에서 실행되고 있어 공부해 둘 필요가 있을 것 같다.
Issue
이슈는 Go 프로그램이 리눅스 환경에서 돌아갈 경우 CFS에 맞춰 GOMAXPROCS 값 설정이 될 수 있도록 개선을 요청하는 내용이다. 여기서 언급되는 CFS는 Completely Fair Scheduler로 프로그램마다 CPU 할당을 매우 공정하게 할당해 주는 스케줄러로 리눅스 커널 2.6.23 버전부터 도입되어 지금까지 사용되고 있다.
제기된 이슈는 GOMAXPROCS가 CFS에 맞춰 설정되지 않고 있어 프로그램에서 높은 지연율(latency)을 보이고 있다는 것인데 주로 부하가 발생하거나 백그라운드 GC가 발생할 때 발생하고 있다는 것이다. 또한 작은 컨테이너를 더 좋은 고성능 컴퓨터에서 돌릴수록 이 현상이 더 발생한다고 한다.
컨테이너 환경에서의 CPU 제한
호스트 환경에서 프로그램을 실행할 때 CFS는 문제가 되지 않는다. 하지만 컨테이너 환경으로 가면 이야기가 달라지는데 이는 컨테이너를 실행할 때는 일반적으로 사용하는 리소스를 제한하여 실행하기 때문이다. 이슈의 원인을 확인하기 전 컨테이너에서는 어떻게 CPU 리소스를 제한하는지 확인해 보자.
Docker나 Podman 등과 같은 컨테이너 관리도구를 이용해 컨테이너를 생성할 때 CPU 제한 시 일반적으로는 `--cpus` 옵션을 이용해서 사용량을 제한한다.
docker run --rm --name=ubuntu --cpus="1" --entrypoint="whoami" ubuntu:22.04
위와 같은 방법 외에도 CPU Quota와 Period 옵션으로 동일한 설정으로 구성할 수 있는데 컨테이너 관리도구의 공식문서를 보면 `--cpus` 옵션은 `--cpu-period`, `--cpu-quota` 옵션을 설정하는 것과 동일하다고 설명하고 있다.
Specify how much of the available CPU resources a container can use. For instance, if the host machine has two CPUs and you set --cpus="1.5", the container is guaranteed at most one and a half of the CPUs. This is the equivalent of setting --cpu-period="100000" and --cpu-quota="150000"
# 다음 두 명령어는 같은 값의 CPU 사용량 제한을 한다.
# quota와 period는 마이크로초(ms) 단위로 입력한다.
docker run --rm --cpus="1" --entrypoint="whoami" ubuntu:22.04
docker run --rm --cpu-quota="100000" --cpu-period="100000" --entrypoint="whoami" ubuntu:22.04
CPU Period, Quota
옵션으로 사용되는 Quota와 Period는 CFS에서 프로세스 스케줄링 시 참고하는 값이며 다음과 같은 의미이다.
CPU Period
CPU 스케줄링 주기를 의미하며 CPU 리소스가 다시 재할당되는 주기를 의미한다. 마이크로초(µs) 단위로 입력해 값을 설정할 수 있다. 기본값은 100ms(100000µs)이다.
CPU Quota
프로그램이 사용할 수 있는 CPU 리소스 할당량을 의미하며 한 번의 주기(Period) 동안 프로그램이 CPU 리소스를 사용해 실행할 수 있는 시간을 의미한다. 동일하게 마이크로초(µs) 단위로 설정한다.
정리하면 프로그램을 실행할 때 설정된 CPU Period마다 설정된 Quota 만큼 CPU 리소스를 사용할 수 있다.
예시 #1
만약 CPU Period 값이 기본값이며 Quota를 10ms(10000µs)로 설정하면 100ms 주기동안 프로그램은 CPU를 10ms 만큼만 사용하여 실행하며 나머지 90ms 동안은 멈춰있게 된다. 그리고 다시 100ms가 지나면 다시 10ms 만큼 사용하여 실행하며 다시 90ms 동안 멈추며 반복하게 된다. 여기서 멈추는 것은 일반적으로 쓰로틀링(Throttling) 걸린다고 한다.
예시 #2
만약 CPU Period 값이 기본값이며 Quota를 100ms(100000µs)로 설정하면 100ms 주기동안 CPU를 100ms 만큼 사용하여 다음 주기가 오기 전까지 멈추지 않고 계속 실행하며 다음 주기가 와도 동일하게 멈추지 않고 계속 실행된다.
예시 #3
만약 CPU Period 값이 기본값이며 Quota를 200ms(200000µs)로 설정하면 환경에 따라 다르게 실행된다.
Quota를 Period 보다 더 크게 설정하면 멀티코어 환경에서 의미가 있는데 CPU Period와 CPU Quota는 코어별 설정이기 때문에 위 설정에 따르면 모든 코어에 대해 주기 내(여기선 100ms) 200ms 만큼 사용할 수 있다는 뜻이다. 즉 100ms 주기 당 2개 코어를 100ms 동안 사용하거나 4개 코어를 각각 50ms 동안 사용하거나 3개 코어를 100ms, 50ms, 50ms 동안 사용하게 된다.
멀티코어가 아닌 싱글코어인 경우에는 의미가 없는 옵션인데 코어별 설정이기 때문에 결국 한 개 CPU에서 실행할 수 있는 주기를 넘어섰기 때문에 Quota를 100ms로 설정한 것과 동일한 효과를 가진다. (효과가 있는지 없는지와는 별개로 설정 적용은 가능하다.)
이슈의 원인
위에서 정리한 CFS에서 프로세스가 스케줄링되는 방식을 보면 왜 GOMAXPROCS 값이 이슈가 되는지 여전히 알기가 어렵다. 이슈에서 언급한 내용을 보면 다음과 같은 내용이 있다.
The default setting of runtime.GOMAXPROCS() (to be the number of os-apparent processors) can be greatly misaligned with container cpu quota (e.g. as implemented through cfs bandwidth control by docker).
This can lead to large latency artifacts in programs, especially under peak load, or when saturating all processors during background GC phases.
여기서 언급된 문제는 다음과 같다.
- GOMAXPROCS의 기본 값과 컨테이너 CPU Quota 설정이 (크게) 잘못 조정될 수 있다.
- 잘못 조정된 설정으로 인해 지연율(latency)이 높아질 수 있다. (특히 부하나 백그라운드 GC가 발생하여 모든 코어를 사용할 때)
즉, 컨테이너 환경일 때 GOMAXPROCS 값과 CPU Quota 값이 잘못 설정된 상황에서 부하나 백그라운드 GC가 발생하여 모든 코어를 사용하게 되었을 때 높은 지연율을 보일 수 있다는 것이다. 다시 이 내용을 아까 위에서 정리한 CFS 스케줄링과 같이 연관 지어 보면 GOMAXPROCS 값과 CPU Quota 값이 서로 맞지 않게 잘못 설정된 경우 부하나 백그라운드 GC가 발생할 때 CPU Quota를 빠르게 소진하게 되고 Period가 다시 재할당되기 전까지 쓰로틀링이 걸리게 되는 것이다.
부하가 걸리지 않는 일반적인 상황에서는 Quota를 빠르게 소진하지 않기 때문에 Period 내 천천히 Quota를 소비하기 때문에 쓰로틀링이 걸릴 가능이 낮거나 걸리더라도 다음 Period 재할당 전까지 시간이 짧지만 반대의 경우는 재할당까지 시간이 너무 오래 걸리게 되어 높은 지연율을 보이게 된다.
내부에서 사용하거나 지연율이 중요하지 않은 프로그램이라면 큰 문제가 되지 않겠지만 사용자와 통신하는 API 서버나 지연율이 매우 중요한 서비스인 경우에는 크리티컬 할 수 있다.
해결책
근본적인 해결책은 CPU Quota를 모두 소진하지 않도록 하는 것이다.
Go 프로그램에서는 GOMAXPROCS 값을 적절하게 변경하여 소진이 안되도록 하거나 늦출 수 있다. 다만 컨테이너가 모두 같은 Quota로 설정되어 실행되는 것이 아니며 어떤 컨테이너는 다른 컨테이너보다 더 많은 CPU, 메모리 리소스를 필요로 할 수 있고 어떤 컨테이너는 매우 작은 리소스로도 충분히 돌릴 수 있다. 따라서 코드에 GOMAXPROCS 값을 고정해 사용할 수 없기 때문에 Quota에 맞게 자동으로 맞출 수 있는 방법이 필요하다. 우버에서는 이를 위해 CPU Quota에 맞게 GOMAXPROCS 값을 자동으로 조정해 주는 패키지를 만들었다.
링크의 설명과 같이 컨테이너 CPU Quota에 맞게 GOMAXPROCS 값을 조정해준다고 한다.
다음 글에서는 이 이슈가 아직 유효한지 테스트를 진행하고 automaxprocs를 적용했을 때 GOMAXPROCS 값이 적절하게 설정되는지 확인하려고 한다.
참고사항
내용만 보면 이런 생각이 들 수도 있다.
Go 말고도 파이썬이나 자바에서도 충분히 발생할 수 있을 것 같은데?
그렇다. 원인은 GC를 사용하는 프로그래밍 언어라던가 아니면 코딩 시 설계가 잘못되어 불필요한 부하를 발생시킬 경우 쓰로틀링이 발생하기 때문에 다른 언어에서도 충분히 발생할 수 있는 문제가 있다.
Cgroups 안에서 JVM 실행 시 애플리케이션이 멈추는 이슈에 대한 내용을 다룬 글도 있다.
Application Pauses When Running JVM Inside Linux Control Groups | LinkedIn Engineering
Reference
- runtime: make `GOMAXPROCS` cfs-aware on `GOOS=linux` · Issue #33803 · golang/go (github.com)
- Runtime options with Memory, CPUs, and GPUs | Docker Docs
- Application Pauses When Running JVM Inside Linux Control Groups | LinkedIn Engineering
- 3.2. cpu Red Hat Enterprise Linux 6 | Red Hat Customer Portal
'Developer > Go' 카테고리의 다른 글
Golang 고루틴(goroutine) 라이프사이클 관리 - channel (0) | 2024.04.24 |
---|---|
Golang 고루틴(goroutine) 라이프사이클 관리 - context.Context (0) | 2024.04.21 |
Golang defer 실행 순서 (1) | 2024.01.07 |