정기 롤아웃 중 발생한 오류를 추적하며, DNS로 보였던 문제가 실제로는 AWS VPC conntrack 포화, Cilium과 역방향 경로 필터링의 상호작용, gRPC 클라이언트 재연결/로드 밸런싱 설정이 결합되어 패킷 드롭과 지연을 유발한 과정과 해결책을 자세히 다룹니다.
URL: https://www.datadoghq.com/blog/engineering/grpc-dns-and-load-balancing-incident/
Title: 항상 문제는 DNS… 아닐 때를 빼면: gRPC, Kubernetes, 그리고 AWS 네트워킹 심층 탐구 | Datadog

Laurent Bernaille

David Lentz
이 이야기는 우리의 핵심 서비스 중 하나에 대한 일상적인 업데이트가 오류 증가를 야기하면서 시작되었습니다. 겉보기에는 단순한 문제 같았습니다—로그는 DNS를 가리켰고, 메트릭은 사용자 영향이 매우 낮다고 나타났습니다. 하지만 몇 주가 지나도 엔지니어들은 여전히 드롭된 패킷을 두고 고민하며 커널 코드를 샅샅이 살피고, Kubernetes 네트워킹과 gRPC 클라이언트 재연결 알고리즘의 복잡성을 파고들었습니다. 그러나 어느 한 팀도 자신들의 관점만으로는 이 문제 전체를 완전히 이해할 수 없었습니다.
이 글에서는 우리가 어떻게 조사하고 결국 이 인시던트를 해결했는지에 관한 이야기를 들려드립니다. 아울러 그 과정에서 얻은 지식도 함께 공유합니다. 예를 들면 다음과 같습니다:
2021년 9월부터, 실시간 및 과거 메트릭 데이터를 데이터 스토어에서 조회하는 역할을 맡은 메트릭 쿼리 서비스에 업데이트를 롤아웃할 때마다 오류가 증가하기 시작했습니다. 이 서비스는 프런트엔드 웹 애플리케이션과 모니터 평가 클라이언트를 포함한 여러 클라이언트에게 메트릭을 제공합니다. 모니터는 이 데이터를 사용해 알림 여부를 판단합니다.

클라이언트는 실패한 쿼리를 자동으로 재시도하므로 사용자에게 노출되는 오류는 최소화되었습니다. 하지만 재시도는 대시보드와 모니터의 성능에 영향을 주는 지연을 초래했습니다.

서비스의 로그는 DNS 오류로 인해 서비스가 의존성—우리의 메트릭 데이터를 보관하는 데이터 스토어—에 연결하지 못하고 있음을 보여주었습니다. 이를 바탕으로 우리 서비스가 호스팅되는 Kubernetes 클러스터 내부의 DNS 활동을 조사하기 시작했습니다.
우리는 각 노드에 로컬 DNS 캐시를 두어 성능을 개선하기 위해 인프라에 NodeLocal DNSCache를 사용합니다. NodeLocal DNSCache는 node-local-dns라는 DaemonSet으로 배포되며, 슬림화된 CoreDNS 버전을 실행합니다.
node-local-dns 파드에서 OOM 오류가 발생하고 있음을 확인했습니다. 이 파드들은 메모리 제한이 64 MB로 설정되어 있었고, 동시에 처리할 수 있는 요청을 1,000건으로 제한하는 max_concurrent 매개변수가 설정되어 있었습니다. 예상대로 롤아웃 중에 이 한도를 초과하는 요청은 거부되고 있음을 확인했습니다.

OOM 오류로 미루어 보아 64 MB 메모리로는 캐시와 1,000건의 동시 요청을 감당하기에 충분하지 않다는 것을 알았고, 파드의 메모리 할당량을 256 MB로 늘렸습니다. OOM 오류는 사라졌지만, DNS 오류는 계속되었습니다.
또한 node-local-dns가 어떻게 1,000건의 동시 요청 한계에 도달하는지도 불분명했습니다. 각 쿼리를 약 5ms 이내에 처리할 것으로 기대했기 때문에, 캐시 히트율이 0%라 하더라도 초당 최소 200,000건의 쿼리를 처리할 수 있어야 합니다. 서비스의 요청률은 대부분 초당 400건 수준이었고, 롤아웃 중에 약 2,000건까지 증가했지만 여전히 기대 용량보다 훨씬 낮았습니다.

