pid 네임스페이스에 관한 이슈와 해결책

[들어 가기전]
고아(orphan) process : 부모 프로세스가 자식 프로세스보다 먼저 종료되면, 자식 프로세스는 고아 프로세스가 된다. 고아 프로세스는 init(pid 1)에서 거둬 들여, 고아의 부모 프로세스는 init(pid 1)이 된다. 고아 프로세스가 종료되면 init 이 종료 상태를 파악해 좀비 프로세스가 되는것을 막는다.

좀비(zombie/defunct) process : 자식 프로세스는 종료되었지만 부모 프로세스가 wait()등으로 자식 프로세스의 종료 상태를 파악하지 못해 자식 프로세스를 거둬 드릴 수 없는 경우로 process table 에만 남아 있어 kill -9 좀비프로세스PID 로도 삭제되지 않는다.
좀비 프로세스를 삭제 방법
- kill -CHLD PPID (부모 프로세스가 CHLD 시그널을 처리할 수 있는 경우에만 가능)
- kill -KILL PPID (좀비 프로세스의 부모프로세스를 제거한다. 모든 child 가 죽기 때문에 신중해야함)

pid 네임스페이스에 관한 이슈와 해결책을 보고 정리해 봤다

!!! Caution 잘못된 번역이나 이해로 틀릴 수 있음!!! ^^;;


[linux 의 pid(process identifier)]

user space 의 root 프로세스는 init (pid 1, 이것의 부모는 pid 0 으로 커널)이다.
1 번 프로세스가 죽으면 커널이 패닉을 발생해 시스템을 재부팅해야 한다.


[linux namespace 생성]

리눅스 네임스페이스들(uts(host, domain 구분), network, mount, ipc, cgroup(cpu,memory,disk,network 등 리소스 구분), pid)는 unshare 시스템콜로 생성한다. unshare 하면 바로 새로운 네임스페이스로 들어간다.
하지만 pid 네임스페이스의 경우 unshare 로 바로 pid 네임스페이스로 들어가지 않고, fork 로 pid 네임스페이스에 pid 1(init)번를� 만든다. 이 1번 프로세스는 pid 네임스페이스 밖(호스트)에서는 다른 pid 값이다.


[네임스페이스에서의 pid 1]

1. pid 네임스페이스의 init 프로세스는 기본 signal 핸들러가 없어, HUP, INT, QUIT, TERM, KILL .. 등의 시그널 처리를 하지 못한다.(별다른 옵션 없이 docker 컨테이너를 수행하면 ctrl-c 가 동작하지 않는다. 그래서 docker kill 등을 사용한다.)

2. pid 네임스페이스의 init 외 다른 프로세스가 자식 프로세스보다 먼저 죽으면 그 자식 프로세스의 부모는 init (pid 1)이 되고, init 은 프로세스들이 exit 상태를 수집해 커널이 process table 에서 정리할 수 있도록 한다.

3. pid 네임스페이스의 init(pid 1)이 죽으면, 같은 네임스페이스의 모든 프로세스가 강제로 제거되며, pid 네임스페이스는 clean up 된다.


[docker 의 실수]

docker(runc:lightweight docker runtime)는 컨테이너의 entrypoint 를 pid 1 로 하는 새로운 pid 네임스페이스를 만든다. pid 1번에서 signal 처리가 되어 있지 않으면 ctrl-c 와 같은 signal 이 동작하지 않는다. 손자 프로세스가 종료(exit)되기 전에 죽은 자식 프로세스를 fork 하게 되면 zombie(좀비) 프로세스가 컨테이너에 쌓이게 되고, 결국 process table 을 꽉 채우게되는 상황이 발생할 수 있다.
이런 문제를 피하기 위해 fork-exec 를 할 수 있는 특별할 application process 를 실행할 수 있다. 이는 컨테이너가 복잡해지지만 실제 호스트(컨테이너가 아닌)의 init 처럼 만들 수 있다. 사람들은 종속성 격리의 이점을 희생하더라도 여러개의 apt 를 실행(dockerfile 에서 apt install a b c 컨테이너 내에서 패키지를 설치)을 포함시키는 경향이 있다.


[Rkt 해결책]

