64MiB 블록의 착각: vParquet completion과 Go 힙 메모리 폭발

배치 요청의 실패 증폭 메커니즘을 밝혀낸 뒤에도, 내 마음속에는 더 본질적인 질문이 남아 있었다.

“애초에 그 Ingester 1대는 왜 16GiB 메모리를 다 쓰고 OOM으로 죽었을까?”

쿠버네티스 환경에서 파드가 OOM으로 죽으면 가장 흔히 취하는 조치는 “메모리 리밋을 더 올려주는 것”이다. 하지만 Ingester 파드 하나에 할당된 16GiB는 결코 작은 메모리가 아니었다. 더구나 우리는 블록이 너무 커져서 메모리를 많이 쓰지 않도록 max_block_bytes 설정을 64MiB로 꽤 보수적으로 잡아둔 상태였다.

“64MiB짜리 블록을 다루는 프로세스가 대체 왜 16GiB를 넘겨서 죽었을까?”

원인을 정확히 규명하지 않고 메모리를 32GiB로 늘려봤자 똑같이 죽을 것이 뻔했다. 그래서 나는 직관적인 추측을 하나씩 검증하며 숫자로 증명해 보기로 했다.


내가 세웠던 세 가지 초기 가설과 그 결과

처음 장애를 마주했을 때 머릿속에 떠오른 가설은 세 가지였다.

가설 1: 특정 Ingester로 트래픽이 몰렸거나 규격 외 트레이스가 들어왔는가?

가장 먼저 떠올린 생각은 “해시 링이 틀어져서 한 파드로 트래픽이 쏠렸거나, 비정상적으로 거대한 트레이스 하나가 들어와 메모리를 터뜨렸을 것”이라는 추측이었다. 실제로 Ingester 로그를 뒤져보니 다음과 같은 경고가 눈에 띄었다.

level=warn msg="TRACE_TOO_LARGE" max=5000000 totalSize=5124981

“5MB를 초과하는 트레이스가 들어왔으니 이게 범인이 아닐까?” 싶었다.

하지만 지표와 로그를 교차 검증하면서 이 가설은 빠르게 설득력을 잃었다.

  1. 인그레스 지표상 트래픽은 9대의 Ingester에 고르게 분산되고 있었다.
  2. TRACE_TOO_LARGE 로그는 장애가 없던 평상시(Baseline)에도 간헐적으로 찍히던 흔한 경고였다.
  3. 결정적으로, 해당 코드를 확인해 보니 5MB를 초과한 트레이스는 수신 시점에 즉시 거절(reject)되어 메모리에 적재되지 않았다.

단순히 거대 트레이스 하나 때문에 16GiB가 통째로 날아갔다고 보기에는 논리적 비약이 너무 컸다.

가설 2: 디스크의 WAL을 읽는 Replay 작업이 메모리를 먹었는가?

파드가 재시작되자마자 20여 초 만에 다시 죽었기 때문에, 두 번째로 의심한 것은 “죽기 직전 디스크에 쌓인 WAL(Write-Ahead Log)을 기동 시 읽어 들이는 과정에서 메모리가 터지는 게 아닐까?” 하는 점이었다.

하지만 재시작 로그의 타임스탬프를 밀리초 단위로 뜯어보았을 때, 내 짐작은 완전히 빗나갔음을 알게 되었다.

2026-07-10 10:11:30.120 msg="wal replay complete" duration=72.4ms
2026-07-10 10:11:30.250 msg="reloading local blocks tenants=35"
2026-07-10 10:11:31.010 msg="Tempo started"
...
2026-07-10 10:11:48.200 msg="completing block" tenant=대규모트래픽API block=f49a6e59-...
2026-07-10 10:11:52.890 (OOMKilled, exit 137 발생)

로그는 명확한 사실을 말하고 있었다. WAL을 다시 읽는 Replay 작업은 고작 72ms 만에 끝났다.

진짜 병목은 그 뒤였다. 로컬에 남아있던 블록을 압축·변환하는 completing block 작업이 시작되었고, 그 작업이 15초 이상 지속되다가 10시 11분 52초에 컨테이너가 OOM으로 강제 종료되었다.

문제는 WAL 읽기가 아니라, 블록 완료(Block Completion) 작업 에 있었다.


20초의 생존 시간 동안 pprof로 확인한 것

가설을 좁히고 나니, “이것은 추측으로 풀 수 있는 문제가 아니며, 실제 메모리를 누가 할당하는지 프로파일러로 봐야 한다”는 결론에 도달했다.

하지만 문제는 파드가 뜨고 나서 20여 초 만에 죽어버린다는 점이었다. 프로파일링을 길게 돌릴 여유가 없었다. 나는 파드가 재시작되어 기동되는 타이밍에 맞춰 곧바로 Go 런타임의 힙 프로파일(pprof) 엔드포인트를 호출해 짧은 스냅샷을 받아냈다.

