Egress Gateway 운영 가이드 — passthrough + ISTIO_MUTUAL (출처: LLM 답변 + Istio 1.30 공식 문서)
앱이 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 · 범위 관측·운영함정·종료. 구조의 조립은 구조 정본에 위임 · 선행개념 egress gateway 기본 구성
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 스트림" 하나에서 모든 게 따라 나온다
게이트웨이 입장에서 트래픽은 요청이 아니라 연결이다. 외부 TLS를 안 풀기 때문에 L7(HTTP)이 없고, 한 번 맺힌 암호화 TCP 스트림이 오래 살아 있다. 이 그림에서 세 가지 운영 특성이 기계적으로 연역된다 — ①관측은 L4+SNI만 ②연결을 소수 노드에 모으니 연결 단위 자원이 병목 ③종료는 연결이 닫히길 기다림.
세 갈래로 펼쳐 보면:
세 갈래의 공통 뿌리가 하나(앵커)라는 게 핵심이다. 아래 섹션은 각 갈래를 "앵커에서 왜 이렇게 되는가"로 전개한다. 갈래마다 디테일이 다르지만, 막힐 때마다 앵커로 돌아오면 답이 나온다.
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 게이트웨이를 통과한 전체 외부 연결"을 중앙에서 볼 수 있다.
# 외부 호스트별 신규 연결률 (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"})
*_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에서 켠다.
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인지 본다.
# 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)를 받아온다.
"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과 연결).
- 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 병목 정본에 따로 박아 두었다.
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로 연결을 살아있게 유지하는 게 표준 대응이다.
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.
이 구조의 게이트웨이는 이미 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 참조.
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 인증서는 앱이 책임지므로(게이트웨이가 아니라 — 게이트웨이는 그 봉투를 안 푸니까), 그 만료·갱신은 게이트웨이 운영 밖이다. "인증서 만료"를 디버깅할 때 어느 쪽 인증서인지부터 구분해야 헛다리를 안 짚는다.
- 이 구조에서 게이트웨이는 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를 종료하기로 하면 두 가지가 동시에 시작된다 — 이 동시성이 문제의 근원이다.
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 동작, graceful shutdown 런북 참조. 여기서는 표로 위치만 정리하고, 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 중 강제 종료 |
종료 타임라인 (권장 구성 기준)
읽는 법: 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를 설정한다.
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).
위 예시에서 meshConfig.defaultConfig에 둔 terminationDrainDuration/EXIT_ON_ZERO_ACTIVE_CONNECTIONS는 egress 게이트웨이뿐 아니라 메시의 모든 사이드카·게이트웨이에 전역 적용된다. egress 게이트웨이에만 다른 drain을 주려면 메시 전역이 아니라 워크로드 한정으로 — overlays의 podTemplate에 proxy.istio.io/config 어노테이션을 박아({"terminationDrainDuration":"60s"}) 해당 Deployment에만 적용해야 한다.
이 기능은 활성 연결 수를 Envoy stats(downstream_cx_active)로 폴링해 판단한다. 그런데 실제 운영에서 그 stats 조회가 일시적으로 실패하면(타임아웃) 프록시가 비정상 종료(abort) 되는 사례가 보고됐다 — 연결이 많을 때 특히. 또 장기 연결이 끝나지 않으면 영원히 0이 안 돼서 결국 grace period의 SIGKILL에 의존하게 된다. 따라서 (a) terminationGracePeriodSeconds를 충분히 길게 두는 안전망은 반드시 함께 두고, (b) 끝나지 않는 장기 스트림에는 별도 상한(연결 최대 수명)을 두는 걸 고려할 것. (istio/istio 이슈 #50596)
떴는지 한 번 확인
# 종료되는 게이트웨이 Pod의 proxy 로그 — graceful termination 메시지 확인
kubectl logs <egress-pod> -n istio-system -c istio-proxy -f
# "Graceful termination period is 60s, starting..."
# "Graceful termination period complete, terminating remaining proxies."
# 현재 적용된 drain 값 확인
kubectl exec <egress-pod> -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 지표와 교차).
- 이 구조의 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(SDSdefault/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 병목 정본 — §03 함정들의 산술적 한계 수치·완화 운영값 YAML·reset 분기 런북·알람 (심화 정본)
- TCP 병목 한계 축소 재현 랩 — 함정 0·1·2를 테스트 클러스터에서 직접 재현하는 절차
- Egress Gateway 도입 가이드 (사내 공유본) — 이 운영 지식이 도입 의사결정 문서로 압축된 형태
- Egress HTTP vs HTTPS 설정 차이 — 외부 endpoint가 HTTP/HTTPS일 때 설정 차이 (후속)
- Egress gateway 기본 구성 — ServiceEntry·Gateway·DestinationRule 조립
- graceful shutdown 런북 · Envoy drain & listener 동작 — drain/grace 파라미터 정본
- Envoy drain 기본 5초, SIGTERM 시 새 연결 거부 후 대기 동작,
EXIT_ON_ZERO_ACTIVE_CONNECTIONS(대략 v1.12경 도입 — 적용 전config_dump로 확인 권장) — Istio change notes / pilot-agent 문서, 및 운영 가이드 자료. terminationDrainDuration/terminationGracePeriodSeconds관계(drain < grace),preStopsleep으로 endpoint 전파 대기, 종료 타임라인 — Istio 데이터플레인 graceful shutdown 운영 가이드 및 deployment best practices.EXIT_ON_ZERO_ACTIVE_CONNECTIONSstats 조회 실패 시 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 · 📎 destinationrule-egress.yaml · 📎 proxy-dump.sh
- 안쪽 ISTIO_MUTUAL leg(포트 15443) 정합 매니페스트: 📎 gateway-egress-cnn-mtls.yaml · 📎 destinationrule-egress-cnn-mtls.yaml
- ↗ Istio: Egress Gateways · ↗ Egress TLS Origination
관련 검증 → Egress mTLS 리포트 — HTTPS over mTLS (ISTIO_MUTUAL) · 구조 정본 — CRD·장단점·활용·운영