Featured image of post Windows:ディスク 100% の原因を PowerShell で記録するには

Windows:ディスク 100% の原因を PowerShell で記録するには

タスクマネージャーのディスク使用率が 100% に張り付いているのに、プロセスタブを見ても犯人が見つからないことがありました。リアルタイムで目視するのに限界を感じたので、PowerShell で記録することにしたのでメモです。

タスクマネージャーで見えない理由

タスクマネージャーの更新間隔はデフォルトで 1 秒ごとですが、0.5 秒以下で終わる処理はほぼ目に入りません。複数のプロセスが交互に動いている場合はさらに特定が難しいです。

もうひとつ、これは後から気づいたことですが、タスクマネージャーの「ディスク 100%」はビジー率(ディスクが応答待ちになっていた時間の割合)であり、転送量が多いことを意味しないです。転送量が数百 KB/s しかなくても、応答が遅いディスクであれば 100% に張り付きます。

なので「転送量が大きいプロセスを探せばいい」と思っていたのですが、記録してみると想定と全然違うデータが出てきました。

Get-Counter を使う理由

PowerShell でプロセスごとの IO 量を取得する方法はいくつかあります。Get-Process でも .IO* プロパティが取れますが、これは起動してからの累計値なので、ある瞬間の転送速度にはなりません。差分を自分で計算する方法もありますが、タイミングを正確に揃えるのが意外と面倒です。

Get-Counter を使うと、Windows のパフォーマンスカウンターを通じて「1 秒あたりの IO 量」を直接取得できます。OS が計算した瞬間速度なので、自前で差分を取る必要がないです。

ただ、プロセス単位のカウンターだけでは「ディスクが詰まっているのに犯人が見つからない」という状況に対処できないことがわかってきました。実際にこのスクリプトを使った調査でも、ビジー率が 47%・応答時間が 474ms という状況でプロセス単位のアラートは一件も出なかったです。転送量が少なくても応答が遅いディスクは詰まる、というビジー率の話がここでも出てきます。

なので、スクリプトではプロセス単位とディスク全体の 2 つに分けて監視する構成にしました。

プロセス単位の IO

\Process(*)\IO Read Bytes/sec
\Process(*)\IO Write Bytes/sec
\Process(*)\ID Process

個々のプロセスが「どれだけ IO を出しているか」を転送量ベースで記録します。SearchIndexer や Windows Update のような特定プロセスの絞り込みに有効です。ただ、このカウンターはディスク IO だけでなくネットワークや名前付きパイプも含む「全 IO」の値です。ディスクだけを厳密に分離したい場合は Process Monitor(Sysinternals)が必要ですが、怪しいプロセスを絞り込む目的であれば全 IO で十分かなと思います。

ディスク全体の状態

\PhysicalDisk(_Total)\% Disk Time
\PhysicalDisk(_Total)\Avg. Disk sec/Transfer
\PhysicalDisk(_Total)\Avg. Disk Queue Length
\Process(System)\IO Read Bytes/sec
\Process(System)\IO Write Bytes/sec
\Memory\Pages/sec

タスクマネージャーと同じビジー率・応答時間・待ち行列に加え、カーネルやドライバー起因の IO(System プロセス)とメモリのページング発生数を同時に取得します。プロセス単位で犯人が見つからないとき、このデータが手がかりになります。

2 つを組み合わせることで、転送量が少なくてもディスクが詰まる状況や、GPU ドライバーの不安定さに伴うシステム全体の IO 停滞なども記録できるようになりました。なお、これらすべてのカウンターは 1 回の Get-Counter 呼び出しでまとめて取得しています。2 回に分けると内部の待機処理が 2 回走り、ループ周期が余分に延びてしまうためです。

5 秒間隔にした理由

最初は 1 秒ごとに記録しようとしました。試してみると、Get-Counter は内部で約 1 秒の待機処理を行う仕様になっていて、「1 秒待って取得 → 処理 → また 1 秒待って取得」と繰り返すとループ周期が 1 秒より確実に長くなりました。

5 秒にすると、内部待機(約 1 秒)と処理時間を差し引いた残りをスリープに使えるので、実際の周期を正味 5 秒に近づけられます。ディスク負荷の調査であれば 5 秒あれば十分な粒度で、ログファイルのサイズも抑えられます。

スクリプトの構成

CSV の書き込み