이 시점에서 우리는 서비스 롤아웃이 오류를 유발하고, 그로 인해 사용자 체감 지연이 증가하며, 그 오류의 원인이 DNS 실패라는 사실을 알고 있었습니다. node-local-dns 메트릭을 살펴본 결과, 로컬 DNS 캐시가 업스트림 DNS 리졸버를 비정상으로 표시하고 있음을 발견했습니다.

node-local-dns가 업스트림으로 전달하는 각 요청은 max_concurrent 한도에 포함되는 슬롯을 소모합니다. node-local-dns는 업스트림 리졸버에 TCP 연결을 설정하고 만료될 때까지(기본 10초) 이 연결을 재사용합니다. 헬스 체크 실패는 node-local-dns가 업스트림에 연결을 설정하지 못했을 가능성을 시사하며, 이는 왜 우리가 max_concurrent 한도에 도달했는지에 대한 설명이 됩니다. forward 플러그인은 업스트림에서 응답을 받기 위한 5초 타임아웃을 가지므로, 업스트림에 연결할 수 없다면 초당 200건의 쿼리만으로도 한도에 도달합니다.
네트워크 문제일 가능성을 의심하기 시작했습니다. 사용 중인 인스턴스 타입의 최대 지속 처리량인 5 Gbps에는 한참 못 미친다는 것을 확인했지만, 롤아웃과 상관관계를 보이는 TCP 재전송 증가를 확인했습니다.


TCP 재전송 증가로 보아 패킷 손실이 발생하고 있었고, 이는 네트워크 포화로 인한 것으로 보였습니다. 처리량 그래프에는 드러나지 않을 정도로 짧은 트래픽 스파이크인 마이크로버스트가 일시적인 병목을 만들고 있다고 의심하며, AWS의 추가 네트워킹 관련 메트릭까지 조사 범위를 넓혔습니다.
다음 메트릭 쿼리 서비스 업데이트를 롤아웃하기 전에, 네트워크 성능을 더 깊게 파악하기 위해 Datadog Agent에서 ENA(Elastic Network Adapter) 메트릭을 활성화했습니다. 대역폭과 처리량 같은 일부 ENA 메트릭에서는 롤아웃과 상관관계를 보이는 문제가 나타나지 않았습니다. 그러나 conntrack_allowance_exceeded라는 메트릭에서 유의미한 증가를 발견했습니다.

이 메트릭은 VPC의 커넥션 트래킹(conntrack) 메커니즘이 포화되어 패킷이 드롭되는 횟수를 추적합니다. Conntrack은 호스트로부터 오가고 있는 네트워크 트래픽의 상태를 추적하여, EC2 보안 그룹에서 사용되는 상태 기반 패킷 필터링 같은 네트워크 기능을 가능하게 합니다. conntrack 테이블의 각 항목은 하나의 연결—관련 패킷의 흐름—을 나타냅니다. conntrack은 흐름이 진행됨에 따라 테이블 내 각 연결의 상태를 갱신하고, 타임아웃되거나 완료되면 연결을 제거합니다. conntrack 테이블에는 지원 가능한 항목 수의 상한이 있습니다. 테이블이 가득 차면 호스트는 더 이상 추가 연결을 생성할 수 없습니다.
이 인스턴스들에서는 두 개의 별도 conntrack 테이블에서 커넥션 트래킹이 이루어집니다. 하이퍼바이저 레벨에서 인스턴스별로 유지되는 VPC conntrack과, 각 인스턴스 내부의 Linux conntrack입니다.
ENA 메트릭이 VPC conntrack 포화를 보여준 것은 의외였습니다. Linux conntrack(아래 표시)의 연결 수는 상대적으로 낮았기 때문입니다. 과거에는 수십만 개의 연결도 문제없이 처리하는 인스턴스를 보아왔는데, 이번 롤아웃 동안 연결 수는 60,000 미만에서 피크를 찍고 있었습니다.

