DEV_BBAK
← 포스트

EKS에서 Node.js 에이전트 서버가 가끔 죽던 이유

·16 min read·로딩중...

회사에서 코드리뷰 에이전트 서버를 운영하고 있다. 마스트라(Mastra)로 만든 코드리뷰 워크플로우와, 리뷰 결과를 받아 다시 도는 피드백 루프 워크플로우가 이 서버의 주된 일이다. EKS에서 돌고 있고, 대부분의 날은 별일 없다.

그러다 가끔 죽는다. 503 알림이 오고 들어가 보면 파드가 막 재시작된 직후다. 죽기 직전 로그에는 딱히 잘못된 게 없다. 잘 돌다가 죽는다.

kubectl describe pod를 열어 Last State를 보면 답이 있다.

Last State:  Terminated
  Reason:    OOMKilled
  Exit Code: 137

커널이 이 프로세스를 죽였다는 뜻이다. 애플리케이션 에러가 아니라 SIGKILL. 이 한 줄에서 거꾸로 올라가면 k8s 위에서 이런 일이 왜 가능한지가 보인다.

503이면 프로세스부터 확인한다

503을 보고 "왜 503이 나오지?"라고 묻는 순간 디버깅은 엉뚱한 데로 간다. 503은 애플리케이션이 만든 응답이 아니다. 로드밸런서 입장에서 "이 뒤에는 지금 응답할 프로세스가 없다"는 신고에 가깝다. 원인을 코드에서 찾기 전에 프로세스의 생존부터 확인해야 한다.

프로세스가 응답하지 않는 상태로 도달하는 경로는 크게 두 가지다.

  1. 프로세스가 소멸했다. RSS가 컨테이너 메모리 limit을 넘는 순간 커널이 SIGKILL을 보낸다. exit code 137, OOMKilled. 우리 케이스가 이쪽이었다.
  2. 프로세스는 살아있는데 응답하지 않는다. readiness probe가 실패해 엔드포인트에서 빠지면 살아있는 파드가 503을 만들어낸다. 대표 원인은 event loop 블로킹이다.

둘은 처방이 완전히 다르다. 그래서 첫 진단은 언제나 describe pod의 Last State와 exit code다. 137이면 메모리, probe 실패면 응답성. 이 글은 137 쪽을 따라간다.

V8은 컨테이너 limit을 모른다

Node.js의 메모리 이야기를 하면 GC부터 떠올리는데, GC가 관리하는 영역과 커널이 제한하는 영역은 다르다.

  • V8 old space: GC가 관리하는 힙. 객체와 문자열 대부분이 여기 산다.
  • 그 밖의 RSS: Buffer, native 확장, 스레드 스택, 직렬화 버퍼. GC의 관할이 아니다.

컨테이너의 memory limit은 이 구분을 안 한다. cgroup은 RSS 전체를 잰다. 힙에 여유가 있어도 힙 + Buffer + 직렬화 버퍼의 합이 limit을 넘는 순간 SIGKILL이다.

에이전트 서버는 이 "GC 관할 밖" 영역을 자주 건드린다. 수 MB짜리 객체를 JSON.stringify하면 결과 문자열만큼 RSS가 즉시 오르고, structuredClone은 원본과 복사본이 동시에 힙에 떠 있는 순간을 만든다. 스냅샷을 매 스텝 저장하는 워크플로우 엔진이라면 이 비용이 계속 반복된다.

여기에 함정이 하나 있다. --max-old-space-size를 지정하지 않으면 V8이 힙 한계를 정하는데, 그 기준은 컨테이너의 memory limit이 아니라 시스템 전체 메모리다. limit 1Gi인 파드가 16Gi 노드에서 돌면 V8은 버전에 따라 수 GB까지 쓸 수 있다고 믿는다. GC 입장에서는 끝까지 여유가 있으니 회수를 서두르지 않고, RSS는 limit을 향해 올라가고, 커널이 먼저 움직인다. "잘 돌다가 죽는" 패턴의 상당수가 이 간극에서 나온다.

max-old-space-size 정하기

k8s는 죽은 파드를 재시작해주지만 그게 전부다. SIGKILL에는 graceful shutdown이 없어서 그 순간 돌고 있던 워크플로우 실행은 그대로 사라진다. 다시 뜬 파드가 같은 조건에서 같은 큰 PR을 받으면 같은 방식으로 또 죽고, 그 파드로 가는 요청은 재시작이 끝날 때까지 503이 된다. (스냅샷 persist는 비용이지만, 동시에 죽은 뒤 실행을 이어갈 유일한 수단이기도 하다. 이 양면성은 뒤에서 다시 나온다.)

