[Go] GC 튜닝: OOM(Out of Memory) 방지하기

Go는 좀 써보니까, 규모 좀 있는 데이터 처리에 있어서는 극악의 효율을 보여준다.

굳이 빅데이터까지 가지 않더라도 JSON 상하차 같은거 할때 사이즈가 크면 쓰레기 메모리가 금방 쌓인다.
io.Read 자체에서 쌓이는 것도 있고, Go 특유의 리플렉션 떡칠, 포인터 할당으로 인해 쌓이는 것도 있다.
내 사용사례의 경우에는 실제 메모리 사용량과 누적된 메모리가 7배쯤 차이가 났다.

Go fanboy들은 코드를 잘못짜서 그런거라는 주장을 하곤 하던데, 그냥 유즈케이스가 그랬다. 내가 할당 관련해서 코드 수준 리팩토링을 할만큼 했는데도 이정도였던거고, 언어 자체가 이런 사례에 잘 맞지 않았던 것이다.

하여튼 문제는 이런 상황에서는 호스트든 컨테이너든 OOM으로 비명횡사할 수 있다는 것이다.
C/C++, Rust 같은 언어들과는 다르게 메모리 해제가 GC에 의해 비동기적으로 실행되다보니, 실제 사용량은 얼마 되지도 않는데, GC 제거 대기상태의 쓰레기 메모리들로 인해서 OOM이 뜰 때가 잦다.

https://weaviate.io/blog/gomemlimit-a-game-changer-for-high-memory-applications




상황 재현

전체 코드는 다음 레포에서 확인할 수 있다.
https://github.com/myyrakle/golab

코드는 다음과 같다.

package main

import (
	"fmt"
	"runtime/debug"
)

func main() {
	i := 0

	// 1.5gb 정도
	memorySize := 1500 * 1024 * 1024

	for {
		var bytes [][]byte

		fmt.Printf("%d번째 메모리 할당 (%d Byte)\n", i, memorySize)

		chunkSize := 1000000
		for j := 0; j < chunkSize; j++ {
			bytes = append(bytes, make([]byte, memorySize/chunkSize))
		}

		fmt.Println(bytes[len(bytes)-1][0])

		i += 1
	}
}

1.5기가어치의 메모리를 매 루프마다 할당하고 해제하도록 했다.
그리고 GC 자체가 좀 느려지게 만들려고 할당을 청크단위로 복잡하게 했다. 실제로는 저거보다 복잡하게 할당되는 경우가 더 많을 것이다.


FROM golang:1.20-alpine3.16 AS builder

WORKDIR /app
ADD . /app

RUN apk add alpine-sdk

RUN go build -o bin/app cmd/memoryeater/main.go

FROM alpine

WORKDIR /app

COPY --from=builder /app/bin/app /app/app

CMD ["sh", "-c", "/app/app"]

빌드하고


docker build -f docker/Dockerfile.memory -t memoryeater .
docker run -m 2048m --memory-swap 0 --memory-reservation 0 memoryeater

적당히 2기가 주고 실행하면


좀 돌다가 뻗는다.
쓰레기가 쌓여서 압사한 것이다.


Exit Code도 OOM을 나타내는 137번으로 나왔다.

이제 문제를 해결해보자.




코드 수준의 개선

할당 코드들을 먼저 수정하는게 우선이 되어야 한다.
빈번한 할당과 해제를 유도하는 패턴의 코드를 최소화하는 것이 중요하다. 이건 별도 포스트를 참조한다.

관련 포스트
https://blog.naver.com/sssang97/223232328524

하지만 순수하게 GC의 한계로 인해서 메모리 병목이 생기는 경우도 꽤 빈번하게 발생한다. 이 포스트에서는 GC의 문제를 개선하는 방향을 주로 다룬다.




직접 Free하기 (비추천)

문제가 되는 할당과 해제 시점을 명확하게 추정할 수 있다면 아예 직접 트리거하는게 나을 수도 있다.
물론 복잡한 구조를 가진 시스템에서는 사용하기 어렵고, 위 예제와 같이 명확하고 선형적인 메모리 사용 패턴을 가진 경우에나 사용하기 적절할 것이다.

그럼 이것도 안정적으로 동작한다.




GOGC (비권장)

GOGC는 GC가 도는 주기를 조정할 수 있게 해주는 환경변수 옵션이다.
이건 비율 기반으로 동작하는데, 마지막 수단으로만 남겨두는걸 권장하고 싶다.

GOGC의 기본값은 100으로, 메모리가 100%만큼(2배) 증가할때마다 GC를 트리거하겠다는 의미다.
그래서 200%으로 설정하면 메모리가 3배 늘때마다 GC가 실행되고, 50%으로 설정하면 메모리가 1.5배로 늘때마다 GC가 실행된다.
그래서 50%으로 설정하면 GC가 더 빈번하게 도니까 좀더 컴팩트하게 쓰레기가 정리될 수 있겠으나, GC 자체의 부하가 늘어서 CPU 사용량이 늘 수 있다.

코드에서 SetGCPercent를 통해 설정하거나

환경변수로 넘겨서 활성화시킬 수 있다.

docker build -f docker/Dockerfile.memory -t memoryeater .
docker run -m 2048m --memory-swap 0 --memory-reservation 0 -e GOGC=50 memoryeater

좀더 빈번하게 도니까 전보다 안정적으로 돌긴 한다.
근데 이건 메모리가 심하게 차지 않은 상황에서도 과한 부하를 줄 수 있기 때문에 대신 GOMEMLIMIT 같은걸 쓰기를 권장한다.




GOMEMLIMIT을 통한 Memory Limit 구성

GOMEMLIMIT은 1.19에 추가된 GC 튜닝용 플래그다.
이건 Golang GC 자체에 대해서 Soft한 Memory Limit을 설정하게 해준다.

이게 뭔 소리나면, 이걸 1gb로 설정해놓으면, 메모리가 1gb 차면 바로 GC를 실행시킨다는 것이다.
그래서 GOGC 조정보다는 훨씬 명확하고 안정성이 있다.

이것도 debug 모듈을 통해 직접 코드에서 변경하거나

환경변수를 통해 주입할 수 있다.

docker build -f docker/Dockerfile.memory -t memoryeater .
docker run -m 2048m --memory-swap 0 --memory-reservation 0 -e GOMEMLIMIT=1000MiB memoryeater

그러면 GC 때문에 좀 느려지긴 하지만 안정적으로 동작한다.
1기가가 쌓일때마다 GC를 트리거하기 때문이다.




참조
https://weaviate.io/blog/gomemlimit-a-game-changer-for-high-memory-applications
https://johngrib.github.io/wiki/java/gc/death-spiral/
https://tech.kakao.com/posts/618
https://blog.twitch.tv/en/2019/04/10/go-memory-ballast-how-i-learnt-to-stop-worrying-and-love-the-heap/