개요
최근 '도커, 쿠버네티스를 활용한 컨테이너 개발 실전 입문' 책을 읽는 스터디를 시작했다.
도커 개념부터 차근차근 공부하고 있고, 이미지 빌드와 컨테이너 생성 역시 실습을 통해 공부하게 되었다.
그러던 중 터미널에 ctrl + c 를 눌러도 컨테이너가 종료되지 않는 현상을 발견했다.
오늘은 그 이유를 알아보겠다.
문제 상황
0. macOS를 이용하고 있다.
1. Dockerfile 을 작성하여 이미지를 빌드하고, 해당 이미지로 생성한 컨테이너를 실행한다.
2. Go 로 작성된 간단한 애플리케이션이 생성한 컨테이너에서 실행되도록 한다.
main.go
package main
import (
"fmt"
"log"
"net/http"
)
func main() {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
log.Println("received request")
fmt.Fprintf(w, "Hello Docker!")
})
log.Println("start server")
server := &http.Server{
Addr: ":8080",
}
if err := server.ListenAndServe(); err != nil {
log.Println(err)
}
}
Dockerfile
FROM golang:1.9
RUN mkdir /echo
COPY main.go /echo
CMD ["go", "run", "/echo/main.go"]
위와 같은 상황에서 ctrl + c 를 눌렀을 때, 컨테이너가 종료되지 않는다.
(docker ps 를 통해 확인해보면 프로세스가 죽지 않고 살아있는 것을 확인할 수 있다)
동작 구조
docker container run -p 9000:8080 example/echo:latest
위 명령어를 수행하고, ctrl + c 를 입력했을 때 정상적인 처리 순서는 아래와 같다.
1. 호스트의 docker run 프로세스 종료
2. dockerd 가 컨테이너의 PID1에 SIGINT 를 전달 (종료 신호)
3. PID1 이 SIGINT 처리 (컨테이너 종료)
3번 과정에서 PID1이 SIGINT를 무시하면 컨테이너가 종료되지 않는 문제가 발생한다.
PID 1 라는 개념이 생소할 수 있다.
그러니 PID 1 먼저 알아보자.
Unix/Linux PID 1
PID 1은 리눅스가 부팅될 때 커널이 가장 먼저 실행하는 프로세스이다.
PID 1은 시스템 초기화 (서비스 및 데몬 실행 등), 자식 프로세스 관리 (좀비 프로세스 제거 등), 신호 처리 (SIGTERM, SIGINT 등의 신호) 를 수행한다.
참고로 유닉스/리눅스 프로세스 구조는 트리 구조를 이룬다.
이 때 가장 상위 프로세스(루트 프로세스) 가 PID1 이 되며, 이후 생성되는 프로세스들은 모두 PID1 의 자식 프로세스가 된다.
리눅스의 경우 부팅될 때 정상적인 init 프로세스를 하도록 init 시스템(systemd, init 등)을 구성하지만, docker는 그렇지 않다.
Docker 컨테이너 PID 1
docker 컨테이너의 PID 1 도 가장 먼저 실행되는 프로세스이다.
하지만 리눅스 PID 1와 다르게, init 시스템을 보장하지 않는다.
그 이유는 docker 컨테이너의 PID1은 Dockerfile 작성 내용에 따라 init 시스템일 수도 있고, 애플리케이션일 수도 있기 때문이다.
문제의 원인
다시 문제로 돌아가서, 우리의 문제는 docker 컨테이너의 PID 1이 SIGINT를 무시한다는 점이었다.
작성했던 Dockerfile 을 살펴보자.
FROM golang:1.9
RUN mkdir /echo
COPY main.go /echo
CMD ["go", "run", "/echo/main.go"]
위 코드에 기반하면 go run 프로세스가 PID 1(루트 프로세스)이 되고, 내부에서 main.go 의 실행 파일은 자식 프로세스가 된다.
(컨테이너 생성 후 실행되는 CMD 인스트럭션을 봐야 한다)
- 생성된 docker 컨테이너의 PID1은 init 시스템이 아니다.
- 호스트에서 ctrl + c 를 입력하여 SIGINT 를 보내도 PID 1 (go run) 은 자식 프로세스(애플리케이션 실행 파일)에게 종료 신호를 보내지 않는다 (신호를 받은 자신은 종료 된다).
- 애플리케이션 실행파일은 고아 프로세스가 된다.
- 일반적인 리눅스 시스템이라면 init 시스템이 고아 프로세스를 정리한다.
- 하지만 생성한 컨테이너는 init 시스템이 없기 때문에, 고아 프로세스가 계속해서 실행된다.
Dockerfile 수정해보기
그렇다면 애플리케이션 실행파일 그 자체가 PID 1이 되면 어떨까?
컨테이너가 실행될 때 빌드된 애플리케이션 실행파일을 바로 실행하도록 Dockerfile을 수정해보았다.
FROM golang:1.9
WORKDIR /echo
COPY main.go .
RUN go build -o echo main.go
CMD ["/echo/echo"]
Dockerfile을 수정하니 ctrl + c 로 종료시킬 수 있었다.
애플리케이션 실행파일 자체가 PID 1이 되었기 때문에 SIGINT 신호를 받고 즉시 종료한 것이다.
원인에 근거한 다양한 해결 방법
init 시스템의 부재로 위와 같은 문제가 발생하는 것으로 추측된다.
이에 따라 해결 방안은 아래와 같다.
1. 실행 파일 이용하기: 빌드된 파일을 바로 실행되도록 CMD 인스트럭션 작성 / exec 명령어 활용
→ 실행 파일 자체가 docker 컨테이너의 PID1 가 되도록 하는 방법 (신호를 직접 받을 수 있음)
2. tini (init 시스템) 사용하기
→ tini 가 PID 1이 되어 자식 프로세스 정리하도록 하는 방법
RUN apt-get update && apt-get install -y tini
ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["go", "run", "/echo/main.go"]
3. --init 옵션 사용하기
→ init 프로세스를 실행하여 고아 프로세스를 정리하도록 하는 방법
docker run --init {컨테이너명}
마치며
docker 기본 지식이 많지 않아서 부족한 내용이 있을 수 있다.
본 글을 읽고 해당 내용이 궁금하다면 공식 문서 등의 자료를 더 참고하는 것이 좋을 것 같다.
혹시 잘못된 내용이 있다면 댓글 부탁드립니다.