Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,12 +58,18 @@ OpenProxy를 경유하는 OpenSQL 3노드와 GCS 원본 저장소를 연결합
| **리더 VM 즉시 상실** | 계획 전환과 구분해 비계획 장애 중 쓰기 회복과 데이터 보존 측정 | 리더 VM과 같은 VM의 OpenProxy A가 함께 상실, 30 req/s × 180초, `ha420vm00001`, 2026-10-04 | 새 primary 승격(timeline 12→13) 후 옛 노드 재합류. 201 **4,349건**의 DB 누락·중복 **0건**. 전환 중 HTTP 500 467·503 582건·결과 불명 3건 | [비계획 장애 실측](docs/test-results/opensql-primary-unplanned-fault-load-20261004.md) |
| **etcd 두 멤버 중단** | etcd 정족수 상실 시 Patroni failsafe 동작과 앱 HTTP 쓰기 결과 확인 | 약 100초 정족수 상실, 30 req/s × 150초, `failsafe_mode=true`, `e428htwo01`, 2026-10-04 | **4,500/4,500** HTTP 201, DB 4,500 ID 각 1행. TTL(30초) 이후 구간에서도 201 2,100건 | [etcd HTTP failsafe](docs/test-results/opensql-etcd-http-failsafe-20261004.md) |
| **DB 장애 중 인덱싱 복구** | 문서 인덱싱 중 primary 장애가 나도 Worker lease·재처리로 최종 정합성이 맞는지 검증 | PDF 12건·DOCX 12건 선접수 후 primary 프로세스 종료, 시험 전용 Worker 유지, `ha424fault003`, 2026-10-04 | 최종 **24/24건** `INDEXED`, Chunk 36·ACTIVE Vector 36·자연키·Outbox 멱등 키 중복 **0**. Lease 만료 뒤 두 번째 Attempt 성공(약 5분 43초, 기본 Lease 5분 포함) | [Worker 장애 복구](docs/test-results/opensql-worker-primary-fault-20261004.md) |
| **Primary 네트워크 분할·비동기** | 살아 있는 옛 primary가 앱에는 연결되지만 두 standby와 단절됐을 때 성공 응답 보존 확인 | 5 req/s × 150초, `partnet01`, 2026-10-17. DB 밖 최초 응답과 승격 후 DB 대조 | 751건 중 최초 `201` **632건**; 그중 **40건이 새 primary에서 누락**. 데이터 보존 기준 **실패** | [비동기 분할 시험](docs/test-results/opensql-primary-network-partition-20261017/README.md) |
| **Primary 네트워크 분할·동기 strict** | 같은 장애 형태에서 성공 응답 보존과 장애 중 가용성을 분리해 확인 | 5 req/s × 150초, 별도 요청 751건, `syncpart01`, 2026-10-17. 시험용 Patroni 역할 어댑터를 거친 OpenProxy 경로 | 최초 `201` **586건**의 DB 누락 **0건**. `500` **77건**·`503` **81건**·결과 불명 **7건**으로 무중단 가용성은 실패 | [동기 strict 분할 시험](docs/test-results/opensql-primary-network-partition-sync-20261017/README.md) |
| **분할 후 멱등 재전송** | 동기 strict 분할 중 실패·결과 불명 요청을 같은 ID로 재전송해 최종 처리·중복 여부 확인 | 멱등 API의 **새 요청 751건** `ha503part01` → 네트워크 복구 후 **같은 751개 ID** 재전송 `ha503replay01`, 2026-10-17 | 첫 시도 `201` **585건**(DB 누락 **0**)·`500` **112건**·`503` **46건**·불명 **8건**. 재전송 시 기존 **585건 `200`**, 미반영 **166건 `201`** → 최종 DB **751건·중복 0건**. 첫 시도 가용성은 실패 | [멱등 본 시험·재전송](docs/test-results/opensql-primary-network-partition-retry-20261017/README.md) |
| **복제 지연·권한 안전성** | 권한 조회가 standby로 가는 경로에서, 회수한 ADMIN이 뒤처진 standby 때문에 다시 허용되는지 확인 | standby 적용을 지연시킨 뒤 ADMIN 회수, OpenProxy 경유 관리자 요청 재시험, 2026-09-29 재현 → 2026-10-01 수정 후 | 수정 전 HTTP 200 재현 → 관리자 인가를 primary 트랜잭션으로 고정한 뒤 **403**. 적용 범위는 HTTP 관리자 경로 | [문제 재현](docs/test-results/opensql-permission-replica-lag-20260929.md), [수정 후 검증](docs/test-results/opensql-admin-role-primary-consistency-20261001.md) |

