Rubyスクリプトの外部コマンド呼び出しが本番で予期しない遅延を起こす理由──プロセス管理とタイムアウト設計

現場で「なぜか遅い」が起きる場面

定期実行されるRubyスクリプトで、ImageMagickやFFmpeg、あるいは独自の外部ツールを呼び出している場合、開発環境では問題なく動作しているのに、本番環境に上げると突然応答が遅くなる、あるいは周辺のプロセスまで巻き込んで遅延が広がる──こういう経験は多くのエンジニアが持っています。

問題は往々にして、外部コマンドの呼び出し自体ではなく、呼び出したプロセスがどう終了するか、終了しなかった場合にどうなるか、システムリソースがどう圧迫されるか という、運用時に初めて顔を出す層にあります。

Rubyで外部コマンドを呼ぶときの「見えない待機」

Rubyで外部コマンドを実行する一般的な方法は system, exec, backtick(バッククォート), Open3 などですが、これらはいずれもプロセス生成と終了待機をセットで行います。

# よく見かける書き方
result = `convert input.jpg -resize 100x100 output.jpg`

このコードは、シェルを経由してImageMagickの convert コマンドを実行し、そのプロセスが完全に終了するまで、Rubyプロセスはブロック(待機)します。開発環境で数ファイル処理する分には目立ちませんが、本番環境で数百件のバッチ処理が走ると、以下のような連鎖が起きます。

  1. 各コマンド実行ごとに数秒~数十秒の待機が発生
  2. 複数のRubyプロセスが同時に起動された場合、全プロセスが同じリソースを奪い合う
  3. メモリやディスクI/Oが飽和し、システム全体が重くなる
  4. その結果、個別のコマンド実行時間も延びる(悪循環)

開発環境では単一プロセスで実行するため、こうした競合が起きにくく、問題が隠れたままになります。

タイムアウト設計がないことの危険性

さらに厄介なのは、呼び出したコマンドが何らかの理由でハング(応答停止)した場合、Rubyプロセスは永遠に待機し続ける という点です。

例えば、ネットワーク経由でリソースにアクセスするコマンドが通信障害に遭った場合、あるいは入力ファイルが破損していて処理が進まない場合、タイムアウト機構がなければそのプロセスはゾンビのように残ります。

本番環境では複数のスクリプトやジョブが並行実行されることが多いため、こうした「応答しないプロセス」が積み重なると、やがてシステム全体のプロセス上限に達し、新しいジョブが起動できなくなります。

実装の現実的な対策

1. タイムアウト付きの呼び出し

Open3 を使い、明示的にタイムアウトを設定するのが最も確実です。

require 'open3'
require 'timeout'

def run_command_with_timeout(command, timeout_sec = 30)
  begin
    Timeout.timeout(timeout_sec) do
      stdout, stderr, status = Open3.capture3(command)
      
      if status.success?
        { success: true, output: stdout }
      else
        { success: false, error: stderr, exit_code: status.exitstatus }
      end
    end
  rescue Timeout::Error
    { success: false, error: "Command timed out after #{timeout_sec}s" }
  rescue => e
    { success: false, error: e.message }
  end
end

# 使用例
result = run_command_with_timeout("convert input.jpg -resize 100x100 output.jpg", 60)
unless result[:success]
  Rails.logger.warn("Command failed: #{result[:error]}")
  # エラーハンドリング
end

重要なのは、タイムアウト値を単なる「理論上の最大実行時間」ではなく、本番環境での実測値に基づいて設定する ことです。開発環境で5秒で終わるコマンドでも、本番環境のディスク負荷が高い時間帯では30秒かかることは珍しくありません。

2. プロセス数の制限

複数のコマンド実行が並行する場合、キューイング機構を入れてプロセス数を制限します。

require 'thread'

class CommandQueue
  def initialize(max_workers = 3)
    @queue = Queue.new
    @max_workers = max_workers
    @workers = []
  end

  def start
    @max_workers.times do
      @workers << Thread.new do
        while job = @queue.pop
          break if job == :stop
          execute_job(job)
        end
      end
    end
  end

  def add_job(command, timeout = 30)
    @queue.push({ command: command, timeout: timeout })
  end

  def shutdown
    @max_workers.times { @queue.push(:stop) }
    @workers.each(&:join)
  end

  private

  def execute_job(job)
    result = run_command_with_timeout(job[:command], job[:timeout])
    # ログ出力やエラーハンドリング
  end
end

# 使用例
queue = CommandQueue.new(3)  # 同時実行数を3に制限
queue.start

1000.times do |i|
  queue.add_job("process_file_#{i}.jpg")
end

queue.shutdown

このアプローチにより、システムリソースの圧迫を防ぎ、個別コマンドの実行時間を予測可能にできます。

3. ログと監視の組み込み

外部コマンド呼び出しは本番環境でのトラブル調査が難しいため、実行時間、終了コード、標準エラー出力を必ずログに記録します。

def run_command_with_logging(command, timeout_sec = 30)
  start_time = Time.now
  
  result = run_command_with_timeout(command, timeout_sec)
  elapsed = Time.now - start_time
  
  log_entry = {
    command: command,
    success: result[:success],
    elapsed_ms: (elapsed * 1000).round,
    timeout_sec: timeout_sec,
    error: result[:error]
  }
  
  Rails.logger.info("External command executed: #{log_entry.to_json}")
  
  result
end

こうすることで、後から「あの時間帯になぜ遅延が起きたのか」を追跡できます。

落とし穴と回避策

シェル経由の実行による予期しない動作

system やバッククォートでコマンド文字列を渡すと、シェルが解釈します。これにより、ファイル名にスペースが含まれている場合の失敗、環境変数の展開による予期しない挙動などが発生します。

# 危険:ファイル名にスペースがあると失敗する可能性
`convert "my file.jpg" output.jpg`

# 安全:配列で渡すとシェルを経由しない
Open3.capture3("convert", "my file.jpg", "output.jpg")

子プロセスの孤立化

Rubyプロセスがタイムアウトや例外で終了した場合、起動した子プロセスが孤立することがあります。特にバックグラウンドで実行するコマンドの場合、親プロセスが終了しても子が残り、システムリソースを消費し続けます。

Open3 を使う場合、プロセスオブジェクトの参照を保持し、必要に応じて明示的に kill することが大切です。

環境変数の不一致

開発環境では特定の環境変数が設定されているが、本番環境では設定されていない、というケースがあります。特にPATHやLD_LIBRARY_PATHの不一致は、コマンドが「見つからない」という形で現れます。

本番環境では、外部コマンドの絶対パスを指定するか、起動スクリプトで環境変数を明示的に設定するほうが安全です。

設計判断のポイント

外部コマンド呼び出しを含む機能を設計する際は、以下を最初から考慮します。

  • 単発実行か、繰り返し実行か:繰り返し実行の場合はキューイング機構が必須
  • 実行時間の見積もり:本番環境での実測値を基に、タイムアウト値を決定
  • 失敗時の動作:タイムアウトやエラーが発生した場合、リトライするか、スキップするか、アラートを出すか
  • リソース制約:メモリ、ディスクI/O、CPU、プロセス数の上限を把握し、それに応じた並行度を決定

小規模なスクリプトでも、本番環境に上げる前に必ずこれらを検討することが、後の保守コストを大きく減らします。