---
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)에서 파일 기반으로 직접 재현해 볼 수 있다.