티스토리 뷰

Container의 PID namespace 격리에 대해서 학습하고 이를 정리하다 보니, Zombie process부터 init system 등 여러 가지를 다시 짚어보는 좋은 계기가 되었습니다.

이미 알고 계시는 내용이 많겠지만, 그래도 공유해봅니다. (좀 깁니다...)

목차

  1. Zombie process
  2. 고의적으로 OrphanProcess 를 만들어봅시다.
  3. 고아 프로세스를 거두어준init process(pid=1)
  4. initprocess?
  5. 여기까지 이야기한 내용을 정리해봅시다.
  6. Container에서의 pid=1에 대하여
    • 그럼 Container에서 Zombie process reaping 은 어떻게 해야할까요?
    • Container 용 경량화된 init system의 등장
  7. Kubernetes의 PID namespacesharing
  8. 최종정리
  9. 참고한 글(함께 읽어보면 좋은 글)

1. Zombie process

Zombie process에 대해서 한번 짚고 넘어가도록 하지요.

  • Zombie process는 실제로는 종료된 프로세스이지만, Process Table에 여전히 남아있는 프로세스입니다.
  • Zombie process는 Defunct process라고도 부릅니다.

이렇게 Process Table에 남게 되는 정보는 컴퓨팅 리소스(CPU, Disk, Memory)를 사용하진 않지만, 최대 프로세스 개수 제한($ sysctl kernel.pid_max)과 같은 일부 시스템 리소스를 차지하게 됩니다. 그 예로, Process table이 꽉 차게 되면 더 이상 프로세스를 실행할 수 없는 상황이 발생할 수 있습니다.

 

그렇다면 종료된 프로세스는 Process Table로부터 언제 제거될까요?

  • 일반적으로 부모 프로세스가 자식 프로세스를 실행하고, 자식 프로세스가 종료되어 부모 프로세스로부터 wait system call(e.g. waitpid)이 호출되면 Process table로부터 제거됩니다.
  • 자식 프로세스의 실행이 종료된 이후에 부모 프로세스가 종료되면, 부모 프로세스와 함께 Process Table로부터 제거됩니다.

하지만 반대로,

  • 부모 프로세스가 자식 프로세스보다 먼저 종료되는 경우
  • 부모 프로세스가 자식 프로세스의 종료 처리 역할을 제대로 하지 못하는 경우

좀비 프로세스가 발생할 수 있습니다. 여기서, 부모 프로세스가 먼저 종료되어 기존의 부모 프로세스를 잃어버린 자식 프로세스들은 Orphan Process(고아 프로세스)가 됩니다.

2. 고의적으로 Orphan Process 를 만들어봅시다.

Bash에서 간단한 스크립트를 하나 만듭니다.

make-orphans.sh

  #!/usr/bin/env bash

  for ((i = 0; i < 3; i ++)); do
      # Make zombies by executing subshells(child processes) on background
      (echo "I'm a child"; sleep 10; echo "Child process terminated") &
  done

  # Exit without `wait` command
  # to make child processes to be zombie-processes
  echo "* Parent process terminated"
  • Bash에서는 명령어를 괄호로 감싸주면 subshell(Child processes)로 실행됩니다.

  • 실행할 명령어 뒤에 &를 붙여주면 background로 실행됩니다.

  • 여기서 Background에서 실행된 자식 프로세스들이 고아 프로세스가 되지 않도록 하기 위해서는 wait 명령어 추가가 필요합니다. 하지만 고의로 생략합니다.

$ bash make-orphans.sh
* Parent process terminated
I'm a child
I'm a child
I'm a child
Child process terminated
Child process terminated
Child process terminated

부모 프로세스가 종료되고 약 10초 이후 자식 프로세스들이 종료되는 것을 확인할 수 있습니다. stdout 이 출력되는 시간은 약간씩 달라질 수 있으나, 어쨌든 부모 프로세스가 자식 프로세스보다 더 빨리 종료되도록 했으니, 자식 프로세스들은 앞서 말했던 "고아 프로세스"가 되어야겠죠?

 

