Microsoft DefenderのファイルロックでPowerShellバッチが失敗する原因と対策|自動リトライ・除外設定・設計ベストプラクティス

夜間バッチや定期ジョブで「さっき作ったばかりのファイル」をすぐ移動・圧縮しようとしたら、なぜか失敗する――原因が Microsoft Defender のスキャンによる一時的なファイルロックだった、という相談が増えています。本稿では PowerShell から見える現象の整理、グローバル設定の可否、コード修正を最小化するラッパー実装、Defender 側の現実的な設定、そして運用ベストプラクティスまでを一気にまとめます。コピペで使える関数とチェックリスト付きです。

目次

Defender による一時ロックで PowerShell スクリプトが失敗する問題の全体像

Microsoft Defender のリアルタイム保護は、生成・変更されたファイルを即時にスキャンします。スキャン中はファイルハンドルが占有され、他プロセス(あなたの PowerShell ジョブを含む)が 共有違反(ERROR_SHARING_VIOLATION / 32, 0x20) でアクセスできないことがあります。結果として、以下のようなコマンドレットが例外や黙示の失敗を起こします。

  • Copy-Item, Move-Item, Remove-Item
  • Compress-Archive(ZIP 生成・更新)
  • Out-File, Set-Content(直後の再オープン)
  • ZIP/CSV の直後検証([System.IO] 経由の即時オープン)

よくあるログ・症状と原因の対応

症状(例)典型的な出力・例外想定原因第一選択の対処
Copy-Item が時々失敗「別のプロセスが使用中のため…」
HResult: 0x80070020
Defender が変更直後のファイルをスキャンリトライ+待機(指数バックオフ)。
必要に応じて作業フォルダーの除外。
Compress-Archive が黙って失敗エラーなし/ZIP が 0 バイト・壊れている入力の一部がスキャン中でハンドル取得に失敗ZIP 完了検証(開けるか・エントリ数)+再試行。
Remove-Item 後にファイルが残る存在確認で残存。
時に IOException
削除直前・直後にスキャンが衝突排他確認→削除→存在確認→再試行。
夜間だけ失敗率が高い不定期・断続的に失敗夜間バッチ集中で I/O が高密度化し衝突増ステージング設計(後述)+ジョブ分散。

結論:PowerShell にグローバルな「自動待機」設定はない

PowerShell(5.1/7.x)には、ファイルロック検出時に自動的に待機・リトライさせるようなグローバル設定($PSPreference 等)は存在しません。各コマンドレットのエラーハンドリングはばらつきがあり、統一オプションもありません。したがって スクリプト側での再試行(Retry)実装 を基本戦略とします。

コード修正を最小化するラッパー戦略

既存の 1 行を関数呼び出しに置き換えるだけで「待ってからもう一度やる」を実現できるよう、汎用ラッパーを用意します。まずはシンプル版(例示)から。

最小ラッパー:1 行置換で導入

function Invoke-WithRetry {
    param(
        [Parameter(Mandatory)][scriptblock]$Action,
        [int]$MaxRetry = 10,
        [int]$DelaySec = 3
    )
    for ($i = 0; $i -lt $MaxRetry; $i++) {
        try {
            & $Action
            return
        } catch {
            Start-Sleep -Seconds $DelaySec
        }
    }
    throw "処理が $MaxRetry 回失敗しました。"
}

# 使い方(既存の 1 行を置換)

Invoke-WithRetry { Compress-Archive -Path $src -DestinationPath $dst } 

この形でも多くの「たまに起こる失敗」は吸収できます。ただし、黙示の失敗(エラーを投げないが結果が不正)には検証ロジックが必要です。そこで本稿では、共有違反だけを賢く判定し、指数バックオフ+ジッターで待機、成功を検証する 実運用向け関数群を用意します。

実運用ラッパー:共有違反を見極める高度版

