태그 보관물: Prometheus

When Ceph OSD Cache Memory Stalls kubelet: Diagnosing Node Memory Exhaustion

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 캐시 메모리 사용량을 제어한 뒤 노드 가용 메모리가 회복됐습니다.

Ceph OSD 캐시 메모리 조치 전후 워커 노드 3대의 MemAvailable 실측 그래프로, 장애 노드가 0.3 GiB 수준에서 응답을 멈춘 뒤 재부팅과 osd_memory_target 축소, limits 적용을 거쳐 세 노드 모두 3~5 GiB대로 회복된 모습입니다.
osd_memory_target 조정과 리소스 선언 전후의 워커 노드 가용 메모리 실측 추이(KST)이며, 워커 1·2·3이 아래 표의 노드A·B·C입니다.
노드 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'

참고

관련 포스트:

참고 문서: Ceph BlueStore Configuration (osd_memory_target) · Rook CephCluster CRD · Rook CephFilesystem CRD · Kubernetes LimitRange

Diagnosing a 9-Second Node Blackout: Rook Tolerations and Control Plane Metrics

9초 단절이 Rook 파드 축출로 번지기까지

워커 노드 한 대가 약 9초 동안 네트워크에서 사라졌습니다. 그 쿠버네티스 노드 순단 하나가 스토리지 컨트롤 파드의 즉시 축출로 번졌고, 결국 상위 워크로드까지 영향을 받았습니다. 정작 처음 눈에 띈 증상은 Ceph 파드가 무더기로 재기동된 것이어서, 저장소 쪽 문제로 오인하기 쉬운 상황이었습니다.

이 글은 그 9초를 어떻게 특정했는지, 그리고 같은 일이 반복되어도 피해가 번지지 않도록 적용한 두 가지 조치를 정리한 운영 기록입니다. 관측 도구 자체가 장애 노드 위에 있을 때 생기는 판단 착오와, 그것을 걷어내기 위해 클러스터 바깥의 관측자를 동원한 과정이 핵심입니다.

쿠버네티스 노드 순단 9초 동안 장애 노드 안의 Prometheus는 전역 장애로 오진하고 클러스터 밖 HAProxy는 워커 1대로 범위를 좁힌 구조와, Rook toleration 5초·60초·기본 300초를 9초 단절과 비교한 개념도입니다.
쿠버네티스 노드 순단 9초를 클러스터 안팎의 관측자가 다르게 본 구조와 축출 유예 시간의 차이입니다.

환경

구성 요소 내용
Kubernetes v1.36.3 (kubeadm HA, control plane 3대 + worker 3대)
컨테이너 런타임 Docker + cri-dockerd
CNI / LB Calico, MetalLB (L2 모드)
스토리지 Rook-Ceph v1.13.10 (mon 3, OSD 3, MDS active+standby)
API 로드밸런서 HAProxy (클러스터 외부 VM, 192.168.x.x:6443)
모니터링 kube-prometheus-stack
가상화 VMware (전 노드 VM)

관측자 편향을 걷어낸 추적과 두 가지 조치

1. 최초 관측과 오진

Prometheus에서 장애 시각의 스크레이프 상태를 조회했더니 55개 타깃 중 26개가 같은 1분 구간에 up=0이었습니다. API 서버 3대 전부, kubelet 6대 전부가 포함되어 있어 처음에는 클러스터 전역 네트워크 장애로 판단했습니다.

kubectl exec -n rook-ceph deploy/rook-ceph-tools -- \
  curl -sG "http://<prometheus-svc>:9090/api/v1/query_range" \
  --data-urlencode 'query=up' \
  --data-urlencode "start=<epoch>" --data-urlencode "end=<epoch>" \
  --data-urlencode "step=15"

그런데 이 판단에는 결함이 있었습니다. Prometheus 파드 자체가 문제가 의심되는 워커 노드 위에서 동작하고 있었기 때문입니다. 관측자가 장애 대상 안에 있으면 “모든 타깃이 죽었다”는 결과는 타깃의 상태가 아니라 관측자의 상태를 반영합니다. 게다가 query_range는 직전 값을 최대 5분까지 끌어와 채우므로, 스크레이프가 아예 누락된 구간이 정상값 1로 보이기도 합니다.

2. 클러스터 바깥의 독립 관측자 확보

편향을 걷어내기 위해 클러스터 외부에 있는 HAProxy의 로그를 확인했습니다. HAProxy는 API 서버 3대를 TCP 체크로 상시 감시하고 있으므로, 물리망이나 마스터에 문제가 있었다면 반드시 흔적이 남습니다.

