* DevSecOps/Operations

Karpenter v1.5.3 노드 통합으로 인한 대규모 장애 분석 및 해결기

Twodragon 2025. 10. 2. 17:25

Karpenter v1.5.3의 공격적인 노드 통합 정책과 PodDisruptionBudget 미설정으로 인해 프로덕션 환경에서 20개 이상의 Pod가 동시에 재시작되며 약 10분간 서비스 장애가 발생했습니다. 이 글에서는 장애의 근본 원인 분석부터 해결 방안까지 상세히 다룹니다.

🚨 사건의 시작

2025년 10월 2일 오후 3시 43분, 갑자기 모니터링 대시보드에 빨간 불이 들어왔습니다.

[CRITICAL] API Gateway health-check failed
HTTPConnectionPool(host='10.20.112.175', port=80):
Max retries exceeded with url: /actuator/health/liveness
Connection refused

처음에는 단순한 배포나 설정 변경으로 인한 문제라고 생각했습니다. 하지만 로그를 확인하는 순간, 이것이 단순한 문제가 아님을 깨달았습니다.

📊 무슨 일이 있었나?

타임라인

15:43:43 - Karpenter가 30일 경과한 노드 3개를 통합(consolidation) 대상으로 식별

# 노드들이 이렇게 표시됨
node-1 (30d old, AMI v1.31.11) → DRIFTED: True
node-2 (30d old, AMI v1.31.11) → DRIFTED: True
node-3 (30d old, AMI v1.31.11) → DRIFTED: True

15:43:44 - 3개 노드에 동시에 karpenter.sh/disrupted=NoSchedule taint 적용

15:44:43 - 약 20개 Pod가 동시에 재스케줄링 시작

  • API Gateway
  • Auth Service
  • User Service
  • Payment Service
  • Notification Service
  • Config Server
  • ... 그리고 더 많은 서비스들

15:45:29 - 연쇄 장애 시작

Connection refused: config-server.backend.svc.cluster.local:8888
Connection refused: auth-service.backend.svc.cluster.local:80
Connection refused: user-service.backend.svc.cluster.local:80

15:47:00 - 모든 Pod가 Ready 상태 도달, 서비스 복구

피해 규모:

  • 영향받은 서비스: 8개 핵심 서비스
  • 재시작된 Pod: 20개 이상
  • 장애 시간: 약 10분
  • 사용자 영향: 일시적인 503 에러, 인증 실패

🔍 근본 원인 분석

1. 공격적인 Karpenter 통합 정책

문제의 시작은 몇 주 전으로 거슬러 올라갑니다.

설정 변경 히스토리:

# 2025년 9월 18일 - 보수적 설정
consolidationPolicy: WhenEmpty  # 빈 노드만 제거

# 2025년 9월 19일 - 공격적 설정으로 변경 🔴
consolidationPolicy: WhenEmptyOrUnderutilized  # 사용률 낮은 노드도 제거
consolidateAfter: 10m  # 단 10분 후!

# 2025년 10월 1일 - Karpenter v1.5.3로 업그레이드
# v1.5.x는 더 공격적인 통합 알고리즘 탑재

Karpenter v1.5.0의 주요 변경사항:

  • "비어있는 노드를 다른 방법보다 우선시"
  • "churn 없는 빈 노드도 중단 허용"

결과: 30일 된 노드가 AMI drift로 인식되자마자 즉시 교체 시작

2. PodDisruptionBudget 미설정

가장 치명적인 문제는 아무런 안전장치가 없었다는 것입니다.

# 우리의 설정 (문제)
# ... PDB가 전혀 없음 ...

# 있어야 했던 설정
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: config-server-pdb
spec:
  minAvailable: 1  # 최소 1개는 유지!

PDB가 없어서:

  • Karpenter가 모든 Pod를 동시에 축출 가능
  • 한 번에 20개 이상의 Pod가 재시작
  • Config Server도 다운되어 전체 시스템 마비

3. 의존성 지옥

서비스 간 강한 결합이 문제를 악화시켰습니다.

Config Server 다운
    ↓
모든 앱이 설정을 로드할 수 없음
    ↓
Auth Service 다운
    ↓
모든 API가 인증을 할 수 없음
    ↓
전체 시스템 마비

4. 짧은 Probe 타임아웃

# 기존 설정
readinessProbe:
  initialDelaySeconds: 60  # 복잡한 앱에는 너무 짧음

# Spring Boot 앱 시작 시간
- Config Server 로드: ~10초
- Auth Service 연결: ~5초
- 데이터베이스 커넥션 풀: ~8초
- 캐시 워밍: ~10초
합계: ~33초 (여유시간 27초밖에 없음!)

