본문 바로가기
Server/Ubuntu

The Curious Case of Pid Namespaces And How Containers Can Share Them

by 유주원 2021. 12. 17.

원문 url

https://hackernoon.com/the-curious-case-of-pid-namespaces-1ce86b6bc900

 

The Curious Case of Pid Namespaces | Hacker Noon

 

hackernoon.com

 

네임스페이스는 리눅스 컨테이너의 기본적인 컴포넌트 중의 하나이며, 공유 자원의 격리를 제공한다 : 각각의 애플리케이션에 대해 시스템 상에서 그들만의 고유한 공간을 제공한다. 네임스페이스 덕분에 각각의 docker 컨테이너는 각각의 파일시스템과 네트워크를 가지고 있는 것처럼 보인다. 리눅스는 많은 release를 거쳐 점진적으로 네임스페이스 지원을 추가했다. 이러한 점진적인 변화로 인해, 네임스페이스의 각 타입은 고유의 해결과제를 가지고 있다. 특히 Pid 네임스페이스는 멀티 프로세싱이 포함되었을 때, 특별한 처리를 요구한다.

 

Pids in Linux

리눅스에서의 프로세스는 트리와 비슷한 구조로 되어 있다. 커널의 각 프로세스는 Pid라고 불리는 유일한 프로세스 식별자를 가진다.

각 프로세스의 레코드는 직접적으로 부모의 pid를 추적한다. fork syscall을 통해 프로세스가 생성되면,  pid가 부모 프로세스에게 전달된다.  커널은 자식에 대한 새로운 pid를 생성하고 그 식별자를 호출 프로세스에 반환하지만, 이 pid의 추적을 수동으로 유지하는 것은 부모에게 달려 있다.

 

커널에 의해 시작되는 첫 번째 프로세스는 pid 1을 가진다. 이 프로세스는 init process 또는 간단히 'init'으로 불린다. init의 부모 pid는 pid 0이며, 부모가 커널임을 의미한다. pid 1은 user-space process tree의 루트이다 : 각 프로세스의 부모를 재귀적으로 따라가면, 모든 프로세스에서 리눅스 시스템의 pid 1에 도달할 수 있다. 만약 pid 1이 죽으면, 커널은 패닉이 되고 시스템을 재부팅 해야 한다.

 

A Quick Overview of Namespaces

리눅스 네임스페이스는 unshare syscall로 생성되며, 생성할 네임스페이스를 나타내는 플래그 집합을 전달한다. 대부분의 경우 unshare를 통해 새로운 네임스페이스로 바로 이동한다. 예를 들어, 프로세스가 네트워크 네임스페이스를 생성하면 곧바로 디바이스가 없는 빈 상태의 네트워크가 표시된다. 

pid 네임스페이스는 약간 다르다 : 만약 pid 네임스페이스를 unshare할 때, 프로세스는 즉시 새로운 네임스페이스로 진입하지 않는다. 대신에, fork가 요구된다. fork가 되면 자식 프로세스가 pid 네임스페이스에 진입하고 pid 1이 된다. 이것은 특별한 속성을 부여한다.

pid 네임스페이스는 프로세스 계층의 분리된 관점을 생성한다는 것은 중요하다. 다시 말해서 fork 프로세스는 실제로 두 개의 pid를 가진다. : 네임스페이스 내부에 pid 1과 네임스페이스 밖에서 보여지는 다른 pid를 가진다.

 

Pid 1 in a Namespace

네임스페이스 내부에 있는 init (pid 1)은 다른 프로세스와 비교해서 3개의 고유한 특징을 가진다.

1) 자동적으로 deault signal hander를 가지지 않기 때문에, 해당 신호에 대한 handler를 등록하지 않는 한 해당 signal은 무시된다.(

많은 dockerize된 프로세스가 ctrl-c에 응답하지 않고, 강제로 'docker kill' 등의 작업을 해줘야 하는 이유이다.)

2) 만약 네임스페이스 내의 다른 프로세스가 자식보다 먼저 죽게 된다면, 해당 프로세스의 자식은 init(pid 1)을 부모프로세스로 삼는다. 이것은 커널이 프로세스 테이블로부터 죽은 프로세스를 제거할 수 있도록 하기 위해 init 프로세스가 프로세스의 exit status를 수집하도록 허용해 준다.

