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/DESIGN.md
ADDED
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
# Design
|
|
2
|
+
|
|
3
|
+
Why this gem is shaped the way it is. If you only want to deploy it, read
|
|
4
|
+
[KUBERNETES.md](KUBERNETES.md) instead.
|
|
5
|
+
|
|
6
|
+
## The problem with the usual liveness probe
|
|
7
|
+
|
|
8
|
+
The common implementation is a rake task, invoked by the kubelet through `exec`,
|
|
9
|
+
that boots the application and asks the broker for `consumer_count` on the
|
|
10
|
+
queues it expects to be served. It has four defects, and three of them cannot
|
|
11
|
+
be tuned away.
|
|
12
|
+
|
|
13
|
+
**It is expensive.** Booting a full Rails application every few seconds, inside
|
|
14
|
+
the worker's own cgroup. A probe measured at 2.7 s of CPU per invocation on a
|
|
15
|
+
15 s period consumes `2.7 / 15 ≈ 0.18` of a core continuously, per pod, forever.
|
|
16
|
+
Multiply by your replica count and compare it against the CPU request of the
|
|
17
|
+
container; on a small worker fleet the probe can easily outweigh the work.
|
|
18
|
+
|
|
19
|
+
**It depends on the disk.** Loading code means reading files. A disk that stalls
|
|
20
|
+
for a few seconds stretches the probe past `timeoutSeconds`, and the kubelet
|
|
21
|
+
kills a pod that was perfectly healthy. A stalling disk is one of the most
|
|
22
|
+
common causes of false restarts, and a probe that reads from it converts that
|
|
23
|
+
cause into an outage.
|
|
24
|
+
|
|
25
|
+
**It cascades.** A liveness probe that checks an external dependency makes that
|
|
26
|
+
dependency a participant in your restart logic. The broker hiccups for ten
|
|
27
|
+
seconds, every replica fails its probe at the same moment, and the whole fleet
|
|
28
|
+
restarts simultaneously — reconnecting all at once, which is exactly what a
|
|
29
|
+
struggling broker least needs.
|
|
30
|
+
|
|
31
|
+
**It measures the wrong thing.** `consumer_count` is a property of a *queue*,
|
|
32
|
+
not of a *pod*. With two replicas, one healthy consumer keeps the count above
|
|
33
|
+
zero while the other replica is hung and consuming nothing. The metric that is
|
|
34
|
+
supposed to detect a dead pod is structurally blind to it.
|
|
35
|
+
|
|
36
|
+
## The inversion
|
|
37
|
+
|
|
38
|
+
Turn the data flow around. The worker knows its own state better than anything
|
|
39
|
+
outside it can, so let the worker publish and let the probe read.
|
|
40
|
+
|
|
41
|
+
Every `tick` seconds (10 by default) the worker checks, **in its own memory**,
|
|
42
|
+
that its consumers are still subscribed, and touches a file on tmpfs. The probe
|
|
43
|
+
loads one dependency-free Ruby file, reads the file's mtime, and exits with 0 or
|
|
44
|
+
1.
|
|
45
|
+
|
|
46
|
+
The probe therefore performs no network I/O and boots no framework, and the
|
|
47
|
+
state it reads — the mark's mtime — comes from tmpfs, which is RAM. Be precise
|
|
48
|
+
about the disk, though: starting the probe still loads the Ruby interpreter and
|
|
49
|
+
two files of this gem from the image filesystem, and those reads are ordinary
|
|
50
|
+
filesystem reads (usually served from page cache, but not guaranteed to be). The
|
|
51
|
+
honest claim is not "no disk" but *no application boot, and no disk on the path
|
|
52
|
+
that decides the answer* — which is what removes the defects above, not tuning.
|
|
53
|
+
|
|
54
|
+
## The health predicate
|
|
55
|
+
|
|
56
|
+
Computed only over Bunny objects held in the worker process:
|
|
57
|
+
|
|
58
|
+
```ruby
|
|
59
|
+
channel = worker.queue.channel
|
|
60
|
+
return false if channel.nil?
|
|
61
|
+
|
|
62
|
+
connection = channel.connection
|
|
63
|
+
return true if recovering?(connection)
|
|
64
|
+
|
|
65
|
+
channel.open? && connection.open? && channel.any_consumers?
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
| State | Verdict | Why |
|
|
69
|
+
|---|---|---|
|
|
70
|
+
| consumers present, channel open | healthy | — |
|
|
71
|
+
| `channel` still `nil` | unhealthy | subscription has not happened yet; `startupProbe` holds the pod |
|
|
72
|
+
| Bunny recovering from a network failure | **healthy** | Bunny reconnects and re-subscribes on its own; restarting now cures nothing and adds a reconnect storm |
|
|
73
|
+
| connection open, no consumers | unhealthy | the worker silently stopped consuming — a restart is the only cure |
|
|
74
|
+
| not the whole set subscribed | unhealthy | a worker that quietly dropped out would otherwise leave the probe green |
|
|
75
|
+
|
|
76
|
+
The last row is the reason the registry compares its size against an expected
|
|
77
|
+
count rather than just checking that whatever registered is alive. Four healthy
|
|
78
|
+
workers out of five look perfectly fine one object at a time.
|
|
79
|
+
|
|
80
|
+
### Nothing here talks to the broker
|
|
81
|
+
|
|
82
|
+
Every call in the predicate reads process-local state. Verified against Bunny 3.2.0:
|
|
83
|
+
|
|
84
|
+
| Call | Implementation |
|
|
85
|
+
|---|---|
|
|
86
|
+
| `worker.queue.channel` | `attr_reader` on `Sneakers::Queue` |
|
|
87
|
+
| `channel.connection` | `attr_reader` on `Bunny::Channel` |
|
|
88
|
+
| `connection.recovering_from_network_failure?` | `@recovery_mutex.synchronize { @recovering_from_network_failure }` |
|
|
89
|
+
| `channel.open?` | `@status == :open` |
|
|
90
|
+
| `connection.open?` | status under `@status_mutex`, then `@transport.open?` → `@socket && !@socket.closed?` |
|
|
91
|
+
| `channel.any_consumers?` | `@consumer_mutex.synchronize { @consumers.any? }` |
|
|
92
|
+
|
|
93
|
+
`IO#closed?` reports the state of the IO object in this process; it is not a
|
|
94
|
+
syscall and not a packet. By contrast, `consumer_count` is a genuine round trip —
|
|
95
|
+
a passive `Queue.Declare` or an HTTP API call. That round trip is what this gem
|
|
96
|
+
does not make.
|
|
97
|
+
|
|
98
|
+
The predicate learns that a consumer was cancelled because RabbitMQ sends
|
|
99
|
+
`basic.cancel` and Bunny deletes the consumer from its own registry. That is a
|
|
100
|
+
push, not a poll — which is what makes an in-memory predicate possible at all,
|
|
101
|
+
and also where its limits come from. See [LIMITATIONS.md](LIMITATIONS.md).
|
|
102
|
+
|
|
103
|
+
`recovering_from_network_failure?` is marked `@private` in Bunny's own
|
|
104
|
+
documentation, so an upgrade may remove it. It is therefore called through
|
|
105
|
+
`respond_to?`: without that guard the call would raise `NoMethodError`, `alive?`
|
|
106
|
+
would catch it and report *unhealthy*, and every pod would enter a restart loop
|
|
107
|
+
on the next Bunny upgrade. What the guard cannot preserve is the exemption
|
|
108
|
+
itself — if the method disappears, so does "healthy while reconnecting",
|
|
109
|
+
silently. That is the thing to re-check when upgrading Bunny.
|
|
110
|
+
|
|
111
|
+
## The mark files
|
|
112
|
+
|
|
113
|
+
```
|
|
114
|
+
<dir>/expected how many forks the probe must wait for
|
|
115
|
+
<dir>/worker-<slot> one per fork, refreshed every tick the fork is healthy
|
|
116
|
+
<dir>/attempt-<slot> starts of a slot that has not become healthy yet
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
**`expected` closes a startup hole.** Without it the probe would pass as soon as
|
|
120
|
+
any single file was fresh: fork 0 has written, fork 1 has not, and the pod looks
|
|
121
|
+
ready while half of it is not subscribed. Every fork writes the same value, so
|
|
122
|
+
they cannot disagree.
|
|
123
|
+
|
|
124
|
+
Every fork rewrites it on every tick, not only at startup. Nothing else restores
|
|
125
|
+
it: if the directory is wiped, `touch!` brings the slot marks back while
|
|
126
|
+
`expected` stays missing, and the probe reports "worker has not started" for the
|
|
127
|
+
rest of the pod's life. Rewriting it is also what lets a respawned set of forks
|
|
128
|
+
correct a count that has been lowered.
|
|
129
|
+
|
|
130
|
+
**Files are named by supervisor slot, not by PID.** A fork killed with SIGKILL
|
|
131
|
+
is respawned into the same slot and overwrites its own file. Had the name
|
|
132
|
+
contained a PID, that file would sit there stale forever and the probe would
|
|
133
|
+
fail permanently, turning one dead fork into a pod that can never come back.
|
|
134
|
+
|
|
135
|
+
`expected` is written through a temporary file and a `rename`, not with a plain
|
|
136
|
+
write. A plain write truncates first, and a probe reading in that window finds
|
|
137
|
+
the file empty and reports that the worker has not started. The window is real
|
|
138
|
+
and recurring: every fork rewrites this file on every tick, while the liveness
|
|
139
|
+
probe reads it on a schedule of its own. `rename` is atomic on tmpfs, so the
|
|
140
|
+
probe sees either the old value or the new one.
|
|
141
|
+
|
|
142
|
+
**`attempt-<slot>` is the respawn counter**, and it lives in this directory for
|
|
143
|
+
the single reason that the directory outlives the fork: a monitor caught in a
|
|
144
|
+
respawn loop is a brand-new object every few hundred milliseconds and can hold
|
|
145
|
+
no counter of its own. The file is removed once the slot becomes healthy, so in
|
|
146
|
+
steady state the directory holds only `expected` and the `worker-<slot>` marks;
|
|
147
|
+
what the counter is for is in
|
|
148
|
+
[LIMITATIONS.md](LIMITATIONS.md#a-respawn-loop-is-reported-once-per-grace-window-not-once-per-respawn).
|
|
149
|
+
|
|
150
|
+
The *contents* of `worker-<slot>` (timestamp, pid, slot) exist only for a human
|
|
151
|
+
running `kubectl exec ... cat`. The probe decides on mtime alone.
|
|
152
|
+
|
|
153
|
+
The directory must be on tmpfs — in Kubernetes, an `emptyDir` with
|
|
154
|
+
`medium: Memory`. Put it on a real disk and the probe starts depending on the
|
|
155
|
+
disk again, which was defect number two.
|
|
156
|
+
|
|
157
|
+
## Why the heartbeat file has no `require` of its own
|
|
158
|
+
|
|
159
|
+
The probe loads exactly one file and nothing else. Measured over 30 invocations
|
|
160
|
+
each; absolute values depend on the hardware, but the ratios do not:
|
|
161
|
+
|
|
162
|
+
| | per invocation |
|
|
163
|
+
|---|---|
|
|
164
|
+
| `ruby --disable-gems -e ''` — bare interpreter | 10 ms |
|
|
165
|
+
| `ruby -e ''` — RubyGems initialized | 38 ms |
|
|
166
|
+
| the probe, with `--disable-gems` and an explicit path | 10.5 ms |
|
|
167
|
+
| the probe, found through RubyGems | 43 ms |
|
|
168
|
+
| **`bundle exec ruby -e ''`** | **300 ms** |
|
|
169
|
+
|
|
170
|
+
Subtract the baselines and the probe's own work is a **few milliseconds**.
|
|
171
|
+
Everything else is interpreter startup, and 28 ms of that is RubyGems
|
|
172
|
+
initializing.
|
|
173
|
+
|
|
174
|
+
Three consequences follow. `bundle exec kicks-liveness` is the portable
|
|
175
|
+
default; most of its runtime is Bundler startup rather than the check itself.
|
|
176
|
+
When the gem is installed in `GEM_HOME`, RubyGems can find it with a plain
|
|
177
|
+
`require` for under 50 ms. Skipping RubyGems entirely is faster still, but it
|
|
178
|
+
requires naming the path to the gem's `lib` directory; see
|
|
179
|
+
[KUBERNETES.md](KUBERNETES.md) for when that trade is worth making.
|
|
180
|
+
|
|
181
|
+
Keeping the probe's path through the heartbeat file free of `require`
|
|
182
|
+
statements — and the file free of references to the rest of the gem — is what
|
|
183
|
+
makes all three possible. The single `require` the file does contain,
|
|
184
|
+
`fileutils`, is lazy and sits in the directory-creation branch that only the
|
|
185
|
+
worker ever reaches.
|
|
186
|
+
|
|
187
|
+
## Where the hooks attach
|
|
188
|
+
|
|
189
|
+
Two `prepend`s, installed by `KicksLiveness.install!` (automatically through a
|
|
190
|
+
Railtie in Rails), covering four methods:
|
|
191
|
+
|
|
192
|
+
- `Sneakers::Worker#run` — register in the registry *after* `super`, so a worker
|
|
193
|
+
whose `subscribe` raised never registers and the probe honestly fails.
|
|
194
|
+
- `Sneakers::Worker#stop` — deregister *before* `super`.
|
|
195
|
+
- `Sneakers::WorkerGroup#after_fork` — start the monitor thread *after* `super`.
|
|
196
|
+
- `Sneakers::WorkerGroup#stop` — record that the shutdown is deliberate, *before*
|
|
197
|
+
`super` starts unsubscribing.
|
|
198
|
+
|
|
199
|
+
`after_fork` rather than `run`, even though `run` is where the monitor must
|
|
200
|
+
ultimately live: `Sneakers::WorkerGroup#run` calls `after_fork` as its very first
|
|
201
|
+
statement and only then resolves `worker_classes`, so hooking `after_fork` is the
|
|
202
|
+
earliest point in the fork at which the application's own `after_fork` hook has
|
|
203
|
+
already run. That matters because resolving the expected consumer count may run
|
|
204
|
+
application code — under `sneakers:active_job` the set is a callable registry —
|
|
205
|
+
and the standard use of `after_fork` is re-establishing the connections a fork
|
|
206
|
+
inherited. Resolving the set first would run that code against a fork that is not
|
|
207
|
+
ready yet, and the `rescue` around the start would then leave the pod with no
|
|
208
|
+
monitor at all. A spec asserts this ordering against the real worker gem, so an
|
|
209
|
+
upgrade that reorders `run` fails in CI rather than in production.
|
|
210
|
+
|
|
211
|
+
`ServerEngine::Server#create_worker` calls `w.extend(@worker_module)`: the
|
|
212
|
+
`WorkerGroup` module lands in the singleton class of the *instance*, not as an
|
|
213
|
+
`include` in a class. A `prepend` to the module still sits ahead of it in that
|
|
214
|
+
lookup chain, which is what makes the hook work; `worker_id` and `config` come
|
|
215
|
+
from `ServerEngine::Worker`. Verified on serverengine 2.4.0, and covered by a
|
|
216
|
+
spec that asserts the order in `ancestors` — this is the most fragile assumption
|
|
217
|
+
in the gem, so it fails in CI rather than in production.
|
|
218
|
+
|
|
219
|
+
Slot numbers come from `worker_id`, which is the index into ServerEngine's
|
|
220
|
+
`@monitors` array, and both `start_new_worker(wid)` and `restart_worker(wid)`
|
|
221
|
+
reuse the same index. That is what the slot-not-PID scheme relies on.
|
|
222
|
+
|
|
223
|
+
The monitor thread is created inside the fork, because threads do not survive
|
|
224
|
+
`fork`. Starting it earlier would accomplish nothing. For the same reason the
|
|
225
|
+
registry initializes its array and mutex at file-load time: that happens once,
|
|
226
|
+
single-threaded, before any fork, so no lazy initialization is needed and none
|
|
227
|
+
can race.
|
|
228
|
+
|
|
229
|
+
In Rails the hooks are installed from a Railtie, and from `to_prepare` rather
|
|
230
|
+
than an `initializer`. An application's `lib` is normally managed by Zeitwerk and
|
|
231
|
+
reloadable, and reloadable constants must not be referenced while the
|
|
232
|
+
application is initializing.
|
|
233
|
+
|
|
234
|
+
Starting the monitor is wrapped in a `rescue`: instrumentation has no right to
|
|
235
|
+
prevent a worker from starting. `Dir.mkdir` raises `Errno::EROFS` if the marks
|
|
236
|
+
directory was never mounted, and resolving the worker set runs application code.
|
|
237
|
+
The worst outcome should be a missing mark and a pod restart, never a pod that
|
|
238
|
+
stays up with silent queues.
|
|
239
|
+
|
|
240
|
+
### The expected consumer count is not configured
|
|
241
|
+
|
|
242
|
+
It is derived from `config[:worker_classes]` — the very set from which the
|
|
243
|
+
worker gem builds its workers. The queue list is therefore never duplicated and
|
|
244
|
+
cannot drift from the application's configuration.
|
|
245
|
+
|
|
246
|
+
That value is an array of classes under `sneakers:run` and a callable registry
|
|
247
|
+
under `sneakers:active_job`, so it is resolved with a `respond_to?(:call)`
|
|
248
|
+
check.
|
|
249
|
+
|
|
250
|
+
## What is configurable, and where
|
|
251
|
+
|
|
252
|
+
| | Where | Why |
|
|
253
|
+
|---|---|---|
|
|
254
|
+
| `logger`, `enabled`, `startup_grace_ticks` | application config block | only the worker needs them |
|
|
255
|
+
| `tick` | either, and the config block wins | only the worker reads it, so two sources cannot contradict each other |
|
|
256
|
+
| `dir`, `max_age` | environment variables **only** | the probe is a separate process |
|
|
257
|
+
|
|
258
|
+
`dir` and `max_age` are deliberately *not* settable from the application. The
|
|
259
|
+
probe is launched by the kubelet as its own process; it never reads, and cannot
|
|
260
|
+
read, the application's initializer. If those two values were configurable in
|
|
261
|
+
both places, a mismatch between what the worker writes and what the probe
|
|
262
|
+
expects would pass unnoticed — and that mismatch is silent by nature: the worker
|
|
263
|
+
would look fine and the probe would look broken. Making them read-only from the
|
|
264
|
+
application removes the failure mode instead of documenting it. A spec asserts
|
|
265
|
+
that `Configuration` does not respond to `dir=` or `max_age=`.
|
|
266
|
+
|
|
267
|
+
`tick` is the opposite case and may be set from either side, because only the
|
|
268
|
+
worker ever reads it: with a single reader there is nothing for a mismatch to
|
|
269
|
+
happen between. `KICKS_LIVENESS_TICK` supplies the initial value and the
|
|
270
|
+
configuration block overrides it, so `config.tick = 5` wins over the variable.
|
|
271
|
+
|
|
272
|
+
Values arriving from a ConfigMap are parsed defensively: garbage falls back to
|
|
273
|
+
the default rather than crashing the worker, and an empty string counts as
|
|
274
|
+
"unset" — in a ConfigMap that is what you get by declaring a key and leaving it
|
|
275
|
+
blank.
|
|
276
|
+
|
|
277
|
+
`max_age` and `tick` are durations, so a value must be parseable *and* positive.
|
|
278
|
+
Zero and negative numbers are the more dangerous half: they parse perfectly
|
|
279
|
+
well, a tick of zero turns the monitor into a hot loop, and a negative `max_age`
|
|
280
|
+
makes every mark stale on arrival, so the probe can never pass again. They fall
|
|
281
|
+
back to the default too.
|
|
282
|
+
|
|
283
|
+
Setting `tick` from the application is held to a stricter standard: a
|
|
284
|
+
non-positive value raises `ArgumentError`. The environment gets a silent fallback
|
|
285
|
+
because a ConfigMap typo must not bring a worker down, whereas an initializer is
|
|
286
|
+
code, and code should fail loudly at boot, where the developer is looking.
|
|
287
|
+
|
|
288
|
+
The logger defaults to `Sneakers.logger` but is resolved lazily, because at the
|
|
289
|
+
time the configuration object is built it may not be set up yet.
|
|
290
|
+
|
|
291
|
+
## Logging: events, not the pulse
|
|
292
|
+
|
|
293
|
+
The pulse lives in the mtime of a file; writing a log line every tick would only
|
|
294
|
+
add noise. What gets logged is transitions.
|
|
295
|
+
|
|
296
|
+
| Event | Level |
|
|
297
|
+
|---|---|
|
|
298
|
+
| started, with the effective settings | INFO |
|
|
299
|
+
| waiting for consumers | INFO |
|
|
300
|
+
| became healthy | INFO |
|
|
301
|
+
| shutting down | INFO |
|
|
302
|
+
| still not healthy after `startup_grace_ticks` ticks | ERROR |
|
|
303
|
+
| was healthy, became unhealthy | ERROR |
|
|
304
|
+
| a slot respawning without ever becoming healthy, once per grace window | ERROR |
|
|
305
|
+
| the monitor thread caught an exception | ERROR |
|
|
306
|
+
| the monitor could not be started at all | ERROR |
|
|
307
|
+
|
|
308
|
+
ERROR is reserved for genuine failure. A logger typically comes up with
|
|
309
|
+
`LOG_LEVEL` defaulting to `error`, so anything important must survive that
|
|
310
|
+
filter — while an ordinary pod start must not emit ERROR on every deploy, or an
|
|
311
|
+
alert on ERROR logs fires on every rollout and is promptly muted.
|
|
312
|
+
|
|
313
|
+
A graceful shutdown is the other half of that rule. `Sneakers::Worker#stop`
|
|
314
|
+
unsubscribes and then waits for the thread pool to drain, which takes as long as
|
|
315
|
+
the longest in-flight job — and the registry empties at the start of it. Judged
|
|
316
|
+
by the consumer count that is indistinguishable from a fault, so every single
|
|
317
|
+
deploy would emit an ERROR. From `WorkerGroup#stop` onwards the predicate is
|
|
318
|
+
therefore suspended: the monitor says so once at INFO and keeps the mark fresh,
|
|
319
|
+
so a slow drain gets the full `terminationGracePeriodSeconds` instead of having
|
|
320
|
+
the probe cut it short at `max_age`.
|
|
321
|
+
|
|
322
|
+
The startup grace exists because a slow start and a start that will never finish
|
|
323
|
+
look identical for the first few ticks. Once the grace expires, staying silent
|
|
324
|
+
would mean a pod that never comes up never says so — hence exactly one ERROR,
|
|
325
|
+
not one per tick.
|