티스토리 뷰

금요일 저녁 8시였다. 애들 셋 씻기고 이제 좀 앉아볼까 하는데 폰이 울렸다. 슬랙 알람. "DB 연결이 안 됩니다." 운영 서버 로그를 열어보니 익숙하면서도 제일 보기 싫은 그 문구가 찍혀 있었다. SQLSTATE[HY000] [1040] Too many connections.

RDS를 몇 년 써봤다는 사람도 이 에러 앞에서는 한 번씩 당황한다. 나도 그랬다. 커넥션이 왜 고갈됐는지, 지금 당장 뭘 해야 서비스가 살아나는지, 그리고 다시는 안 터지게 하려면 뭘 고쳐야 하는지가 머릿속에서 한 번에 정리가 안 되기 때문이다. 이 글은 그날 밤 내가 했던 삽질과, 그 뒤에 정리해둔 대응 순서를 기록한 것이다. 같은 에러로 검색해서 들어온 분이라면 긴급 대응 섹션부터 보셔도 된다.

증상: Too many connections, 그런데 서버는 멀쩡해 보인다

이 장애의 고약한 점은 EC2도 멀쩡하고, RDS CPU도 20%대로 한가해 보인다는 거다. 모니터링 대시보드만 보면 아무 문제가 없다. 그런데 애플리케이션은 DB 커넥션을 못 맺어서 500을 뱉고 있다.

이유는 단순하다. MySQL(MariaDB도 동일)은 동시에 맺을 수 있는 커넥션 수에 상한이 있다. max_connections 파라미터다. 이 상한에 도달하면 새 커넥션 요청을 전부 거절한다. CPU나 메모리가 남아돌아도 소용없다. 문지기가 "정원 초과"라고 막아버리는 상황이다.

먼저 지금 상태를 확인한다. 다행히 운영자용 예약 커넥션 한 자리는 남겨두는 경우가 많아서, 관리 계정으로는 접속이 될 때가 있다.

-- 현재 커넥션 수
SHOW STATUS LIKE 'Threads_connected';

-- 설정된 상한
SHOW VARIABLES LIKE 'max_connections';

-- 역대 최대 동시 커넥션 (이게 max_connections에 붙어 있으면 확정)
SHOW STATUS LIKE 'Max_used_connections';

-- 누가 물고 있는지
SELECT user, host, db, command, time, state
FROM information_schema.processlist
ORDER BY time DESC;

그날 밤 우리 서버는 max_connections가 405였고 Threads_connected가 404였다. 깔끔한 만석이었다.

RDS의 max_connections 기본값은 생각보다 작다

여기서 많이들 놀란다. RDS MySQL의 max_connections 기본값은 고정 숫자가 아니라 인스턴스 메모리 기반 공식이다.

{DBInstanceClassMemory/12582880}

메모리를 12,582,880바이트(약 12MB)로 나눈 값이다. 인스턴스별로 대략 계산해보면 이렇다.

  • db.t3.micro (1GB) → 약 80~85개
  • db.t3.small (2GB) → 약 160~170개
  • db.t3.medium (4GB) → 약 320~340개
  • db.m5.large (8GB) → 약 640~680개

실제 값은 OS와 RDS 관리 프로세스가 쓰는 메모리를 빼고 계산되기 때문에 저 어림값보다 조금 작게 잡힌다. 중요한 건 "t3.small이면 커넥션 160개 남짓"이라는 감각이다. 이게 얼마나 작은 숫자인지는 다음 섹션에서 바로 체감된다.

원인 1: 커넥션 풀 산수를 안 해봤다

교과서는 이렇게 말한다. "커넥션 풀을 쓰면 커넥션을 재사용하므로 효율적입니다." 맞는 말이다. 그런데 실무에서 터지는 건 풀을 안 써서가 아니라, 풀 사이즈 곱셈을 안 해봐서다.

우리 구성이 딱 그랬다. 라라벨 API 서버가 오토스케일링 그룹에 들어 있었고, 평소엔 4대였다. 서버당 PHP-FPM 워커가 40개. PHP는 전통적으로 요청당 커넥션을 맺고 끊지만, 우리는 persistent connection을 켜둔 상태였다. 그러면 산수가 이렇게 된다.

서버 4대 × 워커 40개 = 커넥션 최대 160개  → t3.medium(약 330개)에서 여유 있음
트래픽 몰려서 8대로 스케일아웃 = 320개     → 턱밑
10대 = 400개                              → 폭발

