Skip to content
Merged
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
51 changes: 48 additions & 3 deletions mkdocs/docs/concepts/gateways.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ Provisioning...

A gateway requires a `domain` to be specified in the configuration before creation. The domain is used to generate service endpoints (e.g. `<run name>.<gateway domain>`).

Once the gateway is created and assigned a hostname, configure your DNS by adding a wildcard record for `*.<gateway domain>` (e.g. `*.example.com`). The record should point to the gateway's hostname and should be of type `A` if the hostname is an IP address (most cases), or of type `CNAME` if the hostname is another domain (some private gateways and Kubernetes).
Once the gateway is created and assigned a hostname, configure your DNS by adding a wildcard record for `*.<gateway domain>` (e.g. `*.example.com`). The record should point to the gateway's hostname and should be of type `A` if the hostname is an IP address (most cases), or of type `CNAME` if the hostname is another domain (load balancers, Kubernetes).

??? info "Project name interpolation"
You can use the `${{ run.project_name }}` variable to include the service’s project name in the domain name. This is especially useful when [exporting](exports.md) the gateway to multiple projects, as it ensures each importer receives a unique domain name.
Expand All @@ -76,6 +76,51 @@ You can create gateways with the `aws`, `azure`, `gcp`, or `kubernetes` backends
Gateways in `kubernetes` backend require an external load balancer. Managed Kubernetes solutions usually include a load balancer.
For self-hosted Kubernetes, you must provide a load balancer by yourself.

### Load balancer

