Skip to content
Published on

컨테이너는 거짓말이다 — cgroups와 네임스페이스로 본 커널의 진실

Authors

들어가며

도발적인 제목부터 해명하겠습니다. 커널 소스 코드에는 "컨테이너"라는 객체가 없습니다. struct container 같은 것은 존재하지 않습니다. 우리가 컨테이너라고 부르는 것은 네임스페이스(무엇이 보이는가), cgroups(얼마나 쓸 수 있는가), 그리고 보안 장치들(무엇을 할 수 있는가)을 한 프로세스에 겹쳐 적용한 결과일 뿐입니다. Docker도 containerd도 runc도, 결국 이 커널 기능들을 조합하는 오케스트레이터입니다.

이 사실이 왜 중요할까요? 컨테이너가 "VM의 가벼운 버전"이라는 잘못된 모델을 갖고 있으면, 컨테이너 탈출 취약점이 왜 생기는지, 왜 모든 컨테이너가 커널을 공유하는지, OOM Kill이 왜 그렇게 동작하는지 이해할 수 없습니다. 반대로 커널 기능의 조합이라는 정확한 모델을 가지면, 장애가 났을 때 추상화를 걷어내고 /sys/fs/cgroup과 /proc을 직접 읽으며 진실에 도달할 수 있습니다.

이 글에서는 그 조합을 직접 손으로 만들어 봅니다. unshare로 네임스페이스를 하나씩 체험하고, cgroup 파일을 직접 조작하고, 마지막에는 bash와 Go로 미니 컨테이너를 만듭니다.

컨테이너 = 커널 기능의 조합

먼저 전체 구도를 그림으로 잡습니다.

        "컨테이너"라는 환상
  +--------------------------------------+
  |  사실은 그냥 리눅스 프로세스           |
  |                                      |
  |  + 네임스페이스 (격리: 무엇이 보이나)   |
  |    pid, net, mnt, uts, ipc,          |
  |    user, cgroup, (time)              |
  |                                      |
  |  + cgroups (제한: 얼마나 쓸 수 있나)   |
  |    cpu, memory, io, pids ...         |
  |                                      |
  |  + 보안 레이어 (권한: 무엇을 할 수 있나)|
  |    capabilities, seccomp,            |
  |    LSM (SELinux/AppArmor)            |
  |                                      |
  |  + 루트 파일시스템 (overlayfs 이미지)  |
  +--------------------------------------+
            |
            v
     호스트 커널 하나를 모두가 공유
     (VM과의 근본적 차이)

핵심 명제: 컨테이너 안의 프로세스는 호스트에서 ps로 보이는 평범한 프로세스입니다. 단지 그 프로세스의 "시야"와 "한도"와 "권한"이 조작되어 있을 뿐입니다.

네임스페이스 7종 투어 — unshare 실습

네임스페이스는 커널 리소스의 "시야"를 분리합니다. 현재 안정적으로 쓰이는 것은 8종이지만(time 포함), 컨테이너의 핵심은 7종입니다. 각각 unshare 명령으로 직접 체험해 봅니다.

네임스페이스격리 대상컨테이너에서의 역할
pid프로세스 ID 공간컨테이너 안에서 PID 1부터 시작
net네트워크 스택 전체컨테이너별 인터페이스/라우팅/방화벽
mnt마운트 포인트컨테이너별 파일시스템 트리
uts호스트네임, 도메인네임컨테이너별 hostname
ipcSystem V IPC, POSIX 메시지큐공유메모리 격리
userUID/GID 매핑rootless 컨테이너의 핵심
cgroupcgroup 루트 시야자신의 cgroup 위치 숨김

하나씩 만져 봅니다.

# 1) uts: 가장 간단한 네임스페이스. 호스트네임 격리
sudo unshare --uts bash
hostname container-test   # 호스트의 hostname은 안 바뀜
hostname                  # container-test
exit

# 2) pid: PID 공간 격리. --fork와 --mount-proc이 핵심
sudo unshare --pid --fork --mount-proc bash
echo $$                   # 1  <- 이 셸이 PID 1
ps aux                    # 프로세스가 2~3개만 보임
exit

