diff --git a/docs/decisions/failover-app-reconnect.md b/docs/decisions/failover-app-reconnect.md new file mode 100644 index 00000000..a9e13f79 --- /dev/null +++ b/docs/decisions/failover-app-reconnect.md @@ -0,0 +1,98 @@ +# DB 페일오버 때 앱이 새 DB 로 넘어가는 방법 — 측정 전 후보 정리 + +작성일: 2026-11-08 +상태: 🟢 **측정 끝(11-08, 결정 로그 마지막 줄).** 잴 것·rig 세부 확정. 2026-11-08 사용자 confirm — 후보 A + B, 흉내 K1 + K2. 운영 채택 문서가 아니다. +연관: [`./rpo-rto-targets.md`](./rpo-rto-targets.md) §5 구성 3 · §7 · §8 R1 · +[`../../loadtest/results/replica-promotion-2026-11-08/README.md`](../../loadtest/results/replica-promotion-2026-11-08/README.md) (DB 쪽 승격 0.25~8초) · +[`./replication-lag-and-semisync.md`](./replication-lag-and-semisync.md) + +--- + +## 0. 왜 이 문서인가 + +11-08 승격 실험은 **DB 가 다시 쓰기를 받기까지**만 쟀다(밀림 없으면 약 0.25초, 약 21초 밀리면 약 7.8초). +사용자가 겪는 시간은 그보다 길다: + +``` +원본 DB 죽음 ─▶ ① 알아챔 ─▶ ② 승격 결정 ─▶ ③ 승격 (잼: 0.25~8초) ─▶ ④ 앱 연결을 새 DB 로 ─▶ ⑤ 앱이 실제로 다시 씀 +``` + +④·⑤ 가 비어 있다. 그리고 승격 실험은 원본·리플리카가 **한 박스**라 «못 받은 데이터» 가 0 이었다 — 두 박스에서 얼마를 잃는지도 비어 있다. + +🔴 **범위 메모**: `rpo-rto-targets.md` §7 은 «자동 페일오버 — 리플리카(3) 채택 전엔 의미 없다» 로 안 하기로 했고, +리플리카는 보류 상태다. 이 문서는 **재는 것**이지 페일오버를 운영에 넣자는 게 아니다. 측정 결과는 Q5(리플리카를 둘지)의 근거로만 쓴다. + +## 1. 지금 앱이 DB 에 붙는 방식 (바꿀 대상) + +- `application.yml:43` — `jdbc:mysql://${DB_HOST:shadowfit-mysql}:3306/...` — 호스트 이름 하나. 운영 compose 는 `DB_HOST: shadowfit-mysql`(`docker-compose.prod.yml:95`) +- HikariCP `maximum-pool-size: 15` · `connection-timeout: 30000`(30초). `max-lifetime` 은 기본값 +- Connector/J `connectTimeout`·`socketTimeout` 은 **지정 안 함**(기본값) + +→ 지금은 DB 주소가 바뀌면 **설정을 고쳐 앱을 다시 띄우는 것 말고는 방법이 없다.** + +## 2. 후보 — 앱 연결을 새 DB 로 바꾸는 방법 (④) + +| # | 방법 | 앱 쪽 변경 | 전환 때 하는 일 | 예상되는 함정 (재서 확인할 것) | 운영 부담 | +|---|---|---|---|---|---| +| **A** | **주소 바꾸고 앱 재시작** | 없음 | `DB_HOST` 를 새 DB 로 바꾸고 백엔드 컨테이너 재시작 | 재시작 동안 API 전체가 멈춘다. 그 시간 = Spring 기동 시간(안 쟀다) | 없음. 사람이 해야 함 | +| **B** | **DNS 이름 바꾸기** (예: Route 53 사설 영역 `db.internal`) | `DB_HOST` 를 DNS 이름으로 | 레코드를 새 DB IP 로 | **JVM 이 DNS 결과를 캐시한다** — 레코드 TTL 과 JVM 캐시가 겹쳐 «바꿨는데 옛 주소로 붙는» 시간이 생긴다. 이 길이가 측정 대상 | Route 53 사설 영역 1개 | +| **C** | **사설 IP 옮기기** (EC2 보조 사설 IP 를 새 DB 로 재할당) | `DB_HOST` 를 그 IP 로 | `assign-private-ip-addresses --allow-reassignment` | DNS 캐시는 없다. 대신 **AWS 전용**이고 같은 서브넷(같은 AZ)이어야 한다 — 다른 AZ 로 못 넘긴다 | IAM 권한 · 같은 AZ 제약 | +| **D** | **프록시** (ProxySQL · MySQL Router) | `DB_HOST` 를 프록시로 | 프록시가 백엔드를 바꿈 (자동 가능) | 프록시 자체가 새 단일 장애점. 홉 하나 추가 | 프록시 서버·설정 상시. 🔴 `db-portfolio-roadmap.md:200` 이 «read replica/CQRS 풀구현» 을 오버엔지니어링으로 닫았다(읽기 라우팅 ProxySQL 도 같은 이유로 안 함 — `replication-lag-and-semisync.md:548`) | +| **E** | **JDBC 다중 호스트 URL** (`jdbc:mysql://원본,리플리카/...`) | URL 한 줄 | 없음 — 드라이버가 첫 호스트 실패 시 다음으로 | Connector/J 는 넘어간 연결을 **기본적으로 읽기 전용**으로 연다고 알고 있다(`failOverReadOnly`, 확인 필요). 원본이 살아나면 그쪽으로 되돌아가는 문제(갈라진 두 DB 에 쓰기) | 없음. 대신 위험이 앱 설정 안에 숨는다 | +| F | 매니지드 (RDS Multi-AZ) | — | — | — | 🚫 범위 밖 — `rpo-rto-targets.md` §7 «매니지드는 다른 판단» | + +## 3. 장애를 어떻게 흉내 내나 — 이게 결과를 가장 크게 바꾼다 + +| 흉내 | 앱이 보는 것 | 비고 | +|---|---|---| +| **K1. 프로세스 죽이기** (`docker kill`) | OS 가 살아 있어 연결이 **바로 끊겼다는 응답(RST)** 을 받는다 → 빨리 실패 | 11-08 승격 실험이 쓴 방식. 실제 호스트 상실보다 낙관적 | +| **K2. 네트워크 끊기** (보안 그룹·iptables 로 원본으로 가는 패킷 버리기) | **아무 응답도 없다** → 열린 연결의 쿼리가 타임아웃까지 매달린다. `socketTimeout` 이 기본값(무제한)이면 TCP 재전송 한도까지 갈 수 있다(추정, 확인 필요). 새 연결은 Hikari `connection-timeout` 30초에 걸린다 | 호스트 상실·AZ 장애에 더 가깝다 | + +→ **같은 후보라도 K1 에선 몇 초, K2 에선 수십 초~분일 수 있다**(추정). 그래서 K2 를 빼면 «앱 전환 N초» 를 인용할 수 없다고 본다. +K2 에서 시간이 길게 나오면, 그 다음 질문은 «`socketTimeout`·`connectTimeout` 을 얼마로 둘까» 인데 — 그 값은 이 측정이 근거가 된다(지금 정하지 않는다). + +## 4. 무엇을 재나 (후보와 무관하게 공통) + +- **무대**: EC2 3대 — 원본 DB · 리플리카 DB · 백엔드 + 부하 발생기. 원본·리플리카를 **다른 박스**로 나눠 «못 받아서 잃은 양» 을 같이 잰다 +- **부하**: 운동 기록 저장 같은 쓰기 API 를 일정 속도로 계속 부른다(문서 §8-1 가정 피크 — 초당 45행). 요청마다 «보낸 시각 · 응답 시각 · 성공 여부» 를 남긴다 +- **결과 칸**: 죽인 시각 → 첫 실패 · 마지막 실패 · **첫 성공까지 걸린 시간**, 그 사이 실패 요청 수, 매달린 요청의 최대 응답 시간, + 성공 응답을 받았는데 새 DB 에 없는 쓰기 수(= 잃은 양) +- **①② (알아채기·결정)는 고정값으로 뺀다** — 사람이 하면 사람 시간이고, 자동으로 하면 감시 주기다. 이 실험은 «승격 명령을 내린 뒤» 부터를 잰다. + 안 하면 결과가 «사람이 몇 초 만에 봤나» 에 묻힌다 +- 반복: 후보 × 흉내마다 버림판 1 + 본판 3 이상, 순서는 섞는다 + +## 5. 추천 (Claude) — 결정은 사용자 + +- **A + B, 흉내는 K1·K2 둘 다.** + - A 는 이 규모에서 **실제로 하게 될 방법**이라 기준선으로 필요하다. 남은 빈칸이 «Spring 재기동 시간» 하나라 싸다 + - B 는 업계에서 가장 흔한 방식(매니지드 DB 도 DNS 를 바꾼다)이고, **JVM DNS 캐시라는 앱 쪽 함정**이 결과에 드러난다 — 면접에서 «페일오버 어떻게 하나요» 에 «DNS 를 바꾸면 끝» 이 아니라 그 뒤 몇 초가 어디서 나오는지 말할 수 있다 + - K2 는 §3 이유로 빼면 안 된다 +- C 는 같은 AZ 제약 때문에 AZ 장애엔 못 쓴다 — 추천 안 함. D 는 로드맵이 닫은 방향과 부딪힌다. E 는 읽기 전용·되돌아감 위험을 앱 설정 안에 숨겨서, 재더라도 «이래서 안 쓴다» 를 보이는 용도다 +- 규모: 2 후보 × 2 흉내 × 4판 = 16판. EC2 3대로 한 라운드(시간은 rig 를 만든 뒤 로컬 스모크로 추정) + +## 6. 결정할 것 + +- [x] **Q1. 잴 후보** → 🟢 **A + B** (2026-11-08 사용자 confirm) +- [x] **Q2. 장애 흉내** → 🟢 **K1 + K2** (2026-11-08 사용자 confirm) +- [ ] **Q3. 범위** — §0 메모대로 «측정만, 운영 채택 아님» 으로 둘지. 명시 답은 아직 없다 — 그동안 `rpo-rto-targets.md` §7 «자동 페일오버 안 함» 은 그대로 둔다 + +### 6-1. rig 세부 — ✅ 2026-11-08 사용자 confirm (R-a 앱 박스 DNS · R-b·R-c 추천대로) + +| | 후보 | 추천 | 걸리는 것 | +|---|---|---|---| +| R-a | B 의 DNS 를 무엇으로 | Route 53 사설 영역 → 🟢 **채택은 앱 박스 DNS(dnsmasq)** | 측정용 IAM 사용자에 Route 53 권한이 **없다**(11-08 `AccessDenied` 확인) — 사용자가 콘솔에서 붙여야 한다. 대안은 앱 박스에 작은 DNS 를 띄우는 것(권한 불필요, 레코드 전파 시간은 빠져 낙관적) | +| R-b | 앱의 DB 타임아웃 | 🟢 지금 설정 그대로(기본값) | 시간 제한 넣은 팔을 더하면 판이 2배이고, 넣을 값의 근거가 아직 없다 | +| R-c | 요청 간격 | 🟢 초당 10번 | 가정 피크(세션 시작 초당 0.075번)면 13초에 한 번이라 초 단위 변화를 못 잡는다. 10번은 부하가 아니라 **시간 해상도 0.1초**에서 나온 값이다 | + +## 결정 로그 + +- 2026-11-08: 후보 초안. 11-08 승격 실험이 DB 쪽(0.25~8초)만 쟀고, 앱 전환·두 박스 유실이 비어 있어서 정리했다. 후보 A~F · 흉내 K1/K2 · 공통 측정 칸 · 추천(A + B, K1 + K2). 결정은 미결. +- 2026-11-08: 🟢 **Q1 A + B · Q2 K1 + K2 채택** (사용자 confirm «추천대로»). Q3 는 명시 답 없음. rig 세부 셋(§6-1 R-a~R-c)은 미정 — 특히 R-a 는 IAM 권한이 필요하다. +- 2026-11-08: 🟢 **rig 세부 확정** (사용자 confirm) — R-a 는 **앱 박스에 DNS 를 직접 띄운다**(Route 53 아님 → 레코드 전파 시간이 빠져 + 실제보다 낙관적이라는 것을 결과에 적는다), R-b 기본 타임아웃 그대로, R-c 초당 10번. +- 2026-11-08: **측정 끝** (EC2 m6i.xlarge 3대 · 커밋 `f61c3d25` · 본판 12, [결과](../../loadtest/results/failover-app-reconnect-2026-11-08/README.md)). + 회복 시각: **A 약 106초**(K1·K2 같음) · **B·K1 약 30초 · B·K2 약 5.3초**, 잃은 쓰기 16판 모두 0. + 예상과 다른 것 셋 — ① A 의 106초 중 약 90초는 기동이 아니라 **종료 대기**(systemd 기본 90초에 걸린 것과 맞음, 무엇이 붙잡았는지 미확인 — + 운영은 Docker 라 이 숫자가 아니다) ② B·K2 에서 끊긴 연결 위 요청 약 14개가 **약 16분 매달림**(`socketTimeout` 미지정, §3 예상대로) + ③ DB 장애가 클라이언트에 **401** 로 보임 — `JwtAuthFilter` 가 DB 예외를 토큰 불량과 같이 처리(#848). + `socketTimeout` 값과 운영에서 쓸 후보는 **정하지 않았다**. 목표 RTO 는 `rpo-rto-targets.md` §6-3 (리플리카 보류 유지라 이 측정은 Q5 근거로만 쓰였다). diff --git a/loadtest/results/failover-app-reconnect-2026-11-08/README.md b/loadtest/results/failover-app-reconnect-2026-11-08/README.md new file mode 100644 index 00000000..cc1d02e0 --- /dev/null +++ b/loadtest/results/failover-app-reconnect-2026-11-08/README.md @@ -0,0 +1,87 @@ +# DB 페일오버 때 앱이 새 DB 로 넘어가기까지 (2026-11-08, EC2) + +설계 문서: [`../../../docs/decisions/failover-app-reconnect.md`](../../../docs/decisions/failover-app-reconnect.md) (후보 A + B · 흉내 K1 + K2 · rig 세부 §6-1) · +[`rpo-rto-targets.md`](../../../docs/decisions/rpo-rto-targets.md) §5 구성 3 · §6-3 +짝 실험: [리플리카 승격](../replica-promotion-2026-11-08/README.md) — DB 쪽 승격만 잰 것(0.25~8초) +무대: **EC2 `m6i.xlarge` 3대** (원본 DB · 리플리카 DB · 앱 + 탐침) · MySQL `mysql:8.0` GTID 비동기 복제 · 백엔드 jar(prod 프로파일, Java 21) · +RUN_ID `failover-ec2-20261108-054629` · 커밋 `f61c3d25` · 16판(버림 4 + 본판 12) + +질문: 원본 DB 가 죽고 리플리카를 승격했을 때, **사용자가 다시 저장되는 걸 보기까지** 얼마나 걸리나? 승격 실험은 DB 쪽만 쟀다. + +## 0. 한 줄 + +**DNS 바꾸기(B)는 5~30초, 앱 재시작(A)은 약 106초 — 잃은 쓰기는 16판 모두 0.** +단 B 는 네트워크가 끊기는 장애(K2)에서 **요청 약 14개가 약 16분 매달렸고**, A 의 106초 중 약 90초는 **앱이 안 꺼져서** 생긴 시간이다. +그리고 DB 장애가 클라이언트에겐 **401(인증 실패)** 로 보였다(§3). + +## 1. 결과 (본판, 칸마다 3판) + +모든 시각은 장애를 넣은 순간 기준. 승격 + 전환은 판마다 약 1.2~1.6초에 끝났다(①알아챔·②결정 = 0, 설계 §4). + +| 후보 | 흉내 | 판 | **회복** | 실패 요청 | 가장 오래 걸린 요청 | 5초 넘은 요청 | 잃은 쓰기 | +|---|---|---|---:|---:|---:|---:|---:| +| A 재시작 | K1 프로세스 | 8 · 12 · 14 | **105.3~106.1초** | 1,047~1,057 | 30.0초 | 15~17 | 0 | +| A 재시작 | K2 네트워크 | 6 · 9 · 15 | **105.5~105.8초** | 1,050~1,052 | 31.4~31.5초 | 15~16 | 0 | +| B DNS | K1 프로세스 | 7 · 10 · 13 | **30.4~30.5초** | 0~1 | 30.3초 | 253~255 | 0 | +| B DNS | K2 네트워크 | 5 · 11 · 16 | **5.3~5.4초** | 1~2 | **961~965초** | 13~14 | 0 | + +«회복» = 이 시각 뒤에 보낸 요청은 끝까지 전부 성공한, 그 첫 요청이 돌아온 시각(사용자가 «다시 저장된다» 를 처음 보는 때). +요청은 초당 10번(시간 해상도 0.1초용, 부하 아님 — 설계 §6-1 R-c). 버림판 4개도 같은 범위다(`ec2/summary.tsv`). + +## 2. 읽기 + +### 2-1. A 의 106초 중 약 90초는 «앱이 안 꺼지는» 시간 + +A 판(6·9·12·14·15)의 앱 로그 시각이 모두 같은 모양이다: + +``` +장애 +1.4초 systemctl restart → 종료 시작 (Commencing graceful shutdown) + +31초 Tomcat graceful 끝 — 붙잡혀 있던 요청이 Hikari 30초에 걸려 끝남 + +91.5초 새 프로세스 Starting ← 종료 시작 후 약 90초 + +104초 Started (기동 자체는 12.3~18.1초) +``` + +종료 시작부터 새 프로세스까지가 매번 약 90초 — **systemd 기본 정지 한도(`TimeoutStopSec` 90초) 에 걸려 강제 종료된 것과 맞는다.** +Tomcat 은 31초에 끝났는데 JVM 이 그 뒤로 안 내려갔다. 무엇이 붙잡았는지는 **확인하지 않았다**(스레드 덤프 없음). +→ «A = Spring 기동 시간» 이라는 설계의 예상과 달리, **죽은 DB 앞에선 종료가 기동보다 오래 걸린다.** +운영은 systemd 가 아니라 Docker 라서 정지 한도가 다르다(`docker stop` 기본 10초 뒤 강제 종료) — 운영 A 의 시간은 이 숫자가 아니다. + +### 2-2. B·K2 — 새 요청은 5초에 돌아오지만, 끊긴 연결 위의 요청은 약 16분 매달림 + +- 네트워크를 끊으면 원본은 «끊겼다» 는 응답(RST)조차 안 보낸다. 이미 원본과 이야기하던 요청은 리눅스가 TCP 재전송을 포기할 때까지 기다린다 + (`tcp_retries2=15`, 약 924초 + Hikari 30초 ≈ 961초 관측) +- Connector/J `socketTimeout` 을 지정하지 않아서다(설계 §6-1 R-b, 일부러 기본값). 그 값을 정할 근거로 이 숫자를 쓴다 — 값은 정하지 않았다 +- 매달린 13~14개는 결국 실패로 돌아왔다(401 — §3) + +### 2-3. B·K1 의 30초 — 추정 둘, 이 데이터로는 못 가른다 + +프로세스를 죽이면 앱은 즉시 끊김을 알고 다시 연결하려 한다. 그 순간(전환 1.4초 **전**) DNS 는 아직 옛 주소를 준다. +- 추정 ① JVM 이 DNS 결과를 30초 기억한다(보안 관리자 없을 때 기본) — 옛 주소가 30초 남는다 +- 추정 ② Hikari `connection-timeout` 30초 — 연결을 못 얻은 요청이 30초에 끝난다 + +둘 다 30초라 구분이 안 된다. K2 가 5초인 건 끊김을 몰라 바로 다시 묻지 않았고, 나중에 새 연결을 만들 때는 DNS 기억이 이미 지나 있었기 때문으로 보인다(확인 안 함). + +## 3. 🔴 찾은 결함 — DB 장애가 401 로 보인다 + +실패 응답이 500 이 아니라 **401** 이었다(A 판마다 13~17건, B 판 1~2건 + 매달렸다 돌아온 요청). +`JwtAuthFilter.java:90` 이 요청마다 DB 에서 회원을 읽는데, DB 예외를 `:105` 의 `catch (Exception e)` 가 «토큰 불량» 과 같이 처리한다 — +앱 로그 `JWT 인증 실패: DataAccessResourceFailureException` 137줄. 클라이언트가 401 에 로그아웃하면 **DB 가 잠깐 흔들려도 사용자가 로그아웃된다**(프론트 동작은 확인 안 함). [#848](https://github.com/Shadowfit/init/issues/848). + +## 4. 조건 — 인용할 때 떼면 안 되는 것 + +1. **알아채기·결정 시간 0.** 장애 직후 바로 승격·전환했다. 사람이 하면 사람 시간이 더해진다(§6-3 은 저녁 30분으로 잡았다) +2. **DNS 는 앱 박스의 dnsmasq**(TTL 0) — Route 53 이 아니다(설계 §6-1 R-a). 레코드가 퍼지는 시간이 빠져 **실제보다 낙관적**이다 +3. **앱은 systemd 로 돌았다** — A 의 약 90초 정지 대기는 systemd 기본값에서 나왔다. 운영(Docker)과 다르다 +4. 앱 DB 타임아웃은 지금 설정 그대로(Hikari 30초 · `socketTimeout`·`connectTimeout` 미지정) +5. 비동기 복제인데 잃은 게 0 인 건 쓰기가 초당 10번으로 가벼워 리플리카가 따라가 있었기 때문으로 본다 — 무거운 쓰기에서 두 박스 손실은 안 쟀다 +6. K2 는 원본 박스에서 3306 을 드나드는 패킷을 버린 것 — 호스트 상실의 대리다 + +## 5. 재현 + +```bash +bash launch.sh <커밋 SHA> # EC2 3대. 결과는 S3 의 RUN_ID 아래 +python3 analyze.py # events.tsv + t*/probe.tsv → summary.tsv +``` + +산출물: `ec2/summary.tsv`(판 1줄) · `ec2/events.tsv` · `ec2/CONDITIONS.txt` · `ec2/run.log` · `ec2/*.round.log` · `ec2/*box.txt`. +판별 원본(`t*/probe.tsv`·`app.log`, 전체 앱 로그 `app.full.log`)은 저장소에 안 넣었다 — S3 `shadowfit/failover-ec2-20261108-054629/out/`. diff --git a/loadtest/results/failover-app-reconnect-2026-11-08/analyze.py b/loadtest/results/failover-app-reconnect-2026-11-08/analyze.py new file mode 100644 index 00000000..e003c50d --- /dev/null +++ b/loadtest/results/failover-app-reconnect-2026-11-08/analyze.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 +"""판별 요약 — events.tsv + t*/probe.tsv → 한 판 한 줄 (모든 시각은 장애를 넣은 시각 t_kill 기준, ms) + + first_fail 장애 뒤 처음 실패가 «돌아온» 시각 (실패한 요청의 응답 시각 중 가장 이른 것) + recovered 이 시각 뒤에 «보낸» 요청은 끝까지 전부 성공했다 — 그 첫 요청이 성공으로 돌아온 시각. + 사용자가 «다시 저장된다» 를 처음 보는 시각이다 + n_fail 실패 수 (HTTP 204 가 아닌 것 전부) + fail_kinds 실패 종류별 수 + max_hang 장애 전후로 나간 요청 중 가장 오래 걸린 응답 시간 + n_over_5s 5초 넘게 걸린 요청 수 + unreturned 클라이언트 상한(CLIENT_TIMEOUT_S)까지 안 돌아온 요청 수 (probe.tsv 에 줄이 없다) + lost_acked 성공(204)을 받았는데 새 원본에 없는 쓰기 +""" +import collections +import csv +import os +import sys + +out = sys.argv[1] +ev = list(csv.DictReader(open(os.path.join(out, "events.tsv")), delimiter="\t")) +cols = ["trial", "kind", "arm", "kill", "promoted", "switch_at", "first_fail", "recovered", "n_fail", + "fail_kinds", "max_hang", "n_over_5s", "unreturned", "lost_acked", "rows_on_old", "status"] +print("\t".join(cols)) +for e in ev: + tk = int(e["t_kill_ms"]) + rows = list(csv.DictReader(open(os.path.join(out, "t" + e["trial"], "probe.tsv")), delimiter="\t")) + sent = set() + for r in rows: + sent.add(int(r["seq"])) + max_seq = max(sent) if sent else -1 + unreturned = (max_seq + 1) - len(sent) + rows.sort(key=lambda r: int(r["t_send_ms"])) + fails = [r for r in rows if r["result"] != "204" and int(r["t_done_ms"]) >= tk] + first_fail = min((int(r["t_done_ms"]) for r in fails), default=None) + # recovered: 뒤에서부터 훑어 «마지막 실패보다 뒤에 보낸» 첫 성공 + last_bad_send = max((int(r["t_send_ms"]) for r in rows if r["result"] != "204"), default=None) + rec = None + if last_bad_send is None: + rec = 0 + else: + after = [r for r in rows if int(r["t_send_ms"]) > last_bad_send] + if after: + rec = int(after[0]["t_done_ms"]) - tk + kinds = collections.Counter(r["result"] for r in fails) + lat = [int(r["t_done_ms"]) - int(r["t_send_ms"]) for r in rows if int(r["t_done_ms"]) >= tk] + print("\t".join(str(x) for x in [ + e["trial"], e["kind"], e["arm"], e["kill"], e["promoted_after_kill_ms"], + int(e["t_switch_ms"]) - tk, + "" if first_fail is None else first_fail - tk, + "" if rec is None else rec, + len(fails), ",".join(f"{k}:{v}" for k, v in kinds.most_common()), + max(lat, default=""), sum(1 for x in lat if x > 5000), unreturned, + e["lost_acked"], e["rows_on_old"], e["status"]])) diff --git a/loadtest/results/failover-app-reconnect-2026-11-08/app_userdata.sh b/loadtest/results/failover-app-reconnect-2026-11-08/app_userdata.sh new file mode 100644 index 00000000..3d73d77c --- /dev/null +++ b/loadtest/results/failover-app-reconnect-2026-11-08/app_userdata.sh @@ -0,0 +1,102 @@ +#!/bin/bash +# 앱 박스 — dnsmasq(후보 B 의 DNS) + 백엔드(bootstrap ROLE=app) + 탐침 + 지휘(failover.sh). +# 규칙: 커밋 SHA 고정 · grep 단언 · 감시견(CAP_MIN) · terminate. 끝나면 DB 두 대도 내린다. +# 대문자 자리표시자(골뱅이 두 쌍으로 감싼 것)는 launch.sh 가 채운다. +set -uo pipefail +SHA=@@SHA@@ +RUN_ID=@@RUN_ID@@ +CAP_MIN=@@CAP_MIN@@ +DB1=@@DB1@@ +DB2=@@DB2@@ +TRIALS='@@TRIALS@@' # 비우면 failover.sh 기본(16판). 스모크는 launch.sh 가 짧게 넣는다 +BURN=@@BURN@@ +S3=s3://shadowfit-measure-055447613012/shadowfit/$RUN_ID +RAW=https://raw.githubusercontent.com/Shadowfit/init/$SHA/loadtest/results/failover-app-reconnect-2026-11-08 +RIG=/root/rig +OUT=/root/rig_out +LOG=/root/round.log +NAME=db.failover.internal +exec > >(tee -a "$LOG") 2>&1 +mkdir -p "$RIG" "$OUT" + +up() { aws s3 cp "$LOG" "$S3/app.round.log" --only-show-errors || true; aws s3 sync "$OUT" "$S3/out" --only-show-errors || true; } +finish() { + echo "== finish: $1 ($(date -Is))" + [ -f /root/app.log ] && cp /root/app.log "$OUT/app.full.log" + up + aws s3 cp - "$S3/DONE" <<< "$1 $(date -Is)" || true + for h in "$DB1" "$DB2"; do ssh -i /root/.ssh/rig -o StrictHostKeyChecking=no -o BatchMode=yes -o ConnectTimeout=5 "root@$h" 'shutdown -h now' || true; done + aws s3 cp - "$S3/FINISHED" <<< "$1 $(date -Is)" || true + shutdown -h now +} +echo "== start $(date -Is) SHA=$SHA RUN_ID=$RUN_ID CAP_MIN=$CAP_MIN DB1=$DB1 DB2=$DB2 TRIALS=$TRIALS BURN=$BURN" +( sleep $((CAP_MIN * 60)); echo "🔴 상한 ${CAP_MIN}분 도달 — 끈다"; finish CAP ) & +( while sleep 300; do up; done ) & + +# ── SSH 키 → S3 (DB 박스가 받아 간다) ── +ssh-keygen -q -t ed25519 -N '' -f /root/.ssh/rig || finish KEYGEN +aws s3 cp /root/.ssh/rig.pub "$S3/app_pubkey" --only-show-errors || finish PUBKEY_UPLOAD + +# ── rig 파일 (커밋 SHA 고정) ── +cd /root +for f in failover.sh probe.py analyze.py; do curl -fsSL "$RAW/$f" -o "$RIG/$f" || finish "RIG_DOWNLOAD_$f"; done +grep -q 'promoted_after_kill_ms' "$RIG/failover.sh" || finish RIG_ASSERT +curl -fsSL "https://raw.githubusercontent.com/Shadowfit/init/$SHA/loadtest/aws/bootstrap.sh" -o bootstrap.sh || finish BOOTSTRAP_DOWNLOAD +grep -q 'if \[ "$ROLE" = "app" \]' bootstrap.sh || finish BOOTSTRAP_ASSERT + +# ── 후보 B 의 DNS: dnsmasq (TTL 0) — 앱 박스의 모든 이름 풀이가 여기를 지난다 ── +dnf -y -q install dnsmasq >/dev/null || finish DNSMASQ_INSTALL +# 🔴 스모크 3회차: 위로 넘길 곳을 169.254.169.253 으로 박았더니 외부 이름(github.com)이 30초 내내 안 풀렸다(원인 미확인). +# 그래서 systemd-resolved 가 원래 쓰던 위쪽 DNS(DHCP 로 받은 VPC DNS)를 그대로 쓴다 +UPSTREAM=$(awk '/^nameserver/{print $2; exit}' /run/systemd/resolve/resolv.conf 2>/dev/null) +[ -n "$UPSTREAM" ] || UPSTREAM=169.254.169.253 +echo "dnsmasq 위쪽 DNS: $UPSTREAM" +cat > /etc/dnsmasq.d/failover.conf <> /etc/dnsmasq.conf +echo "$DB1 $NAME" > /etc/failover-hosts +# ⚠️ 스모크 1~4회차는 여기서 죽었는데 진짜 원인은 user-data 의 CRLF 였다(launch.sh fill). 아래 셋(resolved mask · +# 재시도 · 위쪽 DNS)은 그 사이 세운 추정으로 넣은 것이고 원인이 아니었다 — 해롭지 않아 남긴다. 당시 추정: nsswitch 의 +# `resolve` 가 systemd-resolved 를 다시 깨워(disable 만으로는 소켓 활성화가 남는다) dnsmasq 를 우회했다. +# resolved 를 mask 하고, nsswitch 에서 resolve 를 빼서 «glibc → resolv.conf → dnsmasq» 한 길만 남긴다. +systemctl mask --now systemd-resolved systemd-resolved.socket systemd-resolved-varlink.socket systemd-resolved-monitor.socket 2>/dev/null || true +sed -i -E '/^hosts:/ s/resolve( \[[^]]*\])?//' /etc/nsswitch.conf +rm -f /etc/resolv.conf; printf 'nameserver 127.0.0.1\noptions timeout:1\n' > /etc/resolv.conf +systemctl enable --now dnsmasq || finish DNSMASQ_START +dns_diag() { echo "nsswitch: $(grep '^hosts:' /etc/nsswitch.conf)"; echo "resolved: $(systemctl is-active systemd-resolved)"; + echo "getent: $(getent hosts $NAME) / github: $(getent hosts github.com | head -1)"; cat /etc/failover-hosts; journalctl -u dnsmasq --no-pager | tail -5; } +# 🔴 스모크 2회차는 위 정리 뒤에도 같은 자리에서 죽었는데, 진단(바로 아래)에선 이름이 제대로 풀렸다 — dnsmasq 가 +# «Started» 를 찍은 같은 초에 첫 조회를 던진 경합이었다. 1회차도 같은 원인이었을 수 있다(위 추정은 검증 안 됨). +# 그래서 최대 30초 다시 묻는다. +dns_ok=0 +for _ in $(seq 1 30); do + getent hosts github.com >/dev/null && [ "$(getent hosts $NAME | awk '{print $1}')" = "$DB1" ] && { dns_ok=1; break; } + sleep 1 +done +dns_diag +[ "$dns_ok" = 1 ] || finish DNS_BROKEN + +# ── DB 두 대 준비 기다림 ── +for _ in $(seq 1 240); do + aws s3 ls "$S3/READY.db1" >/dev/null 2>&1 && aws s3 ls "$S3/READY.db2" >/dev/null 2>&1 && break + sleep 5 +done +aws s3 ls "$S3/READY.db2" >/dev/null 2>&1 && aws s3 ls "$S3/READY.db1" >/dev/null 2>&1 || finish DB_NOT_READY +export DB1 DB2 OUT BURN +[ -n "$TRIALS" ] && export TRIALS +bash "$RIG/failover.sh" prep || finish PREP_FAIL + +# ── 백엔드 (bootstrap ROLE=app — JDK·빌드·systemd 유닛·헬스 대기). 앱은 DNS 이름으로 원본에 붙는다 ── +ROLE=app REF=$SHA DB_HOST=$NAME bash bootstrap.sh || finish BOOTSTRAP_FAIL +{ echo "instance_type=$(curl -s -H "X-aws-ec2-metadata-token: $(curl -s -X PUT http://169.254.169.254/latest/api/token -H 'X-aws-ec2-metadata-token-ttl-seconds: 60')" http://169.254.169.254/latest/meta-data/instance-type)"; nproc; free -g; } > "$OUT/box.txt" +up + +bash "$RIG/failover.sh" run > "$OUT/run.log" 2>&1 +echo "== rig exit $?" +finish DONE diff --git a/loadtest/results/failover-app-reconnect-2026-11-08/db_userdata.sh b/loadtest/results/failover-app-reconnect-2026-11-08/db_userdata.sh new file mode 100644 index 00000000..75517320 --- /dev/null +++ b/loadtest/results/failover-app-reconnect-2026-11-08/db_userdata.sh @@ -0,0 +1,39 @@ +#!/bin/bash +# DB 박스(원본·리플리카 공통) — docker 와 mysql:8.0 이미지만 준비하고, 앱 박스가 SSH 로 조종할 수 있게 연다. +# MySQL 은 여기서 안 띄운다 — failover.sh 가 판마다 새로 띄운다. +# 규칙: 커밋 SHA 고정 · 감시견(CAP_MIN) · terminate — binlog-replay-time-2026-11-07/ec2_userdata.sh 와 같다. +# 대문자 자리표시자(골뱅이 두 쌍으로 감싼 것)는 launch.sh 가 채운다. +set -uo pipefail +RUN_ID=@@RUN_ID@@ +NODE=@@NODE@@ +CAP_MIN=@@CAP_MIN@@ +S3=s3://shadowfit-measure-055447613012/shadowfit/$RUN_ID +LOG=/root/round.log +exec > >(tee -a "$LOG") 2>&1 +up() { aws s3 cp "$LOG" "$S3/$NODE.round.log" --only-show-errors || true; } +finish() { echo "== finish: $1 ($(date -Is))"; up; shutdown -h now; } +echo "== start $(date -Is) node=$NODE RUN_ID=$RUN_ID CAP_MIN=$CAP_MIN" +( sleep $((CAP_MIN * 60)); echo "🔴 상한 ${CAP_MIN}분 도달 — 끈다"; finish CAP ) & +# 앱 박스가 끝을 알리면(또는 앱 박스가 죽어 SSH 종료가 안 오면 감시견이) 내려간다 +( while sleep 30; do aws s3 ls "$S3/DONE" >/dev/null 2>&1 && finish DONE_MARK; done ) & + +dnf -y -q install docker iptables-nft >/dev/null || finish PKG_FAIL +systemctl enable --now docker || finish DOCKER_FAIL +docker pull -q mysql:8.0 || finish PULL_FAIL + +# 앱 박스의 공개키를 root 로 받는다 (AL2023 cloud-init 은 root 키에 강제명령을 붙여 두므로 별도 줄로 추가) +echo 'PermitRootLogin prohibit-password' > /etc/ssh/sshd_config.d/00-rig.conf +systemctl reload sshd +mkdir -p /root/.ssh; chmod 700 /root/.ssh +for _ in $(seq 1 240); do + aws s3 cp "$S3/app_pubkey" /root/app_pubkey --only-show-errors 2>/dev/null && break + sleep 5 +done +[ -s /root/app_pubkey ] || finish NO_PUBKEY +cat /root/app_pubkey >> /root/.ssh/authorized_keys; chmod 600 /root/.ssh/authorized_keys +{ echo "instance_type=$(curl -s -H "X-aws-ec2-metadata-token: $(curl -s -X PUT http://169.254.169.254/latest/api/token -H 'X-aws-ec2-metadata-token-ttl-seconds: 60')" http://169.254.169.254/latest/meta-data/instance-type)"; nproc; free -g; docker --version; } > /root/box.txt +aws s3 cp /root/box.txt "$S3/$NODE.box.txt" --only-show-errors +aws s3 cp - "$S3/READY.$NODE" <<< "$(date -Is)" +echo "== ready $(date -Is)" +up +wait diff --git a/loadtest/results/failover-app-reconnect-2026-11-08/ec2/CONDITIONS.txt b/loadtest/results/failover-app-reconnect-2026-11-08/ec2/CONDITIONS.txt new file mode 100644 index 00000000..5c1c8101 --- /dev/null +++ b/loadtest/results/failover-app-reconnect-2026-11-08/ec2/CONDITIONS.txt @@ -0,0 +1,16 @@ +# 페일오버 앱 전환 조건 (2026-11-08T05:50:38+00:00) +DB1=172.31.57.130 DB2=172.31.58.216 NAME=db.failover.internal RATE=10 PRE_S=30 STABLE_S=30 POST_CAP_S=1200 CLIENT_TIMEOUT_S=1000 ACCOUNTS=4 +TRIALS=A:K1 B:K1 A:K2 B:K2 B:K2 A:K2 B:K1 A:K1 A:K2 B:K1 B:K2 A:K1 B:K1 A:K1 A:K2 B:K2 (앞 4 판 버림) +java: openjdk version "21.0.13" 2026-10-20 LTS +JVM DNS 캐시 설정(java.security): #networkaddress.cache.ttl=-1 networkaddress.cache.negative.ttl=10 +tcp_retries2=15 · tcp_syn_retries=6 +앱 유닛: +Environment=SPRING_PROFILES_ACTIVE=prod +Environment=DB_HOST=db.failover.internal +Environment=DB_USERNAME=shadowfit +Environment=AI_SERVER_HOST=127.0.0.1 +Environment=AI_SERVER_GRPC_PORT=8585 +Environment=AI_CHANNEL_POOL_SIZE=1 +Environment=SPRING_DATASOURCE_HIKARI_MAXIMUM_POOL_SIZE=15 +ExecStart=/usr/bin/java -XX:MaxRAMPercentage=75.0 -jar /root/init/backend/build/libs/backend-0.0.1-SNAPSHOT.jar +dnsmasq: listen-address=127.0.0.1 bind-interfaces no-resolv server=172.31.0.2 addn-hosts=/etc/failover-hosts local-ttl=0 diff --git a/loadtest/results/failover-app-reconnect-2026-11-08/ec2/app.round.log b/loadtest/results/failover-app-reconnect-2026-11-08/ec2/app.round.log new file mode 100644 index 00000000..922d0e3c --- /dev/null +++ b/loadtest/results/failover-app-reconnect-2026-11-08/ec2/app.round.log @@ -0,0 +1,94 @@ +== start 2026-11-08T05:46:51+00:00 SHA=f61c3d259458227ace4ba2d4a8da2b64dc819ce1 RUN_ID=failover-ec2-20261108-054629 CAP_MIN=420 DB1=172.31.57.130 DB2=172.31.58.216 TRIALS= BURN=4 +dnsmasq 위쪽 DNS: 172.31.0.2 +Created symlink /etc/systemd/system/multi-user.target.wants/dnsmasq.service → /usr/lib/systemd/system/dnsmasq.service. +nsswitch: hosts: files dns myhostname +resolved: inactive +getent: 172.31.57.130 db.failover.internal / github: 20.200.245.247 github.com +172.31.57.130 db.failover.internal +Nov 08 05:47:14 ip-172-31-62-253.ap-northeast-2.compute.internal dnsmasq[5253]: compile time options: IPv6 GNU-getopt DBus no-UBus no-i18n IDN2 DHCP DHCPv6 no-Lua TFTP conntrack ipset no-nftset auth cryptohash DNSSEC loop-detect inotify dumpfile +Nov 08 05:47:14 ip-172-31-62-253.ap-northeast-2.compute.internal dnsmasq[5253]: using nameserver 172.31.0.2#53 +Nov 08 05:47:14 ip-172-31-62-253.ap-northeast-2.compute.internal dnsmasq[5253]: read /etc/hosts - 6 names +Nov 08 05:47:14 ip-172-31-62-253.ap-northeast-2.compute.internal dnsmasq[5253]: read /etc/failover-hosts - 1 names +Nov 08 05:47:14 ip-172-31-62-253.ap-northeast-2.compute.internal systemd[1]: Started dnsmasq.service - DNS caching server.. +Warning: Permanently added '172.31.57.130' (ED25519) to the list of known hosts. +Warning: Permanently added '172.31.58.216' (ED25519) to the list of known hosts. +[failover 05:48:04] prep 끝 — DB 두 대 복제 연결, db.failover.internal → DB1 + +──── root SSH (다른 박스가 이 박스로 붙는 라운드용, #642) ──── + ec2-user 의 authorized_keys 를 root 로 복사(강제명령 줄 제거), PermitRootLogin prohibit-password + +──── 패키지 ──── + buildx 0.12.2 → 0.19.3 으로 올린다 (compose build 는 0.17+ 를 요구한다) + +──── 저장소 — https://github.com/Shadowfit/init.git @ f61c3d259458227ace4ba2d4a8da2b64dc819ce1 ──── + 커밋: f61c3d25 + +──── .env ──── +bootstrap.sh: line 281: compose: command not found +bootstrap.sh: line 281: shadowfit-ai: command not found +bootstrap.sh: line 281: INTERNAL_API_TOKEN:?: command not found +bootstrap.sh: line 281: MYSQL_EXPORTER_PASSWORD: command not found +bootstrap.sh: line 281: :?: command not found +bootstrap.sh: line 281: shadowfit-ai: command not found +bootstrap.sh: line 281: INTERNAL_API_TOKEN: command not found +bootstrap.sh: line 281: unused-in-this-role: command not found + MYSQL_ROOT_PASSWORD 를 rig 기본값과 맞췄다 (+ 해석만 되면 되는 변수들) + compose 해석 확인 ✅ (mysql 만 띄워도 파일 전체가 해석된다) + +──── JDK 21 (bootJar 직접 실행 — build.gradle:13 languageVersion 21) ──── + openjdk version "21.0.13" 2026-10-20 LTS + +──── Spring 빌드 (gradle bootJar, 5~15분) ──── +Downloading https://services.gradle.org/distributions/gradle-8.14.5-bin.zip +.............10%.............20%.............30%.............40%.............50%..............60%.............70%.............80%.............90%.............100% + +Welcome to Gradle 8.14.5! + +Here are the highlights of this release: + - Java 24 support + - GraalVM Native Image toolchain selection + - Enhancements to test reporting + - Build Authoring improvements + +For more details see https://docs.gradle.org/8.14.5/release-notes.html + +Starting a Gradle Daemon (subsequent builds will be faster) +> Task :protobufDummy UP-TO-DATE +> Task :extractIncludeProto +> Task :generateProto + +> Task :compileJava +Note: /root/init/backend/src/main/java/com/shadowfit/global/security/jwt/JwtUtil.java uses or overrides a deprecated API. +Note: Recompile with -Xlint:deprecation for details. + +> Task :processProtoResources +> Task :processResources +> Task :classes +> Task :resolveMainClassName +> Task :bootJar + +[Incubating] Problems report is available at: file:///root/init/backend/build/reports/problems/problems-report.html + +BUILD SUCCESSFUL in 1m 27s +7 actionable tasks: 7 executed + /root/init/backend/build/libs/backend-0.0.1-SNAPSHOT.jar + +──── systemd 유닛 — shadowfit-app (풀 스윕은 Environment= 한 줄을 sed 로 갈아 끼운다) ──── +Created symlink /etc/systemd/system/multi-user.target.wants/shadowfit-app.service → /etc/systemd/system/shadowfit-app.service. + 헬스체크 대기(원격 DB 라 최초 기동은 pool 만큼 커넥션을 새로 튼다 — 여유를 둔다).... — 떴다 + +──── App 준비됨 ──── + 포트 : 8080(HTTP) · 6565(gRPC) · 9090(actuator, 이 박스 안에서만) + DB_HOST : db.failover.internal + INTERNAL_API_TOKEN : nZyEpmhuFFYj2nthx5TMXsrehoGCbl + JWT_SECRET : rVPlTn6fIinkaNu3Z0G4bycsCQPT37SqjQFL6FQ9DY + 풀 크기(현재) : 15 + + 🔴 위 INTERNAL_API_TOKEN 을 부하기(ROLE=p6-loader)의 GHZ_TOKEN 에 그대로 넘긴다 — 다르면 + 전 요청이 401 이다. 풀 값을 바꾸려면: + sed -i 's/^Environment=SPRING_DATASOURCE_HIKARI_MAXIMUM_POOL_SIZE=.*/Environment=SPRING_DATASOURCE_HIKARI_MAXIMUM_POOL_SIZE=<값>/' \ + /etc/systemd/system/shadowfit-app.service + systemctl daemon-reload && systemctl restart shadowfit-app + (measure_poolsizing_10_20.sh 의 apply_pool() 이 이 절차를 그대로 SSH 로 대신 한다) +== rig exit 0 +== finish: DONE (2026-11-08T07:33:05+00:00) diff --git a/loadtest/results/failover-app-reconnect-2026-11-08/ec2/box.txt b/loadtest/results/failover-app-reconnect-2026-11-08/ec2/box.txt new file mode 100644 index 00000000..cb67beb9 --- /dev/null +++ b/loadtest/results/failover-app-reconnect-2026-11-08/ec2/box.txt @@ -0,0 +1,5 @@ +instance_type=m6i.xlarge +4 + total used free shared buff/cache available +Mem: 15 1 11 0 1 13 +Swap: 0 0 0 diff --git a/loadtest/results/failover-app-reconnect-2026-11-08/ec2/db1.box.txt b/loadtest/results/failover-app-reconnect-2026-11-08/ec2/db1.box.txt new file mode 100644 index 00000000..b3f2c0b6 --- /dev/null +++ b/loadtest/results/failover-app-reconnect-2026-11-08/ec2/db1.box.txt @@ -0,0 +1,6 @@ +instance_type=m6i.xlarge +4 + total used free shared buff/cache available +Mem: 15 0 13 0 1 14 +Swap: 0 0 0 +Docker version 25.0.14, build 0bab007 diff --git a/loadtest/results/failover-app-reconnect-2026-11-08/ec2/db1.round.log b/loadtest/results/failover-app-reconnect-2026-11-08/ec2/db1.round.log new file mode 100644 index 00000000..691daa60 --- /dev/null +++ b/loadtest/results/failover-app-reconnect-2026-11-08/ec2/db1.round.log @@ -0,0 +1,5 @@ +== start 2026-11-08T05:46:44+00:00 node=db1 RUN_ID=failover-ec2-20261108-054629 CAP_MIN=420 +Created symlink /etc/systemd/system/multi-user.target.wants/docker.service → /usr/lib/systemd/system/docker.service. +docker.io/library/mysql:8.0 +== ready 2026-11-08T05:47:41+00:00 +== finish: DONE_MARK (2026-11-08T07:33:10+00:00) diff --git a/loadtest/results/failover-app-reconnect-2026-11-08/ec2/db2.round.log b/loadtest/results/failover-app-reconnect-2026-11-08/ec2/db2.round.log new file mode 100644 index 00000000..02bcde34 --- /dev/null +++ b/loadtest/results/failover-app-reconnect-2026-11-08/ec2/db2.round.log @@ -0,0 +1,4 @@ +== start 2026-11-08T05:46:48+00:00 node=db2 RUN_ID=failover-ec2-20261108-054629 CAP_MIN=420 +Created symlink /etc/systemd/system/multi-user.target.wants/docker.service → /usr/lib/systemd/system/docker.service. +docker.io/library/mysql:8.0 +== ready 2026-11-08T05:47:45+00:00 diff --git a/loadtest/results/failover-app-reconnect-2026-11-08/ec2/events.tsv b/loadtest/results/failover-app-reconnect-2026-11-08/ec2/events.tsv new file mode 100644 index 00000000..85f99ceb --- /dev/null +++ b/loadtest/results/failover-app-reconnect-2026-11-08/ec2/events.tsv @@ -0,0 +1,17 @@ +trial kind arm kill t_kill0_ms t_kill_ms io_stop_ms apply_ms switch_sql_ms promoted_after_kill_ms t_switch_ms t_end_ms status lost_acked rows_on_old +1 버림 A K1 1794117100919 1794117101567 413 527 272 1212 1794117102942 1794117239700 stable 0 NA +2 버림 B K1 1794117306348 1794117306865 471 551 275 1298 1794117308334 1794117337469 stable 0 NA +3 버림 A K2 1794117402435 1794117402788 529 590 256 1376 1794117404328 1794117541084 stable 0 304 +4 버림 B K2 1794117608030 1794117608428 398 550 296 1245 1794117609884 1794117637015 stable 0 304 +5 본판 B K2 1794118638677 1794118638988 347 552 313 1213 1794118640412 1794118667543 stable 0 303 +6 본판 A K2 1794119667226 1794119667567 380 519 314 1214 1794119668985 1794119805757 stable 0 303 +7 본판 B K1 1794119872477 1794119873048 357 542 305 1205 1794119874403 1794119904546 stable 0 NA +8 본판 A K1 1794119970790 1794119971389 388 580 293 1262 1794119972869 1794120109633 stable 0 NA +9 본판 A K2 1794120175882 1794120176308 501 548 290 1340 1794120177814 1794120314581 stable 0 304 +10 본판 B K1 1794120381317 1794120381928 388 510 273 1172 1794120383324 1794120413476 stable 0 NA +11 본판 B K2 1794120480331 1794120480708 391 580 274 1246 1794120482130 1794120509260 stable 0 304 +12 본판 A K1 1794121510818 1794121511380 467 531 314 1313 1794121512853 1794121650658 stable 0 NA +13 본판 B K1 1794121713633 1794121714207 450 510 292 1253 1794121715624 1794121744760 stable 0 NA +14 본판 A K1 1794121811041 1794121811669 407 530 293 1231 1794121813236 1794121951018 stable 0 NA +15 본판 A K2 1794122013795 1794122014168 468 562 290 1321 1794122015708 1794122152464 stable 0 304 +16 본판 B K2 1794122219238 1794122219588 387 503 314 1205 1794122220951 1794122248084 stable 0 304 diff --git a/loadtest/results/failover-app-reconnect-2026-11-08/ec2/run.log b/loadtest/results/failover-app-reconnect-2026-11-08/ec2/run.log new file mode 100644 index 00000000..7906aadc --- /dev/null +++ b/loadtest/results/failover-app-reconnect-2026-11-08/ec2/run.log @@ -0,0 +1,65 @@ +[failover 05:50:38] ── 판 1 (버림) A·K1 +계정 4개 준비 → /root/rig_out/t1/tokens +[failover 05:53:59] 판 1: 새 요청 멈춤(stable) — 나간 요청 기다리는 중(최대 1000초) +[failover 05:54:00] 판 1 끝 — 승격 1212ms · 회복 판정 stable · 잃은 쓰기 0 +[failover 05:54:00] ── 판 2 (버림) B·K1 +계정 4개 준비 → /root/rig_out/t2/tokens +[failover 05:55:37] 판 2: 새 요청 멈춤(stable) — 나간 요청 기다리는 중(최대 1000초) +[failover 05:55:37] 판 2 끝 — 승격 1298ms · 회복 판정 stable · 잃은 쓰기 0 +[failover 05:55:37] ── 판 3 (버림) A·K2 +계정 4개 준비 → /root/rig_out/t3/tokens +[failover 05:59:01] 판 3: 새 요청 멈춤(stable) — 나간 요청 기다리는 중(최대 1000초) +[failover 05:59:01] 판 3 끝 — 승격 1376ms · 회복 판정 stable · 잃은 쓰기 0 +[failover 05:59:01] ── 판 4 (버림) B·K2 +계정 4개 준비 → /root/rig_out/t4/tokens +[failover 06:00:37] 판 4: 새 요청 멈춤(stable) — 나간 요청 기다리는 중(최대 1000초) +[failover 06:16:12] 판 4 끝 — 승격 1245ms · 회복 판정 stable · 잃은 쓰기 0 +[failover 06:16:12] ── 판 5 (본판) B·K2 +계정 4개 준비 → /root/rig_out/t5/tokens +[failover 06:17:47] 판 5: 새 요청 멈춤(stable) — 나간 요청 기다리는 중(최대 1000초) +[failover 06:33:21] 판 5 끝 — 승격 1213ms · 회복 판정 stable · 잃은 쓰기 0 +[failover 06:33:21] ── 판 6 (본판) A·K2 +계정 4개 준비 → /root/rig_out/t6/tokens +[failover 06:36:45] 판 6: 새 요청 멈춤(stable) — 나간 요청 기다리는 중(최대 1000초) +[failover 06:36:46] 판 6 끝 — 승격 1214ms · 회복 판정 stable · 잃은 쓰기 0 +[failover 06:36:46] ── 판 7 (본판) B·K1 +계정 4개 준비 → /root/rig_out/t7/tokens +[failover 06:38:24] 판 7: 새 요청 멈춤(stable) — 나간 요청 기다리는 중(최대 1000초) +[failover 06:38:25] 판 7 끝 — 승격 1205ms · 회복 판정 stable · 잃은 쓰기 0 +[failover 06:38:25] ── 판 8 (본판) A·K1 +계정 4개 준비 → /root/rig_out/t8/tokens +[failover 06:41:49] 판 8: 새 요청 멈춤(stable) — 나간 요청 기다리는 중(최대 1000초) +[failover 06:41:50] 판 8 끝 — 승격 1262ms · 회복 판정 stable · 잃은 쓰기 0 +[failover 06:41:50] ── 판 9 (본판) A·K2 +계정 4개 준비 → /root/rig_out/t9/tokens +[failover 06:45:14] 판 9: 새 요청 멈춤(stable) — 나간 요청 기다리는 중(최대 1000초) +[failover 06:45:15] 판 9 끝 — 승격 1340ms · 회복 판정 stable · 잃은 쓰기 0 +[failover 06:45:15] ── 판 10 (본판) B·K1 +계정 4개 준비 → /root/rig_out/t10/tokens +[failover 06:46:53] 판 10: 새 요청 멈춤(stable) — 나간 요청 기다리는 중(최대 1000초) +[failover 06:46:54] 판 10 끝 — 승격 1172ms · 회복 판정 stable · 잃은 쓰기 0 +[failover 06:46:54] ── 판 11 (본판) B·K2 +계정 4개 준비 → /root/rig_out/t11/tokens +[failover 06:48:29] 판 11: 새 요청 멈춤(stable) — 나간 요청 기다리는 중(최대 1000초) +[failover 07:04:04] 판 11 끝 — 승격 1246ms · 회복 판정 stable · 잃은 쓰기 0 +[failover 07:04:04] ── 판 12 (본판) A·K1 +계정 4개 준비 → /root/rig_out/t12/tokens +[failover 07:07:30] 판 12: 새 요청 멈춤(stable) — 나간 요청 기다리는 중(최대 1000초) +[failover 07:07:31] 판 12 끝 — 승격 1313ms · 회복 판정 stable · 잃은 쓰기 0 +[failover 07:07:31] ── 판 13 (본판) B·K1 +계정 4개 준비 → /root/rig_out/t13/tokens +[failover 07:09:04] 판 13: 새 요청 멈춤(stable) — 나간 요청 기다리는 중(최대 1000초) +[failover 07:09:05] 판 13 끝 — 승격 1253ms · 회복 판정 stable · 잃은 쓰기 0 +[failover 07:09:05] ── 판 14 (본판) A·K1 +계정 4개 준비 → /root/rig_out/t14/tokens +[failover 07:12:31] 판 14: 새 요청 멈춤(stable) — 나간 요청 기다리는 중(최대 1000초) +[failover 07:12:31] 판 14 끝 — 승격 1231ms · 회복 판정 stable · 잃은 쓰기 0 +[failover 07:12:31] ── 판 15 (본판) A·K2 +계정 4개 준비 → /root/rig_out/t15/tokens +[failover 07:15:52] 판 15: 새 요청 멈춤(stable) — 나간 요청 기다리는 중(최대 1000초) +[failover 07:15:53] 판 15 끝 — 승격 1321ms · 회복 판정 stable · 잃은 쓰기 0 +[failover 07:15:53] ── 판 16 (본판) B·K2 +계정 4개 준비 → /root/rig_out/t16/tokens +[failover 07:17:28] 판 16: 새 요청 멈춤(stable) — 나간 요청 기다리는 중(최대 1000초) +[failover 07:33:05] 판 16 끝 — 승격 1205ms · 회복 판정 stable · 잃은 쓰기 0 +[failover 07:33:05] 완료 → /root/rig_out diff --git a/loadtest/results/failover-app-reconnect-2026-11-08/ec2/summary.tsv b/loadtest/results/failover-app-reconnect-2026-11-08/ec2/summary.tsv new file mode 100644 index 00000000..8a2118e6 --- /dev/null +++ b/loadtest/results/failover-app-reconnect-2026-11-08/ec2/summary.tsv @@ -0,0 +1,17 @@ +trial kind arm kill promoted switch_at first_fail recovered n_fail fail_kinds max_hang n_over_5s unreturned lost_acked rows_on_old status +1 버림 A K1 1212 1375 1390 104831 1044 EXC:ConnectionRefusedError:1029,401:15 30009 15 0 0 NA stable +2 버림 B K1 1298 1469 30029 30456 1 401:1 30335 254 0 0 NA stable +3 버림 A K2 1376 1540 1584 105163 1045 EXC:ConnectionRefusedError:1030,401:14,EXC:RemoteDisconnected:1 31473 15 0 0 304 stable +4 버림 B K2 1245 1456 962991 5295 1 401:1 962953 14 0 0 304 stable +5 본판 B K2 1213 1424 961556 5395 2 401:2 961530 14 0 0 303 stable +6 본판 A K2 1214 1418 1497 105539 1050 EXC:ConnectionRefusedError:1035,401:13,EXC:RemoteDisconnected:2 31440 15 0 0 303 stable +7 본판 B K1 1205 1355 30075 30441 1 401:1 30275 255 0 0 NA stable +8 본판 A K1 1262 1480 1538 105294 1047 EXC:ConnectionRefusedError:1032,401:15 30009 15 0 0 NA stable +9 본판 A K2 1340 1506 1612 105756 1052 EXC:ConnectionRefusedError:1036,401:14,EXC:RemoteDisconnected:2 31513 16 0 0 304 stable +10 본판 B K1 1172 1396 30035 30469 1 401:1 30343 255 0 0 NA stable +11 본판 B K2 1246 1422 963030 5341 1 401:1 962970 13 0 0 304 stable +12 본판 A K1 1313 1473 1575 106062 1054 EXC:ConnectionRefusedError:1039,401:15 30011 15 0 0 NA stable +13 본판 B K1 1253 1417 30400 0 30336 253 0 0 NA stable +14 본판 A K1 1231 1567 1609 106123 1057 EXC:ConnectionRefusedError:1040,401:17 30009 17 0 0 NA stable +15 본판 A K2 1321 1540 1564 105823 1052 EXC:ConnectionRefusedError:1037,401:13,EXC:RemoteDisconnected:2 31495 15 0 0 304 stable +16 본판 B K2 1205 1363 959832 5445 2 401:2 964740 14 0 0 304 stable diff --git a/loadtest/results/failover-app-reconnect-2026-11-08/failover.sh b/loadtest/results/failover-app-reconnect-2026-11-08/failover.sh new file mode 100644 index 00000000..0c2456d9 --- /dev/null +++ b/loadtest/results/failover-app-reconnect-2026-11-08/failover.sh @@ -0,0 +1,212 @@ +#!/bin/bash +# 페일오버 때 앱이 새 DB 로 넘어가기까지 — 앱 박스에서 돈다 (docs/decisions/failover-app-reconnect.md) +# +# 후보 A 주소 바꾸고 앱 재시작 (systemd 유닛의 DB_HOST 를 새 IP 로 → restart) +# B DNS 이름 바꾸기 (앱은 db.failover.internal 로 붙는다 → 앱 박스 dnsmasq 레코드만 바꾼다) +# 흉내 K1 원본 DB 프로세스 죽이기 (docker kill — 원본 박스 OS 가 살아 있어 연결이 바로 끊긴다) +# K2 원본 DB 네트워크 끊기 (원본 박스에서 3306 을 들고 나는 패킷을 버린다 — 아무 응답도 없다) +# +# 한 판: 두 DB 를 새로 띄워 복제 연결 → 앱을 원본에 붙여 재시작(Flyway) → 계정 준비 → 탐침 시작 → +# PRE_S 뒤 장애 → 바로 승격(①알아챔·②결정 = 0, §4) → 바로 전환 → «연속 성공 STABLE_S 초» 가 되면 +# 새 요청을 멈추고, 나간 요청은 CLIENT_TIMEOUT_S 까지 기다린다 → 잃은 쓰기 대조 +# +# 🔴 앱 타임아웃은 지금 설정 그대로다(§6-1 R-b) — Hikari connection-timeout 30초, Connector/J 의 +# connectTimeout·socketTimeout 은 지정 안 함. 이 rig 은 그걸 바꾸지 않는다. +# 🔴 DNS 는 Route 53 이 아니라 앱 박스의 dnsmasq 다(§6-1 R-a) — 레코드 TTL 0. 레코드 전파 시간이 빠진다. +# +# 사용: failover.sh prep (부트스트랩 전 — 앱이 처음 뜰 DB 를 준비) +# failover.sh run +set -uo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +OUT=${OUT:-/root/rig_out} +DB1=${DB1:?DB1 사설 IP} +DB2=${DB2:?DB2 사설 IP} +PW=${PW:-1234} # bootstrap.sh 의 기본 PW 와 같아야 앱이 붙는다 +NAME=db.failover.internal +HOSTS_FILE=/etc/failover-hosts +SVC=/etc/systemd/system/shadowfit-app.service +BASE=http://127.0.0.1:8080 +RATE=${RATE:-10} +PRE_S=${PRE_S:-30} +STABLE_S=${STABLE_S:-30} +POST_CAP_S=${POST_CAP_S:-1200} +# 리눅스 기본 TCP 재전송 한도(tcp_retries2=15, 약 924초)보다 길게 — 매달린 요청이 서버 쪽에서 먼저 끝나게 +CLIENT_TIMEOUT_S=${CLIENT_TIMEOUT_S:-1000} +ACCOUNTS=${ACCOUNTS:-4} +# 첫 네 판은 칸마다 하나씩 버림. 그 뒤 세 바퀴는 칸 순서를 바꿔 «칸» 과 «판 순서» 가 섞이지 않게 +TRIALS=${TRIALS:-"A:K1 B:K1 A:K2 B:K2 B:K2 A:K2 B:K1 A:K1 A:K2 B:K1 B:K2 A:K1 B:K1 A:K1 A:K2 B:K2"} +BURN=${BURN:-4} +SSH=(ssh -i /root/.ssh/rig -o StrictHostKeyChecking=no -o BatchMode=yes -o ConnectTimeout=5 -o ServerAliveInterval=5) + +mkdir -p "$OUT" +log() { printf '[failover %s] %s\n' "$(date +%T)" "$*" >&2; } +die() { log "🔴 $*"; exit 1; } +now_ms() { date +%s%3N; } +rsh() { local h=$1; shift; "${SSH[@]}" "root@$h" "$@"; } +# SQL 은 표준입력으로 넘긴다 — ssh 따옴표 안에 SQL 을 넣으면 따옴표가 두 번 풀린다 +dq() { local h=$1; printf '%s\n' "$2" | timeout "${3:-60}" "${SSH[@]}" "root@$h" "docker exec -i -e MYSQL_PWD=$PW mdb mysql -uroot -N -s 2>/dev/null"; } + +db_start() { # $1 host $2 server-id — 매 판 새로 (볼륨까지 지움) + rsh "$1" "iptables -F INPUT; iptables -F OUTPUT; docker rm -f mdb >/dev/null 2>&1; docker volume rm -f mvol >/dev/null 2>&1; + docker run -d --name mdb --network host -e MYSQL_ROOT_PASSWORD=$PW -v mvol:/var/lib/mysql mysql:8.0 \ + --server-id=$2 --log-bin=binlog --binlog-format=ROW --gtid-mode=ON --enforce-gtid-consistency=ON \ + --innodb-flush-log-at-trx-commit=1 --sync-binlog=1 --innodb-buffer-pool-size=2G >/dev/null" || die "$1 DB 기동 실패" + local i + for i in $(seq 1 120); do [ "$(dq "$1" 'SELECT @@skip_networking;' 10)" = 0 ] && return 0; sleep 2; done + die "$1 DB 가 4분 안에 쿼리를 안 받는다" +} + +db_pair() { + db_start "$DB1" 1 & local p1=$! + db_start "$DB2" 2 & local p2=$! + wait $p1 || die "DB1 준비 실패"; wait $p2 || die "DB2 준비 실패" + dq "$DB2" "CHANGE REPLICATION SOURCE TO SOURCE_HOST='$DB1', SOURCE_USER='root', SOURCE_PASSWORD='$PW', + SOURCE_AUTO_POSITION=1, GET_SOURCE_PUBLIC_KEY=1; START REPLICA; + SET GLOBAL read_only=1; SET GLOBAL super_read_only=1;" >/dev/null || die "복제 연결 실패" + local i ok=0 + for i in $(seq 1 30); do + [ "$(dq "$DB2" "SELECT SERVICE_STATE FROM performance_schema.replication_connection_status;")" = ON ] && { ok=1; break; } + sleep 1 + done + [ "$ok" = 1 ] || die "리플리카 IO 스레드가 안 붙는다" + # 앱 계정·DB 는 복제를 붙인 «뒤» 원본에서만 만든다 — 이미지 초기화(MYSQL_USER·MYSQL_DATABASE)로 두 대에 따로 + # 만들면 원본 것이 복제돼 넘어갈 때 리플리카에서 «이미 있다» 로 SQL 스레드가 설 수 있다(promote.sh 와 같은 방식) + dq "$DB1" "CREATE DATABASE shadowfit; CREATE USER 'shadowfit'@'%' IDENTIFIED BY '$PW'; + GRANT ALL ON shadowfit.* TO 'shadowfit'@'%';" >/dev/null || die "앱 계정 생성 실패" + local g; g=$(dq "$DB1" "SELECT @@gtid_executed;" | tr -d '\n ') + [ "$(dq "$DB2" "SELECT WAIT_FOR_EXECUTED_GTID_SET('$g', 30);")" = 0 ] || die "리플리카가 앱 계정 생성을 못 받았다" + [ "$(dq "$DB2" "SELECT SERVICE_STATE FROM performance_schema.replication_applier_status;")" = ON ] || die "리플리카 SQL 스레드가 섰다" +} + +point_app() { # $1 arm $2 ip — 유닛·DNS 를 고친다(재시작은 따로) + if [ "$1" = A ]; then + sed -i "s|^Environment=DB_HOST=.*|Environment=DB_HOST=$2|" "$SVC" + else + sed -i "s|^Environment=DB_HOST=.*|Environment=DB_HOST=$NAME|" "$SVC" + echo "$2 $NAME" > "$HOSTS_FILE" + pkill -HUP dnsmasq || die "dnsmasq 가 없다" + fi + systemctl daemon-reload +} + +wait_health() { + local i + for i in $(seq 1 150); do curl -sf --max-time 2 http://127.0.0.1:9090/actuator/health >/dev/null && return 0; sleep 2; done + return 1 +} + +prep() { + db_pair + echo "$DB1 $NAME" > "$HOSTS_FILE"; pkill -HUP dnsmasq + local i; for i in $(seq 1 10); do [ "$(getent hosts $NAME | awk '{print $1}')" = "$DB1" ] && break; sleep 1; done + [ "$(getent hosts $NAME | awk '{print $1}')" = "$DB1" ] || die "getent 가 $NAME 을 DB1 로 못 푼다 — 앱이 쓰는 경로가 dnsmasq 를 안 탄다" + log "prep 끝 — DB 두 대 복제 연결, $NAME → DB1" +} + +# 장애가 난 뒤 «최근 STABLE_S 초에 보낸 요청이 전부 성공으로 돌아왔다» 가 되면 0 +stable() { # $1 tsv $2 기준 시각(ms, 전환 시각) + local now; now=$(now_ms) + awk -F'\t' -v lo=$(( now - (STABLE_S + 3) * 1000 )) -v hi=$(( now - 3000 )) -v from="$2" -v need=$(( RATE * STABLE_S * 8 / 10 )) ' + NR>1 && $4>=lo && $4<=hi && $4>=from { n++; if ($6 != "204") bad++ } + END { exit !(n >= need && bad == 0) }' "$1" +} + +trial() { # $1 번호 $2 arm $3 kill $4 kind + local idx=$1 arm=$2 kill=$3 kind=$4 tdir="$OUT/t$1" + mkdir -p "$tdir/ctrl"; rm -f "$tdir/ctrl/STOP" + log "── 판 $idx ($kind) $arm·$kill" + local applog_off; applog_off=$(stat -c%s /root/app.log 2>/dev/null || echo 0) + + db_pair + point_app "$arm" "$DB1" + systemctl restart shadowfit-app + wait_health || die "판 $idx: 앱이 원본에 붙어 안 뜬다" + local g; g=$(dq "$DB1" "SELECT @@gtid_executed;" | tr -d '\n ') + [ "$(dq "$DB2" "SELECT WAIT_FOR_EXECUTED_GTID_SET('$g', 60);")" = 0 ] || die "판 $idx: 리플리카가 스키마를 못 따라잡았다" + python3 "$HERE/probe.py" setup "$BASE" "$ACCOUNTS" "p${idx}u" "$tdir/tokens" >&2 || die "판 $idx: 계정 준비 실패" + + python3 "$HERE/probe.py" run "$BASE" "$tdir/tokens" "$RATE" "$tdir/probe.tsv" "$tdir/ctrl" "t$idx" "$CLIENT_TIMEOUT_S" \ + > "$tdir/probe.out" 2>&1 & + local ppid=$! + sleep "$PRE_S" + + # ── 장애 ── + local t_kill0 t_kill + t_kill0=$(now_ms) + if [ "$kill" = K1 ]; then + rsh "$DB1" "docker kill mdb >/dev/null" + else + rsh "$DB1" "iptables -I INPUT -p tcp --dport 3306 -j DROP; iptables -I OUTPUT -p tcp --sport 3306 -j DROP" + fi + t_kill=$(now_ms) + + # ── 승격 (①② = 0: 장애 직후 바로) — 11-08 promote.sh 와 같은 순서 ── + local t0 t1 t2 t3 recv w + t0=$(now_ms) + dq "$DB2" "STOP REPLICA IO_THREAD;" 300 >/dev/null || log "⚠️ IO 스레드 중지 실패/시간초과" + t1=$(now_ms) + recv=$(dq "$DB2" "SELECT RECEIVED_TRANSACTION_SET FROM performance_schema.replication_connection_status;" | tr -d '\n ') + if [ -n "$recv" ]; then + w=$(dq "$DB2" "SELECT WAIT_FOR_EXECUTED_GTID_SET('$recv', 600);" 700) + [ "$w" = 0 ] || log "⚠️ 적용 대기 실패 (w=$w)" + fi + t2=$(now_ms) + dq "$DB2" "STOP REPLICA; RESET REPLICA ALL; SET GLOBAL super_read_only=0; SET GLOBAL read_only=0;" 120 >/dev/null || log "⚠️ 전환 SQL 실패" + t3=$(now_ms) + + # ── 앱 전환 ── + point_app "$arm" "$DB2" + [ "$arm" = A ] && systemctl restart --no-block shadowfit-app + local t_switch; t_switch=$(now_ms) + + # ── 회복 대기 ── + local t_end status=stable + while ! stable "$tdir/probe.tsv" "$t_switch"; do + if [ $(( $(now_ms) - t_switch )) -gt $(( POST_CAP_S * 1000 )) ]; then status=cap; break; fi + sleep 1 + done + t_end=$(now_ms) + touch "$tdir/ctrl/STOP" + log "판 $idx: 새 요청 멈춤($status) — 나간 요청 기다리는 중(최대 ${CLIENT_TIMEOUT_S}초)" + wait $ppid + + # ── 잃은 쓰기: 성공(204)을 받은 memo 가 새 원본(DB2)에 있나 ── + awk -F'\t' 'NR>1 && $6=="204" {print "t'"$idx"'-" $1}' "$tdir/probe.tsv" | sort > "$tdir/acked.txt" + dq "$DB2" "SELECT memo FROM shadowfit.daily_logs WHERE memo LIKE 't$idx-%';" 120 | sort > "$tdir/on_new.txt" + comm -23 "$tdir/acked.txt" "$tdir/on_new.txt" > "$tdir/lost.txt" + local n_old=NA + [ "$kill" = K2 ] && n_old=$(dq "$DB1" "SELECT COUNT(*) FROM shadowfit.daily_logs WHERE memo LIKE 't$idx-%';" 30 || echo NA) + + printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \ + "$idx" "$kind" "$arm" "$kill" "$t_kill0" "$t_kill" "$((t1 - t0))" "$((t2 - t1))" "$((t3 - t2))" "$((t3 - t_kill))" \ + "$t_switch" "$t_end" "$status" "$(wc -l < "$tdir/lost.txt")" "$n_old" >> "$OUT/events.tsv" + tail -c +"$((applog_off + 1))" /root/app.log > "$tdir/app.log" 2>/dev/null || true + log "판 $idx 끝 — 승격 $((t3 - t_kill))ms · 회복 판정 $status · 잃은 쓰기 $(wc -l < "$tdir/lost.txt")" +} + +run() { + { + echo "# 페일오버 앱 전환 조건 ($(date -Is))" + echo "DB1=$DB1 DB2=$DB2 NAME=$NAME RATE=$RATE PRE_S=$PRE_S STABLE_S=$STABLE_S POST_CAP_S=$POST_CAP_S CLIENT_TIMEOUT_S=$CLIENT_TIMEOUT_S ACCOUNTS=$ACCOUNTS" + echo "TRIALS=$TRIALS (앞 $BURN 판 버림)" + echo "java: $(java -version 2>&1 | head -1)" + echo "JVM DNS 캐시 설정(java.security): $(grep -hE '^#?networkaddress\.cache\.(negative\.)?ttl' "$(dirname "$(dirname "$(readlink -f "$(command -v java)")")")"/conf/security/java.security 2>/dev/null | tr '\n' ' ')" + echo "tcp_retries2=$(cat /proc/sys/net/ipv4/tcp_retries2) · tcp_syn_retries=$(cat /proc/sys/net/ipv4/tcp_syn_retries)" + echo "앱 유닛:"; grep -E '^(Environment|ExecStart)=' "$SVC" | grep -v -E 'PASSWORD|SECRET|TOKEN' + echo "dnsmasq: $(grep -vE '^\s*(#|$)' /etc/dnsmasq.d/failover.conf | tr '\n' ' ')" + } > "$OUT/CONDITIONS.txt" + printf 'trial\tkind\tarm\tkill\tt_kill0_ms\tt_kill_ms\tio_stop_ms\tapply_ms\tswitch_sql_ms\tpromoted_after_kill_ms\tt_switch_ms\tt_end_ms\tstatus\tlost_acked\trows_on_old\n' > "$OUT/events.tsv" + local n=0 c + for c in $TRIALS; do + n=$((n + 1)) + trial "$n" "${c%%:*}" "${c##*:}" "$([ $n -le "$BURN" ] && echo 버림 || echo 본판)" + done + python3 "$HERE/analyze.py" "$OUT" > "$OUT/summary.tsv" || log "⚠️ 요약 실패 — 원자료는 남아 있다" + log "완료 → $OUT" +} + +case "${1:-}" in + prep) prep ;; + run) run ;; + *) die "prep | run" ;; +esac diff --git a/loadtest/results/failover-app-reconnect-2026-11-08/launch.sh b/loadtest/results/failover-app-reconnect-2026-11-08/launch.sh new file mode 100644 index 00000000..6b4c05e6 --- /dev/null +++ b/loadtest/results/failover-app-reconnect-2026-11-08/launch.sh @@ -0,0 +1,51 @@ +#!/bin/bash +# 이 라운드의 EC2 3대(m6i.xlarge, 같은 AZ)를 띄운다 — db1(원본) · db2(리플리카) · app(백엔드·탐침·DNS·지휘) +# 🔴 커밋 SHA 로 고정한다 — 이 SHA 가 GitHub 에 올라가 있어야 한다(브랜치 URL 은 CDN 캐시로 옛 파일을 준다). +# 사용: bash launch.sh 본 라운드 (16판, 앞 4판 버림) +# TRIALS="A:K1 B:K2" BURN=0 bash launch.sh 스모크 (rig 결함만 거른다 — 값은 인용 안 함) +set -euo pipefail +export MSYS_NO_PATHCONV=1 +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SHA=${1:?SHA 가 필요하다} +TRIALS=${TRIALS:-} +BURN=${BURN:-4} +git ls-remote origin | grep -q "^$SHA" || { echo "🔴 SHA $SHA 가 원격에 없다 — 먼저 푸시"; exit 1; } +RUN_ID=failover-ec2-$(date -u +%Y%m%d-%H%M%S) +REGION=ap-northeast-2 +AMI=$(aws ec2 describe-images --region $REGION --owners amazon \ + --filters "Name=name,Values=al2023-ami-2023*-kernel-6.12-x86_64" "Name=state,Values=available" \ + --query "reverse(sort_by(Images,&CreationDate))[:1].ImageId" --output text) +SUBNET=subnet-0f9ea5dacb7260d34 # ap-northeast-2d (이전 라운드와 같은 AZ) +SG=sg-0382132d2f8b5f6ce +CAP_MIN=${CAP_MIN:-360} +echo "RUN_ID=$RUN_ID AMI=$AMI SHA=$SHA TRIALS=${TRIALS:-기본} BURN=$BURN" + +launch() { # $1 이름 $2 user-data 파일 $3 볼륨GB → 인스턴스 ID 와 사설 IP + aws ec2 run-instances --region $REGION --image-id "$AMI" --instance-type m6i.xlarge \ + --key-name shadowfit-measure --security-group-ids $SG --subnet-id $SUBNET \ + --iam-instance-profile Name=shadowfit-measure \ + --instance-initiated-shutdown-behavior terminate \ + --block-device-mappings "[{\"DeviceName\":\"/dev/xvda\",\"Ebs\":{\"VolumeSize\":$3,\"VolumeType\":\"gp3\",\"DeleteOnTermination\":true}}]" \ + --tag-specifications "ResourceType=instance,Tags=[{Key=Project,Value=shadowfit-measure},{Key=RunId,Value=$RUN_ID},{Key=Name,Value=$RUN_ID-$1}]" \ + "ResourceType=volume,Tags=[{Key=Project,Value=shadowfit-measure}]" \ + --user-data "$(cat "$2")" --query 'Instances[0].[InstanceId,PrivateIpAddress]' --output text +} + +# 🔴 user-data 는 «이 PC 의 파일» 을 그대로 보낸다 — Windows 체크아웃은 CRLF 라 값 끝에 \r 이 붙는다 +# (스모크 1~4회차가 전부 이것으로 죽었다: DB1="IP\r" 이 getent 결과와 안 맞음). 그래서 CR 을 지운다. +# (rig 파일 자체는 GitHub raw 에서 받으므로 저장소의 LF 그대로다) +fill() { sed -e "s|@@RUN_ID@@|$RUN_ID|" -e "s|@@CAP_MIN@@|$CAP_MIN|" "$@" | tr -d '\r'; } +ips=() +for n in db1 db2; do + ud=$(mktemp); fill -e "s|@@NODE@@|$n|" "$HERE/db_userdata.sh" > "$ud" + grep -qE '@@[A-Z_]+@@' "$ud" && { echo "🔴 채우지 않은 자리표시자 ($n)"; exit 1; } + read -r id ip < <(launch "$n" "$ud" 50) + echo "$n → $id $ip"; ips+=("$ip") +done +ud=$(mktemp) +fill -e "s|@@SHA@@|$SHA|" -e "s|@@DB1@@|${ips[0]}|" -e "s|@@DB2@@|${ips[1]}|" -e "s|@@TRIALS@@|$TRIALS|" -e "s|@@BURN@@|$BURN|" \ + "$HERE/app_userdata.sh" > "$ud" +grep -qE '@@[A-Z_]+@@' "$ud" && { echo "🔴 채우지 않은 자리표시자 (app)"; exit 1; } +read -r id ip < <(launch app "$ud" 50) +echo "app → $id $ip" +echo "RUN_ID=$RUN_ID" diff --git a/loadtest/results/failover-app-reconnect-2026-11-08/probe.py b/loadtest/results/failover-app-reconnect-2026-11-08/probe.py new file mode 100644 index 00000000..b0cb2c8e --- /dev/null +++ b/loadtest/results/failover-app-reconnect-2026-11-08/probe.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +"""페일오버 탐침 — 쓰기 API 를 일정 간격으로 계속 부르고 요청마다 한 줄을 남긴다. + +설계: docs/decisions/failover-app-reconnect.md §4 · §6-1 R-c + +쓰는 API 는 «운동 메모 저장»(POST /reports/daily-logs — ExerciseRecordController:95, 경로는 PROBE_PATH 로 바꿀 수 있다)이다. +요청마다 날짜를 다르게 넣어서(계정별로 하루씩 증가) 한 요청 = DB 한 줄이 되게 한다 — 그래야 +«성공 응답을 받았는데 새 DB 에 없는 쓰기» 를 한 건씩 셀 수 있다(memo 에 요청 번호를 넣는다). + +🔴 요청 간격(초당 10번)은 부하가 아니라 **시간 해상도 0.1초**에서 나온 값이다(§6-1 R-c). +🔴 열린 고리(open-loop)다 — 앞 요청이 매달려도 다음 요청은 제시각에 나간다. 그래야 «매달림» 이 + «안 보낸 요청» 으로 숨지 않는다. 요청마다 스레드 하나. + +사용: + probe.py setup BASE N PREFIX TOKENS_OUT 계정 N 개 가입·로그인 → 토큰 파일 + probe.py run BASE TOKENS RATE OUT_TSV CTRL_DIR TAG CLIENT_TIMEOUT_S + CTRL_DIR/STOP 이 생기면 새 요청을 멈추고, 이미 나간 요청은 CLIENT_TIMEOUT_S 까지 기다린 뒤 끝낸다. +""" +import datetime +import json +import os +import sys +import threading +import time +import urllib.error +import urllib.request + +PASSWORD = os.environ.get("PROBE_PASSWORD", "K6load!2026") +LOG_PATH = os.environ.get("PROBE_PATH", "/reports/daily-logs") +BASE_DATE = datetime.date(2000, 1, 1) + + +def ms(): + return int(time.time() * 1000) + + +def post(url, body, token=None, timeout=10): + data = json.dumps(body).encode() + req = urllib.request.Request(url, data=data, method="POST", + headers={"Content-Type": "application/json"}) + if token: + req.add_header("Authorization", "Bearer " + token) + with urllib.request.urlopen(req, timeout=timeout) as r: + return r.status, r.read() + + +def setup(base, n, prefix, out): + tokens = [] + for i in range(n): + email = f"{prefix}{i}@loadtest.local" + try: + post(base + "/member/signup", {"username": f"{prefix}{i}", "email": email, + "password": PASSWORD, "sex": "MALE", "role": "USER"}) + except urllib.error.HTTPError as e: + if e.code != 409: + raise SystemExit(f"signup 실패 {email}: {e.code} {e.read()[:200]}") + _, body = post(base + "/member/login", {"email": email, "password": PASSWORD}) + tok = json.loads(body).get("accessToken") + if not tok: + raise SystemExit(f"login 응답에 accessToken 없음: {body[:200]}") + tokens.append(tok) + with open(out, "w") as f: + f.write("\n".join(tokens) + "\n") + print(f"계정 {n}개 준비 → {out}") + + +def run(base, tokens_file, rate, out_tsv, ctrl, tag, client_timeout): + tokens = [t.strip() for t in open(tokens_file) if t.strip()] + lock = threading.Lock() + out = open(out_tsv, "w", buffering=1) + out.write("seq\tacct\tlog_date\tt_send_ms\tt_done_ms\tresult\n") + threads = [] + + def one(seq, acct, day): + log_date = (BASE_DATE + datetime.timedelta(days=day)).isoformat() + t0 = ms() + try: + st, _ = post(base + LOG_PATH, {"logDate": log_date, "memo": f"{tag}-{seq}", "mood": "GOOD"}, + tokens[acct], timeout=client_timeout) + res = str(st) + except urllib.error.HTTPError as e: + res = str(e.code) + except Exception as e: # 연결 거부·타임아웃·리셋 — 종류를 남긴다 + reason = getattr(e, "reason", None) + res = "EXC:" + type(reason if reason is not None else e).__name__ + t1 = ms() + with lock: + out.write(f"{seq}\t{acct}\t{log_date}\t{t0}\t{t1}\t{res}\n") + + gap = 1.0 / rate + start = time.time() + seq = 0 + stop_path = os.path.join(ctrl, "STOP") + while not os.path.exists(stop_path): + target = start + seq * gap + d = target - time.time() + if d > 0: + time.sleep(d) + acct = seq % len(tokens) + th = threading.Thread(target=one, args=(seq, acct, seq // len(tokens)), daemon=True) + th.start() + threads.append(th) + seq += 1 + deadline = time.time() + client_timeout + 5 + for th in threads: + th.join(max(0.0, deadline - time.time())) + alive = sum(th.is_alive() for th in threads) + out.close() + print(f"보낸 요청 {seq} · 끝까지 안 돌아온 요청 {alive}") + + +if __name__ == "__main__": + cmd = sys.argv[1] + if cmd == "setup": + setup(sys.argv[2], int(sys.argv[3]), sys.argv[4], sys.argv[5]) + elif cmd == "run": + run(sys.argv[2], sys.argv[3], float(sys.argv[4]), sys.argv[5], sys.argv[6], sys.argv[7], float(sys.argv[8])) + else: + raise SystemExit("setup | run")