diff --git a/.gitignore b/.gitignore index 0f6b909..c235132 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,4 @@ out/ # Build and IDE .gradle/ .idea/ +results/multi-instance/ diff --git a/docker-compose.multi.yml b/docker-compose.multi.yml new file mode 100644 index 0000000..0163e4c --- /dev/null +++ b/docker-compose.multi.yml @@ -0,0 +1,194 @@ +x-app-environment: &app-environment + 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} + + SPRING_DATA_REDIS_HOST: redis + SPRING_DATA_REDIS_PORT: 6379 + 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: distributed + +x-app-common: &app-common + build: + context: . + dockerfile: Dockerfile + + image: url-shortener:local + + expose: + - "8080" + + depends_on: + mysql: + condition: service_healthy + redis: + 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 + +services: + app1: + <<: *app-common + + environment: + <<: *app-environment + SHORT_CODE_NODE_ID: "1" + + app2: + <<: *app-common + + environment: + <<: *app-environment + SHORT_CODE_NODE_ID: "2" + + nginx: + image: nginx:1.30.4-alpine + + ports: + - "${APP_PORT:-8080}:80" + + volumes: + - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro + + depends_on: + app1: + condition: service_healthy + app2: + condition: service_healthy + + healthcheck: + test: + [ + "CMD-SHELL", + "wget -qO- http://127.0.0.1/nginx-health | grep -q ok" + ] + interval: 5s + timeout: 3s + retries: 10 + + mysql: + image: mysql:8.4 + + environment: + MYSQL_DATABASE: ${DB_NAME:-url_shortener} + MYSQL_USER: ${DB_USERNAME:-url_shortener} + MYSQL_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD is required} + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:?MYSQL_ROOT_PASSWORD is required} + + ports: + - "${MYSQL_PORT:-3306}:3306" + + volumes: + - mysql-data-multi:/var/lib/mysql + + healthcheck: + test: + [ + "CMD-SHELL", + "mysqladmin ping -h localhost -u root -p$$MYSQL_ROOT_PASSWORD" + ] + interval: 5s + timeout: 3s + retries: 10 + + redis: + image: redis:7.4-alpine + + ports: + - "${REDIS_PORT:-6379}:6379" + + volumes: + - redis-data-multi:/data + + healthcheck: + test: + [ + "CMD", + "redis-cli", + "ping" + ] + interval: 5s + timeout: 3s + retries: 10 + + prometheus: + image: prom/prometheus:v3.13.0 + + ports: + - "${PROMETHEUS_PORT:-9090}:9090" + + volumes: + - ./monitoring/prometheus/prometheus-multi.yml:/etc/prometheus/prometheus.yml:ro + - prometheus-data-multi:/prometheus + + command: + - --config.file=/etc/prometheus/prometheus.yml + - --storage.tsdb.path=/prometheus + - --web.enable-lifecycle + + depends_on: + app1: + condition: service_healthy + app2: + 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 + + k6: + image: grafana/k6:2.1.0 + + profiles: + - test + + environment: + BASE_URL: http://nginx + + volumes: + - ./k6:/scripts:ro + + depends_on: + nginx: + condition: service_healthy + +volumes: + mysql-data-multi: + redis-data-multi: + prometheus-data-multi: + grafana-data: \ No newline at end of file diff --git a/docs/01-requirements.md b/docs/01-requirements.md index eb7ca6e..03db430 100644 --- a/docs/01-requirements.md +++ b/docs/01-requirements.md @@ -74,7 +74,8 @@ URL Shortener는 긴 URL을 짧은 코드로 변환하고, 단축 URL 요청이 - Redis 장애 중에도 리다이렉트 요청의 오류율을 1% 미만으로 유지한다. - Redis 복구 후 Cache Aside 조회 경로로 자동 복귀한다. - 캐시 데이터가 유실돼도 MySQL을 통해 원본 URL을 복구할 수 있어야 한다. -- 현재 구조의 단일 장애 지점은 Spring Boot, MySQL, Redis이다. +- 단일 App 장애 시 Nginx가 다른 App 인스턴스로 GET 요청을 전환한다. +- 현재 서비스 경로의 단일 장애 지점은 Nginx와 MySQL이며, Redis 장애는 MySQL Fallback으로 기능을 유지한다. ### 확장성 @@ -112,6 +113,8 @@ URL Shortener는 긴 URL을 짧은 코드로 변환하고, 단축 URL 요청이 * k6 부하 테스트 * 개선 전후 동일 조건 재측정 * Redis 장애 시 MySQL Fallback 실험 +* 다중 애플리케이션 인스턴스 및 Nginx Failover 실험 +* 다중 인스턴스 Snowflake nodeId 검증 ### 제외 @@ -137,3 +140,5 @@ URL Shortener는 긴 URL을 짧은 코드로 변환하고, 단축 URL 요청이 * [ ] 개선 전후의 p95, p99, RPS, DB 조회 수를 비교했다. * [ ] Redis 장애 시 대응 방법을 확인했다. * [ ] 코드 생성 전략별 장단점과 트레이드오프를 설명할 수 있다. +* [ ] 다중 인스턴스에서 서로 다른 nodeId로 단축 코드 유일성을 검증했다. +* [ ] 단일 App 장애 시 다른 인스턴스로 요청이 전환되는 것을 확인했다. \ No newline at end of file diff --git a/docs/03-architecture.md b/docs/03-architecture.md index 987af93..1110287 100644 --- a/docs/03-architecture.md +++ b/docs/03-architecture.md @@ -14,40 +14,55 @@ ```mermaid flowchart LR Client[Client] - API[Spring Boot API] + Nginx[Nginx] + + App1[Spring Boot App1
nodeId=1] + App2[Spring Boot App2
nodeId=2] + Redis[(Redis)] DB[(MySQL)] + Prometheus[Prometheus] Grafana[Grafana] k6[k6] - Client --> API - API --> Redis - API --> DB - Prometheus --> API + Client --> Nginx + k6 --> Nginx + + Nginx --> App1 + Nginx --> App2 + + App1 --> Redis + App2 --> Redis + + App1 --> DB + App2 --> DB + + Prometheus --> App1 + Prometheus --> App2 Grafana --> Prometheus - k6 --> API ``` -MySQL은 원본 데이터를 보관하는 Source of Truth이며, -Redis는 리다이렉트 조회 성능을 위한 보조 저장소로 사용한다. +Nginx가 클라이언트 요청을 두 개의 Spring Boot 인스턴스로 분산한다. + +두 애플리케이션은 상태를 저장하지 않으며 동일한 MySQL과 Redis를 사용한다. +MySQL은 원본 데이터를 보관하는 Source of Truth이고, +Redis는 리다이렉트 조회 성능을 위한 보조 저장소다. Redis Cache Miss 또는 연결 실패 시 MySQL을 조회한다. -```text -요구사항 정의 -→ MySQL 기반 구조 구현 -→ 부하 테스트 -→ 병목 분석 -→ 구조 개선 -→ 동일 조건 재측정 -``` +리다이렉트 GET 요청에서 특정 애플리케이션 연결에 실패하면 +Nginx가 다른 인스턴스로 요청을 재시도한다. + +POST 생성 요청은 처리 성공 여부가 불분명한 상태에서 재시도할 경우 +중복 생성 가능성이 있으므로 자동 재시도 대상으로 두지 않는다. ## 3. 주요 컴포넌트 | 컴포넌트 | 역할 | 확장 방법 | 장애 영향 | | ---------- | ----------------- | ------------------------------- | ----------- | -| API Server | URL 생성, 검증, 리다이렉트 | 무상태 서버 수평 확장 | 요청 처리 불가 | +| Nginx | 요청 분산 및 App 장애 시 Failover | Load Balancer 이중화 | 장애 시 외부 요청 진입 불가 | +| API Server | URL 생성, 검증, 리다이렉트 | App1·App2 무상태 수평 확장 | 단일 App 장애 시 다른 인스턴스가 처리 | | MySQL | 원본 URL 영구 저장 | Replica, Partitioning, Sharding | 생성 및 조회 불가 | | Prometheus | 애플리케이션 지표 수집 | 현재는 단일 인스턴스 | 성능 지표 수집 불가 | | Grafana | 성능 지표 시각화 | 현재는 단일 인스턴스 | 대시보드 조회 불가 | @@ -199,6 +214,23 @@ Redirect 요청이 계속 서버에 전달되므로 301보다 서버 부하가 초기 구현은 Sequence Base62로 진행하고, 이후 세 방식의 RPS, p95, DB 조회 수와 충돌 횟수를 비교한다. +### 다중 인스턴스에서의 Snowflake + +다중 인스턴스 환경에서는 각 애플리케이션에 서로 다른 nodeId를 할당했다. + +- App1: nodeId=1 +- App2: nodeId=2 + +애플리케이션 내부에서는 `synchronized`로 timestamp와 sequence 갱신을 보호하고, +서버 간 ID 충돌은 서로 다른 nodeId를 통해 방지한다. + +부하 테스트 중 Docker 환경에서 시스템 시간이 4~8ms 역행하는 +Clock Rollback을 확인했다. + +이전 timestamp로 ID를 생성하지 않고, +작은 시간 역행에서는 마지막 생성 시각까지 시계가 복구되기를 제한된 시간 동안 기다린다. +허용 범위를 초과하는 시간 역행은 ID 중복 위험을 막기 위해 실패 처리한다. + ## 9. 데이터 정합성 * 트랜잭션 범위: 원본 URL을 MySQL에 저장하는 단일 트랜잭션 @@ -213,7 +245,7 @@ Hash와 난수 방식에서는 `short_code`에 Unique Constraint를 적용해 | 장애 상황 | 영향 | 감지 방법 | 대응 방법 | | ------------- | -------------- | ----------------------- | ------------------ | -| API 서버 장애 | URL 생성 및 조회 불가 | Health Check, HTTP 오류율 | 서버 재시작, 향후 다중 인스턴스 | +| 단일 API 서버 장애 | 해당 인스턴스 처리 중단 | Health Check, Prometheus `up` | Nginx가 다른 App 인스턴스로 요청 전환 | | DB 장애 | URL 생성 및 조회 불가 | Connection 오류, Actuator | 503 반환, DB 복구 | | Prometheus 장애 | 지표 수집 불가 | Scrape 상태 | 컨테이너 재시작 | | Grafana 장애 | 대시보드 조회 불가 | 컨테이너 상태 | 컨테이너 재시작 | @@ -227,21 +259,34 @@ Redis 장애가 확인된 요청에서는 Redis SET을 생략해 Timeout이 중 이는 기능 지속을 위한 Graceful Degradation이며 Redis 자체의 고가용성을 구성한 것은 아니다. +App1 장애 시 Nginx가 App2를 통해 GET 리다이렉트 요청을 계속 처리한다. + +Failover 실험에서 App1을 강제로 중단했지만 +리다이렉트 요청 실패율 0%를 유지했고, +App1 복구 후 다시 요청 처리에 참여하는 것을 확인했다. + ## 11. 단일 장애 지점 -* 현재 존재하는 SPOF: Spring Boot 단일 인스턴스, MySQL, Redis -* 프로젝트 범위에서 허용한 이유: 로컬 환경에서 초기 구조의 병목을 확인하기 위한 실험이기 때문이다. -* 운영 환경에서의 개선 방법: Load Balancer, 다중 API 서버, MySQL Replica와 장애 조치 구성 +* Spring Boot는 App1과 App2로 구성해 단일 애플리케이션 장애 지점을 개선했다. +* Nginx는 현재 단일 인스턴스이므로 진입 지점의 SPOF로 남아 있다. +* MySQL은 단일 인스턴스로 구성되어 있어 장애 시 생성 및 조회가 불가능하다. +* Redis는 단일 인스턴스지만 장애 시 MySQL Fallback을 통해 리다이렉트 기능을 유지할 수 있다. + +Prometheus와 Grafana도 단일 인스턴스지만 +서비스 요청 처리에는 직접적인 영향을 주지 않는다. -Prometheus와 Grafana도 단일 인스턴스지만 서비스 요청 처리에는 직접적인 영향을 주지 않는다. +운영 환경에서는 Load Balancer 이중화, MySQL Replica와 장애 조치, +Redis Sentinel 또는 Cluster 등을 추가로 고려할 수 있다. ## 12. 확장 전략 ### 애플리케이션 확장 -* 여러 Spring Boot 인스턴스를 Load Balancer 뒤에 배치한다. +* Nginx 뒤에 두 개의 Spring Boot 인스턴스를 배치했다. * 애플리케이션 서버에는 세션이나 URL 상태를 저장하지 않는다. -* 다중 인스턴스 구성은 핵심 실험 이후 검토한다. +* App1과 App2는 동일한 MySQL과 Redis를 사용한다. +* Snowflake 사용 시 각 인스턴스에 서로 다른 nodeId를 할당한다. +* 단일 App 장애 시 Nginx를 통해 다른 인스턴스로 GET 요청을 전환한다. ### 데이터베이스 확장 diff --git a/docs/04-experiment.md b/docs/04-experiment.md index dc7fb97..d6ce463 100644 --- a/docs/04-experiment.md +++ b/docs/04-experiment.md @@ -546,8 +546,81 @@ Redis 복구 후 Cache Aside 경로로 자동 전환되는 것을 확인했다. ![Redis 장애 캐시 및 DB 지표](images/redis-fallback-100vu-cache-db.png) +## 18. 다중 인스턴스 및 Failover -## 18. 실험 한계 +단일 애플리케이션 장애가 전체 서비스 장애로 이어지는 문제를 줄이기 위해 +App 인스턴스를 2개로 확장하고 Nginx를 통해 요청을 분산했다. + +### Snowflake 다중 인스턴스 + +각 인스턴스에 서로 다른 Snowflake nodeId를 할당했다. + +| 인스턴스 | nodeId | +|---|---:| +| App1 | 1 | +| App2 | 2 | + +동시 생성 테스트에서 Nginx를 통해 두 인스턴스에 요청이 거의 동일하게 분산됐으며, +생성된 shortCode의 중복 여부를 검증했다. + +테스트 과정에서 Snowflake 내부 동시성 문제가 아닌 +시스템 Clock Rollback도 확인했다. + +```text +App1: backwardMillis=8 +App2: backwardMillis=4 +``` + +이에 작은 시간 역행에서는 이전 timestamp로 ID를 생성하지 않고 +시계가 마지막 생성 시각까지 복구되기를 기다리도록 처리했다. +큰 시간 역행은 ID 중복 위험을 막기 위해 실패 처리한다. + +### 애플리케이션 Failover + +100 VU의 리다이렉트 요청을 지속하면서 App1을 강제로 중지한 뒤 다시 실행했다. + +| 항목 | 조건 | +|---|---| +| VU | 100 | +| 실행 시간 | 120초 | +| 정상 구간 | 0~30초 | +| App1 중지 | 30~60초 | +| App1 재시작 | 60초 | +| 요청 | GET Redirect | + +### 결과 + +| 지표 | 결과 | +|---|---:| +| 요청 수 | 446,850 | +| 평균 RPS | 3,723.29 | +| 평균 응답 시간 | 26.68ms | +| p95 | 67.40ms | +| 최대 응답 시간 | 2,922.56ms | +| 실패율 | 0% | +| Check 성공률 | 100% | + +App1 중지 후 Prometheus의 `up` 값이 0으로 변경됐으며, +App2가 단독으로 리다이렉트 요청을 처리했다. + +App1 장애 중에도 전체 요청의 실패율은 0%를 유지했다. +App1 재기동 후 Healthy 상태로 복구됐으며 +다시 요청 처리에 참여하는 것도 확인했다. + +#### Grafana 측정 결과 + +![다중 인스턴스 Failover](images/multi-instance-failover.png) + +단일 애플리케이션 구조에서는 App 장애가 전체 요청 처리 불가로 이어질 수 있지만, +두 개의 App 인스턴스와 Nginx를 구성한 뒤에는 +한 인스턴스가 중단되어도 다른 인스턴스가 요청을 계속 처리했다. + +다만 Nginx, MySQL은 여전히 단일 인스턴스이므로 +시스템 전체의 SPOF를 제거한 것은 아니다. +이번 실험은 애플리케이션 계층의 단일 장애 지점을 개선하는 데 범위를 한정한다. + + +## 19. 실험 한계 - 로컬 Docker 환경에서 실행했다. - k6, 애플리케이션, MySQL, Redis가 같은 장비의 자원을 사용했다. @@ -559,11 +632,11 @@ Redis 복구 후 Cache Aside 경로로 자동 전환되는 것을 확인했다. - Stress Test의 k6 최종 결과는 모든 VU 구간을 합산한 값이므로, 특정 VU 구간의 값은 Grafana 시계열을 통해 판단했다. - Redis Stress Test의 처리량 한계가 애플리케이션, Redis 또는 로컬 환경 중 어디에서 발생했는지는 추가로 분리하지 않았다. - 단축 코드 생성 전략도 조건별 한 번만 측정해 Sequence와 Snowflake의 작은 차이가 실행 환경의 변동인지 확인하지 못했다. -- Snowflake 전략은 단일 애플리케이션 인스턴스에서만 실행했으며, 서로 다른 nodeId를 사용하는 다중 인스턴스 환경은 검증하지 않았다. - Redis 장애 실험은 프로세스 중지만 재현했으며 네트워크 지연과 패킷 손실은 검증하지 않았다. - Redis 장애 중 더 높은 부하에서는 MySQL과 커넥션 풀이 포화될 수 있다. +- 다중 인스턴스 실험은 로컬 Docker 환경에서 App 2개와 Nginx 1개로 수행했으며, 실제 독립 서버 장애를 재현한 것은 아니다. -## 19. 후속 실험 +## 20. 후속 실험 - [x] Redis Cache Aside 적용 - [x] Redis 적용 전후 부하 테스트 @@ -574,6 +647,6 @@ Redis 복구 후 Cache Aside 경로로 자동 전환되는 것을 확인했다. - [x] 더 높은 VU로 Stress Test 수행 - [x] Sequence ID + Base62, Hash, Snowflake ID + Base62 비교 - [x] Redis 장애 시 MySQL Fallback 및 자동 복구 검증 +- [x] 다중 애플리케이션 인스턴스와 장애 전환 검증 - [ ] Circuit Breaker를 통한 Redis 장애 구간 Timeout 감소 - [ ] Redis Sentinel 또는 Cluster 기반 고가용성 구성 -- [ ] 다중 애플리케이션 인스턴스와 장애 전환 검증 \ No newline at end of file diff --git a/docs/images/multi-instance-failover.png b/docs/images/multi-instance-failover.png new file mode 100644 index 0000000..f21d2e9 Binary files /dev/null and b/docs/images/multi-instance-failover.png differ diff --git a/k6/multi-instance-failover-test.js b/k6/multi-instance-failover-test.js new file mode 100644 index 0000000..d026a89 --- /dev/null +++ b/k6/multi-instance-failover-test.js @@ -0,0 +1,48 @@ +import http from 'k6/http'; +import { check } from 'k6'; +import { Counter } from 'k6/metrics'; + +const BASE_URL = __ENV.BASE_URL || 'http://localhost:8080'; +const SHORT_CODE = __ENV.SHORT_CODE; + +if (!SHORT_CODE) { + throw new Error('SHORT_CODE environment variable is required'); +} + +const upstreamRetry = new Counter('nginx_upstream_retry'); + +export const options = { + vus: Number(__ENV.VUS || 100), + duration: __ENV.DURATION || '120s', + + thresholds: { + http_req_failed: ['rate<0.01'], + checks: ['rate>0.99'], + }, +}; + +export default function () { + const response = http.get( + `${BASE_URL}/api/v1/${SHORT_CODE}`, + { + redirects: 0, + tags: { + experiment: 'app-failover', + }, + } + ); + + check(response, { + 'redirect status is 302': (r) => r.status === 302, + 'location exists': (r) => + typeof r.headers.Location === 'string' && + r.headers.Location.length > 0, + }); + + const upstream = response.headers['X-Upstream-Addr']; + + // Nginx가 첫 upstream 실패 후 다른 upstream으로 재시도한 경우 + if (upstream && upstream.includes(',')) { + upstreamRetry.add(1); + } +} \ No newline at end of file diff --git a/monitoring/grafana/dashboards/spring-boot-overview.json b/monitoring/grafana/dashboards/spring-boot-overview.json index 53eac58..3e88536 100644 --- a/monitoring/grafana/dashboards/spring-boot-overview.json +++ b/monitoring/grafana/dashboards/spring-boot-overview.json @@ -4,24 +4,7 @@ "metadata": { "name": "spring-boot-overview", "namespace": "default", - "uid": "9f5e1718-e0de-4b16-82e0-fb1fdb5f47b9", - "resourceVersion": "1786000355142018", - "generation": 1, - "creationTimestamp": "2026-08-06T07:12:35Z", - "labels": { - "grafana.app/deprecatedInternalID": "96116541288448" - }, - "annotations": { - "grafana.app/createdBy": "access-policy:service", - "grafana.app/folder": "cfu8feuqxi7lsb", - "grafana.app/managedBy": "classic-file-provisioning", - "grafana.app/managerId": "System Design Dashboards", - "grafana.app/sourceChecksum": "0737391e824d48cce503bc64fcc70134", - "grafana.app/sourcePath": "/var/lib/grafana/dashboards/spring-boot-overview.json", - "grafana.app/sourceTimestamp": "1786000349000", - "grafana.app/folderTitle": "System Design", - "grafana.app/folderUrl": "/dashboards/f/cfu8feuqxi7lsb/system-design" - } + "uid": "9f5e1718-e0de-4b16-82e0-fb1fdb5f47b9" }, "spec": { "annotations": [ @@ -2079,4 +2062,4 @@ "title": "Spring Boot Overview", "variables": [] } -} \ No newline at end of file +} diff --git a/monitoring/grafana/provisioning/dashboards/dashboard.yml b/monitoring/grafana/provisioning/dashboards/dashboard.yml index e79abc5..ad90921 100644 --- a/monitoring/grafana/provisioning/dashboards/dashboard.yml +++ b/monitoring/grafana/provisioning/dashboards/dashboard.yml @@ -3,11 +3,15 @@ apiVersion: 1 providers: - name: System Design Dashboards orgId: 1 + folder: System Design + folderUid: system-design + type: file disableDeletion: false updateIntervalSeconds: 30 allowUiUpdates: false + options: path: /var/lib/grafana/dashboards foldersFromFilesStructure: false \ No newline at end of file diff --git a/monitoring/prometheus/prometheus-multi.yml b/monitoring/prometheus/prometheus-multi.yml new file mode 100644 index 0000000..0236bdf --- /dev/null +++ b/monitoring/prometheus/prometheus-multi.yml @@ -0,0 +1,23 @@ +global: + scrape_interval: 5s + evaluation_interval: 5s + +scrape_configs: + - job_name: spring-boot + metrics_path: /actuator/prometheus + scrape_interval: 5s + + static_configs: + - targets: + - app1:8080 + + labels: + environment: local-multi + app_instance: app1 + + - targets: + - app2:8080 + + labels: + environment: local-multi + app_instance: app2 \ No newline at end of file diff --git a/nginx/nginx.conf b/nginx/nginx.conf new file mode 100644 index 0000000..032b28a --- /dev/null +++ b/nginx/nginx.conf @@ -0,0 +1,61 @@ +worker_processes auto; + +events { + worker_connections 1024; +} + +http { + log_format upstream_log + '$remote_addr ' + '"$request" ' + 'status=$status ' + 'upstream=$upstream_addr ' + 'upstream_status=$upstream_status ' + 'request_time=$request_time ' + 'upstream_time=$upstream_response_time'; + + upstream url_shortener { + server app1:8080 max_fails=1 fail_timeout=5s; + server app2:8080 max_fails=1 fail_timeout=5s; + + keepalive 32; + } + + server { + listen 80; + + access_log /var/log/nginx/access.log upstream_log; + + location = /nginx-health { + access_log off; + default_type text/plain; + return 200 "ok\n"; + } + + location / { + proxy_pass http://url_shortener; + + proxy_http_version 1.1; + proxy_set_header Connection ""; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + proxy_connect_timeout 1s; + proxy_read_timeout 5s; + + proxy_next_upstream + error + timeout + http_502 + http_503 + http_504; + + proxy_next_upstream_tries 2; + + add_header X-Upstream-Addr $upstream_addr always; + } + } +} \ No newline at end of file diff --git a/scripts/run-app-failover-test.sh b/scripts/run-app-failover-test.sh new file mode 100755 index 0000000..a5f665b --- /dev/null +++ b/scripts/run-app-failover-test.sh @@ -0,0 +1,218 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +COMPOSE_FILE="${ROOT_DIR}/docker-compose.multi.yml" + +BASE_URL="${BASE_URL:-http://localhost:8080}" +VUS="${VUS:-100}" +DURATION="${DURATION:-120s}" + +RESULT_DIR="${ROOT_DIR}/results/multi-instance/failover" +RUN_ID="$(date +%Y%m%d-%H%M%S)" +RUN_DIR="${RESULT_DIR}/${RUN_ID}" + +mkdir -p "${RUN_DIR}" + +cleanup() { + echo + echo "[cleanup] Ensuring app1 is running..." + + docker compose \ + -f "${COMPOSE_FILE}" \ + start app1 >/dev/null 2>&1 || true +} + +trap cleanup EXIT + +sleep_until() { + local target="$1" + + while (( SECONDS < target )); do + sleep 1 + done +} + +wait_for_app1() { + echo "Waiting for app1 health..." + + for _ in {1..60}; do + container_id="$( + docker compose \ + -f "${COMPOSE_FILE}" \ + ps -q app1 + )" + + if [[ -n "${container_id}" ]]; then + health="$( + docker inspect \ + --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' \ + "${container_id}" 2>/dev/null || true + )" + + if [[ "${health}" == "healthy" ]]; then + echo "app1 is healthy." + return 0 + fi + fi + + sleep 1 + done + + echo "ERROR: app1 did not become healthy." + return 1 +} + +echo "========================================" +echo "Multi-instance failover test" +echo "========================================" +echo "VU : ${VUS}" +echo "Duration : ${DURATION}" +echo "Result : ${RUN_DIR}" +echo + +echo "[1/7] Checking containers" + +docker compose \ + -f "${COMPOSE_FILE}" \ + ps + +echo +echo "[2/7] Creating test URL" + +response="$( + curl -fsS \ + -X POST \ + "${BASE_URL}/api/v1/data/shorten" \ + -H 'Content-Type: application/json' \ + -d "{ + \"longUrl\": + \"https://example.com/failover/${RUN_ID}\" + }" +)" + +SHORT_CODE="$(echo "${response}" | jq -r '.shortCode')" + +if [[ -z "${SHORT_CODE}" || "${SHORT_CODE}" == "null" ]]; then + echo "ERROR: shortCode was not created." + exit 1 +fi + +echo "shortCode=${SHORT_CODE}" + +echo +echo "[3/7] Warming Redis cache" + +status="$( + curl -s \ + -o /dev/null \ + -w '%{http_code}' \ + "${BASE_URL}/api/v1/${SHORT_CODE}" +)" + +if [[ "${status}" != "302" ]]; then + echo "ERROR: warm-up request failed. status=${status}" + exit 1 +fi + +echo "Warm-up succeeded." + +cat > "${RUN_DIR}/timeline.txt" < "${RUN_DIR}/k6.log" 2>&1 & + +K6_PID=$! + +echo +echo "===== Phase 1: both apps healthy =====" + +sleep_until 30 + +echo "app1_stopped=$(date -Iseconds)" \ + >> "${RUN_DIR}/timeline.txt" + +echo +echo "[5/7] Stopping app1" + +docker compose \ + -f "${COMPOSE_FILE}" \ + stop app1 + +echo +echo "===== Phase 2: app1 down =====" + +sleep_until 60 + +echo "app1_started=$(date -Iseconds)" \ + >> "${RUN_DIR}/timeline.txt" + +echo +echo "[6/7] Starting app1" + +docker compose \ + -f "${COMPOSE_FILE}" \ + start app1 + +wait_for_app1 + +echo "app1_healthy=$(date -Iseconds)" \ + >> "${RUN_DIR}/timeline.txt" + +echo +echo "===== Phase 3: app1 recovered =====" + +sleep_until 120 + +set +e +wait "${K6_PID}" +K6_STATUS=$? +set -e + +echo "test_end=$(date -Iseconds)" \ + >> "${RUN_DIR}/timeline.txt" + +docker compose \ + -f "${COMPOSE_FILE}" \ + logs --no-color nginx \ + --since=5m \ + > "${RUN_DIR}/nginx.log" + +echo +echo "[7/7] Result" + +cat "${RUN_DIR}/timeline.txt" + +echo +jq '{ + requests: .metrics.http_reqs.count, + rps: (.metrics.http_reqs.rate * 100 | round / 100), + average_ms: (.metrics.http_req_duration.avg * 100 | round / 100), + p95_ms: (.metrics.http_req_duration["p(95)"] * 100 | round / 100), + max_ms: (.metrics.http_req_duration.max * 100 | round / 100), + failure_rate_percent: (.metrics.http_req_failed.value * 100), + check_success_rate_percent: (.metrics.checks.value * 100) +}' "${RUN_DIR}/summary.json" + +echo +echo "Result directory:" +echo "${RUN_DIR}" + +exit "${K6_STATUS}" \ No newline at end of file diff --git a/scripts/run-multi-instance-create-test.sh b/scripts/run-multi-instance-create-test.sh new file mode 100755 index 0000000..8c2c132 --- /dev/null +++ b/scripts/run-multi-instance-create-test.sh @@ -0,0 +1,182 @@ +#!/usr/bin/env bash + +set -euo pipefail + +BASE_URL="${BASE_URL:-http://localhost:8080}" +REQUESTS="${REQUESTS:-2000}" +CONCURRENCY="${CONCURRENCY:-50}" + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +RESULT_DIR="${ROOT_DIR}/results/multi-instance/create" +RUN_ID="$(date +%Y%m%d-%H%M%S)" +RUN_DIR="${RESULT_DIR}/${RUN_ID}" + +mkdir -p "${RUN_DIR}/responses" + +echo "========================================" +echo "Multi-instance Snowflake create test" +echo "========================================" +echo "Base URL : ${BASE_URL}" +echo "Requests : ${REQUESTS}" +echo "Concurrency : ${CONCURRENCY}" +echo "Result dir : ${RUN_DIR}" +echo + +echo "[1/5] Nginx health check" + +curl -fsS "${BASE_URL}/nginx-health" >/dev/null + +echo "Nginx is healthy." +echo + +echo "[2/5] Application environment" + +docker compose -f "${ROOT_DIR}/docker-compose.multi.yml" \ + exec -T app1 sh -lc ' + echo "app1 strategy=$SHORT_CODE_STRATEGY nodeId=$SHORT_CODE_NODE_ID" + ' + +docker compose -f "${ROOT_DIR}/docker-compose.multi.yml" \ + exec -T app2 sh -lc ' + echo "app2 strategy=$SHORT_CODE_STRATEGY nodeId=$SHORT_CODE_NODE_ID" + ' + +echo +echo "[3/5] Sending concurrent create requests" + +export BASE_URL +export RUN_DIR +export RUN_ID + +seq 1 "${REQUESTS}" | + xargs -P "${CONCURRENCY}" -I {} \ + sh -c ' + index="$1" + + curl -sS \ + -D "${RUN_DIR}/responses/${index}.headers" \ + -o "${RUN_DIR}/responses/${index}.json" \ + -w "%{http_code}\n" \ + -X POST "${BASE_URL}/api/v1/data/shorten" \ + -H "Content-Type: application/json" \ + -d "{ + \"longUrl\": + \"https://example.com/multi-instance/${RUN_ID}/${index}\" + }" \ + > "${RUN_DIR}/responses/${index}.status" \ + || echo "curl_error" \ + > "${RUN_DIR}/responses/${index}.status" + ' _ {} + +echo "Requests completed." +echo + +echo "[4/5] Aggregating results" + +find "${RUN_DIR}/responses" \ + -name '*.status' \ + -print0 | + xargs -0 cat | + sort | + uniq -c | + awk '{$1=$1; print}' \ + > "${RUN_DIR}/status-counts.txt" + +find "${RUN_DIR}/responses" \ + -name '*.headers' \ + -print0 | + xargs -0 grep -hi '^X-Upstream-Addr:' | + sed 's/\r$//' | + awk '{print $2}' | + sort | + uniq -c | + awk '{$1=$1; print}' \ + > "${RUN_DIR}/upstream-counts.txt" + +find "${RUN_DIR}/responses" \ + -name '*.json' \ + -print0 | + xargs -0 -n1 jq -r \ + 'if .shortCode then .shortCode else empty end' \ + > "${RUN_DIR}/short-codes.txt" + +TOTAL_CODES="$( + wc -l < "${RUN_DIR}/short-codes.txt" | + tr -d ' ' +)" + +UNIQUE_CODES="$( + sort -u "${RUN_DIR}/short-codes.txt" | + wc -l | + tr -d ' ' +)" + +DUPLICATE_CODES="$((TOTAL_CODES - UNIQUE_CODES))" + +SUCCESS_COUNT="$( + awk ' + $2 ~ /^20[0-9]$/ { + total += $1 + } + END { + print total + 0 + } + ' "${RUN_DIR}/status-counts.txt" +)" + +FAILED_COUNT="$((REQUESTS - SUCCESS_COUNT))" + +cat > "${RUN_DIR}/summary.txt" < MAX_NODE_ID) { throw new IllegalArgumentException("nodeId는 0 이상 " + MAX_NODE_ID + " 이하여야 합니다."); @@ -43,8 +47,19 @@ public synchronized long nextId() { // 서버가 여러 대라면 서버 간 중복은 서로 다른 nodeId로 방지 long currentTimestamp = currentTimeMillis(); - if (currentTimestamp < lastTimestamp) { // 원인 : NTP 시간 보정, 가상머신 시간 변경, 서버 시간 수동 변경 - throw new IllegalStateException("시스템 시간이 이전 시각으로 이동했습니다."); + if (currentTimestamp < lastTimestamp) { + long backwardMillis = lastTimestamp - currentTimestamp; + + if (backwardMillis > MAX_CLOCK_BACKWARD_MILLIS) { + throw new IllegalStateException( + "허용 범위를 초과해 시스템 시간이 이전 시각으로 이동했습니다. " + + "backwardMillis=" + backwardMillis + + ", lastTimestamp=" + lastTimestamp + + ", currentTimestamp=" + currentTimestamp + ); + } + + currentTimestamp = waitUntilRecovered(lastTimestamp); } if (currentTimestamp == lastTimestamp) { // 같은 밀리초 안에서 여러 ID를 만들고 있다 @@ -69,6 +84,30 @@ public synchronized long nextId() { return timeStampPart | nodePart | sequence; } + private long waitUntilRecovered(long targetTimestamp) { + long deadlineNanos = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(MAX_CLOCK_BACKWARD_MILLIS); + long currentTimestamp = currentTimeMillis(); + + while (currentTimestamp < targetTimestamp) { + if (System.nanoTime() >= deadlineNanos) { + long backwardMillis = targetTimestamp - currentTimestamp; + + throw new IllegalStateException( + "시스템 시간이 제한 시간 내 복구되지 않았습니다. " + + "backwardMillis=" + backwardMillis + + ", lastTimestamp=" + targetTimestamp + + ", currentTimestamp=" + currentTimestamp + ); + } + + LockSupport.parkNanos(100_000L); + + currentTimestamp = currentTimeMillis(); + } + + return currentTimestamp; + } + private long waitUntilNextMillis(long timestamp) { long currentTimestamp = currentTimeMillis(); diff --git a/src/test/java/com/backendsystemdesignlab/urlshortener/generator/SnowflakeIdGeneratorTest.java b/src/test/java/com/backendsystemdesignlab/urlshortener/generator/SnowflakeIdGeneratorTest.java index 4ea8e4f..76a421b 100644 --- a/src/test/java/com/backendsystemdesignlab/urlshortener/generator/SnowflakeIdGeneratorTest.java +++ b/src/test/java/com/backendsystemdesignlab/urlshortener/generator/SnowflakeIdGeneratorTest.java @@ -2,9 +2,7 @@ import org.junit.jupiter.api.Test; -import java.util.HashSet; -import java.util.List; -import java.util.Set; +import java.util.*; import java.util.stream.IntStream; import static org.assertj.core.api.Assertions.assertThat; @@ -68,4 +66,37 @@ class SnowflakeIdGeneratorTest { () -> new SnowflakeIdGenerator(1024L) ).isInstanceOf(IllegalArgumentException.class); } + + @Test + void 작은_시간_역행은_복구를_기다린다() { + SnowflakeIdGenerator generator = new SnowflakeIdGenerator(1L) { + private final Queue times = new ArrayDeque<>(List.of(1_000L, 995L, 998L, 1_000L)); + + @Override + protected long currentTimeMillis() { + return times.remove(); + } + }; + + long first = generator.nextId(); + long second = generator.nextId(); + + assertNotEquals(first, second); + } + + @Test + void 큰_시간_역행은_ID_생성을_중단한다() { + SnowflakeIdGenerator generator = new SnowflakeIdGenerator(1L) { + private final Queue times = new ArrayDeque<>(List.of(1_000L, 900L)); + + @Override + protected long currentTimeMillis() { + return times.remove(); + } + }; + + generator.nextId(); + + assertThrows(IllegalStateException.class, generator::nextId); + } } \ No newline at end of file