Skip to content

경험공유 - Node OOM으로 인한 Pod 대규모 재배치

실제 운영 중 겪었던 노드 OOM(Out-of-Memory) 장애와 그로 인한 Pod 대규모 재배치 상황을 기록한다.

이 글은 두 파트로 나뉜다. 먼저 실제로 어떻게 디버깅했는지, 당시 급히 작업했던 내용을 공유했다.

그리고 그 뒤에, 정석적인 infrastructure 디버깅 방법인 6-Layer 디버깅 과정을 알고 있었다면 같은 상황을 어떤 순서로 접근했을지 가정해서 비교해봤다.

당시 환경

EKS 위에서 GitLab과 개발 지원을 위한 여러 서비스들을 (캐쉬 서버 등.. 및 수많은 CI/CD용 러너들) 을 지원하는 환경을 구성하고 있는 상황이였다.

그외 별도의 cluster안에 Grafana Alloy, Prometheus, grafana 등 모니터링 스택이 존재하는 상황이였다.

문제가 있던 대상 노드는 Worker Node 2대로 구성되어 있으며, 이하 node-A, node-B로 표기한다.

장애 타임라인

장애 인지 시점을 T+0으로 기준 삼아 정리했다.

경과 시간 이벤트
T+0 빌드 시스템에서 다수의 CI 파이프라인이 동시에 트리거됨
T+2~4분 GitLab Runner들의 요청이 Gitaly에 집중, CPU/RAM 급격히 증가
T+약 16분 node-A MemoryPressure=True 전환 (노드는 여전히 Ready). kubelet이 eviction threshold 초과를 감지하고 Pod 순차 종료 시작
T+약 20분 Eviction 속도가 메모리 회수 속도를 따라가지 못함. kubelet heartbeat 지연 → node-A NotReady 전환
T+약 34분 커널 OOM Killer 발동 기록 (노드가 응답을 재개한 시점 기준, 실제 발동은 더 이른 시각일 가능성 있음)
T+34분~ 살아있는 Pod들이 node-B로 재배치 시도. node-B ~40개, node-A ~7개로 불균형
T+약 60분 node-A 복구. PV 위치 제약으로 Gitaly가 node-A에서 재시작

Part 1. 실제로 어떻게 디버깅했는가

처음에는 그냥 막막했다

kubectl get nodes를 쳤더니 node-A가 NotReady로 떠 있었다. Pod들은 EvictedPending 상태가 뒤섞여 있었고, 일단 뭔가 크게 잘못됐다는 건 알았는데 어디서부터 봐야 할지 몰랐다.

kubectl get nodes
# node-A NotReady
kubectl get pods -A
# 수십 개의 Evicted, Pending Pod들...

노드가 NotReady인데 노드에 SSH를 통한 통신은 불가능하게 구성해두었기에 답답한 상황이었다. 일단 모니터링 대시보드를 켜서 어느 시점부터 문제가 시작됐는지부터 파악하려고 했다.

kubectl debug로 노드에 직접 접근

SSH가 막혀 있어서 kubectl debug node를 사용했다. 이 명령은 노드 위에 디버그용 Pod를 띄워서 노드의 파일시스템과 프로세스에 접근할 수 있게 해준다.

kubectl debug node/node-A -it --image=ubuntu
# 디버그 Pod가 뜨면서 쉘이 열린다

접속하면 /host 아래에 노드의 루트 파일시스템이 마운트되어 있다. chroot로 노드 환경으로 전환한다.

chroot /host

dmesg로 커널 로그 확인

노드 안에 들어오면 가장 먼저 dmesg를 확인했다. OOM Killer가 발동했다면 커널 로그에 흔적이 남아있다.

# 기본 dmesg는 부팅 이후 경과 초(sec) 기준이라 언제 발생했는지 파악이 어렵다.
# -T 옵션으로 wall clock 타임스탬프를 함께 출력한다.
dmesg -T | grep -i "oom\|kill\|out of memory"

Gitaly 프로세스가 OOM Kill의 직접적인 타깃이었다는 걸 여기서 처음 알았다. task_memcg 경로에 gitaly가 명시되어 있었고, burstable cgroup 아래에 있다는 것도 확인됐다. limits가 없으면 Kubernetes는 해당 Pod를 Burstable QoS로 분류하고 cgroup 상한을 설정하지 않는데, 그 때문에 Gitaly가 노드 메모리 전체를 소진할 때까지 아무 제약 없이 동작할 수 있었던 것이다.

