Last active
March 14, 2025 09:22
-
-
Save MakarovCode/2f9493683d8256f31523c80ff6e8e0f0 to your computer and use it in GitHub Desktop.
Heroku dynos autoscaler with redis and sidekiq
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
# app/controllers/application_controller.rb | |
# Base controller for a Rails app integrated with Heroku Autoscaler. | |
# Tracks web response times and stores them in Redis for autoscaling decisions. | |
# Usage: Works with Heroku::Autoscaler to scale the 'web' dyno based on average response time. | |
# Requirements: 'redis' gem, REDIS_URL env variable set (e.g., via Heroku Redis add-on). | |
require 'json' | |
class ApplicationController < ActionController::Base | |
# Hook to measure response time for every action in the app | |
around_action :track_web_load | |
# Example authentication method to set the current user based on an auth token | |
# Not directly related to autoscaling, but included as a typical controller feature | |
def auth_user | |
@current_user = User.find_by_token params[:auth_token] | |
end | |
private | |
# Tracks the time taken to process each request and updates a rolling list of response times | |
# Stored in Redis as "web:last_response_times" for use by Heroku::Autoscaler | |
def track_web_load | |
# Record the start time of the request | |
start_time = Time.now | |
# Execute the action (controller logic) | |
yield | |
# Calculate duration in milliseconds (multiply by 1000 for ms from seconds) | |
duration = (Time.now - start_time) * 1000.0 | |
# Fetch the current list of recent response times from Redis | |
last_responses_times = redis.get("web:last_responses_times") | |
if last_responses_times.nil? | |
# Initialize an empty array if no data exists yet | |
last_responses_times = [] | |
else | |
# Parse the JSON string from Redis into a Ruby array | |
last_responses_times = JSON.parse(last_responses_times) | |
end | |
# Calculate the average response time (avoid division by zero) | |
avr_response_time = last_responses_times.sum / (last_responses_times.size.zero? ? 1 : last_responses_times.size) | |
# Only track significant response times (e.g., >= 100ms) to filter out noise | |
last_responses_times.push(duration) if duration >= 100 | |
# Keep the list at 10 elements max by removing the oldest (first) entry | |
last_responses_times.shift if last_responses_times.size > 10 | |
# Debug output to logs for monitoring (visible in `heroku logs`) | |
puts "=========> LAST DURATION <==============" | |
puts "=========> #{duration}ms <==============" | |
puts "=========> LAST DURATION <==============" | |
puts "#{last_responses_times.inspect}" # Show the current list | |
puts "=========> AVG DURATION <==============" | |
puts "=========> #{avr_response_time}ms <==============" | |
puts "=========> AVG DURATION <==============" | |
# Store the updated list in Redis atomically | |
redis.multi do |multi| | |
# Serialize the array to JSON before saving (Redis stores strings) | |
multi.set("web:last_responses_times", last_responses_times.to_json) | |
end | |
end | |
# Lazy-loaded Redis client instance | |
# Reuses the same connection across requests for efficiency | |
def redis | |
@redis ||= Redis.new(url: ENV['REDIS_URL'] || 'redis://localhost:6379/0') | |
end | |
end |
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
# Heroku Autoscaler Module | |
# Dynamically scales Heroku dynos for Rails with Sidekiq, factoring in concurrency and DB limits. | |
module Heroku | |
require 'sidekiq' # Sidekiq for queue monitoring | |
require 'platform-api' # Heroku API for dyno management | |
require 'redis' # Redis for web response times | |
class Autoscaler | |
attr_accessor :heroku # Heroku API client accessor | |
# Web response time thresholds (ms) | |
MIN_WEB_QUEUE_TIME = 1000 # Scale down if below this | |
MAX_WEB_QUEUE_TIME = 1000 # Scale up if above this | |
# Config from environment variables with defaults | |
HEROKU_APP_NAME = ENV["HEROKU_APP_NAME"] # App name | |
HEROKU_API_TOKEN = ENV["HEROKU_API_TOKEN"] # API token | |
MAX_DB_CONNECTIONS = ENV["MAX_DB_CONNECTIONS"]&.to_i || 40 # Max DB connections (default 40) | |
CONNECTIONS_PER_DYNO = ENV["CONNECTIONS_PER_DYNO"]&.to_i || 2 # Connections per dyno (default 2) | |
SIDEKIQ_CONCURRENCY = ENV["SIDEKIQ_CONCURRENCY"]&.to_i || 5 # Sidekiq threads per dyno (default 5) | |
# Process types with queue names and dyno limits | |
QUEUES = { | |
'web' => { queue: :web, min: 1, max: 3 }, # Web process (Rails) | |
'sidekiq_default' => { queue: 'default', min: 1, max: 3 }, # Sidekiq queues | |
'sidekiq_amaterasu' => { queue: 'amaterasu', min: 0, max: 2 }, | |
'sidekiq_rag' => { queue: 'rag', min: 0, max: 5 }, | |
'sidekiq_automations' => { queue: 'automations', min: 0, max: 1 }, | |
'sidekiq_twilio' => { queue: 'twilio', min: 1, max: 2 }, | |
'sidekiq_scrapping' => { queue: 'scrapping', min: 0, max: 1 } | |
} | |
# Initialize Heroku client | |
def initialize | |
@heroku = PlatformAPI.connect(HEROKU_API_TOKEN) | |
end | |
# Fetch current dyno count for a process | |
def current_dyno_count(process_type) | |
formation = @heroku.formation.info(HEROKU_APP_NAME, process_type) | |
formation['quantity'] || 0 # Default to 0 if no formation | |
rescue PlatformAPI::NotFound | |
0 # Return 0 if process doesn’t exist | |
end | |
# Update dyno count via Heroku API | |
def update_dyno_count(process_type, quantity) | |
@heroku.formation.update(HEROKU_APP_NAME, process_type, { quantity: quantity }) | |
puts "Updated #{process_type} to #{quantity} dynos" # Log update | |
rescue StandardError => e | |
puts "Error updating #{process_type}: #{e.message}" # Log errors | |
end | |
# Total DB connections across all dynos | |
def total_connections | |
QUEUES.sum { |process_type, _| current_dyno_count(process_type) * CONNECTIONS_PER_DYNO } | |
end | |
# Total dynos across all processes | |
def overall_dyno_count | |
QUEUES.sum { |process_type, _| current_dyno_count(process_type) } | |
end | |
# Count busy jobs in a Sidekiq queue | |
def busy_jobs_for_queue(queue_name) | |
busy_count = 0 | |
Sidekiq::Workers.new.each do |_, _, work| | |
busy_count += 1 if work['queue'] == queue_name # Count matching jobs | |
end | |
busy_count | |
end | |
# Average web response time from Redis | |
def web_last_response_time | |
last_responses_times = redis.get("web:last_responses_times") # Fetch from Redis | |
if last_responses_times.nil? | |
last_responses_times = [] # Empty if no data | |
else | |
last_responses_times = JSON.parse(last_responses_times) # Parse JSON | |
end | |
avg = last_responses_times.sum / (last_responses_times.size.zero? ? 1 : last_responses_times.size) # Avoid div by 0 | |
puts "Web avg response time: #{avg.round(2)}ms" # Log average | |
avg | |
end | |
# Main autoscaling logic | |
def autoscale | |
QUEUES.each do |process_type, config| | |
queue_name = config[:queue] # Queue name or :web | |
min_dynos = config[:min] # Min dynos | |
max_dynos = config[:max] # Max dynos | |
# Current dyno count | |
current_dynos = current_dyno_count(process_type) | |
puts "#{process_type} (queue: #{queue_name}) has #{current_dynos} dynos" | |
# Queue size (enqueued jobs) - Note: Errors for :web | |
queue = Sidekiq::Queue.new(queue_name) | |
enqueued_size = queue.size | |
puts "#{queue_name} has #{enqueued_size} enqueued jobs" | |
# Busy jobs | |
busy_size = busy_jobs_for_queue(queue_name) | |
puts "#{queue_name} has #{busy_size} busy jobs" | |
# Total workload | |
total_workload = enqueued_size + busy_size | |
puts "#{queue_name} total workload: #{total_workload}" | |
desired_dynos = min_dynos # Default to min | |
if queue_name == :web | |
# Web scaling based on response time | |
avg_response_time = web_last_response_time | |
desired_dynos = if avg_response_time > MAX_WEB_QUEUE_TIME | |
[current_dynos + 1, max_dynos].min # Scale up | |
elsif avg_response_time <= MIN_WEB_QUEUE_TIME && current_dynos > min_dynos | |
[current_dynos - 1, min_dynos].max # Scale down | |
else | |
current_dynos # No change | |
end | |
else | |
# Sidekiq scaling with concurrency | |
desired_dynos = if total_workload > current_dynos * SIDEKIQ_CONCURRENCY | |
[(total_workload.to_f / SIDEKIQ_CONCURRENCY).ceil, max_dynos].min # Scale up | |
elsif total_workload <= current_dynos * SIDEKIQ_CONCURRENCY && total_workload >= min_dynos | |
[(total_workload.to_f / SIDEKIQ_CONCURRENCY).ceil, max_dynos].min # Adjust to workload | |
else | |
min_dynos # Respect min | |
end | |
end | |
# DB connection check | |
total_conn = total_connections | |
overall_dynos = overall_dyno_count | |
process_used_connections = current_dynos * CONNECTIONS_PER_DYNO | |
new_conn = desired_dynos * CONNECTIONS_PER_DYNO # Desired connections | |
requested_connections = new_conn - process_used_connections # Additional connections | |
if requested_connections + total_conn > MAX_DB_CONNECTIONS | |
available_connections = MAX_DB_CONNECTIONS - total_conn | |
# Corrected line: Returns an integer, not an array, capping additional dynos | |
max_additional_dynos = [[0, (available_connections / CONNECTIONS_PER_DYNO).floor].min, [max_dynos - current_dynos, 0].max].min | |
if max_additional_dynos > 0 | |
adjusted_new_conn = process_used_connections + (max_additional_dynos * CONNECTIONS_PER_DYNO) | |
puts "Warning: Requested scale (#{new_conn}) for #{process_type} would exceed DB connection limit " \ | |
"(#{requested_connections + total_conn}/#{MAX_DB_CONNECTIONS}). " \ | |
"Scaling to maximum possible (#{adjusted_new_conn}) instead, considering overall dynos (#{overall_dynos})." | |
desired_dynos = (adjusted_new_conn / CONNECTIONS_PER_DYNO).floor # Adjust to fit | |
else | |
puts "Cannot scale #{process_type}: would exceed DB connection limit " \ | |
"(#{requested_connections + total_conn}/#{MAX_DB_CONNECTIONS}), " \ | |
"considering overall dynos (#{overall_dynos})." | |
next # Skip scaling | |
end | |
end | |
# Apply new dyno count | |
if desired_dynos != current_dynos | |
update_dyno_count(process_type, desired_dynos) | |
else | |
puts "#{process_type} already at desired count (#{desired_dynos})" | |
end | |
end | |
end | |
# Redis client for web response times | |
def redis | |
@redis ||= Redis.new(url: ENV['REDIS_URL'] || 'redis://localhost:6379/0') | |
end | |
end | |
end |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Description
The Heroku::Autoscaler and ApplicationController duo dynamically scales Rails apps on Heroku, adjusting Sidekiq queue dynos based on workload and the web dyno using Redis-stored response times (last 10 ≥100ms). It optimizes resources with min/max limits and DB connection caps, ensuring efficiency and responsiveness.
Tested with
redis (4.8.1)
sidekiq (7.2.4)
ruby (3.2.2)
rails (7.0.8.3)
Important
Be aware of ENV variables, queues names, min and max, web and default should be at min: 1