컨테이너는 커널 기능의 조합이다

컨테이너라는 개념을 처음 접하면 마치 가벼운 가상 머신처럼 느껴진다. 그러나 컨테이너와 가상 머신은 근본적으로 다른 기술이다. 가상 머신은 하이퍼바이저가 하드웨어를 에뮬레이션하여 완전한 운영체제를 그 위에 실행하는 방식이다. 컨테이너는 하드웨어 에뮬레이션 없이, 호스트 커널의 기능만으로 프로세스를 격리하는 방식이다.

과연 프로세스 격리만으로 가상 머신과 비슷한 효과를 낼 수 있는가? 리눅스 커널이 제공하는 네임스페이스와 cgroup이라는 두 가지 메커니즘이 이를 가능하게 한다. 네임스페이스는 프로세스가 볼 수 있는 시스템 자원의 범위를 제한하고, cgroup은 프로세스가 사용할 수 있는 자원의 양을 제한한다. 이 두 가지를 결합하면 프로세스는 마치 독립된 시스템에서 실행되는 것처럼 동작하게 되는 것이다.

네임스페이스: 보이는 것을 제한하다

리눅스 네임스페이스는 커널 자원을 분할하여 한 그룹의 프로세스가 특정 자원 집합만을 볼 수 있게 한다. 현재 리눅스는 여덟 가지 종류의 네임스페이스를 제공한다.

네임스페이스격리 대상도입 커널
Mount (mnt)파일 시스템 마운트 포인트2.4.19
UTS호스트명과 도메인명2.6.19
IPCSystem V IPC, POSIX 메시지 큐2.6.19
PID프로세스 ID2.6.24
Network (net)네트워크 장치, 스택, 포트2.6.29
User사용자 및 그룹 ID3.8
Cgroupcgroup 루트 디렉토리4.6
Time부팅 시간, 모노토닉 시간5.6

PID 네임스페이스를 예로 들면, 컨테이너 내부의 첫 번째 프로세스는 PID 1을 가진다. 이 프로세스는 호스트에서는 완전히 다른 PID(예를 들어 4523)로 보인다. 컨테이너 내부에서 ps 명령을 실행하면 자신의 네임스페이스에 속한 프로세스만 표시되며, 호스트나 다른 컨테이너의 프로세스는 보이지 않는다. 프로세스의 실제 존재를 숨기는 것이 아니라, 해당 네임스페이스 안에서는 볼 수 있는 범위가 제한되는 것이다.

Mount 네임스페이스는 파일 시스템의 격리를 제공한다. 각 컨테이너는 자신만의 루트 파일 시스템을 가질 수 있으며, 호스트의 파일 시스템 구조와는 완전히 독립적으로 마운트를 구성할 수 있다. 이것이 Ubuntu 호스트 위에서 CentOS 컨테이너를 실행할 수 있는 이유이다. 커널은 호스트의 것을 공유하지만, 유저 공간의 파일 시스템은 완전히 다른 배포판의 것일 수 있는 것이다.

Network 네임스페이스는 이전 포스트에서 살펴보았듯이 네트워크 스택 전체를 격리한다. User 네임스페이스는 컨테이너 내부에서 root로 동작하면서도 호스트에서는 일반 사용자로 매핑되는 것을 가능하게 하여, 컨테이너 탈출(container escape) 시 피해를 최소화하는 보안 계층을 제공한다.

Cgroup: 사용할 수 있는 양을 제한하다

네임스페이스가 가시성을 제한한다면, cgroup(control group)은 사용량을 제한한다. cgroup은 프로세스 그룹의 CPU, 메모리, 디스크 I/O, 네트워크 대역폭 등의 사용량을 제어하고 모니터링하는 커널 기능이다.

cgroup이 없으면 하나의 컨테이너가 호스트의 모든 메모리를 소진하거나, CPU를 독점하여 다른 컨테이너에 영향을 줄 수 있다. cgroup은 이러한 자원 남용을 방지하며, 멀티 테넌트 환경에서 공정한 자원 분배를 보장하는 핵심 메커니즘인 것이다.

