본문으로 건너뛰기
3-1편 — 503과 Half-open Connection

03-1 · 503과 Half-open Connection — waypoint가 죽은 Pod의 커넥션을 재사용한다 (2026-06)

참조한 내용정리 · 이 문서는 아래 원문을 읽고 우리 지식베이스 형식으로 재구성한 요약입니다. 원문 자체가 아니며 정확한 워딩·전체 맥락·그림은 원문에서 확인합니다.

  • 증상은 workload rollout(재시작·배포) 중 간헐적으로 터지는 503이었습니다. public gateway는 response_code_details: via_upstream만 남기고 실제 원인 기록은 waypoint의 upstream_reset_before_response_started{connection_termination} · response_flags: UC에 있었습니다. 다음 hop인 ztunnel과 istio-cni에는 이상 로그가 없었습니다.
  • 원인은 IP 겹침이 아니라 stale connection을 폐기하지 못하는 커넥션 생명주기 관리였습니다. waypoint Envoy의 connect_originate(ORIGINAL_DST) cluster는 IP:Port만을 키로 HBONE 터널을 pool에 보관·재사용하고 ztunnel은 Pod 종료 시 그 터널을 graceful하게 닫지 않습니다.
  • pcap이 결정적이었습니다. 새 Pod가 뜬 직후 TLS handshake 없이 application data stream이 곧바로 인입됐고 새 Pod의 network namespace에는 그 TCP connection 상태가 없으니 커널 TCP 스택이 RST로 답했습니다.
  • 로그·pcap·socket 세 각도가 같은 결론을 가리켰습니다. Envoy debug 로그에서 ConnectionId 79097이 Phase 1과 Phase 2에 동일하게 등장했고(using existing fully connected connection), Pod 삭제 후에도 waypoint 쪽 :15008 socket이 일정 시간 ESTABLISHED로 남아 있었습니다.
  • 즉시 조치는 retry 조건을 reset-before-request에서 reset까지 확장한 것입니다. 채널팀은 이것으로 증상을 해소했지만 근본 해결은 pool key에 workload identity·Pod UID를 넣는 Istio upstream 개선 영역으로 남겼습니다.

Ambient mode 시리즈 3편은 채널팀이 프로덕션에서 겪은 장애들을 다룹니다. 그중 3-1편은 배포할 때마다 조금씩 새던 503을 추적한 기록입니다. 1편이 왜 Ambient로 가는지를, 2편이 waypoint Envoy config가 어떻게 생겼는지를 다뤘다면, 이 글은 그 config가 런타임에 들고 있는 상태가 장애로 이어진 사례입니다.

사이드카 모드에서 5xx를 추적하던 순서 — 게이트웨이 → 사이드카 → 앱을 훑으며 어느 hop이 끊었는지 좁혀 가는 방식 — 는 05 장애 이야기: 간헐적 응답 이상에서 정리했습니다. Ambient에서는 그 hop 구성이 달라집니다. 커넥션을 다루는 주체가 ztunnel과 waypoint로 분리되면서 “누가 커넥션을 열고 누가 닫는가"가 한 프로세스 안에서 끝나지 않습니다. 이 글의 장애가 바로 그 분리에서 났습니다.

1. 문제 상황 — 롤아웃마다 새는 503

문제는 workload rollout(재시작·배포) 과정에서 간헐적으로 터진 503 응답이었습니다. 배포 창구에서만 몇 건씩 새는 형태여서 상시 장애도 아니었고 알림 임계에도 잘 걸리지 않았습니다.

로그를 hop별로 늘어놓으면 어디를 봐야 하는지가 곧바로 드러납니다.

hop핵심 신호해석
public gatewayvia_upstream업스트림에서 받은 것을 전달 — 원인 정보 없음
waypointupstream_reset_before_response_started, UC응답 시작 전 업스트림 커넥션 reset
ztunnel이상 로그 없음자기가 아는 한 아무 일도 없었다
istio-cni이상 로그 없음마찬가지

