resque-kubernetes 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 7a5d6e3560f6a7393489b6fe7509e0777e2d60b6
4
+ data.tar.gz: bf7ea17b439598dc9ac2946138178dc61eaedf42
5
+ SHA512:
6
+ metadata.gz: 0066365884c83d8479316ddbb340e50222496a6eabf7fc75822ad0d9088c92375bd285c5049fb9566d7c6524575ee0786637eb12f53c46de5fdb2262283279ee
7
+ data.tar.gz: c339b564294da1834720fe92a916f82c2b05b110c6c32e30882c3eb65883499862215116aa259fe7b84b27d057d578041daf115627be9f377c5edbd3621b439e
data/.gitignore ADDED
@@ -0,0 +1,9 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /Gemfile.lock
4
+ /_yardoc/
5
+ /coverage/
6
+ /doc/
7
+ /pkg/
8
+ /spec/reports/
9
+ /tmp/
data/.rspec ADDED
@@ -0,0 +1 @@
1
+ --color
data/.ruby-gemset ADDED
@@ -0,0 +1 @@
1
+ resque-kubernetes
data/.ruby-version ADDED
@@ -0,0 +1 @@
1
+ 2.3.1
data/.travis.yml ADDED
@@ -0,0 +1,5 @@
1
+ sudo: false
2
+ language: ruby
3
+ rvm:
4
+ - 2.3.1
5
+ before_install: gem install bundler -v 1.13.6
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in resque-kubernetes.gemspec
4
+ gemspec
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2016 Keylime Toolbox
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,146 @@
1
+ # Resque::Kubernetes
2
+
3
+ Run Resque Jobs as Kubernetes Jobs!
4
+
5
+ Kubernetes has a concept of "Job" which is a pod that runs a container until
6
+ the container finishes and then it terminates the pod (as opposed to trying to
7
+ restart the container).
8
+
9
+ This gem takes advantage of that feature by starting up a Kubernetes Job when
10
+ a Resque Job is enqueued. It then allows the Resque Worker to be modified to
11
+ terminate when there are no more jobs in the queue.
12
+
13
+ Why would you do this?
14
+
15
+ We have unpredictable, resource-intensive jobs. Rather than dedicating large
16
+ nodes in our cluster to run the resque workers, where the resources would be
17
+ idle when there are no jobs to run, we can use auto-scaling to add nodes when
18
+ Kubernetes Job gets created and shut them down when those jobs are complete.
19
+
20
+ ## Installation
21
+
22
+ Add this line to your application's Gemfile:
23
+
24
+ ```ruby
25
+ gem 'resque-kubernetes'
26
+ ```
27
+
28
+ And then execute:
29
+
30
+ $ bundle
31
+
32
+ Or install it yourself as:
33
+
34
+ $ gem install resque-kubernetes
35
+
36
+ ## Usage
37
+
38
+ For any Resque job that you want to run in a Kubernetes job, you'll need to
39
+ modify the job class with two things:
40
+
41
+ - extend the class with `Resque::Kubernetes::Job`
42
+ - and add a method `job_manifest` that returns the Kubernetes manifest for the job
43
+
44
+ ```ruby
45
+ class ResourceIntensiveJob
46
+ extend Resque::Kubernetes::Job
47
+
48
+ def perform
49
+ # ... your existing code
50
+ end
51
+
52
+ def job_manifest
53
+ <<-EOD
54
+ metadata:
55
+ name: worker-job
56
+ spec:
57
+ template:
58
+ metadata:
59
+ name: worker-job
60
+ spec:
61
+ containers:
62
+ - name: worker
63
+ image: us.gcr.io/project-id/some-resque-worker
64
+ env:
65
+ - name: QUEUE
66
+ value: high-memory
67
+ EOD
68
+ end
69
+ end
70
+ ```
71
+
72
+ Make sure that the container image above, which is used to run the resque
73
+ worker, is built to include the `resque-kubernetes` gem as well. The gem will
74
+ add `TERM_ON_EMPTY` to the environment variables. This tells the worker that
75
+ whenever the queue is empty it should terminate the worker. Kubernetes will
76
+ then terminate the Job when the container is done running and will release the
77
+ resources.
78
+
79
+ ## Configuration
80
+
81
+ You can modify the configuration of the gem by creating an initializer in
82
+ your project:
83
+
84
+ ```ruby
85
+ # config/initializers/resque-kubernetes.rb
86
+
87
+ Resque::Kubernetes.configuration do |config|
88
+
89
+ config.environments << "staging"
90
+ config.max_workers = 10
91
+
92
+ end
93
+ ```
94
+
95
+ ### `environments`
96
+
97
+ By default `Resque::Kubernetes` will only manage Kubernetes Jobs in
98
+ `:production`. If you want to add other environments you can update this list
99
+ (`config.environments << "staging"`) or replace it (`config.environments =
100
+ ["production", "development"]`).
101
+
102
+ Note that this only works under Rails, when `Rails.env` is set.
103
+
104
+ ### `max_workers`
105
+
106
+ `Resque::Kubernetes` will spin up a Kuberentes Job each time you enqueue a
107
+ Resque Job. This allows for parallel processing of jobs using the resources
108
+ available to your cluster. By default this is limited to 10 workers, so an not
109
+ to have run-away cloud resource usage.
110
+
111
+ You can set this higher if you need massive scaling and your structure supports
112
+ it.
113
+
114
+ If you don't want more than one job running at a time then set this to 1.
115
+
116
+ ## To Do
117
+
118
+ - We probably need better namespace support, particularly for reaping
119
+ finished jobs and pods.
120
+ - Support for other authentication and server URL options for `kubeclient`.
121
+ See [the many examples](https://github.com/abonas/kubeclient#usage) in their
122
+ README.
123
+
124
+ ## Development
125
+
126
+ After checking out the repo, run `bin/setup` to install dependencies. Then,
127
+ run `rake spec` to run the tests. You can also run `bin/console` for an
128
+ interactive prompt that will allow you to experiment.
129
+
130
+ To install this gem onto your local machine, run `bundle exec rake install`.
131
+ To release a new version, update the version number in `version.rb`, and then
132
+ run `bundle exec rake release`, which will create a git tag for the version,
133
+ push git commits and tags, and push the `.gem` file to
134
+ [rubygems.org](https://rubygems.org).
135
+
136
+ ## Contributing
137
+
138
+ Bug reports and pull requests are welcome on GitHub at
139
+ https://github.com/keylime-toolbox/resque-kubernetes.
140
+
141
+
142
+ ## License
143
+
144
+ The gem is available as open source under the terms of the
145
+ [MIT License](http://opensource.org/licenses/MIT).
146
+
data/Rakefile ADDED
@@ -0,0 +1,6 @@
1
+ require "bundler/gem_tasks"
2
+ require "rspec/core/rake_task"
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+
6
+ task :default => :spec
data/bin/console ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+ require "resque/kubernetes"
5
+
6
+ require "irb"
7
+ IRB.start
data/bin/setup ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -0,0 +1,18 @@
1
+ require "resque/kubernetes/job"
2
+ require "resque/kubernetes/version"
3
+ require "resque/kubernetes/worker"
4
+ require "resque/kubernetes/configurable"
5
+
6
+ module Resque
7
+ module Kubernetes
8
+ extend Configurable
9
+
10
+ # By default only manage kubernetes jobs in :production
11
+ define_setting :environments, [:production]
12
+
13
+ # Limit the number of workers that should be spun up, default 10
14
+ define_setting :max_workers, 10
15
+ end
16
+ end
17
+
18
+ Resque::Worker.include Resque::Kubernetes::Worker
@@ -0,0 +1,32 @@
1
+ module Resque
2
+ module Kubernetes
3
+ module Configurable
4
+
5
+ def configuration
6
+ yield self
7
+ end
8
+
9
+ def define_setting(name, default = nil)
10
+ class_variable_set("@@#{name}", default)
11
+
12
+ define_class_method "#{name}=" do |value|
13
+ class_variable_set("@@#{name}", value)
14
+ end
15
+
16
+ define_class_method name do
17
+ class_variable_get("@@#{name}")
18
+ end
19
+ end
20
+
21
+ private
22
+
23
+ def define_class_method(name, &block)
24
+ (class << self; self; end).instance_eval do
25
+ define_method(name, &block)
26
+ end
27
+ end
28
+
29
+
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,140 @@
1
+ require "securerandom"
2
+ require "kubeclient"
3
+
4
+ module Resque
5
+ module Kubernetes
6
+ module Job
7
+
8
+ def before_enqueue_kubernetes_job(*_)
9
+ if defined? Rails
10
+ return unless Resque::Kubernetes.environments.include?(Rails.env)
11
+ end
12
+
13
+ reap_finished_jobs
14
+ reap_finished_pods
15
+ apply_kubernetes_job
16
+ end
17
+
18
+ private
19
+
20
+ def jobs_client
21
+ return @jobs_client if @jobs_client
22
+ @jobs_client = client("/apis/batch")
23
+ end
24
+
25
+ def pods_client
26
+ return @pods_client if @pods_client
27
+ @pods_client = client("")
28
+ end
29
+
30
+ def client(scope)
31
+ kubeconfig = File.join(ENV["HOME"], ".kube", "config")
32
+
33
+ if File.exist?("/var/run/secrets/kubernetes.io/serviceaccount/token")
34
+ # When running in k8s cluster, use the service account secret token
35
+ auth_options = {bearer_token_file: "/var/run/secrets/kubernetes.io/serviceaccount/token"}
36
+ @jobs_client = Kubeclient::Client.new("https://localhost:8443/apis/batch" , "v1", auth_options: auth_options)
37
+ elsif File.exist?(kubeconfig)
38
+ # When running in development, use the config file for `kubectl`
39
+ kubeconfig = File.join(ENV["HOME"], ".kube", "config")
40
+ config = Kubeclient::Config.read(kubeconfig)
41
+ Kubeclient::Client.new(
42
+ config.context.api_endpoint + scope,
43
+ config.context.api_version,
44
+ {
45
+ ssl_options: config.context.ssl_options,
46
+ auth_options: {use_default_gcp: true}
47
+ }
48
+ )
49
+ end
50
+ end
51
+
52
+ def reap_finished_jobs
53
+ resque_jobs = jobs_client.get_jobs(label_selector: "resque-kubernetes=job")
54
+ finished = resque_jobs.select { |job| job.spec.completions == job.status.succeeded }
55
+
56
+ finished.each do |job|
57
+ jobs_client.delete_job(job.metadata.name, job.metadata.namespace)
58
+ end
59
+ end
60
+
61
+ def reap_finished_pods
62
+ resque_jobs = pods_client.get_pods(label_selector: "resque-kubernetes=pod")
63
+ finished = resque_jobs.select { |pod| pod.status.phase == "Succeeded" }
64
+
65
+ finished.each do |pod|
66
+ pods_client.delete_pod(pod.metadata.name, pod.metadata.namespace)
67
+ end
68
+ end
69
+
70
+ def apply_kubernetes_job
71
+ manifest = job_manifest.dup
72
+ ensure_namespace(manifest)
73
+
74
+ # Do not start job if we have reached our maximum count
75
+ return if jobs_maxed?(manifest["metadata"]["name"], manifest["metadata"]["namespace"])
76
+
77
+ add_labels(manifest)
78
+ ensure_term_on_empty(manifest)
79
+ ensure_reset_policy(manifest)
80
+ update_job_name(manifest)
81
+
82
+ job = Kubeclient::Resource.new(manifest)
83
+ jobs_client.create_job(job)
84
+ end
85
+
86
+ def jobs_maxed?(name, namespace)
87
+ resque_jobs = jobs_client.get_jobs(label_selector: "resque-kubernetes=job,resque-kubernetes-group=#{name}", namespace: namespace)
88
+ running = resque_jobs.select { |job| job.spec.completions != job.status.succeeded }
89
+ running.size == Resque::Kubernetes.max_workers
90
+ end
91
+
92
+ def add_labels(manifest)
93
+ manifest["metadata"] ||= {}
94
+ manifest["metadata"]["labels"] ||= {}
95
+ manifest["metadata"]["labels"]["resque-kubernetes"] = "job"
96
+ manifest["metadata"]["labels"]["resque-kubernetes-group"] = manifest["metadata"]["name"]
97
+ manifest["spec"]["template"]["metadata"] ||= {}
98
+ manifest["spec"]["template"]["metadata"]["labels"] ||= {}
99
+ manifest["spec"]["template"]["metadata"]["labels"]["resque-kubernetes"] = "pod"
100
+ end
101
+
102
+ def ensure_term_on_empty(manifest)
103
+ manifest["spec"]["template"]["spec"] ||= {}
104
+ manifest["spec"]["template"]["spec"]["containers"] ||= []
105
+ manifest["spec"]["template"]["spec"]["containers"].each do |container|
106
+ container["env"] ||= []
107
+ term_on_empty = container["env"].find { |env| env["name"] == "TERM_ON_EMPTY" }
108
+ unless term_on_empty
109
+ term_on_empty = {"name" => "TERM_ON_EMPTY"}
110
+ container["env"] << term_on_empty
111
+ end
112
+ term_on_empty["value"] = "1"
113
+ end
114
+ end
115
+
116
+ def ensure_reset_policy(manifest)
117
+ manifest["spec"]["template"]["spec"]["restartPolicy"] ||= "OnFailure"
118
+ end
119
+
120
+
121
+ def ensure_namespace(manifest)
122
+ manifest["metadata"]["namespace"] ||= "default"
123
+ end
124
+
125
+ def update_job_name(manifest)
126
+ manifest["metadata"]["name"] += "-#{dns_safe_random}"
127
+ end
128
+
129
+ # Returns an n-length string of characters [a-z0-9]
130
+ def dns_safe_random(n = 5)
131
+ s = [SecureRandom.random_bytes(n)].pack("m*")
132
+ s.delete!("=\n")
133
+ s.tr!("+/_-", "0")
134
+ s.tr!("A-Z", "a-z")
135
+ s[0...n]
136
+ end
137
+
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,5 @@
1
+ module Resque
2
+ module Kubernetes
3
+ VERSION = "0.1.0"
4
+ end
5
+ end
@@ -0,0 +1,41 @@
1
+ require "resque"
2
+
3
+ module Resque
4
+ module Kubernetes
5
+ module Worker
6
+ def self.included(base)
7
+ base.class_eval do
8
+ prepend InstanceMethods
9
+ end
10
+ end
11
+
12
+ attr_accessor :term_on_empty
13
+
14
+ module InstanceMethods
15
+ def prepare
16
+ self.term_on_empty = ENV["TERM_ON_EMPTY"] if ENV["TERM_ON_EMPTY"]
17
+ super
18
+ end
19
+
20
+ def shutdown?
21
+ if term_on_empty
22
+ if queues_empty?
23
+ log_with_severity :info, "shutdown: queues are empty"
24
+ shutdown
25
+ end
26
+ end
27
+
28
+ super
29
+ end
30
+ end
31
+
32
+
33
+ private
34
+
35
+ def queues_empty?
36
+ queues.all? { |queue| Resque.size(queue) == 0 }
37
+ end
38
+
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,30 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'resque/kubernetes/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "resque-kubernetes"
8
+ spec.version = Resque::Kubernetes::VERSION
9
+ spec.authors = ["Jeremy Wadsack"]
10
+ spec.email = ["jeremy.wadsack@gmail.com"]
11
+
12
+ spec.summary = %q{Run Resque Jobs as Kubernetes Jobs}
13
+ spec.description = %q{Launches a Kubernetes Job when a Resque Job is enqueued, then terminates the worker when there are no more jobs in the queue.}
14
+ spec.homepage = "https://github.com/keylimetoolbox/resque-kubernetes"
15
+ spec.license = "MIT"
16
+
17
+ spec.files = `git ls-files -z`.split("\x0").reject do |f|
18
+ f.match(%r{^(test|spec|features)/})
19
+ end
20
+ spec.bindir = "exe"
21
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
22
+ spec.require_paths = ["lib"]
23
+
24
+ spec.add_development_dependency "bundler", "~> 1.13"
25
+ spec.add_development_dependency "rake", "~> 10.0"
26
+ spec.add_development_dependency "rspec", "~> 3.0"
27
+
28
+ spec.add_dependency "resque", "~> 1.26"
29
+ spec.add_dependency "kubeclient", "~> 2.2"
30
+ end
metadata ADDED
@@ -0,0 +1,132 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: resque-kubernetes
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Jeremy Wadsack
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2016-12-08 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: bundler
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1.13'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '1.13'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rake
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '10.0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '10.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rspec
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '3.0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '3.0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: resque
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '1.26'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '1.26'
69
+ - !ruby/object:Gem::Dependency
70
+ name: kubeclient
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '2.2'
76
+ type: :runtime
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '2.2'
83
+ description: Launches a Kubernetes Job when a Resque Job is enqueued, then terminates
84
+ the worker when there are no more jobs in the queue.
85
+ email:
86
+ - jeremy.wadsack@gmail.com
87
+ executables: []
88
+ extensions: []
89
+ extra_rdoc_files: []
90
+ files:
91
+ - ".gitignore"
92
+ - ".rspec"
93
+ - ".ruby-gemset"
94
+ - ".ruby-version"
95
+ - ".travis.yml"
96
+ - Gemfile
97
+ - LICENSE.txt
98
+ - README.md
99
+ - Rakefile
100
+ - bin/console
101
+ - bin/setup
102
+ - lib/resque/kubernetes.rb
103
+ - lib/resque/kubernetes/configurable.rb
104
+ - lib/resque/kubernetes/job.rb
105
+ - lib/resque/kubernetes/version.rb
106
+ - lib/resque/kubernetes/worker.rb
107
+ - resque-kubernetes.gemspec
108
+ homepage: https://github.com/keylimetoolbox/resque-kubernetes
109
+ licenses:
110
+ - MIT
111
+ metadata: {}
112
+ post_install_message:
113
+ rdoc_options: []
114
+ require_paths:
115
+ - lib
116
+ required_ruby_version: !ruby/object:Gem::Requirement
117
+ requirements:
118
+ - - ">="
119
+ - !ruby/object:Gem::Version
120
+ version: '0'
121
+ required_rubygems_version: !ruby/object:Gem::Requirement
122
+ requirements:
123
+ - - ">="
124
+ - !ruby/object:Gem::Version
125
+ version: '0'
126
+ requirements: []
127
+ rubyforge_project:
128
+ rubygems_version: 2.5.1
129
+ signing_key:
130
+ specification_version: 4
131
+ summary: Run Resque Jobs as Kubernetes Jobs
132
+ test_files: []