Docker는 어떻게 컨테이너를 만드는가
docker run 한 줄 뒤에서 dockerd, containerd, runc가 협력하는 과정부터 OCI 표준이 이 분리를 가능하게 한 이유까지, 런타임 스택의 설계를 추적한다.
- 01 컨테이너는 어떻게 격리되면서도 빠른가
- 02 Docker 이미지 크기가 10배 차이 나는 이유
- 03 Docker 네트워크는 어떻게 패킷을 옮기는가
- 04 Docker 스토리지는 어디서 끝나고 어디서 시작되는가
- 05 Docker Compose에서 Swarm까지, 어떻게 설계가 이어지는가
- 06 Docker 보안은 왜 여러 계층이 필요한가
- 07 Docker 컨테이너 리소스 관리의 철학
- 08 Docker는 어떻게 컨테이너를 만드는가
- 09 컨테이너 패턴의 통일된 철학은 무엇인가
- 10 코드 한 줄이 프로덕션에 닿기까지
- 11 컨테이너 디버깅은 왜 이렇게 어려운가
- 12 Docker로 Full-stack 서비스를 운영한다는 것은 무엇인가
- 13 Docker에서 Kubernetes로 — 무엇이 달라지는가
docker run nginx를 입력하는 순간, 실제로 무슨 일이 일어나는가? Docker CLI가 마법처럼 컨테이너를 만드는 것처럼 보이지만, 그 뒤에는 세 개의 분리된 런타임이 협력하는 정교한 계층 구조가 있다. 이 구조가 왜 분리되어 있고, 각 계층이 어떤 책임을 지는가?
세 계층으로 나뉜 이유
초기 Docker(1.x)는 모놀리식이었다. dockerd 하나가 이미지 관리, 네트워크, 볼륨, 컨테이너 실행까지 전부 담당했다. 이 구조의 치명적 단점은 dockerd를 재시작하면 모든 컨테이너가 중단된다는 것이었다.
현재 Docker는 세 계층으로 나뉜다.
docker run nginx
│
dockerd ← 사용자 편의 기능 (이미지, 네트워크, 볼륨)
│ gRPC
containerd ← 컨테이너 생명주기 + OCI 번들 변환
│ OCI Bundle
runc ← 실제 프로세스 생성 (Namespace + Cgroup)
│ clone() + execve()
Container Process
이 분리 덕분에 dockerd를 업데이트해도 containerd가 컨테이너를 계속 유지한다. Kubernetes는 한 발 더 나아가 dockerd를 아예 제거하고 containerd를 직접 호출한다(v1.24+).
OCI 표준이 분리를 가능하게 했다
세 계층이 서로 교체 가능한 이유는 OCI(Open Container Initiative) 표준이 인터페이스를 정의했기 때문이다. OCI는 세 가지를 표준화한다.
- Runtime Spec: 컨테이너를 어떻게 실행할지 —
config.json+rootfs/로 구성된 OCI 번들 - Image Spec: 이미지를 어떻게 패키징할지 — manifest, config, layers
- Distribution Spec: 이미지를 어떻게 배포할지 — Registry API
containerd가 runc를 crun이나 kata-containers로 교체할 수 있는 것도, Docker 이미지를 Podman이 그대로 쓸 수 있는 것도 이 표준 덕분이다. OCI 번들(config.json + rootfs/)만 만들 수 있다면 어떤 런타임이든 컨테이너를 실행할 수 있다.
runc가 실제로 하는 일
runc는 OCI Runtime Spec의 참조 구현체다. config.json을 파싱한 뒤 다음 순서로 컨테이너를 만든다.
1. Linux Namespace 생성 (PID, NET, MNT, UTS, IPC)
2. Cgroup 설정 (CPU, Memory, I/O 제한)
3. rootfs 마운트 (OverlayFS)
4. Capabilities 설정
5. Seccomp 필터 적용
6. clone() 시스템 콜 (새 프로세스 생성)
7. execve() (컨테이너 PID 1 실행)
runc가 create와 start를 분리하는 이유가 있다. create 단계에서 Namespace와 Cgroup이 준비되면, containerd가 이 시점에 네트워크 인터페이스(veth pair)를 연결한다. 그 다음에야 start로 프로세스를 실행한다. docker run은 이 두 단계를 한 번에 수행하는 래퍼다.
containerd의 역할 — Content Store와 Snapshot
containerd는 단순히 runc를 호출하는 래퍼가 아니다. 두 가지 핵심 메커니즘을 담당한다.
Content Store: 모든 이미지 레이어를 SHA256 해시로 관리한다. 같은 내용이면 같은 해시 → 한 번만 저장된다. 100개 이미지가 같은 alpine 베이스를 쓴다면 그 레이어는 디스크에 하나만 존재한다.
Snapshot: 이미지의 읽기 전용 레이어들 위에 컨테이너 전용 쓰기 레이어를 얹는다. OverlayFS가 이를 투명하게 합쳐 컨테이너에 하나의 rootfs로 보여준다. 컨테이너가 파일을 수정하면 원본은 건드리지 않고 쓰기 레이어에 Copy-on-Write로 복사본을 만든다.
containerd는 멀티테넌시를 위해 Namespace를 사용한다. Docker가 사용하는 namespace는 moby, Kubernetes는 k8s.io다. ctr -n moby container ls로 Docker 컨테이너를 직접 확인할 수 있다.
OCI 이미지의 실체
docker pull nginx가 받아오는 것은 실제로 다음 구조다.
Image Index (Fat Manifest)
├── linux/amd64 → Manifest A
│ ├── Config (아키텍처, Cmd, Env, diff_ids)
│ └── Layers [sha256:a3ed95..., sha256:9f13e0..., ...]
└── linux/arm64 → Manifest B
└── ...
같은 태그(nginx:latest)로 x86 서버는 amd64 레이어를, Apple Silicon은 arm64 레이어를 받는다. 모든 참조는 SHA256 해시 기반이므로 무결성이 자동으로 보장된다.
docker run --memory=512m --cpus=2 nginx가 내부적으로 만드는 config.json은 다음과 같다.
{
"linux": {
"resources": {
"memory": { "limit": 536870912 },
"cpu": { "quota": 200000, "period": 100000 }
},
"namespaces": [
{"type": "pid"}, {"type": "network"},
{"type": "mount"}, {"type": "uts"}, {"type": "ipc"}
]
}
}
Docker가 제공하는 사용자 친화적 옵션(--memory, --cpus)은 모두 이 JSON 필드로 변환되어 runc에 전달된다.
계층 분리는 안정성과 교체 가능성을 얻는 대신 복잡성을 댓가로 치른다. 컨테이너 실행 경로가 dockerd → containerd → runc → clone()/execve()로 길어지고, 문제가 생겼을 때 어느 계층인지 파악해야 한다. Kubernetes가 dockershim을 제거한 이유 중 하나도 이 불필요한 hop을 줄이기 위해서였다. 각 계층이 맡은 책임을 명확히 알수록 문제를 더 빠르게 찾을 수 있다.
정리
docker run은 dockerd → containerd → runc 세 계층을 순서대로 거쳐clone() + execve()로 끝난다.- OCI 표준이 이 계층들을 교체 가능하게 만든다. runc 대신 crun, kata-containers를 사용할 수 있는 것도 이 덕분이다.
- containerd의 Content Store는 SHA256 기반 중복 제거로 디스크를 절약하고, Snapshot은 OverlayFS로 Copy-on-Write를 구현한다.
docker run의 모든 옵션은 결국config.json의linux.resources와linux.namespaces필드로 변환되어 runc에 전달된다.
계층이 많아 보이지만 각 계층의 책임은 명확하다 — dockerd는 편의, containerd는 관리, runc는 실행. 이 분리가 dockerd를 재시작해도 컨테이너가 살아있는 이유다.