# 3) net: 빈 네트워크 스택이 생긴다
sudo unshare --net bash
ip link                   # lo 하나만, 그것도 DOWN 상태
exit

# 4) mnt: 마운트 트리 격리
sudo unshare --mount bash
mount -t tmpfs tmpfs /mnt # 호스트에는 안 보이는 마운트
findmnt /mnt
exit

# 5) ipc: 공유메모리 격리
ipcmk -M 1024             # 호스트에서 공유메모리 생성
sudo unshare --ipc bash
ipcs -m                   # 안 보임 (격리됨)
exit

# 6) user: 루트 아닌 사용자가 "네임스페이스 안에서만 root"
unshare --user --map-root-user bash   # sudo 불필요!
id                        # uid=0(root) ... 하지만
cat /proc/self/uid_map    # 0 <내UID> 1 형태의 매핑 확인
exit

# 7) cgroup: cgroup 경로의 시야 격리
sudo unshare --cgroup bash
cat /proc/self/cgroup     # 루트(/)처럼 보임
exit

전부 결합하면 이미 컨테이너의 골격입니다.

unshare --user --map-root-user --pid --fork --mount-proc \
        --net --uts --ipc --cgroup bash

user 네임스페이스와 rootless 컨테이너

user 네임스페이스는 보안 관점에서 가장 중요한 조각입니다. 핵심은 UID 매핑입니다. 네임스페이스 안의 UID 0(root)이 호스트의 비특권 UID(예: 100000)로 매핑되면, 컨테이너 안에서 root처럼 행동해도 호스트 관점에서는 평범한 사용자입니다.

   컨테이너 안 시야              호스트의 진실
  +----------------+          +-------------------+
  | uid 0 (root)   |  ----->  | uid 100000 (무권한)|
  | uid 1 (daemon) |  ----->  | uid 100001        |
  | ...            |          | ...               |
  | uid 65535      |  ----->  | uid 165535        |
  +----------------+          +-------------------+

  /etc/subuid, /etc/subgid 가 매핑 가능한 범위를 정의
  newuidmap/newgidmap 헬퍼가 매핑을 기록
# 호스트에서 매핑 범위 확인
cat /etc/subuid    # 예: youngju:100000:65536
cat /etc/subgid

# 실행 중인 프로세스의 매핑 확인
cat /proc/<컨테이너PID 자리에는 실제 숫자>/uid_map

위 명령의 경로는 실제 PID 숫자로 치환해서 사용합니다. 이 매핑 덕분에 Podman과 최근의 Docker rootless 모드는 데몬조차 일반 사용자 권한으로 돌립니다. 컨테이너 탈출에 성공해도 공격자가 얻는 것은 호스트의 무권한 UID이므로 피해 반경이 크게 줄어듭니다. 쿠버네티스도 user namespace 지원(hostUsers false)이 1.33에서 베타 기본 활성화를 거쳐 안정화 단계에 들어섰습니다.

cgroup v2 — 통합 계층과 파일 인터페이스

네임스페이스가 "시야"라면 cgroup은 "한도"입니다. cgroup v2는 v1의 컨트롤러별 분리 계층을 단일 트리로 통합했고, 현재 주요 배포판과 쿠버네티스의 기본입니다.

   /sys/fs/cgroup            (루트, cgroup2 마운트)
   |-- cgroup.controllers     사용 가능한 컨트롤러 목록
   |-- cgroup.subtree_control 자식에게 위임할 컨트롤러
   |-- system.slice/          systemd 서비스들
   |-- user.slice/            로그인 세션들
   +-- kubepods.slice/        쿠버네티스 파드들
       +-- kubepods-burstable.slice/
           +-- kubepods-burstable-pod<해시>.slice/
               +-- cri-containerd-<해시>.scope/
                   |-- cpu.max
                   |-- memory.max
                   |-- io.max
                   +-- pids.max

모든 제어가 파일 읽기/쓰기라는 점이 cgroup의 아름다움입니다. 직접 조작해 봅니다.

