From 389df7c3d556e1069de0d51c3208d909c11008cc Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Wed, 5 Aug 2026 21:55:32 +0200 Subject: [PATCH] Drain API pods before Kubernetes shutdown --- docs/SCALING_AND_MULTI_HOST_DEPLOYMENT.md | 4 ++++ tests/test_deployment_installer.py | 16 ++++++++++++++++ .../deployment/govoplan_deploy/kubernetes.py | 19 +++++++++++++++++++ 3 files changed, 39 insertions(+) diff --git a/docs/SCALING_AND_MULTI_HOST_DEPLOYMENT.md b/docs/SCALING_AND_MULTI_HOST_DEPLOYMENT.md index 19f0d1a..095fcc4 100644 --- a/docs/SCALING_AND_MULTI_HOST_DEPLOYMENT.md +++ b/docs/SCALING_AND_MULTI_HOST_DEPLOYMENT.md @@ -270,6 +270,10 @@ record under the installation evidence directory and never retains the API key. Use `--exercise-api-pod-loss` in an approved drill window to delete one API pod, observe the public readiness path continuously, and record its replacement. +Generated API workloads use a ten-second pre-stop drain so Kubernetes can remove +the terminating endpoint from ingress and service routing before Uvicorn exits. +Do not remove or shorten this drain without repeating the public-path pod-loss +test against the target ingress controller and network implementation. This proves the bounded stateless-node-loss slice only. Session continuity, accepted-job redelivery, state-service failover, and coordinated restore remain separate target exercises whose signed evidence is governed by diff --git a/tests/test_deployment_installer.py b/tests/test_deployment_installer.py index 4091bc7..0593505 100644 --- a/tests/test_deployment_installer.py +++ b/tests/test_deployment_installer.py @@ -460,6 +460,22 @@ class DeploymentInstallerTests(unittest.TestCase): "containers" ][0]["readinessProbe"]["httpGet"]["httpHeaders"], ) + api_pod_spec = deployments["govoplan-cluster-api"]["spec"]["template"][ + "spec" + ] + self.assertEqual(30, api_pod_spec["terminationGracePeriodSeconds"]) + self.assertEqual( + ["/bin/sh", "-c", "sleep 10"], + api_pod_spec["containers"][0]["lifecycle"]["preStop"]["exec"][ + "command" + ], + ) + self.assertNotIn( + "lifecycle", + deployments["govoplan-cluster-worker"]["spec"]["template"]["spec"][ + "containers" + ][0], + ) worker_command = deployments["govoplan-cluster-worker"]["spec"]["template"][ "spec" ]["containers"][0]["command"] diff --git a/tools/deployment/govoplan_deploy/kubernetes.py b/tools/deployment/govoplan_deploy/kubernetes.py index f95c473..b0e9279 100644 --- a/tools/deployment/govoplan_deploy/kubernetes.py +++ b/tools/deployment/govoplan_deploy/kubernetes.py @@ -168,6 +168,7 @@ def render_kubernetes( readiness_path="/health/ready", liveness_path="/health", probe_host=public_host, + graceful_shutdown_seconds=10, extra_environment=_role_database_environment(environment, "API"), ), _service( @@ -658,6 +659,7 @@ def _deployment( readiness_path: str | None = None, liveness_path: str | None = None, probe_host: str | None = None, + graceful_shutdown_seconds: int = 0, extra_environment: Mapping[str, str] | None = None, selector_labels: Mapping[str, str] | None = None, ) -> dict[str, Any]: @@ -715,6 +717,18 @@ def _deployment( container_port or 8000, host=probe_host, ) + if graceful_shutdown_seconds: + container["lifecycle"] = { + "preStop": { + "exec": { + "command": [ + "/bin/sh", + "-c", + f"sleep {graceful_shutdown_seconds}", + ] + } + } + } pod_spec: dict[str, Any] = { "serviceAccountName": service_account, "automountServiceAccountToken": False, @@ -734,6 +748,11 @@ def _deployment( } ], } + if graceful_shutdown_seconds: + pod_spec["terminationGracePeriodSeconds"] = max( + 30, + graceful_shutdown_seconds + 20, + ) container["volumeMounts"] = [{"name": "tmp", "mountPath": "/tmp"}] if s3_ca_secret_name: pod_spec["volumes"].append(_s3_ca_volume(s3_ca_secret_name))