k8s가 지켜주지 않는 것들 — Node.js 에이전트 서버가 503으로 죽는 원리
회사에서 코드리뷰 에이전트 서버를 운영하고 있다. 마스트라(Mastra)로 구성한 코드리뷰 워크플로우와, 리뷰 결과를 반영해 다시 도는 피드백 루프 워크플로우가 이 서버의 주된 일이다. EKS 위에서 돌고 있고, 대부분의 시간은 평화롭다. 메트릭도 평범하고, 응답도 빠르다.
그러다 가끔 죽는다. 어느 순간 슬랙에 503 알림이 오고, 들어가 보면 파드가 재시작 직후다. 죽기 직전 특별한 에러 로그도 없다. 잘 살다가 죽는다.
kubectl describe pod를 열어 Last State를 보면 답이 있다.
Last State: Terminated
Reason: OOMKilled
Exit Code: 137
커널이 이 프로세스를 죽였다. 예외도, 핸들링도 없이. 이 글은 이 한 줄에서 시작해서, 왜 k8s가 이 죽음을 막아주지 않는지, Node.js 서버의 실제 한계가 어디에 있는지, 그리고 에이전틱 워크플로우가 왜 이 한계를 정확히 때리는지 정리한 것이다.
503은 원인이 아니라 시체 발견 신고다
503을 보고 "왜 503이 나오지?"라고 묻는 순간 디버깅은 잘못된 방향으로 간다. 503은 애플리케이션이 만든 응답이 아니다. 로드밸런서나 인그레스가 "이 뒤에 응답할 프로세스가 지금 없다"라고 보고하는 신호다. 원인을 애플리케이션 코드에서 찾기 전에, 프로세스의 생존부터 확인해야 한다.
"프로세스가 없다"에 도달하는 경로는 크게 두 가지다.
- 프로세스가 소멸했다. RSS가 컨테이너 메모리 limit을 넘는 순간 커널이 SIGKILL을 보낸다. exit code 137, OOMKilled. 우리 케이스가 이것이었다.
- 프로세스는 살아있는데 응답하지 않는다. readiness probe가 실패해 엔드포인트에서 제거되면, 살아있는 파드가 503을 만들어낸다. 대표 원인은 event loop 블로킹이다.
둘은 치료법이 완전히 다르다. 그래서 첫 진단은 언제나 kubectl describe pod의 Last State와 exit code다. 137이면 메모리, probe failure면 응답성. 이 글은 137 쪽을 따라간다.
GC가 지키는 것과 커널이 보는 것
Node.js에서 메모리 이야기를 하면 GC부터 떠올리는데, GC가 지키는 영역과 커널이 제한하는 영역은 다르다. 이 간극이 죽음의 지도다.
- V8 old space: GC가 관리하는 힙. 객체, 문자열 대부분이 여기 산다.
- 그 밖의 RSS: Buffer, native 확장 메모리, 스레드 스택, 그리고 직렬화 버퍼. GC의 관할이 아니다.
문제는 컨테이너의 메모리 limit이 이 구분을 안 한다는 것이다. cgroup limit은 RSS 전체를 잰다. 힙이 여유롭다고 서버가 사는 게 아니라, 힙 + Buffer + 직렬화 버퍼의 합이 limit을 넘는 순간 SIGKILL이다.
에이전트 서버는 이 "GC의 관할 밖" 영역을 특히 잘 친다. 수 MB짜리 객체를 JSON.stringify하면 결과 문자열 버퍼만큼 RSS가 즉시 치솟는다. structuredClone은 V8의 워커 직렬화기로 돌아가서, 원본과 복사본이 동시에 힙에 떠 있는 순간이 온다. 스냅샷을 저장하는 워크플로우 엔진이라면 이 비용이 매 스텝 반복된다.
여기에 치명적인 디폴트가 하나 있다. --max-old-space-size를 지정하지 않으면 V8이 힙 한계를 정하는데, 이 기준은 컨테이너의 memory limit이 아니라 시스템 전체 메모리다. 메모리 1Gi limit을 가진 파드가 16Gi 노드에서 돌면, V8은 버전에 따라 수 GB까지 "쓸 수 있다"고 믿는다. GC의 입장에서는 끝까지 여유가 있다. 그래서 GC는 열심히 회수할 이유가 없고, RSS는 limit을 향해 곧장 올라가고, 커널이 먼저 움직인다.
V8이 보는 한계와 커널이 보는 한계가 다르면, GC는 시늉만 하다가 OOMKiller가 마무리한다. "잘 살다가 죽는" 패턴의 상당수가 이 한 줄으로 설명된다.
k8s는 재시작을 해줄 뿐이다
"컨테이너로 올렸으니 뭔가 관리해주겠지"라는 감각이 있다. 맞다. k8s는 죽은 파드를 감지하고 재시작해준다. 그런데 이게 러닝 게임의 발판일 뿐 치료가 아니라는 게 함정이다.
- OOMKilled로 죽은 파드는 재시작되고, 재시작된 파드는 이전과 같은 조건에서 이전과 같은 요청을 받는다. 큰 PR이 도착하면 같은 방식으로 다시 죽는다. 재시작 카운트가 올라가는 동안 모든 요청은 503이 된다.
- readiness probe는 "응답 못 하는 파드를 엔드포인트에서 빼준다"는 방어다. 프로세스가 아예 소멸하는 경로에는 무력하다. 프로세스가 없으면 probe가 성공할 리가 없으니까.
그래서 메모리 운영의 단위는 "프로세스 생존"이 아니라 메모리 예산이어야 한다. 구체적으로는 이것을 숫자로 적어두는 것이다.
- 컨테이너 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:
limits:
memory: "1Gi"
이 한 줄의 효과는 크다. V8이 커널보다 먼저 한계에 부딪히면, 애플리케이션은 JavaScript heap out of memory 오류를 던지고 이벤트 루프는 살아있다. OOMKilled의 시체가 아니라, 스택 트레이스가 있는 예외를 얻는다. 디버깅 가능한 죽음과 디버깅 불가능한 죽음의 차이다. (GC가 더 자주 도느라 처리량이 흔들리는 트레이드오프는 있다. 이 때문에 75%를 넘기면 안 된다.)
에이전틱 워크플로우는 이 물리학의 최악의 적
여기까지는 모든 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(한 번에 다 가져오기). OOM을 '메모리 누수'라는 막연한 단어로 부르지 말아야 하는 이유다. 클래스가 다르면 해결도 다르다.
운용 설계: 참조, ID, 가위
처음 이 문제를 마주했을 때 내 첫 가설은 "call by reference로 전달하면 해결되지 않을까"였다. JavaScript가 객체를 참조로 다룬다는 점을 이용해 복사를 없애자는 생각이었다. 결과부터 말하면 절반은 맞았고 절반은 틀렸다. 그리고 이 절반/절반이 해결의 지도 그 자체다.
경계 안에서는 참조를 공유하라
프로세스 안, 즉 직렬화가 일어나지 않는 구간에서는 복사를 없애는 것이 정답이다. structuredClone, JSON.parse(JSON.stringify(...)), 스프레드로 객체를 통째로 복사하는 코드가 큰 페이로드를 다룬다면 그게 복사 클래스의 범인이다. #20314의 수정이 정확히 이 방식이다. 스냅샷 불변성이 필요하면 전체를 복사할 게 아니라, 실제로 mutating하는 상위 3 depth만 얕게 복사하고 그 아래는 참조를 공유한다. 구조적 공유(structural sharing)다. 4GB가 5MiB로 줄었다.
내 가설 중 맞은 절반이 여기다. 다만 이름은 "call by reference"가 아니라 "복사 제거 + 구조적 공유"가 정확하다. 참조 전달은 이미 기본 동작이고, 문제는 참조 위에 얹힌 복사 습관이었으니까.
경계를 넘으면 ID를 남겨라
틀린 절반이 여기다. 워크플로우 스냅샷은 저장소로 직렬화된다. persist되는 순간 참조는 끊긴다. 메모리 안의 객체 참조는 Postgres row를 가리킬 수 없다. 직렬화 경계를 넘는 데이터에 대해 참조 공유는 물리적으로 불가능하고, 이 구간의 해결은 정반대다. 본체를 밖에 두고 스냅샷에는 ID만 남기는 것.
코드리뷰 워크플로우에 적용하면 이렇게 된다. diff 전문과 파일 내용이라는 큰 페이로드는 S3 같은 객체 저장소에 올리고, 워크플로우 컨텍스트에는 artifactId와 메타데이터만 흐른다. 노드가 필요로 할 때 ID로 가져온다. 스냅샷 직렬화 비용은 페이로드 크기에서 메타데이터 크기로 떨어지고, 2차 산수의 기저 M이 통째로 줄어든다. #17738류의 피크 문제에 대한 근본 대책도 이 방향이다.
쌓이는 것은 자른다
마지막 클래스는 참조도 ID도 아닌 가위다. 재생 버퍼, 캐시, 대기열, "나중에 쓸 수도 있으니 일단 다 들고 있자"로 쌓이는 모든 것. 이 클래스는 보관 자체가 병이라서, 보관량에 상한을 두는 것 외에는 답이 없다. #21219은 재생 버퍼에 상한과 sanitize를, #21518은 배치 fetch로 한 번에 보는 양에 상한을 뒀다. 캐시에 TTL/LRU를 다는 것도 같은 패턴이다.
세 가지를 한 문장으로 압축하면 이렇다.
경계 안에서는 참조를 공유하고, 경계를 넘으면 ID를 남기고, 쌓이는 것은 자른다.
마지막으로, 진단의 10분
설계를 고치기 전에 확인해야 할 것들이 있다. 순서대로 정리한 진단 절차다.
kubectl describe pod→ Last State, exit code. 137이면 메모리, probe fail이면 응답성.- 메트릭에서 RSS와
heapUsed를 같이 본다. 두 선이 벌어져 있다면 GC 관할 밖(native/Buffer/직렬화)이 범인이다. RSS가 힙 한계를 넘어 곧장 올라가다 끊기면 힙 사이징 문제다. - 재현이 된다면 heap snapshot을 뜬다.
--heapsnapshot-signal=SIGUSR2를 켜두고, 죽기 직전 시점에 신호를 보내 retainer(누가 이 객체를 붙잡고 있나)를 본다. 범인은 대부분 배열이나 Map 하나다. - 죽는 타이밍과 입력 크기의 상관관계를 본다. "가끔"이라고 생각했던 죽음이 큰 PR과 정렬되어 있으면, 이 글의 산수가 그대로 적용된다.
정리
- 503은 원인이 아니라 결과다. 먼저 exit code를 보라. OOMKilled(137)라면 싸움은 커널과의 싸움이다.
- GC는 V8 힙만 지킨다. 컨테이너 limit은 RSS 전체를 잰다.
--max-old-space-size를 limit의 50~75%로 명시하는 것은 선택이 아니라 기본값이다. 이 한 줄이 "시체"를 "스택 트레이스"로 바꿔준다. - k8s의 재시작은 치료가 아니라 재발 관찰이다. 운영의 단위는 프로세스 생존이 아니라 메모리 예산이다.
- 에이전틱 워크플로우는 큰 페이로드가 스텝마다 직렬화·복사되는 구조라 메모리 비용이 2차로 자란다. 복사/피크/축적/무경계 fetch 중 어떤 클래스인지 먼저 진단하라.
- 해결은 세 원칙이다. 경계 안에서는 참조를 공유하고, 경계를 넘으면 ID를 남기고, 쌓이는 것은 자른다.
k8s는 파드를 다시 띄워준다. 하지만 파드 안에서 일어나는 일의 책임은 끝까지 프로세스의, 그리고 그걸 설계한 우리 몫이다.