# 새 cgroup 생성과 컨트롤러 활성화
sudo mkdir /sys/fs/cgroup/lab
echo "+cpu +memory +io +pids" | sudo tee /sys/fs/cgroup/cgroup.subtree_control

# cpu: 100ms 주기당 20ms = 0.2 CPU
echo "20000 100000" | sudo tee /sys/fs/cgroup/lab/cpu.max

# memory: 소프트 압박선 192MiB, 하드 한도 256MiB
echo "201326592" | sudo tee /sys/fs/cgroup/lab/memory.high
echo "268435456" | sudo tee /sys/fs/cgroup/lab/memory.max

# pids: 포크밤 방지
echo 128 | sudo tee /sys/fs/cgroup/lab/pids.max

# 현재 셸을 넣고 확인
echo $$ | sudo tee /sys/fs/cgroup/lab/cgroup.procs
cat /sys/fs/cgroup/lab/memory.current   # 현재 사용량
cat /sys/fs/cgroup/lab/cpu.stat         # usage, nr_throttled
cat /sys/fs/cgroup/lab/memory.events    # low/high/max/oom/oom_kill 횟수

memory.high vs memory.max — OOM의 두 얼굴

이 둘의 차이는 운영에서 결정적입니다.

항목memory.highmemory.max
초과 시 동작회수 압박 + 할당 속도 강제 저하회수 시도 후 실패하면 OOM Kill
프로세스 생존죽지 않음 (느려짐)oom_kill로 즉사 가능
용도점진적 압박, 조기 경보최후의 방어선
관측memory.events의 high 카운트memory.events의 oom_kill 카운트

memory.high만 초과한 프로세스는 죽지 않는 대신 메모리 할당이 느려지고 회수(reclaim)에 시간을 빼앗깁니다. "파드가 죽지는 않는데 갑자기 느려졌다"면 memory.events의 high 카운트와 PSI(pressure stall information)를 확인해 보세요.

# 메모리 압박의 정직한 지표: PSI
cat /sys/fs/cgroup/lab/memory.pressure
# some avg10=12.34 ... 일부 태스크가 메모리 대기에 시간을 씀
# full avg10=3.21  ... 모든 태스크가 동시에 막힘 (심각)

쿠버네티스의 memory limits는 memory.max로 구현됩니다. limits 초과 시 컨테이너가 OOMKilled로 재시작되는 이유가 바로 이것입니다.

미니 컨테이너 직접 만들기

이제 배운 조각을 결합해 컨테이너를 직접 만듭니다. 먼저 bash 버전입니다.

#!/bin/bash
# mini-container.sh — unshare + pivot_root + cgroup 결합
# 사전 준비: 루트FS (예: alpine 미니 루트FS를 풀어둔 디렉터리)
set -euo pipefail

ROOTFS=/opt/rootfs-alpine
CG=/sys/fs/cgroup/minic

# 1) cgroup 준비: 0.5 CPU, 256MiB, 프로세스 64개
mkdir -p "$CG"
echo "50000 100000" > "$CG/cpu.max"
echo "268435456"    > "$CG/memory.max"
echo 64             > "$CG/pids.max"

# 2) 네임스페이스 만들고 그 안에서 셋업 스크립트 실행
exec unshare --pid --fork --mount --uts --ipc --net bash -c '
  set -euo pipefail
  ROOTFS=/opt/rootfs-alpine

  # 2-1) 자신을 cgroup에 등록
  echo $$ > /sys/fs/cgroup/minic/cgroup.procs

  # 2-2) 호스트네임
  hostname minic

  # 2-3) 마운트 전파 차단 (호스트 오염 방지)
  mount --make-rprivate /

  # 2-4) pivot_root 준비: new_root는 마운트 포인트여야 함
  mount --bind "$ROOTFS" "$ROOTFS"
  cd "$ROOTFS"
  mkdir -p old_root

  # 2-5) 루트 교체. chroot와 달리 탈출이 어렵다
  pivot_root . old_root

  # 2-6) 새 루트 기준 필수 가상 파일시스템
  mount -t proc  proc  /proc
  mount -t tmpfs tmpfs /tmp

  # 2-7) 옛 루트 접근 차단
  umount -l /old_root
  rmdir /old_root

  # 2-8) 컨테이너의 init 실행
  exec /bin/sh
