[Go] 당근마켓에서의 Golang 도입, 그리고 4년 간의 기록

Seyeon_CHOI·2024년 2월 4일
1

Go

목록 보기
4/5
post-thumbnail

지난 Gophercon Korea 2023에서 진행된 ‘Golang 도입, 그리고 4년 간의 기록’ 세션을 쉽게 정리한 글입니다.
https://www.youtube.com/live/WZthMW0BaNA?si=MxkA35bd9ZVM3G91

당근마켓에서는 왜 Golang을 시작하게 되었을까?

기존 Ruby on Rails를 사용하여 서비스 중 이었지만, 사용자의 증가에 따라 전체 장애 수준의 많은 장애들을 보게 되었다고 한다. 특히, 채팅 기능의 장애는 가장 치명적이였다고 한다. 만약 거래를 하러 나가야 하는데 서버 장애로 채팅이 되지 않는다면? 엄청 치명적인 장애일 것이다. 그리고 각 서비스 도메인 별 간섭을 줄이기 위해 MSA를 도입하기로 결정했다고 한다. 그러다 나온 게 Golang이었다고 한다.


참고

당근마켓 개발팀 Go언어를 도입하다 | 당근테크


Go의 장점

  1. 컴파일 속도가 빠르고 효율적이다.
    • 간결한 문법과 구조 → 컴파일러가 더 적은 양의 코드를 분석한다.
    • 패키지나 파일 단위로 병렬 컴파일을 수행한다.
  2. 빠른 애플리케이션 시작 시간. 재시작이 필요한 경우에 빠르게 복구 가능하다.
    • 대부분의 의존성을 단일 실행 파일에 묶어 배포하므로, 애플리케이션 시작 과정이 단순하다.
    • 간단한 초기화 과정, 복잡한 클래스 계층이나 다양한 초기화 프로세스가 없다.
  3. GC(Garbage Collection)가 있어서 높은 성능을 유지하면서도 코드 관리가 용이하다.
  4. 멀티코어 CPU를 잘 활용할 수 있다.
    • Goroutine과 Channel을 사용하여, 병렬작업을 쉽게 처리할 수 있다.
  5. 언어 표현이 단순하고, 개발 생산성이 뛰어나다.
  6. 애플리케이션 프로파일링, 메트릭 수집 등 표준 라이브러리에 포함되어 있어서 서비스 운영에 필요한 세부 모니터링과 성능 튜닝에 용이하다. (pprof)
  7. 표준 라이브러리가 풍부하다. (텍스트, 암호화, 네트워크 등)
  8. 크로스플랫폼에서 동작하는 앱을 쉽게 개발할 수 있다.
  9. 정적타입 언어이므로 컴파일 타임에 대부분의 오류를 잡을 수 있다.

Go의 아쉬운 점이라 쓰고 단점이라 읽는다.

  1. 라이브러리 생태계가 상대적으로 작다.
    • 특히 데이터분석과 같은 특정 도메인들은 아직 사용하는데 불편함이 있다.
  2. 에러처리가 번거롭다.
    • 각 함수를 정의할 때 에러를 반환하도록 설계하기 때문에, 이를 체크하고 처리해야 한다.
  3. GC는 대규모 힙을 가진 시스템에서 성능 저하를 일으킬 수 있다.
  4. 인터페이스를 적극 활용하면, 런타임 에러에 대해서 디버깅 하는데 어려움이 있다.
    • 동적으로 할당되기 때문에 컴파일 타임에 모든 문제를 발견하기 어렵다.
  5. 다른 언어 대비 Generic 사용이 제한적이다.
  6. Spring과 같이 표준화된 개발 방법론이 서비스 개발에 강제되지 않는다.
    • 사람에 따라 코드 작성 방식이 다를 수 있다.


1. Starvation mode of Mutex

뮤텍스가 동작하는 모드가 상황마다 다르다!??!!?

사용 중인 메모리가 어느 순간 갑자기 폭주하는 현상이 발생했다고 합니다.

2기가 → 8기가를 찍더니 서버 종료가 되어버렸다고 한다.

