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.
- checksums.yaml +4 -4
- data/.yardopts +13 -0
- data/CHANGELOG.md +58 -0
- data/README.md +133 -249
- data/docs/DESIGN.md +51 -16
- data/docs/KUBERNETES.md +32 -9
- data/docs/LIMITATIONS.md +54 -15
- data/docs/SETUP.md +16 -11
- data/docs/VERIFYING.md +54 -9
- data/lib/kicks_liveness/configuration.rb +73 -10
- data/lib/kicks_liveness/heartbeat.rb +102 -15
- data/lib/kicks_liveness/hooks.rb +12 -1
- data/lib/kicks_liveness/monitor.rb +6 -0
- data/lib/kicks_liveness/version.rb +1 -1
- data/lib/kicks_liveness.rb +15 -3
- metadata +5 -8
|
@@ -1,5 +1,57 @@
|
|
|
1
1
|
module KicksLiveness
|
|
2
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
97
|
-
|
|
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
|
|
110
|
-
#
|
|
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
|
-
|
|
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
|
|
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
|
data/lib/kicks_liveness/hooks.rb
CHANGED
|
@@ -60,7 +60,7 @@ module KicksLiveness
|
|
|
60
60
|
consumers: kicks_liveness_expected_consumers
|
|
61
61
|
)
|
|
62
62
|
rescue StandardError => e
|
|
63
|
-
|
|
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
|
data/lib/kicks_liveness.rb
CHANGED
|
@@ -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
|
|
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
|
|
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.
|
|
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.
|
|
15
|
-
|
|
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.
|
|
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'
|