Verifying the probe against a real broker
KUBERNETES.md shows how to confirm the probe on a pod you already run. This document is the other half: a disposable local cluster where the failures themselves can be caused on purpose, so that every claim the gem makes is watched rather than reasoned about.
Two of those claims cannot be checked any other way, because both are properties of Bunny rather than of this gem:
- that
channel.any_consumers?turns false when the broker takes a consumer away, which is what the probe fails on; - that
recovering_from_network_failure?stays true for the whole of a broker outage, which is what keeps a hiccup from restarting every replica.
And one is a property of the kubelet: that a red probe actually restarts the
container. The touch -d recipe in KUBERNETES.md deliberately repairs itself
within one tick, so on its own it never reaches failureThreshold.
The fixture lives in spec/integration/ in the repository. It is not part of the
published gem, and it is not part of rake — nothing under it is a _spec.rb
file, so rspec does not collect it.
What you need
A local single-node cluster. Docker Desktop's built-in Kubernetes is the
cheapest option, because an image built locally is visible to the cluster
immediately, with no registry and no load step — imagePullPolicy: Never is all
it takes.
spec/integration/verify.sh build # image, into the engine the cluster uses
spec/integration/verify.sh status # print and verify the exact target
spec/integration/verify.sh up # namespace, broker, worker
spec/integration/verify.sh down # deletes the namespace
Every kubectl call names its context and namespace explicitly, so the script
never falls back to current-context. It will still operate on whichever target
you configure: run status and inspect both values before up or down. The
script labels namespaces it creates and refuses to apply to or delete an
existing namespace without that ownership label.
The default image uses Kicks. Build it with Sneakers instead without editing the fixture:
AMQP_WORKER_GEM=sneakers spec/integration/verify.sh build
Three things that cost time if you meet them the hard way:
- Build into the right engine. If the machine also runs OrbStack, Colima or
Rancher Desktop, the default docker context is not the one Docker Desktop's
Kubernetes reads images from, and the pod fails with
ErrImageNeverPull. The script passes--context desktop-linuxfor this reason. - A
path:gem is not inGEM_HOME. Bundler adds it to the load path and nothing else. The standard command,bundle exec kicks-liveness, does not care — Bundler put it there and Bundler finds it. The optimised command, plainruby -ewith no Bundler, raisesLoadError. It is one instance of where your image puts its gems, and worth meeting here rather than in a rollout. The fixture image therefore alsogem installs the gem it builds, so that both commands are exercised the way a published gem would be. ruby:*-slimhas no compiler.bunnypullssorted_set, which pullsrbtree, which is a native extension.
The scenarios
Each one is an action and a single observable value. The values below are the
ones actually recorded on Kubernetes v1.25 with kicks 3.4.0, bunny 3.3.0 and
ruby 3.4, using the probe settings printed in KUBERNETES.md
(tick 10 s, max_age 45 s, liveness periodSeconds 30 × failureThreshold 3).
The same scenarios have since been replayed against a production Rails application — five consumers, two runners, one of them an ActiveJob adapter whose worker set is a callable registry rather than an array — on the same kind of throwaway cluster. Every outcome matched the fixture's, which is the point of keeping the fixture small: if a two-queue toy and a real application disagree, the disagreement is the finding.
The startup budget never binds anything here, so no number below depends on it: this fixture subscribes in under a second, where a real application takes tens of seconds. That is precisely why the cold-start measurement in KUBERNETES.md has to be made against your own application — a fixture cannot do it for you.
1. It comes up
spec/integration/verify.sh logs | grep liveness
spec/integration/verify.sh marks
spec/integration/verify.sh probe
INFO: [liveness] slot 0: started: dir=/opt/app/tmp/health max_age=45s tick=10s processes=1 consumers=2
INFO: [liveness] slot 0: waiting for 2 consumers
INFO: [liveness] slot 0: healthy # one tick later
expected generation worker-0 # in the marks directory
1 process(es) healthy # probe, exit 0
The hooks fired inside a real ServerEngine fork, which no double can show. On a
fresh fixture the first attempt also prints started:. Do not use that line as
the sole proof after a same-pod container restart: an inherited unhealthy-run
counter deliberately suppresses repeated start lines, as described in
LIMITATIONS.md.
2. A stale mark
The recipe from KUBERNETES.md. Proves the probe's threshold and the
self-repair, and nothing else — the mark is fixed on the next tick, well inside
failureThreshold, so the container is never killed.
Worth running through both commands here, since this is the one scenario
where the probe is supposed to answer 1. spec/integration/verify.sh probe
does exactly that: the standard bundle exec kicks-liveness and the optimised
ruby -e, which in this image are both available and must agree.
worker-0 stale 120s > 45s # exit 1
1 process(es) healthy # exit 0, twelve seconds later
3. The broker takes the consumer away
The one that matters. Delete a queue the worker consumes from:
kubectl exec deploy/rabbitmq -- rabbitmqctl delete_queue kicks_liveness.alpha
Observed, from the moment of deletion:
| after the deletion | what |
|---|---|
| 9 s | ERROR: [liveness] slot 0: became unhealthy (expected 2 consumers) |
| 59 s | ERROR: [liveness] slot 0: not healthy 60s, expected 2 consumers |
| 61 s | first kubelet failure: Liveness probe failed: worker-0 stale 69s > 45s |
| 91 s, 121 s | the second and third, stale 99s and stale 129s |
| 132 s | Killing: Container worker failed liveness probe, will be restarted |
The two clocks in that table are different, which is worth reading carefully
if you are checking the arithmetic. The left column counts from the deletion.
The stale Ns in the probe's own text is the age of the mark, and the mark
stopped being refreshed one tick before the transition was logged — the tick
that found the process unhealthy is also the first one that did not touch the
file. So the probe's number runs about eight seconds ahead of the left column,
and the kill lands at max_age + periodSeconds × failureThreshold counted
from the last refresh, not from the deletion.
So any_consumers? does turn false, the transition is reported within one tick,
and the pod is restarted after max_age + periodSeconds × failureThreshold
— 132 s against the 135 s the arithmetic in KUBERNETES.md predicts. The worker
re-declares the queue on boot, so the pod recovers by itself.
Note where the probe's own text appears: in the kubelet's Unhealthy event,
not in the pod log. An exec probe's stdout goes nowhere else, which is why
kubectl describe pod is the place to read it.
4. The broker dies
kubectl delete pod -l app=rabbitmq --grace-period=0 --force
The pod must not restart. "It survived" is not enough on its own — a broker that never actually went away would look the same — so check the log for Bunny's own account of the outage:
WARN: Recovering from connection.close (CONNECTION_FORCED - broker forced connection closure with reason 'shutdown')
WARN: Will recover from a network failure (no retry limit)...
WARN: Could not establish TCP connection to rabbitmq:5672 ... TCP connection failed
WARN: Reconnecting in 5.0 seconds
Against that: restartCount stayed 0, the probe exited 0 at every check, and
no became unhealthy was logged. Bunny's topology recovery brought both
consumers back on its own.
5. The broker stays dead
kubectl scale deploy/rabbitmq --replicas=0, then wait. Observed over 319 s
— seven times max_age, more than twice the 135 s steady-state budget — the
mark's age never exceeded 9 s and restartCount stayed 0. The monitor kept
writing, because recovering_from_network_failure? never stops being true:
Bunny has no retry limit, which its own log line above says out loud.
This is Broker unavailability is invisible by design, seen rather than deduced. It is the reason the consumer alert is part of the design and not a suggestion.
Scaling the broker back up: the worker resumed consuming in the same container, no restart.
6. An ordinary deploy
kubectl delete pod on the worker, with the log followed. Not one ERROR line.
Bunny cancels each consumer and the process exits.
If the shutdown finishes inside a single tick — an idle thread pool cancels
quickly — the monitor never gets to run again, so
shutting down: consumers are no longer checked may not appear at all. The
absence of ERROR is the observable, not the presence of that line.
7. Bad broker credentials
Point AMQP_URL at a wrong password. The fork cannot subscribe, so the
supervisor respawns it every start_worker_delay, forever. Over 120 s:
| lines | |
|---|---|
| fork starts | 426 |
started: dir=... consumers=2 |
1 |
waiting for 2 consumers |
1 |
| liveness ERROR | 2 |
INFO: [liveness] slot 0: started: dir=/opt/app/tmp/health max_age=45s tick=10s processes=1 consumers=2
INFO: [liveness] slot 0: waiting for 2 consumers
ERROR: [liveness] slot 0: not healthy 60s over 224 starts, expected 2 consumers
ERROR: [liveness] slot 0: not healthy 60s over 426 starts, expected 2 consumers
Four lines from the gem across 426 respawns: the first incarnation announces itself and reports waiting, every one after that is silent, and the escalation speaks once per grace window carrying the number of starts, which is the diagnosis. This is the behaviour the respawn section describes, against a real storm rather than a simulated one.
Worth knowing what the pod log looks like anyway: 27 000 lines in those same
120 s, an authentication error and a full backtrace printed by the worker gem on
every respawn. The gem's four lines are in there — grep liveness is how you
find them.
8. More than one fork
WORKER_COUNT=2 needs no rebuild; the rake task turns it into
config[:workers].
INFO: [liveness] slot 0: started: ... processes=2 consumers=2
INFO: [liveness] slot 1: started: ... processes=2 consumers=2
expected generation worker-0 worker-1 # expected contains "2"
2 process(es) healthy # exit 0
worker-1 stale 120s > 45s # exit 1 — staling either mark is enough
Both marks are required, not just the first.
One trap while checking this by hand: kubectl exec deploy/worker picks any pod
matching the deployment, and during a rollout that can still be the old one.
Read the marks on a pod named explicitly.
9. A rolling deploy keeps the queue covered
Scenario 6 kills a pod outright. A deploy is the more interesting case, because KUBERNETES.md claims the startup probe incidentally fixes rollouts where the old pod was removed before the new one had subscribed — and that claim is about ordering, which needs two pods to be watched at once.
kubectl rollout restart deploy/worker, with WORKER_COUNT=2, so a fully
subscribed pod is worth two consumers per queue. Counting consumers on the
broker is what makes the ordering visible:
| after | consumers on the queue | pods |
|---|---|---|
| 1 s | 2 | old Ready, new Pending |
| 10 s | 4 | old Ready, new Running but not Ready |
| 16 s | — | the old pod cancels its consumers |
| 18 s | 2 | new pod only, Ready |
Coverage went 2 → 4 → 2 and never dipped. The new pod had subscribed before the old one was asked to stop, and the old one logged no ERROR on its way out.
The mechanism is worth being explicit about, because it is the probe doing it:
a container running its startup probe is not Ready, and with one replica
maxUnavailable: 25% rounds down to zero, so Kubernetes may not remove the
old pod until the new one is Ready. Readiness here means the mark is on
tmpfs, which means the consumers are subscribed. A probe that answered on
process liveness instead would have released the old pod while the new one was
still booting.
10. The alert's metric is the one the docs name
The consumer alert is part of the design, not a suggestion — so its expression deserves the same treatment as everything else here. The broker in this fixture is enough to check it; the plugin is not on by default:
kubectl exec deploy/rabbitmq -- rabbitmq-plugins enable rabbitmq_prometheus
Then read all three endpoints from the worker pod, which has a Ruby but no
curl:
kubectl exec deploy/worker -- ruby -rnet/http -e \
'puts Net::HTTP.get(URI("http://rabbitmq:15692/metrics/detailed?family=queue_consumer_count"))'
Recorded on RabbitMQ 3.13.7:
| endpoint | what comes back |
|---|---|
/metrics |
rabbitmq_queue_consumers 4 — one label-less cluster total |
/metrics/detailed |
telemetry and build info only, no queue metrics |
/metrics/detailed?family=queue_consumer_count |
rabbitmq_detailed_queue_consumers{vhost="/",queue="kicks_liveness.alpha"} 2 |
Three things follow, and all three are now in the manifest documentation: the
metric is a different name from the aggregate, the detailed endpoint needs the
family parameter, and the series carries a vhost label.
Then check the two halves of the alert against a throwaway queue — declare one with no consumers, scrape, delete it, scrape again:
-- queue exists, zero consumers
rabbitmq_detailed_queue_consumers{vhost="/",queue="kicks_liveness.tmpcheck"} 0
-- queue deleted
(no such series)
Both halves matter and they are different failures. An idle queue reports
0, which is what == 0 is for. A deleted queue reports nothing at all,
and neither does a stopped broker or a stopped exporter — so == 0 cannot fire
on the very failure scenario 3 demonstrates, where the queue goes away and the
pod is restarted. That is why the documented rule set pairs it with
absent_over_time, and why an up == 0 on the scrape job is worth having
beside both.
11. A private-PID container restart cannot inherit a healthy mark
This scenario needs the fixture image but not Kubernetes or RabbitMQ. It starts a container that publishes a healthy mark into a named volume, restarts that same container, and delays the new writer for 15 seconds:
spec/integration/verify.sh build
spec/integration/verify.sh generation-restart
During the delay the inherited mark is still fresh, but the probe must reject its old generation. Once the replacement writer publishes its own mark, the same probe must become healthy again. The command checks both transitions and prints output in this form:
before=mnt:[4026532686]:123456
after=mnt:[4026532686]:123789
inherited=heartbeat belongs to a previous container: worker has not started yet
recovered=1 process(es) healthy
The namespace inode is allowed to be reused, as in the example above; the PID 1 start time still distinguishes the two incarnations. The test removes its container and named volume on exit.
This scenario covers the default container-private PID namespace. With
shareProcessNamespace: true or hostPID: true, PID 1 survives the worker
container restart and this guarantee does not hold; see
LIMITATIONS.md.
What to do with a disagreement
Record it, then fix whichever is wrong — the code or the document. The numbers above are a baseline: the arithmetic in KUBERNETES.md is meant to predict them, and a scenario that lands far from its prediction means one of the two is out of date.