Envoy Cluster 해부 — 포트 4-layer, subset, DestinationRule 매핑 (출처: "Istio 운영 노하우" 정리 + Istio 1.30 공식 문서)
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 5계층과 진단에서.
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 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로 묶었다는 점이 본질적으로 다르다.)
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겹으로 갈린다 — 이걸 섞으면 대화가 꼬인다.
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)다.
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의 포트
flowchart TD
A["iptables capture :15001<br/>(outbound 입구)"] --> B["virtual listener :9080<br/>(원래 목적지 포트)"]
B --> C["cluster outbound|80||svc<br/>(Service port)"]
C --> D["endpoint 10.x:8080<br/>(Pod targetPort)"]15001은 “들어가는 문”, listener 포트는 “원래 목적지 라벨”, cluster 포트는 “Service port”, endpoint 포트는 “진짜 Pod port”. 같은 숫자여도 layer가 다르면 다른 것이다.
Service port 80 ↔ targetPort 8080은 정상이다
아래 출력은 이상한 게 아니라 정상이다. cluster 이름은 service port 기준, endpoint는 Pod targetPort 기준이기 때문이다.
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를 분리하기 때문이다.
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이다.
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이 없으면 ||로 가운데가 빈다.
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 하나뿐이다.
reviews-v1 Pod labels: app=reviews, version=v1
reviews-v2 Pod labels: app=reviews, version=v2
reviews-v3 Pod labels: app=reviews, version=v3
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할 수 있다. 소속을 한 줄로 못 박으면:
subset은 Kubernetes Service가 아니다.
subset은 Envoy의 순수 개념도 아니다.
subset은 Istio DestinationRule 개념이고, Istio가 이를 Envoy cluster로 컴파일한다.
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:
apiVersion: v1
kind: Service
metadata:
name: reviews
spec:
selector:
app: reviews
ports:
- name: http
port: 9080
외부 DNS service → DNS resolution:
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:
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가 어느 필드를 가리키나"가 동시에 풀린다.
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 --> EDS6.1 loadBalancer → lb_policy
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) 예:
trafficPolicy:
loadBalancer:
consistentHash:
httpHeaderName: x-user-id # 또는 httpCookie: {name: user, ttl: 0s}
LLM inference처럼 endpoint별 처리 시간이 크게 흔들리는 워크로드는 ROUND_ROBIN보다 LEAST_REQUEST가 직관적으로 낫다. queue가 짧은 endpoint를 선호하기 때문이다.
6.2 connectionPool → circuit_breakers / connection pool
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 응답 플래그)
6.4 outlierDetection → outlier_detection
“문제 있는 endpoint를 load balancing pool에서 잠시 빼는 기능"이다. circuit breaker(보내는 양 제한)와 직교한다 — 이건 “어느 endpoint를 뺄까"를 본다.
outlierDetection:
consecutive5xxErrors: 7
consecutiveGatewayErrors: 5
interval: 30s
baseEjectionTime: 5m
maxEjectionPercent: 50
minHealthPercent: 50
| 필드 | 의미 |
|---|---|
consecutive5xxErrors |
연속 5xx(모든 5xx + local-origin failure 포함)가 몇 번 나오면 endpoint eject할지 |
consecutiveGatewayErrors |
기본값(splitExternalLocalOriginErrors: false)에서는 HTTP 502/503/504 응답과 TCP connect timeout·connection reset 같은 local-origin failure를 함께 집계한다. splitExternalLocalOriginErrors: true로 명시적으로 켰을 때만 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는 기본 상태(splitExternalLocalOriginErrors: false)에서 TCP connect timeout·connection reset 같은 local-origin failure도 HTTP 502/503/504와 함께 집계한다 — “connect 실패는 절대 포함 안 됨"이 아니라, splitExternalLocalOriginErrors: true를 명시적으로 켰을 때만 두 종류가 분리되어 순수 HTTP gateway error만 남는다. 이때 connect 단계 실패만 따로 잡으려면 consecutiveLocalOriginFailures를 쓴다.
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 |
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
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 그대로, 주석 유지).
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을 한눈에:
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 관점으로 만들어내는 결과:
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...
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)와 포트만 다르고 절차는 같다.)
# 1) 원래 목적지 포트의 virtual listener 확인
istioctl proxy-config listener <client-pod> -n <ns> --port 9080
# 2) route가 어떤 cluster를 가리키는지 확인
istioctl proxy-config route <client-pod> -n <ns> --name 9080 -o json
# 3) service port 기준 cluster 확인 (FQDN + port)
istioctl proxy-config cluster <client-pod> -n <ns> \
--fqdn reviews.default.svc.cluster.local --port 9080
# 4) cluster 안의 실제 endpoint port 확인 (여기서 targetPort가 드러남)
istioctl proxy-config endpoint <client-pod> -n <ns> \
--cluster 'outbound|9080||reviews.default.svc.cluster.local'
# 5) transport socket(TLS mode)이 잘 들어갔는지
istioctl proxy-config cluster <client-pod> -n <ns> \
--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).
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계층과 진단, flag 해석은 Envoy 응답 플래그).
istioctl proxy-config route <pod> -n <ns> # route가 원하는 cluster를 가리키나? (NR 의심)
istioctl proxy-config cluster <pod> -n <ns> # 그 cluster가 존재하나? (NC 의심)
istioctl proxy-config endpoint <pod> -n <ns> # healthy endpoint가 있나? (UH 의심)
istioctl proxy-config secret <pod> -n <ns> # mTLS cert가 있나? (UF/handshake 의심)
flowchart TD
NR["NR<br/>(no route)"] --> R["route 확인<br/>proxy-config route"]
NC["NC<br/>(no cluster)"] --> C["cluster 확인<br/>proxy-config cluster"]
UH["UH<br/>(no healthy upstream)"] --> E["endpoint 확인<br/>proxy-config endpoint"]
UF["UF<br/>(upstream conn fail)"] --> S["secret/TLS 확인<br/>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 응답 플래그) 또 istioctl x describe pod <pod> -n <ns>는 그 Pod에 적용되는 DestinationRule·matching subset·TLS mode를 사람이 읽기 쉽게 요약해 준다 (초기 진단용).
핵심 정리
cluster 하나가 모든 정책을 흡수하고, DestinationRule이 그 칸칸으로 컴파일된다 — 이것 하나가 4-layer 혼란과 장애 진단을 동시에 푼다.
포트 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 응답 플래그)- 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(UOvsUH)와 손볼 필드가 완전히 다르다 — 섞으면 엉뚱한 노브를 돌린다.
검증 기록 (2026-07-05 · Istio 1.30.0 / k8s 1.30.6)
검증 방법: Envoy/Istio 공식 문서 대조 + homelab k8s 클러스터(Istio 1.30, sidecar mode)에서 istioctl proxy-config·access log response flag 실측을 병행함.
| 주장 | 판정 | 근거 |
|---|---|---|
| C1. Envoy cluster는 discovery+LB+connectionPool+circuit breaker+outlierDetection+TLS를 흡수한 단일 객체이며 DestinationRule이 그 필드로 컴파일된다 | ✅ 실측 확인 | envoyproxy.io/…/cluster_manager · T13 실측 |
| C2. sidecar mode에서 같은 포트 숫자가 capture(15001)/virtual listener/cluster(Service port)/endpoint(targetPort) 4-layer로 분해된다 | ✅ 실측 확인 | istio.io/…/proxy-cmd · T67 실측 |
| C3. iptables outbound capture 포트는 기본값 15001이다 | ✅ 문헌 확인 | istio.io/…/proxy-cmd |
C4. cluster naming convention은 direction|port|subset|FQDN, subset 없으면 || |
✅ 문헌 확인 | istio.io/…/proxy-cmd |
| C5. subset은 VirtualService가 명시적으로 라우팅할 때만 트래픽이 흐른다 | ✅ 실측 확인 | istio.io/…/virtual-service · T68 실측 |
| C6. NC는 cluster 부재(CDS), UH는 endpoint 부재(EDS) — 서로 다른 layer 문제 | ✅ 실측 확인 | envoyproxy.io/…/substitution_formatter · T65 실측 |
| C7. discovery type은 EDS/STRICT_DNS/LOGICAL_DNS/STATIC/ORIGINAL_DST로 나뉜다 | ✅ 문헌 확인 | envoyproxy.io/…/cluster.proto |
| C8. connectionPool 한도 4종만 circuit_breakers.thresholds로, 나머지는 다른 필드로 분산 컴파일 | ✅ 실측 확인 | T13 실측 |
| C9. 별도 CircuitBreaker CRD는 없다 — connectionPool/outlierDetection이 circuit_breakers로 컴파일된 결과 | ✅ 문헌 확인 | istio.io/…/destination-rule |
| C10. connectionPool 한도 초과 시 UO flag — 장애가 아니라 정책 발동 | ✅ 문헌 확인 | envoyproxy.io/…/substitution_formatter |
| C11. consecutiveGatewayErrors는 TCP connect failure를 항상 제외한다 | ❌ 오류 — 본문 교정 | envoyproxy.io/…/outlier · T12 실측 |
| C12. mTLS는 방향으로 쪼개진다 — PeerAuthentication(inbound) vs DestinationRule.tls(outbound) | ✅ 문헌 확인 | istio.io/…/tls-configuration |
| C13. 명시적 DestinationRule 없으면 Auto mTLS가 mesh 내부에 mTLS 자동 적용 | ✅ 문헌 확인 | istio.io/…/tls-configuration |
| C14. portLevelSettings override 시 destination-level을 부분 상속하지 않는다(생략 필드는 기본값) | ✅ 실측 확인 | istio.io/…/destination-rule · T15 실측 |
| C15. loadBalancer는 simple(택1)/consistentHash(simple과 oneof)/localityLbSetting·warmup(병렬) 3레벨 | ✅ 문헌 확인 | istio.io/…/destination-rule |
| C16. useClientProtocol: true면 h2UpgradePolicy는 무력화된다 | ✅ 문헌 확인 | istio.io/…/destination-rule |