그럼 이번엔 부모 프로세스가 종료된 직후 바로 ps 커맨드를 활용해서 실행 중인 자식 프로세스들을 확인해봅시다.

bash make-orphans.sh; ps -ef | grep bash

이상하네요.

고아 프로세스가 될 것으로 예상했던 자식 프로세스들의 PPID(부모 프로세스 아이디)가 1인 것을 확인하실 수 있습니다. pid=1인 init process(여기선 systemd)가 자식 프로세스들의 부모로 재할당 되었습니다.

3. 고아 프로세스를 거두어준 init process(pid=1)

  • 일반적으로 Linux kernel에서는 부모 프로세스가 먼저 종료된 고아 프로세스(Orphan process)의 부모를 pid=1, 즉 init 프로세스로 재할당 해줍니다. (리눅스 배포판별로 조금씩 다를 수 있다고 합니다만 일반적으로 그렇습니다.)
    • 이러한 원리로, SysV Unix 체계에서는 daemon을 실행할 때 프로세스를 forking 하고 부모 프로세스를 종료하여 고아 프로세스로 만들고 init process가 부모 프로세스로 재할당되도록 하여 프로세스를 데몬화(Deamonize)합니다. (실제로는 세션으로부터 완전히 독립시키기 위해 Double forking 을 수행합니다)

여기서 다시 자식 프로세스들이 종료된 이후에 다시 ps 커맨드를 이용하여 확인해보면, 좀비 프로세스가 되었어야 할 녀석들이 좀비 상태로 남지 않고 사라진 것을 확인할 수 있습니다.

왜 일까요?

4. init process?

Unix 기반 OS에서 init 프로세스는 "부팅 시 최초로 실행되는 프로세스(pid=1)"로 많이 알려져 있습니다. pid=1로 실행되면서 모든 프로세스의 최종 부모 프로세스 역할을 합니다.

또한 익히 알고 계시는 systemd, upstart, 그리고 전통적으로는 SysV init과 같은 init system 들이 init 프로세스로서 사용되고 있습니다. (systemd는 여러 가지 역할을 하지만, 각 init system에 대한 특징과 히스토리들은 여기서 다루지 않겠습니다.)

Ubuntu 16.04에서 확인해보면  /sbin/init  이  systemd 의 symlink인 것을 볼 수 있습니다.

잠시 딴 길로 새면, 이처럼 여러 init system이 등장하면서 우리가 쉽게 발견할 수 있는 차이는 daemon을 관리할 때 사용되는 명령어인데요.

  • SysV init, upstart - /etc/init.d/[daemon] [start|stop|...]
  • systemd - systemctl [start|stop|...] [daemon]

물론 service라는 커맨드를 사용해보신 적도 많으실 겁니다. (e.g. service [daemon] [start|stop|...])

