kicks_liveness 0.1.0

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.
data/docs/DESIGN.md ADDED
@@ -0,0 +1,325 @@
1
+ # Design
2
+
3
+ Why this gem is shaped the way it is. If you only want to deploy it, read
4
+ [KUBERNETES.md](KUBERNETES.md) instead.
5
+
6
+ ## The problem with the usual liveness probe
7
+
8
+ The common implementation is a rake task, invoked by the kubelet through `exec`,
9
+ that boots the application and asks the broker for `consumer_count` on the
10
+ queues it expects to be served. It has four defects, and three of them cannot
11
+ be tuned away.
12
+
13
+ **It is expensive.** Booting a full Rails application every few seconds, inside
14
+ the worker's own cgroup. A probe measured at 2.7 s of CPU per invocation on a
15
+ 15 s period consumes `2.7 / 15 ≈ 0.18` of a core continuously, per pod, forever.
16
+ Multiply by your replica count and compare it against the CPU request of the
17
+ container; on a small worker fleet the probe can easily outweigh the work.
18
+
19
+ **It depends on the disk.** Loading code means reading files. A disk that stalls
20
+ for a few seconds stretches the probe past `timeoutSeconds`, and the kubelet
21
+ kills a pod that was perfectly healthy. A stalling disk is one of the most
22
+ common causes of false restarts, and a probe that reads from it converts that
23
+ cause into an outage.
24
+
25
+ **It cascades.** A liveness probe that checks an external dependency makes that
26
+ dependency a participant in your restart logic. The broker hiccups for ten
27
+ seconds, every replica fails its probe at the same moment, and the whole fleet
28
+ restarts simultaneously — reconnecting all at once, which is exactly what a
29
+ struggling broker least needs.
30
+
31
+ **It measures the wrong thing.** `consumer_count` is a property of a *queue*,
32
+ not of a *pod*. With two replicas, one healthy consumer keeps the count above
33
+ zero while the other replica is hung and consuming nothing. The metric that is
34
+ supposed to detect a dead pod is structurally blind to it.
35
+
36
+ ## The inversion
37
+
38
+ Turn the data flow around. The worker knows its own state better than anything
39
+ outside it can, so let the worker publish and let the probe read.
40
+
41
+ Every `tick` seconds (10 by default) the worker checks, **in its own memory**,
42
+ that its consumers are still subscribed, and touches a file on tmpfs. The probe
43
+ loads one dependency-free Ruby file, reads the file's mtime, and exits with 0 or
44
+ 1.
45
+
46
+ The probe therefore performs no network I/O and boots no framework, and the
47
+ state it reads — the mark's mtime — comes from tmpfs, which is RAM. Be precise
48
+ about the disk, though: starting the probe still loads the Ruby interpreter and
49
+ two files of this gem from the image filesystem, and those reads are ordinary
50
+ filesystem reads (usually served from page cache, but not guaranteed to be). The
51
+ honest claim is not "no disk" but *no application boot, and no disk on the path
52
+ that decides the answer* — which is what removes the defects above, not tuning.
53
+
54
+ ## The health predicate
55
+
56
+ Computed only over Bunny objects held in the worker process:
57
+
58
+ ```ruby
59
+ channel = worker.queue.channel
60
+ return false if channel.nil?
61
+
62
+ connection = channel.connection
63
+ return true if recovering?(connection)
64
+
65
+ channel.open? && connection.open? && channel.any_consumers?
66
+ ```
67
+
68
+ | State | Verdict | Why |
69
+ |---|---|---|
70
+ | consumers present, channel open | healthy | — |
71
+ | `channel` still `nil` | unhealthy | subscription has not happened yet; `startupProbe` holds the pod |
72
+ | Bunny recovering from a network failure | **healthy** | Bunny reconnects and re-subscribes on its own; restarting now cures nothing and adds a reconnect storm |
73
+ | connection open, no consumers | unhealthy | the worker silently stopped consuming — a restart is the only cure |
74
+ | not the whole set subscribed | unhealthy | a worker that quietly dropped out would otherwise leave the probe green |
75
+
76
+ The last row is the reason the registry compares its size against an expected
77
+ count rather than just checking that whatever registered is alive. Four healthy
78
+ workers out of five look perfectly fine one object at a time.
79
+
80
+ ### Nothing here talks to the broker
81
+
82
+ Every call in the predicate reads process-local state. Verified against Bunny 3.2.0:
83
+
84
+ | Call | Implementation |
85
+ |---|---|
86
+ | `worker.queue.channel` | `attr_reader` on `Sneakers::Queue` |
87
+ | `channel.connection` | `attr_reader` on `Bunny::Channel` |
88
+ | `connection.recovering_from_network_failure?` | `@recovery_mutex.synchronize { @recovering_from_network_failure }` |
89
+ | `channel.open?` | `@status == :open` |
90
+ | `connection.open?` | status under `@status_mutex`, then `@transport.open?` → `@socket && !@socket.closed?` |
91
+ | `channel.any_consumers?` | `@consumer_mutex.synchronize { @consumers.any? }` |
92
+
93
+ `IO#closed?` reports the state of the IO object in this process; it is not a
94
+ syscall and not a packet. By contrast, `consumer_count` is a genuine round trip —
95
+ a passive `Queue.Declare` or an HTTP API call. That round trip is what this gem
96
+ does not make.
97
+
98
+ The predicate learns that a consumer was cancelled because RabbitMQ sends
99
+ `basic.cancel` and Bunny deletes the consumer from its own registry. That is a
100
+ push, not a poll — which is what makes an in-memory predicate possible at all,
101
+ and also where its limits come from. See [LIMITATIONS.md](LIMITATIONS.md).
102
+
103
+ `recovering_from_network_failure?` is marked `@private` in Bunny's own
104
+ documentation, so an upgrade may remove it. It is therefore called through
105
+ `respond_to?`: without that guard the call would raise `NoMethodError`, `alive?`
106
+ would catch it and report *unhealthy*, and every pod would enter a restart loop
107
+ on the next Bunny upgrade. What the guard cannot preserve is the exemption
108
+ itself — if the method disappears, so does "healthy while reconnecting",
109
+ silently. That is the thing to re-check when upgrading Bunny.
110
+
111
+ ## The mark files
112
+
113
+ ```
114
+ <dir>/expected how many forks the probe must wait for
115
+ <dir>/worker-<slot> one per fork, refreshed every tick the fork is healthy
116
+ <dir>/attempt-<slot> starts of a slot that has not become healthy yet
117
+ ```
118
+
119
+ **`expected` closes a startup hole.** Without it the probe would pass as soon as
120
+ any single file was fresh: fork 0 has written, fork 1 has not, and the pod looks
121
+ ready while half of it is not subscribed. Every fork writes the same value, so
122
+ they cannot disagree.
123
+
124
+ Every fork rewrites it on every tick, not only at startup. Nothing else restores
125
+ it: if the directory is wiped, `touch!` brings the slot marks back while
126
+ `expected` stays missing, and the probe reports "worker has not started" for the
127
+ rest of the pod's life. Rewriting it is also what lets a respawned set of forks
128
+ correct a count that has been lowered.
129
+
130
+ **Files are named by supervisor slot, not by PID.** A fork killed with SIGKILL
131
+ is respawned into the same slot and overwrites its own file. Had the name
132
+ contained a PID, that file would sit there stale forever and the probe would
133
+ fail permanently, turning one dead fork into a pod that can never come back.
134
+
135
+ `expected` is written through a temporary file and a `rename`, not with a plain
136
+ write. A plain write truncates first, and a probe reading in that window finds
137
+ the file empty and reports that the worker has not started. The window is real
138
+ and recurring: every fork rewrites this file on every tick, while the liveness
139
+ probe reads it on a schedule of its own. `rename` is atomic on tmpfs, so the
140
+ probe sees either the old value or the new one.
141
+
142
+ **`attempt-<slot>` is the respawn counter**, and it lives in this directory for
143
+ the single reason that the directory outlives the fork: a monitor caught in a
144
+ respawn loop is a brand-new object every few hundred milliseconds and can hold
145
+ no counter of its own. The file is removed once the slot becomes healthy, so in
146
+ steady state the directory holds only `expected` and the `worker-<slot>` marks;
147
+ what the counter is for is in
148
+ [LIMITATIONS.md](LIMITATIONS.md#a-respawn-loop-is-reported-once-per-grace-window-not-once-per-respawn).
149
+
150
+ The *contents* of `worker-<slot>` (timestamp, pid, slot) exist only for a human
151
+ running `kubectl exec ... cat`. The probe decides on mtime alone.
152
+
153
+ The directory must be on tmpfs — in Kubernetes, an `emptyDir` with
154
+ `medium: Memory`. Put it on a real disk and the probe starts depending on the
155
+ disk again, which was defect number two.
156
+
157
+ ## Why the heartbeat file has no `require` of its own
158
+
159
+ The probe loads exactly one file and nothing else. Measured over 30 invocations
160
+ each; absolute values depend on the hardware, but the ratios do not:
161
+
162
+ | | per invocation |
163
+ |---|---|
164
+ | `ruby --disable-gems -e ''` — bare interpreter | 10 ms |
165
+ | `ruby -e ''` — RubyGems initialized | 38 ms |
166
+ | the probe, with `--disable-gems` and an explicit path | 10.5 ms |
167
+ | the probe, found through RubyGems | 43 ms |
168
+ | **`bundle exec ruby -e ''`** | **300 ms** |
169
+
170
+ Subtract the baselines and the probe's own work is a **few milliseconds**.
171
+ Everything else is interpreter startup, and 28 ms of that is RubyGems
172
+ initializing.
173
+
174
+ Three consequences follow. `bundle exec kicks-liveness` is the portable
175
+ default; most of its runtime is Bundler startup rather than the check itself.
176
+ When the gem is installed in `GEM_HOME`, RubyGems can find it with a plain
177
+ `require` for under 50 ms. Skipping RubyGems entirely is faster still, but it
178
+ requires naming the path to the gem's `lib` directory; see
179
+ [KUBERNETES.md](KUBERNETES.md) for when that trade is worth making.
180
+
181
+ Keeping the probe's path through the heartbeat file free of `require`
182
+ statements — and the file free of references to the rest of the gem — is what
183
+ makes all three possible. The single `require` the file does contain,
184
+ `fileutils`, is lazy and sits in the directory-creation branch that only the
185
+ worker ever reaches.
186
+
187
+ ## Where the hooks attach
188
+
189
+ Two `prepend`s, installed by `KicksLiveness.install!` (automatically through a
190
+ Railtie in Rails), covering four methods:
191
+
192
+ - `Sneakers::Worker#run` — register in the registry *after* `super`, so a worker
193
+ whose `subscribe` raised never registers and the probe honestly fails.
194
+ - `Sneakers::Worker#stop` — deregister *before* `super`.
195
+ - `Sneakers::WorkerGroup#after_fork` — start the monitor thread *after* `super`.
196
+ - `Sneakers::WorkerGroup#stop` — record that the shutdown is deliberate, *before*
197
+ `super` starts unsubscribing.
198
+
199
+ `after_fork` rather than `run`, even though `run` is where the monitor must
200
+ ultimately live: `Sneakers::WorkerGroup#run` calls `after_fork` as its very first
201
+ statement and only then resolves `worker_classes`, so hooking `after_fork` is the
202
+ earliest point in the fork at which the application's own `after_fork` hook has
203
+ already run. That matters because resolving the expected consumer count may run
204
+ application code — under `sneakers:active_job` the set is a callable registry —
205
+ and the standard use of `after_fork` is re-establishing the connections a fork
206
+ inherited. Resolving the set first would run that code against a fork that is not
207
+ ready yet, and the `rescue` around the start would then leave the pod with no
208
+ monitor at all. A spec asserts this ordering against the real worker gem, so an
209
+ upgrade that reorders `run` fails in CI rather than in production.
210
+
211
+ `ServerEngine::Server#create_worker` calls `w.extend(@worker_module)`: the
212
+ `WorkerGroup` module lands in the singleton class of the *instance*, not as an
213
+ `include` in a class. A `prepend` to the module still sits ahead of it in that
214
+ lookup chain, which is what makes the hook work; `worker_id` and `config` come
215
+ from `ServerEngine::Worker`. Verified on serverengine 2.4.0, and covered by a
216
+ spec that asserts the order in `ancestors` — this is the most fragile assumption
217
+ in the gem, so it fails in CI rather than in production.
218
+
219
+ Slot numbers come from `worker_id`, which is the index into ServerEngine's
220
+ `@monitors` array, and both `start_new_worker(wid)` and `restart_worker(wid)`
221
+ reuse the same index. That is what the slot-not-PID scheme relies on.
222
+
223
+ The monitor thread is created inside the fork, because threads do not survive
224
+ `fork`. Starting it earlier would accomplish nothing. For the same reason the
225
+ registry initializes its array and mutex at file-load time: that happens once,
226
+ single-threaded, before any fork, so no lazy initialization is needed and none
227
+ can race.
228
+
229
+ In Rails the hooks are installed from a Railtie, and from `to_prepare` rather
230
+ than an `initializer`. An application's `lib` is normally managed by Zeitwerk and
231
+ reloadable, and reloadable constants must not be referenced while the
232
+ application is initializing.
233
+
234
+ Starting the monitor is wrapped in a `rescue`: instrumentation has no right to
235
+ prevent a worker from starting. `Dir.mkdir` raises `Errno::EROFS` if the marks
236
+ directory was never mounted, and resolving the worker set runs application code.
237
+ The worst outcome should be a missing mark and a pod restart, never a pod that
238
+ stays up with silent queues.
239
+
240
+ ### The expected consumer count is not configured
241
+
242
+ It is derived from `config[:worker_classes]` — the very set from which the
243
+ worker gem builds its workers. The queue list is therefore never duplicated and
244
+ cannot drift from the application's configuration.
245
+
246
+ That value is an array of classes under `sneakers:run` and a callable registry
247
+ under `sneakers:active_job`, so it is resolved with a `respond_to?(:call)`
248
+ check.
249
+
250
+ ## What is configurable, and where
251
+
252
+ | | Where | Why |
253
+ |---|---|---|
254
+ | `logger`, `enabled`, `startup_grace_ticks` | application config block | only the worker needs them |
255
+ | `tick` | either, and the config block wins | only the worker reads it, so two sources cannot contradict each other |
256
+ | `dir`, `max_age` | environment variables **only** | the probe is a separate process |
257
+
258
+ `dir` and `max_age` are deliberately *not* settable from the application. The
259
+ probe is launched by the kubelet as its own process; it never reads, and cannot
260
+ read, the application's initializer. If those two values were configurable in
261
+ both places, a mismatch between what the worker writes and what the probe
262
+ expects would pass unnoticed — and that mismatch is silent by nature: the worker
263
+ would look fine and the probe would look broken. Making them read-only from the
264
+ application removes the failure mode instead of documenting it. A spec asserts
265
+ that `Configuration` does not respond to `dir=` or `max_age=`.
266
+
267
+ `tick` is the opposite case and may be set from either side, because only the
268
+ worker ever reads it: with a single reader there is nothing for a mismatch to
269
+ happen between. `KICKS_LIVENESS_TICK` supplies the initial value and the
270
+ configuration block overrides it, so `config.tick = 5` wins over the variable.
271
+
272
+ Values arriving from a ConfigMap are parsed defensively: garbage falls back to
273
+ the default rather than crashing the worker, and an empty string counts as
274
+ "unset" — in a ConfigMap that is what you get by declaring a key and leaving it
275
+ blank.
276
+
277
+ `max_age` and `tick` are durations, so a value must be parseable *and* positive.
278
+ Zero and negative numbers are the more dangerous half: they parse perfectly
279
+ well, a tick of zero turns the monitor into a hot loop, and a negative `max_age`
280
+ makes every mark stale on arrival, so the probe can never pass again. They fall
281
+ back to the default too.
282
+
283
+ Setting `tick` from the application is held to a stricter standard: a
284
+ non-positive value raises `ArgumentError`. The environment gets a silent fallback
285
+ because a ConfigMap typo must not bring a worker down, whereas an initializer is
286
+ code, and code should fail loudly at boot, where the developer is looking.
287
+
288
+ The logger defaults to `Sneakers.logger` but is resolved lazily, because at the
289
+ time the configuration object is built it may not be set up yet.
290
+
291
+ ## Logging: events, not the pulse
292
+
293
+ The pulse lives in the mtime of a file; writing a log line every tick would only
294
+ add noise. What gets logged is transitions.
295
+
296
+ | Event | Level |
297
+ |---|---|
298
+ | started, with the effective settings | INFO |
299
+ | waiting for consumers | INFO |
300
+ | became healthy | INFO |
301
+ | shutting down | INFO |
302
+ | still not healthy after `startup_grace_ticks` ticks | ERROR |
303
+ | was healthy, became unhealthy | ERROR |
304
+ | a slot respawning without ever becoming healthy, once per grace window | ERROR |
305
+ | the monitor thread caught an exception | ERROR |
306
+ | the monitor could not be started at all | ERROR |
307
+
308
+ ERROR is reserved for genuine failure. A logger typically comes up with
309
+ `LOG_LEVEL` defaulting to `error`, so anything important must survive that
310
+ filter — while an ordinary pod start must not emit ERROR on every deploy, or an
311
+ alert on ERROR logs fires on every rollout and is promptly muted.
312
+
313
+ A graceful shutdown is the other half of that rule. `Sneakers::Worker#stop`
314
+ unsubscribes and then waits for the thread pool to drain, which takes as long as
315
+ the longest in-flight job — and the registry empties at the start of it. Judged
316
+ by the consumer count that is indistinguishable from a fault, so every single
317
+ deploy would emit an ERROR. From `WorkerGroup#stop` onwards the predicate is
318
+ therefore suspended: the monitor says so once at INFO and keeps the mark fresh,
319
+ so a slow drain gets the full `terminationGracePeriodSeconds` instead of having
320
+ the probe cut it short at `max_age`.
321
+
322
+ The startup grace exists because a slow start and a start that will never finish
323
+ look identical for the first few ticks. Once the grace expires, staying silent
324
+ would mean a pod that never comes up never says so — hence exactly one ERROR,
325
+ not one per tick.