Linux 시리즈 #09 — Namespace·cgroups: 컨테이너 격리와 리소스 제한의 커널 기반

이 글의 핵심

호스트 free에는 메모리가 남는데 컨테이너만 OOM으로 죽거나, CPU가 한가한데 응답이 느린 현상은 대개 cgroup 한도에서 나옵니다. 루트에서 subtree_control로 컨트롤러를 켜지 않으면 하위 memory.max가 먹지 않는 함정, throttle과 memory.events를 먼저 보는 이유를 짚고, 막혔을 때 확인할 순서를 정리합니다.

Linux 시리즈 #09 | 앞서 읽을 만한 글: #05 프로세스·스케줄러 · #06 메모리·가상 메모리 · Linux 커널 내부 동작

Linux 컨테이너는 하나의 커널 위에 여러 격리된 실행 환경을 올리는 기술이고, 그 바탕은 두 가지 커널 기능입니다. 네임스페이스(namespace)는 프로세스에게 무엇이 보이는지, 즉 어떤 PID 번호 체계, 호스트명, 마운트 트리, 네트워크 스택을 갖는지를 나눕니다. cgroups(control groups)는 프로세스 묶음이 CPU, 메모리, I/O, 프로세스 수를 얼마나 쓸 수 있는지를 재고 제한합니다.

두 기능은 서로 다른 축입니다. PID namespace를 나눈다고 CPU 사용량이 제한되지 않고, CPU cgroup을 건다고 포트 충돌이 사라지지 않습니다. Docker, containerd, Kubernetes는 결국 OCI 런타임(runc, crun 등)을 통해 이 두 기능을 스펙에 맞게 조합해 쓰는 것입니다. 컨테이너를 “가벼운 VM”으로 이해하면 이 둘이 한 덩어리처럼 보이지만, 문제를 진단할 때는 “보이는 것의 문제인가, 쓸 수 있는 양의 문제인가”를 나눠 생각해야 합니다.

컨테이너 장애에서 이 구분이 특히 중요합니다. 앱 지표에는 CPU 5%만 찍혀 있고 호스트 top도 한가해 보이는데 응답이 느리다면, 해당 컨테이너의 cgroup에서 CPU 상한 때문에 스로틀링이 쌓이고 있을 가능성이 큽니다. 또 컨테이너 안에서 본 PID 1234와 호스트에서 본 PID 1234는 서로 다른 프로세스일 수 있으므로, 장애 기록에 PID만 적어 두면 나중에 대상을 특정할 수 없습니다. 기록할 때는 docker inspect의 State.Pid나 crictl inspect로 얻은 호스트 기준 PID와 /proc/<PID>/cgroup의 경로를 함께 남기는 것이 좋습니다.

Namespace가 나누는 것

Linux는 프로세스마다 특정 커널 자원에 대한 “뷰”를 namespace로 나눕니다. 종류별로 나누는 대상은 다음과 같습니다.

PID namespace는 PID 번호 체계를 분리합니다. 새 namespace의 첫 프로세스는 그 안에서 PID 1이 되고, 호스트에서는 여전히 원래의 전역 PID로 보입니다. 그래서 ps가 컨테이너 안과 호스트에서 같은 숫자를 서로 다른 프로세스에 붙일 수 있고, kill이 어떤 프로세스를 대상으로 하는지는 어느 PID namespace에서 실행했는지에 달려 있습니다. namespace 안의 PID 1은 처리기를 등록하지 않은 시그널을 무시하고 고아 프로세스를 거둬야 하는 init 역할을 맡기 때문에, 일반 애플리케이션을 PID 1로 실행하면 SIGTERM에 반응하지 않거나 좀비 프로세스가 쌓이는 문제가 생깁니다. tini나 dumb-init을 PID 1로 두는 이유가 이것입니다.

Network namespace는 네트워크 인터페이스, 라우팅 테이블, 방화벽 규칙(iptables/nft), 포트 공간을 통째로 분리합니다. 컨테이너 안의 127.0.0.1은 호스트의 루프백이 아닙니다. Mount namespace는 마운트 트리를 분리하며, 컨테이너의 overlay 루트 파일시스템과 bind 마운트가 여기에 붙습니다. UTS namespace는 호스트명과 NIS 도메인 이름을, IPC namespace는 System V IPC 객체와 POSIX 메시지 큐를 분리합니다.

