---
title: 이중 mTLS Egress — 홉마다 DR 한 벌, 커넥션 설정은 그 홉을 여는 프록시에 박힌다
date: 2026-07-05
type: guide
domain: istio
tags: [egress, mtls, destinationrule, connection-pool]
---
> [!abstract]
> **이중 mTLS** = egress 경로의 두 홉이 각각 별개의 mTLS로 감싸이는 패턴이다.
> 홉 1(앱 sidecar→egressgateway)은 **메시 mTLS(`ISTIO_MUTUAL`)**, 홉 2(egressgateway→외부)는
> gateway가 **사용자 인증서로 시작하는 mTLS(`MUTUAL` + `credentialName`)**. 머릿속에 넣을 한 문장:
> **DestinationRule은 홉마다 한 벌씩 필요하고, 각 벌의 `connectionPool`은 "그 홉의 연결을 여는
> 프록시"의 upstream cluster에 컴파일된다** — 홉 1 설정은 호출자 sidecar에, 홉 2 설정은 gateway에.
> 이 문서는 각 홉의 DR을 해부하고, 커넥션 설정이 어느 Envoy에서 발효되는지, 두 DR이 싸우는
> 고전 함정과 튜닝 도출식까지 정리한다. DR 일반론은
> [DestinationRule 만들기](/docs/istio/egress/destinationrule-fundamentals/),
> 값 도출은 [Egress TCP 처방전](/docs/istio/egress/tcp-tuning/)이 정본.
**대상 환경:** Istio **1.30.0**, sidecar mesh, Helm gateway chart (homelab k8s 1.30.6 기준 컨벤션)
**선행 개념:** egress 2-홉 라우팅([HTTPS passthrough 가이드](/docs/istio/egress/gateway-https/)), DR 병합 규칙([DR 정본](/docs/istio/egress/destinationrule-fundamentals/) §05)
---
## 1. 이중 mTLS의 정체 — 두 홉, 두 mTLS, 두 DR
먼저 용어 정리. 이 문서의 "이중 mTLS"는 [HTTPS over mTLS](/docs/istio/egress/https-over-mtls/)와 **다른 패턴**이다.
| 패턴 | inner/leg-2 TLS의 주체 | gateway가 L7을 보나 | 외부 client cert |
|---|---|---|---|
| HTTPS over mTLS | **앱**이 만든 TLS를 gateway가 안 풀고 통과 | ✗ (이중 봉투, L4만) | 앱이 직접 관리 |
| **이중 mTLS (본 문서)** | **gateway**가 새 mTLS를 시작 (origination) | **✓** (홉 1 종단 후 평문 L7) | gateway가 중앙 관리 |
이중 mTLS에서 앱은 **평문 `http://`로 호출**한다. 요청의 암호화는 인프라가 두 번 입힌다:
1. **홉 1 (sidecar → egressgateway)**: sidecar가 요청을 메시 mTLS(`ISTIO_MUTUAL`)로 래핑. gateway는 이걸 종단하며 호출 워크로드의 SPIFFE 신원을 검증한다 — "누가 나가는가"의 확정 지점.
2. **홉 2 (egressgateway → 외부)**: gateway가 외부 서버가 요구하는 client cert를 제시하며 **새 mTLS를 시작**(`MUTUAL`). 파트너사 상호 TLS 요건을 gateway 한 곳의 인증서로 충족한다 — "무엇을 제시하는가"의 중앙화 지점.
```mermaid
flowchart LR
subgraph mesh["ns: mesh-test"]
APP["app (curl http://...)"] --> SC["sidecar Envoy"]
end
subgraph sys["ns: istio-system"]
GW["istio-egressgateway"]
end
EXT["api.partner.example.com:443"]
SC -->|"hop1: ISTIO_MUTUAL
(mesh cert, SPIFFE)
DR-hop1 = sidecar cluster"| GW
GW -->|"hop2: MUTUAL
(client cert via SDS)
DR-hop2 = gateway cluster"| EXT
```
두 홉은 **암호화 컨텍스트가 완전히 독립**이다 — 인증서 체계도(메시 CA vs 파트너 CA), 검증 대상도(호출자 SPIFFE vs 외부 서버 SAN), 설정 리소스도 다르다. 그래서 **DR이 반드시 두 벌**이고, 이 문서 전체가 그 두 벌의 해부다.
| | DR-hop1 | DR-hop2 |
|---|---|---|
| `host` | `istio-egressgateway.istio-system.svc.cluster.local` (gateway Service FQDN) | `api.partner.example.com` (ServiceEntry host) |
| `tls.mode` | `ISTIO_MUTUAL` (메시 인증서 자동) | `MUTUAL` + `credentialName` (사용자 인증서) |
| 검증 대상 | (Gateway 쪽이) 호출자 SPIFFE | 외부 서버 cert — `sni` + `subjectAltNames` |
| `connectionPool` 발효 위치 | **호출자 sidecar**의 upstream cluster | **gateway**의 upstream cluster |
| 배치 | 클라이언트 ns 또는 istio-system | **gateway와 같은 ns** (credentialName 제약) |
---
## 2. 홉 1 — sidecar → egressgateway (`ISTIO_MUTUAL`)
### 2.1 짝이 맞아야 하는 두 리소스
홉 1 mTLS는 **DR(클라이언트 쪽)과 Gateway(서버 쪽)가 짝**을 이뤄야 성립한다:
- **DR-hop1** `tls.mode: ISTIO_MUTUAL` — sidecar가 자기 SPIFFE client cert를 제시하며 메시 mTLS를 만든다. 인증서·키·CA는 istiod SDS가 자동 공급하므로 경로/secret 지정이 없다.
- **Gateway server** `tls.mode: ISTIO_MUTUAL` — gateway listener가 client cert를 강제(`requireClientCertificate`)하고 mesh CA로 SPIFFE를 검증한 뒤 **종단**한다. 한쪽만 걸면 즉시 깨진다: gateway만 ISTIO_MUTUAL이고 DR이 없으면 sidecar가 평문을 보내 handshake가 거부된다([신원 통제 가이드](/docs/istio/security/egress-mtls-identity-control/) §6 함정 표).
`sni`와 `subjectAltNames`의 역할:
- **`tls.sni`** — sidecar가 만드는 outer ClientHello의 SNI에 **원본 목적지 호스트**를 적재한다. gateway listener가 filter chain(어느 server/채널인가)을 고르는 매칭 키이므로, **DR `sni` == Gateway `hosts[]` == VS 매칭 호스트** 3자 정렬이 깨지면 매칭 실패로 연결이 리셋된다.
- **`subjectAltNames`** — 이 홉에선 "sidecar가 검증하는 gateway의 신원"이다. `ISTIO_MUTUAL`은 검증 컨텍스트를 메시가 자동 구성하므로 보통 생략하고, 명시하면 gateway 서버 cert의 SPIFFE SAN(`spiffe:///ns/istio-system/sa/`)을 핀 고정하는 용도가 된다.
### 2.2 이 홉의 connectionPool은 어디서 발효되나
DR-hop1의 host가 gateway Service이므로, 이 trafficPolicy는 **그 host로 연결을 여는 모든 클라이언트 sidecar**의 upstream cluster(`outbound|443|partner|istio-egressgateway.istio-system.svc.cluster.local`)에 컴파일된다. 즉:
- `tcp.connectTimeout` / `tcp.maxConnections` / `tcp.tcpKeepalive` — **sidecar→gateway TCP 연결**에 적용. 한도는 Envoy 프록시별 독립 집행이라 **"클라이언트 pod당" 상한**이다. pod 하나가 gateway로 수천 연결을 열 일은 드물어 보통 여유가 있지만, 외부 host DR만 튜닝하면 이 홉의 기본 한도가 숨은 병목이 될 수 있다([TCP 처방전](/docs/istio/egress/tcp-tuning/) P1의 레이어 2).
- `http.*`(idleTimeout, maxRequestsPerConnection 등) — 이 패턴은 홉 1이 **HTTP 라우트**(gateway가 종단하므로 L7)라 http 설정도 유효하다. sidecar→gateway 커넥션 풀의 재사용·유휴 정리를 제어한다.
검증은 [DR 정본](/docs/istio/egress/destinationrule-fundamentals/) §08 절차 그대로 — 단 **sidecar pod에서** 본다:
```bash
istioctl proxy-config cluster deploy/sleep -n mesh-test \
--fqdn istio-egressgateway.istio-system.svc.cluster.local -o json | \
jq '.[] | {name, dr: .metadata.filterMetadata.istio.config,
max: .circuitBreakers.thresholds[0].maxConnections,
ka: .upstreamConnectionOptions.tcpKeepalive}'
```
---
## 3. 홉 2 — egressgateway → 외부 (`MUTUAL` + `credentialName`)
### 3.1 client cert 공급 두 방식 — SDS(credentialName) vs 파일 마운트
| 방식 | 선언 | 인증서 전달 경로 | 판단 |
|---|---|---|---|
| **SDS (권장)** | `credentialName: ` | k8s Secret → istiod SDS → gateway Envoy (파드 재시작 불필요, 로테이션 자동 감지) | 표준. 단 **gateway 전용** — sidecar에는 적용 불가 |
| 파일 마운트 (legacy) | `clientCertificate:` / `privateKey:` / `caCertificates:` (파일 경로) | secret을 gateway pod에 volume mount, 경로 하드코딩 | 배포와 결합, 로테이션 시 재기동 — 신규 구성엔 비권장 |
`credentialName`의 두 가지 제약이 설계를 결정한다 ([DR reference](https://istio.io/latest/docs/reference/config/networking/destination-rule/)):
1. **gateway 워크로드 전용**이다 — sidecar는 이 필드를 쓸 수 없다(파일 경로 방식만 가능). "외부 mTLS의 client cert는 gateway에서 중앙 관리"라는 이 패턴의 구도가 여기서 강제된다.
2. **secret은 gateway가 있는 namespace에 있어야** SDS가 찾는다. egressgateway가 istio-system이면 secret도 istio-system.
3. **gateway pod의 ServiceAccount에 secret 읽기 RBAC이 있어야** istiod가 credentialName을 서빙한다 — istiod는 SDS 요청을 SubjectAccessReview로 인가하므로, gateway SA에 해당 ns의 `secrets` `get/watch/list` Role이 없으면 503(`UF`, `TLS_error: Secret is not supplied by SDS`) + istiod 로그 `not authorized to read secrets`로 실패한다. 표준 istio-egressgateway는 Helm chart가 이 Role을 깔아주지만, **주입 템플릿으로 만든 전용 gateway는 직접 추가**해야 한다(T90 실측 — [files/verify/T90/manifest.yaml](files/verify/T90/manifest.yaml)의 `dmtls-egress-sds` Role 참고).
```bash
# 파트너가 발급/승인한 client cert + key + 서버 검증용 CA를 하나의 generic secret으로
kubectl create secret -n istio-system generic partner-client-credential \
--from-file=tls.key=client.partner.example.com.key \
--from-file=tls.crt=client.partner.example.com.crt \
--from-file=ca.crt=partner-ca.crt
# (선택) CRL을 함께 실으려면 --from-file=ca.crl=partner-ca.crl
```
키 이름은 `tls.key`/`tls.crt`/`ca.crt` 고정이다. `ca.crt`가 홉 2에서 **외부 서버 cert를 검증하는 CA**가 된다.
### 3.2 sni · subjectAltNames — 외부 서버를 검증하는 두 다이얼
홉 2의 검증 방향은 홉 1과 반대다 — 이번엔 **gateway가 클라이언트로서 외부 서버를 검증**한다:
- `sni` — 외부 서버로 보내는 ClientHello의 SNI. 외부가 SNI 기반 vhost/LB라면 필수이고, 생략 시 서버가 default cert를 줘 SAN 검증이 어긋날 수 있다.
- `subjectAltNames` — 서버 cert의 SAN이 이 목록과 일치해야 통과. **DNS 하이재킹·잘못된 라우팅으로 엉뚱한 서버에 client cert를 제시하는 사고**를 막는 마지막 잠금이므로 프로덕션에선 명시를 권장한다.
### 3.3 DR-hop2의 배치와 스코프
DR 선택은 **클라이언트 프록시 관점**에서 일어난다: 클라이언트 ns의 DR > 서비스 ns의 DR > 루트 ns(istio-system)의 DR ([DR 정본](/docs/istio/egress/destinationrule-fundamentals/) §06). 홉 2의 "클라이언트"는 **gateway pod**이므로:
- DR-hop2는 **gateway namespace(istio-system)에 배치**한다 — credentialName의 secret 위치 제약과도 일치해 공식 task의 배치가 이것이다.
- istio-system은 루트 ns라 기본(`exportTo` 미지정 = `*`)으로는 mesh 전체에 보인다. sidecar가 이 DR을 집어 외부 host cluster에 `MUTUAL`+credentialName을 입히려다 실패하는 노이즈를 막으려면 **`exportTo: ["."]`로 gateway ns 안으로 좁히는 것**이 위생적이다(트래픽은 어차피 VS가 gateway로 우회시키므로 기능 손실 없음).
### 3.4 portLevelSettings — 한 host의 포트마다 다른 TLS 컨텍스트
한 DR host가 여러 포트를 덮을 때(예: 443은 파트너 mTLS, 8443은 단방향 TLS 테스트 엔드포인트) `portLevelSettings`가 TLS 컨텍스트를 포트 단위로 분리한다:
```yaml
trafficPolicy:
portLevelSettings:
- port: { number: 443 }
tls: { mode: MUTUAL, credentialName: partner-client-credential, sni: api.partner.example.com }
connectionPool: { tcp: { maxConnections: 4096, connectTimeout: 3s } }
- port: { number: 8443 }
tls: { mode: SIMPLE, sni: sandbox.partner.example.com }
connectionPool: { tcp: { maxConnections: 128, connectTimeout: 3s } }
```
> [!warning]
> **portLevelSettings는 상속이 아니라 통째 교체다.** 포트 엔트리에 `tls`만 적으면 그 포트 cluster에서
> top-level `connectionPool`이 **사라진다**(기본값으로 회귀). 트래픽이 실제 타는 포트 엔트리에
> 필요한 필드를 **전부 재기재**하는 것이 규칙 — subset을 쓰면 top→subset→port 3층에서 같은 교체가
> 두 번 일어난다([DR 정본](/docs/istio/egress/destinationrule-fundamentals/) §05의 실측 2단 함정).
### 3.5 이 홉의 connectionPool은 gateway에서 발효된다
DR-hop2의 trafficPolicy는 외부 host의 upstream cluster(`outbound|443||api.partner.example.com`)에 컴파일되는데, 이 cluster로 **실제 연결을 여는 프록시는 gateway**다. 결과적으로:
- 한도(`maxConnections` 등)는 **gateway Envoy에서 집행**된다. sidecar 시절엔 pod마다 분산되던 연결이 gateway로 **전사 합류**하므로, DR 없이 방치하면 Envoy 기본 상한(1,024)에 gateway가 가장 먼저 부딪힌다 — access log flag `UO`, `upstream_cx_overflow` 증가([TCP 처방전](/docs/istio/egress/tcp-tuning/) P1).
- 검증도 **gateway pod에서** 한다: `istioctl proxy-config cluster deploy/istio-egressgateway -n istio-system --fqdn api.partner.example.com ...`. 확인할 pod를 홉과 어긋나게 잡으면(레이어 1 설정을 sidecar에서 찾는 등) 영원히 "미적용"으로 보인다([keepalive 노트](/docs/istio/egress/tcp-keepalive-fields/) §04).
- 인증서·TLS 컨텍스트 변경은 **기존 연결에 소급되지 않는다** — 이미 세워진 커넥션은 옛 client cert로 계속 산다. 수명 상한(`maxConnectionDuration`)이 로테이션 반영 시간을 유계로 만든다(§6).
---
## 4. 고전 함정 — 두 DR이 같은 host를 두고 싸울 때
이 패턴의 장애 대부분은 "DR 두 벌"이라는 구조 자체에서 나온다.
**함정 1 — 한 host에 DR 여러 벌.** 홉 1 정책과 홉 2 정책을 실수로 같은 host에 갈라 쓰면(예: 외부 host에 DR 2벌) 에러가 아니라 **조용한 병합**이 일어난다: subsets는 합집합, top-level trafficPolicy는 **생성시각이 가장 오래된 DR이 승자**, 나머지는 무시. "왜 내 tls 설정이 안 먹지"의 단골 원인이다. 원칙: **host당 DR 1벌** — 홉 1 DR은 gateway Service FQDN을, 홉 2 DR은 ServiceEntry의 외부 host를 host로 가지므로 애초에 겹칠 일이 없게 설계된다([DR 정본](/docs/istio/egress/destinationrule-fundamentals/) §06, T54 실측).
**함정 2 — host 문자열의 조용한 불일치.** DR host는 서비스 레지스트리 문자열과 매칭되며 **실패해도 에러가 없다**. short-name(`istio-egressgateway`)은 DR namespace 기준으로 확장돼 존재하지 않는 host가 되고, 외부 host 오타는 ServiceEntry와 어긋나 그대로 무시된다. 증상은 "설정했는데 기본값" — `proxy-config cluster`의 `dr: null`이 시그니처다.
**함정 3 — 홉 1 매칭 실패는 조용한 우회로 끝난다.** VS mesh 매칭(`gateways: [mesh]`, host/포트)이 어긋나면 트래픽은 gateway로 꺾이지 않고, `ALLOW_ANY`에선 sidecar가 **직접 외부로 나가버린다** — ServiceEntry가 그 포트를 등록하지 않았으면 `PassthroughCluster`로, 등록했으면 SE의 outbound cluster(`outbound|80||api.partner...`)로 직행한다(T90 실측: 후자도 gateway access log 0건, mTLS 0겹으로 동일). 호출은 200이라 겉으론 멀쩡하지만 이중 mTLS는 한 겹도 적용되지 않은 상태다(client cert 미제시 → 파트너 쪽에서 거부되면 그때야 발견). "완료 = 200"이 아니라 **gateway access log 경유 증명**이 완료 조건이고, `REGISTRY_ONLY`로 우회 자체를 막아야 한다([passthrough 가이드](/docs/istio/egress/gateway-https/) §7·§9, T47 실측 — "외부" host가 클러스터 내부 Service로 resolve되는 엣지케이스는 구조적으로 gateway를 못 거친다).
**함정 4 — SNI/host 3자 정렬 붕괴.** DR-hop1 `sni` ≠ Gateway `hosts[]` → gateway filter chain 매칭 실패(연결 리셋). VS의 mesh-측 host ≠ 앱이 부르는 호스트 → 함정 3으로 회귀. 같은 문자열이 여러 리소스에 흩어지는 구조이므로 정렬 지도를 만들어 관리한다(§5 YAML 주석).
**함정 5 — secret 위치/키 이름 불일치.** secret이 gateway namespace에 없거나 키가 `tls.crt`/`tls.key`/`ca.crt`가 아니면 gateway가 cert를 못 찾아 홉 2 handshake가 실패한다. `istioctl proxy-config secret deploy/istio-egressgateway -n istio-system`으로 SDS 로드 여부를 먼저 확인한다.
---
## 5. 전체 YAML — 최소 구성 한 벌
homelab 컨벤션(gateway Service `443 → targetPort 8443`, Gateway CRD는 Envoy가 실제 bind하는 컨테이너 포트 8443 선언 — [신원 통제 가이드](/docs/istio/security/egress-mtls-identity-control/) §4의 포트 모델)을 따른다. 공식 task는 번들 gateway 전제로 443을 선언하니, 자기 환경의 Service/targetPort에 맞춰 정렬할 것.
```yaml
# 0) secret (사전 생성 — §3.1의 kubectl 명령. gateway와 같은 ns 필수)
# 1) ServiceEntry — 외부 host를 레지스트리에 등록 (REGISTRY_ONLY 화이트리스트)
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 } # gateway가 mTLS를 "시작"할 목적지 포트
resolution: DNS
location: MESH_EXTERNAL
---
# 2) Gateway — egress pod에 ISTIO_MUTUAL 종단 서버 (홉 1의 서버 쪽 절반)
apiVersion: networking.istio.io/v1
kind: Gateway
metadata: { name: egress-partner, namespace: istio-system }
spec:
selector: { istio: egressgateway } # gateway pod 라벨과 일치 필수
servers:
- port: { number: 8443, name: https-partner, protocol: HTTPS } # 컨테이너 포트
hosts: [api.partner.example.com] # == DR-hop1 sni == VS hosts (3자 정렬)
tls:
mode: ISTIO_MUTUAL # client cert 강제 + SPIFFE 검증 + 종단
---
# 3) DR-hop1 — sidecar→gateway를 메시 mTLS로 래핑 (호출자 sidecar cluster에 컴파일)
apiVersion: networking.istio.io/v1
kind: DestinationRule
metadata: { name: egressgateway-partner, namespace: istio-system }
spec:
host: istio-egressgateway.istio-system.svc.cluster.local # gateway Service FQDN (외부 host 아님!)
subsets:
- name: partner # labels 없는 subset = 채널별 정책·SNI 앵커
trafficPolicy:
portLevelSettings:
- port: { number: 443 } # Service 포트 기준
tls:
mode: ISTIO_MUTUAL # sidecar가 SPIFFE cert 제시 (Gateway와 짝)
sni: api.partner.example.com # gateway filter chain 매칭 키
connectionPool: # 이 홉 전용 — sidecar pod당 상한
tcp:
maxConnections: 1024 # pod당이라 여유 있게. 기본 벽을 명시로 대체
connectTimeout: 3s # in-cluster 홉 — 빠른 실패
tcpKeepalive: { time: 300s, interval: 30s, probes: 3 } # 유령 연결 정리 (Duration — 단위 필수, §검증 기록)
---
# 4) DR-hop2 — gateway→외부 mTLS origination (gateway cluster에 컴파일)
apiVersion: networking.istio.io/v1
kind: DestinationRule
metadata: { name: partner-originate-mtls, namespace: istio-system } # gateway ns = secret ns
spec:
host: api.partner.example.com # ServiceEntry hosts와 정확 일치
exportTo: ["."] # gateway ns로 한정 — sidecar 오적용 차단 (위생)
trafficPolicy:
portLevelSettings:
- port: { number: 443 }
tls:
mode: MUTUAL # 사용자 인증서 mTLS
credentialName: partner-client-credential # istio-system의 SDS secret
sni: api.partner.example.com # 외부 vhost/LB 매칭
subjectAltNames: [api.partner.example.com] # 서버 SAN 핀 고정 (오라우팅 잠금)
connectionPool: # 이 홉 전용 — 전사 합류 지점이므로 도출식으로
tcp:
maxConnections: 4096 # peak 동시연결 x 2~3 (측정 기반)
connectTimeout: 3s # 외부 장애 시 빠른 실패 (기본 10s)
idleTimeout: 1800s # 채널 최장 유휴보다 길게 — keepalive로는 못 막음
maxConnectionDuration: 3600s # 수명 상한 = cert 로테이션 반영 + 재분배
tcpKeepalive: { time: 300s, interval: 30s, probes: 3 } # FW idle(1800s)의 1/3 (Duration — 단위 필수)
http:
idleTimeout: 900s # HTTP 풀 유휴 정리 (기본 1h)
maxRequestsPerConnection: 1000 # 커넥션 자연 교체 주기 (1은 keepalive off라 과격)
outlierDetection: # 외부가 다중 IP일 때만! 단일 IP면 ejection=전체 차단
consecutive5xxErrors: 5
interval: 30s
baseEjectionTime: 60s
maxEjectionPercent: 50
---
# 5) VirtualService — 두 홉을 잇는 라우팅 (둘 다 http: gateway가 종단하므로 L7 라우트)
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]
http:
- match:
- gateways: [mesh] # 홉 1: 모든 sidecar에서
port: 80 # 앱은 평문 http:// 호출
route:
- destination:
host: istio-egressgateway.istio-system.svc.cluster.local
subset: partner # -> DR-hop1 (ISTIO_MUTUAL + sni 적재)
port: { number: 443 } # gateway Service 포트
- match:
- gateways: [istio-system/egress-partner] # 홉 2: gateway에서 종단 후
port: 8443 # Gateway CRD 서버 포트
route:
- destination:
host: api.partner.example.com # -> DR-hop2 (MUTUAL origination)
port: { number: 443 }
weight: 100
```
**정렬 지도** — 한 글자라도 어긋나면 조용히 깨지는 문자열들:
```
외부 host : SE.hosts == GW.servers.hosts == VS.hosts == DR-hop1 sni
== DR-hop2 host == DR-hop2 sni/subjectAltNames
gw 대상 : DR-hop1 host(egressgateway FQDN) == VS 홉1 destination.host
subset : DR-hop1 subsets[].name == VS 홉1 destination.subset
포트 체인 : Service 443 -> targetPort 8443 == GW.port == VS 홉2 match port
(DR-hop1 portLevelSettings와 VS 홉1 dest는 Service 포트 443 기준)
secret : DR-hop2 credentialName == secret 이름, secret ns == gateway ns
```
---
## 6. 튜닝 표 — long-lived egress mTLS 연결에 실제로 중요한 DR 필드
도출식의 정본은 [TCP 처방전](/docs/istio/egress/tcp-tuning/)·[keepalive 필드 노트](/docs/istio/egress/tcp-keepalive-fields/). 여기선 이중 mTLS 맥락으로 재정렬한다. **입력 3개를 먼저 측정**할 것: 채널 peak 동시연결, 신규 conn/s, 경로상 FW/NAT/LB의 idle timeout.
| 필드 | 어느 홉 DR에 | 도출 규칙 | 왜 (메커니즘) |
|---|---|---|---|
| `tcp.tcpKeepalive.time` | 홉 2 (필요시 홉 1도) | **FW/NAT idle timeout의 1/3 이하** (예: 1800s → 300) | 중간장비가 유휴 세션을 조용히 버리면 half-open — 다음 요청이 RST. probe(빈 ACK)의 왕복이 세션 타이머를 갱신. 1/3은 probe 유실 1~2회를 견디는 여유 |
| `tcp.tcpKeepalive.interval`/`probes` | 〃 | 30s × 3 (= 90s 내 사망 판정) | 죽은 상대 감지 전용 다이얼 — 커널 기본(75s×9≈11분)이면 유령 연결이 maxConnections 슬롯을 점유 |
| `tcp.idleTimeout` | 홉 2 | 채널 최장 유휴 간격보다 **길게** (기본 1h) | **keepalive probe는 데이터가 아니라 이 타이머를 리셋하지 못한다** — "keepalive 넣었는데 정확히 1시간마다 끊겨요"의 정체(T16 실측) |
| `tcp.connectTimeout` | 홉 2: 3s / 홉 1: 3s | 기본 10s는 외부 장애 시 너무 관대 | 파트너 장애 때 gateway 스레드가 연결 수립에 매달리는 시간 = 장애 전파 시간 |
| `tcp.maxConnections` | 홉 2: peak×2~3 / 홉 1: pod당 여유 | 측정 기반. 무한정 금지 | gateway는 전사 합류 지점 — DR 없인 Envoy 기본 1,024 벽(`UO` flag). 반대로 너무 크면 외부 1곳 지연이 gateway 메모리/FD로 전이(격벽 상실) |
| `tcp.maxConnectionDuration` | 홉 2 | 1h 안팎 (재연결 민감 채널은 제외/연장) | 연결 수명 상한. ① scale-out 후 재분배 유도 ② **client cert 로테이션 반영** — TLS 컨텍스트는 신규 handshake에만 적용되므로 상한이 없으면 옛 cert 연결이 무기한 잔존 ③ drain 시 자연 소멸 |
| `http.maxRequestsPerConnection` | 홉 2 | 수백~수천. **1은 keepalive 비활성화라 과격** | 요청 수 기준의 커넥션 자연 교체 — maxConnectionDuration의 보조 다이얼 |
| `http.idleTimeout` | 홉 1·2 | 기본 1h — 풀 회전 주기에 맞게 | 유휴 HTTP 커넥션 정리. 활성 요청 없음 기준 |
| `outlierDetection` | 홉 2 | **다중 IP 목적지일 때만** | 단일 IP 외부에 켜면 ejection = 그 채널 전체 차단(T14 실측) |
> [!tip]
> 값 변경 후엔 반드시 **비소급 원칙**을 기억할 것 — 소켓 옵션·TLS 컨텍스트는 연결 생성 시 1회 적용이라
> 기존 연결은 옛 설정으로 산다. `maxConnectionDuration`이 있으면 자연 교체되고, 없으면 rollout
> restart가 필요하다. 검증 절차(설정 저장 → cluster 결부 → stats → 소켓 `ss -tno`)는
> [keepalive 노트 §04](/docs/istio/egress/tcp-keepalive-fields/)가 정본이며, **홉 1은 sidecar에서,
> 홉 2는 gateway pod에서** 확인한다.
---
## 핵심 정리
- **이중 mTLS = 홉마다 독립된 mTLS 두 겹.** 홉 1은 메시 mTLS(`ISTIO_MUTUAL`, 호출자 SPIFFE 검증), 홉 2는 gateway가 시작하는 사용자 인증서 mTLS(`MUTUAL` + `credentialName`). gateway가 홉 1을 종단하므로 [HTTPS over mTLS](/docs/istio/egress/https-over-mtls/)와 달리 **L7이 보인다**.
- **DR은 두 벌, host가 다르다.** DR-hop1 host = gateway Service FQDN(+subset·sni), DR-hop2 host = ServiceEntry 외부 host. 같은 host에 DR이 겹치면 조용한 병합(최고참 승)으로 한쪽이 무시된다.
- **connectionPool은 "그 홉의 연결을 여는 프록시"에서 발효된다.** 홉 1 설정 = 호출자 sidecar의 cluster(pod당 상한), 홉 2 설정 = gateway의 cluster(전사 합류 — 한도·keepalive·수명 상한의 주 전장).
- **credentialName은 gateway 전용 + secret은 gateway ns.** 키 이름 `tls.crt`/`tls.key`/`ca.crt` 고정, `ca.crt`가 외부 서버 검증 CA. 파일 마운트 방식은 legacy.
- **portLevelSettings·subset은 통째 교체.** 포트 엔트리에 `tls`만 적으면 그 포트 cluster의 connectionPool이 기본값으로 회귀 — 트래픽이 타는 포트에 전부 재기재.
- **200은 완료가 아니다.** 홉 1 매칭이 깨지면 `ALLOW_ANY`에선 sidecar가 조용히 직접 나간다(이중 mTLS 0겹). gateway access log 경유 증명 + `REGISTRY_ONLY`까지가 완료 조건.
---
## 검증 기록 (2026-07-05 · Istio 1.30.0 / k8s 1.30.6)
T90: 전용 egress gateway(주입 템플릿 `inject.istio.io/templates: gateway`, 테스트 ns) + client cert를 강제하는 실제 파트너(nginx `ssl_verify_client on`, mesh 밖)로 본문 배선을 E2E 재현. 원자료: [manifest.yaml](files/verify/T90/manifest.yaml) · [run.sh](files/verify/T90/run.sh) · [result.txt](files/verify/T90/result.txt) · [verdict.json](files/verify/T90/verdict.json)
| # | 주장 | 실측 결과 | 판정 |
|---|---|---|---|
| G1 | 홉 1 = `ISTIO_MUTUAL` (DR-hop1 + Gateway server 짝) | client 사이드카 cluster에 TLS transport socket(sni=외부 host, 메시 SDS `default`) 컴파일, gateway가 종단 후 L7 라우팅. `curl http://` → 200 | ✅ 지지 |
| G2 | 홉 2 = `MUTUAL` + `credentialName` (secret은 gateway ns, `tls.crt/tls.key/ca.crt`) | secret → SDS 2리소스(`kubernetes://` client cert, `kubernetes://-cacert` 검증 CA)로 컴파일. 파트너 응답이 `verify=SUCCESS, dn=CN=egress-client` 에코 = client cert 제시·검증 증명. `subjectAltNames`도 exact 매처로 컴파일 | ✅ 지지 (+보강: §3.1-3 — 전용 gateway는 SA에 secrets RBAC 필수) |
| G3 | connectionPool은 "그 홉을 여는 프록시"에 발효 (홉 1 = 호출자 sidecar, 홉 2 = gateway) | 홉 1 값(max 77, 3s, keepalive 300/30/3)은 **client 사이드카**의 `outbound\|8443\|partner\|...` cluster에, 홉 2 값(max 83, keepalive)은 **gateway**의 `outbound\|443\|\|api.partner.example` cluster에 각각 컴파일 (`istioctl proxy-config cluster` 양쪽 확인) | ✅ 지지 |
| G3-yaml | §5 YAML `tcpKeepalive: { time: 300, interval: 30 }` | CRD validation 즉시 거부(`must be of type string`) — Duration 필드는 `300s`/`30s` 문자열 필수 | 🔬 실측 반증 — 본문 교정 (§5 두 곳) |
| G4 | 함정 3: `ALLOW_ANY`에서 VS mesh 매칭 부재 시 조용한 우회 | VS mesh 라우트 제거 → gateway access log 0건 증가(미경유), 트래픽은 사이드카에서 직접 외부로(nginx가 본 peer = client pod IP, mTLS 0겹, 평문이 TLS 포트에 도착해 400) | ✅ 지지 (+정밀화: SE가 그 포트를 등록했으면 PassthroughCluster가 아닌 SE cluster로 직행 — 함정 3에 반영) |
검증 환경 특이사항: ① 전용 gateway가 istio-system 밖이라 credentialName SDS에 SA RBAC(Role `secrets get/watch/list`)이 추가로 필요했다(없으면 503 `UF` + `Secret is not supplied by SDS` — §3.1-3으로 본문 보강). ② 이 클러스터는 검증 시점부터 istiod `--domain homelab.local`이라 gateway Service의 레지스트리 이름이 `dmtls-egress.istio-vt-t90.svc.homelab.local`이었다 — DR-hop1 host는 반드시 `istioctl proxy-config cluster`에 보이는 레지스트리 이름과 일치시켜야 하며, 본문 예시의 `svc.cluster.local`은 표준(istiod 기본 도메인) 환경 기준이다. ③ 음성 대조로 DR-hop2를 SIMPLE(+CA)로 바꾸면 파트너가 정확히 400 `No required SSL certificate was sent`(nginx error log `client sent no required SSL certificate`)를 반환해, 200의 전제가 실제 client cert 검증이었음을 교차 증명했다.
---
## 참조
**아카이브 내부**
- [HTTPS over mTLS (ISTIO_MUTUAL) 정본](/docs/istio/egress/https-over-mtls/) — 앱 TLS를 보존하는 자매 패턴과의 경계
- [Egress 신원 기반 통제](/docs/istio/security/egress-mtls-identity-control/) — 홉 1 신원 위에 올리는 AuthorizationPolicy·포트 모델의 출처
- [DestinationRule 만들기](/docs/istio/egress/destinationrule-fundamentals/) — host 매칭·병합 규칙·검증 3단의 정본
- [Egress TCP 처방전](/docs/istio/egress/tcp-tuning/) — P1~P5 문제별 connectionPool 도출식
- [tcpKeepalive 필드 노트](/docs/istio/egress/tcp-keepalive-fields/) — time/interval/probes 커널 매핑·검증 절차
- [Egress gateway HTTPS 가이드](/docs/istio/egress/gateway-https/) — 경유 증명·REGISTRY_ONLY·우회 함정
## 출처
- ↗ [Istio: Egress Gateways with TLS Origination](https://istio.io/latest/docs/tasks/traffic-management/egress/egress-gateway-tls-origination/) — mTLS origination 공식 task (secret 생성 위치·Gateway/DR/VS 원형·§3·§5의 근거, 2026-07-05 대조)
- ↗ [Istio: DestinationRule reference](https://istio.io/latest/docs/reference/config/networking/destination-rule/) — ClientTLSSettings(credentialName gateway 전용·secret ns 제약·subjectAltNames), portLevelSettings 비상속, connectionPool 기본값(connectTimeout 10s·idleTimeout 1h·maxRequestsPerConnection 0=무제한) (2026-07-05 대조)
- ↗ [Istio: Understanding TLS Configuration](https://istio.io/latest/docs/ops/configuration/traffic-management/tls-configuration/) · [Gateway reference](https://istio.io/latest/docs/reference/config/networking/gateway/) — ISTIO_MUTUAL 종단 의미론 (연결 문서의 검증 기록 경유)