1. 막힌 자리
11부에서 앱이 제대로 도는지 검증했고, 12부에서는 대시보드를 보면서 파드 스펙을 실측한다. queue와 booking에 메모리와 CPU를 얼마나 줘야 하는지를 부하로 재서 정하는 것이 목표다.
그런데 앱 쪽을 재기 전에 앞이 막혔다. 노트북 한 대에 노드 3대를 올린 클러스터라 자원이 정해져 있다. 지금 파드들에 걸린 requests와 limits가 그 안에서 어떻게 나뉘어 있는지를 모르는 상태였다.
그것을 모른 채 부하를 걸면 앱이 느린 것인지 자리가 없는 것인지 갈리지 않는다.
그래서 12부의 순서가 인프라 대시보드 → 앱 대시보드가 됐다. 이 글은 그중 앞쪽, 인프라 대시보드에서 노드 메모리를 분석하다 막힌 것을 정리한 기록이다.
노드 한 대가 이렇게 나뉘어 있다
k3s 노드는 3대이고 셋 다 물리 메모리가 같다. 한 대를 그리면 이렇다. (단위 MiB는 1,048,576 바이트다. 1000 단위인 MB와 다르고, 파드 스펙에 적는 Mi와 같다.)
k3s-1 물리 7,941 MiB
┌───────────────────────────────────────────────────────────────┐
│ 파드가 도는 자리 상한 5,381 MiB │
│ 지금 차 있는 양 3,671 MiB │
│ 스케줄러가 보고 판단하는 선 5,081 MiB │
├───────────────────────────────────────────────────────────────┤
│ 시스템 몫 (파드에게 안 주고 남긴 것) 예약 2,560 MiB │
│ 지금 쓰는 양 2,235 MiB │
└───────────────────────────────────────────────────────────────┘
여기 나오는 다음의 셋은 이 글에서 차례로 다룬다. 지금은 이렇게만 잡아 둔다.
파드가 도는 자리 커널이 파드를 한 칸에 가둔다. 그 칸이 cgroup 이다 → 3절
파드는 이 칸 밖으로 못 나간다
시스템 예약 kubelet · containerd · sshd 몫을 미리 뗀 것 → 4절
상한 5,381 과 선 5,081 이 다른 이유도 거기서
지금 차 있는 양 이 숫자를 재는 것이 cAdvisor 다 → 3절
대시보드로 보면서 던지게 되는 질문
이 노드에 파드를 더 앉힐 수 있나
지금 쓰고 있는 것 중 어디까지가 파드 몫이고 어디부터가 시스템 몫인가
노드마다 떼어 둔 예약 2,560 MiB 를 줄일 수 있나
셋 다 숫자로 답해야 한다. 그런데 어떤 숫자를 어디서 가져올지가 안 정해진다. 클러스터에서 메모리 숫자를 내는 곳이 넷인데, 넷이 서로 다른 값을 낸다.
node_* prometheus-node-exporter
container_* cAdvisor kubelet 안에 있다
kubelet_* kubelet 자신
kube_* kube-state-metrics
같은 노드의 같은 시각을 재는데 값이 다르다. 어느 것이 맞는지가 아니라 각자 무엇을 재려고 만들어졌는지를 모르면 어느 것을 쓸지 고를 수 없다.
순서는 층을 따라간다. 리눅스가 메모리를 다루는 방식, 그 위에 cgroup이 얹은 것, 다시 그 위에 쿠버네티스가 얹은 것. 층마다 그 층을 재는 곳이 하나씩 나온다.
2. 리눅스 층 — 커널이 도로 가져갈 수 있는 것과 없는 것
메모리를 나눠 주는 주체는 커널이다. 프로그램이 직접 가져가는 것이 아니라 커널에게 요청하고 커널이 배정한다.
그래서 메모리가 다 찼을 때 판단하는 것도 커널이다. 커널은 새 요청을 거절하거나, 이미 배정한 것을 도로 가져와 그 자리를 내준다. 뒤쪽을 회수(reclaim) 라 부르고, 이 글에서 계속 나오는 말이다.
회수할 수 있는지는 그 메모리가 어디서 왔느냐로 갈린다
프로그램이 커널에게 하는 요청이 두 가지이고, 결과가 각각 다른 종류로 남는다.
① 메모리를 달라고 한다
→ 커널이 빈 자리를 준다
→ 프로그램이 거기에 계산 결과를 넣는다 = 익명 메모리
그 값은 이 프로그램이 만든 것이라 디스크 어디에도 없다
커널이 가져오면 없어진다
→ 회수 못 한다
② 파일을 읽어 달라고 한다
→ 커널이 디스크에서 읽어 메모리에 복사한다
→ 프로그램이 그 사본을 읽는다
→ 다 읽은 뒤에도 커널이 사본을 안 지운다 = 페이지 캐시
원본이 디스크에 그대로 있다
커널이 가져와도 다시 읽으면 된다
→ 회수할 수 있다
②에서 복사 단계가 빠질 수 없는 이유는 CPU가 디스크를 직접 못 읽기 때문이다. 커널이 먼저 메모리로 올려야 프로그램이 그 내용을 쓴다.
이 클러스터에서 각각이 무엇인지 보면 갈린다.
익명 메모리 Kafka 브로커 JVM 힙 607 · MySQL InnoDB 버퍼 풀 433
Mimir 가 들고 있는 최근 시리즈 401 · k3s.service 1,708
페이지 캐시 Mimir 가 디스크에 쓴 블록 파일 358 · Alloy 195
카프카 로그 세그먼트 159 · 컨테이너 이미지 레이어 · journald 로그
같은 파드가 양쪽에 다 나온다. mimir-ingester-0은 최근 데이터를 메모리에 들고 있고(익명 401), 자기가 디스크에 쓴 블록 파일을 읽으면 그 사본이 남는다(캐시 358).
한 파드의 메모리가 두 종류로 갈려 있고, 커널이 가져갈 수 있는 것은 뒤쪽뿐이다.
커널은 캐시를 안 지우고 쌓아 둔다
② 페이지 캐시는 다 읽은 뒤에도 사본을 안 지우는 것이 목적이다. 같은 파일을 또 읽을 때 복사 단계를 건너뛴다.
그래서 캐시가 크다는 것은 메모리가 모자라다는 신호가 아니다. 커널은 빈 자리를 비워 두느니 사본으로 채운다. 모자라지면 이것부터 줄인다.
커널이 이 상태를 파일 하나에 적어 둔다 — /proc/meminfo
지금까지 나온 값들을 커널이 /proc/meminfo에 적어 둔다. 디스크에 있는 파일이 아니라 읽을 때마다 커널이 만들어 주는 것이고, 리눅스에서 메모리를 보는 명령은 전부 이 파일을 읽는다.
여기 적힌 항목 이름이 그대로 메트릭 이름이 된다. k3s-1에서 읽으면 50줄쯤 나오는데, 이 글에 걸리는 것만 뽑으면 이렇다.
$ cat /proc/meminfo
MemTotal: 8131780 kB 7,941 MiB 물리 전체
MemFree: 241060 kB 235 아무것도 안 담기지 않은 자리
MemAvailable: 3265556 kB 3,189 커널이 계산한 여유
AnonPages: 4365980 kB 4,264 익명 메모리 회수 못 한다
Cached: 3019452 kB 2,949 페이지 캐시 회수할 수 있다
Buffers: 201788 kB 197 블록 장치 메타데이터
SReclaimable: 78256 kB 76 커널 자료구조 중 회수 가능분
단위가 kB라 1024로 나눠야 MiB가 된다. 나눈 값을 오른쪽에 적어 뒀고, 이 글의 숫자는 전부 MiB다.
물리 전체를 항목으로 채우면 이렇게 맞는다.
AnonPages 4,264 + Cached 2,949 + Buffers 197 + MemFree 235 = 7,645
MemTotal 7,941 − 7,645 = 296 커널 자신이 쓰는 자료구조
Slab 178 · PageTables 26 · KernelStack 15 …
커널은 회수할 차례를 미리 정해 둔다 — Active 와 Inactive
같은 출력에 Active와 Inactive가 있고 그 아래 (anon)·(file)로 또 갈린다. 메모리가 모자란 순간에 무엇을 가져갈지 뒤지면 늦으니, 평소에 목록을 만들어 두고 회수할 때 앞에서부터 집는다.
Active 최근에 쓰였다. 나중에 가져간다
Inactive 한동안 안 쓰였다. 먼저 가져간다
처음 읽으면 Inactive → 다시 읽히면 Active → 안 읽히면 되돌아온다
회수 후보가 될 수 있는 것은 종류와 상관없이 전부 이 목록에 들어간다. 그래서 2×2가 된다.
Active Inactive
익명 메모리 (anon) 3,928 323
페이지 캐시 (file) 1,649 1,482
───── ─────
5,577 1,805 출력의 Active · Inactive 와 맞는다
다만 목록에 올랐다고 다 회수되는 것은 아니다. 캐시는 원본이 디스크에 있어 버리면 되지만, 익명은 버리면 값이 사라져 내보낼 자리가 따로 있어야 한다. 이 노드에는 그 자리가 없다.
그래서 커널이 실제로 가져갈 수 있는 것은 페이지 캐시, Active(file) 1,649와 Inactive(file) 1,482뿐이다.
익명 메모리는 Inactive(anon) 323은 목록에 올라 있어도 못 가져간다.
커널이 계산해 주는 여유 — MemAvailable
"지금 새 프로그램을 띄우면 얼마까지 받을 수 있나"에 답하려면 비어 있는 자리와 회수할 수 있는 것을 더해야 한다.
MemAvailable = MemFree + 페이지 캐시(Active+InActive) + ~~ = 회수 가능한 사용 가능한 값
이 계산을 사람이 하지 않도록 커널이 직접 해서 같은 파일에 적어 둔다. 그 항목이 MemAvailable이고 값이 3,189다.
커널이 무엇을 더하는지 항목으로 맞춰 보면 이렇다.
MemFree 235 지금 비어 있는 자리
파일 캐시 3,131 Active(file) 1,649 + Inactive(file) 1,482
SReclaimable 76 커널 자료구조 중 버릴 수 있는 것
─────
3,442 다 긁어모으면
MemAvailable 3,189 커널이 내놓은 답
─────
253 안전분으로 남겨 둔 몫
Active(file)와 Inactive(file)가 똑같이 더해진다. 버리는 순서만 다를 뿐 커널은 둘 다 "버려도 되는 것"으로 센다. 이 구분이 갈리는 자리는 3절이다.
안전분 253을 빼는 이유는 여기까지 내려가면 커널이 메모리 만드는 일에 매달려 시스템이 느려지기 때문이다.
이것이 node-exporter 가 내는 값이다
node-exporter는 리눅스 서버 한 대를 감시할 때 쓰는 표준 도구다. 쿠버네티스와 무관하게 서버가 있으면 붙여서 CPU·메모리·디스크·네트워크·파일시스템을 본다. 커널이 /proc과 /sys에 적어 두는 값을 읽어 내보내는 것이 전부다.
지금까지 나온 값도 전부 커널이 /proc/meminfo에 적어 둔 것이고, 그 파일을 읽어 Mimir로 보내는 것이 node-exporter다.
node-exporter는 컨테이너 안에서 도는데 호스트 파일을 읽는다. 또한 DaemonSet으로 노드마다 파드가 하나씩 뜨고, 그 파드에 호스트가 통째로 붙어 있다.
hostPID: true 호스트의 프로세스 이름공간을 그대로 쓴다
proc -> /proc 호스트의 /proc 를 컨테이너 안에 붙였다
sys -> /sys
root -> /
컨테이너는 보통 자기 안쪽만 본다. node-exporter는 호스트 파일을 붙여서 노드 전체를 본다. 파드로 떠 있으면서도 노드 값을 내는 것이 그래서다. 이 클러스터에서 observability-host 네임스페이스에 따로 있는 것도 이 권한을 요구하기 때문이다.
하는 일은 읽어서 형식만 바꾸는 것이다. 세는 것도 계산도 커널이 한다. 항목 이름 앞에 node_memory_가 붙고 뒤에 _bytes가 붙어서, MemAvailable은 node_memory_MemAvailable_bytes가 된다.
이 값이 필요한 이유는 노드 전체 상태를 아는 곳이 커널뿐이기 때문이다. 한계는 컨테이너를 모른다는 것이다
/proc/meminfo에 컨테이너별 항목이 없으니 낼 수도 없다.
답할 수 있다 이 노드에 지금 얼마까지 더 줄 수 있나 MemAvailable 3,189
답 못 한다 이 파드가 얼마 쓰나
queue 가 자기 상한 256Mi 를 넘었나
그런데 노드 한 대에 파드가 여럿 산다
k3s-1에는 queue·booking·MySQL·Kafka·Mimir가 같이 앉아 있다. 그중 하나가 메모리를 계속 먹으면 노드가 꽉 차고, 그 상태로 두면 커널이 프로세스를 골라 죽인다. 원인을 만든 파드가 아니라 멀쩡한 파드가 죽을 수 있다.
그래서 쿠버네티스는 파드마다 "여기까지"를 정해 두고 넘은 파드만 멈춘다. 그러려면 둘이 있어야 한다.
파드별로 얼마 쓰는지 센다 누가 넘었는지 알아야 고른다
그 파드만 멈춘다 나머지는 살려야 한다
node-exporter로는 둘 다 안 된다. 노드 전체 합만 내므로 MemAvailable이 줄어드는 것은 보이지만 누가 먹는지 모르고, 안다 해도 그 파드만 멈추게 할 수단이 없다.
그런데 리눅스에는 이 둘을 다 하는 기능이 이미 있다. 쿠버네티스가 그것을 가져다 쓴다.
3. cgroup 층 — 프로세스 묶음별로 세고 상한을 건다
위에서 말한 파드를 다루는 리눅스에서의 기능이 cgroup(control group) 이다.
다만 cgroup은 리눅스 기능이라 파드를 모른다. 아는 것은 프로세스뿐이다.
프로세스를 묶는다
그 묶음이 쓴 양을 따로 센다
상한을 걸고, 넘으면 그 안의 프로세스를 죽인다
파드를 이 위에 얹을 수 있는 이유는 "파드도 결국 프로세스 묶음"이기 때문이다. 컨테이너가 프로세스이고 파드는 그 컨테이너들을 묶은 것이라, cgroup이 원래 하던 일에 그대로 들어맞는다.
컨테이너가 곧 cgroup 이다
컨테이너는 특별한 실행 방식이 아니라 그냥 리눅스 프로세스다. 그 프로세스에 상한을 걸고 시야를 가려 둔 것을 컨테이너라 부른다.
cgroup 얼마나 쓸 수 있나
메모리 · CPU · 디스크 I/O
namespace 무엇이 보이나. 종류별로 따로 있다
pid 다른 프로세스가 안 보인다. ps 를 쳐도 자기 것만 나온다
net 자기 IP · 포트 · 라우팅을 갖는다
mnt 자기 파일시스템만 보인다. 호스트의 / 가 안 보인다
uts 호스트명을 따로 갖는다
여기 쓰는 namespace는 리눅스 커널 기능이고, 쿠버네티스의 Namespace(app·data·observability)와는 이름만 같고 관계가 없다.
파드를 띄우는 순서로 보면 이렇다.
kubelet 이 파드 하나를 띄우기로 한다
→ containerd 에게 그 안의 컨테이너를 만들라고 한다
→ containerd 가 cgroup 을 만들고 상한을 적는다 queue 컨테이너는 256Mi
→ namespace 를 만들어 시야를 가린다 pid · net · mnt …
→ 그 안에서 프로세스를 실행한다
→ 그 프로세스가 곧 컨테이너다
이 중 network namespace 는 컨테이너마다가 아니라 파드에 하나다. 왜 그런지가 바로 아래 pause에서 나온다.
쿠버네티스가 새로 만든 격리 장치가 있는 게 아니라 리눅스가 원래 갖고 있던 것을 쓴다. 파드 스펙에 적는 limits 가 cgroup 상한으로 그대로 옮겨진다.
cgroup 은 계층이고, 층마다 상한이 걸린다
/ 루트. 노드의 모든 프로세스
├─ /kubepods.slice 파드 전부
│ ├─ memory.stat 내역. anon · file · active · inactive
│ ├─ memory.current 지금 쓰는 총량
│ ├─ memory.max 넘을 수 없는 선. 파드에게 내주는 총량
│ └─ 파드 하나 상한 = 그 안 limits 합
│ ├─ pause 컨테이너
│ └─ 앱 컨테이너 상한 = limits. queue 는 256Mi
└─ /system.slice systemd 서비스. 상한 없음
├─ k3s.service apiserver · etcd · kubelet · containerd
│ → kube-reserved 2,048 로 떼어 둔 몫
└─ sshd.service 등 그 외 OS 서비스
→ system-reserved 512 로 떼어 둔 몫

