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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
162 changes: 162 additions & 0 deletions docs/test-results/wal-offsite-recovery-recheck-20261018/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
# WAL 완료 확인 후 DB·GCS 복구 재계측

관련 이슈: #563. 원인 재현·수정은 [#564](https://github.com/DocGrid/docgrid/pull/564), 그 후 같은 부하의 실제 복구 재시험은 이 문서에 기록한다.

**결과: 65건의 성공 응답 중 60건 복원, 5건 DB 누락. 마지막 외부 보존 완료 구간 60건은 모두 복원됐고 구간 안 누락은 0건이다.** 마지막 구간 뒤 5건은 따로 손실로 기록했다. 전체 무손실이나 운영 RPO 0을 주장하지 않는다.

## 목적

앞선 [계측](../dr-functional-recovery-20261018/README.md)은 성공 응답 65건 중 33건만 복원했다. 그때 외부에 마지막으로 전송한 원본은 60건이었으나, DB에 필요한 WAL까지 보관됐는지는 고정 2초와 archive 종료 코드로 추정했다. #564에서는 그 판정이 조기에 성공할 수 있는 경로를 실제 3사례 중 2사례에서 재현하고, 정확한 WAL 파일의 보관·외부 일치를 확인하도록 보완했다.

이번 성공 기준은 **마지막 원격 WAL 확인 시점까지 성공한 요청의 ID·DB 관계·원본 해시와 generation이 모두 복원되는 것**이다. 이후 요청까지 덮기 위해 마지막 전송을 추가하지 않는다. 최초 감사가 끝나기 전에 Worker·Flyway·버킷 재연결을 실행하지 않는다.

## 테스트 환경

| 구성요소 | 실행 위치·실제 범위 |
| --- | --- |
| 소스 | 실제 OpenSQL 17.8의 전용 논리 DB·소유 마커가 있는 두 역할, 공개 DB 설정 유지 |
| 애플리케이션 | 로컬 실제 Spring Boot JVM. 이전과 같은 Java 소스 `3bff887a5f6abdf4155957a658599f8f0366a141`·동일 JAR. SHA-256 `08c5c458110bc96fc4c92cc5b02bd160f6c08b59e21d9ef536f2fa50aa76b27d` |
| 수집·백업 | 로컬 Docker 실제 Barman 3.19.1·별도 카탈로그. 새 기준 백업 DONE·pg_verifybackup 통과 |
| 외부 원본·DB 입력 | 시험 전용 비공개 GCS 버킷 3개. 원본·기준 백업·identity·WAL을 generation 고정하고 실제 바이트 SHA-256·크기 대조 |
| 복원 | 네트워크 없는 Barman import/recover 후 기존 라이선스가 있는 비동기 replica 호스트의 **별도 PGDATA**에서 실제 OpenSQL 실행 |
| 호스트 격리 한계 | 별도 물리 클러스터를 새로 만든 시험은 아니다. 비동기 replica의 원래 Patroni·PostgreSQL만 잠시 중단하고 1,800초 자동 원복 장치를 둔다. Primary·동기 Standby는 유지하고 원래 PGDATA는 복원 대상으로 쓰지 않음 |
| 모델·캐시 | 실제 BGE-M3 요청을 전달하는 제어 프록시·시험 소유 Valkey. 중단 Job 준비 시 실제 모델 응답 하나만 보류. 모조 벡터를 반환하지 않음 |
| 제외 | Ollama 생성 답변·공개 트래픽 전환·OpenProxy 경로·전체 사이트 소실·클라우드 IAM에 의한 원본 접근 차단 |

모든 원장 시각은 UTC(+00:00), 한국 시각은 9시간을 더한다. 사전 준비 시간과 장애 T0 이후 경과를 구분한다. 모델은 기존에 준비된 실행 환경을 재사용했으며 최초 모델 다운로드 시간은 측정하지 않았다.

```text
그림 1 — 실제 입력 보존과 복원 경로

시험 클라이언트 → 격리 JVM → 소유 논리 DB / 시험 원본 GCS
│ 성공 응답 ID·해시를 DB 밖 원장에 저장
▼
OpenSQL WAL → 실제 Barman 수신·보관
│ 전환한 WAL 이름 확보 → 다음 구간 restore point 생성
│ 정확한 완성 파일 도착까지 제한 시간 내 대기
▼
시험 외부 GCS: 기준 백업·identity·WAL·원본 generation 고정
│ 같은 파일을 실제 재읽기 → SHA-256·크기 일치
▼
원본 취득 중단 → 네트워크 없는 복원 도구
→ 라이선스 있는 설치 호스트의 별도 PGDATA
→ 최초 감사 → 통과 후에만 복원 JVM·Worker 활성화
```