3)  만약 init process가 죽으면, pid 네임스페이스에 있는 모든 다른 프로세스들은 강제적으로 종료되고, 네임스페이스는 깨끗하게 정리될 것이다.

 

init process가 컨테이너의 수명과 밀접하게 관련되어 있음은 분명한 사실이다.

 

The Docker "Mistake"

Docker (runc)는 컨테이너 entrypoint(cmd)로 명시된 프로세스를 pid 1로써 새로운 pid 네임스페이스에 명시한다. 이것은 일반적인 응용 프로그램 프로세스에서는 pid 1로 실행되도록 설계되지 않았기 때문에 몇 가지 예기치 않은 동작을 유발시킬 수 있다.

자체 signal handler를 설정하지 않으면 프로세스에 signal event는 동작하지 않는다. 만약 child 프로세스를 fork했는데, 해당 프로세스가 손자 프로세스가 죽기 전에 죽었다면, 컨테이너 상에는 좀비 프로세스가 process table에 계속 쌓이게 될 것이다.

Docker는 컨테이너 내에서 특별한 초기 프로세스가 실행되고, 이 프로세스가 application 프로세스를 fork-exec 시키는게 가능하게 해주었다. 많은 컨테이너가 signal hander 문제를 피하기 위해 이 방법을 사용한다.

이 방법으로 인해 컨테이너의 구조가 복잡해진다. 일단 컨테이너가 실제 init system을 가지고 있으면, 사람들은 컨테이너 내에 멀티 프로세스로 동작하는 apt를 실행시키려 할 것이고, 이로 인해 의존성 isolation에서 손해를 얻을 것이다.

 

The Rkt "Solution"

Rkt는 이 문제에 대해 보다 건전한 방법으로 접근한다. 시작하는 프로세스가 init 프로세스가 아니라고 가정하고, init process(systemd)를 생성하고, systemd는 컨테이너 프로세스를 위한 파일시스템 네임스페이스를 생성한다. systemd는 네임스페이스 내에서 pid1이 되고, 컨테이너 프로세스는 pid 2로써 동작한다. 즉, 이 말은 컨테이너가 init 프로세스를 제공하더라도 pid 2가 되겠지만, 실제로는 그렇게 크게 이슈가 되진 않는다.

 

A Simpler Alternative

더 단순한 솔루션이 있지만, 해당 솔루션에서는 컨테이너 spawner가 init 역할을 해야한다. pid namespamce로 fork를 진행하면, 컨테이너 프로세스를 즉시 실행하는 대신에, spawner가 다시 fork를 할 수 있다. 두 번째 fork는 컨테이너 spawner가 pid 1이 되도록 해준다. 

이를 통해 자식들에게 모든 신호가 전달될 수 있는 signal handler가 설정될 수 있다. 그런 다음 자식이 죽을 때, 좀비 프로세스를 거둘 수 있으며, 해당 시점에서 컨테이너 프로세스의 exit status를 수집하고, 이를  컨테이너 시스템에 전달 할 수 있다. 이 의미는 signal이 기대한 대로 동작이 되며(ctrl-c가 동작됨), 좀비 프로세스가 거둬지는 것을 의미한다.

비슷한 대안이 docker 1.13 이후로 제공이 되었다. 컨테이너를 시작할 때 --init flag를 전달하면, docker가 간단하게 init 프로세스를 시작한다. 그렇지만 해당 방법이 널리 사용되지는 않고 있고, 몇 가지의 버그를 가지고 있다. 프로세스에 ctrl-c를 하였지만, init 프로세스가 종료되지 않는 문제를 발견했다.

 

Multiple Containers in a Pod

여러 관련있는 프로세스를 함께 실행하는 것이 종종 유리하지만, dependency에 의존적이지 않도록 이러한 프로세스들을 별도로 번들화 하는것이 더 바람직하다. 이렇게 하기 위해서, rkt와 kubernetes는 pods란 아이디어를 도입했다. pod는 몇몇의 네임스페이스들을 공유하는 관련된 컨테이너들의 집합이다. rkt 구현에서, 파일시스템을 제외한 모든 네임스페이스는 공유된다.

