PowerShellスクリプト応用 ── ログ調査・アーカイブ・レポート化を安全に自動化する
1. 最初に押さえるべきこと
前回は、PowerShell の基本コマンド、パイプライン、CSV 出力、JSON、.ps1 スクリプト、-WhatIf による安全確認を整理しました。
今回は、その続きとして、実務で使いやすい少し大きめのスクリプトを作ります。
題材は、次のような運用作業です。
- ログを調査する
- エラー行を CSV にまとめる
- 古いログをアーカイブ候補として一覧化する
- 必要なら古いログを移動する
- 実行結果を証跡として残す
PowerShell の応用で大事なのは、コマンドを長くすることではありません。
重要なのは、次の流れです。
- 設定を分ける
- 読み取り処理を作る
- 出力を残す
- 変更処理を関数に分ける
-WhatIfで予行する- 最後に自動実行を検討する
自動化とは「人間の確認を省くこと」ではありません。確認すべき場所を固定し、毎回同じ証跡が残るようにすることです。
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、本番環境では 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.csvarchive-targets.csvarchive-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-Path と Get-ChildItem で確認する |
| CSV が空になる | 対象期間に該当ログがない | Days を広げて確認する |
| 日本語が文字化けする | ログの文字コードが想定と違う | 入出力の文字コードを確認する |
| タスクでは動かない | 実行ユーザーや作業フォルダーが違う | WorkingDirectory と権限を確認する |
| アーカイブ対象が多すぎる | ArchiveDays が短すぎる |
archive-targets.csv を見て調整する |
| 移動先が想定と違う | 相対パスの保持ルールを理解していない | -Preview で Destination を確認する |
| 本番だけ失敗する | 権限・ポリシー・ロック中ファイルの違い | error.txt と transcript.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 は、1 行コマンドでも便利です。
ただし、業務で使うなら、次の順番を守る方が安全です。
見る → 記録する → 予行する → 実行する → 証跡を残す
この形にしておくと、PowerShell は単なる作業短縮ツールではなく、運用を安定させるための小さな業務アプリとして使えるようになります。
参考リンク
関連する記事
同じタグを共有する最新の記事です。さらに近い話題で知識を深められます。
PowerShell実用コマンド集 ── 日常作業でよく使う小さな機能を増やす
PowerShellで日常作業に使う実用コマンドとして、Measure-Object、Group-Object、Select-String、Compare-Object、Tee-Object、Start-Transcriptなどの使いどころを整理します。
PowerShellコマンドの基本 ── まず覚える操作と安全な使い方
PowerShell初心者が実務で迷わないように、コマンドレットの探し方、パイプライン、ファイル操作、CSV処理、実行ポリシー、事故を避ける確認手順までを整理します。
VBScript非推奨化に備える移行ガイド
Windows の VBScript 段階廃止に向け、VBA や Excel マクロ、社内ツールの依存を棚卸しし、静的検出と実行ログで隠れた呼び出しを洗い出す手順、PowerShell や Office Scripts などへの代替選定、署名運用と段階展開までを実務目線で整...
IEモード依存システムの脱却ガイド
Microsoft Edge の IE モード依存をかかえた社内 Web システムを安全に延命しつつ脱却する実務手順を、サイトリスト集中管理、ニュートラルサイト、WebView2 ラッパー、段階的リファクタリング、VDI 隔離、ガバナンス設計まで読者がそのまま着手できる粒度...
Office 2024 / Microsoft 365 で ActiveX が動かないときの対処ガイド
Office 2024 と Microsoft 365 で ActiveX が動かない原因を、設定既定値、bitness、COM 登録、依存ランタイム、IE モードの順で切り分ける実務手順をまとめた現場向けガイドです。
関連トピック
このテーマと近いトピックページです。記事を起点に、関連するサービスや他の記事へ進めます。
Windows技術トピック
Windows 開発、不具合調査、既存資産活用の技術トピックをまとめた入口です。
このテーマがつながるサービス
この記事は次のサービスページにつながります。近い入口からご覧ください。
Windowsアプリ開発
業務アプリ、装置連携、通信ツールなどの Windows ソフト開発を支援します。
既存資産活用・移行支援
COM / ActiveX / OCX、32bit / 64bit 制約を抱える既存資産の活用と移行を支援します。