PowerShell на практике — безопасная автоматизация анализа логов, архивирования и отчётов

· Обновлено: · · PowerShell, Windows, Автоматизация, Анализ логов, Эксплуатация, Использование существующих активов

История изменений (2 обновлений, последнее 30 Aug 2026)

Журнал изменений этой статьи. Там, где версия до правки была заархивирована, она остаётся доступной для чтения по постоянной ссылке с DOI.

Комментарии в примере Get-ScheduledTask переведены на русский. Утверждения статьи не менялись.
Русский текст переписан как полноценный технический перевод, а не калька с японского. Утверждения статьи не менялись.
Первая публикация
Цитирование статьи(DOI (зарегистрированный архив): 10.5281/zenodo.21619867)

Приведённые ниже DOI относятся к ранее зарегистрированным архивным версиям, которые могут отличаться от текущего текста. Для ссылки на текущий текст используйте URL этой страницы.

Го Комура (2026). PowerShell на практике — безопасная автоматизация анализа логов, архивирования и отчётов. KomuraSoft LLC. https://comcomponent.com/ru/blog/2026/06/02/000-powershell-script-log-maintenance-automation/

DOI (зарегистрированный архив)
10.5281/zenodo.21619867
DOI (последняя зарегистрированная версия)
10.5281/zenodo.21619868

1. Сначала зафиксируйте рамки

В предыдущей статье мы разобрали базовые команды PowerShell, конвейер, вывод в CSV, JSON, скрипты .ps1 и безопасную проверку через -WhatIf. Здесь продолжаем ту же линию и пишем чуть более крупный скрипт, которым реально пользоваться в работе. Тема — такая эксплуатационная задача:

  1. Разобрать логи
  2. Собрать строки с ошибками в CSV
  3. Составить список старых логов как кандидатов на архивирование
  4. При необходимости переместить старые логи
  5. Оставить результаты запуска как артефакты

В прикладном PowerShell важно не удлинять команды, а выстроить такой поток:

  1. Отделить настройки
  2. Сделать этап только чтения
  3. Сохранить вывод
  4. Вынести изменяющие операции в функции
  5. Сделать пробный прогон через -WhatIf
  6. К автоматическому запуску переходить в последнюю очередь

Автоматизация — это не «убрать проверку человеком». Это зафиксировать, что именно нужно проверять, и сделать так, чтобы при каждом запуске оставались одни и те же артефакты.

Код из статьи опубликован на GitHub как готовый к запуску набор примеров: финальный скрипт, файл конфигурации, скрипт, который поднимает окружение с тестовыми логами, и тесты Pester на анализ, пробный прогон и перемещение.

powershell-script-log-maintenance-automation - komurasoft-blog-samples (GitHub)

Как эта статья стыкуется с двумя предыдущими

По PowerShell идут три связанные статьи. Это не нумерованная серия, но если читать в таком порядке, линия становится цельной.

Порядок Статья Что закрывает
1 Основы команд PowerShell — операции, которые стоит выучить первыми, и как использовать их безопасно Поиск команд, конвейер, CSV / JSON, .ps1, -WhatIf
2 Практические команды PowerShell — мелкие приёмы для повседневной работы Блоки для агрегации, сравнения, выборки и записи артефактов
3 Эта статья Собрать блоки в один эксплуатационный скрипт: настройки, артефакты, пробный прогон, регулярный запуск

Из обязательного достаточно материала первой статьи. Вторую можно не читать — эту всё равно можно пройти. «Предыдущая статья» в начале как раз про первую.

На схеме сплошная линия обозначает отношение, которое выполняется всегда, а пунктирная — условное отношение (условия указаны в пояснении к каждому отношению на странице сведений). Полный список отношений (всего 16, с доказательствами и степенью уверенности) и определения основных понятий собраны на странице сведений карты знаний (на японском). Данные: JSON-LD / Turtle

2. Что получится на выходе

На этот раз пишем скрипт Invoke-LogMaintenance.ps1.

Основные возможности такие.

Возможность Что делает
Поиск логов Ищет файлы .log в указанной папке и во вложенных
Ограничение по периоду Разбирает только логи, изменённые за последние N дней
Выборка ошибок Достаёт строки с ERROR, WARN, FATAL и т. п.
Вывод в CSV Сохраняет результаты поиска в log-hits.csv
Список старых логов Пишет логи старше N дней в archive-targets.csv
Архивирование Перемещает старые логи в отдельную папку
Пробный прогон При -Preview файлы не перемещаются, показывается только план
Запись запуска Сохраняет transcript, summary JSON и результирующие CSV

