kicks_liveness 0.1.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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +21 -0
- data/LICENSE.txt +21 -0
- data/README.md +319 -0
- data/docs/DESIGN.md +325 -0
- data/docs/KUBERNETES.md +426 -0
- data/docs/LIMITATIONS.md +141 -0
- data/docs/SETUP.md +181 -0
- data/docs/VERIFYING.md +330 -0
- data/exe/kicks-liveness +3 -0
- data/lib/kicks_liveness/attempts.rb +86 -0
- data/lib/kicks_liveness/configuration.rb +83 -0
- data/lib/kicks_liveness/heartbeat.rb +178 -0
- data/lib/kicks_liveness/hooks.rb +93 -0
- data/lib/kicks_liveness/monitor.rb +178 -0
- data/lib/kicks_liveness/probe.rb +16 -0
- data/lib/kicks_liveness/railtie.rb +18 -0
- data/lib/kicks_liveness/registry.rb +102 -0
- data/lib/kicks_liveness/version.rb +4 -0
- data/lib/kicks_liveness.rb +86 -0
- metadata +72 -0
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
module KicksLiveness
|
|
2
|
+
# The liveness mark on the filesystem: written by the worker, read by the
|
|
3
|
+
# probe.
|
|
4
|
+
#
|
|
5
|
+
# The directory must live on tmpfs — in Kubernetes, an emptyDir with
|
|
6
|
+
# <tt>medium: Memory</tt>. Put it on a real disk and the probe starts
|
|
7
|
+
# depending on the disk again, and a stalling disk is one of the most common
|
|
8
|
+
# causes of false restarts.
|
|
9
|
+
#
|
|
10
|
+
# This file deliberately runs no +require+ on the probe's path and refers to
|
|
11
|
+
# nothing else in the gem: the probe can load it alone, with no application
|
|
12
|
+
# code and no Rails behind it. That keeps the optional direct form at ~43 ms
|
|
13
|
+
# and lets it run on a bare interpreter with +--disable-gems+ when the image
|
|
14
|
+
# allows it -- whether Bundler is in the picture depends on where the image
|
|
15
|
+
# put its gems, not on this file. The one +require+ it does contain is lazy,
|
|
16
|
+
# in a branch only the worker reaches.
|
|
17
|
+
#
|
|
18
|
+
# @see file:docs/DESIGN.md#why-the-heartbeat-file-has-no-require-of-its-own
|
|
19
|
+
class Heartbeat
|
|
20
|
+
# @return [String] marks directory used when the environment says nothing
|
|
21
|
+
DEFAULT_DIR = '/opt/app/tmp/health'.freeze
|
|
22
|
+
# @return [Integer] seconds after which a mark is stale, by default
|
|
23
|
+
DEFAULT_MAX_AGE = 45
|
|
24
|
+
|
|
25
|
+
# @return [Hash{Symbol => String}] setting name to environment variable
|
|
26
|
+
ENV_NAMES = {
|
|
27
|
+
dir: 'KICKS_LIVENESS_DIR',
|
|
28
|
+
max_age: 'KICKS_LIVENESS_MAX_AGE',
|
|
29
|
+
tick: 'KICKS_LIVENESS_TICK'
|
|
30
|
+
}.freeze
|
|
31
|
+
|
|
32
|
+
# An empty string counts as unset: in a ConfigMap that is what you get by
|
|
33
|
+
# declaring a key and leaving it blank.
|
|
34
|
+
#
|
|
35
|
+
# @param key [Symbol] one of the keys of {ENV_NAMES}
|
|
36
|
+
# @return [String, nil]
|
|
37
|
+
def self.env_raw(key)
|
|
38
|
+
value = ENV.fetch(ENV_NAMES.fetch(key), nil)
|
|
39
|
+
value unless value.nil? || value.empty?
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Values arrive from a ConfigMap, and a typo there is no reason to bring a
|
|
43
|
+
# worker down — garbage falls back to the default.
|
|
44
|
+
#
|
|
45
|
+
# Both settings are durations, so a value has to be parseable *and*
|
|
46
|
+
# positive. Zero and negative numbers parse perfectly well and are the more
|
|
47
|
+
# dangerous half: a tick of zero turns the monitor into a hot loop, a
|
|
48
|
+
# negative one used to kill the monitor thread on its first sleep, and a
|
|
49
|
+
# negative max_age makes every mark stale on arrival, so the probe can never
|
|
50
|
+
# pass again.
|
|
51
|
+
#
|
|
52
|
+
# @param key [Symbol] one of the keys of {ENV_NAMES}
|
|
53
|
+
# @param default [Integer]
|
|
54
|
+
# @return [Integer] the value from the environment when it is a positive
|
|
55
|
+
# integer, the default otherwise
|
|
56
|
+
def self.env_int(key, default)
|
|
57
|
+
value = Integer(env_raw(key) || default)
|
|
58
|
+
value.positive? ? value : default
|
|
59
|
+
rescue ArgumentError, TypeError
|
|
60
|
+
default
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# @return [String] marks directory
|
|
64
|
+
def self.env_dir
|
|
65
|
+
env_raw(:dir) || DEFAULT_DIR
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# @return [Integer] seconds after which a mark is considered stale
|
|
69
|
+
def self.env_max_age
|
|
70
|
+
env_int(:max_age, DEFAULT_MAX_AGE)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# @param dir [String] marks directory
|
|
74
|
+
# @param max_age [Integer] seconds after which a mark is considered stale
|
|
75
|
+
def initialize(dir: Heartbeat.env_dir, max_age: Heartbeat.env_max_age)
|
|
76
|
+
@dir = dir
|
|
77
|
+
@max_age = max_age
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
attr_reader :dir, :max_age
|
|
81
|
+
|
|
82
|
+
# Records how many forks the probe must wait for. Every fork writes the same
|
|
83
|
+
# value. Without it the probe would pass as soon as any single mark was
|
|
84
|
+
# fresh, while half the workers had not subscribed yet.
|
|
85
|
+
#
|
|
86
|
+
# Written through a rename, because a plain write truncates first: a probe
|
|
87
|
+
# reading in that window finds the file empty and reports that the worker
|
|
88
|
+
# has not started. The window is real and recurring — every fork rewrites
|
|
89
|
+
# this file on every tick, while the liveness probe reads it on a schedule
|
|
90
|
+
# of its own. Rename is atomic on tmpfs.
|
|
91
|
+
#
|
|
92
|
+
# @param processes [Integer]
|
|
93
|
+
# @return [void]
|
|
94
|
+
def declare!(processes)
|
|
95
|
+
make_dir
|
|
96
|
+
# The pid keeps concurrent forks from sharing the temporary file.
|
|
97
|
+
tmp = "#{expected_path}.#{Process.pid}"
|
|
98
|
+
File.write(tmp, processes)
|
|
99
|
+
File.rename(tmp, expected_path)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Refreshes this fork's mark.
|
|
103
|
+
#
|
|
104
|
+
# The file is named by supervisor slot, not by PID: a fork killed with
|
|
105
|
+
# SIGKILL is respawned into the same slot and overwrites its own file. With a
|
|
106
|
+
# PID in the name that file would stay stale forever and the probe would fail
|
|
107
|
+
# permanently.
|
|
108
|
+
#
|
|
109
|
+
# The contents exist only for a human running <tt>kubectl exec ... cat</tt>;
|
|
110
|
+
# the probe decides on mtime alone.
|
|
111
|
+
#
|
|
112
|
+
# @param slot [Integer] supervisor slot of this fork
|
|
113
|
+
# @return [Integer] bytes written
|
|
114
|
+
def touch!(slot)
|
|
115
|
+
make_dir
|
|
116
|
+
File.write(slot_path(slot), "#{Time.now.utc.strftime('%FT%TZ')} pid=#{Process.pid} slot=#{slot}\n")
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# The probe side: is every declared fork's mark present and fresh?
|
|
120
|
+
#
|
|
121
|
+
# The returned message names slots the way the files are named, so that a
|
|
122
|
+
# human reading the Unhealthy event knows which file to look at.
|
|
123
|
+
#
|
|
124
|
+
# @param now [Time] injected in specs
|
|
125
|
+
# @return [Array(Boolean, String)] health, and the reason to print on stdout
|
|
126
|
+
def check(now: Time.now.utc)
|
|
127
|
+
processes = expected
|
|
128
|
+
return [false, "no #{expected_path}: worker has not started yet"] unless processes&.positive?
|
|
129
|
+
|
|
130
|
+
problems = (0...processes).filter_map do |slot|
|
|
131
|
+
age = age_of(slot_path(slot), now)
|
|
132
|
+
next "worker-#{slot} missing" if age.nil?
|
|
133
|
+
|
|
134
|
+
"worker-#{slot} stale #{age.round}s > #{@max_age}s" if age > @max_age
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
problems.empty? ? [true, "#{processes} process(es) healthy"] : [false, problems.join('; ')]
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
private
|
|
141
|
+
|
|
142
|
+
# One syscall on the happy path, and whichever fork gets there first wins.
|
|
143
|
+
#
|
|
144
|
+
# +Dir.mkdir+ creates a single level and raises ENOENT when the parent is
|
|
145
|
+
# missing, which any nested KICKS_LIVENESS_DIR hits. FileUtils is required
|
|
146
|
+
# here rather than at the top of the file: only the worker ever creates the
|
|
147
|
+
# directory, and the probe's path through {#check} must stay free of
|
|
148
|
+
# requires.
|
|
149
|
+
def make_dir
|
|
150
|
+
Dir.mkdir(@dir)
|
|
151
|
+
rescue Errno::EEXIST
|
|
152
|
+
nil
|
|
153
|
+
rescue Errno::ENOENT
|
|
154
|
+
require 'fileutils'
|
|
155
|
+
FileUtils.mkdir_p(@dir)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def expected_path
|
|
159
|
+
File.join(@dir, 'expected')
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def slot_path(slot)
|
|
163
|
+
File.join(@dir, "worker-#{slot}")
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def expected
|
|
167
|
+
Integer(File.read(expected_path))
|
|
168
|
+
rescue StandardError
|
|
169
|
+
nil
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def age_of(path, now)
|
|
173
|
+
now - File.mtime(path)
|
|
174
|
+
rescue StandardError
|
|
175
|
+
nil
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
module KicksLiveness
|
|
2
|
+
# The two modules prepended into the worker gem. Installed together by
|
|
3
|
+
# {KicksLiveness.install!}.
|
|
4
|
+
#
|
|
5
|
+
# @see file:docs/DESIGN.md#where-the-hooks-attach
|
|
6
|
+
# @api private
|
|
7
|
+
module Hooks
|
|
8
|
+
# Prepended to +Sneakers::Worker+. The worker is registered only after a
|
|
9
|
+
# successful subscribe: if +subscribe+ raised, it never reaches the registry
|
|
10
|
+
# and the probe honestly fails.
|
|
11
|
+
# @api private
|
|
12
|
+
module Worker
|
|
13
|
+
# @return [void]
|
|
14
|
+
def run
|
|
15
|
+
super
|
|
16
|
+
Registry.add(self)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# @return [void]
|
|
20
|
+
def stop
|
|
21
|
+
Registry.remove(self)
|
|
22
|
+
super
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Prepended to +Sneakers::WorkerGroup+.
|
|
27
|
+
#
|
|
28
|
+
# +ServerEngine::Server#create_worker+ calls +w.extend(worker_module)+, so
|
|
29
|
+
# +WorkerGroup+ lands in the singleton class of the instance rather than
|
|
30
|
+
# being included in a class. A prepend to the module still sits ahead of it
|
|
31
|
+
# in that lookup chain; +worker_id+ and +config+ come from
|
|
32
|
+
# +ServerEngine::Worker+.
|
|
33
|
+
#
|
|
34
|
+
# @see file:docs/DESIGN.md#where-the-hooks-attach
|
|
35
|
+
# @api private
|
|
36
|
+
module WorkerGroup
|
|
37
|
+
# Starts the monitor from +after_fork+ rather than from +run+.
|
|
38
|
+
#
|
|
39
|
+
# +Sneakers::WorkerGroup#run+ calls +after_fork+ as its very first
|
|
40
|
+
# statement and only afterwards resolves +worker_classes+, so this is the
|
|
41
|
+
# earliest point in the fork at which the application's own +after_fork+
|
|
42
|
+
# hook has already run. That ordering matters because resolving the
|
|
43
|
+
# expected consumer count may run application code: under
|
|
44
|
+
# sneakers:active_job the set is a callable registry, and the standard use
|
|
45
|
+
# of +after_fork+ is to re-establish the connections a fork inherited.
|
|
46
|
+
# Resolving it first would run that code against a fork that is not ready.
|
|
47
|
+
#
|
|
48
|
+
# Wrapped in a rescue: instrumentation has no right to prevent a worker
|
|
49
|
+
# from starting, so the worst outcome is a missing mark and a pod restart
|
|
50
|
+
# rather than a pod that stays up with silent queues.
|
|
51
|
+
#
|
|
52
|
+
# @return [void]
|
|
53
|
+
def after_fork
|
|
54
|
+
super
|
|
55
|
+
|
|
56
|
+
begin
|
|
57
|
+
KicksLiveness.start!(
|
|
58
|
+
slot: worker_id,
|
|
59
|
+
processes: config[:workers] || 1,
|
|
60
|
+
consumers: kicks_liveness_expected_consumers
|
|
61
|
+
)
|
|
62
|
+
rescue StandardError => e
|
|
63
|
+
KicksLiveness.config.resolved_logger&.error("[liveness] failed to start: #{e.class}: #{e.message}")
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Marks the shutdown as deliberate before the workers unsubscribe.
|
|
68
|
+
#
|
|
69
|
+
# +Sneakers::WorkerGroup#stop+ calls +stop+ on each worker, and
|
|
70
|
+
# +Sneakers::Worker#stop+ waits for the thread pool to drain, which takes
|
|
71
|
+
# as long as the longest in-flight job. The registry empties at the start
|
|
72
|
+
# of that, so without this flag the monitor would report a fault on every
|
|
73
|
+
# ordinary deploy.
|
|
74
|
+
#
|
|
75
|
+
# @return [void]
|
|
76
|
+
def stop
|
|
77
|
+
Registry.stopping!
|
|
78
|
+
super
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
# The same set the worker gem itself builds its workers from, so the
|
|
84
|
+
# queue list is never duplicated and cannot drift. An array of classes
|
|
85
|
+
# under sneakers:run, a callable registry under sneakers:active_job.
|
|
86
|
+
def kicks_liveness_expected_consumers
|
|
87
|
+
classes = config[:worker_classes]
|
|
88
|
+
classes = classes.call if classes.respond_to?(:call)
|
|
89
|
+
classes.size
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
module KicksLiveness
|
|
2
|
+
# The thread that publishes the liveness mark. Created inside the fork,
|
|
3
|
+
# because threads do not survive +fork+.
|
|
4
|
+
#
|
|
5
|
+
# @see file:docs/DESIGN.md#logging-events-not-the-pulse
|
|
6
|
+
# @api private
|
|
7
|
+
class Monitor
|
|
8
|
+
# @param slot [Integer] supervisor slot of this fork; names the mark file
|
|
9
|
+
# @param processes [Integer] how many forks the probe must wait for
|
|
10
|
+
# @param consumers [Integer] how many workers must subscribe in this process
|
|
11
|
+
# @param config [Configuration]
|
|
12
|
+
# @param heartbeat [Heartbeat, nil] injected in specs; {Attempts} follows its
|
|
13
|
+
# directory, so injecting it separately is not needed
|
|
14
|
+
def initialize(slot:, processes:, consumers:, config:, heartbeat: nil)
|
|
15
|
+
@slot = slot
|
|
16
|
+
@processes = processes
|
|
17
|
+
@consumers = consumers
|
|
18
|
+
@config = config
|
|
19
|
+
@heartbeat = heartbeat || Heartbeat.new
|
|
20
|
+
@attempts = Attempts.new(@heartbeat.dir)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Declares the expected fork count and starts the tick thread.
|
|
24
|
+
# @return [Thread]
|
|
25
|
+
def start!
|
|
26
|
+
attempt, elapsed = @attempts.record!(@slot)
|
|
27
|
+
@heartbeat.declare!(@processes)
|
|
28
|
+
attempt > 1 ? report_respawn(attempt, elapsed) : report_first_start
|
|
29
|
+
|
|
30
|
+
Thread.new do
|
|
31
|
+
Thread.current.name = 'kicks-liveness'
|
|
32
|
+
run_loop
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# A single step: check, mark if healthy, log any transition. Public so that
|
|
37
|
+
# specs do not have to drive the thread.
|
|
38
|
+
#
|
|
39
|
+
# @return [Boolean] whether the process is healthy at this moment
|
|
40
|
+
def tick!
|
|
41
|
+
# Re-declared on every tick, not only at startup. This file is the probe's
|
|
42
|
+
# only source for how many forks to expect, and nothing else restores it:
|
|
43
|
+
# if the directory is wiped, `touch!` brings back the slot marks while
|
|
44
|
+
# `expected` stays missing, and the probe reports "worker has not started"
|
|
45
|
+
# for the rest of the pod's life. It also lets a respawned set of forks
|
|
46
|
+
# correct the count after the supervisor was told to run fewer of them.
|
|
47
|
+
@heartbeat.declare!(@processes)
|
|
48
|
+
|
|
49
|
+
if Registry.stopping?
|
|
50
|
+
shutdown_tick!
|
|
51
|
+
return true
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
healthy = Registry.healthy?(@consumers)
|
|
55
|
+
if healthy
|
|
56
|
+
@heartbeat.touch!(@slot)
|
|
57
|
+
clear_attempt_once!
|
|
58
|
+
end
|
|
59
|
+
@unhealthy_ticks = healthy ? 0 : unhealthy_ticks + 1
|
|
60
|
+
report(healthy)
|
|
61
|
+
healthy
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def report_first_start
|
|
67
|
+
log(:info, "started: dir=#{@heartbeat.dir} max_age=#{@heartbeat.max_age}s tick=#{@config.tick}s " \
|
|
68
|
+
"processes=#{@processes} consumers=#{@consumers}")
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# A slot that starts again without ever having become healthy is a respawn
|
|
72
|
+
# loop: the supervisor brings the fork back after `start_worker_delay`,
|
|
73
|
+
# which is a fraction of a second, so the process dies long before any
|
|
74
|
+
# single monitor's grace period expires. Two things follow.
|
|
75
|
+
#
|
|
76
|
+
# The start line and the first transition are suppressed. Seeding
|
|
77
|
+
# `@previous` is what does the second part: without it the first tick reads
|
|
78
|
+
# a nil previous state and logs `waiting for N consumers`, which in a storm
|
|
79
|
+
# means hundreds of identical INFO lines a minute and no diagnosis in any of
|
|
80
|
+
# them.
|
|
81
|
+
#
|
|
82
|
+
# The escalation is driven off the marks directory instead of off
|
|
83
|
+
# `unhealthy_ticks`, because that counter dies with the fork and can never
|
|
84
|
+
# reach the threshold. Reporting once per grace window rather than once per
|
|
85
|
+
# respawn is the difference between one line a minute and five a second.
|
|
86
|
+
def report_respawn(attempt, elapsed)
|
|
87
|
+
@previous = false
|
|
88
|
+
|
|
89
|
+
return if elapsed < grace_seconds
|
|
90
|
+
|
|
91
|
+
log(:error, "not healthy #{elapsed.round}s over #{attempt} starts, expected #{@consumers} consumers")
|
|
92
|
+
@attempts.restart_window!(@slot)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def grace_seconds
|
|
96
|
+
@config.startup_grace_ticks * interval
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Once the slot is healthy the respawn counter has done its job, and the
|
|
100
|
+
# next start of this slot deserves to be treated as a fresh one.
|
|
101
|
+
def clear_attempt_once!
|
|
102
|
+
return if @attempt_cleared
|
|
103
|
+
|
|
104
|
+
@attempts.clear!(@slot)
|
|
105
|
+
@attempt_cleared = true
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def unhealthy_ticks
|
|
109
|
+
@unhealthy_ticks ||= 0
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# From the moment shutdown begins the consumer count says nothing about
|
|
113
|
+
# health: the workers unsubscribe one by one while the pool drains, and the
|
|
114
|
+
# process is doing exactly what it was told. So the predicate is dropped and
|
|
115
|
+
# the mark is kept fresh, which leaves a slow drain the whole of
|
|
116
|
+
# terminationGracePeriodSeconds instead of having the probe cut it short at
|
|
117
|
+
# max_age.
|
|
118
|
+
def shutdown_tick!
|
|
119
|
+
@heartbeat.touch!(@slot)
|
|
120
|
+
|
|
121
|
+
return if @shutdown_reported
|
|
122
|
+
|
|
123
|
+
log(:info, 'shutting down: consumers are no longer checked')
|
|
124
|
+
@shutdown_reported = true
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Nothing inside this loop may be allowed to kill the thread. It is the only
|
|
128
|
+
# thing writing the mark, and its death is close to invisible: the exception
|
|
129
|
+
# surfaces as a bare backtrace on stderr, never through the gem's logger,
|
|
130
|
+
# while the pod restarts on every probe budget with nothing in the
|
|
131
|
+
# application log to explain it. `sleep` used to sit outside the guarded
|
|
132
|
+
# block, which is exactly how a non-positive tick killed the monitor.
|
|
133
|
+
def run_loop
|
|
134
|
+
loop do
|
|
135
|
+
tick!
|
|
136
|
+
sleep interval
|
|
137
|
+
rescue StandardError => e
|
|
138
|
+
log(:error, "monitor loop failed: #{e.class}: #{e.message}")
|
|
139
|
+
sleep Configuration::DEFAULT_TICK
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Values coming from the environment are already clamped by
|
|
144
|
+
# {Heartbeat.env_int}; this covers a tick set programmatically.
|
|
145
|
+
def interval
|
|
146
|
+
tick = @config.tick
|
|
147
|
+
tick.is_a?(Numeric) && tick.positive? ? tick : Configuration::DEFAULT_TICK
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Events are logged, not the pulse — the pulse lives in the mark's mtime.
|
|
151
|
+
# ERROR is reserved for genuine failure, because a logger usually comes up
|
|
152
|
+
# with LOG_LEVEL defaulting to error.
|
|
153
|
+
def report(healthy)
|
|
154
|
+
if healthy != @previous
|
|
155
|
+
report_transition(healthy)
|
|
156
|
+
@previous = healthy
|
|
157
|
+
elsif unhealthy_ticks == @config.startup_grace_ticks
|
|
158
|
+
# Startup took longer than normal, which is a failure by now. Staying
|
|
159
|
+
# silent would leave a pod that never comes up saying nothing about it.
|
|
160
|
+
log(:error, "not healthy #{unhealthy_ticks * @config.tick}s, expected #{@consumers} consumers")
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def report_transition(healthy)
|
|
165
|
+
if healthy
|
|
166
|
+
log(:info, 'healthy')
|
|
167
|
+
elsif @previous.nil?
|
|
168
|
+
log(:info, "waiting for #{@consumers} consumers")
|
|
169
|
+
else
|
|
170
|
+
log(:error, "became unhealthy (expected #{@consumers} consumers)")
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def log(level, message)
|
|
175
|
+
@config.resolved_logger&.public_send(level, "[liveness] slot #{@slot}: #{message}")
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Probe entry point. Requiring this file performs the check and terminates the
|
|
2
|
+
# process, so do not require it from an application.
|
|
3
|
+
#
|
|
4
|
+
# In a container manifest:
|
|
5
|
+
# command: ["bundle", "exec", "kicks-liveness"]
|
|
6
|
+
#
|
|
7
|
+
# The gem namespace is deliberately not loaded: this pulls in one
|
|
8
|
+
# dependency-free file. Images that install gems in +GEM_HOME+ can invoke this
|
|
9
|
+
# file directly as a faster, optional form; see
|
|
10
|
+
# docs/KUBERNETES.md#where-your-image-puts-its-gems.
|
|
11
|
+
require_relative 'heartbeat'
|
|
12
|
+
|
|
13
|
+
ok, message = KicksLiveness::Heartbeat.new.check
|
|
14
|
+
|
|
15
|
+
puts message
|
|
16
|
+
exit(ok ? 0 : 1)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
require 'rails/railtie'
|
|
2
|
+
|
|
3
|
+
module KicksLiveness
|
|
4
|
+
# Installs the hooks by itself, so a Rails application only needs the
|
|
5
|
+
# configuration block.
|
|
6
|
+
#
|
|
7
|
+
# +to_prepare+ rather than an initializer: an application's lib is normally
|
|
8
|
+
# managed by Zeitwerk and reloadable, and such constants must not be
|
|
9
|
+
# referenced while the application is initialising.
|
|
10
|
+
#
|
|
11
|
+
# @see file:docs/SETUP.md#rails
|
|
12
|
+
# @api private
|
|
13
|
+
class Railtie < ::Rails::Railtie
|
|
14
|
+
initializer 'kicks_liveness.install' do |app|
|
|
15
|
+
app.config.to_prepare { KicksLiveness.install! }
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
module KicksLiveness
|
|
2
|
+
# Registry of the workers that have subscribed in the current process, and the
|
|
3
|
+
# health predicate computed over them.
|
|
4
|
+
#
|
|
5
|
+
# The predicate reads only in-process state: no network calls, no disk access.
|
|
6
|
+
# @see file:docs/DESIGN.md#the-health-predicate
|
|
7
|
+
# @api private
|
|
8
|
+
module Registry
|
|
9
|
+
# Initialised at file-load time: single-threaded, and before any fork.
|
|
10
|
+
@workers = []
|
|
11
|
+
@mutex = Mutex.new
|
|
12
|
+
@stopping = false
|
|
13
|
+
|
|
14
|
+
class << self
|
|
15
|
+
# Records a worker as subscribed.
|
|
16
|
+
#
|
|
17
|
+
# Registering the same object twice would break the size comparison in
|
|
18
|
+
# {healthy?} and leave the process permanently unhealthy, so identity is
|
|
19
|
+
# checked first. +equal?+ rather than +==+ on purpose: this is a registry
|
|
20
|
+
# of objects, and a worker class is free to define equality however it
|
|
21
|
+
# likes.
|
|
22
|
+
#
|
|
23
|
+
# @param worker [Sneakers::Worker]
|
|
24
|
+
# @return [Array] the registry
|
|
25
|
+
def add(worker)
|
|
26
|
+
@mutex.synchronize do
|
|
27
|
+
@workers << worker unless @workers.any? { |registered| registered.equal?(worker) }
|
|
28
|
+
@workers
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Drops a worker from the registry, on shutdown.
|
|
33
|
+
# @param worker [Sneakers::Worker]
|
|
34
|
+
# @return [Sneakers::Worker, nil]
|
|
35
|
+
def remove(worker)
|
|
36
|
+
@mutex.synchronize { @workers.delete(worker) }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @return [Integer] how many workers are currently registered
|
|
40
|
+
def size
|
|
41
|
+
@mutex.synchronize { @workers.size }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Records that this process is shutting down on purpose.
|
|
45
|
+
#
|
|
46
|
+
# Without it a graceful shutdown is indistinguishable from a failure: the
|
|
47
|
+
# registry empties as each worker unsubscribes, so the monitor would see
|
|
48
|
+
# an incomplete set and report a genuine fault on every deploy.
|
|
49
|
+
#
|
|
50
|
+
# @return [true]
|
|
51
|
+
def stopping!
|
|
52
|
+
@stopping = true
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# @return [Boolean] whether shutdown has begun
|
|
56
|
+
def stopping?
|
|
57
|
+
@stopping
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Whether this process is consuming everything it is supposed to consume.
|
|
61
|
+
#
|
|
62
|
+
# The registry size is compared against +expected+ rather than only
|
|
63
|
+
# checking the workers that did register: four healthy workers out of five
|
|
64
|
+
# look fine one object at a time.
|
|
65
|
+
#
|
|
66
|
+
# @param expected [Integer] how many workers must have subscribed in this process
|
|
67
|
+
# @return [Boolean]
|
|
68
|
+
def healthy?(expected)
|
|
69
|
+
workers = @mutex.synchronize { @workers.dup }
|
|
70
|
+
return false unless expected.positive? && workers.size == expected
|
|
71
|
+
|
|
72
|
+
workers.all? { |worker| alive?(worker) }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def alive?(worker)
|
|
78
|
+
channel = worker.queue.channel
|
|
79
|
+
# No subscription yet; the startup probe is what holds the pod.
|
|
80
|
+
return false if channel.nil?
|
|
81
|
+
|
|
82
|
+
connection = channel.connection
|
|
83
|
+
return true if recovering?(connection)
|
|
84
|
+
|
|
85
|
+
# Connection open but no consumers: the worker silently stopped
|
|
86
|
+
# consuming, and a restart is the only cure.
|
|
87
|
+
channel.open? && connection.open? && channel.any_consumers?
|
|
88
|
+
rescue StandardError
|
|
89
|
+
false
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Bunny marks this method @private, so an upgrade may remove it. The
|
|
93
|
+
# guard keeps that from turning every tick into NoMethodError -> rescued
|
|
94
|
+
# -> unhealthy; the cost is that the recovery exemption would then vanish
|
|
95
|
+
# silently.
|
|
96
|
+
def recovering?(connection)
|
|
97
|
+
connection.respond_to?(:recovering_from_network_failure?) &&
|
|
98
|
+
connection.recovering_from_network_failure?
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
require_relative 'kicks_liveness/version'
|
|
2
|
+
require_relative 'kicks_liveness/heartbeat'
|
|
3
|
+
require_relative 'kicks_liveness/attempts'
|
|
4
|
+
require_relative 'kicks_liveness/configuration'
|
|
5
|
+
require_relative 'kicks_liveness/registry'
|
|
6
|
+
require_relative 'kicks_liveness/monitor'
|
|
7
|
+
require_relative 'kicks_liveness/hooks'
|
|
8
|
+
|
|
9
|
+
# Liveness probe for Kicks and Sneakers workers, backed by a tmpfs heartbeat.
|
|
10
|
+
#
|
|
11
|
+
# The worker publishes a mark from inside its own process, checking its Bunny
|
|
12
|
+
# consumers in memory; the probe reads only the mark's mtime. No Rails, no call
|
|
13
|
+
# to the broker.
|
|
14
|
+
#
|
|
15
|
+
# @see file:docs/SETUP.md
|
|
16
|
+
# @see file:docs/DESIGN.md
|
|
17
|
+
module KicksLiveness
|
|
18
|
+
class << self
|
|
19
|
+
# @return [Configuration] the process-wide configuration
|
|
20
|
+
def config
|
|
21
|
+
@config ||= Configuration.new
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Yields the configuration for the application to adjust.
|
|
25
|
+
#
|
|
26
|
+
# @example
|
|
27
|
+
# KicksLiveness.configure do |config|
|
|
28
|
+
# config.enabled = !Rails.env.local?
|
|
29
|
+
# end
|
|
30
|
+
#
|
|
31
|
+
# @yieldparam config [Configuration]
|
|
32
|
+
# @return [Configuration]
|
|
33
|
+
def configure
|
|
34
|
+
yield(config)
|
|
35
|
+
config
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Installs the two hooks the gem works through. Must run before the runner
|
|
39
|
+
# starts; in Rails a Railtie does it. Idempotent — prepending the same
|
|
40
|
+
# module twice is a no-op.
|
|
41
|
+
#
|
|
42
|
+
# Two requires, not one: +Sneakers::Worker+ is defined by lib/sneakers.rb,
|
|
43
|
+
# while +Sneakers::WorkerGroup+ comes only with sneakers/workergroup, which
|
|
44
|
+
# ships with the runner and is absent in web processes. Relying on the
|
|
45
|
+
# application having required sneakers itself is not safe: with
|
|
46
|
+
# <tt>gem 'kicks', require: false</tt> the to_prepare hook runs earlier and
|
|
47
|
+
# would fail on NameError.
|
|
48
|
+
#
|
|
49
|
+
# @raise [LoadError] if neither +kicks+ nor +sneakers+ is available
|
|
50
|
+
# @return [Module]
|
|
51
|
+
# @see file:docs/SETUP.md#installing-the-hooks
|
|
52
|
+
def install!
|
|
53
|
+
begin
|
|
54
|
+
require 'sneakers'
|
|
55
|
+
require 'sneakers/workergroup'
|
|
56
|
+
rescue LoadError => e
|
|
57
|
+
# The original message is kept: this rescue fires just as readily when
|
|
58
|
+
# the worker gem is installed but something inside it fails to load — a
|
|
59
|
+
# bunny or serverengine version that cannot be required, an extension
|
|
60
|
+
# not built for the image. Reporting that as "the gem is missing" sends
|
|
61
|
+
# the reader to check a Gemfile.lock that is perfectly fine.
|
|
62
|
+
raise LoadError, 'kicks_liveness requires the kicks (>= 3.0) or sneakers (>= 2.11) gem ' \
|
|
63
|
+
"(#{e.message})"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
::Sneakers::Worker.prepend(Hooks::Worker)
|
|
67
|
+
::Sneakers::WorkerGroup.prepend(Hooks::WorkerGroup)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Starts the monitor thread. Called from the WorkerGroup hook, already
|
|
71
|
+
# inside the fork.
|
|
72
|
+
#
|
|
73
|
+
# @param slot [Integer] supervisor slot of this fork
|
|
74
|
+
# @param processes [Integer] how many forks the probe must wait for
|
|
75
|
+
# @param consumers [Integer] how many workers must subscribe here
|
|
76
|
+
# @return [Thread, nil] nil when disabled by configuration
|
|
77
|
+
# @api private
|
|
78
|
+
def start!(slot:, processes:, consumers:)
|
|
79
|
+
return unless config.enabled?
|
|
80
|
+
|
|
81
|
+
Monitor.new(slot: slot, processes: processes, consumers: consumers, config: config).start!
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
require_relative 'kicks_liveness/railtie' if defined?(Rails::Railtie)
|