운영의 단위를 메모리 예산으로 잡으면 숫자가 먼저 나온다.

  • 컨테이너 memory limit: L
  • Node 힙 상한: L의 50~75%. 나머지는 Buffer, native, 스택, 직렬화 버퍼의 몫.

limit이 1Gi라면 힙은 512~768MiB로 잡는다.

env:
  - name: NODE_OPTIONS
    value: "--max-old-space-size=768"  # MB 단위. limit 1Gi 기준
resources:
  requests:
    memory: "1Gi"   # limits와 같게 두면 QoS가 Guaranteed가 된다
  limits:
    memory: "1Gi"
readinessProbe:      # 이 글의 503 논리와 probe는 세트로 다닌다
  httpGet: { path: /healthz, port: 3000 }
  periodSeconds: 10

이렇게 잡으면 어느 쪽 한계가 먼저 오는지가 달라진다. V8이 먼저 한계에 닿으면 프로세스는 여전히 죽는다. exit code 134와 함께 FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory가 로그에 남고, 어디서 죽었는지 스택 트레이스가 따라온다. 커널이 먼저이면 그마저도 없다. 둘 다 프로세스는 죽지만 남는 추적 재료가 다르고, OOM을 잡을 때 이 차이는 크다. 대신 GC가 더 자주 돌아 처리량이 흔들리는 트레이드오프가 있어서 75%는 넘기지 않는다.

스냅샷 비용이 2차로 자라는 구조

여기까지는 모든 Node.js 서버에 해당하는 이야기다. 에이전틱 워크플로우가 특별한 건, 이 메모리 구조의 약점을 전부 동시에 건드리는 데이터 패턴을 갖고 있기 때문이다.

우리 워크플로우를 예로 들면, 노드 사이를 흐르는 데이터는 리뷰 대상 diff, 파일 내용, 이전 리뷰 코멘트, 모델의 중간 결과다. PR이 작으면 아무 일 없다. PR이 커지면 노드 간 데이터가 커지고, 워크플로우 엔진은 스텝마다 컨텍스트를 스냅샷으로 직렬화한다. "가끔 죽는다"의 가끔은 우연이 아니라 PR 크기 분포일 가능성이 높다.

계산해 보면 왜 그런지 보인다. 페이로드 크기 M, 스텝 수 N일 때 컨텍스트가 누적되는 구조라면 스텝 i의 직렬화 비용은 M·i고 총비용은 M·N(N+1)/2다. 선형이 아니라 2차다. 큰 PR 하나가 서버 전체를 죽일 수 있는 이유다.

Mastra 저장소에 최근 몇 달 올라온 이슈들을 보면 같은 원인 클래스가 반복된다.

케이스 원인 클래스 무슨 일이 있었나 해결
#20314 복사 스트리밍 엔진이 모든 delta마다 전체 메시지를 structuredClone. 2MB 툴 결과물 하나에 delta 2,000개 만에 4GB 힙 OOM 3 depth만 얕게 복사하고 나머지는 참조 공유 → 같은 시나리오에서 힙 증가 5MiB
#17738 피크 툴 승인(human-in-the-loop)이 걸릴 때마다 persistWorkflowSnapshot이 상태를 통째로 직렬화. 205MB에서 3GB로 치솟고 반복 OOM 이슈로 등록된 페이로드 구조 문제
#21219 축적 스레드 스트림 브로드캐스트가 raw 모델 요청(base64 문서 포함)을 재생 버퍼에 전부 보관. 한 스레드에 1.07GB, 재구독할 때마다 OOM 크래시 루프 브로드캐스트 사본에서 요청 본문 제거
#21518 무경계 fetch running 전체 row의 스냅샷을 한 번에 메모리로 hydrate. 운영 크래시 루프 100개 단위 배치 fetch로 피크를 O(전체)에서 O(배치)로

원인은 네 갈래다. 복사(매번 통째로 clone), 피크(직렬화 순간의 버퍼), 축적(참조가 풀리지 않는 보관), 무경계 fetch(한 번에 다 가져오기). "메모리 누수"라고 뭉뚱그리면 진단이 안 된다. 클래스가 다르면 처방도 다르다.

메모리를 줄이는 세 가지 방법

