
서비스 · 프로세스 관리 08 / 50 · Part 1. 프로세스 기초
실습 환경: Rocky Linux 9.8 · Ubuntu 24.04.5 (systemd로 부팅한 Docker 격리 컨테이너, 테스트 계정analyst)
"서버에 좀비 프로세스가 수백 개 있는데 kill -9로도 안 지워집니다." 운영 현장에서 자주 듣는 질문이다. 좀비는 이미 죽은 프로세스이기 때문에 죽일 수 없다. 해결책은 좀비가 아니라 그 부모 에 있다.
이번 글에서는 04편에서 잠깐 본 좀비 프로세스를 직접 만들어 정리하고, 반대 상황인 고아 프로세스 가 어떻게 PID 1에 입양되는지 확인한다. 고아화는 공격 도구가 실행 출처를 숨기는 데 쓰는 기법이기도 해서 관제 관점에서도 중요하다.
| 구분 | 좀비 (Zombie, Z) | 고아 (Orphan) |
|---|---|---|
| 상황 | 자식이 종료됐는데 부모가 wait()하지 않음 | 부모가 먼저 종료되고 자식은 계속 실행 |
| 프로세스 상태 | 이미 종료됨 (코드·메모리 해제) | 정상 실행 중 |
| 남은 것 | PID와 종료 상태(프로세스 테이블 항목) | 프로세스 전체 |
| 표시 | Z, <defunct>, 이름이 [ ]로 감싸짐 | PPID가 1(또는 subreaper) |
| kill로 제거 | 불가 | 가능 |
| 해결 | 부모가 wait하게 하거나 부모를 종료 | 필요 없음 (PID 1이 관리) |
자식이 끝나면 부모는 "어떻게 끝났는지(종료 코드, 시그널)"를 알아야 한다. 셸이 $?로 종료 코드를 보여 줄 수 있는 것도 이 덕분이다. 커널은 부모가 wait()로 이 정보를 가져갈 때까지 최소한의 기록만 남겨 두는데, 그 상태가 좀비다. 부모가 정상적으로 wait하면 좀비는 순간적으로만 존재한다.
PID 1 대신 고아를 입양하도록 지정된 프로세스를 subreaper 라고 한다(prctl(PR_SET_CHILD_SUBREAPER)). 사용자별 systemd --user, 컨테이너 런타임, 일부 슈퍼바이저가 이 역할을 한다. 그래서 환경에 따라 고아의 PPID가 1이 아닐 수 있다.

[좀비]
부모 ──fork──► 자식 ──exit()──► Z (종료 정보만 남음)
부모가 wait() 하지 않음 → Z 유지
부모 종료 → 좀비가 PID 1 의 자식으로 넘어감 → PID 1 이 wait() → 소멸
[고아]
부모 ──fork──► 자식 (실행 중)
부모 exit() → 커널이 자식의 PPID 를 PID 1 (또는 subreaper) 로 변경
자식 종료 시 PID 1 이 wait() 로 회수 → 좀비가 남지 않음
두 상황의 결말이 모두 PID 1 로 이어진다는 점이 핵심이다. PID 1(systemd)은 입양한 자식을 항상 wait하도록 만들어져 있다.
# 1) 좀비 3개 만들기: 자식은 즉시 종료, 부모는 wait 없이 잠만 잔다
python3 -c 'import os,time
for i in range(3):
if os.fork()==0: os._exit(i)
time.sleep(120)' &
# 2) 부모와 좀비 확인
ps -o pid,ppid,stat,cmd --ppid $PARENT -p $PARENT
ps -eo pid,ppid,stat,cmd | awk '$3 ~ /^Z/'
# 3) 좀비에 kill -9 → 변화 없음
kill -9 $(ps -eo pid=,stat= | awk '$2 ~ /^Z/{print $1}')
# 4) 부모를 종료 → 좀비가 사라짐
kill $PARENT; ps -eo pid,ppid,stat,cmd | awk '$3 ~ /^Z/' | wc -l
# 5) (Ubuntu) 고아 만들기: 부모(bash -c)가 먼저 끝난다
bash -c 'sleep 300 & echo "child PID=$! parent(bash -c) PID=$$"; sleep 1'
ps -o pid,ppid,stat,cmd -p $C
pstree -ps $C
grep -E '^(PPid|NSpid)' /proc/$C/status