Rkt(rkt is an application container engine) 로 위 문제를 다소 해결할 수 있다. Rkt 는 사용자가 만든 시작 프로세스(container process)가 init 이 아니라고 가정하고 init (systemd) 를 생성하고 systemd 로 컨테이너에서 동작하는 filesystem 네임스페이스를 만들고 구동시킨다. systemd 는 pid 1번이 되고, (사용자가 만든) container process 는 pid 2번으로 동작한다. 만약 container process 가 init 이라면 이것도 pid 2 로 동작해 또 다른 이슈가 될 수 있다.


[간단한 대체 방법]

fork 후 pid 네임스페이스에서 container process 를 바로 실행하는 대신, spawner 가 다시 fork 한다. 이 두번째 fork 는 container spawner 가 pid 1이 되도록 하고, 모든 child 프로세스들에 signal을 전달할 수 있는 signal handler 를 구성한다. 이렇게 하면 child 프로세스가 죽을때 까지 좀비 프로세스를 거둬들일 수 있고, 이 시점에 container process 의 종료 상태를 수집해 컨테이너화 시스템에 전달한다. 이것은 ctrl-c 와 같은 signal 을 처리할 수 있고 zombie 프로세스도 적절히 거둬들 수 있다는 의미다.
docker 1.13 부터 가능하며 --init 플래그를 사용하면 된다.


[하나의 pod 내에서의 다중 컨테이너]

pod 는 네임스페이스를 공유 하는 컨테이너들의 집합이다. rkt 에서는 파일시스템 네임스페이스를 제외한 모든 네임스페이스들이 공유된다.
앞서 pid 네임스페이스 이슈로 kubernetes(k8s) 는 pod 내 컨테이너들간에 pid 네임스페이스를 공유 하지 않는다. 때문에 같은 pod 내의 container 들 끼리 signal 주고 받을 수 없고, 앞서 언급한 init 문제를 가지고 있어 container processor 는 pid 1로 실행된다.
rkt 방법을 사용하면 컨테이너 안에 init 프로세스를 만들지 않아도 통신이나 signal 을 주고받을 수 있는 컨테이너들을 쉽게 생성할 수 있다. 하지만 이미 존재하는 pod 에 컨테이너를 추가하는 상황에서는 바로 되지는 않는다.


[pod 에 컨테이너 추가하기]

k8s 는 pod sandbox 라는 개념이 있다. k8s 컨테이너 런타임은 컨테이너 시작에 앞서 리소스를 할당하는데, 이는 특히 네트워킹에 유용하고 존재하는 pod 에 컨테이너를 추가하는데도 적용할 수 있다. sandbox pod 를 먼저 생성 후 컨테이너를 하나씩 추가하는 경우, 왜 나중에 컨테이너 추가하는게 허용되지 않을까? 이는 데이터베이스 백업이나 로그 수집과 같은 주기적인 작업에 특히 유용할 것이다.
rkt(에서는 container 를 app 으로 부른다.)는 실험적으로 컨테이너와 독립적으로 pod 생성을 허용하여, 추후에라도 pod 에서 컨테이너를 추가,삭제할 수 있다. 별도 실행되는 유닛(프로세스?)없이 systemd 로 가능하게 한다. 새로운 app(container) 시작 요청이 있으면 pod 의 systemd 와 통신한다. 이러한 모델로 init 프로세스는 다음과 같은 추가적인 특권(privilege)을 가진다.
- 호스트 파일시스템 네임스페이스에 접근할 수 있어서 컨테이너 시작을 위한 파일시스템 네임스페이스를 생성할 수 있다.
- 각 app 시작에 필요한 privilege 들을 알 수 없어 전체 privilege 을 유지해야 한다.
- 컨테이너상의 모든 실행중인 프로세스를 볼 수 있다.
non-sandbox 모델이라면 init 프로세스는 child 프로세스를 시작한 다음 compromise 영향을 최소화 하기 위해 privilege 를 삭제한다.


[sandboxes 와 pid namespaces]

