IL-0313 Decagon · 2026-08-05 (게시일)databasegcp
자체 운영 PgBouncer 로 옮기는 운영 카나리에서 일부 요청이 정확히 300초 멈추고 SQLAlchemy 커넥션 풀이 고갈됐다
Decagon 은 GCP 의 관리형 커넥션 풀러도 PgBouncer 를 쓰므로 자체 운영 PgBouncer 로 옮기는 일이 간단할 것으로 보았고, 스테이징에서도 문제가 없어 보였다. 그런데 첫 운영 카나리에서 일부 음성 요청이 대화 도중 멈췄다가 정확히 300초 뒤 타임아웃됐고, 다른 요청은 SQLAlchemy 커넥션 풀 고갈로 실패했다. Postgres 쿼리 지연은 p95 1~3밀리초였고 PgBouncer 의 maxwait 는 0이었다. 타임라인을 맞춰 보니 Postgres 가 밀리초 안에 끝낸 쿼리를 애플리케이션이 300초 동안 기다리고 있었다. 팀은 응답 하나를 SQLAlchemy 의 QueuePool, PgBouncer 의 패킷 파서와 이벤트 루프, OpenSSL 의 TLS 레코드 버퍼, 리눅스 커널의 epoll() 네 계층에 걸쳐 따라갔다.
Decagon 은 PostgreSQL 의 application name 값에 현재 요청 경로를 넣어 데이터베이스 작업을 엔드포인트별로 구분해 왔다. 이 값이 바뀔 때마다 Postgres 는 새 값을 ParameterStatus 패킷으로 클라이언트에 보낸다. 멈춤은 세 조건이 겹칠 때 났다. application name 이 41바이트보다 길어 ParameterStatus 패킷이 64바이트를 넘었고, 쿼리 응답의 크기가 좁은 범위에 들어 이 패킷이 PgBouncer 의 4096바이트 읽기 버퍼 경계에 걸쳤으며, 나뉜 패킷 두 부분이 Postgres 와 PgBouncer 사이의 같은 TLS 레코드에 실렸다. 앞의 두 조건은 전부터 있었고, 관리형 풀러에서 옮기며 바뀐 TLS 경로가 세 번째 조건을 만들었다.
SSL_read() 는 TLS 레코드 하나를 커널 소켓에서 통째로 꺼내 OpenSSL 내부 버퍼에 두고, 호출한 쪽이 덜 읽으면 나머지 평문을 그 버퍼에 남긴다. 재현 사례 하나에서 Postgres 는 4,118바이트 TLS 레코드 하나를 보냈고, PgBouncer 는 평소대로 4,096바이트를 읽었으며, 86바이트 ParameterStatus 패킷이 버퍼 경계에 걸쳐 앞의 70바이트만 보였다. PgBouncer 는 데이터가 더 필요하다고 보고 SSL_pending() 을 확인하지 않은 채 epoll() 대기로 돌아갔다. 나머지 16바이트는 이미 OpenSSL 안에 있었으므로, 이 연결을 깨울 커널 이벤트는 오지 않았다. 멈춘 쿼리는 cursor.execute() 안에서 막혀 연결을 QueuePool 에 반환하지 못했고, Postgres 가 300초 뒤 세션을 끊을 때까지 연결을 잡고 있었다. 이렇게 묶인 연결 몇 개만으로 풀의 실제 크기가 줄었다. 같은 프로세스의 QueuePool 을 쓰는 상관없는 요청들은 줄을 서다 풀 고갈로 실패했다. 문제를 일으킨 요청과 피해를 본 요청은 대개 달랐다.
Decagon 은 애플리케이션에서 application name 값을 줄여 ParameterStatus 패킷이 PgBouncer 의 작은 패킷 파싱 기준 아래에 머물게 했다. 세 조건 중 하나가 없어져 마이그레이션을 계속할 수 있었다. 이어서 PgBouncer 업스트림에 수정을 머지했다. 이 수정은 대기에 들어가기 전에 SSL_pending() 을 확인하고, OpenSSL 에 평문이 이미 있으면 계속 읽게 한다. 이 수정으로 패킷 크기나 응답 모양과 상관없이 교착이 없어진다.
- 심각도
- –
- 영향 범위
- During the production canary, a small number of voice requests stalled mid-conversation for 300 seconds, and unrelated requests sharing the same connection pool failed with pool exhaustion
- 영향 사용자
- –
- 지역
- –
- 감지까지
- –
- 복구까지
- –
증상
GCP 의 관리형 커넥션 풀러에서 자체 운영 PgBouncer 로 옮기는 첫 운영 카나리에서, 일부 음성 요청이 대화 도중 멈췄다가 정확히 300초 뒤 타임아웃됐다. 다른 요청은 그보다 훨씬 일찍 SQLAlchemy 커넥션 풀 고갈로 실패했다.
Sentry 에는 "SSL connection has been closed unexpectedly" 오류가 몇 건 찍혔다. 이 오류는 PostgreSQL 의 idle-in-transaction 세션 타임아웃이 멈춘 세션을 끊은 뒤에야 나왔기 때문에, 따로 떨어진 연결 실패처럼 보였다.
풀 고갈로 실패한 요청에서는 다음 오류가 나왔다. sqlalchemy.exc.TimeoutError: QueuePool limit of size 2 overflow 20 reached, connection timed out, timeout 30.00
처음에는 애플리케이션의 데이터베이스 연결이 모자란 것처럼 보였지만 다른 신호는 용량 문제와 맞지 않았다. Postgres 쿼리 지연은 p95 로 1~3밀리초였고, 클라이언트가 서버 연결을 기다린 시간인 PgBouncer 의 maxwait 는 0이었다.
애플리케이션과 데이터베이스의 타임라인을 맞춰 보니 Postgres 는 해당 쿼리를 밀리초 안에 끝냈는데, 애플리케이션의 스팬은 정확히 300초 동안 열려 있다가 기대한 행을 받았다. 문제의 스택 트레이스는 모두 애플리케이션의 데이터베이스 호출을 가리켰다. 300초 동안 멈춘 요청이 QueuePool 의 연결을 잡고 있는 사이, 다른 요청이 그 연결을 빌리려고 기다리다 30초 뒤 타임아웃된 것이었다.
스테이징에서는 이 문제가 나타나지 않았다. 버그 조건에 맞을 만큼 긴 application name 값을 만드는 엔드포인트가 백엔드 API 에 둘뿐이었고, 그 엔드포인트에서도 버퍼 경계에 걸리는 쿼리 응답은 아주 드물었다.
원인
원인 소프트웨어 결함
- 촉발
- Decagon 이 GCP 의 관리형 커넥션 풀러에서 자체 운영 PgBouncer 로 옮기면서 PgBouncer 와 Postgres 사이의 TLS 경로가 바뀌었다. 첫 운영 카나리에서 이 바뀐 경로 때문에, PgBouncer 에 숨어 있던 버그가 드러날 마지막 조건이 갖춰졌다.
감지
운영 카나리 중 일부 음성 요청이 대화 도중 멈추고 정확히 300초 뒤 타임아웃됐다. Sentry 에 "SSL connection has been closed unexpectedly" 오류가 몇 건 찍혔고, 다른 요청은 SQLAlchemy 커넥션 풀 고갈로 실패했다.
조치
- 애플리케이션에서 application name 값을 줄여 ParameterStatus 패킷이 PgBouncer 의 작은 패킷 파싱 기준 아래에 머물게 했고, 버그의 세 조건 중 하나를 없애 마이그레이션을 계속했다.
후속 조치
- PgBouncer 업스트림에 이벤트 루프 버그의 수정을 머지했다. epoll() 대기로 돌아가기 전에 SSL_pending() 을 확인하고, OpenSSL 에 복호화된 바이트가 남아 있으면 계속 읽는다.
교훈
인프라 마이그레이션은 자기 시스템만 바꾸는 것이 아니라 스택의 다른 곳에 숨어 있던 버그를 드러낼 수 있다. 이번에는 자체 운영 PgBouncer 로 옮긴 일이 SQLAlchemy, PgBouncer, OpenSSL, PostgreSQL wire protocol 이 얽힌 오래된 예외 상황이 드러날 마지막 조건을 만들었다. 버그는 응답이 정확히 그 모양일 때는 늘 재현됐지만, 그런 응답 자체가 드물어 스테이징에서 나타나지 않았다.
출처
관련 사례
IL-0451Aurora MySQL 에서 인덱스를 INVISIBLE 로 바꿀 때마다 Reader 3대의 버퍼풀이 무효화돼 CPU 와 ReadIOPS 가 튀었다Daangn · 2026같은 원인 · 공통 데이터베이스
IL-0281PostHog US 리전 Logs 제품의 ClickHouse 클러스터가 S3 에 둔 로그 데이터를 거의 다 지워 3일보다 오래된 로그가 사라졌다PostHog · 2026같은 원인 · 공통 데이터베이스
같은 원인 · 공통 데이터베이스
IL-0150DynamoDB DNS 자동화의 경쟁 조건이 us-east-1 리전 엔드포인트의 DNS 레코드를 비워 DynamoDB 와 EC2 인스턴스 시작이 실패했고, 그 여파로 EKS, ECS, Fargate 의 컨테이너 시작과 클러스터 스케일링이 14시간 넘게 막혔다Amazon Web Services · 2025같은 원인 · 공통 데이터베이스
같은 원인 · 공통 데이터베이스
IL-0527OpenStreetMap.org 의 복제 diff 생성이 invalid memory alloc request size 오류로 16시간 멈춤OpenStreetMap Foundation · 2025같은 원인 · 공통 데이터베이스
IL-0329Postgres 17 업그레이드 뒤 다시 켠 PGAudit 가 마이그레이션 중 락을 쥔 채 멈춰, 서비스 전반이 느려지고 데이터베이스가 2분 동안 내려갔다incident.io · 2025같은 원인 · 공통 데이터베이스
같은 원인 · 공통 데이터베이스
같은 원인 · 공통 데이터베이스
IL-0024무거운 API 요청이 MongoDB 에 과부하를 걸고 취소되지 않은 요청이 쌓여 Hugging Face Hub 가 사흘 동안 세 번 멈췄다Hugging Face · 2024같은 원인 · 공통 데이터베이스
같은 원인 · 공통 데이터베이스
같은 원인 · 공통 데이터베이스
IL-0385큰 MySQL 테이블의 스키마 마이그레이션 마지막 단계에서 읽기 복제본 다수가 semaphore deadlock 에 걸려 GitHub 핵심 서비스가 2시간 50분 저하됐다GitHub · 2021같은 원인 · 공통 데이터베이스
같은 원인 · 공통 데이터베이스
IL-0196Redis Operator 로 레플리카를 늘리면서 메모리 한도를 낮추자 데이터를 가진 유일한 Redis 파드가 재시작됐고, Sentinel 이 빈 레플리카를 마스터로 올려 데이터가 모두 지워졌다.Palark · 2020같은 원인 · 공통 데이터베이스
같은 원인 · 공통 데이터베이스
같은 원인 · 공통 데이터베이스
같은 원인 · 공통 데이터베이스
IL-0388Redis 고가용 클러스터의 primary 노드가 내려간 뒤 Discord API 가 느려지고 길드 일부가 오프라인으로 보이며 접속이 실패했다Discord · 2017같은 원인 · 공통 데이터베이스
같은 원인 · 공통 데이터베이스
같은 원인 · 공통 데이터베이스
같은 원인 · 공통 데이터베이스
같은 원인 · 공통 데이터베이스
같은 원인 · 공통 데이터베이스
내 클러스터에도 있는지 확인하기
AI 도구와 스크립트용
- 이 사례의 JSON: https://incidentlib-api.benchlet.com/cases/IL-0313
- 가장 비슷한 사례들: https://incidentlib-api.benchlet.com/cases/IL-0313/similar
- MCP 서버(Streamable HTTP, 키 없음):
https://incidentlib-mcp.benchlet.com/mcp
API 는 키가 필요 없습니다. MCP 서버는 search_incidents, get_incident, list_incidents, get_topic, vocab 도구를 내놓습니다. 사용법은 API 와 MCP.