Published on

Go gRPC 메모리 누수? pprof로 잡는 7단계

Authors

운영 중인 Go gRPC 서버가 시간이 지날수록 메모리를 더 먹고, 결국 OOMKilled 혹은 노드 메모리 압박으로 장애가 나는 경우가 있습니다. 이때 가장 먼저 해야 할 일은 “진짜 메모리 누수(leak)인가?”를 확인하는 것입니다. Go는 GC가 있기 때문에, 단순히 RSS가 증가한다고 해서 곧바로 누수라고 단정하기 어렵습니다. gRPC는 스트리밍, 인터셉터, 컨텍스트, keepalive, 큰 메시지 버퍼 등 다양한 경로로 메모리와 고루틴을 잡아둘 수 있어, 증상을 체계적으로 분해해야 합니다.

이 글은 Go gRPC 환경에서 pprof를 중심으로 “재현 → 계측 → 비교(diff) → 원인군 분류 → 수정 → 회귀 방지”까지 7단계로 정리한 실전 플레이북입니다.

참고로 컨테이너 환경에서 재시작이 반복된다면 메모리 이슈 외에도 헬스체크 설정이 원인일 수 있으니, 필요하면 EKS Pod 1분마다 재시작? livenessProbe 실패 해결도 함께 확인해 두면 좋습니다.

1단계: “누수” 판정부터 한다 (RSS vs Heap)

먼저 관찰 지표를 나눕니다.

  • RSS(프로세스 실제 점유 메모리): OS 관점. Go 런타임이 OS에서 잡아둔 메모리, 페이지 캐시, allocator 동작 영향이 섞임
  • Heap(Go 힙 사용량): GC가 관리하는 영역. pprof의 핵심 대상
  • HeapInuse, HeapIdle, HeapReleased: Go가 OS에서 확보한 힙의 상태
  • 고루틴 수: 스트리밍/타임아웃/컨텍스트 누락으로 증가할 수 있음

운영에서 자주 보는 패턴은 다음과 같습니다.

  • Heap은 안정적인데 RSS만 증가: Go가 OS에 메모리를 늦게 반환하거나, Cgo/네이티브/메모리 맵핑/버퍼 풀 영향
  • Heap도 계속 증가: 실제로 객체가 살아남고 있음(누수 또는 의도치 않은 캐시)

컨테이너에서 빠르게 확인하려면 다음을 함께 봅니다.

  • kubectl top pod 혹은 노드 메트릭으로 RSS 추세
  • 애플리케이션 메트릭으로 go_memstats_heap_inuse_bytes, go_memstats_heap_alloc_bytes, go_goroutines

Prometheus를 쓰면 Go 런타임 메트릭은 거의 기본으로 노출되므로, “Heap이 상승하는지”부터 확인하고 다음 단계로 넘어갑니다.

2단계: pprof 엔드포인트 안전하게 열기 (운영 기본기)

Go는 표준 라이브러리로 net/http/pprof를 제공합니다. 중요한 포인트는 gRPC 서버와 별도의 HTTP 서버로 분리하고, 내부망 또는 인증된 경로로만 노출하는 것입니다.

다음은 gRPC 서버와 pprof 서버를 함께 띄우는 예시입니다.

package main

import (
	"log"
	"net"
	"net/http"
	_ "net/http/pprof"

	"google.golang.org/grpc"
)

func main() {
	// gRPC server
	lis, err := net.Listen("tcp", ":50051")
	if err != nil {
		log.Fatal(err)
	}
	grpcServer := grpc.NewServer()
	// pb.RegisterYourServiceServer(grpcServer, ...)

	go func() {
		log.Println("gRPC listening on :50051")
		if err := grpcServer.Serve(lis); err != nil {
			log.Fatal(err)
		}
	}()

	// pprof server (internal only 권장)
	go func() {
		log.Println("pprof listening on 127.0.0.1:6060")
		if err := http.ListenAndServe("127.0.0.1:6060", nil); err != nil {
			log.Fatal(err)
		}
	}()

	select {}
}

Kubernetes라면 kubectl port-forward로만 접근하게 만들면 노출 위험을 크게 줄일 수 있습니다.

kubectl -n yourns port-forward pod/your-pod 6060:6060

3단계: 힙 스냅샷을 “두 번” 떠서 diff 한다

단일 스냅샷만 보면 “현재 많이 쓰는 것”만 보입니다. 누수는 “시간에 따라 증가하는 것”이므로, 같은 워크로드에서 시점 A와 시점 B를 비교해야 합니다.

  1. 시점 A 스냅샷
