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

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

1. 最初に押さえるべきこと

前回は、PowerShell の基本コマンド、パイプライン、CSV 出力、JSON、.ps1 スクリプト、-WhatIf による安全確認を整理しました。

今回は、その続きとして、実務で使いやすい少し大きめのスクリプトを作ります。

題材は、次のような運用作業です。

  1. ログを調査する
  2. エラー行を CSV にまとめる
  3. 古いログをアーカイブ候補として一覧化する
  4. 必要なら古いログを移動する
  5. 実行結果を証跡として残す

PowerShell の応用で大事なのは、コマンドを長くすることではありません。

重要なのは、次の流れです。

  1. 設定を分ける
  2. 読み取り処理を作る
  3. 出力を残す
  4. 変更処理を関数に分ける
  5. -WhatIf で予行する
  6. 最後に自動実行を検討する

自動化とは「人間の確認を省くこと」ではありません。確認すべき場所を固定し、毎回同じ証跡が残るようにすることです。

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 の指定が重要になります。

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

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 を見て調整する
移動先が想定と違う 相対パスの保持ルールを理解していない -PreviewDestination を確認する
本番だけ失敗する 権限・ポリシー・ロック中ファイルの違い 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.csvDestination が想定通りだった
  • 出力先フォルダーに日時付きの証跡が残った
  • エラー時に error.txt が残ることを確認した
  • タスク実行ユーザーの権限を確認した
  • 実行ポリシー、署名、社内ルールを確認した
  • いきなり削除ではなく、まず移動で運用する
  • 復旧する場合の戻し先を決めている

15. まとめ

PowerShell の応用は、難しい構文をたくさん使うことではありません。

実務では、次の型を作ることが重要です。

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

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

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

PowerShell は、1 行コマンドでも便利です。

ただし、業務で使うなら、次の順番を守る方が安全です。

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

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

参考リンク

関連する記事

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

VBScript非推奨化に備える移行ガイド

Windows の VBScript 段階廃止に向け、VBA や Excel マクロ、社内ツールの依存を棚卸しし、静的検出と実行ログで隠れた呼び出しを洗い出す手順、PowerShell や Office Scripts などへの代替選定、署名運用と段階展開までを実務目線で整...

記事を読む

IEモード依存システムの脱却ガイド

Microsoft Edge の IE モード依存をかかえた社内 Web システムを安全に延命しつつ脱却する実務手順を、サイトリスト集中管理、ニュートラルサイト、WebView2 ラッパー、段階的リファクタリング、VDI 隔離、ガバナンス設計まで読者がそのまま着手できる粒度...

記事を読む

関連トピック

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

このテーマがつながるサービス

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

ブログ一覧に戻る