feat(healthcheck): add --health-start-interval for run/create - #5229
akashchamp wants to merge 2 commits into
Conversation
Docker's `--health-start-interval` lets a container probe more frequently while it is still inside `--health-start-period`, then fall back to the regular `--health-interval` cadence once the start period has elapsed. nerdctl accepted `--health-start-period` but had no way to probe faster during it, and docs/command-reference.md still listed `--health-start-interval` as unimplemented. This wires the flag through run/create exactly like the existing `--health-start-period` plumbing (CLI flag, ContainerCreateOptions, validation, the Healthcheck struct that gets stored in the container label and read back on inspect), defaults it to Docker's 5s when unset, and threads it through `nerdctl compose` via `healthcheck.start_interval`. The one real behavioral change is probe scheduling. nerdctl schedules probes with a single systemd timer created once per container, whose tick cadence can't safely be changed while the very unit it triggers is still running (that unit is what invokes `nerdctl container healthcheck`). So the timer now ticks at the faster of `--health-interval` and `--health-start-interval`, and `ExecuteHealthCheck` skips ticks that arrive before the interval that actually applies (start-interval while in the start period, interval afterward) has elapsed since the last probe that really ran. This keeps the systemd side untouched for the container's lifetime while still producing the Docker-compatible effective cadence. Verified with a real container against rootful containerd + systemd: with --health-interval=20s --health-start-period=6s --health-start-interval=1s, probes ran roughly every ~1.3-1.8s during the start period, then the next probe landed ~21.7s after the last one (matching --health-interval, not --health-start-interval) once the start period ended - while `systemctl show` confirmed the timer's OnUnitInactiveUSec stayed fixed at 1s throughout, i.e. the cadence switch is purely the new software-side throttle, not a timer edit. Signed-off-by: Akash Kumar <116457960+akashchamp@users.noreply.github.com>
haytok
left a comment
There was a problem hiding this comment.
CI fails:
=== Failing tests ===
TestContainerHealthCheckAdvance
TestContainerHealthCheckAdvance/Health_check_probes_at_start-interval_cadence_within_the_start_period
TestContainerHealthCheckAdvance/Health_status_transitions_from_healthy_to_unhealthy_after_retries
=====================
- https://github.com/containerd/nerdctl/actions/runs/35927358984/job/107406987902?pr=5229
- https://github.com/containerd/nerdctl/actions/runs/35927358984/job/107406987878?pr=5229
Also, linting fails:
|
Fixed both: split |
…versized test func Three CI failures on this branch, all confined to tests this PR itself touches or added: - "Health status transitions from healthy to unhealthy after retries" (pre-existing test) now fails because probes are throttled to run no more than once per --health-interval (see shouldRunProbe). That test doesn't set --health-interval, so the 30s default applied while the test drives 4 manual "container healthcheck" calls only 2s apart - only the first one actually ran, so FailingStreak never reached the configured 3 retries. Pass --health-interval 1s explicitly so each manual call is spaced beyond the throttle window, matching the pre-existing intent of the test. - "Health check probes at start-interval cadence within the start period" (new in this PR) asserted exactly 2 log entries after 2 manual "container healthcheck" invocations. With --health-start-interval 1s, the container's own background systemd timer also ticks every 1s during the start period and can legitimately land a probe in the ~2s window between the two manual calls, producing a 3rd log entry. Relax the assertion to a lower bound (>= 2) with a comment explaining why, since the property under test - that manual ticks aren't skipped - doesn't require an exact count. - golangci-lint (revive function-length, max 500 lines) failed because the two new start-interval subtests pushed TestContainerHealthCheckAdvance to 553 lines. Move them into a new TestContainerHealthCheckStartInterval function (479 and 103 lines respectively after the split). The EL/almalinux-8 rootful/rootless failures (TestComposeCopy, TestCopyToContainer, TestCopyFromContainer) come from workflow-flaky.yml, this repo's own known-flaky/experimental job bucket, and don't touch any file this PR changes. The gomodjail rootless (TestRunRmTime, a hard wall-clock deadline) and rootless-port-slirp4netns (TestLoadQuiet) failures are likewise in files untouched by this diff. All three are left alone as external. Verified: go build ./..., go vet ./pkg/healthcheck/... ./cmd/nerdctl/container/..., go test ./pkg/healthcheck/..., gofmt/goimports, and golangci-lint on the touched packages; also confirmed by direct line count that both split functions are under the function-length limit. Signed-off-by: Akash Kumar <116457960+akashchamp@users.noreply.github.com>
29f6a38 to
396e3ac
Compare
haytok
left a comment
There was a problem hiding this comment.
The code comments are too long (since they may be generated by AI). Could you shorten them in your own words so they stay readable and easy to maintain?
Also, could you add a case for HEALTHCHECK --start-interval from the Dockerfile in TestRunHealthcheckFromImage?
| if hc.StartPeriod > 0 && hc.StartInterval > 0 && hc.StartInterval < tickInterval { | ||
| tickInterval = hc.StartInterval | ||
| } |
There was a problem hiding this comment.
nit:
| if hc.StartPeriod > 0 && hc.StartInterval > 0 && hc.StartInterval < tickInterval { | |
| tickInterval = hc.StartInterval | |
| } | |
| if hc.StartPeriod > 0 && hc.StartInterval > 0 { | |
| tickInterval = min(tickInterval, hc.StartInterval) | |
| } |
| func shouldRunProbeAt(now, containerCreated, lastProbeAt time.Time, inStartPeriod bool, hc *Healthcheck) bool { | ||
| // No prior probe recorded: always run the first one. | ||
| if lastProbeAt.IsZero() { | ||
| return true | ||
| } | ||
|
|
||
| target := hc.Interval | ||
| if hc.StartPeriod > 0 && inStartPeriod && now.Sub(containerCreated) < hc.StartPeriod { | ||
| target = hc.StartInterval | ||
| } | ||
| return now.Sub(lastProbeAt) >= target | ||
| } |
There was a problem hiding this comment.
Based on healthcheck interval on Docker, if the time left in the start period (since the last probe) is shorter than start-interval, the next probe should be due after that remaining time instead.
| // Probes are now throttled to run no more often than --health-interval | ||
| // (see shouldRunProbe), which otherwise defaults to 30s: shorter than | ||
| // this test's manual invocations are apart, so each of them would | ||
| // otherwise be skipped as "not due yet" instead of actually probing. |
There was a problem hiding this comment.
When the throttle skips a manual nerdctl container healthcheck, the command exits 0 and prints nothing, so it looks exactly like a successful probe, which is confusing. We should at least log the skip at Info or Warn level.
|
Needs rebasing |
Summary
Implements
--health-start-intervalfornerdctl run/nerdctl create, closing the Docker CLI compat gap tracked in #3867 (still listed there as unimplemented as of the most recent audit on 2026-08-29).Docker's
--health-start-intervallets a container probe more frequently while it is still inside--health-start-period, then fall back to the regular--health-intervalcadence once the start period has elapsed. nerdctl already accepted--health-start-periodbut had no way to probe faster during it, anddocs/command-reference.md/docs/healthchecks.mdexplicitly called out--health-start-intervalas unimplemented.--health-start-intervalflag torun/create, mirroring the existing--health-start-periodplumbing:ContainerCreateOptions, flag validation (rejects negative values), theHealthcheckstruct that's stored on the container label and read back viainspect, and defaults to Docker's5swhen unset (ApplyDefaults).nerdctl composeviahealthcheck.start_interval(was previously silently ignored with aTODO, and listed as unimplemented indocs/compose.md).CreateTimer); that timer's own cadence can't safely be changed at runtime while the unit it triggers is still active — and it is active for the whole duration of the verynerdctl container healthcheck <id>invocation it just triggered. So instead the timer now ticks at the faster of--health-intervaland--health-start-interval, andExecuteHealthCheckskips any tick that arrives before the interval that actually applies right now (--health-start-intervalwhile inside the start period,--health-intervalonce it's elapsed or a healthy result has ended it early) has elapsed since the last probe that actually ran. This keeps the systemd unit untouched for the container's whole lifetime while still producing the Docker-compatible effective probe cadence.docs/command-reference.md,docs/healthchecks.md, anddocs/compose.mdto reflect the flag as implemented.Test plan
go build ./...,go vet ./...on the touched packages, and cross-compiles forwindows/freebsd/darwin(the scheduling logic lives in OS-sharedpkg/healthcheck/executor.go, so it has to build everywhere even though only Linux actually runs it).go test ./pkg/healthcheck/... ./pkg/composer/... ./pkg/inspecttypes/...— addedpkg/healthcheck/executor_test.go(table-driven, pure-function tests for the new scheduling decision) and extendedpkg/composer/serviceparser/serviceparser_test.gofor the compose field.cmd/nerdctl/container/container_run_test.go(TestRunHealthcheckFlags, incl. a negative-value validation case) andcmd/nerdctl/container/container_health_check_linux_test.go(TestContainerHealthCheckDefaultsfor the new default/override, plus two new subtests underTestContainerHealthCheckAdvancethat manually drivenerdctl container healthcheckto assert the start-interval cadence during the start period and the fallback to health-interval afterward).nerdctl run -d --health-cmd "exit 1" --health-interval 20s --health-start-period 6s --health-start-interval 1s ...and watchednerdctl inspect ... --format '{{json .State.Health}}'over time. During the 6s start period, probes landed roughly every 1.3-1.8s (four of them,--health-start-interval=1splus systemd's 1sAccuracySecjitter). After the start period ended, the next probe landed ~21.7s after the last one — matching--health-interval=20s, not the 1s start-interval.systemctl show <id>.timer -p TimersMonotonicconfirmedOnUnitInactiveUSecstayed fixed at1sthroughout, i.e. the cadence switch is entirely the new software-side throttle inExecuteHealthCheck, not a timer reconfiguration. Cleaned up the test container, timer, and pulled image afterward.golangci-lintwas not run locally (not installed on the build host); relying on CI for that pass,go vetwas clean.Notes