Istio 3-1편: 503과 Half-open Connection
이 글의 원문은 채널톡 테크블로그에서 확인할 수 있습니다.
안녕하세요, 채널코퍼레이션 DevOps팀 엔지니어 재티(정재홍), 딜런입니다.
이 글은 Istio Ambient mode 도입기 시리즈의 3-1편입니다. 3편에서는 프로덕션에 Ambient mode를 적용하면서 만난 여러 이슈를 다루려고 했는데, 그중 가장 까다로웠던 503 에러는 따로 떼어 깊게 정리할 필요가 있었습니다. 이번 글에서는 이 503 에러가 어떻게 half-open, 혹은 stale connection 문제로 이어졌는지 추적합니다.
- 1편: 왜 Istio Ambient mode인가?
- 2편: Envoy config로 해부하는 Ambient mode
- 3편: 프로덕션에서 만난 당황스러운 이슈들과 트러블슈팅
- 3-1편: 503과 Half-open Connection (현재 글)
- 3-2편: Partially Enrolled Pod와 Untaint Controller
- 3-3편: Ambient mode 안전하게 업그레이드하기
- 3-4편: 부록 - 507 status code와 istiod disconnected 탐지
이 글은 2편에서 설명한 Envoy config 구조를 전제로 합니다. 특히 waypoint가 in-mesh 목적지로 트래픽을 보낼 때 사용하는
connect_originatecluster, internal listener, ORIGINAL_DST cluster, HBONE 터널 개념을 알고 있으면 이후 분석을 따라가기 쉽습니다.
문제 상황
문제는 workload rollout, 즉 재시작이나 배포 과정에서 간헐적으로 발생한 503 응답이었습니다. 처음에는 Istio에서 503 원인으로 자주 언급되는 idle timeout, keep-alive timeout, config propagation delay를 의심했습니다. 하지만 timeout 값을 조정해도 증상이 사라지지 않았고, 설정 전파 지연으로 설명하기에도 로그 타이밍이 맞지 않았습니다.
재현 빈도도 환경마다 달랐습니다. 애플리케이션 배포와 재시작이 더 잦은 dev 환경에서는 비교적 자주 재현되었지만, prod에서는 드문 확률로만 관찰되었습니다.
요청 경로와 503이 만들어지는 지점을 단순화하면 다음과 같습니다.
flowchart LR
C["Client"]
GW["public gateway<br />(Envoy)"]
WP["waypoint<br />(Envoy)"]
ZT["ztunnel<br />:15008"]
POD["새로 생성된 Pod"]
C --> GW --> WP -->|"HBONE"| ZT --> POD
GW -. "503 / via_upstream" .-> C
WP -. "503 / UC<br />connection_termination" .-> GW
style WP fill:#ffcdd2
style ZT fill:#fff3cd
컴포넌트별 로그를 따라가면 503을 실제로 만든 지점이 보입니다. public gateway는 response_code: 503, response_code_details: via_upstream을 남겼고, upstream_host는 envoy://connect_originate/...:15008을 가리키고 있었습니다. 이는 gateway가 직접 에러를 만든 것이 아니라, 아래 단계인 waypoint에서 받은 503을 전달했다는 뜻입니다.
waypoint 로그에서는 response_code: 503, response_code_details: upstream_reset_before_response_started{connection_termination}, response_flags: UC가 찍혔습니다. UC는 UpstreamConnectionTermination의 약자로, upstream connection이 응답을 시작하기 전에 끊겼다는 의미입니다.
그런데 waypoint 바로 다음 hop인 ztunnel에는 이상 로그가 없었습니다. istio-cni에서도 특이한 흔적을 찾지 못했습니다. waypoint는 upstream이 connection을 끊었다고 말하는데, 정작 upstream에 해당하는 ztunnel에서는 에러가 관찰되지 않았습니다. 그래서 처음 던진 질문은 단순했습니다. connection은 어디에서 끊어진 것일까?
문제 재현
프로덕션에서 이 문제를 직접 디버깅하기는 어려웠습니다. 실제 서비스 환경에서는 istio 컴포넌트가 만들어내는 트래픽 양이 많아, 문제의 흔적만 골라내기가 쉽지 않았습니다. 그래서 dummy application과 전용 gateway, waypoint, ztunnel을 별도로 띄운 격리 환경을 구성했습니다.
범위를 줄여가며 확인한 결과, gateway를 제거한 Pod -> waypoint -> waypoint 경로에서도 같은 503을 재현할 수 있었습니다. 이후 분석은 waypoint, ztunnel, destination Pod 사이의 구간에 집중했습니다.
기존 access log만으로는 더 들어가기 어렵다고 판단해 두 가지 정보를 추가로 수집했습니다.
- waypoint Envoy의 debug level 로그
- destination Pod 내부에서 캡처한 TCP 패킷
패킷 캡처를 위해 NET_RAW와 NET_ADMIN 권한을 가진 tcpdump sidecar를 주입했습니다. 그리고 문제가 Pod 종료 시점과 관련 있다고 보고, Pod 생성부터 종료 이후 잔여 패킷까지 전체 라이프사이클을 캡처하도록 구성했습니다. 캡처된 pcap은 분석을 위해 S3에 업로드했습니다.
문제 분석
정상과 비정상의 차이를 pcap에서 찾다
destination Pod에서 직접 tcpdump를 떠서 Wireshark로 열었을 때, 이 문제가 단순한 access log 노이즈가 아니라는 확신을 얻었습니다.
destination Pod의 모든 network interface를 캡처하면 두 종류의 패킷이 동시에 보입니다. 하나는 waypoint에서 ztunnel로 들어오는 HBONE/mTLS 구간의 암호화된 패킷입니다. 다른 하나는 ztunnel socket을 거쳐 application으로 전달되는 평문 패킷입니다. 덕분에 같은 Pod 안에서 터널 내부의 암호화된 트래픽과 터널을 빠져나온 평문 트래픽을 나란히 비교할 수 있었습니다.
flowchart LR
WP["waypoint<br />(Envoy)"]
subgraph POD["destination pod (tcpdump: 전체 interface)"]
direction LR
ZT["ztunnel socket<br />:15008"]
APP["application"]
end
WP -->|"1. encrypted<br />(HBONE / mTLS)"| ZT
ZT -->|"2. decrypted<br />(plaintext)"| APP
style ZT fill:#fff3cd
503을 재현했을 때 포착된 장면은 결정적이었습니다. destination Pod가 새로 생성된 직후인데도 TLS handshake 없이 application data stream이 곧바로 들어오고 있었습니다. 정상이라면 암호화 구간에서 TCP handshake와 TLS handshake가 먼저 이루어진 뒤 data frame이 오가야 합니다. 그런데 비정상 케이스에서는 그 과정이 통째로 생략된 채 data frame이 먼저 들어왔습니다.
정상 케이스에서는 다음처럼 HBONE 터널을 새로 수립한 뒤에 data frame이 오갑니다.
sequenceDiagram
participant W as Waypoint
participant Z as Ztunnel
participant P as Pod (신규)
W->>Z: TCP Handshake (SYN/SYN-ACK/ACK)
W->>Z: TLS Handshake
Note over W,Z: HBONE 터널 수립
W->>Z: HTTP/2 Data Frame (GET /ping)
Z->>P: Decapsulated Request
P->>Z: 200 OK
Z->>W: HTTP/2 Data Frame (Response)
반면 비정상 케이스에서는 handshake 없이 data frame이 먼저 도착합니다. 새 Pod의 network namespace에는 이 TCP connection 상태가 없기 때문에, kernel TCP stack은 RST로 응답합니다.
sequenceDiagram
participant W as Waypoint
participant Z as Ztunnel
participant P as Pod (신규)
Note over W,Z: TCP/TLS handshake 없음<br />(기존 connection 재사용)
W->>Z: HTTP/2 Data Frame
Note over Z: 새 Pod netns에는 기존 TCP 상태 없음
Z-->>W: TCP RST
W-->>W: upstream_reset -> 503 (UC)
이 장면에서 의심이 좁혀졌습니다. 새 Pod가 막 떴는데도 application data가 handshake 없이 이어서 들어왔다는 것은, 보내는 쪽인 waypoint가 이 Pod를 이미 connection을 맺어 둔 대상으로 착각하고 있다는 뜻일 수 있습니다. 즉 Envoy가 기존 upstream connection을 재사용하고 있을 가능성이 높았습니다.
질문은 이렇게 바뀌었습니다. Envoy는 왜 새 Pod를 향해 handshake도 없는 connection에 application data를 실어 보냈을까?
Pod 상태 분석
비정상 응답을 한 Pod 자체에는 문제가 없었습니다. probe 설정과 running state 모두 정상이었습니다. 대신 중요한 단서가 있었습니다. 비정상 응답을 받은 Pod의 IP가 짧은 시간 안에 재사용되고 있었습니다. 직전에 삭제된 다른 Pod가 쓰던 IP를 새 Pod가 그대로 물려받은 상황이었습니다.
원인 진단
IP 겹침이 아니라 stale connection이 진짜 원인
이 상황을 보면 “AWS VPC CNI가 Pod IP를 재할당해서 IP가 겹치는 것이 문제”라고 결론 내리기 쉽습니다. 하지만 IP 재사용은 근본 원인이 아니라, 문제가 드러나게 만드는 조건에 가깝습니다.
진짜 원인은 waypoint Envoy가 IP:Port를 key로 들고 있던 HTTP/2 connection을, 목적지 Pod가 이미 terminate되었는데도 폐기하지 못하고 계속 붙들고 있었다는 점입니다. 같은 IP가 새 Pod에 재할당되면 Envoy는 stale connection을 아직 살아 있는 같은 목적지의 connection으로 보고 재사용할 수 있습니다. 그 결과가 503이었습니다.
두 컴포넌트의 동작이 함께 맞물렸습니다.
- waypoint Envoy는 upstream connection pool을
IP:Port기준으로 관리합니다. - ztunnel은 Pod 종료 시점에 waypoint가 들고 있는 HBONE connection을 GOAWAY나 FIN으로 정리해주지 못했습니다.
이때 구분해야 할 경로가 있습니다. istio/ztunnel#1637에서 Istio 메인테이너가 언급한 것처럼, ztunnel -> ztunnel 경로는 destination IP와 Service Account를 함께 고려하고 RST를 받으면 HBONE connection을 폐기하기 때문에 상대적으로 안전하다고 볼 수 있습니다. 반면 Envoy(waypoint) -> ztunnel 경로는 Envoy의 connection reuse 동작에 더 민감합니다. 우리가 겪은 문제는 정확히 이 경로에서 발생했습니다.
이 글에서 말하는 half-open, 혹은 stale connection은 새 Pod와 ztunnel은 알지 못하지만 waypoint는 아직 살아 있다고 믿는 connection입니다.
waypoint는 downstream과 upstream을 하나의 connection으로 관리하지 않는다
여기서 2편의 내용을 다시 떠올릴 필요가 있습니다. waypoint Envoy는 client의 downstream connection과 목적지 Pod로 향하는 upstream HBONE connection을 하나의 직접 connection처럼 관리하지 않습니다. 내부적으로 두 영역이 분리되어 있습니다.
하나는 client 요청을 받는 downstream listener이고, 다른 하나는 HBONE 터널을 만들기 위한 internal listener connect_originate와 그 뒤의 connect_originate ORIGINAL_DST cluster입니다. upstream HBONE connection은 이 ORIGINAL_DST cluster의 connection pool에서 IP:Port를 key로 보관되고 재사용됩니다.
flowchart LR
C["Client<br />(downstream)"]
subgraph WP["Waypoint (Envoy) 내부"]
direction LR
DL["downstream<br />listener"]
IL["internal listener<br />connect_originate"]
POOL["connect_originate cluster<br />(ORIGINAL_DST)<br />connection pool<br />key = IP:Port"]
DL -. "internal listener 경계<br />(user-space)" .-> IL
IL --> POOL
end
C -->|"downstream conn"| DL
POOL -->|"upstream conn<br />HBONE / mTLS"| ZT["ztunnel :15008"] --> P["Pod"]
style IL fill:#e3d7ff
style POOL fill:#fff3cd
이 구조 때문에 downstream 요청을 처리하는 시점에 upstream HBONE connection이 stale인지 곧바로 드러나지 않습니다. Envoy pool에 fully connected 상태로 남아 있으면, 같은 IP:Port로 향하는 요청에서 그 connection을 재사용할 수 있습니다.
waypoint Envoy debug 로그로 connection 재사용을 확인
가설을 확인하기 위해 waypoint debug log를 분석했습니다. 실험은 두 단계로 나누었습니다.
- Phase 1: 새 IP를 쓰는
Pod-aaa로 요청을 보내 신규 HBONE connection이 만들어지게 합니다. - Phase 2:
Pod-aaa를 삭제한 뒤 같은 IP를 재사용하는Pod-bbb로 요청을 보냅니다.
결과는 명확했습니다. Phase 1에서 만들어진 connection ID가 Phase 2에서도 동일하게 등장했습니다. 새 Pod로 향하는 요청인데도 새로운 connection을 맺지 않고 기존 connection을 재사용한 것입니다.

