Гайды

Автозапуск на Windows: задача расписания или служба

Служба не видит экран, а задача расписания по умолчанию умирает вместе с вашим сеансом. Разбираем оба варианта, показываем настройки S4U и триггер загрузки и объясняем, почему баг возвращался после каждой переустановки.

Симптом, с которого всё началось: сайт работает, пока владелец сервера подключён по 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. Но и это ещё не доказательство. Настоящих проверок две:

  1. Приложение отвечает и отдаёт нужную версию. Открытый порт не доказывает ничего — за ним может сидеть процесс со старым кодом. Проверять надо ручку живости, отдающую версию сборки. Почему так и какие ещё проверки нужны — в статье про мониторинг личного сервиса.
  2. Слушатель порта сидит в сессии 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 останавливается вместе с сеансом, если для учётной записи не включён «задержанный» режим. Настройка живучести, ограничение памяти и корректная остановка фоновых задач там решаются иначе — и разобраны отдельно.

Хотите ИИ, который помнит вас?

Persona держит контекст между чатами и работает на вашем железе — если развернуть её у себя и подключить локальную модель.

Создать аккаунт