Node.js 서버에서 DB 연결 끊김에 대처하기

gompro·2024년 8월 10일
1
post-thumbnail
post-custom-banner

Tldr;

  1. mysql2 드라이버 사용 중 Aurora MySQL 자동 업그레이드로 순단이 발생하였고, Pool does Not exists 에러가 발생
  2. mysql2 드라이버를 mysql 드라이버로 변경하여 해결
  3. 가능하다면 더 유연한 옵션을 제공하는 mariadb 드라이버를 사용하자

발단

Aurora MySQL 디비를 사용할 경우, minor 혹은 patch 버전에 대한 자동 업그레이드가 기본 활성화돼있다.

그리고 자동 업그레이드가 진행되는 동안 기존 인스턴스를 재부팅하는 과정이 포함되므로 애플리케이션의 디비 연결에 잠깐의 끊어짐이 발생할 수 있다.

재부팅으로 인한 다운타임은 인스턴스의 사이즈나 버전 등에 따라 차이가 있지만 내 경우에는 그리 길지 않았다. (12:03 ~ 12:04, 즉 1분 미만)

rds logs and event

하지만 몇몇 서버에서 버전 업그레이드가 종료된 후에도 새로운 인스턴스에 접속하지 못하는 현상이 지속되었으며, 문제가 되는 쿠버네티스 파드를 모두 제거 > 재기동한 후에야 정상화되었다.

이후 문제가 된 애플리케이션의 로그를 확인하니 아래와 같이 "Pool does Not exists" 와 같이 남아있었다.

[Nest] 1  - 08/05/2024, 4:00:11 AM  ERROR [AllExceptions] {"statusCode":500,"code":500,"error":"Error","message":"Pool does Not exists."}

첫 시도

처음에는 파드 재시작을 통해 문제를 해결했기 때문에 해당 프로세스를 자동화할 방법을 생각했다.

아래 코드처럼 Pool does Not exists 에러 발생 시 process.kill을 해 프로세스를 죽여 파드 재시작을 트리거하는 것이다.

process.kill

즉, SIGTERM 시그널 수신 > 리소스 정리 > 파드 재시작 순서로 애플리케이션을 재실행하여 자연스럽게 다시 디비에 연결하는 것이다.

하지만 이 방법은 파드 재시작 ~ 애플리케이션 재기동까지 수십초 이상 걸리므로 장애 회복까지 너무 오랜 시간이 걸린다.

또 그냥 생각해봐도 문제가 발생했을 때 그냥 다시 디비에 연결하는 방식이 훨씬 효율적이다.

그래서 해당 솔루션을 적용하는 대신 에러가 발생한 원인을 좀 더 파악해보기로 했다.

원인

코드를 따라가보니 해당 에러는 mysql2 드라이버의 아래 코드로부터 발생하고 있었다.

pool-does-not-exists

해당 에러는 clusterNodenull 즉 가용 가능한 노드가 없을 때 발생한다.

좀 더 상세히 말하면 커넥션 풀에서 새로운 커넥션을 가져오려할 때 연결할 수 있는 노드를 찾지 못해 에러가 발생한 것이다.

mysql2 드라이버의 경우 사용 가능한 옵션에 대해 따로 문서를 제공하지 않지만, mysql 드라이버와 거의 호환가능하므로 mysql 드라이버의 pool 연결 옵션을 살펴보자.

mysql pool 연결 옵션에 따르면, pool cluster 사용 시 커넥션 풀의 기본 동작은 removeNodeErrorCount 수치가 5에 다다르면 해당 노드를 더 이상 연결할 수 없는 노드로 간주, 해당 노드를 제거한다.

pool options

코드를 보면 더욱 명확하게 알 수 있다.

increase-error-count

혹시 몰라 키바나에 남은 애플리케이션 로그를 확인해보니 더욱 명확해졌다. 설명처럼 5번 같은 노드에 접속 시도 후 실패한 뒤에는 다른 노드에 연결을 시도했다.

ECONREFUSED

그렇게 사용 가능한 모든 노드에 연결을 시도한 뒤 실패하자 Pool does Not exists 에러를 뱉은 것이다.

해결?

깃헙 이슈를 뒤져보니 나와 동일한 사례가 몇 가지 보였고, 또 블로그에서 RDS 버전 자동 업그레이드에 대한 대처를 한 사례가 있어서 찾아본대로 restoreNodeTimeout 옵션을 적용해보기로 했다.

하지만 확인 결과 이미 해당 옵션이 1000 (1초)로 적용되어 있었다.

즉 옵션이 적용되어 있었음에도 동작하지 않은 것이다.

mysql2 레포지토리에서 restoreNodeTimeout검색해보면 해당 옵션이 아직 구현되지 않았음을 확인할 수 있다.

restore-node-timeout

결국 해당 옵션을 지원하는 mysql 드라이버를 사용하기로 결정, mysql2 드라이버를 사용하고 있던 애플리케이션의 드라이버를 mysql로 교체했다.

한 가지 주의점은 mysql 드라이버 사용 시 MySQL 8+의 caching_sha2_password authentication plugin 을 사용할 경우 인증을 실패한다.

그러므로 사용하는 계정이 mysql_native_password를 사용하는지 확인한 후 mysql 드라이버를 사용해야 한다.

SELECT user, plugin, host FROM mysql.user;

password

만약 위 스크린샷처럼 caching_sha2_password 방식을 사용할 경우, mysql 드라이버를 사용할 수 없다.

이 경우 mysql_native_password를 사용하는 새로운 계정을 만들어 해당 계정을 사용하게끔 할 수 있다.