그날 저녁 마케팅 푸시가 나갔고, 오토스케일링은 설계대로 성실하게 인스턴스를 10대까지 늘렸다. 앱 서버는 늘어나는데 DB 커넥션 상한은 그대로니까, 서버를 늘릴수록 장애가 심해지는 역설이 완성됐다. 오토스케일링의 최대 인스턴스 수 × 서버당 풀 사이즈가 max_connections를 넘는 순간, 그 구성은 시한폭탄이다. 스프링 부트의 HikariCP든, Node의 pg 풀이든, 장고의 CONN_MAX_AGE든 전부 같은 산수가 적용된다.

원인 2: Lambda가 끼어 있으면 곱셈이 더 가팔라진다

우리 사례는 아니지만, 요즘 이 에러의 단골 원인이 하나 더 있다. 서버리스다. Lambda는 동시 실행 하나당 실행 환경이 하나씩 뜨고, 각 환경이 DB 커넥션을 쥔다. 동시 실행 500이면 커넥션 500개다. 풀이고 뭐고 없이 상한을 뚫는다.

게다가 Lambda 실행 환경은 끝나도 바로 사라지지 않고 한동안 유지되면서(웜 상태) 커넥션을 물고 있는 경우가 많다. 트래픽 스파이크가 지나간 뒤에도 커넥션 수가 한참 높게 유지되는 그래프를 봤다면 십중팔구 이 패턴이다. Lambda에서 RDS를 직접 때리는 구조라면 뒤에 나올 RDS Proxy가 사실상 정답에 가깝다.

긴급 대응: 서비스부터 살리는 30분

원인 분석은 나중이고, 일단 살려야 한다. 그날 밤 기준으로 효과가 있었던 순서다.

1단계. 관리 계정으로 접속해서 오래 물고 있는 커넥션을 정리한다. processlist에서 Sleep 상태로 수백 초씩 누워 있는 커넥션이 보이면 끊어도 된다. RDS에서는 KILL 대신 전용 프로시저를 쓴다.

CALL mysql.rds_kill(프로세스ID);

한 건씩 죽이는 게 답답하면 processlist에서 kill 문을 생성해서 한꺼번에 돌리는 방법도 있다. 단, 어떤 커넥션인지 확인하고 죽이자. 배치 작업의 긴 트랜잭션을 끊으면 롤백으로 더 큰 불을 만들 수 있다.

2단계. 파라미터 그룹에서 max_connections를 임시로 올린다. RDS는 기본 파라미터 그룹을 수정할 수 없으니 커스텀 파라미터 그룹이 이미 붙어 있어야 한다. max_connections는 동적 파라미터라서 재부팅 없이 적용된다. 이게 되는 구성이면 5분 안에 숨통이 트인다. 다만 이건 진통제다. 메모리가 받쳐주지 않는데 상한만 올리면 이번엔 스왑과 OOM이 온다. 커넥션 하나가 버퍼 등으로 수 MB씩 먹을 수 있다는 걸 기억하고, 임시로 1.5배 정도까지만.

3단계. 커넥션을 뿜어내는 쪽을 잠근다. 우리는 오토스케일링 최대 대수를 일단 6대로 줄였다. Lambda가 원인이라면 해당 함수의 reserved concurrency를 낮추는 게 같은 역할을 한다. 유입을 줄이는 게 아니라 DB로 가는 수도꼭지를 잠그는 것이다.

이 세 개로 그날 밤은 넘겼다. 11시쯤 애들 재우러 들어갔으니 세 시간짜리 장애였다.

재발 방지 1: 풀 사이즈를 공식으로 정한다

다음 주에 제대로 정리했다. 핵심은 감으로 잡던 풀 사이즈를 공식으로 바꾼 것이다.

(서버 최대 대수 × 서버당 풀 최대치) + 배치/크론 + 관리자 접속 여유분
  ≤ max_connections × 0.8

0.8을 곱하는 이유는 페일오버나 점검 때 쓸 여유, 그리고 예상 못 한 접속(사람 손, 모니터링 도구)의 몫이다. 거꾸로 말하면 풀 최대치는 이렇게 역산한다.

서버당 풀 최대치 = (max_connections × 0.8 − 배치 − 여유분) ÷ 서버 최대 대수

여기서 "서버 대수"는 평소 대수가 아니라 오토스케일링 최대 대수다. 이걸 평소 대수로 계산해두는 바람에 터지는 집이 많다. 우리도 그랬고.

