PowerShellスクリプト応用 ── ログ調査・アーカイブ・レポート化を安全に自動化する

· 更新日: · · PowerShell, Windows, 自動化, ログ調査, 運用改善, 既存資産活用

更新履歴(3件・最終更新 2026年08月02日)

この記事に加えた変更の記録です。アーカイブした更新前のバージョンは、DOI付きの固定URLから読めます。

記事の冒頭に「この記事の知識マップ」節を追加しました。本文で扱っている概念とその関係を、要約・図・詳細ページへのリンクにまとめたものです。本文の主張は変えていません。
外部レビュー(1283件)への対応として本文を更新しました。個々の変更内容は、この下の履歴を参照してください。
3本の記事の役割と読み分けの表を追加しました。`Patterns`が正規表現として扱われること(`ERROR.`の意図しないマッチ、`[regex]::Escape()`、`-SimpleMatch`)、生成物の正解形、タスクが登録できたかを確認する方法、メール通知の方式を先に決めるべき理由(`Send-MailMessage`は非推奨)を追加しました。
初版公開
この記事を引用する(DOI: 10.5281/zenodo.21589822)

この記事はZenodoにアーカイブされています。常に最新版へ解決されるDOIと、いま表示している版に固定されたDOIの両方を下に示します。

小村 豪(2026)「PowerShellスクリプト応用 ── ログ調査・アーカイブ・レポート化を安全に自動化する」合同会社小村ソフト. https://doi.org/10.5281/zenodo.21589822 https://staging.comcomponent.com/blog/2026/06/01/000-powershell-script-log-maintenance-automation/

DOI(最新版)
10.5281/zenodo.21589822
DOI(この版)
10.5281/zenodo.21732834

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)

この記事の位置づけ

PowerShell の記事は、次の 3 本が続きものになっています。番号付きの連載ではありませんが、この順で読むと流れがつながります。

順番 記事 扱う範囲
1 PowerShellコマンドの基本 ── まず覚える操作と安全な使い方 コマンドの探し方、パイプライン、CSV / JSON、.ps1-WhatIf
2 PowerShell実用コマンド集 ── 日常作業でよく使う小さな機能を増やす 集計、比較、抽出、証跡出力の部品
3 この記事 部品を 1 本の運用スクリプトにまとめ、設定・証跡・予行・定期実行まで作る

前提として必要なのは 1 の内容だけです。2 を読んでいなくても、この記事は追えます。冒頭の「前回」は 1 を指しています。

この記事の知識マップ

この記事は、PowerShellでログ調査からアーカイブ、証跡保存、定期実行までを安全に自動化する実務手順を扱う。Get-ChildItemとSelect-Stringでログを検索・抽出してExport-Csvでレポート化し、実行条件はConvertFrom-Jsonで読み込むJSON設定ファイルに分離し、実行結果はConvertTo-Jsonで要約する。古いログの移動処理はSupportsShouldProcessと-WhatIfで予行できる関数に分離し、Start-Transcriptでセッション全体を記録したうえでタスクスケジューラに登録して定期実行する。あわせて、Select-Stringの既定の正規表現解釈による意図しないパターンマッチや、非推奨のSend-MailMessageに頼らない通知方式の選定にも注意を促している。

PowerShellによるログ運用自動化の知識マップPowerShellでログ調査・アーカイブ・証跡保存・定期実行までを安全に自動化する仕組みと、正規表現の落とし穴や非推奨コマンドの扱いを示す図。利用する利用する利用する利用する利用する利用する利用する前提とするで構成できるで構成できる推奨される対応利用するで構成できる利用する原因になり得る用いるのは非推奨ログ運用の自動化(調査・アーカイブ)SupportsShouldProcessと-WhatIf/-ConfirmSelect-StringGet-ChildItemExport-CsvConvertFrom-JsonConvertTo-JsonMove-ItemStart-TranscriptJSON設定ファイルタスクスケジューラの無人実行ScheduledTasksモジュールのコマンドレット群正規表現意図しない正規表現マッチSend-MailMessageコマンドレット

図の実線は常に成り立つ関係、破線は条件付きの関係です(成立条件は詳細ページの各関係の説明に記載)。関係すべての一覧(全16件、根拠・確度つき)と主要概念の定義は知識マップ詳細ページにまとめています。データ: JSON-LD / Turtle

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-Json を使います。ConvertTo-Json はオブジェクトを JSON 文字列へ変換するコマンドレットで、深い階層を扱う場合は -Depth の指定が重要になります。

Patterns は正規表現として扱われる

1 か所だけ、先に注意しておきます。Patterns の値は、最終的に Select-String -Pattern へ渡されます。Select-String は、既定でこの値を 正規表現として 解釈します。

つまり、ERROR. と書くと「ERROR のあとに任意の 1 文字」という意味になり、ERRORSERROR: にも当たります。逆に ERROR だけの行には当たりません。C:\App のようにバックスラッシュを含む文字列も、そのままでは正規表現の制御文字として扱われます。

  • 正規表現として使いたい: そのまま書きます。"ERROR|FATAL""\[ERROR\]" のように書けます
  • 文字列そのままで当てたい: [regex]::Escape("ERROR.") の結果を JSON に入れるか、スクリプト側の Select-String-SimpleMatch を足します

