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.
@@ -1,95 +1,182 @@
1
1
  module KicksLiveness
2
- # Identifies an ordinary Linux container incarnation shared by the worker and
3
- # exec probes without writing anything to the marks directory. This assumes
4
- # the container owns PID 1; shared and host PID namespaces are documented as
5
- # unsupported in +docs/LIMITATIONS.md+.
2
+ # Identifies the container generation and PID topology from Linux procfs,
3
+ # independently in the worker and each exec probe.
4
+ #
5
+ # @see file:docs/LIMITATIONS.md#container-generations-need-linux-procfs-and-a-private-pid-namespace
6
6
  # @api private
7
7
  module ContainerGeneration
8
- module_function
9
-
10
- # @return [String, nil] current private-PID Linux container incarnation, or
11
- # nil when procfs does not expose one
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
+ #
12
23
  # @api private
13
- def current
14
- mount_namespace = File.readlink('/proc/self/ns/mnt')
15
- stat = File.read('/proc/1/stat')
16
- closing_parenthesis = stat.rindex(') ')
17
- return unless closing_parenthesis
18
-
19
- # After the command in parentheses, field 3 (`state`) is index 0. Process
20
- # start time is field 22, therefore index 19 in this tail.
21
- started_at = stat[(closing_parenthesis + 2)..].split[19]
22
- return unless started_at && Integer(started_at).positive?
23
-
24
- "#{mount_namespace}:#{started_at}"
25
- rescue StandardError
26
- nil
27
- end
28
- end
29
-
30
- # Generation-specific parts of the filesystem contract.
31
- # @api private
32
- module GenerationGuard
33
- private
34
-
35
- def generation_path
36
- File.join(@dir, 'generation')
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
37
69
  end
38
70
 
39
- def declared_generation
40
- File.read(generation_path)
41
- rescue StandardError
42
- nil
43
- end
44
-
45
- def current_generation?
46
- !@generation || declared_generation == @generation
47
- end
48
-
49
- def previous_generation?(path)
50
- @generation && File.read(path)[/\bgeneration=(\S+)/, 1] != @generation
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
51
146
  end
52
147
  end
53
148
 
54
- # The liveness marks on the filesystem: written by the worker, read by the
55
- # probe.
56
- #
57
- # The directory must live on tmpfs — in Kubernetes, an emptyDir with
58
- # <tt>medium: Memory</tt>. Put it on a real disk and the probe starts
59
- # depending on the disk again, and a stalling disk is one of the most common
60
- # causes of false restarts.
149
+ # Filesystem contract shared by the worker and probe. The marks directory
150
+ # must be on tmpfs (an emptyDir with medium: Memory in Kubernetes).
61
151
  #
62
- # This file deliberately runs no +require+ on the probe's path and refers to
63
- # nothing else in the gem: the probe can load it alone, with no application
64
- # code and no Rails behind it. That keeps the optional direct form at ~43 ms
65
- # and lets it run on a bare interpreter with +--disable-gems+ when the image
66
- # allows it -- whether Bundler is in the picture depends on where the image
67
- # put its gems, not on this file. The one +require+ it does contain is lazy,
68
- # 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.
69
155
  #
70
156
  # @see file:docs/DESIGN.md#why-the-heartbeat-file-has-no-require-of-its-own
71
157
  class Heartbeat
72
- include GenerationGuard
73
-
74
158
  # @return [String] marks directory used when the environment says nothing
75
159
  DEFAULT_DIR = '/opt/app/tmp/health'.freeze
76
160
  # @return [Integer] seconds after which a mark is stale, by default
77
161
  DEFAULT_MAX_AGE = 45
78
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
+
79
167
  # @return [Hash{Symbol => String}] setting name to environment variable
80
168
  ENV_NAMES = {
81
169
  dir: 'KICKS_LIVENESS_DIR',
82
170
  max_age: 'KICKS_LIVENESS_MAX_AGE',
83
- tick: 'KICKS_LIVENESS_TICK'
171
+ tick: 'KICKS_LIVENESS_TICK',
172
+ shutdown_grace: 'KICKS_LIVENESS_SHUTDOWN_GRACE'
84
173
  }.freeze
85
174
 
86
- # Linux exposes a stable identifier shared by a private-PID container and
87
- # its exec probes. A restarted container gets a new identifier even though
88
- # its Kubernetes emptyDir survives.
89
- # @return [String, nil] current private-PID container incarnation, or nil
90
- # off Linux
175
+ # Inspects this process's container generation and PID topology.
176
+ # @return [ContainerGeneration::Result] what this process can prove about
177
+ # its own incarnation
91
178
  # @api private
