From 3ec23f3d8c1572633f983b943386ec893d8bd696 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B8=B0=EB=AF=BC?= Date: Fri, 9 Oct 2026 05:48:30 +0900 Subject: [PATCH 1/6] test: add live OpenSQL HA Grafana dashboard --- monitoring/grafana/README.md | 13 ++ .../grafana/dashboards/docgrid-ha-demo.json | 116 ++++++++++++++++++ monitoring/grafana/tests/test_ha_dashboard.py | 82 +++++++++++++ 3 files changed, 211 insertions(+) create mode 100644 monitoring/grafana/dashboards/docgrid-ha-demo.json create mode 100644 monitoring/grafana/tests/test_ha_dashboard.py diff --git a/monitoring/grafana/README.md b/monitoring/grafana/README.md index 8f042160..f8dd2b1a 100644 --- a/monitoring/grafana/README.md +++ b/monitoring/grafana/README.md @@ -35,6 +35,19 @@ 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노드, Hikari 대기, standby WAL 수신·재생 차이, k6 결과별 응답/초와 활성 VU를 표시한다. +`run_id` 변수를 선택해 한 실행만 볼 수 있다. + +이 화면의 `up=1`은 지표 수집 성공일 뿐 HTTP 쓰기 성공이 아니다. OpenProxy 프로세스 준비 상태는 +현재 Prometheus에 직접 수집하지 않으므로 운영자 장애 로그와 HTTP 원장을 함께 본다. 그래프만으로 +성공 응답 데이터의 보존이나 RPO를 주장하지 않으며 요청 ID별 DB 대조 결과를 별도로 확인한다. +Remote Write의 누적 Trend p95/p99는 복구 시점 지연으로 오해하기 쉬워 패널에서 제외했다. +실행·장애 계측 결과는 [GCP HA 대시보드 검증 기록](../../docs/test-results/opensql-ha-live-dashboard-fault-rehearsal-20261009.md)에 둔다. + ## 화면 구성 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..5b4aa3ee --- /dev/null +++ b/monitoring/grafana/dashboards/docgrid-ha-demo.json @@ -0,0 +1,116 @@ +{ + "annotations": {"list": []}, + "description": "GCP OpenSQL HA 시연: k6 HTTP 결과와 앱·Patroni 상태를 같은 시간축에서 본다. OpenProxy 프로세스 상태는 별도 장애 로그에서 확인하고, 정합성은 요청 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": "timeseries", + "title": "앱 A/B·DB 3대 지표 수집", + "description": "Prometheus scrape 성공(1)·실패(0). up=1은 실제 HTTP 쓰기 성공을 뜻하지 않는다.", + "datasource": {"type": "prometheus", "uid": "ha-prometheus"}, + "gridPos": {"x": 0, "y": 1, "w": 12, "h": 7}, + "fieldConfig": {"defaults": {"min": 0, "max": 1, "unit": "short"}, "overrides": []}, + "targets": [{"refId": "A", "expr": "up{job=~\"docgrid-app|patroni\"}", "legendFormat": "{{job}} / {{node}}", "datasource": {"type": "prometheus", "uid": "ha-prometheus"}}] + }, + { + "id": 3, + "type": "timeseries", + "title": "Patroni primary 역할", + "description": "노드별 primary=1·replica=0. 역할 전환은 이 값과 timeline을 함께 확인한다.", + "datasource": {"type": "prometheus", "uid": "ha-prometheus"}, + "gridPos": {"x": 12, "y": 1, "w": 12, "h": 7}, + "fieldConfig": {"defaults": {"min": 0, "max": 1, "unit": "short"}, "overrides": []}, + "targets": [{"refId": "A", "expr": "patroni_primary{job=\"patroni\"}", "legendFormat": "{{node}}", "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": 8, "w": 12, "h": 7}, + "fieldConfig": {"defaults": {"min": 0, "unit": "short"}, "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": 8, "w": 12, "h": 7}, + "fieldConfig": {"defaults": {"min": 0, "unit": "bytes"}, "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": 6, + "type": "row", + "title": "k6 HTTP 결과", + "collapsed": false, + "gridPos": {"x": 0, "y": 15, "w": 24, "h": 1}, + "panels": [] + }, + { + "id": 7, + "type": "timeseries", + "title": "HTTP 결과별 응답/초", + "description": "응답이 끝난 시각의 k6 Counter를 30초 창으로 계산한다. timeout·결과 불명은 응답 지연만큼 장애 주입 시각보다 늦게 나타날 수 있다.", + "datasource": {"type": "prometheus", "uid": "ha-prometheus"}, + "gridPos": {"x": 0, "y": 16, "w": 16, "h": 8}, + "fieldConfig": {"defaults": {"min": 0, "unit": "reqps"}, "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": "기타 HTTP 실패", "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": 16, "w": 8, "h": 8}, + "fieldConfig": {"defaults": {"min": 0, "unit": "short"}, "overrides": []}, + "targets": [{"refId": "A", "expr": "k6_vus{run_id=~\"$run_id\"}", "legendFormat": "VU", "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_ha_outcome_201_total, run_id)", + "query": {"query": "label_values(k6_ha_outcome_201_total, run_id)", "refId": "PrometheusVariableQueryEditor-VariableQuery"}, + "includeAll": true, + "allValue": ".*", + "current": {"selected": true, "text": "All", "value": "$__all"}, + "options": [], + "refresh": 1, + "sort": 1 + }] + }, + "time": {"from": "now-15m", "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/test_ha_dashboard.py b/monitoring/grafana/tests/test_ha_dashboard.py new file mode 100644 index 00000000..7cd70441 --- /dev/null +++ b/monitoring/grafana/tests/test_ha_dashboard.py @@ -0,0 +1,82 @@ +"""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_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"]) + + 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) + + +if __name__ == "__main__": + unittest.main() From daececb30a278f20d46e12eb14da5c8bb5f343a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B8=B0=EB=AF=BC?= Date: Fri, 9 Oct 2026 05:48:40 +0900 Subject: [PATCH 2/6] docs: record live HA baseline and fault rehearsal --- .../issue-448/dashboard-and-preflight.md | 23 +++++ .../evidence/issue-448/ha448base01.md | 14 +++ .../evidence/issue-448/ha448proc01.md | 18 ++++ .../evidence/issue-448/ha448proxy01.md | 16 ++++ .../evidence/issue-448/ha448smoke01.md | 14 +++ ...live-dashboard-fault-rehearsal-20261009.md | 89 +++++++++++++++++++ 6 files changed, 174 insertions(+) create mode 100644 docs/test-results/evidence/issue-448/dashboard-and-preflight.md create mode 100644 docs/test-results/evidence/issue-448/ha448base01.md create mode 100644 docs/test-results/evidence/issue-448/ha448proc01.md create mode 100644 docs/test-results/evidence/issue-448/ha448proxy01.md create mode 100644 docs/test-results/evidence/issue-448/ha448smoke01.md create mode 100644 docs/test-results/opensql-ha-live-dashboard-fault-rehearsal-20261009.md 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/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/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/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..ecdf2ada --- /dev/null +++ b/docs/test-results/opensql-ha-live-dashboard-fault-rehearsal-20261009.md @@ -0,0 +1,89 @@ +# 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를 구간 복구 지연인 것처럼 표시하지 않도록 해당 패널을 제외했다. + +## 실행 결과 + +| 실행 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) 및 각 실행 문서를 본다. From c79491408d84649f8f488bfbb3bc604027a969ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B8=B0=EB=AF=BC?= Date: Fri, 9 Oct 2026 05:50:27 +0900 Subject: [PATCH 3/6] test: run HA dashboard checks in monitoring validation --- monitoring/verify.sh | 1 + 1 file changed, 1 insertion(+) 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 From a815da8b5cb7843129a7108b62e4b1903be56095 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B8=B0=EB=AF=BC?= Date: Fri, 9 Oct 2026 15:45:17 +0900 Subject: [PATCH 4/6] test: extend OpenSQL HA dashboard with status counters --- .../hadash1009b01/db-counts.csv.gz | Bin 0 -> 4454 bytes .../hadash1009b01/safe-events.jsonl.gz | Bin 0 -> 29533 bytes ...352\263\204\354\270\241-1\354\264\210.csv" | 62 +++++++ .../run-01-local-check.md | 13 ++ .../run-02-live-apply.md | 18 ++ .../run-03-openproxy-compact.md | 32 ++++ .../run-04-status-code-local.md | 24 +++ .../run-05-expired-fixture-smoke.md | 20 +++ .../run-06-guarded-fixture-smoke.md | 21 +++ .../run-07-http-baseline.md | 35 ++++ .../run-08-app-b-status-rehearsal.md | 23 +++ .../run-09-live-dashboard-apply.md | 22 +++ .../run-10-pr-review.md | 21 +++ ...live-dashboard-fault-rehearsal-20261009.md | 2 + monitoring/blackbox/blackbox.yml | 4 + .../blackbox/prometheus-scrape.example.yml | 15 ++ monitoring/grafana/README.md | 22 ++- .../grafana/dashboards/docgrid-ha-demo.json | 167 +++++++++++++++--- monitoring/grafana/tests/test_ha_dashboard.py | 80 +++++++++ scripts/opensql/ha_evidence.py | 5 +- scripts/opensql/ha_probe_load.js | 14 +- scripts/opensql/sanitize_ha_k6_events.py | 3 +- scripts/opensql/test_ha_evidence.py | 9 + scripts/opensql/test_sanitize_ha_k6_events.py | 21 +++ .../opensql/test_verify_ha_prometheus_rw.py | 20 ++- scripts/opensql/verify_ha_prometheus_rw.py | 22 ++- 26 files changed, 632 insertions(+), 43 deletions(-) create mode 100644 docs/test-results/evidence/ha-dashboard-preview-20261009/hadash1009b01/db-counts.csv.gz create mode 100644 docs/test-results/evidence/ha-dashboard-preview-20261009/hadash1009b01/safe-events.jsonl.gz create mode 100644 "docs/test-results/evidence/ha-dashboard-preview-20261009/hadash1009b01/\352\263\204\354\270\241-1\354\264\210.csv" create mode 100644 docs/test-results/evidence/ha-dashboard-preview-20261009/run-01-local-check.md create mode 100644 docs/test-results/evidence/ha-dashboard-preview-20261009/run-02-live-apply.md create mode 100644 docs/test-results/evidence/ha-dashboard-preview-20261009/run-03-openproxy-compact.md create mode 100644 docs/test-results/evidence/ha-dashboard-preview-20261009/run-04-status-code-local.md create mode 100644 docs/test-results/evidence/ha-dashboard-preview-20261009/run-05-expired-fixture-smoke.md create mode 100644 docs/test-results/evidence/ha-dashboard-preview-20261009/run-06-guarded-fixture-smoke.md create mode 100644 docs/test-results/evidence/ha-dashboard-preview-20261009/run-07-http-baseline.md create mode 100644 docs/test-results/evidence/ha-dashboard-preview-20261009/run-08-app-b-status-rehearsal.md create mode 100644 docs/test-results/evidence/ha-dashboard-preview-20261009/run-09-live-dashboard-apply.md create mode 100644 docs/test-results/evidence/ha-dashboard-preview-20261009/run-10-pr-review.md create mode 100644 monitoring/blackbox/blackbox.yml create mode 100644 monitoring/blackbox/prometheus-scrape.example.yml 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 0000000000000000000000000000000000000000..97c7197edaafd9ee07c4af7128296535910526b4 GIT binary patch literal 4454 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 S^^%ewOd) <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/opensql-ha-live-dashboard-fault-rehearsal-20261009.md b/docs/test-results/opensql-ha-live-dashboard-fault-rehearsal-20261009.md index ecdf2ada..aa993c46 100644 --- a/docs/test-results/opensql-ha-live-dashboard-fault-rehearsal-20261009.md +++ b/docs/test-results/opensql-ha-live-dashboard-fault-rehearsal-20261009.md @@ -28,6 +28,8 @@ GCP 부하 VM: k6 ──HTTP request_id──▶ 내부 LB ──▶ 앱 A/B 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 판정 | 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 f8dd2b1a..6d0b585b 100644 --- a/monitoring/grafana/README.md +++ b/monitoring/grafana/README.md @@ -39,15 +39,27 @@ Git에서 다음 파일을 수정한다. `dashboards/docgrid-ha-demo.json`은 별도 GCP 관측 VM에서 사용하는 수동 가져오기용 Dashboard다. 번들 Data Source가 아니라 인증된 관측 VM의 Prometheus UID `ha-prometheus`를 요구한다. 앱 A/B, -Patroni 3노드, Hikari 대기, standby WAL 수신·재생 차이, k6 결과별 응답/초와 활성 VU를 표시한다. -`run_id` 변수를 선택해 한 실행만 볼 수 있다. - -이 화면의 `up=1`은 지표 수집 성공일 뿐 HTTP 쓰기 성공이 아니다. OpenProxy 프로세스 준비 상태는 -현재 Prometheus에 직접 수집하지 않으므로 운영자 장애 로그와 HTTP 원장을 함께 본다. 그래프만으로 +Patroni 3노드의 수집·역할 상태와 관측 VM에서 본 OpenProxy A/B TCP 포트 연결을 타임라인으로 표시하고, +현재 primary·timeline·연결 가능한 프록시 포트 수, 실행 ID별 HTTP 2xx·3xx·4xx·5xx 누적 응답 건수, 앱별 5xx, +Hikari 대기·연결 timeout, standby WAL 수신·재생 차이, k6 결과별 응답/초와 활성 VU를 보여준다. +`run_id` 변수를 선택해 한 실행만 볼 수 있다. `All`이면 최근 10분 이내 여러 실행의 건수가 +합산될 수 있으므로 영상에서는 특정 실행 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)에 둔다. +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 index 5b4aa3ee..3db8e1ea 100644 --- a/monitoring/grafana/dashboards/docgrid-ha-demo.json +++ b/monitoring/grafana/dashboards/docgrid-ha-demo.json @@ -1,6 +1,6 @@ { - "annotations": {"list": []}, - "description": "GCP OpenSQL HA 시연: k6 HTTP 결과와 앱·Patroni 상태를 같은 시간축에서 본다. OpenProxy 프로세스 상태는 별도 장애 로그에서 확인하고, 정합성은 요청 ID 원장과 DB 대조로 판정한다.", + "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, @@ -15,23 +15,72 @@ }, { "id": 2, - "type": "timeseries", - "title": "앱 A/B·DB 3대 지표 수집", - "description": "Prometheus scrape 성공(1)·실패(0). up=1은 실제 HTTP 쓰기 성공을 뜻하지 않는다.", + "type": "state-timeline", + "title": "앱 A/B 지표 수집 상태", + "description": "Prometheus scrape 성공(1)·실패(0). 실패는 앱 VM 중단의 단독 증거가 아니며, 성공도 실제 HTTP 쓰기 성공을 뜻하지 않는다.", "datasource": {"type": "prometheus", "uid": "ha-prometheus"}, - "gridPos": {"x": 0, "y": 1, "w": 12, "h": 7}, - "fieldConfig": {"defaults": {"min": 0, "max": 1, "unit": "short"}, "overrides": []}, - "targets": [{"refId": "A", "expr": "up{job=~\"docgrid-app|patroni\"}", "legendFormat": "{{job}} / {{node}}", "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": "timeseries", - "title": "Patroni primary 역할", - "description": "노드별 primary=1·replica=0. 역할 전환은 이 값과 timeline을 함께 확인한다.", + "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": 12, "y": 1, "w": 12, "h": 7}, - "fieldConfig": {"defaults": {"min": 0, "max": 1, "unit": "short"}, "overrides": []}, - "targets": [{"refId": "A", "expr": "patroni_primary{job=\"patroni\"}", "legendFormat": "{{node}}", "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, @@ -39,8 +88,8 @@ "title": "앱 Hikari 연결 대기", "description": "앱별 Hikari pending 연결 요청 수. DB 전환 중 풀 대기가 쌓이는지 관측한다.", "datasource": {"type": "prometheus", "uid": "ha-prometheus"}, - "gridPos": {"x": 0, "y": 8, "w": 12, "h": 7}, - "fieldConfig": {"defaults": {"min": 0, "unit": "short"}, "overrides": []}, + "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"}}] }, { @@ -49,31 +98,51 @@ "title": "standby 수신 후 WAL 재생 대기", "description": "각 standby가 이미 받은 WAL과 재생한 WAL의 차이(Byte). primary와 standby 사이 전체 복제 지연이나 RPO는 이 값만으로 판정할 수 없다.", "datasource": {"type": "prometheus", "uid": "ha-prometheus"}, - "gridPos": {"x": 12, "y": 8, "w": 12, "h": 7}, - "fieldConfig": {"defaults": {"min": 0, "unit": "bytes"}, "overrides": []}, + "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": 15, "w": 24, "h": 1}, + "gridPos": {"x": 0, "y": 10, "w": 24, "h": 1}, "panels": [] }, { "id": 7, "type": "timeseries", "title": "HTTP 결과별 응답/초", - "description": "응답이 끝난 시각의 k6 Counter를 30초 창으로 계산한다. timeout·결과 불명은 응답 지연만큼 장애 주입 시각보다 늦게 나타날 수 있다.", + "description": "응답이 끝난 시각의 k6 Counter를 30초 창으로 계산한다. 기타 예상 외 응답에는 201 이외의 2xx도 포함한다. timeout·결과 불명은 응답 지연만큼 장애 주입 시각보다 늦게 나타날 수 있다.", "datasource": {"type": "prometheus", "uid": "ha-prometheus"}, - "gridPos": {"x": 0, "y": 16, "w": 16, "h": 8}, - "fieldConfig": {"defaults": {"min": 0, "unit": "reqps"}, "overrides": []}, + "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": "기타 HTTP 실패", "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"}} ] }, @@ -83,9 +152,53 @@ "title": "활성 k6 VU", "description": "활성 가상 사용자 수. constant-arrival-rate에서 응답 대기 증가의 보조 지표이며, 전송하지 못한 iterations는 별도 k6 원장으로 판정한다.", "datasource": {"type": "prometheus", "uid": "ha-prometheus"}, - "gridPos": {"x": 16, "y": 16, "w": 8, "h": 8}, - "fieldConfig": {"defaults": {"min": 0, "unit": "short"}, "overrides": []}, + "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 응답을 받은 누적 건수. 결과 불명은 포함하지 않는다. 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": "green", "value": null}]}}, "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]))", "instant": true, "legendFormat": "2xx", "datasource": {"type": "prometheus", "uid": "ha-prometheus"}}] + }, + { + "id": 16, + "type": "stat", + "title": "3xx 누적 응답", + "description": "선택한 실행 ID에서 HTTP 3xx 응답을 받은 누적 건수. 지표가 아직 없는 경우, 2xx가 수집된 실행에 한해서 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": "green", "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 (sum(last_over_time(k6_ha_http_2xx_total{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": "green", "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 (sum(last_over_time(k6_ha_http_2xx_total{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": "green", "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 (sum(last_over_time(k6_ha_http_2xx_total{run_id=~\"$run_id\"}[10m])) * 0)", "instant": true, "legendFormat": "5xx", "datasource": {"type": "prometheus", "uid": "ha-prometheus"}}] } ], "refresh": "5s", @@ -103,11 +216,11 @@ "allValue": ".*", "current": {"selected": true, "text": "All", "value": "$__all"}, "options": [], - "refresh": 1, + "refresh": 2, "sort": 1 }] }, - "time": {"from": "now-15m", "to": "now"}, + "time": {"from": "now-5m", "to": "now"}, "timepicker": {"refresh_intervals": ["5s", "10s", "30s", "1m"]}, "timezone": "browser", "title": "DocGrid OpenSQL HA Live", diff --git a/monitoring/grafana/tests/test_ha_dashboard.py b/monitoring/grafana/tests/test_ha_dashboard.py index 7cd70441..da1d1427 100644 --- a/monitoring/grafana/tests/test_ha_dashboard.py +++ b/monitoring/grafana/tests/test_ha_dashboard.py @@ -62,12 +62,27 @@ def test_outcome_buckets_include_other_and_unknown(self): 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"]} + 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"]) + 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"]) + self.assertEqual(2, variables[0]["refresh"]) def test_wal_panel_excludes_primary(self): """Only replicas have a meaningful receive-versus-replay backlog.""" @@ -77,6 +92,71 @@ def test_wal_panel_excludes_primary(self): 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/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) From 765a87bb33e90b87ebfa4f04dbfe49003addf595 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B8=B0=EB=AF=BC?= Date: Fri, 9 Oct 2026 16:20:45 +0900 Subject: [PATCH 5/6] test: show zero-success HA runs in Grafana --- .../ha448-dashboard-regression-summary.md | 11 ++++ .../evidence/issue-448/ha448dashlive01.md | 11 ++++ .../evidence/issue-448/ha448dashpromql01.md | 9 ++++ .../evidence/issue-448/ha448dashpromql02.md | 9 ++++ .../evidence/issue-448/ha448dashunit01.md | 9 ++++ .../evidence/issue-448/ha448dashunit02.md | 9 ++++ monitoring/grafana/README.md | 14 ++++- .../grafana/dashboards/docgrid-ha-demo.json | 24 ++++----- .../tests/ha_dashboard_http_classes.yml | 53 +++++++++++++++++++ monitoring/grafana/tests/test_ha_dashboard.py | 15 ++++++ 10 files changed, 150 insertions(+), 14 deletions(-) create mode 100644 docs/test-results/evidence/issue-448/ha448-dashboard-regression-summary.md create mode 100644 docs/test-results/evidence/issue-448/ha448dashlive01.md create mode 100644 docs/test-results/evidence/issue-448/ha448dashpromql01.md create mode 100644 docs/test-results/evidence/issue-448/ha448dashpromql02.md create mode 100644 docs/test-results/evidence/issue-448/ha448dashunit01.md create mode 100644 docs/test-results/evidence/issue-448/ha448dashunit02.md create mode 100644 monitoring/grafana/tests/ha_dashboard_http_classes.yml 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/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/monitoring/grafana/README.md b/monitoring/grafana/README.md index 6d0b585b..ae998643 100644 --- a/monitoring/grafana/README.md +++ b/monitoring/grafana/README.md @@ -42,8 +42,10 @@ Git에서 다음 파일을 수정한다. Patroni 3노드의 수집·역할 상태와 관측 VM에서 본 OpenProxy A/B TCP 포트 연결을 타임라인으로 표시하고, 현재 primary·timeline·연결 가능한 프록시 포트 수, 실행 ID별 HTTP 2xx·3xx·4xx·5xx 누적 응답 건수, 앱별 5xx, Hikari 대기·연결 timeout, standby WAL 수신·재생 차이, k6 결과별 응답/초와 활성 VU를 보여준다. -`run_id` 변수를 선택해 한 실행만 볼 수 있다. `All`이면 최근 10분 이내 여러 실행의 건수가 -합산될 수 있으므로 영상에서는 특정 실행 ID를 선택한다. 코드 대역 카드에는 응답이 오지 않은 +`run_id` 변수는 k6 VU 지표에서 읽어 201 응답이 0건인 실행도 선택할 수 있게 한다. 각 코드 대역 카드에 +해당 응답이 없더라도 선택한 실행의 VU 지표가 수집됐다면 0건으로 표시한다. `All`이면 최근 10분 이내 +여러 실행의 건수가 합산될 수 있으므로 영상에서는 특정 실행 ID를 선택한다. 새 실행 ID가 자동 새로고침만으로 +목록에 나타나는지는 검증되지 않았으므로 촬영 전 목록을 다시 열어 확인한다. 코드 대역 카드에는 응답이 오지 않은 결과 불명 요청이 포함되지 않으며, 이 건수는 아래 결과별 응답/초 그래프와 안전 원장에서 별도로 확인한다. 이 화면의 `up=1`은 지표 수집 성공일 뿐 HTTP 쓰기 성공이 아니며, `up=0`만으로 VM 중단을 @@ -55,6 +57,14 @@ Hikari 대기·연결 timeout, standby WAL 수신·재생 차이, k6 결과별 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와 같은 diff --git a/monitoring/grafana/dashboards/docgrid-ha-demo.json b/monitoring/grafana/dashboards/docgrid-ha-demo.json index 3db8e1ea..5809379a 100644 --- a/monitoring/grafana/dashboards/docgrid-ha-demo.json +++ b/monitoring/grafana/dashboards/docgrid-ha-demo.json @@ -160,23 +160,23 @@ "id": 15, "type": "stat", "title": "2xx 누적 응답", - "description": "선택한 실행 ID에서 HTTP 2xx 응답을 받은 누적 건수. 결과 불명은 포함하지 않는다. All 선택 시 최근 10분에 기록된 모든 실행의 합계다.", + "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": "green", "value": null}]}}, "overrides": []}, + "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]))", "instant": true, "legendFormat": "2xx", "datasource": {"type": "prometheus", "uid": "ha-prometheus"}}] + "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 응답을 받은 누적 건수. 지표가 아직 없는 경우, 2xx가 수집된 실행에 한해서 0으로 표시한다.", + "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": "green", "value": null}, {"color": "yellow", "value": 1}]}}, "overrides": []}, + "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 (sum(last_over_time(k6_ha_http_2xx_total{run_id=~\"$run_id\"}[10m])) * 0)", "instant": true, "legendFormat": "3xx", "datasource": {"type": "prometheus", "uid": "ha-prometheus"}}] + "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, @@ -185,9 +185,9 @@ "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": "green", "value": null}, {"color": "yellow", "value": 1}]}}, "overrides": []}, + "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 (sum(last_over_time(k6_ha_http_2xx_total{run_id=~\"$run_id\"}[10m])) * 0)", "instant": true, "legendFormat": "4xx", "datasource": {"type": "prometheus", "uid": "ha-prometheus"}}] + "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, @@ -196,9 +196,9 @@ "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": "green", "value": null}, {"color": "red", "value": 1}]}}, "overrides": []}, + "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 (sum(last_over_time(k6_ha_http_2xx_total{run_id=~\"$run_id\"}[10m])) * 0)", "instant": true, "legendFormat": "5xx", "datasource": {"type": "prometheus", "uid": "ha-prometheus"}}] + "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", @@ -210,8 +210,8 @@ "label": "실행 ID", "type": "query", "datasource": {"type": "prometheus", "uid": "ha-prometheus"}, - "definition": "label_values(k6_ha_outcome_201_total, run_id)", - "query": {"query": "label_values(k6_ha_outcome_201_total, run_id)", "refId": "PrometheusVariableQueryEditor-VariableQuery"}, + "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"}, 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 index da1d1427..386f7db8 100644 --- a/monitoring/grafana/tests/test_ha_dashboard.py +++ b/monitoring/grafana/tests/test_ha_dashboard.py @@ -65,6 +65,9 @@ def test_outcome_buckets_include_other_and_unknown(self): 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"]) @@ -73,6 +76,15 @@ def test_http_class_cards_keep_received_statuses_separate_from_unknown(self): 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"]) @@ -82,6 +94,9 @@ def test_run_filter_and_interpretation(self): 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): From 8796ac1ebcb20ca3e8a85ead4b1b26f783c9fc7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B8=B0=EB=AF=BC?= Date: Fri, 9 Oct 2026 17:24:31 +0900 Subject: [PATCH 6/6] docs: record Grafana normal rehearsal and ID reconciliation --- ...4\262\255ID-\353\214\200\354\241\260.json" | 20 + .../issue-448/ha449run0001/config-safe.json | 11 + .../issue-448/ha449run0001/db-counts.csv.gz | Bin 0 -> 4306 bytes .../issue-448/ha449run0001/k6-summary.json | 12 + .../ha449run0001/safe-events.jsonl.gz | Bin 0 -> 28568 bytes ...352\263\204\354\270\241-1\354\264\210.csv" | 63 +++ ...354\270\241-\354\232\224\354\225\275.json" | 1 + ...\355\226\211-\352\270\260\353\241\235.txt" | 21 + ...4\355\226\211-\354\232\224\354\225\275.md" | 38 ++ ...355\221\234-\353\214\200\354\241\260.json" | 1 + ...4\262\255ID-\353\214\200\354\241\260.json" | 20 + .../issue-448/ha449smk0001/config-safe.json | 11 + .../issue-448/ha449smk0001/db-counts.csv.gz | Bin 0 -> 75 bytes .../issue-448/ha449smk0001/k6-summary.json | 12 + .../ha449smk0001/safe-events.jsonl.gz | Bin 0 -> 256 bytes ...352\263\204\354\270\241-1\354\264\210.csv" | 7 + ...354\270\241-\354\232\224\354\225\275.json" | 1 + ...\355\226\211-\352\270\260\353\241\235.txt" | 21 + ...4\355\226\211-\354\232\224\354\225\275.md" | 30 ++ ...355\221\234-\353\214\200\354\241\260.json" | 1 + ...ensql-grafana-normal-rehearsal-20261009.md | 372 ++++++++++++++++++ 21 files changed, 642 insertions(+) create mode 100644 "docs/test-results/evidence/issue-448/ha449run0001/DB-\354\232\224\354\262\255ID-\353\214\200\354\241\260.json" create mode 100644 docs/test-results/evidence/issue-448/ha449run0001/config-safe.json create mode 100644 docs/test-results/evidence/issue-448/ha449run0001/db-counts.csv.gz create mode 100644 docs/test-results/evidence/issue-448/ha449run0001/k6-summary.json create mode 100644 docs/test-results/evidence/issue-448/ha449run0001/safe-events.jsonl.gz create mode 100644 "docs/test-results/evidence/issue-448/ha449run0001/\352\263\204\354\270\241-1\354\264\210.csv" create mode 100644 "docs/test-results/evidence/issue-448/ha449run0001/\352\263\204\354\270\241-\354\232\224\354\225\275.json" create mode 100644 "docs/test-results/evidence/issue-448/ha449run0001/\354\213\244\355\226\211-\352\270\260\353\241\235.txt" create mode 100644 "docs/test-results/evidence/issue-448/ha449run0001/\354\213\244\355\226\211-\354\232\224\354\225\275.md" create mode 100644 "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" create mode 100644 "docs/test-results/evidence/issue-448/ha449smk0001/DB-\354\232\224\354\262\255ID-\353\214\200\354\241\260.json" create mode 100644 docs/test-results/evidence/issue-448/ha449smk0001/config-safe.json create mode 100644 docs/test-results/evidence/issue-448/ha449smk0001/db-counts.csv.gz create mode 100644 docs/test-results/evidence/issue-448/ha449smk0001/k6-summary.json create mode 100644 docs/test-results/evidence/issue-448/ha449smk0001/safe-events.jsonl.gz create mode 100644 "docs/test-results/evidence/issue-448/ha449smk0001/\352\263\204\354\270\241-1\354\264\210.csv" create mode 100644 "docs/test-results/evidence/issue-448/ha449smk0001/\352\263\204\354\270\241-\354\232\224\354\225\275.json" create mode 100644 "docs/test-results/evidence/issue-448/ha449smk0001/\354\213\244\355\226\211-\352\270\260\353\241\235.txt" create mode 100644 "docs/test-results/evidence/issue-448/ha449smk0001/\354\213\244\355\226\211-\354\232\224\354\225\275.md" create mode 100644 "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" create mode 100644 docs/test-results/opensql-grafana-normal-rehearsal-20261009.md 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 0000000000000000000000000000000000000000..a3d800ef7f72fd5ac55092b6957ee088bc28fd48 GIT binary patch literal 4306 zcmd6ke^AnS8pq4ZGEw}|CYj21vuCwe8QqFzvTW1dHC@B9jb35NP?2s~kSs^3R75yh zmQY)pn-p1;23nq$faZEwH_&NfKm3JXR9v>q9}$*6zMBO1g)gAc-XA-2vlD+k^Wyb> zKF{-f(I@9jv;Dkguyl>L{#d_h)}z%uJFgkS%b9iFi~gCtShf;t{yaFf?Be-bTglQ( zXDYUozUr}zT(*KwIq%+4Eaq1n5TIE5D3+JyRQyO;JseoT#i>yL3yM6S>tQ+FVc*s zNIaNJF&l`M;TS6@=9pXYmLW7}T!1&LDHa3KIxM%E3>@<)-eN{`tjHOr9bTWGQsyoI zSI$Hi>+hpL-Gm+rpHTnC9H$1TKjyCuLfT{?^+lgqvEKwJQ z@xJvaP)=fV@-RzaIS;d2RkVP20MUurtu6}pC{#{jb5aq7u$+p>!Jt&z!*~pY3WHL+ z7tmiYu{$W<4p>I+h9^!mf}{G(rfvFPJ~?6fc`qfAv1s!j_lbuV(A1S+JA8>%az zQwtB-5x?T><1R}X@fMsF9ilXNXCzM-tkE-k;^|yr+O4$JBiKlZ;eefOes8NM?BHl$4z#`-+#hJ zpw95?o=@0Qzcv_Ya_093`(W46h9re_%_qvC=!O&7t*fCSt%@6}zeKN) zGB)Ky$YFT~AsFIjaE%Ee z_w%`elee0BK36d1e*|6Ad)(a-bhY=`QR@7L?stcVumykpOPAR`t7CfCT#g-+=5+)o z2kU~8gLHw(fx3X?039+Jsq-iK>->`abO;in!Yt z{X;*N)%VU=p~qos)p_F`!x0<1sg3NiOabmy#S0u3SDqyxzp5C@EPu1JuSrvS>EN2E zgWbkCbp#WQw}0UwKo5BLU_n{E+^>!XBO#2iDe|Rv30hhMJ>h_`5;)gYJOrWTcWGK$ zBYoQepb|LWB^#1CPy>uU(M9%RedHSygnP6g`q^}0Ht>lK+bi>t6BM+2v|ze09mobE zVYC58Pt$~(fjgbWt=OBgJynEK+E&`rG+;9z>y)+1Zpvs?v{KqO8UUkW7!l6VuI>v? z5iS9~)J8U7H_1*^LP)%CVG6JW_^TG%AloF%ucW~UvTO)DS|uqb9HtHIO`r(hcD8J} zwO7|xTb8-yjr2L8>15&fXXSgkpVVHaZ>C zSL*nqtg>?I;qbvh5=H$suC2WSM(bdd`!H@-+oIe47g!I<)}@B88a$l@q0~0aZTxE<;F+6MFQ9`aLZlvW(i9e3@LP8~mvj;j7 z)S|etmWmQSjx_|MM=)|&?!igRJqTI0UQ^rKgBfebb1bCC=rwPt8#w#=1<~&+O7QbX z)x{Q4EBbCt!KhkhA+`T_fgL?gH$Kw=)$PnzeYOdI>qGX{-1l%z2yJU$&GP{E30;=Q zizDz&{vVfX7!=CoQTuzk>S_)%Cw?0H@#S7XE{me|XlpAEQz!0?S@-oW*KE9aFt$9s zTVJcp2h+3&(m1qbD%BUb4bwZ6uPj2A(7oh-yY!u1~St zgOFwG<*+=0?(JPc$g;mUrTt{kW!-YkWk)0MsGf7#gTKc@HK(i3;6Bxg&*J{36@QOQ z(~1jld0O!WoK!0oR$s(*YQ>`J6V-XuC#z3Y=U4Nq1&?i4`9M$5z|ib5?l2}9HyJk= z*BVzFF~+6FMaBi1K%zlSo6s!E z8f&=?qxCQnh_Y-}1a9<&1VUK@j}su|vfP7^W$)#%+=K4zCxavKi&NXXf|Hi}i;v+^ z-<#`0UP$DtSO|)b;p0GYHhwE8&cSDa;^X*2P@IRaQ8a?$eEfhyC@+=Yl{d-(`Jj9v z#&-1I;RK%wVu*62yjdoY5oJhObCsZqScR-={!DO(cn5ih)4qzh8o9bTRuD^!MaDLd z7l__oFFEp3LSYZIi{4hfUqS**vj$+dUqW$|-(F-EvVUNoXP;w#$3D&Gvrn<}*vHv9 n%xv~C_R)!g-ApO-2D6xXjd_(>WbKK!QqJFhFmLqIv}ykZ|4siy literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..46cb7ab759224556b33f49eaef693f5495d7c8a1 GIT binary patch literal 28568 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..3c81f72dd5b880bb2cfb7df8c08c47097fac3df1 GIT binary patch literal 75 zcmb2|=3oE;CT49fT^(Otz0UJK7d21m_xqpIKdb%ll!uAoMxAqK%?u0-uIL(HIcsvk d&_&qfkXlBM?**3G3p8B>7%tWAJYNSi0sx`38qNR! literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..250449d186596e1da9dbcc0a863638be569c8aaa GIT binary patch literal 256 zcmV+b0ssCViwFP!000021GUn@PQ)M(2H?9-vEeM60j9f4pTm=v2JOTpRay((J#2h; z+chS}s3np)lKCMYf94`w67@!C&^xI>g1v)%jkZ>Li(1`_+l`j}#rcK?_2o<7vrKj~R}L$7%~ZKAVjnPv%~g4U9WyF7 zOqHAev#TvCyTV2$HkPn0X0^?=DTddMoc4m=cNtq=F|A8g#NHor*j$yb+TQ`$b+~Ll G2LJ&45O^^F literal 0 HcmV?d00001 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의 정상 리허설 결과로 관리한다. 원본 압축 파일은 사전 허용 필드 검사와 사후 민감 패턴 검사를 통과했다.