--- type: src tags: [istio, phantom-workloads, eds] created: 2026-06-07 --- # Istio Phantom Workloads 처리 방법 > [!abstract] > 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 채널이 막힐 때. > [!note] 근본 원인은 "컨트롤 플레인 전파 지연"이며, 이것은 버그가 아니라 메시 아키텍처의 본질적 트레이드오프다. 그래서 "phantom을 없앤다"는 목표는 성립하지 않는다 — 윈도를 좁히고 그 안의 요청을 흡수해 **영향을 0에 수렴**시키는 것이 현실적 목표다. ```mermaid sequenceDiagram participant Pod as Pod (workload) participant API as K8s API / istiod participant Envoy as Envoy (data plane) participant Up as Upstream (dead IP) Pod->>API: terminate -> EndpointSlice remove Note over API,Envoy: phantom window (debounce + push queue + xDS push) Envoy->>Up: request to stale IP Up-->>Envoy: connect fail (UF / 503) Note over Envoy,Up: B: retry to other ep / outlier eject (absorb) API->>Envoy: new EDS-CLA arrives Note over API,Envoy: A: smaller debounce -> shorter window Envoy->>Envoy: stale IP removed -> window closed ``` ### 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](xds__src-envoy-response-flags.html) 참조. `UF`가 1차 신호이지만 `UC`/`UT`도 phantom일 수 있다 — IP가 완전히 사라지면 connect 자체가 실패해 `UF`지만, 죽은 Pod의 IP가 재사용되었거나 좀비 keep-alive 연결이 남아 connect는 성립한 뒤 RST/타임아웃되면 `UC`/`UT`로 찍힌다. 따라서 [phantom 개념 노트](arch__note-phantom-workloads.html)가 `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단계의 합을 줄여 윈도를 *물리적으로* 좁히는 길이다. 단, 컨트롤 플레인 전역에 영향을 주므로 트레이드오프가 크다. > [!correction] 원본의 `Pilot --endpointUpdateDelay` 표현은 부정확하다. > 그런 이름의 플래그는 현재 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 메커니즘](gt__src-envoy-drain-listeners.html), [graceful termination 런북](gt__src-runbook.html), [프로덕션 적용](gt__src-w6-production-apply.html). #### B-2. 동적 retry + OutlierDetection (송신측 — 가장 효과적) stale endpoint로 간 요청이 실패하면 **다른 endpoint로 재시도**하고, 반복 실패하는 endpoint는 **자동으로 LB 풀에서 추방(eject)** 한다. 재시도는 *한 요청*을 윈도 밖으로 빼내고(살아있는 형제 endpoint로 보내 성공시킴), outlier detection은 *그 stale IP 자체*를 풀에서 빼 후속 요청이 다시 그쪽으로 안 가게 만든다. 이것이 phantom workload를 데이터 플레인 레벨에서 흡수하는 핵심이다. ```yaml 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 # 멱등 보장 시에만 ``` > [!warning] 위 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`를 더 높게 잡아 보수적으로 운영한다. > [!note] retry는 **멱등(idempotent) 연산에서만** 안전하다. POST 같은 비멱등 요청을 무분별하게 retry하면 중복 처리를 유발한다. `retryOn`에 `connect-failure`/`refused-stream`을 넣는 이유는, stale endpoint로의 연결 자체가 실패한 경우(서버가 요청을 처리하기 *전*)는 재시도해도 중복이 없어 안전하기 때문이다 — phantom의 `UF`가 정확히 이 케이스다. ## 4. 예시: 흡수가 실제로 일어났는지 검증 설정을 넣은 뒤 phantom 윈도가 실제로 줄었는지/흡수됐는지 확인하는 포인트. 핵심은 "pool 전체가 아니라 stale IP만 빠졌는가"를 눈으로 확인하는 것이다. **재현 → 관측 절차:** 1. **재현**: `kubectl delete pod --grace-period=0`로 윈도를 강제로 연다(grace period 0이면 drain 없이 즉시 사라져 phantom이 잘 재현된다). 직후 호출측 사이드카 access log를 tail: ``` kubectl logs -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) 의심. ## 핵심 정리 - **하나의 그림**: 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는 끌 수 없는 안전망이다. - **`UF` vs `UH`를 혼동하면 정반대 처방을 한다**: `UF`가 보이면 retry/drain을 강화해야 하지만, `UH`가 보이면 오히려 outlier 설정을 *완화*해야 할 수 있다. 같은 503이라도 flag가 triage를 가른다 — [Envoy response flags](xds__src-envoy-response-flags.html). - **drain은 송신측이 아니라 수신측 완화**: B의 retry/outlier는 호출자 사이드카가 stale을 회피하는 것이고, graceful drain은 종료되는 Pod가 endpoint에서 먼저 빠져 *애초에* phantom이 될 시간을 줄이는 것이다. 둘은 보완 관계다([drain 메커니즘](gt__src-envoy-drain-listeners.html)). - **debounce를 줄이면 phantom이 다른 문제로 바뀐다**: `PILOT_DEBOUNCE_AFTER`를 낮추면 push 빈도가 급증해 `istiod` CPU가 포화되고, 그러면 오히려 전파가 느려져 phantom window가 다시 넓어진다. `pilot_xds_pushes`와 convergence time을 함께 봐야 한다. ## 관련 노트 - [phantom workloads 개념 노트](arch__note-phantom-workloads.html)