Удаления нет. В этой первой прикладной части мы останавливаемся на более безопасном «перемещении», а не на удалении.

3. Структура папок

Для примера возьмём такую раскладку.

C:\Ops
  Invoke-LogMaintenance.ps1
  log-maintenance.json

C:\App\Logs
  app.log
  batch.log
  old
    app-202401.log

C:\App\Reports
  20260602-030000
    log-hits.csv
    archive-targets.csv
    archive-result.csv
    summary.json
    transcript.txt

C:\App\Archive
  20260602-030000
    old
      app-202401.log

Если тело скрипта и файл конфигурации разведены, проще подменять окружения. Можно менять только пути: в разработке C:\Test\Logs, в production — D:\App\Logs.

4. Файл конфигурации

Сначала создайте log-maintenance.json.

{
  "LogPath": "C:\\App\\Logs",
  "OutputPath": "C:\\App\\Reports",
  "Days": 7,
  "Patterns": [
    "ERROR",
    "WARN",
    "FATAL"
  ],
  "ArchiveDays": 90,
  "ArchivePath": "C:\\App\\Archive"
}

Поля означают следующее.

Параметр Смысл
LogPath Папка логов, которую нужно разобрать
OutputPath Куда писать отчёты
Days За сколько последних дней смотреть логи
Patterns Строки и шаблоны для поиска
ArchiveDays С какого возраста лог становится кандидатом на архивирование
ArchivePath Папка назначения архива

JSON позволяет менять условия, не правя тело скрипта.

В PowerShell ConvertFrom-Json превращает JSON в объект. Обратно, чтобы сохранить результат обработки в JSON, используют ConvertTo-Json. ConvertTo-Json — командлет, который сериализует объект в строку JSON; для глубокой вложенности важно задавать -Depth.

Patterns трактуется как регулярное выражение

Одно место лучше оговорить сразу. Значения Patterns в итоге уходят в Select-String -Pattern. По умолчанию Select-String читает это значение как регулярное выражение.

То есть ERROR. означает «ERROR и любой один символ следом» и попадёт на ERRORS и ERROR:. Строка, где есть только ERROR, наоборот, не совпадёт. Строка вроде C:\App с обратными слэшами тоже, если оставить как есть, воспринимается как управляющие символы регулярного выражения.

  • Нужно именно регулярное выражение: пишите как есть. Можно "ERROR|FATAL" или "\[ERROR\]"
  • Нужно буквальное совпадение строки: положите в JSON результат [regex]::Escape("ERROR.") либо добавьте -SimpleMatch к Select-String в скрипте

Кроме того, Select-String по умолчанию не различает регистр. Строка с error попадёт так же, как с ERROR. Если регистр важен, добавьте -CaseSensitive.

5. Сначала только чтение

Не начинайте с перемещения файлов. Сначала найдите логи и сохраните их в CSV.

$config = Get-Content .\log-maintenance.json -Raw -Encoding UTF8 | ConvertFrom-Json

$since = (Get-Date).AddDays(-[int]$config.Days)

$files = Get-ChildItem -LiteralPath $config.LogPath -Filter *.log -File -Recurse |
  Where-Object { $_.LastWriteTime -ge $since }

$files |
  Select-Object FullName, Length, LastWriteTime

Дальше ищите по содержимому логов.

$patterns = [string[]]$config.Patterns

Select-String -LiteralPath ($files | Select-Object -ExpandProperty FullName) -Pattern $patterns |
  Select-Object Path, LineNumber, Pattern, Line |
  Export-Csv .\log-hits.csv -NoTypeInformation -Encoding UTF8

До этого места всё только читается. Даже на папке из production стоит остановиться именно здесь.

6. Список старых логов

Дальше составьте список кандидатов на архивирование.

$limit = (Get-Date).AddDays(-[int]$config.ArchiveDays)

$targets = Get-ChildItem -LiteralPath $config.LogPath -Filter *.log -File -Recurse |
  Where-Object { $_.LastWriteTime -lt $limit } |
  Sort-Object LastWriteTime

$targets |
  Select-Object FullName, Length, LastWriteTime |
  Export-Csv .\archive-targets.csv -NoTypeInformation -Encoding UTF8

