System/OS

Linux 프로세스 관리 - 좀비 프로세스에 관하여 [3편]

마늘김 2025. 1. 28. 21:11

 지난 포스팅들을 통해서 리눅스에서의 프로세스 라이프사이클과 좀비 프로세스에 대해서 알아보았습니다. 특히 컨테이너 환경에서 발생할 수 있는 좀비 프로세스는 전체 시스템에 영향을 줄 수 있어 주의가 필요합니다.

 

 

Linux 프로세스 관리 - 좀비 프로세스에 관하여 [1편]

리눅스에서 지금 당장 아래 명령어를 입력해보자!$ ps aux$ pstree 아마도 리눅스를 조금 다루어 보았다면 'ps'는 상당히 익숙한 명령어일 것입니다. 잘 아시겠지만, 리눅스에서 명령어 'ps'는 현재

tech-recipe.tistory.com

 

Linux 프로세스 관리 - 좀비 프로세스에 관하여 [2편]

이번 포스팅에서는 본격적으로 리눅스 좀비 프로세스에 대해서 이야기해보고자 합니다. 앞으로 논의할 내용을 이해하기 위해서는 리눅스 프로세스에 대한 기본적인 개념을 알아야 합니다. 이

tech-recipe.tistory.com

 이번 포스팅에서는 컨테이너에서 좀비 프로세스 생성을 방지하거나, 혹은 생성 되었더라도 이를 회수할 수 있는 방법에 대해서 알아보도록 하겠습니다.


코드 레벨에서의 수정

 앞선 포스팅에서 살펴 보았던 go 코드 조각을 다시 가져와 보겠습니다.

// backend/internal/process/manager.go
func CreateZombieProcess() error {
	cmd := exec.Command("sh", "-c", `		
       trap 'wait' EXIT			# trap 주석 제거
       sleep 1000 &					
       sleep_pid=$!					
       
       sleep 2
       kill -TERM $sleep_pid		
       exit 0						
    `)
	return cmd.Run()
}

 이미 코드에 있었던 "trap 'wait' EXIT"을 추가해 주는 것입니다. 사실 이미 코드에 있었죠. 이 코드는 sh이 EXIT 신호, 즉 종료 신호를 받으면 wait() 시스템 콜을 통해 자식 프로세스의 수거를 마치도록 하는 방식입니다. 간단한 코드 수정으로 sh의 자식 프로세스에 대한 회수가 제대로 이루어지도록 한다는 점에서 상당히 장점이 있습니다.

 다만 위 방식은 sh에 한정된 방식이라는 점에서 그 한계가 있습니다. 또한 sh이 비정상적으로 종료되는 경우에는 trap이 실행되지 않을 수도 있고, 자식 프로세스의 종료를 모두 기다려 wait() 시스템 콜을 처리 해야 하므로 전체 프로세스의 종료가 지연될 가능성도 있습니다.

 

컨테이너 레벨에서의 해결책

 앞선 포스팅에서 '컨테이너 내에서 고아 프로세스가 발생하면 네임스페이스 내의 PID 1이 이를 입양한다는 것'에 착안한 방식입니다. 바로 tini와 같은 컨테이너용 경량화 init을 도입하는 방식입니다. 이를 통해 컨테이너 내의 PID 1을 tini로 지정하여 고아 프로세스가 발생하고 이 프로세스가 좀비가 되더라도 입양과 회수를 할 수 있도록 하는 것입니다. 아래 Dockerfile을 보시죠

# backend/Dockerfile_tini
FROM golang:1.23.5-alpine

WORKDIR /app

RUN apk add --no-cache procps tini

COPY . .
RUN go build -o main ./cmd/main.go

EXPOSE 8080

ENTRYPOINT ["/sbin/tini", "--"]
CMD ["./main"]

 우선 apk add를 통해 tini를 컨테이너에 설치하고, 엔트리 포인트를 /sbin/tini로 하여 PID 1이 되도록 합니다. 실제로 tini의 github 레포의 설명을 보아도 좀비 프로세스 처리에 관한 설명이 있습니다. [git repository] krallin/tiny

Tini is the simplest init you could think of.

All Tini does is spawn a single child (Tini is meant to be run in a container), and wait for it to exit all the while reaping zombies and performing signal forwarding.

 그렇다면 위 Dockerfile로 빌드된 컨테이너의 프로세스 상태를 알아 보겠습니다. ps -ef 명령어를 통해서 한번 알아보죠.

$ docker exec -it zombie-process-demo-with-tini ps -ef

docker exec -it zombie-process-demo-with-tini ps -ef
UID          PID    PPID  C STIME TTY          TIME CMD
root           1       0  0 11:37 ?        00:00:00 /sbin/tini -- ./main
root           6       1  0 11:37 ?        00:00:00 ./main
root          36       0 50 11:40 pts/0    00:00:00 ps -ef

 출력되는 정보를 보면 PID 1번이 tini로 실행되고 있는 것을 알 수 있습니다. 그리고 그 자식 프로세스로 PID 6의 go 애플리케이션 main이 실행되고 있는 것을 알 수 있습니다. 실제로 이전 포스팅에서 사용했던 api를 호출해 보면 아래와 같이 tini - main - sh - sleep 순으로 부모 - 자식 관계를 형성하고 있는것을 알 수 있습니다.

$ docker exec -it zombie-process-demo-with-tini ps -ef

