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의 포트
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가 어느 필드를 가리키나"가 동시에 풀린다.
6.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 |
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)를 써야 한다.
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 의심)
| 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)와 손볼 필드가 완전히 다르다 — 섞으면 엉뚱한 노브를 돌린다.