실제 Barman 명령과 실제 GCS REST를 사용했다. 공개 대기 함수 `wait_for_archived_wal()`를 사용했고 원격 단계는 기존 REST publisher에서 고정 generation을 재읽었다. 전체 `gsutil sync --required-wal` CLI의 클라우드 실행으로 표현하지 않는다.

## 테스트 방법

1. Run ID `DR-RESTORE-20261018-FRESHNESS1`의 목적·부하·성공 기준을 시작 전에 저장했다. [formal-spec](evidence/DR-RESTORE-20261018-FRESHNESS1/formal-spec.json), [실행 바이너리 동일성](evidence/DR-RESTORE-20261018-FRESHNESS1/artifact-identity.json).
2. 같은 합성 문서 생성기로 PDF·DOCX 완료 4건, 실제 응답을 기다리던 Job 1건을 준비했다. Job을 처리하던 소유 JVM을 SIGKILL한 뒤 Worker=false로 다시 기동했다. 소스 준비 점검은 24/24, 새 기준 백업 검증까지 완료했다. 문서 ID·marker·원본 바이트는 실행별로 새로 생성하므로 이전 파일과 byte-identical이라고 주장하지 않는다.
3. 추가 업로드 60건을 목표 11초 간격으로 보내며 300초 주기의 전송을 두 번 수행했다. 수신·전송은 업로드와 직렬 실행해 경계가 명확하며, 전송 때문에 생기는 실제 간격 변화도 기록했다.
4. 매 전송은 전환한 **정확한 WAL 파일**이 Barman wals에 도착해야 진행한다. 다음 파일 진행을 위해 합성 restore point를 만들고, 외부 전송 후 해당 WAL의 고정 generation을 다시 읽어 로컬 SHA-256·크기와 대조했다.
5. 마지막 전송 뒤 요청을 계속 보냈다. 원본 API·수집기·터널을 중단한 T0는 `2026-10-18T14:36:26.952847+00:00`, 외부 입력 동결은 `2026-10-18T14:36:37.353+00:00`이다. 차이는 10.4초이며 두 시각을 같은 것으로 취급하지 않는다.
6. 동결 입력만으로 복원하고 원본에 재접속하지 않았다. Worker·Flyway·버킷 재연결 전 65건 전체를 감사했다. 마지막 확인 구간의 누락이 있으면 활성화를 거부하도록 했다.
7. 통과 뒤 복원본의 버킷 참조만 시험 보존본으로 바꾸고 Worker 동시성 2로 기동했다. 복원된 계정으로 새 로그인 후 기능 6종을 확인했다. 최종 정합성 감사 뒤 원래 3노드·공개 설정·소유 자원을 정리했다.

```text
그림 2 — 성공 응답과 외부 보존의 시간 경계 [관측]

기능 문서 5건 → 초기 전송 0: 5건
→ 업로드 계속 → 전송 1: 33건
→ 업로드 계속 → 전송 2: 60건
└─ WAL·원본 외부 검증 완료
→ 마지막 5건 요청 → T0 → 입력 동결

최초 복원 감사:
보존 확인 구간 60건
├─ 복원 60건
└─ 누락 0건
그 뒤 5건
├─ 복원 0건
└─ 누락 5건

마지막 확인 뒤의 요청을 덮기 위한 추가 전송은 하지 않음
```

이전의 32건 누락과 이번 5건 누락은 모두 명시한다. 개선은 마지막 전송 시점까지의 WAL 누락 공백이 제거됐다는 것이며, 전송 주기 뒤의 요청까지 무손실로 만든 결과는 아니다.

## 관측 결과

### 이전 회차와 재시험 비교

| 항목 | 이전 FORMAL1 | 이번 FRESHNESS1 |
| --- | --- | --- |
| 성공 응답 | 65건 | 65건 |
| 마지막 전송 원본 | 60건, 필요한 WAL 완료 확인 없음 | 60건, 정확한 WAL 외부 바이트 확인 |
| 최초 복원 | 33건 | 60건 |
| DB 누락 | 32건 | 5건 |
| 마지막 전송 구간까지의 누락 | 원본 60건 중 DB 누락 27건 | 0건 |
| 마지막 전송 뒤 요청 | 5건 누락 | 5건 누락 |
| 기능 검증 | 6/6 | 6/6 |
| 마지막 연속 복원 ACK~T0 관측 간격 | 412.061초 | 113.978초 |