장애 시험은 DB 밖 요청 원장의 `request_id`와 최종 primary의 DB 행을 실행 ID별로 대조했습니다. 이 표가
검증한 것은 **성공 응답(201)을 받은 쓰기가 DB에 정확히 한 번 남는지**이며, 장애 순간의 HTTP 500·503과
결과 불명도 그대로 적었습니다. 무중단 가용성이나 모든 장애에서의 RPO 0을 주장하지 않으며, 리더 VM 상실의
안정화 관측 구간 약 52.9초도 일반적인 RTO로 해석하지 않습니다. 동기 standby 링크 차단 시험과 시간 값의
보여 주는 것은 **각 실행에서 성공 응답(201)을 받은 쓰기의 보존 여부**입니다. 비동기 네트워크 분할에서는
이 기준이 실패했고, 동기 strict에서는 성공 응답 보존을 관측했지만 장애 중 첫 요청의 가용성은 떨어졌습니다.
멱등 재전송으로 최종 751건을 처리한 결과를 최초 요청 성공률과 합산하지 않습니다. 비동기·동기 비교·멱등
본 시험은 각각 다른 요청 원장이며, 재전송은 `ha503part01`의 ID에만 적용했습니다. 무중단 가용성이나 모든
장애에서의 RPO 0을 주장하지 않습니다. 리더 VM 상실의 안정화 관측 구간 약 52.9초도 일반적인 RTO로
해석하지 않습니다. 동기 standby 링크 차단 시험과 시간 값의
해석은 [3노드 HA 통합 보고서](docs/test-results/opensql-three-node-ha-integrated-report-20261004.md)에 있습니다.

### 검색 품질·복원·성능
Expand Down
104 changes: 104 additions & 0 deletions docs/test-results/opensql-primary-network-partition-20261017/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
# 살아 있는 Primary의 네트워크 분할 시험 — 2026-10-17

## 판정

**데이터 보존 기준 실패.** 새 GCP 격리 환경에서 옛 Primary는 앱에서 계속 접근 가능하게 두고, DB 노드 간 연결만 끊었다. 최초 HTTP `201`을 받은 632개 요청 가운데 **40개 ID가 승격한 Primary의 DB에 없었다.** 원래 계획했던 `201` 보존 100%를 충족하지 못했다. 별도로 클러스터 역할·복제·LB와 시험 자원은 복구했다.

이 결과는 실제로 관측한 한 회차의 **비동기 복제 구성**에 한정한다. Patroni의 역할 조회는 1초 표본이며 모든 순간의 쓰기 가능 여부를 증명하지 않는다. 비동기 복제 중단 후 옛 Primary가 승인한 쓰기가 새 Primary에 전파되지 않았을 가능성이 높지만 WAL·패킷 수준의 원인 추적은 하지 않았다.

```text
그림 1 — 시험 경로와 차단 범위

부하 VM: 합성 요청 ID별 최초 응답 기록
│ 5 req/s × 150초, 실제 HTTP POST
▼
내부 LB ──▶ 앱 A/B ──▶ OpenProxy A/B ──▶ OpenSQL DB2 (시험 시작 시 Primary)
│
앱→옛 Primary 접속 유지
│
[DB2의 전용 nft input/output DROP]
╳ ╳
DB1 Replica ◀──연결──▶ DB3 Replica
etcd 다수 유지, DB3 승격

별도 제어 경로: VM 접속·1초 역할 표본·180초 자동 원복 타이머
```

