日程Fit|「いつ空いてますか?」の往復はもう不要。候補日を選んでURLを送るだけ|登録不要|今すぐ無料で使う →

PowerShellモジュールのクリーンアップをログオフ・ウィンドウ×・シャットダウンでも確実に実行する方法|設計と実装ガイド

PowerShellモジュールが一時フォルダーや外部サービスにリソースを作る設計は珍しくありません。しかし「モジュールの寿命が尽きるときに必ず後始末を走らせる」ことは想像以上に難題です。特にウインドウ右上の×で閉じられた場合やユーザーのログオフでは、イベントが発火せずクリーンアップ関数が実行されないことが起こり得ます。本記事はその現実を踏まえ、確実性を最大化する実装と運用の具体策を、サンプルコード付きで解説します。

日程Fit。無料・登録不要。「いつ空いてる?」を、ひとつのリンクで。リンクを送って、○△×でかんたん日程調整。無料で日程を作る。
目次

問題の核心:PowerShellだけでは「すべての終了経路」を捕まえられない

PowerShellにはモジュール破棄やプロセス終了をフックできる便利な仕組みがいくつかあります。

  • $ExecutionContext.SessionState.Module.OnRemoveRemove-ModuleImport-Module -Force 時に発火)
  • Register-EngineEvent -SourceIdentifier PowerShell.Exitingexit など正常な終了経路で発火)

ところが、以下のようなケースではいずれのフックも実行されないことがあります。

  • PowerShellコンソールやWindows Terminalのタブを、右上の × で強制的に閉じる
  • OSがユーザー セッションをログオフ/シャットダウンで強制終了する

これらはOS側がコンソールアプリに十分な猶予を与えなかったり、プロセスに終了通知が届いても処理時間制限により強制終了されたりするためです。言い換えると、PowerShell標準イベントだけで完全な後始末を保証することは不可能です。

現実解:複数の仕組みを「重ねがけ」して確率を上げる

クリーンアップの確実性を高めるには、出口(終了経路)を1つに頼らず、複数の仕掛けを組み合わせます。以下は実運用で扱いやすい順に並べた対策群です。

アプローチ具体策長所注意点
ラッパー スクリプト/専用ランチャーpowershell.exe -NoExit -File MyWrapper.ps1 内で Import-Module → 実処理 → 必ず CleanUp()finally で呼んで終了想定どおり必ず実行できるユーザーが毎回ラッパー経由で起動する運用ルールが必要
セッション外クリーナータスク スケジューラで定期実行/ログオフやシャットダウンのイベントで起動。孤立リソースの掃除ユーザー操作に依存しない「使用中かどうか」の判定を設計する必要
ログオフ スクリプト企業環境ならグループポリシーの「ユーザーのログオフ」スクリプトで CleanUp-MyModule を実行一括配布が容易ポリシー適用権限が必要/端末管理下であることが前提
Disposeパターンの徹底モジュールAPIを「作って使って破棄する」形に寄せ、try/finally で確実にDispose学習コストが低い/スクリプト単体で完結人的運用ミスに弱い(finally を書き忘れる等)
(補助)コンソール制御ハンドラSetConsoleCtrlHandler 経由で ベストエフォート の終端フックを追加右上×やログオフで動く場合がある確実ではない/ホストや状況で挙動が変わる

サンプル実装:モジュール側の基本フック

まずは「取れる終了経路はすべて取る」前提で、モジュール内で OnRemovePowerShell.Exiting を仕込みます。加えて、ベストエフォートでコンソール制御ハンドラも登録しておきます。

# MyModule.psm1
using namespace System

# ---- 初期化:リース(一時リソース)の台帳フォルダー ----
$script:LeaseRoot = Join-Path $env:TEMP 'MyModule\leases'
New-Item -ItemType Directory -Path $script:LeaseRoot -Force | Out-Null

function New-MyTempResource {
    [CmdletBinding()]
    param([string]$Name = (New-Guid))
    $lease = [pscustomobject]@{
        Id      = [guid]::NewGuid().ToString()
        Name    = $Name
        Owner   = "$env:COMPUTERNAME\$env:USERNAME"
        Pid     = $PID
        Created = (Get-Date)
        LastUse = (Get-Date)
    }
    $path = Join-Path $script:LeaseRoot "$($lease.Id).json"
    $lease | ConvertTo-Json | Set-Content -Path $path -Encoding UTF8
    $lease
}

function Touch-MyLease {
    param([Parameter(Mandatory)]$Lease)
    $Lease.LastUse = Get-Date
    $path = Join-Path $script:LeaseRoot "$($Lease.Id).json"
    $Lease | ConvertTo-Json | Set-Content -Path $path -Encoding UTF8
}

