Skip to content

Commit 18bf002

Browse files
authored
⏱️ fix: Recycle Sandboxes Before Clock Drift (#39)
* fix: recycle sandboxes before clock drift * test: strengthen clock healthcheck coverage * fix: address clock healthcheck review findings * fix: address clock-skew review findings * fix: opt in to clock recycling from helm * fix: bound clock healthcheck arithmetic * fix: preserve sandbox workspace readiness
1 parent 4b72e9d commit 18bf002

6 files changed

Lines changed: 476 additions & 12 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ jobs:
3131
- name: Block-root package delivery
3232
run: tests/block_root_package_delivery.sh
3333

34+
- name: Sandbox-runner liveness checks
35+
run: tests/sandbox_runner_healthcheck.sh
36+
3437
- name: Validate sandbox Dockerfiles
3538
run: |
3639
docker buildx build --check -f api/Dockerfile .

‎docker/sandbox-runner-healthcheck.sh‎

Lines changed: 128 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,84 @@
22
set -euo pipefail
33

44
fd_limit="${SANDBOX_RUNNER_FD_LIVENESS_LIMIT:-40000}"
5+
clock_skew_limit_seconds="${SANDBOX_RUNNER_CLOCK_SKEW_LIVENESS_LIMIT_SECONDS:-0}"
6+
clock_skew_jitter_seconds="${SANDBOX_RUNNER_CLOCK_SKEW_LIVENESS_JITTER_SECONDS:-2}"
57
timeout_seconds="${SANDBOX_RUNNER_HEALTHCHECK_TIMEOUT_SECONDS:-5}"
8+
manifest_clock_tolerance_seconds=30
69
port="${PORT:-2000}"
7-
url="${SANDBOX_RUNNER_HEALTHCHECK_URL:-http://127.0.0.1:${port}/api/v2/runtimes}"
10+
url="${SANDBOX_RUNNER_HEALTHCHECK_URL:-http://127.0.0.1:${port}/api/v2/health}"
811

9-
case "$fd_limit" in
10-
''|*[!0-9]*)
11-
echo "invalid SANDBOX_RUNNER_FD_LIVENESS_LIMIT: $fd_limit" >&2
12+
validate_non_negative_integer() {
13+
local name="$1"
14+
local value="$2"
15+
16+
case "$value" in
17+
0|[1-9]|[1-9][0-9]*) ;;
18+
*)
19+
echo "invalid ${name}: ${value} (expected a canonical non-negative integer)" >&2
20+
exit 2
21+
;;
22+
esac
23+
}
24+
25+
validate_non_negative_integer SANDBOX_RUNNER_FD_LIVENESS_LIMIT "$fd_limit"
26+
validate_non_negative_integer \
27+
SANDBOX_RUNNER_CLOCK_SKEW_LIVENESS_LIMIT_SECONDS \
28+
"$clock_skew_limit_seconds"
29+
validate_non_negative_integer \
30+
SANDBOX_RUNNER_CLOCK_SKEW_LIVENESS_JITTER_SECONDS \
31+
"$clock_skew_jitter_seconds"
32+
validate_non_negative_integer \
33+
SANDBOX_RUNNER_HEALTHCHECK_TIMEOUT_SECONDS \
34+
"$timeout_seconds"
35+
36+
if [ "$timeout_seconds" -eq 0 ]; then
37+
echo "SANDBOX_RUNNER_HEALTHCHECK_TIMEOUT_SECONDS must be greater than zero" >&2
38+
exit 2
39+
fi
40+
41+
if [ "$clock_skew_limit_seconds" -gt 0 ]; then
42+
if [ "${#clock_skew_limit_seconds}" -gt 2 ] || \
43+
[ "$clock_skew_limit_seconds" -ge "$manifest_clock_tolerance_seconds" ]; then
44+
echo "SANDBOX_RUNNER_CLOCK_SKEW_LIVENESS_LIMIT_SECONDS plus SANDBOX_RUNNER_HEALTHCHECK_TIMEOUT_SECONDS must be less than the ${manifest_clock_tolerance_seconds}-second execution-manifest tolerance" >&2
1245
exit 2
13-
;;
14-
esac
46+
fi
47+
48+
remaining_tolerance_seconds=$((manifest_clock_tolerance_seconds - clock_skew_limit_seconds))
49+
if [ "${#timeout_seconds}" -gt 2 ] || \
50+
[ "$timeout_seconds" -ge "$remaining_tolerance_seconds" ]; then
51+
echo "SANDBOX_RUNNER_CLOCK_SKEW_LIVENESS_LIMIT_SECONDS plus SANDBOX_RUNNER_HEALTHCHECK_TIMEOUT_SECONDS must be less than the ${manifest_clock_tolerance_seconds}-second execution-manifest tolerance" >&2
52+
exit 2
53+
fi
54+
fi
55+
56+
if [ "$clock_skew_limit_seconds" -gt 0 ] && ! command -v date >/dev/null 2>&1; then
57+
echo "sandbox-runner clock-skew check requires date" >&2
58+
exit 2
59+
fi
60+
61+
effective_clock_skew_limit_seconds="$clock_skew_limit_seconds"
62+
if [ "$clock_skew_limit_seconds" -gt 0 ] && [ "$clock_skew_jitter_seconds" -gt 0 ]; then
63+
if [ "${#clock_skew_jitter_seconds}" -gt 2 ] || \
64+
[ "$clock_skew_jitter_seconds" -ge "$clock_skew_limit_seconds" ]; then
65+
echo "SANDBOX_RUNNER_CLOCK_SKEW_LIVENESS_JITTER_SECONDS must be less than SANDBOX_RUNNER_CLOCK_SKEW_LIVENESS_LIMIT_SECONDS" >&2
66+
exit 2
67+
fi
68+
if ! command -v cksum >/dev/null 2>&1; then
69+
echo "sandbox-runner clock-skew jitter requires cksum" >&2
70+
exit 2
71+
fi
72+
73+
# Pods from one rollout have nearly identical ages and drift rates. Give
74+
# each pod a stable threshold within the configured range to reduce the
75+
# chance that same-age replicas restart together.
76+
jitter_key="${HOSTNAME:-sandbox-runner}"
77+
jitter_checksum=$(printf '%s' "$jitter_key" | cksum)
78+
jitter_checksum="${jitter_checksum%% *}"
79+
effective_clock_skew_limit_seconds=$((
80+
clock_skew_limit_seconds - (jitter_checksum % (clock_skew_jitter_seconds + 1))
81+
))
82+
fi
1583

