이번 포스팅에서는 본격적으로 리눅스 좀비 프로세스에 대해서 이야기해보고자 합니다. 앞으로 논의할 내용을 이해하기 위해서는 리눅스 프로세스에 대한 기본적인 개념을 알아야 합니다. 이에 대해서는 앞선 포스팅에 정리해 두었으니 참고하시면 좋을 것 같습니다.
Linux 프로세스 관리 - 좀비 프로세스에 관하여 [1편]
리눅스에서 지금 당장 아래 명령어를 입력해보자!$ ps aux$ pstree 아마도 리눅스를 조금 다루어 보았다면 'ps'는 상당히 익숙한 명령어일 것입니다. 잘 아시겠지만, 리눅스에서 명령어 'ps'는 현재
tech-recipe.tistory.com
고아 프로세스의 생성
이름에서 알 수 있듯, 고아(Orphan) 프로세스란 부모 프로세스가 자식 프로세스보다 먼저 종료되어 부모를 잃은 프로세스를 의미합니다. 하지만 이 고아 상태는 매우 일시적입니다. 기본적으로 리눅스의 커널이 고아가 된 프로세스를 감지하고 PID 1인 systemd(init) 프로세스의 자식 프로세스로 재설정 하기 때문이죠. 이 작업은 우선순위가 높은 커널 작업이기 때문에 대부분의 경우 프로세스가 다음 명령어를 실행하기 전에 이미 완료됩니다.
또한, 특별히 고아 프로세스가 부모 프로세스를 호출해야 하는 경우와 같은 상황이 아니라면, 그 자체의 작업 수행에는 크게 영향을 받지 않습니다. 그리고 앞서 말한 데로 곧바로 systemd(init)에 입양되므로 다시금 부모 - 자식 관계를 형성하며 안정적인 상태가 되죠.
좀비 프로세스?
그렇다면 좀비 프로세스는 무엇일까요? 이를 이해하기 위해서는 사실 리눅스 커널에 대해서 깊은 이해가 필요합니다. 그러나 이를 모두 설명하기에는 너무 긴 내용이므로 간략하게 이야기해 보겠습니다. 커널에는 task_struct라는 모든 프로세스의 상세한 정보를 담고 있는 구조체가 있습니다. 그리고 프로세스 테이블이라는 task_struct 구조체에 대한 색인과도 같은 요소도 존재합니다.(정확한 설명은 아니자만, 대략적으로...)
어쨌든, 프로세스는 실제로 종료되기 전 모든 리소스를 정리하고 그다음에 EXIT_ZOMBIE 상태가 됩니다. 그리고 프로세스 테이블에 PID, 종료 상태(exit status), 리소스 사용 통계 정도만 남겨두게 됩니다. 그리고 부모 프로세스가 wait() 시스템 콜을 통해 회수하기를 기다리게 되는데 프로세스 종료와 회수 직전의 상태가 바로 좀비 프로세스 상태인 것입니다.
시스템 호출을 통해 종료 과정은 완료되었지만, 여전히 프로세스 테이블에 남아있는 상태
systemd(init)의 고아 프로세스와 좀비 프로세스 핸들링
드디어 서론이 끝난 기분이군요. 본격적으로 좀비 프로세스, 그리고 특히 컨테이너 환경에서의 좀비 프로세스에 대해서 논의할 수 있게 된 것 같습니다. 고아 프로세스와 좀비 프로세스는 서로 같은 개념은 아니자만, 둘 다 모두 부모프로세스의 상태나 동작에 영향을 받습니다. 게다가 이를 컨테이너 내에서 의도적으로 잘 조작하면, 좀비 프로세스가 계속 프로세스 테이블에 남아 있도록 할 수 있습니다.
// backend/internal/process/manager.go
func CreateZombieProcess() error {
cmd := exec.Command("sh", "-c", ` # sh 프로세스가 생성됨(부모)
#trap 'wait' EXIT
sleep 1000 & # 백그라운드로 sleep 1000 실행(자식)
sleep_pid=$! # 방금 전 생성된 프로세스의 PID를 환경변수로 지정
sleep 2
kill -TERM $sleep_pid # sleep 1000(자식)에 종료 시그널 전송
exit 0 # sh(부모) 종료
`)
return cmd.Run()
}
이 go 코드 조각에서 어떤 방식으로 작동하는지 살펴보겠습니다. 로컬에서 이 go 애플리케이션을 실행하면 프로세스가 생성됩니다. 그리고 API를 통해 CreateZombieProcess를 호출하면 애플리케이션 프로세스이 자식 프로세스로 'sh'이 생성됩니다. 그리고 이 sh은 다시 'sleep 1000'이라는 자식 프로세스를 백그라운드로 실행합니다. 아래 그림을 참고하시면 될 것 같습니다.
이제 우리는 PID 201인 'sh'의 입장에서 좀 살펴 보겠습니다. sh은 go app(PID 101)의 자식 프로세스이면서, sleep 1000 &(PID 301)의 부모 프로세스이기도 합니다. sh은 sleep 1000을 백그라운드로 실행하고 2초를 기다린 후 백그라운드 프로세스에 종료 시그널을 보냅니다. 그리고 바로 'exit 0'으로 스스로를 종료시킵니다.
이 과정에서 일어나는 일들을 앞서 이야기한 프로세스 생명주기에 관한 내용을 통해 유추해 봅시다. 아마 아래와 같은 단계를 거칠 것입니다.
- 종료 신호를 받은 sleep 1000(PID 301)은 자신의 리소스를 정리하고 EXIT_ZOMBIE 상태가 됨
- 프로세스 테이블에 sleep 1000의 PID와 종료 상태, 리소스 사용량 통계만 남음
- 이때 sh(PID 201)은 자신의 자식 프로세스에 대한 회수를 수행하지 않은 채로 exit 0를 통해 종료
- PID 301은 부모가 없어지면서 좀비 이면서 고아 상태가 됨
- go app(PID 101)은 자신의 자식프로세스에 대해서만 회수 책임이 있으므로 sh의 종료만을 처리
- systemd가 PID 301이 고아 상태임을 파악하고 입양하여 고아 상태를 해소, 회수 책임이 발생
- 프로세스 상태 확인하고 wait() 시스템 콜을 통해 좀비 상태의 PID 301을 회수
이런 식으로 좀비이면서, 고아인 프로세스에 대한 안정적인 라이프사이클 관리를 하게 됩니다. 따라서 이 애플리케이션을 Linux 로컬 머신에서 작동한다면 좀비 프로세스가 생성되지 않는다는 것을 확인할 수 있습니다. 하지만 상황이 Container 내부에서 일어난다면 어떻게 될까요?
컨테이너 환경의 특수성과 좀비 프로세스의 출현
그러나 위와 같은 상황이 컨테이너 환경에 발생하면 전혀 다른 결과가 나타납니다. 다시 한번 아래 그림을 봅시다.
여기서 우리가 주목해야 할 부분은 바로 컨테이너를 위한 네임스페이스와 이로 인한 프로세스 격리입니다. 이것이 매우 중요한 이유는 고아 프로세스의 입양을 누가 하느냐가 결정되기 때문입니다. 고아 프로세스가 발생하게 되면 커널은 해당 프로세스가 속한 PID 네임스페이스를 먼저 확인합니다. 그다음 프로세스가 속한 PID 네임스페이스의 1번 프로세스가 이 고아 프로세스를 입양하게 합니다.
위 그림에서 보면 컨테이너 내부에서 PID 31 프로세스(sleep 1000 &)가 고아가 되면 컨테이너 내부의 1번 프로세스인 go app이 이를 입양하게 된다는 것이죠. 그런데 여기서 문제가 발생합니다. systemd(init)의 경우에는 이런 고아 프로세스를 입양한 후, 이 고아 프로세스가 작업을 종료하고 EXIT_ZOMBIE 상태가 되면 이를 감지하고 wait() 시스템 콜을 호출하여 자식 프로세스에 대한 회수 책임을 다 하게 됩니다. 그러나 불행하게도 우리의 go app에는 그러한 기능이 없습니다. 따라서 커널의 작업에 따라 PID 31인 sleep 1000 &을 PID 1인 go app이 입양하기는 하지만 wait() 시스템 콜을 호출하는 기능이 없기 때문에 입양한 프로세스가 EXIT_ZOMBIE인 상태로 남게 되는 것입니다.
실제 실습을 통해 과정을 하나씩 살펴보겠습니다. 우선 api 호출을 통해 CreateZombieProcess 함수를 호출해 보겠습니다. 그리고 바로 컨테이너의 프로세스 상태를 'ps -ef' 명령어를 통해 확인해 보겠습니다.
$ docker exec -it zombie-process-demo ps -ef
UID PID PPID C STIME TTY TIME CMD
root 1 0 0 18:03 ? 00:00:00 ./main
root 17 1 0 18:03 ? 00:00:00 sh -c # trap 'wait' EXIT sleep 1000 &
root 18 17 0 18:03 ? 00:00:00 sleep 1000
root 19 17 0 18:03 ? 00:00:00 sleep 2
root 20 0 33 18:03 pts/0 00:00:00 ps -ef
먼저 'main'이 해당 컨테이너의 PID 1을 차지하고 있는 것을 확인할 수 있습니다. 그리고 PID 17로 sh이 실행되고 있으며 바로 이어서 PID 18이 백그라운드로 sleep 1000을 수행했습니다. 이때 PID 18의 PPID(Parent PID)를 보면 17인 것을 확인할 수 있십니다. 즉 sh의 자식 프로세스로 sleep 1000 &이 실행된 것이죠. (참고로 PID 19의 'sleep 2'는 프로세스의 상태를 확인하기 위한 약간의 지연 시간을 위한 장치입니다.)
그다음은 api 호출이 끝나고 프로세스가 좀비가 된 상태를 확인해 봅시다. 약 2초 후 역시 'ps -ef' 명령어를 통해 컨테이너 내부의 프로세스 상태를 확인해 보겠습니다.
$ docker exec -it zombie-process-demo ps -ef
UID PID PPID C STIME TTY TIME CMD
root 1 0 0 18:03 ? 00:00:00 ./main
root 18 1 0 18:03 ? 00:00:00 [sleep] <defunct>
root 27 0 50 18:03 pts/0 00:00:00 ps -ef
$ docker exec -it zombie-process-demo ps aux
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
root 1 0.0 0.1 1229276 4184 ? Ssl 18:03 0:00 ./main
root 18 0.0 0.0 0 0 ? Z 18:03 0:00 [sleep] <defunct>
root 33 100 0.0 2524 1728 pts/0 Rs+ 18:04 0:00 ps aux
처음으로 확인할 수 있는 것은 PID 18의 PPID의 변화입니다. 고아가 된 PID 18은 커널에 의해 자신이 속한 네임스페이스의 PID 1에 입양된 것을 확인할 수 있습니다. 또한 'ps aux' 명령어를 통해 상태가 Z(zombie)인 것도 확인할 수 있습니다.
위 그림은 다소 부정확한 부분이 있습니다. 사실 main이 있을 위치에 커널이 있는 게 조금 더 옳습니다만, 컨테이너 환경이라는 의미에서 위와 같이 표현해 보았습니다. 어쨌든 커널은 제 역할을 잘 수행했습니다. 리눅스 기본 설계 철학인 '책임과 권한의 명확성(Clear Responsibility and Authority)'을 구현하기 위해 부모가 없는 프로세스에게 부모를 찾아준 것이죠. 다만 부모 프로세스인 go app이 그 책임을 다 할 수 없었기 때문에(wait()을 호출하는 기능이 없으므로) 로컬 환경에서의 작동과는 전혀 다른 결과로 좀비 프로세스를 생성하게 된 것입니다.
프로세스 탈출과 보안 위협
그럼, 처음부터 컨테이너 내부에서 고아가 된 프로세스를 전체 시스템 레벨에서 PID 1인 systemd(init)가 입양해 가면 되는 거 아닌가? 그렇게 하면 좀비 프로세스도 생길일이 없잖아?
당연하게도 위와 같은 질문을 할 수 있습니다. 그러나 이렇게 된다면 네임스페이스 격리가 깨져 아무런 의미가 없게 됩니다. 또한, 단순히 네임스페이스 격리가 깨졌다는 기술적인 의미뿐만 아니라 심각한 보안 문제를 야기할 수도 있습니다. 만약 누군가 악의적인 의도를 가지고 컨테이너 내의 프로세스를 고의적으로 고아 상태로 만들어 PID 1이 입양하도록 유도하여, 프로세스가 네임 스페이스의 경계를 넘어 호스트 시스템에 접근할 수도 있기 때문입니다.
따라서 이러한 심각한 보안 문제를 방지하기 위해 리눅스 커널은 엄격한 네임스페이스 경계를 유지하며, 반드시 같은 네임스페이스의 PID 1이 고아 프로세스를 입양하게 하는 것입니다.
좀비 프로세스가 시스템에 주는 영향
그렇다면, 이런 좀비 프로세스는 시스템에 어떤 영향을 주는지에 대해서 한번 논의해 봅시다. 사실 좀비 프로세스가 수 개 - 수십 개 정도는 시스템에 크게 영향이 없을 가능성이 큽니다. 그 이유는 실제로 좀비 프로세스는 이미 '종료된' 프로세스 이기 때문입니다. 따라서 특수한 경우를 제외한다면 리소스를 거의 점유하고 있지 않습니다. 그러나 한 가지 확실하게 점유하고 있는 것이 있는데 그것이 바로 앞서 언급했던 프로세스 테이블입니다. 여기에 프로세스는 자신의 상태를 저장해 두고 부모 프로세스의 wait() 시스템 콜을 통해 회수되기를 기다리며 프로세스 테이블을 점유하고 있습니다.
$ ps -ef
UID PID PPID C STIME TTY TIME CMD
root 60043 1 0 18:03 ? 00:00:00 /usr/bin/containerd-shim-runc-v2 -namespace moby -id 1e
root 60065 60043 0 18:03 ? 00:00:00 ./main
root 60330 60065 0 18:03 ? 00:00:00 [sleep] <defunct>
실제로 위와 같이 노드 수준에서 'ps -ef' 명령어를 통해 컨테이너 내부의 프로세스를 조회할 수 있습니다. 컨테이너의 main 프로세스가 PID 60065로 수행 중이며, 좀비가 된 PID 60330은 입양이 되어 컨테이너 내부에서는 PID 1이었던 PID 60065를 부모 프로세스로 가지고 있다는 것을 확인할 수 있습니다.
그런데 이 테이블에 등록될 수 있는 프로세스의 수에 한계가 있다는 것이 문제입니다. 실제로 이 테이블이 가득 차면 실제로 새로운 프로세스를 생성할 수 없는 경우가 발생하기도 합니다. 어떻게 보면 컨테이너로 격리된 프로세스들이 유일하게 공유하는 노드 자원이라고 볼 수도 있겠는데요. 여기에 문제가 생겨서 다른 프로세스를 실행할 수 없다면 이는 시스템 작동에 또 다른 예상치 못한 결과를 가지고 올 수 있습니다.
실제로 저도 자사의 애플리케이션에서 이러한 현상을 발견하여 몇몇 기능이 제대로 작동하지 않는 것을 확인한 바가 있었고, 문제의 원인을 파악하다 보니 이렇게 리눅스 프로세스 레벨까지 파고들게 된 것입니다.
역시 이번 포스팅도 최대한 상세하게 쓰려다 보니 상당히 길어졌군요. 다음 포스팅에서는 그럼 이러한 좀비프로세스를 어떻게 해결해야 하는지 그 해격책을 제시해 보도록 하겠습니다.
▼ Linux 프로세스 관리 - 좀비 프로세스에 관하여 [3편]
Linux 프로세스 관리 - 좀비 프로세스에 관하여 [3편]
지난 포스팅들을 통해서 리눅스에서의 프로세스 라이프사이클과 좀비 프로세스에 대해서 알아보았습니다. 특히 컨테이너 환경에서 발생할 수 있는 좀비 프로세스는 전체 시스템에 영향을 줄
tech-recipe.tistory.com
'System > OS' 카테고리의 다른 글
Linux 프로세스 관리 - 좀비 프로세스에 관하여 [3편] (1) | 2025.01.28 |
---|---|
Linux 프로세스 관리 - 좀비 프로세스에 관하여 [1편] (0) | 2025.01.25 |