- src/dist 산출물 분리 원칙 정리(.gitignore, .gitattributes) - 루트 및 주요 폴더(config/scripts/prompts/tests/src, 런타임 폴더 5종)에 안내용 README.md 추가 - CHANGELOG.md, LICENSE, docs/ops/05-release-and-versioning.md 추가 - docs/README.md 문서 지도 갱신
2379 lines
121 KiB
Markdown
2379 lines
121 KiB
Markdown
# 운영 런북 — 06:00 스케줄링 · 재부팅 내성 · 워치독
|
||
|
||
> **이 문서의 역할**: DMF Crawler 를 Windows 11 PC 에 올려 **매일 06:00 에 사람 없이 실행**시키고, 재부팅·절전·Windows Update·프로세스 강제 종료를 견디게 만들고, 죽었을 때 **사람이 15분 안에 알아채고 복구**하게 만드는 **실행 런북**이다. 이 문서의 명령은 복사·붙여넣기하면 그대로 동작해야 한다. 설계 근거는 `docs/design/01-architecture.md`(정본)에, 스케줄러 옵션의 원문 근거는 `docs/research/08-windows-scheduling-and-resilience.md`에 있다. **충돌하면 아키텍처 정본이 이긴다.**
|
||
|
||
---
|
||
|
||
## 0. 한눈에 보기
|
||
|
||
이 문서가 확정하는 것:
|
||
|
||
- **실행 컨테이너는 Windows 작업 스케줄러다.** Windows 서비스·WSL2 cron·Docker Desktop 은 모두 기각한다. 결정적 이유는 성능이 아니라 **`agy` OAuth 토큰이 `~/.gemini/antigravity-cli/antigravity-oauth-token` 평문 파일로 사용자 프로필 안에 있다**는 사실이다 — 배치는 **반드시 그 사용자 계정 컨텍스트**에서 돌아야 한다.
|
||
- **작업은 3종이다**: `DMF_Crawler_Daily`(배치, S4U, 06:00 + 부팅), `DMF_Crawler_Agent`(알림·워치독, Interactive, 로그온 + 15분 반복), `DMF_Crawler_AgyUpdate`(주간 `agy update`, 일요일 14:00). 전부 `\DMF_Crawler\` 폴더에 등록한다.
|
||
- **워치독은 4번째 작업이 아니다.** heartbeat 신선도 판정은 `DMF_Crawler_Agent` 안(`watchdog.py`)에서 돈다. 알림을 띄울 수 있는 세션에서 판정해야 "판정은 됐는데 화면에 못 띄운다"는 공백이 사라지기 때문이다(아키텍처 ADR-10·기각 기록).
|
||
- **기본값이 우리를 배신하는 4개를 반드시 뒤집는다**: `DisallowStartIfOnBatteries`(기본 true→false), `StopIfGoingOnBatteries`(true→false), `StartWhenAvailable`(false→true), `WakeToRun`(false→true). 손대지 않으면 노트북에서 06:00 에 **안 돈다**.
|
||
- **`AtStartup` 트리거는 보조 수단이지 안전망이 아니다.** Fast Startup(빠른 시작) 때문에 "종료 → 켜기"는 실제로는 커널 세션 최대 절전 복귀라서 부팅 트리거가 안 뜬다. 진짜 캐치업 안전망은 **`StartWhenAvailable`** 이다.
|
||
- **`-LogonType S4U` 가 기본이지만, DPAPI 복호화가 S4U 세션에서 실패하면 즉시 `Password` 로 전환한다.** 이 분기는 추측하지 말고 §2.8 의 **프로브 절차로 실측**한다. 판정이 갈리는 유일한 설치 시점 결정이다.
|
||
- **중복 실행 방어는 3중이다**: 코드 idempotency 가드(오늘 이미 SUCCESS면 종료 0) → `state\run.lock` 파일 락(`msvcrt`) → 스케줄러 `MultipleInstances=IgnoreNew`. 스케줄러 설정만으로는 "06:00 실행 후 재부팅 캐치업" 같은 **순차 재실행**을 못 막는다.
|
||
- **한국은 DST 가 없다(KST=UTC+9 고정).** 작업 트리거에 "표준 시간대에 맞춰 동기화(Synchronize across time zones)"를 **켜지 않는다**. DST 전환 버그의 사정권 밖이다.
|
||
- **Windows Update 자동 재시작은 활성 시간(Active hours) 05:00–23:00 으로 06:00 을 보호한다.** 최대 범위 18시간 제한 안에 들어간다.
|
||
- **로그는 실행 1회당 디렉터리 하나**(`logs\run_<run_id>\`)로 떨어진다. 조사 시작점이 "가장 최근 디렉터리를 연다" 하나로 고정된다.
|
||
|
||
---
|
||
|
||
## 1. 스케줄링 방식 확정
|
||
|
||
### 1.1 워크로드 성질
|
||
|
||
| 항목 | 값 | 스케줄러 선택에 주는 함의 |
|
||
|---|---|---|
|
||
| 실행 빈도 | 하루 1회 06:00 (KST) | 상주 프로세스 불필요 |
|
||
| 실행 시간 | 정상 2~6분, 상한 30분(`ExecutionTimeLimit`) | 타임아웃 여유 필요 |
|
||
| 네트워크 | 공개 Open API 로의 **아웃바운드 HTTPS** 만 | 네트워크 드라이브·UNC·도메인 인증 불필요 |
|
||
| 자격증명 | ① 공공데이터포털 API 키(DPAPI 사용자 범위 암호화) ② `agy` OAuth 토큰(사용자 프로필 평문 파일) | **사용자 프로필이 살아 있어야 한다** |
|
||
| 산출물 | `reports\*.xlsx`, `data\dmf.sqlite3` | 로컬 디스크 쓰기 |
|
||
| 알림 | 실패 시 토스트·강제 모달 | **데스크톱이 있는 세션**이 필요 |
|
||
| 재부팅 내성 | 필수 | 부팅 트리거 + 놓친 작업 캐치업 |
|
||
|
||
### 1.2 후보 4종 평가
|
||
|
||
| 방식 | agy 토큰 접근 | 부팅 내성 | 놓친 실행 캐치업 | 알림 표시 | 설치·형상관리 | 판정 |
|
||
|---|---|---|---|---|---|---|
|
||
| **작업 스케줄러** | ✅ 사용자 계정으로 실행 가능 | ✅ `BootTrigger` | ✅ `StartWhenAvailable` 내장 | △ 배치는 불가 → 별도 Interactive 작업으로 분리 | ✅ XML export/import | **채택** |
|
||
| Windows 서비스(NSSM/WinSW/pywin32) | ⚠️ SYSTEM 이면 **불가**. 사용자 계정으로 돌리면 가능하나 이점 소멸 | ✅ | ❌ 직접 구현 | ❌ **Session 0 격리** — UI 절대 불가 | ❌ 관리자 설치·제거 | 기각 |
|
||
| WSL2 cron | ❌ 토큰이 Windows 프로필에 있음(교차 접근 지저분) | ❌ 배포판이 떠 있어야 cron 이 돈다 | ❌ | ❌ | ❌ | 기각 |
|
||
| Docker Desktop + cron | ❌ 토큰·DPAPI 모두 컨테이너 밖 | ❌ "Start Docker Desktop when you sign in" — **로그인 없이는 안 뜬다** | ❌ | ❌ | △ | 기각 |
|
||
|
||
### 1.3 결정과 그 근거 세 줄
|
||
|
||
1. **`agy` 토큰이 사용자 프로필 평문 파일**이라는 실측 사실(agy SSOT §4.2)이 SYSTEM 계정을 원천 배제한다. 서비스의 유일한 장점(SYSTEM 무인성)이 여기서는 장점이 아니라 **결함**이다.
|
||
2. 하루 1회 5분짜리 배치를 위해 24시간 상주 프로세스를 띄우면 "그 프로세스는 누가 감시하나"라는 감시 대상이 하나 더 생긴다. 작업 스케줄러는 OS 가 이미 감시한다.
|
||
3. 서비스는 Session 0 격리 때문에 **요구 R7.2(강제 창)를 구조적으로 만족할 수 없다.** 작업 스케줄러는 Interactive 로그온 타입 작업으로 이 문제를 정면 해결한다.
|
||
|
||
### 1.4 작업 3종 세트
|
||
|
||
```
|
||
┌──────────────────────────────────────────────────────────────────────────┐
|
||
│ ① \DMF_Crawler\DMF_Crawler_Daily [배치 · UI 없음] │
|
||
│ 트리거 : 매일 06:00 (RandomDelay PT4M) + 시스템 시작 (Delay PT5M) │
|
||
│ 보안 : <도메인>\<사용자>, LogonType S4U(기본), RunLevel Highest │
|
||
│ 설정 : StartWhenAvailable / WakeToRun / 배터리 조건 해제 / │
|
||
│ RestartCount 3 · RestartInterval PT10M / │
|
||
│ ExecutionTimeLimit PT30M / MultipleInstances IgnoreNew │
|
||
│ 액션 : .venv\Scripts\python.exe -m dmf_crawler run --trigger scheduled│
|
||
│ 결과 : 성공 → state\heartbeat.json 갱신 │
|
||
│ 실패 → alerts 테이블 + state\alerts.json + 이벤트 로그 │
|
||
│ ★ 화면에는 아무것도 띄우지 않는다 (S4U 에는 데스크톱이 없다) │
|
||
└──────────────────────────────────────────────────────────────────────────┘
|
||
│ (파일·DB 를 통한 비동기 전달)
|
||
▼
|
||
┌──────────────────────────────────────────────────────────────────────────┐
|
||
│ ② \DMF_Crawler\DMF_Crawler_Agent [알림 · 워치독 · UI 소유자] │
|
||
│ 트리거 : 로그온 시 + 15분마다 무한 반복 │
|
||
│ 보안 : 동일 사용자, LogonType Interactive, RunLevel Limited │
|
||
│ 설정 : ExecutionTimeLimit PT10M / MultipleInstances IgnoreNew │
|
||
│ 액션 : .venv\Scripts\pythonw.exe -m dmf_crawler notify-pump --once │
|
||
│ 동작 : watchdog 판정 → WARN/INFO 토스트 → CRITICAL 복구 GUI(모달) │
|
||
└──────────────────────────────────────────────────────────────────────────┘
|
||
|
||
┌──────────────────────────────────────────────────────────────────────────┐
|
||
│ ③ \DMF_Crawler\DMF_Crawler_AgyUpdate [주간 유지보수] │
|
||
│ 트리거 : 매주 일요일 14:00 │
|
||
│ 보안 : 동일 사용자, LogonType S4U, RunLevel Limited │
|
||
│ 액션 : %LOCALAPPDATA%\agy\bin\agy.exe update │
|
||
│ 이유 : 배치 중 자동 업데이트가 바이너리를 교체하면 실행이 깨진다. │
|
||
│ 배치는 AGY_CLI_DISABLE_AUTO_UPDATE=true 로 자동 업데이트를 │
|
||
│ 끄고(코드에서 주입), 교체는 사람이 깨어 있는 시간에 몰아서 한다.│
|
||
└──────────────────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
**핵심 설계 원칙 한 줄**: *알림을 발생시키는 주체(배치)와 알림을 표시하는 주체(에이전트)를 분리한다.* 배치는 "알림 의도"만 남기고, 데스크톱을 가진 에이전트가 그것을 읽어 띄운다.
|
||
|
||
### 1.5 워치독을 별도 작업으로 만들지 않는 이유
|
||
|
||
`DMF_Crawler_Agent` 가 15분마다 로그온 세션에서 도는데, 그 안에서 heartbeat 판정을 함께 하면 **작업이 하나 줄고** "판정한 세션이 곧 표시 가능한 세션"이라는 성질이 공짜로 따라온다. 07:00 짜리 별도 워치독 작업을 두면 판정은 S4U 세션에서 되고 표시는 다른 세션에서 되어, 두 세션 사이에 또 큐를 놓아야 한다. 기각한다.
|
||
|
||
> **예외**: 사람이 며칠씩 로그오프해 두는 PC 라면 §5.8 의 자동 로그온 대안 또는 부록의 웹훅 확장을 검토하라. 기본 구성은 "다음 로그온 시 밀린 알림을 전부 표시"로 흡수한다(아키텍처 실패 경로 [F]).
|
||
|
||
---
|
||
|
||
## 2. 주 작업 등록 스크립트
|
||
|
||
### 2.1 파일 이름과 호출 관계
|
||
|
||
| 파일 | 역할 |
|
||
|---|---|
|
||
| `scripts\install_tasks.ps1` | **작업 3종 idempotent 등록.** 이 절의 전문 |
|
||
| `scripts\uninstall_tasks.ps1` | 작업 3종 제거 |
|
||
| `python -m dmf_crawler install-task --time 06:00 --user <계정>` | 위 스크립트를 `config.toml` 값으로 호출하는 얇은 래퍼 |
|
||
|
||
> 파일명은 **복수형 `install_tasks.ps1`** 이다(아키텍처 디렉터리 트리 §2). 작업이 3개이므로 단수형은 쓰지 않는다.
|
||
|
||
### 2.2 설정 키 → 스크립트 파라미터 매핑
|
||
|
||
PowerShell 은 TOML 을 파싱하지 못한다. 그래서 **`config.toml` 을 읽는 쪽은 Python CLI 이고, PS 스크립트는 파라미터만 받는다.** 스크립트의 기본값은 `config.toml` 기본값과 **정확히 같게** 유지한다.
|
||
|
||
| `config.toml` 키 | 스크립트 파라미터 | 기본값 |
|
||
|---|---|---|
|
||
| `schedule.daily_time` | `-Time` | `06:00` |
|
||
| `schedule.jitter_seconds` | `-JitterSeconds` | `240` |
|
||
| `schedule.startup_delay_minutes` | `-StartupDelayMinutes` | `5` |
|
||
| `schedule.execution_time_limit_minutes` | `-ExecutionTimeLimitMinutes` | `30` |
|
||
| `schedule.restart_count` | `-RestartCount` | `3` |
|
||
| `schedule.restart_interval_minutes` | `-RestartIntervalMinutes` | `10` |
|
||
| `schedule.agent_repeat_minutes` | `-AgentRepeatMinutes` | `15` |
|
||
| `schedule.agy_update_weekday` | `-AgyUpdateWeekday` | `Sunday` |
|
||
| `schedule.agy_update_time` | `-AgyUpdateTime` | `14:00` |
|
||
| — | `-User` | `$env:USERDOMAIN\$env:USERNAME` |
|
||
| — | `-LogonType` | `S4U` |
|
||
|
||
### 2.3 `scripts\install_tasks.ps1` 전문
|
||
|
||
```powershell
|
||
#Requires -Version 5.1
|
||
<#
|
||
.SYNOPSIS
|
||
DMF Crawler 작업 스케줄러 작업 3종(Daily / Agent / AgyUpdate)을 등록한다.
|
||
|
||
.DESCRIPTION
|
||
멱등(idempotent)하다. 같은 이름의 작업이 이미 있으면 지우고 다시 만든다.
|
||
관리자 권한 PowerShell 에서 실행해야 한다(RunLevel Highest 등록에 필요).
|
||
|
||
.EXAMPLE
|
||
powershell -ExecutionPolicy Bypass -File .\scripts\install_tasks.ps1
|
||
|
||
.EXAMPLE
|
||
# DPAPI 프로브 결과 S4U 가 실패했을 때
|
||
powershell -ExecutionPolicy Bypass -File .\scripts\install_tasks.ps1 -LogonType Password
|
||
|
||
.EXAMPLE
|
||
# 등록 직후 보안 컨텍스트 프로브까지 수행
|
||
powershell -ExecutionPolicy Bypass -File .\scripts\install_tasks.ps1 -Verify
|
||
#>
|
||
[CmdletBinding()]
|
||
param(
|
||
[string]$ProjectRoot = (Split-Path -Parent $PSScriptRoot),
|
||
[string]$TaskPath = '\DMF_Crawler\',
|
||
[string]$Time = '06:00',
|
||
[string]$User = "$env:USERDOMAIN\$env:USERNAME",
|
||
[ValidateSet('S4U', 'Password', 'Interactive')]
|
||
[string]$LogonType = 'S4U',
|
||
[System.Security.SecureString]$Password,
|
||
[ValidateRange(0, 300)]
|
||
[int]$JitterSeconds = 240,
|
||
[ValidateRange(0, 60)]
|
||
[int]$StartupDelayMinutes = 5,
|
||
[ValidateRange(5, 720)]
|
||
[int]$ExecutionTimeLimitMinutes = 30,
|
||
[ValidateRange(0, 255)]
|
||
[int]$RestartCount = 3,
|
||
[ValidateRange(1, 1440)]
|
||
[int]$RestartIntervalMinutes = 10,
|
||
[ValidateRange(1, 1440)]
|
||
[int]$AgentRepeatMinutes = 15,
|
||
[ValidateSet('Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday')]
|
||
[string]$AgyUpdateWeekday = 'Sunday',
|
||
[string]$AgyUpdateTime = '14:00',
|
||
[switch]$SkipAgyUpdateTask,
|
||
[switch]$Verify
|
||
)
|
||
|
||
Set-StrictMode -Version Latest
|
||
$ErrorActionPreference = 'Stop'
|
||
|
||
# ---------------------------------------------------------------- 유틸
|
||
function Write-Step { param([string]$Message) Write-Host "[install_tasks] $Message" }
|
||
function Write-Warn { param([string]$Message) Write-Host "[install_tasks] ! $Message" -ForegroundColor Yellow }
|
||
function Write-Good { param([string]$Message) Write-Host "[install_tasks] + $Message" -ForegroundColor Green }
|
||
|
||
function Assert-Administrator {
|
||
$id = [Security.Principal.WindowsIdentity]::GetCurrent()
|
||
$pr = [Security.Principal.WindowsPrincipal]::new($id)
|
||
if (-not $pr.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
|
||
throw '관리자 권한 PowerShell 에서 실행하세요. (시작 → PowerShell 우클릭 → 관리자 권한으로 실행)'
|
||
}
|
||
}
|
||
|
||
function ConvertTo-PlainText {
|
||
param([System.Security.SecureString]$Secure)
|
||
if (-not $Secure) { return $null }
|
||
$bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($Secure)
|
||
try { return [Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr) }
|
||
finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) }
|
||
}
|
||
|
||
function Get-TimeOfDay {
|
||
param([string]$Text, [string]$Label)
|
||
$parsed = [datetime]::MinValue
|
||
$ok = [datetime]::TryParseExact(
|
||
$Text, 'HH:mm',
|
||
[Globalization.CultureInfo]::InvariantCulture,
|
||
[Globalization.DateTimeStyles]::None,
|
||
[ref]$parsed)
|
||
if (-not $ok) { throw "$Label 형식이 잘못됐습니다: '$Text' (HH:mm 이어야 합니다)" }
|
||
return (Get-Date).Date.AddHours($parsed.Hour).AddMinutes($parsed.Minute)
|
||
}
|
||
|
||
# ---------------------------------------------------------------- 0. 사전 점검
|
||
Assert-Administrator
|
||
|
||
$ProjectRoot = (Resolve-Path -LiteralPath $ProjectRoot).Path
|
||
$PythonExe = Join-Path $ProjectRoot '.venv\Scripts\python.exe'
|
||
$PythonwExe = Join-Path $ProjectRoot '.venv\Scripts\pythonw.exe'
|
||
$AgyExe = Join-Path $env:LOCALAPPDATA 'agy\bin\agy.exe'
|
||
|
||
Write-Step "프로젝트 루트 : $ProjectRoot"
|
||
Write-Step "실행 계정 : $User (LogonType=$LogonType)"
|
||
|
||
foreach ($exe in @($PythonExe, $PythonwExe)) {
|
||
if (-not (Test-Path -LiteralPath $exe)) {
|
||
throw "가상환경 실행 파일이 없습니다: $exe`n → bootstrap.cmd 를 먼저 실행하세요."
|
||
}
|
||
}
|
||
if (-not (Test-Path -LiteralPath (Join-Path $ProjectRoot 'config\config.toml'))) {
|
||
Write-Warn 'config\config.toml 이 없습니다. 등록은 진행하지만 첫 실행은 종료 코드 2(BLOCKED)로 끝납니다.'
|
||
}
|
||
|
||
foreach ($dir in @('state', 'logs', 'reports', 'data')) {
|
||
$p = Join-Path $ProjectRoot $dir
|
||
if (-not (Test-Path -LiteralPath $p)) { New-Item -ItemType Directory -Path $p | Out-Null }
|
||
}
|
||
|
||
$plainPassword = $null
|
||
if ($LogonType -eq 'Password') {
|
||
if (-not $Password) {
|
||
$Password = Read-Host -AsSecureString "«$User» 계정의 Windows 로그인 암호"
|
||
}
|
||
$plainPassword = ConvertTo-PlainText -Secure $Password
|
||
if ([string]::IsNullOrEmpty($plainPassword)) { throw '암호가 비어 있습니다.' }
|
||
}
|
||
|
||
# ---------------------------------------------------------------- 1. 작업 기록(History) 채널 활성화
|
||
# 기본적으로 꺼져 있다. 꺼져 있으면 "기록" 탭이 비고 사후 진단이 불가능하다.
|
||
Write-Step '작업 스케줄러 Operational 로그 활성화'
|
||
& wevtutil.exe set-log 'Microsoft-Windows-TaskScheduler/Operational' /enabled:true /quiet
|
||
& wevtutil.exe set-log 'Microsoft-Windows-TaskScheduler/Operational' /maxsize:67108864
|
||
if ($LASTEXITCODE -ne 0) { Write-Warn "wevtutil 이 $LASTEXITCODE 로 끝났습니다. 기록 없이 진행합니다." }
|
||
|
||
# ---------------------------------------------------------------- 2. 공통 등록 함수
|
||
function Register-DmfTask {
|
||
param(
|
||
[Parameter(Mandatory)][string]$Name,
|
||
[Parameter(Mandatory)][string]$Path,
|
||
[Parameter(Mandatory)]$Action,
|
||
[Parameter(Mandatory)]$Trigger,
|
||
[Parameter(Mandatory)]$Settings,
|
||
[Parameter(Mandatory)]$Principal,
|
||
[Parameter(Mandatory)][string]$Description,
|
||
[string]$PlainPassword
|
||
)
|
||
$existing = Get-ScheduledTask -TaskName $Name -TaskPath $Path -ErrorAction SilentlyContinue
|
||
if ($existing) {
|
||
Write-Step "기존 작업 제거: $Path$Name"
|
||
Unregister-ScheduledTask -TaskName $Name -TaskPath $Path -Confirm:$false
|
||
}
|
||
|
||
$definition = New-ScheduledTask `
|
||
-Action $Action `
|
||
-Trigger $Trigger `
|
||
-Settings $Settings `
|
||
-Principal $Principal `
|
||
-Description $Description
|
||
|
||
if ($PlainPassword) {
|
||
Register-ScheduledTask -TaskName $Name -TaskPath $Path -InputObject $definition `
|
||
-User $Principal.UserId -Password $PlainPassword | Out-Null
|
||
} else {
|
||
Register-ScheduledTask -TaskName $Name -TaskPath $Path -InputObject $definition | Out-Null
|
||
}
|
||
Write-Good "등록 완료: $Path$Name"
|
||
}
|
||
|
||
# ---------------------------------------------------------------- 3. ① DMF_Crawler_Daily
|
||
Write-Step '① DMF_Crawler_Daily 구성'
|
||
|
||
$dailyAt = Get-TimeOfDay -Text $Time -Label 'schedule.daily_time'
|
||
|
||
$actionDaily = New-ScheduledTaskAction `
|
||
-Execute $PythonExe `
|
||
-Argument '-m dmf_crawler run --trigger scheduled' `
|
||
-WorkingDirectory $ProjectRoot
|
||
|
||
$trgDaily = New-ScheduledTaskTrigger -Daily -At $dailyAt `
|
||
-RandomDelay (New-TimeSpan -Seconds $JitterSeconds)
|
||
|
||
# AtStartup 트리거에는 -Delay 파라미터가 없다. CIM 인스턴스 속성을 직접 채운다.
|
||
$trgBoot = New-ScheduledTaskTrigger -AtStartup
|
||
$trgBoot.Delay = "PT${StartupDelayMinutes}M"
|
||
|
||
$setDaily = New-ScheduledTaskSettingsSet `
|
||
-AllowStartIfOnBatteries `
|
||
-DontStopIfGoingOnBatteries `
|
||
-StartWhenAvailable `
|
||
-WakeToRun `
|
||
-DontStopOnIdleEnd `
|
||
-RunOnlyIfNetworkAvailable `
|
||
-ExecutionTimeLimit (New-TimeSpan -Minutes $ExecutionTimeLimitMinutes) `
|
||
-RestartCount $RestartCount `
|
||
-RestartInterval (New-TimeSpan -Minutes $RestartIntervalMinutes) `
|
||
-MultipleInstances IgnoreNew `
|
||
-Priority 5 `
|
||
-Compatibility Win8
|
||
|
||
$prcDaily = New-ScheduledTaskPrincipal -UserId $User -LogonType $LogonType -RunLevel Highest
|
||
|
||
Register-DmfTask `
|
||
-Name 'DMF_Crawler_Daily' `
|
||
-Path $TaskPath `
|
||
-Action $actionDaily `
|
||
-Trigger @($trgDaily, $trgBoot) `
|
||
-Settings $setDaily `
|
||
-Principal $prcDaily `
|
||
-Description "DMF 일일 수집·비교·리포트 배치. 매일 $Time + 부팅 후 ${StartupDelayMinutes}분. UI 를 띄우지 않는다." `
|
||
-PlainPassword $plainPassword
|
||
|
||
# ---------------------------------------------------------------- 4. ② DMF_Crawler_Agent
|
||
Write-Step '② DMF_Crawler_Agent 구성'
|
||
|
||
$actionAgent = New-ScheduledTaskAction `
|
||
-Execute $PythonwExe `
|
||
-Argument '-m dmf_crawler notify-pump --once' `
|
||
-WorkingDirectory $ProjectRoot
|
||
|
||
# (a) 로그온 시. (b) 지금부터 15분마다 무한 반복.
|
||
$trgLogon = New-ScheduledTaskTrigger -AtLogOn -User $User
|
||
|
||
$repeatStart = (Get-Date).AddMinutes(2)
|
||
try {
|
||
$trgRepeat = New-ScheduledTaskTrigger -Once -At $repeatStart `
|
||
-RepetitionInterval (New-TimeSpan -Minutes $AgentRepeatMinutes) `
|
||
-RepetitionDuration ([TimeSpan]::MaxValue)
|
||
} catch {
|
||
# 일부 빌드에서 [TimeSpan]::MaxValue 가 거부된다. 10년으로 대체한다.
|
||
Write-Warn 'RepetitionDuration=MaxValue 거부됨 → 3650일로 대체'
|
||
$trgRepeat = New-ScheduledTaskTrigger -Once -At $repeatStart `
|
||
-RepetitionInterval (New-TimeSpan -Minutes $AgentRepeatMinutes) `
|
||
-RepetitionDuration (New-TimeSpan -Days 3650)
|
||
}
|
||
# 로그온 트리거에도 같은 반복을 붙여 둔다(로그온 이후에도 계속 돌게).
|
||
$trgLogon.Repetition = $trgRepeat.Repetition
|
||
|
||
$setAgent = New-ScheduledTaskSettingsSet `
|
||
-AllowStartIfOnBatteries `
|
||
-DontStopIfGoingOnBatteries `
|
||
-DontStopOnIdleEnd `
|
||
-ExecutionTimeLimit (New-TimeSpan -Minutes 10) `
|
||
-MultipleInstances IgnoreNew `
|
||
-Priority 7 `
|
||
-Compatibility Win8
|
||
|
||
# Interactive 는 로그온한 세션에서만 돈다 — 그것이 목적이다(UI 를 띄우는 유일한 작업).
|
||
$prcAgent = New-ScheduledTaskPrincipal -UserId $User -LogonType Interactive -RunLevel Limited
|
||
|
||
Register-DmfTask `
|
||
-Name 'DMF_Crawler_Agent' `
|
||
-Path $TaskPath `
|
||
-Action $actionAgent `
|
||
-Trigger @($trgLogon, $trgRepeat) `
|
||
-Settings $setAgent `
|
||
-Principal $prcAgent `
|
||
-Description "DMF 알림 에이전트. 로그온 시 + ${AgentRepeatMinutes}분마다 heartbeat 를 점검하고 밀린 알림을 표시한다."
|
||
|
||
# ---------------------------------------------------------------- 5. ③ DMF_Crawler_AgyUpdate
|
||
if ($SkipAgyUpdateTask) {
|
||
Write-Warn '③ DMF_Crawler_AgyUpdate 는 -SkipAgyUpdateTask 로 건너뜁니다.'
|
||
} elseif (-not (Test-Path -LiteralPath $AgyExe)) {
|
||
Write-Warn "agy.exe 를 찾을 수 없어 ③ 을 건너뜁니다: $AgyExe"
|
||
Write-Warn ' → scripts\bootstrap_agy.ps1 실행 후 이 스크립트를 다시 돌리세요.'
|
||
} else {
|
||
Write-Step '③ DMF_Crawler_AgyUpdate 구성'
|
||
|
||
$agyAt = Get-TimeOfDay -Text $AgyUpdateTime -Label 'schedule.agy_update_time'
|
||
|
||
$actionAgy = New-ScheduledTaskAction `
|
||
-Execute $AgyExe `
|
||
-Argument 'update' `
|
||
-WorkingDirectory $ProjectRoot
|
||
|
||
$trgAgy = New-ScheduledTaskTrigger -Weekly -WeeksInterval 1 `
|
||
-DaysOfWeek $AgyUpdateWeekday -At $agyAt `
|
||
-RandomDelay (New-TimeSpan -Minutes 10)
|
||
|
||
$setAgy = New-ScheduledTaskSettingsSet `
|
||
-AllowStartIfOnBatteries `
|
||
-DontStopIfGoingOnBatteries `
|
||
-StartWhenAvailable `
|
||
-RunOnlyIfNetworkAvailable `
|
||
-ExecutionTimeLimit (New-TimeSpan -Minutes 30) `
|
||
-MultipleInstances IgnoreNew `
|
||
-Priority 7 `
|
||
-Compatibility Win8
|
||
|
||
$prcAgy = New-ScheduledTaskPrincipal -UserId $User -LogonType $LogonType -RunLevel Limited
|
||
|
||
Register-DmfTask `
|
||
-Name 'DMF_Crawler_AgyUpdate' `
|
||
-Path $TaskPath `
|
||
-Action $actionAgy `
|
||
-Trigger $trgAgy `
|
||
-Settings $setAgy `
|
||
-Principal $prcAgy `
|
||
-Description "agy CLI 주간 업데이트. 배치 시간대를 피해 $AgyUpdateWeekday $AgyUpdateTime 에 돈다." `
|
||
-PlainPassword $plainPassword
|
||
}
|
||
|
||
# ---------------------------------------------------------------- 6. 등록 결과 요약
|
||
Write-Host ''
|
||
Write-Step '등록 결과'
|
||
Get-ScheduledTask -TaskPath $TaskPath |
|
||
Select-Object TaskName,
|
||
State,
|
||
@{ n = 'LogonType'; e = { $_.Principal.LogonType } },
|
||
@{ n = 'RunLevel'; e = { $_.Principal.RunLevel } },
|
||
@{ n = 'UserId'; e = { $_.Principal.UserId } } |
|
||
Format-Table -AutoSize
|
||
|
||
Get-ScheduledTask -TaskPath $TaskPath | ForEach-Object {
|
||
$info = $_ | Get-ScheduledTaskInfo
|
||
[pscustomobject]@{
|
||
TaskName = $_.TaskName
|
||
NextRunTime = $info.NextRunTime
|
||
LastRunTime = $info.LastRunTime
|
||
LastTaskResult = ('0x{0:X}' -f $info.LastTaskResult)
|
||
}
|
||
} | Format-Table -AutoSize
|
||
|
||
# ---------------------------------------------------------------- 7. 보안 컨텍스트 프로브 (-Verify)
|
||
if ($Verify) {
|
||
Write-Host ''
|
||
Write-Step '보안 컨텍스트 프로브 시작 (DPAPI 복호화가 이 LogonType 에서 되는지 실측)'
|
||
|
||
$probeName = 'DMF_Crawler_Probe'
|
||
$probeOut = Join-Path $ProjectRoot 'state\probe.json'
|
||
if (Test-Path -LiteralPath $probeOut) { Remove-Item -LiteralPath $probeOut -Force }
|
||
|
||
# doctor --json 을 파일로 리다이렉트해야 하므로 cmd.exe 를 경유한다.
|
||
$probeCmd = '/c ""{0}" -m dmf_crawler doctor --json > "{1}" 2>&1"' -f $PythonExe, $probeOut
|
||
|
||
$actionProbe = New-ScheduledTaskAction -Execute $env:ComSpec -Argument $probeCmd -WorkingDirectory $ProjectRoot
|
||
$setProbe = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries `
|
||
-ExecutionTimeLimit (New-TimeSpan -Minutes 3) -MultipleInstances IgnoreNew -Compatibility Win8
|
||
$trgProbe = New-ScheduledTaskTrigger -Once -At (Get-Date).AddYears(10) # 자동 실행은 절대 안 함
|
||
$prcProbe = New-ScheduledTaskPrincipal -UserId $User -LogonType $LogonType -RunLevel Highest
|
||
|
||
Register-DmfTask -Name $probeName -Path $TaskPath -Action $actionProbe -Trigger $trgProbe `
|
||
-Settings $setProbe -Principal $prcProbe -Description '일회성 보안 컨텍스트 프로브(자동 삭제)' `
|
||
-PlainPassword $plainPassword
|
||
|
||
try {
|
||
Start-ScheduledTask -TaskName $probeName -TaskPath $TaskPath
|
||
$deadline = (Get-Date).AddMinutes(3)
|
||
do {
|
||
Start-Sleep -Seconds 2
|
||
$state = (Get-ScheduledTask -TaskName $probeName -TaskPath $TaskPath).State
|
||
} while ($state -eq 'Running' -and (Get-Date) -lt $deadline)
|
||
|
||
if (-not (Test-Path -LiteralPath $probeOut)) {
|
||
Write-Warn '프로브가 출력을 남기지 못했습니다. 작업이 아예 시작되지 못했을 수 있습니다.'
|
||
Write-Warn ' → Get-WinEvent -LogName "Microsoft-Windows-TaskScheduler/Operational" -MaxEvents 30 으로 확인'
|
||
} else {
|
||
$raw = Get-Content -LiteralPath $probeOut -Raw
|
||
try {
|
||
$doc = $raw | ConvertFrom-Json
|
||
$bad = @($doc.checks | Where-Object { -not $_.ok })
|
||
if ($bad.Count -eq 0) {
|
||
Write-Good "프로브 통과: LogonType=$LogonType 에서 모든 진단이 정상입니다."
|
||
} else {
|
||
Write-Warn "프로브 실패 항목 $($bad.Count) 개:"
|
||
$bad | ForEach-Object { Write-Warn (" - [{0}] {1} : {2}" -f $_.key, $_.title, $_.detail) }
|
||
if ($bad.key -contains 'api_key') {
|
||
Write-Warn ''
|
||
Write-Warn ' ★ api_key 체크가 실패했다면 DPAPI 복호화가 이 로그온 타입에서 막힌 것입니다.'
|
||
Write-Warn ' 다음 명령으로 암호 저장 방식으로 다시 등록하세요:'
|
||
Write-Warn " .\scripts\install_tasks.ps1 -LogonType Password -Verify"
|
||
}
|
||
}
|
||
} catch {
|
||
Write-Warn 'JSON 파싱 실패. 원문을 그대로 출력합니다:'
|
||
Write-Host $raw
|
||
}
|
||
}
|
||
} finally {
|
||
Unregister-ScheduledTask -TaskName $probeName -TaskPath $TaskPath -Confirm:$false -ErrorAction SilentlyContinue
|
||
Write-Step '프로브 작업 제거 완료'
|
||
}
|
||
}
|
||
|
||
Write-Host ''
|
||
Write-Good '작업 등록이 끝났습니다. 다음 단계: §2.7 검증 명령을 실행하세요.'
|
||
```
|
||
|
||
### 2.4 `scripts\uninstall_tasks.ps1` 전문
|
||
|
||
```powershell
|
||
#Requires -Version 5.1
|
||
<#
|
||
.SYNOPSIS
|
||
DMF Crawler 작업 3종을 제거한다. 데이터·로그·리포트는 건드리지 않는다.
|
||
#>
|
||
[CmdletBinding()]
|
||
param(
|
||
[string]$TaskPath = '\DMF_Crawler\',
|
||
[switch]$RemoveFolder
|
||
)
|
||
|
||
Set-StrictMode -Version Latest
|
||
$ErrorActionPreference = 'Stop'
|
||
|
||
$names = @('DMF_Crawler_Daily', 'DMF_Crawler_Agent', 'DMF_Crawler_AgyUpdate', 'DMF_Crawler_Probe')
|
||
|
||
foreach ($n in $names) {
|
||
$t = Get-ScheduledTask -TaskName $n -TaskPath $TaskPath -ErrorAction SilentlyContinue
|
||
if ($t) {
|
||
if ($t.State -eq 'Running') {
|
||
Write-Host "[uninstall_tasks] 실행 중 → 중지: $n"
|
||
Stop-ScheduledTask -TaskName $n -TaskPath $TaskPath
|
||
}
|
||
Unregister-ScheduledTask -TaskName $n -TaskPath $TaskPath -Confirm:$false
|
||
Write-Host "[uninstall_tasks] 제거: $TaskPath$n"
|
||
} else {
|
||
Write-Host "[uninstall_tasks] 없음(건너뜀): $TaskPath$n"
|
||
}
|
||
}
|
||
|
||
if ($RemoveFolder) {
|
||
try {
|
||
$svc = New-Object -ComObject 'Schedule.Service'
|
||
$svc.Connect()
|
||
$root = $svc.GetFolder('\')
|
||
$root.DeleteFolder($TaskPath.Trim('\'), 0)
|
||
Write-Host "[uninstall_tasks] 폴더 제거: $TaskPath"
|
||
} catch {
|
||
Write-Host "[uninstall_tasks] 폴더 제거 실패(무해): $($_.Exception.Message)"
|
||
}
|
||
}
|
||
|
||
Write-Host '[uninstall_tasks] 완료. data\ logs\ reports\ backup\ state\ 는 그대로 남아 있습니다.'
|
||
```
|
||
|
||
### 2.5 실행 방법
|
||
|
||
```powershell
|
||
# 관리자 권한 PowerShell 에서
|
||
cd D:\workspace\DMF_Crawler
|
||
powershell -ExecutionPolicy Bypass -File .\scripts\install_tasks.ps1 -Verify
|
||
```
|
||
|
||
`ExecutionPolicy` 를 영구히 바꾸지 않는다. 호출 시점의 `-ExecutionPolicy Bypass` 로 충분하다.
|
||
|
||
### 2.6 왜 액션이 `powershell.exe` 가 아니라 `python.exe` 인가
|
||
|
||
| 이유 | 설명 |
|
||
|---|---|
|
||
| **종료 코드 보존** | 중간에 `powershell.exe` 를 끼우면 스크립트가 `$LASTEXITCODE` 를 명시적으로 `exit` 하지 않는 한 종료 코드가 뭉개진다. `RestartCount` 는 종료 코드에 반응하므로 치명적이다. |
|
||
| **콘솔 창** | S4U 세션에는 데스크톱이 없어 `python.exe` 라도 창이 뜨지 않는다. 반대로 Interactive 로 도는 Agent 는 `pythonw.exe` 를 써야 검은 창이 깜빡이지 않는다. |
|
||
| **레이어 하나 제거** | 인코딩(코드페이지 949 vs UTF-8), 실행 정책, 프로필 로딩 같은 PowerShell 고유 변수를 제거한다. |
|
||
|
||
> **`AGY_CLI_DISABLE_AUTO_UPDATE` 는 작업에 설정하지 않는다.** 작업 스케줄러 액션은 환경변수를 직접 넣을 수 없다. 이 변수는 `agy/client.py` 가 `subprocess` 를 띄울 때 자식 환경에 주입한다 — 배치가 아니라 **코드의 책임**이다.
|
||
|
||
### 2.7 등록 후 검증 명령
|
||
|
||
```powershell
|
||
# (1) 작업 3종이 Ready 상태로 보이는가
|
||
Get-ScheduledTask -TaskPath '\DMF_Crawler\' |
|
||
Format-Table TaskName, State -AutoSize
|
||
|
||
# (2) 다음 실행 시각이 내일 06:00 근처인가
|
||
Get-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Daily' |
|
||
Get-ScheduledTaskInfo |
|
||
Format-List TaskName, NextRunTime, LastRunTime, LastTaskResult, NumberOfMissedRuns
|
||
|
||
# (3) 보안 컨텍스트가 의도대로인가 (SYSTEM 이면 즉시 실패로 간주하라)
|
||
(Get-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Daily').Principal |
|
||
Format-List UserId, LogonType, RunLevel
|
||
|
||
# (4) 배터리·캐치업·절전 해제 4종이 제대로 뒤집혔는가
|
||
(Get-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Daily').Settings |
|
||
Format-List DisallowStartIfOnBatteries, StopIfGoingOnBatteries,
|
||
StartWhenAvailable, WakeToRun, RunOnlyIfNetworkAvailable,
|
||
ExecutionTimeLimit, MultipleInstances, Priority
|
||
|
||
# (5) 재시작 정책
|
||
(Get-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Daily').Settings |
|
||
Format-List RestartCount, RestartInterval
|
||
|
||
# (6) 트리거 2개(Daily + Boot)가 다 붙었는가. BootTrigger 의 Delay 가 PT5M 인가
|
||
(Get-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Daily').Triggers |
|
||
Format-List CimClass, Enabled, StartBoundary, RandomDelay, Delay
|
||
|
||
# (7) 절전 해제 타이머로 등록됐는가 (WakeToRun 의 실제 효과 확인)
|
||
powercfg /waketimers
|
||
|
||
# (8) XML 전문을 눈으로 확인
|
||
Export-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Daily'
|
||
|
||
# (9) 고전 도구로도 교차 확인
|
||
schtasks /Query /TN "\DMF_Crawler\DMF_Crawler_Daily" /V /FO LIST
|
||
```
|
||
|
||
**자동 검증 스니펫** — 하나라도 어긋나면 붉게 출력한다. 설치 직후 그대로 붙여넣어라.
|
||
|
||
```powershell
|
||
$t = Get-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Daily'
|
||
$s = $t.Settings
|
||
$p = $t.Principal
|
||
$exp = [ordered]@{
|
||
'DisallowStartIfOnBatteries = False' = ($s.DisallowStartIfOnBatteries -eq $false)
|
||
'StopIfGoingOnBatteries = False' = ($s.StopIfGoingOnBatteries -eq $false)
|
||
'StartWhenAvailable = True' = ($s.StartWhenAvailable -eq $true)
|
||
'WakeToRun = True' = ($s.WakeToRun -eq $true)
|
||
'MultipleInstances = IgnoreNew' = ($s.MultipleInstances -eq 'IgnoreNew')
|
||
'RestartCount = 3' = ($s.RestartCount -eq 3)
|
||
'RestartInterval = PT10M' = ($s.RestartInterval -eq 'PT10M')
|
||
'ExecutionTimeLimit = PT30M' = ($s.ExecutionTimeLimit -eq 'PT30M')
|
||
'RunLevel = Highest' = ($p.RunLevel -eq 'Highest')
|
||
'UserId != SYSTEM' = ($p.UserId -notmatch 'SYSTEM|LOCALSERVICE|NETWORKSERVICE')
|
||
'트리거 2개(Daily + Boot)' = ($t.Triggers.Count -eq 2)
|
||
}
|
||
$fail = 0
|
||
foreach ($k in $exp.Keys) {
|
||
if ($exp[$k]) { Write-Host (" OK {0}" -f $k) -ForegroundColor Green }
|
||
else { Write-Host (" FAIL {0}" -f $k) -ForegroundColor Red; $fail++ }
|
||
}
|
||
if ($fail -gt 0) { Write-Host "`n$fail 개 항목이 어긋났습니다. install_tasks.ps1 을 다시 실행하세요." -ForegroundColor Red }
|
||
else { Write-Host "`n전 항목 통과." -ForegroundColor Green }
|
||
```
|
||
|
||
### 2.8 ★ S4U vs Password — 설치 시점에 반드시 실측할 단 하나의 분기
|
||
|
||
**사실 관계부터 정확히.** Microsoft 문서의 `TASK_LOGON_S4U` 설명 원문:
|
||
|
||
> "Use an existing interactive token to run a task. The user must log on using a service for user (S4U) logon. When an S4U logon is used, **no password is stored by the system and there is no access to either the network or encrypted files**."
|
||
|
||
여기서 "no access to the network" 는 **네트워크 자원에 사용자 자격증명으로 인증하는 것**(UNC 공유, 매핑 드라이브, Kerberos 위임)을 말한다. **공개 HTTPS 엔드포인트로의 아웃바운드 요청은 막히지 않는다.** 우리 크롤러는 `https://apis.data.go.kr/...` 하나만 호출하므로 이 제약에 걸리지 않는다.
|
||
|
||
**진짜 위험은 "encrypted files" 쪽이다.** S4U 로그온에는 사용자 암호가 개입하지 않으므로 **DPAPI 사용자 마스터 키를 풀지 못해 `CryptUnprotectData` 가 실패할 수 있다.** 우리는 공공데이터포털 API 키를 `secrets_dpapi.py` 로 DPAPI 암호화해 저장한다 → **정면 충돌 가능성이 있다.**
|
||
|
||
| | `S4U` | `Password` |
|
||
|---|---|---|
|
||
| 암호 저장 | 안 함 | Task Scheduler 자격증명 저장소에 저장 |
|
||
| 로그오프 상태 실행 | ✅ | ✅ |
|
||
| 아웃바운드 HTTPS | ✅ | ✅ |
|
||
| UNC·매핑 드라이브 | ❌ | ✅ |
|
||
| **DPAPI 사용자 범위 복호화** | **⚠️ 실패할 수 있음 — 실측 필요** | ✅ |
|
||
| 사용자 암호 변경 시 | 영향 없음 | **작업이 깨짐 → 재등록 필요** |
|
||
| 필요 권한 | 해당 계정에 `Logon as Batch` | 동일 |
|
||
|
||
**판정 절차(추측 금지):**
|
||
|
||
```powershell
|
||
# 1) S4U 로 등록하면서 프로브까지 수행
|
||
.\scripts\install_tasks.ps1 -LogonType S4U -Verify
|
||
|
||
# 2) 출력의 api_key 체크를 본다.
|
||
# "OK" → S4U 그대로 간다. 끝.
|
||
# "FAIL: DPAPI ..." → 3) 으로.
|
||
|
||
# 3) 암호 저장 방식으로 재등록
|
||
.\scripts\install_tasks.ps1 -LogonType Password -Verify
|
||
```
|
||
|
||
**`Password` 로 갔다면 반드시 기록할 것**: 이 PC 는 **Windows 로그인 암호를 바꾸는 순간 06:00 배치가 죽는다.** 암호 변경 후 `install_tasks.ps1 -LogonType Password` 재실행이 필수 절차다. `docs/ops/02-failure-alerting.md` 의 복구 안내와 온보딩 GUI 체크 ⑨ 에 이 문구가 들어가야 한다.
|
||
|
||
> **`Logon as Batch` 권한**: "Tasks registered with the TASK_LOGON_PASSWORD or TASK_LOGON_S4U flag will only launch if the specified user has the Logon as Batch privilege enabled. Administrators and Backup Operators group users have this privilege enabled by default." 개인 PC 의 관리자 계정이면 기본으로 있다. 없으면 `secpol.msc → 로컬 정책 → 사용자 권한 할당 → 일괄 작업으로 로그온`에 계정을 추가한다.
|
||
|
||
---
|
||
|
||
## 3. 동등한 schtasks XML 전문
|
||
|
||
작업 스케줄러 XML 은 **형상관리 가능한 정본**이다. `scripts\tasks\` 아래에 두고 Git 에 올린다. `<UserId>` 와 경로만 PC 에 맞게 치환한다.
|
||
|
||
> **인코딩 주의**: `schtasks /Create /XML` 은 **UTF-16 LE** 파일을 기대한다. PowerShell 에서 저장할 때 `Out-File -Encoding Unicode` 를 쓰거나, 인코딩 문제를 피하려면 `Register-ScheduledTask -Xml (Get-Content -Raw -Encoding UTF8 ...)` 를 쓴다.
|
||
|
||
### 3.1 `scripts\tasks\DMF_Crawler_Daily.xml`
|
||
|
||
```xml
|
||
<?xml version="1.0" encoding="UTF-16"?>
|
||
<Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
|
||
<RegistrationInfo>
|
||
<Date>2026-09-02T00:00:00</Date>
|
||
<Author>DMF Crawler</Author>
|
||
<Description>DMF 일일 수집·비교·리포트 배치. 매일 06:00 + 부팅 후 5분. UI 를 띄우지 않는다.</Description>
|
||
<URI>\DMF_Crawler\DMF_Crawler_Daily</URI>
|
||
</RegistrationInfo>
|
||
<Triggers>
|
||
<CalendarTrigger>
|
||
<StartBoundary>2026-09-02T06:00:00</StartBoundary>
|
||
<Enabled>true</Enabled>
|
||
<RandomDelay>PT4M</RandomDelay>
|
||
<ScheduleByDay>
|
||
<DaysInterval>1</DaysInterval>
|
||
</ScheduleByDay>
|
||
</CalendarTrigger>
|
||
<BootTrigger>
|
||
<Enabled>true</Enabled>
|
||
<Delay>PT5M</Delay>
|
||
</BootTrigger>
|
||
</Triggers>
|
||
<Principals>
|
||
<Principal id="Author">
|
||
<UserId>DESKTOP-XXXXXXX\encep</UserId>
|
||
<LogonType>S4U</LogonType>
|
||
<RunLevel>HighestAvailable</RunLevel>
|
||
</Principal>
|
||
</Principals>
|
||
<Settings>
|
||
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
|
||
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
|
||
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
|
||
<AllowHardTerminate>true</AllowHardTerminate>
|
||
<StartWhenAvailable>true</StartWhenAvailable>
|
||
<RunOnlyIfNetworkAvailable>true</RunOnlyIfNetworkAvailable>
|
||
<IdleSettings>
|
||
<StopOnIdleEnd>false</StopOnIdleEnd>
|
||
<RestartOnIdle>false</RestartOnIdle>
|
||
</IdleSettings>
|
||
<AllowStartOnDemand>true</AllowStartOnDemand>
|
||
<Enabled>true</Enabled>
|
||
<Hidden>false</Hidden>
|
||
<RunOnlyIfIdle>false</RunOnlyIfIdle>
|
||
<DisallowStartOnRemoteAppSession>false</DisallowStartOnRemoteAppSession>
|
||
<UseUnifiedSchedulingEngine>true</UseUnifiedSchedulingEngine>
|
||
<WakeToRun>true</WakeToRun>
|
||
<ExecutionTimeLimit>PT30M</ExecutionTimeLimit>
|
||
<Priority>5</Priority>
|
||
<RestartOnFailure>
|
||
<Interval>PT10M</Interval>
|
||
<Count>3</Count>
|
||
</RestartOnFailure>
|
||
</Settings>
|
||
<Actions Context="Author">
|
||
<Exec>
|
||
<Command>D:\workspace\DMF_Crawler\.venv\Scripts\python.exe</Command>
|
||
<Arguments>-m dmf_crawler run --trigger scheduled</Arguments>
|
||
<WorkingDirectory>D:\workspace\DMF_Crawler</WorkingDirectory>
|
||
</Exec>
|
||
</Actions>
|
||
</Task>
|
||
```
|
||
|
||
**XSD 상 유효성 근거 3가지** (이걸 모르면 "왜 임포트가 거부되는지" 를 못 찾는다):
|
||
|
||
- `<RestartOnFailure><Interval>` 은 `PT1M` 이상 `P31D` 이하로 제한된다. `PT10M` 은 유효.
|
||
- `<RestartOnFailure><Count>` 는 `unsignedByte` 이고 최소 1. `3` 은 유효.
|
||
- `<ExecutionTimeLimit>` 에 `PT0S` 를 주면 **무제한**이 된다. 값을 아예 생략하면 기본 3일이다. 우리는 폭주 방지를 위해 `PT30M` 을 명시한다.
|
||
|
||
### 3.2 `scripts\tasks\DMF_Crawler_Agent.xml`
|
||
|
||
```xml
|
||
<?xml version="1.0" encoding="UTF-16"?>
|
||
<Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
|
||
<RegistrationInfo>
|
||
<Date>2026-09-02T00:00:00</Date>
|
||
<Author>DMF Crawler</Author>
|
||
<Description>DMF 알림 에이전트. 로그온 시 + 15분마다 heartbeat 를 점검하고 밀린 알림을 표시한다.</Description>
|
||
<URI>\DMF_Crawler\DMF_Crawler_Agent</URI>
|
||
</RegistrationInfo>
|
||
<Triggers>
|
||
<LogonTrigger>
|
||
<Enabled>true</Enabled>
|
||
<UserId>DESKTOP-XXXXXXX\encep</UserId>
|
||
<Repetition>
|
||
<Interval>PT15M</Interval>
|
||
<StopAtDurationEnd>false</StopAtDurationEnd>
|
||
</Repetition>
|
||
</LogonTrigger>
|
||
<TimeTrigger>
|
||
<StartBoundary>2026-09-02T00:05:00</StartBoundary>
|
||
<Enabled>true</Enabled>
|
||
<Repetition>
|
||
<Interval>PT15M</Interval>
|
||
<StopAtDurationEnd>false</StopAtDurationEnd>
|
||
</Repetition>
|
||
</TimeTrigger>
|
||
</Triggers>
|
||
<Principals>
|
||
<Principal id="Author">
|
||
<UserId>DESKTOP-XXXXXXX\encep</UserId>
|
||
<LogonType>InteractiveToken</LogonType>
|
||
<RunLevel>LeastPrivilege</RunLevel>
|
||
</Principal>
|
||
</Principals>
|
||
<Settings>
|
||
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
|
||
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
|
||
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
|
||
<AllowHardTerminate>true</AllowHardTerminate>
|
||
<StartWhenAvailable>false</StartWhenAvailable>
|
||
<RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>
|
||
<IdleSettings>
|
||
<StopOnIdleEnd>false</StopOnIdleEnd>
|
||
<RestartOnIdle>false</RestartOnIdle>
|
||
</IdleSettings>
|
||
<AllowStartOnDemand>true</AllowStartOnDemand>
|
||
<Enabled>true</Enabled>
|
||
<Hidden>false</Hidden>
|
||
<RunOnlyIfIdle>false</RunOnlyIfIdle>
|
||
<DisallowStartOnRemoteAppSession>false</DisallowStartOnRemoteAppSession>
|
||
<UseUnifiedSchedulingEngine>true</UseUnifiedSchedulingEngine>
|
||
<WakeToRun>false</WakeToRun>
|
||
<ExecutionTimeLimit>PT10M</ExecutionTimeLimit>
|
||
<Priority>7</Priority>
|
||
</Settings>
|
||
<Actions Context="Author">
|
||
<Exec>
|
||
<Command>D:\workspace\DMF_Crawler\.venv\Scripts\pythonw.exe</Command>
|
||
<Arguments>-m dmf_crawler notify-pump --once</Arguments>
|
||
<WorkingDirectory>D:\workspace\DMF_Crawler</WorkingDirectory>
|
||
</Exec>
|
||
</Actions>
|
||
</Task>
|
||
```
|
||
|
||
`<Repetition>` 에서 `<Duration>` 을 **생략하면 무기한 반복**이다. `<StopAtDurationEnd>false</StopAtDurationEnd>` 와 함께 쓴다. `WakeToRun` 은 **false** — 알리미가 새벽 3시에 PC 를 깨우면 안 된다.
|
||
|
||
### 3.3 `scripts\tasks\DMF_Crawler_AgyUpdate.xml`
|
||
|
||
```xml
|
||
<?xml version="1.0" encoding="UTF-16"?>
|
||
<Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
|
||
<RegistrationInfo>
|
||
<Date>2026-09-02T00:00:00</Date>
|
||
<Author>DMF Crawler</Author>
|
||
<Description>agy CLI 주간 업데이트. 배치 시간대를 피해 일요일 14:00 에 돈다.</Description>
|
||
<URI>\DMF_Crawler\DMF_Crawler_AgyUpdate</URI>
|
||
</RegistrationInfo>
|
||
<Triggers>
|
||
<CalendarTrigger>
|
||
<StartBoundary>2026-09-06T14:00:00</StartBoundary>
|
||
<Enabled>true</Enabled>
|
||
<RandomDelay>PT10M</RandomDelay>
|
||
<ScheduleByWeek>
|
||
<DaysOfWeek>
|
||
<Sunday />
|
||
</DaysOfWeek>
|
||
<WeeksInterval>1</WeeksInterval>
|
||
</ScheduleByWeek>
|
||
</CalendarTrigger>
|
||
</Triggers>
|
||
<Principals>
|
||
<Principal id="Author">
|
||
<UserId>DESKTOP-XXXXXXX\encep</UserId>
|
||
<LogonType>S4U</LogonType>
|
||
<RunLevel>LeastPrivilege</RunLevel>
|
||
</Principal>
|
||
</Principals>
|
||
<Settings>
|
||
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
|
||
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
|
||
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
|
||
<AllowHardTerminate>true</AllowHardTerminate>
|
||
<StartWhenAvailable>true</StartWhenAvailable>
|
||
<RunOnlyIfNetworkAvailable>true</RunOnlyIfNetworkAvailable>
|
||
<IdleSettings>
|
||
<StopOnIdleEnd>false</StopOnIdleEnd>
|
||
<RestartOnIdle>false</RestartOnIdle>
|
||
</IdleSettings>
|
||
<AllowStartOnDemand>true</AllowStartOnDemand>
|
||
<Enabled>true</Enabled>
|
||
<Hidden>false</Hidden>
|
||
<RunOnlyIfIdle>false</RunOnlyIfIdle>
|
||
<DisallowStartOnRemoteAppSession>false</DisallowStartOnRemoteAppSession>
|
||
<UseUnifiedSchedulingEngine>true</UseUnifiedSchedulingEngine>
|
||
<WakeToRun>false</WakeToRun>
|
||
<ExecutionTimeLimit>PT30M</ExecutionTimeLimit>
|
||
<Priority>7</Priority>
|
||
</Settings>
|
||
<Actions Context="Author">
|
||
<Exec>
|
||
<Command>C:\Users\encep\AppData\Local\agy\bin\agy.exe</Command>
|
||
<Arguments>update</Arguments>
|
||
<WorkingDirectory>D:\workspace\DMF_Crawler</WorkingDirectory>
|
||
</Exec>
|
||
</Actions>
|
||
</Task>
|
||
```
|
||
|
||
> `%LOCALAPPDATA%` 같은 환경변수는 `<Command>` 에서 **전개되지 않는다.** 절대 경로를 써야 한다.
|
||
|
||
### 3.4 XML 임포트 · 익스포트 명령
|
||
|
||
```powershell
|
||
# ── 임포트 (schtasks, UTF-16 파일 전제) ──────────────────────────────
|
||
schtasks /Create /TN "\DMF_Crawler\DMF_Crawler_Daily" `
|
||
/XML "D:\workspace\DMF_Crawler\scripts\tasks\DMF_Crawler_Daily.xml" /F
|
||
|
||
# S4U(암호 저장 안 함)로 계정을 지정해 임포트
|
||
schtasks /Create /TN "\DMF_Crawler\DMF_Crawler_Daily" `
|
||
/XML "...\DMF_Crawler_Daily.xml" /RU "DESKTOP-XXXXXXX\encep" /F
|
||
|
||
# 암호 저장 방식으로 임포트 (실행 시 암호를 물어본다)
|
||
schtasks /Create /TN "\DMF_Crawler\DMF_Crawler_Daily" `
|
||
/XML "...\DMF_Crawler_Daily.xml" /RU "DESKTOP-XXXXXXX\encep" /RP * /F
|
||
|
||
# ── 임포트 (PowerShell, 인코딩 걱정 없음) ────────────────────────────
|
||
$xml = Get-Content -Raw -Encoding UTF8 `
|
||
'D:\workspace\DMF_Crawler\scripts\tasks\DMF_Crawler_Daily.xml'
|
||
Register-ScheduledTask -TaskName 'DMF_Crawler_Daily' -TaskPath '\DMF_Crawler\' `
|
||
-Xml $xml -User 'DESKTOP-XXXXXXX\encep' -Force
|
||
|
||
# ── 익스포트 (현재 PC 의 실제 상태를 정본으로 되돌려 받기) ────────────
|
||
New-Item -ItemType Directory -Force -Path 'D:\workspace\DMF_Crawler\scripts\tasks' | Out-Null
|
||
foreach ($n in 'DMF_Crawler_Daily','DMF_Crawler_Agent','DMF_Crawler_AgyUpdate') {
|
||
$t = Get-ScheduledTask -TaskName $n -TaskPath '\DMF_Crawler\' -ErrorAction SilentlyContinue
|
||
if ($t) {
|
||
Export-ScheduledTask -TaskName $n -TaskPath '\DMF_Crawler\' |
|
||
Out-File -Encoding Unicode "D:\workspace\DMF_Crawler\scripts\tasks\$n.xml"
|
||
Write-Host "exported: $n"
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 4. 워치독과 heartbeat
|
||
|
||
### 4.1 heartbeat 파일 규약
|
||
|
||
| 항목 | 값 |
|
||
|---|---|
|
||
| 경로 | `D:\workspace\DMF_Crawler\state\heartbeat.json` |
|
||
| 인코딩 | UTF-8 (BOM 없음), LF |
|
||
| 쓰는 주체 | `pipeline.py` 의 `finalize` 스테이지 (**성공 · 부분성공일 때만**) |
|
||
| 읽는 주체 | `watchdog.py`(에이전트 안), `checks.py` 체크 ⑫, 운영자의 `check_heartbeat.ps1` |
|
||
| 쓰기 방식 | 임시 파일 → `os.replace` 원자 교체. **SQLite 잠금을 요구하지 않는다** |
|
||
| 갱신 시점 | 파이프라인 종료 직전. `status ∈ {SUCCESS, PARTIAL}` 일 때만 `updated_at` 을 현재 시각으로 밀어 올린다 |
|
||
| **갱신하지 않는 경우** | `FAILED`, `BLOCKED`, `ExecutionTimeLimit` 강제 종료, 프로세스 크래시, 작업이 아예 시작되지 못함 |
|
||
|
||
**★ 설계상 가장 중요한 한 줄**: **실패했을 때 heartbeat 를 갱신하면 dead-man switch 가 설계상 무력화된다.** "돌긴 돌았다"를 기록하고 싶은 유혹을 버려라. 실행 시도 자체는 `runs` 테이블과 `events.jsonl` 이 이미 남긴다.
|
||
|
||
### 4.2 `state\heartbeat.json` 스키마와 예시
|
||
|
||
```json
|
||
{
|
||
"schema": 1,
|
||
"updated_at": "2026-09-02T06:04:37+09:00",
|
||
"run_id": "20260902_060012",
|
||
"run_date": "2026-09-02",
|
||
"trigger": "scheduled",
|
||
"status": "SUCCESS",
|
||
"exit_code": 0,
|
||
"started_at": "2026-09-02T06:00:12+09:00",
|
||
"duration_seconds": 265,
|
||
"records_total": 12874,
|
||
"events": { "new": 3, "changed": 1, "withdrawn": 0 },
|
||
"integrity": { "passed": true, "blocked_gate": null },
|
||
"report_path": "D:\\workspace\\DMF_Crawler\\reports\\DMF_리포트_2026-09-02.xlsx",
|
||
"log_dir": "D:\\workspace\\DMF_Crawler\\logs\\run_20260902_060012",
|
||
"agy": { "used": true, "status": "OK", "tokens": 31245 },
|
||
"consecutive_failures": 0,
|
||
"host": "DESKTOP-XXXXXXX",
|
||
"user": "encep",
|
||
"version": "0.1.0"
|
||
}
|
||
```
|
||
|
||
| 필드 | 타입 | 의미 |
|
||
|---|---|---|
|
||
| `schema` | int | 파일 포맷 버전. 읽는 쪽은 모르는 버전이면 WARN 후 무시 |
|
||
| `updated_at` | ISO8601 (오프셋 포함) | **워치독 판정의 유일한 기준** |
|
||
| `run_id` | str | `YYYYMMDD_HHMMSS` (KST). 로그 디렉터리 이름과 1:1 |
|
||
| `run_date` | `YYYY-MM-DD` | 실행 대상 일자. idempotency 가드가 쓰는 키 |
|
||
| `trigger` | `scheduled` \| `startup` \| `manual` | 어느 트리거로 돌았나 |
|
||
| `status` | `SUCCESS` \| `PARTIAL` | `PARTIAL` = 리포트는 나왔지만 무결성 게이트 차단 또는 AI 실패 |
|
||
| `exit_code` | int | 0 / 1 / 2 / 130 |
|
||
| `duration_seconds` | int | 사람이 "느려졌다"를 감지하는 지표 |
|
||
| `records_total` | int | 오늘 수집 총 건수 |
|
||
| `events` | object | 신규/변경/취하 건수 |
|
||
| `integrity.blocked_gate` | str \| null | 차단한 게이트 이름(있으면 PARTIAL 사유) |
|
||
| `report_path` | str | 알림에서 "리포트 열기" 버튼이 쓴다 |
|
||
| `log_dir` | str | 알림에서 "로그 폴더 열기" 버튼이 쓴다 |
|
||
| `agy.status` | `OK` \| `SKIPPED` \| `AUTH` \| `QUOTA` \| `ERROR` | AI 계층 결과 |
|
||
| `consecutive_failures` | int | 마지막 성공 이후 누적 실패 횟수. `notify.consecutive_failure_critical`(3) 승격 판정에 쓴다 |
|
||
|
||
### 4.3 heartbeat 기록기 — `src\dmf_crawler\watchdog.py` 의 쓰기 부분
|
||
|
||
```python
|
||
"""heartbeat 기록과 신선도 판정.
|
||
|
||
이 모듈은 SQLite 를 열지 않는다. 알림 에이전트가 배치와 락 경쟁을 하지
|
||
않고 상태를 읽을 수 있어야 하기 때문이다(아키텍처 §2 state/ 설명).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import os
|
||
import socket
|
||
import tempfile
|
||
from dataclasses import dataclass
|
||
from datetime import datetime, time, timedelta
|
||
from pathlib import Path
|
||
from typing import Any, Literal
|
||
from zoneinfo import ZoneInfo
|
||
|
||
HEARTBEAT_SCHEMA = 1
|
||
|
||
Status = Literal["SUCCESS", "PARTIAL"]
|
||
|
||
|
||
def write_heartbeat(path: Path, payload: dict[str, Any]) -> None:
|
||
"""heartbeat.json 을 원자적으로 교체한다.
|
||
|
||
성공·부분성공일 때만 호출한다. 실패 시 호출하면 dead-man switch 가
|
||
무력화된다.
|
||
"""
|
||
payload = {"schema": HEARTBEAT_SCHEMA, **payload}
|
||
payload.setdefault("host", socket.gethostname())
|
||
payload.setdefault("user", os.environ.get("USERNAME", ""))
|
||
|
||
path.parent.mkdir(parents=True, exist_ok=True)
|
||
fd, tmp_name = tempfile.mkstemp(
|
||
dir=str(path.parent), prefix=".heartbeat-", suffix=".tmp"
|
||
)
|
||
tmp = Path(tmp_name)
|
||
try:
|
||
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as fh:
|
||
json.dump(payload, fh, ensure_ascii=False, indent=2)
|
||
fh.write("\n")
|
||
fh.flush()
|
||
os.fsync(fh.fileno())
|
||
os.replace(tmp, path)
|
||
except BaseException:
|
||
tmp.unlink(missing_ok=True)
|
||
raise
|
||
```
|
||
|
||
### 4.4 워치독 판정 로직 — 같은 파일의 읽기·판정 부분
|
||
|
||
**나이브한 구현이 반드시 틀리는 지점**: "heartbeat 가 120분보다 오래됐으면 경보" 라고 쓰면, 새벽 3시에는 어제 06:00 heartbeat 가 21시간 묵어 있으므로 **매일 밤 오탐이 뜬다.** 올바른 기준은 **"가장 최근에 지나간 예정 실행 시각(expected_last_run) 이후에 갱신됐는가"** 이다.
|
||
|
||
```python
|
||
@dataclass(frozen=True, slots=True)
|
||
class Heartbeat:
|
||
updated_at: datetime
|
||
run_id: str
|
||
run_date: str
|
||
status: str
|
||
exit_code: int
|
||
report_path: str
|
||
log_dir: str
|
||
consecutive_failures: int
|
||
raw: dict[str, Any]
|
||
|
||
@classmethod
|
||
def load(cls, path: Path, tz: ZoneInfo) -> "Heartbeat | None":
|
||
"""읽지 못하면 None. 예외를 밖으로 던지지 않는다 —
|
||
진단기가 죽어서 진단이 안 되는 일이 없어야 한다."""
|
||
try:
|
||
doc = json.loads(path.read_text(encoding="utf-8"))
|
||
except (OSError, ValueError):
|
||
return None
|
||
try:
|
||
updated = datetime.fromisoformat(str(doc["updated_at"]))
|
||
except (KeyError, ValueError):
|
||
return None
|
||
if updated.tzinfo is None:
|
||
updated = updated.replace(tzinfo=tz)
|
||
return cls(
|
||
updated_at=updated.astimezone(tz),
|
||
run_id=str(doc.get("run_id", "")),
|
||
run_date=str(doc.get("run_date", "")),
|
||
status=str(doc.get("status", "")),
|
||
exit_code=int(doc.get("exit_code", -1)),
|
||
report_path=str(doc.get("report_path", "")),
|
||
log_dir=str(doc.get("log_dir", "")),
|
||
consecutive_failures=int(doc.get("consecutive_failures", 0)),
|
||
raw=doc,
|
||
)
|
||
|
||
|
||
@dataclass(frozen=True, slots=True)
|
||
class Verdict:
|
||
stale: bool
|
||
code: str # OK | NEVER_RAN | STALE | GRACE | DEGRADED
|
||
expected_at: datetime | None
|
||
age_minutes: int | None
|
||
detail: str
|
||
|
||
|
||
def expected_last_run(now: datetime, daily_time: time) -> datetime:
|
||
"""now 기준으로 '가장 최근에 지나간 예정 실행 시각'."""
|
||
today = now.replace(
|
||
hour=daily_time.hour, minute=daily_time.minute, second=0, microsecond=0
|
||
)
|
||
return today if now >= today else today - timedelta(days=1)
|
||
|
||
|
||
def judge(
|
||
hb: Heartbeat | None,
|
||
*,
|
||
now: datetime,
|
||
daily_time: time,
|
||
stale_minutes: int,
|
||
) -> Verdict:
|
||
"""워치독 판정.
|
||
|
||
stale_minutes 는 '예정 시각 이후 이만큼 지나도 갱신이 없으면 경보'라는
|
||
유예 시간이다(config: notify.watchdog_stale_minutes, 기본 120).
|
||
지터 4분 + 재시작 3회 × 10분 = 최악 34분 을 충분히 덮는다.
|
||
"""
|
||
expected = expected_last_run(now, daily_time)
|
||
deadline = expected + timedelta(minutes=stale_minutes)
|
||
|
||
if hb is None:
|
||
if now < deadline:
|
||
return Verdict(False, "GRACE", expected, None,
|
||
"아직 한 번도 실행되지 않았지만 유예 시간 안입니다.")
|
||
return Verdict(True, "NEVER_RAN", expected, None,
|
||
"성공 기록이 한 번도 없습니다. 최초 설치가 끝나지 않았을 수 있습니다.")
|
||
|
||
age = int((now - hb.updated_at).total_seconds() // 60)
|
||
|
||
if hb.updated_at >= expected:
|
||
code = "DEGRADED" if hb.status == "PARTIAL" else "OK"
|
||
detail = (
|
||
f"마지막 성공 {hb.updated_at:%Y-%m-%d %H:%M} "
|
||
f"(run_id={hb.run_id}, status={hb.status})"
|
||
)
|
||
return Verdict(False, code, expected, age, detail)
|
||
|
||
if now < deadline:
|
||
return Verdict(False, "GRACE", expected, age,
|
||
f"{expected:%H:%M} 실행이 아직 진행 중이거나 재시도 중일 수 있습니다.")
|
||
|
||
return Verdict(
|
||
True, "STALE", expected, age,
|
||
f"{expected:%Y-%m-%d %H:%M} 예정 실행의 성공 기록이 없습니다. "
|
||
f"마지막 성공은 {hb.updated_at:%Y-%m-%d %H:%M} ({age}분 전)입니다.",
|
||
)
|
||
```
|
||
|
||
### 4.5 판정 → 알림 연결
|
||
|
||
`notify/pump.py` 가 `judge()` 결과를 받아 다음 표대로 행동한다. 알림 문구 4요소(무엇/왜/어떻게/다음 행동)는 `alerts.raise_alert()` 가 **계약으로 강제**한다 — 하나라도 비면 `ValueError`.
|
||
|
||
| `code` | 등급 | 표시 방식 | 알림 코드 | 다음 행동 버튼 |
|
||
|---|---|---|---|---|
|
||
| `OK` | — | 표시 없음 | — | — |
|
||
| `GRACE` | — | 표시 없음 | — | — |
|
||
| `DEGRADED` | WARN | 자동소멸 토스트 | `RUN_PARTIAL` | [리포트 열기] [로그 폴더 열기] |
|
||
| `STALE` | CRITICAL | **강제 모달** (`gui.launch(mode="recover", focus_key="last_run")`) | `WATCHDOG_STALE` | [지금 실행] [작업 상태 확인] [로그 폴더 열기] |
|
||
| `NEVER_RAN` | CRITICAL | **강제 모달** (`mode="setup"`) | `WATCHDOG_NEVER_RAN` | [설치 마법사 열기] |
|
||
|
||
중복 억제: `dedup_key = code + run_date`, 쿨다운 `notify.cooldown_minutes`(기본 240분). 즉 STALE 상태가 지속돼도 하루에 최대 몇 번만 창이 뜬다.
|
||
|
||
### 4.6 운영자용 즉석 점검 스크립트 — `scripts\check_heartbeat.ps1`
|
||
|
||
Python 환경이 깨졌을 때도 상태를 볼 수 있어야 한다. **읽기 전용**이며 아무것도 등록하지 않는다.
|
||
|
||
```powershell
|
||
#Requires -Version 5.1
|
||
<#
|
||
.SYNOPSIS
|
||
state\heartbeat.json 을 읽어 06:00 배치의 최근 상태를 판정한다. 읽기 전용.
|
||
.OUTPUTS
|
||
종료 코드 0 = 정상, 1 = 열화(PARTIAL), 2 = 경보(STALE / 파일 없음)
|
||
#>
|
||
[CmdletBinding()]
|
||
param(
|
||
[string]$ProjectRoot = (Split-Path -Parent $PSScriptRoot),
|
||
[string]$DailyTime = '06:00',
|
||
[int]$StaleMinutes = 120
|
||
)
|
||
|
||
Set-StrictMode -Version Latest
|
||
$ErrorActionPreference = 'Stop'
|
||
|
||
$hbPath = Join-Path $ProjectRoot 'state\heartbeat.json'
|
||
$now = Get-Date
|
||
|
||
if (-not (Test-Path -LiteralPath $hbPath)) {
|
||
Write-Host "[heartbeat] 파일이 없습니다: $hbPath" -ForegroundColor Red
|
||
Write-Host ' → 성공 실행이 한 번도 없습니다. 다음을 실행하세요:' -ForegroundColor Red
|
||
Write-Host ' .\.venv\Scripts\python.exe -m dmf_crawler doctor'
|
||
exit 2
|
||
}
|
||
|
||
$hb = Get-Content -LiteralPath $hbPath -Raw -Encoding UTF8 | ConvertFrom-Json
|
||
$upd = [datetime]::Parse($hb.updated_at)
|
||
|
||
$parts = $DailyTime.Split(':')
|
||
$todayRun = $now.Date.AddHours([int]$parts[0]).AddMinutes([int]$parts[1])
|
||
$expected = if ($now -ge $todayRun) { $todayRun } else { $todayRun.AddDays(-1) }
|
||
$deadline = $expected.AddMinutes($StaleMinutes)
|
||
$ageMin = [int]($now - $upd).TotalMinutes
|
||
|
||
Write-Host ''
|
||
Write-Host ' DMF Crawler heartbeat' -ForegroundColor Cyan
|
||
Write-Host ' ---------------------------------------------------------------'
|
||
Write-Host (" 마지막 성공 : {0:yyyy-MM-dd HH:mm:ss} ({1}분 전)" -f $upd, $ageMin)
|
||
Write-Host (" run_id : {0}" -f $hb.run_id)
|
||
Write-Host (" 상태 : {0} (exit={1})" -f $hb.status, $hb.exit_code)
|
||
Write-Host (" 수집 건수 : {0:N0}" -f $hb.records_total)
|
||
Write-Host (" 변경 : 신규 {0} / 변경 {1} / 취하 {2}" -f $hb.events.new, $hb.events.changed, $hb.events.withdrawn)
|
||
Write-Host (" 소요 : {0}초" -f $hb.duration_seconds)
|
||
Write-Host (" AI : {0} (토큰 {1:N0})" -f $hb.agy.status, $hb.agy.tokens)
|
||
Write-Host (" 리포트 : {0}" -f $hb.report_path)
|
||
Write-Host (" 로그 : {0}" -f $hb.log_dir)
|
||
Write-Host (" 연속 실패 : {0}" -f $hb.consecutive_failures)
|
||
Write-Host (" 기준 예정시각 : {0:yyyy-MM-dd HH:mm} (유예 {1}분 → {2:HH:mm})" -f $expected, $StaleMinutes, $deadline)
|
||
Write-Host ' ---------------------------------------------------------------'
|
||
|
||
if ($upd -ge $expected) {
|
||
if ($hb.status -eq 'PARTIAL') {
|
||
Write-Host ' 판정: DEGRADED — 리포트는 나왔지만 일부 단계가 실패했습니다.' -ForegroundColor Yellow
|
||
Write-Host ' → 로그 폴더의 pipeline.log 에서 WARN 을 확인하세요.'
|
||
exit 1
|
||
}
|
||
Write-Host ' 판정: OK — 최근 예정 실행이 성공했습니다.' -ForegroundColor Green
|
||
exit 0
|
||
}
|
||
|
||
if ($now -lt $deadline) {
|
||
Write-Host ' 판정: GRACE — 아직 유예 시간 안입니다(실행 중이거나 재시도 중).' -ForegroundColor Yellow
|
||
exit 0
|
||
}
|
||
|
||
Write-Host ' 판정: STALE — 예정 실행의 성공 기록이 없습니다!' -ForegroundColor Red
|
||
Write-Host ''
|
||
Write-Host ' 복구 순서:' -ForegroundColor Red
|
||
Write-Host ' 1) Get-ScheduledTask -TaskPath "\DMF_Crawler\" | Get-ScheduledTaskInfo'
|
||
Write-Host ' 2) .\.venv\Scripts\python.exe -m dmf_crawler doctor'
|
||
Write-Host ' 3) .\.venv\Scripts\python.exe -m dmf_crawler run --trigger manual'
|
||
exit 2
|
||
```
|
||
|
||
---
|
||
|
||
## 5. 재부팅 · 전원 · 시각 시나리오 전체
|
||
|
||
### 5.1 시나리오 표 — "이 상황에서 06:00 배치는 어떻게 되는가"
|
||
|
||
| # | 상황 | 06:00 에 실행되는가 | 무엇이 구해주는가 | 사람이 할 일 |
|
||
|---|---|---|---|---|
|
||
| 1 | PC 켜져 있고 로그온 | ✅ 06:00±4분 | Daily 트리거 | 없음 |
|
||
| 2 | PC 켜져 있고 **로그오프** | ✅ | S4U/Password 로그온 타입 | 없음. 알림만 다음 로그온까지 지연 |
|
||
| 3 | PC 켜져 있고 **화면 잠금** | ✅ | 잠금은 세션 종료가 아니다 | 없음 |
|
||
| 4 | **절전(S3/모던 대기)** | ✅ 깨워서 실행 | `WakeToRun` + 전원 관리의 절전 해제 타이머 허용 | §5.4 의 powercfg 1회 설정 |
|
||
| 5 | **최대 절전(S4)** | △ 하드웨어 의존 | `WakeToRun` 은 S4 에서도 시도하지만 보장 없음 | §5.4 로 최대 절전 자체를 끄는 것이 확실 |
|
||
| 6 | **완전히 꺼짐** | ❌ 06:00 에는 못 돎 | 켜지면 `StartWhenAvailable` 이 즉시 캐치업 + 부팅 트리거(+5분) | 없음 |
|
||
| 7 | 06:00 직전 재부팅 중 | ❌ 그 순간엔 못 돎 | `StartWhenAvailable` 캐치업 | 없음 |
|
||
| 8 | Windows Update 재시작이 06:00 에 걸림 | ❌ | 활성 시간 05:00–23:00 으로 **예방** + `StartWhenAvailable` | §5.6 1회 설정 |
|
||
| 9 | BitLocker **PIN** 입력 대기 | ❌ 부팅이 멈춰 있음 | 없음 — OS 가 아직 안 떴다 | §5.9 판단 |
|
||
| 10 | 네트워크가 아직 안 올라옴(부팅 직후) | △ | `BootTrigger Delay PT5M` + `RunOnlyIfNetworkAvailable` + 코드의 재시도 4회 | 없음 |
|
||
| 11 | 06:00 실행 중 배터리로 전환 | ✅ 계속 실행 | `StopIfGoingOnBatteries=false` | 없음 |
|
||
| 12 | 배터리로만 구동 중 | ✅ | `DisallowStartIfOnBatteries=false` | 없음 |
|
||
| 13 | 실행이 30분 초과 | ❌ 강제 종료 | `RestartCount 3` × `PT10M` 재시도 + 스테이지 체크포인트 재개 | 반복되면 `source.page_size` 조정 |
|
||
| 14 | 06:00 실행 성공 후 07:00 재부팅 | 재실행 안 함 | **idempotency 가드**(오늘 SUCCESS → 종료 0) | 없음 |
|
||
| 15 | 여러 날 꺼져 있다가 켜짐 | 당일분 1회만 | `StartWhenAvailable` 은 **놓친 실행을 한 번만** 몰아 실행한다 | 과거분은 `backfill` 로 |
|
||
|
||
### 5.2 부팅 후 네트워크 대기
|
||
|
||
부팅 트리거가 뜨는 시점에 네트워크 스택이 준비돼 있다는 보장이 없다. 3중으로 막는다.
|
||
|
||
1. **`<BootTrigger><Delay>PT5M</Delay>`** — 부팅 후 5분 대기. `New-ScheduledTaskTrigger -AtStartup` 에는 `-Delay` 파라미터가 **없어서** CIM 인스턴스의 `Delay` 속성을 직접 채운다(§2.3 코드 참조).
|
||
2. **`RunOnlyIfNetworkAvailable=true`** — 네트워크가 없으면 아예 시작하지 않는다. 이때 이벤트 ID **112 (`JobNoStartWithoutNetwork`)** 가 기록된다.
|
||
3. **코드의 재시도** — `source.max_attempts=4`, `backoff_base_seconds=5.0`, `Retry-After` 절대 우선. DNS 가 잠깐 안 되는 정도는 여기서 흡수된다.
|
||
|
||
`config.toml` 의 `schedule.startup_delay_minutes` 를 늘리면 1번이 함께 늘어난다. 무선 랜만 쓰는 PC 에서 5분이 부족하면 10분으로 올린다.
|
||
|
||
### 5.3 놓친 작업 실행(캐치업)의 정확한 의미
|
||
|
||
`StartWhenAvailable=true` 의 공식 정의는 "Specifies that the Task Scheduler can start the task at any time after its scheduled time has passed." 실무적으로 알아야 할 것:
|
||
|
||
- **여러 번 놓쳐도 몰아서 여러 번 돌지 않는다.** 3일 꺼져 있다가 켜면 1회 실행된다. 과거 3일치 데이터가 필요하면 `backfill` 을 쓴다.
|
||
- 캐치업 실행은 즉시가 아니라 **최대 10분 이내**에 트리거된다(스케줄러 내부 동작). 이벤트 ID **114 (`MissedTaskLaunched`)** 로 확인할 수 있다.
|
||
- 캐치업과 부팅 트리거가 겹쳐 **두 번 뜰 수 있다.** `MultipleInstances=IgnoreNew`(동시) + idempotency 가드(순차)가 둘 다 흡수한다. 이벤트 ID **322 (`NewInstanceIgnored`)** 가 보이면 정상 동작이다.
|
||
|
||
### 5.4 절전 · 최대 절전 (powercfg)
|
||
|
||
**관리자 권한 명령 프롬프트/PowerShell 에서 1회 실행.**
|
||
|
||
```powershell
|
||
# ── (0) 현재 상태 확인 ─────────────────────────────────────────────
|
||
powercfg /a # 이 PC 가 지원하는 절전 상태 (S0 모던 대기 여부 확인)
|
||
powercfg /waketimers # 현재 등록된 절전 해제 타이머
|
||
powercfg /devicequery wake_armed # 깨울 수 있는 장치
|
||
powercfg /lastwake # 마지막에 무엇이 깨웠는가
|
||
|
||
# ── (1) 절전 해제 타이머 '사용'으로 (WakeToRun 이 실제로 동작하려면 필수) ──
|
||
# SUB_SLEEP = 238c9fa8-0aad-41ed-83f4-97be242c8f20
|
||
# 절전 해제 타이머 허용 = bd3b718a-0680-4d9d-8ab2-e1d2b4ac806d
|
||
powercfg /setacvalueindex SCHEME_CURRENT 238c9fa8-0aad-41ed-83f4-97be242c8f20 bd3b718a-0680-4d9d-8ab2-e1d2b4ac806d 1
|
||
powercfg /setdcvalueindex SCHEME_CURRENT 238c9fa8-0aad-41ed-83f4-97be242c8f20 bd3b718a-0680-4d9d-8ab2-e1d2b4ac806d 1
|
||
powercfg /setactive SCHEME_CURRENT
|
||
|
||
# ── (2) 데스크톱 PC 권장: AC 전원에서는 아예 안 잔다 ───────────────
|
||
powercfg /change standby-timeout-ac 0 # 0 = 사용 안 함
|
||
powercfg /change hibernate-timeout-ac 0
|
||
powercfg /change monitor-timeout-ac 15 # 화면만 끈다 (전기·수명)
|
||
powercfg /change disk-timeout-ac 0
|
||
|
||
# ── (3) 노트북: 배터리에서도 06:00 을 지키고 싶다면 ────────────────
|
||
powercfg /change standby-timeout-dc 0
|
||
powercfg /change hibernate-timeout-dc 0
|
||
# → 배터리 소모가 커진다. 그래도 실행이 우선이면 이렇게 한다.
|
||
|
||
# ── (4) 적용 확인 ─────────────────────────────────────────────────
|
||
powercfg /query SCHEME_CURRENT SUB_SLEEP
|
||
powercfg /waketimers
|
||
```
|
||
|
||
**`powercfg /waketimers` 결과 읽는 법**: 작업을 등록하고 `WakeToRun=true` 라면 다음 06:00 을 가리키는 항목이 하나 보여야 한다. 아무것도 안 보이면 (1) 단계를 안 했거나, 이 PC 가 **모던 대기(S0)** 라서 표기가 다를 수 있다. `powercfg /a` 의 출력에 `대기 (S0 짧은 지연 사용 가능)` 이 있으면 모던 대기 기기다.
|
||
|
||
> **모던 대기(S0) 주의**: S0 기기에서는 "절전" 이 사실상 저전력 유지 상태라 예약 작업이 대체로 잘 돈다. 다만 네트워크가 오프로드 상태일 수 있어 첫 요청이 실패할 수 있다 — 코드의 재시도 4회가 흡수한다.
|
||
|
||
### 5.5 Fast Startup(빠른 시작)과 `AtStartup` 트리거
|
||
|
||
**핵심 사실**: 종료(Shutdown)를 눌러도 Windows 는 실제로 완전히 끄지 않는다. 커널 세션을 `hiberfil.sys` 에 저장하는 **하이브리드 종료**를 하고, 다음 켜기는 **최대 절전 복귀**다. 그래서 **"시스템 시작 시" 트리거가 뜨지 않는다.**
|
||
|
||
- **재시작(Restart)에는 Fast Startup 이 적용되지 않는다.** 재시작은 진짜 부팅이므로 `AtStartup` 이 뜬다.
|
||
- Fast Startup 은 **Windows 기본값으로 켜져 있고, Microsoft 는 끄는 것을 권장하지 않는다.**
|
||
|
||
**우리의 방침**:
|
||
|
||
1. **Fast Startup 을 끄지 않는다.** 부팅 트리거를 안전망으로 삼지 않기 때문이다. 진짜 안전망은 `StartWhenAvailable` 이고, 그것은 Fast Startup 과 무관하게 동작한다.
|
||
2. 부팅 트리거는 **"재시작 후 빠른 복귀"** 용 보조 장치로만 취급한다.
|
||
3. 그래도 끄고 싶다면(예: 이 PC 가 무인 서버 용도):
|
||
|
||
```powershell
|
||
# 최대 절전 파일을 없애면 Fast Startup 도 함께 꺼진다
|
||
powercfg /h off
|
||
|
||
# 확인 — HiberbootEnabled 가 0 이면 Fast Startup 꺼짐
|
||
Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\Power' |
|
||
Select-Object HiberbootEnabled
|
||
|
||
# 되돌리기
|
||
powercfg /h on
|
||
```
|
||
|
||
> `powercfg /h off` 는 **최대 절전 기능 자체를 없앤다.** 노트북에서 최대 절전을 쓰고 있다면 부작용을 감수해야 한다. 그 경우 `HiberbootEnabled` 만 0 으로 두는 방법도 있으나, 최대 절전과 Fast Startup 의 상호작용이 기기마다 달라 ⚠️ 실측을 권한다.
|
||
|
||
### 5.6 Windows Update 재시작과 06:00 충돌 회피
|
||
|
||
Windows Update 는 설치 후 **활성 시간(Active hours) 밖에서** 자동 재시작한다. 기본 활성 시간은 08:00–17:00 이므로 **06:00 이 재시작 창에 정통으로 들어간다.**
|
||
|
||
- 활성 시간 **최대 범위는 18시간**(Windows 10 1607/Server 2016 은 12시간).
|
||
- 05:00 시작 → 23:00 종료 = 18시간. **06:00 을 보호하면서 최대 범위에 딱 맞는다.**
|
||
|
||
```powershell
|
||
# ── 관리자 PowerShell ──────────────────────────────────────────────
|
||
# (A) 정책 경로 (권장 · 사용자가 UI 에서 못 바꾸게 고정)
|
||
$policy = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate'
|
||
New-Item -Path $policy -Force | Out-Null
|
||
Set-ItemProperty -Path $policy -Name 'SetActiveHours' -Value 1 -Type DWord
|
||
Set-ItemProperty -Path $policy -Name 'ActiveHoursStart' -Value 5 -Type DWord
|
||
Set-ItemProperty -Path $policy -Name 'ActiveHoursEnd' -Value 23 -Type DWord
|
||
|
||
# (B) 사용자 설정 경로 (UI 의 '활성 시간'과 같은 값)
|
||
$ux = 'HKLM:\SOFTWARE\Microsoft\WindowsUpdate\UX\Settings'
|
||
New-Item -Path $ux -Force | Out-Null
|
||
Set-ItemProperty -Path $ux -Name 'ActiveHoursStart' -Value 5 -Type DWord
|
||
Set-ItemProperty -Path $ux -Name 'ActiveHoursEnd' -Value 23 -Type DWord
|
||
|
||
# ── 확인 ──────────────────────────────────────────────────────────
|
||
Get-ItemProperty -Path $policy | Select-Object SetActiveHours, ActiveHoursStart, ActiveHoursEnd
|
||
Get-ItemProperty -Path $ux | Select-Object ActiveHoursStart, ActiveHoursEnd
|
||
```
|
||
|
||
GUI 경로: **설정 → Windows Update → 고급 옵션 → 활성 시간**.
|
||
|
||
> **완벽하지는 않다.** 마감 기한(deadline)을 넘긴 강제 재시작은 활성 시간을 무시할 수 있다. 그래서 `StartWhenAvailable` 이 여전히 필요하다. 재시작으로 06:00 을 놓치면 부팅 후 5분(부팅 트리거) 또는 캐치업으로 자동 회복된다.
|
||
|
||
### 5.7 시간대 · DST
|
||
|
||
```powershell
|
||
tzutil /g # 반드시 "Korea Standard Time" 이어야 한다
|
||
w32tm /query /status
|
||
w32tm /resync # 시각이 틀어졌으면
|
||
```
|
||
|
||
- **한국은 1988년 이후 서머타임을 시행하지 않는다.** KST = UTC+9 고정. DST 전환 시 작업이 1시간 일찍/늦게 도는 문제는 이 PC 에서 발생하지 않는다.
|
||
- 트리거 속성의 **"표준 시간대에 맞춰 동기화(Synchronize across time zones)" 를 켜지 마라.** 켜면 `StartBoundary` 가 UTC 로 해석되어(`...Z` 접미사) 로컬 06:00 의 의미가 흔들린다. 우리 XML 의 `<StartBoundary>2026-09-02T06:00:00</StartBoundary>` 에는 **의도적으로 `Z` 나 오프셋이 없다** — 로컬 시각이라는 뜻이다.
|
||
- 코드 쪽 일자 판정은 `general.timezone = "Asia/Seoul"` 로 못박혀 있어 OS 시간대가 바뀌어도 `run_date` 는 흔들리지 않는다.
|
||
- 노트북을 해외에 들고 나가 OS 시간대를 바꾸면 배치는 **그 지역의 06:00** 에 돈다. `run_date` 는 KST 기준이므로 하루에 두 번 돌거나 건너뛸 수 있다 — idempotency 가드가 중복은 막지만, 장기 해외 체류라면 시간대를 바꾸지 않는 편이 낫다.
|
||
|
||
### 5.8 자동 로그온이 필요한가 — 결론: **배치는 불필요, 알림은 조건부**
|
||
|
||
| 대상 | 자동 로그온 필요? | 이유 |
|
||
|---|---|---|
|
||
| `DMF_Crawler_Daily`(배치) | **불필요** | S4U/Password 로그온 타입은 사용자가 로그오프 상태여도 실행된다 |
|
||
| `DMF_Crawler_AgyUpdate` | **불필요** | 동일 |
|
||
| `DMF_Crawler_Agent`(알림·워치독) | **필요할 수도** | Interactive 는 로그온한 세션에서만 돈다. 로그오프 상태면 알림이 **지연**된다 |
|
||
|
||
**로그오프 상태에서 실패했을 때 무슨 일이 일어나는가** (아키텍처 실패 경로 [F]):
|
||
|
||
1. 배치가 `alerts` 테이블 + `state\alerts.json` + Windows 이벤트 로그에 기록한다.
|
||
2. 화면에는 아무것도 안 뜬다.
|
||
3. 다음 로그온 순간 `DMF_Crawler_Agent` 의 로그온 트리거가 즉시 발화해 **밀린 알림을 전부 표시**한다.
|
||
|
||
즉 **알림이 사라지는 게 아니라 늦어질 뿐이다.** 대부분의 개인 PC 운용에서는 이걸로 충분하다.
|
||
|
||
**그래도 즉시 알림이 필요하다면** — 세 가지 대안, 위험도 순:
|
||
|
||
| 대안 | 방법 | 위험 |
|
||
|---|---|---|
|
||
| **A. 로그온한 채 화면만 잠금** (권장) | `Win+L`. 세션은 살아 있으므로 Agent 가 계속 돈다 | 없음. 물리 보안은 잠금 화면이 유지 |
|
||
| **B. 자동 로그온 + 즉시 잠금** | `netplwiz` 또는 Sysinternals `Autologon.exe` 로 자동 로그온 설정 후, 시작 프로그램에 `rundll32.exe user32.dll,LockWorkStation` 등록 | 자동 로그온은 자격증명을 레지스트리에 남긴다(`DefaultPassword`). **Autologon.exe 는 LSA 비밀에 저장해 그나마 낫다.** BitLocker PIN 과 병용 시 무의미(§5.9) |
|
||
| **C. 웹훅 알림 추가** | 배치가 실패 시 디스코드/슬랙 웹훅 호출 | 의존성·비밀 관리 증가. **현재 범위 밖(부록 참조)** |
|
||
|
||
**기본 권고는 A 다.** B 는 이 PC 에 다른 민감 데이터가 없고 물리적으로 안전한 위치일 때만.
|
||
|
||
### 5.9 BitLocker · PIN 의 영향
|
||
|
||
| 구성 | 무인 재부팅 후 06:00 배치 | 판정 |
|
||
|---|---|---|
|
||
| BitLocker 미사용 | ✅ 정상 | 문제 없음 |
|
||
| **TPM 전용** (PIN 없음) | ✅ 정상 — TPM 이 자동으로 볼륨을 해제하고 OS 가 부팅된다 | **이 프로젝트에 권장** |
|
||
| **TPM + PIN**(사전 부팅 PIN) | ❌ **부팅이 PIN 입력 화면에서 멈춘다.** 사람이 PIN 을 넣기 전까지 OS 자체가 뜨지 않으므로 작업 스케줄러도 없다 | 자동화와 정면 충돌 |
|
||
| TPM + 시작 키(USB) | ❌ 동일 | 동일 |
|
||
|
||
> Microsoft 문서: "Preboot authentication can make it more difficult to update unattended or remotely administered devices because a PIN must be entered when a device reboots or resumes from hibernation." / "The only supported silent configuration for BitLocker involves the TPM only."
|
||
|
||
**PIN 을 유지해야 한다면**: 재부팅 후 사람이 PIN 을 넣는 순간 OS 가 뜨고, 그때 `StartWhenAvailable` 이 놓친 06:00 을 즉시 실행한다. 즉 **자동 복구는 되지만 시각은 밀린다.** 이 지연을 허용할 수 없다면 TPM 전용으로 바꾸거나(보안 팀 승인 필요), 06:00 을 사람이 PC 앞에 있는 시각으로 옮겨야 한다.
|
||
|
||
현재 PC 의 상태 확인:
|
||
|
||
```powershell
|
||
manage-bde -status C:
|
||
manage-bde -protectors -get C: # TpmPin 이 보이면 PIN 구성이다
|
||
```
|
||
|
||
### 5.10 재부팅 내성 실증 절차 (설치 후 1회 반드시 수행)
|
||
|
||
```powershell
|
||
# [테스트 1] 캐치업이 실제로 도는가 — 가장 중요한 테스트
|
||
# 1) 작업의 Daily 트리거 시각을 '지금부터 5분 뒤'로 임시 변경
|
||
# 2) PC 를 종료하고 10분 대기
|
||
# 3) PC 를 켜고 로그온하지 않은 채 5분 대기
|
||
# 4) 로그온해서 확인:
|
||
Get-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Daily' | Get-ScheduledTaskInfo
|
||
Get-WinEvent -LogName 'Microsoft-Windows-TaskScheduler/Operational' -MaxEvents 50 |
|
||
Where-Object { $_.Message -like '*DMF_Crawler_Daily*' } |
|
||
Select-Object TimeCreated, Id, LevelDisplayName, Message | Format-List
|
||
# → 이벤트 114(MissedTaskLaunched) 또는 118(BootTrigger) 이 보이면 성공
|
||
# 5) 트리거 시각을 06:00 으로 되돌린다 (install_tasks.ps1 재실행)
|
||
|
||
# [테스트 2] 재시작(Restart)에서 부팅 트리거가 뜨는가
|
||
Restart-Computer # Fast Startup 이 적용되지 않는 경로
|
||
# → 부팅 후 5분 뒤 이벤트 118 확인
|
||
|
||
# [테스트 3] 절전에서 깨어나 실행하는가
|
||
# 1) 트리거를 '지금부터 10분 뒤'로 변경
|
||
# 2) 즉시 절전 진입
|
||
rundll32.exe powrprof.dll,SetSuspendState 0,1,0
|
||
# 3) 15분 뒤 확인
|
||
powercfg /lastwake
|
||
# → "절전 해제 원본: 타이머 - ... DMF_Crawler_Daily" 가 보이면 성공
|
||
|
||
# [테스트 4] 로그오프 상태에서 도는가 (S4U/Password 검증)
|
||
# 1) 로그오프
|
||
# 2) 트리거 시각 경과 후 다시 로그온
|
||
# 3) state\heartbeat.json 의 updated_at 이 갱신됐는지 확인
|
||
.\scripts\check_heartbeat.ps1
|
||
```
|
||
|
||
---
|
||
|
||
## 6. 중복 실행 방지
|
||
|
||
### 6.1 3중 방어와 각 층이 막는 것
|
||
|
||
| 층 | 구현 | 막는 것 | 못 막는 것 |
|
||
|---|---|---|---|
|
||
| ① 스케줄러 | `MultipleInstances=IgnoreNew` | **동시** 실행(06:00 트리거와 부팅 트리거가 겹칠 때) | 순차 재실행 |
|
||
| ② 파일 락 | `state\run.lock` + `msvcrt.locking` | 스케줄러 밖에서 시작된 동시 실행(수동 + 자동) | 순차 재실행 |
|
||
| ③ 코드 가드 | `repo.last_success_run_on(오늘)` 조회 | **순차** 재실행(06:00 성공 후 07:00 재부팅 캐치업) | 의도적 재실행(`--force` 로 우회) |
|
||
|
||
**③ 이 없으면 append-only 스키마가 오염된다.** 같은 날 스냅샷이 두 벌 들어가면 다음 날 diff 의 기준선이 어느 쪽인지 모호해진다. 스케줄러 설정만 믿는 설계는 여기서 무너진다.
|
||
|
||
### 6.2 `src\dmf_crawler\runlock.py` 전문
|
||
|
||
```python
|
||
"""state\\run.lock 배타 락. Windows 전용(msvcrt).
|
||
|
||
파일 끝 멀찍한 오프셋의 1바이트를 잠그고, 사람이 읽을 메타데이터는 파일
|
||
앞쪽(잠그지 않은 영역)에 고정 길이로 쓴다. 잠근 영역을 truncate 하면
|
||
Windows 에서 오류가 나기 때문이다.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import contextlib
|
||
import datetime as _dt
|
||
import msvcrt
|
||
import os
|
||
import socket
|
||
from collections.abc import Iterator
|
||
from pathlib import Path
|
||
|
||
from .errors import DmfError
|
||
|
||
_LOCK_OFFSET = 1_000_000 # 이 위치의 1바이트를 잠근다
|
||
_META_SIZE = 256 # 파일 앞 256바이트에 메타데이터를 고정 길이로 기록
|
||
|
||
|
||
class LockBusy(DmfError):
|
||
"""다른 인스턴스가 이미 락을 쥐고 있다."""
|
||
|
||
|
||
def read_holder(lock_path: Path) -> str:
|
||
"""락 파일 앞부분의 메타데이터를 그대로 읽는다(진단용). 실패하면 빈 문자열."""
|
||
try:
|
||
with open(lock_path, "rb") as fh:
|
||
return fh.read(_META_SIZE).decode("utf-8", "replace").rstrip("\x00 \n")
|
||
except OSError:
|
||
return ""
|
||
|
||
|
||
@contextlib.contextmanager
|
||
def exclusive(lock_path: Path, *, run_id: str = "", trigger: str = "") -> Iterator[None]:
|
||
"""배타 락을 잡는다. 이미 잡혀 있으면 LockBusy 를 던진다(대기하지 않는다).
|
||
|
||
사용:
|
||
try:
|
||
with runlock.exclusive(paths.STATE / "run.lock", run_id=rid):
|
||
...
|
||
except runlock.LockBusy:
|
||
log.info("다른 인스턴스가 실행 중 — 종료 코드 0")
|
||
return 0
|
||
"""
|
||
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
||
|
||
# a+b: 없으면 만들고, 있으면 내용을 지우지 않는다.
|
||
fh = open(lock_path, "a+b")
|
||
try:
|
||
# 파일이 오프셋보다 짧으면 잠글 바이트가 없다. 미리 늘려 둔다.
|
||
fh.seek(0, os.SEEK_END)
|
||
if fh.tell() <= _LOCK_OFFSET:
|
||
fh.write(b"\x00" * (_LOCK_OFFSET + 1 - fh.tell()))
|
||
fh.flush()
|
||
|
||
fh.seek(_LOCK_OFFSET)
|
||
try:
|
||
msvcrt.locking(fh.fileno(), msvcrt.LK_NBLCK, 1)
|
||
except OSError as exc:
|
||
holder = read_holder(lock_path)
|
||
raise LockBusy(
|
||
f"이미 실행 중입니다. lock={lock_path} holder=[{holder}]"
|
||
) from exc
|
||
|
||
# 여기부터 락 보유 구간
|
||
meta = (
|
||
f"pid={os.getpid()} host={socket.gethostname()} "
|
||
f"user={os.environ.get('USERNAME', '')} "
|
||
f"run_id={run_id} trigger={trigger} "
|
||
f"acquired={_dt.datetime.now().astimezone().isoformat(timespec='seconds')}"
|
||
).encode("utf-8")[: _META_SIZE - 1]
|
||
fh.seek(0)
|
||
fh.write(meta.ljust(_META_SIZE, b" ") + b"\n")
|
||
fh.flush()
|
||
os.fsync(fh.fileno())
|
||
|
||
try:
|
||
yield
|
||
finally:
|
||
fh.seek(_LOCK_OFFSET)
|
||
with contextlib.suppress(OSError):
|
||
msvcrt.locking(fh.fileno(), msvcrt.LK_UNLCK, 1)
|
||
finally:
|
||
fh.close()
|
||
```
|
||
|
||
**왜 뮤텍스(`CreateMutex`)가 아니라 파일 락인가**
|
||
|
||
| | 파일 락 (채택) | 네임드 뮤텍스 |
|
||
|---|---|---|
|
||
| 세션 경계 | 파일이므로 **세션·로그온 타입 무관** | `Global\` 접두사가 없으면 세션마다 별개. S4U 세션과 Interactive 세션이 서로를 못 본다 |
|
||
| 잔해 | 프로세스가 죽으면 OS 가 핸들을 닫아 락이 자동 해제 | 동일하나, 소유권 포기(abandoned) 처리를 코드가 다뤄야 함 |
|
||
| 진단성 | **누가 잡고 있는지 파일을 열어보면 안다** | 밖에서 볼 방법이 사실상 없다 |
|
||
| 의존성 | stdlib `msvcrt` | `ctypes` 로 Win32 직접 호출 |
|
||
|
||
세션 경계 문제 하나만으로 결정된다. 배치는 S4U 세션, 수동 실행은 Interactive 세션에서 뜨는데 뮤텍스는 `Global\` 을 빼먹는 순간 조용히 무력화된다.
|
||
|
||
### 6.3 idempotency 가드의 정확한 규칙
|
||
|
||
```
|
||
run --trigger scheduled|startup 로 진입
|
||
└─ repo.last_success_run_on(today, tz="Asia/Seoul") 조회
|
||
├─ status ∈ {SUCCESS, PARTIAL} 인 run 이 있다 → "SKIPPED" 기록 후 종료 코드 0
|
||
└─ 없다 → 정상 진행
|
||
|
||
--force 가 붙으면 이 조회를 건너뛴다.
|
||
report-only / backfill / doctor 는 가드 대상이 아니다.
|
||
```
|
||
|
||
- **`PARTIAL` 도 "오늘은 이미 돌았다"로 친다.** 리포트가 나왔기 때문이다. 다시 돌리고 싶으면 `--force`.
|
||
- `FAILED` / `BLOCKED` 는 가드에 걸리지 않는다 → 재시작(`RestartCount`)이 정상적으로 재시도한다.
|
||
- 스테이지 체크포인트(`stage_status`)와는 다른 층이다. 체크포인트는 **같은 `run_id` 안에서** 성공한 스테이지를 건너뛰고, 가드는 **다른 `run_id` 의 재실행 자체**를 막는다.
|
||
|
||
### 6.4 락이 걸려 있을 때 사람이 하는 일
|
||
|
||
```powershell
|
||
# 1) 누가 잡고 있는지 본다
|
||
Get-Content -LiteralPath 'D:\workspace\DMF_Crawler\state\run.lock' -TotalCount 1
|
||
|
||
# 2) 그 PID 가 정말 살아 있는지 확인
|
||
Get-Process -Id <PID> -ErrorAction SilentlyContinue |
|
||
Select-Object Id, ProcessName, StartTime, Path
|
||
|
||
# 3) 살아 있으면 기다린다(정상). ExecutionTimeLimit 30분 안에 끝난다.
|
||
# 4) 죽어 있는데 락이 남아 있다면 → 있을 수 없는 상황이다.
|
||
# OS 가 핸들을 닫으면서 락도 풀리기 때문이다.
|
||
# 그래도 의심되면 파일을 지운다(실행 중이 아님을 확인한 뒤에만):
|
||
Remove-Item -LiteralPath 'D:\workspace\DMF_Crawler\state\run.lock' -Force
|
||
|
||
# 5) 작업 자체를 강제 종료해야 한다면
|
||
Stop-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Daily'
|
||
```
|
||
|
||
---
|
||
|
||
## 7. 로그 규약
|
||
|
||
### 7.1 실행 ID
|
||
|
||
- 형식: **`YYYYMMDD_HHMMSS`** (KST, 프로세스 시작 시각). 예: `20260902_060012`
|
||
- 생성: `run` 진입 직후, 로그 디렉터리를 만들기 전에 한 번.
|
||
- 전파: `runs.run_id`(PK) · `stage_status` · `snapshots` · `events` · `agy_calls` · `heartbeat.json` · 로그 디렉터리 이름 · 알림 레코드에 **동일한 값**이 들어간다.
|
||
- 지터로 06:00:00 이 아니라 06:04:12 에 시작해도 `run_date` 는 `2026-09-02` 다. **`run_id` 와 `run_date` 를 혼동하지 마라.**
|
||
|
||
### 7.2 디렉터리와 파일
|
||
|
||
```
|
||
D:\workspace\DMF_Crawler\logs\
|
||
└── run_20260902_060012\
|
||
├── pipeline.log 사람이 읽는 전체 로그 (텍스트)
|
||
├── events.jsonl 기계가 읽는 구조화 이벤트 (1줄 1 JSON)
|
||
├── agy.stdout.json agy JSON 봉투 원문 (감사용, 손대지 않은 원본)
|
||
├── agy.stderr.log agy 진단 출력 (stdout 과 절대 섞지 않는다)
|
||
└── agy_cli.log agy --log-file 로 지정한 agy 내부 로그
|
||
```
|
||
|
||
**실행 1회 = 디렉터리 1개.** 조사의 시작점이 "`logs` 에서 가장 최근 디렉터리를 연다" 하나로 고정된다. 롤링 단일 파일은 여러 실행이 뒤섞여 "어느 줄이 어제 것인지" 를 매번 다시 따져야 한다.
|
||
|
||
`agy` 의 stdout 과 stderr 을 분리하는 것은 **agy SSOT §5.3 규약**이다. 섞으면 JSON 파싱이 오염된다.
|
||
|
||
### 7.3 `pipeline.log` 포맷
|
||
|
||
```
|
||
2026-09-02 06:00:12.431 +0900 | INFO | run | run_id=20260902_060012 trigger=scheduled 시작
|
||
2026-09-02 06:00:12.502 +0900 | INFO | runlock | 락 획득 state\run.lock
|
||
2026-09-02 06:00:12.610 +0900 | INFO | preflight | 스키마 버전 3, integrity_check ok, 여유 공간 214.6GB
|
||
2026-09-02 06:00:13.004 +0900 | INFO | fetch | totalCount=12874 pages=129 page_size=100
|
||
2026-09-02 06:01:47.882 +0900 | WARNING | http | 429 Retry-After=30 page=61 attempt=1 대기 30.0s
|
||
2026-09-02 06:02:31.115 +0900 | INFO | fetch | 완료 12874건 129페이지 138.1s 아카이브=data\raw\2026-09-02
|
||
2026-09-02 06:02:33.900 +0900 | INFO | normalize | 12874건 정규화, dmf_key 충돌 2건 결정론적 접미사 부여
|
||
2026-09-02 06:02:34.220 +0900 | INFO | integrity | 게이트 5/5 통과 (drop=0.001 null=0.000 dup=0.0002)
|
||
2026-09-02 06:02:34.905 +0900 | INFO | diff | new=3 changed=1 withdrawn=0 base_run=20260901_060008
|
||
2026-09-02 06:02:36.470 +0900 | INFO | persist | 스냅샷 12874행 · 이벤트 4행 커밋
|
||
2026-09-02 06:03:41.008 +0900 | INFO | enrich | agy exit=0 status=OK in=28914 out=1204 41.9s
|
||
2026-09-02 06:04:29.771 +0900 | INFO | report | 8시트 생성 → reports\DMF_리포트_2026-09-02.xlsx (원자 교체)
|
||
2026-09-02 06:04:35.310 +0900 | INFO | backup | VACUUM INTO backup\dmf_2026-09-02.sqlite3 (48.2MB), 보존 30개 유지
|
||
2026-09-02 06:04:37.002 +0900 | INFO | finalize | status=SUCCESS exit=0 265.6s heartbeat 갱신
|
||
```
|
||
|
||
| 컬럼 | 내용 |
|
||
|---|---|
|
||
| 1 | 로컬 시각 + **UTC 오프셋**(`+0900`). 오프셋을 빼먹으면 로그를 나중에 못 믿는다 |
|
||
| 2 | 레벨 (`DEBUG`/`INFO`/`WARNING`/`ERROR`) — `logging.level` 로 하한 조절 |
|
||
| 3 | 스테이지 또는 모듈 이름 |
|
||
| 4 | 메시지 |
|
||
|
||
**마스킹**: `logging.mask_patterns`(기본 `["serviceKey", "access_token"]`)에 걸리는 키의 값은 기록 직전에 `***` 로 치환한다. API 키가 URL 쿼리에 들어가므로 **요청 URL 로깅은 항상 마스킹을 통과해야 한다.**
|
||
|
||
### 7.4 `events.jsonl` 스키마
|
||
|
||
한 줄에 JSON 객체 하나. 기계가 읽는다. 필드는 다음을 **항상** 포함한다.
|
||
|
||
| 필드 | 타입 | 설명 |
|
||
|---|---|---|
|
||
| `ts` | ISO8601+오프셋 | 이벤트 시각 |
|
||
| `run_id` | str | 실행 ID |
|
||
| `stage` | str | `run` \| `preflight` \| `fetch` \| … \| `finalize` |
|
||
| `event` | str | 이벤트 이름(아래 표) |
|
||
| `level` | str | `INFO` \| `WARN` \| `ERROR` |
|
||
| `data` | object | 이벤트별 페이로드 |
|
||
|
||
주요 `event` 값과 `data` 필드:
|
||
|
||
| `event` | `data` 주요 키 |
|
||
|---|---|
|
||
| `run_started` | `trigger`, `pid`, `version`, `python`, `run_date` |
|
||
| `stage_started` / `stage_finished` | `stage`, `status`, `duration_ms` |
|
||
| `fetch_page` | `page`, `http_status`, `bytes`, `elapsed_ms`, `attempt` |
|
||
| `http_retry` | `page`, `reason`, `retry_after_s`, `sleep_s`, `attempt` |
|
||
| `fetch_summary` | `total_count`, `pages`, `records`, `duration_ms`, `archive_dir` |
|
||
| `integrity_gate` | `gate`, `passed`, `observed`, `threshold` |
|
||
| `diff_summary` | `new`, `changed`, `withdrawn`, `base_run_id` |
|
||
| `agy_call` | `exit_code`, `status`, `model`, `tokens_in`, `tokens_out`, `duration_ms`, `error_class` |
|
||
| `report_written` | `path`, `sheets`, `bytes`, `fallback_name` |
|
||
| `alert_raised` | `code`, `severity`, `dedup_key`, `suppressed` |
|
||
| `run_finished` | `status`, `exit_code`, `duration_ms`, `records_total` |
|
||
|
||
예시 3줄:
|
||
|
||
```jsonl
|
||
{"ts":"2026-09-02T06:00:12.431+09:00","run_id":"20260902_060012","stage":"run","event":"run_started","level":"INFO","data":{"trigger":"scheduled","pid":18244,"version":"0.1.0","python":"3.12.6","run_date":"2026-09-02"}}
|
||
{"ts":"2026-09-02T06:01:47.882+09:00","run_id":"20260902_060012","stage":"fetch","event":"http_retry","level":"WARN","data":{"page":61,"reason":"429","retry_after_s":30,"sleep_s":30.0,"attempt":1}}
|
||
{"ts":"2026-09-02T06:04:37.002+09:00","run_id":"20260902_060012","stage":"finalize","event":"run_finished","level":"INFO","data":{"status":"SUCCESS","exit_code":0,"duration_ms":265571,"records_total":12874}}
|
||
```
|
||
|
||
**필수 기록 필드 체크리스트** (요구 7항):
|
||
|
||
- [x] 시작 — `run_started`(트리거·PID·버전)
|
||
- [x] 종료 — `run_finished`(상태·종료 코드)
|
||
- [x] 건수 — `fetch_summary.records`, `diff_summary.{new,changed,withdrawn}`
|
||
- [x] 소요 — 각 `stage_finished.duration_ms` + `run_finished.duration_ms`
|
||
- [x] 토큰 사용량 — `agy_call.{tokens_in,tokens_out}`
|
||
- [x] 오류 — `level:"ERROR"` 이벤트 + `alert_raised`
|
||
|
||
### 7.5 로테이션·보존
|
||
|
||
| 대상 | 설정 키 | 기본 | 정리 시점 |
|
||
|---|---|---|---|
|
||
| 로그 디렉터리 `logs\run_*` | `logging.retain_days` | 90일 | `finalize` 스테이지 |
|
||
| API 원문 아카이브 `data\raw\<날짜>` | `source.archive_retain_days` | 180일 | `finalize` 스테이지 |
|
||
| 리포트 `reports\*.xlsx` | `report.retain_days` | 365일 | `finalize` 스테이지 |
|
||
| DB 백업 `backup\*.sqlite3` | `backup.keep_count` | 30개 | `backup` 스테이지 |
|
||
| Task Scheduler Operational 로그 | `wevtutil /maxsize` | 64MB | OS 가 순환 |
|
||
|
||
**파일 단위 롤링(`RotatingFileHandler`)을 쓰지 않는 이유**: 실행 1회당 로그가 수십 KB 로 작고, 디렉터리 단위 보존이 훨씬 단순하다. 롤링은 "이 줄이 어느 실행 것인가" 를 매번 되묻게 만든다.
|
||
|
||
수동으로 지금 정리하려면:
|
||
|
||
```powershell
|
||
$root = 'D:\workspace\DMF_Crawler'
|
||
$cut = (Get-Date).AddDays(-90)
|
||
Get-ChildItem -LiteralPath "$root\logs" -Directory -Filter 'run_*' |
|
||
Where-Object { $_.LastWriteTime -lt $cut } |
|
||
Remove-Item -Recurse -Force -WhatIf # 확인 후 -WhatIf 를 떼고 재실행
|
||
```
|
||
|
||
### 7.6 디스크 사용량 감각
|
||
|
||
| 항목 | 1일 | 90일 | 비고 |
|
||
|---|---|---|---|
|
||
| 로그 디렉터리 | ~60KB | ~5MB | agy 봉투 포함 |
|
||
| API 원문 아카이브 | ~4MB | ~360MB(180일) | 페이지 129개 × ~30KB |
|
||
| 리포트 xlsx | ~1.5MB | ~550MB(365일) | 시트 8종 |
|
||
| DB 증분 | ~3MB | ~270MB | append-only 스냅샷 |
|
||
| DB 백업 | ~50MB | ~1.5GB(30개) | `VACUUM INTO` 압축본 |
|
||
|
||
`backup.min_free_gb`(기본 2.0) 미만이면 백업을 건너뛰고 WARN 을 남긴다. 여유가 10GB 미만으로 떨어지면 `source.archive_retain_days` 를 줄이는 것이 가장 효과가 크다.
|
||
|
||
---
|
||
|
||
## 8. 일상 운영 절차
|
||
|
||
### 8.1 정상 동작 확인 (아침 30초)
|
||
|
||
```powershell
|
||
cd D:\workspace\DMF_Crawler
|
||
|
||
# (1) 가장 빠른 방법 — heartbeat 한 방
|
||
.\scripts\check_heartbeat.ps1
|
||
|
||
# (2) 오늘 리포트가 나왔는가
|
||
Get-ChildItem .\reports -Filter "DMF_리포트_$(Get-Date -Format 'yyyy-MM-dd').xlsx" |
|
||
Format-List Name, Length, LastWriteTime
|
||
|
||
# (3) 스케줄러가 보는 마지막 결과 (0x0 이면 정상)
|
||
Get-ScheduledTask -TaskPath '\DMF_Crawler\' | Get-ScheduledTaskInfo |
|
||
Select-Object TaskName,
|
||
LastRunTime,
|
||
@{ n='LastResult'; e={ '0x{0:X}' -f $_.LastTaskResult } },
|
||
NextRunTime |
|
||
Format-Table -AutoSize
|
||
|
||
# (4) 전체 진단 (느리지만 확실)
|
||
.\.venv\Scripts\python.exe -m dmf_crawler doctor
|
||
|
||
# (5) 오늘 로그를 눈으로
|
||
$last = Get-ChildItem .\logs -Directory -Filter 'run_*' | Sort-Object Name -Descending | Select-Object -First 1
|
||
Get-Content "$($last.FullName)\pipeline.log" -Tail 40
|
||
```
|
||
|
||
### 8.2 수동 재실행
|
||
|
||
```powershell
|
||
cd D:\workspace\DMF_Crawler
|
||
|
||
# (A) 스케줄러를 통해 실행 — 실제 배치와 100% 같은 보안 컨텍스트로 돈다.
|
||
# "스케줄에서만 실패하는" 문제를 재현하는 유일한 방법이다.
|
||
Start-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Daily'
|
||
# 완료 대기
|
||
while ((Get-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Daily').State -eq 'Running') {
|
||
Start-Sleep -Seconds 5; Write-Host '.' -NoNewline
|
||
}
|
||
Get-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Daily' | Get-ScheduledTaskInfo
|
||
|
||
# (B) 콘솔에서 직접 실행 — 로그를 눈으로 보며 디버깅할 때
|
||
.\.venv\Scripts\python.exe -m dmf_crawler run --trigger manual
|
||
|
||
# (C) 오늘 이미 성공했는데 그래도 다시 돌리고 싶다
|
||
.\.venv\Scripts\python.exe -m dmf_crawler run --trigger manual --force
|
||
|
||
# (D) 네트워크를 건드리지 않고 흐름만 점검
|
||
.\.venv\Scripts\python.exe -m dmf_crawler run --trigger manual --dry-run
|
||
|
||
# (E) AI 단계를 건너뛰고 실행 (토큰 절약 / agy 장애 시)
|
||
.\.venv\Scripts\python.exe -m dmf_crawler run --trigger manual --skip-agy
|
||
|
||
# (F) 수집·비교는 그대로 두고 리포트 파일만 다시 만든다
|
||
# (엑셀로 열어둬서 저장이 실패했을 때가 대표적)
|
||
.\.venv\Scripts\python.exe -m dmf_crawler report-only
|
||
.\.venv\Scripts\python.exe -m dmf_crawler report-only --date 2026-09-01
|
||
```
|
||
|
||
> **(A) 와 (B) 의 차이를 항상 의식하라.** (B) 는 당신의 Interactive 세션에서 돈다. DPAPI·프로필·PATH 가 전부 다르다. "손으로는 되는데 06:00 에는 안 된다" 의 원인은 거의 항상 여기다.
|
||
|
||
### 8.3 특정 날짜 백필
|
||
|
||
```powershell
|
||
# 원문 아카이브(data\raw\<날짜>)가 남아 있는 구간만 대상이다.
|
||
# 파서를 고친 뒤 과거를 다시 해석할 때 쓴다.
|
||
|
||
# (1) 아카이브가 어느 날짜까지 있는지 확인
|
||
Get-ChildItem .\data\raw -Directory | Select-Object -ExpandProperty Name
|
||
|
||
# (2) 원문 재파싱 + diff 재계산 + 리포트 재생성
|
||
.\.venv\Scripts\python.exe -m dmf_crawler backfill `
|
||
--from 2026-08-25 --to 2026-08-31 --reparse --rediff --rereport
|
||
|
||
# (3) 리포트만 다시 만들기(파서·diff 는 그대로)
|
||
.\.venv\Scripts\python.exe -m dmf_crawler backfill `
|
||
--from 2026-08-25 --to 2026-08-31 --rereport
|
||
|
||
# (4) 백필 전 반드시 백업
|
||
.\.venv\Scripts\python.exe -m dmf_crawler backup --now
|
||
```
|
||
|
||
**백필로 할 수 없는 것**: 아카이브가 없는 날짜는 복원할 수 없다. 공식 API 는 "특정 과거 시점의 스냅샷" 을 제공하지 않기 때문이다. 그래서 `data\raw\` 보존이 중요하다(`source.archive_retain_days=180`).
|
||
|
||
### 8.4 일시 중지 · 재개
|
||
|
||
```powershell
|
||
# 배치만 멈춘다 (알림 에이전트는 계속 돈다 → 곧 STALE 경보가 뜬다)
|
||
Disable-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Daily'
|
||
|
||
# 장기 휴지(휴가 등)라면 알림도 함께 멈춰 오탐을 막는다
|
||
Disable-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Agent'
|
||
|
||
# 상태 확인 — State 가 Disabled 로 보인다
|
||
Get-ScheduledTask -TaskPath '\DMF_Crawler\' | Format-Table TaskName, State -AutoSize
|
||
|
||
# 재개
|
||
Enable-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Daily'
|
||
Enable-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Agent'
|
||
|
||
# 재개 직후 확인: NextRunTime 이 다음 06:00 을 가리키는가
|
||
Get-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Daily' |
|
||
Get-ScheduledTaskInfo | Select-Object NextRunTime
|
||
```
|
||
|
||
> **중지 중에 지나간 06:00 은 재개해도 캐치업하지 않는다.** `StartWhenAvailable` 은 "작업이 활성인데 못 돈 경우" 를 다룬다. 비활성 기간의 데이터가 필요하면 `backfill` 을 쓰거나, 그날 데이터는 없는 것으로 확정된다(공식 API 에 과거 스냅샷이 없으므로).
|
||
|
||
### 8.5 제거
|
||
|
||
```powershell
|
||
# (1) 작업만 제거 — 데이터는 남는다
|
||
powershell -ExecutionPolicy Bypass -File .\scripts\uninstall_tasks.ps1
|
||
|
||
# (2) 작업 + 폴더까지 제거
|
||
powershell -ExecutionPolicy Bypass -File .\scripts\uninstall_tasks.ps1 -RemoveFolder
|
||
|
||
# (3) 바로가기 제거
|
||
Remove-Item "$env:USERPROFILE\Desktop\DMF 설정.lnk" -ErrorAction SilentlyContinue
|
||
Remove-Item "$env:USERPROFILE\Desktop\지금 실행.lnk" -ErrorAction SilentlyContinue
|
||
|
||
# (4) 저장된 API 키(DPAPI) 제거
|
||
.\.venv\Scripts\python.exe -m dmf_crawler doctor --json # 무엇이 남아있는지 먼저 확인
|
||
# GUI: "DMF 설정" → API 키 → 삭제
|
||
|
||
# (5) 완전 삭제 (되돌릴 수 없다. 백업을 먼저 다른 곳으로 옮겨라)
|
||
Remove-Item -LiteralPath 'D:\workspace\DMF_Crawler' -Recurse -Force
|
||
|
||
# (6) 이 프로젝트가 바꾼 시스템 설정 되돌리기 (선택)
|
||
powercfg /change standby-timeout-ac 30
|
||
# 활성 시간 정책 제거
|
||
Remove-ItemProperty -Path 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate' `
|
||
-Name 'SetActiveHours','ActiveHoursStart','ActiveHoursEnd' -ErrorAction SilentlyContinue
|
||
```
|
||
|
||
> `agy` 자체는 이 프로젝트 소유가 아니다. 다른 용도로도 쓴다면 지우지 마라. 지우려면 `%LOCALAPPDATA%\agy` 와 `~\.gemini\antigravity-cli` 를 삭제한다. **토큰 파일이므로 삭제 = 로그아웃이다.**
|
||
|
||
---
|
||
|
||
## 9. 점검 체크리스트
|
||
|
||
### 9.1 설치 직후 (1회, 전부 통과해야 운영 개시)
|
||
|
||
- [ ] `python --version` 이 3.12 이상
|
||
- [ ] `.venv\Scripts\python.exe -m dmf_crawler version` 이 패키지·의존성 3종·agy 버전을 출력
|
||
- [ ] `.venv\Scripts\python.exe -m dmf_crawler doctor` 가 **12종 전부 통과**(종료 코드 0)
|
||
- [ ] `Get-ScheduledTask -TaskPath '\DMF_Crawler\'` 가 **3개**를 `Ready` 로 보여줌
|
||
- [ ] `DMF_Crawler_Daily` 의 `Principal.UserId` 가 **SYSTEM 이 아님**, `RunLevel=Highest`
|
||
- [ ] §2.7 자동 검증 스니펫이 **전 항목 통과**
|
||
- [ ] §2.8 프로브(`-Verify`)에서 **api_key 체크 통과** — 실패했다면 `-LogonType Password` 로 재등록했고 다시 통과
|
||
- [ ] `powercfg /waketimers` 에 다음 06:00 항목이 보임
|
||
- [ ] 활성 시간이 **05–23** 으로 설정됨(§5.6 확인 명령)
|
||
- [ ] `tzutil /g` 가 `Korea Standard Time`
|
||
- [ ] `wevtutil get-log "Microsoft-Windows-TaskScheduler/Operational"` 이 `enabled: true`
|
||
- [ ] `Start-ScheduledTask` 로 **스케줄러 경유 1회 실행 성공** → `reports\` 에 오늘 xlsx 생성
|
||
- [ ] `state\heartbeat.json` 이 생성되고 `status=SUCCESS`
|
||
- [ ] `.\scripts\check_heartbeat.ps1` 이 종료 코드 0
|
||
- [ ] `backup\` 에 `dmf_<날짜>.sqlite3` 생성 (`backup.dir` 이 **다른 드라이브**를 가리키면 더 좋다)
|
||
- [ ] `DMF 설정.lnk` / `지금 실행.lnk` 더블클릭 시 **콘솔 창 없이** GUI 가 뜸
|
||
- [ ] §5.10 테스트 1(캐치업)을 실제로 수행하고 이벤트 114 또는 118 확인
|
||
- [ ] BitLocker 구성 확인 — TPM+PIN 이면 §5.9 의 지연을 이해관계자가 수용
|
||
|
||
### 9.2 매주 (5분, 월요일 권장)
|
||
|
||
- [ ] `.\scripts\check_heartbeat.ps1` — 판정 OK, `consecutive_failures = 0`
|
||
- [ ] 최근 7일 리포트가 7개 있는가: `Get-ChildItem .\reports -Filter '*.xlsx' | Sort-Object LastWriteTime -Descending | Select-Object -First 8`
|
||
- [ ] `duration_seconds` 추이 — 지난주 대비 2배 이상 늘었으면 원인 확인
|
||
- [ ] `Get-ScheduledTaskInfo` 의 `LastTaskResult` 가 7일 내내 `0x0`
|
||
- [ ] `agy` 상태: 최근 7일 `heartbeat.agy.status` 가 `AUTH` 로 바뀐 적 없는가 (있으면 재로그인 필요)
|
||
- [ ] 일일 토큰 사용량이 `agy.daily_token_cap`(300000) 대비 여유 있는가
|
||
- [ ] 디스크 여유: `Get-PSDrive D | Select-Object Used, Free`
|
||
- [ ] 백업 개수: `(Get-ChildItem .\backup -Filter '*.sqlite3').Count` 가 30 이하
|
||
- [ ] `DMF_Crawler_AgyUpdate` 가 지난 일요일에 돌았고 `agy --version` 이 갱신됐는가
|
||
- [ ] 로그에 신규 WARN 패턴이 있는가:
|
||
```powershell
|
||
Get-ChildItem .\logs -Directory -Filter 'run_*' |
|
||
Sort-Object Name -Descending | Select-Object -First 7 |
|
||
ForEach-Object { Select-String -Path "$($_.FullName)\pipeline.log" -Pattern 'WARNING|ERROR' } |
|
||
Group-Object { ($_.Line -split '\|')[3].Trim() } |
|
||
Sort-Object Count -Descending | Format-Table Count, Name -AutoSize
|
||
```
|
||
|
||
### 9.3 장애 시 (STALE 경보가 떴을 때 순서대로)
|
||
|
||
- [ ] **1단계 — 무엇이 죽었나**
|
||
```powershell
|
||
.\scripts\check_heartbeat.ps1
|
||
Get-ScheduledTask -TaskPath '\DMF_Crawler\' | Get-ScheduledTaskInfo |
|
||
Select-Object TaskName, LastRunTime, @{n='R';e={'0x{0:X}' -f $_.LastTaskResult}}, NextRunTime
|
||
```
|
||
- [ ] **2단계 — 작업이 시작조차 못 했나, 시작했다가 실패했나**
|
||
```powershell
|
||
Get-WinEvent -LogName 'Microsoft-Windows-TaskScheduler/Operational' -MaxEvents 100 |
|
||
Where-Object { $_.Message -like '*DMF_Crawler*' } |
|
||
Select-Object TimeCreated, Id, LevelDisplayName,
|
||
@{n='Msg';e={ ($_.Message -split "`n")[0] }} |
|
||
Format-Table -AutoSize
|
||
```
|
||
→ 이벤트 ID 로 §11.2 표를 참조한다. `101`/`104`/`332` 면 **시작 실패**(보안·전원 문제), `102`/`201` 이 있으면 **시작은 했다**(코드 문제).
|
||
- [ ] **3단계 — 시작했다면 로그를 본다**
|
||
```powershell
|
||
$last = Get-ChildItem .\logs -Directory -Filter 'run_*' | Sort-Object Name -Descending | Select-Object -First 1
|
||
Get-Content "$($last.FullName)\pipeline.log" -Tail 60
|
||
Get-Content "$($last.FullName)\events.jsonl" | Select-Object -Last 20
|
||
```
|
||
- [ ] **4단계 — 전면 진단**
|
||
```powershell
|
||
.\.venv\Scripts\python.exe -m dmf_crawler doctor
|
||
```
|
||
- [ ] **5단계 — 스케줄러 경유로 재현**
|
||
```powershell
|
||
Start-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Daily'
|
||
```
|
||
- [ ] **6단계 — 그래도 안 되면 컨텍스트 차이를 의심**하고 §2.8 프로브를 다시 돌린다
|
||
```powershell
|
||
.\scripts\install_tasks.ps1 -Verify
|
||
```
|
||
- [ ] **7단계 — 작업 정의가 손상됐다면 재등록**
|
||
```powershell
|
||
.\scripts\install_tasks.ps1
|
||
# 또는 GUI 의 "작업 다시 등록" 버튼 / .\.venv\Scripts\python.exe -m dmf_crawler doctor --fix-tasks
|
||
```
|
||
- [ ] **8단계 — DB 가 의심되면**
|
||
```powershell
|
||
.\.venv\Scripts\python.exe -m dmf_crawler db check
|
||
.\.venv\Scripts\python.exe -m dmf_crawler db version
|
||
# 손상 확인 시: backup\ 의 최신본을 data\dmf.sqlite3 로 복사 후 report-only
|
||
```
|
||
|
||
**자동 재등록을 하지 않는 이유**: 사용자가 의도적으로 작업을 껐을 수 있다. 진단은 자동, **복구는 사람의 클릭 한 번**이 원칙이다(아키텍처 실패 시나리오 #29).
|
||
|
||
---
|
||
|
||
## 10. 완전한 설치 절차 (새 PC, 0부터)
|
||
|
||
소요 20~30분. **관리자 권한 PowerShell** 을 기본으로 한다.
|
||
|
||
### 단계 0 — 전제 확인
|
||
|
||
```powershell
|
||
# Windows 버전 (Windows 10 1809 이상 / Windows 11 권장)
|
||
[System.Environment]::OSVersion.Version
|
||
winver
|
||
|
||
# 아키텍처 (agy 는 windows_amd64)
|
||
$env:PROCESSOR_ARCHITECTURE # AMD64 여야 한다
|
||
|
||
# 관리자 권한인가
|
||
([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole('Administrator')
|
||
|
||
# 이 계정이 배치를 돌릴 계정인가 (agy 토큰이 이 프로필에 저장된다)
|
||
whoami
|
||
```
|
||
|
||
**준비물**: ① 공공데이터포털 계정과 **DMF 서비스 활용 신청 승인**(활용 신청 직후 키가 즉시 유효하지 않을 수 있다) ② Google 계정(agy 로그인용) ③ 디스크 여유 10GB 이상.
|
||
|
||
### 단계 1 — Python 설치
|
||
|
||
```powershell
|
||
# winget 이 있으면 (권장)
|
||
winget install --id Python.Python.3.12 --scope machine --silent `
|
||
--override "/quiet InstallAllUsers=1 PrependPath=1 Include_test=0"
|
||
|
||
# 새 셸을 열고 확인
|
||
python --version # Python 3.12.x
|
||
py -3.12 --version
|
||
```
|
||
|
||
수동 설치라면 python.org 에서 **3.12 64-bit** 를 받고 설치 시 **"Add python.exe to PATH"** 를 반드시 체크한다.
|
||
|
||
> 3.11 이상이 필요하다(`tomllib`). 3.12 를 권장한다.
|
||
|
||
### 단계 2 — 프로젝트 배치
|
||
|
||
```powershell
|
||
# Git 으로 받는 경우
|
||
git clone <저장소 URL> D:\workspace\DMF_Crawler
|
||
|
||
# 압축본을 푸는 경우
|
||
Expand-Archive .\DMF_Crawler.zip -DestinationPath D:\
|
||
|
||
cd D:\workspace\DMF_Crawler
|
||
Get-ChildItem # bootstrap.cmd, pyproject.toml, src\, scripts\, config\ 가 보여야 한다
|
||
```
|
||
|
||
**경로 규칙**: 공백·한글이 없는 짧은 경로를 쓴다. OneDrive 동기화 폴더(`%USERPROFILE%\OneDrive\...`) 아래에 두지 마라 — SQLite WAL 과 충돌하고, 리포트 파일이 동기화 중 잠긴다.
|
||
|
||
### 단계 3 — 부트스트랩 (venv + 의존성 + 바로가기)
|
||
|
||
```cmd
|
||
:: 프로젝트 루트에서 더블클릭하거나
|
||
D:\workspace\DMF_Crawler\bootstrap.cmd
|
||
```
|
||
|
||
`bootstrap.cmd` 가 하는 일:
|
||
|
||
1. `python -m venv .venv`
|
||
2. `.venv\Scripts\python.exe -m pip install --upgrade pip`
|
||
3. `.venv\Scripts\python.exe -m pip install -e .` → 런타임 의존성 **3개**(`httpx`, `XlsxWriter`, `jsonschema`) 설치
|
||
4. `scripts\make_shortcuts.ps1` 호출 → 바탕화면에 `DMF 설정.lnk` / `지금 실행.lnk` 생성
|
||
5. 온보딩 GUI 기동 (`pythonw -m dmf_crawler onboard --mode setup`)
|
||
|
||
수동으로 같은 일을 하려면:
|
||
|
||
```powershell
|
||
cd D:\workspace\DMF_Crawler
|
||
python -m venv .venv
|
||
.\.venv\Scripts\python.exe -m pip install --upgrade pip
|
||
.\.venv\Scripts\python.exe -m pip install -e .
|
||
.\.venv\Scripts\python.exe -m dmf_crawler version
|
||
```
|
||
|
||
### 단계 4 — agy 부트스트랩
|
||
|
||
```powershell
|
||
# 자동
|
||
powershell -ExecutionPolicy Bypass -File .\scripts\bootstrap_agy.ps1
|
||
|
||
# 스크립트가 하는 일:
|
||
# 1) %LOCALAPPDATA%\agy\bin\agy.exe 존재 확인
|
||
# 2) 없으면 공식 설치 스크립트 무인 실행
|
||
# 3) agy --version 출력
|
||
```
|
||
|
||
수동 설치:
|
||
|
||
```powershell
|
||
irm https://antigravity.google/cli/install.ps1 | iex
|
||
|
||
# 확인
|
||
& "$env:LOCALAPPDATA\agy\bin\agy.exe" --version
|
||
```
|
||
|
||
**로그인** — 반드시 **배치를 돌릴 그 계정**으로:
|
||
|
||
```powershell
|
||
& "$env:LOCALAPPDATA\agy\bin\agy.exe" login
|
||
# 브라우저가 열린다. Google 계정으로 로그인.
|
||
|
||
# 토큰 파일이 생겼는지 확인 (이 파일이 배치 인증의 전부다)
|
||
Get-Item "$env:USERPROFILE\.gemini\antigravity-cli\antigravity-oauth-token" |
|
||
Format-List FullName, Length, LastWriteTime
|
||
|
||
# 헤드리스 왕복 실측 (30초 이상 걸리는 게 정상 — 첫 호출 오버헤드)
|
||
& "$env:LOCALAPPDATA\agy\bin\agy.exe" -p "Reply with exactly: PONG" `
|
||
--output-format json --print-timeout 90s
|
||
$LASTEXITCODE # 0 이어야 한다
|
||
```
|
||
|
||
> **`antigravity-oauth-token` 은 평문 JSON 이다. 유출 = 계정 탈취다.** 이 파일을 백업·공유·Git 에 올리지 마라. `.gitignore` 에 `*oauth-token*` 이 들어 있는 이유다.
|
||
|
||
### 단계 5 — 설정과 API 키
|
||
|
||
```powershell
|
||
# (1) 설정 파일 확인
|
||
Get-Content .\config\config.toml | Select-Object -First 20
|
||
|
||
# (2) PC 별 오버라이드가 필요하면 (백업을 다른 드라이브로 등)
|
||
Copy-Item .\config\config.local.toml.example .\config\config.local.toml
|
||
notepad .\config\config.local.toml
|
||
# [backup]
|
||
# dir = "E:/DMF_Backup"
|
||
|
||
# (3) API 키 등록 — GUI 가 DPAPI 로 암호화 저장한다
|
||
.\.venv\Scripts\pythonw.exe -m dmf_crawler onboard --mode setup
|
||
# 또는 바탕화면의 "DMF 설정" 더블클릭
|
||
|
||
# (4) 연락처 이메일도 넣는다 (User-Agent 에 들어간다. 정중한 접근 원칙)
|
||
# config.toml 의 general.contact_email
|
||
```
|
||
|
||
**API 키를 config.toml 에 직접 쓰지 마라.** 그 파일은 Git 에 올라간다. 온보딩 GUI 를 통하면 DPAPI 사용자 범위로 암호화되어 저장된다.
|
||
|
||
### 단계 6 — 첫 실행 (스케줄러 없이)
|
||
|
||
```powershell
|
||
# (1) 진단부터
|
||
.\.venv\Scripts\python.exe -m dmf_crawler doctor
|
||
|
||
# (2) DB 초기화
|
||
.\.venv\Scripts\python.exe -m dmf_crawler db migrate
|
||
.\.venv\Scripts\python.exe -m dmf_crawler db version
|
||
|
||
# (3) 실제 1회 실행 (첫 실행은 기준선 수립 — diff 는 비어 있는 게 정상)
|
||
.\.venv\Scripts\python.exe -m dmf_crawler run --trigger manual
|
||
|
||
# (4) 결과 확인
|
||
Get-ChildItem .\reports
|
||
Get-Content .\state\heartbeat.json
|
||
```
|
||
|
||
**첫 실행에서 diff 가 0 인 것은 정상이다.** 비교 대상(전일 스냅샷)이 없기 때문이다. 진짜 검증은 **둘째 날**에 이루어진다.
|
||
|
||
### 단계 7 — 시스템 설정 (전원 · 업데이트 · 시간대)
|
||
|
||
```powershell
|
||
# 절전 해제 타이머 허용
|
||
powercfg /setacvalueindex SCHEME_CURRENT 238c9fa8-0aad-41ed-83f4-97be242c8f20 bd3b718a-0680-4d9d-8ab2-e1d2b4ac806d 1
|
||
powercfg /setdcvalueindex SCHEME_CURRENT 238c9fa8-0aad-41ed-83f4-97be242c8f20 bd3b718a-0680-4d9d-8ab2-e1d2b4ac806d 1
|
||
powercfg /setactive SCHEME_CURRENT
|
||
|
||
# AC 전원에서는 안 자게
|
||
powercfg /change standby-timeout-ac 0
|
||
powercfg /change hibernate-timeout-ac 0
|
||
|
||
# Windows Update 활성 시간 05–23 (06:00 보호)
|
||
$ux = 'HKLM:\SOFTWARE\Microsoft\WindowsUpdate\UX\Settings'
|
||
New-Item -Path $ux -Force | Out-Null
|
||
Set-ItemProperty -Path $ux -Name 'ActiveHoursStart' -Value 5 -Type DWord
|
||
Set-ItemProperty -Path $ux -Name 'ActiveHoursEnd' -Value 23 -Type DWord
|
||
|
||
# 시간대 확인
|
||
tzutil /g # Korea Standard Time
|
||
w32tm /resync
|
||
```
|
||
|
||
### 단계 8 — 작업 등록
|
||
|
||
```powershell
|
||
cd D:\workspace\DMF_Crawler
|
||
powershell -ExecutionPolicy Bypass -File .\scripts\install_tasks.ps1 -Verify
|
||
```
|
||
|
||
프로브에서 `api_key` 가 실패하면:
|
||
|
||
```powershell
|
||
powershell -ExecutionPolicy Bypass -File .\scripts\install_tasks.ps1 -LogonType Password -Verify
|
||
```
|
||
|
||
### 단계 9 — 등록 검증
|
||
|
||
§2.7 의 **자동 검증 스니펫**을 그대로 붙여넣어 전 항목 통과를 확인한다. 이어서:
|
||
|
||
```powershell
|
||
# 스케줄러 경유 실행이 성공하는가 (이게 진짜 검증이다)
|
||
Start-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Daily'
|
||
while ((Get-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Daily').State -eq 'Running') {
|
||
Start-Sleep -Seconds 5
|
||
}
|
||
Get-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Daily' | Get-ScheduledTaskInfo
|
||
.\scripts\check_heartbeat.ps1
|
||
```
|
||
|
||
### 단계 10 — 알림 경로 검증
|
||
|
||
```powershell
|
||
# 에이전트를 즉시 한 번 돌린다
|
||
Start-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Agent'
|
||
|
||
# 인위적으로 STALE 을 만들어 강제 모달이 실제로 뜨는지 본다
|
||
Copy-Item .\state\heartbeat.json .\state\heartbeat.json.bak
|
||
# heartbeat.json 의 updated_at 을 이틀 전으로 편집
|
||
notepad .\state\heartbeat.json
|
||
Start-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Agent'
|
||
# → 복구 GUI 가 떠야 한다
|
||
|
||
# 원상복구
|
||
Move-Item .\state\heartbeat.json.bak .\state\heartbeat.json -Force
|
||
```
|
||
|
||
**이 테스트를 건너뛰지 마라.** 알림이 안 뜨는 시스템은 감시 없는 시스템과 같다.
|
||
|
||
### 단계 11 — 다음 날 아침 확인 (설치 완료 판정)
|
||
|
||
```powershell
|
||
.\scripts\check_heartbeat.ps1
|
||
# 판정 OK + run_id 가 오늘 06:0x + status=SUCCESS
|
||
Get-ChildItem .\reports -Filter "DMF_리포트_$(Get-Date -Format 'yyyy-MM-dd').xlsx"
|
||
```
|
||
|
||
**여기까지 통과해야 설치 완료다.** 손으로 돌려본 것만으로는 완료가 아니다.
|
||
|
||
### 설치 요약 카드 (복사용)
|
||
|
||
```powershell
|
||
# 관리자 PowerShell, 순서대로
|
||
winget install --id Python.Python.3.12 --scope machine --silent
|
||
git clone <URL> D:\workspace\DMF_Crawler ; cd D:\workspace\DMF_Crawler
|
||
.\bootstrap.cmd
|
||
powershell -ExecutionPolicy Bypass -File .\scripts\bootstrap_agy.ps1
|
||
& "$env:LOCALAPPDATA\agy\bin\agy.exe" login
|
||
.\.venv\Scripts\pythonw.exe -m dmf_crawler onboard --mode setup # API 키 입력
|
||
.\.venv\Scripts\python.exe -m dmf_crawler db migrate
|
||
.\.venv\Scripts\python.exe -m dmf_crawler run --trigger manual
|
||
powercfg /change standby-timeout-ac 0
|
||
powershell -ExecutionPolicy Bypass -File .\scripts\install_tasks.ps1 -Verify
|
||
Start-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Daily'
|
||
.\scripts\check_heartbeat.ps1
|
||
```
|
||
|
||
---
|
||
|
||
## 11. 트러블슈팅 레퍼런스
|
||
|
||
### 11.1 `LastTaskResult` 값 읽는 법
|
||
|
||
`Get-ScheduledTaskInfo` 의 `LastTaskResult` 는 **작업이 실행한 프로그램의 종료 코드** 또는 **스케줄러 자체의 HRESULT** 다.
|
||
|
||
| 값 | 의미 | 우리 시스템에서의 해석 |
|
||
|---|---|---|
|
||
| `0x0` | 성공 | 정상 (SUCCESS / PARTIAL / SKIPPED) |
|
||
| `0x1` | 프로그램이 1 로 종료 | **FAILED** — 리포트 미생성. 재시도 가치 있음 |
|
||
| `0x2` | 프로그램이 2 로 종료 | **BLOCKED** — 전제조건 미충족(API 키·설정·DB). 재시도해도 같다 |
|
||
| `0x82` (130) | 사용자 중단 | 콘솔에서 Ctrl+C |
|
||
| `0x41300` | `SCHED_S_TASK_READY` — 다음 예정 시각에 실행 대기 | 정상 |
|
||
| `0x41301` | `SCHED_S_TASK_RUNNING` — 현재 실행 중 | 정상 (조회 시점에 돌고 있음) |
|
||
| `0x41302` | `SCHED_S_TASK_DISABLED` — 비활성화됨 | 누가 껐다. `Enable-ScheduledTask` |
|
||
| `0x41303` | `SCHED_S_TASK_HAS_NOT_RUN` — 한 번도 안 돎 | 등록 직후의 정상 상태 |
|
||
| `0x41304` | `SCHED_S_TASK_NO_MORE_RUNS` | 트리거가 만료됐다. 재등록 필요 |
|
||
| `0x41305` | `SCHED_S_TASK_NOT_SCHEDULED` — 예약에 필요한 속성 누락 | 트리거가 없거나 깨졌다. 재등록 |
|
||
| `0x41306` | `SCHED_S_TASK_TERMINATED` — 마지막 실행이 종료됨 | `ExecutionTimeLimit` 초과 또는 `Stop-ScheduledTask` |
|
||
| `0x41307` | `SCHED_S_TASK_NO_VALID_TRIGGERS` | 트리거가 없거나 전부 비활성 |
|
||
| `0x41325` | `SCHED_S_TASK_QUEUED` | 큐에 대기 중 |
|
||
| `0x8004130F` | `SCHED_E_ACCOUNT_INFORMATION_NOT_SET` | **자격증명 문제.** 암호 변경 후 재등록 안 했거나 S4U 등록 실패 |
|
||
| `0x80041310` | `SCHED_E_ACCOUNT_NAME_NOT_FOUND` | `-User` 로 준 계정명이 틀렸다 |
|
||
| `0x8004131F` | `SCHED_E_ALREADY_RUNNING` | 이미 실행 중 (IgnoreNew 정상 동작) |
|
||
| `0x80041321` | `SCHED_E_INVALID_TASK_HASH` — 작업 이미지 손상 | **작업 정의가 손상됐다. 재등록이 유일한 해법** |
|
||
| `0x800710E0` | 요청 거부 | 관리자 권한/RunLevel 문제 |
|
||
|
||
> 시스템·네트워크 오류 코드(예: `64`)가 그대로 나올 수도 있다. `net helpmsg 64` 로 의미를 확인한다.
|
||
|
||
### 11.2 `Microsoft-Windows-TaskScheduler/Operational` 주요 이벤트 ID
|
||
|
||
| ID | 이름 | 의미 | 우리 시스템에서 |
|
||
|---|---|---|---|
|
||
| 100 | JobStart | 작업 인스턴스 시작 | 정상 |
|
||
| **101** | JobStartFailed | **작업 시작 실패** | 보안 컨텍스트·경로 문제 |
|
||
| 102 | JobSuccess | 작업 완료 | 정상 |
|
||
| **103** | JobFailure | **작업 실행 실패** | 종료 코드 비0 |
|
||
| **104** | LogonFailure | **사용자 로그온 실패** | 암호 변경 / S4U 실패 → §2.8 |
|
||
| 105 | ImpersonationFailure | 가장(impersonation) 실패 | 동일 계열 |
|
||
| 106 | JobRegistered | 작업 등록됨 | `install_tasks.ps1` 실행 흔적 |
|
||
| 107 | TimeTrigger | 시간 트리거로 실행 | **06:00 정상 발화** |
|
||
| 108 | EventTrigger | 이벤트 트리거로 실행 | 미사용 |
|
||
| 110 | Run | 사용자를 위해 실행 시작 | 정상 |
|
||
| **111** | JobTermination | **시간 초과로 종료** | `ExecutionTimeLimit` 30분 초과 |
|
||
| **112** | JobNoStartWithoutNetwork | **네트워크 없어 미시작** | `RunOnlyIfNetworkAvailable` 동작 |
|
||
| **114** | MissedTaskLaunched | **놓친 작업을 실행** | `StartWhenAvailable` 캐치업 성공 |
|
||
| 116 | TaskRegisteredWithoutCredentials | 자격증명 저장 실패 | Password 등록 실패 |
|
||
| **118** | BootTrigger | **부팅 트리거로 실행** | 재시작 후 정상 발화 |
|
||
| 119 | LogonTrigger | 로그온 트리거로 실행 | Agent 정상 |
|
||
| 126 | FailedTaskRestart | 실패한 작업 재시작 시도 | `RestartCount` 동작 중 |
|
||
| 129 | CreatedTaskProcess | 새 프로세스로 실행 | PID 확인용 |
|
||
| 140 / 141 / 142 | TaskUpdated / TaskDeleted / TaskDisabled | 사람이 작업을 바꿨다 | **작업이 사라진 원인 추적** |
|
||
| **145** | TaskStartedOnComputerWakeup | **절전 해제 후 실행** | `WakeToRun` 성공 증거 |
|
||
| 200 / 201 | ActionStart / ActionSuccess | 액션 시작·완료 | `201` 의 `ResultCode` 가 종료 코드 |
|
||
| **202** | ActionFailure | **액션 실패** | 프로그램이 비0 종료 |
|
||
| **203** | ActionLaunchFailure | **액션 실행 실패** | 경로가 틀렸거나 실행 권한 없음 |
|
||
| **322** | NewInstanceIgnored | **이미 실행 중이라 무시** | `IgnoreNew` 정상 동작 |
|
||
| **326** | NoStartOnBatteries | **배터리라서 미시작** | 설정이 안 뒤집혔다! §2.7 재확인 |
|
||
| 327 | StoppingOnBatteries | 배터리 전환으로 중단 | 동일 |
|
||
| **329** | StoppingOnTimeout | **시간 초과로 중단** | 111 과 함께 본다 |
|
||
| **332** | NoStartUserNotLoggedOn | **사용자 미로그온으로 미시작** | Interactive 작업의 정상 동작 |
|
||
| 400 / 401 | ScheduleServiceStart / StartFailed | 스케줄러 서비스 상태 | 서비스 자체 문제 |
|
||
|
||
조회 명령:
|
||
|
||
```powershell
|
||
# 우리 작업 관련만, 최근 100건
|
||
Get-WinEvent -LogName 'Microsoft-Windows-TaskScheduler/Operational' -MaxEvents 200 |
|
||
Where-Object { $_.Message -like '*DMF_Crawler*' } |
|
||
Select-Object TimeCreated, Id, LevelDisplayName,
|
||
@{ n='First'; e={ ($_.Message -split "`n")[0] } } |
|
||
Format-Table -AutoSize
|
||
|
||
# 특정 ID 만 (시작 실패 계열)
|
||
Get-WinEvent -FilterHashtable @{
|
||
LogName = 'Microsoft-Windows-TaskScheduler/Operational'
|
||
Id = 101, 103, 104, 111, 112, 202, 203, 326, 332
|
||
StartTime = (Get-Date).AddDays(-7)
|
||
} | Format-List TimeCreated, Id, Message
|
||
```
|
||
|
||
### 11.3 증상 → 원인 → 조치 빠른 표
|
||
|
||
| 증상 | 가장 흔한 원인 | 조치 |
|
||
|---|---|---|
|
||
| 06:00 에 아무 일도 안 일어남, `LastRunTime` 이 어제 | PC 가 꺼져 있었다 | 정상. 켜지면 캐치업. `powercfg /waketimers` 확인 |
|
||
| 이벤트 326 이 보임 | `DisallowStartIfOnBatteries` 가 안 뒤집혔다 | `install_tasks.ps1` 재실행 |
|
||
| 이벤트 104, `LastTaskResult=0x8004130F` | 사용자 암호를 바꿨다 | `install_tasks.ps1 -LogonType Password` 재실행 |
|
||
| 손으로는 되는데 스케줄러로는 종료 코드 2 | **DPAPI 가 S4U 에서 실패** | §2.8 프로브 → `-LogonType Password` |
|
||
| `agy` 만 실패, 나머지 정상 | OAuth 토큰 만료 | `agy login` 재실행. 리포트는 계속 나온다(설계상 정상) |
|
||
| 이벤트 111 + 329 | 30분 초과 | `source.page_size` 하향 또는 `execution_time_limit_minutes` 상향 |
|
||
| 리포트가 `_HHMMSS` 붙은 이름으로 저장됨 | Excel 로 파일을 열어뒀다 | Excel 닫고 `report-only` |
|
||
| 이벤트 322 가 매일 2건 | 06:00 트리거와 부팅 트리거가 겹침 | **정상.** 방어가 작동 중 |
|
||
| 작업이 통째로 사라짐 | 이벤트 141 확인 → 누가 지웠다 | `install_tasks.ps1` 재등록 |
|
||
| `NumberOfMissedRuns` 가 계속 증가 | 작업이 실행 자체를 못 하고 있다 | 이벤트 101/104/332 확인 |
|
||
| 알림이 전혀 안 뜸 | 로그오프 상태였거나 Agent 가 비활성 | `Get-ScheduledTask ... Agent` 상태 확인, §5.8 |
|
||
| `doctor` 는 통과하는데 06:00 만 실패 | 컨텍스트 차이 | 반드시 `Start-ScheduledTask` 로 재현 |
|
||
|
||
---
|
||
|
||
## 부록. 미해결 / 실측 필요
|
||
|
||
- [ ] **S4U 로그온에서 DPAPI 사용자 범위 `CryptUnprotectData` 가 성공하는가.** 이 런북 전체에서 가장 중요한 미검증 항목이다. Microsoft 문서는 "no access to ... encrypted files" 만 말할 뿐 DPAPI 마스터 키에 대해 명시하지 않는다. §2.8 프로브로 설치 시점에 **반드시 실측**하고 결과를 이 문서에 기록할 것.
|
||
- [ ] `New-ScheduledTaskTrigger -AtStartup` 이 반환하는 CIM 인스턴스에 `Delay` 속성을 대입하는 방식이 Windows 11 26xxx 빌드에서 정상 반영되는지. 등록 후 `Export-ScheduledTask` 로 `<Delay>PT5M</Delay>` 가 실제로 들어갔는지 눈으로 확인할 것.
|
||
- [ ] `-RepetitionDuration ([TimeSpan]::MaxValue)` 가 이 빌드에서 예외 없이 통과하는지. 통과했다면 XML 에 `<Duration>` 이 생략되는지(=무기한) 확인. 폴백(3650일) 경로가 실제로 필요한지 판정.
|
||
- [ ] **모던 대기(S0) 기기에서 `WakeToRun` 이 실제로 06:00 에 깨우는가.** `powercfg /a` 로 이 PC 의 절전 상태 종류를 먼저 확정하고, §5.10 테스트 3 을 수행할 것.
|
||
- [ ] `powercfg /waketimers` 출력에 `DMF_Crawler_Daily` 가 표시되는 정확한 문구. 표시되지 않는데 실제로는 깨우는 사례가 있으므로, 표시 유무만으로 판정하지 말 것.
|
||
- [ ] Fast Startup 이 켜진 상태에서 "종료 → 켜기" 시 `StartWhenAvailable` 캐치업이 몇 분 안에 뜨는지. 이벤트 114 의 실제 지연 시간 측정.
|
||
- [ ] Windows Update 마감 기한(deadline) 초과 시 활성 시간을 무시하고 05–23 사이에 재시작하는 사례가 이 PC 에서 발생하는지. 발생 빈도에 따라 06:00 을 다른 시각으로 옮길지 판단.
|
||
- [ ] `agy update` 의 정확한 종료 코드와, 업데이트 중 다른 `agy` 인스턴스가 있을 때의 동작(agy SSOT 부록 미해결 항목과 동일). `DMF_Crawler_AgyUpdate` 실패 시 알림을 띄울지 여부는 이 결과에 달려 있다.
|
||
- [ ] `DMF_Crawler_AgyUpdate` 를 S4U 로 등록했을 때 `agy update` 가 OAuth 토큰 파일을 정상적으로 읽는가. 읽지 못한다면 이 작업만 Interactive 로 내려야 한다.
|
||
- [ ] 배치가 실행되는 30분 사이에 사용자가 리포트 xlsx 를 열어두는 빈도. 폴백 파일명 발생률이 높으면 `report.lock_retries` 상향 또는 저장 시각 조정 검토.
|
||
- [ ] 로그오프 상태를 며칠 유지하는 운영 형태가 실제로 발생하는가. 발생한다면 §5.8 대안 B(자동 로그온) 또는 웹훅 확장(현재 범위 밖)을 다음 마일스톤 후보로 승격.
|
||
- [ ] `state\run.lock` 의 `_LOCK_OFFSET = 1_000_000` 로 파일이 1MB 로 잡히는 것이 스파스 파일로 처리되는지(NTFS). 아니라면 오프셋을 4096 정도로 줄여도 되는지 확인.
|
||
- [ ] `msvcrt.locking` 이 네트워크 드라이브·OneDrive 동기화 폴더에서 오동작하는 조건. 프로젝트 경로를 로컬 고정 디스크로 제한하는 것을 온보딩 체크에 추가할지 판단.
|
||
- [ ] `wevtutil set-log ... /maxsize:67108864` 가 관리자 권한 없이도 성공하는지, 실패 시 무해한지. `install_tasks.ps1` 이 이미 관리자 전제이므로 실무상 문제는 없으나 문구를 확정할 것.
|
||
- [ ] `doctor --json` 의 출력 스키마(`checks[].key/title/ok/detail`)가 `install_tasks.ps1 -Verify` 의 파싱과 일치하는지. **`checks.py` 구현 시 이 계약을 깨지 말 것** — 깨지면 프로브가 조용히 무력화된다.
|