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

 

 

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
                }
            }
        }
    }()
    
    // 메인 애플리케이션 로직
    // ...
}

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

 

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

리눅스에서 지금 당장 아래 명령어를 입력해보자!

$ ps aux
$ pstree

 아마도 리눅스를 조금 다루어 보았다면 'ps'는 상당히 익숙한 명령어일 것입니다. 잘 아시겠지만, 리눅스에서 명령어 'ps'는 현재 프로세스의 상태를 출력해주죠. 이번 포스팅에서는 리눅스 프로세스에 대해서 한번 다뤄보려고 합니다. 특히 그 중에도 부모 - 자식 프로세스간의 관계좀비 프로세스에 관해서 말이죠.


Linux의 PID 1 - init, 그리고 systemd

 리눅스의 첫 번째 user space(사용자 공간) 프로세스인 init커널 부팅 후 첫 번째로 실행되는 프로세스입니다. 일반적으로 리눅스의 모든 프로세스들은 하나의 부모 프로세스를 가지게 되는데, 특이하게도 이 init은 부모 프로세스가 없습니다. 그 이유는 init이 최초의 프로세스이기 때문이죠. 게다가 init은 직, 간접적으로 다른 프로세스들의 부모 프로세스가 됩니다. 따라서 이 최초의 프로세스 init은 PID(Process ID) 1번을 부여받게 됩니다.
 PID 1은 절대로 종료되어서는 안 되며, 시스템이 실행되는 동안 항상 존재해야 합니다. 또한 시스템 재시작 없이는 이 PID 1을 교체할 수도 없죠. PID 1이 종료된다면 커널은 시스템을 종료할 것입니다.
 
 그런데, 사실 현대의 리눅스PID 1로 init이 아닌 systemd를 사용합니다. 자 한번 아래 명령어들을 따라서 입력하고 그 출력을 살펴보시죠.

$ ps aux

USER         PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
root           1  0.0  0.2 166424 11776 ?        Ss   00:45   0:01 /sbin/init
root           2  0.0  0.0      0     0 ?        S    00:45   0:00 [kthreadd]
root           3  0.0  0.0      0     0 ?        I<   00:45   0:00 [rcu_gp]
root           4  0.0  0.0      0     0 ?        I<   00:45   0:00 [rcu_par_gp]
root           5  0.0  0.0      0     0 ?        I<   00:45   0:00 [slub_flushwq]
root           6  0.0  0.0      0     0 ?        I<   00:45   0:00 [netns]

 참고로 이번 포스팅에 사용되는 리눅스는 Ubuntu 22.04입니다. 어? 그런데 이상하죠? 방금 전에 제가 분명 PID 1은 init이 아닌 systemd를 사용한다고 했는데, 'ps aux' 명령어의 출력을 살펴보면 PID 1의 COMMAND가 init인 것을 알 수 있습니다. 이상하군요? 자 그럼... 다음 명령어를 한번 입력해 봅시다.

$ top

top - 16:22:33 up 15:37,  1 user,  load average: 0.00, 0.00, 0.00
Tasks: 212 total,   1 running, 211 sleeping,   0 stopped,   0 zombie
%Cpu(s):  0.0 us,  0.3 sy,  0.0 ni, 99.7 id,  0.0 wa,  0.0 hi,  0.0 si,  0.0 st
MiB Mem :   3911.9 total,   2196.0 free,    286.8 used,   1429.1 buff/cache
MiB Swap:   3911.0 total,   3911.0 free,      0.0 used.   3368.2 avail Mem 

    PID USER      PR  NI    VIRT    RES    SHR S  %CPU  %MEM     TIME+ COMMAND                                     
      1 root      20   0  166424  11776   8404 S   0.0   0.3   0:01.79 systemd                                     
      2 root      20   0       0      0      0 S   0.0   0.0   0:00.01 kthreadd                                    
      3 root       0 -20       0      0      0 I   0.0   0.0   0:00.00 rcu_gp                                      
      4 root       0 -20       0      0      0 I   0.0   0.0   0:00.00 rcu_par_gp                                  
      5 root       0 -20       0      0      0 I   0.0   0.0   0:00.00 slub_flushwq

 'top' 명령어는 전반적은 시스템의 상태를 확인할 수 있는 명령어입니다. 오! 그런데 이번에는 PID 1번의 COMMAND가 systemd라고 표시되어 있습니다. 도대체 무엇이 진실일까요? 이를 알아보기 위해 다음 명령어를 입력해 봅시다.

