---
title: Phantom workload는 컨트롤 플레인의 endpoint 전파 지연으로 stale 스냅샷을 믿는 데이터 플레인이 이미 사라진 워크로드로 트래픽을 보내는 현상이다
date: 2026-06-10
type: note
domain: istio
tags: [istio, control-plane, mental-model]
---
> [!abstract]
> Phantom workload는 버그가 아니라 메시 아키텍처의 본질적 트레이드오프 — **데이터 플레인 설정의 최종 일관성(eventual consistency)** — 가 시간 축에 투영된 현상이다. Pod가 사라진 시점과 그 사실이 모든 Envoy의 EDS 스냅샷에 반영되는 시점 사이에는 항상 **전파 지연(propagation lag)** 윈도가 존재하고, 그 윈도 안에서 Envoy는 죽은 IP를 healthy로 믿고 트래픽을 보낸다. 이 note는 *왜 phantom이 구조적으로 불가피한가* 라는 멘탈모델에 집중한다. 단계별 발생 표·완화 YAML·운영 detail은 [Phantom workloads 처리](/public/istio/arch__src-phantom-workloads.html)에 위임한다.
>
> **대상독자**: Istio 메시에서 "배포할 때마다 잠깐 503이 뜨는데 app 로그는 깨끗하다"를 디버깅하는 SRE. **선행개념**: Envoy cluster/EDS(`direction|port|subset|fqdn`), `istiod` xDS push, K8s EndpointSlice. **범위**: 발생 원리·식별·완화 방향 (구체 YAML은 src에 위임).
## 1. 배경: 라우팅 결정은 누가, 어떤 데이터로 내리나
phantom을 이해하려면 먼저 "메시에서 트래픽의 목적지 IP를 누가 결정하는가"를 정확히 짚어야 한다. 단순 Kubernetes에서는 Service ClusterIP로 보내면 kube-proxy/iptables가 그 순간의 살아있는 endpoint로 DNAT한다 — 결정이 **데이터 경로 위에서 실시간**으로 일어난다. Istio는 이 모델을 버린다. sidecar Envoy가 트래픽을 가로채(15001 outbound) **자기가 들고 있는 설정만 보고** 직접 목적지 IP를 고른다. kube-proxy는 경로에서 빠진다.
그 "설정"의 핵심이 **Envoy cluster**와 그에 매달린 **endpoint 목록**이다. cluster는 `outbound|8080||catalog.istioinaction.svc.cluster.local` 같은 `direction|port|subset|fqdn` 이름으로 식별되고, 실제 목적지 IP들은 **EDS(Endpoint Discovery Service)** 가 푸시하는 `ClusterLoadAssignment`(CLA)에 담긴다. 즉 Envoy의 라우팅 입력은 두 갈래다.
```
+-------------------+ +---------------------------------+
| CDS: cluster 정의 | ---> | "outbound|8080||catalog...svc" |
| (어떤 그룹이 있나) | +---------------------------------+
+-------------------+ |
v EDS
+---------------------------------+
| CLA endpoints (이 그룹의 IP들) |
| 10.1.0.7:8080 HEALTHY |
| 10.1.0.8:8080 HEALTHY <-- 이게|
+---------------------------------+ stale일 수 있다
```
여기서 결정적 사실: **이 endpoint 목록은 K8s가 아니라 `istiod`가 비동기로 푸시한 스냅샷**이다. K8s EndpointSlice가 ground truth이고, Envoy의 CLA는 그 truth를 `istiod`가 watch→가공→push한 *복제본*이다. truth와 복제본이 따로 존재하고 push가 비동기라는 이 한 가지 사실에서 phantom이 전부 따라 나온다. 그래서 다음 절의 멘탈모델이 성립한다.
## 2. 핵심 멘탈모델: phantom = "eventual consistency"의 시간 단면
**한 문장 앵커**: "실제 endpoint 상태"와 "Envoy가 믿는 endpoint 상태"는 분산 시스템의 **두 복제본**이며, 둘은 eventually consistent하다 — *언젠가는* 같아지지만 *지금 이 순간* 같다는 보장은 없다. phantom은 그 둘이 어긋난 짧은 구간을 트래픽이 통과할 때 발생한다.
라우팅 결정의 주체가 컨트롤 플레인이 아니라 **각 Envoy가 로컬에 들고 있는 CLA 스냅샷**이라는 점이 핵심이다. `istiod`는 그 스냅샷을 xDS로 *비동기 푸시*할 뿐이고, Envoy는 자기가 마지막으로 받은 스냅샷만 신뢰한다. Pod가 죽어도 그 IP가 CLA에서 빠지는 push가 아직 안 왔으면, Envoy 입장에서 그 endpoint는 여전히 HEALTHY다. 죽은 줄 모르고 보낸다 — 이게 phantom이다.
```mermaid
flowchart LR
subgraph truth["Ground truth (K8s)"]
EP["EndpointSlice
Pod-X: REMOVED"]
end
subgraph cp["Control plane"]
PILOT["istiod
debounce + push queue"]
end
subgraph dp["Data plane"]
ENVOY["Envoy EDS-CLA
Pod-X: still HEALTHY (stale)"]
end
EP -- "watch event" --> PILOT
PILOT -- "xDS push (lag)" --> ENVOY
ENVOY -- "traffic to dead IP" --> X(("503 / 504"))
```
`Pod-X: REMOVED`(좌)와 `Pod-X: HEALTHY`(우)가 공존하는 동안, 그 사이를 지나는 요청이 phantom 트래픽이다. lag이 0이면 phantom도 0이지만, 비동기 push 구조상 lag을 0으로 만들 수는 없다. **그래서 phantom은 고칠 버그가 아니라 윈도를 좁히고 흡수해야 할 구조적 현상이다** — 이게 4절 완화 전략 전체의 전제가 된다.
### lag을 만드는 4단계와 윈도를 넓히는 조건
phantom 윈도 = "K8s가 endpoint를 떼어낸 t0"부터 "마지막 Envoy가 새 EDS를 ACK한 t1"까지. 그 사이엔 네 개의 **직렬** 지연이 누적된다(직렬이라 합산된다는 게 중요).
```mermaid
sequenceDiagram
participant K as kubelet/EndpointSlice
participant I as istiod
participant E as Envoy
Note over K: t0 Pod 종료, endpoint 제거
K->>I: watch event
Note over I: ① debounce (PILOT_DEBOUNCE_AFTER, 기본 100ms)
Note over I: ② push queue 대기 (throttle)
I->>E: ③ EDS push (네트워크·직렬화)
Note over E: ④ Envoy 반영 + ACK
Note over E: t1 stale 해소 — phantom 윈도 종료
```
| 지연 단계 | 무엇이 늦추나 | 윈도가 커지는 조건 |
|---|---|---|
| ① debounce | `istiod`가 이벤트를 배칭(`PILOT_DEBOUNCE_AFTER` 100ms / `PILOT_DEBOUNCE_MAX` 10s) | 대량 변경 burst(롤링 배포·노드 drain) |
| ② push queue | `PILOT_PUSH_THROTTLE`로 동시 push 제한 | proxy 수 많음, `istiod` CPU 포화 |
| ③ push 전송 | 직렬화 + 네트워크 RTT | proxy 다수·대형 config |
| ④ ACK 반영 | Envoy가 받아 적용 | 느린/과부하 sidecar |
핵심: lag은 **부하에 비례**한다. 평상시 수백 ms이던 윈도가 대규모 배포·`istiod` saturation·네트워크 파티션에서 수 초~수십 초로 벌어지고, phantom이 "가끔 503"에서 "배포 때마다 503 폭증"으로 가시화된다. 컨트롤 플레인이 endpoint를 얼마나 늦게 알았는지가 아니라 *마지막 한 대의 Envoy까지 동기화*가 끝나야 윈도가 닫힌다는 점이 중요하다 — 윈도의 종료 조건이 평균이 아니라 **꼬리(tail)** 라서, proxy가 많을수록 "한 대쯤 늦는" 확률이 올라간다.
이 sync 완료 여부는 [데이터 플레인 sync 상태](/public/istio/xds__note-data-plane-sync-state.html)의 `istioctl proxy-status`로 가시화된다 — 해당 Envoy의 EDS가 `SYNCED`가 아니라 `STALE`이면 그 proxy는 아직 phantom 윈도 안에 있는 것이다.
## 3. 식별: 503/504와 Envoy response flag로 phantom을 지목
phantom의 함정은 **애플리케이션 로그가 깨끗하다**는 것이다. 죽은 IP는 애초에 요청을 받지 못했으니 그쪽 app 로그엔 아무것도 없고, 실패는 호출자 sidecar/gateway access log에만 찍힌다. 따라서 app 코드를 의심하면 오진한다. 진단의 결정적 단서는 호출자 Envoy access log의 **response flag** — Envoy가 "왜 이 요청이 실패했는지"를 한두 글자로 남기는 메타데이터다.
| flag | 의미 | phantom에서의 해석 |
|---|---|---|
| `UH` | No healthy upstream | EDS에 healthy endpoint가 0개 — outlier가 죽은 IP를 모두 eject했거나 모두 stale |
| `UC` | Upstream connection termination | 죽은 IP가 RST로 끊음 — 연결 자체 실패(retry 안전 영역) |
| `UT` | Upstream request timeout | 죽은 IP가 응답 없이 묵묵 — keep-alive에 남은 좀비 연결 전형 |
플래그가 `UC`/`UT`/`UH`로 일관되고 app 로그엔 대응 흔적이 없다 = stale 네트워크 상태(phantom) 강한 신호다. 플래그 의미 전체 표는 [Envoy response flags](/public/istio/xds__src-envoy-response-flags.html) 참조.
### 워크드 예시: 배포 직후 503을 phantom으로 확정하기
`webapp`이 `catalog`를 호출하는 메시에서 `catalog`를 롤링 배포한 직후 호출자 503이 관측됐다고 하자. 두 단계로 phantom을 확정한다.
**1단계 — 윈도 안인지 확인**: 호출자 proxy의 EDS sync 상태를 본다.
```bash
$ istioctl proxy-status
# 기대: 정상 proxy는 EDS 열이 SYNCED.
# webapp proxy의 EDS가 STALE이면 → 아직 새 CLA를 못 받음 = phantom 윈도 안.
NAME CLUSTER CDS LDS EDS RDS
webapp-xxxx.istioinaction Kubernetes SYNCED SYNCED STALE SYNCED
```
**2단계 — response flag로 stale 단정**: 호출자 sidecar access log에서 phantom 의심 플래그만 추출한다.
```bash
# 호출자 sidecar access log에서 phantom 의심 플래그만 추출
$ kubectl logs deploy/webapp -c istio-proxy -n istioinaction | grep -E '"(UC|UT|UH)"'
# 기대: 5xx 라인에 response_flag로 UC/UT/UH가 찍힘 — 같은 시각 catalog app 로그는 무흔적
[...] "GET /items HTTP/1.1" 503 UC ... upstream_host "10.1.0.8:8080"
```
`upstream_host`의 `10.1.0.8`을 `kubectl get pod -o wide`로 조회해 **이미 사라진 Pod의 IP**임을 확인하면 phantom 확정이다. EDS는 `STALE`(못 받음) + flag는 `UC`(죽은 IP가 RST) + app 로그 무흔적 — 세 신호가 한 시각에 모이면 app/네트워크/인증을 더 파지 말고 윈도 완화로 넘어간다.
## 4. 두 갈래 완화: 컨트롤 플레인 가속 vs 데이터 플레인 흡수
lag이 불가피하다면(2절 앵커의 직접 귀결) 대응은 둘뿐이다 — **윈도를 좁히거나(A)**, **윈도 안의 실패를 데이터 플레인이 스스로 흡수하거나(B)**. 멘탈모델 차원에서 A는 "원인을 줄이기", B는 "증상을 무해화하기"다. 실무 표준은 **B**다 — A는 트레이드오프가 크고 lag을 0으로 만들지 못하기 때문이다.
```mermaid
flowchart TD
P["Phantom = lag window"]
P --> A["A. 윈도 축소
(control plane)"]
P --> B["B. 윈도 흡수
(data plane, 표준)"]
A --> A1["debounce 튜닝
PILOT_DEBOUNCE_*"]
A --> A2["graceful drain
terminationDrainDuration"]
B --> B1["retry
다른 endpoint로 재시도"]
B --> B2["outlierDetection
실패 endpoint eject"]
```
**A. 윈도 축소.** `PILOT_DEBOUNCE_AFTER`를 줄이면 반응은 빨라지나 push 횟수가 늘어 `istiod` CPU를 태운다 — 포화 상태에선 오히려 ②③ 지연이 커져 역효과. 더 견고한 축소는 *떠나는 쪽*을 손보는 것이다: `preStop` hook + `terminationGracePeriodSeconds`로 Pod가 죽기 전에 먼저 readiness fail → endpoint에서 빠지고, sidecar는 `terminationDrainDuration`(기본 5s) 동안 in-flight를 drain한다. 즉 "endpoint 제거를 push보다 먼저, 종료를 drain보다 나중에" 순서를 강제해 윈도 자체를 만들지 않는 전략이다(상세: [Envoy drain listeners](/public/istio/gt__src-envoy-drain-listeners.html)).
> [!warning] A의 한계 — debounce를 아무리 줄여도 "마지막 Envoy까지 ACK"라는 ④ 단계의 분산 지연은 남는다. lag은 작아질 뿐 0이 되지 않으므로, A만으로 phantom을 없앨 수 없다.
**B. 윈도 흡수.** stale IP로 간 요청이 실패하면 *다른* endpoint로 retry하고(`VirtualService.http.retries`), 반복 실패하는 endpoint는 LB 풀에서 자동 eject한다(`DestinationRule.trafficPolicy.outlierDetection`). retry가 단발 실패를 가리고, outlier가 stale IP를 풀에서 빼 후속 요청이 아예 그쪽으로 안 가게 한다 — 둘이 합쳐져 윈도 전체를 무해화한다. 단 **retry는 멱등 연산에서만 안전**하고 `retryOn`은 `connect-failure,refused-stream` 같은 *연결 단계 실패*로 좁혀야 한다(서버가 요청을 처리하기 전이라 재시도가 중복을 안 만든다). outlier eject가 cluster의 `circuit_breakers`로 변환되는 메커니즘은 [Circuit breaking](/docs/istio/circuit-breaking-mechanisms/)에서 다룬다. 구체 YAML(`consecutive5xxErrors`, `baseEjectionTime`, retry attempts)은 [src](/public/istio/arch__src-phantom-workloads.html)에 있다.
## 핵심 정리
- Phantom workload = 컨트롤·데이터 플레인 endpoint 복제본의 eventual consistency가 어긋난 **시간 단면**. 라우팅은 Envoy가 로컬 CLA 스냅샷만 보고 결정하므로, 죽은 Pod의 IP가 EDS-CLA에 stale로 남아 트래픽이 흐른다.
- 윈도 = debounce + push queue + 전송 + ACK 4단계 **직렬** 지연의 합. **부하에 비례**해 벌어지고(대규모 배포·`istiod` 포화), 종료 조건이 *마지막 한 대(tail)* 라 proxy가 많을수록 길어진다.
- 식별 = app 로그는 깨끗하고 호출자 Envoy access log에만 `UH`/`UC`/`UT` response flag가 찍히는 패턴. + `upstream_host`가 사라진 Pod IP.
- 완화 = (A) 윈도 축소(debounce·graceful drain, 한계 명확) vs (B) 데이터 플레인 흡수(retry + outlier detection). **B가 실무 표준**.
- 진단 첫걸음 = 해당 proxy의 EDS가 `SYNCED`인지 `STALE`인지 `istioctl proxy-status`로 확인.
## What you might be missing
- **outlier detection이 phantom을 악화시킬 수 있다.** stale IP를 모두 eject하고 healthy가 0개가 되면 `UH`(No healthy upstream)로 *전체* 요청이 즉시 실패한다. `maxEjectionPercent`를 100으로 두면 일시적으로 풀이 빌 수 있으니, 소규모 서비스에선 보수적으로 설정해야 한다.
- **gateway에서의 phantom은 sidecar가 없어 더 까다롭다.** ingress gateway가 백엔드 Pod로 직접 보낼 때도 같은 EDS lag을 겪지만, app 측 sidecar drain 전략이 적용되지 않으므로 gateway 쪽 outlier/retry 의존도가 더 크다.
- **`UT`(timeout)는 retry로 가리면 안 되는 경우가 많다.** 연결은 됐는데 응답이 없는 상황은 서버가 *이미 요청을 받아 처리 중*일 수 있어, 비멱등 요청을 retry하면 중복이 된다. `UC`/`refused-stream`(연결 전 실패)과 달리 `UT`는 retry 안전 영역이 아니다.
- **proxy-status가 SYNCED인데도 503이면 phantom이 아니다.** sync가 끝났다면 EDS는 최신이므로, 그때의 503은 진짜 endpoint 부재·app 오류·mTLS/AuthorizationPolicy 거부 등 다른 원인을 봐야 한다. phantom은 *전파 중* 윈도에 한정된 진단이다.
- **EndpointSlice 자체의 K8s 지연도 윈도에 포함된다.** lag의 t0를 "Pod 종료"로 잡았지만, kubelet→EndpointSlice 반영에도 지연이 있다. `istiod`를 아무리 튜닝해도 이 상류 지연은 메시 밖이라 손댈 수 없다 — graceful drain(B의 readiness fail)이 더 근본적인 이유다.