이 문제를 처음 마주했을 때 내 첫 가설은 "call by reference로 전달하면 해결되지 않을까"였다. JavaScript가 객체를 참조로 다룬다는 점을 이용해 복사를 없애자는 생각이었다. 정리하고 보니 이 생각은 절반만 맞았다.

참조 공유 (프로세스 안)

직렬화가 일어나지 않는 구간에서는 복사 제거가 정답이다. structuredClone, JSON.parse(JSON.stringify(...)), 스프레드로 객체를 통째로 복사하는 코드가 큰 페이로드를 다룬다면 그게 복사 클래스의 원인이다. #20314의 수정이 정확히 이 방식인데, 전체를 복사하는 대신 실제로 값을 바꾸는 상위 3 depth만 얕게 복사하고 나머지는 참조를 공유했다. 구조적 공유(structural sharing)다. 4GB가 5MiB로 줄었다.

"call by reference"라는 내 원래 표현은 부정확했다. 참조 전달은 이미 기본 동작이고, 문제는 그 위에 얹힌 복사 습관이었다.

ID 위임 (저장소 경계)

틀린 절반은 여기 있다. 워크플로우 스냅샷은 저장소로 직렬화되고, persist되는 순간 참조는 끊긴다. 메모리 안의 객체 참조는 저장소의 row를 가리킬 수 없다. 직렬화 경계를 넘는 데이터에는 정반대의 해결이 필요하다. 본체를 밖에 두고 스냅샷에는 ID만 남기는 것.

코드리뷰 워크플로우에 대입하면, diff 전문과 파일 내용은 S3 같은 객체 저장소에 두고 워크플로우 컨텍스트에는 artifactId와 메타데이터만 흐르게 한다. 노드가 필요할 때 ID로 가져온다. 직렬화 비용이 페이로드 크기에서 메타데이터 크기로 줄고, 위 2차식의 기저 M이 통째로 줄어든다. #17738 같은 피크 문제의 근본 대책도 이 방향이다. 공짜는 아니다. 노드마다 저장소 왕복이 생기고, 가져온 데이터를 또 메모리에 들고 있으면 원점으로 돌아간다. 캐시 정책까지가 이 설계의 일부다.

상한 설정 (버퍼와 캐시)

축적과 무경계 fetch에는 상한이 처방이다. 재생 버퍼, 캐시, 대기열처럼 나중에 쓸지 몰라 들고 있는 모든 것이 여기 속하는데, 두 클래스 모두 "한 번에 들고 있는 양"에 상한을 두는 같은 처방으로 묶인다. #21219은 재생 버퍼에 상한과 sanitize를 걸었고, #21518은 100개 단위 배치 fetch로 피크를 O(전체)에서 O(배치)로 낮췄다. 캐시에 TTL/LRU를 다는 것도 같은 패턴이다.

세 방향을 한 줄로 정리하면:

경계 안에서는 참조를 공유하고, 경계를 넘으면 ID를 남기고, 쌓이는 것은 상한으로 자른다.

진단 순서

지금까지의 내용을 진단 절차로 바꾸면 이렇다.

  1. kubectl describe pod → Last State와 exit code. 137이면 메모리, probe 실패면 응답성.
  2. 메트릭에서 RSS와 heapUsed를 같이 본다. 두 선이 벌어져 있으면 GC 관할 밖(Buffer, 직렬화 버퍼)이 원인이고, RSS가 limit 근처에서 끊기듯 사라지면 힙 사이징 문제다.
  3. heap snapshot이 필요하면 알아야 한다. 커널이 먼저 죽이는 상황에서는 "죽기 직전"을 노리는 게 불가능하다는 게 실제 난관이다. 힙 상한을 limit 안으로 조인 뒤 V8 한계 안에서 재현하면 --heapsnapshot-signal=SIGUSR2--heapsnapshot-near-heap-limit로 잡을 수 있다. retainer(누가 이 객체를 붙잡고 있나)를 보면 대부분 배열이나 Map 하나가 나온다.
  4. 죽는 타이밍과 입력 크기의 상관관계를 본다. 재시작 시각 목록과 큰 PR이 겹치면 이 글의 2차식이 그대로 적용된다.

k8s는 파드를 다시 띄워준다. 하지만 같은 조건에 같은 큰 PR이 오면 같은 방식으로 다시 죽는다. V8이 커널보다 먼저 한계를 보게 만들고, 큰 페이로드를 컨텍스트 밖으로 빼고, 쌓이는 것에 상한을 거는 일은 k8s가 아니라 우리가 정하는 영역이다.