컨테이너가 노드를 죽일 때: 좀비 프로세스와 PID 1 이해하기
Source: Dev.to
컨테이너가 노드를 죽일 때: 좀비 프로세스와 PID 1 이해하기
개요
컨테이너를 실행하면서 노드가 갑자기 종료되는 상황을 겪어본 적이 있나요?
대부분의 경우, 이는 PID 1 프로세스가 올바르게 신호를 처리하지 못하거나, 좀비 프로세스가 누적돼 시스템 리소스를 고갈시키기 때문입니다. 이 글에서는 이러한 현상이 왜 발생하는지, 그리고 어떻게 방지할 수 있는지 살펴보겠습니다.
1️⃣ PID 1 이란?
- PID 1은 Linux 시스템에서 init 프로세스를 의미합니다.
- 컨테이너 내부에서는 보통 애플리케이션이 직접 PID 1이 되거나,
tini,dumb-init같은 작은 init 프로세스를 앞에 두어 실행합니다. - PID 1은 시그널 전달과 자식 프로세스 정리(리퍼) 역할을 담당합니다.
핵심 포인트
- PID 1은 기본적으로 SIGTERM, SIGINT 등을 무시합니다.
- 자식 프로세스가 종료될 때
wait()를 호출하지 않으면 좀비 프로세스가 남게 됩니다.
2️⃣ 좀비 프로세스란?
# 좀비 프로세스 확인 명령
ps -ef | grep Z
- 좀비 프로세스는 실행이 끝났지만 부모 프로세스가 아직
wait()를 호출하지 않아 프로세스 테이블에 남아 있는 상태를 말합니다. - 메모리 자체는 차지하지 않지만, PID 테이블 엔트리를 차지하므로 PID가 고갈되면 새로운 프로세스를 생성할 수 없게 됩니다.
- 컨테이너 내부에서 PID 1이
wait()를 수행하지 않으면, 수백·수천 개의 좀비가 쌓여 노드 전체가 멈출 수 있습니다.
3️⃣ 컨테이너가 노드를 죽이는 시나리오
| 단계 | 설명 |
|---|---|
| 1️⃣ | 애플리케이션이 PID 1로 실행됨 |
| 2️⃣ | 애플리케이션이 자식 프로세스를 스폰하고 종료 |
| 3️⃣ | PID 1이 SIGCHLD 시그널을 무시하거나 wait()를 호출하지 않음 |
| 4️⃣ | 자식 프로세스가 좀비 상태로 남음 |
| 5️⃣ | 시간이 지나면서 좀비가 누적 → PID 테이블 포화 |
| 6️⃣ | 새로운 프로세스 생성 불가 → 노드 전체가 응답 없음 |
4️⃣ 해결 방법
✅ 1. 작은 init 프로세스 사용하기
tini,dumb-init,s6-overlay등은 PID 1 역할을 대신 수행하면서 시그널 전달과 좀비 정리를 자동으로 처리합니다.
# Dockerfile 예시
FROM node:20-alpine
RUN apk add --no-cache tini
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node", "app.js"]
✅ 2. 애플리케이션 레벨에서 wait() 구현
- Go, Python, Java 등에서 시그널 핸들러를 설정하고,
os.Wait()혹은Process.wait()를 호출해 자식 프로세스를 정리합니다.
// Go 예시
signalChan := make(chan os.Signal, 1)
signal.Notify(signalChan, syscall.SIGTERM, syscall.SIGINT)
go func() {
<-signalChan
// graceful shutdown
os.Exit(0)
}()
// 자식 프로세스 대기
for {
var ws syscall.WaitStatus
pid, err := syscall.Wait4(-1, &ws, 0, nil)
if err != nil && err != syscall.ECHILD {
log.Fatalf("wait error: %v", err)
}
if pid == 0 {
break
}
}
✅ 3. --init 플래그 활용 (Docker)
docker run --init my-image
- Docker가 자동으로
tini를 삽입해 줍니다.
✅ 4. 리소스 제한 설정
--pids-limit옵션으로 컨테이너당 허용 PID 수를 제한하면, 문제가 발생했을 때 전체 노드에 미치는 영향을 최소화할 수 있습니다.
docker run --pids-limit=100 my-image
5️⃣ 모니터링 팁
| 도구 | 사용 목적 |
|---|---|
cAdvisor | 컨테이너별 CPU/메모리/PID 사용량 시각화 |
Prometheus + node_exporter | 시스템 전체 PID 테이블 사용량 수집 |
kubectl top pod | 쿠버네티스 환경에서 pod별 리소스 사용량 확인 |
| `ps -ef | grep Z` |
마무리
컨테이너가 PID 1 역할을 제대로 수행하지 못하거나, 좀비 프로세스가 누적되면 노드 전체가 멈출 수 있습니다.
작은 init 프로세스를 도입하고, 애플리케이션 레벨에서 시그널과 wait()를 올바르게 처리한다면 이러한 위험을 크게 줄일 수 있습니다.
핵심 요약
- PID 1은 시그널 전달과 좀비 정리 책임이 있다.
- 좀비 프로세스는 PID 테이블을 고갈시켜 시스템을 마비시킨다.
tini,--init,--pids-limit등으로 예방하고, 모니터링을 통해 조기에 감지한다.
이 글은 원본 Dev.to 포스트를 한국어로 번역한 것이며, 내용의 정확성을 위해 원문을 함께 참고하시기 바랍니다.
Source: …
훅
내 경력 초기에, 컨테이너에 대한 생각을 영원히 바꿔놓은 사건을 목격했습니다. 우리는 Rocky Linux 노드에서 Kubernetes 위에 MySQL을 실행하고 있었습니다. 모든 것이 정상처럼 보였지만, 노드가 하나씩 죽기 시작했습니다. 원인은? 좀비 프로세스—수백 개가 조용히 쌓여 노드가 더 이상 감당하지 못하게 만든 것이었습니다.
이 사건은 나에게 근본적인 진실을 가르쳐 주었습니다: 컨테이너는 가벼운 VM이 아니라 단지 프로세스일 뿐이라는 것입니다.
Linux에서 프로세스가 실행을 마치면 단순히 사라지는 것이 아닙니다. 좀비 상태에 들어갑니다: 프로세스는 종료되었지만, 그 엔트리는 아직 프로세스 테이블에 남아 있습니다. 부모는 wait()를 사용해 자식의 종료 상태를 읽어야 합니다. 부모가 wait()를 호출하기 전까지, 자식은 좀비 상태로 남아 있습니다.
부모 프로세스
Parent Process
|
|--- fork() ---> Child Process
| |
| | (does work)
| |
| v
| Exits (becomes zombie)
| |
|<--- wait() ---------+
|
v
Zombie cleaned up
일반적인 Linux 시스템에서는 이것이 큰 문제가 되지 않습니다. 부모가 wait()를 호출하지 않고 사라지면, 고아가 된 자식들은 init 프로세스(PID 1)에 의해 입양되며, init은 주기적으로 이러한 좀비들을 수거합니다.
왜 컨테이너가 이 모델을 깨는가
컨테이너를 init 프로세스 없이 실행하면 애플리케이션이 PID 1이 됩니다. 전통적인 init 프로세스가 없기 때문에 이제 애플리케이션이 좀비 프로세스를 수거할 책임을 지게 됩니다.
FROM mysql:8.0
# MySQL 프로세스가 PID 1이 됨
# init 시스템으로 설계된 것이 아님
대부분의 애플리케이션—MySQL을 포함—은 init 프로세스로 설계되지 않았습니다. 이들은 고아가 된 자식 프로세스에 대해 wait()를 호출하지 않으므로, 자식 프로세스가 종료될 때 좀비가 되어 정리해줄 주체가 없습니다. 노드에서 ps aux | grep Z를 실행하면 수백 개의 좀비 MySQL 헬퍼 프로세스가 표시되는데, 이들은 죽었지만 여전히 프로세스 테이블에 항목을 차지하고 있습니다.
각 좀비는 다음을 보유합니다:
- 프로세스 테이블의 항목
- PID (PID는 유한함)
결국 PID가 부족해지거나 프로세스 테이블이 가득 차서 새로운 프로세스를 생성할 수 없게 됩니다. 노드는 불안정해지고 서비스가 충돌합니다.
해결책: Tini
해결 방법은 놀라울 정도로 간단합니다: 컨테이너용으로 설계된 적절한 init 프로세스를 사용하세요. Tini는 컨테이너 전용으로 만들어진 최소한의 init 시스템입니다. Tini는:
- PID 1로 실행됩니다
- 애플리케이션을 자식 프로세스로 실행합니다
- 시그널을 올바르게 전달합니다
wait()를 호출하여 좀비 프로세스를 정리합니다
구현
옵션 1: Dockerfile에 설치
FROM mysql:8.0
# Install tini
RUN apt-get update && apt-get install -y tini
# Set tini as entrypoint
ENTRYPOINT ["/usr/bin/tini", "--"]
# Your actual command
CMD ["mysqld"]
옵션 2: Docker의 내장 init 사용
docker run --init mysql:8.0
옵션 3: Kubernetes
apiVersion: v1
kind: Pod
spec:
containers:
- name: mysql
image: mysql:8.0
# For Kubernetes, bake tini into the image or use a base image that includes it
Kubernetes에서는 Tini와 같은 init을 이미지에 포함시키는 것이 가장 안전한 패턴입니다. 런타임 플래그에 의존하는 것은 환경 간에 이식성이 없기 때문입니다.
더 큰 교훈
이 사건은 컨테이너에 대한 나의 정신 모델에 도전을 주었습니다. 나는 컨테이너를 “가벼운 VM”—자신만의 작은 세상을 실행하는 격리된 상자—라고 생각했었습니다. 현실은 다릅니다: 컨테이너는 단지 멋진 격리(네임스페이스, cgroup)를 가진 하나의 프로세스일 뿐이며, 호스트와 커널을 공유합니다. 그 프로세스가 좀비 프로세스를 생성하거나, 메모리를 과다 사용하거나, PID를 고갈시키는 등 잘못 동작하면 호스트가 영향을 받습니다.
이를 이해하면 다음과 같은 작업 방식이 바뀝니다:
- 컨테이너 문제 디버깅
- 컨테이너 이미지 설계
- 리소스 제한 및 격리에 대한 사고 방식
빠른 참고
| 시나리오 | 무슨 일이 일어나는가 | 해결책 |
|---|---|---|
| App as PID 1, spawns children | 좀비 프로세스가 누적됨 | Use Tini or --init |
| App crashes without signal handling | 고아된 자식이 좀비가 됨 | Proper init + signal forwarding |
| Too many zombies | PID 고갈, 노드 불안정 | Prevention via init system |
- 좀비 프로세스는 정상이며, 회수되지 않을 때만 문제가 됩니다.
- 컨테이너는 기본적으로 전통적인 init을 갖고 있지 않습니다.
- 앱이 특별히 설계되지 않은 한 PID 1이 되어서는 안 됩니다.
- Tini(또는
dumb‑init)는 간단한 해결책으로 표준 관행이 되어야 합니다. - 컨테이너는 프로세스이며, 가상 머신이 아닙니다—절대 잊지 마세요.