kicks_liveness 0.1.2 → 0.1.4

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.
data/docs/KUBERNETES.md CHANGED
@@ -3,6 +3,9 @@
3
3
  ## Manifest
4
4
 
5
5
  ```yaml
6
+ env:
7
+ - name: KICKS_LIVENESS_SHUTDOWN_GRACE
8
+ value: "60" # at least terminationGracePeriodSeconds
6
9
  startupProbe:
7
10
  exec:
8
11
  command: ["bundle", "exec", "kicks-liveness"]
@@ -55,11 +58,16 @@ mark. A fresh mark from the previous container is rejected until every fork in
55
58
  the current one has subscribed and published its own mark, so preserving the
56
59
  cache cannot make `startupProbe` succeed early.
57
60
 
58
- That guarantee assumes the default container-private PID namespace. Do not set
59
- `shareProcessNamespace: true` or `hostPID: true` on a pod that relies on it: in
60
- either topology PID 1 survives a worker-container restart, and the generation
61
- guard can accept an inherited fresh mark. See
62
- [LIMITATIONS.md](LIMITATIONS.md#container-generations-require-linux-procfs-and-container-owned-pid-1).
61
+ That guarantee needs the default container-private PID namespace. Do not set
62
+ `shareProcessNamespace: true` or `hostPID: true`: in either topology PID 1
63
+ survives a worker-container restart, so the generation repeats and an inherited
64
+ mark would be accepted. Since 0.1.3 the gem rejects that topology when either
65
+ the worker or probe can read and compare both namespace links. With Kubernetes'
66
+ default capability set those reads failed on the measured node: the shared-PID
67
+ pod was `unverifiable`, and the host-PID pod was `unavailable` because node init
68
+ reported a zero start time. Those states warn and retain the documented degraded
69
+ checks; they do not claim the topology is safe. See
70
+ [LIMITATIONS.md](LIMITATIONS.md#container-generations-need-linux-procfs-and-a-private-pid-namespace).
63
71
 
64
72
  ## Why the command looks like that
65
73
 
@@ -121,9 +129,12 @@ which is not something to rely on in a manifest.
121
129
 
122
130
  Keep the worker and probe on the same gem version. Since 0.1.2 the heartbeat
123
131
  protocol includes a container generation; a newer probe correctly rejects the
124
- generation-less files written by an older worker. `bundle exec` guarantees that
125
- both sides resolve from the same bundle. If the faster form below installs a
126
- second copy in `GEM_HOME`, rebuild that copy on every gem upgrade too.
132
+ generation-less files written by an older worker. Since 0.1.3 it also includes
133
+ the fail-closed topology state, which only a 0.1.3 probe reads: an older probe
134
+ beside a 0.1.3 worker would ignore a proven unsupported PID topology instead of
135
+ failing on it. `bundle exec` guarantees that both sides resolve from the same
136
+ bundle. If the faster form below installs a second copy in `GEM_HOME`, rebuild
137
+ that copy on every gem upgrade too.
127
138
 
128
139
  ### Where your image puts its gems
129
140
 
@@ -134,11 +145,17 @@ image. One line, no cluster needed:
134
145
  docker run --rm your-image ruby -e "require 'kicks_liveness/probe'"
135
146
  ```
136
147
 