'

핵심 포인트 세 가지입니다. 첫째, chroot가 아니라 pivot_root를 씁니다. chroot는 시야만 바꾸지만 pivot_root는 마운트 네임스페이스의 루트 자체를 교체하고 옛 루트를 떼어내므로 탈출이 훨씬 어렵습니다. 둘째, mount --make-rprivate로 마운트 이벤트 전파를 끊지 않으면 컨테이너 안의 umount가 호스트에 전파될 수 있습니다. 셋째, proc은 pid 네임스페이스 안에서 다시 마운트해야 ps가 격리된 시야를 보여줍니다.

같은 것을 Go로 만들면 runc의 축소판이 됩니다.

// minic.go — Go로 만드는 미니 컨테이너 (개념 검증용)
package main

import (
	"fmt"
	"os"
	"os/exec"
	"path/filepath"
	"syscall"
)

func main() {
	switch os.Args[1] {
	case "run": // 부모: 네임스페이스를 갖고 자기 자신을 재실행
		parent()
	case "child": // 자식: 새 네임스페이스 안에서 셋업
		child()
	default:
		panic("usage: minic run <cmd>")
	}
}

func parent() {
	cmd := exec.Command("/proc/self/exe",
		append([]string{"child"}, os.Args[2:]...)...)
	cmd.SysProcAttr = &syscall.SysProcAttr{
		Cloneflags: syscall.CLONE_NEWPID | syscall.CLONE_NEWNS |
			syscall.CLONE_NEWUTS | syscall.CLONE_NEWIPC |
			syscall.CLONE_NEWNET,
	}
	cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
	if err := cmd.Run(); err != nil {
		fmt.Println("error:", err)
		os.Exit(1)
	}
}

func child() {
	rootfs := "/opt/rootfs-alpine"

	// cgroup 등록 (v2 파일 인터페이스 그대로)
	cg := "/sys/fs/cgroup/minic"
	os.MkdirAll(cg, 0755)
	os.WriteFile(filepath.Join(cg, "memory.max"),
		[]byte("268435456"), 0644)
	os.WriteFile(filepath.Join(cg, "pids.max"), []byte("64"), 0644)
	os.WriteFile(filepath.Join(cg, "cgroup.procs"),
		[]byte(fmt.Sprint(os.Getpid())), 0644)

	syscall.Sethostname([]byte("minic"))

	// 마운트 전파 차단 + pivot_root
	syscall.Mount("", "/", "", syscall.MS_REC|syscall.MS_PRIVATE, "")
	syscall.Mount(rootfs, rootfs, "", syscall.MS_BIND, "")
	os.MkdirAll(rootfs+"/old_root", 0700)
	syscall.PivotRoot(rootfs, rootfs+"/old_root")
	os.Chdir("/")
	syscall.Mount("proc", "/proc", "proc", 0, "")
	syscall.Unmount("/old_root", syscall.MNT_DETACH)
	os.Remove("/old_root")

	// 사용자가 준 명령 실행
	cmd := exec.Command(os.Args[2], os.Args[3:]...)
	cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
	cmd.Run()
}
go build -o minic minic.go
sudo ./minic run /bin/sh
# 안에서: hostname -> minic, ps aux -> PID 1부터, ip link -> lo만

70줄 남짓으로 Docker의 핵심 격리가 재현됩니다. 프로덕션 런타임과의 차이는 이 다음부터입니다 — 이미지 관리, 네트워크 배선, 그리고 보안 레이어.

overlayfs — 이미지 레이어의 원리

