도커 컨테이너 까보기(3) – Docker Process, Binary

잡설

이제는 IT 엔지니어 중에서 Docker 를 한번도 접해보지 않은 사람을 없을 듯 하다.
Registry 에서 image 를 pull 한 후 자신만의 image 를 만들거나, container 를 이용해서 손쉽게 개발 환경을 구축하는 등, Docker 는 이미 업무에서 많이 대중화되었다.

사용법은 제법 알겠는데 Docker 내부를 많이 뜯어본 사람은 얼마나 될지 잘 모르겠다.
난 공돌이라서 그런지 추상적인 개념만으로 어떤 솔루션을 사용하다보면 항상 뒷끝이 개운치 않고 찜찜함이 남는다.
항상 뭔가 손에 잡히고 눈에 보여야만 이해가 되는 것이다.
물질주의자도 아닌데 물리적인 부분이 이해가 되어야만 그 기반 위에 무언가를 쌓을 수 있다.
그 때문인지 대학시절에도 공업수학을 배울 때, 이중적분이나 삼중적분 같은 것들은 이해하기가 참 힘들었었다.
이런거 없어도 세상 잘 돌아가는데…

Docker 를 공부하면서도 똑같은 과정을 겪었다.
image, container 같은 것을 자유롭게 다룰 수는 있으나, 마음 한켠에 항상 ‘나 이거 알고 쓰는거 맞나?’하는 찝찝함을 떨치지 못했다.
그래서, 결국 개인적으로 Docker 내부에서 사용하는 기술들을 하나하나 뜯어보게 되었다.
사람은 아는만큼 보인다고 뜯어보면 뜯어볼수록 자꾸 깊은 수렁으로 빠져드는 느낌이…
호기심도 지적 재료가 많아서 생기는거라고 스스로 자위하기로…
사실 들어가고 또 들어가서 Kernel code 까지 가게 되면 한 이슈에 대한 공부가 끝이 없게 된다.
예전 스터디모임을 할 때, Linux kernel 의 booting code 만 분석하는데도 어마어마한 시간이 소요되었었다.
(물론 그 만큼 시스템을 깊이 있게 이해하는데 많은 도움이 된건 사실.)
조금 다양한 솔루션을 분석해볼 때는 적당한 깊이에서 끊어내는 냉정함도 필요한 듯 하다.

개인적으로 적당한 깊이라고 하면, 솔루션에 문제가 생겼을 때 그 아키텍처에 대한 이해의 바탕 위에서 Troubleshooting 을 통해 문제지점을 파악하고 해결할 수 있는 정도라고 생각하고 있다.
물론 해당 솔루션에 대한 코드에 기여하고 싶다면, 당연히 그 이상 팔 수 있는 한계까지 파야 한다고 생각한다.

각설하고…

Docker Binary

이제부터 Docker 엔진을 구성하는 binary 와 daemon process 들에 대해 까볼까 한다.
내가 분석한 환경은 Centos7.6, Docker 19.03.1 이다.

Docker 엔진을 구성하는 binary/process 들은 version 별, system 별로 조금씩 다를 수 있다.
(물론 위의 버전보다 한참 하위 버전은 많이 다를 수 있다.)
개념 상 큰 틀에서는 유사할 것이므로 위의 버전을 기준으로 파악해보도록 하자.

Docker 를 설치하면 호스트 서버의 /bin 디렉토리 아래에 다음과 같은 파일들이 존재한다.
하나하나씩 살펴보자.

docker

docker 파일은 CLI(Command Line Interface)를 수행하기 위한 binary 이다.
즉, 사용자가 docker engine 으로 다양한 명령을 요청할 때 사용하는 프로그램이다.
기본적으로 docker –help 를 보면 대부분 손쉽게 사용할 수 있다.
docker CLI 를 통해 수행된 명령은 REST API wrapper 를 통해 docker daemon 에 전달된다.

When people say “Docker” they typically mean Docker Engine, the client-server application made up of the Docker daemon, a REST API that specifies interfaces for interacting with the daemon, and a command line interface (CLI) client that talks to the daemon (through the REST API wrapper). Docker Engine accepts dockercommands from the CLI, such as docker run <image>docker ps to list running containers, docker image ls to list images, and so on.

What’s the difference between Docker Engine and Docker Machine?