그리고 프로파일 결과를 열었을 때, 원인이 적나라하게 드러났다.

// pprof 누적 메모리 할당(alloc_space) 주요 스택
Ingester.flushLoop
  -> handleComplete
    -> instance.CompleteBlock
      -> CompleteBlockWithBackend
        -> vparquet4.CreateBlock
          -> parquet.appendRow / Schema.Deconstruct
          -> rowGroupRows.ReadRows
          -> repeatedColumnBuffer.writeRow
          -> ColumnWriter.WriteRows

메모리 할당의 지분 대부분이 수신이나 네트워크 처리가 아니라, 로컬 블록을 최종 Parquet 포맷으로 변환하는 vparquet4.CreateBlock 경로에 집중되어 있었다.

여기서 주목해야 할 점은 이것이 메모리가 서서히 새어나가는 ‘메모리 누수(Memory Leak)‘가 아니었다는 사실이다. 프로파일의 inuse_space(현재 사용 중인 힙)가 아니라 alloc_space(누적 할당된 힙)를 보았을 때, 블록 변환이 일어나는 단 몇 초 사이에 수 기가바이트(GB) 단위의 객체가 폭발적으로 생성되었다가 GC를 기다리는 일시적 메모리 할당 압력(Transient Allocation Pressure) 이 발생하고 있었다.


64MiB 블록의 착각: 디스크 바이트와 Go 객체의 괴리

여기서 또 하나의 깊은 궁금증이 생겼다.

“블록 크기는 64MiB로 제한해 두었는데, 대체 왜 Parquet으로 변환하는 과정에서 수 GiB의 객체가 생겨나는 걸까?”

이 질문의 답을 찾기 위해 Tempo의 스토리지 엔진 구조를 파고들면서, 내가 “디스크에 쓰인 바이트(Bytes)“와 “프로그래밍 언어의 런타임 객체(Go Struct)“를 같은 크기로 착각하고 있었다는 사실 을 깨달았다.

flowchart TD
  DISK["디스크의 고밀도 WAL 블록<br>(압축·인코딩된 바이너리 약 64MiB)"] -->|"역직렬화 (Unpack)"| HEAP["Go 인메모리 구조체로 팽창<br>(수많은 포인터, 슬라이스, 맵 객체)"]
  HEAP -->|"포맷 전환"| CONVERT["Parquet 컬럼 버퍼링<br>(Schema Deconstruct & RowGroup Buffer)"]
  CONVERT -->|"최종 직렬화"| PARQUET["S3용 vParquet 블록 생성"]

  classDef disk fill:#e3f2fd,stroke:#1565c0,stroke-width:1px;
  classDef heap fill:#ffebee,stroke:#c62828,stroke-width:1px;
  class DISK,PARQUET disk;
  class HEAP,CONVERT heap;

디스크에 저장된 64MiB는 Protocol Buffers 등으로 압축되고 고도로 인코딩된 정제된 바이너리 바이트다. 하지만 이를 쿼리와 장기 보관에 유리한 Parquet 포맷으로 재구성하려면 다음과 같은 과정이 일어난다.

  1. 인메모리 객체 팽창: 디스크의 압축 바이트를 읽어 수많은 개별 Go 구조체(parquet.Row, Value, Span, Attribute)로 역직렬화한다. 이 과정에서 Go 언어 특유의 포인터 오버헤드, 슬라이스 헤더, 메모리 정렬(Padding) 등이 더해지면서 데이터는 디스크 크기 대비 수 배에서 십수 배까지 메모리상에서 팽창한다.
  2. 컬럼 버퍼 할당: 행(Row) 기반 데이터를 Parquet의 열(Column) 기반 구조로 뒤집기 위해 수많은 repeatedColumnBuffer와 페이지 버퍼를 메모리에 새로 할당한다.
  3. 메모리 공존: 블록 변환이 진행되는 동안 [디스크에서 읽어온 원본 데이터]와 [변환 중인 Go 구조체], 그리고 [새로 쓰여지는 타겟 Parquet 버퍼]가 같은 프로세스 힙 메모리 공간에 동시에 공존 한다.

결국 “디스크 블록이 64MiB니까 안전하겠지”라는 내 생각은 순진한 착각이었다. 특히 속성(Attribute)이 많거나 이벤트 로그가 길게 붙은 복잡한 트레이스가 포함된 블록일 경우, 디스크 바이트는 작아도 이를 메모리에 펼치는 순간 발생하는 런타임 힙 압력은 통제 범위를 쉽게 벗어날 수 있었다.


런타임 설정이 빚어낸 결정타: Ballast와 GOMEMLIMIT의 부재

