Published on

Go 고루틴 leak 추적 - pprof·trace로 10분 진단

Authors

서버가 멀쩡히 돌아가는 것처럼 보이는데 goroutine 개수만 계속 증가하거나, 메모리가 서서히 오르고 GC가 잦아지면 거의 항상 “고루틴이 어딘가에서 끝나지 못하고 대기 상태로 쌓이는” 누수를 의심해야 합니다. Go의 고루틴 leak은 크래시를 잘 내지 않아서 더 위험합니다. 트래픽이 늘거나 특정 에러 경로가 반복될 때만 증상이 나타나며, 결국에는 파일 디스크립터 고갈, 큐 적체, OOM, tail latency 폭증으로 이어집니다.

이 글은 pprof + trace 조합으로 “누수가 시작되는 스택”을 빠르게 찾는 루틴을 제공합니다. 목표는 완벽한 포렌식이 아니라, 10분 안에 원인 후보를 1~2곳으로 줄이는 것입니다.

0. 고루틴 leak의 전형적인 패턴

누수는 보통 “종료 조건이 없는 대기”로 나타납니다. 아래 패턴이 특히 흔합니다.

  • chan receive 또는 chan send에서 영원히 블록
  • select에서 ctx.Done()을 안 받거나, 종료 경로에서 cancel()을 호출하지 않음
  • time.TickerStop() 하지 않음
  • 워커 풀에서 결과 채널을 닫지 않거나 소비자가 없어 send가 막힘
  • http.Response.BodyClose() 하지 않아 커넥션/리더가 대기
  • 무한 재시도 루프에서 sleep만 있고 종료 신호가 없음

고루틴 leak 자체는 “고루틴 수 증가”로 보이지만, 실제 원인은 취소 전파 실패채널 소유권(누가 닫는가) 불명확인 경우가 많습니다. 비슷한 맥락에서, 다른 환경에서의 빠른 원인 추적 루틴은 systemd 서비스 재시작 무한루프 9단계 진단법 같은 글의 접근(증상 -> 관측치 -> 원인 후보 좁히기)과도 닮아 있습니다. (본문에서는 ->으로 대체해 표현합니다.)

1. 2분 준비: pprof 엔드포인트 켜기

가장 빠른 방법은 net/http/pprof를 붙이고, 운영/스테이징에서 잠깐만 노출하는 것입니다.

package main

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

func main() {
	go func() {
		// 내부망에서만 접근되도록 방화벽/인증을 꼭 적용하세요.
		log.Println(http.ListenAndServe("127.0.0.1:6060", nil))
	}()

	// ... your server
	select {}
}

컨테이너라면 포트 포워딩으로 접근합니다.

kubectl port-forward deploy/myapp 6060:6060

이제 아래 URL에서 고루틴 덤프를 바로 볼 수 있습니다.

  • http://127.0.0.1:6060/debug/pprof/goroutine?debug=2

debug=2는 스택을 사람이 읽기 좋은 텍스트로 보여줍니다.

2. 3분 진단: goroutine 프로파일로 “어디서 쌓이나” 보기

2.1 현재 스냅샷 확인

우선 스냅샷을 눈으로 봅니다.

curl -s "http://127.0.0.1:6060/debug/pprof/goroutine?debug=2" | head -n 60

여기서 핵심은 상태반복되는 스택입니다. 예를 들어 다음이 수십, 수백 번 반복되면 강력한 후보입니다.

  • chan receive
  • select
  • IO wait
  • sync.(*Mutex).Lock
  • net/http 내부의 read loop

2.2 pprof로 “같은 스택이 몇 개인지” 집계

텍스트 덤프만으로는 “몇 개가 같은 스택인지” 집계가 어렵습니다. go tool pprof를 씁니다.

go tool pprof -top "http://127.0.0.1:6060/debug/pprof/goroutine"

출력에서 상위 항목이 곧 “가장 많이 쌓인 스택”입니다. 이어서 소스 위치를 보고 싶다면:

go tool pprof -list "my/module/path" "http://127.0.0.1:6060/debug/pprof/goroutine"

또는 인터랙티브 모드에서:

go tool pprof "http://127.0.0.1:6060/debug/pprof/goroutine"
# (pprof) top
# (pprof) web
# (pprof) list handlerName

web는 그래프를 띄우는데, 서버 환경에 따라 불편할 수 있어 toplist 만으로도 충분합니다.

2.3 “증가하는지” 확인: 30초 간격으로 2번 비교

