좀비 프로세스와 고아 프로세스
자식 프로세스 종료 후의 수거 문제
자식 프로세스가 종료되면 커널은 부모가 wait()로 종료 상태를 수거할 때까지 프로세스 테이블에 항목을 남겨두는데, 이것이 좀비 프로세스다. 반대로 부모가 먼저 죽으면 자식은 고아가 되어 PID 1(init)에게 입양된다. 프로세스를 종료시키는 시그널은 SIGINT(Ctrl+C), SIGTERM(kill, docker stop), SIGKILL(강제 사살)로 나뉘며, SIGKILL만 프로세스가 무시할 수 없다.
개념
자식 프로세스가 끝나면 완전히 사라질 것 같지만 바로 안 사라진다. 커널이 프로세스 테이블에 "PID 1234, 종료 코드 0" 같은 메타데이터를 남겨두고, 부모가 wait()으로 수거해가길 기다린다. 메모리나 CPU 같은 무거운 자원은 이미 반납된 상태고, 이름표만 벽에 붙어있는 셈이다. wait()가 이름표를 떼는 역할이다.
좀비 프로세스
자식이 끝났는데 부모가 wait()를 안 부른 경우. 프로세스 테이블에 항목이 남아있는 상태가 좀비 프로세스다. ps로 보면 상태가 Z(zombie)로 뜬다.
좀비 하나는 이름표 하나라 별거 아닌데, 부모가 자식을 계속 만들면서 wait()를 안 하면 좀비가 쌓인다. 프로세스 테이블에 빈자리가 없어지면 새 프로세스를 못 만드는 상황이 온다.
고아 프로세스
자식이 아직 실행 중인데 부모가 먼저 죽은 경우. 이 자식이 고아 프로세스다.
고아를 방치하면 나중에 종료됐을 때 아무도 wait()를 해줄 수 없다. 그래서 커널이 PID 1(init/systemd) 에게 고아를 입양시킨다. PID 1은 OS 부팅 시 가장 먼저 만들어지는 프로세스이고, 입양된 자식이 끝나면 대신 wait()로 수거해준다.
Docker 컨테이너에서도 같은 문제가 있다. 컨테이너의 PID 1이 좀비 수거를 제대로 안 하면 좀비가 쌓이기 때문에, tini 같은 init 프로세스를 넣거나 --init 플래그를 쓴다.
시그널
프로세스를 종료시키는 방법이 시그널이다. 커널이나 다른 프로세스가 특정 프로세스에게 보내는 알림으로, "종료해", "강제로 죽어" 같은 신호다.
| 시그널 | 언제 발생 | 프로세스가 할 수 있는 것 |
|---|---|---|
| SIGINT | Ctrl+C |
받아서 정리하고 종료 가능 |
| SIGTERM | kill PID, docker stop |
받아서 정리하고 종료 가능 |
| SIGKILL | kill -9 PID |
무시 불가, 즉시 사망 |
SIGINT와 SIGTERM은 둘 다 프로세스가 받아서 정리할 수 있다는 점에서 같다. 차이는 보내는 주체다.
- SIGINT — 사용자가 키보드로 직접 (
Ctrl+C) - SIGTERM — 다른 프로세스나 시스템이 보냄 (
docker stop, 배포 시 프로세스 교체 등)
SIGKILL은 커널이 프로세스를 즉시 제거한다. 코드를 한 줄도 실행할 수 없어서 정리할 기회가 없다. 프로세스가 먹통일 때의 최후 수단이다.
Graceful Shutdown
SIGTERM을 받았을 때 "진행 중인 요청 마무리 → DB 연결 끊기 → 종료"처럼 깔끔하게 종료하는 것을 graceful shutdown이라고 한다.
서버를 배포할 때 기존 프로세스를 새 버전으로 교체하는 과정은 실제로 "기존 컨테이너에 SIGTERM 보내서 죽이고, 새 컨테이너를 띄우는 것"이다. 기존 프로세스 입장에서는 갑자기 SIGTERM이 날아오는 것이고, 이때 graceful shutdown을 제대로 구현해야 사용자가 에러를 안 본다.
docker stop의 동작도 같은 구조다.
- 컨테이너에 SIGTERM 전송
- 10초 대기
- 안 죽으면 SIGKILL로 강제 종료