Docker는 거들 뿐: 컨테이너는 격리된 프로세스 묶음이다

"Docker를 띄웠다"와 "컨테이너를 띄웠다"는 같은 말일까요? 엄밀히 말하면 아닙니다. Docker는 프로그램이고, 컨테이너는 커널이 격리해서 돌리는 프로세스 묶음에 붙인 이름입니다.

목차

투명한 컨테이너 안의 프로세스 하나를 리눅스 커널이라는 넓은 받침대가 받치고 있는 그림

Docker로 이미지를 만들고 배포까지 해 봤어도, 막상 "Docker랑 컨테이너는 뭐가 달라요?"라는 질문을 받으면 말이 꼬이기 쉽습니다. 평소에 두 단어를 거의 같은 뜻으로 쓰니까요.

답부터 말하면 세 줄이에요.

  1. Docker는 프로그램입니다. 설치하고, 실행하고, 끌 수 있는 소프트웨어 묶음이죠.
  2. 컨테이너는 '실행 방식'의 이름입니다. 격리된 채로 함께 돌고 있는 평범한 프로세스 묶음을 부르는 말이에요.
  3. 그 격리는 리눅스 커널이 합니다. namespace, cgroup 같은 커널 기능으로요. 이미지의 파일은 OverlayFS가 준비합니다.

말로만 하면 잘 와닿지 않으니, Docker를 걷어 내고 서버에서 하나씩 확인해 볼게요.

'세트'라는 음식은 없다

키오스크 옆 쟁반 위에 햄버거, 감자튀김, 콜라가 점선으로 묶여 있는 그림

햄버거 가게 메뉴판의 '세트'는 무슨 음식일까요? 사실 그런 음식은 없죠. 버거, 감자튀김, 콜라를 한 쟁반에 묶어 부르는 이름일 뿐입니다.

컨테이너도 마찬가지예요. 리눅스 커널 안에 'container'라는 부품은 없습니다. namespace, cgroup, OverlayFS 같은 커널 기능을 프로세스 묶음 하나에 함께 걸어 둔 상태를 컨테이너라고 부를 뿐이에요. 이 비유에서 Docker는 주문을 받아 세트를 차려 주는 키오스크고, 음식을 실제로 만드는 주방은 커널입니다.

Docker는 프로그램이다

우리가 매일 치는 docker의 정체는 뭘까요? 명령 하나처럼 보이지만, 설치해 보면 역할이 나뉜 프로그램이 넷이나 따라 들어옵니다.

docker CLI에서 dockerd, containerd, runc를 거쳐 리눅스 커널에 격리를 요청하는 흐름

프로그램 하는 일
docker 터미널에서 치는 명령(CLI). 요청을 dockerd에 전달만 합니다
dockerd 서버에서 계속 도는 Docker 데몬. 이미지, 네트워크, 볼륨을 관리하고 요청을 받습니다
containerd 컨테이너를 만들고, 시작하고, 멈추는 생명주기를 관리합니다
runc 커널에 요청해서 격리된 프로세스를 실제로 만드는 작은 도구입니다. 만들고 나면 빠집니다

이 프로그램들도 서버에서는 평범한 프로세스예요. Docker가 설치된 리눅스 서버에서 보면 이렇습니다.

bash
ps -ef | grep -E 'dockerd|containerd'
text
root   880     1  0 09:12 ?  00:00:41 /usr/bin/dockerd -H fd:// --containerd=/run/containerd/containerd.sock
root   901     1  0 09:12 ?  00:01:05 /usr/bin/containerd

이 글의 출력은 모두 예시입니다. PID나 경로, 버전 같은 값은 서버마다 달라요.

굳이 이렇게 여러 개로 나눈 데는 이유가 있습니다. 역할을 나눠 두면 다른 도구도 containerd와 runc를 그대로 가져다 쓸 수 있거든요. 실제로 Kubernetes는 Docker를 거치지 않고 containerd에 바로 요청합니다.

Docker 없이도 컨테이너는 돈다

