PowerShellとNode.jsの定期実行で「二重起動」を防ぐ:ロックファイル方式の実装
この記事で扱うこと
Windowsのタスクスケジューラに登録したNode.jsスクリプトが、前回の実行がまだ終わっていないタイミングで次の実行が始まってしまい、同じファイルに二重に書き込みが走って結果が壊れる——という問題への対処をまとめます。
原因ははっきりしていて、タスクスケジューラは「前の実行が終わっているか」を見ずに時刻だけでトリガーを発火させます。処理対象のデータ量が増えて1回の実行時間が延びると、次のトリガーと衝突する確率が上がります。対処自体は難しくなく、ロックファイルを使って多重実行を防ぐという定番のパターンで解決します。
環境
- Windows 11 24H2
- PowerShell 7.4(
pwsh) - Node.js v20.11.1
PowerShell 5.1(powershell.exe)でも構文はほぼ共通ですが、本記事のサンプルは pwsh での動作を前提にしています。
1. Node.js側:ロックファイルによる排他制御
まず、実行中かどうかを判定するロックモジュールを作ります。ポイントは2つで、(1) ロックファイルの作成に wx(存在したら失敗する)フラグを使うこと、(2) 異常終了でロックが残った「stale lock」を検出して自動的に解除することです。
// lock.js
const fs = require('fs');
const path = require('path');
function acquireLock(lockPath) {
try {
// 'wx' = 新規作成のみ。既にファイルがあれば EEXIST で失敗する
const fd = fs.openSync(lockPath, 'wx');
fs.writeSync(fd, String(process.pid));
fs.closeSync(fd);
return true;
} catch (err) {
if (err.code !== 'EEXIST') throw err;
// ロックファイルが残っている場合、記録されたPIDが
// 今も生きているプロセスかを確認する
const pid = Number(fs.readFileSync(lockPath, 'utf8').trim());
if (isProcessAlive(pid)) {
return false; // 本当に実行中 → 今回はスキップ
}
// stale lock(前回異常終了して残ったロック)と判断し、上書きして取得し直す
fs.writeFileSync(lockPath, String(process.pid));
return true;
}
}
function isProcessAlive(pid) {
if (!pid) return false;
try {
// signal 0 はプロセスを終了させずに存在確認だけ行う
process.kill(pid, 0);
return true;
} catch (err) {
return err.code === 'EPERM'; // 権限エラーなら存在はしている
}
}
function releaseLock(lockPath) {
if (fs.existsSync(lockPath)) fs.unlinkSync(lockPath);
}
module.exports = { acquireLock, releaseLock };
呼び出し側では、必ず finally でロックを解放します。ここを try の外や条件分岐の中に書くと、例外発生時にロックが残り続けて次回以降ずっとスキップされる事故につながります。
// task.js
const path = require('path');
const { acquireLock, releaseLock } = require('./lock');
const LOCK_PATH = path.join(__dirname, 'task.lock');
async function main() {
if (!acquireLock(LOCK_PATH)) {
console.log('前回の実行が継続中のため、今回はスキップします。');
process.exit(0);
}
try {
await runTask();
console.log('処理が完了しました。');
} catch (err) {
console.error('処理中にエラーが発生しました:', err.message);
process.exitCode = 1;
} finally {
releaseLock(LOCK_PATH);
}
}
async function runTask() {
// ここに実際の処理(ファイル集計・API呼び出し等)を書く
}
main();
2. PowerShell側:呼び出しラッパー
タスクスケジューラから直接 node task.js を叩くと、Node側の例外がスケジューラのログに残らず「実行はされたが結果が分からない」状態になりがちです。PowerShellでラップし、終了コードとログ出力を確実に残します。
# run-task.ps1
$ErrorActionPreference = 'Stop'
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$logPath = Join-Path $scriptDir 'task.log'
$nodeScript = Join-Path $scriptDir 'task.js'
$timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
"[$timestamp] start" | Out-File -FilePath $logPath -Append -Encoding utf8
try {
& node $nodeScript 2>&1 | Out-File -FilePath $logPath -Append -Encoding utf8
$exitCode = $LASTEXITCODE
}
catch {
"[$timestamp] PowerShell側で例外: $($_.Exception.Message)" |
Out-File -FilePath $logPath -Append -Encoding utf8
$exitCode = 1
}
$timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
"[$timestamp] end (exit=$exitCode)" | Out-File -FilePath $logPath -Append -Encoding utf8
exit $exitCode
タスクスケジューラの「操作」には pwsh.exe を指定し、引数に -NoProfile -ExecutionPolicy Bypass -File "C:\path\to\run-task.ps1" を渡す形で登録します。-NoProfile を付けないと、環境によってはプロファイルスクリプトの読み込みで起動が遅くなったり、想定外の設定が混ざったりすることがあるため明示しておくと安全です。
よくあるエラーと対処
-
EACCESでロックファイルの作成に失敗する
ロックファイルの配置先がOneDriveの同期対象フォルダなど、書き込みタイミングでロックが入るディレクトリだと発生することがあります。同期対象外のフォルダ(例:C:\ProgramData\<任意フォルダ>)に置くと解消します。 -
stale lock の判定がうまくいかない(
isProcessAliveが常に false を返す)
タスクマネージャーでプロセスをKillした直後などはPIDがすぐ別プロセスに再利用されることがあり得ます。厳密にやるなら、ロックファイルにPIDだけでなくプロセス開始時刻も記録し、isProcessAlive側でも突き合わせると誤判定を減らせます。今回のサンプルはPIDのみのシンプル版です。 -
タスクスケジューラ上は「実行中」のままフリーズする
Node側の非同期処理でawaitし忘れた箇所があると、Node自体は終了せずプロセスが残り続けます。process.exit()を明示するか、未解決のPromiseが残っていないかを先に確認してください。
まとめ
- タスクスケジューラは前回実行の完了を待たずに次のトリガーを発火させるため、処理時間が延びると二重起動が起こり得る
- Node.js側は
fs.openSync(path, 'wx')を使ったロックファイルで排他制御し、PIDで生死判定してstale lockを自動解除する - PowerShell側はラッパースクリプトで終了コードとログを確実に残し、タスクスケジューラの登録は
-NoProfile -ExecutionPolicy Bypassを明示する
同じ構成は、Node.jsに限らずPowerShellスクリプト単体を定期実行する場合にも応用できます(New-Item -ItemType File -ErrorAction Stop が wx フラグ相当の役割を果たします)。