지난 Gophercon Korea 2023에서 진행된 ‘Golang 도입, 그리고 4년 간의 기록’ 세션을 쉽게 정리한 글입니다.
https://www.youtube.com/live/WZthMW0BaNA?si=MxkA35bd9ZVM3G91
기존 Ruby on Rails를 사용하여 서비스 중 이었지만, 사용자의 증가에 따라 전체 장애 수준의 많은 장애들을 보게 되었다고 한다. 특히, 채팅 기능의 장애는 가장 치명적이였다고 한다. 만약 거래를 하러 나가야 하는데 서버 장애로 채팅이 되지 않는다면? 엄청 치명적인 장애일 것이다. 그리고 각 서비스 도메인 별 간섭을 줄이기 위해 MSA를 도입하기로 결정했다고 한다. 그러다 나온 게 Golang이었다고 한다.
참고
뮤텍스가 동작하는 모드가 상황마다 다르다!??!!?
사용 중인 메모리가 어느 순간 갑자기 폭주하는 현상이 발생했다고 합니다.
2기가 → 8기가를 찍더니 서버 종료가 되어버렸다고 한다.
pprof 도구를 통해 분석 결과
채팅에서는 다양한 동시성을 제어하기 위해, 고루틴과 뮤텍스를 적극적으로 활용 중이다.
트래픽이 높아짐에 따라, 동시성 제어가 정상적으로 되지 않았다.
런타임 시스템에서 사용하는 내부 함수로, 고루틴(goroutine)을 일시적으로 "대기(park)"상태로 만든다.
여기서 Go 언어의 Mutex mode에 대해 알아보자.
Mutex는 Normal과 Starvation 중에서 하나의 mode로 있다.
Normal Mode
Starvation Mode
Starvation mode는 Tail latency의 문제점을 예방하기 위해 중요한 선택이다.
Tail latency란, 전체 시스템의 응답 시간 중 가장 늦은 응답 시간을 의미한다.
Normal mode에서는 빠르게 수행되는 작업이 계속해서 Mutex를 획득할 수 있어, 느린 작업이 '기아' 상태에 빠질 수 있다. 하지만 Starvation mode로 전환함으로써, 대기 중인 모든 작업이 공정하게 Mutex를 획득할 기회를 얻게 된다.
Starvation mode에 진입 후, Normal mode로 전환되지 않고 Queue에 쌓이기만 하는 상황이었다.
이때 Benchmark로 100/1000/2000/10000 등 다양한 케이스에 대해서 돌려봤고, 매직넘버로 2000을 선택하였다고 한다.
Container 환경에서 원치 않는 CPU Throttling
전체 CPU 사용량은 낮은데 CPU Throttling이 걸리고 있는 현상을 발견했다고 한다.
→ 해당 사항이 Go 스케줄링에 영향을 끼칠 것이다.
NumCPU(현재 시스템에서 사용 가능한 CPU)가 cgroup에 할당된 것보다 크게 잡히면 어떤 현상이 나타날까?
그 전에 우린 Go runtime Schedule 구조에 대해 먼저 알아야한다.
G - 고루틴
M - Worker thread or machine
P
와 연결되어 작동P - 프로세서
Go code를 실행하기 위한 자원
고루틴이 실행될 수 있는 자원을 추상화
G
는 일반적으로 P
의 로컬 큐에 삽입
P
는 실행할 G
를 선택하여 해당 G
를 실행 중인 M
과 연결
M
이 현재 연결되어 있는 P
에 따라 하나 이상의 G
를 실행
M
과 P
사이, 그리고 P
와 G
사이에서는 다양한 스케줄링 정책이 적용될 수 있음
이러한 설계는 M:N 스케줄링을 가능하게 하며, 많은 수의 고루틴을 효율적으로 스케줄링할 수 있다.
여기서 P-M은 서로 1:1 대응이 되어야 한다.
근데 만약 cgroup에 할당된 CPU보다 P가 많게 된다면?
→ 할당되지 않은 P에서 처리하는 Goroutine이 스케쥴링 단에서 블로킹된다.
https://github.com/uber-go/automaxprocs
라이브러리를 main.go에 import만 하면 된다.
코드개선
Runtime 개선
변수 초기화 때, 필요한 만큼만 Cap을 할당하는 방식을 선택했다.
map이나 slice는 메모리를 할당할 때, doubling 방식을 사용한다.
처음 지정한 할당된 메모리 크기에서 넘어가는 케이스마다 2^n 으로 메모리를 할당하는 상황이다. 그래서 slice & map 에서는 initialization 시에 필요한 만큼만 할당하여 런타임중에 불필요한 malloc 호출 및 mem copy를 줄였고 doubling을 피해 필요한만큼만 메모리 사용하였다.
Cap을 미리 알 수 있는 경우?
make를 활용해 cap을 주어서 변수 초기화를 시켰다.
자주 사용되는 객체, 생성 비용이 비싼 객체 등 이런 특성을 지닌 객체들에 대해 resource pool을 만들었다.
resource pool?
한정된 수의 리소스를 효율적으로 관리하기 위한 프로그래밍 패턴
리소스 풀을 사용하면 리소스를 필요할 때마다 생성하고 해제하는 대신, 미리 생성해둔 리소스를 재사용할 수 있어 성능을 향상시킬 수 있다.
resource pool 동작 방식
구현 예시
import "sync"
var pool = sync.Pool{
New: func() interface{} {
return []byte{}
},
}
func main() {
item := pool.Get() // 풀에서 리소스 가져오기
// 리소스 사용
pool.Put(item) // 풀에 리소스 반환
}
코드 상의 개선을 진행했지만, GC Frequency가 높은 현상을 발견했다.
분당 약 400회씩 GC가 돌고 있었기에 400회의 stop-the-world가 발생했다. stop-the-world가 발생하게 되면 latency에 영향을 주게 되기에 결국 P99에 영향을 줄 수 있게 된다.
GC Frequency?
stop-the-world?
P99?
프로파일링 결과
GC cycle의 횟수를 줄이면 나아질까?
- GC의 Cycle로 인한 Latency 영향도 최소화 할 수 있다.
- 인프라 비용 절감할 수 있다.
그래서 GC를 튜닝하기로 결정했다.
Go의 runtime은 GC와 관련된 많은 튜닝 옵션을 주지 않는다.
GOGC
GOGC
는 기존 메모리 대비 얼만큼의 메모리가 할당되면 GC가 실행될지에 대한 백분율 값을 설정하는 환경 변수이다. 기본값은 100이다.MemoryLimit
Go runtime의 GC 알고리즘은 CMS이고 CMS를 튜닝하는 방법들이 몇 가지 있는데, 당근마켓에서 선택한 방법은 soft limit을 기준으로만 GC를 수행하게 끔 튜닝하는 것이었다.
Latency가 중요한 서비스이다보니 GC가 Latency에 주는 영향을 최소화하고 싶었기에GC cycle 횟수 감소를 목표로 설정하게 되었다고 한다.
GC가 수행 되기 전에 순간적으로 메모리를 많이 사용하는 상황이 발생할 경우, OOM(Out of Memory)으로 어플리케이션이 비정상 종료될 수 있다. 현재 운영중인 어플리케이션의 메모리 사용 패턴상 순간적으로 메모리를 많이 쓰는 케이스가 생기지 않는다고 한다. OOM의 위험이 없다보니 해당 패턴을 사용하기로 결정했다. Soft Limit을 너무 타이트하게 가져가지 않도록 어느정도 여유를 두고 적용하였다.
기존
Default 값
현재
CPU 사용량이 40% → 25%
GC Frequency 400회 → 8회
CPU Time 30% → 1%
Pod count 245개 → 120개
의 결과를 보여줬다.
당근마켓에서는 여기서 그치지 않고 CPU와 Memory 관리를 위해 Autopprof를 개발했다고 한다.
https://github.com/daangn/autopprof
일정 수준 사용량이 넘었을 때 슬랙 알람이 가기 때문에 매번 서버에 들어가 pprof를 찍어보는 번거로운 작업을 할 이유가 없어졌다. 나중에 트래픽이 많은 서비스를 직접 운영하게 된다고 한번 꼭 사용해보고 싶고 거기에 더해 추가적인 기능도 붙혀서 오픈소스로 배포도 해보고 싶다.
영상을 보고 정리하면서 아직 내가 얼마나 부족하고 아는 게 없는 지 많이 알게 된 것 같다. 나중에 연사자로 참여할 때까지 열심히 한번 달려보고 싶다.