diff --git a/.gitignore b/.gitignore index c235132..e4e880e 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,5 @@ out/ .gradle/ .idea/ results/multi-instance/ +results/resilience/circuit-breaker/ +results/sentinel/ \ No newline at end of file diff --git a/docker-compose.sentinel.yml b/docker-compose.sentinel.yml new file mode 100644 index 0000000..676b72e --- /dev/null +++ b/docker-compose.sentinel.yml @@ -0,0 +1,262 @@ +services: + + redis-master: + image: redis:7.4-alpine + command: + - redis-server + - --appendonly + - "no" + ports: + - "6379:6379" + volumes: + - redis-master-data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 10 + + redis-replica-1: + image: redis:7.4-alpine + command: + - redis-server + - --replicaof + - redis-master + - "6379" + - --replica-announce-ip + - redis-replica-1 + - --replica-announce-port + - "6379" + - --appendonly + - "no" + depends_on: + redis-master: + condition: service_healthy + volumes: + - redis-replica-1-data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 10 + + redis-replica-2: + image: redis:7.4-alpine + command: + - redis-server + - --replicaof + - redis-master + - "6379" + - --replica-announce-ip + - redis-replica-2 + - --replica-announce-port + - "6379" + - --appendonly + - "no" + depends_on: + redis-master: + condition: service_healthy + volumes: + - redis-replica-2-data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 10 + + sentinel-1: + image: redis:7.4-alpine + command: > + sh -c " + cp /etc/redis/sentinel-template.conf /tmp/sentinel.conf && + redis-server /tmp/sentinel.conf --sentinel + " + volumes: + - ./redis/sentinel/sentinel.conf:/etc/redis/sentinel-template.conf:ro + depends_on: + redis-master: + condition: service_healthy + redis-replica-1: + condition: service_healthy + redis-replica-2: + condition: service_healthy + healthcheck: + test: [ "CMD", "redis-cli", "-p", "26379", "ping" ] + interval: 5s + timeout: 3s + retries: 10 + + sentinel-2: + image: redis:7.4-alpine + command: > + sh -c " + cp /etc/redis/sentinel-template.conf /tmp/sentinel.conf && + redis-server /tmp/sentinel.conf --sentinel + " + volumes: + - ./redis/sentinel/sentinel.conf:/etc/redis/sentinel-template.conf:ro + depends_on: + redis-master: + condition: service_healthy + redis-replica-1: + condition: service_healthy + redis-replica-2: + condition: service_healthy + healthcheck: + test: [ "CMD", "redis-cli", "-p", "26379", "ping" ] + interval: 5s + timeout: 3s + retries: 10 + + sentinel-3: + image: redis:7.4-alpine + command: > + sh -c " + cp /etc/redis/sentinel-template.conf /tmp/sentinel.conf && + redis-server /tmp/sentinel.conf --sentinel + " + volumes: + - ./redis/sentinel/sentinel.conf:/etc/redis/sentinel-template.conf:ro + depends_on: + redis-master: + condition: service_healthy + redis-replica-1: + condition: service_healthy + redis-replica-2: + condition: service_healthy + healthcheck: + test: [ "CMD", "redis-cli", "-p", "26379", "ping" ] + interval: 5s + timeout: 3s + retries: 10 + + mysql: + image: mysql:8.4 + environment: + MYSQL_DATABASE: url_shortener + MYSQL_USER: url_shortener + MYSQL_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD is required} + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:?MYSQL_ROOT_PASSWORD is required} + + ports: + - "3306:3306" + + volumes: + - mysql-data:/var/lib/mysql + + healthcheck: + test: + [ + "CMD-SHELL", + "mysqladmin ping -h localhost -u root -p$$MYSQL_ROOT_PASSWORD" + ] + interval: 5s + timeout: 3s + retries: 10 + + app: + build: + context: . + dockerfile: Dockerfile + + image: url-shortener:local + + ports: + - "${APP_PORT:-8080}:8080" + + environment: + SPRING_PROFILES_ACTIVE: sentinel + + APP_NAME: ${APP_NAME:-url-shortener} + VIRTUAL_THREADS_ENABLED: ${VIRTUAL_THREADS_ENABLED:-false} + + SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/${DB_NAME:-url_shortener}?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Seoul + SPRING_DATASOURCE_USERNAME: ${DB_USERNAME:-url_shortener} + SPRING_DATASOURCE_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD is required} + + REDIS_SENTINEL_MASTER: url-shortener-master + REDIS_SENTINEL_NODE_1: sentinel-1:26379 + REDIS_SENTINEL_NODE_2: sentinel-2:26379 + REDIS_SENTINEL_NODE_3: sentinel-3:26379 + + REDIS_CONNECT_TIMEOUT: ${REDIS_CONNECT_TIMEOUT:-200ms} + REDIS_COMMAND_TIMEOUT: ${REDIS_COMMAND_TIMEOUT:-200ms} + + CACHE_ENABLED: ${CACHE_ENABLED:-true} + DB_POOL_MAX_SIZE: ${DB_POOL_MAX_SIZE:-10} + + SHORT_CODE_STRATEGY: ${SHORT_CODE_STRATEGY:-sequence} + SHORT_CODE_NODE_ID: ${SHORT_CODE_NODE_ID:-1} + + depends_on: + mysql: + condition: service_healthy + + sentinel-1: + condition: service_healthy + + sentinel-2: + condition: service_healthy + + sentinel-3: + condition: service_healthy + + healthcheck: + test: + [ + "CMD", + "curl", + "-fsS", + "http://127.0.0.1:8080/actuator/health" + ] + interval: 10s + timeout: 3s + retries: 5 + start_period: 20s + + prometheus: + image: prom/prometheus:v3.13.0 + + ports: + - "${PROMETHEUS_PORT:-9090}:9090" + + volumes: + - ./monitoring/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro + - prometheus-data:/prometheus + + command: + - --config.file=/etc/prometheus/prometheus.yml + - --storage.tsdb.path=/prometheus + - --web.enable-lifecycle + + depends_on: + app: + condition: service_healthy + + grafana: + image: grafana/grafana:13.1.1-ubuntu + + ports: + - "${GRAFANA_PORT:-3000}:3000" + + environment: + GF_SECURITY_ADMIN_USER: ${GRAFANA_ADMIN_USER:-admin} + GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:-admin} + GF_USERS_ALLOW_SIGN_UP: "false" + + volumes: + - grafana-data:/var/lib/grafana + - ./monitoring/grafana/provisioning/datasources:/etc/grafana/provisioning/datasources:ro + - ./monitoring/grafana/provisioning/dashboards:/etc/grafana/provisioning/dashboards:ro + - ./monitoring/grafana/dashboards:/var/lib/grafana/dashboards:ro + + depends_on: + - prometheus + +volumes: + mysql-data: + redis-master-data: + redis-replica-1-data: + redis-replica-2-data: + prometheus-data: + grafana-data: \ No newline at end of file diff --git a/docs/01-requirements.md b/docs/01-requirements.md index 2d59b3d..b796497 100644 --- a/docs/01-requirements.md +++ b/docs/01-requirements.md @@ -77,6 +77,10 @@ URL Shortener는 긴 URL을 짧은 코드로 변환하고, 단축 URL 요청이 - 단일 App 장애 시 Nginx가 다른 App 인스턴스로 GET 요청을 전환한다. - 현재 서비스 경로의 단일 장애 지점은 Nginx와 MySQL이며, Redis 장애는 MySQL Fallback으로 기능을 유지한다. - Redis 장애가 반복되면 Circuit Breaker가 Redis 호출을 차단해 반복적인 Timeout을 줄인다. +- Redis 요청 실패 시 MySQL Fallback을 통해 리다이렉트 기능을 유지한다. +- Redis 장애가 반복되면 Circuit Breaker가 Redis 호출을 차단해 반복적인 Timeout을 줄인다. +- Redis Master 장애 시 Sentinel이 Replica를 새로운 Master로 승격한다. +- Failover가 진행되는 동안 Circuit Breaker와 MySQL Fallback으로 사용자 요청을 처리한다. ### 확장성 @@ -117,6 +121,8 @@ URL Shortener는 긴 URL을 짧은 코드로 변환하고, 단축 URL 요청이 * 다중 애플리케이션 인스턴스 및 Nginx Failover 실험 * 다중 인스턴스 Snowflake nodeId 검증 * Redis Circuit Breaker 적용 및 장애 구간 응답 지연 비교 +* Redis Sentinel 기반 Master·Replica 구성 +* Redis Master 장애와 자동 Failover 검증 ### 제외 @@ -144,4 +150,7 @@ URL Shortener는 긴 URL을 짧은 코드로 변환하고, 단축 URL 요청이 * [ ] 코드 생성 전략별 장단점과 트레이드오프를 설명할 수 있다. * [ ] 다중 인스턴스에서 서로 다른 nodeId로 단축 코드 유일성을 검증했다. * [ ] 단일 App 장애 시 다른 인스턴스로 요청이 전환되는 것을 확인했다. -* [ ] Redis 장애 시 Circuit Breaker를 통해 반복적인 Redis Timeout을 줄였다. \ No newline at end of file +* [ ] Redis 장애 시 Circuit Breaker를 통해 반복적인 Redis Timeout을 줄였다. +* [ ] Redis Master 장애 시 Sentinel이 Replica를 자동 승격한다. +* [ ] Failover 과정에서도 리다이렉트 요청 실패율 0%를 유지한다. +* [ ] Failover 이후 Redis Cache 조회 경로가 자동 복구된다. \ No newline at end of file diff --git a/docs/03-architecture.md b/docs/03-architecture.md index 792deb0..bcb23b9 100644 --- a/docs/03-architecture.md +++ b/docs/03-architecture.md @@ -7,7 +7,8 @@ * 설계에서 우선한 요소: 단순한 초기 구조와 측정 가능한 베이스라인 * 감수한 트레이드오프: 초기에는 모든 조회 요청이 MySQL에 집중된다. * 초기 코드 생성 전략: Sequence ID + Base62 -* 최종 설계: 단일 인스턴스에서는 Sequence를 기본으로 사용하고, 다중 인스턴스에서는 Snowflake 적용을 검토한다. +* 최종 코드 생성 전략: 단일 인스턴스에서는 Sequence + Base62를 기본으로 사용하고, 다중 인스턴스에서는 서로 다른 nodeId를 할당한 Snowflake + Base62의 유일성을 검증했다. +* 장애 대응 전략: 애플리케이션 계층은 Nginx 기반 Failover, Redis 계층은 Circuit Breaker·MySQL Fallback과 Sentinel 기반 자동 Failover를 적용한다. ## 2. 전체 아키텍처 @@ -19,9 +20,16 @@ flowchart LR App1[Spring Boot App1
nodeId=1] App2[Spring Boot App2
nodeId=2] - Redis[(Redis)] DB[(MySQL)] + Sentinel1[Sentinel 1] + Sentinel2[Sentinel 2] + Sentinel3[Sentinel 3] + + RedisMaster[(Current Redis Master)] + RedisReplica1[(Redis Replica)] + RedisReplica2[(Redis Replica)] + Prometheus[Prometheus] Grafana[Grafana] k6[k6] @@ -32,12 +40,27 @@ flowchart LR Nginx --> App1 Nginx --> App2 - App1 --> Redis - App2 --> Redis - App1 --> DB App2 --> DB + App1 -. Master Discovery .-> Sentinel1 + App1 -. Master Discovery .-> Sentinel2 + App1 -. Master Discovery .-> Sentinel3 + + App2 -. Master Discovery .-> Sentinel1 + App2 -. Master Discovery .-> Sentinel2 + App2 -. Master Discovery .-> Sentinel3 + + App1 --> RedisMaster + App2 --> RedisMaster + + Sentinel1 -. Monitor .-> RedisMaster + Sentinel2 -. Monitor .-> RedisMaster + Sentinel3 -. Monitor .-> RedisMaster + + RedisMaster --> RedisReplica1 + RedisMaster --> RedisReplica2 + Prometheus --> App1 Prometheus --> App2 Grafana --> Prometheus @@ -45,11 +68,18 @@ flowchart LR Nginx가 클라이언트 요청을 두 개의 Spring Boot 인스턴스로 분산한다. -두 애플리케이션은 상태를 저장하지 않으며 동일한 MySQL과 Redis를 사용한다. -MySQL은 원본 데이터를 보관하는 Source of Truth이고, -Redis는 리다이렉트 조회 성능을 위한 보조 저장소다. +두 애플리케이션은 상태를 저장하지 않으며 동일한 MySQL을 사용한다. +MySQL은 원본 데이터를 보관하는 Source of Truth이다. + +Redis는 리다이렉트 조회 성능을 위한 보조 저장소이며, +Master 1개와 Replica 2개를 3개의 Sentinel이 감시한다. -Redis Cache Miss 또는 연결 실패 시 MySQL을 조회한다. +애플리케이션은 Sentinel을 통해 현재 Redis Master를 탐색하고, +실제 캐시 요청은 현재 Master에 전달한다. + +Redis Master 장애 시 Sentinel이 Replica 하나를 새로운 Master로 승격한다. +Failover가 진행되는 동안 발생하는 Redis 요청 실패는 +Circuit Breaker와 MySQL Fallback을 통해 처리한다. 리다이렉트 GET 요청에서 특정 애플리케이션 연결에 실패하면 Nginx가 다른 인스턴스로 요청을 재시도한다. @@ -57,6 +87,9 @@ Nginx가 다른 인스턴스로 요청을 재시도한다. POST 생성 요청은 처리 성공 여부가 불분명한 상태에서 재시도할 경우 중복 생성 가능성이 있으므로 자동 재시도 대상으로 두지 않는다. +다중 애플리케이션 Failover와 Redis Sentinel Failover는 +각각 별도의 로컬 Docker 환경에서 검증했다. + ## 3. 주요 컴포넌트 | 컴포넌트 | 역할 | 확장 방법 | 장애 영향 | @@ -67,8 +100,8 @@ POST 생성 요청은 처리 성공 여부가 불분명한 상태에서 재시 | Prometheus | 애플리케이션 지표 수집 | 현재는 단일 인스턴스 | 성능 지표 수집 불가 | | Grafana | 성능 지표 시각화 | 현재는 단일 인스턴스 | 대시보드 조회 불가 | | k6 | 부하 테스트 실행 | VU 단계적 증가 | 서비스에는 영향 없음 | -| Redis | 원본 URL 조회 캐시 | Sentinel, Cluster | 장애 시 MySQL Fallback으로 지연 증가 | - +| Redis | 원본 URL 조회 캐시 | Sentinel 기반 Master·Replica Failover, 필요 시 Cluster 검토 | Master 장애 시 Sentinel 자동 Failover, 전환 중 MySQL Fallback | +| Redis Sentinel | Redis Master 감시 및 자동 Failover | Sentinel 3개, quorum 2 | Sentinel 과반수 상실 시 자동 Failover 제한 | ## 4. 요청 흐름 ### URL 생성 @@ -81,13 +114,16 @@ POST 생성 요청은 처리 성공 여부가 불분명한 상태에서 재시 ### URL 리다이렉트 -1. Circuit Breaker가 CLOSED이면 `shortCode`로 Redis를 조회한다. -2. Cache Hit이면 원본 URL을 반환한다. -3. Cache Miss이면 MySQL의 `short_code` 인덱스로 조회하고 Redis에 저장한다. -4. Redis 조회에 실패하면 MySQL로 Fallback한다. -5. Redis 실패가 반복돼 Circuit Breaker가 OPEN되면 Redis 호출을 생략하고 바로 MySQL을 조회한다. -6. Redis 복구가 확인되면 Circuit Breaker가 CLOSED로 돌아가 Cache Aside 경로를 다시 사용한다. -7. 원본 URL을 `302 Found`로 반환한다. +1. 애플리케이션은 Sentinel을 통해 현재 Redis Master를 탐색한다. +2. Circuit Breaker가 CLOSED이면 `shortCode`로 Redis를 조회한다. +3. Cache Hit이면 원본 URL을 반환한다. +4. Cache Miss이면 MySQL의 `short_code` 인덱스로 조회하고 Redis에 저장한다. +5. Redis 조회에 실패하면 MySQL로 Fallback한다. +6. Redis 실패가 반복돼 Circuit Breaker가 OPEN되면 Redis 호출을 생략하고 바로 MySQL을 조회한다. +7. Redis Master 장애 시 Sentinel이 Replica 하나를 새로운 Master로 승격한다. +8. Lettuce가 Sentinel을 통해 변경된 Master를 탐색한다. +9. Redis 호출 성공이 확인되면 Circuit Breaker가 CLOSED로 돌아가 Cache Aside 경로를 다시 사용한다. +10. 원본 URL을 `302 Found`로 반환한다. ### 실패 흐름 @@ -239,19 +275,22 @@ Clock Rollback을 확인했다. * 동시성 제어: MySQL Auto Increment로 ID 유일성을 보장한다. * 중복 요청 처리: 동일한 원본 URL의 중복 생성을 허용한다. * 저장 실패 처리: DB 저장에 실패하면 단축 URL을 반환하지 않는다. -* DB와 캐시 간 정합성: 초기 구조에서는 캐시를 사용하지 않는다. - +* DB와 캐시 간 정합성: MySQL을 Source of Truth로 두고 Redis에는 조회 결과만 캐시한다. +* Cache Miss 시 MySQL을 조회한 뒤 Redis에 저장한다. +* Redis 저장 실패는 원본 데이터 정합성에 영향을 주지 않으며 DB 조회 결과를 그대로 반환한다. +* Redis Master 장애 시 Sentinel이 Replica를 승격하며, Failover 공백 구간에는 MySQL Fallback으로 조회 기능을 유지한다. Hash와 난수 방식에서는 `short_code`에 Unique Constraint를 적용해 코드 중복을 방지한다. ## 10. 장애 대응 -| 장애 상황 | 영향 | 감지 방법 | 대응 방법 | -| ------------- | -------------- | ----------------------- | ------------------ | +| 장애 상황 | 영향 | 감지 방법 | 대응 방법 | +|---|---|---|---| | 단일 API 서버 장애 | 해당 인스턴스 처리 중단 | Health Check, Prometheus `up` | Nginx가 다른 App 인스턴스로 요청 전환 | -| DB 장애 | URL 생성 및 조회 불가 | Connection 오류, Actuator | 503 반환, DB 복구 | -| Prometheus 장애 | 지표 수집 불가 | Scrape 상태 | 컨테이너 재시작 | -| Grafana 장애 | 대시보드 조회 불가 | 컨테이너 상태 | 컨테이너 재시작 | -| Redis 장애 | 응답 지연 및 DB 부하 증가 | Cache Error, Fallback 지표 | Circuit Breaker OPEN 후 MySQL Fallback | +| DB 장애 | URL 생성 및 조회 불가 | Connection 오류, Actuator | 503 반환, DB 복구 | +| Redis 요청 실패 | 응답 지연 및 DB 부하 증가 | Cache Error, Fallback 지표 | Circuit Breaker OPEN 후 MySQL Fallback | +| Redis Master 장애 | Cache 사용 불가 및 Failover 공백 발생 | Sentinel, Redis 연결 오류 | Replica 자동 승격 후 새 Master로 재연결 | +| Prometheus 장애 | 지표 수집 불가 | Scrape 상태 | 컨테이너 재시작 | +| Grafana 장애 | 대시보드 조회 불가 | 컨테이너 상태 | 컨테이너 재시작 | 초기 구조에서는 DB 장애 시 요청을 처리할 대체 저장소가 없다. @@ -266,11 +305,22 @@ Circuit이 OPEN 상태로 전환된다. OPEN 상태에서는 Redis GET 자체를 호출하지 않고 즉시 MySQL로 Fallback한다. -일정 시간이 지난 뒤 제한된 요청으로 Redis 복구 여부를 확인하고, -정상 응답이 확인되면 다시 Cache Aside 경로로 복귀한다. +Redis 계층은 Master 1개, Replica 2개와 Sentinel 3개로 구성했다. + +Master 장애 시 Sentinel quorum을 통해 Replica 하나를 새로운 Master로 승격하고, +애플리케이션의 Lettuce 클라이언트가 변경된 Master를 다시 탐색한다. + +100 VU 환경에서 현재 Redis Master를 강제로 중단한 결과, +Sentinel은 8.567초 후 `redis-replica-2`를 새로운 Master로 승격했다. + +Failover가 진행되는 동안 Circuit Breaker와 MySQL Fallback이 요청을 처리해 +총 938,870건의 리다이렉트 요청에서 실패율 0%를 유지했다. -이는 기능 지속과 장애 구간의 반복 Timeout을 줄이기 위한 -Graceful Degradation이며 Redis 자체를 이중화한 것은 아니다. +Failover 이후 Cache Hit이 다시 증가하면서 +Redis 조회 경로가 자동으로 복구되는 것을 확인했다. + +Circuit Breaker와 Fallback은 Failover 공백 구간에서 사용자 요청을 보호하고, +Sentinel은 Redis 계층 자체를 자동 복구하는 역할을 담당한다. App1 장애 시 Nginx가 App2를 통해 GET 리다이렉트 요청을 계속 처리한다. @@ -281,15 +331,21 @@ App1 복구 후 다시 요청 처리에 참여하는 것을 확인했다. ## 11. 단일 장애 지점 * Spring Boot는 App1과 App2로 구성해 단일 애플리케이션 장애 지점을 개선했다. -* Nginx는 현재 단일 인스턴스이므로 진입 지점의 SPOF로 남아 있다. -* MySQL은 단일 인스턴스로 구성되어 있어 장애 시 생성 및 조회가 불가능하다. -* Redis는 단일 인스턴스지만 장애 시 MySQL Fallback을 통해 리다이렉트 기능을 유지할 수 있다. +* Redis는 Master 1개와 Replica 2개를 구성하고 Sentinel 3개를 통해 단일 Redis 노드 장애에 대한 자동 Failover를 검증했다. +* Nginx는 현재 단일 인스턴스이므로 외부 요청 진입 지점의 SPOF로 남아 있다. +* MySQL은 단일 인스턴스로 구성되어 있어 장애 시 URL 생성 및 조회가 불가능하다. +* Prometheus와 Grafana도 단일 인스턴스지만 서비스 요청 처리에는 직접적인 영향을 주지 않는다. + +Redis 노드와 Sentinel은 모두 동일한 Docker Desktop 호스트에서 실행했기 때문에, +호스트 자체가 장애 나는 경우 전체 Redis 계층이 함께 영향을 받는다. + +따라서 이번 실험은 Redis 프로세스 또는 컨테이너 단위 Failover를 검증한 것이며, +독립적인 Failure Domain에 배치된 실제 운영 환경 수준의 고가용성을 의미하지 않는다. -Prometheus와 Grafana도 단일 인스턴스지만 -서비스 요청 처리에는 직접적인 영향을 주지 않는다. +운영 환경에서는 Load Balancer 이중화와 MySQL Replica 및 장애 조치를 추가로 고려할 수 있다. -운영 환경에서는 Load Balancer 이중화, MySQL Replica와 장애 조치, -Redis Sentinel 또는 Cluster 등을 추가로 고려할 수 있다. +Redis 저장 용량 또는 처리량이 단일 노드의 한계를 초과할 경우에는 +고가용성과 별도로 Redis Cluster 기반 Sharding을 검토할 수 있다. ## 12. 확장 전략 @@ -309,7 +365,8 @@ Redis Sentinel 또는 Cluster 등을 추가로 고려할 수 있다. ### 캐시 확장 -부하 테스트를 통해 DB 조회 병목이 확인되면 Redis Cache-Aside를 적용한다. +DB Only 부하 테스트에서 반복적인 MySQL 조회가 병목으로 확인돼 +Redis Cache-Aside를 적용했다. ```text Client @@ -319,10 +376,18 @@ Client ``` - 전략: Redis Cache Aside -- 키: `short-url:{shortCode}` +- 키: ```short-url:{shortCode}``` - TTL: 1시간 -- Redis 장애: MySQL Fallback -- 한계: 장애 중 DB 부하와 응답 지연 증가 +- Redis 요청 실패: Circuit Breaker + MySQL Fallback +- Redis Master 장애: Sentinel 기반 Replica 자동 승격 +- Source of Truth: MySQL +- 한계: Failover 공백 구간의 DB 부하 증가와 동일 호스트 Failure Domain + +현재 문제는 Redis 저장 용량 부족이 아니라 단일 Master 장애였기 때문에 +Redis Cluster는 적용하지 않았다. + +향후 Redis 단일 노드의 저장 용량 또는 처리량이 병목으로 확인되면 +Cluster 기반 Sharding을 검토한다. ## 13. 보안 @@ -354,6 +419,7 @@ Client * Redis Cache Error 수 * MySQL Fallback 수 * Redis Circuit Breaker Rejected 수 +* Redis Master Failover 소요 시간 ### Logs @@ -361,6 +427,7 @@ Client * 주요 이벤트: URL 생성 실패, 조회 실패, DB 오류 * 오류 로그: 예외 종류와 요청 경로 * 민감정보 제외 기준: 원본 URL과 Query Parameter 전체를 기록하지 않는다. +* Sentinel Master 전환 이벤트와 Failover 시작·완료 시각 ### Alerts diff --git a/docs/04-experiment.md b/docs/04-experiment.md index 588398d..76c9a64 100644 --- a/docs/04-experiment.md +++ b/docs/04-experiment.md @@ -721,6 +721,106 @@ Timeout 비용을 줄이는 역할을 한다. ![Redis Circuit Breaker](images/redis-circuit-breaker-100vu.png) +## 20. Redis Sentinel 자동 Failover + +Circuit Breaker와 MySQL Fallback을 통해 Redis 장애 중에도 +서비스 요청을 처리할 수 있었지만, Redis 자체는 단일 인스턴스로 남아 있었다. + +Redis 계층의 자동 복구를 검증하기 위해 +1개의 Master, 2개의 Replica, 3개의 Sentinel로 구성된 +Redis Sentinel 환경을 구축했다. + +```text + Spring Boot + | + Sentinel x 3 + | + Current Master + / | \ + Redis Redis Redis +``` + +Sentinel은 Master 장애를 감지하면 Replica 중 하나를 새로운 Master로 +승격하고, Spring Boot의 Lettuce 클라이언트는 Sentinel을 통해 +변경된 Master를 다시 탐색하도록 구성했다. + +### 실험 조건 + +| 항목 | 값 | +|---|---:| +| VU | 100 | +| 실행 시간 | 120초 | +| Master 중단 | 실행 30초 후 | +| 기존 Master 재시작 | 실행 약 60초 후 | +| Redis 노드 | 3개 | +| Sentinel | 3개 | +| Sentinel quorum | 2 | +| down-after-milliseconds | 5초 | +| Redis Timeout | 200ms | + +### 장애 전환 결과 + +실험 시작 당시 Sentinel이 관리하는 Master는 +`redis-replica-1`이었다. + +실행 30초 후 현재 Master를 강제로 중단했고, +Sentinel은 `redis-replica-2`를 새로운 Master로 승격했다. + +| 지표 | 결과 | +|---|---:| +| 기존 Master | redis-replica-1 | +| 새로운 Master | redis-replica-2 | +| Sentinel Failover 시간 | 8.567초 | +| 총 요청 수 | 938,870 | +| 처리량 | 7,823.30 req/s | +| 평균 응답시간 | 12.65ms | +| p95 | 23.72ms | +| 최대 응답시간 | 1.98s | +| 요청 실패율 | 0% | +| Redirect 검증 성공률 | 100% | + +Master 장애 직후 Redis 연결 실패가 발생하면서 +Circuit Breaker Rejected와 MySQL Fallback이 증가했다. + +이 구간에서는 Redis 대신 MySQL이 요청을 처리했으며 +5xx 오류는 발생하지 않았다. + +이후 Sentinel이 약 8.6초 만에 Replica를 새로운 Master로 승격했고, +애플리케이션이 새로운 Redis Master에 다시 연결되면서 +Circuit Breaker Rejected와 Fallback은 감소하고 +Cache Hit 경로가 자동으로 복구됐다. + +```text +Redis Master 장애 + | + v +Circuit Breaker OPEN + | + +----> MySQL Fallback + | | + | +----> 사용자 요청 지속 + | + v +Sentinel 장애 감지 + | + v +Replica -> Master 승격 + | + v +Lettuce 새 Master 탐색 + | + v +Redis Cache 자동 복구 +``` + +따라서 Circuit Breaker와 Fallback은 Redis Failover가 진행되는 +공백 구간에서 사용자 요청을 보호하고, +Sentinel은 Redis 계층 자체를 자동 복구하는 역할을 담당한다. + +#### Grafana 측정 결과 + +![Redis Sentinel Failover](images/redis-sentinel-failover-100vu.png) + ## 19. 실험 한계 - 로컬 Docker 환경에서 실행했다. @@ -738,6 +838,12 @@ Timeout 비용을 줄이는 역할을 한다. - 다중 인스턴스 실험은 로컬 Docker 환경에서 App 2개와 Nginx 1개로 수행했으며, 실제 독립 서버 장애를 재현한 것은 아니다. - Circuit Breaker의 상태 전환은 Rejected 지표를 통해 간접적으로 확인했으며, CLOSED, OPEN, HALF_OPEN 상태 자체를 별도 메트릭으로 기록하지 않았다. +- Sentinel, Redis 노드가 모두 동일한 Docker Desktop 호스트에서 실행됐기 때문에 + 독립적인 Failure Domain을 구성한 실제 운영 환경의 HA와는 차이가 있다. +- p95는 23.72ms로 유지됐지만 Failover 과정에서 최대 1.98초의 + tail latency가 발생했다. +- Redis Sentinel은 자동 Failover를 제공하지만 데이터 Sharding을 제공하지 않으므로, + Redis 단일 노드의 저장 용량 또는 처리량 한계를 해결하기 위한 구조는 아니다. ## 20. 후속 실험 @@ -752,4 +858,4 @@ Timeout 비용을 줄이는 역할을 한다. - [x] Redis 장애 시 MySQL Fallback 및 자동 복구 검증 - [x] 다중 애플리케이션 인스턴스와 장애 전환 검증 - [x] Circuit Breaker를 통한 Redis 장애 구간 Timeout 감소 -- [ ] Redis Sentinel 또는 Cluster 기반 고가용성 구성 +- [x] Redis Sentinel 기반 자동 Failover 검증 diff --git a/docs/images/redis-sentinel-failover-100vu.png b/docs/images/redis-sentinel-failover-100vu.png new file mode 100644 index 0000000..91fe491 Binary files /dev/null and b/docs/images/redis-sentinel-failover-100vu.png differ diff --git a/k6/sentinel-failover-test.js b/k6/sentinel-failover-test.js new file mode 100644 index 0000000..546d482 --- /dev/null +++ b/k6/sentinel-failover-test.js @@ -0,0 +1,31 @@ +import http from 'k6/http'; +import { check } from 'k6'; + +const BASE_URL = __ENV.BASE_URL || 'http://localhost:8080'; +const SHORT_CODE = __ENV.SHORT_CODE; + +export const options = { + scenarios: { + sentinel_failover: { + executor: 'constant-vus', + vus: Number(__ENV.VUS || 100), + duration: __ENV.DURATION || '120s', + }, + }, +}; + +export default function () { + const response = http.get( + `${BASE_URL}/api/v1/${SHORT_CODE}`, + { + redirects: 0, + tags: { + name: 'redirect', + }, + } + ); + + check(response, { + 'redirect status is 302': (res) => res.status === 302, + }); +} \ No newline at end of file diff --git a/redis/sentinel/sentinel.conf b/redis/sentinel/sentinel.conf new file mode 100644 index 0000000..8b3e007 --- /dev/null +++ b/redis/sentinel/sentinel.conf @@ -0,0 +1,10 @@ +port 26379 + +sentinel resolve-hostnames yes +sentinel announce-hostnames yes + +sentinel monitor url-shortener-master redis-master 6379 2 + +sentinel down-after-milliseconds url-shortener-master 5000 +sentinel failover-timeout url-shortener-master 30000 +sentinel parallel-syncs url-shortener-master 1 \ No newline at end of file diff --git a/scripts/run-sentinel-failover-test.sh b/scripts/run-sentinel-failover-test.sh new file mode 100755 index 0000000..a13b83a --- /dev/null +++ b/scripts/run-sentinel-failover-test.sh @@ -0,0 +1,265 @@ +#!/usr/bin/env bash + +set -euo pipefail + +COMPOSE_FILE="docker-compose.sentinel.yml" + +BASE_URL="${BASE_URL:-http://localhost:8080}" +VUS="${VUS:-100}" +DURATION="${DURATION:-120s}" + +if [ -z "${SHORT_CODE:-}" ]; then + echo "SHORT_CODE 환경변수가 필요합니다." + echo "예: SHORT_CODE=abc123 ./scripts/run-sentinel-failover-test.sh" + exit 1 +fi + +TIMESTAMP="$(date '+%Y%m%d-%H%M%S')" +RESULT_DIR="results/sentinel/failover/${TIMESTAMP}" + +mkdir -p "${RESULT_DIR}" + +get_master() { + docker compose -f "${COMPOSE_FILE}" \ + exec -T sentinel-1 \ + redis-cli -p 26379 --raw \ + SENTINEL get-master-addr-by-name url-shortener-master \ + | sed -n '1p' \ + | tr -d '\r' +} + +now_ms() { + python3 -c 'import time; print(int(time.time() * 1000))' +} + +now_iso() { + date '+%Y-%m-%dT%H:%M:%S%z' +} + +FAILED_MASTER="" + +cleanup() { + if [ -n "${FAILED_MASTER}" ]; then + docker compose -f "${COMPOSE_FILE}" \ + start "${FAILED_MASTER}" > /dev/null 2>&1 || true + fi +} + +trap cleanup EXIT + +echo "=== Sentinel Failover Test ===" +echo "SHORT_CODE=${SHORT_CODE}" +echo "VUS=${VUS}" +echo "DURATION=${DURATION}" +echo "RESULT_DIR=${RESULT_DIR}" +echo + +echo "1. 애플리케이션 상태 확인" + +curl -fsS "${BASE_URL}/actuator/health" +echo +echo + +echo "2. Sentinel 합의 상태 확인" + +for i in 1 2 3; do + echo -n "sentinel-${i}: " + + docker compose -f "${COMPOSE_FILE}" \ + exec -T "sentinel-${i}" \ + redis-cli -p 26379 --raw \ + SENTINEL get-master-addr-by-name url-shortener-master \ + | head -n 1 +done + +echo + +echo "3. Cache Warm-up" + +for i in $(seq 1 10); do + curl -s \ + -o /dev/null \ + -w "%{http_code}\n" \ + "${BASE_URL}/api/v1/${SHORT_CODE}" +done + +echo + +MASTER_BEFORE="$(get_master)" + +if [ -z "${MASTER_BEFORE}" ]; then + echo "현재 Redis Master를 확인할 수 없습니다." + exit 1 +fi + +echo "현재 Master: ${MASTER_BEFORE}" + +TEST_START="$(now_iso)" +TEST_START_MS="$(now_ms)" + +echo +echo "4. k6 시작" + +k6 run \ + --summary-export="${RESULT_DIR}/summary.json" \ + -e BASE_URL="${BASE_URL}" \ + -e SHORT_CODE="${SHORT_CODE}" \ + -e VUS="${VUS}" \ + -e DURATION="${DURATION}" \ + k6/sentinel-failover-test.js \ + | tee "${RESULT_DIR}/k6.log" & + +K6_PID=$! + +# +# 0 ~ 30초 : 정상 +# + +sleep 30 + +FAILED_MASTER="$(get_master)" + +MASTER_STOP_TIME="$(now_iso)" +MASTER_STOP_MS="$(now_ms)" + +echo +echo "======================================" +echo "Redis Master 장애 발생" +echo "Master: ${FAILED_MASTER}" +echo "Time: ${MASTER_STOP_TIME}" +echo "======================================" + +docker compose -f "${COMPOSE_FILE}" \ + stop "${FAILED_MASTER}" + +# +# Sentinel이 새로운 Master를 선택하는 시간 측정 +# + +( + DEADLINE=$(( $(now_ms) + 30000 )) + + while true; do + CURRENT_MS="$(now_ms)" + + if [ "${CURRENT_MS}" -gt "${DEADLINE}" ]; then + echo "failover_timeout" \ + > "${RESULT_DIR}/master-after.txt" + break + fi + + NEW_MASTER="$(get_master 2>/dev/null || true)" + + if [ -n "${NEW_MASTER}" ] \ + && [ "${NEW_MASTER}" != "${FAILED_MASTER}" ]; then + + SWITCH_TIME="$(now_iso)" + SWITCH_MS="$(now_ms)" + + echo "${NEW_MASTER}" \ + > "${RESULT_DIR}/master-after.txt" + + echo "${SWITCH_TIME}" \ + > "${RESULT_DIR}/master-switch-time.txt" + + echo "${SWITCH_MS}" \ + > "${RESULT_DIR}/master-switch-ms.txt" + + echo + echo "======================================" + echo "Sentinel Failover 완료" + echo "New Master: ${NEW_MASTER}" + echo "Time: ${SWITCH_TIME}" + echo "======================================" + + break + fi + + sleep 0.2 + done +) & + +WATCH_PID=$! + +# +# 30 ~ 60초 : 기존 Master DOWN +# + +sleep 30 + +MASTER_RESTART_TIME="$(now_iso)" + +echo +echo "======================================" +echo "기존 Master 재시작" +echo "Node: ${FAILED_MASTER}" +echo "Time: ${MASTER_RESTART_TIME}" +echo "======================================" + +docker compose -f "${COMPOSE_FILE}" \ + start "${FAILED_MASTER}" + +FAILED_MASTER="" + +# +# 60 ~ 120초 : 복구 안정화 +# + +set +e +wait "${K6_PID}" +K6_EXIT_CODE=$? +set -e + +wait "${WATCH_PID}" || true + +TEST_END="$(now_iso)" + +MASTER_AFTER="$(get_master)" + +SWITCH_MS="" + +if [ -f "${RESULT_DIR}/master-switch-ms.txt" ]; then + SWITCH_MS="$(cat "${RESULT_DIR}/master-switch-ms.txt")" +fi + +FAILOVER_MS="" + +if [ -n "${SWITCH_MS}" ]; then + FAILOVER_MS=$(( SWITCH_MS - MASTER_STOP_MS )) +fi + +{ + echo "test_start=${TEST_START}" + echo "short_code=${SHORT_CODE}" + echo "vus=${VUS}" + echo "duration=${DURATION}" + + echo "master_before=${MASTER_BEFORE}" + echo "master_stopped=${MASTER_STOP_TIME}" + + echo "master_after=${MASTER_AFTER}" + + if [ -n "${FAILOVER_MS}" ]; then + echo "sentinel_failover_ms=${FAILOVER_MS}" + fi + + echo "old_master_restarted=${MASTER_RESTART_TIME}" + echo "test_end=${TEST_END}" + echo "k6_exit_code=${K6_EXIT_CODE}" + +} > "${RESULT_DIR}/timeline.txt" + +docker compose -f "${COMPOSE_FILE}" \ + logs sentinel-1 sentinel-2 sentinel-3 \ + > "${RESULT_DIR}/sentinel.log" + +echo +echo "======================================" +echo "Test Complete" +echo "======================================" + +cat "${RESULT_DIR}/timeline.txt" + +echo +echo "Result:" +echo "${RESULT_DIR}" \ No newline at end of file diff --git a/src/main/resources/application-sentinel.yml b/src/main/resources/application-sentinel.yml new file mode 100644 index 0000000..ff4a55c --- /dev/null +++ b/src/main/resources/application-sentinel.yml @@ -0,0 +1,16 @@ +spring: + config: + activate: + on-profile: sentinel + + data: + redis: + sentinel: + master: ${REDIS_SENTINEL_MASTER:url-shortener-master} + nodes: + - ${REDIS_SENTINEL_NODE_1:sentinel-1:26379} + - ${REDIS_SENTINEL_NODE_2:sentinel-2:26379} + - ${REDIS_SENTINEL_NODE_3:sentinel-3:26379} + + connect-timeout: 200ms + timeout: 200ms \ No newline at end of file