Docker와 Dumb-Init

오늘은 도커에서 안정적인 구동을 위해 사용하는 dumb-init과 pid1 그리고 init 시스템에 대한 이야기를 하려고 합니다.

Init과 PID 1

Linux, macOS 등 대다수 OS에서 가장 첫번째 PID는 초기에 실행되는 Init 프로세스가 할당 받습니다. 그래서 ps 등으로 확인해보면 init 관련 프로세스가 PID 1을 가지고 있는 경우가 많습니다.

그리고 Init 프로세스는 Unix 기반 운영 체제에서 부팅 시 최초의 프로세스가 되는 데몬으로 PID 1번이기 때문에 모든 프로세스의 직/간접적인 부모 프로세스가 됩니다. 이는 Init의 역할인 고아(Orphaned) 프로세스를 입양하기 때문이죠.

그러나 도커의 경량 컨테이너들의 경우 systemd, sysvinit 등의 init 시스템이 없기 때문에 ENTRYPOINT에 명시한 명령어, 즉 사용자가 만든 어플리케이션이나 쉘 스크립트가 PID 1번을 받습니다.

ENTRYPOINT ["/app/run.sh"] # this is pid 1

Problem

그럼 일반 어플리케이션이 PID 1을 가질 떈 어떤 문제가 발생할까요?

Signal

일반 어플리케이션이나 스크립트가 PID 1을 받으면 원래 목적이 프로세스를 관리하는 어플리케이션이 아니기 떄문에 정상적으로 시그널 처리를 할 수 없을 가능성이 있습니다. 만약 앱 실행을 위해 ENTRYPOINT에 /app/run.sh 가 지정된 경우 해당 쉘 스크립트가 PID 1을 가져가게 되고, 해당 스크립트로 실행된 어플리케이션이 하위 PID를 받게 됩니다.

.host(node)
└── [PID 1] /app/run.sh
    └── [PID 2] /app/server --bind 8080

이런 경우 쉘 스크립트가 시그널을 처리할 수 없기 때문에 정상적으로 시그널 처리를 하지 못합니다. 또한 사용자 어플리케이션의 경우도 아래 예시와 같이 별도로 Signal 핸들러를 구성한게 아니라면 처리하지 못하는건 동일합니다.

// Go에서 signal 처리
package main

import "os/signal"

func main(){
	go func() {
			sig := <-sigs
			log.Info(sig)
			// Signal 처리 로직
			}
	}()
}

Orphaned/Zombie Process

부모 프로세스의 강제 종료로 남겨진 자식 프로세스들을 고아 프로세스(orphaned proecess)라고 합니다. 부모가 사라진 고아 프로세스는 Init 프로세스인 PID 1을 새로운 부모로 바라보게 되는데, 이를 입양이라고 하죠. Init은 입양된 고아 프로세스들이 작업이 완료되면 wait 시스템 콜을 호출해서 고아 프로세스의 종료 상태를 회수하여 좀비 프로세스가 되는걸 막아줍니다.

위에 설명한 것과 같이 경량 컨테이너에선 Init 시스템이 별도로 없고 사용자 어플리케이션이 PID 1이 될 수 있기 때문에 이러한 처리가 제대로 지원하지 못합니다. 그래서 좀비 프로세스로 남게됩니다.

Dumt-Init

dumb-init은 이러한 문제를 해결하기 위해 Yelp에서 만든 도구입니다. 복잡한건 아니고 Docker에서 실제 실행할 명령 앞에 사용하여 PID 1로 동작시키고, Signal을 받을 때 프로세스 세션으로 전달하는 역할을 수행합니다. 이를 위해 모든 Signal에 대해 Handler를 가지고 있습니다.

ENTRYPOINT 로 실행할 스크립트/앱을 넘겨줄 때 먼저 dumb-init을 사용하고, 인자값으로 전달하는 형태로 사용할 수 있습니다.

# Builder
# ....

# Runner
FROM debian:buster
RUN apt install -y dump-init
ENTRYPOINT ["/usr/bin/dumb-init","--","/app/run.sh"]

CMD로 처리하는 경우에도 비슷합니다.

ENTRYPOINT ["/usr/bin/dumb-init", "--"]
CMD ["/app/run.sh"]
# ubuntu
apt install -y dumb-init

# alpine
apk add dumb-init

# python
pip3 install dumb-init

# binary
wget -O /usr/local/bin/dumb-init https://github.com/Yelp/dumb-init/releases/download/v1.2.5/dumb-init_1.2.5_x86_64
chmod +x /usr/local/bin/dumb-init

이러한 역할 이외에도 최소 단위의 컨테이너에서 시스템을 관리할 수 있도록 여러가지 기능들을 지원하고 있습니다. 자세한건 dumb-init 깃헙을 참고해주세요.

https://github.com/Yelp/dumb-init

Case-study

Without dumb-init

먼저 dumb-init 없이 만들어봅시다. 테스트를 위해 간단하게 도커 파일과 쉘 스크립트를 준비합니다.

Dockerfile

FROM alpine:3.7

RUN mkdir /app
WORKDIR /app
COPY . .

ENTRYPOINT ["/app/1.sh"]

1.sh

#!/bin/sh

sleep 60

특별한 로직은 없고 단순히 alpine 이미지에서 sleep 60을 수행하는 이미지입니다. docker build 후 실행하고, docker exec로 접근해서 보면 1.sh가 pid1을 받은 것을 알 수 있습니다.

그리고 Host에서 Ctrl+C로 인터럽트를 주더라도 시그널을 처리할 수 없기 떄문에 60초가 지난 이후에 종료됩니다.

With dumb-init

그럼 dumb-init을 사용한 경우는 어떨까요? Dockerfile에서 apk로 dumb-init을 설치 후 ENTRYPOINT에 dumb-init을 로드하도록 명시하였습니다.

FROM alpine:3.7

RUN mkdir /app
RUN apk add dumb-init
WORKDIR /app
COPY . .

ENTRYPOINT ["/usr/bin/dumb-init","--","/app/1.sh"]

실행해보면, dumb-init이 pid1을 받았고 인터럽트가 발생해도 시그널 처리가 되기 떄문에 즉시 종료됩니다.

References

  • https://github.com/Yelp/dumb-init
  • https://en.wikipedia.org/wiki/Init