Skip to content
cover-dataengineering

컨테이너 환경을 위한 초기화 시스템 (tini, dumb-init)

  • DataEngineering

📅 May 27, 2022

⏱️5 min read

쿠버네티스 기반의 데이터플랫폼을 운영하다보면 이미지의 ENTRYPOINTtini, dumb-init과 같은 명령어를 사용하는 경우가 많습니다. 예를 들어 Airflow에서는 dumb-init을, SparkOperator에서는 tini를 사용하고 있습니다. 이 글에서는 컨테이너 환경에서 왜 이러한 초기화 시스템이 필요한지 알아보려 합니다.



PID 1의 역할

cmd-top

리눅스에서 PID 1은 부팅 시 커널에 의해 최초로 실행되는 init 프로세스입니다. init 프로세스는 SSH 데몬, Docker 데몬, Apache/Nginx 시작 등과 같은 시스템들의 시작을 담당합니다. 각 프로세스는 차례로 추가 하위 프로세스를 생성할 수 있습니다. PID 1은 결국 모든 프로세스의 최종 부모 프로세스 역할을 하게 됩니다. 현재 배포판들은 복잡한 init 대신 systemd가 초기화 시스템의 역할을 대신하고 있습니다.

zombie

여기까지는 일반적인 상황입니다. 만약 예기치 못한 상황으로 인해 프로세스가 종료되면 어떻게 될까요? bash(PID 5) 프로세스가 종료된다고 가정해보겠습니다. 5번은 이제 좀비 프로세스로 변합니다.

왜 이런 일이 발생할까요? Unix는 부모 프로세스가 종료 상태를 수집하기 위해 자식 프로세스 종료를 명시적으로 대기하는 방식으로 설계되었기 때문입니다. 좀비 프로세스는 부모 프로세스가 시스템 호출의 waitpid() 시스템 명령을 수행할 때까지 존재합니다. 좀비를 제거하기 위해 자식 프로세스에서 waitpid()를 호출하는 작업을 reaping이라고 합니다.

대부분의 경우 이러한 상황이 큰 문제가 되지 않습니다. 많은 어플리케이션이 자식 프로세스를 올바르게 가져옵니다. sshd를 사용하는 위의 예시에서 bash가 종료되면 운영 체제는 SIGCHLD 신호를 sshd에 보내 깨우게 합니다. sshd는 신호를 통해 인지하고 자식 프로세스를 거둡니다.

하지만 부모 프로세스가 의도적으로 종료되거나 사용자가 프로세스를 종료시켰다고 가정해보겠습니다. 그러면 그 자식 프로세스들은 어떻게 될까요? 더 이상 상위 프로세스가 없으므로 **고아 상태(orphaned)**가 됩니다.

init 프로세스는 이를 해결하기 위한 작업을 수행합니다. 바로 고아 상태가 된 자식 프로세스를 거두는 것(adopt) 입니다. init 프로세스에 의해 생성된 적이 없지만 프로세스의 부모가 되어 좀비 프로세스가 되지 않도록 정리해주는 역할을 합니다.

adopted

백그라운드에서 실행되는 nginx 프로세스를 예시로 들어보겠습니다. 먼저 nginx는 자식 프로세스를 만듭니다. 그리고 nginx 프로세스가 종료됩니다. 고아가 된 nginx 자식 프로세스는 init 프로세스가 거두어들입니다.

이러한 init 프로세스의 역할 덕분에 우리는 어플리케이션을 개발할 때 크게 신경쓰지 않게 되었습니다. 하지만 쿠버네티스를 포함한 컨테이너 환경의 경우, 조금 다릅니다.


컨테이너 내부에서의 프로세스 동작

도커는 컨테이너 ENTRYPOINT(CMD)로 명시된 프로세스를 PID 1로써 새로운 PID 네임스페이스에 정의합니다. 그리고 컨테이너 내부에 있는 PID 1 프로세스에만 신호를 보내 종료할 수 있습니다. 이러한 이유로 컨테이너는 경량화 이미지를 기반으로 단일 프로세스만 실행하는 경우가 많습니다. 두 가지 예시를 살펴보겠습니다.

1. sh 프로세스가 PID 1인 경우
Dockerfile을 통해 다음과 같은 컨테이너 명령을 지정하면 실행을 위해 쉘에 전달됩니다. 그 결과 아래와 같은 프로세스 트리가 생성됩니다.

- docker run (on the host machine)
  - /bin/sh (PID 1, inside container)
    - python my_server.py (PID 2, inside container)