위 간격은 이 부하·전송 타이밍에서 측정한 값이다. 운영 RPO 보장값이나 무손실 선언이 아니다. DB 누락 5건 중 보존된 원본이 있는 사례는 0건이다. 이 5건은 DB와 원본 모두 마지막 외부 보존 범위 밖에 있었다.

### 외부 전송과 실제 요청 간격

| 전송 | 보존 원본 수 | 보존 WAL/history 파일 수 | 소요 | 결과 |
| --- | --- | --- | --- | --- |
| 0 | 5 | 5 | 67.284초 | PASS |
| 1 | 33 | 6 | 66.737초 | PASS |
| 2 | 60 | 7 | 67.315초 | PASS |

손실 관측 요청 60건의 실제 전송 간격 59개는 최소 7.068초·중앙 10.997초·최대 74.887초였다. 목표 11초와 다르며, 전송 중 대기·이후 따라잡기를 포함한다. 두 회차를 엄밀한 처리량 벤치마크로 비교하지 않는다.

### 기능별 복구 경과

| 기능 | 표본 | 완료 시각 UTC | T0부터 | 결과 |
| --- | --- | --- | --- | --- |
| 기존 원본 다운로드·해시 | 4건 | 2026-10-18T14:39:09.852552+00:00 | 162.9초 | 통과 |
| 기존 문서 검색 | 1건 | 2026-10-18T14:39:25.400051+00:00 | 178.447초 | 통과 |
| 중단 Job 재개 | 1건 | 2026-10-18T14:40:21.317855+00:00 | 234.365초 | 통과 |
| 새 업로드 | 1건 | 2026-10-18T14:40:28.376043+00:00 | 241.423초 | 통과 |
| 새 문서 인덱싱 | 1건 | 2026-10-18T14:51:37.672099+00:00 | 910.719초 | 통과 |
| 새 원본 다운로드·해시 | 1건 | 2026-10-18T14:51:39.754136+00:00 | 912.801초 | 통과 |

복원 DB 기동과 사용자 기능 재개는 다른 시각이다. 이 표는 격리 문서 다운로드·검색·인덱싱 경로의 관측 시간이며, 공개 서비스 전체 RTO나 Ollama 답변 복구 시간이 아니다.

이번에는 이전 33건보다 많은 60건을 복원했고 Worker 동시성은 2로 유지했다. 새 문서는 복원된 대기 Job 뒤에서 처리됐다. 14:43:55 UTC의 읽기 전용 조회에서 전체 61개 Job 중 INDEXED 27개·처리 중 1개·대기 33개를 확인했다. 따라서 새 인덱싱 경과가 늘어도 같은 큐 크기의 성능 회귀로 단정할 수 없다. [대기열 관측](evidence/DR-RESTORE-20261018-FRESHNESS1/queue-1-result.json).

```text
그림 3 — 최초 감사 뒤에만 활성화하는 내부 순서 [실행 원장]

sequence 312: 복원 완료
→ sequence 313: 원장·ID·원본을 최초 감사, 파일로 확정
[Worker=false / Flyway=false / repair=false / source_access=false]
→ sequence 314: 보존 구간 누락 0건 확인
├─ 누락 있음: 중단·정리
└─ 누락 없음
→ sequence 315: 복원본 버킷 참조 재연결
→ sequence 319: 복원 JVM·Worker 활성화
→ 다운로드·검색·같은 Job 재개·새 업로드·인덱싱
→ sequence 335: 최종 정합성 감사
→ sequence 336: 원래 클러스터·소유 자원 정리
```

중단 Job은 하나를 유지하면서 Attempt 1→2, 최종 INDEXED를 확인했다. 최종 감사 대상은 61문서이며 미완료 Job 0건·중복 Chunk 0건·중복 Embedding 0건·누락 Embedding 0건이다. [최초 감사](evidence/DR-RESTORE-20261018-FRESHNESS1/initial-audit.json), [구간 판정](evidence/DR-RESTORE-20261018-FRESHNESS1/checkpoint-coverage.json), [최종 스냅샷](evidence/DR-RESTORE-20261018-FRESHNESS1/final-snapshot.json).

### WAL 재생 끝 확인

