kicks_liveness 0.1.1 → 0.1.3
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 +4 -4
- data/CHANGELOG.md +70 -1
- data/README.md +14 -3
- data/docs/DESIGN.md +136 -178
- data/docs/KUBERNETES.md +29 -0
- data/docs/LIMITATIONS.md +152 -28
- data/docs/SETUP.md +17 -4
- data/docs/VERIFYING.md +138 -5
- data/lib/kicks_liveness/attempts.rb +21 -16
- data/lib/kicks_liveness/configuration.rb +4 -9
- data/lib/kicks_liveness/heartbeat.rb +273 -63
- data/lib/kicks_liveness/hooks.rb +11 -29
- data/lib/kicks_liveness/monitor.rb +36 -46
- data/lib/kicks_liveness/registry.rb +3 -15
- data/lib/kicks_liveness/topology_report.rb +110 -0
- data/lib/kicks_liveness/version.rb +1 -1
- data/lib/kicks_liveness.rb +5 -3
- metadata +5 -8
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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,30 @@ 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 is safe only when something else owns the deadline
|
|
74
|
+
|
|
75
|
+
`Registry.stopping!` suspends the consumer predicate before `WorkerGroup#stop`
|
|
76
|
+
unsubscribes workers and drains their pools. The monitor keeps the heartbeat
|
|
77
|
+
fresh for the rest of the process so liveness does not interrupt an in-flight
|
|
78
|
+
job. During pod deletion the kubelet enforces `terminationGracePeriodSeconds`,
|
|
79
|
+
which bounds this exemption externally.
|
|
80
|
+
|
|
81
|
+
A SIGTERM from an operator, sidecar, or internal supervisor enters the same
|
|
82
|
+
path without deleting the pod. If Sneakers or Bunny then drains or reconnects
|
|
83
|
+
indefinitely, the heartbeat stays fresh with zero consumers and the pod can
|
|
84
|
+
remain Ready forever. The process cannot infer an external deadline from the
|
|
85
|
+
signal.
|
|
86
|
+
|
|
87
|
+
This is existing shutdown behaviour, not a 0.1.3 regression. Until the exemption
|
|
88
|
+
is bounded — planned for 0.1.4 — send SIGTERM only through a termination path
|
|
89
|
+
that has its own enforced deadline. Use pod deletion for Kubernetes operations.
|
|
90
|
+
An internal supervisor must eventually make PID 1 exit itself, or ask the
|
|
91
|
+
container runtime to stop it, if graceful shutdown does not complete; an
|
|
92
|
+
in-namespace `kill -9 1` is not a reliable escalation even when `kill` reports
|
|
93
|
+
success. Keep the external consumer alert enabled as well: it still sees the
|
|
94
|
+
zero-consumer state that the process-local predicate has intentionally stopped
|
|
95
|
+
evaluating.
|
|
96
|
+
|
|
81
97
|
## Changing the worker count at runtime is not supported
|
|
82
98
|
|
|
83
99
|
`ServerEngine` can re-read its configuration on SIGHUP and scale the fork set,
|
|
@@ -92,25 +108,133 @@ count and the retired slots eventually go stale. Neither direction is safe.
|
|
|
92
108
|
|
|
93
109
|
If you change `workers`, restart the runner. Runtime scaling is not supported.
|
|
94
110
|
|
|
95
|
-
##
|
|
111
|
+
## Container generations need Linux procfs and a private PID namespace
|
|
112
|
+
|
|
113
|
+
Kubernetes keeps an `emptyDir` across restarts of a container in the same pod.
|
|
114
|
+
To stop the next container inheriting a fresh heartbeat, the worker and its exec
|
|
115
|
+
probe independently identify their shared incarnation from Linux procfs: the
|
|
116
|
+
mount namespace plus PID 1 start time. No application cache is removed.
|
|
117
|
+
|
|
118
|
+
That is an incarnation only where the container owns PID 1. With
|
|
119
|
+
`shareProcessNamespace: true` PID 1 is the pod sandbox; with `hostPID: true` it
|
|
120
|
+
is the node's init process. Neither restarts when the worker container does.
|
|
121
|
+
If the replacement container also receives the same mount namespace inode,
|
|
122
|
+
the complete identifier repeats and an inherited fresh mark can satisfy the
|
|
123
|
+
0.1.2 startup probe.
|
|
124
|
+
|
|
125
|
+
Rejection in 0.1.3 depends on procfs access. On the measured Kubernetes node,
|
|
126
|
+
default capabilities left shared PID `unverifiable` and host PID `unavailable`;
|
|
127
|
+
node init reported start time zero. Adding `CAP_SYS_PTRACE` made shared PID
|
|
128
|
+
provably `unsafe`. This is not a recommendation to add the capability.
|
|
129
|
+
The full restart reproduction and Kubernetes measurement are in
|
|
130
|
+
[VERIFYING.md](VERIFYING.md#12-shared-and-host-pid-topology-outcomes).
|
|
131
|
+
|
|
132
|
+
### Proven unsafe: the probe fails, and keeps failing
|
|
133
|
+
|
|
134
|
+
Both links readable and different is proof that PID 1 belongs to something
|
|
135
|
+
outside this container. The probe exits 1 with
|
|
136
|
+
|
|
137
|
+
```
|
|
138
|
+
unsupported PID topology: pod must not set shareProcessNamespace or hostPID
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Procfs access can differ between the worker and probe. A worker that proves the
|
|
142
|
+
topology unsafe publishes `topology-unsafe` instead of a heartbeat; a probe
|
|
143
|
+
rejects either that file or its own unsafe result before checking marks.
|
|
144
|
+
Inherited generations and another fork's marks cannot override the verdict.
|
|
145
|
+
|
|
146
|
+
The worker logs the reason and recovery at ERROR once for the uninterrupted
|
|
147
|
+
unsafe state, across forks and container restarts sharing the volume. Later
|
|
148
|
+
containers in that pod therefore do not repeat the ERROR; read the verdict from
|
|
149
|
+
the kubelet's `Unhealthy` event or `topology-unsafe`.
|
|
150
|
+
|
|
151
|
+
This state is terminal for the marks volume. Neither PID setting can change on
|
|
152
|
+
a live pod, and its `emptyDir` dies with it. **Recovery is recreating the pod
|
|
153
|
+
without those settings.** Until then probes fail and the container restarts.
|
|
154
|
+
Unreadable namespace links do not establish this verdict; they take a degraded
|
|
155
|
+
branch below.
|
|
96
156
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
157
|
+
### Unverifiable: the 0.1.2 guarantee, and one warning
|
|
158
|
+
|
|
159
|
+
Reading `/proc/1/ns/mnt` is a ptrace-level operation and may be denied, including
|
|
160
|
+
when PID 1 runs as a different user. Denial cannot distinguish a valid private
|
|
161
|
+
PID namespace from an unsupported topology. If the other procfs entries yield a
|
|
162
|
+
generation, the probe retains its 0.1.2 check and output; the worker logs:
|
|
163
|
+
|
|
164
|
+
```
|
|
165
|
+
WARN [liveness] slot 0: PID topology unverified (cannot read /proc/1/ns/mnt (Errno::EACCES)):
|
|
166
|
+
the container generation still scopes the heartbeat, but shareProcessNamespace and hostPID
|
|
167
|
+
cannot be ruled out
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
Read this as unverifiable, not safe: an unsupported topology still has the
|
|
171
|
+
0.1.2 inherited-heartbeat weakness. The WARN is once per derived generation,
|
|
172
|
+
across fork respawns. The measured Kubernetes shared-PID case reached this state;
|
|
173
|
+
its credentials and capability comparison are recorded in VERIFYING.md above.
|
|
174
|
+
|
|
175
|
+
There is a narrower startup window when the worker and probe have asymmetric
|
|
176
|
+
access: the worker can prove the topology unsafe, but the probe cannot. Before
|
|
177
|
+
that worker reaches the monitor and publishes `topology-unsafe`, the probe can
|
|
178
|
+
accept inherited marks only if the volume already contains a fresh heartbeat
|
|
179
|
+
from an older worker, or from one that also could not prove the topology. Both
|
|
180
|
+
conditions are required. A normal Kubernetes exec probe inherits the
|
|
181
|
+
container's credentials and reaches the same procfs verdict itself; after a
|
|
182
|
+
0.1.3 worker has proved the topology, `topology-unsafe` also survives every
|
|
183
|
+
later restart in the same documented `emptyDir`. The residual window therefore
|
|
184
|
+
requires an in-place restart that also changes the image or effective access,
|
|
185
|
+
or a marks volume that outlives the pod contrary to this setup. In that case the
|
|
186
|
+
probe retains the explicitly degraded 0.1.2 guarantee until the current worker
|
|
187
|
+
publishes its verdict.
|
|
188
|
+
|
|
189
|
+
### No generation at all
|
|
190
|
+
|
|
191
|
+
If `/proc/self/ns/mnt` or `/proc/1/stat` cannot yield a generation, the probe
|
|
192
|
+
falls back to the 0.1.1 freshness-only check and may accept fresh inherited
|
|
193
|
+
marks. The worker warns once for the uninterrupted unavailable state. A verified
|
|
194
|
+
topology clears the report flag so a later degradation is reported again:
|
|
195
|
+
|
|
196
|
+
```
|
|
197
|
+
WARN [liveness] slot 0: no container generation (cannot read /proc/1/stat (Errno::EACCES)):
|
|
198
|
+
falling back to freshness only, which cannot reject a heartbeat inherited from a previous
|
|
199
|
+
container
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
A zero start time uses the detail `PID 1 start time is zero; possible hostPID or
|
|
203
|
+
virtualized procfs` in that warning. The field counts clock ticks after boot,
|
|
204
|
+
so zero is valid, not a parse error. It was observed for node init in a host-PID
|
|
205
|
+
pod, but may also be exposed for a legitimate PID 1 by virtualized procfs.
|
|
206
|
+
Without measurements of those runtimes, zero alone remains `unavailable`.
|
|
207
|
+
|
|
208
|
+
Off Linux the same freshness-only fallback is **silent**, an intentional
|
|
209
|
+
compatibility mode. Degraded Linux warnings also stay off the probe's stdout:
|
|
210
|
+
they belong in the worker log, not repeated beside unrelated failures in
|
|
211
|
+
`Unhealthy` events. A proven unsafe topology is different: it is the probe's
|
|
212
|
+
verdict.
|
|
213
|
+
|
|
214
|
+
### The probe must run in the worker's container
|
|
215
|
+
|
|
216
|
+
The worker and its exec probe must run in the **same container**. A neighbouring
|
|
217
|
+
sidecar can mount the same `emptyDir`, but it has a different mount namespace and
|
|
218
|
+
therefore treats the worker's marks as belonging to another container.
|
|
219
|
+
Kubernetes exec probes already run inside the container they check; do not move
|
|
220
|
+
`kicks-liveness` into a separate health sidecar.
|
|
221
|
+
|
|
222
|
+
## A respawn loop is reported once per grace window, not once per respawn
|
|
102
223
|
|
|
103
|
-
The
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
224
|
+
The supervisor respawns a fork that cannot subscribe after `start_worker_delay`,
|
|
225
|
+
potentially before its monitor reaches startup grace. `attempt-<slot>` therefore
|
|
226
|
+
keeps the count and window start outside process memory. The first incarnation
|
|
227
|
+
logs startup, further starts within `startup_grace_ticks × tick` are silent, and
|
|
228
|
+
each expired window produces one ERROR with elapsed time and cumulative starts:
|
|
107
229
|
|
|
108
230
|
```
|
|
109
231
|
[liveness] slot 0: not healthy 62s over 305 starts, expected 5 consumers
|
|
110
232
|
```
|
|
111
233
|
|
|
112
|
-
The
|
|
113
|
-
|
|
234
|
+
The generation in the file rejects an earlier container's count. A replacement
|
|
235
|
+
logs its own `started:` and cannot escalate over starts it did not take; the
|
|
236
|
+
first healthy tick clears the counter. Without a generation, the old volume-wide
|
|
237
|
+
scope remains: read it as starts since this slot was last healthy in the pod.
|
|
114
238
|
|
|
115
239
|
## Do not install both `kicks` and `sneakers`
|
|
116
240
|
|
data/docs/SETUP.md
CHANGED
|
@@ -174,11 +174,24 @@ Once workers are running, the marks directory is the other half of the answer:
|
|
|
174
174
|
```
|
|
175
175
|
$ ls -l /opt/app/tmp/health/
|
|
176
176
|
expected
|
|
177
|
+
generation
|
|
177
178
|
worker-0
|
|
178
179
|
worker-1
|
|
179
180
|
```
|
|
180
181
|
|
|
181
|
-
One `worker-<slot>` file per fork, plus `expected
|
|
182
|
-
the slot files are not, the workers
|
|
183
|
-
|
|
184
|
-
which is removed as soon as
|
|
182
|
+
One `worker-<slot>` file per fork, plus `expected` and the current container
|
|
183
|
+
`generation`. If `expected` is there and the slot files are not, the workers
|
|
184
|
+
have not finished subscribing. A slot that keeps restarting without ever
|
|
185
|
+
subscribing also leaves an `attempt-<slot>` file, which is removed as soon as
|
|
186
|
+
that slot becomes healthy.
|
|
187
|
+
|
|
188
|
+
The `generation` file is authoritative only while procfs generation detection is
|
|
189
|
+
available: where it is not, the worker writes no generation and a file left by
|
|
190
|
+
an earlier container stays visible, so `kubectl exec ... cat <dir>/generation`
|
|
191
|
+
can show a stale incarnation. It is not deleted in that state on purpose —
|
|
192
|
+
removing it could fail a concurrently running probe that does have a valid
|
|
193
|
+
generation, for a reason that has nothing to do with the worker's health. Two
|
|
194
|
+
further files appear only when something is wrong: `topology-unsafe`, which
|
|
195
|
+
fails every probe until the pod is recreated, and `topology-report`, which is
|
|
196
|
+
how the worker keeps what it says about the topology to one line. Both are explained in
|
|
197
|
+
[LIMITATIONS.md](LIMITATIONS.md#container-generations-need-linux-procfs-and-a-private-pid-namespace).
|
data/docs/VERIFYING.md
CHANGED
|
@@ -35,6 +35,10 @@ spec/integration/verify.sh up # namespace, broker, worker
|
|
|
35
35
|
spec/integration/verify.sh down # deletes the namespace
|
|
36
36
|
```
|
|
37
37
|
|
|
38
|
+
Scenarios 11 and 12 are the exception: they need the image and Docker, but no
|
|
39
|
+
cluster and no broker, because both are about the container itself rather than
|
|
40
|
+
about consuming.
|
|
41
|
+
|
|
38
42
|
Every `kubectl` call names its context and namespace explicitly, so the script
|
|
39
43
|
never falls back to `current-context`. It will still operate on whichever target
|
|
40
44
|
you configure: run `status` and inspect both values before `up` or `down`. The
|
|
@@ -97,11 +101,15 @@ spec/integration/verify.sh probe
|
|
|
97
101
|
INFO: [liveness] slot 0: started: dir=/opt/app/tmp/health max_age=45s tick=10s processes=1 consumers=2
|
|
98
102
|
INFO: [liveness] slot 0: waiting for 2 consumers
|
|
99
103
|
INFO: [liveness] slot 0: healthy # one tick later
|
|
100
|
-
expected worker-0
|
|
104
|
+
expected generation worker-0 # in the marks directory
|
|
101
105
|
1 process(es) healthy # probe, exit 0
|
|
102
106
|
```
|
|
103
107
|
|
|
104
|
-
The hooks fired inside a real ServerEngine fork, which no double can show.
|
|
108
|
+
The hooks fired inside a real ServerEngine fork, which no double can show. The
|
|
109
|
+
`started:` line is also valid proof after a same-pod container restart: since
|
|
110
|
+
0.1.3 the respawn counter carries the container generation, so a replacement
|
|
111
|
+
container reports its own first start rather than continuing the previous
|
|
112
|
+
container's unhealthy run.
|
|
105
113
|
|
|
106
114
|
### 2. A stale mark
|
|
107
115
|
|
|
@@ -242,9 +250,9 @@ find them.
|
|
|
242
250
|
```
|
|
243
251
|
INFO: [liveness] slot 0: started: ... processes=2 consumers=2
|
|
244
252
|
INFO: [liveness] slot 1: started: ... processes=2 consumers=2
|
|
245
|
-
expected worker-0 worker-1
|
|
246
|
-
2 process(es) healthy
|
|
247
|
-
worker-1 stale 120s > 45s
|
|
253
|
+
expected generation worker-0 worker-1 # expected contains "2"
|
|
254
|
+
2 process(es) healthy # exit 0
|
|
255
|
+
worker-1 stale 120s > 45s # exit 1 — staling either mark is enough
|
|
248
256
|
```
|
|
249
257
|
|
|
250
258
|
Both marks are required, not just the first.
|
|
@@ -331,6 +339,131 @@ pod is restarted. That is why the documented rule set pairs it with
|
|
|
331
339
|
`absent_over_time`, and why an `up == 0` on the scrape job is worth having
|
|
332
340
|
beside both.
|
|
333
341
|
|
|
342
|
+
### 11. A private-PID container restart cannot inherit a healthy mark
|
|
343
|
+
|
|
344
|
+
This scenario needs the fixture image but not Kubernetes or RabbitMQ. It starts
|
|
345
|
+
a container that publishes a healthy mark into a named volume, restarts that
|
|
346
|
+
same container, and delays the new writer for 15 seconds:
|
|
347
|
+
|
|
348
|
+
```bash
|
|
349
|
+
spec/integration/verify.sh build
|
|
350
|
+
spec/integration/verify.sh generation-restart
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
During the delay the inherited mark is still fresh, but the probe must reject
|
|
354
|
+
its old generation. Once the replacement writer publishes its own mark, the
|
|
355
|
+
same probe must become healthy again. The command checks both transitions and
|
|
356
|
+
prints output in this form:
|
|
357
|
+
|
|
358
|
+
```
|
|
359
|
+
before=mnt:[4026532686]:123456
|
|
360
|
+
after=mnt:[4026532686]:123789
|
|
361
|
+
inherited=heartbeat belongs to a previous container: worker has not started yet
|
|
362
|
+
recovered=1 process(es) healthy
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
The namespace inode is allowed to be reused, as in the example above; the PID 1
|
|
366
|
+
start time still distinguishes the two incarnations. The test removes its
|
|
367
|
+
container and named volume on exit.
|
|
368
|
+
|
|
369
|
+
The command also prints the topology it ran under, which for an ordinary
|
|
370
|
+
container is the fully verified one:
|
|
371
|
+
|
|
372
|
+
```
|
|
373
|
+
generation-restart: topology=verified
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
This scenario covers the default container-private PID namespace. With
|
|
377
|
+
`shareProcessNamespace: true` or `hostPID: true`, PID 1 survives the worker
|
|
378
|
+
container restart and this guarantee cannot hold — scenario 12 is that case,
|
|
379
|
+
showing both fail-closed rejection when the topology can be proved and the
|
|
380
|
+
degraded outcomes when procfs access cannot prove it.
|
|
381
|
+
|
|
382
|
+
### 12. Shared and host PID topology outcomes
|
|
383
|
+
|
|
384
|
+
The four PID topologies, in one command. It needs the fixture image but neither
|
|
385
|
+
Kubernetes nor RabbitMQ, because everything here turns on which PID namespace
|
|
386
|
+
the worker container is in:
|
|
387
|
+
|
|
388
|
+
```bash
|
|
389
|
+
spec/integration/verify.sh build
|
|
390
|
+
spec/integration/verify.sh topology
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
Recorded on Linux 6.12 (arm64), Docker 27.4.1, ruby 3.4.10:
|
|
394
|
+
|
|
395
|
+
```
|
|
396
|
+
shared-pid: topology=unsafe
|
|
397
|
+
shared-pid: 0.1.2 generation before=mnt:[4026532693]:17207878 after=mnt:[4026532693]:17207878
|
|
398
|
+
shared-pid inherited heartbeat: exit=1 unsupported PID topology: pod must not set shareProcessNamespace or hostPID
|
|
399
|
+
shared-pid: marks directory holds expected generation topology-unsafe worker-0
|
|
400
|
+
shared-pid after the worker published: exit=1 unsupported PID topology: pod must not set shareProcessNamespace or hostPID
|
|
401
|
+
host-pid: topology=unsafe
|
|
402
|
+
host-pid: 0.1.2 generation before=mnt:[4026532699]:105 after=mnt:[4026532699]:105
|
|
403
|
+
host-pid inherited heartbeat: exit=1 unsupported PID topology: pod must not set shareProcessNamespace or hostPID
|
|
404
|
+
host-pid: marks directory holds expected generation topology-unsafe worker-0
|
|
405
|
+
host-pid after the worker published: exit=1 unsupported PID topology: pod must not set shareProcessNamespace or hostPID
|
|
406
|
+
private-pid: topology=verified
|
|
407
|
+
private-pid: exit=0 1 process(es) healthy
|
|
408
|
+
unverifiable: topology=unverifiable
|
|
409
|
+
unverifiable: warnings across three fork respawns=1
|
|
410
|
+
unverifiable inherited heartbeat: exit=1 heartbeat belongs to a previous container: worker has not started yet
|
|
411
|
+
unverifiable own heartbeat: exit=0 1 process(es) healthy
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
Four cases, and each line is the point of one of them.
|
|
415
|
+
|
|
416
|
+
A separate run on a real Kubernetes v1.25.0 node on Docker Desktop used
|
|
417
|
+
containerd, no `securityContext`, a root worker and the default capability set.
|
|
418
|
+
It produced a different and operationally important table:
|
|
419
|
+
|
|
420
|
+
| pod topology | PID 1 | PID 1 UID | `/proc/1/ns/mnt` | start time | 0.1.3 state |
|
|
421
|
+
|---|---|---:|---|---:|---|
|
|
422
|
+
| `hostPID: true` | node init | 0 | denied | 0 | `unavailable` |
|
|
423
|
+
| `shareProcessNamespace: true` | pod sandbox | 65535 | denied | 19031096 | `unverifiable` |
|
|
424
|
+
| private PID | container process | 0 | readable | 19031243 | `verified` |
|
|
425
|
+
| shared PID plus `CAP_SYS_PTRACE` | pod sandbox | 65535 | readable | — | `unsafe` |
|
|
426
|
+
|
|
427
|
+
The Docker matrix proves the fail-closed branch by granting the access it needs;
|
|
428
|
+
the Kubernetes measurement shows that this is not the default result for either
|
|
429
|
+
unsupported setting. The degraded states still warn, but they retain the weaker
|
|
430
|
+
generation or freshness-only guarantees described in LIMITATIONS.md.
|
|
431
|
+
|
|
432
|
+
**`shared-pid`** joins another container's PID namespace, which is what
|
|
433
|
+
Kubernetes `shareProcessNamespace: true` does — there PID 1 is the pod sandbox.
|
|
434
|
+
**`host-pid`** uses the node's PID namespace, which is `hostPID: true`. In both,
|
|
435
|
+
the run publishes a complete, fresh heartbeat in the **0.1.2 format**, restarts
|
|
436
|
+
only the worker container, and recomputes the 0.1.2 identifier by the 0.1.2
|
|
437
|
+
rule. The `before`/`after` pair is the defect being reproduced rather than
|
|
438
|
+
argued about: both halves repeat, including the mount namespace inode, which the
|
|
439
|
+
kernel handed straight back to the replacement container. A 0.1.2 probe would
|
|
440
|
+
have accepted those inherited marks. The revised probe rejects the topology
|
|
441
|
+
before it ever looks at them, and the worker then adds `topology-unsafe` beside
|
|
442
|
+
the inherited files rather than a heartbeat of its own.
|
|
443
|
+
|
|
444
|
+
Reading PID 1's namespace link is a ptrace-level operation, so the `host-pid`
|
|
445
|
+
case runs with `CAP_SYS_PTRACE`; without it the link is unreadable and the
|
|
446
|
+
outcome depends on whether a generation can still be derived. Node init on the
|
|
447
|
+
measured Kubernetes node reported start time zero, so that pod was
|
|
448
|
+
`unavailable`. A readable positive start time would instead leave the topology
|
|
449
|
+
`unverifiable`. The fourth case below constructs that latter branch explicitly;
|
|
450
|
+
both are honest limits on what a container can prove about a topology it is not
|
|
451
|
+
allowed to inspect.
|
|
452
|
+
|
|
453
|
+
**`private-pid`** is the fully verified positive control: an ordinary container,
|
|
454
|
+
owning PID 1, probe green.
|
|
455
|
+
|
|
456
|
+
**`unverifiable`** is the second positive control, and the one that has to stay
|
|
457
|
+
green: a private-PID container whose checker cannot read `/proc/1/ns/mnt`. The
|
|
458
|
+
0.1.2 generation check is kept in full — a heartbeat from another generation is
|
|
459
|
+
still rejected, its own is still accepted — and the cost is exactly one worker
|
|
460
|
+
warning across three fork respawns, not a probe that is permanently red.
|
|
461
|
+
|
|
462
|
+
That case is constructed by running the checker as a user other than PID 1's.
|
|
463
|
+
It is **not** a model of a Kubernetes exec probe, which has no user setting of
|
|
464
|
+
its own and normally inherits the container process credentials; it is simply
|
|
465
|
+
the one way to make a namespace link unreadable without a custom procfs mount.
|
|
466
|
+
|
|
334
467
|
## What to do with a disagreement
|
|
335
468
|
|
|
336
469
|
Record it, then fix whichever is wrong — the code or the document. The numbers
|
|
@@ -3,23 +3,18 @@ require 'fileutils'
|
|
|
3
3
|
module KicksLiveness
|
|
4
4
|
# Counts starts of a supervisor slot that have not reached healthy yet.
|
|
5
5
|
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
6
|
+
# Persistent state survives fork respawns; its generation separates container
|
|
7
|
+
# incarnations when available. Kept outside {Heartbeat} because the probe does
|
|
8
|
+
# not read these counters.
|
|
9
9
|
#
|
|
10
|
-
#
|
|
11
|
-
# respawn storm destroys process state every few hundred milliseconds: the
|
|
12
|
-
# supervisor brings a failing fork back after +start_worker_delay+, and every
|
|
13
|
-
# incarnation of the monitor believes it is the first. That is how an outage
|
|
14
|
-
# can produce thousands of identical INFO lines and not one ERROR. The
|
|
15
|
-
# directory is the only state that survives the fork.
|
|
16
|
-
#
|
|
17
|
-
# @see file:docs/DESIGN.md#logging-events-not-the-pulse
|
|
10
|
+
# @see file:docs/LIMITATIONS.md#a-respawn-loop-is-reported-once-per-grace-window-not-once-per-respawn
|
|
18
11
|
# @api private
|
|
19
12
|
class Attempts
|
|
20
13
|
# @param dir [String] marks directory
|
|
21
|
-
|
|
14
|
+
# @param generation [String, nil] container incarnation the count belongs to
|
|
15
|
+
def initialize(dir, generation: nil)
|
|
22
16
|
@dir = dir
|
|
17
|
+
@generation = generation
|
|
23
18
|
end
|
|
24
19
|
|
|
25
20
|
# Records a start of this slot.
|
|
@@ -62,22 +57,32 @@ module KicksLiveness
|
|
|
62
57
|
File.join(@dir, "attempt-#{slot}")
|
|
63
58
|
end
|
|
64
59
|
|
|
65
|
-
#
|
|
66
|
-
#
|
|
60
|
+
# Missing, unreadable, or foreign-generation counts start a new run.
|
|
61
|
+
# Counter failures must not prevent worker startup.
|
|
67
62
|
def read(slot)
|
|
68
|
-
count, epoch = File.read(path(slot)).split
|
|
63
|
+
count, epoch, generation = File.read(path(slot)).split
|
|
64
|
+
return [0, nil] unless same_generation?(generation)
|
|
65
|
+
|
|
69
66
|
[Integer(count), Time.at(Integer(epoch)).utc]
|
|
70
67
|
rescue StandardError
|
|
71
68
|
[0, nil]
|
|
72
69
|
end
|
|
73
70
|
|
|
71
|
+
def same_generation?(declared)
|
|
72
|
+
return true unless @generation
|
|
73
|
+
|
|
74
|
+
declared == "generation=#{@generation}"
|
|
75
|
+
end
|
|
76
|
+
|
|
74
77
|
# Failing to write degrades the reporting back to one line per respawn,
|
|
75
78
|
# which is noisy but harmless — so a read-only directory is swallowed here
|
|
76
79
|
# rather than escalated.
|
|
77
80
|
def write(slot, count, first)
|
|
78
81
|
FileUtils.mkdir_p(@dir)
|
|
82
|
+
contents = "#{count} #{first.to_i}"
|
|
83
|
+
contents = "#{contents} generation=#{@generation}" if @generation
|
|
79
84
|
tmp = "#{path(slot)}.#{Process.pid}"
|
|
80
|
-
File.write(tmp, "#{
|
|
85
|
+
File.write(tmp, "#{contents}\n")
|
|
81
86
|
File.rename(tmp, path(slot))
|
|
82
87
|
rescue StandardError
|
|
83
88
|
nil
|
|
@@ -3,11 +3,8 @@ require_relative 'heartbeat'
|
|
|
3
3
|
module KicksLiveness
|
|
4
4
|
# What the application may configure, and what comes from the environment only.
|
|
5
5
|
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
# application's initializer. Were they settable in both places, a mismatch
|
|
9
|
-
# between what the worker writes and what the probe expects would pass
|
|
10
|
-
# unnoticed.
|
|
6
|
+
# dir and max_age are environment-only: the separate exec probe cannot read
|
|
7
|
+
# an application initializer and must resolve the same values as the worker.
|
|
11
8
|
#
|
|
12
9
|
# @see file:docs/SETUP.md#configuration
|
|
13
10
|
# @see file:docs/DESIGN.md#what-is-configurable-and-where
|
|
@@ -44,10 +41,8 @@ module KicksLiveness
|
|
|
44
41
|
@enabled
|
|
45
42
|
end
|
|
46
43
|
|
|
47
|
-
#
|
|
48
|
-
#
|
|
49
|
-
# should fail loudly at boot, where the developer is looking. Environment
|
|
50
|
-
# input is resolved separately by {#environment_tick}, with a safe fallback.
|
|
44
|
+
# Initializer values fail at boot; environment input uses a safe fallback
|
|
45
|
+
# through {#environment_tick}.
|
|
51
46
|
#
|
|
52
47
|
# @param seconds [Numeric]
|
|
53
48
|
# @raise [ArgumentError] if not a positive number
|