go tool pprof -seconds 10 -alloc_space http://127.0.0.1:6060/debug/pprof/heap

위 커맨드는 예시고, 보통은 파일로 저장해 비교합니다.

curl -sS http://127.0.0.1:6060/debug/pprof/heap > heap_a.pb.gz
  1. 트래픽을 일정 시간 흘린 뒤 시점 B
curl -sS http://127.0.0.1:6060/debug/pprof/heap > heap_b.pb.gz
  1. diff 분석
go tool pprof -diff_base heap_a.pb.gz heap_b.pb.gz

pprof 콘솔에서 자주 쓰는 명령은 다음입니다.

  • top: 증가량 상위
  • top -cum: 누적 경로 기준
  • list 함수명: 소스 라인 단위
  • web: 그래프(환경에 따라 graphviz 필요)

여기서 핵심은 inuse 기준alloc 기준을 구분하는 것입니다.

  • inuse_space: 현재 살아있는(해제되지 않은) 메모리 → 누수 추적에 유리
  • alloc_space: 누적 할당량 → 핫패스 최적화/GC 압박 분석에 유리

4단계: 고루틴 누수부터 확인한다 (gRPC에서 특히 흔함)

메모리 증가의 원인이 사실은 “고루틴 증가”인 경우가 많습니다. gRPC 스트리밍에서 Recv 루프가 종료되지 않거나, 컨텍스트 취소를 무시하거나, 채널 send가 막혀서 goroutine이 영원히 대기하는 패턴이 대표적입니다.

고루틴 프로파일을 떠서 증가 여부를 확인합니다.

curl -sS http://127.0.0.1:6060/debug/pprof/goroutine?debug=2 > goroutines.txt

debug=2는 스택을 텍스트로 자세히 보여줍니다. 여기서 다음을 찾습니다.

  • 동일한 스택이 수백/수천 개 반복
  • (*ServerStream).RecvMsg 혹은 transport 관련 함수에서 대기
  • select {} 혹은 chan receive/send에서 영구 블록

예시로, 서버 스트리밍에서 흔히 나오는 실수는 다음과 같습니다.

func (s *Server) Stream(req *pb.Req, stream pb.Svc_StreamServer) error {
	for {
		msg, err := stream.Recv()
		if err != nil {
			return err
		}
		_ = msg
		// ctx.Done() 확인 없이 내부 작업이 블록되면, 클라이언트가 끊겨도 고루틴이 남을 수 있음
	}
}

개선 포인트는 다음 중 하나 이상입니다.

  • ctx := stream.Context()를 가져와 selectctx.Done()을 항상 감시
  • 내부 워커/채널 구조에 타임아웃과 종료 신호 추가
  • Recv/Send 루프에서 에러 처리 시 io.EOF 등 정상 종료 케이스 분리

고루틴이 계속 쌓이면 스택 메모리와 각종 참조가 함께 살아남아 힙 증가로 이어질 수 있습니다.

5단계: gRPC 메시지/버퍼/압축 설정이 힙을 키우는지 확인

gRPC는 큰 메시지, 압축, 스트리밍 버퍼링에 따라 할당 패턴이 크게 달라집니다. 다음 항목들을 점검합니다.

(1) 과도한 메시지 크기

서버/클라이언트에 MaxRecvMsgSize, MaxSendMsgSize를 무제한에 가깝게 두면, 특정 요청 하나가 힙을 크게 흔들 수 있습니다.

grpc.NewServer(
	grpc.MaxRecvMsgSize(4*1024*1024),
	grpc.MaxSendMsgSize(4*1024*1024),
)

(2) 압축 사용 시 일시적 버퍼 증가

압축은 CPU뿐 아니라 버퍼 할당을 유발합니다. alloc_space가 폭증하고 GC가 바빠진다면 압축 정책을 재검토합니다.

(3) 스트리밍에서 무제한 큐

서버가 생산자, 클라이언트가 소비자인데 네트워크가 느리면 Send가 막힐 수 있습니다. 이때 내부적으로 메시지를 큐잉하는 구조가 있다면 메모리 증가로 직결됩니다.

  • 해결: bounded channel, backpressure, drop 정책, 혹은 서버 측 생산 속도 제한

6단계: 인터셉터/컨텍스트/로깅이 참조를 붙잡고 있지 않은지 본다

실제 누수의 다수는 “큰 객체를 전역/캐시에 넣었거나, 컨텍스트에 넣어두고 해제되지 않는” 형태입니다. gRPC에서는 인터셉터가 공통 경로라 특히 위험합니다.

(1) 컨텍스트에 큰 값을 저장