На этом шаге файлы ещё не перемещаются. Откройте archive-targets.csv и проверьте, не слишком ли много кандидатов и не ошиблись ли вы папкой.

7. Изменяющие операции вынесите в функции

Перемещение файлов и другие изменяющие операции отделите от чтения.

Если у функции в PowerShell указан SupportsShouldProcess, она умеет -WhatIf и -Confirm. -WhatIf не выполняет действие, а показывает, что собирались изменить; -Confirm спрашивает подтверждение перед выполнением. Подробности — в статье Microsoft Learn about_Functions_CmdletBindingAttribute.

function Move-OldLogFile {
  [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = "Medium")]
  param(
    [Parameter(Mandatory)]
    [System.IO.FileInfo[]]$File,

    [Parameter(Mandatory)]
    [string]$SourceRoot,

    [Parameter(Mandatory)]
    [string]$ArchiveRoot
  )

  foreach ($item in $File) {
    $relativePath = [System.IO.Path]::GetRelativePath($SourceRoot, $item.FullName)
    $destination = Join-Path $ArchiveRoot $relativePath
    $destinationDirectory = Split-Path -Path $destination -Parent

    if ($PSCmdlet.ShouldProcess($item.FullName, "Move to $destination")) {
      if (-not [System.IO.Directory]::Exists($destinationDirectory)) {
        [System.IO.Directory]::CreateDirectory($destinationDirectory) | Out-Null
      }

      Move-Item -LiteralPath $item.FullName -Destination $destination -ErrorAction Stop

      [pscustomobject]@{
        Source      = $item.FullName
        Destination = $destination
        Status      = "Moved"
        Message     = ""
      }
    }
    else {
      [pscustomobject]@{
        Source      = $item.FullName
        Destination = $destination
        Status      = "Preview"
        Message     = ""
      }
    }
  }
}

Главное: $PSCmdlet.ShouldProcess() стоит непосредственно перед Move-Item. Решение «менять или нет» принимается не снаружи функции, а в последний момент перед самим изменением.

8. Готовый скрипт

Ниже — собранная версия. Имя файла — Invoke-LogMaintenance.ps1.

# Invoke-LogMaintenance.ps1
#Requires -Version 7.0

[CmdletBinding()]
param(
  [ValidateNotNullOrEmpty()]
  [string]$ConfigPath = ".\log-maintenance.json",

  [switch]$Preview,

  [switch]$SkipArchive,

  [switch]$SkipTranscript
)

Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"

function Ensure-Directory {
  [CmdletBinding()]
  param(
    [Parameter(Mandatory)]
    [string]$Path
  )

  if (-not [System.IO.Directory]::Exists($Path)) {
    [System.IO.Directory]::CreateDirectory($Path) | Out-Null
  }
}

function Import-LogMaintenanceConfig {
  [CmdletBinding()]
  param(
    [Parameter(Mandatory)]
    [string]$Path
  )

  if (-not (Test-Path -LiteralPath $Path)) {
    throw "Config file not found: $Path"
  }

  $config = Get-Content -LiteralPath $Path -Raw -Encoding UTF8 | ConvertFrom-Json

  foreach ($name in @("LogPath", "OutputPath", "Days", "Patterns", "ArchiveDays", "ArchivePath")) {
    if (-not ($config.PSObject.Properties.Name -contains $name)) {
      throw "Config value missing: $name"
    }
  }

  if ([string]::IsNullOrWhiteSpace([string]$config.LogPath)) {
    throw "LogPath is empty."
  }

  if (-not (Test-Path -LiteralPath $config.LogPath)) {
    throw "LogPath not found: $($config.LogPath)"
  }

  if ([string]::IsNullOrWhiteSpace([string]$config.OutputPath)) {
    throw "OutputPath is empty."
  }

  if ([string]::IsNullOrWhiteSpace([string]$config.ArchivePath)) {
    throw "ArchivePath is empty."
  }

  if (@($config.Patterns).Count -eq 0) {
    throw "Patterns is empty."
  }

  if ([int]$config.Days -lt 1) {
    throw "Days must be 1 or greater."
  }

  if ([int]$config.ArchiveDays -lt 1) {
    throw "ArchiveDays must be 1 or greater."
  }

  return $config
}