User namespace는 UID/GID를 매핑해, namespace 안에서는 root(UID 0)인 프로세스가 호스트에서는 권한 없는 UID로 동작하게 합니다. rootless 컨테이너의 핵심이지만, 보안 정책상 비특권 사용자의 user namespace 생성을 막아 둔 배포판이나 조직도 있습니다. Cgroup namespace는 /proc/self/cgroup과 cgroupfs에서 보이는 경로의 루트를 바꿔, 컨테이너가 호스트의 cgroup 트리 구조를 보지 못하게 합니다. 경로가 다르게 보일 뿐 한도가 사라지는 것은 아닙니다. Time namespace(커널 5.6 이상)는 CLOCK_MONOTONIC과 CLOCK_BOOTTIME에 오프셋을 줄 수 있어, 체크포인트 후 다른 호스트로 옮긴 프로세스의 시간 연속성을 맞추는 데 쓰입니다.

lsns로 시스템의 namespace 목록과 종류, 사용 중인 프로세스를 볼 수 있습니다. /proc/<PID>/ns/ 아래 링크가 가리키는 inode 번호가 같으면 같은 namespace 객체이므로, 두 프로세스가 같은 namespace에 있는지 판단할 때는 이 번호를 비교하는 것이 가장 확실합니다.

unshare, clone, setns

프로세스는 새 namespace를 만들거나, 이미 있는 namespace에 합류할 수 있습니다. 셸에서 실험할 때는 unshare 명령을 씁니다.

# 새 PID + UTS + mount namespace에서 bash 실행
sudo unshare --pid --fork --mount-proc --uts bash

# 안에서 확인
echo $$        # 1
ps aux         # namespace 안의 프로세스만 보임
hostname demo  # 호스트의 hostname은 바뀌지 않음

unshare(CLONE_NEWPID)를 호출한 프로세스 자신은 새 PID namespace로 들어가지 않고, 그 뒤에 만드는 자식부터 새 namespace에 속합니다. 그래서 --fork로 자식 프로세스를 만들어 그 자식이 새 namespace의 PID 1이 되게 합니다. --mount-proc는 새 mount namespace를 만들고 그 안에 /proc을 다시 마운트합니다. 이것을 빼면 ps가 여전히 호스트의 /proc을 읽어 호스트의 모든 프로세스가 보입니다.

시스템콜 수준에서는 clone(2)이나 clone3(2)에 CLONE_NEWPID, CLONE_NEWNET 같은 플래그를 주어 새 namespace에서 자식을 만들고, unshare(2)로 현재 프로세스를 새 namespace로 옮기고(PID namespace 제외), setns(2)로 /proc/<PID>/ns/... 파일 디스크립터가 가리키는 기존 namespace에 합류합니다. unshare 명령의 옵션들은 같은 커널 기능의 다른 입구입니다. clone3는 인자를 struct clone_args 구조체로 넘기는 새 인터페이스이며 커널 5.3부터 쓸 수 있습니다. CLONE_NEWUSER는 자식의 uid_map/gid_map을 부모가 정해진 순서에 맞춰 써야 하는 등 절차가 까다로워, 애플리케이션에서 직접 다루기보다 런타임에 맡기는 것이 일반적입니다.

/proc로 확인하기

ls -l /proc/self/ns
readlink /proc/self/ns/net     # net:[4026531840] 같은 형식, 숫자가 inode
readlink /proc/$$/ns/pid

두 프로세스의 readlink 결과 숫자가 같으면 같은 namespace입니다. ip netns는 network namespace에 이름을 붙여 /var/run/netns/ 아래에 등록해 두는 도구인데, Docker나 대부분의 CNI 플러그인은 여기에 등록하지 않거나 다른 경로를 쓰므로 ip netns list에 없다고 해서 namespace가 없는 것은 아닙니다.

nsenter는 실행 중인 프로세스의 namespace에 들어가 명령을 실행합니다. docker exec와 kubectl exec도 같은 원리로 동작합니다.

# 대상 프로세스의 network + PID namespace에서 셸 실행
sudo nsenter -t <PID> -n -p /bin/bash

# network namespace만 들어가 호스트의 도구로 소켓 확인
sudo nsenter -t <PID> -n ss -lntp

-n만 쓰면 network namespace만 바뀌고 파일시스템은 호스트 것을 그대로 보므로, 컨테이너 이미지에 없는 ss나 tcpdump를 호스트에서 가져다 쓸 수 있습니다. 반대로 -m으로 mount namespace까지 들어가면 /가 컨테이너의 루트로 바뀌므로, 어느 루트에서 파일을 보고 있는지 의식하지 않으면 엉뚱한 파일을 읽거나 고치게 됩니다.

