[서론] 컨테이너가 죽지 않아!

 운영 중인 Kubernetes 노드에서 컨테이너 런타임(Containerd)을 업데이트하거나 재시작해야 할 때가 있습니다. 상식적으로 생각해 보면 부모 프로세스인 데몬이 종료되면, 그 자식 프로세스인 컨테이너들도 함께 종료되거나 고아(Orphan)가 되어 문제가 생겨야 할 것 같습니다. 하지만 실제로 그런일은 일어나지 않습니다. 서비스 중인 컨테이너(Pod)들은 아무런 영향 없이 계속 동작합니다. 어떻게 이것이 가능할까요? 도대체 리눅스 커널에서는 어떤 일이 벌어지고 있는 걸까요? 이 '죽지 않는 컨테이너'의 비밀을 풀기 위해 프로세스 트리의 족보를 파해져 보았습니다.

장비를 정지합니다. 정지하겠습니다. 어? 안 되잖아?

 이번 포스팅에는 잘 작동하고 있는 Kubernetes 클러스터의 Control plane 노드(Locky Linux 10, root 사용자 환경)에서 진행됩니다.

멀쩡히 작동하는 Kubernetes 클러스터

 위와 같이 잘 작동하고 있는 Kubernetes 클러스터가 있습니다. API 서버도 잘 작동중이고, Containerd 프로세스도 문제없이 작동 중입니다. 이제 Containerd 프로세스를 정지시켜 보겠습니다. 그리고 Kube-apiserver가 잘 응답하는지 확인해 보겠습니다.

# Conatinerd 프로세스 중지
systemctl stop containerd

# kube-apiserver Port 확인 및 kubectl 응답 확인
ss -nltp | grep 6443

kubectl get pods -n kube-system | grep api

Containerd 서비스는 중지 되었지만, kube-apiserver는 정상 작동 중

 분명 Containerd 서비스는 중지 되었지만, 여전히 6443 포트는 LISTEN 상태이고, kubectl을 통해서도 문제없이 Kubernetes 클러스터와 통신이 가능합니다. 정말 컨테이너가 죽지 않습니다. 그런데 Containerd 프로세스의 상태를 자세히 살펴보면 Cgroup 필드에 특이한 점을 발견할 수 있습니다.

  • 앞서 존재했던 1088523 containerd 프로세스(/usr/local/bin/containerd)가 보이지 않음
  • 그러나 여전히 containerd-shim-runc-v2라는 프로세스들은 여러 개가 보임

 일반적으로 프로세스가 중지되고 나면 Cgroup 필드 자체가 표시되지 않는데 Containerd는 Cgroup 필드가 살아 있습니다. 도대체 왜 이런 현상이 일어나고 있을까요?

중지된 kubelet 프로세스는 Cgroup 필드도 사라짐

[관찰] 족보가 이상한데요...? feat. pstree

 Containerd 프로세스가 중지 되었는데도 컨테이너(Pod)가 문제없이 작동한다는 것은, 아무래도 containerd와 컨테이너(Pod)가 서로 부모-자식 관계가 아닌것일지도 모르겠다는 추측을 하게 합니다. (리눅스 프로세스의 부모-자식 관계에 대한 상세한 내용은 이전 포스팅을 참고해 주세요.) 다음 명령어를 통해서 프로세스 트리를 확인해 봅시다.

 

 

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

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

tech-recipe.tistory.com

# Conatinerd process 재시작
systemctl start containerd

# 프로세스 트리 확인
pstree

containerd와 형제 관계로 보이는 containerd-shim 프로세스들

 pstree를 통해 살펴보면 위에서 확인할 수 있듯, containerd와 containerd-shim은 부모-자식 관계가 아닌 형제 관계인것 처럼 보입니다. 예상했던 예상밖의(?) 결과입니다. 우선 여기서 확실하게 확인되는 것은 컨테이너(Pod)와 containerd 프로세스는 확실히 부모-자식 관계가 아니라는 점이고, 이 때문에 containerd 프로세스가 중지되거나 재시작되어도 컨테이너(Pod)는 아무 문제 없이 잘 작동할 수 있었던 것입니다. 그런데 containerd-shim은 도대체 무엇일까요? 왜 이 프로세스들이 컨테이너(Pod)들의 부모 프로세스의 위치를 가지고 있을까요?