쿠버네티스 역시 pod를 지원하기 때문에, docker를 사용하는 것과 유사한 접근방식을 보여준다. 앞서 언급한 pid 네임스페이스 이슈 때문에, 쿠버네티스는 아직 동일한 pod내의 컨테이너들 사이에서 pid 네임스페이스를 공유하지 않는다. 이것은 동일한 pod 내에 있는 프로세스들이 서로 신호를 보낼 수 없음을 의미힌다. 게다가 pod 내의 각각의 컨테이너는 앞서 언급한 init 이슈가 존재한다: 모든 컨테이너 프로세스는 pid 1로 실행될 것이다.

rkt 접근 방법은 pod에 대해 나은 점을 보인다. 컨테이너 내부에서 init process를 실행할 필요가 없고, 프로세스간 서로 신호를 보낼 수 있는 멀티 프로세스를 생성하는 게 쉽다. 운나쁘게도 기존 존재하고 있는 pod에 컨테이너를 추가하려고 할 땐 상황이 달라진다.

 

Adding a Containers to a Pod

컨테이너 runtime interface를 통해, 쿠버네티스에선 pod sandbox의 개념을 도입했다. 이것을 통해 컨테이너 runtime은 컨테이너들을 시작하기 전에 리소스를 할당할 수 있다. 네트워킹에 특히 유용하지만, 이 개념을 통해 기존 pod에 컨테이너를 추가할 수도 있다. 만약 pod sandbox를 처음 생성하고, 그 다음 컨테이너들을 하나씩 시작했다면, 나중에 컨테이너를 추가하는 것도 가능할 것이다. 이러한 방법은 특히 데이터베이스 백업이나 로그 수집 등과 같은 주기적인 작업에 유용하다.

Rkt는 이러한 기능을 실험적으로 도입함으로써, 모든 컨테이너의 독립적인 pod 생성을 가능하게 해 준다. 컨테이너(rkt 용어로 "apps")는 나중에 pod에서 추가되거나 제거될 수 있다. Rkt는 실행 unit 없이 systemd를 시작한다. 그런 다음 새로운 앱들을 실행하기 위해 pod의 systemd와 통신한다. 해당 방법에서는 init 프로세스에 추가 권한이 있고, 새로운 attack vector가 도입되지만, 꽤나 좋은 솔루션으로 볼 수 있다.

rkt sandbox 모델에서의 systemd 프로세스 :

 

-  호스트의 파일시스템 네임스페이스로의 접근이 가능하기 때문에, 컨테이너를 시작할 때 파일시스템 네임스페이스를 생성할 수 있다.

- 각각의 새로운 앱들이 필요로 하는 권한 집합을 미리 알지 못하기 때문에 전체 권한을 유지해야 한다.

- 컨테이너에서 실행 중인 다른 프로세스들을 볼 수 있다.

 

non-sandbox 모델에서는, init 프로세스는 자식 프로세스를 시작한 다음 피해를 최소화 하기 위해 권환을 회수할 수 있다.

 

SandBoxes and Pid Namespaces

init, sandbox 및 pid 네임스페이스를 처리하기 위한 몇 가지 방법이 있다. 각각의 방법 들은 몇 가지 단점들을 가지고 있다.

 

- pid 네임스페이스는 sandbox와 함께 생성되지 않는다. 대신 각각의 컨테이너는 자신의 pid 네임스페이스를 가진다. 이 방법은 오늘날의 쿠버네티스 동작과 일치한다. 그리고 단일 프로세스를 처리하는데 있어서 이점도 가지고 있다. 주요 단점은 pod내에 프로세스들에게 신호를 보낼 수 없다는 점이다.

- pid 네임스페이스는 sandbox와 함께 생성되지 않는다. 대신, 각 pid 네임스페이스는 sandbox에서 첫 번째 컨테이너가 시작될 때 생성된다. 이 방법으로 하면 각각의 프로세스에 신호를 보낼 수가 있다. 단점은 첫 번째 프로세스가 pod의 마스터가 되며, 만약에 죽게 되면 모든 다른 컨테이너 프로세스들은 종료가 된다. 마스터 프로세스는 pod의 생명 주기 동안 반드시 살아 있어야 한다.