また、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

ここまでは読み取りだけです。本番フォルダーで試す場合も、まずはこの段階で止めます。

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     = ""
      }
    }
  }
}

ポイントは、Move-Item の直前に $PSCmdlet.ShouldProcess() を置いていることです。変更するかどうかの判断は、関数の外ではなく、変更する直前に置きます。

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 セッションのコマンドとコンソール出力をテキストファイルに記録するためのコマンドレットです。運用スクリプトでは、後から「いつ・どの条件で・何が出たか」を確認しやすくなります。

生成物の「正解形」

最初の 1 回は、ファイルが出たかどうかより、中身が想定どおりかを見ます。ログの内容はダミーですが、列や項目の並びは、この記事のスクリプトが出すとおりです。

log-hits.csv は、Path / LineNumber / Pattern / Line の 4 列です。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 の 3 列です。

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

archive-result.csv は、Source / Destination / Status / Message の 4 列です。-Preview を付けたときは StatusPreview になり、実行したときは Moved になります。

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

日時の表記は、Export-Csv が OS のロケール設定に従って文字列化するため、環境によって変わります。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 だけ見れば済むようにしておくと楽です。HitCount が跳ねた日、ArchiveTargetCount が急に増えた日だけ CSV を開く、という運用にできます。

なお、対象が 0 件でも CSV は作られます。Export-CsvWithHeader が、ヘッダー行だけのファイルを書くようにしてあるためです。「ファイルがない」と「該当が 0 件だった」を取り違えないための作りです。

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 でローカルコンピューターへタスクを登録します。

登録できたかを確認する

登録コマンドが通っただけでは、まだ「動く」とは言えません。実行を待つ前に、登録内容を目視で確認します。

