nerve 0.5.4 → 0.6.0

Sign up to get free protection for your applications and to get access to all the features.
data/.gitignore CHANGED
@@ -19,3 +19,4 @@ tmp
19
19
  .\#*
20
20
  vendor
21
21
  .vagrant
22
+ .ruby-version
data/.travis.yml CHANGED
@@ -1,5 +1,8 @@
1
1
  language: ruby
2
2
  cache: bundler
3
+ sudo: false
3
4
  rvm:
4
5
  - 1.9.3
5
-
6
+ - 2.0.0
7
+ - 2.1.6
8
+ - 2.2.2
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- nerve (0.5.4)
4
+ nerve (0.6.0)
5
5
  bunny (= 1.1.0)
6
6
  etcd (~> 0.2.3)
7
7
  json
@@ -34,7 +34,7 @@ GEM
34
34
  method_source (0.8.2)
35
35
  minitest (5.5.1)
36
36
  mixlib-log (1.6.0)
37
- multi_json (1.10.1)
37
+ multi_json (1.11.2)
38
38
  pry (0.10.1)
39
39
  coderay (~> 1.1.0)
40
40
  method_source (~> 0.8.1)
@@ -70,3 +70,6 @@ DEPENDENCIES
70
70
  pry
71
71
  rake
72
72
  rspec (~> 3.1.0)
73
+
74
+ BUNDLED WITH
75
+ 1.10.5
data/README.md CHANGED
@@ -39,6 +39,7 @@ An example config file is available in `example/nerve.conf.json`.
39
39
  The config file is composed of two main sections:
40
40
 
41
41
  * `instance_id`: the name nerve will submit when registering services; makes debugging easier
42
+ * `heartbeat_path`: a path to a file on disk to touch as nerve makes progress. This allows you to work around https://github.com/zk-ruby/zk/issues/50 by restarting a stuck nerve.
42
43
  * `services`: the hash (from service name to config) of the services nerve will be monitoring
43
44
  * `service_conf_dir`: path to a directory in which each json file will be interpreted as a service with the basename of the file minus the .json extension
44
45
 
@@ -53,6 +54,7 @@ The configuration contains the following options:
53
54
  * `reporter_type`: the mechanism used to report up/down information; depending on the reporter you choose, additional parameters may be required. Defaults to `zookeeper`
54
55
  * `check_interval`: the frequency with which service checks will be initiated; defaults to `500ms`
55
56
  * `checks`: a list of checks that nerve will perform; if all of the pass, the service will be registered; otherwise, it will be un-registered
57
+ * `weight`: a positive integer weight value which can be used to affect the haproxy backend weighting in synapse.
56
58
 
57
59
  #### Zookeeper Reporter ####
58
60
 
@@ -6,6 +6,7 @@
6
6
  "etcd_port": 4001,
7
7
  "etcd_path": "/nerve/services/your_http_service/services",
8
8
  "check_interval": 2,
