본문으로 건너뛰기
Previous
Next
Linux 시리즈 #09 — Namespace·cgroups: 컨테이너 격리와 리소스 제한의 커널 기반

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

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

이 글의 핵심

Linux 컨테이너는 namespace로 “보이는 세계”를 나누고, cgroups로 CPU·메모리·I/O·프로세스 수를 계층적으로 제한합니다. unshare·clone·setns로 네임스페이스를 만들고, cgroup v1/v2·systemd slice·Docker 런타임이 이를 어떻게 쓰는지까지 한 흐름으로 정리합니다.

Linux 시리즈 #09 | 앞서 읽을 만한 것: #05 프로세스·스케줄러 · #06 메모리·가상 메모리 · Linux 완전 가이드

Linux 컨테이너는 한 커널 위에 여러 격리된 실행 환경을 올리는 기술이고, 그 뼈대는 두 갈래다. 네임스페이스(namespace)가 “뭐가 보이고, 어떤 PID·호스트명·마운트·네트 스택으로 인식되느냐”를 갈라 주고, cgroups가 “이 프로세스 묶음이 CPU·메모리·I/O·pids를 얼마나 쓰느냐”를 재고, 제한한다. 이 둘은 같은 축이 아니다. PID namespace만으로 CPU를 아끼는 게 아니고, CPU cgroup만으로는 포트 충돌을 없앨 수 없다. Docker·containerd·Kubernetes는 결국 OCI 런타임(runc 등)과 cgroup 드라이버로 이 조합을 스펙에 맞게 구현할 뿐이다.

Docker 컨테이너 디버깅하면서 제일 짜증 나는 지점이 여기다. 앱 로그엔 “CPU 5%인데 느리다”만 찍혀 있고, 호스트 top을 보면 여유로워 보이는데 실제론 해당 컨테이너의 cgroup에서 cpu.stat throttling이 쌓이고 있었다—이런 케이스, 한 번쯤 겪었을 것이다. 컨테이너 에서 본 PID 1234와 호스트 전역 PID 1234는 같은 엔티티가 아닐 수 있고, 장애 티켓엔 “PID”만 적혀 있으면 나중에 아무도 못 맞춘다. 기록할 때는 호스트 기준 docker inspect의 Pidcrictl로 잡은 전역 PID, 그리고 /proc/그PID/cgroup 한 줄(가능하면 v2 경로)을 같이 남기는 쪽이 살아남는다.

솔직히 말하면, 새로 서버/클러스터를 잡는 입장이면 cgroups v2 쓰세요. v1은 컨트롤러마다 트리가 갈라져 /proc/PID/cgroup이 여러 줄로 나오고, “CPU는 이 경로, memory는 저 경로”로 흩어지기 쉽다. v2는 unified hierarchy에서 한 트리로 CPU·memory·io를 읽는 게 자연스럽고, 인터페이스도 cpu.stat, memory.current 쪽이 정리돼 있다. 레거시 노드·벤더 커널을 억지로 못 박는 경우가 아니라면, 기본을 v2로 두는 쪽이 운영·옵저빌리티 모두에 유리하다. v1/v2 혼용이면 문서에 나온 /sys/fs/cgroup/... 경로가 없을 수 있으니, 막히면 mount | grep cgroup이랑 /sys/fs/cgroup/cgroup.controllers부터 보면 된다.

독자가 흔히 빠지는 착각은 “컨테이너 = 가벼운 VM” 식으로 가시성할당량을 한덩이로 이해하는 것이다. namespace는 “보이는 전역( PID·NET·MNT… )”을 쪼개고, cgroup은 “쓸 수 있는 양”을 쪼갠다. chroot는 루트 만 바꿨고, namespace가 그걸 일반화했고, Fair Group Scheduling·memory cgroup이 합쳐져 cgroups가 됐다. OCI 런타임은 둘을 동시에 깔아서 “같은 포트/같은 PID 숫자가 엇갈리지 않게” 하면서 “요청·리밋”도 한 실행 단위에 묶는다.

Namespace가 갈라 놓는 것

