Linux 커널 심층 탐구: 좀비 프로세스와 현대 프로세스 관리
Source: Dev.to
ps aux를 입력했을 때 STAT 열에 나타나는 으스스한 “Z” 문자를 본 적이 있나요?
또는 쿠버네티스 pod에서 CrashLoopBackOff를 조사하던 중 “PID limit reached” 오류를 마주한 적이 있나요?
이 모든 현상의 원인은 좀비 프로세스입니다.
좀비 프로세스는 메모리나 CPU를 소모하지 않지만, 프로세스 ID (PID) 라는 소중한 자원을 고정시켜 두고 시스템을 떠돌며 평안히 쉬지 못합니다.
이 글에서는 프로세스가 사후에 왜 좀비가 되는지, 그리고 sub‑reaper와 Go의 os/exec 패키지와 같은 최신 메커니즘이 시스템 콜 수준에서 어떻게 이를 수확(reap)하는지 살펴보겠습니다.
1. 좀비 프로세스의 정체 – 왜 “시체”가 남는가?
Linux에서는 프로세스가 exit()을 호출해 종료되더라도 즉시 사라지지 않습니다.
커널은 부모가 “그 자식이 왜 죽었는가?” 라고 물어볼 때까지 프로세스 테이블에 최소한의 정보(PID, 종료 상태, CPU 사용량 등)를 유지합니다.
이 상태 – “죽었지만 부모에게 보고되기를 기다리는 상태” – 가 바로 좀비 프로세스입니다.
정상적인 생명 주기
부모가 wait()를 호출하면 좀비가 정리되고 사라집니다.

시스템 콜 요약표
| 시스템 콜 | 설명 |
|---|---|
wait() | 어떤 자식이든 종료될 때까지 블록합니다. |
waitpid() | 더 유연합니다 – PID를 지정하거나 비블로킹 모드(WNOHANG) 등을 사용할 수 있습니다. |
Source:
2. 좀비가 증가하는 시나리오 – 자식 방치
문제는 부모가 wait() 를 호출하지 않을 때 발생합니다.
- 부모가
SIGCHLD시그널을 무시하면(예: 시그널 핸들러가 없을 경우) 자식은 영원히 좀비 상태가 됩니다.

cgroup에 의한 PID 고갈
과거에는 시스템의 pid_max(기본값 32 768)가 상한이었습니다.
컨테이너 시대(Kubernetes)에는 PID 제한이 cgroup 수준(pids.max)에서 적용됩니다.
파드의 cgroup에 정의된 제한에 도달하면, 호스트에 여전히 사용 가능한 PID가 남아 있더라도 컨테이너는 더 이상 fork() 할 수 없으며( EAGAIN – Resource temporarily unavailable 반환) 대규모 좀비 발생이 이 할당량을 빠르게 고갈시킬 수 있습니다.
Note – cgroup은 프로세스 그룹에 대해 CPU, 메모리, PID 등 자원을 격리하고 제한하는 커널 기능입니다. 이것이 Docker와 Kubernetes에서 말하는 “리소스 제한”의 실제 의미입니다.
3. 고아 프로세스와 init의 자비 (및 서브‑리퍼)
좀비를 만든 부모 프로세스가 스스로 죽으면 어떻게 될까?
init 프로세스(PID 1)에 의한 수집
부모가 죽으면 그 자식은 고아 프로세스가 되며 자동으로 **init 프로세스(PID 1)**에게 입양됩니다.
현대의 init 시스템(예: systemd)은 입양된 자식에게 wait()를 호출해 좀비를 정리합니다.

