Skip to content

Commit ee22398

Browse files
authored
fix(deployments): handle Docker restart races (#868)
Update Docker deployment status reconciliation to observe Docker inspect state for exited and missing containers. Stopped one-shot containers remain observable, retryable OnFailure exits stay non-terminal until the backoff limit is exhausted, and Docker GPU allocations are released only when status handling reaches a terminal deployment result. Signed-off-by: Ryan S <267728323+ironcommit@users.noreply.github.com>
1 parent 61a4adf commit ee22398

19 files changed

Lines changed: 315 additions & 42 deletions

File tree

packages/nmp_customization_common/src/nmp/customization_common/config.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from pydantic import Field
88

99

10-
class CustomizationCommonConfig(create_service_config_class("customizer")): # type: ignore[misc]
10+
class CustomizationCommonConfig(create_service_config_class("customizer")): # ty: ignore[unsupported-base]
1111
"""Environment variables use the ``NMP_CUSTOMIZER_`` prefix."""
1212

1313
tasks_image: str | None = Field(

plugins/nemo-agents/examples/calculator-agent/src/calculator_agent/register.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,10 @@
88

99
from collections.abc import AsyncGenerator
1010

11-
from nat.builder.builder import Builder # type: ignore
12-
from nat.builder.function import FunctionGroup # type: ignore
13-
from nat.cli.register_workflow import register_function_group # type: ignore
14-
from nat.data_models.function import FunctionGroupBaseConfig # type: ignore
11+
from nat.builder.builder import Builder
12+
from nat.builder.function import FunctionGroup
13+
from nat.cli.register_workflow import register_function_group
14+
from nat.data_models.function import FunctionGroupBaseConfig
1515
from pydantic import Field
1616

1717

plugins/nemo-deployments/openapi/openapi.yaml

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

plugins/nemo-deployments/src/nemo_deployments_plugin/backends/docker/backend.py

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@
7878
SecretResolutionError,
7979
resolve_deployment_config_secrets,
8080
)
81-
from nemo_deployments_plugin.types import Endpoint, RestartPolicy
81+
from nemo_deployments_plugin.types import NON_TERMINAL_DEPLOYMENT_STATUSES, Endpoint, RestartPolicy
8282
from nemo_platform_plugin.capabilities import docker_from_env_kwargs, probe_docker
8383
from nemo_platform_plugin.client.adapter import client_from_platform
8484
from nemo_platform_plugin.config import LOOPBACK_ADDRESSES
@@ -139,6 +139,21 @@ def _config_files_tar(config_files: list[ConfigFile]) -> bytes:
139139
return buf.getvalue()
140140

141141

142+
def _docker_inspect_attrs(container: DockerContainer) -> dict[str, Any]:
143+
return container.attrs or {}
144+
145+
146+
def _docker_inspect_exit_code(container: DockerContainer) -> int:
147+
state = _docker_inspect_attrs(container).get("State") or {}
148+
if not isinstance(state, dict):
149+
return 1
150+
return int(state.get("ExitCode", 1))
151+
152+
153+
def _docker_inspect_restart_count(container: DockerContainer) -> int:
154+
return int(_docker_inspect_attrs(container).get("RestartCount", 0))
155+
156+
142157
class DockerDeploymentBackend(DeploymentBackend):
143158
"""Manage deployments and volumes as Docker containers and volumes."""
144159

@@ -676,12 +691,15 @@ async def read_status(self, *, workspace: str, name: str) -> BackendStatusUpdate
676691
container = await asyncio.to_thread(self._client.containers.get, c_name)
677692
if not self._container_matches_deployment_group(container, workspace, name):
678693
restart_policy = await self._resolve_restart_policy(workspace, name)
679-
return missing_container_status(restart_policy, container_name=c_name)
694+
status_update = missing_container_status(restart_policy, container_name=c_name)
695+
if status_update.status not in NON_TERMINAL_DEPLOYMENT_STATUSES and self._gpu_pool is not None:
696+
self._gpu_pool.release_gpu(dep_key)
697+
return status_update
680698
await asyncio.to_thread(container.reload)
681699
except self._docker_errors.NotFound:
682700
restart_policy = await self._resolve_restart_policy(workspace, name)
683701
status_update = missing_container_status(restart_policy, container_name=c_name)
684-
if restart_policy in _ONE_SHOT_RESTART_POLICIES and self._gpu_pool is not None:
702+
if status_update.status not in NON_TERMINAL_DEPLOYMENT_STATUSES and self._gpu_pool is not None:
685703
self._gpu_pool.release_gpu(dep_key)
686704
return status_update
687705
except (
@@ -751,8 +769,8 @@ async def read_status(self, *, workspace: str, name: str) -> BackendStatusUpdate
751769
)
752770

753771
if state in ("exited", "dead"):
754-
exit_code = int(container.attrs.get("State", {}).get("ExitCode", 1))
755-
restart_count = int(container.attrs.get("RestartCount", 0))
772+
exit_code = _docker_inspect_exit_code(container)
773+
restart_count = _docker_inspect_restart_count(container)
756774
return self._status_from_exited_container(
757775
exit_code=exit_code,
758776
restart_policy=restart_policy,
@@ -805,6 +823,13 @@ def _status_from_exited_container(
805823
)
806824
if restart_policy == "OnFailure":
807825
backoff_limit = int(labels.get(BACKOFF_LIMIT_LABEL, "6"))
826+
if backoff_limit == 0:
827+
return BackendStatusUpdate(
828+
status="STARTING",
829+
status_message=(f"Container exited (code {exit_code}); retry {restart_count}/unlimited"),
830+
exit_code=exit_code,
831+
endpoints=resolved_endpoints,
832+
)
808833
if restart_count < backoff_limit:
809834
return BackendStatusUpdate(
810835
status="STARTING",
@@ -887,9 +912,8 @@ def _wait_for_exit() -> int:
887912
endpoints=endpoints,
888913
)
889914
if container.status in _EXITED_CONTAINER_STATES:
890-
attrs = container.attrs or {}
891-
exit_code = int(attrs.get("State", {}).get("ExitCode", 1))
892-
restart_count = int(attrs.get("RestartCount", 0))
915+
exit_code = _docker_inspect_exit_code(container)
916+
restart_count = _docker_inspect_restart_count(container)
893917
return self._status_from_exited_container(
894918
exit_code=exit_code,
895919
restart_policy=restart_policy,

plugins/nemo-deployments/src/nemo_deployments_plugin/backends/docker/containers.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,8 @@ def restart_policy_kwargs(restart_policy: RestartPolicy, backoff_limit: int) ->
9595
if restart_policy == "Always":
9696
return {"restart_policy": {"Name": "always"}}
9797
if restart_policy == "OnFailure":
98+
if backoff_limit < 1:
99+
raise DeploymentConfigError("backoff_limit must be at least 1 for OnFailure restart policy")
98100
return {"restart_policy": {"Name": "on-failure", "MaximumRetryCount": backoff_limit}}
99101
return {}
100102

plugins/nemo-deployments/src/nemo_deployments_plugin/entities.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -285,7 +285,12 @@ class DeploymentConfig(NemoEntity, entity_type=ENTITY_TYPE_DEPLOYMENT_CONFIG):
285285
volume_mounts: list[VolumeMount] = Field(default_factory=list, alias="volumeMounts")
286286
config_files: list[ConfigFile] = Field(default_factory=list, alias="configFiles")
287287
restart_policy: RestartPolicy = Field(default="Always", alias="restartPolicy")
288-
backoff_limit: int = Field(default=6, alias="backoffLimit")
288+
backoff_limit: int = Field(
289+
default=6,
290+
ge=1,
291+
alias="backoffLimit",
292+
description="Retry limit for OnFailure deployments; must be positive. Never deployments disable retries internally.",
293+
)
289294
drift_recovery: DriftRecoveryPolicy = Field(default_factory=DriftRecoveryPolicy, alias="driftRecovery")
290295
labels: dict[str, str] = Field(default_factory=dict)
291296
backend_config: DeploymentBackendConfig = Field(default_factory=DeploymentBackendConfig, alias="backendConfig")

plugins/nemo-deployments/src/nemo_deployments_plugin/schema.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,11 @@ class CreateDeploymentConfigRequest(BaseModel):
102102
volume_mounts: list[VolumeMount] = Field(default_factory=list)
103103
config_files: list[ConfigFile] = Field(default_factory=list)
104104
restart_policy: RestartPolicy = "Always"
105-
backoff_limit: int = 6
105+
backoff_limit: int = Field(
106+
default=6,
107+
ge=1,
108+
description="Retry limit for OnFailure deployments; must be positive. Never deployments disable retries internally.",
109+
)
106110
drift_recovery: DriftRecoveryPolicy | None = None
107111
labels: dict[str, str] = Field(default_factory=dict)
108112
backend_config: DeploymentBackendConfig = Field(default_factory=DeploymentBackendConfig)

plugins/nemo-deployments/tests/integration/backends/docker/test_docker_backend.py

Lines changed: 78 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,19 @@ def _worker_port_base(worker_id: str) -> int:
4949
return TEST_PORT_RANGE_START + worker_index * TEST_PORT_RANGE_SIZE
5050

5151

52-
def _build_docker_backend(worker_id: str = "master", **config_overrides: Any) -> DockerDeploymentBackend:
53-
mock_entities = AsyncMock()
52+
@pytest.fixture
53+
def mock_entities() -> AsyncMock:
54+
return AsyncMock()
55+
56+
57+
def _build_docker_backend(
58+
worker_id: str = "master",
59+
*,
60+
mock_entities: AsyncMock | None = None,
61+
**config_overrides: Any,
62+
) -> DockerDeploymentBackend:
63+
if mock_entities is None:
64+
mock_entities = AsyncMock()
5465
mock_sdk = MagicMock()
5566
port_base = _worker_port_base(worker_id)
5667
executor_config: dict[str, Any] = {
@@ -70,8 +81,8 @@ def _build_docker_backend(worker_id: str = "master", **config_overrides: Any) ->
7081

7182

7283
@pytest.fixture
73-
def docker_backend(worker_id: str) -> DockerDeploymentBackend:
74-
return _build_docker_backend(worker_id)
84+
def docker_backend(worker_id: str, mock_entities: AsyncMock) -> DockerDeploymentBackend:
85+
return _build_docker_backend(worker_id, mock_entities=mock_entities)
7586

7687

7788
def _never_config() -> DeploymentConfig:
@@ -148,9 +159,12 @@ async def test_volume_lifecycle(docker_backend: DockerDeploymentBackend) -> None
148159

149160

150161
@pytest.mark.asyncio
151-
async def test_never_deployment_succeeds(docker_backend: DockerDeploymentBackend) -> None:
162+
async def test_never_deployment_succeeds(
163+
docker_backend: DockerDeploymentBackend,
164+
mock_entities: AsyncMock,
165+
) -> None:
152166
config = _never_config()
153-
docker_backend._entities.get.return_value = config # ty: ignore[unresolved-attribute]
167+
mock_entities.get.return_value = config
154168
c_name = container_name("itest", "echo-job")
155169
client = docker.from_env()
156170

@@ -189,7 +203,14 @@ async def test_never_deployment_outlives_observe_wait_then_succeeds(worker_id: s
189203
oneshot_observe_timeout_seconds=observe_timeout_seconds,
190204
)
191205
config = _never_sleep_config(sleep_seconds=job_sleep_seconds)
192-
docker_backend._entities.get.return_value = config # ty: ignore[unresolved-attribute]
206+
deployment = Deployment(name="sleep-job", workspace="itest", deployment_config="sleep-cfg")
207+
208+
async def get_side_effect(entity_type, name, workspace=None):
209+
if entity_type is Deployment:
210+
return deployment
211+
return config
212+
213+
docker_backend._entities.get.side_effect = get_side_effect # ty: ignore[unresolved-attribute]
193214
c_name = container_name("itest", "sleep-job")
194215
client = docker.from_env()
195216

@@ -233,7 +254,55 @@ async def test_never_deployment_outlives_observe_wait_then_succeeds(worker_id: s
233254

234255

235256
@pytest.mark.asyncio
236-
async def test_lost_detection_for_always(docker_backend: DockerDeploymentBackend) -> None:
257+
async def test_removed_never_deployment_reports_missing(
258+
docker_backend: DockerDeploymentBackend,
259+
mock_entities: AsyncMock,
260+
) -> None:
261+
config = _never_config()
262+
deployment = Deployment(name="removed-job", workspace="itest", deployment_config="echo-cfg")
263+
264+
async def get_side_effect(entity_type, name, workspace=None):
265+
if entity_type is Deployment:
266+
return deployment
267+
return config
268+
269+
mock_entities.get.side_effect = get_side_effect
270+
c_name = container_name("itest", "removed-job")
271+
client = docker.from_env()
272+
273+
try:
274+
created = await docker_backend.create_deployment(
275+
workspace="itest",
276+
name="removed-job",
277+
config_name="echo-cfg",
278+
labels={"managed-by": MANAGED_BY_LABEL},
279+
backend_config={},
280+
)
281+
assert created.status == "SUCCEEDED"
282+
assert created.exit_code == 0
283+
284+
container = client.containers.get(c_name)
285+
result = container.wait(timeout=20)
286+
assert result["StatusCode"] == 0
287+
container.remove(force=True)
288+
289+
status = await docker_backend.read_status(workspace="itest", name="removed-job")
290+
291+
assert status.status == "FAILED"
292+
assert status.exit_code is None
293+
assert status.error_details == {
294+
"expected_container_name": c_name,
295+
}
296+
finally:
297+
await docker_backend.delete_deployment("itest", "removed-job")
298+
force_remove_container(client, c_name)
299+
300+
301+
@pytest.mark.asyncio
302+
async def test_lost_detection_for_always(
303+
docker_backend: DockerDeploymentBackend,
304+
mock_entities: AsyncMock,
305+
) -> None:
237306
deployment = Deployment(name="lost-srv", workspace="itest", deployment_config="http-cfg")
238307
config = _always_http_config()
239308

@@ -242,7 +311,7 @@ async def get_side_effect(entity_type, name, workspace=None):
242311
return deployment
243312
return config
244313

245-
docker_backend._entities.get.side_effect = get_side_effect # ty: ignore[unresolved-attribute]
314+
mock_entities.get.side_effect = get_side_effect
246315
c_name = container_name("itest", "lost-srv")
247316
client = docker.from_env()
248317

plugins/nemo-deployments/tests/unit/backends/docker/docker_helpers.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -119,9 +119,9 @@ def lora_config(*, restart_policy: RestartPolicy = "Always") -> DeploymentConfig
119119
)
120120

121121

122-
def container_attrs(*, status: str = "running", exit_code: int = 0) -> dict[str, Any]:
122+
def container_attrs(*, status: str = "running", exit_code: int = 0, restart_count: int = 0) -> dict[str, Any]:
123123
del status
124124
return {
125125
"State": {"ExitCode": exit_code, "StartedAt": "2026-01-01T00:00:00Z"},
126-
"RestartCount": 0,
126+
"RestartCount": restart_count,
127127
}

0 commit comments

Comments
 (0)