homelab89 Docs Logs Legacy Files ☰ TOC 🌓
noteistio 2026-06-07istioenvoyxdsproxy-configtroubleshooting

Envoy 요청 라우팅은 listener→route→cluster→endpoint 체인을 따르며 istioctl proxy-config로 각 단계를 짚어 오설정을 찾는다

이 문서가 다루는 것

Envoy가 요청 하나를 처리하는 경로는 항상 Listener → Route → Cluster → Endpoint (+ mTLS면 Secret) 라는 고정된 체인이다. 그래서 장애 진단은 자유로운 추리가 아니라 “이 고정 체인의 어느 단계에서 끊겼는가” 하나만 묻는 결정적 절차가 된다. 그 답을 단계별로 dump해 주는 도구가 istioctl proxy-config {listener,route,cluster,endpoint,secret}, 시작점을 바로 가리켜 주는 단서가 access log의 response flag다. 이 note는 그 체인 멘탈모델과 단계별 디버깅 절차에 집중하고, flag 전체 표·xDS 계층 내부·cluster 필드 매핑 같은 깊은 detail은 각 src 문서로 위임한다.


01. 배경 — 왜 “체인"으로 생각해야 하나

Istio 메시에서 “v2가 안 떠요”, “503이 나요” 같은 증상은 원인 후보가 너무 많다. VirtualService 오타일 수도, DestinationRule 누락일 수도, Pod label 불일치일 수도, mTLS mode 충돌일 수도 있다. 증상에서 곧장 원인을 찍으려 하면 추측이 되고, 추측은 틀린 곳을 고치게 만든다.

빠져나갈 길은 Envoy 자체의 구조에 있다. Envoy는 요청을 받으면 매번 똑같은 순서로 “이 요청을 어떻게 처리할지"를 resolve한다. 이 순서는 설정마다 달라지지 않는 불변(invariant)이다. 즉 어떤 라우팅 장애든, 이 고정된 결정 파이프라인 위의 정확히 한 지점에서 답이 안 나와 끊긴 것이다. 그러면 진단은 “원인이 무엇일까"라는 열린 질문에서, “이 파이프라인을 위에서 아래로 내려가며 처음 답이 빈 단계를 찾아라” 라는 닫힌 절차로 바뀐다. 이것이 체인 멘탈모델의 전부이고, 이 문서의 나머지는 그 체인을 짚는 법이다.

이 멘탈모델을 쓰려면 두 가지 선행 개념이 필요하다.

  • xDS = Envoy의 동적 설정 프로토콜. istiod가 각 Envoy(sidecar/gateway)에게 listener(LDS)·route(RDS)·cluster(CDS)·endpoint(EDS)·secret(SDS)을 push한다. 체인의 각 단계는 그대로 하나의 xDS 종류에 대응한다. xDS 계층 자체의 내부 동작은 → xDS 계층 개념.
  • cluster ≠ endpoint. Envoy의 cluster는 “어디로 보낼지"라는 논리적 목적지(upstream pool)이고, 그 안에 실제로 누가 있는지(Pod IP)는 별도 채널(EDS)로 내려온다. 이 분리가 진단의 핵심 함정을 만든다(§02 anchor, §03).

02. 핵심 — 체인의 각 단계가 답하는 질문

Anchor: 머릿속에 그릴 그림은 이 한 줄이다 — 요청은 Listener → Route → Cluster → Endpoint (+Secret) 를 따라 흐르고, 각 단계는 직전 단계의 결정을 입력으로 받아 “예/아니오"를 하나씩 답한다. 어느 단계가 처음 “아니오"를 내는지가 곧 근본 원인의 위치다.

flowchart LR
  REQ[request] --> L["Listener (LDS)<br/>where received"]
  L --> R["Route (RDS)<br/>which cluster"]
  R --> C["Cluster (CDS)<br/>upstream pool exists?"]
  C --> E["Endpoint (EDS)<br/>healthy Pod IP?"]
  C -.mTLS.-> S["Secret (SDS)<br/>cert/key/CA"]
  E --> UP[upstream Pod]

각 단계를 “필드"가 아니라 “그게 답하는 질문"으로 보면 체인이 직관적으로 잡힌다.

