컴퓨터 구조 운영체제 네트워크 자료구조 알고리즘 데이터베이스 내 학습
os · 17

좀비 / 고아 프로세스

고아좀비프로세스

프로세스는 종료해도 곧바로 흔적 없이 사라지지 않습니다. 부모 프로세스가 자식이 어떻게 끝났는지(정상 종료인지, 오류 코드가 무엇인지) 알 수 있어야 하기 때문에, 커널은 종료한 자식의 종료 상태를 부모가 가져갈 때까지 프로세스 테이블에 남겨 둡니다. 실행은 끝났지만 부모가 아직 종료 상태를 회수하지 않아 이렇게 기록만 남은 프로세스를 좀비 프로세스(zombie process)라고 부릅니다. 반대로 부모가 자식보다 먼저 종료해서 부모를 잃은 채 계속 실행 중인 프로세스는 고아 프로세스(orphan process)입니다.

두 이름은 비슷해 보이지만 상태가 전혀 다릅니다. 좀비는 이미 끝난 프로세스이고 고아는 아직 살아 있는 프로세스입니다. 고아는 init(PID 1)이 입양해서 종료될 때 회수해 주므로 보통 문제가 되지 않지만, 좀비는 부모가 회수하지 않으면 프로세스 테이블 항목을 계속 차지하다가 쌓이면 새 프로세스를 만들지 못하게 할 수 있습니다. 이 토픽에서는 좀비가 생기고 사라지는 과정, 고아가 입양되는 과정, 좀비가 쌓일 때의 문제를 봅니다.

핵심 개념

종료 상태 회수

부모가 wait()나 waitpid()를 호출하면 종료한 자식의 종료 상태를 받아 가고, 이때 커널이 자식의 프로세스 테이블 항목을 완전히 지웁니다. 이 회수를 reaping(수확)이라고도 부릅니다.

핵심 개념

좀비 프로세스

종료는 했지만 부모가 회수하지 않은 자식입니다. 메모리 같은 자원은 대부분 반납되었고 PID, 종료 상태, 자원 사용 정보 같은 최소한의 정보만 남습니다. ps에서는 상태 Z, defunct로 보입니다.

핵심 개념

고아 프로세스

부모가 먼저 종료되어 부모 없이 실행 중인 프로세스입니다. 커널이 이 프로세스의 부모를 init(PID 1, 보통 systemd)으로 바꿔 입양시키고, 이후 종료되면 init이 회수합니다.

핵심 개념

SIGCHLD

자식이 종료하면 커널이 부모에게 SIGCHLD 신호를 보냅니다. 부모는 이 신호를 받았을 때 핸들러에서 waitpid를 호출해 자식을 회수하는 방식을 흔히 씁니다.

핵심 개념

프로세스 테이블의 한계

프로세스 테이블의 항목과 PID는 무한하지 않습니다. 좀비가 항목을 차지한 채 쌓이면 새 프로세스를 만들 수 없게 될 수 있습니다.

핵심 개념

입양과 서브리퍼

고아의 새 부모는 기본적으로 init이지만, 리눅스에서는 서브리퍼로 지정된 조상 프로세스가 있으면 그쪽이 입양합니다. 컨테이너의 PID 1은 해당 PID namespace의 init 역할을 합니다.

좀비는 죽은 프로세스의 기록입니다 — CPU나 메모리를 거의 쓰지 않지만 프로세스 테이블 항목과 PID를 차지합니다.
좀비는 kill로 없앨 수 없습니다 — 이미 죽었기 때문에 SIGKILL을 보내도 소용이 없습니다. 부모가 회수하거나 부모가 종료되어야 사라집니다.
고아는 init이 입양합니다 — 부모를 잃은 프로세스도 계속 실행되며, 종료 시 init이 회수하므로 좀비로 남지 않습니다.
원인은 회수하지 않는 부모입니다 — 자식을 만들고 wait를 부르지 않는 오래 사는 부모(서버, 데몬)가 좀비를 쌓습니다.
이 시각화에서 확인할 것 — "좀비 프로세스" 탭에서 종료 후 회수 전까지의 상태를, "고아 프로세스" 탭에서 PPID가 1로 바뀌는 입양을, "좀비 누적" 탭에서 테이블이 차는 과정을 STEP으로 확인하세요.

좀비와 고아의 배경

커널은 종료한 자식의 상태를 부모가 나중에 가져갈 수 있도록 보관하고, 이 보관 상태가 좀비입니다. 부모가 먼저 끝나면 고아는 init(또는 서브리퍼로 지정된 조상 프로세스)이 입양합니다.

숫자로 확인하기