AWS 지원에 문의한 결과, Linux conntrack의 용량은 인스턴스 타입에 따라 달라지며, VPC conntrack의 최대 항목 수는 우리가 Linux conntrack에서 관측한 값보다 훨씬 크다는 답변을 받았습니다. 당시에는 납득하기 어려웠습니다. VPC conntrack은 포화되고 있는데, Linux conntrack은 충분히 감당 가능한 항목 수를 보여주고 있었기 때문입니다. 더 큰 인스턴스 타입—AWS에 따르면 더 많은 연결을 추적할 수 있는—을 테스트해 보기로 했고, 단순히 서비스 인프라를 상향 스케일링하는 것만으로 문제를 해결할 수 있음을 확인했습니다. 하지만 왜 테이블이 가득 차는지 이해하고 보다 지속 가능하고 비용 효율적인 방안을 찾고자 했습니다.
conntrack이 왜 가득 차는지 이해하려면 서비스의 네트워크 패턴에 대해 더 알아야 했고, Amazon VPC Flow Logs를 살펴보기 시작했습니다.
VPC Flow Logs는 저수준 네트워킹 동작을 모니터링하는 데 매우 유용한 도구입니다. 이번 경우, 메트릭 쿼리 서비스 트래픽에 관한 핵심 정보를 드러냈습니다.
서비스의 또 다른 업데이트를 롤아웃하면서, 각 파드가 새로운 파드로 교체될 때 파드 간 송수신 트래픽의 양을 VPC Flow Logs로 분석했습니다. 파드가 삭제된 뒤에도 메트릭 쿼리 클라이언트가 여전히 그 파드의 예전 IP 주소로 접속을 시도하고 있음을 보았습니다. 클라이언트가 오래된 파드가 더 이상 사용 불가임을 알게 되기 전의 짧은 시간 동안, 소수의 연결 시도만 있을 것으로 예상했었습니다.
더 자세히 알아보기 위해, 유입 트래픽의 유형을 보기 위해 TCP 플래그별로 VPC Flow Logs를 집계했습니다. 아래 그래프는 단일한 예전 IP 주소로의 인바운드 트래픽을 보여줍니다. 파란색 선은 롤아웃 이전에 설정된 장기 연결의 안정적인 비율을 보여줍니다. 빨간색 선은 gRPC가 연결을 우아하게 종료할 때 클라이언트에서 전송되는 FIN 요청을 나타냅니다(예상대로). 노란색 선은 롤아웃 시작 시점에, 클라이언트가 서비스 호스트와의 통신을 확립하기 위해 매우 높은 비율의 SYN 패킷을 생성했음을 보여줍니다. VPC Flow Logs에서 확인한 소스 IP 주소에 따르면, 이러한 연결 요청은 일부 클라이언트—특히 메트릭 데이터를 평가하는 클라이언트—에서 오고 있었습니다. 연결 요청을 수락하지 않음을 나타내는 RST 패킷의 비율이 그에 비례해 증가할 것으로 예상했지만, 놀랍게도 SYN 패킷이 응답을 받지 못하고 있었습니다. 이 데이터에 따르면 하나의 예전 IP 주소가 약 90초 동안 약 90,000번의 연결 시도를 받았습니다.

