근본 원인 분석 청사진: 프로덕션 환경에서 OOMKilled(종료 코드 137) 디버깅하기

최종 업데이트: 2026년 7월 7일
핵심 요약: 종료 코드 137은 컨테이너가 cgroup 메모리 제한을 초과해 커널이 SIGKILL 신호로 프로세스를 종료했다는 의미입니다. 137은 128에 SIGKILL의 신호 번호인 9를 더한 값입니다. 근본 원인 분석은 다음 순서로 진행합니다. 애플리케이션 로그가 아니라 오케스트레이터의 이벤트 스트림에서 프로세스가 종료된 사실을 확인하고, cgroup 제한과 프로세스가 실제로 할당한 메모리를 비교합니다. 그런 다음 서비스 토폴로지에서 가장 먼저 메모리 누수가 시작된 서비스를 찾고, 해당 서비스의 배포 태그를 최근 변경 사항 및 이전 포스트모템과 대조합니다.
개요
- OOM 종료의 증거는 오케스트레이터의 이벤트 스트림에 있습니다. 종료된 프로세스는 SIGKILL을 받고 유용한 로그를 남기기도 전에 종료되기 때문입니다.
- 종료 코드 137이 메모리 부족으로 인한 종료인지는 페이로드를 통해 확인해야 합니다. ECS에서는
stopCode: OutOfMemory,kubectl describe에서는Reason: OOMKilled,dmesg에서는anon-rss항목을 확인합니다. - 원인을 알 수 없는 것처럼 보이는 OOM 중 상당수는 실제로 설정 문제입니다. 힙과 오프힙 메모리인 메타스페이스, 스레드 스택, 다이렉트 버퍼의 합이 cgroup 한도를 초과하거나, 메모리 기반 볼륨이 사용 가능한 여유 메모리를 조용히 소모하는 경우가 많습니다.
- 종료된 컨테이너가 원인이 아니라 피해자인 경우도 많습니다. PromQL의
deriv를 사용해 메모리 증가율을 기준으로 서비스 순위를 매기면 가장 먼저 메모리 누수가 발생한 업스트림 서비스를 찾을 수 있습니다. - 대부분의 메모리 회귀 문제는 배포와 함께 유입됩니다. 의심되는 서비스의 이미지 태그를 메모리 증가 시점과 대조하고 이전 포스트모템을 검색하면 이미 알려진 해결책을 찾을 수 있는 경우가 많습니다.
- 단일 컨테이너의 임계값 알림은 가장 눈에 띄는 증상을 보이는 서비스의 담당자를 호출합니다. Vibe OnCall의 Triage Agent는 사람이 호출되기 전에 이 전체 분석 과정을 실행합니다.
증거: 실제 프로덕션 환경에서 발생한 OOMKilled 원본 페이로드
애플리케이션 로그가 아니라 오케스트레이터의 이벤트 스트림부터 확인해야 합니다. OOM으로 종료된 프로세스는 SIGKILL을 받고 로그를 작성하던 중간에 즉시 종료되므로, 자체 로그에는 결정적인 증거가 남아 있지 않은 경우가 많습니다. 아래는 이번 분석에서 사용할 익명화된 CloudWatch 페이로드입니다. Datadog의 ECS 연동도 거의 동일한 구조를 전달합니다.
{
"timestamp": "2026-07-07T21:04:12.893Z",
"logStream": "ecs-payment-service-cluster/payment-gateway/a1b2c3d4",
"message": "Task failed to start successfully.",
"containerNames": ["payment-container"],
"detail": {
"containerInstanceArn": "arn:aws:ecs:us-east-1:123456789012:container-instance/abc-123",
"exitCode": 137,
"lastStatus": "STOPPED",
"stoppedReason": "Essential container in task exited",
"stopCode": "OutOfMemory",
"reason": "ResourceInitializationError: failed to create new containerName"
},
"platformError": "OOMKilled: Process isolated and terminated by kernel due to cgroup memory limits exceeded."
}
진단에 필요한 핵심 필드는 세 가지입니다.
detail.exitCode: 137과detail.stopCode: OutOfMemory는 애플리케이션 충돌이나 수동 중지가 아니라 커널에 의한 OOM 종료였음을 확인해 줍니다. OOM 중지 코드가 없다면 137은 Docker 중지 타임아웃이나 스케일인 이벤트로도 발생할 수 있습니다.logStream은 클러스터, 서비스, 태스크를 나타냅니다. 이 위치는 조사의 시작점일 뿐이며 반드시 근본 원인이 발생한 위치인 것은 아닙니다.stoppedReason의"Essential container in task exited"는 ECS가 전체 태스크를 종료했다는 의미입니다. 따라서 Envoy, 로그 라우터, 에이전트와 같은 사이드카도 함께 종료되며 해당 메트릭에는 데이터 공백이 발생합니다.
최근 시간 범위에서 발생한 모든 종료 코드 137을 찾으려면 CloudWatch Logs Insights에서 ECS 태스크 상태 변경 이벤트를 다음과 같이 쿼리합니다.
fields @timestamp, detail.group, detail.stoppedReason
| filter detail.containers.0.exitCode = 137
| sort @timestamp desc
| limit 20
Kubernetes에서는 다음 명령어를 사용해 동일한 증거를 직접 확인할 수 있습니다.
kubectl describe pod payment-gateway-7d9f8c -n payments
# Look for:
# Last State: Terminated
# Reason: OOMKilled
# Exit Code: 137
노드 자체에서는 커널 로그를 통해 종료된 프로세스와 종료 당시의 메모리 사용량을 확인할 수 있습니다.
dmesg -T | grep -iE "oom|killed process"
# Memory cgroup out of memory: Killed process 12345 (java)
# total-vm:4915204kB, anon-rss:1966080kB, file-rss:0kB여기서 중요한 값은 anon-rss입니다. 커널이 프로세스를 종료한 시점에 프로세스가 실제로 사용하고 있던 익명 메모리, 즉 힙과 오프힙 메모리의 크기를 나타냅니다.
AWS는 ECS 오류 참고 문서에서 중지된 태스크의 코드를 설명하고 있으며, Kubernetes의 OOM 동작은 공식 메모리 문서에서 확인할 수 있습니다.
근본 원인 분석 청사진: 단계별 가이드
[Raw failure payload]
|
v
[Step 1: Limits vs. actual allocation] --> misconfigured? --> fix limit/heap ratio
|
v
[Step 2: Find the first mover] --> which service's memory climbed FIRST?
|
v
[Step 3: Correlate deployment tags] --> match spike to recent change
|
v
[Root cause + matching past post-mortems]
1단계: cgroup 제한과 실제 메모리 할당량 비교하기
설정된 최대 메모리 제한을 확인하고, 프로세스가 할당하도록 설정된 메모리와 나란히 비교합니다.
# Kubernetes: the configured limit
kubectl get pod payment-gateway-7d9f8c -n payments \
-o jsonpath='{.spec.containers[*].resources.limits.memory}'
# Inside the container (cgroup v2): the kill line and live usage
cat /sys/fs/cgroup/memory.max /sys/fs/cgroup/memory.current
CS에서는 태스크 정의의 memory 하드 제한과 memoryReservation 소프트 제한을 Container Insights에서 보고된 컨테이너 사용량과 비교합니다.
대표적인 실패 사례는 메모리 제한이 512MiB인 컨테이너에서 JVM의 -Xmx512m을 사용하는 경우입니다. 힙은 프로세스가 사용하는 메모리의 일부에 불과합니다. 메타스페이스, 수백 개의 스레드에 각각 약 1MiB씩 할당되는 스레드 스택, 다이렉트 바이트 버퍼, 가비지 컬렉션 오버헤드는 모두 -Xmx 범위 밖에 존재합니다. 네이티브 메모리 추적 기능을 사용해 이를 확인할 수 있습니다.
# requires the JVM flag -XX:NativeMemoryTracking=summary
jcmd <pid> VM.native_memory summary
# Compare "Total: committed" against the cgroup limit — not just the heap line
컨테이너가 설정된 제한에 도달하지 않은 것처럼 보이는데도 종료되는 원인이 되는 두 가지 메모리 계산상의 함정이 있습니다.
- 커널은 프로세스 하나만이 아니라 전체 cgroup의 메모리를 계산합니다. 쓰기 완료를 기다리는 페이지 캐시,
tmpfs마운트, 자식 프로세스도 모두memory.max에 포함됩니다. - Kubernetes에서
medium: Memory로 설정된 메모리 기반emptyDir볼륨도 컨테이너의 메모리 제한에 포함됩니다. 300MiB 크기의 임시 파일을 생성하면 사용 가능한 여유 메모리 중 300MiB가 사라집니다.
일반적으로 힙은 컨테이너 제한의 70~75%로 설정하는 것이 좋습니다. JDK 10 이상에서는 JVM이 cgroup 제한을 직접 읽도록 -XX:MaxRAMPercentage=70을 사용할 수 있습니다. 메모리 제한과 할당 설정에 문제가 없다면 부하 증가나 메모리 누수로 인한 문제일 가능성이 높습니다. 이 경우 2단계로 이동합니다.
2단계: 가장 크게 드러난 피해자가 아니라 가장 먼저 움직인 서비스 찾기
메모리가 부족해진 위치가 반드시 문제가 시작된 위치는 아닙니다. 종료 전 30분 동안의 메모리 증가율을 기준으로 서비스의 순위를 매기세요. 지속적으로 가장 가파르게 증가한 서비스가 주요 의심 대상입니다.
이 분석 과정의 모든 단계는 애플리케이션 로그가 아니라 오케스트레이터의 이벤트 스트림에서 시작합니다. OOM으로 종료된 프로세스는 SIGKILL을 받고 작업 중간에 바로 종료되기 때문에 자체 로그에는 결정적인 증거가 거의 남지 않습니다. 아래는 이번 분석에서 사용할 익명화된 CloudWatch 페이로드이며, Datadog의 ECS 연동도 거의 동일한 구조를 전달합니다.
topk(5, deriv(container_memory_working_set_bytes{namespace="payments"}[30m]))
Datadog에서는 같은 시간 범위에서 kubernetes.memory.working_set 또는 container.memory.working_set을 Pod별로 그래프에 표시하면 됩니다. 원시 메모리 사용량이 아니라 워킹 세트를 사용해야 합니다. 워킹 세트는 회수 가능한 페이지 캐시를 제외하며, kubelet의 축출 로직이 실제로 추적하는 값입니다.
그다음 메모리 증가가 업스트림 서비스에서 전달된 것인지 확인합니다. 큐 깊이인 kafka_consumergroup_lag 또는 SQS의 ApproximateNumberOfMessagesVisible, 연결 수, 재시도 비율을 살펴보세요. 업스트림 프로듀서가 이벤트 크기를 두 배로 늘려 컨슈머에서 OOM이 발생했다면 컨슈머는 피해자이고 프로듀서가 근본 원인입니다. 가장 먼저 변화를 보인 서비스가 의심 대상이며, 그 이후 성능이 저하된 모든 서비스는 장애 영향 범위에 포함됩니다.
3단계: 배포 태그와 최근 변경 사항 연결하기
의심되는 서비스의 배포 기록을 메모리 사용량 그래프와 대조합니다.
kubectl rollout history deployment/payment-gateway -n payments
kubectl get replicasets -n payments --sort-by=.metadata.creationTimestamp \
-o custom-columns=NAME:.metadata.name,IMAGE:.spec.template.spec.containers[0].image,CREATED:.metadata.creationTimestamp
메모리가 증가하기 시작한 시간대에 새로운 이미지 태그가 배포됐다면 해당 배포에서 변경된 내용을 비교합니다. 종속성 업데이트, 캐시 크기 변경, 페이지네이션이 제거된 배치 작업 등을 확인하세요. 그런 다음 같은 서비스와 오류 패턴이 언급된 종료된 포스트모템을 검색합니다. Jira에서는 다음과 같이 검색할 수 있습니다.
project = OPS AND labels = post-mortem AND text ~ "payment-gateway OOM"
동일한 서비스에서 OOM 종료가 반복되는 것은 예외적인 일이 아니라 흔한 상황이며, 이전 티켓에 해결 방법이 기록되어 있는 경우가 많습니다.
마이크로서비스 토폴로지에서 일반적인 임계값 알림이 실패하는 이유
임계값의 착시: 단일 컨테이너의 메모리 알림이 실제 장애처럼 보이지만, 사실은 토폴로지의 다른 위치에서 발생한 메모리 누수로 인한 다운스트림 증상인 상황을 의미합니다. 이 알림을 기준으로 호출하면 온콜 엔지니어가 잘못된 서비스를 조사하게 됩니다. 이 분석 청사진은 바로 이 문제를 바로잡기 위해 존재합니다.
기존 알림 시스템은 하나의 컨테이너가 85%와 같은 고정된 메모리 임계값을 넘는지를 감시합니다. 하지만 마이크로서비스 토폴로지에서 이 신호는 명확하지 않습니다. 업스트림 결제 서비스의 메모리 누수로 큐가 밀리면 세 개의 다운스트림 서비스에서 컨슈머 버퍼가 커지고, 실제로는 정상인 서비스 세 곳에서 각각 ‘메모리 높음’ 알림이 발생할 수 있습니다. 엔지니어는 증상만 보고 호출되고, 잘못된 서비스를 조사하는 동안 실제 메모리 누수는 계속 증가합니다.
정적 임계값 알림은 사람에게 맥락이 없는 모호한 데이터 하나만 전달합니다. 반면 이 분석 청사진을 기반으로 한 트리아지는 누군가가 호출되기 전에 메모리 누수가 발생한 서비스, 장애 영향 범위, 과거에 적용했던 해결책을 함께 식별합니다.
Vibe OnCall의 Triage Agent는 바로 이 과정을 자동화합니다.
- 가로채기: Triage Agent는 알림이 수많은 Slack 메시지로 확산되기 전에 위와 같은 원본 JSON 페이로드를 CloudWatch 또는 Datadog에서 직접 가져옵니다.
- 컨텍스트 매핑: 마이크로서비스 토폴로지 전체에서 메모리 급증을 추적해 정확한 장애 영향 범위를 정의합니다. 어떤 서비스에서 메모리 누수가 가장 먼저 시작됐는지, 어떤 서비스가 그에 따른 부수적인 피해를 입었는지 구분합니다.
- 과거 사례 연결: 현재 배포 태그를 이전 Jira 포스트모템과 대조해 동일한 패턴을 가진 인시던트와 당시 적용된 해결 방법을 찾아냅니다.
- 결과: 호출 알림이 도착하는 시점에는 이미 근본 원인 분석이 완료되어 있습니다. Vibe OnCall을 사용하는 한 중견 기업 고객은 이 분석 과정이 SRE가 노트북을 열기 전에 실행되도록 함으로써 MTTR을 60% 줄이고 인시던트 대응 속도를 70% 높였습니다.
더 넓은 패턴에 대해서는 사람이 호출되기 전에 AI 조사가 실행되는 Tier 0 인시던트 가이드를 참고하세요.
근본 원인 분석에서 피해야 할 패턴
- 증상만 해결하고 인시던트를 종료하지 마세요. 메모리 누수가 있는 서비스의 메모리 제한을 두 배로 늘리면 몇 시간의 여유는 확보할 수 있지만 문제 자체는 해결되지 않습니다. 오히려 다음 OOM 종료가 발생하기까지의 증가 곡선이 길어져 이후 진단이 더 어려워집니다.
- 알림 소음을 줄이기 위해 임계값을 높이지 마세요. 이 소음은 임계값의 착시로 발생한 것입니다. 임계값을 높이면 실제 메모리 누수를 감지하는 시점만 늦어집니다.
- 알림 문구에서 근본 원인 분석을 시작하지 마세요. 알림은 임계값을 가장 먼저 넘은 서비스에서 관찰된 증상을 설명할 뿐입니다. 실제 종료 사유가 기록된 오케스트레이터의 원본 페이로드에서 조사를 시작해야 합니다.
- 포스트모템 검색을 생략하지 마세요. 같은 서비스에서 6개월 전에 OOM 종료가 발생했다면 해결 방법이 이미 기록되어 있을 가능성이 높습니다. 매번 처음부터 새롭게 조사하는 방식으로는 MTTR이 줄어들지 않습니다.
다음 인시던트 예방하기
- ECS의
memoryReservation을 하드 메모리 제한보다 낮게 설정하세요. 두 값 사이의 여유 공간은 OOM Killer를 유발하지 않으면서 순간적인 메모리 증가를 흡수합니다. - JVM 힙은 호스트가 아니라 컨테이너에 맞춰 조정하세요. JDK 10 이상에서는
-XX:MaxRAMPercentage=70을 사용할 수 있습니다. - Kubernetes의 리소스 요청량과 제한량을 모두 설정하세요. 요청량만 설정하고 제한량을 설정하지 않으면 노드 수준의 OOM 종료가 발생해 주변 Pod까지 영향을 받을 수 있습니다.
- 인시던트가 발생할 때가 아니라 배포할 때마다 메모리를 프로파일링하세요. 카나리 분석에서 배포 전후의 메모리 차이를 확인하면 누군가가 호출되기 전에 회귀 문제를 발견할 수 있습니다.
자주 묻는 질문
종료 코드 137은 무엇을 의미하나요?
종료 코드 137은 프로세스가 SIGKILL 신호를 받았다는 의미입니다. 128에 SIGKILL의 신호 번호인 9를 더한 값이며, 대부분 컨테이너가 cgroup 메모리 제한을 초과해 커널의 OOM Killer가 프로세스를 종료한 경우에 발생합니다. ECS에서는 stopCode: OutOfMemory, Kubernetes에서는 reason: OOMKilled를 통해 확인할 수 있습니다.
컨테이너가 설정된 메모리 제한보다 적게 사용했는데도 OOMKilled가 발생한 이유는 무엇인가요?
커널은 프로세스 하나가 아니라 전체 cgroup의 메모리를 계산하기 때문입니다. 쓰기 완료를 기다리는 페이지 캐시, 메모리 기반 emptyDir 볼륨, 자식 프로세스도 모두 제한에 포함됩니다. 또한 노드의 메모리가 부족한 경우에는 개별 컨테이너가 자체 제한에 도달하기 전에 kubelet이 Pod를 축출할 수도 있습니다.
실제로 OOMKilled 오류를 일으킨 서비스를 찾으려면 어떻게 해야 하나요?
절대 메모리 사용량이 아니라 메모리 증가율을 기준으로 서비스의 순위를 매겨야 합니다. PromQL에서는 topk(5, deriv(container_memory_working_set_bytes[30m]))를 사용할 수 있습니다. 종료된 컨테이너는 다운스트림의 피해자인 경우가 많으며, 메모리가 가장 먼저 증가하기 시작한 서비스가 주요 의심 대상입니다.
근본 원인 분석을 자동화할 수 있나요?
증거 수집 단계는 자동화할 수 있습니다. 오류 페이로드 읽기, 장애 영향 범위 매핑, 배포 태그와 과거 포스트모템 연결은 모두 결정론적이며 시스템에서 실행할 수 있는 작업입니다. Vibe OnCall의 Triage Agent와 같은 도구는 호출 전에 이러한 단계를 실행합니다. 실제 해결 방법에 대한 판단은 여전히 엔지니어가 담당하지만, 엔지니어는 원인을 처음부터 찾는 것이 아니라 이미 도출된 답변을 바탕으로 작업을 시작할 수 있습니다.
더 이상 이 분석 과정을 수동으로 실행하지 마세요
위의 모든 단계는 결정론적인 트리아지 작업이지만, 현재는 새벽 3시에 사람이 직접 수행해야 하는 경우가 많습니다. Vibe OnCall의 Triage Agent는 오류가 발생하는 즉시 전체 근본 원인 분석 과정을 실행합니다. 따라서 온콜 엔지니어에게 도착하는 호출에는 무엇이 고장 났는지, 왜 고장 났는지, 이전에는 어떤 방법으로 해결했는지가 이미 포함되어 있습니다. Triage Agent를 사용해 보세요.
방법론 참고: 위 페이로드는 실제 ECS 태스크 실패 이벤트를 익명화한 것입니다. 명령어와 각 필드의 동작은 2026년 7월 기준 AWS ECS, Kubernetes, OpenJDK 공식 문서를 바탕으로 검증했습니다. MTTR 관련 수치는 공개된 Vibe OnCall 중견 기업 고객 사례 연구에서 가져왔습니다.