CSV 保存には注意があります。PowerShell 5.1 の Export-Csv-Encoding UTF8 を指定しても、BOM(バイトオーダーマーク)付きのファイルを出力することがあります。Excelで開くぶんには問題ないですが、他のツールで読み込む場合に文字化けの原因になります。なので今回は StreamWriter を使って BOM なし UTF-8 で直接書き込んでいます。

プロセス名の #数字 について

Chrome や Edge のように同じ名前のプロセスが複数起動している場合、パフォーマンスカウンター上では chromechrome#1chrome#2 のように番号が振られます。これは単なる識別子で、PID とは直接対応しないです。スクリプト内では \Process(*)\ID Process カウンターを使って各インスタンスの PID を取得することで、どのプロセスが実際に動いていたかを特定できるようにしています。

実行

管理者権限の PowerShell で実行します。

Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass
.\Monitor-DiskUsage.ps1

スクリプト本文は以下のとおりです。

# ============================================================
#  Monitor-DiskUsage.ps1  -  プロセスIO・ディスク負荷監視
#
#  【重要な制約事項】
#  \Process(*)\IO *Bytes/sec はディスク・ネットワーク・名前付きパイプを
#  含む「全IO」を返します。ディスクIOのみの計測ではありません。
#  EstimatedIOPct は推定最大スループットを分母とするため
#  100% を超える場合があります。
#  プロセス単位でディスクIOのみを厳密に計測したい場合は
#  Process Monitor (Sysinternals) または ETW を使用してください。
#
#  【Get-Counter の負荷について】
#  プロセス数が200を超える環境では Get-Counter の wildcard 展開が
#  重くなる場合があります。その場合は -IntervalSeconds 10〜30 に
#  増やすことで安定します。
# ============================================================

param(
    [int]    $IntervalSeconds    = 5,      # サンプリング間隔(秒)正味待機時間
    [double] $ThresholdPercent   = 50.0,   # プロセスIO使用率のアラートしきい値(%)
    [string] $OutputDir          = "",     # 出力先フォルダ(省略時はスクリプト隣の IOMonitorLogs)
    [int]    $MaxRunMinutes      = 0,      # 最大実行時間(分)。0 = 無制限
    [int]    $DiskCapacityMBs    = 0,      # ディスク最大スループット手動指定(MB/s)。0 = 自動推定
    [int]    $ConsoleTopN        = 10,     # コンソール表示の最大件数(CSV/ログは全件記録)
    [int]    $QuietMinutes       = 5,      # 「しきい値超えなし」の出力間隔(分)。0 = 毎回出力
    [int]    $DiskBusyThreshold  = 80,     # ディスクビジー率のアラートしきい値(%)
    [double] $LatencyThresholdMs = 50.0    # ディスク平均応答時間のアラートしきい値(ms)
)

# -------------------------------------------------------
# $PSScriptRoot フォールバック(対話実行・ドットソース実行対策)
# -------------------------------------------------------
$scriptBase = if ($PSScriptRoot -and $PSScriptRoot -ne '') {
    $PSScriptRoot
} else {
    (Get-Location).Path
}
if ($OutputDir -eq '') { $OutputDir = Join-Path $scriptBase "IOMonitorLogs" }

# -------------------------------------------------------
# 初期化
# -------------------------------------------------------
if (-not (Test-Path $OutputDir)) {
    New-Item -ItemType Directory -Path $OutputDir | Out-Null
}

$datestamp = Get-Date -Format "yyyyMMdd_HHmmss"
$csvFile   = Join-Path $OutputDir "IOMonitor_$datestamp.csv"
$logFile   = Join-Path $OutputDir "IOMonitor_$datestamp.log"

if (Test-Path $csvFile) { Remove-Item $csvFile -Force }

# -------------------------------------------------------
# ログ出力関数
# -------------------------------------------------------
function Write-Log {
    param([string]$Message, [string]$Level = "INFO")
    $ts    = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
    $line  = "[$ts][$Level] $Message"
    $color = switch ($Level) {
        "ALERT" { "Red"    }
        "WARN"  { "Yellow" }
        default { "Cyan"   }
    }
    Write-Host $line -ForegroundColor $color
    # PS5.1 では Out-File -Encoding UTF8 が BOM 付きになるため StreamWriter で回避
    $sw = [System.IO.StreamWriter]::new($logFile, $true, [System.Text.UTF8Encoding]::new($false))
    try   { $sw.WriteLine($line) }
    finally { $sw.Close() }
}

