cgroup v2, and When Limits Look Like They Are Not Being Honoured
한국어 원문으로 표시합니다.
한 줄 요약
네임스페이스는 무엇을 볼 수 있는가, cgroup 은 얼마나 쓸 수 있는가를
담당합니다. 그리고 이 코스에서 제일 자주 사람을 속이는 사실은, cgroup 이 제한을
걸긴 하지만 /proc 을 가리지는 않는다는 것입니다.
왜 이게 필요했나
cgroup 은 2006년 구글에서 시작해 2007년 커널 2.6.24 에 병합됐습니다. 원래 이름은 "process container" 였는데 당시 이미 널리 쓰이던 container 라는 말과 헷갈린다는 이유로 control group, 줄여서 cgroup 으로 개명됐습니다. 이름의 역사가 곧 역할의 설명입니다 — 격리가 아니라 자원 통제입니다.
한 호스트에 컨테이너 서른 개를 올렸는데 그중 하나가 메모리를 다 먹으면 나머지 스물아홉 개가 함께 죽습니다. 네임스페이스는 이 문제를 전혀 해결하지 못합니다. 서로를 못 볼 뿐 같은 물리 자원을 공유하기 때문입니다.
어떻게 동작하나
먼저 버전을 확인합니다. stat -fc %T /sys/fs/cgroup 이 cgroup2fs 면 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.high 와 memory.max 를 구분하는 것이 중요합니다. high 를 넘으면
회수 압박이 걸리고 할당이 느려지지만 죽지는 않습니다. max 를 넘으면 OOM Kill
입니다. 그래서 high 는 "여기서부터 아프게 하라", max 는 "여기가 벽이다"입니다.
CPU 는 죽이지 않고 재웁니다. cpu.stat 에서 nr_throttled / nr_periods 가
5%를 넘으면 문제로 봅니다. 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.events
의 oom_kill 이 늘어야 진짜 문제다.
CPU 한도는 사용률로 안 보인다. 100ms 마다 할당량을 다 쓰고 나머지를 기다리므로,
평균 사용률은 낮은데 응답만 느려진다. cpu.stat 의 nr_throttled 와
throttled_usec 이 그 증거다.
작은 한도는 오히려 해롭다. CPU 한도를 0.1코어로 잡으면 100ms 중 10ms 만 돌 수 있어서, 짧은 작업도 여러 주기에 걸쳐 끝난다. 지연이 눈에 띄게 커지므로 요청은 낮게, 한도는 넉넉히 또는 없이 두는 쪽이 응답 시간에는 유리하다.
다음 실습에서 할 것
cgroup 버전과 컨트롤러 목록을 확인하고, 메모리·CPU·PID 한도를 각각 걸어 본 뒤 요청값과 관측값을 대조합니다. SIGKILL 로 137 을 직접 만들어 보고 OOM 킬과 어떻게 구분하는지 정리합니다.