# 共有違反(ERROR_SHARING_VIOLATION / 0x20, 0x80070020)判定
function Test-IsSharingViolation {
    param([Parameter(Mandatory)][Exception]$Exception)


$messages = @(
    'used by another process',
    'アクセスできません',
    '別のプロセスが使用中',
    'being used by another process'
)
$hr = $Exception.HResult
if ($Exception.InnerException) { $hr = $Exception.InnerException.HResult }

return (
    $hr -eq -2147024864 -or # 0x80070020
    $messages | ForEach-Object { $Exception.Message -match $_ } | Where-Object { $_ } | Measure-Object | Select-Object -ExpandProperty Count
)


}

# ファイルロック確認(FileShare.None でオープンを試みる)

function Test-FileLocked {
param([Parameter(Mandatory)][string]$Path)
if (-not (Test-Path -LiteralPath $Path)) { return $false }
try {
$fs = [System.IO.File]::Open($Path,
[System.IO.FileMode]::Open,
[System.IO.FileAccess]::ReadWrite,
[System.IO.FileShare]::None)
$fs.Close()
return $false
} catch [System.IO.IOException] {
return $true
}
}

# アンロック待ち

function Wait-UntilUnlocked {
param(
[Parameter(Mandatory)][string]$Path,
[int]$TimeoutSec = 90,
[int]$PollMs = 250
)
$sw = [System.Diagnostics.Stopwatch]::StartNew()
while ($sw.Elapsed.TotalSeconds -lt $TimeoutSec) {
if (-not (Test-FileLocked -Path $Path)) { return $true }
Start-Sleep -Milliseconds $PollMs
}
return $false
}

# 指数バックオフ+ジッターでリトライ

function Invoke-WithSmartRetry {
param(
[Parameter(Mandatory)][scriptblock]$Action,
[int]$MaxRetry = 8,
[int]$BaseDelayMs = 300
)
$attempt = 0
while ($true) {
try {
& $Action
return
} catch {
$attempt++
if ($attempt -gt $MaxRetry) { throw }
if (-not (Test-IsSharingViolation -Exception $_.Exception)) { throw } # 共有違反以外は即時中断
$jitter = Get-Random -Minimum 0 -Maximum $BaseDelayMs
$delay = [math]::Min(5000, [math]::Pow(2, $attempt) * $BaseDelayMs) + $jitter
Start-Sleep -Milliseconds $delay
}
}
} 

Copy/Move/Remove を安全にするラッパー

function Copy-ItemSafe {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$Path,
        [Parameter(Mandatory)][string]$Destination,
        [switch]$VerifyHash,
        [int]$TimeoutSec = 90
    )


# ソースのアンロック待ち
if (Test-Path -LiteralPath $Path -PathType Leaf) {
    if (-not (Wait-UntilUnlocked -Path $Path -TimeoutSec $TimeoutSec)) {
        throw "ソースがロック解除されませんでした: $Path"
    }
}

# 宛先ディレクトリ作成
$destDir = Split-Path -LiteralPath $Destination -Parent
if ($destDir -and -not (Test-Path -LiteralPath $destDir)) {
    New-Item -ItemType Directory -Path $destDir -Force | Out-Null
}

Invoke-WithSmartRetry { 
    Copy-Item -LiteralPath $Path -Destination $Destination -Force -ErrorAction Stop 
}

# 検証
if (Test-Path -LiteralPath $Path -PathType Leaf -and (Test-Path -LiteralPath $Destination -PathType Leaf)) {
    $src = Get-Item -LiteralPath $Path
    $dst = Get-Item -LiteralPath $Destination
    if ($src.Length -ne $dst.Length) {
        throw "コピー後のファイルサイズが一致しません: $Path → $Destination"
    }
    if ($VerifyHash) {
        $h1 = (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash
        $h2 = (Get-FileHash -LiteralPath $Destination -Algorithm SHA256).Hash
        if ($h1 -ne $h2) { throw "ハッシュ不一致: $Path → $Destination" }
    }
}


}

function Move-ItemSafe {
[CmdletBinding()]
param(
[Parameter(Mandatory)][string]$Path,
[Parameter(Mandatory)][string]$Destination,
[int]$TimeoutSec = 90
)


if (Test-Path -LiteralPath $Path -PathType Leaf) {
    if (-not (Wait-UntilUnlocked -Path $Path -TimeoutSec $TimeoutSec)) {
        throw "ソースがロック解除されませんでした: $Path"
    }
}

Invoke-WithSmartRetry { 
    Move-Item -LiteralPath $Path -Destination $Destination -Force -ErrorAction Stop 
}


}

