cgroups
더 많은 작업
- control groups, cgroups; 컨트롤 그룹
- 프로세스를 묶어 그 묶음 전체의 CPU, 메모리, 입출력 사용량을 제한하고 측정하는 리눅스 커널 기능
cgroups(control groups, 컨트롤 그룹)는 프로세스 여러 개를 하나의 그룹으로 묶고, 그 그룹이 쓰는 CPU 시간, 메모리, 디스크 입출력, 프로세스 수 같은 자원을 제한하고 집계하는 리눅스 커널 기능이다. ulimit나 setrlimit는 프로세스 하나 단위로만 제한을 걸지만, cgroups는 자식 프로세스가 아무리 늘어나도 그룹 전체의 합계를 묶을 수 있다.
도커, 쿠버네티스, LXC, systemd가 모두 cgroups 위에서 자원을 관리한다. 네임스페이스가 프로세스에게 "무엇이 보이는지"를 나눈다면 cgroups는 "얼마나 쓸 수 있는지"를 나누며, 둘을 합친 것이 리눅스 컨테이너의 핵심이다.
- 자원 제한: 그룹이 정해진 메모리(페이지 캐시 포함), CPU 시간, 입출력 대역폭, 프로세스 수를 넘지 못하게 한다.
- 우선순위: 자원을 두고 경쟁할 때 그룹마다 CPU 시간이나 입출력의 몫을 다르게 준다.
- 계정(accounting): 그룹별로 실제 사용한 CPU 시간, 메모리, 입출력을 집계한다. 과금과 모니터링에 쓴다.
- 제어: 그룹 전체를 얼리거나(freeze) 다시 풀고, 그룹을 통째로 종료한다.
- 컨트롤러(controller, v1에서는 subsystem): 자원 종류 하나를 다루는 커널 모듈이다.
memory,cpu,io등. - 계층(hierarchy): cgroup은 트리 구조이며 자식 그룹은 부모 그룹의 제한 안에서만 자원을 나눠 받는다. 부모에게 1GB 제한이 있으면 자식들의 합도 1GB를 넘지 못한다.
- 인터페이스: 전용 시스템 호출이 없고 가상 파일 시스템으로 조작한다. 디렉터리를 만들면 cgroup이 생기고, 그 안의 파일에 값을 쓰면 설정이 바뀌며, 파일을 읽으면 통계가 나온다. v1은
cgroup, v2는cgroup2파일 시스템 형식이고, 관례상/sys/fs/cgroup에 마운트한다. - 소속: 모든 프로세스는 각 계층에서 정확히 하나의 cgroup에 속한다. fork한 자식은 부모의 cgroup을 물려받는다. 프로세스가 어디에 속했는지는
/proc/PID/cgroup으로 본다.
v1은 컨트롤러마다 별도의 계층을 마운트할 수 있었다. 그래서 한 프로세스가 cpu 계층에서는 /production에, memory 계층에서는 /testing에 속하는 식의 어긋남이 가능했다. 유연해 보이지만 한 프로그램에 어떤 제한이 걸려 있는지 한눈에 알기 어렵고, 여러 컨트롤러가 함께 움직여야 하는 경우(페이지 캐시를 쓰는 쓰기 입출력은 메모리와 입출력 양쪽에 걸친다)를 제대로 다루지 못했다. 스레드 단위로 서로 다른 그룹에 넣을 수 있는 것도 API를 복잡하게 만들었다.
cgroup v2는 모든 컨트롤러가 하나의 트리를 공유한다. 한 cgroup에서 여러 컨트롤러를 동시에 켜고, 자식에게 어떤 컨트롤러를 넘길지는 cgroup.subtree_control로 정한다. 주요 규칙과 개선점은 다음과 같다.[1]
- 내부 프로세스 금지(no internal process) 규칙: 루트가 아닌 cgroup은 자기 안에 프로세스가 없을 때만 도메인 컨트롤러 자원을 자식에게 나눠 줄 수 있다. 프로세스는 잎(leaf) 노드에만 둔다.
- 기본 단위가 스레드가 아니라 프로세스다. 스레드 단위 분배가 필요하면
cgroup.type에threaded를 써서 스레드 모드 하위 트리를 만든다. - 안전한 위임: 디렉터리와
cgroup.procs,cgroup.threads,cgroup.subtree_control의 쓰기 권한을 주는 방식으로 권한 없는 사용자에게 하위 트리를 맡길 수 있다. 루트리스 컨테이너가 이것을 쓴다. - 압력 정보(PSI, Pressure Stall Information):
cpu.pressure,memory.pressure,io.pressure로 자원 부족 때문에 작업이 얼마나 멈춰 있었는지를 비율로 알려 준다. - 파일 이름 통일:
cpu.max,memory.max,io.max,pids.max처럼 같은 규칙을 따른다. v1의cpu와cpuacct는cpu하나로 합쳐졌고blkio는io가 되었다. - 메모리 계정 통일: 페이지 캐시, 커널 메모리, 소켓 버퍼를 함께 센다. 그래서 쓰기 되돌림(writeback) 입출력을 메모리 그룹에 맞춰 제어할 수 있다.
| 항목 | v1 | v2 |
|---|---|---|
| 계층 | 컨트롤러별로 여러 개 | 하나로 통합 |
| 프로세스 위치 | 어느 노드에나 | 도메인 컨트롤러가 켜진 경우 잎 노드에만 |
| 스레드 분리 | 기본 허용 | threaded 모드에서만 |
| 파일 시스템 형식 | cgroup | cgroup2 |
| CPU 제한 파일 | cpu.cfs_quota_us, cpu.cfs_period_us, cpu.shares | cpu.max, cpu.weight |
| 메모리 제한 파일 | memory.limit_in_bytes, memory.soft_limit_in_bytes | memory.max, memory.high, memory.low, memory.min |
| 종료 알림 | release_agent, notify_on_release | cgroup.events의 populated 값 |
| 권한 위임 | 사실상 불안전 | 공식 지원 |
| 컨트롤러 | 다루는 자원 | 대표 파일(v2) |
|---|---|---|
| cpu | CPU 시간 비중과 상한 | cpu.weight(기본 100, 범위 1~10000), cpu.max(기본 max 100000), cpu.stat
|
| memory | 사용자·커널 메모리, 페이지 캐시, 스와프 | memory.max, memory.high, memory.low, memory.min, memory.swap.max, memory.oom.group
|
| io | 블록 장치 입출력 대역폭과 IOPS, 비중 | io.max, io.weight, io.stat
|
| pids | 만들 수 있는 프로세스·스레드 수 | pids.max, pids.current
|
| cpuset | 쓸 수 있는 CPU와 NUMA 메모리 노드 | cpuset.cpus, cpuset.mems
|
| hugetlb | 거대 페이지 사용량 | hugetlb.2MB.max 등
|
| rdma | RDMA 자원 | rdma.max
|
| misc | 암호화 가상 머신 ASID 같은 기타 스칼라 자원 | misc.max
|
| perf_event | perf 이벤트를 cgroup 단위로 모으기 | (파일 없음) |
| freezer | 그룹 정지와 재개 | v2는 코어 파일 cgroup.freeze
|
v1에만 있던 것으로 devices(장치 접근 허용 목록, v2에서는 eBPF 프로그램으로 대체), net_cls와 net_prio(패킷 분류와 우선순위, v2에서는 eBPF와 소켓 cgroup 매칭으로 대체)가 있다.
메모리 제한은 성격이 서로 다르다.[1]
memory.max: 강제 상한. 회수해도 넘으면 그 cgroup 안에서 OOM 킬러가 돈다.memory.high: 조절 한계. 넘으면 프로세스를 느리게 하고 회수를 강하게 하지만 OOM 킬러는 부르지 않는다.memory.low,memory.min: 보호선. 이 아래의 메모리는 되도록(low) 또는 절대(min) 회수하지 않는다.memory.oom.group: 1이면 OOM이 났을 때 그룹 전체를 한 단위로 죽여 작업이 반쯤 살아남는 일을 막는다. 리눅스 4.19에서 들어왔다.
v2 환경에서 직접 그룹을 만들고 제한을 거는 예다.[1]
# cgroup v2인지 확인 (cgroup2fs면 v2, tmpfs면 v1)
stat -fc %T /sys/fs/cgroup/
# 루트에서 자식에게 cpu, memory, pids 컨트롤러를 넘긴다
echo "+cpu +memory +pids" > /sys/fs/cgroup/cgroup.subtree_control
# 그룹 만들기
mkdir /sys/fs/cgroup/demo
# CPU: 100ms마다 50ms까지만 (CPU 0.5개)
echo "50000 100000" > /sys/fs/cgroup/demo/cpu.max
# 메모리: 512MiB 상한, 400MiB부터 조절
echo 512M > /sys/fs/cgroup/demo/memory.max
echo 400M > /sys/fs/cgroup/demo/memory.high
# 프로세스 수 100개까지 (포크 폭탄 방지)
echo 100 > /sys/fs/cgroup/demo/pids.max
# 현재 셸을 그룹에 넣는다 (이후 이 셸의 자식도 모두 demo 소속)
echo $$ > /sys/fs/cgroup/demo/cgroup.procs
# 사용량과 사건 확인
cat /sys/fs/cgroup/demo/memory.current
cat /sys/fs/cgroup/demo/memory.events # oom, oom_kill 횟수
cat /sys/fs/cgroup/demo/cpu.stat # nr_throttled, throttled_usec
프로세스를 옮길 때는 대상 cgroup의 cgroup.procs에 PID를 쓰며, 그 프로세스의 모든 스레드가 함께 옮겨진다. 비어 있는 그룹은 rmdir로 지운다.
systemd는 모든 서비스와 로그인 세션을 cgroup으로 관리한다. 서비스가 fork를 몇 번 하든 같은 cgroup에 남으므로, systemd는 서비스를 멈출 때 그 cgroup의 프로세스를 빠짐없이 정리한다. systemd는 cgroup 트리를 독점적으로 관리한다고 가정하므로, systemd 시스템에서 직접 트리를 만질 때는 위임받은 하위 트리 안에서만 하는 것이 원칙이다.
- 슬라이스(slice,
.slice): 트리의 중간 노드. 기본으로-.slice(루트) 아래system.slice(시스템 서비스),user.slice(사용자 세션),machine.slice(가상 머신과 컨테이너)가 있다. - 서비스(service,
.service): systemd가 직접 시작한 프로세스 묶음. - 스코프(scope,
.scope): systemd 밖에서 만들어진 프로세스를 등록한 묶음. 로그인 세션(session-1.scope)이나systemd-run --scope로 띄운 명령이 여기에 들어간다.
# /etc/systemd/system/app.service 의 [Service] 절
[Service]
ExecStart=/usr/local/bin/app
CPUQuota=150% # cpu.max = 150000 100000
CPUWeight=200 # cpu.weight
MemoryHigh=1G
MemoryMax=2G # memory.max
TasksMax=512 # pids.max
IOWeight=50
systemd-cgls # cgroup 트리 보기
systemd-cgtop # 그룹별 자원 사용량 실시간 보기
systemd-run --scope -p MemoryMax=1G ./batch.sh # 임시 스코프에 제한 걸어 실행
systemctl set-property app.service MemoryMax=3G # 실행 중 변경
systemd는 한동안 legacy(v1만), hybrid(v1과 v2를 함께), unified(v2만) 세 모드를 지원했다. v253(2023년)부터 v1 지원 중단을 예고했고, v258(2025년)에서 legacy와 hybrid 모드를 제거해 이제 항상 v2만 마운트한다.[2][3]
컨테이너 런타임은 자원 제한을 새로 발명하지 않고 cgroup 파일에 값을 써 넣는다. 도커는 컨테이너마다 cgroup을 하나 만들고 옵션을 다음처럼 옮긴다.
| docker run 옵션 | cgroup v2 설정 |
|---|---|
--cpus=1.5 |
cpu.max = 150000 100000
|
--cpu-shares |
cpu.weight(값 변환)
|
--cpuset-cpus=0-3 |
cpuset.cpus
|
--memory=512m |
memory.max
|
--memory-reservation |
memory.low
|
--memory-swap |
memory.swap.max(메모리와의 차이만큼)
|
--pids-limit |
pids.max
|
쿠버네티스에서 파드의 resources는 다음과 같이 적용된다.[4]
- CPU requests: CPU 비중(
cpu.weight, v1은cpu.shares)이 된다. 경쟁할 때의 몫을 정할 뿐 상한은 아니다. - CPU limits: CFS 할당량(
cpu.max)이 된다. 한도에 닿으면 강제로 멈춰 기다리는 스로틀링(throttling)이 일어난다. 응답 지연의 흔한 원인이라 CPU limits를 아예 두지 않는 운영 방식도 쓰인다. - memory limits:
memory.max가 된다. 넘으면 커널 OOM 킬러가 컨테이너 프로세스를 죽이고, 쿠버네티스는 이를OOMKilled상태로 보여 준다. 메모리 압박을 감지했을 때 사후에 집행되므로 잠깐 한도를 넘을 수도 있다. - memory requests: 스케줄링 기준으로 쓰이고, MemoryQoS 기능을 켜면
memory.min이나memory.high에도 반영된다. - requests와 limits의 조합에 따라 파드는 Guaranteed, Burstable, BestEffort 서비스 품질 등급으로 나뉘고, 각 등급은 kubelet이 만드는 cgroup 트리(
kubepods.slice아래)에서 위치가 다르다. 노드 메모리가 부족할 때 BestEffort가 먼저 쫓겨난다.
쿠버네티스의 cgroup v2 지원은 1.25에서 정식(GA)이 되었고, 리눅스 커널 5.8 이상, containerd 1.4 이상 또는 CRI-O 1.20 이상, systemd cgroup 드라이버가 필요하다. cgroup v1은 1.31에서 유지 보수 모드가 되었고 1.35에서 사용 중단(deprecated)되어, kubelet은 기본 설정으로는 v1 노드에서 시작하지 않는다.[5]
| 기능 | 나누는 것 | 예 |
|---|---|---|
| 네임스페이스 | 보이는 범위(이름과 식별자) | PID, 마운트, 네트워크, UTS(호스트 이름), IPC, 사용자, cgroup, 시간 네임스페이스 |
| cgroups | 쓸 수 있는 양 | CPU 시간, 메모리, 입출력, 프로세스 수 |
| chroot, pivot_root | 파일 시스템 루트 | 컨테이너 이미지의 루트 파일 시스템 |
| capabilities, seccomp, LSM | 할 수 있는 동작 | 시스템 호출 차단, AppArmor, SELinux |
cgroup 네임스페이스(리눅스 4.6)는 컨테이너 안에서 /proc/self/cgroup과 /sys/fs/cgroup이 자기 cgroup을 루트처럼 보이게 해, 호스트의 cgroup 경로를 숨긴다. cgroups 초기 개발 때는 네임스페이스를 만들 때마다 cgroup을 자동으로 만드는 ns 서브시스템이 있었지만 API와 맞지 않는다는 판단으로 제거되었다.
- 2006년: 구글의 Paul Menage와 Rohit Seth가 "process containers"라는 이름으로 개발을 시작했다. 구글은 공유 클러스터(뒤에 Borg로 알려진 시스템)에서 작업 묶음 단위로 자원을 나누려 했다.[6]
- 2007년 말: 커널에서 "container"라는 말이 여러 뜻으로 쓰여 혼란스럽다는 지적에 따라 control groups로 이름을 바꿨다.[7]
- 2008년 1월: 리눅스 2.6.24에 병합되었다.[8] 메모리 컨트롤러는 Balbir Singh, CPU 컨트롤러는 Srivatsa Vaddagiri 등이 기여했다.
- 2010년 11월: 레드햇 엔터프라이즈 리눅스 6.0이 cgroups를 지원 기능으로 넣었다.
- 2013년: 유지 보수를 맡은 Tejun Heo가 재설계를 시작했다. 커널 메모리 컨트롤러(kmemcg)가 3.8에 들어갔다.
- 2014년: cgroup 파일 시스템이 kernfs(3.14)로 옮겨졌고, v1의 더미 계층을 통합 계층으로 쓰는 작업이 3.16에 개발자용으로 들어갔다.
- 2016년 3월: 리눅스 4.5에서 통합 계층(cgroup v2)이 안정 기능이 되어
cgroup2형식으로 마운트할 수 있게 되었다.[9] - 2018년 10월: 리눅스 4.19에서 OOM 킬러가 cgroup을 한 단위로 죽일 수 있게 되었다(
memory.oom.group). - 2019년: 페도라 31이 주요 배포판 가운데 처음으로 v2를 기본값으로 삼았다. 이후 아치 리눅스(2021년 4월), 우분투 21.10, 데비안 11, RHEL 9가 뒤따랐다.[10]
- 2022년: 쿠버네티스 1.25에서 cgroup v2 지원이 정식 기능이 되었다.
- 2025년: systemd v258이 cgroup v1 지원을 제거했다.
cgroup v1에는 그룹의 마지막 프로세스가 끝나면 release_agent 파일에 적힌 프로그램을 호스트의 root 권한으로 실행하는 기능이 있다(notify_on_release가 1일 때). 2022년 2월 공개된 CVE-2022-0492는 이 파일에 쓸 때 초기 사용자 네임스페이스의 CAP_SYS_ADMIN을 확인하지 않은 결함이다. 컨테이너 안의 공격자가 사용자 네임스페이스를 새로 만들어 그 안에서 CAP_SYS_ADMIN을 얻고, cgroup v1을 마운트해 release_agent에 자기 스크립트 경로를 쓰면 호스트에서 root로 실행되어 컨테이너를 벗어난다. 컨테이너가 root로 돌고, AppArmor나 SELinux가 없고, seccomp가 unshare를 막지 않고, 호스트가 비특권 사용자 네임스페이스를 허용할 때 성립한다. 커널에 권한 확인을 추가해 고쳤고, 기본 seccomp 프로필이나 AppArmor, SELinux만 켜져 있어도 막힌다.[11] --privileged 컨테이너에서는 이 결함이 없어도 release_agent로 탈출할 수 있으므로 특권 컨테이너는 쓰지 않는 것이 원칙이다. v2에는 release_agent가 없다.
- pids 제한이 없으면 컨테이너 하나의 포크 폭탄이 호스트 전체의 PID를 고갈시킨다.
- 메모리 상한이 없으면 컨테이너 하나가 호스트의 OOM 킬러를 불러 다른 서비스를 죽인다. 반대로 상한만 두고
memory.high나 모니터링이 없으면 경고 없이 OOMKilled가 반복된다. - 컨테이너 안의
/proc/meminfo,/proc/cpuinfo는 호스트 값을 보여 주므로, cgroup 한도를 모르는 런타임(오래된 JVM 등)은 호스트 전체 메모리를 기준으로 힙을 잡았다가 OOM으로 죽는다. 최신 JVM, Go, .NET 런타임은 cgroup 한도를 읽어 반영한다. LXCFS 같은 도구로 컨테이너용 값을 보여 주기도 한다. - CPU 상한(
cpu.max)은 주기 단위로 집행되므로, 멀티스레드 프로그램이 주기 초반에 할당량을 다 쓰면 남은 시간 동안 모든 스레드가 멈춰 꼬리 지연이 커진다.
- 윈도우: 작업 개체(Job Object)가 프로세스 묶음의 CPU, 메모리, 프로세스 수를 제한하며, 윈도우 컨테이너의 기반이다.
- 솔라리스: 프로젝트와 리소스 제어, 존(Zones).
- FreeBSD:
rctl과 jail.
- ↑ 1.0 1.1 1.2 Control Group v2, Linux kernel documentation
- ↑ systemd v258 release notes
- ↑ Highlights from systemd v258: part one, LWN.net
- ↑ Resource Management for Pods and Containers, Kubernetes
- ↑ About cgroup v2, Kubernetes
- ↑ Process containers, LWN.net (2007-05-29)
- ↑ Notes from a container, LWN.net (2007-10-29)
- ↑ Linux 2.6.24, Kernel Newbies
- ↑ Linux 4.5, Kernel Newbies
- ↑ About cgroup v2, Kubernetes
- ↑ New Linux Vulnerability CVE-2022-0492 Affecting Cgroups: Can Containers Escape?, Unit 42