위 두 줄을 필드째로 옮기면 이렇습니다. public gateway가 남긴 것은 response_code: 503response_code_details: via_upstream — 자기가 만든 503이 아니라 업스트림에서 받은 것을 전달했다는 뜻이라 원인 정보가 없습니다. waypoint가 남긴 것은 response_code: 503, response_code_details: upstream_reset_before_response_started{connection_termination}, response_flags: UC응답 시작 전에 업스트림 커넥션이 reset됐다는 뜻이고 UC는 upstream connection termination입니다.

업스트림 커넥션이 응답 시작 전에 끊기면서 503이 만들어지고, 그 사실을 구체적으로 적어 둔 hop은 waypoint 하나뿐입니다.
도식 텍스트
  • 클라이언트
  • public gw
  • waypoint
  • destination Pod
  1. 1. 요청
  2. 2. HTTP 전달
  3. 3. HBONE :15008 업스트림 커넥션
  4. 4. 응답 시작 전 커넥션 reset
  5. 이 구간의 ztunnel·istio-cni에는 이상 로그가 전혀 없다
  6. 5. 503 · response_flags: UC
  7. 6. 503 · via_upstream
  8. upstream_reset_before_response_started{connection_termination} · 구체적 원인을 남긴 것은 waypoint뿐 — 조사 시작점이다

2. 문제 재현 — 프로덕션을 건드릴 수 없으니 환경을 복제했다

프로덕션에서 이 문제를 직접 디버깅하기는 어렵습니다. 트래픽 볼륨이 크면 debug 로그를 켜는 것 자체가 위험하고 원하는 순간의 패킷을 골라내기도 어렵습니다.

채널팀은 격리된 재현 환경을 따로 구성했습니다. dummy application과 전용 gateway·waypoint·ztunnel을 별도로 띄워 노이즈 없이 문제만 관찰하도록 꾸렸습니다.

재현 경로는 더 줄일 수 있었습니다. public gateway를 빼고 waypoint만 이용한 Pod → waypoint → waypoint 통신에서도 동일하게 503이 재현됐습니다. 게이트웨이·인그레스 계층이 용의선상에서 빠지고 문제는 waypoint의 업스트림 커넥션 관리로 좁혀졌습니다.

구분경로재현 여부
최초 관측 경로public gateway → waypoint → ztunnel → destination Pod재현
단순화한 경로Pod → waypoint → waypoint재현

패킷을 잡기 위해 tcpdump sidecar를 주입했다

원인을 규명하려고 두 종류의 데이터를 모았습니다. 하나는 waypoint의 debug level 로그, 다른 하나는 destination Pod 안에서 뜬 TCP 패킷 캡처입니다.

패킷 캡처 쪽은 구성이 까다롭습니다. 문제가 Pod의 생성·종료 순간에 걸쳐 있어서 앱이 살아 있는 동안만 잡아서는 필요한 구간을 놓칩니다.

항목구성
캡처 주체NET_RAW/NET_ADMIN 권한을 가진 tcpdump sidecar를 주입
캡처 범위Pod 생성부터 종료까지 전체 라이프사이클
종료 처리SIGTERM 이후 남는 잔여 패킷까지 끝까지 캡처
산출물 보관캡처된 pcap을 S3에 업로드

Pod가 사라지면 캡처 파일도 함께 사라집니다. SIGTERM 이후까지 캡처를 유지하고 결과물을 클러스터 밖으로 밀어내야 종료 직후의 상황을 사후에 열어볼 수 있습니다.

3. pcap이 보여준 것 — HBONE 암호화 너머의 두 계층

destination Pod에 직접 tcpdump를 떠서 Wireshark로 열어보고서야 무엇이 벌어지는지가 보였습니다.

Ambient에서 Pod 안의 패킷을 뜨면 성격이 다른 두 계층이 한꺼번에 잡힙니다. 이 구분을 하지 못하면 pcap은 암호문 덩어리로만 보입니다.

계층구간상태
암호화 계층waypoint → ztunnelHBONE/mTLS로 암호화된 패킷
평문 계층ztunnel → applicationztunnel socket을 거쳐 복호화된 평문 패킷

암호화 구간의 페이로드는 읽을 수 없지만 프레임의 순서와 종류는 보입니다. 정상과 비정상의 차이도 거기서 드러납니다.

