솔루션 형태로 납품되는 사내 플랫폼 BE 가 캐시로 Valkey(Redis OSS fork)를 쓴다. 그동안은 VM 1대에 단일 Valkey 가 떠 있었고, 이게 한 대 죽으면 BE 전체가 뜨겁게 식는 구조였다. 납품 사이트엔 DBA 가 없을 거고, K8s 위에 같이 올라가는 플랫폼이니 캐시도 Pod 로 올리되 Sentinel HA 로 짜자 - 이게 출발이었다. 이 글은 그 마이그레이션을 왜 Sentinel 로 골랐고, 어떻게 짰고, 어떤 검증을 거쳤고, 운영 적용 후 어떤 회귀에 부딪혔고, 결과적으로 무엇이 달라졌는지 짧게 정리한 기록이다.
Intro
- 단일 VM Valkey → K8s 위 StatefulSet(data 3) + Deployment(Sentinel 3) Helm chart 로 마이그레이션. 사내 플랫폼 alpha 환경에 적용 완료.
- Sentinel 을 고른 이유: 캐시 워크로드가 샤딩이 필요한 양이 아니었다. Cluster 의 16384 슬롯은 운영 부담만 늘리고 얻는 게 없었다. "단일 master 견고화" 가 진짜 요구사항이었고 거기엔 Sentinel 이 fit.
- 데이터와 Sentinel 을 다른 워크로드 리소스로 분리한 이유: data 는 stable identity (PVC + ordinal), Sentinel 은 무상태 quorum 멤버. 한 Pod 에 묶으면 한쪽 장애가 다른 쪽으로 전파된다.
- 부하테스트 + 5종 failover 시나리오 (master pod kill / master 노드 drain / Sentinel 과반 손실 / helm rolling 중 절체 / network partition) 를 alpha 에서 모두 통과시킨 뒤 운영 적용.
- 운영 적용 직후 두 건의 회귀가 잡혔다. (1) 단순 PING readiness 가 split-brain 을 ready 처리해 분리된 master 가 service endpoint 에 노출, (2) postStart hook 의 valkey-cli 가 graceful-shutdown 중인 master 에 무한 대기 → 8 분+ stuck. 둘 다 readiness 강화 + hard timeout 으로 fail-fast 화.
- 결과: 단일장애점 제거 + helm 한 줄로 재현 가능한 캐시 인프라 + Prometheus 로 6 인스턴스 동시 가시화. DBA 없는 사이트로 납품 가능한 형태가 됐다.
시점 컨텍스트 - 본문에 등장하는 환경은 alpha 한 곳이다. beta · prod 는 진입 준비 중이고, 본 chart 는 alpha 검증을 마친 뒤 다른 환경에 그대로 끌고 갈 계획이다. "운영 적용 후 회귀" 라는 표현도 alpha 환경에서의 검증·운영 단계 이야기로 읽으면 된다.
1. 출발점 - 단일 VM Valkey 의 단일장애점

