Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions frontend/src/locale/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@
"region_description": "Select a region",
"default": "Default",
"default_checkbox": "Turn on default",
"hostname": "Hostname",
"external_ip": "External IP",
"wildcard_domain": "Wildcard domain",
"wildcard_domain_description": "Specify the wildcard domain mapped to the external IP.",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,15 @@ export const useColumnsDefinitions = ({ loading, projectName, onDeleteClick, onE
{
id: 'type',
header: t('gateway.edit.backend'),
cell: (gateway: IGateway) => gateway.backend,
cell: (gateway: IGateway) =>
gateway.replicas.length > 0 ? gateway.replicas.map((r, i) => <div key={i}>{r.backend}</div>) : null,
},

{
id: 'region',
header: t('gateway.edit.region'),
cell: (gateway: IGateway) => gateway.region,
cell: (gateway: IGateway) =>
gateway.replicas.length > 0 ? gateway.replicas.map((r, i) => <div key={i}>{r.region}</div>) : null,
},

{
Expand All @@ -46,9 +48,13 @@ export const useColumnsDefinitions = ({ loading, projectName, onDeleteClick, onE
},

{
id: 'external_ip',
header: t('gateway.edit.external_ip'),
cell: (gateway: IGateway) => gateway.ip_address,
id: 'hostname',
header: t('gateway.edit.hostname'),
cell: (gateway: IGateway) => {
if (gateway.hostname) return gateway.hostname;
if (gateway.replicas.length > 0) return gateway.replicas.map((r, i) => <div key={i}>{r.hostname}</div>);
return null;
},
},

{
Expand Down
8 changes: 8 additions & 0 deletions frontend/src/types/gateway.d.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
declare interface IGatewayReplica {
hostname: string,
backend: string,
region: string,
}

declare interface IGateway {
backend: string,
name: string,
project_name?: string,
ip_address: string,
instance_id: string,
region:string
hostname?: string,
wildcard_domain?: string
default: boolean
replicas: IGatewayReplica[],
created_at?: number,
}

Expand Down
43 changes: 43 additions & 0 deletions mkdocs/docs/concepts/gateways.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,49 @@ domain: example.com

</div>

### Replicas

A gateway can have multiple replicas for improved availability.

<div editor-title="gateway.dstack.yml">

```yaml
type: gateway
name: example-gateway

backend: aws
region: eu-west-1

domain: example.com

certificate: null
replicas: 2
```

</div>

To balance requests between gateway replicas, add DNS records for each replica or set up a load balancer outside of `dstack`. Replica hostnames are displayed in `dstack` CLI and UI.

<div class="termy">

```shell
$ dstack gateway list
NAME BACKEND HOSTNAME DOMAIN DEFAULT STATUS
example-gateway example.com ✓ running
replica=0 aws (eu-west-1) 34.244.128.46
replica=1 aws (eu-west-1) 18.201.201.174
```

</div>

!!! warning "Experimental"
Replicated gateways are an experimental feature and currently have limitations:

- Changing the number of replicas or redeploying replicas is not supported.
- HTTPS is not supported. Use an external load balancer for TLS termination.
- An unavailable gateway replica prevents any new services or service replicas from being added.
- All replicas are bound to the same backend and region.

!!! info "Reference"
For all gateway configuration options, refer to the [reference](../reference/dstack.yml/gateway.md).

Expand Down
4 changes: 4 additions & 0 deletions src/dstack/_internal/cli/services/configurators/gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,10 @@ def th(s: str) -> str:
configuration_table.add_row(th("Region"), plan.spec.configuration.region)
configuration_table.add_row(th("Domain"), domain)

if plan.spec.configuration.replicas is not None:
assert isinstance(plan.spec.configuration.replicas, int)
configuration_table.add_row(th("Replicas"), str(plan.spec.configuration.replicas))

console.print(configuration_table)
console.print()

Expand Down
32 changes: 28 additions & 4 deletions src/dstack/_internal/cli/utils/gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,15 +95,39 @@ def get_gateways_table(
# Ignore errors in case future server versions introduce more interpolation variables
exception_type=None,
)
row = {

gateway_row = {
"NAME": name,
"BACKEND": format_backend(gateway.configuration.backend, gateway.configuration.region),
"HOSTNAME": gateway.hostname,
"DOMAIN": domain,
"DEFAULT": "✓" if gateway.default else "",
"STATUS": gateway.status,
"CREATED": format_date(gateway.created_at),
"ERROR": gateway.status_message,
}
add_row_from_dict(table, row)
if gateway.hostname is not None:
gateway_row["HOSTNAME"] = gateway.hostname
if len(gateway.replicas) == 0:
# replicas not yet created, or it's a pre-0.20.25 server without replica support
gateway_row["BACKEND"] = format_backend(
gateway.configuration.backend, gateway.configuration.region
)
gateway_row["HOSTNAME"] = gateway_row.get("HOSTNAME", gateway.ip_address)
if len(gateway.replicas) == 1:
# compact display for single-replica gateway
gateway_row["BACKEND"] = format_backend(
gateway.replicas[0].backend, gateway.replicas[0].region
)
gateway_row["HOSTNAME"] = gateway_row.get("HOSTNAME", gateway.replicas[0].hostname)
add_row_from_dict(table, gateway_row)

if len(gateway.replicas) > 1:
for replica in gateway.replicas:
replica_row = {
"NAME": f" replica={replica.replica_num}",
"BACKEND": format_backend(replica.backend, replica.region),
"HOSTNAME": replica.hostname,
"CREATED": format_date(replica.created_at),
}
add_row_from_dict(table, replica_row, style="secondary")

return table
2 changes: 2 additions & 0 deletions src/dstack/_internal/core/compatibility/gateways.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,5 +41,7 @@ def _get_gateway_configuration_excludes(

if configuration.router is None:
configuration_excludes["router"] = True
if configuration.replicas is None:
configuration_excludes["replicas"] = True

return configuration_excludes
30 changes: 25 additions & 5 deletions src/dstack/_internal/core/models/gateways.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
from dstack._internal.core.models.routers import AnyGatewayRouterConfig
from dstack._internal.utils.tags import tags_validator

GATEWAY_REPLICAS_DEFAULT = 1


class GatewayStatus(str, Enum):
SUBMITTED = "submitted"
Expand Down Expand Up @@ -90,6 +92,13 @@ class GatewayConfiguration(CoreModel):
" Set to `null` to disable. Defaults to `type: lets-encrypt`"
),
] = LetsEncryptGatewayCertificate()
replicas: Annotated[

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's put a technical upper bound on the number of provisioned replicas, e.g. 20 (to avoid provisioning 1000 replicas in a loop).

Optional[int],
Field(
description=f"The number of gateway replicas. Defaults to `{GATEWAY_REPLICAS_DEFAULT}`",
ge=1,
),
] = None
tags: Annotated[
Optional[Dict[str, str]],
Field(
Expand All @@ -109,6 +118,14 @@ class GatewaySpec(CoreModel):
configuration_path: Optional[str] = None


class GatewayReplica(CoreModel):
hostname: str
replica_num: int
backend: BackendType
region: str
created_at: datetime.datetime


class Gateway(CoreModel):
# TODO(0.21): Make `id` required.
id: Optional[uuid.UUID] = None
Expand All @@ -121,14 +138,13 @@ class Gateway(CoreModel):
status: GatewayStatus
status_message: Optional[str]
hostname: Optional[str]
"""`hostname` is the IP address or hostname the user should set up the domain for.
Could be the same as `ip_address` but also different, for example a gateway behind ALB.
"""Hostname of the load balancer.
Unset if there is no load balancer, in which case users are expected to point the gateway's
wildcard domain name to `replicas[i].hostname`.
"""
ip_address: Optional[str]
"""`ip_address` is the IP address of the gateway instance."""
instance_id: Optional[str]
wildcard_domain: Optional[str]
default: bool
replicas: list[GatewayReplica] = []
backend: Optional[BackendType] = None
"""`backend` duplicates a configuration field on the top level for backward compatibility
with 0.19.x clients that expect it to be required.
Expand All @@ -139,6 +155,10 @@ class Gateway(CoreModel):
with 0.19.x clients that expect it to be required.
Remove after 0.21.
"""
ip_address: Optional[str] = None
"""Deprecated in favor of `replicas[i].hostname`, only set for pre-0.20.25 clients."""
instance_id: Optional[str] = None
"""Deprecated, unused, kept for pre-0.20.25 clients."""


class GatewayPlan(CoreModel):
Expand Down
Loading
Loading