cgroup v1은 각 자원 컨트롤러(cpu, memory, blkio 등)가 독립적인 계층 구조를 가졌다. 이 설계는 유연하지만 복잡했다. 하나의 프로세스가 서로 다른 컨트롤러에서 서로 다른 그룹에 속할 수 있었고, 이로 인해 관리가 어려워지는 문제가 있었다.

cgroup v2는 이러한 복잡성을 해결하기 위해 단일 통합 계층 구조를 채택했다. 모든 컨트롤러가 하나의 트리를 공유하며, 프로세스는 단 하나의 cgroup에만 속한다. 또한 v2에서는 메모리 컨트롤러가 PSI(Pressure Stall Information)를 지원하여 자원 부족 상황을 더 정확하게 파악할 수 있다.

cgroup v2 계층 구조 예시:

/sys/fs/cgroup/
├── cgroup.controllers    (사용 가능한 컨트롤러)
├── cgroup.subtree_control (하위에 활성화할 컨트롤러)
├── container-a/
│   ├── memory.max        (메모리 상한: 512M)
│   ├── cpu.max           (CPU 할당: 50%)
│   └── cgroup.procs      (소속 프로세스 목록)
└── container-b/
    ├── memory.max        (메모리 상한: 1G)
    ├── cpu.max           (CPU 할당: 100%)
    └── cgroup.procs

unshare로 직접 만들어보는 최소 컨테이너

컨테이너가 네임스페이스와 cgroup의 조합이라는 사실은 unshare 명령으로 직접 확인할 수 있다. unshare는 지정한 네임스페이스를 새로 생성하고 그 안에서 명령을 실행하는 유틸리티이다.

# PID, Mount, UTS, Network 네임스페이스를 새로 생성하고 셸 실행
sudo unshare --pid --mount --uts --net --fork --mount-proc /bin/bash

# 컨테이너 안에서:
hostname my-container      # UTS 네임스페이스 덕분에 호스트명 변경 가능
ps aux                     # PID 1부터 시작하는 프로세스 목록
ip addr                    # 비어 있는 네트워크 인터페이스

이 상태에서는 호스트의 프로세스가 보이지 않고, 네트워크도 격리되어 있다. 여기에 chroot나 pivot_root로 루트 파일 시스템을 교체하고, cgroup으로 자원을 제한하면 Docker 컨테이너와 본질적으로 동일한 환경이 만들어진다. Docker가 하는 일은 이러한 커널 기능들을 편리한 인터페이스로 포장하고, 이미지 배포와 네트워킹을 자동화한 것에 가까운 셈이다.

오버레이 파일 시스템

컨테이너 이미지는 어떻게 효율적으로 저장되고 사용되는가? 핵심은 오버레이 파일 시스템(OverlayFS)이다. OverlayFS는 여러 디렉토리를 계층적으로 겹쳐 하나의 통합된 뷰를 제공한다.

컨테이너 이미지는 읽기 전용 레이어의 스택으로 구성된다. 기본 OS 레이어 위에 패키지 설치 레이어, 애플리케이션 코드 레이어가 겹쳐진다. 컨테이너가 실행되면 이 읽기 전용 스택 위에 쓰기 가능한 레이어가 하나 추가된다. 파일을 읽을 때는 위에서 아래로 탐색하여 첫 번째로 발견된 파일을 반환한다. 파일을 수정하면 원본은 그대로 두고 쓰기 레이어에 복사본을 만들어 수정한다(copy-on-write).

┌─────────────────────────┐
│   쓰기 레이어 (컨테이너)   │  ← 변경사항만 저장
├─────────────────────────┤
│   레이어 3: 앱 코드       │  ← 읽기 전용
├─────────────────────────┤
│   레이어 2: 패키지 설치   │  ← 읽기 전용
├─────────────────────────┤
│   레이어 1: 기본 OS      │  ← 읽기 전용
└─────────────────────────┘

