🏠 목록 Envoy 정적/동적(xDS) 설정 실습 — Istio in Action Ch.3.2 (출처: 책 + 실습기록 + 공식문서) 📄 MD 원본 🌓 테마
istioenvoyxds

Envoy 정적/동적(xDS) 설정 실습 — Istio in Action Ch.3.2 (출처: 책 + 실습기록 + 공식문서)

NOTE

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일 뿐이다.

listener (LDS) port 15001 route_config RDS · local_route weighted_clusters by name cluster (CDS) httpbin_a / _b endpoint EDS IP:port route_config_name name cluster_name = 동적 공급(xDS): LDS · RDS · CDS · EDS — route가 가리키는 cluster가 먼저 도착해야 dangling 없음
그림 1. 이름 참조 사슬. listener는 route를 route_config_name으로, route는 cluster를 name으로, cluster는 endpoint를 cluster_name으로 가리킨다. 네 단계(강조)가 모두 동적(xDS) 공급이라, 가리키는 대상이 먼저 도착하지 않으면 참조가 dangling이 되어 순서 제약이 생긴다.

사슬이 "이름 참조"라는 점이 모든 설계의 뿌리다. 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의 "순서 보장"은 단순 메시지 순서가 아니라, 참조 대상이 실제로 사용 가능해질 때까지 대기하는 메커니즘이다.

3. 실습 A — 정적 설정으로 Envoy 기동

먼저 사슬을 통째로 인라인해 본다. /로 들어온 요청을 httpbin 클러스터로 보내는 최소 구성으로, 사슬의 네 노드(listener/route/cluster/endpoint)가 한 파일에 어떻게 한 줄로 이어지는지를 눈으로 본다.

사전 준비 (Mac + Docker)

ℹ 원본은 httpbin 이미지로 `citizenstig/httpbin`과 `kennethreitz/httpbin`을 혼용했는데, 둘 다 **x86_64 전용 + 유지보수 중단** 이미지라 Apple Silicon(M-series)에서 에뮬레이션으로 느리거나 실패한다.

Apple Silicon에서는 멀티아키 + 활발히 유지되는 mccutchen/go-httpbin을 쓴다(원본 맨 위 실습 줄에서 이미 이 이미지를 선택했으므로 그 선택이 옳다). 단 이 이미지의 컨테이너 내부 포트는 8080이다.

# 컨테이너 이름 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를 넣는 것이 공식 권고다.

부트스트랩 — 사슬 한 파일

# 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하지만, 둘을 묶는 끈은 똑같이 이 문자열이다.

ℹ 원본 정적 예시의 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 네트워크 내부 통신에는 무의미).

기동 및 검증

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가 곧 프로덕션 진단의 원천이다:

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가 기대대로 보이고, /clustershttpbin_svc 엔드포인트가 healthy, /stats에서 요청 카운터가 증가해야 한다. 정적이라 모든 리소스가 static_* 섹션에 박혀 있는 것을 확인하라(동적이면 dynamic_*).

4. 실습 B — 파일 기반 xDS (LDS/RDS만 동적, CDS는 STRICT_DNS)

이제 같은 사슬을 조각으로 분리해 공급한다. 핵심 깨달음은 공급 방식만 바뀌고 사슬은 그대로라는 것. 파일 기반 xDS의 "개념 확인"은 LDS/RDS만으로 충분하다 — 라우팅 가중치/리트라이/타임아웃 변경을 재시작 없이 즉시 관찰할 수 있고, 까다로운 EDS 포맷 문제(§5)를 피한다. cluster는 STRICT_DNS로 두어 EDS를 우회한다.

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엔 "어디서 사슬을 받아올지"만 적는다:

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를 파일에서 구독
⚠ 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를 또 파일로 구독 → 사슬 한 칸 더 분리):

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 불필요):

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 노드만 따로):

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

기동 + 가중치 스위치 (동적의 핵심 시연)

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만 바뀐 것 — 이게 동적 공급의 본질이다.

ℹ 파일 기반 동적은 **move(rename) 교체가 필수**다. 에디터로 in-place 저장하면 inotify/kqueue가 안정적으로 감지하지 못한다(쓰기 중간 상태가 노출될 수 있어 Envoy는 atomic rename만 신뢰). 또한 파일 경로는 **부트스트랩 시점에 이미 존재**해야 한다.

5. 왜 파일 기반 EDS만 유독 꼬이나 (핵심 함정)

여기서 사슬의 마지막 노드(endpoint)를 파일로 공급하려 하면 갑자기 깨진다. 원인은 단순한 버그가 아니라 파일 구독의 포맷/이벤트 제약EDS의 "클러스터당 리소스 1개" 제약이 겹치기 때문이다:

결론(학습 경로 권고): 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를 호출해 가공한 래퍼다.

# 경로 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로 확인

검증 포인트: clustersoutbound|8000||httpbin.default.svc.cluster.local처럼 direction|port|subset|fqdn 규칙의 cluster 이름이 보인다. kubectl scale로 replica를 2개로 늘리면 endpoints 출력의 IP 개수가 재시작 없이 늘어난다 — 이게 EDS가 ADS로 live push되는 증거이자, 실습 A/B에서 본 사슬이 프로덕션에서 그대로 돌아가는 모습이다.

핵심 정리

What you might be missing

See also