좀비 프로세스로 인한 트러블 슈팅기
서론
크롤러를 운영하다보면 규모 및 속도에 따라서 크게 두가지 부류로 크롤러를 운영하게 된다.
html 만을 http request 로 가져와서 Parsing 하는 경우
javascript 들이 로딩되고 실행되고 나서 데이터를 가져오기 위해 selenium 같은 헤비한 크롤러를 돌리는 경우
이 외에도 여러 방식이 더 있을 수 있지만 보통은 이 두가지 부류로 크롤링을 하게 된다고 생각한다. 첫번째 http request 의 경우 별도의 프로세스를 뛰어도 되지 않기 때문에 헤비하지 않고, 상대적으로 가벼운 http call 로 진행된다. 대부분의 경우 timeout 이 났을때의 재처리 혹은 에러 처리방안 등등만 잘 고려하면 크게 문제가 되지않는다.
두번째 방식이 selenium 과 같이 별도의 브라우저 프로세스를 뛰어야 하는 경우인데, 이 같은 경우는 프로세스를 하나 더 뛰우기 때문에 프로세스를 처리하는 정책, 몇 개의 worker 를 뛰우는게 좋은지 등등 리소스 측면에서 여러가지로 대응해야 할점이 많다. 오늘은 두번째 방식인 브라우저 프로세스로 인한 크롤러를 운영하며 겪었던 문제를 적어보려고 한다.
현상
크롤러가 초기에는 잘 돌다가 한 3시간 정도의 시간이 지나고나면 갑자기 shutdown signal 를 받고 종료 되 버렸다. 이는 크롤러의 SIGTERM 에 달려있는 핸들러의 로그로 운영체제가 이를 종료하기를 원했다는 것이다. 그래서 로그창을 확인해보니 꺼지기 5분전 마지막 CDP 에 캡쳐된 request 외에는 별다른 로그가 존재하지 않았다.
그래서 메트릭 창을 확인해보니 CPU 도 정상적이고, 메모리도 정상적이였기 때문에 문제를 찾기 어려웠다. 그러던 중 로그를 유심히 살펴보다가 [Errno 11] Resource temporarily unavailable 라는 로그를 발견했다. 보통 운영체제 수준에서 소켓 관련이나 프로세스/스레드의 제한이 Limit 을 넘게 되면 발생하는 문제로 알고 있다.
예를 들면, fork 를 통해서 새로운 프로세스를 만들때 이때 PID 테이블이 고갈되면 이러한 에러를 리턴하는 것으로 기억하고 있었다. 따라서 무언가 프로세스의 Pool 과 관련있겠구나 싶어서 이 부분과 관련된 코드를 찾아보았다.
추측
크로미움을 spawn 할때 현재 PID 에서 자식 프로세스로 spawn 하게 된다. 그때 Chromium 하위에 자식 프로세스들이 spawn 됬는데 이를 정리하지 못하는건가? 라는 생각이 들었다. 운영 환경이 Docker 로 돌아가고 있었기 때문에 Init 프로세스가 내 어플리케이션이라 프로세스를 잘 정리하지 못할 수 있겠다는 생각이 들었다.
이렇게 생각한 이유는 Go 에서 spawn 한 Chromium 은 자식프로세스로 관리하지만 해당 Chromium 이 생성한 자식들은 추적할 방안이 없기 때문이다.