컨테이너 이미지의 레이어 구조는 overlayfs로 구현됩니다.

   컨테이너가 보는 파일시스템 (merged)
  +------------------------------------+
  |  /bin/sh   /etc/app.conf   /tmp/x  |
  +------------------------------------+
      ^ 합성된 단일 뷰
      |
  upperdir (쓰기 가능, 컨테이너 레이어)     <- 변경분만 기록
  +------------------------------------+
  |  /etc/app.conf (수정본)  /tmp/x     |
  +------------------------------------+
  lowerdir (읽기 전용, 이미지 레이어들)     <- 여러 개 중첩 가능
  +------------------------------------+
  |  layer3: 앱 바이너리                |
  |  layer2: 런타임/라이브러리           |
  |  layer1: 베이스 OS                  |
  +------------------------------------+

  - 읽기: 위에서부터 탐색, 가장 위의 파일이 이김
  - 쓰기: lowerdir 파일 수정 시 upperdir로 복사 후 수정 (copy-up)
  - 삭제: upperdir에 whiteout 마커 생성 (실제로는 안 지워짐)
# overlayfs 직접 실습
mkdir -p /tmp/ov/lower /tmp/ov/upper /tmp/ov/work /tmp/ov/merged
echo "from-image" > /tmp/ov/lower/base.txt
sudo mount -t overlay overlay \
  -o lowerdir=/tmp/ov/lower,upperdir=/tmp/ov/upper,workdir=/tmp/ov/work \
  /tmp/ov/merged

echo "modified" > /tmp/ov/merged/base.txt   # copy-up 발생
cat /tmp/ov/lower/base.txt                  # from-image (원본 보존)
cat /tmp/ov/upper/base.txt                  # modified (변경분)

같은 이미지를 쓰는 컨테이너 100개가 lowerdir을 공유하고 각자 얇은 upperdir만 가지므로, 컨테이너 생성이 빠르고 디스크가 절약됩니다. 반대급부로 copy-up 비용 때문에 대용량 파일을 컨테이너 레이어에서 수정하는 워크로드(예: DB 데이터)는 반드시 볼륨을 써야 합니다.

capabilities — root를 쪼개다

전통적 유닉스 권한은 root(전능) 아니면 일반 사용자(무력)의 이분법이었습니다. capabilities는 root의 권한을 약 40개 조각으로 쪼개 필요한 것만 부여합니다.

# 현재 프로세스의 캡 확인
capsh --print
grep Cap /proc/self/status   # CapEff가 유효 캡 비트마스크
capsh --decode=00000000a80425fb   # 비트마스크 해독

# 파일에 캡 부여 (setuid root의 대안)
sudo setcap cap_net_bind_service=+ep ./myserver  # 80포트 바인드만 허용

컨테이너 런타임은 기본적으로 캡을 대폭 깎아서 시작합니다. Docker/containerd의 기본 세트는 CHOWN, DAC_OVERRIDE, FOWNER, SETUID, SETGID, NET_BIND_SERVICE, KILL 등 10여 개로, SYS_ADMIN(사실상 root급), NET_ADMIN, SYS_PTRACE 같은 위험한 캡은 제외됩니다.

# 쿠버네티스: 전부 버리고 필요한 것만 추가하는 것이 모범
securityContext:
  capabilities:
    drop: ["ALL"]
    add: ["NET_BIND_SERVICE"]
  allowPrivilegeEscalation: false

privileged true는 이 모든 보호(캡 제한, seccomp, 디바이스 격리)를 한 번에 해제하는 스위치입니다. "잘 안 되니 privileged"는 컨테이너 보안 모델 전체를 버리는 결정임을 인지해야 합니다.

seccomp — 시스템 콜 필터

seccomp은 프로세스가 호출할 수 있는 시스템 콜 자체를 BPF 필터로 제한합니다. 리눅스 시스템 콜은 400개가 넘지만 일반 애플리케이션이 쓰는 것은 일부입니다. 기본 프로파일은 keyctl, add_key, kexec_load, open_by_handle_at(컨테이너 탈출 사례에 쓰인 콜) 등 위험한 콜을 차단합니다.

{
  "defaultAction": "SCMP_ACT_ERRNO",
  "architectures": ["SCMP_ARCH_X86_64"],
  "syscalls": [
    {
      "names": ["read", "write", "openat", "close", "fstat",
                 "mmap", "brk", "exit_group", "futex", "epoll_wait"],
      "action": "SCMP_ACT_ALLOW"
    }
  ]
}
# 프로세스의 seccomp 상태: 0 없음, 1 strict, 2 filter
grep Seccomp /proc/self/status