*쿠버네티스 config.yaml : "이 클러스터는 어떤 규칙과 자원 배정으로 운영하겠다"라고 작성하는 기반 설정 파일
여기서 파드 몫과 시스템 몫이 갈린다
/kubepods.slice가 파드 전부를 담는 층이므로, 그 안과 밖이 곧 파드 몫과 시스템 몫이다.
파드 몫 /kubepods.slice
시스템 몫 루트 전체 − /kubepods.slice
cgroup이 메모리를 새로 분류하는 것은 아니다. 익명이냐 캐시냐는 그대로 두고, 거기에 누가 쓰느냐는 축을 하나 더한다. 그래서 두 축이 교차한다.
노드 전체 파드 시스템
익명 메모리 4,264 2,503 1,761
페이지 캐시 3,146 2,046 1,100
세로 익명이냐 페이지 캐시냐 커널이 가른다. 회수 가능 여부가 갈린다
가로 파드가 쓰는 것이냐 아니냐 /kubepods.slice 안이냐 밖이냐
여기 페이지 캐시 3,146은 2절에서 본 Cached 2,949에 Buffers 197을 더한 값이다. cgroup은 둘을 file 하나로 센다.
k3s.service가 시스템 쪽에 있는 이유도 여기 있다. 파드가 아니라 노드에서 직접 도는 프로세스라 /kubepods.slice 밖이다. 시스템 익명 메모리 1,761 중 대부분이 이 프로세스 하나다.
cgroup 이 세는 값 — 분류는 노드 층과 같다
cgroup 하나가 디렉터리 하나다. 위의 트리는 개념도가 아니라 /sys/fs/cgroup 아래에 실제로 있는 디렉터리 구조이고, 트리에 적은 세 파일도 실물이다.
/proc/meminfo가 노드 전체를 익명과 캐시로 나눴듯, cgroup도 자기 범위를 같은 항목(익명 / 캐시)으로 나눈다.
그 내역이 memory.stat에 있다.
$ cat /sys/fs/cgroup/kubepods.slice/memory.stat
anon 2624626688 파드 전부의 익명 메모리 2,503 MiB
file 2145509376 파드 전부의 페이지 캐시 2,046
kernel 76140544 커널 자료구조 73
... inactive_file · active_file 이 이어진다
AnonPages·Cached가 anon·file로 이름만 바뀌었고 범위가 파드 전부로 좁혀진 것이다. 이 파일이 층마다 있어서, 컨테이너 cgroup에서 읽으면 그 컨테이너 하나의 익명과 캐시가 나온다.
컨테이너가 읽은 파일의 캐시는 그 컨테이너 몫으로 잡히므로, 로그를 많이 쓰는 컨테이너는 file이 그만큼 크다.
노드 층에 없는 것 — 상한
memory.stat이 내역이라면, 총량과 상한은 파일이 따로 있다. 성격이 다르다 — 하나는 측정이고 하나는 선언이다.
memory.current 파드 전부가 지금 쓰는 총량 4,622 MiB
측정. 커널이 센다. 계속 움직인다
= stat 의 합. anon 2,503 + file 2,046 + kernel 73
memory.max 파드 전부가 넘을 수 없는 선 5,381 MiB
선언. kubelet 이 적는다. 고정
= 물리 7,941 − 예약 2,560
memory.max의 뺄셈에 들어가는 예약 2,560이 노드 config.yaml에 직접 적은 그 값(= /system.slice)이다.
config.yaml kube-reserved=memory=2Gi · system-reserved=memory=512Mi
→ kubelet 이 부팅하며 읽고 계산한다 7,941 − 2,048 − 512 = 5,381
→ /sys/fs/cgroup/kubepods.slice/memory.max 에 쓴다
→ 커널이 그 선을 강제한다
YAML에 적은 숫자가 커널 파일까지 내려간 것이다.
memory.max가 노드 층과 갈리는 지점이기도 하다. 노드에는 "여기까지"라는 파일이 없다. 물리 메모리가 곧 한계이고, 다 차면 커널이 알아서 회수하거나 죽인다. cgroup에는 그 선이 파일로 적혀 있고, 넘는 순간 그 cgroup 안에서만 커널이 개입한다.
컨테이너 cgroup의 memory.max도 같은 방식이고 적는 쪽만 다르다. /kubepods.slice는 kubelet이, 컨테이너 것은 containerd가 적는다.
그런데 커널은 이 둘을 그대로 비교하지 않는다
memory.current 4,622를 memory.max 5,381과 그대로 견주면 파드 전부가 상한의 86%까지 찬 것이 된다. 남은 자리가 759 MiB니 꽤나 용량이 부족한 것처럼 보인다.
그런데 그 파드 현재 총 사용량(current, 4,622) 안에는 파드들의 페이지 캐시(file, 2,046)가 들어 있다. 커널이 언제든 가져갈 수 있는 몫이다.(노드에서처럼)
memory.current 가 memory.max 5,381 에 닿는다
→ 커널이 그 cgroup 의 페이지 캐시부터 회수한다 2,046 이 줄어든다
→ 자리가 생기면 파드는 그대로 계속 돈다
→ 회수해도 안 줄어들 때만 죽인다 익명 메모리는 못 줄이니까
"캐시부터 회수한다"는 것은 그 cgroup의 file(페이지 캐시)2,046을 가져간다는 뜻이고,
제일 중요한 건 그 안에서도 순서가 있다. 노드 전체를 Active·Inactive로 갈라 두었듯 cgroup 안에서도 같은 구분이 있으며, inactive_file 즉, 한동안 안 읽힌 페이지 캐시를 커널이 먼저 가져간다.
memory.stat 의 file 2,046 페이지 캐시
├─ inactive_file 한동안 안 읽힌 것. 커널이 먼저 가져간다
└─ active_file 최근에 읽힌 것. 그래도 모자랄 때 가져간다
memory.stat 의 anon 2,503 익명 메모리
├─ inactive_anon 같은 구분이 여기도 있다
└─ active_anon 그런데 이 노드에서는 둘 다 못 가져간다
버리면 값이 사라지고, 내보낼 자리가 없다
죽고 사는 것을 가르는 값은 memory.current가 아니다. 그 안에 커널이 가져갈 몫(페이지 캐시의 inactive_file 우선)이 섞여 있는 한, 상한에 닿아도 회수로 넘어간다. "지금 얼마나 위험한가"를 알려면 사라질 것을 미리 뺀 값이 필요하다.
그 값이 working_set 이다
memory.current에서 커널이 첫 번째로 가져갈 몫(페이지 캐시 - inactive_file)만 뺀다.
working_set = memory.current − inactive_file
= 익명 메모리 anon
+ 페이지 캐시 중 active_file
+ 커널 자료 kernel
빼고 남기는 기준은 회수 가능 여부가 아니라, 회수했을 때 이 파드가 손해를 보느냐다.
| 항목 | 커널이 회수하면 | working_set 에서 |
|---|---|---|
inactive_file |
아무 일 없다. 곧 없어질 몫이다 | 뺀다 |
active_file |
그 파드가 디스크를 다시 읽는다 | 남긴다 |
anon |
값이 사라진다. 회수 자체가 안 된다 | 남긴다 |
그래서 working_set은 이 파드가 손해 없이는 못 놓는 양이다. 이 기준으로 파드 전부를 다시 재면 3,671이고, 같은 순간의 세 값이 이렇게 벌어진다.
memory.current 4,622 ████████████████████ 86% 캐시까지 센 값
working_set 3,671 ██████████████ 68% 판정에 쓰는 값
memory.max 5,381 └───────────────────┘ 넘을 수 없는 선
여유를 구할 때도 어느 값에서 빼느냐로 답이 달라진다.
memory.max − memory.current 5,381 − 4,622 = 759
memory.max − working_set 5,381 − 3,671 = 1,710
앞쪽은 951을 적게 잡는다. current 안의 inactive_file은 파드가 더 요청하면 커널이 버리고 내줄 자리인데, 이미 쓰이는 것처럼 세기 때문이다. memory.current의 쓰임은 여기까지이고 뒤로는 working_set만 쓴다.
MemAvailable 은 노드 단위, working_set 은 파드 단위
2절에도 캐시를 다루는 계산이 하나 있었다. MemAvailable이다. 둘을 나란히 놓으면 이렇다.
| 무엇을 재나 | 범위 | 계산식 | |
|---|---|---|---|
MemAvailable |
지금 더 줄 수 있는 양 | 노드 전체 | MemFree + 페이지 캐시([ Active(file) + Inactive(file)] + SReclaimable − 안전분 |
working_set |
지금 실제로 쓰는 양 | /kubepods.slice |
memory.current − inactive_file(file) |
식에 나오는 Inactive(file)과 inactive_file은 같은 것을 가리킨다. 둘 다 페이지 캐시이고, /proc/meminfo가 쓰는 표기와 cgroup의 memory.stat이 쓰는 표기가 다를 뿐이다.
MemAvailable은 노드 단위 값이다. 노드 전체에서 지금 더 내줄 수 있는 양이고, 회수할 수 있는 것은 전부 여유로 센다.
working_set은 cgroup 단위 값이다. 그 묶음이 지금 붙들고 있는 양이고, 회수되면 사라질 것만 빼고 센다.
둘은 답이 다른 게 아니라 묻는 것이 다르다. 그래서 한 계산식 안에 섞으면 안 되고, 6절에서 고칠 것이 그 자리다.
대시보드에서 파드가 얼마 쓰는지를 볼 때 필요한 값은 working_set 쪽이다. 그 값을 어디서 얻는지가 남았다.
이것이 cAdvisor 가 내는 값이다
working_set은 cgroup 파일에 적혀 있지 않다. memory.current와 memory.stat을 읽어 계산해야 나오고, 그 계산을 해서 Mimir로 보내는 것이 cAdvisor다.
컨테이너별 자원 사용량을 재려고 구글이 만든 것을 쿠버네티스가 kubelet 안에 넣었다. 그래서 별도 파드로 뜨지 않고 노드의 :10250/metrics/cadvisor를 긁으면 나온다.
이름 앞에 container_가 붙고, 읽어 온 cgroup 파일이 그대로 메트릭이 된다.
memory.stat 의 anon → container_memory_rss
memory.stat 의 file → container_memory_cache
memory.max → container_spec_memory_limit_bytes
memory.current − inactive_file → container_memory_working_set_bytes
앞의 셋은 숫자를 형식만 바꿔 옮긴 것이고, 마지막 하나만 계산해서 만든다. 공짜로 회수될 몫(inactive_file)을 뺀 값이라 그 cgroup이 손해 없이는 못 놓는 양이 된다.
어느 위치의 cgroup 값인지는 라벨이 가른다. - cgroup은 계층 구조이기 때문에
cgroup 하나마다 시계열이 하나씩 나오는데, 이름은 전부 container_memory_working_set_bytes로 같고 중괄호 안만 다르다.
| 라벨 | 어느 범위의 working_set | 무엇과 비교하나 | 지금 값 |
|---|---|---|---|
id="/" |
노드의 모든 프로세스 | 5,906 | |
id="/kubepods.slice" |
파드 전부 | memory.max 5,381 |
3,671 |
pod="queue-xxxxx" |
파드 하나 | 그 안 limits 합 |
|
container="queue" |
컨테이너 하나 | 그 컨테이너의 limits |
container 라벨이 빈 시계열도 같이 나온다. 파드 cgroup 자체를 가리키는 것이라 pause 몫이 거기 들어 있고, 컨테이너만 세려면 container!=""로 걸러야 한다.
루트 id="/" 줄만 조립 방식이 다르다.
루트 cgroup 디렉터리에는 memory.current도 memory.stat도 없다 — 둘 다 비루트 전용 파일이고, 노드 전체는 커널이 이미 /proc/meminfo(node-exporter 역할)에서 세고 있다. 그래서 cAdvisor가 루트만은 그 파일로 같은 꼴을 조립한다.
비루트 cgroup working_set = memory.current − inactive_file
루트 "/" working_set = (MemTotal − MemFree) − Inactive(file)
"working set"을 구하는 것이기에 빼는 항의 정체는 페이지 캐시의 Inactive로 같다 — 그 범위에서 커널이 공짜로 회수할 몫.
층이 같은 이름으로 나오니 뺄셈이 그대로 쿼리가 된다.
container_memory_working_set_bytes{id="/"}
− container_memory_working_set_bytes{id="/kubepods.slice"}
= 2,235 시스템 몫
노드 전체와 파드 전부를 같은 기준으로 잰 값이라 빼도 어긋나지 않는다. 6절에서 표를 고칠 때 쓰는 것이 이 성질이다.
한계 — 지금 쓰는 양과 지금 걸린 상한까지
cAdvisor가 읽는 것은 cgroup 파일뿐이라, 거기 적혀 있는 것까지만 답한다.
답한다 파드 전부가 지금 얼마 쓰나 3,671
그 상한이 얼마인가 5,381
컨테이너 하나가 자기 limits 를 넘었나
답 못 한다 그 상한이 왜 5,381 인가
파드를 더 앉힐 자리가 있나 requests 는 cgroup 에 없다
memory.max가 7,941 − 2,560이라는 것까지는 봤지만 그 예약을 왜 그만큼 뗐는지, 스케줄러가 보는 선은 왜 또 다른지는 cgroup 밖에서 정해진다. 그 값을 계산해서 cgroup 파일에 적는 쪽이 4절이다.
4. 쿠버네티스 층 — 노드를 나누고 그 선을 cgroup 으로 옮긴다
앞의 두 층은 쿠버네티스가 없어도 있는 것이었다. 리눅스 서버라면 어디든 /proc/meminfo(node-exporter)가 있고 cgroup 디렉터리(cAdvisor)가 있다. 그 파일들을 채우는 것은 커널이다.
그중 memory.max 하나만 커널이 채운 것이 아니다. 누군가 계산해서 써 넣은 값이다. 그 일을 하는 것이 노드마다 하나씩 도는 kubelet 이고, 파드를 띄우는 것도 넘은 파드를 내보내는 것도 이 프로세스가 한다.
cAdvisor 는 kubelet 안에 있는데 층이 다르다
memory.max 파일 하나를 셋이 건드린다. 하나가 쓰고 둘이 읽는다.
쓴다 kubelet 이 파일에 5,381 을 적는다
읽는다 커널 적힌 값을 읽어 그 선을 강제한다
읽는다 cAdvisor 적힌 값을 읽어 메트릭으로 내놓는다
container_spec_memory_limit_bytes
cAdvisor는 kubelet 바이너리에 컴파일된 코드라 별도 프로세스가 아니다 — ps로 찾아도 안 나온다. 같은 프로세스 안에 있어도 cAdvisor는 읽는 쪽에만 서 있다.
그래서 층을 가르는 기준은 어느 프로세스에서 도느냐가 아니라 그 값을 누가 정했느냐다.
| 값 | 어디 적혀 있나 | 누가 정했나 |
|---|---|---|
MemTotal · MemAvailable |
/proc/meminfo |
커널이 센다 |
memory.current · memory.stat |
cgroup 파일 | 커널이 센다 |
memory.max |
cgroup 파일 | kubelet 이 계산해 쓴다 |
allocatable · requests |
노드의 파일에 없다. apiserver 에 있다 | 쿠버네티스가 정한다 |
커널이 다루는 위 둘은 초마다 움직인다. 아래 둘은 설정을 바꾸고 kubelet을 다시 띄울 때까지 고정이다. 대시보드에서 한쪽은 출렁이고 한쪽은 평평한 선으로 나온다.
뺄셈이 두 단이고, 값을 둘 뽑는다
kubelet은 부팅하며 노드 config.yaml의 예약 줄을 읽고 노드의 물리 메모리에서 뺄셈을 한다.
중간에 한 번 cgroup /kubepods.slice의 memory.max로 , 끝에 한 번 allocatable 값을 뽑아 apiserver로 보낸다.
capacity 7,941 MiB (7.76 GiB) 노드의 물리 메모리
− kube-reserved 2,048 kubelet · containerd 몫
− system-reserved 512 sshd · systemd 등 OS 몫
──────────────────────────
= 5,381 MiB /kubepods.slice 의 memory.max 에 쓴다
− eviction-hard 300 노드 여유가 이 아래면 파드를 내보낸다
──────────────────────────
= 5,081 MiB (4.96 GiB) allocatable 로 apiserver 에 보고한다
세 줄 전부 노드 config.yaml에 적어 둔 값이고, 그 프로세스들이 실제로 그만큼 쓴다는 뜻이 아니라 파드에게 안 주고 남겨 둔다는 뜻이다.

시스템 쪽을 그 안에 가둔다는 뜻도 아니다. kubelet이 상한을 적는 cgroup은 /kubepods.slice 하나라 /system.slice는 상한 없음 그대로이고, 시스템이 2,560을 넘게 써도 막는 선이 없다. 지금 시스템 몫 2,235가 예약 안에 있는 것은 강제된 결과가 아니라 관측값이다.
두 값이 다른 곳으로 가는 이유 — 읽는 쪽이 다른 데 있다
allocatable은 메트릭이 아니다. kubelet이 Node 오브젝트의 status.allocatable 필드에 실어 apiserver로 올리는 값이고, kubectl describe node로 보인다.

memory.max 같은 노드의 커널이 읽는다. cgroup 파일 하나로 끝난다
allocatable 스케줄러가 읽는다. 스케줄러는 다른 노드에 앉아 있다
쿠버네티스 클러스터의 컴포넌트인 스케줄러는 k3s-1의 /sys/fs/cgroup을 못 읽는다. 노드 사정이 클러스터에 올라가 있어야 자리를 판단할 수 있고, 그 통로가 apiserver다.
세 값이 각각 다른 것을 상대한다
파드 몫을 가리키는 값이 둘이 되고 300 MiB 어긋나지만, 어느 하나가 틀린 것이 아니다.
| 값 | 누가 보나 | 무엇과 비교하나 | 넘으면 |
|---|---|---|---|
5,081 allocatable |
스케줄러 | 이 노드에 앉은 파드의 requests 합 |
파드를 더 안 앉힌다 |
5,381 memory.max |
커널 | 파드 전부의 working_set |
그 안에서 OOM 이 난다 |
300 eviction-hard |
kubelet | 노드 여유 | 파드를 골라 내보낸다 |
스케줄러가 보는 축과 커널이 보는 축이 다르다. 5,081은 스펙에 적힌 값끼리의 덧셈에 걸리는 선이고, 5,381은 지금 실제로 쓰는 양에 걸리는 선이다. 그래서 파드 전체가 5,081을 넘게 써도 커널은 5,381까지 안 죽인다. 그 사이 300 MiB는 kubelet이 먼저 개입하도록 비워 둔 자리다.
노드 한 대를 나누는 선은 여기까지다. 5,381도 5,081도 300도 전부 노드 config.yaml에서 나온 노드 단위 값이다. 그런데 표에 출처가 다른 값이 하나 끼어 있다. 스케줄러가 5,081과 견주는 requests 합 — 노드 설정이 아니라 파드마다 스펙에 적은 값을 더한 것이다. 그 스펙으로 내려간다.
파드 하나에 적는 값 — requests 와 limits, reqeusts와 alloctable
파드 스펙에 메모리 값을 두 개 적는다. 하나가 방금 그 requests이고, limits가 그 짝이다. 노드 층에서 갈린 두 축이 여기서 하나씩 제 값을 갖는다.
requests 스케줄러의 축 노드를 고를 때 allocatable 과 견주는 재료
limits 커널의 축 이 컨테이너 cgroup 의 memory.max 가 된다
requests는 앉힐 때만 쓰인다. 스케줄러는 새 파드가 오면 노드마다 이미 앉은 파드들의 requests 합에 새 파드 몫을 더해 allocatable과 견주고, 통과한 노드 중에서 고른다.
이 덧셈에 실사용은 안 들어간다. requests를 500Mi로 적은 컨테이너가 실제로 얼마를 쓰든 스케줄러는 500으로 더하고, 500Mi를 보장해 주는 것도 아니다. 앉은 뒤의 실행에는 관여하지 않는다.
limits는 앉은 뒤를 맡는다. 컨테이너 cgroup의 memory.max가 되어 커널이 강제한다. working_set이 그 값을 넘으면 커널이 회수를 시도하고, 회수해도 못 줄이면 그 cgroup 안의 프로세스를 죽인다.
선에 걸리면 벌어지는 일이 셋이다
파드가 앉지 못하거나 사라지는 경로가 셋이고, 세 선이 하나씩 맡는다.
안 앉힌다 OOMKill eviction
누가 스케줄러 커널 kubelet
걸리는 선 allocatable 5,081 memory.max · limits eviction-hard 300
견주는 값 requests 합 working_set 노드 여유
대상 새로 오는 파드 그 cgroup 안 프로세스 노드 위 파드 중 하나
결과 Pending 으로 대기 컨테이너 재시작 Evicted 로 남는다
이벤트 기록 FailedScheduling OOMKilling Evicted
왼쪽 하나는 선언 축이고, 오른쪽 둘은 실사용 축이다. 안 앉힘은 실사용과 무관하게 난다 — 노드가 한가해도 이미 앉은 파드들의 requests 합이 allocatable을 채우고 있으면 새 파드는 Pending으로 남는다.
OOMKill은 그 컨테이너가 자기 상한을 넘은 것이라 노드에 메모리가 남아 있어도 난다. eviction은 노드 전체가 모자란 것이라 자기 상한을 안 넘은 파드도 대상이 된다.
eviction에서 kubelet이 누구를 고를지는 requests와 limits의 관계가 정한다. 그 관계에 붙은 이름이 QoS 등급이다.
Guaranteed requests == limits (CPU·메모리 모두) 나중에 내보낸다
Burstable requests < limits, 또는 하나만 적음
BestEffort 둘 다 안 적음 먼저 내보낸다
이것이 kube-state-metrics(KSM) 가 내는 값이다
kubelet이 계산한 allocatable과 파드 스펙에 적힌 requests는 apiserver에 오브젝트로 저장된다. 그것을 읽어 Mimir로 보내는 것이 kube-state-metrics(KSM)다.
노드와 무관한 유일한 출처다. 클러스터에 파드 하나로 뜨고, 읽는 곳도 apiserver 하나다. 노드마다 있을 이유가 없다 — cgroup도 /proc도 안 읽는다. 이름 앞에 kube_가 붙고, 오브젝트의 필드가 그대로 메트릭이 된다.
kube_node_status_capacity 노드가 보고한 물리 메모리
kube_node_status_allocatable kubelet 이 계산한 allocatable
kube_pod_container_resource_requests 파드 스펙에 적힌 값
이 값이 필요한 이유는 스케줄러가 보는 세계가 이쪽이기 때문이다. 파드를 어디 앉힐지는 실사용이 아니라 allocatable과 requests로 정해지므로, "이 노드에 더 앉힐 수 있나"는 이 값으로만 답이 나온다.
한계는 실사용을 모른다는 것이다. requests: 500Mi라고 적혀 있으면 그 값을 낸다. 그 컨테이너가 실제로 50Mi를 쓰든 490Mi를 쓰든 같은 값이다.
답할 수 있다 파드를 새로 앉힐 자리가 있나 allocatable − requests 합
답 못 한다 지금 실제로 얼마 쓰나
apiserver를 거쳐 오는 것도 성질을 하나 만든다. etcd 쿼럼이 깨지면 갱신을 멈추고 마지막 값으로 굳는다. 노드가 죽어도 값이 그대로 남아 있을 수 있다.
kubelet 자신도 :10250/metrics로 지표를 내지만(kubelet_*) 이 대시보드에서는 쓰지 않는다. kubelet은 값을 내는 쪽보다 판정하는 쪽으로 이 글에 나온다 — allocatable을 계산하고, cgroup 상한을 설정하고, 누구를 내보낼지 고른다.
층 셋을 한 표로
2·3·4절을 접으면 이렇게 남는다.
| 층 | 값이 적히는 곳 | 재서 내보내는 곳 | 메트릭 | 성격 |
|---|---|---|---|---|
| 리눅스(2절) | /proc/meminfo |
node-exporter | node_* |
노드 전체 합. 캐시는 전부 여유 |
| cgroup(3절) | cgroup 파일 | cAdvisor | container_* |
묶음별. active_file은 사용 |
| 쿠버네티스(4절) | apiserver 오브젝트 | kube-state-metrics(KSM) | kube_* |
선언값. 실사용과 무관, 고정 |
세 층이 캐시와 실사용을 서로 다르게 세는 것까지 성격 칸에 들어 있다. 행1이 쓰는 값은 전부 이 세 줄 어딘가에서 나온다. 이것을 들고 대시보드로 돌아간다.
5. 층의 값으로 행1 을 짠다
처음에 던진 질문 셋을 다시 놓는다.
이 노드에 파드를 더 앉힐 수 있나
어디까지가 파드 몫이고 어디부터가 시스템 몫인가
노드마다 떼어 둔 예약 2,560 을 줄일 수 있나
첫 질문은 선언 축의 질문이다. 파드를 앉히는 것은 스케줄러이고, 스케줄러는 requests 합과 allocatable만 보므로 다른 값으로는 답이 안 나온다. 나머지 둘은 실사용 축의 질문이다. 지금 실제로 쓰는 양을 파드 몫과 시스템 몫으로 갈라 세야 한다.
축이 다른 값을 한 표에 섞으면 열마다 기준을 따져 읽어야 한다. 그래서 행1을 표 두 개로 갈랐다.
선언 — 스케줄러가 보는 세계 allocatable · requests 합 · 스케줄 여유
실사용 — 커널이 보는 세계 물리 · 파드 사용 · 시스템 사용
노드 여유 · 파드 여유 · 시스템 여유


첫 질문은 선언 표가 그 자리에서 답한다. 스케줄 여유 = allocatable − requests 합이고, 재료 둘 다 kube-state-metrics가 낸다. 남은 두 질문이 실사용 표의 몫이고, 이 표에서 문제가 났다.
그라파나가 표를 만드는 방식
Mimir에 저장되는 것은 시계열이다. 메트릭 이름 + 라벨 조합 하나가 시계열 하나이고, 거기에 시각과 값이 짝으로 쌓인다. 그라파나 패널은 PromQL 쿼리를 Mimir에 보내고, 돌아온 시계열을 그린다. 패널 종류가 정하는 것은 얼마나 요청해서 어떻게 그리느냐다.
표 패널 마지막 시각의 값 하나만 요청한다
시계열마다 행 하나 — node 라벨 k3s-1·2·3 이 세 행이 된다
쿼리 하나가 열 하나 — 여섯 열이면 쿼리 여섯
추이 패널 구간을 요청해 시각순으로 선을 잇는다
정한 값 allocatable 은 평평한 선, 재는 값은 출렁이는 선
행이 노드별로 갈리려면 모든 쿼리 결과에 node 라벨이 있어야 한다. kube_*에는 있는데 node-exporter가 내는 값에는 없다. instance="192.168.0.201:9100" 형태뿐이라 그대로는 다른 출처와 안 붙는다. 그래서 노드 이름이 든 node_uname_info와 조인해 이름을 붙인다.
node_memory_MemTotal_bytes * on(instance) group_left(nodename) node_uname_info
행1의 node_* 쿼리마다 이 조각이 붙어 있는 것이 그래서다.
실사용 표의 여섯 열 — 넷이 뺄셈이다
실사용 쪽 두 질문에 답하려면 열이 여섯 필요하다.
가른다 파드 사용 · 시스템 사용
남은 양 파드 여유 · 시스템 여유
기준 물리 · 노드 여유
이 중 넷이 뺄셈으로 나온다. 뺄셈은 빼는 두 값이 같은 기준일 때만 성립한다 — 여기가 이 글이 막혔던 자리다.
시스템 사용 열이 두 층을 섞었다
행1을 처음 짤 때 시스템 사용을 이렇게 계산했다.
시스템 사용 = (MemTotal − MemAvailable) − Σ working_set{container!=""}
리눅스 층 값 cgroup 층 값
노드 전체가 쓰는 양에서 파드가 쓰는 양을 빼면 시스템이 남는다는 계산인데, 앞항과 뒤항이 다른 층에서 왔다.
이 값이 맞는지는 화면 밖에서 확인된다. 시스템 몫의 대부분은 k3s.service 프로세스 하나이므로, 노드에 들어가 그 RSS와 나란히 놓으면 된다.
k3s-1 k3s-2 k3s-3 MiB
시스템 사용 열의 값 1,356 663 360
k3s.service RSS 1,708 1,120 1,082
k3s-3에서 이 열은 시스템 전체를 360으로 계산하는데, 그 안에서 도는 프로세스 하나가 1,082다. 세 노드 모두 프로세스 하나가 시스템 전체로 계산된 값보다 크다. 식이 틀렸다.
원인 — 캐시를 반대로 세는 값끼리 뺐다
리눅스 층은 active_file을 여유로 센다 — 커널이 MemAvailable에 더해 둔다. cgroup 층은 사용으로 센다 — cAdvisor가 working_set에 남긴다. 두 값을 한 식에 넣으면 k3s-1에서 이렇게 된다.
MemTotal − MemAvailable = 4,740 노드 전체의 "사용"
MemAvailable = MemFree + 페이지 캐시 전부 + SReclaimable − 안전분
→ 캐시를 여유로 쳤다. 파드의 active_file 이 이 사용에는 없다
Σ working_set = 3,384 파드 전부의 "사용"
working_set = memory.current − inactive_file
→ active_file 을 사용으로 남겼다. 여기에는 들어 있다
─────
1,356 앞항에 없는 몫을 뒤항이 들고 있어
그만큼 한 번 더 빠진다
같은 층의 값끼리 빼면 active_file이 양쪽에 같은 방식으로 들어 있어 상쇄된다.
루트 / 의 working_set 5,906 노드 전체 (파드 + 시스템)
(MemTotal − MemFree) − Inactive(file)
/kubepods.slice 의 working_set 3,671 파드 전부
memory.current − inactive_file
─────
2,235 시스템 몫만 남는다
k3s.service RSS 1,708 을 담는다
세 노드를 다 재면 오차가 활성 캐시 양을 따라간다.
Active_file 섞은 식 cgroup 기준 차이
k3s-1 1,649 1,356 2,235 880
k3s-2 1,566 663 1,550 887
k3s-3 1,980 360 1,548 1,188
활성 캐시 양은 그 노드에 앉은 파드가 무엇을 읽느냐가 정한다. 그래서 노드마다 다르게 어긋나고, 화면만 봐서는 어느 값이 맞는지 가릴 수 없는 종류의 오류다.
파드 사용 열도 범위가 어긋난다
같은 검산에서 하나 더 나왔다. 파드 전부가 쓰는 양을 구하는 방법이 둘 있는데, 행1의 파드 사용 열은 위쪽을 쓰고 있다.
Σ working_set{container!=""} 3,384 컨테이너 시계열을 전부 더한다
working_set{id="/kubepods.slice"} 3,671 디렉터리 값을 그대로 읽는다
커널이 memory.max 와 비교하는 값
둘 다 cgroup 층이라 캐시 기준은 같은데 287 차이가 난다. 더한 값이 작은 이유는, /kubepods.slice 안에 컨테이너 시계열로 안 잡히는 몫이 있어서다.
/kubepods.slice 3,671 = 이 아래 전부의 합
├─ 파드 하나
│ ├─ pause 컨테이너 container 라벨이 없다 → Σ 에서 빠진다
│ └─ 앱 컨테이너 Σ 에 들어간다
├─ 파드 …
└─ 컨테이너 밖에 직접 얹힌 몫 죽은 컨테이너가 남긴 캐시는 부모
cgroup 으로 이관된다 → Σ 에서 빠진다
커널이 상한 5,381과 비교하는 값은 디렉터리 값 3,671이다. 개별 합 3,384로 여유를 계산하면 표가 파드 여유를 287만큼 크게 잡는다.
표가 말하는 파드 여유 5,381 − 3,384 = 1,997
커널이 실제로 허용하는 것 5,381 − 3,671 = 1,710
부하 판에서 "아직 자리 있다"로 읽은 곳에 커널은 287만큼 더 가까이 와 있는 셈이다.
고치는 원칙은 하나로 좁혀졌다.
6. 같은 기준 안에서만 뺀다
한 열의 뺄셈은 한 층의 값끼리만 한다. 바꾸기 전에, 식에 나오는 세 값을 한 번 더 세워 둔다.
MemAvailable— 노드 전체에서 지금 달라면 받을 수 있는 양.- 커널이
MemFree + 파일 캐시 전부 + SReclaimable − 안전분으로 계산해 두고, node-exporter가 낸다. - 캐시를 여유 쪽에 놓는 회계다.
- 커널이
working_set— 그 cgroup이 놓으면 손해인, 쓰는 중인 양.- cAdvisor가
memory.current − inactive_file로 계산한다. active_file을 사용 쪽에 놓는 회계라, 같은 캐시가MemAvailable과 반대편에 선다.
- cAdvisor가
allocatable— 노드가 파드에게 내주기로 선언한 총량.- kubelet이
capacity − 예약 2,560 − eviction 300 = 5,081로 계산해 apiserver에 보고한다. - 실사용과 무관하게 고정이고, 스케줄러가
requests합과 비교할 때만 쓴다.
- kubelet이
여섯 열을 이렇게 바꾼다.
물리 MemTotal 리눅스 층 · 그대로 둔다
파드 사용 Σ container{container!=""} cgroup 층
→ working_set{id="/kubepods.slice"} 같은 층 · 범위 교정
시스템 사용 (MemTotal − MemAvailable) − Σ container 리눅스−cgroup 섞임
→ working_set{id="/"} − working_set{id="/kubepods.slice"}
cgroup 층끼리
노드 여유 MemAvailable 리눅스 층
→ capacity − working_set{id="/"} 총량 − cgroup 층
파드 여유 allocatable − Σ container 선언값−cgroup 섞임
→ memory.max 5,381 − working_set{id="/kubepods.slice"}
cgroup 층끼리
시스템 여유 예약 2,560 − 시스템 사용 상수−cgroup · 그대로
블록의 약칭이 쿼리에서는 이 이름이다.
MemTotal node_memory_MemTotal_bytes 리눅스 층 · node-exporter
working_set container_memory_working_set_bytes cgroup 층 · cAdvisor
memory.max container_spec_memory_limit_bytes cgroup 층 · cAdvisor
capacity kube_node_status_capacity 쿠버네티스 층 · KSM
예약 2,560 메트릭이 아니다. config.yaml 에 적은 값
capacity는 선언값(KSM)인데 실사용 표에 껴도 어긋나지 않는다 — 안 움직이는 총량이라 캐시를 어떻게 세느냐는 회계가 아예 안 붙는다. 섞임이 문제가 되는 것은 움직이는 값들 사이다.
node_memory_MemAvailable_bytes가 행1에서 빠진다. 커널 관점의 여유라 cgroup 회계와 섞이지 않고, 실사용 표의 여유 세 칸은 전부 cgroup 기준으로 나온다.
파드 여유의 기준은 allocatable 5,081이 아니라 cgroup 상한 5,381이다. 이 열이 답하는 질문이 "기존 파드가 더 쓸 수 있나"이고, 그 선을 긋는 것은 커널의 memory.max이기 때문이다. 스케줄러 관점의 여유는 선언 표의 스케줄 여유가 따로 답하고 있다.
CPU 표는 고치지 않는다
실사용 CPU 표에도 같은 형태의 뺄셈이 있어 같은 검산을 했다.
k3s-1 k3s-2 k3s-3 millicore
node-exporter 기준 196 186 164
cgroup 기준 171 160 140
차이 +25 +26 +24
세 노드가 모두 같은 방향으로 15퍼센트 남짓 크다. 메모리처럼 노드마다 뒤집히지 않는다.
CPU에는 페이지 캐시 같은 회계 차이가 없다. 대신 node-exporter가 세는 non-idle 시간에는 cgroup에 귀속되지 않는 것이 들어간다 — 인터럽트(irq·softirq)와 steal이다. 원래 다른 것을 세는 차이이고, 노드가 실제로 태우는 CPU에는 그것도 포함이 맞다.
예약 2,560 을 줄일 수 있나
세 번째 질문은 고친 시스템 사용으로 답이 나온다.
k3s-1 k3s-2 k3s-3 MiB
시스템 사용 (cgroup 기준) 2,235 1,550 1,548
떼어 둔 예약 2,560 2,560 2,560
여유 325 1,010 1,012
예약을 2 GiB로 줄이면 k3s-1은 187 MiB가 모자란다. 섞어 뺀 값 1,356으로 계산하면 여유가 1,204로 나와 줄여도 된다는 답이 됐을 자리다. 부하 판에서 이 값이 어디까지 오르는지를 보고 정한다.
여기까지가 행1을 고치는 근거다. 남은 일은 실사용 표의 쿼리 넷을 위 식으로 바꾸는 것이고, 표가 같은 기준으로 서 있어야 부하를 걸며 읽는 숫자도 같은 기준으로 읽힌다.
'HomeLab' 카테고리의 다른 글
| 번외 — 컨테이너 메모리와 런타임 메모리 (0) | 2026.08.17 |
|---|---|
| 번외 — 클러스터 안 카프카 (1) | 2026.08.11 |
| 노트북 홈서버 k3s 구축기 - 11. 앱 검증과 시뮬레이터 (0) | 2026.08.11 |
| 노트북 홈서버 k3s 구축기 - 10. CI 와 CD (0) | 2026.08.09 |
| 번외 — CI 파이프라인 기록 (0) | 2026.08.09 |