docker client 와 server(docker daemon) 간 통신 방식은 기본적으로 unix domain socket(IPC socket)을 사용하며, 이외에도 fd 또는 tcp 를 사용할 수 있다.
fd 방식은 systemd base 인 system 에서만 사용할 수 있는데, 이는 Systemd socket activation 이라는 기능을 이용한다.
unix domain socket 방식을 이용할 경우에는 내부적으로 /var/run/docker.sock 파일을 사용하여 통신한다.
(각 통신방식에 대한 기초 내용은 여기에서 다루지 않는다.)
remote 에서 접속을 해야할 경우에는 tcp 를 사용해야 하고 docker 를 통해 다음과 같이 접속한다.

$ docker -H tcp://0.0.0.0:2375 ps

$ docker -H tcp://192.168.0.131:2375 ps

$ export DOCKER_HOST="tcp://0.0.0.0:2375"
$ docker ps

docker 18.09 버전부터는 아래와 같이 ssh 를 이용하여 docker daemon 에 접속할 수도 있게 되었다.

$ docker -H ssh://me@example.com:22 ps
$ docker -H ssh://me@example.com ps
$ docker -H ssh://example.com ps

위와 같이 docker(client)는 unix domain socket, fd, tcp 방식으로 docker daemon 에 접속하고, docker daemon(server)은 자신이 command 를 수신할 통신 방식을 지정하여 기동해둔다.
즉, 가능한 통신 방식은 docker daemon 이 어떤 방식으로 listen 을 하고 있느냐에 따라 달라질 수 있다.
-H 옵션을 통해 listen 방식을 multiple 로 지정할 수 있다.

# systemd socket activation 기능을 이용하여 local 에서만 접속 가능
$ dockerd -H fd://

# unix domain socket 과 2 개의 tcp ip 를 listen 하도록 기동
$ dockerd -H unix:///var/run/docker.sock -H tcp://192.168.59.106 -H tcp://10.10.10.2

docker-init

Docker document 를 살펴보면 다음과 같은 설명을 볼 수 있다.

Specify an init process
You can use the --init flag to indicate that an init process should be used as the PID 1 in the container. Specifying an init process ensures the usual responsibilities of an init system, such as reaping zombie processes, are performed inside the created container.
The default init process used is the first docker-init executable found in the system path of the Docker daemon process. This docker-init binary, included in the default installation, is backed by tini.

docker run 수행 시 –init 옵션이 주어지지 않을 경우는 container 내에서 init process 를 별도로 기동하지 않는다.
docker run 수행 시 넘겨준 command(/bin/bash)가 그대로 1 번 process 가 된다.

반대로 docker run 수행 시 –init 옵션이 주어질 경우, init process 를 container 구동 후 1 번 process 로 기동하게 된다.
container 내에서 init process 를 1 번으로 구동한다는 것은 중요한 의미가 있다.
이는 child process 를 받아주어 resource 의 누수나 zombie process 의 생성 등을 방지하는 init system 의 역할을 container 내에서 수행한다는 뜻이기 때문이다.
init process 로 사용되는 default binary 는 /bin/docker-init 을 사용한다.
(정확하게는 which docker-init 의 결과로 찾아지는 binary 를 사용)
docker-init 은 container 외부에서 별도로 기동되거나 하는 process 가 아니다.
container 내에서 첫 번째로 기동되어 마치 Host 에서의 init process 처럼 동작하도록 만들어진 프로그램이라고 생각하면 된다.

docker-proxy

container 를 기동할 때 -p 8080:80 옵션을 주면 Host 의 8080 port 로 들어오는 요청을 container 의 80 port 로 전달해준다.

$ docker run -d -p 8080:80 --name web_svr01 httpd

docker ps 를 통해 기동된 container 상태를 보면 PORTS 항목에서 이를 확인할 수 있다.

tcp 를 이해한다면 이러한 port 변환을 누군가 중간에서 수행해주어야한다는 것을 짐작할 수 있다.
client 가 tcp 를 통해 ip/port 정보로 server 에게 연결을 수행하면, 이 때부터는 해당 연결을 시스템이 ip:port 라는 키를 통해 관리하게 된다.
즉, ip 나 port 중의 하나라도 정보가 달라지면 이는 같은 tcp 연결이 아닌 것이다.
tcp 연결이 완료된 후 port 를 변경할 수는 없다.
이는 외부의 client 가 container 로 8080 로 연결한 후 port 만 80 으로 바꾸는 것은 불가능하다는 것이다.
따라서, 중간에서 누군가가 외부 client 의 연결을 tcp 8080 port 로 받아주고, 내부적으로 container 와의 정해진 통신 방식에 의하여 data 를 container 80 port 로 forwarding 해주어야 한다.

