---
type: src
tags: [istio, egress, passthrough, monitoring, graceful-shutdown, operations]
created: 2026-06-07
---
# Egress Gateway 운영 가이드 — passthrough + ISTIO_MUTUAL (출처: LLM 답변 + Istio 1.30 공식 문서)
> [!abstract] 대상 구조
> 앱이 HTTPS로 직접 호출(end-to-end TLS 유지) + 게이트웨이는 PASSTHROUGH(복호화 안 함) + proxy→egress 구간은 ISTIO_MUTUAL로 mTLS. **결론부터:** 이 게이트웨이가 보는 것은 "암호화된 장기 TCP 스트림" 하나뿐이고, 모니터링(L4+SNI만)·운영 함정(연결을 소수 노드에 모음)·graceful shutdown(요청이 아니라 연결이 닫히길 기다림)이 전부 이 한 사실에서 연역된다. 이 문서는 그 연역을 따라간다.
>
> **대상환경** Istio 1.30 / `networking.istio.io/v1` · **대상독자** egress를 운영하는 DevOps/SRE · **범위** 관측·운영함정·종료. 구조의 *조립*은 [구조 정본](gw__src-egress-https-over-mtls.html)에 위임 · **선행개념** [egress gateway 기본 구성](gw__src-egress-gateway.html)
---
## 00. 배경 — 왜 이 게이트웨이는 운영이 다른가
일반 게이트웨이(ingress, 또는 TLS를 종단하는 egress)는 트래픽을 **복호화해서** 안을 본다. HTTP method·path·status code가 다 보이니, 모니터링은 `istio_requests_total`로 에러율을 보고, 정책은 path 기반 라우팅을 걸고, graceful shutdown은 "in-flight **요청**이 끝나길" 기다린다. 이 모든 운영 상식이 **L7이 보인다는 전제** 위에 서 있다.
그런데 이 구조는 그 전제를 의도적으로 깬다. 앱이 외부와 **end-to-end TLS**를 맺고(게이트웨이가 중간에서 복호화하면 그 보장이 깨지므로), 게이트웨이는 **PASSTHROUGH** — 암호문 봉투를 뜯지 않고 그대로 중계한다. 왜 그렇게 하나: 외부 파트너에 대한 기밀성을 게이트웨이가 신뢰 경계에서 빼앗지 않으면서도, egress를 **소수 노드로 강제 경유**시켜 출구 IP를 고정(파트너 방화벽 allowlist)하고 중앙에서 관측·통제하려는 것이다. proxy→egress 안쪽 한 구간만 ISTIO_MUTUAL로 감싸 메시 내부 신원을 입힌다.
이 절충의 대가가 바로 운영의 차이다. 게이트웨이는 자기가 나르는 트래픽의 내용을 **원리적으로** 알 수 없다 — 복호화를 안 하기로 설계했으니까. 그래서 L7에 기대던 운영 상식이 전부 한 단계 내려가야 한다. 무엇이 어떻게 내려가는지가 이 문서의 전부다.
---
## 01. 핵심 — "암호화된 장기 TCP 스트림" 하나에서 모든 게 따라 나온다
> [!note] 머릿속 앵커 (이거 하나만)
> **게이트웨이 입장에서 트래픽은 요청이 아니라 연결이다.** 외부 TLS를 안 풀기 때문에 L7(HTTP)이 없고, 한 번 맺힌 암호화 TCP 스트림이 오래 살아 있다. 이 그림에서 세 가지 운영 특성이 기계적으로 연역된다 — ①관측은 L4+SNI만 ②연결을 소수 노드에 모으니 연결 단위 자원이 병목 ③종료는 연결이 닫히길 기다림.
세 갈래로 펼쳐 보면:
```mermaid
flowchart LR
ANCHOR["Gateway sees only:
encrypted long-lived TCP stream
(no L7 / connection not request)"]
ANCHOR --> M["Monitoring
L4 metrics + SNI only
L7 must come from app"]
ANCHOR --> O["Operations
connections pinned to few nodes
→ SNAT / NAT-idle / SPOF"]
ANCHOR --> S["Shutdown
wait for connection close
not request completion"]
```
세 갈래의 **공통 뿌리**가 하나(앵커)라는 게 핵심이다. 아래 섹션은 각 갈래를 "앵커에서 왜 이렇게 되는가"로 전개한다. 갈래마다 디테일이 다르지만, 막힐 때마다 앵커로 돌아오면 답이 나온다.
---
## 02. 갈래 1 — 모니터링: L7이 없으니 관측이 한 단계 내려간다
앵커에서 곧장 따라 나오는 제약을 한 줄로: **게이트웨이에서는 HTTP 메트릭(요청 수, 상태 코드, 지연)이 나오지 않는다.** 외부 TLS를 종단하지 않으니 Envoy가 그 트래픽을 TCP로만 인식해서다. 그래서 게이트웨이 메트릭은 전부 `istio_tcp_*` 계열이다. "요청이 없고 연결만 있다"가 메트릭 이름표에 그대로 박힌다.
### 게이트웨이에서 나오는 메트릭 (L4)
| 메트릭 | 의미 |
|---|---|
| `istio_tcp_connections_opened_total` | 열린 연결 수 — "어느 외부로 몇 번 연결했나" |
| `istio_tcp_connections_closed_total` | 닫힌 연결 수 — opened와의 차이로 현재 활성 연결 추정 |
| `istio_tcp_sent_bytes_total` / `istio_tcp_received_bytes_total` | 송수신 바이트 — 트래픽 양·이상 급증 탐지 |
비-HTTP egress 트래픽의 표준 관측이 바로 이 TCP 메트릭이다. 게이트웨이 워크로드를 기준으로 집계하면 "egress 게이트웨이를 통과한 전체 외부 연결"을 중앙에서 볼 수 있다.
```promql
# 외부 호스트별 신규 연결률 (source = egress 게이트웨이)
sum(rate(istio_tcp_connections_opened_total{
source_workload="istio-egressgateway"
}[5m])) by (destination_service_name)
# 외부 호스트별 송신 바이트율
sum(rate(istio_tcp_sent_bytes_total{
source_workload="istio-egressgateway"
}[5m])) by (destination_service_name)
# 현재 활성 연결 추정 (opened - closed 누적)
sum(istio_tcp_connections_opened_total{source_workload="istio-egressgateway"})
- sum(istio_tcp_connections_closed_total{source_workload="istio-egressgateway"})
```
> [!warning] opened−closed의 한계
> `*_opened_total`/`*_closed_total`은 **monotonic counter**라 Pod 재시작·카운터 리셋 시 두 합의 차가 음수나 과대값으로 튄다. 또 두 합을 다른 라벨 집합으로 묶으면 매칭이 어긋난다. **정밀한 활성 연결**은 게이지를 직접 보는 편이 정확하다 — Prometheus(15090)에서는 `envoy_cluster_upstream_cx_active`(상류 기준)·`envoy_http_downstream_cx_active`(하류 기준), Envoy admin /stats raw 이름으로는 `upstream_cx_active`·`downstream_cx_active`다(이 raw 이름으로 PromQL을 던지면 매칭이 안 된다). opened−closed는 어디까지나 "추세" 용도로만 쓴다.
### SNI는 어디서 보나 — TCP access log
메트릭은 "몇 개·몇 바이트"를 세지만 "이 연결 하나가 어디로 갔나"는 못 짚는다. 연결 단위 추적·감사는 **access log**가 정확하다. 그런데 여기서도 앵커가 작동한다 — passthrough 구간의 로그는 HTTP가 아니라 TCP 포맷이라, L7 필드 대신 다음 L4 필드가 핵심이다.
| access log 필드 | passthrough에서의 의미 |
|---|---|
| `requested_server_name` | **SNI** — 목적지 외부 호스트명. passthrough에서 "어디로 갔나"의 핵심 단서 |
| `upstream_host` | 실제로 연결된 외부 IP:port |
| `bytes_sent` / `bytes_received` | 이 연결의 송수신 바이트 |
| `duration` | 연결 지속 시간(ms) |
| `response_flags` | 실패 사유 코드(UF/UH/UC 등) — L4 레벨 |
SNI가 "어디로 갔나"의 유일한 단서인 이유: TLS handshake의 ClientHello는 **평문**으로 SNI(목적지 호스트명)를 싣는다. 그래서 게이트웨이가 암호 본문은 못 봐도 **봉투 겉면의 SNI는 읽을 수 있다.** 반대로 HTTP access log의 `method`·`path`·`response_code`는 **여기 없다** — 암호문이라 Envoy가 채울 수 없다. access log는 meshConfig에서 켠다.
```yaml
apiVersion: install.istio.io/v1alpha1
kind: IstioOperator
spec:
meshConfig:
accessLogFile: /dev/stdout
# 기본 포맷에 requested_server_name(SNI), upstream_host, response_flags 포함
```
### 그럼 L7(상태 코드·지연)은 어디서? — 책임 주체 분리
게이트웨이에서 못 보는 L7은 **앱 쪽**에 있다. 앵커가 말하는 "L7 없음"은 게이트웨이 한정이고, TLS 종단 주체인 앱은 응답 코드·지연을 안다. 그래서 이 구조의 관측은 **두 곳을 합치는 것**이 정석이다 — 관측 책임이 L4(게이트웨이)와 L7(앱)으로 쪼개진다.
| 관측 질문 | 어디서 | 무엇으로 |
|---|---|---|
| "어느 외부로 얼마나 나갔나" (감사·통제·이상탐지) | **게이트웨이** | `istio_tcp_*` + TCP access log(SNI) |
| "그 호출이 200이었나, 얼마나 느렸나" (L7 SLO) | **앱** | 앱 계측(OpenTelemetry/APM), 앱 사이드카는 이 구간 L4만 |
### proxy→egress 구간(ISTIO_MUTUAL)은 어떻게 확인하나
앵커의 "안쪽 한 구간만 mTLS"가 실제로 걸렸는지는 메트릭이 아니라 **config 덤프**로 확인한다(런타임 트래픽이 아니라 Envoy가 받은 설정을 직접 본다). 게이트웨이로 가는 cluster의 transportSocket이 mTLS인지 본다.
```bash
# proxy(앱 사이드카) 기준: 게이트웨이로 가는 cluster의 TLS 컨텍스트
istioctl proxy-config cluster deploy/myapp \
--fqdn istio-egressgateway.istio-system.svc.cluster.local -o json \
| grep -A8 transportSocket
# 게이트웨이 기준: 외부로 나가는 cluster (passthrough라 originate TLS 없음 확인)
istioctl proxy-config cluster deploy/istio-egressgateway -n istio-system \
--fqdn api.partner.example.com -o json | grep -A5 transportSocket
```
**ISTIO_MUTUAL이 걸린 안쪽 cluster의 기대 출력** — `transportSocket.name`이 `envoy.transport_sockets.tls`이고, SDS로 워크로드 인증서(`default`)와 루트(`ROOTCA`)를 받아온다.
```json
"transportSocket": {
"name": "envoy.transport_sockets.tls",
"typedConfig": {
"commonTlsContext": {
"tlsCertificateSdsSecretConfigs": [{ "name": "default" }],
"combinedValidationContext": {
"validationContextSdsSecretConfig": { "name": "ROOTCA" }
}
}
}
}
```
**대비 — passthrough 외부 cluster**: 게이트웨이가 외부 TLS를 originate하지 않으므로 위 `transportSocket` 블록이 **아예 없거나** `raw_buffer`로 잡힌다(평문 봉투로 암호문 TCP를 그대로 중계). 즉 "안쪽은 mTLS, 바깥은 평문 봉투"라는 앵커가 이 두 출력의 차이로 그대로 입증된다.
### 대시보드·알람으로 무엇을 걸 수 있나
L7이 없어도 L4만으로 실무 알람은 충분히 만들 수 있다. 단, "에러율"이 아니라 "연결 실패율"이 기준이 된다 — 앵커의 직접적 귀결이다.
- **미등록 목적지로의 시도** — `REGISTRY_ONLY` 환경에서 BlackHole로 떨어지는 연결(`response_flags`에 차단 흔적). 평소 0이어야 하므로 0 초과 시 알람.
- **연결 실패율 급증** — `response_flags` 기준 UF/UH/UC 비율. 외부 파트너 장애·인증서 문제·NAT 고갈의 1차 신호.
- **SNI별 트래픽 이상** — 평소 안 보이던 `requested_server_name` 출현(데이터 유출 의심), 또는 특정 호스트 바이트 급증.
- **활성 연결 수 포화** — opened−closed가 커넥션 풀 한도에 근접(아래 갈래 2의 SNAT/conntrack과 연결).
> [!info] 모니터링에서 자주 놓치는 것
> - **SNI는 클라이언트(앱)가 채워 넣는 값**이라 위조 가능하다 — 모니터링·감사 근거로는 쓰되, "이 SNI니까 안전하다"는 보안 판단은 약하다. 진짜 통제는 목적지 IP allowlist(Cilium·방화벽)에 둬야 한다.
> - **앱 사이드카도 이 구간에선 L7을 못 본다** — 앱→외부가 end-to-end TLS라 사이드카 역시 암호문만 본다. 그래서 "사이드카 메트릭으로 L7 보겠지"는 오해이고, L7은 반드시 **앱 애플리케이션 계측**에서 나와야 한다.
> - `istio_requests_total` 같은 L7 메트릭이 게이트웨이에서 **0으로 나오는 것은 정상** — 장애가 아니라 passthrough의 당연한 결과라, 이걸 SLO 대시보드의 빈 패널로 두면 오해를 부른다. 패널 자체를 TCP 메트릭으로 바꿔야 한다.
---
## 03. 갈래 2 — 운영 함정: 연결을 소수 노드에 모으면 연결 단위 자원이 병목
앵커의 두 번째 면: 게이트웨이가 다루는 건 **장기 연결**이고, egress를 강제하려면 그 연결을 **소수 노드로 모은다**. 이 두 성질이 곱해지면 "연결 단위로 세는 자원"이 전부 병목 후보가 된다. 아래 함정 다섯은 모두 이 한 문장의 파생이다 — **부딪히는 순서는 좁은 깔때기부터: Envoy 1,024(함정 0) → 커널 포트 28K(함정 1) → conntrack 26만.** 산술·완화 운영값 전체는 [TCP 병목 정본](gw__src-egress-tcp-bottlenecks.html)에 따로 박아 두었다.
### 0. Envoy cluster 연결 상한 1,024 — 커널보다 먼저 부딪히는 벽
커널 포트보다 앞에, Envoy 레벨에 더 좁은 병목이 있다. Envoy는 **목적지(cluster)별 동시 upstream 연결 기본 상한이 1,024**다. sidecar 시절엔 절대 안 보이던 값이다 — pod 하나가 한 목적지로 1,024개를 열 일이 없으니까. 게이트웨이에선 **전사 트래픽이 외부 cluster 하나로 합쳐지면서** 가장 먼저 부딪히는 벽이 된다.
- **징후** — 초과분 즉시 거부, access log flag `UO`, `upstream_cx_overflow` 카운터 증가. 클라이언트 증상은 AuthorizationPolicy 거부와 똑같은 reset류라 **flag 없이는 구분 불가**.
- **완화** — 외부 호스트용 DestinationRule에 `connectionPool.tcp.maxConnections`를 측정된 peak의 2~3배로 명시. 이건 성능 튜닝이 아니라 **목적지별 cluster 격벽**이기도 하다(한 외부 기관의 응답 지연이 게이트웨이 전체 FD/메모리로 전이되는 걸 막는 칸막이).
### 1. SNAT 소스 포트 고갈 — 가장 현실적인 한계
egress를 소수 노드로 모으면, 그 노드의 출구 IP 하나가 모든 외부 연결의 source가 된다. TCP 연결은 (source IP, source port, dest IP, dest port) 4-tuple로 구분되는데, source IP가 고정이고 외부 목적지(dest IP:port)가 소수면 **쓸 수 있는 source port(약 28K~64K)가 병목**이 된다. 동시 연결이 그 수에 근접하면 새 연결이 실패한다. 이게 "연결 단위 자원이 병목"의 가장 직접적 사례다.
- **징후** — 간헐적 연결 실패, `TIME_WAIT` 폭증(`ss -s`), conntrack 사용률 상승(`conntrack -C` 대 `nf_conntrack_max`).
- **완화** — 출구 IP 추가(노드/IP 늘려 source 다양화), `maxRequestsPerConnection`으로 연결 재사용 강제, `net.netfilter.nf_conntrack_max` 상향, 연결 풀 한도 설정.
### 2. 유휴 연결이 조용히 끊긴다 — NAT/방화벽 idle timeout
장기 연결(스트리밍, gRPC, DB 커넥션 풀)은 중간 NAT·방화벽의 idle timeout(보통 5~10분)에 걸려 **한쪽 모르게 끊긴다.** 끊긴 줄 모르고 데이터를 보내려다 reset을 받는다. "연결이 오래 산다"는 앵커의 성질이 정확히 역으로 물어버리는 지점이다. TCP keepalive로 연결을 살아있게 유지하는 게 표준 대응이다.
```yaml
apiVersion: networking.istio.io/v1
kind: DestinationRule
metadata:
name: egressgateway-partner
namespace: istio-system
spec:
host: istio-egressgateway.istio-system.svc.cluster.local
subsets:
- name: partner
trafficPolicy:
connectionPool:
tcp:
tcpKeepalive: # 중간 NAT idle timeout 보다 짧게
time: 60s # 유휴 60s 후 keepalive 시작
interval: 20s # 20s 간격 probe
probes: 5
portLevelSettings:
- port: { number: 15443 } # PASSTHROUGH 443과 분리된 ISTIO_MUTUAL 전용 포트
tls: { mode: ISTIO_MUTUAL, sni: api.partner.example.com }
```
핵심: `tcpKeepalive.time`을 **경로상 가장 짧은 NAT idle timeout보다 작게** 설정해야 연결이 죽기 전에 살린다.
단, **keepalive가 못 막는 절단이 하나 있다 — Envoy 자신의 `idleTimeout`(tcp_proxy 기본 1h).** keepalive probe는 데이터가 아니라서 Envoy의 idle timer를 리셋하지 않는다. 역할이 다른 두 장치다: keepalive는 *중간장비*(패킷만 보면 됨)를 깨어 있게 하고, `idleTimeout`은 Envoy 자신의 기준이라 채널의 최장 유휴 간격보다 길게 **직접** 설정해야 한다. "keepalive 넣었는데 정확히 1시간마다 끊긴다"의 정체가 이것 — 재현 절차는 [재현 랩 §4](gw__guide-egress-tcp-failure-reproduction.html).
> [!warning] 안쪽 mTLS leg은 PASSTHROUGH 443과 다른 포트(15443)를 써야 한다
> 이 구조의 게이트웨이는 이미 **443에서 PASSTHROUGH 서버**를 돌린다. 같은 443에 ISTIO_MUTUAL(종단) 서버를 겹쳐 올리면 listener의 filter-chain merge가 충돌해(`filter_chain_not_found`) 연결이 끊긴다. 그래서 proxy→egress 안쪽 mTLS는 egress Service의 전용 tls 포트 **15443**으로 보낸다(`portLevelSettings.port.number: 15443`). 정본 패턴·실험 매니페스트는 [구조 정본 — HTTPS over mTLS](gw__src-egress-https-over-mtls.html) 참조.
### 3. 노드 핀닝 = 단일 장애점 만들기
외부행을 소수 egress 노드로 모으는 순간(앵커가 요구하는 바로 그 집중), 그 노드들이 전체 외부 통신의 SPOF가 된다. 노드 가용성 $p$, 노드 수 $N$, 독립 장애 가정에서 경로 가용성은:
$$A_{\text{sys}} = 1 - (1-p)^{N}, \qquad D_{\text{year}} = (1 - A_{\text{sys}}) \times 525{,}600 \ \text{min}$$
$N=1$은 가용성이 노드 하나와 같아 운영 금지에 가깝고, 최소 2~3개 + AZ 분산이 권장이다. 단, 노드를 늘려도 **공통 NAT 게이트웨이·ToR 스위치·방화벽 정책**을 공유하면 그게 진짜 SPOF라 이 수식은 거짓 위안이 된다 — 독립성이 깨지는 공통 요소부터 점검해야 한다.
### 4. mTLS 인증서는 두 종류, 만료가 다르다
앵커는 mTLS가 한 군데(proxy→egress, ISTIO_MUTUAL)뿐임을 못박는다. 이건 **Istio CA가 발급하는 메시 내부 인증서**로 기본 24시간 수명·자동 로테이션이라 대개 신경 쓸 일이 없다. 반면 **외부 파트너와의 TLS 인증서는 앱이 책임**지므로(게이트웨이가 아니라 — 게이트웨이는 그 봉투를 안 푸니까), 그 만료·갱신은 게이트웨이 운영 밖이다. "인증서 만료"를 디버깅할 때 어느 쪽 인증서인지부터 구분해야 헛다리를 안 짚는다.
> [!info] 운영에서 자주 놓치는 것
> - **이 구조에서 게이트웨이는 L7 정책(경로 기반 라우팅, 헤더 조작, HTTP 레벨 재시도)을 걸 수 없다** — 암호문이라서. 재시도·타임아웃도 L4(연결) 수준만 가능하다. "게이트웨이에서 특정 path만 차단" 같은 요구가 나오면 그건 이 구조로는 불가능하고, 종단(가로채기)이나 앱 레벨로 가야 한다.
> - **REGISTRY_ONLY를 켜면 평소 안 보이던 외부 의존성(로그 수집, 메트릭 푸시, OCSP/CRL 인증서 검증, 컨테이너 레지스트리)이 한꺼번에 막힌다** — 전역 적용 전에 네임스페이스 단위 audit으로 트래픽 인벤토리부터 확보해야 한다.
> - **passthrough라도 egress 강제는 게이트웨이가 아니라 네트워크 계층(Cilium egress 정책 + egress 노드만 NAT)이 만든다** — 게이트웨이로 라우팅했다고 우회가 막히는 게 아니다.
---
## 04. 갈래 3 — Graceful shutdown: 요청이 아니라 연결이 닫히길 기다린다
egress 게이트웨이 Pod가 종료되는 상황은 일상이다 — 롤링 업데이트, 스케일 인, 노드 드레인, 수동 `kubectl delete`. 이때 처리가 부실하면 **진행 중이던 외부 연결이 강제로 끊겨** 503·연결 reset이 발생한다. 앵커의 세 번째 면이 여기서 위력을 발휘한다 — HTTP 게이트웨이라면 "in-flight 요청"이 끝나면 되지만, 이 구조는 **장기 TCP 연결**이라 그 스트림이 닫혀야 끝난다. 그래서 종료가 특히 민감하다.
### Pod 종료 시 실제로 일어나는 일
Kubernetes가 Pod를 종료하기로 하면 **두 가지가 동시에** 시작된다 — 이 동시성이 문제의 근원이다.
```mermaid
flowchart TB
START["Pod 종료 결정"] --> A["(A) Pod가 endpoint 목록에서
제거되기 시작"]
START --> B["(B) 컨테이너들에
SIGTERM 전송"]
A --> PROB["문제: (A)의 전파(EDS propagation)는 몇 초 걸림
그 사이 다른 워크로드는 아직 살아있다고 믿고
새 연결을 계속 보냄 → 종료 중인 Pod가 새 트래픽 받음 → reset"]
B --> PROB
```
Envoy(istio-proxy)는 SIGTERM을 받으면 **새 연결은 안 받고, 기존 연결이 끝나길 기다린 뒤 종료**한다. 그런데 그 대기 시간(drain)의 **기본값이 5초**다. 5초 안에 안 끝난 연결은 그대로 끊긴다. 장기 TCP 연결엔 5초는 턱없이 짧다 — 앵커의 "장기"가 기본값과 충돌하는 지점.
### 다뤄야 할 네 개의 시간 파라미터
> 네 파라미터(`preStop`/`terminationDrainDuration`/`EXIT_ON_ZERO_ACTIVE_CONNECTIONS`/`terminationGracePeriodSeconds`)의 **일반 메커니즘과 grace > preStop+drain 산출 공식**은 graceful-termination 시리즈가 정본이다 — [Envoy drain & listener 동작](gt__src-envoy-drain-listeners.html), [graceful shutdown 런북](gt__src-runbook.html) 참조. 여기서는 표로 위치만 정리하고, **egress passthrough 고유 각도**(아래 본문·What you might be missing)에 집중한다.
| 파라미터 | 위치 | 역할 |
|---|---|---|
| `preStop` (sleep) | Pod spec lifecycle | SIGTERM 전에 잠깐 대기 → endpoint 제거가 전파될 시간 확보(보통 3~5초). 위 (A)·(B) 경쟁 완화의 핵심 |
| `terminationDrainDuration` | proxy.istio.io/config | Envoy가 기존 연결을 기다리는 시간. 기본 5초 → 장기 연결 맞게 상향 |
| `EXIT_ON_ZERO_ACTIVE_CONNECTIONS` | proxyMetadata (대략 v1.12경 도입) | 고정 타임아웃 대신 **활성 연결이 0이 되면 즉시 종료**. 빠른 배포 + 안전 양립 |
| `terminationGracePeriodSeconds` | Pod spec | K8s가 SIGKILL 전까지 주는 전체 시간. **위 합보다 길어야** 함. 짧으면 drain 중 강제 종료 |
### 종료 타임라인 (권장 구성 기준)
```mermaid
gantt
title Pod 종료 타임라인 (권장 구성, t=0 은 종료 결정 시점)
dateFormat s
axisFormat %Ss
section Kubernetes
grace period 데드라인 75s (이후 SIGKILL) :crit, a2, 0, 75s
preStop sleep (endpoint 전파 대기) :a1, 0, 5s
안전여유 65s~75s (10s) :done, slack, 65, 10s
section Envoy proxy
SIGTERM 수신 → draining 진입 :milestone, m1, 5, 0s
기존 TCP 연결 drain (최대 5s~65s) :a3, 5, 60s
연결 0 도달 시 조기 종료(가변, EXIT_ON_ZERO?) :milestone, m2, 45, 0s
```
읽는 법: preStop(0~5s)이 끝난 **5초 시점**에 SIGTERM이 와서 Envoy draining이 시작되고, drain은 최대 65초 시점까지 간다. `EXIT_ON_ZERO`의 조기 종료 milestone은 **고정 시점이 아니라** 활성 연결이 0이 되는 가변 시점이므로(그림의 `?`) drain 구간 어디서든 일어날 수 있다. preStop(5s)+drain(60s)=**65s**가 grace **75s**보다 짧아 생기는 **10초 안전여유**가 그림 끝에 보인다.
순서 규칙 하나만 기억하면 된다: **preStop + drain < terminationGracePeriodSeconds.** drain이 grace period보다 길면 K8s가 중간에 SIGKILL을 날려 결국 연결이 끊긴다.
---
## 05. 예시 — egress 게이트웨이에 종료 구성을 실제로 적용하고 확인
앵커에서 연역한 종료 규칙을 실제 매니페스트로 굳히고, 떴는지 한 번 확인한다.
### 설정 — IstioOperator egressGateways 오버레이
게이트웨이는 IstioOperator의 egressGateways 오버레이로 Pod 어노테이션·grace period를 설정한다.
```yaml
apiVersion: install.istio.io/v1alpha1
kind: IstioOperator
spec:
components:
egressGateways:
- name: istio-egressgateway
enabled: true
k8s:
# K8s가 강제 종료(SIGKILL)까지 주는 전체 시간 — drain 보다 길게
# (overlay로 podTemplate의 terminationGracePeriodSeconds 설정)
overlays:
- kind: Deployment
name: istio-egressgateway
patches:
- path: spec.template.spec.terminationGracePeriodSeconds
value: 75
- path: spec.template.spec.containers.[name:istio-proxy].lifecycle
value:
preStop:
exec:
command: ["sleep", "5"] # endpoint 전파 대기
# proxy 설정: drain 시간 + 활성연결 0 시 조기 종료
meshConfig:
defaultConfig:
terminationDrainDuration: 60s # 장기 TCP 연결 고려해 상향(기본 5s)
proxyMetadata:
EXIT_ON_ZERO_ACTIVE_CONNECTIONS: "true"
```
`terminationDrainDuration: 60s`는 "최대 60초까지 기다린다"이고, `EXIT_ON_ZERO_ACTIVE_CONNECTIONS: "true"`가 있으면 연결이 그 전에 다 닫히는 즉시 종료해 배포 속도를 안 깎는다. `terminationGracePeriodSeconds: 75`는 preStop 5초 + drain 60초보다 길어 SIGKILL 여유를 둔다 — 위 순서 규칙을 숫자로 만족(5+60=65 < 75).
> [!warning] meshConfig.defaultConfig는 전역 적용
> 위 예시에서 `meshConfig.defaultConfig`에 둔 `terminationDrainDuration`/`EXIT_ON_ZERO_ACTIVE_CONNECTIONS`는 **egress 게이트웨이뿐 아니라 메시의 모든 사이드카·게이트웨이에 전역 적용**된다. egress 게이트웨이에만 다른 drain을 주려면 메시 전역이 아니라 **워크로드 한정**으로 — `overlays`의 `podTemplate`에 `proxy.istio.io/config` 어노테이션을 박아(`{"terminationDrainDuration":"60s"}`) 해당 Deployment에만 적용해야 한다.
> [!warning] EXIT_ON_ZERO_ACTIVE_CONNECTIONS의 알려진 함정
> 이 기능은 활성 연결 수를 Envoy stats(`downstream_cx_active`)로 폴링해 판단한다. 그런데 실제 운영에서 **그 stats 조회가 일시적으로 실패하면(타임아웃) 프록시가 비정상 종료(abort)** 되는 사례가 보고됐다 — 연결이 많을 때 특히. 또 **장기 연결이 끝나지 않으면 영원히 0이 안 돼서** 결국 grace period의 SIGKILL에 의존하게 된다. 따라서 (a) `terminationGracePeriodSeconds`를 충분히 길게 두는 안전망은 *반드시* 함께 두고, (b) 끝나지 않는 장기 스트림에는 별도 상한(연결 최대 수명)을 두는 걸 고려할 것. (istio/istio 이슈 #50596)
### 떴는지 한 번 확인
```bash
# 종료되는 게이트웨이 Pod의 proxy 로그 — graceful termination 메시지 확인
kubectl logs -n istio-system -c istio-proxy -f
# "Graceful termination period is 60s, starting..."
# "Graceful termination period complete, terminating remaining proxies."
# 현재 적용된 drain 값 확인
kubectl exec -n istio-system -c istio-proxy -- \
pilot-agent request GET config_dump | grep -i drainDuration
# 배포 중 외부 연결 reset/503 모니터링 (앱 쪽 지표와 교차 확인)
# → istio_tcp_connections_closed_total 의 비정상 급증과 시점 대조
```
기대: 로그에 `Graceful termination period is 60s, starting...`가 뜨고 정상 종료 시 `... complete`가 뒤따른다. config_dump의 `drainDuration`이 `60s`로 잡히면 설정이 실제 반영된 것. 배포 창에서 `istio_tcp_connections_closed_total`이 평소 추세 이상으로 튀지 않으면 reset이 없었다는 신호다(앱 쪽 503 지표와 교차).
> [!info] Shutdown에서 자주 놓치는 것
> - **이 구조의 drain은 "요청 완료"가 아니라 "TCP 연결 종료"를 기다린다.** HTTP 게이트웨이라면 in-flight 요청이 끝나면 되지만, passthrough는 암호화된 TCP 스트림이라 그 스트림이 닫혀야 끝난다 — 그래서 장기 연결(gRPC 스트림, DB 풀, 웹소켓)은 drain 시간을 한참 넘길 수 있고, 결국 grace period의 SIGKILL로 끊긴다. "graceful"이 모든 경우를 막아주지 않는다.
> - **HTTP/2·gRPC는 GOAWAY로 우아하게 끊을 수 있지만, 그건 게이트웨이가 그 프로토콜을 인식할 때 얘기** — passthrough에선 게이트웨이가 암호문만 보므로 GOAWAY를 보낼 주체가 아니다. 그 graceful 종료는 TLS 끝점인 앱·외부 서버의 몫이다.
> - **preStop sleep을 빼먹으면 drain을 아무리 늘려도 배포 때마다 reset이 난다** — endpoint 전파 전에 SIGTERM이 와서, 다른 워크로드가 여전히 종료 중인 Pod로 새 연결을 보내기 때문. drain은 "기존 연결"을 지키지 "새로 들어온 연결"을 막지 못한다.
> - **노드 드레인(노드 자체 종료)은 Pod 종료와 또 다르다** — PodDisruptionBudget(`minAvailable: 1`)이 없으면 여러 egress Pod가 동시에 빠져 경로가 통째로 끊길 수 있다.
---
## 06. 정리 · 운영 체크리스트
**한 그림으로:** 게이트웨이는 "암호화된 장기 TCP 스트림"만 본다. 이 사실 하나에서 — ①관측은 L4+SNI, L7은 앱에 위임 ②연결을 소수 노드에 모으니 SNAT·NAT idle·SPOF가 병목 ③종료는 연결이 닫히길 기다림 — 운영 전부가 연역된다. 막히면 앵커로 돌아오라.
### 운영 체크리스트
| 영역 | 점검 항목 |
|---|---|
| 모니터링 | `accessLogFile` 활성 + `requested_server_name` 포함 확인 |
| 모니터링 | 게이트웨이 대시보드를 L7 패널이 아닌 `istio_tcp_*` 기반으로 구성 |
| 모니터링 | L7 SLO는 앱 계측(OpenTelemetry/APM)에서 수집 — 책임 주체 명시 |
| 모니터링 | 알람: 연결 실패율(UF/UH/UC), 미등록 SNI 출현, 활성 연결 포화 |
| 운영 | egress 노드 conntrack/SNAT 포트 사용률 모니터 + 상한 튜닝 |
| 운영 | 장기 연결 호스트에 `tcpKeepalive`(NAT idle보다 짧게) 설정 |
| 운영 | egress 노드 최소 2~3개 + podAntiAffinity + PDB `minAvailable: 1` |
| 운영 | 공통 NAT/ToR/방화벽 SPOF 여부 점검(노드 수로 안 풀림) |
| 운영 | ISTIO_MUTUAL 적용 여부를 `proxy-config cluster ... transportSocket`으로 확인 |
| shutdown | `preStop` sleep 3~5초로 endpoint 전파 대기 |
| shutdown | `terminationDrainDuration` 장기 연결 고려해 상향(기본 5초) |
| shutdown | `EXIT_ON_ZERO_ACTIVE_CONNECTIONS: "true"` + 충분한 grace period 안전망 |
| shutdown | 배포 중 `istio_tcp_connections_closed_total` 급증/503 교차 모니터 |
## 핵심 정리
- **앵커:** 게이트웨이는 "암호화된 장기 TCP 스트림"만 본다 — 요청이 아니라 연결이 흐른다. 운영의 모든 차이가 여기서 연역된다.
- **관측 (갈래 1):** L7 메트릭은 0이 정상. `istio_tcp_*` + TCP access log의 `requested_server_name`(SNI)으로 보고, L7(상태·지연)은 앱 계측에 위임. SNI를 읽는 이유는 ClientHello가 평문이기 때문.
- **운영 (갈래 2):** "장기 연결을 소수 노드에 집중"의 파생 — SNAT 포트 고갈, NAT idle 끊김(`tcpKeepalive`로 NAT보다 짧게), 노드 SPOF($N\geq2$ + AZ + 공통 SPOF 점검).
- **종료 (갈래 3):** drain은 요청이 아니라 연결 종료를 기다림. `preStop + drain < terminationGracePeriodSeconds` 순서 규칙. 기본 drain 5초는 장기 연결에 너무 짧으니 상향(예 60s) + `EXIT_ON_ZERO_ACTIVE_CONNECTIONS` + grace 안전망(예 75s).
- **안쪽 mTLS leg는 포트 15443** — PASSTHROUGH 443과 분리해 filter-chain 충돌(`filter_chain_not_found`) 회피.
- **검증은 config_dump로** — ISTIO_MUTUAL은 cluster `transportSocket`(SDS `default`/`ROOTCA`), drain은 `config_dump`의 `drainDuration`.
## What you might be missing
- **SNI는 클라이언트가 채우는 값이라 위조 가능** — 감사 근거로는 쓰되 보안 통제는 목적지 IP allowlist(Cilium·방화벽)에 둬야 한다. "이 SNI니까 안전"은 약한 판단.
- **L7 정책을 게이트웨이에 걸 수 없음** — path 라우팅·헤더 조작·HTTP 재시도 전부 암호문이라 불가. 그런 요구는 종단(가로채기)이나 앱 레벨로 가야 한다.
- **`graceful`이 모든 경우를 막지 않는다** — 끝나지 않는 장기 스트림(gRPC, DB 풀, 웹소켓)은 drain을 넘겨 결국 SIGKILL에 의존. grace period 안전망 + 연결 최대 수명 상한을 함께 둘 것.
- **`EXIT_ON_ZERO_ACTIVE_CONNECTIONS`는 stats 조회 실패 시 abort 사례 있음**(이슈 #50596) — 안전망(`terminationGracePeriodSeconds`)을 *반드시* 병행.
- **preStop을 빼면 drain을 늘려도 배포마다 reset** — drain은 기존 연결만 지키고 새 연결을 못 막는다. endpoint 전파 대기가 (A)·(B) 경쟁의 유일한 해법.
- **`meshConfig.defaultConfig`는 메시 전역 적용** — egress만 다르게 주려면 워크로드 한정 `proxy.istio.io/config` 어노테이션으로.
- **노드를 늘려도 공통 NAT/ToR/방화벽이 SPOF면 가용성 수식은 거짓 위안** — 독립성이 깨지는 공통 요소부터.
---
## See also
- [Egress TCP 병목 정본](gw__src-egress-tcp-bottlenecks.html) — §03 함정들의 산술적 한계 수치·완화 운영값 YAML·reset 분기 런북·알람 (심화 정본)
- [TCP 병목 한계 축소 재현 랩](gw__guide-egress-tcp-failure-reproduction.html) — 함정 0·1·2를 테스트 클러스터에서 직접 재현하는 절차
- [Egress Gateway 도입 가이드 (사내 공유본)](gw__guide-egress-adoption-passthrough-vs-mtls.html) — 이 운영 지식이 도입 의사결정 문서로 압축된 형태
- [Egress HTTP vs HTTPS 설정 차이](gw__src-egress-http-vs-https.html) — 외부 endpoint가 HTTP/HTTPS일 때 설정 차이 (후속)
- [Egress gateway 기본 구성](gw__src-egress-gateway.html) — ServiceEntry·Gateway·DestinationRule 조립
- [graceful shutdown 런북](gt__src-runbook.html) · [Envoy drain & listener 동작](gt__src-envoy-drain-listeners.html) — drain/grace 파라미터 정본
---
> [!quote] 출처 (검증 기준 Istio 1.30 / `networking.istio.io/v1`)
> - Envoy drain 기본 5초, SIGTERM 시 새 연결 거부 후 대기 동작, `EXIT_ON_ZERO_ACTIVE_CONNECTIONS`(대략 v1.12경 도입 — 적용 전 `config_dump`로 확인 권장) — Istio change notes / pilot-agent 문서, 및 운영 가이드 자료.
> - `terminationDrainDuration` / `terminationGracePeriodSeconds` 관계(drain < grace), `preStop` sleep으로 endpoint 전파 대기, 종료 타임라인 — Istio 데이터플레인 graceful shutdown 운영 가이드 및 deployment best practices.
> - `EXIT_ON_ZERO_ACTIVE_CONNECTIONS` stats 조회 실패 시 abort 사례 — istio/istio GitHub 이슈 #50596.
> - TCP egress 메트릭(`istio_tcp_connections_opened_total` 등) 및 egress 게이트웨이 메트릭 집계 — Istio egress 모니터링 가이드. TCP keepalive(`tcpKeepalive`)로 NAT idle 끊김 방지 — Istio persistent/keepalive 운영 가이드.
> - ※ 일부 수치(기본 drain 5초 등)는 버전에 따라 달라질 수 있어 적용 전 `config_dump`로 실제 값 확인을 권장함. terminationGracePeriodSeconds가 drain보다 짧으면 SIGKILL로 강제 종료되는 점은 버전 불문 동일.
---
## 관련 파일 · 참조
- 📎 [gateway-egress.yaml](attachment/scenarios/20-egress/gateway-egress.yaml) · 📎 [destinationrule-egress.yaml](attachment/scenarios/20-egress/destinationrule-egress.yaml) · 📎 [proxy-dump.sh](attachment/scripts/proxy-dump.sh)
- 안쪽 ISTIO_MUTUAL leg(포트 15443) 정합 매니페스트: 📎 [gateway-egress-cnn-mtls.yaml](attachment/scenarios/20-egress/gateway-egress-cnn-mtls.yaml) · 📎 [destinationrule-egress-cnn-mtls.yaml](attachment/scenarios/20-egress/destinationrule-egress-cnn-mtls.yaml)
- ↗ [Istio: Egress Gateways](https://istio.io/latest/docs/tasks/traffic-management/egress/egress-gateway/) · ↗ [Egress TLS Origination](https://istio.io/latest/docs/tasks/traffic-management/egress/egress-tls-origination/)
**관련 검증** → [Egress mTLS 리포트 — HTTPS over mTLS (ISTIO_MUTUAL)](gw__report-2026-06-08_egress-mtls.html) · [구조 정본 — CRD·장단점·활용·운영](gw__src-egress-https-over-mtls.html)