$ ls -l /sbin/init

lrwxrwxrwx 1 root root 20 Nov 21  2023 /sbin/init -> /lib/systemd/systemd

 네 맞습니다. 보시는 바와 같이 init은 심볼릭 링크이며 systemd를 가리키고 있음을 확인할 수 있습니다. 앞서 말씀드린 대로 현대 리눅스의 PID 1은 systemd가 차지하고 있습니다. systemd는 기존 init 시스템의 한계를 극복하기 위해 2010년에 개발된 새로운 init 시스템입니다. 다만, 기존 init 기반 스크립트와의 호환성 유지와 같은 측면에서 심볼릭 링크를 사용하는 형태를 하고 있었던 것입니다. 언젠가는 init과 systemd의 차이점을 다뤄보도록 하겠습니다.
 
 마지막으로 명령어 'pstree -p'를 입력해서 출력되는 내용을 살펴보죠. 이 명령어는 프로세스 간의 계층 구조를 시각적으로 나타내줍니다.

$ pstree -p

systemd(1)─┬─ModemManager(830)─┬─{ModemManager}(832)
           │                   └─{ModemManager}(843)
           ├─VGAuthService(742)
           ├─agetty(1027)
           ├─containerd(796)─┬─{containerd}(833)
           │                 ├─{containerd}(834)
           │                 ├─{containerd}(835)
           │                 ├─{containerd}(836)
           │                 ├─{containerd}(840)
           │                 ├─{containerd}(860)
           │                 └─{containerd}(2478)

 위와 같이 PID 1인 systemd를 시작으로 여러 프로세스가 계층적 구조를 이루고 있는 것을 알 수 있습니다. 여기서 systemd와 직접적으로 연결된 프로세스들은 모두 systemd의 자식 프로세스들이고, 당연히 systemd는 이들의 부모 프로세스가 됩니다.
 
 containerd(796)을 봅시다. contianerd는 systemd의 자식 프로세스이기도 하면서, 그 아래 여러 {containerd}들의 부모 프로세스이기도 합니다. systemd(1)의 입장에서 {containerd} (833)와 같은 프로세스를 손자 프로세스라고 부르기도 합니다. 리눅스에서는 이렇게 프로세스들이 서로 계층적인 구조를 지니면서 작동하고 있습니다.
 

프로세스 생명 주기와 회수 책임

 이제부터 본격적인 이야기를 하려고 합니다. 바로 프로세스 생명 주기회수 책임입니다. 아래 다이어그램을 한번 보도록 하겠습니다.

프로세스 생명 주기

 이는, 프로세스의 생명주기를 나타낸 것입니다. 프로세스의 생성, 실행, 그리고 종료 단계에서 일어나는 일들을 순차적으로 도식화한 것입니다. 한 가지 주목할 부분은 의외로 종료 단계에 많은 과정을 거친다는 점이죠. 특히 부모 프로세스는 자식 프로세스로부터 SIGCHLD 시그널을 전송받으면 wait() 시스템 콜을 통해 자식 프로세스의 종료에 대한 마지막 단계를 수행한다는 것입니다.
 즉, 이 wait() 시스템 콜을 통해 자식 프로세스에 대한 회수 책임을 다 하고 있는 것이죠. 이는 단순히, 프로세스 생명 주기에서 수행되는 단계의 수준이 아니라 부모 - 자식 프로세스라는 계층적인 구조를 가진 리눅스 운영체제의 설계 원칙이라 할 수 있습니다.
 
 그렇다면, 여기서 한 가지 질문을 해볼까요?

만약, 자식 프로세스가 종료되기 전 부모 프로세스가 먼저 종료된다면?

자식 프로세스에 대한 회수 책임을 가지고 있는 부모 프로세스가 사라졌습니다. 과연 이럴 때는 어떻게 될까요?
 
이에 대한 구체적인 이야기는 다음 시간에 마저 진행해 보겠습니다. 또한, 이러한 상황이 특히 컨테이너 환경에서는 어떤 영향을 미치는지에 대해서도 한번 알아보죠.


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

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

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

tech-recipe.tistory.com

 

+ Recent posts