Skip to content

One load balancer, several ports

The previous page built the pieces. Real stacks repeat them: one load balancer listening on several ports, each forwarding to its own target group. Only the port changes, so the repetition belongs in a function that returns a fragment.

def web_entrypoint(
    name: str,
    *,
    vpc_id: Expr[str],
    subnets: Expr[list[Expr[str]]],
    ports: Sequence[int],
    allowed_cidrs: Sequence[str],
    scheme: Literal["internet-facing", "internal"] = "internet-facing",
    certificate_arn: Expr[str] | None = None,
    redirect_http: bool = False,
    health_check_path: str = "/",
    tags: list[Expr[Tag]] | None = None,
) -> Resources:
    """One load balancer in front of several ports.

    Logical IDs: `{name}SecurityGroup`, `{name}LoadBalancer`, and per port `p`
    `{name}TargetGroup{p}` and `{name}Listener{p}`. With `redirect_http`, port 80 is opened and
    answered by `{name}HttpRedirect`.
    """
    if redirect_http and (certificate_arn is None or 80 in ports):
        raise ValueError("redirect_http needs a certificate_arn and no forwarded port 80")
    open_ports = [*ports, 80] if redirect_http else list(ports)
    group, lb = f"{name}SecurityGroup", f"{name}LoadBalancer"
    ingress: list[Expr[ec2.SecurityGroupIngressProperty]] = [
        {"IpProtocol": "tcp", "FromPort": p, "ToPort": p, "CidrIp": cidr}
        for p in open_ports
        for cidr in allowed_cidrs
    ]
    resources: Resources = {
        group: ec2.security_group(
            GroupDescription=f"{name} load balancer",
            VpcId=vpc_id,
            SecurityGroupIngress=ingress,
        ),
        lb: load_balancer(subnets=subnets, security_groups=[ref(group)], scheme=scheme, tags=tags),
    }
    for p in ports:
        tg = f"{name}TargetGroup{p}"
        resources[tg] = target_group(vpc_id=vpc_id, port=p, health_check_path=health_check_path, tags=tags)
        resources[f"{name}Listener{p}"] = listener(
            load_balancer=lb, target_group=tg, port=p, certificate_arn=certificate_arn,
        )
    if redirect_http:
        resources[f"{name}HttpRedirect"] = redirect_http_to_https(lb)
    return resources
web_entrypoint(
    "Web",
    vpc_id=vpc_id,
    subnets=private_subnets,
    ports=[8080, 9090],
    allowed_cidrs=["10.0.0.0/8"],
    scheme="internal",
)

creates these logical IDs (the rendered YAML shows the full stack):

Logical ID Resource
WebSecurityGroup the load balancer's security group
WebLoadBalancer the application load balancer
WebTargetGroup8080, WebTargetGroup9090 one target group per port
WebListener8080, WebListener9090 one listener per port

Choices worth noticing

  • allowed_cidrs has no default. A default of 0.0.0.0/0 would be convenient and would silently expose every internal load balancer someone forgot to restrict. Making the caller write it down is a cheap safeguard.
  • Everything the fragment creates refers only to itself. vpc_id and subnets come in as arguments (literals, parameters or imports), and every Ref inside points at another resource in the fragment. The tests check exactly that, so a fragment can be moved between stacks.
  • redirect_http=True opens port 80 and adds {name}HttpRedirect. It insists on a certificate, and on port 80 not already being forwarded somewhere, because the other combinations are mistakes. An error at generation time beats a surprise at deploy.
  • A loop, not a template. The per-port resources come from an ordinary for loop. There is no template language to learn, and the loop body is type-checked.

Attaching the backend

The Auto Scaling group (or service) that receives the traffic names the target groups it registers with, and lets the load balancer's group in:

resources = cfn.combine(
    web_entrypoint("Web", vpc_id=vpc_id, subnets=subnets, ports=[8080],
                   allowed_cidrs=["10.0.0.0/8"], scheme="internal"),
    {
        "AppAccess": allow_from("AppSecurityGroup", "WebSecurityGroup", 8080),
        # the group's TargetGroupARNs=[ref("WebTargetGroup8080")] connects the two
    },
)

cfn.combine guards against two fragments reusing a logical ID, which is the mistake most likely once a stack is built from several of these.