137
- A line like `no /opt/app/tmp/health/expected: worker has not started yet` and
138
- exit 1 means the plain command works — the probe found itself and correctly
139
- reported that no worker is running. A `LoadError` means it does not: keep
140
- `bundle exec kicks-liveness`, or use the explicit-path optimisation below. Do
141
- not put the plain command in a manifest without this check: its failure mode is
148
+ With no worker running, exit 1 and the following line mean the plain command
149
+ works — the probe loaded and found no declared worker count:
150
+
151
+ ```
152
+ no /opt/app/tmp/health/expected: worker has not started yet (is KicksLiveness.install! called before the runner starts? outside Rails it is not automatic)
153
+ ```
154
+
155
+ The question is a [setup hint](SETUP.md#checking-that-the-hooks-are-in-place),
156
+ not proof of missing installation. A `LoadError` means the command does not
157
+ work: keep `bundle exec kicks-liveness`, or use the explicit-path optimisation
158
+ below. Do not put the plain command in a manifest without this check: its failure mode is
142
159
  a pod that never passes `startupProbe` and lands in `CrashLoopBackOff`.
143
160
 
144
161
  What decides it is whether Bundler installed into `GEM_HOME` or somewhere of
@@ -305,6 +322,15 @@ several consumers per worker these add up. If the grace period is shorter than
305
322
  that, the container is SIGKILLed mid-shutdown and in-flight messages are
306
323
  redelivered.
307
324
 
325
+ Set `KICKS_LIVENESS_SHUTDOWN_GRACE` (or `config.shutdown_grace`) to at least
326
+ `terminationGracePeriodSeconds`; both are 60 seconds in the example. The
327
+ monitor keeps the mark fresh only within that shutdown grace. For a SIGTERM
328
+ sent outside pod deletion, a stuck drain stops publishing at the deadline;
329
+ its last mark then has to exceed `max_age`, followed by the liveness probe's
330
+ period and failure threshold. Under normal clock progression, restart
331
+ eligibility is bounded by `shutdown_grace + max_age`, with the probe budget
332
+ additional. During pod deletion the kubelet still owns the termination deadline.
333
+
308
334
  ## Environment variables
309
335
 
310
336
  | Variable | Default | Purpose | Invalid or blank value |
@@ -312,6 +338,7 @@ redelivered.
312
338
  | `KICKS_LIVENESS_DIR` | `/opt/app/tmp/health` | marks directory, **must be on tmpfs** | uses the default |
313
339
  | `KICKS_LIVENESS_MAX_AGE` | `45` | seconds after which a mark is stale | uses the default |
314
340
  | `KICKS_LIVENESS_TICK` | `10` | interval between ticks | uses the default, then the safety fallback below if needed |
341
+ | `KICKS_LIVENESS_SHUTDOWN_GRACE` | `60` | seconds to keep publishing during shutdown; at least `terminationGracePeriodSeconds` | uses the default |
315
342
 
316
343
  Keep `tick` well below `max_age`. A tick longer than half of `max_age` leaves no
317
344
  room for a single missed write, and a tick longer than `max_age` guarantees a
data/docs/LIMITATIONS.md CHANGED
@@ -39,25 +39,17 @@ aware that this probe no longer covers that failure mode.
39
39
 
40
40
  ## Broker unavailability is invisible by design
41
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.
42
+ The probe reports healthy while Bunny recovers an established connection and
43
+ re-subscribes consumers. This avoids restarting every replica during a broker
44
+ outage, but also means an unreachable broker is invisible to this probe. Cover
45
+ that case with the external consumer alert.
50
46
 
51
47
  ## The recovery exemption covers an established connection, not a failed subscribe
52
48
 
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.
49
+ The exemption requires a connection object. If the initial subscription fails,
50
+ the worker never registers and no mark is written. A broker outage at startup
51
+ can therefore restart every replica even though an established connection would
52
+ be exempt.
61
53
 
62
54
  The budget here is the **startup** one, not the steady-state one:
63
55
 
@@ -78,6 +70,39 @@ flapping pod rather than a storm. And the supervisor respawning failing forks
78
70
  inside the container — which is *not* throttled by anything — is now reported:
79
71
  see the respawn escalation below.
80
72
 
73
+ ## Graceful shutdown has a finite heartbeat exemption
74
+
75
+ `Sneakers::WorkerGroup#stop` removes consumers before waiting for their thread
76
+ pools to drain. `Registry.stopping!` therefore suspends the consumer predicate
77
+ before the first worker stops, allowing an orderly drain to keep its heartbeat
78
+ fresh without reporting a consumer failure on every deploy.
79
+
80
+ The exemption lasts for `shutdown_grace` seconds from the first stop callback
81
+ (default 60). Set `KICKS_LIVENESS_SHUTDOWN_GRACE` or `config.shutdown_grace` to at
82
+ least the deployment's `terminationGracePeriodSeconds`. During pod deletion the
83
+ kubelet still owns the outer termination deadline. The gem cannot determine
84
+ whether a SIGTERM came from the kubelet, an operator, or another process.
85
+
86
+ At the deadline, the monitor logs one ERROR and stops refreshing the slot mark.
87
+ It keeps declaring the expected fork count, so an existing mark eventually
88
+ fails as `worker-<slot> stale`, rather than claiming that the worker never
89
+ started. The monitor does not kill the process or resume checking consumers.
90
+ This bounds the formerly indefinite healthy result when an out-of-band SIGTERM
91
+ leaves a process stuck draining with no consumers.
92
+
93
+ Expiry is not an immediate probe failure: the last mark must still exceed
94
+ `max_age`. Under normal clock progression, the bound from stop notification to
95
+ restart eligibility is `shutdown_grace + max_age`; actual restart also depends
96
+ on the probe period and failure threshold. The first stop time and deadline are
97
+ process-local, so repeated stop callbacks do not renew them and a sibling
98
+ replacement fork starts without them.
99
+
100
+ The deadline uses the monotonic clock. A wall-clock jump cannot shorten or
101
+ extend the exemption, but it can change mark staleness: the probe still compares
102
+ filesystem mtime with wall time. Keep the external consumer alert enabled; the
103
+ shutdown grace and the probe budget deliberately allow a period with no
104
+ consumers before a restart.
105
+
81
106
  ## Changing the worker count at runtime is not supported
82
107
 
83
108
  `ServerEngine` can re-read its configuration on SIGHUP and scale the fork set,
@@ -92,65 +117,133 @@ count and the retired slots eventually go stale. Neither direction is safe.
92
117
 
93
118
  If you change `workers`, restart the runner. Runtime scaling is not supported.
94
119
 
95
- ## Container generations require Linux procfs and container-owned PID 1
120
+ ## Container generations need Linux procfs and a private PID namespace
96
121
 
97
122
  Kubernetes keeps an `emptyDir` across restarts of a container in the same pod.
98
- To prevent the next container from inheriting a fresh heartbeat, the worker and
99
- exec probe independently identify their shared incarnation from Linux procfs:
100
- the mount namespace plus PID 1 start time. No application cache is removed.
101
-
102
- The identifier also assumes that the container owns PID 1. With
103
- `shareProcessNamespace: true`, PID 1 is the pod sandbox, and with
104
- `hostPID: true` it is the node's init process; neither restarts when the worker
105
- container does, so the start-time half of the identifier stays constant. The
106
- mount namespace inode is then the only remaining signal, and the kernel
107
- normally hands the just-released inode back to the replacement container in an
108
- otherwise quiet pod. Under either setting the guard silently degrades to the
109
- 0.1.1 freshness-only behavior and can accept a fresh mark from the previous
110
- container. Do not enable either setting on a pod whose `startupProbe` relies on
111
- this guarantee.
112
-
113
- If procfs is unavailable — for example, when using the gem outside a Linux
114
- container — generation detection falls back to the original freshness-only
115
- check. The worker and probe still function, but they cannot distinguish a fresh
116
- mark left by a previous process from one written by the current process. Linux
117
- Kubernetes, Docker, and Nomad containers expose the required procfs entries
118
- under their normal configuration.
123
+ To stop the next container inheriting a fresh heartbeat, the worker and its exec
124
+ probe independently identify their shared incarnation from Linux procfs: the
125
+ mount namespace plus PID 1 start time. No application cache is removed.
126
+
127
+ That is an incarnation only where the container owns PID 1. With
128
+ `shareProcessNamespace: true` PID 1 is the pod sandbox; with `hostPID: true` it
129
+ is the node's init process. Neither restarts when the worker container does.
130
+ If the replacement container also receives the same mount namespace inode,
131
+ the complete identifier repeats and an inherited fresh mark can satisfy the
132
+ 0.1.2 startup probe.
133
+
134
+ Rejection in 0.1.3 depends on procfs access. On the measured Kubernetes node,
135
+ default capabilities left shared PID `unverifiable` and host PID `unavailable`;
136
+ node init reported start time zero. Adding `CAP_SYS_PTRACE` made shared PID
137
+ provably `unsafe`. This is not a recommendation to add the capability.
138
+ The full restart reproduction and Kubernetes measurement are in
139
+ [VERIFYING.md](VERIFYING.md#12-shared-and-host-pid-topology-outcomes).
140
+
141
+ ### Proven unsafe: the probe fails, and keeps failing
142
+
143
+ Both links readable and different is proof that PID 1 belongs to something
144
+ outside this container. The probe exits 1 with
145
+
146
+ ```
147
+ unsupported PID topology: pod must not set shareProcessNamespace or hostPID
148
+ ```
149
+
150
+ Procfs access can differ between the worker and probe. A worker that proves the
151
+ topology unsafe publishes `topology-unsafe` instead of a heartbeat; a probe
152
+ rejects either that file or its own unsafe result before checking marks.
153
+ Inherited generations and another fork's marks cannot override the verdict.
154
+
155
+ The worker logs the reason and recovery at ERROR once for the uninterrupted
156
+ unsafe state, across forks and container restarts sharing the volume. Later
157
+ containers in that pod therefore do not repeat the ERROR; read the verdict from
158
+ the kubelet's `Unhealthy` event or `topology-unsafe`.
159
+
160
+ This state is terminal for the marks volume. Neither PID setting can change on
161
+ a live pod, and its `emptyDir` dies with it. **Recovery is recreating the pod
162
+ without those settings.** Until then probes fail and the container restarts.
163
+ Unreadable namespace links do not establish this verdict; they take a degraded
164
+ branch below.
165
+
166
+ ### Unverifiable: the 0.1.2 guarantee, and one warning
167
+
168
+ Reading `/proc/1/ns/mnt` is a ptrace-level operation and may be denied, including
169
+ when PID 1 runs as a different user. Denial cannot distinguish a valid private
170
+ PID namespace from an unsupported topology. If the other procfs entries yield a
171
+ generation, the probe retains its 0.1.2 check and output; the worker logs:
172
+
173
+ ```
174
+ WARN [liveness] slot 0: PID topology unverified (cannot read /proc/1/ns/mnt (Errno::EACCES)):
175
+ the container generation still scopes the heartbeat, but shareProcessNamespace and hostPID
176
+ cannot be ruled out
177
+ ```
178
+
179
+ Read this as unverifiable, not safe: an unsupported topology still has the
180
+ 0.1.2 inherited-heartbeat weakness. The WARN is once per derived generation,
181
+ across fork respawns. The measured Kubernetes shared-PID case reached this state;
182
+ its credentials and capability comparison are recorded in VERIFYING.md above.
183
+
184
+ There is a narrower startup window when the worker and probe have asymmetric
185
+ access: the worker can prove the topology unsafe, but the probe cannot. Before
186
+ that worker reaches the monitor and publishes `topology-unsafe`, the probe can
187
+ accept inherited marks only if the volume already contains a fresh heartbeat
188
+ from an older worker, or from one that also could not prove the topology. Both
189
+ conditions are required. A normal Kubernetes exec probe inherits the
190
+ container's credentials and reaches the same procfs verdict itself; after a
191
+ 0.1.3 worker has proved the topology, `topology-unsafe` also survives every
192
+ later restart in the same documented `emptyDir`. The residual window therefore
193
+ requires an in-place restart that also changes the image or effective access,
194
+ or a marks volume that outlives the pod contrary to this setup. In that case the
195
+ probe retains the explicitly degraded 0.1.2 guarantee until the current worker
196
+ publishes its verdict.
197
+
198
+ ### No generation at all
199
+
200
+ If `/proc/self/ns/mnt` or `/proc/1/stat` cannot yield a generation, the probe
201
+ falls back to the 0.1.1 freshness-only check and may accept fresh inherited
202
+ marks. The worker warns once for the uninterrupted unavailable state. A verified
203
+ topology clears the report flag so a later degradation is reported again:
204
+
205
+ ```
206
+ WARN [liveness] slot 0: no container generation (cannot read /proc/1/stat (Errno::EACCES)):
207
+ falling back to freshness only, which cannot reject a heartbeat inherited from a previous
208
+ container
209
+ ```
210
+
211
+ A zero start time uses the detail `PID 1 start time is zero; possible hostPID or
212
+ virtualized procfs` in that warning. The field counts clock ticks after boot,
213
+ so zero is valid, not a parse error. It was observed for node init in a host-PID
214
+ pod, but may also be exposed for a legitimate PID 1 by virtualized procfs.
215
+ Without measurements of those runtimes, zero alone remains `unavailable`.
216
+
217
+ Off Linux the same freshness-only fallback is **silent**, an intentional
218
+ compatibility mode. Degraded Linux warnings also stay off the probe's stdout:
219
+ they belong in the worker log, not repeated beside unrelated failures in
220
+ `Unhealthy` events. A proven unsafe topology is different: it is the probe's
221
+ verdict.
222
+
223
+ ### The probe must run in the worker's container
119
224
 
120
225
  The worker and its exec probe must run in the **same container**. A neighbouring
121
- sidecar can mount the same `emptyDir`, but it has a different mount namespace
122
- and therefore treats the worker's marks as belonging to another container.
226
+ sidecar can mount the same `emptyDir`, but it has a different mount namespace and
227
+ therefore treats the worker's marks as belonging to another container.
123
228
  Kubernetes exec probes already run inside the container they check; do not move
124
229
  `kicks-liveness` into a separate health sidecar.
125
230
 
126
231
  ## A respawn loop is reported once per grace window, not once per respawn
127
232
 
128
- When a fork cannot subscribe at all, the supervisor brings it back after
129
- `start_worker_delay` — a fraction of a second — and the monitor in each
130
- incarnation is a brand-new object. Any counter it holds is destroyed before it
131
- can reach a threshold, which is how a real outage used to produce thousands of
132
- identical INFO lines and not a single ERROR.
133
-
134
- The count is therefore kept in the marks directory, which survives the fork.
135
- The start line is logged once, repeated starts within the grace window
136
- (`startup_grace_ticks × tick`) are silent, and after that one ERROR per window
137
- reports the elapsed time and the number of starts:
233
+ The supervisor respawns a fork that cannot subscribe after `start_worker_delay`,
234
+ potentially before its monitor reaches startup grace. `attempt-<slot>` therefore
235
+ keeps the count and window start outside process memory. The first incarnation
236
+ logs startup, further starts within `startup_grace_ticks × tick` are silent, and
237
+ each expired window produces one ERROR with elapsed time and cumulative starts:
138
238
 
139
239
  ```
140
240
  [liveness] slot 0: not healthy 62s over 305 starts, expected 5 consumers
141
241
  ```
142
242
 
143
- The number of starts is the diagnosis: it separates a slow start from a respawn
144
- loop at a glance.
145
-
146
- This unhealthy-run counter is scoped to the marks volume, not to the container
147
- generation. If a container is restarted before its slot has ever become
148
- healthy, the replacement continues the same count: its repeated `started:` line
149
- is suppressed, and an expired grace window may immediately report an ERROR that
150
- includes starts from the previous container. This is diagnostic state only; it
151
- does not participate in the probe result, and the first healthy tick removes
152
- it. Treat the count as "starts since this slot was last healthy in this pod",
153
- not "starts in this container".
243
+ The generation in the file rejects an earlier container's count. A replacement
244
+ logs its own `started:` and cannot escalate over starts it did not take; the
245
+ first healthy tick clears the counter. Without a generation, the old volume-wide
246
+ scope remains: read it as starts since this slot was last healthy in the pod.
154
247
 
155
248
  ## Do not install both `kicks` and `sneakers`
156
249
 
data/docs/SETUP.md CHANGED
@@ -148,10 +148,19 @@ config.logger = Rails.logger
148
148
  | `enabled` | `true` | start the monitor thread | `true` or `false` |
149
149
  | `tick` | `10` | seconds between checks | positive number smaller than `max_age` |
150
150
  | `startup_grace_ticks` | `6` | unhealthy startup ticks before one ERROR | positive integer |
151
+ | `shutdown_grace` | `60` | seconds to keep publishing during shutdown | positive finite number |
151
152
 
152
153
  Invalid combinations are rejected before the monitor starts instead of running
153
154
  one that is guaranteed to publish stale marks or never report a stalled startup.
154
155
 
156
+ `shutdown_grace` defaults from `KICKS_LIVENESS_SHUTDOWN_GRACE`; the initializer
157
+ wins when both are set. Set it to at least your Kubernetes
158
+ `terminationGracePeriodSeconds`. It bounds the shutdown exemption, not the
159
+ restart time: after expiry the last mark still has to exceed `max_age` and the
160
+ probe's failure budget still applies. The environment accepts positive integer
161
+ seconds and silently uses 60 for invalid or blank values. Invalid initializer
162
+ values raise `ArgumentError`; fractional positive seconds are accepted.
163
+
155
164
  `dir` and `max_age` are **not** here — they come from environment variables
156
165
  only. See
157
166
  [DESIGN.md](DESIGN.md#what-is-configurable-and-where) for why, and
@@ -169,6 +178,16 @@ Sneakers::WorkerGroup.ancestors.include?(KicksLiveness::Hooks::WorkerGroup) # =>
169
178
  Both must be `true` before the runner starts. If either is `false`, either
170
179
  `install!` has not run, or it ran before `kicks`/`sneakers` was loaded.
171
180
 
181
+ When `expected` is missing, the probe asks whether `KicksLiveness.install!` was
182
+ called before the runner started. This is a hint, not a diagnosis: a slow boot
183
+ or a removed marks directory can produce the same result. It does not install
184
+ hooks or change the failing exit status. The complete output is shown in the
185
+ [image check](KUBERNETES.md#where-your-image-puts-its-gems).
186
+
187
+ When migrating from another probe, confirm the `[liveness] slot 0: started:`
188
+ line and the `expected` file on dev before removing the old probe gem, its
189
+ initializer and its rake tasks.
190
+
172
191
  Once workers are running, the marks directory is the other half of the answer:
173
192
 
174
193
  ```
@@ -184,3 +203,14 @@ One `worker-<slot>` file per fork, plus `expected` and the current container
184
203
  have not finished subscribing. A slot that keeps restarting without ever
185
204
  subscribing also leaves an `attempt-<slot>` file, which is removed as soon as
186
205
  that slot becomes healthy.
206
+
207
+ The `generation` file is authoritative only while procfs generation detection is
208
+ available: where it is not, the worker writes no generation and a file left by
209
+ an earlier container stays visible, so `kubectl exec ... cat <dir>/generation`
210
+ can show a stale incarnation. It is not deleted in that state on purpose —
211
+ removing it could fail a concurrently running probe that does have a valid
212
+ generation, for a reason that has nothing to do with the worker's health. Two
213
+ further files appear only when something is wrong: `topology-unsafe`, which
214
+ fails every probe until the pod is recreated, and `topology-report`, which is
215
+ how the worker keeps what it says about the topology to one line. Both are explained in
216
+ [LIMITATIONS.md](LIMITATIONS.md#container-generations-need-linux-procfs-and-a-private-pid-namespace).