長時間バッチは「壊れないように」ではなく「壊れたら直る」ように作る
RubyでSeleniumを使い、何十分・何時間も同じブラウザセッションを使い回して巡回処理を続けるバッチを書いていると、途中で必ず何かが起きます。ページのJavaScript実行が突然応答しなくなったり、ブラウザプロセスが固まったり、タブがメモリを食い潰して落ちたり。原因はそのときどきで違うのに、症状だけは「なんとなく不安定」という形で現れるので、個別の例外を一つずつ潰していくアプローチにはどうしても限界があります。
そこで発想を変え、「壊れたら回復させる」ことを前提に全体を設計するようにしました。壊れる原因を完全に排除するのではなく、壊れたことを検知して、決まった手順で立て直す仕組みを最初から組み込んでおく、という考え方です。ここでは実際に効果があった3つのパターンを紹介します。
1. 稼働時間に上限を決めて、自分から終了する
無限ループで動かすバッチは、動かし続けるほど内部状態が劣化していきます。ブラウザ内部のキャッシュやメモリ、Rubyプロセス側で溜まったオブジェクトなど、原因を一つひとつ特定するよりも、一定時間で自分から終了させて、外側のスケジューラに再起動を任せてしまうほうがシンプルで効果的です。
def exit_after_time_limit(start_time, limit_minutes: 60)
elapsed = Time.now - start_time
return unless elapsed >= limit_minutes * 60
puts "#{limit_minutes}分経過したため終了します。"
sleep 3
exit
end
メインループの各周回でこの関数を呼ぶだけです。プロセスを丸ごと終了させれば、蓄積したメモリやハンドルもまとめて解放されます。タスクスケジューラ側で「終了したら数分後に再起動する」という運用にしておけば、内部でどれだけ状態が汚れても、一定間隔で必ずリセットがかかる仕組みになります。
2. ドライバプロセスをkillしてから再起動する
ブラウザが応答しなくなったとき、driver.quitを呼ぶだけでは古いドライバプロセスが残ってしまうことがあります。特にWindows環境では、実行ファイルがロックされたままだと次のドライバ起動が失敗しやすいので、まずプロセスを強制終了してから作り直すようにしました。
def restart_driver_with_retry(build_driver, max_attempts: 3)
max_attempts.times do |i|
begin
if RbConfig::CONFIG['host_os'] =~ /mswin|mingw|cygwin/
system('taskkill /IM chromedriver.exe /F > nul 2>&1')
else
system('pkill -f chromedriver')
end
sleep 2
driver = build_driver.call
return driver
rescue => e
warn "再起動失敗 (#{i + 1}/#{max_attempts}): #{e.message}"
sleep 3
end
end
warn "#{max_attempts}回試しても復旧できません。終了します。"
exit(1)
end
build_driverにはドライバ生成処理を-> { Selenium::WebDriver.for(:chrome, options: options) }のようなラムダで渡します。ポイントは「まずプロセスをkillしてから作り直す」という順番を固定していることです。生きているかもしれない古いプロセスを放置したまま新しいドライバを立てようとすると、ポートの競合やファイルロックが原因で、再現性のない失敗を踏みがちになります。
3. JS実行エラーを「メモリが詰まったサイン」として扱う
execute_scriptがdisconnectedやtarget closedのようなエラーを返すようになったら、それは単発の通信エラーというより、ページを長時間開きっぱなしにしたことでメモリが溜まり、レンダラー側が不安定になっているサインであることが多いです。単純にリトライするだけでなく、表示領域外にある大きなDOM要素を間引いてガベージコレクションを促す処理を挟むと、復帰しやすくなります。
def safe_execute_script(driver, script, *args, max_attempts: 3)
attempts = 0
begin
driver.execute_script(script, *args)
rescue => e
attempts += 1
message = e.message.to_s
unstable = message.include?('disconnected') ||
message.include?('target closed') ||
message.include?('cannot deserialize') ||
message.include?('no such execution context')
raise e unless unstable
return nil if attempts > max_attempts
sleep(attempts * 1.0)
retry
end
end
def reclaim_browser_memory(driver)
# 表示領域外にある大きな要素を間引いてDOMツリーを軽くする
driver.execute_script(<<~JS)
document.querySelectorAll('.item, .card, .list-row').forEach((node, index) => {
const rect = node.getBoundingClientRect();
const offscreen = rect.bottom < -2000 || rect.top > window.innerHeight + 4000;
if (offscreen && index % 2 === 0) node.remove();
});
if (window.gc) window.gc();
JS
rescue => e
warn "メモリ解放処理でエラー(継続): #{e.message}"
end
.itemや.cardのようなクラス名は対象ページごとに変わるので、実際に使うときは巡回先で繰り返し出現する要素のセレクタに置き換えてください。表示領域から大きく外れた要素だけを間引くのがコツで、直近で見た範囲まで消してしまうとページの見た目やイベントハンドラを壊す恐れがあります。無限スクロール型のページを長時間巡回すると、DOMノードが際限なく積み上がっていくことが多いので、定期的にこの間引きを挟むだけで、JS実行エラーの再発頻度が目に見えて下がりました。
どこまで自動で回復させるか、というバランス
3つのパターンはいずれも「異常を検知してから、決まった手順で立て直す」という点で共通していますが、どこまでを自動回復に任せ、どこからを異常終了として人の目に上げるかは設計判断が分かれます。稼働時間の上限やドライバの再起動は、失敗してもやり直しのコストが小さいので機械的に回して問題ありません。一方でJS実行エラーのように、根本原因がページ側の想定外の変更である可能性もある処理は、リトライの上限を超えたら潜在的な問題として記録し、無限にリトライし続けない設計にしておくことが大切です。回復処理そのものが無限ループの一部になってしまうと、壊れていることに誰も気づけないまま延々と空回りするバッチが出来上がってしまいます。
まとめ
- 長時間バッチは「壊れないように作る」より「壊れたら回復する」前提で設計する方が現実的
- 稼働時間の上限を決めて自分で終了させれば、外側のスケジューラに再起動を任せられる
- ドライバプロセスは古いものを確実にkillしてから作り直す。順番を固定するだけで再現性のない失敗が減る
- JS実行エラーの連続は、単なる通信エラーというよりメモリ肥大のサインとして扱い、DOM間引きとGC促進をセットで行うと復帰しやすい
- 回復処理には必ずリトライ上限を設け、機械的に直せるものと人の目に上げるべきものを分けておく


コメント