복원 서버의 마지막 재생 LSN은 `0/31000000`, 마지막 동결 WAL은 `000000060000000000000030`, segment 크기는 `16MB`였다. 이 WAL의 끝과 재생 끝이 일치했다. 마지막 재생 커밋은 `2026-10-18T14:34:33.013865+00:00`다. [읽기 전용 관측](evidence/DR-RESTORE-20261018-FRESHNESS1/replay-boundary-result.json). 이 확인은 Worker 활성화 후 읽었지만 replay LSN을 조회한 것이며 새 쓰기 LSN과 혼동하지 않는다.

## 검증 과정에서 확인된 사항

- 이번 본 복구 회차의 실행 판정은 **PASS**, 추가 차단 사유는 `없음`이다. 초기 준비·본 복구·마지막 공유 자원 정리를 구분해서 기록했다. 앞선 수신 역할 준비 중단은 #564의 별도 LIVE1 증거로 남겨 재현 성공에 섞지 않았다.
- 보완은 공개 PostgreSQL·Patroni·Barman 타이머 정책 변경이 아니다. 지정된 WAL까지 완료를 확인하는 선택적 계약과 격리 호출 경로를 검증했다. 주기 복사의 완료 시각만 보고 업무 최신성·RPO 0을 선언하면 안 된다.
- 원본 접근 차단은 복구 도구 network=none·원본 리스너 종료·클라이언트 원본 GCS 접근 가드다. 클라우드 IAM 거부나 원본 클러스터의 실제 파괴 시험은 아니다. 원본에서 추가 WAL·원본 파일을 보충하지 않았다.
- 전체 클러스터 물리 백업을 사용했지만 애플리케이션 검증·쓰기·정리는 소유 논리 DB와 시험 버킷에 한정했다. 복원 호스트는 기존 비동기 replica의 라이선스를 재사용한 환경이며 원래 PGDATA를 덮어쓰지 않았다.
- 동일 JAR·같은 문서 수·같은 생성기·목표 간격·전송 주기를 유지했다. 실제 네트워크·모델 대기·문서 marker와 ID·전송 소요는 같지 않아 지연 개선 수치를 일반화하지 않는다.
- 정리 확인: 원래 클러스터 복귀·공개 설정 해시 일치·시험 소유 프로세스/컨테이너/DB/역할/키 제거. 시험 버킷 3개 삭제, 의도한 사용자 원본 버킷 유지, 임시 ADC 복사본·수신 인증 설정 복사본 제거. 기존 OAuth 자격은 폐기하지 않았다. 개인 환경 파일 2개의 내용 일치를 확인했다.
- 활성 객체와 버킷을 삭제해도 GCS soft-delete 정책이 삭제된 세대를 보존할 수 있다. 즉시 물리 삭제를 주장하지 않는다. [회차 정리](evidence/DR-RESTORE-20261018-FRESHNESS1/runtime-cleanup-observations.json), [공유 자원 정리](evidence/task-cleanup-r1/result.json), [기존 개인 작업·모델 보존 확인](evidence/local-preservation-r1/result.json).

## 증거와 변경 범위

| 구성요소 | 이번 역할 |
| --- | --- |
| `scripts/opensql/barman_offsite_sync.py` | #564에서 추가한 정확한 WAL 보관 대기 함수. 기본 공개 타이머 동작은 유지 |
| 기존 `dr_recovery.py` 계약 | 원장·동결 입력·최초 감사·활성화·기능 6종·최종 감사·항상 정리 순서 재사용 |
| 이번 결과 디렉터리 | 실제 회차의 별도 로그·전송 경계·스냅샷·집계·정리 증거. 애플리케이션 코드 변경 없음 |

