diff --git a/docs/test-results/evidence/ha-dashboard-preview-20261009/hadash1009b01/db-counts.csv.gz b/docs/test-results/evidence/ha-dashboard-preview-20261009/hadash1009b01/db-counts.csv.gz new file mode 100644 index 00000000..97c7197e Binary files /dev/null and b/docs/test-results/evidence/ha-dashboard-preview-20261009/hadash1009b01/db-counts.csv.gz differ diff --git a/docs/test-results/evidence/ha-dashboard-preview-20261009/hadash1009b01/safe-events.jsonl.gz b/docs/test-results/evidence/ha-dashboard-preview-20261009/hadash1009b01/safe-events.jsonl.gz new file mode 100644 index 00000000..3f6e0841 Binary files /dev/null and b/docs/test-results/evidence/ha-dashboard-preview-20261009/hadash1009b01/safe-events.jsonl.gz differ diff --git "a/docs/test-results/evidence/ha-dashboard-preview-20261009/hadash1009b01/\352\263\204\354\270\241-1\354\264\210.csv" "b/docs/test-results/evidence/ha-dashboard-preview-20261009/hadash1009b01/\352\263\204\354\270\241-1\354\264\210.csv" new file mode 100644 index 00000000..360527e2 --- /dev/null +++ "b/docs/test-results/evidence/ha-dashboard-preview-20261009/hadash1009b01/\352\263\204\354\270\241-1\354\264\210.csv" @@ -0,0 +1,62 @@ +시각_UTC,미전송_건,HTTP_요청_건,활성_VU_최대,확보_VU_최대,HTTP_응답대기_p95_ms,연결대기_p95_ms,반복수행_p95_ms,호스트_CPU_%,호스트_가용메모리_MiB,k6_CPU_%,k6_RSS_MiB,k6_스레드_수 +2026-10-09T06:14:54Z,0,2,,,62.98,4.94,69.33,,7023.18,,44.25,7 +2026-10-09T06:14:55Z,0,30,2.0,80.0,61.06,2.19,64.0,10.61,7019.21,18.95,56.18,7 +2026-10-09T06:14:56Z,0,29,1.0,80.0,57.45,2.67,60.46,6.03,7019.3,9.98,58.32,7 +2026-10-09T06:14:57Z,0,31,1.0,80.0,57.11,2.35,60.06,5.56,7015.34,9.98,60.39,8 +2026-10-09T06:14:58Z,0,30,1.0,80.0,20.29,0.03,21.52,6.97,7011.41,10.98,62.5,8 +2026-10-09T06:14:59Z,0,30,1.0,80.0,61.18,0.02,62.88,5.5,7011.49,7.98,62.65,8 +2026-10-09T06:15:00Z,0,30,1.0,80.0,19.7,0.02,21.46,5.05,7011.51,7.98,62.65,8 +2026-10-09T06:15:01Z,0,29,0.0,80.0,19.67,0.02,21.72,5.05,7011.57,8.98,62.66,8 +2026-10-09T06:15:02Z,0,31,0.0,80.0,56.01,0.02,57.17,6.0,7011.41,6.98,64.7,8 +2026-10-09T06:15:03Z,0,30,0.0,80.0,55.65,0.02,56.93,6.53,7011.47,10.98,64.72,8 +2026-10-09T06:15:04Z,0,30,0.0,80.0,19.01,0.02,20.18,5.03,7011.5,7.98,64.74,8 +2026-10-09T06:15:05Z,0,30,1.0,80.0,55.87,0.02,57.3,5.03,7011.61,6.99,64.75,8 +2026-10-09T06:15:06Z,0,29,1.0,80.0,55.6,0.02,57.61,4.04,7011.69,7.98,64.8,8 +2026-10-09T06:15:07Z,0,31,1.0,80.0,54.75,0.02,55.77,4.5,7011.73,5.99,64.82,8 +2026-10-09T06:15:08Z,0,30,1.0,80.0,57.69,0.02,58.55,6.03,7011.77,9.98,64.91,8 +2026-10-09T06:15:09Z,0,30,1.0,80.0,56.53,0.02,57.69,4.55,7011.57,7.98,64.91,8 +2026-10-09T06:15:10Z,0,30,1.0,80.0,54.5,0.02,55.78,5.0,7011.68,7.98,64.92,8 +2026-10-09T06:15:11Z,0,30,0.0,80.0,52.72,0.02,53.95,5.03,7011.74,6.98,64.93,8 +2026-10-09T06:15:12Z,0,30,0.0,80.0,55.87,0.03,57.36,5.03,7011.8,6.98,64.94,8 +2026-10-09T06:15:13Z,0,30,0.0,80.0,55.52,0.02,56.74,4.06,7011.86,7.98,64.96,8 +2026-10-09T06:15:14Z,0,29,0.0,80.0,54.22,0.02,55.42,8.46,7011.95,10.98,64.98,8 +2026-10-09T06:15:15Z,0,31,1.0,80.0,54.53,0.02,56.09,6.0,7011.95,7.98,64.99,8 +2026-10-09T06:15:16Z,0,29,0.0,80.0,17.55,0.03,19.0,5.03,7012.05,7.98,64.99,8 +2026-10-09T06:15:17Z,0,31,1.0,80.0,54.82,0.05,56.16,5.47,7012.16,6.98,65.0,8 +2026-10-09T06:15:18Z,0,30,1.0,80.0,56.44,0.02,57.55,3.55,7012.21,7.98,65.01,8 +2026-10-09T06:15:19Z,0,30,1.0,80.0,53.81,0.02,54.96,7.0,7012.27,10.97,65.02,8 +2026-10-09T06:15:20Z,0,30,1.0,80.0,56.0,0.02,57.28,5.53,7012.31,8.98,65.03,8 +2026-10-09T06:15:21Z,0,29,0.0,80.0,55.12,0.02,56.95,6.0,7012.42,7.98,65.03,8 +2026-10-09T06:15:22Z,0,31,1.0,80.0,55.16,0.03,56.34,4.55,7012.47,6.99,65.03,8 +2026-10-09T06:15:23Z,0,30,0.0,80.0,51.42,0.02,52.59,4.52,7012.54,6.99,65.04,8 +2026-10-09T06:15:24Z,0,29,0.0,80.0,18.06,0.01,19.07,6.47,7012.58,8.98,65.05,8 +2026-10-09T06:15:25Z,0,31,1.0,80.0,53.23,0.02,54.65,4.06,7012.63,7.98,65.05,8 +2026-10-09T06:15:26Z,0,30,1.0,80.0,55.77,0.02,57.14,4.52,7012.74,7.98,65.12,8 +2026-10-09T06:15:27Z,0,30,1.0,80.0,54.0,0.01,55.29,4.52,7012.74,6.99,65.13,8 +2026-10-09T06:15:28Z,0,30,0.0,80.0,51.59,0.02,52.92,4.04,7012.85,6.99,65.13,8 +2026-10-09T06:15:29Z,0,30,1.0,80.0,53.35,0.05,54.41,4.98,7012.85,6.98,65.14,8 +2026-10-09T06:15:30Z,0,30,0.0,80.0,54.97,0.02,56.26,7.54,7012.96,10.98,65.21,8 +2026-10-09T06:15:31Z,0,29,1.0,80.0,52.27,0.01,53.38,3.05,7013.02,6.99,65.21,8 +2026-10-09T06:15:32Z,0,30,1.0,80.0,54.45,0.02,55.42,4.98,7013.08,5.99,65.22,8 +2026-10-09T06:15:33Z,0,31,0.0,80.0,55.05,0.02,56.17,4.55,7013.16,7.98,65.29,8 +2026-10-09T06:15:34Z,0,30,1.0,80.0,50.92,0.05,51.85,4.55,7013.22,6.98,65.29,8 +2026-10-09T06:15:35Z,0,30,1.0,80.0,55.95,0.04,57.79,5.5,7013.27,7.98,67.31,8 +2026-10-09T06:15:36Z,0,29,1.0,80.0,56.21,0.02,57.68,5.03,7013.39,6.99,67.34,8 +2026-10-09T06:15:37Z,0,31,0.0,80.0,54.38,0.02,55.54,8.46,7009.39,12.97,69.39,8 +2026-10-09T06:15:38Z,0,30,0.0,80.0,56.35,0.02,57.6,4.52,7009.46,6.98,69.39,8 +2026-10-09T06:15:39Z,0,30,1.0,80.0,19.31,0.02,20.65,5.53,7009.52,8.97,69.39,8 +2026-10-09T06:15:40Z,0,29,1.0,80.0,18.86,0.02,20.24,4.04,7009.58,6.99,69.39,8 +2026-10-09T06:15:41Z,0,30,1.0,80.0,55.16,0.02,56.55,5.5,7009.64,6.99,69.39,8 +2026-10-09T06:15:42Z,0,31,1.0,80.0,55.48,0.02,56.76,5.53,7009.73,8.98,70.04,8 +2026-10-09T06:15:43Z,0,30,1.0,80.0,53.81,0.03,55.01,5.03,7009.76,5.99,70.04,8 +2026-10-09T06:15:44Z,0,30,0.0,80.0,52.64,0.02,53.4,6.53,7009.9,10.98,70.07,8 +2026-10-09T06:15:45Z,0,30,1.0,80.0,54.83,0.01,56.02,4.98,7009.9,5.99,70.07,8 +2026-10-09T06:15:46Z,0,30,1.0,80.0,55.44,0.02,56.44,4.55,7010.0,7.99,70.07,8 +2026-10-09T06:15:47Z,0,29,1.0,80.0,55.61,0.04,56.87,5.0,7010.05,6.98,70.09,8 +2026-10-09T06:15:48Z,0,30,0.0,80.0,56.71,0.02,57.9,4.52,7010.1,6.99,70.09,8 +2026-10-09T06:15:49Z,0,31,1.0,80.0,56.1,0.02,57.16,5.47,7010.16,7.98,70.09,8 +2026-10-09T06:15:50Z,0,30,1.0,80.0,55.07,0.02,56.19,,,,, +2026-10-09T06:15:51Z,0,29,0.0,80.0,54.61,0.01,55.92,4.06,7010.21,7.98,70.09,8 +2026-10-09T06:15:52Z,0,31,1.0,80.0,55.37,0.02,56.5,4.5,7010.27,5.99,70.1,8 +2026-10-09T06:15:53Z,0,30,1.0,80.0,53.89,0.1,55.18,6.53,7010.36,10.98,70.17,8 +2026-10-09T06:15:54Z,0,29,0.0,80.0,54.71,0.01,55.87,5.05,7010.45,6.99,70.17,8 diff --git a/docs/test-results/evidence/ha-dashboard-preview-20261009/run-01-local-check.md b/docs/test-results/evidence/ha-dashboard-preview-20261009/run-01-local-check.md new file mode 100644 index 00000000..23facff5 --- /dev/null +++ b/docs/test-results/evidence/ha-dashboard-preview-20261009/run-01-local-check.md @@ -0,0 +1,13 @@ +# HA 대시보드 표시 변경 로컬 검증 — ha-dashboard-preview-20261009-01 + +| 항목 | 기록 | +| --- | --- | +| 실행 시각 | 2026-10-09 14:09:52 KST | +| 목적 | 기존 Grafana JSON의 상태 타임라인·현재 리더 카드·앱 오류 패널을 정적으로 검증 | +| 위치·기준 | 로컬 분리 작업 트리, `test/448`의 `c794914` 기반 미커밋 변경 | +| 절차 | `python3 -m unittest monitoring/grafana/tests/test_ha_dashboard.py -v`; `python3 -m json.tool monitoring/grafana/dashboards/docgrid-ha-demo.json`; `git diff --check` | +| 통과 기준 | 단위 테스트 전부 통과, JSON 파싱 성공, diff 공백 오류 0건 | +| 관측 결과 | 단위 테스트 **9/9 통과**, JSON 파싱 성공, diff 검사 오류 **0건** | +| 미검증 | 실제 Grafana 가져오기·시각 표시, 설치된 Patroni/Hikari 메트릭·PromQL 실행, OpenProxy 포트 관측, 장애 시연 | + +이 기록은 로컬 정적 검증 결과이며 GCP 관측 VM에 적용됐다는 증거가 아니다. 원격 접근에는 별도 승인이 필요한 임시 OS Login 키 등록이 막혀 있어, 원격 결과를 추정해 기록하지 않았다. diff --git a/docs/test-results/evidence/ha-dashboard-preview-20261009/run-02-live-apply.md b/docs/test-results/evidence/ha-dashboard-preview-20261009/run-02-live-apply.md new file mode 100644 index 00000000..7cb333fe --- /dev/null +++ b/docs/test-results/evidence/ha-dashboard-preview-20261009/run-02-live-apply.md @@ -0,0 +1,18 @@ +# HA 대시보드 실제 적용 확인 — ha-dashboard-live-20261009-01 + +| 항목 | 기록 | +| --- | --- | +| 실행·확인 시각 | 2026-10-09 14:32:45 KST | +| 목적 | 변경한 Grafana 대시보드를 관측 VM에 적용하고 기존 수집 경로·새 PromQL 응답을 확인 | +| 실행 위치 | GCP 내부 관측 VM의 Prometheus·Grafana; 로컬 IAP SSH 터널은 화면 확인용 | +| 코드 기준 | `test/448`의 `c794914` 기반 미커밋 대시보드 변경 | +| 사전 조건 | 기존 대시보드 8패널의 ID·제목·쿼리 해시가 저장소 기준과 일치; 앱 2대·Patroni 3대 active target **5/5 up** | +| 방법 | Prometheus API에서 필요한 지표명·새 패널 PromQL 조회 → Grafana 기존 JSON을 0600 백업 → Dashboard API로 JSON 가져오기 → API 재조회 → 임시 OS Login 키 제거 | +| 통과 기준 | DB 상태 결과 3개(Primary 1·Replica 2), 현재 primary·timeline 각 1개, 앱 지표 각 2개, Grafana 12패널·상태 타임라인 2개·숫자 카드 2개, 임시 키 잔존 0개 | +| 실제 결과 | DB 역할 **3개(Primary 1·Replica 2)**, primary 카드 **1개**, 해당 timeline **14**, 앱 5xx·Hikari timeout 시계열 **각 2개·현재 값 모두 0**, Grafana 가져오기 `success`, UID 유지·버전 **2→3**·패널 **8→12**, 임시 OS Login 키 잔존 **0개**, 로컬 개인키·공개키 삭제 | +| 화면 접근 | 기존 로컬 포트는 다른 SSH 프로세스가 점유해 새 로컬 포트로 터널을 열었고 Grafana 로그인 HTTP **200** 확인. 사용자 로그인 후 실제 화면에서 primary 카드 **1개**, timeline 카드 **14**, 앱 상태 **2행**, DB 역할 **3행(Primary 1·Replica 2)**의 색·문구를 확인 | +| 미실행 | 장애 주입, OpenProxy TCP probe, 앱→프록시 연결 지표, 장애 annotation 이벤트 기록, HTTP 쓰기 부하·DB 요청 ID 대조 | + +처음에는 관측 VM 호스트의 loopback Prometheus 주소로 요청해 연결에 실패했다. Prometheus가 컨테이너 내부 네트워크에서만 응답하는 실제 구성을 확인해, 컨테이너 내부 주소를 쉘 변수로만 사용하여 재조회했다. 내부 주소 자체는 이 기록에 남기지 않았다. + +Grafana 대시보드 원본 백업은 관측 VM의 소유자 전용 임시 파일에 남겼다. 관리자 암호는 VM 내부의 기존 보호 파일에서 API 인증에만 사용했고 명령 결과·시험 기록에 출력하지 않았다. 이 실행은 **정상 상태의 지표·API·화면 렌더링 확인**이며, 실제 장애 상황에서 모든 색·막대가 원하는 형태로 바뀌는지 또는 RPO가 0인지 판정하지 않는다. diff --git a/docs/test-results/evidence/ha-dashboard-preview-20261009/run-03-openproxy-compact.md b/docs/test-results/evidence/ha-dashboard-preview-20261009/run-03-openproxy-compact.md new file mode 100644 index 00000000..d363f29d --- /dev/null +++ b/docs/test-results/evidence/ha-dashboard-preview-20261009/run-03-openproxy-compact.md @@ -0,0 +1,32 @@ +# OpenProxy 포트 관측·상단 패널 축소 실측 — ha-dashboard-proxy-20261009-01 + +| 항목 | 기록 | +| --- | --- | +| 실행 시각 | 2026-10-09 14:40~14:48 KST | +| 목적 | OpenProxy A/B 포트의 관측 VM 기준 TCP 연결을 Grafana에 표시하고, primary·timeline·앱·DB 상태 패널을 축소 | +| 실행 위치 | 로컬 검증, GCP 내부 관측 VM의 Prometheus·Grafana·Blackbox Exporter, 기존 앱 2대·DB 3대 | +| 코드 기준 | `test/448`의 `c794914` 기반 미커밋 변경. 이번 확인은 PR·배포 JAR 변경이 아님 | +| 방법 | 로컬 Dashboard 구조 검사 → 관측 VM에서 두 프록시 포트 TCP 확인 → TCP 전용 Exporter 실행 → Prometheus 설정 문법 검사·재적용 → `up`·`probe_success` 쿼리 → Grafana API·실제 화면 확인 | +| 통과 기준 | 앱 2/2·Patroni 3/3·프록시 scrape 2/2, `probe_success` A/B 모두 1, 호스트 포트 비공개, Grafana 14패널·상단 높이 4 이하, 임시 접근 키 제거 | + +## 실행 결과 + +| 확인 | 관측 결과 | 해석 | +| --- | --- | --- | +| 로컬 구조 검사 | `python3 -m unittest monitoring/grafana/tests/test_ha_dashboard.py -v`: **10/10 통과**; JSON 파싱·`git diff --check` 통과 | 패널 ID·배치·쿼리·의미 검사를 통과 | +| TCP 접속 사전 점검 | 관측 VM→OpenProxy A/B 포트 **2/2 연결 성공** | 기존 네트워크 규칙으로 접근 가능해 **새 방화벽 규칙은 만들지 않음** | +| 새 수집 경로 | Prometheus 설정 `promtool check config` 통과, Blackbox Exporter 실행, scrape **2/2 up** | 관측 VM의 TCP probe가 주기적으로 실행됨 | +| 전체 수집 대상 | 앱 **2/2**, Patroni **3/3**, OpenProxy TCP **2/2** | 해당 조회 시점의 7개 target 모두 수집 중 | +| 실제 포트 결과 | `probe_success`: proxy-A **1**, proxy-B **1** | 관측 VM에서 두 TCP 포트에 접속 가능. **SQL 성공을 뜻하지 않음** | +| 외부 노출 | Exporter `9115/tcp`의 호스트 바인딩 **없음** | 관측 VM의 내부 Docker 네트워크에서만 Prometheus가 접근 | +| Grafana 화면 | 기존 UID 유지, 버전 **3→4**, 패널 **12→14**, 프록시 연결 수 **2**, A/B 녹색 막대 2행 | 로그인된 실제 화면에서 신규 패널·축소된 카드 및 상태 타임라인 렌더링 확인 | +| 기존 상태 | 현재 primary **db-node3**, 해당 timeline **14** | 화면 확인 시점의 표시값이며 장애 시험 결과 아님 | +| 정리 | 임시 OS Login 공개키 제거·로컬 개인키 삭제·원격 전송 임시 파일 삭제 | 운영 설정과 롤백용 보호 백업은 유지 | + +## 실패·재시도와 한계 + +- 로컬 YAML 검사 첫 시도는 `PyYAML` 미설치로 실패했다. 같은 설정을 Ruby YAML 파서로 확인했고, 최종 Prometheus 설정은 실제 VM의 `promtool` 검사에 통과했다. +- 첫 `promtool` 실행은 0600 임시 파일을 비특권 컨테이너가 읽지 못해 실패했다. 파일 권한을 낮추지 않고 일회성 검증 컨테이너만 root로 재실행하여 통과했다. +- Exporter 실행과 포트 바인딩 확인에서 원격 명령 인용 오류가 각각 1회 있었다. 오류 명령은 설정을 변경하지 않았고 수정 후 성공했다. +- 실제 OpenProxy 중단·복구 장애는 이번 실행에서 주입하지 않았다. 빨간색 전환, 앱→프록시 실제 사용 경로, SQL 성공, HTTP 부하·DB 정합성은 이 결과로 검증되지 않는다. +- k6 패널은 별도 부하 실행이 없으면 빈 상태가 정상이다. 이 화면의 녹색 포트 막대만으로 가용성·RPO를 주장하지 않는다. diff --git a/docs/test-results/evidence/ha-dashboard-preview-20261009/run-04-status-code-local.md b/docs/test-results/evidence/ha-dashboard-preview-20261009/run-04-status-code-local.md new file mode 100644 index 00000000..d1b85cbe --- /dev/null +++ b/docs/test-results/evidence/ha-dashboard-preview-20261009/run-04-status-code-local.md @@ -0,0 +1,24 @@ +# 응답코드 계측·대시보드 로컬 회귀 — ha-dashboard-code-20261009-01 + +| 항목 | 기록 | +| --- | --- | +| 실행 날짜·시간대 | 2026-10-09 KST. 정확한 시작·종료 시각은 별도로 보존하지 않아 추정하지 않음 | +| 목적 | k6의 2xx·3xx·4xx·5xx 무태그 Counter, 안전 원장, 최종 Remote Write 대조 및 Grafana 카드·배치가 함께 작동하는지 검사 | +| 위치·기준 | 로컬 `test/448`, 기반 커밋 `c79491408d84` + 이 작업의 미커밋 변경. GCP 앱 JAR 회귀 시험이 아님 | +| 명령 | `python3 -m unittest discover -s scripts/opensql -p 'test_*ha*py'`; `python3 -m unittest discover -s monitoring/grafana/tests -p 'test_ha_dashboard.py'`; `python3 -m json.tool monitoring/grafana/dashboards/docgrid-ha-demo.json`; `git diff --check` | +| 통과 기준 | 관련 Python 시험 모두 성공, JSON 파싱·diff 공백 검사 성공, k6 설치 VM에서 실제 스크립트 파싱 성공 | + +## 관측 결과 + +| 검사 | 결과 | 해석 | +| --- | --- | --- | +| HA 관련 Python 시험 | **85/85 통과**, 실패 0 | 안전 이벤트·원격 합계·원장 재생의 200/201/3xx~5xx 경계를 포함 | +| Grafana 정적 시험 | **11/11 통과**, 실패 0 | 누적 카드 4개, 실행 ID 필터, 역할 색, 타임라인 크기, 프록시 범위, 15초 timeout 쿼리 검사 | +| JSON·diff | 파싱 성공, `git diff --check` 성공 | 저장소 JSON 구문·공백 검사 통과. 실제 Grafana 렌더링의 단독 증거는 아님 | +| GCP 부하 VM의 `k6 inspect -e ...` | 종료 코드 **0** | 설치된 k6가 새 JS를 파싱함. 더미 토큰만 사용하여 HTTP 요청 없음 | + +첫 Grafana 정적 시험은 설명문에 ‘결과 불명’ 용어가 빠져 **1건 실패**했다. 설명문을 고치고 재실행하여 11/11 통과했다. 첫 `k6 inspect`는 옵션 대신 프로세스 환경 변수만 주어 필수 `__ENV`가 없다는 파싱 전 오류(종료 코드 107)를 냈다. `-e` 인자로 수정한 재실행은 통과했다. 이 두 실패를 실제 부하 시험 실패나 제품 결함으로 합치지 않는다. + +최종 변경 후 핵심 4개 모듈을 다시 돌려 **35/35 통과**했고 JSON 파싱·`git diff --check`도 통과했다. 별도로 로컬 Python 전체 검색을 시도한 결과 처음에는 **134건 중 5건이 샌드박스의 loopback 소켓 바인딩 제한으로 실행 오류**가 났다. 같은 명령을 loopback 사용이 허용되는 환경에서 다시 실행해 **134/134 통과**했다. 최초 제한을 제품 실패로 판정하지 않으며, 재실행 결과로 로컬 전체 Python 검사 통과를 확인했다. + +원시 테스트 stdout에는 합성 UUID 등이 포함되어 저장하지 않았다. 이 파일은 검사 건수·오류 종류만 별도로 정리한 안전 요약이다. diff --git a/docs/test-results/evidence/ha-dashboard-preview-20261009/run-05-expired-fixture-smoke.md b/docs/test-results/evidence/ha-dashboard-preview-20261009/run-05-expired-fixture-smoke.md new file mode 100644 index 00000000..b3583d7b --- /dev/null +++ b/docs/test-results/evidence/ha-dashboard-preview-20261009/run-05-expired-fixture-smoke.md @@ -0,0 +1,20 @@ +# 최초 접속 시험의 권한 실패 — hadash1009sm01 + +| 항목 | 기록 | +| --- | --- | +| 시각 | 2026-10-09 **15:11:06~15:11:12 KST** (원본 UTC 06:11:06~06:11:12) | +| 목적·위치 | GCP 내부 부하 VM에서 앱 LB를 통한 HTTP 쓰기와 새 Remote Write 지표를 소량으로 확인 | +| 명령·부하 | `run_ha_probe_k6.sh hadash1009sm01 1 5s <내부 LB> <0600 단기 JWT> baseline-write 5`; 목표 1 req/s·5초, 초기 VU 5 | +| 통과 기준 | 201 응답, k6 미전송 0, 코드 대역·원장·Prometheus 최종 합계 일치 | + +| 관측 | 값 | 판정 | +| --- | ---: | --- | +| HTTP 요청·반복 | 6건·6회 | 실제 요청 전송 확인 | +| HTTP 403 / 201 | **6건 / 0건** | **기준선 실패**, k6 종료 코드 99 | +| 미전송·결과 불명 | 0건·0건 | 부하 발생기 드롭이나 네트워크 결과 불명이 원인은 아님 | +| p95 / p99 | 30.32ms / 33.31ms | 실패 응답의 지연. 정상 쓰기 지연과 비교하지 않음 | +| 안전 이벤트 | 12건(전송 6 + 종료 6) | 실행 중 실행별 파일로 수집 | +| 4xx Counter 대조 | 원장 6건 = Prometheus 6건 | **새 코드 대역 계측 자체는 동작** | +| DB 요청 ID 대조 | 미시행 | 201이 없어 성공 쓰기 보존 시험이 아님 | + +기존 합성 관리자 식별자로 서명한 새 토큰을 사용했으나, 앱은 403을 반환했다. **기존 계정 부재인지 ADMIN 권한 부재인지는 이번 실행에서 분리하지 않았다.** 세 DB 노드의 독립 정리 타이머를 먼저 무장한 뒤 새 합성 관리자 fixture를 만들고, 다음 실행 ID `hadash1009sm02`로 재시도했다. 이 실패는 감추거나 성공 기준선에 섞지 않는다. 토큰 값·내부 주소·실제 사용자 정보는 이 기록에 남기지 않았다. diff --git a/docs/test-results/evidence/ha-dashboard-preview-20261009/run-06-guarded-fixture-smoke.md b/docs/test-results/evidence/ha-dashboard-preview-20261009/run-06-guarded-fixture-smoke.md new file mode 100644 index 00000000..bab1558e --- /dev/null +++ b/docs/test-results/evidence/ha-dashboard-preview-20261009/run-06-guarded-fixture-smoke.md @@ -0,0 +1,21 @@ +# 새 합성 관리자 접속 시험 — hadash1009sm02 + +| 항목 | 기록 | +| --- | --- | +| 시각 | 2026-10-09 **15:14:29~15:14:36 KST** (원본 UTC 06:14:29~06:14:36) | +| 목적·위치 | 보호된 합성 ADMIN fixture로 GCP 내부 LB→앱→OpenSQL 쓰기 및 Remote Write를 소량 검증 | +| 안전 관문 | DB 역할 replica 2·primary 1, 기존 시험 계정 0명. DB 3대에 **20분 독립 정리 타이머 3/3** 무장 후 합성 계정 4명 생성. JWT TTL 20분·0600 | +| 명령·부하 | `run_ha_probe_k6.sh hadash1009sm02 1 5s <내부 LB> <0600 단기 JWT> baseline-write 5`; 목표 1 req/s·5초, 초기 VU 5 | +| 통과 기준 | 모든 응답 201, 미전송 0, 안전 이벤트 완결, 원격 지표 최종 합계 일치 | + +| 관측 | 값 | 판정 | +| --- | ---: | --- | +| HTTP 201 / 전체 | **6 / 6건** | 통과, k6 종료 코드 0 | +| 미전송·HTTP 실패·결과 불명 | **0·0·0건** | 요청 경로 정상 | +| 2xx / 3xx / 4xx / 5xx | **6 / 0 / 0 / 0건** | 대역별 누적 Counter 확인 | +| p95 / p99 | 101.03ms / 103.08ms | 6건의 작은 표본; 성능 결론에 사용하지 않음 | +| Remote Write 최종 대조 | 원장 6건 = Prometheus 6건, 첫 조회 일치 | 총합 전달 확인. 중간 전송 공백은 이 대조로 검증하지 않음 | +| 실시간 안전 이벤트·1초 계측 | 12건·검증 종료 코드 0 | 전송 6 + 종료 6의 별도 실행 원장 | +| DB 요청 ID 대조 | 미시행 | 다음 60초 기준선에서 개별 대조 수행 | + +이 성공은 앞선 403 실행을 성공으로 바꾸지 않는다. 합성 계정은 뒤의 기준선까지 유지한 다음 primary에서 제거하고, replica 2대에서 계정 0명을 확인한 후 정리 타이머를 3/3 해제했다. diff --git a/docs/test-results/evidence/ha-dashboard-preview-20261009/run-07-http-baseline.md b/docs/test-results/evidence/ha-dashboard-preview-20261009/run-07-http-baseline.md new file mode 100644 index 00000000..c55ca114 --- /dev/null +++ b/docs/test-results/evidence/ha-dashboard-preview-20261009/run-07-http-baseline.md @@ -0,0 +1,35 @@ +# 실행 ID별 응답코드 카드·DB 대조 기준선 — hadash1009b01 + +| 항목 | 기록 | +| --- | --- | +| 시각 | 2026-10-09 **15:14:54~15:15:55 KST** (원본 UTC 06:14:54~06:15:55) | +| 목적·위치 | GCP 내부 k6→LB→앱 A/B→OpenSQL 정상 쓰기 중 코드 대역 카드와 응답/초가 실제 채워지는지, 응답별 요청 ID가 primary DB와 일치하는지 확인 | +| 코드 기준 | 저장소 `test/448`, 기반 커밋 `c79491408d84` + 이 작업의 미커밋 k6 계측 변경. GCP 백엔드 JAR은 변경하지 않음 | +| 명령·부하 | `run_ha_probe_k6.sh hadash1009b01 30 60s <내부 LB> <0600 단기 JWT> baseline-write 80`; 목표 **30 req/s·60초**, 초기 VU 80, 최대 VU 160, timeout 10초 | +| 통과 기준 | 미전송 0, 201 이외 응답·결과 불명 0, 원격 최종 합계 일치, 201별 DB 1행·누락·중복 0 | + +## 실제 결과 + +| 지표 | 관측값 | 해석 | +| --- | ---: | --- | +| HTTP 요청 / 반복 | **1,801 / 1,801건** | k6 종료 코드 0 | +| 201 / 5xx / 결과 불명 / 미전송 | **1,801 / 0 / 0 / 0건** | 정상 기준선 통과 | +| 2xx / 3xx / 4xx / 5xx 누적 | **1,801 / 0 / 0 / 0건** | Grafana 실행 ID `hadash1009b01` 카드에 실제 1,801·0·0·0 표시 확인 | +| p50 / p95 / p99 | **15.84 / 55.87 / 58.02ms** | k6 최종 요약의 전체 실행 분포; 실시간 구간별 백분위 아님 | +| Remote Write 최종 대조 | 원장 1,801 = Prometheus 1,801, **첫 조회 일치** | 2xx·201 등 최종 Counter 검증. 중간 유실 없음 보장은 아님 | +| DB 고유 ID / 총 행 | **1,801 / 1,801** | primary의 읽기 전용 실행 ID export | +| 201 ID 누락 / 중복 / 고아 DB ID | **0 / 0 / 0건** | 안전 이벤트 3,602건의 요청 ID와 DB CSV를 개별 대조 | +| 1초 계측 | **61행**, 검증 종료 코드 0 | HTTP·미전송 전 구간 샘플 보존 | +| 부하 VM 호스트 CPU / k6 CPU 최댓값 | **10.61% / 18.95%** | 각각 유효 표본 59개; 시작·종료 부근 2개는 값 없음 | +| 활성 VU 최댓값 / k6 RSS 최댓값 | **2 / 70.17 MiB** | 각 유효 표본 60개; VU는 DB 연결 수가 아님 | + +## 원본 근거와 한계 + +- [사전 allowlist 검사 후 복사한 k6 이벤트](hadash1009b01/safe-events.jsonl.gz): SHA-256 `d6ea5c3c8f8f461b96221877c315b565e2371c45cc7146b40744616fb541acde`. +- [primary의 읽기 전용 요청별 행 수](hadash1009b01/db-counts.csv.gz): SHA-256 `f9e94d14a94b966f722678ce44a7504c789585d32d630fa4be10eb2ad559e134`. +- [부하 VM의 1초 숫자 계측](hadash1009b01/계측-1초.csv): SHA-256 `867981a351880d62ae5a7a0f66db1db57f65dd3221630e85d5fe99765802cc4e`. + +원본 이벤트는 `safe_event`가 URL·토큰·추가 키를 **복사 전에 거부**한 결과이고, DB CSV는 합성 요청 ID와 행 수만 담는다. 1초 CSV의 호스트 CPU·k6 CPU는 61행 중 2행, 활성 VU·RSS는 1행이 공란이다. 최종 Counter 일치는 중간 시계열 전송의 매 순간 성공이나 장애 시 RPO 0을 증명하지 않는다. 이 실행에는 장애를 주입하지 않았다. + +정리: 합성 관리자 4명을 primary에서 제거하고 DB 3대 모두 잔여 **0명**, 독립 정리 타이머 **0/3 활성**을 확인했다. 단기 JWT는 별도 비밀 파일로만 다뤘고 공개 증거에 포함하지 않았다. +추가 정리: VM 간 전송에 사용한 시험용 JWT 사본과 로컬 임시 사본, 이번 시험용 임시 OS Login 공개키·개인키를 제거했다. 보존한 요청 원장·DB 대조·1초 계측의 저장소 사본은 삭제 전 SHA-256으로 로컬 원본과 일치함을 확인했다. diff --git a/docs/test-results/evidence/ha-dashboard-preview-20261009/run-08-app-b-status-rehearsal.md b/docs/test-results/evidence/ha-dashboard-preview-20261009/run-08-app-b-status-rehearsal.md new file mode 100644 index 00000000..4659d4bc --- /dev/null +++ b/docs/test-results/evidence/ha-dashboard-preview-20261009/run-08-app-b-status-rehearsal.md @@ -0,0 +1,23 @@ +# 앱 B 상태 타임라인·장애 표식 리허설 — ha-dashboard-app-b-20261009-01 + +| 항목 | 기록 | +| --- | --- | +| 날짜·위치 | 2026-10-09 KST, GCP 앱 B·내부 LB·별도 관측 VM의 Grafana/Prometheus | +| 목적 | 앱 B가 잠시 수집 불가일 때 타임라인이 빨강으로 바뀌고 복구 후 녹색으로 돌아오며, 앱 A는 수집 성공으로 남는지 확인 | +| 사전 관문 | 앱 A/B 서비스 active, LB **HEALTHY 2/2**. 앱 B에 **90초 독립 `systemd-run` 재시작 타이머**를 먼저 무장 | +| 절차 | 앱 B `docgrid.service` 중지 → Grafana 앱 상태·annotation 확인 → 앱 B 시작 → 로컬 metrics 및 LB health 확인 → 관측 VM의 5초 `up` 표본 대조 | +| 통과 기준 | app-B `up: 1→0→1`, app-A `up=1` 유지, 최종 LB 2/2, DB·프록시 상태 유지. 사용자 HTTP 결과는 별도 부하가 없으면 주장하지 않음 | + +## 관측 결과 + +| 확인 | 값 | 해석 | +| --- | --- | --- | +| app-A `up` | 5초 표본 **49/49에서 1** | 리허설 중 A의 지표 수집 유지 | +| app-B `up` | **15:24:55 KST 1→0**, **15:26:20 KST 0→1** | 수집 실패 구간 **약 85초**. 5초 표본 경계의 관측값이며 서비스 중단의 정확한 RTO는 아님 | +| Grafana 화면 | app-B만 녹색→빨강→녹색, app-A 녹색 유지 | 상태 타임라인의 실패·복구 색 실제 렌더링 확인 | +| 장애·복구 annotation | **15:25:06.526**, **15:26:48.112 KST** | 각각 조작 뒤와 최종 확인 뒤 기록. 주입/복구의 정확한 시각으로 사용하지 않음 | +| LB health | 사전 **2/2**, 복구 중 **1/2**, 최종 **2/2 HEALTHY** | 앱 B가 재편입된 사실 확인. LB 전환 지연의 정밀 계측은 아님 | +| 앱 B 서비스·복구 타이머 | 최종 `active/running`, 포트 2개 listen, 타이머 `inactive` | 복구 후 추가 강제 재시작 대기 없음 | +| 사용자 HTTP 결과·DB 대조 | **미시행** | k6는 리허설 전에 종료. 무중단/데이터 정합성 시험으로 해석하지 않음 | + +독립 타이머의 기동·앱 B 서비스 중지·수동 시작은 수행했다. 최종 시점에 타이머는 inactive였으며, 서비스 재기동과 LB 2/2를 별도로 확인했다. 화면의 붉은 띠는 **Prometheus 수집 실패**이고 VM 정지나 모든 요청 실패의 단독 증거가 아니다. 이번 리허설에서 앱·DB·프록시 VM은 새로 만들거나 삭제하지 않았다. diff --git a/docs/test-results/evidence/ha-dashboard-preview-20261009/run-09-live-dashboard-apply.md b/docs/test-results/evidence/ha-dashboard-preview-20261009/run-09-live-dashboard-apply.md new file mode 100644 index 00000000..1520b359 --- /dev/null +++ b/docs/test-results/evidence/ha-dashboard-preview-20261009/run-09-live-dashboard-apply.md @@ -0,0 +1,22 @@ +# 응답코드 카드 실제 대시보드 적용 — ha-dashboard-live-20261009-02 + +| 항목 | 기록 | +| --- | --- | +| 실행 날짜·시간대 | 2026-10-09 KST. 단계별 정확한 API 호출 시각은 미보존 | +| 목적·위치 | GCP 관측 VM의 기존 인증 Grafana에 저장소 Dashboard JSON을 적용하고 사용자 브라우저에서 렌더링 확인 | +| 기준 | 기존 UID `docgrid-opensql-ha-live`, 버전 4·14패널. 백엔드 JAR·DB·Prometheus scrape 설정은 변경하지 않음 | +| 방법 | 기존 API 응답을 관측 VM에 0600 백업 → 동일 UID JSON을 loopback API로 업로드 → API 재조회 → 로그인된 실제 화면 확인 | +| 통과 기준 | UID 유지, **18패널**, 코드 대역 카드 4개·선택 실행 ID·상태 타임라인·프록시 2/2 표시, 기존 지표 유지 | + +| 확인 | 결과 | 해석 | +| --- | --- | --- | +| Grafana 가져오기 | `success`, 버전 **4→5→6→7**, 최종 **18패널** | 세 번의 순차 조정 모두 기존 대시보드 백업 후 적용. 최종 버전 7이 현재 화면 | +| 실행 ID 선택 | `hadash1009b01` 선택 | 다른 실행의 403을 기준선 카드에 합산하지 않음 | +| 카드 | `2xx 1,801`, `3xx 0`, `4xx 0`, `5xx 0` | 실제 60초 기준선 종료 후 화면에서 정확한 정수 확인 | +| 상단 상태 | 현재 primary `db-node3`, timeline `14`, OpenProxy 포트 `2 / 2` | 화면 값이며 SQL 성공/쓰기 RTO 증거와는 구분 | +| 상태·배치 | app-A→app-B 순서, PRIMARY 주황, 타임라인 확대, 하단 보조 그래프 축소 | 장애 색과 사용자 결과가 구별되도록 조정 | +| 증분·축 | Hikari timeout **최근 15초**, 5xx·대기축 soft max, 선 점 숨김 | 짧은 변화와 평상시 0선을 읽기 쉽게 함 | +| 장애 annotation | 앱 B 리허설에 중지·복구 표식 2개, 실제 타임라인 빨강→녹색 | 표식은 조작·검증 후 수동 기록이며 지표 전환 시각과 같지 않음 | + +관측 VM의 과거 대시보드 원본은 0600 롤백용 백업으로 보존했다. 관리자 비밀번호·사설 IP·토큰을 API 결과·이 문서·저장소에 기록하지 않았다. Grafana는 같은 기존 IAP 터널과 인증된 사용자 화면에서 확인했고 외부 공개 포트는 추가하지 않았다. +적용에 사용한 관측 VM의 임시 JSON·스크립트 파일과 임시 OS Login 키는 확인 후 제거했다. 기준선 카드 값은 실행 후 약 10분간 현재 화면에서 조회할 수 있고, 이후에는 해당 실행의 **고정 시간 범위**를 선택해야 한다. 이는 누적값이 삭제된 것이 아니라 카드 쿼리의 10분 lookback 경계다. diff --git a/docs/test-results/evidence/ha-dashboard-preview-20261009/run-10-pr-review.md b/docs/test-results/evidence/ha-dashboard-preview-20261009/run-10-pr-review.md new file mode 100644 index 00000000..956e17ce --- /dev/null +++ b/docs/test-results/evidence/ha-dashboard-preview-20261009/run-10-pr-review.md @@ -0,0 +1,21 @@ +# HA 대시보드 변경 범위·PR 제출 전 검증 — ha-dashboard-pr-review-20261009-01 + +| 항목 | 기록 | +| --- | --- | +| 실행 시각 | 2026-10-09 15:43 KST | +| 목적·위치 | 로컬 분리 작업 트리 `test/448`에서 PR 전체 범위, 설정 구문, 시험 증거 및 비밀정보 혼입 여부를 확인 | +| 기준 | 원격 `develop`의 `6566da5`와 기존 `test/448` 커밋 3개에 이 작업의 추가 변경을 합친 범위. GCP 장애를 새로 주입하지 않음 | +| 통과 기준 | 관련 검사 전부 성공, 압축 증거 복원 가능, 공개 문서·설정에 비밀값·사설 주소·개인 경로 없음, 의도하지 않은 파일 스테이징 없음 | + +| 실행 위치·명령 또는 절차 | 관측 결과 | 해석 | +| --- | --- | --- | +| 로컬: `python3 -m unittest discover -s scripts/opensql -p 'test_*.py'` | **134/134 통과** | HA 계측·원장·기존 OpenSQL 도구 회귀 검사 | +| 로컬: `python3 monitoring/grafana/tests/test_ha_dashboard.py` | **11/11 통과** | 누적 카드·상태 타임라인·접근 경계의 정적 검사 | +| 로컬: `python3 monitoring/grafana/validate_dashboard.py monitoring/grafana/dashboards/docgrid-operations.json` | **36패널·32 PromQL, 성공** | 기존 운영 Dashboard 검사도 유지 | +| 로컬: JSON 파싱, Ruby YAML 파싱, `gzip -t`, `git -c core.whitespace=cr-at-eol diff --cached --check` | **모두 통과** | 대시보드·Blackbox 예시·압축 증거·공백 오류 확인. 원본 1초 CSV의 CRLF는 줄 끝 문자로 취급 | +| 로컬: 공개 문서·Dashboard·Blackbox 설정의 개인 경로, 사설 IPv4, JWT·토큰·개인키 패턴 검색 | **탐지 0파일** | 패턴 검사 결과이며 모든 종류의 정보 유출을 수학적으로 증명하는 것은 아님 | +| 로컬: 압축 이벤트·DB 대조 파일의 같은 패턴 검사 | **탐지 0파일** | 게시할 압축 증거의 비식별 검사 | + +기존 장애 보고서는 첫 실행 당시 **8패널**과 이후 확장한 **18패널**을 명시적으로 구분하도록 보완했다. 첫 실행의 OpenProxy·primary 장애 수치를 새 상태코드 카드에서 재측정했다고 주장하지 않는다. 추가 시험의 403 6건과 정상 기준선 201 1,801건도 서로 다른 실행 ID·파일로 유지한다. 이 검사는 저장소와 기존 증거의 PR 준비 확인이며, 새 GCP 장애 시험이나 CI 결과를 대신하지 않는다. + +첫 스테이징 후 기본 `git diff --cached --check`는 1초 CSV의 **CRLF 62행**을 trailing whitespace로 보고 종료 코드 2를 냈다. 수집된 원본 바이트와 문서의 SHA-256을 보존하기 위해 CSV를 다시 쓰지 않고, Git의 `cr-at-eol` 옵션으로 **실제 줄 끝 공백 오류 0건**을 재확인했다. 이 실패·재검사 경로를 정상 통과 결과와 구분한다. diff --git a/docs/test-results/evidence/issue-448/dashboard-and-preflight.md b/docs/test-results/evidence/issue-448/dashboard-and-preflight.md new file mode 100644 index 00000000..2b870937 --- /dev/null +++ b/docs/test-results/evidence/issue-448/dashboard-and-preflight.md @@ -0,0 +1,23 @@ +# 관측 화면·사전/사후 안전 점검 + +- 실행 ID: `ha448pre01`; 관측일: 2026-10-09 KST; 위치: GCP API, 관측 VM, DB 3대, 부하 VM. +- 목적: 실제 장애 전후 접속·복제·백업 관문을 확인하고 Grafana가 실제 시계열을 읽는지 검증한다. +- 성공 기준: VM 8/8, LB 2/2, primary 1·replica 2, etcd 3/3, OpenProxy 2/2, 관측 대상 5/5, Dashboard 조회 가능. 값이 불명확하면 장애 주입 중단. + +| 위치·명령/방법 | 결과 요약 | 해석 | +| --- | --- | --- | +| 로컬 GCP API: `gcloud compute instances list --format=json(name,zone,status)` | 시험에 필요한 VM **8/8 RUNNING**; 별도 과거 VM 1대는 TERMINATED | 장애 전 구성요소가 실제 기동 중이다. VM 수와 DB/프록시 논리 수는 다르다. | +| GCP API: `backend-services get-health` | LB 앱 A/B **HEALTHY 2/2** | 내부 HTTP 경로의 사전 관문 통과. | +| DB 노드: Patroni `/cluster`, `permission-node-probe health` | node3 leader 1, node1/2 streaming replica 2, lag **0/0**, primary 복제 연결 **2** | 당일 실제 역할 확인. 이전 문서 값을 재사용하지 않았다. | +| DB 노드: etcd endpoint health/member 조회 | 건강한 멤버 **3/3** | 정족수 관문 통과. 이번에는 etcd를 중단하지 않았다. | +| DB 노드: OpenProxy process/port 상태 | A/B **2/2 ready** | 장애 전에 프록시 중복 경로가 살아 있다. | +| GCP API: DB 디스크 snapshot 상태 | 관련 snapshot **8개 READY** | 복구 수단 확인. 이번 프로세스 장애에서 snapshot restore는 필요하지 않았다. | +| 관측 VM: Grafana Dashboard API 가져오기·읽기 | UID `docgrid-opensql-ha-live`, Row 포함 패널 **8개**, Prometheus UID `ha-prometheus`; 최종 재가져오기 `description_match=true` | JSON을 파일로만 작성한 게 아니라 실제 관측 VM에 배포했다. | +| 관측 VM: Prometheus `/api/v1/query` | 앱·Patroni 대상 **5/5**, primary=1 값 1개, replica WAL 패널 **2개·0 Byte** | 화면 패널이 실제 숫자를 반환한다. WAL receive−replay만으로 전체 복제 지연은 알 수 없다. | +| 관측 VM: `count_over_time`으로 실행별 k6 시계열 조회 | 기준선 201 표본 **13개**, 프록시 500 표본 **1개**, primary VU 표본 **25개** | 세 실행의 지표가 Remote Write를 거쳐 조회됨. 표본 수는 요청 건수가 아니다. | +| 로컬: `python3 -m unittest monitoring.grafana.tests.test_ha_dashboard -v` | **5/5 통과** | 데이터소스·패널 겹침·실패 버킷·run_id 범위·WAL replica 한정 정적 검증. | +| 로컬: 기존 `validate_dashboard.py`를 HA JSON에 직접 적용 | 전용 기존 Dashboard UID를 요구해 **적용 불가** | HA 전용 5건 테스트를 쓴다. 이 진단 실패를 HA JSON 통과로 바꾸어 말하지 않는다. | +| 장애 종료 후 GCP API/DB/관측 VM 재조회 | LB **2/2 HEALTHY**, leader 1·replica 2 streaming lag0, OpenProxy A/B ready, Prometheus/Grafana running | 시험 후 서비스 복구 확인. | + +관측 VM의 Prometheus는 내부 주소에만 바인딩돼 있어 `127.0.0.1:9090` 첫 조회는 **연결 실패**였다. 실제 바인딩된 내부 주소로 수정해 위 시계열을 조회했다. 내부 주소값은 명령 출력·문서에 기록하지 않았다. Prometheus의 `up=1`과 DB 요청 ID 정합성은 서로 다른 증거다. +최종 Grafana 재가져오기 뒤 임시 파일 제거의 첫 IAP 시도는 실패했고, 재시도에서 두 파일 모두 없는 것을 확인했다. 관리자 비밀번호는 관측 VM의 보호된 파일에서만 읽었으며 값은 출력하지 않았다. diff --git a/docs/test-results/evidence/issue-448/ha448-dashboard-regression-summary.md b/docs/test-results/evidence/issue-448/ha448-dashboard-regression-summary.md new file mode 100644 index 00000000..f12ee577 --- /dev/null +++ b/docs/test-results/evidence/issue-448/ha448-dashboard-regression-summary.md @@ -0,0 +1,11 @@ +# HA 대시보드 201 없는 실행 표시 회귀 요약 + +| 실행 | 목적 | 위치 | 주요 숫자 | 판정 | +| --- | --- | --- | --- | --- | +| [ha448dashunit01](ha448dashunit01.md) | 첫 정적 회귀 | 로컬 | 11/11, 오류 0 | 통과; 시각 정밀도 한계 기록 | +| [ha448dashpromql01](ha448dashpromql01.md) | 403-only 쿼리 첫 검증 | 로컬 Prometheus 3.5.5 | 0·0·6·0, 4/4 | 통과; 임시 픽스처 | +| [ha448dashunit02](ha448dashunit02.md) | 최종 정적 회귀·JSON | 로컬 | 11/11, JSON 통과 | 통과 | +| [ha448dashpromql02](ha448dashpromql02.md) | 커밋 픽스처 403·201·지표 부재 | 로컬 Prometheus 3.5.5 | 7/7 | 통과 | +| [ha448dashlive01](ha448dashlive01.md) | 403-only 실행의 실제 카드 표시 | GCP Grafana | 0·0·6·0 | 통과; 기존 실행 재조회 | + +별도의 GCP 장애·부하 시험은 실행하지 않았다. 기존 [403 실행 기록](../ha-dashboard-preview-20261009/run-05-expired-fixture-smoke.md)은 HTTP 403 **6건**, HTTP 201 **0건**과 4xx 원장·Prometheus 합계 일치를 보여준다. 이번 수정본을 실제 Grafana에 가져온 뒤 이 실행을 선택해 카드 **0·0·6·0건**을 확인했다. 비대화식 SSH 접속은 공개키 인증 단계에서 실패했고, Grafana Explore의 첫 과거 조회도 표본을 확인하지 못했다. 이후 대시보드의 정확한 과거 구간 조회로 카드 값을 확인했으며, 실패한 시도를 성공한 조회로 바꾸어 기록하지 않는다. diff --git a/docs/test-results/evidence/issue-448/ha448base01.md b/docs/test-results/evidence/issue-448/ha448base01.md new file mode 100644 index 00000000..8db0a408 --- /dev/null +++ b/docs/test-results/evidence/issue-448/ha448base01.md @@ -0,0 +1,14 @@ +# 정상 HTTP 쓰기 기준선 — `ha448base01` + +- 시간: 2026-10-09 **05:20:59~05:22:01 KST**. +- 위치: GCP 내부 부하 VM → 내부 LB → 앱 A/B → OpenProxy A/B → OpenSQL primary. +- 목적/성공 기준: 100 req/s·60초·초기 VU 160에서 미전송 0, 201 응답 전부 DB 1행, 원격 지표 합계 일치. + +| 실행 위치·명령/방법 | 결과 요약 | 해석 | +| --- | --- | --- | +| 부하 VM: `run_ha_probe_k6.sh ha448base01 100 60s <내부 LB URL> <보호된 JWT 파일> baseline-write 160` | HTTP **6,001건**, 201 **6,001**, dropped **0**, k6 exit **0** | 설정된 부하를 실제 전송했고 실패 응답은 없었다. | +| 부하 VM: `ha_load_telemetry.py summarize` | 활성 VU 최대 **3**, 안전 이벤트 **12,002**, VM/1초 검증 exit **0/0** | 사전 확보 VU 160과 실제 활성 VU는 다르다. | +| 관측 VM: Remote Write 최종 합계 대조 | 201 **6,001/6,001**, exit **0** | Dashboard의 해당 run_id를 조회할 수 있다. | +| 현재 primary: `export_ha_probe_counts.sh ha448base01` + 원장 대조 | DB 고유 ID **6,001**, 201 누락 **0**, 중복 **0**, 불명 **0** | 정상 기준선의 요청별 보존 관측. | + +p50/p95/p99는 **11.61/52.20/54.39 ms**다. 원장 SHA-256: `e899e03af52ed73e1458960a0c3e57dea63fe0b6f1a1aeb5f448e297993fee1c`. 이 1회 실행은 장애 없는 기준선이며 장기간 SLO나 반복 분포는 증명하지 않는다. diff --git a/docs/test-results/evidence/issue-448/ha448dashlive01.md b/docs/test-results/evidence/issue-448/ha448dashlive01.md new file mode 100644 index 00000000..31e38a0f --- /dev/null +++ b/docs/test-results/evidence/issue-448/ha448dashlive01.md @@ -0,0 +1,11 @@ +# Grafana 403-only 실행 화면 재확인 — ha448dashlive01 + +- 시각: 2026-10-09 **16:18 KST 결과 확인**. 가져오기 시작 시각은 별도 계측하지 않았다. +- 위치·개정: GCP 관측 VM의 Grafana, PR #449 후속 수정 JSON을 인증된 브라우저에서 기존 UID에 덮어쓰기. +- 목적: 201 응답이 0건인 기존 실행도 선택되고 네 누적 카드가 실제 원장과 일치하는지 확인. +- 방법: 기존 Grafana 정의를 읽기 전용으로 내보내 이전 실행 ID 쿼리를 확인 → 수정 JSON 가져오기 → 같은 UID 덮어쓰기 → 기존 `hadash1009sm01` 선택 → 2026-10-09 **15:10:00~15:13:00 KST** 조회. +- 성공 기준: 실행 ID 목록에 `hadash1009sm01` 표시, 2xx·3xx·4xx·5xx 카드 **0·0·6·0건**, 기존 원장의 HTTP 403 **6건**과 일치. +- 관측: 목록에서 해당 실행을 선택했고 카드가 **0·0·6·0건**으로 표시됐다. 원장에 기록된 HTTP 403 **6건**, HTTP 201 **0건**과 일치한다. 화면 캡처로 결과를 확인했다. +- 해석: 이 기존 실행의 Grafana 표시 문제는 실제 관측 화면에서 해소됐다. 새 GCP 부하나 장애 주입은 실행하지 않았다. +- 한계: 선택한 3분 그래프의 활성 VU 패널은 `No data`를 표시했다. 누적 카드의 10분 회고 쿼리와 그래프 조회는 시간·표본 방식이 다르므로 이를 VU 실시간 그래프의 성공으로 해석하지 않는다. 과거 실행 ID가 5초 자동 새로고침만으로 목록에 새로 나타나는지 역시 이번 조회로 증명하지 않았다. +- 정리: 가져오기 전에 만든 임시 내보내기 파일 **1개(50,726 Byte)**를 검증 후 삭제하고 부재를 확인했다. 실제 대시보드는 유지했다. DB·앱·프록시 상태는 변경하지 않았다. diff --git a/docs/test-results/evidence/issue-448/ha448dashpromql01.md b/docs/test-results/evidence/issue-448/ha448dashpromql01.md new file mode 100644 index 00000000..ce3833ba --- /dev/null +++ b/docs/test-results/evidence/issue-448/ha448dashpromql01.md @@ -0,0 +1,9 @@ +# 403 전용 합성 PromQL 첫 실행 — ha448dashpromql01 + +- 시각: 2026-10-09 16:05 전후 KST. 첫 합성 실행의 정확한 시작 시각은 수집하지 못했다. +- 위치·개정: 로컬 Docker의 기존 `prom/prometheus:v3.5.5` 이미지, 임시 픽스처. +- 목적: 201·2xx 응답이 없는 실행에서 네 누적 카드 쿼리의 실제 PromQL 결과 확인. +- 방법: `promtool test rules`에 VU 표본과 4xx **6건**만 있는 합성 시계열 입력. +- 성공 기준: 2xx·3xx·4xx·5xx가 각각 **0·0·6·0건**. +- 관측: 네 판정 모두 예상대로 평가되어 `SUCCESS`, 종료 코드 **0**. 컨테이너는 `--rm`으로 종료 후 제거됐다. +- 해석: Prometheus 쿼리 계산의 합성 검증이다. 과거 GCP 403 실행의 VU 표본 보존이나 실제 Grafana 표시를 증명하지 않는다. diff --git a/docs/test-results/evidence/issue-448/ha448dashpromql02.md b/docs/test-results/evidence/issue-448/ha448dashpromql02.md new file mode 100644 index 00000000..f8ee50d5 --- /dev/null +++ b/docs/test-results/evidence/issue-448/ha448dashpromql02.md @@ -0,0 +1,9 @@ +# 403·201·지표 부재 합성 PromQL 재검증 — ha448dashpromql02 + +- 시각: 2026-10-09 **16:07:00~16:07:12 KST**. +- 위치·개정: 로컬 Docker의 기존 `prom/prometheus:v3.5.5` 이미지, 저장소의 `monitoring/grafana/tests/ha_dashboard_http_classes.yml`. +- 목적: PR에 포함할 픽스처로 세 조건의 카드 계산을 재검증. +- 명령: `docker run --rm --entrypoint=promtool --mount type=bind,src=<저장소>/monitoring/grafana/tests/ha_dashboard_http_classes.yml,dst=/tmp/test.yml,readonly prom/prometheus:v3.5.5 test rules /tmp/test.yml`. +- 성공 기준: 403 전용 **4건**은 0·0·6·0, 201 기준선 **2건**은 2xx 1,801·4xx 0, 지표가 전혀 없는 **1건**은 결과 없음. +- 관측: 합성 판정 **7/7 통과**, `SUCCESS`, 종료 코드 **0**. 컨테이너는 시험 후 제거됐다. +- 해석: 응답 0건과 지표 부재를 구분하는 PromQL 동작은 확인했다. 이 수치는 합성 값이며 새 GCP 부하 시험 결과가 아니다. diff --git a/docs/test-results/evidence/issue-448/ha448dashunit01.md b/docs/test-results/evidence/issue-448/ha448dashunit01.md new file mode 100644 index 00000000..c0d12a70 --- /dev/null +++ b/docs/test-results/evidence/issue-448/ha448dashunit01.md @@ -0,0 +1,9 @@ +# 대시보드 정적 회귀 확인 — ha448dashunit01 + +- 시각: 2026-10-09 KST. 초기 개발 확인의 정확한 시작 시각은 수집하지 못했다. +- 위치·개정: 로컬 `test/448`, PR #449의 기존 커밋에 수정 중인 작업 트리. +- 목적: 실행 ID·0건 표시 수정 직후 기존 대시보드 불변식이 깨지지 않았는지 확인. +- 명령: `python3 -m unittest monitoring.grafana.tests.test_ha_dashboard -v`. +- 성공 기준: 정적 검증 11건 모두 통과, 오류 0건. +- 관측: **11/11 통과**, 오류 **0건**, 종료 코드 **0**. +- 해석: JSON 구조·수정한 쿼리 문자열은 검사했지만 실제 Prometheus 평가나 GCP 화면 결과는 이 실행으로 확인하지 않았다. diff --git a/docs/test-results/evidence/issue-448/ha448dashunit02.md b/docs/test-results/evidence/issue-448/ha448dashunit02.md new file mode 100644 index 00000000..f30e27db --- /dev/null +++ b/docs/test-results/evidence/issue-448/ha448dashunit02.md @@ -0,0 +1,9 @@ +# 대시보드 최종 정적 회귀 확인 — ha448dashunit02 + +- 시각: 2026-10-09 **16:07:00~16:07:12 KST**. +- 위치·개정: 로컬 `test/448`, PR #449 후속 수정 작업 트리. +- 목적: 수정된 대시보드·검증 픽스처·문서가 함께 일치하는지 확인. +- 명령: `python3 -m unittest monitoring.grafana.tests.test_ha_dashboard -v`; `python3 -m json.tool monitoring/grafana/dashboards/docgrid-ha-demo.json`; `git diff --check`. +- 성공 기준: 단위 시험 11/11, JSON 파싱 성공, diff 공백 오류 0건. +- 관측: 단위 시험 **11/11 통과**, JSON 파싱 **통과**, 공백 오류 **0건**, 세 명령 종료 코드 모두 **0**. +- 해석: 대시보드의 실행 ID 기준·네 카드의 공통 0 대체·픽스처 일치는 정적 검증됐다. 실제 GCP 배포 결과와는 구분한다. diff --git a/docs/test-results/evidence/issue-448/ha448proc01.md b/docs/test-results/evidence/issue-448/ha448proc01.md new file mode 100644 index 00000000..6a7a4622 --- /dev/null +++ b/docs/test-results/evidence/issue-448/ha448proc01.md @@ -0,0 +1,18 @@ +# primary PostgreSQL 프로세스 장애 — `ha448proc01` + +- 시간: 2026-10-09 **05:34:00~05:37:01 KST**. postmaster SIGKILL **05:34:19**. +- 위치: GCP 내부 k6, node3의 PostgreSQL 프로세스만 종료, Patroni/etcd/VM 유지, 복구 후 primary DB. +- 목적/성공 기준: 같은 노드 재시작인지 새 리더 선출인지 구분하고, 201/실패/불명 요청을 DB 행과 대조한다. 장애 중 HTTP 실패는 숨기지 않는다. + +| 실행 위치·명령/방법 | 결과 요약 | 해석 | +| --- | --- | --- | +| DB 3노드 안전 관문: Patroni `/cluster`, DB health, 프록시 A/B status | leader **1**, streaming replica **2·lag0**, 프록시 **2/2 ready**, fixture 타이머 **3/3 armed** | 장애 전제 통과. 기존 JWT 잔여 시간이 짧아 앱 A에서 **새 20분 단기 토큰**을 생성·암호화 전송했고 중간 파일을 지웠다. 첫 토큰 시도는 VM 계정명 불일치로 중단, 수정 후 성공. | +| 부하 VM: `run_ha_probe_k6.sh ha448proc01 30 180s <내부 LB URL> <보호된 JWT 파일> primary-process-fault 160` | HTTP **5,400**, 201 **5,161**, 500 **230**, 401 **4**, 불명 **5**, dropped **0**, k6 exit **99** | 실패율 **4.4259%**로 정상 임계값 실패. 부하 발생기 드롭은 아니다. | +| 현재 primary VM: `ha_primary_process_fault.sh ha448proc01` | **05:34:19 KST**에 검증된 postmaster만 SIGKILL | VM·Patroni·etcd는 중단하지 않았다. | +| 부하 VM 이벤트 타임라인 | 첫 비201 **05:34:19.990**, 마지막 비201 **05:34:32.109**; 연속 201 최대 간격 **11.360초** | 약 12.119초의 비201 관측 구간과 성공 간격은 다른 지표다. | +| 관측 VM: Remote Write 최종 합계 대조 | 201 **5,161/5,161**, 500 **230/230**, 기타 실패 **4/4**, 불명 **5/5**, exit **0** | Grafana 결과별 시계열과 원장 최종 합계 일치. 중간 push의 매회 성공은 미검증. | +| 복구 후 primary: 요청 ID별 `export_ha_probe_counts.sh` | DB 고유 ID **5,164**; 201 누락·중복 **0/0**, 실패 234건 DB 반영 **0**, 불명 5건 중 DB 반영 **3** | 결과 불명은 자동 재시도 시 중복 가능성이 있는 핵심 사례다. | +| Patroni `/cluster`, LB health, 프록시·fixture 점검 | 기존 node3가 다시 **leader/running**, replica 2대 streaming/lag0, LB **2/2 HEALTHY** | 이 시험에서는 새 leader 선출이 없었다. | +| 현재 primary fixture 삭제 → 세 DB VM timer cancel → 부하 VM JWT 삭제 | 합성 계정 **4개 삭제**, timer **3/3 absent**, JWT **2개 absent** | 시험 데이터와 접근 토큰의 원복 확인. | + +p50/p95/p99는 **12.46/2,715.64/5,011.01 ms**, 활성 VU 최대 **155**다. 원장 SHA-256: `5832e1c6ac799f1788098ee3ae17988cb6d571909b83d891cbc16951468741a3`. HTTP 401 4건은 실제 관측됐지만 원인은 이번 범위에서 미확정이다. 201 누락 0건은 이 비동기 복제 실행의 관측값이지 보장된 RPO 0이 아니다. diff --git a/docs/test-results/evidence/issue-448/ha448proxy01.md b/docs/test-results/evidence/issue-448/ha448proxy01.md new file mode 100644 index 00000000..d84c1fc4 --- /dev/null +++ b/docs/test-results/evidence/issue-448/ha448proxy01.md @@ -0,0 +1,16 @@ +# OpenProxy A 프로세스 장애 — `ha448proxy01` + +- 시간: 2026-10-09 **05:24:23~05:26:25 KST**. 프록시 중단 **05:25:03.936**, 복구 **05:25:42.240**. +- 위치: GCP 내부 k6, OpenProxy A가 있는 DB VM, 앱 A/B, 복구 후 primary. +- 목적/성공 기준: 살아 있는 B로 서비스가 이어지는지, 실패·불명·201 누락/중복과 프록시 복구 상태를 각각 기록한다. **HTTP 오류 0건을 성공 전제로 두지 않는다.** + +| 실행 위치·명령/방법 | 결과 요약 | 해석 | +| --- | --- | --- | +| 프록시 A VM: `docgrid-openproxy-fault arm <프록시 A> ha448proxy01 100` | 독립 복구 가드 **armed** | 로컬 제어 세션이 끊겨도 자동 복구하도록 제한했다. | +| 부하 VM: `run_ha_probe_k6.sh ha448proxy01 100 120s <내부 LB URL> <보호된 JWT 파일> proxy-a-fault 160` | HTTP **12,001**, 201 **11,994**, 500 **7**, 드롭 **0**, k6 exit **0** | 실패율 약 **0.0583%**. k6 임계값 통과와 사용자 오류 0은 다르다. | +| 프록시 A VM: `docgrid-openproxy-fault stop` → `recover` | 프로세스 중단→ready까지 **38.304초**; 실행 중 500은 **05:25:04.114~.153 KST**에 7건 | 장애를 재현했지만 201 응답은 이어졌다. 연속 201 최대 간격 **0.049초**는 앱 쓰기 관측값이다. | +| 관측 VM: Remote Write 합계 대조 | 201 **11,994/11,994**, 500 **7/7**, 검증 exit **0** | 결과별 Grafana 시계열이 원장 합계와 일치한다. | +| 현재 primary: 요청 ID별 `export_ha_probe_counts.sh` 대조 | DB 고유 ID **11,994**, 201 누락·중복 **0/0**, 500 반영 **0** | 이 실행의 실패 7건은 최종 DB에 없었다. 다른 장애에서도 항상 그렇다는 뜻은 아니다. | +| 프록시 A VM: `docgrid-openproxy-fault cancel/status` | 가드 **absent**, 프로세스 **1**, 포트 **ready** | 원복 확인. B도 최종 점검에서 ready였다. | + +p50/p95/p99는 **10.89/52.09/55.68 ms**다. 원장 SHA-256: `546e958b61a23ed209d9a3cb6a788a04fd201da2535a21b0ddf866980680faba`. DB 대조 스크립트 첫 SCP 시도는 IAP 일시 실패였고 **재전송 후 대조 성공**했다. 원장 이벤트를 다시 만들거나 이전 실행을 덮어쓰지 않았다. OpenProxy의 500 발생 계층은 이 시험에서 확정하지 않았다. diff --git a/docs/test-results/evidence/issue-448/ha448smoke01.md b/docs/test-results/evidence/issue-448/ha448smoke01.md new file mode 100644 index 00000000..f98ff31b --- /dev/null +++ b/docs/test-results/evidence/issue-448/ha448smoke01.md @@ -0,0 +1,14 @@ +# HA 쓰기 연결 확인 — `ha448smoke01` + +- 시간: 2026-10-09 **05:18:58~05:19:04 KST**. +- 위치: GCP 내부 부하 VM → 내부 LB → 앱 A/B → OpenProxy A/B → OpenSQL primary. +- 목적/성공 기준: 장애 전 짧은 실제 HTTP 쓰기 6건이 201이고, 드롭 0·원장↔DB 불일치 0인지 확인한다. + +| 실행 위치·명령/방법 | 결과 요약 | 해석 | +| --- | --- | --- | +| 부하 VM: `run_ha_probe_k6.sh ha448smoke01 1 5s <내부 LB URL> <보호된 JWT 파일> baseline-write 160` | HTTP **6건**, 201 **6**, dropped **0**, k6 exit **0** | 내부 앱 쓰기 경로가 작동한다. | +| 부하 VM: `ha_load_telemetry.py summarize` | VM 표본기·1초 계측 exit **0/0**, 안전 이벤트 **12건** | 보낸 이벤트 6 + 완료 이벤트 6을 별도로 남겼다. | +| 관측 VM: `verify_ha_prometheus_rw.py verify` | 201 Counter **6/6**, 검증 exit **0** | 최종 누적 합계 일치. 중간 전송 공백은 별도 미검증. | +| 현재 primary: `export_ha_probe_counts.sh ha448smoke01` + 원장 대조 | DB 고유 ID **6**, 201 누락 **0**, 중복 **0**, 예상 밖 ID **0** | 응답과 최종 DB 행이 일치한다. | + +p50/p95/p99는 **60.75/91.96/99.17 ms**다. 실행 원장 `k6-events.jsonl`의 SHA-256: `a94bf19603bd3915daafbea1769c43614b74799a69789ed5508c6baab52f0726`. 5초 smoke는 성능 기준선으로 사용하지 않는다. diff --git "a/docs/test-results/evidence/issue-448/ha449run0001/DB-\354\232\224\354\262\255ID-\353\214\200\354\241\260.json" "b/docs/test-results/evidence/issue-448/ha449run0001/DB-\354\232\224\354\262\255ID-\353\214\200\354\241\260.json" new file mode 100644 index 00000000..5f5dab9d --- /dev/null +++ "b/docs/test-results/evidence/issue-448/ha449run0001/DB-\354\232\224\354\262\255ID-\353\214\200\354\241\260.json" @@ -0,0 +1,20 @@ +{ + "run_id": "ha449run0001", + "db_export_sha256": "9759e3c9e4ce1e6769673a99cae199afed64411f08e650b21434e8215b24d90c", + "client_request_count": 1801, + "db_request_count": 1801, + "db_row_count": 1801, + "acknowledged_count": 1801, + "failed_count": 0, + "unknown_count": 0, + "acknowledged_missing_count": 0, + "acknowledged_duplicate_count": 0, + "db_duplicate_request_count": 0, + "unknown_persisted_count": 0, + "unknown_not_persisted_count": 0, + "failed_persisted_count": 0, + "orphan_db_request_count": 0, + "durable_write_pass": true, + "load_schedule_pass": true, + "normal_baseline_pass": true +} diff --git a/docs/test-results/evidence/issue-448/ha449run0001/config-safe.json b/docs/test-results/evidence/issue-448/ha449run0001/config-safe.json new file mode 100644 index 00000000..6ace01f7 --- /dev/null +++ b/docs/test-results/evidence/issue-448/ha449run0001/config-safe.json @@ -0,0 +1,11 @@ +{ + "run_id": "ha449run0001", + "purpose": "Grafana 및 요청 ID별 DB 대조 정상 부하 리허설", + "location": "GCP 내부 부하 발생기에서 내부 LB로 HTTP 쓰기", + "rate_per_second": 30, + "duration_seconds": 60, + "initial_vus": 80, + "max_vus": 160, + "target": "내부 LB 주소 비공개", + "jwt": "시험용 단기 토큰 값 비공개" +} diff --git a/docs/test-results/evidence/issue-448/ha449run0001/db-counts.csv.gz b/docs/test-results/evidence/issue-448/ha449run0001/db-counts.csv.gz new file mode 100644 index 00000000..a3d800ef Binary files /dev/null and b/docs/test-results/evidence/issue-448/ha449run0001/db-counts.csv.gz differ diff --git a/docs/test-results/evidence/issue-448/ha449run0001/k6-summary.json b/docs/test-results/evidence/issue-448/ha449run0001/k6-summary.json new file mode 100644 index 00000000..f6ab3877 --- /dev/null +++ b/docs/test-results/evidence/issue-448/ha449run0001/k6-summary.json @@ -0,0 +1,12 @@ +{ + "run_id": "ha449run0001", + "iterations": 1801, + "http_requests": 1801, + "dropped_iterations": 0, + "failed_rate": 0, + "duration_ms": { + "p50": 12.455335, + "p95": 53.417057, + "p99": 56.951849 + } +} diff --git a/docs/test-results/evidence/issue-448/ha449run0001/safe-events.jsonl.gz b/docs/test-results/evidence/issue-448/ha449run0001/safe-events.jsonl.gz new file mode 100644 index 00000000..46cb7ab7 Binary files /dev/null and b/docs/test-results/evidence/issue-448/ha449run0001/safe-events.jsonl.gz differ diff --git "a/docs/test-results/evidence/issue-448/ha449run0001/\352\263\204\354\270\241-1\354\264\210.csv" "b/docs/test-results/evidence/issue-448/ha449run0001/\352\263\204\354\270\241-1\354\264\210.csv" new file mode 100644 index 00000000..dadda24f --- /dev/null +++ "b/docs/test-results/evidence/issue-448/ha449run0001/\352\263\204\354\270\241-1\354\264\210.csv" @@ -0,0 +1,63 @@ +시각_UTC,미전송_건,HTTP_요청_건,활성_VU_최대,확보_VU_최대,HTTP_응답대기_p95_ms,연결대기_p95_ms,반복수행_p95_ms,호스트_CPU_%,호스트_가용메모리_MiB,k6_CPU_%,k6_RSS_MiB,k6_스레드_수 +2026-10-09T07:50:29Z,0,0,,,,,,,7042.21,,38.07,5 +2026-10-09T07:50:30Z,0,28,1.0,80.0,172.2,2.88,176.69,14.65,7035.81,27.93,55.3,7 +2026-10-09T07:50:31Z,0,31,0.0,80.0,57.21,1.99,59.9,7.0,7035.61,9.99,57.78,7 +2026-10-09T07:50:32Z,0,29,0.0,80.0,56.89,1.93,59.51,6.97,7031.19,9.98,59.86,7 +2026-10-09T07:50:33Z,0,31,0.0,80.0,54.97,0.02,56.2,7.0,7031.25,10.99,62.07,7 +2026-10-09T07:50:34Z,0,29,0.0,80.0,55.8,0.02,56.72,5.47,7031.34,6.99,62.37,7 +2026-10-09T07:50:35Z,0,30,0.0,80.0,57.11,0.02,58.02,3.54,7031.38,5.99,62.4,7 +2026-10-09T07:50:36Z,0,30,1.0,80.0,55.7,0.02,56.8,6.44,7030.49,6.98,62.41,8 +2026-10-09T07:50:37Z,0,31,0.0,80.0,55.33,0.03,56.44,5.53,7030.27,6.99,62.45,8 +2026-10-09T07:50:38Z,0,30,0.0,80.0,17.2,0.02,19.66,6.53,7026.36,9.99,64.5,8 +2026-10-09T07:50:39Z,0,29,1.0,80.0,51.49,0.02,52.78,4.5,7026.39,7.98,64.52,8 +2026-10-09T07:50:40Z,0,31,0.0,80.0,55.29,0.04,56.57,4.55,7026.47,7.98,64.55,8 +2026-10-09T07:50:41Z,0,30,0.0,80.0,55.5,0.02,56.52,5.0,7026.52,6.98,64.57,8 +2026-10-09T07:50:42Z,0,30,0.0,80.0,54.95,0.02,56.07,5.0,7026.61,6.98,64.58,8 +2026-10-09T07:50:43Z,0,29,0.0,80.0,49.8,0.02,50.83,7.43,7026.66,8.98,64.61,8 +2026-10-09T07:50:44Z,0,30,0.0,80.0,15.97,0.02,17.3,4.04,7026.74,6.99,64.63,8 +2026-10-09T07:50:45Z,0,31,0.0,80.0,55.34,0.02,56.6,,,,, +2026-10-09T07:50:46Z,0,30,0.0,80.0,17.35,0.06,18.77,5.03,7026.79,8.98,64.64,8 +2026-10-09T07:50:47Z,0,30,0.0,80.0,15.32,0.02,16.68,4.5,7026.82,6.99,64.64,8 +2026-10-09T07:50:48Z,0,30,0.0,80.0,15.02,0.02,16.23,5.0,7026.93,5.99,64.64,8 +2026-10-09T07:50:49Z,0,29,0.0,80.0,15.87,0.02,17.83,4.04,7026.98,6.99,64.72,8 +2026-10-09T07:50:50Z,0,31,0.0,80.0,50.61,0.14,51.72,6.5,7027.03,10.98,64.73,8 +2026-10-09T07:50:51Z,0,29,0.0,80.0,54.61,0.02,55.93,3.52,7027.08,5.99,64.74,8 +2026-10-09T07:50:52Z,0,31,1.0,80.0,53.94,0.01,55.15,5.0,7027.18,6.99,64.75,8 +2026-10-09T07:50:53Z,0,29,0.0,80.0,52.83,0.04,53.89,4.52,7027.22,7.98,64.76,8 +2026-10-09T07:50:54Z,0,31,0.0,80.0,19.42,0.02,20.51,4.52,7027.3,5.99,64.78,8 +2026-10-09T07:50:55Z,0,30,0.0,80.0,16.53,0.02,17.98,6.03,7027.35,10.98,64.79,8 +2026-10-09T07:50:56Z,0,30,0.0,80.0,54.13,0.02,55.43,5.47,7027.35,7.99,64.79,8 +2026-10-09T07:50:57Z,0,30,0.0,80.0,50.87,0.02,51.97,4.02,7027.52,6.98,64.8,8 +2026-10-09T07:50:58Z,0,30,0.0,80.0,15.22,0.02,16.22,4.98,7027.52,5.99,64.8,8 +2026-10-09T07:50:59Z,0,29,1.0,80.0,52.57,0.01,53.63,4.52,7027.56,5.99,64.86,8 +2026-10-09T07:51:00Z,0,31,0.0,80.0,53.39,0.01,54.47,5.03,7027.68,8.99,64.88,8 +2026-10-09T07:51:01Z,0,29,1.0,80.0,53.84,0.02,55.1,3.5,7027.72,5.99,64.95,8 +2026-10-09T07:51:02Z,0,31,0.0,80.0,51.12,0.02,52.31,4.5,7027.78,6.99,64.95,8 +2026-10-09T07:51:03Z,0,30,1.0,80.0,52.65,0.04,53.64,3.54,7027.87,5.99,64.96,8 +2026-10-09T07:51:04Z,0,29,0.0,80.0,52.31,0.02,53.14,3.5,7027.91,3.99,64.97,8 +2026-10-09T07:51:05Z,0,31,0.0,80.0,52.44,0.02,53.34,2.54,7027.96,6.99,64.98,8 +2026-10-09T07:51:06Z,0,30,0.0,80.0,54.37,0.02,55.55,5.97,7028.02,8.98,66.98,8 +2026-10-09T07:51:07Z,0,29,0.0,80.0,53.02,0.02,54.01,4.02,7028.1,6.99,66.98,8 +2026-10-09T07:51:08Z,0,31,1.0,80.0,52.92,0.02,53.8,4.02,7028.1,5.99,67.0,8 +2026-10-09T07:51:09Z,0,29,0.0,80.0,14.67,0.02,15.94,4.5,7028.24,6.99,67.01,8 +2026-10-09T07:51:10Z,0,31,0.0,80.0,53.88,0.02,54.87,4.98,7028.29,5.99,67.01,8 +2026-10-09T07:51:11Z,0,30,0.0,80.0,52.44,0.11,53.27,4.02,7028.35,6.99,67.02,8 +2026-10-09T07:51:12Z,0,30,1.0,80.0,53.94,0.01,54.83,4.0,7028.41,5.99,67.04,8 +2026-10-09T07:51:13Z,0,30,0.0,80.0,53.24,0.02,54.56,5.0,7028.51,5.99,69.08,8 +2026-10-09T07:51:14Z,0,30,0.0,80.0,53.07,0.04,54.22,6.0,7028.55,8.98,69.09,8 +2026-10-09T07:51:15Z,0,30,0.0,80.0,51.73,0.05,53.07,4.5,7028.59,7.98,69.1,8 +2026-10-09T07:51:16Z,0,30,0.0,80.0,51.57,0.01,52.92,5.0,7028.64,5.99,69.11,8 +2026-10-09T07:51:17Z,0,29,0.0,80.0,51.74,0.01,52.97,4.5,7024.76,6.98,69.16,8 +2026-10-09T07:51:18Z,0,31,0.0,80.0,53.26,0.03,54.44,4.04,7024.81,7.98,69.17,8 +2026-10-09T07:51:19Z,0,30,0.0,80.0,51.74,0.02,53.1,4.52,7024.86,5.99,69.17,8 +2026-10-09T07:51:20Z,0,30,0.0,80.0,15.48,0.02,16.82,5.91,7024.91,6.99,69.17,8 +2026-10-09T07:51:21Z,0,30,0.0,80.0,51.79,0.01,52.91,7.04,7025.01,9.98,69.2,8 +2026-10-09T07:51:22Z,0,30,0.0,80.0,54.09,0.02,55.3,3.52,7025.08,5.99,69.2,8 +2026-10-09T07:51:23Z,0,30,0.0,80.0,52.82,0.03,53.9,5.5,7025.09,7.98,69.2,8 +2026-10-09T07:51:24Z,0,30,1.0,80.0,52.66,0.02,54.06,5.47,7025.2,5.99,69.27,8 +2026-10-09T07:51:25Z,0,30,0.0,80.0,13.97,0.04,14.94,3.54,7025.26,5.99,69.27,8 +2026-10-09T07:51:26Z,0,30,0.0,80.0,54.25,0.06,55.42,3.02,7025.26,6.99,69.28,8 +2026-10-09T07:51:27Z,0,30,0.0,80.0,52.06,0.02,53.19,5.0,7025.36,5.99,69.57,8 +2026-10-09T07:51:28Z,0,30,0.0,80.0,53.64,0.01,54.56,6.97,7025.45,9.98,69.64,8 +2026-10-09T07:51:29Z,0,30,0.0,80.0,14.86,0.04,16.13,3.52,7025.51,4.99,69.64,8 +2026-10-09T07:51:30Z,0,2,,,13.43,0.01,14.57,6.44,7025.57,9.98,70.23,8 diff --git "a/docs/test-results/evidence/issue-448/ha449run0001/\352\263\204\354\270\241-\354\232\224\354\225\275.json" "b/docs/test-results/evidence/issue-448/ha449run0001/\352\263\204\354\270\241-\354\232\224\354\225\275.json" new file mode 100644 index 00000000..25b6305e --- /dev/null +++ "b/docs/test-results/evidence/issue-448/ha449run0001/\352\263\204\354\270\241-\354\232\224\354\225\275.json" @@ -0,0 +1 @@ +{"active_vu_peak": 1.0, "allocated_vu_peak": 80.0, "dropped": 0, "host_seconds": 61, "requests": 1801, "seconds": 62} diff --git "a/docs/test-results/evidence/issue-448/ha449run0001/\354\213\244\355\226\211-\352\270\260\353\241\235.txt" "b/docs/test-results/evidence/issue-448/ha449run0001/\354\213\244\355\226\211-\352\270\260\353\241\235.txt" new file mode 100644 index 00000000..1f67bdd9 --- /dev/null +++ "b/docs/test-results/evidence/issue-448/ha449run0001/\354\213\244\355\226\211-\352\270\260\353\241\235.txt" @@ -0,0 +1,21 @@ +실행 ID=ha449run0001 +위치=GCP 내부 부하 VM +목적=HA probe 정상 쓰기 기준선 +시작 UTC=2026-10-09T07:50:29Z +요청 속도=30 req/s +계획 시간=60s +성공 기준=HTTP 201, dropped_iterations 0, DB 대조 누락·중복 0 +초기 VU=80 +설정 최대 VU=160 +계측 간격=1초 +지표 태그=모두 비활성 +원격 지표 전송=1 +실시간 백분위=사용하지 않음 +종료 UTC=2026-10-09T07:51:31Z +k6 종료 코드=0 +VM 표본기 종료 코드=0 +1초 계측 검증 종료 코드=0 +실시간 안전 이벤트=3602건 +원시 k6 stderr=비밀·내부 주소 사전 제거 보장 불가로 수집하지 않음 +원격 지표 최종 합계 검증 종료 코드=0 +중간 전송 공백=이 검증으로는 판정 불가 diff --git "a/docs/test-results/evidence/issue-448/ha449run0001/\354\213\244\355\226\211-\354\232\224\354\225\275.md" "b/docs/test-results/evidence/issue-448/ha449run0001/\354\213\244\355\226\211-\354\232\224\354\225\275.md" new file mode 100644 index 00000000..6b2bbd33 --- /dev/null +++ "b/docs/test-results/evidence/issue-448/ha449run0001/\354\213\244\355\226\211-\354\232\224\354\225\275.md" @@ -0,0 +1,38 @@ +# Grafana·DB 요청 ID 정상 기준선 — ha449run0001 + +| 항목 | 이번 실행의 기록 | +| --- | --- | +| 시각 | 2026-10-09 16:50:29~16:51:31 KST (원본 07:50:29~07:51:31 UTC) | +| 목적 | 새 실행 ID의 Grafana 카드·응답/초·VU 표시와 HTTP 원장↔primary DB 요청 ID별 일치를 실측 | +| 위치 | GCP 내부 부하 VM → 내부 LB → 앱 A/B → OpenProxy A/B → OpenSQL primary | +| 실행 코드 | `test/448`의 `765a87bb33e90b87ebfa4f04dbfe49003addf595`; 부하 VM 스크립트 SHA-256 일치 확인. 앱 배포 JAR 버전은 이번 실행에서 재측정하지 않음 | +| 실행 형식 | `run_ha_probe_k6.sh ha449run0001 30 60s <내부 LB URL> <0600 JWT> baseline-write 80` | +| 합격 기준 | 201 외 응답 0, dropped iterations 0, Prometheus 최종 Counter 일치, 201별 DB 1행·누락·중복 0 | + +## 실제 측정 결과 + +| 지표 | 관측값 | 뜻 | +| --- | ---: | --- | +| 목표 부하 / 계획 시간 | 30 req/s / 60초 | k6 constant-arrival-rate 설정 | +| HTTP 요청 / 반복 | 1,801 / 1,801건 | 시작·종료 경계 때문에 30×60과 1건 차이 | +| 201 / 500 / 503 / 기타 실패 / 결과 불명 | 1,801 / 0 / 0 / 0 / 0건 | 사용자에게 실패 응답이 관측되지 않음 | +| Dropped iterations | 0건 | 목표 반복을 시작하지 못한 경우 없음 | +| k6 종료 코드 / HTTP 실패율 | 0 / 0% | 정상 기준선의 k6 threshold 통과 | +| p50 / p95 / p99 | 12.46 / 53.42 / 56.95ms | 최종 요약의 전체 요청 분포. Grafana 실시간 구간별 p95/p99는 아님 | +| 원장 2xx·3xx·4xx·5xx ↔ Prometheus | 1,801·0·0·0 ↔ 1,801·0·0·0건 | 첫 조회에 원격 Counter 최종 합계 일치 | +| primary DB 고유 요청 ID / 행 수 | 1,801 / 1,801 | 읽기 전용 요청 ID별 export | +| 201 누락 / 중복 / 고아 DB ID | 0 / 0 / 0건 | `normal_baseline_pass=true` | +| 안전 원장 이벤트 | 3,602건 | 요청당 `sent` 1개·`acknowledged` 1개 | +| 1초 계측 | 62개 시간 구간·호스트 61개 표본 | 샘플러·요약 검증 종료 코드 0 | +| 1초 표본 활성 VU 최대 / 확보 VU 최대 | 1 / 80 | 순간 최대를 완전 포착한 값은 아니며 DB 연결 수가 아님 | + +## Grafana에서 직접 확인한 화면 + +실행 ID를 `ha449run0001`로 고정하고 **16:50:20~16:51:50 KST**를 조회했다. 2xx 카드 **1,801**, 3xx·4xx·5xx 카드 **각 0**, HTTP 201/초 선과 활성 VU 선이 실제로 나타났다. 같은 화면에서 primary는 `db-node3`, timeline **14**, 프록시 포트 **2/2**, 앱 A/B 지표 수집 성공, DB node1/2 REPLICA·node3 PRIMARY였다. 앱별 HTTP 5xx/초와 Hikari timeout은 0선이었다. 이들은 **그 시간의 관측 상태**이며 이후 상태나 장애 시 동작을 보증하지 않는다. + +## 원본 증거·안전 + +- [허용 필드만 남긴 k6 이벤트](safe-events.jsonl.gz), [요청 ID별 DB 행 수](db-counts.csv.gz), [k6 요약](k6-summary.json), [Prometheus 최종 Counter 대조](원격-지표-대조.json), [DB 요청 ID별 대조](DB-요청ID-대조.json), [1초 계측](계측-1초.csv), [실행 기록](실행-기록.txt). +- 이벤트 압축본 SHA-256: `12ed11bd703afc6d9b0d7c2d575fa1790a4b1a33be5492b3a9c9c69b19c986f8`. DB CSV 압축본 SHA-256: `43d5721e963eedcc30fa7f3812c707ad6d855407325c8421be2f1d8f95689c01`. +- 원본 k6 stderr는 비밀·주소를 기록 전에 안정적으로 가릴 수 없어 **수집하지 않았다**. 원격 지표 최종 합계 일치는 중간 구간의 매 전송 성공이나 미래 장애의 RPO 0을 보장하지 않는다. Node1/2의 DB 행 전체 대조, etcd 정족수·배포 JAR 버전 재확인, 장애 주입은 이번 정상 리허설 범위 밖이다. +- 시험 후 합성 관리자 계정 **4→0명**, 독립 정리 타이머 **3→0개**를 확인했다. 원격·로컬 JWT, 1시간 만료 OS Login 키와 전송용 임시 사본을 제거했다. VM·LB는 중단하지 않았다. diff --git "a/docs/test-results/evidence/issue-448/ha449run0001/\354\233\220\352\262\251-\354\247\200\355\221\234-\353\214\200\354\241\260.json" "b/docs/test-results/evidence/issue-448/ha449run0001/\354\233\220\352\262\251-\354\247\200\355\221\234-\353\214\200\354\241\260.json" new file mode 100644 index 00000000..9dd08eec --- /dev/null +++ "b/docs/test-results/evidence/issue-448/ha449run0001/\354\233\220\352\262\251-\354\247\200\355\221\234-\353\214\200\354\241\260.json" @@ -0,0 +1 @@ +{"db_reconciliation_verified": false, "expected": {"201": 1801, "500": 0, "503": 0, "http_2xx": 1801, "http_3xx": 0, "http_4xx": 0, "http_5xx": 0, "other_failed": 0, "unknown": 0}, "final_totals_match": true, "intermediate_delivery_verified": false, "observed": {"201": 1801, "500": 0, "503": 0, "http_2xx": 1801, "http_3xx": 0, "http_4xx": 0, "http_5xx": 0, "other_failed": 0, "unknown": 0}, "query_attempts": 1, "run_id": "ha449run0001"} diff --git "a/docs/test-results/evidence/issue-448/ha449smk0001/DB-\354\232\224\354\262\255ID-\353\214\200\354\241\260.json" "b/docs/test-results/evidence/issue-448/ha449smk0001/DB-\354\232\224\354\262\255ID-\353\214\200\354\241\260.json" new file mode 100644 index 00000000..12a8af3c --- /dev/null +++ "b/docs/test-results/evidence/issue-448/ha449smk0001/DB-\354\232\224\354\262\255ID-\353\214\200\354\241\260.json" @@ -0,0 +1,20 @@ +{ + "run_id": "ha449smk0001", + "db_export_sha256": "2daee1c122c37ea46667e9a6119285c92396669665e127f404c6261f0fc416d7", + "client_request_count": 5, + "db_request_count": 5, + "db_row_count": 5, + "acknowledged_count": 5, + "failed_count": 0, + "unknown_count": 0, + "acknowledged_missing_count": 0, + "acknowledged_duplicate_count": 0, + "db_duplicate_request_count": 0, + "unknown_persisted_count": 0, + "unknown_not_persisted_count": 0, + "failed_persisted_count": 0, + "orphan_db_request_count": 0, + "durable_write_pass": true, + "load_schedule_pass": true, + "normal_baseline_pass": true +} diff --git a/docs/test-results/evidence/issue-448/ha449smk0001/config-safe.json b/docs/test-results/evidence/issue-448/ha449smk0001/config-safe.json new file mode 100644 index 00000000..3f62e215 --- /dev/null +++ b/docs/test-results/evidence/issue-448/ha449smk0001/config-safe.json @@ -0,0 +1,11 @@ +{ + "run_id": "ha449smk0001", + "purpose": "Grafana 및 원장 수신 스모크", + "location": "GCP 내부 부하 발생기에서 내부 LB로 HTTP 쓰기", + "rate_per_second": 1, + "duration_seconds": 5, + "initial_vus": 5, + "max_vus": 160, + "target": "내부 LB 주소 비공개", + "jwt": "시험용 단기 토큰 값 비공개" +} diff --git a/docs/test-results/evidence/issue-448/ha449smk0001/db-counts.csv.gz b/docs/test-results/evidence/issue-448/ha449smk0001/db-counts.csv.gz new file mode 100644 index 00000000..3c81f72d Binary files /dev/null and b/docs/test-results/evidence/issue-448/ha449smk0001/db-counts.csv.gz differ diff --git a/docs/test-results/evidence/issue-448/ha449smk0001/k6-summary.json b/docs/test-results/evidence/issue-448/ha449smk0001/k6-summary.json new file mode 100644 index 00000000..e60b14d2 --- /dev/null +++ b/docs/test-results/evidence/issue-448/ha449smk0001/k6-summary.json @@ -0,0 +1,12 @@ +{ + "run_id": "ha449smk0001", + "iterations": 5, + "http_requests": 5, + "dropped_iterations": 0, + "failed_rate": 0, + "duration_ms": { + "p50": 85.045609, + "p95": 1317.9520659999998, + "p99": 1477.7540139999999 + } +} diff --git a/docs/test-results/evidence/issue-448/ha449smk0001/safe-events.jsonl.gz b/docs/test-results/evidence/issue-448/ha449smk0001/safe-events.jsonl.gz new file mode 100644 index 00000000..250449d1 Binary files /dev/null and b/docs/test-results/evidence/issue-448/ha449smk0001/safe-events.jsonl.gz differ diff --git "a/docs/test-results/evidence/issue-448/ha449smk0001/\352\263\204\354\270\241-1\354\264\210.csv" "b/docs/test-results/evidence/issue-448/ha449smk0001/\352\263\204\354\270\241-1\354\264\210.csv" new file mode 100644 index 00000000..c4061835 --- /dev/null +++ "b/docs/test-results/evidence/issue-448/ha449smk0001/\352\263\204\354\270\241-1\354\264\210.csv" @@ -0,0 +1,7 @@ +시각_UTC,미전송_건,HTTP_요청_건,활성_VU_최대,확보_VU_최대,HTTP_응답대기_p95_ms,연결대기_p95_ms,반복수행_p95_ms,호스트_CPU_%,호스트_가용메모리_MiB,k6_CPU_%,k6_RSS_MiB,k6_스레드_수 +2026-10-09T07:49:12Z,0,1,,,84.19,4.81,93.74,,7056.68,,30.5,5 +2026-10-09T07:49:13Z,0,1,0.0,5.0,60.56,3.4,64.84,5.47,7055.23,8.98,45.51,8 +2026-10-09T07:49:14Z,0,0,0.0,5.0,,,,1.0,7055.23,1.0,45.51,8 +2026-10-09T07:49:15Z,0,2,1.0,5.0,1517.27,1.65,1520.24,0.5,7055.23,1.0,45.51,8 +2026-10-09T07:49:16Z,0,1,0.0,5.0,55.97,1.38,58.32,0.5,7055.23,1.0,45.51,8 +2026-10-09T07:49:17Z,0,0,0.0,5.0,,,,1.49,7055.23,0.0,45.51,8 diff --git "a/docs/test-results/evidence/issue-448/ha449smk0001/\352\263\204\354\270\241-\354\232\224\354\225\275.json" "b/docs/test-results/evidence/issue-448/ha449smk0001/\352\263\204\354\270\241-\354\232\224\354\225\275.json" new file mode 100644 index 00000000..416c19d2 --- /dev/null +++ "b/docs/test-results/evidence/issue-448/ha449smk0001/\352\263\204\354\270\241-\354\232\224\354\225\275.json" @@ -0,0 +1 @@ +{"active_vu_peak": 1.0, "allocated_vu_peak": 5.0, "dropped": 0, "host_seconds": 6, "requests": 5, "seconds": 6} diff --git "a/docs/test-results/evidence/issue-448/ha449smk0001/\354\213\244\355\226\211-\352\270\260\353\241\235.txt" "b/docs/test-results/evidence/issue-448/ha449smk0001/\354\213\244\355\226\211-\352\270\260\353\241\235.txt" new file mode 100644 index 00000000..bddcd29a --- /dev/null +++ "b/docs/test-results/evidence/issue-448/ha449smk0001/\354\213\244\355\226\211-\352\270\260\353\241\235.txt" @@ -0,0 +1,21 @@ +실행 ID=ha449smk0001 +위치=GCP 내부 부하 VM +목적=HA probe 정상 쓰기 기준선 +시작 UTC=2026-10-09T07:49:12Z +요청 속도=1 req/s +계획 시간=5s +성공 기준=HTTP 201, dropped_iterations 0, DB 대조 누락·중복 0 +초기 VU=5 +설정 최대 VU=160 +계측 간격=1초 +지표 태그=모두 비활성 +원격 지표 전송=1 +실시간 백분위=사용하지 않음 +종료 UTC=2026-10-09T07:49:18Z +k6 종료 코드=0 +VM 표본기 종료 코드=0 +1초 계측 검증 종료 코드=0 +실시간 안전 이벤트=10건 +원시 k6 stderr=비밀·내부 주소 사전 제거 보장 불가로 수집하지 않음 +원격 지표 최종 합계 검증 종료 코드=0 +중간 전송 공백=이 검증으로는 판정 불가 diff --git "a/docs/test-results/evidence/issue-448/ha449smk0001/\354\213\244\355\226\211-\354\232\224\354\225\275.md" "b/docs/test-results/evidence/issue-448/ha449smk0001/\354\213\244\355\226\211-\354\232\224\354\225\275.md" new file mode 100644 index 00000000..d42b992e --- /dev/null +++ "b/docs/test-results/evidence/issue-448/ha449smk0001/\354\213\244\355\226\211-\354\232\224\354\225\275.md" @@ -0,0 +1,30 @@ +# Grafana 원격 지표 스모크 — ha449smk0001 + +| 항목 | 이번 실행의 기록 | +| --- | --- | +| 시각 | 2026-10-09 16:49:12~16:49:18 KST (원본 07:49:12~07:49:18 UTC) | +| 목적 | 내부 LB까지 인증된 쓰기와 k6→Prometheus Remote Write의 최종 합계가 연결되는지 소량 확인 | +| 위치 | GCP 내부 부하 VM → 내부 LB → 앱 A/B → OpenProxy → 현재 OpenSQL primary | +| 실행 코드 | `test/448`의 `765a87bb33e90b87ebfa4f04dbfe49003addf595`; 부하 VM의 실행 스크립트 SHA-256 일치 확인 | +| 실행 형식 | `run_ha_probe_k6.sh ha449smk0001 1 5s <내부 LB URL> <0600 JWT> baseline-write 5` | +| 합격 기준 | HTTP 201만 발생, 미전송 0, 원격 Counter 최종 합계 일치, 요청 ID별 DB 1행 | + +## 관측 결과 + +| 지표 | 값 | 해석 | +| --- | ---: | --- | +| HTTP 요청 / 반복 / 미전송 | 5 / 5 / 0건 | k6 종료 코드 0 | +| HTTP 201 / 다른 응답 / 결과 불명 | 5 / 0 / 0건 | 인증된 정상 쓰기 5건 | +| 2xx·3xx·4xx·5xx 원격 합계 | 5·0·0·0건 | 원장과 Prometheus 최종 Counter가 첫 조회에 일치 | +| primary DB 고유 ID / 총 행 | 5 / 5건 | 각 201 요청 ID가 정확히 1행 | +| 201 ID 누락 / 중복 / 고아 DB ID | 0 / 0 / 0건 | 요청 ID별 대조 통과 | +| p50 / p95 / p99 | 85.05 / 1,317.95 / 1,477.75ms | 단 5건의 준비 스모크 분포. 성능 기준선으로 사용하지 않음 | +| 1초 계측 | 6개 시각 구간, 호스트 6개 표본 | 계측 요약 종료 코드 0 | + +## 증거와 판정 경계 + +- [허용 필드만 남긴 k6 이벤트 10건](safe-events.jsonl.gz), [요청 ID별 DB 행 수](db-counts.csv.gz), [k6 요약](k6-summary.json), [원격 Counter 대조](원격-지표-대조.json), [DB 요청 ID 대조](DB-요청ID-대조.json), [1초 계측](계측-1초.csv), [실행 기록](실행-기록.txt)을 분리 보관한다. +- 이벤트 압축본 SHA-256: `e0f694a7ea4da8df5437e39b2feb79b8f7c87014c893f7bbefbf411ada78e410`. DB CSV 압축본 SHA-256: `2bfe133ad76920241167704fa9680aa0d0ec6edcafee01aef9d12ec9fddb6202`. +- `import_completed_ha_k6.py`로 원본 이벤트 시각을 유지하며 대조했고 `normal_baseline_pass=true`였다. 이 5건은 연결 확인이며 30 req/s 부하 성능 결론에는 넣지 않는다. +- Remote Write **최종 합계**는 맞았지만 전송 중간의 모든 시각에 누락이 없었다는 뜻은 아니다. 장애 주입은 하지 않았다. +- 공개 증거에는 JWT·내부 주소·프로젝트 ID·시험 관리자 ID를 포함하지 않았다. 시험용 관리자 4명·3대의 정리 타이머는 뒤의 정상 기준선 종료 후 함께 제거했다. diff --git "a/docs/test-results/evidence/issue-448/ha449smk0001/\354\233\220\352\262\251-\354\247\200\355\221\234-\353\214\200\354\241\260.json" "b/docs/test-results/evidence/issue-448/ha449smk0001/\354\233\220\352\262\251-\354\247\200\355\221\234-\353\214\200\354\241\260.json" new file mode 100644 index 00000000..3255acc9 --- /dev/null +++ "b/docs/test-results/evidence/issue-448/ha449smk0001/\354\233\220\352\262\251-\354\247\200\355\221\234-\353\214\200\354\241\260.json" @@ -0,0 +1 @@ +{"db_reconciliation_verified": false, "expected": {"201": 5, "500": 0, "503": 0, "http_2xx": 5, "http_3xx": 0, "http_4xx": 0, "http_5xx": 0, "other_failed": 0, "unknown": 0}, "final_totals_match": true, "intermediate_delivery_verified": false, "observed": {"201": 5, "500": 0, "503": 0, "http_2xx": 5, "http_3xx": 0, "http_4xx": 0, "http_5xx": 0, "other_failed": 0, "unknown": 0}, "query_attempts": 1, "run_id": "ha449smk0001"} diff --git a/docs/test-results/opensql-grafana-normal-rehearsal-20261009.md b/docs/test-results/opensql-grafana-normal-rehearsal-20261009.md new file mode 100644 index 00000000..18ca5bbd --- /dev/null +++ b/docs/test-results/opensql-grafana-normal-rehearsal-20261009.md @@ -0,0 +1,372 @@ +# OpenSQL HA 정상 부하·Grafana 리허설: 직접 따라 하는 안내 + +작성일: 2026-10-09 KST · 범위: 정상 쓰기, Grafana 표시, 요청 ID별 DB 대조 · 장애 주입: **미실행** + +## 결론 + +GCP 내부 부하 VM에서 **30 req/s × 60초**를 실행했다. `ha449run0001`의 실제 HTTP 요청 **1,801건은 전부 201**, 미전송 **0건**, Prometheus의 최종 2xx Counter **1,801건**, 현재 primary DB의 요청 ID **1,801개·1,801행**이었다. ID별 누락·중복·고아 DB 행은 모두 **0건**이다. [5초 스모크](evidence/issue-448/ha449smk0001/실행-요약.md)와 [1분 기준선](evidence/issue-448/ha449run0001/실행-요약.md)의 로그·원본은 따로 보관했다. + +Grafana에서 이 실행 ID를 선택하니 **2xx 1,801 / 3xx 0 / 4xx 0 / 5xx 0**, HTTP 201/초 그래프와 활성 VU 그래프가 실제로 나왔다. 첨부 문서의 “정상 201 미실행”은 **2026-10-08 당시**의 기록이다. 오늘은 그 관문만 완료했으며 프록시·primary 장애는 발생시키지 않았다. + +기존 13002 IAP 터널이 열려 있으면 [이번 실행의 Grafana 시간 구간](http://localhost:13002/d/docgrid-opensql-ha-live/docgrid-opensql-ha-live?from=1791532220000&to=1791532310000&timezone=browser&var-run_id=ha449run0001)에서 바로 볼 수 있다. 터널이 닫혔으면 아래 터미널 A부터 다시 연다. 이 링크의 시간 범위는 **16:50:20~16:51:50 KST**이며, 새 시험을 볼 때는 실행 ID와 시간 범위를 바꿔야 한다. + +## 1. 먼저 개념을 그림으로 보기 + +그림 1. 필요한 화면은 **터미널 3개 + 브라우저 1개**다. + +```text +내 Mac +├─ 터미널 A: 관측 VM까지 Grafana IAP 터널을 계속 유지 +│ 13002(local) ──암호화 SSH──▶ Grafana 3000 +│ +├─ 터미널 B: GCP 대상 확인·시험 계정/JWT 준비·DB 원장 대조·정리 +│ DB 후보 3대에 자동 정리 타이머 → 현재 primary에 합성 ADMIN 생성 +│ → 20분 JWT 생성·암호화 전송 → 종료 후 DB 대조·계정/토큰 제거 +│ +└─ 터미널 C: 부하 VM SSH 셸 + 배포 스크립트 확인 → k6 5초 스모크 → 별도 ID로 1분 기준선 + → 안전 이벤트·1초 계측·원격 지표 대조 생성 + +브라우저: Grafana에서 실행 ID 선택, 카드와 그래프 관찰 +``` + +DB VM마다 터미널을 하나씩 더 띄울 필요는 없다. 터미널 B가 SSH로 세 노드를 확인한다. 각 터미널의 쉘 변수는 자동 공유되지 않으니, 실행 ID와 임시 키 경로는 **내 컴퓨터 안에서만** 맞춰야 한다. + +그림 2. 한 HTTP 쓰기가 DB와 그래프로 갈라지는 경로다. + +```text +부하 VM ha_probe_load.js + ├─ 실행 ID + VU + 반복 번호 → request_id 생성 + └─ ADMIN JWT를 붙여 POST /api/ha-probe/writes + │ + ▼ + 내부 LB → 앱 A 또는 B → Spring Security(ADMIN 확인) + │ + ▼ + HaProbeWriteController.write() + │ TX_BEGIN + ▼ + HaProbeWriteService.write() + │ Hikari → OpenProxy → 현재 primary + └─ ha_probe_writes INSERT·커밋 + │ + ├─ HTTP 201 → k6 원장 acknowledged + ├─ k6 Counter → Remote Write → Prometheus → Grafana + └─ 종료 후 DB request_id별 행 수를 원장과 대조 +``` + +[k6 스크립트](https://github.com/DocGrid/docgrid/blob/765a87bb33e90b87ebfa4f04dbfe49003addf595/scripts/opensql/ha_probe_load.js), [컨트롤러](https://github.com/DocGrid/docgrid/blob/765a87bb33e90b87ebfa4f04dbfe49003addf595/backend/src/main/java/com/opensource/docgrid/domain/failover/controller/HaProbeWriteController.java), [쓰기 서비스](https://github.com/DocGrid/docgrid/blob/765a87bb33e90b87ebfa4f04dbfe49003addf595/backend/src/main/java/com/opensource/docgrid/domain/failover/service/command/HaProbeWriteService.java)가 실제 이 경로다. 기존 HA probe는 요청 ID의 **중복 저장을 자동 차단하지 않는다**. 이번 “중복 0”은 사후 DB 대조 결과다. + +그림 3. 시험 계정과 비밀은 부하보다 먼저 안전망을 만들고, 실패해도 정리한다. + +```text +새 run ID + ├─ DB VM 3대에 독립 자동 정리 타이머 3/3 무장 + │ 실패하면 계정 생성 금지 + ├─ 현재 primary에 합성 시험 계정 4명 생성 + ├─ ADMIN ID로 20분 JWT 파일(0600) 생성 + └─ 암호화 SCP → 부하 VM의 0600 파일 + │ + ▼ + 5초 스모크 / 1분 기준선 + │ + ▼ + 결과가 성공이든 실패든 요청별 DB CSV 추출 + │ + ▼ + 계정 4→0, 타이머 3→0, JWT·임시 키 제거 +``` + +아래 `<...>`는 사용자가 자기 터미널에서 채우는 자리다. 내부 IP·프로젝트 ID·JWT·ADMIN ID는 채팅·Git·공개 로그에 쓰지 않는다. 원시 k6 stderr는 기록 전에 안전하게 가릴 수 없어 이번에도 수집하지 않았다. + +## 2. 실행 전에 아는 용어 + +| 용어 | 쉬운 뜻 | 이번 판정에서 주의할 점 | +| --- | --- | --- | +| primary / replica | 쓰기를 받는 DB 한 대 / 내용을 따라가는 DB 두 대 | Patroni의 역할 표시와 HTTP 쓰기 성공은 별개 | +| OpenProxy | 앱 DB 연결을 현재 primary로 보내는 경로 | 포트 probe 2/2는 TCP 접속 가능이지 SQL 성공 보장이 아님 | +| VU / dropped iteration | k6의 가상 사용자 / 목표 시각에 시작하지 못한 반복 | VU는 DB 연결 수가 아님. 이번 초기 VU 80, 미전송 0 | +| run ID / request ID | 한 실행의 이름 / 그 안의 개별 쓰기 이름 | Grafana는 반드시 해당 run ID를 선택; All은 다른 실행까지 합산 | + +## 3. 어느 터미널에서 무슨 명령을 치나 + +### 터미널 A — Grafana 터널 + +먼저 `lsof -nP -iTCP:13002 -sTCP:LISTEN`으로 포트를 확인한다. 이미 본인의 Grafana 터널이 있으면 그대로 사용한다. 다른 프로그램이 점유했다면 13003 등 빈 포트로 **명령과 URL을 함께** 바꾼다. + +```bash +ZONE='us-central1-a' +OBSERVER_VM='<관측 VM 이름>' +gcloud compute ssh "$OBSERVER_VM" --zone="$ZONE" --tunnel-through-iap -- -N -L 13002:127.0.0.1:3000 +``` + +이 명령은 아무 결과 없이 계속 실행 중인 것이 정상이다. 닫지 말고 브라우저에서 `http://localhost:13002/d/docgrid-opensql-ha-live/docgrid-opensql-ha-live`를 연다. Grafana 3000 포트를 인터넷에 공개하지 않는다. + +### 터미널 B — 계정·JWT·결과 대조 + +저장소 루트에서 시작한다. GCP 대상 계정·프로젝트를 **개인 화면에서 확인**한다. + +```bash +cd +gcloud auth list --filter=status:ACTIVE +gcloud config get-value project +gcloud compute instances list --format='table(name,zone.basename(),status)' +``` + +오늘은 관련 앱 A/B·부하·관측·캐시·DB 3대가 RUNNING, 내부 LB 백엔드 2/2가 HEALTHY였다. VM RUNNING만으로 201 쓰기 성공을 말하지 않는다. 필요하면 `gcloud compute backend-services get-health '<내부 LB 백엔드 서비스 이름>' --region='<리전>'`으로 다시 확인한다. + +오늘 사용한 접근 방식은 **1시간 만료 OS Login 공개키**였다. 기존 정상 SSH 키가 있으면 재발급은 생략한다. 새 키를 쓸 경우 시험 종료 즉시 제거한다. + +```bash +umask 077 +ACCESS_DIR="$(mktemp -d)" +ssh-keygen -q -t ed25519 -N '' -f "$ACCESS_DIR/key" +ZONE='us-central1-a' +LOAD_VM='<부하 VM 이름>' +gcloud compute ssh "$LOAD_VM" --zone="$ZONE" --tunnel-through-iap \ + --ssh-key-file="$ACCESS_DIR/key" --ssh-key-expire-after=1h --command='true' +``` + +실행 ID는 새 12글자를 쓴다. [정리 도구](https://github.com/DocGrid/docgrid/blob/765a87bb33e90b87ebfa4f04dbfe49003addf595/scripts/opensql/ha_fixture_cleanup.py)가 활성 GCP 대상과 DB 3대의 역할을 확인하고 타이머를 모두 무장한 뒤에만 시험 계정을 만든다. + +```bash +RUN_ID="ha$(date -u +%m%d%H%M%S)" +export OPENSQL_EXPECTED_ACCOUNT="$(gcloud auth list --filter=status:ACTIVE --format='value(account)')" +export OPENSQL_EXPECTED_PROJECT="$(gcloud config get-value project)" +export OPENSQL_GCP_ZONE="$ZONE" +export OPENSQL_SSH_KEY="$ACCESS_DIR/key" +python3 scripts/opensql/ha_fixture_cleanup.py prepare "$RUN_ID" --guard-seconds 1200 +python3 scripts/opensql/ha_fixture_cleanup.py status "$RUN_ID" --guard-seconds 1200 +``` + +`prepare`는 “시험 계정=4명, 타이머=3/3 무장”을 출력해야 한다. `status`에서 primary가 정확히 한 대인지 보고, 그 VM을 다음 변수에 적는다. 오늘의 primary가 3번 노드였다고 **다음에도 같다고 가정하면 안 된다**. + +```bash +PRIMARY_VM='' +ADMIN_ID="$(gcloud compute ssh "$PRIMARY_VM" --zone="$ZONE" --tunnel-through-iap \ + --ssh-key-file="$ACCESS_DIR/key" \ + --command="sudo -n /usr/local/sbin/docgrid-permission-fixture status '$PRIMARY_VM' '$RUN_ID' 1200" \ + | awk -F, -v name="ha-$RUN_ID-admin" '$1==name {print $2}')" +[[ "$ADMIN_ID" =~ ^[0-9]+$ ]] && echo 'ADMIN 조회 성공' +test -f .env && echo '보호된 로컬 설정 파일 확인' +``` + +두 확인 메시지가 모두 나와야 다음 블록으로 간다. 작업용 Git worktree에 `.env`가 없을 수 있으므로, 비밀 파일이 보관된 별도의 승인된 로컬 checkout에서 JWT 생성 명령만 실행해도 된다. 비밀 파일을 새 worktree나 Git에 복사하지 않는다. + +```bash +TOKEN_DIR="$(mktemp -d)" +python3 scripts/opensql/create_ha_probe_jwt.py --env-file .env \ + --user-id "$ADMIN_ID" --subject "ha-$RUN_ID-admin@invalid.example" \ + --output "$TOKEN_DIR/jwt" --ttl-seconds 1200 +gcloud compute scp "$TOKEN_DIR/jwt" "$LOAD_VM:/tmp/$RUN_ID.jwt" \ + --zone="$ZONE" --tunnel-through-iap --ssh-key-file="$ACCESS_DIR/key" +gcloud compute ssh "$LOAD_VM" --zone="$ZONE" --tunnel-through-iap \ + --ssh-key-file="$ACCESS_DIR/key" --command="stat -c '%a' /tmp/$RUN_ID.jwt" +``` + +ADMIN 조회 또는 보호된 설정 확인 메시지가 없으면 **이후 명령을 실행하지 말고 아래의 정리 단계로 간다.** 마지막 파일 권한 출력은 **600**이어야 한다. JWT 자체를 `cat`하거나 터미널 로그에 남기지 않는다. JWT와 독립 타이머의 유효 기간이 20분이므로, 준비가 길어졌다면 새 ID로 처음부터 다시 한다. + +### 터미널 C — 부하 VM + +새 로컬 터미널을 열어 B에서 만든 **실행 ID와 임시 키 디렉터리 경로만 내 컴퓨터 안에서** 옮긴다. SSH 접속 뒤에는 프롬프트가 부하 VM임을 확인한다. + +```bash +ZONE='us-central1-a' +LOAD_VM='<부하 VM 이름>' +ACCESS_DIR='<터미널 B의 임시 키 디렉터리>' +gcloud compute ssh "$LOAD_VM" --zone="$ZONE" --tunnel-through-iap --ssh-key-file="$ACCESS_DIR/key" +``` + +이하 명령은 **부하 VM 안에서** 실행한다. 동명 백업 스크립트가 있으므로 현행 `$HOME/ha-scripts`를 선택하고, Mac 저장소 원본의 SHA-256과 비교한다. + +```bash +RUN_ID='<터미널 B와 똑같은 실행 ID>' +SCRIPT_DIR="$HOME/ha-scripts" +TOKEN_FILE="/tmp/$RUN_ID.jwt" +test -r "$SCRIPT_DIR/run_ha_probe_k6.sh" +test "$(stat -c '%a' "$TOKEN_FILE")" = 600 +sha256sum "$SCRIPT_DIR/run_ha_probe_k6.sh" "$SCRIPT_DIR/ha_probe_load.js" +``` + +터미널 B에서는 `shasum -a 256 scripts/opensql/run_ha_probe_k6.sh scripts/opensql/ha_probe_load.js`를 실행한다. 이번 실제 원본과 배포 파일은 각각 SHA-256 앞자리 `9a1a10c7…`, `736f82bf…`가 일치했다. **다른 해시면 시험을 시작하지 않는다.** + +내부 LB 주소는 GCP 포워딩 규칙에서 본인이 확인하되 공개 문서에는 넣지 않는다. 부하 VM에서 비표시 입력으로 받아 사용한다. 관측 VM 내부 DNS는 이번 환경에서 조회됐다. + +```bash +read -rsp '내부 LB IPv4(화면에 표시 안 됨): ' LB_IP; echo +OBSERVER_VM='<관측 VM 내부 DNS 이름>' +OBS_IP="$(getent ahostsv4 "$OBSERVER_VM" | awk 'NR==1 {print $1}')" +test -n "$LB_IP" && test -n "$OBS_IP" +LB_URL="http://$LB_IP/api/ha-probe/writes" +export HA_PROM_RW_URL="http://$OBS_IP:9090/api/v1/write" +``` + +LB 규칙 이름을 모르면 터미널 B에서 `gcloud compute forwarding-rules list --format='table(name,region.basename(),loadBalancingScheme)'`로 후보를 찾고, 정확한 규칙을 `gcloud compute forwarding-rules describe '<규칙 이름>' --region='<리전>' --format='value(IPAddress)'`로 **본인 화면에서만** 읽는다. 5초 스모크는 **별도 새 ID**, 1분 기준선도 **또 다른 새 ID**가 필요하다. 두 실행은 같은 단기 ADMIN JWT를 쓰되 결과 원장은 섞지 않는다. 스모크가 201·drop 0·원격 Counter 일치를 보여주지 않으면 1분 실행을 시작하지 않는다. + +```bash +SMOKE_RUN_ID="ha$(date -u +%m%d%H%M%S)" +test "$SMOKE_RUN_ID" != "$RUN_ID" +HA_PROM_RW_URL="$HA_PROM_RW_URL" bash "$SCRIPT_DIR/run_ha_probe_k6.sh" \ + "$SMOKE_RUN_ID" 1 5s "$LB_URL" "$TOKEN_FILE" baseline-write 5 +``` + +스모크 종료 코드 0, `k6-summary.json`의 201 5건·미전송 0, `원격-지표-대조.json`의 `final_totals_match=true`를 읽고 **통과한 경우에만** 다음 블록을 실행한다. 5건은 이 환경의 실제 관측값이며 다른 실행은 경계 시각 때문에 건수가 약간 달라질 수 있다. + +```bash +HA_PROM_RW_URL="$HA_PROM_RW_URL" bash "$SCRIPT_DIR/run_ha_probe_k6.sh" \ + "$RUN_ID" 30 60s "$LB_URL" "$TOKEN_FILE" baseline-write 80 +python3 -m json.tool "$HOME/ha389-runs/$RUN_ID/k6-summary.json" +python3 "$SCRIPT_DIR/sanitize_ha_k6_events.py" \ + --source "$HOME/ha389-runs/$RUN_ID/k6-events.jsonl" \ + --output "$HOME/ha389-runs/$RUN_ID/k6-events-safe.jsonl" --run-id "$RUN_ID" +python3 -m json.tool "$HOME/ha389-runs/$RUN_ID/원격-지표-대조.json" \ + | grep -E 'final_totals_match|query_attempts' +``` + +[실행 래퍼](https://github.com/DocGrid/docgrid/blob/765a87bb33e90b87ebfa4f04dbfe49003addf595/scripts/opensql/run_ha_probe_k6.sh)는 실행별 0700 폴더에 원장·요약·1초 수치를 구분해 쓴다. 종료 출력은 이번에 `run_id=ha449run0001 k6_exit=0 events=3602`였다. `final_totals_match=true`도 확인했다. 이는 **최종 Counter** 대조이며 Remote Write 중간의 매 전송 성공을 보장하지 않는다. + +### 브라우저 — 실행 중·종료 직후 + +Grafana에서 시간 범위 **Last 5 minutes**, 새로고침 **5s**, 실행 ID를 **이번 ID**로 고른다. 새 ID는 k6 Remote Write가 시작된 뒤에 생긴다. 안 보이면 대시보드를 Refresh하거나 시간 범위를 다시 적용한다. **All은 모든 최근 실행을 합산한다.** 오늘 All은 스모크 5건 + 기준선 1,801건으로 2xx **1,806**이었고, `ha449run0001`을 선택하면 **1,801**이었다. + +### 터미널 B — 현재 primary DB와 ID별 대조, 그다음 무조건 정리 + +DB exporter를 **현재** primary에 실행한다. 장애 후라면 primary가 달라질 수 있으므로 역할을 다시 확인한다. 파일에는 합성 request ID와 행 수만 남는다. + +```bash +umask 077 +EVIDENCE_DIR="$(mktemp -d)" +gcloud compute ssh "$PRIMARY_VM" --zone="$ZONE" --tunnel-through-iap \ + --ssh-key-file="$ACCESS_DIR/key" \ + --command="sudo -n /usr/local/sbin/docgrid-ha-probe-export '$PRIMARY_VM' '$RUN_ID'" \ + > "$EVIDENCE_DIR/db-counts.csv" +wc -l "$EVIDENCE_DIR/db-counts.csv" +``` + +오늘 기준선은 **CSV 1,802줄 = 헤더 1 + ID 1,801개**였다. 부하 VM에서 **sanitize를 통과한** `k6-events-safe.jsonl`과 `k6-summary.json`만 암호화 SCP로 비공개 분석 폴더에 가져온다. 원본 metrics JSONL·stderr·JWT를 복사하지 않는다. + +```bash +REMOTE_RUN_DIR='<터미널 C에서 확인한 이번 실행 폴더>' +gcloud compute scp "$LOAD_VM:$REMOTE_RUN_DIR/k6-events-safe.jsonl" \ + "$EVIDENCE_DIR/safe-events.jsonl" --zone="$ZONE" --tunnel-through-iap \ + --ssh-key-file="$ACCESS_DIR/key" +gcloud compute scp "$LOAD_VM:$REMOTE_RUN_DIR/k6-summary.json" \ + "$EVIDENCE_DIR/k6-summary.json" --zone="$ZONE" --tunnel-through-iap \ + --ssh-key-file="$ACCESS_DIR/key" +``` + +[비식별 설정 예시](evidence/issue-448/ha449run0001/config-safe.json)를 `$EVIDENCE_DIR/config-safe.json`에 복사해 **새 ID·속도·기간**으로 고친다. 내부 주소·토큰은 넣지 않는다. 그다음 [기존 완료 실행 가져오기 도구](https://github.com/DocGrid/docgrid/blob/765a87bb33e90b87ebfa4f04dbfe49003addf595/scripts/opensql/import_completed_ha_k6.py)로 개별 ID를 비교한다. + +```bash +python3 scripts/opensql/import_completed_ha_k6.py \ + --events "$EVIDENCE_DIR/safe-events.jsonl" \ + --k6-summary "$EVIDENCE_DIR/k6-summary.json" \ + --db-csv "$EVIDENCE_DIR/db-counts.csv" \ + --config-file "$EVIDENCE_DIR/config-safe.json" \ + --output "$EVIDENCE_DIR/imported" --run-id "$RUN_ID" --scenario baseline-write \ + --opensql-version not-rechecked --openproxy-version not-rechecked \ + --patroni-version not-rechecked --etcd-version not-rechecked +jq '{normal_baseline_pass,acknowledged_count,acknowledged_missing_count,db_duplicate_request_count,orphan_db_request_count}' \ + "$EVIDENCE_DIR/imported/reconciliation.json" +``` + +`normal_baseline_pass=true`이고 201 수 = DB 고유 ID 수, 누락·중복·고아 ID가 모두 0이어야 한다. 실패나 결과 불명은 201과 섞어 “성공”으로 바꾸지 않는다. + +**시험 성공·실패와 관계없이 즉시 정리한다.** 오늘은 계정 4→0명, 세 VM의 타이머 3→0개를 확인했다. 정리 실패 시 임시 키를 먼저 지우지 말고 남은 계정·타이머를 조사한다. + +```bash +python3 scripts/opensql/ha_fixture_cleanup.py cleanup "$RUN_ID" --guard-seconds 1200 +python3 scripts/opensql/ha_fixture_cleanup.py status "$RUN_ID" --guard-seconds 1200 +gcloud compute ssh "$LOAD_VM" --zone="$ZONE" --tunnel-through-iap \ + --ssh-key-file="$ACCESS_DIR/key" \ + --command="rm -f /tmp/$RUN_ID.jwt; test ! -e /tmp/$RUN_ID.jwt" +gcloud compute os-login ssh-keys remove --key-file="$ACCESS_DIR/key.pub" --quiet +rm -f "$TOKEN_DIR/jwt" "$ACCESS_DIR/key" "$ACCESS_DIR/key.pub" +rmdir "$TOKEN_DIR" "$ACCESS_DIR" +``` + +지우는 것은 **이번 실행의 임시 토큰·키뿐**이다. VM·LB·다른 실행의 데이터와 로그는 지우지 않는다. + +## 4. Grafana에서 무엇이 어떻게 보여야 하나 + +그림 4. 이번 **정상 실행에서 실제 관찰한** 시간 흐름이다. + +```text +16:50:20 KST 16:50:29 k6 시작 약 60초간 쓰기 16:51:31 종료 + │ │ │ │ +DB 역할 node3 PRIMARY ───────────── 유지 ─────────────── node3 +timeline 14 ───────────────── 유지 ─────────────── 14 +proxy TCP 2/2 ───────────────── 유지 ─────────────── 2/2 +앱 지표 수집 A/B 성공 ───────────── 유지 ─────────────── 성공 +HTTP 201/초 선 등장 → 약 30 req/s 근처 → 종료 후 감소 +2xx 누적 카드 0 → 계속 증가 ───────────────────────▶ 1,801 +3xx·4xx·5xx 0 ─────────────────────────────────▶ 0 +활성 VU 부하 중 시계열 표시, 최대 1 → 종료 후 종료 +Hikari 대기·timeout 0선 +앱 5xx/초 0선 +DB ID 대조 시험 종료 뒤 별도 수행 ────────────▶ 1,801개 각 1행 +``` + +| 패널 | 오늘 본 값 | 왜 바뀌거나 그대로인가 · 한계 | +| --- | --- | --- | +| 앱 A/B 수집 | 두 행 모두 성공 | Prometheus `up`은 관리 지표 수집 성공. 모든 HTTP 쓰기 성공의 증거는 아님 | +| DB 역할·primary | node1/2 REPLICA, node3 PRIMARY | Patroni 역할 지표. 이번엔 장애가 없어 유지 | +| timeline | 14 | 현재 primary의 복구 이력. 역할 승격이 없어서 유지 | +| OpenProxy 포트 | 2/2 연결 가능 | 관측 VM의 TCP probe. 앱의 SQL 성공을 뜻하지 않음 | +| HTTP 201/초 | 실행 중 선이 나타나 약 30 req/s | 완료된 201 Counter의 최근 30초 비율이라 처음·끝에서 완만하게 변함 | +| 2xx·3xx·4xx·5xx | 1,801·0·0·0 | 실행 ID별 응답 대역. 이번 2xx는 모두 201이지만 항상 같은 뜻은 아님 | +| 활성 k6 VU | 실행 중 표시, 1초 표본 최대 1 | 진행 중인 반복 수. 순간 최대를 완전 포착한 값은 아니며, 종료 후 시계열이 사라져도 기록된 수치는 남음 | +| Hikari 대기·timeout | 0선 | DB 연결 대기·timeout. DB 무결성의 단독 증거는 아님 | +| 앱별 HTTP 5xx | 0선 | 앱이 기록한 오류. LB 자체 응답은 여기서 빠질 수 있어 k6 결과와 함께 봄 | +| standby WAL 재생 대기 | 거의 0선 | 이미 받은 WAL의 재생 대기량. 전체 복제 지연이나 RPO 0 보장은 아님 | + +그림 5. 다음 **장애 영상에서 해석할 예상 흐름**이다. 아래는 이번에 실행한 결과가 아닌 설명용 예시다. + +```text +[예시: replica 쪽 OpenProxy A만 중단] +장애 표식 → proxy-A “연결 불가” → 앱이 생존 proxy-B로 새 연결 + → k6 5xx·결과 불명·201/초 변화 확인 +복구 표식 → proxy-A “연결 가능” → k6 201/초 안정화 +※ TCP가 복구돼도 SQL·DB 행 대조를 따로 해야 한다. + +[예시: primary VM 전체 상실] +장애 표식 → 그 DB 행은 지표 수집 실패, 동거 proxy 행도 TCP 실패 + → Patroni 새 primary 선출, timeline 변화 가능 + → 그 사이 k6 5xx·결과 불명·201 공백 확인 +복구 뒤 → 201 재개, 옛 노드 replica 재합류, 새 primary DB에 ID별 대조 +※ 이번 리허설에서는 이런 장애를 주입하지 않았다. +``` + +Grafana는 **언제 어떤 지표가 움직였는지**, 외부 원장과 DB CSV는 **201을 받은 ID가 실제 1행인지**를 말한다. `up=0`만으로 VM 사망이라고 단정하지 않고, proxy TCP 연결 성공만으로 SQL 성공이라고 말하지 않는다. k6 timeout 결과는 요청 시작보다 늦게 그래프에 찍힐 수 있다. + +## 5. 이번에 실제 실행한 것과 결과 + +| 위치 | 실행한 명령·방법 | 실제 결과 | 판정 | +| --- | --- | --- | --- | +| Mac 제어 | GCP VM 목록·내부 LB backend health | 관련 VM 8대 RUNNING, LB 2/2 HEALTHY | 환경 확인; 쓰기 성공의 증거는 아님 | +| Mac 제어 | `ha_fixture_cleanup.py prepare ha449run0001 --guard-seconds 1200` | 계정 4명·독립 타이머 3/3 | 준비 통과 | +| GCP 부하 VM | `run_ha_probe_k6.sh ha449smk0001 1 5s … baseline-write 5` | 5건 201, drop 0, k6 종료 0 | 연결 스모크 통과 | +| GCP 부하 VM | `run_ha_probe_k6.sh ha449run0001 30 60s … baseline-write 80` | 1,801건 201, 실패 0, drop 0, p95 53.42ms, 종료 0 | 정상 1분 기준선 통과 | +| GCP 부하 VM | 원장 sanitize·Remote Write 대조 | 안전 이벤트 3,602건, 원장=Prometheus 1,801, 첫 조회 일치 | 최종 Counter 일치; 중간 전송 전부는 미확인 | +| 현재 primary·Mac | DB export·완료 실행 ID별 대조 | DB 1,801 ID·1,801행, 누락·중복·고아 0 | 정합성 통과 | +| Grafana | 실행 ID와 16:50:20~16:51:50 KST 고정 조회 | 카드 1,801·0·0·0, 201/초·VU 선 | 화면 연결 확인 | +| Mac·GCP | 계정·타이머·JWT·임시 키 제거 | 잔여 계정 0, 활성 타이머 0/3 | 원복 확인; VM은 유지 | + +## 6. 오류가 나면 중단 지점 + +| 증상 | 먼저 볼 것 | 잘못된 결론 | +| --- | --- | --- | +| 401·403 | JWT 만료·ADMIN 역할·앱 설정 | DB 장애라고 부르지 않음 | +| dropped iterations > 0 | VU·부하 VM CPU·요청 대기 | 목표 부하를 전부 발생했다고 하지 않음 | +| k6 201인데 Grafana No data | 실행 ID·시간 범위·Remote Write 대조 | DB 쓰기 실패라고 단정하지 않음 | +| 카드와 원장 불일치 | All 합산·10분 lookback·원격 최종 합계 | 합산 카드를 한 실행 결과로 쓰지 않음 | +| DB 0행 또는 2행 이상 | 현재 primary·같은 run ID인지 재확인 | 정상 통과·RPO 0을 선언하지 않음 | +| 정리 상태가 0이 아님 | 세 타이머·현재 primary 계정 재조회 | 정리 완료라고 보고하지 않음 | + +이번 준비 중에도 **초기 SSH 공개키 인증이 실패**했다. 그래서 기존 키를 추측하거나 보호된 파일을 뒤지는 대신, 만료 시간이 1시간인 새 OS Login 키를 등록해 접속했고 종료 후 제거했다. 또 인증 없이 내부 LB에 보낸 사전 POST는 **401**이었다. 이것은 API 보안 경계가 응답한 것이지 정상 201 쓰기 성공이 아니다. 정상 부하는 합성 ADMIN JWT를 준비한 뒤에야 수행했다. Grafana의 과거 구간을 URL의 ISO 시각으로 직접 넣은 첫 시도는 1970년으로 잘못 해석됐다. 화면의 시간 선택기를 쓰거나 Unix 밀리초 값을 쓰면 정상 구간이 표시된다. 이 준비 단계의 실패·재시도는 5초·1분 k6 실행 결과에 섞지 않았다. + +## 7. 아직 시험하지 않은 것 + +이번에는 PostgreSQL 프로세스 종료, primary VM 상실, OpenProxy 중단을 **실행하지 않았다**. 장애 시 오류율·RTO·RPO는 이번 문서의 신규 결과가 아니다. Node1/2의 전체 DB 행 대조, etcd 정족수, 앱 A/B 배포 JAR 버전도 이번 실행에서는 재측정하지 않았다. 다음 장애 시연은 최신 백업·독립 복구 가드·현재 역할을 다시 확인하고 **목적마다 별도 실행 ID**로 해야 한다. + +이 문서와 안전 증거는 PR #449의 정상 리허설 결과로 관리한다. 원본 압축 파일은 사전 허용 필드 검사와 사후 민감 패턴 검사를 통과했다. diff --git a/docs/test-results/opensql-ha-live-dashboard-fault-rehearsal-20261009.md b/docs/test-results/opensql-ha-live-dashboard-fault-rehearsal-20261009.md new file mode 100644 index 00000000..aa993c46 --- /dev/null +++ b/docs/test-results/opensql-ha-live-dashboard-fault-rehearsal-20261009.md @@ -0,0 +1,91 @@ +# GCP OpenSQL HA 대시보드·실제 쓰기·장애 리허설 — 2026-10-09 + +- 이슈: [#448](https://github.com/DocGrid/docgrid/issues/448) +- 실행 코드 기준: `9ba3e515a53979fd7fe5ffe74681fe96792bb036` (#447 머지 직후). 이 문서와 HA Dashboard는 별도 `test/448` 변경이다. +- 환경: GCP 내부 부하 VM 1대 → 내부 LB → 백엔드 A/B 2대 → OpenProxy A/B 2개 → PostgreSQL/Patroni 3노드. 별도 관측 VM의 Prometheus·Grafana, 공용 캐시 VM 1대가 켜져 있었다. DB 노드 두 대에 OpenProxy가 공존하므로 VM 수와 논리 구성요소 수를 혼동하지 않는다. +- 목적: **같은 화면**에서 실제 HTTP 쓰기·앱·DB·k6 상태를 관찰하되, 데이터 정합성은 외부 요청 원장과 DB 행 대조로 독립 판정한다. +- 원본: 실행별 `k6-events.jsonl`, `k6-summary.json`, `원격-지표-대조.json`, `계측-1초.csv`, `실행-기록.txt`는 부하 VM의 실행 ID별 보호된 폴더에 보존했다. 원본 k6 지표에는 비공개 주소가 들어갈 수 있어 저장소에 복사하지 않았다. 각 실행 문서에 이벤트 원장의 SHA-256을 적었다. + +## 관측 경로와 판정 경계 + +```text +GCP 부하 VM: k6 ──HTTP request_id──▶ 내부 LB ──▶ 앱 A/B + │ │ │ + │ Hikari 풀 ▼ + │ 대기 지표 OpenProxy A/B + │ │ + ├─ 완료 이벤트·지연 ──Remote Write──────────────────┐ │ + └─ DB 밖 원장(201/실패/불명) │ ▼ + PostgreSQL primary + replica 2 + │ Patroni 지표 │ + └──────────┬───────────┘ + ▼ + Prometheus → Grafana + +화면: 응답률·VU·앱/DB 수집·primary 역할·WAL 재생 대기 +최종 판정: 원장의 request_id ↔ 복구 후 primary DB의 행 수 +``` + +Grafana Dashboard UID는 `docgrid-opensql-ha-live`다. 패널 8개(제목 Row 2개 포함), `run_id` 선택 변수 1개이며 인증된 관측 VM에 실제 가져와 읽어 보았다. OpenProxy **프로세스 상태 자체는 Prometheus에 직접 수집하지 않는다.** 따라서 화면만으로 A/B 프록시가 살아 있다고 판단하지 않고 장애 가드의 프로세스·포트 확인과 HTTP 결과를 함께 사용한다. k6 Remote Write의 누적 p95/p99를 구간 복구 지연인 것처럼 표시하지 않도록 해당 패널을 제외했다. + +위 **8패널은 이 절의 장애 실행 당시 구성**이다. 이후 같은 작업 브랜치에서 OpenProxy의 관측 VM 기준 TCP 포트 지표와 HTTP 2xx·3xx·4xx·5xx 누적 카드 등을 더해 **18패널**로 확장했다. [후속 실행별 기록](evidence/ha-dashboard-preview-20261009/run-09-live-dashboard-apply.md)과 [새 정상 기준선](evidence/ha-dashboard-preview-20261009/run-07-http-baseline.md)을 구분해 읽어야 한다. TCP 포트 연결은 OpenProxy 프로세스 상태·SQL 처리 성공·앱에서 사용하는 경로의 증거가 아니며, 아래 OpenProxy·primary 장애 실행을 새 카드 구성으로 다시 실행했다고 주장하지 않는다. + +## 실행 결과 + +| 실행 ID | 조건 | HTTP 결과 | 드롭 | p95 / p99 | 요청 ID별 DB 판정 | +| --- | --- | --- | ---: | --- | --- | +| [`ha448smoke01`](evidence/issue-448/ha448smoke01.md) | 1 req/s, 5초, 사전 연결 확인 | 201 **6/6** | 0 | 91.96 / 99.17 ms | 201 누락·중복 **0/0** | +| [`ha448base01`](evidence/issue-448/ha448base01.md) | 100 req/s, 60초, 정상 기준선 | 201 **6,001/6,001** | 0 | 52.20 / 54.39 ms | 201 누락·중복 **0/0** | +| [`ha448proxy01`](evidence/issue-448/ha448proxy01.md) | 100 req/s, 120초, OpenProxy A 프로세스 중단·복구 | 201 **11,994**, 500 **7** / 12,001 | 0 | 52.09 / 55.68 ms | 201 누락·중복 **0/0**, 500 DB 반영 **0** | +| [`ha448proc01`](evidence/issue-448/ha448proc01.md) | 30 req/s, 180초, primary postmaster SIGKILL | 201 **5,161**, 500 **230**, 401 **4**, 결과 불명 **5** / 5,400 | 0 | 2,715.64 / 5,011.01 ms | 201 누락·중복 **0/0**, 불명 중 DB 반영 **3** | + +기준선 k6 종료 코드는 **0**, 프록시 실행도 **0**이다. primary 실행은 HTTP 실패율 임계값 때문에 **99**이며 장애 시험의 관측 결과를 숨기지 않는다. 네 실행 모두 VM 1초 계측과 Prometheus 최종 Counter 합계 대조가 통과했다. 이 최종 합계는 중간 Remote Write 전송 공백까지 없었다는 증명은 아니다. + +```text +OpenProxy A 프로세스 장애 — UTC +20:24:23 k6 100 req/s 시작 +20:25:03.936 A 프로세스 중단 (독립 복구 가드 무장) +20:25:04.114~.153 HTTP 500 총 7건 +20:25:42.240 A 프로세스·포트 복구 확인 +20:26:25 k6 종료, 요청 ID 12,001건 대조 완료 + +중단~복구: 38.304초(프록시 프로세스 기준) +연속된 201 응답 간 최대 간격: 0.049초(HTTP 관측 기준) +→ 프록시 하나가 내려가도 정상 쓰기가 계속됐지만, 오류 0건은 아니다. +``` + +```text +primary PostgreSQL 프로세스 장애 — UTC +20:34:00 k6 30 req/s 시작 +20:34:19 현재 primary의 postmaster만 SIGKILL +20:34:19.990 첫 201 이외 응답 +20:34:32.109 마지막 201 이외 응답 +20:37:01 k6 종료 + +201 응답 사이 최대 간격: 11.360초 +첫~마지막 비201 관측 구간: 12.119초 +Patroni: 같은 노드가 다시 leader/running, replica 2대 streaming/lag 0 +→ 새 리더 선출 시험이 아니라 같은 노드 DB 프로세스 재시작 시험이다. +``` + +## 정합성과 운영 해석 + +```text +클라이언트 응답 복구 후 primary 행 판정 +201 ───────────────▶ 1행 보존 관측 +201 ───────────────▶ 0행 / 2행 이상 누락 / 중복 +500·401 ────────────▶ 0행 이 실행에서는 미반영 +결과 불명 5건 ──────▶ 3행(각 ID 1행) 응답 없음 ≠ DB 미반영 + +primary 실행의 전체 DB 고유 ID: 5,164개 = 201 ID 5,161개 + 불명 반영 ID 3개 +``` + +이번 실행에서 201 누락이 0건인 것은 **관측값**이며 비동기 복제의 절대 RPO 0 보장이 아니다. 500·401·불명은 사용자가 겪은 가용성 실패다. 401 4건의 개별 원인과 500 발생 계층은 이 실행에서 확정하지 않았다. 결과 불명 3건이 실제 반영됐으므로 멱등 계약 없이 무조건 재시도하면 중복 위험이 있다. 장애 전후 HTTP 최대 성공 간격과 프로세스 재기동 시간을 하나의 RTO 수치로 섞지 않았다. + +## 원복 및 미검증 범위 + +- 장애 뒤 LB 백엔드 **2/2 HEALTHY**, Patroni **leader 1·streaming replica 2(lag 0)**, OpenProxy **A/B 모두 프로세스 1·포트 ready**를 다시 확인했다. +- 합성 ADMIN 계정 **4개 삭제**, DB VM 3대의 fixture 타이머 **3/3 해제**, 프록시 복구 가드 **해제**, 임시 JWT **2개 삭제**, 원격 임시 장애·대조 스크립트 삭제를 확인했다. +- 앱·부하·관측·DB VM은 시연 재사용을 위해 **계속 RUNNING**이다. VM·디스크·외부 IPv4 비용은 계속 발생한다. +- OpenProxy B 장애, 리더 VM 상실, 다중 반복 분포, 프록시별 실시간 Prometheus status, 비동기 복제 지연 중 RPO 경계는 이번 실행 범위 밖이다. +- 원본 측정값의 해시와 명령·실패 재시도는 [실행별 로그](evidence/issue-448/dashboard-and-preflight.md) 및 각 실행 문서를 본다. diff --git a/monitoring/blackbox/blackbox.yml b/monitoring/blackbox/blackbox.yml new file mode 100644 index 00000000..efd69f7a --- /dev/null +++ b/monitoring/blackbox/blackbox.yml @@ -0,0 +1,4 @@ +modules: + tcp_connect: + prober: tcp + timeout: 1s diff --git a/monitoring/blackbox/prometheus-scrape.example.yml b/monitoring/blackbox/prometheus-scrape.example.yml new file mode 100644 index 00000000..d48c8bb6 --- /dev/null +++ b/monitoring/blackbox/prometheus-scrape.example.yml @@ -0,0 +1,15 @@ +# 관측 VM의 기존 scrape_configs에 추가한다. 내부 주소는 실제 배포 시에만 대입한다. +- job_name: openproxy-tcp + metrics_path: /probe + params: + module: [tcp_connect] + static_configs: + - targets: [":6432"] + labels: {node: "proxy-A"} + - targets: [":6432"] + labels: {node: "proxy-B"} + relabel_configs: + - source_labels: [__address__] + target_label: __param_target + - target_label: __address__ + replacement: blackbox-exporter:9115 diff --git a/monitoring/grafana/README.md b/monitoring/grafana/README.md index 8f042160..ae998643 100644 --- a/monitoring/grafana/README.md +++ b/monitoring/grafana/README.md @@ -35,6 +35,41 @@ Git에서 다음 파일을 수정한다. 인증·TLS·권한 정책을 적용하고 Dashboard JSON을 가져온다. 이때 Prometheus Data Source UID를 `docgrid-prometheus`로 만들거나 가져오기 과정에서 조직 Data Source로 교체한다. +## GCP OpenSQL HA 시연 Dashboard + +`dashboards/docgrid-ha-demo.json`은 별도 GCP 관측 VM에서 사용하는 수동 가져오기용 Dashboard다. +번들 Data Source가 아니라 인증된 관측 VM의 Prometheus UID `ha-prometheus`를 요구한다. 앱 A/B, +Patroni 3노드의 수집·역할 상태와 관측 VM에서 본 OpenProxy A/B TCP 포트 연결을 타임라인으로 표시하고, +현재 primary·timeline·연결 가능한 프록시 포트 수, 실행 ID별 HTTP 2xx·3xx·4xx·5xx 누적 응답 건수, 앱별 5xx, +Hikari 대기·연결 timeout, standby WAL 수신·재생 차이, k6 결과별 응답/초와 활성 VU를 보여준다. +`run_id` 변수는 k6 VU 지표에서 읽어 201 응답이 0건인 실행도 선택할 수 있게 한다. 각 코드 대역 카드에 +해당 응답이 없더라도 선택한 실행의 VU 지표가 수집됐다면 0건으로 표시한다. `All`이면 최근 10분 이내 +여러 실행의 건수가 합산될 수 있으므로 영상에서는 특정 실행 ID를 선택한다. 새 실행 ID가 자동 새로고침만으로 +목록에 나타나는지는 검증되지 않았으므로 촬영 전 목록을 다시 열어 확인한다. 코드 대역 카드에는 응답이 오지 않은 +결과 불명 요청이 포함되지 않으며, 이 건수는 아래 결과별 응답/초 그래프와 안전 원장에서 별도로 확인한다. + +이 화면의 `up=1`은 지표 수집 성공일 뿐 HTTP 쓰기 성공이 아니며, `up=0`만으로 VM 중단을 +확정하지 않는다. 앱별 5xx는 LB가 직접 반환한 오류를 포함하지 않을 수 있다. OpenProxy 포트 연결은 +관측 VM에서 Blackbox Exporter의 TCP probe로 확인하며 SQL 성공을 보장하지 않는다. 앱→프록시 연결 경로는 +현재 Prometheus에 직접 수집하지 않으므로 운영자 장애 로그와 HTTP 원장을 +함께 본다. 장애 주입·복구 annotation은 설정만 있으며 실제 이벤트를 기록해야 화면에 나타난다. 그래프만으로 +성공 응답 데이터의 보존이나 RPO를 주장하지 않으며 요청 ID별 DB 대조 결과를 별도로 확인한다. +Remote Write의 누적 Trend p95/p99는 복구 시점 지연으로 오해하기 쉬워 패널에서 제외했다. +실행·장애 계측 결과는 [GCP HA 대시보드 검증 기록](../../docs/test-results/opensql-ha-live-dashboard-fault-rehearsal-20261009.md)에 둔다. + +201이 없는 실행의 카드 표시 방식은 `python3 -m unittest monitoring.grafana.tests.test_ha_dashboard`로 +구조를 확인하고, Prometheus가 설치된 환경에서는 아래 합성 PromQL 회귀 시험으로 확인한다. +이 시험은 실제 GCP Remote Write 수신이나 과거 실행의 시계열 보존을 증명하지 않는다. + +```bash +promtool test rules monitoring/grafana/tests/ha_dashboard_http_classes.yml +``` + +OpenProxy 관측에는 `monitoring/blackbox/blackbox.yml`의 TCP 전용 모듈을 사용한다. +Prometheus 잡의 재현용 형식은 `monitoring/blackbox/prometheus-scrape.example.yml`에 있으며, +실제 두 내부 대상 주소는 관측 VM 설정에만 둔다. Exporter는 관측 VM의 Prometheus와 같은 +내부 Docker 네트워크에서 실행하고 호스트 포트를 게시하지 않는다. + ## 화면 구성 Dashboard는 `cluster`, `environment`, `instance` 순서로 조회 범위를 좁힌다. 기본값 `All`은 같은 diff --git a/monitoring/grafana/dashboards/docgrid-ha-demo.json b/monitoring/grafana/dashboards/docgrid-ha-demo.json new file mode 100644 index 00000000..5809379a --- /dev/null +++ b/monitoring/grafana/dashboards/docgrid-ha-demo.json @@ -0,0 +1,229 @@ +{ + "annotations": {"list": [{"builtIn": 1, "datasource": {"type": "grafana", "uid": "-- Grafana --"}, "enable": true, "hide": false, "iconColor": "rgba(255, 152, 48, 1)", "name": "장애 주입·복구 기록", "type": "dashboard"}]}, + "description": "GCP OpenSQL HA 시연: 앱·DB 지표 수집, Patroni 역할, 관측 VM에서 OpenProxy 포트 연결, 실행 ID별 k6 HTTP 응답코드 대역 누적 건수와 결과별 응답/초를 같은 시간축에서 본다. 결과 불명처럼 HTTP 응답이 없는 요청은 코드 대역 카드에 포함되지 않는다. 앱→프록시 연결 경로는 아직 직접 수집하지 않는다. 정합성은 요청 ID 원장과 DB 대조로 판정한다.", + "editable": false, + "graphTooltip": 1, + "id": null, + "panels": [ + { + "id": 1, + "type": "row", + "title": "서비스와 DB 상태", + "collapsed": false, + "gridPos": {"x": 0, "y": 0, "w": 24, "h": 1}, + "panels": [] + }, + { + "id": 2, + "type": "state-timeline", + "title": "앱 A/B 지표 수집 상태", + "description": "Prometheus scrape 성공(1)·실패(0). 실패는 앱 VM 중단의 단독 증거가 아니며, 성공도 실제 HTTP 쓰기 성공을 뜻하지 않는다.", + "datasource": {"type": "prometheus", "uid": "ha-prometheus"}, + "gridPos": {"x": 0, "y": 4, "w": 8, "h": 6}, + "fieldConfig": {"defaults": {"min": 0, "max": 1, "noValue": "지표 없음", "mappings": [{"type": "value", "options": {"0": {"text": "수집 실패", "color": "red"}, "1": {"text": "수집 성공", "color": "green"}}}]}, "overrides": []}, + "options": {"mergeValues": true, "showValue": "always", "alignValue": "center", "rowHeight": 0.9}, + "targets": [ + {"refId": "A", "expr": "up{job=\"docgrid-app\",node=\"app-A\"}", "legendFormat": "app-A", "datasource": {"type": "prometheus", "uid": "ha-prometheus"}}, + {"refId": "B", "expr": "up{job=\"docgrid-app\",node=\"app-B\"}", "legendFormat": "app-B", "datasource": {"type": "prometheus", "uid": "ha-prometheus"}} + ] + }, + { + "id": 3, + "type": "state-timeline", + "title": "DB node1~3 역할·PostgreSQL 상태", + "description": "2=PRIMARY, 1=REPLICA, 0=PostgreSQL 중지, -1=Patroni 지표 수집 실패, 3=기타/전환 중. 수집 실패만으로 VM 상실을 단정하지 않는다.", + "datasource": {"type": "prometheus", "uid": "ha-prometheus"}, + "gridPos": {"x": 8, "y": 4, "w": 8, "h": 6}, + "fieldConfig": {"defaults": {"noValue": "지표 없음", "mappings": [{"type": "value", "options": {"-1": {"text": "수집 실패", "color": "red"}, "0": {"text": "PostgreSQL 중지", "color": "yellow"}, "1": {"text": "REPLICA", "color": "blue"}, "2": {"text": "PRIMARY", "color": "orange"}, "3": {"text": "기타/전환 중", "color": "purple"}}}]}, "overrides": []}, + "options": {"mergeValues": true, "showValue": "always", "alignValue": "center", "rowHeight": 0.9}, + "targets": [{"refId": "A", "expr": "(patroni_postgres_running{job=\"patroni\"} == 0) or on(instance) (2 * (patroni_primary{job=\"patroni\"} == 1)) or on(instance) (patroni_replica{job=\"patroni\"} == 1) or on(instance) ((up{job=\"patroni\"} == 0) - 1) or on(instance) (((up{job=\"patroni\"} == 1) * 0) + 3)", "legendFormat": "{{node}}", "datasource": {"type": "prometheus", "uid": "ha-prometheus"}}] + }, + { + "id": 9, + "type": "stat", + "title": "현재 primary", + "description": "Patroni가 현재 primary로 보고하는 노드. 지표가 없으면 리더 상실·수집 장애를 별도로 확인해야 한다.", + "datasource": {"type": "prometheus", "uid": "ha-prometheus"}, + "gridPos": {"x": 0, "y": 1, "w": 8, "h": 3}, + "fieldConfig": {"defaults": {"noValue": "리더 정보 없음"}, "overrides": []}, + "options": {"reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false}, "textMode": "name", "colorMode": "none", "graphMode": "none", "justifyMode": "auto", "orientation": "auto"}, + "targets": [{"refId": "A", "expr": "patroni_primary{job=\"patroni\"} == 1", "instant": true, "legendFormat": "{{node}}", "datasource": {"type": "prometheus", "uid": "ha-prometheus"}}] + }, + { + "id": 10, + "type": "stat", + "title": "PostgreSQL 승격 이력 (timeline)", + "description": "현재 primary의 PostgreSQL timeline. 승격 시 변화를 확인하며, 모든 재시작마다 증가한다는 뜻은 아니다. 다른 노드의 최대값을 리더의 값으로 대신하지 않는다.", + "datasource": {"type": "prometheus", "uid": "ha-prometheus"}, + "gridPos": {"x": 8, "y": 1, "w": 8, "h": 3}, + "fieldConfig": {"defaults": {"unit": "short", "noValue": "리더 정보 없음"}, "overrides": []}, + "options": {"reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false}, "textMode": "value", "colorMode": "none", "graphMode": "none", "justifyMode": "auto", "orientation": "auto"}, + "targets": [{"refId": "A", "expr": "patroni_postgres_timeline{job=\"patroni\"} and on(instance) (patroni_primary{job=\"patroni\"} == 1)", "instant": true, "legendFormat": "{{node}}", "datasource": {"type": "prometheus", "uid": "ha-prometheus"}}] + }, + { + "id": 13, + "type": "stat", + "title": "OpenProxy 포트 연결", + "description": "관측 VM에서 TCP 연결에 성공한 OpenProxy 포트 수. SQL 요청 성공이나 앱의 프록시 사용 여부를 뜻하지 않는다.", + "datasource": {"type": "prometheus", "uid": "ha-prometheus"}, + "gridPos": {"x": 16, "y": 1, "w": 8, "h": 3}, + "fieldConfig": {"defaults": {"min": 0, "max": 2, "noValue": "포트 지표 없음", "mappings": [{"type": "value", "options": {"0": {"text": "0 / 2", "color": "red"}, "1": {"text": "1 / 2", "color": "red"}, "2": {"text": "2 / 2", "color": "green"}}}], "thresholds": {"mode": "absolute", "steps": [{"color": "red", "value": null}, {"color": "green", "value": 2}]}}, "overrides": []}, + "options": {"reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false}, "textMode": "value", "colorMode": "value", "graphMode": "none", "justifyMode": "auto", "orientation": "auto"}, + "targets": [{"refId": "A", "expr": "sum(probe_success{job=\"openproxy-tcp\"})", "instant": true, "legendFormat": "연결 가능 / 2", "datasource": {"type": "prometheus", "uid": "ha-prometheus"}}] + }, + { + "id": 14, + "type": "state-timeline", + "title": "관측 VM→OpenProxy A/B 포트 연결", + "description": "1=관측 VM에서 TCP 연결 가능, 0=연결 불가. 포트 응답은 SQL 처리 성공이나 앱→프록시 경로의 단독 증거가 아니다.", + "datasource": {"type": "prometheus", "uid": "ha-prometheus"}, + "gridPos": {"x": 16, "y": 4, "w": 8, "h": 6}, + "fieldConfig": {"defaults": {"min": 0, "max": 1, "noValue": "지표 없음", "mappings": [{"type": "value", "options": {"0": {"text": "연결 불가", "color": "red"}, "1": {"text": "연결 가능", "color": "green"}}}]}, "overrides": []}, + "options": {"mergeValues": true, "showValue": "always", "alignValue": "center", "rowHeight": 0.9}, + "targets": [{"refId": "A", "expr": "label_replace(probe_success{job=\"openproxy-tcp\",node=\"proxy-A\"}, \"display\", \"proxy-A (db-node2)\", \"node\", \".*\") or label_replace(probe_success{job=\"openproxy-tcp\",node=\"proxy-B\"}, \"display\", \"proxy-B (db-node3)\", \"node\", \".*\")", "legendFormat": "{{display}}", "datasource": {"type": "prometheus", "uid": "ha-prometheus"}}] + }, + { + "id": 4, + "type": "timeseries", + "title": "앱 Hikari 연결 대기", + "description": "앱별 Hikari pending 연결 요청 수. DB 전환 중 풀 대기가 쌓이는지 관측한다.", + "datasource": {"type": "prometheus", "uid": "ha-prometheus"}, + "gridPos": {"x": 0, "y": 20, "w": 12, "h": 4}, + "fieldConfig": {"defaults": {"min": 0, "unit": "short", "custom": {"axisSoftMax": 10, "showPoints": "never"}}, "overrides": []}, + "targets": [{"refId": "A", "expr": "hikaricp_connections_pending{job=\"docgrid-app\"}", "legendFormat": "{{node}} / {{pool}}", "datasource": {"type": "prometheus", "uid": "ha-prometheus"}}] + }, + { + "id": 5, + "type": "timeseries", + "title": "standby 수신 후 WAL 재생 대기", + "description": "각 standby가 이미 받은 WAL과 재생한 WAL의 차이(Byte). primary와 standby 사이 전체 복제 지연이나 RPO는 이 값만으로 판정할 수 없다.", + "datasource": {"type": "prometheus", "uid": "ha-prometheus"}, + "gridPos": {"x": 12, "y": 24, "w": 12, "h": 4}, + "fieldConfig": {"defaults": {"min": 0, "unit": "bytes", "custom": {"showPoints": "never"}}, "overrides": []}, + "targets": [{"refId": "A", "expr": "clamp_min(patroni_xlog_received_location{job=\"patroni\"} - patroni_xlog_replayed_location{job=\"patroni\"}, 0) and on(instance) (patroni_replica{job=\"patroni\"} == 1)", "legendFormat": "{{node}}", "datasource": {"type": "prometheus", "uid": "ha-prometheus"}}] + }, + { + "id": 11, + "type": "timeseries", + "title": "앱별 HTTP 5xx/초", + "description": "앱 Actuator가 관측한 5xx만 표시한다. LB가 앱에 전달하지 않고 반환한 5xx는 포함되지 않을 수 있어 k6 전체 결과와 함께 읽는다.", + "datasource": {"type": "prometheus", "uid": "ha-prometheus"}, + "gridPos": {"x": 0, "y": 24, "w": 12, "h": 4}, + "fieldConfig": {"defaults": {"min": 0, "unit": "reqps", "custom": {"axisSoftMax": 10, "showPoints": "never"}}, "overrides": []}, + "targets": [{"refId": "A", "expr": "sum by (node) (rate(http_server_requests_seconds_count{job=\"docgrid-app\",status=~\"5..\"}[30s])) or on(node) (sum by (node) (up{job=\"docgrid-app\"} == 1) * 0)", "legendFormat": "{{node}}", "datasource": {"type": "prometheus", "uid": "ha-prometheus"}}] + }, + { + "id": 12, + "type": "timeseries", + "title": "앱별 Hikari 연결 timeout 증분 (최근 15초)", + "description": "최근 15초 동안 증가한 Hikari 연결 timeout 수. 앱 지표 수집 실패는 0으로 숨기지 않는다.", + "datasource": {"type": "prometheus", "uid": "ha-prometheus"}, + "gridPos": {"x": 12, "y": 20, "w": 12, "h": 4}, + "fieldConfig": {"defaults": {"min": 0, "unit": "short", "custom": {"axisSoftMax": 10, "showPoints": "never"}}, "overrides": []}, + "targets": [{"refId": "A", "expr": "sum by (node) (increase(hikaricp_connections_timeout_total{job=\"docgrid-app\"}[15s])) or on(node) (sum by (node) (up{job=\"docgrid-app\"} == 1) * 0)", "legendFormat": "{{node}}", "datasource": {"type": "prometheus", "uid": "ha-prometheus"}}] + }, + { + "id": 6, + "type": "row", + "title": "k6 HTTP 결과", + "collapsed": false, + "gridPos": {"x": 0, "y": 10, "w": 24, "h": 1}, + "panels": [] + }, + { + "id": 7, + "type": "timeseries", + "title": "HTTP 결과별 응답/초", + "description": "응답이 끝난 시각의 k6 Counter를 30초 창으로 계산한다. 기타 예상 외 응답에는 201 이외의 2xx도 포함한다. timeout·결과 불명은 응답 지연만큼 장애 주입 시각보다 늦게 나타날 수 있다.", + "datasource": {"type": "prometheus", "uid": "ha-prometheus"}, + "gridPos": {"x": 0, "y": 14, "w": 16, "h": 6}, + "fieldConfig": {"defaults": {"min": 0, "unit": "reqps", "custom": {"showPoints": "never"}}, "overrides": []}, + "targets": [ + {"refId": "A", "expr": "sum(rate(k6_ha_outcome_201_total{run_id=~\"$run_id\"}[30s]))", "legendFormat": "HTTP 201", "datasource": {"type": "prometheus", "uid": "ha-prometheus"}}, + {"refId": "B", "expr": "sum(rate(k6_ha_outcome_500_total{run_id=~\"$run_id\"}[30s]))", "legendFormat": "HTTP 500", "datasource": {"type": "prometheus", "uid": "ha-prometheus"}}, + {"refId": "C", "expr": "sum(rate(k6_ha_outcome_503_total{run_id=~\"$run_id\"}[30s]))", "legendFormat": "HTTP 503", "datasource": {"type": "prometheus", "uid": "ha-prometheus"}}, + {"refId": "D", "expr": "sum(rate(k6_ha_outcome_other_failed_total{run_id=~\"$run_id\"}[30s]))", "legendFormat": "기타 예상 외 응답", "datasource": {"type": "prometheus", "uid": "ha-prometheus"}}, + {"refId": "E", "expr": "sum(rate(k6_ha_outcome_unknown_total{run_id=~\"$run_id\"}[30s]))", "legendFormat": "결과 불명", "datasource": {"type": "prometheus", "uid": "ha-prometheus"}} + ] + }, + { + "id": 8, + "type": "timeseries", + "title": "활성 k6 VU", + "description": "활성 가상 사용자 수. constant-arrival-rate에서 응답 대기 증가의 보조 지표이며, 전송하지 못한 iterations는 별도 k6 원장으로 판정한다.", + "datasource": {"type": "prometheus", "uid": "ha-prometheus"}, + "gridPos": {"x": 16, "y": 14, "w": 8, "h": 6}, + "fieldConfig": {"defaults": {"min": 0, "unit": "short", "custom": {"showPoints": "never"}}, "overrides": []}, + "targets": [{"refId": "A", "expr": "k6_vus{run_id=~\"$run_id\"}", "legendFormat": "VU", "datasource": {"type": "prometheus", "uid": "ha-prometheus"}}] + }, + { + "id": 15, + "type": "stat", + "title": "2xx 누적 응답", + "description": "선택한 실행 ID에서 HTTP 2xx 응답을 받은 누적 건수. k6 VU 지표가 있고 2xx 응답이 없으면 0으로 표시한다. 결과 불명은 포함하지 않는다. All 선택 시 최근 10분에 기록된 모든 실행의 합계다.", + "datasource": {"type": "prometheus", "uid": "ha-prometheus"}, + "gridPos": {"x": 0, "y": 11, "w": 6, "h": 3}, + "fieldConfig": {"defaults": {"unit": "none", "decimals": 0, "noValue": "시험 지표 없음", "thresholds": {"mode": "absolute", "steps": [{"color": "gray", "value": null}, {"color": "green", "value": 1}]}}, "overrides": []}, + "options": {"reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false}, "textMode": "value", "colorMode": "value", "graphMode": "none"}, + "targets": [{"refId": "A", "expr": "sum(last_over_time(k6_ha_http_2xx_total{run_id=~\"$run_id\"}[10m])) or (max(last_over_time(k6_vus{run_id=~\"$run_id\"}[10m])) * 0)", "instant": true, "legendFormat": "2xx", "datasource": {"type": "prometheus", "uid": "ha-prometheus"}}] + }, + { + "id": 16, + "type": "stat", + "title": "3xx 누적 응답", + "description": "선택한 실행 ID에서 HTTP 3xx 응답을 받은 누적 건수. k6 VU 지표가 있고 3xx 응답이 없으면 0으로 표시한다.", + "datasource": {"type": "prometheus", "uid": "ha-prometheus"}, + "gridPos": {"x": 6, "y": 11, "w": 6, "h": 3}, + "fieldConfig": {"defaults": {"unit": "none", "decimals": 0, "noValue": "시험 지표 없음", "thresholds": {"mode": "absolute", "steps": [{"color": "gray", "value": null}, {"color": "yellow", "value": 1}]}}, "overrides": []}, + "options": {"reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false}, "textMode": "value", "colorMode": "value", "graphMode": "none"}, + "targets": [{"refId": "A", "expr": "sum(last_over_time(k6_ha_http_3xx_total{run_id=~\"$run_id\"}[10m])) or (max(last_over_time(k6_vus{run_id=~\"$run_id\"}[10m])) * 0)", "instant": true, "legendFormat": "3xx", "datasource": {"type": "prometheus", "uid": "ha-prometheus"}}] + }, + { + "id": 17, + "type": "stat", + "title": "4xx 누적 응답", + "description": "선택한 실행 ID에서 HTTP 4xx 응답을 받은 누적 건수. 결과 불명은 포함하지 않는다.", + "datasource": {"type": "prometheus", "uid": "ha-prometheus"}, + "gridPos": {"x": 12, "y": 11, "w": 6, "h": 3}, + "fieldConfig": {"defaults": {"unit": "none", "decimals": 0, "noValue": "시험 지표 없음", "thresholds": {"mode": "absolute", "steps": [{"color": "gray", "value": null}, {"color": "yellow", "value": 1}]}}, "overrides": []}, + "options": {"reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false}, "textMode": "value", "colorMode": "value", "graphMode": "none"}, + "targets": [{"refId": "A", "expr": "sum(last_over_time(k6_ha_http_4xx_total{run_id=~\"$run_id\"}[10m])) or (max(last_over_time(k6_vus{run_id=~\"$run_id\"}[10m])) * 0)", "instant": true, "legendFormat": "4xx", "datasource": {"type": "prometheus", "uid": "ha-prometheus"}}] + }, + { + "id": 18, + "type": "stat", + "title": "5xx 누적 응답", + "description": "선택한 실행 ID에서 HTTP 5xx 응답을 받은 누적 건수. 결과 불명은 포함하지 않는다.", + "datasource": {"type": "prometheus", "uid": "ha-prometheus"}, + "gridPos": {"x": 18, "y": 11, "w": 6, "h": 3}, + "fieldConfig": {"defaults": {"unit": "none", "decimals": 0, "noValue": "시험 지표 없음", "thresholds": {"mode": "absolute", "steps": [{"color": "gray", "value": null}, {"color": "red", "value": 1}]}}, "overrides": []}, + "options": {"reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false}, "textMode": "value", "colorMode": "value", "graphMode": "none"}, + "targets": [{"refId": "A", "expr": "sum(last_over_time(k6_ha_http_5xx_total{run_id=~\"$run_id\"}[10m])) or (max(last_over_time(k6_vus{run_id=~\"$run_id\"}[10m])) * 0)", "instant": true, "legendFormat": "5xx", "datasource": {"type": "prometheus", "uid": "ha-prometheus"}}] + } + ], + "refresh": "5s", + "schemaVersion": 41, + "tags": ["opensql", "ha", "test"], + "templating": { + "list": [{ + "name": "run_id", + "label": "실행 ID", + "type": "query", + "datasource": {"type": "prometheus", "uid": "ha-prometheus"}, + "definition": "label_values(k6_vus, run_id)", + "query": {"query": "label_values(k6_vus, run_id)", "refId": "PrometheusVariableQueryEditor-VariableQuery"}, + "includeAll": true, + "allValue": ".*", + "current": {"selected": true, "text": "All", "value": "$__all"}, + "options": [], + "refresh": 2, + "sort": 1 + }] + }, + "time": {"from": "now-5m", "to": "now"}, + "timepicker": {"refresh_intervals": ["5s", "10s", "30s", "1m"]}, + "timezone": "browser", + "title": "DocGrid OpenSQL HA Live", + "uid": "docgrid-opensql-ha-live", + "version": 1 +} diff --git a/monitoring/grafana/tests/ha_dashboard_http_classes.yml b/monitoring/grafana/tests/ha_dashboard_http_classes.yml new file mode 100644 index 00000000..62344ee0 --- /dev/null +++ b/monitoring/grafana/tests/ha_dashboard_http_classes.yml @@ -0,0 +1,53 @@ +# Synthetic PromQL checks: a 403-only run has no HTTP 201 or 2xx series. +rule_files: [] +evaluation_interval: 1m +tests: + - interval: 1m + input_series: + - series: 'k6_vus{run_id="r403"}' + values: '1 1 1 1' + - series: 'k6_ha_http_4xx_total{run_id="r403"}' + values: '6 6 6 6' + promql_expr_test: + - expr: 'sum(last_over_time(k6_ha_http_2xx_total{run_id=~"r403"}[10m])) or (max(last_over_time(k6_vus{run_id=~"r403"}[10m])) * 0)' + eval_time: 3m + exp_samples: + - labels: '{}' + value: 0 + - expr: 'sum(last_over_time(k6_ha_http_3xx_total{run_id=~"r403"}[10m])) or (max(last_over_time(k6_vus{run_id=~"r403"}[10m])) * 0)' + eval_time: 3m + exp_samples: + - labels: '{}' + value: 0 + - expr: 'sum(last_over_time(k6_ha_http_4xx_total{run_id=~"r403"}[10m])) or (max(last_over_time(k6_vus{run_id=~"r403"}[10m])) * 0)' + eval_time: 3m + exp_samples: + - labels: '{}' + value: 6 + - expr: 'sum(last_over_time(k6_ha_http_5xx_total{run_id=~"r403"}[10m])) or (max(last_over_time(k6_vus{run_id=~"r403"}[10m])) * 0)' + eval_time: 3m + exp_samples: + - labels: '{}' + value: 0 + - interval: 1m + input_series: + - series: 'k6_vus{run_id="r201"}' + values: '1 1 1 1' + - series: 'k6_ha_http_2xx_total{run_id="r201"}' + values: '1801 1801 1801 1801' + promql_expr_test: + - expr: 'sum(last_over_time(k6_ha_http_2xx_total{run_id=~"r201"}[10m])) or (max(last_over_time(k6_vus{run_id=~"r201"}[10m])) * 0)' + eval_time: 3m + exp_samples: + - labels: '{}' + value: 1801 + - expr: 'sum(last_over_time(k6_ha_http_4xx_total{run_id=~"r201"}[10m])) or (max(last_over_time(k6_vus{run_id=~"r201"}[10m])) * 0)' + eval_time: 3m + exp_samples: + - labels: '{}' + value: 0 + - interval: 1m + promql_expr_test: + - expr: 'sum(last_over_time(k6_ha_http_2xx_total{run_id=~"missing"}[10m])) or (max(last_over_time(k6_vus{run_id=~"missing"}[10m])) * 0)' + eval_time: 3m + exp_samples: [] diff --git a/monitoring/grafana/tests/test_ha_dashboard.py b/monitoring/grafana/tests/test_ha_dashboard.py new file mode 100644 index 00000000..386f7db8 --- /dev/null +++ b/monitoring/grafana/tests/test_ha_dashboard.py @@ -0,0 +1,177 @@ +"""Check the HA demo dashboard's evidence and layout boundaries.""" + +from __future__ import annotations + +import json +import unittest +from pathlib import Path + + +DASHBOARD = Path(__file__).resolve().parents[1] / "dashboards" / "docgrid-ha-demo.json" + + +class HaDashboardTest(unittest.TestCase): + """Keep the HA panels readable without treating graphs as DB reconciliation.""" + + @classmethod + def setUpClass(cls): + """Load the committed dashboard once for deterministic static checks.""" + cls.dashboard = json.loads(DASHBOARD.read_text(encoding="utf-8")) + + def test_datasource_and_access_scope(self): + """All queries use the observer's authenticated, private data source.""" + self.assertEqual("docgrid-opensql-ha-live", self.dashboard["uid"]) + self.assertFalse(self.dashboard["editable"]) + panels = [panel for panel in self.dashboard["panels"] if panel["type"] != "row"] + self.assertTrue(panels) + for panel in panels: + self.assertEqual("ha-prometheus", panel["datasource"]["uid"]) + self.assertTrue(panel["description"]) + for target in panel["targets"]: + self.assertEqual("ha-prometheus", target["datasource"]["uid"]) + self.assertTrue(target["expr"]) + + def test_panel_grid_does_not_overlap(self): + """Each panel owns an exclusive rectangle in the Grafana grid.""" + occupied = set() + ids = set() + for panel in self.dashboard["panels"]: + self.assertNotIn(panel["id"], ids) + ids.add(panel["id"]) + grid = panel["gridPos"] + for x in range(grid["x"], grid["x"] + grid["w"]): + for y in range(grid["y"], grid["y"] + grid["h"]): + self.assertNotIn((x, y), occupied) + occupied.add((x, y)) + + def test_outcome_buckets_include_other_and_unknown(self): + """An HTTP 502/504 or unknown result must not disappear from the view.""" + expressions = "\n".join( + target["expr"] + for panel in self.dashboard["panels"] + for target in panel.get("targets", []) + ) + for name in ( + "k6_ha_outcome_201_total", + "k6_ha_outcome_500_total", + "k6_ha_outcome_503_total", + "k6_ha_outcome_other_failed_total", + "k6_ha_outcome_unknown_total", + ): + self.assertIn(name, expressions) + self.assertNotIn("histogram_quantile", expressions) + self.assertNotIn("p95", expressions) + + def test_http_class_cards_keep_received_statuses_separate_from_unknown(self): + """A selected run exposes four cumulative code classes alongside rates.""" + panels = {panel["id"]: panel for panel in self.dashboard["panels"]} + promql_fixture = ( + DASHBOARD.parent.parent / "tests" / "ha_dashboard_http_classes.yml" + ).read_text(encoding="utf-8") + for panel_id, name in zip((15, 16, 17, 18), ("2xx", "3xx", "4xx", "5xx")): + panel = panels[panel_id] + self.assertEqual("stat", panel["type"]) + self.assertTrue(panel["targets"][0]["instant"]) + self.assertIn(f"k6_ha_http_{name}_total", panel["targets"][0]["expr"]) + self.assertIn('run_id=~"$run_id"', panel["targets"][0]["expr"]) + self.assertIn("누적", panel["title"]) + self.assertEqual("none", panel["fieldConfig"]["defaults"]["unit"]) + # 1. A run with no 201 response still has a VU series to anchor zero counts. + self.assertIn( + 'or (max(last_over_time(k6_vus{run_id=~"$run_id"}[10m])) * 0)', + panel["targets"][0]["expr"], + ) + self.assertIn(panel["targets"][0]["expr"].replace("$run_id", "r403"), promql_fixture) + self.assertEqual( + "gray", panel["fieldConfig"]["defaults"]["thresholds"]["steps"][0]["color"] + ) + self.assertIn("결과 불명", self.dashboard["description"]) + self.assertLess(panels[15]["gridPos"]["y"], panels[7]["gridPos"]["y"]) + + def test_run_filter_and_interpretation(self): + """A recorded run is selectable, and RPO remains an external-DB claim.""" + variables = self.dashboard["templating"]["list"] + self.assertEqual(["run_id"], [variable["name"] for variable in variables]) + self.assertIn("run_id=~", json.dumps(self.dashboard["panels"])) + self.assertIn("요청 ID 원장과 DB 대조", self.dashboard["description"]) + # 2. The run selector cannot depend on receiving HTTP 201. + self.assertEqual("label_values(k6_vus, run_id)", variables[0]["definition"]) + self.assertEqual(variables[0]["definition"], variables[0]["query"]["query"]) + self.assertEqual(2, variables[0]["refresh"]) + + def test_wal_panel_excludes_primary(self): + """Only replicas have a meaningful receive-versus-replay backlog.""" + panel = next(panel for panel in self.dashboard["panels"] if panel["id"] == 5) + expression = panel["targets"][0]["expr"] + self.assertIn("patroni_xlog_received_location", expression) + self.assertIn("and on(instance)", expression) + self.assertIn("patroni_replica", expression) + + def test_state_timelines_distinguish_scrape_and_postgres_failure(self): + """A failed scrape must not be described as a proven VM outage.""" + panels = {panel["id"]: panel for panel in self.dashboard["panels"]} + app = panels[2] + database = panels[3] + self.assertEqual("state-timeline", app["type"]) + self.assertEqual("state-timeline", database["type"]) + self.assertEqual('up{job="docgrid-app",node="app-A"}', app["targets"][0]["expr"]) + self.assertEqual('up{job="docgrid-app",node="app-B"}', app["targets"][1]["expr"]) + self.assertIn("patroni_postgres_running", database["targets"][0]["expr"]) + self.assertIn('up{job="patroni"}', database["targets"][0]["expr"]) + self.assertEqual( + "수집 실패", + database["fieldConfig"]["defaults"]["mappings"][0]["options"]["-1"]["text"], + ) + self.assertIn("단정하지 않는다", database["description"]) + self.assertEqual("orange", database["fieldConfig"]["defaults"]["mappings"][0]["options"]["2"]["color"]) + + def test_primary_cards_only_use_current_primary(self): + """A past or replica timeline must not impersonate the current leader.""" + panels = {panel["id"]: panel for panel in self.dashboard["panels"]} + for panel_id in (9, 10): + self.assertEqual("stat", panels[panel_id]["type"]) + self.assertTrue(panels[panel_id]["targets"][0]["instant"]) + self.assertIn('patroni_primary{job="patroni"} == 1', panels[panel_id]["targets"][0]["expr"]) + self.assertIn("and on(instance)", panels[10]["targets"][0]["expr"]) + self.assertNotIn("max(", panels[10]["targets"][0]["expr"]) + + def test_compact_status_layout_and_proxy_probe_scope(self): + """Top status panels stay compact and label TCP reachability honestly.""" + panels = {panel["id"]: panel for panel in self.dashboard["panels"]} + for panel_id in (9, 10, 13): + self.assertLessEqual(panels[panel_id]["gridPos"]["h"], 3) + self.assertEqual(8, panels[panel_id]["gridPos"]["w"]) + for panel_id in (2, 3, 14): + self.assertEqual(6, panels[panel_id]["gridPos"]["h"]) + self.assertEqual(8, panels[panel_id]["gridPos"]["w"]) + self.assertEqual("state-timeline", panels[14]["type"]) + self.assertIn('probe_success{job="openproxy-tcp",node="proxy-A"}', panels[14]["targets"][0]["expr"]) + self.assertIn("db-node2", panels[14]["targets"][0]["expr"]) + self.assertIn("db-node3", panels[14]["targets"][0]["expr"]) + self.assertIn("SQL", panels[14]["description"]) + self.assertIn("관측 VM", panels[14]["title"]) + self.assertEqual('sum(probe_success{job="openproxy-tcp"})', panels[13]["targets"][0]["expr"]) + self.assertEqual("2 / 2", panels[13]["fieldConfig"]["defaults"]["mappings"][0]["options"]["2"]["text"]) + self.assertEqual("red", panels[13]["fieldConfig"]["defaults"]["mappings"][0]["options"]["1"]["color"]) + + def test_app_error_and_pool_timeout_cannot_replace_k6_result(self): + """App-local failures remain separate from client-observed outcomes.""" + panels = {panel["id"]: panel for panel in self.dashboard["panels"]} + self.assertIn("http_server_requests_seconds_count", panels[11]["targets"][0]["expr"]) + self.assertIn("LB", panels[11]["description"]) + self.assertIn("hikaricp_connections_timeout_total", panels[12]["targets"][0]["expr"]) + self.assertIn("[15s]", panels[12]["targets"][0]["expr"]) + self.assertIn("axisSoftMax", panels[12]["fieldConfig"]["defaults"]["custom"]) + self.assertEqual("never", panels[7]["fieldConfig"]["defaults"]["custom"]["showPoints"]) + self.assertIn("k6_ha_outcome_500_total", panels[7]["targets"][1]["expr"]) + + def test_annotation_source_is_explicit_fault_event(self): + """Show recorded fault events rather than a marker for every failed scrape.""" + annotations = self.dashboard["annotations"]["list"] + self.assertEqual(1, len(annotations)) + self.assertEqual("-- Grafana --", annotations[0]["datasource"]["uid"]) + self.assertTrue(annotations[0]["enable"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/monitoring/verify.sh b/monitoring/verify.sh index 5c092470..6676d4db 100755 --- a/monitoring/verify.sh +++ b/monitoring/verify.sh @@ -61,6 +61,7 @@ done # 4. Grafana Dashboard 불변식과 격리 E2E Compose를 검증한다. python3 "$ROOT_DIR/monitoring/grafana/validate_dashboard.py" \ "$ROOT_DIR/monitoring/grafana/dashboards/docgrid-operations.json" +python3 "$ROOT_DIR/monitoring/grafana/tests/test_ha_dashboard.py" sh -n "$ROOT_DIR/monitoring/grafana/tests/run-e2e.sh" GRAFANA_TEST_PORT=13000 docker compose \ -f "$ROOT_DIR/monitoring/grafana/tests/docker-compose.yml" config --quiet diff --git a/scripts/opensql/ha_evidence.py b/scripts/opensql/ha_evidence.py index 57e93c76..fd1564b0 100644 --- a/scripts/opensql/ha_evidence.py +++ b/scripts/opensql/ha_evidence.py @@ -149,8 +149,9 @@ def replay(manifest, events): status = event.get("http_status") if kind == "acknowledged" and (not isinstance(status, int) or not 200 <= status < 300): raise EvidenceError("성공 응답에는 2xx HTTP 상태가 필요합니다") - if kind == "failed" and (not isinstance(status, int) or not 300 <= status < 600): - raise EvidenceError("실패 응답에는 3xx~5xx HTTP 상태가 필요합니다") + if kind == "failed" and (not isinstance(status, int) or + not 200 <= status < 600 or status == 201): + raise EvidenceError("실패 응답에는 201 이외 2xx~5xx HTTP 상태가 필요합니다") if kind == "unknown" and (status is not None or event.get("reason") not in {"timeout", "connection_lost", "client_stopped", "other"}): raise EvidenceError("결과 불명에는 허용된 사유만 기록합니다") diff --git a/scripts/opensql/ha_probe_load.js b/scripts/opensql/ha_probe_load.js index ad797f19..1df04740 100644 --- a/scripts/opensql/ha_probe_load.js +++ b/scripts/opensql/ha_probe_load.js @@ -16,6 +16,11 @@ const outcome500 = new Counter('ha_outcome_500'); const outcome503 = new Counter('ha_outcome_503'); const outcomeOtherFailed = new Counter('ha_outcome_other_failed'); const outcomeUnknown = new Counter('ha_outcome_unknown'); +// Count received HTTP responses separately from unknown transport outcomes. +const http2xx = new Counter('ha_http_2xx'); +const http3xx = new Counter('ha_http_3xx'); +const http4xx = new Counter('ha_http_4xx'); +const http5xx = new Counter('ha_http_5xx'); if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,59}$/.test(runId || '') || !target || !token || !__ENV.HA_SUMMARY_FILE) { throw new Error('A short HA_RUN_ID, HA_TARGET_URL, HA_JWT and HA_SUMMARY_FILE are required'); @@ -66,11 +71,16 @@ export default function () { event(requestId, 'unknown', { reason: 'other' }); return; } + // 2. Class counters stay tag-free; run_id is added only by Remote Write. + if (response.status >= 200 && response.status < 300) http2xx.add(1); + else if (response.status >= 300 && response.status < 400) http3xx.add(1); + else if (response.status >= 400 && response.status < 500) http4xx.add(1); + else if (response.status >= 500 && response.status < 600) http5xx.add(1); if (response.status === 201) { outcome201.add(1); event(requestId, 'acknowledged', { http_status: 201 }); - } else if (response.status >= 300 && response.status < 600) { - // 2. Keep unexpected HTTP failures visible alongside the main 500/503 buckets. + } else if (response.status >= 200 && response.status < 600) { + // 3. Keep every unexpected HTTP response visible alongside 500/503. if (response.status === 500) outcome500.add(1); else if (response.status === 503) outcome503.add(1); else outcomeOtherFailed.add(1); diff --git a/scripts/opensql/sanitize_ha_k6_events.py b/scripts/opensql/sanitize_ha_k6_events.py index a17a37c5..541b7f72 100644 --- a/scripts/opensql/sanitize_ha_k6_events.py +++ b/scripts/opensql/sanitize_ha_k6_events.py @@ -44,7 +44,8 @@ def safe_event(value: object, run_id: str) -> dict: kind == "acknowledged" and value["http_status"] != 201: raise ValueError("unexpected success status") if kind == "failed" and (type(value["http_status"]) is not int or - not 300 <= value["http_status"] < 600): + not 200 <= value["http_status"] < 600 or + value["http_status"] == 201): raise ValueError("unexpected failure status") if kind == "unknown" and value["reason"] not in {"timeout", "connection_lost", "client_stopped", "other"}: raise ValueError("unexpected unknown reason") diff --git a/scripts/opensql/test_ha_evidence.py b/scripts/opensql/test_ha_evidence.py index d07df204..62aa11ba 100644 --- a/scripts/opensql/test_ha_evidence.py +++ b/scripts/opensql/test_ha_evidence.py @@ -77,6 +77,15 @@ def test_duplicate_and_invalid_terminals_are_not_appended(self): with self.assertRaisesRegex(EVIDENCE.EvidenceError, "미완료"): EVIDENCE.append_event(self.directory, "failed", request_id="one", http_status=500) + def test_unexpected_2xx_is_recorded_as_failed_not_unknown(self): + """A received HTTP 200 still has a code class but violates the 201 contract.""" + EVIDENCE.append_event(self.directory, "sent", request_id="unexpected", operation="write") + with self.assertRaisesRegex(EVIDENCE.EvidenceError, "201 이외"): + EVIDENCE.append_event(self.directory, "failed", request_id="unexpected", http_status=201) + EVIDENCE.append_event(self.directory, "failed", request_id="unexpected", http_status=200) + summary = EVIDENCE.export(self.directory) + self.assertEqual(1, summary["outcome_counts"]["FAILED"]) + def test_tampered_raw_or_derived_evidence_fails_verification(self): """The report is not trusted when a raw line or derived count is edited.""" EVIDENCE.append_event(self.directory, "sent", request_id="one", operation="write") diff --git a/scripts/opensql/test_sanitize_ha_k6_events.py b/scripts/opensql/test_sanitize_ha_k6_events.py index 972995aa..46152f21 100644 --- a/scripts/opensql/test_sanitize_ha_k6_events.py +++ b/scripts/opensql/test_sanitize_ha_k6_events.py @@ -57,6 +57,27 @@ def test_wrong_status_rejects_export(self): SANITIZER.sanitize(self.source, self.output, "ha391b100") self.assertFalse(self.output.exists()) + def test_unexpected_http_200_is_safe_failed_evidence(self): + """An HTTP response can be 2xx while violating the probe's 201 contract.""" + failed = { + "at": "2026-10-02T11:40:48.234Z", "event_id": "ha391b100-v1-i0-failed", + "kind": "failed", "http_status": 200, + "request_id": "ha391b100-v1-i0", "run_id": "ha391b100", + } + self.write(self.sent, failed) + self.assertEqual(2, SANITIZER.sanitize(self.source, self.output, "ha391b100")) + + def test_failed_201_is_rejected(self): + """The only accepted 201 classification is acknowledged.""" + failed = { + "at": "2026-10-02T11:40:48.234Z", "event_id": "ha391b100-v1-i0-failed", + "kind": "failed", "http_status": 201, + "request_id": "ha391b100-v1-i0", "run_id": "ha391b100", + } + self.write(self.sent, failed) + with self.assertRaises(ValueError): + SANITIZER.sanitize(self.source, self.output, "ha391b100") + if __name__ == "__main__": unittest.main() diff --git a/scripts/opensql/test_verify_ha_prometheus_rw.py b/scripts/opensql/test_verify_ha_prometheus_rw.py index c483b3d4..73b1f47f 100644 --- a/scripts/opensql/test_verify_ha_prometheus_rw.py +++ b/scripts/opensql/test_verify_ha_prometheus_rw.py @@ -50,6 +50,21 @@ def test_counts_all_terminal_buckets_without_collapsing_other_failures(self): self.write_event(request_id, kind, **fields) self.assertEqual({name: 1 for name in remote_write.OUTCOMES}, remote_write.expected_outcomes(self.events, self.run_id)) + counts = remote_write.expected_counts(self.events, self.run_id) + self.assertEqual( + {"http_2xx": 1, "http_3xx": 0, "http_4xx": 0, "http_5xx": 3}, + {name: counts[name] for name in ("http_2xx", "http_3xx", "http_4xx", "http_5xx")}, + ) + + def test_http_class_counts_include_redirect_and_client_error(self): + """Every received status class is visible without exposing status tags.""" + for index, status in enumerate((200, 302, 401, 504)): + request_id = f"{self.run_id}-v1-i{index}" + self.write_event(request_id, "sent", operation="ha_probe_write") + self.write_event(request_id, "failed", http_status=status) + counts = remote_write.expected_counts(self.events, self.run_id) + self.assertEqual([1, 1, 1, 1], [counts[f"http_{name}"] for name in remote_write.HTTP_CLASSES]) + self.assertEqual(4, counts["other_failed"]) def test_rejects_missing_or_duplicate_terminal_events(self): """An incomplete client ledger cannot validate remote-write delivery.""" @@ -97,6 +112,9 @@ def open(self, request, timeout): self.assertEqual(3, count) self.assertIn("k6_ha_outcome_201_total", opener.url) self.assertIn("last_over_time", opener.url) + with patch.object(remote_write, "build_opener", return_value=opener): + remote_write.remote_count("http://10.1.2.3:9090/api/v1/query", self.run_id, "http_5xx") + self.assertIn("k6_ha_http_5xx_total", opener.url) def test_verdict_explicitly_excludes_live_gaps_and_database_effects(self): """Matching totals do not claim a continuous graph or RPO 0.""" @@ -104,7 +122,7 @@ def test_verdict_explicitly_excludes_live_gaps_and_database_effects(self): self.write_event(request_id, "sent", operation="ha_probe_write") self.write_event(request_id, "acknowledged", http_status=201) with patch.object(remote_write, "remote_count", side_effect=lambda _, __, name: - 1 if name == "201" else 0): + 1 if name in ("201", "http_2xx") else 0): result = remote_write.verify( self.events, self.run_id, "http://10.1.2.3:9090/api/v1/write") self.assertTrue(result["final_totals_match"]) diff --git a/scripts/opensql/verify_ha_prometheus_rw.py b/scripts/opensql/verify_ha_prometheus_rw.py index 77e80bc6..395d2910 100644 --- a/scripts/opensql/verify_ha_prometheus_rw.py +++ b/scripts/opensql/verify_ha_prometheus_rw.py @@ -22,6 +22,7 @@ OUTCOMES = ("201", "500", "503", "other_failed", "unknown") +HTTP_CLASSES = ("2xx", "3xx", "4xx", "5xx") PRIVATE_NETWORKS = tuple(ipaddress.ip_network(cidr) for cidr in ( "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16" )) @@ -43,8 +44,8 @@ def query_base(write_url: str) -> str: return f"http://{address}:9090/api/v1/query" -def expected_outcomes(events_path: Path, run_id: str) -> dict[str, int]: - """Count exactly one terminal outcome per sent request in the safe ledger.""" +def expected_counts(events_path: Path, run_id: str) -> dict[str, int]: + """Count terminal outcomes and received HTTP classes in the safe ledger.""" if not RUN_ID.fullmatch(run_id): raise ValueError("실행 ID가 올바르지 않습니다") seen: dict[str, set[str]] = {} @@ -60,19 +61,30 @@ def expected_outcomes(events_path: Path, run_id: str) -> dict[str, int]: kinds.add(kind) if kind == "acknowledged": counts["201"] += 1 + counts["http_2xx"] += 1 elif kind == "failed": status = item["http_status"] counts[str(status) if status in (500, 503) else "other_failed"] += 1 + counts[f"http_{status // 100}xx"] += 1 elif kind == "unknown": counts["unknown"] += 1 if not seen or any(len(kinds) != 2 or "sent" not in kinds for kinds in seen.values()): raise ValueError("요청 원장에 전송 또는 종료 이벤트가 빠졌습니다") + return {name: counts[name] for name in OUTCOMES} | { + f"http_{name}": counts[f"http_{name}"] for name in HTTP_CLASSES + } + + +def expected_outcomes(events_path: Path, run_id: str) -> dict[str, int]: + """Retain the outcome-only view for existing callers.""" + counts = expected_counts(events_path, run_id) return {name: counts[name] for name in OUTCOMES} def remote_count(query_url: str, run_id: str, outcome: str) -> int: """Read the last non-stale counter in a bounded window after final flush.""" - metric = f"k6_ha_outcome_{outcome}_total" + metric = (f"k6_ha_{outcome}_total" if outcome.startswith("http_") + else f"k6_ha_outcome_{outcome}_total") expression = f'last_over_time({metric}{{run_id="{run_id}"}}[10m])' url = f"{query_url}?{urlencode({'query': expression})}" # 1. Do not send a private endpoint through local proxy environment variables. @@ -95,10 +107,10 @@ def remote_count(query_url: str, run_id: str, outcome: str) -> int: def verify(events_path: Path, run_id: str, write_url: str) -> dict: """Compare dashboard totals with the ledger, not with final DB rows.""" query_url = query_base(write_url) - expected = expected_outcomes(events_path, run_id) + expected = expected_counts(events_path, run_id) # 2. Allow a bounded ingestion delay after k6's final remote-write flush. for attempt in range(1, 5): - observed = {name: remote_count(query_url, run_id, name) for name in OUTCOMES} + observed = {name: remote_count(query_url, run_id, name) for name in expected} if observed == expected or attempt == 4: break time.sleep(2)