ALB: Layer 7 Load Balancer Basics and Your First HTTP Release
Through the chapters so far, we built the VPC and S3. In this chapter, Tasuku is exposed to the outside world for the first time. However, it won't be over HTTPS. The ALB we build in this chapter accepts only HTTP (port 80). We'll issue an SSL certificate for Tasuku's domain and terminate HTTPS on port 443 after we cover Route 53 and ACM in the next chapter.
6.1. Layer 7 load balancer basics
ALB (Application Load Balancer) is a load balancer that operates at Layer 7 (the application layer) of the OSI reference model.1 What sets it apart from an L4 load balancer (NLB), which only looks as far as TCP/UDP port numbers, is that it can decide where to route a request after reading the HTTP method, path, and header contents. Tasuku will eventually route between two kinds of applications, web and api, by domain. Since that routing decision needs L7 information, Tasuku's load balancer is an ALB.
An ALB is made up of three components. A listener determines which port and protocol the ALB accepts requests on. A target group is a unit that groups together the servers a request is forwarded to (in this series, ECS Fargate tasks). A health check is the mechanism by which the ALB periodically confirms whether each target belonging to a target group is actually able to process requests.
At this point in the chapter, we won't create either a target group or a health check, because the ECS Fargate tasks that requests would be forwarded to don't exist yet. This is the same approach as in Chapter 3, where we left the actual IAM roles in a preview table and deferred the implementation to the chapter that uses them.
| Component | This chapter | Chapter that creates it |
|---|---|---|
| Listener | Created (port 80, fixed response) | — |
| Target group | Not created | Chapter 10 (2: one for web, one for api) |
| Health check | Concept only | Chapter 10 |
6.2. The design decision to release over HTTP first
Ideally, the ALB's listener should use HTTPS (port 443). We'd rather not stay exposed over plain HTTP for long. Even so, this chapter sticks to HTTP alone, because using port 443 requires an SSL certificate, and issuing a certificate requires domain verification. Domain verification is Route 53's job, which we cover in the next chapter, and it isn't done yet at this point in the chapter.
So in this chapter we create only a port 80 listener and check what the ALB looks like right after it starts up. Once the domain and SSL certificate are ready in the next chapter, we'll add a port 443 listener and switch port 80 over to redirecting exclusively to port 443. The order is: release over HTTP first, then replace it with HTTPS.
ALB costs accrue along two axes: charges for the hours it's running, and LCU (Load Balancer Capacity Unit) charges based on the volume of requests processed.2 Following the NAT Gateway in Chapter 4, this chapter adds another resource that's "billed by the hour even when you're not using it." We won't state specific prices here since they're subject to change. If you try this yourself, check the current pricing on the AWS pricing page and delete the resource once you're done using it.
6.3. The ALB security group
The security group chain diagram shown in section 4.7 of Chapter 4 shows what things look like once the series is complete. The ALB's security group will eventually accept port 443 from the internet. At this point in the chapter, though, the ALB itself has only a port 80 listener. The security group we create in this chapter allows only port 80 too. We'll add the port 443 permission in the next chapter, when we add that listener.
resource "aws_security_group" "alb" {
name = "${local.name_prefix}-alb-sg"
description = "SG for the ALB. Allows only port 80 (port 443 is added in Chapter 7)"
vpc_id = aws_vpc.main.id
tags = {
Name = "${local.name_prefix}-alb-sg"
}
}
resource "aws_vpc_security_group_ingress_rule" "alb_http" {
security_group_id = aws_security_group.alb.id
cidr_ipv4 = "0.0.0.0/0"
from_port = 80
to_port = 80
ip_protocol = "tcp"
description = "Port 80 from the internet (HTTP)"
}
The reason we write ingress as the standalone aws_vpc_security_group_ingress_rule resource instead of the inline ingress argument is the same reason we split S3's settings into several standalone resources in Chapter 5. In the current provider, the inline arguments on aws_security_group are deprecated, and the recommended approach is to use a separate resource per CIDR block.3
We haven't defined any egress (outbound) rules on this security group. The ALB only needs to communicate with its registered targets once a target group exists.4 At this point in the chapter, no target group exists yet, so the ALB has no outbound destination. Leaving egress empty is a temporary state that applies only to this chapter. From Chapter 8 onward, once the security group for the ECS Fargate tasks exists, we'll add egress rules to this security group pointing at it.
When you create a new aws_security_group, AWS normally adds an allow-all egress rule by default. However, when you create this resource newly through Terraform, if you don't write any egress block, Terraform itself revokes that default rule.5 The fact that this security group's egress is left empty isn't an oversight — it's a deliberate state that takes this behavior into account.
6.4. The access log S3 bucket and its bucket policy
In section 5.2 of Chapter 5, we said the ALB's access log bucket would be covered in this chapter, because we couldn't work through the bucket policy design at a stage when the ALB that sends the logs didn't exist yet. Here, we make good on that promise.
To send ALB access logs to S3, the bucket needs a bucket policy that allows ELB (Elastic Load Balancing) to write to it. There are two ways to grant this permission. One is to grant it to the region-specific ELB account ID assigned to each region. For ap-northeast-1, this account ID is 582318560864.6 The other is to grant it to logdelivery.elasticloadbalancing.amazonaws.com, a service principal dedicated to log delivery. The former continues to be supported for regions that existed before August 2022, but switching to the latter, which uses the service principal, is now recommended.7 This chapter adopts the recommended latter approach.
resource "aws_s3_bucket_policy" "alb_logs" {
bucket = aws_s3_bucket.alb_logs.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "AllowALBAccessLogDelivery"
Effect = "Allow"
Principal = {
Service = "logdelivery.elasticloadbalancing.amazonaws.com"
}
Action = "s3:PutObject"
Resource = "${aws_s3_bucket.alb_logs.arn}/alb/AWSLogs/${data.aws_caller_identity.current.account_id}/*"
}
]
})
}
This policy doesn't add the extra hardening of narrowing things down to the source ALB with an aws:SourceArn condition. AWS's official documentation itself presents this condition not as mandatory, but as a recommendation separate from the base policy.8 The reason we didn't add the condition isn't purely a security judgment call. If we used the ALB's ARN in aws:SourceArn, this bucket policy would end up referencing the ALB itself. That would turn this chapter's Terraform code into a mutually referential relationship: "the ALB's access_logs attribute references this bucket, and the bucket policy references that ALB." The circumstances differ from the "chicken and egg" problem covered in section 5.3 of Chapter 5, where the backend configuration ends up requiring a bucket that's under that same backend's management, but this is likewise a case where the ordering fails to line up. To fix the ordering — bucket policy first, ALB itself second — as a Terraform dependency, we make the dependency on this bucket policy explicit with depends_on in the aws_lb resource definition. We'll show the implementation in the next section.
For encryption, we use only SSE-S3. The ALB's access log bucket doesn't support SSE-KMS encryption.9 Unlike the attachments bucket in Chapter 5, we don't configure versioning on this bucket. Versioning is useful on the attachments bucket because it lets you recover objects that would otherwise be lost to an overwrite or deletion. Access log objects are written out by the ALB as new files at regular intervals, so the same key is never overwritten. Since no overwrite ever happens, versioning's recovery benefit doesn't apply either. This is where the idea from section 5.1 of Chapter 5 — that the scope in which a principle applies changes depending on its use case — shows up again.
6.5. Terraform implementation
We write everything covered so far into a new file called alb.tf. Even though the ALB's access log bucket is an S3 resource, we group it into this chapter's alb.tf, the file that creates the ALB. This follows the same policy we've kept since Chapter 2, of grouping each chapter's main resources into a single file.
The security group is as shown in the previous section. Next, we create the access log bucket. As in Chapter 5, we split the bucket itself, Block Public Access, encryption, lifecycle, and the bucket policy into separate resources. We reference data.aws_caller_identity.current as-is, from where it was already declared in iam.tf in Chapter 3.
resource "aws_s3_bucket" "alb_logs" {
bucket = "${local.name_prefix}-alb-logs-${data.aws_caller_identity.current.account_id}"
tags = {
Name = "${local.name_prefix}-alb-logs"
}
}
resource "aws_s3_bucket_public_access_block" "alb_logs" {
bucket = aws_s3_bucket.alb_logs.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
resource "aws_s3_bucket_server_side_encryption_configuration" "alb_logs" {
bucket = aws_s3_bucket.alb_logs.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
}
}
resource "aws_s3_bucket_lifecycle_configuration" "alb_logs" {
bucket = aws_s3_bucket.alb_logs.id
rule {
id = "expire-alb-logs"
status = "Enabled"
filter {}
expiration {
days = 180
}
}
}
The lifecycle configuration automatically deletes logs older than 180 days. Since access logs keep accumulating day after day, we set up, in advance, a policy of not retaining them indefinitely, the same as we did with the attachments bucket in Chapter 5 (though there's a difference: the attachments bucket targets the expiration of noncurrent versions, whereas here we delete the current object itself after 180 days).
The bucket policy is as shown in the previous section. With the bucket now ready, we can create the ALB itself and its listener.
resource "aws_lb" "main" {
name = "${local.name_prefix}-alb"
internal = false
load_balancer_type = "application"
security_groups = [aws_security_group.alb.id]
subnets = [for k, v in aws_subnet.this : v.id if local.subnets[k].tier == "public"]
drop_invalid_header_fields = true
access_logs {
bucket = aws_s3_bucket.alb_logs.id
prefix = "alb"
enabled = true
}
tags = {
Name = "${local.name_prefix}-alb"
}
depends_on = [aws_s3_bucket_policy.alb_logs]
}
resource "aws_lb_listener" "http" {
load_balancer_arn = aws_lb.main.arn
port = "80"
protocol = "HTTP"
default_action {
type = "fixed-response"
fixed_response {
content_type = "text/plain"
status_code = "200"
message_body = "Tasuku ALB is up (${var.env})"
}
}
}
For subnets, we pass the two public subnets created in Chapter 4, referencing them directly from aws_subnet.this. We set internal to false because this ALB receives inbound traffic from the internet.
drop_invalid_header_fields is an attribute that determines whether the ALB strips out malformed HTTP headers, and its default value is false.10 Enabling it helps defend against attacks that originate from headers that shouldn't be interpreted as legitimate requests (HTTP desync attacks).11 This chapter's listener returns only a fixed response, so the actual damage from an attack would be small, but this setting is an attribute of the ALB itself, and it carries over even after we start forwarding to target groups in later chapters. There's ample reason to enable it.
We don't set idle_timeout explicitly, leaving it at its default of 60 seconds.12 Since this chapter's listener doesn't do anything that holds a connection open for a long time, there's no reason to change the default.
The listener's default_action is just fixed-response. The values you can specify for content_type are limited to five: text/plain, text/css, text/html, application/javascript, and application/json.13 In this chapter, we choose text/plain, which needs no formatting. message_body has an upper limit of 1,024 characters,14 but that leaves plenty of room for the short confirmation message this chapter returns.
Finally, we add the values that later chapters will reference to outputs.tf.
output "alb_arn" {
description = "The ALB's ARN (Chapter 6)"
value = aws_lb.main.arn
}
output "alb_dns_name" {
description = "The ALB's DNS name (referenced in Chapter 6's verification and Chapter 7's Route 53 record design)"
value = aws_lb.main.dns_name
}
output "alb_zone_id" {
description = "The ALB's canonical hosted zone ID (referenced as the zone_id of Chapter 7's Route 53 alias record)"
value = aws_lb.main.zone_id
}
output "alb_listener_http_arn" {
description = "The port 80 listener's ARN (referenced in Chapter 7's move to HTTPS and Chapter 10's added listener rules)"
value = aws_lb_listener.http.arn
}
output "alb_security_group_id" {
description = "The ALB security group's ID (referenced as the ingress source for Chapter 8's ECS task security group)"
value = aws_security_group.alb.id
}
We'll use alb_dns_name and alb_zone_id when we create the Route 53 alias record in the next chapter. alb_security_group_id is referenced in Chapter 8, where we create the security group for the ECS Fargate tasks, as the source that's allowed ingress from the ALB.
6.6. Verification
We run terraform fmt -check -recursive -diff and terraform validate to confirm the syntax and consistency with the provider schema.15 This series' verification goes only as far as validate; plan and apply aren't included because they require AWS credentials. We haven't changed the verification level described in section 5.9 of Chapter 5 for this chapter either.
If you actually run apply in your own AWS environment, you can get the ALB's DNS name with terraform output alb_dns_name and access it with curl, which returns a 200 status along with the body Tasuku ALB is up (dev). Provisioning the ALB takes a few minutes. If the access log permissions are set up correctly, an empty test file named ELBAccessLogTestFile is created in the log bucket. This isn't an actual log file — it's a marker for confirming the permissions.16
Summary
- An ALB consists of three components — listener, target group, and health check — but this chapter creates only the listener, responding with a fixed response that has no forwarding destination
- Since the domain and SSL certificate aren't ready yet, port 443 is deferred to the next chapter, and this chapter releases over HTTP (port 80) only
- The ALB security group's egress is left empty at this point in the chapter, since no target group exists yet
- For the access log bucket's policy, we adopted the currently recommended log-delivery service principal approach
- To keep the bucket policy and the ALB itself from referencing each other, we made the ordering explicit with
depends_oninstead of using anaws:SourceArncondition
Next steps
- Chapter 7: Route 53 and ACM: Publishing a Custom Domain with DNS Delegation and HTTPS: Verifying the domain, issuing the SSL certificate, and moving to port 443
- Chapter 5: S3: Bucket Design and Best Practices for Managing tfstate: Bucket design principles and best practices for managing tfstate
- Series table of contents: The structure of all 12 chapters and how to read through them