이 역할을 담당하는 것이 docker-proxy 이다.

docker 를 기동할 때 위와 같이 -p 옵션을 통해 특정 port 를 외부로 publish 하지 않으면 docker-proxy 는 불필요하다. (기동되지도 않는다.)
docker host 내부의 container 간의 통신은 docker0 라는 bridge 를 통해 기본적으로 가능하기 때문이다.

즉, 외부와 docker-proxy 사이의 통신은 8080/tcp 를 사용하고, docker-proxy 와 container 간의 통신은 docker0 라는 내부 bridge 를 사용하게 된다.

8082 에서 8080 으로 forwarding 하는 아래 솔루션의 예를 보자.
외부 Browser 에서 들어온 요청이 8082 port 로 listen 하고 있는 docker-proxy 로 먼저 전달된다.
이는 내부의 docker0(172.17.0.1) -> veth184471 -> eth0(172.17.0.2) 8080 의 과정을 통해 container 로 전달 됨을 확인할 수 있다.
docker-proxy 가 없다면 Browser 와 docker0 의 중간에 연결 매개체가 없으므로, container 의 8080 port 로는 외부와 통신을 수행할 수 없다.

docker-proxy 를 통한 port forwarding (그림출처 : https://blogs.itemis.com)

docker run -p 를 통해 다수의 container 를 기동할 때 외부 노출 port 를 모두 동일하게 줄 수 있을까?
즉, 다음과 같은 수행이 가능할까?

$ docker run -d -p 8080:80 --name web_svr01 httpd
$ docker run -d -p 8080:90 --name web_svr02 httpd

당연히 안된다.
위와 같이 container 를 구동하게 되면 동일한 8080 port 에 대해 2 개의 docker-proxy 가 binding 하려 할 것이다.
이미 알고 있듯이 두 개의 프로세스가 동일한 tcp port 에 대해 binding 하는 것은 시스템에서 허용하지 않는다. (물론 port 는 같더라도 다른 ethernet interface, 즉 다른 ip 를 이용하여 bind 하는 것은 가능하다.)

docker run -d -p 8080:80 –name web_svr01 httpd 명령을 통해 container 를 실행하면, 아래와 같이 docker-proxy process 가 기동되는 것을 Host 에서 확인할 수 있다.

그리고, docker-proxy process 는 8080 port 를 통해 listen 중이다.
(아래 lsof 결과 중에서 *:webcache 라는 port 로 LISTEN 을 하고 있는데, webcache 는 /etc/services 에 8080 으로 정의되어 있다.)

dockerd, containerd

dockerd 와 containerd 는 서로 밀접하게 관련되어 있어서 묶어서 함께 살펴보도록 하자.
일단 각자의 역할은 아래와 같다.

  • dockerd : volume, image, networking 관리 뿐 아니라 orchestration 까지 관장하여 처리한다. client 로부터 REST API 형식의 요청을 수신하여 처리한다.
  • containerd : container 의 lifecycle 을 관리한다. client 로부터의 container 관리 관련 요청은 dockerd 를 거쳐 gRPC 통신을 통해 containerd 로 전달된다.

docker service 를 맨 처음 시작하면 기동되는 daemon process 는 어떤 것이 있을까?
일단 현재 떠 있는 docker, container service 를 모두 중지시켜보자.

$ systemctl stop docker.service
$ systemctl stop containerd.service

이제 docker service 를 올려보자.

기본적으로 구동되는 daemon process 는 dockerdcontainerd 두 개이다.
이 둘을 합쳐서 docker engine 이라고 부른다.
docker.service 를 start 하면 docker daemon 이 자동으로 containerd 까지 기동해준다.
(docker.service 를 stop 할 때는 위처럼 각각의 service 를 내려야 한다.)

containerd 를 별도로 사용자가 manual 하게 기동할 수도 있다.
하지만, 이 때는 dockerd 에게 containerd 와의 통신을 위한 socket path 를 알려주어야 한다.
dockerd 를 기동할 때 아래와 같이 path 를 넘겨준다.
(바로 위의 화면 캡처 사진에도 dockerd 가 –containerd flag 와 함께 떠있는 것을 볼 수 있다.)

$ dockerd --containerd /run/containerd/containerd.sock

참고로, systemctl start docker.service 를 수행하면 service 관련 설정을 /etc/systemd/system/multi-user.target.wants/docker.service 파일에서 확인하여 기동하게 된다. 이곳에 containerd 를 연계하여 기동하도록 설정되어 있다.

임시로 사용하는 socket file 이나 pid file 같은 경우 /var/run 이나 /run 디렉토리 아래에 존재하는 것을 볼 수 있다. (/var/run 은 /run 디렉토리에 대한 symbolic link 이다.)
/run 디렉토리는 각종 솔루션들이 runtime 에 임시로 사용하는 정보들을 저장하는 공간이며 tmpfs 를 사용한다. (예전에는 일반적인 disk 공간을 사용하는 디렉토리였다.)
docker 관련한 각종 runtime 파일들도 이곳에 저장된다.
docker CLI 와 docker daemon 사이의 통신을 위한 docker.sock 파일이나, docker daemon 과 containerd 사이의 통신을 위한 containerd.sock 파일도 이곳에 존재한다.

예전에는 containerd 가 지금처럼 별도로 분리된 component 가 아니었는데, 이제 점차로 docker component 와 container component 는 완전히 분리되어 별도의 솔루션으로 관리되는 듯 하다.
이렇게 분리되어 있으면 docker 를 upgrade 하거나 문제가 생겼을 때에도 container 에 영향을 주지 않도록 할 수 있다.
아래 그림을 보면 containerd 가 완전히 분리되어 Service interface 를 제공하고 있음을 확인할 수 있다. 즉, docker 뿐 아니라 container 관련 서비스를 누구나 사용할 수 있도록 완전히 분리하여 API 를 제공한다.

containerd-shim, runc

Docker 관련 binary 중 마지막으로 containerd-shim과 runc 라는 파일이 존재한다.
(맨 위쪽의 binary 화면 캡처에서 runc 는 누락되어 있는데 /bin 아래에는 runc 도 존재한다.)
이 파일들이 왜 필요한지를 이해하기 위해 먼저 container runtime 에 대해 알아야할 것 같다.

“container 의 실행 환경”

docker daemon 의 기능 중 container 의 lifecycle 을 관리하는 기능들은 containerd 가 담당하게 되었다.
그런데, container 라는게 무엇인가?
격리 및 리소스 관리 기술이 적용된 프로세스‘이다.
이러한 격리 환경을 제공하기 위해 내부적으로 cgroup 과 namespace 등의 Linux kernel 기술이 사용된다.
즉, cgroup 이나 namespace 등의 기술을 container 실행할 때 사용하는 것이다.
그런데, 이러한 cgroup 과 namespace 등을 다루는 방법(Interface)은 시스템마다 다르지 않을까?
그리고, Linux kernel 버전에 따라 계속 변하지 않을까?
그러면 Docker 는 어떻게 대처해야 하지?
(이런 고민들이 Docker engine 개발자들에게 생길 수 밖에 없었을 것 같다.)

Docker 와 libcontainer

과거에 Docker 는 이러한 기술들을 사용하기 위해 LXC(Linux Container)libvirt 같은 중간 매개체(driver, library)를 통해 간접적으로 control 하였었다.
즉, kernel 의 가상화 관련 기술들을 간접적으로 사용한 것이다.
Kernel 관련 가상화 기술들이 Docker 의 발전에 꼭 필요한 요소인데, 이를 다루는 인터페이스는 LXC 나 libvirt 같은 외부 솔루션에 의존하다보니 답답함을 느끼지 않았을까 싶다.
그래서, 어느 순간부터 Docker 진영에서 kernel 의 가상화 기술을 다루기 위한 interface 를 자체적으로 개발, 관리해야한다는 필요성이 생겼는데, 이 때문에 개발된 것이 libcontainer 이다.
kernel 의 가상화 관련 기술들을 직접적으로 다룰 수 있는 자체 구현체를 가지게 된 것이다.
그리고, Docker version 1.11 이후 libcontainer 는 또 한번 refactoring 과정을 거치게 되는데, 이것이 바로 현재 runC 라고 불리는 container runtime 이다.

runC 는 system 에서 container 관련된 기능들에 대해 docker 가 쉽게 사용할 수 있도록 해주는 가볍고 이식가능한 container runtime 이다.

그런데, 기억해야할 것은 system 의 container 관련 기술을 다루는 interface 도 표준화되어 있다는 것이다.
그 표준을 OCI(Open Container Initiative)라고 한다.
정리하면, containerd 는 container 의 관리를 위해 runC 를 사용하는데, runC 는 kernel 의 container 관련 기술을 다루기 위해 OCI spec 을 준수하고 있다고 이해하면 되겠다.

자, 그럼 container 를 하나 실행하는 과정을 통해 containerd, containerd-shim, runc binary 가 어떻게 사용되는지를 정리해보자.
사용자가 docker run 을 통해 container 기동을 요청하면 다음의 과정으로 처리된다.
(개인적으로 이 부분을 파보는데 상당히 애를 먹었다. 국내 이런저런 자료로는 이해할 수 없고 부정확하다.)

  • dockerd 는 요청을 gRPC 를 통해 containerd 로 전달한다.
  • containerd 는 exec 을 통해 containerd-shim 을 자식으로 생성한다.
  • containerd-shim 은 runc 를 이용하여 container 를 생성한다.
    (runc 는 container 가 정상적으로 실행되면 exit 한다.)
  • containerd-shim 은 그대로 살아있으며, 이는 container 내에서 실행되는 process 들의 부모가 된다.

containerd-shim 이라는 것이 왜 필요한지에 대해 처음에는 이해하지 못했는데 다음과 같은 이유가 있다.

  • daemonless container 를 위해서다. 즉, container 하나 뜬다고 해서 뭔가 계속 수행되는 runtime daemon 이 존재할 필요가 없다. (runc 는 이미 exit 했다.)
    containerd-shim 의 경우는 껍데기나 마찬가지다.
  • container 를 위한 STDIO 및 fd 를 계속 유지해준다. dockerd 와 containerd 가 둘다 죽게 되는 상황이 되면 pipe 의 한쪽이 닫혀서 container 까지 죽을 수 있는데 이를 막아준다.
    즉, docker daemon 들의 장애 상황이 container 까지 전파되지 않도록 해준다.
    이는 container 에게 영향을 주지 않고 docker engine 의 upgrade 작업도 수행할 수 있게 해준다.
  • container 의 exit status 등을 higher level tool 로 보고할 수 있다.
    containerd-shim -> runc -> container 인 상황에서 runc 는 exit 하게 되므로 container 의 parent 는 containerd-shim 이 된다.
    따라서, containerd-shim 은 자식의 상태를 파악하고 이를 어딘가로 보고해줄 수 있는 구조가 된다.

참고로 container runtime 은 다른 것으로 변경할 수도 있다.
runc 가 아닌 다른 것으로 변경하는 것도 가능하다는 것을 알아두자.
현재 사용하는 runtime 은 docker info 에서 확인할 수 있다.

$ docker info|grep -i runtime
Runtimes: runc
Default Runtime: runc

이제 docker run 을 이용하여 다양한 방식으로 container 를 기동한 후 Host 에서 ps 를 통해 기동되어 있는 Process 들을 확인해보자.

위의 내용을 잘 이해하였다면,
아마도 떠 있는 모든 Process 들이 눈에 잘 들어올 것이고…
그 Process 들이 왜 필요한지도 알 수 있을 것이고…
내부의 처리 흐름과 기동 과정까지…
모두 머리 속에서 잘 그려지지 않을까 생각한다.

이제 대문에 걸려 있는 아래 그림 안의 용어들이 눈에 쏙 들어오지 않는가?

아래 그림은 어떤가?


References
stackoverflow.com
docker.com
Docker debug 모드
Container 외부 통신 구조
Docker Bridge Networking Deep Dive
https://containerd.io/docs
docker runtime execution
What is containerd?
containerd: A daemon to control runC
run directory in linux
A lightweight universal container runtime
Docker, Containerd & Standalone Runtimes — Here’s What You Should Know
Why we need containerd-shim?
Building a container supervisor
Engine is now built on runC and containerd
Docker: All you need to know — Containers Part 2
how-does-docker-work
Docker source code analysis (1): Docker architecture

You may also like...