이 설계의 장점은 같은 이미지를 사용하는 여러 컨테이너가 읽기 전용 레이어를 공유할 수 있다는 것이다. 100개의 컨테이너가 같은 Ubuntu 이미지를 사용하더라도 기본 레이어는 디스크에 한 번만 저장되며, 각 컨테이너는 자신만의 쓰기 레이어만 별도로 갖는다. 이전 포스트에서 다룬 가상 메모리의 copy-on-write와 동일한 원리가 파일 시스템 수준에서 적용된 것이다.

Seccomp과 Capability

네임스페이스와 cgroup이 격리와 자원 제한을 제공하지만, 보안 측면에서는 추가적인 방어 계층이 필요하다. 컨테이너 내부의 프로세스는 여전히 호스트의 커널을 공유하므로, 시스템 콜을 통해 커널에 직접 접근할 수 있기 때문이다.

Seccomp(Secure Computing Mode)은 프로세스가 사용할 수 있는 시스템 콜을 제한한다. Docker는 기본적으로 약 40개의 위험한 시스템 콜을 차단하는 seccomp 프로파일을 적용한다. 예를 들어 reboot(), mount(), kexec_load() 같은 시스템 콜은 컨테이너 내부에서 호출할 수 없다. 컨테이너가 필요로 하는 시스템 콜만 허용하는 화이트리스트 방식을 적용하면 커널 취약점이 발견되더라도 공격 표면을 크게 줄일 수 있는 것이다.

Linux Capability는 전통적인 root 권한을 세분화한 것이다. 과거에는 root이냐 아니냐의 이분법이었지만, Capability를 통해 root 권한을 30개 이상의 개별 권한으로 분리할 수 있다. 예를 들어 CAP_NET_BIND_SERVICE만 부여하면 1024 미만의 포트에 바인딩할 수 있지만 다른 root 권한은 없다. Docker는 컨테이너에 기본적으로 제한된 Capability 집합만 부여하여, root로 실행되더라도 실제 호스트의 root와는 매우 다른 수준의 권한을 갖게 하는 것이다.

컨테이너 vs 가상 머신

컨테이너와 가상 머신의 차이를 이해하면 각각의 적합한 사용 사례가 명확해진다.

가상 머신:                          컨테이너:

┌─────────┐ ┌─────────┐          ┌─────────┐ ┌─────────┐
│  App A  │ │  App B  │          │  App A  │ │  App B  │
├─────────┤ ├─────────┤          ├─────────┤ ├─────────┤
│Guest OS │ │Guest OS │          │  Bins/  │ │  Bins/  │
│ (전체)  │ │ (전체)  │          │  Libs   │ │  Libs   │
├─────────┴─┴─────────┤          ├─────────┴─┴─────────┤
│     하이퍼바이저      │          │    호스트 OS 커널     │
├─────────────────────┤          ├─────────────────────┤
│       하드웨어       │          │       하드웨어       │
└─────────────────────┘          └─────────────────────┘

가상 머신은 하이퍼바이저가 하드웨어를 에뮬레이션하고 그 위에 완전한 게스트 OS를 실행한다. 격리 수준이 매우 높아 서로 다른 운영체제를 실행할 수 있지만, 게스트 OS의 부팅 시간과 자원 소비가 오버헤드로 작용한다. 컨테이너는 호스트 커널을 공유하므로 수 밀리초 만에 시작할 수 있고 메모리 소비도 적지만, 호스트와 동일한 커널을 사용해야 하며 격리 수준이 가상 머신에 비해 낮다.