고루틴 leak은 스냅샷 1장만으로는 애매할 때가 많습니다. 30초 간격으로 2번 채집해 차이를 봅니다.

curl -s "http://127.0.0.1:6060/debug/pprof/goroutine" -o g1.pb.gz
sleep 30
curl -s "http://127.0.0.1:6060/debug/pprof/goroutine" -o g2.pb.gz

go tool pprof -top g1.pb.gz

go tool pprof -top g2.pb.gz

g2에서 특정 함수 스택이 눈에 띄게 증가하면, 그 스택이 “누수의 축적 지점”입니다.

3. 3분 진단: trace로 “왜 끝나지 않는지” 보기

pprof가 “어디서 쌓이는지”를 알려준다면, runtime/trace는 “언제 만들어졌고 어떤 이벤트로 블록됐는지”를 더 잘 보여줍니다. 특히 채널 송수신, 네트워크 블록, 스케줄링 지연을 타임라인으로 볼 수 있어, 원인 후보를 확 좁힙니다.

3.1 trace를 코드로 노출하기

간단히 HTTP로 트레이스를 뜨는 엔드포인트를 하나 둡니다.

package debug

import (
	"net/http"
	"runtime/trace"
	"time"
)

func RegisterTrace(mux *http.ServeMux) {
	mux.HandleFunc("/debug/trace", func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "application/octet-stream")
		_ = trace.Start(w)
		defer trace.Stop()

		// 보통 3~5초면 충분합니다.
		time.Sleep(5 * time.Second)
	})
}

운영에서는 인증/내부망 제한이 필수입니다.

3.2 trace 수집 및 보기

curl -s "http://127.0.0.1:8080/debug/trace" -o trace.out

go tool trace trace.out

브라우저 UI에서 다음을 우선 봅니다.

  • Goroutines: 특정 함수에서 생성된 고루틴이 계속 남아있는지
  • Network blocking profile: 네트워크 read/write에서 막히는지
  • Scheduler latency: 스케줄링 지연이 누적되는지

trace UI에서 고루틴을 클릭하면 스택과 이벤트 흐름을 볼 수 있어, pprof의 “정지 스택”보다 맥락이 잘 드러납니다.

4. pprof로 원인 유형을 빠르게 분류하는 체크리스트

pprof의 고루틴 스택을 보고 아래처럼 분류하면, 수정 방향이 거의 정해집니다.

4.1 chan send 에서 다수 대기

의미: 생산자가 보내는데 소비자가 없거나, 버퍼가 꽉 찼거나, 소비자가 조기 종료.

대응:

  • 결과 채널에 대한 소비가 항상 발생하는지 확인
  • 에러 경로에서 소비를 건너뛰지 않는지 확인
  • selectctx.Done()을 추가해 송신을 포기할 수 있게 만들기
select {
case out <- v:
	// ok
case <-ctx.Done():
	return ctx.Err()
}

4.2 chan receive 에서 다수 대기

의미: 생산자가 멈췄는데 소비자가 계속 기다림. 채널을 닫지 않았거나, 생산 고루틴이 조용히 죽었을 가능성.

대응:

  • “채널을 닫는 주체”를 명확히 하고, 생산 루프 종료 시 close(ch) 보장
  • fan-out 구조라면 errgroupcontext로 종료 전파

4.3 select 에서 다수 대기

의미: select에 종료 조건이 없거나, ctx.Done()을 빼먹음.

대응:

  • 모든 장기 루프에 ctx.Done() 케이스 추가
  • 생성자에서 WithCancel을 만들었으면 반드시 cancel() 호출

이 패턴은 Python async에서도 ContextVar 같은 컨텍스트 전파가 안 되면 추적이 어려워지는 것과 유사합니다. 컨텍스트를 “끝까지 전달”하는 관점은 Python 데코레이터+ContextVar로 async 로그 추적도 참고할 만합니다.

4.4 net/http 또는 crypto/tls 내부에서 대기

의미: 응답 바디 미종료, 타임아웃 부재, 커넥션 풀 고갈, 서버가 응답을 안 주는 상황.

대응:

  • http.Client에 타임아웃 설정
  • defer resp.Body.Close() 누락 여부 확인
  • Transport 튜닝(필요 시)
client := &http.Client{
	Timeout: 10 * time.Second,
}

resp, err := client.Get(url)
if err != nil {
	return err
}
defer resp.Body.Close()

5. 재현이 어려울 때: “고루틴 수”를 지표로 박아두기