이 시점에서 ENA 메트릭은 포화된 VPC conntrack이 네트워크 연결을 방해하고 DNS 오류를 유발하고 있음을 보여주었습니다. 또한 VPC Flow Logs는 특정 유형의 클라이언트가 예전 파드에 접속하려고 SYN 요청을 보내고 있지만, 호스트가 응답하고 있지 않음을 드러냈습니다.
VPC conntrack은 왜 가득 차고—Linux conntrack은 왜 그렇지 않은지—를 이해하기 위해 네트워크 내부에서 무슨 일이 일어나는지 더 깊이 들여다보았습니다. 메트릭 쿼리 서비스로 오고 가는 패킷의 경로를 정의하는 두 가지 핵심 요소에 주목했습니다. Cilium과 역방향 경로 필터링(reverse path filtering)입니다.
메트릭 쿼리 서비스 파드를 호스팅하는 EC2 인스턴스는 두 개의 ENI(Elastic Network Interface)를 사용합니다. 기본 ENI—호스트에서는 ens5로 식별—는 호스트 네트워크 네임스페이스에서 실행되는 프로세스와의 송수신 트래픽을 담당하고, 보조 ENI—ens6—는 호스트 위의 파드와의 송수신 트래픽을 담당합니다.
우리는 Kubernetes 클러스터 내부 네트워킹 관리를 위해 ipam:eni 모드의 Cilium을 사용합니다. 새로운 파드가 생성되면, Cilium은 그 파드가 클러스터의 나머지와 트래픽을 주고받을 수 있도록 설정합니다. VPC의 CIDR 범위에서 IP 주소 일부를 예약하고, 보조 ENI 위에서 각 파드에 네이티브 라우팅 가능한 주소를 부여합니다.
Cilium은 파드 IP로 향하는 트래픽의 종단점이 될 가상 이더넷 디바이스(veth)를 생성합니다. 다음으로, VPC로부터 들어오는 트래픽이 파드에 도달할 수 있도록 호스트의 라우팅 테이블에 해당 veth로의 경로를 추가합니다. 또한 파드 IP로부터 나가는 트래픽을 보조 ENI로 보내기 위한 IP 라우팅 규칙도 호스트에 추가합니다.

롤아웃이 시작되면, Kubernetes는 기존 파드를 삭제하고 Cilium은 해당 파드의 라우팅 테이블 항목과 호스트의 IP 라우팅 규칙을 제거합니다. 이 시점에서, 예전 파드는 더 이상 존재하지 않지만 VPC 네트워킹은 여전히 그 IP 주소로 향하는 트래픽을 해당 파드가 실행되던 호스트의 보조 ENI로 전송합니다. 예전 IP로 전송된 패킷에 무슨 일이 일어나는지 이해하기 위해, 트래픽을 시뮬레이션하고 호스트의 라우팅 동작을 분석했습니다. 두 개의 노드 A와 B를 만들고, 그 사이에 트래픽을 보냈습니다.
노드 A(IP 10.a.b.c)에서, 노드 B의 보조 ENI(ens6)에 할당되어 있지만 노드 B의 파드에서 사용 중이 아닌 10.x.y.z 주소로 패킷을 보냈습니다:
nodeA:~$ nc -vz 10.x.y.z 12345
노드 B에서는 응답이 없었지만, 유입되는 SYN 요청과 재시도를 관찰할 수 있었습니다:
nodeB:~$ sudo tcpdump -pni ens6 "port 12345"listening on ens5, link-type EN10MB (Ethernet), capture size 262144 bytes08:28:52.086251 IP 10.a.b.c.51718 > 10.x.y.z.12345: Flags [S], seq 4126537246, win 26883, options [mss 8961,sackOK,TS val 2002199904 ecr 0,nop,wscale 9], length 0
관련 경로를 조회하자 오류가 표시되었습니다:
$ ip route get 10.x.y.z from 10.a.b.c iif ens6RTNETLINK answers: Invalid cross-device link
이 오류가 역방향 경로 필터링에서 기인한다는 것을 알고 있었고, 커널 로그를 추가로 확인하니 테스트 트래픽이 Martian 패킷으로 식별되고 있었습니다:
Oct 28 08:25:54 ip-10-y-y-z kernel: IPv4: martian source 10.x.y.z from 10.a.b.c, on dev ens6
이 시뮬레이션은 역방향 경로 필터링이 패킷을 드롭하고 있음을 보여주었습니다. 다음으로 그 이유를 밝히고자 했습니다.
역방향 경로 필터링은 각 패킷을 검사하여 소스, 목적지, 반환 경로에 기반해 유효성을 판단하는 Linux 보안 기능입니다. 이는 반환 경로가 유입 인터페이스와 다른 인터페이스를 사용하게 될 요청—즉 Martian 패킷—을 식별하고 무시함으로써 스푸핑을 방지하는 데 도움을 줍니다.
롤아웃 동안, 클라이언트가 삭제된 파드의 IP 주소로 SYN 패킷을 보낼 때마다, VPC는 해당 패킷을 해당 호스트의 보조 ENI로 라우팅했습니다. 패킷은 보조 ENI로 유입되었지만, Kubernetes가 예전 파드를 삭제하면서 Cilium이 IP 규칙을 제거했기 때문에 그 인터페이스를 통한 반환 경로는 존재하지 않았습니다. 즉, 그 요청에 대한 호스트의 응답은 기본 경로—기본 ENI—로 나가야 했습니다. 그러나 역방향 경로 필터링은 이를 Martian 패킷으로 간주하고 Linux conntrack에 추가되기 전에 드롭해 버렸습니다. 호스트는 SYN-ACK, RST, 또는 ICMP host unreachable 패킷으로 응답하지 않았고, 그 결과 클라이언트는 자신의 연결 요청이 오래된 IP로 향하고 있음을 알 수 없었습니다.