리눅스 배포판, 버전마다 모두 다르겠지만, 이전에는 SysV init의 wrapper 로서 역할을 하다가 현재는 systemd, sysV init, upstart을 아우르는 wrapper로서 역할을 하고 있습니다. (예시 참고: http://manpages.ubuntu.com/manpages/bionic/man8/service.8.html)

 

다시 돌아와서, init process의 또 한 가지 중요한 역할은 부모 프로세스를 잃어버린 고아 프로세스를 거두어 좀비 프로세스가 되지 않도록 정리해 주는 역할을 하는 것이죠. 이러한 역할을 Reaping이라고도 합니다. 위의 예제에서 고아 프로세스가 종료된 이후 좀비 프로세스가 되지 않은 것도 init process의 Reaping에 의한 것이라고 볼 수 있습니다.

 

추가적으로, init process 대신 userspace에서 서비스 관리를 수행하는 sub-reaper의 개념도 존재합니다만 여기선 다루지 않겠습니다. (대신 참고링크)

5. 여기까지 이야기한 내용을 정리해봅시다.

  • Zombie process는 실제로는 종료된 프로세스이지만, Process Table에 여전히 남아있는 프로세스입니다.
  • Orphan process는 부모 프로세스가 먼저 종료되어 고아가 된 프로세스입니다.
  • Orphan process가 발생하면, 커널에 의해 부모 프로세스가 init process(pid=1)으로 할당됩니다.
    • Init process는 Orphan process를 거두어들이는 역할을 합니다.
  • 이러한 고아 프로세스들의 실행이 종료되고 Process Table에서 제거되려면 부모 프로세스에서 wait system call을 호출해 주어야 하는데요. 이걸 해주지 않으면 zombie process로 남습니다.
    • 그래서 init 프로세스에서는 주기적으로 wait 콜을 호출하여 좀비 프로세스를 정리해 주는 역할을 합니다. 이러한 동작을 일반적으로 Reaping이라고 부릅니다.

이처럼 Unix 시스템에서 pid=1은 매우 중요한 의미를 지니고 있다고 볼 수 있습니다.

여기까지 왔으면 대충 감을 잡으셨겠지만.. 그래도 하고자 하던 이야기는 계속해야겠지요?

6. Container에서의 pid=1에 대하여

Container는 기본적으로 단일 프로세스만을 실행하도록 만드는 것이 권장됩니다. 여러 가지 이유가 있겠지만 그중 하나로, Container에서 격리된 PID namespace에서 최초로 실행된 pid=1의 프로세스와 함께 컨테이너의 수명이 결정되기 때문이죠.

만약 Container 내부에서 여러 개의 다른 역할을 하는 process 들이 실행되면, Container의 상태와 실행 중인 application들의 상태가 일치하지 않으므로, Container가 실행 중이더라도 내부의 모든 application들이 정상적으로 실행되고 있는 상태인지 보장할 수 없게 됩니다.

결국 최초로 실행된 Process 실행/종료와 Container 자체의 실행/종료가 같은 의미를 가지는데요.

 

어쨌든 이처럼 Container는 단일 프로세스를 실행하는 방식으로 설계되었고, Container 내부에서 실행되는 첫 번째 프로세스의 pid는 1 이 됩니다.

하지만 이전에 알아본 대로 pid=1로 실행되는 프로세스는 이러한 Reaping 역할을 잘 수행할 수 있거나, 또는 애플리케이션에서 고아 프로세스가 발생하지 않도록 보장해야 합니다. 그렇지 않으면 의도치 않은 상황에서 좀비 프로세스가 발생하고 정리되지 않아 누적될 수 있겠죠.

즉, Container에서 좀비 프로세스가 발생하지 않도록 보장하거나, reaping이 가능하도록 설계가 필요합니다.

 

그럼 Container에서 Zombie process reaping 은 어떻게 해야할까요?

몇 가지 방법을 생각해 볼 수 있습니다.

  1. bash를 pid=1로 실행, Application process를 bash(pid=1)의 자식 프로세스로 실행
  2. Application에 Reaping 역할을 구현
  3. Container에서 systemd 와 같은 기존의 init process 실행

첫 번째 방법에 대하여, bash는 Orphan process를 처리할 수 있는 reaper로 가장 익히 알려진 프로그램입니다. 하지만 우리는 여기서 docker stop 명령어를 생각해 볼 필요가 있습니다.

Docker는 docker stop 을 실행하는 경우 SIGTERM을 container의 처음 실행된 프로세스로 전송합니다. 하지만 bash는 SIGTERM을 자식 프로세스로 전달하지 않기 때문에 graceful shut down 이 불가능해집니다.

결국 pid=1은 자식 프로세스들에게 SIGTERM을 적절하게 전파해 주고 자식 프로세스들이 완전히 종료될 때까지 기다려 줄 수 있어야 합니다. 하지만 bash는 안타깝게도 SIGTERM signal을 자식 프로세스에 전파하지도 않고, 종료되길 기다려주지도 않습니다. bash의 자식 프로세스들은 결국 bash가 종료되면서 SIGKILL signal에 의해 강제 종료 됩니다.

 

두 번째 방법도 역시 Reaping 역할뿐만 아니라, 위와 같은 경우처럼 Signal handling을 잘 해줘야겠지요.

세 번째 방법은 일반적인 경우 권장되진 않습니다. 이러한 init system의 일부 기능을 활용하기 위해, 기존의 init system(SysV init, systemd, upstart, etc.)을 Container에서 실행하는 것은 닭 잡는 데 소 잡는 칼을 쓰는 격이죠.

Container 용 경량화된 init system의 등장

이런 히스토리에서, Container에서도 필요한 이런 기존의 init system의 중요한 역할들을 할 수 있는 경량화된 init system들이 등장하기 시작했습니다.

그 예로, Yelp/dumb-init, krallin/tini, phusion/baseimage-docker 같은 것들이 있습니다. 여기서 tini 의 경우 Docker 1.13.0 부터는 --init option으로 사용될 수 있도록 추가되었습니다.

https://docs.docker.com/engine/release-notes/prior-releases/#runtime-1

https://github.com/moby/moby/pull/26061

7. Kubernetes의 PID namespace sharing

Google에서는 Kubernetes에서 Zombie process reaping을 위해 PID namespace sharing(--docker-disable-shared-pid=false)을 제안합니다.

PID namespace sharing을 하게 되면, 같은 Pod의 컨테이너들은 하나의 PID namespace를 공유하게 되고, 기존에 Kubernetes에서 Network namespace를 유지하던 pause 컨테이너의 프로세스가 pid=1로서 Reaping을 담당하게 됩니다.

 

위 옵션은, Kubernetes 1.7에서 기본적으로 해당 옵션이 활성화되어 있지만, 1.8 버전부터는 다시 비활성화되었기 때문에 직접 활성화해야 합니다.

 

혹시 궁금하신 분들을 위해... 왜 옵션의 기본값 rollback이 되었는지에 대한 배경은 아래 링크에서 확인하실 수 있습니다. (요약해보면... 이러한 옵션의 기본적인 활성화는 Container에서 systemd 와 같은 init system에 의존하는 일부 애플리케이션들과 충돌을 일으키고, 여러 가지 측면에서 좀 더 고려될 필요가 있다는 판단하에 일단 1.8에서 다시 되돌린 것으로 보입니다.)

https://github.com/kubernetes/kubernetes/issues/48937 (대략 여기쯤부터 보시면 될 것 같습니다.)

 

다시 돌아와서, pause process의 코드의 sigreap function을 한번 살펴보면 waitpid syscall을 통해 Reaping을 수행하도록 구현된 것을 확인할 수 있습니다.

https://github.com/kubernetes/kubernetes/blob/68108c70e29a74bb455ab63adeb5725a37e94e4f/build/pause/linux/pause.c#L37-L40

 

부록)

