Docker 컨테이너가 갑자기 멈추고 Exit Code 137만 남으면 처음엔 이미지 문제나 애플리케이션 버그처럼 보일 수 있다. 그런데 실제 현장에서는 이 숫자가 컨테이너 안의 프로세스가 SIGKILL로 강제 종료됐다는 신호인 경우가 많고, 그중에서도 가장 흔한 축은 메모리 압박이다.
Docker 공식 resource constraints 문서도 호스트 메모리가 부족해지면 Linux 커널이 OOM(Out Of Memory) 상황에서 프로세스를 죽일 수 있다고 설명한다. 또 docker stats 문서는 실행 중 컨테이너의 메모리 사용량, 제한치, PIDS를 같이 보라고 안내한다. 그래서 137을 보면 로그만 다시 읽기보다 메모리 제한, 실제 사용량, 최근 컨테이너 종료 방식을 먼저 같이 보는 편이 훨씬 빠르다.
이 글은 생성형 AI를 활용해 초안을 만들고, Docker 공식 문서를 다시 확인해 정리했다.
flowchart TD A[Exit Code 137 확인] --> B{컨테이너가 OOMKilled 인가?} B -->|예| C[메모리 제한과 사용량 확인] B -->|아니오| D[SIGKILL 유발 주체 확인] C --> E[docker stats와 inspect로 원인 분리] D --> F[강제 stop, 오케스트레이터, 호스트 정책 점검] E --> G[메모리 제한 상향 또는 앱 사용량 축소] F --> G G --> H[작은 부하로 재검증]
칠판 치트시트
137이면 먼저SIGKILL강제 종료를 의심한다- 제일 먼저
OOMKilled여부를 본다docker stats로 메모리 사용량과 제한치를 같이 본다--memory를 올리기 전에 앱이 한 번에 너무 많이 잡아먹는지 같이 본다- 다시 띄우기만 반복하면 원인이 안 사라진다
이런 증상이면 이 문서가 맞다
docker ps -a에서 종료 컨테이너 상태가Exited (137)로 보인다.- 서비스가 평소엔 뜨다가 트래픽이나 배치 작업 때만 죽는다.
- 로그에 명확한 애플리케이션 에러 없이 갑자기 컨테이너가 재시작된다.
- Node.js, Python, Java처럼 메모리 사용량이 순간적으로 튀는 작업에서 반복된다.
Exit Code 137이 뜻하는 것
리눅스에서 종료 코드 137은 보통 128 + 9로 계산한다. 여기서 9는 SIGKILL이다. 즉 프로세스가 정리 시간을 거의 못 받고 강하게 끊겼다는 뜻에 가깝다. Docker 문서도 메모리가 부족하면 커널이 프로세스를 죽여 시스템을 보호할 수 있다고 설명한다.
다만 137이 항상 OOM 하나만 의미하는 건 아니다. 예를 들어 사람이 docker kill을 보냈거나, 오케스트레이터가 강제로 종료했거나, 호스트 수준 정책이 개입했을 수도 있다. 그래서 첫 분기는 단순하다. OOMKilled인지 아닌지 먼저 자른다.
가장 빠른 확인 순서
1) inspect에서 OOMKilled부터 본다
가장 먼저 볼 값은 종료 코드보다 OOMKilled다. 이 값이 true면 메모리 쪽부터 보면 된다.
docker inspect <container> --format '{{.State.OOMKilled}} {{.State.ExitCode}} {{.State.Error}}'true 137이면 메모리 압박 가능성이 높다.false 137이면 사람이 죽였는지, 오케스트레이터가 강제 종료했는지 같이 봐야 한다.
작은 미니 사례로 보면 더 쉽다. 예를 들어 야간 배치 컨테이너가 새벽 3시마다 137로 죽고 OOMKilled=true라면, 앱 로그보다 먼저 메모리 상한과 입력량을 보는 편이 빠르다. 반대로 OOMKilled=false인데 배포 직후만 죽는다면 종료 신호를 보낸 다른 프로세스나 배포 스크립트를 의심하는 게 맞다.
2) docker stats로 실제 메모리 사용량을 본다
Docker 공식 docker stats 문서 기준으로 가장 빨리 볼 수 있는 값은 MEM USAGE / LIMIT다.
docker stats --no-stream여기서 봐야 할 핵심은 두 가지다.
- 메모리 사용량이 제한치에 거의 붙는가
1.95GiB / 2GiB처럼 거의 꽉 차 있으면 OOM 가능성이 높다. - PIDS가 비정상적으로 늘어나는가
Docker 문서도 PIDS가 과하게 큰데 실제 프로세스 수는 적으면 내부에서 쓰레드가 과도하게 늘고 있을 수 있다고 설명한다.
실무에서는 CPU보다 MEM USAGE / LIMIT, 그리고 필요한 경우 PIDS를 먼저 보는 편이 빠르다.
3) 컨테이너 메모리 제한이 너무 빡빡하지 않은지 본다
Docker 공식 resource constraints 문서는 컨테이너에 메모리 제한을 줄 수 있다고 설명한다. -m 또는 --memory가 아주 낮게 잡혀 있으면 앱이 조금만 커져도 바로 죽는다.
예를 들어 이런 식이다.
docker run --memory=512m my-app개발 환경에서는 돌아가던 앱이 운영에서만 죽는 대표적인 이유가 여기 있다. 로컬은 사실상 무제한에 가깝게 돌았는데, 운영 컨테이너는 512m 또는 1g로 제한돼 있으면 빌드나 대형 요청에서 바로 137이 날 수 있다.
흔한 원인 4가지
1) 애플리케이션이 한 번에 너무 많이 메모리를 잡아먹음
가장 흔하다. 대용량 JSON을 통째로 읽거나, 대형 이미지를 여러 장 동시에 변환하거나, 한 번에 큰 배치를 메모리에 올리면 컨테이너 제한을 금방 넘긴다.
- 나쁜 예: 큰 파일 전체를 메모리에 읽고 한 번에 처리
- 좋은 예: 스트림 처리, 페이지네이션, 배치 쪼개기
예를 들어 Node.js 빌드 컨테이너라면 번들 단계에서 메모리가 튀고, Python ETL 컨테이너라면 dataframe을 한꺼번에 합치는 시점에서 메모리가 뛸 수 있다.
2) 컨테이너 제한은 작은데 앱 기본 메모리 설정은 큼
앱 자체 힙 설정과 Docker 제한이 어긋나는 경우도 많다. 예를 들어 Node.js에 --max-old-space-size=4096를 줬는데 컨테이너 제한은 2g면, 앱은 4GB를 쓰려 하고 컨테이너는 2GB에서 죽는다. 이 경우 앱 튜닝만 해도 안 되고, 컨테이너 제한과 앱 옵션을 함께 맞춰야 한다.
3) swap, reservation, host 여유 메모리 이해 없이 운영함
Docker 문서도 메모리와 swap, soft limit, hard limit이 서로 다르게 동작한다고 설명한다. 운영에서는 --memory만 보는 게 아니라 호스트 자체 여유 메모리도 같이 봐야 한다. 호스트 메모리가 너무 타이트하면 한 컨테이너만이 아니라 주변 프로세스까지 흔들릴 수 있다.
4) 사람이 직접 죽였거나 외부 오케스트레이터가 정리함
OOMKilled=false인데 137이면 이쪽을 본다. 예를 들어 사람이 docker kill을 쳤거나, 배포 스크립트가 짧은 간격으로 컨테이너를 교체했거나, 쿠버네티스/오케스트레이터 쪽에서 정책상 강제 종료했을 수 있다. 이때는 메모리보다 누가 종료 신호를 보냈는지가 핵심이다.
가장 빠른 복구 순서
1) 작은 트래픽으로 재현한다
대형 요청이나 대량 배치에서만 죽는지 먼저 본다. 작은 입력에서는 괜찮고 큰 입력에서만 137이 뜨면, 원인은 대체로 메모리 피크다.
2) 메모리 제한을 현실적인 수준으로 올린다
예를 들어 512m로는 빌드 컨테이너가 버티기 어렵다면 1g 또는 2g로 올려서 먼저 안정화한다. 다만 무조건 크게만 올리면 호스트 전체가 힘들어질 수 있으니, docker stats로 실제 사용량을 본 뒤 조정하는 편이 안전하다.
3) 앱 처리 단위를 줄인다
근본 해결은 여기다.
- 큰 파일을 스트림 처리로 전환
- 한 번에 처리하는 배치 크기 축소
- 병렬 worker 수 축소
- 캐시/임시 객체 정리
예를 들어 10만 건을 한 번에 처리하던 배치를 5000건씩 쪼개면 137이 사라지는 경우가 많다.
4) 재시작 정책만 믿지 않는다
컨테이너가 다시 올라오는 것과 원인이 사라지는 건 다르다. restart: always만 걸어두면 잠깐 살아났다가 다시 죽는 루프가 계속될 수 있다. 그래서 재시작 정책은 보조고, 실제로는 메모리 상한과 앱 피크 사용량을 맞추는 쪽이 먼저다.
현장 미니 사례 3개
사례 A) Next.js 빌드 컨테이너가 배포 때만 137로 종료
- 상황: 로컬에선 통과, CI 컨테이너에서만 실패
- 원인: CI 컨테이너 메모리 제한이 낮음
- 복구: 컨테이너 메모리 상향 + 빌드 동시성 축소
- 검증: 같은 커밋 재배포 시
docker stats기준 제한치 근처까지 치솟지 않는지 확인
사례 B) Python ETL 컨테이너가 새벽 배치 때만 재시작
- 상황: 낮에는 괜찮고 대량 적재 시간대만 죽음
- 원인: dataframe을 한 번에 메모리에 적재
- 복구: chunk 단위 처리로 전환
- 검증: 배치 처리 시간이 조금 늘어도 메모리 피크가 제한치 아래로 내려오는지 확인
사례 C) Exit 137인데 OOMKilled가 false
- 상황: 운영자는 메모리 문제로 오해
- 원인: 배포 스크립트가 교체 시
docker kill사용 - 복구: stop grace period 포함한 정상 종료 절차로 수정
- 검증: 다음 배포에서 종료 코드가 137 대신 정상 종료 흐름으로 바뀌는지 확인
운영자가 바로 판단하는 기준
| 상황 | 먼저 볼 것 | 이유 |
|---|---|---|
OOMKilled=true | 메모리 제한, 실제 사용량 | 메모리 압박 가능성이 가장 큼 |
OOMKilled=false + 137 | kill 주체, 배포 스크립트 | 외부 강제 종료일 수 있음 |
| 빌드/배치 때만 죽음 | 처리 단위, 병렬 수 | 피크 메모리 문제일 가능성이 큼 |
| 늘 제한치 근처 | docker stats의 MEM USAGE / LIMIT | 상향 또는 구조 변경 판단 근거 |
| PIDS 급증 | 쓰레드/프로세스 생성 패턴 | 메모리 외 병목 힌트가 될 수 있음 |
검색형 FAQ
Q1. Exit Code 137이면 무조건 메모리 부족인가요?
아니다. 가장 흔한 원인은 메모리 압박이지만, 사람이 docker kill을 보냈거나 외부 오케스트레이터가 강제 종료했을 수도 있다. 그래서 OOMKilled부터 먼저 본다.
Q2. docker stats만 보면 충분한가요?
충분하진 않지만 가장 빠른 시작점이다. 메모리 사용량과 제한치를 보고, 이어서 docker inspect의 종료 상태를 같이 봐야 한다.
Q3. 메모리만 올리면 끝나나요?
임시 완화는 되지만 근본 해결은 아니다. 큰 입력을 한 번에 처리하는 구조면 다시 같은 문제가 난다.
Q4. Node.js heap out of memory와도 관련 있나요?
그렇다. 앱 내부에서 힙이 커지고, 그 결과 컨테이너 제한까지 넘기면 둘이 함께 이어질 수 있다. 그래서 앱 메모리 설정과 컨테이너 메모리 제한을 같이 봐야 한다.
다음 읽기
- 36. Node.js heap out of memory(FATAL ERROR) 해결 가이드
- 35. Exec failed (signal SIGKILL)로 중간 종료될 때 해결
- 29. Exec failed (signal SIGTERM)로 중간 종료될 때 해결
- 25. Quartz에서 글 옮기고 지운 뒤 ENOENT로 다시 죽을 때
- Troubleshooting 허브
한 줄 결론
Docker Exit Code 137은 대개 “컨테이너가 그냥 이상하게 죽었다”가 아니라 강제 종료가 들어왔고, 그중에서도 메모리 압박 가능성이 크다는 신호다.
로그만 반복해서 보기보다 OOMKilled 확인 → docker stats 확인 → 제한치와 앱 메모리 사용량 같이 조정 순서로 가는 편이 훨씬 빠르다.
※ 이 문서는 생성형 AI를 활용해 작성되었습니다.