"outbox stream 하나 추가됐으니 모니터링에 잡히게만 해줘." 이 한 줄에서 시작했다. exporter가 보는 valkey stream 목록에 세 개를 더 넣고 helm upgrade 를 누르면 끝나는 줄 알았는데, 그 한 줄이 data 노드 전체를 rolling restart 시키고 master 재생성 시 failover를 1회 동반했다. 즉 모니터링 전용 변경이 데이터 토폴로지를 흔드는 구조였다. 그리고 그렇게 pod가 재기동될 때마다 Sentinel 안에는 죽은 IP가 ghost로 영구히 쌓이고 있었다. 이 글은 "지표 하나 더 보겠다"는 요청을 따라 들어가다 만난 세 개의 구조적 이슈(ghost 누적 / 모니터링과 데이터의 결합 / sentinel 초기 master 하드코딩)를 announce-ip · StatefulSet 전환 · exporter 분리라는 세 갈래로 끊어낸 기록이다.
본 글은 사내 솔루션에서 사용되는 단일 VM Valkey to K8s Sentinel HA 마이그레이션에 이어 구조개선에 대한 글이다. 이전 글은 아래와 같다.
단일 VM Valkey 를 K8s Sentinel HA 로 옮긴 기록
솔루션 형태로 납품되는 사내 플랫폼 BE 가 캐시로 Valkey(Redis OSS fork)를 쓴다. 그동안은 VM 1대에 단일 Valkey 가 떠 있었고, 이게 한 대 죽으면 BE 전체가 뜨겁게 식는 구조였다. 납품 사이트엔 DBA 가
ramos-log.tistory.com
Intro
- 발단은 사소했다. 앱의 outbox stream 키 정의(7개)와 chart values의 exporter 감시 목록(4개)이 어긋나 있어, 신규 stream 3종이 지표에 안 잡혔다. 그걸 맞추려고 목록을 고치자 data pod 전체가 rolling restart 됐다.
- 원인은 stream 목록이 data 사이드카 exporter의 args(pod template) 였다는 점. 목록을 바꾸면 pod template이 바뀌고, 그래서 data가 통째로 재기동되며 master 재생성 시 failover가 1회 따라왔다. 모니터링 변경이 데이터 평면을 흔드는 결합이 핵심 문제였다.
- pod가 재기동될 때마다 IP가 바뀌고, Sentinel은 죽은 옛 IP를
s_down엔트리로 영구 보관한다. 이게 ghost다. replica ghost는 클라이언트 부팅 지연(실측: Redisson 초기화 15초)을, sentinel peer ghost는num-other-sentinels분모를 부풀려 failover 인가 majority 마진 저하(가용성 영향)를 만든다. - 세 갈래로 끊었다. (A) data pod가 안정 FQDN을
replica-announce-ip로 announce → replica ghost 미발생. (B) sentinel을 Deployment → StatefulSet으로 전환(announce-ipFQDN) + 부팅 시 실제 master를 자동 발견하는 discovery init → peer ghost 미발생 + 자가치유. (C) stream 점검을 별도 standalone Deployment로 분리 → 목록 변경 시 data 노드 무중단. - alpha · beta · prod values 전 환경에 A+B+C 적용·검증 완료. chart 기본값은 하위호환 위해 Deployment/false로 두고, values에서만 켰다. BE 접속정보(headless Service 이름)는 변경 불필요가 실측으로 확인됐다.
출발점: "stream 하나 더 보이게" 가 data를 재기동시켰다
발단은 정말 작은 요청이었다. 앱(BE)이 outbox 패턴으로 쓰는 Redis stream이 늘었으니, 모니터링에서도 그 stream의 길이가 보이게 해달라는 것.
들여다보니 어긋남이 먼저 눈에 들어왔다. 앱의 stream 키 정의 파일에는 stream이 7개인데, chart의 values-*.yaml 의 metrics.data.streams 에는 4개만 적혀 있었다. 신규 stream 3종(member:delete, monitoring:collector-connected, monitoring:rule-sync-requested)은 앱이 런타임에 XADD 로 자동 생성해 valkey엔 이미 존재했지만, exporter가 그 목록을 모르니 지표로는 안 나왔다. drift였다.
여기까진 단순하다. values에 3개를 더 적고 helm upgrade 하면 끝. 그런데 그 순간 data pod 3대가 전부 rolling restart에 들어갔다.
이유는 stream 목록이 들어가 있던 위치였다. 그 목록은 data StatefulSet에 붙은 사이드카 exporter의 실행 args, 즉 pod template의 일부였다. pod template이 바뀌면 StatefulSet은 pod를 새로 굴린다. 그리고 그 과정에서 master가 재생성될 때 Sentinel이 failover를 1회 일으킨다.

