✍️ 필사 모드: 컨테이너 내부 완전 가이드 2025: Namespaces, cgroups v2, OverlayFS, runc, OCI — Docker/Kubernetes가 실제로 동작하는 방식
한국어들어가며: 컨테이너는 마법이 아니다
한 가지 고백
"컨테이너는 가벼운 VM이다"라는 말을 들어봤을 것이다. 틀렸다. 컨테이너는 VM이 아니다. 컨테이너는 Linux 커널의 기존 기능들을 영리하게 조합한 프로세스 격리 기법일 뿐이다.
Docker나 Kubernetes를 매일 쓰면서도 속으로 궁금해본 적이 있을 것이다:
docker run뒤에 실제로 무슨 일이 일어나는가?- 왜 컨테이너는 호스트와 다른 프로세스 목록을 보는가?
- 이미지 레이어는 실제로 어떻게 구현되어 있는가?
- CPU/메모리 제한은 어디서 강제되는가?
- "Rootless 컨테이너"는 무엇이 다른가?
이 글은 그 질문들에 답한다. Docker 없이도 커맨드 라인과 커널 기능만으로 컨테이너를 직접 만들 수 있다. 그리고 이 지식은 단지 호기심이 아니라 보안, 디버깅, 성능 튜닝의 기초다.
컨테이너의 구성 요소
현대 Linux 컨테이너는 세 가지 핵심 기술로 이루어진다:
- Namespaces: 자원의 시야(view)를 분리 — 격리.
- cgroups: 자원의 사용량을 제한 — 자원 제어.
- Overlay filesystem: 이미지 레이어 관리.
여기에 보조적으로 seccomp, capabilities, AppArmor/SELinux 같은 보안 레이어가 얹힌다. 이 전체를 관리하는 것이 container runtime (runc, crun)이다.
1. Namespaces: 격리의 기본
Namespace란?
Linux namespace는 커널 자원의 별도 인스턴스를 프로세스 그룹에 제공한다. 한 namespace 안의 프로세스는 자신만의 PID, 네트워크, 파일 시스템 등을 갖고, 다른 namespace의 것을 보지 못한다.
현재 Linux가 지원하는 7가지 namespace:
| Namespace | 격리하는 것 | 예시 |
|---|---|---|
| PID | 프로세스 ID | PID 1이 각자 다름 |
| Mount (mnt) | 마운트 포인트 | 각자의 파일시스템 트리 |
| Network (net) | 네트워크 스택 | 각자의 인터페이스, 라우팅 |
| UTS | 호스트명, 도메인명 | hostname이 다름 |
| IPC | System V IPC, POSIX msg queue | 세마포어, 공유 메모리 |
| User | UID, GID | 컨테이너 내 root = 호스트의 일반 사용자 |
| Cgroup | cgroup 계층 시야 | /proc/cgroups 결과 다름 |
직접 만들어 보자: unshare
unshare 명령어로 새 namespace를 만들 수 있다:
# UTS namespace 분리
sudo unshare --uts bash
hostname newhost
hostname
# "newhost" 출력
# 다른 쉘에서:
hostname
# 원래 호스트명 출력 (격리됨)
놀랍지 않은가? docker run이 내부적으로 하는 일의 일부를 한 줄로 재현했다.
PID Namespace: 컨테이너의 PID 1
sudo unshare --pid --fork --mount-proc bash
ps aux
# PID 1이 bash!
--mount-proc 플래그가 중요하다. /proc은 커널이 프로세스 정보를 노출하는 특수 파일시스템인데, mount namespace와 함께 새로 마운트해야 올바른 PID가 보인다.
왜 PID 1이 중요한가? Linux에서 PID 1은 init 프로세스로 특별한 역할이 있다:
- 고아 프로세스를 입양.
- SIGTERM을 기본 핸들러로 무시 (명시적 핸들러 필요).
- 죽으면 모든 자식도 죽음.
컨테이너 안의 애플리케이션이 PID 1로 실행되면, 좀비 프로세스 수확(reaping) 책임이 생긴다. 그래서 tini, dumb-init 같은 미니 init 프로그램이 필요할 때가 있다.
Network Namespace: 네트워크 격리
# 새 network namespace 생성
sudo ip netns add mynet
# 그 안에서 명령 실행
sudo ip netns exec mynet ip link
# lo만 보임, 다른 인터페이스는 없음
# veth pair로 연결 (호스트 ↔ netns)
sudo ip link add veth0 type veth peer name veth1
sudo ip link set veth1 netns mynet
sudo ip addr add 10.0.0.1/24 dev veth0
sudo ip netns exec mynet ip addr add 10.0.0.2/24 dev veth1
sudo ip link set veth0 up
sudo ip netns exec mynet ip link set veth1 up
sudo ip netns exec mynet ip link set lo up
# 이제 호스트와 netns 사이 통신 가능
ping 10.0.0.2
Docker가 docker0 브리지를 만드는 게 바로 이 과정의 확장판이다.
User Namespace: 무루트 컨테이너의 핵심
User namespace는 특히 보안 관점에서 중요하다:
- 컨테이너 내부에서
uid=0(root)이지만 호스트에선 일반 사용자. - 공격자가 컨테이너를 탈출해도 호스트에선 제한된 권한.
# unshare로 user namespace 생성
unshare -U -r bash
id
# uid=0(root) gid=0(root)
# 호스트에서 확인
ps -ef | grep bash
# 같은 프로세스가 일반 사용자 UID로 보임
-r 플래그는 root UID 매핑을 설정한다. 더 복잡한 매핑은 /proc/[pid]/uid_map, gid_map을 직접 수정.
clone(2) 시스템콜
저수준에선 clone() 시스템콜이 namespace를 만든다:
#define _GNU_SOURCE
#include <sched.h>
#include <sys/wait.h>
int child_main(void *arg) {
printf("Child PID: %d\n", getpid()); // 1
execlp("bash", "bash", NULL);
return 0;
}
int main() {
char stack[8192];
clone(child_main, stack + 8192,
CLONE_NEWPID | CLONE_NEWNS | CLONE_NEWUTS | SIGCHLD,
NULL);
wait(NULL);
return 0;
}
fork()는 내부적으로 clone()을 호출한다. 컨테이너 런타임은 clone()에 namespace 플래그를 명시해서 새 프로세스를 생성한다.
2. cgroups v2: 자원 제한의 심장
cgroups란?
cgroups (Control Groups) 는 프로세스 그룹에 자원 사용 제한과 계정(accounting) 을 걸 수 있는 커널 기능이다.
cgroups가 관리하는 자원:
- CPU: 시간, 코어.
- Memory: 물리 메모리, 스왑.
- Block I/O: 디스크 대역폭.
- PIDs: 최대 프로세스 수.
- Network: 대역폭 제어 (별도 도구 필요).
v1 vs v2
cgroups는 두 가지 버전이 있다:
- v1 (2007): 각 자원별 독립 계층. 복잡한 혼합 가능하지만 비일관성.
- v2 (2016): 단일 계층 구조. 더 일관되고 단순.
최신 배포판(systemd 기반)은 기본 v2를 쓴다. Kubernetes 1.25+부터 v2 기본 지원.
파일 시스템 인터페이스
cgroups v2는 /sys/fs/cgroup에 마운트되며 파일 시스템처럼 조작한다:
# cgroup v2 확인
stat -fc %T /sys/fs/cgroup
# cgroup2fs
# 새 cgroup 생성
sudo mkdir /sys/fs/cgroup/mygroup
# 생성된 파일들 보기
ls /sys/fs/cgroup/mygroup/
# cgroup.procs, memory.max, cpu.max, io.max, ...
파일들의 의미:
cgroup.procs: 이 그룹에 속한 프로세스 PID 목록.memory.max: 최대 메모리 (초과 시 OOM kill).memory.current: 현재 사용 중.cpu.max: CPU 시간 할당량.io.max: 디스크 I/O 제한.
CPU 제한
# 500ms / 1000ms = 50% CPU
echo "500000 1000000" | sudo tee /sys/fs/cgroup/mygroup/cpu.max
# 현재 셸을 이 cgroup에 넣기
echo $$ | sudo tee /sys/fs/cgroup/mygroup/cgroup.procs
# CPU 바운드 작업
yes > /dev/null &
# 이제 이 프로세스는 50%만 쓰게 제한됨
top으로 확인하면 yes가 50% 근처에서 제한된다.
Memory 제한
# 100MB 제한
echo "100M" | sudo tee /sys/fs/cgroup/mygroup/memory.max
# 100MB 초과 시도
python3 -c 'x = "a" * (200 * 1024 * 1024)'
# Killed (OOM)
OOM kill은 커널 로그에 기록된다:
dmesg | tail
# [...] Memory cgroup out of memory: Killed process ...
계층 구조
cgroups v2는 트리 구조다:
/sys/fs/cgroup/
├── kubelet/
│ ├── pod1/
│ │ ├── container1/
│ │ └── container2/
│ └── pod2/
│ └── container1/
└── system.slice/
자식은 부모의 제한 내에서만 동작한다. Pod에 500m CPU를 할당하면, 그 안의 컨테이너는 합쳐서 500m을 넘을 수 없다.
Kubernetes와 cgroups
# Pod spec
resources:
limits:
cpu: "500m"
memory: "512Mi"
requests:
cpu: "250m"
memory: "256Mi"
kubelet이 이를 cgroups 값으로 변환:
limits.cpu: 500m→cpu.max = 50000 100000(50%)limits.memory: 512Mi→memory.max = 536870912requests.cpu: 250m→cpu.weight = 25(상대 가중치)
kubelet은 이 값을 Pod 시작 시 cgroup 파일에 씀으로써 격리를 강제한다.
3. OverlayFS: 이미지 레이어의 비밀
Union Filesystem이란?
Docker 이미지의 가장 큰 특징은 레이어다. 베이스 이미지 위에 애플리케이션 코드를, 그 위에 설정 파일을 쌓는다. 같은 베이스를 쓰는 이미지들은 레이어를 공유해 디스크 공간을 절약한다.
이를 가능하게 하는 것이 union filesystem이다. 여러 디렉토리를 "하나로 합쳐서" 보이게 한다. Linux에는 여러 구현이 있었다 (AUFS, Btrfs, Device Mapper, OverlayFS). 현재 OverlayFS가 사실상 표준이다.
OverlayFS 기본
OverlayFS는 네 가지 디렉토리를 사용한다:
- lower: 읽기 전용 베이스 (여러 개 가능, 스택).
- upper: 쓰기 가능한 레이어.
- work: OverlayFS 내부 작업용.
- merged: 합쳐진 뷰 (사용자가 접근).
직접 만들어 보자
mkdir -p /tmp/overlay/{lower1,lower2,upper,work,merged}
# 베이스 레이어에 파일 생성
echo "from lower1" > /tmp/overlay/lower1/file1.txt
echo "from lower2" > /tmp/overlay/lower2/file2.txt
echo "lower1 version" > /tmp/overlay/lower1/shared.txt
echo "lower2 version" > /tmp/overlay/lower2/shared.txt
# OverlayFS 마운트
sudo mount -t overlay overlay \
-o lowerdir=/tmp/overlay/lower1:/tmp/overlay/lower2,\
upperdir=/tmp/overlay/upper,\
workdir=/tmp/overlay/work \
/tmp/overlay/merged
# 합쳐진 뷰
ls /tmp/overlay/merged
# file1.txt file2.txt shared.txt
cat /tmp/overlay/merged/shared.txt
# "lower1 version" (왼쪽이 우선)
Copy-on-Write
merged에서 파일을 수정하면 어떻게 되는가?
echo "modified" > /tmp/overlay/merged/file1.txt
ls /tmp/overlay/upper/
# file1.txt (수정된 버전)
cat /tmp/overlay/lower1/file1.txt
# "from lower1" (원본 그대로!)
핵심: 수정이 upper 레이어로 간다. lower는 그대로. 이것이 copy-on-write 다. 여러 컨테이너가 같은 이미지를 공유하면서 각자 수정할 수 있는 비밀.
Whiteouts: 파일 삭제
삭제도 copy-on-write로 처리된다. lower의 파일을 삭제하면, upper에 whiteout 파일이 만들어진다:
rm /tmp/overlay/merged/file2.txt
ls -la /tmp/overlay/upper/
# c--------- file2.txt (character device, 0:0)
ls /tmp/overlay/merged/
# file2.txt가 없음
Whiteout은 특수한 character device 파일(major=0, minor=0)이다. OverlayFS는 이를 "삭제 표식"으로 해석한다.
Docker의 OverlayFS 사용
Docker 이미지는 여러 레이어로 저장된다:
docker inspect nginx:latest | jq '.[0].GraphDriver'
{
"Data": {
"LowerDir": "/var/lib/docker/overlay2/.../diff:/var/lib/docker/overlay2/.../diff:...",
"MergedDir": "/var/lib/docker/overlay2/.../merged",
"UpperDir": "/var/lib/docker/overlay2/.../diff",
"WorkDir": "/var/lib/docker/overlay2/.../work"
},
"Name": "overlay2"
}
각 이미지 레이어가 lower로 쌓이고, 컨테이너 시작 시 upper가 새로 생성된다. 컨테이너가 쓰기를 하면 upper에 쌓이고, 컨테이너 삭제 시 upper도 삭제 (볼륨 마운트는 별개).
이미지 공유의 효율
같은 베이스 이미지(예: ubuntu:22.04)를 쓰는 10개 컨테이너가 있다면:
- 이미지 자체는 디스크에 한 번만 저장.
- 각 컨테이너는 얇은 upper 레이어만.
- 파일 공유는 커널의 OverlayFS가 처리.
- 메모리 page cache도 공유.
결과: 100개 컨테이너가 수 GB가 아닌 수 MB의 디스크만 더 쓴다.
4. OCI 표준과 runc
Open Container Initiative
2015년 Docker 주도로 Open Container Initiative (OCI) 가 설립됐다. 목표: 컨테이너 런타임과 이미지 포맷을 표준화.
OCI의 3대 명세:
- Runtime Spec: 컨테이너 실행 방법.
- Image Spec: 이미지 포맷.
- Distribution Spec: 레지스트리 API.
OCI Runtime Bundle
OCI 런타임이 실행할 수 있는 "bundle"은 특정 구조를 가진다:
bundle/
├── config.json # 런타임 설정 (명세에 따름)
└── rootfs/ # 컨테이너의 루트 파일시스템
├── bin/
├── etc/
└── ...
config.json에는 namespaces, cgroups, 마운트, 명령어, 환경변수, capabilities 등이 명시된다.
runc: 레퍼런스 구현
runc는 OCI Runtime Spec의 레퍼런스 구현이다. Docker가 원래 자체 구현을 runc로 분리했다. Go로 작성됐고 매우 얇은 레이어다.
# OCI bundle 만들기
mkdir -p mycontainer/rootfs
# Alpine 루트 파일시스템 다운로드
docker export $(docker create alpine) | tar -C mycontainer/rootfs -xf -
# config.json 생성
cd mycontainer
runc spec # 기본 config.json 생성
# 실행
sudo runc run mycontainer
# / $ (Alpine 쉘 프롬프트)
runc spec이 만든 config.json을 보면 namespaces, cgroups 설정 등이 모두 들어있다. Docker가 하는 일의 핵심은 이 JSON을 만드는 것이다.
runc의 동작
runc run이 하는 일:
- config.json 파싱.
clone()으로 새 프로세스 생성 + namespace 플래그.- cgroup 생성 및 가입.
- 새 rootfs를
pivot_root로 전환. - 마운트 정리.
- capabilities, seccomp, AppArmor 적용.
- 지정된 명령어 실행.
이 흐름이 컨테이너의 모든 것이다. runc의 전체 코드는 수천 줄에 불과하다. "간단한 프로세스 생성의 복잡한 조합"인 것이다.
crun: 더 빠른 대안
crun은 C로 작성된 OCI 런타임이다:
- runc 대비 빠른 시작 시간.
- 적은 메모리 사용.
- rootless 지원 우수.
- Red Hat이 주도.
Podman과 일부 Kubernetes 환경이 기본으로 crun을 쓴다.
5. 상위 계층: containerd, CRI-O, Docker
런타임 계층의 분리
- Low-level runtime (runc, crun): 단일 컨테이너 실행.
- High-level runtime (containerd, CRI-O): 이미지 관리, 네트워크, 볼륨.
- Client (Docker CLI, kubectl): 사용자 인터페이스.
containerd
containerd는 CNCF 프로젝트로, Docker가 자체 엔진에서 분리한 것이다. 기능:
- OCI 이미지 pull/push.
- 컨테이너 생명주기 관리.
- 네트워크와 스토리지 플러그인.
- Kubernetes CRI (Container Runtime Interface) 구현.
containerd는 shim 프로세스를 통해 runc를 호출한다. shim은 컨테이너 프로세스의 부모로 남아서 containerd가 재시작해도 컨테이너를 유지한다.
CRI-O
Red Hat이 Kubernetes만을 위해 만든 가벼운 런타임. OCI 호환. Kubernetes CRI를 직접 구현.
Docker Engine의 구조
Docker CLI
↓ (REST API)
Docker Daemon (dockerd)
↓
containerd
↓
containerd-shim (컨테이너당 1개)
↓
runc (execve하고 종료)
↓
컨테이너 프로세스
Docker는 사실 여러 레이어의 얇은 포장지다. 진짜 컨테이너 기술은 runc와 커널에 있다.
Kubernetes의 선택
Kubernetes는 CRI를 통해 어떤 런타임과도 연동한다:
- containerd (기본, CNCF graduated).
- CRI-O (경량화된 대안).
- dockershim (deprecated, 1.24에서 제거).
이 변경(dockershim 제거)이 2022년 큰 뉴스였다. 사실 "Docker를 버린 것" 이 아니라 "중간 계층을 단순화한 것" 이다. 이미지는 여전히 Docker와 호환된다.
6. 네트워크: CNI와 brdige
컨테이너 네트워킹의 문제
컨테이너는 자기 network namespace를 가지지만, 실제로 통신하려면:
- 호스트 네트워크와 연결되어야 함.
- 다른 컨테이너와 통신 가능해야 함.
- 외부 인터넷 접근.
Docker의 기본: docker0 bridge
ip link show docker0
# docker0: <BROADCAST,MULTICAST,UP,LOWER_UP> ...
brctl show docker0
# bridge name bridge id STP enabled interfaces
# docker0 8000.0242... no veth1234
# veth5678
Docker는:
- 호스트에 docker0 브리지 생성.
- 각 컨테이너마다 veth pair (가상 이더넷 페어).
- 한쪽은 호스트의 docker0, 다른 쪽은 컨테이너의 eth0.
- iptables로 NAT 설정 (컨테이너 → 외부).
CNI (Container Network Interface)
Kubernetes는 네트워킹을 CNI 플러그인으로 추상화한다:
/etc/cni/net.d/10-mynet.conf
{
"cniVersion": "0.4.0",
"name": "mynet",
"type": "bridge",
"bridge": "cni0",
"ipam": {
"type": "host-local",
"subnet": "10.244.0.0/16"
}
}
kubelet이 Pod 생성 시 CNI 플러그인을 호출해 네트워크 설정.
주요 CNI 플러그인:
- Flannel: 간단한 VXLAN 기반 오버레이.
- Calico: BGP 라우팅, 네트워크 정책.
- Cilium: eBPF 기반, 고성능.
- Weave Net: 메시 네트워킹.
- AWS VPC CNI: EC2 ENI 직접 사용.
7. 보안 메커니즘: 여러 겹의 방어
Capabilities
Linux capabilities는 root 권한을 세분화한 것이다. 전통적으로 root는 모든 것을 할 수 있었지만, capabilities는 "네트워크 관리 권한", "파일 소유권 변경 권한" 등으로 나눈다.
컨테이너는 제한된 capabilities로 실행된다:
docker run --cap-drop=ALL --cap-add=NET_BIND_SERVICE nginx
NET_BIND_SERVICE만 허용 → 80 포트는 바인딩할 수 있지만 루트 권한은 없음.
Docker 기본 허용 capabilities (13개):
- CHOWN, DAC_OVERRIDE, FOWNER, FSETID
- KILL, SETGID, SETUID, NET_BIND_SERVICE
- NET_RAW, SYS_CHROOT, MKNOD, AUDIT_WRITE, SETFCAP
나머지는 기본 차단. 필요하면 --cap-add로 명시.
seccomp
seccomp (secure computing mode)는 프로세스가 호출할 수 있는 시스템콜을 제한한다.
Docker는 기본 seccomp 프로필로 ~60개의 위험한 시스템콜을 차단한다 (전체 300+개 중):
reboot: 호스트 재부팅 차단.swapon/swapoff: 스왑 설정 차단.settimeofday: 시간 조작 차단.kexec_load: 커널 교체 차단.
더 엄격한 프로필을 원하면 직접 작성 가능.
AppArmor / SELinux
Mandatory Access Control 시스템:
- AppArmor: 경로 기반 (Ubuntu 기본).
- SELinux: 레이블 기반 (Red Hat 계열 기본).
컨테이너의 파일 접근, 네트워크 사용, 프로세스 조작을 세밀하게 제어한다.
Rootless 컨테이너
Rootless는 root 없이 컨테이너를 실행하는 것이다:
- User namespace로 루트 권한을 "가짜로" 부여.
- 호스트에선 일반 사용자 권한.
- 탈출해도 제한된 권한.
- Docker는 rootless mode 지원, Podman은 기본 rootless.
# Podman
podman run alpine
# 이미 rootless (유저 권한)
# Docker rootless
dockerd-rootless.sh &
docker run alpine
제약: 일부 네트워크 기능 제한, 포트 1024 미만 바인딩 불가 (별도 설정 필요).
8. 처음부터 컨테이너 만들기
이론은 충분하다. 직접 만들어 보자.
필요한 것들
- Alpine 루트 파일시스템 (tarball).
- 쉘과 기본 도구.
스크립트
#!/bin/bash
# mycontainer.sh - 20줄짜리 컨테이너 런타임
set -e
ROOTFS=/tmp/mycontainer/rootfs
mkdir -p $ROOTFS
# 1. 루트 파일시스템 준비 (한 번만)
if [ ! -f $ROOTFS/bin/sh ]; then
docker export $(docker create alpine) | tar -C $ROOTFS -xf -
fi
# 2. Network namespace 생성
ip netns add mycon
ip link add mycon-veth type veth peer name mycon-inner
ip link set mycon-inner netns mycon
ip addr add 10.0.100.1/24 dev mycon-veth
ip netns exec mycon ip addr add 10.0.100.2/24 dev mycon-inner
ip link set mycon-veth up
ip netns exec mycon ip link set mycon-inner up
ip netns exec mycon ip link set lo up
ip netns exec mycon ip route add default via 10.0.100.1
# 3. cgroup 설정
mkdir -p /sys/fs/cgroup/mycon
echo "100000000" > /sys/fs/cgroup/mycon/memory.max # 100MB
echo "50000 100000" > /sys/fs/cgroup/mycon/cpu.max # 50% CPU
# 4. 컨테이너 실행 (unshare로 namespace 분리)
ip netns exec mycon \
unshare --pid --mount --uts --ipc --fork --mount-proc=$ROOTFS/proc bash -c "
# cgroup 참가
echo \$\$ > /sys/fs/cgroup/mycon/cgroup.procs
# 호스트명 설정
hostname mycontainer
# 루트 전환
mount --bind $ROOTFS $ROOTFS
cd $ROOTFS
mkdir -p old_root
pivot_root . old_root
cd /
umount -l /old_root
rmdir /old_root
# Alpine shell 실행
exec /bin/sh
"
이 스크립트는:
- Alpine 루트 파일시스템을
/tmp/mycontainer/rootfs에 준비. - 별도 network namespace + veth pair로 네트워킹.
- cgroup으로 100MB/50% CPU 제한.
- unshare로 PID/mount/UTS/IPC namespace 격리.
- pivot_root로 루트 전환.
- Alpine의 /bin/sh 실행.
이것이 Docker가 하는 일의 전부다. 수천 줄의 Docker 코드는 대부분 이미지 관리, 네트워킹 자동화, API 서버, 로깅, 보안 프로필 같은 주변 기능이다. 컨테이너 자체의 핵심은 위의 20줄이다.
9. 디버깅과 관찰
컨테이너 내부 들여다보기
# 컨테이너 PID 찾기
docker inspect --format '{{.State.Pid}}' mycontainer
# 12345
# 그 프로세스의 namespace 확인
sudo ls -l /proc/12345/ns/
# ipc -> ipc:[4026532282]
# mnt -> mnt:[4026532279]
# net -> net:[4026532285]
# pid -> pid:[4026532283]
# user -> user:[4026531837]
# uts -> uts:[4026532281]
# 호스트의 namespace와 비교
sudo ls -l /proc/1/ns/
같은 번호면 호스트와 공유, 다른 번호면 격리.
nsenter: 기존 namespace 진입
# 컨테이너의 namespace로 들어가기
sudo nsenter --target 12345 --mount --uts --ipc --net --pid -- bash
# 이제 컨테이너 안에 있음
docker exec가 내부적으로 하는 일이다.
cgroup 상태 확인
# 컨테이너의 cgroup 찾기
cat /proc/12345/cgroup
# 0::/docker/abc123.../
# 그 cgroup의 현재 메모리 사용
cat /sys/fs/cgroup/docker/abc123.../memory.current
# CPU 사용 통계
cat /sys/fs/cgroup/docker/abc123.../cpu.stat
보안 검사
# 실행 중인 컨테이너의 capabilities
grep Cap /proc/12345/status
# CapInh: 00000000a80425fb
# CapPrm: 00000000a80425fb
# CapEff: 00000000a80425fb
# 해석
capsh --decode=00000000a80425fb
# cap_chown,cap_dac_override,...
# seccomp 상태
grep Seccomp /proc/12345/status
# Seccomp: 2 (0=disabled, 1=strict, 2=filter)
프로파일링
# bpftrace로 시스템콜 추적
sudo bpftrace -e 'tracepoint:syscalls:sys_enter_* /pid==12345/ { @[probe] = count(); }'
# perf로 CPU 프로파일링
sudo perf record -p 12345 -F 99 -g
sudo perf report
10. 흔한 함정과 해결책
함정 1: 좀비 프로세스
증상: 컨테이너 내부에서 ps로 <defunct> 프로세스 쌓임.
원인: PID 1이 애플리케이션이고, 고아 프로세스를 수확하지 않음.
해결:
# tini 사용
RUN apk add --no-cache tini
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["my_app"]
# 또는 docker run --init
docker run --init myimage
--init 플래그는 tini를 자동 삽입한다.
함정 2: OOM이 조용히 발생
증상: 컨테이너가 이유 없이 재시작.
원인: cgroup memory.max 초과로 OOM kill.
해결:
# 호스트에서 확인
dmesg | grep -i "killed process"
# 또는 kubectl
kubectl describe pod mypod
# Events: OOMKilled
메모리 limit을 늘리거나 애플리케이션 메모리 누수를 찾아야 한다.
함정 3: JVM이 컨테이너 메모리를 인식 못 함
증상: Java 앱이 컨테이너 limit보다 많은 메모리를 요청해 OOM.
원인: 오래된 JVM은 /proc/meminfo를 호스트 값으로 인식.
해결:
- JDK 8u191+:
-XX:+UseContainerSupport(기본 on). - JDK 10+: 자동 인식.
- 수동:
-Xmx512m명시적 설정.
함정 4: Ulimit 누수
컨테이너는 ulimit을 호스트에서 상속한다. 호스트가 작은 nofile이면 컨테이너도 작다.
해결:
docker run --ulimit nofile=65536:65536 myimage
함정 5: DNS가 느림
증상: 컨테이너 내부에서 DNS 조회가 이상하게 느림.
원인: musl libc (Alpine)의 DNS 동작이 glibc와 다름. 일부 조회가 실패하면 순차 재시도.
해결: Alpine이 꼭 필요 없으면 debian-slim 등 glibc 기반 베이스 사용.
함정 6: 컨테이너 내부에서 cgroup 수정 불가
증상: 컨테이너 내부에서 /sys/fs/cgroup에 쓰려니 권한 거부.
원인: 컨테이너의 cgroup은 읽기 전용으로 마운트.
해결: 보통 애플리케이션이 cgroup을 수정할 필요 없음. 꼭 필요하면 --cgroupns=host 또는 privileged mode (위험).
11. 성능 특성
컨테이너 vs VM 오버헤드
| 항목 | VM | 컨테이너 |
|---|---|---|
| 시작 시간 | 수십 초 | 수백 ms |
| 메모리 오버헤드 | 수백 MB | 수 MB |
| 디스크 오버헤드 | 수 GB | 수 MB (이미지 공유) |
| CPU 오버헤드 | 5~10% | ~0% |
| 격리 수준 | 강함 (하드웨어) | 중간 (커널 공유) |
컨테이너의 CPU 오버헤드는 거의 0이다. Linux 커널 그대로 실행하기 때문.
OverlayFS 쓰기 성능
OverlayFS의 copy-on-write는 첫 수정 시 파일 전체 복사가 필요하다:
# 컨테이너 내부에서 큰 파일 수정
time dd if=/dev/zero of=/large.bin bs=1M count=100
# 첫 수정 시 느림 (upper로 복사)
echo "change" >> /large.bin
# 여전히 느림 (100MB 전체 복사)
해결: 자주 수정되는 데이터는 볼륨 마운트로 처리. 컨테이너 레이어 밖에 두기.
cgroup CPU 제한의 함정
cpu.max = 50000 100000은 "100ms 중 50ms"를 의미한다. 이 덕분에:
- 여러 코어에 분산된 짧은 버스트는 제한되지 않을 수 있음.
- 긴 버스트는 100ms 주기로 잘림 → 레이턴시 스파이크.
이는 "CPU throttling" 이라는 악명 높은 문제로 이어진다. Java 같은 멀티스레드 앱에서 특히 문제.
완화책:
- CPU limit을 설정하지 않고 request만 사용.
cpu.cfs_period_us를 작게 조정 (더 정밀한 제한).- Kubernetes static CPU manager로 코어 배정.
퀴즈로 복습하기
Q1. 컨테이너가 "가벼운 VM"이 아니라는 말의 정확한 의미는?
A. VM은 하이퍼바이저 위에서 별도의 OS 커널을 실행한다. 각 VM이 자기 커널을 가지므로 격리가 강하지만 오버헤드가 크다. 반면 컨테이너는 호스트 커널을 그대로 공유하고, Linux 커널의 기능(namespaces, cgroups)만으로 프로세스 간 시야를 분리한다. 따라서:
(1) 시작 시간이 수백 ms로 빠르다 (커널 부팅 불필요). (2) 메모리 오버헤드가 거의 없다 (커널 공유). (3) CPU 오버헤드가 0에 가깝다. (4) 그러나 격리 수준이 약하다 — 커널 취약점 하나로 호스트 전체가 뚫릴 수 있다.
VM이 격리의 강함 대 성능을 택했다면, 컨테이너는 그 반대다. 그래서 AWS Firecracker, Kata Containers 같은 "경량 VM" 기술이 둘의 중간을 목표로 한다.
Q2. cgroups v1과 v2의 가장 큰 차이는?
A. v1은 각 자원(CPU, memory, io, pids 등)마다 독립된 계층을 가진다. 한 프로세스가 CPU는 groupA에 속하고 memory는 groupB에 속할 수 있어 복잡하고 일관성이 없었다. v2는 단일 통합 계층을 사용한다. 모든 자원 컨트롤러가 같은 트리 구조를 공유하며, 한 프로세스는 오직 한 cgroup에만 속한다.
이점: (1) 일관성: 한 cgroup이 모든 자원을 함께 제한. (2) 단순성: 설정과 이해가 쉬움. (3) 새 기능: PSI (Pressure Stall Information), io.weight 등 v2 전용 기능. (4) Rootless 지원: 일반 사용자도 서브트리 관리 가능.
Kubernetes 1.25+, Red Hat 9+, Ubuntu 22.04+, Docker 20+ 모두 v2를 기본으로 한다.
Q3. OverlayFS의 copy-on-write가 어떻게 이미지 공유를 가능하게 하는가?
A. OverlayFS는 lowerdir를 읽기 전용 베이스로, upperdir를 쓰기 가능 레이어로 분리한다. 같은 lower를 쓰는 여러 컨테이너가 있을 때:
(1) 디스크: lower 파일은 단 하나의 사본만 존재. 모든 컨테이너가 같은 물리 파일 공유. (2) 페이지 캐시: lower에서 읽힌 파일은 커널 page cache에 한 번만 로드. 여러 컨테이너가 공유. (3) 쓰기: 컨테이너가 파일을 수정하면 먼저 upper로 복사한 뒤 수정. lower는 그대로. (4) 삭제: lower 파일을 삭제하면 upper에 whiteout(character device) 생성. lower는 여전히 있지만 "보이지 않게".
이 덕분에 100개의 컨테이너가 같은 nginx:latest 이미지를 쓰면, 디스크 사용은 수십 MB만 증가한다 (각 upper). 이것이 Docker의 "가벼움"의 핵심이다.
Q4. Docker에서 --cap-drop=ALL --cap-add=NET_BIND_SERVICE가 무엇을 의미하는가?
A. Linux capabilities는 전통적인 "all-or-nothing" root 권한을 38개의 세분화된 권한으로 나눈 것이다. 루트가 할 수 있는 일들을 각각의 capability로 분리했다:
CAP_NET_BIND_SERVICE: 1024 미만 포트 바인딩.CAP_SYS_ADMIN: 마운트, swapon 등 (매우 강력).CAP_CHOWN: 파일 소유권 변경.CAP_DAC_OVERRIDE: 파일 권한 검사 우회.- 등등.
--cap-drop=ALL로 모든 capability를 제거하고, --cap-add=NET_BIND_SERVICE로 딱 그 하나만 추가한다. 결과: 컨테이너 안에서 uid=0이어도 80 포트는 바인딩할 수 있지만, 다른 root-like 작업(마운트, 커널 모듈 로드, 임의 파일 접근 등)은 불가능하다.
이는 최소 권한 원칙의 실천이며, 컨테이너 탈출 취약점의 피해를 크게 줄인다. 프로덕션 컨테이너는 항상 이런 제한을 걸어야 한다.
Q5. Kubernetes에서 CPU limit을 걸면 왜 "CPU throttling" 문제가 생기는가?
A. Kubernetes의 CPU limit은 cgroup의 cpu.max로 변환된다. 예를 들어 limits.cpu: 500m은 "100ms(period) 중 50ms(quota)"를 의미한다. 문제는 이 쿼터가 100ms 단위의 완강한 창으로 동작한다는 점이다:
- 애플리케이션이 100ms 동안 여러 코어에 걸쳐 총 50ms-CPU를 소모하면 남은 50ms는 강제 sleep.
- 짧은 버스트가 끝나면 나머지 기간 동안 CPU를 못 쓴다.
- 결과: 레이턴시 스파이크. 평균 CPU 사용은 낮아 보이는데 p99가 끔찍해진다.
특히 JVM 같은 멀티스레드 애플리케이션에서 여러 스레드가 동시에 실행되면 순식간에 쿼터를 소진하고 다음 period까지 멈춘다. Netflix 등 여러 회사가 이 문제로 CPU limit을 아예 설정하지 않거나, static CPU manager로 코어를 전용 할당하는 방식을 쓴다.
해결책:
(1) CPU limit을 설정하지 말고 request만 사용 (cgroup weight로 fair share).
(2) cpu.cfs_period_us 축소 (100ms → 10ms) — 더 고른 분산.
(3) Static CPU Manager — 코어를 전용 할당.
(4) Guaranteed QoS + integer CPU — Kubernetes가 코어 pinning.
결과적으로 "limit = request + 약간"이라는 통념은 의외로 적용하기 까다롭다.
마치며: 커널의 우아함
핵심 정리
- 컨테이너 = Namespaces + cgroups + OverlayFS + runtime.
- Namespaces: 시야 격리 (PID, mount, net, UTS, IPC, user, cgroup).
- cgroups v2: 자원 제한과 계정 (단일 통합 계층).
- OverlayFS: copy-on-write로 이미지 레이어 공유.
- OCI runtime spec: runc가 표준 구현.
- 상위 런타임: containerd, CRI-O가 관리 기능 제공.
- 보안: capabilities + seccomp + AppArmor/SELinux + rootless.
왜 이 지식이 필요한가?
Docker/Kubernetes를 단지 "도구"로 쓰는 것과 내부를 이해하는 것은 큰 차이를 만든다:
- 디버깅: "왜 이 컨테이너가 느려?" 에 정확히 답할 수 있다.
- 보안: 어떤 권한을 빼야 하는지 안다.
- 최적화: cgroup 설정, 스토리지 드라이버를 튜닝할 수 있다.
- 문제 해결: 좀비 프로세스, OOM, throttling을 바로 이해한다.
- 새 도구 수용: Podman, Kata, Firecracker 등 변형들이 쉬워진다.
Docker가 준 선물
Docker의 진짜 혁신은 기술이 아니라 추상화였다. Linux 커널은 2008년부터 namespaces와 cgroups를 갖고 있었다. 하지만 이를 편하게 사용할 수 있는 도구가 없었다. Docker는 복잡한 커널 기능을 "이미지, 컨테이너, 네트워크"라는 직관적인 단어로 감쌌다.
이제 우리는 그 내부를 들여다봤다. 복잡해 보이지만 결국 Linux 프로세스 생성의 영리한 변형이다. 이 지식을 가지면, 다음에 docker run을 칠 때 완전히 다른 눈으로 볼 수 있을 것이다.
참고 자료
- Linux man pages: namespaces(7)
- cgroups v2 documentation
- OverlayFS documentation
- OCI Runtime Specification
- runc source code
- Linux Containers Wiki
- Julia Evans: What even is a container?
- Liz Rice: Building a Container from Scratch - 명강연
- Docker deep dive
- CRI-O: Kubernetes Container Runtime
- containerd documentation
- Rootless Containers
현재 단락 (1/476)
"컨테이너는 가벼운 VM이다"라는 말을 들어봤을 것이다. **틀렸다.** 컨테이너는 VM이 아니다. 컨테이너는 **Linux 커널의 기존 기능들을 영리하게 조합**한 프로세스 격리...