PowerShell 脚本进阶 ── 安全地实现日志排查、归档与报表自动化

· · PowerShell, Windows, 自动化, 日志排查, 运维改善, 存量资产利用

1. 首先应该掌握的内容

上一篇文章整理了 PowerShell 的基本命令、管道、CSV 输出、JSON、.ps1 脚本,以及通过 -WhatIf 进行的安全确认。本文作为续篇,来制作一个在实务中更好用、规模稍大一些的脚本。题材是下面这类运维工作。

  1. 排查日志
  2. 把错误行汇总到 CSV
  3. 把旧日志列为归档候选清单
  4. 必要时移动旧日志
  5. 把执行结果作为留存记录保存下来

PowerShell 进阶的重点,并不是把命令写得更长,而是搭建出下面这样的流程。

  1. 拆分配置
  2. 编写读取处理
  3. 留存输出结果
  4. 把变更处理拆分为函数
  5. -WhatIf 进行预演
  6. 最后再考虑自动执行

所谓自动化,并不是「省去人工确认」,而是把该确认的位置固定下来,让每次都能留下同样的记录。

另外,本文中出现的代码,已作为可直接运行的完整示例(完成版脚本、配置文件、用于创建虚拟日志环境的脚本,以及验证排查、预演、移动的 Pester 测试)发布在 GitHub 上。

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

2. 本次要制作的内容

本次要制作一个名为 Invoke-LogMaintenance.ps1 的脚本。

主要功能如下。

功能 内容
日志搜索 搜索指定文件夹下的 .log 文件
指定期间 只排查最近 N 天内更新过的日志
错误提取 提取包含 ERRORWARNFATAL 等的行
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,生产环境使用 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-JsonConvertTo-Json 是把对象转换为 JSON 字符串的 Cmdlet,在处理较深的层级结构时,指定 -Depth 很重要。

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

到这里为止都只是读取。即使要在生产文件夹中尝试,也先在这一步停下来。

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 来处理。

这个阶段要确认的文件有以下 3 个。

  • 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 会话中的命令与控制台输出记录到文本文件中的 Cmdlet。对于运维脚本,这样便于事后确认「什么时候、在什么条件下、输出了什么」。

11. 通过任务计划程序定期执行

手动执行没有问题之后,就可以通过任务计划程序定期执行。

首先是一个每天凌晨 3 点执行的示例。

$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 把任务注册到本地计算机上。

在生产环境中运维时,还要确认以下几点。

  • 执行用户拥有日志文件夹的读取权限
  • 拥有归档目标位置的写入权限
  • pwsh.exe 的路径可用
  • 符合脚本的执行策略与签名规则
  • 手动执行与任务执行的结果一致
  • 失败时可以查看 error.txt 与任务历史

12. 常见问题

症状 原因 对策
找不到日志 LogPath 配置有误 Test-PathGet-ChildItem 确认
CSV 为空 目标期间内没有对应的日志 扩大 Days 的范围后再确认
日文出现乱码 日志的字符编码与预期不同 确认输入输出的字符编码
在任务计划程序中无法运行 执行用户或工作文件夹不同 确认 WorkingDirectory 与权限
归档对象过多 ArchiveDays 设置得太短 查看 archive-targets.csv 后再调整
移动位置与预期不同 未理解相对路径的保留规则 -Preview 确认 Destination
只有生产环境失败 权限、策略、文件被锁定等差异 确认 error.txttranscript.txt

尤其是在任务计划程序中运行时,「自己手动执行」和「任务的执行用户」可能并不相同。如果手动执行没问题、但在任务中失败,请先怀疑权限和工作文件夹。

13. 二次改造时的思路

这个脚本的前提是要根据现场情况逐步改造,而不是直接照搬使用。下面列举一些常见的改造示例。

想实现的效果 改造位置
也想把 .txt 纳入对象 修改 Get-ChildItem -Filter *.log
也想查看 ERROR 前后的几行 基于 Select-String 的结果,用 Get-Content 获取前后行
想先压缩再归档 Move-OldLogFile 之前加入 Compress-Archive
想发送邮件通知 基于 summary.json 添加通知处理
想按应用分别配置 准备多份 JSON,并把任务拆开
想把删除也自动化 先以移动方式运行一段时间确认后再考虑