# 쿠버네티스에서 런타임 기본 프로파일 적용
# securityContext.seccompProfile.type: RuntimeDefault

실무 기준: RuntimeDefault를 전 워크로드의 기본으로 깔고, 차단으로 인한 오류(EPERM)가 의심되면 strace로 확인 후 커스텀 프로파일로 좁혀서 허용하는 순서가 안전합니다.

런타임은 무엇을 하는가 — runc의 일

이제 런타임 계층의 분업이 명확해집니다.

  kubelet
    | CRI (gRPC)
    v
  containerd          이미지 풀/저장, 스냅샷(overlayfs), 컨테이너 라이프사이클
    | OCI 스펙 (config.json)
    v
  runc                이 글에서 다룬 모든 것의 실행자:
                      네임스페이스 생성, cgroup 설정, pivot_root,
                      capabilities 적용, seccomp 적재, 프로세스 exec
                      -- 실행 후에는 사라짐 (데몬 아님)

runc는 OCI 런타임 스펙의 config.json(네임스페이스 목록, cgroup 한도, 캡, seccomp, 마운트, rootfs 경로)을 받아 우리가 위에서 손으로 한 일을 정확하고 안전하게 수행하는 도구입니다. 우리의 미니 컨테이너와 본질이 같습니다.

# 실행 중인 컨테이너의 OCI 설정 구경
sudo cat /run/containerd/io.containerd.runtime.v2.task/k8s.io/*/config.json \
  | jq '.linux.namespaces, .process.capabilities.effective' | head -30

쿠버네티스와의 연결 — QoS와 cgroup 드라이버

쿠버네티스의 추상화가 cgroup 트리에 어떻게 내려앉는지 정리합니다.

QoS 클래스조건cgroup 상의 의미
Guaranteed모든 컨테이너 requests = limitscpu.max 고정, OOM 점수 최저(보호 최대)
Burstablerequests 있음, limits와 다름cpu.weight 비례 배분, 중간 보호
BestEffortrequests/limits 없음최소 weight, OOM 1순위 희생자

메모리 압박 시 커널 OOM Killer는 oom_score_adj가 높은 순서로 죽이는데, kubelet이 QoS에 따라 이 값을 설정합니다. BestEffort가 먼저 죽는 것은 정책이 아니라 cgroup과 OOM 점수의 직접 결과입니다.

cgroup 드라이버는 cgroupfs가 아닌 systemd 드라이버가 표준입니다. cgroup 트리의 관리 주체가 둘(systemd와 kubelet)이 되면 충돌하기 때문입니다. cgroup v2 전제의 기능들 — memory.high 기반 메모리 QoS(MemoryQoS 기능 게이트), swap 제어, PSI 기반 축출 신호 — 이 계속 늘고 있으므로 노드의 v2 여부 확인은 기본입니다.

stat -fc %T /sys/fs/cgroup    # cgroup2fs 면 v2

디버깅 도구 상자

추상화를 걷어내고 직접 보는 도구들입니다.

# 1) lsns: 호스트의 모든 네임스페이스 나열
lsns                       # 타입별 네임스페이스와 대표 PID
lsns -t net                # net 네임스페이스만

# 2) nsenter: 특정 프로세스의 네임스페이스로 진입
PID=$(pgrep -f my-app | head -1)
nsenter -t "$PID" -n ss -tlnp      # 그 컨테이너의 net ns에서 소켓 보기
nsenter -t "$PID" -m -p ps aux     # mnt+pid ns에서 프로세스 보기
nsenter -t "$PID" -a bash          # 전부 진입 (사실상 exec)
# 디버깅 도구가 없는 distroless 컨테이너도 nsenter로 조사 가능
# (바이너리는 호스트 것을 쓰면서 시야만 컨테이너로)

# 3) /proc에서 네임스페이스 ID 직접 비교
ls -l /proc/$$/ns/         # 각 ns의 inode 번호
ls -l /proc/"$PID"/ns/     # 같은 inode = 같은 네임스페이스

# 4) cgroup 경로 추적: 프로세스 -> cgroup -> 한도/사용량
cat /proc/"$PID"/cgroup    # cgroup 경로 확인
CG=/sys/fs/cgroup$(cut -d: -f3 /proc/"$PID"/cgroup)
cat "$CG/memory.current" "$CG/memory.max" "$CG/memory.events"
cat "$CG/cpu.stat" | grep -E "usage|throttled"
cat "$CG/memory.pressure" "$CG/cpu.pressure"   # PSI

# 5) OOM Kill 사후 분석
dmesg -T | grep -i -A5 "killed process"
journalctl -k | grep -i oom

특히 "파드가 OOMKilled인데 그래프상 메모리는 limits 아래였다"는 사건은, 그래프의 해상도(보통 15초+)와 순간 스파이크의 차이거나, 페이지 캐시를 포함한 memory.current와 RSS만 보는 그래프의 차이인 경우가 대부분입니다. memory.events의 oom_kill 카운터와 dmesg가 진실을 말해줍니다.

함정과 안티패턴

  • privileged true를 디버깅 편의로 남발하는 것. 격리 모델 전체가 해제됩니다. 필요한 캡만 추가하는 것이 정답입니다.
  • 컨테이너를 VM처럼 생각하고 커널 공유를 잊는 것. 한 컨테이너의 커널 패닉/익스플로잇은 노드 전체의 문제입니다.
  • DB 데이터를 컨테이너 레이어(overlayfs upperdir)에 쓰는 것. copy-up 비용과 휘발성 양쪽에서 잘못입니다.
  • memory limits 없이 BestEffort로 중요한 워크로드를 돌리는 것. 압박 시 1순위 희생자입니다.
  • PID 1 문제를 잊는 것. 컨테이너의 첫 프로세스는 시그널 기본 동작이 다르고 좀비 회수 책임이 있습니다. tini 같은 경량 init이나 셸의 exec 패턴을 쓰세요.
  • cgroup v1 시절 지식(memory.limit_in_bytes 등 파일명)으로 v2 노드를 조작하려는 것. 인터페이스가 다릅니다.
  • nsenter 없이 컨테이너 안에 디버깅 도구를 설치하려 드는 것. 이미지 불변성을 깨고 재현성을 해칩니다.

운영 체크리스트

  • 노드가 cgroup v2인지, cgroup 드라이버가 systemd인지 확인했는가
  • 모든 워크로드에 seccomp RuntimeDefault가 기본 적용되는가
  • capabilities는 drop ALL 후 필요한 것만 추가하는가
  • privileged 컨테이너의 존재 이유가 문서화되고 정기 검토되는가
  • rootless/user namespace 적용 가능성을 평가했는가
  • memory.events(oom_kill, high)와 PSI를 노드/파드 지표로 수집하는가
  • QoS 클래스 설계(무엇이 Guaranteed여야 하는가)가 의도적인가
  • 쓰기 많은 데이터 경로가 overlayfs가 아닌 볼륨을 쓰는가
  • PID 1 시그널/좀비 처리가 모든 이미지에서 해결되어 있는가
  • lsns/nsenter 기반 디버깅 절차가 런북에 있는가

마치며

"컨테이너는 거짓말"이라는 제목은 비난이 아니라 찬사입니다. 네임스페이스, cgroups, capabilities, seccomp, overlayfs — 각각 독립적으로 진화한 커널 기능들이 조합되어, 마치 격리된 작은 머신이 존재하는 듯한 환상을 만들어 냅니다. 좋은 추상화가 늘 그렇듯 평소에는 환상을 즐기면 됩니다. 그러나 장애와 보안의 순간에는 환상을 꿰뚫고 /sys/fs/cgroup과 /proc/PID/ns를 직접 읽을 수 있는 사람이 문제를 해결합니다. 이 글의 실습들이 그 투시력의 출발점이 되기를 바랍니다.

참고 자료