How to Write Service Discovery Plugin
Fluentd supports pluggable service discovery which lets an output plugin resolve its destinations at run time. The plugin filenames prefixed sd_ are registered as Service Discovery Plugins.
See Plugin Base Class API for details on the common APIs for all the plugin types.
Here is an example of a custom service discovery plugin that reads a list of servers from a plain text file, one host:port per line, and re-reads it on a timer. It takes a required parameter called path, which is the pathname of that file, and an optional interval.
require 'fluent/plugin/service_discovery'
require 'fluent/plugin_helper'
module Fluent::Plugin
class MyListServiceDiscovery < ServiceDiscovery
include Fluent::PluginHelper::Mixin
# Register MyListServiceDiscovery as 'my_list'.
Fluent::Plugin.register_sd('my_list', self)
helpers :timer
config_param :path, :string
config_param :interval, :time, default: 10
def configure(conf)
super
unless File.readable?(@path)
raise Fluent::ConfigError, "my_list: path=#{@path} is not readable"
end
@services = fetch_services
end
def start(queue)
timer_execute(:sd_my_list, @interval) do
refresh(queue)
end
super()
end
private
# Reads one "host:port" per line. Blank lines and # comments are skipped.
def fetch_services
File.readlines(@path).filter_map do |line|
line = line.sub(/#.*/, '').strip
next if line.empty?
host, port = line.split(':', 2)
Service.new(:my_list, host, port.to_i, line, 60, false, nil, nil, nil)
end
end
# Announces the additions before the removals, so that the owner plugin is
# never left without a destination.
def refresh(queue)
services = begin
fetch_services
rescue => e
log.error "my_list: failed to read the list", path: @path, error: e
return
end
(services - @services).each { |s| queue.push(ServiceDiscovery.service_in_msg(s)) }
(@services - services).each { |s| queue.push(ServiceDiscovery.service_out_msg(s)) }
@services = services
end
end
end
Save this as sd_my_list.rb in a loadable plugin path.
With out_forward output plugin:
<match test>
@type forward
<service_discovery>
@type my_list
path /path/to/servers.txt
interval 2
</service_discovery>
</match>
/path/to/servers.txt holds one server per line:
# host:port
127.0.0.1:24230
127.0.0.1:24231
out_forward picks up both of them at startup:
adding forwarding server '127.0.0.1:24230' host="127.0.0.1" port=24230 weight=60
adding forwarding server '127.0.0.1:24231' host="127.0.0.1" port=24231 weight=60
Replacing the second line of the file with another server swaps the destination without a restart:
Service in: name=127.0.0.1:24232 127.0.0.1:24232
Service out: name=127.0.0.1:24231 127.0.0.1:24231
Fluent::Plugin::ServiceDiscovery does not include the plugin helper mixin, so a plugin which needs a helper includes Fluent::PluginHelper::Mixin itself, as the example above does. The built-in sd_file and sd_srv do the same.
How To Use Service Discovery From Plugins
Service discovery plugins are designed to be used from output plugins. The service discovery plugin helper is there for this purpose:
# in class definition
helpers :service_discovery
# in #configure
service_discovery_configure(:my_output_sd_watcher, static_default_service_directive: 'server')
# in #write, or wherever a destination is picked
service_discovery_select_service do |node|
send_data(node, chunk)
end
The helper owns the lifecycle of the configured plugins. It creates one instance per <service_discovery> section, starts them with the queue they push their updates onto, keeps the current set of services, and shuts them down with the owner plugin.
See Service Discovery Plugin Helper API for details.
Methods
Fluent::Plugin::ServiceDiscovery keeps the current destinations in @services, and gives the plugin two class methods to announce a change. A plugin implements #configure for the initial list, and #start for the updates.
#configure(conf)
It sets @services to the destinations the plugin knows at startup. The owner plugin reads them while it configures itself, so a destination missing here does not exist for the owner plugin until the plugin announces it later.
Raise Fluent::ConfigError here for anything the plugin cannot work with, so that a misconfiguration stops Fluentd at startup instead of showing up later as a lost destination.
#start(queue)
It starts watching wherever the plugin discovers destinations from. queue is a Thread::Queue owned by the owner plugin. A plugin which never changes its list, like sd_static, does not have to override this method.
The plugin schedules its own updates. The example above and sd_srv use the timer plugin helper, and sd_file watches the file with the event loop plugin helper.
.service_in_msg(service)
It builds the message which adds service to the destinations of the owner plugin.
.service_out_msg(service)
It builds the message which removes service from the destinations of the owner plugin. The built-in plugins push the additions before the removals, so that the owner plugin always has at least one destination left.
Services
A destination is a Fluent::Plugin::ServiceDiscovery::Service, a Struct with these members in this order:
| Member | Description |
|---|---|
plugin_name |
The name of the plugin which found the service. The built-in plugins use :static, :file and :srv. |
host |
The IP address or the host name of the server. |
port |
The port number of the server. |
name |
The name of the server. The owner plugin uses it for logging, and out_forward also uses it to verify the certificate when host is an address. |
weight |
The load balancing weight. The built-in plugins default to 60. |
standby |
true marks the server as a standby node. |
username |
The username for authentication. |
password |
The password for authentication. |
shared_key |
The shared key for the server. |
#discovery_id identifies a service, and it is built from every member. Two services which differ in any one of them, weight included, are different services, so a changed weight is announced as one removal and one addition of the same server.
How The Services Reach The Owner Plugin
The service discovery plugin helper owns a manager which holds the plugins configured in the <service_discovery> sections. The manager builds the initial set of destinations from #services of every plugin, then polls the queue every 3 seconds by default and applies the messages it finds there. This is where the Service in: and Service out: log lines come from.
The manager skips the polling when every configured plugin is static, since a static list never changes. A plugin registered under any other name is polled.
The owner plugin may keep an object of its own per service instead of the struct. out_forward builds a node which owns the connection to the server, which is why service_discovery_select_service yields that node rather than the Service.
Writing Tests
Fluentd does not provide a test driver for service discovery plugins, so tests instantiate the plugin and drive it directly. The tests of the built-in plugins do the same.
# test/plugin/test_sd_my_list.rb
require 'test/unit'
require 'fileutils'
require 'timeout'
require 'fluent/test'
require 'fluent/test/helpers'
# Your own plugin
require 'fluent/plugin/sd_my_list'
class ServiceDiscoveryMyListTest < Test::Unit::TestCase
include Fluent::Test::Helpers
TMP_DIR = File.expand_path(File.dirname(__FILE__) + '/tmp/sd_my_list')
LIST_PATH = File.join(TMP_DIR, 'list.txt')
Service = Fluent::Plugin::ServiceDiscovery::Service
def setup
Fluent::Test.setup
FileUtils.mkdir_p(TMP_DIR)
end
def teardown
FileUtils.rm_rf(TMP_DIR)
end
def create_plugin(conf)
sd = Fluent::Plugin::MyListServiceDiscovery.new
sd.configure(config_element('service_discovery', '', conf))
sd
end
sub_test_case 'configure' do
test 'reads the list' do
File.write(LIST_PATH, "# host:port\n127.0.0.1:24224\n")
sd = create_plugin('path' => LIST_PATH)
assert_equal([Service.new(:my_list, '127.0.0.1', 24224, '127.0.0.1:24224', 60, false, nil, nil, nil)], sd.services)
end
test 'raises ConfigError for an unreadable path' do
assert_raise(Fluent::ConfigError) do
create_plugin('path' => File.join(TMP_DIR, 'no-such-file'))
end
end
end
sub_test_case 'start' do
test 'pushes the difference onto the queue' do
File.write(LIST_PATH, "127.0.0.1:24224\n")
sd = create_plugin('path' => LIST_PATH, 'interval' => '1')
queue = []
sd.start(queue)
begin
File.write(LIST_PATH, "127.0.0.1:24225\n")
Timeout.timeout(10) { sleep 0.1 until queue.size >= 2 }
ensure
sd.stop; sd.shutdown; sd.close; sd.terminate
end
assert_equal([:service_in, :service_out], queue.map(&:type))
assert_equal(24225, queue[0].service.port)
assert_equal(24224, queue[1].service.port)
end
end
end
config_element comes from Fluent::Test::Helpers, which fluent/test does not load by itself. A plain Array works as the queue in tests, since the plugin only pushes onto it.
Overview of Tests
Testing for service discovery plugins is mainly for:
- Validation of configuration (i.e.
#configure) - Validation of the services built from the configuration
- Validation of the messages pushed while the plugin is running
The lifecycle of the plugin in a test is:
- Instantiate the plugin
- Configure the plugin
- Start the plugin with a queue, and run test code
- Assert the services and the messages on the queue
- Shut the plugin down
For:
- configuration tests, repeat steps # 1-2
- full feature tests, repeat steps # 1-5
See Testing API for Plugins for details.