컨테이너는 VM이 아니다
Docker 컨테이너는 가상 머신처럼 보이지만, 실제로는 Linux 커널의 격리 기능을 조합한 프로세스입니다. 컨테이너를 제대로 이해하려면 그 내부를 구성하는 세 가지 핵심 기술을 알아야 합니다: Namespace, Cgroup, 그리고 OCI Runtime.
Linux Namespace: 프로세스 격리의 핵심
Namespace는 커널 리소스를 프로세스 단위로 격리합니다. 컨테이너 내부의 프로세스는 자신만의 독립된 환경을 가진 것처럼 보입니다.
| Namespace | 격리 대상 | 효과 |
|---|---|---|
| PID | 프로세스 ID | 컨테이너 내 PID 1부터 시작 |
| NET | 네트워크 스택 | 독립된 IP, 포트, 라우팅 테이블 |
| MNT | 파일시스템 마운트 | 독립된 마운트 포인트 |
| UTS | 호스트명 | 컨테이너별 hostname 설정 |
| IPC | 프로세스 간 통신 | 독립된 세마포어, 메시지 큐 |
| USER | 사용자/그룹 ID | 컨테이너 내 root ≠ 호스트 root |
| CGROUP | cgroup 루트 디렉토리 | 자신의 cgroup 트리만 조회 |
직접 namespace를 만들어 컨테이너의 격리를 체험할 수 있습니다.
# 새로운 PID + NET + MNT namespace에서 bash 실행
sudo unshare --pid --net --mount --fork bash
# 컨테이너 안에서 확인
hostname # 호스트명은 같음 (UTS namespace 미지정)
ip addr # 네트워크 인터페이스 없음 (loopback만)
ps aux # PID 격리 확인 (proc 마운트 필요)
mount -t proc proc /proc
ps aux # PID 1이 현재 bash
# 호스트에서 확인
sudo lsns -t pid # namespace 목록 조회
Cgroup v2: 리소스 제한의 기반
Namespace가 격리를 담당한다면, Cgroup(Control Group)은 리소스 사용량을 제한합니다. 최신 배포판은 Cgroup v2를 기본 사용합니다.
# Cgroup v2 확인
mount | grep cgroup2
# cgroup2 on /sys/fs/cgroup type cgroup2 ...
# 수동으로 cgroup 생성 및 메모리 제한
sudo mkdir /sys/fs/cgroup/my-container
echo "104857600" | sudo tee /sys/fs/cgroup/my-container/memory.max # 100MB
echo "50000 100000" | sudo tee /sys/fs/cgroup/my-container/cpu.max # CPU 50%
# 프로세스를 cgroup에 할당
echo $$ | sudo tee /sys/fs/cgroup/my-container/cgroup.procs
# 메모리 사용량 확인
cat /sys/fs/cgroup/my-container/memory.current
| 컨트롤러 | 파일 | 역할 |
|---|---|---|
| memory | memory.max | 메모리 상한 (초과 시 OOM Kill) |
| cpu | cpu.max | CPU 시간 제한 (quota/period) |
| io | io.max | 블록 I/O 대역폭 제한 |
| pids | pids.max | 프로세스 수 제한 (fork bomb 방지) |
K8s kubectl debug 트러블슈팅에서 다루는 Pod 디버깅도 결국 이 cgroup 경계 안에서 동작합니다.
OCI Runtime Spec: 표준화된 컨테이너 실행
OCI(Open Container Initiative)는 컨테이너 이미지와 런타임의 표준 스펙입니다. 이 표준 덕분에 Docker, Podman, containerd 등이 동일한 이미지를 실행할 수 있습니다.
# OCI 번들 구조
my-container/
├── config.json # 런타임 설정 (namespace, cgroup, mounts, entrypoint)
└── rootfs/ # 루트 파일시스템
├── bin/
├── etc/
├── lib/
└── ...
// config.json 핵심 구조
{
"ociVersion": "1.1.0",
"process": {
"terminal": false,
"user": { "uid": 0, "gid": 0 },
"args": ["/app/server"],
"env": ["PATH=/usr/bin:/bin", "NODE_ENV=production"],
"cwd": "/app"
},
"root": {
"path": "rootfs",
"readonly": true
},
"linux": {
"namespaces": [
{ "type": "pid" },
{ "type": "network" },
{ "type": "mount" },
{ "type": "ipc" },
{ "type": "uts" }
],
"resources": {
"memory": { "limit": 104857600 },
"cpu": { "quota": 50000, "period": 100000 }
}
}
}
runc: 저수준 컨테이너 런타임
runc는 OCI 스펙을 구현하는 참조 런타임입니다. Docker와 Kubernetes 모두 최종적으로 runc를 호출하여 컨테이너를 생성합니다.
# runc로 직접 컨테이너 실행
# 1. 루트 파일시스템 준비
mkdir -p my-container/rootfs
docker export $(docker create alpine:latest) | tar -C my-container/rootfs -xf -
# 2. OCI config 생성
cd my-container
runc spec # 기본 config.json 생성
# 3. 컨테이너 생성 및 실행
sudo runc create my-test-container
sudo runc start my-test-container
# 4. 상태 확인
sudo runc list
sudo runc state my-test-container
# 5. 정리
sudo runc delete my-test-container
runc의 라이프사이클은 명확합니다: create → start → (running) → kill → delete. 이것이 모든 컨테이너의 기본 생명주기입니다.
containerd: 고수준 컨테이너 런타임
containerd는 runc 위에서 동작하는 데몬으로, 이미지 관리, 스냅샷, 네트워킹 등 컨테이너 운영에 필요한 고수준 기능을 제공합니다. Kubernetes는 CRI(Container Runtime Interface)를 통해 containerd와 통신합니다.
# 호출 체인 (Kubernetes 기준)
kubelet → CRI → containerd → containerd-shim-runc-v2 → runc → 컨테이너 프로세스
# containerd 직접 사용 (ctr CLI)
sudo ctr images pull docker.io/library/nginx:alpine
sudo ctr images list
# 컨테이너 생성 및 실행
sudo ctr run --rm docker.io/library/nginx:alpine my-nginx
# 네임스페이스 확인 (containerd의 논리적 격리)
sudo ctr namespaces list
# default
# moby ← Docker가 사용하는 네임스페이스
# k8s.io ← Kubernetes가 사용하는 네임스페이스
| 구분 | runc | containerd | Docker |
|---|---|---|---|
| 역할 | 컨테이너 생성/실행 | 이미지+컨테이너 관리 | UX + 빌드 + 네트워킹 |
| 레벨 | Low-level runtime | High-level runtime | Container engine |
| 데몬 | 없음 (바이너리) | containerd 데몬 | dockerd 데몬 |
| K8s 연동 | 직접 불가 | CRI 네이티브 | cri-dockerd 필요 |
containerd-shim: 왜 필요한가
containerd-shim은 containerd와 runc 사이의 중간 프로세스입니다. 핵심 역할은 두 가지입니다.
- containerd 재시작 허용: shim이 컨테이너의 부모 프로세스가 되므로, containerd를 업데이트해도 실행 중인 컨테이너에 영향 없음
- STDIO 관리: 컨테이너의 stdout/stderr를 로그 파일로 전달
# 실행 중인 shim 프로세스 확인
ps aux | grep containerd-shim
# root 1234 containerd-shim-runc-v2 -namespace k8s.io -id abc123...
# 프로세스 트리
pstree -p $(pgrep containerd-shim | head -1)
# containerd-shim(1234)─┬─nginx(1235)
# └─nginx(1236)
보안 강화: rootless와 gVisor
기본 runc는 호스트 커널을 공유하므로, 추가 격리 옵션을 고려해야 합니다.
# Rootless 컨테이너 (Docker)
# 컨테이너 프로세스가 호스트의 비특권 사용자로 실행
dockerd-rootless-setuptool.sh install
docker context use rootless
# gVisor (runsc) - 사용자 공간 커널
# runc 대신 runsc를 런타임으로 등록
cat /etc/containerd/config.toml
# [plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runsc]
# runtime_type = "io.containerd.runsc.v1"
# K8s RuntimeClass로 Pod에 적용
apiVersion: node.k8s.io/v1
kind: RuntimeClass
metadata:
name: gvisor
handler: runsc
K8s Pod Security Standards와 함께 적용하면 컨테이너 보안을 다층으로 강화할 수 있습니다.
운영 트러블슈팅
# 컨테이너의 namespace 정보 확인
sudo ls -la /proc/<PID>/ns/
# lrwxrwxrwx 1 root root 0 pid -> 'pid:[4026532456]'
# lrwxrwxrwx 1 root root 0 net -> 'net:[4026532518]'
# 컨테이너 네트워크 namespace에 진입
sudo nsenter --target <PID> --net ip addr
sudo nsenter --target <PID> --pid --mount ps aux
# cgroup 리소스 사용량 실시간 모니터링
watch -n 1 cat /sys/fs/cgroup/system.slice/docker-<ID>.scope/memory.current
# OOM Kill 로그 확인
dmesg | grep -i "oom|killed process"
journalctl -k | grep -i oom
컨테이너는 마법이 아닙니다. Namespace로 격리하고, Cgroup으로 제한하고, OCI 스펙으로 표준화한 Linux 프로세스입니다. 이 기반을 이해하면 Docker 문제 디버깅, Kubernetes 노드 트러블슈팅, 보안 강화 설계까지 한 단계 깊이 있는 인프라 운영이 가능합니다.