- Published on
GitLab Runner Docker executor OOM·Exit 137 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버/CI 환경에서 Job failed: exit code 137은 생각보다 자주 마주칩니다. 특히 GitLab Runner의 Docker executor를 쓰는 경우, 빌드 컨테이너가 메모리를 과도하게 쓰다가 커널 OOM Killer에 의해 강제 종료되면서 137로 끝나는 패턴이 흔합니다. 문제는 “메모리를 늘리면 된다”로 끝나지 않는다는 점입니다. Docker의 cgroup 제한, runner 설정, 빌드 도구(예: Node/Java/Go) 특성, 캐시/아티팩트 전략이 얽혀 있어 재발하기 쉽습니다.
이 글은 Exit 137의 정확한 의미 확인 → 어디서 OOM이 났는지(호스트 vs 컨테이너) 판별 → GitLab Runner/Docker 레벨의 메모리 제한 조정 → 빌드 자체 메모리 최적화 순서로 정리합니다.
> 참고로 쿠버네티스 환경에서 비슷한 “리소스/종료” 디버깅이 필요하다면, Kubernetes Pod가 Terminating에 멈출 때 - finalizer·grace·SIGTERM 실전 디버깅도 함께 보면 원인 분리가 빨라집니다.
Exit 137이 의미하는 것 (OOM과의 관계)
- 리눅스에서 137 = 128 + 9(SIGKILL) 입니다.
- 즉 프로세스가
SIGKILL로 종료되었음을 뜻합니다. - CI에서 흔한 원인은 다음 두 가지입니다.
- 커널 OOM Killer가 메모리 부족으로 프로세스를 강제 종료
- Docker/런타임이 cgroup 제한을 넘긴 프로세스를 강제 종료(OOM)
여기서 중요한 포인트는 “로그에 OOM이 안 찍혔다”가 OOM이 아니란 뜻이 아니라는 것입니다. OOM은 종종 커널 로그에만 남고, 컨테이너 stdout에는 아무 흔적이 없을 수 있습니다.
1단계: OOM이 ‘호스트’에서 났는지 ‘컨테이너’에서 났는지 판별
호스트(러너 머신)에서 OOM이 난 경우
러너가 설치된 머신에서 다음을 확인합니다.
# 커널 OOM 로그 확인
sudo dmesg -T | egrep -i 'out of memory|oom-killer|killed process'
# systemd journal을 쓰는 경우
sudo journalctl -k --since "1 hour ago" | egrep -i 'out of memory|oom|killed process'
여기서 Killed process <pid> (node|java|docker|...) 같은 라인이 나오면 호스트 메모리가 바닥나 OOM Killer가 개입한 것입니다. 이 경우 컨테이너 메모리 제한을 늘려도 호스트가 부족하면 동일하게 죽습니다.
컨테이너(cgroup)에서 OOM이 난 경우
Docker executor는 기본적으로 빌드를 컨테이너에서 돌립니다. 컨테이너 OOM 여부는 다음으로 확인합니다.
# 해당 시점에 남아있는 컨테이너가 있다면 OOMKilled 여부 확인
docker ps -a --no-trunc
# 컨테이너 상태에서 OOMKilled 확인
docker inspect <container_id> --format '{{.State.OOMKilled}} {{.State.ExitCode}}'
# Docker 데몬 로그(환경에 따라 위치 상이)
sudo journalctl -u docker --since "1 hour ago" | egrep -i 'oom|killed'
OOMKilled=true가 찍히면 컨테이너 메모리 제한(혹은 호스트 압박으로 인한) OOM입니다.
2단계: GitLab Runner(Docker executor) 메모리 제한 이해하기
Docker executor에서 메모리 관련으로 자주 헷갈리는 지점은 build 컨테이너, service 컨테이너, 그리고 helper 컨테이너가 각각 따로 뜬다는 점입니다.
- build: 실제 스크립트 실행
- services:
docker:dind,postgres,redis등 - helper: 아티팩트 업/다운로드 등
따라서 “빌드 컨테이너에만 메모리 늘렸는데 여전히 137”이면, 서비스(특히 DinD)나 helper가 터지는 경우도 있습니다.
3단계: runner 설정으로 build/service 메모리 상한 올리기
GitLab Runner 설정 파일(config.toml)에서 Docker executor의 리소스 제한을 조정할 수 있습니다.
일반적으로 위치는 다음 중 하나입니다.
/etc/gitlab-runner/config.toml- (Docker로 runner 실행 시) runner 컨테이너 내부의
/etc/gitlab-runner/config.toml
예시: build/service 메모리 제한 설정
[[runners]]
name = "docker-runner"
url = "https://gitlab.example.com/"
token = "***"
executor = "docker"
[runners.docker]
image = "docker:24"
privileged = true
pull_policy = "if-not-present"
# build 컨테이너 제한
memory = "6g"
memory_swap = "6g" # swap 포함 총량(환경에 따라 의미가 달라질 수 있음)
# service 컨테이너 제한
service_memory = "4g"
service_memory_swap = "4g"
# helper 컨테이너 제한(버전에 따라 지원)
helper_memory = "512m"
# OOM killer 관련
oom_kill_disable = false
적용 후에는 runner 재시작이 필요합니다.
sudo gitlab-runner restart
# 또는
sudo systemctl restart gitlab-runner
주의: memory_swap의 함정
memory="6g",memory_swap="6g"는 “스왑까지 포함해 6G”로 동작할 수 있습니다(= 사실상 스왑이 없는 것과 비슷).- 스왑을 허용하고 싶다면
memory_swap을 더 크게 잡거나, 운영 정책상 스왑을 쓰지 않는다면 swap을 0으로 보고 순수 메모리로 해결하는 게 안전합니다.
4단계: DinD(docker:dind) 사용 시 OOM이 더 잘 나는 이유와 대응
Docker-in-Docker는 빌드 중 레이어/캐시/압축 작업 때문에 메모리를 크게 먹습니다. 특히 다음 조합에서 폭발합니다.
- 멀티 스테이지 Dockerfile + 대형 의존성(Node/Java)
docker build가 병렬로 돌거나, BuildKit이 공격적으로 캐시/압축npm ci/pnpm i/yarn install이 메모리 많이 사용
대응 1) service_memory를 충분히 주기
DinD를 서비스로 띄운다면 service_memory를 넉넉히 줘야 합니다.
[runners.docker]
services = ["docker:24-dind"]
service_memory = "6g"
대응 2) BuildKit 메모리 폭주 완화
환경에 따라 BuildKit이 메모리를 더 쓰는 경우가 있어, 일시적으로 끄고 비교해볼 수 있습니다.
build:
variables:
DOCKER_BUILDKIT: "0"
script:
- docker build -t myapp:${CI_COMMIT_SHA} .
반대로 BuildKit이 더 효율적인 경우도 있으니, 끄는 것은 “원인 확인” 용도로 보고 최종은 측정 기반으로 결정하세요.
5단계: 빌드 도구별 ‘메모리 상한’ 지정 (가장 재발 방지에 효과적)
컨테이너 메모리를 늘리는 것만으로는 “가끔” 터지는 문제를 없애기 어렵습니다. 빌드 프로세스 자체에 상한을 주는 게 재발 방지에 더 강합니다.
Node.js / Next.js / Webpack
Node는 기본 힙 제한 때문에 빌드 도중 OOM이 나거나, 반대로 시스템 메모리를 과도하게 먹기도 합니다. CI에서는 명시적으로 힙을 지정하는 편이 안전합니다.
build_frontend:
image: node:20
script:
- export NODE_OPTIONS="--max-old-space-size=4096"
- npm ci
- npm run build
Next.js 빌드가 무거운 경우는 구조적인 개선도 필요합니다. 프론트 빌드가 느리고 메모리/TTFB 이슈가 엮여 있다면 Next.js 14 RSC 느림? TTFB 급증 7가지 해결에서 빌드/런타임 병목을 같이 점검해보는 것을 권합니다.
Java (Gradle/Maven)
Gradle은 데몬/병렬 빌드로 메모리를 크게 씁니다.
build_backend:
image: gradle:8-jdk17
variables:
GRADLE_OPTS: "-Dorg.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=512m"
script:
- gradle --no-daemon clean build
Go
Go 빌드 자체는 비교적 안정적이지만, 대형 모노레포에서 병렬 컴파일/링크 시 피크가 커질 수 있습니다.
# 병렬도를 낮춰 피크 메모리 완화
GOMAXPROCS=2 go build ./...
6단계: 캐시/아티팩트 전략으로 메모리 피크 줄이기
OOM은 “총 사용량”보다 “순간 피크”가 문제인 경우가 많습니다. 캐시를 잘못 쓰면 오히려 메모리/디스크 압박을 키웁니다.
- 의존성 캐시는 압축/해제 과정에서 메모리를 씁니다.
- 너무 큰 캐시는 GitLab Runner helper가 업/다운로드 중 메모리를 더 씁니다.
예시: Node 캐시를 과하게 잡지 않기
cache:
key:
files:
- package-lock.json
paths:
- .npm/
build:
script:
- npm ci --cache .npm --prefer-offline
- npm run build
node_modules/ 자체를 캐시하는 것은 프로젝트에 따라 비효율/불안정(플랫폼 차이, 바이너리 모듈 등)할 수 있으니, 가능하면 npm/pnpm store 캐시로 시작하는 편이 안전합니다.
7단계: 러너 호스트 자체의 메모리/오버커밋 점검
Docker executor는 “호스트가 여유롭다”는 전제가 있습니다. 다음 상황이면 아무리 컨테이너 제한을 조정해도 OOM이 납니다.
- 동일 러너에서 동시 작업 수(
concurrent)가 과도 - 호스트에 다른 프로세스(모니터링, 다른 CI, DB 등)가 상주
- 스왑이 완전히 비활성화되어 순간 피크에 취약
동시성(concurrent) 낮추기
/etc/gitlab-runner/config.toml 상단의 concurrent를 조정합니다.
concurrent = 2
check_interval = 0
동시성을 낮추는 것은 “근본 해결이 아니다”라고 느낄 수 있지만, 실제로는 피크 메모리를 선형으로 줄이는 가장 확실한 방법입니다.
8단계: 진단 체크리스트 (10분 컷)
- GitLab Job 로그에서 마지막으로 실행된 커맨드가 무엇인지 확인
- 호스트에서
dmesg -T | grep -i oom확인 - 컨테이너
docker inspect ... OOMKilled확인 config.toml에서memory/service_memory/helper_memory설정 확인- DinD 사용 여부 확인(사용 시 service_memory 우선 점검)
- 빌드 도구별 힙/메모리 상한 설정(Node/Java)
- 러너
concurrent와 동일 머신의 다른 워크로드 확인
결론: “메모리 증설”보다 “상한 설정 + 원인 분리”가 핵심
Exit 137은 단순 에러코드가 아니라 SIGKILL의 결과이며, 대부분 OOM과 연결됩니다. 해결의 핵심은:
- 먼저 호스트 OOM vs 컨테이너 OOM을 분리하고,
- GitLab Runner의 Docker executor에서 build/service/helper 각각의 메모리 상한을 올리며,
- Node/Gradle 같은 빌드 프로세스에는 명시적 메모리 상한을 걸어 재발을 막는 것입니다.
이 3가지만 체계적으로 적용해도 “가끔 터지는 137”을 상당히 안정적으로 제거할 수 있습니다.