92
- def self.container_generation
179
+ def self.topology
93
180
  ContainerGeneration.current
94
181
  end
95
182
 
@@ -103,15 +190,9 @@ module KicksLiveness
103
190
  value unless value.nil? || value.empty?
104
191
  end
105
192
 
106
- # Values arrive from a ConfigMap, and a typo there is no reason to bring a
107
- # worker down — garbage falls back to the default.
108
- #
109
- # Both settings are durations, so a value has to be parseable *and*
110
- # positive. Zero and negative numbers parse perfectly well and are the more
111
- # dangerous half: a tick of zero turns the monitor into a hot loop, a
112
- # negative one used to kill the monitor thread on its first sleep, and a
113
- # negative max_age makes every mark stale on arrival, so the probe can never
114
- # pass again.
193
+ # Environment durations require positive integers. Invalid input falls
194
+ # back to the default; accepting zero or negatives can stop the monitor
195
+ # or make healthy marks stale immediately.
115
196
  #
116
197
  # @param key [Symbol] one of the keys of {ENV_NAMES}
117
198
  # @param default [Integer]
@@ -136,68 +217,90 @@ module KicksLiveness
136
217
 
137
218
  # @param dir [String] marks directory
138
219
  # @param max_age [Integer] seconds after which a mark is considered stale
139
- # @param generation [String, nil] container incarnation; injected in specs
220
+ # @param topology [ContainerGeneration::Result] what this process proved
221
+ # about its incarnation; injected in specs
140
222
  def initialize(
141
223
  dir: Heartbeat.env_dir,
142
224
  max_age: Heartbeat.env_max_age,
143
- generation: Heartbeat.container_generation
225
+ topology: Heartbeat.topology
144
226
  )
145
227
  @dir = dir
146
228
  @max_age = max_age
147
- @generation = generation
229
+ @topology = topology
230
+ @generation = topology.generation
148
231
  end
149
232
 
150
- attr_reader :dir, :max_age
233
+ # @return [String] marks directory
234
+ attr_reader :dir
235
+ # @return [Integer] seconds after which a mark is considered stale
236
+ attr_reader :max_age
237
+ # @return [ContainerGeneration::Result] what this process proved about its
238
+ # incarnation, and the incarnation itself where there is one
239
+ attr_reader :topology
151
240
 
152
- # Records how many forks the probe must wait for. Every fork writes the same
153
- # value. Without it the probe would pass as soon as any single mark was
154
- # fresh, while half the workers had not subscribed yet.
155
- #
156
- # Written through a rename, because a plain write truncates first: a probe
157
- # reading in that window finds the file empty and reports that the worker
158
- # has not started. The window is real and recurring — every fork rewrites
159
- # this file on every tick, while the liveness probe reads it on a schedule
160
- # of its own. Rename is atomic on tmpfs.
241
+ # Declares how many fork marks the probe requires. Atomic publication
242
+ # prevents a concurrent probe from reading a truncated count or generation.
161
243
  #
162
244
  # @param processes [Integer]
163
- # @return [void]
245
+ # @return [Integer] bytes written
164
246
  def declare!(processes)
247
+ return reject_topology! if @topology.unsafe?
248
+
165
249
  make_dir
166
250
  atomic_write(generation_path, @generation) if @generation
167
251
  atomic_write(expected_path, processes)
168
252
  end
169
253
 
170
- # Refreshes this fork's mark.
171
- #
172
- # The file is named by supervisor slot, not by PID: a fork killed with
173
- # SIGKILL is respawned into the same slot and overwrites its own file. With a
174
- # PID in the name that file would stay stale forever and the probe would fail
175
- # permanently.
176
- #
177
- # The timestamp, pid and slot exist for a human running
178
- # <tt>kubectl exec ... cat</tt>. The generation is also checked by the probe:
179
- # a Kubernetes emptyDir survives a container restart, so freshness alone
180
- # cannot distinguish this process from the one that just exited.
254
+ # Refreshes the supervisor slot, so a respawn replaces the old mark instead
255
+ # of leaving a stale PID-named file. The probe checks generation and mtime;
256
+ # the remaining contents are diagnostic.
181
257
  #
182
258
  # @param slot [Integer] supervisor slot of this fork
183
259
  # @return [Integer] bytes written
184
260
  def touch!(slot)
261
+ return reject_topology! if @topology.unsafe?
262
+
185
263
  make_dir
186
264
  contents = "#{Time.now.utc.strftime('%FT%TZ')} pid=#{Process.pid} slot=#{slot}"
187
265
  contents = "#{contents} generation=#{@generation}" if @generation
