homelab89 Docs Logs Legacy Files ☰ TOC 🌓
noteistio 2026-06-10istiocontrol-planemental-model

Phantom workload는 컨트롤 플레인의 endpoint 전파 지연으로 stale 스냅샷을 믿는 데이터 플레인이 이미 사라진 워크로드로 트래픽을 보내는 현상이다

ABSTRACT

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으로 확정하기

webappcatalog를 호출하는 메시에서 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_host10.1.0.8kubectl 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).

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는 멱등 연산에서만 안전하고 retryOnconnect-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/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)이 더 근본적인 이유다.

Files