start-command 0.35.1 → 0.35.2
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.
- package/CHANGELOG.md +10 -0
- package/package.json +1 -1
- package/src/lib/args-parser.js +1 -0
- package/src/lib/cgroup-memory.js +283 -0
- package/src/lib/detached-finalize.js +17 -2
- package/src/lib/docker-cleanup.js +27 -8
- package/src/lib/docker-recovery-options.js +31 -3
- package/src/lib/docker-runtime-args.js +10 -4
- package/src/lib/execution-control.js +36 -0
- package/src/lib/execution-recovery.js +90 -10
- package/src/lib/execution-store.js +9 -0
- package/src/lib/exit-reason.js +67 -6
- package/src/lib/recovery-delay.js +151 -0
- package/src/lib/status-formatter.js +16 -0
- package/src/lib/usage.js +2 -0
- package/test/docker-runtime-options.js +2 -0
- package/test/regression-174.js +2 -0
- package/test/regression-180.js +196 -0
- package/test/regression-181.js +338 -0
- package/test/regression-182.js +439 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# start-command
|
|
2
2
|
|
|
3
|
+
## 0.35.2
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- e234c05: Stop reporting `exitReason: memory-exhaustion (cgroup-oom-killer)` and `memoryExhausted: true` (`Docker reported State.OOMKilled=true`) for executions that exited 0, 1 or any other ordinary code while Docker's sticky `OOMKilled` flag was set. The flag is container-wide (moby/moby#43564): it turns on when _any_ process in the container is OOM-killed, such as a `rustc` child under `cargo test`, and stays on until the next start. It now counts as the exit reason only when the command itself died by SIGKILL (exit 137) or its exit code is unknown, the same rule `--on-kill-resume` uses since #178. `oomKilled: true` is still reported. A stale cgroup reason stored by an older version is dropped by `--status`. An attached container kept after a child OOM kill now says `Docker reports a process in it was OOM-killed`.
|
|
8
|
+
|
|
9
|
+
Add `--on-kill-resume-delay <min[-max]>` for `--on-kill-resume`/`--recovery-command`: wait a uniformly random number of seconds (e.g. `30-90`; one number is a fixed delay, the default `0` resumes at once) before each recovery, so executions killed by the same host-wide OOM event do not all restart in the same second and trigger the next one. The chosen delay is printed in the `[Recovery k/N]` line, stored as `delayMs` in `recoveryHistory` and as `lastRecoveryDelayMs`, and shown in the `[Isolation] On kill:` line. A `--stop` or `--terminate` during the wait cancels the pending resume; `--terminate` of the already exited container now reports `recovery-cancelled` instead of failing.
|
|
10
|
+
|
|
11
|
+
Record the container's cgroup v2 memory counters for detached Docker executions: the completion watcher samples `memory.max`, `memory.peak` and the `memory.events` `oom`/`oom_kill` counters every second (and once more after the container exits, since the cgroup disappears with it). They are written as a `Memory:` line after the container post-mortem, stored as `cgroupMemory` (`limitBytes`, `peakBytes`, `oomEvents`, `oomKills`) and shown by `--status` as `Cgroup Memory:` with a note telling a container-limit OOM (`oom_kill == oom`) from a host-wide or parent-cgroup OOM (`oom_kill > oom`). A non-zero `oomKills` explains a SIGKILL or unknown exit as a cgroup OOM kill, and each `--on-kill-resume` `recoveryHistory` entry notes `oomEvents`/`oomKills` of the killed run. Hosts without cgroup v2 or with a remote `DOCKER_HOST` record nothing.
|
|
12
|
+
|
|
3
13
|
## 0.35.1
|
|
4
14
|
|
|
5
15
|
### Patch Changes
|
package/package.json
CHANGED
package/src/lib/args-parser.js
CHANGED
|
@@ -197,6 +197,7 @@ function parseArgs(args) {
|
|
|
197
197
|
networkAliases: [], // Docker network-scoped aliases
|
|
198
198
|
onKillResume: null, // Resume up to N times when the docker main process is killed (issue #176)
|
|
199
199
|
recoveryCommand: null, // Command run in the same container on such a resume (issue #176)
|
|
200
|
+
onKillResumeDelay: null, // Random <min>[-<max>] seconds to wait before each such resume (issue #181)
|
|
200
201
|
endpoint: null, // SSH endpoint (current level, e.g., user@host)
|
|
201
202
|
endpointStack: null, // SSH endpoints for each level (with nulls for non-ssh levels)
|
|
202
203
|
user: false, // Create isolated user
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cgroup v2 memory counters of a detached docker container (issue #182).
|
|
3
|
+
*
|
|
4
|
+
* Docker's `State.OOMKilled` is container-wide and sticky (moby/moby#43564):
|
|
5
|
+
* it says that *some* process in the container was OOM-killed at some point,
|
|
6
|
+
* but not how many, not whether the container hit its own `--memory` limit or
|
|
7
|
+
* the whole host ran out, and not how close the execution came to its limit.
|
|
8
|
+
* The kernel keeps all three per cgroup
|
|
9
|
+
* (https://docs.kernel.org/admin-guide/cgroup-v2.html#memory-interface-files):
|
|
10
|
+
* memory.events `oom` the cgroup hit its own limit
|
|
11
|
+
* memory.events `oom_kill` processes killed here by *any* OOM killer, so
|
|
12
|
+
* `oom_kill > oom` points to a host-wide (or
|
|
13
|
+
* parent cgroup) OOM
|
|
14
|
+
* memory.peak / memory.max peak usage (Linux 5.19+) and limit (`max` when
|
|
15
|
+
* unlimited)
|
|
16
|
+
*
|
|
17
|
+
* The cgroup is removed when the container stops, so the values cannot be read
|
|
18
|
+
* afterwards. The detached completion watcher therefore samples them while the
|
|
19
|
+
* container runs and hands the last sample to the post-mortem, the finalizer
|
|
20
|
+
* and the recovery step. Best effort by design: no cgroup v2, a remote docker
|
|
21
|
+
* daemon or a hidden `/proc` leave the counters unknown, and a kill in the last
|
|
22
|
+
* sampling interval can be missed when the cgroup is gone before the final
|
|
23
|
+
* read.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const { shellQuote } = require('./isolation-log-utils');
|
|
27
|
+
|
|
28
|
+
/** Seconds between two samples of a running container's cgroup. */
|
|
29
|
+
const CGROUP_SAMPLE_INTERVAL_SECONDS = 1;
|
|
30
|
+
|
|
31
|
+
/** Shell variables used by the sampler snippets. */
|
|
32
|
+
const CGROUP_SHELL_VARS = {
|
|
33
|
+
/** Last sample: `<memory.max> <memory.peak> <oom> <oom_kill>`. */
|
|
34
|
+
sample: '__start_command_cgroup',
|
|
35
|
+
sampler: '__start_command_cgroup_sampler',
|
|
36
|
+
file: '__start_command_cgroup_file',
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Shell functions shared by the sampler and its final read:
|
|
41
|
+
* __start_command_cgroup_dir NAME print the container's cgroup v2 directory
|
|
42
|
+
* __start_command_cgroup_read DIR print `<max> <peak> <oom> <oom_kill> DIR`
|
|
43
|
+
*
|
|
44
|
+
* The directory comes from `/proc/<.State.Pid>/cgroup` (`0::<path>`), which
|
|
45
|
+
* covers the systemd and cgroupfs drivers, `--cgroup-parent` and rootless
|
|
46
|
+
* docker alike; the two default layouts are fallbacks. A candidate must name
|
|
47
|
+
* the container ID, so a PID reported by a remote daemon can never select an
|
|
48
|
+
* unrelated local cgroup. `START_COMMAND_CGROUP_ROOT` and
|
|
49
|
+
* `START_COMMAND_PROC_ROOT` replace `/sys/fs/cgroup` and `/proc` (tests).
|
|
50
|
+
*
|
|
51
|
+
* @returns {string} Shell function definitions
|
|
52
|
+
*/
|
|
53
|
+
function buildCgroupFunctionsSnippet() {
|
|
54
|
+
return [
|
|
55
|
+
'__start_command_cgroup_dir() { __scd_id=$(docker inspect -f \'{{.Id}}\' "$1" 2>/dev/null); ' +
|
|
56
|
+
'[ -n "$__scd_id" ] || return 1; ' +
|
|
57
|
+
"__scd_pid=$(docker inspect -f '{{.State.Pid}}' \"$1\" 2>/dev/null); __scd_rel=''; " +
|
|
58
|
+
'case "$__scd_pid" in \'\'|0|*[!0-9]*) ;; ' +
|
|
59
|
+
'*) __scd_rel=$(sed -n \'s/^0:://p\' "${START_COMMAND_PROC_ROOT:-/proc}/$__scd_pid/cgroup" 2>/dev/null);; esac; ' +
|
|
60
|
+
'__scd_root=${START_COMMAND_CGROUP_ROOT:-/sys/fs/cgroup}; ' +
|
|
61
|
+
'for __scd_dir in ${__scd_rel:+"$__scd_root$__scd_rel"} ' +
|
|
62
|
+
'"$__scd_root/system.slice/docker-$__scd_id.scope" "$__scd_root/docker/$__scd_id"; do ' +
|
|
63
|
+
'case "$__scd_dir" in *"$__scd_id"*) if [ -r "$__scd_dir/memory.events" ]; then ' +
|
|
64
|
+
'printf \'%s\' "$__scd_dir"; return 0; fi;; esac; done; return 1; }',
|
|
65
|
+
'__start_command_cgroup_read() { __scr_events=$(cat "$1/memory.events" 2>/dev/null) || return 1; ' +
|
|
66
|
+
"__scr_oom=$(printf '%s\\n' \"$__scr_events\" | sed -n 's/^oom //p'); " +
|
|
67
|
+
"__scr_kill=$(printf '%s\\n' \"$__scr_events\" | sed -n 's/^oom_kill //p'); " +
|
|
68
|
+
'__scr_max=$(cat "$1/memory.max" 2>/dev/null); __scr_peak=$(cat "$1/memory.peak" 2>/dev/null); ' +
|
|
69
|
+
'printf \'%s %s %s %s %s\\n\' "${__scr_max:--}" "${__scr_peak:--}" ' +
|
|
70
|
+
'"${__scr_oom:--}" "${__scr_kill:--}" "$1"; }',
|
|
71
|
+
].join('; ');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Shell fragment starting the background sampler. It writes each sample to a
|
|
76
|
+
* temp file with `mv`, so the watcher never reads a half-written line, and
|
|
77
|
+
* ends by itself once the cgroup is gone.
|
|
78
|
+
* @param {string} containerName - Container to sample
|
|
79
|
+
* @returns {string} Shell command
|
|
80
|
+
*/
|
|
81
|
+
function buildCgroupSamplerStartSnippet(containerName) {
|
|
82
|
+
const v = CGROUP_SHELL_VARS;
|
|
83
|
+
return [
|
|
84
|
+
buildCgroupFunctionsSnippet(),
|
|
85
|
+
`${v.file}="\${TMPDIR:-/tmp}/start-command-cgroup.$$"`,
|
|
86
|
+
`rm -f "$${v.file}" "$${v.file}.tmp"`,
|
|
87
|
+
`( __scs_dir=$(__start_command_cgroup_dir ${shellQuote(containerName)}) || exit 0; ` +
|
|
88
|
+
'while __scs_line=$(__start_command_cgroup_read "$__scs_dir"); do ' +
|
|
89
|
+
`printf '%s\\n' "$__scs_line" > "$${v.file}.tmp" && mv -f "$${v.file}.tmp" "$${v.file}"; ` +
|
|
90
|
+
`sleep ${CGROUP_SAMPLE_INTERVAL_SECONDS}; done ) >/dev/null 2>&1 & ${v.sampler}=$!`,
|
|
91
|
+
].join('; ');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Shell fragment stopping the sampler once the container has exited and
|
|
96
|
+
* leaving the last sample (`<max> <peak> <oom> <oom_kill>`, or empty) in
|
|
97
|
+
* `$__start_command_cgroup`. The cgroup can outlive the main process for a
|
|
98
|
+
* moment, so it is read one last time when it still exists.
|
|
99
|
+
* @returns {string} Shell command
|
|
100
|
+
*/
|
|
101
|
+
function buildCgroupSamplerStopSnippet() {
|
|
102
|
+
const v = CGROUP_SHELL_VARS;
|
|
103
|
+
return [
|
|
104
|
+
`kill "$${v.sampler}" 2>/dev/null`,
|
|
105
|
+
`wait "$${v.sampler}" 2>/dev/null`,
|
|
106
|
+
`${v.sample}=$(cat "$${v.file}" 2>/dev/null)`,
|
|
107
|
+
`__scs_dir=$(printf '%s' "$${v.sample}" | cut -s -d ' ' -f 5-)`,
|
|
108
|
+
`if [ -n "$__scs_dir" ] && __scs_line=$(__start_command_cgroup_read "$__scs_dir"); then ${v.sample}=$__scs_line; fi`,
|
|
109
|
+
`rm -f "$${v.file}" "$${v.file}.tmp"`,
|
|
110
|
+
`${v.sample}=$(printf '%s' "$${v.sample}" | cut -d ' ' -f 1-4)`,
|
|
111
|
+
].join('; ');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Shell fragment appending the `Memory:` line of the post-mortem when a
|
|
116
|
+
* sample exists.
|
|
117
|
+
* @param {string} quotedLogPath - Already shell-quoted log path
|
|
118
|
+
* @returns {string} Shell command
|
|
119
|
+
*/
|
|
120
|
+
function buildCgroupMemoryLogSnippet(quotedLogPath) {
|
|
121
|
+
const v = CGROUP_SHELL_VARS;
|
|
122
|
+
// A function, so `$1`.. of the watcher script itself stay untouched.
|
|
123
|
+
return (
|
|
124
|
+
`__start_command_cgroup_log() { __scm_note=''; ` +
|
|
125
|
+
`if [ "$4" -gt 0 ] 2>/dev/null; then if [ "$4" -gt "$3" ] 2>/dev/null; then ` +
|
|
126
|
+
`__scm_note=' (${OOM_SCOPE_NOTES[OOM_SCOPE.HOST_OR_PARENT]})'; ` +
|
|
127
|
+
`else __scm_note=' (${OOM_SCOPE_NOTES[OOM_SCOPE.CONTAINER_LIMIT]})'; fi; fi; ` +
|
|
128
|
+
`printf 'Memory: memory.max=%s memory.peak=%s oom=%s oom_kill=%s%s\\n' ` +
|
|
129
|
+
`"$1" "$2" "$3" "$4" "$__scm_note"; }; ` +
|
|
130
|
+
`if [ -n "$${v.sample}" ]; then __start_command_cgroup_log $${v.sample} >> ${quotedLogPath}; fi`
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The `Memory:` line for a sample, exactly as the watcher's shell writes it.
|
|
136
|
+
* Used where JavaScript writes the post-mortem itself (the recovery step).
|
|
137
|
+
* @param {string|null|undefined} text - `<memory.max> <memory.peak> <oom> <oom_kill>`
|
|
138
|
+
* @returns {?string} Line without a trailing newline, or null without a sample
|
|
139
|
+
*/
|
|
140
|
+
function formatCgroupMemoryLogLine(text) {
|
|
141
|
+
const counters = parseCgroupMemorySample(text);
|
|
142
|
+
if (!counters) {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
const [max, peak, oom, kill] = String(text).trim().split(/\s+/);
|
|
146
|
+
const scope = describeCgroupOomScope(counters);
|
|
147
|
+
return `Memory: memory.max=${max} memory.peak=${peak} oom=${oom} oom_kill=${kill}${
|
|
148
|
+
scope ? ` (${OOM_SCOPE_NOTES[scope]})` : ''
|
|
149
|
+
}`;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Where the OOM killer that killed processes in the container came from. */
|
|
153
|
+
const OOM_SCOPE = {
|
|
154
|
+
CONTAINER_LIMIT: 'container-limit',
|
|
155
|
+
HOST_OR_PARENT: 'host-or-parent',
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const OOM_SCOPE_NOTES = {
|
|
159
|
+
[OOM_SCOPE.CONTAINER_LIMIT]: 'the container hit its own memory limit',
|
|
160
|
+
[OOM_SCOPE.HOST_OR_PARENT]:
|
|
161
|
+
'oom_kill > oom: a host-wide or parent cgroup OOM killed processes here',
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
function parseCounter(text) {
|
|
165
|
+
return /^\d+$/.test(text || '') ? Number(text) : null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Parse a sample written by the watcher.
|
|
170
|
+
* @param {string|null|undefined} text - `<memory.max> <memory.peak> <oom> <oom_kill>`
|
|
171
|
+
* @returns {?{limitBytes: ?number, peakBytes: ?number, oomEvents: ?number, oomKills: ?number}}
|
|
172
|
+
* Counters (`limitBytes` is null without a limit), or null without a sample
|
|
173
|
+
*/
|
|
174
|
+
function parseCgroupMemorySample(text) {
|
|
175
|
+
const fields = String(text === null || text === undefined ? '' : text)
|
|
176
|
+
.trim()
|
|
177
|
+
.split(/\s+/);
|
|
178
|
+
if (fields.length < 4) {
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
const [max, peak, oom, kill] = fields;
|
|
182
|
+
const counters = {
|
|
183
|
+
limitBytes: parseCounter(max),
|
|
184
|
+
peakBytes: parseCounter(peak),
|
|
185
|
+
oomEvents: parseCounter(oom),
|
|
186
|
+
oomKills: parseCounter(kill),
|
|
187
|
+
};
|
|
188
|
+
if (counters.oomEvents === null && counters.oomKills === null) {
|
|
189
|
+
// memory.events was unreadable: the line carries no information.
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
return counters;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Normalize counters read back from an execution record.
|
|
197
|
+
* @param {*} value - `record.cgroupMemory`
|
|
198
|
+
* @returns {?{limitBytes: ?number, peakBytes: ?number, oomEvents: ?number, oomKills: ?number}}
|
|
199
|
+
*/
|
|
200
|
+
function normalizeCgroupMemory(value) {
|
|
201
|
+
if (!value || typeof value !== 'object') {
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
const counter = (field) => {
|
|
205
|
+
const raw = value[field];
|
|
206
|
+
const number = typeof raw === 'string' && raw !== '' ? Number(raw) : raw;
|
|
207
|
+
return Number.isSafeInteger(number) && number >= 0 ? number : null;
|
|
208
|
+
};
|
|
209
|
+
return {
|
|
210
|
+
limitBytes: counter('limitBytes'),
|
|
211
|
+
peakBytes: counter('peakBytes'),
|
|
212
|
+
oomEvents: counter('oomEvents'),
|
|
213
|
+
oomKills: counter('oomKills'),
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* @param {?object} memory - Counters
|
|
219
|
+
* @returns {?string} One of OOM_SCOPE, or null when nothing was OOM-killed
|
|
220
|
+
*/
|
|
221
|
+
function describeCgroupOomScope(memory) {
|
|
222
|
+
const counters = normalizeCgroupMemory(memory);
|
|
223
|
+
if (!counters || !(counters.oomKills > 0)) {
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
return counters.oomKills > (counters.oomEvents || 0)
|
|
227
|
+
? OOM_SCOPE.HOST_OR_PARENT
|
|
228
|
+
: OOM_SCOPE.CONTAINER_LIMIT;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function formatBytes(bytes) {
|
|
232
|
+
if (bytes === null) {
|
|
233
|
+
return 'unknown';
|
|
234
|
+
}
|
|
235
|
+
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB'];
|
|
236
|
+
let value = bytes;
|
|
237
|
+
let unit = 0;
|
|
238
|
+
while (value >= 1024 && unit < units.length - 1) {
|
|
239
|
+
value /= 1024;
|
|
240
|
+
unit++;
|
|
241
|
+
}
|
|
242
|
+
return unit === 0 ? `${bytes} B` : `${value.toFixed(1)} ${units[unit]}`;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Human-readable counters for `--status`.
|
|
247
|
+
* @param {?object} memory - Counters
|
|
248
|
+
* @returns {?string} e.g. `peak 255.9 MiB of 256.0 MiB limit, oom 0, oom_kill 3 (...)`
|
|
249
|
+
*/
|
|
250
|
+
function formatCgroupMemory(memory) {
|
|
251
|
+
const counters = normalizeCgroupMemory(memory);
|
|
252
|
+
if (!counters) {
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
const limit =
|
|
256
|
+
counters.limitBytes === null
|
|
257
|
+
? 'no limit'
|
|
258
|
+
: `${formatBytes(counters.limitBytes)} limit`;
|
|
259
|
+
const count = (value) => (value === null ? 'unknown' : value);
|
|
260
|
+
const scope = describeCgroupOomScope(counters);
|
|
261
|
+
return (
|
|
262
|
+
`peak ${formatBytes(counters.peakBytes)} of ${limit}, ` +
|
|
263
|
+
`oom ${count(counters.oomEvents)}, oom_kill ${count(counters.oomKills)}${
|
|
264
|
+
scope ? ` (${OOM_SCOPE_NOTES[scope]})` : ''
|
|
265
|
+
}`
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
module.exports = {
|
|
270
|
+
CGROUP_SAMPLE_INTERVAL_SECONDS,
|
|
271
|
+
CGROUP_SHELL_VARS,
|
|
272
|
+
OOM_SCOPE,
|
|
273
|
+
OOM_SCOPE_NOTES,
|
|
274
|
+
buildCgroupFunctionsSnippet,
|
|
275
|
+
buildCgroupMemoryLogSnippet,
|
|
276
|
+
buildCgroupSamplerStartSnippet,
|
|
277
|
+
buildCgroupSamplerStopSnippet,
|
|
278
|
+
describeCgroupOomScope,
|
|
279
|
+
formatCgroupMemory,
|
|
280
|
+
formatCgroupMemoryLogLine,
|
|
281
|
+
normalizeCgroupMemory,
|
|
282
|
+
parseCgroupMemorySample,
|
|
283
|
+
};
|
|
@@ -23,6 +23,10 @@ const {
|
|
|
23
23
|
normalizeDockerTimestamp,
|
|
24
24
|
} = require('./docker-post-mortem');
|
|
25
25
|
const { shellQuote } = require('./isolation-log-utils');
|
|
26
|
+
const {
|
|
27
|
+
CGROUP_SHELL_VARS,
|
|
28
|
+
parseCgroupMemorySample,
|
|
29
|
+
} = require('./cgroup-memory');
|
|
26
30
|
|
|
27
31
|
/** Provenance markers for `endTime` (issue #170.2). */
|
|
28
32
|
const END_TIME_SOURCE = {
|
|
@@ -57,6 +61,8 @@ const WATCHER_LOST_CONTAINER = 'watcher-lost-container';
|
|
|
57
61
|
* @param {string|null} [options.finishedAt] - `.State.FinishedAt`
|
|
58
62
|
* @param {string|null} [options.containerError] - `.State.Error`
|
|
59
63
|
* @param {boolean|string|null} [options.running] - `.State.Running`
|
|
64
|
+
* @param {string|null} [options.cgroupMemory] - Last cgroup v2 sample of the
|
|
65
|
+
* watcher, `<memory.max> <memory.peak> <oom> <oom_kill>` (issue #182)
|
|
60
66
|
* @returns {{updated: boolean, reason?: string, record?: Object}} Outcome
|
|
61
67
|
*/
|
|
62
68
|
function finalizeDetachedExecution(options = {}) {
|
|
@@ -123,6 +129,12 @@ function finalizeDetachedExecution(options = {}) {
|
|
|
123
129
|
record.oomKilled = oomKilled;
|
|
124
130
|
}
|
|
125
131
|
|
|
132
|
+
// Counted by the kernel for this run only, unlike the sticky `OOMKilled`.
|
|
133
|
+
const cgroupMemory = parseCgroupMemorySample(options.cgroupMemory);
|
|
134
|
+
if (cgroupMemory) {
|
|
135
|
+
record.cgroupMemory = cgroupMemory;
|
|
136
|
+
}
|
|
137
|
+
|
|
126
138
|
const exitReason =
|
|
127
139
|
resolveReason(record) || (finishedAt ? null : WATCHER_LOST_CONTAINER);
|
|
128
140
|
if (exitReason) {
|
|
@@ -162,6 +174,7 @@ function resolveReason(record) {
|
|
|
162
174
|
exitCode: record.exitCode,
|
|
163
175
|
logTail,
|
|
164
176
|
oomKilled: record.oomKilled,
|
|
177
|
+
cgroupMemory: record.cgroupMemory,
|
|
165
178
|
});
|
|
166
179
|
} catch {
|
|
167
180
|
return null;
|
|
@@ -244,13 +257,13 @@ function buildDetachedFinalizeSnippet(executionId) {
|
|
|
244
257
|
`${shellQuote(process.execPath)} ${shellQuote(__filename)} ` +
|
|
245
258
|
`${shellQuote(executionId)} "$${v.exit}" "$${v.oom}" ` +
|
|
246
259
|
`"$${v.started}" "$${v.finished}" "$${v.error}" "$${v.running}" ` +
|
|
247
|
-
|
|
260
|
+
`"$${CGROUP_SHELL_VARS.sample}" >/dev/null 2>&1 || true`
|
|
248
261
|
);
|
|
249
262
|
}
|
|
250
263
|
|
|
251
264
|
/**
|
|
252
265
|
* Entry point used by the detached watcher:
|
|
253
|
-
* <runtime> detached-finalize.js <uuid> <exit> <oom> <started> <finished> <error> <running>
|
|
266
|
+
* <runtime> detached-finalize.js <uuid> <exit> <oom> <started> <finished> <error> <running> <cgroup>
|
|
254
267
|
* Always exits 0 — a bookkeeping failure must never turn into a visible error
|
|
255
268
|
* in a log the user is reading for the command's own output.
|
|
256
269
|
* @param {string[]} argv - Positional arguments
|
|
@@ -265,6 +278,7 @@ function main(argv) {
|
|
|
265
278
|
finishedAt,
|
|
266
279
|
containerError,
|
|
267
280
|
running,
|
|
281
|
+
cgroupMemory,
|
|
268
282
|
] = argv;
|
|
269
283
|
if (!executionId || process.env.START_DISABLE_TRACKING === 'true') {
|
|
270
284
|
return;
|
|
@@ -285,6 +299,7 @@ function main(argv) {
|
|
|
285
299
|
finishedAt,
|
|
286
300
|
containerError,
|
|
287
301
|
running,
|
|
302
|
+
cgroupMemory,
|
|
288
303
|
});
|
|
289
304
|
} catch {
|
|
290
305
|
// Deliberately silent: see the exit-0 contract above.
|
|
@@ -6,7 +6,10 @@ const {
|
|
|
6
6
|
readLogTail,
|
|
7
7
|
shellQuote,
|
|
8
8
|
} = require('./isolation-log-utils');
|
|
9
|
-
const {
|
|
9
|
+
const {
|
|
10
|
+
isOomKillOfCommand,
|
|
11
|
+
resolveMemoryExhaustion,
|
|
12
|
+
} = require('./exit-reason');
|
|
10
13
|
const {
|
|
11
14
|
buildDockerPostMortemSnippet,
|
|
12
15
|
buildDockerRemovalNoteSnippet,
|
|
@@ -20,6 +23,11 @@ const {
|
|
|
20
23
|
SHELL_VARS,
|
|
21
24
|
} = require('./docker-post-mortem');
|
|
22
25
|
const { buildDetachedFinalizeSnippet } = require('./detached-finalize');
|
|
26
|
+
const {
|
|
27
|
+
buildCgroupMemoryLogSnippet,
|
|
28
|
+
buildCgroupSamplerStartSnippet,
|
|
29
|
+
buildCgroupSamplerStopSnippet,
|
|
30
|
+
} = require('./cgroup-memory');
|
|
23
31
|
|
|
24
32
|
const DOCKER_CONTAINER_CLEANUP_POLICY = {
|
|
25
33
|
DEFAULT: 'default',
|
|
@@ -266,10 +274,16 @@ function buildAttachedDockerKeptMessage({
|
|
|
266
274
|
oomKilled,
|
|
267
275
|
logPath,
|
|
268
276
|
}) {
|
|
269
|
-
let message
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
277
|
+
let message;
|
|
278
|
+
if (oomKilled !== true) {
|
|
279
|
+
message = `\nContainer kept because the command failed.`;
|
|
280
|
+
} else if (isOomKillOfCommand({ exitCode, oomKilled })) {
|
|
281
|
+
message = `\nContainer kept because Docker reports it was OOM-killed.`;
|
|
282
|
+
} else {
|
|
283
|
+
// The flag is container-wide (#180): a child was OOM-killed, the command
|
|
284
|
+
// itself exited on its own.
|
|
285
|
+
message = `\nContainer kept because Docker reports a process in it was OOM-killed.`;
|
|
286
|
+
}
|
|
273
287
|
const memory = resolveMemoryExhaustion({
|
|
274
288
|
exitCode,
|
|
275
289
|
logTail: logPath ? readLogTail(logPath, FATAL_MARKER_TAIL_BYTES) : null,
|
|
@@ -343,7 +357,9 @@ function buildDetachedDockerCompletionScript(
|
|
|
343
357
|
watcherOptions = {}
|
|
344
358
|
) {
|
|
345
359
|
const quotedName = shellQuote(containerName);
|
|
346
|
-
|
|
360
|
+
// The container's cgroup disappears with it, so its memory counters are
|
|
361
|
+
// sampled while it runs (issue #182).
|
|
362
|
+
const parts = [buildCgroupSamplerStartSnippet(containerName)];
|
|
347
363
|
// Everything that assumes the container has exited: cleanup, footer and
|
|
348
364
|
// finalization. Guarded as a whole by `.State.Running` below.
|
|
349
365
|
const exited = [];
|
|
@@ -358,14 +374,16 @@ function buildDetachedDockerCompletionScript(
|
|
|
358
374
|
containerName,
|
|
359
375
|
quotedLogPath
|
|
360
376
|
);
|
|
361
|
-
const
|
|
362
|
-
const
|
|
377
|
+
const memory = buildCgroupMemoryLogSnippet(quotedLogPath);
|
|
378
|
+
const remove = `docker rm -f ${quotedName} >> ${quotedLogPath} 2>&1 || true; ${removalNote}; ${memory}`;
|
|
379
|
+
const keep = `${postMortem}; ${memory}; ${buildDockerKeptLogSnippet(containerName, quotedLogPath)}`;
|
|
363
380
|
|
|
364
381
|
const since = watcherOptions.since
|
|
365
382
|
? ` --since ${shellQuote(watcherOptions.since)}`
|
|
366
383
|
: '';
|
|
367
384
|
parts.push(`docker logs -f${since} ${quotedName} >> ${quotedLogPath} 2>&1`);
|
|
368
385
|
parts.push(buildDockerWaitForExitSnippet(containerName, quotedLogPath));
|
|
386
|
+
parts.push(buildCgroupSamplerStopSnippet());
|
|
369
387
|
parts.push(buildDockerStateSnippet(containerName));
|
|
370
388
|
if (policy === DOCKER_CONTAINER_CLEANUP_POLICY.ALWAYS) {
|
|
371
389
|
exited.push(remove);
|
|
@@ -386,6 +404,7 @@ function buildDetachedDockerCompletionScript(
|
|
|
386
404
|
} else {
|
|
387
405
|
parts.push(`docker wait ${quotedName} >/dev/null 2>&1`);
|
|
388
406
|
parts.push(buildDockerWaitForExitSnippet(containerName));
|
|
407
|
+
parts.push(buildCgroupSamplerStopSnippet());
|
|
389
408
|
parts.push(buildDockerStateSnippet(containerName));
|
|
390
409
|
if (policy === DOCKER_CONTAINER_CLEANUP_POLICY.ALWAYS) {
|
|
391
410
|
exited.push(`docker rm -f ${quotedName} >/dev/null 2>&1 || true`);
|
|
@@ -4,8 +4,12 @@
|
|
|
4
4
|
* killed (exit 137 / OOMKilled)
|
|
5
5
|
* --recovery-command <cmd> command to run in the same container on resume
|
|
6
6
|
* (without it, the original command is re-run)
|
|
7
|
+
* --on-kill-resume-delay <s> wait a random <min>[-<max>] seconds before
|
|
8
|
+
* each resume (issue #181)
|
|
7
9
|
*/
|
|
8
10
|
|
|
11
|
+
const { parseOnKillResumeDelayValue } = require('./recovery-delay');
|
|
12
|
+
|
|
9
13
|
function parseOnKillResumeValue(value) {
|
|
10
14
|
const text = String(value).trim();
|
|
11
15
|
const count = Number(text);
|
|
@@ -19,18 +23,34 @@ function parseOnKillResumeValue(value) {
|
|
|
19
23
|
|
|
20
24
|
function parseDockerRecoveryOption(args, index, options) {
|
|
21
25
|
const arg = args[index];
|
|
22
|
-
if (
|
|
26
|
+
if (
|
|
27
|
+
arg === '--on-kill-resume' ||
|
|
28
|
+
arg === '--recovery-command' ||
|
|
29
|
+
arg === '--on-kill-resume-delay'
|
|
30
|
+
) {
|
|
23
31
|
if (index + 1 >= args.length || args[index + 1].startsWith('-')) {
|
|
24
|
-
const value =
|
|
32
|
+
const value = {
|
|
33
|
+
'--on-kill-resume': 'count',
|
|
34
|
+
'--recovery-command': 'command',
|
|
35
|
+
'--on-kill-resume-delay': 'seconds',
|
|
36
|
+
}[arg];
|
|
25
37
|
throw new Error(`Option ${arg} requires a ${value} argument`);
|
|
26
38
|
}
|
|
27
39
|
if (arg === '--on-kill-resume') {
|
|
28
40
|
options.onKillResume = parseOnKillResumeValue(args[index + 1]);
|
|
41
|
+
} else if (arg === '--on-kill-resume-delay') {
|
|
42
|
+
options.onKillResumeDelay = parseOnKillResumeDelayValue(args[index + 1]);
|
|
29
43
|
} else {
|
|
30
44
|
options.recoveryCommand = args[index + 1];
|
|
31
45
|
}
|
|
32
46
|
return 2;
|
|
33
47
|
}
|
|
48
|
+
if (arg.startsWith('--on-kill-resume-delay=')) {
|
|
49
|
+
options.onKillResumeDelay = parseOnKillResumeDelayValue(
|
|
50
|
+
arg.slice('--on-kill-resume-delay='.length)
|
|
51
|
+
);
|
|
52
|
+
return 1;
|
|
53
|
+
}
|
|
34
54
|
if (arg.startsWith('--on-kill-resume=')) {
|
|
35
55
|
options.onKillResume = parseOnKillResumeValue(
|
|
36
56
|
arg.slice('--on-kill-resume='.length)
|
|
@@ -47,7 +67,7 @@ function parseDockerRecoveryOption(args, index, options) {
|
|
|
47
67
|
/**
|
|
48
68
|
* Recovery is driven by the detached docker completion watcher, so it needs a
|
|
49
69
|
* detached session whose only isolation level is docker. A recovery command on
|
|
50
|
-
* its own implies one attempt.
|
|
70
|
+
* its own implies one attempt; a delay without either has nothing to delay.
|
|
51
71
|
* @param {object} options - Parsed options
|
|
52
72
|
* @throws {Error} If the recovery options are used where they cannot work
|
|
53
73
|
*/
|
|
@@ -62,6 +82,14 @@ function validateDockerRecoveryOptions(options) {
|
|
|
62
82
|
options.onKillResume ??= 1;
|
|
63
83
|
}
|
|
64
84
|
if (!options.onKillResume) {
|
|
85
|
+
if (
|
|
86
|
+
options.onKillResumeDelay !== null &&
|
|
87
|
+
options.onKillResumeDelay !== undefined
|
|
88
|
+
) {
|
|
89
|
+
throw new Error(
|
|
90
|
+
'--on-kill-resume-delay requires --on-kill-resume or --recovery-command'
|
|
91
|
+
);
|
|
92
|
+
}
|
|
65
93
|
return;
|
|
66
94
|
}
|
|
67
95
|
const flag = options.recoveryCommand
|
|
@@ -9,11 +9,12 @@ const {
|
|
|
9
9
|
buildResourceLimitsStatusLine,
|
|
10
10
|
normalizeResourceLimits,
|
|
11
11
|
} = require('./docker-resource-limits');
|
|
12
|
+
const { getOnKillResumeDelay } = require('./recovery-delay');
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* Number of kill recoveries requested at launch; a recovery command on its own
|
|
15
16
|
* implies one (issue #176).
|
|
16
|
-
* @param {object} options - Options (onKillResume, recoveryCommand)
|
|
17
|
+
* @param {object} options - Options (onKillResume, recoveryCommand, onKillResumeDelay)
|
|
17
18
|
* @returns {?number} Attempt limit, or null when recovery is off
|
|
18
19
|
*/
|
|
19
20
|
function getOnKillResume(options = {}) {
|
|
@@ -63,7 +64,7 @@ function buildDockerRuntimeArgs(options = {}) {
|
|
|
63
64
|
* options (volumes, mounts, env, privileged, networks, resource limits and
|
|
64
65
|
* the launch-time recovery command). Empty collections and a falsy privileged
|
|
65
66
|
* flag contribute no lines.
|
|
66
|
-
* @param {object} options - Options (volumes, mounts, env, privileged, network, networks, networkAliases, resourceLimits, onKillResume, recoveryCommand)
|
|
67
|
+
* @param {object} options - Options (volumes, mounts, env, privileged, network, networks, networkAliases, resourceLimits, onKillResume, recoveryCommand, onKillResumeDelay)
|
|
67
68
|
* @returns {string[]} Status lines for the start block / log header
|
|
68
69
|
*/
|
|
69
70
|
function buildDockerRuntimeStatusLines(options = {}) {
|
|
@@ -99,8 +100,10 @@ function buildDockerRuntimeStatusLines(options = {}) {
|
|
|
99
100
|
const onKillResume = getOnKillResume(options);
|
|
100
101
|
if (onKillResume) {
|
|
101
102
|
const what = options.recoveryCommand || 'the original command';
|
|
103
|
+
const delay = getOnKillResumeDelay(options);
|
|
104
|
+
const after = delay ? ` after a random ${delay}s delay` : '';
|
|
102
105
|
lines.push(
|
|
103
|
-
`[Isolation] On kill: resume up to ${onKillResume} time(s) with ${what}`
|
|
106
|
+
`[Isolation] On kill: resume up to ${onKillResume} time(s)${after} with ${what}`
|
|
104
107
|
);
|
|
105
108
|
}
|
|
106
109
|
return lines;
|
|
@@ -109,7 +112,7 @@ function buildDockerRuntimeStatusLines(options = {}) {
|
|
|
109
112
|
/**
|
|
110
113
|
* Build the execution-record metadata for docker runtime options, normalizing
|
|
111
114
|
* empty collections and a falsy privileged flag to `null`.
|
|
112
|
-
* @param {object} options - Options (volumes, mounts, env, privileged, network, networks, networkAliases, resourceLimits, onKillResume, recoveryCommand)
|
|
115
|
+
* @param {object} options - Options (volumes, mounts, env, privileged, network, networks, networkAliases, resourceLimits, onKillResume, recoveryCommand, onKillResumeDelay)
|
|
113
116
|
* @returns {object} Record metadata
|
|
114
117
|
*/
|
|
115
118
|
function buildDockerRuntimeMetadata(options = {}) {
|
|
@@ -130,6 +133,9 @@ function buildDockerRuntimeMetadata(options = {}) {
|
|
|
130
133
|
resourceLimits: resourceLimits.length > 0 ? resourceLimits : null,
|
|
131
134
|
onKillResume: getOnKillResume(options),
|
|
132
135
|
recoveryCommand: options.recoveryCommand || null,
|
|
136
|
+
onKillResumeDelay: getOnKillResume(options)
|
|
137
|
+
? getOnKillResumeDelay(options)
|
|
138
|
+
: null,
|
|
133
139
|
};
|
|
134
140
|
}
|
|
135
141
|
|
|
@@ -302,6 +302,23 @@ function restoreStopRequested(store, record, previous) {
|
|
|
302
302
|
}
|
|
303
303
|
}
|
|
304
304
|
|
|
305
|
+
/**
|
|
306
|
+
* Whether a failed docker control command hit a container that has already
|
|
307
|
+
* exited while launch-time recovery is configured: the stop marker alone then
|
|
308
|
+
* cancels the resume the watcher may still be waiting to make.
|
|
309
|
+
* @param {object} record - Execution record
|
|
310
|
+
* @param {object} result - Failed control command result
|
|
311
|
+
* @returns {boolean} Whether the stop marker should stand
|
|
312
|
+
*/
|
|
313
|
+
function isPendingRecoveryStop(record, result) {
|
|
314
|
+
const options = record.options || {};
|
|
315
|
+
if (options.isolated !== 'docker' || !options.onKillResume) {
|
|
316
|
+
return false;
|
|
317
|
+
}
|
|
318
|
+
const detail = `${result.stderr || ''} ${result.error || ''}`;
|
|
319
|
+
return /is not running/i.test(detail);
|
|
320
|
+
}
|
|
321
|
+
|
|
305
322
|
function controlExecution(store, identifier, action, runner = runCommand) {
|
|
306
323
|
if (!store) {
|
|
307
324
|
return { success: false, error: 'Execution tracking is disabled.' };
|
|
@@ -325,6 +342,25 @@ function controlExecution(store, identifier, action, runner = runCommand) {
|
|
|
325
342
|
// which exits 137 just like the OOM killer.
|
|
326
343
|
const previousStopRequestedAt = markStopRequested(store, record);
|
|
327
344
|
const result = runner(control.command, control.args);
|
|
345
|
+
if (!result.success && isPendingRecoveryStop(record, result)) {
|
|
346
|
+
// The container already exited and its watcher is waiting out
|
|
347
|
+
// `--on-kill-resume-delay` (issue #181): `docker kill` has nothing to
|
|
348
|
+
// signal, but the marker it left cancels the pending resume.
|
|
349
|
+
return {
|
|
350
|
+
success: true,
|
|
351
|
+
output: formatControlResultAsLinksNotation({
|
|
352
|
+
action,
|
|
353
|
+
identifier,
|
|
354
|
+
uuid: record.uuid,
|
|
355
|
+
status: 'recovery-cancelled',
|
|
356
|
+
backend: record.options.isolated,
|
|
357
|
+
sessionName: record.options.sessionName,
|
|
358
|
+
method: control.method,
|
|
359
|
+
processIds: null,
|
|
360
|
+
message: `Cancelled the pending recovery of detached docker container: ${record.options.sessionName}`,
|
|
361
|
+
}),
|
|
362
|
+
};
|
|
363
|
+
}
|
|
328
364
|
if (!result.success) {
|
|
329
365
|
restoreStopRequested(store, record, previousStopRequestedAt);
|
|
330
366
|
const detail =
|