docker-compose exit code 137 — OOM이 아닌데 137이 나오는 이유와 SIGTERM 처리로 해결하기

1. 문제 정의

docker-compose up --exit-code-from combined 명령으로 통합 테스트를 실행한 CI 환경에서, 테스트 컨테이너 combined는 정상적으로 종료(exit code 0)했는데도 docker-compose가 반환하는 최종 exit code가 137로 뜨는 문제가 있었습니다.

1
2
3
4
5
6
7
docker-compose up --build --exit-code-from combined; echo $?
# ... 테스트는 모두 통과 (exit 0)
# TEST ENDED WITH EXIT CODE OF: 0
# EXITING SCRIPT WITH EXIT CODE OF: 0
# Aborting on container exit...
# Stopping b-combined   ... done
# 137          <-- CLI가 반환한 exit code

핵심 오해 지점은 **“테스트가 성공했으니 exit code는 당연히 0이어야 한다”**는 전제였습니다. 실제로는 종료 코드가 테스트 컨테이너의 결과만 반영하지 않습니다.

에러 로그에서 OOMKilled가 명시적으로 false이므로 메모리 부족(OOM)은 원인이 아닙니다.

2. 표면 증상

  • 테스트 컨테이너 combined의 로그는 EXITING SCRIPT WITH EXIT CODE OF: 0으로 마무리됨.
  • 그런데 docker-compose가 반환하는 코드는 0이 아닌 137.
  • docker inspect <container-id> 결과 State.OOMKilled: false (컨테이너는 메모리 부족으로 안 죽음).
  • Cypress 테스트 라인을 init.sh에서 주석 처리하면 exit code가 0이 됨 — 즉 테스트 실행 여부와 상관이 있는 문제.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
"State": {
    "Status": "exited",
    "Running": false,
    "Paused": false,
    "Restarting": false,
    "OOMKilled": false,
    "Dead": false,
    "Pid": 0,
    "ExitCode": 137,
    "Error": ""
}

증상 fingerprint (symptom fingerprint) — 빠른 판단 기준

  • 에러 메시지: 터미널에 Aborting on container exit... 이후 137 반환
  • 발생 단계: docker-compose up --exit-code-from <svc> 의 종료 단계
  • 관련 도구: docker-compose v1 (–exit-code-from, –abort-on-container-exit)
  • 흔한 오해: “137 = 무조건 OOM” → ❌ (SIGKILL로도 137이 됨)
  • 빠른 판단: OOMKilled: false + Aborting on container exit... 로그가 보이면 OOM이 아니라 강제 종료(신호 9) 문제

3. 원인 추적

exit code 137은 128 + 9 입니다. 리눅스/도커에서 신호(시그널)로 종료된 프로세스의 exit code는 128 + 신호번호가 됩니다.

신호번호신호명프로세스 exit code
9SIGKILL (즉시 강제 종료)128 + 9 = 137
15SIGTERM (graceful 요청)128 + 15 = 143

즉 137의 뜻은 단순히 “SIGKILL로 강제 종료되었다"는 것입니다.

OOM이 아닌 데도 SIGKILL이 발생하는 경로를 따라가면 원인이 드러납니다.

  1. --exit-code-from combined 옵션은 docker-compose에서 --abort-on-container-exit을 함께 활성화합니다.
    • docker-compose 문서: --abort-on-container-exit → “Stops all containers if any container was stopped.”
  2. combined 컨테이너가 테스트를 끝내고 exit 0으로 종료됩니다.
  3. 이 신호를 받은 docker-compose는 “한 컨테이너가 종료됐으니 나머지도 모두 중단해야 한다"고 판단해 함께 떠 있던 b-db(MongoDB) 컨테이너를 중단(stop)합니다.
  4. 이때 docker는 b-db에게 먼저 SIGTERM(15) 을 보내 graceful shutdown을 요청하지만, 기본 종료 타임아웃(기본값 10초, stop?t=10) 안에 스스로 종료되지 못하면 SIGKILL(9) 을 보내 강제로 죽입니다.
  5. 그 결과 b-db가 137로 종료되고, abort 흐름에 따라 docker-compose가 반환한 최종 코드도 137이 됩니다.

