🏠 목록 Envoy Cluster 해부 — 포트 4-layer, subset, DestinationRule 매핑 (출처: "Istio 운영 노하우" 정리 + Istio 1.30 공식 문서) 📄 MD 원본 🌓 테마
istioenvoyclusterdestinationrulesubsetload-balancingconnection-pool

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의 포트
iptables :15001outbound 입구virtual listener :9080원래 목적지 포트cluster outbound|80||svcService portendpoint 10.x:8080Pod targetPort
그림 1. outbound 변환: iptables가 :15001로 캡처 → virtual listener가 원래 목적지 포트(:9080)로 분기 → cluster(outbound|80||svc, Service port) → endpoint(Pod targetPort :8080). 포트가 세 번 바뀜.
★ 한 문장 멘탈모델

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가 porttargetPort를 분리하기 때문이다.

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

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 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_assignmentSTATIC/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가 어느 필드를 가리키나"가 동시에 풀린다.

DestinationRule trafficPolicyloadBalancerconnectionPooloutlierDetectiontlssubsetsEnvoy Clusterlb_policycircuit_breakersoutlier_detectiontransport_socketEDS subset cluster
그림 2. DestinationRule.trafficPolicy 필드와 Envoy Cluster 필드의 1:1 매핑: loadBalancer→lb_policy, connectionPool→circuit_breakers, outlierDetection→outlier_detection, tls→transport_socket, subsets→EDS subset cluster.

6.1 loadBalancer → lb_policy

trafficPolicy:
  loadBalancer:
    simple: LEAST_REQUEST

는 cluster의 lb_policy: LEAST_REQUEST로 컴파일된다. 이 표는 레벨이 3종으로 다르다(이걸 모르면 "왜 consistentHash랑 simple을 같이 못 쓰지?"에서 막힌다): simple은 enum 4종(택1), consistentHashsimpleoneof(둘 중 하나만 — 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_REQUESTROUND_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의 다른 칸으로 흩어진다: connectTimeoutconnect_timeout, tcpKeepaliveupstream_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)를 써야 한다.

⚠ 함정 — 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
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: 80targetPort: 8080DestinationRule portLevelSettings.port.number: 80subset: 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는 부분 상속하지 않는다

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: STATICSTATIC, resolution: DNSSTRICT_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 의심)
NR (no route)route 확인proxy-config routeNC (no cluster)cluster 확인proxy-config clusterUH (no healthy)endpoint 확인proxy-config endpointUF (conn fail)secret/TLS 확인proxy-config secret
그림 3. 503 response flag별 진단 명령 매핑: NR→route, NC→cluster, UH→endpoint, UF→secret. flag를 보면 곧장 어느 proxy-config를 봐야 할지 결정됨.
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