kubelet 로그도 확인했다.

journalctl -u kubelet --since "1 hour ago" | grep -iE "evict|memory|oom"

Eviction 시작 시각과 OOM Kill 시각이 다르다는 것도 여기서 확인됐다. kubelet은 장애 발생 후 약 16분 시점부터 임계값을 감지하고 Pod를 순차적으로 종료하기 시작했지만, 그게 충분히 빠르지 않았고 결국 커널의 OOM Killer가 약 34분 시점에 직접 개입했다.

Grafana에서 메트릭과 로그로 상관관계 파악

노드 접근으로 사건의 단서는 확보했는데, 왜 갑자기? 라는 질문이 남았다. Grafana를 열어서 해당 시간대의 메트릭을 봤다.

메모리 사용량 그래프 (container_memory_working_set_bytes):

  • T+0 이전: 전반적으로 안정적
  • T+2~4분: Gitaly 메모리 사용량이 급격히 올라가기 시작
  • T+약 16분: node-A 전체 가용 메모리 소진, 노드 MemoryPressure 전환

Grafana Alloy가 수집한 kubelet 이벤트 로그:

Grafana의 Explore 탭에서 Loki로 kubelet 이벤트를 보니 T+0 직후부터 Gitaly 관련 로그 볼륨이 눈에 띄게 증가했다. 이 시점이 빌드 시스템에서 다수의 빌드가 동시에 트리거된 시점과 정확히 맞물렸다. GitLab Runner들이 일제히 Git 작업을 Gitaly에 요청하면서 부하가 집중된 것이었다.

최종 가설: 빌드 시스템에서 다수의 CI 파이프라인이 동시에 트리거되면서 GitLab Runner들이 Gitaly에 대량의 요청을 쏟아냈고, Gitaly 메모리가 폭증했다. limits가 설정되어 있지 않았기 때문에 제한 없이 노드 전체 메모리를 잠식했다.

# Gitaly resource 설정 확인 — 이게 문제였다
# limits가 아예 없었다
kubectl get pod gitaly-0 -n gitlab \
  -o jsonpath='{.spec.containers[0].resources}' | jq .

복구 조치 — scale up/down 트릭

Pod 분산을 위해 Deployment를 재시작했는데, 한 가지 문제가 생겼다. replicas=2인 Deployment를 rollout restart하면 새로 뜨는 두 Pod 모두 node-B에 배치되는 경우가 있었다. anti-affinity 설정이 없으니 Kubernetes 입장에서는 리소스가 충분한 아무 노드에나 배치해도 된다.

이걸 해결하기 위해 scale up -> scale down 방식을 썼다.

# 1. 먼저 replicas를 3으로 올린다
kubectl scale deployment/gitlab-webservice --replicas=3 -n <namespace>

# 2. 3번째 Pod가 node-A에 배치됐는지 확인
kubectl get pods -n gitlab -o wide -l app=webservice

# 3. 다시 2로 줄인다
kubectl scale deployment/gitlab-webservice --replicas=2 -n <namespace>
# Kubernetes가 같은 노드에 있는 두 Pod 중 하나를 제거 -> 두 노드에 1개씩 분산

재시작 대상 Deployment:

kubectl rollout restart deployment/<pod-name> -n <namesapce>
# ...
# 그외 연관된 것들 재시작

Part 2. 6-Layer 디버깅 프레임워크란?

6-Layer 프레임워크는 Kubernetes 클러스터에서 발생하는 장애를 체계적으로 추적하기 위한 접근 방식이다. 클러스터를 구성하는 요소를 6개의 레이어로 나누고, 위에서 아래로(Top-down) 또는 아래서 위로(Bottom-up) 순서대로 진단한다.

Layer 1: 클러스터 인프라     — 노드 상태, AWS 인프라, 스코프 파악
Layer 2: 컨트롤 플레인       — API Server, Scheduler, 인증/인가
Layer 3: 노드               — kubelet, containerd, 메모리/디스크 압박
Layer 4: 네트워크            — VPC CNI, CoreDNS, Service, Ingress
Layer 5: 워크로드            — Pod 상태, Deployment, HPA, Probe
Layer 6: 애플리케이션        — 앱 로그, 외부 의존성, 리소스 설정

