kicks_liveness 0.1.0 → 0.1.2

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,5 +1,57 @@
1
1
  module KicksLiveness
2
- # The liveness mark on the filesystem: written by the worker, read by the
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+.
6
+ # @api private
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
12
+ # @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')
37
+ end
38
+
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
51
+ end
52
+ end
53
+
54
+ # The liveness marks on the filesystem: written by the worker, read by the
3
55
  # probe.
4
56
  #
5
57
  # The directory must live on tmpfs — in Kubernetes, an emptyDir with
@@ -17,6 +69,8 @@ module KicksLiveness
17
69
  #
18
70
  # @see file:docs/DESIGN.md#why-the-heartbeat-file-has-no-require-of-its-own
19
71
  class Heartbeat
72
+ include GenerationGuard
73
+
20
74
  # @return [String] marks directory used when the environment says nothing
21
75
  DEFAULT_DIR = '/opt/app/tmp/health'.freeze
22
76
  # @return [Integer] seconds after which a mark is stale, by default
@@ -29,6 +83,16 @@ module KicksLiveness
29
83
  tick: 'KICKS_LIVENESS_TICK'
30
84
  }.freeze
31
85
 
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
91
+ # @api private
92
+ def self.container_generation
93
+ ContainerGeneration.current
94
+ end
95
+
32
96
  # An empty string counts as unset: in a ConfigMap that is what you get by
33
97
  # declaring a key and leaving it blank.
34
98
  #
@@ -72,9 +136,15 @@ module KicksLiveness
72
136
 
73
137
  # @param dir [String] marks directory
74
138
  # @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)
139
+ # @param generation [String, nil] container incarnation; injected in specs
140
+ def initialize(
141
+ dir: Heartbeat.env_dir,
142
+ max_age: Heartbeat.env_max_age,
143
+ generation: Heartbeat.container_generation
144
+ )
76
145
  @dir = dir
77
146
  @max_age = max_age
147
+ @generation = generation
78
148
  end
79
149
 
80
150
  attr_reader :dir, :max_age
@@ -93,10 +163,8 @@ module KicksLiveness
93
163
  # @return [void]
94
164
  def declare!(processes)
95
165
  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)
166
+ atomic_write(generation_path, @generation) if @generation
167
+ atomic_write(expected_path, processes)
100
168
  end
101
169
 
102
170
  # Refreshes this fork's mark.
@@ -106,14 +174,18 @@ module KicksLiveness
106
174
  # PID in the name that file would stay stale forever and the probe would fail
107
175
  # permanently.
108
176
  #
109
- # The contents exist only for a human running <tt>kubectl exec ... cat</tt>;
110
- # the probe decides on mtime alone.
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.
111
181
  #
112
182
  # @param slot [Integer] supervisor slot of this fork
113
183
  # @return [Integer] bytes written
114
184
  def touch!(slot)
115
185
  make_dir
116
- File.write(slot_path(slot), "#{Time.now.utc.strftime('%FT%TZ')} pid=#{Process.pid} slot=#{slot}\n")
186
+ contents = "#{Time.now.utc.strftime('%FT%TZ')} pid=#{Process.pid} slot=#{slot}"
187
+ contents = "#{contents} generation=#{@generation}" if @generation
188
+ atomic_write(slot_path(slot), "#{contents}\n")
117
189
  end
118
190
 
119
191
  # The probe side: is every declared fork's mark present and fresh?
@@ -126,13 +198,9 @@ module KicksLiveness
126
198
  def check(now: Time.now.utc)
127
199
  processes = expected
128
200
  return [false, "no #{expected_path}: worker has not started yet"] unless processes&.positive?
201
+ return [false, 'heartbeat belongs to a previous container: worker has not started yet'] unless current_generation?
129
202
 
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
203
+ problems = (0...processes).filter_map { |slot| problem_for(slot, now) }
136
204
 
137
205
  problems.empty? ? [true, "#{processes} process(es) healthy"] : [false, problems.join('; ')]
138
206
  end
@@ -169,6 +237,25 @@ module KicksLiveness
169
237
  nil
170
238
  end
171
239
 
240
+ def problem_for(slot, now)
241
+ path = slot_path(slot)
242
+ age = age_of(path, now)
243
+ return "worker-#{slot} missing" if age.nil?
244
+ return "worker-#{slot} belongs to a previous container" if previous_generation?(path)
245
+
246
+ "worker-#{slot} stale #{age.round}s > #{@max_age}s" if age > @max_age
247
+ rescue StandardError
248
+ "worker-#{slot} unreadable"
249
+ end
250
+
251
+ def atomic_write(path, contents)
252
+ # The pid keeps concurrent forks from sharing the temporary file.
253
+ tmp = "#{path}.#{Process.pid}"
254
+ bytes = File.write(tmp, contents)
255
+ File.rename(tmp, path)
256
+ bytes
257
+ end
258
+
172
259
  def age_of(path, now)
173
260
  now - File.mtime(path)
174
261
  rescue StandardError
@@ -60,7 +60,7 @@ module KicksLiveness
60
60
  consumers: kicks_liveness_expected_consumers
61
61
  )
62
62
  rescue StandardError => e