188
266
  atomic_write(slot_path(slot), "#{contents}\n")
189
267
  end
190
268
 
269
+ # Publishes the terminal fail-closed state for a proven unsafe topology,
270
+ # instead of any mark.
271
+ #
272
+ # Explicit evidence is needed because inherited generation and slot marks
273
+ # may still be fresh. It precedes normal writes and is never removed during
274
+ # the marks volume's lifetime.
275
+ #
276
+ # @return [Integer, nil] bytes written, or nil when this process has not
277
+ # proven the topology unsafe and so has nothing to publish
278
+ # @api private
279
+ def reject_topology!
280
+ return unless @topology.unsafe?
281
+
282
+ make_dir
283
+ atomic_write(topology_path, "#{[UNSUPPORTED_TOPOLOGY, @topology.detail].compact.join("\n")}\n")
284
+ end
285
+
191
286
  # The probe side: is every declared fork's mark present and fresh?
192
287
  #
193
- # The returned message names slots the way the files are named, so that a
194
- # human reading the Unhealthy event knows which file to look at.
288
+ # Published or local unsafe evidence takes precedence over every generation
289
+ # and freshness check, including when this probe cannot verify the topology.
195
290
  #
196
291
  # @param now [Time] injected in specs
197
292
  # @return [Array(Boolean, String)] health, and the reason to print on stdout
198
293
  def check(now: Time.now.utc)
294
+ return [false, UNSUPPORTED_TOPOLOGY] if unsupported_topology?
295
+
199
296
  processes = expected
200
- return [false, "no #{expected_path}: worker has not started yet"] unless processes&.positive?
297
+ unless processes&.positive?
298
+ return [
299
+ false,
300
+ "no #{expected_path}: worker has not started yet " \
301
+ '(is KicksLiveness.install! called before the runner starts? outside Rails it is not automatic)'
302
+ ]
303
+ end
201
304
  return [false, 'heartbeat belongs to a previous container: worker has not started yet'] unless current_generation?
202
305
 
203
306
  problems = (0...processes).filter_map { |slot| problem_for(slot, now) }
@@ -207,13 +310,8 @@ module KicksLiveness
207
310
 
208
311
  private
209
312
 
210
- # One syscall on the happy path, and whichever fork gets there first wins.
211
- #
212
- # +Dir.mkdir+ creates a single level and raises ENOENT when the parent is
213
- # missing, which any nested KICKS_LIVENESS_DIR hits. FileUtils is required
214
- # here rather than at the top of the file: only the worker ever creates the
215
- # directory, and the probe's path through {#check} must stay free of
216
- # requires.
313
+ # Try one mkdir first; competing forks may already have created it.
314
+ # Only missing parent directories need fileutils, loaded on the worker path.
217
315
  def make_dir
218
316
  Dir.mkdir(@dir)
219
317
  rescue Errno::EEXIST
@@ -231,12 +329,44 @@ module KicksLiveness
231
329
  File.join(@dir, "worker-#{slot}")
232
330
  end
233
331
 
332
+ def generation_path
333
+ File.join(@dir, 'generation')
334
+ end
335
+
336
+ def topology_path
337
+ File.join(@dir, 'topology-unsafe')
338
+ end
339
+
340
+ # The worker's published proof first, then this process's own: either is
341
+ # enough, and no weaker result from the other side can take it back.
342
+ #
343
+ # Existence is the whole signal, so this guard cannot be defeated by a
344
+ # partial read and holds even where the file itself is not readable. Its
345
+ # contents are there for a human running `cat`.
346
+ def unsupported_topology?
347
+ File.exist?(topology_path) || @topology.unsafe?
348
+ end
349
+
234
350
  def expected
235
351
  Integer(File.read(expected_path))
236
352
  rescue StandardError
237
353
  nil
238
354
  end
239
355
 
356
+ def declared_generation
357
+ File.read(generation_path)
358
+ rescue StandardError
359
+ nil
360
+ end
361
+
362
+ def current_generation?
363
+ !@generation || declared_generation == @generation
364
+ end
365
+
366
+ def previous_generation?(path)
367
+ @generation && File.read(path)[/\bgeneration=(\S+)/, 1] != @generation
368
+ end
369
+
240
370
  def problem_for(slot, now)
241
371
  path = slot_path(slot)
242
372
  age = age_of(path, now)
@@ -25,29 +25,17 @@ module KicksLiveness
25
25
 
26
26
  # Prepended to +Sneakers::WorkerGroup+.
27
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+.
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
- # 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.
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
- # 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.
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
- # The same set the worker gem itself builds its workers from, so the
95
- # queue list is never duplicated and cannot drift. An array of classes
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)