[Feature] Supporting dstack-inside-dstack for runs#4023
[Feature] Supporting dstack-inside-dstack for runs#4023peterschmidt85 wants to merge 12 commits into
Conversation
Removing the job server connection on PROVISIONING/PULLING iterations reset the failure time tracked by the pool, so retry_timed_out() could never fire and jobs with a failing tunnel were never terminated by the server. Remove the connection only when the job leaves RUNNING; jobs_terminating covers the final cleanup. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
DSTACK_TOKEN alone now overrides the token of the project configured in config.yml. DSTACK_SERVER_URL still takes effect only together with DSTACK_TOKEN, and a missing token or project now produces an explicit error instead of a silent fallback when no config is available. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Enabling dstack access inside a run now reads like Docker-in-Docker (`docker: true`): `dstack: true`. No migration needed: the field is stored in run spec JSON only; pre-release DBs with the old field can be reset. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The reverse forward targeted 127.0.0.1 while the server may be bound to a specific address via DSTACK_SERVER_HOST, making the forward target unreachable. Derive the target from the bind address and include it in the error when the server is not reachable through the tunnel. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
| await self._tunnel.aexec( | ||
| f"mkdir -p {remote_dir} && chmod 755 {remote_dir} && rm -f {remote_socket}" | ||
| ) | ||
| server_socket = _get_server_socket() | ||
| self._tunnel.reverse_forwarded_sockets = [ | ||
| SocketPair( | ||
| local=server_socket, | ||
| remote=UnixSocket(path=_REMOTE_SOCKET_PATH), | ||
| ) | ||
| ] | ||
| # Probe through the job socket itself: a socket path can remain after its listener | ||
| # becomes unreachable. | ||
| self._tunnel.forwarded_sockets = [ | ||
| SocketPair( | ||
| local=UnixSocket(path=self._probe_socket_path), | ||
| remote=UnixSocket(path=_REMOTE_SOCKET_PATH), | ||
| ) | ||
| ] | ||
| await self._tunnel.aopen() |
There was a problem hiding this comment.
So if there are multiple dstack server replicas, each replica will overwrite /run/dstack/server.sock once (at JobServerConnectionsPool.ensure), and then the socket will point to the replica that overwrote it last?
I anticipate a few issues with that design:
- Disproportionate distribution of requests among
dstackserver replicas. For example, the server replica that starts last (e.g., as a result of scaling or rolling update) will gradually overtake/run/dstack/server.sockin all existing jobs, and all requests from all jobs will be handled exclusively by that single replica. - Worse availability.
- If the server replica behind
/run/dstack/server.sockstops or fails, the job will be unable to request the server for some period of time (potentially longer than 30s if the job was locked) until another server replica detects the missing or unhealthy/run/dstack/server.sockand takes over. - The server will also be unavailable for jobs during the overwrite — requests issued between
rm -f {remote_socket}andself._tunnel.aopen()will fail.
- If the server replica behind
Issue 2ii can be fixed with the existing design by not overwriting the socket if it already exists and is healthy.
Issues 1 and 2i would require revisiting the design. One alternative design would be to have each server replica create its own Unix socket in the job container (similar to gateways), and have a load balancer in front of the sockets. For example, the runner could act as a load balancer.
There was a problem hiding this comment.
Good catch — addressed in 42c8f6e. Instead of unconditionally overwriting /run/dstack/server.sock on every open, a replica now probes it first and only (re)creates the reverse forward when it's missing or unreachable; otherwise it adopts the existing one. That keeps a single stable owner, so replicas no longer steal the socket from each other (issue 1) and there's no overwrite gap (issue 2ii). If the owner replica dies, another takes over on its next health check (issue 2i) — bounded, not instant.
Verified with two replicas on a shared Postgres and a real SSH fleet, with a job probing its socket once a second:
- Unfixed: start replica B, kill B's server → probe breaks (
200 → FAIL), because B had stolen the socket. - Fixed: same steps → probe holds at
200(B adopted; replica A stays owner). - Fixed failover: kill the owning replica entirely → the other takes over in ~33s (bounded by the 30s health-check throttle + tick).
This deliberately keeps the single-socket design and doesn't spread a single job's load across replicas or make failover instant — the per-replica-socket + load-balancer approach you sketched would, at the cost of a runner-side change. Happy to file that as a follow-up if we want those properties.
There was a problem hiding this comment.
That keeps a single stable owner, so replicas no longer steal the socket from each other (issue 1).
To be precise, issue 1 is about disproportionate distribution of requests, and I think it is still relevant. Depending on server replica startup timing and job startup timing, a single server replica can still occupy /run/dstack/server.sock in all or most jobs.
I don't mind filing issues 1 and 2i as a follow-up.
There was a problem hiding this comment.
You're right, and thanks for the precision — issue 1 (even request distribution) is only partially addressed. Adopt stops the churn and gives a stable owner, but since the owner is whichever replica opens the socket first, startup timing can still concentrate most or all jobs on a single replica. So balanced distribution (issue 1) and instant failover (2i) remain open; the per-replica-socket + load-balancer design is the way to get them.
We'll create follow-up issues for 1 and 2i.
_get_lock and remove_all only run await-free expressions, so the extra lock adds nothing under the single-threaded event loop. Make _get_lock synchronous and drop the lock. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The persistent server connection counts as SSH activity, so a dev environment with both dstack access and inactivity_duration would never be detected as inactive. Reject the combination at configuration time instead of silently breaking inactivity_duration. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
With multiple server replicas, each replica used to overwrite the job's /run/dstack/server.sock on every open, so ownership churned between replicas (they repeatedly stole the socket from each other) and every overwrite briefly broke access. Probe the socket first and (re)create the reverse forward only when it is missing or unreachable; otherwise adopt the existing one. This keeps a single stable owner. If the owner replica dies, another takes over on its next health check. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Allow `dstack: true` on service configurations, mirroring tasks and dev environments. Each replica is a separate job that gets its own `/run/dstack/server.sock`, so multiple replicas behave like multi-node job replicas. Exclude the field from run specs sent to older servers, same as for tasks and dev environments. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Problem
Runs may need to call dstack themselves, for example to inspect runs, submit
another run, or attach to it. This currently requires the dstack server to
accept direct connections from the run.
UX
Add
dstack: trueto a run configuration.With
dstack: true, dstack exposes the server managing the run through/run/dstack/server.sockinside each job. It setsDSTACK_SERVER_URLto thecorresponding
http+unixURL andDSTACK_PROJECTto the current project,allowing the CLI and API clients to use that connection.
Authentication remains explicit. dstack does not inject
DSTACK_TOKEN; it canbe passed through the existing environment or secret mechanisms.
The CLI also accepts
DSTACK_SERVER_URL,DSTACK_PROJECT, andDSTACK_TOKENas a complete configuration without requiring
~/.dstack/config.yml.dstackcannot be combined withinactivity_durationon dev environments,since the persistent server connection counts as activity.
Implementation
Before starting commands, the server opens an SSH connection to each job and
creates a remote Unix-socket forward. Connections to
/run/dstack/server.sockinside the job are forwarded to the HTTP port of theserver managing the run.
DSTACK_SERVER_URLpoints clients to this socket.This uses the existing job SSH path, so the server does not need a public URL
or gateway. Health checks use the same socket path end to end; if the SSH
connection or socket becomes stale, the server closes and recreates the
tunnel. The tunnel is removed when the job terminates.
With multiple server replicas, a replica probes the socket and adopts an
existing healthy one instead of overwriting it, so jobs keep a single stable
server owner.