---
type: note
tags: [istio, envoy, xds]
created: 2026-06-07
---
# 파일 기반 xDS는 DiscoveryResponse 포맷·move 교체만 감지하고 EDS의 "클러스터당 CLA 1개" 제약 때문에 디버깅이 까다롭다
> [!abstract]
> 머릿속에 둘 한 장면: **파일 기반 xDS는 gRPC ADS의 구독 계약을 그대로 둔 채 전송(transport)만 "로컬 파일"로 바꾼 것**이다. 그래서 세 가지 좁은 제약은 따로 생긴 규칙이 아니라 모두 *그 계약*에서 흘러나온다 — ① 파일은 인라인 조각이 아니라 루트에 `resources:` 배열을 둔 **DiscoveryResponse 응답**이어야 하고, ② 파일 watcher가 신뢰하는 변경 신호는 **move(rename) 이벤트** 하나뿐이며, ③ EDS만은 "응답 1개 = 클러스터 1개"라 **클러스터당 ClusterLoadAssignment 1개**만 허용된다. 결론: LDS/RDS 학습용으로는 훌륭하지만 **파일 EDS는 함정**이고, xDS를 진짜로 보려면 gRPC ADS(Istio면 `istioctl proxy-config`)로 우회하는 편이 빠르다. 깊은 실습 절차는 [정적/동적 xDS 실습](xds__src-envoy-static-dynamic-xds-lab.html)에 위임하고, 여기서는 **왜 그런 제약이 생기고 어떻게 판단할지**의 멘탈모델만 다룬다.
---
## 1. 배경 — 왜 파일 기반 xDS를 손으로 만져보는가
xDS는 한 문장으로 "**설정을 누가, 어떤 전송으로 Envoy에 주입하는가**"의 문제다. Envoy는 Listener(LDS)·Route(RDS)·Cluster(CDS)·Endpoint(EDS)를 부팅 시 한 번 읽고 끝내는 게 아니라, 런타임에 *구독*해서 받아온다. 이 구독을 누가 채워주느냐가 전송(transport)이고, 같은 LDS/RDS/CDS/EDS 리소스라도 전송은 세 가지로 나뉜다:
| 전송 방식 | config_source | 푸시 주체 | 용도 |
|---|---|---|---|
| **static** | (없음, 부트스트랩 인라인) | 없음(고정) | 최소 부팅·테스트 |
| **filesystem(파일)** | `path:` / `path_config_source:` | 로컬 파일 watcher | 단독 실습·엣지 케이스 |
| **gRPC ADS** | `api_config_source: {api_type: GRPC}` | 컨트롤 플레인(istiod) | 프로덕션(Istio) |
프로덕션에서 Istio는 **gRPC ADS 한 채널**로 모든 리소스를 단일 스트림에 실어 보낸다. 그런데 그 ADS 스트림 안을 직접 들여다보긴 어렵다 — istiod가 KRM(VirtualService·DestinationRule 등)을 번역해 푸시하는 결과만 보일 뿐, "Envoy가 한 개의 RouteConfiguration을 받으면 무슨 일이 벌어지는가"를 손으로 한 줄씩 바꿔보긴 힘들다. **파일 기반 xDS는 그 ADS 스트림을 로컬 파일로 "외재화(externalize)"한 것**이다. 컨트롤 플레인 없이 `lds.yaml`·`rds.yaml`을 직접 쓰고, 파일 한 줄 바꾸면 Envoy가 재시작 없이 반영한다. 그래서 "동적 라우팅이 실제로 어떻게 갱신되는가"를 컨트롤 플레인의 추상화를 걷어내고 맨손으로 체득하는 최고의 실습 도구다.
선행 개념: 정적/동적의 경계와 어떤 계층을 동적화할지는 [정적 vs 동적 설정](xds__note-envoy-static-vs-dynamic-config.html), 계층 분할(LDS→RDS, CDS→EDS의 의존 사슬)의 전체 그림은 [xDS API 계층](xds__note-xds-api-layers.html)에 있다. 이 글은 그 위에서 **"파일로 바꾸면 어떤 함정이 새로 생기나"** 만 다룬다.
---
## 2. 핵심 멘탈모델 — "transport만 바꾼 것"에서 세 제약이 따라 나온다
붙잡을 단 하나의 그림:
> **파일 기반 xDS는 gRPC ADS와 동일한 구독 계약을 쓰되, 메시지를 운반하는 채널만 gRPC 스트림 → 로컬 파일로 바꾼다.** Envoy 입장에선 "응답(DiscoveryResponse)을 어디서 받느냐"만 다르고, *받은 응답을 어떻게 검증·적용하느냐*는 똑같다.
이 한 줄을 쥐면 뒤의 세 제약이 전부 *연역*된다. 각 제약은 새로 만든 규칙이 아니라, 동일한 구독 계약의 어느 부분이 파일이라는 전송에서 드러나는가의 차이일 뿐이다:
```mermaid
flowchart TD
contract["동일한 xDS 구독 계약
(gRPC ADS와 공유)"]
contract -->|"응답은 DiscoveryResponse 타입이다"| c1["제약 ① 포맷
루트 resources[] + @type"]
contract -->|"새 응답 = 새 파일 도착 이벤트"| c2["제약 ② 갱신 신호
move(rename)만 안정"]
contract -->|"EDS 응답 단위 = 클러스터 1개"| c3["제약 ③ EDS
클러스터당 CLA 1개"]
c1 -.동일 검증 경로.-> mux["SubscriptionImpl / grpc-mux
(파일이든 gRPC든 공통)"]
c3 -.length 검사.-> mux
```
세 제약을 하나씩 *계약에서 끌어내며* 보면:
### 2-1. 제약 ① — 파일은 DiscoveryResponse여야 한다 (포맷)
ADS 스트림에서 오는 메시지는 `DiscoveryResponse`다. 전송만 바꿨으니 **파일의 모양도 정확히 DiscoveryResponse**여야 한다. 즉 루트에 `resources:` 배열이 있고, 각 원소는 `"@type"`으로 구체 리소스 타입을 명시한다:
```yaml
# rds.yaml — 루트 resources[] + @type 이 계약
version_info: "0" # 선택, 디버깅·관찰용
resources:
- "@type": type.googleapis.com/envoy.config.route.v3.RouteConfiguration
name: local_route
virtual_hosts: [ ... ]
```
여기서 오는 혼동:
- **부분 설정 YAML이 아니다.** "Listener 한 조각"을 그냥 쓰면 안 되고, `Listener`를 `resources[]`로 감싼 응답 형태여야 한다. static 부트스트랩에서 쓰던 인라인 형식과 구조가 다르다는 점이 첫 번째 혼동 지점이다 — 같은 리소스라도 *인라인은 필드 값*, *파일 xDS는 응답 페이로드*라는 위상 차이다.
- **경로는 부트스트랩 시점에 이미 존재**해야 한다. watcher는 초기 로드에서 파일을 읽으므로, 없으면 빈 구독 상태로 시작해 이후 생성에 반응이 어긋난다.
- `@type`이 틀리면 조용히 무시되거나 reject된다. 타입 FQDN(`envoy.config.route.v3.RouteConfiguration` 등)은 v3 API 기준으로 정확히 적어야 한다.
### 2-2. 제약 ② — 갱신은 move(rename)로만 안정 감지된다 (이벤트)
ADS에선 "새 응답이 왔다"가 명시적 메시지다. 파일 전송엔 그런 메시지가 없으니, **"파일이 바뀌었다"는 OS 파일시스템 이벤트로 대신**한다 — `inotify`(Linux)/`kqueue`(macOS). 문제는 에디터/스크립트의 in-place 저장이 발생시키는 이벤트가 플랫폼마다 다르고 불안정하다는 것이다. Envoy가 안정적으로 신뢰하는 트리거는 **rename(move) = `MOVED_TO`** 한 가지다.
```bash
# 잘못: in-place 수정 — inotify가 일관되게 안 잡음(IN_MODIFY/CLOSE_WRITE 누락 가능)
vi xds/rds.yaml
# 옳음: 새 파일에 쓰고 atomic move 로 교체 — 항상 MOVED_TO 발생
cat > xds/rds.new <<'EOF'
... 새 DiscoveryResponse ...
EOF
mv xds/rds.new xds/rds.yaml # rename = 원자적 교체, watcher 확실히 감지
```
**왜 move인가**: rename은 디렉터리 엔트리를 원자적으로 바꾸므로 "반쯤 쓰인 파일을 읽는" race가 없고, 단일 `MOVED_TO` 이벤트로 환원된다. in-place write는 truncate→write 사이의 중간 상태와 다중 이벤트(`IN_MODIFY` 여러 번, `CLOSE_WRITE`)를 만들어 watcher 구현이 빠뜨리기 쉽다. 그래서 ConfigMap을 마운트하는 Kubernetes도 내부적으로 **symlink swap(=rename)** 으로 갱신하며, 그 패턴을 안정 감지하라고 Envoy가 `path_config_source.watched_directory`를 추가했다. 즉 "rename으로만 갱신하라"는 임의의 규칙이 아니라 *원자적 교체만이 일관된 단일 이벤트를 보장*한다는 파일시스템 물리에서 나온다.
```mermaid
sequenceDiagram
participant Op as Operator/script
participant FS as filesystem
participant W as Envoy file watcher
participant E as Envoy config
Op->>FS: write rds.new (full DiscoveryResponse)
Op->>FS: mv rds.new rds.yaml (rename)
FS-->>W: MOVED_TO 이벤트(원자적)
W->>FS: re-read rds.yaml
W->>E: route_config 갱신(재시작 없음)
Note over W,E: in-place write 면 이 화살표가 누락될 수 있음
```
### 2-3. 제약 ③ — EDS는 클러스터당 CLA 1개 (구독 단위 비대칭, 핵심 함정)
세 제약 중 가장 비자명하고 가장 자주 터지는 것. **이건 파일 전송의 한계가 아니라 EDS 구독 단위의 본질이 파일에서 노출되는 것**이다. EDS 리소스의 단위는 "클러스터 1개의 ClusterLoadAssignment(CLA) 1개"다. CDS/LDS/RDS는 한 파일(=한 DiscoveryResponse)에 여러 리소스를 넣어도 되지만 — CDS는 애초에 "클러스터 *목록*"을 받는 게 정상이니까 — **EDS는 한 구독 응답이 정확히 그 클러스터의 CLA 하나**여야 한다. "특정 클러스터에 대한 답"이라 1개여야 한다는 의미가, "리소스 목록"인 CDS와 충돌하는 게 함정의 본질이다.
```yaml
# eds.yaml — 두 클러스터의 CLA를 한 파일에 욱여넣으면 거부
resources:
- "@type": type.googleapis.com/envoy.config.endpoint.v3.ClusterLoadAssignment
cluster_name: httpbin_a # CLA #1
endpoints: [ ... ]
- "@type": type.googleapis.com/envoy.config.endpoint.v3.ClusterLoadAssignment
cluster_name: httpbin_b # CLA #2 ← 이게 문제
```
이 경우 Envoy 로그에 다음이 뜬다:
```
gRPC config for type.googleapis.com/envoy.config.endpoint.v3.ClusterLoadAssignment rejected:
Unexpected EDS resource length: 2
```
여기서 두 가지를 동시에 이해해야 한다:
- **"왜 gRPC 로그?"** — 로그 머리에 `gRPC config`가 붙지만 전송이 gRPC라는 뜻이 아니다. §2의 멘탈모델 그대로, filesystem 구독도 Envoy 내부의 공통 구독 검증 경로(SubscriptionImpl/grpc-mux)를 타기 때문에 prefix가 `gRPC config`로 찍힌다. "나는 파일을 쓰는데 왜 gRPC 로그가 뜨지"에서 막히지 말 것 — 이게 바로 "transport만 다르고 검증은 공통"의 증거다.
- **"왜 length 2?"** — 파일 EDS 구독은 클러스터별로 별도 응답을 기대하는데, 한 파일에 둘을 넣으면 "이 클러스터에 대한 CLA가 2개 왔다"로 해석돼 length 2로 reject된다.
**회피책 — STRICT_DNS로 EDS 자체를 제거**: 학습/실습이라면 CDS에서 클러스터 타입을 `STRICT_DNS`(또는 `LOGICAL_DNS`)로 두고 엔드포인트를 인라인 `load_assignment`로 적으면 EDS 구독이 필요 없어진다. Envoy가 DNS로 IP를 직접 해석하므로 파일 EDS의 length 함정을 통째로 건너뛴다.
```yaml
# cds.yaml — STRICT_DNS 면 EDS 불필요(엔드포인트 인라인)
resources:
- "@type": type.googleapis.com/envoy.config.cluster.v3.Cluster
name: httpbin_a
type: STRICT_DNS # EDS 대신 DNS 해석
lb_policy: ROUND_ROBIN
load_assignment:
cluster_name: httpbin_a
endpoints:
- lb_endpoints:
- endpoint: { address: { socket_address: { address: httpbin-a, port_value: 8080 } } }
```
부트스트랩 배선은 이 계약을 그대로 반영한다 — `lds_config: { path: ... }`, `cds_config: { path: ... }`로 가리키고, HCM 안에서 RDS를 `rds.config_source.path`로 건다. 즉 파일 하나마다 그게 채우는 구독이 1:1로 묶인다.
```mermaid
flowchart LR
subgraph FS["filesystem xDS"]
boot["bootstrap.yaml"]
lds["lds.yaml
(Listener)"]
rds["rds.yaml
(Route)"]
cds["cds.yaml
(Cluster)"]
eds["eds.yaml
(CLA) — 함정"]
end
boot -->|lds_config path| lds
boot -->|cds_config path| cds
lds -->|rds.config_source path| rds
cds -->|eds_config path| eds
watcher["file watcher
(inotify/kqueue)"] -.move 이벤트.-> lds & rds & cds & eds
```
---
## 3. 예시 — move로 가중치 전환을 관찰하고, 거부를 재현한다
세 제약이 실제로 어떻게 드러나는지 한 사이클로 묶어 본다. 전체 동작 실습(가중치 스위치·Admin API 덤프 전 과정)은 중복하지 않고 [정적/동적 xDS 실습](xds__src-envoy-static-dynamic-xds-lab.html) §3–4로 위임하고, 여기서는 *핵심 동작과 기대 출력*만 확인한다.
**(a) move로 RDS 가중치 전환 — 제약 ②가 살아 있음을 확인.** A→B 트래픽을 100% B로 옮기는 새 RouteConfiguration을 atomic move로 교체한 뒤, Admin API에서 갱신을 확인한다:
```bash
# 주의: admin 의 ?resource= 는 type-URL 필터지 'routes' 같은 약어가 아니다.
# 전체 config_dump 를 받아 jq 로 추출해야 한다(정본 lab과 일치).
curl -s http://127.0.0.1:15000/config_dump \
| jq '.. | .weighted_clusters? // empty'
# 기대: httpbin_a weight 0, httpbin_b weight 100 으로 갱신
```
`mv`로 교체하면 위 출력이 재시작 없이 바뀌고, `vi`로 in-place 저장하면 (플랫폼에 따라) 바뀌지 않는다 — 그 차이가 곧 제약 ②의 증명이다.
**(b) 거부를 일부러 재현 — 제약 ③의 시그니처.** §2-3의 두-CLA `eds.yaml`을 그대로 넣어 보면 Envoy 로그에 `Unexpected EDS resource length: 2`가 뜬다. 이 줄을 보면 "한 파일에 CLA를 여러 개 넣었다"로 즉시 환원하고, 클러스터별 분리 또는 STRICT_DNS 회피로 간다.
**(c) Istio 실환경에서 EDS를 제대로 보는 법(파일이 아니라 ADS 경로).** 파일 EDS의 함정을 우회해 "진짜 EDS가 런타임에 갱신되는 것"을 보려면 ADS로 간다:
```bash
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 후 endpoints 가 증가하면 EDS 동적 반영 확인
```
cluster 이름은 `direction|port|subset|fqdn` 규칙(예: `outbound|8000||httpbin.default.svc.cluster.local`)을 따른다. 이 좌표로 어떤 클러스터의 EDS를 보는지 특정한다. 데이터플레인 동기화 상태(SYNCED/STALE)는 [데이터플레인 동기화 상태](xds__note-data-plane-sync-state.html)에서, 진단 도구 사용은 [Envoy admin API 진단](xds__note-envoy-admin-api-diagnosis.html)에서 다룬다.
---
## 4. 정리 — 무엇으로 우회할지의 의사결정
멘탈모델 한 줄로 되돌아가면: **파일 xDS는 "transport만 파일로 바꾼 ADS"** 이고, 그래서 LDS/RDS는 손으로 체득하기 훌륭하지만 EDS는 구독 단위 비대칭 때문에 함정이다. 그러니 *보고 싶은 것*에 따라 전송을 고른다:
| 목적 | 권장 경로 | 이유 |
|---|---|---|
| LDS/RDS 동적성(라우팅 가중치·timeout·retry) "확인만" | **파일 LDS/RDS + CDS는 STRICT_DNS** | EDS 함정 회피, move 한 번으로 관찰 |
| 엔드포인트가 런타임에 바뀌는 EDS 동작 자체 | **gRPC ADS** (go-control-plane 미니 CP 1~2h) | 파일 EDS length·이벤트 삽질보다 빠름 |
| Istio 이해가 최종 목적 | **Istio on kind + `istioctl proxy-config`** | 진짜 ADS/gRPC xDS를 그대로 관찰 |
```mermaid
flowchart TD
q{무엇을 보고 싶나?}
q -->|"LDS/RDS 동적 라우팅"| a["파일 LDS/RDS
CDS=STRICT_DNS"]
q -->|"EDS 엔드포인트 변화"| b["gRPC ADS
(go-control-plane)"]
q -->|"Istio 동작 자체"| c["kind + istioctl
proxy-config"]
b -.파일 EDS 회피.-> a
c -.프로덕션 경로.-> b
```
## 핵심 정리
- **한 문장 앵커**: 파일 기반 xDS = gRPC ADS와 같은 구독 계약, 전송만 로컬 파일. 세 제약은 이 한 문장의 연역이다.
- 파일 xDS의 계약 3가지: **(1) 루트 `resources:` + `@type`의 DiscoveryResponse 포맷, (2) move(rename) 이벤트만 안정 감지, (3) EDS는 클러스터당 CLA 1개.**
- **`Unexpected EDS resource length: N`** 은 한 파일에 CLA를 여러 개 넣었다는 신호 → 클러스터별로 분리하거나 **STRICT_DNS로 EDS 자체를 제거**. 로그의 `gRPC config` prefix는 전송이 gRPC라서가 아니라 *검증 경로가 공통*이라서다.
- 파일은 **부트스트랩 시점에 존재**해야 하고, 갱신은 **`mv`(atomic rename)** 로. in-place 저장은 watcher가 놓칠 수 있다. ConfigMap이 symlink swap을 쓰는 이유, `watched_directory` 옵션이 생긴 이유가 모두 여기에 있다.
- 학습 경로: 동적 라우팅 확인은 **파일 LDS/RDS(CDS=STRICT_DNS)**, 진짜 EDS/ADS는 **gRPC**(go-control-plane 또는 Istio `istioctl proxy-config`).
## What you might be missing
- **`path` vs `path_config_source`는 다르다.** 단순 `path:`는 레거시 단일 파일 watcher라 위 이벤트 함정에 그대로 노출된다. 최신 Envoy는 `path_config_source: { path, watched_directory }`를 제공해 **디렉터리를 watch**하고 symlink swap(ConfigMap·Secret 마운트 패턴)을 안정적으로 감지한다. 파일 EDS를 굳이 써야 하면 이 쪽을 쓴다.
- **macOS Docker Desktop의 파일 이벤트는 신뢰도가 더 낮다.** 가상 파일시스템(gRPC-FUSE/virtiofs) 경유라 `inotify` 전파가 누락되곤 한다. 같은 manifest가 Linux에선 되고 Mac에선 "왜 안 바뀌지"가 되는 흔한 원인 — Envoy 버그가 아니라 호스트 FS 이벤트 전달 문제다.
- **Istio는 사이드카에 파일 xDS를 쓰지 않는다.** istiod가 **gRPC ADS**로 LDS/RDS/CDS/EDS/SDS를 단일 스트림으로 푸시한다. 파일 기반은 어디까지나 **Envoy 단독 학습/특수 임베디드** 용도다. 따라서 이 note의 함정들은 "Istio 운영 중 만날 버그"가 아니라 "xDS 멘탈모델을 손으로 체득할 때의 함정"으로 분리해 이해해야 한다.
- **`version_info`는 의미적 버전이 아니라 관찰용 라벨**이다. 파일 갱신 자체(=move)가 reload를 트리거하고, `version_info`를 안 바꿔도 새 내용이 반영된다. `config_dump`에서 변경 추적을 쉽게 하려고 올려 두는 것일 뿐, "버전을 올려야 반영된다"는 오해를 하기 쉽다.
- **EDS length 거부는 "잘못된 설정"이 아니라 "구독 단위 오해"** 다. CDS의 다중 리소스 습관을 EDS에 그대로 옮기면 터진다. EDS만 "응답 1개 = 클러스터 1개"라는 비대칭을 기억하면 대부분의 파일 EDS 삽질이 사라진다.