3주 간격으로 두 번 멈춘 워커 노드
같은 클러스터에서 3주 간격으로 두 번, 워커 노드 하나가 응답을 멈추고 그 여파로 데이터베이스가 중단됐습니다. 두 번째 사고를 파고들어 찾은 원인은 Ceph OSD 캐시 메모리 문제였습니다. OSD가 osd_memory_target 설정에 따라 노드당 3 GiB를 쓰는데도 리소스 선언은 전혀 없었고, 그 결과 노드 메모리가 수 시간에 걸쳐 말라가다 kubelet이 멈췄습니다.
이 글은 그 연쇄를 추적한 과정과, osd_memory_target 조정 및 데몬별 requests/limits 선언으로 가용 메모리를 0.3 GiB에서 5.1 GiB까지 회복시킨 기록입니다. 중간에 Rook의 리소스 설정이 CR에 따라 무시되는 함정도 하나 만났습니다.
환경
| 구성 요소 | 내용 |
|---|---|
| Kubernetes | v1.36.3 (control plane 3대 + worker 3대) |
| 워커 노드 | 노드당 allocatable 7.8 GiB, swap 없음 |
| 스토리지 | Rook-Ceph v1.13.10 (mon 3, OSD 3, MDS active+standby) |
| 워크로드 | MariaDB, WordPress, kube-prometheus-stack |
NotReady 추적부터 두 단계 메모리 조치까지
1. 증상 — 노드는 살아 있는데 NotReady
장애 노드는 ping에 응답하고 SSH도 열려 있었습니다. kubelet 포트(10250)까지 살아 있었는데 노드만 NotReady였습니다. kubelet이 API 서버에 하트비트를 보내지 못한 것입니다.
kubectl get lease -n kube-node-lease <worker-node> -o jsonpath='{.spec.renewTime}'
# → 37분 전에서 멈춰 있음. 그런데 호스트는 정상 응답
영속 저널이 켜져 있어 재부팅 이전 로그를 읽을 수 있었던 것이 결정적이었습니다. 강제 재부팅을 했다면 메모리에만 있던 로그는 사라졌을 것입니다.
journalctl -b -1 --no-pager -u kubelet --since "<장애시각 -10분>"
2. kubelet이 멈추기 직전의 신호
kubelet 로그의 마지막 줄들이 패턴을 보여줬습니다.
kubelet: "Failed to rotate log for container" err="... docker does not support reopening container log files"
currentSize=41897845 maxSize=10485760 # calico-node 41 MB
currentSize=61953310 maxSize=10485760 # mgr watch-active 62 MB
kubelet: "Housekeeping took longer than expected" expected="1s" actual="1.302s"
kubelet: (마지막 로그)
kernel: libceph: osd0 down
kernel: rbd: rbd0: encountered watch error: -107 # ENOTCONN
systemd-journald: Under memory pressure, flushing caches.
(이후 저널 완전 정지)
journald: Under memory pressure가 핵심 단서였습니다. 디스크는 55%로 여유가 있었으니 용량 문제가 아니었습니다.
3. 메모리 추이 확인
Prometheus에서 노드별 가용 메모리를 조회하니 장애 노드만 다른 양상이었습니다.
node_memory_MemAvailable_bytes (총 7.7 GiB, swap 없음) 장애 노드 06:00=0.6 07:00=0.4 07:20=0.3 08:00=0.3 08:10=0.3 GiB ← 수 시간째 4% 수준 다른 노드 0.5~1.5 GiB / 0.8~2.1 GiB
주목할 점은 OOM killer가 돌지 않았다는 것입니다. 특정 컨테이너가 한도를 넘은 게 아니라 시스템 전역 메모리가 말라 커널 reclaim이 thrash되면서 kubelet이 응답 불능에 빠진 것이었습니다. swap이 없어 완충이 전혀 없었습니다.
4. 범인 — 리소스 선언이 없는 OSD
파드별 메모리를 보니 OSD가 압도적이었습니다.
kubectl top pods -A --sort-by=memory | head -5 rook-ceph-osd-1 3088Mi rook-ceph-osd-2 2972Mi rook-ceph-osd-0 1232Mi # 직전에 재시작되어 아직 안 자람 mariadb-galera-0 1494Mi prometheus 806Mi
설정을 확인하니 세 가지가 동시에 문제였습니다.
ceph config get osd osd_memory_target # 4294967296 (4 GiB, 기본값)
ceph config get osd osd_memory_target_autotune # false (노드 크기에 맞춰 조정 안 함)
kubectl get pods -n rook-ceph -l app=rook-ceph-osd \
-o jsonpath='{.items[0].spec.containers[0].resources}'
# {} ← requests / limits 둘 다 없음
allocatable이 7.8 GiB인 노드에서 OSD 하나가 4 GiB를 목표로 상한 없이 자랍니다. 여기에 DB와 모니터링 스택이 더해지면 수지가 맞지 않습니다.
더 나쁜 건 스케줄러가 이를 몰랐다는 점입니다. requests가 14~30%만 선언돼 있는데 실사용은 63~89%였습니다. 스케줄러 입장에선 노드가 비어 보여 계속 파드를 배치합니다.
5. 1단계 — osd_memory_target 축소 (무중단)
OSD가 스스로 bluestore 캐시를 줄이도록 하는 런타임 설정이라 재시작이 필요 없습니다.
kubectl exec -n rook-ceph deploy/rook-ceph-tools -- \ ceph config set osd osd_memory_target 2147483648 # 4 GiB -> 2 GiB
수 분 내에 OSD 메모리가 3,084 → 2,006 Mi로 내려갔습니다. 실측 기준 osd_memory_target 대비 실사용 비율이 약 0.75였습니다.
주의할 점이 있습니다. kubectl top nodes로 보면 회수 효과가 금세 상쇄된 것처럼 보이는데, 이는 페이지 캐시가 다시 채워진 것이라 무해합니다. 판단은 node_memory_MemAvailable_bytes로 해야 합니다.
MemAvailable 변화 노드A 0.5 GiB → 1.3 GiB (2.6배) 노드B 1.3 GiB → 2.2 GiB
6. 2단계 — 데몬별 requests/limits 선언
osd_memory_target 설정은 OSD의 자율 조절(soft)일 뿐입니다. 쿠버네티스에 알리고 강제 상한을 두려면 CR에 선언해야 합니다. 둘은 서로 다른 문제를 풉니다.
| 설정 | 해결하는 것 |
|---|---|
osd_memory_target |
OSD가 스스로 캐시를 줄임 (실사용량 자체를 낮춤) |
| k8s requests | 스케줄러가 현실을 알게 함 → 과잉 배치 방지 |
| k8s limits | cgroup 강제 상한 → 노드 전체 사망을 컨테이너 1개 OOM으로 축소 |
이번 사고에서 OOM killer가 돌지 않은 것이 바로 limits 부재 때문이었습니다. 상한이 있었다면 OSD 하나만 죽고 Ceph는 degraded로 버텼을 텐데, 상한이 없어 노드가 통째로 말랐습니다.
# CephCluster CR
spec:
resources:
osd: { requests: {memory: 2Gi}, limits: {memory: 3Gi} }
mon: { requests: {memory: 512Mi}, limits: {memory: 1Gi} }
mgr: { requests: {memory: 512Mi}, limits: {memory: 1Gi} }
limit을 target에 바짝 붙이면 rocksdb와 메모리 단편화 오버헤드로 OOM kill 루프에 빠집니다. target 2 GiB에 limit 3 GiB로 1 GiB 여유를 뒀습니다.
적용은 위험도가 낮은 순서로 한 종류씩, 각 단계마다 ceph health를 확인하며 진행했습니다.
# mgr -> mds -> mon -> osd 순. 각 단계 사이에 HEALTH_OK 확인
kubectl patch cephcluster -n rook-ceph rook-ceph --type merge \
-p '{"spec":{"resources":{"mgr":{"requests":{"memory":"512Mi"},"limits":{"memory":"1Gi"}}}}}'
리소스 선언 중 만난 Rook·LimitRange 함정
MDS 리소스가 CephCluster CR 에서 무시됨
현상 — CephCluster.spec.resources.mds에 값을 넣었더니 CR에는 기록되는데 MDS 파드에는 전혀 반영되지 않았습니다. mgr은 같은 방식으로 적용됐는데 MDS만 롤링조차 되지 않았습니다.
kubectl get pods -n rook-ceph -l app=rook-ceph-mds \
-o jsonpath='{range .items[*]}{.metadata.name}{" "}{.spec.containers[0].resources}{"\n"}{end}'
# mds-myfs-a ... {} <- 비어 있음
# mds-myfs-b ... {}
원인 — Rook에서 MDS 데몬 배포는 CephFilesystem CR이 소유합니다. CephCluster의 resources가 지원하는 키는 mgr·mon·osd·prepareosd·crashcollector·exporter 등이고 mds는 포함되지 않아 조용히 무시됩니다.
해결 — CephFilesystem.spec.metadataServer.resources에 선언합니다.
kubectl patch cephfilesystem -n rook-ceph myfs --type merge \
-p '{"spec":{"metadataServer":{"resources":{"requests":{"memory":"256Mi"},"limits":{"memory":"1Gi"}}}}}'
전환 중 standby MDS가 교체되며 20초가량 HEALTH_WARN이 떴다가 복귀했고, CephFS를 쓰는 워크로드는 영향을 받지 않았습니다.
LimitRange 를 전역 기본값으로 착각
현상 — 리소스 선언이 없는 컨테이너가 95개나 돼서 LimitRange로 기본값을 주려 했는데, 한 네임스페이스에 선언하면 전체에 적용될 것으로 기대했습니다.
원인 — LimitRange는 네임스페이스 스코프이고 쿠버네티스에 전역 기본 LimitRange라는 개념 자체가 없습니다. server dry-run으로 확인했습니다.
LimitRange 있는 네임스페이스 → {"limits":{"memory":"256Mi"},"requests":{"cpu":"10m","memory":"64Mi"}}
LimitRange 없는 네임스페이스 → {} (아무것도 주입되지 않음)
해결 — 대상 네임스페이스마다 오브젝트를 하나씩 둡니다. 같은 검증에서 함정을 하나 더 찾았습니다. limit만 선언하고 request를 빠뜨리면 request가 defaultRequest가 아니라 limit과 같은 값이 됩니다. requests가 부풀어 스케줄링이 막힐 수 있으니 항상 함께 선언해야 합니다.
선언: limits{1Gi}, request 없음 → 결과: limits{1Gi} requests{memory:1Gi} # 64Mi 아님
덧붙여 max/min은 넣지 않았습니다. 조건 위반 시 파드 생성을 거부하므로 정상 워크로드가 막힐 위험이 있습니다. CPU limit도 throttling 때문에 두지 않았습니다.
가용 메모리 0.3 GiB에서 5.1 GiB로 회복
Ceph OSD 캐시 메모리 사용량을 제어한 뒤 노드 가용 메모리가 회복됐습니다.