차단 중 DB2에서 두 peer의 etcd·Patroni·DB TCP 포트 연결은 각 **0/2**, DB1·DB3에서는 서로의 연결이 각 **1/2**였다. 앱 A/B에서 DB2의 OpenProxy TCP 포트 연결은 유지됐다. 포트 도달성은 쓰기 성공과 별도로 확인했다.

## 사전 조건과 방법

| 항목 | 실제 확인한 조건 |
| --- | --- |
| 실행 위치 | 새 GCP 격리 환경, DB 3VM·앱 2VM·Redis 1VM·부하 1VM |
| DB 제어 | Patroni `ttl=30초`, `loop_wait=10초`, `failsafe_mode=true`, `synchronous_mode=None` |
| 초기 역할 | 계획된 switchover 후 DB2 Primary, DB1·DB3 streaming Replica |
| 데이터 경로 | OpenProxy는 DB2·DB3에 설치. 명시적 `BEGIN READ WRITE` 확인에서 새 Primary로 라우팅 |
| 부하 | 내부 LB의 `/api/ha-probe/writes`, 5 req/s × 150초, 합성 ADMIN JWT와 요청 ID |
| 자동 원복 | 분할 전 빈 nft 테이블로 시스템 타이머 실행·제거 시험 통과; 실제 규칙은 180초 후 자동 제거되도록 예약 |
| 판정 | 요청별 최초 HTTP 응답 원장과 **새 Primary의** `ha_probe_writes` ID별 행 수 대조. 자동 재시도 결과를 최초 응답과 섞지 않음 |

기준선 `partbase01`은 같은 앱 경로에서 **75건 중 `201` 75건, DB 75건, 중복 0건, dropped 0건**이었다. [기준선 로그](partition-baseline-01.log), [요청 원장](partbase01-http-events.jsonl), [k6 요약](partbase01-k6-summary.json)을 분리했다.

```text
그림 2 — 관측 시각, UTC; 표본 간 상태는 미관측

12:45:15 150초 HTTP 요청 원장 시작
12:45:52 DB2의 로컬 etcd health 조회 불가 첫 1초 표본
├─ DB2↔DB1/DB3 TCP 단절, DB1↔DB3 유지
└─ 옛 Primary가 여전히 running인 동안 일부 쓰기에 201 응답
12:45:51~12:46:09 최종 DB에서 누락된 201 응답 40건의 시각
12:46:11 DB2 `primary/running` 마지막 1초 표본
12:46:20 DB3 `primary/running` 첫 1초 표본
12:46:52 DB2 `replica/running` 첫 1초 표본
12:47:34 DB2 로컬 etcd health 복구 첫 1초 표본
12:47:48 부하 종료; 이후 새 Primary의 DB ID별 감사

DB2 상태가 일부 구간 `primary/other`였으므로 그 구간의
실제 SQL 쓰기 가능 여부는 이 역할 표본만으로 판정하지 않는다.
```

## 요청·DB 대조

| 최초 결과 | 요청 ID | 최종 DB에 존재 | 최종 DB에 없음 |
| --- | ---: | ---: | ---: |
| HTTP `201` | 632 | 592 | **40** |
| HTTP `500` | 64 | — | — |
| HTTP `503` | 44 | — | — |
| HTTP `401` | 6 | — | — |
| 결과 불명 | 5 | 1 | 4 |
| 합계 | **751** | **593** | — |

`500`·`503`·`401`로 분류된 114개 요청 ID는 DB에 0건이었다. 전체 DB 기록은 **593개 고유 ID·593행·중복 0건**이다. `201` 누락 40건의 첫 응답 시각은 `12:45:51.779~12:46:09.222 UTC`로, 옛 Primary가 `primary/running`으로 관측된 구간에 집중됐다. 결과 불명 5건 중 1건은 DB에 있으므로 재시도 없이 실패로 단정해서는 안 된다.