단계 xDS 결정하는 것 한 줄 질문 Istio 리소스(주된 것)
Listener LDS 어느 포트/주소에서 받고 어떤 filter chain으로 처리할지 “트래픽을 받긴 했나” Gateway, Sidecar, PeerAuthentication, Service port
Route RDS 이 요청(host/path/header)을 어느 cluster로 보낼지 “보낼 곳을 정했나” VirtualService, Gateway, HTTPRoute
Cluster CDS 그 목적지(upstream pool)가 존재하는지 + LB/TLS/CB 정책 “목적지가 정의됐나” Service, ServiceEntry, DestinationRule
Endpoint EDS 그 cluster 안의 실제 Pod IP가 healthy하게 있는지 “보낼 실체가 있나” EndpointSlice, readiness, selector, WorkloadEntry
Secret SDS mTLS handshake에 쓸 cert/key/CA “신원 증명을 할 수 있나” istiod CA, PeerAuthentication, DestinationRule TLS

왜 cluster와 endpoint가 분리됐나 — 가장 흔한 함정의 뿌리

cluster(CDS)는 “reviews v1으로 보내라"는 논리적 목적지이고, 그 안에 실제로 누가 있는지(EDS)는 따로 내려온다. 이렇게 쪼갠 이유는 운영상 명확하다 — Pod는 scale·재시작·장애로 IP가 수시로 바뀌지만 “reviews v1으로 보낸다"는 라우팅 의도는 그대로다. endpoint만 자주 갱신하고 cluster/route는 안정적으로 두기 위해 두 채널을 분리했다. 그 대가로 “cluster는 있는데 endpoint가 0개"라는 상태가 정상적으로 존재하고, 이것이 진단을 헷갈리게 하는 1순위 함정이다. “cluster가 보인다 = 트래픽이 간다"는 거짓이다.

cluster 이름 — 진단이 곧 문자열 대조인 이유

cluster 이름은 direction|port|subset|fqdn 규칙을 따른다(예: outbound|9080|v1|reviews.default.svc.cluster.local). subset이 없으면 가운데가 비어 outbound|9080||reviews... 가 된다. route 단계는 이 cluster 이름을 문자열 그대로 참조한다. 그래서 진단의 핵심 동작은 거창한 추론이 아니라, “route가 가리키는 cluster 문자열"과 “실제 존재하는 cluster 문자열"을 글자 그대로 대조하는 것이다. 한 글자라도 어긋나면(특히 subset 칸) 요청은 그 자리에서 끊긴다. cluster 필드의 상세 매핑은 → Cluster 해부.

subset cluster — DestinationRule이 만드는 단계, 두 실패 모드

가장 자주 만나는 라우팅 실패는 route는 subset cluster를 가리키는데 그 cluster가 존재하지 않는 경우다. 원인은 거의 항상 DestinationRule이다.

subset은 Kubernetes Service 개념도, Envoy 순수 개념도 아니다. DestinationRule이 정의하는, 같은 Service 뒤의 endpoint를 label로 나눈 named 그룹이다. istiod는 DestinationRule의 subsets를 보고서야 outbound|9080|v1|... 같은 subset cluster를 만든다. 따라서 VirtualService가 subset: v1로 보내는데 DestinationRule이 없거나 그 subset을 정의하지 않으면, route는 존재하지 않는 cluster를 가리키게 된다.

여기서 결정적인 건 두 실패 모드를 단계로 구분하는 것이다. 둘 다 “v2가 안 된다"로 보이지만 체인에서 끊긴 위치가 다르고, 따라서 고치는 곳도 다르다.

  • cluster 부재 → NC(NoClusterFound): subset 자체가 DestinationRule에 정의 안 됨. route가 가리키는 cluster 문자열이 proxy-config cluster 목록에 아예 없다. 고친다 = DestinationRule에 subset 추가.
  • endpoint 부재 → 503 UH(NoHealthyUpstream): subset cluster는 만들어졌지만, subset의 label selector와 실제 Pod label이 어긋나 endpoint가 0개. cluster는 있는데 proxy-config endpoint가 비었다. 고친다 = subset label ↔ Pod label 일치.