pprof 도구를 통해 분석 결과

  • Gopark에서 가장 많은 메모리를 사용 중이었고 그 중 sync.(*Mutex).lockSlow에서 많은 메모리를 사용 중이었다고 한다.
    • sync.(*Mutex).lockSlow는 MutexLock() 메서드가 일반적인 빠른 경로(fast path)로 락을 획득하지 못했을 때 호출된다.

채팅에서는 다양한 동시성을 제어하기 위해, 고루틴과 뮤텍스를 적극적으로 활용 중이다.

트래픽이 높아짐에 따라, 동시성 제어가 정상적으로 되지 않았다.


여기서 GoPark란?

런타임 시스템에서 사용하는 내부 함수로, 고루틴(goroutine)을 일시적으로 "대기(park)"상태로 만든다.

  • 이 함수가 호출되면 해당 고루틴은 실행을 일시 중단하고 대기 상태가 된다.
  • 일반적으로 다른 고루틴이 먼저 어떤 작업을 완료할 때까지 기다리는 것을 의미한다.

여기서 Go 언어의 Mutex mode에 대해 알아보자.


Mutex mode

Mutex는 NormalStarvation 중에서 하나의 mode로 있다.


Normal Mode

  • 일반적으로 Goroutine들이 Mutex를 획득하려고 시도할 때, 대기열에 FIFO 순서로 대기열에 들어간다.
  • 이 모드에서는 우선 순위가 없으므로, 어떤 Goroutine이 먼저 도착하면 먼저 Mutex를 획득한다.

Starvation Mode

  • 만약 어떤 Goroutine이 1ms 이상 Mutex를 획득하지 못하면 Starvation mode로 전환된다.
  • Starvation mode에선 mutex의 소유권이 대기열의 맨 앞에 있는 Goroutine에게 넘어간다.
  • 새로 들어온 Goroutine은 Mutex를 즉시 획득하지 못하고 대기열의 끝에 추가한다.
  • 만약 어떤 Goroutine이 뮤텍스의 소유권을 획득하고 대기열의 마지막이거나,
    1ms 이하로 대기했다면 mutex는 다시 normal mode로 전환된다.

Starvation modeTail latency의 문제점을 예방하기 위해 중요한 선택이다.


Tail latency란, 전체 시스템의 응답 시간 중 가장 늦은 응답 시간을 의미한다.

Normal mode에서는 빠르게 수행되는 작업이 계속해서 Mutex를 획득할 수 있어, 느린 작업이 '기아' 상태에 빠질 수 있다. 하지만 Starvation mode로 전환함으로써, 대기 중인 모든 작업이 공정하게 Mutex를 획득할 기회를 얻게 된다.


Solution of mutex starvation mode

Starvation mode에 진입 후, Normal mode로 전환되지 않고 Queue에 쌓이기만 하는 상황이었다.

  • 여러 개의 Mutex 사용
    • 기존에 하나의 Mutex만을 사용하여 모든 리소스에 접근을 제어하던 방식에서 여러 개의 Mutex를 사용하게 되면, 동시에 여러 작업을 수행할 수 있어 성능이 향상된다.
  • 그룹 지정
    • 그룹 내부에서만 경쟁이 일어나고 그룹 간에는 별도로 동작할 수 있다.
    • 이로 인해 병목 현상을 줄이고, 여러 그룹이 병렬로 작업을 수행할 수 있다.
  • MapReduce 개념 활용
    • MapReduce는 병렬 처리와 분산 처리를 돕는 프로그래밍 모델
    • 데이터를 여러 부분으로 나누고 각 부분을 병렬로 처리한 뒤, 다시 합치는 방식으로 작동한다.
    • Mutex에 적용하면, 각 Mutex 그룹은 작은 데이터 세트에 대해 독립적으로 작업을 수행한다.

이때 Benchmark로 100/1000/2000/10000 등 다양한 케이스에 대해서 돌려봤고, 매직넘버로 2000을 선택하였다고 한다.


2. CPU Throttling Optimization

Container 환경에서 원치 않는 CPU Throttling

전체 CPU 사용량은 낮은데 CPU Throttling이 걸리고 있는 현상을 발견했다고 한다.

가설

  • 4코어 CPU로 설정된 컨테이너에 NumCPU가 16으로 잡혀있음을 확인했다.
    • 16은 호스트 머신의 CPU 코어 수였다.

→ 해당 사항이 Go 스케줄링에 영향을 끼칠 것이다.