Top-down: 서비스 장애처럼 증상이 먼저 보일 때. Layer 1부터 원인이 어느 레이어에 있는지 좁혀 내려간다.

Bottom-up: 예방적 점검이나 마이그레이션 후 검증처럼 기반부터 확인할 때.

프로덕션 인시던트에는 보통 Top-down이 더 효과적이다. 증상에서 시작해서 원인이 있는 레이어를 빠르게 특정할 수 있기 때문이다.


Part 3. 만약 6-Layer로 접근했다면 어땠을까?

당시에는 노드에 직접 들어가서 dmesg를 뒤지는 방식으로 접근했다. 증거는 결국 찾았지만 경로가 직관에 의존했다. 6-Layer 프레임워크를 알고 있었다면 아마 이런 순서로 접근했을 것 같다.

Layer 1: 클러스터 인프라 — "어느 범위가 문제인가?"

가장 먼저 전체 상태를 조감하고 스코프를 확정한다. 이 단계에서 "전체 클러스터 장애인가, 단일 노드 장애인가"를 먼저 판단하면 이후 진단이 훨씬 빠르다.

# 클러스터 자체는 살아있는가
aws eks describe-cluster --name my-cluster --query 'cluster.status' --output text
# ACTIVE — 컨트롤 플레인은 정상

# 노드 상태
kubectl get nodes -o wide
# node-A: NotReady, node-B: Ready -> 단일 노드 장애로 확정

# 비정상 Pod 전체 파악
kubectl get pods -A

판단: 전체 클러스터가 아닌 node-A 단독 문제. Layer 2(컨트롤 플레인)는 건너뛰거나 가볍게 확인하고 Layer 3으로 집중한다.

Layer 2: 컨트롤 플레인 — "API/Scheduler는 정상인가?"

Layer 1에서 컨트롤 플레인이 정상임을 이미 확인했으므로 빠르게 지나간다. 다만 Scheduler가 재배치 시도를 하고 있는지 정도는 확인할 수 있다.

# API Server 응답 확인
kubectl cluster-info

# CloudWatch Logs에서 Scheduler 로그 확인
# /aws/eks/<cluster>/cluster -> scheduler 로그
# "0/2 nodes are available: 1 Insufficient memory" 패턴이 보이면
# -> 재배치 시도는 하고 있으나 다른 노드도 여유가 없는 상황

이 단계에서 "컨트롤 플레인 문제 없음, 노드 문제"를 확인하면 곧바로 Layer 3으로 이동한다.

Layer 3: 노드 — "노드에서 무슨 일이 일어났는가?"

이번 인시던트의 핵심 레이어다. 실제로 했던 것처럼 kubectl debug node로 접근하는 것이 여기서 나온다. 6-Layer 프레임워크도 노드 레이어에서는 이 방법을 권장한다.

# Condition 확인 — 증상의 종류를 먼저 파악
kubectl describe node node-A | grep -A10 Conditions

# 리소스 할당 현황
kubectl describe node node-A | grep -A10 "Allocated resources"

# 노드 접근 (SSH 불가 시)
kubectl debug node/node-A -it --image=ubuntu

노드 안에서:

chroot /host

# OOM Killer 발동 여부 — 커널 로그 확인
dmesg | grep -iE "oom|out of memory|killed process"
# -> Gitaly 프로세스가 타깃이었음 확인

# kubelet Eviction 시작 시점 확인
journalctl -u kubelet --since "1 hour ago" \
  | grep -iE "evict|threshold|reclaim"

6-Layer 방식으로 접근했다면 dmesg에 도달하는 경로가 훨씬 명확했을 것이다. 당시에는 "노드가 이상하니까 일단 들어가서 보자"는 식이었는데, 프레임워크가 있었으면 "Layer 3, MemoryPressure 확인 -> 노드 진입 -> dmesg + kubelet 로그"라는 순서가 머릿속에 명확하게 그려졌을 것 같다.

Layer 4: 네트워크 — "통신에 영향은 없었는가?"

노드 장애가 네트워크 레이어에 미친 영향을 확인한다.