위 코드를 보면 pause process는 SIGTERM을 받으면, pid=1이지만 같은 PID namespace에 있는 자식 프로세스들에게 SIGTERM 전달을 해주지 않습니다.

 

이유는 Pod의 Lifecycle에 있습니다.

Kubernetes는 Container보다 pod이라는 추상화된 상태로 실행 단위를 정의하기 때문에, Pod 내부의 Container 종료도 역시 Pod 단위로 이루어지는데요.

https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle/#pod-termination

그러니까 pod termination의 동작을 살펴보면,

  1. 각 노드마다 실행되는 kubelet이 먼저 해당 pod에 있는 각각의 container에 먼저 SIGTERM을 보냅니다. (Graceful Shutdown 시도)
  2. 이때 미리 정의해두었던 terminationGracePeriodSeconds 유예시간(기본 30초) 이후에도 컨테이너가 종료되지 않으면 SIGKILL로 강제 종료
  3. 마지막에 hidden container인 pause 컨테이너를 종료하게 됩니다.

kubelet이 pod에 존재하는 컨테이너들에 각각 SIGTERM을 먼저 보내서 모두 종료하고 마지막으로 pause 와 같은 hidden container를 종료하기 때문에, pause 컨테이너가 SIGTERM을 전파해 줄 필요가 없는 것이죠.

 

 