CREATE USER 'newuser1'@'localhost' IDENTIFIED WITH mysql_native_password BY 'Password@123';
GRANT ALL ON *.* TO 'newuser1'@'localhost';

다른 디비 드라이버는?

문제가 일단락되니 다른 언어에서 사용하는 드라이버는 재시작 등으로 인한 연결 상실에 어떻게 대처하는지 궁금해졌다.

가장 먼저 Python 생태계에서 가장 유명한 SQLAlchemy의 문서를 찾아봤는데, SQLAlchemy의 경우 커넥션 풀 설정에 대한 별도 문서가 있었다.

해당 문서의 Dealing with disconnects 항목을 읽어보니 크게 2가지 방식의 대처 방법이 있다.

첫 번째는 Pessimistic 방식이다.

해당 방식은 풀에서 꺼내오는 커넥션의 상태가 항상 닫혀있거나 사용할 수 없는 상태일 수 있다 가정하고, 사용 가능 여부를 체크한다. (ex ping())

물론 매번 ping 을 날려 확인하는 방식은 비효율적이므로 내부적으로 커넥션의 age를 확인해 체크 여부를 결정한다.

pool base.py를 살펴보면 커넥션이 얼마나 오래됐는지 (fresh) 확인 후 pre_ping 요청을 날리는 것을 확인할 수 있다.

pre_ping

두 번째는 Optimistic 방식이다.

이 방식은 적극적으로 커넥션의 유효성을 확인하는 대신 연결 도중 에러가 발생하면 해당 커넥션을 풀에서 제거하고, 새로운 커넥션을 가져오는 방식이다.

별도의 유효성 체크를 하지 않으므로 pessimistic 방식보다 더 효율적이지만 짧은 wait_timeout 설정을 사용하는 경우 꽤 자주 닫힌 커넥션을 마주할 수 있다.

그래서 추가적으로 pool_recycle 파라미터를 사용하여 짧은 wait_timeout에 대처할 수 있게 셋팅해줘야 한다.
(커넥션의 최대 수명 즉 max-age를 셋팅하여 커넥션을 해당 시간 이상 지속될 수 없게 한다.)

recycle

다른 드라이버들도 차이는 있지만 SQLAlchemy와 비슷한 방식을 사용한다.

Tomcat JDBC Connection Pool 문서를 살펴보면,

testOnBorrow, validationQuery 옵션을 통해 pessimistic 방식을 지원한다. (커넥션을 가져오기 전에 ping을 날려 커넥션을 확인)

jdbc-options

한 가지 차이점은 커넥션을 가져올 때 (checkout) age 체크를 하는 SQLAlchemy와는 달리 Tomcat JDBC Connection Pool은 idle 커넥션을 정리하는 별도의 Thread를 실행시켜 주기적으로 정리함을 알 수 있다. (minEvictableIdleTimeMillis 값이 기본 60초로 설정. 즉 60초 이상 idle 상태가 지속되면 정리 대상이 됨)

Mysql2에서 대처하기

  1. enableKeepAlive, keepAliveInitialDelay

위 옵션을 사용하면 드라이버에서 연결을 맺기 위해 사용하는 TCP 스트림에 keep-alive를 설정할 수 있다.

keepAliveInitialDelay 값을 mysql 서버의 wait_timeout 값보다 적은 값으로 설정하면 wait_timeout이 트리거되기 전에 타임아웃을 갱신할 수 있다.

  1. maxIdle, idleTimeout

maxIdle 값은 기본으로 connectionLimit과 동일한 10으로 설정되어 있는데 그 경우 idle 커넥션을 재갱신하는 루프가 돌지 않는다.

그러므로 maxIdle 값을 더 작은 값으로 설정하여 idleTimeout에 설정된 시간만큼 사용되지 않은 커넥션을 갱신될 수 있게 한다.

대안

typeorm 문서에 적혀있지는 않지만 mariadb 커넥터mysql 혹은 mysql2 드라이버 대신 사용할 수 있다.

mariadb 드라이버는 Tomcat JDBC나 SQLAlchemy처럼 pessimistic 방식의 validation을 지원한다. (minDelayValidation)

pessimistic approach

또 별도의 reaper 함수를 통해 주기적인 idle 커넥션 제거도 수행한다.

reaper

여기에 MySQL 8+의 caching_sha2_password 방식도 지원하므로 MySQL 8버전 이상을 사용하고 있다면 가장 매력적인 옵션이다.

(기타) Aurora Mysql 디비 사용 시 권장사항

Aurora Mysql DBA 핸드북

  1. 커넥션을 주기적으로 검증/확인하라
  2. Pool에 커넥션이 너무 오래 남게하지 마라

위 2가지를 권장하고 있다.

• Check and validate connections periodically even when they're not
borrowed. It helps detect and clean up broken or unhealthy connections
before an application thread attempts to use them.

• Don't let connections remain in the pool indefinitely. Recycle
connections by closing and reopening them periodically (for example,
every 15 minutes), which frees the resources associated with these
connections. It also helps prevent dangerous situations such as runaway
queries or zombie connections that clients have abandoned. This
recommendation applies to all connections, not just idle ones.

여기에 추가적으로 작은 디비 인스턴스를 사용할 경우 사용할 수 있는 max_connection 수에 제한이 있어 짧은 wait_timeout 셋팅이 거의 강제된다.

이 때 위에 살펴본것처럼 커넥션 풀을 주기적으로 순환시켜주는 설정을 따로 하지 않을 경우 수많은 쿼리 에러를 마주칠 수 있다.

참고

profile
다양한 것들을 시도합니다
post-custom-banner

0개의 댓글