Skip to content

필사 모드: 컨테이너 내부 완전 가이드 2025: Namespaces, cgroups v2, OverlayFS, runc, OCI — Docker/Kubernetes가 실제로 동작하는 방식

한국어
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.
원문 렌더가 준비되기 전까지 텍스트 가이드로 표시합니다.

들어가며: 컨테이너는 마법이 아니다

한 가지 고백

"컨테이너는 가벼운 VM이다"라는 말을 들어봤을 것이다. **틀렸다.** 컨테이너는 VM이 아니다. 컨테이너는 **Linux 커널의 기존 기능들을 영리하게 조합**한 프로세스 격리 기법일 뿐이다.

Docker나 Kubernetes를 매일 쓰면서도 속으로 궁금해본 적이 있을 것이다:

- `docker run` 뒤에 실제로 무슨 일이 일어나는가?

- 왜 컨테이너는 호스트와 다른 프로세스 목록을 보는가?

- 이미지 레이어는 실제로 어떻게 구현되어 있는가?

- CPU/메모리 제한은 어디서 강제되는가?

- "Rootless 컨테이너"는 무엇이 다른가?

이 글은 그 질문들에 답한다. **Docker 없이도 커맨드 라인과 커널 기능만으로 컨테이너를 직접 만들 수 있다**. 그리고 이 지식은 단지 호기심이 아니라 **보안, 디버깅, 성능 튜닝**의 기초다.

컨테이너의 구성 요소

현대 Linux 컨테이너는 **세 가지 핵심 기술**로 이루어진다:

1. **Namespaces**: 자원의 시야(view)를 분리 — 격리.

2. **cgroups**: 자원의 사용량을 제한 — 자원 제어.

3. **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 = 536870912`

- `requests.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대 명세:

1. **Runtime Spec**: 컨테이너 실행 방법.

2. **Image Spec**: 이미지 포맷.

3. **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`이 하는 일:

1. config.json 파싱.

2. `clone()`으로 새 프로세스 생성 + namespace 플래그.

3. cgroup 생성 및 가입.

4. 새 rootfs를 `pivot_root`로 전환.

5. 마운트 정리.

6. capabilities, seccomp, AppArmor 적용.

7. 지정된 명령어 실행.

이 흐름이 컨테이너의 모든 것이다. 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를 가지지만, 실제로 통신하려면:

1. 호스트 네트워크와 연결되어야 함.

2. 다른 컨테이너와 통신 가능해야 함.

3. 외부 인터넷 접근.

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는:

1. 호스트에 **docker0 브리지** 생성.

2. 각 컨테이너마다 **veth pair** (가상 이더넷 페어).

3. 한쪽은 호스트의 docker0, 다른 쪽은 컨테이너의 eth0.

4. **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

"

이 스크립트는:

1. Alpine 루트 파일시스템을 `/tmp/mycontainer/rootfs`에 준비.

2. 별도 network namespace + veth pair로 네트워킹.

3. cgroup으로 100MB/50% CPU 제한.

4. unshare로 PID/mount/UTS/IPC namespace 격리.

5. pivot_root로 루트 전환.

6. 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로 코어 배정.

퀴즈로 복습하기

**A.** VM은 **하이퍼바이저 위에서 별도의 OS 커널을 실행**한다. 각 VM이 자기 커널을 가지므로 격리가 강하지만 오버헤드가 크다. 반면 컨테이너는 **호스트 커널을 그대로 공유**하고, Linux 커널의 기능(namespaces, cgroups)만으로 프로세스 간 시야를 분리한다. 따라서:

(1) 시작 시간이 수백 ms로 빠르다 (커널 부팅 불필요).

(2) 메모리 오버헤드가 거의 없다 (커널 공유).

(3) CPU 오버헤드가 0에 가깝다.

(4) 그러나 **격리 수준이 약하다** — 커널 취약점 하나로 호스트 전체가 뚫릴 수 있다.

VM이 격리의 강함 대 성능을 택했다면, 컨테이너는 그 반대다. 그래서 AWS Firecracker, Kata Containers 같은 "경량 VM" 기술이 둘의 중간을 목표로 한다.

**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를 기본으로 한다.

**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의 "가벼움"의 핵심이다.

**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 작업(마운트, 커널 모듈 로드, 임의 파일 접근 등)은 불가능하다.

이는 **최소 권한 원칙**의 실천이며, 컨테이너 탈출 취약점의 피해를 크게 줄인다. 프로덕션 컨테이너는 항상 이런 제한을 걸어야 한다.

**A.** Kubernetes의 CPU limit은 cgroup의 `cpu.max`로 변환된다. 예를 들어 `limits.cpu: 500m`은 "100ms(period) 중 50ms(quota)"를 의미한다. 문제는 이 쿼터가 **100ms 단위의 완강한 창**으로 동작한다는 점이다:

1. 애플리케이션이 100ms 동안 여러 코어에 걸쳐 총 50ms-CPU를 소모하면 **남은 50ms는 강제 sleep**.

2. 짧은 버스트가 끝나면 나머지 기간 동안 CPU를 못 쓴다.

3. 결과: **레이턴시 스파이크**. 평균 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 + 약간"이라는 통념은 의외로 적용하기 까다롭다.

마치며: 커널의 우아함

핵심 정리

1. **컨테이너 = Namespaces + cgroups + OverlayFS + runtime**.

2. **Namespaces**: 시야 격리 (PID, mount, net, UTS, IPC, user, cgroup).

3. **cgroups v2**: 자원 제한과 계정 (단일 통합 계층).

4. **OverlayFS**: copy-on-write로 이미지 레이어 공유.

5. **OCI runtime spec**: runc가 표준 구현.

6. **상위 런타임**: containerd, CRI-O가 관리 기능 제공.

7. **보안**: 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)](https://man7.org/linux/man-pages/man7/namespaces.7.html)

- [cgroups v2 documentation](https://www.kernel.org/doc/html/latest/admin-guide/cgroup-v2.html)

- [OverlayFS documentation](https://www.kernel.org/doc/html/latest/filesystems/overlayfs.html)

- [OCI Runtime Specification](https://github.com/opencontainers/runtime-spec)

- [runc source code](https://github.com/opencontainers/runc)

- [Linux Containers Wiki](https://linuxcontainers.org/)

- [Julia Evans: What even is a container?](https://jvns.ca/blog/2016/10/10/what-even-is-a-container/)

- [Liz Rice: Building a Container from Scratch](https://www.youtube.com/watch?v=8fi7uSYlOdc) - 명강연

- [Docker deep dive](https://docs.docker.com/engine/docker-overview/)

- [CRI-O: Kubernetes Container Runtime](https://cri-o.io/)

- [containerd documentation](https://containerd.io/docs/)

- [Rootless Containers](https://rootlesscontaine.rs/)

현재 단락 (1/461)

"컨테이너는 가벼운 VM이다"라는 말을 들어봤을 것이다. **틀렸다.** 컨테이너는 VM이 아니다. 컨테이너는 **Linux 커널의 기존 기능들을 영리하게 조합**한 프로세스 격리...

작성 글자: 0원문 글자: 18,581작성 단락: 0/461