디버그 로그가 이 과정을 그대로 보여줍니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
b-combined exited with code 0
Aborting on container exit...
...
POST /v1.25/containers/<b-combined>/stop?t=10 HTTP/1.1" 204 0
...
Stopping b-db         ...
Pending: {<Container: b-db>}   <-- 10초 안에 graceful 종료를 기다림
Pending: {<Container: b-db>}
... (반복)
POST /v1.25/containers/<b-db>/stop?t=10 HTTP/1.1" 204 0
137
  • stop?t=10 이 10초 종료 타임아웃입니다.
  • b-db가 계속 Pending 상태로 대기하다가 grace period(10초)를 넘겨 SIGKILL 되면서 137이 됩니다.

원인 후보 매트릭스 (root cause matrix)

원인 후보확인 명령해당 시 증상해결 방향
메모리 부족 (OOM)docker inspect <id> → State.OOMKilledOOMKilled: true컨테이너/엔진 메모리 상향, 리소스 제한 조정
다른 컨테이너 SIGKILL (이번 사례)OOMKilled: false + Aborting on container exit... + stop?t=10 대기 로그테스트는 0인데 전체 137graceful shutdown, --timeout 증가
디스크 부족로그에 No space left on device로그에 명시적 오류docker system prune, 용량 확보

4. 근본 원인

표면적으로는 “테스트 컨테이너가 0으로 나가는데 왜 137?“이지만, 진짜 근본 원인은 abort가 발생했을 때 함께 종료되는 b-db 컨테이너가 SIGTERM을 제대로 처리하지 못해 10초 안에 graceful 하게 종료되지 않고, 결국 SIGKILL(9)로 강제 종료되면서 137이 되었다는 것입니다.

핵심 포인트:

  • --exit-code-from은 **“이 컨테이너의 exit code를 명령의 exit code로 사용하라”**는 의미지만, docker-compose v1에서 이 옵션은 --abort-on-container-exit을 수반합니다.
  • abort가 트리거되면 모든 다른 컨테이너도 함께 중단되며, 그 강제 중단의 결과가 전체 exit code에 반영됩니다.
  • 즉 테스트 성공 여부와 무관하게, 함께 죽는 컨테이너가 죽는 방식이 최종 exit code를 결정합니다.

DevTrace verdict (핵심 판단) 이 문제의 핵심은 “테스트 컨테이너가 0으로 끝났는데도 137이 나온다"는 겉보기 모순이 아니라, --abort-on-container-exit으로 함께 종료되는 다른 컨테이너(DB)가 SIGTERM을 graceful 하게 처리하지 못해 SIGKILL로 강제 종료되면서 전체 코드가 137이 된다는 점입니다. OOM 여부를 확인하기 전에 OOMKilled: false부터 먼저 배제하는 것이 첫 단계입니다.

5. 코드 해결책

방법 A — 종료 대기 시간(timeout)을 늘리기 (가장 빠른 해결)

DB가 graceful 하게 종료될 시간을 주면 SIGKILL로 밀리지 않습니다.

1
docker-compose up --build --exit-code-from combined --timeout 600
  • --timeout은 각 컨테이너가 SIGTERM을 받은 뒤 강제 종료(SIGKILL)되기 전에 얼마나 기다릴지(초) 를 지정합니다.
  • 기본값(10초)보다 긴 시간을 주면 MongoDB 등 충분한 종료 시간이 필요한 컨테이너가 정상적으로 내려갑니다.
  • 적용 후 테스트가 모두 통과하면 exit code가 0으로 반환됩니다.

방법 B — DB 컨테이너가 SIGTERM을 graceful 하게 처리하도록 개선 (근본 해결)