[실험] 왜 컨테이너들이 containerd-shim의 자식이 되어 있을까?

커널에서는 무슨 일이 일어났던 것일까?

 strace 명령어를 사용하여 커널 레벨에서의 시스템 콜(System Call)을 추적, 컨테이너(Pod)가 생성될 때 실제로 어떤 일들이 벌어지고 있는지 확인해 봅시다. 현재 우리가 살펴보고 있는 컨트롤 플레인 노드에서 Pod를 고정적으로 생성하기 위해 Static Pod를 사용할 예정입니다. 다음과 같은 명령어를 통해 Static pod를 생성하고, 시스템 콜을 추적해 봅시다.

# containerd 프로세스의 시스템 콜을 추적
sudo strace -f -e trace=clone,execve,exit_group,prctl -p $(systemctl show containerd -p MainPID --value) 2>&1 | grep -v "SIGURG"
# 새로운 터미널 창을 열고 control plane에 접근
# nginx.yaml 파일 생성
kubectl run nginx --image=nginx --dry-run=client -o yaml > nginx.yaml

# static pod 생성
mv nginx.yaml /etc/kubernetes/manifests/

# 프로세스 트리 확인
pstree

strace와 pstree 명령어를 통한 프로세스 상태 추적

 

kubernetes-study/process-tree-and-cgruop at main · garlicKim21/kubernetes-study

Kubernetes Study의 기록입니다. Contribute to garlicKim21/kubernetes-study development by creating an account on GitHub.

github.com

containerd-shim(PID 1224703)과 pause 컨테이너(PID 1224727)

 strace와 pstree 명령어를 통해 확인할 수 있는 로그 및 프로세스 트리는 위 Github 레포 링크에서 자세하게 살펴볼 수 있습니다. 우선 pstree.txt 파일을 살펴보면 containerd의 PID는 1108358이고 nginx static pod의 부모인 containerd-shim의 PID는 1224703이며 pause 컨테이너의 PID는 1224727, nginx 컨테이너의 PID는 1224754 임을 확인할 수 있습니다. 이 프로세스들이 언제 어떻게 탄생했는지는 strace.logs 파일을 통해 확인할 수 있습니다.