Linux는 프로세스마다 특정 커널 객체 집합에 대한 “뷰”를 namespace로 나눈다. PID namespace는 PID 번호 체계를 분리한다. 새 트리의 첫 프로세스는 그 안에선 PID 1이 되고, 호스트에선 여전히 전역 PID로 보인다—그래서 ps가 컨테이너·호스트에서 같은 숫자를 전혀 다른 프로세스에 붙일 수 있다. kill이 누구를 죽이는지는 어느 PID 공간이냐에 달렸다.

Network namespace는 인터페이스·라우팅·iptables/nft·포트 공간이 통째로 따로다. 컨테이너의 127.0.0.1은 호스트 루프백이 아니다. Mount namespace는 마운트 트리; overlay·bind·tmpfs가 여기서 돈다. UTS는 hostname과 NIS 도메인. IPC는 SysV·POSIX IPC 이름을 나눈다. User namespace는 UID/GID 매핑(루트리스·권한 축소의 핵심)인데, 정책에 따라 생성이 막힌 환경도 있다. Cgroup namespace/proc/self/cgroup에 보이는 문자열을 루트에 맞게 다시 쓰는 쪽에 가깝지, 한도가 사라지는 건 아니다. Time namespaceCLOCK_MONOTONIC 기준을 나누는 등 테스트·시뮬 쪽에서 가끔 쓰인다.

lsns로 시스템에 있는 namespace 객체·타입·사용 PID를 훑을 수 있고, /proc/.../ns/mnt 같은 링크의 inode가 같으면 같은 namespace 객체다. ipc·cgroup 같은 표기는 도구/문서에 따라 흔들릴 수 있으니, 트러블슈팅에선 “inode로 동일성 판단”을 불변식으로 쓰는 편이 안전하다.

unshare, clone, setns

프로세스는 새 namespace를 만들거나, 이미 있는 쪽에 조인할 수 있다. 셸에서 실험하려면 unshare가 입구다.

# 새 PID + UTS + mount namespace에서 /bin/bash (권한 필요할 수 있음)
sudo unshare --pid --fork --mount-proc --uts bash

--fork는 PID namespace 규칙상 자식이 첫 프로세스가 되게 자주 쓰고, --mount-proc/proc을 새 트리에 맞게 붙이려는 것이다. 시스템콜 쪽에선 clone(2)/clone3(2)CLONE_NEW* 플래그를 주고(예: CLONE_NEWPID, CLONE_NEWNET), setns(2)/proc/[pid]/ns/... FD로 이미 있는 namespace에 합류한다. unshare--mount·--net 등은 이와 같은 커널 기능의 다른 입구다. clone3struct clone_args로 한꺼번에 넘기는 쪽이고, runc·crun이 환경에 맞게 고른다. CLONE_NEWUSER는 uid_map 타이밍이 까다로워서 앱에서 직접 만지다가 망하는 경우가 많고, 런타임에 맡기는 게 일반적이다.

PID namespace는 누가 그 안에서 첫 PID가 될지·/proc을 언제 맞춰 붙일지가 얽혀서, unshare --pid--fork·--mount-proc와 자주 엮인다. 컨테이너 tini·dumb-init류는 이 부트스트랩이 끊기지 않게 붙는 역할이다.

/proc로 확인하기

ls -l /proc/self/ns
readlink /proc/self/ns/net
readlink /proc/$$/ns/pid

두 프로세스가 같은 net을 쓰는지 ls -li로 inode를 비교하면 된다. 컨테이너 안 /proc은 보안 프로파일에 일부가 숨을 수도 있다. ip netns는 network namespace에 이름을 붙이는 운영자 UX(보통 루트)인데, CNI/도커는 항상 /var/run/netns만 쓰는 건 아니다. ip netns에 없다 = 없다,로 단정하진 말자.

nsenter는 이미 떠 있는 PID의 namespace에 쉘을 밀어 넣는다. docker exec·kubectl exec이 비슷한 아이디어다.

# 예: target PID의 network + pid (root·권한 필요)
# sudo nsenter -t <PID> -n -p /bin/bash

mount namespace까지 섞으면 ls /의 의미가 호스트와 달라지니, 어느 루트에서 파일을 봤는지 메모 없이 cat하다가 사고 난다.

cgroups: v1은 알되, v2로 가라

