---
type: src
tags: [istio, envoy, cluster, destinationrule, subset, load-balancing, connection-pool]
created: 2026-06-07
---
# Envoy Cluster 해부 — 포트 4-layer, subset, DestinationRule 매핑 (출처: "Istio 운영 노하우" 정리 + Istio 1.30 공식 문서)
> [!abstract] 이 문서가 다루는 것
> Envoy **cluster**는 "이 트래픽을 어느 upstream으로, 어떻게 보낼까"를 답하는 단 하나의 객체다 — discovery·LB·connection pool·circuit breaker·outlier detection·TLS를 전부 품는다. 그리고 사람이 쓰는 **DestinationRule**이 istiod를 거쳐 이 cluster의 칸칸으로 **컴파일**된다. 이 문서는 ① cluster가 무엇을 품는가, ② 그 cluster를 따라가면 같은 포트 숫자가 왜 4개의 다른 layer로 갈라지는가, ③ DestinationRule의 각 필드가 cluster의 어디로 떨어지는가, ④ 그것을 `istioctl proxy-config`로 추적하는 법을 다룬다.
**대상환경**: Istio 1.30, sidecar mode (Ambient는 datapath가 다름). **대상독자**: cluster/endpoint/포트가 헷갈려 디버깅이 막히는 SRE. **선행개념**: Kubernetes Service `port`/`targetPort`, sidecar가 트래픽을 가로챈다는 사실. **범위**: outbound cluster 중심. route가 cluster를 참조하는 법·xDS 5계층(LDS/RDS/CDS/EDS/SDS)·response flag 상세는 [Envoy 응답 플래그](xds__src-envoy-response-flags.html)·[xDS 5계층과 진단](xds__src-xds-layers-and-diagnosis.html)에서.
## 01. 배경 — sidecar가 끼어드는 순간 포트가 4개로 갈라진다
sidecar가 없던 세계는 단순하다. app이 `reviews:9080`으로 connect하면 kube-proxy가 Service VIP를 Pod IP로 NAT해서 그대로 보낸다. 포트는 사실상 하나의 의미만 가진다.
sidecar mode는 그 사이에 Envoy를 강제로 끼워 넣는다. iptables가 app의 **outbound TCP를 통째로 가로채** Envoy로 돌리고, Envoy가 "원래 어디로 가려던 거였지?"를 복원해 라우팅·정책·mTLS를 적용한 뒤 진짜 backend로 보낸다. 이 가로채기-복원-재전송 과정에서 **하나였던 포트 숫자가 의미가 다른 4개의 layer로 분해된다.** Istio 디버깅 초심자가 "cluster는 80인데 endpoint는 왜 8080이지?"에서 막히는 이유가 바로 이것이다 — 그들은 4개를 하나로 보고 있다.
그 라우팅의 끝, "이제 어느 backend로 보낼까"를 결정하는 객체가 **cluster**다. cluster는 Envoy의 보편 추상이다: NGINX의 `upstream` 블록이 답하는 질문("어느 서버 그룹으로")에, Istio가 운영에서 필요로 하는 모든 질문(어떻게 분산할까, 몇 개까지 동시에 보낼까, 죽은 endpoint를 어떻게 뺄까, TLS는 어떻게 걸까)을 한 객체로 합쳐 놓은 것이다. 그래서 **"포트가 왜 4개냐"와 "DestinationRule이 어디로 가냐"는 결국 같은 질문** — 둘 다 "cluster라는 객체를 정확히 읽을 줄 아느냐"로 수렴한다. 이 문서는 그 cluster를 해부한다.
## 02. 핵심 멘탈모델 — cluster 하나가 모든 정책을 흡수한다
**머릿속에 하나만 담으면 이것입니다**: Envoy cluster는 "upstream backend pool" 하나에 discovery·LB·pool·CB·outlier·TLS를 다 욱여넣은 객체이고, 사람이 쓰는 DestinationRule이 istiod를 거쳐 그 cluster의 칸칸으로 **컴파일**된다 — 그리고 그 cluster를 따라가다 보면 같은 포트 숫자가 4개의 다른 layer(capture 15001 → listener → Service port → endpoint targetPort)로 나타난다. 나머지는 전부 이 그림의 세부다.
개념적으로 cluster는 NGINX `upstream`과 출발점이 같다. "어느 backend 서버들로 보낼 것인가"라는 질문은 동일하다.
```nginx
# NGINX upstream — backend 목록이 거의 전부
upstream reviews {
server 10.0.1.10:9080;
server 10.0.2.20:9080;
}
```
차이는 cluster가 그 한 객체에 **훨씬 많은 것을 흡수**한다는 점이다. Envoy Cluster proto에는 `name`, `type`, `eds_cluster_config`, `connect_timeout`, `lb_policy`, `load_assignment`, `circuit_breakers`, HTTP protocol options, DNS 설정, `outlier_detection`, `transport_socket`가 모두 들어간다. (비유의 한계: NGINX도 upstream에 `least_conn`·`keepalive`·health check를 붙일 수 있지만, cluster는 이 모든 운영 축을 **xDS로 동적 교체 가능한 한 resource**로 묶었다는 점이 본질적으로 다르다.)
```text
NGINX upstream
≈ backend server group
Envoy cluster
= backend server group
+ dynamic discovery (EDS/DNS/STATIC)
+ LB policy
+ connection pool
+ circuit breaker
+ outlier detection
+ TLS/mTLS transport socket
+ HTTP/1.1·HTTP/2 protocol option
+ observability/stat metadata
```
Envoy cluster manager가 이 cluster들을 모두 관리하고, filter stack은 cluster로부터 L3/L4 connection 또는 HTTP connection pool handle을 얻는다. cluster는 static으로 박을 수도, CDS API로 동적 fetch할 수도 있는데 **Istio에서는 대부분 istiod가 CDS로 자동 생성**한다. 그래서 용어가 3겹으로 갈린다 — 이걸 섞으면 대화가 꼬인다.
```text
DestinationRule = Istio 고수준 정책 CRD (사람이 쓰는 것)
Envoy cluster = istiod가 DestinationRule/Service/ServiceEntry를 보고 생성한 Envoy xDS Cluster resource
istioctl proxy-config cluster = 특정 proxy가 실제로 받은 Envoy cluster 설정을 보는 명령
```
## 03. 포트 4-layer — 같은 숫자라도 layer가 다르다
앵커의 후반부, "포트가 4개로 갈라진다"를 펼친다. 같은 `9080`(또는 `80`)이 서로 다른 layer에서 등장하는데 **숫자는 같아도 의미가 전혀 다르다.** 4개 layer는 capture / virtual listener / cluster(=Service port) / endpoint(=targetPort)다.
```text
15001 (capture port)
= iptables가 outbound TCP를 Envoy로 빨아들이는 입구 포트
= app이 어디로 가든 일단 여기로 redirect됨 (Istio 내부 convention, 표준 의미 없음)
listener 9080 (virtual listener)
= Envoy 내부에서 "원래 목적지 포트가 9080이었네" 하고 고른 가상 listener
= downstream 요청을 분류하는 수신 쪽 포트
cluster 의 9080 (service port)
= outbound|9080||svc 의 가운데 숫자
= Envoy가 upstream으로 보낼 대상 Kubernetes Service port
endpoint :8080 (targetPort)
= 실제 Pod의 targetPort/containerPort
= EDS가 내려주는 실제 backend IP의 포트
```
```mermaid
flowchart TD
A["iptables capture :15001
(outbound 입구)"] --> B["virtual listener :9080
(원래 목적지 포트)"]
B --> C["cluster outbound|80||svc
(Service port)"]
C --> D["endpoint 10.x:8080
(Pod targetPort)"]
```
> [!key] 한 문장 멘탈모델
> 15001은 "들어가는 문", listener 포트는 "원래 목적지 라벨", cluster 포트는 "Service port", endpoint 포트는 "진짜 Pod port". 같은 숫자여도 layer가 다르면 다른 것이다.
### Service port 80 ↔ targetPort 8080은 정상이다
아래 출력은 이상한 게 아니라 **정상**이다. cluster 이름은 service port 기준, endpoint는 Pod targetPort 기준이기 때문이다.
```text
cluster: outbound|80||istio-egressgateway.istio-system.svc.cluster.local
endpoint: 10.255.126.47:8080 HEALTHY outbound|80||istio-egressgateway.istio-system.svc.cluster.local
```
이유는 Kubernetes Service가 `port`와 `targetPort`를 분리하기 때문이다.
```yaml
apiVersion: v1
kind: Service
metadata:
name: istio-egressgateway
namespace: istio-system
spec:
ports:
- name: http2
port: 80 # cluster 이름에 들어가는 service port
targetPort: 8080 # EDS endpoint에 나오는 실제 Pod port
```
즉 cluster 이름은 **service port `80`** 기준, EDS endpoint는 **Pod targetPort `8080`** 으로 나온다. 이 분리는 한 군데 더 영향을 준다: `DestinationRule.portLevelSettings.port.number`가 가리키는 포트도 endpoint 8080이 아니라 **destination Service port 80**이다. Istio 문서도 `PortTrafficPolicy.port`를 "destination service의 port number"로 정의한다. 즉 **"포트 정책을 거는 모든 곳은 Service port 기준"** 이라는 규칙이 4-layer에서 자연히 따라 나온다.
## 04. cluster 이름 규칙과 subset
cluster를 손으로 더듬으려면 그 이름을 읽을 줄 알아야 한다. Istio의 내부 naming convention은 4-tuple이다.
```text
outbound | 9080 | v1 | reviews.default.svc.cluster.local
방향 포트 subset 서비스 FQDN
```
- **방향**: `outbound`(밖으로 나감) / `inbound`(들어옴)
- **포트**: destination Service port (= 4-layer의 cluster layer)
- **subset**: DestinationRule subset 이름. 없으면 빈칸
- **FQDN**: 대상 service의 full DNS 이름
subset이 없으면 `||`로 가운데가 빈다.
```text
outbound|9080||reviews.default.svc.cluster.local ← 전체 endpoint pool
outbound|9080|v1|reviews.default.svc.cluster.local ← v1 subset만
outbound|9080|v2|reviews.default.svc.cluster.local ← v2 subset만
```
`||` 사이가 비면 "특정 subset이 아니라 전체 service endpoint pool로 가는 cluster"라는 뜻이다.
### subset의 정체 — Service도, Envoy 순수개념도 아니다
subset이 헷갈리는 이유는 소속이 애매하기 때문이다. 정확히는 **subset = 같은 Kubernetes Service 뒤 endpoint들을 label 기준으로 나눈 named 그룹**이고, 그 정의처는 DestinationRule 하나뿐이다.
```text
reviews-v1 Pod labels: app=reviews, version=v1
reviews-v2 Pod labels: app=reviews, version=v2
reviews-v3 Pod labels: app=reviews, version=v3
```
```yaml
apiVersion: networking.istio.io/v1
kind: DestinationRule
metadata:
name: reviews
spec:
host: reviews.default.svc.cluster.local
subsets:
- name: v1
labels:
version: v1
- name: v2
labels:
version: v2
```
그러면 istiod가 `outbound|9080|v1|...`, `outbound|9080|v2|...` cluster를 만든다. 공식 문서 기준 subset은 "서비스의 개별 version을 나타내는 named set"이고, label이 service registry의 endpoint를 필터링하며, subset 단위로 traffic policy를 override할 수 있다. 소속을 한 줄로 못 박으면:
```text
subset은 Kubernetes Service가 아니다.
subset은 Envoy의 순수 개념도 아니다.
subset은 Istio DestinationRule 개념이고, Istio가 이를 Envoy cluster로 컴파일한다.
```
> [!warning] 함정 — subset cluster 존재 ≠ 트래픽 흐름, 그리고 NC vs UH
> subset 정책은 **route rule이 그 subset으로 명시적으로 트래픽을 보낼 때만** 효과가 있다. VirtualService에서 `subset: v2`로 라우팅하지 않으면 subset cluster는 만들어져 있어도 트래픽이 흐르지 않는다. 두 실패 모드를 layer로 구분하라: ① subset label과 실제 Pod label이 어긋나면 **cluster는 존재하지만 endpoint가 비어 `503 UH`(NoHealthyUpstream)**. ② `NC`(NoClusterFound)는 route가 **존재하지 않는 cluster를 가리킬 때**(예: subset 자체가 DestinationRule에 정의 안 됨). cluster 부재(CDS) vs endpoint 부재(EDS)는 다른 layer다.
## 05. endpoint discovery — cluster의 `type`이 답하는 질문
cluster 이름을 읽었으면, 그 cluster가 "endpoint 목록을 어디서 얻는가"를 알아야 한다. 이것이 cluster의 `type`(service discovery type)이고, 곧 `proxy-config cluster`의 TYPE 컬럼에 그대로 노출된다.
| 방식 | 의미 | Istio에서 흔한 사용처 |
|---|---|---|
| `EDS` | endpoint 목록을 xDS management server(Istio에서는 istiod)로부터 받음 | **K8s Service**, EndpointSlice, WorkloadEntry, multi-cluster endpoint |
| `STRICT_DNS` | DNS를 주기적으로 resolve해 나온 IP 전체를 endpoint로 사용 | 외부 DNS service (`ServiceEntry resolution: DNS`) |
| `LOGICAL_DNS` | DNS 이름을 logical upstream으로 사용. 전체 IP set LB보다 하나의 logical host에 가까움 | 일부 외부 서비스 |
| `STATIC` | endpoint 주소를 설정에 박아 넣음 | `ServiceEntry resolution: STATIC` (고정 IP) |
| `ORIGINAL_DST` | endpoint 목록을 미리 갖지 않고, 원래 목적지 IP(iptables가 보존한 SO_ORIGINAL_DST)로 그대로 전송. LB는 사실상 `PASSTHROUGH` | `PassthroughCluster` / `InboundPassthroughCluster` — registry에 없는 외부 목적지(`REGISTRY_ONLY`가 아닐 때) |
핵심 구조는 **"cluster는 CDS로, 그 endpoint 목록은 EDS로 따로"** 다 — Envoy Cluster proto에서 `eds_cluster_config`는 EDS update용 설정이고, `load_assignment`는 `STATIC`/`STRICT_DNS`/`LOGICAL_DNS` cluster의 멤버를 직접 지정한다. 이 분리가 "cluster는 떴는데 endpoint가 비었다"는 진단 상황의 근원이다.
K8s Service → 보통 EDS:
```yaml
apiVersion: v1
kind: Service
metadata:
name: reviews
spec:
selector:
app: reviews
ports:
- name: http
port: 9080
```
외부 DNS service → DNS resolution:
```yaml
apiVersion: networking.istio.io/v1
kind: ServiceEntry
metadata:
name: external-api
spec:
hosts:
- api.vendor.com
ports:
- number: 443
name: tls
protocol: TLS
resolution: DNS
```
고정 endpoint → STATIC:
```yaml
apiVersion: networking.istio.io/v1
kind: ServiceEntry
metadata:
name: legacy-db
spec:
hosts:
- legacy-db.internal
ports:
- number: 5432
name: tcp-postgres
protocol: TCP
resolution: STATIC
endpoints:
- address: 10.10.1.10
- address: 10.10.1.11
```
## 06. DestinationRule → Envoy cluster 필드 매핑 (이 문서의 심장)
여기가 앵커의 앞부분, "DestinationRule이 cluster 칸칸으로 컴파일된다"를 펼치는 곳이다. DestinationRule을 "Envoy 설정 언어의 프론트엔드"로 보면 — `trafficPolicy` 하위 5개 블록이 cluster의 서로 다른 5개 칸으로 떨어진다. 이 매핑을 외우면 "어떤 필드를 만지면 cluster의 무엇이 바뀌나 / 장애 flag가 어느 필드를 가리키나"가 동시에 풀린다.
```mermaid
flowchart LR
subgraph DR["DestinationRule trafficPolicy"]
LB["loadBalancer"]
CP["connectionPool"]
OD["outlierDetection"]
TLS["tls"]
SS["subsets"]
end
subgraph EC["Envoy Cluster"]
LP["lb_policy"]
CB["circuit_breakers"]
ODT["outlier_detection"]
TS["transport_socket"]
EDS["EDS subset cluster"]
end
LB --> LP
CP --> CB
OD --> ODT
TLS --> TS
SS --> EDS
```
### 6.1 loadBalancer → lb_policy
```yaml
trafficPolicy:
loadBalancer:
simple: LEAST_REQUEST
```
는 cluster의 `lb_policy: LEAST_REQUEST`로 컴파일된다. 이 표는 **레벨이 3종으로 다르다**(이걸 모르면 "왜 consistentHash랑 simple을 같이 못 쓰지?"에서 막힌다): `simple`은 enum 4종(택1), `consistentHash`는 `simple`과 **oneof**(둘 중 하나만 — LB 알고리즘 자체 선택), `localityLbSetting`/`warmup`은 선택된 LB와 **병렬로 적용되는 별도 필드**다.
| Istio 설정 | 레벨 | 의미 |
|---|---|---|
| `simple: ROUND_ROBIN` | enum (oneof A) | 순서대로 분산 |
| `simple: LEAST_REQUEST` | enum (oneof A) | outstanding request가 적은 endpoint 선호 |
| `simple: RANDOM` | enum (oneof A) | healthy host 중 random 선택 |
| `simple: PASSTHROUGH` | enum (oneof A) | original destination으로 그대로 전송. 고급/주의 |
| `consistentHash` | oneof B (simple과 배타) | header/cookie/source IP/query param 기반 soft affinity (RingHash, MagLev) |
| `localityLbSetting` | 병렬 필드 | region/zone/subzone 기반 locality LB/failover |
| `warmup` | 병렬 필드 | 새 endpoint에 traffic을 점진적으로 증가 |
Istio 문서는 `LEAST_REQUEST`를 `ROUND_ROBIN`의 더 안전한 drop-in replacement로 권장한다. sticky session(consistentHash) 예:
```yaml
trafficPolicy:
loadBalancer:
consistentHash:
httpHeaderName: x-user-id # 또는 httpCookie: {name: user, ttl: 0s}
```
> [!tip] 핵심
> LLM inference처럼 endpoint별 처리 시간이 크게 흔들리는 워크로드는 `ROUND_ROBIN`보다 `LEAST_REQUEST`가 직관적으로 낫다. queue가 짧은 endpoint를 선호하기 때문이다.
### 6.2 connectionPool → circuit_breakers / connection pool
```yaml
connectionPool:
tcp:
maxConnections: 200
connectTimeout: 500ms
tcpKeepalive:
time: 7200s
interval: 75s
http:
http1MaxPendingRequests: 2000
http2MaxRequests: 20000
maxRequestsPerConnection: 0
maxRetries: 1
idleTimeout: 30m
h2UpgradePolicy: UPGRADE
```
**컴파일 위치 주의** — `ConnectionPoolSettings`의 모든 필드가 `circuit_breakers.thresholds`로 가는 게 아니다. 한도 4종(`maxConnections`/`http1MaxPendingRequests`/`http2MaxRequests`/`maxRetries`)만 `circuit_breakers.thresholds`로 가고, 나머지는 cluster의 다른 칸으로 흩어진다: `connectTimeout`→`connect_timeout`, `tcpKeepalive`→`upstream_connection_options.tcp_keepalive`, `idleTimeout`/`maxRequestsPerConnection`/`h2UpgradePolicy` 등 HTTP 옵션→`common_http_protocol_options`. 이걸 알아야 `UO` flag가 정확히 어느 4개 한도 때문인지 좁힐 수 있다.
| 필드 | 컴파일 위치 | 의미 |
|---|---|---|
| `tcp.maxConnections` | `circuit_breakers.thresholds.max_connections` | destination host당 최대 connection 수. **HTTP/1.1에서는 host당 connection 수**, **HTTP/2에서는 connection 하나가 다수 request를 multiplex하므로 사실상 1 connection** → HTTP/2 cluster에서는 `http2MaxRequests`가 실질 동시성 한도 |
| `tcp.connectTimeout` | `connect_timeout` | upstream TCP connect timeout |
| `tcp.tcpKeepalive` | `upstream_connection_options.tcp_keepalive` | SO_KEEPALIVE 설정 |
| `tcp.maxConnectionDuration` | `common_http_protocol_options.max_connection_duration` | connection 최대 생존 시간 |
| `tcp.idleTimeout` | `common_http_protocol_options.idle_timeout` | TCP idle timeout |
| `http.http1MaxPendingRequests` | `circuit_breakers.thresholds.max_pending_requests` | ready connection이 없을 때 대기 가능한 request 수 |
| `http.http2MaxRequests` | `circuit_breakers.thresholds.max_requests` | destination으로의 active request 최대치 |
| `http.maxRequestsPerConnection` | `common_http_protocol_options.max_requests_per_connection` | connection당 최대 request 수. `1`이면 keep-alive 사실상 비활성화 |
| `http.maxRetries` | `circuit_breakers.thresholds.max_retries` | cluster 전체 outstanding retry 제한 |
| `http.idleTimeout` | `common_http_protocol_options.idle_timeout` | HTTP upstream connection pool idle timeout |
| `http.h2UpgradePolicy` | HTTP protocol options | HTTP/1.1 upstream connection을 HTTP/2로 upgrade할지 |
| `http.useClientProtocol` | HTTP protocol options | client protocol을 upstream에도 보존할지 |
| `http.maxConcurrentStreams` | HTTP/2 protocol options | HTTP/2 connection당 concurrent stream 제한 |
`ConnectionPoolSettings`는 upstream host에 대한 설정이며 TCP/HTTP 레벨에 적용된다.
### 6.3 circuit breaker — 별도 CRD가 없다
Istio에 `CircuitBreaker`라는 CRD는 **없다.** 실무에서 말하는 circuit breaker는 위 connectionPool 한도(+일부 outlierDetection)가 cluster의 `circuit_breakers` threshold로 컴파일된 결과일 뿐이다. 이걸 알면 "circuit breaker 설정 어디서 하지?"가 곧장 "DestinationRule connectionPool"로 번역된다.
이 한도에 걸리면 access log에 `UO`(`UpstreamOverflow`) flag가 찍힌다. 의미는 **"upstream이 죽었다"가 아니라 "Envoy가 정한 동시성/connection/pending/retry 한도를 넘어서 Envoy가 더 보내지 않았다"** — 즉 장애가 아니라 정책 발동이다. `503 UO`를 보면 DestinationRule connectionPool(`maxConnections`/pending/`http2MaxRequests`/`maxRetries`)부터 확인하라. LLM/GPU queue 포화에서 흔하다. (flag 상세는 [Envoy 응답 플래그](xds__src-envoy-response-flags.html))
### 6.4 outlierDetection → outlier_detection
"문제 있는 endpoint를 load balancing pool에서 잠시 빼는 기능"이다. circuit breaker(보내는 양 제한)와 직교한다 — 이건 "어느 endpoint를 뺄까"를 본다.
```yaml
outlierDetection:
consecutive5xxErrors: 7
consecutiveGatewayErrors: 5
interval: 30s
baseEjectionTime: 5m
maxEjectionPercent: 50
minHealthPercent: 50
```
| 필드 | 의미 |
|---|---|
| `consecutive5xxErrors` | 연속 5xx(모든 5xx + local-origin failure 포함)가 몇 번 나오면 endpoint eject할지 |
| `consecutiveGatewayErrors` | **HTTP 502/503/504 응답 연속만** 집계. TCP connect failure는 여기 포함 **안 됨** |
| `consecutiveLocalOriginFailures` | **TCP connect failure / connect timeout 등 local-origin failure** 기준 (`splitExternalLocalOriginErrors: true`일 때 5xx와 분리 집계) |
| `interval` | outlier 분석 주기 |
| `baseEjectionTime` | 최소 eject 시간 |
| `maxEjectionPercent` | 전체 endpoint 중 최대 몇 %까지 eject할지 |
| `minHealthPercent` | healthy 비율이 너무 낮으면 outlier detection을 비활성화할 기준 |
HTTP에서는 5xx를 계속 반환하는 host를 일정 시간 pool에서 제거한다. 단, `consecutiveGatewayErrors`는 HTTP 502/503/504 응답만 세고 TCP connect failure는 세지 않으므로, connect 단계 실패로 endpoint를 빼려면 `consecutiveLocalOriginFailures`(+`splitExternalLocalOriginErrors: true`)를 써야 한다.
> [!warning] 함정 — maxEjectionPercent와 UH
> Pod 수가 적은 서비스에서 `maxEjectionPercent`를 너무 높이면 정상 endpoint까지 pool에서 빠져 **`UH`(NoHealthyUpstream)** 가 난다. endpoint 2개짜리 서비스에 `maxEjectionPercent: 100`을 걸면 일시적 5xx 한 번에 전체가 빠질 수 있다. `minHealthPercent`로 하한 안전장치를 두라.
### 6.5 tls → transport_socket
DestinationRule의 `trafficPolicy.tls`는 cluster의 upstream transport socket / TLS context로 들어간다. 즉 **클라이언트 쪽 TLS를 cluster가 들고 있다.**
| mode | 의미 |
|---|---|
| `DISABLE` | upstream으로 TLS를 만들지 않음 |
| `SIMPLE` | 일반 TLS origination (서버 인증서만 검증) |
| `MUTUAL` | 사용자가 지정한 client cert/key로 mTLS |
| `ISTIO_MUTUAL` | Istio가 발급한 workload cert로 mTLS |
```yaml
trafficPolicy:
tls:
mode: ISTIO_MUTUAL # client sidecar → server sidecar, Istio workload cert로 mTLS
```
여기서 mTLS가 **방향으로 쪼개진다**는 점이 핵심이다: `PeerAuthentication`은 sidecar가 어떤 mTLS inbound를 받을지(서버 쪽)를, `DestinationRule.tls`는 sidecar가 outbound로 어떤 TLS를 보낼지(클라이언트 쪽)를 정한다. 명시적 DestinationRule이 없으면 Auto mTLS가 mesh 내부 트래픽에 mTLS를 자동 적용하려 한다. `ISTIO_MUTUAL` 모드에서는 다른 ClientTLSSettings 필드를 비워야 한다. 외부 HTTPS는 `SIMPLE` + `sni: api.vendor.com` 형태로 쓴다.
### 6.6 HTTP protocol — h2UpgradePolicy vs useClientProtocol
```text
h2UpgradePolicy: UPGRADE
→ upstream을 HTTP/2로 올려서 보낼 수 있음
useClientProtocol: true
→ client가 HTTP/1.1이면 upstream도 HTTP/1.1, client가 HTTP/2면 upstream도 HTTP/2
→ 이 경우 h2UpgradePolicy는 효과가 없다
```
둘을 동시에 잘못 쓰면 `useClientProtocol`이 우선해 `h2UpgradePolicy`가 무력화된다. HTTP/3는 Envoy에 support가 있지만 일반 DestinationRule 운영에서는 HTTP/2·gRPC 쪽을 주로 제어하고, HTTP/3 upstream은 gateway/proxy 고급 설정 영역에 가깝다.
## 07. 적용 예시 — Service/DR/VS 한 벌, 그리고 그게 만드는 cluster
> 예제 포트 구분: 이 §07의 **`llm-server`(Service port 80 / targetPort 8080)** 가 본 문서의 메인 예제다. §08 검증 명령에서 쓰는 **`reviews`(9080)** 는 bookinfo 표준 예제라 포트 숫자만 다를 뿐 layer 구조는 동일하다.
지금까지의 매핑이 실제로 어떻게 한 파일로 묶이는지 본다. `Service port: 80` ↔ `targetPort: 8080` ↔ `DestinationRule portLevelSettings.port.number: 80` ↔ `subset: v1/v2`가 한 곳에서 정렬되는 완전한 예시다 (apply 그대로, 주석 유지).
```yaml
apiVersion: v1
kind: Namespace
metadata:
name: mesh-demo
labels:
istio-injection: enabled
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: llm-server
namespace: mesh-demo
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: llm-server-v1
namespace: mesh-demo
spec:
replicas: 2
selector:
matchLabels:
app: llm-server
version: v1
template:
metadata:
labels:
app: llm-server
version: v1
spec:
serviceAccountName: llm-server
containers:
- name: app
image: gcr.io/google-samples/hello-app:1.0
ports:
- name: http
containerPort: 8080
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: llm-server-v2
namespace: mesh-demo
spec:
replicas: 1
selector:
matchLabels:
app: llm-server
version: v2
template:
metadata:
labels:
app: llm-server
version: v2
spec:
serviceAccountName: llm-server
containers:
- name: app
image: gcr.io/google-samples/hello-app:2.0
ports:
- name: http
containerPort: 8080
---
apiVersion: v1
kind: Service
metadata:
name: llm-server
namespace: mesh-demo
spec:
selector:
app: llm-server
ports:
- name: http
port: 80
targetPort: 8080
---
apiVersion: networking.istio.io/v1
kind: DestinationRule
metadata:
name: llm-server
namespace: mesh-demo
spec:
host: llm-server.mesh-demo.svc.cluster.local
# 이 trafficPolicy는 기본적으로 이 destination host의 모든 port에 적용된다.
# 다만 아래 portLevelSettings가 같은 port를 override하면,
# destination-level 설정이 자동 상속되지 않는다.
trafficPolicy:
loadBalancer:
simple: LEAST_REQUEST
connectionPool:
tcp:
maxConnections: 100
connectTimeout: 300ms
tcpKeepalive:
time: 7200s
interval: 75s
http:
http1MaxPendingRequests: 1000
http2MaxRequests: 10000
maxRequestsPerConnection: 0
maxRetries: 2
idleTimeout: 30m
h2UpgradePolicy: UPGRADE
outlierDetection:
consecutive5xxErrors: 7
consecutiveGatewayErrors: 5
interval: 30s
baseEjectionTime: 5m
maxEjectionPercent: 50
minHealthPercent: 50
tls:
mode: ISTIO_MUTUAL
# 이 port.number는 Service port 80이다.
# targetPort/containerPort 8080이 아니다.
portLevelSettings:
- port:
number: 80
loadBalancer:
simple: LEAST_REQUEST
connectionPool:
tcp:
maxConnections: 200
connectTimeout: 500ms
http:
http1MaxPendingRequests: 2000
http2MaxRequests: 20000
maxRequestsPerConnection: 0
maxRetries: 1
idleTimeout: 30m
h2UpgradePolicy: UPGRADE
outlierDetection:
consecutive5xxErrors: 7
consecutiveGatewayErrors: 5
interval: 30s
baseEjectionTime: 5m
maxEjectionPercent: 50
minHealthPercent: 50
tls:
mode: ISTIO_MUTUAL
# subset은 Service 뒤 endpoint들을 label 기준으로 나눈 이름 있는 그룹이다.
subsets:
- name: v1
labels:
version: v1
trafficPolicy:
loadBalancer:
simple: LEAST_REQUEST
- name: v2
labels:
version: v2
trafficPolicy:
loadBalancer:
simple: LEAST_REQUEST
connectionPool:
http:
http2MaxRequests: 5000
maxRetries: 0
---
apiVersion: networking.istio.io/v1
kind: VirtualService
metadata:
name: llm-server
namespace: mesh-demo
spec:
hosts:
- llm-server.mesh-demo.svc.cluster.local
http:
- name: canary-by-header
match:
- headers:
x-canary:
exact: v2
route:
- destination:
host: llm-server.mesh-demo.svc.cluster.local
subset: v2
port:
number: 80
- name: default-90-10
route:
- destination:
host: llm-server.mesh-demo.svc.cluster.local
subset: v1
port:
number: 80
weight: 90
- destination:
host: llm-server.mesh-demo.svc.cluster.local
subset: v2
port:
number: 80
weight: 10
```
**정렬 지도** — 같은 값이 여러 리소스에 흩어져 있는 magic-string을 한눈에:
```text
Service.port 80 == cluster 이름의 PORT == portLevelSettings.port.number == VS destination.port.number
Service.targetPort 8080 == containerPort == EDS endpoint port
subset 이름 v1/v2 == DR.subsets[].name == VS destination.subset == cluster 이름의 SUBSET
```
이 YAML이 Envoy 관점으로 만들어내는 결과:
```text
Envoy cluster (CDS):
outbound|80||llm-server.mesh-demo.svc.cluster.local ← 전체
outbound|80|v1|llm-server.mesh-demo.svc.cluster.local ← v1 subset
outbound|80|v2|llm-server.mesh-demo.svc.cluster.local ← v2 subset
Endpoint (EDS) — 실제 Pod targetPort 8080:
10.244.1.11:8080 HEALTHY outbound|80|v1|llm-server...
10.244.2.18:8080 HEALTHY outbound|80|v1|llm-server...
10.244.3.22:8080 HEALTHY outbound|80|v2|llm-server...
```
> [!warning] 함정 — portLevelSettings는 부분 상속하지 않는다
> `portLevelSettings`가 destination-level과 같은 port를 override하면 **destination-level 설정을 "부분 상속"하지 않는다.** port-level traffic policy에서 생략한 필드는 destination-level 값이 아니라 **기본값**이 적용된다. 그래서 위 YAML처럼 port-level override를 쓸 때는 필요한 필드(outlierDetection·tls 포함)를 반복 명시하는 편이 안전하다.
## 08. 떴는지 한 번 확인 — 포트·cluster·endpoint 추적
설정이 Envoy에 실제로 반영됐는지는 `istioctl proxy-config`로 4-layer를 따라 내려가며 확인한다. 명령의 순서 자체가 §03의 layer 순서(listener → route → cluster → endpoint)와 같다. (아래는 bookinfo 표준 예 **`reviews`(9080)** 기준 — §07 `llm-server`(80)와 포트만 다르고 절차는 같다.)
```bash
# 1) 원래 목적지 포트의 virtual listener 확인
istioctl proxy-config listener -n --port 9080
# 2) route가 어떤 cluster를 가리키는지 확인
istioctl proxy-config route -n --name 9080 -o json
# 3) service port 기준 cluster 확인 (FQDN + port)
istioctl proxy-config cluster -n \
--fqdn reviews.default.svc.cluster.local --port 9080
# 4) cluster 안의 실제 endpoint port 확인 (여기서 targetPort가 드러남)
istioctl proxy-config endpoint -n \
--cluster 'outbound|9080||reviews.default.svc.cluster.local'
# 5) transport socket(TLS mode)이 잘 들어갔는지
istioctl proxy-config cluster -n \
--fqdn reviews.default.svc.cluster.local -o json | jq '.[].transportSocket'
```
`proxy-config cluster`는 SERVICE FQDN / PORT / SUBSET / DIRECTION / TYPE를 요약해 보여준다. TYPE 컬럼은 §05의 discovery type을 그대로 노출한다: K8s Service/EndpointSlice → `EDS`, `ServiceEntry resolution: STATIC` → `STATIC`, `resolution: DNS` → `STRICT_DNS` 또는 `LOGICAL_DNS`, registry에 없는 outbound로 흘러간 경우 → `PassthroughCluster`(`ORIGINAL_DST`).
```text
SERVICE FQDN PORT SUBSET DIRECTION TYPE
reviews.default.svc.cluster.local 9080 - outbound EDS
reviews.default.svc.cluster.local 9080 v1 outbound EDS
reviews.default.svc.cluster.local 9080 v2 outbound EDS
```
장애 분석은 **route → cluster → endpoint → secret** 순서로 내려간다. response flag가 어느 layer를 먼저 의심할지 알려준다 (자세한 xDS 5계층은 [xDS 5계층과 진단](xds__src-xds-layers-and-diagnosis.html), flag 해석은 [Envoy 응답 플래그](xds__src-envoy-response-flags.html)).
```bash
istioctl proxy-config route -n # route가 원하는 cluster를 가리키나? (NR 의심)
istioctl proxy-config cluster -n # 그 cluster가 존재하나? (NC 의심)
istioctl proxy-config endpoint -n # healthy endpoint가 있나? (UH 의심)
istioctl proxy-config secret -n # mTLS cert가 있나? (UF/handshake 의심)
```
```mermaid
flowchart TD
NR["NR
(no route)"] --> R["route 확인
proxy-config route"]
NC["NC
(no cluster)"] --> C["cluster 확인
proxy-config cluster"]
UH["UH
(no healthy upstream)"] --> E["endpoint 확인
proxy-config endpoint"]
UF["UF
(upstream conn fail)"] --> S["secret/TLS 확인
proxy-config secret"]
```
| flag | 의심 layer | 확인 명령 |
|---|---|---|
| `NR` | route 없음 | `proxy-config route` |
| `NC` | cluster 없음 | `proxy-config cluster` |
| `UH` | healthy endpoint 없음 | `proxy-config endpoint` |
| `UF` | upstream connect / TLS handshake 실패 | `proxy-config secret` |
(각 flag의 정확한 의미·UO/UC 등 나머지 플래그 상세는 [Envoy 응답 플래그](xds__src-envoy-response-flags.html)) 또 `istioctl x describe pod -n `는 그 Pod에 적용되는 DestinationRule·matching subset·TLS mode를 사람이 읽기 쉽게 요약해 준다 (초기 진단용).
## 핵심 정리
cluster 하나가 모든 정책을 흡수하고, DestinationRule이 그 칸칸으로 컴파일된다 — 이것 하나가 4-layer 혼란과 장애 진단을 동시에 푼다.
```text
포트 4-layer:
15001 = outbound capture port (iptables 입구)
listener N = 원래 목적지 포트 라벨 (virtual listener)
cluster N = destination Service port
endpoint N = 실제 Pod targetPort/containerPort
→ Service port 80 ↔ targetPort 8080 분리는 정상
Envoy cluster = upstream backend pool
+ discovery(EDS/DNS/STATIC/ORIGINAL_DST=Passthrough) + LB + connectionPool + circuit breaker
+ outlier detection + TLS transport socket + HTTP protocol option
cluster 이름 = outbound|PORT|SUBSET|FQDN ('||'면 subset 없음 = 전체 pool)
subset = DestinationRule의 label 기반 named 그룹 (Service도, Envoy 순수개념도 아님)
DestinationRule → Envoy cluster 매핑:
loadBalancer → lb_policy
connectionPool → circuit_breakers / connection pool (초과 시 UO)
outlierDetection → outlier_detection (과한 maxEjectionPercent → UH)
tls → transport_socket (ISTIO_MUTUAL = workload cert mTLS)
portLevelSettings.port.number = Service port (targetPort 아님)
+ port-level override는 destination-level을 부분 상속하지 않는다 (생략 필드는 기본값)
진단 순서 = listener → route → cluster → endpoint → secret (flag NR/NC/UH/UF가 layer를 지목)
```
## What you might be missing
- **cluster는 만들어져 있어도 비어 있을 수 있다.** `proxy-config cluster`에 subset cluster가 보여도, subset label과 실제 Pod label이 어긋나면 `proxy-config endpoint`가 빈다. cluster 존재(CDS)와 endpoint 존재(EDS)는 별개 layer다 — `503 UH`는 cluster 없음이 아니라 endpoint 없음이다.
- **`UO`(UpstreamOverflow)는 장애가 아니라 정책 발동이다.** Envoy가 connectionPool 한도를 지킨 정상 동작 — app/DB보다 DestinationRule connectionPool·retry 폭증을 먼저 본다. LLM inference에서 blanket retry는 GPU queue를 더 밀어 tail latency를 악화시킨다. (flag 상세 → [Envoy 응답 플래그](xds__src-envoy-response-flags.html))
- **portLevelSettings의 "비상속"은 운영 사고의 단골이다.** destination-level에 outlierDetection·tls를 잘 잡아 놓고 port-level에 LB만 추가하면, 그 port에서는 outlierDetection·tls가 기본값으로 떨어진다(STRICT mTLS가 풀리는 식). port-level override를 쓸 거면 전 필드를 반복 명시하라.
- **Auto mTLS 때문에 DestinationRule.tls를 안 써도 mTLS가 걸린다.** 명시적 `tls.mode: DISABLE` 없이 PeerAuthentication만 STRICT로 바꾸면, client 쪽 DestinationRule이 plaintext를 강제하던 경로에서 `503 UF`(handshake mismatch)가 난다. server PeerAuthentication과 client DestinationRule.tls는 짝으로 맞춰야 한다.
- **circuit breaker와 outlier detection은 직교한다.** circuit breaker(connectionPool)는 "한 host로 얼마나 보낼까"의 상한이고, outlier detection은 "어느 host를 pool에서 뺄까"의 판정이다. 둘 다 `503`을 낼 수 있지만 flag(`UO` vs `UH`)와 손볼 필드가 완전히 다르다 — 섞으면 엉뚱한 노브를 돌린다.