부하 도구의 `k6_exit=0`은 전송과 계측의 종료를 뜻할 뿐 **데이터 보존 시험의 합격이 아니다.** dropped iteration은 0건, HTTP p50·p95·p99는 각각 **17.4·5,015.0·5,052.5ms**였다. 분할 해제 후 관측된 `201` 56건은 모두 DB에 있었고 그 구간의 실패·결과 불명은 0건이었다.

```text
그림 3 — 성공 응답과 DB 원장의 불일치

외부 원장: 요청 ID별 최초 결과
├─ 201 632건 ─────┬── DB에 592건 존재
│ └── DB에 40건 없음 ← 이번 시험 실패
├─ 500·503·401 114건 ── DB에 0건 존재
└─ 결과 불명 5건 ───┬── DB에 1건 존재
└── DB에 4건 없음

최종 DB: 고유 ID 593건, 중복 ID 0건
재시도/재확정: 이 실행에는 포함하지 않음
```

[주 시험 로그](partition-primary-01.log), [HTTP 최초 응답 원장](partnet01-http-events.jsonl), [최종 DB ID별 원장](partnet01-db-audit.csv), [DB1 역할 표본](partnet01-db1-observer.csv), [DB2 역할 표본](partnet01-db2-observer.csv), [DB3 역할 표본](partnet01-db3-observer.csv), [k6 요약](partnet01-k6-summary.json)을 각각 보관했다. JSONL에는 합성 요청 ID·시각·응답 종류만 있다. CSV에는 합성 요청 ID·행 수 또는 안전한 노드 라벨·역할·상태만 있다. 복사 전에 필드·값 allowlist를 검사했다.

`scripts/opensql/audit_primary_partition.py`를 같은 HTTP 원장과 DB 원장에 다시 실행해 **751개 요청·201 누락 40개·DB 중복 0개·FAIL**을 재계산했다. [감사 실행 기록](partition-audit-01.log)과 [집계 JSON](partnet01-audit-summary.json)을 원시 증거와 분리했다.

## 준비 중 확인한 사항과 복구

| 항목 | 관측과 조치 |
| --- | --- |
| 자동 원복 준비 | 첫·세 번째 smoke에서 타이머 완료 확인 시점이 너무 짧아 실패했다. 폴링 기한을 늘린 네 번째 smoke가 `automatic_rollback=passed`였다. [개별 기록](partition-smoke-01.log), [두 번째](partition-smoke-02.log), [세 번째](partition-smoke-03.log), [네 번째](partition-smoke-04.log) |
| 역할 이전 준비 | 첫 시도는 Patroni 후보 이름·Replica 상태 전제가 틀려 **요청을 보내지 않고** 중단했다. 실제 `/cluster` 정보를 사용한 두 번째 시도에서 HTTP 200, DB2 Primary와 streaming 2개를 확인했다. [첫 기록](partition-switchover-01.log), [두 번째](partition-switchover-02.log) |
| 프록시 판정 | 일반 `SELECT`는 복제본에 라우팅될 수 있어 `pg_is_in_recovery()=true`가 나왔다. 명시적 read-write 트랜잭션으로 다시 확인해 새 Primary 경로가 정상임을 확인했다. |
| 합성 계정 | DB1에는 OpenProxy 설정이 없어 첫 자동 정리 타이머 설치가 실패했다. OpenProxy가 설치된 DB2·DB3에만 반복 정리 타이머를 두고, 제거는 Proxy의 read-write 경로로 처리했다. 시험 후 계정 0건·두 타이머 모두 비활성을 확인했다. |
| 환경 원복 | 분할 nft 테이블 0개, 새 Primary 1개·streaming Replica 2개, 내부 LB HEALTHY 2/2. 시험 전 생성한 스냅샷 3개, VM별 임시 OS 관리자 바인딩, 임시 SSH 키, 합성 JWT, DB VM 시험 스크립트·원격 관측 파일을 제거했다. 기존 7VM은 시험 환경으로 유지했다. |

