Kubernetes Kubernetes Memory OOMKilled Affinity Toleration

Kubernetes 노드 메모리 리밸런싱 — OOMKilled 해결과 워크로드 분산

Control Plane 노드에 메모리 대식가 워크로드가 몰려 OOMKilled가 반복됐다. fork()와 Copy-on-Write 메커니즘을 이해하고 affinity/toleration으로 워크로드를 분산한 과정.

node1에 메모리 대식가 워크로드가 몰려 88% 사용률에 도달했고, DefectDojo의 valkey가 OOMKilled로 2091회 재시작을 반복했다. 근본 원인을 분석하고 affinity/toleration을 활용해 워크로드를 분산한 과정을 정리한다.

워크로드 리밸런싱

환경

  • Kubernetes 1.32 (kubespray), Cilium Gateway API
  • 노드 3대: node1 (Control Plane + Worker), node2 (Worker), node3-gpu (GPU Worker)
  • Longhorn 분산 스토리지
  • DefectDojo (보안 취약점 관리): django, postgresql, valkey, celery
  • SonarQube (코드 품질 분석)

문제 상황

node1 메모리 과부하

kubectl top nodes 결과 node1 메모리 사용률이 88%에 도달했다. 주요 원인:

워크로드메모리 사용량노드
SonarQube~2.1GBnode1
DefectDojo (django + postgresql + valkey)~1.5GBnode1

valkey OOMKilled 반복

DefectDojo의 valkey(Redis 호환 캐시) pod가 OOMKilled 상태로 2091회 재시작을 반복했다.

defectdojo-valkey-master-0 0/1 OOMKilled 2091 7d

설정값:

  • requests: 64Mi
  • limits: 64Mi (chart 기본값이 override)
  • 실제 RDB 로드 시 사용량: 57.91MB

64Mi 한도에서 57.91MB를 사용하고 있었는데 왜 OOM이 발생했을까?

근본 원인 분석

valkey OOMKilled: fork()와 Copy-on-Write

valkey/Redis는 데이터를 메모리에 저장한다. 서버 재시작 시 데이터 유실을 방지하기 위해 주기적으로 RDB 스냅샷을 디스크에 저장한다.

저장 방식 비교:

방식동작단점
동기식메인 프로세스가 직접 저장저장 중 서비스 멈춤
비동기식 (fork)자식 프로세스가 저장메모리 추가 필요

valkey는 서비스 중단 없이 저장하기 위해 fork() 를 사용한다.

fork() 동작 흐름

1. 부모 프로세스: "지금 메모리 상태를 저장해야겠다"
2. fork() 호출 → 자식 프로세스 생성 (메모리 공유)
3. 자식: 메모리 내용을 디스크에 쓰기 시작
4. 부모: 클라이언트 요청 계속 처리
5. 자식: 저장 완료 후 종료

Copy-on-Write 메커니즘

fork() 직후에는 메모리를 복제하지 않는다. 부모와 자식이 같은 메모리 페이지를 공유한다.

fork() 직후:
부모 프로세스 ─┬─ 메모리 페이지 A (공유)
자식 프로세스 ─┘

복제는 쓰기가 발생할 때 일어난다 (Copy-on-Write):

부모가 데이터를 쓰면:
부모 프로세스 ── 메모리 페이지 A' (새로 복제)
자식 프로세스 ── 메모리 페이지 A (원본 유지)

OOM 발생 시나리오

valkey가 57MB를 사용하던 상황에서:

  1. background save 시작 → fork() 호출
  2. 자식 프로세스가 RDB 저장 중
  3. 부모 프로세스는 클라이언트 요청 처리 → 데이터 쓰기 발생
  4. 쓰기가 발생한 페이지마다 복제 → 메모리 급증
  5. 최악의 경우 2배 메모리 필요 (57MB × 2 = 114MB)
  6. 64Mi 한도 초과 → OOMKilled

스케줄링 불균형: requests 기반 배치

Kubernetes 스케줄러는 Pod를 노드에 배치할 때 requests를 기준으로 판단한다. 실제 메모리 사용량이 아니다.

예시:

  • Pod A: requests 100Mi, limits 500Mi, 실제 사용량 400Mi
  • 노드 여유 메모리: 200Mi

