Microservices Platform with ECS
In this post, we take a look at how to deploy a microservices architecture into AWS that can run at scale with both cost efficiency and high availability. Read on to find out more!
Join the DZone community and get the full member experience.
Join For FreeArchitecting applications with microservices is all the rage with developers right now, but running them at scale with cost efficiency and high availability can be a real challenge. In this post, we will address this challenge by looking at an approach to building microservices with Spring Boot and deploying them with CloudFormation on AWS EC2 Container Service (ECS) and Application Load Balancers (ALB). We will start with describing the steps to build the microservice, then walk through the platform for running the microservices, and finally deploy our microservice on the platform.
Spring Boot was chosen for the microservice development as it is a very popular framework in the Java community for building “stand-alone, production-grade Spring based Applications” quickly and easily. However, since ECS is just running Docker containers you can substitute your preferred development framework for Spring Boot and the platform described in this post will still be able to run your microservice.
This post builds upon a prior post called Automating ECS: Provisioning in CloudFormation that does an awesome job of explaining how to use ECS. If you are new to ECS, I’d highly recommend you review that before proceeding. This post will expand upon that by using the new Application Load Balancer that provides two huge features to improve the ECS experience:
- Target Groups: Previously in a “Classic” Elastic Load Balancer (ELB), all targets had to be able to handle all possible types of requests that the ELB received. Now with target groups, you can route different URLs to different target groups, allowing heterogeneous deployments. Specifically, you can have two target groups that handle different URLs (eg. /bananas and /apples) and use the ALB to route traffic appropriately.
- Per Target Ports: Previously in an ELB, all targets had to listen on the same port for traffic from the ELB. In ECS, this meant that you had to manage the ports that each container listened on. Additionally, you couldn’t run multiple instances of a given container on a single ECS container instance since they would have different ports. Now, each container can use an ephemeral port (next available assigned by ECS) making port management and scaling up on a single ECS container instance a non-issue.
The infrastructure we create will look like the diagram below. Notice that there is a single shared ECS cluster and a single shared ALB with a target group, EC2 Container Registry (ECR) and ECS Service for each microservice deployed to the platform. This approach enables a cost efficient solution by using a single pool of compute resources for all the services. Additionally, high availability is accomplished via an Auto Scaling Group (ASG) for the ECS container instances that spans multiple Availability Zones (AZ).

