--- type: note tags: [istio, envoy, xds, admin-api, diagnosis] created: 2026-06-07 --- # Envoy Admin API(config_dump/clusters/stats)는 데이터 플레인이 실제로 적용한 설정과 런타임 상태를 보는 1차 진단 도구다 > [!abstract] > istioctl·Kiali·access log가 모두 "추상화된 뷰"라면, Envoy **Admin API**는 데이터 플레인의 **그라운드 트루스(ground truth)** 다. istiod가 "의도한 설정"과 Envoy가 "실제로 적용한 설정"이 갈라질 때(STALE/NACK), 그 갈라짐은 결국 이 Admin API에서만 확정된다. 이 노트는 **"Admin API가 무엇을 보여주고 왜 1차 도구인가"** 의 멘탈모델을 세우고, `config_dump`·`/clusters`·`/stats` 세 면을 "설정 → 멤버 health → 트래픽 결과"의 인과로 묶어 읽는 법을 다룬다. > ⚠️ **버전 skew 주의**: 이 환경은 istiod 1.30.0 ↔ 로컬 istioctl 1.27.0이다. `proxy-status`의 VERSION 컬럼이나 `proxy-config` 일부 필드가 어긋나 보일 수 있으나 이는 client 표시 한계이며 메시 동작 이상이 아니다. 정밀 진단은 버전을 맞춘 1.30 istioctl 사용 권장. --- ## 01. 배경 — "설정을 줬다"와 "설정대로 돈다"는 다른 사건이다 Istio는 선언형이다. CRD(`VirtualService`, `DestinationRule`…)를 apply하면 istiod가 그걸 Envoy 설정으로 번역해 xDS로 각 프록시에 push한다. 그래서 디버깅할 때 우리는 본능적으로 **CRD(의도)** 를 들여다본다. 그런데 트래픽이 실패할 때 진짜 물어야 할 질문은 다르다 — **"내가 의도한 그 설정이 지금 이 Envoy 안에 실제로 들어가 있고, 그 endpoint가 살아있고, 요청이 어디서 깨지나?"** 이 질문이 중요한 이유는 xDS가 **eventually consistent**이기 때문이다. istiod와 Envoy는 비동기 스트림으로 연결돼 있고, push → ACK 사이엔 시간차가 있다. push가 실패하거나(연결 끊김), Envoy가 설정을 거부하거나(NACK), endpoint warming이 안 풀리면 — **CRD는 완벽한데 Envoy 안엔 옛 설정이 박혀 있는** 상태가 생긴다. CRD만 봐선 이걸 절대 못 잡는다. CRD는 "내 의도"일 뿐 "데이터 플레인의 현실"이 아니기 때문이다. 그래서 진단엔 세 개의 분리된 검증 축이 필요하다. | 검증 축 | 무엇을 답하나 | 도구 | |---|---|---| | **의도 검증** | 내 CRD가 문법·정합성에 맞나 | `istioctl analyze` | | **전달 검증** | istiod가 push했고 Envoy가 ACK했나 | `istioctl proxy-status` | | **적용 검증** | 그래서 Envoy 안에 지금 뭐가 들어 있나 | **Envoy Admin API** ★ | 세 축이 모두 일치할 때만 "설정대로 동작 중"이라 단정할 수 있다. 그리고 마지막 **적용 검증의 최종심급**이 Admin API다 — 다른 무엇도 Envoy 내부를 직접 보여주지 못한다. > **선행 개념**: xDS 5계층(LDS/RDS/CDS/EDS/SDS), cluster 이름 규칙 `direction|port|subset|fqdn`, sync 상태값(SYNCED/STALE/NOT SENT). 5계층 운영 매핑은 [xDS 5계층과 istioctl 진단](xds__src-xds-layers-and-diagnosis.html), sync 상태값은 [데이터 플레인 sync 상태](xds__note-data-plane-sync-state.html)에 위임한다. --- ## 02. 멘탈모델 ANCHOR — Admin API는 추상화 사다리의 맨 밑이다 > [!key] 한 장의 그림 > Istio 진단 도구는 추상화 사다리를 이룬다. **위로 갈수록 사람이 읽기 쉽고, 아래로 갈수록 프록시가 실제로 하는 일에 가깝다.** 위의 모든 도구는 결국 맨 밑 Admin API(또는 동일 소스인 xDS) 위에 얹힌 가공물이다. 가공 과정에서 정보가 빠지거나 도구 버전이 어긋날 때, **"진짜 Envoy 안에 뭐가 들어 있나"를 확정하는 최종심급이 맨 밑 칸**이다. ```text Kiali / Grafana ← 메트릭 집계 뷰 (mesh 전체 그래프) istioctl analyze ← istiod가 "의도"를 검증 (CRD 정합성) istioctl proxy-status ← 전달(delivery) 검증 (ACK/NACK 받았나) istioctl proxy-config ← Envoy 설정을 사람이 보기 좋게 가공 Envoy Admin API ← Envoy 안의 raw 설정·런타임 상태 (ground truth) ★ ``` 이 사다리가 진단 전략의 전부다. `istioctl proxy-config`는 내부적으로 Envoy의 `config_dump`를 호출해 보기 좋게 **포맷팅·필터링**한 것이고, `proxy-status`는 istiod가 가진 push 기록과 Envoy의 ACK를 **대조**한 것이다. 즉 상위 도구는 편의를 위해 raw를 깎아낸다 — 그래서 빠른 답엔 좋지만, 깎인 정보(warming 상태, outlier 통계, filter chain 세부)가 바로 원인일 땐 무력하다. 그때 맨 밑으로 내려간다. 핵심 멘탈모델은 이렇게 닫힌다. proxy-status가 **"보냈는데 ACK 못 받음(STALE)"** 을 알려주면, config_dump가 **"그래서 지금 Envoy 안엔 옛날 설정이 있다"** 를 증명한다. 전자는 istiod의 관점, 후자는 Envoy의 관점 — eventually consistent의 두 끝을 각각 확정하는 것이다. --- ## 03. Admin endpoint는 어디에 — localhost:15000과 포트 분리의 설계 Istio istio-proxy(sidecar **및** ingress/egress gateway)에서 Envoy Admin API는 **`127.0.0.1:15000`** 에 바인딩된다. `127.0.0.1`(localhost) 바인딩이 설계의 핵심이다 — admin 인터페이스는 **인증이 없고** `/quitquitquit`(Envoy 종료)·`/drain_listeners`·`/logging` 같은 **상태를 바꾸는 위험 엔드포인트**를 포함하므로, Pod 네트워크 밖으로 절대 새면 안 된다. localhost 바인딩이 1차 방어선이고, 그래서 같은 Pod의 app 컨테이너(또는 `kubectl exec`로 istio-proxy 컨테이너 내부)에서만 닿는다. ```text +--------------------------- Pod (app + istio-proxy) ---------------------------+ | | | app container istio-proxy (Envoy) container | | | curl localhost:15000 ------> 127.0.0.1:15000 Admin API (no auth) | | 15001 outbound capture listener | | 15006 inbound capture listener | | 15020 merged metrics / health (Pilot agent)| | 15021 status (readiness) | | 15090 envoy prometheus (/stats/prometheus)| +------------------------------------------------------------------------------+ ``` 왜 Istio가 Envoy의 한 포트(15000)에서 다 하지 않고 원포트들을 쪼개 뒀나 — **보안 표면을 면(面)별로 분리**하려는 것이다. 15000(admin)은 위험하니 localhost-only로 가두고, 외부 Prometheus가 긁어야 하는 **메트릭은 15090(`/stats/prometheus`)** 으로, **헬스/probe는 15020/15021** 로 따로 뺐다. 즉 15090은 15000의 `/stats` 중 안전한 일부만 외부 노출하는 전용 창구다 — 같은 데이터, 다른 목적, 다른 노출 정책. 접근 경로는 세 가지다. ```bash # (1) sidecar 컨테이너 내부에서 직접 — 가장 원본 kubectl exec deploy/sleep -c istio-proxy -n default -- \ curl -s localhost:15000/config_dump | jq '.configs | length' # 기대: 7~8 (BootstrapConfigDump, ClustersConfigDump, ListenersConfigDump, # RoutesConfigDump, SecretsConfigDump, ... 의 개수) # (2) istioctl이 감싼 형태 — admin API를 호출해 가공 istioctl proxy-config clusters deploy/sleep -n default -o short # (3) 브라우저로 admin UI 전체를 보고 싶을 때 (localhost:15000 포워드) istioctl dashboard envoy deploy/sleep.default # → http://localhost:15000 로 config_dump/clusters/stats 링크 제공 ``` > [!warning] 함정 — "admin = 15000"은 istio-proxy 관례일 뿐 > **비-Istio standalone Envoy**(예: [정적/동적 xDS 실습](xds__src-envoy-static-dynamic-xds-lab.html))는 부트스트랩 YAML이 admin 포트를 직접 정하므로 흔히 **9901**(실습 설정에 따라 15000일 수도)이다. Istio sidecar와 gateway가 둘 다 15000인 건 **istio-proxy가 같은 부트스트랩 템플릿을 쓰기 때문**이지 Envoy의 보편 규칙이 아니다. standalone Envoy에선 그 부트스트랩의 `admin.address.socket_address.port_value`를 봐야 한다. --- ## 04. 세 면을 묶어 읽는 인과 — config_dump → /clusters → /stats 여기가 이 노트의 심장이다. Admin API의 세 엔드포인트는 따로 보는 게 아니라 **"설정 → 멤버 health → 트래픽 결과"** 의 인과 순서로 묶어 읽는다. 같은 cluster를 세 각도에서 보는 것이다 — config_dump는 **정의**, /clusters는 그 정의의 **멤버가 살아있나**, /stats는 그 멤버로 보낸 **요청이 어떻게 됐나**. ```mermaid flowchart TD START["증상: 503 / 요청 실패"] PS["istioctl proxy-status
(전달 검증: STALE / 누락?)"] CD["config_dump
설정이 Envoy에 있나
active vs warming"] CL["/clusters
endpoint HEALTHY?
outlier eject?"] ST["/stats
rq_5xx / cx_connect_fail
connected_state"] ROOT["원인 확정"] START --> PS PS -->|SYNCED인데도 실패| CD PS -->|STALE / 누락| ST CD -->|설정 있음| CL CD -->|warming 정체| ST CL -->|endpoint 0 / eject| ST CL -->|HEALTHY인데 실패| ST ST --> ROOT ``` ### config_dump — 부트스트랩 + 동적 설정의 현재 스냅샷 `/config_dump`는 Envoy가 **지금 이 순간 들고 있는 전체 설정**을 JSON으로 토해낸다. xDS 5계층(LDS/RDS/CDS/EDS/SDS)이 각각 별도 섹션으로 들어가고, 더 중요한 건 각 동적 리소스를 **상태(state)별로 구분**해 보여준다는 점이다. ```text config_dump.configs[]: BootstrapConfigDump ← Envoy가 시작할 때 받은 고정 설정 (어느 istiod, node metadata) ClustersConfigDump static_clusters ← 부트스트랩에 박힌 cluster (예: xds-grpc=istiod, prometheus_stats) dynamic_active_clusters ← CDS로 받아 "현재 활성"인 cluster ★ 진짜 적용된 것 dynamic_warming_clusters ← 받았지만 아직 warming(endpoint/DNS 대기) — 트래픽 못 받음 ListenersConfigDump ← static / dynamic_active / dynamic_warming 동일 구조 RoutesConfigDump ← RDS route (dynamic_route_configs) SecretsConfigDump ← SDS cert/key/CA (값은 마스킹, 메타데이터만) ``` 진단에서 결정적 구분은 **`active` vs `warming`** 이다. 왜 Envoy가 cluster를 warming 단계에 묶어 두냐면 — endpoint나 DNS가 준비되기 전에 트래픽을 흘리면 즉시 실패하기 때문이다. CDS는 도착했지만 EDS endpoint나 DNS 해석을 못 받은 cluster는 `dynamic_warming_clusters`에 머물고, 그 cluster로 가는 트래픽은 실패한다. `proxy-config cluster`의 요약 출력은 cluster가 "있다"고만 나오므로, active인지 warming에 갇혔는지는 raw config_dump로만 갈린다. ```bash # 특정 리소스만 떠서 노이즈 줄이기 (Envoy가 지원하는 resource 필터) kubectl exec deploy/sleep -c istio-proxy -- \ curl -s 'localhost:15000/config_dump?resource=dynamic_active_clusters' \ | jq '.configs[].cluster.name' | head # 기대 예: "outbound|8000||httpbin.default.svc.cluster.local" # warming에 갇힌 cluster가 있나? (있으면 EDS/DNS 문제 의심) kubectl exec deploy/sleep -c istio-proxy -- \ curl -s localhost:15000/config_dump \ | jq '.. | .dynamic_warming_clusters? // empty' # 기대: 빈 결과(정상). 무언가 나오면 warming 정체 → /stats로 교차확인 ``` bootstrap 섹션은 "이 Envoy가 어느 istiod에 붙어 시작했는가, node ID/metadata가 무엇인가"를 답한다 — injection 문제·잘못된 istiod 연결을 추적할 때 `istioctl proxy-config bootstrap`으로 같은 데이터를 본다. ### /clusters — 그 cluster의 endpoint가 지금 살아있나 `/config_dump`가 "설정이 무엇인가"라면, `/clusters`는 **"그 cluster의 엔드포인트가 지금 살아있나"** 라는 런타임 상태를 준다. 같은 cluster라도 config_dump는 정의를, `/clusters`는 멤버 IP별 health/통계를 보여준다. ```bash kubectl exec deploy/sleep -c istio-proxy -- \ curl -s 'localhost:15000/clusters?format=json' \ | jq '.cluster_statuses[] | select(.name=="outbound|8000||httpbin.default.svc.cluster.local") | {name, host: .host_statuses[].address.socket_address, health: .host_statuses[].health_status}' ``` 기대 출력(정상 — 엔드포인트가 healthy): ```json { "name": "outbound|8000||httpbin.default.svc.cluster.local", "host": { "address": "10.244.1.12", "port_value": 8000 }, "health": { "eds_health_status": "HEALTHY" } } ``` 여기서 봐야 할 신호: - `eds_health_status: HEALTHY` → EDS가 준 endpoint가 정상. - `failed_outlier_check: true` → **outlier detection으로 eject됨**. cluster·endpoint는 존재하는데 연속 5xx로 풀에서 빠졌다는 뜻. 텍스트 모드에 같이 나오는 `cx_active`(active connection)·`rq_error`·`outlier::*` 카운터로 정도를 본다. - 엔드포인트가 0개 → CDS는 왔으나 EDS가 비어 `503 UH`(NoHealthyUpstream)의 직접 원인. K8s readiness/selector를 의심. `/listeners`는 더 단순하다 — 현재 바인딩된 listener 주소 목록(`0.0.0.0:15001` 등)을 주어, 기대한 포트의 listener가 실제로 떠 있는지 확인한다. ```bash kubectl exec deploy/sleep -c istio-proxy -- curl -s localhost:15000/listeners | head # 기대: virtualOutbound 0.0.0.0:15001, virtualInbound 0.0.0.0:15006, port별 listener ... ``` 이 단계가 라우팅 체인의 cluster→endpoint 구간이다. listener→route→cluster→endpoint 전체 체인을 istioctl로 짚어 내려가는 절차는 [Envoy 라우팅 체인 디버깅](xds__note-envoy-routing-chain-debugging.html)에서 다룬다. ### /stats — 요청·실패를 카운터로 관측 `/stats`는 Envoy의 모든 카운터·게이지·히스토그램을 평문으로 쏟아낸다(수백~수천 줄). 진단은 **이름 prefix로 grep해서 관심 축만 본다**. Istio cluster 이름 규칙(`direction|port|subset|fqdn`)이 그대로 stat 키에 박히므로, 특정 upstream의 요청·에러를 정확히 좁힐 수 있다 — 이게 config_dump/clusters와 stats를 같은 cluster로 꿰는 접착제다. ```bash # 특정 upstream cluster의 요청/에러 카운터만 kubectl exec deploy/sleep -c istio-proxy -- \ curl -s 'localhost:15000/stats?filter=outbound.8000.*httpbin' \ | grep -E 'upstream_rq_(total|2xx|4xx|5xx)|upstream_cx_connect_fail|pending_overflow' ``` 기대 출력(예): ```text cluster.outbound|8000||httpbin.default.svc.cluster.local.upstream_rq_total: 42 cluster.outbound|8000||httpbin.default.svc.cluster.local.upstream_rq_2xx: 40 cluster.outbound|8000||httpbin.default.svc.cluster.local.upstream_rq_5xx: 2 cluster.outbound|8000||httpbin.default.svc.cluster.local.upstream_cx_connect_fail: 0 ``` 진단에 자주 쓰는 stat 축: | stat prefix | 무엇을 말하나 | 비정상 신호 | |---|---|---| | `cluster..upstream_rq_5xx` | 그 cluster로 보낸 요청 중 5xx | 증가 = 백엔드 장애 또는 mTLS 실패 | | `cluster..upstream_cx_connect_fail` | TCP 연결 자체 실패 | endpoint 죽음/네트워크/방화벽 | | `cluster..upstream_rq_pending_overflow` | connection pool circuit breaker 발동 | [Circuit breaking](gw__note-circuit-breaking-mechanisms.html) 한도 초과 | | `cluster..ssl.connection_error` | upstream TLS/mTLS handshake 실패 | SDS cert 문제, mode 불일치 | | `listener.0.0.0.0_15006.*` | inbound listener 처리량 | downstream 연결 거부 흐름 | | `server.*` / `control_plane.*` | Envoy 자기 상태·xDS 연결 | `control_plane.connected_state=0` = istiod 미연결 | ```bash # istiod 연결 살아있나 (proxy-status에서 누락된 proxy를 Envoy 쪽에서 교차확인) kubectl exec deploy/sleep -c istio-proxy -- \ curl -s localhost:15000/stats | grep -E 'control_plane.(connected_state|rq_total)' # 기대: control_plane.connected_state: 1 (0이면 sidecar가 istiod에 안 붙음) ``` `control_plane.connected_state`가 0이면, 이는 [proxy-status](xds__note-data-plane-sync-state.html)에서 그 proxy가 목록에서 누락되는 현상의 **Envoy 쪽 근거**다 — istiod 연결이 끊긴 동안 push를 못 받으니 config_dump도 옛날 설정으로 고정된다. 운영용 메트릭은 보통 15000이 아니라 **15090의 `/stats/prometheus`** 를 Prometheus가 긁는다 — 진단은 15000에서 한 Envoy를 깊게, 모니터링은 15090에서 전체를 집계. --- ## 05. 예시 — 503 한 건을 세 면으로 끝까지 좁히기 세 엔드포인트가 어떻게 인과로 이어지는지, 전형적 분기를 따라가 본다. - **`proxy-status`는 `SYNCED`인데 트래픽 실패** → 설정 전달은 정상이니 적용·런타임으로 내려간다. `config_dump?resource=dynamic_active_clusters`에서 cluster가 active임을 확인 → `/clusters`에서 `eds_health_status`·outlier 확인 → `/stats`에서 `upstream_rq_5xx`(백엔드/mTLS)냐 `ssl.connection_error`(handshake)냐로 원인 확정. - **`proxy-status`에서 proxy 누락** → `/stats`의 `control_plane.connected_state=0`로 "Envoy가 istiod에 못 붙음"을 확정. 이 경우 config_dump는 **stale일 수밖에 없다** — 끊긴 동안 push를 못 받으니까. 원인은 메시 설정이 아니라 연결/네트워크. - **config_dump에서 cluster가 `warming`에 갇힘** → `/clusters`엔 endpoint 0개로 보이고, `/stats`의 EDS·DNS 관련 카운터로 왜 warming이 안 풀리는지(EDS 빈 응답? DNS 미해석?) 좁힌다. 증상은 `503 UH`. 가장 흔한 황금 경로를 한 줄로 묶으면: ```text proxy-status SYNCED → config_dump active(=warming 아님) → /clusters HEALTHY → /stats rq_5xx↑ ⇒ 설정·endpoint는 멀쩡, 문제는 백엔드 응답 또는 mTLS handshake. 5xx vs ssl.connection_error로 분기. ``` 응답 flag(`NR`/`NC`/`UH`/`UF`)로 어느 면부터 볼지 시작점을 좁히는 표는 [xDS 5계층과 istioctl 진단](xds__src-xds-layers-and-diagnosis.html)에 정리돼 있다. --- ## 핵심 정리 ```text 멘탈모델 = 진단 도구는 추상화 사다리. 상위(Kiali/proxy-config/proxy-status)는 전부 맨 밑 Admin API의 가공물. Admin API = 데이터 플레인 ground truth, 적용 검증의 최종심급. 세 검증 축 = 의도(analyze) / 전달(proxy-status) / 적용(Admin API). 셋이 일치할 때만 "설정대로 동작". 노출 = istio-proxy localhost:15000 (auth 없음 → localhost-only). 메트릭 15090, probe 15020/15021로 분리. config_dump = 현재 설정 스냅샷. dynamic_active vs dynamic_warming 구분이 핵심(warming=트래픽 못 받음). /clusters = endpoint health (HEALTHY / outlier eject / 0개=503 UH). config_dump가 정의면 이건 런타임. /stats = 카운터로 결과 관측. upstream_rq_5xx / cx_connect_fail / control_plane.connected_state. 읽는 순서 = proxy-status(전달) → config_dump(설정) → /clusters(멤버 health) → /stats(트래픽 결과). ``` ## What you might be missing - **`istioctl proxy-config`는 config_dump의 가공·필터링된 뷰다.** 요약 출력에서 안 보이는 필드(warming 상태, filter chain 세부, outlier 통계)는 raw `config_dump`/`?format=json`을 직접 떠야 보인다. 도구가 "cluster 있음"이라 해도 raw에서 그게 `warming`이면 트래픽은 안 간다. - **admin은 인증이 없다.** 15000을 Service로 노출하거나 NetworkPolicy로 막지 않으면, 같은 네임스페이스의 다른 Pod가 `/quitquitquit`(Envoy 종료)·`/drain_listeners`·`/logging`을 호출할 수 있다. localhost 바인딩이 1차 방어선이고, 외부 메트릭은 반드시 15090을 쓴다. - **stats는 Envoy 시작 이후 누적 카운터다.** 절댓값이 아니라 **시간차(delta)·rate**로 봐야 의미가 있다. `upstream_rq_5xx: 200`이 어제부터 쌓인 것일 수도 있으므로, 두 번 떠서 차이를 보거나 Prometheus rate로 본다. 진단 중 `curl -X POST localhost:15000/reset_counters`로 0에서 다시 재면 깔끔하다(단 상태 변경 호출이므로 운영 중 주의). - **config_dump는 "지금 적용된 것"이지 "istiod가 의도한 것"이 아니다.** push 실패/NACK면 config_dump가 옛 설정으로 고정된다. 그래서 의도 검증(`istioctl analyze`)·전달 검증(`proxy-status`)·적용 검증(Admin API) 셋을 분리해서 봐야 한다 — 셋이 일치할 때만 "설정대로 동작 중"이라 단정할 수 있다. - **비-Istio standalone Envoy는 admin 포트가 15000이 아닐 수 있다.** Istio ingress/egress **gateway는 istio-proxy라 sidecar와 동일하게 15000**이지만, istio-proxy가 아닌 standalone Envoy는 부트스트랩 YAML의 admin 포트(흔히 9901)를 봐야 한다 — sidecar 관례를 standalone에 그대로 들고 가면 헛다리. EDS·warming 함정은 [정적/동적 xDS 실습](xds__src-envoy-static-dynamic-xds-lab.html)에서 파일 기반으로 직접 재현해 볼 수 있다.