# DestinationRule이 있어야 subset cluster가 생성된다
apiVersion: networking.istio.io/v1
kind: DestinationRule
metadata:
  name: reviews
spec:
  host: reviews.default.svc.cluster.local
  subsets:
  - name: v1
    labels: { version: v1 }   # 이 label이 Pod에 실제로 있어야 endpoint가 채워짐
  - name: v2
    labels: { version: v2 }
함정

subset cluster는 route가 명시적으로 그 subset으로 보낼 때만 트래픽이 흐른다. subset이 정의돼 cluster가 만들어져 있어도, VirtualService에서 subset: v2로 라우팅하지 않으면 그 cluster는 그냥 idle하게 존재만 한다. 다시, “cluster가 보인다 = 트래픽이 간다"가 아니다.

또 하나 흔한 케이스는 warming이다. DestinationRule을 막 apply한 직후엔 istiod가 CDS→EDS→RDS 순으로 push하는데(ADS bottom-up), endpoint가 다 채워지기 전 짧은 시간 동안 NC/UH가 잠깐 보일 수 있다. 지속되면 설정 문제, 수 초 내 사라지면 warming이다. 각 xDS 계층이 정확히 무엇을 내려주고 ADS가 적용 순서를 어떻게 보장하는지는 → xDS 5계층과 진단.

response flag — 체인의 어느 단계인지 Envoy가 직접 말해 준다

체인을 1단계부터 다 훑지 않아도 된다. access log의 response flag가 “어느 단계에서 끊겼는지"를 직접 가리키므로, flag → 단계로 시작점을 점프할 수 있다. flag는 방향(U=upstream/D=downstream/L=local) + 사건(F/H/O/T/R)의 약어 조합인 경우가 많아 일부는 외우지 않아도 추론된다 — UFU(upstream 쪽) + F(connection Failure), UHU + H(no Healthy upstream)로 읽힌다. 다만 이 분해 규칙은 U로 시작하는 flag(UF/UH/UO/UT 등)에만 적용된다. 바로 아래 표의 NR·NC는 U/D/L이 아니라 N(No route/cluster found)으로 시작하는 별개의 접두어라 이 규칙으로는 추론되지 않고, NoRouteFound/NoClusterFound라는 이름 자체를 직접 참고해야 한다. 방향이 끊긴 위치(보내는 쪽 vs upstream)를, 사건이 §03의 어느 검증 단계인지를 알려 준다는 원칙은 유지되지만, 그 값을 매번 “추론"만으로 얻을 순 없다.

flag long 끊긴 단계 먼저 볼 proxy-config
NR NoRouteFound Route route — VS hosts/gateways/port, SNI
NC NoClusterFound Cluster cluster — DestinationRule subset, ServiceEntry
UH NoHealthyUpstream Endpoint endpoint — selector/readiness/outlier
UF UpstreamConnectionFailure Secret/transport secret — mTLS mode mismatch, port/firewall
UO UpstreamOverflow Cluster 정책 cluster — DestinationRule connectionPool/CB
UT UpstreamRequestTimeout upstream/app VS timeout, app latency

NR이면 route부터, NC면 cluster, UH면 endpoint, UF면 secret/mTLS부터 보면 된다. flag 28종 전체 표·short↔long·response_code_details/upstream_transport_failure_reason까지 access log에 노출하는 법은 → Response Flags 레퍼런스.


03. 예시 — istioctl proxy-config로 체인을 단계별로 검증

진단은 체인을 위에서 아래로 내려가며 “끊긴 곳"을 찾는 절차다. 각 명령은 그 proxy의 Envoy가 실제로 받은 설정을 dump한다(istiod가 의도한 설정이 아님 — 이 차이는 §What you might be missing). 시작 단계는 response flag로 점프하거나, 모르면 route부터 내려가면 된다.

# 0. 진단 대상 Pod 확정 (보내는 쪽 sidecar 기준으로 본다)
POD=$(kubectl get pod -n default -l app=sleep -o jsonpath='{.items[0].metadata.name}')

