redis-operator로 Redis Sentinel을 배포하면 Pod나 노드가 죽어도 Sentinel이 failover를 해 줍니다. 하지만 이 보호는 한 Kubernetes 클러스터(한 Site) 안에서만 유효합니다. 클러스터나 데이터센터가 통째로 사라지면 operator도, Sentinel도, 데이터도 함께 사라집니다.
그래서 다른 Site에 같은 구성을 하나 더 두고, 운영 Site(A)의 변경을 DR Site(B)로 실시간 복제하는 DR이 필요합니다. 이 글은 그 복제를 직접 만든 엔진 redis-dr로 구성하는 과정을 다룹니다.
- Site A, B 모두 redis-operator로 Sentinel(master 1 + replica 2, Sentinel 3)을 배포합니다.
- 두 Site 사이에
redis-dr을 두어 A의 replica에서 B의 master로 복제합니다. - 복제를 확인하고, 소스 replica를 죽였을 때와 엔진을 재시작했을 때 full sync 없이 이어받는지 확인합니다.
- full sync가 일어나는 세 경우(운영자 요청, full sync 도중 엔진 종료, master failover)를 실행하고, 복구 뒤 A와 B의 데이터를 비교합니다.
이 글에서 Site A는 운영(active) Site, Site B는 DR(standby) Site이고, 줄여서 A, B라고 씁니다. 복제는 항상 A에서 B로 흐릅니다.
1. 개요
1.1 오픈소스 검토
Redis 데이터를 다른 인스턴스로 복제하는 오픈소스로는 RedisShake, redis-rdb-cli, RIOT-X 등이 있습니다.
RedisShake는 체크포인트 재개와 토폴로지 변경을 지원하지 않습니다. 소스와 연결이 끊기면 처음부터 full sync를 하고, B에 같은 key가 남아 있으면 BUSYKEY로 종료합니다(rewrite 설정으로 피할 수 있습니다). 소스 replica가 master로 승격되면 다른 replica로 옮기지 않고 승격된 master에 다시 붙습니다. redis-rdb-cli(rst)는 Sentinel 주소를 주면 소스가 master로 고정되고, replica를 직접 지정하면 Sentinel 추적과 자동 전환을 쓸 수 없습니다. 쓰는 쪽(B)은 redis://host:port 고정 주소만 받아서 B에서 failover가 나면 따라가지 못합니다. 복제 위치(replid, offset)도 메모리에만 있어서 프로세스를 재시작하면 처음부터 다시 받습니다. RIOT-X는 SCAN과 Keyspace Notification 기반이라 연결이 끊긴 동안의 변경을 놓칠 수 있습니다.
따라서 replica에서만 읽고, 소스를 자동으로 전환하며, 재시작과 전환 때 full sync 없이 이어받는 DR 조건을 모두 채우는 도구는 없었습니다.
Redis 자체는 replid와 offset을 이용하는 PSYNC로 중단된 복제를 이어받을 수 있으므로, 부족한 것은 도구입니다. 그래서 Java 라이브러리 redis-replicator를 기반으로 체크포인트 관리, 소스 replica 선택과 failover 처리를 담당하는 redis-dr을 구현했습니다.
1.2 DR 요건
오픈소스 검토 결과를 바탕으로 DR 복제 엔진이 갖춰야 할 기본 기능 요건을 다섯 가지로 정했습니다. A와 B의 Sentinel 운영을 방해하지 않으면서, 장애가 나도 full sync 없이 이어받는 것이 목표입니다.
- 소스는 항상 replica: master에 붙으면 Sentinel이 복제 클라이언트를 replica로 보고 승격 후보로 삼을 수 있습니다.
- 소스 자동 전환: 연결한 replica가 죽거나 master로 승격되면 다른 replica로 옮깁니다.
- full resync 회피: 체크포인트를 보존해 재시작과 전환 때 이어받습니다.
- B는 일반 클라이언트로 쓰기: B에
REPLICAOF를 걸지 않으므로 B의 operator, Sentinel과 충돌하지 않습니다. - 이어받을 수 없으면 full sync로 복구: 체크포인트가 온전하지 않거나 이력이 갈라져 이어받을 수 없을 때는 B를 비우고 처음부터 받아 A와 같은 상태로 되돌립니다.
2. 아키텍처
A와 B는 각각 Sentinel로 운영되는 Redis(master 1 + replica 2, Sentinel 3)이고, redis-dr은 그 사이에서 A의 변경을 B로 옮기는 프로세스 하나입니다.
아래 그림은 그 구성과, 엔진이 A와 B의 Sentinel에서 각각 무엇을 조회하는지를 보여 줍니다.
Site A (active) Site B (DR standby)
┌─────────────────────────────┐ ┌───────────┐ ┌─────────────────────────────┐
│ Sentinel A x3 (quorum 2) │ │ │ │ Sentinel B x3 (quorum 2) │
│ │ │ │ │ │
│ redis-a-0 master <- app │ │ redis-dr │──writes─►│ redis-b-0 master │
│ redis-a-1 replica (source) │──PSYNC──►│ (engine) │ │ redis-b-1 replica │
│ redis-a-2 replica │ │ │ │ redis-b-2 replica │
│ │ │ │ │ + checkpoint (stored in B) │
└─────────────────────────────┘ └───────────┘ └─────────────────────────────┘
redis-dr -> Sentinel A : current master and replica list (picks the source replica)
redis-dr -> Sentinel B : DR master address (tracks the write target)
redis-dr은 A의 replica 한 곳에 복제 클라이언트로 붙어 PSYNC로 스트림을 받고, 받은 명령을 B의 master에 씁니다.
이 복제는 Redis 복제와 마찬가지로 비동기입니다. A가 갑자기 사라지면 아직 B에 반영되지 않은 변경(lagBytes만큼)은 유실될 수 있으므로, RPO는 0이 아닙니다.
2.1 주요 기능
redis-dr은 Sentinel과 Cluster 두 방식의 Redis DR을 모두 지원합니다. replica를 소스로 복제하므로 장애가 발생해도 즉각적이고 안정적으로 이어쓸 수 있고, 운영자 API로 진행 현황을 조회하고 복제를 중지·재개하거나 full sync를 요청할 수 있습니다.
- replica에서만 읽기: A의 master에는 붙지 않습니다.
- 체크포인트를 B에 저장: 복제 위치(replid + offset)를 데이터와 같은 Lua 스크립트로 B 안에 기록합니다. 데이터와 위치가 함께 반영되거나 함께 반영되지 않으므로, 엔진을 재시작하거나 소스를 바꿔도 중복이나 유실 없이 이어집니다.
- 소스 자동 전환: 소스 replica 연결이 끊기거나 master로 승격되면 이어받을 수 있는 다른 replica를 골라
PSYNC를 이어갑니다. - B failover 추적: B의 Sentinel에서 현재 master 주소를 받아 씁니다. B에서 failover가 나면 새 master로 따라갑니다.
- 이력이 갈라지면 full sync로 복구: A의 failover로 엔진이 읽은 위치가 새 master의 승격 위치보다 앞서면 B에 새 master에 없는 데이터가 반영된 것이라(이력이 갈라진 상태) 이어받을 수 없으므로, B를 비우고 full sync를 합니다.
- 죽지 않고 대기: B에 쓸 수 없거나 읽을 A replica가 없어도 종료하지 않고, 원인을 로그와 상태 API에 남긴 채 기다립니다. 문제가 풀리면 저장된 체크포인트부터 이어갑니다.
- 운영자 API: 진행 현황 조회, 복제 중지와 재개, full sync 강제를 REST로 제공합니다(Swagger UI 포함).
같은 엔진이 Redis Cluster 모드(REDIS_DR_MODE=cluster)도 지원합니다. shard마다 worker를 하나씩 띄우는 방식이며, 이 글에서는 Sentinel 모드만 다룹니다.
3. DR 환경 구성 (Kubernetes)
namespace는 Site A site-a, Site B site-b, 엔진 dr-engine으로 나눕니다. 실제로는 Site마다 다른 클러스터를 쓰는 것이 맞지만, 이 글은 한 클러스터 안에서 구성합니다. 클러스터가 다를 때의 네트워크 구성은 redis-dr 저장소의 cross-cluster-network.md를 참고하세요.
이 글에서 쓰는 helm values와 매니페스트는 redis-dr 저장소의 k8s/ 폴더에 있습니다. 먼저 저장소를 clone하고, 이후 명령은 모두 저장소 루트에서 실행합니다.
git clone https://github.com/cnapcloud/redis-dr.git
cd redis-dr
3.1 redis-operator로 Sentinel 배포
먼저 helm 저장소를 추가하고 operator를 설치합니다. operator는 한 번만 설치하면 모든 namespace의 RedisReplication을 관리합니다.
helm repo add ot-helm https://ot-container-kit.github.io/helm-charts/
helm repo update
helm upgrade --install redis-operator ot-helm/redis-operator \
--version 0.27.0 -n redis-operator --create-namespace
kubectl rollout status deploy/redis-operator -n redis-operator
Sentinel은 redis-replication 차트의 sentinel.enabled 옵션으로 함께 배포합니다. Site A의 values입니다.
# k8s/values-sentinel-a.yaml
redisReplication:
name: redis-a
clusterSize: 3 # master 1 + replica 2
image: quay.io/opstree/redis
tag: v7.0.15 # redis-dr은 Redis 7 이상 필요
sentinel:
enabled: true
image: quay.io/opstree/redis-sentinel
tag: v7.0.15
size: 3
downAfterMilliseconds: "5000"
failoverTimeout: "10000"
parallelSyncs: "1"
storageSpec:
volumeClaimTemplate:
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 1Gi
Site B의 values(values-sentinel-b.yaml)는 name만 redis-b로 다르고 나머지는 같습니다. 두 Site를 배포합니다.
helm upgrade --install redis-a ot-helm/redis-replication --version 0.17.1 \
-n site-a --create-namespace -f k8s/values-sentinel-a.yaml
helm upgrade --install redis-b ot-helm/redis-replication --version 0.17.1 \
-n site-b --create-namespace -f k8s/values-sentinel-b.yaml
설정 중 눈여겨볼 항목입니다.
| 항목 | 설명 |
|---|---|
clusterSize: 3 | Redis Pod 수. master 1 + replica 2입니다. redis-dr은 replica에서만 읽으므로 replica가 1대 이상 있어야 합니다. |
sentinel.size: 3 | Sentinel Pod 수. quorum은 2로 설정됩니다. |
downAfterMilliseconds | master가 응답하지 않을 때 Sentinel이 장애로 판단하기까지의 시간. 차트 기본값(5초)을 그대로 명시했습니다. |
tag: v7.0.15 | redis-dr은 Redis 7.0 이상이 필요합니다(소스에서 COMMAND LIST를 사용). |
배포 직후에는 Pod가 모두 master로 시작했다가, operator가 잠시 뒤 복제를 구성합니다(이 환경에서는 1분 안쪽). 아래로 확인합니다.
kubectl get pods -n site-a
NAME READY STATUS RESTARTS AGE
redis-a-0 1/1 Running 0 1m
redis-a-1 1/1 Running 0 1m
redis-a-2 1/1 Running 0 1m
redis-a-s-0 1/1 Running 0 1m # Sentinel
redis-a-s-1 1/1 Running 0 1m
redis-a-s-2 1/1 Running 0 1m
# 각 Pod의 역할
for p in 0 1 2; do
echo -n "redis-a-$p: "
kubectl exec -n site-a redis-a-$p -c redis-a -- redis-cli role | head -1
done
# redis-a-0: master
# redis-a-1: slave
# redis-a-2: slave
# Sentinel이 아는 master
kubectl exec -n site-a redis-a-s-0 -- redis-cli -p 26379 sentinel get-master-addr-by-name mymaster
# 10.42.1.147
# 6379
Sentinel의 master 이름은 mymaster입니다. Sentinel 접속 주소는 headless 서비스 redis-a-s-hl.site-a.svc.cluster.local:26379이고, Sentinel은 master와 replica를 호스트명이 아닌 IP로 알려 줍니다. redis-dr은 이 IP로 직접 접속하므로 엔진 Pod에서 Redis Pod IP로 닿을 수 있어야 합니다. Site B도 같은 방식으로 확인합니다(site-b, redis-b-s-hl).
3.2 redis-dr 배포
저장소 루트에서 엔진 이미지를 빌드합니다. 저장소의 Dockerfile은 JDK로 빌드하고 JRE로 실행하며, 비root 사용자(uid 10001)로 동작합니다.
docker build -t redis-dr:0.1.0 .
보통은 클러스터가 접근할 수 있는 레지스트리에 푸시합니다. 이 글의 Kubernetes 클러스터는 lima VM으로 만든 k3s이고, 레지스트리 없이 노드마다 이미지를 직접 import했습니다. 이미지가 없는 노드에 Pod가 스케줄되면 ErrImagePull로 멈추므로, 레지스트리를 쓰지 않는다면 Pod가 뜰 수 있는 노드에 모두 넣어야 합니다.
# lima 노드 3대(master-1, worker-1, worker-2)에 이미지를 직접 import
for n in master-1 worker-1 worker-2; do
docker save redis-dr:0.1.0 | limactl shell $n sudo k3s ctr images import -
done
Deployment입니다. 필요한 설정은 환경변수로 줍니다.
# k8s/redis-dr-sentinel.yaml
apiVersion: v1
kind: Namespace
metadata:
name: dr-engine
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: redis-dr
namespace: dr-engine
spec:
replicas: 1 # 한 Site A -> Site B 쌍에는 하나만 실행
strategy:
type: Recreate # 롤아웃 중에도 두 인스턴스가 겹치지 않게 함
selector:
matchLabels: { app: redis-dr }
template:
metadata:
labels: { app: redis-dr }
spec:
containers:
- name: redis-dr
image: redis-dr:0.1.0
env:
- name: REDIS_DR_MODE
value: sentinel
- name: REDIS_DR_SENTINEL_SOURCE_NODES
value: redis-a-s-hl.site-a.svc.cluster.local:26379
- name: REDIS_DR_SENTINEL_SOURCE_MASTER
value: mymaster
- name: REDIS_DR_SENTINEL_TARGET_NODES
value: redis-b-s-hl.site-b.svc.cluster.local:26379
- name: REDIS_DR_SENTINEL_TARGET_MASTER
value: mymaster
resources:
requests: { cpu: 100m, memory: 256Mi }
limits: { memory: 512Mi }
replicas: 1과Recreate는 지켜야 합니다. 같은 체크포인트를 두 인스턴스가 동시에 쓰는 구성은 지원하지 않습니다. 롤링 업데이트로 두 Pod가 잠깐 겹치는 것도 피해야 합니다.
첫 실행의 full sync는 B를 비웁니다(
FLUSHALL). 데이터가 들어 있는 B에 처음 붙이기 전에 반드시 확인하세요.
kubectl apply -f k8s/redis-dr-sentinel.yaml
kubectl rollout status deploy/redis-dr -n dr-engine
kubectl logs -n dr-engine deploy/redis-dr
3.3 복제 확인
기동 로그입니다. 체크포인트가 없어 full sync를 하고, A의 replica 하나를 소스로 고릅니다(PING/REPLCONF 등 프로토콜 로그는 생략).
10:25:40.140 INFO replication read timeout: 30s (source ping period 10s)
10:25:40.156 INFO B master connected: host=10.42.1.148
10:25:40.156 INFO no checkpoint in B, full sync
10:25:40.365 INFO source selected: host=10.42.0.101 mode=full_sync elapsed_ms=5
10:25:45.974 INFO FULLRESYNC 57f08be0466a624d4cc45af6084e9c6be5116fed 12991
10:25:45.985 INFO full sync started: B flushed, DR unavailable until the snapshot is loaded
10:25:45.996 INFO full sync done: keys=0 elapsed_ms=17 replid=57f08be0 offset=12991
10:25:50.161 INFO stat: src=10.42.0.101 replid=57f08be0 offset=13811 cp=OK@12991 applied=0 restored=0
소스는 replica(10.42.0.101, redis-a-1)입니다. A에 데이터를 써서 B에 도착하는지 봅니다.
# A master에 key 1000개와 hash, list를 씀
kubectl exec -n site-a redis-a-0 -c redis-a -- sh -c '
for i in $(seq 1 1000); do echo "SET key:$i value-$i"; done | redis-cli >/dev/null
redis-cli SET greeting hello
redis-cli HSET user:1 name lemon role admin
redis-cli RPUSH queue a b c
redis-cli dbsize'
# 1003
# A와 B에서 같은 값을 읽음
cmp='redis-cli dbsize | tr "\n" " "; redis-cli get greeting | tr "\n" " "; \
redis-cli hgetall user:1 | paste -sd, - | tr "\n" " "; redis-cli lrange queue 0 -1 | paste -sd, -'
kubectl exec -n site-a redis-a-0 -c redis-a -- sh -c "$cmp"
kubectl exec -n site-b redis-b-0 -c redis-b -- sh -c "$cmp"
# A: 1003 hello name,lemon,role,admin a,b,c
# B: 1004 hello name,lemon,role,admin a,b,c
데이터는 같은데 B의 dbsize가 1 더 큽니다. B 안에 저장된 체크포인트 key {dr}:checkpoint가 하나 더 있기 때문입니다. B의 replica(redis-b-1)에도 dbsize를 보면 같은 key 수(1004)가 도착해 있습니다.
stat: 로그는 5초마다 나옵니다. 쓰기가 있으면 아래처럼 applied가 늘고 cp=OK@…의 offset이 소스 offset을 따라갑니다. 쓰기가 없을 때는 PING을 반영하지 않으므로 cp가 멈춰 있어도 정상입니다.
10:26:05.074 INFO stat: src=10.42.0.101 replid=57f08be0 offset=57922 cp=OK@57512 applied=1003 restored=0
| 필드 | 의미 |
|---|---|
src | 지금 읽고 있는 A의 replica IP |
replid, offset | 읽은 위치 |
cp=OK@… | B에 저장된 체크포인트 offset |
applied | B에 반영한 명령 수 |
restored | full sync로 복원한 key 수 |
운영자 API로 상태를 봅니다. 이 API는 인증이 없어서 기본적으로 Pod 안의 loopback(127.0.0.1)에서만 듣습니다. port-forward로 접근합니다.
kubectl -n dr-engine port-forward deploy/redis-dr 8081:8081 & # 이후 API 호출에도 이 port-forward를 씁니다
curl -s localhost:8081/api/v1/status | python3 -m json.tool
{
"mode": "sentinel",
"engine": "RUNNING",
"partitions": [
{
"partition": 0,
"state": "STREAMING",
"sourceHost": "10.42.0.101",
"replId": "57f08be0466a624d4cc45af6084e9c6be5116fed",
"offset": 57922,
"persistedState": "OK",
"persistedOffset": 57512,
"masterOffset": 57922,
"lagBytes": 0,
"applied": 1003,
"appliedPerSecond": 200.4
}
]
}
(detail, sessionMode, asOf 등 일부 필드는 생략했습니다.) state가 STREAMING이고 lagBytes가 0이면 A의 변경이 지연 없이 B로 흐르고 있습니다. Swagger UI는 http://localhost:8081/swagger-ui.html입니다.
4. DR 엔진 구성
4.1 내부 구조
소스(redis.dr 패키지)를 읽고 Sentinel 모드 기준으로 그린 구조입니다.
RedisDrApplication (Spring Boot)
│
├─ DrEngine ─ 시작하면 partition 목록을 만들고 partition마다 worker를 하나씩 띄움
│ │ (Sentinel 모드는 partition 1개, Cluster 모드는 shard 수만큼)
│ │
│ └─ ReplicationWorker (partition마다 1개, 전용 스케줄러 스레드)
│ │ 운영자 API에 보이는 상태:
│ │ INITIALIZING (B 접속, 체크포인트 읽기. 1초마다 재시도)
│ │ → WAITING_SOURCE (읽을 replica 찾기. 0.5초마다 재시도)
│ │ → FULL_SYNC / STREAMING
│ │ WAITING_TARGET: B에 쓰기 실패 / PAUSED: 운영자가 중지
│ │
│ ├─ SourceTopology ─── Sentinel A에서 master 주소와 replica 목록을 조회하고,
│ │ 각 Redis 노드에는 직접 접속해 INFO replication을 읽음
│ ├─ SourceSelector ─── 후보 replica가 이어받을 수 있는지 판단(unusable),
│ │ 승격 뒤 체크포인트 처리 결정(reconcile: KEEP / RE_ANCHOR / DIVERGED)
│ ├─ redis-replicator ─ 선택한 replica에 PSYNC 세션 (RDB 스냅샷 + 명령 스트림)
│ ├─ EventApplier ───── 스트림 이벤트를 B에 반영
│ │ (RDB 시작 → FLUSHALL, key → RESTORE, 명령 → 그대로 실행.
│ │ PING, REPLCONF, MULTI, EXEC, __sentinel__ PUBLISH는 건너뜀)
│ └─ TargetWriter ───── Sentinel B로 찾은 B master에 Lua EVAL로 쓰고 체크포인트 갱신
│
├─ 주기 작업
│ ├─ SourceWatchJob (1초) 소스 replica가 master로 승격됐는지 확인 → 세션을 닫고 다른 replica로 전환
│ ├─ StatReportJob (5초) stat 로그, 운영자 API 상태 갱신
│ └─ LayoutCheckJob (5초) Cluster 모드 전용(shard 수·slot 배치 비교). Sentinel 모드에서는 동작하지 않음
│
└─ OperatorController REST: status / partitions/{index} / resume-check / pause / resume / full-sync
4.2 동작 흐름
시작
├─ B master 접속 (Sentinel B), B의 {dr}:checkpoint 읽기
│ ├─ 없음 / state=SYNCING ──► full sync 모드
│ └─ state=OK ──────────────► resume 모드 (replid, offset)
│
├─ 소스 선택 (0.5초마다 재확인, 사유가 바뀔 때만 로그)
│ Sentinel A에서 master와 replica 조회 → 후보마다 INFO replication 검사
│
├─ PSYNC
│ resume: PSYNC <replid> <다음 offset> ──► +CONTINUE ──► 증분 스트림
│ full sync: PSYNC ? -1 ──► +FULLRESYNC ──► B FLUSHALL → RDB 복원 → 증분 스트림
│
├─ 스트림 적용: 명령 하나마다 (명령 실행 + 체크포인트 갱신)을 Lua 하나로 B에 보냄
│
└─ 세션이 끝나면(연결 끊김, 소스 승격, B 쓰기 실패) 처음 단계로 돌아가 다시 선택
후보 replica 검사 규칙은 SourceSelectorImpl에 있습니다. 아래를 모두 통과해야 소스로 쓸 수 있습니다.
| 검사 | 통과 조건 |
|---|---|
| 역할 | role=slave |
| 링크 | master_link_status=up |
| 현재 master를 따르는지 | master_host가 Sentinel A가 알려 준 현재 master IP와 같음 |
| 진행 위치 | master_repl_offset >= 체크포인트 offset (replica가 체크포인트보다 뒤처지지 않음) |
| backlog | repl_backlog_active=1이고 repl_backlog_first_byte_offset <= offset + 1 (필요한 구간이 backlog에 남아 있음) |
| 이력 id | master_replid가 체크포인트 replid와 같거나, master_replid2와 같고 second_repl_offset 범위 안 |
체크포인트가 없으면 마지막 세 항목은 검사하지 않고, 정상인 replica면 아무거나 씁니다.
master 쪽 대조도 합니다(reconcile). failover로 master가 바뀌어 이력 id가 달라졌을 때 판정은 세 가지입니다.
| 판정 | 조건 | 처리 |
|---|---|---|
KEEP | 체크포인트가 없거나, 대상이 아직 master가 아니거나, master의 replid가 같음 | 그대로 진행 |
RE_ANCHOR | 체크포인트 replid가 새 master의 master_replid2이고 offset 범위 안 | 같은 위치를 새 replid로 다시 맞추고 증분 유지 (checkpoint re-anchored after failover) |
DIVERGED | 위 둘 다 아님: 체크포인트 offset이 새 master의 second_repl_offset을 넘거나 이력 id가 이어지지 않음(엔진이 새 master에 없는 데이터까지 읽은 경우. 시나리오 3) | 체크포인트를 버리고 B를 비운 뒤 full sync |
체크포인트와 쓰기는 B의 SentinelTargetWriterImpl이 합니다. 체크포인트는 B의 db 0에 있는 hash {dr}:checkpoint입니다.
kubectl exec -n site-b redis-b-0 -c redis-b -- redis-cli hgetall '{dr}:checkpoint'
# state OK
# replid 57f08be0466a624d4cc45af6084e9c6be5116fed
# offset 83640
스트림의 명령은 아래 Lua 스크립트로 적용합니다. 이미 반영한 offset이면 DUP으로 건너뛰므로, 재시도해도 중복 적용되지 않습니다.
redis.call('SELECT', 0)
local cur = redis.call('HGET', KEYS[1], 'offset')
if cur and tonumber(cur) >= tonumber(ARGV[2]) then return 'DUP' end
redis.call('SELECT', ARGV[3]) -- 명령이 속한 db
local r = redis.call(unpack(ARGV, 4)) -- 스트림의 명령 실행
redis.call('SELECT', 0)
redis.call('HSET', KEYS[1], 'state', 'OK', 'replid', ARGV[1], 'offset', ARGV[2])
return r
MULTI/EXEC로 묶인 트랜잭션은 원자적으로 적용되지 않습니다. 엔진은 MULTI와 EXEC를 건너뛰고 안의 명령을 위 스크립트로 하나씩 적용합니다. 최종 데이터는 같지만, 적용되는 짧은 순간에는 트랜잭션의 중간 상태가 B에 있을 수 있습니다.
full sync 중에는 state=SYNCING으로 표시하고, 완료하면 OK와 함께 replid·offset을 기록합니다. full sync 도중 엔진이 종료되면 다음 시작 때 SYNCING을 보고 처음부터 다시 합니다. 그 밖에 {dr}:paused key는 운영자가 복제를 중지했는지를 B에 남기는 표시입니다.
4.3 설정 방법
설정과 기본값은 jar 안의 application.yaml에 있고, 아래 세 방법으로 덮어씁니다. 뒤의 것이 앞의 것을 덮어씁니다.
- 기본값:
application.yaml - 외부 설정 파일: 바꿀 항목만 담은
application.yaml을 ConfigMap으로 마운트하고SPRING_CONFIG_ADDITIONAL_LOCATION=file:/config/로 지정 - 환경변수: 프로퍼티 전체 경로를 대문자로 쓰고 점과 하이픈은
_로 바꿈
redis-dr.mode -> REDIS_DR_MODE
redis-dr.sentinel.source.nodes -> REDIS_DR_SENTINEL_SOURCE_NODES
redis-dr.sentinel.fullsync-fallback-seconds -> REDIS_DR_SENTINEL_FULLSYNC_FALLBACK_SECONDS
server.address -> SERVER_ADDRESS
Sentinel 모드의 주요 설정입니다. source는 Site A(운영), target은 Site B(DR)입니다.
| 프로퍼티 | 기본값 | 설명 |
|---|---|---|
redis-dr.mode | sentinel | sentinel 또는 cluster |
redis-dr.sentinel.source.nodes | sentinel-a:26379 | A의 Sentinel 노드들(쉼표 구분). 앞에서부터 처음 응답하는 노드를 씁니다. |
redis-dr.sentinel.source.master | mymaster | A의 Sentinel이 감시하는 master 이름 |
redis-dr.sentinel.source.domain-suffix | "" | Sentinel이 redis-a-0.redis-a처럼 점이 하나인 짧은 호스트명으로 알려 줄 때 뒤에 붙일 도메인 |
redis-dr.sentinel.target.nodes / .master / .domain-suffix | sentinel-b:26379 / mymaster / "" | B의 Sentinel. B master 주소를 추적합니다. |
redis-dr.sentinel.fullsync-fallback-seconds | 20 | 정상인 replica인데 체크포인트를 이어받지 못하는 상태(뒤처짐, backlog 소진, 이력 id 불일치)가 이 시간 이상 이어지면 체크포인트를 버리고 B를 비운 뒤 full sync로 전환. 0이면 사용 안 함 |
redis-dr.replication.source-ping-period-seconds | 10 | A Redis의 repl-ping-replica-period 값을 선언하는 설정. A와 같게 두세요. |
redis-dr.replication.read-timeout-seconds | 30 | 복제 소켓이 이 시간 동안 조용하면 세션을 닫고 소스를 다시 고름. ping 주기의 2배보다 커야 함 |
server.address / server.port | 127.0.0.1 / 8081 | 운영자 API. 인증이 없으므로 기본은 loopback만 엽니다. |
redis-dr.scheduler.source-watch-ms | 1000 | 소스 replica 승격 확인 주기 |
redis-dr.scheduler.stat-report-ms | 5000 | stat: 로그 주기 |
domain-suffix는 Sentinel이 짧은 호스트명을 알려 주는 환경에서만 필요합니다. 이 글의 operator 구성은 IP로 알려 주므로 쓰지 않았습니다.
Sentinel 노드를 여러 개 주면 하나가 죽어도 조회가 이어집니다.
REDIS_DR_SENTINEL_SOURCE_NODES=redis-a-s-0.redis-a-s-hl.site-a.svc.cluster.local:26379,redis-a-s-1.redis-a-s-hl.site-a.svc.cluster.local:26379
4.4 운영자 API
| 메서드 | 경로 | 설명 |
|---|---|---|
| GET | /api/v1/status | 모든 partition의 상태 |
| GET | /api/v1/partitions/{index} | partition 하나의 상태 |
| GET | /api/v1/partitions/{index}/resume-check | 체크포인트에서 이어받을 수 있는지 확인(아무것도 바꾸지 않음) |
| POST | /api/v1/pause | 복제 중지(체크포인트 유지). full sync 적재 중에는 409이며 ?force=true로 강제할 수 있음 |
| POST | /api/v1/resume | 체크포인트에서 재개. 이어받을 수 없으면 full sync를 하지 않고 409로 거부 |
| POST | /api/v1/full-sync | 체크포인트를 지우고 처음부터 full sync. body {"confirm":"FLUSH_B"} 필수 |
pause, resume, full-sync는 ?partition=을 생략하면 모든 partition에 적용됩니다(Sentinel 모드는 partition이 하나입니다).
중지 상태는 B에 저장되어 엔진을 재시작해도 유지됩니다. 중지가 A의 replication backlog보다 오래 이어지면 체크포인트를 쓸 수 없게 되어 resume이 거부되므로, 이때는 resume-check로 확인한 뒤 full-sync를 선택해야 합니다.
full-sync는 B를 비웁니다. 인증이 없으므로 server.address를 0.0.0.0으로 열 때는 NetworkPolicy 등으로 접근을 직접 제한해야 합니다.
5. 장애 시 동작 확인
5.1 소스 replica 장애
소스로 쓰던 redis-a-1 Pod를 지우고, 그 사이 A에 key 500개를 더 씁니다.
kubectl delete pod redis-a-1 -n site-a --wait=false
kubectl exec -n site-a redis-a-0 -c redis-a -- sh -c \
'for i in $(seq 1001 1500); do echo "SET key:$i value-$i"; done | redis-cli >/dev/null'
엔진 로그입니다.
10:26:23.888 ERROR [redis-replicator] socket error. redis-server[10.42.0.101:6379]
java.io.EOFException: null
...
10:26:24.100 WARN source session ended, reselecting: host=10.42.0.101 cause=java.io.EOFException
10:26:24.304 INFO source selected: host=10.42.2.96 mode=resume elapsed_ms=2
10:26:24.309 INFO CONTINUE 57f08be0466a624d4cc45af6084e9c6be5116fed
10:26:24.984 INFO stat: src=10.42.2.96 replid=57f08be0 offset=84050 cp=OK@83640 applied=1503 restored=0
연결이 끊긴 뒤 약 0.4초 만에 redis-a-2(10.42.2.96)로 옮겼고, mode=resume과 CONTINUE로 full sync 없이 이어받았습니다. 끊기는 순간 스택트레이스가 ERROR로 찍히는데, 이것은 redis-replicator 라이브러리가 남기는 로그입니다. 바로 아래에 source session ended, reselecting이 나오고 resume으로 이어지면 정상입니다. 결과는 A 1503개, B 1504개(체크포인트 key 포함)로 맞았습니다.
5.2 엔진 재시작
A에 key 100개를 더 쓴 뒤 엔진을 재시작합니다.
kubectl rollout restart deploy/redis-dr -n dr-engine
10:27:18.529 INFO resume from persisted checkpoint: replid=57f08be0 offset=97549
10:27:18.734 WARN waiting for source: no online replica yet, waiting
10:27:21.775 INFO source selected: host=10.42.0.105 mode=resume elapsed_ms=3046
10:27:21.825 INFO CONTINUE 57f08be0466a624d4cc45af6084e9c6be5116fed
B에 저장된 체크포인트를 읽어 resume으로 시작했고 CONTINUE로 이어받았습니다(10.42.0.105는 5.1에서 지운 redis-a-1이 새로 뜬 IP입니다). 시작 직후 약 3초 동안은 Sentinel 조회에 온라인 replica가 없어서 waiting for source로 기다렸고, 이후 소스를 골랐습니다. 이 대기는 자동으로 풀립니다(0.5초마다 다시 확인, 사유가 바뀔 때만 로그). 재시작 뒤 A 1603개, B 1604개로 맞았습니다.
5.3 full sync가 일어나는 경우
지금까지는 full sync 없이 이어받는 경우였습니다. 반대로 full sync가 일어나는 경우 세 가지를 실행해 봅니다. full sync 구간은 눈에 띄게 하려고 A에 key 10만 개(약 24MB)를 먼저 넣었습니다.
kubectl exec -n site-a redis-a-0 -c redis-a -- sh -c '
V=$(head -c 200 /dev/zero | tr "\0" x)
awk -v v="$V" "BEGIN{for(i=1;i<=100000;i++) print \"SET big:\" i \" \" v i}" | redis-cli >/dev/null
redis-cli dbsize'
# 101603
엔진이 이 10만 개를 증분으로 반영해(applied=100000) B도 101,604개(체크포인트 key 포함)가 됐습니다.
A와 B가 정말 같은지는 key 수만으로는 알 수 없어서, 아래 스크립트로 key 목록과 값의 해시를 비교합니다. 체크포인트 key({dr}:*)는 뺍니다. 값은 MGET으로 읽으므로 string 타입만 비교하고, hash나 list 같은 타입은 key 목록만 비교됩니다. 시나리오 1과 3에서 같은 스크립트를 씁니다.
# digest.sh
echo "dbsize=$(redis-cli dbsize | cut -d' ' -f1)"
redis-cli --scan | grep -v '^{dr}:' | sort > /tmp/keys.txt
echo "keys_md5=$(md5sum < /tmp/keys.txt | cut -c1-12) count=$(wc -l < /tmp/keys.txt)"
echo "values_md5=$(cat /tmp/keys.txt | xargs -n 500 redis-cli mget 2>/dev/null | md5sum | cut -c1-12)"
kubectl exec -i -n site-a redis-a-0 -c redis-a -- sh -s < digest.sh # A master
kubectl exec -i -n site-b redis-b-0 -c redis-b -- sh -s < digest.sh # B master
시나리오 1. 운영자가 full sync를 요청
운영자 API의 full-sync는 B를 비우고 처음부터 다시 받습니다. 확인 문구 없이 호출하면 거부됩니다.
# 3.3의 port-forward가 닫혔다면 다시 엽니다
kubectl -n dr-engine port-forward deploy/redis-dr 8081:8081 &
curl -s -X POST localhost:8081/api/v1/full-sync -H 'Content-Type: application/json' -d '{}'
# {"status":400,"detail":"Invalid request","errors":[{"message":"is required","field":"confirm"}]}
curl -s -X POST localhost:8081/api/v1/full-sync -H 'Content-Type: application/json' -d '{"confirm":"FLUSH_B"}'
10:55:51.120 WARN operator request: action=full-sync partition=all from=127.0.0.1
10:55:51.173 WARN full sync requested by the operator: checkpoint deleted, B will be flushed when the snapshot starts
10:55:51.346 INFO source selected: host=10.42.0.105 mode=full_sync elapsed_ms=2
10:55:56.419 INFO FULLRESYNC 57f08be0466a624d4cc45af6084e9c6be5116fed 24524595
10:55:56.462 INFO full sync started: B flushed, DR unavailable until the snapshot is loaded
10:55:57.247 INFO stat: src=10.42.0.105 replid=? offset=-1 cp=SYNCING@null applied=100000 restored=17029
10:56:02.265 INFO stat: src=10.42.0.105 replid=? offset=-1 cp=SYNCING@null applied=100000 restored=76311
10:56:04.509 INFO full sync done: keys=101603 elapsed_ms=8083 replid=57f08be0 offset=24524595
10:56:07.269 INFO stat: src=10.42.0.105 replid=57f08be0 offset=24526795 cp=OK@24524595 applied=100000 restored=101603
- 요청부터 완료까지 약 13초였습니다. 이 중 적재는 8초(
elapsed_ms=8083)이고, 앞의 약 5초는 소스 replica가FULLRESYNC를 시작하기까지의 대기입니다. 이 replica의repl-diskless-sync-delay가 기본값 5초입니다. - 이 5초 동안은 상태 API의
state가STREAMING(sessionMode=full_sync)으로 보입니다. 스냅샷 적재가 시작되면FULL_SYNC로 바뀌고,restored,expectedKeys,approxPercent가 올라갑니다.
FULL_SYNC restored= 17951 expected= 101603 pct= 17
FULL_SYNC restored= 42808 expected= 101603 pct= 42
FULL_SYNC restored= 65774 expected= 101603 pct= 64
FULL_SYNC restored= 89135 expected= 101603 pct= 87
적재가 끝날 때까지 B는 비어 있거나 일부만 채워진 상태라 DR로 쓸 수 없습니다.
끝난 뒤 해시를 비교하면 같습니다.
A: dbsize=101603 keys_md5=ba3081fa0709 count=101603 values_md5=876ee5ecb809
B: dbsize=101604 keys_md5=ba3081fa0709 count=101603 values_md5=876ee5ecb809
시나리오 2. full sync 도중 엔진이 죽음
위 full sync를 다시 요청하고, restored가 2만을 넘었을 때 엔진 Pod를 강제로 지웁니다.
kubectl delete pod -n dr-engine -l app=redis-dr --grace-period=0 --force
그 시점에 B는 일부만 채워졌고, 체크포인트는 SYNCING이며 replid와 offset이 없습니다.
kubectl exec -n site-b redis-b-0 -c redis-b -- sh -c 'redis-cli dbsize; redis-cli hgetall "{dr}:checkpoint"'
# 25746
# state
# SYNCING
새로 뜬 엔진은 이 체크포인트를 쓸 수 없다고 보고 처음부터 다시 받습니다.
10:57:59.765 INFO B master connected: host=10.42.1.148
10:57:59.765 WARN checkpoint in B is not usable, full sync: state=SYNCING
10:57:59.972 INFO source selected: host=10.42.0.105 mode=full_sync elapsed_ms=6
10:58:04.157 INFO full sync started: B flushed, DR unavailable until the snapshot is loaded
10:58:11.806 INFO full sync done: keys=101603 elapsed_ms=7675 replid=57f08be0 offset=24550470
반쯤 채워진 B의 데이터 위에 이어서 쓰지 않고, B를 비우고 전체를 다시 받아 keys=101603으로 끝났습니다. full sync를 시작할 때 체크포인트를 SYNCING으로 두고 끝나야 OK로 바꾸므로, SYNCING은 곧 “B가 온전하지 않다"는 표시입니다.
시나리오 3. master failover
failover로 replica가 새 master로 승격돼도 쓰기가 곧바로 새 master로 가지는 않습니다. 클라이언트가 승격을 알고 재연결하기 전까지는 옛 master에 계속 쓰고, 옛 master도 Sentinel이 replica로 강등하기 전까지는 쓰기를 받습니다. 이 구간에 옛 master에만 들어간 쓰기는 새 master에 없고, 옛 master가 새 master의 replica가 될 때 새 master의 데이터로 덮어씌워져 A에서 사라집니다. 그런데 엔진의 소스 replica가 그 쓰기를 이미 받았다면, 엔진은 그것을 B에 반영한 상태입니다. 그러면 엔진이 읽은 위치(offset)가 새 master의 승격 시점 위치(failover_offset)보다 앞서고, B에는 A에 없는 데이터가 남습니다. 이것이 이력이 갈라진(diverged) 상태입니다.
이 상태로 이어받으면 그 데이터가 B에 계속 남아 A와 어긋나므로, 엔진은 이어받지 않고 B를 비운 뒤 full sync를 합니다. 소스 replica가 승격됐는지는 판단 기준이 아니고, offset이 failover_offset보다 앞서는지만 봅니다.
현재 A의 master와 엔진의 소스 replica를 확인합니다. 이 글의 실행에서는 master가 redis-a-1, 소스가 redis-a-0이었습니다.
pod_of() { kubectl get pods -n site-a -o jsonpath='{range .items[*]}{.metadata.name} {.status.podIP}{"\n"}{end}' | awk -v ip="$1" '$2==ip{print $1}'; }
MASTER=$(pod_of $(kubectl exec -n site-a redis-a-s-0 -- redis-cli -p 26379 sentinel get-master-addr-by-name mymaster | head -1))
SRC=$(pod_of $(kubectl logs -n dr-engine deploy/redis-dr | grep 'stat:' | tail -1 | sed 's/.*src=\([^ ]*\).*/\1/'))
echo "master=$MASTER source=$SRC"
소스가 아닌 replica가 승격되도록 소스를 승격 후보에서 빼고, 쓰기 부하 중에 failover를 일으킵니다.
# 소스 replica는 승격되지 않게 함
kubectl exec -n site-a $SRC -c redis-a -- redis-cli config set replica-priority 0
# 터미널 1: A master에 쓰기 부하
kubectl exec -n site-a $MASTER -c redis-a -- sh -c \
'awk "BEGIN{for(i=1;i<=400000;i++) print \"SET chk:\" i \" \" i}" | redis-cli >/dev/null'
# 터미널 2: 부하 도중에 강제 failover
kubectl exec -n site-a redis-a-s-0 -- redis-cli -p 26379 sentinel failover mymaster
# 시험 뒤 원복
kubectl exec -n site-a $SRC -c redis-a -- redis-cli config set replica-priority 100
redis-a-2가 새 master가 됐고, 엔진 로그는 이렇습니다.
03:36:50.469 WARN source session ended, reselecting: host=10.42.1.154 cause=java.io.EOFException
03:36:50.675 WARN source history diverged, dropping checkpoint and full sync (B will be flushed): offset=77978389 failover_offset=75479191
03:36:55.240 INFO full sync started: B flushed, DR unavailable until the snapshot is loaded
03:37:40.536 INFO full sync done: keys=535644 elapsed_ms=45387 replid=d474860b offset=75481558
엔진의 offset이 failover_offset보다 약 2.5MB 앞서 있어서 diverged로 판단하고 B를 비운 뒤 full sync했습니다. keys는 새 master의 key 수와 같습니다. 부하 40만 건 중 새 master가 받은 만큼만 남기 때문에 부하 전 key 수에 chk:* 138,798개를 더한 값이 됩니다.
끝난 뒤 A의 새 master와 B를 5.3의 digest.sh로 비교하면 같습니다.
A(redis-a-2): dbsize=535644 keys_md5=4fd190db3479 count=535644 values_md5=813846eaed90
B: dbsize=535645 keys_md5=4fd190db3479 count=535644 values_md5=813846eaed90
결국 replica를 master로 강제 승격하면 옛 master가 살아 있어서, 클라이언트가 승격을 알기 전까지 쓰기가 옛 master에 계속 들어가고 이 쓰기는 모두 사라집니다. 이 실행에서는 약 6초 분량이었습니다. 반면 master가 죽어 replica가 자동으로 승격되는 경우에는 죽은 master에 더 쓸 수 없으므로, 죽기 직전에 복제되지 않은 쓰기(복제 지연만큼)만 사라집니다.
두 경우 모두 redis-dr은 B를 새 master와 같은 상태로 맞추므로 A와 B는 일치하지만, 사라진 쓰기는 B에서도 되살릴 수 없습니다.
마무리
redis-operator와 Sentinel은 한 Site 안의 장애를 막아 주고, 다른 Site로의 복제는 redis-dr이 맡습니다. redis-dr은 A의 replica에서 복제 스트림을 읽어 B의 master에 일반 클라이언트로 쓰고, 읽은 위치를 데이터와 함께 B 안에 저장합니다.
이 구조 덕분에 소스 replica가 죽거나 엔진이 재시작되어도 full sync 없이 이어받을 수 있었습니다. 이어받을 수 없을 때는 B를 비우고 처음부터 다시 받아 A와 같은 상태로 되돌립니다. 이 글에서는 운영자 요청, full sync 도중 종료, master failover의 세 경우를 실행했고, 모두 복구 뒤 A와 B의 데이터가 맞았습니다.
실제 운영에서는 서로 다른 클러스터 간의 복제, DR 승격과 원복 절차, 운영 규모의 데이터량 검증이 반드시 다루어져야 합니다. 이 글은 한 클러스터 안에 두 Site를 구성했기 때문에 이 부분들은 다루지 못했습니다. 클러스터가 다를 때의 네트워크 구성은 redis-dr 저장소의 cross-cluster-network.md에 정리해 두었습니다.