-
Notifications
You must be signed in to change notification settings - Fork 41
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add a script for cleaning passenger workers
- Loading branch information
Showing
1 changed file
with
221 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,221 @@ | ||
#!/usr/bin/env ruby | ||
# frozen_string_literal: true | ||
|
||
require 'open3' | ||
|
||
# Configuration Constants | ||
PASSENGER_STATUS_CMD = 'passenger-status' | ||
MIN_WORKERS_ALLOWED = 4 | ||
LAST_USED_THRESHOLD_SECONDS = 10 * 60 # 10 minutes | ||
|
||
# Class representing a single Passenger Worker Process | ||
class WorkerProcess | ||
attr_reader :pid, :sessions, :processed, :uptime_seconds, :cpu, :memory_mb, :last_used_seconds | ||
|
||
def initialize(pid:, sessions:, processed:, uptime_str:, cpu:, memory_str:, last_used_str:) | ||
@pid = pid | ||
@sessions = sessions | ||
@processed = processed | ||
@uptime_seconds = parse_time(uptime_str) | ||
@cpu = cpu | ||
@memory_mb = parse_memory(memory_str) | ||
@last_used_seconds = parse_time(last_used_str) | ||
end | ||
|
||
# Parses a time string like "16m 52s" into total seconds | ||
def parse_time(time_str) | ||
total_seconds = 0 | ||
# Match patterns like "16m", "52s", etc. | ||
time_str.scan(/(\d+)m|(\d+)s/) do |min, sec| | ||
total_seconds += min.to_i * 60 if min | ||
total_seconds += sec.to_i if sec | ||
end | ||
total_seconds | ||
end | ||
|
||
# Parses memory string like "184M", "1.2G", "512K" into integer megabytes | ||
def parse_memory(mem_str) | ||
match = mem_str.strip.match(/^([\d.]+)([KMGTP])?$/i) | ||
return 0 unless match | ||
|
||
value = match[1].to_f | ||
unit = match[2]&.upcase || 'M' | ||
|
||
case unit | ||
when 'K' | ||
(value / 1024).round(2) | ||
when 'M' | ||
value.round(2) | ||
when 'G' | ||
(value * 1024).round(2) | ||
when 'T' | ||
(value * 1024 * 1024).round(2) | ||
when 'P' | ||
(value * 1024 * 1024 * 1024).round(2) | ||
else | ||
value.round(2) | ||
end | ||
end | ||
|
||
def to_s | ||
"PID: #{@pid}, Last Used: #{@last_used_seconds}s, Memory: #{@memory_mb} MB" | ||
end | ||
end | ||
|
||
# Class responsible for executing and parsing passenger-status output | ||
class PassengerStatusParser | ||
attr_reader :total_processes, :workers | ||
|
||
def initialize(command: PASSENGER_STATUS_CMD) | ||
@command = command | ||
@total_processes = 0 | ||
@workers = [] | ||
end | ||
|
||
def execute | ||
stdout, stderr, status = Open3.capture3(@command) | ||
|
||
unless status.success? | ||
raise "Error executing #{@command}: #{stderr}" | ||
end | ||
|
||
parse(stdout) | ||
end | ||
|
||
private | ||
def parse(output) | ||
current_worker_data = {} | ||
in_app_group = false | ||
|
||
output.each_line do |line| | ||
line = line.strip | ||
|
||
# Capture total processes | ||
if line.start_with?('Processes :') | ||
@total_processes = line.split(':').last.strip.to_i | ||
end | ||
|
||
# Detect start of Application groups | ||
if line.start_with?('----------- Application groups -----------') | ||
in_app_group = true | ||
next | ||
end | ||
|
||
next unless in_app_group | ||
|
||
# Start of a worker entry | ||
if line.start_with?('* PID:') | ||
# Save previous worker if exists | ||
if current_worker_data.any? | ||
@workers << build_worker(current_worker_data) | ||
current_worker_data = {} | ||
end | ||
|
||
# Extract PID, Sessions, Processed, Uptime | ||
pid_match = line.match(/\* PID:\s*(\d+)\s+Sessions:\s*(\d+)\s+Processed:\s*(\d+)\s+Uptime:\s*([\dm\s]+s)/) | ||
if pid_match | ||
current_worker_data[:pid] = pid_match[1].to_i | ||
current_worker_data[:sessions] = pid_match[2].to_i | ||
current_worker_data[:processed] = pid_match[3].to_i | ||
current_worker_data[:uptime_str] = pid_match[4].strip | ||
end | ||
elsif line.start_with?('CPU:') | ||
# Extract CPU and Memory | ||
cpu_match = line.match(/CPU:\s*([\d.]+)%/) | ||
memory_match = line.match(/Memory\s*:\s*([\d.]+[KMGTP]?)/) | ||
current_worker_data[:cpu] = cpu_match[1].to_f if cpu_match | ||
current_worker_data[:memory_str] = memory_match[1] if memory_match | ||
elsif line.start_with?('Last used:') | ||
# Extract Last used | ||
last_used_match = line.match(/Last used:\s*([\dm\s]+s)\s*(?:ago|ag)?/) | ||
if last_used_match | ||
current_worker_data[:last_used_str] = last_used_match[1].strip | ||
end | ||
end | ||
end | ||
|
||
# Add the last worker if exists | ||
if current_worker_data.any? | ||
@workers << build_worker(current_worker_data) | ||
end | ||
end | ||
|
||
def build_worker(data) | ||
WorkerProcess.new( | ||
pid: data[:pid], | ||
sessions: data[:sessions], | ||
processed: data[:processed], | ||
uptime_str: data[:uptime_str], | ||
cpu: data[:cpu], | ||
memory_str: data[:memory_str], | ||
last_used_str: data[:last_used_str] | ||
) | ||
end | ||
end | ||
|
||
# Class responsible for managing Passenger Workers | ||
class PassengerWorkerManager | ||
def initialize(parser: PassengerStatusParser.new) | ||
@parser = parser | ||
end | ||
|
||
def run | ||
begin | ||
@parser.execute | ||
rescue => e | ||
puts e.message | ||
exit 1 | ||
end | ||
|
||
total_processes = @parser.total_processes | ||
workers = @parser.workers | ||
|
||
puts "Total Processes: #{total_processes}" | ||
puts "Total Workers: #{workers.size}" | ||
|
||
if total_processes > MIN_WORKERS_ALLOWED | ||
puts "Number of processes (#{total_processes}) exceeds the minimum allowed (#{MIN_WORKERS_ALLOWED})." | ||
|
||
worker_to_kill = find_worker_to_kill(workers) | ||
|
||
if worker_to_kill | ||
pid = worker_to_kill.pid | ||
last_used_seconds = worker_to_kill.last_used_seconds | ||
last_used_minutes = (last_used_seconds / 60).to_i | ||
last_used_seconds_remainder = last_used_seconds % 60 | ||
|
||
puts "Killing worker PID #{pid} with last used time of #{last_used_minutes}m #{last_used_seconds_remainder}s and memory: #{worker_to_kill.memory_mb} MB." | ||
|
||
kill_worker(pid) | ||
else | ||
puts "No workers have been idle for more than #{LAST_USED_THRESHOLD_SECONDS / 60} minutes." | ||
end | ||
else | ||
puts "Number of processes (#{total_processes}) is under (#{MIN_WORKERS_ALLOWED}). No action needed." | ||
end | ||
end | ||
|
||
private | ||
def find_worker_to_kill(workers) | ||
eligible_workers = workers.select { |w| w.last_used_seconds > LAST_USED_THRESHOLD_SECONDS } | ||
|
||
return nil if eligible_workers.empty? | ||
|
||
# Find the worker with the maximum last used time | ||
eligible_workers.max_by { |w| w.last_used_seconds } | ||
end | ||
|
||
def kill_worker(pid) | ||
Process.kill('TERM', pid) | ||
puts "Successfully sent TERM signal to PID #{pid}." | ||
rescue Errno::ESRCH | ||
puts "Process with PID #{pid} does not exist." | ||
rescue Errno::EPERM | ||
puts "Insufficient permissions to kill PID #{pid}." | ||
rescue => e | ||
puts "Failed to kill PID #{pid}: #{e.message}" | ||
end | ||
end | ||
|
||
manager = PassengerWorkerManager.new | ||
manager.run |