💡 왜 이런 일이 발생했나?

완벽한 폭풍(Perfect Storm):

  1. ✅ Karpenter v1.5.3의 강화된 통합 로직
  2. WhenEmptyOrUnderutilized 정책 활성화
  3. ✅ 매우 짧은 consolidateAfter: 10m
  4. ✅ PDB 완전히 없음
  5. ✅ 30일 된 노드 3개 존재 (AMI drifted)
  6. ✅ 노드 생성 완료 → 즉시 통합 시작

결과: 3개 노드의 모든 Pod가 동시에 재시작

🛠️ 해결 방안

즉시 조치 (24시간 내) - P0

1. PodDisruptionBudget 추가 (필수!)

Config Server (가장 중요!)

apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: config-server-pdb
  namespace: backend
spec:
  minAvailable: 1  # 반드시 1개는 유지!
  selector:
    matchLabels:
      app: config-server

일반 서비스

apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: api-gateway-pdb
spec:
  maxUnavailable: 1  # 한 번에 1개씩만
  selector:
    matchLabels:
      app: api-gateway

대규모 서비스 (10개 이상 replica)

apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: user-service-pdb
spec:
  maxUnavailable: 33%  # 최대 1/3까지
  selector:
    matchLabels:
      app: user-service

2. Karpenter 정책 변경

Option A: 안전 모드 (권장)

spec:
  disruption:
    consolidationPolicy: WhenEmpty  # 완전히 빈 노드만
    consolidateAfter: 30m           # 30분으로 증가
    budgets:
      - nodes: "10%"

Option B: 스케줄 기반 (더 안전)

spec:
  disruption:
    consolidationPolicy: WhenEmptyOrUnderutilized
    consolidateAfter: 1h
    budgets:
      - nodes: "5%"
        schedule: "0 2 * * *"  # 새벽 2시에만!
        duration: 2h
      - nodes: "0"  # 나머지 시간 완전 차단

Option C: Drift만 허용

spec:
  disruption:
    consolidationPolicy: WhenEmpty
    consolidateAfter: 24h
    # AMI drift된 노드만 교체
  template:
    spec:
      expireAfter: 720h  # 30일 후 만료

단기 조치 (1주일 내) - P1

3. Readiness Probe 개선

# Before
readinessProbe:
  initialDelaySeconds: 60
  failureThreshold: 4

# After
readinessProbe:
  httpGet:
    path: /actuator/health/readiness
    port: 8080
  initialDelaySeconds: 120  # 2배로 증가
  periodSeconds: 10
  timeoutSeconds: 5
  failureThreshold: 3
  successThreshold: 1

4. PreStop Hook 추가

lifecycle:
  preStop:
    exec:
      command:
        - sh
        - -c
        - |
          # graceful shutdown
          sleep 15
          # 또는 앱에 shutdown 신호 전송
          # curl -X POST localhost:8080/actuator/shutdown

5. Startup Probe 추가

startupProbe:
  httpGet:
    path: /actuator/health/liveness
    port: 8080
  initialDelaySeconds: 0
  periodSeconds: 5
  failureThreshold: 30  # 최대 150초 = 2.5분

중기 조치 (1개월 내) - P2

6. Config Server를 Optional로 변경

# application.yml
spring:
  cloud:
    config:
      fail-fast: false  # 실패해도 시작!
      retry:
        max-attempts: 3
        initial-interval: 3000
        max-interval: 10000

7. Circuit Breaker 강화

@Service
class AuthService(
    private val authClient: AuthClient,
    private val cache: CacheManager
) {

    @CircuitBreaker(
        name = "auth-service",
        fallbackMethod = "fallbackAuth"
    )
    @Retry(
        name = "auth-service",
        maxAttempts = 3
    )
    fun verifyToken(token: String): AuthResponse {
        return authClient.verify(token)
    }

    private fun fallbackAuth(token: String, ex: Exception): AuthResponse {
        logger.warn("Auth service unavailable, using cache", ex)

        // 캐시에서 시도
        return cache.get(token)?.let { cached ->
            AuthResponse(
                valid = true,
                userId = cached.userId,
                fromCache = true
            )
        } ?: throw ServiceUnavailableException(
            "Auth service temporarily unavailable"
        )
    }
}

8. 헬스체크에 의존성 체크 추가

