mTLS Passthrough — 홉별 DestinationRule·connectionPool (홉 2는 tls 없는 DR)
mTLS Passthrough(HTTPS over mTLS, 용어 정본)에서
홉 1(sidecar→egressgateway)과 홉 2(egressgateway→외부) 각각에 connectionPool을 거는 방법을 다룬다.
머릿속에 넣을 한 문장: DR은 두 벌이되 홉 2 DR엔 tls 블록이 아예 없다 — gateway는 앱의 TLS
ciphertext를 raw TCP로 흘릴 뿐이므로, tls.mode를 고르는 문제가 아니라 생략하는 문제다
(tls.mode: tcp라는 값은 존재하지 않고 CRD가 거부한다 — T91 실측). connectionPool은 두 홉 모두
유효하며 “그 홉의 연결을 여는 프록시"에 발효된다: 홉 1 값은 호출자 sidecar, 홉 2 값은 gateway.
패턴 자체의 해부(이중 봉투·leg-2 tcp 필수)는 정본,
이 문서는 그 위의 DR 두 벌 구성만 좁혀 다룬다.
대상 환경: Istio 1.30.0, sidecar mesh (homelab k8s 1.30.6 기준 컨벤션) 검증: 본문의 모든 주장은 실제 클러스터 E2E 실측으로 확인했다 (검증 ID T91 — 문서 끝 “검증 기록"에 원자료·재현 스크립트). 본문의 “(T91)“은 그 기록의 해당 항목을 가리킨다.
0. 패턴 요약 — 두 홉, 두 봉투, DR 두 벌
mTLS Passthrough는 “종단간 암호화는 건드리지 않으면서, egress에서 누가 나가는지(SPIFFE 신원)는 확인하고 싶다“에 대한 답이다. 동작은 봉투 두 겹으로 요약된다:
- 앱은 평소처럼
https://를 직접 호출한다 — 이 TLS(inner)는 앱↔외부 서버 사이에서만 열리고, 중간의 누구도 풀지 않는다. - sidecar가 그 위에 메시 mTLS(outer,
ISTIO_MUTUAL)를 한 겹 더 입혀 egressgateway로 보낸다 (홉 1). - gateway는 outer만 종단해 호출 워크로드의 SPIFFE 신원을 암호학적으로 검증한 뒤, 안에 든 inner TLS bytes를 복호화 없이 raw TCP로 그대로 외부에 흘린다 (홉 2).
flowchart LR
subgraph appns["ns: mesh-test"]
APP["app<br/>curl https://<br/>(inner TLS 생성)"] --> SC["sidecar Envoy<br/>outer ISTIO_MUTUAL 래핑<br/>← DR-hop1 (tls + pool)"]
end
subgraph sysns["ns: istio-system"]
GW["egressgateway<br/>outer 종단 = SPIFFE 확인<br/>← DR-hop2 (pool만, tls 없음)"]
end
EXT["api.partner.example.com:443<br/>(inner TLS를 서버가 종단)"]
SC -->|"홉 1: outer mTLS<br/>(속: inner TLS 그대로)"| GW
GW -->|"홉 2: raw TCP relay<br/>(inner TLS 그대로)"| EXT여기서 이 문서의 주제가 나온다. 각 홉의 커넥션을 여는 프록시가 서로 다르다 — 홉 1 연결은 각 클라이언트 pod의 sidecar가, 홉 2 연결은 gateway가 연다. DestinationRule의 connectionPool은 항상 “연결을 여는 쪽” Envoy에 설정으로 박히므로(§2), 홉별로 풀을 걸려면 DR이 두 벌 필요하고, 두 벌의 생김새가 상당히 다르다(§1). 패턴 자체를 더 깊이 — 왜 gateway 뒤 라우트가 tcp여야 하는지, passthrough/origination 대비 트레이드오프 — 는 해부 문서와 패턴 지도가 다룬다.
1. DR 두 벌 — 그리고 홉 2 “일반 TCP"의 올바른 표현
이 패턴의 두 홉은 성격이 완전히 다르다:
| DR-hop1 | DR-hop2 | |
|---|---|---|
host |
gateway Service의 레지스트리 FQDN | ServiceEntry 외부 host |
tls |
ISTIO_MUTUAL + sni (outer 봉투 생성) |
블록 자체가 없음 (raw TCP) |
connectionPool 발효 위치 |
호출자 sidecar의 upstream cluster | gateway의 upstream cluster |
| 존재 이유 | mTLS 래핑 + SNI 적재 + 홉 1 풀 | 홉 2 풀 튜닝 전용 |
exportTo |
두지 않는다 (기본 * = mesh 전체) |
["."] — gateway ns로 한정 |
가시성이 반대인 이유 — 소비자가 누구인가로 갈린다. DR-hop1의 소비자는 모든 호출 네임스페이스의 sidecar다(트래픽 없이도 각 클라이언트 sidecar의 cluster에 컴파일돼야 outer mTLS가 성립). 그래서 exportTo를 생략해 mesh 전체에 공개한다 — 클라이언트가 gateway와 같은 ns여야 한다는 제약은 없다(istio-system의 DR을 mesh-test의 sidecar가 집는 것이 표준 배치). 반대로 좁혀버리면(예: exportTo: ["."]) 클라이언트 sidecar가 DR을 못 보고 평문으로 gateway의 ISTIO_MUTUAL listener에 부딪혀 handshake가 거부된다. DR-hop2는 소비자가 gateway pod 하나뿐이라 ["."]로 좁히는 것이 위생이다(§5의 같은-ns 한계 포함).
홉 2가 “일반 TCP"라는 말의 정확한 의미: gateway가 outer ISTIO_MUTUAL을 종단하고 나면 손에 남는 건 앱이 만든 inner TLS ciphertext다. gateway는 이걸 복호화도 재암호화도 하지 않고 tcp_proxy로 그대로 외부에 흘린다. 와이어가 평문인 게 아니라 — gateway 입장에서 TCP relay라는 뜻이고, 종단간 암호화는 앱↔외부 서버의 inner TLS가 책임진다.
이걸 DR로 표현하는 방법은 셋이고, 셋 다 결과가 같다 (외부 host는 MESH_EXTERNAL이라 auto-mTLS 대상이 아니므로):
- DR-hop2를 아예 안 만든다 — 기본값 = TLS origination 없음 = raw forward. 풀 튜닝이 필요 없다면 이게 정답.
tls블록 없이connectionPool만 있는 DR — 풀 튜닝이 필요할 때의 표준형 (이 문서의 §3).tls.mode: DISABLE명시 — 방어적 명시가 필요할 때. 기능상 2와 동일.
반면 tls.mode: tcp는 존재하지 않는 값이다. ClientTLSSettings.mode의 enum은 넷뿐이고, apply 시점에 CRD validation이 거부한다 (T91 실측):
The DestinationRule "..." is invalid:
* spec.trafficPolicy.tls.mode: Unsupported value: "tcp":
supported values: "DISABLE", "SIMPLE", "MUTUAL", "ISTIO_MUTUAL"
자매 패턴과의 경계: 이중 mTLS는 DR-hop2가
MUTUAL + credentialName으로 gateway가 새 TLS를 시작한다. mTLS Passthrough에서 DR-hop2의
mode만 바꿔서 그 패턴이 되는 게 아니다 — 앱 호출(https→http), Gateway protocol(TLS→HTTPS),
VS 라우트 타입(tls·tcp→http·http)이 함께 바뀌어야 한다(패턴 지도 §3 시그니처 표).
2. connectionPool은 어느 Envoy에서 발효되나
동작 원리부터. istiod는 DR의 trafficPolicy를 “그 DR의 host를 향한 upstream cluster 설정"으로 컴파일해 Envoy에 내려준다. 그런데 upstream cluster는 프록시마다 따로 존재한다 — 같은 host라도 sidecar의 cluster와 gateway의 cluster는 별개다. 따라서 설정이 실제로 힘을 갖는 곳은 그 host로 연결을 여는 프록시의 cluster다. 이 문서에선 이를 “발효(effective) 위치"라 부른다. 홉 1 연결은 클라이언트 sidecar가, 홉 2 연결은 gateway가 열므로:
- DR-hop1 풀 → 호출자 sidecar의
outbound|<port>|<subset>|<gateway-fqdn>cluster. 한도는 Envoy별 독립 집행이라 클라이언트 pod당 상한이다.maxConnections는 여유 있게,connectTimeout은 in-cluster 홉이므로 짧게(3s). - DR-hop2 풀 → gateway의
outbound|443||<외부host>cluster. sidecar 시절 pod마다 분산되던 연결이 gateway로 전사 합류하는 지점 — DR 없이 방치하면 Envoy 기본 상한(1,024)에 gateway가 먼저 부딪힌다.maxConnections·tcpKeepalive·maxConnectionDuration의 주 전장 (TCP 처방전 P1).
이 원칙은 실측으로 확인된 것이다(T91): 두 DR을 구분 추적하려고 maxConnections에 서로 다른 식별값을 넣고(DR-hop1=111, DR-hop2=222) 양쪽 프록시의 cluster 설정을 각각 덤프했더니, 111은 클라이언트 sidecar에만, 222는 gateway에만 나타났다 — 확인 절차는 §4. 같은 원칙이 origination 계열(gateway가 외부로 TLS를 새로 시작하는 이중 mTLS 패턴, T90)에서도 동일하게 성립한다.
한 가지 이 패턴 특유의 제약: 홉 2는 TCP 라우트이므로 connectionPool.http.*가 무의미하다. 이중 mTLS(홉 2가 L7)와 달리 여기선 gateway가 요청 단위를 모르므로 http.idleTimeout·maxRequestsPerConnection 같은 다이얼이 적용될 대상이 없다. 커넥션 수명 관리는 tcp.idleTimeout·tcp.maxConnectionDuration·tcp.tcpKeepalive로만 한다. 홉 1도 마찬가지로 TLS(tcp_proxy) 라우트라 tcp.*만 유효하다.
3. 전체 YAML — 표준 배치 한 벌
표준 배치(공유 istio-egressgateway, Service 443 → targetPort 8443, 클라이언트는 mesh-test) 기준. 검증 하네스(T91)는 전용 gateway·다른 포트를 썼으며 원자료는 검증 기록 참조. 레지스트리 도메인이 표준이 아니면(istioctl proxy-config cluster에 보이는 이름 기준) k8s Service host를 그에 맞출 것.
# 1) ServiceEntry — 외부 host 등록 (protocol TLS: gateway가 L7을 파싱하지 않음)
apiVersion: networking.istio.io/v1
kind: ServiceEntry
metadata: { name: partner-api, namespace: istio-system }
spec:
hosts: [api.partner.example.com]
ports:
- { number: 443, name: tls, protocol: TLS }
resolution: DNS
location: MESH_EXTERNAL
---
# 2) Gateway — outer ISTIO_MUTUAL 종단 서버 (protocol TLS — HTTPS 아님, inner는 opaque)
apiVersion: networking.istio.io/v1
kind: Gateway
metadata: { name: egress-partner, namespace: istio-system }
spec:
selector: { istio: egressgateway }
servers:
- port: { number: 8443, name: tls-partner, protocol: TLS } # 컨테이너 포트
hosts: [api.partner.example.com]
tls: { mode: ISTIO_MUTUAL } # client cert 강제 + SPIFFE 검증 + outer만 종단
---
# 3) DR-hop1 — outer mTLS 래핑 + 홉 1 풀 (호출자 sidecar cluster에 컴파일)
apiVersion: networking.istio.io/v1
kind: DestinationRule
metadata: { name: egressgateway-partner, namespace: istio-system }
spec: # exportTo 없음(기본 *) — 소비자 = 모든 클라이언트 ns의 sidecar
host: istio-egressgateway.istio-system.svc.cluster.local # 레지스트리 FQDN
subsets:
- name: partner
trafficPolicy:
portLevelSettings:
- port: { number: 443 } # Service 포트 기준
tls:
mode: ISTIO_MUTUAL # sidecar가 SPIFFE cert 제시
sni: api.partner.example.com # gateway filter chain 매칭 키
connectionPool: # tls와 같은 포트 엔트리에 — 통째 교체 규칙(§5)
tcp:
maxConnections: 1024 # pod당 상한 — 여유 있게
connectTimeout: 3s
tcpKeepalive: { time: 300s, interval: 30s, probes: 3 }
---
# 4) DR-hop2 — tls 블록 없음(raw TCP), 홉 2 풀 전용 (gateway cluster에 컴파일)
apiVersion: networking.istio.io/v1
kind: DestinationRule
metadata: { name: partner-tcp-pool, namespace: istio-system } # gateway ns 배치
spec:
host: api.partner.example.com # ServiceEntry hosts와 정확 일치
exportTo: ["."] # 타 ns sidecar 오적용 차단 (§5 스코프 주의)
trafficPolicy:
connectionPool:
tcp: # http.* 는 TCP 라우트라 무의미 — tcp만
maxConnections: 4096 # 전사 합류 지점 — peak x 2~3 (측정 기반)
connectTimeout: 3s
idleTimeout: 1800s # 채널 최장 유휴보다 길게
maxConnectionDuration: 3600s # 수명 상한 — 재분배·drain 자연 소멸
tcpKeepalive: { time: 300s, interval: 30s, probes: 3 } # FW idle의 1/3
---
# 5) VirtualService — leg-1 tls(SNI 생존) / leg-2 tcp(SNI 소비 후 raw relay)
apiVersion: networking.istio.io/v1
kind: VirtualService
metadata: { name: partner-via-egress, namespace: istio-system }
spec:
hosts: [api.partner.example.com]
gateways: [mesh, istio-system/egress-partner]
tls:
- match:
- gateways: [mesh]
port: 443
sniHosts: [api.partner.example.com] # 앱 ClientHello의 SNI로 매치
route:
- destination:
host: istio-egressgateway.istio-system.svc.cluster.local
subset: partner # -> DR-hop1
port: { number: 443 }
tcp:
- match:
- gateways: [istio-system/egress-partner]
port: 8443
route:
- destination:
host: api.partner.example.com # -> DR-hop2 (tls 없음 = raw forward)
port: { number: 443 }
정렬 지도 — 한 글자 어긋나면 조용히 깨지는 문자열:
외부 host : SE.hosts == GW.servers.hosts == VS.hosts == VS leg-1 sniHosts
== DR-hop1 sni == DR-hop2 host
gw 대상 : DR-hop1 host(레지스트리 FQDN) == VS leg-1 destination.host
subset : DR-hop1 subsets[].name == VS leg-1 destination.subset
포트 체인 : Service 443 -> targetPort 8443 == GW.port == VS leg-2 match port
(DR-hop1 portLevelSettings와 VS leg-1 dest는 Service 포트 443 기준)
4. 검증 절차 — 홉과 프록시를 어긋나게 잡지 말 것
DR 정본 §08의 3단(설정 저장 → cluster 결부 → 실사용)을 홉별로 올바른 pod에서 실행한다. 홉 1은 클라이언트 sidecar에서, 홉 2는 gateway pod에서 — 어긋나게 잡으면 영원히 “미적용"으로 보인다.
# 홉 1 — 클라이언트 sidecar: 풀 + ISTIO_MUTUAL + sni가 subset cluster에 붙었나
istioctl proxy-config cluster deploy/<client> -n mesh-test \
--fqdn istio-egressgateway.istio-system.svc.cluster.local -o json | \
jq '.[] | select(.name | contains("|partner|")) |
{name, max: .circuitBreakers.thresholds[0].maxConnections,
ka: .upstreamConnectionOptions.tcpKeepalive,
sni: .transportSocket.typedConfig.sni}'
# 홉 2 — gateway pod: 풀은 붙고 transportSocket은 "없어야" 한다 (raw TCP의 시그니처)
istioctl proxy-config cluster deploy/istio-egressgateway -n istio-system \
--fqdn api.partner.example.com -o json | \
jq '.[] | {name, max: .circuitBreakers.thresholds[0].maxConnections,
ka: .upstreamConnectionOptions.tcpKeepalive,
transportSocket: (.transportSocket // "ABSENT (raw TCP)")}'
# 경유 증명 — 200이 아니라 이것이 완료 조건
kubectl -n istio-system logs deploy/istio-egressgateway --tail=5 | \
grep 'outbound|443||api.partner.example.com'
실측(T91)에서는 위 절차로 다음을 확인했다. 식별값 111(DR-hop1)은 클라이언트 sidecar의 gateway행 subset cluster에만, 222(DR-hop2)는 gateway의 외부 host cluster에만 나타났다 — 풀이 홉별로 정확히 갈라져 발효된다는 뜻이다. 같은 덤프에서 홉 2 cluster에는 transportSocket(TLS 설정)이 아예 없었고(= raw TCP의 시그니처), E2E 호출에서는 외부 서버가 앱이 보낸 원본 SNI를 그대로 봤으며 클라이언트는 외부 서버의 인증서를 직접 봤다 — 경로 중간의 누구도 TLS를 다시 만들지 않았다는 증거다. gateway listener는 requireClientCertificate: true로 홉 1의 신원 확인을 강제하고 있었다.
5. 함정
- portLevelSettings는 통째 교체. DR-hop1처럼 포트 엔트리를 쓰면 그 포트 cluster는 top-level trafficPolicy를 상속하지 않는다.
tls만 적고connectionPool을 빼먹으면 풀이 기본값으로 회귀 — 트래픽이 타는 포트 엔트리에 전부 재기재 (DR 정본 §05). exportTo: ["."]는 같은 ns 안에서는 안 가린다. T91에서 gateway와 같은 ns의 클라이언트 sidecar에도 DR-hop2가 컴파일됐다(무해 — VS tls 라우트가 트래픽을 먼저 gateway로 우회시킴). 표준 배치(gateway는 istio-system, 클라이언트는 앱 ns)에서는 의도대로 타 ns를 가린다.http.*풀 설정을 넣어도 조용히 무의미. 두 홉 모두 TCP proxy 라우트라 HTTP 커넥션 풀 개념이 없다. “maxRequestsPerConnection이 안 먹어요"는 버그가 아니라 패턴의 귀결 — 요청 단위 다이얼이 필요하면 origination 계열로 가야 한다.- outlierDetection은 다중 IP 목적지일 때만. 단일 IP 외부에 켜면 ejection = 채널 전체 차단 (TCP 처방전, T14).
- 그 밖의 egress 공통 함정 세 가지도 이 패턴에 그대로 적용된다: ① DR host 문자열이 레지스트리와 다르면 에러 없이 통째로 무시된다 — 증상은 “설정했는데 기본값”(
proxy-config cluster에서 DR 결부가 안 보임). ② VS 홉 1 매칭이 어긋나면outboundTrafficPolicy: ALLOW_ANY에선 sidecar가 gateway를 건너뛰고 직접 외부로 나간다 — 호출은 200이라 겉보기 멀쩡하지만 신원 확인이 0겹이 된다(그래서 §4의 “완료 조건 = gateway access log”). ③ SNI 정렬 붕괴(DRsni≠ Gatewayhosts≠ VSsniHosts)는 연결 리셋으로 나타난다. 상세 메커니즘·진단 절차는 이중 mTLS 가이드 §4.
핵심 정리
- 홉 2 “일반 TCP” =
tls.mode를 고르는 게 아니라 생략하는 것. DR 미생성 / tls 없는 풀 전용 DR /DISABLE명시 — 셋 다 동치.tls.mode: tcp는 존재하지 않아 CRD가 거부한다. - connectionPool은 두 홉 모두 유효하고, 발효 위치가 갈린다 — 홉 1 = 호출자 sidecar(pod당 상한), 홉 2 = gateway(전사 합류 상한). origination 계열(T90)과 동일 원칙의 passthrough 실측 확인(T91).
- 이 패턴의 풀은
tcp.*만. 두 홉 다 TCP proxy라http.*는 적용 대상이 없다. - 검증은 홉과 pod를 맞춰서 — 홉 1은 sidecar, 홉 2는 gateway. 홉 2 cluster에
transportSocket이 없는 것이 raw TCP의 시그니처이자 정상이다.
검증 기록 (2026-07-05 · Istio 1.30.0 / k8s 1.30.6)
T91: 전용 egress gateway(주입 템플릿, Service 8443) + mesh 밖 echo 서버(self-signed HTTPS)로 본문 배선을 E2E 재현. 원자료: workloads.yaml · wiring.yaml · dr-mode-tcp-invalid.yaml · run.sh · result.txt · verdict.json
| # | 주장 | 실측 결과 | 판정 |
|---|---|---|---|
| N1 | tls.mode: tcp는 존재하지 않음 |
apply 즉시 CRD validation 거부 — enum DISABLE/SIMPLE/MUTUAL/ISTIO_MUTUAL |
✅ 지지 |
| E1 | inner TLS end-to-end 보존 | 200. 클라이언트가 본 서버 cert = 외부 서버의 self-signed 원본(재종단 없음), 서버가 본 SNI = 앱 ClientHello 원형, clientCertificate: {} |
✅ 지지 |
| P1 | gateway 경유 | 서버가 본 peer IP = gateway pod IP, gateway access log outbound|443||<host> 기록, istio_tcp_*만 생성(L7 지표 없음) |
✅ 지지 |
| C1 | DR-hop1 풀 → 호출자 sidecar | 식별값 max=111·3s·keepalive 300/30/3이 클라이언트 sidecar의 subset cluster에 컴파일 (+SDS default, sni) |
✅ 지지 |
| C2 | DR-hop2 풀 → gateway, TLS 0겹 | 식별값 max=222·keepalive가 gateway의 외부 host cluster에 컴파일, transportSocket 부재 |
✅ 지지 |
| S1 | 홉 1 SPIFFE 강제 | gateway 8443 listener requireClientCertificate: true |
✅ 지지 |
| B1 | exportTo: ["."] 스코프 |
같은 ns 클라이언트 sidecar에도 DR-hop2 컴파일(타 ns만 차단) — §5에 반영 | ✅ 지지 (+정밀화) |
검증 환경 특이사항: ① 이 클러스터는 istiod --domain homelab.local이라 DR-hop1 host를 *.svc.homelab.local로 기재(본문 예시는 표준 cluster.local 기준 — 반드시 istioctl proxy-config cluster에 보이는 레지스트리 이름과 일치시킬 것). ② gateway Envoy 네이티브 per-cluster stats(upstream_cx_total)는 기본 statsMatcher에 걸러져 안 보였고, 사용 증명은 access log + istio_tcp_* 텔레메트리로 대체(출처 미확인 추정 — 설정 증명은 cluster JSON circuitBreakers.thresholds로 충족).
참조
아카이브 내부
- Egress HTTPS 패턴 지도 — 용어 정립·4패턴 CRD 시그니처
- mTLS Passthrough 해부 — 이 패턴의 데이터 경로·CRD 4종 정본 (T07)
- 이중 mTLS — 홉마다 DR 한 벌 — 자매 패턴(origination)·발효 위치 원칙의 출처 (T90)
- DestinationRule 만들기 — host 매칭·병합·검증 3단
- Egress TCP 처방전 · tcpKeepalive 필드 노트 — 값 도출식
출처
- ↗ Istio: DestinationRule reference — ClientTLSSettings.TLSmode — mode enum 4종 (2026-07-05 대조)
- ↗ Istio: DestinationRule reference — ConnectionPoolSettings — tcp/http 설정 적용 범위 (2026-07-05 대조)