텍스트 원본(실제 출력):
[analyst@rocky9-lab ~]$ python3 -c 'import os,time
> for i in range(3):
> if os.fork()==0: os._exit(i)
> time.sleep(120)' &
[analyst@rocky9-lab ~]$ sleep 1; PARENT=$!; ps -o pid,ppid,stat,cmd --ppid $PARENT -p $PARENT
PID PPID STAT CMD
356 333 S+ python3 -c import os,time for i in range(3): if os.fork()==0: os._exit(i) time.sleep(
358 356 Z+ [python3] <defunct>
359 356 Z+ [python3] <defunct>
360 356 Z+ [python3] <defunct>
[analyst@rocky9-lab ~]$ ps -eo pid,ppid,stat,cmd | awk '$3 ~ /^Z/'
358 356 Z+ [python3] <defunct>
359 356 Z+ [python3] <defunct>
360 356 Z+ [python3] <defunct>
[analyst@rocky9-lab ~]$ kill -9 $(ps -eo pid=,stat= | awk '$2 ~ /^Z/{print $1}'); sleep 0.5; ps -eo pid,ppid,stat,cmd | awk '$3 ~ /^Z/'
358 356 Z+ [python3] <defunct>
359 356 Z+ [python3] <defunct>
360 356 Z+ [python3] <defunct>
[analyst@rocky9-lab ~]$ kill $PARENT; sleep 0.5; ps -eo pid,ppid,stat,cmd | awk '$3 ~ /^Z/' | wc -l
0
analyst@ubuntu-lab:~$ bash -c 'sleep 300 & echo "child PID=$! parent(bash -c) PID=$$"; sleep 1'
child PID=341 parent(bash -c) PID=340
analyst@ubuntu-lab:~$ C=$(pgrep -nx sleep); ps -o pid,ppid,stat,cmd -p $C
PID PPID STAT CMD
341 1 S+ sleep 300
analyst@ubuntu-lab:~$ pstree -ps $C
systemd(1)───sleep(341)
analyst@ubuntu-lab:~$ grep -E '^(PPid|NSpid)' /proc/$C/status
PPid: 1
NSpid: 341
analyst@ubuntu-lab:~$ kill $C
| 관찰 | 의미 |
|---|---|
부모 356 S+, 자식 358·359·360 Z+ [python3] <defunct> | 자식 3개가 종료했지만 부모가 회수하지 않아 좀비가 되었다 |
| 좀비의 PPID가 모두 356 | 좀비의 원인은 PPID가 가리키는 부모 다. 조사 대상은 356이다 |
kill -9 후에도 좀비 3개 그대로 | 이미 종료된 프로세스는 시그널을 처리할 주체가 없다 |
| 부모 종료 후 좀비 수 0 | 좀비가 PID 1로 넘어가 즉시 회수되었다 |
| Ubuntu: child 341, parent 340 | bash -c가 sleep을 백그라운드로 띄우고 1초 뒤 종료했다 |
sleep 341의 PPID 1 | 부모가 사라져 PID 1에 입양된 고아 다. 상태는 S+로 정상 실행 중이다 |
pstree -ps → systemd(1)───sleep(341) | 원래 부모(bash → sshd)와의 관계가 트리에서 완전히 사라졌다 |
NSpid: 341 | PID 네임스페이스가 하나라 번호가 하나만 보인다. 컨테이너라면 여러 값이 나온다 |
이 실습 환경에서는 SSH 세션의 고아가 PID 1에 입양되었다. 데스크톱 세션처럼
systemd --user가 subreaper로 동작하는 환경에서는 PPID가 사용자 systemd의 PID로 보일 수 있다.
| 주제 | 내용 |
|---|---|
| 출처 은닉 | 공격자는 nohup cmd & 후 로그아웃하거나, 이중 fork(19편)로 일부러 고아가 되어 트리에서 웹 서버·셸과의 연결을 끊는다. 결과만 보면 "systemd가 실행한 프로세스"처럼 보인다 |
| 출처 복원 | 고아가 된 뒤에는 PPID로 추적할 수 없다. auditd execve 로그(당시 ppid), 세션 ID(/proc/PID/sessionid), 사용자·시작 시각, cgroup(어느 서비스·세션 소속인지)으로 복원한다 |
| 좀비 누적 = DoS | 좀비는 PID를 점유한다. 버그 있는 서비스가 좀비를 계속 만들면 pid_max나 TasksMax에 도달해 새 프로세스를 만들 수 없게 된다 |
| 컨테이너 PID 1 | 컨테이너에서 PID 1이 일반 애플리케이션이면 입양한 고아를 wait하지 않아 좀비가 쌓인다. tini 같은 init을 쓰는 이유다 |
[Detection] 사용자 계정 프로세스인데 PPID=1, TTY=?, 시작 시각이 새벽
↓
[확인] ps -o pid,ppid,sid,user,lstart,tty,args -ww -p <PID>
cat /proc/<PID>/cgroup ← 어느 session-N.scope 소속이었나
cat /proc/<PID>/sessionid ← 로그인 세션 번호
↓
[상관 분석] journalctl _SYSTEMD_UNIT=sshd.service 에서 같은 세션 로그인 기록
last / wtmp 에서 해당 시각 로그인 IP
↓
[판단] 로그인 → 백그라운드 실행 → 로그아웃 패턴 = 지속 실행 의도
↓
[Response] 프로세스 증거 수집 후 종료, 계정 비밀번호·키 점검
| 신호 | 해석 |
|---|---|
| 좀비 다수 + 같은 PPID | 해당 부모 서비스의 버그. 재시작 대상 |
| 좀비의 부모가 모르는 프로세스 | 악성 도구가 자식을 대량 생성 중일 수 있다 |
| 사용자 프로세스 PPID 1 + TTY 없음 | 데몬화·고아화. 정상 서비스인지 목록과 대조 |
| 실수 | 결과 | 예방 |
|---|---|---|
좀비에 kill -9 반복 | 효과 없음 | PPID 확인 → 부모 처리 |
| 부모가 중요 서비스인데 바로 kill | 서비스 중단 | 좀비 수가 적으면 영향이 거의 없다. 점검 시간에 재시작 |
| PPID 1이면 "시스템 프로세스"라고 판단 | 고아화된 악성 프로세스를 놓침 | 사용자·exe·cgroup·세션으로 확인 |
| 컨테이너 PID 1에 앱 직접 실행 | 좀비 누적 | init 프로세스(tini, --init) 사용 |
[ ] wait 하지 않는 부모로 좀비 3개를 만들었다
[ ] 좀비의 PPID 로 원인 부모를 찾았다
[ ] kill -9 로 좀비가 사라지지 않는 것을 확인했다
[ ] 부모 종료 후 좀비가 회수되는 것을 확인했다
[ ] 부모가 먼저 끝난 고아의 PPID 가 1 이 되는 것을 확인했다
[ ] 고아화가 출처 은닉에 쓰이는 이유를 설명할 수 있다
다음 글 「09. 프로세스 우선순위 (nice·renice)」 에서는 CPU를 여러 프로세스가 나눠 쓸 때 누가 더 많이 받는지를 정하는 nice 값을 다룬다. 같은 CPU 코어에서 nice 0과 nice 19 프로세스를 경쟁시켜 실제 CPU 배분 차이 를 측정하고, 일반 사용자가 우선순위를 올릴 수 없는 이유를 확인한다.