init, sandboxes, pid namespace 를 이용한 방법들 각각 약간의 결점(단점)이 있고, 다음 옵션을 사용할 수 있다.
1. pid namespace 가 sandbox 와 같이 생성되지 않을때, 각 컨테이너는 자신의만의 pid 네임스페이스를 가진다. k8s 에서 사용하는 방식이며, signal 처리를 위한 간단한 init 전략이다. 중요 단점으로는 pod 내 프로세스들간 signal 을 통신을 할 수 없다.
2. pid namespace 가 sandbox 와 같이 생성되지 않을때, sandbox 에서 처음으로 시작되는 컨테이너에 pid namespace 가 생성된다. 이 상태에서는 프로세스간 signal을 주고 받을 수 있다. 단점은 첫번째 프로세스가 pod 의 master 가 되고 이 master 프로세스가 죽으면 컨테이너 내 모든 프로세스는 커널에 의해 종료된다. 때문에 master 프로세스는 pod lifecycle 동안 살아 있어야 한다.
3. pid namespace 가 sandbox와 함께 생성될때, 다른 프로세스를 실행할 수 있는 smart init 프로세스를 포함한다. 이것은 rkt app sandbox 가 동작하는 방식이다. 단점으로는 init 프로세스가 너무 많은 privilege 를 가져 그 만큼 보안 공격에 취약할 수 있다.
4. pid namespace 가 sandbox와 함께 생성될때, sandbox 는 signal을 처리하고 zombie 프로세스를 회수하는 간단한 init 프로세스를 포함한다. 다른 프로세스들은 pid namespace 로 들어갈 수 있지만 부모 프로세스들은 네임스페이스 밖에 있다. init 프로세스는 새로운 컨테이너를 시작하지 않기 때문에, privilege 나 호스트 파일시스템 액세스를 유지할 필요가 없다. 단점은 네임스페이스 내의 각 프로세스들은 pid 0인 부모를 가져 일반적인 process tree 가 깨진다.
5. pid namespace 와 init 이 정확히 위 4번 옵션과 같을때, 다른 프로세스들은 pid namespace 에 들어가고 daemonize 된다. 커널은 위 4번에 pid 0으로 깨진 process tree 에서의 부모를 init 으로 돌릴 것이다. 단점은 daemonize 후 밖에 있는 새로운 프로세스를 모니터링 하기 어렵다. containerizationo system 은 간단하게 프로세스를 waiting 하는 대신 pid 를 통해 프로세스를 추적하도록 강요된다.

(필자는) 위 옵션 중 4번, 5번을 선호한다. 사실 기대하는 프로세스의 lifetime 에 따라 하나를 선택하면 된다. 위 옵션들은 프로세스를 오래 실행하기 위한 좋은 방법들이고 특히 프로세스를 spawner 로 daemonize 프로세스를 종료하는 docker 환경에 좋은 솔루션이다. 만약 간단하게 끝나는 프로세스라면 4번 옵션을 사용하고 프로세스를 pid process tree 와 분리해 유지하면 아주 쉽다. 
어떤 작업들은 k8s 가 init 처럼 동작하는 pause container를 생성하는것과 비슷하다. k8s 에 pid namespace 공유가 지원되면 5번 옵션도 가능할것이다.


[결론]

pid namespace 에는 꽤 숨겨진 복잡도가 존재한다. 오늘날 containerization 시스템은 중요한 단점과 이걸 피할 수 있는 방법들에 따라 선택한다. docker 의 싱글 컨테이너의 단점은 잘 이해되고 적당한 해결방법이 있는 반면, 컨테이너 spawner 가 init 처럼 동작하게 허용되면 컨테이너 빌더 작업이 단순화된다.
그룹 컨테이너의 경우 init 분리 방식이 rkt 가 docker 보다 우수하다. rkt 방식은 현재 k8s pod 모델에서 가능하지 않는 signal 을 통한 프로세스간 통신을 허용한다. 하지만 지연된 시작 컨테이너가 포함되기 때문에 rkt 방식도 몇가지 단점을 보인다.
지연된 시작 컨테이너에 가장 강력한 해결 방법은 pid namespace 와 함께 간단한 init 프로세스를 시작하는것이다. 하지만 새로운 컨테이너를 생성하려면 container spawner 를 통해야 한다. 이는 init 프로세스가 privileges 삭제를 허용하여, 공격을 차단한다. spawner 는 새 프로세스를 daemonize 하고 process tree 를 일관되게 유지하거나 새 프로세스의 부모로 남아 프로세스 관리를 단순화할 수 있다.


comments: