--- type: note tags: [istio, xds, envoy, ads] created: 2026-06-07 --- # xDS는 Envoy 설정을 LDS/RDS/CDS/EDS/SDS 계층으로 나누고 ADS가 이를 단일 스트림으로 묶어 적용 순서를 보장한다 > [!abstract] 이 문서가 다루는 것 > Envoy의 동적 설정은 단일 덩어리가 아니라 **5개 xDS API**(LDS/RDS/CDS/EDS/SDS)로 분리되어 "리스닝 지점 / 라우팅 / upstream pool / 실제 endpoint / TLS secret"이라는 서로 다른 계층을 각각 담당한다. 이 분리는 *부분 업데이트*를 가능하게 하지만 동시에 **순서 문제**를 낳는다 — route가 아직 없는 cluster를 가리키면 트래픽이 깨진다. **ADS(Aggregated Discovery Service)** 가 이 여러 xDS를 하나의 gRPC 스트림으로 묶어 적용 순서를 보장함으로써 그 문제를 푼다. 이 문서는 그 **개념·멘탈모델**에 집중하고, `istioctl proxy-config`로 각 계층을 진단하는 운영 상세는 [xDS 5계층과 istioctl 진단](xds__src-xds-layers-and-diagnosis.html)으로 위임한다. > > 대상환경: Istio 1.30, sidecar mode, Envoy. · 대상독자: xDS를 "이름은 들었는데 왜 5개로 쪼개고 ADS가 뭘 해결하는지" 모르는 DevOps/SRE. · 범위: 계층 분리의 동기 → 5계층 역할 → 순서/일관성 메커니즘 → istiod 전달 경로. · 선행: Envoy listener/route/cluster 기본 개념. --- ## 01. 배경 — 왜 설정을 한 덩어리가 아니라 계층으로 쪼개나 > [!key] 한 문장 멘탈모델 (이 그림 하나만 잡으면 된다) > **xDS = istiod가 K8s/Istio 상태를 Envoy 설정으로 컴파일해, 변화 빈도가 다른 5개 계층(LDS/RDS/CDS/EDS/SDS)을 각각 독립 갱신하는 채널이고, ADS는 그 5개를 한 스트림에 묶어 "참조 대상이 먼저, 참조하는 쪽이 나중"이라는 적용 순서를 강제하는 봉투다.** 아래 모든 디테일은 이 한 그림의 세부다. Envoy를 정적 부트스트랩 YAML로만 운영하면 listener/route/cluster/endpoint가 한 파일에 고정된다(정적/동적 경계는 [정적 vs 동적 설정](xds__note-envoy-static-vs-dynamic-config.html) 참조). 메시 환경에서 이게 깨지는 이유는 단 하나 — 이 설정의 각 부분이 **변화 빈도가 완전히 다르다**. ```text Endpoint : Pod가 뜨고 죽을 때마다 변함 → 초 단위로 자주 Cluster : Service/DestinationRule 바뀔 때 → 가끔 Route : VirtualService 바뀔 때 → 가끔 Listener : Gateway/포트 바뀔 때 → 드물게 Secret : 인증서 rotation 주기마다 → 드물게 ``` 설정이 단일 blob이라면 Pod 하나가 죽을 때마다 listener·route·cluster 전체를 다시 컴파일해 내려보내야 한다 — 가장 자주 바뀌는 부분(endpoint)이 가장 안 바뀌는 부분(listener)의 재전송을 끌고 다닌다. xDS는 이를 **독립적으로 갱신 가능한 5개 API**로 쪼개서, endpoint만 바뀌면 EDS만 push하고 나머지는 손대지 않는다. 이것이 계층 분리의 1차 동기 — **변화 빈도별 분리(decoupling by churn rate)** 다. 분리에는 대가가 따르는데(03절의 순서 문제), 그 대가를 ADS가 갚는 구조다. --- ## 02. 5개 계층 각각이 담당하는 질문 요청 하나가 Envoy를 통과하는 흐름을 따라가면 다섯 계층의 역할이 한 줄로 꿰진다. **요청은 항상 `Listener → Route → Cluster → Endpoint` 순으로 resolve되고, mTLS가 걸리면 그 과정에서 `Secret`을 쓴다.** 각 계층을 "필드"가 아니라 "그게 답하는 질문"으로 읽으면 외울 필요가 없다. ```mermaid flowchart TD L["LDS / Listener
where to receive"] R["RDS / Route
which cluster"] C["CDS / Cluster
upstream pool + policy"] E["EDS / Endpoint
actual Pod IPs"] S["SDS / Secret
mTLS cert / key / CA"] L --> R R --> C C --> E C -. mTLS .-> S ``` | API | 풀네임 | 답하는 질문 | 내려주는 것 | Istio 리소스(대표) | |---|---|---|---|---| | **LDS** | Listener Discovery Service | **어디서** 트래픽을 받나 | listener + filter chain (15001 out / 15006 in / 포트별) | `Gateway`, `Sidecar`, `PeerAuthentication` | | **RDS** | Route Discovery Service | 이 HTTP 요청을 **어느 cluster로** | route config (virtual host, route entry) | `VirtualService`, `Gateway` | | **CDS** | Cluster Discovery Service | upstream **목적지 정의**와 정책 | cluster (upstream pool) + LB/timeout/outlier | `Service`, `ServiceEntry`, `DestinationRule` | | **EDS** | Endpoint Discovery Service | 그 cluster의 **실제 Pod IP**들 | cluster 안의 endpoint 목록 | `EndpointSlice`, `WorkloadEntry` | | **SDS** | Secret Discovery Service | mTLS handshake에서 **workload identity** 증명 | TLS cert / private key / root CA | istiod CA, `PeerAuthentication` | 가장 자주 혼동되는 두 지점을 메커니즘으로 못 박아 두자. - **RDS의 D는 Discovery다** — "Route *Direct* Service"가 아니다. xDS 전체가 `x + Discovery Service` 패턴(L/R/C/E/S + DS)이라는 점을 기억하면 안 헷갈린다. - **CDS와 EDS는 따로 온다** — cluster(`outbound|9080|v1|reviews...`)는 CDS로 정의되고, 그 cluster 안에 들어갈 endpoint 목록(`10.244.1.12:9080` ...)은 EDS로 **별도** 스트림으로 온다. 이게 분리돼 있기 때문에 "cluster 객체는 존재하는데 그 안의 endpoint가 비어서 `503 UH`(no healthy upstream)"라는, 처음엔 모순처럼 보이는 상태가 가능하다. cluster 이름 규칙 `direction|port|subset|fqdn`의 해부는 [Cluster 해부](xds__src-cluster-anatomy.html) 참조. --- ## 03. 계층 분리의 대가 — 참조 무결성과 순서 문제 계층을 나눈 대가로 **참조 무결성(referential integrity)** 문제가 생긴다. 각 계층은 윗 계층을 값이 아니라 **이름으로 참조**하기 때문이다. ```text LDS listener --참조--> RDS route (RouteConfiguration 이름) RDS route --참조--> CDS cluster (이름: outbound|9080|v1|reviews...) CDS cluster --참조--> EDS endpoint set (같은 cluster 이름) ``` 이름으로 참조하니 **갱신 순서가 틀리면 댕글링 참조(dangling reference)** 가 생긴다. route를 cluster Y로 바꾸는 변경을 5개 독립 스트림으로 보냈다고 하자. ```text [잘못된 순서] 1) RDS 먼저 push → route가 cluster Y를 가리킴 그런데 Envoy는 아직 cluster Y를 모름 → 이 순간 트래픽이 "존재하지 않는 cluster"로 가서 깨짐 2) CDS 나중에 push → 뒤늦게 cluster Y 생성 (이미 늦음) ``` 핵심 불변식은 단 하나다 — **"route가 아직 없거나 이미 사라진 cluster를 가리키는 순간이 생기면 안 된다."** 이 불변식을 지키려면 갱신을 *순서대로* 적용해야 하는데, 5개 API가 각각 독립 스트림이면 메시지 도착 순서를 제어할 방법이 없다(스트림 간에는 순서 보장이 없다). 여기서 ADS가 등장한다. --- ## 04. ADS — 단일 스트림으로 순서를 사다 Envoy 표준에서 CDS/EDS/LDS/RDS/SDS는 각각 독립 gRPC streaming endpoint를 가질 수 있다(개별 xDS). **ADS(Aggregated Discovery Service)** 는 이 여러 리소스 타입을 **하나의 양방향 gRPC 스트림**으로 묶는다. Istio(istiod)는 모든 프록시에 대해 예외 없이 ADS를 쓴다. ADS가 사주는 것은 묶음 자체가 아니라 **순서(ordering)** 다. 단일 스트림이므로 istiod가 보낸 순서대로 Envoy가 받는다. 따라서 istiod는 **참조 대상을 먼저, 참조하는 쪽을 나중에** 보내는 식으로 댕글링 참조를 원천 차단한다. ```mermaid sequenceDiagram participant K as K8s API + Istio CRD participant I as istiod (compiler) participant E as Envoy (single ADS stream) K->>I: Service/VS/DR/EndpointSlice change Note over I: compile to xDS I->>E: CDS (define cluster Y) E-->>I: ACK I->>E: EDS (fill cluster Y endpoints) E-->>I: ACK I->>E: RDS (route now points to cluster Y) E-->>I: ACK I->>E: LDS (refresh listener if needed) E-->>I: ACK ``` ### 적용 순서의 비대칭 (add vs remove) 순서는 변경 방향에 따라 **뒤집힌다**. 같은 불변식을 양방향에서 지키려면 그래야 하기 때문이다. ```text ADD (추가) : CDS → EDS → RDS → LDS (bottom-up: 참조 대상부터 만든다) REMOVE (삭제) : LDS → RDS → CDS → EDS (top-down: 참조하는 손을 먼저 뗀다) ``` 추가는 "가리킬 대상을 먼저 만든다", 삭제는 "가리키던 손을 먼저 뗀다". 두 방향 모두 같은 불변식("빈/없는 cluster를 가리키는 route가 없다")을 지키므로 추가·삭제 어느 쪽도 트래픽이 끊기지 않는다. 이 비대칭이 graceful한 라우팅 전환·드레인의 기반이다. --- ## 05. 순서만으론 부족하다 — warming과 ACK/NACK ADS의 순서 보장만으로는 두 구멍이 남는다. ① cluster를 받았다고 그 endpoint가 즉시 채워지는 건 아니다. ② Envoy가 받은 설정이 항상 유효한 것도 아니다. 두 메커니즘이 각각을 막는다. **① Cluster warming.** Envoy는 새 cluster를 받으면 **곧바로 트래픽에 투입하지 않고** "warming" 상태에 둔다. warming 중에는 그 cluster가 의존하는 것들(EDS endpoint, 필요 시 SDS secret, active health check 1회)이 준비될 때까지 기다린다. 준비가 끝나야 cluster가 "warm"이 되어 실제로 사용된다. 덕분에 CDS는 받았지만 EDS가 아직 안 온 짧은 윈도우에도 트래픽이 빈 cluster로 쏟아지지 않는다. **warming = "endpoint 없는 cluster로 라우팅하지 않게 하는 안전장치".** (ADS 순서는 "CDS가 RDS보다 먼저 도착"을, warming은 "도착한 CDS라도 채워지기 전엔 안 쓴다"를 보장 — 층이 다르다.) **② ACK / NACK.** Envoy는 설정을 받으면 control plane에 응답한다. ```text ACK : 설정을 받아들이고 적용함 (정상) NACK : 설정이 유효하지 않아 거부함 → Envoy는 직전 good config를 유지 ``` NACK는 중요한 fail-safe 속성을 준다 — **잘못된 설정을 push해도 Envoy가 멈추지 않고 마지막 정상 설정으로 계속 동작**한다. 이 ACK/NACK 결과는 `istioctl proxy-status`의 **`SYNCED`(ACK) / `STALE`(보냈으나 ACK 못 받음) / `NOT SENT`(보낼 것 없음)** 로 드러난다. 그래서 데이터 플레인은 **eventually consistent** 모델이고, 트러블슈팅 1단계가 "sync 됐나?"가 된다 — sync 상태값 의미와 진단 흐름은 [데이터 플레인 sync 상태](xds__note-data-plane-sync-state.html) 참조. > [!warning] 함정 — warming/NACK는 "조용하다" > NACK된 설정은 적용이 안 됐을 뿐 에러가 사용자에게 안 보일 수 있다. `proxy-config`만 보면 *옛* 설정이 멀쩡히 보여 정상처럼 착각하기 쉽다. 그래서 `proxy-config`(프록시가 *실제로 받은* 설정)와 `analyze`/`proxy-status`(istiod가 *의도한* 설정·sync 여부)를 함께 봐야 의도-반영 갭을 잡는다. --- ## 06. 예시 — 5계층을 눈으로 보고 "빈 cluster" 증상을 진단한다 추상 설명을 땅에 박자. bookinfo의 `reviews` 호출을 예로, 한 요청이 통과하는 4계층을 `istioctl proxy-config`로 그대로 들여다본다(검증은 `productpage` Pod 시점). 명령·증상 매핑의 풀 레퍼런스는 [xDS 5계층과 istioctl 진단](xds__src-xds-layers-and-diagnosis.html)에 있다 — 여기선 멘탈모델을 출력에 붙이는 데만 집중한다. `Listener → Route` (LDS/RDS) — 어디서 받아 어느 route로: ```bash istioctl proxy-config route deploy/productpage-v1 -n default --name 9080 -o short # NAME VHOST NAME DOMAINS MATCH VIRTUAL SERVICE # 9080 reviews:9080 reviews, reviews.default ... /* reviews.default ``` `Cluster → Endpoint` (CDS/EDS) — 어느 upstream pool로, 실제 Pod IP는 뭔지: ```bash istioctl proxy-config cluster deploy/productpage-v1 -n default --fqdn reviews.default.svc.cluster.local -o short # SERVICE FQDN PORT SUBSET ... DESTINATION RULE # reviews.default.svc.cluster.local 9080 - ... reviews.default # reviews.default.svc.cluster.local 9080 v1 ... reviews.default istioctl proxy-config endpoint deploy/productpage-v1 -n default \ --cluster "outbound|9080|v1|reviews.default.svc.cluster.local" -o short # ENDPOINT STATUS CLUSTER # 10.244.1.12:9080 HEALTHY outbound|9080|v1|reviews.default.svc.cluster.local ``` 여기까지가 정상 상태다. 이제 03/05절의 메커니즘이 어떻게 **증상**으로 드러나는지 — `reviews-v1`을 0 replica로 줄여 endpoint를 비워 본다. ```bash kubectl scale deploy/reviews-v1 -n default --replicas=0 istioctl proxy-config endpoint deploy/productpage-v1 -n default \ --cluster "outbound|9080|v1|reviews.default.svc.cluster.local" -o short # ENDPOINT STATUS CLUSTER # (empty) ``` **cluster 객체는 그대로 있는데(CDS) endpoint만 사라졌다(EDS)** — 정확히 02절에서 "CDS와 EDS는 따로 온다"가 가능하게 만든 상태다. 이 subset으로 가는 요청은 Envoy 응답 플래그 `UH`(no healthy upstream)와 함께 `503`이 된다. ```bash kubectl exec deploy/productpage-v1 -n default -c istio-proxy -- \ curl -s -o /dev/null -w "%{http_code}\n" http://reviews:9080/health # 503 ← cluster는 있으나 EDS가 비어 UH ``` 마지막으로 "이게 잘못된 설정 때문인가, 아니면 받아들여진 정상 설정 결과인가"를 sync 상태로 가른다. ```bash istioctl proxy-status # NAME CDS LDS EDS RDS ECDS ISTIOD ... # productpage-v1...default SYNCED SYNCED SYNCED SYNCED NOT SENT istiod-xxx ... ``` 모든 계층이 `SYNCED` = **설정은 의도대로 ACK됐다.** 즉 이 503은 push/NACK 문제가 아니라 "endpoint가 실제로 0개"라는 토폴로지 사실이다. 만약 여기서 EDS가 `STALE`이었다면 진단 방향은 정반대(istiod가 push했는데 프록시가 ACK 못 함)로 바뀐다 — 이 한 표가 "받은 것 vs 의도한 것"의 갭을 한눈에 가른다. --- ## 07. Istio가 실제로 push하는 경로 — istiod와 istio-agent 표준 Envoy는 외부 xDS management server에 직접 붙지만, Istio sidecar는 한 단계를 더 둔다. 각 Pod의 **istio-agent(pilot-agent)** 가 Envoy와 istiod 사이에 끼어든다. ```mermaid flowchart LR ISTIOD["istiod
(xDS server + CA)"] AGENT["istio-agent
(per-pod, xDS proxy + SDS)"] ENVOY["Envoy
(localhost ADS)"] ISTIOD -- ADS over mTLS --> AGENT AGENT -- ADS over localhost --> ENVOY AGENT -. SDS cert/key .-> ENVOY ``` - Envoy는 **localhost의 istio-agent**에게 ADS를 요청한다(Envoy는 istiod를 직접 모른다). - istio-agent가 istiod와 **mTLS gRPC 스트림**을 유지하며 xDS를 중계한다. - **SDS는 특히 agent 로컬에서 처리**된다 — workload 인증서의 private key는 네트워크를 타지 않고 agent가 메모리에서 Envoy에 SDS로 건넨다(key가 istiod로 오가지 않음). mTLS/SPIFFE identity 발급 흐름은 [mTLS와 SPIFFE identity](sec__note-mtls-spiffe-identity.html) 참조. istiod 쪽에서는 K8s informer가 Service/Endpoint/Pod/Istio CRD 변화를 감지 → 내부 모델 갱신 → 영향받는 프록시에게만 **증분(delta) 또는 전체 push**를 ADS로 내보낸다. 한 번의 변경이 메시 전체 프록시 push로 번질 수 있어, 이 push 부하가 대규모 메시에서 istiod 성능의 핵심 변수다 — [control plane 성능 요인](arch__note-control-plane-performance-factors.html) 참조. --- ## 핵심 정리 ```text 계층 분리 이유 : 변화 빈도가 다른 설정을 독립 갱신 (endpoint만 바뀌면 EDS만 push) 5계층 : LDS(어디서 받나) RDS(어느 cluster로) CDS(upstream 정의) EDS(실제 IP) SDS(mTLS secret) resolve: Listener → Route → Cluster → Endpoint (+ Secret) 주의 : RDS의 D=Discovery / CDS와 EDS는 따로 옴(cluster 있어도 endpoint 빌 수 있음 → UH 503) ADS : 5개 xDS를 단일 gRPC 스트림으로 묶어 순서 보장 (핵심 가치는 묶음이 아니라 순서) 순서 : ADD = CDS→EDS→RDS→LDS(bottom-up) / REMOVE = top-down 불변식 : route가 빈/없는 cluster를 가리키는 순간이 없다 일관성 : warming(준비 안 된 cluster 미사용) + ACK/NACK(나쁜 설정 거부, last good 유지) → eventually consistent → proxy-status SYNCED/STALE/NOT SENT로 표면화 Istio : Envoy → localhost istio-agent → (mTLS) istiod. SDS는 agent 로컬(key 안 나감) ``` ## What you might be missing - **ADS의 핵심 가치는 "묶음"이 아니라 "순서"다.** 여러 xDS를 한 스트림에 넣는 것 자체가 목적이 아니라, 단일 스트림이라야 댕글링 참조(route → 없는 cluster)를 막는 적용 순서를 강제할 수 있기 때문이다. 그래서 Istio는 예외 없이 ADS만 쓴다. - **warming과 ADS 순서는 다른 층의 안전장치다.** ADS는 "CDS가 RDS보다 먼저 도착"을, warming은 "도착한 CDS cluster라도 endpoint가 채워지기 전엔 안 쓴다"를 보장한다. 둘이 합쳐져야 "라우팅 전환 중 트래픽 무중단"이 성립한다. 하나만으로는 짧은 깨짐 윈도우가 남는다. - **NACK는 fail-safe다 — 잘못된 push가 메시를 멈추지 않는다.** NACK된 프록시는 마지막 정상 설정으로 계속 돈다. 위험은 오히려 "겉으론 정상인데 새 설정이 조용히 거부됨"이다. `proxy-config`만 보면 옛 설정이 보여 정상처럼 착각하기 쉽다 — `proxy-status`(STALE 여부)와 istiod NACK 메트릭을 함께 봐야 한다. - **SDS의 비대칭에 주의.** LDS/RDS/CDS/EDS는 istiod가 컴파일해 push하는 "토폴로지" 설정이지만, SDS는 workload **신원**과 묶여 있고 private key가 agent 로컬에 머문다. 그래서 인증서 만료/rotation 문제는 다른 4계층과 증상 양상이 다르다(handshake 단계 `503 UF`, AuthorizationPolicy principal mismatch 등). - **proxy-config는 "받은 것", analyze는 "의도한 것".** 이 둘의 갭이 곧 push/ACK 실패다. xDS를 진짜로 이해하려면 리소스 apply 전후로 `proxy-config ... -o json`을 diff 떠 보는 것이 가장 빠른 훈련이다(절차는 [진단 src](xds__src-xds-layers-and-diagnosis.html) §08).