🏠 목록 Istio Phantom Workloads 처리 방법 📄 MD 원본 🌓 테마
istiophantom-workloadseds

Istio Phantom Workloads 처리 방법

NOTE

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 채널이 막힐 때.

ℹ 근본 원인은 "컨트롤 플레인 전파 지연"이며, 이것은 버그가 아니라 메시 아키텍처의 본질적 트레이드오프다. 그래서 "phantom을 없앤다"는 목표는 성립하지 않는다 — 윈도를 좁히고 그 안의 요청을 흡수해 **영향을 0에 수렴**시키는 것이 현실적 목표다.
PodK8s API / istiodEnvoyUpstream (dead IP)terminate → EP removephantom window (debounce + queue + push)request to stale IPconnect fail (UF/503)B: retry/outlier eject (흡수)new EDS-CLA arrivesA: smaller debounce → 윈도 단축stale IP 제거 → 윈도 종료
그림 1. Pod 종료 후 EDS 제거가 Envoy에 닿기 전 윈도에서 죽은 IP로 요청 실패. 흡수(B: retry/outlier)와 단축(A: debounce)이 두 처방.

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 — 응답 지연 연결은 됐으나 응답 없음

이 표의 핵심은 UFUH가 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단계의 합을 줄여 윈도를 물리적으로 좁히는 길이다. 단, 컨트롤 플레인 전역에 영향을 주므로 트레이드오프가 크다.

ℹ 원본의 `Pilot --endpointUpdateDelay` 표현은 부정확하다.

그런 이름의 플래그는 현재 Istio에 없다. 전파 지연 관련 실제 튜닝 포인트는 istioddebounce 환경변수다: - 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  # 멱등 보장 시에만
⚠ 위 yaml의 `consecutive5xxErrors: 1` + `maxEjectionPercent: 100` 조합은 단발 5xx(phantom으로 인한 일시적 503) **한 번**에 endpoint를 즉시 eject하고 최대 100%까지 빼낼 수 있어, 정상 endpoint까지 추방하는 **self-inflicted `UH`**(특히 endpoint 수가 적은 서비스)를 유발한다. phantom을 잡으려던 outlier가 pool을 통째로 비워 정반대 flag(`UF`→`UH`)를 만드는 것이다. 이는 [Cluster 해부](xds__src-cluster-anatomy.html)·[Envoy response flags](xds__src-envoy-response-flags.html)가 'outlier detection이 `UH`의 숨은 범인'이라고 경고하는 바로 그 안티패턴이다. 실무에서는 `maxEjectionPercent`를 50 이하로, endpoint가 소수인 서비스는 `consecutive5xxErrors`를 더 높게 잡아 보수적으로 운영한다.
ℹ retry는 **멱등(idempotent) 연산에서만** 안전하다. POST 같은 비멱등 요청을 무분별하게 retry하면 중복 처리를 유발한다. `retryOn`에 `connect-failure`/`refused-stream`을 넣는 이유는, stale endpoint로의 연결 자체가 실패한 경우(서버가 요청을 처리하기 *전*)는 재시도해도 중복이 없어 안전하기 때문이다 — phantom의 `UF`가 정확히 이 케이스다.

4. 예시: 흡수가 실제로 일어났는지 검증

설정을 넣은 뒤 phantom 윈도가 실제로 줄었는지/흡수됐는지 확인하는 포인트. 핵심은 "pool 전체가 아니라 stale IP만 빠졌는가"를 눈으로 확인하는 것이다.

재현 → 관측 절차:

  1. 재현: kubectl delete pod <victim> --grace-period=0로 윈도를 강제로 연다(grace period 0이면 drain 없이 즉시 사라져 phantom이 잘 재현된다). 직후 호출측 사이드카 access log를 tail: kubectl logs <caller> -c istio-proxy -f UF 발생 → 재시도 → 200 패턴과 window 길이를 눈으로 확인한다.

  2. 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만 빠진 것을 확인.

  3. 전파 지연(윈도 폭) 관측: pilot_proxy_convergence_time 히스토그램으로 push 도달 시간 분포를(= 윈도가 실제로 얼마나 닫히는지), pilot_xds_pushes로 push 빈도를 본다. A 튜닝을 만졌다면 이 둘을 함께 봐야 한다 — push 횟수가 급증했는데 convergence가 느려졌으면 A의 역설(§3.A)에 빠진 것이다.

  4. access log 패턴: 정상 흡수 시 UF → 재시도 후 200 패턴이 보인다. UH가 늘면 outlier 과도 eject(§3.B warning) 의심.

핵심 정리

What you might be missing

관련 노트