不过,一开始不要把所有功能都塞进去,会更安全。对于运维脚本来说,重要的不是功能多,而是失败时能够追溯。

14. 现场运维检查清单

在定期执行 PowerShell 脚本之前,确认以下几点。

  • 先用 -SkipArchive 只执行了读取处理
  • 接着用 -Preview 确认了移动的预定操作
  • archive-targets.csv 中的对象是合理的
  • archive-result.csv 中的 Destination 与预期一致
  • 输出目标文件夹中留下了带日期时间的记录
  • 确认了出错时会保留 error.txt
  • 确认了任务执行用户的权限
  • 确认了执行策略、签名以及公司内部规则
  • 不直接删除,先以移动方式运维
  • 已确定需要恢复时的还原位置

15. 小结

即便是 PowerShell 的进阶用法,也不需要用到很多复杂的语法。真正在实务中起作用的,是提前搭建好这样的套路。

  • 把配置拆分为 JSON
  • 先编写读取处理
  • 用 CSV 与 JSON 留存记录
  • 把变更处理拆分为函数
  • 准备相当于 -WhatIf 的预演
  • 让 transcript 与 error.txt 具备可追溯性
  • 先手动执行确认,再定期执行

本次的脚本以日志排查与归档为题材,但这种思路同样适用于其他业务。

  • 文件整理
  • 报表输出
  • CSV 汇总
  • 批处理替换
  • 旧资产盘点
  • 日常、月度的运维确认

PowerShell 即便只用一行命令也很方便,但如果要用在业务中,遵守下面的顺序会更安全。

查看 → 记录 → 预演 → 执行 → 留存记录

保持这种模式,PowerShell 就不只是一个缩短作业时间的工具,而能成为一个用来稳定运维的小型业务应用。

参考链接

共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。

常见问题

汇总了咨询这一主题时常见的问题。

用 PowerShell 自动化日志排查时,应该从什么开始入手?
不要一开始就编写移动、删除等变更处理,而是先只制作读取处理。先用 Get-ChildItem 查找目标日志,用 Select-String 提取错误行并输出为 CSV,把这一步先完整做出来。即使要在生产文件夹中试运行,也要先在这个只读阶段停下来确认结果。之后再逐步推进到归档对象清单化、把移动处理封装为函数,这样比较安全。
PowerShell 的 -WhatIf 该如何嵌入脚本?
只要在函数的 CmdletBinding 属性中加上 SupportsShouldProcess,该函数就能使用 -WhatIf 与 -Confirm。要点是把 $PSCmdlet.ShouldProcess() 放在真正执行变更的处理(例如 Move-Item)之前,让「是否变更」的判断就在变更发生前那一刻做出。本文的脚本把 -Preview 开关以 -WhatIf:$Preview 的形式传入,这样就能在不实际移动的情况下,只确认预定要执行的操作。
PowerShell 脚本手动运行没问题,放到任务计划程序里却失败,是什么原因?
典型原因是自己手动执行时和任务的执行用户不一致。需要确认执行用户是否拥有日志文件夹的读取权限、归档目标位置的写入权限,工作文件夹(WorkingDirectory)是否正确,pwsh.exe 的路径是否可用,以及是否符合执行策略与签名规则。失败时应确保可以通过 error.txt、transcript.txt 以及任务历史进行追踪,这一点很重要。
可以把旧日志的删除也自动化吗?
从一开始就自动化删除并不推荐。本文的脚本也只做到比删除更安全的「移动」为止。建议先以移动的方式运行一段时间,查看 archive-targets.csv、archive-result.csv 等留存记录,确认没有问题之后,再考虑把删除自动化。同时也要事先决定好需要恢复时的还原位置。

作者简介

本文作者的个人简介页面。

Go Komura

小村软件有限公司 代表

以 Windows 软件开发、技术咨询与故障排查为中心,擅长难以复现的故障调查,以及既有资产仍在运行的项目。

返回博客列表