컨테이너가 Docker의 것이라면, Docker 없이는 컨테이너를 못 띄워야 하잖아요. 현실은 반대입니다.

  • Podman, nerdctl(containerd용 CLI) 같은 도구로도 같은 이미지를 실행할 수 있습니다.
  • Kubernetes는 Docker 없이 containerd나 CRI-O로 컨테이너를 돌립니다.

"Kubernetes가 Docker를 버렸다"는 말이 한때 돌았는데, 엄밀히 말하면 1.24 버전에서 버린 건 Docker를 부르던 연결 코드(dockershim)예요. Docker로 만든 이미지는 지금도 Kubernetes에서 잘만 돌아갑니다.

이게 되는 건 OCI(Open Container Initiative)라는 공통 규격 덕분입니다. OCI는 이미지를 어떤 형식으로 만들고, 그 이미지를 어떻게 실행할지를 정해 둔 약속이에요. 바다를 오가는 화물 컨테이너가 규격이 정해져 있어서 어느 배, 트럭, 크레인으로도 옮길 수 있는 것과 같죠. 이미지가 OCI 규격만 따르면 누가 만들었든 어느 도구로나 실행할 수 있습니다.

컨테이너는 호스트에서 그냥 프로세스다

그럼 컨테이너는 서버에서 어떻게 보일까요? 하나 띄워서 서버(호스트) 쪽에서 들여다보겠습니다. 예시로 쓸 nginx는 웹 요청을 받아 페이지를 돌려주는 웹 서버예요. 실행하면 관리자 역할의 master 프로세스 하나와, 실제로 요청을 처리하는 worker 프로세스 여러 개가 뜹니다.

bash
docker run -d --name web nginx:alpine
pgrep -a nginx
text
4321 nginx: master process nginx -g daemon off;
4370 nginx: worker process

컨테이너 안에 있다던 nginx가 서버의 프로세스 목록에 그냥 4321번으로 있습니다. 이번엔 이 서버의 1번 프로세스를 볼게요. 리눅스에서 PID 1은 부팅할 때 가장 먼저 시작된 프로세스입니다.

bash
ps -p 1
text
  PID TTY          TIME CMD
    1 ?        00:00:03 systemd

같은 질문을 컨테이너 안에서 하면 어떨까요?

bash
docker exec web ps
text
PID   USER     TIME  COMMAND
    1 root      0:00 nginx: master process nginx -g daemon off;
   30 nginx     0:00 nginx: worker process
   38 root      0:00 ps

호스트의 프로세스 목록에서 4321번, 4370번인 nginx가 컨테이너 안에서는 1번, 30번으로 보이는 그림

서버에서 1번은 systemd인데, 컨테이너 안에서는 nginx master가 1번입니다. 같은 프로세스가 번호표를 두 장 갖고 있는 셈이에요. 전교 번호로는 4321번인 학생이 자기 반 출석부에서는 1번일 수 있는 것처럼요. nginx 입장에서는 자기가 막 켜진 컴퓨터의 첫 프로그램인 줄 아는 거죠. 컨테이너 안이 딴 컴퓨터처럼 느껴지는 이유가 이겁니다.

이렇게 번호를 따로 매겨 주는 커널 기능이 PID namespace입니다. 참고로 nginx:alpine을 쓴 건 기본 nginx 이미지에 ps 명령이 없어서예요.

컨테이너는 프로세스 '묶음'이다

위 결과를 다시 보면 nginx가 두 줄입니다. 컨테이너 하나에 프로세스도 하나일 것 같지만, 꼭 그렇지는 않아요. 컨테이너의 첫 프로세스(PID 1)가 다른 프로세스를 띄우면, 그 자식도 같은 격리 설정을 물려받아 같은 컨테이너에 들어갑니다. nginx master가 띄운 worker들처럼요.

호스트는 이 프로세스들이 한 컨테이너라는 걸 어떻게 알까요? 호스트에서는 각각 따로 보이지만, 커널이 이 묶음을 두 군데에 기록해 둡니다.

첫째, 같은 cgroup 폴더에 들어 있습니다.

bash
cat /proc/4321/cgroup /proc/4370/cgroup
text
0::/system.slice/docker-3f2a9c1e….scope
0::/system.slice/docker-3f2a9c1e….scope