# API 백엔드는 하루 종일 DOWN 이력이 없었음
journalctl -t haproxy --since "YYYY-MM-DD 00:00:00" | grep -iE "DOWN|UP|no server"

Server www.example.com/app1 is DOWN, reason: Layer6 timeout, check duration: 2001ms.
backend 'www.example.com' has no server available!
Server www.example.com/app1 is UP, reason: Layer6 check passed, check duration: 1ms.

결과는 명확했습니다. 6443 백엔드는 DOWN 기록이 한 건도 없었고 NIC 에러·드롭·carrier 카운터도 모두 0이었습니다. 즉 물리망과 마스터 3대는 사건 내내 정상이었습니다. 유일한 이벤트는 Ingress 컨트롤러 백엔드가 9초 동안 Layer6 타임아웃을 낸 것뿐이었습니다.

3. ARP 테이블로 MetalLB L2 소유자 특정

그 백엔드 주소는 MetalLB가 L2로 광고하는 LoadBalancer IP입니다. L2 모드에서는 speaker 하나가 해당 IP를 소유하므로, 그 speaker가 있는 노드가 사라지면 IP도 함께 사라집니다. HAProxy의 ARP 테이블에서 소유자를 바로 확인할 수 있었습니다.

ip neigh show | grep -E "192.168.x."

192.168.x.112 dev ens33 lladdr 00:0c:29:xx:xx:xx REACHABLE   # worker-node
192.168.x.120 dev ens33 lladdr 00:0c:29:xx:xx:xx REACHABLE   # LoadBalancer IP - 동일 MAC

LoadBalancer IP의 MAC이 특정 워커 노드의 MAC과 동일했습니다. 여기에 kube-controller-manager 로그가 같은 시각 그 노드의 파드를 축출한 기록이 더해지면서, 서로 독립된 두 관측자가 같은 노드를 지목하게 되었습니다.

taint_eviction.go:111] "Deleting pod" controller="taint-eviction-controller" pod="rook-ceph/rook-ceph-operator-..."
taint_eviction.go:111] "Deleting pod" controller="taint-eviction-controller" pod="rook-ceph/rook-ceph-mgr-a-..."
taint_eviction.go:111] "Deleting pod" controller="taint-eviction-controller" pod="rook-ceph/rook-ceph-mds-myfs-b-..."

4. 게스트 내부에는 증거가 없음을 확인

노드가 네트워크에서 사라졌다면 게스트 안에도 흔적이 남아야 합니다. 그런데 어느 지표에도 흔적이 없었습니다.

확인 항목 결과
node_network_carrier_changes_total 전 노드 불변 — 링크 플랩 없음
node_network_up 1 유지
node_boot_time_seconds 불변 — 노드 재부팅 없음
kubelet process_start_time_seconds 불변 — kubelet 재시작 없음
calico-node / kube-proxy / CoreDNS 재시작 0회
HAProxy NIC 카운터 error / dropped / carrier 모두 0

링크는 끊기지 않았는데 네트워크에서는 사라졌고, 물리망과 다른 VM은 멀쩡했습니다. 이 조합은 게스트 계층이 아니라 그 바깥, 즉 하이퍼바이저 계층의 순간적인 정지를 시사합니다. 다만 해당 계층의 이벤트 로그에 접근하지 못해 근본 원인 확정까지는 이르지 못했다는 점을 분명히 해 둡니다. 스냅샷이나 백업 스케줄은 없다는 것이 확인되어, 정기 작업 가설은 배제되었습니다.

5. 조치 1 — Rook toleration 완화

9초짜리 쿠버네티스 노드 순단 하나가 큰 사고가 된 직접적인 이유는 Rook이 자신의 파드에 붙이는 축출 유예 시간이 5초였기 때문입니다. 노드에 unreachable 테인트가 붙자마자 operator·mgr·MDS가 즉시 축출되어 재배치되었습니다.

다행히 데이터 경로인 mon과 OSD에는 이 설정이 적용되지 않습니다. 이들은 쿠버네티스 기본값 300초를 쓰기 때문에 축출되지 않았고, 그래서 Ceph 자체는 HEALTH_OK를 유지했습니다. 유예 시간을 60초로 올렸습니다.

# operator가 관리하는 데몬(mgr, MDS, exporter, crashcollector)에 적용
kubectl set env deploy/rook-ceph-operator -n rook-ceph \
  ROOK_UNREACHABLE_NODE_TOLERATION_SECONDS=60