function Remove-ItemSafe {
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory)][string]$Path,
[int]$TimeoutSec = 90,
[switch]$Recurse
)


if (Test-Path -LiteralPath $Path -PathType Leaf) {
    if (-not (Wait-UntilUnlocked -Path $Path -TimeoutSec $TimeoutSec)) {
        throw "ロック解除されず削除できませんでした: $Path"
    }
}
Invoke-WithSmartRetry {
    Remove-Item -LiteralPath $Path -Force -ErrorAction Stop -Recurse:$Recurse
}


} 

Compress-Archive を堅牢化するラッパー

Compress-Archive は環境によっては失敗時に例外を投げないケースがありえます。そこで、入力のアンロック待ち・一時ファイル生成・ZIP が開けるかまで検証する関数を用意します。

function Test-ZipOpenable {
    param([Parameter(Mandatory)][string]$Path)
    try {
        $fs  = [System.IO.File]::Open($Path, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::Read)
        $zip = New-Object System.IO.Compression.ZipArchive($fs)
        $null = $zip.Entries.Count
        $zip.Dispose()
        $fs.Close()
        return $true
    } catch {
        return $false
    }
}

function Compress-ArchiveSafe {
[CmdletBinding()]
param(
[Parameter(Mandatory)][string[]]$Path,
[Parameter(Mandatory)][string]$DestinationPath,
[ValidateSet('Optimal','Fastest','NoCompression')]
[string]$CompressionLevel = 'Optimal',
[int]$TimeoutSec = 90
)


# 入力が全てアンロックされるのを待つ
foreach ($p in $Path) {
    if (Test-Path -LiteralPath $p -PathType Leaf) {
        if (-not (Wait-UntilUnlocked -Path $p -TimeoutSec $TimeoutSec)) {
            throw "入力のロック解除待ちをタイムアウト: $p"
        }
    }
}

# 一時 ZIP で作って最後にアトミックリネーム
$dstDir = Split-Path -LiteralPath $DestinationPath -Parent
if ($dstDir -and -not (Test-Path -LiteralPath $dstDir)) {
    New-Item -ItemType Directory -Path $dstDir -Force | Out-Null
}
$temp = Join-Path $dstDir ("{0}.partial.{1}.zip" -f [IO.Path]::GetFileNameWithoutExtension($DestinationPath), [guid]::NewGuid().ToString("N"))

Invoke-WithSmartRetry {
    if (Test-Path -LiteralPath $temp) { Remove-Item -LiteralPath $temp -Force -ErrorAction SilentlyContinue }
    Compress-Archive -Path $Path -DestinationPath $temp -CompressionLevel $CompressionLevel -ErrorAction Stop
}

# 成功検証
if (-not (Test-Path -LiteralPath $temp) -or -not (Test-ZipOpenable -Path $temp)) {
    throw "ZIP の作成検証に失敗しました: $temp"
}

# 既存 ZIP 退避(必要なら)
if (Test-Path -LiteralPath $DestinationPath) {
    $bak = "$DestinationPath.bak"
    Remove-Item -LiteralPath $bak -Force -ErrorAction SilentlyContinue
    Move-Item -LiteralPath $DestinationPath -Destination $bak -Force
}

Move-Item -LiteralPath $temp -Destination $DestinationPath -Force


} 

1 行置換の具体例

# Before
# Copy-Item -Path $src -Destination $dst -Force

# After

Copy-ItemSafe -Path $src -Destination $dst -VerifyHash

# Before

# Compress-Archive -Path $srcs -DestinationPath $zip

# After

Compress-ArchiveSafe -Path $srcs -DestinationPath $zip 

ラッパーを超える「設計で避ける」パターン