@Component
class DependencyHealthIndicator(
    private val configServerClient: ConfigServerClient,
    private val databaseConnection: DataSource
) : HealthIndicator {

    override fun health(): Health {
        val checks = mutableMapOf<String, Boolean>()

        // Config Server 체크
        checks["config-server"] = try {
            configServerClient.ping()
            true
        } catch (e: Exception) {
            false
        }

        // DB 체크
        checks["database"] = try {
            databaseConnection.connection.use { it.isValid(1) }
        } catch (e: Exception) {
            false
        }

        val allHealthy = checks.values.all { it }

        return if (allHealthy) {
            Health.up()
                .withDetails(checks)
                .build()
        } else {
            Health.down()
                .withDetails(checks)
                .withDetail("reason", "Dependencies not ready")
                .build()
        }
    }
}

🚀 Karpenter v1.7.1 업그레이드 고려사항

현재 AWS에서는 v1.7.1로의 업그레이드를 권장하고 있습니다. 주요 개선사항:

v1.7.0/v1.7.1에서 해결된 문제들

1. AMI Drift 처리 시 Disruption Budget 무시 문제 해결

# v1.5.3의 문제
- consolidation이 비활성화되어도 drift 노드가 종료되지 않음
- disruption budget이 drift 처리 시 무시됨

# v1.7.0에서 해결
- drift와 consolidation이 독립적으로 동작
- disruption budget이 모든 경우에 올바르게 적용

2. 성능 개선

  • AMI drift 감지 시 DescribeInstances API 호출 감소
  • instance.ImageID 대신 nodeClaim.Status.ImageID 사용

업그레이드 전략

현재 설정 확인:

spec:
  template:
    spec:
      expireAfter: 720h  # 30일

→ 이 설정 덕분에 현재는 30일 이내 노드 교체가 발생하지 않음

권장 업그레이드 절차:

  1. 사전 준비 (1주일)
  2. # PDB 먼저 적용 kubectl apply -f pdb/ # 모니터링 강화 kubectl apply -f prometheus-alerts/karpenter.yaml
  3. 테스트 환경 검증 (3일)
  4. # 개발 환경에서 먼저 테스트 helm upgrade karpenter oci://public.ecr.aws/karpenter/karpenter \ --version 1.7.1 \ --namespace karpenter \ -f values-dev.yaml
  5. 프로덕션 업그레이드 (새벽 시간대)
  6. # 새벽 2-4시 유지보수 시간 helm upgrade karpenter oci://public.ecr.aws/karpenter/karpenter \ --version 1.7.1 \ --namespace karpenter \ -f values-prod.yaml \ --wait
  7. 업그레이드 후 모니터링 (1주일)
    • NodeClaim drift 이벤트 모니터링
    • Disruption budget 준수 확인
    • 노드 교체 패턴 관찰

📈 모니터링 및 알림 개선

Prometheus Alerts

# alerts/karpenter.yaml
groups:
  - name: karpenter-disruption
    rules:
      # 노드 중단 알림
      - alert: KarpenterNodeDisruption
        expr: karpenter_nodes_disrupted > 0
        for: 1m
        labels:
          severity: warning
          team: platform
        annotations:
          summary: "Karpenter가 노드를 중단하고 있습니다"
          description: "{{ $value }}개 노드가 중단 중입니다. PDB 확인이 필요합니다."

      # 대량 중단 알림
      - alert: KarpenterMassDisruption
        expr: count(karpenter_nodeclaims_disrupted{reason="consolidation"} == 1) > 2
        for: 5m
        labels:
          severity: critical
          team: platform
        annotations:
          summary: "🚨 Karpenter 대량 노드 중단 감지"
          description: "{{ $value }}개 노드가 동시에 중단되고 있습니다! 즉시 확인이 필요합니다."
          runbook: "https://wiki.company.com/runbook/karpenter-mass-disruption"

      # PDB 위반 알림
      - alert: PodDisruptionBudgetViolation
        expr: kube_poddisruptionbudget_status_pods_allowed == 0
        for: 2m
        labels:
          severity: warning
        annotations:
          summary: "PDB가 모든 중단을 차단하고 있습니다"
          description: "{{ $labels.poddisruptionbudget }}의 허용 Pod 수가 0입니다."

Grafana 대시보드

{
  "dashboard": {
    "title": "Karpenter Operations",
    "panels": [
      {
        "title": "Node Disruptions (24h)",
        "targets": [
          {
            "expr": "sum(rate(karpenter_nodes_disrupted[5m])) by (reason)"
          }
        ]
      },
      {
        "title": "Active PDBs",
        "targets": [
          {
            "expr": "count(kube_poddisruptionbudget_status_pods_allowed > 0)"
          }
        ]
      },
      {
        "title": "Pod Restart Rate",
        "targets": [
          {
            "expr": "sum(rate(kube_pod_container_status_restarts_total[5m])) by (namespace)"
          }
        ]
      }
    ]
  }
}

슬랙 알림 설정