또, 위 k8s 문서에서는 send a TERM signal to process 1 inside each container라고 서술하고 있는데, PID namespace가 공유된 경우엔 같은 Pod에서 pid=1은 항상 pause process 일 텐데 pod lifecycle이 어떻게 동작할지 궁금하네요.

확인해보니 실제로는 docker client가 각각의 Container에 stop 명령어를 보내는 것과 똑같이 동작합니다.

https://github.com/kubernetes/kubernetes/blob/60cb99909fd62d5edc37de61ab9a39b217052ec8/pkg/kubelet/dockershim/docker_container.go#L305-L312

 

그리고 docker client는 stop 핸들러가 동작하면 각각 Container의 "main process"에 SIGTERM을 보내고, 유예시간 이후에 SIGKILL로 강제 종료를 하게 됩니다. 실제로 docker stop 명령어도 다음과 같이 설명하고 있네요.

The main process inside the container will receive SIGTERM, and after a grace period, SIGKILL.
For example uses of this command, refer to the examples section below.
https://docs.docker.com/engine/reference/commandline/stop/

 

Docker를 Container runtime에서 PID namespace sharing 을 사용하는 겅우에도 pid namespace를 공유하더라도 각 컨테이너의 종료는 pod lifecycle에 따라 잘 동작하겠지요.

8. 최종정리

중간 정리(5. 여기까지 이야기한 내용을 정리해봅시다.)까지는 Zombie process, Orphan process, init system, Zombie process reaping에 대해 자세히 설명하였습니다.

 

그리고 Container에서의 Zombie process reaping에 대한 내용을 다뤘는데요. 정리해보면...

  1. Container에서는 최초로 실행되는 프로세스가 pid=1이 됨
    • Container 내부에서 Zombie process가 발생하지 않을 것이란 보장이 필요
    • 그렇지 않으면, Zombie process reaping 문제가 발생할 수 있음
  2. Container에서의 Zombie process reaping 방법
    • bash를 entrypoint로
      • 다만, SIGTERM을 자식 프로세스에 전달해 주지 않기 때문에, Graceful shutdown이 불가능(모든 자식 프로세스들은 강제 종료됨)
    • Application에 직접 reaping 을 구현
    • 기존 init system 사용(SysV init, systemd, upstart ...)
      • 단순 Zombie process reaping을 위해 전체 시스템 초기화 용도의 init system들을 가져다 쓰는 것은 권장되지 않음.
  3. 이런 문제들로 인해 Container 용도의 경량화된 init system들이 등장
  4. Kubernetes 에서의 PID namespace sharing
    • Google에서는 Kubernetes에서 Zombie process reaping을 위해 PID namespace sharing(--docker-disable-shared-pid=false)을 제안
    • k8s 1.7에선 기본적으로 활성화, 하지만 1.8에선 다시 비활성화됨
    • k8s pod의 hidden container인 pause 컨테이너의 프로세스가 pid=1로서 reaping을 담당
    • (부록) kubelet이 pod 종료 시 미리 정의된 pod lifecycle 대로 내부의 각 container들을 먼저 종료한 뒤 마지막으로 pause 컨테이너를 종료하기 때문에, pause 컨테이너는 SIGTERM 전파를 해줄 필요가 없음

9. 참고한 글(함께 읽어보면 좋은 글)

 

다소 장황하게 설명한 것 같은데, 끝까지 읽어주신 분들께 감사드립니다. ( _ _)

댓글
최근에 올라온 글
Total
Today
Yesterday