1684
if [ "$fd_limit" -gt 0 ]; then
1785
fd_count=$(find /proc/1/fd -mindepth 1 -maxdepth 1 2>/dev/null | wc -l | tr -d '[:space:]')
@@ -21,4 +89,57 @@ if [ "$fd_limit" -gt 0 ]; then
2189
fi
2290
fi
2391

24-
curl -fsS --max-time "$timeout_seconds" "$url" >/dev/null
92+
if [ "$clock_skew_limit_seconds" -eq 0 ]; then
93+
curl -fsS --max-time "$timeout_seconds" "$url" >/dev/null
94+
exit 0
95+
fi
96+
97+
host_before_seconds=$(date -u +%s)
98+
response_headers=$(curl -fsS --max-time "$timeout_seconds" --dump-header - --output /dev/null "$url")
99+
host_after_seconds=$(date -u +%s)
100+
101+
guest_date=$(printf '%s\n' "$response_headers" | awk '
102+
tolower($1) == "date:" {
103+
sub(/\r$/, "")
104+
sub(/^[^:]*:[[:space:]]*/, "")
105+
value = $0
106+
}
107+
END { print value }
108+
')
109+
110+
if [ -z "$guest_date" ]; then
111+
echo "sandbox-runner unhealthy: guest response is missing the HTTP Date header" >&2
112+
exit 1
113+
fi
114+
115+
http_date_pattern='^(Mon|Tue|Wed|Thu|Fri|Sat|Sun), [0-9]{2} (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) [0-9]{4} [0-9]{2}:[0-9]{2}:[0-9]{2} GMT$'
116+
if ! [[ "$guest_date" =~ $http_date_pattern ]]; then
117+
echo "sandbox-runner unhealthy: guest returned an invalid HTTP Date header: ${guest_date}" >&2
118+
exit 1
119+
fi
120+
121+
if ! guest_seconds=$(date -u -d "$guest_date" +%s 2>/dev/null); then
122+
echo "sandbox-runner unhealthy: guest returned an invalid HTTP Date header: ${guest_date}" >&2
123+
exit 1
124+
fi
125+
126+
# The response can take time to arrive, so compare guest time with the host
127+
# interval that enclosed the request instead of with a single sample. This
128+
# prevents probe latency from looking like backward clock drift.
129+
if [ "$guest_seconds" -lt "$host_before_seconds" ]; then
130+
skew_seconds=$((guest_seconds - host_before_seconds))
131+
elif [ "$guest_seconds" -gt "$host_after_seconds" ]; then
132+
skew_seconds=$((guest_seconds - host_after_seconds))
133+
else
134+
skew_seconds=0
135+
fi
136+
137+
absolute_skew_seconds="$skew_seconds"
138+
if [ "$absolute_skew_seconds" -lt 0 ]; then
139+
absolute_skew_seconds=$((-absolute_skew_seconds))
140+
fi
141+
142+
if [ "$absolute_skew_seconds" -ge "$effective_clock_skew_limit_seconds" ]; then
143+
echo "sandbox-runner unhealthy: guest clock skew is ${skew_seconds}s, pod limit is ${effective_clock_skew_limit_seconds}s (configured maximum ${clock_skew_limit_seconds}s)" >&2
144+
exit 1
145+
fi

‎helm/codeapi/templates/worker-sandbox-deployment.yaml‎

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,22 @@ Split runtime deployments:
2727
{{- if and (not $packagesFromPvc) (not .Values.workerSandbox.kvmEnabled) }}
2828
{{- fail "workerSandbox.packages.source=image requires workerSandbox.kvmEnabled=true because the baked runner boots from a libkrun block root image" }}
2929
{{- end }}
30+
{{- $clockSkewLimitValue := toString .Values.workerSandbox.sandboxRunner.clockSkewLivenessLimitSeconds }}
31+
{{- $clockSkewJitterValue := toString .Values.workerSandbox.sandboxRunner.clockSkewLivenessJitterSeconds }}
32+
{{- if not (regexMatch "^(0|[1-9][0-9]*)$" $clockSkewLimitValue) }}
33+
{{- fail "workerSandbox.sandboxRunner.clockSkewLivenessLimitSeconds must be a non-negative integer" }}
34+
{{- end }}
35+
{{- if not (regexMatch "^(0|[1-9][0-9]*)$" $clockSkewJitterValue) }}
36+
{{- fail "workerSandbox.sandboxRunner.clockSkewLivenessJitterSeconds must be a non-negative integer" }}
37+
{{- end }}
38+
{{- $clockSkewLimit := int $clockSkewLimitValue }}
39+
{{- $clockSkewJitter := int $clockSkewJitterValue }}
40+
{{- if or (lt $clockSkewLimit 0) (ge $clockSkewLimit 25) }}
41+
{{- fail "workerSandbox.sandboxRunner.clockSkewLivenessLimitSeconds must be between 0 and 24 so the 5-second healthcheck timeout stays below the 30-second execution-manifest tolerance" }}
42+
{{- end }}
43+
{{- if or (lt $clockSkewJitter 0) (and (gt $clockSkewLimit 0) (ge $clockSkewJitter $clockSkewLimit)) }}
44+
{{- fail "workerSandbox.sandboxRunner.clockSkewLivenessJitterSeconds must be non-negative and less than clockSkewLivenessLimitSeconds when the clock-skew guard is enabled" }}
45+
{{- end }}
3046
{{- range .Values.workerSandbox.sandboxExtraEnv }}
3147
{{- $name := .name | default "" }}
3248
{{- if and $name (or (regexMatch "(?i)(SECRET|TOKEN|PASSWORD|KEY)" $name) (hasPrefix "REDIS_" $name) (hasPrefix "AWS_" $name) (hasPrefix "S3_" $name) (hasPrefix "MINIO_" $name) (hasPrefix "CODEAPI_" $name) (eq $name "FILE_SERVER_URL") (eq $name "TOOL_CALL_SERVER_URL")) }}
@@ -258,6 +274,10 @@ spec:
258274
value: {{ .Values.workerSandbox.launcher.filterVsockEnotconn | quote }}
259275
- name: SANDBOX_RUNNER_FD_LIVENESS_LIMIT
260276
value: {{ .Values.workerSandbox.sandboxRunner.fdLivenessLimit | quote }}
277+
- name: SANDBOX_RUNNER_CLOCK_SKEW_LIVENESS_LIMIT_SECONDS
278+
value: {{ $clockSkewLimit | quote }}
279+
- name: SANDBOX_RUNNER_CLOCK_SKEW_LIVENESS_JITTER_SECONDS
280+
value: {{ $clockSkewJitter | quote }}
261281
- name: EGRESS_GATEWAY_URL
262282
value: "http://{{ include "codeapi.fullname" . }}-egress-gateway:{{ .Values.egressGateway.service.port }}"
263283
- name: SANDBOX_LOG_LEVEL
@@ -362,14 +382,15 @@ spec:
362382
- /usr/local/bin/sandbox-runner-healthcheck.sh
363383
initialDelaySeconds: 60
364384
periodSeconds: 30
365-
timeoutSeconds: 5
385+
timeoutSeconds: 7
366386
readinessProbe:
367-
httpGet:
368-
path: /api/v2/health
369-
port: sandbox
387+
exec:
388+
command:
389+
- /usr/local/bin/sandbox-runner-healthcheck.sh
370390
initialDelaySeconds: 30
371391
periodSeconds: 10
372-
timeoutSeconds: 5
392+
timeoutSeconds: 7
393+
failureThreshold: 1
373394
resources:
374395
{{- $sandboxResources := deepCopy .Values.workerSandbox.resources }}
375396
{{- if $useKvmDevicePlugin }}

‎helm/codeapi/values.yaml‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,15 @@ workerSandbox:
200200
# Restart a sandbox-runner before launcher/VMM host-side fd retention can
201201
# exhaust the process file descriptor limit. Set 0 to disable.
202202
fdLivenessLimit: 40000
203+
# Restart a sandbox-runner when its microVM guest wall clock differs from
204+
# the host by this many seconds. This must remain comfortably below the
205+
# 30-second execution-manifest clock tolerance; the chart reserves the
206+
# five-second probe timeout and accepts at most 24. Set 0 to disable.
207+
clockSkewLivenessLimitSeconds: 10
208+
# Subtract a stable 0..N-second offset from each pod's clock-skew limit so
209+
# replicas created together do not all recycle at once. Must be less than
210+
# clockSkewLivenessLimitSeconds.
211+
clockSkewLivenessJitterSeconds: 2
203212
inheritSharedScheduling: true
204213
nodeSelector: {}
205214
tolerations: []

‎tests/block_root_package_delivery.sh‎

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,23 @@ assert_not_contains() {
3636
fi
3737
}
3838

39+
assert_env_value() {
40+
local file="$1"
41+
local name="$2"
42+
local expected="$3"
43+
local message="$4"
44+
if ! awk -v name="$name" -v expected="$expected" '
45+
$0 ~ "- name: " name {
46+
getline
47+
if ($0 ~ "value: \\\"" expected "\\\"") found = 1
48+
}
49+
END { exit found ? 0 : 1 }
50+
' "$file"; then
51+
echo "$message" >&2
52+
exit 1
53+
fi
54+
}
55+
3956
assert_compose_mode() {
4057
local compose_file="$1"
4158
local service="$2"
@@ -196,6 +213,25 @@ assert_not_contains \
196213
"$TMP_DIR/helm-image.yaml" \
197214
'app.kubernetes.io/component: package-init' \
198215
"default Helm render must not create package-init"
216+
assert_env_value \
217+
"$TMP_DIR/helm-image.yaml" \
218+
SANDBOX_RUNNER_CLOCK_SKEW_LIVENESS_LIMIT_SECONDS \
219+
10 \
220+
"default Helm render must keep clock skew below the manifest tolerance"
221+
assert_env_value \
222+
"$TMP_DIR/helm-image.yaml" \
223+
SANDBOX_RUNNER_CLOCK_SKEW_LIVENESS_JITTER_SECONDS \
224+
2 \
225+
"default Helm render must stagger clock-skew recycling across replicas"
226+
assert_contains \
227+
"$TMP_DIR/helm-image.yaml" \
228+
'^[[:space:]]+failureThreshold: 1$' \
229+
"sandbox-runner readiness must fail immediately when clock skew is detected"
230+
231+
if [ "$(grep -Fc -- '- /usr/local/bin/sandbox-runner-healthcheck.sh' "$TMP_DIR/helm-image.yaml")" -lt 2 ]; then
232+
echo "sandbox-runner liveness and readiness must both check guest clock skew" >&2
233+
exit 1
234+
fi
199235

200236
helm template codeapi "$TMP_DIR/chart" \
201237
--set executionManifest.privateKey=test \
@@ -243,6 +279,74 @@ assert_contains \
243279
'value: "0"' \
244280
"fdLivenessLimit=0 must reach the healthcheck instead of reverting to 40000"
245281

282+
helm template codeapi "$TMP_DIR/chart" \
283+
--set executionManifest.privateKey=test \
284+
--set executionManifest.publicKey=test \
285+
--set workerSandbox.sandboxRunner.clockSkewLivenessLimitSeconds=0 \
286+
> "$TMP_DIR/helm-no-clock-skew-liveness.yaml"
287+
288+
assert_env_value \
289+
"$TMP_DIR/helm-no-clock-skew-liveness.yaml" \
290+
SANDBOX_RUNNER_CLOCK_SKEW_LIVENESS_LIMIT_SECONDS \
291+
0 \
292+
"clockSkewLivenessLimitSeconds=0 must reach the healthcheck instead of reverting to 10"
293+
294+
if helm template codeapi "$TMP_DIR/chart" \
295+
--set executionManifest.privateKey=test \
296+
--set executionManifest.publicKey=test \
297+
--set workerSandbox.sandboxRunner.clockSkewLivenessLimitSeconds=25 \
298+
> "$TMP_DIR/invalid-clock-skew-limit.yaml" 2> "$TMP_DIR/invalid-clock-skew-limit.log"; then
299+
echo "clock-skew liveness limit must stay below manifest tolerance" >&2
300+
exit 1
301+
fi
302+
303+
assert_contains \
304+
"$TMP_DIR/invalid-clock-skew-limit.log" \
305+
'clockSkewLivenessLimitSeconds must be between 0 and 24' \
306+
"clock-skew limit validation failed for an unexpected reason"
307+
308+
if helm template codeapi "$TMP_DIR/chart" \
309+
--set executionManifest.privateKey=test \
310+
--set executionManifest.publicKey=test \
311+
--set workerSandbox.sandboxRunner.clockSkewLivenessLimitSeconds=29.5 \
312+
> "$TMP_DIR/fractional-clock-skew-limit.yaml" 2> "$TMP_DIR/fractional-clock-skew-limit.log"; then
313+
echo "clock-skew liveness limit must reject fractional values" >&2
314+
exit 1
315+
fi
316+
317+
assert_contains \
318+
"$TMP_DIR/fractional-clock-skew-limit.log" \
319+
'clockSkewLivenessLimitSeconds must be a non-negative integer' \
320+
"fractional clock-skew limit validation failed for an unexpected reason"
321+
322+
if helm template codeapi "$TMP_DIR/chart" \
323+
--set executionManifest.privateKey=test \
324+
--set executionManifest.publicKey=test \
325+
--set workerSandbox.sandboxRunner.clockSkewLivenessJitterSeconds=10 \
326+
> "$TMP_DIR/invalid-clock-skew-jitter.yaml" 2> "$TMP_DIR/invalid-clock-skew-jitter.log"; then
327+
echo "clock-skew liveness jitter must stay below the configured limit" >&2
328+
exit 1
329+
fi
330+
331+
assert_contains \
332+
"$TMP_DIR/invalid-clock-skew-jitter.log" \
333+
'clockSkewLivenessJitterSeconds must be non-negative and less than clockSkewLivenessLimitSeconds' \
334+
"clock-skew jitter validation failed for an unexpected reason"
335+
336+
if helm template codeapi "$TMP_DIR/chart" \
337+
--set executionManifest.privateKey=test \
338+
--set executionManifest.publicKey=test \
339+
--set workerSandbox.sandboxRunner.clockSkewLivenessJitterSeconds=1.5 \
340+
> "$TMP_DIR/fractional-clock-skew-jitter.yaml" 2> "$TMP_DIR/fractional-clock-skew-jitter.log"; then
341+
echo "clock-skew liveness jitter must reject fractional values" >&2
342+
exit 1
343+
fi
344+
345+
assert_contains \
346+
"$TMP_DIR/fractional-clock-skew-jitter.log" \
347+
'clockSkewLivenessJitterSeconds must be a non-negative integer' \
348+
"fractional clock-skew jitter validation failed for an unexpected reason"
349+
246350
if helm template codeapi "$TMP_DIR/chart" \
247351
--set executionManifest.privateKey=test \
248352
--set executionManifest.publicKey=test \

0 commit comments

Comments
 (0)