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 +7 -0
- data/CHANGELOG.md +21 -0
- data/LICENSE.txt +21 -0
- data/README.md +319 -0
- data/docs/DESIGN.md +325 -0
- data/docs/KUBERNETES.md +426 -0
- data/docs/LIMITATIONS.md +141 -0
- data/docs/SETUP.md +181 -0
- data/docs/VERIFYING.md +330 -0
- data/exe/kicks-liveness +3 -0
- data/lib/kicks_liveness/attempts.rb +86 -0
- data/lib/kicks_liveness/configuration.rb +83 -0
- data/lib/kicks_liveness/heartbeat.rb +178 -0
- data/lib/kicks_liveness/hooks.rb +93 -0
- data/lib/kicks_liveness/monitor.rb +178 -0
- data/lib/kicks_liveness/probe.rb +16 -0
- data/lib/kicks_liveness/railtie.rb +18 -0
- data/lib/kicks_liveness/registry.rb +102 -0
- data/lib/kicks_liveness/version.rb +4 -0
- data/lib/kicks_liveness.rb +86 -0
- metadata +72 -0
data/docs/KUBERNETES.md
ADDED
|
@@ -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.
|
data/docs/LIMITATIONS.md
ADDED
|
@@ -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.
|