cgroups: v1과 v2

cgroups v1은 컨트롤러(cpu, memory, blkio 등)마다 별도의 계층을 가질 수 있어서, 한 프로세스가 /sys/fs/cgroup/cpu/...와 /sys/fs/cgroup/memory/...에 동시에 속하고 /proc/self/cgroup이 여러 줄로 나옵니다. 유연하지만 “이 서비스의 cgroup”을 하나의 경로로 말하기 어렵고, 모니터링이 컨트롤러마다 다른 경로를 봐야 합니다. 파일 이름도 컨트롤러마다 규칙이 달랐습니다.

cgroups v2는 하나의 루트(보통 /sys/fs/cgroup) 아래 단일 트리를 두고, 각 노드의 cgroup.subtree_control에 +cpu +memory처럼 써서 자식 노드에서 쓸 컨트롤러를 켭니다. 부모에서 컨트롤러를 켜지 않으면 자식 디렉터리에 cpu.max, memory.max 같은 파일이 아예 생기지 않습니다. systemd나 kubelet이 만든 cgroup은 이미 이 설정이 되어 있지만, 손으로 디렉터리를 만들어 실험할 때는 이 단계를 빠뜨리기 쉽습니다. 또 v2에는 “내부 프로세스 금지” 규칙이 있어, 루트가 아닌 cgroup은 자식에게 컨트롤러를 나눠 주면서 동시에 자기 자신에 프로세스를 둘 수 없습니다.

새 서버나 클러스터를 구성한다면 v2를 기본으로 두는 것이 좋습니다. 최근 주요 배포판(Fedora, Ubuntu 21.10 이후, Debian 11 이후, RHEL 9 등)은 기본이 v2이고, PSI 같은 관측 기능과 메모리 관리 개선도 v2 쪽에만 있습니다. 시스템이 어느 쪽인지는 stat -fc %T /sys/fs/cgroup이 cgroup2fs를 출력하는지로 확인할 수 있습니다.

v2에서 자주 보는 파일은 cpu.stat(사용 시간과 스로틀링 횟수), memory.current(현재 사용량), memory.events(한도 도달과 OOM 킬 횟수), io.stat입니다. 커널에서 PSI가 켜져 있으면 cpu.pressure, memory.pressure, io.pressure로 자원을 기다리느라 멈춘 시간의 비율도 볼 수 있습니다. 메모리 사용량에는 애플리케이션 힙뿐 아니라 그 cgroup이 읽고 쓴 파일의 페이지 캐시도 포함된다는 점을 기억해야 합니다.

# 실험용: 루트에서 자식 cgroup용 컨트롤러 확인/활성화
cat /sys/fs/cgroup/cgroup.controllers
cat /sys/fs/cgroup/cgroup.subtree_control
echo "+cpu +memory +pids" | sudo tee /sys/fs/cgroup/cgroup.subtree_control

sudo mkdir /sys/fs/cgroup/demo
echo "20000 100000" | sudo tee /sys/fs/cgroup/demo/cpu.max     # 100ms마다 20ms = CPU 0.2개
echo 268435456 | sudo tee /sys/fs/cgroup/demo/memory.max       # 256MiB
echo 100 | sudo tee /sys/fs/cgroup/demo/pids.max
echo $$ | sudo tee /sys/fs/cgroup/demo/cgroup.procs            # 현재 셸을 이동

cat /sys/fs/cgroup/demo/cpu.stat
cat /sys/fs/cgroup/demo/memory.current

cpu.max는 quota period 형식(마이크로초)이고, quota 자리에 max를 쓰면 상한이 없습니다. systemd가 관리하는 시스템에서는 루트 아래에 직접 디렉터리를 만들기보다 systemd-run --scope -p CPUQuota=20% -p MemoryMax=256M bash처럼 systemd를 통해 만드는 편이 안전합니다. systemd는 자기가 관리하는 트리를 다른 도구가 바꾸는 것을 가정하지 않기 때문입니다.

v1만 쓰는 오래된 호스트는 cpu.cfs_quota_us, memory.limit_in_bytes 같은 파일 이름을 씁니다. v2를 가정한 도구나 문서를 따라 하다가 “파일이 없다”는 오류가 나면 먼저 v1인지, v1과 v2를 섞은 hybrid 모드인지 확인합니다.

컨트롤러별로 짚기