이 동작을 확인해 주는 커널 로그를 보았습니다:
Oct 28 08:25:54 nodeB kernel: IPv4: martian source 10.x.y.z from 10.a.b.c, on dev ens6
이로써 역방향 경로 필터링이 패킷을 드롭하고 있음을 확인했지만, 이 서비스의 호스트는 비대칭 라우팅을 허용하는 loose 모드를 사용하도록 설정되어 있었기 때문에 패킷이 드롭되리라 예상하지는 않았습니다. loose 모드는 패킷이 어떤 인터페이스를 통해서도 라우팅될 수 없다면 그때만 패킷을 드롭합니다. 즉, 보조 ENI로 유입된 패킷은 반환 경로가 기본 ENI라 하더라도 허용되어야 합니다. (반면 strict 모드는 유입된 인터페이스로 다시 라우팅될 수 없는 패킷을 드롭합니다.) 역방향 경로 필터링은 실제로는 loose 모드인데도 마치 strict 모드인 것처럼 동작하고 있었습니다.
이 이유는 인시던트를 종결한 뒤에야 이해할 수 있었습니다. 역방향 경로 필터링을 구현한 커널 코드를 살펴본 결과, RTNETLINK answers: Invalid cross-device link 오류와 Martian 패킷 로그는 여기의 return -EXDEV 문으로 인해 트리거되고 있었습니다:
e_rpf: return -EXDEV;
해당 return 문으로 이어지는 경로에서, rpf 매개변수의 값을 확인하는 last_resort 조건을 찾았습니다(우리의 경우 값 2는 loose 모드를 의미):
last_resort: if (rpf) goto e_rpf;
그런데 어떻게 last_resort로 도달했을까요? 이 테스트가 수상해 보였습니다:
if (no_addr) goto last_resort;
no_addr는 여기에서 설정됩니다:
no_addr = idev->ifa_list == NULL;
ifa_list에는 디바이스에 연결된 IP 목록이 들어가지만, 우리의 경우 보조 ENI인 ens6에는 IP 주소가 없었습니다. ens6에 우리 네트워크와 전혀 무관한 IP 주소를 하나 추가하여 다시 테스트했습니다.
$ ip addr add 192.168.1.1/32 dev ens6$ ip route get 10.x.y.z from 10.a.b.c iif ens610.x.y.z from 10.a.b.c via 10.m.n.1 dev ens5 cache iif ens6
이로써 보조 ENI에 IP 주소가 할당되어 있을 때에만 역방향 경로 필터링이 기대한 대로 동작한다는 사실이 확인되었습니다. 왜 no_addr 체크가 이런 방식으로 작성되어 있는지까지는 설명하지 못했지만, 적어도 역방향 경로 필터링이 예상과 다르게 동작한 이유는 이해할 수 있었습니다.
삭제된 파드로 향하는 요청이 Martian 패킷이 되지 않도록 방지하기 위해, 이후 Cilium 프로젝트에 PR을 기여하여 예전 파드의 IP 주소에 대해 "unreachable" 라우트를 추가했습니다. 이렇게 하면 유입 SYN이 역방향 경로 필터링에 의해 드롭되지 않고, 클라이언트가 명확한 ICMP 에러를 받게 됩니다.
한편 우리는 이제 Linux conntrack이 가득 차지 않은 이유도 이해했습니다. 패킷이 conntrack 테이블에 추가되기 전에 역방향 경로 필터링에 의해 드롭되고 있었기 때문입니다. 메트릭 쿼리 클라이언트가 예전 IP 주소로 높은 비율의 SYN 요청을 보내고 있다는 사실은 알았지만, 왜 그런지 이해하지 못했습니다. 그래서 클라이언트의 동작을 정의하는 gRPC 설정을 조사했습니다.
클라이언트가 서비스의 새 파드로 연결이 지연되는 이유를 설명하기 위해, 롤아웃 이후 파드 IP 주소 변경이 어떻게—그리고 얼마나 빨리—전파되는지를 이해하고자 했습니다. DNS 전파에는 여러 요인이 작용했으며, 각각이 전체 과정에 어떻게 지연을 더하는지 분석했습니다.
우리는 여러 클러스터에 배포된 서비스 간 통신을 제공하기 위해 external-dns를 사용합니다. 우리의 external-dns 구성은 TTL 15초를 사용하며, 사용하는 버전은 kubelet이 파드 삭제를 마칠 때까지 파드의 DNS 레코드를 업데이트하지 않습니다. (새로운 버전의 external-dns는 Kubernetes API가 삭제 명령을 받는 즉시 예전 주소를 등록 해제합니다.) SIGTERM을 받은 후, 서비스는 GracefulStop을 호출하고 종료 전에 10초 타임아웃을 적용하여 kubelet이 파드 삭제를 마치도록 합니다. 파드가 삭제된 후에는 다음 external-dns 동기화가 이루어질 때까지(우리의 경우 15초마다) Route 53의 DNS 주소가 업데이트되지 않습니다. 이를 바탕으로, 클라이언트가 업데이트된 DNS 레코드를 사용할 수 있게 되기까지 평균 25초(10초 타임아웃 + 15초 TTL 만료 및 Route 53 업데이트)—최악의 경우 40초—가 걸릴 것으로 예상했습니다.
연결이 끊기면 gRPC 클라이언트는 서비스를 재연결하기 위해 서비스의 DNS 이름을 다시 해석(resolve)합니다. 필요한 백엔드에 성공적으로 연결될 때까지 DNS 이름을 반복적으로 다시 해석합니다. 클라이언트의 재해석 빈도—연결 시도 빈도와는 무관—는 gRPC가 DNS 서버 과부하를 피하기 위해 제공하는 min_time_between_resolutions 매개변수에 의해 결정됩니다. 클라이언트는 이 매개변수의 기본값을 사용하므로, 재해석 사이에 30초를 대기합니다. 필요한 모든 DNS 업데이트 활동을 고려하면, 롤아웃이 파드 삭제를 시작한 후 30초 또는—더 가능성 있게—60초 후에 클라이언트가 업데이트된 DNS 정보를 갖게 됩니다.
우리는 이제 클라이언트가 업데이트된 DNS 정보를 얻는 데 ~60초가 걸리는 동안 예전 파드로 SYN 요청을 계속 보낸다는 것을 알았지만, 왜 그렇게 높은 비율로 시도했는지는 여전히 모르고 있었습니다.
서비스 클라이언트의 gRPC 설정에서 설명을 찾기 시작했고, 이 문제와 상관관계를 보이는 로드 밸런싱 정책 변경을 발견했습니다. 역사적으로 우리의 클라이언트는 gRPC의 pick_first 로드 밸런싱 정책을 사용해왔습니다. 2021년 6월, 별도의 문제를 해결하기 위해 영향을 받은 서비스의 클라이언트를 gRPC의 round_robin 로드 밸런싱 정책으로 변경했습니다. 그 인시던트 이후 여러 서비스의 클라이언트에 대해 round_robin을 기본 정책으로 적용하기로 했습니다. 2021년 8월 메트릭 쿼리 클라이언트를 업데이트할 때 새 기본값이 적용되었고, 클라이언트는 처음으로 round_robin 구성을 사용하기 시작했습니다.
이 변경이 클라이언트의 높은 SYN 요청률을 설명한다고 생각했습니다. pick_first 정책에서는 각 클라이언트가 서비스의 백엔드 중 하나에만 연결합니다. 그러나 round_robin 정책을 사용하면 클라이언트는 모든 백엔드에 연결합니다. 각 클라이언트는 각 연결을 열기 위해 SYN 요청을 보내므로, round_robin 정책이 VPC Flow Logs에서 본 SYN 급증을 설명한다고 여겼습니다.
하지만 자세히 들여다보니, 이 애플리케이션은 백엔드마다 하나의 gRPC 채널을 생성하고 있었기 때문에, 각 채널이 단일 타깃만 받는 한 pick_first와 round_robin의 동작은 동일해야 했습니다.