# alertmanager.yml
route:
  receiver: 'slack-platform'
  group_by: ['alertname', 'cluster']
  group_wait: 10s
  group_interval: 5m
  repeat_interval: 3h
  routes:
    - match:
        severity: critical
      receiver: 'slack-oncall'
      continue: true

receivers:
  - name: 'slack-platform'
    slack_configs:
      - api_url: 'https://hooks.slack.com/services/...'
        channel: '#platform-alerts'
        title: '{{ .GroupLabels.alertname }}'
        text: '{{ range .Alerts }}{{ .Annotations.description }}{{ end }}'

  - name: 'slack-oncall'
    slack_configs:
      - api_url: 'https://hooks.slack.com/services/...'
        channel: '#oncall-critical'
        title: '🚨 CRITICAL: {{ .GroupLabels.alertname }}'
        text: '{{ range .Alerts }}{{ .Annotations.description }}{{ end }}'

🧪 Chaos Engineering: 사전 예방

이런 장애를 미연에 방지하려면?

1. 정기적인 노드 중단 테스트

#!/bin/bash
# chaos-test-node-disruption.sh

# 업무 시간에 노드 하나를 drain 해보기
kubectl drain <node-name> \
  --ignore-daemonsets \
  --delete-emptydir-data \
  --grace-period=30 \
  --timeout=5m

# 서비스 헬스 체크
for i in {1..60}; do
  curl -f https://api.company.com/health || echo "FAILED at $i seconds"
  sleep 1
done

# 노드 복구
kubectl uncordon <node-name>

2. PDB 효과성 검증

# PDB가 제대로 동작하는지 확인
kubectl get pdb --all-namespaces

# 각 PDB의 현재 상태 확인
kubectl describe pdb config-server-pdb

# 예상 출력:
# Allowed disruptions: 2  ✅ OK
# Current: 3
# Desired: 3
# Total: 3

3. Karpenter Dry-Run 테스트

# test-consolidation.yaml
apiVersion: karpenter.sh/v1
kind: NodePool
metadata:
  name: test-consolidation
spec:
  disruption:
    consolidationPolicy: WhenEmptyOrUnderutilized
    consolidateAfter: 1m  # 테스트용으로 짧게
    budgets:
      - nodes: "1"  # 한 번에 1개만

📚 교훈 및 베스트 프랙티스

배운 점

  1. PDB는 선택이 아닌 필수
    • 모든 프로덕션 서비스에 PDB 설정
    • 특히 Config Server, Auth Service 같은 핵심 인프라는 minAvailable: 1 필수
  2. Karpenter 설정은 보수적으로
    • WhenEmpty가 대부분의 경우 충분함
    • consolidateAfter는 최소 30분 이상
    • 스케줄 기반 통합이 가장 안전
  3. 의존성을 느슨하게
    • Config Server fail-fast: false
    • Circuit Breaker + Fallback 패턴
    • 헬스체크에 의존성 체크 포함
  4. 점진적 롤아웃
    • 인프라 변경도 카나리 배포
    • 테스트 환경 → 스테이징 → 프로덕션

체크리스트

새 서비스 배포 시:

  • PodDisruptionBudget 설정했는가?
  • Readiness probe가 충분히 긴가? (최소 120초)
  • PreStop hook이 있는가?
  • Circuit Breaker가 있는가?
  • 헬스체크가 의존성을 확인하는가?

Karpenter 설정 변경 시:

  • 테스트 환경에서 먼저 검증했는가?
  • consolidateAfter가 충분히 긴가? (최소 30분)
  • Disruption budget이 설정되어 있는가?
  • 모니터링과 알림이 준비되었는가?
  • 롤백 계획이 있는가?

🎯 결론

이번 장애는 다음 세 가지의 완벽한 조합으로 발생했습니다:

  1. Karpenter v1.5.3의 공격적인 통합 정책
  2. PodDisruptionBudget 완전 부재
  3. 강한 서비스 간 결합

하지만 이를 통해 많은 것을 배웠습니다:

  • 인프라 변경도 애플리케이션 배포만큼 신중해야 함
  • 안전장치(PDB)는 "나중에"가 아닌 "지금" 설정해야 함
  • 의존성은 언제나 실패할 수 있다고 가정해야 함

핵심 메시지:

클라우드 네이티브 환경에서는 "모든 것이 실패할 수 있다"는 전제로 시스템을 설계해야 합니다. Karpenter의 노드 통합은 정상적인 동작이지만, 우리의 시스템이 이에 대비되어 있지 않았던 것이 문제였습니다.


📖 참고 자료


이 글이 도움이 되셨나요? 댓글로 여러분의 경험을 공유해주세요! 🙏

유사한 장애를 경험하셨거나 더 나은 해결 방법이 있다면 알려주세요!