kicks_liveness 0.1.1 → 0.1.3
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 +70 -1
- data/README.md +14 -3
- data/docs/DESIGN.md +136 -178
- data/docs/KUBERNETES.md +29 -0
- data/docs/LIMITATIONS.md +152 -28
- data/docs/SETUP.md +17 -4
- data/docs/VERIFYING.md +138 -5
- data/lib/kicks_liveness/attempts.rb +21 -16
- data/lib/kicks_liveness/configuration.rb +4 -9
- data/lib/kicks_liveness/heartbeat.rb +273 -63
- data/lib/kicks_liveness/hooks.rb +11 -29
- data/lib/kicks_liveness/monitor.rb +36 -46
- data/lib/kicks_liveness/registry.rb +3 -15
- data/lib/kicks_liveness/topology_report.rb +110 -0
- data/lib/kicks_liveness/version.rb +1 -1
- data/lib/kicks_liveness.rb +5 -3
- metadata +5 -8
|
@@ -1,19 +1,157 @@
|
|
|
1
1
|
module KicksLiveness
|
|
2
|
-
#
|
|
3
|
-
# probe.
|
|
2
|
+
# Identifies the container generation and PID topology from Linux procfs,
|
|
3
|
+
# independently in the worker and each exec probe.
|
|
4
4
|
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
# @see file:docs/LIMITATIONS.md#container-generations-need-linux-procfs-and-a-private-pid-namespace
|
|
6
|
+
# @api private
|
|
7
|
+
module ContainerGeneration
|
|
8
|
+
# @return [String] this process's mount namespace link
|
|
9
|
+
SELF_NAMESPACE = '/proc/self/ns/mnt'.freeze
|
|
10
|
+
# @return [String] PID 1's mount namespace link
|
|
11
|
+
PID1_NAMESPACE = '/proc/1/ns/mnt'.freeze
|
|
12
|
+
# @return [String] PID 1's status line, which carries its start time
|
|
13
|
+
PID1_STAT = '/proc/1/stat'.freeze
|
|
14
|
+
|
|
15
|
+
# One procfs inspection, as a value.
|
|
16
|
+
#
|
|
17
|
+
# +:verified+:: matching mount namespaces, with a generation.
|
|
18
|
+
# +:unsafe+:: different mount namespaces; no trustworthy generation.
|
|
19
|
+
# +:unverifiable+:: a generation, but PID 1's namespace could not be read.
|
|
20
|
+
# +:unavailable+:: Linux procfs yielded no generation.
|
|
21
|
+
# +:non_linux+:: another operating system.
|
|
22
|
+
#
|
|
23
|
+
# @api private
|
|
24
|
+
class Result
|
|
25
|
+
class << self
|
|
26
|
+
# @param generation [String]
|
|
27
|
+
# @return [Result]
|
|
28
|
+
def verified(generation) = new(:verified, generation: generation)
|
|
29
|
+
|
|
30
|
+
# @param detail [String] which namespaces differ
|
|
31
|
+
# @return [Result]
|
|
32
|
+
def unsafe(detail) = new(:unsafe, detail: detail)
|
|
33
|
+
|
|
34
|
+
# @param generation [String]
|
|
35
|
+
# @param detail [String] what could not be read
|
|
36
|
+
# @return [Result]
|
|
37
|
+
def unverifiable(generation, detail) = new(:unverifiable, generation: generation, detail: detail)
|
|
38
|
+
|
|
39
|
+
# @param detail [String] what could not be read
|
|
40
|
+
# @return [Result]
|
|
41
|
+
def unavailable(detail) = new(:unavailable, detail: detail)
|
|
42
|
+
|
|
43
|
+
# @return [Result]
|
|
44
|
+
def non_linux = new(:non_linux)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# @return [Symbol] +:verified+, +:unsafe+, +:unverifiable+, +:unavailable+ or +:non_linux+
|
|
48
|
+
attr_reader :state
|
|
49
|
+
# @return [String, nil] the container incarnation, in the two states that have one
|
|
50
|
+
attr_reader :generation
|
|
51
|
+
# @return [String, nil] what could not be read, or which namespaces differ
|
|
52
|
+
attr_reader :detail
|
|
53
|
+
|
|
54
|
+
# @param state [Symbol]
|
|
55
|
+
# @param generation [String, nil]
|
|
56
|
+
# @param detail [String, nil]
|
|
57
|
+
def initialize(state, generation: nil, detail: nil)
|
|
58
|
+
@state = state
|
|
59
|
+
@generation = generation
|
|
60
|
+
@detail = detail
|
|
61
|
+
freeze
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# @return [Boolean]
|
|
65
|
+
def verified? = @state == :verified
|
|
66
|
+
|
|
67
|
+
# @return [Boolean]
|
|
68
|
+
def unsafe? = @state == :unsafe
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
class << self
|
|
72
|
+
# Reads the three procfs entries the protocol needs, in the one order that
|
|
73
|
+
# cannot be talked out of a rejection: an unsafe topology is decided before
|
|
74
|
+
# anything is derived from PID 1, because in that topology PID 1 belongs to
|
|
75
|
+
# the pod sandbox or the node and its start time means nothing here.
|
|
76
|
+
#
|
|
77
|
+
# @param linux [Boolean] injected in specs, where procfs is stubbed
|
|
78
|
+
# @return [Result]
|
|
79
|
+
def current(linux: linux?)
|
|
80
|
+
return Result.non_linux unless linux
|
|
81
|
+
|
|
82
|
+
mine, mine_error = read_link(SELF_NAMESPACE)
|
|
83
|
+
pid1, pid1_error = read_link(PID1_NAMESPACE)
|
|
84
|
+
return Result.unsafe("PID 1 is in #{pid1}, this process in #{mine}") if another_namespace?(mine, pid1)
|
|
85
|
+
|
|
86
|
+
started_at, stat_error = pid1_started_at
|
|
87
|
+
return Result.unavailable(mine_error || stat_error) unless mine && started_at
|
|
88
|
+
|
|
89
|
+
generation = "#{mine}:#{started_at}"
|
|
90
|
+
pid1 ? Result.verified(generation) : Result.unverifiable(generation, pid1_error)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
# Missing procfs does not identify the OS. JRuby reports "java", so use
|
|
96
|
+
# its predefined RbConfig to identify the host. CRuby on Linux answers
|
|
97
|
+
# first, including under --disable-gems where RbConfig may be undefined.
|
|
98
|
+
# https://bugs.ruby-lang.org/issues/8090
|
|
99
|
+
def linux?
|
|
100
|
+
return true if RUBY_PLATFORM.include?('linux')
|
|
101
|
+
return false unless defined?(RbConfig)
|
|
102
|
+
|
|
103
|
+
RbConfig::CONFIG['host_os'].to_s.include?('linux')
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Only a comparison both sides of which were read can prove anything.
|
|
107
|
+
# @param mine [String, nil] this process's mount namespace
|
|
108
|
+
# @param pid1 [String, nil] PID 1's mount namespace
|
|
109
|
+
# @return [Boolean]
|
|
110
|
+
def another_namespace?(mine, pid1)
|
|
111
|
+
!mine.nil? && !pid1.nil? && mine != pid1
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# @param path [String] a procfs namespace link
|
|
115
|
+
# @return [Array(String, nil), Array(nil, String)] the link target, or why it could not be read
|
|
116
|
+
def read_link(path)
|
|
117
|
+
[File.readlink(path), nil]
|
|
118
|
+
rescue StandardError => e
|
|
119
|
+
[nil, "cannot read #{path} (#{e.class})"]
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# @return [Array(String, nil), Array(nil, String)] PID 1's start time, or why it is missing
|
|
123
|
+
def pid1_started_at
|
|
124
|
+
started_at = start_time_field(File.read(PID1_STAT))
|
|
125
|
+
return [started_at.to_s, nil] if started_at&.positive?
|
|
126
|
+
return [nil, 'PID 1 start time is zero; possible hostPID or virtualized procfs'] if started_at&.zero?
|
|
127
|
+
|
|
128
|
+
[nil, "cannot parse #{PID1_STAT}"]
|
|
129
|
+
rescue StandardError => e
|
|
130
|
+
[nil, "cannot read #{PID1_STAT} (#{e.class})"]
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# @param stat [String] the contents of /proc/1/stat
|
|
134
|
+
# @return [Integer, nil]
|
|
135
|
+
def start_time_field(stat)
|
|
136
|
+
closing_parenthesis = stat.rindex(') ')
|
|
137
|
+
return unless closing_parenthesis
|
|
138
|
+
|
|
139
|
+
# After the command in parentheses, field 3 (`state`) is index 0. Process
|
|
140
|
+
# start time is field 22, therefore index 19 in this tail.
|
|
141
|
+
started_at = stat[(closing_parenthesis + 2)..].split[19]
|
|
142
|
+
Integer(started_at) if started_at
|
|
143
|
+
rescue ArgumentError, TypeError
|
|
144
|
+
nil
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Filesystem contract shared by the worker and probe. The marks directory
|
|
150
|
+
# must be on tmpfs (an emptyDir with medium: Memory in Kubernetes).
|
|
9
151
|
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
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.
|
|
152
|
+
# The probe loads this file alone, including under --disable-gems. Keep its
|
|
153
|
+
# read path free of requires and references to other gem code; fileutils is
|
|
154
|
+
# loaded lazily only when the worker needs to create parent directories.
|
|
17
155
|
#
|
|
18
156
|
# @see file:docs/DESIGN.md#why-the-heartbeat-file-has-no-require-of-its-own
|
|
19
157
|
class Heartbeat
|
|
@@ -22,6 +160,10 @@ module KicksLiveness
|
|
|
22
160
|
# @return [Integer] seconds after which a mark is stale, by default
|
|
23
161
|
DEFAULT_MAX_AGE = 45
|
|
24
162
|
|
|
163
|
+
# Shared verdict for unsafe topology evidence from either participant.
|
|
164
|
+
# @return [String]
|
|
165
|
+
UNSUPPORTED_TOPOLOGY = 'unsupported PID topology: pod must not set shareProcessNamespace or hostPID'.freeze
|
|
166
|
+
|
|
25
167
|
# @return [Hash{Symbol => String}] setting name to environment variable
|
|
26
168
|
ENV_NAMES = {
|
|
27
169
|
dir: 'KICKS_LIVENESS_DIR',
|
|
@@ -29,6 +171,14 @@ module KicksLiveness
|
|
|
29
171
|
tick: 'KICKS_LIVENESS_TICK'
|
|
30
172
|
}.freeze
|
|
31
173
|
|
|
174
|
+
# Inspects this process's container generation and PID topology.
|
|
175
|
+
# @return [ContainerGeneration::Result] what this process can prove about
|
|
176
|
+
# its own incarnation
|
|
177
|
+
# @api private
|
|
178
|
+
def self.topology
|
|
179
|
+
ContainerGeneration.current
|
|
180
|
+
end
|
|
181
|
+
|
|
32
182
|
# An empty string counts as unset: in a ConfigMap that is what you get by
|
|
33
183
|
# declaring a key and leaving it blank.
|
|
34
184
|
#
|
|
@@ -39,15 +189,9 @@ module KicksLiveness
|
|
|
39
189
|
value unless value.nil? || value.empty?
|
|
40
190
|
end
|
|
41
191
|
|
|
42
|
-
#
|
|
43
|
-
#
|
|
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.
|
|
192
|
+
# Environment durations require positive integers. Invalid input falls
|
|
193
|
+
# back to the default; accepting zero or negatives can stop the monitor
|
|
194
|
+
# or make healthy marks stale immediately.
|
|
51
195
|
#
|
|
52
196
|
# @param key [Symbol] one of the keys of {ENV_NAMES}
|
|
53
197
|
# @param default [Integer]
|
|
@@ -72,80 +216,95 @@ module KicksLiveness
|
|
|
72
216
|
|
|
73
217
|
# @param dir [String] marks directory
|
|
74
218
|
# @param max_age [Integer] seconds after which a mark is considered stale
|
|
75
|
-
|
|
219
|
+
# @param topology [ContainerGeneration::Result] what this process proved
|
|
220
|
+
# about its incarnation; injected in specs
|
|
221
|
+
def initialize(
|
|
222
|
+
dir: Heartbeat.env_dir,
|
|
223
|
+
max_age: Heartbeat.env_max_age,
|
|
224
|
+
topology: Heartbeat.topology
|
|
225
|
+
)
|
|
76
226
|
@dir = dir
|
|
77
227
|
@max_age = max_age
|
|
228
|
+
@topology = topology
|
|
229
|
+
@generation = topology.generation
|
|
78
230
|
end
|
|
79
231
|
|
|
80
|
-
|
|
232
|
+
# @return [String] marks directory
|
|
233
|
+
attr_reader :dir
|
|
234
|
+
# @return [Integer] seconds after which a mark is considered stale
|
|
235
|
+
attr_reader :max_age
|
|
236
|
+
# @return [ContainerGeneration::Result] what this process proved about its
|
|
237
|
+
# incarnation, and the incarnation itself where there is one
|
|
238
|
+
attr_reader :topology
|
|
81
239
|
|
|
82
|
-
#
|
|
83
|
-
#
|
|
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.
|
|
240
|
+
# Declares how many fork marks the probe requires. Atomic publication
|
|
241
|
+
# prevents a concurrent probe from reading a truncated count or generation.
|
|
91
242
|
#
|
|
92
243
|
# @param processes [Integer]
|
|
93
|
-
# @return [
|
|
244
|
+
# @return [Integer] bytes written
|
|
94
245
|
def declare!(processes)
|
|
246
|
+
return reject_topology! if @topology.unsafe?
|
|
247
|
+
|
|
95
248
|
make_dir
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
File.write(tmp, processes)
|
|
99
|
-
File.rename(tmp, expected_path)
|
|
249
|
+
atomic_write(generation_path, @generation) if @generation
|
|
250
|
+
atomic_write(expected_path, processes)
|
|
100
251
|
end
|
|
101
252
|
|
|
102
|
-
# Refreshes
|
|
103
|
-
#
|
|
104
|
-
#
|
|
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.
|
|
253
|
+
# Refreshes the supervisor slot, so a respawn replaces the old mark instead
|
|
254
|
+
# of leaving a stale PID-named file. The probe checks generation and mtime;
|
|
255
|
+
# the remaining contents are diagnostic.
|
|
111
256
|
#
|
|
112
257
|
# @param slot [Integer] supervisor slot of this fork
|
|
113
258
|
# @return [Integer] bytes written
|
|
114
259
|
def touch!(slot)
|
|
260
|
+
return reject_topology! if @topology.unsafe?
|
|
261
|
+
|
|
262
|
+
make_dir
|
|
263
|
+
contents = "#{Time.now.utc.strftime('%FT%TZ')} pid=#{Process.pid} slot=#{slot}"
|
|
264
|
+
contents = "#{contents} generation=#{@generation}" if @generation
|
|
265
|
+
atomic_write(slot_path(slot), "#{contents}\n")
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# Publishes the terminal fail-closed state for a proven unsafe topology,
|
|
269
|
+
# instead of any mark.
|
|
270
|
+
#
|
|
271
|
+
# Explicit evidence is needed because inherited generation and slot marks
|
|
272
|
+
# may still be fresh. It precedes normal writes and is never removed during
|
|
273
|
+
# the marks volume's lifetime.
|
|
274
|
+
#
|
|
275
|
+
# @return [Integer, nil] bytes written, or nil when this process has not
|
|
276
|
+
# proven the topology unsafe and so has nothing to publish
|
|
277
|
+
# @api private
|
|
278
|
+
def reject_topology!
|
|
279
|
+
return unless @topology.unsafe?
|
|
280
|
+
|
|
115
281
|
make_dir
|
|
116
|
-
|
|
282
|
+
atomic_write(topology_path, "#{[UNSUPPORTED_TOPOLOGY, @topology.detail].compact.join("\n")}\n")
|
|
117
283
|
end
|
|
118
284
|
|
|
119
285
|
# The probe side: is every declared fork's mark present and fresh?
|
|
120
286
|
#
|
|
121
|
-
#
|
|
122
|
-
#
|
|
287
|
+
# Published or local unsafe evidence takes precedence over every generation
|
|
288
|
+
# and freshness check, including when this probe cannot verify the topology.
|
|
123
289
|
#
|
|
124
290
|
# @param now [Time] injected in specs
|
|
125
291
|
# @return [Array(Boolean, String)] health, and the reason to print on stdout
|
|
126
292
|
def check(now: Time.now.utc)
|
|
293
|
+
return [false, UNSUPPORTED_TOPOLOGY] if unsupported_topology?
|
|
294
|
+
|
|
127
295
|
processes = expected
|
|
128
296
|
return [false, "no #{expected_path}: worker has not started yet"] unless processes&.positive?
|
|
297
|
+
return [false, 'heartbeat belongs to a previous container: worker has not started yet'] unless current_generation?
|
|
129
298
|
|
|
130
|
-
problems = (0...processes).filter_map
|
|
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
|
|
299
|
+
problems = (0...processes).filter_map { |slot| problem_for(slot, now) }
|
|
136
300
|
|
|
137
301
|
problems.empty? ? [true, "#{processes} process(es) healthy"] : [false, problems.join('; ')]
|
|
138
302
|
end
|
|
139
303
|
|
|
140
304
|
private
|
|
141
305
|
|
|
142
|
-
#
|
|
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.
|
|
306
|
+
# Try one mkdir first; competing forks may already have created it.
|
|
307
|
+
# Only missing parent directories need fileutils, loaded on the worker path.
|
|
149
308
|
def make_dir
|
|
150
309
|
Dir.mkdir(@dir)
|
|
151
310
|
rescue Errno::EEXIST
|
|
@@ -163,12 +322,63 @@ module KicksLiveness
|
|
|
163
322
|
File.join(@dir, "worker-#{slot}")
|
|
164
323
|
end
|
|
165
324
|
|
|
325
|
+
def generation_path
|
|
326
|
+
File.join(@dir, 'generation')
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def topology_path
|
|
330
|
+
File.join(@dir, 'topology-unsafe')
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
# The worker's published proof first, then this process's own: either is
|
|
334
|
+
# enough, and no weaker result from the other side can take it back.
|
|
335
|
+
#
|
|
336
|
+
# Existence is the whole signal, so this guard cannot be defeated by a
|
|
337
|
+
# partial read and holds even where the file itself is not readable. Its
|
|
338
|
+
# contents are there for a human running `cat`.
|
|
339
|
+
def unsupported_topology?
|
|
340
|
+
File.exist?(topology_path) || @topology.unsafe?
|
|
341
|
+
end
|
|
342
|
+
|
|
166
343
|
def expected
|
|
167
344
|
Integer(File.read(expected_path))
|
|
168
345
|
rescue StandardError
|
|
169
346
|
nil
|
|
170
347
|
end
|
|
171
348
|
|
|
349
|
+
def declared_generation
|
|
350
|
+
File.read(generation_path)
|
|
351
|
+
rescue StandardError
|
|
352
|
+
nil
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
def current_generation?
|
|
356
|
+
!@generation || declared_generation == @generation
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def previous_generation?(path)
|
|
360
|
+
@generation && File.read(path)[/\bgeneration=(\S+)/, 1] != @generation
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
def problem_for(slot, now)
|
|
364
|
+
path = slot_path(slot)
|
|
365
|
+
age = age_of(path, now)
|
|
366
|
+
return "worker-#{slot} missing" if age.nil?
|
|
367
|
+
return "worker-#{slot} belongs to a previous container" if previous_generation?(path)
|
|
368
|
+
|
|
369
|
+
"worker-#{slot} stale #{age.round}s > #{@max_age}s" if age > @max_age
|
|
370
|
+
rescue StandardError
|
|
371
|
+
"worker-#{slot} unreadable"
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
def atomic_write(path, contents)
|
|
375
|
+
# The pid keeps concurrent forks from sharing the temporary file.
|
|
376
|
+
tmp = "#{path}.#{Process.pid}"
|
|
377
|
+
bytes = File.write(tmp, contents)
|
|
378
|
+
File.rename(tmp, path)
|
|
379
|
+
bytes
|
|
380
|
+
end
|
|
381
|
+
|
|
172
382
|
def age_of(path, now)
|
|
173
383
|
now - File.mtime(path)
|
|
174
384
|
rescue StandardError
|
data/lib/kicks_liveness/hooks.rb
CHANGED
|
@@ -25,29 +25,17 @@ module KicksLiveness
|
|
|
25
25
|
|
|
26
26
|
# Prepended to +Sneakers::WorkerGroup+.
|
|
27
27
|
#
|
|
28
|
-
#
|
|
29
|
-
#
|
|
30
|
-
#
|
|
31
|
-
# in that lookup chain; +worker_id+ and +config+ come from
|
|
32
|
-
# +ServerEngine::Worker+.
|
|
28
|
+
# ServerEngine extends each worker instance with WorkerGroup. A prepend to
|
|
29
|
+
# the module still intercepts that singleton-class chain; worker_id and
|
|
30
|
+
# config come from ServerEngine::Worker.
|
|
33
31
|
#
|
|
34
32
|
# @see file:docs/DESIGN.md#where-the-hooks-attach
|
|
35
33
|
# @api private
|
|
36
34
|
module WorkerGroup
|
|
37
|
-
#
|
|
38
|
-
#
|
|
39
|
-
#
|
|
40
|
-
#
|
|
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.
|
|
35
|
+
# Run after the application's after_fork restores inherited connections.
|
|
36
|
+
# Resolving callable worker_classes earlier could run application code before
|
|
37
|
+
# those connections are ready. Rescue startup errors so instrumentation
|
|
38
|
+
# cannot prevent the worker itself from starting.
|
|
51
39
|
#
|
|
52
40
|
# @return [void]
|
|
53
41
|
def after_fork
|
|
@@ -64,13 +52,8 @@ module KicksLiveness
|
|
|
64
52
|
end
|
|
65
53
|
end
|
|
66
54
|
|
|
67
|
-
#
|
|
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.
|
|
55
|
+
# Set the flag before any worker unsubscribes or starts draining, so the
|
|
56
|
+
# monitor does not treat deliberate removal as a consumer failure.
|
|
74
57
|
#
|
|
75
58
|
# @return [void]
|
|
76
59
|
def stop
|
|
@@ -91,9 +74,8 @@ module KicksLiveness
|
|
|
91
74
|
nil
|
|
92
75
|
end
|
|
93
76
|
|
|
94
|
-
#
|
|
95
|
-
#
|
|
96
|
-
# under sneakers:run, a callable registry under sneakers:active_job.
|
|
77
|
+
# Use the worker gem's own set: an array for sneakers:run, a callable registry
|
|
78
|
+
# for sneakers:active_job. This must run after the application's after_fork.
|
|
97
79
|
def kicks_liveness_expected_consumers
|
|
98
80
|
classes = config[:worker_classes]
|
|
99
81
|
classes = classes.call if classes.respond_to?(:call)
|
|
@@ -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,12 +41,8 @@ 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
48
|
if Registry.stopping?
|
|
@@ -63,26 +62,30 @@ module KicksLiveness
|
|
|
63
62
|
|
|
64
63
|
private
|
|
65
64
|
|
|
65
|
+
# Publish unsafe evidence without recording a start or launching the loop.
|
|
66
|
+
def reject_topology!
|
|
67
|
+
# Reported before the state is published: a marks directory that cannot be
|
|
68
|
+
# written to is exactly the case where the log line is all the operator
|
|
69
|
+
# will get.
|
|
70
|
+
report_topology
|
|
71
|
+
@heartbeat.reject_topology!
|
|
72
|
+
nil
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Claim before logging so simultaneous forks cannot report the same state.
|
|
76
|
+
def report_topology
|
|
77
|
+
level, message = TopologyReport.new(@heartbeat.dir, @heartbeat.topology).claim!
|
|
78
|
+
|
|
79
|
+
log(level, message) if level
|
|
80
|
+
end
|
|
81
|
+
|
|
66
82
|
def report_first_start
|
|
67
83
|
log(:info, "started: dir=#{@heartbeat.dir} max_age=#{@heartbeat.max_age}s tick=#{@config.tick}s " \
|
|
68
84
|
"processes=#{@processes} consumers=#{@consumers}")
|
|
69
85
|
end
|
|
70
86
|
|
|
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.
|
|
87
|
+
# Seed @previous to suppress repeated waiting messages. Escalation uses the
|
|
88
|
+
# persistent attempt window because unhealthy_ticks dies with each fork.
|
|
86
89
|
def report_respawn(attempt, elapsed)
|
|
87
90
|
@previous = false
|
|
88
91
|
|
|
@@ -96,8 +99,7 @@ module KicksLiveness
|
|
|
96
99
|
@config.startup_grace_ticks * interval
|
|
97
100
|
end
|
|
98
101
|
|
|
99
|
-
#
|
|
100
|
-
# next start of this slot deserves to be treated as a fresh one.
|
|
102
|
+
# Clear once after recovery so a later failure starts a new unhealthy run.
|
|
101
103
|
def clear_attempt_once!
|
|
102
104
|
return if @attempt_cleared
|
|
103
105
|
|
|
@@ -109,12 +111,9 @@ module KicksLiveness
|
|
|
109
111
|
@unhealthy_ticks ||= 0
|
|
110
112
|
end
|
|
111
113
|
|
|
112
|
-
#
|
|
113
|
-
#
|
|
114
|
-
#
|
|
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.
|
|
114
|
+
# Deliberate drain suspends the predicate for the rest of the process.
|
|
115
|
+
# An external deadline must bound it; the monitor does not own that deadline.
|
|
116
|
+
# @see file:docs/LIMITATIONS.md#graceful-shutdown-is-safe-only-when-something-else-owns-the-deadline
|
|
118
117
|
def shutdown_tick!
|
|
119
118
|
@heartbeat.touch!(@slot)
|
|
120
119
|
|
|
@@ -124,12 +123,8 @@ module KicksLiveness
|
|
|
124
123
|
@shutdown_reported = true
|
|
125
124
|
end
|
|
126
125
|
|
|
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.
|
|
126
|
+
# Guard tick and sleep together so an exception cannot kill the only
|
|
127
|
+
# heartbeat writer. Recovery uses the default interval.
|
|
133
128
|
def run_loop
|
|
134
129
|
loop do
|
|
135
130
|
tick!
|
|
@@ -147,16 +142,13 @@ module KicksLiveness
|
|
|
147
142
|
tick.is_a?(Numeric) && tick.positive? ? tick : Configuration::DEFAULT_TICK
|
|
148
143
|
end
|
|
149
144
|
|
|
150
|
-
#
|
|
151
|
-
# ERROR is reserved for genuine failure, because a logger usually comes up
|
|
152
|
-
# with LOG_LEVEL defaulting to error.
|
|
145
|
+
# Log transitions and startup escalation; the periodic pulse is the mtime.
|
|
153
146
|
def report(healthy)
|
|
154
147
|
if healthy != @previous
|
|
155
148
|
report_transition(healthy)
|
|
156
149
|
@previous = healthy
|
|
157
150
|
elsif unhealthy_ticks == @config.startup_grace_ticks
|
|
158
|
-
#
|
|
159
|
-
# silent would leave a pod that never comes up saying nothing about it.
|
|
151
|
+
# Report once when a surviving monitor exhausts startup grace.
|
|
160
152
|
log(:error, "not healthy #{unhealthy_ticks * @config.tick}s, expected #{@consumers} consumers")
|
|
161
153
|
end
|
|
162
154
|
end
|
|
@@ -174,10 +166,8 @@ module KicksLiveness
|
|
|
174
166
|
def log(level, message)
|
|
175
167
|
@config.resolved_logger&.public_send(level, "[liveness] slot #{@slot}: #{message}")
|
|
176
168
|
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.
|
|
169
|
+
# Logger failures must not stop the heartbeat writer. Do not retry the
|
|
170
|
+
# same logger from its failure path.
|
|
181
171
|
nil
|
|
182
172
|
end
|
|
183
173
|
end
|
|
@@ -14,11 +14,8 @@ module KicksLiveness
|
|
|
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,16 +33,7 @@ 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
39
|
def stopping!
|