# 1. route가 원하는 cluster를 가리키는지
istioctl proxy-config route   "$POD" -n default
# 2. 그 cluster가 존재하는지 (route의 cluster 문자열과 글자 그대로 대조)
istioctl proxy-config cluster "$POD" -n default
# 3. 그 cluster에 healthy endpoint가 있는지
istioctl proxy-config endpoint "$POD" -n default
# 4. mTLS면 secret이 있는지
istioctl proxy-config secret  "$POD" -n default

1단계 — route가 가리키는 cluster 문자열 뽑기 (특정 host로 좁혀서):

istioctl proxy-config route "$POD" -n default --name 9080 -o json \
  | jq '.[].virtualHosts[] | select(.name|test("reviews")) | .routes[].route.cluster'
"outbound|9080|v1|reviews.default.svc.cluster.local"

2단계 — 그 cluster가 실재하는지 대조:

istioctl proxy-config cluster "$POD" -n default --fqdn reviews.default.svc.cluster.local
SERVICE FQDN                              PORT  SUBSET  DIRECTION  TYPE  DESTINATION RULE
reviews.default.svc.cluster.local         9080  -       outbound   EDS   reviews.default
reviews.default.svc.cluster.local         9080  v1      outbound   EDS   reviews.default
reviews.default.svc.cluster.local         9080  v2      outbound   EDS   reviews.default
  • route가 v2를 가리키는데 위 목록에 v2 줄이 없다 → cluster 부재(NC). DestinationRule subset 누락이다.
  • v2 줄은 있는데 endpoint가 비었다 → UH. 다음 단계로:

3단계 — 그 cluster에 healthy endpoint가 있는지:

istioctl proxy-config endpoint "$POD" -n default \
  --cluster "outbound|9080|v2|reviews.default.svc.cluster.local"
ENDPOINT             STATUS      OUTLIER CHECK     CLUSTER
10.244.1.12:9080     HEALTHY     OK                outbound|9080|v2|reviews...

endpoint 목록이 0줄이면 selector mismatch 또는 readiness 미충족. STATUSUNHEALTHY거나 outlier로 eject됐으면 그쪽을 본다.

이 decision tree가 §02 anchor를 절차로 옮긴 것이다 — 각 분기가 체인의 한 단계, 각 leaf가 하나의 response flag다:

flowchart TD
  S[traffic fails] --> R{proxy-config route<br/>cluster를 가리키나?}
  R -->|route 없음| NR[NR: VS hosts/gateways/port 점검]
  R -->|가리킴| C{proxy-config cluster<br/>그 cluster 존재?}
  C -->|없음| NC[NC: DestinationRule subset 누락]
  C -->|존재| E{proxy-config endpoint<br/>healthy 있나?}
  E -->|0개| UH[UH: selector/readiness/outlier]
  E -->|있음| SEC{mTLS handshake OK?}
  SEC -->|실패| UF[UF: proxy-config secret / mTLS mode]
  SEC -->|OK| APP[app 자체 5xx 의심: 503 -]

flag로 시작점 점프 — 체인을 1단계부터 훑기 전에, 끊긴 요청의 flag를 보면 어느 단계부터 볼지 바로 정해진다:

# 끊긴 요청의 flag를 보는 가장 빠른 길 — 보내는 쪽 sidecar 로그
kubectl logs -n default "$POD" -c istio-proxy --tail=20
[2026-06-07T...] "GET /api/v1/reviews HTTP/1.1" 503 UF ... outbound|9080|v2|reviews...
                                                    ^^^^ → secret/mTLS 단계부터 본다
함정

503 -(flag가 -)는 “Envoy 레벨 에러 없음"일 뿐 “장애 없음"이 아니다. app이 직접 503 body를 돌려준 경우다. 503 UF(Envoy가 app에 닿지도 못함)와 대응 부서가 다르다.

체인을 보강하는 두 도구 — proxy-config가 “받은 설정"만 보여주므로 양옆을 막아 준다:

  • istioctl analyze -A — 적용 전 의도 수준의 오류(host mismatch, subset 미정의, injection 누락)를 잡는다. 경고 0이 시나리오 합격선.
  • istioctl proxy-status — 그 proxy가 istiod와 sync(ACK) 됐는지. proxy-config 출력이 최신인지 보증한다(→ xDS 진단 §05).