NumCPU(현재 시스템에서 사용 가능한 CPU)가 cgroup에 할당된 것보다 크게 잡히면 어떤 현상이 나타날까?


그 전에 우린 Go runtime Schedule 구조에 대해 먼저 알아야한다.


Go runtime Schedule

G - 고루틴

M - Worker thread or machine

  • 운영체제의 스레드
  • 고루틴의 코드를 실행할 수 있는 능력이 있지만, 일반적으로 P와 연결되어 작동

P - 프로세서

  • Go code를 실행하기 위한 자원

  • 고루틴이 실행될 수 있는 자원을 추상화

  • G는 일반적으로 P의 로컬 큐에 삽입

  • P는 실행할 G를 선택하여 해당 G를 실행 중인 M과 연결

  • M이 현재 연결되어 있는 P에 따라 하나 이상의 G를 실행

  • MP 사이, 그리고 PG 사이에서는 다양한 스케줄링 정책이 적용될 수 있음

이러한 설계는 M:N 스케줄링을 가능하게 하며, 많은 수의 고루틴을 효율적으로 스케줄링할 수 있다.

여기서 P-M은 서로 1:1 대응이 되어야 한다.

근데 만약 cgroup에 할당된 CPU보다 P가 많게 된다면?

→ 할당되지 않은 P에서 처리하는 Goroutine이 스케쥴링 단에서 블로킹된다.


적용

https://github.com/uber-go/automaxprocs

라이브러리를 main.go에 import만 하면 된다.


3. 메모리 최적화하기

  1. 코드개선

    • 불필요한 메모리 할당을 최소화한다.
    • 메모리 할당을 피할 수 없다면, 할당된 메모리를 재사용한다.
  2. Runtime 개선

    • GC 튜닝을 해본다.

불 필요한 메모리 할당을 최소화

변수 초기화 때, 필요한 만큼만 Cap을 할당하는 방식을 선택했다.

map이나 slice는 메모리를 할당할 때, doubling 방식을 사용한다.

처음 지정한 할당된 메모리 크기에서 넘어가는 케이스마다 2^n 으로 메모리를 할당하는 상황이다. 그래서 slice & map 에서는 initialization 시에 필요한 만큼만 할당하여 런타임중에 불필요한 malloc 호출 및 mem copy를 줄였고 doubling을 피해 필요한만큼만 메모리 사용하였다.


Cap을 미리 알 수 있는 경우?

make를 활용해 cap을 주어서 변수 초기화를 시켰다.


할당된 메모리를 재사용

자주 사용되는 객체, 생성 비용이 비싼 객체 등 이런 특성을 지닌 객체들에 대해 resource pool을 만들었다.


resource pool?

한정된 수의 리소스를 효율적으로 관리하기 위한 프로그래밍 패턴

리소스 풀을 사용하면 리소스를 필요할 때마다 생성하고 해제하는 대신, 미리 생성해둔 리소스를 재사용할 수 있어 성능을 향상시킬 수 있다.


resource pool 동작 방식

  1. 초기화: 풀은 시작할 때 일정 수의 리소스를 미리 생성한다.
  2. 요청: 리소스가 필요한 클라이언트는 풀에 리소스를 요청한다.
  3. 할당: 풀은 사용 가능한 리소스를 클라이언트에게 할당한다.
  4. 사용: 클라이언트는 리소스를 사용한 후에 다시 풀에 반환한다.
  5. 재사용: 반환된 리소스는 다시 풀에 저장되어, 다음에 필요한 클라이언트에게 할당될 수 있다.

구현 예시

import "sync"

var pool = sync.Pool{
    New: func() interface{} {
        return []byte{}
    },
}

func main() {
    item := pool.Get() // 풀에서 리소스 가져오기
    // 리소스 사용
    pool.Put(item) // 풀에 리소스 반환
}

4. GC 튜닝하기

코드 상의 개선을 진행했지만, GC Frequency가 높은 현상을 발견했다.

분당 약 400회씩 GC가 돌고 있었기에 400회의 stop-the-world가 발생했다. stop-the-world가 발생하게 되면 latency에 영향을 주게 되기에 결국 P99에 영향을 줄 수 있게 된다.

GC Frequency?

  • 프로그램이 실행되는 동안 가비지 컬렉션(GC)이 얼마나 자주 발생하는지를 나타낸다.