# nginx Pod의 부모 프로세스 PID 1224703 생성
165 [pid 1224700] clone(child_stack=NULL, flags=CLONE_VM|CLONE_PIDFD|CLONE_VFORK|SIGCHLDstrace: Process 1224703 attached
166  <unfinished ...>

# PID 1224703 프로세스의 containerd-shim 실행
167 [pid 1224703] execve("/usr/local/bin/containerd-shim-runc-v2", ["/usr/local/bin/containerd-shim-r"..., "-namespace", "k8s.i    o", "-id", "c8b394369634a6c6755d4e9094907a92"..., "-address", "/run/containerd/containerd.sock"], 0xc000170900 /* 14 vars *    / <unfinished ...>
168 [pid 1224700] <... clone resumed>, parent_tid=[12]) = 1224703

# containerd-shim의 부모 프로세스였던 PID 1224700 프로세스 그룹 종료
169 [pid 1224703] <... execve resumed>)     = 0
170 [pid 1224700] exit_group(0)             = ?
171 [pid 1224701] +++ exited with 0 +++
172 [pid 1224696] +++ exited with 0 +++
173 [pid 1224699] +++ exited with 0 +++
174 [pid 1224695] +++ exited with 0 +++
175 [pid 1224700] +++ exited with 0 +++
176 [pid 1224698] +++ exited with 0 +++
177 [pid 1224697] +++ exited with 0 +++
178 [pid 1224694] +++ exited with 0 +++

 먼저 containerd-shim(PID 1142827)이 생겨나는 과정을 뜯어봅시다. 위 내용은 containerd-shim이 독립하여 systemd(PID 1)에 입양되어 containerd와 형제 프로세스가 되는 부분의 로그입니다. 중간 과정이 약간 생략되었지만, 큰 틀은 아래 그림과 같습니다.

containerd-shim이 systemd에 입양되는 과정

 contianerd로부터 생성된 프로세스 PID 1224700PID 1224703을 생성한 후 종료되면서, containerd-shim(PID 1224703)고아(Orphan) 상태가 되고 systemd(PID 1)이 이를 입양, containerd와 형제 프로세스가 되는 것입니다.

그럼 컨테이너(Pod)는 언제 생겼을까?

 다시 pstree를 상세하게 뜯어보겠습니다. containerd-shim(PID 1224703)의 자식 프로세스로 nginx(PID 1224754)pause(PID 1224727)이 보입니다. 그런데 그 어디를 살펴보아도 Pod는 보이지 않습니다. 그 이유는 Pod는 Kubernetes가 만든 논리적인 그룹일 뿐, 리눅스 커널에는 존재하지 않는 객체이기 때문입니다. 여기서 Kubernetes의 Infra 컨테이너(또는 sandbox 컨테이너)라는 개념이 등장합니다. Infra 컨테이너가 바로 pause(PID 1224727)이고 이것이 Pod의 구현체입니다. Kubernetes는 Pod라는 논리적 그룹을 구현하기 위해 리눅스 네임스페이스 기능을 사용하는데, 이 네임스페이스는 최소한 하나의 프로세스가 그 안에 살아 있어야만 유지됩니다. 이를 위해 아무 일도 하지 않지만 네임스페이스를 유지할 수 있는 프로세스인 pause를 생성하여 sandbox 환경(Pod)을 구현하게 됩니다. 그럼 이 pause 컨테이너가 어떤 과정을 통해 생성되고 있는지 strace 로그를 통해 추적해 보겠습니다.

# pause(PID 1224727) 프로세스 추적

# pause(PID 1224727)의 부모는 PID 1224726 확인
# PID 1224726의 부모는 PID 1224723 확인
# pause(PID 1224727) 프로세스 생성 후 부모 프로세스(PID 1224726), 조부모 프로세스(PID 1224723) 모두 종료
235 [pid 1224723] clone(child_stack=0x7ffec80fe4c0, flags=CLONE_PARENT|SIGCHLDstrace: Process 1224726 attached
236 ) = 1224726
237 [pid 1224726] prctl(PR_SET_NAME, "runc:[1:CHILD]") = 0
238 [pid 1224726] clone(child_stack=0x7ffec80fe4c0, flags=CLONE_PARENT|SIGCHLDstrace: Process 1224727 attached
239 ) = 1224727
240 [pid 1224727] prctl(PR_SET_NAME, "runc:[2:INIT]") = 0
241 [pid 1224726] exit_group(0)             = ?
242 [pid 1224726] +++ exited with 0 +++
243 [pid 1224714] --- SIGCHLD {si_signo=SIGCHLD, si_code=CLD_EXITED, si_pid=1224726, si_uid=0, si_status=0, si_utime=0, si_stim    e=0} ---
244 [pid 1224723] exit_group(0)             = ?
245 [pid 1224723] +++ exited with 0 +++

# PID 1224723 부모는 PID 1224714
226 [pid 1224714] clone(child_stack=NULL, flags=CLONE_VM|CLONE_PIDFD|CLONE_VFORK|SIGCHLDstrace: Process 1224723 attached

# PID 1224714의 부모는 PID 1224711
# PID 1224711은 정확히는 프로세스가 아니라 스레드
207 [pid 1224711] clone(child_stack=NULL, flags=CLONE_VM|CLONE_PIDFD|CLONE_VFORK|SIGCHLDstrace: Process 1224714 attached

# 스레드 PID 1224711의 부모는 스레드 PID 1224709
197 [pid 1224709] clone(child_stack=0xc00022c000, flags=CLONE_VM|CLONE_FS|CLONE_FILES|CLONE_SIGHAND|CLONE_THREAD|CLONE_SYSVSEM|    CLONE_SETTLS
strace: Process 1224711 attached

# 스레드 PID 1224709의 부모는 스레드 PID 1224706
# 191 라인의 로그 뒷 부분을 분리해서 보아야 함
190 [pid 1224706] clone(child_stack=0xc00021a000, flags=CLONE_VM|CLONE_FS|CLONE_FILES|CLONE_SIGHAND|CLONE_THREAD|CLONE_SYSVSEM|    CLONE_SETTLS
<unfinished ...>
191 strace: Process 1224709 attached
193 [pid 1224706] <... clone resumed>, tls=0xc00005b198) = 1224709

# 스레드 PID 1224706의 부모는 PID 1224703(containerd-shim 프로세스)
184 [pid 1224703] clone(child_stack=0xc000070000, flags=CLONE_VM|CLONE_FS|CLONE_FILES|CLONE_SIGHAND|CLONE_THREAD|CLONE_SYSVSEM|    CLONE_SETTLS
strace: Process 1224706 attached
185 , tls=0xc000058e98) = 1224706

# 주요 포인트! - containerd-shim(PID 1224703)으로 부터 생성되는 스레드 PID 1224710의 생성
182 [pid 1224703] clone(child_stack=0xc000074000, flags=CLONE_VM|CLONE_FS|CLONE_FILES|CLONE_SIGHAND|CLONE_THREAD|CLONE_SYSVSEM|    CLONE_SETTLSstrace: Process 1224705 attached
183 , tls=0xc000058798) = 1224705

# (PR_SET_CHILD_SUBREAPER, 1) 속성을 추가하는 스레드 PID 1224710
191 [pid 1224705] clone(child_stack=0xc000216000, flags=CLONE_VM|CLONE_FS|CLONE_FILES|CLONE_SIGHAND|CLONE_THREAD|CLONE_SYSVSEM|    CLONE_SETTLS
192  <unfinished ...>
195 [pid 1224705] <... clone resumed>, tls=0xc000100798) = 1224710
196 [pid 1224710] prctl(PR_SET_CHILD_SUBREAPER, 1) = 0

 

[다이어그램] Pause 컨테이너 프로세스 생성 단계

 

 Pause 컨테이너 하나를 생성하는데도 이렇게 많고 복잡한 과정을 거칩니다.(심지어 이것도 많은 단계들이 생략된 것입니다.)이 복잡한 과정의 주요 내용을 요약해 보면 다음과 같습니다.

단계 내용 비고
1 단계 - containerd-shim 프로세스에서 시작하여 여러 프로세스와 스레드를 거치면서 Pause 컨테이너 프로세스를 생성
- Pause 컨테이너가 생성되고 나면 containerd-shim과 Pause 프로세스 사이의 중간 단계 프로세스와 스레드는 모두 종료하여 Pause 컨테이너 프로세스를 고아(Orphan) 상태로 만들어 입양 보낼 준비를 진행
 
2 단계 - containerd-shim의 또 다른 스레드에서 PR_SET_CHILD_SUBREAPER를 선언
- 이로인해 containerd-shim도 Subreaper 속성을 부여 받음
1단계와 거의 동시에 일어남
3 단계 - 고아가 된 Pause 컨테이너가 가장 가까운 reaper를 찾음
- Subreaper 속성이 부여된 containerd-shim 프로세스가 Pause 컨테이너 프로세스를 입양
 

하청의 하청... 저수준 컨테이너 런타임 runc의 역할

 Kubernetes에서 Pod 생성 요청이 들어오면 노드의 kubelet 데몬이 container runtime(containerd)에게 컨테이너 생성을 명령합니다. 그러면 그 명령을 받은 containerd는 containerd-shim을 통해 컨테이너 프로세스를 구동시킵니다. 그런데, 사실 containerd-shim 역시도 직접 컨테이너를 만드는 것이 아니라 runc라는 저수준 컨테이너 런타임을 통해 실제 컨테이너를 생성합니다. 정말 하청에 하청을 몇 번이나 내리는지...

 

 다시 한번 strace.logs 파일을 살펴봅시다. Pause 컨테이너와 Nginx 컨테이너가 생성되는 시점을 유심히 살펴보면서 runc가 어떤 역할을 하는지 알아봅시다. 이번에는 PID 1224714를 중심으로 살펴보겠습니다. 그 이유는 PID 1224714가 runc를 실행하기 때문입니다.

# containerd-shim의 스레드인 PID 1224711이 PID 1224714 프로세스를 생성
[pid 1224711] clone(child_stack=NULL, flags=CLONE_VM|CLONE_PIDFD|CLONE_VFORK|SIGCHLDstrace: Process 1224714 attached
[pid 1224714] prctl(PR_SET_PDEATHSIG, SIGKILL) = 0

# PID 1224814 프로세스가 runc 명령어를 실행하면서 runc로 변형됨 
# runc create 명령을 통해 컨테이너 격리공간(Namespace, Cgroups)만 만들고, 프로세스는 실행하지 않음
[pid 1224714] execve("/usr/local/bin/runc", ["/usr/local/bin/runc", "--root", "/run/containerd/runc/k8s.io", "--log", "/run/containerd/io.containerd.ru"..., "--log-format", "json", "--systemd-cgroup", "create", "--bundle", "/run/containerd/io.containerd.ru"..., "--pid-file", "/run/containerd/io.containerd.ru"..., "c8b394369634a6c6755d4e9094907a92"...], 0xc000283030 /* 13 vars */ <unfinished ...>
[pid 1224711] <... clone resumed>, parent_tid=[15]) = 1224714
[pid 1224714] <... execve resumed>)     = 0
[pid 1224714] prctl(PR_SET_CHILD_SUBREAPER, 1) = 0
[pid 1224714] clone(child_stack=NULL, flags=CLONE_VM|CLONE_PIDFD|CLONE_VFORKstrace: Process 1224722 attached
[pid 1224714] <... clone resumed>, parent_tid=[17]) = 1224722