새 IP를 받은 Pod-aaa로 요청이 들어오며 connect_originate cluster에 신규 HBONE connection이 생성되는 로그입니다. 이후 재사용 여부를 추적하기 위해 같은 ConnectionId를 확인했습니다.

Pod-aaa가 삭제된 뒤 같은 IP를 받은 Pod-bbb로 요청이 들어왔지만, waypoint는 새 connection을 만들지 않고 기존 connection을 재사용했습니다.
sequenceDiagram
participant W as Waypoint (Envoy)
participant Pool as connect_originate<br />connection pool
participant A as Pod-aaa (IP X)
participant B as Pod-bbb (IP X 재사용)
rect rgb(212, 237, 218)
Note over W,A: Phase 1 - 신규 IP Pod-aaa
W->>Pool: upstream 연결 요청
Pool->>A: HBONE connection 신규 수립
A->>W: request proxied (정상)
end
Note over A,B: Pod-aaa 삭제, 이후 IP X가 Pod-bbb에 재할당
rect rgb(255, 205, 210)
Note over W,B: Phase 2 - 동일 IP Pod-bbb
W->>Pool: upstream 연결 요청
Pool-->>W: existing fully connected connection 재사용
Pool--xB: data 전송 후 reset
end
debug log의 핵심은 기존 connection을 재사용한다는 메시지였습니다. 같은 IP:Port를 근거로 Phase 1의 connection을 Phase 2에서도 그대로 사용하고 있었습니다.
ztunnel은 connection을 어떻게 닫는가
그렇다면 ztunnel은 Pod가 종료될 때 connection을 정리하고 있었을까요? destination Pod의 라이프사이클 전체를 담은 pcap을 확인했지만, HTTP/2 GOAWAY나 FIN 패킷은 관찰되지 않았습니다. Pod가 종료될 때 ztunnel이 HBONE connection을 graceful하게 닫지 못했고, waypoint는 connection이 죽었다는 사실을 알 수 없었습니다.
이 현상은 Istio upstream에도 리포트했습니다. 팀원 딜런이 재현 과정과 waypoint 로그를 정리해 istio/ztunnel#1637 이슈로 등록했습니다. 같은 증상은 AWS VPC CNI/EKS 환경뿐 아니라, waypoint 없이 ingress gateway만 사용하는 Envoy -> ztunnel 경로에서도 보고되었습니다. connection lifecycle과 draining에 대한 더 넓은 논의는 istio/ztunnel#1191에서 진행되고 있습니다.
waypoint의 socket 상태
마지막으로 socket 상태를 확인했습니다. 가설이 맞다면 in-mesh Pod가 삭제된 뒤에도 waypoint 안에는 해당 Pod IP를 peer로 하는 :15008 socket이 한동안 ESTABLISHED 상태로 남아 있어야 합니다.
실제로 임의의 Pod를 삭제한 뒤 관찰해보니, waypoint 내부에 해당 Pod IP를 향한 socket이 일정 시간 동안 ESTABLISHED로 남아 있었습니다. 로그, pcap, socket 상태가 모두 같은 결론을 가리켰고, stale connection 재사용이 원인이라고 판단했습니다.
문제 대응
대응 목표는 명확했습니다. network component에서 reset이 발생하더라도, 그것이 애플리케이션 응답의 5xx로 전파되지 않게 해야 했습니다. 먼저 근본 해결에 가까운 방향을 정리하고, 그다음 당장 적용 가능한 완화책을 검토했습니다.
근본 해결책: connection 식별 key 개선
가장 깔끔한 해결은 connection pool의 key를 단순한 IP:Port가 아니게 만드는 것입니다. Envoy ORIGINAL_DST cluster의 connection pool key에 Pod UID 같은 instance-level metadata가 포함된다면, 같은 IP를 재사용하더라도 다른 Pod로 취급할 수 있습니다. 그러면 stale connection을 새 Pod에 재사용하는 일이 원천적으로 막힙니다.
ztunnel의 HBONE connection pool은 이미 단순히 IP만 보지 않습니다. 대략 source identity, destination identity, destination address, source IP를 묶어 WorkloadKey를 만들고 이를 pool key로 사용합니다. destination identity는 spiffe://<trust-domain>/ns/<namespace>/sa/<service-account> 형태입니다.
다만 Service Account만으로는 충분하지 않습니다. 같은 Deployment에서 새로 생성된 Pod는 보통 같은 Service Account를 사용하기 때문입니다. Pod-aaa와 Pod-bbb를 안정적으로 구분하려면 Pod UID처럼 Pod 인스턴스마다 달라지는 값이 필요합니다.
근본 해결책: connection 상태 관리 개선
다른 방향은 ztunnel이 Pod 종료 시점에 waypoint가 들고 있는 connection을 정리하도록 만드는 것입니다. 예를 들어 GOAWAY나 FIN을 통해 waypoint가 더 이상 해당 HBONE connection을 재사용하지 않게 만들 수 있습니다.
하지만 이 접근은 생각보다 어렵습니다. ambient mode는 application에 투명해야 하므로, application이 직접 waypoint에게 종료 신호를 보내도록 바꾸는 것은 전제와 맞지 않습니다. 그렇다면 ztunnel이 해야 하는데, Pod가 종료되면 CNI가 veth와 network namespace를 정리하면서 ztunnel이 Pod network namespace 안에 만들어 둔 HBONE 종단 socket도 사라질 수 있습니다. 사후에 Pod 종료를 감지하더라도 그 connection으로 GOAWAY를 보낼 통로가 이미 없어질 수 있습니다.
sequenceDiagram
participant WP as Waypoint<br />(Envoy)
participant ZT as Ztunnel<br />(pod netns 내 소켓)
participant POD as Pod<br />(application)
Note over WP,ZT: waypoint-ztunnel HBONE connection은<br />pod network namespace 안에서 종단됨
rect rgb(255, 205, 210)
Note over POD: pod 종료 (SIGTERM -> exit)
Note over ZT,POD: netns/veth는 pod lifecycle과 함께 정리됨
POD--xZT: ztunnel의 HBONE 종단 소켓도 사라짐
Note over ZT: 사후 감지 시점에는 이미 정리 신호를 보내기 어려움
ZT--xWP: GOAWAY 전달 실패
end
Note over WP: 정리 신호 미수신 -> stale connection 유지
Note over WP,POD: 같은 IP의 새 pod 요청에서 stale connection 재사용
또 하나의 난점은 GOAWAY 자체가 모든 연결을 즉시 닫는 신호가 아니라는 점입니다. HBONE은 HTTP/2 CONNECT로 만든 outer connection 안에 실제 TCP stream인 inner connection을 싣는 구조입니다. GOAWAY는 대체로 더 이상 새 stream을 만들지 말라는 신호에 가깝고, 이미 열려 있는 inner connection까지 즉시 정리하는 문제는 별도로 남습니다.
istio/ztunnel#1191에서는 ShutdownStarting 시점에 GOAWAY를 보내는 방식, CNI DEL hook으로 네트워크 제거 직전에 정리하는 방식, client가 Pod 삭제를 감지해 pool에서 제거하는 방식, keepalive로 timeout 정리를 유도하는 방식 등이 논의되고 있습니다. 각각 타이밍과 복잡도에서 다른 trade-off가 있습니다.
이 두 방향은 모두 Istio나 Envoy upstream 개선이 필요한 영역입니다. 그래서 단기 대응은 별도로 필요했습니다.
즉시 적용할 수 있는 방안: RST에 대한 retry
당장 적용할 수 있는 현실적인 대응은 RST(reset)에 대한 retry였습니다.
기존 설정은 reset-before-request에 대해서만 retry하도록 되어 있었습니다. 이를 reset까지 포함하도록 확장하면, stale connection으로 인해 reset이 발생했을 때 waypoint가 자동으로 다시 시도할 수 있습니다. 여기서 RST는 ztunnel application logic이 유효하지 않은 연결이라고 판단해 보내는 신호라기보다는, 새 Pod의 network namespace 안에 기존 TCP connection 상태가 없어 kernel TCP stack에서 발생한 reset으로 보는 것이 더 정확합니다.
sequenceDiagram
participant C as Client
participant W as Waypoint (Retry Enabled)
participant Z as Ztunnel
participant P as Pod (신규)
C->>W: Request
Note over W: 1차 시도 - stale connection 사용
W->>Z: Data (old connection)
Z-->>W: TCP RST
Note over W: reset 감지 -> 자동 재시도
W->>Z: TCP/TLS Handshake (new)
W->>Z: Data (new connection)
Z->>P: Forward Request
P->>Z: 200 OK
Z->>W: Response
W->>C: 200 OK
다른 후보로 aggressive HTTP/2 keepalive나 HBONE idle timeout 조정도 검토했습니다. Istio에는 Envoy proxy가 ztunnel로 맺는 HBONE connection을 pool에 얼마나 오래 둘지 제어하는 meshConfig.hboneIdleTimeout 설정이 있습니다. 이 값을 짧게 잡으면 idle 상태의 stale connection을 더 빨리 정리할 수 있습니다. HTTP/2 keepalive 역시 stale connection을 빠르게 감지하자는 아이디어입니다.
하지만 둘 다 stale connection을 빨리 줄이는 보조 수단일 뿐, IP 재사용 타이밍과 connection 재사용이 겹치는 상황 자체를 원천적으로 막지는 못합니다. 실제로 커뮤니티에서도 ztunnel의 KEEPALIVE_* 환경변수 조정만으로는 해결되지 않았다는 보고가 있어, 최종적으로는 retry를 단기 완화책으로 선택했습니다.
다만 reset retry를 켤 때는 주의가 필요합니다. 이번에 관찰한 waypoint -> ztunnel stale connection 재사용 문제뿐 아니라, 다른 원인으로 발생한 RST에도 retry가 trigger될 수 있습니다. Pod OOM, process crash, 아직 원인을 모르는 unexpected RST도 같은 조건에 걸릴 수 있습니다. 따라서 retry를 적용할 때는 대상 API가 멱등한지, 중복 실행이 애플리케이션 상태나 외부 시스템에 부작용을 만들지 않는지 함께 확인해야 합니다.
결론
결과적으로 waypoint 수준에서 reset retry를 적용해, stale connection 재사용으로 인한 UpstreamConnectionTermination 503 문제를 완화할 수 있었습니다.
이 문제의 핵심은 IP가 겹친 것이 아니었습니다. Envoy가 IP:Port로 식별한 connection을 목적지 Pod가 사라진 뒤에도 폐기하지 못했고, 같은 IP가 새 Pod에 재할당되면서 stale connection이 잘못 재사용된 것이 문제였습니다. connection 재사용, ztunnel의 graceful close 부재, IP 재사용이라는 조건이 겹치면서 Ambient mode에서만 드러나는 함정이 된 셈입니다.
Sidecar mode와 달리 Ambient mode에서는 connection을 다루는 주체가 ztunnel과 waypoint로 나뉩니다. 그만큼 문제를 추적할 때도 access log만으로는 부족했고, Envoy debug log, pcap, socket 상태를 함께 보아야 했습니다. 이 추적 과정은 비슷한 503이나 reset 문제를 만났을 때 다시 사용할 수 있는 방법론으로 남았습니다.
긴 글 읽어주셔서 감사합니다.
- 1편: 왜 Istio Ambient mode인가?
- 2편: Envoy config로 해부하는 Ambient mode
- 3편: 프로덕션에서 만난 당황스러운 이슈들과 트러블슈팅
- 3-1편: 503과 Half-open Connection (현재 글)
- 3-2편: Partially Enrolled Pod와 Untaint Controller
- 3-3편: Ambient mode 안전하게 업그레이드하기
- 3-4편: 부록 - 507 status code와 istiod disconnected 탐지