가상화·컨테이너 — OS를 통째로 나누다
지금까지 OS가 하드웨어를 추상화해 여러 프로그램이 공유하게 하는 걸 봤다. 그런데 한 걸음 더 나아가 — 하나의 물리 머신에서 여러 개의 격리된 "컴퓨터"나 "환경"을 통째로 돌리고 싶을 때가 있다. 클라우드가 한 서버를 수십 고객에게 나눠 팔고, MSA가 서비스마다 독립된 환경을 원하는 게 그것이다. 이걸 푸는 두 방식 — **가상 머신(VM)**과 컨테이너 — 는 무엇을 어디까지 가상화하느냐가 다르고, 그 차이가 곧 무게와 격리의 트레이드오프다.
하드웨어를 통째로 — 가상 머신
**가상 머신(VM)**은 하드웨어 자체를 가상화한다. **하이퍼바이저**라는 소프트웨어 층이 — 가짜 CPU·메모리·디스크·네트워크 카드를 만들어, 그 위에 게스트 OS를 통째로 올린다. 그래서 한 물리 서버에 리눅스 VM과 윈도우 VM이 서로를 전혀 모른 채 돌 수 있다.
장점은 강한 격리다 — 각 VM이 자기 커널을 가지니, 한 VM이 커널째 망가져도 다른 VM은 멀쩡하다. 단점은 무게다 — OS를 통째로 올리니 메모리·디스크를 많이 먹고, 부팅도 느리다(07편의 ring -1에서 하이퍼바이저가 돈다).
커널을 공유한다 — 컨테이너
컨테이너는 발상을 바꾼다 — OS를 새로 올리는 대신, 호스트의 커널을 공유하고 프로세스 수준에서만 격리한다. "각 컨테이너는 자기만의 세상인 줄 알지만, 사실 같은 커널 위의 격리된 프로세스"인 것이다. 이 격리를 만드는 게 리눅스의 두 기능이다 — **namespace**는 보는 것을 격리한다(프로세스 목록·네트워크·파일 마운트·호스트명을 각자 따로 봐서, 컨테이너가 서로를 못 본다). cgroup은 쓰는 것을 제한한다(CPU·메모리·I/O를 컨테이너별로 상한과 할당을 둔다). (출처: namespaces & cgroups.)
커널을 새로 안 올리니 가볍고 빠르다 — 수 MB에 수십 ms 부팅이라, 한 서버에 수백 개도 띄운다. 대신 커널을 공유하니 — VM만큼 강한 격리는 아니다(커널 취약점은 공유된다).
왜 알아야 하나 — 오늘날 서비스를 컨테이너로 배포하는 게 바로 이것이다 — *프로세스 격리(02편)*를 namespace+cgroup으로 강화한 것이다. 특히 cgroup의 자원 제한이 — 한 작업이 CPU·메모리를 독식하지 못하게 격벽을 치는 발상이고, 컨테이너에 메모리 상한을 두면 그 안에서 도는 프로그램도 그 한도를 인식해야 한다(상한을 모르면 한도를 넘겨 강제 종료된다). 그리고 VM vs 컨테이너의 트레이드오프(강한 격리·무거움 vs 가벼움·약한 격리)는 — *"안전이냐 가벼움이냐"*라는, 시스템 설계에 늘 등장하는 선택의 또 다른 얼굴이다.
정리하면, 가상화는 한 물리 머신을 여러 격리된 환경으로 나눈다 — VM은 하이퍼바이저로 하드웨어를 가상화해 게스트 OS를 통째로 올려 강한 격리를 주지만 무겁고, 컨테이너는 *호스트 커널을 공유하고 namespace(뷰 격리)+cgroup(자원 제한)*으로 프로세스만 격리해 가볍지만 격리가 약하다. 이 cgroup 자원 격리가 컨테이너가 자원을 독식하지 않게 막는 토대다.
실제로 눈으로 보기
네임스페이스를 직접 만들어 본다
sudo unshare --pid --fork --mount-proc bash # 새 PID 네임스페이스로 들어간다
ps -ef
# UID PID PPID C STIME TTY TIME CMD
# root 1 0 0 03:12 pts/0 00:00 bash ← 여기서 내가 PID 1 이다
# root 9 1 0 03:12 pts/0 00:00 ps
호스트에서는 여전히 평범한 프로세스인데, 안에서는 자기가 1번이다. 컨테이너가 "격리된 것처럼 보이는" 실체가 이것이다 — 새 커널을 띄운 것이 아니라 보이는 범위를 좁힌 것이다.
lsns # 시스템의 네임스페이스 목록
# NS TYPE NPROCS PID USER COMMAND
# 4026531836 pid 241 1 root /sbin/init
# 4026532471 pid 2 8123 root bash ← 방금 만든 것
종류는 여섯이다 — PID(프로세스 번호), mount(파일시스템 뷰), network(인터페이스·포트), UTS(호스트명), IPC, user(UID 매핑).
자원 제한은 cgroup 이 한다
mkdir /sys/fs/cgroup/demo
echo "100000 100000" > /sys/fs/cgroup/demo/cpu.max # 1 코어
echo "268435456" > /sys/fs/cgroup/demo/memory.max # 256MB
echo $$ > /sys/fs/cgroup/demo/cgroup.procs # 이 셸을 넣는다
cat /sys/fs/cgroup/demo/memory.current # 현재 사용량
cat /sys/fs/cgroup/demo/memory.events
# oom_kill 3 ← 이 그룹 안에서 3번 죽었다
네임스페이스가 "무엇을 보나"를 정하고, cgroup 이 "얼마나 쓰나"를 정한다. 이 둘이 컨테이너의 전부라고 해도 지나치지 않다.
여기서 자주 겪는 함정이 하나 있다 — 프로그램이 /proc/meminfo나 nproc을 읽으면 호스트 전체 값이 나온다. cgroup 으로 256MB를 줬는데 프로그램은 호스트의 32GB를 보고 그에 맞춰 버퍼를 잡다가 OOM 으로 죽는다. 컨테이너 인식(cgroup 값을 읽는) 설정이 필요한 이유다.
컨테이너에 정말 커널이 없는가
uname -r # 호스트에서
# 6.8.0-45-generic
docker run --rm alpine uname -r # 컨테이너 안에서
# 6.8.0-45-generic ← 같다
커널이 같다는 것이 컨테이너의 정의이자 한계다. 그래서 가볍고 빠르게 뜨지만, 다른 커널이 필요한 것(다른 OS, 특정 커널 모듈)은 못 돌리고, 커널 취약점이 격리를 뚫으면 호스트까지 닿는다.
VM 은 반대다. 게스트마다 커널이 따로라 격리는 강하지만 부팅 시간과 메모리를 그만큼 낸다.
cat /proc/self/cgroup # 내가 어느 cgroup 에 있나
# 0::/system.slice/docker-a1b2c3.scope
컨테이너 안에서 이 파일을 읽으면 자기가 컨테이너인지 알 수 있다 — 격리가 완벽하지 않다는 증거이기도 하다.
이로써 운영체제 노트를 마친다. 추상화(프로세스·가상 메모리·파일·소켓)·관리(스케줄링·메모리)·보호(모드·격리)·동시성(락·데드락)·I/O·가상화 까지 — OS는 결국 한정된 하드웨어를 수많은 프로그램이 안전하게 공유하게 하는 거대한 관리자였다. 우리가 쓰는 언어·프레임워크·시스템이 왜 그렇게 동작하고 어디서 막히는가의 답은 — 거의 언제나 한 계층 아래, OS와 하드웨어에 있다.