# operator와 tools 자신의 toleration은 설치 매니페스트가 소유하므로 별도 패치
for d in rook-ceph-operator rook-ceph-tools; do
  kubectl patch deploy -n rook-ceph $d --type=json \
    -p='[{"op":"replace","path":"/spec/template/spec/tolerations/0/tolerationSeconds","value":60}]'
done

이 변경은 operator 파드를 재기동시키고, 이어서 mgr과 MDS 배포본이 순차적으로 롤링됩니다. MDS는 hot standby가 있어 전환이 수 초에 그쳤고, CephFS를 사용하는 워크로드 파드는 재시작 없이 그대로 유지되었습니다.

6. 조치 2 — 컨트롤 플레인 메트릭 노출

사후 분석 과정에서 더 불편했던 것은 etcd·scheduler·controller-manager 지표가 통째로 비어 있었다는 점입니다. 28시간 치를 조회해도 이 세 잡의 스크레이프 성공률이 0%였습니다. 장애 순간 컨트롤 플레인이 어떤 상태였는지 확인할 수단이 아예 없었던 셈입니다.

원인은 단순했습니다. kubeadm 기본값이 세 컴포넌트를 모두 루프백에만 바인딩하기 때문에, 노드 IP로 스크레이프하면 구조적으로 연결이 거부됩니다.

kube-controller-manager   --bind-address=127.0.0.1
kube-scheduler            --bind-address=127.0.0.1
etcd                      --listen-metrics-urls=http://127.0.0.1:2381

마스터 3대의 static pod 매니페스트를 한 대씩 수정했습니다. etcd가 포함되므로 반드시 한 대씩 진행하고, 다음 노드로 넘어가기 전에 쿼럼을 확인해야 합니다.

# 마스터 1대에서 실행 후 검증, 그 다음 노드로 이동
sudo sed -i 's|--bind-address=127.0.0.1|--bind-address=0.0.0.0|' \
  /etc/kubernetes/manifests/kube-controller-manager.yaml \
  /etc/kubernetes/manifests/kube-scheduler.yaml

sudo sed -i -E 's#(- --listen-metrics-urls=).*#\1http://0.0.0.0:2381#' \
  /etc/kubernetes/manifests/etcd.yaml

여기서 한 가지 함정이 있는데, 아래 etcd 이중 바인딩 사례에서 따로 다루겠습니다. 그리고 이 수정만으로는 kubeadm upgrade 시점에 매니페스트가 재생성되면서 모두 되돌아갑니다. kubeadm-config ConfigMap에도 같은 인자를 넣어야 영구적으로 유지됩니다.

controllerManager:
  extraArgs:
  - name: bind-address
    value: 0.0.0.0
scheduler:
  extraArgs:
  - name: bind-address
    value: 0.0.0.0
etcd:
  local:
    dataDir: /var/lib/etcd
    extraArgs:
    - name: listen-metrics-urls
      value: http://0.0.0.0:2381

이 ConfigMap은 업그레이드와 join 시점에만 읽히므로, 반영해도 실행 중인 컴포넌트에는 영향이 없고 노드 재기동도 발생하지 않습니다.

etcd 이중 바인딩과 스크레이프 오독

etcd가 기동하지 않고 마스터 노드가 NotReady로 빠짐

현상 — 첫 마스터에 위 수정을 적용한 직후 해당 노드가 NotReady가 되었습니다. 2379와 6443 포트가 모두 닫혔고, etcd 쿼럼은 2/3로 떨어졌습니다. 호스트 자체는 살아 있어 SSH와 kubelet 포트는 정상이었습니다.

원인 — 처음에 liveness probe를 배려한다는 이유로 루프백을 남긴 채 와일드카드를 덧붙였습니다. 그런데 0.0.0.0은 루프백을 이미 포함하므로 같은 주소를 두 번 바인딩하게 되어 etcd가 기동에 실패했습니다.

# 잘못된 설정
--listen-metrics-urls=http://127.0.0.1:2381,http://0.0.0.0:2381

# etcd 로그
{"level":"fatal","msg":"discovery failed",
 "error":"listen tcp 127.0.0.1:2381: bind: address already in use"}