function CleanUp-MyModule {
    [CmdletBinding()]
    param(
        [switch]$AllUsers,
        [switch]$Aggressive,
        [string]$Reason = 'Unknown'
    )
    # 冪等・多重呼び出しに耐えるためのミューテックス
    $m = New-Object System.Threading.Mutex($false, "Global\MyModule-Cleanup")
    try {
        if (-not $m.WaitOne([TimeSpan]::FromSeconds(5))) { return }

        $now = Get-Date
        $ownerFilter = if ($AllUsers) { { $true } } else { { $_.Owner -eq "$env:COMPUTERNAME\$env:USERNAME" } }
        $leases = Get-ChildItem $script:LeaseRoot -Filter *.json -ErrorAction SilentlyContinue |
                  ForEach-Object { Get-Content $_.FullName -Raw | ConvertFrom-Json } |
                  Where-Object $ownerFilter

        foreach ($lease in $leases) {
            $age = $now - ([datetime]$lease.LastUse)
            $expired = ($Aggressive -or $age.TotalMinutes -ge 30 -or $lease.Pid -eq $PID)
            if ($expired) {
                try {
                    # 実際の外部リソース削除処理(ここを書き換える)
                    # Remove-Item や REST API 呼び出し等
                    # 例: Remove-Item -Path (Join-Path $env:TEMP $lease.Name) -Recurse -Force
                } catch {
                    # ログだけ残して続行
                } finally {
                    $fn = Join-Path $script:LeaseRoot "$($lease.Id).json"
                    Remove-Item $fn -ErrorAction SilentlyContinue
                }
            }
        }
    } finally {
        $m.ReleaseMutex() | Out-Null
        $m.Dispose()
    }
}

# ---- OnRemove: Remove-Module などで発火 ----
$ExecutionContext.SessionState.Module.OnRemove = {
    try { CleanUp-MyModule -Reason 'OnRemove' } catch { }
}

# ---- PowerShell.Exiting: 正常終了時に発火 ----
Register-EngineEvent -SourceIdentifier PowerShell.Exiting -Action {
    try { CleanUp-MyModule -Reason 'PowerShell.Exiting' } catch { }
} | Out-Null

# ---- コンソール制御ハンドラ(ベストエフォート)----
Add-Type -TypeDefinition @"
using System;
using System.Runtime.InteropServices;
public static class ConsoleCtrl {
    public delegate bool HandlerRoutine(int ctrlType);
    [DllImport("Kernel32.dll")]
    public static extern bool SetConsoleCtrlHandler(HandlerRoutine Handler, bool Add);
}
"@
$script:consoleHandler = [ConsoleCtrl+HandlerRoutine]{
    param([int]$t)
    try { CleanUp-MyModule -Reason "ConsoleCtrl:$t" } catch { }
    # 戻り値 false: 既定処理を継続(OSにブロックしない)
    $false
}
[ConsoleCtrl]::SetConsoleCtrlHandler($script:consoleHandler, $true) | Out-Null

注意:このハンドラは「効くこともある」程度の保険です。OSやホスト、同時実行状況によっては呼ばれません。必ず次章のセッション外クリーナーと併用してください。

ラッパー(専用ランチャー)で finally を強制する

ユーザーが必ず専用ランチャーから作業を開始する運用にできるなら、これが最も堅実です。finally で確実に CleanUp-MyModule を呼び出せます。

# MyWrapper.ps1
param([string]$ScriptPath)

# モジュールをロード
Import-Module 'C:\Program Files\MyCompany\MyModule\MyModule.psd1' -Force

try {
    if ($ScriptPath -and (Test-Path $ScriptPath)) {
        & $ScriptPath
    } else {
        Write-Host '作業を行ってください。終了するには Enter。'
        Read-Host
    }
}
finally {
    try { CleanUp-MyModule -Reason 'WrapperFinally' } catch { }
    Remove-Module MyModule -Force -ErrorAction SilentlyContinue
}

セッション外クリーナー(定期・イベント駆動)の作り方

「誰かが消し忘れても一定時間で自動的に消す」仕組みは現実運用で効果絶大です。ここでは 定期清掃ログオフ/シャットダウンのイベントでの起動 を示します。

1) 15分ごとの定期清掃タスク

# 管理者で実行:定期清掃タスクの登録
$module = 'C:\Program Files\MyCompany\MyModule\MyModule.psd1'
$dir = 'C:\ProgramData\MyCompany'
New-Item -ItemType Directory -Path $dir -Force | Out-Null
$cleanupScript = Join-Path $dir 'CleanUp-MyModule.ps1'
@'
param([string]$Reason = "Scheduled")
try {
    Import-Module "'"$module"'" -ErrorAction SilentlyContinue
    CleanUp-MyModule -AllUsers -Aggressive -Reason $Reason
} catch { }
'@ | Set-Content -Path $cleanupScript -Encoding UTF8

$action = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument "-NoProfile -WindowStyle Hidden -File `"$cleanupScript`" -Reason Scheduled"
$trigger = New-ScheduledTaskTrigger -At ((Get-Date).AddMinutes(1)) -Once -RepetitionInterval (New-TimeSpan -Minutes 15) -RepetitionDuration ([TimeSpan]::MaxValue)
$principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -RunLevel Highest
$task = New-ScheduledTask -Action $action -Trigger $trigger -Principal $principal -Settings (New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -StartWhenAvailable)
Register-ScheduledTask -TaskName 'MyModule\PeriodicClean' -InputObject $task -Force | Out-Null 

2) ログオフ/シャットダウンのイベントで起動

イベントトリガーはXMLまたは schtasks.exe での登録が簡単です。以下はセキュリティログの「ユーザーによるログオフ(Event ID 4647)」と、システムログの「シャットダウン開始(Event ID 1074)」を捕まえる例です。

# 管理者で実行:イベントトリガータスクを作成
$cleanupCmd = "powershell.exe -NoProfile -WindowStyle Hidden -File `"$cleanupScript`" -Reason Event"

# ログオフ(4647)
schtasks /Create /TN "MyModule\OnLogoffClean" /TR "$cleanupCmd" /SC ONEVENT /EC Security /MO "*[System[Provider[@Name='Microsoft-Windows-Security-Auditing'] and (EventID=4647)]]" /RU SYSTEM /F | Out-Null

# シャットダウン(1074)
schtasks /Create /TN "MyModule\OnShutdownClean" /TR "$cleanupCmd" /SC ONEVENT /EC System /MO "*[System[Provider[@Name='User32'] and (EventID=1074)]]" /RU SYSTEM /F | Out-Null

イベントトリガーは「ユーザーセッションが既に終了している」状況でも、SYSTEM アカウントでクリーンアップを実行できるため、終了経路の穴を大きく埋められます。

Disposeパターンで「作成と破棄」をセットにする

モジュールの公開関数を「ハンドル(セッション)を返す → 使い終わったら Dispose/Close する」という形に寄せるのも強力です。PowerShellクラスで IDisposable を実装し、try/finally と組み合わせれば、スクリプト利用者の習慣に溶け込みます。

class MyModuleHandle : System.IDisposable {
    [string]$Id
    MyModuleHandle() {
        $this.Id = [guid]::NewGuid().ToString()
        # 実際のリソース確保処理
        New-MyTempResource | Out-Null
    }
    [void] Dispose() {
        # 実際の解放処理
        CleanUp-MyModule -Reason "Dispose"
    }
}

function Use-MyModule {
    param([scriptblock]$Action)
    $h = [MyModuleHandle]::new()
    try   { & $Action.Invoke($h) }
    finally { $h.Dispose() }
}

このパターンを推進するため、ドキュメント・サンプル・テンプレートを通して「必ず Use-MyModule に処理を入れる」運用を浸透させましょう。

ログ設計と冪等性:クリーンアップ関数を何度呼んでも安全に

  • 冪等性:既に削除済みの対象に対してもエラーにせず 成功扱い にする(存在しないパスは黙ってスキップ)。
  • タイムスライドLastUse と現在時刻の差で「孤立」かどうかを判断(例:30分以上アクセスなし)。
  • 排他制御:グローバルミューテックスで多重起動を防止。
  • 遅延解放:外部APIが一時的にエラーの場合は再試行/バックオフ。
  • 監査%ProgramData%\MyCompany\MyModule\logs に日時・理由(Reason)・対象数をローテーション出力。

終了経路ごとの挙動マトリクス

操作OnRemovePowerShell.ExitingConsoleCtrlHandlerイベントトリガー定期清掃
Remove-Module
Import-Module -Force
exit / 正常終了✓(Remove-Module されていれば)場合により✓
ウインドウ右上の ×✕(多くは不発)✕(不発)場合により✓後で✓(孤立清掃)
ログオフ場合により✓✓(イベントトリガー)後で✓
シャットダウン/再起動場合により✓✓(イベントトリガー)後で✓
プロセス強制終了(Kill)✓(多くは直後に発生)後で✓

実用レベルの「二段構え」設計例

  1. モジュール内フックの最大化OnRemovePowerShell.Exiting、ベストエフォートのコンソールハンドラ。
  2. ラッパー運用の徹底:利用者には必ず MyWrapper.ps1 を配布・ショートカット化。
  3. セッション外クリーナー:15分周期の定期清掃+ログオフ/シャットダウンイベントトリガー。
  4. 冪等・台帳管理%TEMP%\MyModule\leases\*.json にGUIDキーで記録し、消し忘れの検出を単純化。

テスト手順:挙動をログで可視化する

