cgroups와 Namespace로 보는 컨테이너의 실제 경계
컨테이너는 Host 커널을 공유하면서 Namespace로 보이는 세계를 나누고, cgroups로 CPU와 메모리 같은 자원 한계를 강제한다. Docker, ECS, K8s의 limits와 OOM Kill, CPU throttle이 커널 수준에서 어떻게 이어지는지 정리한다.
Script Companion
오디오와 함께 스크립트 보기
- 01
컨테이너를 이해할 때 첫 단서는 격리와 제한을 나눠 보는 것이다. 프론트엔드의 iframe sandbox가 부모 DOM 접근을 막고, 별도 스토리지 컨텍스트를 주는 것처럼, Linux 컨테이너도 커널 기능으로 각 프로세스가 보는 세계를 다르게 만든다. PID Namespace는 다른 프로세스를 안 보이게 하고, Network Namespace는 독립 IP 스택을 만들며, Mount Namespace는 독립 파일시스템 뷰를 제공한다. 여기에 cgroups가 CPU와 메모리 사용량을 제한한다.
- 02
Namespace는 같은 Host에서 실행되는 프로세스들이 서로 다른 시스템 자원 뷰를 보게 만드는 Linux 커널 기능이다. 조작에는 clone, unshare, setns 시스템 콜이 쓰인다. Linux 6.x 기준으로 Namespace는 일곱 종류가 있다. PID는 프로세스 ID 번호 공간, NET은 네트워크 스택, MNT는 마운트 뷰, UTS는 호스트명과 도메인명, IPC는 세마포어와 공유메모리 같은 System V IPC, USER는 사용자와 그룹 ID 매핑, CGROUP은 cgroup 루트 디렉토리 뷰를 격리한다.
- 03
cgroups, 즉 Control Groups는 격리된 세계 안에서 자원을 얼마나 쓸 수 있는지 제한하고, 모니터링하고, 집계하는 커널 기능이다. cgroups v2는 Linux 4.15 이후 안정화되고 5.x부터 기본값으로 쓰이며, 모든 컨트롤러를 단일 계층 구조인 /sys/fs/cgroup 아래에 통합했다. cpu 컨트롤러는 cpu.weight와 cpu.max로 가중치와 쿼터를 다루고, memory 컨트롤러는 memory.high와 memory.max로 소프트 제한과 하드 제한을 다룬다. io 컨트롤러는 io.max와 io.weight로 블록 I/O 대역폭과 IOPS를 제어한다.
- 04
CPU 제한은 cpu.max의 쿼터와 주기로 표현된다. 예를 들어 50000 100000은 100ms마다 50ms만 CPU를 쓸 수 있다는 뜻이므로, 실질적으로 CPU 50% 제한이다. 메모리에서는 memory.high와 memory.max의 선택이 중요하다. memory.high는 초과를 일시 허용하면서 스로틀링과 메모리 회수를 유도하는 소프트 제한이고, memory.max는 초과를 허용하지 않아 OOM Kill로 강제 종료되는 하드 제한이다. 그래서 운영에서는 memory.high를 조기 경보로 보고, memory.max를 절대 한계로 본다.
- 05
memory.high를 어떻게 잡느냐도 실패 양상을 가른다. 실무 패턴으로 memory.high를 memory.max의 0.9, 즉 90%로 두면 한계 도달 전에 스로틀링으로 압력을 감지할 수 있다. K8s v1.36 Memory QoS는 memoryThrottlingFactor 기본값을 0.9로 고정하고, kubelet이 memory.high를 limits.memory 곱하기 0.9로 설정한다. 반대로 memory.high만 있고 memory.max가 없으면 메모리 누수 시 OOM 없이 회수가 반복되어 노드 전체 메모리 압박으로 이어질 수 있다. Linux 커널 5.9 미만, 예를 들어 AL2의 5.4 커널에서는 memory.high 사용을 피해야 한다.
- 06
cgroups v1에서 v2로 넘어갈 때의 핵심 변화는 계층 구조다. v1은 cpu와 memory처럼 컨트롤러마다 독립 계층을 가졌고, 하나의 프로세스가 컨트롤러별로 다른 경로에 속할 수 있었다. v2는 단일 통합 트리라서 프로세스가 항상 하나의 cgroup에만 속한다. 파일명도 바뀐다. v1의 memory.limit_in_bytes는 v2의 memory.max가 되고, cpu.cfs_quota_us와 cpu.cfs_period_us는 cpu.max 하나에 quota period 형식으로 통합된다. K8s에서는 v2가 1.25에서 GA가 되었고, v1은 1.31부터 maintenance mode다.
- 07
컨테이너는 별도 OS나 별도 커널이 아니다. Host 커널을 공유하되, Namespace로 격리된 뷰를 제공하고, cgroups로 자원 사용량을 제한한 프로세스 그룹이다. VM은 게스트 OS 전용 커널을 갖고 하이퍼바이저 기반 격리를 사용하므로 시작 시간이 수십 초이고 오버헤드가 높다. 컨테이너는 프로세스 시작에 가까워 수백 ms 수준으로 시작할 수 있지만, Host 커널을 공유하므로 커널 취약점 공유라는 경계 조건도 갖는다. 격리 강도만 보면 VM이 더 강하고, 컨테이너는 더 가볍다.
- 08
Docker와 K8s의 설정은 결국 cgroups v2 파일 값으로 변환된다. Docker에서 메모리 512m과 CPU 0.5를 설정하면 커널의 memory.max와 cpu.max에 해당 제한이 반영된다. K8s에서는 containerd와 runc 경로를 거쳐 limits.memory 512Mi가 memory.max 536870912로, requests.memory 256Mi가 memory.low 268435456으로, limits.cpu 500m이 cpu.max 50000 100000으로 변환된다. requests.cpu 250m은 cpu.weight 10처럼 비례 가중치로 반영된다. 설정 문법은 달라도 강제 지점은 커널이다.
- 09
ECS와 AWS 관점에서도 같은 원리가 이어진다. ECS 태스크 정의의 memory 512와 cpu 256은 cgroups v2의 memory.max와 cpu.max로 변환되어 강제된다. NestJS 앱이 메모리 누수로 512MB를 넘으면 커널 OOM Killer가 컨테이너 프로세스를 종료하고, ECS는 태스크를 재시작한다. 컨테이너 안으로 셸을 여는 작업은 해당 컨테이너의 Namespace Set 안으로 들어가는 일이다. EC2 위의 ECS는 하나의 Linux 커널을 여러 태스크가 Namespace로 나눠 공유하고, Fargate는 태스크별 micro VM을 제공해 Namespace와 VM의 이중 격리를 제공한다.
- 10
대표적인 장애는 cgroups 제한을 Host 자원 상태로 착각할 때 시작된다. ECS 태스크나 K8s Pod가 갑자기 재시작되고 exit code 137 또는 OOMKilled true가 보이면, 이는 SIGKILL로 종료되었다는 뜻이며 memory.max 초과로 커널 OOM Killer가 개입한 상황이다. free -m에서 Host 메모리가 남아 보여도 컨테이너의 cgroup 제한은 별도다. 또 memory.high 초과는 OOM Kill과 달리 커널 로그를 남기지 않는다. 컨테이너가 점점 느려지고 docker stats의 MEM%가 높게 유지된다면 memory.events의 high 카운터를 봐야 한다.
- 11
CPU 문제도 평균 사용률만 보면 놓치기 쉽다. cpu.max가 50000 100000이면 100ms 구간에서 50ms를 넘긴 뒤 남은 시간 동안 프로세스가 멈출 수 있다. 그래서 CloudWatch의 CPUUtilization이 낮아 보여도 요청 처리나 GC처럼 순간적인 burst가 필요한 작업에서 throttle이 걸리면 p99 레이턴시가 급등할 수 있다. PID Namespace 쪽에서는 컨테이너의 PID 1 책임도 중요하다. PID 1이 자식 프로세스를 wait로 회수하지 않으면 좀비가 쌓이고, SIGTERM이나 SIGINT 핸들러가 없으면 docker stop이 기본 10초 뒤 SIGKILL을 기다릴 수 있다. 정리하면 Namespace는 보이는 세계를 나누고, cgroups는 그 세계가 쓸 수 있는 자원 한계를 강제한다.
같은 레이어