homelab89 Docs Logs Legacy Files ☰ TOC 🌓
guideistio 2026-07-06egressmtlsconnection-pooltcphelmrunbook

mTLS Passthrough TCP 운영 종합 — connectionPool·port reuse·런북

ABSTRACT

egress mTLS Passthrough(HTTPS over mTLS, 용어 정본) 채널의 TCP 운영 설정을 한 문서에 모았다. 구성은 두괄식이다 — 문제 4가지(2장) → 답이 되는 전체 설정 한 벌(3장) → 문제별 원리·설정 상세(4~7장) 순서다. 각 주제의 정본은 문서 끝 참조 절에 있다.

대상 환경: Istio 1.30.0, sidecar mesh (homelab k8s 1.30.6 기준 컨벤션) 검증: 본문 주장 대부분은 소스 정본들의 클러스터 실측(T16·T52·T53·T55·T57·T91·T93 — 각 정본의 검증 기록 참조)을 인용한다. 이 문서 자체 실측은 gateway Helm chart의 securityContext 동작 1건(T94 — 문서 끝 “검증 기록”, 번들 files/verify/T94/)이다. 읽는 법: 바로 적용할 사람은 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) gateway Helm chart
P3 (6장) 한동안 안 쓰던 연결이 죽어 있다 방화벽이 유휴 연결을 조용히 끊는다. Envoy도 1시간이면 스스로 끊는다 tcpKeepaliveidleTimeout둘 다 설정 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)
flowchart LR
  APP["app<br/>(inner TLS 생성)"] --> SC["sidecar<br/>(outer mTLS 래핑)"]
  SC -->|"홉 1: outer mTLS"| GW["egress gateway<br/>(outer만 벗겨 신원 확인)"]
  GW -->|"홉 2: TCP 그대로 전달"| EXT["외부 서버<br/>(inner TLS 종단)"]

와이어 어디에도 평문 구간은 없다. “홉 2가 일반 TCP"라는 말은 gateway가 TCP로 그대로 전달한다는 뜻이지, 암호화가 없다는 뜻이 아니다.


2. 문제 4가지 — 증상부터

gateway를 도입하면 이 넷 중 하나는 만난다. 증상으로 먼저 구분한다.

# 증상 gateway 쪽 기록 원인 한 줄
P1 특정 채널 호출이 일정 개수에서 거부된다 access log flag UO Envoy 기본 상한 1,024에 전사 트래픽이 부딪힘
P2 외부로 새 연결 자체가 안 열린다 flag UF + 로그 EADDRNOTAVAIL 발신 포트 고갈 — pod당 약 28,000개, 닫은 포트는 60초 잠김
P3 유휴 후 첫 요청만 실패 / 정확히 1시간마다 절단 기록 없음 — 시간 패턴으로 구분 방화벽 유휴 절단 + Envoy idleTimeout
P4 배포·스케일 인마다 일괄 reset upstream_cx_destroy 급증 종료 전 대기(drain)가 기본 5초

어느 문제부터 만나나 — 앱의 연결 사용 방식이 정한다

앱이 연결을 쓰는 방식은 둘 중 하나다.

  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) 각 값이 왜 필요한지는 주석의 P1P4 표시를 따라 47장에서 찾는다. 채널마다 넣는 것(레이어 1 — CRD 5종)과, 전역으로 한 번 넣는 것(레이어 2 — gateway Helm chart)으로 나뉜다.

레이어 1 — 배선 + DR 2개 (채널당 1세트)

# 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 — gateway Helm chart values (전역 1벌)

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 }

securityContext:                   # pod-level — P2 커널 파라미터 (차트 함정은 5장)
  sysctls:
  - { name: net.ipv4.ip_unprivileged_port_start, value: "0" }
  - { name: net.ipv4.ip_local_port_range, value: "10240 60999" }
  - { name: net.ipv4.tcp_tw_reuse, value: "1" }   # kubelet·PSA 관문 선행 (5장)

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 }

(+ P4의 PDB는 7장, tcp_tw_reuse 관문 2개는 5장 참조)


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는 프록시마다 따로 있다. 그래서 설정이 실제로 먹는 곳은 그 목적지로 연결을 여는 프록시다.

flowchart LR
  DR1["DR-hop1<br/>(host: gateway)"] -.->|컴파일| SC["호출자 sidecar의 cluster<br/>→ pod당 상한"]
  DR2["DR-hop2<br/>(host: 외부 host)"] -.->|컴파일| GW["gateway의 cluster<br/>→ 전사 합류 상한"]
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장):

# DR-hop2 (gateway→외부) — 전사 연결이 모이는 곳, 핵심
    connectionPool:
      tcp:
        maxConnections: 4096   # 시작값 — 큰 채널은 부록 A로 재계산
        connectTimeout: 3s     # 외부 장애 시 빠른 실패 (기본 10s)
# 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개가 산수로 정해버린 한계라, 규모가 커지면 어떤 채널이든 만난다.

원리 1 — gateway는 연결을 재사용하지 못한다

gateway는 연결을 “통과"시키는 장치가 아니다. 양쪽에 연결을 하나씩 잡고, 가운데서 바이트를 복사하는 중계기다.

