운영 안정화 변경을 최신 main과 통합한다

This commit is contained in:
Hide_D committed 2026-11-14 15:13:53 +09:00
commit 087517821e
47 files changed
+1558 -123

No files matched your search

+3
View File
@@ -2,6 +2,9 @@
## 문서 상태와 사용법
2026-11-09 저장 최적화: [물리 압축·정리 처리량·ZFS 운영 경계](../play-audit-storage.md).
판단 원문과 현재 기수 보존 계약을 유지하며 저장 표현과 이전 기수 정리 구현을 변경한다.
이 문서는 플레이 감사의 구조·현재 구현 경계·남은 요구사항을 관리하는 기준 문서다.
사용 방법과 DB 적용은 [운영 안내](../play-audit-operations.md)를 따른다.
날짜별 구현·측정 기록은 Git 이력과 상위 작업공간의 `report/`에서 확인한다.
+149
View File
@@ -0,0 +1,149 @@
# Hyper-V 저장·복원 이후 게임 복구
Windows 재부팅 때 Linux VM이 저장·복원되면 게스트의 shutdown/resume hook이
실행되지 않을 수 있다. 게임은 종료 이벤트 없이도 기존 lease 만료와 DB snapshot
재적재로 복구한다. Windows 식별자 확인은 이 경로에 추가하는 선택 기능이다.
## 예약턴 임대 복구
- 실행 루프가 종료되면 DB에서 자기 owner의 장수·국가 예약턴 임대를 해제한다.
In-memory rollback으로 leased set에서 빠진 row도 정리한다. 큐 내용과 revision,
다른 실행자/API의 임대는 변경하지 않는다.
- 계산 전 임대 획득 충돌은 전체 메모리 savepoint를 복원한 뒤 1초 단위로 재시도한다.
한 충돌 구간의 상한은 301초다(기본 queue lease 5분 + 여유 1초). 그동안 다음
loop마다 운영자 pause/stop과 daemon 권위를 확인한다. 성공한 batch에서 상한을
초기화하며 큐가 바뀌어도 같은 실패 구간의 상한을 연장하지 않는다.
- 저장·finalize 단계의 소유권 상실, 메모리 복원 실패와 다른 게임 오류는 자동
재시도하지 않는다. 상한을 넘은 획득 충돌도 기존 PAUSED/장애 이력 경로로 간다.
- daemon의 30초 lease와 fencing epoch, 기존 10분 이하 catch-up 및 장시간 복구
정책은 유지한다. 운영자가 정지한 profile을 자동 START하지 않는다.
## Windows Node.js 에이전트
필요 파일은 `windows-boot-agent.mjs` 하나이며 외부 npm package가 필요 없다.
Node.js 24.22.1 이상을 사용한다. 설정 예시는
`tools/host-recovery/windows-boot-agent.example.json`이다.
Node.js가 Windows 내장 PowerShell의 CIM 조회로 `Win32_OperatingSystem.LastBootUpTime`
UTC ticks를 읽고 hostname과 함께 hash한다. 별도의 PowerShell 스크립트 설치나
Linux에서 Windows 관리자 명령 실행은 필요 없다. Node 에이전트를 재시작해도
같은 Windows boot의 식별자는 같고, Windows를 재부팅하면 달라진다.
실제 Windows에서 CIM 조회가 실패하면 가짜 식별자를 만들지 않고 시작을 거부한다.
1. 파일들을 `C:\ProgramData\Sammo` 같은 고정 경로에 복사한다.
2. 디렉터리를 만든 뒤 Windows 터미널에서 private key를 생성한다. 기존 파일을
덮어쓰지 않으며 key 내용은 출력하지 않는다.
```text
node C:\ProgramData\Sammo\windows-boot-agent.mjs --create-key C:\ProgramData\Sammo\host-boot.key
```
3. 설정 예시를 `C:\ProgramData\Sammo\host-boot.json`으로 복사한다. `keyFile`은
위 경로, `bindAddress`는 **Linux VM에서 접근할 수 있는 Windows의 사설 주소**로
설정한다. 예시의 `127.0.0.1`은 안전한 기본값이며 원격 Linux에서 접근할 수 없다.
`port` 기본값은 17781이다. Windows 방화벽은 해당 사설 listener에 Linux VM
주소에서 오는 TCP 연결만 허용하도록 설정한다. 기존 공개 Caddy는 변경하지 않는다.
4. key 파일은 실행 계정만 읽도록 NTFS 권한을 제한하고, 같은 내용을 Linux의
`/run/secrets/host_boot_key` 같은 private file로 비공개 전달한다. Git·메신저·
CLI 인자에 key 내용을 넣지 않는다. 에이전트는 key를 시작할 때 읽으므로 key
교체 시 양쪽 프로세스를 함께 재시작한다.
5. 실행한다.
```text
node C:\ProgramData\Sammo\windows-boot-agent.mjs --config C:\ProgramData\Sammo\host-boot.json
```
6. Windows 작업 스케줄러에 **시스템 시작 시** task를 등록한다. 프로그램은 Node의
절대 `node.exe` 경로, 인자는 위 `.mjs --config ...` 부분을 사용한다. 로그인 없이
실행되고 실패 시 재시작하도록 구성한다. 설정 파일을 사용하므로 터미널 세션의
임시 환경변수에 의존하지 않는다. 실제 Windows 설치·방화벽 변경·task 등록은
대상 host에서 별도 적용하고 확인해야 한다.
원한다면 설정 파일 대신 `HOST_BOOT_KEY_FILE`, `HOST_BOOT_BIND_ADDRESS`,
`HOST_BOOT_PORT` 환경변수로도 실행할 수 있다.
## Linux 연결
기존 game runtime/PM2가 상속하는 서버 환경에 아래 두 값을 함께 설정한다.
설정이 없으면 기능을 사용하지 않는다. 설정이 불완전하거나 key 파일이 없거나 잘못돼도
고정 경고와 함께 보조 관측만 비활성화하며 게임 기동은 계속한다.
```dotenv
TURN_HOST_BOOT_URL=http://windows-private-address:17781/v1/boot-id
TURN_HOST_BOOT_KEY_FILE=/run/secrets/host_boot_key
```
Docker에서는 private key가 들어 있는 전용 디렉터리를 runtime에 read-only mount하고 두 환경변수를
**runtime container environment**에 전달해야 한다. 호스트의 `.env`에만 추가하면
자동 전달되지 않는다. 기존 Compose와 host overlay를 함께 적용하는 전용 wrapper를
사용한다. 실제 secret 파일의 host 경로와 Windows 사설 주소를 확인하기 전에는
운영 stack 설정을 추측해 바꾸지 않는다.
예를 들어 host overlay의 runtime 설정에는 다음 형태로 연결할 수 있다. host 경로는
placeholder이며 실제 운영 경로로 교체해야 한다.
```yaml
services:
runtime:
environment:
TURN_HOST_BOOT_URL: ${TURN_HOST_BOOT_URL:-}
TURN_HOST_BOOT_KEY_FILE: /run/sammo-host-recovery/host_boot.key
volumes:
- type: bind
source: /absolute/private/path/host-recovery
target: /run/sammo-host-recovery
read_only: true
bind:
create_host_path: true
```
위 디렉터리는 보조 기능 전용이므로 없으면 빈 디렉터리를 만들 수 있다. 파일 부재는
관측만 비활성화한다. DB·backup 같은 필수 저장소에는 이 fallback을 적용하지 않는다.
운영에는 이 전용 디렉터리의 `host_boot.key` 파일을 전달한다.
처음 인증에 성공한 식별자를 프로세스의 기준으로 삼고 고정된 성공 로그를 남긴다. 이후 다른 식별자를
확인하면 `TurnDaemonLeaseLostError`로 기존 실행자를 중단하고 DB를 다시 읽는다.
종료 시 owner/epoch가 여전히 자기 것인 경우에만 DB lease와 `clock_ready`를
해제하므로 다른 실행자를 중지시키지 않는다. 재시작은 기존 profile 상태를
보존하며 PAUSED/STOPPED를 임의로 RUNNING으로 바꾸지 않는다.
보통 최대 2초 간격으로 확인한다. 벽시계와 monotonic 경과가 1초 이상 어긋나면
cache를 무시하고 다시 조회한다. 조회 timeout은 1초다. 네트워크·인증·응답 오류는
분당 한 번의 고정 경고만 남기고 기존 DB lease 복구를 유지한다. 처음 인증받기 전
발생한 reboot는 이 신호만으로 확정할 수 없으며 기존 복구 경로가 담당한다.
## 인증 프로토콜과 검증
key 파일은 32바이트 난수를 lowercase hex 64자로 저장한다. 매 요청마다 새 32바이트
challenge를 만들며, key 자체를 네트워크에 보내지 않는다.
- GET `/v1/boot-id`, query string 없음.
- `x-sammo-challenge`: lowercase hex 64자.
- `x-sammo-signature`: `HMAC-SHA256(key, "sammo-host-boot-request-v1\n" + challenge)`.
- JSON: `version=1`, `challenge`, `bootId`(hex 64자), `signature`.
- 응답 signature:
`HMAC-SHA256(key, "sammo-host-boot-response-v1\n" + challenge + "\n" + bootId)`.
위 `\n`은 실제 LF다. Linux는 challenge와 응답 signature를 검증하고, redirect와
2048바이트 초과 응답을 거부한다. 서명된 옛 응답도 새 challenge에는 유효하지 않다.
시각 정확도나 요청 시각 만료에 의존하지 않는다. 이 프로토콜은 인증과 무결성을
제공하며 암호화는 하지 않는다. 사설 망에서만 사용하며 공개 무인증 재개 API를
만들지 않는다.
```sh
node --test tools/host-recovery/windows-boot-agent.test.mjs
pnpm --filter @sammo-ts/game-engine test hostBootMonitor.test.ts runtimePauseGate.test.ts reservedTurnLeaseRetry.test.ts
```
CI에서는 Windows boot를 모사한 HTTP 응답, 위조·재사용·timeout, 실제 PostgreSQL
lease fencing과 queue cleanup을 검증한다. 실제 Windows CIM·Task Scheduler와
Hyper-V 재부팅 검증을 대체하지 않는다. 배포 후 기존 장애 진단 화면의 lease/
epoch와 public lobby `turnEngineRunning`, 실제 tick/queue revision 진행을 확인한다.
기능을 해제하려면 Linux의 두 환경변수를 함께 제거하고 해당 runtime을 정상
재기동한다. Windows task도 중지할 수 있다. DB migration이나 게임 초기화는 없다.
근거: [Microsoft LastBootUpTime](https://learn.microsoft.com/en-us/windows/win32/cimwin32prov/win32-operatingsystem),
[Node.js HMAC와 timingSafeEqual](https://nodejs.org/api/crypto.html),
[게임 시계 복구 계약](../architecture/game-clock-reconciliation.md),
[릴리스 운영](../release-operations.md).
+5 -1
View File
@@ -5,6 +5,9 @@
관리한다. 아래 적용 절차는 각 프로필의 DB 보존 업데이트를 위한 것이며,
문서에 기능이 기재되어 있다고 운영 DB에 적용된 것은 아니다.
저장량 최적화, 중지된 프로필 정리, ZFS 이전과 rollback은
[저장량과 운영 정리](play-audit-storage.md)를 따른다.
## 지금 사용할 수 있는 기능
| 화면 | 사용할 수 있는 정보 | 읽을 때 주의할 점 |
@@ -55,7 +58,7 @@
## DB migration과 적용 순서
정식 game migration에 감사 테이블과 인덱스가 포함되어 있다. `prisma db push`나
수동 CREATE TABLE로 대신 적용하지 않는다. 현재 game chain은58개이며 다음 감사
수동 CREATE TABLE로 대신 적용하지 않는다. 현재 game chain은59개이며 다음 감사
migration들을 포함한다. 기존 기록을 삭제하거나 지난달 상세를 역산하지 않는다.
| migration | 준비되는 저장소/제약 |
@@ -69,6 +72,7 @@ migration들을 포함한다. 기존 기록을 삭제하거나 지난달 상세
| `20260916060000_widen_play_audit_ticks` | 월 표본/정책 tick을 BIGINT로 확장하여 약60개월 이후 INTEGER 초과 방지 |
| `20260916070000_add_play_audit_decision` | NPC/자동턴 결정 요약과 순서별 상세 chunk, bounded 정리용 FK/index |
| `20260916080000_index_play_audit_decision_month` | 기존 장수 인덱스를 월 조건 포함 인덱스로 교체하여 기수 전체 조회 방지 |
| `20261109000000_compact_play_audit_storage` | 무손실 압축 chunk 형식과 작은 JSON inline 압축 설정 |
운영은 [릴리스 절차](release-operations.md)의 **DB 보존 버전 업데이트**로 해당 고정
commit을 적용한다. 이 기능을 켜기 위해 시나리오를 초기화할 필요는 없다. 수동 환경의
+76
View File
@@ -0,0 +1,76 @@
# 플레이 감사 저장량과 운영 정리
2026-11-09 저장 최적화는 Core 전용 감사의 물리 표현과 정리 구현을 바꾼다.
현재 기수 전체 기록·판단 순서·정책 참조·hash·API 응답·권한은 유지한다.
원문 샘플링이나 보존기간 축소를 하지 않는다.
## 물리 표현
- 결정 chunk는 기존 `steps` JSONB 배열 또는 `{encoding:"gzip-json-v1",data:"base64"}`로 저장한다.
128 step 단위는 그대로이며 gzip level 3을 사용한다. 압축 envelope가 더 크면 원래 배열을 쓴다.
- replay hash는 압축 전 원문으로 계산한다. API는 공용 decoder 뒤 기존 step schema를 검증한다.
손상·알 수 없는 버전은 조회 오류이며 빈 자료로 바꾸지 않는다.
- 압축 해제는 chunk당 16MiB로 제한한다. 더 큰 원문은 기존 배열로 저장해 잘라내지 않는다.
- 장수 snapshot `data`와 결정 `summary`는 PostgreSQL `STORAGE MAIN`,
`toast_tuple_target=512`로 작은 반복 JSON도 inline 압축한다. JSON 값은 바뀌지 않는다.
- migration `20261109000000_compact_play_audit_storage`는 기존 테이블을 재작성하지 않는다.
chunk CHECK는 새 쓰기에 즉시 적용하고 대형 과거 자료 검사는 `NOT VALID`로 미룬다.
정리 후 `ALTER TABLE ... VALIDATE CONSTRAINT play_audit_decision_chunk_steps_check`를 실행한다.
기존 자료의 압축 변경은 별도 bounded backfill 또는 검증된 export/restore가 필요하다.
## 정리 처리량과 실행 수명주기
- 이전 기수 결정 부모를 기존 `(server_id,nation_id,tick,id)` 인덱스로 최대 200개 잠근다.
한 transaction의 chunk+header 삭제 합도 200행 이하이며 큰 결정 하나가 있어도 상한을 지킨다.
- 다음 성공 batch까지 100ms를 기다린다. RESET과 동일 advisory lock, identity 재검사,
2초 statement/250ms lock 제한, rollback을 유지한다. 현재 기수의 결정과 표본은 삭제하지 않는다.
- API outbox는 전달 후 24시간을 보존한다. 기존 인덱스 순서로 최대 500행씩 정리하고,
backlog가 있으면 다음 dispatch에서도 이어간다. 한 호출은 최대 10 batch/50ms이며
미전달 자료는 삭제하지 않는다. 빈 상태에서는 60초 주기로 돌아간다.
- game API/daemon이 중지된 프로필도 host scheduler가 다음 CLI로 정리한다.
CLI는 접속 URL이나 SQL 원문을 출력하지 않는다. 기본은 dry-run이다.
```sh
# 먼저 common/logic/infra/game-engine을 빌드한다. 접속 정보는 기존 process 환경으로 전달한다.
node tools/play-audit-maintenance.mjs --schemas=che,kwe,pwe,twe,nya,pya,hwe
node tools/play-audit-maintenance.mjs --schemas=che,kwe,pwe,twe,nya,pya,hwe --budget-ms=3000 --apply
```
schema마다 최신 identity를 읽고 각 삭제 transaction에서 다시 확인한다. 예상 identity가 바뀌면
그 schema의 처리를 중단한다. host timer는 다음 실행에서 새 identity를 읽는다.
장수/국가/명령/input_event 같은 gameplay 테이블은 이 CLI의 삭제 대상이 아니다.
## ZFS 운영 경계
호스트는 감사 전용 tablespace와 backup 전용 dataset을 별도로 제공한다.
hot gameplay, WAL, legacy archive를 임의로 이동하지 않는다. DB와 tablespace는 한 cluster이므로
tablespace 파일만 백업하는 것으로 복구를 보장하지 않는다.
1. 전체 DB의 일관된 압축 backup을 ZFS에 만들고 checksum, archive 읽기, 실제 복원을 확인한다.
2. DB 보존 DEPLOY로 writer/reader/migration을 함께 적용한다.
3. profile 상태를 기록하고 공식 STOP으로 쓰기를 중단한다. 원래 STOPPED인 profile은 유지한다.
가오픈까지 1시간 이상 남은 RESERVED는 runtimeExpected=false이므로 예약을 유지할 수 있다.
실제 process 비기동과 identity/hash 안정성을 확인하고 export의 예상 상태를 RESERVED로 고정한다.
유지보수 중 RESET 등으로 identity가 바뀌면 해당 profile을 새로 export/검증하며 이전 자료를 덮어쓰지 않는다.
4. 각 schema에서 현재 serverId의 8개 감사 테이블을 일관되게 export한다.
월 child는 현재 월 header와 join하고 결정 chunk는 현재 결정 header와 join한다.
5. 격리 DB에서 복원·건수·내용 hash를 검증한다. 이전 기수는 전체 ZFS backup에 남는다.
6. 하나의 transaction에서 감사 테이블만 TRUNCATE하고 ZFS tablespace로 옮긴 뒤
현재 기수 export를 다시 넣는다. 부모를 먼저 넣고 모든 건수/hash와 identity를 확인한 뒤 commit한다.
index도 별도로 ZFS로 옮긴다. 실패하면 전체 transaction을 rollback한다.
7. CHECK 검증과 ANALYZE를 수행하고 원래 RUNNING이었던 profile만 공식 START한다.
현재 기록, 새 기록의 decode/hash, lease/clock, 공개 HTTPS와 루트/ZFS 실제 사용량을 확인한다.
기존 데이터가 적을 때는 bounded DELETE 뒤 별도 재작성으로 공간을 회수할 수도 있다.
수천만 건 backlog에서 export/TRUNCATE/restore는 현재 자료를 보존하며 물리 공간을 즉시
회수하기 위한 계획된 유지보수 절차다. backup 검증이나 profile 정지가 빠진 자동 작업으로 쓰지 않는다.
ZFS dataset이 없을 때 루트 디렉터리에 쓰지 않도록 host mount guard와 재현 가능한 Compose 설정이 필요하다.
## rollback
이전 reader는 압축 envelope를 이해하지 못한다. 구형 버전으로 돌아가기 전 해당 기록을
새 decoder로 원래 JSON 배열로 변환하고 같은 hash/개수를 검증한다. 물리 압축 설정은
이전 애플리케이션과 호환되며 원래 TOAST 설정으로 돌릴 필요가 없다.
현재 기수 복원 중 실패하면 transaction rollback으로 기존 table/data/tablespace를 보존한다.
기수 전체 과거 자료가 필요하면 ZFS의 변경 전 backup을 별도 DB에 복원한다.
운영 DB 전체를 과거 dump로 덮어써 새 gameplay를 잃게 하지 않는다.
+12
View File
@@ -376,6 +376,18 @@ Hyper-V의 VM 정지/재개, 체크포인트 또는 host 시간 보정은 게스
NTP 로그와 Windows Hyper-V/백업 작업 이력을 대조합니다. lease 기간을 늘리거나
시간 동기화를 임의로 끄는 것으로 해결하지 않습니다.
예약턴의 실행 전 임대 충돌은 메모리 rollback 뒤 1초 간격, 한 구간 최대 301초 동안
해제·만료를 기다립니다. flush/finalize의 ownership 상실은 재시도하지 않습니다.
종료 시에는 rollback으로 journal에서 사라진 자기 owner의 queue 임대도 DB에서
해제합니다. 다른 owner와 queue 내용/revision은 보존합니다. 대기 중에도 매 loop의
운영자 pause/stop과 daemon gate를 통과하며 상한을 넘으면 기존 오류 정지를 유지합니다.
Windows의 저장·복원으로 guest 이벤트가 없을 때를 위한 선택적 boot identity 확인은
[Windows Node.js 에이전트 설치와 Linux 연결](./developer/host-boot-recovery.md)을 따릅니다.
`TURN_HOST_BOOT_URL`과 private `TURN_HOST_BOOT_KEY_FILE`을 함께 설정하면 인증된
식별자 변경을 기존 lease 상실/재기동으로 처리합니다. 통신 실패는 기존 복구를
막지 않으며 운영자의 PAUSED/STOPPED를 자동 해제하지 않습니다.
Turn daemon의 DB persistence interactive transaction은 연결 획득 대기(`maxWait`)
10초와 callback 실행(`timeout`) 30초를 각각 제한합니다. Prisma 기본 연결 대기
2초는 일시적인 DB 지연에도 callback 시작 전 `P2028`로 서버를 PAUSED로 만들 수