0. Contents
1. Overview
2장 정리: 손잡이, 문, 방
- 2장은 커널이 가진 손잡이, 문, 방을 하나씩 만져 봤다
- 소켓은 손잡이였다
- 인터페이스는 문이었다
- 네트워크 네임스페이스는 문·주소·라우팅이 통째로 따로인 방이었다
- 02-1 의 마지막에 도커 컨테이너의 방에 nsenter 로 들어가 보기도 했다
3장의 주제: 방이 만들어지는 과정
- 3장은 그 방이 어떻게 만들어지는지를 본다
- 컨테이너라는 말은 커널에 없다
- 커널이 아는 것은 프로세스뿐이다
- 어떤 프로세스가 다른 방(네임스페이스)에 들어가 있고 어떤 계량기(cgroup)에 묶여 있을 때 우리가 그걸 컨테이너라고 부른다
- 그래서 이 편의 질문은 둘이다
- 누가 그 프로세스를 방에 넣어 주는가
- 방에 들어간 프로세스는 어떤 길로 밖에 나가는가
다루는 범위
- 이 편은 책의 3장 앞부분, 컨테이너의 역사와 런타임과 cgroup·네임스페이스까지를 다룬다
- 방이 밖으로 나가는 길 — 도커의 네트워크 모드와 CNI — 는 03-2 로 넘긴다
2. Three Problems — 집 하나, 집 셋, 방 셋
2.1 한 호스트의 긴장 — 라이브러리, 절차, 포트
공용 서버 시절의 문제 세 가지
- 책은 컨테이너를 역사로 설명한다
- 서버 한 대를 여러 팀이 같이 쓰던 시절의 문제는 셋이었다
- 라이브러리 — A 팀은 파이썬 3.8 이 필요하고 B 팀은 3.11 이 필요한데 호스트에는 하나만 깔린다
- 배포 절차 — 회사마다, 심지어 팀마다 배포 스크립트가 달라서 새로 온 사람은 그 스크립트를 해독하는 것부터 시작한다
- 한 호스트를 나눠 쓰는 데서 오는 긴장
- 관리자는 서버를 놀리기 아까워 앱을 더 받는다
- 그러면서도 한 팀이 다른 팀을 망가뜨릴까 봐 배포 권한을 안 준다
네트워크 관점에서 본 셋째 문제
- OS 가 하나면 TCP/IP 스택도 하나고, 스택이 하나면 포트 공간도 하나다
- 02-1 의 4.2 에서 본 그대로다
- A 팀 Tomcat 이 8080 을 잡으면 B 팀 Tomcat 은 8081 로 비켜야 한다
- 누가 몇 번을 쓰는지 표로 관리해야 하고, 방화벽 담당자에게도 알려야 한다
- 포트 하나 바꾸는 데 사람 셋이 모인다
2.2 VM — 집을 한 채 더 짓는다
VM 이 푸는 문제: 포트 공간
- 하이퍼바이저는 하드웨어를 흉내 내서 머신 한 대 위에 게스트 OS 를 여럿 띄운다
- 게스트마다 OS 가 있으니 스택도 하나씩이고, 포트 공간도 따로다
- A 팀과 B 팀이 둘 다 8080 을 쓸 수 있다 → 셋째 문제가 풀린다
VM 의 한계: 무게와 남는 문제
- 대신 집을 한 채씩 더 지은 것이라 무겁다
- 게스트마다 커널을 통째로 깔아야 한다
- 그 안에서 라이브러리를 어떻게 깔고 어떤 순서로 배포할지는 여전히 개발자 몫이다
- 첫째와 둘째 문제는 그대로 남는다
2.3 Container — 방을 나눈다
컨테이너가 세 문제를 푸는 방식
- 컨테이너는 집을 더 짓지 않는다 — 커널 하나 안에 방을 나눈다
- 방마다 자기 파일시스템이 있어서 파이썬 3.8 과 3.11 이 옆방에 공존한다
- 방에 무엇을 깔아 둘지는 이미지 한 장에 적혀 있어서 배포는 “이미지 받아서 켜기” 로 줄어든다
- 방마다 네트워크 네임스페이스가 따로 있으니 8080 도 셋이 동시에 쓴다
책의 덧붙임: “컨테이너는 실체가 없다” 는 지적
- 컨테이너는 실체가 없다고 말하는 사람들이 있고, 그 말은 기술적으로 맞다
- 커널이 원래 갖고 있던 기능에 이름을 붙인 것뿐이다
- 하지만 그 지적은 핵심을 놓친다
- 어려운 문제는 “앱을 어떻게 배포하고 관리하느냐” 였다
- 컨테이너는 그 문제를 푼 도구다
- 이 편의 순서
- 그 “커널이 원래 갖고 있던 기능” 이 무엇인지를 4장에서 본다
- 그 전에 누가 그 기능을 호출하는지를 3장에서 본다
3. Runtimes — 누가 켜고 누가 관리하나
3.1 저수준과 고수준 — 켜는 순간과 그 전후
책의 구분: 저수준과 고수준
- 책은 컨테이너 런타임을 저수준과 고수준으로 나눈다
- 저수준: 컨테이너를 만들고 실행하는 것
- 고수준: 이미지를 빌드하고 보관하고 공유하고 인스턴스를 관리하는 것
- 목록으로 보면 밋밋한데, 시간 순서로 늘어놓으면 경계가 보인다
시간 순서로 본 경계
- 저수준은 “켜는 순간” 만 담당한다
- 입력은 이미 풀려 있는 폴더 하나(루트 파일시스템)와 설정 파일 하나(config.json)다
- 그걸 받아 커널에 시스템콜을 날려 앱 프로세스를 띄우면 종료한다
- 고수준은 켜기 전과 켠 후 전부다
- 켜기 전 — 이미지 받기, 레이어 풀기, 설정 쓰기
- 켠 후 — 감시, 로그, 목록, 중지, 삭제
- 켜는 순간 자체는 저수준을 불러 시킨다
나눈 이유: OS 와 가장 밀접한 부분
- 이렇게 나눈 이유는 켜는 부분이 OS 와 가장 밀접하기 때문이다
- 시스템콜을 직접 치는 부분만 작게 떼어 규격으로 못 박아 두면
- 그 위는 커널 세부를 몰라도 된다
- 그 자리에 다른 저수준을 끼울 수도 있다
3.2 스택 — dockerd, containerd, runC, 그리고 kubelet
도커 엔진의 분리: 바이너리 하나 → 셋
- 도커 엔진은 처음에 바이너리 하나였다
- 2016년의 도커 1.11 에서 셋으로 갈라졌다
- dockerd — 사용자 요청을 받는다
- containerd — 이미지와 수명을 관리한다
- runC — 켜기만 한다
runC 의 출처와 범위
- 원래 도커 안에 있던 libcontainer 라이브러리를 꺼내 독립 실행 파일로 만든 것이다
- libcontainer 는 지금도 runC 안의 라이브러리로 남아 있다
- containerd 는 거기서 발전한 것이 아니라 별도로 만든 관리 서비스다
- runC 는 리눅스 전용이다
- 책은 runC 기능 목록에 Windows 컨테이너 지원을 넣었지만, Windows 컨테이너는 별도 런타임이 맡는다
- 지금 “도커 엔진” 이라고 부르는 것은 이 세 프로그램의 묶음이다
쿠버네티스 노드: 맨 위만 다르다
- dockerd 자리에 kubelet 이 오고, kubelet 은 containerd 에 직접 말을 건다
- 그 아래 shim, runC, 앱은 도커 단독일 때와 같다
예전 경로: dockershim
- 예전에는 한 층을 더 거쳤다
- kubelet 안에 dockershim 이라는 번역기가 있었다
- kubelet → dockershim → dockerd → containerd → runC 였다
- 도커 안에 이미 containerd 가 있으니 중복이었다
- 1.20 에서 폐기를 예고하고 1.24 에서 뺐다
- “쿠버네티스에서 도커를 못 쓴다” 는 오해가 여기서 나왔다
- 빠진 건 노드에서 켜는 경로뿐이고 이미지를 만드는 쪽(docker build)은 그대로다
- 이미지가 규격품이라 누가 켜든 같기 때문이다
“런타임” 이라는 말의 두 뜻
- “런타임” 이라는 말은 ②와 ③ 둘 다를 가리킨다
- 쿠버네티스 문서가 “컨테이너 런타임을 설치하라” 고 하면 containerd 나 CRI-O 다
- OCI 문서의 runtime 은 runC 다
- 누가 런타임이라고 하면 켜는 쪽인지 관리하는 쪽인지 한 번 되묻는 게 안전하다
3.3 OCI 와 CRI — 약속은 두 장
그림의 점선 두 개
- 그림의 점선 둘이 이 스택을 갈아 끼울 수 있게 만드는 약속이다
- 자리가 다르고 주인도 다르다
OCI: 규격 문서 세 장
- OCI 는 리눅스 재단 아래 단체이고, 규격 문서 세 장을 낸다
- Runtime Spec — config.json 에 무엇을 적을 수 있고 런타임이 그걸 어떤 순서로 처리해야 하는지
- Image Spec — 이미지가 어떤 파일들(레이어 tar, 매니페스트, 설정)로 이루어지는지
- Distribution Spec — 레지스트리와 주고받는 HTTP API
- 그래서 도커로 빌드한 이미지를 Harbor 에 올리고, containerd 가 받아서, runC 나 gVisor 가 켜는 식으로 단계마다 다른 제품이어도 된다
- 고수준 런타임이 runC 를 부를 때 지키는 약속이 이 중 Runtime Spec 이다
- 그림에서 containerd 아래의 선이다
CRI: 쿠버네티스 프로젝트의 gRPC 인터페이스
- CRI 는 OCI 소속이 아니다
- 쿠버네티스 프로젝트가 자기 저장소에 .proto 파일로 정의한 gRPC 인터페이스다
- kubelet 이 고수준 런타임에 말을 걸 때 쓴다
- 그림에서 kubelet 아래의 선이다
- containerd 와 CRI-O 는 둘 다 이 두 선 사이에 앉아서 위로는 CRI, 아래로는 OCI 를 지킨다
- 둘의 차이는 이 정도다
- containerd 는 도커에서 나와 범용이다
- CRI-O 는 처음부터 쿠버네티스만 보고 만들어서 자체 CLI 문화가 없다
OCI 선 아래의 저수준 런타임 세 가지
- OCI 선 아래에 runC 말고 다른 것이 올 수 있다는 점이 뒤에서 중요해진다
- runC — 앱을 호스트 커널 위에 직접 띄운다
- 네임스페이스로 가려 놓았을 뿐 시스템콜은 호스트 커널이 처리한다
- gVisor(runsc) — 앱과 커널 사이에 Go 로 짠 가짜 커널을 끼워 시스템콜 대부분을 자기가 흉내 낸다
- Kata — 컨테이너마다 작은 VM 을 띄운다
고르는 방법: RuntimeClass
- 셋 다 containerd 입장에서는 똑같이 “폴더와 config.json 줄 테니 켜라” 로 부른다
- 쿠버네티스에서는 RuntimeClass 로 Pod 마다 고른다
- 컨테이너가 “VM 은 무겁다” 에서 출발했는데 Kata 는 보안 때문에 그 자리에 VM 을 다시 넣은 셈이다
3.4 runC 는 사라지고 shim 이 남는다 [실습]
실습: 컨테이너 하나의 프로세스 트리
- 3.1 의 “켜고 나면 종료한다” 를 확인하려고 VM 에서 컨테이너를 하나 띄우고 프로세스 트리를 봤다
docker run -d --name c3 --memory 256m nginx:alpine
ps -eo pid,ppid,comm --forest | grep -E "dockerd|containerd|nginx|runc"
pgrep -a runc
644805 1 dockerd
1659053 1 containerd
2777998 1 containerd-shim
2778024 2777998 \_ nginx
2778074 2778024 \_ nginx
(no runc process)
결과: runC 는 없고 shim 이 부모
- runC 가 목록에 없다
- nginx 의 부모는 containerd-shim 이다
- shim 의 부모는 PID 1 이다 — containerd 의 자식이 아니다
- 띄우자마자 부모와 연을 끊어서(double fork) PID 1 의 자식이 되기 때문이다
runC 가 한 일: 프로세스 단위로 풀기
- runC 가 fork 로 자식을 하나 만든다
- 그 자식이 스스로 unshare 로 새 네임스페이스에 들어간다
- 자기 PID 를 cgroup 파일에 쓴다
- pivot_root 로 루트를 바꾼다
- execve 로 자기 몸을 nginx 로 갈아 끼운다
execve: 프로세스의 내용물 교체
- execve 는 새 프로세스를 만드는 게 아니라 지금 프로세스의 내용물을 다른 프로그램으로 바꾸는 시스템콜이다
- 방에 들어가서 준비한 “그 프로세스” 가 그대로 앱이 된다
- 컨테이너를 띄우는 일과 앱을 띄우는 일이 두 단계가 아니라 한 동작인 이유다
- 02-1 의 5.1 에서
ip netns exec로 방 안에 python3 를 띄운 것과 정확히 같은 구조다ip가 방에 들어간 뒤 execve 로 python3 가 됐다- 방 안에는 python3 만 남았다
shim 이 필요한 이유: 부모 자리
- 리눅스에서 프로세스는 부모가 있어야 한다
- 부모가 죽으면 자식의 표준 출력 파이프가 끊기고 종료 코드를 받아 줄 사람도 없어진다
- runC 가 nginx 의 부모로 남아 있으면 컨테이너 수만큼 runC 가 떠 있어야 한다
- dockerd 가 부모면 dockerd 업그레이드 한 번에 컨테이너가 전부 죽는다
- 그래서 작고 할 일 없는 shim 을 하나 끼워 부모 자리를 맡긴다
shim 이 하는 일 두 가지
- 파이프를 쥐고 있는다
- 앱이 죽으면 종료 코드를 containerd 에 전해 준다
- 덕분에 dockerd 를 재시작해도 컨테이너는 그대로 돈다
“데몬 없는 컨테이너” 의 범위
- 책은 이를 “데몬 없는 컨테이너” 라고 부르는데 범위를 좁혀 읽는다
- 없어진 것은 컨테이너마다 오래 사는 런타임 프로세스이지 데몬 전체가 아니다
- runC 라는 명령이 종료할 뿐 containerd 와 shim 은 여전히 떠 있다
- 쿠버네티스가 아니라 도커 단독이어도 같은 구조다
- Podman 은 containerd 대신 conmon 이라는 자기 감시 프로세스를 두는데 자리는 같다
전체 명령과 출력 — 01-runtime
4. Primitives — 방과 계량기
runC 가 쓰는 커널 기능 두 가지
- 도커를 쓰든 containerd 를 쓰든 실제로 커널을 건드리는 건 runC 다
- runC 가 쓰는 커널 기능은 둘이다 — 책이 한 줄로 정리한 구분이 이 절의 전부다
- 네임스페이스: 프로세스가 무엇을 볼 수 있는지
- cgroup: 얼마나 쓸 수 있는지
4.1 Namespaces — 같은 이름, 다른 실체 [실습]
네임스페이스를 가리키는 번호
- 네임스페이스는 이름이 없고 번호만 있다
- 어떤 프로세스가 어느 방에 있는지는
/proc/PID/ns/아래의 링크가 가리키는 번호로 안다 - 호스트의 PID 1 과 방금 띄운 nginx 를 나란히 놓고 비교했다
PID=$(docker inspect -f '{{.State.Pid}}' c3)
for n in net pid mnt uts ipc user; do
printf "%-5s %s %s\n" $n "$(sudo readlink /proc/1/ns/$n)" "$(sudo readlink /proc/$PID/ns/$n)"
done
net net:[4026531840] net:[4026532265]
pid pid:[4026531836] pid:[4026532263]
mnt mnt:[4026531841] mnt:[4026532255]
uts uts:[4026531838] uts:[4026532261]
ipc ipc:[4026531839] ipc:[4026532262]
user user:[4026531837] user:[4026531837]
결과: user 하나만 같은 번호
- 다섯 개는 번호가 다르고 user 하나만 같다
- 여섯 개가 전부 “같은 이름을 다른 프로세스가 다르게 본다” 라는 점은 같다
- 무엇이 달라지는지는 종류마다 다르다
2778024방 안에서 → 같은 nginx 가
PID 1. ps 를 쳐도 자기 식구만 보인다ens3 10.0.0.198방 안에서 → eth0 은 veth 한쪽 끝
172.17.0.2. 02-1 의 그 방/ 는 디스크방 안에서 →
/ 는 이미지 레이어를 겹친 폴더. pivot_root 가 바꿨다방 안에서 → 컨테이너 ID 앞 12자리. 같은 커널인데 이름이 둘
user 네임스페이스와 보안
- user 네임스페이스가 호스트와 같은 번호인 것은 보안 이야기다
- 켜면 컨테이너 안에서 root 로 돌아도 호스트에서는 일반 사용자로 매핑된다 → 탈출해도 호스트 root 가 아니다
- 도커는 기본으로 안 켜고(userns-remap 옵션), Podman 은 기본으로 켠다
- 쿠버네티스는 1.30 무렵에 Pod 단위 지원이 들어갔다
- 목록에 cgroup 이라는 일곱 번째 항목도 있는데, cgroup 경로 자체를 격리하는 것이라 몰라도 된다
PID 비교: 컨테이너 안과 밖
echo "outside: $PID"; docker exec c3 sh -c 'ps -o pid,comm'
outside: 2778024
PID COMMAND
1 nginx
30 nginx
결과: 같은 프로세스, 다른 번호
- 같은 프로세스를 두 방이 다른 번호로 부른다
- 02-1 의 2.4 에서 소켓 하나를 FD 번호와 inode 로 따로 가리킨 것과 같은 모양이다
4.2 cgroup — 폴더 하나와 파일 몇 개 [실습]
cgroup: 방이 아니라 계량기
- cgroup 은 방이 아니라 계량기다
- 방에 누가 있든 상관없이, 등록된 프로세스들이 쓰는 양만 센다
- 실체는
/sys/fs/cgroup아래의 디렉터리 하나다 - 3.4 에서
--memory 256m으로 띄운 컨테이너의 폴더를 찾아갔다
CG=$(cut -d: -f3 /proc/$PID/cgroup)
cat /sys/fs/cgroup$CG/memory.max
cat /sys/fs/cgroup$CG/cgroup.procs
268435456
2778024
2778074
결과: 파일에 적힌 한도와 PID
- memory.max 에 256 MiB 가 바이트로 적혀 있다
- cgroup.procs 에 nginx 마스터와 워커들의 PID 가 적혀 있다
- runC 가 한 일이 정확히 이 파일 쓰기다
- 폴더를 만든다
- memory.max 에 숫자를 쓴다
- cgroup.procs 에 자기 PID 를 쓴다
- 그 순간부터 그 프로세스와 자식들은 합쳐서 256 MiB 를 넘게 못 쓴다
docker run --memory의 숫자가 이 파일로 간다
네트워크와 닿는 지점: net_cls
- cgroup 에는 그 cgroup 에서 나가는 패킷에 꼬리표를 붙이는 기능(net_cls)이 있다
- 02-2 에서 본 Netfilter 나 tc 가 “어느 컨테이너에서 나온 패킷인가” 로 규칙을 걸 수 있다
- Cilium 이 eBPF 로 Pod 를 식별하는 것이 이 발상의 발전형이다
- CNI 장에서 다시 만난다
전체 명령과 출력 — 01-runtime
4.3 Pod 에서는 — 방 여섯 중 둘을 나눠 쓴다
Pod 의 변형: 방 일부를 같이 쓴다
- 여기까지는 도커 컨테이너 하나의 이야기다
- 컨테이너 하나가 방 여섯을 전부 새로 받는다
- Pod 는 여기서 변형이 생긴다
- Pod 안의 컨테이너들은 net 과 ipc(그리고 uts)는 하나를 같이 쓰고, pid 와 mnt 는 각자 갖는다
- 그래서 같은 Pod 의 컨테이너끼리 localhost 로 통하면서도 파일은 서로 안 보인다
- 02-1 의 5.3 에서 그린 분해도가 그 그림이다
방을 붙잡는 pause 컨테이너
- 그 방을 들고 있는 것이 pause 컨테이너다
- pause 가 먼저 떠서 네트워크 방을 만들어 붙잡는다
- 앱 컨테이너들은 runC 의 unshare 대신 setns 로 그 방에 합류한다
- 앱이 재시작돼도 pause 가 살아 있으니 Pod IP 가 안 바뀐다
5. Discussion
5.1 도구 지도 — 질문에서 명령으로
도구를 나누는 두 갈래
- 이 편에서 쓴 도구는 두 질문으로 나뉜다
- 누가 이 프로세스를 켰고 지금 부모가 누구인가
- 이 프로세스는 어느 방에 있고 얼마나 쓸 수 있나
ps -eo pid,ppid,comm --forestrunC 가 남아 있나 →
pgrep -a runc호스트 쪽 PID 는 →
docker inspect -f '{{.State.Pid}}'어느 저수준인가 →
docker info | grep -i runtimereadlink /proc/PID/ns/*안에서 보면 →
docker exec … ps계량기 폴더는 →
cat /proc/PID/cgroup한도는 →
cat /sys/fs/cgroup/…/memory.max주의: 권한 오류
/proc/PID/ns는 root 가 아니면 다른 사용자의 프로세스에 대해 Permission denied 가 난다- 호스트의 PID 1 을 보려면 sudo 가 필요하다
- 비어 있다고 네임스페이스가 없는 게 아니다
핵심 메시지
컨테이너를 켠다는 것은 프로세스 하나를 방에 넣고 계량기를 달아 띄우는 것이고, 그 일을 하는 runC 는 띄우자마자 사라진다. ps 로 보면 남는 것은 평범한 프로세스 하나와 그 부모 shim 뿐이다. 그래서 02-1 에서
ip netns list가 비어 있던 것도, 호스트의 ps 에 컨테이너의 nginx 가 그대로 보이는 것도 이상하지 않다. 다음 편은 그 방이 밖으로 나가는 길을 도커의 네트워크 모드로 본다 — bridge, host, none, macvlan, overlay 가 각각 방과 문을 어떻게 조합한 것인지, 그리고 CNI 는 도커 모델과 무엇이 다른지.
6. References
"A Standard Container bundle contains all the information needed to load and run a container."
"the shim ... is used to keep the container's stdio open and report the exit status"
"A namespace wraps a global system resource in an abstraction that makes it appear to the processes within the namespace that they have their own isolated instance"