function Export-CsvWithHeader {
  [CmdletBinding()]
  param(
    [Parameter(Mandatory)]
    [object[]]$InputObject,

    [Parameter(Mandatory)]
    [string]$Path,

    [Parameter(Mandatory)]
    [string[]]$Header
  )

  if ($InputObject.Count -gt 0) {
    $InputObject |
      Export-Csv -LiteralPath $Path -NoTypeInformation -Encoding UTF8
  }
  else {
    ($Header -join ",") |
      Set-Content -LiteralPath $Path -Encoding UTF8
  }
}

function Get-LogHit {
  [CmdletBinding()]
  param(
    [Parameter(Mandatory)]
    [string]$LogPath,

    [Parameter(Mandatory)]
    [ValidateRange(1, 3650)]
    [int]$Days,

    [Parameter(Mandatory)]
    [string[]]$Pattern
  )

  $since = (Get-Date).AddDays(-$Days)

  $files = @(
    Get-ChildItem -LiteralPath $LogPath -Filter *.log -File -Recurse -ErrorAction Stop |
      Where-Object { $_.LastWriteTime -ge $since }
  )

  Write-Verbose "Recent log files: $($files.Count)"

  if ($files.Count -eq 0) {
    return @()
  }

  $paths = $files | Select-Object -ExpandProperty FullName

  Select-String -LiteralPath $paths -Pattern $Pattern -ErrorAction Stop |
    ForEach-Object {
      [pscustomobject]@{
        Path       = $_.Path
        LineNumber = $_.LineNumber
        Pattern    = $_.Pattern
        Line       = $_.Line.Trim()
      }
    }
}

function Get-OldLogFile {
  [CmdletBinding()]
  param(
    [Parameter(Mandatory)]
    [string]$LogPath,

    [Parameter(Mandatory)]
    [ValidateRange(1, 3650)]
    [int]$ArchiveDays
  )

  $limit = (Get-Date).AddDays(-$ArchiveDays)

  Get-ChildItem -LiteralPath $LogPath -Filter *.log -File -Recurse -ErrorAction Stop |
    Where-Object { $_.LastWriteTime -lt $limit } |
    Sort-Object LastWriteTime
}

function Move-OldLogFile {
  [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = "Medium")]
  param(
    [Parameter(Mandatory)]
    [System.IO.FileInfo[]]$File,

    [Parameter(Mandatory)]
    [string]$SourceRoot,

    [Parameter(Mandatory)]
    [string]$ArchiveRoot
  )

  foreach ($item in $File) {
    $relativePath = [System.IO.Path]::GetRelativePath($SourceRoot, $item.FullName)
    $destination = Join-Path $ArchiveRoot $relativePath
    $destinationDirectory = Split-Path -Path $destination -Parent

    if (Test-Path -LiteralPath $destination) {
      $name = [System.IO.Path]::GetFileNameWithoutExtension($item.Name)
      $ext = $item.Extension
      $destination = Join-Path $destinationDirectory ("{0}_{1:yyyyMMddHHmmss}{2}" -f $name, $item.LastWriteTime, $ext)
    }

    if ($PSCmdlet.ShouldProcess($item.FullName, "Move to $destination")) {
      Ensure-Directory -Path $destinationDirectory

      Move-Item -LiteralPath $item.FullName -Destination $destination -ErrorAction Stop

      [pscustomobject]@{
        Source      = $item.FullName
        Destination = $destination
        Status      = "Moved"
        Message     = ""
      }
    }
    else {
      [pscustomobject]@{
        Source      = $item.FullName
        Destination = $destination
        Status      = "Preview"
        Message     = ""
      }
    }
  }
}

$config = Import-LogMaintenanceConfig -Path $ConfigPath

$runStamp = Get-Date -Format "yyyyMMdd-HHmmss"
$reportDir = Join-Path ([string]$config.OutputPath) $runStamp
Ensure-Directory -Path $reportDir

$transcriptStarted = $false
$transcriptPath = Join-Path $reportDir "transcript.txt"