kubectl get pods -n kube-system -l k8s-app=kube-dns -o wide
kubectl run dnstest --image=busybox:1.36 --rm -it -- sh -c "time nslookup gitlab.svc.cluster.local"

이번 케이스에서 CoreDNS는 2기 구성이어서 완전 단절은 아니었지만, 재배치 과정에서 일시적으로 1기가 불안정한 상태를 거쳤다. GitLab 내부 서비스 간 통신이 간헐적으로 느려진 게 이것과 연관됐을 수 있다.

Layer 5: 워크로드 — "Pod들은 어떤 상태인가?"

# Evicted/Pending Pod 전체 파악
kubectl get pods -A | grep -E "Evicted|Pending"

# 스케줄링 실패 원인 확인
kubectl get events -A --sort-by='.lastTimestamp' | grep -E "Insufficient|FailedScheduling"
# "0/2 nodes are available: 1 Insufficient memory, 1 node(s) had taint"

# 재배치 후 불균형 확인
kubectl get pods -A -o wide | awk '{print $8}' | sort | uniq -c
# node-A: 7개, node-B: 40개

여기서 Gitaly가 왜 재배치가 안 됐는지도 알 수 있다.

kubectl describe pod gitaly-0 -n gitlab | grep -A5 "Events"
# "pod has unbound immediate PersistentVolumeClaims"
# EBS 볼륨이 node-A가 있는 AZ에 바인딩되어 있어서 다른 노드로 갈 수 없다

Layer 6: 애플리케이션 — "앱 자체에 뭔가 설정 문제가 있지 않았나?"

근본 원인을 확정하는 레이어다. 여기서 "왜 Gitaly가 메모리를 이렇게 많이 썼는가"를 본다.

# resource 설정 확인
kubectl get pod gitaly-0 -n gitlab -o jsonpath='{.spec.containers[0].resources}' | jq .
# requests는 있지만 limits가 없다

Grafana에서 메모리 사용량 추이도 같이 확인했다.

# 빌드 트리거 시점부터 급격히 우상향
container_memory_working_set_bytes{pod=~"gitaly.*"}

Grafana 메트릭과 dmesg에서 얻은 증거를 합치면 최종 결론이 나온다.

빌드 시스템에서 다수의 CI 파이프라인이 동시에 트리거되면서 GitLab Runner들이 Gitaly에 대량의 Git 요청을 집중시켰다. Gitaly에 limits가 없었기 때문에 메모리가 무제한으로 증가해 노드 전체 메모리를 소진했고, kubelet Eviction → Linux OOM Kill 순으로 진행됐다.

두 접근 방식 비교

실제 했던 방식 6-Layer 프레임워크
시작점 "노드가 이상하니 일단 들어가보자" Layer 1에서 스코프 확정 후 해당 레이어로 이동
경로 직관 -> dmesg -> Grafana Layer 1 -> 3 -> 4 -> 5 -> 6 순서대로
장점 증거를 빠르게 찾음 빠뜨리는 레이어 없이 체계적으로 점검
단점 다른 레이어(네트워크, 워크로드)를 나중에 뒤늦게 점검 처음엔 다소 느리게 느껴질 수 있음

결과적으로는 둘 다 같은 원인에 도달했다. 다만 6 Layer 방식이었으면 CoreDNS 불안정처럼 놓칠 뻔했던 부수 피해를 더 일찍 체계적으로 파악했을 것 같다.

재발 방지 계획

일단... CI파이프라인을 동일한 시간에 모두 스케줄링을 하지말라고 부탁은 했지만....

그 외에도 다음과 같은 내용들을 새로운 issue로 생성하여서 관리하였다.

  • Pod Anti-Affinity: 같은 Deployment의 Pod가 서로 다른 노드에 배치되도록 강제
  • Resource Limits 추가: 전체 워크로드에 requests 추가, stateless 앱에 limits도 추가
  • cgroup 메모리 제한 설정: Gitaly 등 hard limit 적용이 어려운 앱에 cgroup 수준 메모리 상한 별도 구성
  • Gitaly 전용 노드 분리; taint/toleration으로 Gitaly를 격리하여 타 워크로드와 메모리 경합 방지
  • CI 동시 실행 수 제한: GitLab Runner의 concurrent 설정 및 프로젝트별 파이프라인 동시 실행 제한 검토