Reducing ALB Costs Using Host-Based Rules

In this article, we show an opportunity to reduce costs when multiple Application Load Balancers (ALB) are in use. By using one of the ALB’s features and minor changes to the environment, it’s possible to save some money on your AWS bill.

Francisco Collet

Background

As a Devops Engineer, I’m trying to improve things at every opportunity. Every place I go, while assessing the environment, my mind is already thinking about changes that could be made, either for improving performance, making things simpler or to optimize cost.

Bearing that in mind, while working on a micro-services project during a recent client engagement, I noticed that we could make use of the Application Load Balancer (ALB) Host-Based routing to reduce the number of ALBs we had provisioned. In this particular case, we had about 15 micro-services as part of the stack and each of those had a dedicated ALB to handle the requests. As you may be aware, each ALB has a cost associated to it, so by reducing the number of those resources, we are reducing the costs for running that environment.

The host based routing feature has been available for about two years. It would appear there are many companies that could benefit from this, and reduce their AWS billing by several hundreds of dollars with just a little work around that change.

And so, this article began…


Understanding the Host-Based Routing

Whenever you create a new ALB, it will be associated with a Full Qualified Domain Name (FQDN) like internal-stackname-bunch_of_random_chars.ap-southeast-2.elb.amazonaws.com. This FQDN is normally not useful in quickly identifying the service provided by the ALB, and might not work with your application config file, since it will be randmonly generated. Besides that, it’s not even associated with your company’s DNS domain.

In order to overcome this naming issue, an ALIAS record is normally created, allowing you to access a name like service1.mydomain.com.au. Through DNS naming resolution you’ll reach the service you intend, without the need to know that random name auto-generated by AWS. On the backside of the ALB, you’ll have two resources associated with it: a Listener which will indicate, basically, which port the ALB will listen to requests, and the Target Group, which will define where the ALB should send the requests (normally EC2 instances or a ECS Service).

This will work fine in cases where you have a single service being provided. However when you increase the number of services provided as part of your stack, and you start to replicate the components required by each service, there comes the opportunity to improve and reduce some AWS costs by having a single ALB to provide all services of your stack. You can even mix EC2 and ECS Services and make a single ALB providing both services.

The question that comes up is: considering that you have a single ALB to serve multiple services being provided by diferrent containers or EC2 instances, how does the ELB know to which service it should send the request, considering that all services are using the same port (normally 80 or 443)? And that’s when the Host-Based routing comes into play.

The Host-Based routing is a capability of the ALB that will redirect the requests to the right service based on the request Host-header. Basically, with a single ALB, if the request is being made to service1.mydomain.com.au it will send the requests to the Target Group associated with the Service 1. If the request is being made to service2.mydomain.com.au it will send the request to the Target Group associated with Service 2. It’s that simple.


And how can we achieve this?

The configuration to make it happen is quite simple and can be done with a few changes on your CloudFormation (CF) template. Below is a simple CF containing the resources needed by a regular ALB setup for two services.

Multiple ALB CloudFormation code:

```
Service1LoadBalancer:
    Type: AWS::ElasticLoadBalancingV2::LoadBalancer
    Properties:
        Scheme: internal
        SecurityGroups: 
            - !Ref Service1SecurityGroup
        Subnets: !Ref PrivateSubnets

Service1Listener:
    Type: AWS::ElasticLoadBalancingV2::Listener
    Properties:
        LoadBalancerArn: !Ref Service1LoadBalancer
        Protocol: HTTP
        Port: 80
        DefaultActions:
            - TargetGroupArn: !Ref Service1TargetGroup
            Type: forward

Service1TargetGroup:
    Type: AWS::ElasticLoadBalancingV2::TargetGroup
    Properties:
        Port: 80
        Protocol: HTTP
        VpcId: !Ref VpcId
        HealthCheckIntervalSeconds: 30
        HealthCheckPath: /status
        HealthCheckTimeoutSeconds: 15
        HealthyThresholdCount: 2
        UnhealthyThresholdCount: 6
        Matcher:
            HttpCode: 200
        TargetGroupAttributes:
            - Key: deregistration_delay.timeout_seconds
            Value: 30

Service1DNSRecord:
    Type: AWS::Route53::RecordSet
    Properties:
        HostedZoneId: !Ref HostedZoneID
        Name: !Sub service1.mydomain.com.au
        Type: A
        AliasTarget:
            DNSName: !Ref Service1LoadBalancer
            HostedZoneId: !Ref AWSAPJHostedZoneID



Service2LoadBalancer:
    Type: AWS::ElasticLoadBalancingV2::LoadBalancer
    Properties:
        Scheme: internal
        SecurityGroups: 
            - !Ref Service2SecurityGroup
        Subnets: !Ref PrivateSubnets

Service2Listener:
    Type: AWS::ElasticLoadBalancingV2::Listener
    Properties:
        LoadBalancerArn: !Ref Service2LoadBalancer
        Protocol: HTTP
        Port: 80
        DefaultActions:
            - TargetGroupArn: !Ref Service2TargetGroup
            Type: forward

Service2TargetGroup:
    Type: AWS::ElasticLoadBalancingV2::TargetGroup
    Properties:
        Port: 80
        Protocol: HTTP
        VpcId: !Ref VpcId
        HealthCheckIntervalSeconds: 30
        HealthCheckPath: /status
        HealthCheckTimeoutSeconds: 15
        HealthyThresholdCount: 2
        UnhealthyThresholdCount: 6
        Matcher:
            HttpCode: 200
        TargetGroupAttributes:
            - Key: deregistration_delay.timeout_seconds
            Value: 30

Service2DNSRecord:
    Type: AWS::Route53::RecordSet
    Properties:
        HostedZoneId: !Ref HostedZoneID
        Name: !Sub service2.mydomain.com.au
        Type: A
        AliasTarget:
            DNSName: !Ref Service2LoadBalancer
            HostedZoneId: !Ref AWSAPJHostedZoneID
```

With that CF you would have two services running behind their own ALB. I’ve supressed a few resources like an ECS Service that would be attached to the Target Group just because it’s not in the scope of this content, but keep in mind that you would have a resource like an ECS Task referencing the Target Group. Other values like SecurityGroup attached to the ALB Resource as well as some References (like HostedZoneID and PrivateSubnets) would be available on the Parameters Section of the CF.


Below you have the same stack as the above but using a single ALB with multple Host-Header based rules.

Single ALB CloudFormation code:

```
SharedLoadBalancer:
    Type: AWS::ElasticLoadBalancingV2::LoadBalancer
    Properties:
    Scheme: internal
    SecurityGroups: 
        - !Ref SecurityGroupInternalShared
    Subnets: !Ref PrivateSubnets

SharedListener:
    Type: AWS::ElasticLoadBalancingV2::Listener
    Properties:
        LoadBalancerArn: !Ref LoadBalancerInternalShared
        Protocol: HTTP
        Port: 80
        DefaultActions:
            - Type: fixed-response
            FixedResponseConfig:
                ContentType: text/plain
                MessageBody: "You've reached the the Load Balancer, but not matched any of the Host based rules"
                StatusCode: 200

Service1TargetGroup:
    Type: AWS::ElasticLoadBalancingV2::TargetGroup
    Properties:
        Port: 80
        Protocol: HTTP
        VpcId: !Ref VpcId
        HealthCheckIntervalSeconds: 30
        HealthCheckPath: /status
        HealthCheckTimeoutSeconds: 15
        HealthyThresholdCount: 2
        UnhealthyThresholdCount: 6
        Matcher:
            HttpCode: 200
        TargetGroupAttributes:
            - Key: deregistration_delay.timeout_seconds
            Value: 30

Service1ListenerRule:
    Type: AWS::ElasticLoadBalancingV2::ListenerRule
    Properties:
        Actions:
        - Type: forward
            TargetGroupArn: !Ref Service1TargetGroup
        Conditions:
        - Field: host-header
            Values: 
            - !Sub service1.mydomain.com.au
        ListenerArn: !Ref SharedListener
        Priority: 1

Service1DNSRecord:
    Type: AWS::Route53::RecordSet
    Properties:
        HostedZoneId: !Ref HostedZoneID
        Name: !Sub service1.mydomain.com.au
        Type: A
        AliasTarget:
            DNSName: !Ref SharedLoadBalancer
            HostedZoneId: !Ref AWSAPJHostedZoneID

Service2TargetGroup:
    Type: AWS::ElasticLoadBalancingV2::TargetGroup
    Properties:
        Port: 80
        Protocol: HTTP
        VpcId: !Ref VpcId
        HealthCheckIntervalSeconds: 30
        HealthCheckPath: /status
        HealthCheckTimeoutSeconds: 15
        HealthyThresholdCount: 2
        UnhealthyThresholdCount: 6
        Matcher:
            HttpCode: 200
        TargetGroupAttributes:
            - Key: deregistration_delay.timeout_seconds
            Value: 30

Service2ListenerRule:
    Type: AWS::ElasticLoadBalancingV2::ListenerRule
    Properties:
        Actions:
        - Type: forward
            TargetGroupArn: !Ref Service2TargetGroup
        Conditions:
        - Field: host-header
            Values: 
            - !Sub service2.mydomain.com.au
        ListenerArn: !Ref SharedListener
        Priority: 2

Service2DNSRecord:
    Type: AWS::Route53::RecordSet
    Properties:
        HostedZoneId: !Ref HostedZoneID
        Name: !Sub service2.mydomain.com.au
        Type: A
        AliasTarget:
            DNSName: !Ref SharedLoadBalancer
            HostedZoneId: !Ref AWSAPJHostedZoneID
```

As you can see the changes are really simple and you now have a single ALB with a default action, which in our case is to just present a message stating that none of the rules were matched. We could also have one of the services as the default action or provide a different HTTP response code, like 4xx or 5xx. On the CF template side, instead of having a Listener Resource, we now have a Listener Rule Resource, which defines the Target Group that will receive the request based on the defined rule (in our case, the host header).


What if I have a single FQDN for all my services?

Well… There are some cases where multiple services are provided using a single FQDN like www.mydomain.com.au/service1 and www.mydeomain.com.au/service2. In that case, we have good and bad news for you. The bad is that you won’t be able to use a single ALB with Host-Based rules. The good news is that you can still use a single ALB but, in that case, instead of using the host-header rule to define the destination of the requests, you’ll use path pattern based rules on the ALB so it can define the intented target for each requests.

