Istio Phantom Workloads 처리 방법
Phantom workload는 이미 사라진 Pod의 endpoint IP가 Envoy의 EDS-CLA에 stale로 남아 트래픽이 죽은 IP로 흘러 발생하는 유령 라우팅이다. 머릿속에 담을 단 하나의 그림: "이벤트 발생 → 새 EDS 도달" 사이의 시간축 윈도(phantom window). 이 윈도가 열려 있는 동안 들어온 요청이 죽은 IP로 간다. 근본 원인은 컨트롤→데이터 플레인 전파의 최종 일관성(propagation lag) 이라 윈도를 0으로 만들 수 없고, 대응은 같은 윈도를 두 각도에서 친다 — (A) 윈도 폭 줄이기(전파 가속, 마지막 수단)와 (B) 윈도 안 요청 흡수(retry + outlier detection + graceful drain, 실무 표준).
1. 배경: 왜 데이터 플레인은 진실을 모르는가
서비스 메시에서 트래픽을 어디로 보낼지 결정하는 주체는 데이터 플레인(Envoy 사이드카) 이다. 그런데 Envoy는 K8s API를 직접 보지 않는다. 라우팅 결정은 전적으로 자기 안에 들고 있는 로컬 설정 스냅샷에 의존한다. 이 스냅샷, 특히 "이 서비스의 살아있는 endpoint IP 목록"(EDS-CLA, Cluster Load Assignment)은 컨트롤 플레인 istiod가 xDS의 EDS(Endpoint Discovery Service) 채널로 push해 줘야 갱신된다.
여기서 메시 아키텍처의 본질적 전제가 드러난다 — 데이터 플레인 설정은 eventually consistent(최종 일관성) 다. "진실"(실제 Pod 상태)은 K8s API에 있고, Envoy가 들고 있는 것은 그 진실의 지연된 복제본이다. 그래서 실제 endpoint 상태가 바뀐 순간과 Envoy가 그 변화를 인지하는 순간 사이에는 언제나 전파 지연(propagation lag)이 존재한다. 이건 튜닝 부족이 아니라, 중앙 컨트롤 플레인이 수천 개 프록시에 push로 동기화하는 구조라면 피할 수 없는 트레이드오프다.
Phantom workload는 바로 이 지연이 빚는 결함이다 — 실제로는 이미 사라졌거나 unhealthy인데, Envoy의 endpoint 목록에는 아직 살아있는 것으로 남아 트래픽이 계속 가는 가짜 endpoint. 죽은 IP를 가리키는 유령이라 "phantom"이다. 이 문서는 그 유령이 왜 생기는지(메커니즘), 어떻게 식별하는지(response flag), 그리고 어디서 잡는지(완화 A/B)를 시간축 하나로 꿰어 본다.
2. 메커니즘: phantom window라는 단 하나의 그림
앵커: phantom의 모든 것은 하나의 시간축 윈도로 환원된다. "Pod 종료 이벤트 발생" 시점에 윈도가 열리고, "새 EDS-CLA가 Envoy에 도달" 시점에 닫힌다. 이 윈도가 열려 있는 동안 호출자 사이드카가 EDS를 참조하면 죽은 IP를 healthy로 믿고 트래픽을 보낸다. 따라서 모든 완화는 두 동작 중 하나다 — 윈도를 빨리 닫거나(A), 윈도가 열린 동안 들어온 요청을 흡수하거나(B). 이 한 줄을 잡으면 아래 표·다이어그램·YAML이 전부 그 자리에 들어맞는다.
윈도가 열리고 닫히는 과정을 단계로 풀면:
| 단계 | 발생 과정 | 결과 |
|---|---|---|
| 1. 워크로드 상태 변화 | Pod가 종료/크래시 → kubelet이 Endpoints/EndpointSlice에서 제거, K8s API 이벤트 발생 | 컨트롤 플레인이 이벤트 수신 (윈도 열림) |
| 2. 구성 전파 지연 | istiod의 debounce(이벤트 배칭) + push 큐 대기 + xDS push 시간 동안 새 EDS가 아직 Envoy에 도달하지 않음 |
데이터 플레인이 stale 상태 유지 (윈도 지속) |
| 3. 유령(Phantom) 라우팅 | Envoy는 이미 사라진 endpoint IP를 여전히 healthy로 믿고 트래픽 전송 | connect 실패(UF), 503/504, 타임아웃 |
윈도 폭은 2단계가 결정한다 — debounce + push queue + xDS push의 합이다. 그래서 윈도가 넓어지는 상황은 곧 이 세 항이 커지는 상황이다: 대량 업데이트(롤링 배포, 노드 드레인)로 이벤트가 폭주하거나, istiod가 CPU saturation으로 push를 못 따라가거나, 네트워크 파티션으로 xDS 채널이 막힐 때.
2.1 식별: 어떤 response flag가 phantom인가
윈도가 열린 줄을 어떻게 밖에서 아는가? phantom은 access log의 response flag로 자기 정체를 드러낸다. 워크로드가 사라져 IP가 죽은 경우 Envoy는 TCP connect 자체에 실패하므로, 1차 신호는 UF(UpstreamConnectionFailure) — 503과 함께 UF가 찍히는 것이 phantom의 가장 특징적 패턴이다.
| flag | 의미 | phantom과의 관계 |
|---|---|---|
UF |
UpstreamConnectionFailure — 죽은 IP로 connect 실패 | phantom 1순위 신호 (stale IP가 아직 pool에 살아있음) |
UH |
No healthy upstream — pool에 healthy endpoint 0 | phantom과 반대 상황: stale이 제거된 직후 pool이 비었거나, outlier가 과도 eject한 경우 |
UC |
Upstream connection termination — 연결 수립 후 종료 | app crash로 처리 도중 끊김 |
UT |
Upstream request timeout — 응답 지연 | 연결은 됐으나 응답 없음 |
이 표의 핵심은 UF와 UH가 phantom triage에서 정반대 의미라는 것이다. UF는 "stale IP가 아직 pool에 남아 connect 실패" = 윈도가 열려 있다는 신호이고, UH는 "pool이 비었다" = stale을 (혹은 정상 endpoint까지) 빼낸 결과다. 같은 503이라도 flag가 처방을 가른다. 자세한 503 triage는 Envoy response flags 참조.
UF가 1차 신호이지만 UC/UT도 phantom일 수 있다 — IP가 완전히 사라지면 connect 자체가 실패해 UF지만, 죽은 Pod의 IP가 재사용되었거나 좀비 keep-alive 연결이 남아 connect는 성립한 뒤 RST/타임아웃되면 UC/UT로 찍힌다. 따라서 phantom 개념 노트가 UC/UT/UH를 phantom 신호로 묶은 것도 이 좀비-연결 케이스에서는 틀리지 않으나, 죽은 IP의 가장 직접적·1차 신호는 UF임을 우선 본다.
오진 함정: 장애 원인이 애플리케이션 코드가 아니라 stale 네트워크 상태에 있어, 애플리케이션 로그만 보면 정상인데 게이트웨이/사이드카 access log에만 실패가 찍힌다. flag를 안 보면 "앱은 멀쩡한데 왜 503이지"에서 막힌다.
3. 완화: 같은 윈도를 두 각도에서 친다
윈도가 메커니즘의 전부이므로 대응도 윈도에 대한 두 동작뿐이다 — (A) 전파를 빠르게 해서 윈도를 닫는 시점을 당기거나, (B) 데이터 플레인이 stale endpoint를 스스로 회피하게 만들어 윈도 안 요청을 흡수한다. 실무에서는 B(특히 retry + outlier detection)가 비용 대비 효과가 가장 크고, A의 컨트롤 플레인 튜닝은 마지막 수단이다.
A. 전파 가속 (윈도 폭 줄이기 — 마지막 수단)
A는 위 표 2단계의 합을 줄여 윈도를 물리적으로 좁히는 길이다. 단, 컨트롤 플레인 전역에 영향을 주므로 트레이드오프가 크다.
그런 이름의 플래그는 현재 Istio에 없다. 전파 지연 관련 실제 튜닝 포인트는 istiod의 debounce 환경변수다:
- PILOT_DEBOUNCE_AFTER (기본 100ms) — 이벤트 수신 후 push 큐에 넣기 전 대기. 줄이면 반응 빨라지지만 push 횟수 증가.
- PILOT_DEBOUNCE_MAX (기본 10s) — debounce 누적 상한.
- PILOT_PUSH_THROTTLE — 동시 push 개수 제한.
이 값들은 트레이드오프가 크므로 컨트롤 플레인이 포화 상태가 아닌 한 기본값을 유지하는 게 권장된다. "aggressive refresh"로 무작정 줄이면 istiod CPU만 태우고 phantom은 그대로일 수 있다.
A가 마지막 수단인 이유가 여기 있다 — debounce는 "이벤트를 모아 한 번에 push"하는 배칭 장치라 istiod 부하를 낮춰 준다. PILOT_DEBOUNCE_AFTER를 낮추면 작은 변화마다 push가 터져 push 빈도가 급증하고, istiod CPU가 포화되면 오히려 전체 push가 느려져 윈도가 다시 넓어진다. 윈도를 줄이려던 손이 윈도를 키우는 역설이다.
B. 데이터 플레인 흡수 (윈도 안 요청 처리 — 실무 표준)
phantom window는 0으로 만들 수 없으므로(아키텍처상 lag은 항상 존재), 데이터 플레인이 window 안의 요청을 스스로 흡수해야 한다. 흡수는 두 방향에서 일어난다 — 수신측(종료되는 Pod가 endpoint에서 먼저 빠짐, B-1)과 송신측(호출자 사이드카가 stale endpoint를 retry/eject로 회피, B-2). 이 둘은 같은 윈도를 양 끝에서 좁히는 보완 관계다.
B-1. Graceful drain (수신측 — endpoint에서 먼저 빠지기)
phantom 관점에서 graceful shutdown의 핵심은 Pod가 endpoint에서 빠지는 시점이다. 종료 시 사이드카가 먼저 readiness fail로 Endpoints에서 제거되고, 그동안 in-flight 요청은 drain되어야 새로 들어오는 트래픽이 죽기 직전 IP로 가지 않는다. 이건 윈도가 열리는 순간(표 1단계)을 앞당기는 셈이라, phantom 윈도를 송신측이 아닌 수신측에서 줄인다. Istio는 terminationDrainDuration(기본 5s)으로 종료 전 in-flight 연결 처리 시간을 준다.
preStop hook / terminationGracePeriodSeconds 산출, drain listener 동작, readinessProbe 튜닝 같은 구현 디테일은 graceful-termination 시리즈가 정본이다 — Envoy drain 메커니즘, graceful termination 런북, 프로덕션 적용.
B-2. 동적 retry + OutlierDetection (송신측 — 가장 효과적)
stale endpoint로 간 요청이 실패하면 다른 endpoint로 재시도하고, 반복 실패하는 endpoint는 자동으로 LB 풀에서 추방(eject) 한다. 재시도는 한 요청을 윈도 밖으로 빼내고(살아있는 형제 endpoint로 보내 성공시킴), outlier detection은 그 stale IP 자체를 풀에서 빼 후속 요청이 다시 그쪽으로 안 가게 만든다. 이것이 phantom workload를 데이터 플레인 레벨에서 흡수하는 핵심이다.
apiVersion: networking.istio.io/v1
kind: DestinationRule
metadata:
name: catalog-resilience
namespace: istioinaction
spec:
host: catalog.istioinaction.svc.cluster.local
trafficPolicy:
outlierDetection:
consecutive5xxErrors: 1 # 연속 1회 5xx면 평가 대상
interval: 5s # 5초마다 상태 재평가
baseEjectionTime: 5s # 추방 후 최소 5초 격리 (재시도 시 지수적으로 증가)
maxEjectionPercent: 100 # 최대 100%까지 격리 가능 (아래 경고)
---
apiVersion: networking.istio.io/v1
kind: VirtualService
metadata:
name: catalog-retry
namespace: istioinaction
spec:
hosts:
- catalog.istioinaction.svc.cluster.local
http:
- route:
- destination:
host: catalog.istioinaction.svc.cluster.local
retries:
attempts: 3
perTryTimeout: 2s
retryOn: gateway-error,connect-failure,refused-stream # 멱등 보장 시에만
4. 예시: 흡수가 실제로 일어났는지 검증
설정을 넣은 뒤 phantom 윈도가 실제로 줄었는지/흡수됐는지 확인하는 포인트. 핵심은 "pool 전체가 아니라 stale IP만 빠졌는가"를 눈으로 확인하는 것이다.
재현 → 관측 절차:
-
재현:
kubectl delete pod <victim> --grace-period=0로 윈도를 강제로 연다(grace period 0이면 drain 없이 즉시 사라져 phantom이 잘 재현된다). 직후 호출측 사이드카 access log를 tail:kubectl logs <caller> -c istio-proxy -fUF발생 → 재시도 →200패턴과 window 길이를 눈으로 확인한다. -
outlier eject 발생: envoy admin
GET localhost:15000/clusters에서 해당 cluster의outlier_check::ejections_active카운터와 endpoint별cx_active변화 확인. 출력 토큰 예시:outbound|80||catalog...::1.2.3.4:8080::outlier_check::ejections_active::1 outbound|80||catalog...::1.2.3.5:8080::cx_active::3성공 기준: 죽은 endpoint(1.2.3.4)는ejections_active::1(>0)로 추방되고, 건강한 형제 endpoint(1.2.3.5)는cx_active가 유지되어야 한다 — pool 전체가 아니라 stale IP만 빠진 것을 확인. -
전파 지연(윈도 폭) 관측:
pilot_proxy_convergence_time히스토그램으로 push 도달 시간 분포를(= 윈도가 실제로 얼마나 닫히는지),pilot_xds_pushes로 push 빈도를 본다. A 튜닝을 만졌다면 이 둘을 함께 봐야 한다 — push 횟수가 급증했는데 convergence가 느려졌으면 A의 역설(§3.A)에 빠진 것이다. -
access log 패턴: 정상 흡수 시
UF → 재시도 후 200패턴이 보인다.UH가 늘면 outlier 과도 eject(§3.B warning) 의심.
핵심 정리
- 하나의 그림: phantom = "이벤트 발생 → 새 EDS 도달" 시간축 윈도. 모든 완화는 윈도를 닫거나(A) 윈도 안 요청을 흡수(B)하는 두 동작뿐이다.
- 정의: 이미 사라진 워크로드의 IP가 Envoy EDS-CLA에 stale로 남아 트래픽이 죽은 IP로 향하는 유령 라우팅.
- 근본 원인: 컨트롤→데이터 플레인 전파의 최종 일관성(debounce + push queue + xDS push = phantom window). 버그가 아니라 메시의 본질적 lag.
- 1차 신호: access log의
UF(죽은 IP connect 실패).UH는 정반대(pool empty / outlier 과도 eject). - 대응 A(전파 가속):
PILOT_DEBOUNCE_AFTER/_MAX등 — 트레이드오프 크므로 마지막 수단(낮추면 push 폭주로 윈도가 도로 넓어짐). - 대응 B(데이터 플레인 흡수, 표준): retry(요청 단위) + outlier detection(IP 단위) + graceful drain(수신측)으로 window 안 요청을 흡수.
What you might be missing
- A와 B는 같은 축의 다른 지점: A는 phantom window의 폭을 줄이고, B는 window 안의 요청을 흡수한다. window를 0으로 만들 수는 없으므로(아키텍처상 lag은 항상 존재) B는 끌 수 없는 안전망이다.
UFvsUH를 혼동하면 정반대 처방을 한다:UF가 보이면 retry/drain을 강화해야 하지만,UH가 보이면 오히려 outlier 설정을 완화해야 할 수 있다. 같은 503이라도 flag가 triage를 가른다 — Envoy response flags.- drain은 송신측이 아니라 수신측 완화: B의 retry/outlier는 호출자 사이드카가 stale을 회피하는 것이고, graceful drain은 종료되는 Pod가 endpoint에서 먼저 빠져 애초에 phantom이 될 시간을 줄이는 것이다. 둘은 보완 관계다(drain 메커니즘).
- debounce를 줄이면 phantom이 다른 문제로 바뀐다:
PILOT_DEBOUNCE_AFTER를 낮추면 push 빈도가 급증해istiodCPU가 포화되고, 그러면 오히려 전파가 느려져 phantom window가 다시 넓어진다.pilot_xds_pushes와 convergence time을 함께 봐야 한다.