Setup Your Development Environment
You will need to install the Spring Boot CLI to get started. The recommended way is to use SDKMAN! for the installation. First install SDKMAN! with:
$ curl -s "https://get.sdkman.io" | bash
Then, install Spring Boot with:
$ sdk install springboot
Alternatively, you could install with Homebrew:
$ brew tap pivotal/tap
$ brew install springboot
Scaffold Your Microservice Project
For this example, we will be creating a microservice to manage bananas. Use the Spring Boot CLI to create a project:
$ spring init --build=gradle --package-name=com.stelligent --dependencies=web,actuator,hateoas -n Banana banana-service
This will create a new subdirectory named banana-service with the skeleton of a microservice in src/main/java/com/stelligent and a build.gradle file.
Develop the Microservice
Development of the microservice is a topic for an entire post of its own, but let’s look at a few important bits. First, the application is defined in BananaApplication:
@SpringBootApplication
public class BananaApplication {
public static void main(String[] args) {
SpringApplication.run(BananaApplication.class, args);
}
}
The @SpringBootApplication annotation marks the location to start component scanning and enables configuration of the context within the class.
Next, we have the controller class with contains the declaration of the REST routes.
@RequestMapping("/bananas")
@RestController
public class BananaController {
@RequestMapping(method = RequestMethod.POST)
public @ResponseBody BananaResource create(@RequestBody Banana banana)
{
// create a banana...
}
@RequestMapping(path = "/{id}", method = RequestMethod.GET)
public @ResponseBody BananaResource retrieve(@PathVariable long id)
{
// get a banana by its id
}
}
These sample routes handle a POST of JSON banana data to /bananas for creating a new banana, and a GET from /bananas/1234 for retrieving a banana by its ID. To view a complete implementation of the controller including support for POST, PUT, GET, PATCH, and DELETE as well as HATEOAS for links between resources, check out BananaController.java.
Additionally, to look at how to accomplish unit testing of the services, check out the tests created in BananaControllerTest.java using WebMvcTest, MockMvc, and Mockito.
Create Microservice Platform
The platform will consist of a separate CloudFormation stack that contains the following resources:
- VPC: To provide the network infrastructure to launch the ECS container instances into.
- ECS Cluster: The cluster that the services will be deployed into.
- Auto Scaling Group: To manage the ECS container instances that contain the compute resources for running the containers.
- Application Load Balancer: To provide load balancing for the microservices running in containers. Additionally, this provides service discovery for the microservices.
The template is available at platform.template. The AMIs used by the Launch Configuration for the EC2 Container Instances must be the ECS optimized AMIs:
Mappings:
AWSRegionToAMI:
us-east-1:
AMIID: ami-2b3b6041
us-west-2:
AMIID: ami-ac6872cd
eu-west-1:
AMIID: ami-03238b70
ap-northeast-1:
AMIID: ami-fb2f1295
ap-southeast-2:
AMIID: ami-43547120
us-west-1:
AMIID: ami-bfe095df
ap-southeast-1:
AMIID: ami-c78f43a4
eu-central-1:
AMIID: ami-e1e6f88d
Additionally, the EC2 Container Instances must have the ECS Agent configured to register with the newly created ECS Cluster:
ContainerInstances:
Type: AWS::AutoScaling::LaunchConfiguration
Metadata:
AWS::CloudFormation::Init:
config:
commands:
01_add_instance_to_cluster:
command: !Sub |
#!/bin/bash
echo ECS_CLUSTER=${EcsCluster} >> /etc/ecs/ecs.config
Next, an Application Load Balancer is created for the later stacks to register with:
EcsElb:
Type: AWS::ElasticLoadBalancingV2::LoadBalancer
Properties:
Subnets:
- !Ref PublicSubnetAZ1
- !Ref PublicSubnetAZ2
- !Ref PublicSubnetAZ3
EcsElbListener:
Type: AWS::ElasticLoadBalancingV2::Listener
Properties:
LoadBalancerArn: !Ref EcsElb
DefaultActions:
- Type: forward
TargetGroupArn: !Ref EcsElbDefaultTargetGroup
Port: '80'
Protocol: HTTP
Finally, we have a Gradle task in our build.gradle for upserting the platform CloudFormation stack based on a custom task named StackUpTask defined in buildSrc.
task platformUp(type: StackUpTask) {
region project.region
stackName "${project.stackBaseName}-platform"
template file("ecs-resources/platform.template")
waitForComplete true
capabilityIam true
if(project.hasProperty('keyName')) {
stackParams['KeyName'] = project.keyName
}
}
Simply run the following to create/update the platform stack:
$ gradle platformUp
Deploy Microservice
Once the platform stack has been created, there are two additional stacks to create for each microservice. First, there is a repo stack that creates the EC2 Container Registry (ECR) for the microservice. This stack also creates a target group for the microservice and adds the target group to the ALB with a rule for which URL path patterns should be routed to the target group.
The second stack is for the service and creates the ECS task definition based on the version of the docker image that should be run, as well as the ECS service which specifies how many tasks to run and the ALB to associate with.
The reason for the two stacks is that you must have the ECR provisioned before you can push a docker image to it, and you must have a docker image in the ECR before creating the ECS service. Ideally, you would create the repo stack once, then configure a CodePipeline job to continuously push changes to the code to ECR as new images and then updating the service stack to reference the newly pushed image.
The entire repo template is available at repo.template, an important new resource to check out is the ALB Listener Rule that provides the URL patterns that should be handled by the new target group that is created:
EcsElbListenerRule:
Type: AWS::ElasticLoadBalancingV2::ListenerRule
Properties:
Actions:
- Type: forward
TargetGroupArn: !Ref EcsElbTargetGroup
Conditions:
- Field: path-pattern
Values: [“/bananas”]
ListenerArn: !Ref EcsElbListenerArn
Priority: 1
The entire service template is available at service.template, but notice that the ECS Task Definition uses port 0 for HostPort. This allows for ephemeral ports that are assigned by ECS to remove the requirement for us to manage container ports:
MicroserviceTaskDefinition:
Type: AWS::ECS::TaskDefinition
Properties:
ContainerDefinitions:
- Name: banana-service
Cpu: '10'
Essential: 'true'
Image: !Ref ImageUrl
Memory: '300'
PortMappings:
- HostPort: 0
ContainerPort: 8080
Volumes: []
Next, notice how the ECS Service is created and associated with the newly created Target Group:
EcsService:
Type: AWS::ECS::Service
Properties:
Cluster: !Ref EcsCluster
DesiredCount: 6
DeploymentConfiguration:
MaximumPercent: 100
MinimumHealthyPercent: 0
LoadBalancers:
- ContainerName: microservice-exemplar-container
ContainerPort: '8080'
TargetGroupArn: !Ref EcsElbTargetGroupArn
Role: !Ref EcsServiceRole
TaskDefinition: !Ref MicroserviceTaskDefinition
Finally, we have a Gradle task in our service build.gradle for upserting the repo CloudFormation stack:
task repoUp(type: StackUpTask) {
region project.region
stackName "${project.stackBaseName}-repo-${project.name}"
template file("../ecs-resources/repo.template")
waitForComplete true
capabilityIam true
stackParams['PathPattern'] ='/bananas'
stackParams['RepoName'] = project.name
}
And then another to upsert the service CloudFormation stack:
task serviceUp(type: StackUpTask) {
region project.region
stackName "${project.stackBaseName}-service-${project.name}"
template file("../ecs-resources/service.template")
waitForComplete true
capabilityIam true
stackParams['ServiceDesiredCount'] = project.serviceDesiredCount
stackParams['ImageUrl'] = "${project.repoUrl}:${project.revision}"
mustRunAfter dockerPushImage
}
And finally, a task to coordinate the management of the stacks and the build/push of the image:
task deploy(dependsOn: ['dockerPushImage', 'serviceUp']) {
description "Upserts the repo stack, pushes a docker image, then upserts the service stack"
}
dockerPushImage.dependsOn repoUp
This then provides a simple command to deploy new or update existing microservices:
$ gradle deploy
Defining a similar build.gradle file in other microservices to deploy them to the same platform.
Blue/Green Deployment
When running the Gradle deploy, the existing service stack is updated to use a new task definition that references a new docker image in ECR. This CloudFormation update causes ECS to do a rolling replacement of the containers, launching new containers with the new image and killing containers with the old image.
However, if you are looking for a more traditional blue/green deployment, this could be accomplished by creating a new service stack (the green stack) with the new docker image, rather than updating the existing. The new stack would attach to the existing ALB target group at which point you could update the existing service stack (the blue stack) to no longer reference the ALB target group, which would take it out of service without killing the containers.
Next Steps
Stay tuned for future blog posts that builds on this platform by accomplishing service discovery in a more decoupled manner through the use of Eureka as a service registry, Ribbon as a service client, and Zuul as an edge router.
Additionally, this solution isn’t complete since there is no Continuous Delivery pipeline defined. Look for an additional post showing how to use CodePipeline to orchestrate the movement of changes to the microservice source code into production.
The code for the examples demonstrated in this post are located at https://github.com/stelligent/microservice-exemplar. Let us know if you have any comments or questions @stelligent.
Published at DZone with permission of Casey Lee, DZone MVB. See the original article here.
Opinions expressed by DZone contributors are their own.
Trending
-
Top 10 Engineering KPIs Technical Leaders Should Know
-
Managing Data Residency, the Demo
-
What Is mTLS? How To Implement It With Istio
-
Automating the Migration From JS to TS for the ZK Framework
Comments