# -------------------------------------------------------
# CSV 追記ヘルパー(BOM なし UTF-8 で StreamWriter 直書き)
# PS5.1 の Export-Csv -Append -Encoding UTF8 は毎回 BOM を書き込み
# ファイルが壊れる既知バグがあるため StreamWriter で回避。
# 毎回 open/close するが5秒に1回程度のオーバーヘッドは無視できる。
# 常時 open にすると Ctrl+C 割り込み時にバッファが未フラッシュのまま
# ファイルが破損するリスクがあるため不採用。
# -------------------------------------------------------
function Append-CsvRow {
    param([PSCustomObject]$Row, [string]$FilePath)
    $needHeader = -not (Test-Path $FilePath) -or (Get-Item $FilePath).Length -eq 0
    $sw = [System.IO.StreamWriter]::new(
        $FilePath,
        $true,
        [System.Text.UTF8Encoding]::new($false)
    )
    try {
        if ($needHeader) {
            $sw.WriteLine(($Row.PSObject.Properties.Name -join ','))
        }
        $values = $Row.PSObject.Properties.Value | ForEach-Object {
            $v = "$_"
            # RFC 4180: カンマ・ダブルクォート・改行を含むフィールドはクォートで囲む
            if ($v -match '[,"\r\n]') { '"' + $v.Replace('"','""') + '"' } else { $v }
        }
        $sw.WriteLine($values -join ',')
    } finally {
        $sw.Close()
    }
}

# -------------------------------------------------------
# ディスク最大スループット推定(バイト/秒)
# 判定優先順位:
#   1. 手動指定(-DiskCapacityMBs)
#   2. C: ドライブの BusType(MSFT_Disk 経由、複数ディスク混在環境で最も正確)
#   3. 全ディスクの BusType(C: 取得失敗時のフォールバック)
#   4. Win32_DiskDrive の Model 名(BusType 取得失敗時のフォールバック)
# 戻り値: [PSCustomObject]@{ Bytes=[long]; WarnMessage=[string] }
# WarnMessage が空でない場合、呼び出し側でログに記録する
# -------------------------------------------------------
function Get-DiskMaxThroughput {
    param([int]$ManualMBs)
    if ($ManualMBs -gt 0) {
        return [PSCustomObject]@{ Bytes = [long]($ManualMBs * 1MB); WarnMessage = '' }
    }

    # BusType からスループット推定値(バイト/秒)を返すヘルパー
    # BusType: 17=NVMe, 11=SATA, 10=SAS, 7=USB, 3=SCSI
    # 仮想環境(BusType=0)または未知の場合は [long]0 を返す
    function ConvertTo-ThroughputBytes([int]$BusType, [int]$MediaType) {
        if ($BusType -eq 17)                        { return [long](3000 * 1MB) }  # NVMe PCIe3
        if ($BusType -eq 11 -and $MediaType -eq 4) { return [long](500  * 1MB) }  # SATA SSD
        if ($BusType -eq 11)                        { return [long](150  * 1MB) }  # SATA HDD
        if ($BusType -eq 10)                        { return [long](300  * 1MB) }  # SAS(保守的推定)
        if ($BusType -eq 7  -and $MediaType -eq 4) { return [long](400  * 1MB) }  # USB SSD(USB3.0相当)
        if ($BusType -eq 7)                         { return [long](100  * 1MB) }  # USB HDD
        return [long]0  # BusType=0(仮想環境等)または未知
    }

    try {
        # ステップ1: C: ドライブの物理ディスクを特定して判定
        # C: が存在しない環境(Server Core 等)は次のブロックへフォールバック
        $cPartition = Get-Partition -DriveLetter 'C' -ErrorAction Stop
        $cDisk      = $cPartition | Get-Disk -ErrorAction Stop
        $cDiskInfo  = Get-CimInstance -ClassName MSFT_Disk `
                        -Namespace 'root/Microsoft/Windows/Storage' -ErrorAction Stop |
                      Where-Object { $_.Number -eq $cDisk.Number } |
                      Select-Object -First 1
        if ($cDiskInfo) {
            if ($cDiskInfo.BusType -eq 0) {
                # BusType=0 は仮想環境(Hyper-V/VMware)でよく見られる不定値
                # 次のブロックへフォールバック
            } else {
                $bytes = ConvertTo-ThroughputBytes -BusType $cDiskInfo.BusType -MediaType $cDiskInfo.MediaType
                if ($bytes -gt 0) { return [PSCustomObject]@{ Bytes = $bytes; WarnMessage = '' } }
            }
        }
    } catch { <# C: ドライブ取得失敗 → フォールバックへ #> }

    try {
        # ステップ2: 全ディスクの BusType で判定(BusType=0 は仮想環境の不定値のため除外)
        $disks   = Get-CimInstance -ClassName MSFT_Disk `
                     -Namespace 'root/Microsoft/Windows/Storage' -ErrorAction Stop
        $hasNVMe = $disks | Where-Object { $_.BusType -eq 17 }
        $hasSSD  = $disks | Where-Object { $_.BusType -ne 17 -and $_.BusType -ne 0 -and $_.MediaType -eq 4 }
        if ($hasNVMe) { return [PSCustomObject]@{ Bytes = [long](3000 * 1MB); WarnMessage = '' } }
        if ($hasSSD)  { return [PSCustomObject]@{ Bytes = [long](500  * 1MB); WarnMessage = '' } }

        # ステップ3: Model 名によるフォールバック判定
        $w32disks = Get-CimInstance -ClassName Win32_DiskDrive -ErrorAction Stop
        if ($w32disks | Where-Object { $_.Model -match 'NVMe' }) {
            return [PSCustomObject]@{ Bytes = [long](3000 * 1MB); WarnMessage = '' }
        }
        if ($w32disks | Where-Object { $_.Model -match 'SSD' -and $_.Model -notmatch 'NVMe' }) {
            return [PSCustomObject]@{ Bytes = [long](500 * 1MB); WarnMessage = '' }
        }
        return [PSCustomObject]@{ Bytes = [long](150 * 1MB); WarnMessage = '' }
    } catch {
        $msg = "ディスク種別を判定できませんでした(仮想環境・権限不足の可能性)。" +
               "500 MB/s をデフォルト使用します。正確な監視には -DiskCapacityMBs で明示指定してください。"
        return [PSCustomObject]@{ Bytes = [long](500 * 1MB); WarnMessage = $msg }
    }
}