정상이라면 SYN·ClientHello·CONNECT가 오가며 터널을 세운 뒤에야 data frame이 흐릅니다. 비정상 케이스에는 그 왕복이 통째로 없고, 애플리케이션에는 아무것도 닿지 않은 채 커널이 RST로 답합니다.
도식 텍스트
  • waypoint
  • ztunnel :15008
  • 애플리케이션
  1. 분기 — 정상 — 터널을 새로 세운다
    1. 1. TCP SYN
    2. 2. SYN-ACK
    3. 3. ACK · TLS ClientHello
    4. 4. ServerHello · 인증서 교환
    5. 5. HTTP/2 CONNECT
    6. 6. 200
    7. 7. data frame
    8. 8. 복호화된 평문
    9. 9. 응답
  2. 비정상 — 재사용된 터널이라 handshake가 없다
    1. 1. data frame부터 도착
    2. 2. RST
    3. 새로 생성된 Pod의 netns에는 이 TCP connection 상태가 없다 · 답한 것은 ztunnel이 아니라 커널 TCP 스택이다

waypoint 입장에서는 응답 시작 전에 업스트림이 끊긴 것이므로 upstream_reset_before_response_started가 찍히고 503이 나갑니다.

Pod 자체는 멀쩡했다

비정상 응답을 한 Pod 자체에는 문제가 없었습니다. probe 설정도, running state도 정상이었습니다. 애플리케이션 레벨에는 찾을 것이 없고 문제는 앱에 도달하기 전 커넥션 상태에 있습니다.

단서는 Pod 밖에 있었습니다. 비정상 응답을 받은 Pod의 IP가 짧은 시간 안에 재사용됐습니다 — 직전에 삭제된 다른 Pod가 쓰던 IP를 새 Pod가 그대로 물려받은 상황이었습니다.

4. 가설 — “IP 겹침"이 아니라 “stale connection”

여기서 결론을 “IP가 겹쳐서 생긴 문제"로 내리면 절반만 맞습니다. 채널팀이 짚은 원인은 stale connection을 폐기하지 못하는 커넥션 생명주기 관리이고 IP 재사용은 그 문제가 드러날 확률을 높이는 조건일 뿐입니다.

이 글에서 말하는 half-open(stale) connection새로운 Pod와 그 ztunnel은 인지하지 못하는 채로, waypoint는 아직 살아 있다고 믿는 connection입니다. 한쪽만 살아 있다고 믿는 상태라서 half-open입니다.

waypoint는 downstream과 upstream을 직접 잇지 않는다

가설이 성립하려면 waypoint가 커넥션을 재사용할 구조여야 합니다. waypoint Envoy는 client(downstream)와 목적지 Pod(upstream)를 하나의 직접 connection으로 연결해서 관리하지 않습니다. 내부는 두 영역으로 나뉘어 있습니다.

  • client의 요청을 받는 downstream listener
  • HBONE 터널, 즉 upstream connection을 별도로 맺어 connection pool로 관리하는 internal listener connect_originate 와 그에 연결된 connect_originate(ORIGINAL_DST) cluster

이 cluster의 pool은 IP:Port만을 키로 터널을 보관·재사용합니다.

downstream과 upstream은 internal listener 경계로 단절되어 있습니다 — 그 너머의 connect_originate(ORIGINAL_DST) cluster는 IP:Port만을 키로 HBONE 터널을 pool에 보관·재사용합니다.
도식 텍스트
  • downstream — client 요청 수신 listener
  • 라우팅 — route → cluster 선택
  • internal listener — user-space 경계 — 여기서 단절
  • connect_originate — ORIGINAL_DST · pool key = IP:Port
  • ztunnel — HTTP/2 CONNECT · HBONE :15008
  • 터널 요청
  • outer 터널

HBONE은 HTTP/2 CONNECT로 만든 outer connection(터널) 안에 실제 TCP 스트림인 inner connection을 실어 나르는 구조입니다. pool이 재사용하는 대상은 outer connection이고 요청마다 새로 열리는 것은 inner stream입니다. 이 구분은 뒤의 “GOAWAY로 정리하면 되지 않나"라는 질문에서 다시 등장합니다.

세 조건이 겹칠 때만 터진다

가설을 조건으로 분해하면 이렇습니다.

