Skip to content

Commit d137594

Browse files
authored
Support in-place update for service gateway (#4221)
The `gateway` property in service configurations can now be updated in-place. ```shell $ dstack apply -f my-service.dstack.yml Active run my-service already exists. Detected changes that can be updated in-place: - Configuration properties: - gateway Update the run? [y/n]: y NAME BACKEND GPU PRICE STATUS SUBMITTED my-service aws (us-east-2) - $0.0005 (spot) running 09:22 my-service provisioning completed (running) Service is published at: http://my-service.second-gateway.example.com ``` This allows you to reassign a service to a different gateway without stopping the service. ```shell $ dstack event [2026-08-27 09:28:33] [run my-service, gateway second-gateway] Service assigned to gateway [2026-08-27 09:28:33] [👤admin] [run my-service] Run updated. Deployment: 1. Changed fields: configuration.gateway [2026-08-27 09:28:39] [run my-service, gateway-replica first-gateway-0] Service unregistered [2026-08-27 09:28:39] [job my-service-0-0, gateway-replica first-gateway-0] Service replica unregistered [2026-08-27 09:28:41] [run my-service, gateway-replica second-gateway-0] Service registered [2026-08-27 09:28:41] [job my-service-0-0, gateway-replica second-gateway-0] Service replica registered ``` Migrating between gateways would typically result in service downtime while you are rerouting your client traffic to the new gateway. During reassignment, the service may also be unavailable on both gateways for a brief period (several seconds). Migration from a gateway to running without a gateway (`gateway: false`) and vice versa is also supported.
1 parent 260060c commit d137594

6 files changed

Lines changed: 415 additions & 11 deletions

File tree

mkdocs/docs/concepts/services.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1534,7 +1534,7 @@ The rolling deployment stops when all replicas are updated or when a new deploym
15341534

15351535
Rolling deployment supports changes to the following properties: `port`, `probes`, `resources`, `volumes`, `docker`, `files`, `image`, `user`, `privileged`, `entrypoint`, `working_dir`, `python`, `nvcc`, `single_branch`, `env`, `shell`, `commands`, as well as changes to [repo](#repos) or [file](#files) contents.
15361536

1537-
Changes to `replicas` and `scaling` can be applied without redeploying replicas.
1537+
Changes to `priority`, `replicas`, `scaling`, and `gateway` can be applied without redeploying replicas.
15381538

15391539
Changes to other properties require a full service restart.
15401540

src/dstack/_internal/core/models/configurations.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1167,7 +1167,8 @@ class ServiceConfigurationParams(CoreModel):
11671167
description=(
11681168
"The name of the gateway. Specify boolean `false` to run without a gateway."
11691169
" Specify boolean `true` to run with the default gateway."
1170-
" Omit to run with the default gateway if there is one, or without a gateway otherwise"
1170+
" Omit to run with the default gateway if there is one, or without a gateway otherwise."
1171+
" Can be updated in-place to migrate existing services between gateways"
11711172
),
11721173
),
11731174
] = None

src/dstack/_internal/server/services/runs/__init__.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -675,6 +675,17 @@ async def apply_plan(
675675
raise ServerClientError(
676676
"Failed to apply plan. Resource has been changed. Try again or use force apply."
677677
)
678+
if (
679+
run_spec.configuration.type == "service"
680+
and current_resource.run_spec.configuration.type == "service"
681+
and run_spec.configuration.gateway != current_resource.run_spec.configuration.gateway
682+
):
683+
await services.assign_service(
684+
session=session,
685+
run_model=current_resource_model,
686+
run_spec=run_spec,
687+
is_new_service_submission=False,
688+
)
678689
new_deployment_num = current_resource.deployment_num + 1
679690
# FIXME: potentially long write transaction
680691
# Avoid getting run_model after update
@@ -783,8 +794,9 @@ async def submit_run(
783794
)
784795

785796
if run_spec.configuration.type == "service":
786-
# FIXME: Register services asynchronously in the background
787-
await services.register_service(session, run_model, run_spec)
797+
await services.assign_service(
798+
session, run_model, run_spec, is_new_service_submission=True
799+
)
788800
service_config = run_spec.configuration
789801

790802
global_replica_num = 0 # Global counter across all groups for unique replica_num

src/dstack/_internal/server/services/runs/spec.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@
5757
"replicas",
5858
"groups",
5959
"scaling",
60+
"gateway",
6061
# rolling deployment
6162
# NOTE: keep this list in sync with the "Rolling deployment" section in services.md
6263
"port",
@@ -433,7 +434,7 @@ def _check_dynamo_in_place_update_compatibility(
433434
_router_affecting_top_level_fields = tuple(
434435
f
435436
for f in _TYPE_SPECIFIC_CONF_UPDATABLE_FIELDS.get("service", [])
436-
if f not in ("replicas", "groups", "scaling")
437+
if f not in ("replicas", "groups", "scaling", "gateway")
437438
)
438439
for field in _router_affecting_top_level_fields:
439440
if getattr(current_cfg, field, None) != getattr(new_cfg, field, None):

src/dstack/_internal/server/services/services/__init__.py

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,12 @@
3333
logger = get_logger(__name__)
3434

3535

36-
async def register_service(session: AsyncSession, run_model: RunModel, run_spec: RunSpec):
36+
async def assign_service(
37+
session: AsyncSession,
38+
run_model: RunModel,
39+
run_spec: RunSpec,
40+
is_new_service_submission: bool,
41+
) -> None:
3742
assert isinstance(run_spec.configuration, ServiceConfiguration)
3843

3944
if isinstance(run_spec.configuration.gateway, EntityReference) or isinstance(
@@ -70,14 +75,21 @@ async def register_service(session: AsyncSession, run_model: RunModel, run_spec:
7075
"The service requires a gateway, but there is no default gateway in the project"
7176
)
7277

78+
if (
79+
not is_new_service_submission
80+
and (gateway.id if gateway is not None else None) == run_model.gateway_id
81+
):
82+
return
83+
7384
if gateway is not None:
74-
service_spec = await _register_service_in_gateway(session, run_model, run_spec, gateway)
85+
service_spec = await _assign_service_to_gateway(session, run_model, run_spec, gateway)
7586
run_model.gateway = gateway
7687
# For faster registration
7788
for replica_model in get_gateway_replica_models(gateway):
7889
replica_model.skip_min_processing_interval = True
7990
elif not settings.FORBID_SERVICES_WITHOUT_GATEWAY:
80-
service_spec = _register_service_in_server(session, run_model, run_spec)
91+
service_spec = _assign_service_to_in_server_proxy(session, run_model, run_spec)
92+
run_model.gateway = None
8193
else:
8294
raise ResourceNotExistsError(
8395
"This dstack-server installation forbids services without a gateway."
@@ -86,7 +98,7 @@ async def register_service(session: AsyncSession, run_model: RunModel, run_spec:
8698
run_model.service_spec = service_spec.model_dump_json()
8799

88100

89-
async def _register_service_in_gateway(
101+
async def _assign_service_to_gateway(
90102
session: AsyncSession, run_model: RunModel, run_spec: RunSpec, gateway: GatewayModel
91103
) -> ServiceSpec:
92104
assert run_spec.configuration.type == "service"
@@ -150,7 +162,7 @@ async def _register_service_in_gateway(
150162
return service_spec
151163

152164

153-
def _register_service_in_server(
165+
def _assign_service_to_in_server_proxy(
154166
session: AsyncSession, run_model: RunModel, run_spec: RunSpec
155167
) -> ServiceSpec:
156168
assert run_spec.configuration.type == "service"

0 commit comments

Comments
 (0)