Microservices Platform with ECS
UPDATE: The work for this blog post inspired the creation of a tool named mu to simplify the management of your microservices in ECS. Learn more by visiting getmu.io!
Architecting 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 be 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 it’s 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.
Are you interested in building resilient applications in AWS? Stelligent is hiring!
Stelligent Amazon Pollycast
|