Karpenter v1.5.3 노드 통합으로 인한 대규모 장애 분석 및 해결기
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):
- ✅ Karpenter v1.5.3의 강화된 통합 로직
- ✅
WhenEmptyOrUnderutilized정책 활성화 - ✅ 매우 짧은
consolidateAfter: 10m - ✅ PDB 완전히 없음
- ✅ 30일 된 노드 3개 존재 (AMI drifted)
- ✅ 노드 생성 완료 → 즉시 통합 시작
→ 결과: 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 감지 시
DescribeInstancesAPI 호출 감소 instance.ImageID대신nodeClaim.Status.ImageID사용
업그레이드 전략
현재 설정 확인:
spec:
template:
spec:
expireAfter: 720h # 30일
→ 이 설정 덕분에 현재는 30일 이내 노드 교체가 발생하지 않음
권장 업그레이드 절차:
- 사전 준비 (1주일)
# PDB 먼저 적용 kubectl apply -f pdb/ # 모니터링 강화 kubectl apply -f prometheus-alerts/karpenter.yaml- 테스트 환경 검증 (3일)
# 개발 환경에서 먼저 테스트 helm upgrade karpenter oci://public.ecr.aws/karpenter/karpenter \ --version 1.7.1 \ --namespace karpenter \ -f values-dev.yaml- 프로덕션 업그레이드 (새벽 시간대)
# 새벽 2-4시 유지보수 시간 helm upgrade karpenter oci://public.ecr.aws/karpenter/karpenter \ --version 1.7.1 \ --namespace karpenter \ -f values-prod.yaml \ --wait- 업그레이드 후 모니터링 (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개만
📚 교훈 및 베스트 프랙티스
배운 점
- PDB는 선택이 아닌 필수
- 모든 프로덕션 서비스에 PDB 설정
- 특히 Config Server, Auth Service 같은 핵심 인프라는
minAvailable: 1필수
- Karpenter 설정은 보수적으로
WhenEmpty가 대부분의 경우 충분함consolidateAfter는 최소 30분 이상- 스케줄 기반 통합이 가장 안전
- 의존성을 느슨하게
- Config Server fail-fast: false
- Circuit Breaker + Fallback 패턴
- 헬스체크에 의존성 체크 포함
- 점진적 롤아웃
- 인프라 변경도 카나리 배포
- 테스트 환경 → 스테이징 → 프로덕션
체크리스트
새 서비스 배포 시:
- PodDisruptionBudget 설정했는가?
- Readiness probe가 충분히 긴가? (최소 120초)
- PreStop hook이 있는가?
- Circuit Breaker가 있는가?
- 헬스체크가 의존성을 확인하는가?
Karpenter 설정 변경 시:
- 테스트 환경에서 먼저 검증했는가?
- consolidateAfter가 충분히 긴가? (최소 30분)
- Disruption budget이 설정되어 있는가?
- 모니터링과 알림이 준비되었는가?
- 롤백 계획이 있는가?
🎯 결론
이번 장애는 다음 세 가지의 완벽한 조합으로 발생했습니다:
- Karpenter v1.5.3의 공격적인 통합 정책
- PodDisruptionBudget 완전 부재
- 강한 서비스 간 결합
하지만 이를 통해 많은 것을 배웠습니다:
- 인프라 변경도 애플리케이션 배포만큼 신중해야 함
- 안전장치(PDB)는 "나중에"가 아닌 "지금" 설정해야 함
- 의존성은 언제나 실패할 수 있다고 가정해야 함
핵심 메시지:
클라우드 네이티브 환경에서는 "모든 것이 실패할 수 있다"는 전제로 시스템을 설계해야 합니다. Karpenter의 노드 통합은 정상적인 동작이지만, 우리의 시스템이 이에 대비되어 있지 않았던 것이 문제였습니다.
📖 참고 자료
- Karpenter v1.5.0 Release Notes
- Karpenter v1.7.0 Release Notes
- Kubernetes PodDisruptionBudget
- Karpenter Disruption Documentation
- AWS re:Invent - Karpenter Best Practices
이 글이 도움이 되셨나요? 댓글로 여러분의 경험을 공유해주세요! 🙏
유사한 장애를 경험하셨거나 더 나은 해결 방법이 있다면 알려주세요!