Go GOMEMLIMIT·GOGC — 힙 800MB인데 OOMKilled가 나는 이유
이 글의 순서8개 절
컨테이너 limit이 1GB인데 힙 800MB에서 OOMKilled가 났다. GC가 있는 언어인데 왜 메모리가 넘쳤을까. GC는 살아 있는 힙의 비율로 돌고, 컨테이너는 절대값으로 프로세스를 죽인다. 이 글은 두 기준 사이를 잇는 GOMEMLIMIT과, 애초에 힙으로 덜 보내는 escape analysis를 함께 본다.
스택이냐 힙이냐는 컴파일러가 정한다
Go에는 C의 malloc처럼 힙에 놓으라고 지정하는 문법이 없다. new나 &를 써도 마찬가지다. 어디에 놓을지는 컴파일러가 escape analysis로 정하고, 기준은 이 값이 함수 밖에서도 필요한가 하나다.
함수를 회의실로 보면 쉽다. 회의 중에 화이트보드에 쓴 메모는 회의가 끝나면 지워진다. 이게 스택이고, 치우는 데 따로 드는 비용이 없다. 메모를 회의실 밖으로 들고 나가야 하면 종이에 옮겨 창고에 넣어야 하고, 창고는 GC가 관리한다. 이게 힙이다.
두 가지는 짚고 가자. 지역 변수의 주소를 반환하는 Go 코드가 잘못된 포인터를 만들지는 않는다. 필요한 수명만큼 컴파일러가 저장 공간을 마련해 주니, C에서 스택 지역 변수의 주소를 돌려줄 때 생기는 문제가 Go에는 없다.
또 탈출하지 않는다고 나온 값도 스택에 객체 하나로 놓인다는 보장은 없다. 값이 레지스터에만 남거나, 계산이 단순해져 객체 자체가 사라질 수도 있다. 언어가 보장하는 건 동작이고, 구체적인 배치는 컴파일러 버전과 최적화에 따라 바뀐다. 그래서 배치는 문법으로 추측하지 않고 도구에 물어본다.
🧪 직접 해보기
값으로 돌려준 onStack만 진단에 나오지 않는다
탈출이 생기기 쉬운 모양을 다섯 함수에 담았다. 한 파일에 넣고 컴파일러의 진단을 읽어 보자.
package main
func main() {}
type Point struct{ X, Y int }
func onStack() Point { p := Point{1, 2}; return p } // 값 반환 — 복사되어 나감func byPointer() *Point { p := Point{1, 2}; return &p } // ① 포인터 반환func toIface() any { p := Point{1, 2}; return p } // ② 인터페이스로 반환func captured() func() int { n := 0 return func() int { n++; return n } // ③ 클로저 캡처}func unknownSize(n int) int { buf := make([]byte, n) // ④ 크기를 컴파일 때 모름 return copy(buf, "hello")}$ go build -gcflags='-m' . 2>&1 | grep -v inlin# escape./main.go:8:27: moved to heap: p ← ① byPointer의 p./main.go:9:52: p escapes to heap ← ② toIface의 박싱./main.go:11:2: moved to heap: n ← ③ captured의 n./main.go:12:9: func literal escapes to heap ← ③ 클로저 자체./main.go:15:13: make([]byte, n) escapes to heap ← ④$ GOTOOLCHAIN=go1.27.0 go build -gcflags='-m' . 2>&1 | grep make./main.go:15:13: make([]byte, n) does not escape출력에 onStack의 줄은 없다. 값을 복사해서 돌려주니 원본 p는 함수가 끝날 때 같이 사라져도 된다. 나머지 넷은 Go 1.24.7에서 힙으로 갔고, 그중 ④만 Go 1.27.0에서 판정이 바뀌었다.
④가 바뀐 이유는 Go 1.25 릴리스 노트에 한 줄로 나온다. 슬라이스의 저장 공간을 스택에 두는 경우가 늘었다는 내용이다. 크기를 32바이트와 33바이트로 나눠 할당 횟수를 재면 경계가 보인다.
package vmake
import "testing"
//go:noinlinefunc use(n int) int { buf := make([]byte, n) return copy(buf, "hello")}
func BenchmarkMake32(b *testing.B) { for i := 0; i < b.N; i++ { use(32) }}
func BenchmarkMake33(b *testing.B) { for i := 0; i < b.N; i++ { use(33) }}$ GOTOOLCHAIN=go1.24.7 go test -run '^$' -bench . -benchmem -benchtime=100msBenchmarkMake32-10 13048869 9.401 ns/op 32 B/op 1 allocs/opBenchmarkMake33-10 12464780 9.748 ns/op 48 B/op 1 allocs/op$ GOTOOLCHAIN=go1.27.0 go test -run '^$' -bench . -benchmem -benchtime=100msBenchmarkMake32-10 55769622 1.802 ns/op 0 B/op 0 allocs/opBenchmarkMake33-10 15216597 7.681 ns/op 48 B/op 1 allocs/opGo 1.27.0의 Make32 줄만 0 allocs/op다. 크기를 컴파일할 때 몰라도 실행할 때 32바이트 안이면 스택 버퍼를 쓰고, 33바이트부터는 Go 1.24.7처럼 힙에 할당한다. 소스는 그대로인데 툴체인만 바꿔서 결과가 달라졌다.
그래서 이 넷을 힙 할당을 만드는 목록으로 외우지는 않는다. 인터페이스로 넘긴다고, 클로저를 만든다고 반드시 새 할당이 생기지도 않는다. 컴파일러가 실제 사용 범위를 볼 수 있으면 같은 문법도 스택에 남는다. 이 넷은 확인할 후보다.
그래도 핫패스에서 자주 눈에 띄는 쪽은 ②다. any로 받는 함수에 값을 계속 넘기면 그 값들이 힙으로 나가면서 allocs/op가 올라가는 경우가 있다. 이것도 빌드에서 확인할 일이다.
Go의 할당 자체는 빠르다. 그래도 할당이 많으면 문제가 되는 건, 할당한 만큼이 나중에 GC가 훑고 치울 일로 돌아오기 때문이다.
🧪 직접 해보기
같은 NewPair가 한 빌드 안에서 두 판정을 받는다
앞 실험의 진단을 한 줄씩 떼어 읽으면 곤란할 때가 있다. 같은 생성식에 “힙으로 탈출한다”와 “탈출하지 않는다”가 함께 나올 수 있어서다. 짧은 함수 하나로 확인해 보자.
package allocation
type Pair struct { A, B int64}
func NewPair(n int64) *Pair { return &Pair{A: n, B: n + 1}}
func SumLocal(n int64) int64 { p := NewPair(n) return p.A + p.B}
var Published *Pair
func Publish(n int64) { Published = NewPair(n)}NewPair는 두 정수를 가진 객체의 주소를 돌려준다. SumLocal은 필드의 합만 구하고 끝내고, Publish는 전역 변수에 그 주소를 남긴다. 전역 저장은 탈출을 분명히 만들려고 넣은 실험 장치다.
package allocation
import "testing"
var sink int64
func BenchmarkLocal(b *testing.B) { for i := 0; i < b.N; i++ { sink += SumLocal(int64(i)) }}
func BenchmarkPublished(b *testing.B) { for i := 0; i < b.N; i++ { Publish(int64(i)) }}$ go build -gcflags='-m=2' allocation.go 2>&1 | grep -E 'inline NewPair|inlining call|escapes to heap$|does not escape'./allocation.go:7:6: can inline NewPair with cost 9 as: func(int64) *Pair { return &Pair{...} }./allocation.go:12:14: inlining call to NewPair./allocation.go:19:21: inlining call to NewPair./allocation.go:8:9: &Pair{...} escapes to heap./allocation.go:12:14: &Pair{...} does not escape./allocation.go:19:21: &Pair{...} escapes to heap마지막 세 줄이 모두 같은 &Pair{}인데 판정이 다르다. 8번째 줄은 NewPair 자체의 본문이고, 12번째와 19번째 줄은 SumLocal과 Publish에 펼쳐 넣은 사본이다. 인라이닝은 짧은 함수의 내용을 호출한 자리에 펼쳐 넣는 최적화인데, 펼치고 나면 컴파일러가 그 값이 어디까지 쓰이는지 정확히 볼 수 있다. SumLocal에서는 합을 구한 뒤 주소가 남지 않아 탈출하지 않고, Publish에서는 전역에 남아 탈출한다.
그래서 진단을 읽을 때는 메시지 문구보다 파일과 줄 번호, 그 주변의 inlining call부터 본다. 벤치마크로 옮기면 차이가 그대로 나온다.
$ go test -run '^$' -bench . -benchmem -benchtime=200ms -count=3 allocation.go allocation_test.goBenchmarkLocal-2 396647101 0.6096 ns/op 0 B/op 0 allocs/opBenchmarkLocal-2 396067387 0.6077 ns/op 0 B/op 0 allocs/opBenchmarkLocal-2 401408246 0.6848 ns/op 0 B/op 0 allocs/opBenchmarkPublished-2 11122455 18.36 ns/op 16 B/op 1 allocs/opBenchmarkPublished-2 14999492 16.15 ns/op 16 B/op 1 allocs/opBenchmarkPublished-2 13671566 16.87 ns/op 16 B/op 1 allocs/op같은 NewPair를 부르는데 한쪽은 0 allocs/op, 다른 쪽은 16바이트짜리 1 allocs/op다. 이제 인라이닝만 꺼 보자.
$ go test -run '^$' -bench . -benchmem -benchtime=200ms -gcflags='-l' allocation.go allocation_test.goBenchmarkLocal-2 13994080 16.01 ns/op 16 B/op 1 allocs/opBenchmarkPublished-2 13299541 15.88 ns/op 16 B/op 1 allocs/op옵션 하나로 BenchmarkLocal의 할당이 0에서 1로 바뀌었다. 코드는 한 글자도 건드리지 않았다. 포인터를 쓰면 힙으로 간다는 말로는 이 결과를 설명할 수 없다.
이 맥(darwin/arm64, Go 1.24.7)에서도 다시 돌렸다. Local은 0.78ns에 0 allocs/op, Published는 6.8ns에 16 B/op, 1 allocs/op였고, -l을 주면 Local도 16 B/op, 1 allocs/op가 됐다. 시간은 환경마다 다르지만 할당 횟수는 원문과 같았다.
-l이나 //go:noinline은 원인을 떼어 보는 보조 실험에는 쓸 수 있지만 실제 배포 빌드와 다른 결과를 만든다. 최종 비용은 평소 빌드 조건에서 다시 잰다. B/op도 작업당 힙에 할당한 바이트의 누계라서 이미 회수된 것까지 들어 있고, 지금 살아 있는 메모리는 이 숫자로 알 수 없다.
탈출을 다 없애면 빨라지나
꼭 그렇지는 않다. 힙 할당 하나를 없애려다 복사 비용이나 공유 상태를 새로 얻을 수 있다. 진단을 읽을 줄 알게 되면 탈출을 전부 없애고 싶어지는데, 그 전에 아래 두 가지 대가를 본다.
값으로 돌려준다
// 호출마다 Config 전체를 복사한다func Load() Config힙 할당 하나를 없애는 대신 호출마다 구조체를 통째로 복사한다. 호출자가 그 값을 오래 들고 있어야 한다면 값 반환으로는 요구를 맞추지 못한다.
전역 버퍼를 다시 쓴다
// 요청끼리 나눠 쓴다var buf [64 << 10]byte할당은 줄지만 공유 상태가 생긴다. 동시 접근을 누가 막는지, 이전 요청의 내용이 남지 않는지를 책임져야 한다.
순서를 정해 두면 헷갈릴 일이 줄어든다. 올바른 수명과 API의 뜻이 먼저고, 프로파일로 느린 경로를 찾는 게 그다음이고, 그 경로의 할당을 줄일 수 있는지가 마지막이다. 고친 뒤에는 결과가 맞는지, 처리 시간, 할당량을 함께 비교한다. 짧은 벤치마크에서 본 이득이 실제 입력 분포에서도 남는지까지 확인한다.
GC는 앱과 함께 돌고 객체를 옮기지 않는다
Go GC는 삼색 마크·스윕 알고리즘으로 앱과 동시에 돌고, 객체를 옮기지 않는다. JVM처럼 세대를 나누거나 압축하지도 않는다. 단순함과 짧은 멈춤을 택하고 단편화를 감수한 설계다.
삼색은 탐색이 어디까지 왔는지를 가리키는 이름이다. 객체에 실제로 색 정보가 들어 있지는 않다. 회수 대상은 루트에서 닿을 수 없는 객체이고, 루트에는 고루틴 스택과 전역 변수의 참조가 들어간다. 오래됐다거나 이제 안 쓸 것 같다는 이유로 지우지는 않으니, 살아 있는 데이터는 설정으로 줄일 수 없다.
마킹을 앱과 같이 돌리는 대가는 쓰기 장벽이다. 마킹 중에 포인터가 바뀌어도 살아 있는 객체를 놓치지 않도록 포인터 쓰기에 일이 조금 붙는다. 짧은 전체 정지(STW)도 두 번 남아 있으니, 동시 GC라고 멈춤이나 비용이 아예 없지는 않다.
Go가 정확하게 마킹할 수 있는 건 정적 타입 덕이다. 어떤 필드가 포인터인지 컴파일할 때 알기 때문에, 힙을 훑으면서 이 8바이트가 포인터인지 정수인지 추측할 필요가 없다. 마킹 방식은 버전마다 다듬어지고 있다. Go 1.25에 실험으로 들어온 Green Tea GC는 Go 1.26부터 기본이 됐다. 작은 객체를 메모리 블록 단위로 모아 훑어 캐시 지역성을 높인 설계이고, 릴리스 노트는 GC를 많이 쓰는 프로그램에서 GC 오버헤드가 10~40% 줄 것으로 본다.1
비용에는 살아 있는 데이터의 양, 포인터 구조, 새로 할당하는 속도가 함께 영향을 준다. 같은 바이트 수라도 큰 바이트 배열과 포인터로 촘촘히 엮인 구조는 훑는 비용이 다를 수 있다. 알고리즘은 설정으로 바꿀 수 없고, 설정으로 바꿀 수 있는 건 GC가 언제 시작하느냐다. 그 값이 GOGC와 GOMEMLIMIT이다.
GOGC는 살아 있는 힙의 비율로 목표를 잡는다
GC가 언제 돌지 정하는 값이 GOGC다. 기본값은 100이고, 다음 목표 힙 크기를 이렇게 잡는다.
목표 힙 = 라이브 힙 + (라이브 힙 + GC 루트) × GOGC / 100루트가 작으면 GOGC=100은 대략 살아남은 힙의 2배다. live가 200MB면 목표는 400MB 근처, 600MB면 1.2GB 근처다. 값을 키우면 힙을 더 크게 허용하는 대신 GC 빈도를 낮출 여지가 생긴다.
이 식을 읽을 때 흔히 이렇게 오해한다.
목표 힙에 닿는 순간 GC가 시작된다.
런타임은 목표 근처에서 수집을 마칠 수 있게 그보다 먼저 마킹을 시작하고, 진행 속도도 조절한다. 최소 힙 목표나 메모리 제한 같은 조건도 함께 끼어든다. 실제 출력에서 확인해 보자.
🧪 직접 해보기
gctrace로 보면 목표가 live를 따라 두 배씩 올라간다
live 200MiB를 끝까지 붙잡고, 64KiB짜리 쓰레기를 계속 만드는 프로그램이다. GODEBUG=gctrace=1을 주면 GC가 돌 때마다 한 줄씩 찍힌다.
package main
import "fmt"
var live [][]byte // 끝까지 살아 있는 데이터var sink []byte // 바로 버려지는 데이터
func main() { for i := 0; i < 200; i++ { // live 약 200MiB live = append(live, make([]byte, 1<<20)) } for i := 0; i < 100_000; i++ { // 64KiB 쓰레기를 계속 만든다 sink = make([]byte, 64<<10) } fmt.Println("live MiB:", len(live))}$ go build -o saw main.go$ GODEBUG=gctrace=1 GOMAXPROCS=2 ./sawgc 1 @0.000s 2%: … 4->4->4 MB, 4 MB goal, 0 MB stacks, 0 MB globals, 2 Pgc 2 @0.001s 3%: … 9->9->9 MB, 9 MB goal, 0 MB stacks, 0 MB globals, 2 Pgc 3 @0.001s 4%: … 18->18->18 MB, 18 MB goal, 0 MB stacks, 0 MB globals, 2 Pgc 4 @0.001s 4%: … 36->36->36 MB, 36 MB goal, 0 MB stacks, 0 MB globals, 2 Pgc 5 @0.001s 5%: … 71->72->72 MB, 72 MB goal, 0 MB stacks, 0 MB globals, 2 Pgc 6 @0.001s 6%: … 141->151->151 MB, 144 MB goal, 0 MB stacks, 0 MB globals, 2 Pgc 7 @0.002s 5%: … 285->286->200 MB, 302 MB goal, 0 MB stacks, 0 MB globals, 2 P…gc 36 @0.099s 0%: … 398->402->203 MB, 402 MB goal, 0 MB stacks, 0 MB globals, 2 Pgc 37 @0.102s 0%: … 402->404->201 MB, 407 MB goal, 0 MB stacks, 0 MB globals, 2 Pgc 38 @0.104s 0%: … 398->399->201 MB, 403 MB goal, 0 MB stacks, 0 MB globals, 2 Plive MiB: 200398->399->201 MB는 차례로 GC를 시작할 때의 힙, 끝날 때의 힙, 끝난 뒤 살아남은 힙이고, 그 뒤가 다음 목표다.2 앞쪽 여섯 줄에서 live를 쌓는 동안 목표가 4, 9, 18, 36, 72, 144MB로 두 배씩 올라간다. 비율로 잡으니 live가 늘면 목표도 같이 늘어난다.
뒤쪽 세 줄은 live가 200MB에서 멈춘 뒤의 톱니다. 세 줄 모두 시작할 때의 힙(398, 402, 398)이 목표(402, 407, 403)보다 작다. 런타임이 목표에 닿기 전에 마킹을 시작했다.
gctrace는 출력 자체에 비용이 있어서, 추적을 켠 실행의 시간을 추적 없는 실행과 바로 비교하지 않는다. 런타임 문서도 이 형식은 바뀔 수 있다고 적어 두었으니, 운영용 파서를 이 형식에 고정하지 않는다.
힙 800MB에서 왜 OOMKilled가 나나
GC 목표가 컨테이너 limit보다 높게 잡힐 수 있어서다. live 600MB에 GOGC=100이면 목표는 1.2GB 근처인데, limit이 1GB면 GC가 목표에 맞춰 돌기 전에 메모리가 limit을 먼저 넘는다. 게다가 커널이 세는 건 힙보다 큰 RSS다. 컨테이너의 메모리 limit은 이 프로세스가 1GB를 넘으면 죽인다는 절대값이고, 커널은 GC가 언제 돌 예정인지 모른다.
RSS에는 힙 말고도 고루틴 스택, 런타임 메타데이터, 아직 OS에 돌려주지 않은 페이지가 들어간다. 그래서 힙 수치만 보면 여유가 있는데도 죽는다. 그림 2번 막대의 비율은 예시이고, 실제 비율은 서비스마다 다르다.
GC가 끝나 힙이 줄어도 운영체제가 보는 메모리가 바로 같은 만큼 줄지는 않는다. 런타임이 공간을 다시 쓰려고 들고 있을 수 있고, OS로 돌려주는 일은 따로 진행된다. 설정을 바꾸고 RSS가 곧바로 떨어지기를 기대하면 관측을 잘못 읽기 쉽다.
이 글에서 컨테이너 OOMKilled를 재현하지는 않았다. 아래 실험은 모두 컨테이너 밖에서 런타임이 내놓는 수치로 확인했다.
GOMEMLIMIT으로 절대값 상한을 하나 더 둔다
GOMEMLIMIT은 Go 1.19에 들어온 소프트 메모리 상한이다. 이 값에 가까워지면 GOGC 목표가 아직 멀어도 GC를 앞당긴다. 비율 규칙 옆에 절대값 규칙을 하나 더 두어 커널과 같은 단위로 맞추는 셈이다.
| 설정 | 기준 | 상한 |
|---|---|---|
GOGC |
live에 대한 비율. 기본 100 | 없다 |
GOMEMLIMIT |
런타임이 쓰는 메모리의 바이트 수 | 소프트. 넘을 수 있다 |
런타임이 쓰는 메모리는 runtime.MemStats의 Sys에서 HeapReleased를 뺀 값이다. 힙뿐 아니라 런타임이 매핑해 관리하는 메모리가 들어가고, Go 바이너리 자체와 cgo가 직접 할당한 메모리, 운영체제가 프로세스 대신 쥔 메모리는 빠진다. 그래서 컨테이너 limit을 그대로 넣으면 모자란다. 제한에 가까워지면 런타임은 GC를 앞당기는 것과 함께 OS에 메모리를 더 적극적으로 돌려준다.
🧪 직접 해보기
300MiB를 걸자 GC 목표가 287MB로 내려갔다
앞의 saw를 그대로 두고 GOMEMLIMIT만 줬다. live는 똑같이 200MiB다.
$ GOMEMLIMIT=300MiB GODEBUG=gctrace=1 GOMAXPROCS=2 ./saw 2>&1 | tail -4gc 80 @0.078s 1%: … 275->276->201 MB, 287 MB goal, 0 MB stacks, 0 MB globals, 2 Pgc 81 @0.079s 1%: … 275->275->200 MB, 287 MB goal, 0 MB stacks, 0 MB globals, 2 Pgc 82 @0.080s 1%: … 275->285->210 MB, 287 MB goal, 0 MB stacks, 0 MB globals, 2 Plive MiB: 200목표가 400MB 언저리에서 287MB로 내려왔다. 300MiB보다 조금 낮은 건 제한이 힙에 더해 런타임이 쓰는 다른 메모리까지 세기 때문이다. 그 대신 같은 일을 하는 동안 GC는 38번에서 82번으로 늘었다(여러 번 돌리면 81~82번). 톱니가 낮아진 만큼 더 자주 꺾인다.
🧪 직접 해보기
할당 누계는 그대로 두고 설정만 다섯 번 바꿨다
기준 데이터 8MiB를 계속 들고 있으면서 64KiB 배열을 4,096번 만든다. 최근 16개만 전역 배열에 남기니 나머지는 참조가 끊겨 회수 대상이 된다. 모두 256MiB를 할당하지만 한꺼번에 붙잡고 있지는 않는다.
package main
import ( "fmt" "os" "runtime" "time")
var recent [16][]byte
func main() { fmt.Println("Go:", runtime.Version(), "GOGC:", os.Getenv("GOGC"), "GOMEMLIMIT:", os.Getenv("GOMEMLIMIT")) base := make([]byte, 8<<20) for i := range base { base[i] = 1 } runtime.GC() // 기준점 정렬. 아래 GC 증가량에는 포함하지 않는다. var before, after runtime.MemStats runtime.ReadMemStats(&before)
start := time.Now() var checksum uint64 var peak uint64 for i := 0; i < 4096; i++ { data := make([]byte, 64<<10) data[0] = byte(i) data[len(data)-1] = byte(i >> 8) recent[i%len(recent)] = data checksum += uint64(data[0]) + uint64(data[len(data)-1]) if i%64 == 0 { var m runtime.MemStats runtime.ReadMemStats(&m) if m.HeapAlloc > peak { peak = m.HeapAlloc } } } elapsed := time.Since(start) runtime.ReadMemStats(&after) runtime.KeepAlive(base)
const mib = 1 << 20 fmt.Printf("할당 누계 증가: %.1f MiB\n", float64(after.TotalAlloc-before.TotalAlloc)/mib) fmt.Printf("GC 횟수 증가: %d\n", after.NumGC-before.NumGC) fmt.Printf("표본 힙 최고점: %.1f MiB\n", float64(peak)/mib) fmt.Printf("종료 시 NextGC: %.1f MiB\n", float64(after.NextGC)/mib) fmt.Printf("종료 시 Sys-HeapReleased: %.1f MiB\n", float64(after.Sys-after.HeapReleased)/mib) fmt.Println("체크섬:", checksum, "경과:", elapsed)}시작 직후의 runtime.GC()는 기준점을 맞추려고 부른 것이고, 아래 GC 횟수에는 넣지 않았다. 반복문 안에서 강제 수집을 부르는 기법으로 가져가지 않는다. runtime.KeepAlive(base)는 마지막 측정까지 기준 데이터가 살아 있어야 한다는 뜻을 적어 둔 것이고, 서비스 코드의 모든 변수에 붙이는 코드는 아니다.
go build -o gc-demo main.goGOGC=50 GOMEMLIMIT=off ./gc-demoGOGC=200 GOMEMLIMIT=off ./gc-demoGOGC=200 GOMEMLIMIT=24MiB ./gc-demoGOGC=off GOMEMLIMIT=24MiB ./gc-demoGOGC=off GOMEMLIMIT=off ./gc-demo| 설정 | GC 횟수 | 힙 최고점 |
|---|---|---|
| GOGC=50 · 제한 없음 | 58 | 14.6 MiB |
| GOGC=200 · 제한 없음 | 13 | 32.3 MiB |
| GOGC=200 · 24MiB | 25 | 19.5 MiB |
| GOGC=off · 24MiB | 30 | 20.4 MiB |
| GOGC=off · 제한 없음 | 0 | 260.1 MiB |
Go 1.24.7, linux/amd64 2코어에서 잰 값이다. 다섯 번 모두 할당 누계 증가는 256.0MiB, 체크섬은 552960으로 같았다. 힙 최고점은 64번에 한 번 읽은 표본이라 진짜 최고치보다 낮을 수 있고, GC 횟수는 같은 명령을 다시 돌려도 몇 번씩 흔들린다.
GOGC를 50에서 200으로 올리자 GC가 58번에서 13번으로 줄고 최고점은 14.6MiB에서 32.3MiB로 올랐다. 방향은 분명하지만, GOGC를 4배로 하면 GC가 4분의 1이 된다는 식의 배수로 굳히지는 않는다.
같은 GOGC=200에 24MiB 제한을 더하자 최고점이 32.3MiB에서 19.5MiB로 눌리고 GC는 13번에서 25번으로 늘었다. 메모리를 묶어 두는 대가로 CPU를 더 쓴 것이다.
네 번째 줄이 가장 중요하다. GOGC=off인데도 GC가 30번 돌았다. 비율 규칙을 꺼도 유한한 메모리 제한이 있으면 그 제한을 지키려는 수집이 돈다. 마지막 줄처럼 둘 다 끄면 GC가 한 번도 돌지 않고 힙이 260MiB까지 자란다. 두 설정은 함께 목표를 정한다.
할당 누계는 다섯 번 모두 256.0MiB였다. 설정을 어떻게 바꿔도 애플리케이션이 만드는 쓰레기의 양은 그대로다.
이 맥(darwin/arm64, Go 1.24.7)에서 설정마다 세 번씩 다시 돌린 GC 횟수의 가운데 값은 아래와 같다. 할당 누계와 체크섬은 원문과 같았다.
GOGC=50이 51번으로 원문보다 조금 적었을 뿐, 제한을 걸면 GC가 늘고 둘 다 끄면 0번이라는 방향은 같았다. Go 1.27.0으로 돌리면 24MiB 제한을 건 두 설정이 34~39번으로 조금 더 돌았다.
숫자를 읽을 때는 무엇을 센 값인지부터 본다.
| 관측값 | 알 수 있는 것 | 알 수 없는 것 |
|---|---|---|
HeapAlloc |
할당돼 있는 힙 객체의 바이트 수 | 커널이 보는 RSS |
NextGC |
런타임이 잡은 다음 목표 | 프로세스 전체의 한도 |
Sys - HeapReleased |
메모리 제한이 세는 양 | cgo 할당, 런타임 밖 매핑 |
| 프로세스 RSS | OS와 컨테이너가 보는 양 | 런타임 안에서 어디에 썼는지 |
HeapAlloc에는 아직 회수되지 않은 죽은 객체도 들어 있고, NextGC는 목표일 뿐 상한이 아니다. 그래서 실제 설정은 이렇게 잡는다.
// env: GOMEMLIMIT=900MiB ← limit의 90% 안팎. RSS에는 런타임이 모르는 메모리도 있다// env: GOGC=100 ← 비율 규칙은 그대로 둔다. live가 작을 땐 이쪽이 먼저 걸린다
// 코드로 걸 수도 있다debug.SetMemoryLimit(900 << 20)
// GOGC=off + GOMEMLIMIT은 "상한까지는 GC를 돌리지 않겠다"는 뜻이다.// 메모리를 꽉 채워 써도 되는 배치 작업에 맞고, 지연에 민감한 서비스에는 맞지 않는다.GC 가이드는 컨테이너처럼 메모리가 정해진 환경이라면 런타임이 모르는 메모리를 위해 5~10% 여유를 더 두라고 권한다. cgo를 많이 쓰거나 큰 파일 매핑이 있으면 여유를 더 둔다.
소프트 상한이라는 점도 기억해 둔다. 살아 있는 데이터만으로 상한을 넘으면 아무리 수집해도 내려가지 않는다. 런타임은 GC가 CPU를 다 가져가지 않도록 GC의 CPU 사용을 대략 50%로 묶어 두기 때문에, 그럴 때는 제한을 넘긴 채 계속 돌다가 결국 OOMKilled가 된다. GOMEMLIMIT이 막아 주는 건 쓰레기가 쌓여서 넘치는 경우이고, 살아 있는 데이터가 많아서 넘치는 경우는 막지 못한다. 가이드도 이미 환경 한도에 가까운 프로그램이라면 메모리 제한으로 버티기보다 limit을 올리거나 GOGC를 낮추라고 한다.
정리
- 스택이냐 힙이냐는 컴파일러가 정한다. 같은
&Pair{}도 인라이닝에 따라, 같은make도 툴체인에 따라 판정이 갈렸다. 문법으로 세지 말고-gcflags=-m과-benchmem으로 잰다. - GC는 앱과 동시에 도는 삼색 마크·스윕이고 객체를 옮기지 않는다. 짧은 멈춤을 얻는 대신 쓰기 장벽을 낸다.
GOGC는 비율이라 live가 늘면 목표도 늘어난다. 마킹은 목표에 닿기 전에 시작한다.- 컨테이너는 RSS의 절대값으로 죽인다. limit 1GB에서 힙 800MB로도 죽을 수 있는 이유다.
GOMEMLIMIT을 limit보다 낮게 걸면 목표가 그 아래로 내려오고, GC 횟수가 그만큼 늘어난다.GOGC=off여도 제한이 있으면 GC는 돈다.
GC를 공부할 때는 삼색 마킹과 쓰기 장벽이 핵심이라고 생각했다. 그런데 서비스가 죽은 자리에서 문제였던 건 알고리즘이 아니라 GC가 언제 시작하느냐였고, 그 기준과 커널의 기준은 단위부터 달랐다.
앞 글의 GOMAXPROCS와 모양이 같다는 것도 정리하면서 보였다. 런타임은 호스트의 코어 수와 힙의 비율로 정하고, 컨테이너는 CPU quota와 RSS로 제한한다. 둘을 맞추는 값이 CPU 쪽에 GOMAXPROCS, 메모리 쪽에 GOMEMLIMIT 하나씩 있다.
설정을 다섯 번 바꾸는 동안 할당 누계 256MiB는 그대로였다. 이 숫자를 줄이는 건 코드 쪽 일이고, 다음에는 버퍼를 다시 쓰는 sync.Pool을 본다.
참고 자료
- A Guide to the Go Garbage Collector · GOGC: 목표 힙 계산식
- A Guide to the Go Garbage Collector · Memory limit: 소프트 상한, GC CPU 약 50% 상한, 5~10% 여유 권고
- runtime/debug.SetMemoryLimit: 제한에 들어가는 메모리와 빠지는 메모리,
GOGC=off에서도 지킨다는 설명 - runtime · Environment Variables:
GOGC,GOMEMLIMIT,gctrace출력 형식 - runtime.MemStats:
HeapAlloc,NextGC,Sys,HeapReleased - Go FAQ · Stack or heap: 저장 위치는 컴파일러가 정한다
- Go 1.19 Release Notes · Runtime: 소프트 메모리 제한 도입
- Go 1.25 Release Notes · Faster slices: 슬라이스 저장 공간을 스택에 두는 경우 확대
- Go 1.26 Release Notes · New garbage collector: Green Tea GC 기본 적용
- Proposal: Eliminate STW stack re-scanning: Go 1.8 하이브리드 쓰기 장벽
- Linux cgroup v2 · Memory Interface Files:
memory.max와 OOM