63
- KicksLiveness.config.resolved_logger&.error("[liveness] failed to start: #{e.class}: #{e.message}")
63
+ report_start_failure(e)
64
64
  end
65
65
  end
66
66
 
@@ -80,6 +80,17 @@ module KicksLiveness
80
80
 
81
81
  private
82
82
 
83
+ def report_start_failure(error)
84
+ KicksLiveness.config.resolved_logger&.error(
85
+ "[liveness] failed to start: #{error.class}: #{error.message}"
86
+ )
87
+ rescue StandardError
88
+ # This is already the failure path. Neither resolving the configuration
89
+ # again nor a broken logger may let liveness instrumentation stop the
90
+ # worker process it is meant to observe.
91
+ nil
92
+ end
93
+
83
94
  # The same set the worker gem itself builds its workers from, so the
84
95
  # queue list is never duplicated and cannot drift. An array of classes
85
96
  # under sneakers:run, a callable registry under sneakers:active_job.
@@ -173,6 +173,12 @@ module KicksLiveness
173
173
 
174
174
  def log(level, message)
175
175
  @config.resolved_logger&.public_send(level, "[liveness] slot #{@slot}: #{message}")
176
+ rescue StandardError
177
+ # Logging is diagnostic, while the heartbeat is the liveness contract. A
178
+ # broken custom logger must not prevent a mark from being written or kill
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.
181
+ nil
176
182
  end
177
183
  end
178
184
  end
@@ -1,4 +1,4 @@
1
1
  module KicksLiveness
2
2
  # @return [String] gem version
3
- VERSION = '0.1.0'.freeze
3
+ VERSION = '0.1.2'.freeze
4
4
  end
@@ -9,8 +9,8 @@ require_relative 'kicks_liveness/hooks'
9
9
  # Liveness probe for Kicks and Sneakers workers, backed by a tmpfs heartbeat.
10
10
  #
11
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.
12
+ # consumers in memory; the probe reads only the container generation and mark
13
+ # mtimes. No Rails, no call to the broker.
14
14
  #
15
15
  # @see file:docs/SETUP.md
16
16
  # @see file:docs/DESIGN.md
@@ -46,10 +46,13 @@ module KicksLiveness
46
46
  # <tt>gem 'kicks', require: false</tt> the to_prepare hook runs earlier and
47
47
  # would fail on NameError.
48
48
  #
49
- # @raise [LoadError] if neither +kicks+ nor +sneakers+ is available
49
+ # @raise [LoadError] if neither worker gem is available, or if both +kicks+
50
+ # and +sneakers+ are activated
50
51
  # @return [Module]
51
52
  # @see file:docs/SETUP.md#installing-the-hooks
52
53
  def install!
54
+ reject_ambiguous_worker_gems!
55
+
53
56
  begin
54
57
  require 'sneakers'
55
58
  require 'sneakers/workergroup'
@@ -80,6 +83,15 @@ module KicksLiveness
80
83
 
81
84
  Monitor.new(slot: slot, processes: processes, consumers: consumers, config: config).start!
82
85
  end
86
+
87
+ private
88
+
89
+ def reject_ambiguous_worker_gems!
90
+ return unless defined?(Gem.loaded_specs)
91
+ return unless Gem.loaded_specs.key?('kicks') && Gem.loaded_specs.key?('sneakers')
92
+
93
+ raise LoadError, 'kicks_liveness cannot run with both kicks and sneakers activated; install exactly one'
94
+ end
83
95
  end
84
96
  end
85
97
 
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.0
4
+ version: 0.1.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - PoroshkinaVV
@@ -11,12 +11,8 @@ date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies: []
12
12
  description: |
13
13
  A liveness probe for RabbitMQ worker pods that loads no Rails and never talks
14
- to the broker. The worker publishes a heartbeat to tmpfs from inside its own
15
- process, checking its Bunny consumers in memory; the probe only reads the
16
- file's mtime and runs as `bundle exec kicks-liveness`. It loads no application
17
- code; aside from Bundler and the interpreter it loads only the gem's small
18
- probe files, while the state itself comes from tmpfs. A broker hiccup cannot
19
- restart every replica at once.
14
+ to the broker. Workers check their Bunny consumers in memory and publish a
15
+ heartbeat to tmpfs; the probe only reads that heartbeat.
20
16
  email:
21
17
  - lera.poroshkina@mail.ru
22
18
  executables:
@@ -24,6 +20,7 @@ executables:
24
20
  extensions: []
25
21
  extra_rdoc_files: []
26
22
  files:
23
+ - ".yardopts"
27
24
  - CHANGELOG.md
28
25
  - LICENSE.txt
29
26
  - README.md
@@ -48,7 +45,7 @@ licenses:
48
45
  - MIT
49
46
  metadata:
50
47
  source_code_uri: https://github.com/PoroshkinaVV/kicks_liveness
51
- documentation_uri: https://rubydoc.info/gems/kicks_liveness/0.1.0
48
+ documentation_uri: https://rubydoc.info/gems/kicks_liveness/0.1.2
52
49
  changelog_uri: https://github.com/PoroshkinaVV/kicks_liveness/blob/main/CHANGELOG.md
53
50
  bug_tracker_uri: https://github.com/PoroshkinaVV/kicks_liveness/issues
54
51
  rubygems_mfa_required: 'true'