쉘을 PID 1로 사용하면 실제로 2번 프로세스에 signal를 보내는 것이 거의 불가능합니다. 쉘로 보낸 신호는 하위 프로세스로 전달되지 않으며 프로세스가 완료될 때까지 셸이 종료되지 않습니다. 이 경우 컨테이너를 종료하기 위해 SIGKILL을 보내야 합니다.

2. 내 프로세스가 PID 1인 경우
Dockerfile에서 다음과 같이 정의하면 프로세스가 즉시 시작되고 컨테이너의 초기화 시스템으로써 작동하여 다음과 같은 프로세스 트리가 생성됩니다.

- docker run (on the host machine)
  - python my_server.py (PID 1, inside container)

이러한 구조가 1번 예시보다 나은 방법입니다. 프로세스는 이제 실제로 보내는 신호를 수신합니다. 그러나 PID 1이므로 예상대로 응답하지 않을 수 있습니다.


PID 1의 Signal Propagation 문제

컨테이너 환경도 마찬가지로 PID 1은 초기화 시스템의 책임이 있습니다. 일반적인 프로세스는 TERM에 대한 자체 handler를 등록하여 종료하기 전 cleanup을 수행할 수 있습니다. 프로세스가 signal handler를 등록하지 않은 경우, 커널은 일반적으로 TERM 신호에 대한 기본 동작인 프로세스 종료를 수행합니다.

반면 PID 1은 TERM 신호에 대해 기본 동작으로 실행되지 않습니다. 따라서 signal handler를 등록하지 않은 경우, TERM은 프로세스에 아무런 영향도 미치지 못합니다. 만약 자식 프로세스가 하위 프로세스를 생성하고 먼저 죽었다면, 컨테이너 상에 좀비 프로세스가 계속 쌓일 수 있습니다.

docker run이 SIGTERM을 수신하면 컨테이너 자체가 죽지 않더라도 신호를 컨테이너로 전달한 다음 종료됩니다. docker stop 명령을 사용해도 마찬가지입니다. TERM signal을 보내고 10초 동안 기다린 다음 프로세스가 여전히 중지되지 않으면 KILL이 전송되어 정리할 기회 없이 즉시 중지됩니다.


dumb-init

dumb-init은 이러한 문제를 해결하고 컨테이너를 일반 프로세스와 같은 형태로 사용할 수 있도록 지원하기 위해 만들어졌습니다. systemd과 달리 컨테이너에서 사용하기 위해 경량화된 형태로 개발된 초기화 시스템입니다. dumb-init을 사용하면 다음과 같은 프로세스 트리가 생성됩니다.

- docker run (on the host machine)
  - dumb-init (PID 1, inside container)
    - python my_server.py (PID 2, inside container)

dumb-init은 모든 signal에 대해 signal handler를 등록하고 해당 signal을 프로세스 세션으로 전달합니다. 파이썬 프로세스는 더 이상 PID 1로 실행되지 않기 때문에 dumb-init이 TERM과 같은 신호를 전달할 때 handler를 등록하지 않아도 프로세스 종료가 가능합니다. dumb-init은 signal propagation 뿐만 아니라 고아 상태가 된 자식 프로세스를 거두는 역할(adopt)도 수행합니다.

RUN apt install dumb-init
ENTRYPOINT ["/usr/bin/dumb-init", "--", "/my/script"]

사용 방법은 정말 간단합니다. 이미지에 바이너리를 설치하고 명령어 실행할 때 추가하면 됩니다.


Airflow 이미지에서 dumb-init 사용

Airflow도 dumb-init를 ENTRYPOINT에서 사용하고 있습니다. webserver, worker, scheduler pod에서 bash -c ENTRYPOINT를 사용하는데 bash는 자식에게 signal을 전달 안하기 때문에 dumb-init 사용이 필요합니다. 컨테이너 내에서는 환경변수를 통해 다르게 설정할 수 있도록 지원하고 있습니다. 설정 값의 차이는 아래와 같습니다.

  • DUMB_INIT_SETSID=1 : 메인 프로세스 그룹의 모든 프로세스에 SIGNAL 전파
  • DUMB_INIT_SETSID=0 : 메인 프로세스에만 SIGNAL 전파

공식 차트에서 worker pod은 0으로 나머지는 1로 설정되어 있습니다.
이유는 Celery Worker의 warm shutdown을 지원하기 위해서 입니다. 특히 Airflow on Kubernetes 구성에서 CeleryExecutor를 사용하는 경우, task의 정상적인 종료를 위해 필요합니다. 이 부분은 다음 포스트에 이어서 정리해보겠습니다.


Reference

← PrevNext →
  • Powered by Contentful
  • COPYRIGHT © 2020 by @swalloow