リトライは最後の砦です。根本的には「Defender のスキャンとバッチの I/O を衝突させない」設計が効きます。

  • ステージング → リネーム(同一ボリューム内):作業は D:\Work\staging\* で行い、完成後に Rename-Item で公開フォルダーへ移動。同一ボリューム内リネームはほぼアトミックで短時間。
  • 遅延公開:生成直後は .partial 拡張子、検証完了後に正式名へ。
  • 一括圧縮の直前ウォームアップ:対象を先に Get-Content 等で軽く読む(小さな I/O)→ Defender が先にスキャンを終える可能性。
  • ジョブ分散:同時に大量のファイルを作らない。並列度を制御。
  • プロセス交代:コピーは OS 純正の robocopy を使い、ビルトインの再試行を活用。

robocopy を使った堅牢コピー

robocopy は共有違反に強く、/R(リトライ回数)と /W(待機秒)で制御できます。戻り値は 0 が「何もコピーしなかった」、1 が「正常にコピーあり」である点に注意。

$args = @($src, $dst, '/COPY:DAT', '/R:8', '/W:2', '/NFL', '/NDL', '/NP', '/NJH', '/NJS')
$p = Start-Process -FilePath robocopy.exe -ArgumentList $args -Wait -PassThru -WindowStyle Hidden
if ($p.ExitCode -gt 7) { throw "robocopy 失敗: ExitCode=$($p.ExitCode)" }

Defender 側の対処:除外・集中管理・ポリシー

セキュリティと可用性のバランス上、全面停止は原則として非推奨です。現実的には「最小限の除外」を組み合わせます。

除外の種類とポイント

除外種類例利点リスク/注意おすすめ度
パス(フォルダー/ファイル)D:\Batch\Staging影響範囲を限定しやすいそのフォルダー配下の検知が弱まる高(まずはここから)
プロセスrobocopy.exe などI/O を発生させるツール単位で除外powershell.exe 除外は広すぎ危険中(対象を厳選)
拡張子.tmp, .partial一時拡張子を除外しやすい攻撃者の悪用に注意低〜中(環境次第)

PowerShell でのローカル設定例

# 管理者として実行
Add-MpPreference -ExclusionPath "D:\Batch\Staging"
Add-MpPreference -ExclusionPath "D:\Batch\Temp"
Add-MpPreference -ExclusionProcess "robocopy.exe"
# 状態確認
Get-MpPreference | Select-Object ExclusionPath, ExclusionProcess

組織内ではグループポリシー(GPO)や Microsoft Intune から集中管理します。GPO では「Microsoft Defender Antivirus > 除外」に関するポリシー、Intune ではエンドポイントセキュリティの「アンチウイルス」プロファイル等を利用します。除外内容は情報システム部門と必ず合意し、監査証跡を残しましょう。

やってはいけない対処

  • リアルタイム保護の恒久無効化:短期的には成功してもリスクが極大。
  • 広範なプロセス除外(例:powershell.exe/cmd.exe):攻撃に直結します。
  • 拡張子の無差別除外:ZIP や EXE のような高リスク拡張子の除外は原則禁止。

運用ベストプラクティス

  • タイムアウトを必ず設定:無限待機はデッドロックの温床。関数はすべて秒数上限付きに。
  • ログ出力の標準化:共有違反の再試行回数、最終的な成功/失敗を必ず記録。
  • 終了時のサマリ通知:失敗ファイルの一覧と原因(共有違反/その他)を朝の通知へ。
  • 夜間の I/O ピークを避ける:ジョブ開始時刻をずらし、並列数を制御。
  • 検証を先に設計:ZIP は開けるか、コピーはサイズ/ハッシュ一致かを標準化。
  • ステージング+アトミック公開:.partial → 正式名リネームの運用ルール化。

ログ・監視の具体例

# Defender の運用ログを収集(直近)
Get-WinEvent -LogName "Microsoft-Windows-Windows Defender/Operational" -MaxEvents 100 |
  Select-Object TimeCreated, Id, LevelDisplayName, Message

# PowerShell 側のトランスクリプト

$log = "D:\Batch\Logs$(Get-Date -Format yyyyMMdd_HHmmss).log"
Start-Transcript -Path $log

