vignette/docs/ops/public-runtime-watchdog.md

21 KiB

Public Runtime Watchdog

이 runbook은 Windows 재부팅, 업데이트, 프로세스 장애 뒤 Vignette 공개 런타임을 복구한다.

복구 소스 신뢰 계약

운영 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에 둔다.

사용자 업로드는 release root와 분리한 영속 절대 경로만 사용한다. 권장 기본값은 %LOCALAPPDATA%\Vignette\public-runtime\uploads다. 이 경로는 같은 Windows 호스트의 release 교체와 재부팅에는 유지되지만 호스트 장애를 견디는 외부 백업은 아니다. consumer(start/boot/watchdog/registrar)는 빈 경로를 만들지 않는다. 별도 initializer가 현재 DB의 정확한 /uploads/profile-avatars/ 참조를 copy-only·no-overwrite·SHA-256으로 검증해 만든 뒤에만 두 task action에 -UserUploadDir로 고정한다. 상대 경로, Git root와 겹치거나 이를 포함하는 경로, 기존 symlink/junction/reparse point를 통과하는 경로, 디렉터리가 아니거나 쓸 수 없는 경로는 프로세스 변경 전에 fail-closed한다. watchdog -CheckOnly는 실행 중 API 프로세스의 USER_UPLOAD_DIR까지 비교하므로, health가 정상이더라도 값이 없거나 다른 release-local 경로면 실패한다.

공개 static mount는 USER_UPLOAD_DIR/profile-avatars 하나뿐이다. 같은 legacy root의 multimodal-audio는 private storage이며 공개 migration 대상도 static 서빙 대상도 아니다. 보존 중인 private audio DB 참조가 하나라도 있으면 initializer는 별도 private migration 없이는 중단한다. migration manifest와 write-freeze sentinel은 공개 upload root와 Git root 밖의 절대 private state directory에만 둔다.

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))"
$userUploadDir = Join-Path $env:LOCALAPPDATA 'Vignette\public-runtime\uploads'
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 결과는 빈 값이어야 한다.

공개 아바타 저장소 초기화와 fresh cutover

이 단계가 task 설치보다 먼저다. 기존 API도 upload_write_freeze health 계약을 지원해야 한다. initializer는 freeze를 CreateNew로 게시하고 기존 API의 write lease가 0이 될 때까지 기다린 뒤, caller가 명시한 3개 source root의 flat profile-avatars regular file 전체 union을 보존한다. 현재 승인 기준은 preserved 93개와 DB 참조 8개이며, 같은 URL을 여러 행이 참조하면 파일은 한 번 복사하고 reference count는 보존한다. 호출자는 사전 계산한 preserved object count와 privacy-safe path/content/size inventory SHA256을 함께 고정해야 한다. worker는 copy 전후 재스캔과 manifest v2 proof까지 그 pin을 재검증한다. 원본은 삭제·이동하지 않으며 대상 충돌, source 간 hash 충돌, 누락 1건, 경로 인코딩/중첩, active private audio가 있으면 중단한다.

$userUploadDir = Join-Path $env:LOCALAPPDATA 'Vignette\public-runtime\uploads'
$uploadStateDir = Join-Path $env:LOCALAPPDATA 'Vignette\public-runtime\private-state'
$uploadFreezePath = Join-Path $uploadStateDir 'avatar-cutover.freeze.json'
$legacyUploadRoots = @(
  'D:\exact-approved-upload-root-1'
  'D:\exact-approved-upload-root-2'
  'D:\exact-approved-upload-root-3'
)
$expectedPreservedInventorySha256 = '<approved lowercase SHA256>'
$initializer = Join-Path $releaseRoot 'scripts\initialize-public-runtime-upload-root.ps1'
$initJson = & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $initializer `
  -StableSourceRoot $releaseRoot `
  -UserUploadDir $userUploadDir `
  -ManifestStateDir $uploadStateDir `
  -UserUploadWriteFreezePath $uploadFreezePath `
  -ExpectedReferenceCount 8 `
  -ExpectedPreservedObjectCount 93 `
  -ExpectedPreservedInventorySha256 $expectedPreservedInventorySha256 `
  -SourceUploadDir $legacyUploadRoots
if ($LASTEXITCODE -ne 0) { throw 'avatar storage initialization failed' }
$init = $initJson | ConvertFrom-Json
$uploadManifestSha = [string]$init.manifest_sha256
$uploadManifestPath = Join-Path $uploadStateDir "public-avatar-upload-$uploadManifestSha.json"
if ((Get-FileHash -LiteralPath $uploadManifestPath -Algorithm SHA256).Hash.ToLowerInvariant() -ne $uploadManifestSha) {
  throw 'private migration manifest hash mismatch'
}