그리고 풀 사이즈는 크다고 좋은 게 아니다. DB가 실제로 동시에 일할 수 있는 쿼리 수는 vCPU 수에 수렴한다. 풀을 수백 개로 잡아봐야 대부분 줄만 서 있고, 컨텍스트 스위칭 비용으로 오히려 느려진다. HikariCP 문서가 제안하는 (코어 수 × 2) + 디스크 수 같은 작은 값에서 시작해서 부하 테스트로 늘려가는 쪽이 맞다. 이론은 "풀은 여유 있게"지만, 실제로는 "풀은 생각보다 작게"가 정답인 경우가 훨씬 많았다.

재발 방지 2: wait_timeout과 유휴 커넥션 정리

processlist에서 봤던 수백 초짜리 Sleep 커넥션들, 그게 자리만 차지하는 유령 손님이다. 앱이 커넥션을 닫지 않고 종료됐거나, persistent connection이 회수되지 않고 쌓인 결과다. 두 군데서 잡는다.

  • DB 쪽: wait_timeout(기본 28800초 = 8시간)을 내린다. 우리는 300초로 잡았다. 8시간은 유령 손님에게 자리를 8시간 보장해주는 설정이다.
  • 앱 쪽: 풀의 idle timeout을 DB의 wait_timeout보다 짧게 잡는다. 반대로 하면 DB가 먼저 끊은 죽은 커넥션을 앱이 쓰려다 "MySQL server has gone away"를 만난다. 이 에러로 검색해서 온 분이라면 범인은 이 순서 역전이다.

재발 방지 3: RDS Proxy, 쓸 만한가

Lambda 얘기가 나왔으니 RDS Proxy도 짚고 가자. 앱과 RDS 사이에 끼어서 커넥션 풀을 대신 관리해주는 관리형 프록시다. 수천 개의 앱 커넥션을 받아서 DB에는 소수의 커넥션으로 다중화(multiplexing)해준다. 페일오버 시간도 단축된다.

직접 붙여본 소감은 이렇다. Lambda처럼 커넥션 수가 예측 불가능하게 튀는 구조에서는 투자 대비 효과가 확실하다. 반면 서버 대수가 뻔하고 풀 산수가 되는 일반 EC2/ECS 구성에서는 비용(vCPU당 과금)이 아깝게 느껴질 수 있다. 우리는 Lambda 기반 신규 서비스에만 붙이고, 기존 API 서버는 풀 설정 정리로 끝냈다. 전부 붙이는 게 아니라 커넥션 패턴이 난폭한 워크로드에만 선별 투입하는 게 합리적이라고 본다.

재발 방지 4: 터지기 전에 알람이 오게

마지막 조각은 모니터링이다. 이 장애는 예고가 길다. 커넥션 수는 보통 몇 주에 걸쳐 슬금슬금 오르다가 어느 날 상한을 친다. 그래프만 보고 있었어도 2주 전에 알 수 있었다.

  • CloudWatch의 DatabaseConnections 지표에 알람을 건다. 기준은 max_connections의 80%. 우리는 t3.medium 기준 260에 걸어뒀다.
  • Performance Insights를 켠다(프리 티어로 7일 보존은 무료). 커넥션을 누가 만드는지, 어떤 쿼리가 오래 무는지가 바로 보인다.
  • 배포 체크리스트에 "오토스케일링 최대 대수 바꾸면 풀 산수 다시" 한 줄을 넣었다. 제일 원시적인데 제일 효과 있었다.

정리: 그날 밤 이후 바뀐 것

순서대로 요약하면 이렇다.

  1. 장애 중이면: processlist 확인 → 유휴 커넥션 kill → 동적 파라미터로 max_connections 임시 증설 → 커넥션 발생원(오토스케일링/Lambda 동시성) 조이기
  2. 풀 사이즈는 오토스케일링 최대 대수 기준으로 역산, 전체 합이 max_connections의 80% 이하
  3. wait_timeout은 내리고, 앱 풀의 idle timeout은 그보다 더 짧게
  4. Lambda가 DB를 직접 때리면 RDS Proxy 검토
  5. DatabaseConnections 80% 알람은 지금 바로 걸 것

Too many connections는 사실 DB 장애가 아니다. 용량 계획 장애다. 인스턴스를 키우는 건 상한을 미루는 것일 뿐이고, 산수를 해놓지 않으면 더 큰 인스턴스에서 더 크게 터진다. 금요일 저녁의 세 시간을 수업료로 내고 배운 결론이다. 이 글이 누군가의 금요일 저녁을 지켜주면 좋겠다.

이미지 출처: Unsplash — Kevin Ache(@kevinache), Taylor Vick(@tvick), Luke Chesser(@lukechesser)

'AWS > RDS' 카테고리의 다른 글

AWS RDS proxy 적용 후기  (0) 2023.04.11
댓글


최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday