Add option to bind to activated sockets (#2362)

Bind to (systemd) activated sockets, regardless of configured binds.

Systemd can present sockets as file descriptors that are already opened.
By default Puma will use these but only if it was explicitly told to bind
to the socket. If not, it will close the activated sockets. This means
all configuration is duplicated.

Binds can contain additional configuration, but only SSL config is really
relevant since the unix and TCP socket options are ignored.

This means there is a lot of duplicated configuration for no additional
value in most setups. This option tells the launcher to bind to all
activated sockets, regardless of existing binds.

The special value 'only' can be passed. If systemd activated sockets are
detected, all other binds are cleared. When they aren't detected, the
regular binds will be used.
This commit is contained in:
Ewoud Kohl van Wijngaarden 2020-11-10 18:24:39 +01:00 committed by GitHub
parent 64c0153cd0
commit 5af91ff6aa
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 135 additions and 0 deletions

View File

@ -6,6 +6,7 @@
* Integrate with systemd's watchdog and notification features ([#2438])
* Adds max_fast_inline as a configuration option for the Server object ([#2406])
* You can now fork workers from worker 0 using SIGURG w/o fork_worker enabled [#2449]
* Add option to bind to systemd activated sockets ([#2362])
* Bugfixes
* Your bugfix goes here <Most recent on the top, like GitHub> (#Github Number)

View File

@ -129,6 +129,21 @@ Puma will detect the release path socket as different than the one provided by
systemd and attempt to bind it again, resulting in the exception
`There is already a server bound to:`.
### Binding
By default you need to configure puma to have binds matching with all
ListenStream statements. Any mismatched systemd ListenStreams will be closed by
puma.
To automatically bind to all activated sockets, the option
`--bind-to-activated-sockets` can be used. This matches the config DSL
`bind_to_activated_sockets` statement. This will cause puma to create a bind
automatically for any activated socket. When systemd socket activation is not
enabled, this option does nothing.
This also accepts an optional argument `only` (DSL: `'only'`) to discard any
binds that's not socket activated.
## Usage
Without socket activation, use `systemctl` as root (e.g. via `sudo`) as

View File

@ -111,6 +111,43 @@ module Puma
["LISTEN_FDS", "LISTEN_PID"] # Signal to remove these keys from ENV
end
# Synthesize binds from systemd socket activation
#
# When systemd socket activation is enabled, it can be tedious to keep the
# binds in sync. This method can synthesize any binds based on the received
# activated sockets. Any existing matching binds will be respected.
#
# When only_matching is true in, all binds that do not match an activated
# socket is removed in place.
#
# It's a noop if no activated sockets were received.
def synthesize_binds_from_activated_fs(binds, only_matching)
return binds unless activated_sockets.any?
activated_binds = []
activated_sockets.keys.each do |proto, addr, port|
if port
tcp_url = "#{proto}://#{addr}:#{port}"
ssl_url = "ssl://#{addr}:#{port}"
ssl_url_prefix = "#{ssl_url}?"
existing = binds.find { |bind| bind == tcp_url || bind == ssl_url || bind.start_with?(ssl_url_prefix) }
activated_binds << (existing || tcp_url)
else
# TODO: can there be a SSL bind without a port?
activated_binds << "#{proto}://#{addr}"
end
end
if only_matching
activated_binds
else
binds | activated_binds
end
end
def parse(binds, logger, log_msg = 'Listening')
binds.each do |str|
uri = URI.parse str

View File

@ -104,6 +104,10 @@ module Puma
user_config.bind arg
end
o.on "--bind-to-activated-sockets [only]", "Bind to all activated sockets" do |arg|
user_config.bind_to_activated_sockets(arg || true)
end
o.on "-C", "--config PATH", "Load PATH as a config file" do |arg|
file_config.load arg
end

View File

@ -191,6 +191,32 @@ module Puma
@options[:binds] = []
end
# Bind to (systemd) activated sockets, regardless of configured binds.
#
# Systemd can present sockets as file descriptors that are already opened.
# By default Puma will use these but only if it was explicitly told to bind
# to the socket. If not, it will close the activated sockets. This means
# all configuration is duplicated.
#
# Binds can contain additional configuration, but only SSL config is really
# relevant since the unix and TCP socket options are ignored.
#
# This means there is a lot of duplicated configuration for no additional
# value in most setups. This method tells the launcher to bind to all
# activated sockets, regardless of existing bind.
#
# To clear configured binds, the value only can be passed. This will clear
# out any binds that may have been configured.
#
# @example Use any systemd activated sockets as well as configured binds
# bind_to_activated_sockets
#
# @example Only bind to systemd activated sockets, ignoring other binds
# bind_to_activated_sockets 'only'
def bind_to_activated_sockets(bind=true)
@options[:bind_to_activated_sockets] = bind
end
# Define the TCP port to bind to. Use +bind+ for more advanced options.
#
# @example

View File

@ -58,6 +58,13 @@ module Puma
@config.load
if @config.options[:bind_to_activated_sockets]
@config.options[:binds] = @binder.synthesize_binds_from_activated_fs(
@config.options[:binds],
@config.options[:bind_to_activated_sockets] == 'only'
)
end
@options = @config.options
@config.clamp

View File

@ -34,6 +34,51 @@ end
class TestBinder < TestBinderBase
parallelize_me!
def test_synthesize_binds_from_activated_fds_no_sockets
binds = ['tcp://0.0.0.0:3000']
result = @binder.synthesize_binds_from_activated_fs(binds, true)
assert_equal ['tcp://0.0.0.0:3000'], result
end
def test_synthesize_binds_from_activated_fds_non_matching_together
binds = ['tcp://0.0.0.0:3000']
sockets = {['tcp', '0.0.0.0', '5000'] => nil}
@binder.instance_variable_set(:@activated_sockets, sockets)
result = @binder.synthesize_binds_from_activated_fs(binds, false)
assert_equal ['tcp://0.0.0.0:3000', 'tcp://0.0.0.0:5000'], result
end
def test_synthesize_binds_from_activated_fds_non_matching_only
binds = ['tcp://0.0.0.0:3000']
sockets = {['tcp', '0.0.0.0', '5000'] => nil}
@binder.instance_variable_set(:@activated_sockets, sockets)
result = @binder.synthesize_binds_from_activated_fs(binds, true)
assert_equal ['tcp://0.0.0.0:5000'], result
end
def test_synthesize_binds_from_activated_fds_complex_binds
binds = [
'tcp://0.0.0.0:3000',
'ssl://192.0.2.100:5000',
'ssl://192.0.2.101:5000?no_tlsv1=true',
'unix:///run/puma.sock'
]
sockets = {
['tcp', '0.0.0.0', '5000'] => nil,
['tcp', '192.0.2.100', '5000'] => nil,
['tcp', '192.0.2.101', '5000'] => nil,
['unix', '/run/puma.sock'] => nil
}
@binder.instance_variable_set(:@activated_sockets, sockets)
result = @binder.synthesize_binds_from_activated_fs(binds, false)
expected = ['tcp://0.0.0.0:3000', 'ssl://192.0.2.100:5000', 'ssl://192.0.2.101:5000?no_tlsv1=true', 'unix:///run/puma.sock', 'tcp://0.0.0.0:5000']
assert_equal expected, result
end
def test_localhost_addresses_dont_alter_listeners_for_tcp_addresses
@binder.parse ["tcp://localhost:0"], @events