--- 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`)와 손볼 필드가 완전히 다르다 — 섞으면 엉뚱한 노브를 돌린다.