과연 컨테이너가 가상 머신을 대체할 수 있는가? 완전한 대체는 아니다. 다른 커널이 필요한 워크로드(예를 들어 Linux 호스트에서 Windows 실행)나 강력한 보안 격리가 필수적인 환경에서는 가상 머신이 여전히 필요하다. 그러나 같은 커널 위에서 애플리케이션 수준의 격리가 필요한 대부분의 경우, 컨테이너가 훨씬 효율적인 선택인 것이다. 최근에는 Kata Containers처럼 경량 가상 머신 안에서 컨테이너를 실행하여 양쪽의 장점을 결합하려는 시도도 있다.

OCI 런타임 명세

초기의 컨테이너 생태계는 Docker가 사실상 표준이었다. 그러나 컨테이너 기술이 성숙하면서 표준화의 필요성이 대두되었고, OCI(Open Container Initiative)가 탄생했다.

OCI는 두 가지 핵심 명세를 정의한다. 런타임 명세(Runtime Specification)는 컨테이너의 실행 환경을 정의한다. 루트 파일 시스템의 경로, 적용할 네임스페이스의 종류, cgroup 설정, seccomp 프로파일 등이 JSON 형식으로 기술된다. 이미지 명세(Image Specification)는 컨테이너 이미지의 형식을 정의하여 서로 다른 런타임 간의 호환성을 보장한다.

runc는 OCI 런타임 명세의 레퍼런스 구현이다. Docker도 내부적으로 runc를 사용하여 컨테이너를 생성하고 실행한다. 이 표준화 덕분에 Docker, Podman, containerd 등 다양한 컨테이너 런타임이 동일한 이미지를 사용하고 호환되는 컨테이너를 실행할 수 있는 것이다.

시리즈를 마치며: 커널 개념의 연결

이 시리즈는 운영체제 개요에서 시작하여 컨테이너에 이르기까지, 리눅스 커널의 핵심 개념들을 살펴보았다. 돌이켜 보면 이 개념들은 독립적으로 존재하는 것이 아니라 서로 긴밀하게 연결되어 있다.

프로세스 관리에서 배운 fork()clone()은 컨테이너의 네임스페이스 생성에 그대로 사용된다. clone() 시스템 콜에 CLONE_NEWPID, CLONE_NEWNET 같은 플래그를 전달하면 새로운 네임스페이스에서 프로세스가 생성되는 것이다. 스케줄러의 CFS는 cgroup의 CPU 컨트롤러와 통합되어 컨테이너 간의 CPU 분배를 관리한다.

가상 메모리에서 배운 copy-on-write는 오버레이 파일 시스템의 동작 원리와 동일하다. 페이지 수준에서의 최적화가 파일 시스템 수준에서도 적용된 셈이다. 메모리 관리의 OOM Killer는 cgroup 메모리 컨트롤러와 연동하여, 컨테이너가 메모리 한도를 초과하면 호스트가 아닌 해당 컨테이너 내부의 프로세스를 종료한다.

파일 시스템에서 배운 VFS 추상화 계층은 OverlayFS가 동작하는 기반이다. VFS가 제공하는 통일된 인터페이스 위에 다양한 파일 시스템을 겹쳐 올리는 것이 가능한 것이다. 시스템 콜에서 배운 seccomp은 컨테이너의 시스템 콜 필터링에 직접 사용된다.

네트워킹에서 배운 네트워크 네임스페이스, veth, 브릿지는 컨테이너 네트워킹의 구성 요소 그 자체이다. 동기화에서 배운 RCU와 락 프리 자료 구조는 이 모든 서브시스템이 멀티코어 환경에서 높은 성능을 유지할 수 있게 하는 기반이다.

결국 컨테이너는 새로운 기술이 아니라, 수십 년간 발전해 온 리눅스 커널 기능들의 정교한 조합인 것이다. 커널의 각 서브시스템을 이해하면 컨테이너가 어떻게 동작하는지 자연스럽게 이해할 수 있으며, 컨테이너의 한계가 어디에 있는지도 명확해진다. 이 시리즈가 리눅스 커널의 내부 동작을 이해하는 출발점이 되었기를 바란다.