조건주체내용
① 커넥션 재사용waypoint EnvoyIP:Port 키만으로 기존 HBONE 터널을 그대로 재사용한다
② graceful close 부재ztunnelPod 종료 시 GOAWAY도 FIN도 보내지 않아, waypoint는 터널이 살아 있다고 믿는다
③ IP 재사용IPAM/CNI삭제된 Pod의 IP가 짧은 시간 안에 새 Pod에 재할당된다
half-open은 아무도 신호를 보내지 않아서 생깁니다 — Pod가 사라져도 waypoint의 pool에는 살아 있는 터널로 남고, 같은 IP를 받은 다음 Pod에게 handshake 없는 data frame이 날아갑니다. 터널이 이미 걷힌 뒤라면 같은 요청이 정상 처리됩니다.
도식 텍스트
  • 클라이언트
  • waypoint
  • ztunnel
  • destination Pod
  1. 1. Pod-aaa로 요청
  2. 2. HBONE CONNECT :15008
  3. 3. 복호화된 평문 → Pod-aaa
  4. pool key = 10.90.142.96:15008 → ConnectionId 79097
  5. Pod-aaa 종료 · 같은 IP를 Pod-bbb가 승계
  6. ztunnel은 GOAWAY도 FIN도 보내지 않는다 · waypoint에게 79097은 여전히 살아 있는 터널이다
  7. 4. 같은 IP:Port로 다음 요청
  8. 분기 — pool에 79097이 남아 있다 (hboneIdleTimeout 이내)
    1. 5. 재사용 · handshake 없이 data frame
    2. 6. RST · 새 netns에 상태 없음
    3. 7. 503 · UC
  9. pool에서 이미 걷혔다 — idle timeout 만료 또는 reset 재시도
    1. 5'. 새 HBONE 터널 handshake
    2. 6'. 복호화된 평문 → Pod-bbb
    3. 7'. 200

5. 가설 검증 — 로그·pcap·socket 세 각도

로그와 pcap, socket 상태가 각각 따로 같은 결론을 냈습니다.

5.1 Envoy debug 로그 — 같은 ConnectionId가 두 번 등장했다

두 단계로 나눈 실험을 돌렸습니다.

단계동작로그
Phase 1새 Pod(Pod-aaa)로 요청신규 HBONE connection 생성, ConnectionId 79097 기록
Phase 2같은 IP를 받은 새 Pod(Pod-bbb)로 요청동일한 ConnectionId 79097이 다시 등장

새 Pod에 보낸 요청인데도 새 connection을 맺지 않고 옛 connection을 재사용했다는 증거입니다.

using existing fully connected connection

상대편 Pod가 이미 존재하지 않는데도 Envoy는 이 커넥션을 “fully connected"로 판단했습니다.

5.2 pcap — 종료 신호가 아예 없었다

pcap을 다시 뒤져 Pod 종료 시점을 봤습니다. HTTP/2 GOAWAY도 TCP FIN도 보이지 않았습니다. ztunnel이 Pod 종료 시 upstream 커넥션을 graceful하게 닫지 않는다는 뜻이고 조건 ②의 직접 증거입니다.

5.3 socket 상태 — 삭제 후에도 ESTABLISHED

임의의 Pod를 삭제한 뒤 socket 상태를 관찰하자 일정 시간 동안 해당 socket이 ESTABLISHED로 남아 있었습니다. 삭제된 Pod IP를 peer로 하는 :15008 socket이 waypoint 쪽에 살아 있었습니다. (원문은 어떤 도구로 socket을 관찰했는지까지는 밝히지 않습니다.)

각도관측확인한 조건
Envoy debug 로그ConnectionId 79097 재등장 · using existing fully connected connection① 커넥션 재사용
pcapPod 종료 시 GOAWAY·FIN 미관측② graceful close 부재
socket 상태삭제 후에도 :15008 socket이 ESTABLISHED①②의 결과 상태
ESTABLISHED는 “상대가 살아 있다"는 뜻이 아닙니다. TCP는 양쪽 커널이 각자 상태를 들고 있는 프로토콜이라 한쪽이 통째로 사라져도 남은 쪽은 keepalive나 다음 전송으로 실패를 확인하기 전까지 계속 ESTABLISHED로 표시합니다. 커넥션 pool의 “살아 있음” 판정을 socket 상태에만 의존하면 half-open을 걸러낼 수 없습니다.

