재부팅 뒤 워치독이 5분마다 콘솔 창에 실패만 뿌리던 문제의 원인 세 가지를 고친다. - 워치독이 DB 다운을 감지하고 직접 복구한다. postgres(vignette-dev-db) 기동은 boot 담당이라 start-public-runtime.ps1 재호출로는 절대 복구되지 않았고, 그 결과 워치독은 고칠 수 없는 대상에 start를 무한 재시도하며 실패만 기록했다. db를 health check 항목에 넣고, 재시작 전에 컨테이너를 되살리며, 복구 실패 시에는 runtime 재시작을 시도하지 않고 종료한다. - 작업 액션을 wscript 런처(watch-public-runtime-task.vbs) 경유로 등록한다. powershell.exe를 직접 등록하면 -WindowStyle Hidden이어도 conhost 창이 매 실행 번쩍이고, 5분 주기에서는 그것이 곧 화면을 가리는 창이 된다. 런처는 pin 인자를 해석하지 않고 전달만 하며 provenance 검증은 기존대로 watchdog이 수행한다. - boot이 web preview 상태를 보고 -SkipWebRestart를 조건부로 붙인다. 무조건 스킵하면 재부팅 직후처럼 vite가 죽은 상태에서 boot 경로로는 web이 영영 복구되지 않았다. hidden trigger는 액션이 wscript 런처를 거치는지 함께 검증하도록 맞췄다.
240 lines
12 KiB
Markdown
240 lines
12 KiB
Markdown
# Public Runtime Watchdog
|
|
|
|
이 runbook은 Windows 재부팅, 업데이트, 프로세스 장애 뒤 Vignette 공개 런타임을 복구한다.
|
|
|
|
- engine gateway: http://127.0.0.1:9099
|
|
- prod API: http://127.0.0.1:8001
|
|
- web preview: http://127.0.0.1:5174
|
|
- Cloudflare tunnel: https://api-vignette.chanpaca.net
|
|
|
|
## 복구 소스 신뢰 계약
|
|
|
|
운영 task는 개발 중인 shared branch worktree를 실행하지 않는다. 아래 조건을 모두 만족하는 별도 release
|
|
worktree만 허용한다.
|
|
|
|
- 승인된 commit을 가리키는 detached HEAD
|
|
- tracked/untracked non-ignored 변경 0
|
|
- 설치 시 기록한 Git commit SHA와 tree SHA 일치
|
|
- watchdog 또는 boot script SHA-256과 start-public-runtime.ps1 SHA-256 일치
|
|
- task action의 working directory와 실행 script가 동일 release root
|
|
|
|
watchdog은 이 증거를 health probe와 failcount 기록보다 먼저 다시 확인한다. boot recovery는 Docker, DB,
|
|
프로세스 mutation보다 먼저 확인한다. 하나라도 달라지면 현재 운영 프로세스를 유지하고 nonzero로 종료한다.
|
|
|
|
API secret은 release root의 apps/api/.env에 두되 task 인자에는 넣지 않는다. 이 파일과 web node_modules,
|
|
runtime log는 Git ignore 대상이다. Cloudflared와 Claude CLI credential은 현재 Windows 사용자 profile에 둔다.
|
|
|
|
## Stable Release 준비
|
|
|
|
아래 작업은 승인된 clean commit이 생긴 뒤 단일 public mutation owner가 수행한다. 기존 release root를
|
|
덮어쓰지 않는다.
|
|
|
|
$ErrorActionPreference = 'Stop'
|
|
$repoRoot = 'D:\workspace\vignette'
|
|
$commit = (& git.exe -C $repoRoot rev-parse --verify HEAD).Trim()
|
|
if ($LASTEXITCODE -ne 0) { throw 'HEAD 조회 실패' }
|
|
$releaseRoot = "D:\workspace\vignette-public-runtime-$($commit.Substring(0, 12))"
|
|
if (Test-Path -LiteralPath $releaseRoot) { throw "release root already exists: $releaseRoot" }
|
|
|
|
& git.exe -C $repoRoot worktree add --detach $releaseRoot $commit
|
|
if ($LASTEXITCODE -ne 0) { throw 'detached release worktree 생성 실패' }
|
|
|
|
Copy-Item -LiteralPath (Join-Path $repoRoot 'apps\api\.env') `
|
|
-Destination (Join-Path $releaseRoot 'apps\api\.env')
|
|
Push-Location (Join-Path $releaseRoot 'apps\web')
|
|
& npm.cmd ci
|
|
if ($LASTEXITCODE -ne 0) { throw 'release web npm ci 실패' }
|
|
Pop-Location
|
|
|
|
$dirty = @(& git.exe -C $releaseRoot status --porcelain=v1 --untracked-files=normal)
|
|
if ($LASTEXITCODE -ne 0 -or $dirty.Count -ne 0) {
|
|
throw "release source가 clean하지 않음: $($dirty -join '; ')"
|
|
}
|
|
|
|
apps/api/.env의 내용을 console이나 evidence에 출력하지 않는다. 새 root에 node_modules와 .env를 준비한 뒤에도
|
|
위 Git status 결과는 빈 값이어야 한다.
|
|
|
|
## Task 설치 또는 승격
|
|
|
|
두 registrar 자체도 동일 stable release root에서 실행해야 한다. 다른 worktree의 registrar로 target만
|
|
바꾸는 호출은 거부된다.
|
|
|
|
$bootRegistrar = Join-Path $releaseRoot 'scripts\register-boot-task.ps1'
|
|
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $bootRegistrar `
|
|
-StableSourceRoot $releaseRoot
|
|
if ($LASTEXITCODE -ne 0) { throw 'boot task 등록 실패' }
|
|
|
|
$watchdogInstaller = Join-Path $releaseRoot 'scripts\install-public-runtime-task.ps1'
|
|
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $watchdogInstaller `
|
|
-StableSourceRoot $releaseRoot `
|
|
-IntervalMinutes 5
|
|
if ($LASTEXITCODE -ne 0) { throw 'watchdog task 등록 실패' }
|
|
|
|
task 이름과 역할:
|
|
|
|
- VignettePublicRuntime: 사용자 로그온 시 Docker, PostgreSQL, runtime 복구
|
|
- VignettePublicRuntimeWatchdog: 사용자 로그온 및 5분 반복 health/recovery
|
|
|
|
둘 다 현재 사용자의 Interactive/Limited task다. 사용자 로그인 전 headless boot가 필요하면 별도
|
|
operator-managed service account가 필요하며 credential을 script나 task arguments에 넣지 않는다.
|
|
|
|
### 등록 직후 source pin 검증
|
|
|
|
RunNow 전에 action을 읽어 두 task가 같은 release root와 commit을 가리키는지 확인한다.
|
|
|
|
$requirements = @{
|
|
VignettePublicRuntime = @(
|
|
'-StableSourceRoot',
|
|
'-ExpectedSourceCommit',
|
|
'-ExpectedSourceTree',
|
|
'-ExpectedBootScriptSha256',
|
|
'-ExpectedStartScriptSha256'
|
|
)
|
|
VignettePublicRuntimeWatchdog = @(
|
|
'-StableSourceRoot',
|
|
'-ExpectedSourceCommit',
|
|
'-ExpectedSourceTree',
|
|
'-ExpectedWatchdogSha256',
|
|
'-ExpectedStartScriptSha256'
|
|
)
|
|
}
|
|
|
|
foreach ($taskName in $requirements.Keys) {
|
|
$task = Get-ScheduledTask -TaskName $taskName -ErrorAction Stop
|
|
$action = @($task.Actions)[0]
|
|
if ($action.WorkingDirectory -ne $releaseRoot) {
|
|
throw "$taskName working directory drift: $($action.WorkingDirectory)"
|
|
}
|
|
if ($action.Arguments.IndexOf($releaseRoot, [StringComparison]::OrdinalIgnoreCase) -lt 0) {
|
|
throw "$taskName release root pin 누락"
|
|
}
|
|
if ($action.Arguments.IndexOf($commit, [StringComparison]::OrdinalIgnoreCase) -lt 0) {
|
|
throw "$taskName commit pin 누락"
|
|
}
|
|
foreach ($marker in $requirements[$taskName]) {
|
|
if ($action.Arguments.IndexOf($marker, [StringComparison]::Ordinal) -lt 0) {
|
|
throw "$taskName action pin 누락: $marker"
|
|
}
|
|
}
|
|
}
|
|
|
|
검증 뒤 watchdog만 명시적으로 실행하고 완료 상태를 확인한다.
|
|
|
|
Start-ScheduledTask -TaskName VignettePublicRuntimeWatchdog
|
|
Get-ScheduledTaskInfo -TaskName VignettePublicRuntimeWatchdog
|
|
|
|
LastTaskResult=0과 stable release root의 public-runtime-watchdog.failcount=0을 확인한다. health만 정상이라고
|
|
새 source 배포가 완료된 것은 아니다. 공개 API process cwd, Git commit, OpenAPI, auth, voice provider/model,
|
|
실제 session smoke까지 별도 배포 gate에서 확인한다.
|
|
|
|
## 숨김 수동 Trigger
|
|
|
|
watch-public-runtime-hidden.vbs는 source script를 직접 실행하지 않는다. 등록된 watchdog task action이
|
|
wscript 런처(watch-public-runtime-task.vbs)를 거치고 StableSourceRoot, commit, tree, watchdog SHA,
|
|
start SHA marker가 모두 있으며 legacy Workspace action이 아님을 검사한 뒤 Start-ScheduledTask만 호출한다.
|
|
|
|
cscript.exe //nologo scripts\watch-public-runtime-hidden.vbs
|
|
|
|
task가 아직 legacy shared-worktree action이거나 powershell.exe를 직접 실행하도록 등록돼 있으면 VBS도 fail-closed한다.
|
|
|
|
## Task Action이 wscript 런처를 거치는 이유
|
|
|
|
install-public-runtime-task.ps1은 액션을 `wscript.exe "<root>\scripts\watch-public-runtime-task.vbs" -File ...`로
|
|
등록한다. powershell.exe를 액션으로 직접 등록하면 `-WindowStyle Hidden`을 붙여도 conhost 창이 실행 순간
|
|
번쩍이고, 5분 주기 watchdog에서는 그것이 곧 "5분마다 화면에 뜨는 콘솔 창"이 된다(2026-08-08/09/12 세 번 재발).
|
|
런처는 pin 인자를 해석하지 않고 그대로 전달만 하며, provenance 검증은 watch-public-runtime.ps1이 수행한다.
|
|
|
|
## Manual Source Recovery
|
|
|
|
운영 code를 강제로 교체해야 할 때도 shared worktree의 start-public-runtime.ps1을 실행하지 않는다.
|
|
위 pin 검증을 끝낸 release root의 script만 사용한다.
|
|
|
|
$startScript = Join-Path $releaseRoot 'scripts\start-public-runtime.ps1'
|
|
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $startScript `
|
|
-Workspace $releaseRoot `
|
|
-ForceApiRestart `
|
|
-SkipEngineRestart `
|
|
-SkipWebRestart `
|
|
-SkipCloudflaredRestart
|
|
if ($LASTEXITCODE -ne 0) { throw 'public API 교체 실패' }
|
|
|
|
start-public-runtime.ps1은 engine, API, web, tunnel을 분리해 이미 healthy인 표면을 유지한다. local Whisper와
|
|
MeloTTS exact readiness가 닫히기 전에는 API restart gate를 통과하지 않는다.
|
|
|
|
위 명령은 routine API-only 복구라 cloudflared를 유지하며 **G7 fresh topology 증거를 만들지 않는다**. G7 공개
|
|
승격에서는 기존 API/cloudflared PID를 재사용하지 않고 아래 opt-in 계약을 사용한다. 실행 전에 detached-clean
|
|
commit/tree와 Python/cloudflared/config SHA를 read-only로 고정하고, config ingress가 이미 exact public topology인지
|
|
확인한다. 이 모드는 `-ForceApiRestart`가 필수이고 `-SkipCloudflaredRestart`를 허용하지 않는다.
|
|
|
|
$python = 'C:\Users\encep\AppData\Local\Programs\Python\Python311\python.exe'
|
|
$cloudflared = 'C:\Users\encep\AppData\Local\Microsoft\WinGet\Links\cloudflared.exe'
|
|
$cloudflaredConfig = 'C:\Users\encep\.cloudflared\vignette-config.yml'
|
|
$commit = (& git.exe -C $releaseRoot rev-parse --verify HEAD).Trim()
|
|
$tree = (& git.exe -C $releaseRoot rev-parse --verify 'HEAD^{tree}').Trim()
|
|
$pythonSha = (Get-FileHash -LiteralPath $python -Algorithm SHA256).Hash.ToLowerInvariant()
|
|
$cloudflaredSha = (Get-FileHash -LiteralPath $cloudflared -Algorithm SHA256).Hash.ToLowerInvariant()
|
|
$configSha = (Get-FileHash -LiteralPath $cloudflaredConfig -Algorithm SHA256).Hash.ToLowerInvariant()
|
|
$receipt = Join-Path (Join-Path 'D:\workspace\vignette-runtime-evidence' $commit) 'public-runtime-launch-provenance.json'
|
|
|
|
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $startScript `
|
|
-Workspace $releaseRoot `
|
|
-ForceApiRestart `
|
|
-SkipEngineRestart `
|
|
-SkipWebRestart `
|
|
-RequireFreshPublicProvenance `
|
|
-ExpectedSourceCommit $commit `
|
|
-ExpectedSourceTree $tree `
|
|
-ExpectedPythonSha256 $pythonSha `
|
|
-ExpectedCloudflaredSha256 $cloudflaredSha `
|
|
-ExpectedCloudflaredConfigSha256 $configSha `
|
|
-RuntimeProvenancePath $receipt
|
|
if ($LASTEXITCODE -ne 0) { throw 'fresh public provenance 승격 실패' }
|
|
|
|
receipt에는 raw command line·config contents를 넣지 않고 PID/start/executable·command SHA/실제 cwd와 topology 입력만
|
|
남긴다. 이 receipt의 PID와 pin을 `run-g7-external-proof-window.py --topology-mode windows-host`에 그대로 전달하고,
|
|
공개 health·auth·OpenAPI·local provider ready를 확인하기 전에는 task action을 새 root로 재등록하지 않는다.
|
|
|
|
## Read-only CheckOnly
|
|
|
|
watchdog script를 직접 CheckOnly로 실행할 때도 task와 같은 pin을 모두 전달해야 한다.
|
|
|
|
$watchScript = Join-Path $releaseRoot 'scripts\watch-public-runtime.ps1'
|
|
$startScript = Join-Path $releaseRoot 'scripts\start-public-runtime.ps1'
|
|
$commit = (& git.exe -C $releaseRoot rev-parse --verify HEAD).Trim()
|
|
$tree = (& git.exe -C $releaseRoot rev-parse --verify 'HEAD^{tree}').Trim()
|
|
$watchSha = (Get-FileHash -LiteralPath $watchScript -Algorithm SHA256).Hash.ToLowerInvariant()
|
|
$startSha = (Get-FileHash -LiteralPath $startScript -Algorithm SHA256).Hash.ToLowerInvariant()
|
|
|
|
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $watchScript `
|
|
-StableSourceRoot $releaseRoot `
|
|
-ExpectedSourceCommit $commit `
|
|
-ExpectedSourceTree $tree `
|
|
-ExpectedWatchdogSha256 $watchSha `
|
|
-ExpectedStartScriptSha256 $startSha `
|
|
-CheckOnly
|
|
|
|
추가 public host는 DNS와 routing이 실제로 열린 뒤 installer의
|
|
AdditionalPublicHealthUrls에 명시한다. 아직 열리지 않은 future host를 기본 probe에 넣어 restart loop를 만들지 않는다.
|
|
|
|
## Health와 로그
|
|
|
|
Invoke-RestMethod http://127.0.0.1:9099/health
|
|
Invoke-RestMethod http://127.0.0.1:8001/health
|
|
Invoke-RestMethod https://api-vignette.chanpaca.net/health
|
|
Get-ScheduledTaskInfo -TaskName VignettePublicRuntimeWatchdog
|
|
Get-Content -LiteralPath (Join-Path $releaseRoot 'public-runtime-watchdog.log') -Tail 50
|
|
Get-Content -LiteralPath (Join-Path $releaseRoot 'apps\api\engine.public.err.log') -Tail 50
|
|
Get-Content -LiteralPath (Join-Path $releaseRoot 'apps\api\api.public.err.log') -Tail 50
|
|
|
|
engine /ready는 실제 Claude generation을 수행할 수 있어 소량의 budget을 사용한다. shared secret으로 기동한
|
|
gateway는 token header가 필요하다.
|
|
|
|
엔진 장애 중에도 API health가 environment=prod, db=true이면 관리자와 인증 제어면은 유지한다. source
|
|
provenance failure는 재시작으로 우회하지 말고 task action과 stable release를 다시 승격한다.
|
|
|
|
## Task 제거
|
|
|
|
제거는 명시적 운영 결정으로만 수행한다.
|
|
|
|
Unregister-ScheduledTask -TaskName VignettePublicRuntimeWatchdog -Confirm:$false
|
|
Unregister-ScheduledTask -TaskName VignettePublicRuntime -Confirm:$false
|