cgroups와 namespace — 컨테이너 격리가 실제로 작동하는 방식
"컨테이너는 가상 머신이 아니다"라는 말을 들어보셨을 겁니다. 그렇다면 컨테이너는 정확히 어떻게 격리되는 걸까요? 커널을 공유하는데 어떻게 서로 영향을 주지 않을 수 있을까요?
컨테이너의 두 가지 핵심 기술
컨테이너는 두 가지 Linux 커널 기능의 조합입니다.
- Namespace: 프로세스가 볼 수 있는 자원의 범위를 격리
- cgroups: 프로세스가 사용할 수 있는 자원의 양을 제한
┌── 가상 머신 ────────────┐ ┌── 컨테이너 ──────────────┐
│ ┌── Guest OS ────────┐ │ │ ┌── 애플리케이션 ──────┐ │
│ │ ┌── App ────────┐ │ │ │ │ │ │
│ │ └───────────────┘ │ │ │ └──────────────────────┘ │
│ └────────────────────┘ │ │ namespace + cgroups │
│ 하이퍼바이저 │ │ 호스트 커널 공유 │
│ 호스트 OS │ │ 호스트 OS │
└─────────────────────────┘ └──────────────────────────┘
Linux Namespace
6가지(+1) 네임스페이스
| 네임스페이스 | 격리하는 것 | 플래그 |
|---|---|---|
| PID | 프로세스 ID | CLONE_NEWPID |
| Network | 네트워크 인터페이스, IP, 라우팅 | CLONE_NEWNET |
| Mount | 파일 시스템 마운트 포인트 | CLONE_NEWNS |
| UTS | 호스트명, 도메인명 | CLONE_NEWUTS |
| IPC | System V IPC, POSIX 메시지 큐 | CLONE_NEWIPC |
| User | UID/GID 매핑 | CLONE_NEWUSER |
| Cgroup | cgroup 루트 디렉토리 뷰 | CLONE_NEWCGROUP |
PID Namespace
컨테이너 내부에 독립적인 프로세스 공간을 제공합니다.
# 호스트에서 보이는 프로세스
ps aux | grep nginx
# root 12345 nginx: master process
# nginx 12346 nginx: worker process
# 컨테이너 내부에서 보이는 프로세스
docker exec mynginx ps aux
# PID USER COMMAND
# 1 root nginx: master process ← PID 1
# 7 nginx nginx: worker process
컨테이너의 메인 프로세스는 PID 1이 됩니다. 이것이 ENTRYPOINT로 지정한 프로세스이며, 이 프로세스가 종료되면 컨테이너도 종료됩니다.
# 컨테이너의 네임스페이스 확인
ls -la /proc/$(docker inspect --format '{{.State.Pid}}' mynginx)/ns/
# pid -> pid:[4026532xxx]
# net -> net:[4026532yyy]
# mnt -> mnt:[4026532zzz]
# ...
Network Namespace
각 컨테이너는 독립적인 네트워크 스택을 가집니다.
# 컨테이너의 네트워크 인터페이스
docker exec myapp ip addr
# 1: lo: <LOOPBACK,UP> inet 127.0.0.1/8
# 42: eth0@if43: <BROADCAST,UP> inet 172.17.0.2/16
# 호스트의 네트워크 인터페이스 (완전히 다름)
ip addr
# 1: lo: <LOOPBACK,UP> inet 127.0.0.1/8
# 2: eth0: <BROADCAST,UP> inet 192.168.1.10/24
# 3: docker0: <BROADCAST,UP> inet 172.17.0.1/16
# 43: vethXXX@if42: <BROADCAST,UP>
각 컨테이너는 자신만의 IP, 라우팅 테이블, iptables 규칙, 포트 공간을 가집니다. 따라서 여러 컨테이너가 같은 포트(예: 80)를 사용해도 충돌이 없습니다.
Mount Namespace
컨테이너는 독립적인 파일 시스템 뷰를 가집니다.
# 컨테이너 내부의 파일 시스템 (OverlayFS)
docker exec myapp mount | head -5
# overlay on / type overlay (rw,relatime,lowerdir=...,upperdir=...,workdir=...)
# proc on /proc type proc (rw,nosuid,nodev,noexec)
# tmpfs on /dev type tmpfs (rw,nosuid)
호스트의 파일 시스템과 완전히 분리되어 있고, 볼륨으로 명시적으로 마운트한 경로만 공유됩니다.
UTS Namespace
컨테이너가 독립적인 호스트명을 가집니다.
# 컨테이너의 호스트명
docker exec myapp hostname
# a1b2c3d4e5f6 (컨테이너 ID)
# 호스트의 호스트명
hostname
# my-server
User Namespace
컨테이너 내부의 UID/GID를 호스트의 다른 UID/GID에 매핑합니다.
컨테이너 내부: root (UID 0)
↕ 매핑
호스트: nobody (UID 65534) 또는 다른 일반 사용자
# User namespace 매핑 확인 (rootless Docker)
cat /proc/$(docker inspect --format '{{.State.Pid}}' myapp)/uid_map
# 0 100000 65536
# 컨테이너의 UID 0 → 호스트의 UID 100000
User namespace를 사용하면 컨테이너 탈출이 발생해도 호스트에서는 일반 사용자 권한만 가지게 됩니다.
cgroups (Control Groups)
cgroups는 프로세스 그룹의 리소스 사용량을 제한, 계측, 격리합니다.
제어 가능한 리소스
| 서브시스템 | 제어하는 것 |
|---|---|
| cpu | CPU 시간 할당 |
| cpuset | 특정 CPU 코어 할당 |
| memory | 메모리 사용량 제한 |
| blkio | 블록 I/O 대역폭 |
| devices | 디바이스 접근 제어 |
| pids | 프로세스 수 제한 |
| net_cls | 네트워크 트래픽 분류 |
Docker에서의 cgroups 적용
# 메모리 제한
docker run --memory 512m myapp
# CPU 제한
docker run --cpus 1.5 myapp
# 현재 cgroup 설정 확인
docker inspect myapp --format '{{.HostConfig.Memory}}'
# 536870912 (512MB in bytes)
# 리소스 사용량 모니터링
docker stats myapp
# CONTAINER CPU % MEM USAGE / LIMIT MEM %
# myapp 0.50% 128MiB / 512MiB 25.00%
cgroups 파일 시스템
# cgroups v1: 각 서브시스템이 별도 계층
ls /sys/fs/cgroup/
# blkio/ cpu/ cpuset/ devices/ memory/ pids/ ...
# 특정 컨테이너의 메모리 제한 확인
cat /sys/fs/cgroup/memory/docker/<container-id>/memory.limit_in_bytes
# cgroups v2: 통합된 계층 구조
ls /sys/fs/cgroup/system.slice/docker-<container-id>.scope/
# memory.max cpu.max pids.max ...
cgroups v1 vs v2
| 특성 | v1 | v2 |
|---|---|---|
| 계층 구조 | 서브시스템별 별도 트리 | 단일 통합 트리 |
| 관리 복잡성 | 높음 | 낮음 |
| PSI (Pressure Stall Info) | X | O |
| Rootless 컨테이너 | 제한적 | 완전 지원 |
| 메모리+스왑 통합 제어 | X | O |
| 커널 요구 | 2.6.24+ | 4.5+ (5.2+ 권장) |
# 현재 cgroups 버전 확인
stat -fc %T /sys/fs/cgroup/
# cgroup2fs → v2
# tmpfs → v1
# 또는
mount | grep cgroup
메모리 cgroup 동작
docker run --memory 256m --memory-swap 256m myapp
┌── 메모리 cgroup ──────────────────────┐
│ memory.max = 256MB │
│ memory.swap.max = 0MB (스왑 없음) │
│ │
│ 사용량이 256MB 초과 시: │
│ 1. 커널이 reclaim 시도 │
│ 2. reclaim 실패 시 OOM Killer 작동 │
│ 3. 컨테이너 프로세스 종료 (OOMKilled) │
└───────────────────────────────────────┘
# OOMKilled 확인
docker inspect myapp --format '{{.State.OOMKilled}}'
# true
네임스페이스와 cgroups의 조합
Docker가 컨테이너를 생성할 때 내부적으로 일어나는 일을 단순화하면 다음과 같습니다.
1. 새 네임스페이스 생성 (PID, Net, Mount, UTS, IPC, User)
2. cgroup 생성 및 리소스 제한 설정
3. 루트 파일 시스템 구성 (OverlayFS)
4. 네트워크 인터페이스 연결 (veth pair)
5. 프로세스 실행 (ENTRYPOINT/CMD)
이 과정을 직접 수행하는 도구가 runc이고, runc가 OCI 표준에 따라 컨테이너를 실행합니다.
unshare로 직접 네임스페이스 생성
# PID + Mount 네임스페이스를 만들어 새 셸 실행 (개념 이해용)
sudo unshare --pid --mount --fork /bin/bash
# 새 네임스페이스 안에서
mount -t proc proc /proc
ps aux
# PID COMMAND
# 1 /bin/bash ← PID 1!
이것이 컨테이너의 핵심입니다. 새로운 네임스페이스를 만들고 그 안에서 프로세스를 실행하는 것입니다.
실제 격리 수준 확인
# 컨테이너의 네임스페이스 ID 확인
docker inspect --format '{{.State.Pid}}' myapp
# 12345
ls -la /proc/12345/ns/
# ipc -> ipc:[4026532xxx]
# mnt -> mnt:[4026532yyy]
# net -> net:[4026532zzz]
# pid -> pid:[4026532aaa]
# user -> user:[4026531837] ← 호스트와 같으면 User NS 미사용
# uts -> uts:[4026532bbb]
# 호스트의 네임스페이스 ID와 비교
ls -la /proc/1/ns/
# 다른 숫자면 격리됨, 같으면 공유
정리
- Namespace는 프로세스가 볼 수 있는 자원의 범위를 격리합니다. PID, Network, Mount, UTS, IPC, User, Cgroup 7가지가 있습니다.
- cgroups는 프로세스 그룹의 CPU, 메모리, I/O 등 리소스 사용량을 제한하고 계측합니다.
- 컨테이너는 이 두 기술의 조합일 뿐, 가상 머신처럼 별도의 커널을 가지지 않습니다.
- cgroups v2는 통합된 계층 구조, PSI 지원, rootless 컨테이너 등에서 v1보다 개선되었습니다.
- User namespace를 활용하면 컨테이너 내부의 root가 호스트에서는 일반 사용자로 매핑되어 보안이 크게 강화됩니다.
- 이 기본 원리를 이해하면 Docker뿐만 아니라 Podman, containerd, Kubernetes까지 컨테이너 기술 전반을 이해하는 기반이 됩니다.