cgroups v1은 서브시스템(컨트롤러)마다 다른 계층을 가질 수 있어서, 한 프로세스가 /sys/fs/cgroup/cpu/.../sys/fs/cgroup/memory/...동시에 얹히고 /proc/self/cgroup여러 줄이 된다. 유연해 보이지만 “이 서비스의 cgroup”을 한 경로로 말하기 어렵고, 모니터링·알람이 경로 불일치로 빗나가기 쉽다. CPU·memory·blkio 등 파일 이름도 컨트롤러마다 달랐다.

cgroups v2는 단일 루트(보통 /sys/fs/cgroup) 아래 한 트리에서 cgroup.subtree_control로 자식에 컨트롤러를 켜 주는 모델이다. 루트에 +cpu +memory가 안 켜져 있으면 하위 memory.max를 써도 안 먹는 식—systemd·kubelet이 만든 경로는 대개 이미 손봤는데, 수동 실험에서만 “왜 있는데 안 되지?”가 난다. 프로세스는 cgroup.procs에 PID를 써서 그룹으로 옮긴다. cpu.maxquota period 형식, max 토큰은 무제한에 가깝다.

v2 관측에서 자주 보는 것: cpu.stat(throttle 포함), memory.current, memory.events, io.stat. PSI(memory.pressure 등)가 켜져 있으면 기다림 누적이 한눈에 든다. 앱 힙만 보면 안 되고 파일 캐시까지 한도 안에서 같이 밟힌다.

# (예시) 데모용 — 실제 경로는 시스템마다 다름
sudo mkdir -p /sys/fs/cgroup/demo.slice
cat /sys/fs/cgroup/cgroup.controllers
echo "20000 100000" | sudo tee /sys/fs/cgroup/demo.slice/cpu.max
echo 268435456 | sudo tee /sys/fs/cgroup/demo.slice/memory.max
echo $$ | sudo tee /sys/fs/cgroup/demo.slice/cgroup.procs
cat /sys/fs/cgroup/demo.slice/cpu.stat
cat /sys/fs/cgroup/demo.slice/memory.current

v1 레거시 호스트는 cpu.cfs_quota_us·memory.limit_in_bytes 같은 키를 아직 쓰고, 도구·문서가 v2만 가정이면 “파일이 없다”로 막힌다. 그때는 혼용/순수 v1부터 의심하면 된다.

컨트롤러 느낌으로만 짚기

CPU — 가중치(cpu.weight 등)와 상한(quota/period, v1에선 cpu.cfs_*). throttling이면 cpu.stat 쪽 throttled 누적이 늘어난다. Memory — 한도에 닿으면 OOM· cgroup 레벨 킬. 호스트 free는 남는데 컨테이너만 죽는 패턴이 여기 찍힌다. ioio.max·io.stat 쪽(환경·드라이버 의존). 페이지 캐시·다른 층이 섞이면 io.stat만으로 “디스크 OK”로 단정하긴 어렵다. pids — fork 폭주 방지, EAGAIN·스레드 생성 실패로 튄다. devices(v1 등)·seccomp·—device랑 “열리는 것 같은데 open이 거절”이 겹칠 수 있다. cpuset·huge는 DB·NUMA·DPDK 쪽에서 트리와 쿠베 토폴로지 매니저랑 엮인다.

systemd, Docker, 그 너머

systemd는 slice(트리에서의 “자리”)·service(데몬)·scope(바깥에서 온 PID 묶음)로 cgroup 노드를 짜고, MemoryMax=, CPUQuota=, TasksMax= 같은 값이 같은 내용을 cgroup에 투영된다. systemd-cgtop으로 한번 훑어 보면 “왜 이 프로세스가 이 cgroup이지?”의 답이 어느 유닛인지로 떨어지는 경우가 많다. user.slice vs system.slice는 OOM·기본 정책이 달라서, 같은 바이너리를 유저 서비스 vs 시스템 유닛으로 돌릴 때 체감이 갈릴 수 있다.

Docker(containerd→runc)는 기본으로 PID·mount·UTS·IPC·network(옵션에 따라 host) 등 namespace 집합을 씌우고, cgroup 경로에 --cpus·--memory·--pids-limit매핑한다. rootless는 user namespace·cgroup 위임이 얽혀 같은 플래그도 기대와 다를 수 있다.

docker run -d --name demo --cpus="0.5" --memory=256m nginx
pid=$(docker inspect -f '{{.State.Pid}}' demo)
cat /proc/$pid/cgroup
ls -l /proc/$pid/ns

