Skip to content

Load balancer building blocks

An application load balancer is four small resources that always appear together: the load balancer, a target group per backend, a listener per port that connects the two, and security groups so traffic can flow. Each one has a handful of properties you set the same way every time, which is what makes them worth wrapping.

These helpers are deliberately thin. They take plain arguments and return one generated resource, so the type checker still knows exactly what you built.

from collections.abc import Sequence
from typing import Literal

from cfn_typed_sumo.types import ec2
from cfn_typed_sumo.types import elasticloadbalancingv2 as elbv2

from cfn_typed import Expr, Resources, Tag, ref

The load balancer

def load_balancer(
    *,
    subnets: Expr[list[Expr[str]]],
    security_groups: list[Expr[str]],
    scheme: Literal["internet-facing", "internal"] = "internet-facing",
    idle_timeout: int = 60,
    tags: list[Expr[Tag]] | None = None,
) -> elbv2.LoadBalancer:
    """An application load balancer. No `Name`, so CloudFormation picks a unique one."""
    props: elbv2.LoadBalancerProps = {
        "Type": "application",
        "Scheme": scheme,
        "Subnets": subnets,
        "SecurityGroups": security_groups,
        "LoadBalancerAttributes": [{"Key": "idle_timeout.timeout_seconds", "Value": str(idle_timeout)}],
    }
    if tags:
        props["Tags"] = tags
    return elbv2.load_balancer(**props)
  • There is no Name. Leaving it out lets CloudFormation choose a unique one, so a replacement can be created before the old load balancer is deleted. A fixed name blocks that.
  • scheme is a Literal. "internal" or "internet-facing" autocompletes, and a typo such as "internet_facing" is an error before deploy.
  • idle_timeout is the one attribute almost everyone changes, so it is an argument and the rest stay at their defaults.

The target group

def target_group(
    *,
    vpc_id: Expr[str],
    port: int,
    protocol: Literal["HTTP", "HTTPS"] = "HTTP",
    health_check_path: str = "/",
    health_check_port: str = "traffic-port",
    deregistration_delay: int = 30,
    target_type: Literal["instance", "ip", "lambda"] = "instance",
    tags: list[Expr[Tag]] | None = None,
) -> elbv2.TargetGroup:
    """Where the traffic goes. A short deregistration delay makes deploys faster."""
    props: elbv2.TargetGroupProps = {
        "VpcId": vpc_id,
        "Port": port,
        "Protocol": protocol,
        "TargetType": target_type,
        "HealthCheckEnabled": True,
        "HealthCheckPath": health_check_path,
        "HealthCheckPort": health_check_port,
        "HealthyThresholdCount": 2,
        "TargetGroupAttributes": [
            {"Key": "deregistration_delay.timeout_seconds", "Value": str(deregistration_delay)},
        ],
    }
    if tags:
        props["Tags"] = tags
    return elbv2.target_group(**props)

The short deregistration_delay is a deliberate default. The AWS default of five minutes makes every deploy wait for connections that will never finish. Raise it for services with long-lived requests.

Listeners

def listener(
    *,
    load_balancer: str,
    target_group: str,
    port: int,
    certificate_arn: Expr[str] | None = None,
) -> elbv2.Listener:
    """Forward a port to a target group. A certificate turns it into an HTTPS listener."""
    props: elbv2.ListenerProps = {
        "LoadBalancerArn": ref(load_balancer),
        "Port": port,
        "Protocol": "HTTPS" if certificate_arn else "HTTP",
        "DefaultActions": [{"Type": "forward", "TargetGroupArn": ref(target_group)}],
    }
    if certificate_arn:
        props["Certificates"] = [{"CertificateArn": certificate_arn}]
        props["SslPolicy"] = "ELBSecurityPolicy-TLS13-1-2-2021-06"
    return elbv2.listener(**props)


def redirect_http_to_https(load_balancer: str, *, port: int = 80, to_port: int = 443) -> elbv2.Listener:
    """A listener that answers with a permanent redirect instead of forwarding."""
    return elbv2.listener(
        LoadBalancerArn=ref(load_balancer),
        Port=port,
        Protocol="HTTP",
        DefaultActions=[{
            "Type": "redirect",
            "RedirectConfig": {"Protocol": "HTTPS", "Port": str(to_port), "StatusCode": "HTTP_301"},
        }],
    )
  • A certificate decides the protocol. Passing certificate_arn makes the listener HTTPS and adds a TLS policy; leaving it out makes plain HTTP. One argument, so the two can never disagree.
  • Listeners and target groups are named by logical ID, not passed as objects. The helper turns them into ref(...). That is the same trade-off as everywhere else in this book: a short call site, with the name as a string.
  • redirect_http_to_https shows a different default action. The generated ListenerAction type has a field for each kind of action, and RedirectConfig is checked like everything else.

Letting traffic through

def allow_from(group: str, source_group: str, port: int) -> ec2.SecurityGroupIngress:
    """Let `source_group` (the load balancer's) reach `group` (the instances') on `port`."""
    return ec2.security_group_ingress(
        GroupId=ref(group),
        SourceSecurityGroupId=ref(source_group),
        IpProtocol="tcp",
        FromPort=port,
        ToPort=port,
    )

The instances (or tasks) behind the load balancer should accept traffic from the load balancer's security group, not from an address range. Then it stays correct when the load balancer's addresses change, and nothing else in the VPC can reach the port.