정리하면 이렇다. 순수하게 모니터링 목적인 변경(어떤 stream을 볼 것인가)이 데이터 토폴로지(누가 master인가, replica IP가 무엇인가)를 흔들고 있었다. 관측을 늘리려다 가용성을 건드리는 셈이다. 그리고 이 재기동이 반복될수록 Sentinel 안에는 다른 문제가 조용히 쌓이고 있었다.
따라 들어가니 이슈가 셋이었다
요청 하나를 따라 들어갔더니 서로 엮인 구조적 이슈 셋이 나왔다.
outbox stream drift
위에서 본 그대로다. 앱의 stream 키 정의가 진실의 원천인데 chart values가 수동으로 따라가는 구조라, 둘이 어긋나면 신규 stream이 관측 사각지대로 빠진다. 사람이 두 곳을 동기화해야 하는 한 drift는 반복된다.
모니터링 변경이 data 재기동을 유발
stream 목록이 data 사이드카의 args인 한, "무엇을 볼지" 를 바꾸는 일이 "데이터 노드를 재기동" 시킨다. 모니터링과 데이터 평면이 한 pod template에 묶여 있는 결합(coupling) 이 근본 원인이다.
Sentinel ghost 누적
pod가 재기동되며 IP가 바뀌면, Sentinel은 옛 IP를 메모리에서 지우지 않고 s_down flag만 붙여 영구 보관한다. 이걸 ghost라고 부른다. 그런데 ghost는 한 종류가 아니고, 처방이 다르다.
- replica ghost: 죽은 data pod의 옛 IP가 slave 목록에 잔존한다. 클라이언트가 부팅하며 Sentinel에게 slave 목록을 받으면 그 죽은 IP에도 TCP connect를 시도하고,
down-after-milliseconds만큼(실측 10초) timeout을 먹는다. 실제 인시던트(2026-05-14)에서 Redisson 초기화가elapsedMs=15107, 즉 15초까지 늘어진 원인이 이거였다. - sentinel peer ghost: 죽은 sentinel pod의 옛 IP/runid가 peer 목록에 잔존한다. 이건 cosmetic이 아니다.
num-other-sentinels분모를 부풀려 failover 인가에 필요한 majority 마진을 깎는다. 실측으로 정상 3대 중 1개가 ghost로 남으면 두 sentinel이 전체를 4로 착각하고, 그 상태에서 1대만 더 죽어도 majority에 미달해 failover를 못 한다. 가용성 영향이다.
ghost가 쌓이면 그동안은 각 sentinel에서 SENTINEL RESET mymaster 를 순차 실행해 손으로 정리했다. quorum 무중단으로 정리되긴 하지만, 재기동이 일어날 때마다 사람이 청소해야 하는 구조라는 게 문제였다.
AS-IS / TO-BE
세 이슈를 한 표로 모으면 무엇을 바꿔야 하는지가 분명해진다.
| 구분 | AS-IS | TO-BE |
|---|---|---|
| stream 추가 반영 | values 기재 + helm upgrade → data pod rolling restart + failover 1회 |
stream 점검을 별도 valkey-data-metrics Deployment로 분리 → 목록 변경 시 data 노드 무중단 |
| replica ghost | pod 재기동 시 옛 IP가 s_down 으로 누적 → 수동 SENTINEL RESET 필요 |
data pod가 안정 FQDN을 replica-announce-ip 로 announce → 재기동해도 ghost 미발생 |
| sentinel peer ghost | sentinel=Deployment, 재기동마다 IP/myid 변경 → peer ghost 누적, majority 마진 저하 | sentinel=StatefulSet + announce-ip(FQDN) → peer ghost 미발생 |
| sentinel 부팅 시 master 인지 | sentinel monitor 가 초기 master를 하드코딩 → 전 sentinel 동시 재기동 시 실제 master 못 찾고 failover 루프 |
부팅 시 master discovery init이 실제 master(FQDN)를 자동 발견 후 monitor 교체 → 자가치유 |
| 모니터링↔데이터 결합 | exporter 사이드카가 data StatefulSet에 결합 | stream/key-group은 standalone, data 사이드카는 instance/replication 지표만 |
| BE 접속정보 | (영향 우려) | headless Service valkey-sentinel:26379 이름 불변 → 변경 불필요 |
구조 (TO-BE)
세 갈래(A/B/C)를 하나의 그림으로.

