夜間バッチや定期ジョブで「さっき作ったばかりのファイル」をすぐ移動・圧縮しようとしたら、なぜか失敗する――原因が Microsoft Defender のスキャンによる一時的なファイルロックだった、という相談が増えています。本稿では PowerShell から見える現象の整理、グローバル設定の可否、コード修正を最小化するラッパー実装、Defender 側の現実的な設定、そして運用ベストプラクティスまでを一気にまとめます。コピペで使える関数とチェックリスト付きです。
Defender による一時ロックで PowerShell スクリプトが失敗する問題の全体像
Microsoft Defender のリアルタイム保護は、生成・変更されたファイルを即時にスキャンします。スキャン中はファイルハンドルが占有され、他プロセス(あなたの PowerShell ジョブを含む)が 共有違反(ERROR_SHARING_VIOLATION / 32, 0x20) でアクセスできないことがあります。結果として、以下のようなコマンドレットが例外や黙示の失敗を起こします。
Copy-Item,Move-Item,Remove-ItemCompress-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 行置換から始めましょう。

コメント