Skip to content

Commit 763bc16

Browse files
authored
Implement replicated services (#986)
* Rework store and registry routes * Refactor nginx service * Refactor SSH tunnel, add nginx tests * Fix openai store, update readme * Start client refactoring, nginx config is broken * Drop nginx fallback * Refactor nginx to return 503 without replicas * Normalize domain name * Test gateway client with replicas * Run services end to end * Improve services logging * Add replicas to ServiceConfiguration, add replica_num to JobSpec * Drop JobSpec.pool_name, spawn replicas on submit * Show provisioning status per job, show replicas in run table * Test and debug replicas replacement * Add tests for replicas * Address review comments
1 parent 7736bf6 commit 763bc16

38 files changed

Lines changed: 1276 additions & 803 deletions

File tree

gateway/README.md

Lines changed: 24 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -10,20 +10,28 @@
1010

1111
## Development
1212

13-
1. Build the wheel:
14-
```
15-
python -m build .
16-
```
17-
2. Upload the wheel:
18-
```shell
19-
scp dist/dstack_gateway-0.0.0-py3-none-any.whl ubuntu@${GATEWAY}:/tmp/
20-
```
21-
3. Install the wheel:
22-
```
23-
ssh ubuntu@${GATEWAY} "pip install --force-reinstall /tmp/dstack_gateway-0.0.0-py3-none-any.whl"
24-
```
25-
4. Run the tunnel and the gateway:
26-
```
27-
ssh -L 9001:localhost:8000 -t ubuntu@${GATEWAY} "uvicorn dstack.gateway.main:app"
28-
```
13+
1. Provision a gateway through dstack:
14+
```shell
15+
dstack gateway create --backend aws --region us-east-1 --domain my.wildcard.domain.com
16+
```
17+
2. Extract the project key from the sqlite to the file
18+
3. Build gateway locally and deploy it:
19+
```shell
20+
HOST=ubuntu@x.my.wildcard.domain.com
21+
ID_RSA=/path/to/the/project/key
22+
WHEEL=dstack_gateway-0.0.0-py3-none-any.whl
23+
24+
python -m build .
25+
scp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i "${ID_RSA}" "./dist/${WHEEL}" "${HOST}":/tmp/
26+
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i "${ID_RSA}" "${HOST}" "/bin/sh /home/ubuntu/dstack/update.sh /tmp/${WHEEL} dev"
27+
```
28+
4. Open SSH tunnel to the gateway:
29+
```shell
30+
ssh -L 9001:localhost:8000 -i "${ID_RSA}" "${HOST}"
31+
```
2932
5. Visit the gateway docs page at http://localhost:9001/docs
33+
34+
To follow logs, use the command:
35+
```shell
36+
journalctl -u dstack.gateway.service -f
37+
```

gateway/src/dstack/gateway/auth/routes.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
from fastapi import APIRouter, Depends, HTTPException, Security
22
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
33

4-
from dstack.gateway.services.auth import AuthProvider, get_auth
4+
from dstack.gateway.core.auth import AuthProvider, get_auth
55

66
router = APIRouter()
77

88

9+
# TODO(egor-s): support Authorization header alternative for web browsers
10+
11+
912
@router.get("/{project}")
1013
async def get_auth(
1114
project: str,
File renamed without changes.
File renamed without changes.
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
import importlib.resources
2+
import logging
3+
import subprocess
4+
import tempfile
5+
from asyncio import Lock
6+
from pathlib import Path
7+
from typing import Annotated, Dict, Literal, Union
8+
9+
import jinja2
10+
from pydantic import BaseModel, Field
11+
12+
from dstack.gateway.common import run_async
13+
from dstack.gateway.errors import GatewayError
14+
15+
CONFIGS_DIR = Path("/etc/nginx/sites-enabled")
16+
GATEWAY_PORT = 8000
17+
logger = logging.getLogger(__name__)
18+
19+
20+
class SiteConfig(BaseModel):
21+
type: str
22+
domain: str
23+
24+
def render(self) -> str:
25+
template = importlib.resources.read_text(
26+
"dstack.gateway.resources.nginx", f"{self.type}.jinja2"
27+
)
28+
return jinja2.Template(template).render(
29+
**self.model_dump(),
30+
gateway_port=GATEWAY_PORT,
31+
)
32+
33+
34+
class ServiceConfig(SiteConfig):
35+
type: Literal["service"] = "service"
36+
project: str
37+
service_id: str
38+
auth: bool
39+
servers: Dict[str, str] = {}
40+
41+
42+
class EntrypointConfig(SiteConfig):
43+
type: Literal["entrypoint"] = "entrypoint"
44+
proxy_path: str
45+
46+
47+
class Nginx(BaseModel):
48+
"""
49+
Nginx keeps track of registered domains, updates nginx config and issues SSL certificates.
50+
Its internal state could be serialized to a file and restored from it using pydantic.
51+
"""
52+
53+
configs: Dict[
54+
str, Annotated[Union[ServiceConfig, EntrypointConfig], Field(discriminator="type")]
55+
] = {}
56+
_lock: Lock = Lock()
57+
58+
async def register_service(self, project: str, service_id: str, domain: str, auth: bool):
59+
config_name = self.get_config_name(domain)
60+
conf = ServiceConfig(
61+
project=project,
62+
service_id=service_id,
63+
domain=domain,
64+
auth=auth,
65+
)
66+
67+
async with self._lock:
68+
if config_name in self.configs:
69+
raise GatewayError(f"Domain {domain} is already registered")
70+
71+
logger.debug("Registering service domain %s", domain)
72+
73+
await run_async(self.run_certbot, domain)
74+
await run_async(self.write_conf, conf.render(), config_name)
75+
self.configs[config_name] = conf
76+
77+
logger.info("Service domain %s is registered now", domain)
78+
79+
async def register_entrypoint(self, domain: str, prefix: str):
80+
config_name = self.get_config_name(domain)
81+
conf = EntrypointConfig(
82+
domain=domain,
83+
proxy_path=prefix,
84+
)
85+
86+
async with self._lock:
87+
if config_name in self.configs:
88+
raise GatewayError(f"Domain {domain} is already registered")
89+
90+
logger.debug("Registering entrypoint domain %s", domain)
91+
92+
await run_async(self.run_certbot, domain)
93+
await run_async(self.write_conf, conf.render(), config_name)
94+
self.configs[config_name] = conf
95+
96+
logger.info("Entrypoint domain %s is registered now", domain)
97+
98+
async def unregister_domain(self, domain: str):
99+
config_name = self.get_config_name(domain)
100+
101+
async with self._lock:
102+
if config_name not in self.configs:
103+
raise GatewayError("Domain is not registered")
104+
105+
logger.debug("Unregistering domain %s", domain)
106+
107+
await run_async(sudo_rm, CONFIGS_DIR / config_name)
108+
await run_async(self.reload)
109+
self.configs.pop(config_name)
110+
111+
logger.info("Domain %s is unregistered now", domain)
112+
113+
async def add_upstream(self, domain: str, server: str, replica_id: str):
114+
config_name = self.get_config_name(domain)
115+
116+
async with self._lock:
117+
if config_name not in self.configs:
118+
raise GatewayError(f"Domain {domain} is not registered")
119+
120+
logger.debug("Adding upstream %s to domain %s", server, domain)
121+
122+
conf = self.configs[config_name].model_copy(deep=True)
123+
conf.servers[replica_id] = server
124+
await run_async(self.write_conf, conf.render(), config_name)
125+
self.configs[config_name] = conf
126+
127+
logger.debug("Upstream %s is added to domain %s", server, domain)
128+
129+
async def remove_upstream(self, domain: str, replica_id: str):
130+
config_name = self.get_config_name(domain)
131+
132+
async with self._lock:
133+
if config_name not in self.configs:
134+
raise GatewayError(f"Domain {domain} is not registered")
135+
if replica_id not in self.configs[config_name].servers:
136+
raise GatewayError(f"Upstream {replica_id} is not registered")
137+
138+
logger.debug("Removing upstream %s from domain %s", replica_id, domain)
139+
140+
conf = self.configs[config_name].model_copy(deep=True)
141+
conf.servers.pop(replica_id)
142+
await run_async(self.write_conf, conf.render(), config_name)
143+
self.configs[config_name] = conf
144+
145+
logger.debug("Upstream %s is removed from domain %s", replica_id, domain)
146+
147+
@staticmethod
148+
def reload():
149+
cmd = ["sudo", "systemctl", "reload", "nginx.service"]
150+
r = subprocess.run(cmd)
151+
if r.returncode != 0:
152+
raise GatewayError("Failed to reload nginx")
153+
154+
@classmethod
155+
def write_conf(cls, conf: str, conf_name: str):
156+
"""Update config and reload nginx. Rollback changes on error."""
157+
conf_path = CONFIGS_DIR / conf_name
158+
old_conf = conf_path.read_text() if conf_path.exists() else None
159+
160+
sudo_write(conf_path, conf)
161+
try:
162+
cls.reload()
163+
except GatewayError:
164+
# rollback changes
165+
if old_conf is not None:
166+
sudo_write(conf_path, old_conf)
167+
else:
168+
sudo_rm(conf_path)
169+
raise
170+
171+
@staticmethod
172+
def run_certbot(domain: str):
173+
logger.info("Running certbot for %s", domain)
174+
cmd = ["sudo", "certbot", "certonly"]
175+
cmd += ["--non-interactive", "--agree-tos", "--register-unsafely-without-email"]
176+
cmd += ["--nginx", "--domain", domain]
177+
r = subprocess.run(cmd, capture_output=True)
178+
if r.returncode != 0:
179+
raise GatewayError(f"Certbot failed:\n{r.stderr.decode()}")
180+
181+
@staticmethod
182+
def get_config_name(domain: str) -> str:
183+
return f"443-{domain}.conf"
184+
185+
186+
def sudo_write(path: Path, content: str):
187+
with tempfile.NamedTemporaryFile("w") as temp:
188+
temp.write(content)
189+
temp.flush()
190+
temp.seek(0)
191+
r = subprocess.run(["sudo", "cp", "-p", temp.name, path])
192+
if r.returncode != 0:
193+
raise GatewayError("Failed to copy file as sudo")
194+
195+
196+
def sudo_rm(path: Path):
197+
r = subprocess.run(["sudo", "rm", path])
198+
if r.returncode != 0:
199+
raise GatewayError("Failed to remove file as sudo")
File renamed without changes.

0 commit comments

Comments
 (0)