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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 206f62be48c616553a219c499e1106beb68a49fd3c91ca30a1d7f3ee812c15f7
4
+ data.tar.gz: b2edcb9d1a6dd351df5b7a25d27e358582def6b6d8cdc4f7ad70b055ad1c25a9
5
+ SHA512:
6
+ metadata.gz: 06caaf5bfe502f5a83eebe11d03a8122f91ad03672fe2b11f046143ed107d6b81d126344cc293afc2ee311c6fb2f1a1f42739e82ff37b3e737cad7d3b71d97fb
7
+ data.tar.gz: 6cf539fef1a39318702015363035bc8f1538fa9cabaaa6543eca038a62059db98a21b9b82a2a53be62a0315e6872adc7b6891c586df52d1bff3700b2071eb700
data/CHANGELOG.md ADDED
@@ -0,0 +1,21 @@
1
+ # Changelog
2
+
3
+ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
4
+ and this project adheres to [Semantic Versioning](https://semver.org/).
5
+
6
+ ## [0.1.0] - 2026-09-08
7
+
8
+ ### Added
9
+
10
+ - First public release. A liveness probe for
11
+ [Kicks](https://github.com/ruby-amqp/kicks) and
12
+ [Sneakers](https://github.com/jondot/sneakers) workers: the worker publishes a
13
+ liveness mark to tmpfs, checking its consumers against the Bunny objects in
14
+ the process's memory, while the probe reads nothing but the mtime — no Rails
15
+ and no call to the broker.
16
+
17
+ What it does and why it is built this way is in
18
+ [docs/](https://github.com/PoroshkinaVV/kicks_liveness/tree/main/docs); start
19
+ with `SETUP.md`, and read `LIMITATIONS.md` before relying on it.
20
+
21
+ [0.1.0]: https://github.com/PoroshkinaVV/kicks_liveness/releases/tag/v0.1.0
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 PoroshkinaVV
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,319 @@
1
+ # kicks_liveness
2
+
3
+ A liveness probe for [Kicks](https://github.com/ruby-amqp/kicks) workers (and
4
+ for its predecessor Sneakers): the worker itself publishes a liveness mark to
5
+ tmpfs, and the probe only reads it.
6
+
7
+ ## Documentation
8
+
9
+ - [docs/SETUP.md](docs/SETUP.md) — installation and wiring: Rails, Sinatra,
10
+ Hanami, Roda, and no framework at all; what `install!` does and when to call
11
+ it.
12
+ - [docs/DESIGN.md](docs/DESIGN.md) — why the gem is built this way: the health
13
+ predicate, the mark files, slots instead of PIDs, where the hooks attach, what
14
+ is configurable and why `dir` and `max_age` live in the environment only.
15
+ - [docs/KUBERNETES.md](docs/KUBERNETES.md) — the manifest, the budget
16
+ arithmetic, verifying on a live pod, the alert on consumers.
17
+ - [docs/LIMITATIONS.md](docs/LIMITATIONS.md) — where the in-memory predicate
18
+ stops being truthful.
19
+ - [docs/VERIFYING.md](docs/VERIFYING.md) — the runbook that causes the real failures on a local cluster: what was observed, and how long each took.
20
+
21
+ ## Why
22
+
23
+ The usual implementation is a rake task that boots Rails and asks RabbitMQ for
24
+ `consumer_count`. It has three defects that cannot be fixed in place:
25
+
26
+ 1. **It is expensive.** Booting Rails every few seconds inside the worker's own
27
+ cgroup. On one real service this cost 2.7 s per invocation on a 15 s
28
+ period — 38% of the container's CPU request, around the clock.
29
+ 2. **It depends on the disk.** A disk that stalls for a few seconds stretches
30
+ the boot past `timeoutSeconds`, and the kubelet kills a healthy pod.
31
+ 3. **It cascades.** A liveness probe that checks an external dependency turns a
32
+ broker hiccup into every replica restarting at once, finishing the broker off.
33
+
34
+ On top of that, `consumer_count` is a metric of the **queue**, not of the pod:
35
+ with two replicas the live one covers for the stalled one, and the probe cannot
36
+ tell.
37
+
38
+ This gem inverts that. Every 10 seconds the worker checks, against the Bunny
39
+ objects **in its own memory**, that its consumers are in place, and touches a
40
+ file. The standard probe invocation took 156 ms in the fixture container, makes
41
+ no network call, boots no framework, and reads the state it judges from tmpfs,
42
+ which is RAM. It does still load Bundler, the interpreter and the gem's probe
43
+ files from the image filesystem, so the disk is not out of the picture
44
+ altogether — it is reduced to a small process startup instead of a full
45
+ application boot on every probe.
46
+
47
+ ## Installation
48
+
49
+ ```ruby
50
+ gem 'kicks_liveness'
51
+ ```
52
+
53
+ You need `kicks` (>= 3.0) **or** `sneakers` (>= 2.11). Neither is declared as a
54
+ dependency of this gem, on purpose: at runtime only the `Sneakers` namespace is
55
+ required, and both gems provide it. If neither is present, `install!` raises a
56
+ `LoadError` naming both. Both floors are exercised in CI at their exact
57
+ versions, not just through a `~>` that resolves to the newest release.
58
+
59
+ Keeping both gems in one `Gemfile` is **not** allowed, and nothing enforces
60
+ that: not Bundler, not a crash at boot. Both own the file `lib/sneakers.rb`, so
61
+ one silently wins the load path. An application that has moved to `kicks` would
62
+ keep executing `sneakers` 2.12 code while its `Gemfile.lock` claims otherwise.
63
+ `sneakers` also caps the `kicks` and `bunny` versions.
64
+
65
+ The gem installs its hooks itself through a Railtie. Outside Rails, call
66
+ `KicksLiveness.install!` before the runner starts: the tie to Rails is a single
67
+ conditionally required file; it is not loaded outside Rails, and no Rails code is
68
+ pulled in. The details, including the one genuine restriction — workers must be
69
+ started through `Sneakers::Runner` — are in [docs/SETUP.md](docs/SETUP.md).
70
+
71
+ ### Initializer
72
+
73
+ Optional. For example, a Rails application can disable the monitor locally:
74
+
75
+ ```ruby
76
+ KicksLiveness.configure do |config|
77
+ config.enabled = !Rails.env.local?
78
+ end
79
+ ```
80
+
81
+ The full list: `logger` (defaults lazily to `Sneakers.logger`), `enabled`,
82
+ `tick` (10 s), `startup_grace_ticks` (6).
83
+ A non-positive `tick` raises `ArgumentError`: the monitor thread sleeps on it,
84
+ so a non-positive value is not a setting but a broken monitor.
85
+
86
+ ### Environment variables
87
+
88
+ | Variable | Default | |
89
+ |---|---|---|
90
+ | `KICKS_LIVENESS_DIR` | `/opt/app/tmp/health` | marks directory, **must live on tmpfs** |
91
+ | `KICKS_LIVENESS_MAX_AGE` | `45` | seconds after which a mark is stale |
92
+ | `KICKS_LIVENESS_TICK` | `10` | interval between ticks |
93
+
94
+ `tick` is the one setting that appears in both places; the initializer wins over
95
+ the variable, and only the worker reads it either way.
96
+
97
+ **Two runners in one pod need two directories.** An application that runs both
98
+ `rake sneakers:run` and `rake sneakers:active_job` has two supervisors, each
99
+ numbering its forks from zero — so with the default `KICKS_LIVENESS_DIR` they
100
+ would overwrite each other's `expected` and `worker-0`, and the probe would
101
+ answer for whichever wrote last. In separate pods, which is the usual
102
+ arrangement, there is nothing to do: each gets its own `emptyDir`. In one pod,
103
+ give each runner its own `KICKS_LIVENESS_DIR`.
104
+
105
+ `dir` and `max_age` deliberately **cannot** be set in the initializer. The probe
106
+ is launched by the kubelet as a separate process, which does not — and cannot —
107
+ read the application's initializer. Were they settable in two places, a
108
+ mismatch between the worker and the probe would pass unnoticed.
109
+
110
+ Garbage in a value does not bring the worker down — it falls back to the default.
111
+ An empty string counts as unset: in a ConfigMap that is what you get by
112
+ declaring a key and leaving it blank. `max_age` and `tick` are durations, so
113
+ zero and negative values fall back too: they parse perfectly well, and a
114
+ negative `max_age` would make every mark stale on arrival.
115
+
116
+ ## Manifest
117
+
118
+ ```yaml
119
+ startupProbe:
120
+ exec:
121
+ command: ["bundle", "exec", "kicks-liveness"]
122
+ periodSeconds: 5
123
+ timeoutSeconds: 5
124
+ failureThreshold: 60
125
+ livenessProbe:
126
+ exec:
127
+ command: ["bundle", "exec", "kicks-liveness"]
128
+ periodSeconds: 30
129
+ timeoutSeconds: 5
130
+ failureThreshold: 3
131
+ terminationGracePeriodSeconds: 60
132
+ ```
133
+
134
+ The marks directory has to be on tmpfs:
135
+
136
+ ```yaml
137
+ volumeMounts:
138
+ - mountPath: /opt/app/tmp
139
+ name: app-tmp
140
+ volumes:
141
+ - name: app-tmp
142
+ emptyDir:
143
+ medium: Memory # without this the mark lands on disk
144
+ ```
145
+
146
+ The container needs a writable volume mounted at, or above,
147
+ `KICKS_LIVENESS_DIR`. The default mount at `/opt/app/tmp` covers
148
+ `/opt/app/tmp/health`, and the gem creates the `health` subdirectory itself.
149
+ Without this mount, a container using `readOnlyRootFilesystem: true` cannot
150
+ publish heartbeat files and never passes the startup probe.
151
+
152
+ Why it looks like this:
153
+
154
+ - **`bundle exec kicks-liveness` is the standard command**, and it works
155
+ regardless of where Bundler installed the gems. There is a faster form —
156
+ `ruby -e "require 'kicks_liveness/probe'"`, 52 ms against
157
+ 156 ms — but it needs the gems to be in `GEM_HOME`, and **setting
158
+ `BUNDLE_PATH` at all moves them**, even to the directory `GEM_HOME` already
159
+ points at. Check before you optimise:
160
+
161
+ ```bash
162
+ docker run --rm your-image bundle exec kicks-liveness
163
+ docker run --rm your-image ruby -e "require 'kicks_liveness/probe'"
164
+ ```
165
+
166
+ Both should print `no .../expected` and exit 1. If the second raises
167
+ `LoadError`, keep the standard command — at `periodSeconds: 30` it costs about
168
+ 0.005 of a core, against roughly 0.18 for a probe that boots the application.
169
+ The trade-offs are in
170
+ [docs/KUBERNETES.md](docs/KUBERNETES.md#where-your-image-puts-its-gems).
171
+
172
+ - **`startupProbe` is mandatory.** Startup and steady state have different time
173
+ budgets, and one probe cannot serve both. While it runs, liveness is disabled
174
+ and the container is not Ready. `failureThreshold: 60` buys 300 s, which is
175
+ deliberately generous — measure a *freshly created* pod before trimming it, as
176
+ a restarted container inherits a warm compile cache and a new pod does not:
177
+ [measure the cold start](docs/KUBERNETES.md#measure-the-cold-start-not-the-restart).
178
+ - **It also protects rollouts — given `maxUnavailable: 0`.** Because the
179
+ container is not Ready until the mark exists, a `RollingUpdate` that is not
180
+ allowed to drop below full capacity cannot remove the old pod before the new
181
+ one has subscribed. That is a property of the strategy as much as of the probe:
182
+ with a non-zero `maxUnavailable`, or with `Recreate`, the old pod may go first
183
+ and leave the queue uncovered.
184
+ - **`initialDelaySeconds` on liveness is unnecessary**; the startupProbe plays
185
+ that role.
186
+ - Before a kill there is `max_age + periodSeconds × failureThreshold` = 45 + 90 =
187
+ **135 s** of confirmed silence. Slow on purpose: a worker is not
188
+ latency-critical, and a false restart costs more than two minutes of stalling.
189
+
190
+ ## Required companion: an alert on consumers
191
+
192
+ The probe deliberately reports healthy while Bunny is reconnecting — otherwise a
193
+ broker hiccup would restart every replica at once. The price is that **broker
194
+ unavailability stops being visible automatically**. A pod can be consuming
195
+ nothing and look perfectly healthy.
196
+
197
+ So the probe needs an alert alongside it, and that alert should be in place
198
+ **before** the manifest is switched over:
199
+
200
+ ```yaml
201
+ - alert: QueueWithoutConsumers
202
+ expr: rabbitmq_detailed_queue_consumers{queue=~"myapp\..+", queue!~".*delayed_active_job.*"} == 0
203
+ for: 5m
204
+
205
+ # Not optional: the expression above cannot fire when the series is gone.
206
+ - alert: QueueConsumerMetricMissing
207
+ expr: absent_over_time(rabbitmq_detailed_queue_consumers{queue=~"myapp\..+"}[10m])
208
+ for: 5m
209
+ ```
210
+
211
+ Excluding the delayed queues is mandatory — they have no consumers by design, and
212
+ without the exclusion the alert fires permanently until someone silences it.
213
+ Match them as a **substring**: under ActiveJob the full name looks like
214
+ `myapp.active_job.myapp.delayed_active_job:60`, so an anchored pattern never
215
+ matches.
216
+
217
+ Two traps in getting that metric, both measured on RabbitMQ 3.13.7 rather than
218
+ recalled. The per-queue series is `rabbitmq_detailed_queue_consumers`, and it
219
+ only exists on `/metrics/detailed` **scraped with `family=queue_consumer_count`**
220
+ — bare, that endpoint serves no queue metrics at all, while plain `/metrics`
221
+ offers a same-sounding `rabbitmq_queue_consumers` that is a single label-less
222
+ cluster total, so a `{queue=~...}` matcher against it is quietly never true. And
223
+ the series **disappears** rather than going to zero when the queue is deleted or
224
+ the broker stops, which is why the second alert is there.
225
+ [docs/KUBERNETES.md](docs/KUBERNETES.md#getting-that-metric-at-all) has the
226
+ scrape config.
227
+
228
+ ## What is checked
229
+
230
+ The mark is written only if **every** worker in the process has subscribed:
231
+
232
+ | State | Verdict |
233
+ |---|---|
234
+ | consumers in place, channel open | healthy |
235
+ | Bunny is recovering the connection | **healthy** — the broker heals itself |
236
+ | connection open, no consumers | unhealthy, a restart is the cure |
237
+ | not the whole set subscribed | unhealthy |
238
+
239
+ The expected consumer count comes from `config[:worker_classes]` — the same set
240
+ kicks itself builds its workers from. That way the queue list is never
241
+ duplicated and cannot drift from the configuration.
242
+
243
+ The files are named by supervisor slot rather than by PID: a dead fork is
244
+ respawned into the same slot and overwrites its own file. With a PID in the
245
+ name, SIGKILL would leave a stale file forever. With `workers > 1` the probe
246
+ requires every slot to be fresh.
247
+
248
+ ## Limitations
249
+
250
+ The predicate is computed over the Bunny objects **in the process's memory** —
251
+ that is the whole point, but it has a boundary: it is exactly as truthful as
252
+ Bunny's own bookkeeping. The broker is never asked for its opinion.
253
+
254
+ **1. Consumer bookkeeping is updated on the worker's thread pool.** When the
255
+ broker cancels a consumer (on `consumer_timeout`, for example), it sends
256
+ `basic.cancel`, and Bunny removes the entry from its list — but it does so
257
+ through `@work_pool.submit`, that is, on the same pool that processes jobs
258
+ (`threads: 10` by default). Until a thread frees up, `any_consumers?` still
259
+ answers yes.
260
+
261
+ In practice this means: if **all** of the worker's threads are busy with stuck
262
+ jobs, the probe stays green even though the pod is consuming nothing. One stuck
263
+ job out of ten does not produce that effect.
264
+
265
+ **2. `recover_cancelled_consumers!` makes the probe blind to a cancelled
266
+ consumer.** `Bunny::Channel#recover_cancelled_consumers!` is an opt-in method
267
+ that neither Kicks nor Sneakers enables. With it on, Bunny re-subscribes the
268
+ consumer itself on `basic.cancel` and **keeps** the entry in its list — so the
269
+ "consumer was cancelled" case stops being detectable at all. Do not enable it
270
+ together with this probe.
271
+
272
+ **3. Broker unavailability is invisible by design** — see [the alert on
273
+ consumers](#required-companion-an-alert-on-consumers) above.
274
+
275
+ All three limitations point the same way: an alert on consumers, watching the
276
+ broker **from the outside**, is not a nice-to-have but part of the design. It
277
+ catches exactly what an in-memory predicate cannot see.
278
+
279
+ ## Verifying on a live pod
280
+
281
+ ```bash
282
+ kubectl exec deploy/myapp -- ls -l /opt/app/tmp/health/
283
+ kubectl exec deploy/myapp -- sh -c 'bundle exec kicks-liveness; echo "exit=$?"'
284
+ kubectl logs deploy/myapp | grep liveness
285
+ ```
286
+
287
+ Check the **negative** path too — otherwise a working probe is
288
+ indistinguishable from an always-green one:
289
+
290
+ ```bash
291
+ kubectl exec deploy/myapp -- sh -c 'touch -d "2 minutes ago" /opt/app/tmp/health/worker-0; bundle exec kicks-liveness; echo "exit=$?"'
292
+ ```
293
+
294
+ Expect `worker-0 stale 120s > 45s` and `exit=1`. One tick later the mark repairs
295
+ itself.
296
+
297
+ ## Logs
298
+
299
+ Events are logged, not the pulse: the pulse lives in the mark's mtime.
300
+
301
+ | Event | Level |
302
+ |---|---|
303
+ | started, with the effective settings | INFO |
304
+ | waiting for consumers | INFO |
305
+ | became healthy | INFO |
306
+ | shutting down | INFO |
307
+ | still not healthy after `startup_grace_ticks` ticks | ERROR |
308
+ | was healthy, became unhealthy | ERROR |
309
+ | a slot respawning without ever becoming healthy, once per grace window | ERROR |
310
+ | the monitor thread caught an exception | ERROR |
311
+ | the monitor could not be started at all | ERROR |
312
+
313
+ ERROR is reserved for genuine failure: a logger usually comes up with
314
+ `LOG_LEVEL` defaulting to `error`, so anything that matters must survive that
315
+ filter, while an ordinary deploy must not make noise. That is also why shutdown
316
+ is an event of its own: the workers unsubscribe while the thread pool drains,
317
+ and judged by the consumer count alone a normal deploy would look exactly like
318
+ a fault. From the moment shutdown begins the probe keeps the mark fresh
319
+ instead, so a slow drain gets the full `terminationGracePeriodSeconds`.