The optional `load_balancer` property allows you to provision a load balancer in front of the gateway, which is useful for balancing requests between multiple gateway [replicas](#replicas), or for using certain [certificate](#certificate) types, such as AWS ACM.

Currently, only AWS Application Load Balancer (ALB) is supported:

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

```yaml
type: gateway
name: example-gateway
backend: aws
region: eu-west-1
domain: example.com
replicas: 2
load_balancer:
type: alb
certificate:
type: acm
arn: arn:aws:acm:eu-west-1:164099421079:certificate/3670388f-f43b-4872-aaf8-907b107a170d
```

</div>

??? info "Requirements"
An ALB gateway requires:

- The `aws` backend.
- Either `certificate: { type: acm, ... }` or `certificate: null`.
- A VPC with at least two subnets in different availability zones. If `public_ip: False`, subnets must be private and have a route to a NAT gateway.

The provisioned load balancer provides a hostname you can add to your DNS records. Replica hostnames do not need to be added to DNS.

<div class="termy">

```
$ dstack gateway list
NAME BACKEND HOSTNAME DOMAIN DEFAULT STATUS
example-gateway dstack-6t7i1b03-lb-338524206.eu-west-1.elb.amazonaws.com example.com ✓ running
replica=0 aws (eu-west-1) 34.246.162.72 running
replica=1 aws (eu-west-1) 52.18.222.190 running
```

</div>

### Certificate

By default, when you run a service with a gateway, `dstack` provisions an SSL certificate via Let's Encrypt for the configured domain. This automatically enables HTTPS for the service endpoint.
Expand All @@ -89,7 +134,7 @@ If you disable [public IP](#public-ip) (e.g. to make the gateway private) or if

* `lets-encrypt` (default) — Automatic certificates via [Let's Encrypt](https://letsencrypt.org/). Requires a [public IP](#public-ip).
* `acm` — Certificates managed by [AWS Certificate Manager](https://aws.amazon.com/certificate-manager/). AWS-only. TLS is terminated at the load balancer, not at the gateway, and HTTP requests are redirected to HTTPS by the ALB.
Requires a VPC with at least two subnets in different availability zones to provision a load balancer. If `public_ip: False`, subnets must be private and have a route to NAT gateway.
Implies `load_balancer: { type: alb }`.
* `null` — No certificate. Services will use HTTP.

### Public IP
Expand Down Expand Up @@ -155,7 +200,7 @@ 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.
To balance requests between gateway replicas, add DNS records for each replica, use a natively-supported [load balancer](#load-balancer), or set up a load balancer outside of `dstack`. Replica hostnames are displayed in `dstack` CLI and UI.

<div class="termy">

Expand Down
10 changes: 10 additions & 0 deletions mkdocs/docs/reference/dstack.yml/gateway.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,13 @@ Set to `null` to disable certificates (e.g. for [private gateways](../../concept
show_root_heading: false
type:
required: true

### `load_balancer`

=== "ALB"

#SCHEMA# dstack._internal.core.models.gateways.ALBGatewayLoadBalancer
overrides:
show_root_heading: false
type:
required: true
109 changes: 68 additions & 41 deletions src/dstack/_internal/core/backends/aws/compute.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,11 @@ class AWSGatewayBackendData(CoreModel):
lb_arn: str
tg_arn: str
listener_arn: str
http_listener_arn: Optional[str] = None # None for old gateways
"""Primary listener"""
http_listener_arn: Optional[str] = None
"""Listener for the HTTP->HTTPS redirection.
`None` for `certificate: null` gateways and for pre-0.20.17 gateways that have no redirection
"""


class AWSVolumeBackendData(CoreModel):
Expand Down Expand Up @@ -598,9 +602,8 @@ def create_gateway_load_balancer(
self,
configuration: GatewayLoadBalancerConfiguration,
) -> GatewayLoadBalancerData:
"""Creates an ALB, target group, and listeners for a gateway with an ACM certificate."""
assert configuration.certificate is not None
assert configuration.certificate.type == "acm"
"""Creates an ALB, target group, and listeners for a gateway."""
assert configuration.certificate is None or configuration.certificate.type == "acm"

ec2_client = self.session.client("ec2", region_name=configuration.region)
elb_client = self.session.client("elbv2", region_name=configuration.region)
Expand Down Expand Up @@ -636,7 +639,8 @@ def create_gateway_load_balancer(
)
if len(lb_subnets_ids) < 2:
raise ComputeError(
"Deploying gateway with ACM certificate requires at least two subnets in different AZs"
"Deploying a gateway with a load balancer requires at least two subnets"
" in different AZs"
)

# Using short names as LB and target groups have length limit of 32.
Expand Down Expand Up @@ -668,43 +672,66 @@ def create_gateway_load_balancer(
tg_arn = response["TargetGroups"][0]["TargetGroupArn"]
logger.debug("Created Target Group for gateway %s", configuration.gateway_name)

logger.debug("Creating HTTPS ALB listener for gateway %s...", configuration.gateway_name)
response = elb_client.create_listener(
LoadBalancerArn=lb_arn,
Protocol="HTTPS",
Port=443,
SslPolicy="ELBSecurityPolicy-2016-08",
Certificates=[
{"CertificateArn": configuration.certificate.arn},
],
DefaultActions=[
{
"Type": "forward",
"TargetGroupArn": tg_arn,
}
],
)
listener_arn = response["Listeners"][0]["ListenerArn"]
logger.debug("Created HTTPS ALB listener for gateway %s", configuration.gateway_name)
if configuration.certificate is not None:
logger.debug(
"Creating HTTPS ALB listener for gateway %s...", configuration.gateway_name
)
response = elb_client.create_listener(
LoadBalancerArn=lb_arn,
Protocol="HTTPS",
Port=443,
SslPolicy="ELBSecurityPolicy-2016-08",
Certificates=[
{"CertificateArn": configuration.certificate.arn},
],
DefaultActions=[
{
"Type": "forward",
"TargetGroupArn": tg_arn,
}
],
)
listener_arn = response["Listeners"][0]["ListenerArn"]
logger.debug("Created HTTPS ALB listener for gateway %s", configuration.gateway_name)

logger.debug("Creating HTTP ALB listener for gateway %s...", configuration.gateway_name)
response = elb_client.create_listener(
LoadBalancerArn=lb_arn,
Protocol="HTTP",
Port=80,
DefaultActions=[
{
"Type": "redirect",
"RedirectConfig": {
"Protocol": "HTTPS",
"Port": "443",
"StatusCode": "HTTP_301",
},
}
],
)
http_listener_arn = response["Listeners"][0]["ListenerArn"]
logger.debug("Created HTTP ALB listener for gateway %s", configuration.gateway_name)
logger.debug(
"Creating HTTP ALB listener for gateway %s...", configuration.gateway_name
)
response = elb_client.create_listener(
LoadBalancerArn=lb_arn,
Protocol="HTTP",
Port=80,
DefaultActions=[
{
"Type": "redirect",
"RedirectConfig": {
"Protocol": "HTTPS",
"Port": "443",
"StatusCode": "HTTP_301",
},
}
],
)
http_listener_arn = response["Listeners"][0]["ListenerArn"]
logger.debug("Created HTTP ALB listener for gateway %s", configuration.gateway_name)
else:
logger.debug(
"Creating HTTP ALB listener for gateway %s...", configuration.gateway_name
)
response = elb_client.create_listener(
LoadBalancerArn=lb_arn,
Protocol="HTTP",
Port=80,
DefaultActions=[
{
"Type": "forward",
"TargetGroupArn": tg_arn,
}
],
)
listener_arn = response["Listeners"][0]["ListenerArn"]
http_listener_arn = None
logger.debug("Created HTTP ALB listener for gateway %s", configuration.gateway_name)

return GatewayLoadBalancerData(
hostname=lb_dns_name,
Expand Down
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 @@ -67,5 +67,7 @@ def _get_gateway_configuration_excludes(

if configuration.default is None:
configuration_excludes["default"] = True
if configuration.load_balancer is None:
configuration_excludes["load_balancer"] = True

return configuration_excludes
25 changes: 24 additions & 1 deletion src/dstack/_internal/core/models/gateways.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,13 @@ class LetsEncryptGatewayCertificate(CoreModel):

class ACMGatewayCertificate(CoreModel):
type: Annotated[
Literal["acm"], Field(description="Certificates by AWS Certificate Manager (ACM)")
Literal["acm"],
Field(
description=(
"Certificates by AWS Certificate Manager (ACM)."
" Implies `load_balancer: { type: alb }`"
)
),
] = "acm"
arn: Annotated[
str, Field(description="The ARN of the wildcard ACM certificate for the domain")
Expand All @@ -52,6 +58,13 @@ class GatewayCertificate(RootModel[Annotated[AnyGatewayCertificate, Field(discri
pass


class ALBGatewayLoadBalancer(CoreModel):
type: Annotated[Literal["alb"], Field(description="AWS Application Load Balancer")] = "alb"


AnyGatewayLoadBalancer = Union[ALBGatewayLoadBalancer]


class GatewayConfiguration(CoreModel):
type: Literal["gateway"] = "gateway"
name: Annotated[Optional[str], Field(description="The gateway name")] = None
Expand Down Expand Up @@ -92,6 +105,16 @@ class GatewayConfiguration(CoreModel):
),
] = None
public_ip: Annotated[bool, Field(description="Allocate public IP for the gateway")] = True
load_balancer: Annotated[
Optional[AnyGatewayLoadBalancer],
Field(
discriminator="type",
description=(
"The load balancer configuration."
" Set to `type: alb` to front the gateway with an AWS Application Load Balancer"
),
),
] = None
certificate: Annotated[
Optional[AnyGatewayCertificate],
Field(
Expand Down
16 changes: 15 additions & 1 deletion src/dstack/_internal/core/services/gateways.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
from dstack._internal.core.models.gateways import GatewayConfiguration
from dstack._internal.core.models.gateways import (
ALBGatewayLoadBalancer,
AnyGatewayLoadBalancer,
GatewayConfiguration,
)
from dstack._internal.core.services.diff import ModelDiff, diff_models


Expand All @@ -9,3 +13,13 @@ def diff_gateway_configurations(old: GatewayConfiguration, new: GatewayConfigura
# default=None => default should stay unchanged => shouldn't be in the diff
reset={"default"} if new.default is None else {},
)


def get_effective_load_balancer(
configuration: GatewayConfiguration,
) -> AnyGatewayLoadBalancer | None:
if configuration.load_balancer is not None:
return configuration.load_balancer
if configuration.certificate is not None and configuration.certificate.type == "acm":
return ALBGatewayLoadBalancer()
return None
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
GatewayReplicaStatus,
GatewayStatus,
)
from dstack._internal.core.services.gateways import get_effective_load_balancer
from dstack._internal.server.background.pipeline_tasks.base import (
NOW_PLACEHOLDER,
Fetcher,
Expand Down Expand Up @@ -349,7 +350,7 @@ class _SubmittedResult:
async def _process_submitted_gateway(gateway_model: GatewayModel) -> _SubmittedResult:
configuration = gateways_services.get_gateway_configuration(gateway_model)
update_map: _GatewayUpdateMap = {}
if configuration.certificate is not None and configuration.certificate.type == "acm":
if get_effective_load_balancer(configuration) is not None:
try:
(
_,
Expand Down
12 changes: 12 additions & 0 deletions src/dstack/_internal/server/services/gateways/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -1204,6 +1204,18 @@ def _validate_gateway_configuration(configuration: GatewayConfiguration):
f"Cannot provision {replicas} gateway replicas. This server allows at most {GATEWAY_MAX_REPLICAS}"
)

if configuration.load_balancer is not None:
if configuration.load_balancer.type == "alb":
if configuration.backend != BackendType.AWS:
raise ServerClientError(
"`load_balancer: { type: alb }` is supported for `aws` backend only"
)
if configuration.certificate is not None and configuration.certificate.type != "acm":
raise ServerClientError(
"`load_balancer: { type: alb }` can only be used with `certificate: null` or"
" `certificate: { type: acm }`"
)

if configuration.certificate is not None:
if configuration.certificate.type == "lets-encrypt" and not configuration.public_ip:
raise ServerClientError(
Expand Down
3 changes: 3 additions & 0 deletions src/dstack/_internal/server/testing/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
from dstack._internal.core.models.gateways import (
GATEWAY_REPLICAS_DEFAULT,
AnyGatewayCertificate,
AnyGatewayLoadBalancer,
GatewayConfiguration,
GatewayReplicaConfiguration,
GatewayReplicaStatus,
Expand Down Expand Up @@ -669,6 +670,7 @@ async def create_gateway(
forbid_new_services: bool = False,
populate_configuration: bool = True,
certificate: Optional[AnyGatewayCertificate] = LetsEncryptGatewayCertificate(),
load_balancer: Optional[AnyGatewayLoadBalancer] = None,
hostname: Optional[str] = None,
backend_data: Optional[str] = None,
) -> GatewayModel:
Expand All @@ -689,6 +691,7 @@ async def create_gateway(
domain=wildcard_domain,
replicas=replicas,
certificate=certificate,
load_balancer=load_balancer,
).model_dump_json()
gateway = GatewayModel(
project_id=project_id,
Expand Down
Loading
Loading