자식 서브‑리퍼
Linux 3.4부터 프로세스는 자신을 서브‑리퍼로 선언할 수 있습니다:
prctl(PR_SET_CHILD_SUBREAPER, 1);
호출하는 프로세스는 “내 계층 내의 모든 고아 자손을 입양하겠다.” 라고 선언하는 것입니다.
Docker, containerd, 그리고 Chrome의 프로세스 관리자는 이 기능을 사용해 PID 1에 과부하를 주지 않으면서 고아 프로세스를 수집합니다.
4. 구현 패턴 피하기 / 좀비 정리
패턴 A – “Honestly Wait” (기본)
SIGCHLD 핸들러에서 waitpid(-1, &status, WNOHANG)을 반복해 모든 종료된 자식을 회수합니다.
void sigchld_handler(int sig) {
int status;
while (waitpid(-1, &status, WNOHANG) > 0) {
/* child reaped */
}
}
패턴 B – 이중 포크 (데몬화)
사용 시점: 부모가 자식으로부터 의도적으로 분리하고 싶을 때(예: 데몬 생성).
- 부모가 자식을 포크하고 즉시
wait()합니다. - 자식이 손자(실제 작업자)를 포크한 뒤
exit()합니다. - 손자는 고아가 되어
init(또는 서브‑리퍼)에게 입양되고 자동으로 회수됩니다.
주의:
systemd(Type=simple)가 관리하는 서비스에서는 이중 포크가systemd가 주 프로세스를 추적하지 못하게 할 수 있습니다. 필요한 경우에만 수동 데몬화를 사용하십시오.
패턴 C – 컨테이너에서 tini(또는 dumb‑init)
애플리케이션이 Docker 컨테이너 내부에서 PID 1로 실행될 때 기본 신호 처리와 wait()가 제대로 동작하지 않을 수 있습니다.
tini 또는 dumb‑init 같은 작은 init 시스템을 컨테이너 엔트리포인트로 사용하면 “PID 1 문제”를 해결할 수 있습니다.
FROM alpine
RUN apk add --no-cache tini
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["my‑app"]
5. 언어 심층 탐구: Go의 os/exec – 숨겨진 리핑
Go 표준 라이브러리의 os/exec 패키지는 외부 명령을 쉽게 실행할 수 있게 해 주지만, 실제 wait 시스템 콜은 사용자에게 감춰져 있습니다.
cmd.Run()이 하는 일
// Simplified internal logic of Cmd.Run()
func (c *exec.Cmd) Run() error {
if err := c.Start(); err != nil { // fork + exec
return err
}
return c.Wait() // blocks, then reaps the child
}
Start()는fork/exec를 수행합니다 (clone시스템 콜을 통해).Wait()는 자식 프로세스가 종료될 때까지 블록하고, 이후 적절한waitpid를 호출해 자식을 회수합니다.
Start()를 호출하고 이후에 Wait()를 호출하지 않으면, 자식이 종료될 때 좀비 프로세스가 됩니다. 따라서 항상 Start()와 Wait()를 짝지어 사용하거나, 두 작업을 모두 수행하는 Run()을 사용하십시오.
TL;DR
- Zombie = 부모가
wait()을 호출하지 않아 PID를 차지하고 있는 죽은 프로세스. - Orphan = 부모가 사망; 자식은
init(PID 1)이나 sub‑reaper에 의해 양육됨. - Avoid zombies by handling
SIGCHLDand always reaping children (waitpid/wait). → 좀비를 방지하려면SIGCHLD를 처리하고 항상 자식을 회수(waitpid/wait)하십시오. - In containers, use a proper init (
tini,dumb‑init) or rely on Docker’s sub‑reaper. → 컨테이너에서는 적절한 init(tini,dumb‑init)을 사용하거나 Docker의 sub‑reaper에 의존하십시오. - In Go, never forget to call
Cmd.Wait()afterCmd.Start(). → Go에서는Cmd.Start()후에 반드시Cmd.Wait()를 호출하는 것을 잊지 마세요.
Source:
cmd.Start()와 cmd.Wait()를 올바르게 사용하기 (Go)
if err := c.Start(); err != nil { // 내부적으로 clone + execve
return err
}
return c.Wait() // 여기서 wait4 시스템 콜을 호출!
함정: cmd.Start()만 사용하기
비동기 처리를 위해 cmd.Start()만 호출하고 cmd.Wait()를 잊어버리면, 자식 프로세스가 좀비가 되어 Go 프로그램이 실행되는 동안 계속 남게 됩니다.
// 💀 좀비 생성 코드
func spawnAndForget() {
cmd := exec.Command("sleep", "1")
cmd.Start() // Wait()를 호출하지 않음!
// 1초 뒤, sleep 명령이 좀비(Z) 상태가 되고,
// 이 Go 프로그램이 종료될 때까지 남아 있음.
}
Go의 가비지 컬렉터는 메모리상의 cmd 구조체를 수집할 수 있지만, OS 상의 프로세스 상태(좀비)는 수집하지 못 합니다.
Start()를 사용한다면 반드시 Wait()를 명시적으로 호출해야 합니다. 예를 들어 별도의 goroutine에서 호출할 수 있습니다.
// ✅ 올바른 비동기 실행
func spawnAsync() {
cmd := exec.Command("sleep", "1")
if err := cmd.Start(); err != nil {
return
}
// goroutine에서 수집 대기
go func() {
cmd.Wait() // 여기서 wait4가 호출되고, 좀비는 평화롭게 사라짐
}()
}
낮은 레이어에서 무슨 일이 일어날까?
Linux 환경에서 cmd.Wait()는 최종적으로 syscall.Wait4를 호출합니다. 이 함수는 커널로부터 종료 상태를 받아 ProcessState 구조체에 저장합니다.
Go 런타임은 시그널을 능숙하게 처리하지만, 외부 명령을 개별적으로 실행할 때는 개발자가 Wait 메서드를 통해 명시적으로 wait 시스템 콜을 수행해야 하는 설계입니다.
결론
좀비 프로세스가 존재하는 것은 사양상의 동작이며 버그는 아닙니다. 하지만 이를 방치하면 “자식 방치” 버그가 됩니다.
2026년 트러블슈팅에서는 ps 명령만 사용하는 것이 아니라 누가 wait를 건너뛰었는지 혹은 어떤 컨테이너가 PID 한도에 도달하려는지를 bpftrace로 모니터링해야 합니다. 프로세스 트리(pstree -p)를 추적하고, 책임을 다하지 못한 부모 프로세스를 찾아 수정하세요.