---
title: mTLS Passthrough TCP 운영 종합 — connectionPool·port reuse·런북
date: 2026-07-06
type: guide
domain: istio
tags: [egress, mtls, connection-pool, tcp, helm, runbook]
---
> [!abstract]
> egress **mTLS Passthrough**(HTTPS over mTLS, [용어 정본](/docs/istio/egress/https-pattern-map/)) 채널의
> TCP 운영 설정을 한 문서에 모았다. 구성은 두괄식이다 — 왜 문제가 생기나·문제 4가지(2장) → 답이 되는
> 전체 설정 한 벌(3장) → 문제별 설정 상세(4~7장) 순서다.
> 각 주제의 정본은 문서 끝 참조 절에 있다.
**대상 환경:** Istio **1.30.0**, sidecar mesh (homelab k8s 1.30.6 기준 컨벤션)
**검증:** 본문 주장 대부분은 소스 정본들의 클러스터 실측(T16·T52·T53·T55·T57·T91·T93 — 각 정본의 검증 기록 참조)을 인용한다. 이 문서 자체 실측은 2건 — gateway Helm chart의 securityContext 렌더링(**T94**)과 주입 webhook의 securityContext 덮어쓰기(**T95**)다. (문서 끝 "검증 기록", 번들 `files/verify/T94/`·`files/verify/T95/`)
**읽는 법:** 바로 적용할 사람은 0장 요약과 3장(전체 설정)·8장(검증). 문제별 배경은 4~7장. 장애 대응은 9장 런북. 값을 조정하고 싶으면 부록 A.
---
## 0. 요약
egress gateway를 두면, 외부로 나가는 연결이 gateway 몇 대로 모인다.
그러면 전에는 없던 문제 4가지가 생긴다. 전부 "연결의 개수"와 "연결의 수명" 문제다.
| # | 문제 | 왜 생기나 | 해결 | 설정 위치 |
|---|---|---|---|---|
| P1 (4장) | 연결이 일정 개수에서 거부된다 | Envoy의 기본 상한이 목적지당 1,024개 | `maxConnections`를 직접 지정 | DestinationRule |
| P2 (5장) | 새 연결을 못 만든다 (포트 부족) | 발신 포트가 pod당 약 28,000개뿐이고, 닫은 포트도 60초간 재사용 불가 | ① 앱 keep-alive ② gateway 증설 ③ 커널 설정(`tcp_tw_reuse`) | istiod Helm values(커널 설정)·gateway chart(증설) |
| P3 (6장) | 한동안 안 쓰던 연결이 죽어 있다 | 방화벽이 유휴 연결을 조용히 끊는다. Envoy도 1시간이면 스스로 끊는다 | `tcpKeepalive`와 `idleTimeout`을 **둘 다** 설정 | DestinationRule |
| P4 (7장) | 배포할 때마다 연결이 끊긴다 | 종료 전 대기(drain)가 기본 5초뿐 | drain 300초 + PDB | gateway Helm chart |
장애가 났을 때 넷 중 뭔지 구분하는 방법은 9장 런북에 있다.
(다섯 번째 후보였던 conntrack은 위 한도들보다 훨씬 넓어서 일반 규모에선 도달하지 않는다 — 부록 B)
기억할 것 3개:
1. **DestinationRule은 2개 필요하다.** 연결 설정은 "연결을 여는 쪽"에 붙는다. 앱→gateway 연결은 앱의 sidecar가 열고, gateway→외부 연결은 gateway가 연다. 그래서 구간마다 하나씩이다. (4장)
2. **1,024는 "아무 설정도 안 했을 때" 기본값이다.** 함정이 하나 있다: connectionPool을 일부만 설정하면, 나머지 상한은 1,024가 아니라 **무제한**으로 풀린다. 그래서 `maxConnections`는 항상 직접 적는다. (4장)
3. **시작값으로 시작한다.** 이 문서의 4096, 300 같은 값은 일반 REST API 채널 기준의 시작값이다. 트래픽이 큰 채널만 부록 A의 계산식으로 다시 잡으면 된다.
### 0.1 용어 최소 사전 — 처음 읽는 사람용
| 용어 | 뜻 |
|---|---|
| sidecar | 앱 pod마다 함께 뜨는 프록시(Envoy). 앱의 모든 트래픽이 이걸 거쳐 나감 |
| egress gateway | 외부(파트너망·인터넷)로 나가는 트래픽을 한곳에 모아 내보내는 전용 프록시 pod |
| DestinationRule (DR) | "이 목적지로 갈 때는 이렇게 연결하라"를 정하는 Istio 리소스 — connectionPool이 여기 들어감 |
| connectionPool | 목적지로 여는 연결의 개수 상한·수명·타임아웃 규칙 묶음 |
| cluster (Envoy) | Envoy 내부에서 "목적지 하나"를 부르는 단위. DR 설정이 최종적으로 여기에 반영됨 |
| SNI | TLS 접속 첫 인사에 "이 도메인 찾아왔다"고 밝히는 필드. gateway가 채널을 구분하는 열쇠 |
| mTLS / SPIFFE 신원 | 메시 내부 상호 TLS — 워크로드마다 발급된 인증서로 "누가 보냈나"를 증명 |
| response flag | Envoy access log의 실패 원인 코드. 이 문서에선 `UO`(풀 초과)·`UF`(연결 실패) 두 개만 알면 됨 |
| TIME_WAIT | TCP 연결을 닫은 뒤 커널이 그 포트를 60초간 잠가두는 상태 — 포트 고갈의 주범 |
---
## 1. 패턴 개요 — 두 홉, 두 봉투
mTLS Passthrough(HTTPS over mTLS)가 푸는 문제는 하나다. 종단간 암호화는 건드리지 않으면서, 누가 나가는지만 egress에서 확인하고 싶다.
동작은 3단계다:
1. 앱은 평소처럼 `https://`를 직접 호출한다. 이 TLS(**inner**)는 앱과 외부 서버 사이에서만 열린다. 중간의 누구도 풀지 않는다.
2. sidecar가 그 위에 메시 mTLS(**outer**)를 한 겹 더 입혀 gateway로 보낸다. (홉 1)
3. gateway는 outer만 벗겨서 호출자의 신원(SPIFFE — 메시가 워크로드마다 발급하는 인증서 ID)을 확인한다. 안에 든 inner TLS는 풀지 않고 그대로 외부로 흘린다. (홉 2)
```mermaid
flowchart LR
APP["app
(inner TLS 생성)"] --> SC["sidecar
(outer mTLS 래핑)"]
SC -->|"홉 1: outer mTLS"| GW["egress gateway
(outer만 벗겨 신원 확인)"]
GW -->|"홉 2: TCP 그대로 전달"| EXT["외부 서버
(inner TLS 종단)"]
```
와이어 어디에도 평문 구간은 없다. "홉 2가 일반 TCP"라는 말은 gateway가 TCP로 그대로 전달한다는 뜻이지, 암호화가 없다는 뜻이 아니다.
---
## 2. 왜 문제가 생기나 — 두 가지 사실과 문제 4가지
egress gateway를 도입하면 외부로 나가는 연결이 gateway 몇 대로 몰린다. 이 집중을 고려하지 않고 운영하면 구조적으로 결국 아래 문제 4가지로 이어진다.
더 큰 문제는 장애가 난 뒤다. 넷 다 클라이언트 증상이 비슷해서, 배경지식이 없으면 원인 찾기가 어렵다.
그래서 이 장은 원인이 되는 사실 2개를 먼저 깔고, 그 위에서 문제 4가지를 정의한다. 해결 설정은 3장에 한 벌로 제시한다.
### 2.1 사실 1 — gateway는 연결을 재사용하지 못한다
gateway는 연결을 "통과"시키는 장치가 아니다. 양쪽에 연결을 하나씩 잡고, 가운데서 바이트를 복사하는 중계기다.
```mermaid
flowchart LR
APP["app"] -->|"연결 A"| GW["gateway
소켓 2개: A 받기 + B 열기"]
GW -->|"연결 B (새로 엶)"| EXT["외부 서버"]
```
앱이 연결 1개를 만들면 gateway에는 소켓이 2개 잡힌다. A를 받는 소켓 하나, 외부로 새로 여는 B의 소켓 하나. 그리고 B를 열 때마다 발신 포트를 1개씩 쓴다.
L7 proxy라면 B 하나를 여러 앱의 요청이 나눠 탈 수 있다. (요청의 경계를 아니까)
근데 이 패턴의 gateway가 보는 건 암호화된 바이트 덩어리다. 내용을 모르니 섞을 수 없다.
그래서 A 1개당 B 1개, 1:1이 강제다. (PASSTHROUGH든 ISTIO_MUTUAL이든 같다)
이 1:1은 닫을 때도 그대로다. 앱이 A를 닫으면 gateway도 B를 닫는다. 앱이 연결을 아끼면 gateway 포트도 아껴지고, 앱이 낭비하면 gateway가 대신 아껴줄 방법이 없다.
### 2.2 사실 2 — 발신 포트는 유한하다
TCP 연결은 (출발 IP, 출발 포트, 목적지 IP, 목적지 포트) 4개로 구분된다.
외부 API 호출에서 목적지는 고정이고, 출발 IP도 gateway pod IP로 고정이다. 그러면 변수는 출발 포트 하나뿐이다.
- **동시 연결 한계**: 커널이 발신 연결에 빌려주는 임시 포트(ephemeral port)는 기본 약 **28,000개**다 (범위 32768–60999). pod 1개, 목적지 1개당 이게 전부다.
- **초당 연결 한계**: 닫은 포트는 바로 반납되지 않는다. **TIME_WAIT 상태로 60초간 잠긴다.** (커널 고정값 — 방금 닫은 연결의 지연 패킷이 같은 포트의 새 연결에 끼어드는 걸 막는 격리 시간) 그래서 초당 r개씩 새로 열면, 어느 순간이든 잠겨 있는 포트가 r × 60개다. 이게 28,000을 넘으면 빌려줄 포트가 없다:
```
r × 60초 ≤ 28,000 → r ≤ 약 470개/초
```
(비유: 컵 28,000개짜리 대여점인데 반납된 컵은 소독에 60초 걸린다. 초당 470명까지만 지속 가능하다)
- 예: pod 500개가 keep-alive 없이 초당 1번씩 호출하면 gateway에 초당 500개가 모인다. gateway 1대면 몇십 초 안에 포트가 마른다.
### 2.3 문제 4가지 — 정의
두 사실 위에 서면 문제 4가지가 그대로 나온다:
| # | 증상 | gateway 쪽 기록 | 원인 한 줄 |
|---|---|---|---|
| P1 | 특정 채널 호출이 일정 개수에서 거부된다 | access log flag `UO` | Envoy 기본 상한 1,024에 전사 트래픽이 부딪힘 |
| P2 | 외부로 새 연결 자체가 안 열린다 | flag `UF` + 로그 `EADDRNOTAVAIL` | 발신 포트 고갈 — 사실 2의 산술 한계 |
| P3 | 유휴 후 첫 요청만 실패 / 정확히 1시간마다 절단 | `upstream_cx_idle_timeout` 증가 (Envoy 절단일 때만 — 방화벽 절단은 기록 없음) | 방화벽 유휴 절단 + Envoy `idleTimeout` |
| P4 | 배포·스케일 인마다 일괄 reset | `upstream_cx_destroy` 급증 | 종료 전 대기(drain)가 기본 5초 |
표의 gateway 쪽 기록 중 `UO`(P1)와 idleTimeout 정각 절단(P3)은 직접 재현해서 확인했다 ([T52 실측 번들](/docs/istio/egress/circuit-breaking-mechanisms/files/verify/T52/) · [T16 실측 번들](/docs/istio/egress/tcp-keepalive-fields/files/verify/T16/)). `UF`+`EADDRNOTAVAIL`(P2)과 `upstream_cx_destroy` 급증(P4)은 Envoy 공식 문서 기준이고(부록 D), 직접 재현하지는 않았다.
### 2.4 어느 문제부터 만나나 — 앱의 연결 사용 방식이 정한다
앱이 연결을 쓰는 방식은 둘 중 하나다.
1. **요청마다 열고 닫는다 (단발성).** 열린 연결은 안 쌓인다. 대신 닫은 포트(TIME_WAIT)가 쌓인다. → **P2부터 만난다.**
2. **연결을 계속 들고 쓴다 (keep-alive).** 포트는 안 마른다. 대신 열린 연결이 쌓인다. → **P1부터 만난다.**
근데 P2의 근본 처방이 "앱을 keep-alive로 바꾸는 것"이다(5장). 처방을 따르는 순간 앱은 2번 방식이 되고, 이번엔 P1 쪽 상한이 일하기 시작한다. P3·P4는 그렇게 수명이 길어진 연결의 후속 문제다. 그래서 골라 잡는 게 아니라 넷을 세트로 잡는다.
---
## 3. 답 — 전체 YAML 한 벌 (복사해서 시작)
2장의 문제 4가지를 전부 막는 설정이다. 예시 채널: `api.partner-a.example.com:443`. 값은 전부 시작값이다. (조정은 부록 A)
각 값이 왜 필요한지는 주석의 P1~P4 표시를 따라 4~7장에서 찾는다.
채널마다 넣는 것(레이어 1 — CRD 5종)과, 전역으로 한 번 넣는 것(레이어 2 — gateway·istiod Helm values)으로 나뉜다.
### 레이어 1 — 배선 + DR 2개 (채널당 1세트)
```yaml
# 1) ServiceEntry — 외부 host 등록 (protocol TLS: gateway가 L7 파싱 안 함)
apiVersion: networking.istio.io/v1
kind: ServiceEntry
metadata: { name: partner-a, namespace: istio-system }
spec:
hosts: [api.partner-a.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-a, namespace: istio-system }
spec:
selector: { istio: egressgateway }
servers:
- port: { number: 8443, name: tls-partner-a, protocol: TLS }
hosts: [api.partner-a.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-a, namespace: istio-system }
spec: # exportTo 없음(기본 *) — 소비자 = 모든 클라이언트 ns sidecar
host: istio-egressgateway.istio-system.svc.cluster.local # 레지스트리 FQDN과 일치 필수
subsets:
- name: partner-a
trafficPolicy:
portLevelSettings:
- port: { number: 443 } # Service 포트 기준
tls:
mode: ISTIO_MUTUAL # sidecar가 SPIFFE cert 제시
sni: api.partner-a.example.com # gateway filter chain 매칭 키
connectionPool: # tls와 같은 포트 엔트리에 — 통째 교체 규칙(4장)
tcp:
maxConnections: 4096 # "클라이언트 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-a-tcp-pool, namespace: istio-system }
spec:
host: api.partner-a.example.com # ServiceEntry hosts와 정확 일치
exportTo: ["."] # 타 ns sidecar 오적용 차단
trafficPolicy:
connectionPool:
tcp: # http.* 는 TCP 라우트라 무의미 — tcp만
maxConnections: 4096 # P1: 전사 연결이 모이는 곳
connectTimeout: 3s # 외부 장애 시 빠른 실패 (기본 10s)
idleTimeout: 1800s # P3: 채널 최장 유휴보다 길게 (keepalive로 못 막음)
maxConnectionDuration: 3600s # P3/P4: 수명 상한 — 재분배·drain 자연 소멸
# (재연결 민감 채널은 제외하거나 길게)
tcpKeepalive: { time: 300s, interval: 30s, probes: 3 } # P3: 방화벽 유휴 제한의 1/3
---
# 5) VirtualService — leg-1 tls(SNI 생존) / leg-2 tcp(SNI 소비 후 raw relay)
apiVersion: networking.istio.io/v1
kind: VirtualService
metadata: { name: partner-a-via-egress, namespace: istio-system }
spec:
hosts: [api.partner-a.example.com]
gateways: [mesh, istio-system/egress-partner-a]
tls:
- match:
- gateways: [mesh]
port: 443
sniHosts: [api.partner-a.example.com] # 앱 ClientHello의 SNI로 매치
route:
- destination:
host: istio-egressgateway.istio-system.svc.cluster.local
subset: partner-a # -> DR-hop1
port: { number: 443 }
tcp:
- match:
- gateways: [istio-system/egress-partner-a]
port: 8443
route:
- destination:
host: api.partner-a.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`
- gateway 대상: 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
### 레이어 2 — Helm values (전역 1벌: gateway 차트 + istiod 차트)
```yaml
# (a) gateway 차트 values
replicaCount: 3 # 포트 공간 = 28k × replica (P2)
autoscaling:
enabled: true
minReplicas: 3
maxReplicas: 6
targetCPUUtilizationPercentage: 70 # 연결 중심 부하는 CPU와 상관이 약함 —
# upstream_cx_active 기반 custom metric 권장
affinity:
podAntiAffinity: # SNAT 환경에선 required가 필수 (5장)
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector: { matchLabels: { istio: egressgateway } }
topologyKey: kubernetes.io/hostname
nodeSelector: { node-role/egress: "true" } # 전용 노드풀 = 방화벽 등록 IP 안정화
tolerations:
- { key: node-role/egress, operator: Exists }
podAnnotations:
proxy.istio.io/config: |
terminationDrainDuration: 300s # P4
proxyMetadata:
EXIT_ON_ZERO_ACTIVE_CONNECTIONS: "true"
service:
type: ClusterIP # egress는 외부 노출 불필요
ports:
- { name: status-port, port: 15021, targetPort: 15021 }
- { name: tls-egress, port: 443, targetPort: 8443 }
```
```yaml
# (b) istiod 차트 values — P2 커널 파라미터는 여기
# gateway 차트의 securityContext에 넣으면 주입 webhook이 조용히 덮어쓴다 (5장, 실측 T95)
gateways:
securityContext: # 주입되는 모든 gateway(ingress 포함)에 전역 적용
sysctls:
- { name: net.ipv4.ip_unprivileged_port_start, value: "0" } # 자동 기본 재기재 (5장 함정)
- { name: net.ipv4.ip_local_port_range, value: "10240 60999" }
- { name: net.ipv4.tcp_tw_reuse, value: "1" } # kubelet·PSA 관문 선행 (5장)
```
(+ P4의 PDB는 7장, `tcp_tw_reuse` 관문 2개는 5장 참조. istiod values 변경 후엔 gateway rollout restart 필요)
---
## 4. P1 — 연결이 일정 개수에서 거부된다
### 문제
특정 채널 호출이 갑자기 거부된다. gateway access log에 flag `UO`가 찍히고 `envoy_cluster_upstream_cx_overflow` 지표가 오른다.
원인: connectionPool을 안 걸면 **Envoy 내장 기본 상한이 목적지당 동시 1,024개**다. sidecar 시절엔 pod 하나가 한 목적지로 1,024개를 열 일이 없어서 한 번도 안 보이던 벽인데, gateway에선 전사 트래픽이 한곳에 모이니 바로 부딪힌다.
### 해결
`maxConnections`를 직접 지정한다. 시작값 4096. (근거와 재계산은 부록 A)
근데 한 군데가 아니라 **두 군데**에 적어야 한다. 이유는 아래.
### 상세 — DR이 2개인 이유
istiod는 DR을 "그 목적지를 향한 cluster 설정"으로 컴파일해서 프록시에 내려주는데, cluster는 **프록시마다 따로** 있다. 그래서 설정이 실제로 먹는 곳은 **그 목적지로 연결을 여는 프록시**다.
```mermaid
flowchart LR
DR1["DR-hop1
(host: gateway)"] -.->|컴파일| SC["호출자 sidecar의 cluster
→ pod당 상한"]
DR2["DR-hop2
(host: 외부 host)"] -.->|컴파일| GW["gateway의 cluster
→ 전사 합류 상한"]
```
| | DR-hop1 (sidecar→gw) | DR-hop2 (gw→외부) |
|---|---|---|
| `host` | gateway Service의 레지스트리 FQDN (= `istioctl proxy-config cluster` 출력에 보이는 전체 이름) | ServiceEntry의 외부 host |
| `tls` | `ISTIO_MUTUAL` + `sni` (outer 봉투를 만든다) | **블록 자체가 없다** (TCP 그대로 전달) |
| connectionPool 적용 위치 | **호출자 sidecar** — 값은 "클라이언트 pod당" 상한 | **gateway** — 전사 연결이 모이는 곳, 튜닝의 핵심 지점 |
| `exportTo` | 안 적는다 (mesh 전체 공개 — 모든 호출 ns의 sidecar가 써야 함) | `["."]` (쓰는 건 gateway뿐) |
정말 그런가? 확인하는 방법이 있다. 두 DR의 `maxConnections`에 서로 다른 식별값을 넣고 양쪽 프록시의 cluster를 덤프해 보면, 각 값이 자기 프록시에만 나타난다. (8장, 실측 T91)
규칙 3개:
1. **`tls.mode: tcp`는 없다.** enum은 `DISABLE/SIMPLE/MUTUAL/ISTIO_MUTUAL` 4개뿐이고, apply하면 CRD가 거부한다. 홉 2의 "일반 TCP"는 mode를 고르는 게 아니라 **tls 블록을 빼는 것**이다. (DR을 안 만들거나 / tls 없이 풀만 있는 DR / `DISABLE` 명시 — 셋 다 같다)
2. **`http.*` 풀 설정은 조용히 무시된다.** 두 홉 다 TCP 라우트라 HTTP 풀 개념이 없다. 연결 관리는 `tcp.idleTimeout`, `tcp.maxConnectionDuration`, `tcp.tcpKeepalive`로만 한다.
3. **DR의 `host`가 Istio가 아는 이름과 다르면 에러 없이 통째로 무시된다.** 증상은 "설정했는데 기본값"이다. `istioctl proxy-config cluster`에 보이는 이름 기준으로 맞춘다.
### 상세 — 기본값 함정 2개 (실측으로 확정)
- **기본값이 둘이다.** "기본 1,024"는 connectionPool을 **아예 안 걸었을 때** 얘기다. connectionPool에 아무 필드든 하나라도 걸면, 생략한 상한은 1,024가 아니라 **무제한**으로 바뀐다:
| 상태 | maxConnections 실효값 |
|---|---|
| DR/connectionPool 자체가 없음 | **1,024** — Envoy 내장 기본 |
| connectionPool을 걸고 maxConnections 생략 | **4294967295 ≈ 무제한** — Istio가 명시 주입 |
| 명시 | 명시한 값 |
`idleTimeout` 하나 넣자고 connectionPool을 만들면 1,024 안전망이 조용히 사라진다. 그래서 connectionPool을 일부만 도입할 때도 `maxConnections`는 같이 적는다.
- **subset과 portLevelSettings는 병합이 아니라 통째 교체다.** subset에 설정을 쓰면 상위 설정을 필드 단위로 물려받지 않는다. `connectionPool`, `tls` 같은 덩어리 단위로 교체된다. 포트 엔트리에 `tls`만 적고 connectionPool을 빼먹으면 그 포트의 풀은 기본값으로 돌아간다. 트래픽이 타는 포트 엔트리에 전부 다시 적는다.
### 상세 — 이 상한은 평소에 걸리라고 두는 값이 아니다
`maxConnections`가 세는 건 "지금 열려 있는 연결"뿐이다. 닫고 버린 포트(TIME_WAIT)는 안 센다.
그래서 요청마다 열고 닫는 단발성 채널에선 이 상한이 평소에 놀고 있는 게 정상이다. (그 채널의 진짜 위험은 포트 고갈 — 5장. 그건 이 상한이 못 막는다)
그럼 왜 두나? 외부 서버가 느려지는 날 때문이다.
1. 열려 있는 연결 수는 대략 "초당 새 연결 × 연결 수명"이다.
2. 평소: 초당 500개 × 0.05초 = 동시 25개. 상한 근처에도 안 간다.
3. 외부 응답이 5초로 늘어진 날: 500 × 5 = 2,500개. 순식간에 쌓인다.
응답이 안 오면 연결을 못 닫는다. 단발성 앱도 이 순간엔 "오래 들고 있는" 앱이 된다. 이때 gateway가 연결을 무한정 쌓아 같이 죽는 걸 이 상한이 끊는다. 재시도 폭풍(실패 → 앱 일제 재시도 → 연결 시도 급증)도 같은 방식으로 막는다. (퓨즈와 같다 — 평소에 안 끊어진다고 빼지 않는다. `connectTimeout: 3s`도 같은 목적의 세트다)
keep-alive 채널은 다르다. 평시에도 열린 연결이 쌓이니, 여기선 이 상한이 장애 대비가 아니라 평시 용량 설계값이다. (부록 A: 최대 동시 연결 × 2~3)
핵심 설정만 뽑으면 이렇다 (전체 YAML은 3장):
```yaml
# DR-hop2 (gateway→외부) — 전사 연결이 모이는 곳, 핵심
connectionPool:
tcp:
maxConnections: 4096 # 시작값 — 큰 채널은 부록 A로 재계산
connectTimeout: 3s # 외부 장애 시 빠른 실패 (기본 10s)
```
```yaml
# DR-hop1 (sidecar→gateway)의 subset에도 — pod당 상한이라 여유 있지만, 숨은 1,024는 동일
connectionPool:
tcp: { maxConnections: 4096 }
```
**확인**: `istioctl proxy-config cluster`로 양쪽 값 확인(8장). 알람은 `upstream_cx_overflow`(9장).
---
## 5. P2 — 새 연결을 못 만든다 (포트 고갈)
### 문제
gateway에서 외부로 connect가 실패한다. access log에 flag `UF`, gateway 로그에 `EADDRNOTAVAIL`.
원인은 2장의 두 사실이다. 포트는 pod당·목적지당 동시 약 28,000개, 초당 약 470개가 산수로 정해진 한계고(2.2절), gateway는 1:1 중계라 앱 대신 연결을 아껴줄 수도 없다(2.1절).
### 해결 (우선순위 순)
1. **앱 keep-alive** — 유일한 근본 처방. keep-alive는 응답이 끝나도 연결을 닫지 않는 약속이다. 다음 요청이 그 연결을 그대로 탄다. 새 연결이 없으면 새 포트도 없고, TIME_WAIT도 안 쌓인다. 2.2절의 산술 자체가 무의미해진다. TIME_WAIT 알람이 울리면 인프라 튜닝 전에 호출 라이브러리의 connection reuse 설정부터 본다.
(여기서 keep-alive는 HTTP 연결 재사용이다. 6장의 `tcpKeepalive` — 유휴 연결에 보내는 생존 신호 — 와는 이름만 비슷한 다른 것이다)
2. **gateway 증설** — 포트 공간이 replica 수만큼 늘어난다. 단 SNAT 환경이면 노드를 갈라야 효과가 있다 (아래).
3. **커널 파라미터** — 포트 범위 확장(`ip_local_port_range`) + TIME_WAIT 포트 재사용(`tcp_tw_reuse`).
처방 1을 따르면 앱은 "연결을 들고 쓰는" 쪽이 된다. 그때부터는 4장의 `maxConnections`가 평시에도 일하는 세상이다. P1·P2가 세트인 이유다(2장).
### 상세 1 — SNAT부터 확인한다
포트 계산은 "출발 IP = gateway pod IP"가 전제다. CNI가 노드를 나갈 때 SNAT하면(예: Calico `natOutgoing: true`) 출발 IP가 노드 IP로 바뀐다. 그러면 둘이 달라진다:
1. 같은 노드의 gateway pod들이 노드 IP의 포트를 나눠 쓴다. replica를 늘려도 같은 노드면 효과가 없다. 그래서 `podAntiAffinity`가 필수가 된다.
2. 방화벽에 등록하는 단위가 바뀐다 (pod 대역 ↔ 노드 IP). 용량 계산과 보안 신청을 같은 설정이 동시에 결정하니, 도입 전에 가장 먼저 확인한다.
### 상세 2 — 커널 파라미터는 istiod Helm values로 넣는다 (gateway 차트가 아니다)
커널 파라미터는 노드가 아니라 **pod 네트워크 네임스페이스(netns) 값**이다. 새 netns는 호스트 값이 아니라 커널 기본값으로 시작한다. 그래서 노드 `/etc/sysctl.d`에 넣어도 gateway pod에는 안 먹는다.
그럼 gateway 차트의 `securityContext`에 넣으면 되나? **그것도 안 된다. 이 절의 핵심 함정이다** (T95 실측):
1. gateway 차트 values의 sysctls는 Deployment까지는 정상 렌더링된다 (T94).
2. 근데 gateway pod는 생성 시점에 sidecar injector(MutatingWebhook)를 거친다. 주입 템플릿이 pod-level `securityContext`를 **무조건** 다시 쓰고, `sysctls`는 리스트라 병합이 아니라 **통째 교체**된다.
3. 결과: 최종 pod에는 istiod 쪽 값(기본 `ip_unprivileged_port_start=0` 하나)만 남는다. 차트에 적은 `ip_local_port_range`는 pod spec에서도 `/proc`에서도 사라진다. **에러가 없어서 Deployment만 보면 적용된 걸로 착각한다.**
실효 주입 지점은 **istiod 차트 values의 `gateways.securityContext`**다. 주입 템플릿이 pod를 만들 때 이 값을 읽는다:
```yaml
# istiod Helm values (gateway 차트 아님!)
gateways:
securityContext: # 주입되는 모든 gateway(ingress 포함)에 전역 적용
sysctls:
- { name: net.ipv4.ip_unprivileged_port_start, value: "0" } # 재기재 필수 (아래 함정)
- { name: net.ipv4.ip_local_port_range, value: "10240 60999" } # safe: 포트 풀 28k -> 50k
- { name: net.ipv4.tcp_tw_reuse, value: "1" } # unsafe: 관문 2개 선행 (아래)
```
> **덮어쓰기 함정 2겹 (실측 T94·T95)**: 기본 상태에선 차트와 주입 템플릿이 저권한 포트 바인딩용
> `sysctls: [net.ipv4.ip_unprivileged_port_start=0]`을 자동으로 넣어준다. 근데 securityContext를
> 직접 지정하는 순간 — gateway 차트에서든(T94) istiod values에서든(T95) — 이 자동 기본이
> **통째로 사라진다**. 그래서 위처럼 `ip_unprivileged_port_start`를 같이 다시 적는다.
> (빼먹으면 runAsNonRoot인 gateway가 1024 미만 포트를 못 연다)
적용·확인 주의 2개:
1. webhook은 pod **생성 시점**에만 개입한다. istiod values를 바꾼 뒤 gateway를 rollout restart 해야 반영된다.
2. 확인은 Deployment가 아니라 **pod**에서 한다. Deployment에는 차트 값이 그대로 남아 있어서 속는다.
`tcp_tw_reuse=1`은 Helm values에 적는 것만으로 안 먹는다. 관문 2개를 먼저 열어야 하고, 빼먹으면 pod가 `SysctlForbidden`으로 뜬다:
```yaml
# (1) kubelet: 해당 노드 그룹에 allowedUnsafeSysctls 허용 (safe 목록에 없는 sysctl이라서)
kubelet_config_extra_args:
allowedUnsafeSysctls: ["net.ipv4.tcp_tw_reuse"]
# (2) 네임스페이스 PSA(Pod Security Admission) 정책이 unsafe sysctl을 허용하는지 확인
# 의미: 나가는 연결에 TIME_WAIT 포트 재사용 허용 (TCP timestamps 켜져 있어야 함, 기본 on)
# 참고: ip_local_port_range·ip_unprivileged_port_start는 safe sysctl — 관문 없이 선언만으로 적용
```
**확인**:
```bash
# 1) 최종 pod spec에 sysctls가 남아 있나 — Deployment 말고 pod를 본다 (T95 함정)
kubectl -n istio-system get pod -l istio=egressgateway \
-o jsonpath='{.items[0].spec.securityContext.sysctls}'
# 2) 커널에 실제로 박혔나
kubectl -n istio-system exec deploy/istio-egressgateway -- \
cat /proc/sys/net/ipv4/tcp_tw_reuse /proc/sys/net/ipv4/ip_local_port_range
# 기대: 1 / 10240 60999
```
알람은 TIME_WAIT 개수(9장). 2만을 넘으면 커널 튜닝이 아니라 앱 keep-alive 캠페인 신호다.
---
## 6. P3 — 한동안 안 쓰던 연결이 죽어 있다
### 문제
증상이 두 갈래다. 원인이 다르니 구분해야 한다:
1. **유휴 후 첫 요청만 실패한다** — 방화벽이 오래 조용한 연결을 세션 테이블에서 조용히 지운 것. 연결 양쪽은 살아 있다고 믿는데 중간이 끊겨 있다. 트래픽 적은 채널에서 먼저 터진다. (바쁜 채널은 유휴해질 틈이 없다)
2. **정확히 N초(기본 1시간)에 끊긴다** — Envoy 자신의 `idleTimeout`이다.
keep-alive로 연결을 오래 쓰기 시작하면(5장) 반드시 따라오는 문제다. 연결이 오래 살수록 유휴 구간도 생기니까.
### 해결
원인이 둘이라 설정도 둘 다 필요하다:
- 방화벽 몫: `tcpKeepalive` — 주기적으로 안부 패킷(probe)을 보내 방화벽 세션 타이머를 갱신한다. (5장의 앱 keep-alive와는 다른 물건이다 — 그건 연결 재사용, 이건 살아 있는 연결의 생존 신호)
- Envoy 몫: `idleTimeout` — keepalive probe는 데이터가 아니라서 **Envoy의 idle 타이머를 못 돌려놓는다** (실측 확인). "keepalive 넣었는데 정확히 1시간마다 끊긴다"가 바로 이 경우다. 채널의 최장 유휴 시간보다 길게 직접 정한다.
### 상세 — keepalive 세 필드
| DR 필드 | 소켓 옵션 | 역할 | 시작값 |
|---|---|---|---|
| `time` | TCP_KEEPIDLE | **방화벽 세션 유지** — 유휴 time초 후 probe(빈 ACK)를 보내고, 그 왕복이 방화벽 타이머를 갱신 | 300 (방화벽 유휴 제한의 1/3 이하 — 부록 A) |
| `interval` | TCP_KEEPINTVL | **죽은 상대 감지** — 무응답이면 이 간격으로 재시도 | 30 |
| `probes` | TCP_KEEPCNT | 연속 무응답이 이 횟수면 죽었다고 보고 폐기 | 3 (30×3=90초 안에 죽은 연결 정리) |
Envoy는 연결을 열 때 소켓 옵션(setsockopt)만 걸고, probe는 커널이 보낸다. 커널 sysctl 값은 안 바뀐다. 그리고 이미 열려 있는 연결에는 소급되지 않는다. (기존 연결은 idleTimeout이나 maxConnectionDuration으로 자연 교체되게 한다)
```yaml
# DR-hop2 (gateway→외부)에 추가 (전체는 3장)
connectionPool:
tcp:
idleTimeout: 1800s # Envoy 몫 — 채널 최장 유휴보다 길게
maxConnectionDuration: 3600s # 수명 상한 — 재분배·drain 자연 소멸 (재연결 민감 채널은 제외)
tcpKeepalive: { time: 300s, interval: 30s, probes: 3 } # 방화벽 몫
```
### (선택) 채널별 DR 없이 전역으로
mesh 전체에 기본 keepalive를 깔 수도 있다. (DR이 있으면 DR이 이긴다)
```yaml
meshConfig:
tcpKeepalive: { time: 300s, interval: 30s, probes: 3 }
# time은 "모든 채널의 방화벽 유휴 제한 중 최솟값의 1/3 이하"로 — 전역이라 채널별 조정 여지가 없다
```
주의: pod sysctl(`net.ipv4.tcp_keepalive_*`)만으로는 Envoy 연결에 keepalive가 아예 안 켜진다.
sysctl은 값만 정하고, 켜는 스위치(`SO_KEEPALIVE`)는 소켓을 만든 쪽이 걸어야 한다. Envoy는 설정 없이는 안 건다.
**확인**: 소켓의 timer 필드를 직접 본다 (8장의 ② — sysctl로 확인하면 보는 곳이 틀린 거다).
---
## 7. P4 — 배포할 때마다 연결이 끊긴다
### 문제
재배포나 스케일 인 시점마다 클라이언트가 일괄 reset을 받는다.
원인: gateway 종료 시 살아 있는 연결을 기다려주는 시간(`terminationDrainDuration`)이 기본 **5초**뿐이다. 수명이 긴 연결에겐 너무 짧다.
### 해결
drain을 늘리고, 동시에 죽는 pod 수를 제한하고(PDB), 연결 수명에 상한을 둔다. (P3의 `maxConnectionDuration`이 여기서도 일한다 — 수명 상한이 있으면 drain 시간 안에 자연 소멸된다)
### 상세
```yaml
# gateway Helm values 중 P4 관련 부분 (전체는 3장)
podAnnotations:
proxy.istio.io/config: |
terminationDrainDuration: 300s # 기본 5s -> 300s
proxyMetadata:
EXIT_ON_ZERO_ACTIVE_CONNECTIONS: "true" # drain 중 연결 0이면 조기 종료
```
```yaml
# PDB (gateway 차트가 안 만들어줌 — 별도 적용)
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata: { name: egressgateway, namespace: istio-system }
spec:
minAvailable: 2
selector: { matchLabels: { istio: egressgateway } }
```
**확인**: 카나리 배포 중 `envoy_cluster_upstream_cx_destroy` 급증 여부와 클라이언트 에러율.
---
## 8. 적용 검증 — 확인할 pod를 헷갈리지 말 것
홉 1은 **클라이언트 sidecar**에서, 홉 2는 **gateway pod**에서 확인한다. 반대로 잡으면 영원히 "미적용"으로 보인다.
설정은 이 순서로 흐른다. 순서대로 확인하면 어디서 끊겼는지 바로 나온다:
```mermaid
flowchart LR
DR["DR"] --> XDS["istiod
(xDS)"] --> CL["Envoy
cluster"] --> SOCK["커널
소켓"] --> WIRE["와이어
probe"]
```
### ① xDS — Envoy cluster에 도달했나
```bash
# 홉 1 — 클라이언트 sidecar: 풀 + ISTIO_MUTUAL + sni가 subset cluster에 붙었나
istioctl proxy-config cluster deploy/ -n \
--fqdn istio-egressgateway.istio-system.svc.cluster.local -o json | \
jq '.[] | select(.name | contains("|partner-a|")) |
{name, dr: .metadata.filterMetadata.istio.config,
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-a.example.com -o json | \
jq '.[] | {name, max: .circuitBreakers.thresholds[0].maxConnections,
ka: .upstreamConnectionOptions.tcpKeepalive,
transportSocket: (.transportSocket // "ABSENT (raw TCP)")}'
```
읽는 법:
- `dr: null`이면 값 이전의 문제다. DR이 cluster에 아예 안 붙은 것이다. (host 불일치, exportTo, 중복 DR, pod를 잘못 잡음)
- `max: 4294967295`면 connectionPool이 그 cluster에 안 닿은 것이다. (미설정 상한에 주입되는 값)
- subset 줄만 `ka: null`이면 통째 교체 함정이다. subset에 tcpKeepalive를 다시 안 적은 것이다.
### ② 소켓 — 커널에 실제로 박혔나
DR의 keepalive는 sysctl이 아니라 소켓 옵션으로 들어간다. 그래서 `sysctl`로 확인하면 보는 곳이 틀린 거다. `ss -o`의 timer 필드를 본다:
```bash
kubectl exec deploy/istio-egressgateway -n istio-system -- \
ss -tno state established 'dst <외부IP>'
# 기대: timer:(keepalive,4min32sec,0) ← 300초에서 카운트다운 = time:300 적용 확정
# 미적용: timer가 없거나(SO_KEEPALIVE off), 2시간 근처(커널 기본 7200s)
```
### ③ 경유 증명 — 200이 아니라 이게 완료 조건이다
VS 매칭이 어긋나면 sidecar가 gateway를 건너뛰고 직접 나간다 (`outboundTrafficPolicy: ALLOW_ANY` 기준).
호출은 200이라 멀쩡해 보이는데, 신원 확인은 한 겹도 안 된 상태다. 그래서 gateway access log로 경유를 증명해야 끝난 거다:
```bash
kubectl -n istio-system logs deploy/istio-egressgateway --tail=5 | \
grep 'outbound|443||api.partner-a.example.com'
```
### ④ (선택) 와이어 — probe가 실제로 나가나
```bash
# gw 노드에서: 유휴 300초 후 length 0 ACK → 상대의 즉시 ACK, 이후 300초 주기 반복
tcpdump -ni any host <외부IP> and port 443
```
---
## 9. 운영 — 장애 런북과 알람
### 9.1 원인 구분 — 클라이언트 증상은 다 똑같다
풀 초과, 포트 고갈, 인가 거부, 설정 오류가 클라이언트에선 전부 "SSL_ERROR_SYSCALL / Connection reset"류로 보인다.
구분은 gateway 쪽 기록으로만 가능하다. 이 표가 없으면 정상 통제 동작을 장애로 오인한다:
| gateway 쪽 기록 | 원인 | 대응 |
|---|---|---|
| access log flag `UO` | Envoy 연결 풀 초과 (P1) | maxConnections 재계산 |
| access log flag `UF` | gw→외부 connect 실패 — 포트 고갈(P2) 또는 외부 장애 | `ss -tan state time-wait \| wc -l` 확인 |
| rbac 로그 `denied` | AuthorizationPolicy 거부 — **정상 통제 동작** | 정책 확인 |
| gw 로그 미도달 + sidecar에 `PassthroughCluster` | mesh 쪽 VS 미매칭 (sniHosts 오타, sidecar 미주입) | 정렬 체크리스트(3장) 대조 |
| handshake 즉시 실패 | DR `sni` 누락/오타, 비-mesh 클라이언트 | 정렬 체크리스트 대조 |
| 무응답 timeout (reset조차 없음) | conntrack 유실 — 대규모에서만 (부록 B) | 노드 dmesg + `conntrack -C` |
| 유휴 후 첫 요청만 실패 | 방화벽이 유휴 연결을 이미 끊어둠 (P3) | keepalive.time 재점검 |
| 정확히 N초(기본 1h)에 절단 | Envoy `idleTimeout` — keepalive로는 안 막힘 (P3) | idleTimeout 직접 설정 |
진단 순서: ① gw access log(거쳐갔나) → ② rbac 로그(왜 거부됐나) → ③ `istioctl proxy-config listeners`·`istioctl analyze`(설정 정합) → ④ `ss`·`conntrack`(커널). tcpdump는 최후 수단이다.
### 9.2 알람 (Prometheus) — 문제별 1:1
| 알람 | 조건 | 의미 |
|---|---|---|
| 연결 거부 | `rate(envoy_cluster_upstream_cx_overflow[5m]) > 0` | P1 발동 — 재계산 필요 |
| 풀 사용률 | `upstream_cx_active / maxConnections > 0.8` | P1 선제 증설 신호 |
| connect 실패 | `rate(envoy_cluster_upstream_cx_connect_fail[5m]) > 0` | P2 또는 외부 장애 |
| TIME_WAIT | egress 노드 `node_sockstat_TCP_tw > 20000` | 연결 생성·소멸 과다 → **앱 keep-alive 캠페인** |
| conntrack | `node_nf_conntrack_entries / limit > 0.7` | 노드 전역 자원 경계 신호 (부록 B) — 울리기 전엔 조치 불필요 |
주의 하나: `upstream_cx_overflow` 카운터는 mesh의 stats matcher 설정에 따라 노출되지 않을 수 있다. 실제로 T52 실측 환경에서 카운터가 안 보여서 access log의 `UO`로만 확인 가능했다. 알람을 걸기 전에 카운터가 노출되는지부터 확인한다.
### 9.3 그 밖의 운영 주의 4개
1. **maxConnections를 무한정 키우면 격벽이 사라진다.** 외부 기관 한 곳이 느려지면 그 cluster에 연결이 쌓이고, gateway 메모리가 마르면 다른 목적지까지 번진다. (메모리 부담 = 동시 연결 × 소켓 2개 × 버퍼 약 1MiB)
2. **재연결에 민감한 채널(전문망, long-lived 스트림)에는 시작값을 그대로 쓰면 안 된다.** `maxConnectionDuration`을 빼거나 늘리고, drain도 더 늘린다. 시작값은 일반 REST API 파트너 기준이다.
3. **도입 전에 기준선을 잰다.** 직접 나갈 때 대비 p99 연결 수립 시간, 초당 연결, TIME_WAIT 추이, gateway CPU. "gateway 때문에 느려졌다" 논쟁을 데이터로 끝내는 장치다.
4. **상대 기관 입장도 있다.** 파트너 서버가 보는 출발 IP가 몇 개로 줄어든다. 상대쪽 IP 기반 rate limit이나 이상탐지에 걸릴 수 있으니 미리 알려준다.
참고 둘: outlierDetection은 단일 IP 목적지에선 기본값(ejection 상한 10%)에 막혀 사실상 무력하다 — DNS가 IP를 여러 개 줄 때만 검토한다. connectTimeout은 HTTP 경로에선 Istio 기본 재시도 2회와 곱해져 최악 지연이 3배가 된다 — 이 패턴의 두 홉(TCP)에는 해당 없지만, 같은 값을 HTTP 경로에 재사용할 때는 같이 계산한다.
---
## 부록 A — 값을 조정하고 싶을 때 (측정과 계산)
시작값이 안 맞는 큰 채널은 3개를 측정해서 다시 계산한다. 측정은 gateway 도입 **전에** sidecar 메트릭에서:
```
① 채널별 최대 동시 연결 + 초당 새 연결
istio_tcp_connections_opened_total{destination_service="<외부host>"} → 최대치, rate()로 conn/s
② 방화벽/중간장비의 유휴 제한 시간 — 네트워크팀 확인 (보통 30~60분)
③ SNAT 여부 — CNI 설정 확인 (5장)
```
```mermaid
flowchart LR
I1["① 최대 동시 연결
예: 1,500"] -->|"× 2~3 여유"| O1["maxConnections
4096"]
I2["① 초당 새 연결
예: 250"] -->|"÷ 470 + 이중화"| O2["replicaCount
3"]
I3["② 방화벽 유휴 제한
예: 1800s"] -->|"× 1/3 이하"| O3["keepalive.time
300"]
```
`idleTimeout`은 채널의 최장 유휴 시간보다 길게 잡는다. (시작값 1800s)
채널 온보딩 시트 — 시트에는 값이 아니라 계산식을 적는다. 다음 사람이 숫자를 복사하는 사고를 막는 장치다:
| 채널 | 최대 동시 연결 (측정) | conn/s (측정) | maxConnections (=최대×2~3) | 방화벽 유휴 제한 → keepalive.time (=1/3) | idleTimeout (최장 유휴+여유) |
|---|---|---|---|---|---|
| partner-a | 1,500 | 250 | 4096 | 1800s → 300 | 1800s |
| partner-b | … | … | … | … | … |
온보딩 = "DR 1세트 추가 + 전역값 재검토". 전역(레이어 2)은 모든 채널의 conn/s 합으로 다시 계산한다.
## 부록 B — conntrack (노드 sysctl): 다채널 합산 규모에서만
일반 규모에선 손댈 필요 없다. 기본 한도 약 26만은 Envoy 1,024나 포트 28,000보다 훨씬 넓어서, P1·P2를 잡았다면 여기까지 안 온다. 다만 알람 하나(9.2절)는 걸어둔다. 이게 터지면 reset도 없이 조용히 패킷이 사라져서, 알람 없이는 진단이 제일 어렵다.
- 계산식: 전 채널 합산 conn/s × 잔류 120초 = 상시 점유 엔트리 (예: 초당 250개 → 3만 ≈ 기본 한도의 12%)
- 알람이 70%에 접근하면 그때 조치한다 (전용 egress 노드풀의 **노드** `/etc/sysctl.d` — conntrack은 패킷이 호스트 netns의 iptables/CNI를 지날 때 생기므로 pod sysctl이 아니다):
```bash
# /etc/sysctl.d/90-egress-gw.conf
net.netfilter.nf_conntrack_max = 1048576 # 기본 ~26만
net.netfilter.nf_conntrack_tcp_timeout_time_wait = 30 # 기본 120s 단축
```
## 부록 C — 검증 상태: 무엇이 실측이고, 무엇을 적용 환경에서 재확인해야 하나
**실측 완료** (Istio 1.30.0 / k8s 1.30.6 — 원자료는 각 정본 문서의 검증 번들, "검증 기록" 절 참조):
| 주장 | 방법 |
|---|---|
| connectionPool이 홉별로 다른 프록시에 적용 (홉1=sidecar, 홉2=gateway) | 식별값 111/222 주입 후 양쪽 cluster 덤프 |
| 홉 2 cluster에 transportSocket 부재 = raw TCP, inner TLS end-to-end 보존 | E2E 호출 + config dump + 서버측 SNI/cert 검증 |
| `tls.mode: tcp` 미존재 (CRD 거부) | apply 실측 |
| keepalive probe가 Envoy idleTimeout을 리셋하지 못함 | 유휴 절단 실측 |
| DR keepalive는 sysctl이 아니라 소켓 옵션 (`ss -o` timer로 검증) | 소켓 실측 |
| Envoy cluster 기본 1024 / connectionPool 부분 설정 시 무제한 주입 | config dump 실측 |
| subset/portLevelSettings 통째 교체 (deep-merge 아님) | config dump 실측 |
| TIME_WAIT 60s (커널 하드코딩) → pod당·목적지당 ~470 conn/s | 커널 소스 + 실측 |
**적용 환경에서 재확인 필요** — 이 표를 채우기 전에는 시작값을 그대로 운영값으로 확정하지 말 것:
| 항목 | 확인 방법 | 값에 미치는 영향 |
|---|---|---|
| Istio 버전·설치 방식 | — | API·기본값 차이 (본 문서는 1.30 기준) |
| 방화벽/중간장비 유휴 제한 시간 | 네트워크팀 | `keepalive.time`·`idleTimeout` 계산 입력 |
| SNAT 여부 (CNI 설정) | CNI IPPool 설정 | antiAffinity 필수 여부, 방화벽 등록 단위, 포트 계산 전제 |
| 채널별 최대 동시 연결·conn/s | 도입 전 sidecar 메트릭 | `maxConnections`·replica 수 |
| kubelet unsafe sysctl 정책 | 클러스터 관리 주체 | `tcp_tw_reuse` 적용 가능 여부 |
| keepalive ACK를 세션 갱신으로 인정 안 하는 중간장비 | 유휴 30분 후 첫 요청 실측 | 드물지만 존재 — 채널별 확인 |
| P2·P4 로그 시그니처 (`UF`+`EADDRNOTAVAIL` · `upstream_cx_destroy` 급증) | 장애 시 gateway 로그·지표 | 직접 재현 실측 아님 — Envoy 공식 문서 기준 (부록 D) |
| istiod `gateways.securityContext` 경유 sysctl의 최종 pod 반영 | istiod values 적용 + gateway 재기동 후 pod spec·`/proc` | T95는 injector configmap 렌더링까지 실측 — 클러스터 최종 반영은 적용 환경에서 확인 |
## 부록 D — 출처 (외부 공식 문서)
- Envoy access log response flags (`UO`·`UF`의 정의): envoyproxy.io › configuration › observability › access_log › usage
- Envoy circuit breaking 기본값(cluster당 1024): envoyproxy.io › intro › arch_overview › upstream › circuit_breaking
- Envoy tcp_proxy idleTimeout 기본 1h: envoyproxy.io › api-v3 › tcp_proxy.proto
- Istio DestinationRule 레퍼런스 (ConnectionPoolSettings·ClientTLSSettings): istio.io › docs › reference › config › networking › destination-rule
- TCP keepalive 소켓 옵션: man 7 tcp (TCP_KEEPIDLE/KEEPINTVL/KEEPCNT)
- 리눅스 ephemeral port 범위: docs.kernel.org › networking › ip-sysctl
- k8s sysctl 정책 (safe/unsafe): kubernetes.io › tasks › administer-cluster › sysctl-cluster
---
## 검증 기록 (2026-07-06 · Istio 1.30.0 / k8s 1.30.6)
이 문서는 종합본이라 실측 대부분을 소스 정본에서 인용한다 — 부록 C "실측 완료" 표의 원본:
| 인용 주장 | 원본 실측 |
|---|---|
| connectionPool 홉별 적용(식별값 111/222) · 홉 2 transportSocket 부재 · `tls.mode: tcp` 미존재 | [mTLS Passthrough — 홉별 DR·connectionPool 구성](/docs/istio/egress/mtls-passthrough-connectionpool/) T91 |
| 이중 기본값(4294967295 명시 주입) · connectTimeout×retry 곱 | [DR connectionPool 정본](/docs/istio/egress/dr-connection-settings/) T93 |
| keepalive ↔ 커널 소켓 옵션 1:1 매핑 · probe가 idleTimeout을 리셋 못함 | [tcpKeepalive 필드 노트](/docs/istio/egress/tcp-keepalive-fields/) T55·T16 |
| subset/portLevelSettings 통째 교체(deep-merge 아님) | [DestinationRule 만들기](/docs/istio/egress/destinationrule-fundamentals/) T53 |
| Envoy cluster 기본 1024 발동(UO) | [circuit breaking 메커니즘](/docs/istio/egress/circuit-breaking-mechanisms/) T52 |
| TIME_WAIT 60s(커널 하드코딩) → ~470 conn/s | [Egress TCP 병목 정본](/docs/istio/egress/tcp-bottlenecks/) T57 |
이 문서 자체 실측 — **T94**: istio/gateway Helm chart 1.30.0의 pod securityContext 동작 (차트 렌더링 검증, 클러스터 불요). 원자료: [run.sh](files/verify/T94/run.sh) · [result.txt](files/verify/T94/result.txt) · [verdict.json](files/verify/T94/verdict.json)
| # | 주장 | 실측 결과 | 판정 |
|---|---|---|---|
| C1 | 차트가 pod-level `securityContext` value 노출 (기본 `{}`) | `helm show values`에 존재 — 주석: "미설정 시 80/443 바인딩 최소 권한 자동 설정" | ✅ 지지 |
| C2 | 미설정 시 `sysctls: [net.ipv4.ip_unprivileged_port_start=0]` 자동 주입 | 기본 `helm template` 렌더링에서 해당 블록 확인 | ✅ 지지 |
| C3 | values 지정 시 자동 기본이 **통째 대체** (merge 아님) | `tcp_tw_reuse`만 지정해 렌더링 → `ip_unprivileged_port_start` 소멸 | ✅ 지지 |
이 문서 자체 실측 — **T95**: gateway 차트 values의 sysctls가 sidecar injector(gateway 주입 템플릿)에 의해 최종 pod에서 덮어써지는 문제 (클러스터 실측, 격리 ns `istio-vt-t95`). 원자료: [run.sh](files/verify/T95/run.sh) · [result.txt](files/verify/T95/result.txt) · [verdict.json](files/verify/T95/verdict.json) · [gateway values](files/verify/T95/t95-gateway-values.yaml) · [istiod fix values](files/verify/T95/t95-istiod-fix-values.yaml)
| # | 주장 | 실측 결과 | 판정 |
|---|---|---|---|
| C1 | 차트 values의 sysctls는 Deployment까지 렌더링됨 (T94 재확인) | Deployment pod template에 sysctl 2종(`ip_unprivileged_port_start`·`ip_local_port_range`) 존재 | ✅ 지지 |
| C2 | 주입 webhook이 pod-level securityContext를 통째 교체 → values의 sysctls 소실 | 최종 pod에 `ip_unprivileged_port_start=0`만 잔존, `/proc`의 ip_local_port_range = 32768 60999(커널 기본값). 에러·이벤트 없음 | ✅ 지지 |
| C3 | 실효 주입 지점 = istiod values `gateways.securityContext` | istiod 차트 렌더링에서 injector configmap `data.values` 전달·템플릿 분기 사용 확인. 클러스터 최종 반영은 미실행(컨트롤플레인 변경 승인 필요) | ⚠️ 렌더링 수준 지지 |
---
## 참조
**아카이브 내부 — 각 주제의 정본**
- [mTLS Passthrough — 홉별 DR·connectionPool 구성](/docs/istio/egress/mtls-passthrough-connectionpool/) — 3장 레이어 1·4장의 정본 (T91)
- [DR connectionPool 정본 — 필드별 동작·매핑·관측](/docs/istio/egress/dr-connection-settings/) — 필드 12종 상세·이중 기본값·retry 곱 (T93)
- [tcpKeepalive 필드 노트 — time·interval·probes](/docs/istio/egress/tcp-keepalive-fields/) — 6장·8장 검증의 정본 (T55·T16)
- [Egress TCP 문제별 처방전](/docs/istio/egress/tcp-tuning/) — 4~7장 문제 중심 구조·부록 A 계산식의 정본
- [Egress TCP 병목 정본](/docs/istio/egress/tcp-bottlenecks/) — 5장 포트 계산·SNAT·런북·알람의 정본 (T57)
- [Egress mTLS Passthrough 해부](/docs/istio/egress/https-over-mtls/) — 1장 패턴 자체의 정본 (T07)
- [Egress HTTPS 패턴 지도](/docs/istio/egress/https-pattern-map/) — 용어 정본
- [Pod 커널 파라미터 정본](/docs/istio/egress/pod-sysctl-netns/) — 5장 sysctl netns 초기화·safe/unsafe 관문의 정본