우리는 더 이상 SYN 급증이 연결 수 때문이라고 의심하지 않았지만, gRPC 로드 밸런싱 정책 변경과 상관관계가 있다는 사실은 알고 있었기 때문에, 그들의 재연결 동작에서 설명을 찾았습니다. pick_first 구성에서는 클라이언트가 서비스와의 연결이 끊겨도 자동으로 재연결을 시도하지 않습니다. 대신 애플리케이션이 재연결을 요청할 때까지 기다립니다. round_robin 정책으로 전환하면, 채널이 연결 해제될 때마다 클라이언트가 자동으로 재연결합니다.
클라이언트의 재연결 비율을 좌우하는 요인 중 하나는 설정된 연결 백오프 값입니다. 클라이언트 코드를 확인해 보니, 아주 오래전에 설정했던 매우 공격적인 gRPC 재연결 타임아웃이 있어, 연결할 수 없을 때 더 자주 재시도하게 되어 있었습니다. pick_first 정책에서는 상대적으로 재연결이 적었기 때문에 이 공격적인 설정의 영향이 눈에 띄지 않았지만, round_robin 정책으로 바꾸면서 그 영향이 두드러지기 시작했습니다. 이 서비스에는 약 900개의 클라이언트—약 600개의 파드, 각 파드에 15개의 컨테이너—가 있습니다. 각 클라이언트가 약 300ms마다 재연결을 시도하고 있었기 때문에, 초당 최대 30,000개의 SYN 요청이 발생했습니다.
예전 파드로 전송된 SYN 요청은 역방향 경로 필터링에 의해 드롭되었지만, 그 전에 VPC conntrack에는 추가되었습니다. 일반적으로 VPC 커넥션 트래킹 로직은 호스트가 RST 또는 ACK(요청을 수락했음을 의미)로 응답할 때 상태를 갱신하며 이러한 기록을 유지합니다. 60초 내 인지되지(acknowledge) 않은 연결 요청은 VPC conntrack 테이블에서 자동으로 제거되어야 하지만, 롤아웃 중에는 클라이언트 요청이 제거 속도보다 훨씬 빠르게 테이블에 누적되고 있었습니다.
사용자 지정 gRPC 재연결 매개변수가 SYN 급증을 유발하고 있음을 이해한 뒤, 아래와 같이 해당 매개변수를 제거하고 기본값을 복원하는 PR을 만들었습니다.

