---
type: src
tags: [istio, envoy, xds]
created: 2026-06-07
---
# Envoy 정적/동적(xDS) 설정 실습 — Istio in Action Ch.3.2 (출처: 책 + 실습기록 + 공식문서)
> [!abstract]
> Envoy 설정은 `listener → route → cluster → endpoint`가 서로를 **이름으로** 참조하는 한 줄 사슬이다. 정적 모드는 이 사슬을 bootstrap에 통째로 인라인하고, 동적 모드(xDS)는 *같은 사슬의 각 노드*를 컨트롤 플레인이 런타임에 push로 채운다 — 사슬 자체는 동일하고 "공급 방식"만 다르다.
> 이 문서는 그 한 사슬을 **정적(bootstrap 인라인) → 파일 기반 동적(LDS/RDS) → 실제 ADS(gRPC)** 세 가지 공급 방식으로 직접 돌려, xDS 계층(LDS/RDS/CDS/EDS/SDS+ADS)이 왜 그렇게 설계됐는지를 손으로 체득하는 실습 기록이다.
> 결론 먼저: 학습이 목적이면 파일 기반 EDS 삽질을 피하고, 파일 동적 LDS/RDS로 라우팅 변화를 눈으로 본 뒤 곧장 Istio on kind의 `istioctl proxy-config`로 진짜 ADS를 관찰하는 경로가 가장 효율적이다.
>
> **대상환경**: Mac + Docker (Apple Silicon 포함), Envoy v1.35, Istio 1.30. **대상독자**: Envoy/Istio data plane이 "어떻게 설정을 받는가"를 메커니즘 수준으로 알고 싶은 DevOps/SRE. **범위**: 한 백엔드 트래픽을 정적·파일동적·ADS로 공급. **선행개념**: HTTP proxy, gRPC, Docker network.
## 1. 배경 — 왜 Envoy에 "정적"과 "동적" 두 모드가 있나
Envoy는 reverse proxy다. 트래픽을 받으려면 최소한 네 가지를 알아야 한다: **어느 포트로 받고**(listener), **무슨 규칙으로 분배하고**(route), **어떤 백엔드 그룹으로 보내고**(cluster), **그 그룹의 실제 IP는 무엇인지**(endpoint). 이 네 가지를 한 파일(`envoy.yaml`)에 다 적어두면 끝 — 이게 **정적(static)** 설정이다. 단독 proxy를 띄울 땐 완벽하다.
문제는 service mesh다. Istio 사이드카는 **mesh 토폴로지가 살아 움직이는** 환경에서 동작한다. Pod가 scale-out되면 endpoint가 늘고, VirtualService를 고치면 route가 바뀌고, 새 Service가 생기면 cluster가 추가된다. 이걸 정적 파일로 다루려면 **변경마다 사이드카를 재시작**해야 하는데, 그러면 in-flight 연결이 끊기고 mesh 전체가 출렁인다. 재시작 없이, **컨트롤 플레인(istiod)이 런타임에 설정을 push**해 반영하는 메커니즘 — 그게 **동적(dynamic)** 설정이고, 그 push 프로토콜이 **xDS**다.
즉 정적 vs 동적은 "기능"의 차이가 아니라 **설정을 누가·언제 공급하느냐**의 차이다. 정적은 *내가 부팅 시점에 통째로*, 동적은 *컨트롤 플레인이 런타임에 조각조각*. 이 한 문장을 잡고 가면 아래 세 실습이 모두 "같은 사슬, 다른 공급"으로 보인다.
> 책은 Envoy **v3 설정 API**만 다룬다(v1/v2는 폐기됨). 실습도 v3 `typed_config` YAML로 진행한다. v3 type URL은 `type.googleapis.com/envoy....v3....` 형태로, 각 리소스가 자기 타입을 명시하는 self-describing 구조다(파일/gRPC 어느 경로든 동일).
## 2. 핵심 아키텍처 — 사슬과 xDS 계층
**머릿속 앵커**: Envoy 설정은 `listener → route → cluster → endpoint` 한 사슬이고, 각 노드는 **다음 노드를 이름(string)으로 참조**한다. 정적이든 동적이든 이 사슬의 모양은 같다. xDS의 각 "x"는 이 사슬의 한 노드를 *런타임에 채워 넣는 API*일 뿐이다.
```mermaid
flowchart LR
L["listener (LDS)
port 15001"] --> R["route_config (RDS)
local_route"]
R --> W["weighted_clusters
by name"]
W --> C["cluster (CDS)
httpbin_a / _b"]
C --> E["load_assignment / endpoint (EDS)
actual IP:port"]
classDef dyn fill:#efe,stroke:#8a8;
L:::dyn
R:::dyn
C:::dyn
E:::dyn
```
사슬이 "이름 참조"라는 점이 모든 설계의 뿌리다. listener는 route를 `route_config_name`으로, route는 cluster를 `name`으로, cluster는 endpoint를 `cluster_name`으로 가리킨다. 가리키는 대상이 아직 없으면 그 참조는 **dangling**이다. 그래서 동적 공급에는 **순서 제약**이 생긴다: route가 가리키는 cluster가 먼저 도착해야 한다.
### xDS 계층 — 각 API가 채우는 사슬 노드
각 API는 사슬의 한 노드를 담당한다. "필드"가 아니라 "그게 답하는 질문"으로 보면 직관적이다:
| API | 답하는 질문 (= 채우는 노드) | 비유 |
|---|---|---|
| **LDS** (Listener) | 어떤 포트로 트래픽을 받을지 | 현관문 |
| **RDS** (Route) | 받은 요청을 어떤 클러스터로 보낼지(가중치/리트라이/타임아웃) | 교통 표지판 |
| **CDS** (Cluster) | 백엔드 서비스 그룹 정의 | 목적지 건물 목록 |
| **EDS** (Endpoint) | 클러스터에 속한 실제 IP들 | 건물 안 실제 방 번호 |
| **SDS** (Secret) | TLS 인증서 | 출입증 |
| **ADS** (Aggregated) | 위 전부를 단일 gRPC 스트림으로 묶어 순서 보장 | 모든 변경을 한 통로로 |
### 왜 ADS인가 — 단일 스트림이 푸는 문제
xDS를 계층별로 따로 스트림으로 받으면(예: LDS는 LDS 스트림, CDS는 CDS 스트림) **계층 간 도착 순서를 보장할 수 없다.** route가 먼저 도착하고 그 route가 가리키는 cluster가 아직 안 왔으면, Envoy는 일시적으로 dangling 참조 상태에 빠진다. **ADS(Aggregated Discovery Service)** 는 모든 계층을 *하나의 gRPC 스트림*으로 묶어, 컨트롤 플레인이 "cluster 먼저, 그 다음 route" 순서로 보낼 수 있게 한다. Istio는 `istiod`가 이 ADS 한 스트림으로 사이드카에 LDS/RDS/CDS/EDS/SDS 전체를 push한다.
순서 보장의 실체는 **warming**이다. ADS가 새 cluster를 받으면 Envoy는 곧장 트래픽을 흘리지 않고, EDS로 endpoint가 채워질 때까지 그 cluster를 **warming** 상태로 둔다. route가 가리키는 cluster가 warming을 끝내고 ready가 된 뒤에야 트래픽이 흐른다. 즉 ADS의 "순서 보장"은 단순 메시지 순서가 아니라, *참조 대상이 실제로 사용 가능해질 때까지 대기*하는 메커니즘이다.
- **정적 모드**: 사슬 전체가 하나의 `envoy.yaml` `static_resources`에 인라인 → 부팅 시 한 번에 완결, 재시작으로만 변경.
- **동적 모드**: listener는 LDS가, route는 RDS가, cluster는 CDS가, endpoint는 EDS가 각각 push. 참조는 여전히 이름이므로 가리키는 대상이 먼저 와야 하고(ADS가 warming으로 보장), 무중단 반영된다.
- 계층별 상세는 [xDS API 계층](xds__note-xds-api-layers.html) 참조.
## 3. 실습 A — 정적 설정으로 Envoy 기동
먼저 사슬을 **통째로 인라인**해 본다. `/`로 들어온 요청을 `httpbin` 클러스터로 보내는 최소 구성으로, 사슬의 네 노드(listener/route/cluster/endpoint)가 한 파일에 어떻게 한 줄로 이어지는지를 눈으로 본다.
### 사전 준비 (Mac + Docker)
> [!correction] 원본은 httpbin 이미지로 `citizenstig/httpbin`과 `kennethreitz/httpbin`을 혼용했는데, 둘 다 **x86_64 전용 + 유지보수 중단** 이미지라 Apple Silicon(M-series)에서 에뮬레이션으로 느리거나 실패한다.
> Apple Silicon에서는 멀티아키 + 활발히 유지되는 `mccutchen/go-httpbin`을 쓴다(원본 맨 위 실습 줄에서 이미 이 이미지를 선택했으므로 그 선택이 옳다). 단 이 이미지의 컨테이너 내부 포트는 **8080**이다.
```bash
# 컨테이너 이름 DNS 해석을 위한 사용자 정의 네트워크
docker network create envoylab 2>/dev/null || true
# 멀티아키 httpbin (컨테이너 내부 포트 8080)
docker run -d --name httpbin --network envoylab mccutchen/go-httpbin:latest
# Envoy 이미지 (arm64/x86_64 모두 지원하는 태그)
docker pull envoyproxy/envoy:v1.35-latest
```
> Docker Desktop(macOS)은 IPv6 경로 차이로 DNS/연결이 불안정할 수 있어, Envoy 클러스터에 `dns_lookup_family: V4_ONLY`를 넣는 것이 공식 권고다.
### 부트스트랩 — 사슬 한 파일
```yaml
# envoy.yaml — Admin API: http://localhost:9901
admin:
address:
socket_address: { address: 0.0.0.0, port_value: 9901 }
static_resources:
listeners:
- name: listener_httpbin
address:
socket_address: { address: 0.0.0.0, port_value: 15001 }
filter_chains:
- filters:
- name: envoy.filters.network.http_connection_manager
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
stat_prefix: ingress_http
codec_type: AUTO # HTTP/1.1·HTTP/2 자동 협상
route_config: # ← route 노드를 인라인 (동적이면 RDS 자리)
name: local_route
virtual_hosts:
- name: vhost_all
domains: ["*"]
routes:
- match: { prefix: "/" }
route: { cluster: httpbin_svc } # ← cluster를 "이름"으로 참조
http_filters:
- name: envoy.filters.http.router
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
clusters:
- name: httpbin_svc # ← route가 가리키는 그 이름
connect_timeout: 5s
type: LOGICAL_DNS
dns_lookup_family: V4_ONLY
lb_policy: ROUND_ROBIN
load_assignment: # ← endpoint 노드를 인라인 (동적이면 EDS 자리)
cluster_name: httpbin_svc
endpoints:
- lb_endpoints:
- endpoint:
address:
socket_address: { address: httpbin, port_value: 8080 } # go-httpbin 내부 포트
```
route의 `cluster: httpbin_svc`와 cluster의 `name: httpbin_svc`가 같은 문자열로 이어지는 게 사슬의 "이름 참조"다. 동적 모드에서는 이 두 노드를 RDS와 CDS가 따로 push하지만, 둘을 묶는 끈은 똑같이 이 문자열이다.
> [!correction] 원본 정적 예시의 cluster 엔드포인트가 `port_value: 8080`인데, 함께 쓴 백엔드가 내부 포트 80인 `citizenstig/httpbin`이라 포트 불일치로 연결 실패가 났을 것이다.
> `mccutchen/go-httpbin`(내부 8080)으로 통일하면 위처럼 `8080`이 맞다. 핵심 원리: **Envoy cluster의 endpoint port는 "백엔드 컨테이너의 내부 listen 포트"이지 호스트로 publish한 포트(`-p`)가 아니다.** 같은 docker 네트워크 안에서는 컨테이너 이름 + 내부 포트로 직접 통신하므로 `-p` 매핑이 불필요하다(원본이 `-p 8000:8080`을 붙였지만 envoylab 네트워크 내부 통신에는 무의미).
### 기동 및 검증
```bash
chmod 644 envoy.yaml # 컨테이너 내 envoy 유저가 읽을 수 있어야 함
docker run --rm -d --name envoy \
--network envoylab \
-p 15001:15001 -p 9901:9901 \
-v "$(pwd)/envoy.yaml:/etc/envoy/envoy.yaml:ro" \
envoyproxy/envoy:v1.35-latest \
-c /etc/envoy/envoy.yaml
# 기능 확인 (예상: 200)
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:15001/get
```
Admin API로 현재 상태 덤프 — 이 `/config_dump`가 곧 프로덕션 진단의 원천이다:
```bash
curl -s http://localhost:9901/config_dump | jq '.configs | length'
curl -s 'http://localhost:9901/clusters?format=json' | jq '.cluster_statuses[].name'
curl -s http://localhost:9901/listeners
curl -s http://localhost:9901/stats | grep -E 'cluster_manager|listener_manager|server'
```
**검증 포인트**: `/config_dump`에 listener/route/cluster가 기대대로 보이고, `/clusters`에 `httpbin_svc` 엔드포인트가 healthy, `/stats`에서 요청 카운터가 증가해야 한다. 정적이라 모든 리소스가 `static_*` 섹션에 박혀 있는 것을 확인하라(동적이면 `dynamic_*`).
## 4. 실습 B — 파일 기반 xDS (LDS/RDS만 동적, CDS는 STRICT_DNS)
이제 같은 사슬을 **조각으로 분리**해 공급한다. 핵심 깨달음은 *공급 방식만 바뀌고 사슬은 그대로*라는 것. 파일 기반 xDS의 "개념 확인"은 LDS/RDS만으로 충분하다 — 라우팅 가중치/리트라이/타임아웃 변경을 재시작 없이 즉시 관찰할 수 있고, 까다로운 EDS 포맷 문제(§5)를 피한다. cluster는 `STRICT_DNS`로 두어 EDS를 우회한다.
```bash
docker network create envoylab 2>/dev/null || true
docker run -d --rm --name httpbin-a --network envoylab mccutchen/go-httpbin:latest
docker run -d --rm --name httpbin-b --network envoylab mccutchen/go-httpbin:latest
```
> 실습 A는 admin을 `9901`로 썼지만, 실습 B는 **`15000`**을 쓴다 — `15000`이 Istio 사이드카의 표준 Envoy admin 포트라 실제 환경과 맞추기 위한 의도적 선택이다.
`xds/bootstrap.yaml` — 이제 bootstrap엔 "어디서 사슬을 받아올지"만 적는다:
```yaml
admin:
# 컨테이너 외부(-p 15000)에서 config_dump를 떠야 하므로 0.0.0.0으로 바인드.
# 127.0.0.1로 두면 컨테이너 내부에만 바인드돼 host의 :15000 접근이 막힌다(아래 함정).
address: { socket_address: { address: 0.0.0.0, port_value: 15000 } }
node: { id: demo-1, cluster: demo }
dynamic_resources:
lds_config: { path: /etc/envoy/lds.yaml } # listener를 파일에서 구독
cds_config: { path: /etc/envoy/cds.yaml } # cluster를 파일에서 구독
```
> [!warning] admin bind 주소 함정 — admin `socket_address`를 `127.0.0.1`로 두면 admin은 **컨테이너 내부 루프백에만** 바인드된다. 그 상태로 `-p 15000:15000`을 publish하고 host에서 `127.0.0.1:15000/config_dump`를 호출하면 연결이 거부돼 실습이 재현되지 않는다. host에서 떠야 하면 **`0.0.0.0`** 으로 바인드하거나, bind를 `127.0.0.1`로 두려면 `docker exec envoy curl 127.0.0.1:15000/...`처럼 **컨테이너 내부에서** 호출해야 한다.
`xds/lds.yaml` (HCM이 RDS를 *또 파일로* 구독 → 사슬 한 칸 더 분리):
```yaml
resources:
- "@type": type.googleapis.com/envoy.config.listener.v3.Listener
name: l_http_15001
address: { socket_address: { address: 0.0.0.0, port_value: 15001 } }
filter_chains:
- filters:
- name: envoy.filters.network.http_connection_manager
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
stat_prefix: dyn_hcm
codec_type: AUTO
rds:
route_config_name: local_route # ← route를 "이름"으로 구독
# v3에서 config_source의 단순 `path`는 deprecated → `path_config_source` 권장.
config_source:
path_config_source: { path: /etc/envoy/rds.yaml }
http_filters:
- name: envoy.filters.http.router
typed_config: { "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router }
```
`xds/cds.yaml` (STRICT_DNS로 간소화 → EDS 불필요):
```yaml
resources:
- "@type": type.googleapis.com/envoy.config.cluster.v3.Cluster
name: httpbin_a
type: STRICT_DNS
connect_timeout: 2s
lb_policy: ROUND_ROBIN
load_assignment:
cluster_name: httpbin_a
endpoints:
- lb_endpoints:
- endpoint: { address: { socket_address: { address: httpbin-a, port_value: 8080 } } }
- "@type": type.googleapis.com/envoy.config.cluster.v3.Cluster
name: httpbin_b
type: STRICT_DNS
connect_timeout: 2s
lb_policy: ROUND_ROBIN
load_assignment:
cluster_name: httpbin_b
endpoints:
- lb_endpoints:
- endpoint: { address: { socket_address: { address: httpbin-b, port_value: 8080 } } }
```
`xds/rds.yaml` (처음엔 100% A — 사슬의 route 노드만 따로):
```yaml
version_info: "0"
resources:
- "@type": type.googleapis.com/envoy.config.route.v3.RouteConfiguration
name: local_route
virtual_hosts:
- name: all
domains: ["*"]
routes:
- match: { prefix: "/" }
route:
weighted_clusters:
clusters:
- { name: httpbin_a, weight: 100 } # CDS의 cluster 이름과 정렬
- { name: httpbin_b, weight: 0 }
timeout: 15s
```
### 기동 + 가중치 스위치 (동적의 핵심 시연)
```bash
docker run --rm -d --name envoy --network envoylab \
-p 15001:15001 -p 15000:15000 \
-v "$PWD/xds:/etc/envoy:ro" \
envoyproxy/envoy:v1.35-latest \
-c /etc/envoy/bootstrap.yaml
# 헤더로 어느 백엔드가 응답했는지 확인 (go-httpbin은 /headers, /uuid 등 제공)
curl -s http://127.0.0.1:15001/headers # 초기엔 A로 라우팅
# RDS 가중치 100% B로 교체 — 반드시 'mv'(move)로 교체
cat > xds/rds.new <<'EOF'
version_info: "1"
resources:
- "@type": type.googleapis.com/envoy.config.route.v3.RouteConfiguration
name: local_route
virtual_hosts:
- name: all
domains: ["*"]
routes:
- match: { prefix: "/" }
route:
weighted_clusters:
clusters:
- { name: httpbin_a, weight: 0 }
- { name: httpbin_b, weight: 100 }
timeout: 15s
EOF
mv xds/rds.new xds/rds.yaml # in-place 수정이 아니라 move여야 감지됨
# config_dump의 ?resource=는 type URL 기반 필터라 'routes' 같은 약어로는 필터되지 않는다.
# 동적 route config는 jq로 추출하는 편이 확실하다.
curl -s http://127.0.0.1:15000/config_dump \
| jq '.. | .dynamic_route_configs? // empty'
```
**검증 포인트**: 교체 후 `/headers`가 B로 라우팅되고, `/config_dump`의 route가 `dynamic_route_configs`에 (정적이 아니라 동적으로) 잡힌다. Envoy 프로세스를 재시작하지 않고 route만 바뀐 것 — 이게 동적 공급의 본질이다.
> [!note] 파일 기반 동적은 **move(rename) 교체가 필수**다. 에디터로 in-place 저장하면 inotify/kqueue가 안정적으로 감지하지 못한다(쓰기 중간 상태가 노출될 수 있어 Envoy는 atomic rename만 신뢰). 또한 파일 경로는 **부트스트랩 시점에 이미 존재**해야 한다.
## 5. 왜 파일 기반 EDS만 유독 꼬이나 (핵심 함정)
여기서 사슬의 마지막 노드(endpoint)를 파일로 공급하려 하면 갑자기 깨진다. 원인은 단순한 버그가 아니라 **파일 구독의 포맷/이벤트 제약**과 **EDS의 "클러스터당 리소스 1개" 제약**이 겹치기 때문이다:
- 파일 구독은 루트에 `resources:` 배열이 있는 **DiscoveryResponse 포맷**만 파싱하며, **move 교체만** 감지하고, 경로는 초기 로드 시 존재해야 한다.
- **EDS는 Envoy가 "특정 클러스터의 CLA(ClusterLoadAssignment) 1개"만** 기대한다. 한 파일에 둘을 넣으면 `Unexpected EDS resource length: 2`로 거부된다. (gRPC ADS에서는 클러스터별로 구독·응답이 분리되므로 이 제약이 표면화되지 않는다 — 파일이라는 *단일 채널에 여러 클러스터의 CLA를 욱여넣기* 때문에 생기는 문제다.)
- Docker Desktop(macOS)의 파일시스템 이벤트 전달 특성(심볼릭 링크/ConfigMap 패턴)까지 겹치면 재현이 까다롭다. 그래서 Envoy가 `path_config_source.watched_directory` 옵션을 추가했다.
- 파일 EDS 제약의 원리(포맷·이벤트·리소스 1개 제약)는 [파일 기반 xDS 제약](xds__note-file-based-xds-constraints.html)을 정본으로 참조.
**결론(학습 경로 권고)**: Istio 이해가 목적이고 xDS 동작을 "확인만" 하려면 파일 기반 EDS에 매달리지 말 것. (A) 파일 동적 LDS/RDS로 라우팅 회복탄력성 체득 → (C) Istio on kind에서 `istioctl proxy-config`로 진짜 ADS/gRPC xDS 관찰. xDS 자체를 깊게 보려면 (B) go-control-plane 예제로 ADS 미니 컨트롤 플레인을 1~2시간 돌리는 게 파일 EDS 삽질보다 이득이다.
## 6. 실습 C — Istio on kind에서 진짜 ADS 관찰
마지막은 실제 mesh다. 앞 두 실습에서 본 사슬·`/config_dump`가 그대로, 다만 공급자가 `istiod`의 gRPC ADS로 바뀐 형태다. `istioctl proxy-config`는 마법이 아니라 사이드카 admin의 `/config_dump`를 호출해 가공한 래퍼다.
```bash
# 경로 C — Istio on kind에서 xDS 관찰 (최종 목적에 직결)
istioctl proxy-config clusters deploy/sleep -n default -o short
istioctl proxy-config endpoints deploy/sleep -n default \
--cluster "outbound|8000||httpbin.default.svc.cluster.local" -o json
# kubectl scale deploy/httpbin --replicas=2 → EDS 변화를 endpoints로 확인
```
**검증 포인트**: `clusters`에 `outbound|8000||httpbin.default.svc.cluster.local`처럼 `direction|port|subset|fqdn` 규칙의 cluster 이름이 보인다. `kubectl scale`로 replica를 2개로 늘리면 `endpoints` 출력의 IP 개수가 재시작 없이 늘어난다 — 이게 EDS가 ADS로 live push되는 증거이자, 실습 A/B에서 본 사슬이 프로덕션에서 그대로 돌아가는 모습이다.
## 핵심 정리
- **하나의 사슬, 세 공급**: `listener→route→cluster→endpoint`가 이름으로 참조하는 사슬은 정적·파일동적·ADS에서 모두 동일하다. 바뀌는 건 "누가 언제 채우느냐"뿐.
- **정적 vs 동적**: 정적은 bootstrap에 사슬을 인라인해 재시작으로만 바뀐다. 동적(xDS)은 컨트롤 플레인이 각 계층을 런타임에 push해 무중단 반영한다.
- **xDS 계층 + ADS**: LDS/RDS/CDS/EDS/SDS가 이름으로 서로를 참조하고, ADS가 단일 gRPC 스트림으로 묶어 **의존 순서를 warming으로 보장**한다(cluster ready 후 트래픽).
- **파일 EDS 함정**: 파일 구독은 DiscoveryResponse 포맷 + move(rename) 감지 + 클러스터당 CLA 1개 제약이 겹쳐 가장 잘 깨진다 — 개념 확인은 LDS/RDS로 충분.
- **학습 경로 3줄**: (A) 파일 동적 LDS/RDS로 라우팅 동작 체득 → (B) 깊게 보려면 go-control-plane ADS 미니 CP → (C) Istio on kind에서 `istioctl proxy-config`로 진짜 ADS 관찰.
## What you might be missing
- **ADS warming 순서**: ADS는 단순히 "한 스트림"이 아니라, 새 cluster를 받으면 EDS로 endpoint가 채워질 때까지 **warming** 상태로 두고, route가 가리키는 cluster가 준비된 뒤에야 트래픽을 흘린다. 단일 스트림 순서 보장의 실체가 이 warming이다 — 파일 EDS가 깨지는 이유와 같은 뿌리(참조 대상이 먼저 와야 한다)다.
- **SDS와 mTLS의 연결**: SDS는 단순 인증서 배포가 아니라, Istio에서 워크로드의 SPIFFE 식별자(`spiffe://.../sa/...`)를 담은 인증서를 사이드카 메모리로만 전달(파일 미저장)하는 경로다. mTLS·AuthorizationPolicy의 ID 근거가 여기서 온다.
- **`istioctl proxy-config`의 정체**: 이 명령은 마법이 아니라 사이드카 admin의 `/config_dump`를 호출해 보기 좋게 가공한 래퍼다 — 실습 A/B에서 본 `/config_dump`가 곧 프로덕션 진단의 원천이다. admin 진단 패턴은 [Envoy admin API 진단](xds__note-envoy-admin-api-diagnosis.html) 참조.
- **`-p` 매핑 오해**: cluster endpoint port는 백엔드 컨테이너의 내부 listen 포트이지 host로 publish한 포트가 아니다(실습 A correction). 같은 docker network면 컨테이너 이름+내부 포트로 직접 통신한다.
## See also
- [Envoy 정적 vs 동적 설정](xds__note-envoy-static-vs-dynamic-config.html)
- [xDS API 계층](xds__note-xds-api-layers.html)
- [파일 기반 xDS 제약](xds__note-file-based-xds-constraints.html)
- [Envoy admin API 진단](xds__note-envoy-admin-api-diagnosis.html)
- [Cluster 해부](xds__src-cluster-anatomy.html)