Phantom workload는 컨트롤 플레인의 endpoint 전파 지연으로 stale 스냅샷을 믿는 데이터 플레인이 이미 사라진 워크로드로 트래픽을 보내는 현상이다
Phantom workload는 버그가 아니라 메시 아키텍처의 본질적 트레이드오프 — 데이터 플레인 설정의 최종 일관성(eventual consistency) — 가 시간 축에 투영된 현상이다. Pod가 사라진 시점과 그 사실이 모든 Envoy의 EDS 스냅샷에 반영되는 시점 사이에는 항상 전파 지연(propagation lag) 윈도가 존재하고, 그 윈도 안에서 Envoy는 죽은 IP를 healthy로 믿고 트래픽을 보낸다. 이 note는 왜 phantom이 구조적으로 불가피한가 라는 멘탈모델에 집중한다. 단계별 발생 표·완화 YAML·운영 detail은 Phantom workloads 처리에 위임한다.
대상독자: 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이다.
flowchart LR
subgraph truth["Ground truth (K8s)"]
EP["EndpointSlice<br/>Pod-X: REMOVED"]
end
subgraph cp["Control plane"]
PILOT["istiod<br/>debounce + push queue"]
end
subgraph dp["Data plane"]
ENVOY["Envoy EDS-CLA<br/>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"까지. 그 사이엔 네 개의 직렬 지연이 누적된다(직렬이라 합산된다는 게 중요).
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 상태의 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 참조.
워크드 예시: 배포 직후 503을 phantom으로 확정하기
webapp이 catalog를 호출하는 메시에서 catalog를 롤링 배포한 직후 호출자 503이 관측됐다고 하자. 두 단계로 phantom을 확정한다.
1단계 — 윈도 안인지 확인: 호출자 proxy의 EDS sync 상태를 본다.
$ 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 의심 플래그만 추출한다.
# 호출자 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으로 만들지 못하기 때문이다.
flowchart TD
P["Phantom = lag window"]
P --> A["A. 윈도 축소<br/>(control plane)"]
P --> B["B. 윈도 흡수<br/>(data plane, 표준)"]
A --> A1["debounce 튜닝<br/>PILOT_DEBOUNCE_*"]
A --> A2["graceful drain<br/>terminationDrainDuration"]
B --> B1["retry<br/>다른 endpoint로 재시도"]
B --> B2["outlierDetection<br/>실패 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).
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에서 다룬다. 구체 YAML(consecutive5xxErrors, baseEjectionTime, retry attempts)은 src에 있다.
핵심 정리
- 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/UTresponse 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)이 더 근본적인 이유다.