Redis Sentinel에서 Cluster로 옮기다가 오후 4시간을 날린 이야기
시작은 조용했다
지난주 화요일 오후, 팀 내부 논의 끝에 오래 미뤄뒀던 Redis Sentinel → Cluster 마이그레이션을 시작했다. 트래픽이 완만한 시간대(오후 2시)를 골랐고, 사전 리허설도 두 번 돌렸고, 롤백 스크립트도 미리 준비했다. 그런데 4시간 뒤 나는 팀 채널에 "미안, 롤백한다" 를 치고 있었다.
문제는 Redis도, Cluster 노드도, 네트워크도 아니었다. 클라이언트 라이브러리였다. 정확히는 "우리가 Cluster를 이해한다고 착각하는" 클라이언트 코드들.
우리 서비스는 세션 캐시 + rate limit 카운터 + 소규모 pub/sub 세 가지 용도로 Redis를 쓴다. Sentinel 구성으로 5년쯤 굴렸는데, 최근에 세션 데이터가 20GB를 넘기면서 단일 마스터가 아슬아슬해졌다. 그래서 3 master + 3 replica 클러스터로 옮기기로 했다.
앱 서버는 8개 서비스, 각각 다른 언어 스택이었다. Node.js 서비스 3개는 ioredis, Python 서비스 2개는 redis-py, Go 서비스 2개는 go-redis/v9, 그리고 Java 하나는 Lettuce. 리허설 때는 스테이징에서 다 잘 됐다. Cluster 모드 붙이고, 몇 개 키 넣고 빼고, 노드 장애 시뮬레이션도 통과.
프로덕션 배포 순서는 이랬다. 먼저 Cluster를 띄운다. 데이터를 redis-cli --cluster import 로 옮긴다. 앱들을 하나씩 Cluster 연결로 롤링 배포한다. Sentinel은 마지막에 내린다.
첫 15분: Node.js 서비스가 이상하다
Node.js 세션 서비스 3대를 먼저 배포했다. 헬스체크는 다 초록불. 그런데 에러 대시보드가 조금씩 붉어졌다. 초당 몇 건, 그러다 몇 십 건. 로그를 봤더니:
MOVED 5798 10.4.12.3:6379
MOVED 리다이렉트가 raw로 노출되어 있었다. 어? ioredis는 Cluster 모드에서 이걸 자동으로 처리해줘야 한다. 코드를 급하게 확인했다.
const redis = new Redis.Cluster([
{ host: 'redis-cluster.internal', port: 6379 }
]);
Cluster 클라이언트로 보였다. 그런데 왜? 5분쯤 파다가 발견했다. 우리 서비스는 5년 전 Sentinel 코드에서 Cluster로 넘어오면서, 어떤 시점에 누군가 Redis.Cluster 를 감싼 헬퍼 함수를 만들었다. 그리고 그 헬퍼는 특정 명령을 재시도할 때 raw 클라이언트를 꺼내서 직접 호출하고 있었다. client.sendCommand() 로. 이 경로에는 리다이렉트 처리가 없었다.
솔직히 이걸 리허설에서 잡지 못한 게 뼈아프다. 스테이징에서는 슬롯이 재배치될 일이 없어서 MOVED가 거의 안 뜬다. 리허설의 함정이다.
30분: Python 서비스에서 pipeline이 폭발
Node.js를 임시 롤백하고 Python으로 넘어갔다. redis-py는 신뢰가 높은 라이브러리라 큰 걱정을 안 했다. 그런데 rate limit 로직에서 파이프라인이 죽기 시작했다.
CROSSSLOT Keys in request don't hash to the same slot
이건 예상했어야 했다. Cluster는 파이프라인 안의 모든 키가 같은 슬롯에 있어야 한다. 우리 rate limit 코드는 user:{id}:count, user:{id}:window, global:rate 세 키를 한 파이프라인에 담고 있었다. global:rate 가 다른 슬롯이니 CROSSSLOT.
해시태그를 쓰면 된다는 걸 안다. {user:id}:count 처럼 중괄호로 감싼 부분만 슬롯 계산에 쓴다. 그런데 global:rate 는? 논리적으로 이 키는 전역 카운터라 사용자별 슬롯에 넣을 수도 없다.
여기서 5년 전에 짜여진 rate limit 알고리즘 자체를 뜯어봐야 했다. 팀에서 이 코드를 원저자가 이미 퇴사해서 아무도 정확히 몰랐다. 급한대로 global:rate 를 파이프라인에서 빼고 별도 호출로 돌리는 hotfix 를 넣었다. 성능은 미세하게 나빠지지만 일단 뚫어야 했다.
90분: Go 서비스에서 갑자기 커넥션이 폭증
Node.js, Python 을 정리하고 Go 서비스를 배포했다. go-redis/v9 는 잘 만들어진 라이브러리라 별 문제 없을 줄 알았다. 그런데 Cluster 노드 하나의 커넥션 수가 30초 만에 3000개를 찍었다. 다른 두 노드는 200개 수준.
원인은 Pub/Sub 였다. go-redis 의 Cluster 클라이언트에서 Subscribe() 를 호출하면, 채널명의 슬롯이 있는 노드에 붙는다. 우리는 채널명을 events 라는 고정 문자열로 썼기 때문에 모든 인스턴스가 같은 노드에 붙는다. Sentinel 시절에는 마스터 하나뿐이라 이게 문제가 안 됐는데, Cluster 로 오면서 특정 노드에만 부하가 쏠렸다.
거기다 하필 Go 서비스 하나가 워커 풀에서 연결을 재사용하지 않고 이벤트마다 새 subscriber 를 만들고 있었다. 왜 그렇게 짰는지는 코드 커멘트에 "TODO: refactor" 라고만 적혀있었다. Sentinel 시절에는 커넥션 수가 몇 백을 안 넘어서 문제가 안 드러났던 것.
이 시점에 오후 4시. 커넥션 파일 디스크립터가 노드 하나에서 한계에 근접했고, 다른 서비스가 그 노드에 못 붙기 시작했다. 나는 잠깐 마우스에서 손을 뗐다.
롤백 결정
살릴 수 있는 방향은 두 개였다.
첫째, Go 서비스를 급하게 고쳐서 Pub/Sub 만 별도 Sentinel Redis(레거시 그대로 두고) 로 뺀다. 30분이면 가능하지만, 새로운 아키텍처가 임시로 두 개의 Redis 시스템을 갖게 된다.
둘째, 통째로 롤백. 데이터는 아직 Sentinel 에 그대로 남아있고, 앱들만 커넥션 스트링을 다시 돌리면 된다.
팀 내부에서 5분 논의 끝에 롤백을 택했다. 이유는 하나다. Pub/Sub 채널 설계, 파이프라인 슬롯 문제, ioredis 헬퍼 코드 이슈 — 이 세 가지는 앞으로도 계속 나올 수 있는 종류의 문제고, 지금 임시방편으로 넘기면 다음 주에 또 다른 형태로 튀어나올 게 뻔했다.
오후 6시, 롤백 완료. 데이터 유실 없음. 마이그레이션은 실패했다.
남은 것
며칠 지나서 팀에서 정리한 체크리스트가 있다. 두 가지만 옮긴다.
첫째, 마이그레이션 전에 코드베이스에서 raw Redis 명령을 부르는 지점을 다 찾아야 한다. grep 으로는 부족하다. 헬퍼 함수 몇 겹 뒤에 숨어있는 경우가 많다. 우리는 이번에 각 서비스마다 "Redis 호출 감사" 를 먼저 하고, 모든 명령이 Cluster 인식 경로를 통하는지 확인하기로 했다.
둘째, 리허설 환경에서 슬롯 재배치를 강제로 발생시켜야 한다. Cluster는 정상 상태일 때는 MOVED가 거의 없고, ASK는 더더욱 없다. redis-cli --cluster reshard 로 슬롯을 옮기면서 부하 테스트를 돌려봐야 프로덕션과 비슷한 조건이 된다. 우리는 다음 리허설에서 이 단계를 추가한다.
파이프라인/트랜잭션에 여러 키가 있는 지점도 다 찾아서 해시태그 설계를 다시 해야 한다. 여기까지 오면 마이그레이션은 인프라 작업이 아니라 코드 리팩토링 프로젝트가 된다. 애초에 그렇게 접근했어야 한다.
다음 마이그레이션은 3주 뒤로 잡았다. 이번에는 실패해도 배운 게 남았으니까, 완전 손해는 아니다 — 라고 억지로 위로해본다. 혹시 비슷한 삽질 하신 분 계시면 어떻게 정리하셨는지 궁금하다.