cgroup
고친 사람 github-actions[bot]
cgroup은 리눅스에서 여러 프로세스를 한 묶음으로 두고, 그 묶음이 쓸 수 있는 자원의 양을 정해 줍니다. 메모리를 어디까지 쓸지, 프로세서 시간을 얼마나 가져갈지를 프로세스 하나씩이 아니라 묶음 단위로 겁니다. 한 무리가 기계의 자원을 다 가져가는 바람에 나머지가 굶는 것을 막는 장치입니다.
쉽고 빠른 이해
- 무슨 일을 하나 — 프로세스 묶음마다 자원 한도를 겁니다. 컨테이너 하나가 메모리를 128MiB까지만 쓰게 하는 것이 그 일입니다.
- 왜 있나 — 한 기계를 여러 무리가 나눠 쓸 때 몫을 정해 두지 않으면, 한쪽이 메모리와 프로세서를 몰아 쓰고 나머지가 굶습니다.
- 어떻게 도나
- 디렉토리를 하나 만들면 묶음이 하나 생깁니다.
- 그 안의 정해진 파일에 프로세스 번호를 적어 넣으면 그 프로세스가 묶음에 듭니다.
- 한도 파일에 값을 적으면 묶음 전체에 그 한도가 걸립니다.
- 대가 — 메모리 한도에 닿아 더 줄일 수 없으면 커널이 그 묶음 안의 프로세스를 골라 죽입니다. 그리고 한도는 부모에서 자식으로 내려갈수록 좁아지기만 합니다.
상세
한 건물에 여러 세대가 살면서 전기를 나눠 씁니다. 세대마다 계량기와 차단기를 달아 두면 한 세대가 전열기를 몰아 써도 다른 세대의 불은 안 꺼집니다.
cgroup은 프로세스를 계층으로 묶고, 그 계층을 따라 시스템 자원을 정해진 방식으로 나눠 주는 장치입니다. 이름은 control group을 줄인 것입니다. 언제나 소문자로 씁니다. 기능 전체를 가리킬 때도 이 한 낱말을 쓰고, 개별 묶음 여럿을 가리킬 때만 cgroups라고 복수로 씁니다.
크게 두 부분으로 이루어집니다. 코어는 프로세스를 계층으로 묶는 일을 맡습니다. 컨트롤러는 그 계층을 따라 한 가지 자원을 나눠 주는 일을 맡습니다.
묶음들은 트리를 이룹니다. 시스템의 모든 프로세스는 오직 하나의 cgroup에 속하고, 한 프로세스의 모든 스레드는 같은 cgroup에 속합니다. 프로세스가 만들어질 때는 그때 부모 프로세스가 속해 있던 cgroup에 들어갑니다. 만들어진 뒤에 다른 cgroup으로 옮길 수 있습니다. 옮겨도 이미 있던 자손 프로세스는 따라오지 않습니다.
flowchart TD
R["루트 cgroup · 처음엔 모든 프로세스가 여기 있다"]
R --> A["자식 cgroup · 컨트롤러를 켜면 한도가 걸린다"]
R --> B["자식 cgroup"]
A --> A1["손자 cgroup · 부모 한도 안에서 더 좁힐 수만 있다"]
A --> A2["손자 cgroup"]
컨트롤러는 cgroup마다 따로 켜고 끌 수 있습니다. 다만 두 가지 제약을 지키는 선에서만 그렇습니다. 첫째, 모든 컨트롤러의 동작은 계층을 따릅니다. 어떤 cgroup에서 컨트롤러를 켜면 그 아래 하위 계층에 속한 모든 프로세스가 영향을 받습니다. 둘째, 중첩된 cgroup에서 컨트롤러를 켜면 자원 분배를 더 좁히는 쪽으로만 작용합니다. 루트에 가까운 쪽에 걸어 둔 제한은 그보다 먼 쪽에서 뒤집을 수 없습니다.
파일 트리로 드러난다
cgroup에는 판이 둘 있습니다. 먼저 나온 v1과 나중에 나온 v2입니다. 둘을 가르는 대목이 계층의 수입니다. v1은 계층을 여럿 둘 수 있었고, v2에는 계층이 하나뿐입니다. 아래 설명은 모두 v2를 기준으로 합니다.
cgroup을 만지는 창구는 전용 파일 시스템입니다. mount -t cgroup2 none $MOUNT_POINT로
올립니다. v2를 지원하면서 v1 계층에 묶이지 않은 컨트롤러는 모두 자동으로 v2 계층에 묶여
루트에 나타납니다.
처음에는 루트 cgroup 하나만 있고 모든 프로세스가 거기 속합니다. 하위 디렉토리를 만들면
(mkdir $CGROUP_NAME) 자식 cgroup이 하나 생깁니다. 각 cgroup에는 읽고 쓸 수 있는
인터페이스 파일 cgroup.procs가 있습니다. 읽으면 그 cgroup에 속한 프로세스 번호가 한 줄에
하나씩 나옵니다. 번호는 정렬돼 있지 않습니다. 읽는 도중에 프로세스가 다른 cgroup으로 갔다가
돌아오거나 번호가 재사용되면 같은 번호가 두 번 나오기도 합니다.
디렉토리 한 칸의 생김새는 이렇습니다. 소속을 적는 파일과 한도를 적는 파일이 같은 칸에 나란히 놓이고, 그 아래에 자식 칸이 붙습니다.
flowchart TD
subgraph P["cgroup 디렉토리 한 칸"]
A["cgroup.procs · 이 묶음에 든 프로세스 번호"]
B["cpu.max · 프로세서 한도"]
C["memory.max · 메모리 한도"]
D["pids.max · 프로세스 수 한도"]
end
P --> Q["하위 디렉토리 · 자식 cgroup 한 칸"]
Q --> Q1["그 안에도 같은 파일들이 다시 놓인다"]
프로세스를 옮기는 방법도 같은 파일입니다. 옮길 곳의 cgroup.procs에 그 프로세스 번호를
쓰면 됩니다. 한 번의 write(2) 호출로는 프로세스 하나만 옮길 수 있습니다. 이름 뒤의
(2)는 리눅스 매뉴얼의 2장, 곧 시스템 콜을 가리키는 표기입니다. 여러 스레드로 이루어진
프로세스라면 아무 스레드의 번호나 써도 그 프로세스의 스레드 전부가 함께 옮겨 옵니다.
어떤 프로세스가 지금 어디에 속하는지는 /proc/$PID/cgroup을 읽어서 봅니다. v2 항목은
언제나 0::$PATH 꼴입니다. 앞의 0::은 v2 항목에 늘 똑같이 붙는 머리표이고, 뒤의
$PATH가 그 프로세스가 속한 cgroup의 경로입니다.
자식도 없고 살아 있는 프로세스도 없는 cgroup은 디렉토리를 지워서(rmdir $CGROUP_NAME)
없앱니다. 자식이 없고 좀비 프로세스만 딸려 있는 cgroup은 비어 있는 것으로 쳐서 지울 수
있습니다.
배경
한 기계를 성격이 다른 여러 무리가 나눠 씁니다. 대학의 큰 서버 한 대를 학생과 교수와 시스템 작업이 같이 쓰는 상황이 그런 예입니다. 누가 무엇을 얼마나 쓸지 정해 두지 않으면 한쪽이 프로세서와 메모리와 디스크를 몰아 쓰고 나머지는 굶습니다. 커널 문서가 그 서버의 자원 계획을 이런 모양으로 그려 둡니다.
flowchart TD
T["서버 한 대 전체"]
T --> A["교수 · 메모리 50% · 디스크 50%"]
T --> B["학생 · 메모리 30% · 디스크 30%"]
T --> C["시스템 작업 · 20% · 어디서나 돌 수 있다"]
이렇게 무리별로 계획을 세우려면, 먼저 프로세스를 무리로 묶을 수단이 있어야 합니다.
그 묶는 수단을 저마다 다시 만들고 있었습니다. 리눅스 커널에서 자원 추적을 목적으로
프로세스를 모으려는 시도가 여럿 있었습니다. cpusets, CKRM/ResGroups, UserBeanCounters,
가상 서버 네임스페이스가 그런 것들입니다. 이 시도들은 전부 「프로세스를 묶고 나눈다」는 같은
기본 개념을 필요로 했고, 새로 만들어진 프로세스가 부모와 같은 묶음에 들어가야 한다는 점도
같았습니다.
그래서 커널은 그런 묶음을 만드는 데 꼭 필요한 최소한의 장치만 내주기로 했습니다. 시스템이
늘 지나는 핵심 경로에 주는 영향은 최소로 두고, cpusets 같은 개별 서브시스템이 필요한
동작을 얹을 수 있도록 훅을 열어 둡니다. 그 묶음에 붙은 이름이 control group, 줄여서
cgroup입니다.
예시
systemd 유닛의 MemoryMax=
systemd는 서비스 유닛 설정 한 줄로 그 유닛의 한도를 정합니다.
MemoryMax=512M
이 설정은 v2 계층의 memory 컨트롤러를 조종합니다. 그 유닛이 돌리는 프로세스들의 메모리
사용량에 절대 한도를 겁니다. 값은 바이트 수입니다. K·M·G·T를 붙이면 1024를 밑으로 한
킬로바이트·메가바이트·기가바이트·테라바이트로 읽습니다. 전체 물리 메모리에 대한 백분율로
적을 수도 있고, infinity를 주면 한도를 안 겁니다. 사용량을 한도 아래로 억누를 수 없으면
그 유닛 안에서 메모리 부족 킬러(out-of-memory killer)가 불려 나옵니다. systemd 문서는
MemoryHigh=를 주된 제어 수단으로 쓰고 MemoryMax=는 마지막 방어선으로 쓰기를 권합니다.
설정 한 줄이 어디로 내려가는지도 그 문서가 밝혀 둡니다. MemoryMax=는 cgroup의
memory.max 속성을 조종합니다.
쿠버네티스 파드의 resources.limits
쿠버네티스는 컨테이너마다 요청량과 한도를 적게 합니다.
resources:
requests:
memory: "64Mi"
cpu: "250m"
limits:
memory: "128Mi"
cpu: "500m"
kubelet이 파드의 컨테이너를 띄울 때 그 컨테이너의 요청량과 한도를 컨테이너 런타임에 넘깁니다. 리눅스에서는 컨테이너 런타임이 대개 커널 cgroup을 설정해 적어 둔 한도를 적용하고 강제합니다.
값마다 하는 일이 다릅니다. 프로세서 한도는 컨테이너가 쓸 수 있는 프로세서 시간의 단단한 천장이 됩니다. 스케줄링 구간마다 커널이 한도를 넘었는지 보고, 넘었으면 그 cgroup이 다시 실행되기 전까지 기다리게 합니다. 메모리 한도는 그 cgroup의 메모리 한도가 됩니다. 컨테이너가 한도보다 더 많은 메모리를 잡으려 하면 커널의 메모리 부족 서브시스템이 작동합니다. 대개는 그 메모리를 잡으려 한 컨테이너 안의 프로세스 하나를 멈춰 세웁니다.
같은 「한도 초과」인데 자원에 따라 뒤가 갈립니다.
flowchart TD
A["컨테이너가 한도에 닿았다"]
A --> B["프로세서 한도"]
A --> C["메모리 한도"]
B --> B1["다음 스케줄링 구간까지 기다리게 한다"]
B1 --> B2["기다린 뒤 다시 돈다"]
C --> C1["메모리 부족 서브시스템이 작동한다"]
C1 --> C2["프로세스 하나가 멈춰 선다"]
메모리 요청량 쪽은 주로 파드를 어느 노드에 놓을지 정할 때 씁니다. cgroup v2를 쓰는
노드에서는 컨테이너 런타임이 이 값을 memory.min과 memory.low를 정하는 힌트로 쓰기도
합니다.
도커의 --memory
도커는 컨테이너를 띄우는 명령줄 옵션으로 한도를 받습니다.
-m 또는 --memory=
컨테이너가 쓸 수 있는 메모리의 최대치입니다. 이 옵션을 쓴다면 허용되는 최솟값이 6m이라
적어도 6메가바이트는 줘야 합니다. 값은 양의 정수에 b·k·m·g를 붙여 씁니다. 도커는 단단한
한도와 무른 한도 중 어느 쪽이든 걸 수 있습니다. 단단한 한도는 정해진 양을 넘겨 쓰지 못하게
하고, 무른 한도는 호스트 쪽에 메모리가 모자라거나 경합이 생기는 것 같은 조건이 걸릴 때만
작동합니다. 무른 한도를 거는 옵션은 --memory-reservation이고, --memory보다 작은 값으로
적습니다.
갈래
컨트롤러를 가르는 축은 하나입니다. 무슨 자원을 재고 무엇을 막느냐. 종류가 여럿이지만 자주 만나는 넷은 아래와 같습니다. 각 컨트롤러는 한도를 적는 대표 파일을 하나씩 갖습니다.
cpu
프로세서 사이클의 분배를 조절합니다. CPU(Central Processing Unit, 중앙처리장치)를 나눠 쓰는 방식은 스케줄링 정책마다 다릅니다. 여기서 보통 정책은 일반 프로세스에 쓰는 기본 정책이고, 실시간 정책은 정해진 시간 안에 반드시 돌아야 하는 프로세스에 쓰는 정책입니다.
보통 정책에는 두 모델을 씁니다. 가중치 모델은 묶음마다 비율을 주고 그 비율대로 나눕니다. 절대 대역폭 한도 모델은 구간마다 쓸 수 있는 최대치를 못 박습니다. 실시간 정책에는 절대 대역폭 할당 모델을 씁니다.
한도는 cpu.max에 적습니다. 루트가 아닌 cgroup에만 있는 두 값짜리 파일입니다. 기본값은
max 100000입니다. $MAX $PERIOD 꼴로 적습니다. 그 묶음이 $PERIOD 길이의 구간마다
$MAX까지 쓸 수 있다는 뜻입니다. $MAX 자리에 max를 적으면 한도가 없습니다.
memory
메모리의 분배를 조절합니다. 메모리는 상태를 가지는 자원이라 한도 모델과 보호 모델을 둘 다 둡니다. 쓴 양과 회수 압력이 서로 얽혀 있어서 분배 모델이 복잡합니다. 회수 압력은 메모리가 모자랄 때 커널이 이미 나눠 준 메모리를 되가져가려는 힘을 말합니다.
한 cgroup의 주요 메모리 사용은 물샐틈없지는 않아도 대체로 추적됩니다. 그래서 총 소비량을 계산하고 웬만큼 제어할 수 있습니다. 지금 추적하는 것은 셋입니다. 사용자 영역 메모리(페이지 캐시와 익명 메모리), dentry와 inode 같은 커널 자료구조, 그리고 TCP(Transmission Control Protocol, 전송 제어 규약) 소켓 버퍼입니다. dentry는 파일 이름과 그 파일을 잇는 커널 안의 기록이고, inode는 파일 자체의 정보를 담은 기록입니다.
한도는 memory.max에 적습니다. 루트가 아닌 cgroup에만 있는 한 값짜리 파일입니다. 기본값은
max입니다. 메모리 사용량의 단단한 한도이고, cgroup의 사용량을 제한하는 주된 수단입니다.
어떤 cgroup의 사용량이 이 한도에 닿았는데 줄일 수 없으면 그 cgroup 안에서 메모리 부족
킬러가 불려 나옵니다. 어떤 상황에서는 사용량이 잠깐 한도를 넘기도 합니다.
io
입출력 자원의 분배를 조절합니다. 가중치에 따른 분배와, 절대 대역폭이나 초당 입출력 횟수로 자르는 분배를 둘 다 둡니다.
한도는 io.max에 적습니다. 루트가 아닌 cgroup에만 있고, 줄마다 $MAJ:$MIN 장치 번호를
키로 씁니다. 줄 안에는 네 가지 키를 쓸 수 있습니다.
| 키 | 무엇을 자르나 |
|---|---|
rbps |
초당 읽는 최대 바이트 수 |
wbps |
초당 쓰는 최대 바이트 수 |
riops |
초당 읽기 작업의 최대 횟수 |
wiops |
초당 쓰기 작업의 최대 횟수 |
8:16 장치에 읽기 초당 2MiB, 쓰기 초당 120회를 거는 것은 이렇게 적습니다.
echo "8:16 rbps=2097152 wiops=120" > io.max
pids
프로세스 수를 조절합니다. 정해진 한도에 닿은 뒤로는 그 cgroup이 새 작업을 fork()나
clone()으로 만들지 못하게 막습니다.
따로 컨트롤러를 둔 까닭이 있습니다. 작업 수는 다른 컨트롤러가 못 막는 방식으로도 고갈될 수 있습니다. 자기를 끝없이 복제하는 fork 폭탄이 그런 예입니다. fork 폭탄은 메모리 제한에 걸리기 전에 작업 수를 먼저 소진할 가능성이 높습니다.
한도는 pids.max에 적습니다. 루트가 아닌 cgroup에만 있는 한 값짜리 파일입니다. 기본값은
max입니다. 프로세스 수의 단단한 한도입니다. 지금 몇 개인지는 pids.current에서 읽습니다.
그 cgroup과 그 자손에 속한 프로세스의 수입니다.
경계
컨테이너 둘을 띄우면 서로의 프로세스 목록이 안 보입니다. 이것도 cgroup이 하는 일일까요. 아닙니다.
cgroup이 맡은 것은 프로세스를 계층으로 묶고 그 계층을 따라 시스템 자원을 나눠 주는 일까지입니다. 무엇을 얼마나 쓰게 할지가 그 범위입니다. 무엇이 보이느냐를 가르는 것은 네임스페이스의 몫입니다. 컨테이너 하나는 두 기능을 같이 써서 만들어집니다.
flowchart TD
subgraph K["컨테이너 하나를 이루는 커널 기능"]
A["cgroup · 얼마나 쓰나"]
B["네임스페이스 · 무엇이 보이나"]
end
B --> B1["cgroup 네임스페이스 · CLONE_NEWCGROUP"]
이름이 겹치는 한 칸이 그림의 아래쪽입니다. 네임스페이스의 종류 가운데 하나가 cgroup
네임스페이스(CLONE_NEWCGROUP)이고, 이것이 가리는 것은 프로세스에게 보이는 cgroup 루트
디렉토리입니다. 이름에 cgroup이 들어가도 가리는 일을 하는 쪽은 네임스페이스입니다.
관련 항목
cgroup이 재고 나눠 주는 시스템 자원
CPU · 메모리 · 디스크 입출력 · 페이지 캐시 · TCP · 프로세스 · 스레드
cgroup과 함께 컨테이너를 이루는 커널 기능
네임스페이스 · 커널 · 시스템 콜 · 파일 시스템 · 마운트 · 사용자 공간
cgroup을 설정 한 줄로 감싸 쓰는 제품
systemd · 도커 · 쿠버네티스 · 컨테이너 런타임 · 컨테이너 · kubelet
한도에 닿았을 때 커널이 하는 조치
메모리 부족 킬러 · 스로틀링 · fork 폭탄 · 스케줄링 · 메모리 회수
cgroup이 속한 상위 분류
다른 이름: control group · 컨트롤 그룹 · 제어 그룹 · cgroups · cgroup v2