현재 구성에서 외부 최초 성공 응답의 보존을 주장하려면 비동기 복제의 손실 창을 줄이거나, 다수와 단절된 옛 Primary의 쓰기 승인 경로를 더 일찍 막는 설계가 필요하다. 이는 이번 시험에서 구현·검증한 개선안이 아니다. 동기 복제·강제 fencing·재전송 API의 효과도 이 실행으로는 확인하지 않았다.
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
run_id=guard-unit-01
목적=Primary 및 peer 선택 안전 조건 검증
위치=로컬 격리 작업 트리
명령=PYTHONDONTWRITEBYTECODE=1 python3 scripts/opensql/test_primary_partition_guard.py
성공 기준=4개 검사 통과
시작_KST=2026-10-17 21:07:31 KST
....
----------------------------------------------------------------------
Ran 4 tests in 0.000s

OK
종료_KST=2026-10-17 21:07:31 KST
종료_코드=0
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
run_id=guard-unit-02
목적=실제 Replica state 반영 후 가드 단위 시험 재실행
위치=로컬 격리 Git worktree
방법=python3 -m unittest scripts/opensql/test_primary_partition_guard.py
성공기준=4개 시험 통과
실행시각_UTC=정확한 시각을 출력·수집하지 못함; 2026-10-17T12:44:49Z 이전
관측=ImportError: primary_partition_guard 모듈을 찾지 못해 시험 0건 실행
판정=FAIL. 프로젝트 루트에서 실행할 때 PYTHONPATH가 빠졌음.
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
run_id=guard-unit-03
목적=모듈 검색 경로 수정 후 가드 단위 시험 재실행
위치=로컬 격리 Git worktree
방법=PYTHONPATH=scripts/opensql python3 -m unittest scripts/opensql/test_primary_partition_guard.py
성공기준=4개 시험 통과
실행시각_UTC=정확한 시각을 출력·수집하지 못함; 2026-10-17T12:44:49Z 이전
관측=4개 중 3개 통과·1개 오류. 시험 fixture가 Replica 상태를 running으로 가정했으나 실제 Patroni cluster는 streaming을 반환함.
판정=FAIL. 시험 입력을 실제 API 상태에 맞춰 수정함.
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
run_id=guard-unit-04
목적=Replica 상태와 시험 입력을 맞춘 뒤 단위·구문 검증
위치=로컬 격리 Git worktree
방법=PYTHONPATH=scripts/opensql python3 -m unittest scripts/opensql/test_primary_partition_guard.py; Python ast.parse 5개 스크립트; bash -n run_ha_probe_k6.sh
성공기준=4개 단위 시험 통과, Python·셸 구문 오류 0건
실행시각_UTC=정확한 시각을 출력·수집하지 못함; 2026-10-17T12:44:49Z 이전
관측=4/4 통과, Python 구문 검사 통과, bash 구문 검사 통과
판정=PASS
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
run_id=guard-unit-05
목적=분할 가드와 오프라인 감사기의 최종 단위·구문 검증
위치=로컬 격리 Git worktree
방법=PYTHONPATH=scripts/opensql python3 -m unittest scripts/opensql/test_primary_partition_guard.py scripts/opensql/test_audit_primary_partition.py; Python ast.parse 6개 스크립트; bash -n scripts/opensql/run_ha_probe_k6.sh
성공기준=7개 단위 시험 통과, Python·셸 구문 오류 0건
시작_UTC=2026-10-17T12:57:47Z
종료_UTC=2026-10-17T12:57:47Z
결과=7/7 통과, Python 구문 6/6 통과, 셸 구문 통과
판정=PASS
Loading
Loading