- 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 문서 지도 갱신
4814 lines
290 KiB
Markdown
4814 lines
290 KiB
Markdown
# Windows 06:00 스케줄링 · 재부팅 내성 · 장애 알림 정본
|
||
|
||
> **이 문서의 역할**: DMF_Crawler 를 Windows 11 PC 에서 매일 06:00 에 확실히 실행하고, 재부팅·절전·업데이트·실패를 견디며, 죽었을 때 사람이 알아채고 복구할 수 있게 만드는 **운영 정본(SSOT)** 이다. 이 문서 하나만 읽고 스케줄러 등록·워치독·알림·복구 절차 전부를 구현할 수 있어야 한다.
|
||
|
||
---
|
||
|
||
## 0. 한눈에 보기
|
||
|
||
이 문서에서 내린 결론:
|
||
|
||
- **서비스(NSSM/WinSW/pywin32)로 만들지 마라. Windows 작업 스케줄러 작업 3개 조합이 정답이다.** 하루 1회 배치 워크로드에 상주 서비스는 과잉이며, 서비스는 **Session 0 격리** 때문에 토스트 알림·헤드풀 브라우저·사용자 프로필 접근이 모두 깨진다. Microsoft 공식 문서가 "Services cannot directly interact with a user as of Windows Vista" 라고 못박고 있다.
|
||
- **작업 3종 세트**: ① `DMF_Crawler_Daily` (매일 06:00 + 시스템 시작 시 트리거 + `StartWhenAvailable`), ② `DMF_Crawler_Watchdog` (매일 07:00, heartbeat 검증 + 알림), ③ `DMF_Crawler_Notify` (알림 전용, **로그온한 사용자 세션**에서 실행 — 토스트를 띄우기 위한 유일한 합법적 경로).
|
||
- **주 작업의 principal 은 `-LogonType S4U` + `-RunLevel Highest` 를 기본으로 하라.** S4U 는 비밀번호를 저장하지 않지만 **"no password is stored by the system and there is no access to either the network or encrypted files"** — 즉 **네트워크 드라이브·UNC 경로·DPAPI 암호화 파일 접근이 막힌다.** 크롤러가 keyring(Windows Credential Manager) 이나 EFS 파일에서 API 키를 읽는다면 S4U 는 실패하고 **`-LogonType Password` + 저장된 암호**로 가야 한다. 이 선택이 이 문서에서 가장 중요한 분기점이다.
|
||
- **`WakeToRun` + `AllowStartIfOnBatteries` + `DontStopIfGoingOnBatteries` + `StartWhenAvailable` 4종은 필수.** 기본값이 각각 `false` / (배터리 시 시작 금지 `DisallowStartIfOnBatteries=true`) / (배터리 전환 시 중단 `StopIfGoingOnBatteries=true`) / `false` 라서, 손대지 않으면 06:00 에 안 돈다.
|
||
- **`AtStartup` 트리거는 Fast Startup(빠른 시작) 때문에 신뢰할 수 없다.** 종료(Shutdown)는 실제로는 커널 세션 최대 절전이라 "시스템 시작 시" 트리거가 안 뜬다. `StartWhenAvailable`(놓친 작업 즉시 실행)을 진짜 안전망으로 삼고, 필요하면 `powercfg /h off` 로 Fast Startup 을 끈다(단, 재시작(Restart)에는 Fast Startup 이 적용되지 않는다).
|
||
- **토스트 알림은 "안 뜰 수 있다"를 전제로 설계하라.** 안 뜨는 조건: 로그온 전, Session 0(서비스), "로그온 여부에 관계없이 실행" 비대화형 세션, 집중 지원(방해 금지) 모드, AppId(AUMID) 미등록. 따라서 **알림은 3단 폴백**: ① BurntToast 토스트(버튼 2개: 로그 열기 / 재실행) → ② `msg.exe *` 세션 메시지 → ③ **웹훅(디스코드/텔레그램/슬랙) + healthchecks.io dead-man switch**. 웹훅만이 PC 가 꺼져 있어도 사람에게 닿는다.
|
||
- **dead-man switch 를 반드시 붙여라.** healthchecks.io 를 Period=24h / Grace=2h 로 설정하고, 06:00 작업 시작 시 `/start`, 성공 시 루트 URL, 실패 시 `/fail` 을 ping 한다. PC 가 통째로 꺼져 있거나 작업 스케줄러 자체가 죽어도 이것만은 알려준다.
|
||
- **heartbeat 는 파일 + 이벤트 로그 이중 기록.** `state\heartbeat.json` 에 실행 ID·시작/종료 시각·건수·exit code 를 쓰고, 동시에 `DMFCrawler` 이벤트 소스로 Windows 이벤트 로그에 남긴다. 이벤트 로그에 남기면 "특정 이벤트가 기록될 때" 트리거로 즉시 알림 작업을 띄울 수 있다.
|
||
- **`Write-EventLog`/`New-EventLog` 은 PowerShell 7 에 없다.** PowerShell 5.1(`powershell.exe`)을 쓰거나, PS7 에서는 `[System.Diagnostics.EventLog]::WriteEntry(...)` 를 직접 호출하라. `New-WinEvent` 는 ETW 전용이라 대체재가 아니다.
|
||
- **WSL2 cron / Docker Desktop 은 이 프로젝트에 부적합.** WSL 은 배포판이 살아 있어야만 cron 이 돌고 "systemd services will NOT keep your WSL instance alive", Docker Desktop 은 "Start Docker Desktop when you sign in to your computer" — 즉 **로그인 없이는 컨테이너가 안 뜬다.** 둘 다 로그인 의존성이 추가되므로 채택하지 않는다.
|
||
|
||
---
|
||
|
||
## 1. 목차
|
||
|
||
| # | 섹션 | 무엇을 결정하는가 |
|
||
|---|------|------------------|
|
||
| 0 | 한눈에 보기 | 최종 권고 요약 |
|
||
| 2 | 아키텍처 결정: 서비스 vs 작업 스케줄러 | 실행 컨테이너 선택 |
|
||
| 3 | 작업 스케줄러 완전 명세 | 트리거·설정·principal 전 항목 |
|
||
| 4 | 보안 컨텍스트: S4U vs Password vs Interactive | 자격증명 접근 가능 여부 |
|
||
| 5 | PowerShell 작업 등록 완전 스크립트 | 실제 등록 코드 |
|
||
| 6 | schtasks XML 정본 | 형상관리용 XML |
|
||
| 7 | 서비스화 옵션 비교(NSSM/WinSW/pywin32/sc.exe) | 왜 안 쓰는가 |
|
||
| 8 | Session 0 격리 | headless 브라우저·토스트 영향 |
|
||
| 9 | 워치독 · 헬스체크 · dead-man switch | 죽었을 때 감지 |
|
||
| 10 | 이벤트 로그와 이벤트 트리거 작업 | 즉시 반응 알림 |
|
||
| 11 | Windows 알림 전 방식 비교 | 토스트 라이브러리 선택 |
|
||
| 12 | 토스트가 안 뜨는 조건과 폴백 | 알림 신뢰성 |
|
||
| 13 | 복구 안내 메시지 설계 | 실제 문구·버튼 코드 |
|
||
| 14 | 재부팅 · 전원 · 시간대 | 물리 환경 대응 |
|
||
| 15 | Windows Update 재부팅 회피 | 06:00 충돌 방지 |
|
||
| 16 | WSL2 / Docker Desktop 옵션과 한계 | 대안 평가 |
|
||
| 17 | 로그 로테이션 · 실행 ID · 실패 스크린샷 | 사후 진단 |
|
||
| 18 | 실행 결과 코드 · 이벤트 ID 레퍼런스 | 트러블슈팅 |
|
||
| 19 | agy(Antigravity CLI) headless 실행 통합 | AI CLI 연동 |
|
||
| 20 | 최종 배치 절차 체크리스트 | 설치 순서 |
|
||
| 부록 A | 출처 목록 | 전체 URL |
|
||
| 부록 B | 미해결 질문 | 실측 필요 항목 |
|
||
|
||
---
|
||
|
||
## 2. 아키텍처 결정: 서비스 vs 작업 스케줄러
|
||
|
||
### 2.1 워크로드 특성
|
||
|
||
| 특성 | 값 | 함의 |
|
||
|------|-----|------|
|
||
| 실행 빈도 | 하루 1회 06:00 | 상주 프로세스 불필요 |
|
||
| 예상 실행 시간 | 수 분 ~ 수십 분 | `ExecutionTimeLimit` 여유 필요 |
|
||
| 필요 리소스 | 네트워크, headless Chromium(Playwright), xlsx 쓰기, AI CLI(`agy -p`) | 사용자 프로필·브라우저 캐시 경로 의존 |
|
||
| 실패 시 요구 | 사람이 즉시 인지 + 재실행 안내 | 대화형 세션 알림 필요 |
|
||
| 재부팅 내성 | 필수 | 부팅 트리거 + 놓친 작업 실행 |
|
||
|
||
### 2.2 결론 — 작업 스케줄러 + 워치독 작업 조합
|
||
|
||
**권고: Windows 작업 스케줄러 작업 3개.** 근거:
|
||
|
||
1. **하루 1회 배치에 24시간 상주 서비스는 낭비이자 위험.** 서비스는 항상 떠 있어야 하므로 메모리 누수·좀비 상태·재시작 폭풍 관리 비용이 붙는다. 작업 스케줄러는 실행하고 죽으면 끝이다.
|
||
2. **Session 0 격리가 치명적.** 서비스로 만들면 Playwright 헤드풀 디버깅 불가, 토스트 알림 불가, `%USERPROFILE%\AppData\Local\ms-playwright` 브라우저 경로 불일치가 발생한다(→ §8).
|
||
3. **작업 스케줄러가 이미 재시작·놓친 작업·전원 조건·부팅 트리거를 전부 내장한다.** 서비스로 만들면 `sc failure` 로 일부를 흉내내야 하는데, `sc failure` 는 **프로세스가 죽었을 때만** 동작하지 "정상 종료했지만 exit code 가 1" 인 경우는 잡지 못한다(그건 `AppExit` 같은 NSSM 확장이 필요).
|
||
4. **서비스는 관리자 권한 설치·제거가 필요하고 형상관리가 어렵다.** 작업 스케줄러 작업은 XML 하나로 export/import 되어 Git 에 올릴 수 있다.
|
||
|
||
**단, 서비스가 정답인 경우도 명시한다**: 상시 폴링(예: 1분마다 변경 감지)이 필요해지거나, 웹 대시보드를 24시간 띄워야 하면 그때 WinSW 로 서비스화하라(§7.2).
|
||
|
||
### 2.3 작업 3종 세트 설계
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────────┐
|
||
│ ① DMF_Crawler_Daily │
|
||
│ 트리거: 매일 06:00 (RandomDelay PT2M) + AtStartup (Delay PT3M)│
|
||
│ principal: S4U 또는 Password, RunLevel Highest │
|
||
│ 설정: StartWhenAvailable, WakeToRun, 배터리 무시, │
|
||
│ RestartCount 3 / RestartInterval PT10M, │
|
||
│ ExecutionTimeLimit PT2H, MultipleInstances IgnoreNew │
|
||
│ 액션: powershell.exe -File scripts\run-daily.ps1 │
|
||
│ → 성공 시 state\heartbeat.json 갱신 + 이벤트 ID 1000 │
|
||
│ → 실패 시 이벤트 ID 1001 + notify 큐 파일 작성 │
|
||
└─────────────────────────────────────────────────────────────────┘
|
||
│
|
||
▼
|
||
┌─────────────────────────────────────────────────────────────────┐
|
||
│ ② DMF_Crawler_Watchdog │
|
||
│ 트리거: 매일 07:00 (+ 시스템 시작 후 PT10M) │
|
||
│ principal: 동일 │
|
||
│ 액션: powershell.exe -File scripts\watchdog.ps1 │
|
||
│ → heartbeat 가 오늘 것이 아니면 알림 큐 작성 + 웹훅 발사 │
|
||
└─────────────────────────────────────────────────────────────────┘
|
||
│
|
||
▼
|
||
┌─────────────────────────────────────────────────────────────────┐
|
||
│ ③ DMF_Crawler_Notify │
|
||
│ 트리거: (a) 사용자 로그온 시, (b) 이벤트 로그 ID 1001 기록 시 │
|
||
│ principal: -LogonType Interactive (반드시!) , RunLevel Limited│
|
||
│ 액션: powershell.exe -File scripts\notify.ps1 │
|
||
│ → 알림 큐 파일이 있으면 BurntToast 토스트 표시 │
|
||
└─────────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
핵심 설계 원칙: **알림을 띄우는 주체를 크롤러 작업에서 분리한다.** 크롤러는 "알림 큐 파일"만 쓰고, 대화형 세션에서 도는 별도 작업이 그것을 읽어 토스트를 띄운다. 이것이 Microsoft 가 문서에서 권장하는 패턴("Create a separate hidden GUI application and use the CreateProcessAsUser function to run the application within the context of the interactive user ... communicate with the service through some method of interprocess communication (IPC)")의 작업 스케줄러 버전이다.
|
||
|
||
---
|
||
|
||
## 3. 작업 스케줄러 완전 명세
|
||
|
||
### 3.1 트리거 (`New-ScheduledTaskTrigger`)
|
||
|
||
`New-ScheduledTaskTrigger` 의 파라미터 세트는 5개다.
|
||
|
||
```powershell
|
||
# Once (기본)
|
||
New-ScheduledTaskTrigger -At <DateTime> [-RandomDelay <TimeSpan>] [-Once]
|
||
[-RepetitionDuration <TimeSpan>] [-RepetitionInterval <TimeSpan>]
|
||
|
||
# Daily
|
||
New-ScheduledTaskTrigger -At <DateTime> [-Daily] [-DaysInterval <UInt32>]
|
||
[-RandomDelay <TimeSpan>]
|
||
|
||
# Weekly
|
||
New-ScheduledTaskTrigger -At <DateTime> [-RandomDelay <TimeSpan>]
|
||
[-DaysOfWeek <DayOfWeek[]>] [-Weekly] [-WeeksInterval <UInt32>]
|
||
|
||
# Startup
|
||
New-ScheduledTaskTrigger [-RandomDelay <TimeSpan>] [-AtStartup]
|
||
|
||
# Logon
|
||
New-ScheduledTaskTrigger [-RandomDelay <TimeSpan>] [-AtLogOn] [-User <String>]
|
||
```
|
||
|
||
| 파라미터 | 타입 | 의미 |
|
||
|---------|------|------|
|
||
| `-At` | DateTime | 트리거 날짜/시각. **calendar-based 트리거(Once, Daily, Weekly)에서만 유효**하며 해당 세트에서 Mandatory. |
|
||
| `-AtLogOn` | SwitchParameter | 사용자가 로그온할 때 시작 |
|
||
| `-AtStartup` | SwitchParameter | 시스템이 시작될 때 시작 |
|
||
| `-Daily` | SwitchParameter | 매일 반복 |
|
||
| `-DaysInterval` | UInt32 | 일 간격. "An interval of 1 produces a daily schedule. An interval of 2 produces an every-other day schedule." |
|
||
| `-DaysOfWeek` | DayOfWeek[] | Sunday, Monday, Tuesday, Wednesday, Thursday, Friday, Saturday |
|
||
| `-Once` | SwitchParameter | `-At` 시각에 1회 |
|
||
| `-RandomDelay` | TimeSpan | 트리거 시작 시각에 더할 랜덤 지연 |
|
||
| `-RepetitionInterval` / `-RepetitionDuration` | TimeSpan | Once 세트에서 반복 |
|
||
| `-User` | String | Logon 세트에서 특정 사용자 |
|
||
| `-Weekly` / `-WeeksInterval` | Switch / UInt32 | 주간 반복 |
|
||
|
||
공식 예제(원문 그대로):
|
||
|
||
```powershell
|
||
# Example 1: Register a scheduled task that starts a task once
|
||
PS C:\>$Sta = New-ScheduledTaskAction -Execute "Cmd"
|
||
PS C:\>$Stt = New-ScheduledTaskTrigger -Once -At 3am
|
||
PS C:\>Register-ScheduledTask Task01 -Action $Sta -Trigger $Stt
|
||
|
||
# Example 2: Register a scheduled task that starts every day
|
||
PS C:\>$Sta = New-ScheduledTaskAction -Execute "Cmd"
|
||
PS C:\>$Stt = New-ScheduledTaskTrigger -Daily -At 3am
|
||
PS C:\>Register-ScheduledTask Task01 -Action $Sta -Trigger $Stt
|
||
|
||
# Example 3: Register a scheduled task that starts every 3 days
|
||
PS C:\>$Sta = New-ScheduledTaskAction -Execute "Cmd"
|
||
PS C:\>$Stt = New-ScheduledTaskTrigger -Daily -DaysInterval 3 -At 3am
|
||
PS C:\>Register-ScheduledTask Task01 -Action $Sta -Trigger $Stt
|
||
|
||
# Example 4: Register a scheduled task that starts every-other week
|
||
PS C:\>$Sta = New-ScheduledTaskAction -Execute "Cmd"
|
||
PS C:\>$Stt = New-ScheduledTaskTrigger -Weekly -WeeksInterval 2 -DaysOfWeek Sunday -At 3am
|
||
PS C:\>Register-ScheduledTask Task01 -Action $Sta -Trigger $Stt
|
||
|
||
# Example 5: Register a scheduled task that starts when a user logs on
|
||
PS C:\>$Sta = New-ScheduledTaskAction -Execute "Cmd"
|
||
PS C:\>$Stt = New-ScheduledTaskTrigger -AtLogon
|
||
PS C:\>Register-ScheduledTask Task01 -Action $Sta -Trigger $Stt
|
||
```
|
||
|
||
**한 작업에 트리거 여러 개를 붙일 수 있다.** 공식 설명: "Each task can contain one or more triggers, which means there are many ways that you can start a task. If a task has multiple triggers, Task Scheduler starts the task when any of the triggers occur." → 06:00 Daily 와 AtStartup 을 배열로 함께 넘긴다.
|
||
|
||
**부팅 트리거 지연(`Delay`)**: `New-ScheduledTaskTrigger -AtStartup` 에는 `-Delay` 파라미터가 없고 `-RandomDelay` 만 있다. **부팅 후 고정 지연**을 원하면 XML 의 `<BootTrigger><Delay>PT3M</Delay></BootTrigger>` 를 직접 써야 한다. 스키마:
|
||
|
||
```xml
|
||
<xs:element name="Delay" type="duration" />
|
||
```
|
||
|
||
> Delay (bootTriggerType): "Specifies the amount of time between when the system is booted and when the task is started. The format for this string is PnYnMnDTnHnMnS ... (for example, PT5M specifies 5 minutes and P1M4DT2H5M specifies one month, four days, two hours, and five minutes)." 기본값은 `PT0M`.
|
||
|
||
부팅 직후 네트워크 스택이 아직 안 올라온 경우를 대비해 **`PT3M` 정도의 Delay + `RunOnlyIfNetworkAvailable`** 조합을 쓴다(§14.6).
|
||
|
||
### 3.2 설정 (`New-ScheduledTaskSettingsSet`)
|
||
|
||
전체 구문(원문):
|
||
|
||
```powershell
|
||
New-ScheduledTaskSettingsSet
|
||
[-DisallowDemandStart]
|
||
[-DisallowHardTerminate]
|
||
[-Compatibility <CompatibilityEnum>]
|
||
[-DeleteExpiredTaskAfter <TimeSpan>]
|
||
[-AllowStartIfOnBatteries]
|
||
[-Disable]
|
||
[-MaintenanceExclusive]
|
||
[-Hidden]
|
||
[-RunOnlyIfIdle]
|
||
[-IdleWaitTimeout <TimeSpan>]
|
||
[-NetworkId <String>]
|
||
[-NetworkName <String>]
|
||
[-DisallowStartOnRemoteAppSession]
|
||
[-MaintenancePeriod <TimeSpan>]
|
||
[-MaintenanceDeadline <TimeSpan>]
|
||
[-StartWhenAvailable]
|
||
[-DontStopIfGoingOnBatteries]
|
||
[-WakeToRun]
|
||
[-IdleDuration <TimeSpan>]
|
||
[-RestartOnIdle]
|
||
[-DontStopOnIdleEnd]
|
||
[-ExecutionTimeLimit <TimeSpan>]
|
||
[-MultipleInstances <MultipleInstancesEnum>]
|
||
[-Priority <Int32>]
|
||
[-RestartCount <Int32>]
|
||
[-RestartInterval <TimeSpan>]
|
||
[-RunOnlyIfNetworkAvailable]
|
||
[-CimSession <CimSession[]>]
|
||
[-ThrottleLimit <Int32>]
|
||
[-AsJob]
|
||
[<CommonParameters>]
|
||
```
|
||
|
||
Description(원문): "The **New-ScheduledTaskSettingsSet** cmdlet creates an object that contains scheduled task settings. Each scheduled task has one set of task settings. Use this cmdlet to configure options to manage the behavior of the task upon completion, to manage the behavior of the task if a problem occurs, or to manage the behavior of the task if an instance of the task is already running."
|
||
|
||
주요 파라미터 상세:
|
||
|
||
| 파라미터 | 타입 | 설명 (공식 문구) | DMF_Crawler 값 |
|
||
|---------|------|-----------------|----------------|
|
||
| `-AllowStartIfOnBatteries` | SwitchParameter | "Indicates that Task Scheduler starts if the computer is running on battery power." | **켠다** |
|
||
| `-DontStopIfGoingOnBatteries` | SwitchParameter | 배터리로 전환되어도 작업을 중단하지 않음 | **켠다** |
|
||
| `-StartWhenAvailable` | SwitchParameter | 예약 시각을 놓쳤을 때 가능한 한 빨리 실행 | **켠다** |
|
||
| `-WakeToRun` | SwitchParameter | 작업 실행을 위해 컴퓨터를 절전에서 깨움 | **켠다** |
|
||
| `-ExecutionTimeLimit` | TimeSpan | "specify if the task is not finished after one hour, it is considered as failed" / "Without the ExecutionTimeLimit setting defined, the time limit set to it's default of three days" | `PT2H` |
|
||
| `-MultipleInstances` | MultipleInstancesEnum | 이미 실행 중일 때 정책 | `IgnoreNew` |
|
||
| `-RestartCount` | Int32 | 재시작 시도 횟수 | `3` |
|
||
| `-RestartInterval` | TimeSpan | 재시작 간격 | `PT10M` |
|
||
| `-RunOnlyIfNetworkAvailable` | SwitchParameter | 네트워크 사용 가능할 때만 실행 | **켠다**(부팅 트리거 대비) |
|
||
| `-Priority` | Int32 | 우선순위(기본 7). 예제: `New-ScheduledTaskSettingsSet -Priority 5` | `5` |
|
||
| `-Compatibility` | CompatibilityEnum | 허용값: **At, V1, Vista, Win7, Win8** | `Win8` |
|
||
| `-Hidden` | SwitchParameter | UI 에서 숨김 | 끔 |
|
||
| `-DisallowDemandStart` | SwitchParameter | "Indicates that the task cannot be started by using either the Run command or the Context menu." | **끔**(수동 재실행이 필요하므로) |
|
||
| `-DisallowHardTerminate` | SwitchParameter | 강제 종료 금지 | 끔 |
|
||
| `-DeleteExpiredTaskAfter` | TimeSpan | 만료 후 삭제 대기 시간 | 미지정 |
|
||
| `-RunOnlyIfIdle` / `-IdleDuration` / `-IdleWaitTimeout` | Switch / TimeSpan | 유휴 조건. 예제: `-RunOnlyIfIdle -IdleDuration 00:02:00 -IdleWaitTimeout 02:30:00` | **끔**(06:00 에 유휴 대기하면 안 됨) |
|
||
| `-DontStopOnIdleEnd` | SwitchParameter | 유휴 종료 시에도 중단 안 함 | 켬(안전) |
|
||
| `-NetworkId` / `-NetworkName` | String | `RunOnlyIfNetworkAvailable` 시 확인할 네트워크 프로필 | 미지정(아무 네트워크) |
|
||
| `-DisallowStartOnRemoteAppSession` | SwitchParameter | RAIL 세션에서 시작 금지 | 끔 |
|
||
| `-MaintenanceExclusive` / `-MaintenancePeriod` / `-MaintenanceDeadline` | Switch / TimeSpan | 자동 유지 관리 창에서 실행 | 미사용 |
|
||
|
||
**재시작 설정 공식 예제**(원문):
|
||
|
||
```powershell
|
||
PS C:\>$Sta = New-ScheduledTaskAction -Execute "Cmd"
|
||
PS C:\>$Stset = New-ScheduledTaskSettingsSet -RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 60)
|
||
PS C:\>Register-ScheduledTask Task01 -Action $Sta -Settings $Stset
|
||
```
|
||
|
||
> "The second command creates scheduled task settings that specify that Task Scheduler attempts three restarts of the task at sixty minute intervals."
|
||
|
||
**`RestartInterval` 제약(XSD 실측)** — `restartType` 복합 타입:
|
||
|
||
```xml
|
||
<xs:complexType name="restartType">
|
||
<xs:all>
|
||
<xs:element name="Interval">
|
||
<xs:simpleType>
|
||
<xs:restriction base="duration">
|
||
<xs:minInclusive value="PT1M" />
|
||
<xs:maxInclusive value="P31D" />
|
||
</xs:restriction>
|
||
</xs:simpleType>
|
||
</xs:element>
|
||
<xs:element name="Count">
|
||
<xs:simpleType>
|
||
<xs:restriction base="unsignedByte">
|
||
<xs:minInclusive value="1" />
|
||
</xs:restriction>
|
||
</xs:simpleType>
|
||
</xs:element>
|
||
</xs:all>
|
||
</xs:complexType>
|
||
```
|
||
|
||
→ **`Interval` 은 `PT1M` 이상 `P31D` 이하, `Count` 는 `unsignedByte`(1~255)**. `PT10M` / `3` 은 유효.
|
||
|
||
**`ExecutionTimeLimit` 정의(원문)**:
|
||
|
||
> "Amount of time allowed to complete the task. The format for this string is PnYnMnDTnHnMnS ... **A value of PT0S will enable the task to run indefinitely.**"
|
||
|
||
→ 무제한을 원하면 `PT0S`. 우리는 폭주 방지를 위해 `PT2H` 를 쓴다.
|
||
|
||
### 3.3 `settingsType` XSD 전문과 기본값
|
||
|
||
이것이 **XML 로 작업을 정의할 때의 정본**이다. 기본값을 모르면 "왜 06:00 에 안 도는지" 를 영원히 못 찾는다.
|
||
|
||
```xml
|
||
<xs:complexType name="settingsType">
|
||
<xs:all>
|
||
<xs:element name="AllowStartOnDemand" type="boolean" default="true" minOccurs="0" />
|
||
<xs:element name="RestartOnFailure" type="restartType" minOccurs="0" />
|
||
<xs:element name="MultipleInstancesPolicy" type="multipleInstancesPolicyType" default="IgnoreNew" minOccurs="0" />
|
||
<xs:element name="DisallowStartIfOnBatteries" type="boolean" default="true" minOccurs="0" />
|
||
<xs:element name="StopIfGoingOnBatteries" type="boolean" default="true" minOccurs="0" />
|
||
<xs:element name="AllowHardTerminate" type="boolean" default="true" minOccurs="0" />
|
||
<xs:element name="StartWhenAvailable" type="boolean" default="false" minOccurs="0" />
|
||
<xs:element name="NetworkProfileName" type="string" minOccurs="0" />
|
||
<xs:element name="RunOnlyIfNetworkAvailable" type="boolean" default="false" minOccurs="0" />
|
||
<xs:element name="WakeToRun" type="boolean" default="false" minOccurs="0" />
|
||
<xs:element name="Enabled" type="boolean" default="true" minOccurs="0" />
|
||
<xs:element name="Hidden" type="boolean" default="false" minOccurs="0" />
|
||
<xs:element name="DeleteExpiredTaskAfter" type="duration" default="PT0S" minOccurs="0" />
|
||
<xs:element name="IdleSettings" type="idleSettingsType" minOccurs="0" />
|
||
<xs:element name="NetworkSettings" type="networkSettingsType" minOccurs="0" />
|
||
<xs:element name="ExecutionTimeLimit" type="duration" minOccurs="0" />
|
||
<xs:element name="Priority" type="priorityType" default="7" minOccurs="0" />
|
||
<xs:element name="RunOnlyIfIdle" type="boolean" default="false" minOccurs="0" />
|
||
<xs:element name="UseUnifiedSchedulingEngine" type="boolean" default="false" minOccurs="0" />
|
||
<xs:element name="DisallowStartOnRemoteAppSession" type="boolean" default="false" minOccurs="0" />
|
||
</xs:all>
|
||
</xs:complexType>
|
||
```
|
||
|
||
자식 요소 설명(공식 표 원문 번역·병기):
|
||
|
||
| 요소 | 타입 | 설명 |
|
||
|------|------|------|
|
||
| `AllowHardTerminate` | boolean | "Specifies if the Task Scheduler service allows hard termination of the task." |
|
||
| `AllowStartOnDemand` | boolean | "Specifies that the task can be started by using either the Run command or the Context menu." |
|
||
| `DeleteExpiredTaskAfter` | duration | "Specifies the amount of time that the Task Scheduler will wait before deleting the task after it expires. If no value is specified for this element, then the Task Scheduler service will not delete the task." |
|
||
| `DisallowStartIfOnBatteries` | boolean | "Specifies that the task will not be started if the computer is running on battery power." **기본 true — 반드시 false 로 바꿀 것** |
|
||
| `DisallowStartOnRemoteAppSession` | boolean | "Specifies that the task should not start if the task is triggered to run in a Remote Applications Integrated Locally (RAIL) session." |
|
||
| `Enabled` | boolean | "Specifies that the task is enabled. The task can be performed only when this setting is **True**." |
|
||
| `ExecutionTimeLimit` | duration | "Specifies the amount of time allowed to complete the task." |
|
||
| `Hidden` | boolean | "Specifies, by default, that the task will not be visible in the user interface (UI)." |
|
||
| `IdleSettings` | idleSettingsType | "Specifies how the Task Scheduler performs tasks when the computer is in an idle state." |
|
||
| `MultipleInstancesPolicy` | multipleInstancesPolicyType | "Specifies the policy that defines how the Task Scheduler deals with multiple instances of the task." |
|
||
| `NetworkProfileName` | string | "Specifies the name of a network profile. The Task Scheduler service verifies the availability of this network when the RunOnlyIfNetworkAvailable element is set to True. The name is used for display purposes." |
|
||
| `NetworkSettings` | networkSettingsType | "Specifies the settings that the Task Scheduler service uses to obtain a network profile. The Task Scheduler service checks the availability of this network when the RunOnlyIfNetworkAvailable element is set to True." |
|
||
| `Priority` | priorityType | "Specifies the priority level for the task." |
|
||
| `RestartOnFailure` | restartType | "Specifies that the Task Scheduler will attempt to restart the task if it fails for any reason." |
|
||
| `RunOnlyIfIdle` | boolean | "Specifies that the task is run only when the computer is in an idle state." |
|
||
| `RunOnlyIfNetworkAvailable` | boolean | "Specifies that the Task Scheduler will run the task only when a network is available." |
|
||
| `StartWhenAvailable` | boolean | "Specifies that the Task Scheduler can start the task at any time after its scheduled time has passed." |
|
||
| `StopIfGoingOnBatteries` | boolean | "Specifies that the task will be stopped if the computer switches to battery power." **기본 true — false 로** |
|
||
| `UseUnifiedSchedulingEngine` | boolean | "Specifies that the task is run by using the Unified Scheduling Engine." |
|
||
| `WakeToRun` | boolean | "Specifies that Task Scheduler will wake the computer before it runs the task." |
|
||
|
||
`NetworkSettings` 요소 XSD:
|
||
|
||
```xml
|
||
<xs:element name="NetworkSettings" type="networkSettingsType" minOccurs="0" />
|
||
```
|
||
|
||
> "Contains the settings that the Task Scheduler service uses to obtain a network profile. The Task Scheduler service checks the availability of this network when the **RunOnlyIfNetworkAvailable** element is set to **True**."
|
||
|
||
요구사항: Minimum supported client — Windows Vista [desktop apps only] / Minimum supported server — Windows Server 2008 [desktop apps only].
|
||
|
||
### 3.4 다중 인스턴스 정책 (`MultipleInstances`)
|
||
|
||
`multipleInstancesPolicyType` 기본값은 `IgnoreNew`. 선택지와 의미:
|
||
|
||
| 값 | 동작 | DMF_Crawler 적합성 |
|
||
|----|------|-------------------|
|
||
| `IgnoreNew` (기본) | 이미 실행 중이면 새 인스턴스 무시 | ✅ **채택** — 06:00 작업이 아직 돌고 있는데 부팅 트리거로 또 뜨는 사고 방지 |
|
||
| `Parallel` | 병렬 실행 | ❌ xlsx 파일 경합 발생 |
|
||
| `Queue` | 대기 후 순차 실행 | △ 이론상 가능하나 2시간 뒤 실행돼도 의미 없음 |
|
||
| `StopExisting` | 기존 인스턴스 중단 후 새로 시작 | ❌ 크롤링 중간에 끊김 |
|
||
|
||
관련 이벤트 ID: `322 NewInstanceIgnored` ("Task instance already running"), `324 NewInstanceQueued`, `323 RunningInstanceStopped`.
|
||
|
||
### 3.5 작업 기록(History) 활성화
|
||
|
||
`Microsoft-Windows-TaskScheduler/Operational` 채널은 **기본적으로 비활성화**되어 있다. 이 채널이 꺼져 있으면 작업 스케줄러 UI 의 "기록(History)" 탭이 비어 있고, 이벤트 트리거 작업도 동작하지 않는다.
|
||
|
||
```powershell
|
||
# 관리자 PowerShell — 작업 기록 켜기
|
||
wevtutil set-log "Microsoft-Windows-TaskScheduler/Operational" /enabled:true /quiet
|
||
|
||
# 최대 크기 64MB 로 확대(기본 1MB 라 며칠이면 덮어씀)
|
||
wevtutil set-log "Microsoft-Windows-TaskScheduler/Operational" /maxsize:67108864
|
||
|
||
# 상태 확인
|
||
wevtutil get-log "Microsoft-Windows-TaskScheduler/Operational"
|
||
```
|
||
|
||
GUI 경로: 작업 스케줄러 → 우측 **작업(Action)** 창 → **모든 작업 기록 사용(Enable All Tasks History)**.
|
||
|
||
---
|
||
|
||
## 4. 보안 컨텍스트: S4U vs Password vs Interactive — **이 문서에서 가장 중요한 결정**
|
||
|
||
### 4.1 `TASK_LOGON_TYPE` 전체 표 (공식 원문)
|
||
|
||
| Value | Meaning |
|
||
|-------|---------|
|
||
| **TASK_LOGON_NONE** — 0 | The logon method is not specified. Used for non-NT credentials. |
|
||
| **TASK_LOGON_PASSWORD** — 1 | Use a password for logging on the user. The password must be supplied at registration time. |
|
||
| **TASK_LOGON_S4U** — 2 | 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.** |
|
||
| **TASK_LOGON_INTERACTIVE_TOKEN** — 3 | User must already be logged on. The task will be run only in an existing interactive session. |
|
||
| **TASK_LOGON_GROUP** — 4 | Group activation. The userId field specifies the group. |
|
||
| **TASK_LOGON_SERVICE_ACCOUNT** — 5 | Indicates that a Local System, Local Service, or Network Service account is being used as a security context to run the task. |
|
||
| **TASK_LOGON_INTERACTIVE_TOKEN_OR_PASSWORD** — 6 | First use the interactive token. If the user is not logged on (no interactive token is available), then the password is used. The password must be specified when a task is registered. **This flag is not recommended for new tasks because it is less reliable than TASK_LOGON_PASSWORD.** |
|
||
|
||
Remarks(원문):
|
||
- "This property is valid only when a user identifier is specified by the **UserId** property."
|
||
- "When reading or writing XML for a task, the logon type is specified in the **<LogonType>** element of the Task Scheduler schema."
|
||
- "For a task, that contains a message box action, the message box will be displayed if the task is activated and the task has an interactive logon type. To set the task logon type to interactive, specify 3 (**TASK_LOGON_INTERACTIVE_TOKEN**) or 4 (**TASK_LOGON_GROUP**) in the **LogonType** property of the task principal, or in the *logonType* parameter of **TaskFolder.RegisterTask** or **TaskFolder.RegisterTaskDefinition**."
|
||
|
||
PowerShell `New-ScheduledTaskPrincipal -LogonType` 허용값: **None, Password, S4U, Interactive, Group, ServiceAccount, InteractiveOrPassword**.
|
||
|
||
### 4.2 `New-ScheduledTaskPrincipal` 전체 명세
|
||
|
||
```powershell
|
||
# User (기본)
|
||
New-ScheduledTaskPrincipal
|
||
[[-Id] <String>]
|
||
[[-RunLevel] <RunLevelEnum>]
|
||
[[-ProcessTokenSidType] <ProcessTokenSidTypeEnum>]
|
||
[[-RequiredPrivilege] <String[]>]
|
||
[-UserId] <String>
|
||
[[-LogonType] <LogonTypeEnum>]
|
||
[-CimSession <CimSession[]>] [-ThrottleLimit <Int32>] [-AsJob]
|
||
|
||
# Group
|
||
New-ScheduledTaskPrincipal
|
||
[-GroupId] <String>
|
||
[[-Id] <String>] [[-RunLevel] <RunLevelEnum>]
|
||
[[-ProcessTokenSidType] <ProcessTokenSidTypeEnum>]
|
||
[[-RequiredPrivilege] <String[]>]
|
||
[-CimSession <CimSession[]>] [-ThrottleLimit <Int32>] [-AsJob]
|
||
```
|
||
|
||
| 파라미터 | 값 | 설명 |
|
||
|---------|-----|------|
|
||
| `-UserId` | String (User 세트에서 Mandatory) | "Specifies the user ID that Task Scheduler uses to run the tasks that are associated with the principal." |
|
||
| `-GroupId` | String (Group 세트에서 Mandatory) | "Specifies the ID of a user group that Task Scheduler uses to run the tasks that are associated with the principal." |
|
||
| `-LogonType` | None / Password / S4U / Interactive / Group / ServiceAccount / InteractiveOrPassword | 위 표 참조 |
|
||
| `-RunLevel` | **Limited, Highest** | "Highest. Tasks run by using the highest privileges." / "Limited. Tasks run by using the least-privileged user account (LUA)." |
|
||
| `-ProcessTokenSidType` | **None, Unrestricted, Default** | "Specifies the security ID (SID) type of the process token." |
|
||
| `-RequiredPrivilege` | String[] | "Specifies an array of user rights that Task Scheduler uses to run the tasks that are associated with the principal. Specify the constant name that is associated with a user right." |
|
||
| `-Id` | String | "Specifies the ID of a scheduled task principal." (XML 의 `<Principal id="...">`) |
|
||
|
||
공식 예제(원문):
|
||
|
||
```powershell
|
||
# Example 1: Local Service 계정 + ServiceAccount 로그온
|
||
PS C:\>$Sta = New-ScheduledTaskAction -Execute "Cmd"
|
||
PS C:\>$STPrin = New-ScheduledTaskPrincipal -UserId "LOCALSERVICE" -LogonType ServiceAccount
|
||
PS C:\>Register-ScheduledTask Task01 -Action $Sta -Principal $STPrin
|
||
|
||
# Example 2: Administrators 그룹 + 최고 권한
|
||
PS C:\>$Sta = New-ScheduledTaskAction cmd
|
||
PS C:\>$STPrin = New-ScheduledTaskPrincipal -GroupId "BUILTIN\Administrators" -RunLevel Highest
|
||
PS C:\>Register-ScheduledTask Task01 -Action $Sta -Principal $STPrin
|
||
```
|
||
|
||
### 4.3 S4U 의 치명적 제약과 우리 프로젝트에 대한 영향
|
||
|
||
공식 문장을 다시 인용한다: **"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."**
|
||
|
||
| 크롤러가 하는 일 | S4U 에서 되는가 | 비고 |
|
||
|-----------------|----------------|------|
|
||
| HTTPS 로 nedrug.mfds.go.kr 크롤링 | ✅ 된다 | 여기서 "network" 는 **네트워크 자원 인증(SMB/UNC/Kerberos 위임)** 을 뜻한다. 아웃바운드 TCP/HTTP 는 정상 동작 |
|
||
| 로컬 디스크(`D:\workspace\DMF_Crawler`) 읽기/쓰기 | ✅ 된다 | |
|
||
| **UNC 경로 `\\NAS\reports` 에 xlsx 저장** | ❌ **안 된다** | 네트워크 자격증명 없음 |
|
||
| **매핑된 네트워크 드라이브(`Z:\`)** | ❌ 안 된다 | 비대화형 세션에는 드라이브 매핑 자체가 없음 |
|
||
| **EFS 로 암호화된 파일 읽기** | ❌ 안 된다 | "encrypted files" 로 명시 |
|
||
| **Windows Credential Manager / python `keyring` (DPAPI 사용자 키)** | ⚠️ **불확실 — 실측 필요** | DPAPI 사용자 마스터 키는 로그온 시 암호에서 파생된다. S4U 토큰은 대화형 토큰을 흉내내지만 암호를 모르므로 **DPAPI 복호화 실패 가능성이 높다.** `keyring`(Windows 백엔드 = Credential Manager)을 쓴다면 반드시 실측하라 → 부록 B-1 |
|
||
| 환경변수 `%USERPROFILE%` 확장 | ✅ 된다 | 다만 프로필 미로드 시 `HKCU` 접근 실패 가능 |
|
||
| `HKCU` 레지스트리 읽기/쓰기 | ⚠️ 불안정 | 비대화형 세션에서 사용자 하이브가 로드되지 않을 수 있음 |
|
||
| **토스트 알림 표시** | ❌ **절대 안 된다** | 대화형 데스크톱이 없음 (→ §12) |
|
||
|
||
### 4.4 결정 트리 — 어느 LogonType 을 쓸 것인가
|
||
|
||
```
|
||
크롤러가 API 키/비밀번호를 어디서 읽는가?
|
||
│
|
||
├─ .env 파일 또는 평문/커스텀 암호화 파일 (로컬 디스크)
|
||
│ └─ ✅ S4U 사용. 비밀번호 저장 불필요, 가장 안전.
|
||
│ New-ScheduledTaskPrincipal -UserId "$env:USERDOMAIN\$env:USERNAME" `
|
||
│ -LogonType S4U -RunLevel Highest
|
||
│
|
||
├─ Windows Credential Manager / python keyring / DPAPI
|
||
│ └─ ⚠️ 먼저 S4U 로 실측(부록 B-1). 실패하면 ↓
|
||
│
|
||
├─ UNC/네트워크 드라이브에 결과물 저장, 또는 위 실측 실패
|
||
│ └─ ✅ Password 사용. 계정 암호를 Task Scheduler 자격증명 저장소에 저장.
|
||
│ Register-ScheduledTask -User "DOMAIN\user" -Password "..." -RunLevel Highest
|
||
│ (LogonType 은 자동으로 Password 가 됨)
|
||
│ ⚠️ 계정 암호를 바꾸면 작업이 깨진다 → 변경 절차를 운영 문서에 명시
|
||
│
|
||
└─ 알림 표시 전용 작업 (DMF_Crawler_Notify)
|
||
└─ ✅ Interactive 사용. 반드시.
|
||
New-ScheduledTaskPrincipal -UserId "..." -LogonType Interactive -RunLevel Limited
|
||
```
|
||
|
||
### 4.5 `Log on as a batch job` 권한 — 잊으면 조용히 실패한다
|
||
|
||
공식 문서(Security Contexts for Tasks) 원문:
|
||
|
||
> "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."
|
||
|
||
즉 **S4U 든 Password 든 `SeBatchLogonRight` 가 필요**하다. Administrators 그룹 멤버면 기본 보유. 아니면:
|
||
|
||
```
|
||
secpol.msc → 로컬 정책 → 사용자 권한 할당 →
|
||
"배치 작업으로 로그온(Log on as a batch job)" 에 계정 추가
|
||
```
|
||
|
||
권한이 없으면 등록은 되지만 실행에 실패하고, 등록 시점에 `SCHED_S_BATCH_LOGON_PROBLEM (0x0004131C)` — "The task is registered, but may fail to start. Batch logon privilege needs to be enabled for the task principal." 가 반환된다.
|
||
|
||
### 4.6 UAC / RunLevel 규칙 (공식 원문 요약)
|
||
|
||
- "By default, a task runs with low level privileges when UAC is turned on."
|
||
- "If a task's actions must have elevated privileges to run, then you must set the **RunLevel** property to **TASK_RUNLEVEL_HIGHEST**."
|
||
- "If a task is registered using the Administrators group for the security context of the task, then you must also set the **RunLevel** property to **TASK_RUNLEVEL_HIGHEST** if you want to run the task."
|
||
- "If a task is registered using the Builtin\Administrator account or the Local System or Local Service accounts, then the **RunLevel** property will be **ignored**." (UAC 가 꺼져 있어도 무시된다.)
|
||
- "**From a low privilege process, you cannot register a task with the RunLevel property equal to TASK_RUNLEVEL_HIGHEST**, but you can register a task with the RunLevel property equal to TASK_RUNLEVEL_LUA." → **작업 등록 스크립트는 반드시 관리자 권한 PowerShell 에서 실행**해야 한다.
|
||
- "You are not allowed to register the task as Builtin/Administrator, Local System, or for a group" (저권한 프로세스에서).
|
||
- "The value of the **RunLevel** property doesn't affect the permissions needed to run or delete a task."
|
||
|
||
### 4.7 등록 시 암호가 필요한 경우 (공식 원문)
|
||
|
||
Administrators 그룹 멤버 계정으로 등록할 때 암호가 필요한 상황:
|
||
- "If you register the task to run under the security context of your account or a different user's account and you use the **TASK_LOGON_PASSWORD** flag in the RegisterTask or RegisterTaskDefinition method."
|
||
- "If you register the task to run under the security context of a **different user's account** and you use the **TASK_LOGON_S4U** flag."
|
||
|
||
→ **자기 자신 계정 + S4U 로 등록하면 암호 입력이 필요 없다.** 이것이 S4U 를 선호하는 실무적 이유.
|
||
|
||
또한: "You cannot use a user group as the security context of a task when you register the task using the TASK_LOGON_S4U flag or the TASK_LOGON_PASSWORD flag."
|
||
|
||
비관리자 계정에서 등록할 때: "you do not need to specify a password when registering the task if you register the task to run under the security context of your account and you use the S4U or interactive logon type. Otherwise, you need to specify a password ... Also, you cannot register the task using the Local Service account or by using a group for the task's security context."
|
||
|
||
### 4.8 작업 읽기/수정/삭제/실행 권한 (공식 원문 요약)
|
||
|
||
- "By default, a user who creates a task can read, update, delete, and run the task."
|
||
- "Members of the Administrators group or the SYSTEM account can read, update, delete, and run **any** tasks."
|
||
- "Members of the Users group, the LocalService account, and the NetworkService account can only read, update, delete, and run the tasks that **they have created**."
|
||
- "A user must have WriteDAC permission in addition to the read/write permissions to update a task if the task update requires a change to the DACL for the task."
|
||
|
||
### 4.9 "Run whether user is logged on or not" 이 깨뜨리는 것들
|
||
|
||
실제 사례에서 반복 확인된 실패 패턴:
|
||
|
||
| 증상 | 원인 | 해결 |
|
||
|------|------|------|
|
||
| exit `0x1`, 로그 없음 | 상대 경로 사용, 작업 디렉터리 미지정 | **`-WorkingDirectory` (XML 의 `<WorkingDirectory>`) 명시 + 스크립트 내 전 경로 절대경로화** |
|
||
| 매핑된 드라이브 `Z:\` 없음 | 비대화형 세션에 드라이브 매핑 없음 | UNC 절대경로 + Password 로그온, 또는 로컬 경로로 변경 |
|
||
| `HKCU` 값이 비어 있음 | 사용자 하이브 미로드 | 설정을 `HKLM` 또는 파일로 이전 |
|
||
| 작업이 계속 "큐에 대기 중(Queued)" | 로그온한 사용자가 한 번도 없는 상태 + Interactive 토큰 | Password 또는 S4U 로 전환 |
|
||
| Playwright 브라우저 못 찾음 | `%USERPROFILE%\AppData\Local\ms-playwright` 가 다른 계정 프로필 | `PLAYWRIGHT_BROWSERS_PATH` 를 고정 경로로(§8.3) |
|
||
| 토스트가 안 뜸 | 비대화형 세션 | 별도 Interactive 작업으로 분리(§12) |
|
||
| GUI 앱이 실행은 되는데 창이 없음 | "the app was in fact running, but its window was nowhere to be seen because it runs in a non-interactive session" | 설계상 불가. 분리 필수 |
|
||
|
||
권고 조치(공식 답변에서 정리):
|
||
1. **작업 기록(History) 활성화** — Action pane → Enable All Tasks History
|
||
2. 기록 탭에서 예약 시각 이후 launch failure 확인
|
||
3. **보안 이벤트 로그**에서 해당 계정 관련 이벤트 확인
|
||
4. **"Log on as a batch job" 권한 확인**
|
||
5. **stdout/stderr 캡처 활성화** (§17)
|
||
6. 절대 경로 사용 / **Start in 폴더 명시** / 작업 암호 저장 / UNC 는 명시적 자격증명 사용
|
||
|
||
---
|
||
|
||
## 5. PowerShell 작업 등록 완전 스크립트
|
||
|
||
### 5.1 `Register-ScheduledTask` 파라미터 세트
|
||
|
||
```powershell
|
||
# User (기본)
|
||
Register-ScheduledTask [[-Password] <String>] [[-User] <String>] [-TaskName] <String>
|
||
[[-TaskPath] <String>] [-Action] <CimInstance[]> [[-Description] <String>]
|
||
[[-Settings] <CimInstance>] [[-Trigger] <CimInstance[]>] [[-RunLevel] <RunLevelEnum>]
|
||
[-Force] [-CimSession <CimSession[]>] [-ThrottleLimit <Int32>] [-AsJob]
|
||
|
||
# Xml
|
||
Register-ScheduledTask [[-Password] <String>] [[-User] <String>] [-TaskName] <String>
|
||
[[-TaskPath] <String>] [-Xml] <String> [-Force] [-CimSession <CimSession[]>]
|
||
[-ThrottleLimit <Int32>] [-AsJob]
|
||
|
||
# Principal
|
||
Register-ScheduledTask [-TaskName] <String> [[-TaskPath] <String>] [[-Principal] <CimInstance>]
|
||
[-Action] <CimInstance[]> [[-Description] <String>] [[-Settings] <CimInstance>]
|
||
[[-Trigger] <CimInstance[]>] [-Force] [-CimSession <CimSession[]>]
|
||
[-ThrottleLimit <Int32>] [-AsJob]
|
||
|
||
# Object
|
||
Register-ScheduledTask [-InputObject] <CimInstance> [[-Password] <String>] [[-User] <String>]
|
||
[[-TaskName] <String>] [[-TaskPath] <String>] [-Force] [-CimSession <CimSession[]>]
|
||
[-ThrottleLimit <Int32>] [-AsJob]
|
||
```
|
||
|
||
Description(원문): "You can register a task to run executable files (`.exe` and `.com`), batch files (`.bat` and `.cmd`), or any registered file type. However, this cmdlet does not check whether the file you intend it to run is compatible with your version, edition, or platform specialization of Windows."
|
||
|
||
주요 파라미터 원문:
|
||
- `-Action`: "Specifies an array of one or more work items for the task to run. If you specify multiple actions, the computer runs them in order. **You can specify up to 32 actions.**"
|
||
- `-Password`: "Specifies a password for the user account in the context of which the task runs. **The password is ignored for the well-known system accounts.** Well-known accounts are: NT AUTHORITY\SYSTEM, NT AUTHORITY\LOCALSERVICE, NT AUTHORITY\NETWORKSERVICE, and the well-known security identifiers (SIDs) for all three accounts."
|
||
- `-Principal`: "Specifies the security context in which a task is run."
|
||
- `-RunLevel`: "Specifies the required privilege level to run tasks that are associated with the principal." (Limited, Highest)
|
||
- `-Settings`: "Specifies a configuration that the Task Scheduler service uses to determine how to run a task."
|
||
- `-Description`: "Briefly describes the task."
|
||
- `-TaskName`: "Specifies the name of a scheduled task."
|
||
- `-TaskPath`: "Specifies an array of one or more paths for scheduled tasks in Task Scheduler namespace. You can use `\*` for a wildcard character query. You can use `\` for the root folder. **To specify a full TaskPath you need to include the leading and trailing `\`.** If you do not specify a path, the cmdlet uses the root folder."
|
||
- `-Force`: "Instructs the cmdlet to perform the operation without prompting for confirmation."
|
||
- `-InputObject`: "Specifies the input object that is used in a pipeline command."
|
||
|
||
공식 예제(원문):
|
||
|
||
```powershell
|
||
PS C:\> $Time = New-ScheduledTaskTrigger -At 12:00 -Once
|
||
PS C:\> $User = "Contoso\Administrator"
|
||
PS C:\> $PS = New-ScheduledTaskAction -Execute "PowerShell.exe"
|
||
PS C:\> Register-ScheduledTask -TaskName "SoftwareScan" -Trigger $Time -User $User -Action $PS
|
||
```
|
||
|
||
### 5.2 스크립트 1 — `scripts\register-tasks.ps1` (완결)
|
||
|
||
> **관리자 권한 PowerShell 에서 실행.** 저권한 프로세스에서는 `RunLevel Highest` 등록이 거부된다(§4.6).
|
||
|
||
```powershell
|
||
#Requires -RunAsAdministrator
|
||
<#
|
||
.SYNOPSIS
|
||
DMF_Crawler 의 Windows 작업 스케줄러 작업 3종을 등록한다.
|
||
.DESCRIPTION
|
||
(1) DMF_Crawler_Daily : 매일 06:00 + 부팅 시. 크롤링 본체.
|
||
(2) DMF_Crawler_Watchdog : 매일 07:00 + 부팅 후 10분. heartbeat 검증.
|
||
(3) DMF_Crawler_Notify : 로그온 시 + 실패 이벤트 시. 토스트 알림(대화형).
|
||
.PARAMETER LogonMode
|
||
'S4U' : 암호 저장 안 함. 네트워크 자원/EFS/DPAPI 접근 불가.
|
||
'Password' : 계정 암호를 저장. 네트워크 자원 접근 가능.
|
||
.EXAMPLE
|
||
.\register-tasks.ps1 -LogonMode S4U
|
||
.\register-tasks.ps1 -LogonMode Password
|
||
#>
|
||
[CmdletBinding()]
|
||
param(
|
||
[ValidateSet('S4U', 'Password')]
|
||
[string]$LogonMode = 'S4U',
|
||
|
||
[string]$Root = 'D:\workspace\DMF_Crawler',
|
||
|
||
[string]$TaskFolder = '\DMF_Crawler\',
|
||
|
||
[string]$RunAsUser = "$env:USERDOMAIN\$env:USERNAME"
|
||
)
|
||
|
||
$ErrorActionPreference = 'Stop'
|
||
Set-StrictMode -Version Latest
|
||
|
||
# ---------------------------------------------------------------- 준비
|
||
$Scripts = Join-Path $Root 'scripts'
|
||
$Logs = Join-Path $Root 'logs'
|
||
$State = Join-Path $Root 'state'
|
||
$Ops = Join-Path $Root 'ops'
|
||
|
||
foreach ($d in @($Scripts, $Logs, $State, $Ops)) {
|
||
if (-not (Test-Path $d)) { New-Item -ItemType Directory -Path $d -Force | Out-Null }
|
||
}
|
||
|
||
# PowerShell 5.1 실행 파일을 명시적으로 사용한다.
|
||
# 이유: Write-EventLog / New-EventLog 가 PowerShell 7 에 없다(§10).
|
||
$PwshExe = "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe"
|
||
if (-not (Test-Path $PwshExe)) { throw "powershell.exe 를 찾을 수 없습니다: $PwshExe" }
|
||
|
||
Write-Host "[*] Root : $Root"
|
||
Write-Host "[*] LogonMode : $LogonMode"
|
||
Write-Host "[*] RunAsUser : $RunAsUser"
|
||
Write-Host "[*] TaskFolder : $TaskFolder"
|
||
|
||
# 작업 기록(History) 채널 활성화 + 크기 확대 (기본 비활성, 기본 1MB)
|
||
Write-Host "[*] TaskScheduler/Operational 채널 활성화..."
|
||
& wevtutil.exe set-log "Microsoft-Windows-TaskScheduler/Operational" /enabled:true /quiet
|
||
& wevtutil.exe set-log "Microsoft-Windows-TaskScheduler/Operational" /maxsize:67108864
|
||
|
||
# 이벤트 로그 소스 등록 (§10). 관리자 권한 필요.
|
||
if (-not [System.Diagnostics.EventLog]::SourceExists('DMFCrawler')) {
|
||
Write-Host "[*] 이벤트 소스 'DMFCrawler' 등록 (Application 로그)..."
|
||
New-EventLog -LogName 'Application' -Source 'DMFCrawler'
|
||
} else {
|
||
Write-Host "[=] 이벤트 소스 'DMFCrawler' 이미 존재"
|
||
}
|
||
|
||
# ---------------------------------------------------------------- 공통 설정
|
||
$CommonSettings = New-ScheduledTaskSettingsSet `
|
||
-AllowStartIfOnBatteries `
|
||
-DontStopIfGoingOnBatteries `
|
||
-StartWhenAvailable `
|
||
-DontStopOnIdleEnd `
|
||
-WakeToRun `
|
||
-RunOnlyIfNetworkAvailable `
|
||
-MultipleInstances IgnoreNew `
|
||
-ExecutionTimeLimit (New-TimeSpan -Hours 2) `
|
||
-RestartCount 3 `
|
||
-RestartInterval (New-TimeSpan -Minutes 10) `
|
||
-Priority 5 `
|
||
-Compatibility Win8
|
||
|
||
# ---------------------------------------------------------------- principal
|
||
function New-DmfPrincipal {
|
||
param([ValidateSet('Batch','Interactive')][string]$Kind)
|
||
|
||
if ($Kind -eq 'Interactive') {
|
||
return New-ScheduledTaskPrincipal -UserId $RunAsUser `
|
||
-LogonType Interactive `
|
||
-RunLevel Limited
|
||
}
|
||
if ($LogonMode -eq 'S4U') {
|
||
return New-ScheduledTaskPrincipal -UserId $RunAsUser `
|
||
-LogonType S4U `
|
||
-RunLevel Highest
|
||
}
|
||
# Password 모드는 Register-ScheduledTask -User/-Password 경로를 쓴다.
|
||
return $null
|
||
}
|
||
|
||
# ---------------------------------------------------------------- (1) Daily
|
||
$DailyAction = New-ScheduledTaskAction `
|
||
-Execute $PwshExe `
|
||
-Argument "-NoProfile -NonInteractive -ExecutionPolicy Bypass -File `"$Scripts\run-daily.ps1`"" `
|
||
-WorkingDirectory $Root
|
||
|
||
$DailyTriggers = @(
|
||
(New-ScheduledTaskTrigger -Daily -At '06:00' -RandomDelay (New-TimeSpan -Minutes 2)),
|
||
(New-ScheduledTaskTrigger -AtStartup -RandomDelay (New-TimeSpan -Minutes 3))
|
||
)
|
||
|
||
# ---------------------------------------------------------------- (2) Watchdog
|
||
$WatchdogAction = New-ScheduledTaskAction `
|
||
-Execute $PwshExe `
|
||
-Argument "-NoProfile -NonInteractive -ExecutionPolicy Bypass -File `"$Scripts\watchdog.ps1`"" `
|
||
-WorkingDirectory $Root
|
||
|
||
$WatchdogTriggers = @(
|
||
(New-ScheduledTaskTrigger -Daily -At '07:00'),
|
||
(New-ScheduledTaskTrigger -AtStartup -RandomDelay (New-TimeSpan -Minutes 10))
|
||
)
|
||
|
||
# ---------------------------------------------------------------- (3) Notify (대화형)
|
||
$NotifyAction = New-ScheduledTaskAction `
|
||
-Execute $PwshExe `
|
||
-Argument "-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File `"$Scripts\notify.ps1`"" `
|
||
-WorkingDirectory $Root
|
||
|
||
$NotifyTriggers = @(
|
||
(New-ScheduledTaskTrigger -AtLogOn -User $RunAsUser -RandomDelay (New-TimeSpan -Seconds 30))
|
||
)
|
||
|
||
$NotifySettings = New-ScheduledTaskSettingsSet `
|
||
-AllowStartIfOnBatteries `
|
||
-DontStopIfGoingOnBatteries `
|
||
-StartWhenAvailable `
|
||
-MultipleInstances IgnoreNew `
|
||
-ExecutionTimeLimit (New-TimeSpan -Minutes 10) `
|
||
-Compatibility Win8
|
||
|
||
# ---------------------------------------------------------------- 등록 함수
|
||
$script:PlainPassword = $null
|
||
|
||
function Register-DmfTask {
|
||
param(
|
||
[string]$Name,
|
||
[string]$Description,
|
||
$Action,
|
||
$Triggers,
|
||
$Settings,
|
||
[ValidateSet('Batch','Interactive')][string]$Kind
|
||
)
|
||
|
||
Write-Host "[*] 등록: $TaskFolder$Name"
|
||
$principal = New-DmfPrincipal -Kind $Kind
|
||
|
||
if ($null -ne $principal) {
|
||
Register-ScheduledTask -TaskName $Name -TaskPath $TaskFolder `
|
||
-Action $Action -Trigger $Triggers -Settings $Settings `
|
||
-Principal $principal -Description $Description -Force | Out-Null
|
||
}
|
||
else {
|
||
# Password 모드: 암호를 대화형으로 받는다(스크립트에 하드코딩 금지)
|
||
if (-not $script:PlainPassword) {
|
||
$sec = Read-Host -AsSecureString "[$RunAsUser] 계정 암호 입력"
|
||
$bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($sec)
|
||
$script:PlainPassword = [Runtime.InteropServices.Marshal]::PtrToStringAuto($bstr)
|
||
[Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr)
|
||
}
|
||
Register-ScheduledTask -TaskName $Name -TaskPath $TaskFolder `
|
||
-Action $Action -Trigger $Triggers -Settings $Settings `
|
||
-User $RunAsUser -Password $script:PlainPassword `
|
||
-RunLevel Highest -Description $Description -Force | Out-Null
|
||
}
|
||
}
|
||
|
||
Register-DmfTask -Name 'DMF_Crawler_Daily' `
|
||
-Description 'MFDS DMF 공고/현황 크롤링 및 xlsx 리포트 생성 (매일 06:00)' `
|
||
-Action $DailyAction -Triggers $DailyTriggers -Settings $CommonSettings -Kind Batch
|
||
|
||
Register-DmfTask -Name 'DMF_Crawler_Watchdog' `
|
||
-Description 'DMF_Crawler_Daily heartbeat 검증 및 실패 알림 (매일 07:00)' `
|
||
-Action $WatchdogAction -Triggers $WatchdogTriggers -Settings $CommonSettings -Kind Batch
|
||
|
||
Register-DmfTask -Name 'DMF_Crawler_Notify' `
|
||
-Description 'DMF_Crawler 알림 큐를 읽어 토스트 표시 (대화형 세션 전용)' `
|
||
-Action $NotifyAction -Triggers $NotifyTriggers -Settings $NotifySettings -Kind Interactive
|
||
|
||
# ---------------------------------------------------------------- 부팅 트리거 Delay 주입
|
||
# New-ScheduledTaskTrigger -AtStartup 에는 고정 -Delay 파라미터가 없다.
|
||
# XML 을 직접 수정하여 <BootTrigger><Delay>PT3M</Delay> 를 넣는다.
|
||
Write-Host "[*] BootTrigger Delay 주입..."
|
||
foreach ($pair in @(
|
||
@{ Name = 'DMF_Crawler_Daily'; Delay = 'PT3M' },
|
||
@{ Name = 'DMF_Crawler_Watchdog'; Delay = 'PT10M' })) {
|
||
|
||
$xml = Export-ScheduledTask -TaskName $pair.Name -TaskPath $TaskFolder
|
||
if ($xml -notmatch '<BootTrigger>') { continue }
|
||
if ($xml -match '<BootTrigger>\s*<Delay>') { continue } # 이미 적용됨
|
||
|
||
$xml = $xml -replace '<BootTrigger>', "<BootTrigger><Delay>$($pair.Delay)</Delay>"
|
||
Register-ScheduledTask -TaskName $pair.Name -TaskPath $TaskFolder -Xml $xml -Force | Out-Null
|
||
Write-Host " - $($pair.Name): Delay $($pair.Delay) 적용"
|
||
}
|
||
|
||
# ---------------------------------------------------------------- XML 백업(형상관리)
|
||
foreach ($n in @('DMF_Crawler_Daily','DMF_Crawler_Watchdog','DMF_Crawler_Notify')) {
|
||
Export-ScheduledTask -TaskName $n -TaskPath $TaskFolder |
|
||
Out-File -FilePath (Join-Path $Ops "$n.xml") -Encoding utf8
|
||
}
|
||
Write-Host "[=] XML 백업: $Ops"
|
||
|
||
# ---------------------------------------------------------------- 검증
|
||
Write-Host ""
|
||
Write-Host "[=] 등록 결과:"
|
||
Get-ScheduledTask -TaskPath $TaskFolder |
|
||
Select-Object TaskName, State,
|
||
@{n='LogonType'; e={ $_.Principal.LogonType }},
|
||
@{n='RunLevel'; e={ $_.Principal.RunLevel }} |
|
||
Format-Table -AutoSize
|
||
|
||
Write-Host ""
|
||
Write-Host "[=] 다음 실행 예정 시각:"
|
||
Get-ScheduledTask -TaskPath $TaskFolder |
|
||
Get-ScheduledTaskInfo |
|
||
Select-Object TaskName, NextRunTime, LastRunTime, LastTaskResult |
|
||
Format-Table -AutoSize
|
||
|
||
Write-Host ""
|
||
Write-Host "[!] 수동 테스트: Start-ScheduledTask -TaskPath '$TaskFolder' -TaskName 'DMF_Crawler_Daily'"
|
||
Write-Host "[!] 전체 삭제 : Get-ScheduledTask -TaskPath '$TaskFolder' | Unregister-ScheduledTask -Confirm:`$false"
|
||
```
|
||
|
||
### 5.3 등록 후 즉시 확인할 명령 모음
|
||
|
||
```powershell
|
||
# 작업 상태 전체
|
||
Get-ScheduledTask -TaskPath '\DMF_Crawler\' | Format-List TaskName, State, Principal, Settings
|
||
|
||
# 다음 실행 시각 / 마지막 결과 코드
|
||
Get-ScheduledTask -TaskPath '\DMF_Crawler\' | Get-ScheduledTaskInfo
|
||
|
||
# XML 로 내보내 형상관리에 커밋 (반드시 UTF-8)
|
||
Export-ScheduledTask -TaskName 'DMF_Crawler_Daily' -TaskPath '\DMF_Crawler\' |
|
||
Out-File -Encoding utf8 'D:\workspace\DMF_Crawler\ops\DMF_Crawler_Daily.xml'
|
||
|
||
# 수동 실행 (조건 무시하고 즉시)
|
||
Start-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Daily'
|
||
|
||
# 실행 중 중단
|
||
Stop-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Daily'
|
||
|
||
# 일시 비활성화 / 재활성화
|
||
Disable-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Daily'
|
||
Enable-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Daily'
|
||
|
||
# 최근 작업 기록 30건
|
||
Get-WinEvent -LogName 'Microsoft-Windows-TaskScheduler/Operational' -MaxEvents 200 |
|
||
Where-Object { $_.Message -match 'DMF_Crawler' } |
|
||
Select-Object -First 30 TimeCreated, Id, LevelDisplayName, Message |
|
||
Format-List
|
||
|
||
# 우리 애플리케이션 이벤트만
|
||
Get-WinEvent -FilterHashtable @{ LogName='Application'; ProviderName='DMFCrawler' } -MaxEvents 20 |
|
||
Select-Object TimeCreated, Id, LevelDisplayName, Message | Format-List
|
||
```
|
||
|
||
---
|
||
|
||
## 6. schtasks XML 정본
|
||
|
||
### 6.1 `schtasks /create` 파라미터 (공식)
|
||
|
||
```
|
||
schtasks /create /sc <scheduletype> /tn <taskname> /tr <taskrun>
|
||
[/s <computer> [/u [<domain>\]<user> [/p <password>]]]
|
||
[/ru {[<domain>\]<user> | system}] [/rp <password>]
|
||
[/mo <modifier>] [/d <day>[,<day>...] | *] [/m <month>[,<month>...]]
|
||
[/i <idletime>] [/st <starttime>] [/ri <interval>]
|
||
[{/et <endtime> | /du <duration>} [/k]] [/sd <startdate>] [/ed <enddate>]
|
||
[/it] [/np] [/z] [/xml <xmlfile>] [/v1] [/f] [/rl <level>]
|
||
[/delay <delaytime>] [/hresult]
|
||
```
|
||
|
||
핵심 파라미터(공식 원문 발췌):
|
||
|
||
| 파라미터 | 설명 |
|
||
|---------|------|
|
||
| `/sc <scheduletype>` | MINUTE, HOURLY, DAILY, WEEKLY, MONTHLY, ONCE, **ONSTART** — "Specifies that the task runs every time the system starts. You can specify a start date, or run the task the next time the system starts." · ONLOGON — "Specifies that the task runs whenever a user (any user) logs on." · ONIDLE · **ONEVENT** — "Specifies that the task runs based on an event that matches information from the system event log including the EventID." |
|
||
| `/tn <taskname>` | "Each task on the system must have a unique name and must conform to the rules for file names, **not exceeding 238 characters**. Use quotation marks to enclose names that include spaces. **To store your scheduled task in a different folder, run /tn `<folder name\task name>`.**" |
|
||
| `/tr <Taskrun>` | "Type the fully qualified path and file name of an executable file, script file, or batch file. **The path name must not exceed 262 characters.** If you don't add the path, schtasks assumes that the file is in the `<systemroot>\System32` directory." |
|
||
| `/s <computer>` | 원격 컴퓨터 이름/IP. 기본은 로컬 |
|
||
| `/u [<domain>\]<user>` / `/p <password>` | "The **/u** and **/p** parameters are valid only when you use **/s**." |
|
||
| `/ru {user \| system}` | "Runs the task with permissions of the specified user account." **System** = "the local System account, a highly privileged account used by the operating system and system services." |
|
||
| `/rp <password>` | "**Don't use the /rp parameter for tasks that run with System account credentials (/ru System).** The System account doesn't have a password and SchTasks.exe doesn't prompt for one." |
|
||
| `/mo <modifier>` | 스케줄 타입별 반복 배수. DAILY 는 1~365, WEEKLY 는 1~52, MONTHLY 는 1~12 또는 LASTDAY/FIRST/SECOND/THIRD/FOURTH |
|
||
| `/st <Starttime>` | "Specifies the start time for the task, using the 24-hour time format, **HH:mm**. The default value is the current time on the local computer. The **/st** parameter is valid with MINUTE, HOURLY, DAILY, WEEKLY, MONTHLY, and ONCE schedules. It's required for a ONCE schedule." |
|
||
| `/ri <interval>` | "Specifies the repetition interval for the scheduled task, in minutes. **This isn't applicable for schedule types: MINUTE, HOURLY, ONSTART, ONLOGON, ONIDLE, and ONEVENT.** Valid range is 1 - 599940 (599940 minutes = 9999 hours). If either the **/et** or **/du** parameters are specified, the default is **10 minutes**." |
|
||
| `/et <endtime>` / `/du <duration>` / `/k` | MINUTE/HOURLY 전용 종료 시각·최대 지속시간·종료 시 프로세스 kill |
|
||
| `/sd` / `/ed` | 시작/종료 날짜. 형식은 로캘 종속("Only one format is valid for each locale") |
|
||
| `/ec <channelname>` | "Specifies the event channel name triggered by the ONEVENT schedule type that matches a system event log criteria." |
|
||
| `/it` | "Specifies to run the scheduled task **only when the run as user is logged on** to the computer. This parameter has no effect on tasks that run with system permissions or tasks that already have the interactive-only property set. **You can't use a change command to remove the interactive-only property from a task.**" → 대화형 알림 작업용 |
|
||
| `/np` | "**No password is stored.** The task runs non-interactively as the given user. **Only local resources are available.**" → S4U 에 해당 |
|
||
| `/z` | "Specifies to delete the task upon the completion of its schedule." |
|
||
| `/rl <level>` | LIMITED 또는 **HIGHEST** |
|
||
| `/delay <delaytime>` | ONSTART/ONLOGON/ONEVENT 트리거 지연 |
|
||
| `/xml <xmlfile>` | "Creates a task specified in the XML file. Can be combined with the **/ru** ..." — XML 이 이미 principal 을 포함하면 `/rp` 만 조합 |
|
||
| `/v1` | Task Scheduler 1.0 호환 작업 생성 |
|
||
| `/f` | 강제(기존 작업 덮어쓰기) |
|
||
| `/hresult` | 결과를 HRESULT 로 출력 |
|
||
|
||
XML 임포트 실전 명령:
|
||
|
||
```cmd
|
||
schtasks /create /XML "D:\workspace\DMF_Crawler\ops\DMF_Crawler_Daily.xml" /TN "\DMF_Crawler\DMF_Crawler_Daily" /F
|
||
schtasks.exe /create /RU DOMAIN\user /RP password /TN "\DMF_Crawler\DMF_Crawler_Daily" /XML "D:\workspace\DMF_Crawler\ops\DMF_Crawler_Daily.xml"
|
||
schtasks /create /RU SYSTEM /SC ONSTART /TN "\DMF_Crawler\Boot" /TR "powershell.exe -File D:\workspace\DMF_Crawler\scripts\run-daily.ps1" /DELAY 0003:00 /RL HIGHEST /F
|
||
```
|
||
|
||
> ⚠️ **인코딩 함정**: "Even though Task Scheduler exports files as UTF-16, it refuses to read them unless they're **UTF-8** encoded." → `Export-ScheduledTask ... | Out-File -Encoding utf8` 로 저장한다. PowerShell 7 이면 `-Encoding utf8BOM` 을 명시하라(PS7 의 `utf8` 은 BOM 없음).
|
||
|
||
`msg`/메시지 상자 액션은 최신 Windows 에서 Task Scheduler UI 로는 만들 수 없다("Can't Create Tasks to Display Messages in Task Scheduler in Windows 8 and Later"). `msg.exe` 를 `/tr` 로 실행하는 방식으로 우회한다(§12.4).
|
||
|
||
### 6.2 `DMF_Crawler_Daily.xml` — 완결 XML
|
||
|
||
`ops\DMF_Crawler_Daily.xml` 로 저장하고 Git 에 커밋한다. **`<UserId>` 는 실제 `DOMAIN\user` 또는 SID 로 치환할 것.**
|
||
|
||
```xml
|
||
<?xml version="1.0" encoding="UTF-8"?>
|
||
<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>MFDS DMF 공고/현황 크롤링 및 xlsx 리포트 생성 (매일 06:00 + 부팅 시 보정)</Description>
|
||
<URI>\DMF_Crawler\DMF_Crawler_Daily</URI>
|
||
</RegistrationInfo>
|
||
|
||
<Triggers>
|
||
<!-- 매일 06:00, 최대 2분 랜덤 지연 -->
|
||
<CalendarTrigger>
|
||
<StartBoundary>2026-09-02T06:00:00</StartBoundary>
|
||
<Enabled>true</Enabled>
|
||
<RandomDelay>PT2M</RandomDelay>
|
||
<ScheduleByDay>
|
||
<DaysInterval>1</DaysInterval>
|
||
</ScheduleByDay>
|
||
</CalendarTrigger>
|
||
|
||
<!-- 시스템 시작 3분 후. Fast Startup 시에는 발화하지 않을 수 있으므로
|
||
StartWhenAvailable 이 진짜 안전망이다(§14.2). -->
|
||
<BootTrigger>
|
||
<Enabled>true</Enabled>
|
||
<Delay>PT3M</Delay>
|
||
</BootTrigger>
|
||
</Triggers>
|
||
|
||
<Principals>
|
||
<Principal id="Author">
|
||
<!-- S4U 모드. Password 모드로 바꾸려면 LogonType 을 Password 로 하고
|
||
schtasks /RU /RP 또는 Register-ScheduledTask -User -Password 로 등록한다. -->
|
||
<UserId>DESKTOP-XXXX\encep</UserId>
|
||
<LogonType>S4U</LogonType>
|
||
<RunLevel>HighestAvailable</RunLevel>
|
||
</Principal>
|
||
</Principals>
|
||
|
||
<Settings>
|
||
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
|
||
|
||
<!-- 전원: 배터리에서도 시작하고, 배터리로 전환돼도 멈추지 않는다.
|
||
둘 다 XSD 기본값이 true 이므로 명시적으로 false 로 뒤집어야 한다. -->
|
||
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
|
||
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
|
||
|
||
<AllowHardTerminate>true</AllowHardTerminate>
|
||
|
||
<!-- 놓친 작업 즉시 실행: 재부팅/절전/전원차단 내성의 핵심. 기본값 false. -->
|
||
<StartWhenAvailable>true</StartWhenAvailable>
|
||
|
||
<!-- 네트워크가 준비된 뒤에만 실행. 기본값 false. -->
|
||
<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>
|
||
|
||
<!-- 절전 상태에서 깨워서 실행. powercfg 로 wake timer 허용 필요(§14.5). 기본값 false. -->
|
||
<WakeToRun>true</WakeToRun>
|
||
|
||
<!-- 2시간 넘으면 폭주로 간주하고 종료. PT0S 면 무제한. 미지정 시 기본 3일. -->
|
||
<ExecutionTimeLimit>PT2H</ExecutionTimeLimit>
|
||
|
||
<Priority>5</Priority>
|
||
|
||
<!-- 실패 시 10분 간격 3회 재시도.
|
||
XSD 제약: Interval 은 PT1M ~ P31D, Count 는 unsignedByte 최소 1 -->
|
||
<RestartOnFailure>
|
||
<Interval>PT10M</Interval>
|
||
<Count>3</Count>
|
||
</RestartOnFailure>
|
||
</Settings>
|
||
|
||
<Actions Context="Author">
|
||
<Exec>
|
||
<Command>C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe</Command>
|
||
<Arguments>-NoProfile -NonInteractive -ExecutionPolicy Bypass -File "D:\workspace\DMF_Crawler\scripts\run-daily.ps1"</Arguments>
|
||
<WorkingDirectory>D:\workspace\DMF_Crawler</WorkingDirectory>
|
||
</Exec>
|
||
</Actions>
|
||
</Task>
|
||
```
|
||
|
||
### 6.3 `DMF_Crawler_Notify.xml` — 대화형 + 이벤트 트리거
|
||
|
||
토스트를 띄우려면 **반드시 `<LogonType>InteractiveToken</LogonType>`** 이어야 한다.
|
||
|
||
```xml
|
||
<?xml version="1.0" encoding="UTF-8"?>
|
||
<Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
|
||
<RegistrationInfo>
|
||
<Author>DMF_Crawler</Author>
|
||
<Description>DMF_Crawler 알림 큐를 읽어 토스트를 표시한다 (대화형 세션 전용)</Description>
|
||
<URI>\DMF_Crawler\DMF_Crawler_Notify</URI>
|
||
</RegistrationInfo>
|
||
|
||
<Triggers>
|
||
<!-- (a) 사용자 로그온 30초 후: 로그온 전에 쌓인 알림 큐를 소화 -->
|
||
<LogonTrigger>
|
||
<Enabled>true</Enabled>
|
||
<Delay>PT30S</Delay>
|
||
<UserId>DESKTOP-XXXX\encep</UserId>
|
||
</LogonTrigger>
|
||
|
||
<!-- (b) DMFCrawler 소스가 Application 로그에 EventID 1001(실패)을 쓰면 즉시 -->
|
||
<EventTrigger>
|
||
<Enabled>true</Enabled>
|
||
<Subscription><QueryList><Query Id="0" Path="Application"><Select Path="Application">*[System[Provider[@Name='DMFCrawler'] and (EventID=1001)]]</Select></Query></QueryList></Subscription>
|
||
</EventTrigger>
|
||
|
||
<!-- (c) 작업 스케줄러가 DMF_Crawler_Daily 를 시작하지 못했을 때
|
||
101 JobStartFailed / 103 JobFailure(ActionStartFailed) /
|
||
111 JobTermination / 203 ActionLaunchFailure / 331 TimeoutWontWork -->
|
||
<EventTrigger>
|
||
<Enabled>true</Enabled>
|
||
<Subscription><QueryList><Query Id="0" Path="Microsoft-Windows-TaskScheduler/Operational"><Select Path="Microsoft-Windows-TaskScheduler/Operational">*[System[(EventID=101 or EventID=103 or EventID=111 or EventID=203 or EventID=331 or EventID=332)]] and *[EventData[Data[@Name='TaskName']='\DMF_Crawler\DMF_Crawler_Daily']]</Select></Query></QueryList></Subscription>
|
||
</EventTrigger>
|
||
</Triggers>
|
||
|
||
<Principals>
|
||
<Principal id="Author">
|
||
<UserId>DESKTOP-XXXX\encep</UserId>
|
||
<!-- 이 값이 InteractiveToken 이 아니면 토스트는 절대 안 뜬다 -->
|
||
<LogonType>InteractiveToken</LogonType>
|
||
<RunLevel>LeastPrivilege</RunLevel>
|
||
</Principal>
|
||
</Principals>
|
||
|
||
<Settings>
|
||
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
|
||
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
|
||
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
|
||
<StartWhenAvailable>true</StartWhenAvailable>
|
||
<AllowStartOnDemand>true</AllowStartOnDemand>
|
||
<Enabled>true</Enabled>
|
||
<Hidden>true</Hidden>
|
||
<RunOnlyIfIdle>false</RunOnlyIfIdle>
|
||
<UseUnifiedSchedulingEngine>true</UseUnifiedSchedulingEngine>
|
||
<ExecutionTimeLimit>PT10M</ExecutionTimeLimit>
|
||
<Priority>7</Priority>
|
||
</Settings>
|
||
|
||
<Actions Context="Author">
|
||
<Exec>
|
||
<Command>C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe</Command>
|
||
<Arguments>-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File "D:\workspace\DMF_Crawler\scripts\notify.ps1"</Arguments>
|
||
<WorkingDirectory>D:\workspace\DMF_Crawler</WorkingDirectory>
|
||
</Exec>
|
||
</Actions>
|
||
</Task>
|
||
```
|
||
|
||
임포트:
|
||
|
||
```cmd
|
||
schtasks /create /XML "D:\workspace\DMF_Crawler\ops\DMF_Crawler_Notify.xml" /TN "\DMF_Crawler\DMF_Crawler_Notify" /F
|
||
```
|
||
|
||
### 6.4 이벤트 트리거 XPath 작성법
|
||
|
||
`EventTrigger` 의 `Subscription` 은 **XPath 쿼리 문자열**이다. 공식: "The **Subscription** property gets or sets the XPath query string that identifies the event that fires the trigger." 또한 `ValueQueries` 는 "a collection of named XPath queries, with each query applied to the last matching event XML returned from the subscription query specified in the **Subscription** property" — 즉 이벤트에서 값을 뽑아 액션 인자로 넘길 수 있다(`$(EventValue)` 형태).
|
||
|
||
GUI 의 "On an event" 트리거는 EventID 만 지정할 수 있지만, **사용자 지정 이벤트 필터(custom event filter)** 로 전환하면 임의의 XPath 를 넣을 수 있다.
|
||
|
||
XPath 를 손으로 쓰지 말고 **이벤트 뷰어에서 만들어 복사**하는 것이 가장 안전하다:
|
||
1. 이벤트 뷰어 → 해당 로그 → **현재 로그 필터링** → **XML** 탭 → **쿼리를 수동으로 편집**
|
||
2. 생성된 `<QueryList>...</QueryList>` 복사
|
||
3. 작업 XML 에 넣을 때 `<`, `>`, `&` 를 `<`, `>`, `&` 로 이스케이프
|
||
|
||
---
|
||
|
||
## 7. 서비스화 옵션 비교 — 왜 채택하지 않는가
|
||
|
||
### 7.1 종합 비교표
|
||
|
||
| 항목 | **작업 스케줄러**(권고) | NSSM | WinSW | pywin32 (`win32serviceutil`) | .NET `BackgroundService` |
|
||
|------|----------------------|------|-------|------------------------------|--------------------------|
|
||
| 하루 1회 배치 적합성 | ✅ 최적 | ❌ 과잉 | ❌ 과잉 | ❌ 과잉 | ❌ 과잉 |
|
||
| 설치 방식 | `Register-ScheduledTask` / `schtasks /XML` | `nssm install <svc>` (GUI) 또는 `nssm install <svc> <app> [options]` | `winsw install myapp.xml` | `python svc.py install` | `sc.exe create` |
|
||
| 형상관리 | ✅ XML export/import | ⚠️ 레지스트리 값 | ✅ XML 파일 | ⚠️ 코드 | ⚠️ 코드 |
|
||
| 실패 시 자동 재시작 | ✅ `RestartOnFailure` (Interval/Count) | ✅ `AppExit` = Restart/Ignore/Exit, `AppRestartDelay`, `AppThrottle` | ✅ `<onfailure action="restart" delay="10 sec"/>` 다단계 | ⚠️ `sc failure` 로 별도 설정 | ⚠️ `sc.exe failure` |
|
||
| **정상 종료 + exit code≠0 감지** | ✅ 감지 후 재시도 | ✅ `AppExit` 로 처리 | ✅ `<onfailure>` | ❌ 서비스 프레임워크가 종료를 정상으로 봄 | ⚠️ `Environment.Exit(1)` 명시 필요 |
|
||
| 로그 로테이션 내장 | ❌ (직접 구현) | ✅ `AppRotateFiles` / `AppRotateOnline` / `AppRotateSeconds` / `AppRotateBytes` | ✅ `<log mode="roll">` | ❌ | ❌ |
|
||
| **Session 0 격리 회피** | ✅ 대화형 작업 분리 가능 | ❌ 불가 | ❌ 불가(`interactive` 플래그는 Vista 이후 무의미) | ❌ 불가 | ❌ 불가 |
|
||
| **토스트 알림** | ✅ (대화형 작업으로) | ❌ | ❌ | ❌ | ❌ |
|
||
| 유지보수 상태 | ✅ OS 내장 | ⚠️ 버전 확인 실패(§7.2 참조) | ✅ v2.12.0 Latest, v3.0.0-alpha.11 pre-release | ✅ 활발 | ✅ MS 공식 |
|
||
| 런타임 요구 | 없음 | 없음 | .NET Framework 4.6.1+ 또는 .NET 7 네이티브 | Python + pywin32 | .NET 8 SDK+ |
|
||
| 라이선스 | — | Public domain(관례) | MIT ("permissive") | PSF/BSD 계열 | MIT |
|
||
|
||
### 7.2 NSSM 상세
|
||
|
||
`nssm.cc/usage` 에서 확인한 설정 파라미터:
|
||
|
||
**설치/제거**
|
||
```cmd
|
||
nssm install <servicename> :: GUI 설치 마법사(탭 여러 개)
|
||
nssm install <servicename> <application> [options]
|
||
nssm remove <servicename> :: 확인 프롬프트 후 제거
|
||
```
|
||
|
||
**재시작 동작**
|
||
| 레지스트리 파라미터 | 의미 |
|
||
|-------------------|------|
|
||
| `AppExit` | `Restart`, `Ignore`, `Exit` 중 하나. 설정 안 하면 기본 restart |
|
||
| `AppRestartDelay` | 재시작 간 대기 밀리초. 대기 중 서비스는 "Paused" 로 표시됨 |
|
||
| `AppThrottle` | CPU 루프 방지. 앱이 임계값(기본 1500ms) 전에 종료하면 스로틀. **대기 시간을 2배씩 늘려 최대 256초까지** |
|
||
|
||
**로깅 및 회전**
|
||
| 파라미터 | 의미 |
|
||
|---------|------|
|
||
| `AppStdout` / `AppStderr` | 앱 출력을 지정 파일로 리다이렉트 |
|
||
| `AppRotateFiles` | 회전 활성화. 기존 파일은 **ISO8601 타임스탬프**로 이름 변경 |
|
||
| `AppRotateOnline` | 실행 중에도 크기 기준으로 회전 |
|
||
| `AppRotateSeconds` / `AppRotateBytes` | 회전 임계값 |
|
||
|
||
**종료 제어**
|
||
| 파라미터 | 의미 |
|
||
|---------|------|
|
||
| `AppStopMethodSkip` | 합산 값으로 종료 방법 비활성화: **1=skip Control-C, 2=skip WM_CLOSE, 4=skip WM_QUIT, 8=skip TerminateProcess** |
|
||
| `AppStopMethodConsole` / `AppStopMethodWindow` / `AppStopMethodThreads` | 각 방법의 타임아웃(기본 **1500ms**) |
|
||
|
||
**한계**: nssm.cc 문서에는 **Session 0 격리나 대화형 데스크톱 제약에 대한 언급이 전혀 없다**. 그리고 `nssm.cc/download` 는 조사 시점에 **timeout of 60000ms exceeded** 로 응답하지 않아 **최신 버전·릴리스 날짜를 확인하지 못했다** (⚠️ 미검증 — 부록 B-6). NSSM 이 수년간 릴리스가 없다는 것은 커뮤니티에서 널리 알려진 사실이나, 이 조사에서는 확인하지 못했다.
|
||
|
||
### 7.3 WinSW 상세 (서비스화가 필요해지면 이것을 써라)
|
||
|
||
**릴리스 현황**(GitHub Releases 페이지 실측):
|
||
|
||
| Tag | Date | Status |
|
||
|-----|------|--------|
|
||
| v3.0.0-alpha.11 | January 29 | Pre-release |
|
||
| **v2.12.0** | January 28 | **Latest** |
|
||
| v3.0.0-alpha.10 | August 9 | Pre-release |
|
||
| v3.0.0-alpha.9 | April 9 | Pre-release |
|
||
| v2.11.0 | March 17 | Standard |
|
||
| v3.0.0-alpha.8 | March 16 | Pre-release |
|
||
| v3.0.0-alpha.7 | December 23 | Pre-release |
|
||
| v3.0.0-alpha.6 | November 15 | Pre-release |
|
||
| v3.0.0-alpha.5 | October 22 | Pre-release |
|
||
| v2.10.3 | October 17 | Standard |
|
||
|
||
> ⚠️ 위 표의 연도가 GitHub UI 에서 생략되어 **연도 미확인**. "v2.12.0 이 Latest 안정판, 3.x 는 alpha" 라는 사실만 확정.
|
||
|
||
"WinSW 3.x is in active development on the default `v3` branch. GitHub Releases contain stable 2.x versions and 3.x pre-releases, while NuGet and Maven packages currently support 2.x."
|
||
|
||
**런타임 요구**: "WinSW 3 requires **.NET Framework 4.6.1 or later** or native executables based on **.NET 7**." — .NET Framework 4.6.1 은 "preinstalled since Windows 10, version 1511 and Windows Server 2016" 이므로 Windows 11 에서는 추가 설치 불필요.
|
||
|
||
**라이선스**: MIT ("permissive").
|
||
|
||
**설치/시작**
|
||
```cmd
|
||
winsw install myapp.xml [options]
|
||
winsw start myapp.xml
|
||
```
|
||
|
||
**공식 샘플 XML**
|
||
```xml
|
||
<service>
|
||
<id>jenkins</id>
|
||
<name>Jenkins</name>
|
||
<description>This service runs Jenkins continuous integration system.</description>
|
||
<env name="JENKINS_HOME" value="%BASE%"/>
|
||
<executable>java</executable>
|
||
<arguments>-Xrs -Xmx256m -jar "%BASE%\jenkins.war" --httpPort=8080</arguments>
|
||
<log mode="roll"></log>
|
||
</service>
|
||
```
|
||
|
||
**XML 요소 레퍼런스**
|
||
| 요소 | 설명(원문) |
|
||
|------|-----------|
|
||
| `executable` (필수) | 실행 파일. 절대 경로 또는 PATH 에서 검색 가능한 이름 |
|
||
| `arguments` | "The `<arguments>` element specifies the arguments to be passed to the executable." |
|
||
| `workingdirectory` | "Some services need to run with a working directory specified." |
|
||
| `env` | `<env name="HOME" value="c:\abc" />` |
|
||
| `log` | `<logpath>` 와 시작 모드: **append(기본), reset, ignore, roll** |
|
||
| `onfailure` | `action` = **restart / reboot / none**, 선택적 `delay` 속성. 여러 개를 순서대로 나열 가능 |
|
||
| `resetfailure` | "Controls the timing in which Windows SCM resets the failure count." 기본 **1 day** |
|
||
| `startmode` | Automatic 또는 Manual. 기본 **Automatic** |
|
||
| `delayedAutoStart` | Automatic + 지연 시작. "Will not take affect on old Windows versions older than Windows 7." |
|
||
| `stoptimeout` | 정상 종료 대기 시간. 기본 **15초** |
|
||
| `serviceaccount` | `<username>`, `<password>`, 선택 `<allowservicelogon>` |
|
||
| `interactive` | "If specified, the service will be allowed to interact with the desktop." ⚠️ **경고: Since Windows Vista/UAC, services cannot truly interact with the desktop.** |
|
||
|
||
**onfailure + 로그 롤링 완전 예제**
|
||
```xml
|
||
<service>
|
||
<id>myservice</id>
|
||
<executable>java</executable>
|
||
<arguments>-jar myapp.jar</arguments>
|
||
<workingdirectory>C:\app</workingdirectory>
|
||
<env name="JAVA_HOME" value="C:\Java"/>
|
||
<log mode="roll"/>
|
||
<onfailure action="restart" delay="10 sec"/>
|
||
<onfailure action="restart" delay="20 sec"/>
|
||
<onfailure action="reboot"/>
|
||
<resetfailure>1 hour</resetfailure>
|
||
<stoptimeout>10sec</stoptimeout>
|
||
<startmode>Automatic</startmode>
|
||
</service>
|
||
```
|
||
|
||
### 7.4 `sc.exe failure` — 서비스 복구 옵션 정확한 명령줄
|
||
|
||
공식 구문:
|
||
|
||
```
|
||
sc [<ServerName>] failure [<ServiceName>] [reset= <ErrorFreePeriod>] [reboot= <BroadcastMessage>]
|
||
[command= <CommandLine>] [actions= {"" | {[run/<MS>] | [restart/<MS>] | [reboot/<MS>]}[/...]]
|
||
```
|
||
|
||
| 파라미터 | 설명(원문) |
|
||
|---------|-----------|
|
||
| `<ServerName>` | "Specifies the name of the remote server on which the service is located. The name must use the Universal Naming Convention (UNC) format (for example, `\\myserver`). To run SC.exe locally, omit this parameter." |
|
||
| `<ServiceName>` | "Specifies the service name returned by the **getkeyname** operation." |
|
||
| `reset= <ErrorFreePeriod>` | "Specifies the length of the period (**in seconds**) with no failures after which the failure count should be reset to **0** (zero). Note that this parameter requires the **actions=** parameter." |
|
||
| `reboot= <BroadcastMessage>` | "Specifies the message to be broadcast when a service fails." |
|
||
| `command= <CommandLine>` | "Specifies the command-line command to be run when the specified service fails." |
|
||
| `actions=` | "Specifies one or more failure actions and their delay times (**in milliseconds**), separated by a forward slash (`/`). Valid actions are **run**, **restart**, and **reboot**. If more than one action is specified, each action must be separated by a forward slash. Use **actions= \"\"** to take no action a service fails. Note that this parameter requires the **reset=** parameter." |
|
||
|
||
**Remarks(원문, 중요)**:
|
||
- "Not all services allow changes to their failure options. Some run as part of a service set."
|
||
- "To run a batch file when a service fails, specify **command=**`Cmd.exe <Drive>:\<FileName>.bat`, where `<Drive>:\<FileName>.bat` is the fully qualified name of the batch file."
|
||
- "To run a VBS file when a service fails, specify **command=**`Cscript <Drive>:\<MyScript>.vbs`"
|
||
- "**You can specify up to three separate actions** with the **actions=** parameter, to be used the first, second, and third times that a service fails."
|
||
- "For each command-line option (parameter), **the equal sign is part of the option name**."
|
||
- "**A space is required between an option and its value** (for example, **actions= restart**). **If the space is omitted, the operation will fail.**"
|
||
|
||
**공식 예제(원문 그대로)**:
|
||
```cmd
|
||
sc failure msftpsvc reset= 30 actions= restart/5000
|
||
sc failure dfs reset= 60 command= c:\windows\services\restart_dfs.exe actions= run/5000
|
||
sc failure dfs reset= 60 actions= reboot/30000
|
||
sc failure dfs reset= 60 reboot= "The Distributed File System service has failed. Because of this, the computer will reboot in 30 seconds." actions= reboot/30000
|
||
sc failure myservice reset= 3600 reboot= "MyService crashed -- rebooting machine" command= "%windir%\MyServiceRecovery.exe" actions= restart/5000/run/10000/reboot/60000
|
||
```
|
||
|
||
.NET 공식 튜토리얼의 예시:
|
||
```cmd
|
||
sc.exe failure ".NET Joke Service" reset= 0 actions= restart/60000/restart/60000/run/1000
|
||
```
|
||
|
||
**DMF_Crawler 가 굳이 서비스가 된다면** 쓸 명령:
|
||
```cmd
|
||
sc.exe failure "DMFCrawler" reset= 86400 actions= restart/60000/restart/60000/restart/60000
|
||
sc.exe failureflag "DMFCrawler" 1
|
||
```
|
||
> ⚠️ `sc failureflag`(정상 종료(non-crash)에도 복구 동작을 적용하는 플래그)는 조사한 공식 `Sc failure` 문서에 **언급되어 있지 않다.** 실존하는 서브커맨드이지만 이 문서에서는 검증되지 않았다 — ⚠️ 미검증.
|
||
|
||
**`sc failure` 의 근본 한계**: 프로세스가 **비정상 종료(crash)** 할 때만 트리거된다. 크롤러가 "정상적으로 실행되어 exit code 1 로 종료" 하면 SCM 은 실패로 보지 않는다(그래서 .NET 예제가 `Environment.Exit(1)` 을 명시적으로 호출하며 주석에 "In order for the Windows Service Management system to leverage configured recovery options, we need to terminate the process with a non-zero exit code" 라고 적었다). **작업 스케줄러의 `RestartOnFailure` 는 이런 제약이 없다.**
|
||
|
||
### 7.5 pywin32 서비스 (참고)
|
||
|
||
`win32serviceutil.ServiceFramework` 요구사항:
|
||
- 필수 속성: `_svc_name_` (서비스 이름), `_svc_display_name_` (표시 이름)
|
||
- 선택 속성: `_svc_deps_` (의존 서비스), `_svc_description_`
|
||
- 필수 메서드: `SvcDoRun` ("starts the service and doesn't return until stopped"), `SvcStop` ("stops the service")
|
||
- 진입점: 인자가 없으면 `servicemanager.Initialize()` → `servicemanager.PrepareToHostSingle()` → `servicemanager.StartServiceCtrlDispatcher()`, 인자가 있으면 install/start/stop 처리
|
||
|
||
**채택하지 않는 이유**: 파이썬 인터프리터·pywin32 버전 결합, PyInstaller 로 단일 파일화하지 않으면 배포가 취약, 그리고 위 모든 Session 0 문제를 그대로 물려받는다.
|
||
|
||
### 7.6 .NET `BackgroundService` (참고)
|
||
|
||
Microsoft 공식 워커 서비스 튜토리얼의 핵심 코드 패턴:
|
||
|
||
```csharp
|
||
HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);
|
||
builder.Services.AddWindowsService(options =>
|
||
{
|
||
options.ServiceName = ".NET Joke Service";
|
||
});
|
||
```
|
||
|
||
로깅 설정(`appsettings.{Environment}.json`) — 이벤트 로그 심각도 기본은 `Warning`:
|
||
```json
|
||
{
|
||
"Logging": {
|
||
"LogLevel": { "Default": "Warning" },
|
||
"EventLog": {
|
||
"SourceName": "The Joke Service",
|
||
"LogName": "Application",
|
||
"LogLevel": {
|
||
"Microsoft": "Information",
|
||
"Microsoft.Hosting.Lifetime": "Information"
|
||
}
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
`ExecuteAsync` 의 예외 처리 패턴(주석 원문 포함):
|
||
```csharp
|
||
catch (Exception ex)
|
||
{
|
||
logger.LogError(ex, "{Message}", ex.Message);
|
||
// Terminates this process and returns an exit code to the operating system.
|
||
// This is required to avoid the 'BackgroundServiceExceptionBehavior', which
|
||
// performs one of two scenarios:
|
||
// 1. When set to "Ignore": will do nothing at all, errors cause zombie services.
|
||
// 2. When set to "StopHost": will cleanly stop the host, and log errors.
|
||
//
|
||
// In order for the Windows Service Management system to leverage configured
|
||
// recovery options, we need to terminate the process with a non-zero exit code.
|
||
Environment.Exit(1);
|
||
}
|
||
```
|
||
|
||
프로젝트 파일 예시:
|
||
```xml
|
||
<Project Sdk="Microsoft.NET.Sdk.Worker">
|
||
<PropertyGroup>
|
||
<TargetFramework>net10.0-windows</TargetFramework>
|
||
<Nullable>enable</Nullable>
|
||
<ImplicitUsings>true</ImplicitUsings>
|
||
<RootNamespace>App.WindowsService</RootNamespace>
|
||
</PropertyGroup>
|
||
<ItemGroup>
|
||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.10" />
|
||
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="9.0.10" />
|
||
</ItemGroup>
|
||
</Project>
|
||
```
|
||
|
||
**채택하지 않는 이유**: 크롤러가 Python 기반인데 .NET 런타임을 하나 더 끌어들이는 것은 무의미하다.
|
||
|
||
### 7.7 최종 권고 (근거 정리)
|
||
|
||
> **DMF_Crawler 는 서비스로 만들지 않는다. Windows 작업 스케줄러 작업 3개 + 파일 기반 heartbeat + 외부 dead-man switch 로 간다.**
|
||
|
||
근거 5가지:
|
||
1. **워크로드가 배치형이다.** 하루 1회, 수십 분. 상주가 필요 없다.
|
||
2. **Session 0 격리가 요구사항("죽으면 Windows 알림")과 정면충돌한다.** 서비스에서는 토스트를 못 띄운다(§8, §12).
|
||
3. **작업 스케줄러가 필요한 복원 기능을 이미 전부 내장한다.** `StartWhenAvailable`(놓친 작업), `RestartOnFailure`(재시도), `WakeToRun`(절전 해제), `BootTrigger`(부팅), 배터리 조건. 서비스로 이걸 재현하려면 NSSM/WinSW 설정 + `sc failure` + 별도 스케줄러가 필요하고, 그래도 `StartWhenAvailable` 에 해당하는 기능은 없다.
|
||
4. **exit code≠0 을 실패로 인식한다.** `sc failure` 는 크래시만 잡는다.
|
||
5. **형상관리와 재현성.** 작업 XML 3개를 Git 에 커밋하면 새 PC 에서 `schtasks /create /XML` 3번으로 복원된다.
|
||
|
||
**서비스로 전환해야 하는 시점**: 폴링 주기가 시간 단위 미만으로 내려가거나, 상시 웹 대시보드/큐 컨슈머가 필요해질 때. 그때는 **WinSW 2.12.0**(XML 형상관리 + `<onfailure>` 다단계 + `<log mode="roll">`)를 쓰고, **알림은 여전히 별도 대화형 작업으로 분리**하라.
|
||
|
||
---
|
||
|
||
## 8. Session 0 격리 — headless 브라우저와 토스트에 미치는 영향
|
||
|
||
### 8.1 무엇이 일어나는가 (공식 원문)
|
||
|
||
`Interactive Services` 문서:
|
||
|
||
> **"Services cannot directly interact with a user as of Windows Vista. Therefore, the techniques mentioned in the section titled Using an Interactive Service should not be used in new code."**
|
||
|
||
> "By default, services use a **noninteractive window station** and cannot interact with the user."
|
||
|
||
> "**All services run in Terminal Services session 0.** Therefore, if an interactive service displays a user interface, it is visible only to the user who connected to session 0. Because there is no way to guarantee that the interactive user is connected to session 0, do not configure a service to run as an interactive service under Terminal Services or on a system that supports fast user switching."
|
||
|
||
`NoInteractiveServices` 레지스트리 값:
|
||
|
||
> 레지스트리 키: **`HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Windows`**
|
||
> "The **NoInteractiveServices** value **defaults to 1**, which means that **no service is allowed to run interactively**, regardless of whether it has **SERVICE_INTERACTIVE_PROCESS**. When **NoInteractiveServices** is set to a 0, services with SERVICE_INTERACTIVE_PROCESS are allowed to run interactively."
|
||
> (Windows 7 / Server 2008 R2 / XP / Server 2003 에서는 기본값이 0 이었다.)
|
||
|
||
경고(원문):
|
||
> "Services running in an elevated security context, such as the LocalSystem account, **should not create a window on the interactive desktop** because any other application that is running on the interactive desktop can interact with this window. This exposes the service to any application that a logged-on user executes."
|
||
|
||
역사적 배경:
|
||
> "Until Windows Server 2003, services and the first logged on user used to run in the same session, Session 0 ... However, in Windows Vista, Windows Server 2008, and later versions of Windows, the operating system **isolates services in Session 0** and runs applications in other sessions, so services are protected from attacks that originate in application code."
|
||
|
||
그리고 **Session 0 데스크톱으로 전환할 방법조차 사라졌다**:
|
||
> "When you update to or install **Windows 10 Version 1803 or later** or Server 2019, the **Interactive Services Detection Service (UI0Detect) will no longer be present**, which means you can no longer switch desktop to Session 0."
|
||
|
||
### 8.2 서비스에서 사용자와 상호작용하는 유일한 합법적 방법 (공식)
|
||
|
||
> "You can use the following techniques to interact with the user from a service on all supported versions of Windows:
|
||
> - Display a dialog box in the user's session using the **WTSSendMessage** function.
|
||
> - Create a separate hidden GUI application and use the **CreateProcessAsUser** function to run the application within the context of the interactive user. Design the GUI application to communicate with the service through some method of **interprocess communication (IPC)**, for example, named pipes. ... Note that IPC can expose your service interfaces over the network unless you use an appropriate access control list (ACL)."
|
||
>
|
||
> "If this service runs on a multiuser system, add the application to the following key so that it is run in each session: **`HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\Run`**."
|
||
|
||
Windows Server 2003 / XP 한정(현재는 무의미):
|
||
> "Display a message box by calling the **MessageBox** function with **MB_SERVICE_NOTIFICATION**."
|
||
|
||
→ **우리 설계(§2.3)의 "알림 큐 파일 + 대화형 작업" 은 위 두 번째 기법의 파일 기반 IPC 구현이다.** 명명된 파이프 대신 JSON 파일을 쓰므로 네트워크 노출 위험도 없다.
|
||
|
||
### 8.3 Playwright / Chromium 에 미치는 영향
|
||
|
||
**보고된 실제 증상**(GitHub microsoft/playwright#20242):
|
||
> "the playwright browser runs planned and everything goes fine except **the window doesn't show up**. But I need to see the window."
|
||
|
||
사용자가 한 일: `.bat` 파일(`@python a.py` + `@pause`)을 NSSM 으로 서비스 등록 → 스크립트는 정상 실행되지만 브라우저 창이 보이지 않음. 직접 실행하면 창이 보인다.
|
||
|
||
원인: **Session 0 격리.** 서비스는 Session 0 의 비대화형 윈도우 스테이션에서 돌기 때문에 창이 있는 것 자체가 다른 세션에서 보이지 않는다. 이는 버그가 아니라 설계다.
|
||
|
||
관련 이슈: #20242, #12174 ("Headful chromium on Windows does not launch without '--single-process'"), #3191, #34306 ("Unable to launch chromium in headless mode in win11 with v1.49.1"), #34508 ("chromium-headless-shell"), playwright-python#2498.
|
||
|
||
**우리 프로젝트에 대한 실무 결론**:
|
||
- **headless 로 돌린다면 Session 0 자체는 문제가 아니다.** 헤드리스 Chromium 은 데스크톱이 필요 없다.
|
||
- 진짜 문제는 **브라우저 바이너리 경로**다. Playwright 기본 다운로드 위치는 Windows 에서 `%USERPROFILE%\AppData\Local\ms-playwright` 인데, 작업이 `SYSTEM` 이나 다른 계정으로 돌면 그 계정의 프로필을 보게 되어 브라우저를 못 찾는다.
|
||
|
||
**해결: `PLAYWRIGHT_BROWSERS_PATH` 를 고정 경로로 못박는다.**
|
||
|
||
```powershell
|
||
# 설치 시 (관리자 PowerShell) — 시스템 환경변수로 고정
|
||
[Environment]::SetEnvironmentVariable(
|
||
'PLAYWRIGHT_BROWSERS_PATH',
|
||
'D:\workspace\DMF_Crawler\.playwright-browsers',
|
||
'Machine')
|
||
|
||
# 현재 세션에도 반영
|
||
$env:PLAYWRIGHT_BROWSERS_PATH = 'D:\workspace\DMF_Crawler\.playwright-browsers'
|
||
|
||
# 브라우저 설치 (헤드리스만 쓰면 --only-shell 로 용량 절감)
|
||
python -m playwright install chromium
|
||
# 또는
|
||
python -m playwright install chromium --only-shell
|
||
```
|
||
|
||
공식 문서(playwright.dev/python/docs/browsers) 확인 사항:
|
||
- "On Windows systems, Playwright automatically stores browser binaries in `%USERPROFILE%\AppData\Local\ms-playwright` unless configured otherwise."
|
||
- 설치 시: `PLAYWRIGHT_BROWSERS_PATH=$HOME/pw-browsers python -m playwright install`
|
||
- 런타임 시: `PLAYWRIGHT_BROWSERS_PATH=$HOME/pw-browsers python playwright_script.py` — **설치와 실행 양쪽에서 같은 값을 써야 한다.**
|
||
- `playwright install chromium` : Chromium 만 설치
|
||
- `playwright install --with-deps chromium` : 브라우저 + 시스템 의존성
|
||
- `--only-shell` : "Playwright ships separate builds for headed and headless modes. To reduce download size when running tests only in headless mode, use the `--only-shell` flag to avoid downloading the full Chromium browser."
|
||
|
||
### 8.4 Session 0 회피 체크리스트
|
||
|
||
| 하고 싶은 것 | Session 0(서비스)에서 | 우리 방식 |
|
||
|-------------|---------------------|----------|
|
||
| headless Chromium 크롤링 | ✅ 가능 (경로만 고정하면) | 작업 스케줄러 배치 작업 |
|
||
| headed 브라우저 디버깅 | ❌ 불가 | 개발 시 수동 실행 |
|
||
| 토스트 알림 | ❌ 불가 | 대화형 작업(`DMF_Crawler_Notify`) |
|
||
| `MessageBox` 팝업 | ❌ 불가(Vista 이후) | 대화형 작업 |
|
||
| `msg.exe *` | △ 세션이 존재해야 함 | 폴백 2단계 |
|
||
| 웹훅(디스코드/슬랙) | ✅ 가능 | 폴백 3단계 — 항상 동작 |
|
||
|
||
---
|
||
|
||
## 9. 워치독 · 헬스체크 · dead-man switch
|
||
|
||
### 9.1 3중 감시 구조
|
||
|
||
```
|
||
[1] 로컬 heartbeat 파일 state\heartbeat.json ← 워치독 작업이 07:00 에 검사
|
||
[2] Windows 이벤트 로그 Application/DMFCrawler ← 이벤트 트리거가 즉시 반응
|
||
[3] 외부 dead-man switch healthchecks.io ← PC 가 꺼져 있어도 알려준다
|
||
```
|
||
|
||
**[3] 이 없으면 "PC 가 통째로 꺼져 있는 상황"을 절대 감지할 수 없다.** 로컬 워치독은 PC 가 켜져 있을 때만 돈다.
|
||
|
||
### 9.2 heartbeat 파일 스키마
|
||
|
||
`D:\workspace\DMF_Crawler\state\heartbeat.json`
|
||
|
||
```json
|
||
{
|
||
"run_id": "20260902-060003-a7f31c",
|
||
"task": "DMF_Crawler_Daily",
|
||
"started_at": "2026-09-02T06:00:03+09:00",
|
||
"finished_at": "2026-09-02T06:11:47+09:00",
|
||
"duration_sec": 704,
|
||
"status": "success",
|
||
"exit_code": 0,
|
||
"counts": { "new": 12, "changed": 3, "withdrawn": 1, "total_scanned": 4187 },
|
||
"report_path": "D:\\workspace\\DMF_Crawler\\reports\\DMF_2026-09-02.xlsx",
|
||
"log_path": "D:\\workspace\\DMF_Crawler\\logs\\run-20260902-060003-a7f31c.log",
|
||
"error": null,
|
||
"host": "DESKTOP-XXXX",
|
||
"schema_version": 1
|
||
}
|
||
```
|
||
|
||
실패 시:
|
||
```json
|
||
{
|
||
"run_id": "20260902-060003-a7f31c",
|
||
"status": "failed",
|
||
"exit_code": 3,
|
||
"error": "PlaywrightTimeoutError: Timeout 30000ms exceeded waiting for selector '#dmfList'",
|
||
"failed_stage": "crawl:list_page",
|
||
"screenshot_path": "D:\\workspace\\DMF_Crawler\\logs\\shots\\20260902-060003-a7f31c-fail.png"
|
||
}
|
||
```
|
||
|
||
### 9.3 이벤트 로그 ID 규약 (자체 정의)
|
||
|
||
| Event ID | EntryType | 의미 |
|
||
|----------|-----------|------|
|
||
| 1000 | Information | 실행 시작 (run_id 포함) |
|
||
| 1001 | **Error** | 실행 실패 — **`DMF_Crawler_Notify` 이벤트 트리거의 대상** |
|
||
| 1002 | Information | 실행 성공 (건수 포함) |
|
||
| 1003 | Warning | 부분 성공 (일부 페이지 실패했으나 리포트는 생성) |
|
||
| 1010 | **Error** | 워치독: heartbeat 미갱신 감지 |
|
||
| 1011 | Warning | 워치독: heartbeat 는 있으나 status=failed |
|
||
| 1012 | Information | 워치독: 정상 확인 |
|
||
| 1020 | Warning | 알림 전송 실패(토스트/웹훅 모두) |
|
||
|
||
### 9.4 스크립트 2 — `scripts\run-daily.ps1` (본체 래퍼, 완결)
|
||
|
||
크롤러 본체(Python)는 별도로 두고, 이 래퍼가 **실행 ID·로깅·heartbeat·이벤트 로그·healthchecks ping·실패 큐 작성**을 전담한다.
|
||
|
||
```powershell
|
||
<#
|
||
.SYNOPSIS
|
||
DMF_Crawler 일일 실행 래퍼. 작업 스케줄러 DMF_Crawler_Daily 가 호출한다.
|
||
.DESCRIPTION
|
||
- 실행 ID 생성, 로그 파일 분리, heartbeat 갱신
|
||
- Windows 이벤트 로그 기록 (Application / DMFCrawler)
|
||
- healthchecks.io dead-man switch ping (/start, 성공, /fail)
|
||
- 실패 시 알림 큐 파일 작성 + 웹훅 발사
|
||
- 오래된 로그 정리
|
||
.NOTES
|
||
반드시 PowerShell 5.1(powershell.exe)로 실행한다. Write-EventLog 가 PS7 에 없다.
|
||
#>
|
||
[CmdletBinding()]
|
||
param(
|
||
[string]$Root = 'D:\workspace\DMF_Crawler'
|
||
)
|
||
|
||
$ErrorActionPreference = 'Stop'
|
||
Set-StrictMode -Version Latest
|
||
|
||
# ---------------------------------------------------------------- 경로
|
||
$Scripts = Join-Path $Root 'scripts'
|
||
$Logs = Join-Path $Root 'logs'
|
||
$Shots = Join-Path $Logs 'shots'
|
||
$State = Join-Path $Root 'state'
|
||
$Reports = Join-Path $Root 'reports'
|
||
$QueueDir = Join-Path $State 'notify-queue'
|
||
$Heartbeat = Join-Path $State 'heartbeat.json'
|
||
$ConfigPs1 = Join-Path $Root 'config\ops.config.ps1'
|
||
|
||
foreach ($d in @($Logs, $Shots, $State, $Reports, $QueueDir)) {
|
||
if (-not (Test-Path $d)) { New-Item -ItemType Directory -Path $d -Force | Out-Null }
|
||
}
|
||
|
||
# ---------------------------------------------------------------- 설정 로드
|
||
# config\ops.config.ps1 예시:
|
||
# $HealthcheckUrl = 'https://hc-ping.com/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'
|
||
# $DiscordWebhook = 'https://discord.com/api/webhooks/.../...'
|
||
# $TelegramToken = '...'
|
||
# $TelegramChatId = '...'
|
||
# $OwnerName = '홍길동 (010-0000-0000)'
|
||
$HealthcheckUrl = $null
|
||
$DiscordWebhook = $null
|
||
$TelegramToken = $null
|
||
$TelegramChatId = $null
|
||
$OwnerName = '담당자 미설정'
|
||
if (Test-Path $ConfigPs1) { . $ConfigPs1 }
|
||
|
||
# ---------------------------------------------------------------- 실행 ID / 로그
|
||
$RunId = '{0}-{1}' -f (Get-Date -Format 'yyyyMMdd-HHmmss'),
|
||
([guid]::NewGuid().ToString('N').Substring(0, 6))
|
||
$LogFile = Join-Path $Logs ("run-$RunId.log")
|
||
$Started = Get-Date
|
||
|
||
function Write-Log {
|
||
param([string]$Level, [string]$Message)
|
||
$line = '{0} [{1}] {2}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss.fff'), $Level, $Message
|
||
Add-Content -Path $LogFile -Value $line -Encoding UTF8
|
||
Write-Host $line
|
||
}
|
||
|
||
function Write-DmfEvent {
|
||
param(
|
||
[int]$EventId,
|
||
[ValidateSet('Information','Warning','Error')][string]$EntryType,
|
||
[string]$Message
|
||
)
|
||
try {
|
||
# PowerShell 5.1 경로
|
||
Write-EventLog -LogName 'Application' -Source 'DMFCrawler' `
|
||
-EventId $EventId -EntryType $EntryType -Message $Message
|
||
} catch {
|
||
# PowerShell 7 폴백: Write-EventLog 가 없다.
|
||
try {
|
||
$type = [System.Diagnostics.EventLogEntryType]::$EntryType
|
||
[System.Diagnostics.EventLog]::WriteEntry('DMFCrawler', $Message, $type, $EventId)
|
||
} catch {
|
||
Write-Log 'WARN' "이벤트 로그 기록 실패: $($_.Exception.Message)"
|
||
}
|
||
}
|
||
}
|
||
|
||
function Invoke-Ping {
|
||
param([string]$Url, [string]$Body = $null)
|
||
if (-not $Url) { return }
|
||
try {
|
||
# TLS 1.2 강제 (구형 .NET 기본값 대응)
|
||
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||
if ($Body) {
|
||
Invoke-RestMethod -Uri $Url -Method Post -Body $Body -TimeoutSec 20 | Out-Null
|
||
} else {
|
||
Invoke-RestMethod -Uri $Url -TimeoutSec 20 | Out-Null
|
||
}
|
||
Write-Log 'INFO' "ping OK: $Url"
|
||
} catch {
|
||
Write-Log 'WARN' "ping 실패($Url): $($_.Exception.Message)"
|
||
}
|
||
}
|
||
|
||
function Save-Heartbeat {
|
||
param([hashtable]$Data)
|
||
$Data['host'] = $env:COMPUTERNAME
|
||
$Data['schema_version'] = 1
|
||
$json = $Data | ConvertTo-Json -Depth 6
|
||
# 원자적 쓰기: 임시 파일에 쓰고 교체
|
||
$tmp = "$Heartbeat.tmp"
|
||
Set-Content -Path $tmp -Value $json -Encoding UTF8
|
||
Move-Item -Path $tmp -Destination $Heartbeat -Force
|
||
}
|
||
|
||
function Add-NotifyQueue {
|
||
param([hashtable]$Payload)
|
||
$file = Join-Path $QueueDir ("notify-$RunId.json")
|
||
($Payload | ConvertTo-Json -Depth 6) | Set-Content -Path $file -Encoding UTF8
|
||
Write-Log 'INFO' "알림 큐 작성: $file"
|
||
}
|
||
|
||
function Send-Webhook {
|
||
param([string]$Title, [string]$Text)
|
||
# healthchecks 와 별개로, 사람에게 즉시 닿는 채널
|
||
if ($DiscordWebhook) {
|
||
try {
|
||
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||
$body = @{ content = "**$Title**`n$Text" } | ConvertTo-Json -Depth 3
|
||
Invoke-RestMethod -Uri $DiscordWebhook -Method Post `
|
||
-ContentType 'application/json; charset=utf-8' `
|
||
-Body ([Text.Encoding]::UTF8.GetBytes($body)) -TimeoutSec 20 | Out-Null
|
||
Write-Log 'INFO' 'Discord 웹훅 전송 완료'
|
||
} catch { Write-Log 'WARN' "Discord 웹훅 실패: $($_.Exception.Message)" }
|
||
}
|
||
if ($TelegramToken -and $TelegramChatId) {
|
||
try {
|
||
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||
$uri = "https://api.telegram.org/bot$TelegramToken/sendMessage"
|
||
Invoke-RestMethod -Uri $uri -Method Post -TimeoutSec 20 -Body @{
|
||
chat_id = $TelegramChatId
|
||
text = "$Title`n$Text"
|
||
} | Out-Null
|
||
Write-Log 'INFO' 'Telegram 전송 완료'
|
||
} catch { Write-Log 'WARN' "Telegram 실패: $($_.Exception.Message)" }
|
||
}
|
||
}
|
||
|
||
# ---------------------------------------------------------------- 시작
|
||
Write-Log 'INFO' "=== DMF_Crawler 실행 시작 run_id=$RunId host=$env:COMPUTERNAME user=$env:USERNAME ==="
|
||
Write-DmfEvent -EventId 1000 -EntryType Information -Message "DMF_Crawler 실행 시작`nrun_id=$RunId`nlog=$LogFile"
|
||
|
||
# healthchecks: /start (rid 로 start-success 를 짝지어 소요시간 계산)
|
||
$Rid = [guid]::NewGuid().ToString()
|
||
if ($HealthcheckUrl) { Invoke-Ping -Url ("{0}/start?rid={1}" -f $HealthcheckUrl, $Rid) }
|
||
|
||
Save-Heartbeat @{
|
||
run_id = $RunId
|
||
task = 'DMF_Crawler_Daily'
|
||
started_at = $Started.ToString('o')
|
||
finished_at = $null
|
||
status = 'running'
|
||
exit_code = $null
|
||
log_path = $LogFile
|
||
}
|
||
|
||
# ---------------------------------------------------------------- 본체 실행
|
||
$ExitCode = 0
|
||
$ErrorText = $null
|
||
$Counts = @{ new = 0; changed = 0; withdrawn = 0; total_scanned = 0 }
|
||
$ReportPath = $null
|
||
|
||
try {
|
||
# Playwright 브라우저 경로 고정 (§8.3)
|
||
$env:PLAYWRIGHT_BROWSERS_PATH = Join-Path $Root '.playwright-browsers'
|
||
$env:DMF_RUN_ID = $RunId
|
||
$env:DMF_LOG_FILE = $LogFile
|
||
$env:PYTHONIOENCODING = 'utf-8'
|
||
|
||
$py = Join-Path $Root '.venv\Scripts\python.exe'
|
||
if (-not (Test-Path $py)) { $py = 'python' }
|
||
|
||
Write-Log 'INFO' "python: $py"
|
||
Write-Log 'INFO' 'crawler 시작...'
|
||
|
||
# stdout/stderr 를 모두 로그로 캡처. 2>&1 로 병합.
|
||
& $py -m dmf_crawler.main --run-id $RunId --out $Reports 2>&1 |
|
||
ForEach-Object { Add-Content -Path $LogFile -Value $_ -Encoding UTF8 }
|
||
|
||
$ExitCode = $LASTEXITCODE
|
||
Write-Log 'INFO' "crawler 종료 exit_code=$ExitCode"
|
||
|
||
if ($ExitCode -ne 0) { throw "크롤러가 exit code $ExitCode 로 종료했습니다." }
|
||
|
||
# 크롤러가 남긴 결과 요약 읽기 (Python 쪽에서 써 준다)
|
||
$summaryFile = Join-Path $State ("summary-$RunId.json")
|
||
if (Test-Path $summaryFile) {
|
||
$s = Get-Content $summaryFile -Raw -Encoding UTF8 | ConvertFrom-Json
|
||
$Counts = @{
|
||
new = [int]$s.new
|
||
changed = [int]$s.changed
|
||
withdrawn = [int]$s.withdrawn
|
||
total_scanned = [int]$s.total_scanned
|
||
}
|
||
$ReportPath = [string]$s.report_path
|
||
}
|
||
}
|
||
catch {
|
||
$ExitCode = if ($ExitCode -ne 0) { $ExitCode } else { 1 }
|
||
$ErrorText = $_.Exception.Message
|
||
Write-Log 'ERROR' $ErrorText
|
||
Write-Log 'ERROR' ($_.ScriptStackTrace)
|
||
}
|
||
|
||
# ---------------------------------------------------------------- 마무리
|
||
$Finished = Get-Date
|
||
$Duration = [int]($Finished - $Started).TotalSeconds
|
||
|
||
if ($ExitCode -eq 0) {
|
||
Save-Heartbeat @{
|
||
run_id = $RunId
|
||
task = 'DMF_Crawler_Daily'
|
||
started_at = $Started.ToString('o')
|
||
finished_at = $Finished.ToString('o')
|
||
duration_sec= $Duration
|
||
status = 'success'
|
||
exit_code = 0
|
||
counts = $Counts
|
||
report_path = $ReportPath
|
||
log_path = $LogFile
|
||
error = $null
|
||
}
|
||
|
||
$msg = @"
|
||
DMF_Crawler 실행 성공
|
||
run_id=$RunId
|
||
소요=${Duration}초
|
||
신규=$($Counts.new) / 변경=$($Counts.changed) / 취하=$($Counts.withdrawn) / 스캔=$($Counts.total_scanned)
|
||
report=$ReportPath
|
||
"@
|
||
Write-Log 'INFO' '=== 성공 ==='
|
||
Write-DmfEvent -EventId 1002 -EntryType Information -Message $msg
|
||
|
||
if ($HealthcheckUrl) {
|
||
Invoke-Ping -Url ("{0}?rid={1}" -f $HealthcheckUrl, $Rid) -Body $msg
|
||
}
|
||
|
||
# 성공 시에도 신규/변경/취하가 있으면 알림 큐에 넣는다(정보성)
|
||
if (($Counts.new + $Counts.changed + $Counts.withdrawn) -gt 0) {
|
||
Add-NotifyQueue @{
|
||
level = 'info'
|
||
run_id = $RunId
|
||
title = "DMF 변동 $($Counts.new + $Counts.changed + $Counts.withdrawn)건"
|
||
body = "신규 $($Counts.new) · 변경 $($Counts.changed) · 취하 $($Counts.withdrawn)"
|
||
report = $ReportPath
|
||
log = $LogFile
|
||
created = $Finished.ToString('o')
|
||
}
|
||
}
|
||
}
|
||
else {
|
||
Save-Heartbeat @{
|
||
run_id = $RunId
|
||
task = 'DMF_Crawler_Daily'
|
||
started_at = $Started.ToString('o')
|
||
finished_at = $Finished.ToString('o')
|
||
duration_sec = $Duration
|
||
status = 'failed'
|
||
exit_code = $ExitCode
|
||
counts = $Counts
|
||
report_path = $null
|
||
log_path = $LogFile
|
||
error = $ErrorText
|
||
}
|
||
|
||
$msg = @"
|
||
DMF_Crawler 실행 실패
|
||
run_id = $RunId
|
||
exit = $ExitCode
|
||
error = $ErrorText
|
||
log = $LogFile
|
||
host = $env:COMPUTERNAME
|
||
"@
|
||
Write-Log 'ERROR' '=== 실패 ==='
|
||
# EventId 1001 은 DMF_Crawler_Notify 의 이벤트 트리거가 감시한다
|
||
Write-DmfEvent -EventId 1001 -EntryType Error -Message $msg
|
||
|
||
if ($HealthcheckUrl) {
|
||
Invoke-Ping -Url ("{0}/fail?rid={1}" -f $HealthcheckUrl, $Rid) -Body $msg
|
||
}
|
||
|
||
Add-NotifyQueue @{
|
||
level = 'error'
|
||
run_id = $RunId
|
||
title = 'DMF 크롤러 실패'
|
||
body = $ErrorText
|
||
log = $LogFile
|
||
owner = $OwnerName
|
||
created = $Finished.ToString('o')
|
||
retry = 'Start-ScheduledTask -TaskPath ''\DMF_Crawler\'' -TaskName ''DMF_Crawler_Daily'''
|
||
}
|
||
|
||
Send-Webhook -Title '🚨 DMF 크롤러 실패' -Text $msg
|
||
}
|
||
|
||
# ---------------------------------------------------------------- 로그 정리 (§17.2)
|
||
try {
|
||
Get-ChildItem -Path $Logs -Filter 'run-*.log' -File |
|
||
Where-Object { $_.LastWriteTime -lt (Get-Date).AddDays(-30) } |
|
||
Remove-Item -Force -ErrorAction SilentlyContinue
|
||
Get-ChildItem -Path $Shots -Filter '*.png' -File |
|
||
Where-Object { $_.LastWriteTime -lt (Get-Date).AddDays(-14) } |
|
||
Remove-Item -Force -ErrorAction SilentlyContinue
|
||
Get-ChildItem -Path $QueueDir -Filter 'notify-*.json' -File |
|
||
Where-Object { $_.LastWriteTime -lt (Get-Date).AddDays(-7) } |
|
||
Remove-Item -Force -ErrorAction SilentlyContinue
|
||
} catch { Write-Log 'WARN' "로그 정리 실패: $($_.Exception.Message)" }
|
||
|
||
Write-Log 'INFO' "=== 종료 exit_code=$ExitCode duration=${Duration}s ==="
|
||
exit $ExitCode
|
||
```
|
||
|
||
### 9.5 스크립트 3 — `scripts\watchdog.ps1` (완결)
|
||
|
||
```powershell
|
||
<#
|
||
.SYNOPSIS
|
||
DMF_Crawler_Daily 의 heartbeat 를 검증하고, 미갱신/실패 시 알림을 발생시킨다.
|
||
.DESCRIPTION
|
||
매일 07:00 (그리고 부팅 후 10분) 에 실행.
|
||
- heartbeat.json 의 finished_at 이 오늘(또는 지정 시간 창) 안인지 확인
|
||
- status 가 success 인지 확인
|
||
- 작업 스케줄러의 LastTaskResult 도 교차 확인
|
||
- 문제가 있으면 이벤트 로그 1010/1011 + 알림 큐 + 웹훅
|
||
- 선택: 자동 1회 재실행(-AutoRetry)
|
||
.NOTES
|
||
PowerShell 5.1 로 실행할 것.
|
||
#>
|
||
[CmdletBinding()]
|
||
param(
|
||
[string]$Root = 'D:\workspace\DMF_Crawler',
|
||
[int]$MaxAgeHours = 26, # 06:00 실행 + 여유. 26시간 지나면 이상.
|
||
[switch]$AutoRetry # 미갱신 감지 시 Daily 작업을 1회 강제 실행
|
||
)
|
||
|
||
$ErrorActionPreference = 'Stop'
|
||
Set-StrictMode -Version Latest
|
||
|
||
$State = Join-Path $Root 'state'
|
||
$Logs = Join-Path $Root 'logs'
|
||
$QueueDir = Join-Path $State 'notify-queue'
|
||
$Heartbeat = Join-Path $State 'heartbeat.json'
|
||
$ConfigPs1 = Join-Path $Root 'config\ops.config.ps1'
|
||
$WdLog = Join-Path $Logs ('watchdog-{0}.log' -f (Get-Date -Format 'yyyyMM'))
|
||
|
||
foreach ($d in @($State, $Logs, $QueueDir)) {
|
||
if (-not (Test-Path $d)) { New-Item -ItemType Directory -Path $d -Force | Out-Null }
|
||
}
|
||
|
||
$HealthcheckWatchdogUrl = $null
|
||
$DiscordWebhook = $null
|
||
$TelegramToken = $null
|
||
$TelegramChatId = $null
|
||
$OwnerName = '담당자 미설정'
|
||
if (Test-Path $ConfigPs1) { . $ConfigPs1 }
|
||
|
||
function Write-Log {
|
||
param([string]$Level, [string]$Message)
|
||
$line = '{0} [{1}] {2}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $Level, $Message
|
||
Add-Content -Path $WdLog -Value $line -Encoding UTF8
|
||
Write-Host $line
|
||
}
|
||
|
||
function Write-DmfEvent {
|
||
param([int]$EventId, [string]$EntryType, [string]$Message)
|
||
try {
|
||
Write-EventLog -LogName 'Application' -Source 'DMFCrawler' `
|
||
-EventId $EventId -EntryType $EntryType -Message $Message
|
||
} catch {
|
||
try {
|
||
$t = [System.Diagnostics.EventLogEntryType]::$EntryType
|
||
[System.Diagnostics.EventLog]::WriteEntry('DMFCrawler', $Message, $t, $EventId)
|
||
} catch { Write-Log 'WARN' "이벤트 로그 기록 실패: $($_.Exception.Message)" }
|
||
}
|
||
}
|
||
|
||
function Add-NotifyQueue {
|
||
param([hashtable]$Payload)
|
||
$id = (Get-Date -Format 'yyyyMMdd-HHmmss')
|
||
$file = Join-Path $QueueDir ("notify-watchdog-$id.json")
|
||
($Payload | ConvertTo-Json -Depth 6) | Set-Content -Path $file -Encoding UTF8
|
||
Write-Log 'INFO' "알림 큐 작성: $file"
|
||
}
|
||
|
||
function Send-Webhook {
|
||
param([string]$Title, [string]$Text)
|
||
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||
if ($DiscordWebhook) {
|
||
try {
|
||
$body = @{ content = "**$Title**`n$Text" } | ConvertTo-Json -Depth 3
|
||
Invoke-RestMethod -Uri $DiscordWebhook -Method Post `
|
||
-ContentType 'application/json; charset=utf-8' `
|
||
-Body ([Text.Encoding]::UTF8.GetBytes($body)) -TimeoutSec 20 | Out-Null
|
||
} catch { Write-Log 'WARN' "Discord 실패: $($_.Exception.Message)" }
|
||
}
|
||
if ($TelegramToken -and $TelegramChatId) {
|
||
try {
|
||
Invoke-RestMethod -Uri "https://api.telegram.org/bot$TelegramToken/sendMessage" `
|
||
-Method Post -TimeoutSec 20 -Body @{ chat_id = $TelegramChatId; text = "$Title`n$Text" } | Out-Null
|
||
} catch { Write-Log 'WARN' "Telegram 실패: $($_.Exception.Message)" }
|
||
}
|
||
}
|
||
|
||
Write-Log 'INFO' '=== 워치독 시작 ==='
|
||
|
||
# ---------------------------------------------------------------- 1. 작업 존재/상태 확인
|
||
$problems = New-Object System.Collections.Generic.List[string]
|
||
|
||
try {
|
||
$task = Get-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Daily' -ErrorAction Stop
|
||
$info = $task | Get-ScheduledTaskInfo
|
||
Write-Log 'INFO' ("작업 상태: State={0} LastRunTime={1} LastTaskResult=0x{2:X} NextRunTime={3}" -f `
|
||
$task.State, $info.LastRunTime, $info.LastTaskResult, $info.NextRunTime)
|
||
|
||
if ($task.State -eq 'Disabled') {
|
||
$problems.Add('작업 DMF_Crawler_Daily 가 [사용 안 함(Disabled)] 상태입니다.')
|
||
}
|
||
if ($info.LastTaskResult -ne 0 -and $info.LastTaskResult -ne 267009) {
|
||
# 267009 = 0x41301 SCHED_S_TASK_RUNNING
|
||
$problems.Add(("마지막 실행 결과 코드가 0x{0:X} 입니다 (0 이 아님)." -f $info.LastTaskResult))
|
||
}
|
||
}
|
||
catch {
|
||
$problems.Add("작업 DMF_Crawler_Daily 를 찾을 수 없습니다: $($_.Exception.Message)")
|
||
}
|
||
|
||
# ---------------------------------------------------------------- 2. heartbeat 검증
|
||
$hb = $null
|
||
if (-not (Test-Path $Heartbeat)) {
|
||
$problems.Add("heartbeat 파일이 없습니다: $Heartbeat")
|
||
}
|
||
else {
|
||
try {
|
||
$hb = Get-Content $Heartbeat -Raw -Encoding UTF8 | ConvertFrom-Json
|
||
} catch {
|
||
$problems.Add("heartbeat 파일 파싱 실패: $($_.Exception.Message)")
|
||
}
|
||
}
|
||
|
||
if ($hb) {
|
||
$finishedAt = $null
|
||
if ($hb.finished_at) { $finishedAt = [datetime]::Parse($hb.finished_at) }
|
||
|
||
if (-not $finishedAt) {
|
||
$problems.Add("heartbeat 에 finished_at 이 없습니다 (status=$($hb.status)). 실행 중 중단된 것으로 보입니다. run_id=$($hb.run_id)")
|
||
}
|
||
else {
|
||
$ageH = [math]::Round(((Get-Date) - $finishedAt).TotalHours, 1)
|
||
Write-Log 'INFO' "heartbeat: status=$($hb.status) finished_at=$finishedAt (${ageH}시간 전) run_id=$($hb.run_id)"
|
||
|
||
if ($ageH -gt $MaxAgeHours) {
|
||
$problems.Add("마지막 성공 실행이 ${ageH}시간 전입니다 (임계 ${MaxAgeHours}시간). run_id=$($hb.run_id)")
|
||
}
|
||
if ($hb.status -ne 'success') {
|
||
$problems.Add("마지막 실행 status=$($hb.status) exit_code=$($hb.exit_code) error=$($hb.error)")
|
||
}
|
||
}
|
||
}
|
||
|
||
# ---------------------------------------------------------------- 3. 판정 및 조치
|
||
if ($problems.Count -eq 0) {
|
||
Write-Log 'INFO' '정상. 이상 없음.'
|
||
Write-DmfEvent -EventId 1012 -EntryType Information `
|
||
-Message "워치독 정상 확인. 마지막 run_id=$($hb.run_id) status=$($hb.status)"
|
||
if ($HealthcheckWatchdogUrl) {
|
||
try {
|
||
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||
Invoke-RestMethod -Uri $HealthcheckWatchdogUrl -TimeoutSec 20 | Out-Null
|
||
} catch { Write-Log 'WARN' "워치독 ping 실패: $($_.Exception.Message)" }
|
||
}
|
||
Write-Log 'INFO' '=== 워치독 종료 (정상) ==='
|
||
exit 0
|
||
}
|
||
|
||
$detail = ($problems | ForEach-Object { " - $_" }) -join "`n"
|
||
$body = @"
|
||
DMF 크롤러 이상 감지 ($(Get-Date -Format 'yyyy-MM-dd HH:mm'))
|
||
호스트: $env:COMPUTERNAME
|
||
|
||
감지된 문제:
|
||
$detail
|
||
|
||
로그 폴더 : $Logs
|
||
heartbeat : $Heartbeat
|
||
담당자 : $OwnerName
|
||
|
||
재실행 명령:
|
||
Start-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Daily'
|
||
"@
|
||
|
||
Write-Log 'ERROR' $body
|
||
Write-DmfEvent -EventId 1010 -EntryType Error -Message $body
|
||
|
||
Add-NotifyQueue @{
|
||
level = 'error'
|
||
source = 'watchdog'
|
||
title = 'DMF 크롤러 이상 감지'
|
||
body = ($problems -join ' / ')
|
||
detail = $body
|
||
log = $Logs
|
||
owner = $OwnerName
|
||
created = (Get-Date).ToString('o')
|
||
retry = "Start-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Daily'"
|
||
}
|
||
|
||
Send-Webhook -Title '⚠️ DMF 크롤러 워치독 경보' -Text $body
|
||
|
||
# 로그온 세션이 있으면 즉시 알림 작업을 깨운다
|
||
try {
|
||
Start-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Notify' -ErrorAction Stop
|
||
Write-Log 'INFO' 'DMF_Crawler_Notify 작업을 트리거했습니다.'
|
||
} catch {
|
||
Write-Log 'WARN' "Notify 작업 트리거 실패(로그온 세션 없음 가능): $($_.Exception.Message)"
|
||
}
|
||
|
||
# ---------------------------------------------------------------- 4. 자동 재시도(옵션)
|
||
if ($AutoRetry) {
|
||
try {
|
||
Write-Log 'INFO' 'AutoRetry: DMF_Crawler_Daily 를 강제 실행합니다.'
|
||
Start-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Daily' -ErrorAction Stop
|
||
} catch {
|
||
Write-Log 'ERROR' "AutoRetry 실패: $($_.Exception.Message)"
|
||
}
|
||
}
|
||
|
||
Write-Log 'INFO' '=== 워치독 종료 (이상) ==='
|
||
exit 1
|
||
```
|
||
|
||
### 9.6 healthchecks.io — dead-man switch
|
||
|
||
**개념**: "Healthchecks.io works as a **dead man's switch** for processes that need to run continuously or on a regular, known schedule. This monitoring technique, sometimes called 'heartbeat monitoring', is a type of dead man's switch." / "Your cron job sends an HTTP request ('ping') to Healthchecks.io every time it completes. **When Healthchecks.io does not receive the HTTP request at the expected time, it notifies you.**"
|
||
|
||
**핑 API 엔드포인트 전체(공식)**:
|
||
|
||
| Action | URL |
|
||
|--------|-----|
|
||
| Success | `https://hc-ping.com/<uuid>` |
|
||
| Start | `https://hc-ping.com/<uuid>/start` |
|
||
| Failure | `https://hc-ping.com/<uuid>/fail` |
|
||
| Log | `https://hc-ping.com/<uuid>/log` |
|
||
| Exit Status | `https://hc-ping.com/<uuid>/<exit-status>` |
|
||
|
||
- **Slug 기반**: `<uuid>` 자리에 `<ping-key>/<slug>` 를 넣을 수 있다 → `https://hc-ping.com/<ping-key>/<slug>`
|
||
- **Run ID (`rid`)**: "Optional UUID parameter matching start and completion pings to calculate accurate job duration." 형식: `rid=123e4567-e89b-12d3-a456-426614174000`
|
||
- **Create Flag (slug 전용)**: `create=1` — slug 가 없으면 자동으로 체크를 생성
|
||
- **Exit Status**: 정수 0–255. "Healthchecks.io interprets **0 as a success** and **all other values as a failure**."
|
||
- **POST Body Limit**: 응답에 `Ping-Body-Limit: <n>` 헤더가 있으며 "typically 100 kB limit per ping"
|
||
- **Rate Limiting**: "Do not ping a check more frequently than is necessary. **If you ping a check more than 5 times per minute, some of the requests may get rate limited.**"
|
||
|
||
**Period 와 Grace Time (공식)**:
|
||
- **Period**: "the expected time between pings" — 일일 작업이면 **24시간**
|
||
- **Grace Time**: "the additional time to wait before sending an alert when a check is late" — 일일 작업이면 **1~2시간**이 일반적
|
||
- "if you use start signals to measure job duration, grace time also becomes **the maximum allowed time gap between 'start' and 'success' signals**." ⚠️ 즉 `/start` 를 쓰면 Grace Time 이 **작업 최대 허용 실행시간**이 되므로, `ExecutionTimeLimit PT2H` 보다 넉넉하게 잡아야 한다.
|
||
- **Cron mode / OnCalendar mode**: cron 표현식 또는 systemd timer 형식. 지정 항목은 ① 스케줄 표현식, ② **서버의 시간대**("the cron daemon typically uses the system's local time"), ③ 늦은 핑에 대한 grace time. 예: `0 6 * * *` + `Asia/Seoul`
|
||
|
||
**DMF_Crawler 권장 설정**:
|
||
|
||
| 항목 | 값 | 비고 |
|
||
|------|-----|------|
|
||
| Schedule mode | **Cron** | `0 6 * * *` |
|
||
| Time zone | **Asia/Seoul** | DST 없음 → 안전 |
|
||
| Grace Time | **3시간** | `/start` 를 쓰므로 ExecutionTimeLimit(2H)보다 크게 |
|
||
| 알림 채널 | 이메일 + 디스코드/텔레그램 | 이중화 |
|
||
|
||
**PowerShell 핑 예제(공식 문서 원문)**:
|
||
```powershell
|
||
Invoke-RestMethod https://hc-ping.com/your-uuid-here
|
||
Invoke-RestMethod -Uri https://hc-ping.com/your-uuid-here -Method Post -Body "temperature=-7"
|
||
```
|
||
|
||
작업 스케줄러에서 직접 실행하는 명령(공식):
|
||
```
|
||
powershell.exe -ExecutionPolicy bypass -File C:\Scripts\healthchecks.ps1
|
||
powershell.exe -Command "&{Invoke-RestMethod https://hc-ping.com/your-uuid-here}"
|
||
```
|
||
|
||
> ⚠️ 조사 시점의 `healthchecks.io/docs/powershell/` 페이지에는 `/start`, `/fail`, `/{exit-status}` 엔드포인트, 재시도/타임아웃 가이드, 무료 플랜 한도가 **포함되어 있지 않았다.** 이들은 `docs/http_api/` 와 `docs/configuring_checks/` 에서 확인했다. **무료 플랜의 체크 개수 한도는 이 조사에서 확인하지 못했다** — ⚠️ 미검증(부록 B-4).
|
||
|
||
### 9.7 Uptime Kuma — 자체 호스팅 대안
|
||
|
||
**개념**: "Push monitoring flips normal monitoring logic: instead of Uptime Kuma checking your service, your service sends a heartbeat to Uptime Kuma. If the heartbeat stops arriving within a defined timeout, the monitor is marked down and notifications fire." / "This is called a **dead man's switch**, by analogy with the train brake that engages automatically if the driver lets go: **the absence of signal is the alert signal.**"
|
||
|
||
**설정 절차**: "Click on Monitor Type and select **Push** from the dropdown. Set a heartbeat interval. If your service does not 'check in' within this time period of its last 'check in', then Uptime Kuma will assume the service is down and send you an alert."
|
||
- **최소 heartbeat interval: 20초**
|
||
- 재시도 횟수와 재시도 간격을 설정하면 "a 'grace period' for if your service missed a 'check in' or two" 를 얻는다.
|
||
|
||
**Push URL 형식**: `https://DOMAIN/api/push/CODE?status=up&msg=OK&ping=`
|
||
|
||
**PowerShell 핑**:
|
||
```powershell
|
||
Invoke-WebRequest -Uri "https://uptimekuma.example.com/api/push/XXXXXXXX?status=up&msg=OK&ping=704" -UseBasicParsing
|
||
```
|
||
Python 예제(문서 원문):
|
||
```python
|
||
import requests
|
||
r = requests.get("http://uptimekuma.mydomain.com/api/push/xyz")
|
||
```
|
||
|
||
**언제 Uptime Kuma 를 쓰나**: 외부 SaaS 에 데이터를 보내기 어려운 조직(제약사 내부망 등)이거나 이미 Uptime Kuma 를 운영 중일 때. 그 외에는 healthchecks.io 가 설정이 간단하다.
|
||
|
||
> ⚠️ Uptime Kuma 는 **자체 호스팅이므로 Uptime Kuma 서버 자체가 죽으면 감시가 사라진다.** 크롤러와 같은 PC 에 올리면 dead-man switch 로서 의미가 없다. 반드시 **다른 호스트**에 두어라.
|
||
|
||
---
|
||
|
||
## 10. 이벤트 로그 기록과 이벤트 트리거 작업
|
||
|
||
### 10.1 `New-EventLog` (PowerShell 5.1 전용)
|
||
|
||
```powershell
|
||
New-EventLog
|
||
[-LogName] <string>
|
||
[-Source] <string[]>
|
||
[[-ComputerName] <string[]>]
|
||
[-CategoryResourceFile <string>]
|
||
[-MessageResourceFile <string>]
|
||
[-ParameterResourceFile <string>]
|
||
[<CommonParameters>]
|
||
```
|
||
|
||
Description(원문): "This cmdlet creates a new **classic** event log on a local or remote computer. It can also register an event source that writes to the new log or to an existing log. The cmdlets that contain the `EventLog` noun (the Event log cmdlets) work **only on classic event logs**. To get events from logs that use the Windows Event Log technology in Windows Vista and later versions of Windows, use `Get-WinEvent`."
|
||
|
||
파라미터:
|
||
| 파라미터 | 별칭 | 설명 |
|
||
|---------|------|------|
|
||
| `-LogName` | LN | "Specifies the name of the event log. If the log does not exist, `New-EventLog` creates the log ... If the log exists, `New-EventLog` registers a new source for the event log." |
|
||
| `-Source` | SRC | "Specifies the names of the event log sources, such as application programs that write to the event log. **This parameter is required.**" |
|
||
| `-ComputerName` | CN | 기본은 로컬 컴퓨터. "This parameter does not rely on PowerShell remoting." |
|
||
| `-CategoryResourceFile` | CRF | 카테고리 문자열 파일 |
|
||
| `-MessageResourceFile` | MRF | 이벤트 메시지 파일 |
|
||
| `-ParameterResourceFile` | PRF | 파라미터 치환 문자열 파일 |
|
||
|
||
공식 예제:
|
||
```powershell
|
||
# Example 1 - create a new event log
|
||
New-EventLog -Source TestApp -LogName TestLog -MessageResourceFile C:\Test\TestApp.dll
|
||
|
||
# Example 2 - add a new event source to an existing log
|
||
$file = "C:\Program Files\TestApps\NewTestApp.dll"
|
||
New-EventLog -ComputerName Server01 -Source NewTestApp -LogName Application -MessageResourceFile $file -CategoryResourceFile $file
|
||
```
|
||
|
||
Notes(원문):
|
||
- "To use `New-EventLog` on Windows Vista and later versions of Windows, **open PowerShell with the Run as administrator option**."
|
||
- "To create an event source ... you must be a member of the Administrators group on the computer."
|
||
- "When you create a new event log and a new event source, the system registers the new source for the new log, but **the log is not created until the first entry is written to it**."
|
||
- "When you create a new event log, the associated file is stored in the `$Env:SystemRoot\System32\Config` directory ... The file name is the first eight characters of the **Log** property with an `.evt` file name extension."
|
||
|
||
### 10.2 `Write-EventLog` (PowerShell 5.1 전용)
|
||
|
||
```powershell
|
||
Write-EventLog
|
||
[-LogName] <String>
|
||
[-Source] <String>
|
||
[[-EntryType] <EventLogEntryType>]
|
||
[-Category <Int16>]
|
||
[-EventId] <Int32>
|
||
[-Message] <String>
|
||
[-RawData <Byte[]>]
|
||
[-ComputerName <String>]
|
||
[<CommonParameters>]
|
||
```
|
||
|
||
> "To write an event to an event log, **the event log must exist on the computer and the source must be registered for the event log.**"
|
||
|
||
파라미터:
|
||
| 파라미터 | 별칭 | 값/제약 |
|
||
|---------|------|--------|
|
||
| `-LogName` | LN | 필수. "The log name is the value of the **Log** property, not the **LogDisplayName**. Wildcard characters are not permitted." |
|
||
| `-Source` | SRC | 필수. "typically the name of the application that is writing the event to the log" |
|
||
| `-EventId` | ID, EID | 필수. "**The maximum value for the EventId parameter is 65535.**" |
|
||
| `-EntryType` | ET | 허용값: **Error, Information, FailureAudit, SuccessAudit, Warning**. 기본 **Information** |
|
||
| `-Message` | MSG | 필수 |
|
||
| `-Category` | — | Int16. "Enter an integer that is associated with the strings in the category message file" |
|
||
| `-RawData` | RD | Byte[] |
|
||
| `-ComputerName` | CN | 원격 컴퓨터 |
|
||
|
||
공식 예제:
|
||
```powershell
|
||
Write-EventLog -LogName "Application" -Source "MyApp" -EventID 3001 -EntryType Information -Message "MyApp added a user-requested feature to the display." -Category 1 -RawData 10,20
|
||
Write-EventLog -ComputerName "Server01" -LogName Application -Source "MyApp" -EventID 3001 -Message "MyApp added a user-requested feature to the display."
|
||
```
|
||
|
||
Notes: "For some Windows event logs, writing events requires administrator rights. You must start PowerShell using the **Run as Administrator** option."
|
||
|
||
### 10.3 PowerShell 7 문제와 해결
|
||
|
||
**문제**: "PowerShell 7 doesn't include `Write-EventLog`, and `New-EventLog` is not recognised as a name of a cmdlet in PowerShell 7." / "The `Write-EventLog` cmdlet is deprecated in PowerShell 7+ because it relies on unsupported APIs."
|
||
|
||
**`New-WinEvent` 는 대체재가 아니다**:
|
||
- "It creates **Event Tracing for Windows (ETW) events**, not traditional Windows Event Log entries"
|
||
- "It's designed for ETW channels, not the classic Application/System/Security logs"
|
||
- "**Not a true replacement** for `Write-EventLog`"
|
||
- "you need to register a separate event provider, which can be complicated"
|
||
- "You cannot freely set the log name and entry type or easily create custom sources like the classic cmdlets."
|
||
|
||
**해결 1 (권장) — .NET 클래스 직접 호출**:
|
||
```powershell
|
||
[System.Diagnostics.EventLog]::WriteEntry("MySource", "Message text", [System.Diagnostics.EventLogEntryType]::Information, 1000)
|
||
```
|
||
- "Works in both PowerShell 5 and 7"
|
||
- "Provides direct .NET access to event logging functionality"
|
||
- "**Requires the event source to be pre-registered on the system**"
|
||
|
||
소스 등록도 .NET 으로 가능(관리자 권한 필요):
|
||
```powershell
|
||
if (-not [System.Diagnostics.EventLog]::SourceExists('DMFCrawler')) {
|
||
[System.Diagnostics.EventLog]::CreateEventSource('DMFCrawler', 'Application')
|
||
}
|
||
```
|
||
|
||
**해결 2 — PowerShell 5.1 을 쓴다.** 우리 설계는 이쪽을 택했다: 작업 액션의 실행 파일을 `C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe` 로 고정. 이유: 클래식 cmdlet 이 그대로 동작하고, Windows 11 에 항상 존재하며, PowerShell 7 설치 여부에 의존하지 않는다.
|
||
|
||
**해결 3 — 호환 모드로 모듈 임포트**: "you can import the classic `Microsoft.PowerShell.Management` module in compatibility mode to use the legacy cmdlets." (`Import-Module Microsoft.PowerShell.Management -UseWindowsPowerShell`)
|
||
|
||
**해결 4 — `System.Diagnostics.TraceSource` + `EventLogTraceListener`**: app.config 기반. 복잡하지만 다중 리스너 시나리오에 유리.
|
||
|
||
우리 스크립트는 **해결 1 + 해결 2 를 모두 구현**(try/catch 폴백)했다(§9.4 `Write-DmfEvent`).
|
||
|
||
### 10.4 이벤트 트리거 작업 — 실패를 즉시 알림으로
|
||
|
||
`DMF_Crawler_Notify` 는 세 종류의 이벤트를 감시한다(§6.3 XML 참조):
|
||
|
||
| 소스 | EventID | 언제 |
|
||
|------|---------|------|
|
||
| `Application` / `DMFCrawler` | 1001 | 크롤러가 스스로 실패를 선언 |
|
||
| `Microsoft-Windows-TaskScheduler/Operational` | 101 | Task Scheduler 가 작업 시작에 실패 |
|
||
| 〃 | 103 | 작업 액션 실행 실패 |
|
||
| 〃 | 111 | 시간 초과로 작업 종료(`ExecutionTimeLimit`) |
|
||
| 〃 | 203 | 액션 실행 실패(Failed to launch action) |
|
||
| 〃 | 331 | 타임아웃 메커니즘 생성 실패 |
|
||
| 〃 | 332 | 사용자 미로그온으로 작업 미시작 |
|
||
|
||
PowerShell 로 이벤트 트리거를 만들려면 CIM 클래스를 직접 다뤄야 하므로, **XML import 방식(§6.3)이 실무상 유일하게 깔끔한 경로**다.
|
||
|
||
수동으로 이벤트 트리거를 테스트하려면:
|
||
```powershell
|
||
# 실패 이벤트를 강제로 발생시켜 Notify 가 뜨는지 확인
|
||
Write-EventLog -LogName Application -Source DMFCrawler -EventId 1001 -EntryType Error `
|
||
-Message "TEST: 이벤트 트리거 동작 확인 (무시해도 됩니다)"
|
||
```
|
||
|
||
---
|
||
|
||
## 11. Windows 알림 전 방식 비교
|
||
|
||
### 11.1 종합 비교표
|
||
|
||
| 라이브러리 | 언어 | 설치 | 버튼/액션 | 콜백 | AppId(AUMID) | 이미지/진행바 | 유지보수 | 이 프로젝트 적합성 |
|
||
|-----------|------|------|----------|------|--------------|--------------|---------|------------------|
|
||
| **BurntToast** | PowerShell | `Install-Module -Name BurntToast` 또는 `choco install burnttoast-psmodule` | ✅ `New-BTButton` (Protocol/Snooze/Dismiss, 색상) | △ 이벤트 핸들링은 **PowerShell 7.1+** 필요 | ⚠️ v1.0.0 에서 **AppId 커스터마이징 제거**. 대신 "Windows shortcut creation with proper AppUserModelID" 도입 | ✅ AppLogo, Header, Urgent | ✅ v1.1.0 | ✅ **1순위 채택** |
|
||
| **win11toast** | Python | `pip install win11toast` | ✅ `buttons=[{activationType, arguments, content}]` | ✅ `on_click=lambda args: ...` | ⚠️ 문서에 `app_id` 언급 없음 | ✅ image, progress, dialogue(TTS) | ✅ | ○ 파이썬에서 직접 띄울 때 |
|
||
| **windows-toasts** | Python | `python -m pip install windows-toasts` | ✅ `ToastButton` | ✅ `on_activated` | ✅ "Custom AUMIDs need registration via PowerShell or the registry" | ✅ | ✅ Python 3.9+ | ○ 가장 정석적 |
|
||
| **winotify** | Python | `pip install winotify` | ✅ 클릭 가능한 액션 | △ | △ | 아이콘/오디오 | △ | △ |
|
||
| **plyer** | Python | `pip install plyer` | ❌ | ❌ | ❌ | ❌ | ⚠️ "For Windows it uses **win10toast** (an old version of win11toast)" | ❌ 부적합 |
|
||
| **toasted** | Python | `pip install toasted` | ✅ | ✅ | ✅ | ✅ "supports all notification elements provided by Windows, such as inputs, selects, buttons, images, and different text styles" | △ | △ 기능은 가장 풍부 |
|
||
|
||
**결론**: **BurntToast** 를 1순위로 채택한다. 이유:
|
||
1. 알림 작업이 이미 PowerShell 로 돌고 있으므로 파이썬 런타임 의존이 없다.
|
||
2. 버튼·색상·Header·Urgent 를 모두 지원한다.
|
||
3. PowerShell Gallery 로 설치·업데이트가 간단하다.
|
||
|
||
파이썬 코드 안에서 직접 띄워야 할 상황이 생기면 **windows-toasts**(AUMID 등록 지원, Python 3.9+, Windows SDK 바인딩 사용 — "avoiding workarounds like Powershell hacks")를 쓴다.
|
||
|
||
### 11.2 BurntToast 상세
|
||
|
||
**설치**
|
||
```powershell
|
||
Install-Module -Name BurntToast -Scope CurrentUser -Force
|
||
# 또는
|
||
choco install burnttoast-psmodule
|
||
```
|
||
|
||
**지원 환경**: "Windows 10 and Windows Server 2019 and above." / "on all supported versions of PowerShell, including Windows PowerShell" — 단 "certain event handling requires **PowerShell 7.1+**".
|
||
|
||
**주요 cmdlet**: `New-BurntToastNotification`, `New-BTButton`, `New-BTAction`, `New-BTHeader`, `Submit-BTNotification`, `New-BTText`, `New-BTVisual`, `New-BTBinding`, `New-BTContent`.
|
||
|
||
**주요 파라미터**: `-Button`, `-Header`, `-AppLogo`. v1.1.0 에서 "support for Important Notifications using the **Urgent** switch" 및 버튼 색상 설정 추가.
|
||
|
||
**v1.0.0 breaking changes(중요)**: "Significant removals include **custom audio path support**, **AppId customization**, and **shoulder tap notifications**. The update introduced **Windows shortcut creation with proper AppUserModelID** to enable 'full toast branding when launching PowerShell.'"
|
||
|
||
**`New-BTButton` 완전 명세**(공식 Help 원문):
|
||
|
||
```powershell
|
||
New-BTButton [-Snooze] [-Dismiss] [-Content <String>] [-Arguments <String>]
|
||
[-ActivationType <Microsoft.Toolkit.Uwp.Notifications.ToastActivationType>]
|
||
[-ImageUri <String>] [-Id <String>] [-Color <String>]
|
||
```
|
||
|
||
| 파라미터 | 타입 | 설명(원문) |
|
||
|---------|------|-----------|
|
||
| `Snooze` | Switch | "Creates a system-handled snooze button" |
|
||
| `Dismiss` | Switch | "Creates a system-handled dismiss button" |
|
||
| `Content` | String | "The text to display on this button" |
|
||
| `Arguments` | String | "App-defined string to pass when the button is pressed" |
|
||
| `ActivationType` | ToastActivationType | "Defines the activation type that triggers when the button is pressed. **Defaults to Protocol**" |
|
||
| `ImageUri` | String | "Path or URI of an image icon to display next to the button label" |
|
||
| `Id` | String | "Specifies an ID associated with another toast control" |
|
||
| `Color` | String | 허용값: **`Green`** 또는 **`Red`** |
|
||
|
||
공식 예제(원문):
|
||
```powershell
|
||
New-BTButton -Dismiss
|
||
New-BTButton -Snooze
|
||
New-BTButton -Snooze -Content 'Sleep' -Id 'TimeSelection'
|
||
New-BTButton -Content 'Blog' -Arguments 'https://king.geek.nz'
|
||
$pic = 'C:\temp\example.png'; New-BTButton -Content 'View Picture' -Arguments $pic -ImageUri $pic
|
||
New-BTButton -Content 'Approve' -Arguments 'approve' -Color Green
|
||
New-BTButton -Content 'Delete' -Arguments 'delete' -Color Red
|
||
```
|
||
|
||
**`-ActivationType Protocol` 이 기본값**이라는 점이 핵심이다. `-Arguments` 에 URL·파일 경로·커스텀 프로토콜을 넣으면 Windows 셸이 그것을 연다. 즉 **"로그 열기" 버튼**은 `-Arguments 'D:\...\run-xxx.log'` 만으로 동작한다(연결 프로그램으로 열림). **"재실행" 버튼**은 실행 파일을 직접 호출할 수 없으므로 커스텀 프로토콜 핸들러를 등록해야 한다(§13.3).
|
||
|
||
### 11.3 win11toast 상세
|
||
|
||
**설치**: `pip install win11toast`
|
||
|
||
**최소 예제**:
|
||
```python
|
||
from win11toast import toast
|
||
toast('Hello Python🐍')
|
||
```
|
||
|
||
**제목 + 본문 + 클릭 시 URL**:
|
||
```python
|
||
toast('Hello Python', 'Click to open url', on_click='https://www.python.org')
|
||
```
|
||
|
||
**프로토콜 버튼**:
|
||
```python
|
||
buttons = [
|
||
{'activationType': 'protocol', 'arguments': 'https://google.com', 'content': 'Open Google'},
|
||
{'activationType': 'protocol', 'arguments': 'file:///C:/Windows/Media', 'content': 'Open Folder'}
|
||
]
|
||
toast('Title', 'Body', buttons=buttons)
|
||
```
|
||
|
||
**클릭 콜백**:
|
||
```python
|
||
toast('Hello', 'Message', on_click=lambda args: print('clicked!', args))
|
||
```
|
||
|
||
**이미지**:
|
||
```python
|
||
toast('Hello', 'Body', image='https://example.com/image.png')
|
||
toast('Hello', 'Body', image={'src': 'url', 'placement': 'hero'})
|
||
```
|
||
|
||
**진행 바**:
|
||
```python
|
||
from win11toast import notify, update_progress
|
||
notify(progress={'title': 'Title', 'status': 'Downloading...', 'value': '0'})
|
||
update_progress({'value': 0.5, 'valueStringOverride': '50%'})
|
||
```
|
||
|
||
**TTS**:
|
||
```python
|
||
toast('Hello Python🐍', dialogue='Hello world')
|
||
```
|
||
|
||
의존성: "WinRT libraries for Windows 10/11 integration".
|
||
> ⚠️ win11toast 문서에는 **서비스/작업 스케줄러 비대화형 실행 시의 제약이 언급되어 있지 않다.** 그래도 §12 의 제약은 그대로 적용된다(OS 레벨 제약이므로).
|
||
|
||
### 11.4 windows-toasts 상세
|
||
|
||
**요구사항**: "Python **3.9 or later**", "supports Windows 10 and 11". "A Python library that uses **Windows SDK bindings** to create and deliver notifications, avoiding workarounds like Powershell hacks."
|
||
|
||
**설치**:
|
||
```bash
|
||
python -m pip install windows-toasts
|
||
```
|
||
|
||
**최소 예제**:
|
||
```python
|
||
from windows_toasts import WindowsToaster, Toast
|
||
|
||
toaster = WindowsToaster('Python')
|
||
newToast = Toast()
|
||
newToast.text_fields = ['Hello, World!']
|
||
toaster.show_toast(newToast)
|
||
```
|
||
|
||
구성 요소:
|
||
- `WindowsToaster` : app identifier 로 초기화
|
||
- `Toast` : 알림 객체
|
||
- `text_fields` : 본문(리스트)
|
||
- `show_toast()` : 표시
|
||
|
||
**인터랙티브**: `InteractableWindowsToaster` 클래스 + `ToastButton` + `on_activated` 콜백.
|
||
**AUMID**: "Custom AUMIDs need registration via **PowerShell or the registry** for proper branding and notification delivery."
|
||
|
||
> ⚠️ 조사 시점에 readthedocs 랜딩/getting_started 페이지에서 "Custom AUMIDs", "Advanced usage", "Problem solving" 섹션의 **상세 내용을 확인하지 못했다.** Task Scheduler/서비스 관련 caveat 는 그 "Problem solving" 페이지에 있을 가능성이 높다 — ⚠️ 미검증(부록 B-5).
|
||
|
||
---
|
||
|
||
## 12. 토스트가 안 뜨는 조건과 폴백 설계
|
||
|
||
### 12.1 안 뜨는 조건 전체
|
||
|
||
| # | 조건 | 근거 | 대응 |
|
||
|---|------|------|------|
|
||
| 1 | **작업이 "사용자의 로그온 여부에 관계없이 실행"(S4U/Password)** | "Tasks that run when the user is not logged on do not have access to the user's interactive desktop" / "A task launching a GUI app was registered as 'run regardless.' The app was in fact running, but its window was nowhere to be seen because it runs in a **non-interactive session** — anything that requires an interactive display fundamentally cannot work under this configuration." / "This is not a bug — it's **by design**." | **알림 전용 작업을 `InteractiveToken` 으로 분리**(§2.3, §6.3) |
|
||
| 2 | **Windows 서비스(Session 0)** | "Services cannot directly interact with a user as of Windows Vista." / `NoInteractiveServices` 기본값 1 | 서비스화하지 않는다(§7.7) |
|
||
| 3 | **아무도 로그온하지 않은 상태** | 대화형 세션이 존재하지 않음 | 알림 큐 파일에 쌓아두고 **로그온 트리거**로 나중에 표시 + 웹훅 즉시 발사 |
|
||
| 4 | **집중 지원 / 방해 금지(Do not disturb)** | "Starting with Windows 11 build 22557, Microsoft reimagined focus assist experiences and it is now called **Do not disturb**." / "When Focus turns on Do Not Disturb, **only apps in the priority list can send notifications.** Suppressed notifications produce no sound, vibration, or visual pop-up when they arrive. The notification silently queues in the Action Center with its original timestamp preserved." | **우선 순위 알림 목록에 PowerShell 추가**(§12.2) + 웹훅 폴백 |
|
||
| 5 | **AppId(AUMID) 미등록** | BurntToast v1.0.0 이 AppId 커스터마이징을 제거하고 "Windows shortcut creation with proper AppUserModelID" 를 도입 | BurntToast 가 만드는 바로가기를 유지 / windows-toasts 는 AUMID 를 레지스트리에 등록 |
|
||
| 6 | 잠금 화면 상태 | 잠금 화면 알림 설정에 종속 | 웹훅 폴백 |
|
||
| 7 | 전체 화면(게임/프레젠테이션) | Windows 자동 억제 | Action Center 에 남음 + 웹훅 |
|
||
| 8 | Windows 알림 전체 OFF | 설정 → 시스템 → 알림 | 설치 시 점검 항목(§20) |
|
||
|
||
### 12.2 집중 지원 / 방해 금지 대응
|
||
|
||
**GUI 경로**: **설정 → 시스템 → 알림 → 우선 순위 알림 설정(Set priority notifications)** → **앱 추가(Add apps)** → PowerShell(또는 알림을 보내는 앱) 선택.
|
||
|
||
**레지스트리 경로(자동화용)**:
|
||
- 메인 설정: `HKCU:\Software\Microsoft\Windows\CurrentVersion\Notifications\Settings`
|
||
- 전역 토스트 활성화 키: `NOC_GLOBAL_SETTING_TOASTS_ENABLED`
|
||
- 앱별 설정: `HKCU:\Software\Microsoft\Windows\CurrentVersion\Notifications\Settings\<App ID>` — "each application will have its own unique AppID and settings in this location"
|
||
|
||
**PowerShell 명령**(출처 원문):
|
||
```powershell
|
||
# 방해 금지 비활성화 (= 토스트 허용)
|
||
New-ItemProperty -Path "HKCU:\Software\Microsoft\Windows\CurrentVersion\Notifications\Settings" `
|
||
-Name "NOC_GLOBAL_SETTING_TOASTS_ENABLED" -Value 0 -PropertyType DWord -Force
|
||
|
||
# 방해 금지 활성화
|
||
New-ItemProperty -Path "HKCU:\Software\Microsoft\Windows\CurrentVersion\Notifications\Settings" `
|
||
-Name "NOC_GLOBAL_SETTING_TOASTS_ENABLED" -Value 1 -PropertyType DWord -Force
|
||
```
|
||
|
||
주의사항(원문): "Settings apply **per-user** and don't require administrator rights unless restricted by enterprise policy" / "This still applies per-user, so you'll need to run any scripts that modify priority notification behavior **at logon**." / "Some applications use independent notification systems that won't be affected by Windows 11's built-in Do Not Disturb feature."
|
||
|
||
> ⚠️ 위 `NOC_GLOBAL_SETTING_TOASTS_ENABLED` 의 값 의미(0=허용/1=금지)는 출처 문서의 서술을 그대로 옮긴 것이며, 실제 동작 방향은 **실측 확인이 필요하다** — ⚠️ 미검증(부록 B-3). **우리 스크립트는 이 값을 건드리지 않는다.** 사용자 설정을 임의로 바꾸는 것은 위험하고, 우리에겐 웹훅 폴백이 있다.
|
||
|
||
### 12.3 폴백 3단계 설계
|
||
|
||
```
|
||
[1단계] BurntToast 토스트 (대화형 세션)
|
||
├─ 성공 → 끝
|
||
└─ 실패/세션 없음 ↓
|
||
[2단계] msg.exe * (활성 세션에 시스템 메시지 박스)
|
||
├─ 성공 → 끝
|
||
└─ 실패(Home 에디션 / 세션 없음 / 권한 없음) ↓
|
||
[3단계] 웹훅 (Discord / Telegram / Slack) + healthchecks.io
|
||
└─ PC 가 꺼져 있어도, 사용자가 자리에 없어도 도달
|
||
```
|
||
|
||
**추가 보조**: `[System.Windows.Forms.MessageBox]` 팝업 — 대화형 세션에서 토스트가 무시될 때 무조건 화면 중앙에 뜬다(단, 사용자가 닫아야 하므로 남용 금지).
|
||
|
||
### 12.4 `msg.exe` 상세 (공식)
|
||
|
||
```
|
||
msg {<username> | <sessionname> | <sessionID> | @<filename> | *}
|
||
[/server:<servername>] [/time:<seconds>] [/v] [/w] [<message>]
|
||
```
|
||
|
||
> Note: "**You must have Message special access permission to send a message.**"
|
||
|
||
| 파라미터 | 설명(원문) |
|
||
|---------|-----------|
|
||
| `<username>` | "Specifies the name of the user that you want to receive the message. If you don't specify a user or a session, this command displays an error message. **When specifying a session, it must be an active one.**" |
|
||
| `<sessionname>` | 세션 이름 |
|
||
| `<sessionID>` | 세션 숫자 ID |
|
||
| `@<filename>` | "Identifies a file containing a list of user names, session names, and session IDs" |
|
||
| `*` | "**Sends the message to all user names on the system.**" |
|
||
| `/server:<servername>` | "If unspecified, /server uses the server to which you are currently logged on." |
|
||
| `/time:<seconds>` | "Specifies the amount of time that the message you sent is displayed on the user's screen. ... **If no time limit is set, the message defaults to 60 seconds** and disappears." |
|
||
| `/v` | "Displays information about the actions being performed." |
|
||
| `/w` | "Waits for an acknowledgment from the user that the message has been received. Use this parameter with `/time:<seconds>` to avoid a possible long delay if the user does not immediately respond." |
|
||
| `<message>` | "If no message is specified, you will be prompted to enter a message. To send a message that is contained in a file, type the less than (`<`) symbol followed by the file name." |
|
||
|
||
공식 예제:
|
||
```cmd
|
||
msg User1 Let's meet at 1PM today
|
||
msg modem02 Let's meet at 1PM today
|
||
msg @userlist Let's meet at 1PM today
|
||
msg * Let's meet at 1PM today
|
||
msg * /time:10 Let's meet at 1PM today
|
||
```
|
||
|
||
작업 스케줄러 조합 예(출처 원문):
|
||
```cmd
|
||
schtasks /create /sc WEEKLY /tn "BackupReminder" /tr "msg.exe * 'Backup scheduled at 7:00PM. Be sure Passport drive and AC power are plugged in!'" /d THU /st 18:45
|
||
```
|
||
|
||
**제약 (중요)**:
|
||
- "The user must have **Message access permission** for the session to be able to send messages via msg command."
|
||
- 레지스트리 `AllowRemoteRPC` 값이 **1** 이어야 한다("if the value is not 1, it should be changed to 1"). 경로는 `HKLM\SYSTEM\CurrentControlSet\Control\Terminal Server` 의 `AllowRemoteRPC`.
|
||
- **"The MSG command is only available to versions of Windows that are NOT the 'Home' version, such as Pro/Enterprise."** → Windows 11 **Home** 에서는 `msg.exe` 가 없다. 이 프로젝트의 대상 PC 는 **Windows 11 Pro** 이므로 사용 가능하다.
|
||
- 활성 세션이 없으면 전송되지 않는다.
|
||
|
||
### 12.5 웹훅 폴백 — 유일하게 항상 닿는 채널
|
||
|
||
**Discord**
|
||
```powershell
|
||
$body = @{ content = "**🚨 DMF 크롤러 실패**`n로그: D:\workspace\DMF_Crawler\logs\run-xxx.log" } | ConvertTo-Json -Depth 3
|
||
Invoke-RestMethod -Uri $DiscordWebhook -Method Post `
|
||
-ContentType 'application/json; charset=utf-8' `
|
||
-Body ([Text.Encoding]::UTF8.GetBytes($body)) -TimeoutSec 20
|
||
```
|
||
> 한글이 깨지지 않도록 반드시 `[Text.Encoding]::UTF8.GetBytes()` 로 바이트 배열을 넘긴다.
|
||
|
||
**Telegram**
|
||
```powershell
|
||
Invoke-RestMethod -Uri "https://api.telegram.org/bot$TelegramToken/sendMessage" -Method Post -Body @{
|
||
chat_id = $TelegramChatId
|
||
text = "🚨 DMF 크롤러 실패`nrun_id=$RunId`n$ErrorText"
|
||
} -TimeoutSec 20
|
||
```
|
||
|
||
**Slack (Incoming Webhook)**
|
||
```powershell
|
||
$payload = @{ text = "🚨 DMF 크롤러 실패`nrun_id=$RunId" } | ConvertTo-Json -Depth 3
|
||
Invoke-RestMethod -Uri $SlackWebhook -Method Post `
|
||
-ContentType 'application/json; charset=utf-8' `
|
||
-Body ([Text.Encoding]::UTF8.GetBytes($payload)) -TimeoutSec 20
|
||
```
|
||
|
||
**이메일 (SMTP)**
|
||
```powershell
|
||
Send-MailMessage -SmtpServer 'smtp.gmail.com' -Port 587 -UseSsl `
|
||
-Credential $cred -From 'bot@example.com' -To 'yunchanpaca@gmail.com' `
|
||
-Subject '[DMF] 크롤러 실패 알림' -Body $msg -Encoding UTF8
|
||
```
|
||
> ⚠️ `Send-MailMessage` 는 PowerShell 팀이 공식적으로 obsolete 로 표시한 cmdlet이다(대안: MailKit). 새로 짤 때는 웹훅을 우선하라.
|
||
|
||
---
|
||
|
||
## 13. 복구 안내 메시지 설계
|
||
|
||
### 13.1 메시지에 반드시 담을 4가지
|
||
|
||
| 요소 | 왜 필요한가 | 예시 |
|
||
|------|-----------|------|
|
||
| **무엇이 실패했는지** | 사람이 판단할 최소 정보 | "06:00 DMF 크롤링이 실패했습니다 (목록 페이지 타임아웃)" |
|
||
| **로그 경로** | 즉시 원인 확인 | `D:\workspace\DMF_Crawler\logs\run-20260902-060003-a7f31c.log` |
|
||
| **재실행 명령** | 복붙으로 즉시 복구 | `Start-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Daily'` |
|
||
| **담당자** | 에스컬레이션 | "담당: 홍길동 (010-0000-0000)" |
|
||
|
||
추가 권장: **run_id**(로그·리포트·이벤트 로그를 관통하는 상관 키), **호스트명**, **실패 단계(failed_stage)**, **다음 자동 재시도 시각**.
|
||
|
||
### 13.2 실제 문구 예시
|
||
|
||
**토스트(짧게 — 토스트는 2~3줄이 한계)**
|
||
```
|
||
제목 : 🚨 DMF 크롤러 실패 (06:00)
|
||
본문 : 목록 페이지 타임아웃 · run 20260902-060003
|
||
로그를 열어 원인을 확인하거나 지금 재실행하세요.
|
||
버튼 : [로그 열기] [지금 재실행] [닫기]
|
||
```
|
||
|
||
**웹훅(길게 — 전체 맥락)**
|
||
```
|
||
🚨 DMF 크롤러 실패
|
||
|
||
실행 ID : 20260902-060003-a7f31c
|
||
호스트 : DESKTOP-XXXX
|
||
시작 : 2026-09-02 06:00:03
|
||
실패 : 2026-09-02 06:00:37 (34초)
|
||
단계 : crawl:list_page
|
||
오류 : PlaywrightTimeoutError: Timeout 30000ms exceeded waiting for selector '#dmfList'
|
||
exit : 3
|
||
|
||
로그 : D:\workspace\DMF_Crawler\logs\run-20260902-060003-a7f31c.log
|
||
스크린샷 : D:\workspace\DMF_Crawler\logs\shots\20260902-060003-a7f31c-fail.png
|
||
|
||
▶ 재실행
|
||
Start-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Daily'
|
||
|
||
▶ 상태 확인
|
||
Get-ScheduledTask -TaskPath '\DMF_Crawler\' | Get-ScheduledTaskInfo
|
||
|
||
▶ 자동 재시도
|
||
작업 스케줄러가 10분 간격으로 최대 3회 재시도합니다 (다음 06:10 예정).
|
||
|
||
담당: 홍길동 (010-0000-0000) / yunchanpaca@gmail.com
|
||
```
|
||
|
||
**워치독 경보(PC 는 켜져 있는데 06:00 실행 흔적이 없음)**
|
||
```
|
||
⚠️ DMF 크롤러 워치독 경보 (07:00)
|
||
|
||
07:00 시점에 오늘자 실행 기록을 찾지 못했습니다.
|
||
|
||
감지된 문제:
|
||
- 마지막 성공 실행이 27.4시간 전입니다 (임계 26시간). run_id=20260901-060002-3b91de
|
||
- 마지막 실행 결과 코드가 0x41306 (SCHED_S_TASK_TERMINATED) 입니다.
|
||
|
||
가능한 원인:
|
||
1) 06:00 에 PC 가 꺼져 있었고 StartWhenAvailable 도 동작하지 않았다
|
||
2) 작업이 ExecutionTimeLimit(2시간)을 넘겨 강제 종료되었다
|
||
3) 작업이 [사용 안 함] 상태로 바뀌었다
|
||
4) 계정 암호가 변경되어 Password 로그온이 실패했다
|
||
|
||
▶ 즉시 조치
|
||
Start-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Daily'
|
||
|
||
▶ 진단
|
||
Get-ScheduledTask -TaskPath '\DMF_Crawler\' | Get-ScheduledTaskInfo
|
||
Get-WinEvent -LogName 'Microsoft-Windows-TaskScheduler/Operational' -MaxEvents 50 |
|
||
Where-Object { $_.Message -match 'DMF_Crawler' } | Format-List TimeCreated, Id, Message
|
||
|
||
로그 폴더: D:\workspace\DMF_Crawler\logs
|
||
담당: 홍길동 (010-0000-0000)
|
||
```
|
||
|
||
### 13.3 재실행 버튼을 위한 커스텀 프로토콜 핸들러
|
||
|
||
BurntToast 버튼의 `-ActivationType` 기본값이 `Protocol` 이므로, **`-Arguments` 에 넣은 문자열은 셸이 URL 로 해석해 연다.** 실행 파일을 바로 못 부르므로, `dmfcrawler:` 프로토콜을 레지스트리에 등록해 우회한다.
|
||
|
||
**설치 스크립트 `scripts\register-protocol.ps1`** (관리자 권한 1회 실행)
|
||
|
||
```powershell
|
||
#Requires -RunAsAdministrator
|
||
<#
|
||
.SYNOPSIS
|
||
dmfcrawler: 커스텀 URL 프로토콜을 등록한다.
|
||
토스트 버튼에서 dmfcrawler://retry 또는 dmfcrawler://openlog?path=... 를 호출할 수 있게 된다.
|
||
#>
|
||
param([string]$Root = 'D:\workspace\DMF_Crawler')
|
||
|
||
$handler = Join-Path $Root 'scripts\protocol-handler.ps1'
|
||
$pwsh = "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe"
|
||
|
||
$key = 'HKCU:\Software\Classes\dmfcrawler'
|
||
New-Item -Path $key -Force | Out-Null
|
||
Set-ItemProperty -Path $key -Name '(Default)' -Value 'URL:DMF Crawler Protocol'
|
||
Set-ItemProperty -Path $key -Name 'URL Protocol' -Value ''
|
||
|
||
New-Item -Path "$key\shell\open\command" -Force | Out-Null
|
||
Set-ItemProperty -Path "$key\shell\open\command" -Name '(Default)' `
|
||
-Value "`"$pwsh`" -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File `"$handler`" `"%1`""
|
||
|
||
Write-Host "[=] dmfcrawler: 프로토콜 등록 완료"
|
||
Write-Host " 테스트: Start-Process 'dmfcrawler://retry'"
|
||
```
|
||
|
||
**핸들러 `scripts\protocol-handler.ps1`**
|
||
|
||
```powershell
|
||
<#
|
||
.SYNOPSIS
|
||
dmfcrawler:// URL 을 처리한다.
|
||
.DESCRIPTION
|
||
dmfcrawler://retry → DMF_Crawler_Daily 작업을 즉시 실행
|
||
dmfcrawler://openlog → 최신 로그 파일을 메모장으로 열기
|
||
dmfcrawler://openlogdir → 로그 폴더를 탐색기로 열기
|
||
dmfcrawler://openreport → 최신 xlsx 리포트 열기
|
||
#>
|
||
param([string]$Url)
|
||
|
||
$Root = 'D:\workspace\DMF_Crawler'
|
||
$Logs = Join-Path $Root 'logs'
|
||
$Reports = Join-Path $Root 'reports'
|
||
$State = Join-Path $Root 'state'
|
||
|
||
# dmfcrawler://retry/ 형태에서 명령 추출
|
||
$cmd = ($Url -replace '^dmfcrawler:/*', '') -replace '/.*$', ''
|
||
$cmd = $cmd.ToLowerInvariant().Trim()
|
||
|
||
switch ($cmd) {
|
||
'retry' {
|
||
try {
|
||
Start-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Daily' -ErrorAction Stop
|
||
[System.Reflection.Assembly]::LoadWithPartialName('System.Windows.Forms') | Out-Null
|
||
[System.Windows.Forms.MessageBox]::Show(
|
||
'DMF 크롤러를 재실행했습니다.' + "`n" +
|
||
'진행 상황은 로그 폴더에서 확인하세요:' + "`n" + $Logs,
|
||
'DMF Crawler', 'OK', 'Information') | Out-Null
|
||
} catch {
|
||
[System.Reflection.Assembly]::LoadWithPartialName('System.Windows.Forms') | Out-Null
|
||
[System.Windows.Forms.MessageBox]::Show(
|
||
"재실행 실패:`n$($_.Exception.Message)", 'DMF Crawler', 'OK', 'Error') | Out-Null
|
||
}
|
||
}
|
||
'openlog' {
|
||
$latest = Get-ChildItem -Path $Logs -Filter 'run-*.log' -File -ErrorAction SilentlyContinue |
|
||
Sort-Object LastWriteTime -Descending | Select-Object -First 1
|
||
if ($latest) { Start-Process notepad.exe $latest.FullName }
|
||
else { Start-Process explorer.exe $Logs }
|
||
}
|
||
'openlogdir' { Start-Process explorer.exe $Logs }
|
||
'openreport' {
|
||
$latest = Get-ChildItem -Path $Reports -Filter '*.xlsx' -File -ErrorAction SilentlyContinue |
|
||
Sort-Object LastWriteTime -Descending | Select-Object -First 1
|
||
if ($latest) { Start-Process $latest.FullName }
|
||
else { Start-Process explorer.exe $Reports }
|
||
}
|
||
default { Start-Process explorer.exe $Root }
|
||
}
|
||
```
|
||
|
||
**프로토콜 없이 가는 간단한 대안**: 버튼 `-Arguments` 에 로그 **파일 경로**(→ 연결 프로그램으로 열림)나 **폴더 경로**(→ 탐색기로 열림)를 그대로 넣으면 프로토콜 등록 없이도 "로그 열기"는 동작한다. **"재실행"만 프로토콜이 필요하다.**
|
||
|
||
### 13.4 스크립트 4 — `scripts\notify.ps1` (완결)
|
||
|
||
```powershell
|
||
<#
|
||
.SYNOPSIS
|
||
알림 큐(state\notify-queue\*.json)를 읽어 Windows 토스트로 표시한다.
|
||
.DESCRIPTION
|
||
DMF_Crawler_Notify 작업(대화형, LogonType=InteractiveToken)이 호출한다.
|
||
- 큐 파일을 최신순으로 읽어 표시하고, 표시한 파일은 archive 로 이동
|
||
- BurntToast 실패 시 msg.exe → MessageBox 순으로 폴백
|
||
- 큐가 비어 있으면 아무것도 하지 않고 조용히 종료
|
||
.NOTES
|
||
이 스크립트는 반드시 대화형 세션에서 실행되어야 한다.
|
||
S4U/Password 컨텍스트에서 실행하면 토스트가 표시되지 않는다.
|
||
#>
|
||
[CmdletBinding()]
|
||
param(
|
||
[string]$Root = 'D:\workspace\DMF_Crawler',
|
||
[int]$MaxToasts = 3, # 한 번에 표시할 최대 토스트 수(폭주 방지)
|
||
[switch]$TestMode # 큐와 무관하게 샘플 토스트 1개 표시
|
||
)
|
||
|
||
$ErrorActionPreference = 'Continue'
|
||
Set-StrictMode -Version Latest
|
||
|
||
$State = Join-Path $Root 'state'
|
||
$Logs = Join-Path $Root 'logs'
|
||
$Reports = Join-Path $Root 'reports'
|
||
$QueueDir = Join-Path $State 'notify-queue'
|
||
$Archive = Join-Path $QueueDir 'archive'
|
||
$NfLog = Join-Path $Logs ('notify-{0}.log' -f (Get-Date -Format 'yyyyMM'))
|
||
|
||
foreach ($d in @($QueueDir, $Archive, $Logs)) {
|
||
if (-not (Test-Path $d)) { New-Item -ItemType Directory -Path $d -Force | Out-Null }
|
||
}
|
||
|
||
function Write-Log {
|
||
param([string]$Level, [string]$Message)
|
||
$line = '{0} [{1}] {2}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $Level, $Message
|
||
Add-Content -Path $NfLog -Value $line -Encoding UTF8
|
||
}
|
||
|
||
# ---------------------------------------------------------------- BurntToast 준비
|
||
$HasBurntToast = $false
|
||
try {
|
||
if (-not (Get-Module -ListAvailable -Name BurntToast)) {
|
||
Write-Log 'INFO' 'BurntToast 미설치 → 설치 시도'
|
||
Install-Module -Name BurntToast -Scope CurrentUser -Force -AllowClobber -ErrorAction Stop
|
||
}
|
||
Import-Module BurntToast -ErrorAction Stop
|
||
$HasBurntToast = $true
|
||
} catch {
|
||
Write-Log 'WARN' "BurntToast 사용 불가: $($_.Exception.Message)"
|
||
}
|
||
|
||
# ---------------------------------------------------------------- 폴백들
|
||
function Show-MsgExe {
|
||
param([string]$Text)
|
||
try {
|
||
# /time:0 은 사용자가 닫을 때까지 표시. 여기서는 120초.
|
||
& "$env:SystemRoot\System32\msg.exe" '*' '/time:120' $Text 2>$null
|
||
if ($LASTEXITCODE -eq 0) { Write-Log 'INFO' 'msg.exe 전송 성공'; return $true }
|
||
} catch { }
|
||
Write-Log 'WARN' 'msg.exe 실패 (Home 에디션 / 활성 세션 없음 / 권한 없음)'
|
||
return $false
|
||
}
|
||
|
||
function Show-MessageBox {
|
||
param([string]$Title, [string]$Text, [string]$Icon = 'Error')
|
||
try {
|
||
Add-Type -AssemblyName System.Windows.Forms -ErrorAction Stop
|
||
[System.Windows.Forms.MessageBox]::Show($Text, $Title, 'OK', $Icon) | Out-Null
|
||
Write-Log 'INFO' 'MessageBox 표시 성공'
|
||
return $true
|
||
} catch {
|
||
Write-Log 'WARN' "MessageBox 실패: $($_.Exception.Message)"
|
||
return $false
|
||
}
|
||
}
|
||
|
||
# ---------------------------------------------------------------- 토스트 표시
|
||
function Show-DmfToast {
|
||
param([psobject]$Item)
|
||
|
||
$level = if ($Item.PSObject.Properties.Name -contains 'level') { [string]$Item.level } else { 'info' }
|
||
$title = if ($Item.PSObject.Properties.Name -contains 'title') { [string]$Item.title } else { 'DMF Crawler' }
|
||
$body = if ($Item.PSObject.Properties.Name -contains 'body') { [string]$Item.body } else { '' }
|
||
$logRef = if ($Item.PSObject.Properties.Name -contains 'log') { [string]$Item.log } else { $Logs }
|
||
$owner = if ($Item.PSObject.Properties.Name -contains 'owner') { [string]$Item.owner } else { $null }
|
||
$runId = if ($Item.PSObject.Properties.Name -contains 'run_id'){ [string]$Item.run_id} else { '' }
|
||
|
||
$icon = if ($level -eq 'error') { '🚨' } elseif ($level -eq 'warn') { '⚠️' } else { 'ℹ️' }
|
||
$head = "$icon $title"
|
||
$lines = @()
|
||
if ($body) { $lines += $body }
|
||
if ($runId) { $lines += "run $runId" }
|
||
if ($owner) { $lines += "담당: $owner" }
|
||
$text = $lines -join "`n"
|
||
|
||
if ($HasBurntToast) {
|
||
try {
|
||
$buttons = @()
|
||
|
||
# 로그 열기: ActivationType 기본값이 Protocol 이므로 파일/폴더 경로를 그대로 넘긴다
|
||
$logTarget = if (Test-Path $logRef) { $logRef } else { $Logs }
|
||
$buttons += New-BTButton -Content '로그 열기' -Arguments $logTarget
|
||
|
||
if ($level -eq 'error') {
|
||
# 재실행: dmfcrawler: 프로토콜이 등록되어 있어야 동작(§13.3)
|
||
$buttons += New-BTButton -Content '지금 재실행' -Arguments 'dmfcrawler://retry' -Color Red
|
||
} else {
|
||
$rep = Get-ChildItem -Path $Reports -Filter '*.xlsx' -File -ErrorAction SilentlyContinue |
|
||
Sort-Object LastWriteTime -Descending | Select-Object -First 1
|
||
if ($rep) { $buttons += New-BTButton -Content '리포트 열기' -Arguments $rep.FullName -Color Green }
|
||
}
|
||
|
||
$buttons += New-BTButton -Dismiss -Content '닫기'
|
||
|
||
$params = @{
|
||
Text = @($head, $text)
|
||
Button = $buttons
|
||
}
|
||
# 오류는 Urgent(중요 알림)로 — 방해 금지 상태에서도 뚫을 확률을 높인다
|
||
if ($level -eq 'error' -and (Get-Command New-BurntToastNotification).Parameters.ContainsKey('Urgent')) {
|
||
$params['Urgent'] = $true
|
||
}
|
||
|
||
New-BurntToastNotification @params
|
||
Write-Log 'INFO' "토스트 표시: $head"
|
||
return $true
|
||
}
|
||
catch {
|
||
Write-Log 'WARN' "BurntToast 표시 실패: $($_.Exception.Message)"
|
||
}
|
||
}
|
||
|
||
# 폴백 1: msg.exe
|
||
$flat = "$head`n$text`n로그: $logTarget"
|
||
if (Show-MsgExe -Text $flat) { return $true }
|
||
|
||
# 폴백 2: MessageBox
|
||
$ic = if ($level -eq 'error') { 'Error' } else { 'Information' }
|
||
if (Show-MessageBox -Title $head -Text $flat -Icon $ic) { return $true }
|
||
|
||
Write-Log 'ERROR' '모든 알림 경로 실패'
|
||
return $false
|
||
}
|
||
|
||
# ---------------------------------------------------------------- 실행
|
||
if ($TestMode) {
|
||
Show-DmfToast ([pscustomobject]@{
|
||
level = 'error'
|
||
title = 'DMF 크롤러 실패 (테스트)'
|
||
body = '목록 페이지 타임아웃 · 이것은 테스트 알림입니다'
|
||
log = $Logs
|
||
owner = '테스트 담당자'
|
||
run_id = 'TEST-000000'
|
||
})
|
||
exit 0
|
||
}
|
||
|
||
$items = Get-ChildItem -Path $QueueDir -Filter 'notify-*.json' -File -ErrorAction SilentlyContinue |
|
||
Sort-Object LastWriteTime -Descending
|
||
|
||
if (-not $items) {
|
||
Write-Log 'INFO' '알림 큐 비어 있음 — 종료'
|
||
exit 0
|
||
}
|
||
|
||
$shown = 0
|
||
foreach ($f in $items) {
|
||
try {
|
||
$obj = Get-Content $f.FullName -Raw -Encoding UTF8 | ConvertFrom-Json
|
||
} catch {
|
||
Write-Log 'WARN' "큐 파일 파싱 실패($($f.Name)): $($_.Exception.Message)"
|
||
Move-Item -Path $f.FullName -Destination (Join-Path $Archive $f.Name) -Force -ErrorAction SilentlyContinue
|
||
continue
|
||
}
|
||
|
||
if ($shown -lt $MaxToasts) {
|
||
[void](Show-DmfToast -Item $obj)
|
||
$shown++
|
||
Start-Sleep -Milliseconds 800 # 토스트가 겹치지 않도록 간격
|
||
} else {
|
||
Write-Log 'INFO' "표시 한도 초과 — 건너뜀: $($f.Name)"
|
||
}
|
||
|
||
Move-Item -Path $f.FullName -Destination (Join-Path $Archive $f.Name) -Force -ErrorAction SilentlyContinue
|
||
}
|
||
|
||
# archive 정리 (30일)
|
||
Get-ChildItem -Path $Archive -Filter '*.json' -File -ErrorAction SilentlyContinue |
|
||
Where-Object { $_.LastWriteTime -lt (Get-Date).AddDays(-30) } |
|
||
Remove-Item -Force -ErrorAction SilentlyContinue
|
||
|
||
Write-Log 'INFO' "완료 — 표시 $shown 건 / 처리 $($items.Count) 건"
|
||
exit 0
|
||
```
|
||
|
||
**테스트**:
|
||
```powershell
|
||
# 대화형 세션에서 직접
|
||
.\scripts\notify.ps1 -TestMode
|
||
|
||
# 작업으로
|
||
Start-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Notify'
|
||
```
|
||
|
||
---
|
||
|
||
## 14. 재부팅 · 전원 · 시간대
|
||
|
||
### 14.1 자동 로그온이 필요한가?
|
||
|
||
**결론: 필요 없다. 그리고 쓰지 마라.**
|
||
|
||
크롤링 본체는 S4U/Password 로 **로그온 없이도 실행**된다. 자동 로그온이 필요한 유일한 이유는 "재부팅 직후에도 토스트를 띄우고 싶다" 인데, 그것은 웹훅으로 대체된다(§12.5).
|
||
|
||
Sysinternals **Autologon** 을 굳이 쓴다면(공식 문서):
|
||
|
||
> "Autologon enables you to easily configure Windows' built-in autologon mechanism. Instead of waiting for a user to enter their name and password, Windows uses the credentials you enter with Autologon, **which are encrypted in the Registry**, to log on the specified user automatically."
|
||
|
||
> ⚠️ **WARNING**: "Although the password is encrypted in the registry as an **LSA secret**, **a user with administrative rights can easily retrieve and decrypt it.**"
|
||
|
||
사용법:
|
||
- GUI: `autologon.exe` 실행 → 다이얼로그 입력 → **Enable**
|
||
- 명령줄: **`autologon user domain password`**
|
||
- 해제: **Disable** 버튼, 또는 부팅 시 **Shift 키를 누르고 있으면** 그 회차의 autologon 이 비활성화됨
|
||
- 다운로드: `https://download.sysinternals.com/files/AutoLogon.zip` (495 KB) / Sysinternals Live: `https://live.sysinternals.com/Autologon.exe`
|
||
- 주의: "Autologon does **not verify** the submitted credentials, nor does it verify that the specified user account is allowed to log on to the computer."
|
||
- 주의: "When **Exchange Activesync password restrictions** are in place, Windows will not process the autologon configuration."
|
||
|
||
**보안 판단**: 제약사 DMF 데이터를 다루는 PC 에 평문에 준하는 자동 로그온 자격증명을 남기는 것은 부적절하다. **채택하지 않는다.**
|
||
|
||
### 14.2 Fast Startup(빠른 시작)과 `AtStartup` 트리거
|
||
|
||
**Fast Startup 이 무엇을 하는가**(공식 커널 문서 원문):
|
||
|
||
> "To prepare for a fast startup, Windows performs a full shutdown sequence and saves a hibernation file.
|
||
> 1. First, as in a full shutdown, Windows closes all applications and logs off all user sessions. **At this stage, no applications are running, but the Windows kernel is loaded and the system session is running.**
|
||
> 2. Next, the power manager sends system power IRPs to device drivers to tell them to prepare their devices to enter hibernation.
|
||
> 3. Finally, **Windows saves the kernel memory image (including the loaded kernel-mode drivers) in Hiberfil.sys** and shuts down the computer."
|
||
|
||
> "During a cold startup, the boot loader constructs a kernel memory image by loading the sections of the Windows kernel file into memory and linking them. ... **In contrast, a fast startup simply loads the hibernation file (Hiberfil.sys) into memory.**"
|
||
|
||
Windows 의 시작 모드는 3가지: **Cold(traditional)**, **Wake-from-hibernation**, **Fast(앞의 둘을 결합, Windows 8 도입)**.
|
||
|
||
Fast Startup 여부는 드라이버 수준에서 `SYSTEM_POWER_STATE_CONTEXT` 의 `TargetSystemState` / `EffectiveSystemState` 비트필드로 구분한다:
|
||
- `TargetSystemState = PowerSystemHibernate` **AND** `EffectiveSystemState = PowerSystemHibernate` → **wake-from-hibernation**
|
||
- `TargetSystemState = PowerSystemShutdown` **AND** `EffectiveSystemState = PowerSystemHibernate` → **fast startup**
|
||
|
||
**왜 `AtStartup` 이 안 뜨는가**: "when Fast Startup is turned on and user shuts down system, **the system goes to hibernation and then wakes up instead of booting from scratch.** Since the system is resuming from hibernation rather than performing an actual boot/startup, the 'At startup' trigger **may not fire as expected**."
|
||
|
||
**중요 예외**: "**The Fast Startup setting doesn't apply to Restart.**" — 즉 **다시 시작(Restart)** 은 항상 완전 부팅이므로 `AtStartup` 이 발화한다. **종료(Shutdown) 후 켜기**만 문제다.
|
||
|
||
Fast Startup 관련 공식 문서의 추가 정보:
|
||
- 활성 위치: **Control Panel\All Control Panel Items\Power Options\System Settings**
|
||
- "**Fast Startup is enabled by default in Windows.**"
|
||
- "**Disabling Fast Startup is not recommended.**"
|
||
- 하이브리드 종료를 피하려면: `Shutdown /s /t 0` (전체 종료가 기본), 하이브리드를 쓰려면 `Shutdown /s /hybrid /t 0`
|
||
- 관련 트러블슈팅 레지스트리: `HKEY_LOCAL_MACHINE\System\CurrentControlSet\Control\CrashControl\` 의 `DumpFilters` — "Remove everything and make sure that **dumpfve.sys** is the only value listed." (이벤트 ID 45 가 System 로그에 있을 때)
|
||
|
||
**대응 3가지 (우선순위 순)**:
|
||
|
||
1. **`StartWhenAvailable=true` 로 충분하다 (권장).** 06:00 을 놓쳤으면 부팅/복귀 후 곧바로 실행된다. `AtStartup` 트리거는 보조일 뿐이다.
|
||
2. **이벤트 트리거로 대체.** 로그: `Microsoft-Windows-Diagnostics-Performance`, 소스: `PowerTroubleshooter`, **Event ID 1** — 절전/최대 절전 복귀를 감지한다. (Fast Startup 도 하이버네이션 복귀이므로 이 이벤트가 발생한다.)
|
||
3. **Fast Startup 을 끈다** (마지막 수단):
|
||
```powershell
|
||
# 최대 절전 자체를 끄면 Fast Startup 도 함께 꺼진다 (hiberfil.sys 삭제됨)
|
||
powercfg /hibernate off
|
||
|
||
# 또는 최대 절전은 유지하고 Fast Startup(하이버부트)만 끈다
|
||
Set-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\Power' `
|
||
-Name 'HiberbootEnabled' -Value 0 -Type DWord
|
||
```
|
||
> ⚠️ `HiberbootEnabled` 레지스트리 값 이름과 경로는 널리 알려진 값이나, **이번 조사에서 인용한 Microsoft 문서에는 명시되어 있지 않았다** — ⚠️ 미검증(부록 B-7). 공식 문서가 확인해 준 것은 "Fast Startup 은 Control Panel\...\Power Options\System Settings 에서 켜고 끈다" 와 "`Shutdown /s /t 0` 가 완전 종료" 뿐이다.
|
||
|
||
**참고 워크어라운드**(커뮤니티): 모든 Startup 트리거 작업을 로그온 시 강제로 한 번 돌리는 방법
|
||
```powershell
|
||
Get-ScheduledTask | Where-Object {$_.Triggers.TriggerType -eq 'Startup'} | Start-ScheduledTask
|
||
```
|
||
(`HKLM\...\Run` 이나 바로가기로 트리거) — 우리는 `StartWhenAvailable` 로 충분하므로 쓰지 않는다.
|
||
|
||
### 14.3 BitLocker 사전 부팅 PIN 과 무인 재부팅
|
||
|
||
**핵심 사실**(Microsoft BitLocker countermeasures 문서):
|
||
|
||
> "**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.**"
|
||
|
||
> **Network Unlock**: "Network Unlock allows systems that meet the hardware requirements and have BitLocker enabled with **TPM+PIN** to boot into Windows **without user intervention**. It requires **direct ethernet connectivity to a Windows Deployment Services (WDS) server**."
|
||
|
||
**우리 상황에 대한 결론**:
|
||
|
||
| 구성 | 재부팅 후 자동 복구 | 권고 |
|
||
|------|-------------------|------|
|
||
| **BitLocker 미사용** | ✅ 완전 자동 | 물리 보안이 확보된 사내 PC 라면 허용 가능 |
|
||
| **BitLocker TPM-only** | ✅ 완전 자동 | ✅ **권장** — 디스크 도난 대비 + 무인 부팅 양립 |
|
||
| **BitLocker TPM+PIN** | ❌ 사람이 PIN 입력해야 부팅 | ❌ 이 프로젝트에는 부적합 |
|
||
| **TPM+PIN + Network Unlock** | ✅ 자동 (WDS 서버 필요) | ❌ 단독 PC 에 WDS 를 세울 이유가 없음 |
|
||
|
||
또한 "conflicts can occur when using **MDM and GPO settings together**" — 회사 정책이 MDM 으로 TPM+PIN 을 강제한다면 **재부팅 후 자동 복구는 포기하고, 대신 워치독 + 웹훅으로 "PC 가 PIN 입력 대기 중" 을 사람이 알아채게** 설계해야 한다.
|
||
|
||
**현재 상태 확인**:
|
||
```powershell
|
||
manage-bde -status C:
|
||
Get-BitLockerVolume | Select-Object MountPoint, ProtectionStatus, KeyProtector
|
||
```
|
||
`KeyProtector` 에 `TpmPin` 이 있으면 무인 재부팅 불가.
|
||
|
||
### 14.4 Windows Update 재부팅과 06:00 충돌 회피
|
||
|
||
→ §15 에서 상세히 다룬다.
|
||
|
||
### 14.5 절전 · 최대 절전 · powercfg
|
||
|
||
**`WakeToRun` 이 동작하려면 3가지가 모두 필요하다**:
|
||
1. 작업 설정에 `<WakeToRun>true</WakeToRun>`
|
||
2. **전원 관리 옵션에서 "절전 모드 해제 타이머 허용(Allow wake timers)" 이 켜져 있어야 함**
|
||
3. 하드웨어/BIOS 가 웨이크 타이머를 지원해야 함
|
||
|
||
**권장 전원 설정 (AC 전원 기준)**:
|
||
```powershell
|
||
# 관리자 권한 필수 ("To make changes to power settings, Powercfg must be run from an elevated command prompt.")
|
||
|
||
# AC 전원에서 절전/최대 절전 진입 안 함
|
||
powercfg /change standby-timeout-ac 0
|
||
powercfg /change hibernate-timeout-ac 0
|
||
|
||
# 화면만 끄기 (10분)
|
||
powercfg /change monitor-timeout-ac 10
|
||
|
||
# 디스크는 끄지 않음
|
||
powercfg /change disk-timeout-ac 0
|
||
|
||
# 웨이크 타이머 허용 (현재 활성 전원 구성표, AC)
|
||
# GUID: SUB_SLEEP = 238c9fa8-0aad-41ed-83f4-97be242c8f20
|
||
# RTCWAKE = bd3b718a-0680-4d9d-8ab2-e1d2b4ac806d
|
||
powercfg /setacvalueindex SCHEME_CURRENT 238c9fa8-0aad-41ed-83f4-97be242c8f20 bd3b718a-0680-4d9d-8ab2-e1d2b4ac806d 1
|
||
powercfg /setactive SCHEME_CURRENT
|
||
|
||
# 현재 웨이크 타이머 목록 확인 (우리 작업이 보여야 한다)
|
||
powercfg /waketimers
|
||
|
||
# 무엇이 PC 를 깨우는지 조사
|
||
powercfg /lastwake
|
||
powercfg /devicequery wake_armed
|
||
```
|
||
|
||
공식/출처 확인 사항:
|
||
- "The `/change` parameter allows you to modify settings in the current power scheme, including standby-timeout-ac, hibernate-timeout-ac and other timeout settings, **with values specified in minutes**."
|
||
- "**-ac** refers to AC power (plugged-in), while **DC** refers to battery power"
|
||
- "**standby-timeout-ac 0**: Sets standby timeout to 0 minutes when plugged in (**prevents sleep**)"
|
||
- "**hibernate-timeout-ac 0**: Sets hibernation timeout to 0 minutes when plugged in (prevents hibernation)"
|
||
- "**/hibernate off**: Disables hibernation functionality"
|
||
- "**Wake timers can be enumerated with `/WakeTimers`** and are typically used to run scheduled tasks. The `/waketimers` command helps locate which schedules are authorized to wake the computer from sleep or hibernation."
|
||
|
||
> ⚠️ 위 GUID 두 개(`238c9fa8-...` = 절전 하위 그룹, `bd3b718a-...` = 절전 모드 해제 타이머 허용)는 널리 통용되는 표준 값이지만 **이번 조사에서 Microsoft 문서로 직접 확인하지 못했다** — ⚠️ 미검증(부록 B-8). 확실한 검증 방법은 `powercfg /query SCHEME_CURRENT SUB_SLEEP` 출력에서 실제 GUID 를 읽는 것이다.
|
||
|
||
**가장 안전한 운영 방침**: 이 PC 는 **24시간 켜 두고 절전에 들어가지 않게 한다.** 그러면 `WakeToRun` 에 의존할 필요조차 없다. `WakeToRun` 은 사용자가 실수로 절전을 켰을 때의 보험이다.
|
||
|
||
### 14.6 부팅 후 네트워크 대기
|
||
|
||
부팅 직후에는 네트워크 스택·DNS·프록시가 아직 준비되지 않았을 수 있다. 3중으로 방어한다:
|
||
|
||
1. **`<BootTrigger><Delay>PT3M</Delay>`** — 부팅 후 3분 대기(§3.1)
|
||
2. **`RunOnlyIfNetworkAvailable=true`** — "Specifies that the Task Scheduler will run the task only when a network is available." 특정 프로필만 보려면 `NetworkSettings`/`NetworkProfileName` 을 지정
|
||
3. **스크립트 내부 재시도 루프** — 가장 확실하다:
|
||
|
||
```powershell
|
||
# run-daily.ps1 본체 실행 직전에 삽입
|
||
function Wait-Network {
|
||
param([string]$TestHost = 'nedrug.mfds.go.kr', [int]$MaxTries = 20, [int]$DelaySec = 15)
|
||
for ($i = 1; $i -le $MaxTries; $i++) {
|
||
try {
|
||
$ok = Test-NetConnection -ComputerName $TestHost -Port 443 -InformationLevel Quiet -WarningAction SilentlyContinue
|
||
if ($ok) { Write-Log 'INFO' "네트워크 준비 완료 (시도 $i)"; return $true }
|
||
} catch { }
|
||
Write-Log 'WARN' "네트워크 대기 중... ($i/$MaxTries)"
|
||
Start-Sleep -Seconds $DelaySec
|
||
}
|
||
Write-Log 'ERROR' "네트워크 준비 실패 (${MaxTries}회 시도, 총 $($MaxTries*$DelaySec)초)"
|
||
return $false
|
||
}
|
||
|
||
if (-not (Wait-Network)) { throw '네트워크를 사용할 수 없어 크롤링을 중단합니다.' }
|
||
```
|
||
(최대 20회 × 15초 = 5분. `ExecutionTimeLimit PT2H` 안에서 충분히 여유롭다.)
|
||
|
||
### 14.7 시간대 · DST · "Synchronize across time zones"
|
||
|
||
**"Synchronize across time zones" 가 하는 일**: "When you check the 'Synchronize across time zones' option, **the task is scheduled by UTC.** If the computer's time zone is changed, the task will continue to run according to the UTC time recorded in the task's XML file."
|
||
|
||
**중요한 기본값 차이**:
|
||
> "When creating a scheduled task trigger using the **Task Scheduler UI**, the setting 'Synchronize Across Time Zones' is **disabled by default**. However, the PowerShell cmdlet **`New-ScheduledTaskTrigger` exhibits the opposite behavior.**"
|
||
|
||
→ **`New-ScheduledTaskTrigger` 로 만든 트리거는 기본적으로 UTC 기준(동기화 ON)이 된다.** XML 의 `<StartBoundary>` 에 타임존 오프셋(`2026-09-02T06:00:00+09:00`)이 붙으면 UTC 동기화, 오프셋이 없으면(`2026-09-02T06:00:00`) 로컬 시간이다.
|
||
|
||
**우리 프로젝트에 대한 결론**:
|
||
- **한국(Asia/Seoul, KST, UTC+9)은 DST 를 시행하지 않는다.** 따라서 DST 전환 문제는 발생하지 않는다.
|
||
- 그럼에도 **`<StartBoundary>` 에 오프셋을 넣지 않고 로컬 시간으로 두는 것을 권장**한다. "매일 아침 06:00" 이라는 업무 요구를 그대로 표현하며, PC 시간대가 바뀌더라도 "그 PC 의 아침 6시" 라는 의미가 유지된다.
|
||
- §6.2 XML 은 `<StartBoundary>2026-09-02T06:00:00</StartBoundary>` — 오프셋 없음(로컬 시간)으로 작성했다.
|
||
|
||
**DST 지역에서의 알려진 동작**(참고): "on the day that daylight savings time starts in a year, for a Pacific time zone (time shifted from 02:00 AM to 03:00 AM in 2022), **any task that's scheduled to run between 02:00 AM and 02:59:59:999 AM will run at the earliest possible time that exists in that given day — that is, at 03:00 AM.** This applies to all DST time zones based on the various times at which they observe DST." (주간/월간 캘린더 트리거 기준)
|
||
|
||
**알려진 이슈**: "After the end of DST, jobs may run one hour early, but after removing 'Synchronize across time zones' from the schedule, the start time becomes the current local non-DST time." / "there appears to be a bug when using the 'once per week on a specific day' option with DST changes." / 서버 재부팅 + DST 전환이 겹치면 작업이 예상치 못한 시각에 실행되는 사례도 보고되었다.
|
||
|
||
**시간대 확인/설정**:
|
||
```powershell
|
||
Get-TimeZone
|
||
Set-TimeZone -Id 'Korea Standard Time'
|
||
w32tm /query /status # 시간 동기화 상태
|
||
w32tm /resync # 강제 동기화
|
||
```
|
||
|
||
---
|
||
|
||
## 15. Windows Update 재부팅과 06:00 충돌 회피
|
||
|
||
### 15.1 활성 시간(Active hours)
|
||
|
||
공식 문서(Manage device restarts after updates):
|
||
|
||
> "*Active hours* identify the period of time when you expect the device to be in use. **Automatic restarts after an update occur outside of the active hours.**"
|
||
> "By default, active hours are from **8 AM to 5 PM** on PCs. Users can manually change the active hours."
|
||
> "The max active hours length for Windows 10, version 1607 and Windows Server 2016 is **12**. **Later versions support max active hours length of 18 hours.**"
|
||
|
||
**문제**: 기본 활성 시간이 08:00~17:00 이므로 **06:00 은 활성 시간 밖이다.** → Windows Update 가 정확히 우리 실행 시각에 재부팅할 수 있다.
|
||
|
||
**해결**: 활성 시간을 **05:00 ~ 22:00 (17시간)** 으로 설정한다. 06:00 이 활성 시간 안에 들어가 자동 재부팅이 억제된다. 최대 18시간 제한 안에 있다.
|
||
|
||
**그룹 정책 경로**:
|
||
```
|
||
컴퓨터 구성\관리 템플릿\Windows 구성 요소\Windows 업데이트
|
||
→ "활성 시간 동안 업데이트에 대한 자동 다시 시작 해제"
|
||
(Turn off auto-restart for updates during active hours)
|
||
→ 사용 → 시작/종료 시각 설정
|
||
```
|
||
|
||
**최대 범위 정책**:
|
||
```
|
||
컴퓨터 구성\관리 템플릿\Windows 구성 요소\Windows 업데이트
|
||
→ "자동 다시 시작에 대한 활성 시간 범위 지정"
|
||
(Specify active hours range for auto-restarts)
|
||
```
|
||
|
||
**MDM (Update Policy CSP)**: `ActiveHoursStart`, `ActiveHoursEnd`, `ActiveHoursMaxRange`
|
||
|
||
**레지스트리 (공식 문서가 권장하지 않는 방법)**:
|
||
> "Note: **Directly editing the Windows registry isn't recommended.**"
|
||
> "This method isn't recommended, and should only be used when you can't use group policy or MDM. Any settings configured through the registry might conflict with any existing configuration that uses any of the other methods."
|
||
|
||
정책 경로 (공식):
|
||
```
|
||
HKLM\Software\Policies\Microsoft\Windows\WindowsUpdate
|
||
SetActiveHours (활성 시간 기능 켜기/끄기)
|
||
ActiveHoursStart (시작 시각)
|
||
ActiveHoursEnd (종료 시각)
|
||
```
|
||
|
||
사용자 설정 경로 (비정책, 커뮤니티 출처):
|
||
```
|
||
HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\WindowsUpdate\UX\Settings
|
||
ActiveHoursStart REG_DWORD (24시간제)
|
||
ActiveHoursEnd REG_DWORD (24시간제)
|
||
```
|
||
> "Time values can range from integer values **0-24** (0-12 meaning 12:00 A.M. to 12:00 P.M. and 13-24 meaning 1:00 P.M. to 12:00 A.M.). For example, 8 = 8:00 AM."
|
||
|
||
커뮤니티에서 통용되는 명령 예시(원문):
|
||
```cmd
|
||
reg add "HKLM\SOFTWARE\Microsoft\WindowsUpdate\UX\Settings" /v "ActiveHoursStart" /t REG_DWORD /d 5 /f
|
||
reg add "HKLM\SOFTWARE\Microsoft\WindowsUpdate\UX\Settings" /v "ActiveHoursEnd" /t REG_DWORD /d 16 /f
|
||
```
|
||
|
||
**DMF_Crawler 권장 스크립트** (`scripts\set-active-hours.ps1`, 관리자 권한):
|
||
```powershell
|
||
#Requires -RunAsAdministrator
|
||
<#
|
||
.SYNOPSIS
|
||
Windows Update 활성 시간을 05:00~22:00 으로 설정해 06:00 크롤링과의 재부팅 충돌을 막는다.
|
||
.NOTES
|
||
정책 경로(HKLM\Software\Policies\...)를 우선 사용한다.
|
||
최대 활성 시간 길이는 18시간이므로 05→22(17시간)는 유효하다.
|
||
#>
|
||
$policy = 'HKLM:\Software\Policies\Microsoft\Windows\WindowsUpdate'
|
||
if (-not (Test-Path $policy)) { New-Item -Path $policy -Force | Out-Null }
|
||
|
||
New-ItemProperty -Path $policy -Name 'SetActiveHours' -Value 1 -PropertyType DWord -Force | Out-Null
|
||
New-ItemProperty -Path $policy -Name 'ActiveHoursStart' -Value 5 -PropertyType DWord -Force | Out-Null
|
||
New-ItemProperty -Path $policy -Name 'ActiveHoursEnd' -Value 22 -PropertyType DWord -Force | Out-Null
|
||
|
||
Write-Host '[=] Windows Update 활성 시간을 05:00~22:00 으로 설정했습니다.'
|
||
Write-Host ' 확인: 설정 > Windows 업데이트 > 고급 옵션 > 활성 시간'
|
||
gpupdate /target:computer /force | Out-Null
|
||
```
|
||
|
||
**GUI 확인 경로**: "To manually configure active hours on a device, go to **Settings > Windows Update > Advanced options** and select **Active hours**."
|
||
|
||
### 15.2 로그온한 사용자가 있을 때 재부팅 안 함
|
||
|
||
정책: **"No auto-restart with logged on users for scheduled automatic updates installations"**
|
||
> "prevents automatic restart when a user is signed in. If a user schedules the restart in the update notification, the device restarts at the time the user specifies even if a user is signed in at the time. **This policy only applies when Configure Automatic Updates is set to option 4 - Auto download and schedule the install.**"
|
||
|
||
레지스트리:
|
||
```
|
||
HKLM\Software\Policies\Microsoft\Windows\WindowsUpdate\AU
|
||
AuOptions = 4
|
||
NoAutoRebootWithLoggedOnUsers = 1
|
||
```
|
||
|
||
**공식 주의사항(중요)**:
|
||
> "When using Remote Desktop Protocol (RDP) connections, **only active RDP sessions are considered signed-in users. Devices that don't have locally signed-in users, or active RDP sessions, are restarted.**"
|
||
|
||
> "The **No auto-restart with logged on users** policy was never created as a CSP. In Group Policy this policy doesn't work exactly as per description. **This policy can result in no quality update reboots period, given many users never log off.** The recommendation to replace this would be to leverage **compliance deadline** and then to configure no-auto reboot to prevent non-user aware reboots prior to the deadline being reached. For server devices, leverage Configure Automatic Updates options **7 - notify to install and notify to reboot**."
|
||
|
||
→ **이 정책 하나에 의존하지 마라.** 우리 PC 는 무인 운영이므로 "로그온한 사용자" 가 없을 가능성이 높고, 그러면 이 정책은 무의미하다. **활성 시간(§15.1)이 주 방어선**이다.
|
||
|
||
### 15.3 설치 시각 예약과 강제 재부팅
|
||
|
||
```
|
||
HKLM\Software\Policies\Microsoft\Windows\WindowsUpdate\AU
|
||
AuOptions = 4 (Auto download and schedule the install)
|
||
ScheduledInstallTime = <시각>
|
||
AlwaysAutoRebootAtScheduledTime = 0/1
|
||
AlwaysAutoRebootAtScheduledTimeMinutes = <분> (로그온 사용자에게 경고할 타이머)
|
||
```
|
||
> "The setting to **Always automatically restart at the scheduled time** forces a restart after the specified installation time. It lets you configure a timer to warn a signed-in user that a restart is going to occur. **This policy is a legacy policy and isn't applicable for Windows 11.**"
|
||
|
||
> "**Turn off auto-restart for updates during active hours** prevents automatic restart during active hours."
|
||
|
||
### 15.4 재시작 지연 한도
|
||
|
||
> "After Windows installs an update, it attempts to automatically restart outside of active hours. **If the restart doesn't succeed after a default period of seven days**, the user sees a notification that a restart is required. To change the delay, use the setting to **Specify deadline before auto-restart for update installation**. The minimum value is **two days** and the maximum value is **two weeks (14 days)**. This policy is a legacy policy and isn't applicable for Windows 11."
|
||
|
||
### 15.5 재부팅 후 자기 치유 확인
|
||
|
||
Windows Update 로 재부팅되었더라도 다음이 보장된다:
|
||
1. `StartWhenAvailable=true` → 06:00 을 놓쳤으면 부팅 후 즉시 실행
|
||
2. `<BootTrigger><Delay>PT3M</Delay>` → 부팅 3분 후에도 트리거(Restart 는 완전 부팅이므로 확실히 발화)
|
||
3. `MultipleInstancesPolicy=IgnoreNew` → 위 둘이 동시에 걸려도 중복 실행되지 않음
|
||
4. 07:00 워치독이 최종 확인
|
||
|
||
**재부팅 이력 확인**:
|
||
```powershell
|
||
# 최근 시스템 시작/종료 이벤트
|
||
Get-WinEvent -FilterHashtable @{ LogName='System'; Id=6005,6006,6008,1074,41 } -MaxEvents 30 |
|
||
Select-Object TimeCreated, Id, Message | Format-List
|
||
|
||
# Windows Update 설치 이력
|
||
Get-WinEvent -FilterHashtable @{ LogName='System'; ProviderName='Microsoft-Windows-WindowsUpdateClient' } -MaxEvents 20 |
|
||
Select-Object TimeCreated, Id, Message | Format-List
|
||
```
|
||
|
||
---
|
||
|
||
## 16. WSL2 / Docker Desktop 옵션과 한계
|
||
|
||
### 16.1 WSL2 + cron / systemd
|
||
|
||
**systemd 활성화 방법**(공식 문서):
|
||
|
||
1. WSL 버전이 **0.67.6 이상**이어야 한다.
|
||
- 확인: `wsl --version` — "if the command throws `Invalid command line option: --version` error, you must update WSL"
|
||
- 업데이트: `wsl --update` 또는 Microsoft Store 에서 최신 버전 설치(`https://aka.ms/wslstorepage`)
|
||
2. `/etc/wsl.conf` 편집:
|
||
```ini
|
||
[boot]
|
||
systemd=true
|
||
```
|
||
3. `wsl.exe --shutdown` 으로 전체 WSL 인스턴스 재시작
|
||
4. 확인: `systemctl status` / `systemctl list-unit-files --type=service`
|
||
|
||
Debian/Ubuntu/Kali Rolling 계열은 `systemd-sysv` 패키지도 필요:
|
||
```bash
|
||
sudo apt-get update -y && sudo apt-get install systemd systemd-sysv -y
|
||
```
|
||
|
||
"Systemd is now the default for the current version of Ubuntu that will be installed using the `wsl --install` command default."
|
||
|
||
**`wsl.conf` 의 `[boot]` 섹션**(공식 표, Windows 11 및 Server 2022 전용):
|
||
|
||
| Key | Value | Default | Notes |
|
||
|-----|-------|---------|-------|
|
||
| `command` | string | Null | "A string of the command that you would like to run when the WSL instance starts. **This command is run as the root user.** e.g: `service docker start`." |
|
||
| `protectBinfmt` | boolean | `true` | "Prevents WSL from generating systemd units when systemd is enabled." |
|
||
| `systemd` | boolean | (배포판 종속) | systemd 를 init 으로 사용 |
|
||
|
||
`wsl.conf` 전체 섹션: `automount`, `network`, `interop`, `user`, `boot`, `gpu`, `time`. (`[time] useWindowsTimezone` 기본 true — Windows 시간대를 따라간다.)
|
||
|
||
**설정 반영 규칙(중요)**: "**The 8 second rule for configuration changes** — You must wait until the subsystem running your Linux distribution completely stops running and restarts for configuration setting updates to appear. This typically takes about **8 seconds** after closing ALL instances of the distribution shell." 확인: `wsl --list --running`.
|
||
|
||
**치명적 한계 (공식 원문)**:
|
||
|
||
> "It is also important to note that with these changes, **systemd services will NOT keep your WSL instance alive.** Your WSL instance will stay alive in the same way it did previous to this update."
|
||
|
||
즉 **WSL 배포판이 살아 있지 않으면 systemd 서비스도 cron 도 돌지 않는다.**
|
||
|
||
커뮤니티에서 확인된 추가 사실:
|
||
- "Cron inside WSL will not run until the WSL distro starts. **WSL doesn't start cron automatically**, meaning that your automated tasks aren't getting executed by default."
|
||
- "if you setup a Task Scheduler event to start the WSL cron service on bootup, **the cron does not run automatically**."
|
||
- 해결: "create a Windows Scheduled Task that starts WSL at boot or at user logon" + 배포판을 살려두는 프로세스:
|
||
```bash
|
||
systemctl start cron; nohup sleep infinity >/dev/null 2>&1
|
||
```
|
||
"this command starts the cron service while keeping a lightweight process running so the distro doesn't immediately exit."
|
||
- "**WSL can stop when no Linux processes are running.** If cron is running under systemd, that may be enough. If your distro still exits, keep one harmless long-running process active."
|
||
- "The key issue is that you need a process running that's a child of the Microsoft init (like an interactive shell) to keep WSL from idle-terminating."
|
||
|
||
관련 이슈: microsoft/WSL#9072("Cron will not keep running in background and does not log to cron.log"), microsoft/WSL#10732("WSL cannot be run by scheduled task").
|
||
|
||
**결론: 채택하지 않는다.**
|
||
- 어차피 **Windows 작업 스케줄러로 WSL 을 띄워야** 하므로, 작업 스케줄러 의존을 제거하지 못한다. 계층만 하나 늘어난다.
|
||
- WSL 배포판 유지를 위한 `sleep infinity` 같은 인위적 장치가 필요하다.
|
||
- Playwright 브라우저를 WSL 안에 별도 설치해야 하고, xlsx 결과물을 `/mnt/d/...` 로 쓰면 성능이 나쁘다.
|
||
- 토스트 알림은 여전히 Windows 쪽에서 처리해야 한다.
|
||
|
||
**만약 그래도 WSL 을 써야 한다면** (예: 크롤러가 Linux 전용 도구에 의존):
|
||
```powershell
|
||
# 작업 스케줄러 액션
|
||
# Execute : C:\Windows\System32\wsl.exe
|
||
# Argument: -d Ubuntu -u root -- bash -lc "cd /mnt/d/workspace/DMF_Crawler && ./run.sh"
|
||
New-ScheduledTaskAction -Execute "$env:SystemRoot\System32\wsl.exe" `
|
||
-Argument '-d Ubuntu -u root -- bash -lc "cd /mnt/d/workspace/DMF_Crawler && ./run.sh"'
|
||
```
|
||
→ **cron 을 쓰지 말고 Windows 작업 스케줄러가 직접 `wsl.exe` 를 호출하게 하라.** 이러면 WSL 상주 문제가 사라진다.
|
||
|
||
### 16.2 Docker Desktop + `restart: always`
|
||
|
||
**Docker 재시작 정책 4종(공식)**:
|
||
|
||
| 정책 | 동작(원문) |
|
||
|------|-----------|
|
||
| `no` | "Don't automatically restart the container. (Default)" |
|
||
| `on-failure[:max-retries]` | 0 이 아닌 종료 코드에서만 재시작, 재시도 횟수 제한 가능. **"Does not restart if the daemon restarts."** |
|
||
| `always` | "Always restart the container if it stops" — 단, 수동 정지한 경우에는 데몬 재시작 후에만 다시 시작 |
|
||
| `unless-stopped` | `always` 와 유사하나, 수동 정지한 컨테이너는 데몬 재시작 후에도 정지 상태 유지 |
|
||
|
||
**데몬 재시작 시**: "containers with `always` and `unless-stopped` policies will resume running. The `on-failure` policy does not trigger on daemon restart. The `no` policy never auto-restarts."
|
||
|
||
**결정적 한계 — Docker Desktop 설정**:
|
||
|
||
> **"Start Docker Desktop when you sign in to your computer"** — "This setting automatically launches Docker Desktop upon **user login**. It's **disabled by default** but recommended for those who use Docker frequently."
|
||
|
||
> **"Open Docker Dashboard when Docker Desktop starts"** — 기본 비활성.
|
||
|
||
즉 **Docker Desktop 은 사용자 로그인 이후에 뜬다.** 로그인하지 않은 상태에서는 데몬이 없으므로 `restart: always` 컨테이너도 뜨지 않는다.
|
||
|
||
> ⚠️ Docker 공식 문서에는 "Docker Desktop on Windows requires user sign-in / cannot run as a service before login" 이라는 **명시적 문장은 없었다.** 다만 "Start Docker Desktop when you sign in to your computer" 라는 설정 문구 자체가 로그인 의존성을 드러낸다 — ⚠️ 부분 검증.
|
||
|
||
**결론: 채택하지 않는다.** 로그인 의존성이 생기면 §14.1 에서 배제한 자동 로그온을 다시 끌어와야 하고, 그것은 보안상 부적절하다.
|
||
|
||
**대안적 사용법**: 크롤러를 컨테이너화하고 싶다면, **Docker Desktop 대신 Windows 작업 스케줄러가 `docker run --rm` 을 호출**하게 하라. 그래도 데몬 기동 문제는 남는다. 이 규모의 프로젝트에는 과한 복잡도다.
|
||
|
||
---
|
||
|
||
## 17. 로그 관리 · 실행 ID · 실패 스크린샷
|
||
|
||
### 17.1 디렉터리 구조
|
||
|
||
```
|
||
D:\workspace\DMF_Crawler\
|
||
├─ scripts\
|
||
│ ├─ register-tasks.ps1 # 작업 3종 등록 (관리자)
|
||
│ ├─ register-protocol.ps1 # dmfcrawler: 프로토콜 등록 (관리자, 1회)
|
||
│ ├─ protocol-handler.ps1 # dmfcrawler:// URL 처리
|
||
│ ├─ run-daily.ps1 # 크롤링 래퍼 (작업 ① 이 호출)
|
||
│ ├─ watchdog.ps1 # heartbeat 검증 (작업 ② 가 호출)
|
||
│ ├─ notify.ps1 # 토스트 표시 (작업 ③ 이 호출)
|
||
│ ├─ set-active-hours.ps1 # Windows Update 활성 시간 (관리자, 1회)
|
||
│ └─ preflight.ps1 # 설치 전 환경 점검 (§20)
|
||
├─ config\
|
||
│ └─ ops.config.ps1 # 웹훅 URL, healthchecks UUID, 담당자 (Git 제외!)
|
||
├─ ops\
|
||
│ ├─ DMF_Crawler_Daily.xml # 작업 XML (Git 커밋)
|
||
│ ├─ DMF_Crawler_Watchdog.xml
|
||
│ └─ DMF_Crawler_Notify.xml
|
||
├─ state\
|
||
│ ├─ heartbeat.json # 최근 실행 상태
|
||
│ ├─ summary-<run_id>.json # 크롤러가 남기는 실행 요약
|
||
│ └─ notify-queue\
|
||
│ ├─ notify-<run_id>.json # 미표시 알림
|
||
│ └─ archive\ # 표시 완료 알림 (30일 보관)
|
||
├─ logs\
|
||
│ ├─ run-<run_id>.log # 실행별 로그 (30일 보관)
|
||
│ ├─ watchdog-YYYYMM.log # 워치독 월별 로그
|
||
│ ├─ notify-YYYYMM.log # 알림 월별 로그
|
||
│ └─ shots\
|
||
│ └─ <run_id>-<stage>.png # 실패 스크린샷 (14일 보관)
|
||
├─ reports\
|
||
│ └─ DMF_YYYY-MM-DD.xlsx # 결과물
|
||
└─ .playwright-browsers\ # PLAYWRIGHT_BROWSERS_PATH 고정 위치
|
||
```
|
||
|
||
> **`config\ops.config.ps1` 은 반드시 `.gitignore` 에 넣어라.** 웹훅 URL 과 healthchecks UUID 는 그 자체가 인증 토큰이다.
|
||
|
||
### 17.2 로그 로테이션 정책
|
||
|
||
| 대상 | 보관 | 방식 |
|
||
|------|------|------|
|
||
| `logs\run-*.log` | **30일** | 실행별 파일 분리 → `run-daily.ps1` 종료 시 30일 초과분 삭제 |
|
||
| `logs\watchdog-YYYYMM.log` | **12개월** | 월별 파일. 별도 정리 작업 |
|
||
| `logs\notify-YYYYMM.log` | **12개월** | 〃 |
|
||
| `logs\shots\*.png` | **14일** | 용량이 크므로 짧게 |
|
||
| `state\notify-queue\archive\*.json` | **30일** | |
|
||
| `reports\*.xlsx` | **무기한** | 업무 산출물. 삭제 금지 |
|
||
| `Microsoft-Windows-TaskScheduler/Operational` | 64MB 순환 | `wevtutil set-log ... /maxsize:67108864` |
|
||
| `Application` 로그 | OS 기본 | 필요 시 `Limit-EventLog` 로 조정 |
|
||
|
||
**실행별 파일 분리 방식**을 택한 이유: 단일 파일 + 크기 기반 회전은 PowerShell 로 구현하면 파일 잠금 경합이 생긴다. 하루 1회 실행이므로 파일이 하루 하나씩만 늘어나 관리가 단순하다.
|
||
|
||
**월별 정리 작업**(선택, 작업 스케줄러에 등록):
|
||
```powershell
|
||
# scripts\rotate-logs.ps1
|
||
param([string]$Root = 'D:\workspace\DMF_Crawler')
|
||
$Logs = Join-Path $Root 'logs'
|
||
$Shots = Join-Path $Logs 'shots'
|
||
|
||
$rules = @(
|
||
@{ Path = $Logs; Filter = 'run-*.log'; Days = 30 },
|
||
@{ Path = $Logs; Filter = 'watchdog-*.log'; Days = 365 },
|
||
@{ Path = $Logs; Filter = 'notify-*.log'; Days = 365 },
|
||
@{ Path = $Shots; Filter = '*.png'; Days = 14 }
|
||
)
|
||
|
||
foreach ($r in $rules) {
|
||
if (-not (Test-Path $r.Path)) { continue }
|
||
Get-ChildItem -Path $r.Path -Filter $r.Filter -File |
|
||
Where-Object { $_.LastWriteTime -lt (Get-Date).AddDays(-$r.Days) } |
|
||
ForEach-Object {
|
||
Write-Host "삭제: $($_.FullName)"
|
||
Remove-Item $_.FullName -Force -ErrorAction SilentlyContinue
|
||
}
|
||
}
|
||
|
||
# 오래된 로그를 지우는 대신 압축하고 싶다면:
|
||
# Compress-Archive -Path (Join-Path $Logs 'run-2026*.log') -DestinationPath (Join-Path $Logs 'archive-2026.zip') -Update
|
||
```
|
||
|
||
### 17.3 실행 ID(run_id) 규약
|
||
|
||
형식: `yyyyMMdd-HHmmss-<6자리 hex>`
|
||
예: `20260902-060003-a7f31c`
|
||
|
||
이 값이 **모든 산출물을 관통하는 상관 키**다:
|
||
- 로그 파일명: `logs\run-20260902-060003-a7f31c.log`
|
||
- 스크린샷: `logs\shots\20260902-060003-a7f31c-crawl_list_page.png`
|
||
- heartbeat: `state\heartbeat.json` 의 `run_id`
|
||
- 요약: `state\summary-20260902-060003-a7f31c.json`
|
||
- 알림 큐: `state\notify-queue\notify-20260902-060003-a7f31c.json`
|
||
- 이벤트 로그 메시지 본문에 `run_id=...`
|
||
- healthchecks 의 `rid` 파라미터(별도 UUID지만 로그에 함께 기록)
|
||
- 크롤러 프로세스에 `--run-id` 인자 + `DMF_RUN_ID` 환경변수로 전달
|
||
|
||
**Python 쪽에서 사용**:
|
||
```python
|
||
import os, sys, json, pathlib, datetime
|
||
|
||
RUN_ID = os.environ.get("DMF_RUN_ID") or datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
|
||
ROOT = pathlib.Path(r"D:\workspace\DMF_Crawler")
|
||
SHOTS = ROOT / "logs" / "shots"
|
||
STATE = ROOT / "state"
|
||
SHOTS.mkdir(parents=True, exist_ok=True)
|
||
|
||
def shot_path(stage: str) -> pathlib.Path:
|
||
"""실패 스크린샷 경로. stage 는 crawl_list_page 같은 슬러그."""
|
||
return SHOTS / f"{RUN_ID}-{stage}.png"
|
||
|
||
def write_summary(new: int, changed: int, withdrawn: int, total: int, report: str) -> None:
|
||
(STATE / f"summary-{RUN_ID}.json").write_text(
|
||
json.dumps({
|
||
"run_id": RUN_ID,
|
||
"new": new, "changed": changed, "withdrawn": withdrawn,
|
||
"total_scanned": total, "report_path": report,
|
||
}, ensure_ascii=False, indent=2),
|
||
encoding="utf-8",
|
||
)
|
||
```
|
||
|
||
### 17.4 실패 스크린샷 (Playwright)
|
||
|
||
```python
|
||
from playwright.sync_api import sync_playwright, TimeoutError as PWTimeout
|
||
|
||
def crawl_stage(page, stage: str, fn):
|
||
"""각 단계를 감싸고, 실패하면 스크린샷 + HTML 덤프를 남긴다."""
|
||
try:
|
||
return fn()
|
||
except Exception:
|
||
try:
|
||
page.screenshot(path=str(shot_path(stage)), full_page=True)
|
||
html = SHOTS / f"{RUN_ID}-{stage}.html"
|
||
html.write_text(page.content(), encoding="utf-8")
|
||
print(f"[FAIL] stage={stage} shot={shot_path(stage)} html={html}", file=sys.stderr)
|
||
except Exception as e2:
|
||
print(f"[WARN] 스크린샷 저장 실패: {e2}", file=sys.stderr)
|
||
raise
|
||
|
||
|
||
with sync_playwright() as p:
|
||
browser = p.chromium.launch(headless=True)
|
||
ctx = browser.new_context(
|
||
viewport={"width": 1600, "height": 1200},
|
||
locale="ko-KR",
|
||
timezone_id="Asia/Seoul",
|
||
# 실패 재현을 위한 트레이스/비디오는 필요할 때만 켠다(용량 큼)
|
||
)
|
||
# 전체 트레이스가 필요하면:
|
||
# ctx.tracing.start(screenshots=True, snapshots=True, sources=True)
|
||
page = ctx.new_page()
|
||
page.set_default_timeout(30_000)
|
||
|
||
try:
|
||
crawl_stage(page, "list_page", lambda: page.goto("https://nedrug.mfds.go.kr/..."))
|
||
# ...
|
||
finally:
|
||
# ctx.tracing.stop(path=str(SHOTS / f"{RUN_ID}-trace.zip"))
|
||
ctx.close()
|
||
browser.close()
|
||
```
|
||
|
||
### 17.5 로그 라인 포맷
|
||
|
||
```
|
||
2026-09-02 06:00:03.117 [INFO ] === DMF_Crawler 실행 시작 run_id=20260902-060003-a7f31c host=DESKTOP-XXXX user=encep ===
|
||
2026-09-02 06:00:03.240 [INFO ] python: D:\workspace\DMF_Crawler\.venv\Scripts\python.exe
|
||
2026-09-02 06:00:04.882 [INFO ] 네트워크 준비 완료 (시도 1)
|
||
2026-09-02 06:00:05.010 [INFO ] crawler 시작...
|
||
2026-09-02 06:00:37.442 [ERROR] PlaywrightTimeoutError: Timeout 30000ms exceeded waiting for selector '#dmfList'
|
||
2026-09-02 06:00:37.501 [INFO ] crawler 종료 exit_code=3
|
||
2026-09-02 06:00:37.610 [ERROR] === 실패 ===
|
||
2026-09-02 06:00:38.220 [INFO ] Discord 웹훅 전송 완료
|
||
2026-09-02 06:00:38.310 [INFO ] === 종료 exit_code=3 duration=35s ===
|
||
```
|
||
|
||
원칙:
|
||
- **밀리초까지** 기록 (성능 병목 추적)
|
||
- 레벨은 `INFO / WARN / ERROR` 3단계로 단순화
|
||
- 시작·종료 라인에 `===` 구분자 → grep 이 쉽다
|
||
- **stdout 과 stderr 를 모두 캡처**해야 한다. 파이썬 예외 트레이스백은 stderr 로 나간다. `2>&1` 필수.
|
||
- 크롤러 exit code 를 의미 있게 설계하라: `0`=성공, `1`=일반 오류, `2`=설정/환경 오류, `3`=크롤링 실패, `4`=리포트 생성 실패
|
||
|
||
---
|
||
|
||
## 18. 실행 결과 코드 · 이벤트 ID 레퍼런스
|
||
|
||
### 18.1 Task Scheduler `LastTaskResult` (HRESULT) — 공식 상수 전체
|
||
|
||
"The constants that begin with SCHED_S_ are **success** constants, and the constants that begin with SCHED_E_ are **error** constants."
|
||
|
||
| 상수 | 값 | 의미 |
|
||
|------|-----|------|
|
||
| `SCHED_E_SERVICE_NOT_LOCALSYSTEM` | `6200L` | The Task Scheduler service must be configured to run in the System account to function properly. Individual tasks may be configured to run in other accounts. |
|
||
| `SCHED_S_TASK_READY` | `0x00041300` | The task is ready to run at its next scheduled time. |
|
||
| **`SCHED_S_TASK_RUNNING`** | **`0x00041301`** | **The task is currently running.** |
|
||
| `SCHED_S_TASK_DISABLED` | `0x00041302` | The task will not run at the scheduled times because it has been disabled. |
|
||
| `SCHED_S_TASK_HAS_NOT_RUN` | `0x00041303` | The task has not yet run. |
|
||
| `SCHED_S_TASK_NO_MORE_RUNS` | `0x00041304` | There are no more runs scheduled for this task. |
|
||
| `SCHED_S_TASK_NOT_SCHEDULED` | `0x00041305` | One or more of the properties that are needed to run this task on a schedule have not been set. |
|
||
| **`SCHED_S_TASK_TERMINATED`** | **`0x00041306`** | **The last run of the task was terminated by the user.** (ExecutionTimeLimit 초과나 Stop-ScheduledTask 로도 나타남) |
|
||
| `SCHED_S_TASK_NO_VALID_TRIGGERS` | `0x00041307` | Either the task has no triggers or the existing triggers are disabled or not set. |
|
||
| `SCHED_S_EVENT_TRIGGER` | `0x00041308` | Event triggers don't have set run times. |
|
||
| `SCHED_E_TRIGGER_NOT_FOUND` | `0x80041309` | Trigger not found. |
|
||
| `SCHED_E_TASK_NOT_READY` | `0x8004130A` | One or more of the properties that are needed to run this task have not been set. |
|
||
| `SCHED_E_TASK_NOT_RUNNING` | `0x8004130B` | There is no running instance of the task. |
|
||
| `SCHED_E_SERVICE_NOT_INSTALLED` | `0x8004130C` | The Task Scheduler Service is not installed on this computer. |
|
||
| `SCHED_E_CANNOT_OPEN_TASK` | `0x8004130D` | The task object could not be opened. |
|
||
| `SCHED_E_INVALID_TASK` | `0x8004130E` | The object is either an invalid task object or is not a task object. |
|
||
| **`SCHED_E_ACCOUNT_INFORMATION_NOT_SET`** | **`0x8004130F`** | **No account information could be found in the Task Scheduler security database for the task indicated.** (계정 암호 변경 후 흔함) |
|
||
| `SCHED_E_ACCOUNT_NAME_NOT_FOUND` | `0x80041310` | Unable to establish existence of the account specified. |
|
||
| `SCHED_E_ACCOUNT_DBASE_CORRUPT` | `0x80041311` | Corruption was detected in the Task Scheduler security database; the database has been reset. |
|
||
| `SCHED_E_NO_SECURITY_SERVICES` | `0x80041312` | Task Scheduler security services are available only on Windows NT. |
|
||
| `SCHED_E_UNKNOWN_OBJECT_VERSION` | `0x80041313` | The task object version is either unsupported or invalid. |
|
||
| **`SCHED_E_UNSUPPORTED_ACCOUNT_OPTION`** | **`0x80041314`** | **The task has been configured with an unsupported combination of account settings and run time options.** (S4U + 대화형 요구 조합 등) |
|
||
| `SCHED_E_SERVICE_NOT_RUNNING` | `0x80041315` | The Task Scheduler Service is not running. |
|
||
| `SCHED_E_UNEXPECTEDNODE` | `0x80041316` | The task XML contains an unexpected node. |
|
||
| `SCHED_E_NAMESPACE` | `0x80041317` | The task XML contains an element or attribute from an unexpected namespace. |
|
||
| `SCHED_E_INVALIDVALUE` | `0x80041318` | The task XML contains a value which is incorrectly formatted or out of range. |
|
||
| `SCHED_E_MISSINGNODE` | `0x80041319` | The task XML is missing a required element or attribute. |
|
||
| `SCHED_E_MALFORMEDXML` | `0x8004131A` | The task XML is malformed. |
|
||
| `SCHED_S_SOME_TRIGGERS_FAILED` | `0x0004131B` | The task is registered, but not all specified triggers will start the task, check task scheduler event log for detailed information. |
|
||
| **`SCHED_S_BATCH_LOGON_PROBLEM`** | **`0x0004131C`** | **The task is registered, but may fail to start. Batch logon privilege needs to be enabled for the task principal.** (→ §4.5) |
|
||
| `SCHED_E_TOO_MANY_NODES` | `0x8004131D` | The task XML contains too many nodes of the same type. |
|
||
| `SCHED_E_PAST_END_BOUNDARY` | `0x8004131E` | The task cannot be started after the trigger's end boundary. |
|
||
| `SCHED_E_ALREADY_RUNNING` | `0x8004131F` | An instance of this task is already running. |
|
||
| **`SCHED_E_USER_NOT_LOGGED_ON`** | **`0x80041320`** | **The task will not run because the user is not logged on.** (Interactive 로그온 타입인데 세션이 없음) |
|
||
| `SCHED_E_INVALID_TASK_HASH` | `0x80041321` | The task image is corrupt or has been tampered with. |
|
||
| `SCHED_E_SERVICE_NOT_AVAILABLE` | `0x80041322` | The Task Scheduler service is not available. |
|
||
| `SCHED_E_SERVICE_TOO_BUSY` | `0x80041323` | The Task Scheduler service is too busy to handle your request. Please try again later. |
|
||
| **`SCHED_E_TASK_ATTEMPTED`** | **`0x80041324`** | **The Task Scheduler service attempted to run the task, but the task did not run due to one of the constraints in the task definition.** (배터리·유휴·네트워크 조건 미충족) |
|
||
| `SCHED_S_TASK_QUEUED` | `0x00041325` | The Task Scheduler service has asked the task to run. |
|
||
| `SCHED_E_TASK_DISABLED` | `0x80041326` | The task is disabled. |
|
||
| `SCHED_E_TASK_NOT_V1_COMPAT` | `0x80041327` | The task has properties that are not compatible with previous versions of Windows. |
|
||
| `SCHED_E_START_ON_DEMAND` | `0x80041328` | The task settings do not allow the task to start on demand. |
|
||
| `SCHED_E_TASK_NOT_UBPM_COMPAT` | `0x80041329` | The combination of properties that task is using is not compatible with the scheduling engine. |
|
||
| `SCHED_E_DEPRECATED_FEATURE_USED` | `0x80041330` | The task definition uses a deprecated feature. |
|
||
|
||
**기타 자주 보는 값**:
|
||
| 코드 | 의미 |
|
||
|------|------|
|
||
| `0x0` | 정상 완료 |
|
||
| **`0x1`** | "Incorrect function called or unknown function called" — 실무상 **스크립트가 exit 1 로 끝났거나 경로/권한 문제** |
|
||
| `0x2` | 파일을 찾을 수 없음 |
|
||
| `0x41301` | 실행 중(십진 267009) |
|
||
| `0x41306` | 종료됨(십진 267014) |
|
||
| **`0x800710E0`** | "The operator or administrator has refused the request" |
|
||
|
||
> Note(공식): "Some Task Scheduler APIs can return system and network error codes (64 for example). You can check the definition of these types of error codes by using the **`net helpmsg`** command in the command prompt window. For example, the command **`net helpmsg 64`** returns the message: The specified network name is no longer available."
|
||
|
||
**HRESULT 해석 헬퍼**:
|
||
```powershell
|
||
function Get-TaskResultText {
|
||
param([int]$Code)
|
||
$map = @{
|
||
0 = '성공'
|
||
1 = '일반 오류 (0x1) — 스크립트 exit 1 / 경로 / 권한 확인'
|
||
2 = '파일을 찾을 수 없음 (0x2)'
|
||
267008 = 'SCHED_S_TASK_READY — 다음 예정 시각 대기 중'
|
||
267009 = 'SCHED_S_TASK_RUNNING — 실행 중'
|
||
267010 = 'SCHED_S_TASK_DISABLED — 사용 안 함'
|
||
267011 = 'SCHED_S_TASK_HAS_NOT_RUN — 아직 실행된 적 없음'
|
||
267014 = 'SCHED_S_TASK_TERMINATED — 강제 종료됨(시간 초과 가능)'
|
||
-2147216615 = 'SCHED_E_ACCOUNT_INFORMATION_NOT_SET (0x8004130F) — 계정 정보 없음'
|
||
-2147216602 = 'SCHED_E_USER_NOT_LOGGED_ON (0x80041320) — 사용자 미로그온'
|
||
-2147216598 = 'SCHED_E_TASK_ATTEMPTED (0x80041324) — 조건 미충족으로 미실행'
|
||
}
|
||
if ($map.ContainsKey($Code)) { return $map[$Code] }
|
||
return ('알 수 없음 (0x{0:X8}) — net helpmsg {1} 로 확인' -f $Code, $Code)
|
||
}
|
||
|
||
Get-ScheduledTask -TaskPath '\DMF_Crawler\' | Get-ScheduledTaskInfo |
|
||
Select-Object TaskName, LastRunTime,
|
||
@{n='Result'; e={ '0x{0:X}' -f $_.LastTaskResult }},
|
||
@{n='Meaning'; e={ Get-TaskResultText $_.LastTaskResult }}
|
||
```
|
||
|
||
### 18.2 `Microsoft-Windows-TaskScheduler/Operational` 이벤트 ID 전체
|
||
|
||
`StandardTaskEventId` 열거형(dahall/TaskScheduler 소스에서 추출한 완전 목록):
|
||
|
||
| ID | Name | 의미 |
|
||
|----|------|------|
|
||
| 100 | JobStart | Task Scheduler started an instance of a task |
|
||
| **101** | **JobStartFailed** | **Task Scheduler failed to start a task** |
|
||
| 102 | JobSuccess | Task completed successfully |
|
||
| **103** | **JobFailure** | **Task execution failed** |
|
||
| 104 | LogonFailure | Failed to log on the user |
|
||
| 105 | ImpersonationFailure | Failed to impersonate a user |
|
||
| 106 | JobRegistered | User registered a task |
|
||
| 107 | TimeTrigger | Task launched due to time trigger |
|
||
| 108 | EventTrigger | Task launched due to event trigger |
|
||
| 109 | ImmediateTrigger | Task launched due to registration trigger |
|
||
| 110 | Run | Task launched for a user |
|
||
| **111** | **JobTermination** | **Task terminated for exceeding time allocation** (= ExecutionTimeLimit 초과) |
|
||
| 112 | JobNoStartWithoutNetwork | Network unavailable, task not started |
|
||
| 113 | TaskRegisteredWithoutSomeTriggers | Some triggers won't start the task |
|
||
| **114** | **MissedTaskLaunched** | **Missed task started on-demand** (= StartWhenAvailable 동작 확인용) |
|
||
| 115 | TransactionRollbackFailure | Failed to roll back transaction |
|
||
| 116 | TaskRegisteredWithoutCredentials | Credentials couldn't be stored |
|
||
| 117 | IdleTrigger | Task launched due to idle condition |
|
||
| **118** | **BootTrigger** | **Task launched due to system startup** |
|
||
| 119 | LogonTrigger | Task launched due to user logon |
|
||
| 120 | ConsoleConnectTrigger | Task launched on console connection |
|
||
| 121 | ConsoleDisconnectTrigger | Task launched on console disconnection |
|
||
| 122 | RemoteConnectTrigger | Task launched on remote connection |
|
||
| 123 | RemoteDisconnectTrigger | Task launched on remote disconnection |
|
||
| 124 | SessionLockTrigger | Task launched on computer lock |
|
||
| 125 | SessionUnlockTrigger | Task launched on computer unlock |
|
||
| 126 | FailedTaskRestart | Failed task restart attempt |
|
||
| 127 | RejectedTaskRestart | Shutdown race condition restart attempt |
|
||
| 128 | IgnoredTaskStart | Task not launched—end time exceeded |
|
||
| 129 | CreatedTaskProcess | Task launched in new process |
|
||
| 130 | TaskNotRunServiceBusy | Service busy, task not started |
|
||
| 131 | TaskNotStartedTaskQueueQuotaExceeded | Task queue quota exceeded |
|
||
| 132 | TaskQueueQuotaApproaching | Queue quota approaching limit |
|
||
| 133 | TaskNotStartedEngineQuotaExceeded | Engine quota exceeded |
|
||
| 134 | EngineQuotaApproaching | Engine quota approaching limit |
|
||
| 135 | NotStartedWithoutIdle | Machine not idle, task not launched |
|
||
| 140 | TaskUpdated | User updated a task |
|
||
| 141 | TaskDeleted | User deleted a task |
|
||
| 142 | TaskDisabled | User disabled a task |
|
||
| **145** | **TaskStartedOnComputerWakeup** | **Task started on computer wakeup** (= WakeToRun 동작 확인용) |
|
||
| 150 | TaskEventSubscriptionFailed | Failed to subscribe event trigger |
|
||
| 200 | ActionStart | Task action launched |
|
||
| 201 | ActionSuccess | Task action completed successfully |
|
||
| 202 | ActionFailure | Task action failed |
|
||
| **203** | **ActionLaunchFailure** | **Failed to launch action** (경로 오류·권한 오류의 전형) |
|
||
| 204 | EventRenderFailed | Failed to retrieve event values |
|
||
| 205 | EventAggregateFailed | Failed to match event pattern |
|
||
| 301 | SessionExit | Task engine shutting down |
|
||
| 303 | SessionError | Task engine shutting down due to error |
|
||
| 304 | SessionSentJob | Task sent to engine |
|
||
| 305 | SessionSentJobFailed | Task not sent to engine |
|
||
| 306 | SessionFailedToProcessMessage | Thread pool failed to process message |
|
||
| 307 | SessionManagerConnectFailed | Failed to connect to engine process |
|
||
| 308 | SessionConnected | Connected to engine process |
|
||
| 309 | SessionJobsOrphaned | Tasks orphaned during shutdown |
|
||
| 310 | SessionProcessStarted | Engine process started |
|
||
| 311 | SessionProcessLaunchFailed | Failed to start engine process |
|
||
| 312 | SessionWin32ObjectCreated | Win32 job object created |
|
||
| 313 | SessionChannelReady | Channel ready for messages |
|
||
| 314 | SessionIdle | No tasks running, idle timer started |
|
||
| 315 | SessionProcessConnectFailed | Engine failed to connect to service |
|
||
| 316 | SessionMessageSendFailed | Engine failed to send message |
|
||
| 317 | SessionProcessMainStarted | Engine process started |
|
||
| 318 | SessionProcessMainShutdown | Engine process shut down |
|
||
| 319 | SessionProcessReceivedStartJob | Engine received task launch request |
|
||
| 320 | SessionProcessReceivedStopJob | Engine received task stop request |
|
||
| **322** | **NewInstanceIgnored** | **Task instance already running** (= MultipleInstances IgnoreNew 동작) |
|
||
| 323 | RunningInstanceStopped | Running instance stopped for new launch |
|
||
| 324 | NewInstanceQueued | Task queued for later launch |
|
||
| 325 | InstanceQueued | Task queued for immediate launch |
|
||
| **326** | **NoStartOnBatteries** | **Computer on batteries, task not started** (= DisallowStartIfOnBatteries) |
|
||
| **327** | **StoppingOnBatteries** | **Instance stopped, switched to battery** (= StopIfGoingOnBatteries) |
|
||
| 328 | StoppingOffIdle | Instance stopped, computer no longer idle |
|
||
| **329** | **StoppingOnTimeout** | **Instance stopped due to timeout** |
|
||
| 330 | StoppingOnRequest | Instance stopped by user request |
|
||
| **331** | **TimeoutWontWork** | **Timeout mechanism creation failed** |
|
||
| **332** | **NoStartUserNotLoggedOn** | **User not logged on, task not started** |
|
||
| 400 | ScheduleServiceStart | Task Scheduler service started |
|
||
| 401 | ScheduleServiceStartFailed | Service startup failed |
|
||
| 402 | ScheduleServiceStop | Service shutting down |
|
||
| 403 | ScheduleServiceError | Service encountered error |
|
||
| 404 | ScheduleServiceRpcInitError | RPC initialization error |
|
||
| 405 | ScheduleServiceComInitError | COM initialization failed |
|
||
| 406 | ScheduleServiceCredStoreInitError | Credentials store initialization failed |
|
||
| 407 | ScheduleServiceLsaInitError | LSA initialization failed |
|
||
| 408 | ScheduleServiceIdleServiceInitError | Idle detection module initialization failed |
|
||
| 409 | ScheduleServiceTimeChangeInitError | Time change notification initialization failed |
|
||
| 411 | ScheduleServiceTimeChangeSignaled | Service received time change notification |
|
||
| **412** | **ScheduleServiceRunBootJobsFailed** | **Failed to launch boot-triggered tasks** |
|
||
| 700 | CompatStart | Compatibility module started |
|
||
| 701 | CompatStartFailed | Compatibility module startup failed |
|
||
| 702 | CompatStartRpcFailed | RPC server initialization failed |
|
||
| 703 | CompatStartNetscheduleFailed | Net Schedule API initialization failed |
|
||
| 704 | CompatStartLsaFailed | LSA initialization failed |
|
||
| 705 | CompatDirectoryMonitorFailed | Directory monitoring startup failed |
|
||
| 706 | CompatTaskStatusUpdateFailed | Task status update failed |
|
||
| 707 | CompatTaskDeleteFailed | Task deletion failed |
|
||
| 708 | CompatTaskSetSdFailed | Security descriptor assignment failed |
|
||
| 709 | CompatTaskUpdateFailed | Task update failed |
|
||
| 710 | CompatUpgradeStartFailed | Upgrade initialization failed |
|
||
| 711 | CompatUpgradeNsAccountFailed | NetSchedule account upgrade failed |
|
||
| 712 | CompatUpgradeStoreEnumFailed | Store enumeration failed |
|
||
| 713 | CompatUpgradeTaskLoadFailed | Task load for upgrade failed |
|
||
| 714 | CompatUpgradeTaskRegistrationFailed | Task registration during upgrade failed |
|
||
| 715 | CompatUpgradeLsaCleanupFailed | LSA store deletion failed |
|
||
| 716 | CompatUpgradeFailed | Task upgrade failed |
|
||
| 717 | CompatUpgradeNeedNotDetermined | Upgrade need determination failed |
|
||
| 718 | VistaBeta2CredstoreUpgradeFailed | Beta 2 credential store upgrade failed |
|
||
| -2 | Unknown | Unknown/undefined value |
|
||
|
||
> ⚠️ 조사한 다른 출처는 채널이 기록하는 이벤트를 "100, 102, 103, 106, 107, 108, 110, 118, 119, 129, 140, 141, 200, 201" 로, 그리고 다른 곳은 "101 = 작업 시작 실패, 102 = 정상 완료, 107 = 트리거로 실행" 으로 설명한다. 위 표(소스 코드 유래)가 가장 완전하다.
|
||
|
||
**진단용 쿼리 모음**:
|
||
```powershell
|
||
# 우리 작업의 최근 실패만
|
||
Get-WinEvent -FilterHashtable @{
|
||
LogName = 'Microsoft-Windows-TaskScheduler/Operational'
|
||
Id = 101,103,111,203,326,327,329,331,332,412
|
||
StartTime = (Get-Date).AddDays(-7)
|
||
} -ErrorAction SilentlyContinue |
|
||
Where-Object { $_.Message -match 'DMF_Crawler' } |
|
||
Select-Object TimeCreated, Id, Message | Format-List
|
||
|
||
# StartWhenAvailable 이 실제로 동작했는지 (놓친 작업 실행)
|
||
Get-WinEvent -FilterHashtable @{ LogName='Microsoft-Windows-TaskScheduler/Operational'; Id=114 } -MaxEvents 20 -ErrorAction SilentlyContinue
|
||
|
||
# WakeToRun 이 동작했는지
|
||
Get-WinEvent -FilterHashtable @{ LogName='Microsoft-Windows-TaskScheduler/Operational'; Id=145 } -MaxEvents 20 -ErrorAction SilentlyContinue
|
||
|
||
# 부팅 트리거가 발화했는지 (Fast Startup 검증)
|
||
Get-WinEvent -FilterHashtable @{ LogName='Microsoft-Windows-TaskScheduler/Operational'; Id=118 } -MaxEvents 20 -ErrorAction SilentlyContinue
|
||
```
|
||
|
||
---
|
||
|
||
## 19. AI 에이전트 CLI headless 실행 통합
|
||
|
||
> **이 프로젝트의 AI CLI 는 Google Antigravity CLI (`agy`) 를 headless(`agy -p`) 로 사용한다 (Claude Code 가 아니다).**
|
||
> 아래 §19.1 은 원 조사에서 확인된 **Claude Code `claude -p` 의 공식 명세**로, `agy -p` 의 동작을 설계할 때 참고할 **레퍼런스 모델**로만 남긴다. `agy` 자체의 플래그 명세는 이 조사 범위 밖이며 **미검증**이다(부록 B-9).
|
||
|
||
### 19.1 참고 — Claude Code headless 모드 공식 명세
|
||
|
||
**기본 사용**(공식 문서 원문):
|
||
```bash
|
||
claude -p "Find and fix the bug in auth.py" --allowedTools "Read,Edit,Bash"
|
||
claude -p "What does the auth module do?"
|
||
```
|
||
|
||
> "Add the `-p` (or `--print`) flag to any `claude` command to run it non-interactively."
|
||
> "Claude Code exits with **code 0 on success and a non-zero code when the run fails**, so your scripts can branch on the exit status. If you pass an invalid flag, Claude Code reports the error to **stderr** before the run starts. When a failure happens inside the run, such as missing authentication, Claude Code prints the failure as the result on **stdout**."
|
||
|
||
**`--bare` 모드 (CI/스케줄 실행에 중요)**:
|
||
> "Add `--bare` to reduce startup time by skipping auto-discovery of hooks, skills, custom commands, subagents, plugins, MCP servers, auto memory, and CLAUDE.md."
|
||
> "Bare mode is useful for CI and scripts where you need the same result on every machine."
|
||
> "Without `--bare`, a `-p` session runs the hooks in a project's `.claude/settings.json` and connects the servers in its `.mcp.json`, **even in a folder you've never trusted.** A `-p` session shows no workspace trust dialog and no per-server approval prompt."
|
||
> "In bare mode, Claude Code never reads OAuth credentials or the system keychain. For the Anthropic API, set `ANTHROPIC_API_KEY` in the environment"
|
||
> "`--bare` is the recommended mode for scripted and SDK calls, and will become the default for `-p` in a future release."
|
||
|
||
```bash
|
||
claude --bare -p "Summarize README.md" --allowedTools "Read"
|
||
```
|
||
|
||
bare 모드에서 컨텍스트를 주입하는 플래그:
|
||
|
||
| 로드 대상 | 플래그 |
|
||
|----------|-------|
|
||
| System prompt additions | `--append-system-prompt`, `--append-system-prompt-file` |
|
||
| Settings | `--settings <file-or-json>` |
|
||
| MCP servers | `--mcp-config <file-or-json>` |
|
||
| Custom agents | `--agents <json>` |
|
||
| A plugin | `--plugin-dir <path>`, `--plugin-url <url>` |
|
||
|
||
**출력 형식 `--output-format`**:
|
||
- `text` (기본): plain text output
|
||
- `json`: "structured JSON with result, session ID, and metadata"
|
||
- `stream-json`: "newline-delimited JSON for real-time streaming"
|
||
|
||
```bash
|
||
claude -p "Summarize this project" --output-format json
|
||
claude -p "Extract the main function names from auth.py" \
|
||
--output-format json \
|
||
--json-schema '{"type":"object","properties":{"functions":{"type":"array","items":{"type":"string"}}},"required":["functions"]}'
|
||
```
|
||
> "The response includes metadata about the request (session ID, usage, etc.) with the structured output in the **`structured_output`** field."
|
||
> "If the value isn't a valid JSON Schema, `claude` exits with `Error: --json-schema is not a valid JSON Schema` followed by the validator's diagnostic."
|
||
> "With `--output-format json`, the response payload includes **`total_cost_usd`** and a per-model cost breakdown ... Both figures are **client-side estimates** and can differ from your actual bill."
|
||
|
||
jq 로 파싱:
|
||
```bash
|
||
claude -p "Summarize this project" --output-format json | jq -r '.result'
|
||
claude -p "Extract function names from auth.py" --output-format json \
|
||
--json-schema '{...}' | jq '.structured_output'
|
||
```
|
||
|
||
**스트리밍**:
|
||
```bash
|
||
claude -p "Explain recursion" --output-format stream-json --verbose --include-partial-messages
|
||
claude -p "Write a poem" --output-format stream-json --verbose --include-partial-messages | \
|
||
jq -rj 'select(.type == "stream_event" and .event.delta.type? == "text_delta") | .event.delta.text'
|
||
```
|
||
> "The last line of the stream is a **`result`** message with the final response text, cost, and session metadata."
|
||
|
||
**stdin 파이프**:
|
||
```bash
|
||
cat build-error.txt | claude -p 'concisely explain the root cause of this build error' > output.txt
|
||
```
|
||
> "Piped stdin is capped at **10MB**. If you exceed the cap, Claude Code exits with a clear error and a non-zero status. To work with larger inputs, write the content to a file and reference the file path in your prompt instead of piping it."
|
||
> "If Claude Code can't read stdin, for example because the process that started it disconnected its end, Claude Code prints a warning to stderr and continues with the prompt from the command line. **Before v2.1.211, an unreadable stdin on Windows crashed the session or made it exit silently with no output.**" ← **Windows 스케줄 실행에서 특히 중요한 항목**
|
||
|
||
**SIGTERM 처리**:
|
||
> "If you stop a `claude -p` run with SIGTERM, for example with `kill` or from a process supervisor, Claude Code **exits with code 143**. Claude Code leaves the turn that was in progress unfinished and records no result for it. **To end the turn instead, send SIGINT**, or call the Agent SDK's `interrupt()`, before you stop the process."
|
||
> "On SIGTERM, Claude Code terminates the process tree of any Bash command that is still running. Claude Code then runs `SessionEnd` hooks and exits."
|
||
|
||
→ **작업 스케줄러가 `ExecutionTimeLimit` 초과로 작업을 종료할 때 이 경로를 탄다.** exit 143 을 실패로 처리하되 "시간 초과" 로 구분하라.
|
||
|
||
**백그라운드 작업 종료 처리**:
|
||
> "If Claude starts a background Bash task during a `claude -p` run ... that shell is terminated about **five seconds** after Claude has returned its final result and stdin has closed."
|
||
> "Background subagents and workflows are exempt from the five-second grace ... From v2.1.182, that wait is capped at **ten minutes** of continuous idle waiting by default ... Adjust the cap with `CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS`, or set it to `0` to wait without a limit."
|
||
|
||
**스트림 배출 지연**:
|
||
> "If your consumer reads the stream slowly, Claude Code waits for the queued output to drain before exiting, scaling the wait with how much is still queued, **capped at 30 seconds**. Before v2.1.214 the exit wait was capped at about two seconds, which could cut off the end of a large response."
|
||
|
||
**서브에이전트 메시지 추적**: `--forward-subagent-text` 또는 `CLAUDE_CODE_FORWARD_SUBAGENT_TEXT` 로 서브에이전트 텍스트/사고 블록까지 스트림에 포함(요구: v2.1.211 이상).
|
||
|
||
**`-p` 와 조합 불가한 플래그**: "Claude Code rejects `--bg`, and rejects `--cloud` with a task description, with an error naming the conflict; `--cloud` with a session ID and `-p` instead queues a message into that cloud session and exits."
|
||
|
||
**npm 스크립트 예시(Windows 이식성 고려, 원문)**:
|
||
```json
|
||
{
|
||
"scripts": {
|
||
"lint:claude": "git diff main | claude -p \"you are a typo linter. for each typo in this diff, report filename:line on one line and the issue on the next. return nothing else.\""
|
||
}
|
||
}
|
||
```
|
||
|
||
### 19.2 `agy -p` 통합 설계 (이 프로젝트의 실제 구현)
|
||
|
||
**설계 원칙 — AI CLI 를 감싸는 방식**:
|
||
|
||
1. **AI CLI 호출은 크롤링 성공 이후의 별도 단계로 둔다.** AI 요약이 실패해도 xlsx 리포트는 나와야 한다.
|
||
2. **타임아웃을 반드시 건다.** AI CLI 가 무한 대기하면 `ExecutionTimeLimit` 까지 작업이 붙잡힌다.
|
||
3. **구조화 출력(JSON)을 요구하고, 파싱 실패를 허용한다.**
|
||
4. **`agy` 부재 시 자동 부트스트랩**하되, 부트스트랩 자체를 실패로 간주하지 않고 "AI 요약 없이 진행" 으로 강등한다.
|
||
|
||
**`scripts\invoke-agy.ps1`** (완결):
|
||
|
||
```powershell
|
||
<#
|
||
.SYNOPSIS
|
||
Google Antigravity CLI(agy)를 headless(-p) 로 호출해 DMF 변동사항 요약을 얻는다.
|
||
.DESCRIPTION
|
||
- agy 가 없으면 부트스트랩을 시도하고, 실패하면 요약 없이 진행(비치명적)
|
||
- 타임아웃(기본 10분) 안에 끝나지 않으면 강제 종료
|
||
- stdout/stderr 를 로그에 남기고, JSON 파싱을 시도
|
||
.OUTPUTS
|
||
성공: 요약 문자열 / 실패: $null
|
||
.NOTES
|
||
agy 의 정확한 플래그는 이 조사에서 검증되지 않았다(부록 B-9).
|
||
아래 -p / --output-format 은 Claude Code 의 관례를 따른 가정이며,
|
||
실제 배포 전에 `agy --help` 로 반드시 확인하고 수정할 것.
|
||
#>
|
||
[CmdletBinding()]
|
||
param(
|
||
[Parameter(Mandatory)][string]$PromptFile, # 프롬프트 텍스트 파일
|
||
[Parameter(Mandatory)][string]$InputFile, # 변동 데이터(JSON/CSV)
|
||
[string]$Root = 'D:\workspace\DMF_Crawler',
|
||
[int]$TimeoutSec = 600,
|
||
[switch]$AllowPrompt # agy 미설치 시 Windows 창을 띄워 설치 안내
|
||
)
|
||
|
||
$ErrorActionPreference = 'Continue'
|
||
$Logs = Join-Path $Root 'logs'
|
||
$Log = Join-Path $Logs ('agy-{0}.log' -f ($env:DMF_RUN_ID ?? (Get-Date -Format 'yyyyMMdd-HHmmss')))
|
||
|
||
function W { param($m) Add-Content -Path $Log -Value ("{0} {1}" -f (Get-Date -Format 'HH:mm:ss'), $m) -Encoding UTF8 }
|
||
|
||
# ---------------------------------------------------------------- agy 탐색
|
||
$agy = (Get-Command 'agy' -ErrorAction SilentlyContinue)?.Source
|
||
if (-not $agy) {
|
||
$candidates = @(
|
||
(Join-Path $env:LOCALAPPDATA 'Programs\antigravity\agy.exe'),
|
||
(Join-Path $env:ProgramFiles 'Antigravity\agy.exe'),
|
||
(Join-Path $env:APPDATA 'npm\agy.cmd')
|
||
)
|
||
$agy = $candidates | Where-Object { Test-Path $_ } | Select-Object -First 1
|
||
}
|
||
|
||
if (-not $agy) {
|
||
W '[WARN] agy 를 찾을 수 없습니다. 부트스트랩을 시도합니다.'
|
||
try {
|
||
# 부트스트랩 방법은 배포 형태에 따라 다르다. npm 배포를 가정한 예시:
|
||
& npm install -g @google/antigravity-cli 2>&1 | ForEach-Object { W $_ }
|
||
$agy = (Get-Command 'agy' -ErrorAction SilentlyContinue)?.Source
|
||
} catch {
|
||
W "[WARN] 부트스트랩 실패: $($_.Exception.Message)"
|
||
}
|
||
}
|
||
|
||
if (-not $agy) {
|
||
W '[ERROR] agy 사용 불가 — AI 요약 없이 진행합니다.'
|
||
if ($AllowPrompt) {
|
||
# 대화형 세션에서만 의미가 있다. 비대화형이면 조용히 실패한다.
|
||
try {
|
||
Add-Type -AssemblyName System.Windows.Forms -ErrorAction Stop
|
||
[System.Windows.Forms.MessageBox]::Show(
|
||
"Antigravity CLI(agy)가 설치되어 있지 않습니다.`n" +
|
||
"AI 요약 없이 리포트를 생성했습니다.`n`n" +
|
||
"설치 후 다시 실행하세요.",
|
||
'DMF Crawler', 'OK', 'Warning') | Out-Null
|
||
} catch { }
|
||
}
|
||
return $null
|
||
}
|
||
|
||
W "[INFO] agy: $agy"
|
||
|
||
# ---------------------------------------------------------------- 호출
|
||
$prompt = Get-Content $PromptFile -Raw -Encoding UTF8
|
||
$outFile = Join-Path $Logs ('agy-out-{0}.json' -f ($env:DMF_RUN_ID ?? 'manual'))
|
||
$errFile = Join-Path $Logs ('agy-err-{0}.txt' -f ($env:DMF_RUN_ID ?? 'manual'))
|
||
|
||
# 프롬프트에 입력 파일 경로를 넣는다(파이프 대신 파일 참조 — 대용량 안전)
|
||
$fullPrompt = "$prompt`n`n입력 데이터 파일: $InputFile"
|
||
$tmpPrompt = Join-Path $env:TEMP ("agy-prompt-{0}.txt" -f ([guid]::NewGuid().ToString('N')))
|
||
Set-Content -Path $tmpPrompt -Value $fullPrompt -Encoding UTF8
|
||
|
||
$psi = New-Object System.Diagnostics.ProcessStartInfo
|
||
$psi.FileName = $agy
|
||
# ⚠️ 아래 인자는 가정이다. 배포 전 `agy --help` 로 검증할 것.
|
||
$psi.Arguments = '-p --output-format json'
|
||
$psi.RedirectStandardInput = $true
|
||
$psi.RedirectStandardOutput = $true
|
||
$psi.RedirectStandardError = $true
|
||
$psi.UseShellExecute = $false
|
||
$psi.CreateNoWindow = $true
|
||
$psi.WorkingDirectory = $Root
|
||
$psi.StandardOutputEncoding = [Text.Encoding]::UTF8
|
||
$psi.StandardErrorEncoding = [Text.Encoding]::UTF8
|
||
|
||
$proc = [System.Diagnostics.Process]::Start($psi)
|
||
$proc.StandardInput.Write($fullPrompt)
|
||
$proc.StandardInput.Close()
|
||
|
||
$stdoutTask = $proc.StandardOutput.ReadToEndAsync()
|
||
$stderrTask = $proc.StandardError.ReadToEndAsync()
|
||
|
||
if (-not $proc.WaitForExit($TimeoutSec * 1000)) {
|
||
W "[ERROR] agy 타임아웃(${TimeoutSec}초) — 프로세스 트리를 종료합니다."
|
||
try { $proc.Kill($true) } catch { try { $proc.Kill() } catch { } }
|
||
Remove-Item $tmpPrompt -Force -ErrorAction SilentlyContinue
|
||
return $null
|
||
}
|
||
|
||
$stdout = $stdoutTask.Result
|
||
$stderr = $stderrTask.Result
|
||
$exit = $proc.ExitCode
|
||
|
||
Set-Content -Path $outFile -Value $stdout -Encoding UTF8
|
||
if ($stderr) { Set-Content -Path $errFile -Value $stderr -Encoding UTF8 }
|
||
Remove-Item $tmpPrompt -Force -ErrorAction SilentlyContinue
|
||
|
||
W "[INFO] agy exit=$exit stdout=$($stdout.Length)B stderr=$($stderr.Length)B"
|
||
|
||
# 143 = SIGTERM (Claude Code 규약). agy 도 유사할 수 있으므로 구분해 기록한다.
|
||
if ($exit -eq 143) { W '[WARN] agy 가 SIGTERM 으로 종료되었습니다(외부 종료/타임아웃).'; return $null }
|
||
if ($exit -ne 0) { W "[ERROR] agy 비정상 종료 exit=$exit"; return $null }
|
||
|
||
# ---------------------------------------------------------------- 파싱
|
||
try {
|
||
$obj = $stdout | ConvertFrom-Json -ErrorAction Stop
|
||
if ($obj.PSObject.Properties.Name -contains 'structured_output') { return $obj.structured_output }
|
||
if ($obj.PSObject.Properties.Name -contains 'result') { return $obj.result }
|
||
return $obj
|
||
} catch {
|
||
W "[WARN] JSON 파싱 실패 — 원문을 그대로 사용합니다: $($_.Exception.Message)"
|
||
return $stdout
|
||
}
|
||
```
|
||
|
||
**`run-daily.ps1` 에서의 호출 위치**: 크롤링 성공 → xlsx 생성 **이전** 또는 **이후**. 실패해도 리포트는 나와야 하므로 반드시 try/catch 로 감싸고 `$null` 을 허용한다.
|
||
|
||
```powershell
|
||
# run-daily.ps1 안, 크롤링 성공 직후
|
||
$aiSummary = $null
|
||
try {
|
||
$aiSummary = & (Join-Path $Scripts 'invoke-agy.ps1') `
|
||
-PromptFile (Join-Path $Root 'prompts\dmf-summary.md') `
|
||
-InputFile (Join-Path $State "summary-$RunId.json") `
|
||
-Root $Root -TimeoutSec 600
|
||
} catch {
|
||
Write-Log 'WARN' "AI 요약 실패(비치명적): $($_.Exception.Message)"
|
||
}
|
||
if ($aiSummary) { Write-Log 'INFO' 'AI 요약 생성 완료' }
|
||
else { Write-Log 'WARN' 'AI 요약 없이 리포트를 생성합니다' }
|
||
```
|
||
|
||
### 19.3 AI CLI 를 스케줄 실행할 때의 함정
|
||
|
||
| 함정 | 증상 | 대응 |
|
||
|------|------|------|
|
||
| **인증 자격증명이 비대화형 세션에서 안 읽힘** | 인증 실패 결과가 stdout 으로 출력됨 | 환경변수(API 키)를 **시스템 환경변수**로 등록. S4U 라면 DPAPI/키체인 의존 금지(§4.3) |
|
||
| **stdin 이 닫혀 있음** | Windows 에서 세션 크래시 또는 무출력 종료(구버전) | 프롬프트를 **인자나 파일로** 넘기고 stdin 파이프를 피한다 |
|
||
| **무한 대기** | 작업이 `ExecutionTimeLimit` 까지 잡혀 있음 | 스크립트 레벨 타임아웃 + `Kill($true)` (프로세스 트리 종료) |
|
||
| **터미널 색상 이스케이프가 로그에 섞임** | 로그 판독 불가 | `NO_COLOR=1`, `TERM=dumb` 환경변수 설정 |
|
||
| **한글 깨짐** | mojibake | `$psi.StandardOutputEncoding = [Text.Encoding]::UTF8`, `PYTHONIOENCODING=utf-8`, 콘솔 `chcp 65001` |
|
||
| **프로젝트 훅/MCP 서버가 자동 로드됨** | 예측 불가한 동작·지연 | `--bare` 에 해당하는 옵션이 있으면 사용 |
|
||
| **비용 폭주** | 매일 자동 실행이므로 누적 | JSON 출력의 비용 필드를 로그에 남기고 임계치 알림 |
|
||
|
||
---
|
||
|
||
## 20. 최종 배치 절차 체크리스트
|
||
|
||
### 20.1 사전 점검 스크립트 `scripts\preflight.ps1`
|
||
|
||
```powershell
|
||
<#
|
||
.SYNOPSIS
|
||
DMF_Crawler 운영 환경을 점검한다. 설치 전과 정기 점검에 사용.
|
||
#>
|
||
param([string]$Root = 'D:\workspace\DMF_Crawler')
|
||
|
||
$ok = 0; $warn = 0; $fail = 0
|
||
function Check {
|
||
param([string]$Name, [scriptblock]$Test, [string]$Fix = '')
|
||
try {
|
||
$r = & $Test
|
||
if ($r -eq $true) { Write-Host "[ OK ] $Name" -ForegroundColor Green; $script:ok++ }
|
||
elseif ($r -eq 'warn') { Write-Host "[WARN] $Name" -ForegroundColor Yellow; $script:warn++; if ($Fix) { Write-Host " → $Fix" } }
|
||
else { Write-Host "[FAIL] $Name" -ForegroundColor Red; $script:fail++; if ($Fix) { Write-Host " → $Fix" } }
|
||
} catch {
|
||
Write-Host "[FAIL] $Name : $($_.Exception.Message)" -ForegroundColor Red; $script:fail++
|
||
if ($Fix) { Write-Host " → $Fix" }
|
||
}
|
||
}
|
||
|
||
Write-Host "=== DMF_Crawler 환경 점검 ===" -ForegroundColor Cyan
|
||
|
||
Check 'OS 가 Windows 10/11 인가' { [Environment]::OSVersion.Version.Major -ge 10 }
|
||
|
||
Check '관리자 권한으로 실행 중인가' {
|
||
([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()
|
||
).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
|
||
} '관리자 권한 PowerShell 로 다시 실행하세요.'
|
||
|
||
Check '프로젝트 루트 존재' { Test-Path $Root } "New-Item -ItemType Directory $Root"
|
||
|
||
Check 'PowerShell 5.1 존재' {
|
||
Test-Path "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe"
|
||
}
|
||
|
||
Check 'Task Scheduler 서비스 실행 중' {
|
||
(Get-Service -Name 'Schedule').Status -eq 'Running'
|
||
} 'Start-Service Schedule'
|
||
|
||
Check 'TaskScheduler/Operational 채널 활성' {
|
||
$l = & wevtutil.exe get-log 'Microsoft-Windows-TaskScheduler/Operational' 2>$null
|
||
if ($l -match 'enabled:\s*true') { $true } else { 'warn' }
|
||
} 'wevtutil set-log "Microsoft-Windows-TaskScheduler/Operational" /enabled:true /quiet'
|
||
|
||
Check '이벤트 소스 DMFCrawler 등록' {
|
||
[System.Diagnostics.EventLog]::SourceExists('DMFCrawler')
|
||
} "New-EventLog -LogName Application -Source DMFCrawler"
|
||
|
||
Check '계정에 Log on as a batch job 권한' {
|
||
# 정확한 확인은 secedit export 가 필요. 여기서는 Administrators 멤버 여부로 근사한다.
|
||
$isAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()
|
||
).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
|
||
if ($isAdmin) { $true } else { 'warn' }
|
||
} 'secpol.msc → 로컬 정책 → 사용자 권한 할당 → 배치 작업으로 로그온'
|
||
|
||
Check '시간대가 Korea Standard Time' {
|
||
if ((Get-TimeZone).Id -eq 'Korea Standard Time') { $true } else { 'warn' }
|
||
} "Set-TimeZone -Id 'Korea Standard Time'"
|
||
|
||
Check '시간 동기화 정상' {
|
||
$s = & w32tm /query /status 2>$null
|
||
if ($LASTEXITCODE -eq 0) { $true } else { 'warn' }
|
||
} 'w32tm /resync'
|
||
|
||
Check 'AC 전원에서 절전 안 함' {
|
||
$out = & powercfg /query SCHEME_CURRENT SUB_SLEEP STANDBYIDLE 2>$null
|
||
if ($out -match 'AC Power Setting Index:\s*0x00000000') { $true } else { 'warn' }
|
||
} 'powercfg /change standby-timeout-ac 0'
|
||
|
||
Check 'Windows Update 활성 시간에 06:00 포함' {
|
||
$p = 'HKLM:\Software\Policies\Microsoft\Windows\WindowsUpdate'
|
||
if (Test-Path $p) {
|
||
$s = (Get-ItemProperty $p -Name ActiveHoursStart -ErrorAction SilentlyContinue).ActiveHoursStart
|
||
$e = (Get-ItemProperty $p -Name ActiveHoursEnd -ErrorAction SilentlyContinue).ActiveHoursEnd
|
||
if ($null -ne $s -and $s -le 6 -and $e -ge 6) { $true } else { 'warn' }
|
||
} else { 'warn' }
|
||
} '.\scripts\set-active-hours.ps1 실행'
|
||
|
||
Check 'BitLocker 가 TPM+PIN 이 아님' {
|
||
try {
|
||
$v = Get-BitLockerVolume -MountPoint 'C:' -ErrorAction Stop
|
||
if ($v.ProtectionStatus -eq 'Off') { $true }
|
||
elseif ($v.KeyProtector.KeyProtectorType -contains 'TpmPin') { 'warn' }
|
||
else { $true }
|
||
} catch { $true } # BitLocker 미사용
|
||
} 'TPM+PIN 이면 무인 재부팅 후 자동 복구가 불가합니다(§14.3)'
|
||
|
||
Check 'BurntToast 모듈 설치' {
|
||
if (Get-Module -ListAvailable -Name BurntToast) { $true } else { 'warn' }
|
||
} 'Install-Module -Name BurntToast -Scope CurrentUser -Force'
|
||
|
||
Check 'msg.exe 존재 (Pro/Enterprise)' {
|
||
if (Test-Path "$env:SystemRoot\System32\msg.exe") { $true } else { 'warn' }
|
||
} 'Home 에디션에는 msg.exe 가 없습니다. 웹훅 폴백에 의존하세요.'
|
||
|
||
Check 'Python 가상환경' {
|
||
Test-Path (Join-Path $Root '.venv\Scripts\python.exe')
|
||
} "python -m venv $Root\.venv"
|
||
|
||
Check 'PLAYWRIGHT_BROWSERS_PATH 시스템 변수 설정' {
|
||
$v = [Environment]::GetEnvironmentVariable('PLAYWRIGHT_BROWSERS_PATH', 'Machine')
|
||
if ($v) { $true } else { 'warn' }
|
||
} "[Environment]::SetEnvironmentVariable('PLAYWRIGHT_BROWSERS_PATH','$Root\.playwright-browsers','Machine')"
|
||
|
||
Check 'Playwright 브라우저 설치됨' {
|
||
$p = [Environment]::GetEnvironmentVariable('PLAYWRIGHT_BROWSERS_PATH','Machine')
|
||
if ($p -and (Test-Path $p) -and (Get-ChildItem $p -Directory -ErrorAction SilentlyContinue)) { $true } else { 'warn' }
|
||
} 'python -m playwright install chromium'
|
||
|
||
Check '네트워크로 대상 사이트 접근 가능' {
|
||
Test-NetConnection -ComputerName 'nedrug.mfds.go.kr' -Port 443 -InformationLevel Quiet -WarningAction SilentlyContinue
|
||
} '방화벽/프록시 확인'
|
||
|
||
Check 'config\ops.config.ps1 존재' {
|
||
Test-Path (Join-Path $Root 'config\ops.config.ps1')
|
||
} '웹훅/healthchecks 설정 파일을 만드세요(§17.1)'
|
||
|
||
Check 'ops.config.ps1 이 .gitignore 에 있음' {
|
||
$gi = Join-Path $Root '.gitignore'
|
||
if ((Test-Path $gi) -and ((Get-Content $gi -Raw) -match 'ops\.config\.ps1')) { $true } else { 'warn' }
|
||
} '.gitignore 에 config/ops.config.ps1 을 추가하세요 (웹훅 URL 유출 방지)'
|
||
|
||
Check 'dmfcrawler: 프로토콜 등록' {
|
||
if (Test-Path 'HKCU:\Software\Classes\dmfcrawler') { $true } else { 'warn' }
|
||
} '.\scripts\register-protocol.ps1 실행 (토스트 재실행 버튼용)'
|
||
|
||
Write-Host ""
|
||
Write-Host ("=== 결과: OK {0} / WARN {1} / FAIL {2} ===" -f $ok, $warn, $fail) -ForegroundColor Cyan
|
||
if ($fail -gt 0) { exit 1 }
|
||
exit 0
|
||
```
|
||
|
||
### 20.2 설치 순서
|
||
|
||
```powershell
|
||
# 0) 관리자 권한 PowerShell 을 연다.
|
||
|
||
# 1) 프로젝트 배치
|
||
cd D:\workspace\DMF_Crawler
|
||
|
||
# 2) Python 환경
|
||
python -m venv .venv
|
||
.\.venv\Scripts\Activate.ps1
|
||
pip install -r requirements.txt
|
||
|
||
# 3) Playwright 브라우저 경로 고정 후 설치
|
||
[Environment]::SetEnvironmentVariable('PLAYWRIGHT_BROWSERS_PATH','D:\workspace\DMF_Crawler\.playwright-browsers','Machine')
|
||
$env:PLAYWRIGHT_BROWSERS_PATH = 'D:\workspace\DMF_Crawler\.playwright-browsers'
|
||
python -m playwright install chromium
|
||
|
||
# 4) 알림 모듈
|
||
Install-Module -Name BurntToast -Scope CurrentUser -Force
|
||
|
||
# 5) 운영 설정 파일 작성 (Git 제외)
|
||
# config\ops.config.ps1 에 웹훅 URL, healthchecks UUID, 담당자 기입
|
||
|
||
# 6) 전원 설정
|
||
powercfg /change standby-timeout-ac 0
|
||
powercfg /change hibernate-timeout-ac 0
|
||
powercfg /change monitor-timeout-ac 10
|
||
powercfg /change disk-timeout-ac 0
|
||
|
||
# 7) Windows Update 활성 시간
|
||
.\scripts\set-active-hours.ps1
|
||
|
||
# 8) 커스텀 프로토콜 등록 (토스트 "재실행" 버튼용)
|
||
.\scripts\register-protocol.ps1
|
||
|
||
# 9) 작업 등록
|
||
.\scripts\register-tasks.ps1 -LogonMode S4U
|
||
# (keyring/UNC 가 필요하면 -LogonMode Password)
|
||
|
||
# 10) 사전 점검
|
||
.\scripts\preflight.ps1
|
||
|
||
# 11) 알림 경로 테스트
|
||
.\scripts\notify.ps1 -TestMode
|
||
|
||
# 12) 실제 실행 테스트 (수동 트리거)
|
||
Start-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Daily'
|
||
Get-ScheduledTask -TaskPath '\DMF_Crawler\' | Get-ScheduledTaskInfo
|
||
|
||
# 13) 워치독 테스트
|
||
.\scripts\watchdog.ps1
|
||
|
||
# 14) 실패 시나리오 테스트 (§20.3)
|
||
```
|
||
|
||
### 20.3 반드시 실행해야 할 실패 시나리오 테스트
|
||
|
||
| # | 시나리오 | 방법 | 기대 결과 |
|
||
|---|---------|------|----------|
|
||
| 1 | 크롤러가 exit 1 로 실패 | 크롤러에 `--simulate-failure` 플래그 추가 또는 임시로 `exit 1` | heartbeat status=failed, 이벤트 1001, 알림 큐 생성, 토스트, 웹훅 |
|
||
| 2 | 로그온 전 실패 | 로그아웃 상태에서 작업 강제 실행 | 토스트는 안 뜨지만 웹훅은 도착. 로그인 시 큐에서 토스트 표시 |
|
||
| 3 | heartbeat 미갱신 | `state\heartbeat.json` 의 `finished_at` 을 3일 전으로 수정 → `watchdog.ps1` 실행 | 이벤트 1010, 알림, 웹훅 |
|
||
| 4 | 작업 비활성화 | `Disable-ScheduledTask` → 워치독 실행 | "Disabled 상태" 감지 |
|
||
| 5 | 네트워크 차단 | 방화벽으로 outbound 443 차단 → 작업 실행 | 네트워크 대기 5분 후 실패, 알림 |
|
||
| 6 | 시간 초과 | `ExecutionTimeLimit` 를 `PT1M` 으로 임시 변경 후 긴 작업 실행 | 이벤트 111/329, LastTaskResult=0x41306 |
|
||
| 7 | 재부팅 | 06:00 직전에 PC 종료 → 07:00 에 부팅 | `StartWhenAvailable` 로 즉시 실행(이벤트 114) |
|
||
| 8 | 계정 암호 변경 (Password 모드만) | 암호 변경 후 작업 실행 | `0x8004130F` — 작업 재등록 필요. 이 절차를 운영 문서에 적을 것 |
|
||
| 9 | healthchecks 미도달 | 크롤러 작업을 하루 비활성화 | Period+Grace 경과 후 healthchecks 알림 도착 |
|
||
| 10 | 디스크 가득 참 | 임시로 큰 파일 생성 | 명확한 오류 메시지 + 알림 |
|
||
|
||
### 20.4 운영 중 정기 점검 (월 1회)
|
||
|
||
```powershell
|
||
# 1) 작업 상태
|
||
Get-ScheduledTask -TaskPath '\DMF_Crawler\' | Get-ScheduledTaskInfo |
|
||
Format-Table TaskName, LastRunTime, LastTaskResult, NextRunTime -AutoSize
|
||
|
||
# 2) 최근 30일 실패 이력
|
||
Get-WinEvent -FilterHashtable @{ LogName='Application'; ProviderName='DMFCrawler'; Level=2 } -MaxEvents 50 -ErrorAction SilentlyContinue |
|
||
Select-Object TimeCreated, Id, Message | Format-List
|
||
|
||
# 3) heartbeat 신선도
|
||
Get-Content 'D:\workspace\DMF_Crawler\state\heartbeat.json' -Raw | ConvertFrom-Json |
|
||
Select-Object run_id, status, finished_at, duration_sec, exit_code
|
||
|
||
# 4) 디스크 사용량
|
||
Get-ChildItem 'D:\workspace\DMF_Crawler' -Recurse -File |
|
||
Group-Object { $_.Directory.Name } |
|
||
Select-Object Name, Count, @{n='MB'; e={ [math]::Round(($_.Group | Measure-Object Length -Sum).Sum / 1MB, 1) }} |
|
||
Sort-Object MB -Descending
|
||
|
||
# 5) 사전 점검 재실행
|
||
.\scripts\preflight.ps1
|
||
|
||
# 6) 활성 시간 정책이 유지되는지
|
||
Get-ItemProperty 'HKLM:\Software\Policies\Microsoft\Windows\WindowsUpdate' |
|
||
Select-Object SetActiveHours, ActiveHoursStart, ActiveHoursEnd
|
||
```
|
||
|
||
---
|
||
|
||
## 부록 A. 출처 목록
|
||
|
||
확인 여부 표기:
|
||
- **F** = WebFetch 로 실제 페이지를 열어 내용을 확인함 (verified)
|
||
- **S** = 검색 결과 목록에만 등장 (제목/URL 은 확인, 본문 미확인)
|
||
- **X** = 열었으나 실패 (404 / 타임아웃 등)
|
||
|
||
### A.1 Microsoft Learn — PowerShell ScheduledTasks 모듈
|
||
|
||
| 제목 | URL | 확인 |
|
||
|------|-----|------|
|
||
| New-ScheduledTaskSettingsSet (ScheduledTasks) | https://learn.microsoft.com/en-us/powershell/module/scheduledtasks/new-scheduledtasksettingsset?view=windowsserver2025-ps | **F** |
|
||
| New-ScheduledTaskPrincipal (ScheduledTasks) | https://learn.microsoft.com/en-us/powershell/module/scheduledtasks/new-scheduledtaskprincipal?view=windowsserver2025-ps | **F** |
|
||
| New-ScheduledTaskTrigger (ScheduledTasks) | https://learn.microsoft.com/en-us/powershell/module/scheduledtasks/new-scheduledtasktrigger?view=windowsserver2025-ps | **F** |
|
||
| Register-ScheduledTask (ScheduledTasks) | https://learn.microsoft.com/en-us/powershell/module/scheduledtasks/register-scheduledtask?view=windowsserver2025-ps | **F** |
|
||
| New-ScheduledTask (ScheduledTasks) | https://learn.microsoft.com/en-us/powershell/module/scheduledtasks/new-scheduledtask?view=windowsserver2025-ps | S |
|
||
| New-ScheduledTaskAction (ScheduledTasks) | https://learn.microsoft.com/en-us/powershell/module/scheduledtasks/new-scheduledtaskaction?view=windowsserver2025-ps | S |
|
||
| Set-ScheduledTask (ScheduledTasks) | https://learn.microsoft.com/en-us/powershell/module/scheduledtasks/set-scheduledtask?view=windowsserver2025-ps | S |
|
||
| Set-ClusteredScheduledTask (ScheduledTasks) | https://learn.microsoft.com/en-us/powershell/module/scheduledtasks/set-clusteredscheduledtask?view=windowsserver2022-ps&viewFallbackFrom=win10-ps | S |
|
||
| new scheduledtaskprincipal (버전 미지정) | https://learn.microsoft.com/en-us/powershell/module/scheduledtasks/new-scheduledtaskprincipal | S |
|
||
| new scheduledtask (de-de) | https://learn.microsoft.com/de-de/powershell/module/scheduledtasks/new-scheduledtask | S |
|
||
| PowerShell scripting jj649824 (it-it) | https://learn.microsoft.com/it-it/previous-versions/windows/powershell-scripting/jj649824(v=wps.620) | S |
|
||
| PowerShell scripting jj649825 (zh-cn) | https://learn.microsoft.com/zh-cn/previous-versions/windows/powershell-scripting/jj649825(v=wps.620) | S |
|
||
| New-ScheduledTaskPrincipal — PDQ | https://www.pdq.com/powershell/new-scheduledtaskprincipal/ | S |
|
||
| Scheduling a script in Task Scheduler using PowerShell (Jana's blog) | https://scripting4ever.wordpress.com/2020/09/21/scheduling-a-script-in-task-scheduler-using-powershell/ | S |
|
||
| Powershell — create scheduled task in Windows Task Scheduler part 3 | https://ciysys.com/blog/powershell-schedule-task-part3.htm | S |
|
||
|
||
### A.2 Microsoft Learn — Task Scheduler Win32 스키마 / API
|
||
|
||
| 제목 | URL | 확인 |
|
||
|------|-----|------|
|
||
| settingsType Complex Type | https://learn.microsoft.com/en-us/windows/win32/taskschd/taskschedulerschema-settingstype-complextype | **F** |
|
||
| restartType Complex Type | https://learn.microsoft.com/en-us/windows/win32/taskschd/taskschedulerschema-restarttype-complextype | **F** |
|
||
| ExecutionTimeLimit (settingsType) Element | https://learn.microsoft.com/en-us/windows/win32/taskschd/taskschedulerschema-executiontimelimit-settingstype-element | **F** |
|
||
| NetworkSettings (settingsType) Element | https://learn.microsoft.com/en-us/windows/win32/taskschd/taskschedulerschema-networksettings-settingstype-element | **F** |
|
||
| Delay (bootTriggerType) Element | https://learn.microsoft.com/en-us/windows/win32/TaskSchd/taskschedulerschema-delay-boottriggertype-element | **F** |
|
||
| Principal.LogonType property | https://learn.microsoft.com/en-us/windows/win32/taskschd/principal-logontype | **F** |
|
||
| Security Contexts for Tasks | https://learn.microsoft.com/en-us/windows/win32/taskschd/security-contexts-for-running-tasks | **F** |
|
||
| Task Scheduler error and success constants (WinError.h) | https://learn.microsoft.com/en-us/windows/win32/taskschd/task-scheduler-error-and-success-constants | **F** |
|
||
| Task security context (구 URL) | https://learn.microsoft.com/en-us/windows/win32/taskschd/task-security-context | **X** (404) |
|
||
| DisallowStartIfOnBatteries (settingsType) Element | https://learn.microsoft.com/en-us/windows/win32/taskschd/taskschedulerschema-disallowstartifonbatteries-settingstype-element | S |
|
||
| ITaskSettings::get_DisallowStartIfOnBatteries | https://learn.microsoft.com/en-us/windows/win32/api/taskschd/nf-taskschd-itasksettings-get_disallowstartifonbatteries | S |
|
||
| Task Scheduler Schema | https://learn.microsoft.com/en-us/windows/win32/taskschd/task-scheduler-schema | S |
|
||
| Task Scheduler Schema Elements | https://learn.microsoft.com/en-us/windows/win32/taskschd/task-scheduler-schema-elements | S |
|
||
| TaskSettings | https://learn.microsoft.com/windows/win32/taskschd/tasksettings | S |
|
||
| Task idle conditions | https://learn.microsoft.com/en-us/windows/win32/taskschd/task-idle-conditions | S |
|
||
| BootTrigger.Delay property | https://learn.microsoft.com/en-us/windows/win32/taskschd/boottrigger-delay | S |
|
||
| BootTrigger.Delay (el-gr) | https://learn.microsoft.com/el-gr/windows/win32/taskschd/boottrigger-delay | S |
|
||
| BootTrigger object | https://learn.microsoft.com/en-us/windows/win32/taskschd/boottrigger | S |
|
||
| BootTrigger (triggerGroup) Element | https://learn.microsoft.com/en-us/windows/win32/taskschd/taskschedulerschema-boottrigger-triggergroup-element | S |
|
||
| BootTrigger (triggerGroup) Element (en-gb) | https://learn.microsoft.com/en-gb/windows/win32/taskschd/taskschedulerschema-boottrigger-triggergroup-element | S |
|
||
| Delay (bootTriggerType) Element (en-gb) | https://learn.microsoft.com/en-gb/windows/win32/taskschd/taskschedulerschema-delay-boottriggertype-element | S |
|
||
| Delay (registrationTriggerType) Element | https://learn.microsoft.com/en-us/windows/win32/taskschd/taskschedulerschema-delay-registrationtriggertype-element | S |
|
||
| IRegistrationTrigger::get_Delay | https://learn.microsoft.com/en-us/windows/win32/api/taskschd/nf-taskschd-iregistrationtrigger-get_delay | S |
|
||
| EventTrigger object | https://learn.microsoft.com/en-us/windows/win32/taskschd/eventtrigger | S |
|
||
| EventTrigger object (cs-cz) | https://learn.microsoft.com/cs-cz/windows/desktop/TaskSchd/eventtrigger | S |
|
||
| IEventTrigger interface (taskschd.h) | https://learn.microsoft.com/en-us/windows/win32/api/taskschd/nn-taskschd-ieventtrigger | S |
|
||
| eventTriggerType Complex Type | https://learn.microsoft.com/en-us/windows/win32/TaskSchd/taskschedulerschema-eventtriggertype-complextype | S |
|
||
| EventTrigger (triggerGroup) Element (en-au) | https://learn.microsoft.com/en-au/windows/win32/taskschd/taskschedulerschema-eventtrigger-triggergroup-element | S |
|
||
| eventtrigger.md (GitHub 원본) | https://github.com/MicrosoftDocs/win32/blob/docs/desktop-src/TaskSchd/eventtrigger.md | S |
|
||
| MonthlyDOWTrigger object | https://learn.microsoft.com/en-us/windows/win32/TaskSchd/monthlydowtrigger | S |
|
||
| MonthlyDOWTrigger (th-th) | https://learn.microsoft.com/th-th/windows/win32/taskschd/monthlydowtrigger | S |
|
||
| MonthlyTrigger object (sv-se) | https://learn.microsoft.com/sv-se/windows/win32/taskschd/monthlytrigger | S |
|
||
| WeeklyTrigger (en-au) | https://learn.microsoft.com/en-au/windows/win32/taskschd/weeklytrigger | S |
|
||
| Weekly Trigger Example (XML) | https://learn.microsoft.com/en-us/windows/win32/taskschd/weekly-trigger-example--xml- | S |
|
||
| Time Trigger Example (XML) | https://learn.microsoft.com/en-us/windows/win32/taskschd/time-trigger-example--xml- | S |
|
||
| Logon Trigger Example (XML) | https://learn.microsoft.com/en-us/windows/win32/taskschd/logon-trigger-example--xml- | S |
|
||
| Schtasks.exe (Win32) | https://learn.microsoft.com/en-us/windows/win32/taskschd/schtasks | S |
|
||
| [MS-TSCH]: BootTrigger | https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-tsch/dfcb1665-2a76-4fa7-b4d4-fdb5387d5d7c | S |
|
||
| [MS-TSCH]: 2.5.3.6 EventTrigger | https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-tsch/34e05890-8338-408e-a87d-81534898126a | S |
|
||
| [MS-GPPREF]: ScheduledTasks XML Example | https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-gppref/850b333b-9336-496a-bf93-a20f33748454 | S |
|
||
| Task Properties (WS2008 R2) | https://learn.microsoft.com/en-us/previous-versions/windows/it-pro/windows-server-2008-R2-and-2008/cc775003(v=ws.10) | S |
|
||
| Reference The Event That Triggered Your Task (it-it) | https://learn.microsoft.com/it-it/archive/blogs/otto/reference-the-event-that-triggered-your-task | S |
|
||
| Running a scheduled task after another | https://learn.microsoft.com/en-us/archive/blogs/davethompson/running-a-scheduled-task-after-another | S |
|
||
| Importing XML into Task Scheduler | https://learn.microsoft.com/en-us/archive/msdn-technet-forums/cdc10106-11b4-4ed4-b637-b33f0c1ce01c | S |
|
||
| Import Scheduled Task with Powershell | https://learn.microsoft.com/en-us/archive/msdn-technet-forums/34517e40-a827-41b2-b361-254894d80404 | S |
|
||
| Run a script as hidden task scheduler with powershell | https://learn.microsoft.com/en-us/archive/msdn-technet-forums/d6701df5-db2f-42e5-a790-ce2fc39f853b | S |
|
||
| Register Scheduled task with S4U Logon type (TechNet Wiki 40309) | https://learn.microsoft.com/en-us/archive/technet-wiki/40309.register-scheduled-task-with-s4u-logon-type | S |
|
||
| schtasks create (Windows Commands) | https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/schtasks-create | **F** |
|
||
| schtasks (ja-jp, XP) | https://learn.microsoft.com/ja-jp/previous-versions/windows/it-pro/windows-xp/bb490996(v=technet.10) | S |
|
||
|
||
### A.3 Microsoft Learn — Task Scheduler Q&A / 트러블슈팅
|
||
|
||
| 제목 | URL | 확인 |
|
||
|------|-----|------|
|
||
| Scheduled Tasks with "Startup" trigger not firing | https://learn.microsoft.com/en-us/answers/questions/1180993/scheduled-tasks-with-startup-trigger-not-firing | **F** |
|
||
| Scheduled Tasks with "Startup" trigger not firing (en-gb) | https://learn.microsoft.com/en-gb/answers/questions/1180993/scheduled-tasks-with-startup-trigger-not-firing | S |
|
||
| Task Scheduler is not working with option Run whether user logged on or not | https://learn.microsoft.com/en-gb/answers/questions/2141588/task-scheduler-is-not-working-with-option-run-whea | **F** |
|
||
| Why does my scheduled task sit queued until the first time someone logs in? | https://learn.microsoft.com/en-us/answers/a/1082877 | **F** |
|
||
| Scheduled "At Startup" task that worked in Windows 7 no longer works after upgrade to Windows 10 | https://learn.microsoft.com/en-us/answers/questions/3254425/scheduled-at-startup-task-that-worked-in-windows-7 | S |
|
||
| Task Scheduler Configuration | https://learn.microsoft.com/en-us/answers/questions/3310309/task-scheduler-configuration | S |
|
||
| Task Scheduler Error (page 2) | https://learn.microsoft.com/en-us/answers/questions/2730255/task-scheduler-error?page=2 | S |
|
||
| Can't register scheduled task with managed service account | https://learn.microsoft.com/en-us/answers/questions/607773/cant-register-sheduled-task-with-managed-service-a?orderby=newest%2Chelpful&orderBy=Helpful | S |
|
||
| Windows Task Scheduler Error (one or more of the specified arguments are not valid) | https://learn.microsoft.com/en-us/answers/questions/2820575/windows-task-scheduler-error-(one-or-more-of-the-s | S |
|
||
| Task scheduler error 267014 "process terminated by user" | https://learn.microsoft.com/en-us/answers/questions/d8563660-4111-4c83-88a6-f28b72309b6e/task-scheduler-error-267014-process-terminated-by?forum=windows-all | S |
|
||
| scheduler tasks with security options "Run whether user is logged on or not" | https://learn.microsoft.com/en-us/answers/questions/5789906/scheduler-tasks-with-security-options-run-whether | S |
|
||
| Import Scheduled Task with Powershell and S4U | https://learn.microsoft.com/en-us/answers/questions/184857/import-scheduled-task-with-powershell-and-s4u | S |
|
||
| task scheduler error a specified logon session does not exist | https://learn.microsoft.com/en-us/archive/blogs/supportingwindows/task-scheduler-error-a-specified-logon-session-does-not-exist | S |
|
||
| Schedule not working well on Task Scheduler | https://learn.microsoft.com/en-us/answers/questions/472121/schedule-not-working-well-on-task-scheduler | S |
|
||
| task scheduler task not running on triggered time | https://learn.microsoft.com/answers/questions/333001/task-scheduler-task-not-running-on-triggerd-time.html?orderby=oldest | S |
|
||
| Powershell: New-ScheduledTaskTrigger cmdlet with indefinite duration | https://learn.microsoft.com/en-us/answers/questions/145419/powershell-new-scheduledtasktrigger-cmdlet-with-in | S |
|
||
| Powershell: New-ScheduledTaskTrigger cmdlet AtLogon and Repetition | https://learn.microsoft.com/en-us/answers/questions/573477/powershell-new-scheduledtasktrigger-cmdlet-atlogon | S |
|
||
| Windows Task Scheduler "Synchronize across time zones" does not work as expected | https://learn.microsoft.com/en-us/answers/questions/790592/windows-task-scheduler-synchronize-accross-time-zo | S |
|
||
| Task Scheduler run unexpectedly after server reboot following DST change | https://learn.microsoft.com/en-us/answers/a/1978310 | S |
|
||
| Task Scheduler did not start monthly task after daylight saving change (page 2) | https://learn.microsoft.com/en-us/answers/questions/340419/task-scheduler-did-not-start-monthly-task-after-da?page=2 | S |
|
||
| Task Scheduler did not start monthly task after DST change (answer) | https://learn.microsoft.com/en-us/answers/a/341974 | S |
|
||
| windows scheduler — tasks are running at the same time despite different time settings | https://learn.microsoft.com/en-us/answers/questions/1032006/windows-scheduler-tasks-are-running-at-the-same-ti | S |
|
||
| Windows 10 — Task Scheduler — "Author" field in XML | https://learn.microsoft.com/en-us/answers/questions/370031/windows-10-task-scheduler-author-field-in-xml | S |
|
||
| Run Scheduled Task as SYSTEM only when user is logged in | https://learn.microsoft.com/en-us/answers/questions/259563/run-scheduled-task-as-system-only-when-user-is-log | S |
|
||
| Why is my Windows pc automatically locking after startup? | https://learn.microsoft.com/en-us/answers/questions/2286178/why-is-my-windows-pc-automatically-locking-after-s | S |
|
||
| windows service not really running after shutdown | https://learn.microsoft.com/en-us/answers/questions/1275681/windows-service-not-really-running-after-shutdown | S |
|
||
|
||
### A.4 Microsoft Learn — 서비스 / Session 0
|
||
|
||
| 제목 | URL | 확인 |
|
||
|------|-----|------|
|
||
| Interactive Services (Win32) | https://learn.microsoft.com/en-us/windows/win32/services/interactive-services | **F** |
|
||
| Sc failure (WS2012 R2) | https://learn.microsoft.com/en-us/previous-versions/windows/it-pro/windows-server-2012-r2-and-2012/cc742019(v=ws.11) | **F** |
|
||
| SC (Windows XP) | https://learn.microsoft.com/en-us/previous-versions/windows/it-pro/windows-xp/bb490995(v=technet.10) | S |
|
||
| SC (WS2008 R2) cc753662 | https://learn.microsoft.com/en-us/previous-versions/windows/it-pro/windows-server-2008-r2-and-2008/cc753662(v=ws.11) | S |
|
||
| Create Windows Service using BackgroundService (.NET) | https://learn.microsoft.com/en-us/dotnet/core/extensions/windows-service | **F** |
|
||
| Troubleshooting System Services (TechNet Wiki 14774) | https://learn.microsoft.com/en-us/archive/technet-wiki/14774.troubleshooting-system-services | S |
|
||
| Guidelines for Services (Win32 rstmgr) | https://learn.microsoft.com/en-us/windows/win32/rstmgr/guidelines-for-services | S |
|
||
| Agent Service Fails to Start on Standalone Server (SQL) | https://learn.microsoft.com/en-us/troubleshoot/sql/database-engine/startup-shutdown/agent-service-fails-start-stand-alone-server | S |
|
||
| How to show details of service | https://learn.microsoft.com/en-us/answers/questions/917245/how-to-show-details-of-service | S |
|
||
| Windows Services (blogs/hanybarakat) | https://learn.microsoft.com/en-us/archive/blogs/hanybarakat/windows-services | S |
|
||
| Launching an interactive process from Windows Service in Windows Vista and later | https://learn.microsoft.com/en-us/archive/blogs/winsdk/launching-an-interactive-process-from-windows-service-in-windows-vista-and-later | S |
|
||
| How to launch a process interactively from a Windows Service? | https://learn.microsoft.com/en-us/archive/blogs/winsdk/how-to-launch-a-process-interactively-from-a-windows-service | S |
|
||
| Services and Session Zero in Vista and Windows Server 2008 | https://learn.microsoft.com/en-us/archive/blogs/brad_rutkowski/services-and-session-zero-in-vista-and-windows-server-2008 | S |
|
||
| Do you still use the MessageBox API in your Windows Service? | https://learn.microsoft.com/en-us/archive/blogs/yvesdolc/do-you-still-use-the-messagebox-api-in-your-windows-service | S |
|
||
| Creating a user-interactive Task Sequence experience | https://learn.microsoft.com/en-us/archive/blogs/cameronk/creating-a-user-interactive-task-sequence-experience | S |
|
||
| Interactive Services Detection service removed from Windows 10 1803 | https://learn.microsoft.com/en-us/answers/questions/fad0c42f-9d12-4cf6-a54a-2f9cf8731e4f/we-noticed-that-the-interactive-services-detection?forum=windows-all | S |
|
||
| Screen recording from a Windows service running under local system account | https://learn.microsoft.com/en-us/answers/questions/5875437/screen-recording-from-a-windows-service-which-is-r | S |
|
||
| dn653293 (previous-versions hardware design) | https://learn.microsoft.com/en-us/previous-versions/windows/hardware/design/dn653293(v=vs.85) | S |
|
||
| WTSSendMessage 함수 | https://learn.microsoft.com/en-us/windows/desktop/api/wtsapi32/nf-wtsapi32-wtssendmessagea | S |
|
||
| CreateProcessAsUser 함수 | https://learn.microsoft.com/en-us/windows/desktop/api/processthreadsapi/nf-processthreadsapi-createprocessasusera | S |
|
||
| MessageBox 함수 | https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-messagebox | S |
|
||
| CreateService 함수 | https://learn.microsoft.com/en-us/windows/desktop/api/Winsvc/nf-winsvc-createservicea | S |
|
||
| Window Stations | https://learn.microsoft.com/en-us/windows/desktop/winstation/window-stations | S |
|
||
| LocalSystem account | https://learn.microsoft.com/en-us/windows/win32/services/localsystem-account | S |
|
||
|
||
### A.5 Microsoft Learn — 이벤트 로그
|
||
|
||
| 제목 | URL | 확인 |
|
||
|------|-----|------|
|
||
| New-EventLog (PowerShell 5.1) | https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.management/new-eventlog?view=powershell-5.1 | **F** |
|
||
| Write-EventLog (PowerShell 5.1) | https://learn.microsoft.com/en-us/powershell/module/Microsoft.powershell.management/write-eventlog?view=powershell-5.1 | **F** |
|
||
| How to write windows event logs in PowerShell 7 | https://learn.microsoft.com/en-us/answers/questions/593292/how-to-write-windows-event-logs-in-powershell-7 | **F** |
|
||
| New-EventLog (PowerShell 5.0, previous) | https://learn.microsoft.com/en-us/previous-versions/powershell/module/microsoft.powershell.management/new-eventlog?view=powershell-5.0 | S |
|
||
| New-WinEvent (PowerShell 3.0, previous) | https://learn.microsoft.com/en-us/previous-versions/powershell/module/Microsoft.PowerShell.Diagnostics/new-winevent?view=powershell-3.0 | S |
|
||
| dd347687 (previous-versions) | https://learn.microsoft.com/en-us/previous-versions/dd347687(v=technet.10) | S |
|
||
| dd315363 (previous-versions) | https://learn.microsoft.com/en-us/previous-versions//dd315363(v=technet.10)?redirectedfrom=MSDN | S |
|
||
| EventLogEntryType Enumeration (.NET) | https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.eventlogentrytype | S |
|
||
| TaskScheduler/TaskService/TaskEvent.cs (dahall) | https://github.com/dahall/TaskScheduler/blob/master/TaskService/TaskEvent.cs | S |
|
||
| TaskEvent.cs raw (StandardTaskEventId 열거형) | https://raw.githubusercontent.com/dahall/TaskScheduler/master/TaskService/TaskEvent.cs | **F** |
|
||
| EventTracker KB — Event Id 101 Microsoft-Windows-TaskScheduler | https://kb.eventtracker.com/evtpass/evtpages/EventId_101_Microsoft-Windows-TaskScheduler_61809.asp | S |
|
||
| Event ID 101 Source Microsoft-Windows-TaskScheduler/Operational | https://www.myeventlog.com/search/show/855 | S |
|
||
| Fix Task Scheduler failed to start, Event ID 101 | https://www.thewindowsclub.com/fix-task-scheduler-failed-to-start-event-id-101 | S |
|
||
| Task Scheduler Event IDs (mnaoumov.NET) | https://mnaoumov.wordpress.com/2014/05/15/task-scheduler-event-ids/ | S |
|
||
| ETW — Windows Scheduled Tasks (artifacts.help) | https://artefacts.help/windows_etw_scheduled_task.html | S |
|
||
| Monitor Task Scheduler? (KS-Soft) | https://www.ks-soft.net/phpBB/viewtopic.php?t=8251 | S |
|
||
| How to Write to the Windows Event Log Using PowerShell? (SharePoint Diary) | https://www.sharepointdiary.com/2022/08/powershell-write-to-event-log.html | S |
|
||
| How to Write Logs to the Windows Event Viewer from PowerShell/CMD (Windows OS Hub) | https://woshub.com/write-logs-event-viewer-powershell-cmd/ | S |
|
||
| PowerShell and the Windows Event Log (cyberfella) | https://www.cyberfella.co.uk/2022/05/16/powershell-and-the-windows-event-log/ | S |
|
||
| PowerShell Windows Event Log: Create Custom Log Entries (Command in Line) | https://www.commandinline.com/powershell-write-eventlog-custom/ | S |
|
||
| [BUG] win_task KeyError exception for LastTaskResult (saltstack) | https://github.com/saltstack/salt/issues/66441 | S |
|
||
| All Task Scheduler Errors and Success Codes (TechDirectArchive) | https://techdirectarchive.com/2020/03/24/task-scheduler-errors-and-success-code-what-does-code-0x41301-mean/ | S |
|
||
| Task Scheduler Error and Success Codes explained (TheWindowsClub) | https://www.thewindowsclub.com/task-scheduler-error-and-success-code-explained | S |
|
||
| Scheduled Tasks — Result Codes (Lakshmikanth) | https://www.lakshmikanth.com/scheduled-tasks-result-codes/ | S |
|
||
| How to analyze Task Scheduler 0x41301 error code and fix it | https://www.get-itsolutions.com/task-scheduler-0x41301-error-code-fix/ | S |
|
||
| Windows Scheduled tasks result codes (Starbeam Systems) | https://starbeamsystems.com/knowledge-base/16-windows/82-windows-scheduled-tasks-result-codes | S |
|
||
| Windows Task Scheduler Last Run Result 0x2 (Qlik community) | https://community.qlik.com/t5/Talend-Studio/Windows-Task-Scheduler-Last-Run-Result-0x2/td-p/2331821 | S |
|
||
|
||
### A.6 Microsoft Learn — 전원 / Fast Startup / BitLocker / Windows Update
|
||
|
||
| 제목 | URL | 확인 |
|
||
|------|-----|------|
|
||
| Fast startup causes hibernation or shutdown to fail in Windows 10 or 8.1 | https://learn.microsoft.com/en-us/troubleshoot/windows-client/deployment/fast-startup-causes-system-hibernation-shutdown-fail | **F** |
|
||
| Fast startup causes… (canonical URL) | https://learn.microsoft.com/en-us/troubleshoot/windows-client/setup-upgrade-and-drivers/fast-startup-causes-system-hibernation-shutdown-fail | **F** |
|
||
| Fast startup causes… (pt-br) | https://docs.microsoft.com/pt-br/troubleshoot/windows-client/deployment/fast-startup-causes-system-hibernation-shutdown-fail | S |
|
||
| Distinguishing Fast Startup from Wake-from-Hibernation | https://learn.microsoft.com/en-us/windows-hardware/drivers/kernel/distinguishing-fast-startup-from-wake-from-hibernation | **F** |
|
||
| Supporting Windows 8 Fast Startup with Group Policy | https://learn.microsoft.com/en-us/archive/blogs/keithmayer/supporting-windows-8-fast-startup-with-group-policy | S |
|
||
| SYSTEM_POWER_STATE_CONTEXT 구조체 | https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/wdm/ns-wdm-_system_power_state_context | S |
|
||
| SYSTEM_POWER_STATE 열거형 | https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/wdm/ne-wdm-_system_power_state | S |
|
||
| Manage device restarts after updates | https://learn.microsoft.com/en-us/windows/deployment/update/waas-restart | **F** |
|
||
| Policy CSP — Update (ActiveHoursStart/End/MaxRange) | https://learn.microsoft.com/en-us/windows/client-management/mdm/policy-csp-update | S |
|
||
| Specify deadlines for automatic updates and restarts (WUfB) | https://learn.microsoft.com/en-us/windows/deployment/update/wufb-compliancedeadlines | S |
|
||
| Windows Update: FAQ | https://support.microsoft.com/windows/windows-update-faq-8a903416-6f45-0718-f5c7-375e92dddeb2 | S |
|
||
| BitLocker countermeasures | https://learn.microsoft.com/en-us/windows/security/operating-system-security/data-protection/bitlocker/countermeasures | S |
|
||
| Troubleshooting BitLocker policies from the client side (Intune) | https://learn.microsoft.com/en-us/troubleshoot/mem/intune/device-protection/troubleshoot-bitlocker-policies | S |
|
||
| Notes on BitLocker and the TPM and the pre-boot password or PIN (The Old New Thing) | https://devblogs.microsoft.com/oldnewthing/20220412-00/?p=106468 | S |
|
||
| Autologon — Sysinternals | https://learn.microsoft.com/en-us/sysinternals/downloads/autologon | **F** |
|
||
| Autologon 다운로드 (ZIP) | https://download.sysinternals.com/files/AutoLogon.zip | S |
|
||
| Autologon (Sysinternals Live) | https://live.sysinternals.com/Autologon.exe | S |
|
||
| Protecting the Automatic Logon Password | https://learn.microsoft.com/en-us/windows/win32/secauthn/protecting-the-automatic-logon-password/ | S |
|
||
| Hibernate Enabled but Timed Hibernate Not Working | https://learn.microsoft.com/en-us/answers/questions/3853365/hibernate-enabled-but-timed-hibernate-not-working | S |
|
||
| hh875530 (WS2012 R2, 전원 관련) | https://learn.microsoft.com/nb-no/previous-versions/windows/it-pro/windows-server-2012-r2-and-2012/hh875530(v=ws.11) | S |
|
||
| How to back up and restore the registry in Windows | https://support.microsoft.com/help/322756 | S |
|
||
|
||
### A.7 Microsoft Learn — WSL
|
||
|
||
| 제목 | URL | 확인 |
|
||
|------|-----|------|
|
||
| Use systemd to manage Linux services with WSL | https://learn.microsoft.com/en-us/windows/wsl/systemd | **F** |
|
||
| Advanced settings configuration in WSL (wsl.conf / .wslconfig) | https://learn.microsoft.com/en-us/windows/wsl/wsl-config | **F** |
|
||
| wsl2 can't run in the background after enabling systemd | https://learn.microsoft.com/en-us/answers/questions/1192206/wsl2-cant-run-in-the-background-after-enabling-sys | S |
|
||
| systemd support is now available in WSL (devblogs) | https://devblogs.microsoft.com/commandline/systemd-support-is-now-available-in-wsl/ | S |
|
||
| Background Task Support in WSL (devblogs, 2017) | https://devblogs.microsoft.com/commandline/background-task-support-in-wsl/ | S |
|
||
| WSL in Microsoft Store | https://aka.ms/wslstorepage | S |
|
||
| Ubuntu Desktop 23.04 release roundup (systemd default on WSL) | https://canonical.com/blog/ubuntu-desktop-23-04-release-roundup | S |
|
||
| Enable systemd on Ubuntu WSL (Ubuntu blog) | https://ubuntu.com/blog/ubuntu-wsl-enable-systemd | S |
|
||
| Run a .Net Echo Bot as a systemd service on Ubuntu WSL | https://ubuntu.com/tutorials/run-dotnet-echo-bot-with-systemd-on-ubuntu-wsl#1-overview | S |
|
||
| microk8s demo (craigloewen-msft) | https://github.com/craigloewen-msft/microk8sdemo | S |
|
||
| Cron will not keep running in background (microsoft/WSL#9072) | https://github.com/microsoft/WSL/issues/9072 | S |
|
||
| WSL cannot be run by scheduled task (microsoft/WSL#10732) | https://github.com/microsoft/WSL/issues/10732 | S |
|
||
| How to Launch Cron Automatically in WSL (How-To Geek) | https://www.howtogeek.com/746532/how-to-launch-cron-automatically-in-wsl-on-windows-10-and-11/ | S |
|
||
| cron on Windows Subsystem for Linux (Open Water Foundation) | https://learn.openwaterfoundation.org/owf-learn-linux-shell/appendix-cron/cron-wsl/cron-wsl/ | S |
|
||
| Use WSL Cron Jobs to Run Windows Scheduled Tasks (pwshtips) | https://pwshtips.com/posts/wsl-cron-run-windows-scheduled-tasks/ | S |
|
||
| snapcraft | https://snapcraft.io/ | S |
|
||
| microk8s | https://microk8s.io/ | S |
|
||
| Install MicroK8s on WSL2 | https://microk8s.io/docs/install-wsl2 | S |
|
||
| systemd.io | https://systemd.io | S |
|
||
|
||
### A.8 알림 (BurntToast / Python 토스트 / msg.exe / 집중 지원)
|
||
|
||
| 제목 | URL | 확인 |
|
||
|------|-----|------|
|
||
| Windos/BurntToast (GitHub) | https://github.com/Windos/BurntToast | **F** |
|
||
| BurntToast README (raw) | https://raw.githubusercontent.com/Windos/BurntToast/main/README.md | **F** |
|
||
| BurntToast New-BTButton Help (raw) | https://raw.githubusercontent.com/Windos/BurntToast/main/Help/New-BTButton.md | **F** |
|
||
| BurntToast New-BTButton docs (raw, 구 경로) | https://raw.githubusercontent.com/Windos/BurntToast/main/docs/New-BTButton.md | **X** (404) |
|
||
| PowerShell Gallery — BurntToast 0.4 New-BurntToastNotification.ps1 | https://www.powershellgallery.com/packages/BurntToast/0.4/Content/New-BurntToastNotification.ps1 | S |
|
||
| Display toast notifications with PowerShell's BurntToast module (PDQ) | https://www.pdq.com/blog/display-toast-notifications-with-powershell-burnt-toast-module/ | S |
|
||
| Create pop-up notifications from PowerShell with BurntToast (Luis Llamas) | https://www.luisllamas.es/en/burnttoast/ | S |
|
||
| Generate Windows toast notifications with BurntToast (4sysops) | https://4sysops.com/archives/generate-windows-toast-notifications-with-the-powershell-module-burnttoast/ | S |
|
||
| Windows Toast Notifications With PowerShell (Wit IT) | https://witit.blog/windows-toast-notifications-with-powershell/ | S |
|
||
| Reboot Notifications with BurntToast (Joshua Dearing) | https://www.dearing.dev/posts/Reboot-Notifications-with-BurntToast-A-Simple-Guide/ | S |
|
||
| Efficient Notifications Using PowerShell BurntToast (psplaybook) | https://www.psplaybook.com/2024/12/03/custom-powershell-notification-script-using-burnttoast-module/ | S |
|
||
| Powershell: burnt toast notification (PowerShell Forums) | https://forums.powershell.org/t/powershell-burnt-toast-notification/24222 | S |
|
||
| powershell script for system reboot (MS Q&A) | https://learn.microsoft.com/en-us/answers/questions/1339894/powershell-script-for-system-reboot | S |
|
||
| GitHub30/win11toast | https://github.com/GitHub30/win11toast | **F** |
|
||
| win11toast (PyPI) | https://pypi.org/project/win11toast/ | S |
|
||
| DatGuy1/Windows-Toasts | https://github.com/DatGuy1/Windows-Toasts | S |
|
||
| Windows-Toasts 문서 (readthedocs) | https://windows-toasts.readthedocs.io/ | **F** |
|
||
| Windows-Toasts Getting started | https://windows-toasts.readthedocs.io/en/latest/getting_started.html | **F** |
|
||
| Windows-Toasts (PyPI) | https://pypi.org/project/Windows-Toasts | S |
|
||
| toasted (PyPI) | https://pypi.org/project/toasted | S |
|
||
| toasted 0.2.0 (PyPI) | https://pypi.org/project/toasted/0.2.0 | S |
|
||
| ysfchn/toasted (GitHub) | https://github.com/ysfchn/toasted | S |
|
||
| Python: Windows Toast Notifications (Tongere) | https://tongere.hashnode.dev/python-windows-toast-notifications | S |
|
||
| Notificaciones de Windows con Python (DEV) | https://dev.to/asjordi/notificaciones-de-windows-con-python-5cdk | S |
|
||
| msg (Windows Commands) | https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/msg | **F** |
|
||
| The Windows Process Journey — msg.exe (Medium) | https://medium.com/@boutnaru/the-windows-process-journey-msg-exe-message-utility-a241640c365c | S |
|
||
| msg.exe (STRONTIC xcyclopedia) | https://strontic.github.io/xcyclopedia/library/msg.exe-200850BBE6A2DE88A212E0E86C3FF845.html | S |
|
||
| Send Messages to all currently logged on Users (SID-500) | https://sid-500.com/2017/10/07/active-directory-send-messages-to-all-currently-logged-on-users-msg-exe/comment-page-1/ | S |
|
||
| Display message on screen as was done in Win7 thru Task Scheduler (Eleven Forum) | https://www.elevenforum.com/t/display-message-on-screen-as-was-done-in-win7-thru-task-scheduler.16861/ | S |
|
||
| [Fix] Can't Create Tasks to Display Messages in Task Scheduler (AskVG) | https://www.askvg.com/fix-cant-create-tasks-to-display-messages-in-windows-8-task-scheduler/ | S |
|
||
| Msg command how to (cezeo) | https://www.cezeo.com/tips-and-tricks/msg-command/ | S |
|
||
| Windows XP in a Nutshell — msg (O'Reilly) | https://www.oreilly.com/library/view/windows-xp-in/0596009003/re122.html | S |
|
||
| How to Set Priority Notifications for Do Not Disturb in Windows 11 (NinjaOne) | https://www.ninjaone.com/blog/priority-notifications-for-do-not-disturb/ | **F** |
|
||
| Turn On or Off Focus Assist in Windows 11 (Eleven Forum) | https://www.elevenforum.com/t/turn-on-or-off-focus-assist-in-windows-11.1351/ | S |
|
||
| How to use Focus assist to avoid distractions in Windows 11 (Windows Central) | https://www.windowscentral.com/how-use-focus-assist-avoid-distractions-windows-11 | S |
|
||
| How to Use Focus Assist on Windows 11 (Nerds Chalk) | https://nerdschalk.com/how-to-use-focus-assist-on-windows-11/ | S |
|
||
| How to Use Focus Assist on Windows 11 (groovyPost) | https://www.groovypost.com/howto/use-focus-assist-on-windows-11/ | S |
|
||
| Master Windows Notifications: Do Not Disturb & Focus Assist (Windows Forum) | https://windowsforum.com/threads/master-windows-notifications-silence-noise-with-do-not-disturb-and-focus-assist.404264/ | S |
|
||
| Windows 11 Focus Assist Explained (onewebcare) | https://onewebcare.com/windows/windows-11-focus-assist/ | S |
|
||
| How to reduce distractions in Windows 11 with Focus Assist (TechRadar) | https://www.techradar.com/how-to/how-to-reduce-distractions-in-windows-11 | S |
|
||
| Show App UI at Logon with Windows 11 Task Scheduler (Windows Forum) | https://windowsforum.com/threads/show-app-ui-at-logon-with-windows-11-task-scheduler.392418/ | S |
|
||
|
||
### A.9 헬스체크 / dead-man switch
|
||
|
||
| 제목 | URL | 확인 |
|
||
|------|-----|------|
|
||
| Monitor PowerShell Scripts with Healthchecks.io | https://healthchecks.io/docs/powershell/ | **F** |
|
||
| Healthchecks.io Pinging API (HTTP API) | https://healthchecks.io/docs/http_api/ | **F** |
|
||
| Healthchecks.io Configuring checks (Period/Grace) | https://healthchecks.io/docs/configuring_checks/ | **F** |
|
||
| How to Monitor Cron Jobs with Healthchecks.io | https://healthchecks.io/docs/monitoring_cron_jobs/ | S |
|
||
| Healthchecks.io Documentation | https://healthchecks.io/docs/ | S |
|
||
| PowerShell — devroom.io Healthchecks (미러) | https://healthchecks.devroom.io/docs/powershell/ | S |
|
||
| Documentation — WMIT Watchdog (미러) | https://healthchecks.it.wm.edu/docs/ | S |
|
||
| PowerShell — Healthchecks (verbis.dkfz.de 미러) | https://healthchecks.verbis.dkfz.de/docs/powershell/ | S |
|
||
| Documentation — Async Healthchecks (미러) | https://hc.async.com.br/docs/ | S |
|
||
| PowerShell — RDSec Healthchecks.io (미러) | https://healthchecks.cloud.rdsec.nl/docs/powershell/ | S |
|
||
| PowerShell — Health Checks (iqusong 미러) | https://healthchecks.iqusong.com/docs/powershell/ | S |
|
||
| Healthchecks.io: The Ultimate Guide (Medium) | https://medium.com/@nisheet110/healthchecks-io-the-ultimate-guide-to-application-and-cron-job-monitoring-fd1b6bf311fc | S |
|
||
| Uptime Kuma — Configure Push Monitor (Programster) | https://blog.programster.org/uptime-kuma-configure-push-monitor | **F** |
|
||
| Using Push heartbeat monitoring in Uptime Kuma (SmartxTechnologies Wiki) | https://wiki.smartxtechnologies.com/uptime-kuma/monitor-push-heartbeat | **X** (본문 없음) |
|
||
| jmclaren7/uptime-kuma-push (PowerShell) | https://github.com/jmclaren7/uptime-kuma-push | S |
|
||
| JPVenson/uptime-kuma-pushr | https://github.com/JPVenson/uptime-kuma-pushr | S |
|
||
| Monitoring sites and backups with Uptime Kuma (Symfolidity) | https://symfolidity.com/en/articles/monitoring-sites-and-backups-with-uptime-kuma/ | S |
|
||
| Self-Host Uptime Kuma on a VPS (RDP.sh) | https://rdp.sh/blog/self-host-uptime-kuma-on-a-vps-for-free-status-monitoring | S |
|
||
| The Dead Man's Switch: Backup Monitoring with Duplicati and Uptime Kuma (tywer.dev) | https://tywer.dev/the-dead-mans-switch-foolproof-backup-monitoring-with-duplicati-and-uptime-kuma | S |
|
||
| Self-Hosted Cron Job Monitoring: Healthchecks vs Uptime Kuma vs Prometheus 2026 (Pi Stack) | https://www.pistack.xyz/posts/self-hosted-cron-job-monitoring-healthchecks-uptime-kuma-prometheus-guide-2026/ | S |
|
||
| Uptime Kuma: The Monitoring Tool That Actually Makes Sense (Substack) | https://dataengineeringtoolkit.substack.com/p/uptime-kuma-the-monitoring-tool-that | S |
|
||
|
||
### A.10 서비스 래퍼 (NSSM / WinSW / pywin32)
|
||
|
||
| 제목 | URL | 확인 |
|
||
|------|-----|------|
|
||
| NSSM Usage | https://nssm.cc/usage | **F** |
|
||
| NSSM Download (버전 확인 실패) | https://nssm.cc/download | **X** (timeout 60000ms) |
|
||
| winsw/winsw (GitHub) | https://github.com/winsw/winsw | **F** |
|
||
| WinSW XML config file (v3 docs) | https://github.com/winsw/winsw/blob/v3/docs/xml-config-file.md | **F** |
|
||
| WinSW Releases | https://github.com/winsw/winsw/releases | **F** |
|
||
| pywin32 win32serviceutil.py (mhammond) | https://github.com/mhammond/pywin32/blob/main/win32/Lib/win32serviceutil.py | S |
|
||
| Pywin32 win32serviceutil.py (SublimeText 미러) | https://github.com/SublimeText/Pywin32/blob/master/lib/x32/win32/lib/win32serviceutil.py | S |
|
||
| An example Windows service implemented with pywin32 wrappers (gist) | https://gist.github.com/drmalex07/10554232 | S |
|
||
| Python as a Windows Service Example (MSSQLTips) | https://www.mssqltips.com/sqlservertip/7318/python-as-a-windows-service/ | S |
|
||
| Creating a one-file Windows service in Python with pywin32 and PyInstaller (Metallapan) | https://metallapan.se/post/windows-service-pywin32-pyinstaller/ | S |
|
||
| Developing a python based Windows Service (Nathan Sanders) | https://www.nathanasanders.com/2022/04/09/developing-a-python-based-windows-service/ | S |
|
||
| Building a Robust Windows Service in Python with win32serviceutil (DEV) | https://dev.to/demola12/building-a-robust-windows-service-in-python-with-win32serviceutil-part-13-1k6k | S |
|
||
| Python Programming on Win32 ch18 (O'Reilly) | https://www.oreilly.com/library/view/python-programming-on/1565926218/ch18s05s04.html | S |
|
||
| Microsoft.Extensions.Hosting.WindowsServices (NuGet) | https://nuget.org/packages/Microsoft.Extensions.Hosting.WindowsServices | S |
|
||
|
||
### A.11 Playwright / 브라우저
|
||
|
||
| 제목 | URL | 확인 |
|
||
|------|-----|------|
|
||
| Browsers (Playwright Python) | https://playwright.dev/python/docs/browsers | **F** |
|
||
| Browsers (Playwright) | https://playwright.dev/docs/browsers | S |
|
||
| microsoft/playwright (GitHub) | https://github.com/microsoft/playwright | S |
|
||
| [Question] chromium doesn't show up? (#20242, NSSM 서비스) | https://github.com/microsoft/playwright/issues/20242 | **F** |
|
||
| Headful chromium on Windows does not launch without '--single-process' (#12174) | https://github.com/microsoft/playwright/issues/12174 | S |
|
||
| [Bug] Unable to launch chromium in headless mode in win11 v1.49.1 (#34306) | https://github.com/microsoft/playwright/issues/34306 | S |
|
||
| [Bug] chromium-headless-shell (#34508) | https://github.com/microsoft/playwright/issues/34508 | S |
|
||
| [Bug] headless=True but XServer error log (playwright-python#2498) | https://github.com/microsoft/playwright-python/issues/2498 | S |
|
||
| Headless Chrome Explained (browserless.io) | https://www.browserless.io/blog/headless-chrome | S |
|
||
| How to Run Tests in Playwright Headless Chrome? (BrowserStack) | https://www.browserstack.com/guide/playwright-headless-chrome | S |
|
||
| Playwright (software) — Wikipedia | https://en.wikipedia.org/wiki/Playwright_(software) | S |
|
||
|
||
### A.12 Docker
|
||
|
||
| 제목 | URL | 확인 |
|
||
|------|-----|------|
|
||
| Start containers automatically (restart policies) | https://docs.docker.com/engine/containers/start-containers-automatically/ | **F** |
|
||
| Docker Desktop settings (General) | https://docs.docker.com/desktop/settings-and-maintenance/settings/ | **F** |
|
||
|
||
### A.13 AI CLI headless (참고 모델)
|
||
|
||
| 제목 | URL | 확인 |
|
||
|------|-----|------|
|
||
| Run Claude Code programmatically (headless) — 공식 | https://code.claude.com/docs/en/headless | **F** |
|
||
| Claude Code docs 인덱스 | https://code.claude.com/docs/llms.txt | S |
|
||
| Agent SDK overview | https://code.claude.com/docs/en/agent-sdk/overview | S |
|
||
| CLI reference | https://code.claude.com/docs/en/cli-reference | S |
|
||
| Environment variables | https://code.claude.com/docs/en/env-vars | S |
|
||
| Permissions | https://code.claude.com/docs/en/permissions | S |
|
||
| Sub-agents | https://code.claude.com/docs/en/sub-agents | S |
|
||
| Claude Console | https://platform.claude.com | S |
|
||
| jq | https://jqlang.org/ | S |
|
||
| JSON Schema | https://json-schema.org/ | S |
|
||
| What Is Claude Code Headless Mode? (MindStudio) | https://www.mindstudio.ai/blog/claude-code-headless-mode-autonomous-agents | S |
|
||
| Claude Code Can Work While You Sleep (wmedia.es) | https://wmedia.es/en/tips/claude-code-headless-mode-autonomous-agent | S |
|
||
| Background Claude | https://backgroundclaude.com/ | S |
|
||
| Claude Code Headless Mode: The Complete Self-Hosting Guide (2026) (amux) | https://amux.io/guides/claude-code-headless/ | S |
|
||
| How to Set Up Cron Jobs with Claude Code (Usagebar) | https://usagebar.com/blog/how-to-do-cron-job-setup-on-claude-code | S |
|
||
| Claude Code Automation: Non-Interactive Mode (DevShelfHub) | https://www.devshelfhub.com/tutorials/claude-code/automation/ | S |
|
||
| Claude Code Headless Mode Guide (2026) (Like One) | https://likeone.ai/blog/claude-code-headless-mode-guide-2026/ | S |
|
||
| Claude Code Headless Mode: The -p Flag (AI Skill Certs) | https://aiskillcerts.com/concepts/claude-code-config/the-p-flag-for-non-interactive-mode | S |
|
||
| Claude Code Headless Mode (Build This Now) | https://www.buildthisnow.com/blog/guide/development/claude-code-headless-mode | S |
|
||
|
||
### A.14 기타 (전원 / 활성 시간 / 시간대 / 커뮤니티)
|
||
|
||
| 제목 | URL | 확인 |
|
||
|------|-----|------|
|
||
| PowerCfg command (SS64) | https://ss64.com/nt/powercfg.html | S |
|
||
| PowerCFG Commands for Windows 11 and 10: Complete Guide (iTechGuides) | https://www.itechguides.com/mastering-the-powercfg-command-on-windows-11-and-10/ | S |
|
||
| How to Manage Windows 11 Power and Sleep Settings (cloudspress) | https://www.cloudspress.com/how-to-manage-your-windows-11-power-and-sleep-settings-a-step-by-step-guide/ | S |
|
||
| PowerCFG Command Guide for Windows 11 and 10 (cloudspress) | https://www.cloudspress.com/mastering-the-powercfg-command-on-windows-11-and-10/ | S |
|
||
| PowerCFG Tutorial: A Complete Guide (pchardwarepro) | https://www.pchardwarepro.com/en/PowerCFG-tutorial:-A-complete-guide-to-mastering-power-management-in-Windows/ | S |
|
||
| PowerShell on Windows 11 Home: cap display/sleep/hibernate (DEV) | https://dev.to/teoman_egeselcuk_d962da6/title-powershell-on-windows-11-home-how-to-cap-displaysleephibernate-at-5-minutes-300-s-7ag | S |
|
||
| Turn On or Off Fast Startup in Windows 10 (Ten Forums) | https://www.tenforums.com/tutorials/4189-turn-off-fast-startup-windows-10-a.html | S |
|
||
| Why I wish I had disabled Windows… (Yahoo Tech) | https://tech.yahoo.com/computing/articles/why-wish-had-disabled-windows-163120947.html | S |
|
||
| Set Active Hours for Windows Update in Windows 11 (Eleven Forum) | https://www.elevenforum.com/t/set-active-hours-for-windows-update-in-windows-11.3436/ | S |
|
||
| Script to update active hours on Windows 10/11 devices (Hexnode) | https://www.hexnode.com/mobile-device-management/help/script-to-update-active-hours-on-windows-10-11-devices/ | S |
|
||
| Set Active Hours To Avoid Random Restarts In Windows 11 (HTMD Blog) | https://www.anoopcnair.com/set-active-hours-to-avoid-restarts-windows-11/ | S |
|
||
| 3 Ways to Change Windows 10 Active Hours (MajorGeeks) | https://www.majorgeeks.com/content/page/3_ways_to_change_windows_10_active_hours.html | S |
|
||
| How to configure and use Active Hours in Windows 11 (TheWindowsClub) | https://www.thewindowsclub.com/configure-and-use-active-hours-in-windows-10 | S |
|
||
| How to Disable Active Hours in Windows 11 (techradar.info) | https://techradar.info/how-to-disable-active-hours-in-windows-11-the-ultimate-power-user-guide/ | S |
|
||
| Maimer/update-active-hours (GitHub) | https://github.com/Maimer/update-active-hours | S |
|
||
| No Reboot (itch.io) | https://blearychicken.itch.io/no-reboot | S |
|
||
| MiWorkspace: Changing Active Hours on Windows 11 (UMich TeamDynamix) | https://teamdynamix.umich.edu/TDClient/30/Portal/KB/PrintArticle?ID=13604 | S |
|
||
| Scheduled Task Trigger — Synchronize Across Time Zones (The CLI Guy) | https://www.thecliguy.co.uk/2020/02/09/scheduled-task-trigger-synchronize-across-time-zones/ | S |
|
||
| xScheduledTask: Trigger has no option for Synchronize across time zones (ComputerManagementDsc#109) | https://github.com/dsccommunity/ComputerManagementDsc/issues/109 | S |
|
||
| Scheduled task timezone support (trigger.dev changelog) | https://trigger.dev/changelog/scheduled-task-timezones | S |
|
||
| Run whether user is logged on or not (bnosac/taskscheduleR#44) | https://github.com/bnosac/taskscheduleR/issues/44 | S |
|
||
| Task Scheduler (run whether user is logged on or not) (iditect) | https://www.iditect.com/program-example/windows--task-scheduler-run-whether-user-is-logged-on-or-not.html | S |
|
||
| Fixing Task Scheduler Tasks That Don't Run or Exit 0x1 (KomuraSoft) | https://comcomponent.com/en/blog/windows-task-scheduler-reliable-scheduled-tasks/ | S |
|
||
| How to Use BitLocker with PIN (Dell) | https://www.dell.com/support/kbdoc/en-us/000142382/how-to-use-bitlocker-with-pin | S |
|
||
| How to Enable a Pre-Boot BitLocker PIN on Windows (How-To Geek) | https://www.howtogeek.com/262720/how-to-enable-a-pre-boot-bitlocker-pin-on-windows/ | S |
|
||
| How to enable Pre-Boot BitLocker startup PIN with Intune (Oliver Kieselbach) | https://oliverkieselbach.com/2019/08/02/how-to-enable-pre-boot-bitlocker-startup-pin-on-windows-with-intune/comment-page-1/ | S |
|
||
| Silently enable BitLocker with PIN during Autopilot (Katy's Tech Blog) | https://katystech.blog/mem/bitlocker-with-pin | S |
|
||
| Fix BitLocker Endless Recovery Key Prompt (4idiotz) | https://4idiotz.com/tech/computers-and-operating-systems/bitlocker-troubleshooting/fix-bitlocker-endless-recovery-key-prompt-ultimate-troubleshooting-guide-for-windows-users/ | S |
|
||
| Introduction to systemctl (Linode) | https://www.linode.com/docs/guides/introduction-to-systemctl/ | S |
|
||
| Understanding and Using Systemd (Linux.com) | https://www.linux.com/training-tutorials/understanding-and-using-systemd/ | S |
|
||
| Systemd Essentials (DigitalOcean) | https://www.digitalocean.com/community/tutorials/systemd-essentials-working-with-services-units-and-the-journal | S |
|
||
| How To Sandbox Processes With Systemd (DigitalOcean) | https://www.digitalocean.com/community/tutorials/how-to-sandbox-processes-with-systemd-on-ubuntu-20-04 | S |
|
||
| about_CommonParameters | https://go.microsoft.com/fwlink/?LinkID=113216 | S |
|
||
| New-CimSession (fwlink) | https://go.microsoft.com/fwlink/p/?LinkId=227967 | S |
|
||
| Get-CimSession (fwlink) | https://go.microsoft.com/fwlink/p/?LinkId=227966 | S |
|
||
| XML duration type (fwlink 106886) | https://go.microsoft.com/fwlink/p/?linkid=106886 | S |
|
||
| Events and Errors Message Center | https://www.microsoft.com/technet/support/ee/ee_advanced.aspx | S |
|
||
|
||
**총 출처 수: 약 230개 (중복 URL 제외).**
|
||
|
||
---
|
||
|
||
## 부록 B. 미해결 질문 / 실측 필요 항목
|
||
|
||
### B.1 반드시 실측해야 하는 항목 (구현 전 차단 요소)
|
||
|
||
- [ ] **B-1. S4U 로그온에서 Windows Credential Manager / python `keyring` / DPAPI 복호화가 동작하는가?**
|
||
공식 문서는 "no password is stored by the system and **there is no access to either the network or encrypted files**" 라고만 말한다. DPAPI 사용자 마스터 키 접근 가능 여부는 명시되어 있지 않다.
|
||
**검증 방법**: S4U 로 등록한 테스트 작업에서 `python -c "import keyring; print(keyring.get_password('dmf','api'))"` 를 실행하고 로그로 결과를 확인한다. 실패하면 §4.4 결정 트리에 따라 `Password` 모드로 전환하거나 자격증명을 파일 기반으로 옮긴다.
|
||
- [ ] **B-2. `agy`(Google Antigravity CLI)의 실제 헤드리스 플래그.**
|
||
`-p`, `--output-format`, `--allowedTools` 등은 Claude Code 의 관례를 차용한 **가정**이다. 이번 조사에서 `agy` 관련 공식 문서를 전혀 열람하지 못했다.
|
||
**검증 방법**: `agy --help`, `agy -p --help` 출력을 확보하고 §19.2 스크립트의 `$psi.Arguments` 를 수정한다. 또한 `agy` 의 설치 경로·부트스트랩 명령(`npm install -g ...` 가정)도 확인해야 한다.
|
||
- [ ] **B-3. `NOC_GLOBAL_SETTING_TOASTS_ENABLED` 의 값 방향(0=허용? 1=허용?).**
|
||
출처가 "0 → 방해 금지 비활성화 / 1 → 방해 금지 활성화" 로 서술했으나 값 이름(TOASTS_**ENABLED**)과 반대 방향처럼 읽힌다. 사용자 설정을 잘못 바꾸면 알림이 완전히 죽는다.
|
||
**검증 방법**: 설정 UI 에서 방해 금지를 켜고/끄며 레지스트리 값 변화를 관찰한다. **검증 전에는 이 값을 프로그램으로 변경하지 마라.**
|
||
- [ ] **B-4. healthchecks.io 무료 플랜의 체크 개수·보존 기간 한도.**
|
||
`docs/powershell/` 과 `docs/http_api/` 어디에도 플랜 한도가 없었다. 크롤러용 1개 + 워치독용 1개 = 2개면 충분할 것으로 보이나 확인 필요.
|
||
**검증 방법**: https://healthchecks.io/pricing/ 확인.
|
||
- [ ] **B-5. windows-toasts 의 "Problem solving" / "Custom AUMIDs" 페이지 내용.**
|
||
Task Scheduler·서비스에서의 caveat 와 AUMID 등록 절차가 이 페이지들에 있을 가능성이 높으나 열람하지 못했다.
|
||
**검증 방법**: https://windows-toasts.readthedocs.io/ 의 해당 섹션 직접 확인. (BurntToast 를 쓰기로 했으므로 우선순위는 낮다.)
|
||
- [ ] **B-6. NSSM 의 최신 릴리스 버전과 날짜.**
|
||
https://nssm.cc/download 이 60초 타임아웃으로 응답하지 않았다. NSSM 을 채택하지 않기로 했으므로 차단 요소는 아니다.
|
||
- [ ] **B-7. Fast Startup 비활성화 레지스트리 값 `HiberbootEnabled` 의 정확한 경로/이름.**
|
||
`HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\Power` 의 `HiberbootEnabled` 는 널리 통용되나 인용한 Microsoft 문서에는 없었다.
|
||
**검증 방법**: 제어판에서 "빠른 시작 켜기" 를 토글하며 레지스트리 값 변화를 관찰. 또는 `powercfg /h off` 만 사용(이쪽은 공식 확인됨).
|
||
- [ ] **B-8. `powercfg` 절전 하위 그룹/웨이크 타이머 GUID.**
|
||
`238c9fa8-0aad-41ed-83f4-97be242c8f20`(SUB_SLEEP), `bd3b718a-0680-4d9d-8ab2-e1d2b4ac806d`(RTCWAKE) 는 표준 값으로 알려져 있으나 Microsoft 문서로 확인하지 못했다.
|
||
**검증 방법**: `powercfg /query SCHEME_CURRENT SUB_SLEEP` 출력에서 실제 GUID 를 읽어 스크립트에 반영.
|
||
- [ ] **B-9. `sc failureflag` 서브커맨드의 존재와 구문.**
|
||
공식 `Sc failure` 문서에는 언급이 없다. 서비스화를 하지 않기로 했으므로 차단 요소는 아니다.
|
||
**검증 방법**: `sc.exe failureflag /?` 실행.
|
||
|
||
### B.2 이 프로젝트에서 결정해야 할 운영 사항
|
||
|
||
- [ ] **B-10. LogonType 최종 결정.** B-1 실측 결과에 따라 `S4U` 또는 `Password`. Password 로 간다면 **계정 암호 변경 시의 작업 재등록 절차**를 운영 문서에 명시할 것(증상: `0x8004130F`).
|
||
- [ ] **B-11. 리포트 저장 위치.** 로컬(`D:\workspace\DMF_Crawler\reports`)로 끝낼 것인가, 네트워크 공유(UNC)로도 복사할 것인가? UNC 를 쓰면 S4U 가 불가능해진다(§4.3).
|
||
- [ ] **B-12. 알림 채널 확정.** Discord / Telegram / Slack / 이메일 중 무엇을 쓸 것인가? 최소 1개는 반드시 있어야 한다(PC 전원 차단 시 유일한 경로).
|
||
- [ ] **B-13. healthchecks.io vs 자체 Uptime Kuma.** 제약사 내부 정책상 외부 SaaS 로 ping 을 보내는 것이 허용되는가? (ping 자체에는 업무 데이터가 없지만 정책 확인 필요.)
|
||
- [ ] **B-14. PC 를 24시간 켜 둘 것인가?** 켜 둔다면 `WakeToRun` 은 보험일 뿐이고 설계가 단순해진다. 꺼 둔다면 `WakeToRun` + BIOS 웨이크 타이머 지원 여부를 실측해야 한다.
|
||
- [ ] **B-15. BitLocker 구성.** TPM-only 로 갈 것인가? 회사 정책이 TPM+PIN 을 강제한다면 재부팅 후 자동 복구를 포기하고 워치독 알림으로 대응한다(§14.3).
|
||
- [ ] **B-16. `ExecutionTimeLimit` 값.** `PT2H` 는 가정이다. 실제 크롤링 소요 시간을 2주간 측정해 **평균의 3배** 정도로 조정하라. healthchecks 의 Grace Time 도 함께 조정해야 한다(§9.6).
|
||
- [ ] **B-17. 크롤러 exit code 규약 확정.** `0`=성공, `1`=일반, `2`=설정/환경, `3`=크롤링 실패, `4`=리포트 생성 실패 로 제안했다. Python 측 구현과 합의할 것.
|
||
- [ ] **B-18. 알림 큐 폭주 방지.** 며칠 연속 실패하면 로그온 시 토스트가 여러 개 뜬다. `notify.ps1` 의 `-MaxToasts 3` 으로 제한했으나, "3일 연속 실패" 를 하나로 묶어 요약하는 로직이 더 나을 수 있다.
|
||
|
||
### B.3 확인되었으나 후속 실측이 권장되는 사항
|
||
|
||
- [ ] **B-19. Fast Startup 환경에서 `<BootTrigger>` 가 실제로 발화하는가?** 문서상 "may not fire" 이므로, 대상 PC 에서 ① 종료 후 켜기 ② 다시 시작 두 경우를 각각 시험하고 이벤트 ID **118 (BootTrigger)** 발생 여부를 확인하라.
|
||
- [ ] **B-20. `StartWhenAvailable` 이 실제로 언제 발화하는가?** 06:00 을 놓쳤을 때 부팅 후 몇 분 뒤에 실행되는지 이벤트 ID **114 (MissedTaskLaunched)** 로 측정하라. Microsoft 는 정확한 지연을 문서화하지 않는다.
|
||
- [ ] **B-21. `RunOnlyIfNetworkAvailable` 이 부팅 직후를 제대로 판별하는가?** 오탐으로 작업이 아예 실행되지 않을 위험이 있다(이벤트 ID **112 JobNoStartWithoutNetwork**). 문제가 있으면 이 설정을 끄고 스크립트 내부 `Wait-Network` 재시도(§14.6)에만 의존하라.
|
||
- [ ] **B-22. `Microsoft-Windows-TaskScheduler/Operational` 채널의 64MB 설정이 실제로 적용되는가?** `wevtutil get-log` 로 `maxSize` 를 확인하라.
|
||
- [ ] **B-23. 토스트 버튼 `-ActivationType Protocol` 로 `dmfcrawler://retry` 가 실제 동작하는가?** §13.3 프로토콜 등록 후 `Start-Process 'dmfcrawler://retry'` 로 먼저 검증하고, 그 다음 토스트 버튼으로 검증하라.
|
||
- [ ] **B-24. Windows 11 Pro 에서 `msg.exe *` 가 비대화형 작업(S4U)에서 호출될 때 동작하는가?** `AllowRemoteRPC=1` 설정이 필요한지 실측하라.
|
||
- [ ] **B-25. BurntToast 의 `-Urgent` 스위치가 이 버전에 존재하는가?** v1.1.0 에서 추가되었다고 하나, 설치된 버전에 따라 없을 수 있다. `notify.ps1` 은 `(Get-Command New-BurntToastNotification).Parameters.ContainsKey('Urgent')` 로 방어했다.
|
||
|
||
---
|
||
|
||
*이 문서는 조사 raw dump `agent-a99bc957be6fc45cc.md` (7,111줄)를 전량 정독하여 작성되었다. 인용문 중 영문 원문은 출처 페이지의 문장을 그대로 옮긴 것이며, 한국어 서술은 그에 대한 해설과 이 프로젝트에 대한 적용 판단이다. `⚠️ 미검증` 표시가 붙은 항목은 출처로 확인되지 않았으므로 구현 전 반드시 실측하라.*
|