# ... バッチ処理 ...

Stop-Transcript 

失敗パターン別・最短の「回避レシピ」

目的最短レシピ備考
コピーの安定化Copy-ItemSafe に置換、もしくは robocopy /R:8 /W:2サイズ/ハッシュ検証を推奨
圧縮の安定化Compress-ArchiveSafe(一時 ZIP → 検証 → リネーム)黙示失敗を確実に検出
削除の安定化Remove-ItemSafe(アンロック待ち+再試行)存在確認で完了保証
夜間の集中失敗ステージング設計+ジョブ分散+除外フォルダー根本的な衝突を減らす

チェックリスト:本番前にここまでやれば安全

  • 作業ディレクトリは D:\Batch\Staging など固定し、不要に広い場所を使わない。
  • 出力ファイルは .partial で生成し、検証後に正式名へ Move-Item。
  • コピー・圧縮・削除は *Safe 系関数に統一し、リトライと検証を内包。
  • robocopy 等の OS 標準ツールを適材適所で併用。
  • Defender の除外はパス中心で最小限、変更はチケット化し監査可能に。
  • ログは「再試行回数/最終判定/処理時間」を必ず残す。
  • 失敗サマリを通知(Teams/メール等)し、翌朝の一次対応を高速化。

FAQ:よくある疑問と回答

Q. PowerShell に「ロックされたら解放まで待ってくれる」グローバル設定はありますか?

A. ありません。各コマンドレットの設計が異なるため、統一的な自動待機は提供されていません。スクリプト側にリトライ処理を実装してください。

Q. 全スクリプトを書き換えるのは大変です。簡単な回避策は?

A. 本稿の Invoke-WithRetry もしくは Invoke-WithSmartRetry と *Safe 関数を読み込み、既存の 1 行を関数呼び出しに置き換える方式が最小変更です。コピーは robocopy に置換するのも効果的です。

Q. Defender 側で完全に解決できますか?

A. 除外設定で衝突は大幅に減らせますが、ゼロにはできません。セキュリティとのトレードオフがあるため、「最小限の除外」+「スクリプト側の堅牢化」+「設計で避ける」の三位一体が現実解です。

将来的な改善提案

  • ファイルロック時の自動リトライ API:PowerShell/Win32 API レベルでの Wait-on-Share 機構の公式提供。
  • スキャン完了イベントのフック:Defender が「特定パスのスキャン完了」を通知し、バッチが待ち合わせできる仕組み。
  • 操作別の推奨リトライポリシーの標準化:Copy/Move/Compress での推奨値(回数・待機)を公式ドキュメント化。

導入・運用のテンプレート(モジュール化)

以下を Batch.IO.psm1 として保存し、ジョブの冒頭で Import-Module すれば横展開が容易です。

# Batch.IO.psm1(抜粋)
# ここまでで定義した Invoke-WithSmartRetry / Test-IsSharingViolation /
# Test-FileLocked / Wait-UntilUnlocked / Copy-ItemSafe / Move-ItemSafe /
# Remove-ItemSafe / Test-ZipOpenable / Compress-ArchiveSafe をまとめて配置。

# ログユーティリティ(任意)

function Write-OpLog {
param([string]$Message, [ValidateSet('INFO','WARN','ERROR')][string]$Level='INFO')
$ts = Get-Date -Format 'yyyy-MM-dd HH:mm:ss.fff'
"$ts [$Level] $Message" | Tee-Object -FilePath "D:\Batch\Logs\op.log" -Append
} 

まとめ

Defender の一時ロックは「バグ」ではなく、設計上の正しい挙動です。だからこそ、(1)スクリプトで賢く待つ、(2)設計で衝突を避ける、(3)Defender を最小限にチューニングする の 3 層で向き合うのが再発を防ぐ王道です。本稿のラッパー関数とチェックリストをそのまま導入すれば、作業時間を伸ばさずに失敗確率だけを確実に落とせます。まずは作業用フォルダーの除外と、*Safe 関数の 1 行置換から始めましょう。

この記事を書いた人

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

コメント

コメントする

目次