가장 바람직한 방법은 종료 시점에 SIGTERM 핸들러가 DB 클라이언트를 정리하고 빠르게 종료되도록 하는 것입니다. combined 컨테이너의 스타트 스크립트에서 테스트 종료 후 백엔드/프론트 서버를 종료할 때도, kill -9가 아니라 kill(기본 SIGTERM) 후 어플리케이션이 세션을 정리하도록 하는 것이 권장됩니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
#!/bin/bash
# Start front end server
history-server dist -p 8080 &
front_pid=$!

# Start back end server that interacts with DB
nodemon -L server &
back_pid=$!

# Run tests
NODE_ENV=test $(npm bin)/cypress run --config video=false --browser chrome
test_exit_code=$?
echo "TEST ENDED WITH EXIT CODE OF: $test_exit_code"

# End front and backend server (graceful)
kill $front_pid    # SIGTERM 기본값 — graceful 종료 유도
kill $back_pid
wait $front_pid 2>/dev/null
wait $back_pid  2>/dev/null

echo "EXITING SCRIPT WITH EXIT CODE OF: $test_exit_code"
exit "$test_exit_code"

주의: 종료 시점의 graceful 처리는 애플리케이션 구조와 DB 라이브러리에 따라 결과가 달라질 수 있으므로, 여기서는 “SIGTERM을 받으면 커넥션을 정리하고 종료되도록 처리한다"는 일반적인 점검 기준을 안내합니다.

방법 C — 더 이상 죽을 컨테이너가 없도록 구성

b-db가 실행 중일 필요가 없는 테스트라면 DB가 강제 종료될 때 반환 코드에 영향받지 않도록 별도 실행, 또는 테스트가 끝난 뒤에만 필요한 컨테이너를 분리하는 것이 대안이 될 수 있습니다. (프로젝트 구성마다 다르므로 환경에 따라 적용 여부를 판단해야 합니다.)

6. 검증 환경 및 로컬 실측 재현

원문(StackOverflow 59296801)은 docker-compose v1.25.0 사례입니다. 이 글의 해결 원리는 글 작성 환경에서 docker compose v2로 재현·검증했습니다. 원문은 v1, 아래 실측은 v2에서 일반 원리를 확인한 것이므로, 두 버전의 CLI 반환 코드가 다를 수 있음을 아래 6.3에서 분리해 정리합니다.

6.1 검증 환경

아래 명령은 다음 환경에서 실행했습니다.

항목값
호스트 OS / 커널Ubuntu 22.04.5 LTS / Linux 6.8.0-1049-oracle
CPU 아키텍처aarch64 (arm64)
Docker Engine (Client/Server)29.5.2
Docker Composev5.1.4 (v2)
Docker Compose v1 (docker-compose)미설치 → v2로 재현
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
$ cat /etc/os-release | grep -E 'VERSION_ID|PRETTY_NAME'
PRETTY_NAME="Ubuntu 22.04.5 LTS"
VERSION_ID="22.04"

$ sudo docker version --format '{{.Server.Version}}'
29.5.2

$ sudo docker compose version
Docker Compose version v5.1.4

$ sudo docker compose up --help | grep -E 'abort-on|exit-code-from|timeout'
      --abort-on-container-exit      Stops all containers if any
      --exit-code-from string        Return the exit code of the selected
                                     --abort-on-container-exit
  -t, --timeout int                  Use this timeout in seconds for
      --wait-timeout int             Maximum duration in seconds to wait

docker compose up --help에서 확인한 v2 옵션 구조: v1의 --abort-on-container-exit, --exit-code-from, --timeout과 동일한 명령·플래그 구조를 사용합니다.

6.2 재현 절차 (최소 재현 구성)

문제 상황을 최소로 재현하기 위해 두 서비스를 구성했습니다. app 컨테이너는 곧바로 exit 0 하는 테스트 역할, db 컨테이너는 SIGTERM을 무시해서 graceful 종료가 안 되는 DB 역할입니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# docker-compose.yml
services:
  db:
    build:
      context: .
      dockerfile: Dockerfile.db
  app:
    build:
      context: .
      dockerfile: Dockerfile.app
    depends_on:
      - db
