LabHub
배우기 러닝패스 코스

コンテナの内部原理

cgroup v2と、制限が守られていないように見えるとき

LabHub 에서 이어서 보기

한국어 원문으로 표시합니다.

한 줄 요약

네임스페이스는 무엇을 볼 수 있는가, cgroup 은 얼마나 쓸 수 있는가를 담당합니다. 그리고 이 코스에서 제일 자주 사람을 속이는 사실은, cgroup 이 제한을 걸긴 하지만 /proc 을 가리지는 않는다는 것입니다.

概念マップ: 네임스페이스는 무엇을 볼 수 있는가, cgroup 은 얼마나 쓸 수 있는가・/proc 을 가리지는 않는다・자원 통제・5%를 넘으면 문제

왜 이게 필요했나

cgroup 은 2006년 구글에서 시작해 2007년 커널 2.6.24 에 병합됐습니다. 원래 이름은 "process container" 였는데 당시 이미 널리 쓰이던 container 라는 말과 헷갈린다는 이유로 control group, 줄여서 cgroup 으로 개명됐습니다. 이름의 역사가 곧 역할의 설명입니다 — 격리가 아니라 자원 통제입니다.

한 호스트에 컨테이너 서른 개를 올렸는데 그중 하나가 메모리를 다 먹으면 나머지 스물아홉 개가 함께 죽습니다. 네임스페이스는 이 문제를 전혀 해결하지 못합니다. 서로를 못 볼 뿐 같은 물리 자원을 공유하기 때문입니다.

어떻게 동작하나

먼저 버전을 확인합니다. stat -fc %T /sys/fs/cgroupcgroup2fs 면 v2, tmpfs 면 v1 입니다. v2 의 주요 파일은 다음과 같습니다.

파일
cpu.max quota period 두 값. 50000 100000 이면 0.5 CPU
cpu.weight 1~10000 사이의 상대 가중치, 기본 100
cpu.stat nr_periods, nr_throttled, throttled_usec
memory.min/low/high/max 보호선과 상한
memory.current 현재 사용량
memory.events 한도에 걸린 횟수
pids.max 프로세스 개수 상한

v1 에서는 각각 cpu.cfs_quota_us, memory.limit_in_bytes 였습니다.

memory.highmemory.max 를 구분하는 것이 중요합니다. high 를 넘으면 회수 압박이 걸리고 할당이 느려지지만 죽지는 않습니다. max 를 넘으면 OOM Kill 입니다. 그래서 high 는 "여기서부터 아프게 하라", max 는 "여기가 벽이다"입니다.

CPU 는 죽이지 않고 재웁니다. cpu.stat 에서 nr_throttled / nr_periods5%를 넘으면 문제로 봅니다. 1000 주기 중 350 번 스로틀됐다면 35%이고, 이때 CPU 사용률 그래프는 한가해 보이는데 응답시간만 튀는 전형적인 그림이 나옵니다.

자주 쓰는 바이트 값은 외워 두면 편합니다. 64MiB = 67108864, 192MiB = 201326592, 256MiB = 268435456, 512MiB = 536870912.

종료코드 137 = 128 + 9 = SIGKILL 입니다. OOM 킬은 앱에게 어떤 예외도 주지 않습니다. try/except 로 잡을 수 없고 로그도 남지 않습니다. 커널 로그의 Memory cgroup out of memory: Killed process ... 를 봐야 확정할 수 있고, 런타임 쪽에서는 State.OOMKilled 로 갈라야 합니다.

현장에서 만나는 모습

가장 중요한 함정입니다. --memory=256m --cpus=0.5 로 띄운 컨테이너에서 free -m 을 치면 64228MB 가, nproc 은 32 가 나옵니다. cgroup 은 제한을 걸지만 /proc 을 가리지 않기 때문입니다. JVM 은 UseContainerSupport 로 cgroup 파일을 직접 읽어 이 문제를 해결했지만, Node 의 os.cpus() 와 Go 의 runtime.NumCPU() 는 여전히 호스트 값을 봅니다. 그래서 Go 서비스에는 GOMAXPROCS 를 명시해야 합니다. 32 스레드로 워커를 띄워 놓고 0.5 코어를 배정하면 스로틀만 쌓입니다.

쿠버네티스의 QoS 클래스도 여기서 나옵니다. Guaranteed 는 OOM score -997, Burstable 은 2~999, BestEffort 는 1000 입니다. BestEffort 가 먼저 죽는 것은 정책 문장이 아니라 cgroup 과 OOM 점수의 직접적인 결과입니다.

한 가지 더. rootless 환경에서는 제한을 걸어도 실제로 강제되지 않을 수 있습니다. systemd delegation(Delegate=cpu memory pids io)이 없으면 요청은 기록되지만 커널에 반영되지 않습니다. 그래서 다음 실습은 docker inspect 의 HostConfig 요청값과 컨테이너가 실제로 보는 값을 둘 다 확인합니다. 둘이 다르면 그것 자체가 배울 거리입니다.

v1 과 v2 가 다르게 보이는 자리

cgroup 은 v1 과 v2 가 공존하고, 파일 이름과 의미가 다르다. 어느 쪽인지 먼저 확인하지 않으면 없는 파일을 찾게 된다.

stat -fc %T /sys/fs/cgroup      # cgroup2fs 면 v2, tmpfs 면 v1
무엇 v1 v2
메모리 한도 memory/memory.limit_in_bytes memory.max
현재 사용 memory.usage_in_bytes memory.current
CPU 한도 cpu.cfs_quota_us / cpu.cfs_period_us cpu.max (두 값이 한 줄)
스로틀 통계 cpu.stat cpu.stat
OOM 기록 (없음) memory.events

v2 의 memory.events 가 특히 유용하다. v1 에는 없어서 "이 컨테이너가 OOM 으로 죽은 적이 있는가" 를 커널 로그로만 알 수 있었다.

한도가 없으면 max 로 나온다. 숫자가 아니라 max 문자열이므로, 파싱하는 코드에서 예외가 난다. 도구를 만들 때 자주 걸린다.

메모리 한도는 페이지 캐시도 센다. 파일을 많이 읽는 컨테이너는 캐시가 쌓여 한도에 닿는데, 커널이 그 캐시를 회수하므로 대개 죽지는 않는다. 그래서 memory.current 가 한도에 붙어 있는 것 자체는 문제가 아니다. memory.eventsoom_kill 이 늘어야 진짜 문제다.

CPU 한도는 사용률로 안 보인다. 100ms 마다 할당량을 다 쓰고 나머지를 기다리므로, 평균 사용률은 낮은데 응답만 느려진다. cpu.statnr_throttledthrottled_usec 이 그 증거다.

작은 한도는 오히려 해롭다. CPU 한도를 0.1코어로 잡으면 100ms 중 10ms 만 돌 수 있어서, 짧은 작업도 여러 주기에 걸쳐 끝난다. 지연이 눈에 띄게 커지므로 요청은 낮게, 한도는 넉넉히 또는 없이 두는 쪽이 응답 시간에는 유리하다.

다음 실습에서 할 것

cgroup 버전과 컨트롤러 목록을 확인하고, 메모리·CPU·PID 한도를 각각 걸어 본 뒤 요청값과 관측값을 대조합니다. SIGKILL 로 137 을 직접 만들어 보고 OOM 킬과 어떻게 구분하는지 정리합니다.