파일 기반 xDS는 DiscoveryResponse 포맷·move 교체만 감지하고 EDS의 "클러스터당 CLA 1개" 제약 때문에 디버깅이 까다롭다
머릿속에 둘 한 장면: 파일 기반 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 실습에 위임하고, 여기서는 왜 그런 제약이 생기고 어떻게 판단할지의 멘탈모델만 다룬다.
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 동적 설정, 계층 분할(LDS→RDS, CDS→EDS의 의존 사슬)의 전체 그림은 xDS API 계층에 있다. 이 글은 그 위에서 "파일로 바꾸면 어떤 함정이 새로 생기나" 만 다룬다.
2. 핵심 멘탈모델 — "transport만 바꾼 것"에서 세 제약이 따라 나온다
붙잡을 단 하나의 그림:
파일 기반 xDS는 gRPC ADS와 동일한 구독 계약을 쓰되, 메시지를 운반하는 채널만 gRPC 스트림 → 로컬 파일로 바꾼다. Envoy 입장에선 "응답(DiscoveryResponse)을 어디서 받느냐"만 다르고, 받은 응답을 어떻게 검증·적용하느냐는 똑같다.
이 한 줄을 쥐면 뒤의 세 제약이 전부 연역된다. 각 제약은 새로 만든 규칙이 아니라, 동일한 구독 계약의 어느 부분이 파일이라는 전송에서 드러나는가의 차이일 뿐이다:
세 제약을 하나씩 계약에서 끌어내며 보면:
2-1. 제약 ① — 파일은 DiscoveryResponse여야 한다 (포맷)
ADS 스트림에서 오는 메시지는 DiscoveryResponse다. 전송만 바꿨으니 파일의 모양도 정확히 DiscoveryResponse여야 한다. 즉 루트에 resources: 배열이 있고, 각 원소는 "@type"으로 구체 리소스 타입을 명시한다:
# 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 한 가지다.
# 잘못: 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으로만 갱신하라"는 임의의 규칙이 아니라 원자적 교체만이 일관된 단일 이벤트를 보장한다는 파일시스템 물리에서 나온다.
2-3. 제약 ③ — EDS는 클러스터당 CLA 1개 (구독 단위 비대칭, 핵심 함정)
세 제약 중 가장 비자명하고 가장 자주 터지는 것. 이건 파일 전송의 한계가 아니라 EDS 구독 단위의 본질이 파일에서 노출되는 것이다. EDS 리소스의 단위는 "클러스터 1개의 ClusterLoadAssignment(CLA) 1개"다. CDS/LDS/RDS는 한 파일(=한 DiscoveryResponse)에 여러 리소스를 넣어도 되지만 — CDS는 애초에 "클러스터 목록"을 받는 게 정상이니까 — EDS는 한 구독 응답이 정확히 그 클러스터의 CLA 하나여야 한다. "특정 클러스터에 대한 답"이라 1개여야 한다는 의미가, "리소스 목록"인 CDS와 충돌하는 게 함정의 본질이다.
# 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 함정을 통째로 건너뛴다.
# 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로 묶인다.
3. 예시 — move로 가중치 전환을 관찰하고, 거부를 재현한다
세 제약이 실제로 어떻게 드러나는지 한 사이클로 묶어 본다. 전체 동작 실습(가중치 스위치·Admin API 덤프 전 과정)은 중복하지 않고 정적/동적 xDS 실습 §3–4로 위임하고, 여기서는 핵심 동작과 기대 출력만 확인한다.
(a) move로 RDS 가중치 전환 — 제약 ②가 살아 있음을 확인. A→B 트래픽을 100% B로 옮기는 새 RouteConfiguration을 atomic move로 교체한 뒤, Admin API에서 갱신을 확인한다:
# 주의: 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로 간다:
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)는 데이터플레인 동기화 상태에서, 진단 도구 사용은 Envoy admin API 진단에서 다룬다.
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를 그대로 관찰 |
핵심 정리
- 한 문장 앵커: 파일 기반 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 configprefix는 전송이 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
pathvspath_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 삽질이 사라진다.