CPU 컨트롤러는 경쟁할 때의 비율을 정하는 가중치(cpu.weight)와 절대 상한(cpu.max)을 다룹니다. 상한에 걸리면 그 주기의 남은 시간 동안 cgroup의 스레드가 실행되지 못하고, cpu.stat의 nr_throttled와 throttled_usec가 늘어납니다. 멀티스레드 애플리케이션은 짧은 시간에 여러 코어로 quota를 소진하기 때문에, 평균 CPU 사용률이 낮아도 요청마다 수십 밀리초씩 멈추는 지연이 생길 수 있습니다.

메모리 컨트롤러는 사용량이 memory.high를 넘으면 회수를 강하게 하며 할당을 늦추고, memory.max에 닿으면 먼저 페이지 캐시 등을 회수해 보고, 그래도 부족하면 그 cgroup 안에서 OOM 킬러를 실행합니다. 호스트 free에는 메모리가 남아 있는데 컨테이너만 죽는 현상이 이것이며, memory.events의 max와 oom_kill 카운터에 기록됩니다.

I/O 컨트롤러는 io.max로 장치별 대역폭과 IOPS 상한을, io.weight로 비율을 정합니다. 버퍼드 쓰기는 페이지 캐시를 거쳐 나중에 기록되므로, v2에서는 writeback까지 원래 cgroup에 귀속시키지만 파일시스템과 I/O 스케줄러 지원 여부에 따라 효과가 달라집니다. pids 컨트롤러(pids.max)는 fork 폭주를 막으며, 한도에 닿으면 fork()나 스레드 생성이 EAGAIN으로 실패합니다. cpuset 컨트롤러는 사용할 CPU와 NUMA 노드를 고정하며, 지연에 민감한 DB나 Kubernetes의 CPU Manager 정적 정책에서 쓰입니다. v2에는 장치 접근을 제어하는 devices 파일이 없고 eBPF 프로그램으로 대체되었습니다.

systemd, Docker, Kubernetes

systemd는 slice(트리 안의 자리), service(데몬), scope(외부에서 시작된 프로세스 묶음) 단위로 cgroup 노드를 만들고, 유닛 파일의 MemoryMax=, CPUQuota=, TasksMax= 같은 설정을 해당 cgroup의 memory.max, cpu.max, pids.max로 옮겨 씁니다. systemd-cgtop으로 cgroup별 자원 사용량을 보면, 어떤 프로세스가 어느 유닛에 속해 있는지 바로 드러납니다. 시스템 서비스는 system.slice, 사용자 세션은 user.slice 아래에 놓이므로, 같은 프로그램이라도 어떻게 실행했느냐에 따라 적용되는 한도가 다를 수 있습니다.

Docker(containerd와 runc)는 기본으로 PID, mount, UTS, IPC, network namespace를 새로 만들고(옵션에 따라 호스트 것을 공유), cgroup v2 환경에서는 cgroup namespace도 분리합니다. user namespace는 기본으로 쓰지 않습니다. --cpus, --memory, --pids-limit 같은 옵션은 컨테이너 cgroup의 파일로 옮겨집니다.

docker run -d --name demo --cpus="0.5" --memory=256m nginx
pid=$(docker inspect -f '{{.State.Pid}}' demo)
cat /proc/$pid/cgroup        # 0::/system.slice/docker-<id>.scope (systemd 드라이버, v2)
ls -l /proc/$pid/ns

cg=/sys/fs/cgroup$(cut -d: -f3 /proc/$pid/cgroup)
cat $cg/cpu.max              # 50000 100000
cat $cg/memory.max           # 268435456

--privileged는 모든 capability와 장치 접근을 허용하고 seccomp와 AppArmor 같은 보호를 대부분 끕니다. 개발 PC에서 --privileged로 동작을 확인한 컨테이너가 권한이 제한된 CI나 운영 환경에서 실패하는 경우가 많으므로, 필요한 권한만 --cap-add나 --device로 명시하는 편이 낫습니다. 컨테이너가 어떤 namespace와 자원 설정으로 만들어졌는지는 OCI 번들의 config.json에서 linux.namespaces와 linux.resources를 보면 확인할 수 있습니다.

Kubernetes에서는 kubelet과 컨테이너 런타임의 cgroup 드라이버(systemd 또는 cgroupfs)가 일치해야 합니다. 둘이 다르면 같은 자원을 두 관리자가 서로 다른 트리로 관리하게 되어, 자원 압박 상황에서 노드가 불안정해질 수 있습니다. cgroup v2 노드에서는 systemd 드라이버가 권장됩니다. Pod의 requests와 limits는 kubelet이 Pod와 컨테이너 cgroup의 cpu.weight, cpu.max, memory.max로 옮겨 씁니다.