# PID 1224714는 PID 1224723을 생셩
# PID 1224723이 runc init을 사용하여 컨테이너 초기화 작업을 진행
# 이후 PID 1224726 > Pause 컨테이너(PID 1224727)로 이어지는 과정
[pid 1224714] clone(child_stack=NULL, flags=CLONE_VM|CLONE_PIDFD|CLONE_VFORK|SIGCHLDstrace: Process 1224723 attached
[pid 1224723] execve("/proc/self/fd/6", ["/usr/local/bin/runc", "init"], 0xc0001ff7c0 /* 7 vars */ <unfinished ...>
[pid 1224714] <... clone resumed>, parent_tid=[18]) = 1224723
[pid 1224723] <... execve resumed>)     = 0
[pid 1224723] prctl(PR_SET_DUMPABLE, SUID_DUMP_DISABLE) = 0
[pid 1224723] prctl(PR_SET_NAME, "runc:[0:PARENT]"...) = 0
[pid 1224723] clone(child_stack=0x7ffec80fe4c0, flags=CLONE_PARENT|SIGCHLDstrace: Process 1224726 attached
[pid 1224723] exit_group(0)             = ?
[pid 1224723] +++ exited with 0 +++
[pid 1224714] --- SIGCHLD {si_signo=SIGCHLD, si_code=CLD_EXITED, si_pid=1224726, si_uid=0, si_status=0, si_utime=0, si_stime=0} ---
[pid 1224714] --- SIGCHLD {si_signo=SIGCHLD, si_code=CLD_EXITED, si_pid=1224723, si_uid=0, si_status=0, si_utime=0, si_stime=0} ---
[pid 1224714] exit_group(0)             = ?
[pid 1224714] +++ exited with 0 +++
[pid 1224711] --- SIGCHLD {si_signo=SIGCHLD, si_code=CLD_EXITED, si_pid=1224714, si_uid=0, si_status=0, si_utime=0, si_stime=1 /* 0.01 s */} ---

 위 내용을 해석해 보면 다음과 같습니다.

  1. containerd-shim의 스레드인 PID 1224711이 프로세스 PID 1224714를 생성
  2. 프로세스 PID 1224714는 'runc create'로 Namespace와 Cgroups만 만들고 일시 중지
  3. PID 1224714가 PID 1224723을 생성하여 'runc init'으로 컨테이너 프로세스 초기화 진행
  4. 이후 PID 1224726 생성 → Pause 컨테이너(PID 1224727) 생성으로 이어짐

 여기서 한 가지 짚고 넘어가야 할 것이 있습니다. 바로 PID 1224723PID 122476입니다. 다른 프로세스와 다르게 이들만 특별하게 CLONE_PARENT 설정을 사용하고 있는데, 이는 runc의 특별한 역할 때문입니다. runc의 역할은 오로지 컨테이너 생성과 실행에만 있으며, 이것이 끝나면 바로 종료됩니다. 따라서 CLONE_PARENT 옵션을 통해 미리 고아가 될 컨테이너 프로세스의 부모를 더 상위 프로세스로 지정해 주어 프로세스 상속 관계의 안정성을 확보합니다.

 참고로, runc는 OCI(Open Container Initiative) 표준을 준수하는 저수준 런타임으로 containerd가 이를 사용함으로 해서 역시 OCI 표준을 만족할 수 있게 되는 것입니다.

 

 추가로 Nginx 컨테이너(PID 1224754) 역시도 그 프로세스를 추적해 보면 우리가 지금까지 Pause 컨테이너를 추적했던 것과 거의 흡사한 내용을 확인할 수 있을 것입니다.

[분석] 왜 containerd는 containerd-shim을 입양 보냈을까?

 왜 이런 복잡한 과정을 거쳐서 컨테이너를 만들까요? 이것이 바로 Daemonless Architecture라는 설계 철학의 구현이기 때문입니다. 만약 containerd-shim이 containerd의 자식 프로세스라면 container runtime 업그레이드와 같은 상황에서 모든 서비스가 중지되는 문제가 발생할 것입니다. 하지만 containerd-shim을 의도적으로 고아(Orphan)로 만들고 systemd로 입양 보내면서 container runtime과 컨테이너 프로세스 간의 결합도를 분리하고 고가용성을 확보할 수 있게 된 것입니다.

 

 또한, runc의 경우에는 의도적으로 컨테이너 프로세스의 부모 프로세스로 존재하지 않게 하기 위해 CLONE_PARENT 옵션을 주어 실행, 미리 더 상위 프로세스로 입양되게 하여 프로세스 상속 관계의 안정성을 확보하는 것은 매우 영리한 설계라 할 수 있겠습니다.

[결론 및 예고] 리소스를 통제하는 보이지 않는 손: Cgroup

 지금까지 프로세스 트리가 어떻게 '생명 주기'의 안정성을 위해 분리되었는지 확인해 보았습니다. Daemonless Architecture와 영리한 결합도 분리를 통해 시스템 안정성을 확보하기 위해 이런 복잡한 과정을 거친다는 것이... 리눅스 시스템은 파고들수록 정교하면서도 또 이해하기에 어렵다고 느껴집니다. 그런데 여기서 한 가지 의문이 듭니다.

분명 containerd와 containerd-shim은 서로 남남이 되었는데 어째서 systemctl status containerd 명령어에서 Cgroup에 함께 보일까?

 

 다음 포스팅에서는 pstree 뒤에 숨겨진 리소스를 통제하는 보이지 않는 손 Cgroup과 Systemd Slice의 이중적인 구조에 대해서 파해쳐 보도록 하겠습니다.

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

 

 

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

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

 

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

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

 

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)의 고아 프로세스와 좀비 프로세스 핸들링

 드디어 서론이 끝난 기분이군요. 본격적으로 좀비 프로세스, 그리고 특히 컨테이너 환경에서의 좀비 프로세스에 대해서 논의할 수 있게 된 것 같습니다. 고아 프로세스와 좀비 프로세스는 서로 같은 개념은 아니자만, 둘 다 모두 부모프로세스의 상태나 동작에 영향을 받습니다. 게다가 이를 컨테이너 내에서 의도적으로 잘 조작하면, 좀비 프로세스가 계속 프로세스 테이블에 남아 있도록 할 수 있습니다.

[git repository] 좀비 프로세스 데모

// 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'으로 스스로를 종료시킵니다.

 

 이 과정에서 일어나는 일들을 앞서 이야기한 프로세스 생명주기에 관한 내용을 통해 유추해 봅시다. 아마 아래와 같은 단계를 거칠 것입니다.

  1. 종료 신호를 받은 sleep 1000(PID 301)은 자신의 리소스를 정리하고 EXIT_ZOMBIE 상태가 됨
  2. 프로세스 테이블에 sleep 1000의 PID와 종료 상태, 리소스 사용량 통계만 남음
  3. 이때 sh(PID 201)은 자신의 자식 프로세스에 대한 회수를 수행하지 않은 채로 exit 0를 통해 종료
  4. PID 301은 부모가 없어지면서 좀비 이면서 고아 상태가 됨
  5. go app(PID 101)은 자신의 자식프로세스에 대해서만 회수 책임이 있으므로 sh의 종료만을 처리
  6. systemd가 PID 301이 고아 상태임을 파악하고 입양하여 고아 상태를 해소, 회수 책임이 발생
  7. 프로세스 상태 확인하고 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

 

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

$ 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