- A: 모든 data pod가
replica-announce-ip=<안정 FQDN>을 announce → Sentinel이 FQDN으로 replica를 등록한다(replica ghost 차단). - B: sentinel을 StatefulSet으로 전환 +
announce-ip(FQDN) + master discovery init(peer ghost 차단 + 부팅 자가치유). - C: stream/key-group 점검을
valkey-data-metrics(+valkey-dataClusterIP)로 분리(모니터링 변경 무중단).
A: replica ghost를 IP가 아닌 FQDN으로 끊다
ghost가 쌓이는 메커니즘의 뿌리는 단순하다. Sentinel은 master로부터 INFO replication 을 받아 replica를 등록하는데, 이때 replica를 IP로 식별한다. data가 StatefulSet이라 pod 이름(valkey-0)은 안정적이지만, 컨테이너 네트워킹은 pod 재기동 시 IP를 새로 할당한다. 그러니 재기동마다 "새 IP의 replica가 등장"하고, 옛 IP는 지워지지 않는다.
해법은 replica가 자기를 IP가 아니라 안정 식별자로 보고하게 만드는 것이다. StatefulSet의 init-config가 각 data pod의 valkey.conf 에 다음을 주입하게 했다.
replica-announce-ip <hostname>.<headless-fqdn>
replica-announce-port 6379
이러면 master의 INFO replication 이 replica를 안정 FQDN 으로 보고하고, resolve-hostnames yes 인 Sentinel이 그 FQDN으로 replica를 등록한다. pod가 재기동돼 IP가 바뀌어도 식별자(FQDN)는 그대로라 같은 엔트리를 재사용한다. 새 ghost가 안 생긴다.
검증(alpha): replica pod를 삭제하고 재기동한 뒤에도 num-slaves 가 2로 유지됐고(새 ghost 0), slave가 FQDN으로 등록되는 걸 확인했다. 단, 기존에 IP로 등록돼 운영 중이던 클러스터는 이 변경을 처음 적용한 직후 1회 SENTINEL RESET 으로 announce 전 IP로 등록된 잔여분을 정리해줘야 한다(이후엔 불필요).
B: sentinel을 StatefulSet으로, 그리고 master를 "발견"하게
replica ghost를 막아도 sentinel peer ghost가 남는다. sentinel이 Deployment라 pod가 재생성될 때마다 IP도 myid도 바뀌고, 다른 sentinel들은 옛 peer를 ghost로 안고 간다. 그래서 sentinel도 StatefulSet으로 전환하고, 각 sentinel이 자기를 안정 FQDN으로 광고하게 했다.
sentinel announce-ip <pod>.<sentinel-headless-fqdn>
여기까진 A의 sentinel판이다. 그런데 전환을 검증하다 더 큰 함정을 만났다.
함정: 초기 master 하드코딩이 만든 failover 루프
chart의 configmap에는 sentinel monitor mymaster valkey-0 ... 가 적혀 있었다. 즉 초기 master를 valkey-0 으로 하드코딩한 것이다. 평소엔 문제가 없다. 그런데 Deployment → StatefulSet 컷오버나 전 sentinel 동시 재기동으로 모든 sentinel이 fresh 부팅 하면, 그 시점의 실제 master가 valkey-0이 아닐 때(과거 failover 이력이 있으면) 전 sentinel이 stale 주소를 물고 시작한다.
게다가 악순환이 더 있다. data의 readiness 검사가 "sentinel이 말하는 master가 자신인가?" 를 보는데, 모든 sentinel이 엉뚱한 master를 가리키면 진짜 master가 NotReady가 되어 headless DNS에서 빠지고, 그래서 더 못 고치는 상태로 빠진다. failover 루프.
수정: 부팅 시 실제 master를 발견하는 init
sentinel의 config 복사 init이 부팅 시 data 토폴로지를 조회해 실제 master(안정 FQDN) 를 찾아낸 뒤, sentinel monitor 라인을 그 master로 교체하도록 했다. data 쪽 init-replica.sh 가 "Sentinel이 진실의 원천, ordinal은 fallback" 으로 동작하는 것과 같은 철학을, sentinel 부팅 경로에도 적용한 셈이다. Deployment/StatefulSet 공통으로 넣었다.
검증(alpha): sentinel을 단일로도, 전체 동시로도 재기동해봤는데 두 경우 모두 peer ghost 0이었고, discovery init이 실제 master를 자동으로 찾아 수동 개입 없이 자가치유 했다. 하드코딩 시절이라면 전체 동시 재기동에서 failover 루프에 빠졌을 시나리오다.
C: 모니터링을 데이터에서 떼어내다
마지막은 1번 문제의 직접 해법이다. stream/key-group 점검을 data 사이드카에서 들어내, standalone valkey-data-metrics Deployment로 옮겼다. 이 Deployment는 별도로 둔 valkey-data ClusterIP Service를 통해 valkey 노드에 read-only로 XLEN/XINFO 만 던진다.
이러면 결합이 끊긴다.
- stream 목록을 바꿔도 바뀌는 건 standalone Deployment의 pod template뿐이다. data StatefulSet은 손대지 않으니 valkey 노드 무중단, failover도 없다.
- data 사이드카는 이제 instance/replication 지표(
redis_up,redis_memory_*,redis_master_link_up등)만 담당한다. stream은 standalone이 본다.
metrics.data.streamExporter.enabled=true 면 이 분리 모드가 켜진다. stream 추가 절차도 단순해졌다. 앱의 stream 키 변경에 맞춰 values-<env>.yaml 의 stream 목록을 갱신하고 helm upgrade 하면, standalone Deployment만 재기동되고 valkey는 그대로다. 검증은 standalone의 /metrics 를 port-forward해서 curl로 긁어 redis_stream_length{stream=...} 에 신규 stream이 포함되는지 보면 된다(컨테이너에 wget/sh가 없어 port-forward + curl 경로를 썼다).
Grafana stream 패널을 쓴다면 한 가지 주의. 기존엔 data pod별로 series가 떠서 pod=~"$pod" 필터를 걸었는데, standalone은 단일 series라 그 필터를 제거해야 한다.
검증: 무엇을 어떻게 확인했나
A/B/C를 alpha · beta에서 실측으로 확인하고 prod baseline에 반영했다.
| 환경 | A | B | C | 상태 |
|---|---|---|---|---|
| alpha | ✅ | ✅ | ✅ | 배포·검증 완료 |
| beta | ✅ | ✅ | ✅ | 배포·검증 완료(미러 후 helm upgrade) |
| prod | ✅ | ✅ | ✅ | 납품 표준 baseline 반영(사업장 배포 시 동일 구조) |
실측 항목
- replica/sentinel pod를 단일로도, 전체 동시로도 재기동한 뒤 ghost 0 (FQDN 엔트리 재사용 확인).
- 전 sentinel 동시 재기동 시 discovery init이 실제 master를 자동 발견 → failover 루프 없이 자가치유.
- standalone exporter가
redis_up=1이고, 신규 stream을 포함해redis_stream_length노출. - 데이터 평면 무손실, BE 접속정보(headless Service FQDN) 변경 불필요 를 실측 확인. BE는 여전히
valkey-sentinel.<ns>.svc:26379로 붙고, Service 이름/selector가 전환과 무관하게 동일하다. helm lint+helm template(alpha · beta · prod) 통과.
운영 컷오버 주의: 기존 클러스터에 처음 적용할 때
신규로 구축하는 클러스터는 처음부터 A+B+C 상태라 매끄럽지만, 이미 운영 중인 기존 클러스터에 처음 적용할 땐 전환 비용이 한 번 든다.
- A 적용: data가 announce-ip를 새로 달면서 rolling restart 1회.
- B 컷오버: sentinel Deployment → StatefulSet으로 바뀌며 전 sentinel pod가 한 번에 교체된다. 그동안 수십 초의 failover-blind 윈도우가 1회 발생한다. discovery init이 자동 복구하지만, 6.1의 readiness 악순환 가능성 때문에 한산한 시간대에 수행하는 걸 권한다.
- 컷오버 직후 1회
SENTINEL RESET mymaster로 전환기에 IP로 등록됐던 잔여 ghost를 정리한다. 이후엔 announce-ip/FQDN 덕에 불필요하다. - 운영 명령 주의: sentinel이 환경별로 Deployment일 수도 StatefulSet일 수도 있으니,
kubectl exec대상은deploy/...같은 고정 이름 대신 라벨 셀렉터(-l app.kubernetes.io/component=sentinel)로 잡는다. 고정 이름은 kind가 다른 환경에서 깨진다.
마무리: 이번에 남은 사고 흐름
요청은 "지표 하나 더 보이게" 였는데, 끝나고 보니 캐시 인프라의 결합 세 군데를 끊은 작업이 됐다. 이번 작업이 남긴 진짜 자산은 chart diff보다 그 사고 흐름이다.
- 모니터링과 데이터 평면은 같은 pod template에 묶지 않는다. "무엇을 볼지" 를 바꾸는 일이 "누가 master인지" 를 흔들면 안 된다. 관측은 read-only standalone으로 떼어낸다.
- 분산 시스템에서 노드는 IP가 아니라 안정 식별자로 등록한다. IP는 재기동마다 바뀌는 휘발성 값이고, Sentinel처럼 한 번 본 노드를 영구 기억하는 컴포넌트에선 그게 곧 ghost가 된다. announce-ip(FQDN)로 식별자를 고정하면 ghost의 발생 원인 자체가 사라진다.
- 하드코딩된 초기 상태는 "전부 동시에 재시작" 시나리오에서 터진다. 초기 master 하드코딩은 평소엔 멀쩡하다가 전 sentinel fresh 부팅에서 failover 루프를 만든다. 부팅 시 실제 상태를 발견(discovery)하게 만드는 게 정답이고, 그게 곧 자가치유다.
- ghost는 종류별로 처방이 다르다. replica ghost와 peer ghost를 한 덩어리로 보면 RESET 같은 사후 청소만 반복하게 된다. 둘을 분리해 각각 announce-ip / StatefulSet 전환으로 근본 차단하는 게 "재기동 때마다 사람이 청소" 를 없앤다.
다음에 같은 패턴(상태를 IP로 기억하는 컴포넌트 + 잦은 pod 재기동 + 모니터링 결합)을 만나면, 이 네 줄을 펴놓고 시작하면 된다.
References
High availability with Redis Sentinel
High availability for non-clustered Redis
redis.io
'Redis' 카테고리의 다른 글
| 단일 VM Valkey 를 K8s Sentinel HA 로 옮긴 기록 (0) | 2026.05.04 |
|---|---|
| Redis는 왜 빠른가 - "인메모리 + 싱글 스레드"는 절반짜리 정답이다 (1) | 2026.04.19 |