우리가 실행중인 어플리케이션이 PID 가 1 인 이유는 도커 환경에서 실행되었기 때문이다. 운영또한 도커 환경에서 실행되므로 앞으로 아래 코드들은 모두 도커 환경에서 실행되었다고 생각해주면 된다.
일단 추측이 맞는지 테스트 해보기 위해서 간단하게 sh 와 sleep 을 이용해서 테스트를 진행하기로 했다.
┌─────────────────────────────────────────────────────────────────┐
│ 1. Go spawns 'sh' process │
│ zombie-check (PID 1) │
│ └─ sh (PID 10) │
│ │
│ 2. sh launches 'sleep' in background (&) and exits immediately │
│ zombie-check (PID 1) │
│ └─ sh (PID 10) │
│ └─ sleep (PID 20) ← running in background │
│ │
│ 3. sh exits, Go reaps it via cmd.Run() ✅ │
│ zombie-check (PID 1) │
│ └─ sleep (PID 20) ← ORPHAN! Kernel reparents to PID 1 │
│ │
│ 4. After 100ms, sleep exits │
│ zombie-check (PID 1) │
│ └─ sleep (PID 20) [defunct] ← ZOMBIE! 🧟 │
│ │
│ 5. Go doesn't track reparented processes │
│ → Receives SIGCHLD but ignores it │
│ → Zombie remains forever (or until dumb-init reaps it) │
└─────────────────────────────────────────────────────────────────┘
테스트 하고자 하는 방법론은 간단하다. Go 의 exec.Command 로 sh 를 spawn 하고, sh 가 sleep 을 spawn 한다. sleep 은 background 에서 진행되므로 sh 는 바로 종료되고, sleep 은 좀비 프로세스로 남게 되는지를 테스트 해보는 것이다.
func main() {
// Create 10 zombie processes
for i := 0; i < 10; i++ {
cmd := exec.Command("sh", "-c", "sleep 0.1 &")
cmd.Run()
time.Sleep(200 * time.Millisecond)
fmt.Printf("Generated zombie %d/10\n", i+1)
}
for {
time.Sleep(2 * time.Second)
}
}
이를 docker 로 실행시키고 docker exec $ID_FIXED ps -o pid,ppid,stat,comm,args 커맨드를 통해 확인하면 생성된 sh 의 자식 프로세스인 sleep 이 어떻게 처리되는지 알 수 있다.
Checking process table inside container:
PID PPID STAT COMMAND COMMAND
1 0 S zombie-check ./zombie-check
12 1 Z sleep [sleep]
14 1 Z sleep [sleep]
16 1 Z sleep [sleep]
18 1 Z sleep [sleep]
20 1 Z sleep [sleep]
22 1 Z sleep [sleep]
24 1 Z sleep [sleep]
26 1 Z sleep [sleep]
28 1 Z sleep [sleep]
30 1 Z sleep [sleep]
31 0 R ps ps -o pid,ppid,stat,comm,args
확인해보면 sleep 상태의 좀비 프로세스들이 무수히 생겨났다는걸 알 수 있습니다. 이제 도커에서 PID 1 인 제 Go application 이 자식프로세스가 죽었을때 adopt 하지 않는것을 알았으니 실제 크롤러를 로컬 환경에서 장시간 돌려 확인해보도록 하겠습니다.
재현
일단 예전 크롤러 파일을 뛰우고 크롤러를 뛰운 다음에 Zombie Process 가 계속해서 증가하는지 모니터링 해보도록 하겠습니다. (해당 스크립트는 Claude code 와 함께 작성하였습니다)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
📊 Statistics
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
🧟 Zombie processes: 45 ⚠️ Warning
📦 Total processes: 75
🆔 PID usage: 78 / 99999 (0.1%)
실제로 확인해보니 계속해서 Zombie Process 가 종료되지 않고 늘지 않는 것을 확인할 수 있습니다.
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
🔍 Zombie Processes (showing up to 10)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
PID PPID STAT COMMAND
──── ──── ──── ───────
55 1 Z chromium
56 1 Z chromium
57 1 Z chromium
58 1 Z chromium
60 1 Z chromium
61 1 Z chromium
67 1 Z chromium
70 1 Z chromium
169 1 Z chromium
295 1 Z chromium
실제로 확인해보면 크로미움 커맨드로 실행된 Process 들이며 PPID 는 1로 가지고 있습니다. 여기서 어 진짜 자식프로세스인가 의문이 들어 실제로 프로세스를 한번 커맨드로 확인해보았습니다.
1685 1679 S /usr/lib/chromium/chromium --type=zygote --no-zygote-sandbox --no-sandbox --headless --headless
1686 1679 S /usr/lib/chromium/chromium --type=zygote --no-sandbox --headless --headless
1701 1679 S /usr/lib/chromium/chromium --type=utility --utility-sub-type=network.mojom.NetworkService
1718 1686 S /usr/lib/chromium/chromium --type=renderer --headless --no-sandbox --disable-dev-shm-usage
1738 1685 S /usr/lib/chromium/chromium --type=gpu-process --no-sandbox --disable-dev-shm-usage
확인해보니 renderer 나 gpu-process 등 chromium 의 하위 프로세스로 생성된 자식들임을 확인 할 수 있습니다. 이러한 프로세스 들이 부모 chromium 이 죽어서 PID 1 로 입양 되었지만, 실제로 Go 에서는 이를 정리하지 않기 때문에 정리가 되고 있지 않던 것이 였습니다.
수정
sigChan := make(chan os.Signal, 10)
signal.Notify(sigChan, syscall.SIGCHLD)
go func() {
for range sigChan {
// 모든 종료된 자식 reap
for {
var status syscall.WaitStatus
pid, err := syscall.Wait4(-1, &status, syscall.WNOHANG, nil)
if pid <= 0 || err != nil {
break
}
log.Debug().Int("pid", pid).Msg("Reaped zombie")
}
}
}()
이러한 에러를 수정하기 위해서는 어떠한 방법이 있을지 고민해보다가 1차원적으로는 위와 같은 Go 코드를 짤 방법을 생각해보았습니다. 하지만, 뭔가 Dockerfile 이 아닐때 실행해도 잘 될까? 무언가 좀 보장하기 어렵게 만드는거 같다는 생각이 들었고, 동시성 이슈는 없을까..? 등등 조금 부족한 OS 지식으로 이러한 코드를 작성하고 안전하다고 하기에는 무리가 있다는 생각이 들었습니다.
그래서 검색을 해보니 이미 유명한 이슈였고, 해결하는 방법으로 dumb-init 이라는 프로세스가 존재했습니다. dumb-init 은 아주 간단하게 커맨드의 앞에 작성해주고 뒤에 실행하고 싶은 executable 한 커맨드를 넘겨주면 됩니다.
테스트
이제 dumb-init 으로 실행된 도커파일을 monitoring 툴을 통해서 감지해보자.
📍 Stage 1/4: Initial crawling (0-10min)
Expected: 0-10 zombies
💡 Tip: Open another terminal and run:
docker logs -f crawler-fixed-test
Press Ctrl+C to stop monitoring...
╔════════════════════════════════════════════════════════════════╗
║ 🧟 Zombie Process Monitor - Bug Reproduction Test ║
╚════════════════════════════════════════════════════════════════╝
📅 Time: 2026-01-19 15:46:32
⏱️ Elapsed: 0h 0m 10s
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
📊 Statistics
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
🧟 Zombie processes: 0 ✅ Normal
📦 Total processes: 32
🆔 PID usage: 35 / 99999 (0.0%)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
🔍 Zombie Processes (showing up to 10)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
None yet - system is clean ✨
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
📍 Stage 1/4: Initial crawling (0-10min)
Expected: 0-10 zombies
💡 Tip: Open another terminal and run:
docker logs -f crawler-fixed-test
오래 켜놓아도 프로세스가 32에서 증가하거나 줄어들지 않는 것을 확인할 수 있다. 이제 함께 실행한 기존에 버그가 있던 코드로 실행된 도커를 확인해보자.
📊 Statistics
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
🧟 Zombie processes: 43 ⚠️ Warning
📦 Total processes: 73
🆔 PID usage: 76 / 99999 (0.1%)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
🔍 Zombie Processes (showing up to 10)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
PID PPID STAT COMMAND
──── ──── ──── ───────
46 1 Z chromium
47 1 Z chromium
49 1 Z chromium
50 1 Z chromium
60 1 Z chromium
62 1 Z chromium
103 1 Z chromium
106 1 Z chromium
156 1 Z chromium
375 1 Z chromium
위와 같이 기존 코드는 좀비 프로세스가 잘 정리되지 않는 것을 확인해볼 수 있다.
마치며
이번 트러블 슈팅은 OS 적 지식이 도움이 좀 많이 되었던거 같다. OS 지식 기반으로 Claude 와 함께 추론하여 버그를 찾았는데 시각화 하는 과정에서 꽤 많은 도움을 많이 받았다. 이제 일단 fix 는 해두었으니 이를 모니터링 수단과 함께 연동할 방법을 찾아봐야겠다.