1
2
3
# Dockerfile.app — 테스트가 곧바로 exit 0으로 끝나는 역할
FROM alpine:3.20
CMD ["sh", "-c", "echo 'app-test-ENDED exit 0'; sleep 1; exit 0"]
1
2
3
# Dockerfile.db — SIGTERM을 무시하는 서비스 (graceful 미처리 재현)
FROM alpine:3.20
CMD ["sh", "-c", "trap '' TERM; echo 'db started (ignoring SIGTERM)'; while true; do sleep 1; done"]

6.3 로컬 실측 실행 결과 (docker compose v2)

(a) docker compose down 후 up --abort-on-container-exit (전체 서비스)

app이 exit 0으로 종료되자 compose가 abort하며 db를 중단하려 합니다. db는 SIGTERM(trap '' TERM)을 무시하므로 grace period(10초)를 넘겨 SIGKILL로 밀립니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
$ sudo docker compose up --abort-on-container-exit
... 
app-1  | app-test-ENDED exit 0
app-1 exited with code 0
 Compose Stopping Aborting on container exit...
 Container repro137-app-1 Stopping
 Container repro137-app-1 Stopped
 Container repro137-db-1 Stopping
 Container repro137-db-1 Stopped
 db-1 exited with code 137          <-- SIGTERM 무시 → SIGKILL로 137

docker inspect로 실제 상태를 확인하면 OOMKilled: false + ExitCode: 137 입니다.

1
2
3
4
$ sudo docker inspect <db-container-id> | grep -E '"Status"|"OOMKilled"|"ExitCode"'
            "Status": "exited",
            "OOMKilled": false,
            "ExitCode": 137,
  • 이 실측에서 db 컨테이너가 SIGKILL로 137이 되는 핵심 메커니즘을 v2에서 재현했습니다.
  • 다만 compose CLI의 반환값은 echo $? 기준 0이었습니다. v2는 --exit-code-from에 지정된 컨테이너의 exit code를 CLI 반환값으로 사용하므로, app(0)이 선택된 경우 db(137)는 전체 반환값이 아니라 해당 컨테이너의 inspect ExitCode에만 남습니다. 이는 v1.25.0에서 CLI가 137을 보고한 것과 버전별 차이입니다.

(b) --exit-code-from db로 지정 (강제 종료된 컨테이너가 선택되는 경우)

강제 종료된 컨테이너를 --exit-code-from으로 지정하면 CLI 반환값이 137이 되는 것을 확인할 수 있습니다.

1
2
3
$ sudo docker compose up --abort-on-container-exit --exit-code-from db; echo $?
... db-1 exited with code 137
137

(c) SIGTERM을 graceful 하게 처리하는 db로 교체 (방법 B 재현)

SIGTERM 핸들러가 정상 동작하면 db가 강제 종료되지 않고 exit 0으로 내려가며, CLI도 0을 반환합니다.

1
2
3
# Dockerfile.db — SIGTERM을 받으면 정리 후 바로 종료
FROM alpine:3.20
CMD ["sh", "-c", "trap 'echo db-graceful-exit; exit 0' TERM; echo 'db started (handling SIGTERM)'; while true; do sleep 1; done"]
1
2
3
4
5
6
7
$ sudo docker compose up --abort-on-container-exit --exit-code-from app; echo $?
app-1 exited with code 0
 Container repro137-db-1 Stopping
db-1  | db-graceful-exit
 Container repro137-db-1 Stopped
db-1 exited with code 0
0
1
2
3
4
$ sudo docker inspect <db-container-id> | grep -E '"Status"|"OOMKilled"|"ExitCode"'
            "Status": "exited",
            "OOMKilled": false,
            "ExitCode": 0,

로컬 실측 요약 (docker compose v2):