기존 구성은 단순했다 - VM 한 대, Valkey 한 프로세스, BE 가 직접 host:port 로 박는다. 솔루션을 새 사이트에 깔 때마다 그 VM 한 대가 단일장애점이 됐다. 사이트엔 DBA 가 없을 가능성이 높고, OS · Valkey 패치 · 백업 · 절체 시 수동 개입까지 전부 우리 운영팀이 들여다봐야 했다.
플랫폼 자체는 어차피 K8s 위에 올라가는데 캐시만 VM 에 뽑아두는 게 점점 어색해졌다. K8s 위에 캐시를 올리되, 단일 Pod 가 아니라 HA 로 - 이게 명확해진 다음 글로 옮겨갔다.
2. 왜 Cluster 가 아니라 Sentinel 인가
K8s 위 Valkey HA 옵션은 사실상 두 가지다. Sentinel 이냐 Cluster 냐. 비교를 해봤다.
| 축 | Sentinel | Cluster |
|---|---|---|
| 용도 | 단일 master 견고화 (자동 failover) | 데이터 샤딩 + HA |
| 토폴로지 | data 3 + sentinel 3 (or quorum) | 최소 6 노드 (master 3 + replica 3) |
| 클라이언트 | Sentinel 프로토콜 인지 (Lettuce/Jedis/Redisson 모두 지원) | Cluster 프로토콜 (MOVED/ASK 처리, 멀티키 제약) |
| 운영 부담 | 슬롯/리샤딩 개념 없음 | 16384 슬롯 + 리샤딩 운영 |
| 멀티키 트랜잭션 | 자유 | 같은 슬롯(hash tag) 으로 강제 |
내 워크로드는 샤딩이 필요한 데이터량이 아니었다. 캐시 + 분산락 + 짧은 stream 몇 개. Cluster 를 깔면 16384 슬롯 운영, 멀티키 hash tag 제약, 클라이언트 코드 변경까지 따라온다. 얻는 건 거의 없고 부담만 따라온다.
진짜 요구사항은 "단일 master 가 죽었을 때 자동으로 다른 노드가 master 가 되는 것" 한 줄이었고, 거기엔 Sentinel 이 정확히 fit 했다. Redisson 도 Lettuce 도 Sentinel 모드를 1급으로 지원하니 BE 코드는 한 줄도 안 바꿔도 되는 점도 컸다 (커넥션 string 만 sentinel:// 로 바꾸면 끝).
한 가지 추가 - 향후 데이터량이 정말 폭증해서 샤딩이 필요해지면 그때 Cluster 로 가도 늦지 않다. 지금 Cluster 로 선제 대응하는 건 YAGNI 의 정석.
3. 구조 - 두 가지 워크로드 리소스로 분리
chart 의 핵심 결정은 단순하다.

3.1 왜 data 는 StatefulSet, sentinel 은 Deployment 인가
- data 는 영속 상태가 있다. Pod 별 PVC, ordinal 기반 안정 hostname (
valkey-0/1/2), 부트스트랩 시valkey-0을 초기 master 로 삼는 컨벤션. 전부 StatefulSet 의 1급 기능. - sentinel 은 무상태 quorum 멤버다. PVC 도 ordinal 도 필요 없다. Deployment 가 자연스럽고, 굴리기도 가볍다.
같은 Pod 에 묶을 수도 있었다. Bitnami chart 가 한때 그랬다. 하지만 한쪽 장애가 다른 쪽으로 전파된다. data 컨테이너 OOM 으로 Pod 가 재시작되면 그 노드의 Sentinel 도 같이 사라져 quorum 이 흔들린다. 장애 도메인을 분리하는 게 Sentinel 의 의도와 맞다.
3.2 AntiAffinity + PDB + 롤링 strategy 의 조합
세 개를 같이 안 박으면 운영 중에 반드시 깨진다.
| 설정 | 의도 | 빠뜨리면 |
|---|---|---|
requiredDuringScheduling podAntiAffinity |
data 3 / sentinel 3 가 서로 다른 노드에 분산 | 한 노드 죽으면 quorum 동시 손실 |
PodDisruptionBudget(maxUnavailable=1) |
자발적 disruption (drain, upgrade) 시 한 번에 한 대만 빠짐 | 노드 drain 이 quorum 깨뜨리고 진행 |
RollingUpdate(maxSurge=0, maxUnavailable=1) |
required AntiAffinity + replicas=3 환경에서 신규 pod 가 Pending 으로 영원히 멈추는 회귀 차단 | helm upgrade 가 영영 안 끝남 |
특히 마지막 maxSurge=0 은 함정이다. 기본값 maxSurge=25% (= 1 추가) 로 두면, AntiAffinity 가 required 인 환경에서 4 번째 pod 가 들어갈 노드가 없어 영원히 Pending 으로 멈춘다. 이걸 sidecar 활성화 commit 같은 timing 에 한 번 깔끔하게 잡고 갔다.
3.3 부트스트랩 / failover 후 토폴로지 일관성
초기에 짠 init-replica.sh 는 단순했다. ordinal=0 이면 master, 그 외엔 valkey-0 에 REPLICAOF. 이게 failover 후엔 깨진다. master 가 valkey-1 로 옮겨간 상태에서 valkey-0 이 재기동되면 자기를 master 로 가정하고 split-brain 발생.
수정한 정책:
SENTINEL get-master-addr-by-name mymaster로 현재 인지 master 조회- sentinel 응답 있음:
- 본인 = master → 작업 없음
- 그 외 → 인지 master 에 REPLICAOF
- sentinel 응답 없음 (최초 부트스트랩) → ordinal=0 은 master, 그 외엔
valkey-0에 REPLICAOF (fallback)
Sentinel 이 진실의 원천, ordinal 은 부트스트랩 fallback 일 뿐 — 이 한 줄이 토폴로지 일관성의 핵심.
3.4 메트릭 - 한 chart 에서 두 평면 동시 노출
oliver006/redis_exporter 사이드카를 data Pod 와 sentinel Pod 양쪽에 박았다. 같은 이미지지만 노출되는 메트릭이 다르다.
- data sidecar (:9121) →
redis_up,redis_memory_*,redis_master_link_up,redis_stream_*,redis_key_group_*... - sentinel sidecar (:9355) →
redis_sentinel_master_status,redis_sentinel_master_ok_sentinels...
한쪽만 박으면 토폴로지 quorum 또는 분산락/stream 둘 중 하나가 메트릭에 안 잡힌다. 운영 환경의 Prometheus 가 Operator 미사용 vanilla 이라 ServiceMonitor 가 안 먹혀, prometheus.io/scrape Pod annotation 기반 디스커버리로 통일했다. 이 메트릭 평면 설계는 별도 학습 노트로 따로 정리해둠.
4. 검증 - 부하테스트 + failover 시나리오 5종
운영 적용 전에 alpha 에서 다음 시나리오들을 모두 돌렸다. chart 만 짜고 끝내면 진짜 문제는 운영에서 만난다.
| 시나리오 | 기대 동작 | 측정한 것 |
|---|---|---|
| master pod 강제 kill | Sentinel quorum (2/3) 으로 failover, 새 master 승격, BE 재연결 | failover 시간, BE 측 에러 윈도우 |
| master 노드 drain | PDB 가 한 번에 한 대만 빼고, AntiAffinity 가 다른 노드 보장 | drain 동안 service 가용성 |
| Sentinel 2 대 동시 kill (quorum 임계) | 1 대 남은 sentinel 은 단독 판단 불가 → 새 failover 트리거 안 됨 (정상) | quorum 메트릭이 1 로 떨어지는지, 알람 firing |
| helm rolling upgrade 도중 master 절체 | RollingUpdate strategy + PDB 로 한 번에 한 대만 교체, 토폴로지 보존 | 새 pod readiness 통과 시점, 분리된 master 미발생 |
| network partition (sentinel ↔ data 일시 단절) | partition 동안 sentinel 이 sdown 인지, 복구 시 자동 재합류 | sdown → odown 전이, link 복구 시간 |
부하는 redis-benchmark + 실제 BE 트래픽 mirror 로 SET/GET/락 획득 패턴을 같이 돌렸다. BE 측 클라이언트(Redisson/Lettuce) 가 Sentinel 인지 master 변경에 자동 재연결하는지가 핵심 확인점. 결과는 Lettuce 의 RedisClient.connectSentinel(...) + MasterReplica 패턴이 평균 10 초 이내에 새 master 로 reconnect.
5. 운영 적용 후 부딪힌 두 회귀 - readiness 와 hook timeout
검증을 그렇게 돌리고도, 운영 적용 첫 주에 두 건이 잡혔다. 둘 다 검증 시나리오엔 안 들어 있던 상태에서 발생해서 이번 작업에서 가장 진하게 배운 부분이다.
5.1 단순 PING readiness 가 split-brain 을 ready 처리
기존 readiness 는 한 줄짜리였다 - valkey-cli PING | grep PONG. 그런데 helm rolling 도중 이런 흐름이 발생했다:

helm --wait 도 정상 success 로 처리해서 사람이 인지조차 못 한다. 이 회귀를 잡고 readiness 자체를 다시 짰다.
핵심 변경:
init-replica.sh정상 종료 시/var/run/valkey/replica-init-done마커 파일 생성 (emptyDir 라 컨테이너 재기동 시 자동 초기화 → stale marker 없음)- 신규
readiness.sh가 다음을 모두 통과해야 ready:- 마커 파일 존재 (= postStart 정상 종료)
- PING 응답
- ROLE 확정 → replica 면
master_link_status:up, master 면 sentinel 인지 master = 본인 일치 검증 - sentinel 응답이 IP/FQDN 어느 쪽으로 와도 본인을 식별하는 듀얼 매치
핵심 교훈은 한 줄이다. readiness 는 "프로세스 살아있나" 가 아니라 "토폴로지 안에서 정합성 있게 살아있나" 까지 봐야 한다. 알람이 울리기 전에 K8s 가 fail-fast 로 막아주려면, 모니터링 알람의 invariant 와 readiness 의 invariant 가 같아야 한다.
5.2 postStart hook 안의 valkey-cli 가 8 분+ stuck
같은 시기 또 한 건. postStart hook 의 init-replica.sh 가 graceful-shutdown 중인 master 에 SENTINEL get-master-addr / PING / REPLICAOF 를 보냈는데, 응답이 안 와서 hook 자체가 8 분 넘게 stuck. K8s 입장에선 hook 진행 중이라 not-ready 도 아닌 어정쩡한 상태로 helm 이 진행 중처럼 보였다.
대응은 단순하지만 효과는 크다 — 모든 valkey-cli 호출을 coreutils timeout 으로 wrap.
timeout 5 valkey-cli ... SENTINEL get-master-addr-by-name mymaster
timeout 3 valkey-cli ... PING
timeout 10 valkey-cli ... REPLICAOF "$MASTER" 6379
set -e 와 결합되어 stuck 대신 fail-fast → readiness fail → helm timeout → 사람 개입 트리거.
교훈: hook/probe 안의 외부 호출은 무조건 hard timeout. graceful-shutdown / split-brain 같은 비정상 토폴로지에서 무한 대기는 흔하다. shell timeout 한 번 빠지면 K8s 의 self-healing 메커니즘 자체가 동작을 멈춘다.
두 회귀 모두 검증 시나리오 5종에 직접 포함되지 않았다. "검증을 다 통과했는데 운영에서 터졌다" 가 아니라, 검증 시나리오 자체가 split-brain 발생 직전의 readiness 동작이나 hook 의 stuck 가능성을 명시적으로 측정하지 않았기 때문. 다음 마이그레이션 때 이 두 가지는 검증 체크리스트의 1급 항목으로 박아둬야겠다고 정리.
6. 결과 - 무엇이 달라졌나
| 축 | Before (VM 단일) | After (K8s Sentinel HA) |
|---|---|---|
| 단일장애점 | Valkey VM 1 대 = SPOF | data 3 / sentinel 3 분산, AntiAffinity 로 노드 분리 |
| 절체 | 수동 (사람이 개입) | Sentinel quorum 자동 failover, BE 측 약 10 초 내 재연결 |
| 재현성 | VM 셋업 가이드 (수동) | helm install 한 줄, values 만 환경별로 분리 |
| 가시성 | 호스트 metric 일부 | 6 인스턴스 (data 3 + sentinel 3) 동시 Prometheus 노출, Grafana 단일 대시보드 |
| 납품 적합성 | DBA 필수 | DBA 없는 사이트에도 동일 chart 로 배포 가능 |
| 운영 정합성 | 단순 PING readiness | readiness 가 토폴로지 invariant 까지 검증, hook 은 hard timeout 으로 fail-fast |
가장 큰 변화는 "솔루션을 깔러 갈 때 들고 갈 수 있는 형태가 됐다" 는 점이다. 사이트마다 VM 셋업하고 Valkey 깔고 백업 잡 짜는 게 아니라, K8s 클러스터만 있으면 chart 한 번으로 캐시 인프라가 선다. 그 위에 우리 BE 가 그대로 올라간다.
7. 마무리 - 다음에 같은 마이그레이션을 할 때
이번 작업이 남긴 진짜 자산은 chart 자체보다 "단일 자원을 K8s HA 로 옮길 때의 디폴트 사고 흐름" 이었다.
- 요구사항을 좁힌다 : "HA 화 한다" 가 아니라 "단일장애점 하나를 죽인다" 같은 한 줄로. 그래야 Sentinel/Cluster 같은 선택지가 자동으로 좁혀진다.
- 장애 도메인을 분리한다 : data 와 quorum 은 같은 Pod 에 두지 않는다. 한쪽 장애가 다른 쪽으로 전파되면 HA 의 가정이 깨진다.
- AntiAffinity / PDB / 롤링 strategy 는 묶음 단위로 셋팅한다 : 셋 중 하나만 빠져도 운영 중 한 번은 반드시 깨진다.
- readiness 를 모니터링과 같은 invariant 로 쓴다 : 알람이 울리기 전에 K8s 가 막아주는 게 사후 알람보다 항상 낫다.
- hook/probe 의 외부 호출엔 무조건 hard timeout : self-healing 의 전제 조건.
다음에 같은 패턴 (VM 단일 자원 → K8s HA) 마이그레이션을 할 일이 또 생기면, 위 다섯 줄을 그대로 펴놓고 시작하면 된다.
'Redis' 카테고리의 다른 글
| 지표 하나에서 시작된 Valkey Sentinel 구조 개선기 (1) | 2026.06.03 |
|---|---|
| Redis는 왜 빠른가 - "인메모리 + 싱글 스레드"는 절반짜리 정답이다 (1) | 2026.04.19 |