누수는 재현이 어렵습니다. 그래서 “고루틴 수가 증가하는지”를 상시 관측하는 것이 중요합니다.

5.1 expvar로 goroutine 수 노출

import (
	"expvar"
	"runtime"
	"time"
)

func init() {
	expvar.Publish("goroutines", expvar.Func(func() any {
		return runtime.NumGoroutine()
	}))

	go func() {
		for range time.Tick(10 * time.Second) {
			// 필요하면 로그로도 남겨 추세를 봅니다.
			_ = runtime.NumGoroutine()
		}
	}()
}

Prometheus를 쓴다면 runtime/metrics 또는 Go runtime 메트릭을 그대로 스크랩하는 편이 더 일반적이지만, 핵심은 “증가 추세를 빨리 감지”하는 것입니다.

6. 자주 터지는 실제 누수 예제 2가지와 고치는 법

6.1 time.Ticker 누수

문제 코드:

func pollForever() {
	t := time.NewTicker(1 * time.Second)
	for range t.C {
		// ...
	}
}

이 함수가 여러 번 호출되거나, 중간에 빠져나가야 하는데 못 빠져나가면 ticker와 고루틴이 함께 누수됩니다.

개선:

func poll(ctx context.Context) error {
	t := time.NewTicker(1 * time.Second)
	defer t.Stop()

	for {
		select {
		case <-t.C:
			// ...
		case <-ctx.Done():
			return ctx.Err()
		}
	}
}

6.2 fan-out 작업에서 결과 채널 send 블록

문제 코드(소비자가 에러로 일찍 리턴하면 생산자들이 send에서 멈춤):

func doAll(urls []string) error {
	out := make(chan error)
	for _, u := range urls {
		go func(url string) {
			out <- fetch(url) // 소비자가 없으면 여기서 블록
		}(u)
	}

	for range urls {
		if err := <-out; err != nil {
			return err
		}
	}
	return nil
}

개선 방향은 두 가지입니다.

  • context로 취소 전파 + send 시 ctx.Done() 처리
  • 혹은 버퍼 채널로 “최소한의” 역압을 완화

아래는 컨텍스트 취소를 포함한 형태입니다.

func doAll(ctx context.Context, urls []string) error {
	ctx, cancel := context.WithCancel(ctx)
	defer cancel()

	out := make(chan error)
	for _, u := range urls {
		go func(url string) {
			err := fetch(url)
			select {
			case out <- err:
			case <-ctx.Done():
			}
		}(u)
	}

	for range urls {
		if err := <-out; err != nil {
			cancel()
			return err
		}
	}
	return nil
}

7. 10분 루틴 요약

운영에서 고루틴 leak 의심 시, 다음 순서로 진행하면 빠르게 좁힐 수 있습니다.

  1. net/http/pprof 활성화 후 goroutine?debug=2로 반복 스택 확인
  2. go tool pprof -top으로 “가장 많이 쌓인 스택”을 집계
  3. 30초 간격으로 2번 떠서 증가하는 스택이 무엇인지 확인
  4. runtime/trace 5초 수집 후 go tool trace로 블로킹 이벤트(채널/네트워크/락) 확인
  5. 원인 유형별로 수정: ctx.Done() 추가, cancel() 보장, 채널 close 소유권 정리, resp.Body.Close()/타임아웃 추가, Ticker.Stop()

이 루틴은 “짧은 시간에 범인을 특정”하는 데 초점이 있습니다. 인프라 레벨에서 간헐 장애를 10분 내로 좁히는 방식이 궁금하다면 EKS Pod에서 Kinesis 403 AccessDenied 10분 진단처럼 관측치 기반으로 범위를 줄이는 접근도 함께 참고하면, 트러블슈팅 감각을 더 빠르게 체득할 수 있습니다.

8. 마무리: leak을 “고치기 쉬운 구조”로 바꾸기

고루틴 leak은 디버깅보다 구조가 좌우합니다. 다음 3가지만 지켜도 재발이 크게 줄어듭니다.

  • 장기 실행 고루틴은 반드시 context로 생명주기를 제어한다
  • 채널은 “누가 닫는가”를 코드 레벨에서 명확히 한다
  • 외부 I/O는 타임아웃과 종료 처리를 기본값으로 둔다

pprof와 trace는 그 구조적 결함을 드러내는 가장 빠른 도구입니다. 고루틴 수가 증가하기 시작한 그 순간, 위 10분 루틴으로 스택을 확보해두면(특히 증가 구간의 스냅샷) 원인 규명 난이도가 급격히 내려갑니다.