stop-the-world?

  • 가비지 컬렉션 동작을 수행할 때 모든 실행 중인 고루틴(goroutines)이 일시적으로 멈추는 과정이다.
  • 모든 고루틴이 멈추므로 가비지 컬렉터가 안전하게 메모리를 점검하고 회수할 수 있다.

P99?

  • 99th percentile
  • 모든 측정치 중에서 가장 높은 1%를 제외한 값들이 이 값 이하라는 것을 의미한다.
  • 예를 들어, 웹 서비스의 응답 시간이 P99에서 300ms라고 한다면, 이는 99%의 요청이 300ms 이내에 완료됐다는 것을 나타낸다.

프로파일링 결과

  • GC 작업이 CPU time의 30%를 소모한다.
  • 불필요하게 GC 작업을 하는 건, 운영하는데 굉장한 낭비한다.

GC cycle의 횟수를 줄이면 나아질까?

  • GC의 Cycle로 인한 Latency 영향도 최소화 할 수 있다.
  • 인프라 비용 절감할 수 있다.

그래서 GC를 튜닝하기로 결정했다.



GC 튜닝하기

Go의 runtime은 GC와 관련된 많은 튜닝 옵션을 주지 않는다.

  • 튜닝할 수 있는 파라미터가 2개 (1.20 버전 기준)
    • GOGC

      • GOGC는 기존 메모리 대비 얼만큼의 메모리가 할당되면 GC가 실행될지에 대한 백분율 값을 설정하는 환경 변수이다. 기본값은 100이다.
      • https://pkg.go.dev/runtime 패키지 참고
    • MemoryLimit


Go runtime의 GC 알고리즘은 CMS이고 CMS를 튜닝하는 방법들이 몇 가지 있는데, 당근마켓에서 선택한 방법은 soft limit을 기준으로만 GC를 수행하게 끔 튜닝하는 것이었다.

Latency가 중요한 서비스이다보니 GC가 Latency에 주는 영향을 최소화하고 싶었기에GC cycle 횟수 감소를 목표로 설정하게 되었다고 한다.

GC가 수행 되기 전에 순간적으로 메모리를 많이 사용하는 상황이 발생할 경우, OOM(Out of Memory)으로 어플리케이션이 비정상 종료될 수 있다. 현재 운영중인 어플리케이션의 메모리 사용 패턴상 순간적으로 메모리를 많이 쓰는 케이스가 생기지 않는다고 한다. OOM의 위험이 없다보니 해당 패턴을 사용하기로 결정했다. Soft Limit을 너무 타이트하게 가져가지 않도록 어느정도 여유를 두고 적용하였다.


기존

Default 값

  • GOGC=100(%)
  • MemoryLimit: no soft limit.

현재

  • GOGC=-1(%)
    • -1로 두어 GOGC 방식으로 GC가 돌지 않도록 구현했다.
  • MemoryLimit: soft limit은 어플리케이션의 메모리 사용 패턴과 특성을 고려하여 container 스펙의 60~80%정도로 적용했다.
    • MemoryLimit이 넘을 경우에만 GC가 동작하도록 했다.

GC 튜닝 결과

CPU 사용량이 40% → 25%

GC Frequency 400회 → 8회

CPU Time 30% → 1%

Pod count 245개 → 120개

의 결과를 보여줬다.


당근마켓에서는 여기서 그치지 않고 CPU와 Memory 관리를 위해 Autopprof를 개발했다고 한다.

https://github.com/daangn/autopprof

일정 수준 사용량이 넘었을 때 슬랙 알람이 가기 때문에 매번 서버에 들어가 pprof를 찍어보는 번거로운 작업을 할 이유가 없어졌다. 나중에 트래픽이 많은 서비스를 직접 운영하게 된다고 한번 꼭 사용해보고 싶고 거기에 더해 추가적인 기능도 붙혀서 오픈소스로 배포도 해보고 싶다.



영상을 보고 정리하면서 아직 내가 얼마나 부족하고 아는 게 없는 지 많이 알게 된 것 같다. 나중에 연사자로 참여할 때까지 열심히 한번 달려보고 싶다.

profile
오물쪼물 코딩생활 ๑•‿•๑

0개의 댓글