GUI なら、タスクスケジューラ(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

LastTaskResult0 以外なら、まずレポートフォルダーの error.txttranscript.txt を見ます。

ただし、タスク自体が起動できていない場合は、スクリプトが 1 行も動いていないのでレポートフォルダーすら作られません。そのときは、タスクを右クリックして開いたプロパティではなく、一覧でタスクを選んだときに下部に出る「履歴」タブを見ます。履歴は既定で無効なことがあるため、右ペインの「すべてのタスク履歴を有効にする」で有効化してから、右クリックの「実行する」で手動起動して確かめます。

本番運用では、次の点も確認します。

  • 実行ユーザーにログフォルダーの読み取り権限がある
  • アーカイブ先への書き込み権限がある
  • pwsh.exe のパスが通っている
  • スクリプトの実行ポリシーや署名ルールに合っている
  • 手動実行とタスク実行で同じ結果になる
  • 失敗時に error.txt やタスク履歴を確認できる

12. よくあるつまずき

症状 原因 対処
ログが見つからない LogPath が間違っている Test-PathGet-ChildItem で確認する
CSV が空になる 対象期間に該当ログがない Days を広げて確認する
日本語が文字化けする ログの文字コードが想定と違う 入出力の文字コードを確認する
タスクでは動かない 実行ユーザーや作業フォルダーが違う WorkingDirectory と権限を確認する
アーカイブ対象が多すぎる ArchiveDays が短すぎる archive-targets.csv を見て調整する
移動先が想定と違う 相対パスの保持ルールを理解していない -PreviewDestination を確認する
本番だけ失敗する 権限・ポリシー・ロック中ファイルの違い error.txttranscript.txt を確認する

特に、タスクスケジューラで動かす場合は「自分で実行したとき」と「タスクの実行ユーザー」が違うことがあります。手動では動くのにタスクでは失敗するなら、まず権限と作業フォルダーを疑ってください。

13. 改造する場合の考え方

このスクリプトは、そのまま使うよりも、現場に合わせて少しずつ改造する前提です。よくある改造例を挙げます。

やりたいこと 改造箇所
.txt も対象にしたい Get-ChildItem -Filter *.log を変更
ERROR の前後数行も見たい Select-String の結果を元に Get-Content で周辺行を取得
圧縮してからアーカイブしたい Move-OldLogFile の前に Compress-Archive を追加
メール通知したい summary.json をもとに通知処理を追加
アプリごとに設定を分けたい JSON を複数用意してタスクを分ける
削除まで自動化したい まず移動運用で一定期間確認してから検討

ただし、最初から全部入れない方が安全です。運用スクリプトは、機能が多いことよりも、失敗したときに追えることが重要です。

メール通知だけは先に方式を決める

「メール通知したい」は要望として多いのですが、ここだけは実装前に方式を決めておく必要があります。

PowerShell の Send-MailMessage は、Microsoft Learn 上で「obsolete(旧式)」と明記されており、SMTP サーバーへの安全な接続を保証しないため使わないよう案内されています。PowerShell 内に直接の後継コマンドレットはなく、代替として MailKit などのライブラリ、Exchange Online 環境なら Microsoft Graph PowerShell SDK の Send-MgUserMail が挙げられています。

方式は、環境で分かれます。

環境 通知方式
社内 SMTP リレーがあり、認証も TLS も不要 Send-MailMessage でも動くが、将来の削除に備えて関数 1 つに閉じ込める
認証付き SMTP や TLS が必要 MailKit などのライブラリを使う
Microsoft 365 環境 Microsoft Graph 経由(Send-MgUserMail)にする
メールでなくてよい Teams などの Webhook、または監視基盤へのファイル連携にする

どの方式でも、通知処理は summary.json を読んで送るだけにして、本処理からは切り離してください。通知が失敗してもログ調査とアーカイブは完了している、という形にしておくのが実務では効きます。

14. 現場での運用チェックリスト

PowerShell スクリプトを定期実行する前に、次の点を確認します。

  • まず -SkipArchive で読み取り処理だけ実行した
  • 次に -Preview で移動予定を確認した
  • archive-targets.csv の対象が妥当だった
  • archive-result.csvDestination が想定通りだった
  • 出力先フォルダーに日時付きの証跡が残った
  • エラー時に error.txt が残ることを確認した
  • タスク実行ユーザーの権限を確認した
  • 実行ポリシー、署名、社内ルールを確認した
  • いきなり削除ではなく、まず移動で運用する
  • 復旧する場合の戻し先を決めている

15. まとめ

PowerShell の応用といっても、難しい構文をたくさん使う必要はありません。実務で効くのは、こういう型を作っておくことです。

  • 設定を JSON に分ける
  • 読み取り処理を先に作る
  • CSV と JSON で証跡を残す
  • 変更処理は関数に分ける
  • -WhatIf 相当の予行を用意する
  • transcript と error.txt で追跡できるようにする
  • 手動実行で確認してから定期実行する

今回のスクリプトは、ログ調査とアーカイブを題材にしていますが、考え方は他の業務にも使えます。

  • ファイル整理
  • 帳票出力
  • CSV 集計
  • バッチ置き換え
  • 古い資産の棚卸し
  • 日次・月次の運用確認

PowerShell は 1 行コマンドでも便利ですが、業務で使うなら、次の順番を守る方が安全です。

見る → 記録する → 予行する → 実行する → 証跡を残す

この形にしておくと、PowerShell は単なる作業短縮ツールではなく、運用を安定させるための小さな業務アプリとして使えるようになります。

参考リンク

同じタグを共有する最新の記事です。さらに近い話題で知識を深められます。

このテーマと近いトピックページです。記事を起点に、関連するサービスや他の記事へ進めます。

この記事は次のサービスページにつながります。近い入口からご覧ください。

よくある質問

この記事のテーマについて、相談時によくある質問をまとめています。

PowerShellでログ調査を自動化するとき、何から作り始めればよいですか?
いきなり移動や削除などの変更処理を書かず、まず読み取り処理だけを作ります。Get-ChildItemで対象ログを探し、Select-Stringでエラー行を抽出してCSVに出力するところまでを先に完成させます。本番フォルダーで試す場合も、この読み取り段階で一度止めて結果を確認します。その後にアーカイブ対象の一覧化、移動処理の関数化へと段階的に進めるのが安全です。
PowerShellの-WhatIfはどうやってスクリプトに組み込みますか?
関数のCmdletBinding属性にSupportsShouldProcessを付けると、その関数で-WhatIfと-Confirmが使えるようになります。実際に変更する処理(Move-Itemなど)の直前に$PSCmdlet.ShouldProcess()を置き、変更するかどうかの判断を変更の直前で行うのがポイントです。この記事のスクリプトでは-Previewスイッチを-WhatIf:$Previewとして渡し、移動せずに予定だけを確認できるようにしています。
PowerShellスクリプトが手動では動くのにタスクスケジューラでは失敗するのはなぜですか?
自分で実行したときとタスクの実行ユーザーが違うことが典型的な原因です。実行ユーザーにログフォルダーの読み取り権限やアーカイブ先への書き込み権限があるか、作業フォルダー(WorkingDirectory)が正しいか、pwsh.exeのパスが通っているか、実行ポリシーや署名ルールに合っているかを確認します。失敗時はerror.txtやtranscript.txt、タスク履歴で追跡できるようにしておくことが重要です。
古いログの削除まで自動化してもよいですか?
最初から削除を自動化するのは推奨されません。この記事のスクリプトも、削除ではなく安全な「移動」までに留めています。まず移動運用で一定期間確認し、archive-targets.csvやarchive-result.csvなどの証跡を見て問題がないと分かってから削除の自動化を検討します。復旧する場合の戻し先を事前に決めておくことも大切です。

著者プロフィール

記事の著者プロフィールページです。

小村 豪

合同会社小村ソフト 代表

Windows ソフト開発、技術相談、不具合調査を中心に、既存資産が残る案件や原因が見えにくい障害調査に強みがあります。

ブログ一覧に戻る