Симптом, с которого всё началось: сайт работает, пока владелец сервера подключён по RDP. Отключился — через несколько минут сайт лёг. Первая версия диагноза была «падает приложение». Она была неверной: приложение не падало, его останавливала операционная система.
Причина оказалась в одной строке настроек задачи планировщика. Разбираем её и заодно отвечаем на вопрос, который задают раньше: делать службу или задачу расписания. Автозапуск — второй этап из шести в общем маршруте о том, как поднять свой ИИ-сервис; без него все следующие этапы держатся на том, что вы не выходите из системы.
#Что понадобится
- Windows 10, 11 или Windows Server. Проверялось на Windows Server 2019.
- Права администратора — обязательно, и ниже объясняется, на что именно они нужны.
- Приложение, которое умеет запускаться одной командой и не требует интерактивного ввода. Если это Persona — сначала пошаговая установка на Windows, потому что подписанного установщика у неё нет и всё ставится из командной строки.
- Понимание, нужен ли вашему приложению доступ к экрану. От этого ответа зависит весь выбор.
#Шаг 0. Выбрать между службой и задачей
Это не вопрос вкуса. У службы Windows нет графической сессии: она живёт в сессии 0, где нет рабочего стола, нет окон и нечего снимать. Приложению, которое делает скриншоты, читает активное окно или показывает уведомления, служба не подходит принципиально — не «работает плохо», а не работает.
| Критерий | Служба Windows | Задача расписания |
|---|---|---|
| Доступ к экрану | ✗ нет сессии | ✓ при интерактивном входе |
| Работает без логина | ✓ | ✓ только при S4U |
| Возврат после ребута | ✓ | ✓ только с триггером загрузки |
| Перезапуск при падении | ✓ встроенный | ✓ через RestartOnFailure |
| Настройка без стороннего обёртчика | ✗ Python-скрипту нужен nssm или аналог |
✓ штатно |
| Права на создание | админ | админ на часть параметров |
Практический вывод: обычному веб-приложению подойдёт и то и другое, но задача расписания настраивается штатными средствами без обёрток. Приложению с захватом экрана альтернативы нет. То же ограничение, только в более жёсткой форме, действует в контейнере — почему Docker отменяет захват экрана, разобрано отдельно. На Linux этот выбор выглядит проще, потому что там оба варианта сходятся в одном механизме — автозапуске через systemd.
Дальше — про задачу расписания.
#Шаг 1. Понять, чем вас убьёт значение по умолчанию
Планировщик Windows создаёт задачу с типом входа Interactive — в интерфейсе это переключатель «Выполнять только для вошедших в систему пользователей». Пока ваш сеанс жив, задача тикает. Логаут — Windows останавливает задачу. Всё, что задача породила, умирает вместе с ней.
Именно это и произошло в нашем случае: задача-watchdog держала веб-сервер, логаут убивал задачу, сервер уходил следом. В логах приложения — ничего: оно не падало, его прибили снаружи.
Второе значение по умолчанию, которое ломает всё после перезагрузки: у задачи нет триггера «при запуске системы». Есть повторяющийся триггер по времени — но он не сработает, пока в систему никто не вошёл.
#Шаг 2. Создать задачу с правильным принципалом
Нужный набор — три параметра принципала и два триггера:
$action = New-ScheduledTaskAction -Execute "C:\path\.venv\Scripts\python.exe" `
-Argument "C:\path\ops\watchdog.py" `
-WorkingDirectory "C:\path"
$triggers = @(
New-ScheduledTaskTrigger -AtStartup
$(New-ScheduledTaskTrigger -Once -At (Get-Date) `
-RepetitionInterval (New-TimeSpan -Minutes 1))
)
$principal = New-ScheduledTaskPrincipal -UserId "DOMAIN\Owner" `
-LogonType S4U `
-RunLevel Highest
$settings = New-ScheduledTaskSettingsSet `
-StartWhenAvailable `
-MultipleInstances IgnoreNew `
-DontStopIfGoingOnBatteries `
-AllowStartIfOnBatteries `
-ExecutionTimeLimit (New-TimeSpan -Minutes 10) `
-RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 1)
Register-ScheduledTask -TaskName "MyAppWatchdog" -Action $action `
-Trigger $triggers -Principal $principal -Settings $settings -Force
Что здесь делает каждая важная строка:
LogonType S4U— Service-for-User. Даёт токен пользователя без хранения пароля и означает «выполнять независимо от того, вошёл ли пользователь». Это и есть исправление главного бага.-AtStartup— задача стартует после загрузки системы, когда в неё никто не входил.StartWhenAvailable— догнать пропущенный запуск, если машина была выключена.IgnoreNew— никогда не накладывать второй экземпляр на работающий.ExecutionTimeLimit— короткий лимит нужен именно вместе сIgnoreNew: зависший прогон без лимита заблокирует все следующие навсегда. Ставьте лимит по верхней оценке честного прогона, а не «без ограничения». Если задача делает что-то тяжёлое — распознавание текста, эмбеддинги, — лимит считается по её худшему случаю; про порядок этой фоновой нагрузки есть отдельный разбор, сколько это весит и тормозит ли ПК.RunLevel Highest— запас прав. Не всегда обязателен, но именно он позже сделает ваш деплой сложнее (см. раздел про грабли).
#Шаг 3. Запустить от администратора и понять, почему
Скрипт из шага 2 надо выполнять в PowerShell от имени администратора. Не «под учётной записью, которая состоит в администраторах», а именно с повышением. Разница здесь не теоретическая: у члена группы администраторов при обычном запуске токен отфильтрован UAC, группа помечена как «используется только для запрета».
Экспериментально проверенная граница на нашей машине:
| Операция | Из обычного шелла |
|---|---|
Set-ScheduledTask -Settings ... |
разрешено |
Принципал Interactive / RunLevel Limited |
разрешено |
Принципал LogonType S4U |
отказано (0x80070005) |
Принципал RunLevel Highest |
отказано (0x80070005) |
Триггер AtStartup |
отказано (0x80070005) |
Из этой таблицы следует неочевидное и дорогое: установщик, запущенный без повышения, отработает «успешно» и оставит задачу интерактивной. Он поменяет настройки, на которые прав хватает, и промолчит про три, на которые не хватило. Именно поэтому баг с «работает только при входе владельца» возвращался после каждой переустановки: код возврата установщика был нулевым.
#Проверка результата
Проверять надо не отчёт установщика, а фактическое состояние задачи и фактическое поведение системы.
$t = Get-ScheduledTask -TaskName "MyAppWatchdog"
"$($t.Principal.LogonType) / $($t.Principal.RunLevel)" # ждём: S4U / Highest
$t.Triggers | ForEach-Object { $_.CimClass.CimClassName } # ждём BootTrigger и TimeTrigger
Start-ScheduledTask -TaskName "MyAppWatchdog"
Get-ScheduledTaskInfo -TaskName "MyAppWatchdog" | Format-List LastRunTime, LastTaskResult
LastTaskResult должен быть 0. Но и это ещё не доказательство. Настоящих проверок две:
- Приложение отвечает и отдаёт нужную версию. Открытый порт не доказывает ничего — за ним может сидеть процесс со старым кодом. Проверять надо ручку живости, отдающую версию сборки. Почему так и какие ещё проверки нужны — в статье про мониторинг личного сервиса.
- Слушатель порта сидит в сессии 0. Это и есть доказательство того, что автозапуск переживёт логаут:
Get-NetTCPConnection -LocalPort 8000 -State Listen | ForEach-Object {
$p = Get-CimInstance Win32_Process -Filter "ProcessId=$($_.OwningProcess)"
"PID $($p.ProcessId) SessionId=$($p.SessionId) $($p.Name)"
}
SessionId=0 — задача действительно работает вне вашего сеанса. Финальная приёмка — перезагрузить машину, не входить в систему и открыть сервис с другого устройства.
#Что может пойти не так
Win32_Process.CommandLine приходит пустым. Из неповышенного шелла у чужих процессов командная строка не читается. Привычный фильтр Where-Object { $_.CommandLine -like '*app.web.main*' } даёт ноль строк даже когда сервер работает, и очень легко решить, что процесса нет, и наплодить дублей. Ищите слушателя через Get-NetTCPConnection, а не через командную строку.
Процессов сессии 0 не видно в Диспетчере задач. Вкладка «Процессы» показывает только текущий сеанс. Нужна вкладка «Подробности» с колонкой «ИД сеанса».
taskkill из обычного шелла врёт. По процессу сессии 0, поднятому с RunLevel=Highest, он печатает SUCCESS, возвращает 0 и ничего не убивает. Это не баг ретраев, это отсутствие прав. Отсюда следствие для деплоя: скрипт обновления, который убивает старый процесс и стартует новый, будет докладывать об успехе, а сайт продолжит отдавать старый код. Лечится либо повышенным шеллом для деплоя, либо схемой «попросить, а не убить»: неповышенный процесс кладёт файл-маркер, повышенная задача его валидирует и перезапускает сервис сама, а успех считается доказанным только после подтверждения версии по HTTP.
S4U не даёт сетевых учётных данных. Токен S4U локальный: задача не достучится до сетевых шар и UNC-путей от имени пользователя. Пока данные лежат на локальном диске — проблемы нет. Переезд данных на сетевую шару сломает автозапуск, и придётся выбирать между хранением пароля и групповой управляемой учётной записью.
Профиль пользователя резолвится не туда. Скрипт, который берёт путь через домашний каталог, при неинтерактивном типе входа может получить не то, что ожидает. Прокидывайте пути к данным явными переменными окружения — иначе однажды приложение стартует со свежесозданной пустой базой.
Задача есть, но не тикает. Смотрите Get-ScheduledTaskInfo: если LastRunTime старый, задача либо отключена, либо предыдущий экземпляр висит и IgnoreNew блокирует новые. Второе — прямое следствие отсутствия лимита выполнения.
#Частые вопросы
#Почему не сделать службу через nssm и не мучиться?
Можно, если приложению не нужен экран. Служба даст встроенный перезапуск и понятное управление. Но если в приложении есть захват экрана, чтение активного окна или что-то ещё, требующее рабочего стола, служба отменит эту функцию целиком: в сессии 0 нечего снимать.
#Обязателен ли RunLevel=Highest?
Нет, если ваше приложение слушает порт выше 1024 и работает с файлами своего пользователя. Повышение даёт запас прав на восстановление, но платой становится невозможность управлять этим процессом из обычного терминала. Если деплой у вас идёт из неповышенного шелла и вы не готовы строить схему с маркером-запросом — начните без Highest.
#Как задать задаче переменные окружения?
Планировщик не имеет отдельного поля для переменных. Рабочие варианты: обёртка-скрипт, которая их выставляет и запускает приложение; либо чтение конфигурации из файла по абсолютному пути; либо машинные переменные окружения. Полагаться на пользовательские переменные при S4U не стоит.
#Что делать, если после перезагрузки сервис всё равно не поднялся?
Проверьте три вещи по порядку: есть ли у задачи триггер AtStartup, какой у неё LogonType, и не отвалилась ли она по LastTaskResult. Если триггера или S4U нет — установщик отработал без повышения и молча пропустил именно эти параметры.
#Нужно ли право «Вход в качестве пакетного задания»?
Оно нужно, если задача с S4U отказывается стартовать по правам. Проверяется в локальной политике безопасности, в назначении прав пользователя. Не пытайтесь обойти это, прописав пароль в задачу через ключи /RU /RP — пароль нигде не должен храниться.
#Есть ли аналог этой ловушки на Linux?
Да, другой формы: пользовательский unit останавливается вместе с сеансом, если для учётной записи не включён «задержанный» режим. Настройка живучести, ограничение памяти и корректная остановка фоновых задач там решаются иначе — и разобраны отдельно.