1. 막힌 자리
번외 — 노드 메모리와 쿠버네티스 메모리를 진행할 때는 노드 한 대를 밖에서 봤다. 메모리 숫자를 내는 자리를 층으로 갈랐다.
- 리눅스 층 — 커널이 센다.
/proc/meminfo의MemTotal·MemAvailable, 지표로는 node-exporter의node_memory_*. 도로 가져갈 수 있는 것(페이지 캐시)과 없는 것(익명 메모리)으로 갈린다 - cgroup 층 — 커널이 센다. cgroup 파일의
memory.current·memory.stat, 지표로는 cAdvisor의container_memory_working_set_bytes. 프로세스 묶음별로 세고, 그 묶음에 상한을 건다 - 쿠버네티스 층 — kubelet이 계산해 적는다.
memory.max는 cgroup 파일에 쓰고allocatable·requests는 apiserver에 보고한다. 지표로는 kube-state-metrics의kube_*. 노드를 나눈 선을 cgroup 파일로 옮기는 층이다
그 구분으로 인프라 대시보드 행1 — 노드 한 대의 메모리를 선언(스케줄러가 보는 값)과 실사용(커널이 보는 값)으로 갈라 놓은 표 — 의 뺄셈을 고쳤다.
다음 작업이 스펙 재산정, 파드 스펙에 적어 둔 requests·limit을 실측으로 다시 정하는 일이었다. 둘은 하는 일이 다르다.
requests— 쿠버네티스만 아는 선언. 스케줄러가 자리를 잡을 때 쓰고, 노드가 빡빡해지면 어느 파드를 먼저 내보낼지도 정한다limit— cgroup 상한. 회수 불가한 사용이 넘으면 커널이 OOMKill 한다
컨테이너 전수의 requests·실사용·limit을 한 표로 놓으니 어긋난 모양이 셋이었다.
| 모양 | 크기 | 무엇이 문제인가 |
|---|---|---|
| requests 과대 (requests > 사용) | 4,859 MiB | 자리를 잡아 두고 안 쓴다 |
| requests 과소 (사용 > requests) | 1,018 MiB | 압박이 오면 이 파드부터 나간다 |
| limit 임박 (peak 가 limit 의 76-90%) | 컨테이너 9개 | 조금 더 쓰면 OOMKill 이다 |
과대·과소·임박 셋은 독립이 아니다. requests를 넘겨 쓰는데 limit에 여유가 많으면 선언이 작은 것이고, requests는 맞는데 limit만 좁으면 상한이 작은 것이다. requests·실사용·limit을 같이 놓아야 어느 쪽을 고칠지가 갈린다.
AI가 낸 컴포넌트 표
세 모양을 잡으려면 컨테이너마다 값을 다시 정해야 한다. 나는 AI에게 클러스터 전수 분석을 맡겼고, AI가 지금 문제가 된다고 판단한 자리를 이렇게 냈다.
| 컴포넌트 | 지금 상태 | 낸 조치 |
|---|---|---|
| Tempo | 차트 기본값 밸러스트 1,024 MiB · GOMEMLIMIT 800 MiB · GC 12.58 회/초 · CPU 120 m. go_memstats_heap_alloc 1,039 MiB 인데 process_resident_memory 109 MiB |
밸러스트를 0 으로 끈다 |
| Kafka 브로커 ×3 | -Xmx 없음 · MaxRAMPercentage 60 → 힙 상한 921 Mi · 사용 621-643 Mi |
-Xmx 512m 명시 후 재측정 |
| Argo controller | go_memstats_heap_alloc 127 Mi · process_resident_memory 359-487 Mi |
GOGC=50 · GOMEMLIMIT=384MiB |
| Strimzi operator | -Xmx 없음 · 실사용 281 Mi · request 256 Mi · limit 384 Mi |
힙을 고정한다 |
| Mimir ingester ×3 | request 768 Mi · limit 2 Gi | 실측 RSS 로 재산정 |
그 밖에 Alloy request 상향 · Loki 내장 캐시 상한 재배분 · Redis maxmemory 설정이 같이 나왔다.
"지금 상태" 열이 읽히지 않았다
Tempo 행만 봐도 그렇다. GOMEMLIMIT 800 MiB가 무엇을 거는 값인지, GC 12.58 회/초가 많은 것인지 적은 것인지, 1,039 MiB와 109 MiB가 나란히 있는 것이 정상인지. 판단이 서는 항목이 하나도 없었다.
훑어 보니 같은 낱말이 행마다 반복되고 있었고, 그 낱말들을 나는 정확히 모른다.
| 낱말 | 모르는 것 |
|---|---|
| 힙 · 스택 | 둘이 어떻게 다른지 나올 때마다 헷갈린다 |
| 런타임 | 말은 자주 보는데 무엇을 가리키는지 흐릿하다 |
| RSS | 지표 이름은 읽히는데 무엇을 잰 값인지 모른다 |
| GC · GOGC | 언제 왜 도는지 모른다 |
| -Xms · -Xmx · MaxRAMPercentage · GOMEMLIMIT | 자바 쪽에서 힙 크기를 정한 적이 있고 여기서도 같은 종류가 나오는데, 서로 어떻게 다른지 모른다 |
| 비힙 | 힙 밖에도 뭔가 있다는 것만 안다 |
낱말들이 서로 이어져 있다는 것은 안다. 한 덩어리라는 감은 있다. 그런데 이어져 있다는 것까지가 아는 전부고, 어디가 어디에 어떻게 붙는지는 모른다. 값을 왜 그렇게 정하느냐 이전에, 무엇이 문제라는 것인지조차 이해가 안 됐다.
모르는 낱말은 AI가 낸 표 밖에도 있다
같은 낱말을 나는 클러스터를 세우는 동안 이미 여러 번 적어 뒀다. AI가 낸 컴포넌트 표에 없는 것만 꼽아도 이렇다.
| 적어 둔 자리 | 적어 둔 것 |
|---|---|
| booking values | JAVA_OPTS: -XX:MaxRAMPercentage=75 |
| topicOperator | -Xms: 64m · -Xmx: 128m |
| booking 대시보드 패널 설명 | "limit 1Gi 의 75%인 768Mi가 힙이고, 나머지는 클래스 정보와 컴파일해 둔 기계어가 쓴다. 힙이 상한 가까이 붙어 있으면 GC가 잦아진다" |
나는 적을 때 설계를 AI 에 맡기고 이해는 뒤로 미뤘다.
그래서 나는 스펙 재산정을 여기서 멈추고 이 편을 쓴다. 이해를 먼저 쌓고, 내가 설계한 것을 되짚는다.
절 배치
컨테이너 밖은 리눅스 층·cgroup 층·쿠버네티스 층으로 갈랐고, 그 끝에서 나온 것이 컨테이너에 걸리는 상한 memory.max다. 이 편은 그 상한의 안쪽이다.
| 절 | 세우는 것 | 다음 절에 넘기는 것 |
|---|---|---|
| 2 | 힙 · 스택 · 스레드당 스택 | 힙 크기는 코드만 봐선 모른다 |
| 3 | 런타임 · 힙 지표 | 힙에 얼마나 있는지는 런타임만 안다 |
| 4 | VSZ · RSS | 힙 지표와 RSS 가 벌어져 있다 |
| 5 | GC · GOGC · 손잡이 | GC 페이스는 상한을 안 정한다 |
| 6 | 두 상한 · working_set · 비힙 · 사용자 수준 스레드 · 앱이 거는 상한 |
낱말이 다 선다 |
안을 파헤치다 보면 밖에서 정리해 둔 것과 다시 만난다.
| 밖에서 정리한 것 | 안에서 다시 만나는 자리 |
|---|---|
| 익명 메모리(회수 불가) / 페이지 캐시(회수 가능) | 4절 — RSS 안이 같은 기준으로 갈린다 |
| 익명 메모리의 예로 든 Kafka 브로커의 607 Mi | 6절 — 그 익명이 힙과 비힙으로 갈린다 |
memory.max — 커널이 강제하는 상한 |
6절 — 런타임이 스스로 지키는 상한과 마주 세운다 |
memory.max가 걸린 묶음 안에서 프로세스 하나가 무엇을 받는지부터 본다.
2. 힙과 스택
프로세스가 커널에게서 받는 것은 메모리 자체가 아니라 쓸 수 있는 주소의 범위다. 이것을 주소 공간이라 한다.
높은 주소
┌──────────────────┐
│ 스택 │ 함수 호출마다 쌓이고, 함수가 끝나면 사라진다
├──────────────────┤
│ (빈 공간) │
├──────────────────┤
│ 힙 │ 실행 중에 "달라"고 요청해서 받는다. 위로 자란다
├──────────────────┤
│ data · bss │ 전역 변수
├──────────────────┤
│ text │ 기계어 코드. 읽기 전용
└──────────────────┘
낮은 주소
그림은 자리만 보여 준다. 조각마다 무엇이 들어가고 크기가 언제 정해지는지가 다르다.
| 조각 | 여기 들어가는 것 | 언제 정해지나 | 분류 |
|---|---|---|---|
| text | 함수 본체를 기계어로 번역한 것 | 컴파일할 때. 실행 내내 안 변한다 | 정적 |
| data | 초기값이 있는 전역 변수 — var limit = 300000 |
컴파일할 때. 값까지 실행 파일 안에 들어 있다 | 정적 |
| bss | 초기값이 없는 전역 변수 — var total int64 |
컴파일할 때 자리만. 값은 시작할 때 0으로 채운다 | 정적 |
| 스택 | 지금 실행 중인 함수의 지역 변수·매개변수, 그 함수가 끝나면 돌아갈 자리 | 함수에 들어가면 잡히고 나오면 풀린다 | 자동 |
| 힙 | 프로그램이 "이만큼 달라"고 해서 받아 낸 자리. 요청 하나마다 만드는 객체, 네트워크나 디스크에서 읽어 들인 바이트, 나중에 쓰려고 들고 있는 캐시 — 셋 다 몇 개가 생길지를 들어오는 일감이 정한다 | 실행 중에 요청한 만큼. 언제 버릴지도 실행 중에 정해진다 | 동적 |
앞의 셋은 실행 파일에서 크기가 읽힌다. 남는 것이 스택과 힙인데, 스택은 스레드 수에 스택 크기를 곱하면 나오고 힙은 돌려 보기 전에는 모른다. 그 둘을 차례로 본다.
스택은 스레드마다 하나씩, 늘리면 값이 붙는다
text·data·bss·스택·힙으로 나뉜다는 것은 프로세스가 실행 흐름 하나일 때의 그림이다. 스레드를 여럿 돌리면 갈린다.
프로세스 하나
├─ text · data · bss 하나. 스레드 전부가 공유
├─ 힙 하나. 스레드 전부가 공유
│ 요청마다 만든 객체, 읽어 온 응답 바이트, 쌓아 둔 캐시
├─ 스레드 A ─ 스택 A 스레드 하나 몫으로 따로 예약
│ ├ main() 프레임
│ ├ handle() 프레임
│ └ parse() 프레임 ← 지금 실행 중. 반환하면 이 칸만 풀린다
└─ 스레드 B ─ 스택 B
└ ...
함수마다 생기는 것은 스택이 아니라 그 안의 프레임 한 칸이다. 스택은 스레드가 만들어질 때 한 번 잡히고, 함수를 부를 때마다 지역 변수·매개변수·돌아갈 주소를 담은 프레임이 그 안에 쌓인다. 반환하면 그 칸만 풀린다.
스택은 스레드마다 붙으니 동시 실행을 늘리면 개수도 그만큼 는다. 커널이 만들고 커널이 굴리는 이 스레드를 OS 스레드라 한다. 여기에 값이 둘 붙는다.
- 스택 크기 — 리눅스는 OS 스레드 하나에 기본 8 MiB를 예약한다. 다만 프로그램이 스레드를 만들 때 이 값을 직접 넘길 수 있고, JVM은 그렇게 한다(리눅스 x64 기본
-Xss1 MiB) - 문맥 교환 — CPU 하나가 여러 스레드를 번갈아 돌리려면 돌던 것의 실행 상태를 저장하고 다음 것을 복원해야 한다. 스레드는 이 일을 커널이 한다
그래서 스레드 개수가 늘면 커널 자원과 문맥 교환 비용이 같이 는다.
힙은 무엇이 어떻게 올라가고, 누가 버리나
먼저 올라가는 쪽이다. 소스 어디에도 "이건 힙에 둬라"라고 적지 않는다. 이 클러스터의 두 언어가 그것을 서로 다르게 정한다.
Go — 컴파일러가 가른다.
func newTicket() *Ticket {
t := Ticket{seat: "A12"} // 함수 안에서 만든 값
return &t // 그 값이 있는 자리의 주소를 밖으로 넘긴다
}
함수가 끝나면 프레임은 풀리는데 부른 쪽은 그 주소를 들고 계속 쓴다. 스택에 뒀다면 이미 없어진 자리를 가리키게 된다. 그래서 컴파일러가 코드를 훑어 함수 밖으로 새어 나가는 값만 골라 힙으로 보낸다. 같은 모양의 코드라도 밖으로 안 넘기면 스택에 남는다.
Java — new 를 쓰면 힙이다.
Ticket t = new Ticket("A12");
스택 프레임 힙
┌───────────┐ ┌─────────────────┐
│ t = 0x7f… │ ───► │ Ticket("A12") │
└───────────┘ └─────────────────┘
변수 t 는 스택 프레임 안에 있지만 그 안에 든 것은 객체가 아니라 힙에 있는 객체의 주소다. 가르는 판단이 없으니 new 를 부른 횟수가 곧 힙에 쌓인 개수가 된다.
정하는 주체는 컴파일러와 new 로 다른데, 둘 다 프로그래머가 "힙"이라고 쓰지 않고 얼마나 쌓일지는 실행 중에 들어오는 일감이 정한다. 그래서 힙이 얼마나 커질지는 코드만 읽어서 알 수 없다.
이제 버리는 쪽이다. 올라간 자리는 다 쓰고 나면 버려야 한다. 그리고 버리는 문제가 남는 것은 힙뿐이다 — text와 data는 프로세스가 죽을 때까지 그대로 있고, 스택은 함수가 끝나면 저절로 풀린다. 힙만 함수 경계와 수명이 안 묶여 있어서 자동으로 풀릴 자리가 없다. 안 버리면 계속 쌓이고(누수), 아직 쓰는 것을 버리면 프로그램이 깨진다.
그런데 버리는 문장이 어느 코드에도 없다. Go에도 Java에도 "이제 이 자리를 반납한다"고 적는 자리가 없다. 그런데도 힙이 끝없이 부풀지 않고 쓰던 객체가 사라지지도 않으니, 코드 밖에서 그 일을 하는 무엇이 있다는 뜻이다. 그것이 다음 절이다.
3. 런타임
앞 절이 질문 하나를 남겼다. 힙을 버린다고 적은 문장이 코드에 없는데, 그럼 누가 버리나.
그 "코드 밖의 무엇"의 이름이 런타임이다.
일반적으로 런타임 시스템은 스택과 힙을 구축하고 관리하는 책임이 일부 있으며 쓰레기 수집, 스레드, 그 밖에 해당 언어로 빌드되는 동적 기능들과 같은 기능들을 포함할 수 있다.
— 위키백과 · 런타임 시스템
정의에 스택·힙 관리와 쓰레기 수집이 그대로 들어 있다. 이 글에서 쓰는 말로 줄이면, 내가 짠 코드와 같은 프로세스 안에서 함께 도는 코드인데 내가 짜지 않았고 실행할 때 이미 들어와 있다.
하는 일이 셋이다.
- 힙을 잡아 준다 — 앱이 "객체 하나 만들자"고 하면 힙에서 자리를 떼어 준다. 무엇이 올라갈지는 컴파일러와
new가 정했고, 그 자리를 실제로 떼어 주는 쪽이 이것이다 - 다 쓴 것을 치운다 — 아무도 안 쓰게 된 자리를 되쓸 수 있게 만든다. 코드에 없던 그 문장이 여기 있다
- 실행 단위를 스케줄링한다 — 어느 코드를 어느 스레드 위에서 돌릴지 정한다. 셋 중 여기서 처음 나오는 것이다
어느 언어에 있고, 어디서 오나
모든 언어에 런타임이 있는 것은 아니다.
| 언어 | 컴파일 결과 | 런타임 | 이 클러스터의 파드 |
|---|---|---|---|
| C · C++ | 기계어 | 없다 — free·delete 를 직접 부른다 |
Redis · MySQL |
| Go | 기계어 | 실행 파일 안에 함께 묶여 나온다 | Tempo · Loki · Mimir · Alloy · Argo · queue |
| Java | 바이트코드 (JVM 이 읽는 중간 형식) | 그것을 읽어 돌리는 JVM 본체 libjvm.so |
Kafka 브로커 · booking · Strimzi |
C 와 Go 는 둘 다 기계어로 컴파일되는데 런타임 유무가 갈린다. 컴파일 방식이 정하는 것이 아니고, 그래서 힙을 버린다고 적는 문장(free)이 C 에만 있다.
Go 와 Java 는 둘 다 런타임이 있으니 하는 일 셋을 둘 다 한다. 다만 하는 방식이 갈리고, 셋 중 아직 안 본 것이 하나 남았다.
실행 단위는 런타임이 굴린다
여기까지가 힙이다 — 런타임이 잡아 주고 런타임이 치운다. 그런데 런타임이 손대는 것이 힙만은 아니다.
스택은 스레드마다 붙는다. 그리고 CPU 하나가 여러 스레드를 번갈아 돌리려면 매번 실행 상태를 저장하고 복원해야 한다(문맥 교환). 둘 다 커널 몫이라, 스레드를 하나 늘릴 때마다 스택이 하나 더 잡히고 커널이 할 일도 그만큼 는다.
Go 와 Java 의 런타임이 같은 우회로를 냈다. 커널이 아니라 런타임 자신이 스케줄링하는 실행 단위를 만들고, 그 위에서 코드를 돌린다.
그러면 스택도 커널이 안 잡아 준다. 런타임이 그 몫을 스스로 마련해야 하고, 어디서 마련하느냐가 갈린다.
| 누가 만들고 굴리나 | 스택 크기 | 스택이 잡히는 자리 | |
|---|---|---|---|
| OS 스레드 | 커널 | 리눅스 기본 8 MiB 예약. JVM 은 -Xss 1 MiB |
힙 밖 |
| 고루틴 (Go) | Go 런타임 | 2 KiB 로 시작해 필요하면 늘린다 | 힙 밖 |
| 가상 스레드 (Java 21) | JVM | 필요한 만큼 자란다 | 힙 안 |
셋이 세 축에서 갈린다.
- 커널이 굴리나, 런타임이 굴리나 — 고루틴과 가상 스레드는 런타임이 굴린다. 이 둘을 묶어 사용자 수준 스레드라 한다. OS 스레드를 안 쓴다는 뜻은 아니고 그 위에 얹혀서 도는 것이며, 커널이 아는 것은 OS 스레드까지다
- 런타임이 스택을 어디에 마련하나 — Go 런타임은 힙과 따로 셈하는 자리에 두고, JVM 은 가상 스레드의 스택을 힙 안에 객체로 둔다
- 그래서 그 몫을 덮는 값이 갈린다 — OS 스레드는
-Xss× 스레드 수로 힙 밖에서, 고루틴은GOMEMLIMIT안에서, 가상 스레드는-Xmx안에서 센다
이름만 짚고 넘어간다. 셋이 각각 무엇을 정하는 값이고 그 값을 넘으면 어떻게 되는지는 6절이다 — 셋이 같은 종류가 아니다. 하는 일 셋 중 셋째가 여기까지다.
힙에 얼마나 있는지는 런타임만 안다
힙을 잡아 주는 것도 치우는 것도 실행 단위를 굴리는 것도 런타임이다. 그러니 힙에 무엇이 얼마나 있는지도 런타임만 안다. 힙이 얼마나 커질지는 코드만 읽어서 알 수 없다고 했는데, 런타임이 세어 내주는 지표를 읽으면 나온다.
그 지표를 보기 전에 힙이 어디에 있는 것인지부터 놓고 본다. 힙은 커널이 프로세스에 준 주소 공간의 한 조각이었고, 그 주소 공간은 메모리 자체가 아니라 쓸 수 있는 번지 범위였다.
파드
└ 컨테이너
└ 프로세스 ─ 주소 공간 ─ text · data · bss · 스택 · 힙 ← 런타임이 아는 칸
컨테이너 하나에 프로세스 하나가 보통이다. 파드에 컨테이너가 더 있거나 앱이 자식 프로세스를 띄우면 이 줄기가 옆으로 늘어나는데, 늘어난 몫은 어차피 이 숫자 밖이다.
그 안쪽 한 칸을 세는 숫자가 이것이다.
Go go_memstats_heap_alloc_bytes 할당돼 아직 해제되지 않은 힙 객체의 합
JVM jvm_memory_used_bytes{area="heap"} 힙 영역에 들어 있는 양
둘 다 지금 그 프로세스의 힙에 객체가 얼마나 들어앉아 있는지를 센다. 주소 공간이 프로세스마다 하나씩이니 힙도 프로세스마다 하나씩이고, 이 숫자는 계측된 그 프로세스 하나의 것이다.
파드가 쓰는 메모리로 읽기에는 너무 좁다. 힙 한 칸만 세니, 같은 프로세스의 스택과 실행 파일 코드가 이미 이 숫자 밖이고 컨테이너나 프로세스가 더 있으면 그것들도 밖이다.
그래서 이 숫자로는 파드가 메모리를 얼마나 쓰는지 말할 수 없다.
힙 밖까지 세는 지표는 따로 있다. 프로세스에 물리 메모리가 얼마나 붙었는지는 OS 가 알고 있고, 그것을 내주는 것이 process_resident_memory_bytes 다. 줄여서 RSS 라 부른다.
여기까지가 이 절이 답한 것이다. 힙을 버리는 것은 런타임이고, 힙에 얼마가 들어 있는지는 런타임이 내주는 지표에 나온다. 그 숫자가 힙 한 칸짜리라는 것까지 왔다. 다음 절은 그 바깥을 세는 RSS 를 판다.
4. RSS
힙 지표는 "힙에 객체가 얼마나 들어앉았나"에 답했다. RSS 가 답하는 질문은 다르다 — "이 프로세스가 물리 메모리를 얼마나 차지하고 있나"다. 세는 쪽도 런타임이 아니라 커널이다.
이 값을 읽으려면 "물리 메모리를 차지한다"가 무슨 일인지부터 있어야 한다.
주소를 받는 것과 메모리가 붙는 것
프로세스가 커널에게서 받는 것은 메모리 자체가 아니라 쓸 수 있는 주소의 범위였다. 그 "메모리 자체"가 물리 메모리 — RAM 칩의 실제 자리다.
커널은 RAM 을 통으로 다루지 않고 4 KiB 조각으로 잘라 나눠 주며, 그 조각 하나를 물리 페이지라 한다. 어느 번지가 어느 페이지를 가리키는지는 커널이 프로세스마다 표를 하나씩 들고 관리한다.
그 표가 언제 채워지느냐가 이 절의 전부다.
프로세스가 커널에게 주소 범위를 달라고 하면 커널이 정해 주는 것은 범위뿐이다. "이 범위는 네 것"이라는 약속이고 표는 비어 있다. 물리 메모리는 한 장도 안 붙는다.
붙는 순간은 따로 있다. 그 주소에 처음 값을 써 넣을 때 커널이 그제서야 페이지 한 장을 떼어 표에 적는다. 쓰지 않은 자리는 표가 끝까지 비어 있다.
힙만 그런 것이 아니다. 힙이 모자라 런타임이 더 받아 오는 것도, 스레드를 만들 때 스택 몫을 잡는 것도, 실행 파일 코드가 올라오는 것도 전부 이 규칙을 따른다. 스택을 리눅스 기본 8 MiB 로 예약한다고 했던 것이 이 얘기다 — 주소만 잡아 둔 것이고, 함수가 깊이 안 들어가면 붙는 페이지는 몇 장뿐이다.
그래서 한 프로세스를 두고 숫자가 둘 나온다.
| 값 | 세는 것 | 지표 이름 |
|---|---|---|
| 가상 크기 (VSZ) | 약속받은 주소의 합. 1 GiB 를 잡으면 바로 1 GiB 가 는다 | process_virtual_memory_bytes |
| RSS | 물리 페이지가 붙은 합. 값을 써 넣은 만큼만 는다 | process_resident_memory_bytes |
RSS 는 Resident Set Size — 물리 메모리에 상주(resident)하는 집합의 크기라는 이름이다.
1 GiB를 잡아 놓고 한 번도 안 건드리면, VSZ 는 1 GiB 늘고 RSS 는 그대로다.
RSS 안에 무엇이 들어 있나
이 프로세스에서 값이 써진 것 전부다. 리눅스가 메모리를 가르는 기준 그대로 두 몫이다.
| RSS 안 | 무엇이 들어 있나 |
|---|---|
| 익명 메모리 | 값을 써 넣은 힙 페이지 · 스택 · 전역. 디스크에 원본이 없어 버리면 잃는다 |
| 페이지 캐시 중 이 프로세스에 매핑된 것 | 실행 파일 코드 · 공유 라이브러리. 디스크 사본이라 버려도 다시 읽으면 된다 |
그래서 RSS 는 "이 프로세스가 노드 메모리를 실제로 얼마나 점유하나"에 답한다. 컨테이너에 프로세스가 하나면 사실상 그 컨테이너의 점유량이다.
같은 프로세스를 세는 놈이 둘이다
힙 지표와 RSS, 같은 프로세스에 숫자가 둘이 됐다. 숫자만이 아니라 세는 놈까지 봐야 뜻이 정해진다.
| 지표 | 누가 세나 | 무엇을 보나 | 답하는 질문 |
|---|---|---|---|
힙 지표 go_memstats_heap_alloc |
Go 런타임 | 힙에 객체가 들어앉았나 | 힙의 내역 |
RSS process_resident_memory |
OS | 물리 페이지가 붙었나 | 프로세스의 점유량 |
서로가 서로를 대신하지 못한다. RSS 는 물리 페이지가 붙었다는 사실만 알지 그것이 힙인지 스택인지, 산 객체의 자리인지 죽은 객체가 쓰던 자리인지 모른다. 힙 지표는 객체를 알지만, 배정은 적어 두는 일일 뿐이라 물리 페이지가 붙었는지 모른다.
VSZ 까지 셋을 층으로 놓으면 이렇다.
커널이 힙으로 준 주소 범위 VSZ (다른 구역들과 함께)
└ 그중 런타임이 객체에 배정한 만큼 힙 지표 (heap_alloc)
└ 그중 값이 써져 물리 페이지가 붙은 만큼 RSS 의 힙 몫 (스택·코드와 함께)
힙 지표와 RSS 의 폭 — 죽은 객체의 자리
AI 가 낸 표의 Argo controller 행이다.
힙 지표 127 Mi
RSS 359-487 Mi
RSS 가 힙 밖까지 세니 더 큰 방향은 맞다.
그런데 힙 밖 몫(스택·실행 파일 코드)은 수십 Mi 수준이라 세 배라는 폭이 안 나온다. 폭의 정체는 힙 페이지 하나의 일생을 따라가면 보인다.
① 배정 객체가 자리에 앉는다 힙 지표 +
② 값 써짐 물리 페이지가 붙는다 RSS +
③ 객체 죽음 아무도 안 가리키게 된다 둘 다 그대로
④ 치움 런타임이 자리를 회수한다 힙 지표 −
⑤ 반납 물리 페이지를 커널에 돌려준다 RSS −
④ 치움의 이름이 GC(Garbage Collection) 다. 죽은 객체를 찾아 그 자리를 다음 객체를 앉혀도 되는 빈자리로 되돌리는 일이다. 물리 페이지를 건드리는 일이 아니라 런타임이 제 기록의 표시를 바꾸는 일이라, 이 순간 RSS 는 안 움직인다.
⑤ 반납을 런타임은 서두르지 않는다. 그 빈자리는 곧 다음 객체가 앉을 자리고, 커널에 돌려줬다가 다시 받으면 그 요청이 한 번 더 든다. 그래서 런타임은 페이지를 쥔 채 되쓴다. 새 객체가 앉으면 힙 지표만 다시 늘고 RSS 는 그대로다 — 자리는 비었다 찼다 돌지만, 페이지는 한 번 붙으면 오래 붙어 있는다.
그래서 어느 순간에 재든 힙 안에는 치워져서 다음 객체를 기다리는 페이지가 깔려 있다. 힙 지표에는 없고 RSS 에는 있는 몫 — Argo 폭의 대부분이 이것이다.
남는 질문은 시점이다. GC 가 언제 도는지가 이 폭을 정한다 — 늦게 돌수록 죽은 객체와 빈 페이지가 더 쌓인다. 그 시점의 규칙과, 사람이 그것을 만지는 값이 다음 절이다.
5. GC와 GOGC
앱이 도는 동안 힙에는 이런 일이 반복된다. 함수가 요청을 처리하며 힙에 객체를 만들고, 반환하면 프레임이 풀리면서 그 객체를 가리키던 참조도 같이 사라진다. 객체는 힙에 남았는데 가리키는 놈이 없다. 프로그램이 그 객체에 다시 닿을 길은 없고, 요청마다 이런 고아가 몇 개씩 생긴다.
GC 가 치우는 것이 정확히 이것이다 — 참조가 하나도 안 남은 객체. 닿을 수 없으면 다시 쓸 수도 없으니 쓰레기로 판정해도 안전하다. 기준은 참조 유무 하나고, 물리 페이지가 붙었는지는 판정에 안 들어간다.
찾아 치우는 데는 CPU 가 든다. 그래서 GC 는 맞바꾸기다 — 자주 돌리면 CPU 를 계속 쓰고, 미루면 죽은 객체가 물리 페이지를 깔고 앉은 채 쌓인다. 어느 쪽으로 기울일지 정하는 자리가 이 절의 손잡이다.
Go 는 살아있는 양의 두 배에서 돈다
Go 런타임에 "언제 돌라"를 정해 주는 값이 GOGC 다. 이 글에서 손잡이는 이렇게 배포 설정에 사람이 값을 넣어 런타임 동작을 바꾸는 자리를 가리킨다.
규칙은 한 줄인데, 읽으려면 힙 지표의 성질 하나가 먼저 필요하다. 죽은 객체는 치워지기 전까지 그대로 잡히므로 힙 지표 = 살아있는 객체 + 아직 안 치운 죽은 객체다. GC 가 돌면 죽은 몫이 빠져 살아있는 양까지 떨어졌다가, 일감이 들어오는 만큼 다시 쌓인다.
GOGC기본값 100 — 힙 지표가, 직전 GC 가 끝났을 때 살아있던 양의 두 배에 닿으면 돈다.
살아있는 양이 100 Mi 로 일정하다고 치고 heap_alloc 을 시간축으로 그리면 이 모양이 나온다.
힙 지표 100 ↗↗ 200 ·GC↘ 100 ↗↗ 200 ·GC↘ 100 … 차오르다 GC 에 수직으로 떨어지기를 반복
RSS ──────────── 200 언저리에서 거의 수평 ────────────
위 줄이 톱니(sawtooth) 라 부르는 그래프다. 바닥이 살아있는 양(100), 꼭대기가 두 배 선(200)이고, 그 사이를 채우며 올라가는 것은 대부분 죽은 채 쌓이는 객체다.
핵심은 아래 줄이다. 힙 지표는 GC 마다 바닥까지 떨어지는데 RSS 는 같이 안 떨어진다. 꼭대기까지 차오르는 동안 그 자리마다 값이 써져 물리 페이지가 붙었고, 커널로 돌려주는 반납은 별개 단계라 늦게 오기 때문이다.
그래서 살아있는 것은 100 인데 RSS 는 200 언저리다. 이 100 의 차이에 이름을 붙이면 둘이다.
- 헤드룸 — 두 배 선까지 차게 두는 규칙의 몫. 살아있는 양만큼이 위에 얹힌다
- 반납 지연 — 그 꼭대기가 안 내려오는 이유
이제 Argo controller 를 대 본다.
힙 지표 127 Mi
RSS 359-487 Mi 두 배로 쳐도 254 — 그보다 크다
숫자가 그림 그대로 떨어지지는 않는데, 안 떨어지는 이유가 전부 안 잰 것들이다.
- 127 이 톱니의 바닥인지 중간인지 — 한 순간의 값으로는 모른다
- RSS 가 기억하는 것은 지금 톱니가 아니라 역대 가장 높았던 꼭대기다
- 힙 밖 몫(고루틴 스택·런타임 메타데이터·실행 파일 코드)은 안 쟀다 —
go_memstats_heap_sys·heap_idle·stack_sys를 떠야 갈린다
그래서 이 그림에서 가져갈 것은 숫자가 아니라 방향이다. 살아있는 양보다 몇 배 큰 RSS 는 바빠서 생기는 것이 아니라 이 구조가 만든다.
GOGC 를 낮추는 것은 두 배 선을 끌어내리는 것이다. GOGC=50 이면 살아있는 양의 1.5배 — 위 예에서 150 — 에서 돌아 톱니 꼭대기가 낮아지고, RSS 가 따라 내려온다. 대신 같은 쓰레기를 더 자주 치우니 CPU 를 더 쓴다. 메모리를 줄이는 대신 CPU 를 내는 손잡이고, Argo 의 폭이 정말 이 구조였는지는 이 값을 걸고 RSS 가 내려오는지로 확인된다.
JVM 은 Eden 이 차면 돈다
JVM 도 고르는 기준은 같다 — 참조가 없어진 객체. 갈리는 것은 도는 시점을 재는 방식이다. Go 가 힙 지표가 선에 닿는 것을 신호로 쓴다면, JVM 은 힙을 방 셋으로 나눠 두고 그중 한 방이 차는 것을 신호로 쓴다.
| 방 | 무엇이 들어가나 |
|---|---|
| Eden | 새로 만든 객체는 전부 여기부터 |
| Survivor | GC 에서 한 번 살아남은 객체가 잠깐 머문다 |
| Tenured | 오래 살아남은 객체가 옮겨 가서 산다 |
새 객체는 Eden 에 쌓이고, Eden 이 차면 GC 가 돈다. 살아남은 것은 다음 방으로 옮기고 Eden 을 비운다.
그럼 Eden 의 크기는 누가 정하나. 힙 전체 크기에서 비율로 나뉜다. JVM 은 힙 전체의 상한을 -Xmx 라는 값으로 받는데(이 값이 어떻게 정해지는지는 다음 절), booking 의 -Xmx 768 Mi 는 실측으로 이렇게 갈라져 있다.
Tenured 512.0 MiB 768 의 3분의 2
Eden 204.8 MiB 남은 3분의 1 을 8 : 1 : 1 로
Survivor 25.6 MiB × 2
그래서 -Xmx 를 절반으로 내리면 Eden 도 절반이 되어, 같은 일감에서 GC 가 두 배 자주 돈다. 상한이 시점을 직접 정하지는 않지만 방 크기를 거쳐 빈도에 걸린다. Tenured 쪽 회수는 시점 규칙이 또 다르고, 이 편에서는 안 다룬다.
"언제 도나"는 알았고 "어디까지 자라나"가 남았다
여기까지 온 길을 한 줄씩 놓으면 이렇다.
2절 힙 · 스택 힙의 객체만 수명이 함수에 안 묶인다. 참조가 사라져 죽은 뒤에도
누가 치우기 전까지 힙에 남고, 얼마나 커질지는 코드만 봐서는 모른다
3절 런타임 그 죽은 객체를 치우는 것이 런타임이고,
힙에 얼마가 들었는지도 런타임이 힙 지표로 내준다
4절 RSS 그런데 파드가 실제로 차지한 메모리는 OS 가 RSS 로 따로 세고,
힙 지표보다 몇 배 크다 — 죽은 객체가 쓰던 페이지를 치운 뒤에도
커널에 바로 안 돌려주기 때문이다
5절 GC · GOGC 그 치움이 GC 고, 그래서 폭은 GC 가 언제 도는지가 정한다.
Go 는 힙 지표가 두 배 선에 닿을 때 돌고, JVM 은 Eden 이 찰 때 돈다
이 절이 정한 것은 거기까지 — GC 가 도는 시점이다. 힙이 어디까지 자라도 되는지는 안 정해졌고, 두 언어가 여기서 갈린다.
Go 에는 자람을 막는 선이 없다. GOGC 가 정하는 것은 다음 GC 지점이 직전 살아남은 양의 몇 배냐일 뿐이라, 살아있는 양이 늘면 그 지점도 같이 올라간다. 일감이 늘어 살아있는 양이 두 배가 되면 GC 가 도는 힙 크기도 두 배가 된다. 위에서 100·200 으로 그린 톱니가 통째로 올라가는 것이고, 그것을 잡아 두는 값은 GOGC 에 없다.
JVM 에는 선이 있다. Eden 이 차면 돈다는 규칙 자체가 방 크기에 매여 있고, 그 방들이 -Xmx 를 나눈 값이기 때문이다. 다만 -Xmx 가 어디서 오는지는 아직 안 봤다 — booking 은 그 값을 직접 적지 않았는데도 768 Mi 로 서 있다.
그래서 1절에 적어 둔 모르는 낱말 중 셋이 남는데, 셋 다 방금 세운 그림의 빈자리에 하나씩 꽂힌다.
GOMEMLIMIT— Go 의 없는 선 자리에 사람이 넣는 값. Tempo 에 800 MiB 로 걸려 있던 그것이다-Xms·-Xmx·MaxRAMPercentage— JVM 선의 출처. booking 이-Xmx없이도 768 Mi 로 서 있는 이유가 이 안에 있다- 비힙 — 선 바깥에 남는 몫. booking 은 힙 사용이 74 Mi 인데 컨테이너는 420 MiB 를 쓴다 — 힙만으로는 설명이 안 되는 나머지다
이 셋은 전부 컨테이너 안쪽의 값이다. 그런데 이 편을 시작하게 만든 문제 — 예약이 과대·과소로 어긋나고 peak 가 limit 의 76-90% 에 붙은 컨테이너가 아홉이던 그 상태 — 에서 파드를 실제로 죽이고 살리는 선은 컨테이너 바깥에서 커널이 긋는 limit 이다.
안쪽과 바깥은 서로를 아는가. limit 을 바꾸면 안쪽 선이 따라 움직이는가, 안쪽 선을 다 정하면 limit 이 지켜지는가. 다음 절이 그 둘을 마주 세운다.
6. 두 상한
앞 절 끝의 질문을 그대로 들고 간다 — limit 을 바꾸면 안쪽 선이 따라 움직이는가. 안쪽 선을 다 정하면 limit 이 지켜지는가. 답하려면 두 선을 한자리에 놓아야 한다.
| 실체 | 누가 지키나 | 넘으면 | |
|---|---|---|---|
바깥 선 limit |
cgroup 의 memory.max |
커널 | OOMKill — 예고 없이 밖에서 죽는다 |
| 안쪽 선 | 런타임 설정 — JVM 의 힙 상한 · Go 의 GOMEMLIMIT |
런타임이 스스로 | 언어마다 다르다. 아래에서 가른다 |
기본값 상태에서 둘은 서로를 참조하지 않는다. 커널은 런타임의 힙 지표를 읽지 않고, 런타임이 limit 을 아는지는 언어가 갈린다 — JVM 은 읽어 오고, Go 는 모른다.
바깥 선부터 확정하고, 안쪽 선을 언어별로 세운 다음, 끝에서 질문에 답을 모은다.
바깥 선 — 커널은 working_set 으로 판정한다
limit 은 values 에 적는 쿠버네티스 값이지만, kubelet 이 cgroup 파일 memory.max 에 써 넣는 순간부터 지키는 쪽은 커널이다. 그리고 커널이 판정에 쓰는 숫자는 힙 지표도 RSS 도 아니다 — cgroup 이 직접 세는 세 번째 숫자다.
| 지표 | 누가 세나 | 세는 범위 | 빼는 것 |
|---|---|---|---|
| RSS | OS | 프로세스 하나 | 없다. 매핑된 파일 페이지도 넣는다 |
container_memory_working_set_bytes |
커널 (cgroup) | cgroup 이 묶은 것 전부 | 안 쓰는(inactive) 파일 캐시를 뺀다. 계속 읽고 쓰는 활성 캐시는 남는다 |
범위는 working_set 쪽을 키우고 뺄셈은 깎으니, 어느 쪽이 클지는 그때그때 다르다. Strimzi operator 는 뺄셈이 이겼다.
VmRSS 312 MiB 프로세스 하나인데 더 크다
container working_set 296 MiB cgroup 전체인데 더 작다
층이 다른 값끼리 빼면 안 된다는 뜻이다. 노드 실사용에서 파드 몫을 빼 시스템 몫을 구하는 식의 계산이 여기서 걸린다.
working_set 이 limit 을 넘으면 커널은 그 cgroup 의 프로세스에 SIGKILL 을 보낸다. 힙인지 비힙인지, 산 객체의 자리인지 죽은 객체가 쓰던 자리인지 — 안쪽 사정은 하나도 안 보고 총량 하나로 자른다. 바깥 선은 여기까지다. 이제 안쪽을 언어별로 본다.
JVM — limit 을 읽어 와서 선을 긋는다
5절이 남긴 수수께끼부터 푼다. booking 은 -Xmx 를 안 적었는데 힙 상한이 768 Mi 로 서 있었다. 768 = limit 1 Gi 의 75% 고, values 에 적어 둔 것은 MaxRAMPercentage=75 하나였다.
경로는 이렇다. JVM 의 힙 상한 기본 규칙은 컨테이너 이전 시대의 "물리 RAM 의 4분의 1"이다. 컨테이너 안에서 그대로 두면 노드 8 GiB 의 4분의 1 인 2 GiB 를 잡으려 들고, limit 512 MiB 컨테이너라면 커널이 먼저 죽인다.
그래서 JDK 10 부터 JVM 이 cgroup 파일을 직접 읽어 "물리 RAM" 자리에 컨테이너 limit 을 놓는다(UseContainerSupport, 기본 켜짐). 그 위에 손잡이 셋이 얹힌다.
| 손잡이 | 무엇을 정하나 |
|---|---|
-Xmx |
힙 상한을 절대값으로 명시. 있으면 아래 비율은 무시된다 |
MaxRAMPercentage |
limit 의 몇 % 를 힙 상한으로. 기본값 25 |
-Xms |
시작할 때 잡아 두는 힙 크기. -Xmx 와 같게 두면 늘었다 줄었다 하는 요동이 없어진다 |
topicOperator 에 -Xms 64m·-Xmx 128m 을 함께 적어 둔 것이 이 짝이다. 시작은 64 에서 하고 상한은 128 이라 그 사이에서만 오르내리고, 그렇게 실제로 확보해 둔 양을 커밋이라 한다 — 커밋은 사용과 상한 사이에 있다.
나는 values 에서 비율 쪽을 두 군데 명시해 뒀다.
Kafka 브로커 limit 1,536 Mi × 60% = 921.6 Mi 실측 MaxHeapSize 922.0 MiB
booking limit 1 Gi × 75% = 768 Mi
-Xmx 가 없으면 비율이 선을 긋고, 비율은 limit 에서 파생된다. 5절 끝 질문의 반쪽이 여기서 답이 된다 — JVM 쪽은 limit 을 바꾸면 안쪽 선이 따라 움직인다.
이 선에 닿으면 나는 일도 바깥 선과 다르다. 힙이 상한에 닿으면 JVM 은 GC 를 몰아 돌리고, 그래도 자리가 안 나면 OutOfMemoryError 를 던진다 — OOMKill 처럼 밖에서 죽임당하는 것이 아니라 앱 안에서 나는 실패다.
비힙 — 선을 그어도 limit 은 안 나온다
5절 끝 질문의 남은 반쪽 — 안쪽 선을 다 정하면 limit 이 지켜지나. 아니다. 커널이 재는 working_set 은 컨테이너 전부를 세는데 힙 상한은 힙 한 칸에만 그은 선이라, 선 바깥 몫이 limit 안에서 같이 산다. 이 몫을 비힙이라 한다.
| 비힙 | 무엇 | 무엇이 늘리나 | 상한 손잡이 |
|---|---|---|---|
| 메타스페이스 | 클래스 정보 | 로드한 클래스 수 | MaxMetaspaceSize (기본 무제한) |
| 코드캐시 | 바이트코드를 기계어로 번역해 둔 것 (JIT) | 자주 도는 메서드 수 | ReservedCodeCacheSize |
| 스레드 스택 | 스레드마다 하나씩 | 스레드 수 | -Xss × 스레드 수 |
| 다이렉트 버퍼 | 네트워크·디스크 I/O 용 버퍼 | I/O 량 | MaxDirectMemorySize |
| GC 관리 구조 | 회수에 쓰는 자료구조 | 힙 크기에 비례 | — |
스레드 스택 행은 OS 스레드에만 해당한다. booking 처럼 가상 스레드를 쓰면 그 스택은 힙 안에 놓여 -Xmx 안에서 세므로 이 행에 안 들어간다.
메타스페이스·코드캐시·스레드 스택·다이렉트 버퍼·GC 관리 구조는 모두 익명 메모리다. 힙과 같은 성질인데 -Xmx 밖에 있다. JVM 셋을 떠 보면 이 몫이 작지 않다.
limit |
힙 상한 (limit 대비) | 힙 사용 | 비힙 (잰 것만) | working_set |
|
|---|---|---|---|---|---|
| Strimzi operator | 384 Mi | 58 Mi (15%) | 52 Mi | 메타스페이스 86 Mi | 296 Mi |
| booking | 1,024 Mi | 768 Mi (75%) | 74 Mi | 합 181 Mi | 420 Mi |
| Kafka 브로커 | 1,536 Mi | 922 Mi (60%) | 325 Mi (커밋 389) | 메타스페이스 68 Mi | 621-643 Mi |
비힙 열이 고르지 않은 것은 잴 수 있는 것이 파드마다 달라서다. booking 은 앱이 JVM 지표를 내보내 다섯 조각의 합이 나오고, Strimzi·Kafka 는 jcmd 로 메타스페이스 한 조각만 떴다.
이 표에서 대 볼 것은 힙 사용과 working_set 사이의 거리다.
- Strimzi — 힙은 52 를 쓰는데 컨테이너는 296 을 쓴다. 메타스페이스(86) 하나가 힙 상한(58)보다 크다
- booking — 힙은 74, 컨테이너는 420. 힙 상한 768 은 이 420 의 대부분과 상관없는 선이다
- Kafka — 힙 커밋 389 에 메타스페이스 68 을 더해도 164-186 이 빈다(621-643 의 양 끝).
working_set은 안 쓰는 파일 캐시만 빼므로 계속 읽고 쓰는 로그 세그먼트의 활성 캐시가 여기 남아 있고, JVM 네이티브 몫과 가르려면-XX:NativeMemoryTracking을 켜야 한다
limit 을 향해 차오르는 것은 working_set 전체인데, 안쪽 선이 다스리는 것은 그중 힙 한 칸뿐이다. 안쪽 선을 다 정해도 limit 이 지켜지지 않는 이유가 이 거리다.
그래서 내가 익명 메모리의 예로 든 Kafka 브로커의 607 Mi도 힙 단독이 아니다. 나는 그 값에 "JVM 힙"이라는 이름을 붙였는데, 익명 메모리를 잰 값이라 힙과 비힙이 함께 들어 있다.
Go — limit 을 아예 모른다
Go 런타임에는 cgroup 을 읽는 단계 자체가 없다. 힙 상한 기본값은 int64 최대 — 사실상 무한이다. 두 배 선은 살아있는 양을 따라 올라가는 선이라, limit 1 GiB 컨테이너 안에서 다음 GC 지점이 2 GiB 로 잡혀도 런타임은 이상한 줄 모른다. Go 파드가 죽는 선은 언제나 바깥의 OOMKill 이다.
그 빈자리에 사람이 넣는 값이 GOMEMLIMIT(Go 1.19부터)이다. JVM 처럼 런타임이 읽어 오는 것이 아니라 limit 을 보고 사람이 옮겨 적는 값이고, -Xmx 와 두 가지가 더 다르다.
- 덮는 범위 — 힙만이 아니라 힙 + 고루틴 스택 + 런타임 메타데이터를 한 값으로 묶는다
- 성격 — 상한이 아니라 목표다. 넘어도 실패하지 않는다. 살아있는 양이 목표를 넘으면, 런타임이 목표를 맞추려고 GC 를 계속 세게 돌릴 뿐이다
범위에 고루틴 스택이 들어 있는데, 고루틴이 무엇인지는 아직 이름뿐이다. 두 선이 다 선 지금이 그것을 놓을 자리다.
사용자 수준 스레드 — 그 스택은 어느 선이 세나
출발은 OS 스레드에 붙는 값이다. 스레드는 커널이 만들고 커널이 굴리며, 하나마다 스택이 예약되고(JVM 은 -Xss 1 MiB) 번갈아 돌릴 때마다 문맥 교환이 든다. 동시 요청 수백 개를 스레드 수백 개로 받으면 그 값이 그대로 쌓인다.
Go 와 Java 의 런타임은 같은 우회로를 냈다. 커널이 아니라 런타임 자신이 만들고 굴리는 실행 단위를 두고, OS 스레드 위에 얹어 돌린다. 이런 실행 단위를 사용자 수준 스레드라 하고, 이 클러스터에는 둘 다 있다.
| 누가 만들고 굴리나 | 스택 | |
|---|---|---|
| 고루틴 (Go) | Go 런타임 | 힙 밖. 2 KiB 로 시작해 필요하면 늘린다 |
| 가상 스레드 (Java 21) | JVM | 힙 안. 객체로 놓여 필요한 만큼 자란다 |
동작의 핵심은 기다릴 때 비켜 주는 것이다. 고루틴이나 가상 스레드가 DB 응답 같은 것을 기다리게 되면, 런타임이 그것을 OS 스레드에서 떼어 내고 다른 것을 그 자리에 얹는다. 커널은 이 교체를 모른다. 그래서 OS 스레드는 CPU 코어 수 언저리면 되고, 실행 단위는 수만 개를 띄워도 스레드만큼 값이 안 든다.
이 클러스터의 두 앱이 그 위에 서 있다.
- queue — 대기열 순번을 들고 폴링을 받는 Go 서비스. 연결마다 고루틴 하나를 붙인다
- booking —
spring.threads.virtual.enabled=true. 요청마다 가상 스레드 하나를 쓴다
사용자 수준 스레드가 이 절의 두 선과 만나는 자리는 스택이다. 커널이 안 만드는 실행 단위라 스택도 커널이 안 잡아 주고, 런타임이 마련한 그 스택이 어느 값 안에서 세어지는지가 갈린다.
OS 스레드 스택 -Xss × 스레드 수 힙 밖 — 비힙으로 limit 안에 든다
가상 스레드 스택 -Xmx 안 JVM 이 스택을 힙 안에 객체로 두므로
고루틴 스택 GOMEMLIMIT 안 힙 밖이지만 목표가 같이 묶어 센다
1절에서 서로 어떻게 다른지 몰랐던 -Xmx 와 GOMEMLIMIT 이 힙만 정하는 값이 아니라는 뜻이다. booking 이 동시 요청을 늘리면 그 스택 몫은 비힙이 아니라 -Xmx 안에서 늘고, queue 가 연결을 늘리면 GOMEMLIMIT 이 그 몫을 같이 센다.
런타임이 없는 언어는 앱이 직접 건다
C·C++에는 "언제 회수하나"를 정하는 런타임이 없다. 그래서 상한도 앱이 자기 자료구조에 직접 건다.
- Redis —
maxmemory가 그 자리다. 안 걸면 무한이라 데이터가 쌓이는 만큼 자라다 컨테이너limit에서 끝난다. 걸어 두면 그 선에서 오래된 키를 버리거나 쓰기를 거절하고, 기본은 거절이다 - MySQL — InnoDB 버퍼 풀이 JVM의 힙에 해당하는 자리인데 기본값이 128 MiB로 고정돼 있다. 미설정의 문제가 무한이 아니라 부족이다
앱이 직접 거는 상한은 런타임이 있는 쪽에도 있다. Loki는 Go 프로그램이지만 조회를 빠르게 하려고 프로세스 안에 캐시를 여러 개 두고, 그 캐시마다 크기 상한이 따로 있다. 그 상한들의 합은 컨테이너 limit과 무관하게 정해진다. 런타임 손잡이와 앱 설정은 다른 축이다.
총정리 — 질문의 답과, 다 선 낱말들
이 절 첫머리의 질문 둘에 답이 모였다.
limit 을 바꾸면 안쪽 선이 따라 움직이는가 — JVM 만 그렇다. JVM 은 cgroup 을 읽어 limit × 비율로 선을 긋기 때문이다(booking 75% → 768 Mi). -Xmx 로 절대값을 적으면 그 연결도 끊긴다. Go 는 처음부터 limit 을 모르니, limit 을 바꾸면 GOMEMLIMIT 도 사람이 다시 옮겨 적어야 한다.
안쪽 선을 다 정하면 limit 이 지켜지는가 — 아니다. JVM 은 비힙이 선 밖에서 limit 안을 채우고, Go 의 선은 목표라 강제가 아니고, 앱이 거는 상한은 아예 다른 축이다. limit 을 지킬 책임은 어느 런타임에도 없고, 그래서 마지막 선은 언제나 커널의 OOMKill 이다.
두 언어가 갈린 자리를 세로로 세우면 이렇다.
| Go | JVM | |
|---|---|---|
| 힙에 무엇이 올라가나 | 컴파일러가 정한다 (새어 나가면 힙) | 객체 본체는 힙, 참조는 스택 |
| 실행 단위 | 고루틴. 런타임이 굴리고 스택 2 KiB 시작 | OS 스레드(커널, -Xss 1 MiB) 또는 가상 스레드(JVM, 스택은 힙 안) |
| 힙을 통으로 쓰나 | 통으로 쓴다 | Eden · Survivor · Tenured 로 나눈다 |
| GC 가 도는 시점 | 힙 지표가 살아있는 양의 두 배에 닿을 때 (GOGC) |
Eden 이 찰 때. Eden 크기는 -Xmx 에서 파생된다 |
| 힙 상한 기본값 | 없다 (int64 최대) | limit 의 25% |
| cgroup 을 읽나 | 안 읽는다 | 읽는다 (UseContainerSupport) |
| 잇는 손잡이 | GOMEMLIMIT — 사람이 옮겨 적는 목표 |
-Xmx·MaxRAMPercentage — 상한 |
| 손잡이가 덮는 범위 | 힙 + 고루틴 스택 + 런타임 메타 | 힙만 (가상 스레드 스택 포함). 비힙은 밖 |
| 안쪽 선을 넘으면 | 실패 없음 — GC 만 세게 돈다 | OutOfMemoryError — 앱 안에서 실패 |
| 바깥 선을 넘으면 | OOMKill | OOMKill |
그리고 1절 표 앞에서 판단이 안 서던 낱말들이 전부 섰다.
| 1절의 낱말 | 지금 |
|---|---|
| 힙 · 스택 | 주소 공간의 조각. 스택은 스레드마다 하나에 프레임이 쌓였다 풀리고, 힙만 수명이 함수에 안 묶여 치우는 문제가 남는다 |
| 런타임 | 내가 안 짰는데 같은 프로세스에서 함께 도는 코드. 힙을 잡아 주고, 치우고, 실행 단위를 굴린다 |
| RSS | 프로세스에 물리 페이지가 붙은 양. 힙 지표와의 폭은 죽은 객체가 쓰던 페이지와 반납 지연이 만든다 |
| GC · GOGC | 참조가 안 남은 객체를 치우는 일 · 그 시점(두 배 선)의 배율. 낮추면 메모리가 줄고 CPU 를 더 쓴다 |
| -Xms · -Xmx · MaxRAMPercentage | JVM 안쪽 선의 세 손잡이 — 시작 크기 · 절대값 상한 · limit 파생 비율 |
| GOMEMLIMIT | Go 안쪽 선. 사람이 옮겨 적는 목표 — 힙과 고루틴 스택과 런타임 메타를 묶는다 |
| 비힙 | -Xmx 밖에서 limit 안을 채우는 몫 — 메타스페이스 · 코드캐시 · OS 스레드 스택 · 버퍼 · GC 구조 |
남은 일은 이 낱말로 AI 가 낸 표를 내 눈으로 다시 읽고 컨테이너마다 값을 정하는 것이다. 멈춰 뒀던 스펙 재산정으로 돌아간 후 클러스터 안정화 작업을 마친 뒤 부하테스인 12부를 진행한다.
'HomeLab' 카테고리의 다른 글
| 번외 — 노드 메모리와 쿠버네티스 메모리 (0) | 2026.08.15 |
|---|---|
| 번외 — 클러스터 안 카프카 (1) | 2026.08.11 |
| 노트북 홈서버 k3s 구축기 - 11. 앱 검증과 시뮬레이터 (0) | 2026.08.11 |
| 노트북 홈서버 k3s 구축기 - 10. CI 와 CD (0) | 2026.08.09 |
| 번외 — CI 파이프라인 기록 (0) | 2026.08.09 |