해결 — 중복을 제거하고 와일드카드만 남겼습니다. 0.0.0.0이 루프백도 커버하므로 127.0.0.1:2381을 보는 liveness probe는 그대로 동작합니다. 수정 후 kubelet이 약 20초 만에 etcd를 재기동했고, 이어서 API 서버와 kubelet이 차례로 붙으면서 노드가 Ready로 복귀했습니다. etcd는 raft term 변화 없이 같은 인덱스로 깨끗하게 재합류했습니다.

sudo sed -i -E 's#(- --listen-metrics-urls=).*#\1http://0.0.0.0:2381#' \
  /etc/kubernetes/manifests/etcd.yaml

kubectl exec -n kube-system etcd-<master-node> -- etcdctl \
  --endpoints=https://192.168.x.101:2379,https://192.168.x.102:2379,https://192.168.x.103:2379 \
  --cacert=/etc/kubernetes/pki/etcd/ca.crt \
  --cert=/etc/kubernetes/pki/etcd/server.crt \
  --key=/etc/kubernetes/pki/etcd/server.key endpoint health

이 작업 중 백업 파일을 /etc/kubernetes/manifests/ 안에 만들지 않도록 주의해야 합니다. kubelet이 그 디렉토리를 감시하므로 백업 파일까지 또 하나의 static pod로 읽어 중복 기동을 일으킵니다.

모든 스크레이프 실패를 클러스터 전역 장애로 오독

현상 — 장애 구간에 Prometheus 타깃이 대량으로 up=0이 되어, 마스터를 포함한 클러스터 전체가 끊긴 것처럼 보였습니다.

원인 — Prometheus 파드가 장애 노드 위에 있었습니다. 관측자가 장애 범위 안에 포함되면 “전부 죽었다”는 결과는 타깃이 아니라 관측자의 상태입니다. 여기에 query_range의 lookback 동작이 겹쳐, 스크레이프가 누락된 구간과 정상 구간을 구분할 수 없었습니다.

해결 — 클러스터 밖의 HAProxy 로그와 ARP 테이블을 독립 관측자로 사용해 범위를 다시 그렸습니다. 모니터링 스택이 감시 대상과 같은 장애 도메인에 있으면 그 스택의 데이터만으로는 범위를 확정할 수 없다는 점이 이번 분석의 가장 큰 교훈이었습니다.

축출 유예 60초와 컨트롤 플레인 메트릭 9/9

조치 이후에는 쿠버네티스 노드 순단 발생 시에도 스토리지 컨트롤 파드가 즉시 축출되지 않으며, 컨트롤 플레인 지표도 사후 분석이 가능한 수준으로 확보되었습니다.

항목 조치 전 조치 후
Rook 파드 축출 유예 5초 60초
etcd / scheduler / controller-manager 스크레이프 0 / 9 성공 9 / 9 성공
kubeadm upgrade 후 설정 유지 되돌아감 유지됨
etcd 쿼럼 3 / 3 3 / 3

변경 직후 뜻하지 않게 실전 검증도 이루어졌습니다. 마지막 마스터를 수정하면서 etcd 리더 선출이 일어났고, 그 여파로 노드 3대가 잠시 NotReady로 표시되었습니다. 기존 5초 설정이었다면 operator와 mgr, MDS가 그대로 축출되었을 상황입니다.

kubectl get pods -n rook-ceph

# 축출된 파드 없음 - AGE가 모두 toleration 변경 시점이거나 그 이전
rook-ceph-operator-...     1/1  Running  0  26m   # 설정 변경 시점
rook-ceph-mgr-a-...        3/3  Running  0  25m
rook-ceph-mds-myfs-a-...   2/2  Running  0  25m
rook-ceph-mon-a-...        2/2  Running  0  35d   # 무변동
rook-ceph-osd-0-...        2/2  Running  0  35d   # 무변동

Ceph 파드 축출은 한 건도 발생하지 않았고, 노드는 자동으로 Ready로 복귀했습니다. 최종 상태는 노드 6대 Ready, 비정상 파드 0, etcd 쿼럼 3/3, Ceph HEALTH_OK입니다.

다만 정직하게 남겨둘 부분이 있습니다. 노드가 왜 9초간 사라졌는지는 아직 확정하지 못했습니다. 이번 조치는 원인 제거가 아니라 피해 범위를 줄이는 완화책이며, 하이퍼바이저 계층의 이벤트 로그를 확보하기 전까지는 재발 가능성이 남아 있습니다.

참고

관련 포스트:

참고 문서: Kubernetes Taints and Tolerations · kubeadm ClusterConfiguration v1beta4 · etcd Monitoring · Rook Ceph Operator Helm Chart