| 노드 | MemAvailable (전) | MemAvailable (후) | top (전 → 후) |
|---|---|---|---|
| 노드A | 0.3 GiB | 5.1 GiB | 60% → 35% |
| 노드B | 0.5 GiB | 3.3 GiB | 88% → 54% |
| 노드C | 1.3 GiB | 3.9 GiB | 81% → 48% |
노드B가 0.5 GiB였다는 것은 장애 노드가 쓰러지기 직전(0.3 GiB)과 거의 같은 수준이었다는 뜻입니다. 다음 차례였던 셈입니다.
OSD 롤링 적용 중에도 데이터 경로는 멈추지 않았습니다.
osd[0:2/2 1:2/2 2:2/2] limit적용=0/3 HEALTH_OK osd[0:2/2 1:1/2 2:2/2] limit적용=1/3 HEALTH_WARN 1 osds down; Degraded osd[0:2/2 1:2/2 2:0/2] limit적용=2/3 HEALTH_WARN 1 osds down; Degraded osd[0:2/2 1:2/2 2:2/2] limit적용=3/3 HEALTH_OK
풀이 size=3 / min_size=2라 한 대씩 재시작하는 동안 I/O가 계속됐고, 상위 데이터베이스도 영향을 받지 않았습니다.
검증은 이렇게 합니다.
kubectl exec -n rook-ceph deploy/rook-ceph-tools -- ceph config get osd osd_memory_target
kubectl get pods -n rook-ceph -l app=rook-ceph-osd \
-o jsonpath='{.items[0].spec.containers[0].resources}'
kubectl top nodes
# 판단은 MemAvailable 로. top 은 페이지 캐시 때문에 과장돼 보인다
curl -sG "http://<prometheus>/api/v1/query" \
--data-urlencode 'query=node_memory_MemAvailable_bytes/1024/1024/1024'
참고
관련 포스트:
- Diagnosing a 9-Second Node Blackout: Rook Tolerations and Control Plane Metrics — 첫 번째 사고 기록. 당시 원인을 확정하지 못하고 하이퍼바이저 계층을 가설로 남겼는데, 이번 조사로 메모리 고갈이 훨씬 설득력 있는 설명임이 드러났습니다
- Kubernetes MariaDB Failover with Rook Ceph — Rook Ceph 기반 워크로드의 장애 전환
- Upgrading Kubernetes Add-ons: Calico, MetalLB, and Traefik — 동일 클러스터의 애드온 운영
참고 문서: Ceph BlueStore Configuration (osd_memory_target) · Rook CephCluster CRD · Rook CephFilesystem CRD · Kubernetes LimitRange




