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.
@@ -0,0 +1,426 @@
1
+ # Running under Kubernetes
2
+
3
+ ## Manifest
4
+
5
+ ```yaml
6
+ startupProbe:
7
+ exec:
8
+ command: ["bundle", "exec", "kicks-liveness"]
9
+ periodSeconds: 5
10
+ timeoutSeconds: 5
11
+ failureThreshold: 60
12
+
13
+ livenessProbe:
14
+ exec:
15
+ command: ["bundle", "exec", "kicks-liveness"]
16
+ periodSeconds: 30
17
+ timeoutSeconds: 5
18
+ failureThreshold: 3
19
+
20
+ terminationGracePeriodSeconds: 60
21
+ ```
22
+
23
+ The marks directory must be on tmpfs:
24
+
25
+ ```yaml
26
+ volumeMounts:
27
+ - mountPath: /opt/app/tmp
28
+ name: app-tmp
29
+ volumes:
30
+ - name: app-tmp
31
+ emptyDir:
32
+ medium: Memory # without this the mark lands on a disk
33
+ ```
34
+
35
+ The container must have a writable volume mounted at, or above,
36
+ `KICKS_LIVENESS_DIR`. With the default value, mounting `/opt/app/tmp` covers
37
+ `/opt/app/tmp/health`, and the gem creates the `health` subdirectory itself.
38
+
39
+ This mount is required when `readOnlyRootFilesystem: true`. Without it, the
40
+ worker cannot create the heartbeat files, the startup probe never succeeds,
41
+ and Kubernetes eventually restarts the container. If you change
42
+ `KICKS_LIVENESS_DIR`, make sure that `volumeMount.mountPath` covers the new
43
+ location.
44
+
45
+ Without `medium: Memory` an `emptyDir` is backed by the node's disk, and the
46
+ probe starts depending on the disk again — which is one of the things it exists
47
+ to avoid.
48
+
49
+ ## Why the command looks like that
50
+
51
+ **`bundle exec kicks-liveness` is the standard command.** It works regardless
52
+ of where Bundler installed the gems, because Bundler is the thing that knows
53
+ where it put them. It is what you should reach for unless you have measured a
54
+ reason not to.
55
+
56
+ There is a faster form, and the difference is real but small — measured in a
57
+ container on the fixture in [VERIFYING.md](VERIFYING.md):
58
+
59
+ | | per invocation | works when |
60
+ |---|---|---|
61
+ | `bundle exec kicks-liveness` | 156 ms | always |
62
+ | `ruby -e "require 'kicks_liveness/probe'"` | 52 ms | the gems are in `GEM_HOME` |
63
+ | `ruby --disable-gems -I <dir> -e "..."` | ~10 ms | you add a line to the Dockerfile |
64
+
65
+ Those three were timed inside a container, twenty invocations each, on the
66
+ fixture in [VERIFYING.md](VERIFYING.md). Absolute numbers move with the machine
67
+ — [DESIGN.md](DESIGN.md#why-the-heartbeat-file-has-no-require-of-its-own) breaks
68
+ the same commands down on a host and gets smaller ones. The ratios are what to
69
+ plan against.
70
+
71
+ Keep the second and third in proportion. At `periodSeconds: 30`, the standard
72
+ command costs `0.156 / 30 ≈ 0.005` of a core and the fastest one saves about
73
+ `0.005`. Against a probe that boots the application — 4 s in the median and 27 s
74
+ in the tail, which is what this gem replaces — all three have already won by two
75
+ orders of magnitude. Reach for the optimised forms when you are on a very short
76
+ period or a very tight CPU request, not by default.
77
+
78
+ The bare executable, `kicks-liveness` without `bundle exec`, is a third thing
79
+ again: it depends on the gem's `bin` directory being on `PATH`, which it is not
80
+ in an image that moves its gems. Use it from `kubectl exec` by hand if it works
81
+ there; do not put it in a manifest.
82
+
83
+ **`startupProbe` is not optional.** Startup and steady state have different time
84
+ budgets, and one probe cannot serve both. While the startup probe runs, liveness
85
+ is disabled and the container is not Ready.
86
+
87
+ That last part also protects a rollout, **on the condition that the strategy is
88
+ not allowed to drop below full capacity** — `RollingUpdate` with
89
+ `maxUnavailable: 0` (which is what `25%` rounds down to at one replica). Then
90
+ Kubernetes may not remove the old pod until the new one is Ready, and Ready here
91
+ means the mark is on tmpfs, which means the consumers are subscribed. State the
92
+ condition when you rely on it: with a non-zero `maxUnavailable`, or with a
93
+ `Recreate` strategy, the old pod can be gone while the new one is still booting,
94
+ and nothing is consuming in between. [VERIFYING.md](VERIFYING.md#9-a-rolling-deploy-keeps-the-queue-covered)
95
+ measures the covered case.
96
+
97
+ **`initialDelaySeconds` on the liveness probe is unnecessary.** With a
98
+ `startupProbe` present, liveness does not run until the startup probe succeeds,
99
+ so the delay would have no effect.
100
+
101
+ `kicks-liveness` and `require 'kicks_liveness/probe'` do the same thing; the
102
+ difference is only in how the file is found. Run through `bundle exec`, the
103
+ executable is found regardless of `BUNDLE_PATH`, which is why it is the standard
104
+ command. Run bare, it depends on the gem's `bin` directory being on `PATH`,
105
+ which is not something to rely on in a manifest.
106
+
107
+ ### Where your image puts its gems
108
+
109
+ Before replacing the standard command with plain Ruby, run this against your
110
+ image. One line, no cluster needed:
111
+
112
+ ```bash
113
+ docker run --rm your-image ruby -e "require 'kicks_liveness/probe'"
114
+ ```
115
+
116
+ A line like `no /opt/app/tmp/health/expected: worker has not started yet` and
117
+ exit 1 means the plain command works — the probe found itself and correctly
118
+ reported that no worker is running. A `LoadError` means it does not: keep
119
+ `bundle exec kicks-liveness`, or use the explicit-path optimisation below. Do
120
+ not put the plain command in a manifest without this check: its failure mode is
121
+ a pod that never passes `startupProbe` and lands in `CrashLoopBackOff`.
122
+
123
+ What decides it is whether Bundler installed into `GEM_HOME` or somewhere of
124
+ its own. **Setting `BUNDLE_PATH` at all is enough to move them**, including
125
+ setting it to the very directory `GEM_HOME` already points at — a common
126
+ Dockerfile idiom that looks like a no-op and is not:
127
+
128
+ ```dockerfile
129
+ ENV BUNDLE_PATH=/usr/local/bundle # GEM_HOME is already /usr/local/bundle
130
+ ```
131
+
132
+ ```
133
+ /usr/local/bundle/gems/ <- where GEM_HOME looks
134
+ /usr/local/bundle/ruby/3.4.0/gems/ <- where that Dockerfile puts them
135
+ ```
136
+
137
+ Plain `ruby -e` then raises `LoadError`, and so does the `kicks-liveness`
138
+ executable, because the binstub directory Bundler used is not on `PATH`
139
+ either. `bundle config set path vendor/bundle` does the same thing more
140
+ visibly.
141
+
142
+ #### If your image moves its gems
143
+
144
+ Three ways out, in the order worth considering them.
145
+
146
+ **Keep the standard command.** `bundle exec kicks-liveness` already works. This
147
+ is not a consolation prize: at `periodSeconds: 30` it costs about
148
+ `0.156 / 30 ≈ 0.005` of a core, against roughly 0.18 for a probe that boots the
149
+ application.
150
+
151
+ **Stop moving the gems.** Dropping `BUNDLE_PATH` from the Dockerfile and letting
152
+ the base image's `GEM_HOME` stand restores the fast command with no
153
+ probe-specific machinery at all. That is a decision about how your image is
154
+ built rather than about this gem, and it may well be the right one for other
155
+ reasons.
156
+
157
+ **Name the path explicitly.** Pass the gem's `lib` directory with `-I`; once you
158
+ are naming a path anyway, add `--disable-gems`, which costs nothing extra and
159
+ makes the probe faster again. This buys the fastest command in exchange for a
160
+ line in the Dockerfile — the next section is about writing that line so it does
161
+ not go stale on the following gem upgrade.
162
+
163
+ ### Skipping RubyGems entirely
164
+
165
+ `--disable-gems` switches off the RubyGems library: gem activation, gem path
166
+ resolution, the `Gem` constant. It does **not** switch off `require`, which is a
167
+ plain `$LOAD_PATH` lookup — so `-I <dir>` is all the probe needs in order to be
168
+ found.
169
+
170
+ And it needs nothing beyond that, because requiring `kicks_liveness/probe` loads
171
+ exactly two files: the probe entry point and the heartbeat. The heartbeat file
172
+ runs no `require` on the path the probe takes — the one it contains,
173
+ `fileutils`, is lazy and sits in a branch only the worker reaches — and it
174
+ refers to nothing else in the gem, so there is no gem to activate: no gem's code
175
+ is involved. This is the reason that file is kept dependency-free.
176
+
177
+ The worker side is the opposite and needs RubyGems, because it needs `kicks` or
178
+ `sneakers`. That is fine: the worker is an ordinary application process started
179
+ under Bundler, and nobody launches it with `--disable-gems`.
180
+
181
+ So the probe can run on a bare interpreter:
182
+
183
+ | | per invocation |
184
+ |---|---|
185
+ | `ruby -e "require 'kicks_liveness/probe'"` | 43 ms |
186
+ | `ruby --disable-gems -I <gem lib> -e "require 'kicks_liveness/probe'"` | 10.5 ms |
187
+
188
+ The catch is the path. A gem's `lib` directory carries its version:
189
+
190
+ ```
191
+ /usr/local/bundle/gems/kicks_liveness-0.1.0/lib
192
+ ```
193
+
194
+ Put that in a manifest and the manifest becomes wrong on the next gem upgrade —
195
+ silently, and the symptom is `CrashLoopBackOff`, which is exactly the class of
196
+ failure this gem exists to remove. So do not hardcode it there.
197
+
198
+ Instead resolve it once at image build time and expose a stable path:
199
+
200
+ ```dockerfile
201
+ RUN ln -s "$(bundle info kicks_liveness --path)/lib" /opt/kicks-liveness
202
+ ```
203
+
204
+ **Ask bundler, not RubyGems.** It is tempting to resolve the path with
205
+ `ruby -e 'Gem::Specification.find_by_name("kicks_liveness")...'`, but in the
206
+ image this section is about, that command fails the same way the probe does:
207
+ `find_by_name` searches `GEM_HOME`, the gem is under `vendor/bundle`, and you
208
+ get `Gem::MissingSpecError` at build time instead of a symlink. Bundler is the
209
+ thing that knows where it put the gem, so bundler is what has to be asked.
210
+ (`bundle show kicks_liveness` prints the same path on older bundlers.)
211
+
212
+ ```yaml
213
+ exec:
214
+ command: ["ruby", "--disable-gems", "-I", "/opt/kicks-liveness",
215
+ "-e", "require 'kicks_liveness/probe'"]
216
+ ```
217
+
218
+ Now the version lives in the Dockerfile, which is rebuilt when the gem changes,
219
+ and the manifest stays stable across upgrades.
220
+
221
+ **Is it worth it?** Usually not. At `periodSeconds: 30`, saving 33 ms per
222
+ invocation is `0.033 / 30 ≈ 0.001` of a core — against roughly 0.18 of a core
223
+ for a probe that boots the application. Both options have already won by two
224
+ orders of magnitude, and the simpler command has one less thing to keep in sync.
225
+ Reach for `--disable-gems` when you are on a very short period, a very tight CPU
226
+ request, or when you have to pass `-I` anyway.
227
+
228
+ ## The arithmetic
229
+
230
+ **Startup budget** = `periodSeconds × failureThreshold` = 5 × 60 = **300 s**.
231
+ There is no `initialDelaySeconds`: the startup probe fails harmlessly until the
232
+ consumers are up, so a delay would only postpone the first check.
233
+
234
+ That number is deliberately generous, and it is the one value here you should
235
+ not trim on taste. The two failure modes are not symmetric: an oversized budget
236
+ only means a genuinely broken container is restarted later, which for a
237
+ background worker costs nothing, while an undersized one means the container
238
+ never starts at all — and the symptom, a pod that keeps being killed, reads
239
+ exactly like a broken probe.
240
+
241
+ ### Measure the cold start, not the restart
242
+
243
+ A pod that has been running for a week is the wrong thing to measure. The marks
244
+ directory is an `emptyDir`, and applications commonly put a bootsnap or
245
+ similar compile cache under the same mount — so a **newly created pod** starts
246
+ with that cache empty and recompiles it during `run_initializers`, while a
247
+ **restarted container** in an existing pod inherits a warm one.
248
+
249
+ A worked example, from a Rails worker with five consumers measured on two
250
+ environments. Cold, on a fast local machine: **60–61 s** from container start
251
+ to the first healthy mark. The same application on slower infrastructure, under
252
+ a 75 s budget: containers killed at **74–76 s**, then coming up on the retry
253
+ because the second attempt found the cache warm.
254
+
255
+ Read that pair carefully, because it is the trap rather than the number. The
256
+ budget was not obviously wrong — it exceeded every warm measurement and most
257
+ cold ones. It was simply sitting on top of the distribution, so the failure
258
+ arrived as an intermittently flapping pod that healed itself on restart, which
259
+ is about the hardest symptom there is to attribute to a probe setting. A budget
260
+ chosen from the median is a budget that fails on the tail; and the tail moves
261
+ with the machine, so it is not something you can compute once from someone
262
+ else's numbers.
263
+
264
+ So: create a fresh pod, time it from container start to the first
265
+ `[liveness] slot 0: healthy`, repeat it enough times to see the tail rather
266
+ than the median, and only then consider lowering `failureThreshold`. Kubernetes
267
+ documents `startupProbe` for exactly this — slow-starting containers, sized
268
+ against the worst observed initialisation, not the usual one.
269
+
270
+ The better fix is outside this gem: warm the cache at image build time (point
271
+ `BOOTSNAP_CACHE_DIR` somewhere outside the mounted volume and run
272
+ `bootsnap precompile`). Then cold starts stop existing, every pod starts
273
+ faster, and the threshold can honestly come down.
274
+
275
+ **Steady state** = `max_age` + `periodSeconds × failureThreshold` = 45 + 90 =
276
+ **135 s** of confirmed silence before the container is killed. That is slow on
277
+ purpose: a background worker is not latency-critical, and a false restart costs
278
+ more than two minutes of a genuinely hung pod. Note that `max_age` is part of
279
+ this sum — the mark has to go stale before a probe can fail on it.
280
+
281
+ **Grace period** must cover the slowest orderly shutdown. Cancelling a consumer
282
+ is a round trip to the broker, and Bunny applies its own timeout to it; with
283
+ several consumers per worker these add up. If the grace period is shorter than
284
+ that, the container is SIGKILLed mid-shutdown and in-flight messages are
285
+ redelivered.
286
+
287
+ ## Environment variables
288
+
289
+ | Variable | Default | |
290
+ |---|---|---|
291
+ | `KICKS_LIVENESS_DIR` | `/opt/app/tmp/health` | marks directory, **must be on tmpfs** |
292
+ | `KICKS_LIVENESS_MAX_AGE` | `45` | seconds after which a mark is stale |
293
+ | `KICKS_LIVENESS_TICK` | `10` | interval between ticks |
294
+
295
+ Keep `tick` well below `max_age`. A tick longer than half of `max_age` leaves no
296
+ room for a single missed write, and a tick longer than `max_age` guarantees a
297
+ restart loop.
298
+
299
+ `KICKS_LIVENESS_DIR` is also what separates two runners that share a pod. An
300
+ application running both `rake sneakers:run` and `rake sneakers:active_job` has
301
+ two supervisors, and each numbers its forks from zero — so on the default path
302
+ they would overwrite each other's `expected` and `worker-0`, and the probe would
303
+ report whichever wrote last. Deployed as two pods, which is the usual shape,
304
+ each already has its own `emptyDir` and there is nothing to do. In one pod, give
305
+ each runner its own directory:
306
+
307
+ ```yaml
308
+ env:
309
+ - name: KICKS_LIVENESS_DIR
310
+ value: /opt/app/tmp/health/active_job
311
+ ```
312
+
313
+ Both still have to sit under the tmpfs mount, and each container needs its own
314
+ probe command pointed at its own directory — the probe reads
315
+ `KICKS_LIVENESS_DIR` from its own environment, which the kubelet takes from the
316
+ container it runs in.
317
+
318
+ Garbage in a value does not crash the worker — it falls back to the default. An
319
+ empty string counts as unset, which is what a ConfigMap gives you when a key is
320
+ declared and left blank.
321
+
322
+ ## Verifying on a live pod
323
+
324
+ ```bash
325
+ kubectl exec deploy/myapp -- ls -l /opt/app/tmp/health/
326
+ kubectl exec deploy/myapp -- sh -c 'bundle exec kicks-liveness; echo "exit=$?"'
327
+ kubectl logs deploy/myapp | grep liveness
328
+ ```
329
+
330
+ Then verify the **negative** path. This is not optional: without it you cannot
331
+ distinguish a working probe from one that is green unconditionally, and a
332
+ permanently green liveness probe is worse than no probe at all, because it looks
333
+ like coverage.
334
+
335
+ ```bash
336
+ kubectl exec deploy/myapp -- sh -c \
337
+ 'touch -d "2 minutes ago" /opt/app/tmp/health/worker-0; bundle exec kicks-liveness; echo "exit=$?"'
338
+ ```
339
+
340
+ Expected output is `worker-0 stale 120s > 45s` and `exit=1`. One tick later the
341
+ mark repairs itself, so the test is safe to run in production.
342
+
343
+ The probe prints its reason to stdout, and the kubelet surfaces that text in the
344
+ `Unhealthy` event. This matters more than it sounds: an exec probe has its own
345
+ stdout, which does **not** appear in the pod's logs, so the event is the only
346
+ place the reason is visible. `kubectl describe pod` is where you read it.
347
+
348
+ That recipe proves the probe reacts. It does not prove that the **kubelet**
349
+ reacts, because the mark repairs itself on the next tick, long before
350
+ `failureThreshold` is reached — and the failures that would prove it are not
351
+ ones to cause on a pod you care about. [VERIFYING.md](VERIFYING.md) causes
352
+ them on a disposable local cluster instead: a deleted queue, a killed broker,
353
+ wrong credentials, a rolling deploy.
354
+
355
+ ## Required companion: an alert on consumers
356
+
357
+ The probe deliberately reports healthy while Bunny is reconnecting, so that a
358
+ broker hiccup does not restart every replica at once. The price is that **broker
359
+ unavailability stops being visible automatically**: a pod can consume nothing and
360
+ still look perfectly healthy.
361
+
362
+ So the probe needs an external alert, and that alert should exist **before** you
363
+ switch the manifest over:
364
+
365
+ ```yaml
366
+ - alert: QueueWithoutConsumers
367
+ expr: rabbitmq_detailed_queue_consumers{queue=~"myapp\..+", queue!~".*delayed_active_job.*"} == 0
368
+ for: 5m
369
+
370
+ # Not optional: the expression above cannot fire when the series is gone.
371
+ - alert: QueueConsumerMetricMissing
372
+ expr: absent_over_time(rabbitmq_detailed_queue_consumers{queue=~"myapp\..+"}[10m])
373
+ for: 5m
374
+ ```
375
+
376
+ Exclude delayed queues — they have no consumers by design, and without the
377
+ exclusion the alert fires permanently until someone silences it. Match them as a
378
+ **substring**: with ActiveJob the full name looks like
379
+ `myapp.active_job.myapp.delayed_active_job:60`, where the ActiveJob prefix is
380
+ prepended and the delay is appended after a colon, so an anchored pattern will
381
+ never match.
382
+
383
+ ### Getting that metric at all
384
+
385
+ Three things about the `rabbitmq_prometheus` plugin, measured on 3.13.7 rather
386
+ than taken from memory:
387
+
388
+ **The name is `rabbitmq_detailed_queue_consumers`, not
389
+ `rabbitmq_queue_consumers`.** The two are different metrics. On the ordinary
390
+ `/metrics` endpoint, `rabbitmq_queue_consumers` exists but carries **no labels
391
+ at all** — it is one cluster-wide total:
392
+
393
+ ```
394
+ rabbitmq_queue_consumers 4
395
+ ```
396
+
397
+ A `{queue=~...}` matcher against that selects nothing, so an alert written on it
398
+ is silently never true.
399
+
400
+ **`/metrics/detailed` returns nothing unless you ask for a family.** Scraped
401
+ bare it serves only telemetry and build info. The scrape config has to name what
402
+ it wants:
403
+
404
+ ```yaml
405
+ - job_name: rabbitmq
406
+ metrics_path: /metrics/detailed
407
+ params:
408
+ family: [queue_consumer_count]
409
+ ```
410
+
411
+ Then the series arrives with the labels the alert needs — including a `vhost`
412
+ label, which is worth adding to the matcher if more than one vhost is in play:
413
+
414
+ ```
415
+ rabbitmq_detailed_queue_consumers{vhost="/",queue="myapp.orders"} 2
416
+ ```
417
+
418
+ **A series that disappears is not a series that is zero.** This is why the
419
+ second alert is not decoration. `rabbitmq_detailed_queue_consumers` is exported
420
+ per existing queue, so it vanishes — rather than dropping to 0 — when the queue
421
+ is deleted, when the broker goes down, and when the exporter itself stops. A
422
+ deleted queue is exactly the failure this probe is built to catch: the consumer
423
+ goes away, the pod is restarted, and the `== 0` alert stays quiet throughout
424
+ because there is nothing left to compare. `absent_over_time` covers that; so
425
+ does an `up == 0` alert on the scrape job, and you want both if the broker and
426
+ the exporter can fail independently.
@@ -0,0 +1,141 @@
1
+ # Limitations
2
+
3
+ The predicate is computed over Bunny objects held in the worker's own memory.
4
+ That is the whole point — it keeps the health decision independent of a broker
5
+ round trip — but it has a boundary: the predicate is exactly as truthful as
6
+ Bunny's own bookkeeping. The broker is never asked for its opinion.
7
+
8
+ The first three sections below point in the same direction: an alert on
9
+ consumers, which looks at the broker from **the outside**, is part of the design
10
+ rather than a suggestion, because it catches precisely what an in-memory
11
+ predicate cannot see. The sections after them are boundaries of another kind:
12
+ what the probe does not survive, and what it does not set out to answer.
13
+
14
+ ## Consumer bookkeeping is updated on the worker's thread pool
15
+
16
+ When the broker cancels a consumer — a `consumer_timeout` expiring, for
17
+ instance — it sends `basic.cancel`, and Bunny removes the consumer from its
18
+ registry. But it does that inside `@work_pool.submit`, i.e. on the same thread
19
+ pool that processes messages (`threads: 10` by default). Until a thread is free,
20
+ `any_consumers?` still answers "yes".
21
+
22
+ In practice: if **every** thread of a worker is occupied by a stuck job, the
23
+ probe stays green even though the pod is no longer consuming anything. One stuck
24
+ job out of ten does not produce this effect.
25
+
26
+ This gap cannot be closed from inside the process. It is the reason the external
27
+ alert exists.
28
+
29
+ ## `recover_cancelled_consumers!` makes the probe blind to a cancelled consumer
30
+
31
+ `Bunny::Channel#recover_cancelled_consumers!` is opt-in; neither Kicks nor
32
+ Sneakers enables it. If your application does enable it, Bunny
33
+ responds to `basic.cancel` by re-subscribing the consumer and **keeping** its
34
+ registry entry, so `any_consumers?` remains true. The "consumer was cancelled"
35
+ case then stops being detected.
36
+
37
+ Do not enable it together with this probe. If you need it for other reasons, be
38
+ aware that this probe no longer covers that failure mode.
39
+
40
+ ## Broker unavailability is invisible by design
41
+
42
+ While Bunny is recovering from a network failure, the probe reports healthy. This
43
+ is deliberate: the alternative is that a ten-second broker hiccup restarts every
44
+ replica simultaneously and reconnects them all at once, which is what a
45
+ struggling broker least needs. Bunny reconnects and re-subscribes its consumers
46
+ by itself, so a restart at that moment cures nothing.
47
+
48
+ The consequence, plainly: a pod whose broker is unreachable looks healthy to
49
+ this probe. Cover it with the alert.
50
+
51
+ ## The recovery exemption covers an established connection, not a failed subscribe
52
+
53
+ While Bunny is recovering from a network failure the probe reports healthy, so
54
+ that a broker hiccup does not restart every replica at once. That exemption is
55
+ asked of a `connection` object, which has to exist before it can be asked.
56
+
57
+ If the broker is unreachable at the moment a worker subscribes, there is no
58
+ connection at all: the registry stays empty and no mark is ever written. So the
59
+ cascade the exemption avoids for a live connection is still possible at startup
60
+ — broker down, every replica restarting.
61
+
62
+ The budget here is the **startup** one, not the steady-state one:
63
+
64
+ ```
65
+ initialDelaySeconds + periodSeconds × failureThreshold
66
+ ```
67
+
68
+ which with the values in [KUBERNETES.md](KUBERNETES.md#the-arithmetic) — no
69
+ `initialDelaySeconds`, `periodSeconds: 5`, `failureThreshold: 60` — is 300 s.
70
+ `max_age` does not appear in it, and neither do the liveness settings. Both are
71
+ about a mark that has stopped being refreshed; here there is no mark to go
72
+ stale, so the probe fails on `worker-0 missing` from its very first check, and
73
+ the container never leaves `startupProbe` for liveness to take over.
74
+
75
+ Two things take the edge off it. Kubernetes backs container restarts off
76
+ exponentially, up to five minutes, so from the outside this looks like a
77
+ flapping pod rather than a storm. And the supervisor respawning failing forks
78
+ inside the container — which is *not* throttled by anything — is now reported:
79
+ see the respawn escalation below.
80
+
81
+ ## Changing the worker count at runtime is not supported
82
+
83
+ `ServerEngine` re-reads its configuration on SIGHUP, and a changed `workers`
84
+ value scales the fork set. Scaling **up** is fine. Scaling **down** is only
85
+ handled when the supervisor also restarts the forks, which is what the restart
86
+ path does: the surviving monitors re-declare the current count on their next
87
+ tick and the probe follows.
88
+
89
+ What cannot be handled is a reload that lowers `workers` while leaving existing
90
+ forks running. `reload_config` executes in the supervisor, and a fork holds its
91
+ own copy of the configuration from the moment it was forked — so a fork cannot
92
+ see the new number however often it looks. The declared count then stays too
93
+ high, the marks for the retired slots go stale, and the probe fails until the
94
+ pod restarts.
95
+
96
+ If you scale workers, restart them.
97
+
98
+ ## A respawn loop is reported once per grace window, not once per respawn
99
+
100
+ When a fork cannot subscribe at all, the supervisor brings it back after
101
+ `start_worker_delay` — a fraction of a second — and the monitor in each
102
+ incarnation is a brand-new object. Any counter it holds is destroyed before it
103
+ can reach a threshold, which is how a real outage used to produce thousands of
104
+ identical INFO lines and not a single ERROR.
105
+
106
+ The count is therefore kept in the marks directory, which survives the fork.
107
+ The start line is logged once, repeated starts within the grace window
108
+ (`startup_grace_ticks × tick`) are silent, and after that one ERROR per window
109
+ reports the elapsed time and the number of starts:
110
+
111
+ ```
112
+ [liveness] slot 0: not healthy 62s over 305 starts, expected 5 consumers
113
+ ```
114
+
115
+ The number of starts is the diagnosis: it separates a slow start from a respawn
116
+ loop at a glance.
117
+
118
+ ## Do not install both `kicks` and `sneakers`
119
+
120
+ The gem declares neither as a dependency, because at runtime it needs only the
121
+ `Sneakers` namespace, which both provide. But both gems own the file
122
+ `lib/sneakers.rb`, and having both in one `Gemfile` does not fail — it silently
123
+ resolves by load-path order. An application that migrated to `kicks` can end up
124
+ executing the `sneakers` code while its lockfile says otherwise, and pulling in
125
+ `sneakers` also caps the versions of `kicks` and `bunny` that Bundler will
126
+ resolve.
127
+
128
+ A loud failure gets fixed; a silent substitution does not. Keep exactly one of
129
+ the two.
130
+
131
+ If neither is present, `install!` raises a `LoadError` naming both with their
132
+ required versions.
133
+
134
+ ## The probe reports per pod, not per queue
135
+
136
+ This is a feature rather than a defect, but it is worth stating: the probe
137
+ answers "is *this* process consuming what it is supposed to consume?" It cannot
138
+ tell you anything about the queue as a whole, about other replicas, or about
139
+ messages piling up. Backlog and throughput are monitoring concerns, not liveness
140
+ concerns, and a liveness probe that tried to cover them would restart pods for
141
+ reasons a restart cannot fix.