6. 문제 대응

6.1 근본 해결책 — Istio upstream 개선이 필요한 영역

대응의 목적은 stale connection이 재사용되지 못하게 하는 것이고 손댈 곳은 pool key와 종료 시점 신호입니다.

(a) connection pool key에 신원을 넣는다

현재 Envoy의 ORIGINAL_DST cluster는 목적지 주소만으로 pool을 구분합니다.

AS-IS
connection pool key ~= "10.90.142.96:15008"

TO-BE
connection pool key ~= "dst=10.90.142.96:15008 + dst_id=[spiffe://cluster.local/ns/default/sa/api]"

여기에 더해 이상적으로는 Pod UID 같은 인스턴스별 고유값까지 key에 포함해야 합니다. workload identity(SPIFFE ID)만으로는 같은 ServiceAccount를 쓰는 다른 Pod를 구분하지 못하기 때문입니다. key에 인스턴스 고유값이 들어가면 IP가 재사용되더라도 stale connection을 다시 쓸 수 없습니다.

(b) 종료 시점에 커넥션 정리 신호를 보낸다

“Pod가 죽을 때 GOAWAY를 보내면 되지 않나” 싶지만 원문은 이 경로가 간단하지 않은 이유를 짚습니다.

난점내용
타이밍 신호 부재ztunnel이 “지금 이 Pod가 종료된다"를 확실히 알 경로가 마땅치 않다
이미 늦은 시점Pod가 완전히 종료된 뒤에는 정리 신호를 보낼 수 없다
통로 자체가 사라진다Pod 종료 후 CNI가 veth·netns를 정리하면서 GOAWAY를 보낼 통로도 사라질 수 있다
GOAWAY의 사정거리GOAWAY는 outer connection(HTTP/2 터널)에만 영향 — inner connection은 별도 처리 필요

커뮤니티에서 논의 중인 후보들도 각각 타이밍과 복잡도에서 trade-off가 다릅니다.

후보방식
ShutdownStarting 신호Pod 종료가 시작되는 시점을 잡아 GOAWAY를 전송
CNI DEL hook네트워크가 제거되기 직전에 정리를 수행
client 측 감지클라이언트 쪽에서 Pod 삭제를 감지해 pool에서 제거

이 문제는 설정 하나로 닫히지 않고 Istio가 커넥션 생명주기를 어디서 책임질 것인가라는 설계 논의로 이어집니다. 관련 논의는 istio/ztunnel#1637(재현 사례 리포트)과 istio/ztunnel#1191(커넥션 생명주기 개선)에서 진행 중입니다.

6.2 즉시 적용할 수 있는 방안 — RST에 대한 retry

waypoint의 retry 정책은 원래 reset-before-request만 재시도 대상으로 잡았습니다. 이를 reset까지 포함하도록 확장하면 stale connection 때문에 reset이 발생했을 때 waypoint가 자동으로 다시 시도합니다. 재시도 시점에는 pool에서 죽은 커넥션이 걷혀 나가므로 새 커넥션이 맺히고 요청은 정상 처리됩니다.

retry_on: reset,connect-failure,refused-stream,...
구분변경 전변경 후
retry 대상reset-before-requestreset 포함으로 확장
효과stale connection RST는 그대로 503RST 발생 시 waypoint가 자동 재시도
성격증상 완화이지 근본 해결이 아니다

원문은 커뮤니티에서 EnvoyFilter로 위 retry_on을 추가하는 것이 사실상 유일하게 효과를 본 우회책으로 보고되고 있다고 밝히고 채널팀도 waypoint 수준에서 이 retry를 적용해 문제를 해소했다고 말합니다. 채널팀이 적용한 EnvoyFilter의 구체적인 YAML은 공개하지 않습니다.

retry 확대는 멱등성을 전제로 합니다. reset은 “요청이 업스트림에 전달되었는지 알 수 없는” 상태를 포함하므로 비멱등 API(결제·주문 생성 등)에 무차별로 걸면 중복 처리가 납니다. 적용 전에 대상 라우트의 API 멱등성을 확인해야 합니다. 이 함정은 사이드카 모드에서 retry를 넓힐 때와 동일합니다 — 05 장애 이야기 참고.