- pid 네임스페이스가 sandbox와 함께 생성된다. sandbox는 다른 프로세스를 시작할 때 사용되는 init 프로세스를 포함한다. rkt app sandbox가 동작하는 방법이다. 위에서 언급한 대로, 단점은 init 프로세스가 너무 많은 권한을 가지고 있으며, 새로운 attack vector가 도입된다는 것이다.

- sandbox와 함께 pid 네임스페이스가 생성된다. sandbox는 신호를 핸들링하고, 좀비 프로세스를 처리하는 간단한 init 프로세스를 가지고 있다. 각각의 다른 프로세스는 pid 네임스페이스에 진입하지만 여전히 네임스페이스 바깥 쪽에 활성화된 부모가 있다. init 프로세스가 새로운 컨테이너를 시작하기 않기 때문에, 권한을 유지할 필요는 없으며,  호스트 파일시스템에 접근할 필요도 없다. 단점은 네임스페이스 내부에서 각각의 프로세스가 pid 0의 부모를 가진 것처럼 보이기 때문에 프로세스 트리의 정상적인 구조는 깨졌다고 봐야 한다.

- pid 네임스페이스와 init의 동작이 위의 방법과 동일하다. 각각의 프로세스는 pid 네임스페이스로 진입하고, 데몬화한다. (부모가 종료됨) 커널은 프로세스의 부모를 init으로 변경해서 위의 깨진 프로세스 트리 구조를 수정한다. 단점은 데몬화가 어렵게 된 후, 외부에서 새로운 프로세스를 모니터링 하는 것이다. 컨테이너 시스템에서는 단순하게 프로세스를 기다리는 것 대신에 pid를 통해 프로세스를 강제로 추적해야 한다. 

 

어떤 방법이 가장 좋아 보이나? 필자의 경우 4와 5번째 방법을 선호 한다. 사실 프로세스의 예상 수명에 따라 둘 중에 선택할 수 있다. 5번째 방법은 장기적으로 돌아가는 프로세스(특히 process spawner가 process를 데몬화 하는 docker 같은 경우) 에 적합하다. 만약 프로세스가 짧은 수명을 가지고 있다면 4번째 방법이 적합하며, 대신 pid 프로세스 트리와 프로세스를 분라하면 작업이 간단해진다.

 

몇가지 작업은 쿠버네티스가 init 역할을 할 수 있는 pause 컨테이너를 만들기 위한 방법과 비슷하게 보인다. 일단 쿠버네티스가 pid 네임스페이스를 공유힐 수 있도록 지원되면, 5번째 방법은 곧 뒤따를 것이다.

 

Conclusion

pid 네임스페이스에는 숨겨진 복잡성이 꽤나 존재한다. 또한 오늘날 컨테이너 시스템이 가진 중요한 단점들은 다른 대안들을 통해 회피 할 수 있다. docker의 단일 컨테이너에 대한 단점은 충분히 이해되며, 컨테이너 spawner가 init의 역할을 하도록 허요하면, 충분히 합리적인 해결 방안을 가진다. 

컨테이너 그룹의 경우, rkt의 init 분리 방식이 docker 접근방법보다 더 우수하다. 이것은 현재 쿠버네티스 pod 모델에서는 가능하지 않은 프로세스 간의 신호를 통한 통신을 가능하게 해준다. 하지만 시작 컨테이너가 지연되면, rkt 접근 방식에서도 몇가지 단점을 보여주게 된다. 

지연된 시작 컨테이너에 대한 가장 강력한 접근 방법은 단순한 init 프로세스를 pid 네임스페이스와 함께 시작하는 것이다. 대신 컨테이너 spawner를 경유하여 새로운 컨테이너 프로세스들을 만들어야 한다. 이를 통해 init 프로세스의 권한을 제한하고 vector 공격을 차단 할 수 있다. spawner는 새로운 프로세스를 데몬화 하여 프로세스 트리를 일관되게 유지하거나 새로운 프로세스의 부모로 남아 프로세스 관리를 단순화 하도록 할 수 있다.