nerve 0.5.4 → 0.6.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.
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