# -------------------------------------------------------
# 起動
# -------------------------------------------------------
$startTime    = Get-Date
$lastQuietLog = [datetime]::MinValue   # 「しきい値超えなし」を最後にログした時刻
$diskResult   = Get-DiskMaxThroughput -ManualMBs $DiskCapacityMBs
$diskCapacity = $diskResult.Bytes
$sampleNo          = 0
$diskCounterFails  = 0   # カウンター取得の連続失敗回数

Write-Log "監視開始 | 間隔=${IntervalSeconds}秒 | しきい値=${ThresholdPercent}% | 出力先=$OutputDir"
Write-Log "ディスク最大スループット推定値: $([math]::Round($diskCapacity / 1MB)) MB/s$(if($DiskCapacityMBs -gt 0){' (手動指定)'}else{' (自動推定)'})"
Write-Log "※ EstimatedIOPct は全IOを含む推定値のため 100% を超える場合があります"
Write-Log "コンソール表示: 上位 $ConsoleTopN 件(CSV/ログは全件記録)"
Write-Log "ディスクビジー率しきい値: ${DiskBusyThreshold}% | 応答時間しきい値: ${LatencyThresholdMs}ms"
if ($diskResult.WarnMessage -ne '') { Write-Log $diskResult.WarnMessage "WARN" }
Write-Log "CSV : $csvFile"
Write-Log "LOG : $logFile"
Write-Log "停止するには Ctrl+C を押してください"
Write-Host ""

# -------------------------------------------------------
# メインループ
# -------------------------------------------------------
try {
    while ($true) {

        if ($MaxRunMinutes -gt 0) {
            if (((Get-Date) - $startTime).TotalMinutes -ge $MaxRunMinutes) {
                Write-Log "最大実行時間 ${MaxRunMinutes} 分に達したため終了します。"
                break
            }
        }

        $sampleNo++
        $loopStart = Get-Date
        $timestamp = $loopStart.ToString("yyyy-MM-dd HH:mm:ss")

        # --- 全カウンターを1回の Get-Counter で取得(2回呼び出すと周期が約2秒延びるため統合)---
        # 取得するカウンター:
        #   プロセス単位 IO・PID : InstanceName と PID を 1:1 対応づけ、犯人プロセスを特定
        #   PhysicalDisk         : ビジー率・応答時間・待ち行列(タスクマネージャーと同じ指標)
        #   Process(System) IO   : ドライバ/カーネル起因の高IO検出
        #   Memory Pages/sec     : メモリ不足によるページングの検出
        #
        # -SampleInterval 1             : 内部で1回だけ約1秒待機してレート値を算出
        # -ErrorAction SilentlyContinue : プロセスの起動・終了タイミングの無効サンプルを許容し
        #                                 後段で Status -ne 0 のサンプルを個別スキップする
        $diskBusyPct      = 0.0   # ディスクビジー率(%)
        $diskLatencyMs    = 0.0   # 平均応答時間(ms)
        $diskQueueLen     = 0.0   # 待ち行列の長さ(2以上で深刻)
        $systemIOKBs      = 0.0   # System プロセスの総IO(KB/s)=ドライバ/カーネル起因IO
        $pagesPerSec      = 0.0   # メモリページング発生数(100以上でメモリ不足の疑い)
        try {
            $allCounters = Get-Counter -Counter @(
                '\PhysicalDisk(_Total)\% Disk Time',
                '\PhysicalDisk(_Total)\Avg. Disk sec/Transfer',
                '\PhysicalDisk(_Total)\Avg. Disk Queue Length',
                '\Process(System)\IO Read Bytes/sec',
                '\Process(System)\IO Write Bytes/sec',
                '\Memory\Pages/sec',
                '\Process(*)\IO Read Bytes/sec',
                '\Process(*)\IO Write Bytes/sec',
                '\Process(*)\ID Process'
            ) -SampleInterval 1 -ErrorAction SilentlyContinue
            $diskCounterFails = 0   # 成功したらリセット
        } catch {
            $diskCounterFails++
            Write-Log "カウンター取得失敗(スキップ、連続 ${diskCounterFails} 回目): $_" "WARN"
            $failElapsed = ((Get-Date) - $loopStart).TotalSeconds
            $failWait    = [math]::Max(0, $IntervalSeconds - $failElapsed)
            if ($failWait -gt 0) { Start-Sleep -Milliseconds ([int]($failWait * 1000)) }
            continue
        }
        if (-not $allCounters) {
            Write-Log "カウンターオブジェクトが空です(スキップ)" "WARN"
            $failElapsed = ((Get-Date) - $loopStart).TotalSeconds
            $failWait    = [math]::Max(0, $IntervalSeconds - $failElapsed)
            if ($failWait -gt 0) { Start-Sleep -Milliseconds ([int]($failWait * 1000)) }
            continue
        }
        # --- 全サンプルを1回のループで振り分け(ディスク全体・プロセス単位を同時処理)---
        # ① ディスク全体カウンター(PhysicalDisk・Memory)→ 専用変数へ
        # ② プロセス単位カウンター(Process(*))→ readMap/writeMap/pidMap/nameMap へ
        #
        # 重要: Process(System) は $systemIOKBs に集計するが
        #       readMap/writeMap には含めない(プロセス一覧への二重出力を防ぐ)
        $pathByPid = @{}
        $readMap   = @{}
        $writeMap  = @{}
        $pidMap    = @{}   # lowercase InstanceName → PID
        $nameMap   = @{}   # lowercase InstanceName → 元ケース InstanceName(表示用)

        foreach ($sample in $allCounters.CounterSamples) {
            if ($sample.Status -ne 0) { continue }

            $pl = $sample.Path.ToLowerInvariant()

            # ── ディスク全体・システム要因カウンター ──
            if    ($pl -like '*% disk time*')             { $diskBusyPct   = [math]::Round($sample.CookedValue, 1);           continue }
            elseif ($pl -like '*avg. disk sec/transfer*') { $diskLatencyMs = [math]::Round($sample.CookedValue * 1000, 1);    continue }
            elseif ($pl -like '*avg. disk queue length*') { $diskQueueLen  = [math]::Round($sample.CookedValue, 2);           continue }
            elseif ($pl -like '*process(system)*io*')     { $systemIOKBs  += [math]::Round($sample.CookedValue / 1KB, 1);    continue }
            elseif ($pl -like '*pages/sec*')              { $pagesPerSec   = [math]::Round($sample.CookedValue, 1);           continue }

            # ── プロセス単位カウンター ──
            # InstanceName が null のサンプルをスキップ(Status=0 でも稀に発生する)
            if ($null -eq $sample.InstanceName) { continue }

            $instRaw = $sample.InstanceName
            $inst    = $instRaw.ToLowerInvariant()
            # _total・idle は二重計上防止のため除外
            # system は上段で集計済みのためプロセス一覧には含めない
            if ($inst -eq '_total' -or $inst -eq 'idle' -or $inst -eq 'system') { continue }
            $nameMap[$inst] = $instRaw   # 表示用に元ケースを保存

            if ($pl -like '*io read*') {
                $readMap[$inst] = $sample.CookedValue
            } elseif ($pl -like '*io write*') {
                $writeMap[$inst] = $sample.CookedValue
            } elseif ($pl -like '*id process*') {
                $pidMap[$inst] = [int]$sample.CookedValue
            }
        }

        # readMap と writeMap の Union(書き込みのみのプロセスの漏れを防ぐ)
        $allInsts = [System.Collections.Generic.HashSet[string]]::new()
        $readMap.Keys  | ForEach-Object { [void]$allInsts.Add($_) }
        $writeMap.Keys | ForEach-Object { [void]$allInsts.Add($_) }

        # --- しきい値を超えるプロセスを抽出 ---
        $alerts = @()
        foreach ($inst in $allInsts) {
            $read  = if ($readMap.ContainsKey($inst))  { $readMap[$inst]  } else { 0.0 }
            $write = if ($writeMap.ContainsKey($inst)) { $writeMap[$inst] } else { 0.0 }
            $total = $read + $write
            $pct   = [math]::Round(($total / $diskCapacity) * 100, 2)

            if ($pct -ge $ThresholdPercent) {
                $procId  = if ($pidMap.ContainsKey($inst)) { $pidMap[$inst] } else { 0 }
                # nameMap から元ケースの InstanceName を復元
                # (前段ループの $instRaw は最後のサンプルの値が残るため直接使用しない)
                $instRaw  = if ($nameMap.ContainsKey($inst)) { $nameMap[$inst] } else { $inst }
                $baseName = ($instRaw -replace '#\d+$', '')

                $alerts += [PSCustomObject]@{
                    Timestamp       = $timestamp
                    InstanceName    = $instRaw      # Chrome#3 等、インスタンスを一意に特定できる名前
                    ProcessName     = $baseName     # Chrome 等、人間が読みやすいベース名
                    PID             = $procId
                    EstimatedIOPct  = $pct
                    ReadKBPerSec    = [math]::Round($read  / 1KB, 1)
                    WriteKBPerSec   = [math]::Round($write / 1KB, 1)
                    TotalIOKBPerSec = [math]::Round($total / 1KB, 1)
                    ExePath         = ''            # アラート確定後に後付け(下段で取得)
                }
            }
        }

        # --- アラート対象の PID のみ EXEパスを取得(全件取得より効率的)---
        # PID は \Process(*)\ID Process カウンター(pidMap)から取得済みのため
        # Get-Process は ExePath の取得にのみ使用する。
        if ($alerts.Count -gt 0) {
            $neededPids = $alerts | Where-Object { $_.PID -gt 0 } |
                          Select-Object -ExpandProperty PID -Unique
            foreach ($procId in $neededPids) {
                try {
                    $p = Get-Process -Id $procId -ErrorAction Stop
                    $pathByPid[$procId] = if ($p.Path) { $p.Path } else { '' }
                } catch {
                    $pathByPid[$procId] = ''
                }
            }
            foreach ($a in $alerts) {
                $a.ExePath = if ($pathByPid.ContainsKey($a.PID)) { $pathByPid[$a.PID] } else { '' }
            }
        }

        # --- ディスク全体のビジー率・応答時間アラート(タスクマネージャーと同じ指標)---
        if ($diskBusyPct -ge $DiskBusyThreshold -or $diskLatencyMs -ge $LatencyThresholdMs) {
            Write-Log "【ディスク高負荷】ビジー率: ${diskBusyPct}% | 応答時間: ${diskLatencyMs}ms | 待ち行列: ${diskQueueLen}" "ALERT"

            # システム要因の自動判別(複数該当する場合はすべて出力)
            if ($pagesPerSec -ge 100) {
                Write-Log "  └ 原因候補: メモリ不足によるページング(Pages/sec: ${pagesPerSec})- メモリ使用量の確認またはRAM増設を推奨" "ALERT"
            }
            if ($systemIOKBs -ge 1024) {
                Write-Log "  └ 原因候補: System プロセス(ドライバ/カーネル)の高IO(${systemIOKBs} KB/s)- Windows Update・ウイルス対策・SysMain を確認" "ALERT"
            }
            if ($diskQueueLen -ge 0.5 -and $pagesPerSec -lt 100 -and $systemIOKBs -lt 1024) {
                Write-Log "  └ 原因候補: ディスク自体の応答遅延(待ち行列: ${diskQueueLen})- CrystalDiskInfo でディスク健全性を確認" "ALERT"
            }
            if ($diskQueueLen -lt 0.5 -and $pagesPerSec -lt 100 -and $systemIOKBs -lt 1024) {
                Write-Log "  └ 原因候補: 短時間の高負荷(瞬間的な書き込みスパイク等)- 継続的に高い場合はディスク健全性を確認" "ALERT"
            }
        }

        # --- プロセス単位の結果出力 ---
        if ($alerts.Count -gt 0) {
            $alerts = $alerts | Sort-Object EstimatedIOPct -Descending
            Write-Log "【サンプル #$sampleNo】しきい値超えプロセス: $($alerts.Count) 件" "ALERT"
            $lastQuietLog = [datetime]::MinValue   # アラート後の「収束」を必ずログに残すためリセット

            # コンソールは上位 N 件のみ表示(CSV/ログは全件記録)
            $displayAlerts = $alerts | Select-Object -First $ConsoleTopN
            foreach ($a in $displayAlerts) {
                $pathInfo = if ($a.ExePath) { " | $($a.ExePath)" } else { '' }
                $msg = "  $($a.InstanceName) (PID:$($a.PID)) | " +
                       "IO使用率: $($a.EstimatedIOPct)% | " +
                       "R: $($a.ReadKBPerSec) KB/s  W: $($a.WriteKBPerSec) KB/s$pathInfo"
                Write-Log $msg "ALERT"
            }
            if ($alerts.Count -gt $ConsoleTopN) {
                Write-Log "  ...他 $($alerts.Count - $ConsoleTopN) 件(CSV/ログを参照)" "ALERT"
            }

            # CSV は全件記録(ロック競合時はリトライ)
            foreach ($a in $alerts) {
                for ($retry = 1; $retry -le 3; $retry++) {
                    try {
                        Append-CsvRow -Row $a -FilePath $csvFile
                        break
                    } catch {
                        if ($retry -lt 3) {
                            Start-Sleep -Milliseconds 200
                        } else {
                            Write-Log "CSV 書込失敗(3回リトライ後あきらめ): $_" "WARN"
                        }
                    }
                }
            }
        } else {
            # 「しきい値超えなし」は QuietMinutes 分に1回だけログに出す(連続出力を抑制)
            # アラート発生後は必ず1回出力して「収束した」ことをログに残す
            $now          = Get-Date
            $quietElapsed = ($now - $lastQuietLog).TotalMinutes
            if ($QuietMinutes -eq 0 -or $quietElapsed -ge $QuietMinutes) {
                $quietSuffix = if ($QuietMinutes -eq 0) { "(抑制なし・毎回出力)" } else { "(次回出力まで最大 ${QuietMinutes} 分抑制)" }
                Write-Log "サンプル #$sampleNo : プロセス単位のIOはしきい値以下 | ビジー率: ${diskBusyPct}% 応答時間: ${diskLatencyMs}ms 待ち行列: ${diskQueueLen} System-IO: ${systemIOKBs}KB/s Pages/sec: ${pagesPerSec}${quietSuffix}"
                $lastQuietLog = $now
            }
        }

        # 正味 IntervalSeconds 秒待機(Get-Counter の約1秒待機と処理時間を差し引く)
        $elapsed = ((Get-Date) - $loopStart).TotalSeconds
        $wait    = [math]::Max(0, $IntervalSeconds - $elapsed)
        if ($wait -gt 0) { Start-Sleep -Milliseconds ([int]($wait * 1000)) }
    }
}
finally {
    $totalMin = [math]::Round(((Get-Date) - $startTime).TotalMinutes, 1)
    Write-Log "監視終了 | 総サンプル数: $sampleNo | 経過時間: ${totalMin} 分"
    Write-Host ""
    Write-Host "ログ保存先 : $logFile" -ForegroundColor Green
    Write-Host "CSV保存先  : $csvFile"  -ForegroundColor Green
}

停止は Ctrl+C で、終了時に CSV とログの保存先が表示されます。

実際に動かしてわかったこと

スクリプトを動かしてしばらくすると、こんなログが出ました。

[ALERT] 【ディスク高負荷】ビジー率: 47.1% | 応答時間: 474ms | 待ち行列: 0.94
[INFO]  サンプル #N : プロセス単位のIOはしきい値以下

ディスクは明らかに詰まっているのに、プロセス単位のアラートは一件も出なかったです。応答時間 474ms は正常な SSD では通常 1〜5ms 程度なので、この瞬間にかなり悪化していたことになります。

これをきっかけに Windows のイベントログを確認しました。

Get-WinEvent -LogName "System" |
  Where-Object { $_.ProviderName -eq "nvlddmkm" } |
  Select-Object TimeCreated, Id, Message |
  Format-List

nvlddmkm(NVIDIA のグラフィックスドライバー)のイベント ID 153 が 2 件記録されていました。GPU が応答しなくなりドライバーがリセットした記録です。GPU ドライバーがクラッシュすると、その回復処理でシステム全体が一時的に重くなり、ディスクへのアクセスが詰まることがあるようです。

ドライバーのバージョンを確認すると約 2 ヶ月古いものが入っていたので、最新版に更新しました。インストール時は「カスタム」を選んでグラフィックスドライバーのみにチェックを絞り、余計なソフトウェアは入れないようにしました。

ただ、これで解決したかはまだわからないです。。引き続きスクリプトを改善しながら様子を見ています。

よくある犯人

自分の場合は GPU ドライバーが原因だったかもですが、一般的にディスク高負荷の原因としてよく挙がるプロセスは以下のようなものです。

  • SearchIndexer:Windows 検索のインデックス作成。短時間だけ現れて消えるパターンが多い
  • Windows Update(wuaucltWaaSMedicSvc):バックグラウンドで動いていることに気づきにくい
  • ウイルス対策ソフトのスキャン:特にフルスキャン時
  • SysMain(SuperFetch):SSD の環境では無効にすると改善する場合がある

これらは 5 秒間隔の記録でも十分に捕捉できます。一瞬で終わる処理でも、繰り返し発生していれば記録に残るので、なんとなく特定できると良いなと思っています。

注意点

実行時にいくつか注意点があります。

  • 管理者権限がないとパフォーマンスカウンターの一部が取得できない
  • プロセス数が 200 を超える環境では -IntervalSeconds 10 程度に増やすと安定する
  • CSV は Excel で開いたままにしていると書き込みがロックされるため、確認するときはいったん閉じるかコピーして使う
  • しきい値(デフォルト 50%)は環境によって調整する

しきい値をカスタマイズするには、実行時にパラメーターで指定します。

.\Monitor-DiskUsage.ps1 -ThresholdPercent 10 -DiskBusyThreshold 70 -LatencyThresholdMs 30

文字コードについて

スクリプトには日本語のコメントや出力メッセージが含まれているため、そのままでは実行時に文字化けエラーが出ることがあります。

自分の場合はこんなエラーが出ました。

発生場所 Monitor-DiskUsage.ps1:16 文字:14
+ $scriptBase = if ($PSScriptRoot -and $PSScriptRoot -ne '') {
+              ~
'=' の後に式が存在しません。
式またはステートメントのトークン 'xxx' を使用できません。

コードそのものは正しいのに、日本語部分が文字化けして PowerShell が構文エラーと誤認するパターンです。

原因はファイルの文字コードにあります。PowerShell 5.1(Windows 標準)はデフォルトで Shift-JIS を想定していますが、多くのエディタは UTF-8 で保存します。この食い違いが文字化けを引き起こします。

VSCode で文字コードを変換するといいでしょう。

  1. VSCode でスクリプトを開く
  2. 画面右下の文字コード表示(UTF-8 等)をクリック
  3. 「Encoding で保存」を選択
  4. 「UTF-8 with BOM」を選んで保存

BOM(Byte Order Mark)はファイルの先頭に付く識別子で、PowerShell 5.1 はこれを手がかりに UTF-8 ファイルを正しく認識します。BOM なしの UTF-8 では認識できないため、必ず「with BOM」を選ぶ必要があります。

保存し直すと冒頭の構文エラーが消え、日本語のログメッセージも正しく表示されるようになりました。

補足

長時間記録する場合、ログファイルがそれなりのサイズになります。C ドライブ上に保存し続けると、その C ドライブへの書き込み自体がわずかに影響することもあります。-OutputDir パラメーターで別のドライブに保存先を変えるといいでしょう。

Windows PowerShell 実践システム管理ガイド 第3版 (Amazon) Windows セキュリティインターナル ―PowerShell で理解する Windows の認証、認可、監査の仕組み (Amazon)

コメントを送る
Hugo で構築されています。  /  テーマ StackJimmy によって設計されています。
本サイトに記載されている会社名・製品名などは、各社の商標または登録商標です。