둘째, 같은 namespace 번호를 씁니다.

bash
sudo readlink /proc/4321/ns/pid /proc/4370/ns/pid /proc/1/ns/pid
text
pid:[4026532412]
pid:[4026532412]
pid:[4026531836]

nginx 두 프로세스는 같은 번호를, 호스트의 systemd(1번)는 다른 번호를 쓰고 있죠. docker top web을 실행하면 이 컨테이너에 속한 프로세스를 호스트 번호로 보여 주는데, Docker도 결국 이 기록을 보고 목록을 만듭니다.

엄밀히 말하면 커널에는 '컨테이너'라는 기록이 따로 없어요. 같은 cgroup과 같은 namespace를 쓰는 프로세스들이 있을 뿐이고, Docker가 그 묶음에 web이라는 이름을 붙여 관리합니다.

Docker 없이 컨테이너의 뼈대 만들기

정말 커널 기능만으로 격리가 될까요? Docker 없이 직접 만들어 보면 확실합니다. 리눅스 기본 패키지(util-linux)에 들어 있는 unshare 명령을 쓸 거예요. 이름 그대로 지금까지 서버의 모든 프로세스와 함께 쓰던(share) 것을 끊고(un), 새로 만들어 달라고 커널에 요청하는 명령입니다.

터미널을 두 개 띄우고, 터미널 A에서 실행합니다.

bash
sudo unshare --pid --fork --mount-proc --uts bash
옵션 뜻
--pid 새 PID namespace. 번호표를 새로 매깁니다
--uts hostname을 따로 갖습니다
--mount-proc 프로세스 정보(/proc)를 새로 연결해서, ps가 새 번호표 기준으로 보이게 합니다
--fork 새 namespace 안에서 bash를 자식으로 시작합니다. 그래야 bash가 1번이 됩니다

프롬프트가 #으로 바뀌면 새 namespace 안에 들어온 겁니다.

text
# ps -e
    PID TTY          TIME CMD
      1 pts/0    00:00:00 bash
      9 pts/0    00:00:00 ps
# hostname my-box
# hostname
my-box

bash가 1번이고 프로세스는 둘뿐이에요. 앞에서 docker exec web ps로 본 모습과 똑같죠. 터미널 B(호스트)에서는 어떻게 보일까요?

text
$ hostname
ip-10-0-1-23
$ ps -e | wc -l
183

A에서 hostname을 바꿨는데 B는 그대로고, 서버 전체에는 여전히 프로세스가 수백 개 있습니다. A에서 exit를 치면 빠져나와요.

Docker 설치 없이 명령 한 줄로 컨테이너의 뼈대가 생겼습니다. 여기에 이미지 파일을 /로 끼우고, cgroup으로 메모리를 제한하고, 네트워크를 연결하면 그게 컨테이너예요. 이 일을 대신 해 주는 게 Docker고, 엄밀히 말하면 그중에서도 runc입니다.

정리

  • Docker는 docker → dockerd → containerd → runc로 이어지는 프로그램 묶음입니다.
  • 컨테이너는 격리된 채로 함께 돌고 있는 평범한 프로세스 묶음입니다. 호스트에서 보면 프로세스 목록의 몇 줄일 뿐이죠.
  • 격리는 커널이 합니다. Docker는 커널에 요청을 대신 넣어 주는 거들 뿐이에요.

namespace, cgroup, OverlayFS가 각각 정확히 무엇을 하는지는 컨테이너를 만드는 리눅스 커널 기능에서 따라가 봤습니다.

직접 해 보기

Docker가 설치된 리눅스 서버와 sudo 권한이 필요합니다.

bash
# 컨테이너는 호스트의 프로세스
docker run -d --name web nginx:alpine
pgrep -a nginx
ps -p 1
docker exec web ps

# 컨테이너에 속한 프로세스(호스트 번호), 컨테이너와 호스트의 PID namespace 번호 비교
docker top web
sudo readlink /proc/$(pgrep -o nginx)/ns/pid /proc/1/ns/pid

# Docker 없이 namespace 만들기 (끝나면 exit)
sudo unshare --pid --fork --mount-proc --uts bash

# 정리
docker rm -f web