예를 들어 요청 바디/디코딩 결과/대형 맵을 context.WithValue로 넣고, 그 컨텍스트가 장수 고루틴에 전달되면 메모리가 오래 살아남습니다.

type ctxKey string

func unaryInterceptor(
	ctx context.Context,
	req any,
	info *grpc.UnaryServerInfo,
	handler grpc.UnaryHandler,
) (any, error) {
	big := make([]byte, 10*1024*1024)
	ctx = context.WithValue(ctx, ctxKey("big"), big)
	return handler(ctx, req)
}

원칙은 단순합니다.

  • 컨텍스트에는 작은 식별자(예: request id)만 넣기
  • 큰 데이터는 함수 스코프에서 처리하고, 장수 객체에 매달지 않기

(2) 로깅/트레이싱에서 request 전체를 구조체로 보관

비동기 로거가 큐에 요청 객체를 그대로 넣으면, 요청이 커질수록 큐가 힙을 잡아먹습니다. 로깅에는 필요한 필드만 추출해 넣습니다.

(3) 메트릭 라벨 폭발

Prometheus 라벨에 사용자 id 같은 고카디널리티 값을 넣으면, 메모리뿐 아니라 시스템 전체가 느려집니다. 라벨 설계를 다시 하세요.

7단계: 수정 후 재현 테스트와 회귀 방지(자동화)

원인을 수정했다면, “메모리가 다시 안정화되는지”를 재현 시나리오로 확인해야 합니다.

(1) 로컬/스테이징에서 부하 테스트 + pprof 자동 수집

예를 들어 heyghz 같은 도구로 gRPC 부하를 걸고, 일정 주기로 힙 스냅샷을 저장합니다.

for i in 1 2 3 4 5; do
  curl -sS http://127.0.0.1:6060/debug/pprof/heap > "heap_${i}.pb.gz"
  sleep 60
done

그 다음 1번과 5번을 diff 해서 “증가량 상위가 사라졌는지”를 확인합니다.

go tool pprof -diff_base heap_1.pb.gz heap_5.pb.gz

(2) 누수 패턴별 체크리스트를 PR 템플릿에 넣기

  • 스트리밍 루프에 ctx.Done() 반영했는가
  • 인터셉터에서 큰 객체를 저장하지 않는가
  • 채널/큐가 bounded 인가
  • 타임아웃/데드라인이 전 구간에 설정돼 있는가

(3) 운영에서 OOM으로 재시작되기 전에 알림

Kubernetes라면 메모리 사용량과 재시작 횟수를 함께 알림으로 묶는 게 좋습니다. 재시작이 잦아지면 원인이 메모리 누수뿐 아니라 probe, 노드 리소스, CNI 이슈도 섞일 수 있으니, 상황에 따라 Terraform apply 후 EKS 노드 NotReady - CNI·IRSA·보안그룹 점검 같은 인프라 관점 체크도 병행하세요.

자주 나오는 원인 Top 5 (pprof로 확인되는 형태)

정리 차원에서, Go gRPC에서 실제로 자주 마주치는 “pprof 시그니처”를 묶어보면 다음과 같습니다.

  1. 고루틴 증가: goroutine?debug=2에서 동일 스택 반복
  2. 큰 슬라이스/바이트 버퍼가 inuse 상위: 메시지 크기/버퍼링/로깅 큐 의심
  3. context.WithValue로 큰 객체가 살아남음: 인터셉터/미들웨어 점검
  4. 라벨 폭발로 메트릭 저장 구조가 커짐: 고카디널리티 제거
  5. alloc_space만 크고 inuse_space는 작음: 핫패스 최적화, 풀링(sync.Pool) 검토

마무리: pprof는 “현재”가 아니라 “증가”를 본다

메모리 문제를 빠르게 해결하는 핵심은 단일 스냅샷이 아니라 시간 차를 둔 diff로 “증가하는 할당/참조”를 찾는 것입니다. 그 다음 고루틴, 스트리밍 종료, 컨텍스트 참조, 큐/버퍼 상한 같은 gRPC 특유의 지점을 순서대로 제거하면, 대부분은 짧은 시간 안에 원인을 좁힐 수 있습니다.

추가로, 트래픽 급증 상황에서 리소스 고갈을 진단하는 접근은 언어가 달라도 비슷합니다. 병목을 지표로 분해하는 감각이 필요하다면 Spring Boot 대용량 트래픽 - HikariCP 풀 고갈 진단처럼 “고갈을 관측으로 증명”하는 글도 함께 읽어보면 도움이 됩니다.