終了フックが本当に動いているかは、証跡ログで確認します。

$log = Join-Path $env:TEMP 'MyModule-Cleanup.log'
Register-EngineEvent -SourceIdentifier PowerShell.Exiting -Action {
    Add-Content -Path $log -Value "$(Get-Date) - Exiting"
} | Out-Null
$ExecutionContext.SessionState.Module.OnRemove = {
    Add-Content -Path $log -Value "$(Get-Date) - OnRemove"
}
# コンソール制御ハンドラや CleanUp-MyModule 内にも Add-Content を入れておく

そのうえで以下の操作を一通り試し、ログに刻まれる行の違いを確認しましょう。

  • Remove-ModuleImport-Module -Force
  • exit
  • 右上の × で閉じる
  • ログオフ/シャットダウン
  • Stop-Process やタスクマネージャで Kill

よくある落とし穴と対策

  • 外部APIのレート制限:クリーンアップが集中するイベント(ログオフなど)では一時的に失敗しがち。指数バックオフ+遅延再試行を。
  • 権限の壁:ユーザー権限で作ったものを SYSTEM で削除できない/その逆のケースに注意。作成・削除の責務を統一するか、ACLを最初から適切に。
  • マルチセッション:同一ユーザーの複数セッション(RDPなど)で競合が起きやすい。グローバルミューテックス名にユーザー名だけでなく 端末セッションID を含める設計も有効。
  • Windows Terminalと従来コンソール:ハンドラの呼ばれ方が微妙に異なることがある。ホストごとに試験を。
  • クロスプラットフォーム:PowerShell 7でLinux/macOSを対象にする場合、シグナルと終了処理の流儀が異なる。Windowsのイベントトリガーに相当する仕組み(systemdユーザーサービス等)でセッション外クリーナーを用意する。

最小構成テンプレート(貼って動かせる一式)

以下は「台帳管理+冪等クリーンアップ+定期清掃」の最小骨格です。モジュール名や実リソースの削除処理だけ差し替えれば、そのままプロジェクトに組み込めます。

# Install-Cleaner.ps1 (管理者で1回実行)
$module = 'C:\Program Files\MyCompany\MyModule\MyModule.psd1'
$dir = 'C:\ProgramData\MyCompany'
$cleanupScript = Join-Path $dir 'CleanUp-MyModule.ps1'
New-Item -ItemType Directory -Path $dir -Force | Out-Null
@'
param([string]$Reason="Scheduled")
try {
    Import-Module "'"$module"'" -ErrorAction SilentlyContinue
    CleanUp-MyModule -AllUsers -Aggressive -Reason $Reason
} catch { }
'@ | Set-Content -Path $cleanupScript -Encoding UTF8

$action  = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument "-NoProfile -WindowStyle Hidden -File `"$cleanupScript`" -Reason Scheduled"
$trigger = New-ScheduledTaskTrigger -At ((Get-Date).AddMinutes(1)) -Once -RepetitionInterval (New-TimeSpan -Minutes 15) -RepetitionDuration ([TimeSpan]::MaxValue)
$principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -RunLevel Highest
Register-ScheduledTask -TaskName 'MyModule\PeriodicClean' -Action $action -Trigger $trigger -Principal $principal -Force | Out-Null

$cleanupCmd = "powershell.exe -NoProfile -WindowStyle Hidden -File `"$cleanupScript`" -Reason Event"
schtasks /Create /TN "MyModule\OnLogoffClean"   /TR "$cleanupCmd" /SC ONEVENT /EC Security /MO "*[System[Provider[@Name='Microsoft-Windows-Security-Auditing'] and (EventID=4647)]]" /RU SYSTEM /F | Out-Null
schtasks /Create /TN "MyModule\OnShutdownClean" /TR "$cleanupCmd" /SC ONEVENT /EC System   /MO "*[System[Provider[@Name='User32'] and (EventID=1074)]]" /RU SYSTEM /F | Out-Null
Write-Host "Cleaner Installed."

まとめ:保証できないものを、設計で保証に近づける

  • 事実:PowerShellの OnRemovePowerShell.Exiting は「正常終了」に強く、「強制終了」に弱い。
  • 戦略明示的に終了させる仕組み(ラッパー/Dispose) と、ユーザーに依存しない後掃除(定期清掃+イベント駆動) の二段構え。
  • 要件:クリーンアップは冪等で安全、台帳で追跡、ログで観測、権限を設計時に固定。
  • 効果:右上×やログオフ、シャットダウン、Killなど多様な終了経路に対し、実害(孤立リソース)を最小化できる。

完全な保証は不可能でも、設計と運用で「限りなく保証に近づける」ことはできます。今日から既存モジュールに台帳・冪等・二段構えの原則を組み込み、現場で本当に効くクリーンアップへ進化させていきましょう。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次