The configuration are really similar with a small change on the rule condition. Instead of using host-header as the parameter for the Listener Rule Condition, you’ll use path-pattern and specify a pattern like /service1/* as the Value for that Condition.


But what are the costs benefits with this change?

Because of the way AWS charges for ALBs, it might not be a simple math calculation to define the new costs for your setup. If you have 10 ALBs, making the changes to use a single one with multiple rules in place will not bring your costs to 110 of the original costs. Below is the way that AWS charges the for the ALB. I’m using the current prices in Sydney Region, but this could change at any moment by AWS, so keep an eye on the ALB prices page.

  • $0.0252 per Application Load-Balancer per hour
  • $0.008 per LCU per hour

LCU measures the dimentions on which ALB processes your traffic(averaged over an hour). The dimensions measured are:

  • New connections: Number of newly established connections per second. Typically, many requests are sent per connection.
  • Active connections: Number of active connections per minute.
  • Processed bytes: The number of bytes processed by the load balancer in Gigabytes (GB) for HTTP(S) requests and responses.
  • Rule evaluations: It is the product of number of rules processed by your load balancer and the request rate. The first 10 processed rules are free (Rule evaluations = Request rate * (Number of rules processed - 10 free rules)

AWS charges you only on the dimension with the highest usage and an LCU includes:

  • 25 new connections per second.
  • 3,000 active connections per minute.
  • 1 GB per hour for EC2 instances, containers and IP addresses as targets and 0.4 GB per hour for Lambda functions as targets
  • 1,000 rule evaluations per second

With that information in mind, you can check the saving you could get by making this change on your stack. As an example, let’s take a look into a use case and how we can get to the costs for each implementation:


Use case:

You have 15 ALBs in place that receive about 100 new connections per second all together. Those ALBs have a total of 7000 active connections per minute, on average, and sending and receiving around 3GB of data to and from the containers associated with it per hour. Based on that usage and considering that those 15 ALBs would be up and running 30 days, your ALB costs would be something like:

Multiple ALBs Costs:
  • (((0.0252 * 24) * 30) * 15) = $272.25
Multiple ALBs LCU Costs:
  • New connections: 100 new connections per second would be 4 LCUs
  • Active connections: 7000 active connections per minute would be 2.33 LCUs
  • Processed bytes: 3GB of processed data per hour would be 3 LCUs
  • Rule evaluations: Because each ALB have a unique rule, your ALB is only evaluating the default rule, so, this number would be the same as the number of connections per second(100), so 0.1 LCU

Because only the dimension with the highest usage is considered, in this case you would be charged by the New connection dimension, which is 4.

  • ((0.008 * 4) * 24) * 30 = $23.04

In this scenario your total ALB cost would be around $295.29.


Let’s redo this calculation considering a single ALB with multiple rules now. For this example, we’ll consider that for each of the ALB that we are consolidating we’ll need 2 rules on the single ALB, so we’ll have a total of 31 rules in place, since the default rule also needs to be considered.

Single ALB Costs:
  • (((0.0252 * 24) * 30) * 15) = $18.15
Single ALB LCU Costs:
  • New connections: 100 new connections per second would be 4 LCUs
  • Active connections: 7000 active connections per minute would be 2.33 LCUs
  • Processed bytes: 3GB of processed data per hour would be 3 LCUs
  • Rule evaluations: Considering the worst case scenario where all 31 rules will be evaluated for all connections, we would have 3100 rules evaluated per second. The first 10 rules are free, so we end up with 3090 rules evaluated per second, which would result in 3.09 LCUs

Again, a single dimension is used and in our case, it would be the same as before.

  • ((0.008 * 4) * 24) * 30 = $23.04

In that particular case, by using the single ALB approach, our costs would be $41.19. That’s a $254 saving from the previous case.


Things to consider

Below are a few things that needs to be considered when planning for a change in your ALBs strategy:

  • If you have both internal and external facing services, you’ll need at least one ALB for each. You can’t mix internal and external services in the same ALB

  • You can see that in the Listener Rule Resource, there is a Priority property there. This will indictae to the ALB the order that the rules will be evaluated and and once it gets a match, it will quit evaluating rules and will send the request to the matched rule. Whenever possible, try to have your services with the highest percentage of requests at the top of the list, so you have less rules evaluated per second, which reduces the number of LCU used

  • For path pattern cases you also need to carefully consider rules ordering if you have path patterns like /service1/* and as /service1/images/*. You need to have the more specific path with a higest priority rule, otherwise requests intented to be sent to/service1/images/* Target Group will be sent to /service1/* Target Group

  • There is a limit of 100 rules associated with an ALB Listener. This number includes the default rule

  • Consider the Security Groups in use by each ALB before consolidating it. Even with the ingress rule being mostly using 80 and 443 ports, the source of the requests might be different between the services and you might not want to consolidate ALBs that are accessed by different sources.


Summary

As you could see from the example shown, the required change is not complex and can be done very quickly. Even though this change may not save you tens of thousands of dollars per month, depending on the existent infra-structure that you have, it is still cost-savings and at the very least allow for that money to be better allocated elsewhere.