블록 변환 자체의 메모리 스파이크도 무거웠지만, 이를 받쳐주던 우리의 시스템 설정이 결정타를 날렸다. 당시 Ingester 파드의 환경을 점검했을 때 뼈아픈 설정 실수들이 드러났다.

# 당시 Ingester의 설정 상태
resources:
  limits:
    memory: 16Gi
args:
  - -mem-ballast-size-mbs=1024  # 1GiB 메모리 밸러스트 상주
  - -concurrent_flushes=4       # 기본값: 4개 블록 동시 플러시
env:
  # GOMEMLIMIT 설정 없음 (기본값 MaxInt64)
  - name: GOGC
    value: "100"

이 조합을 보며 나는 시스템이 왜 OOM으로 직행할 수밖에 없었는지 퍼즐을 맞출 수 있었다.

1. 1GiB Ballast가 갉아먹은 안전 마진

과거 Go의 GC 오버헤드를 줄이기 위해 관행적으로 넣어두었던 1GiB 메모리 밸러스트(-mem-ballast-size-mbs=1024)가 프로세스 시작부터 힙 공간 1GiB를 고정 점유하고 있었다. 16GiB라는 타이트한 cgroup 한계선 내에서, 예기치 못한 메모리 스파이크를 받아내야 할 소중한 헤드룸 1GiB를 우리 스스로 깎아먹고 있었던 것 이다.

2. cgroup 벽 앞에서 눈감은 Go GC

가장 치명적이었던 것은 GOMEMLIMIT의 부재였다.

Go 런타임의 GOGC=100 기본 정책은 “현재 라이브 힙의 2배가 될 때까지 GC를 미룬다”는 방식이다. 문제는 Go 런타임이 쿠버네티스의 cgroup limit(16GiB)을 스스로 인지하지 못한다는 점이다.

블록 변환 작업으로 메모리가 12GiB, 14GiB로 치솟는 순간에도 Go 런타임은 “아직 GC를 돌릴 때가 아니다”라고 생각했다. 반면 리눅스 커널의 cgroup 메모리 컨트롤러는 16GiB를 찍는 바로 그 순간, 프로세스에 경고 한 번 주지 않고 즉시 OOMKilled를 날렸다.

3. 동시 플러시의 중첩

기본값으로 켜져 있던 concurrent_flushes=4 역시 위험했다. 하나의 블록 변환도 수 GiB의 할당 압력을 만드는데, 여러 테넌트의 블록 컷 시점이 겹쳐 2~3개의 변환이 동시에 돌면 16GiB는 몇 초 만에 돌파당할 수밖에 없는 구조였다.


내가 내린 처방과 결과

원인의 메커니즘을 명확히 이해하고 나니, 어떤 조치를 취해야 할지 답이 보였다. 나는 단순히 메모리 리밋을 늘리는 대신, 런타임이 한계선 앞에서 제 역할을 할 수 있도록 설정을 재설계했다.

  1. 메모리 밸러스트 제거: 불필요하게 1GiB를 상주시키던 -mem-ballast-size-mbs 인자를 완전히 삭제해 순수 헤드룸을 확보했다.
  2. GOMEMLIMIT 도입: cgroup limit(16GiB)보다 4GiB 낮은 GOMEMLIMIT=12GiB 를 설정했다. 일시적으로 메모리 할당이 폭증하더라도, 12GiB 지점에 도달하면 Go 런타임이 커널 OOM에 부딪히기 전에 선제적이고 공격적으로 GC를 수행하도록 강제했다.
  3. 동시 플러시 제한: concurrent_flushes를 4에서 1 로 줄여, 고비용의 vParquet completion 작업들이 동시에 메모리 스파이크를 일으키지 않도록 직렬화했다.

이 변경을 적용한 뒤, 동일한 트래픽 패턴과 동일한 블록 변환 워크로드에서도 Ingester는 단 한 번도 OOM으로 죽지 않고 안정적으로 메모리를 회수하며 동작했다.


내가 얻은 생각의 정리

이번 분석을 통해 나는 시스템을 바라보는 시야를 한 단계 더 넓힐 수 있었다.

첫째, 스토리지의 바이트 크기와 메모리의 객체 크기는 완전히 다른 차원의 개념 이라는 점이다. 압축과 인코딩 뒤에 숨겨진 언어 런타임의 객체 팽창 배수(Amplification Factor)를 고려하지 않은 채 디스크 수치만 보고 리소스를 산정하는 것은 대단히 위험하다.

둘째, 컨테이너 환경에서 언어 런타임과 리눅스 커널 cgroup 사이의 완충 지대를 만드는 법 을 배웠다. GOMEMLIMIT은 단순한 튜닝 옵션이 아니라, 커널 OOM 킬러라는 단두대 앞에서 애플리케이션이 숨 쉴 수 있는 생명선이었다.