PPID 변화 — 부모(PID 200)가 먼저 종료하면 자식(PID 201)의 PPID가 보통 200에서 1로 바뀝니다. 프로세스 번호는 예시입니다.
좀비 누적(가정값) — 프로세스 테이블이 8칸이고 init과 부모가 2칸을 쓰면, 회수되지 않은 좀비가 6개 쌓이는 순간 남은 칸이 없어 새 프로세스를 만들 수 없습니다. 실제 한도는 시스템 설정에 따라 훨씬 큽니다.

자주 하는 오해

좀비는 자원을 많이 먹는다 — 메모리나 CPU는 거의 쓰지 않습니다. 문제는 프로세스 테이블 항목과 PID를 차지한다는 점입니다.
부모가 죽은 프로세스가 좀비다 — 부모가 먼저 죽은 쪽은 고아입니다. 좀비는 자식이 먼저 끝났는데 부모가 회수하지 않은 쪽입니다.
kill -9로 좀비를 지울 수 있다 — 이미 종료한 프로세스에는 신호가 효과가 없습니다. 부모가 wait하거나 부모가 종료되어 init이 회수해야 합니다.
고아 프로세스는 반드시 종료된다 — 고아는 입양된 뒤에도 계속 실행됩니다. 데몬이 일부러 고아가 되도록 만드는 경우도 있습니다.

면접에서 이렇게 물어요

면접 질문

좀비 프로세스란 무엇인가요?

실행은 끝났지만 부모가 wait()로 종료 상태를 회수하지 않아 프로세스 테이블에 항목이 남아 있는 프로세스입니다.

면접 질문

좀비를 없애려면?

부모가 wait()나 waitpid()로 회수하게 하거나, SIGCHLD 핸들러에서 회수하게 합니다. 부모가 종료되면 좀비는 init이 입양해 회수합니다. kill로는 없앨 수 없습니다.

면접 질문

고아 프로세스는 어떻게 처리되나요?

부모가 먼저 종료하면 init(PID 1)이 새 부모가 되어 입양하고, 이 프로세스가 종료할 때 종료 상태를 회수해 좀비가 남지 않게 합니다.

면접 질문

좀비가 많이 쌓이면 어떤 문제가 생기나요?

프로세스 테이블 항목과 PID가 소진되어 새 프로세스를 만들 수 없게 될 수 있습니다. 자원은 거의 쓰지 않아도 시스템 전체의 프로세스 생성이 막힙니다.

실제 시스템에서는

wait(2) 문서 — 좀비는 부모가 회수하기 전까지 커널 프로세스 테이블의 한 칸을 차지하고, 테이블이 차면 더 만들 수 없다고 설명합니다. SIGCHLD를 무시하도록 설정하면 종료한 자식이 좀비가 되지 않는다는 규정도 있습니다.
컨테이너 — 컨테이너의 첫 프로세스가 자식을 회수하지 않으면 컨테이너 안에 좀비가 쌓일 수 있습니다. docker run의 --init 옵션은 회수를 맡는 작은 init 프로세스를 PID 1로 넣어 줍니다.
관찰 — ps의 상태 열에서 Z로 보이는 프로세스가 좀비이고, PPID를 보면 회수하지 않는 부모를 찾을 수 있습니다.
데몬화 — 전통적인 데몬은 fork 후 부모를 종료해 자식이 보통 init에 입양되고, setsid로 제어 터미널에서 분리됩니다.

함께 보면 좋은 토픽

프로세스 — 프로세스 상태와 PCB, 프로세스 테이블이 무엇인지 봅니다.
시스템 콜 — fork, exit, wait가 시스템 콜로 호출되는 과정을 봅니다.
스레드 — 프로세스와 스레드의 종료 처리가 어떻게 다른지 비교합니다.

참고 자료

리눅스 man 페이지 — wait(2), signal(7), prctl(2), ps(1)
교재 — Silberschatz 외, "Operating System Concepts"의 프로세스 종료 절, W. Richard Stevens, "Advanced Programming in the UNIX Environment"

C — 회수하지 않는 부모가 좀비를 만든다

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>

int main(void) {
    pid_t pid = fork();
    if (pid == 0) {
        exit(0);                  /* 자식: 곧바로 종료 */
    }
    printf("child pid=%d\n", pid);
    sleep(60);                    /* 부모: wait()를 부르지 않고 60초 동안 대기 */
    /* waitpid(pid, NULL, 0);   ← 이 줄을 넣으면 좀비가 사라진다 */
}