initializer 성공 시 freeze는 의도적으로 남는다. 이어지는 -RequireFreshPublicProvenance cutover는 old API가 active+valid+drained freeze를 증명한 뒤 tunnel을 먼저 닫고 API를 교체한다. frozen 새 API와 새 tunnel의 local/public GET, listener PID/cwd/env, receipt를 검증한 뒤에만 소유 token과 일치하는 sentinel을 지우고 쓰기를 재개한다. 쓰기 재개 전 실패는 prior API/tunnel과 write availability를 복원한다. 쓰기 재개 뒤에는 old upload root로 자동 rollback하지 않는다.

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 `
  -UserUploadDir $userUploadDir `
  -UserUploadManifestPath $uploadManifestPath `
  -ExpectedUserUploadManifestSha256 $uploadManifestSha `
  -UserUploadWriteFreezePath $uploadFreezePath
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 `
  -UserUploadDir $userUploadDir `
  -UserUploadManifestPath $uploadManifestPath `
  -ExpectedUserUploadManifestSha256 $uploadManifestSha `
  -UserUploadWriteFreezePath $uploadFreezePath `
  -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',
    '-UserUploadDir',
    '-UserUploadManifestPath',
    '-ExpectedUserUploadManifestSha256',
    '-UserUploadWriteFreezePath'
  )
  VignettePublicRuntimeWatchdog = @(
    '-StableSourceRoot',
    '-ExpectedSourceCommit',
    '-ExpectedSourceTree',
    '-ExpectedWatchdogSha256',
    '-ExpectedStartScriptSha256',
    '-UserUploadDir',
    '-UserUploadManifestPath',
    '-ExpectedUserUploadManifestSha256',
    '-UserUploadWriteFreezePath'
  )
}

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 누락"
  }
  if ($action.Arguments.IndexOf($userUploadDir, [StringComparison]::OrdinalIgnoreCase) -lt 0) {
    throw "$taskName user upload root 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에서 확인한다.

PowerShell 5.1 web build 종료코드 경계

Windows PowerShell 5.1의 Start-Process -PassThru가 반환한 System.Diagnostics.Process는 process handle을 열기 전에 timed WaitForExit(milliseconds)를 호출하면 성공한 자식 프로세스도 ExitCode=$null로 남을 수 있다. start-public-runtime.ps1은 web build 직후 $null = $build.Handle로 handle을 먼저 확보하고, bounded wait 뒤 Refresh()·null guard·nonzero guard 순서로 판정한다. null을 0으로 간주하거나 build를 무조건 재시도하지 않는다.

계약 검증은 실제 Windows PowerShell 5.1에서 성공 프로세스의 종료코드를 읽는 probe를 포함한다.

py -3.11 -B -X utf8 -m pytest -p no:cacheprovider scripts\test_start_public_runtime_contract.py -q

2026-08-29 기준 32 passed이며, detached-clean 44b7835c…·tree 06133249…에 boot/watchdog을 같은 핀으로 재등록한 뒤 5174 자동복구·HTTP 200, LastTaskResult=0, failcount 0을 확인했다. 실제 Windows 재부팅 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이 수행한다.

Docker Desktop ERROR 1920 stale AF_UNIX socket 복구

Docker Desktop 백엔드 로그 또는 %LOCALAPPDATA%\Docker\backend.error.json에 아래 경로의 remove ... The file cannot be accessed by the system(ERROR 1920)이 보이면 com.docker.service나 PostgreSQL volume 문제가 아니다. 비정상 종료 뒤 남은 0바이트 AF_UNIX reparse socket 때문에 백엔드가 startup crash-loop한 것이다.

  • %LOCALAPPDATA%\Docker\run\dockerInference
  • %LOCALAPPDATA%\docker-secrets-engine\engine.sock

Docker upstream의 desktop-feedback #531#536에 같은 결함과 workaround가 기록돼 있다. 개별 socket은 Remove-Item, fsutil, 파일 rename으로도 ERROR 1920이 날 수 있으므로 삭제·factory reset·WSL unregister를 하지 않는다. Docker Desktop과 Docker CLI만 완전히 종료한 뒤 두 부모 디렉터리를 복구 가능한 timestamp 백업명으로 옮긴다.

$stamp = Get-Date -Format 'yyyyMMdd-HHmmss'
$run = 'C:\Users\encep\AppData\Local\Docker\run'
$secrets = 'C:\Users\encep\AppData\Local\docker-secrets-engine'
if ((Resolve-Path -LiteralPath $run).Path -ne $run) { throw 'run 경로 불일치' }
if ((Resolve-Path -LiteralPath $secrets).Path -ne $secrets) { throw 'secrets 경로 불일치' }
$runBackup = Join-Path (Split-Path -Parent $run) ("run.stale-$stamp")
$secretsBackup = Join-Path (Split-Path -Parent $secrets) ("docker-secrets-engine.stale-$stamp")
if (Test-Path -LiteralPath $runBackup) { throw 'run 백업명 충돌' }
if (Test-Path -LiteralPath $secretsBackup) { throw 'secrets 백업명 충돌' }
Move-Item -LiteralPath $run -Destination $runBackup
Move-Item -LiteralPath $secrets -Destination $secretsBackup

Docker Desktop 일반 사용자 재기동 뒤 docker version의 Linux server 응답을 확인한다. DB는 docker inspect vignette-dev-db로 exact container와 recovered named volume이 존재함을 먼저 확인한 경우에만 docker start vignette-dev-db를 실행한다. 새 container/volume 생성, 기존 volume 교체, compose down -v는 금지다. DB healthy와 55432 listener가 닫힌 뒤에만 아래 stable-root API-only 복구로 이어간다. WSL2 Linux engine에서 AlwaysRunService=false이면 com.docker.service가 stopped인 사실만으로 장애 원인이나 복구 완료를 판정하지 않는다.

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 `
  -UserUploadDir $userUploadDir `
  -UserUploadManifestPath $uploadManifestPath `
  -ExpectedUserUploadManifestSha256 $uploadManifestSha `
  -UserUploadWriteFreezePath $uploadFreezePath `
  -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 `
  -UserUploadDir $userUploadDir `
  -UserUploadManifestPath $uploadManifestPath `
  -ExpectedUserUploadManifestSha256 $uploadManifestSha `
  -UserUploadWriteFreezePath $uploadFreezePath `
  -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, secret이 아닌 resolved user_upload_root, migration manifest SHA, 초기/current reference count와 privacy-safe digest, write-freeze path hash, 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 `
  -UserUploadDir $userUploadDir `
  -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