Envoy admin API(config_dump/clusters/stats) 직접 접근으로 더 raw하게 보는 법은 → Envoy Admin API 진단.


핵심 정리

1. 요청 path는 고정 체인이다.
   Listener → Route → Cluster → Endpoint (+ mTLS면 Secret).
   진단 = "이 체인의 어느 단계에서 끊겼나"를 묻는 닫힌 절차.

2. cluster와 endpoint는 분리돼 있다 (Pod IP가 자주 바뀌니까).
   cluster 있음 ≠ 정상. cluster는 CDS, endpoint(Pod IP)는 EDS로 따로 온다.
   "cluster 있는데 endpoint 0" = 503 UH가 가장 흔한 함정.

3. subset cluster는 DestinationRule이 만든다.
   subset 미정의 → NC(cluster 부재) / subset label↔Pod label 불일치 → UH(endpoint 부재).

4. proxy-config로 단계별로 글자 그대로 대조한다.
   route(가리키는 cluster) ↔ cluster(존재 여부) ↔ endpoint(healthy 여부).

5. response flag로 시작 단계를 점프한다.
   NR→route, NC→cluster, UH→endpoint, UF→secret/mTLS.

What you might be missing

  • proxy-config는 “그 proxy가 받은 설정"이지 “istiod가 의도한 설정"이 아니다. push가 안 갔거나 ACK가 안 됐으면(proxy-statusSTALE이거나 proxy가 목록에서 누락) proxy-config 출력이 옛 설정일 수 있다. 그래서 analyze(의도 검증)와 proxy-status(전달 검증)를 체인 진단과 같이 봐야 한다. 라우팅이 “설정대로면 맞는데 안 된다"면 전달 단계부터 의심하라.
  • 체인은 “보내는 쪽” sidecar에서 본다. outbound 라우팅 실패는 client Pod의 sidecar(15001 outbound)에서 결정된다. 그래서 $POD는 호출하는 쪽(sleep/curl) Pod여야 한다. 서버 쪽 sidecar(15006 inbound)를 봐서는 route/cluster 누락이 안 보인다. ingress gateway 경유면 gateway Pod가 진단 대상이다.
  • NR이 라우팅 설정이 아니라 “포트가 HTTP로 인식 안 됨” 문제일 수 있지만, 1.30 기본값에서는 흔치 않다. Service port name이 http/http2/grpc prefix가 아니어도 Istio는 기본적으로 protocol sniffing(http_inspector, 1.6 전후로 기본 활성화)으로 첫 바이트를 검사해 HTTP/HTTP2를 자동 인식하므로, VirtualService가 멀쩡하면 L7 route는 정상 생성된다 — unnamed port(custom-noprefix)에서도 outbound 리스너에 http_connection_manager가 붙고 헤더 매칭 fault-injection이 그대로 동작함을 homelab에서 실측했다(T19). “port name 접두어가 없으면 L7 route를 아예 안 만든다"는 서술이 유효한 건 sniffing 자체가 실패하는 경우(server-first 프로토콜 등)나 PILOT_ENABLE_PROTOCOL_SNIFFING_FOR_(OUTBOUND|INBOUND)=false로 sniffing을 꺼둔 환경뿐이다. proxy-config route에 host 자체가 안 보이면 먼저 sniffing 실패/비활성화 여부를, 그다음에 Service port name을 확인하라.
  • upstream_transport_failure_reason 필드 하나로 mTLS 문제 여부를 확정할 수는 없다(homelab 실측, T20). PeerAuthentication STRICT + DestinationRule tls.mode: DISABLE처럼 클라이언트가 TLS handshake 자체를 시도하지 않는 흔한 mTLS mismatch를 재현해도 이 필드는 비어 있었고, response flag도 UF가 아니라 UC(연결이 응답 전에 리셋됨)로 나타나 순수 TCP 레벨 connection 실패와 필드만으로 구분되지 않았다 — 클라이언트가 raw_buffer transport socket을 쓰면 애초에 채울 TLS 에러가 없기 때문이다. 즉 “필드가 채워지면 mTLS mode mismatch 확정"이라는 방향은 성립하지 않는다. secret 단계를 빠르게 triage하려면 이 필드 하나에 기대지 말고 istioctl proxy-config secret으로 실제 cert/TLS mode를 직접 대조하라(→ Response Flags).
  • subset 정책은 route가 그 subset으로 보낼 때만 발동한다. canary로 DestinationRule subset과 traffic policy를 다 정의해도, VirtualService route가 subset:을 지정하지 않으면 전체 pool cluster로 가서 subset별 정책(별도 connectionPool/outlier 등)이 통째로 무시된다. “정책을 썼는데 안 먹는다"의 단골 원인이다.