이후 롤아웃에서는 9월부터 롤아웃과 상관관계를 보였던 오류가 재현되지 않았고, 인시던트를 종결할 수 있었습니다.
이번 일련의 인시던트는 우리가 의존하는 강력한 추상화—gRPC, Kubernetes, AWS—의 가장자리에 있는 엣지 케이스가 가끔 오류를 만들어 내고, 평소엔 감춰져 있는 복잡성이 표면으로 드러나게 함을 보여주었습니다. 이를 통해 우리의 gRPC 구성에 대해 더 깊이 이해해야 했고, 오랫동안 서비스에서 보아 왔지만 완전히 이해하지 못했던 동작들에 관한 통찰을 얻게 되었습니다. 몇 달에 걸친 조사 끝에, 결국 이번 인시던트의 뿌리가 우리가 스스로 만든 변경에 있음을 알게 되었습니다. 의존성의 버그를 우회하고, 이전 인시던트를 해결하며, 기본 재연결 매개변수를 재정의하기 위해 이루어졌던 이러한 변경들은 당시에는 안전해 보였지만, 훨씬 뒤에 예상치 못한 영향을 일으켰고 상관관계를 파악하기 어려웠습니다.
이 이야기가 흥미롭고 유익했다면, 저희와 함께 일하는 것에도 관심을 가져 보시기 바랍니다.