flowchart LR
  APP["app"] -->|"연결 A"| GW["gateway<br/>소켓 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 — 발신 포트는 유한하다

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대면 몇십 초 안에 포트가 마른다.

해결 (우선순위 순)

  1. 앱 keep-alive — 유일한 근본 처방. keep-alive는 응답이 끝나도 연결을 닫지 않는 약속이다. 다음 요청이 그 연결을 그대로 탄다. 새 연결이 없으면 새 포트도 없고, TIME_WAIT도 안 쌓인다. 위 산술 자체가 무의미해진다. 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 — 커널 파라미터는 gateway Helm chart로 넣는다

커널 파라미터는 노드가 아니라 pod 네트워크 네임스페이스(netns) 값이다. 새 netns는 호스트 값이 아니라 커널 기본값으로 시작한다. 그래서 노드 /etc/sysctl.d에 넣어도 gateway pod에는 안 먹는다. 넣는 곳은 gateway Helm chart의 securityContext 하나다. (istio 1.30 istio/gateway 차트에서 pod securityContext로 그대로 렌더링되는 것 실측 확인 — T94)

차트 함정 (1.30 실측, T94): securityContext를 안 넣으면 차트가 저권한 포트 바인딩용 sysctls: [net.ipv4.ip_unprivileged_port_start=0]을 자동으로 넣어준다. 근데 values에 securityContext를 직접 넣는 순간 이 자동 기본이 통째로 사라진다. 그래서 아래처럼 ip_unprivileged_port_start를 같이 다시 적어야 한다. (빼먹으면 runAsNonRoot인 gateway가 1024 미만 포트를 못 연다)

# gateway Helm values 중 P2 관련 부분 (전체는 3장)
affinity:
  podAntiAffinity:                 # SNAT 환경에선 required가 필수
    requiredDuringSchedulingIgnoredDuringExecution:
    - labelSelector: { matchLabels: { istio: egressgateway } }
      topologyKey: kubernetes.io/hostname

securityContext:                   # pod-level 커널 파라미터
  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개 선행 (아래)

tcp_tw_reuse=1은 Helm values에 적는 것만으로 안 먹는다. 관문 2개를 먼저 열어야 하고, 빼먹으면 pod가 SysctlForbidden으로 뜬다:

# (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 — 관문 없이 선언만으로 적용

확인:

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으로 자연 교체되게 한다)

# DR-hop2 (gateway→외부)에 추가 (전체는 3장)
    connectionPool:
      tcp:
        idleTimeout: 1800s            # Envoy 몫 — 채널 최장 유휴보다 길게
        maxConnectionDuration: 3600s  # 수명 상한 — 재분배·drain 자연 소멸 (재연결 민감 채널은 제외)
        tcpKeepalive: { time: 300s, interval: 30s, probes: 3 }   # 방화벽 몫

(선택) 채널별 DR 없이 전역으로

mesh 전체에 기본 keepalive를 깔 수도 있다. (DR이 있으면 DR이 이긴다)

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 시간 안에 자연 소멸된다)

상세

# gateway Helm values 중 P4 관련 부분 (전체는 3장)
podAnnotations:
  proxy.istio.io/config: |
    terminationDrainDuration: 300s             # 기본 5s -> 300s
    proxyMetadata:
      EXIT_ON_ZERO_ACTIVE_CONNECTIONS: "true"  # drain 중 연결 0이면 조기 종료
# 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에서 확인한다. 반대로 잡으면 영원히 “미적용"으로 보인다.

설정은 이 순서로 흐른다. 순서대로 확인하면 어디서 끊겼는지 바로 나온다:

flowchart LR
  DR["DR"] --> XDS["istiod<br/>(xDS)"] --> CL["Envoy<br/>cluster"] --> SOCK["커널<br/>소켓"] --> WIRE["와이어<br/>probe"]

① xDS — Envoy cluster에 도달했나

# 홉 1 — 클라이언트 sidecar: 풀 + ISTIO_MUTUAL + sni가 subset cluster에 붙었나
istioctl proxy-config cluster deploy/<client> -n <app-ns> \
  --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 필드를 본다:

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로 경유를 증명해야 끝난 거다:

kubectl -n istio-system logs deploy/istio-egressgateway --tail=5 | \
  grep 'outbound|443||api.partner-a.example.com'

④ (선택) 와이어 — probe가 실제로 나가나

# 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) — 울리기 전엔 조치 불필요

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장)
flowchart LR
  I1["① 최대 동시 연결<br/>예: 1,500"] -->|"× 2~3 여유"| O1["maxConnections<br/>4096"]
  I2["① 초당 새 연결<br/>예: 250"] -->|"÷ 470 + 이중화"| O2["replicaCount<br/>3"]
  I3["② 방화벽 유휴 제한<br/>예: 1800s"] -->|"× 1/3 이하"| O3["keepalive.time<br/>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이 아니다):
# /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분 후 첫 요청 실측 드물지만 존재 — 채널별 확인

부록 D — 출처 (외부 공식 문서)

  • 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 구성 T91
이중 기본값(4294967295 명시 주입) · connectTimeout×retry 곱 DR connectionPool 정본 T93
keepalive ↔ 커널 소켓 옵션 1:1 매핑 · probe가 idleTimeout을 리셋 못함 tcpKeepalive 필드 노트 T55·T16
subset/portLevelSettings 통째 교체(deep-merge 아님) DestinationRule 만들기 T53
Envoy cluster 기본 1024 발동(UO) circuit breaking 메커니즘 T52
TIME_WAIT 60s(커널 하드코딩) → ~470 conn/s Egress TCP 병목 정본 T57

이 문서 자체 실측 — T94: istio/gateway Helm chart 1.30.0의 pod securityContext 동작 (차트 렌더링 검증, 클러스터 불요). 원자료: run.sh · result.txt · 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 소멸 ✅ 지지

참조

아카이브 내부 — 각 주제의 정본

Files