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
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`.
|