검증 기록 (2026-07-05 · Istio 1.30.0 / k8s 1.30.6)

검증 방법: Envoy/Istio 공식 문서 대조 + homelab k8s 클러스터(istio-verify 네임스페이스) 실측을 병행. NC/UH subset chain 실측(T65) 1건이 C1·C3·C6·C7 네 주장을 동시에 확증했고, C9는 response flag 해독 규칙의 적용 범위 오류로 본문을 정정했으며, C13은 protocol sniffing 실측(T19)으로 구버전 서술을 갱신했고, C14는 mTLS mismatch 실측(T20)이 문서의 판별 휴리스틱을 반증해 본문을 고쳤다.

주장 판정 근거
C1. 요청 경로는 항상 Listener→Route→Cluster→Endpoint(+Secret)의 고정 체인이며 진단은 그 체인이 처음 끊긴 지점을 찾는 절차다 ✅ 실측 확인 istio.io/…/proxy-cmd · T65 실측
C2. xDS(LDS/RDS/CDS/EDS/SDS)가 체인의 각 단계에 그대로 대응한다 ✅ 문헌 확인 envoyproxy.io/…/xds_protocol
C3. cluster(CDS)는 논리적 목적지, endpoint(EDS)는 실제 Pod IP로 분리된 채널이다 ✅ 실측 확인 istio.io/…/proxy-cmd · T65 실측
C4. cluster 이름은 direction|port|subset|fqdn 규칙을 따른다 ✅ 문헌 확인 istio.io/…/proxy-cmd
C5. subset은 DestinationRule이 정의하는 named 그룹이며 K8s/Envoy 고유 개념이 아니다 ✅ 문헌 확인 istio.io/…/destination-rule
C6. subset 부재→NC, label mismatch→UH라는 서로 다른 두 실패 모드가 있다 ✅ 실측 확인 istio.io/…/destination-rule · T65 실측
C7. route가 가리키지 않는 subset cluster는 healthy endpoint가 있어도 idle하다(“cluster 있음=트래픽 감"은 거짓) ✅ 실측 확인 istio.io/…/destination-rule · T65 실측
C8. DestinationRule apply 직후 CDS→EDS→RDS 순 push로 짧은 warming 구간에 NC/UH가 잠깐 보일 수 있다 ✅ 문헌 확인 envoyproxy.io/…/xds_protocol
C9. (원문) response flag는 방향+사건 조합이라 외우지 않아도 추론된다 ❌ 오류 — 본문 교정 envoyproxy.io/…/substitution_formatter
C10. response flag(NR/NC/UH/UF/UO/UT)와 체인 단계의 대응표 ✅ 문헌 확인 envoyproxy.io/…/substitution_formatter
C11. proxy-config는 “실제로 받은 설정"이며 STALE이면 옛 설정일 수 있다 ✅ 문헌 확인 istio.io/…/proxy-cmd
C12. 체인은 보내는 쪽(outbound 15001) sidecar에서 봐야 한다 ✅ 문헌 확인 istio.io/…/proxy-cmd
C13. (원문) port name에 http/http2/grpc 접두어가 없으면 L7 route를 아예 안 만든다 ⚠️ 구버전 서술 — 갱신 istio.io/…/protocol-selection · T19 실측
C14. (원문) UF에서 upstream_transport_failure_reason 필드로 mTLS 문제 여부를 판별할 수 있다 🔬 실측 반증 — 본문 교정 T20 실측
C15. istioctl analyze는 의도 수준 오류를, proxy-status는 전달(sync) 여부를 검증한다 ✅ 문헌 확인 istio.io/…/istioctl

Files