fix(deployer): terminate workloads gracefully - #19
Conversation
- Add `DEFAULT_TERMINATION_GRACE_PERIOD_SECONDS`, defaulting to 15 seconds. - Declare `WorkloadPlan.termination_grace_period_seconds`, which defaults an unset value and rejects a negative one. - Accept `grace_period_seconds` on workload deletion, which overrides the duration declared by the workload plan. Task 1 of graceful-workload-termination. Signed-off-by: thxCode <thxcode0824@gmail.com>
- Stamp the termination grace period on the containers, as the Docker models layer cannot express a create time stop timeout. - Remove the unhealthy restart container first, otherwise it restarts the containers being drained. - Stop the non-pause containers within the grace period shared by all of them, then remove them, and remove the pause containers at last. Task 2 of graceful-workload-termination. Signed-off-by: thxCode <thxcode0824@gmail.com>
- Stamp the termination grace period on the containers, as the Podman models layer cannot express a create time stop timeout. - Remove the unhealthy restart container first, otherwise it restarts the containers being drained. - Stop the non-pause containers within the grace period shared by all of them, then remove them, and remove the pause containers at last. - Ignore the "already stopped" answer, which podman-py cannot decode on its own. Task 3 of graceful-workload-termination. Signed-off-by: thxCode <thxcode0824@gmail.com>
- Declare the termination grace period on the Pod spec, replacing the API server default. - Forward the overriding grace period when deleting Pods, both by collection and one by one. Task 4 of graceful-workload-termination. Signed-off-by: thxCode <thxcode0824@gmail.com>
- Accept `--grace-period-seconds` on both `delete` and `delete-all`, which overrides the duration declared by the workload plan. Task 5 of graceful-workload-termination. Signed-off-by: thxCode <thxcode0824@gmail.com>
- Stop the drainable containers concurrently instead of walking them against one shared deadline, so a container ignoring the signal no longer starves the ones behind it, which is how Kubernetes terminates a Pod. - Leave a container failing to stop to the forceful removal following it, instead of aborting the deletion and stranding the pause container. - Reject a negative overriding grace period, which used to be clamped to an immediate kill by Docker and Podman, but forwarded verbatim to Kubernetes. - Report every workload deletion failure as an operation error, as neither the SDK errors nor the transport ones underneath share a narrow base class. - Default the grace period to 30 seconds, aligning with the Kubernetes API server, and compare it when reconciling a Pod. - Stop recreating an unchanged Pod: the API server drops a disabled toggle and fills an empty resources declaration back in, neither being a real change. - Declare the grace period after the containers, so positional construction of a workload plan keeps binding the container list to the containers field. - Accept an unset grace period on the deletion commands, and document that deleting all workloads may take the grace period per workload. Review follow-ups of graceful-workload-termination. Signed-off-by: thxCode <thxcode0824@gmail.com>
- Compare Pod resource quantities by the number they denote, instead of rewriting them on the way out to match what the API server stores, which was both lossy and blind to the suffixed spellings the API server rewrites too. - Refuse a quantity Kubernetes would not accept, e.g. an underscore separated or a full-width one, so an invalid declaration cannot pass as an unchanged one and skip the deployment silently. - Report every Kubernetes workload deletion failure as an operation error as well, keeping the API error arm the 405 fallback reads the status from. - Accept the overriding grace period as a keyword argument only. - Bound the concurrent drain to 16 workers. - Pin the concurrent drain with a barrier, on both Docker and Podman, as the ordering assertion alone still passed against a drain that signalled the containers one at a time. Review follow-ups of graceful-workload-termination. Signed-off-by: thxCode <thxcode0824@gmail.com>
There was a problem hiding this comment.
Code Review
This pull request introduces support for graceful termination of workloads across Docker, Podman, and Kubernetes deployers. It adds a grace_period_seconds parameter (defaulting to 30 seconds) to the workload deletion commands and APIs. For Docker and Podman, containers are now stopped concurrently using a thread pool to ensure each container receives the full grace period. For Kubernetes, the termination grace period is declared on the Pod spec, and resource quantity parsing is improved to prevent unnecessary pod recreations. Additionally, comprehensive unit tests have been added to verify the new graceful termination behavior. I have no feedback to provide as there are no review comments.
There was a problem hiding this comment.
Pull request overview
This PR adds a configurable termination grace period to workload deletion across Docker, Podman, and Kubernetes deployers so inference workloads can drain in-flight requests before being forcefully torn down, and it fixes Kubernetes pod equality checks to avoid unnecessary pod recreation when the API server normalizes defaults/quantities.
Changes:
- Introduces
WorkloadPlan.termination_grace_period_seconds(default 30) plus adelete(..., grace_period_seconds=...)override propagated through the deployer facade, Python API, and CLI. - Implements graceful termination for Docker/Podman by stopping drainable containers concurrently (bounded concurrency) before force-removing them, and ensures transport/SDK failures are consistently wrapped as
OperationError. - Updates Kubernetes pod creation/deletion to declare/forward grace periods and improves
equal_pods/equal_containersto tolerate API-server defaulting and resource quantity rewrites.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| tests/gpustack_runtime/deployer/test_termination_grace_period.py | Adds unit coverage for grace-period resolution/propagation, Docker/Podman drain ordering + concurrency, Kubernetes grace handling, and pod/container equality normalization. |
| gpustack_runtime/deployer/types.py | Adds the default grace-period constant, plan validation/defaulting, and forwards the delete override through the deployer base API. |
| gpustack_runtime/deployer/docker.py | Stamps grace period into labels and implements concurrent stop-then-remove deletion with consistent error wrapping. |
| gpustack_runtime/deployer/podman.py | Mirrors Docker behavior, including ignore=True on stop, label-based grace resolution, and consistent error wrapping. |
| gpustack_runtime/deployer/kuberentes.py | Declares/forwards grace periods on pod create/delete, wraps transport failures, and fixes equality by normalizing defaults and parsing quantities numerically. |
| gpustack_runtime/deployer/init.py | Exposes grace_period_seconds in the top-level delete_workload API and forwards it to the selected deployer. |
| gpustack_runtime/cmds/deployer.py | Adds --grace-period-seconds to delete and delete-all and forwards the value to the runtime API. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Deleting a workload was an immediate
SIGKILLon Docker and Podman, and an unconfigurable 30 secondson Kubernetes, so an inference engine never got a chance to drain in-flight requests. This is the gap
the review of gpustack/gpustack#6061
pointed at — it lives in this layer, not in the control plane.
Problem
delete_workload()had no notion of a termination grace period:docker.py/podman.pyremoved every container withremove(force=True), i.e.docker rm -f—immediate SIGKILL, no SIGTERM, no grace;
kuberentes.pynever setterminationGracePeriodSecondson the Pod spec and never forwardedgracePeriodSecondson delete, so the workload silently inherited the API server default with noway to configure it.
Reproduced with a container that traps SIGTERM and appends a marker to a bind-mounted file:
Change
WorkloadPlan.termination_grace_period_seconds(default 30, the Kubernetes API server default)is the declarative source of truth, and
delete_workload(..., grace_period_seconds=N)is a one-shotoverride — mirroring Kubernetes' own
terminationGracePeriodSeconds/gracePeriodSecondspair.a create-time stop timeout at the models layer (
stop_timeoutis in neitherRUN_CREATE_KWARGSnorRUN_HOST_CONFIG_KWARGS). Deletion then removes the autoheal sidecar first — it restarts containerswhose health turns unhealthy, so it would resurrect the very container being drained — stops every
drainable container concurrently so each gets the whole grace period, removes them, and removes
the pause container last, since it holds the namespaces the others share.
ignore=True: without it,stop()on an already-exited container answers304 Not Modifiedand podman-py callsresponse.json()on an empty body, raisingJSONDecodeError— not anAPIError, so it would escape the deletion. INIT containers are alwaysalready exited, so that path is guaranteed to be hit.
and the 405 per-Pod fallback paths.
--grace-period-secondsondeleteanddelete-all.stranding the pause container and the volumes, and deleting a workload now always raises
OperationErroron all three deployers — neither SDK's errors nor the transport errors underneaththem share a base class narrow enough to catch on its own (
json.JSONDecodeErroris not even anOSError).The grace period is not a budget shared between the containers: every one of them is signalled at
once and gets all of it, which is what Kubernetes does. The drain is bounded to 16 workers; a workload
carries a pause container, an autoheal sidecar and a handful of init/run containers, so that ceiling
is not reachable in practice.
Kubernetes stopped recreating unchanged Pods
Comparing the new field in
equal_podssurfaced a pre-existing defect:equal_podsnever returnedTrue, so every
create_workload()on an existing Pod deleted and recreated it — a model reload oneach deployment. Three normalisations, each measured against a live cluster:
hostNetwork: falseNone != False→ changedresourcesunset{claims: null, limits: null, requests: null}or {}did not catch it → changedcpu: 0.5,memory: 1.5Gi500m,1536MiQuantities are now compared by the number they denote rather than by spelling, so any equivalent
spelling reads as unchanged, while a quantity Kubernetes would not accept — underscore separated,
full-width, or whitespace padded, all of which Python's
Decimalwould happily parse — is refusedrather than passing as unchanged and silently skipping the deployment.
Known limit, left for a separate change:
equal_podsstill compares the desired spec against the liveobject field by field, so it survives only because
_create_podhand-mirrors every API server default(
success_threshold=1exists purely for that). The shape that removes the whole class is comparingagainst the last applied spec recorded on the Pod, the way
kubectl applydoes.Test plan
tests/gpustack_runtime/deployer/test_termination_grace_period.py, 41 cases: callorder and arguments on both Docker and Podman, the resolution chain (argument → container label →
30),
grace_period_seconds=0degrading to an immediate kill, a container failing to stop, theKubernetes Pod spec / delete forwarding / transport-failure wrapping, and
equal_podsreading arewritten quantity and an omitted toggle as unchanged. Full suite: 536 passed, 20 skipped;
pre-commitclean.drain, and a drain that signals the containers one at a time, each fail the
threading.Barrierconcurrency test while the other cases survive — so the barrier is the load-bearing guard.
delete took 3.18s,SIGTERM observed: True,handler completed: True(0.16s / False / False before). Two RUNcontainers each needing 4s to drain with
grace=5: both drain completely, 5.14s.grace_period_seconds=0still kills immediately, 0.19s. End-to-end via the CLI as well.docker-desktopcluster (v1.36.1) —spec.terminationGracePeriodSecondsreports the declared value;metadata.deletionGracePeriodSecondsreports 30 without an override and 5 withgrace_period_seconds=5; the container observes SIGTERM while draining. Re-applying an unchangedplan no longer recreates the Pod, while changing the grace period or an image still does.
tests mirroring the Docker ones, plus a targeted check driving podman-py's real
Container.stopagainst a synthetic
304response, which confirmsignore=Trueis required.