쿠버네티스 OOMKilled 해결: Go pprof 메모리 누수 분석 및 최적화

운영 중인 파드(Pod)가 별다른 로그 없이 Restart 횟수만 늘어가고, kubectl describe pod 명령어로 확인했을 때 Exit Code 137 (OOMKilled)가 찍혀 있다면, 이는 애플리케이션이 할당된 메모리 한계(Limit)를 초과했음을 의미합니다. 단순히 메모리 리밋(Limit)을 늘리는 것은 근본적인 해결책이 아닙니다. 이 글에서는 Go 애플리케이션에서 발생하는 힙(Heap) 메모리 누수를 pprof로 정밀 타격하고, GOMEMLIMIT을 통해 컨테이너 환경을 최적화하는 과정을 다룹니다.

1. 현상 분석: 메모리 그래프의 계단식 상승

최근 트래픽이 급증한 마이크로서비스에서 파드가 주기적으로 재시작되는 현상을 경험했습니다. Prometheus와 Grafana로 메모리 사용량(Working Set)을 시각화했을 때, GC(Garbage Collection) 이후에도 메모리가 회수되지 않고 우상향하는 '메모리 릭(Memory Leak)' 패턴이 확인되었습니다.

Critical Warning: Linux 커널의 OOM Killer가 프로세스를 강제 종료시킬 때는 애플리케이션 로그(Panic 등)를 남기지 못하는 경우가 많습니다. 반드시 kubectl get pod -o yamllastState 필드를 확인해야 합니다.

2. Go pprof를 이용한 힙 프로파일링

Go 언어는 런타임 레벨에서 강력한 프로파일링 도구인 pprof를 제공합니다. 이를 통해 현재 메모리를 점유하고 있는 객체와 고루틴(Goroutine)의 상태를 스냅샷으로 뜰 수 있습니다.

프로덕션 환경에서 안전하게 프로파일링 데이터를 수집하기 위해, 별도의 admin 포트를 열거나 net/http/pprof를 등록합니다.

// main.go 설정 예시
import (
    "net/http"
    _ "net/http/pprof" // 사이드 이펙트로 핸들러 등록
    "log"
)

func main() {
    // 비즈니스 로직과 분리된 별도 포트로 pprof 실행 권장
    go func() {
        log.Println(http.ListenAndServe("localhost:6060", nil))
    }()
    
    // ... 기존 애플리케이션 로직 ...
}

코드가 배포되었다면, 로컬 머신에서 kubectl port-forward를 통해 파드에 접근한 뒤 힙 프로파일을 수집합니다.

# 1. 포트 포워딩
kubectl port-forward pod/my-app-pod-12345 6060:6060

# 2. 힙 프로파일 다운로드 및 분석 (inuse_space: 현재 사용 중인 메모리 기준)
go tool pprof -http=:8080 http://localhost:6060/debug/pprof/heap

3. 누수 지점 식별과 수정

pprof 웹 UI의 Flame GraphTop 뷰를 보면, 특정 함수에서 비정상적으로 많은 메모리를 점유하는 것을 볼 수 있습니다. 제 경우, 외부 API 응답을 버퍼링하는 과정에서 bytes.Buffer가 재사용되지 않고 지속적으로 새로 할당되는 문제가 발견되었습니다.

Tip: Go는 메모리 할당 해제가 자동으로 이루어지지만, 전역 변수나 맵(Map)에 포인터가 계속 쌓이거나, 닫히지 않은 고루틴(Goroutine Leak)이 채널을 붙들고 있으면 GC 대상에서 제외됩니다.

수정 코드 예시 (Sync.Pool 활용)

빈번한 할당과 해제가 발생하는 객체는 sync.Pool을 사용하여 메모리 파편화를 줄이고 재사용성을 높여야 합니다.

// 최적화 전: 매 요청마다 대형 버퍼 할당
func processData(data []byte) {
    buf := new(bytes.Buffer) // O(N) Allocation
    buf.Write(data)
    // ...
}

// 최적화 후: sync.Pool 사용
var bufPool = sync.Pool{
    New: func() interface{} {
        return new(bytes.Buffer)
    },
}

func processDataOptimized(data []byte) {
    buf := bufPool.Get().(*bytes.Buffer)
    defer func() {
        buf.Reset() // 중요: 반환 전 초기화
        bufPool.Put(buf)
    }()
    
    buf.Write(data)
    // ...
}

4. 쿠버네티스 환경을 위한 GOMEMLIMIT 설정

Go 1.19 버전부터 도입된 GOMEMLIMIT은 컨테이너 환경에서 매우 중요합니다. 이전에는 Go 런타임이 컨테이너의 메모리 제한을 인지하지 못해, OS가 프로세스를 죽이기 직전까지 힙을 늘리는 경향이 있었습니다. GOMEMLIMIT을 파드의 memory.limit보다 약간 낮게 설정하면, 해당 한계에 도달하기 전 GC를 적극적으로 수행하여 OOMKilled를 방지할 수 있습니다.

설정 항목 기존 설정 (Legacy) 최적화 설정 (Best Practice)
K8s Memory Limit 1Gi (Soft Limit 없음) 1Gi
GOMEMLIMIT 설정 안함 (OS 메모리 전체 인식) 900MiB (Limit의 90%)
OOM 발생 빈도 주 3~4회 발생 발생 0회 (Zero OOM)
Result: 코드 수정과 GOMEMLIMIT 적용 후, 파드의 메모리 사용량은 톱니바퀴 모양(Sawtooth pattern)의 건강한 GC 사이클을 회복했습니다.

결론

쿠버네티스 환경에서의 OOMKilled는 단순히 리소스를 늘려주는 것으로 해결해서는 안 됩니다. 이는 비용 비효율을 초래하고 근본적인 아키텍처 결함을 숨길 뿐입니다. pprof를 통한 주기적인 프로파일링을 CI/CD 파이프라인에 통합하고, GOMEMLIMIT을 적극 활용하여 컨테이너 네이티브한 Go 애플리케이션을 구축하시기 바랍니다.

Post a Comment