C로 PID namespace 만들기

다음은 교육용 최소 예시입니다. 실제 런타임은 마운트, capability, 시그널 처리를 훨씬 더 많이 다룹니다.

#define _GNU_SOURCE
#include <sched.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
#include <signal.h>

static char child_stack[1024 * 1024];

static int child_main(void *arg) {
    (void)arg;
    printf("child: PID inside new namespace = %d\n", getpid());  /* 1 */
    execlp("bash", "bash", (char *)NULL);
    perror("execlp");
    return 1;
}

int main(void) {
    /* 스택은 아래로 자라므로 끝 주소를 넘긴다 */
    pid_t pid = clone(child_main, child_stack + sizeof(child_stack),
                      CLONE_NEWPID | SIGCHLD, NULL);
    if (pid < 0) {
        perror("clone");   /* CAP_SYS_ADMIN이 없으면 EPERM */
        return 1;
    }
    printf("parent: child global PID = %d\n", pid);
    waitpid(pid, NULL, 0);
    return 0;
}

root로 실행하면 자식은 자신을 PID 1로 보고, 부모는 같은 프로세스를 호스트의 전역 PID로 봅니다. 다만 이 상태의 bash에서 ps를 실행하면 호스트의 /proc을 그대로 읽기 때문에 호스트의 프로세스가 모두 보입니다. CLONE_NEWNS를 함께 주고 자식에서 /proc을 다시 마운트해야 namespace 안의 프로세스만 보이며, 런타임은 이런 부트스트랩 단계를 일관되게 처리합니다.

기존 namespace에 합류할 때는 /proc/<PID>/ns/net을 열어 얻은 파일 디스크립터로 setns(fd, CLONE_NEWNET)을 호출합니다. user namespace나 mount namespace에 합류할 때는 단일 스레드여야 하는 등 제약이 있어, 애플리케이션에 직접 넣기보다 nsenter에 맡기는 편이 안전한 경우가 많습니다.

막혔을 때 확인 순서

  1. 호스트 기준 PID로 대상을 확정합니다(docker inspect, crictl inspect, ps -ef --forest). 기록에는 호스트 PID를 남깁니다.
  2. /proc/<PID>/cgroup에서 cgroup 경로를 찾고(v2면 0::/... 한 줄), 그 디렉터리의 memory.events와 cpu.stat을 봅니다.
  3. /proc/<PID>/ns의 inode를 정상 동작하는 프로세스와 비교합니다. 네트워크 문제라면 nsenter -n으로 들어가 ss -lntp와 ip route를 확인합니다.
  4. 권한이 있어 보이는데 실패한다면 SELinux, AppArmor, seccomp, user namespace 제한을 확인합니다.
  5. pids 한도, ulimit -u, systemd TasksMax=, Kubernetes limit처럼 여러 층의 한도가 겹쳐 있다면 어느 것이 먼저 닿는지 따져 봅니다.

호스트 전체 CPU 사용률이 낮은데 느리다면 해당 cgroup의 스로틀링과 PSI 값을 봐야 합니다. OOM이 의심되면 dmesg에 cgroup 이름과 함께 기록이 남기도 하지만, 가장 먼저 볼 것은 memory.events의 oom_kill 카운터입니다.

보안 관점

user namespace는 권한 모델을 바꾸는 기능이라, 커널 취약점 노출 면적을 줄이기 위해 비특권 사용자의 생성을 막는 조직도 있습니다(kernel.unprivileged_userns_clone이나 user.max_user_namespaces 설정, Ubuntu의 AppArmor 제한 등). network namespace를 나누면 방화벽과 tc 규칙도 호스트와 다른 층에 놓이므로, 호스트에서 건 규칙이 컨테이너 내부 트래픽에 적용된다고 가정하면 안 됩니다. mount namespace에서는 suid 바이너리나 취약한 파일시스템 타입의 마운트가 권한 상승으로 이어질 수 있습니다. CAP_SYS_ADMIN은 마운트, namespace 조작, cgroup 변경 등 너무 많은 권한을 한꺼번에 주기 때문에, 최소 권한 원칙을 지키려면 이 capability와 --privileged를 운영 기본값으로 두지 않는 것이 좋습니다.

같이 보면 좋은 글