try {
  if (-not $SkipTranscript) {
    Start-Transcript -Path $transcriptPath -Force | Out-Null
    $transcriptStarted = $true
  }

  Write-Host "Report directory: $reportDir"

  $hits = @(
    Get-LogHit `
      -LogPath ([string]$config.LogPath) `
      -Days ([int]$config.Days) `
      -Pattern ([string[]]$config.Patterns)
  )

  $hitCsv = Join-Path $reportDir "log-hits.csv"
  Export-CsvWithHeader `
    -InputObject $hits `
    -Path $hitCsv `
    -Header @("Path", "LineNumber", "Pattern", "Line")

  $oldFiles = @(
    Get-OldLogFile `
      -LogPath ([string]$config.LogPath) `
      -ArchiveDays ([int]$config.ArchiveDays)
  )

  $archiveTargets = @(
    $oldFiles |
      Select-Object FullName, Length, LastWriteTime
  )

  $archiveTargetCsv = Join-Path $reportDir "archive-targets.csv"
  Export-CsvWithHeader `
    -InputObject $archiveTargets `
    -Path $archiveTargetCsv `
    -Header @("FullName", "Length", "LastWriteTime")

  $moveResults = @()

  if ($SkipArchive) {
    Write-Host "Archive skipped."
  }
  elseif ($oldFiles.Count -eq 0) {
    Write-Host "No archive targets."
  }
  else {
    $archiveRunRoot = Join-Path ([string]$config.ArchivePath) $runStamp

    $moveResults = @(
      Move-OldLogFile `
        -File $oldFiles `
        -SourceRoot ([string]$config.LogPath) `
        -ArchiveRoot $archiveRunRoot `
        -WhatIf:$Preview
    )
  }

  $archiveResultCsv = Join-Path $reportDir "archive-result.csv"
  Export-CsvWithHeader `
    -InputObject $moveResults `
    -Path $archiveResultCsv `
    -Header @("Source", "Destination", "Status", "Message")

  $summary = [pscustomobject]@{
    CheckedAt          = (Get-Date).ToString("s")
    ComputerName       = $env:COMPUTERNAME
    LogPath            = [string]$config.LogPath
    ReportDirectory    = $reportDir
    HitCount           = $hits.Count
    ArchiveTargetCount = $oldFiles.Count
    ArchiveResultCount = $moveResults.Count
    Preview            = [bool]$Preview
    SkipArchive        = [bool]$SkipArchive
  }

  $summaryPath = Join-Path $reportDir "summary.json"
  $summary |
    ConvertTo-Json -Depth 5 |
    Set-Content -LiteralPath $summaryPath -Encoding UTF8

  Write-Host "Finished."
  Write-Host "Hits: $($hits.Count)"
  Write-Host "Archive targets: $($oldFiles.Count)"
}
catch {
  $errorPath = Join-Path $reportDir "error.txt"
  $_ | Out-String | Set-Content -LiteralPath $errorPath -Encoding UTF8
  Write-Error "Failed: $($_.Exception.Message)"
  exit 1
}
finally {
  if ($transcriptStarted) {
    Stop-Transcript | Out-Null
  }
}

9. Примеры запуска

Сначала разберите логи, не архивируя.

.\Invoke-LogMaintenance.ps1 -ConfigPath .\log-maintenance.json -SkipArchive

Дальше посмотрите план архивирования.

.\Invoke-LogMaintenance.ps1 -ConfigPath .\log-maintenance.json -Preview

Если указан -Preview, перемещение старых логов обрабатывается как -WhatIf.

На этом этапе смотрите три файла:

  • log-hits.csv
  • archive-targets.csv
  • archive-result.csv

Если всё в порядке, запускайте без -Preview.

.\Invoke-LogMaintenance.ps1 -ConfigPath .\log-maintenance.json

Чтобы увидеть подробности, добавьте -Verbose.

.\Invoke-LogMaintenance.ps1 -ConfigPath .\log-maintenance.json -Preview -Verbose

10. Какие файлы появляются

При запуске под OutputPath создаётся папка с меткой даты и времени, например C:\App\Reports\20260602-030000.

Внутри появляются такие файлы.

Файл Содержимое
log-hits.csv Строки, отмеченные как ошибки или предупреждения
archive-targets.csv Старые логи, попавшие в кандидаты на архивирование
archive-result.csv Результат перемещения или результат режима Preview
summary.json Сводка по количеству и условиям запуска
transcript.txt Запись сеанса PowerShell
error.txt Подробности, если произошла ошибка

Start-Transcript записывает команды сеанса PowerShell и вывод консоли в текстовый файл. В эксплуатационном скрипте потом проще понять, когда, при каких условиях и что именно получилось.

Как должен выглядеть «правильный» результат

В первый раз важнее не то, что файлы появились, а то, что внутри они такие, как задумано. Содержимое логов здесь учебное, но набор столбцов и полей совпадает с тем, что выдаёт скрипт из статьи.

В log-hits.csv четыре столбца: Path / LineNumber / Pattern / Line. Export-Csv по умолчанию берёт каждое поле в ".

"Path","LineNumber","Pattern","Line"
"C:\App\Logs\app.log","128","ERROR","2026-06-02 02:14:51 [ERROR] OrderService: timeout while calling /api/stock"
"C:\App\Logs\app.log","301","WARN","2026-06-02 02:41:03 [WARN] OrderService: retry 1/3"
"C:\App\Logs\batch.log","57","FATAL","2026-06-02 03:00:12 [FATAL] nightly batch aborted"

В столбец Pattern попадает строка того шаблона, который сработал. Она соответствует значениям из Patterns в файле конфигурации, поэтому по ней можно считать, «какой ключ сколько раз сработал».

В archive-targets.csv три столбца: FullName / Length / LastWriteTime.

"FullName","Length","LastWriteTime"
"C:\App\Logs\old\app-202401.log","10485760","2024/01/31 23:59:58"

В archive-result.csv четыре столбца: Source / Destination / Status / Message. При -Preview в Status будет Preview, при реальном запуске — Moved.

"Source","Destination","Status","Message"
"C:\App\Logs\old\app-202401.log","C:\App\Archive\20260602-030000\old\app-202401.log","Moved",""

Формат даты зависит от того, как Export-Csv строит строку по локали ОС, поэтому в разных средах он может отличаться. Если CSV уходит в следующую систему, это стоит проверить заранее.

summary.json — сводка по количеству и условиям запуска. Пути по правилам JSON экранируются как \\.

{
  "CheckedAt": "2026-06-02T03:00:07",
  "ComputerName": "OPS-01",
  "LogPath": "C:\\App\\Logs",
  "ReportDirectory": "C:\\App\\Reports\\20260602-030000",
  "HitCount": 3,
  "ArchiveTargetCount": 1,
  "ArchiveResultCount": 1,
  "Preview": false,
  "SkipArchive": false
}

Для мониторинга и ежедневной проверки удобно смотреть только summary.json. CSV можно открывать в дни, когда подскочил HitCount или резко вырос ArchiveTargetCount.

CSV создаётся и тогда, когда попаданий нет. Export-CsvWithHeader в этом случае пишет файл только с строкой заголовка. Так не путают «файла нет» и «совпадений было 0».

11. Регулярный запуск через Планировщик заданий

Если ручной запуск прошёл без проблем, скрипт можно ставить на расписание в Планировщике заданий.

Сначала — пример ежедневного запуска в 3:00 ночи.

$scriptPath = "C:\Ops\Invoke-LogMaintenance.ps1"
$configPath = "C:\Ops\log-maintenance.json"

$action = New-ScheduledTaskAction `
  -Execute "pwsh.exe" `
  -Argument "-NoProfile -File `"$scriptPath`" -ConfigPath `"$configPath`"" `
  -WorkingDirectory "C:\Ops"

$trigger = New-ScheduledTaskTrigger -Daily -At 3:00

Register-ScheduledTask `
  -TaskName "AppLogMaintenance" `
  -Action $action `
  -Trigger $trigger `
  -Description "Collect app log errors and archive old logs"

New-ScheduledTaskAction создаёт объект команды, которую выполнит задание, а New-ScheduledTaskTrigger задаёт условие запуска: ежедневно, еженедельно, при входе в систему и т. д. Затем Register-ScheduledTask регистрирует задание на локальном компьютере.

Проверьте, что задание действительно зарегистрировано

Если команда регистрации прошла, это ещё не значит, что задание «будет работать». Перед тем как ждать срабатывания, посмотрите глазами, что именно записалось.

В графическом интерфейсе откройте Планировщик заданий (taskschd.msc) и в левой панели выберите «Библиотека планировщика заданий» — в списке должен появиться только что зарегистрированный AppLogMaintenance. Смотрите такие столбцы.

Столбец На что смотреть
Состояние Если «Готово», задание включено. Если «Отключено», оно не стартует даже при срабатывании триггера
Триггеры Совпадает ли условие с задуманным, например «ежедневно в 3:00»
Следующий запуск Ближайшее запланированное время. Если пусто, проверьте триггер и то, включено ли задание
Результат последнего запуска Появляется после выполнения. 0x0 — успех, 0x1 — обычная ошибка, 0x41301 — задание ещё выполняется

Если этих столбцов нет в списке, щёлкните правой кнопкой по заголовку столбца и добавьте нужные.

То же самое можно проверить из PowerShell. На машине без GUI так обычно быстрее.

# Проверить регистрацию задания
Get-ScheduledTask -TaskName "AppLogMaintenance" |
  Select-Object TaskName, TaskPath, State

# Проверить результат выполнения
Get-ScheduledTaskInfo -TaskName "AppLogMaintenance" |
  Select-Object TaskName, LastRunTime, LastTaskResult, NextRunTime

Если LastTaskResult не 0, сначала смотрите error.txt и transcript.txt в папке отчётов.

Но если само задание не стартовало, скрипт не выполнил ни одной строки, и папки отчётов может не быть вовсе. Тогда смотрите не свойства, открытые через правый щелчок, а вкладку «Журнал» в нижней части окна, когда задание выбрано в списке. Журнал часто выключен по умолчанию: включите его командой «Включить журнал всех заданий» в правой панели, затем правым щелчком выберите «Выполнить» и проверьте ручной запуск.

В production дополнительно проверьте следующее.

  • у учётной записи задания есть право читать папку логов
  • есть право писать в папку архива
  • pwsh.exe находится в PATH
  • скрипт проходит политику выполнения и правила подписи
  • ручной запуск и запуск заданием дают один и тот же результат
  • при сбое можно посмотреть error.txt и журнал задания

12. Типичные сбои

Симптом Причина Что делать
Логи не находятся Неверно указан LogPath Проверьте через Test-Path и Get-ChildItem
CSV получается пустым За выбранный период подходящих логов нет Расширьте Days и проверьте снова
Японский текст отображается некорректно Кодировка лога не та, что ожидалась Проверьте кодировки ввода и вывода
Как задание не работает Другая учётная запись или другая рабочая папка Проверьте WorkingDirectory и права
Слишком много кандидатов на архивирование Слишком маленькое ArchiveDays Посмотрите archive-targets.csv и подберите значение
Путь назначения не тот Не учли правило сохранения относительного пути Проверьте Destination через -Preview
Падает только в production Другие права, политика или заблокированные файлы Смотрите error.txt и transcript.txt

Особенно при запуске через Планировщик заданий учётная запись «под которой запускали вы» и «под которой работает задание» часто различаются. Если вручную скрипт идёт, а как задание падает, сначала проверьте права и рабочую папку.

13. Как дорабатывать скрипт

Этот скрипт рассчитан не столько на запуск «как есть», сколько на постепенную подгонку под площадку. Типичные доработки такие.

Что нужно Что менять
Захватить ещё и .txt Изменить Get-ChildItem -Filter *.log
Посмотреть несколько строк вокруг ERROR По результату Select-String взять соседние строки через Get-Content
Сжимать перед архивированием Добавить Compress-Archive перед Move-OldLogFile
Слать уведомление по почте Добавить отправку на основе summary.json
Развести настройки по приложениям Подготовить несколько JSON и разнести задания
Автоматизировать вплоть до удаления Сначала какое-то время поработать в режиме перемещения, потом решать

Но безопаснее не класть всё сразу в первую версию. Для эксплуатационного скрипта важнее не число функций, а возможность разобраться, когда что-то сломалось.

Для почтовых уведомлений сначала выберите способ

«Хотим почтовое уведомление» просят часто, но именно здесь способ нужно выбрать до реализации.

В Microsoft Learn у Send-MailMessage прямо написано obsolete (устарел): безопасное подключение к SMTP-серверу он не гарантирует, поэтому им пользоваться не рекомендуют. Прямого преемника среди командлетов PowerShell нет; как замена указаны библиотеки вроде MailKit, а в среде Exchange Online — Send-MgUserMail из Microsoft Graph PowerShell SDK.

Способ зависит от среды.

Среда Как уведомлять
Есть внутренний SMTP-релей, аутентификация и TLS не нужны Send-MailMessage ещё отработает, но на случай будущего удаления спрячьте его в одну функцию
Нужны SMTP с аутентификацией или TLS Берите библиотеку вроде MailKit
Среда Microsoft 365 Идите через Microsoft Graph (Send-MgUserMail)
Почта не обязательна Webhook в Teams или выгрузка файла в систему мониторинга

В любом варианте уведомление должно только читать summary.json и отправлять его, отдельно от основной работы. На практике важно, чтобы анализ логов и архивирование уже были завершены, даже если уведомление не ушло.

14. Чек-лист перед регулярным запуском

Перед тем как ставить скрипт PowerShell на расписание, проверьте следующее.

  • сначала запускали только чтение с -SkipArchive
  • затем смотрели план перемещения через -Preview
  • кандидаты в archive-targets.csv выглядят правдоподобно
  • значения Destination в archive-result.csv совпадают с ожиданием
  • в папке вывода остались артефакты с меткой даты и времени
  • при ошибке появляется error.txt
  • проверили права учётной записи, от которой работает задание
  • проверили политику выполнения, подпись и внутренние правила
  • начинаете с перемещения, а не сразу с удаления
  • заранее решено, куда возвращать файлы при откате

15. Итог

Прикладной PowerShell не требует набора сложных конструкций. На работе помогает заранее собранный шаблон.

  • Вынести настройки в JSON
  • Сначала сделать этап только чтения
  • Оставлять артефакты в CSV и JSON
  • Изменяющие операции выносить в функции
  • Держать пробный прогон на уровне -WhatIf
  • Оставлять возможность разобрать запуск по transcript и error.txt
  • Сначала проверить вручную, потом ставить на расписание

Скрипт в статье посвящён анализу логов и архивированию, но тот же подход годится и для других задач.

  • Наведение порядка в файлах
  • Формирование отчётов
  • Агрегация CSV
  • Замена пакетной обработки
  • Инвентаризация старых активов
  • Ежедневные и ежемесячные эксплуатационные проверки

PowerShell удобен и однострочником, но в рабочей задаче безопаснее держать такой порядок:

Посмотреть → Записать → Сделать пробный прогон → Выполнить → Оставить артефакты

Если придерживаться этой формы, PowerShell перестаёт быть просто способом сократить ручную работу и становится небольшим рабочим приложением, которое стабилизирует эксплуатацию.

Справочные ссылки

Недавние статьи с теми же тегами помогут подробнее изучить близкие темы.

Эти страницы показывают тему статьи в более широком контексте услуг и решений.

Статья напрямую связана со следующими услугами.

Частые вопросы

Вопросы, которые часто возникают при консультациях по теме статьи.

С чего начать автоматизацию анализа логов в PowerShell?
Не пишите сразу изменяющие операции вроде перемещения или удаления — сначала сделайте только чтение. Доведите до конца поиск нужных логов через Get-ChildItem, выборку строк с ошибками через Select-String и вывод в CSV. Даже если пробуете на папке из production, остановитесь на этом этапе чтения и проверьте результат. Дальше безопаснее идти поэтапно: сначала список кандидатов на архивирование, затем вынести перемещение в отдельную функцию.
Как встроить -WhatIf в PowerShell-скрипт?
Если в атрибут CmdletBinding функции добавить SupportsShouldProcess, у неё появляются -WhatIf и -Confirm. Главное — вызвать $PSCmdlet.ShouldProcess() непосредственно перед изменяющей операцией (например, Move-Item), чтобы решение «менять или нет» принималось в последний момент. В скрипте из этой статьи переключатель -Preview передаётся как -WhatIf:$Preview, поэтому план перемещения можно проверить, не двигая файлы.
Почему скрипт PowerShell работает вручную, но падает в Планировщике заданий?
Типичная причина — учётная запись задания не та, под которой вы запускали скрипт сами. Проверьте, есть ли у неё право читать папку логов и писать в папку архива, верна ли рабочая папка (WorkingDirectory), находится ли pwsh.exe в PATH и проходит ли скрипт политику выполнения и правила подписи. Важно заранее сделать так, чтобы при сбое причину можно было найти по error.txt, transcript.txt и журналу задания.
Можно ли сразу автоматизировать и удаление старых логов?
Автоматизировать удаление с первого дня не стоит. Скрипт из этой статьи тоже останавливается на более безопасном «перемещении», а не удалении. Сначала поработайте в режиме перемещения, посмотрите по артефактам вроде archive-targets.csv и archive-result.csv, что сюрпризов нет, и только потом думайте об удалении. Заранее решите, куда возвращать файлы, если понадобится откат.

Об авторе

Страница с профилем автора статьи.

Го Комура

Представитель KomuraSoft LLC

Специализируется на разработке программного обеспечения для Windows, техническом консалтинге и расследовании сбоев, особенно в проектах с унаследованными системами и трудно воспроизводимыми ошибками.

Публичные ссылки

Вернуться в блог