--privileged는 격리·캡·디바이스를 사실상 허용 범위를 크게 넓힌다. 개발 PC에선 “됐다”가 CI·스테이징에서 터지는 대표 케이스다. OCI config.jsonlinux.namespaces·linux.resources를 보면 “host 네트워크인 이유” 같은 1차 답이 자주 있다.

쿠버네티스는 kubelet·cgroupDriver(systemd vs cgroupfs)·노드의 v1/v2 가정이 같이 맞아야 limit/request가 커널에 일관되게 흐른다. “limit 줬는데 전혀 안 먹는다”는 드라이버 불일치 의심 리스트에 넣는 게 좋다.

C로 PID namespace 한 번

교육용 최소 예시다(실제 런타임은 마운트·캡·시그널을 훨씬 더 쓴다).

#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());
    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");
        return 1;
    }
    printf("parent: child global PID = %d\n", pid);
    waitpid(pid, NULL, 0);
    return 0;
}

CLONE_NEWPID 뒤에 /proc을 새 트리에 맞추는 등 부트스트랩이 추가로 필요한 경우가 많고, 그걸 runc 쪽이 일관되게 처리한다.

setns/proc/.../ns/net FD를 열고 setns(fd, CLONE_NEWNET) 식으로 이미 떠 있는 스택에 붙는다. 멀티스레드·권한 제약이 있어서 앱에 직접 박기보다 nsenter에 맡기는 편이 안전한 때가 많다.

막힐 때 — 표 없이, 순서만

  1. 전역 PID로 대상을 확정한다(docker top, crictl, ps 트리). 티켓엔 호스트 PID를 쓰자.
  2. /proc/그PID/cgroup — v2면 한 줄 path가 뜨는 경우가 많다. 그 leaf에서 memory.events·cpu.stat을 본다.
  3. /proc/그PID/ns inode — 정상 케이스와 비교. 네트워크 이슈면 nsenter로 들어가 ss -lntp를 본다.
  4. SELinux·AppArmor·seccomp·user namespace 제한 — “권한 있는 것 같은데 실패”에 특히.
  5. pids·ulimit·systemd TasksMax·k8s limit이 겹쳐 먼저 닿는 한도를 계산한다.

“호스트 CPU는 한가한데 느리다”는 전역 %만 믿지 말고, 해당 cgroup의 throttle·memory pressure를 보라. OOM 힌트는 dmesg에 cgroup 이름이 찍히기도 하고, 안 찍혀도 memory.events가 먼저다.

보안은 짧게

User namespace는 권한 모델을 다시 쓰기 때문에 조직에선 커널 옵션으로 생성을 막는 경우도 있다. network namespace는 방화벽·tc 규칙이 호스트와 층이 다르다. mount 쪽은 위험한 suid·취약 fs 타입이 권한 상승로 이어질 수 있고, privileged는 검증용이 아니면 프로덕션 기본으로는 피하는 게 낫다. CAP_SYS_ADMIN은 mount·cgroup·setns랑 잘 겹쳐서 “최소 권한”이랑 트레이드오프가 분명하다.


Namespace는 보이는 세계, cgroups는 쓸 수 있는 양—축이 다르다는 것만 머리에 박으면 절반은 간다. cgroups v2를 기본에 두고, PID는 어느 공간인지, 한도는 어느 cgroup 파일인지로 좁기면, “로그에 CPU 5%” 같은 말이 왜 거짓말처럼 느껴졌는지도 설명이 된다. 앞의 프로세스·스케줄·메모리 글과 겹쳐 읽으면 컨테이너 OOM·스로틀을 설명하는 말이 조금씩 커널 쪽으로 붙는다.

배포 전에는 git addgit commitgit pushnpm run deploy 같은 팀 절차를 따르면 된다.

부록 — 자주 쓰는 것만: grep . /proc/self/cgroup, ls -l /proc/self/ns, systemd-cgtop, 실험은 # sudo unshare --pid --fork --mount-proc bash.

Linux, namespace, cgroups, cgroup v2, unshare, clone, setns, Docker, 컨테이너, systemd, SRE, runc — 글 전체에 이미 흩어져 있으니, 키워드만 따로 나열할 건 없다.