컨테이너는 어떻게 격리되면서도 빠른가
VM과의 근본적 차이부터 Namespace·Cgroups·OverlayFS의 내부 동작까지, Docker가 프로세스 격리를 통해 성능을 지키는 방식을 추적한다.
- 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로 — 무엇이 달라지는가
“컨테이너는 가벼운 VM이다”라는 말을 자주 듣는다. 하지만 이 비유는 틀렸다. VM은 하드웨어를 가상화하고, 컨테이너는 프로세스를 격리한다. 이 차이가 왜 중요한가? 그리고 Docker는 수십 개의 컴포넌트를 어떻게 조합해 격리와 성능을 동시에 달성하는가?
VM vs 컨테이너 — 추상화 수준이 다르다
VM은 하이퍼바이저 위에서 완전한 게스트 OS를 실행한다. CPU, 메모리, 디스크, 네트워크 전부를 가상화한다. 덕분에 격리는 강력하지만 대가가 있다 — 게스트 OS 자체가 수백 MB에서 수 GB를 차지하고, 부팅에 수십 초가 걸린다.
컨테이너는 다르다. 호스트 커널을 공유하고, OS 레이어 없이 애플리케이션 프로세스만 격리한다. 50개의 마이크로서비스를 VM으로 배포하면 50개의 OS가 필요하다. 컨테이너로 배포하면 OS는 하나, 애플리케이션만 50개다.
# VM: 웹 서버 하나를 위한 비용
메모리: 512MB(OS) + 50MB(앱) = 562MB
시작 시간: ~30초
# 컨테이너: 같은 웹 서버
메모리: ~50MB(앱만)
시작 시간: ~0.5초
컨테이너 내부에서 ps aux를 실행하면 자기 프로세스만 보인다. uname -r을 실행하면 호스트와 동일한 커널 버전이 출력된다. 격리된 것처럼 보이지만, 실제로는 호스트의 프로세스다.
Docker의 컴포넌트 계층 — 단일 프로그램이 아니다
많은 개발자가 docker run을 실행할 때 무슨 일이 일어나는지 모른다. Docker는 단일 프로그램이 아니라 독립적인 컴포넌트의 연쇄다.
Docker CLI
↓ REST API (Unix Socket)
dockerd (Docker Daemon)
↓ gRPC
containerd (컨테이너 런타임 관리자)
↓ exec
runc (OCI 런타임 구현체)
↓
Linux Kernel (Namespace, Cgroups)
docker run nginx를 입력하면: CLI가 REST 요청을 만들어 dockerd에 전달하고, dockerd가 이미지를 준비해 containerd에 위임하고, containerd가 runc를 호출해 실제 컨테이너를 생성한다. runc는 Namespace를 생성하고 Cgroup을 설정하고 nginx 프로세스를 실행한 뒤 종료된다. nginx만 남는다.
이 계층화는 유연성을 낳는다. Kubernetes는 dockerd를 거치지 않고 containerd와 직접 통신한다. Podman은 데몬 없이 runc를 직접 호출한다.
Namespace — 무엇이 보이는가를 결정한다
컨테이너의 격리는 Linux Namespace가 만든다. Namespace는 프로세스에게 “독립적인 시스템 뷰”를 제공하는 커널 메커니즘이다. Docker는 7가지 Namespace를 조합한다.
| Namespace | 격리 대상 |
|---|---|
| PID | 프로세스 ID |
| NET | 네트워크 스택, 인터페이스 |
| MNT | 파일시스템 마운트 |
| UTS | 호스트명, 도메인명 |
| IPC | 공유 메모리, 세마포어 |
| USER | UID/GID 매핑 |
| CGROUP | Cgroup 뷰 |
PID Namespace의 결과: 컨테이너 내부에서 nginx는 PID 1이다. 호스트에서는 PID 12345다. 같은 프로세스를 다른 관점으로 보는 것이다. 컨테이너에서 호스트의 프로세스는 보이지 않는다.
Network Namespace의 결과: 각 컨테이너는 독립적인 네트워크 스택을 갖는다. 컨테이너 내부의 eth0은 호스트의 veth 인터페이스와 가상 케이블(veth pair)로 연결된다. 컨테이너가 80번 포트를 열어도 호스트의 80번 포트와 충돌하지 않는다.
--pid=host나 --net=host로 Namespace를 공유하면 격리가 깨진다. --pid=host로 실행된 컨테이너에서는 호스트의 PID 1을 종료하는 것도 이론적으로 가능하다. 필요 최소한의 Namespace만 공유해야 한다.
Cgroups — 얼마나 쓸 수 있는가를 결정한다
Namespace가 “무엇이 보이는가”를 제어한다면, Cgroups(Control Groups)는 “얼마나 사용할 수 있는가”를 제어한다. 제한이 없으면 컨테이너 하나가 CPU와 메모리를 독점해 다른 컨테이너와 호스트 전체를 마비시킬 수 있다.
CPU 제한에는 두 가지 방식이 있다. --cpu-shares는 상대적 우선순위다 — CPU 여유가 있으면 모두 쓸 수 있고, 경합 시에만 비율대로 나눈다. --cpus=0.5는 절대적 제한이다 — 아무리 CPU가 남아도 50% 이상 쓰지 못한다.
메모리 제한(--memory=512m)을 초과하면 OOM Killer가 발동한다. 컨테이너 내에서 가장 높은 OOM 스코어를 가진 프로세스가 SIGKILL을 받는다. --oom-kill-disable은 이 동작을 막지만, 그러면 호스트 전체가 메모리 부족에 빠질 수 있다 — 사용하지 마라.
# 0.5 CPU, 256MB 메모리 제한
docker run --cpus=0.5 --memory=256m nginx
# Cgroup 파일로 직접 확인
cat /sys/fs/cgroup/cpu/docker/<id>/cpu.cfs_quota_us
# 50000 (50ms / 100ms 주기 = 50%)
cat /sys/fs/cgroup/memory/docker/<id>/memory.limit_in_bytes
# 268435456 (256MB)
OverlayFS — 레이어를 하나로 합친다
Docker 이미지는 읽기 전용 레이어의 스택이다. FROM ubuntu가 레이어 1이고, RUN apt-get install nginx가 레이어 2다. 컨테이너를 실행하면 맨 위에 쓰기 가능한 레이어가 하나 추가된다.
이 레이어들을 하나의 파일시스템으로 통합하는 것이 OverlayFS다. 읽기는 위에서 아래로 레이어를 검색해 첫 번째 발견된 파일을 반환한다. 쓰기는 Copy-on-Write(CoW) 방식이다 — 하위 레이어의 파일을 수정하면 상위 레이어로 전체 파일을 복사한 뒤 수정한다. 원본 레이어는 불변이다.
파일 삭제도 실제 삭제가 아니다. 상위 레이어에 .wh.<filename> 형태의 whiteout 파일을 만들어 “이 파일은 없는 것으로 처리하라”고 표시한다.
컨테이너 내부에서 큰 파일을 수정하면 그 파일 전체가 상위 레이어로 복사된다. 100MB 파일의 1바이트를 바꿔도 100MB 복사가 발생한다. 데이터베이스 파일처럼 자주 수정되는 큰 파일은 반드시 Volume으로 마운트해야 한다.
캐시 전략도 여기서 나온다. Dockerfile에서 자주 변경되는 명령(소스 코드 복사)을 뒤로, 잘 변경되지 않는 명령(의존성 설치)을 앞으로 배치하면 중간 레이어가 캐시되어 빌드 시간이 수 분에서 수 초로 줄어든다.
트레이드오프
컨테이너가 VM보다 항상 나은 것은 아니다.
컨테이너가 유리한 경우: 같은 OS 커널을 공유해도 되는 워크로드, 빠른 스케일링이 필요한 마이크로서비스, CI/CD 파이프라인, 클라우드 네이티브 애플리케이션.
VM이 필요한 경우: 서로 다른 OS 커널이 필요한 워크로드, 멀티테넌트 환경에서 완전한 하드웨어 수준의 격리가 필요한 경우, 커널 모듈 로드가 필요한 레거시 앱.
컨테이너의 보안도 맥락이 있다. Namespace와 Cgroups만으로는 VM 수준의 격리에 미치지 못한다. User Namespace(userns-remap)를 활성화하면 컨테이너 내부의 root가 호스트에서는 일반 사용자로 매핑되어 탈출 시 피해를 줄인다. Seccomp 프로파일과 AppArmor/SELinux를 함께 쓰면 격리 수준을 높일 수 있다.
정리
- 컨테이너는 VM이 아니라 격리된 프로세스다 — 호스트 커널을 공유하고, 그래서 빠르다.
- Docker는 단일 프로그램이 아니라 dockerd → containerd → runc → 커널의 계층이다.
- Namespace가 “무엇이 보이는가”를, Cgroups가 “얼마나 쓸 수 있는가”를, OverlayFS가 “파일시스템을 어떻게 합치는가”를 각각 담당한다.
- CoW는 효율적이지만 큰 파일 수정에 숨겨진 비용이 있다 — 데이터는 Volume으로 분리하라.
다음 글에서는 Docker 네트워킹의 내부를 추적한다 — bridge, overlay, host 모드가 각각 어떤 Namespace와 iptables 규칙으로 구현되는지, 그리고 컨테이너 간 통신이 어떤 경로로 흐르는지.