resque-kubernetes 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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: []