시나리오CLI echo $?db ExitCodeOOMKilled
(a) SIGTERM 무시 → SIGKILL0 (exit-code-from=app)137false
(b) --exit-code-from db137137false
(c) graceful 처리00false

7. 버전 차이 (v1 vs v2) 구체화

원문 사례는 docker-compose v1.25.0이고, 이 글에서 재현·검증한 환경은 docker compose v2(v5.1.4) 입니다. 두 버전의 실제 동작 차이를 아래와 같이 정리합니다.

항목docker-compose v1 (원문)docker compose v2 (이 글 실측)
명령어docker-compose (파이썬 기반)docker compose (플러그인/standalone)
--exit-code-from 동작묵시적으로 --abort-on-container-exit 활성화up --help 문서상 --exit-code-from이 --abort-on-container-exit 수반(같은 구조)
기본 stop grace period10초 (stop?t=10)서버 기본 10초 (compose --timeout 기본 동일 -t, docker compose up --help)
CLI 반환 코드원문에서 SIGKILL된 컨테이너가 137을 CLI에 반영 (137 보고)--exit-code-from에 지정된 컨테이너의 exit code를 CLI 반환값으로 사용 → 검증 서비스가 0이면 CLI는 0, 강제 종료 컨테이너를 지정하면 137

정리: 원문은 v1, 재현은 v2에서 일반 원리를 확인했습니다. 핵심 원리(그레이스 종료 안 되는 컨테이너가 SIGKILL로 137이 되고, OOMKilled는 false)는 두 버전에서 동일하게 재현되지만, CLI 자체의 반환 코드가 v1에서는 강제 종료된 컨테이너의 137을, v2에서는 --exit-code-from의 대상 컨테이너 코드를 따르는 차이가 있습니다. 실무에서는 반드시 해당 환경에서 docker compose up --help(또는 v1이라면 docker-compose up --help)로 옵션 의미를 확인하세요.

8. 재발 방지 조치

  • 예방 체크

    • docker compose up --abort-on-container-exit 을 쓸 때는 “다른 컨테이너도 함께 죽는다"는 것을 항상 인지.
    • DB 등 무거운 컨테이너를 함께 두는 경우 --timeout으로 grace period를 충분히 보장.
    • 프로세스가 SIGTERM을 받았을 때 동작을 점검(커넥션 정리, 파일 플러시).
  • 운영 예방책 (검증 명령)

    • 해결 후 최종 성공 여부는 아래 명령으로 확인합니다.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# 1) 전체 exit code 확인 (v2 기준; v1은 docker-compose)
docker compose up --build --exit-code-from combined --timeout 600; echo $?
#    → 성공 시 0

# 2) 어떤 컨테이너가 어떤 코드로 종료됐는지 확인
docker compose ps -a

# 3) OOM 여부 필수 배제
docker inspect <container-id> | grep -E '"OOMKilled"|"ExitCode"'
#    → OOMKilled: false 이면서 ExitCode: 137 이면 SIGTERM 미처리 문제

해결책 비교:

해결책장점단점적합한 상황
--timeout 600 증가변경 최소, 즉시 적용종료 시간 늘어날 수 있음DB 등 graceful 종료가 느린 컨테이너
SIGTERM graceful 처리근본 해결, 종료 시간 단축 (6.3-(c)에서 실측)코드/스크립트 수정 필요정기적으로 반복되는 CI
컨테이너 구성 분리불필요한 종속 제거구조 변경 부담테스트가 DB와 독립적인 경우

잘못된/임시방편 해결책:

  • kill -9 를 습관적으로 사용해 프로세스를 죽이면 graceful 정리를 건너뛰어 데이터 손상·장애의 원인이 됩니다.
  • 137이 나올 때 무조건 메모리만 올리는 것은 원인이 OOM이 아닐 경우 효과가 없고, 리소스를 낭비합니다. 반드시 OOMKilled 필드와 병행 로그를 먼저 확인하세요.

출처