스케줄러 판단:

  • requests(100Mi) 기준 → “여유 있네” → 배치 허용
  • 실제 사용량(400Mi) 기준이었다면 → 배치 거부

SonarQube와 DefectDojo 모두 requests가 낮게 설정되어 있었다. 스케줄러는 “별로 안 쓰겠네”라고 판단하고 둘 다 node1에 배치했지만, 실제로는 메모리 대식가들이었다.

node3-gpu taint 문제

requests를 높이면 스케줄러가 분산하겠지만, node3-gpu에는 nvidia.com/gpu taint가 걸려 있다.

taints:
- key: nvidia.com/gpu
effect: NoSchedule

일반 Pod는 이 노드를 피한다. DefectDojo를 node3-gpu에 배치하려면:

  • toleration: taint를 무시하고 스케줄링 허용
  • affinity: 특정 노드로 강제 배치

해결

Phase 0: valkey 메모리 한도 상향

defectdojo.yaml에서 valkey 메모리 한도를 상향했다.

valkey:
master:
resources:
requests:
memory: 128Mi
limits:
memory: 256Mi

64Mi → 256Mi로 변경하여 fork() 시에도 OOM 없이 동작하도록 여유를 확보했다.

Phase 1: SonarQube → node2 이동

sonarqube.yaml에 affinity를 추가했다.

affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/hostname
operator: In
values:
- node2

Phase 2: DefectDojo → node3-gpu 이동

defectdojo.yaml에 django, postgresql, valkey 각각 affinity를 추가했다. celery는 이미 node2에 있어 이동하지 않았다.

django:
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/hostname
operator: In
values:
- node3-gpu
tolerations:
- key: nvidia.com/gpu
operator: Exists
effect: NoSchedule
postgresql:
primary:
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/hostname
operator: In
values:
- node3-gpu
tolerations:
- key: nvidia.com/gpu
operator: Exists
effect: NoSchedule
valkey:
master:
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/hostname
operator: In
values:
- node3-gpu
tolerations:
- key: nvidia.com/gpu
operator: Exists
effect: NoSchedule

에러 요약

Phase에러원인해결
2PostgreSQL 인증 실패createPostgresqlSecret: true가 helm upgrade 시 새 비밀번호 생성 → 기존 PVC의 DB와 불일치Secret을 원래 비밀번호로 수동 패치

검증 결과

리밸런싱 후 노드 메모리 분포:

  • node1: 88% → 65% (목표 75% 미만 달성)
  • node2: SonarQube 정상 동작
  • node3-gpu: DefectDojo 정상 동작
  • valkey: OOMKilled 없이 안정 동작

핵심 개념 정리

OOMKilled

컨테이너가 메모리 limits를 초과하면 커널이 프로세스를 강제 종료한다. Kubernetes는 이를 OOMKilled로 기록하고 pod를 재시작한다.

fork()와 Copy-on-Write

  • fork(): 프로세스를 복제하여 자식 프로세스를 생성. 서비스 중단 없이 백그라운드 작업 수행에 사용.
  • Copy-on-Write: fork() 시 메모리를 즉시 복제하지 않고 쓰기 발생 시에만 복제. 평소에는 메모리 효율적이나, 쓰기가 많으면 메모리 급증.

requests vs limits

항목용도스케줄러 참조
requests최소 보장 리소스O (배치 기준)
limits최대 허용 리소스X

스케줄러는 requests만 보고 배치를 결정한다. requests가 낮으면 실제 사용량과 관계없이 한 노드에 몰릴 수 있다.

affinity vs toleration

항목역할
affinity”이 노드로 가라” (배치 지정)
toleration”이 taint를 무시해라” (배치 허용)

taint가 있는 노드에 특정 워크로드를 배치하려면 둘 다 필요하다.

핵심 결정 사항

결정근거
valkey limits 256Mifork() 시 최대 2배 메모리 필요, 57MB × 2 + 여유
affinity로 노드 지정requests 조정만으로는 node3-gpu taint 회피 불가
celery는 node2 유지이미 분산되어 있어 이동 불필요

관련 콘텐츠

댓글