(배경 보충: waypoint의 retry 정책을 표준 CRD 밖에서 손대야 할 때 쓰는 수단이 EnvoyFilter입니다 — 08 EnvoyFilter.)

6.3 같이 검토했으나 보조 수단으로 판단한 것들

수단기대 효과원문의 판단
meshConfig.hboneIdleTimeout 단축idle 상태의 stale connection을 더 빨리 정리보조 수단 (기본값 미명시)
HTTP/2 keepalive 주기 조정죽은 터널을 keepalive 실패로 조기 감지보조 수단
ztunnel KEEPALIVE_* 환경변수커넥션 유지·감지 파라미터 조정커뮤니티 보고상 효과 없어 미채택

세 손잡이 모두 타이밍 싸움일 뿐 원인 자체는 없애지 못합니다. idle timeout을 아무리 줄여도 그보다 짧은 간격으로 Pod가 죽고 IP가 재할당되면 창은 여전히 열립니다. 게다가 과하게 줄이면 정상 트래픽에서도 터널 재수립이 잦아져 지연과 부하가 늘어납니다. 장수 커넥션을 파라미터로만 다스릴 때 생기는 부작용은 09 istiod 스케일링과 xDS 커넥션 재분배에서 xDS 커넥션을 두고 다룬 것과 같습니다.

7. 결과와 남은 것

채널팀은 waypoint 수준에서 reset도 retry 대상으로 잡아 stale connection 재사용 문제를 해소했습니다.

이 장애는 커넥션 재사용(Envoy pool) · graceful close의 부재(ztunnel) · IP 재사용(IPAM) 이 겹친 결과입니다. 어느 한 컴포넌트의 버그가 아닙니다. 사이드카 시절과 달리 커넥션을 다루는 주체가 ztunnel과 waypoint로 분리되면서 어느 쪽도 “이 커넥션의 상대가 사라졌다"를 단독으로 알 수 없게 됐습니다.

원문은 sidecar mode에서 같은 문제가 왜 두드러지지 않았는지를 직접 설명하지는 않습니다. 커넥션 관리 주체가 분리됐다는 서술까지가 원문의 범위입니다. 메시가 커넥션을 대신 들고 있으면 어떤 비용이 붙는지는 01 서비스 메시와 Istio 기초에서 다룹니다.

시리즈의 다음 편들은 같은 성격의 함정을 이어서 다룹니다 — 3-2편: Partially Enrolled Pod와 Untaint Controller, 3-3편: Ambient mode 안전하게 업그레이드하기, 3-4편: 507 status code와 istiod disconnected 탐지.

이 문서에서 가져갈 것

  • hop이 늘어난 메시에서 5xx를 볼 때는 via_upstream만 남긴 프록시를 건너뛰고 response_flags(UC 등)와 구체적인 response_code_details를 남긴 프록시부터 조사합니다. 여기서는 waypoint가 시작점이었습니다.
  • 커넥션 pool의 키가 곧 장애 반경입니다. IP:Port만으로 pool을 구분하면 IP가 재사용되는 순간 다른 워크로드로 가는 커넥션이 섞입니다. 키에 workload identity나 인스턴스 고유값(Pod UID)이 들어가야 원천 차단됩니다. IP 재사용은 증상 트리거일 뿐이라 이 구분을 놓치면 IPAM만 만지다가 재발합니다.
  • 암호화된 구간의 pcap도 쓸모가 있습니다. 페이로드를 못 읽어도 handshake 유무와 프레임 순서는 보이고 “TLS handshake 없이 data frame이 왔다"는 관찰 하나가 원인을 짚어냈습니다.
  • Pod 생명주기에 걸친 문제는 캡처 범위도 그만큼 넓혀야 합니다. NET_RAW/NET_ADMIN sidecar로 SIGTERM 이후 잔여 패킷까지 잡고 pcap을 클러스터 밖(S3)으로 내보내지 않았다면 증거는 Pod와 함께 사라졌습니다.
  • retry 확대는 시간을 버는 조치입니다. 멱등성을 확인한 뒤에만 적용하고 근본 수정(upstream pool key 개선)이 들어오기 전까지의 임시 조치로 관리합니다.

소스

마지막 수정 일자