Skip to main content

Command Palette

Search for a command to run...

좀비 프로세스로 인한 트러블 슈팅기

Updated
6 min read

서론

크롤러를 운영하다보면 규모 및 속도에 따라서 크게 두가지 부류로 크롤러를 운영하게 된다.

  • 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 인 이유는 도커 환경에서 실행되었기 때문이다. 운영또한 도커 환경에서 실행되므로 앞으로 아래 코드들은 모두 도커 환경에서 실행되었다고 생각해주면 된다.

일단 추측이 맞는지 테스트 해보기 위해서 간단하게 shsleep 을 이용해서 테스트를 진행하기로 했다.

┌─────────────────────────────────────────────────────────────────┐
│ 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 는 해두었으니 이를 모니터링 수단과 함께 연동할 방법을 찾아봐야겠다.

29 views

More from this blog

RDB 에서 큰 컬럼을 인덱스로 잡으면 안되는 이유

B-Tree 는 기본적으로 페이지 사이즈 와 저장할 수 있는 원소의 개수를 고정값으로 사용한다. 하지만 우리가 실제로 페이지에 저장하는 값은 가변적인 크기를 가지고 있기 때문에 필연적으로 물리적으로 저장해야할 개수가 다 차기도 전에 페이지가 넘치는 상황에 부딪히게 된다. 예를 들어 100KB 를 저장하는 페이지에 위와 같이 데이터를 저장한 상태이다. 여

Feb 26, 20262 min read49

Slotted Page

데이터베이스와 관련된 기술을 보다보면 어떻게 데이터를 관리하고 저장하지? 특히 단편화(Fragmentation) 이 일어나는 것을 어떻게 통제하고 관리할까? 혹은 정렬된 자료구조 내부에서 데이터의 순서를 보존하기 위해 어떠한 행위들을 할까? 궁금해집니다. 오늘은 조금 더 데이터베이스 내부에 쓰이는 자료구조를 들여다보며 연관된 행위를 공부해보려고 합니다. F

Feb 22, 20264 min read63
Slotted Page

MCP 를 통한 workflow 자동화

AI native 최근에 LinkedIn 이나 여러 소셜 플랫폼들의 글을 보면 AI native 회사 라는 워딩들이 많이 보입니다. IBM 의 정의에 따르면 AI native 를 아래와 같이 정의한다고 하는데요. “AI를 사고와 업무 방식에 끊임없이 내재화하는 상태” 그렇다면 팀원들이 계속해서 AI 를 사고와 업무 방식에 끊임 없이 내재화 하려면 어떻게 해야할까요? 개발자들은 이미 Claude code 나 Codex 등 여러 AI Tool...

Feb 14, 20263 min read100

파이썬 톺아보기 2화 - Ast 와 바이트코드

식(Expression) 과 문장(Statement) 프로그래밍을 공부하다보면 위 두 단어를 반드시 마주하게 된다. 가끔 헷갈려하는 경우가 많은데 오늘은 python 에서 기본 모듈인 ast 모듈을 공부하며 이를 알아보도록 하자. 식(Expression) 기본적으로 식(Expression) 이란 평가되면 값이 나오는 코드 조각을 뜻한다. 파이썬에서는 어떠한 부분들이 있을까? 노드 타입설명예시 BinOp이항 연산a + b, x * y...

Feb 6, 20267 min read30
D

dev_roach

41 posts