자식은 바로 종료하지만 부모가 60초 동안 wait를 부르지 않으므로, 그동안 자식은 좀비 상태로 남습니다. 다른 터미널에서 ps로 보면 이 자식의 상태가 Z이고 defunct로 표시됩니다. 부모가 waitpid를 호출하면 커널이 종료 상태를 부모에게 넘기고 좀비 항목을 지웁니다. 부모가 sleep을 마치고 종료해도 좀비는 init에게 입양되어 회수되므로 사라집니다. kill -9로는 이 좀비를 없앨 수 없다는 점도 직접 시험해 볼 수 있습니다.

C — 좀비를 남기지 않는 두 가지 방법

/* 방법 1: SIGCHLD 핸들러에서 끝난 자식을 모두 회수 */
void on_sigchld(int sig) {
    int saved = errno;             /* waitpid가 errno를 바꿀 수 있어 보존 */
    while (waitpid(-1, NULL, WNOHANG) > 0)
        ;
    errno = saved;
}
/* main 안에서 설정 (signal.h, errno.h 필요) */
struct sigaction sa = {0};
sigemptyset(&sa.sa_mask);
sa.sa_handler = on_sigchld;
sa.sa_flags = SA_RESTART;
sigaction(SIGCHLD, &sa, NULL);

/* 방법 2: 종료 상태가 필요 없다면 SIGCHLD를 무시 */
signal(SIGCHLD, SIG_IGN);

첫 방법은 자식이 끝날 때 오는 SIGCHLD를 핸들러에서 받아 waitpid를 반복 호출합니다. 신호는 여러 개가 합쳐져 한 번만 전달될 수 있으므로 WNOHANG으로 더 회수할 자식이 없을 때까지 반복합니다. 둘째 방법은 SIGCHLD의 처리를 SIG_IGN으로 설정해, 종료한 자식이 좀비가 되지 않고 곧바로 정리되게 합니다. 대신 자식의 종료 상태는 받을 수 없습니다. 종료 상태가 필요하면 첫 방법처럼 직접 회수해야 하고, 신호 처리 방식의 세부는 플랫폼마다 다를 수 있어 서버에서는 문서를 확인합니다.

셸 · Docker · Python — 좀비 관찰과 회수

# 좀비 찾기: 상태 Z, 그 PPID가 회수하지 않는 부모
ps -eo pid,ppid,stat,cmd | awk '$3 ~ /^Z/'

# 컨테이너에서 회수를 맡는 init을 PID 1로
docker run --init myapp

# Python: 자식을 띄웠으면 wait/poll로 회수
import subprocess
p = subprocess.Popen(["sleep", "1"])
p.wait()                  # wait/poll로 회수. 부모가 호출하지 않으면 자식은 좀비로 남는 것이 일반 동작

ps의 상태 열에서 Z로 시작하는 프로세스가 좀비이며, 그 PPID가 가리키는 부모가 wait를 부르지 않는 원인입니다. 해결은 좀비를 지우는 것이 아니라 부모를 고치거나 재시작하는 것입니다. docker run의 --init 옵션은 작은 init 프로세스를 컨테이너의 PID 1로 넣어 고아와 좀비를 회수하게 합니다. 파이썬에서는 Popen으로 시작한 자식을 wait나 poll로 종료를 확인하며, 회수하지 않으면 자식은 부모가 종료할 때까지 좀비로 남는 것이 유닉스의 일반 동작입니다. awk의 작은따옴표 안 문자열은 셸에서 그대로 전달되어 3번째 열이 Z로 시작하는 줄만 출력합니다.

Q1

좀비 프로세스(Zombie Process)란?

이미 끝났는데 완전히 정리되지 않은 상태입니다.
자식 프로세스가 종료돼도 부모가 wait() 등으로 종료 상태를 읽어 가기 전까지 커널은 그 정보를 프로세스 테이블에 남겨둡니다. 이 상태의 프로세스가 좀비입니다.

Q2

좀비 프로세스가 사라지게 하는 방법은?

이미 끝난 프로세스에게 신호를 보내도 소용이 없습니다.
좀비는 이미 종료된 상태라 kill로 없앨 수 없습니다. 부모가 wait()/waitpid()로 종료 상태를 회수하면 정리되고, 부모가 종료되면 init(PID 1)이 자식들을 입양해 회수합니다.

Q3

고아 프로세스(Orphan Process)가 생긴 뒤 운영체제가 처리하는 방식은?

부모를 잃은 프로세스도 부모가 필요합니다.
부모가 먼저 종료된 프로세스는 고아가 되며, init(PID 1)이 자동으로 새 부모가 되어 입양합니다. 이후 그 프로세스가 종료되면 init이 종료 상태를 회수해 좀비가 남지 않게 합니다.