Istio 3-4편: 507 status code와 istiod disconnected 탐지
Ambient mode 트러블슈팅 부록: 운영 중 만난 Istio/Envoy 관련 이슈 2가지
Jetty (정재홍) • DevOps Engineer
- DevOps
안녕하세요, 채널코퍼레이션 DevOps팀의 딜런, 재티입니다.
이 글은 Istio Ambient mode 도입기 시리즈의 3-4편입니다. 앞선 3-1, 3-2, 3-3편은 각각 하나의 주제를 깊게 파는 글이었습니다.
이번 3-4편은 조금 성격이 다릅니다. 하나의 큰 원인을 끝까지 파고드는 글이라기보다는, 운영 중 만난 Istio/Envoy 관련 이슈 중 기억에 남는 사례 두 가지를 부록처럼 정리해보려 합니다.
두 사례 모두 Ambient mode에만 한정된 문제는 아닙니다. waypoint에서 관찰한 현상이긴 하지만, 본질적으로는 Envoy가 요청을 buffering하고 retry하는 방식, 또는 Envoy와 istiod 사이의 xDS 연결을 readiness로 어떻게 볼 것인가에 가까운 문제입니다. 따라서 sidecar mode나 ingress gateway를 운영하는 환경에서도 비슷한 관점으로 참고할 수 있습니다.
이번 글에서 다룰 내용은 다음 두 가지입니다.
처음 본
507 Insufficient Storage: large request payload와 retry buffer limitistiod와의 연결이 끊긴 것으로 보이는 gateway/waypoint를 어떻게 탐지할 것인가
1. 처음 본 507 status code
1.1 문제 상황
운영 중 애플리케이션에서 조금 낯선 에러가 보고되었습니다.
MailException: MessengerServer Error: failed to send email
MessengerServer Error: failed to send email
status: 507
msg: exceeded request buffer limit while retrying upstreamHTTP 503이나 504라면 Istio/Envoy를 운영하면서 흔히 마주칠 수 있습니다. 그런데 507은 익숙하지 않았습니다. 일반적으로 507 Insufficient Storage는 서버가 요청을 처리하는 데 필요한 저장 공간을 확보하지 못했다는 의미의 status code입니다. 그래서 처음에는 애플리케이션 서버에서의 스토리지 리소스 부족을 의심했었습니다.
하지만 waypoint access log를 확인해보니, 이 응답은 애플리케이션이 만든 것이 아니라 Envoy가 직접 만든 local reply였습니다.
핵심은 response_code_details였습니다.
request_payload_exceeded_retry_buffer_limitEnvoy 문서에서는 이 값을 다음처럼 설명합니다.
Envoy is doing streaming proxying but too much data arrived while waiting to attempt a retry.
즉 Envoy가 요청 body를 streaming으로 upstream에 전달하고 있었고, 동시에 retry 가능성을 위해 일부 데이터를 buffer에 저장하고 있었는데, retry를 위해 보관해야 하는 request payload가 buffer limit을 넘었다는 뜻입니다.
1.2 507은 request size 제한과는 조금 다르다
여기서 헷갈리기 쉬운 점은 507이 413 Content Too Large와 다르다는 점입니다.
413은 서버가 정의한 request body size 제한을 넘었을 때 나오는 4xx 응답입니다. 반면 이번 507은 “이 요청 payload 자체가 너무 커서 거부한다”라기보다는, “이 요청을 retry하려면 request body를 다시 replay할 수 있어야 하는데, Envoy가 보관해 둔 buffer만으로는 replay할 수 없다”에 가깝습니다.
정상적인 streaming proxy(ing)만 놓고 보면 큰 payload 자체가 항상 문제는 아닙니다. Envoy는 request body 전체를 메모리에 올려두고 upstream으로 보내는 것이 아니라, chunk 단위로 받아 upstream으로 흘려보냅니다.
이 경우 payload 전체가 수십 MB라고 해도, Envoy가 한 번에 그 전체를 buffer로 들고 있을 필요는 없습니다.
문제는 retry에서 발생합니다. Envoy가 upstream 실패 시 요청을 재시도하려면, 같은 request를 다시 upstream으로 보낼 수 있어야 합니다. 그런데 POST body처럼 이미 streaming으로 흘려보낸 데이터를 다시 보내려면, 적어도 retry에 필요한 만큼의 request body를 Envoy가 buffer에 가지고 있어야 합니다.
이때 buffer가 limit을 넘으면 Envoy는 더 이상 retry를 위해 request body를 보관하지 못합니다. 이후 upstream reset이나 5xx 등 retry 조건이 발생하면, Envoy는 요청을 replay할 수 없고 507 local reply를 반환합니다.
이 관점에서 보면, 이번 문제는 “payload가 커서 실패했다”보다는 “large payload request에서 retry가 필요해지는 순간 실패했다”가 더 정확합니다.
1.3 왜 하필 507인가
Envoy 구현을 보면 request_payload_exceeded_retry_buffer_limit 상황에서 Http::Code::InsufficientStorage, 즉 507을 local reply로 보냅니다.
개념적으로는 다음과 같은 흐름입니다.
request body data가 들어온다.
retry 또는 shadowing을 위해 decoding buffer에 데이터를 쌓는다.
새로 들어온 data를 더하면 effective buffer limit을 넘는지 확인한다.
retry/shadow가 필요한 상황에서 limit을 넘으면, 더 이상 buffering을 포기한다.
이후 retry가 필요한 상황이 오면 request를 replay할 수 없다.
Envoy가 507 local reply를 반환한다.
실제 Envoy 코드에서도 buffer limit을 넘는 순간 retry state를 reset하고, 이후 local reply를 만들 때 response code detail을 RequestPayloadExceededRetryBufferLimit로 설정합니다.
response_code: 507
response_code_details: request_payload_exceeded_retry_buffer_limit
body: exceeded request buffer limit while retrying upstream즉 이 507은 애플리케이션의 storage 부족이나 디스크 부족을 의미하지 않습니다. Envoy가 retry를 위해 request payload를 replay할 수 없는 상태가 되었음을 나타내는 신호입니다.
1.4 per_connection_buffer_limit_bytes와 1MB
조사 과정에서 자연스럽게 본 설정은 Envoy의 per_connection_buffer_limit_bytes였습니다. Envoy의 기본 buffer limit은 1MB이고, Istio에서 별도 설정을 하지 않으면 이 기본값의 영향을 받을 수 있습니다.
중요한 점은 이 값이 “request payload 최대 크기”와 완전히 동일한 의미는 아니라는 점입니다. 큰 request라도 정상적으로 streaming만 된다면 통과할 수 있습니다. 하지만 retry를 위해 Envoy가 request body를 보관해야 하는 상황에서는 이 buffer limit이 사실상의 경계처럼 동작할 수 있습니다.
운영자 입장에서는 이것이 조금 난감합니다. 공식적으로 request body size 제한을 걸어둔 것은 아닌데, 특정 조건에서는 일정 크기 이상의 요청이 507로 실패할 수 있기 때문입니다. 특히 그 조건이 “large payload + retryable failure”처럼 평소에는 잘 드러나지 않다가 장애나 reset이 겹칠 때만 나타나면 더 찾기 어렵습니다.
1.5 대응 방안 검토
대응 방안은 크게 세 가지를 생각해볼 수 있었습니다.
1) buffer limit을 늘린다
가장 직접적인 방법은 per_connection_buffer_limit_bytes를 늘리는 것입니다. 실제로 이 값을 늘리면 더 큰 payload에 대해서도 retry buffer를 유지할 수 있습니다.
하지만 운영 관점에서는 쉽게 결정하기 어렵습니다. 몇 MB까지 허용할 것인지 정해야 하고, media나 file upload처럼 payload가 매우 큰 요청까지 고려하면 값을 무작정 키우기 어렵습니다. Envoy의 메모리 사용량도 함께 고려해야 합니다. buffer limit은 실제로 데이터가 쌓일 때 사용되더라도, 많은 connection에서 큰 request가 동시에 들어오는 상황에서는 메모리 압박으로 이어질 수 있습니다.
또 전체 gateway/waypoint에 일괄 적용할지, 특정 route나 service에만 적용할지도 고민입니다. 전체에 적용하면 blast radius가 커지고, 특정 서비스에만 적용하면 관리 복잡도가 올라갑니다.
2) retry를 끈다
retry를 끄면 이 문제는 사라질 수 있습니다. retry를 하지 않으면 request body를 replay할 필요도 없기 때문입니다.
하지만 저희 환경에서는 선택하기 어려웠습니다. 앞선 3-1편에서 다룬 것처럼, Ambient mode를 운영하면서 waypoint/ztunnel 구간의 reset에 대해 retry가 필요하다는 것을 이미 확인했습니다. retry를 끄면 507은 줄어들 수 있지만, 다른 많은 요청에서 503이 사용자에게 그대로 노출될 수 있습니다.
3) large payload는 client에서 retry한다
결국 가장 현실적인 방향은 large payload 요청에 대해서는 client 또는 호출 애플리케이션 레벨에서 retry를 고려하는 것이었습니다.
Envoy가 모든 큰 POST body를 안전하게 buffer해두고 재시도하는 것은 비용이 큽니다. 반면 large payload 요청은 보통 업로드, 메일 발송, 문서 처리처럼 요청 자체가 무겁고 오래 걸리는 작업인 경우가 많습니다. 이런 API는 애플리케이션 레벨에서 멱등성 키(idempotency key)나 중복 처리 방어를 갖추고, 5xx에 대해 재시도하는 편이 더 명시적이고 안전할 수 있습니다.
정리하면, 이 이슈에서 얻은 교훈은 다음과 같습니다.
Envoy/Istio에서 retry를 켜면, large request body에 대해 “보이지 않는 buffer limit”이 생길 수 있습니다. 큰 payload가 항상 실패하는 것은 아니지만, retry가 필요한 순간에는
507 request_payload_exceeded_retry_buffer_limit로 실패할 수 있습니다.
2. istiod와 disconnected 된 것으로 보이는 gateway/waypoint
두 번째 사례는 아직 원인을 명확히 찾지 못한 이슈입니다. 그래서 단정적으로 “원인은 이것이다”라고 말하기는 조심스럽습니다. 대신 관찰한 현상과, 이를 탐지 및 대응하기 위해 어떤 메트릭을 검토했는지 위주로 정리해보겠습니다.
2.1 문제 상황
운영 중 특정 gateway/waypoint Envoy가 istiod와의 xDS 연결을 정상적으로 유지하지 못하는 것으로 보이는 상황이 있었습니다. 당시 로그에는 다음과 같은 메시지가 반복적으로 찍혔습니다.
중요한 필드는 다음과 같습니다.
DeltaAggregatedResources: Envoy가 Delta xDS stream을 사용 중xds-grpc: Envoy가 바라보는 xDS cluster. 실제로는 pod 내부의 pilot-agent UDS proxy14: gRPCUnavailablelookup istiod.istio-system.svc: i/o timeout: pilot-agent가 istiod로 연결하는 과정에서 DNS timeout 발생closed since 1614s ago: 최초 실패 이후 오랜 시간 동안 stream close/retry가 반복됨
이 로그를 보았을 때, istiod DNS lookup이 실패한 것처럼 보였습니다. lookup ... i/o timeout이라는 메시지 자체도 DNS resolution failure에 가까웠습니다.
그래서 처음에는 CoreDNS나 cluster 전반의 DNS 문제를 의심했습니다. 하지만 같은 시간대에 cluster 전체에서 광범위한 DNS lookup error가 보이지는 않았고, CoreDNS 자체도 눈에 띄는 이상이 없어 보였습니다. 즉 에러 메시지는 DNS failure처럼 보였지만, “root cause가 CoreDNS였다” 또는 “cluster DNS가 전반적으로 깨졌다”라고 말할 근거는 부족했습니다.
결국 이 지점에서 태스크의 방향을 조금 바꾸었습니다. DNS lookup 자체를 계속 파고들어 단일 root cause를 특정하기보다는, 비슷한 상황이 다시 발생했을 때 gateway/waypoint가 오래된 xDS config만 들고 계속 traffic을 받는 상태를 어떻게 피할 수 있을지, 또는 적어도 어떻게 빠르게 탐지하고 완화할 수 있을지에 초점을 맞추기로 했습니다.
그래서 이 섹션에서는 원인 자체보다는 “이런 상태를 어떻게 탐지하고 완화(mitigate)할 수 있는가”를 중심으로 정리해보았습니다.
2.2 Envoy, pilot-agent, istiod의 연결 구조
Istio gateway/waypoint Pod 안에는 Envoy와 pilot-agent가 함께 있습니다. Envoy가 직접 istiod에 붙는 것이 아니라, 로컬 pilot-agent를 거쳐 istiod와 통신합니다.
Envoy 입장에서 xDS upstream은 xds-grpc cluster입니다. 그런데 이 cluster의 실제 목적지는 원격 istiod가 아니라 같은 Pod 안의 pilot-agent UDS입니다. pilot-agent가 다시 istiod로 TCP/TLS 연결을 맺고, Envoy와 istiod 사이의 xDS stream을 중계합니다.
따라서 장애가 일어날 수 있는 지점은 두 구간으로 나눠볼 수 있습니다.
Envoy ↔ pilot-agent: Pod 내부 UDS 연결
pilot-agent ↔ istiod: Kubernetes DNS와 network를 거치는 TCP/TLS 연결
이번에 관찰한 로그는 pilot-agent가 istiod로 연결하는 과정에서 실패했고, 그 에러가 Envoy 쪽 gRPC stream close로 전파된 형태였습니다.
2.3 pilot-agent 에러는 Envoy에 전파된다
Istio agent의 xDS proxy 구현을 보면, pilot-agent는 istiod upstream 연결 실패를 Envoy downstream stream에 숨기지 않고 전파합니다. 개념적으로는 다음과 같습니다.
istiod 쪽 stream에서 Recv() 에러가 발생하면 upstream error channel로 전달되고, handler가 error를 반환합니다. 그러면 Envoy가 보고 있던 gRPC config stream도 닫힙니다.
Envoy 쪽에서는 gRPC stream close 시 control_plane.connected_state를 0으로 내리고, backoff 후 다시 연결을 시도합니다.
onRemoteClose()
-> control_plane.connected_state = 0
-> setRetryTimer()
-> backoff 후 다시 xDS stream 생성장애가 지속되면 다음과 같은 루프가 만들어질 수 있습니다.
여기서 중요한 점은 connected_state가 항상 깔끔하게 0으로 유지되는 것이 아니라는 점입니다. 재연결을 시도할 때마다 Envoy ↔ pilot-agent UDS stream은 잠깐 열릴 수 있고, 그 순간 connected_state가 1로 올라갑니다. 이후 pilot-agent가 istiod 연결에 실패하면 다시 0으로 내려갑니다.
즉 istiod와 실제로 통신하지 못하는 상황에서도, 관찰 시점에 따라 connected_state=1이 보일 수 있습니다.
2.4 기존 readinessProbe의 한계
Istio의 기본 readinessProbe는 “최초 xDS config를 받았는가”에 더 가깝습니다. pilot-agent status probe 구현에는 receivedFirstUpdate, atleastOnceReady 같은 상태가 있고, 한 번 ready가 된 뒤에는 이후 xDS 연결이 끊겨도 readiness가 곧바로 실패하지 않을 수 있습니다.
이런 형태입니다.
이 방식은 startup readiness에는 적합합니다. Envoy가 최초 config를 받기 전에는 traffic을 받지 않도록 막을 수 있기 때문입니다.
하지만 “이미 한 번 ready가 된 gateway/waypoint가 이후 istiod와 장시간 disconnected 상태가 되었다”를 탐지하기에는 부족합니다. 최초 config를 받은 사실은 여전히 true로 남아 있기 때문입니다.
운영 관점에서는 이 차이가 중요합니다. gateway/waypoint는 이미 받은 config로 한동안 트래픽을 계속 처리할 수 있습니다. 하지만 istiod와 장시간 연결이 끊기면 새 config, endpoint, certificate rotation 등 control plane 업데이트를 받지 못할 수 있습니다. 따라서 단순히 process가 live인지뿐 아니라, xDS control plane과 현재 연결되어 있는지도 보고 싶어집니다.
2.5 어떤 메트릭을 볼 수 있을까
우리가 검토한 메트릭은 크게 두 가지였습니다.
1) envoy_control_plane_connected_state
Envoy 내부 stat 이름은 다음과 같습니다.
control_plane.connected_statePrometheus로 export되면 보통 다음 이름으로 보입니다.
envoy_control_plane_connected_state이 값은 xDS gRPC stream이 열려 있으면 1, 닫히면 0이 되는 gauge입니다. 앞에서 설명한 것처럼 Envoy ↔ pilot-agent stream 상태를 반영하지만, pilot-agent가 istiod 연결 실패를 error로 전파하면 간접적으로 istiod 연결 문제도 반영할 수 있습니다.
다만 이 stat을 수집하려면 proxyStatsMatcher 설정이 올바르게 되어 있어야 합니다. 여기서 한 번 실수하기 쉬운 포인트가 있습니다.
proxyStatsMatcher.inclusionRegexps는 Prometheus metric 이름이 아니라 Envoy 내부 stat 이름을 기준으로 매칭됩니다. 즉 아래 설정은 잘못된 설정입니다.
올바른 설정은 Envoy 내부 stat 이름인 control_plane.connected_state를 기준으로 해야 합니다.
이 설정이 잘못되어 있으면 /stats를 조회해도 stat 자체가 생성되지 않을 수 있습니다. 실제 장애 당시에도 이 설정이 잘못되어 있어 해당 메트릭을 사후 분석에 바로 사용할 수 없었습니다.
2) envoy_cluster_upstream_cx_active{cluster_name="xds-grpc"}
또 하나 볼 수 있는 값은 xds-grpc cluster의 active connection 수입니다.
envoy_cluster_upstream_cx_active{cluster_name="xds-grpc"}이 값은 Envoy가 pilot-agent UDS에 맺은 upstream connection 수를 나타냅니다. 정상 상태에서는 보통 1입니다. DNS 장애처럼 pilot-agent가 istiod와 연결하지 못해 gRPC stream이 계속 닫히는 경우에는 0으로 떨어지거나, Pod 자체 scraping이 안 되면 시계열이 소실될 수 있습니다.
다만 이 값도 완벽한 istiod 연결 상태는 아닙니다. Envoy ↔ pilot-agent UDS connection을 보는 것이기 때문입니다. 그래도 당시처럼 pilot-agent 에러가 Envoy stream close로 전파되는 케이스에서는 실용적인 신호로 사용할 수 있습니다.
2.6 readinessProbe로 감지하기
메트릭 수집만으로는 alert에는 도움이 되지만, Kubernetes Service endpoint에서 해당 gateway/waypoint를 빼지는 못합니다. 그래서 readinessProbe에 xDS 연결 상태를 반영하는 방안을 검토했습니다.
예를 들면 다음처럼 기본 /healthz/ready와 control_plane.connected_state를 함께 확인할 수 있습니다.
이렇게 하면 xDS stream이 일정 시간 0으로 떨어졌을 때 Pod readiness가 false가 되고, Service endpoint에서 제외될 수 있습니다.
하지만 여기에도 함정이 있습니다. 앞서 설명했듯이 장애 중에도 재연결 루프 때문에 connected_state가 0과 1 사이를 오갈 수 있습니다. 기본 successThreshold는 1이므로, probe가 한 번만 성공해도 곧바로 Ready로 돌아옵니다. 그러면 장애가 지속되는 동안 endpoint에서 빠졌다가 다시 들어오는 flapping이 발생할 수 있습니다.
그래서 단순히 failureThreshold만 두는 것보다, Ready로 복귀할 때도 연속 성공을 요구하는 편이 안전합니다.
failureThreshold: 3은 3회 연속 실패해야 NotReady로 보고, successThreshold: 3은 3회 연속 성공해야 Ready로 복귀하게 합니다. 재연결 루프 중 connected_state=1이 잠깐 보이는 정도로는 Ready에 재진입하지 않도록 완충 장치를 두는 셈입니다.
물론 이 방법도 완벽한 해결책은 아닙니다. connected_state는 Envoy가 istiod에 직접 붙어 있는지를 보는 메트릭이 아니라 Envoy의 xDS stream 상태를 보는 메트릭입니다. 또한 probe에서 /stats를 매번 파싱하는 방식은 다소 거칠고, 설정을 잘못하면 startup 지연이나 flapping을 만들 수도 있습니다.
그럼에도 기본 readiness가 “최초 config 수신 여부”에 가깝다는 점을 고려하면, gateway/waypoint 같은 중요 진입점에서는 현재 control plane 연결 상태를 별도로 보는 것이 운영상 의미가 있다고 판단했습니다.
결론
이번 글에서는 프로덕션 운영 중 만난 두 가지 Istio/Envoy 이슈를 부록처럼 정리했습니다.
첫 번째 507 이슈는 large request payload와 retry가 만날 때 Envoy의 buffer limit이 예상치 못한 응답으로 드러날 수 있다는 사례였습니다. request body size 제한을 명시적으로 걸지 않았더라도, retry를 위해 payload를 replay해야 하는 순간에는 request_payload_exceeded_retry_buffer_limit으로 507이 발생할 수 있습니다. 특히 POST처럼 body가 큰 요청에 retry policy를 적용할 때는 Envoy가 어디까지 대신 재시도할 수 있고, 어디서부터는 client/application이 책임져야 하는지 경계를 정해야 합니다.
두 번째 이슈는 아직 원인을 명확히 특정하지 못했지만, gateway/waypoint가 istiod와의 xDS 연결을 잃은 것으로 보이는 상황을 어떻게 탐지할 수 있는지에 대한 이야기였습니다. 기본 readinessProbe는 최초 config 수신 이후의 control plane 연결 끊김을 충분히 반영하지 못할 수 있습니다. 따라서 control_plane.connected_state, xds-grpc connection metric, 그리고 필요하다면 custom readinessProbe를 함께 고려할 필요가 있습니다.
두 이슈 모두 Ambient mode 자체의 고유한 문제라기보다는, Istio를 운영하면서 Envoy를 실제 data plane으로 쓰기 때문에 마주치는 문제에 가깝습니다. 그래서 Ambient mode를 쓰지 않더라도, Istio gateway나 sidecar Envoy를 운영한다면 비슷한 관점으로 살펴볼 만한 사례라고 생각합니다.
긴 글 읽어주셔서 감사합니다.
3편: 프로덕션에서 만난 당황스러운 이슈들과 트러블슈팅
3-4편: 507 status code와 istiod disconnected 탐지 (현재 글)
