更新履歴(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 による安全確認を整理しました。今回はその続きとして、実務で使いやすい少し大きめのスクリプトを作ります。題材は、次のような運用作業です。
- ログを調査する
- エラー行を CSV にまとめる
- 古いログをアーカイブ候補として一覧化する
- 必要なら古いログを移動する
- 実行結果を証跡として残す
PowerShell の応用で大事なのは、コマンドを長くすることではなく、次の流れを作ることです。
- 設定を分ける
- 読み取り処理を作る
- 出力を残す
- 変更処理を関数に分ける
-WhatIfで予行する- 最後に自動実行を検討する
自動化とは「人間の確認を省くこと」ではありません。確認すべき場所を固定し、毎回同じ証跡が残るようにすることです。
なお、この記事に登場するコードは、そのまま実行できるサンプル一式(完成版スクリプト、設定ファイル、ダミーログ環境の作成スクリプト、調査・予行・移動を検証する 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に頼らない通知方式の選定にも注意を促している。
flowchart LR
accTitle: PowerShellによるログ運用自動化の知識マップ
accDescr: PowerShellでログ調査・アーカイブ・証跡保存・定期実行までを安全に自動化する仕組みと、正規表現の落とし穴や非推奨コマンドの扱いを示す図。
log_maintenance_automation["ログ運用の自動化(調査・アーカイブ)"]
shouldprocess_whatif["SupportsShouldProcessと-WhatIf/-Confirm"]
select_string["Select-String"]
get_childitem["Get-ChildItem"]
export_csv["Export-Csv"]
convertfrom_json["ConvertFrom-Json"]
convertto_json["ConvertTo-Json"]
move_item["Move-Item"]
start_transcript["Start-Transcript"]
json_configuration_file["JSON設定ファイル"]
task_scheduler["タスクスケジューラの無人実行"]
scheduledtasks_cmdlets["ScheduledTasksモジュールのコマンドレット群"]
regular_expression["正規表現"]
unintended_regex_match["意図しない正規表現マッチ"]
send_mailmessage["Send-MailMessageコマンドレット"]
log_maintenance_automation -->|"利用する"| select_string
log_maintenance_automation -->|"利用する"| get_childitem
log_maintenance_automation -->|"利用する"| export_csv
log_maintenance_automation -->|"利用する"| convertfrom_json
log_maintenance_automation -->|"利用する"| convertto_json
log_maintenance_automation -->|"利用する"| move_item
log_maintenance_automation -->|"利用する"| start_transcript
log_maintenance_automation -.->|"前提とする"| json_configuration_file
json_configuration_file -.->|"で構成できる"| convertfrom_json
move_item -->|"で構成できる"| shouldprocess_whatif
shouldprocess_whatif -->|"推奨される対応"| move_item
log_maintenance_automation -->|"利用する"| task_scheduler
task_scheduler -.->|"で構成できる"| scheduledtasks_cmdlets
select_string -->|"利用する"| regular_expression
select_string -.->|"原因になり得る"| unintended_regex_match
send_mailmessage -->|"用いるのは非推奨"| log_maintenance_automation
図の実線は常に成り立つ関係、破線は条件付きの関係です(成立条件は詳細ページの各関係の説明に記載)。関係すべての一覧(全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、本番環境では 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 文字」という意味になり、ERRORS や ERROR: にも当たります。逆に 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.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 セッションのコマンドとコンソール出力をテキストファイルに記録するためのコマンドレットです。運用スクリプトでは、後から「いつ・どの条件で・何が出たか」を確認しやすくなります。
生成物の「正解形」
最初の 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 を付けたときは 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 が 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
LastTaskResult が 0 以外なら、まずレポートフォルダーの error.txt と transcript.txt を見ます。
ただし、タスク自体が起動できていない場合は、スクリプトが 1 行も動いていないのでレポートフォルダーすら作られません。そのときは、タスクを右クリックして開いたプロパティではなく、一覧でタスクを選んだときに下部に出る「履歴」タブを見ます。履歴は既定で無効なことがあるため、右ペインの「すべてのタスク履歴を有効にする」で有効化してから、右クリックの「実行する」で手動起動して確かめます。
本番運用では、次の点も確認します。
- 実行ユーザーにログフォルダーの読み取り権限がある
- アーカイブ先への書き込み権限がある
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 を複数用意してタスクを分ける |
| 削除まで自動化したい | まず移動運用で一定期間確認してから検討 |
ただし、最初から全部入れない方が安全です。運用スクリプトは、機能が多いことよりも、失敗したときに追えることが重要です。
メール通知だけは先に方式を決める
「メール通知したい」は要望として多いのですが、ここだけは実装前に方式を決めておく必要があります。
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.csvのDestinationが想定通りだった- 出力先フォルダーに日時付きの証跡が残った
- エラー時に
error.txtが残ることを確認した - タスク実行ユーザーの権限を確認した
- 実行ポリシー、署名、社内ルールを確認した
- いきなり削除ではなく、まず移動で運用する
- 復旧する場合の戻し先を決めている
15. まとめ
PowerShell の応用といっても、難しい構文をたくさん使う必要はありません。実務で効くのは、こういう型を作っておくことです。
- 設定を JSON に分ける
- 読み取り処理を先に作る
- CSV と JSON で証跡を残す
- 変更処理は関数に分ける
-WhatIf相当の予行を用意する- transcript と
error.txtで追跡できるようにする - 手動実行で確認してから定期実行する
今回のスクリプトは、ログ調査とアーカイブを題材にしていますが、考え方は他の業務にも使えます。
- ファイル整理
- 帳票出力
- CSV 集計
- バッチ置き換え
- 古い資産の棚卸し
- 日次・月次の運用確認
PowerShell は 1 行コマンドでも便利ですが、業務で使うなら、次の順番を守る方が安全です。
見る → 記録する → 予行する → 実行する → 証跡を残す
この形にしておくと、PowerShell は単なる作業短縮ツールではなく、運用を安定させるための小さな業務アプリとして使えるようになります。
参考リンク
関連する記事
同じタグを共有する最新の記事です。さらに近い話題で知識を深められます。
PowerShellスクリプトの引数設計とモジュール化 ── 「動くスクリプト」から「人に渡せるスクリプト」へ
PowerShellスクリプトを他人に渡せる品質に引き上げる手順を整理します。paramブロックと[CmdletBinding()]、入力検証、パイプライン入力、-WhatIf対応、.psm1モジュール化、社内共有とGit管理の勘所まで解説します。
Windows PowerShell 5.1とPowerShell 7の違い ── 社内スクリプト移行の実務ガイド
Windows PowerShell 5.1とPowerShell 7の関係(共存とpwsh.exe)、5.1は新機能追加なしという公式方針、エンコーディング差による文字化け、#Requiresでの防御、タスクスケジューラ更新まで移行手順を整理します。
winget + PowerShellでPCキッティングを自動化する ── 手順書を実行可能にする
新入社員PCのセットアップを再現可能にする方法をまとめます。wingetによるアプリ導入とexport/import、WinGet Configurationの宣言的な構成、PowerShellで補う設定、無人実行時の注意点までを解説します。
PowerShellでREST APIと連携する ── Invoke-RestMethodの実務
PowerShellから社内APIやSaaSのREST APIを呼ぶ実務をまとめます。認証ヘッダーの渡し方、日本語JSONの文字化け対策、4xx/5xxのエラー処理、429のリトライ、ページング、プロキシとTLSの落とし穴まで解説します。
PowerShellスクリプトが遅いときに見るところ ── 配列・パイプライン・突合の勘所
PowerShellスクリプトが遅い原因の定番を整理します。配列の+=がO(n^2)になる理由、パイプラインとforeachの差、突合のハッシュテーブル化、ファイルI/Oの改善、そして正しい測り方までを実務目線で解説します。
関連トピック
このテーマと近いトピックページです。記事を起点に、関連するサービスや他の記事へ進めます。
Windows技術トピック
Windows 開発、不具合調査、既存資産活用の技術トピックをまとめた入口です。
このテーマがつながるサービス
この記事は次のサービスページにつながります。近い入口からご覧ください。
Windowsアプリ開発
業務アプリ、装置連携、通信ツールなどの Windows ソフト開発を支援します。
既存資産活用・移行支援
COM / ActiveX / OCX、32bit / 64bit 制約を抱える既存資産の活用と移行を支援します。
よくある質問
この記事のテーマについて、相談時によくある質問をまとめています。
- 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などの証跡を見て問題がないと分かってから削除の自動化を検討します。復旧する場合の戻し先を事前に決めておくことも大切です。