sidekiq-heroku-autoscale 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/README.md +165 -0
- data/lib/sidekiq-heroku-autoscale.rb +2 -0
- data/lib/sidekiq/heroku_autoscale.rb +69 -0
- data/lib/sidekiq/heroku_autoscale/heroku_app.rb +144 -0
- data/lib/sidekiq/heroku_autoscale/middleware.rb +21 -0
- data/lib/sidekiq/heroku_autoscale/poll_interval.rb +34 -0
- data/lib/sidekiq/heroku_autoscale/process.rb +266 -0
- data/lib/sidekiq/heroku_autoscale/queue_system.rb +102 -0
- data/lib/sidekiq/heroku_autoscale/scale_strategy.rb +53 -0
- data/lib/sidekiq/heroku_autoscale/version.rb +7 -0
- data/lib/sidekiq/heroku_autoscale/web.rb +7 -0
- data/lib/sidekiq/heroku_autoscale/web/inactive.erb +4 -0
- data/lib/sidekiq/heroku_autoscale/web/index.erb +38 -0
- data/lib/sidekiq/heroku_autoscale/web/index.js +182 -0
- data/lib/sidekiq/heroku_autoscale/web_extension.rb +38 -0
- metadata +86 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 3e8ad2ab9e6bdd803cecdf5f0148c5ef5cc25549fca06387e89ed449ff5ec635
|
4
|
+
data.tar.gz: 8997e2e8461d6c08735aa51b7bc9099ee17ef7aa4a42fda33ad96d9105fa12dd
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: b1c57075a0d77844e06fe1d7940cdaa4243c74d9e9fbed2f1012583b9cf6dd41d72ac07c4bca032b0f814714315528450a18cca6f6332a09cb17a0c70cdc3b1f
|
7
|
+
data.tar.gz: d1911ced4e2fc584c334f920aec14b93778e0bbbc3871458c7020da9a55857a919fbe8817240437b5b58011f792de9d1a7158d38d4d8807c70bc5a6353c136a2
|
data/README.md
ADDED
@@ -0,0 +1,165 @@
|
|
1
|
+
# Sidekiq Heroku Autoscale plugin
|
2
|
+
|
3
|
+
This [Sidekiq](https://github.com/mperham/sidekiq) plugin allows Heroku dynos to be started, stopped, and scaled based on job workload. Why? Because running non-stop Sidekiq dynos on Heroku can rack up unnecessary costs for apps with modest background processing needs.
|
4
|
+
|
5
|
+
This is a self-acknowledged rewrite of the [autoscaler](https://github.com/JustinLove/autoscaler) project. While this plugin borrows many foundation concepts from _autoscaler_, it rewrites core operations to address several logistical concerns and enable reporting through a web UI.
|
6
|
+
|
7
|
+
![Web UI](web-preview.png)
|
8
|
+
|
9
|
+
Tested with Sidekiq 6, but should be compatible with other recent Sidekiq versions.
|
10
|
+
|
11
|
+
## How it works
|
12
|
+
|
13
|
+
This plugin operates by tapping into Sidekiq startup hooks and middleware.
|
14
|
+
|
15
|
+
- Whenever a server is started or a job is queued, the appropriate process manager is called on to adjust its scale. Adjustments are throttled across process instances (dynos) so that the Heroku API is only called once every N seconds – 10 by default.
|
16
|
+
|
17
|
+
- When workload demands more dynos, scale will adjust directly upward to target capacity.
|
18
|
+
|
19
|
+
- As workload diminishes, scale will adjust downward one dyno at a time. When downscaling a process, the highest numbered dyno (ex: `worker.1`, `worker.2`, etc...) will be quieted and then removed from the formation. This combines Heroku's [autoscaling logic](https://devcenter.heroku.com/articles/scaling#autoscaling-logic) with Sidekiq's [quieting strategy](https://github.com/mperham/sidekiq/wiki/Signals#tstp).
|
20
|
+
|
21
|
+
## Gem installation
|
22
|
+
|
23
|
+
```ruby
|
24
|
+
gem 'sidekiq'
|
25
|
+
gem 'sidekiq-heroku-autoscale'
|
26
|
+
```
|
27
|
+
|
28
|
+
If you're not using Rails, you'll need to require `sidekiq-heroku-autoscale` after `sidekiq`.
|
29
|
+
|
30
|
+
## Environment config
|
31
|
+
|
32
|
+
You'll need to generate a Heroku platform API token that enables your app to adjust its own dyno formation. This can be done through the Heroku CLI with:
|
33
|
+
|
34
|
+
```shell
|
35
|
+
heroku authorizations:create
|
36
|
+
```
|
37
|
+
|
38
|
+
Copy the `Token` value and add it along with your app's name as environment variables in your app:
|
39
|
+
|
40
|
+
```shell
|
41
|
+
SIDEKIQ_HEROKU_AUTOSCALE_API_TOKEN=<token>
|
42
|
+
SIDEKIQ_HEROKU_AUTOSCALE_APP=<app-name>
|
43
|
+
```
|
44
|
+
|
45
|
+
The Heroku Autoscaler plugin will automatically check for these two environment variables. You'll also find some setup suggestions in Sidekiq's [Heroku deployment](https://github.com/mperham/sidekiq/wiki/Deployment#heroku) docs. Specifically, you'll want to include the `-t 25` option in your Procfile's Sidekiq command to maximize process quietdown time:
|
46
|
+
|
47
|
+
```shell
|
48
|
+
web: bundle exec rails start
|
49
|
+
worker: bundle exec sidekiq -t 25
|
50
|
+
```
|
51
|
+
|
52
|
+
## Plugin config
|
53
|
+
|
54
|
+
Add a configuration file for the Heroku Autoscale plugin. YAML works well. A simple configuration with one `worker` process (named in your Procfile) that monitors all Sidekiq queues and starts/stops in the presence of work looks like this:
|
55
|
+
|
56
|
+
**config/sidekiq_heroku_autoscale.yml**
|
57
|
+
|
58
|
+
```yaml
|
59
|
+
app_name: test-app
|
60
|
+
processes:
|
61
|
+
worker:
|
62
|
+
system:
|
63
|
+
watch_queues: *
|
64
|
+
include_retrying: true
|
65
|
+
include_scheduled: false
|
66
|
+
scale:
|
67
|
+
mode: binary
|
68
|
+
max_dynos: 1
|
69
|
+
```
|
70
|
+
|
71
|
+
Then, add an initializer to hand your configuration off to the plugin:
|
72
|
+
|
73
|
+
**config/initializers/sidekiq.rb**
|
74
|
+
|
75
|
+
```ruby
|
76
|
+
config = YAML.load_file('<path/to/config.yml>')
|
77
|
+
Sidekiq::HerokuAutoscale.init(config)
|
78
|
+
```
|
79
|
+
|
80
|
+
A more advanced configuration with multiple process types that watch specific queues would look like this – where `first` and `second` are two Heroku process types:
|
81
|
+
|
82
|
+
```yaml
|
83
|
+
api_token: <optional - for dynamic insertion only!>
|
84
|
+
app_name: test-app
|
85
|
+
throttle: 20
|
86
|
+
history: 7200
|
87
|
+
processes:
|
88
|
+
first:
|
89
|
+
system:
|
90
|
+
watch_queues:
|
91
|
+
- default
|
92
|
+
- low
|
93
|
+
include_retrying: false
|
94
|
+
include_scheduled: false
|
95
|
+
scale:
|
96
|
+
mode: binary
|
97
|
+
max_dynos: 2
|
98
|
+
quiet_buffer: 15
|
99
|
+
|
100
|
+
second:
|
101
|
+
system:
|
102
|
+
watch_queues:
|
103
|
+
- high
|
104
|
+
include_retrying: false
|
105
|
+
include_scheduled: false
|
106
|
+
scale:
|
107
|
+
mode: linear
|
108
|
+
max_dynos: 5
|
109
|
+
workers_per_dyno: 20
|
110
|
+
min_factor: 1
|
111
|
+
```
|
112
|
+
|
113
|
+
**Config Options**
|
114
|
+
|
115
|
+
- `api_token:` optional, same as `SIDEKIQ_HEROKU_AUTOSCALE_API_TOKEN`. Always prefer the ENV variable, or dynamically insert this.
|
116
|
+
- `app_name:` optional, same as `SIDEKIQ_HEROKU_AUTOSCALE_APP`.
|
117
|
+
- `throttle:` number of seconds to throttle between scale adjustments. The default is 10, so the Heroku API will only be hit once every ten seconds regardless of how many time the process is pinged during that timeframe. This value also dictates the tick frequency on the web UI history graph.
|
118
|
+
- `history:` number of seconds to track history in the web UI. The default is 3600, or 1 hour. The history graph renders ticks using the history duration divided by throttle time – so 3600 seconds of history on a 10 second throttle produce 360 data points. Therefore, it's best to keep these settings in modest proportions to one another. You'll probably be sad if you try to display days or weeks of history.
|
119
|
+
- `processes:` a list of Heroku process types named in your Procfile. For example, `worker` or `sidekiq`.
|
120
|
+
- `process.system.watch_queues:` a list of Sidekiq queues to watch for work, or `*` for all queues. Queue names must be mutually exclusive to avoid collisions. That means a queue name may only appear once across all processes, and that `*` (all) may not be combined with other queue names.
|
121
|
+
- `process.system.include_retrying:` specifies if the Sidekiq retry set should be included while assessing workload. Watching retries may cause undesirable levels of uptime.
|
122
|
+
- `process.system.include_scheduled:` specifies if the Sidekiq scheduled set should be included while assessing workload. Watching scheduled jobs may cause undesirable levels of idle uptime. Also, no new jobs will be scheduled unless Sidekiq is running.
|
123
|
+
- `process.scale.mode:` accepts "binary" (on/off) or "linear" (scaled to workload).
|
124
|
+
- `process.scale.max_dynos:` maximum allowed concurrent dynos. In binary mode, this will be the fixed operating capacity. In linear mode, this will be the maximum extent that dynos may scale up to.
|
125
|
+
- `process.scale.workers_per_dyno:` Linear mode only. This specifies the anticipated workforce per dyno to calculate scale around. This should generally align with Sidekiq's `concurrency` setting.
|
126
|
+
- `process.quiet_buffer:` number of seconds to quiet a dyno (stopping it from taking on new work) before downscaling its process. This buffer occurs _before_ reducing the number of dynos for a given process type. After downscale, you may configure an [additional quietdown threshold](https://github.com/mperham/sidekiq/wiki/Deployment#heroku). Note: during this quiet buffer your formation includes a decomissioned dyno, which is awkward – thus no other scale adjustments (up or down) are allowed until the quieted dyno has been dropped. Be accordingly judicious with this buffer.
|
127
|
+
|
128
|
+
## Web UI
|
129
|
+
|
130
|
+
The web UI is an optional extension of Sidekiq's web UI. To activate it, just require `sidekiq/heroku_autoscale/web` after the base `sidekiq/web`, and then mount `Sidekiq::Web` as normal:
|
131
|
+
|
132
|
+
```ruby
|
133
|
+
require 'sidekiq/web'
|
134
|
+
require 'sidekiq/heroku_autoscale/web'
|
135
|
+
|
136
|
+
Rails.application.routes.draw do
|
137
|
+
mount Sidekiq::Web, at: '/sidekiq'
|
138
|
+
end
|
139
|
+
```
|
140
|
+
|
141
|
+
## Tests
|
142
|
+
|
143
|
+
Nothing fancy...
|
144
|
+
|
145
|
+
```bash
|
146
|
+
# start a redis server
|
147
|
+
redis-server test/redis_test.conf
|
148
|
+
|
149
|
+
# then run tests in another terminal window
|
150
|
+
bundle exec rake test
|
151
|
+
```
|
152
|
+
|
153
|
+
### Contributors
|
154
|
+
|
155
|
+
- Justin Love [@wondible](http://twitter.com/wondible), [https://github.com/JustinLove](https://github.com/JustinLove)
|
156
|
+
- Benjamin Kudria [https://github.com/bkudria](https://github.com/bkudria)
|
157
|
+
- claudiofullscreen [https://github.com/claudiofullscreen](https://github.com/claudiofullscreen)
|
158
|
+
- Fix Peña [https://github.com/fixr](https://github.com/fixr)
|
159
|
+
- Gabriel Givigier Guimarães [https://github.com/givigier](https://github.com/givigier)
|
160
|
+
- Matt Anderson [https://github.com/tonkapark](https://github.com/tonkapark)
|
161
|
+
- Thibaud Guillaume-Gentil [https://github.com/jilion](https://github.com/jilion)
|
162
|
+
|
163
|
+
## Licence
|
164
|
+
|
165
|
+
Sidekiq Heroku Autoscale plugin is released under the [MIT license](https://opensource.org/licenses/MIT).
|
@@ -0,0 +1,69 @@
|
|
1
|
+
require 'sidekiq/heroku_autoscale/heroku_app'
|
2
|
+
require 'sidekiq/heroku_autoscale/middleware'
|
3
|
+
require 'sidekiq/heroku_autoscale/poll_interval'
|
4
|
+
require 'sidekiq/heroku_autoscale/process'
|
5
|
+
require 'sidekiq/heroku_autoscale/queue_system'
|
6
|
+
require 'sidekiq/heroku_autoscale/scale_strategy'
|
7
|
+
require 'sidekiq/heroku_autoscale/version'
|
8
|
+
|
9
|
+
module Sidekiq
|
10
|
+
module HerokuAutoscale
|
11
|
+
|
12
|
+
class << self
|
13
|
+
def app
|
14
|
+
@app
|
15
|
+
end
|
16
|
+
|
17
|
+
def init(options)
|
18
|
+
options = options.transform_keys(&:to_sym)
|
19
|
+
@app = HerokuApp.new(options)
|
20
|
+
|
21
|
+
::Sidekiq.logger.warn('Heroku platform API is not configured for Sidekiq::HerokuAutoscale') unless @app.live?
|
22
|
+
|
23
|
+
# configure sidekiq queue server
|
24
|
+
::Sidekiq.configure_server do |config|
|
25
|
+
config.on(:startup) do
|
26
|
+
dyno_name = ENV['DYNO']
|
27
|
+
next unless dyno_name
|
28
|
+
|
29
|
+
process = @app.process_by_name(dyno_name.split('.').first)
|
30
|
+
next unless process
|
31
|
+
|
32
|
+
process.ping!
|
33
|
+
end
|
34
|
+
|
35
|
+
config.server_middleware do |chain|
|
36
|
+
chain.add(Middleware, @app)
|
37
|
+
end
|
38
|
+
|
39
|
+
# for jobs that queue other jobs...
|
40
|
+
config.client_middleware do |chain|
|
41
|
+
chain.add(Middleware, @app)
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
# configure sidekiq app client
|
46
|
+
::Sidekiq.configure_client do |config|
|
47
|
+
config.client_middleware do |chain|
|
48
|
+
chain.add(Middleware, @app)
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
# immedaitely wake all processes during client launch
|
53
|
+
@app.ping! unless ::Sidekiq.server?
|
54
|
+
|
55
|
+
@app
|
56
|
+
end
|
57
|
+
|
58
|
+
def exception_handler
|
59
|
+
@exception_handler ||= lambda { |ex|
|
60
|
+
p ex
|
61
|
+
puts ex.backtrace
|
62
|
+
}
|
63
|
+
end
|
64
|
+
|
65
|
+
attr_writer :exception_handler
|
66
|
+
end
|
67
|
+
|
68
|
+
end
|
69
|
+
end
|
@@ -0,0 +1,144 @@
|
|
1
|
+
require 'platform-api'
|
2
|
+
|
3
|
+
module Sidekiq
|
4
|
+
module HerokuAutoscale
|
5
|
+
|
6
|
+
class HerokuApp
|
7
|
+
attr_reader :app_name, :throttle, :history
|
8
|
+
|
9
|
+
# Builds process managers based on configuration (presumably loaded from YAML)
|
10
|
+
def initialize(config)
|
11
|
+
config = JSON.parse(JSON.generate(config), symbolize_names: true)
|
12
|
+
|
13
|
+
api_token = config[:api_token] || ENV['SIDEKIQ_HEROKU_AUTOSCALE_API_TOKEN']
|
14
|
+
@app_name = config[:app_name] || ENV['SIDEKIQ_HEROKU_AUTOSCALE_APP']
|
15
|
+
@throttle = config[:throttle] || 10
|
16
|
+
@history = config[:history] || 60 * 60 # 1 hour
|
17
|
+
@client = api_token ? PlatformAPI.connect_oauth(api_token) : nil
|
18
|
+
|
19
|
+
@processes_by_name = {}
|
20
|
+
@processes_by_queue = {}
|
21
|
+
|
22
|
+
config[:processes].each_pair do |name, opts|
|
23
|
+
process = Process.new(
|
24
|
+
app_name: @app_name,
|
25
|
+
name: name,
|
26
|
+
client: @client,
|
27
|
+
throttle: @throttle,
|
28
|
+
history: @history,
|
29
|
+
**opts.slice(:system, :scale, :quiet_buffer)
|
30
|
+
)
|
31
|
+
@processes_by_name[name.to_s] = process
|
32
|
+
|
33
|
+
process.queue_system.watch_queues.each do |queue_name|
|
34
|
+
# a queue may only be managed by a single heroku process type (to avoid scaling conflicts)
|
35
|
+
# thus, raise an error over duplicate queue names or when "*" isn't exclusive
|
36
|
+
if @processes_by_queue.key?(queue_name) || @processes_by_queue.key?('*') || (queue_name == '*' && @processes_by_queue.keys.any?)
|
37
|
+
raise ArgumentError, 'watched queues must be exclusive to a single Heroku process type'
|
38
|
+
end
|
39
|
+
@processes_by_queue[queue_name] = process
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
# checks if there's a live Heroku client setup
|
45
|
+
def live?
|
46
|
+
!!@client
|
47
|
+
end
|
48
|
+
|
49
|
+
# pings all processes in the application
|
50
|
+
# useful for requesting live updates
|
51
|
+
def ping!
|
52
|
+
processes.each(&:ping!)
|
53
|
+
end
|
54
|
+
|
55
|
+
def processes
|
56
|
+
@processes ||= @processes_by_name.values
|
57
|
+
end
|
58
|
+
|
59
|
+
def process_names
|
60
|
+
@process_names ||= @processes_by_name.keys
|
61
|
+
end
|
62
|
+
|
63
|
+
def queue_names
|
64
|
+
@queue_names ||= @processes_by_queue.keys
|
65
|
+
end
|
66
|
+
|
67
|
+
def process_by_name(process_name)
|
68
|
+
@processes_by_name[process_name]
|
69
|
+
end
|
70
|
+
|
71
|
+
def process_for_queue(queue_name)
|
72
|
+
@processes_by_queue[queue_name] || @processes_by_queue['*']
|
73
|
+
end
|
74
|
+
|
75
|
+
def stats
|
76
|
+
histories = history_stats
|
77
|
+
processes.each_with_object({}) do |process, memo|
|
78
|
+
memo[process.name] = {
|
79
|
+
dynos: process.dynos,
|
80
|
+
status: process.status,
|
81
|
+
updated: process.updated_at.to_s,
|
82
|
+
history: histories[process.name],
|
83
|
+
}
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
def history_stats(now=Time.now.utc)
|
88
|
+
# calculate a series time to anchor graph ticks on
|
89
|
+
# the series snaps to thresholds of N (throttle duration)
|
90
|
+
series_time = (now.to_f / @throttle).floor * @throttle
|
91
|
+
num_ticks = (@history / @throttle).floor
|
92
|
+
first_tick = series_time - @throttle * num_ticks
|
93
|
+
|
94
|
+
# all ticks is a hash of timestamp keys to plot
|
95
|
+
all_ticks = Array.new(num_ticks)
|
96
|
+
.each_with_index.map { |v, i| (first_tick + @throttle * i).to_s }
|
97
|
+
.each_with_object({}) { |tick, memo| memo[tick] = nil }
|
98
|
+
|
99
|
+
# get current and previous history collections for each process
|
100
|
+
# history pages snap to thresholds of M (history duration)
|
101
|
+
current_page = (now.to_f / @history).floor * @history
|
102
|
+
previous_page = current_page - @history
|
103
|
+
history_pages = ::Sidekiq.redis do |c|
|
104
|
+
c.pipelined do
|
105
|
+
processes.each do |process|
|
106
|
+
c.hgetall("#{ process.cache_key }:#{ previous_page }")
|
107
|
+
c.hgetall("#{ process.cache_key }:#{ current_page }")
|
108
|
+
end
|
109
|
+
end
|
110
|
+
end
|
111
|
+
|
112
|
+
history_by_process = {}
|
113
|
+
history_pages.each_slice(2).each_with_index do |(a, b), i|
|
114
|
+
process = processes[i]
|
115
|
+
|
116
|
+
# flatten all history pages into a single collection
|
117
|
+
ticks = all_ticks
|
118
|
+
.merge(a.merge!(b))
|
119
|
+
.map { |k, v| [k.to_i, v ? v.to_i : nil] }
|
120
|
+
.sort_by { |tick| tick[0] }
|
121
|
+
|
122
|
+
# separate the older stats from the current history timeframe
|
123
|
+
past_ticks, present_ticks = ticks.partition { |tick| tick[0] < first_tick }
|
124
|
+
|
125
|
+
# select a running value starting point
|
126
|
+
# run from the end of past history, or beginning of present history, or current dynos
|
127
|
+
value = past_ticks.last || present_ticks.detect { |tick| !!tick[1] }
|
128
|
+
value = value ? value[1] : process.dynos
|
129
|
+
|
130
|
+
# assign a running value across all ticks
|
131
|
+
present_ticks.each do |tick|
|
132
|
+
tick[1] ||= value
|
133
|
+
value = tick[1]
|
134
|
+
end
|
135
|
+
|
136
|
+
history_by_process[process.name] = present_ticks
|
137
|
+
end
|
138
|
+
|
139
|
+
history_by_process
|
140
|
+
end
|
141
|
+
end
|
142
|
+
|
143
|
+
end
|
144
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
module Sidekiq
|
2
|
+
module HerokuAutoscale
|
3
|
+
|
4
|
+
class Middleware
|
5
|
+
def initialize(app)
|
6
|
+
@app = app
|
7
|
+
end
|
8
|
+
|
9
|
+
def call(worker_class, item, queue, _=nil)
|
10
|
+
result = yield
|
11
|
+
|
12
|
+
if process = @app.process_for_queue(queue)
|
13
|
+
process.ping!
|
14
|
+
end
|
15
|
+
|
16
|
+
result
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
module Sidekiq
|
2
|
+
module HerokuAutoscale
|
3
|
+
|
4
|
+
class PollInterval
|
5
|
+
def initialize(method_name, before_update: 0, after_update: 0)
|
6
|
+
@method_name = method_name
|
7
|
+
@before_update = before_update
|
8
|
+
@after_update = after_update
|
9
|
+
@requests = {}
|
10
|
+
end
|
11
|
+
|
12
|
+
def call(process)
|
13
|
+
return unless process
|
14
|
+
@requests[process.name] ||= process
|
15
|
+
poll!
|
16
|
+
end
|
17
|
+
|
18
|
+
def poll!
|
19
|
+
@thread ||= Thread.new do
|
20
|
+
begin
|
21
|
+
while @requests.size > 0
|
22
|
+
sleep(@before_update) if @before_update > 0
|
23
|
+
@requests.reject! { |n, p| p.send(@method_name) }
|
24
|
+
sleep(@after_update) if @after_update > 0
|
25
|
+
end
|
26
|
+
ensure
|
27
|
+
@thread = nil
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
end
|
34
|
+
end
|