9
+ "weight": 2,
9
10
  "checks": [
10
11
  {
11
12
  "type": "http",
@@ -5,6 +5,7 @@
5
5
  "zk_hosts": ["localhost:2181"],
6
6
  "zk_path": "/nerve/services/your_http_service/services",
7
7
  "check_interval": 2,
8
+ "weight": 2,
8
9
  "checks": [
9
10
  {
10
11
  "type": "http",
@@ -19,10 +19,23 @@ class Nerve::Reporter
19
19
  def report_down
20
20
  end
21
21
 
22
- def update_data(new_data='')
22
+ def ping?
23
23
  end
24
24
 
25
- def ping?
25
+ def get_service_data(service)
26
+ %w{instance_id host port}.each do |required|
27
+ raise ArgumentError, "missing required argument #{required} for new service watcher" unless service[required]
28
+ end
29
+ d = {'host' => service['host'], 'port' => service['port'], 'name' => service['instance_id']}
30
+ return d unless service.has_key?('weight')
31
+
32
+ # Weight is optional, but it should be well formed if supplied
33
+ if service['weight'].to_i >= 0 and "#{service['weight']}".match /^\d+$/
34
+ d['weight'] = service['weight'].to_i
35
+ else
36
+ raise ArgumentError, "invalid 'weight' argument in service data: #{service.inspect}"
37
+ end
38
+ d
26
39
  end
27
40
 
28
41
  protected
@@ -4,14 +4,12 @@ require 'etcd'
4
4
  class Nerve::Reporter
5
5
  class Etcd < Base
6
6
  def initialize(service)
7
- %w{etcd_host instance_id host port}.each do |required|
8
- raise ArgumentError, "missing required argument #{required} for new service watcher" unless service[required]
9
- end
7
+ raise ArgumentError, "missing required argument etcd_host for new service watcher" unless service['etcd_host']
10
8
  @host = service['etcd_host']
11
9
  @port = service['etcd_port'] || 4003
12
10
  path = service['etcd_path'] || '/'
13
11
  @path = path.split('/').push(service['instance_id']).join('/')
14
- @data = parse_data({'host' => service['host'], 'port' => service['port'], 'name' => service['instance_id']})
12
+ @data = parse_data(get_service_data(service))
15
13
  @key = nil
16
14
  @ttl = (service['check_interval'] || 0.5) * 5
17
15
  @ttl = @ttl.ceil
@@ -36,13 +34,6 @@ class Nerve::Reporter
36
34
  etcd_delete
37
35
  end
38
36
 
39
- def update_data(new_data='')
40
- # nothing in nerve calls this, but implement it like the zookeeper
41
- # reporter just for fun.
42
- @data = parse_data(new_data)
43
- etcd_save
44
- end
45
-
46
37
  def ping?
47
38
  # we get a ping every check_interval.
48
39
  if @key
@@ -1,30 +1,64 @@
1
1
  require 'nerve/reporter/base'
2
+ require 'thread'
2
3
  require 'zk'
3
4
 
5
+
4
6
  class Nerve::Reporter
5
7
  class Zookeeper < Base
8
+ @@zk_pool = {}
9
+ @@zk_pool_count = {}
10
+ @@zk_pool_lock = Mutex.new
11
+
6
12
  def initialize(service)
7
- %w{zk_hosts zk_path instance_id host port}.each do |required|
13
+ %w{zk_hosts zk_path}.each do |required|
8
14
  raise ArgumentError, "missing required argument #{required} for new service watcher" unless service[required]
9
15
  end
10
- @path = service['zk_hosts'].shuffle.join(',') + service['zk_path']
11
- @data = parse_data({'host' => service['host'], 'port' => service['port'], 'name' => service['instance_id']})
16
+ # Since we pool we get one connection per zookeeper cluster
17
+ @zk_connection_string = service['zk_hosts'].sort.join(',')
18
+ @data = parse_data(get_service_data(service))
12
19
 
13
- @key = "/#{service['instance_id']}_"
20
+ @zk_path = service['zk_path']
21
+ @key_prefix = @zk_path + "/#{service['instance_id']}_"
14
22
  @full_key = nil
15
23
  end
16
24
 
17
25
  def start()
18
- log.info "nerve: waiting to connect to zookeeper at #{@path}"
19
- @zk = ZK.new(@path)
20
-
21
- log.info "nerve: successfully created zk connection to #{@path}"
26
+ log.info "nerve: waiting to connect to zookeeper to #{@zk_connection_string}"
27
+ # Ensure that all Zookeeper reporters re-use a single zookeeper
28
+ # connection to any given set of zk hosts.
29
+ @@zk_pool_lock.synchronize {
30
+ unless @@zk_pool.has_key?(@zk_connection_string)
31
+ log.info "nerve: creating pooled connection to #{@zk_connection_string}"
32
+ @@zk_pool[@zk_connection_string] = ZK.new(@zk_connection_string, :timeout => 5)
33
+ @@zk_pool_count[@zk_connection_string] = 1
34
+ log.info "nerve: successfully created zk connection to #{@zk_connection_string}"
35
+ else
36
+ @@zk_pool_count[@zk_connection_string] += 1
37
+ log.info "nerve: re-using existing zookeeper connection to #{@zk_connection_string}"
38
+ end
39
+ @zk = @@zk_pool[@zk_connection_string]
40
+ log.info "nerve: retrieved zk connection to #{@zk_connection_string}"
41
+ }
22
42
  end
23
43
 
24
44
  def stop()
25
- log.info "nerve: closing zk connection at #{@path}"
26
- report_down
27
- @zk.close!
45
+ log.info "nerve: removing zk node at #{@full_key}" if @full_key
46
+ begin
47
+ report_down
48
+ ensure
49
+ @@zk_pool_lock.synchronize {
50
+ @@zk_pool_count[@zk_connection_string] -= 1
51
+ # Last thread to use the connection closes it
52
+ if @@zk_pool_count[@zk_connection_string] == 0
53
+ log.info "nerve: closing zk connection to #{@zk_connection_string}"
54
+ begin
55
+ @zk.close!
56
+ ensure
57
+ @@zk_pool.delete(@zk_connection_string)
58
+ end
59
+ end
60
+ }
61
+ end
28
62
  end
29
63
 
30
64
  def report_up()
@@ -35,13 +69,8 @@ class Nerve::Reporter
35
69
  zk_delete
36
70
  end
37
71
 
38
- def update_data(new_data='')
39
- @data = parse_data(new_data)
40
- zk_save
41
- end
42
-
43
72
  def ping?
44
- return @zk.ping?
73
+ return @zk.connected? && @zk.exists?(@full_key || '/')
45
74
  end
46
75
 
47
76
  private
@@ -54,7 +83,8 @@ class Nerve::Reporter
54
83
  end
55
84
 
56
85
  def zk_create
57
- @full_key = @zk.create(@key, :data => @data, :mode => :ephemeral_sequential)
86
+ @zk.mkdir_p(@zk_path)
87
+ @full_key = @zk.create(@key_prefix, :data => @data, :mode => :ephemeral_sequential)
58
88
  end
59
89
 
60
90
  def zk_save
@@ -40,7 +40,7 @@ module Nerve
40
40
  log.debug "nerve: check #{@name} got response code #{code} with body \"#{body}\""
41
41
  return true
42
42
  else
43
- log.error "nerve: check #{@name} got response code #{code} with body \"#{body}\""
43
+ log.warn "nerve: check #{@name} got response code #{code} with body \"#{body}\""
44
44
  return false
45
45
  end
46
46
  end
@@ -83,12 +83,15 @@ module Nerve
83
83
  raise e
84
84
  ensure
85
85
  log.info "nerve: ending service watch #{@name}"
86
- $EXIT = true
87
86
  @reporter.stop
88
87
  end
89
88
 
90
89
  def check_and_report
91
- @reporter.ping?
90
+ if !@reporter.ping?
91
+ # If the reporter can't ping, then we do not know the status
92
+ # and must force a new report.
93
+ @was_up = nil
94
+ end
92
95
 
93
96
  # what is the status of the service?
94
97
  is_up = check?
data/lib/nerve/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Nerve
2
- VERSION = "0.5.4"
2
+ VERSION = "0.6.0"
3
3
  end
data/lib/nerve.rb CHANGED
@@ -1,3 +1,4 @@
1
+ require 'fileutils'
1
2
  require 'logger'
2
3
  require 'json'
3
4
  require 'timeout'
@@ -28,12 +29,9 @@ module Nerve
28
29
 
29
30
  @instance_id = opts['instance_id']
30
31
  @services = opts['services']
32
+ @heartbeat_path = opts['heartbeat_path']
31
33
  @watchers = {}
32
34
 
33
- # Any exceptions in the watcher threads should wake the main thread so
34
- # that we can fail fast.
35
- Thread.abort_on_exception = true
36
-
37
35
  log.debug 'nerve: completed init'
38
36
  end
39
37
 
@@ -45,15 +43,41 @@ module Nerve
45
43
  end
46
44
 
47
45
  begin
48
- sleep
49
- rescue StandardError => e
46
+ loop do
47
+ # Check that watcher threads are still alive, auto-remediate if they
48
+ # are not. Sometimes zookeeper flakes out or connections are lost to
49
+ # remote datacenter zookeeper clusters, failing is not an option
50
+ relaunch = []
51
+ @watchers.each do |name, watcher_thread|
52
+ unless watcher_thread.alive?
53
+ relaunch << name
54
+ end
55
+ end
56
+
57
+ relaunch.each do |name|
58
+ begin
59
+ log.warn "nerve: watcher #{name} not alive; reaping and relaunching"
60
+ reap_watcher(name)
61
+ rescue => e
62
+ log.warn "nerve: could not reap #{name}, got #{e.inspect}"
63
+ end
64
+ launch_watcher(name, @services[name])
65
+ end
66
+
67
+ unless @heartbeat_path.nil?
68
+ FileUtils.touch(@heartbeat_path)
69
+ end
70
+
71
+ sleep 10
72
+ end
73
+ rescue => e
50
74
  log.error "nerve: encountered unexpected exception #{e.inspect} in main thread"
51
75
  raise e
52
76
  ensure
53
77
  $EXIT = true
54
78
  log.warn 'nerve: reaping all watchers'
55
79
  @watchers.each do |name, watcher_thread|
56
- reap_watcher(name)
80
+ reap_watcher(name) rescue "nerve: watcher #{name} could not be immediately reaped; skippping"
57
81
  end
58
82
  end
59
83
 
data/nerve.gemspec CHANGED
@@ -8,9 +8,13 @@ Gem::Specification.new do |gem|
8
8
  gem.version = Nerve::VERSION
9
9
  gem.authors = ["Martin Rhoads", "Igor Serebryany", "Pierre Carrier"]
10
10
  gem.email = ["martin.rhoads@airbnb.com", "igor.serebryany@airbnb.com"]
11
- gem.description = %q{description}
12
- gem.summary = %q{summary}
13
- gem.homepage = ""
11
+ gem.description = "Nerve is a service registration daemon. It performs health "\
12
+ "checks on your service and then publishes success or failure "\
13
+ "into one of several registries (currently, zookeeper or etcd). "\
14
+ "Nerve is half or SmartStack, and is designed to be operated "\
15
+ "along with Synapse to provide a full service discovery framework"
16
+ gem.summary = %q{A service registration daemon}
17
+ gem.homepage = "https://github.com/airbnb/nerve"
14
18
 
15
19
  gem.files = `git ls-files`.split($/)
16
20
  gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) }
@@ -1,7 +1,12 @@
1
1
  require 'json'
2
2
  require 'nerve/reporter'
3
+ require 'nerve/reporter/base'
3
4
  require 'nerve/service_watcher'
4
5
 
6
+ class Nerve::Reporter::Base
7
+ attr_reader :data
8
+ end
9
+
5
10
  describe "example services are valid" do
6
11
  Dir.foreach("#{File.dirname(__FILE__)}/../example/nerve_services") do |item|
7
12
  next if item == '.' or item == '..'
@@ -15,6 +20,9 @@ describe "example services are valid" do
15
20
  expect { reporter = Nerve::Reporter.new_from_service(service_data) }.to_not raise_error()
16
21
  expect(reporter.is_a?(Nerve::Reporter::Base)).to eql(true)
17
22
  end
23
+ it 'saves the weight data' do
24
+ expect(JSON.parse(Nerve::Reporter.new_from_service(service_data).data)['weight']).to eql(2)
25
+ end
18
26
  end
19
27
 
20
28
  context "when #{item} can be initialized as a valid service watcher" do
@@ -1,4 +1,5 @@
1
1
  require 'spec_helper'
2
+ require 'nerve/reporter/zookeeper'
2
3
 
3
4
  describe Nerve::Reporter do
4
5
  let(:subject) { {
@@ -30,13 +31,42 @@ end
30
31
 
31
32
  describe Nerve::Reporter::Test do
32
33
  let(:subject) {Nerve::Reporter::Test.new({}) }
33
- it 'has parse data method that passes strings' do
34
- expect(subject.send(:parse_data, 'foobar')).to eql('foobar')
34
+ context 'parse_data method' do
35
+ it 'has parse data method that passes strings' do
36
+ expect(subject.send(:parse_data, 'foobar')).to eql('foobar')
37
+ end
38
+ it 'jsonifies anything that is not a string' do
39
+ thing_to_parse = double()
40
+ expect(thing_to_parse).to receive(:to_json).and_return('{"some":"json"}')
41
+ expect(subject.send(:parse_data, thing_to_parse)).to eql('{"some":"json"}')
42
+ end
35
43
  end
36
- it 'jsonifies anything that is not a string' do
37
- thing_to_parse = double()
38
- expect(thing_to_parse).to receive(:to_json).and_return('{"some":"json"}')
39
- expect(subject.send(:parse_data, thing_to_parse)).to eql('{"some":"json"}')
44
+
45
+ context 'get_service_data method' do
46
+ it 'throws on missing arguments' do
47
+ expect { subject.get_service_data({'host' => '127.0.0.1', 'port' => 6666}) }.to raise_error(ArgumentError)
48
+ expect { subject.get_service_data({'host' => '127.0.0.1', 'instance_id' => 'foobar'}) }.to raise_error(ArgumentError)
49
+ expect { subject.get_service_data({'port' => 6666, 'instance_id' => 'foobar'}) }.to raise_error(ArgumentError)
50
+ expect { subject.get_service_data({'host' => '127.0.0.1', 'port' => 6666, 'instance_id' => 'foobar'}) }.not_to raise_error
51
+ end
52
+ it 'takes weight if present and +ve integer' do
53
+ expect(subject.get_service_data({'host' => '127.0.0.1', 'port' => 6666, 'instance_id' => 'foobar', 'weight' => 3})['weight']).to eql(3)
54
+ end
55
+ it 'takes weight if present and 0' do
56
+ expect(subject.get_service_data({'host' => '127.0.0.1', 'port' => 6666, 'instance_id' => 'foobar', 'weight' => 0})['weight']).to eql(0)
57
+ end
58
+ it 'skips weight if not present' do
59
+ expect(subject.get_service_data({'host' => '127.0.0.1', 'port' => 6666, 'instance_id' => 'foobar'})['weight']).to eql(nil)
60
+ end
61
+ it 'skips weight if non integer' do
62
+ expect { subject.get_service_data({'host' => '127.0.0.1', 'port' => 6666, 'instance_id' => 'foobar', 'weight' => 'hello'})['weight'] }.to raise_error(ArgumentError)
63
+ end
64
+ it 'skips weight if a negative integer' do
65
+ expect { subject.get_service_data({'host' => '127.0.0.1', 'port' => 6666, 'instance_id' => 'foobar', 'weight' => -1})['weight'] }.to raise_error(ArgumentError)
66
+ end
67
+ it 'works if the weight is a string' do
68
+ expect(subject.get_service_data({'host' => '127.0.0.1', 'port' => 6666, 'instance_id' => 'foobar', 'weight' => '3'})['weight']).to eql(3)
69
+ end
40
70
  end
41
71
  end
42
72
 
@@ -17,6 +17,7 @@ describe Nerve::Reporter::Zookeeper do
17
17
  it 'deregisters service on exit' do
18
18
  zk = double("zk")
19
19
  allow(zk).to receive(:close!)
20
+ expect(zk).to receive(:mkdir_p) { "zk_path" }
20
21
  expect(zk).to receive(:create) { "full_path" }
21
22
  expect(zk).to receive(:delete).with("full_path", anything())
22
23
 
data/spec/spec_helper.rb CHANGED
@@ -12,7 +12,8 @@ FactoryGirl.find_definitions
12
12
  RSpec.configure do |config|
13
13
  config.run_all_when_everything_filtered = true
14
14
  config.filter_run :focus
15
- config.include Config
15
+ config.include RbConfig
16
+ config.color = true
16
17
 
17
18
  # verify every double we can think of
18
19
  config.mock_with :rspec do |mocks|
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: nerve
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.4
4
+ version: 0.6.0
5
5
  prerelease:
6
6
  platform: ruby
7
7
  authors:
@@ -11,7 +11,7 @@ authors:
11
11
  autorequire:
12
12
  bindir: bin
13
13
  cert_chain: []
14
- date: 2015-02-19 00:00:00.000000000 Z
14
+ date: 2015-08-04 00:00:00.000000000 Z
15
15
  dependencies:
16
16
  - !ruby/object:Gem::Dependency
17
17
  name: json
@@ -141,7 +141,10 @@ dependencies:
141
141
  - - ! '>='
142
142
  - !ruby/object:Gem::Version
143
143
  version: '0'
144
- description: description
144
+ description: Nerve is a service registration daemon. It performs health checks on
145
+ your service and then publishes success or failure into one of several registries
146
+ (currently, zookeeper or etcd). Nerve is half or SmartStack, and is designed to
147
+ be operated along with Synapse to provide a full service discovery framework
145
148
  email:
146
149
  - martin.rhoads@airbnb.com
147
150
  - igor.serebryany@airbnb.com
@@ -189,7 +192,7 @@ files:
189
192
  - spec/lib/nerve/reporter_zookeeper_spec.rb
190
193
  - spec/lib/nerve/service_watcher_spec.rb
191
194
  - spec/spec_helper.rb
192
- homepage: ''
195
+ homepage: https://github.com/airbnb/nerve
193
196
  licenses: []
194
197
  post_install_message:
195
198
  rdoc_options: []
@@ -212,7 +215,7 @@ rubyforge_project:
212
215
  rubygems_version: 1.8.23.2
213
216
  signing_key:
214
217
  specification_version: 3
215
- summary: summary
218
+ summary: A service registration daemon
216
219
  test_files:
217
220
  - spec/.gitkeep
218
221
  - spec/example_services_spec.rb