UID          PID    PPID  C STIME TTY          TIME CMD
root           1       0  0 11:37 ?        00:00:00 /sbin/tini -- ./main
root           6       1  0 11:37 ?        00:00:00 ./main
root          65       6  0 11:44 ?        00:00:00 sh -c 
root          66      65  0 11:44 ?        00:00:00 sleep 1000
root          67      65  0 11:44 ?        00:00:00 sleep 2
root          68       0 99 11:44 pts/0    00:00:00 ps -ef

 다만 2초 정도가 지난 후 다시 프로세스를 확인해 보면 앞선 포스팅에서 생성되었던 좀비 프로세스인 sleep이 존재하지 않는다는 것을 알 수 있습니다. 바로 tini가 좀비 프로세스를 회수했기 때문입니다.

$ docker exec -it zombie-process-demo-with-tini ps -ef

UID          PID    PPID  C STIME TTY          TIME CMD
root           1       0  0 11:37 ?        00:00:00 /sbin/tini -- ./main
root           6       1  0 11:37 ?        00:00:00 ./main
root          75       0 50 11:46 pts/0    00:00:00 ps -ef

 이 외에도 dump-init과 같은 경량 init을 사용할 수 도 있습니다. 그러나 tini나 dump-init과 같은 것에도 단점은 있습니다. 프로세스 계층 구조에 새로운 레이어가 추가되는 점에서 프로세스 추적이 조금 더 복잡해질 수도 있고, 로그 확인과 같은 트러블 슈팅 상황이 조금 더 복잡해 질수 있습니다.

 

Docker의 '--init' 옵션 사용

 다른 방식으로는 Docker로 컨테이너 실행 시 --init 옵션을 사용하는 것입니다. 아래 출력 정보를 보면 docker-init이 PID 1로 등록되어 있는 것을 알 수 있습니다. 역시 좀비 프로세스를 생성하는 api를 호출하여도 프로세스가 더 이상 좀비로 남지 않게 됩니다.

$ docker run --init --name init-option-zombie-process-demo -p 8080:8080 zombie-process-demo
$ docker exec -it init-option-zombie-process-demo ps -ef

UID          PID    PPID  C STIME TTY          TIME CMD
root           1       0  0 11:54 ?        00:00:00 /sbin/docker-init -- ./main
root           7       1  0 11:54 ?        00:00:00 ./main
root          18       0 50 11:55 pts/0    00:00:00 ps -ef

 그러나 이는 docker에서 제공하는 옵션으로 kubernetes에서 사용하기에는 다소 어려운 점이 있습니다.


AI에게 질문

 클로드 3.5를 통해 이런 좀비 프로세스 관리에 대해서 질문해 보았습니다. 크게 두 가지 방법을 알려주었는데, 실제로 작동하는지 테스트해보지는 않았습니다. 따라서 적용에 유의를 요합니다. 우선 첫 번째 해결책은 프로세스 그룹을 통한 관리였습니다.

// Go 언어에서의 더 견고한 프로세스 관리 예시
func ImprovedProcessManagement() error {
    cmd := exec.Command("sleep", "1000")
    
    // 프로세스 그룹 설정
    cmd.SysProcAttr = &syscall.SysProcAttr{
        Setpgid: true,
    }
    
    if err := cmd.Start(); err != nil {
        return fmt.Errorf("failed to start process: %w", err)
    }
    
    // 종료 시그널 처리
    sigChan := make(chan os.Signal, 1)
    signal.Notify(sigChan, syscall.SIGTERM, syscall.SIGINT)
    
    go func() {
        <-sigChan
        // 프로세스 그룹 전체에 시그널 전송
        syscall.Kill(-cmd.Process.Pid, syscall.SIGTERM)
    }()
    
    // 프로세스 상태 관리
    if err := cmd.Wait(); err != nil {
        return fmt.Errorf("process failed: %w", err)
    }
    
    return nil
}

 두 번째 방법은 PID 1, 즉 main에서 직접 자식 프로세스를 관리를 구현하는 방법입니다. 코드는 아래와 같습니다.

// 컨테이너의 PID 1 프로세스에서의 자식 프로세스 관리
func main() {
    // SIGCHLD 시그널 처리
    sigChan := make(chan os.Signal, 1)
    signal.Notify(sigChan, syscall.SIGCHLD)
    
    go func() {
        for range sigChan {
            for {
                // 모든 종료된 자식 프로세스 처리
                if pid, err := syscall.Wait4(-1, nil, syscall.WNOHANG, nil); err != nil || pid <= 0 {
                    break
                }
            }
        }
    }()
    
    // 메인 애플리케이션 로직
    // ...
}

 다시 한번 말씀드리지만 위 코드들은 테스트를 하지 않아 실제로 잘 작동하는지 여부는 확인이 필요합니다.

 

 어쨌든, 이렇게 리눅스 프로세스 관리와 좀비 프로세스에 대해서 그 원인을 파악해 보고, 해결책까지 논의해 보았습니다. 저도 이번 시리즈 포스팅을 작성하면서 더 깊이 있게 리눅스에 대해서 이해할 수 있는 좋은 기회였던 것 같습니다. 아무쪼록 현업에 계시는 개발자 분들, 혹은 리눅스와 컨테이너를 학습하고 계신 분들께 도움이 되기를 바라면서 긴 포스팅 마치도록 하겠습니다. 감사합니다.