kicks_liveness 0.1.2 → 0.1.4
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 +4 -4
- data/CHANGELOG.md +74 -1
- data/README.md +21 -6
- data/docs/DESIGN.md +133 -188
- data/docs/KUBERNETES.md +40 -13
- data/docs/LIMITATIONS.md +154 -61
- data/docs/SETUP.md +30 -0
- data/docs/VERIFYING.md +269 -13
- data/lib/kicks_liveness/attempts.rb +21 -16
- data/lib/kicks_liveness/configuration.rb +24 -9
- data/lib/kicks_liveness/heartbeat.rb +241 -111
- data/lib/kicks_liveness/hooks.rb +11 -29
- data/lib/kicks_liveness/monitor.rb +50 -51
- data/lib/kicks_liveness/registry.rb +17 -19
- data/lib/kicks_liveness/topology_report.rb +110 -0
- data/lib/kicks_liveness/version.rb +1 -1
- data/lib/kicks_liveness.rb +3 -1
- metadata +3 -2
|
@@ -17,15 +17,18 @@ module KicksLiveness
|
|
|
17
17
|
@consumers = consumers
|
|
18
18
|
@config = config
|
|
19
19
|
@heartbeat = heartbeat || Heartbeat.new
|
|
20
|
-
@attempts = Attempts.new(@heartbeat.dir)
|
|
20
|
+
@attempts = Attempts.new(@heartbeat.dir, generation: @heartbeat.topology.generation)
|
|
21
21
|
end
|
|
22
22
|
|
|
23
23
|
# Declares the expected fork count and starts the tick thread.
|
|
24
|
-
# @return [Thread]
|
|
24
|
+
# @return [Thread, nil] nil when the PID topology is proven unsupported
|
|
25
25
|
def start!
|
|
26
|
+
return reject_topology! if @heartbeat.topology.unsafe?
|
|
27
|
+
|
|
26
28
|
attempt, elapsed = @attempts.record!(@slot)
|
|
27
29
|
@heartbeat.declare!(@processes)
|
|
28
30
|
attempt > 1 ? report_respawn(attempt, elapsed) : report_first_start
|
|
31
|
+
report_topology
|
|
29
32
|
|
|
30
33
|
Thread.new do
|
|
31
34
|
Thread.current.name = 'kicks-liveness'
|
|
@@ -38,18 +41,11 @@ module KicksLiveness
|
|
|
38
41
|
#
|
|
39
42
|
# @return [Boolean] whether the process is healthy at this moment
|
|
40
43
|
def tick!
|
|
41
|
-
#
|
|
42
|
-
#
|
|
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.
|
|
44
|
+
# Restore expected after directory removal; refreshing a slot alone
|
|
45
|
+
# cannot recover a probe whose expected count is missing.
|
|
47
46
|
@heartbeat.declare!(@processes)
|
|
48
47
|
|
|
49
|
-
if Registry.stopping?
|
|
50
|
-
shutdown_tick!
|
|
51
|
-
return true
|
|
52
|
-
end
|
|
48
|
+
return shutdown_tick! if Registry.stopping?
|
|
53
49
|
|
|
54
50
|
healthy = Registry.healthy?(@consumers)
|
|
55
51
|
if healthy
|
|
@@ -63,26 +59,30 @@ module KicksLiveness
|
|
|
63
59
|
|
|
64
60
|
private
|
|
65
61
|
|
|
62
|
+
# Publish unsafe evidence without recording a start or launching the loop.
|
|
63
|
+
def reject_topology!
|
|
64
|
+
# Reported before the state is published: a marks directory that cannot be
|
|
65
|
+
# written to is exactly the case where the log line is all the operator
|
|
66
|
+
# will get.
|
|
67
|
+
report_topology
|
|
68
|
+
@heartbeat.reject_topology!
|
|
69
|
+
nil
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Claim before logging so simultaneous forks cannot report the same state.
|
|
73
|
+
def report_topology
|
|
74
|
+
level, message = TopologyReport.new(@heartbeat.dir, @heartbeat.topology).claim!
|
|
75
|
+
|
|
76
|
+
log(level, message) if level
|
|
77
|
+
end
|
|
78
|
+
|
|
66
79
|
def report_first_start
|
|
67
80
|
log(:info, "started: dir=#{@heartbeat.dir} max_age=#{@heartbeat.max_age}s tick=#{@config.tick}s " \
|
|
68
81
|
"processes=#{@processes} consumers=#{@consumers}")
|
|
69
82
|
end
|
|
70
83
|
|
|
71
|
-
#
|
|
72
|
-
#
|
|
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.
|
|
84
|
+
# Seed @previous to suppress repeated waiting messages. Escalation uses the
|
|
85
|
+
# persistent attempt window because unhealthy_ticks dies with each fork.
|
|
86
86
|
def report_respawn(attempt, elapsed)
|
|
87
87
|
@previous = false
|
|
88
88
|
|
|
@@ -96,8 +96,7 @@ module KicksLiveness
|
|
|
96
96
|
@config.startup_grace_ticks * interval
|
|
97
97
|
end
|
|
98
98
|
|
|
99
|
-
#
|
|
100
|
-
# next start of this slot deserves to be treated as a fresh one.
|
|
99
|
+
# Clear once after recovery so a later failure starts a new unhealthy run.
|
|
101
100
|
def clear_attempt_once!
|
|
102
101
|
return if @attempt_cleared
|
|
103
102
|
|
|
@@ -109,27 +108,32 @@ module KicksLiveness
|
|
|
109
108
|
@unhealthy_ticks ||= 0
|
|
110
109
|
end
|
|
111
110
|
|
|
112
|
-
#
|
|
113
|
-
#
|
|
114
|
-
#
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
111
|
+
# The first stop callback, not a delayed tick, starts the monotonic deadline.
|
|
112
|
+
# Expiry stops refreshes; ordinary mark staleness then fails the probe.
|
|
113
|
+
# @see file:docs/LIMITATIONS.md#graceful-shutdown-has-a-finite-heartbeat-exemption
|
|
114
|
+
def shutdown_tick! # rubocop:disable Naming/PredicateMethod -- Publishes the heartbeat and records transitions.
|
|
115
|
+
@shutdown_deadline ||= Registry.stopping_at + @config.shutdown_grace
|
|
116
|
+
report_shutdown_once
|
|
117
|
+
|
|
118
|
+
if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= @shutdown_deadline
|
|
119
|
+
log(:error, 'shutdown grace expired: heartbeat is no longer refreshed') unless @shutdown_expired
|
|
120
|
+
@shutdown_expired = true
|
|
121
|
+
return false
|
|
122
|
+
end
|
|
123
|
+
|
|
119
124
|
@heartbeat.touch!(@slot)
|
|
125
|
+
true
|
|
126
|
+
end
|
|
120
127
|
|
|
128
|
+
def report_shutdown_once
|
|
121
129
|
return if @shutdown_reported
|
|
122
130
|
|
|
123
131
|
log(:info, 'shutting down: consumers are no longer checked')
|
|
124
132
|
@shutdown_reported = true
|
|
125
133
|
end
|
|
126
134
|
|
|
127
|
-
#
|
|
128
|
-
#
|
|
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.
|
|
135
|
+
# Guard tick and sleep together so an exception cannot kill the only
|
|
136
|
+
# heartbeat writer. Recovery uses the default interval.
|
|
133
137
|
def run_loop
|
|
134
138
|
loop do
|
|
135
139
|
tick!
|
|
@@ -147,16 +151,13 @@ module KicksLiveness
|
|
|
147
151
|
tick.is_a?(Numeric) && tick.positive? ? tick : Configuration::DEFAULT_TICK
|
|
148
152
|
end
|
|
149
153
|
|
|
150
|
-
#
|
|
151
|
-
# ERROR is reserved for genuine failure, because a logger usually comes up
|
|
152
|
-
# with LOG_LEVEL defaulting to error.
|
|
154
|
+
# Log transitions and startup escalation; the periodic pulse is the mtime.
|
|
153
155
|
def report(healthy)
|
|
154
156
|
if healthy != @previous
|
|
155
157
|
report_transition(healthy)
|
|
156
158
|
@previous = healthy
|
|
157
159
|
elsif unhealthy_ticks == @config.startup_grace_ticks
|
|
158
|
-
#
|
|
159
|
-
# silent would leave a pod that never comes up saying nothing about it.
|
|
160
|
+
# Report once when a surviving monitor exhausts startup grace.
|
|
160
161
|
log(:error, "not healthy #{unhealthy_ticks * @config.tick}s, expected #{@consumers} consumers")
|
|
161
162
|
end
|
|
162
163
|
end
|
|
@@ -174,10 +175,8 @@ module KicksLiveness
|
|
|
174
175
|
def log(level, message)
|
|
175
176
|
@config.resolved_logger&.public_send(level, "[liveness] slot #{@slot}: #{message}")
|
|
176
177
|
rescue StandardError
|
|
177
|
-
#
|
|
178
|
-
#
|
|
179
|
-
# the only thread that can refresh it. There is deliberately no fallback
|
|
180
|
-
# log here: calling the same logger again would only repeat the failure.
|
|
178
|
+
# Logger failures must not stop the heartbeat writer. Do not retry the
|
|
179
|
+
# same logger from its failure path.
|
|
181
180
|
nil
|
|
182
181
|
end
|
|
183
182
|
end
|
|
@@ -9,16 +9,13 @@ module KicksLiveness
|
|
|
9
9
|
# Initialised at file-load time: single-threaded, and before any fork.
|
|
10
10
|
@workers = []
|
|
11
11
|
@mutex = Mutex.new
|
|
12
|
-
@
|
|
12
|
+
@stopping_at = nil
|
|
13
13
|
|
|
14
14
|
class << self
|
|
15
15
|
# Records a worker as subscribed.
|
|
16
16
|
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
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.
|
|
17
|
+
# Duplicate objects must not inflate the expected-count check. Use identity
|
|
18
|
+
# rather than == because worker classes can override equality.
|
|
22
19
|
#
|
|
23
20
|
# @param worker [Sneakers::Worker]
|
|
24
21
|
# @return [Array] the registry
|
|
@@ -36,25 +33,26 @@ module KicksLiveness
|
|
|
36
33
|
@mutex.synchronize { @workers.delete(worker) }
|
|
37
34
|
end
|
|
38
35
|
|
|
39
|
-
#
|
|
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.
|
|
36
|
+
# Mark deliberate shutdown before unsubscription changes the predicate.
|
|
49
37
|
#
|
|
50
38
|
# @return [true]
|
|
51
|
-
def stopping!
|
|
52
|
-
@
|
|
39
|
+
def stopping! # rubocop:disable Naming/PredicateMethod -- Records the stop notification.
|
|
40
|
+
@mutex.synchronize { @stopping_at ||= Process.clock_gettime(Process::CLOCK_MONOTONIC) }
|
|
41
|
+
true
|
|
53
42
|
end
|
|
54
43
|
|
|
55
44
|
# @return [Boolean] whether shutdown has begun
|
|
56
45
|
def stopping?
|
|
57
|
-
|
|
46
|
+
!stopping_at.nil?
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Captured by the first stop callback, even if the monitor's next tick is
|
|
50
|
+
# delayed. Repeated callbacks cannot extend the shutdown exemption. This
|
|
51
|
+
# is process-local: a sibling replacement fork has not been asked to stop.
|
|
52
|
+
#
|
|
53
|
+
# @return [Float, nil] monotonic time of the first stop notification
|
|
54
|
+
def stopping_at
|
|
55
|
+
@mutex.synchronize { @stopping_at }
|
|
58
56
|
end
|
|
59
57
|
|
|
60
58
|
# Whether this process is consuming everything it is supposed to consume.
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
require 'fileutils'
|
|
2
|
+
|
|
3
|
+
module KicksLiveness
|
|
4
|
+
# Claims worker topology reports across fork respawns. Kept outside
|
|
5
|
+
# {Heartbeat} because the probe does not report these diagnostics.
|
|
6
|
+
#
|
|
7
|
+
# @see file:docs/LIMITATIONS.md#container-generations-need-linux-procfs-and-a-private-pid-namespace
|
|
8
|
+
# @api private
|
|
9
|
+
class TopologyReport
|
|
10
|
+
# @param dir [String] marks directory
|
|
11
|
+
# @param topology [ContainerGeneration::Result] what this worker proved
|
|
12
|
+
def initialize(dir, topology)
|
|
13
|
+
@dir = dir
|
|
14
|
+
@topology = topology
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Claims the right to report this state, once.
|
|
18
|
+
#
|
|
19
|
+
# Unverifiable results use a generation key; unavailable and unsafe results
|
|
20
|
+
# have no trustworthy generation and use only their state. Verification
|
|
21
|
+
# clears the flag so a later degradation can be reported again.
|
|
22
|
+
#
|
|
23
|
+
# @return [Array(Symbol, String), nil] the level and the line to log, or nil
|
|
24
|
+
# when there is nothing new to say
|
|
25
|
+
def claim!
|
|
26
|
+
key = key_for(@topology)
|
|
27
|
+
|
|
28
|
+
unless key
|
|
29
|
+
forget! if @topology.verified?
|
|
30
|
+
return nil
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
return nil unless claim(key)
|
|
34
|
+
|
|
35
|
+
report_for(@topology)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def path
|
|
41
|
+
File.join(@dir, 'topology-report')
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def key_for(topology)
|
|
45
|
+
case topology.state
|
|
46
|
+
when :unsafe then 'unsafe'
|
|
47
|
+
when :unverifiable then "unverifiable #{topology.generation}"
|
|
48
|
+
when :unavailable then 'unavailable'
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Only unsafe changes the probe verdict and is reported at ERROR.
|
|
53
|
+
def report_for(topology)
|
|
54
|
+
case topology.state
|
|
55
|
+
when :unsafe
|
|
56
|
+
[:error, "#{Heartbeat::UNSUPPORTED_TOPOLOGY} (#{topology.detail}); " \
|
|
57
|
+
'liveness probes fail until the pod is recreated without those settings']
|
|
58
|
+
when :unverifiable
|
|
59
|
+
[:warn, "PID topology unverified (#{topology.detail}): the container generation still scopes the heartbeat, " \
|
|
60
|
+
'but shareProcessNamespace and hostPID cannot be ruled out']
|
|
61
|
+
else
|
|
62
|
+
[:warn, "no container generation (#{topology.detail}): falling back to freshness only, " \
|
|
63
|
+
'which cannot reject a heartbeat inherited from a previous container']
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# The runner starts all of its forks together, so the read and write have
|
|
68
|
+
# to be one inter-process operation: an atomic rename protects readers from
|
|
69
|
+
# a partial file, but does not stop every fork reading the old key and each
|
|
70
|
+
# claiming the same report. The state file is also the lock file, so no
|
|
71
|
+
# second path is added to the marks contract. Once it has existed it is
|
|
72
|
+
# cleared by truncation rather than unlink: every process must keep locking
|
|
73
|
+
# the same inode.
|
|
74
|
+
def claim(key)
|
|
75
|
+
synchronize(create: true) do |state|
|
|
76
|
+
state.rewind
|
|
77
|
+
next false if state.read.chomp == key
|
|
78
|
+
|
|
79
|
+
state.rewind
|
|
80
|
+
state.truncate(0)
|
|
81
|
+
state.write("#{key}\n")
|
|
82
|
+
state.flush
|
|
83
|
+
true
|
|
84
|
+
end
|
|
85
|
+
rescue StandardError
|
|
86
|
+
# A report that cannot be remembered must still be made. The cost is
|
|
87
|
+
# repetition on the next start, not silence about a lost guarantee.
|
|
88
|
+
true
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def forget!
|
|
92
|
+
return unless File.exist?(path)
|
|
93
|
+
|
|
94
|
+
synchronize(create: false) { |state| state.truncate(0) }
|
|
95
|
+
rescue StandardError
|
|
96
|
+
nil
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def synchronize(create:)
|
|
100
|
+
FileUtils.mkdir_p(@dir)
|
|
101
|
+
flags = File::RDWR
|
|
102
|
+
flags |= File::CREAT if create
|
|
103
|
+
File.open(path, flags, 0o644) do |state|
|
|
104
|
+
raise IOError, "cannot lock #{path}" unless state.flock(File::LOCK_EX)
|
|
105
|
+
|
|
106
|
+
yield state
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
data/lib/kicks_liveness.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
require_relative 'kicks_liveness/version'
|
|
2
2
|
require_relative 'kicks_liveness/heartbeat'
|
|
3
3
|
require_relative 'kicks_liveness/attempts'
|
|
4
|
+
require_relative 'kicks_liveness/topology_report'
|
|
4
5
|
require_relative 'kicks_liveness/configuration'
|
|
5
6
|
require_relative 'kicks_liveness/registry'
|
|
6
7
|
require_relative 'kicks_liveness/monitor'
|
|
@@ -76,7 +77,8 @@ module KicksLiveness
|
|
|
76
77
|
# @param slot [Integer] supervisor slot of this fork
|
|
77
78
|
# @param processes [Integer] how many forks the probe must wait for
|
|
78
79
|
# @param consumers [Integer] how many workers must subscribe here
|
|
79
|
-
# @return [Thread, nil] nil when disabled by configuration
|
|
80
|
+
# @return [Thread, nil] nil when disabled by configuration, or when the PID
|
|
81
|
+
# topology is proven unsupported
|
|
80
82
|
# @api private
|
|
81
83
|
def start!(slot:, processes:, consumers:)
|
|
82
84
|
return unless config.enabled?
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: kicks_liveness
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.4
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- PoroshkinaVV
|
|
@@ -39,13 +39,14 @@ files:
|
|
|
39
39
|
- lib/kicks_liveness/probe.rb
|
|
40
40
|
- lib/kicks_liveness/railtie.rb
|
|
41
41
|
- lib/kicks_liveness/registry.rb
|
|
42
|
+
- lib/kicks_liveness/topology_report.rb
|
|
42
43
|
- lib/kicks_liveness/version.rb
|
|
43
44
|
homepage: https://github.com/PoroshkinaVV/kicks_liveness
|
|
44
45
|
licenses:
|
|
45
46
|
- MIT
|
|
46
47
|
metadata:
|
|
47
48
|
source_code_uri: https://github.com/PoroshkinaVV/kicks_liveness
|
|
48
|
-
documentation_uri: https://rubydoc.info/gems/kicks_liveness/0.1.
|
|
49
|
+
documentation_uri: https://rubydoc.info/gems/kicks_liveness/0.1.4
|
|
49
50
|
changelog_uri: https://github.com/PoroshkinaVV/kicks_liveness/blob/main/CHANGELOG.md
|
|
50
51
|
bug_tracker_uri: https://github.com/PoroshkinaVV/kicks_liveness/issues
|
|
51
52
|
rubygems_mfa_required: 'true'
|