AWS Serverless Pattern: ECS Fargate Cluster Secure Access via VPC Link
Tags: aws, architecture, serverless, devops
Published: 2022-03-25 15:38:31


In this blog post, we'll look into an AWS serverless pattern on how we can expose a private HTTP endpoint to public users without exposing the internal resources to a public subnet.

Before we dive into the implementation details I would recommend taking a moment to explore the basics of the below services, features, and resources in case you are not already familiar.

- [Amazon API Gateway](https://docs.aws.amazon.com/apigateway/latest/developerguide/welcome.html)
- [Amazon ECS on Fargate](https://docs.aws.amazon.com/AmazonECS/latest/developerguide/AWS_Fargate.html)
- [VPC Link](https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-vpc-links.html)
- [Application Load Balancer](https://docs.aws.amazon.com/elasticloadbalancing/latest/application/application-load-balancer-getting-started.html)

---

## How Does The Pattern Work?
This pattern allows integration of public API gateway endpoint to a private HTTP endpoint. In this case, the private endpoint is Application Load Balancer (ALB) with an ECS Fargate cluster behind it. API Gateway integration between the public API endpoint and the private ALB is enabled using VPC Link. It allows to build a secure pattern without exposing the private subnet resources and can be accessed only via a VPC Link.

![API Gateway and private HTTP endpoint integration](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/lfeir2prgucm1tw3qhhp.png)

---
 

## Implementation Details

Last year, I had submitted this pattern and a sample reference project using AWS [SAM (Serverless Application Model)](https://aws.amazon.com/serverless/sam/) to [ServerlessLand](https://serverlessland.com/).
You can refer to the below sample projects for reference implementations using AWS SAM (Serverless Application Model) and [Terraform](https://www.terraform.io/).

- [SAM reference](https://serverlessland.com/patterns/apigw-vpclink-pvt-alb).
- [Terraform reference](https://serverlessland.com/patterns/apigw-vpclink-pvt-alb-terraform).

These projects provision the following resources. It is assumed that the VPC and subnets are already configured with the required network routes. And, the VPC and subnet ids are provided as inputs.

- Required Security Groups.
- ECS Fargate cluster with service and task definitions.
- Private Application Load Balancer with appropriate listener and target group.
- VPC Link.
- API gateway integration between the API endpoint and the private ALB via the VPC Link.

Refer to the below links for detailed instructions on how to deploy the resources and test it out.

- https://github.com/aws-samples/serverless-patterns/tree/main/apigw-vpclink-pvt-alb-terraform

- https://github.com/aws-samples/serverless-patterns/tree/main/apigw-vpclink-pvt-alb

And, save costs by deleting the created resources once you are done!


---

## LocalStack  - Your local AWS cloud
So, why did I mention about LocalStack here?

I used it for my initial configuration and testing of the Terraform sample project. We would need the LocalStack Pro version for this project as it uses certain services which are not available in the community version.

LocalStack allows you to simulate the AWS cloud environment locally without provisioning anything on AWS.

There are a lot of services available in the community version, which is free! The 'Pro' version will cost you some money but it might still be worth it if you use AWS services for development regularly and the services which are not available in the community version.

Follow along the below steps if you want to run the Terraform project.

- [Install Terraform](https://learn.hashicorp.com/tutorials/terraform/install-cli).
- [Install and configure LocalStack](https://docs.localstack.cloud/get-started/#get-localstack-up-and-running). [Pick whatever installation option works best for you.]

Configure the "aws" provider to use fake credentials and custom URL pointing to the LocalStack environment running locally. The below file is pretty much the same as in https://github.com/aws-samples/serverless-patterns/blob/main/apigw-vpclink-pvt-alb-terraform/main.tf except the provider configuration in line #14. I used the below configuration to include multiple services as a reference but you can configure only the ones you need.

```
# Required providers configuration
terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 4.0.0"
    }
  }

  required_version = ">= 1.0.11"
}

# AWS provider configuration
provider "aws" {
  #profile = "default"
  #region  = "us-east-1"
  region                      = "us-east-1"
  access_key                  = "mock_access_key"
  secret_key                  = "mock_secret_key"
  s3_use_path_style           = true
  skip_credentials_validation = true
  skip_metadata_api_check     = true
  skip_requesting_account_id  = true

  endpoints {
    apigatewayv2   = "http://localhost:4566"
    apigateway     = "http://localhost:4566"
    cloudformation = "http://localhost:4566"
    cloudwatch     = "http://localhost:4566"
    dynamodb       = "http://localhost:4566"
    es             = "http://localhost:4566"
    firehose       = "http://localhost:4566"
    iam            = "http://localhost:4566"
    kinesis        = "http://localhost:4566"
    lambda         = "http://localhost:4566"
    route53        = "http://localhost:4566"
    redshift       = "http://localhost:4566"
    s3             = "http://localhost:4566"
    secretsmanager = "http://localhost:4566"
    ses            = "http://localhost:4566"
    sns            = "http://localhost:4566"
    sqs            = "http://localhost:4566"
    ssm            = "http://localhost:4566"
    stepfunctions  = "http://localhost:4566"
    sts            = "http://localhost:4566"
    ec2            = "http://localhost:4566"
    ecs            = "http://localhost:4566"
    elbv2          = "http://localhost:4566"
    elb            = "http://localhost:4566"
  }
}

# Load balancer security group. CIDR and port ingress can be changed as required.
resource "aws_security_group" "lb_security_group" {
  description = "LoadBalancer Security Group"
  vpc_id = var.vpc_id
  ingress {
    description      = "Allow from anyone on port 80"
    from_port        = 80
    to_port          = 80
    protocol         = "tcp"
    cidr_blocks      = ["0.0.0.0/0"]
  }
}
resource "aws_security_group_rule" "sg_ingress_rule_all_to_lb" {
  type	= "ingress"
  description = "Allow from anyone on port 80"
  from_port         = 80
  to_port           = 80
  protocol          = "tcp"
  cidr_blocks       = ["0.0.0.0/0"]
  ipv6_cidr_blocks  = ["::/0"]
  security_group_id = aws_security_group.lb_security_group.id
}

# Load balancer security group egress rule to ECS cluster security group.
resource "aws_security_group_rule" "sg_egress_rule_lb_to_ecs_cluster" {
  type	= "egress"
  description = "Target group egress"
  from_port         = 80
  to_port           = 80
  protocol          = "tcp"
  security_group_id = aws_security_group.lb_security_group.id
  source_security_group_id = aws_security_group.ecs_security_group.id
}

# ECS cluster security group.
resource "aws_security_group" "ecs_security_group" {
  description = "ECS Security Group"
  vpc_id = var.vpc_id
  egress {
    description      = "Allow all outbound traffic by default"
    from_port        = 0
    to_port          = 0
    protocol         = "-1"
    cidr_blocks      = ["0.0.0.0/0"]
  }
}

# ECS cluster security group ingress from the load balancer.
resource "aws_security_group_rule" "sg_ingress_rule_ecs_cluster_from_lb" {
  type	= "ingress"
  description = "Ingress from Load Balancer"
  from_port         = 80
  to_port           = 80
  protocol          = "tcp"
  security_group_id = aws_security_group.ecs_security_group.id
  source_security_group_id = aws_security_group.lb_security_group.id
}

# Create the internal application load balancer (ALB) in the private subnets.
resource "aws_lb" "ecs_alb" {
  load_balancer_type = "application"
  internal = true
  subnets = var.private_subnets
  security_groups = [aws_security_group.lb_security_group.id]
}

# Create the ALB target group for ECS.
resource "aws_lb_target_group" "alb_ecs_tg" {
  port        = 80
  protocol    = "HTTP"
  target_type = "ip"
  vpc_id      = var.vpc_id
}

# Create the ALB listener with the target group.
resource "aws_lb_listener" "ecs_alb_listener" {
  load_balancer_arn = aws_lb.ecs_alb.arn
  port              = "80"
  protocol          = "HTTP"
  default_action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.alb_ecs_tg.arn
  }
}

# Create the ECS Cluster and Fargate launch type service in the private subnets
resource "aws_ecs_cluster" "ecs_cluster" {
  name  = "demo-ecs-cluster"
}

resource "aws_ecs_service" "demo-ecs-service" {
  name            = "demo-ecs-svc"
  cluster         = aws_ecs_cluster.ecs_cluster.id
  task_definition = aws_ecs_task_definition.ecs_taskdef.arn
  desired_count   = 2
  deployment_maximum_percent = 200
  deployment_minimum_healthy_percent = 50
  enable_ecs_managed_tags = false
  health_check_grace_period_seconds = 60
  launch_type = "FARGATE"
  depends_on      = [aws_lb_target_group.alb_ecs_tg, aws_lb_listener.ecs_alb_listener]

  load_balancer {
    target_group_arn = aws_lb_target_group.alb_ecs_tg.arn
    container_name   = "web"
    container_port   = 80
  }

  network_configuration {
    security_groups = [aws_security_group.ecs_security_group.id]
    subnets = var.private_subnets
  }
}

# Create the ECS Service task definition. 
# 'nginx' image is being used in the container definition.
# This image is pulled from the docker hub which is the default image repository.
# ECS task execution role and the task role is used which can be attached with additional IAM policies to configure the required permissions.
resource "aws_ecs_task_definition" "ecs_taskdef" {
  family = "service"
  container_definitions = jsonencode([
    {
      name      = "web"
      image     = "nginx"
      essential = true
      portMappings = [
        {
          containerPort = 80
          protocol      = "tcp"
        }
      ]
    }
  ])
  cpu       = 512
  memory    = 1024
  execution_role_arn = aws_iam_role.ecs_task_exec_role.arn
  task_role_arn = aws_iam_role.ecs_task_role.arn
  requires_compatibilities = ["FARGATE"]
  network_mode             = "awsvpc"
}
resource "aws_iam_role" "ecs_task_exec_role" {
  name = "ecs_task_exec_role"
  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Action = "sts:AssumeRole"
        Effect = "Allow"
        Principal = {
          Service = "ecs-tasks.amazonaws.com"
        }
      },
    ]
  })
}
resource "aws_iam_role" "ecs_task_role" {
  name = "ecs_task_role"
  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Action = "sts:AssumeRole"
        Effect = "Allow"
        Principal = {
          Service = "ecs-tasks.amazonaws.com"
        }
      },
    ]
  })
}

# Create the VPC Link configured with the private subnets. Security groups are kept empty here, but can be configured as required.
resource "aws_apigatewayv2_vpc_link" "vpclink_apigw_to_alb" {
  name        = "vpclink_apigw_to_alb"
  security_group_ids = []
  subnet_ids = var.private_subnets
}

# Create the API Gateway HTTP endpoint
resource "aws_apigatewayv2_api" "apigw_http_endpoint" {
  name          = "serverlessland-pvt-endpoint"
  protocol_type = "HTTP"
}

# Create the API Gateway HTTP_PROXY integration between the created API and the private load balancer via the VPC Link.
# Ensure that the 'DependsOn' attribute has the VPC Link dependency.
# This is to ensure that the VPC Link is created successfully before the integration and the API GW routes are created.
resource "aws_apigatewayv2_integration" "apigw_integration" {
  api_id           = aws_apigatewayv2_api.apigw_http_endpoint.id
  integration_type = "HTTP_PROXY"
  integration_uri  = aws_lb_listener.ecs_alb_listener.arn

  integration_method = "ANY"
  connection_type    = "VPC_LINK"
  connection_id      = aws_apigatewayv2_vpc_link.vpclink_apigw_to_alb.id
  payload_format_version = "1.0"
  depends_on      = [aws_apigatewayv2_vpc_link.vpclink_apigw_to_alb, 
                    aws_apigatewayv2_api.apigw_http_endpoint, 
                    aws_lb_listener.ecs_alb_listener]
}

# API GW route with ANY method
resource "aws_apigatewayv2_route" "apigw_route" {
  api_id    = aws_apigatewayv2_api.apigw_http_endpoint.id
  route_key = "ANY /{proxy+}"
  target = "integrations/${aws_apigatewayv2_integration.apigw_integration.id}"
  depends_on  = [aws_apigatewayv2_integration.apigw_integration]
}

# Set a default stage
resource "aws_apigatewayv2_stage" "apigw_stage" {
  api_id = aws_apigatewayv2_api.apigw_http_endpoint.id
  name   = "$default"
  auto_deploy = true
  depends_on  = [aws_apigatewayv2_api.apigw_http_endpoint]
}

# Generated API GW endpoint URL that can be used to access the application running on a private ECS Fargate cluster.
output "apigw_endpoint" {
  value = aws_apigatewayv2_api.apigw_http_endpoint.api_endpoint
    description = "API Gateway Endpoint"
}
```

Go ahead and give it a whirl.

Happy Building!

_**Note:** The security group rules and some of the configurations related to the resources created in this sample project may not be production-grade and should be used for reference purposes only._

---

## References
Further reading to learn more on these type of serverless integration patterns:

- https://serverlessland.com/
- https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-private.html
- https://aws.amazon.com/blogs/compute/configuring-private-integrations-with-amazon-api-gateway-http-apis/
- https://localstack.cloud/

---