[집계](evidence/metrics.json) · [실행 원장](evidence/DR-RESTORE-20261018-FRESHNESS1/events.jsonl) · [동결 입력](evidence/DR-RESTORE-20261018-FRESHNESS1/inputs.json) · [요약](evidence/DR-RESTORE-20261018-FRESHNESS1/summary.json) · [기능 관측](evidence/DR-RESTORE-20261018-FRESHNESS1/functional-observations.json) · [비식별 파생물·해시 목록](evidence/export-index.json).
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"elapsed_seconds": 83.177,
"finished_at_utc": "2026-10-18T14:22:20.722199+00:00",
"location": "isolated local runtime and owned cloud source database",
"observations": {
"backup_command_exit_code": 0,
"backup_id": "20261018T142124",
"final_status": "DONE",
"verify_exit_code": 0
},
"purpose": "새 OpenSQL 물리 기준 백업 검증",
"run_id": "DR-RESTORE-20261018-FRESHNESS1-BACKUP",
"started_at_utc": "2026-10-18T14:20:57.547312+00:00",
"success_criteria": "DONE·pg_verifybackup 0",
"verdict": "PASS"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"elapsed_seconds": 29.582,
"finished_at_utc": "2026-10-18T14:22:50.306115+00:00",
"location": "isolated local runtime and owned cloud source database",
"observations": {
"bootstrap_sha256": "984bbf5381b611daf7c1e57781686c6e74762eae2a0238f1947444da494366ab",
"license_environment_present": true,
"source_settings": 8,
"target_settings": 8
},
"purpose": "장애 전 설치 환경·클러스터 식별 보존",
"run_id": "DR-RESTORE-20261018-FRESHNESS1-BOOTSTRAP",
"started_at_utc": "2026-10-18T14:22:20.724864+00:00",
"success_criteria": "라이선스 환경·소스/대상 설정 고정, 클라이언트 계정 ID 고정",
"verdict": "PASS"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
{
"elapsed_seconds": 140.662,
"finished_at_utc": "2026-10-18T14:18:44.965938+00:00",
"location": "isolated local runtime and owned cloud source database",
"observations": [
{
"elapsed_seconds": 32.716,
"ordinal": 1,
"state": "INDEXED"
},
{
"elapsed_seconds": 24.861,
"ordinal": 2,
"state": "INDEXED"
},
{
"elapsed_seconds": 28.196,
"ordinal": 3,
"state": "INDEXED"
},
{
"elapsed_seconds": 25.107,
"ordinal": 4,
"state": "INDEXED"
}
],
"purpose": "기능용 PDF·DOCX 4건 준비",
"run_id": "DR-RESTORE-20261018-FRESHNESS1-CORPUS",
"started_at_utc": "2026-10-18T14:16:24.307909+00:00",
"success_criteria": "업로드 201 4건·INDEXED 4건, 실제 BGE",
"verdict": "PASS"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"elapsed_seconds": 29.674,
"finished_at_utc": "2026-10-18T14:11:25.232088+00:00",
"location": "isolated local runtime and owned cloud source database",
"observations": {
"database_created": true,
"roles_created": 2,
"vector_version": "0.8.1"
},
"purpose": "전용 논리 DB·두 계정 준비",
"run_id": "DR-RESTORE-20261018-FRESHNESS1-DATABASE",
"started_at_utc": "2026-10-18T14:10:55.559295+00:00",
"success_criteria": "소유 마커·역할·vector 확장 및 해당 DB 함수 권한",
"verdict": "PASS"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"elapsed_seconds": 57.008,
"finished_at_utc": "2026-10-18T14:20:57.546547+00:00",
"location": "isolated local runtime and owned cloud source database",
"observations": {
"attempt_count": 1,
"chunk_count": 3,
"document_status": "INDEXING",
"embedding_count": 0,
"job_status": "PROCESSING",
"version_status": "EMBEDDING"
},
"purpose": "실제 BGE 응답 대기 중 Job 중단",
"run_id": "DR-RESTORE-20261018-FRESHNESS1-INTERRUPT",
"started_at_utc": "2026-10-18T14:20:00.539568+00:00",
"success_criteria": "Job PROCESSING·Attempt 1·Worker=false 재기동",
"verdict": "PASS"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"elapsed_seconds": 67.286,
"finished_at_utc": "2026-10-18T14:23:57.593514+00:00",
"location": "isolated local runtime and owned cloud source database",
"observations": {
"backup_bytes": 104355840,
"completed_wals": 5,
"elapsed_seconds": 67.284,
"finished_at_utc": "2026-10-18T14:23:57.591442+00:00",
"preserved_originals": 5,
"purpose": "완성 WAL·identity·기준 백업과 원본 보존본의 외부 전송 및 바이트 대조",
"run_id": "DR-RESTORE-20261018-FRESHNESS1-TRANSFER-0",
"started_at_utc": "2026-10-18T14:22:50.309279+00:00",
"verdict": "PASS"
},
"purpose": "기준 백업·identity·WAL·원본 5건 외부 보존",
"run_id": "DR-RESTORE-20261018-FRESHNESS1-OFFSITE",
"started_at_utc": "2026-10-18T14:22:50.309035+00:00",
"success_criteria": "generation 읽기·SHA·크기 일치",
"verdict": "PASS"
}
Loading
Loading