Containers

Announcing the General Availability of Amazon ECS Service Extensions for AWS CDK

In late 2018, we first shared an introduction to using AWS Cloud Development Kit with Amazon ECS. In the almost two years since that article, countless developers have discovered that they enjoy deploying containers to Amazon Elastic Container Service (ECS) and AWS Fargate by writing infrastructure as code using a declarative SDK in their preferred programming language. At the time of writing this article, the @aws-cdk/aws-ecs module for programmatically defining containerized deployments for ECS is downloaded on NPM more than 110k times each week.

As a quick recap, AWS Cloud Development Kit is an SDK that provides infrastructure constructs. These constructs are simple classes you can instantiate by using the SDK in your preferred programming language. If you use CDK, you will likely be able to write your infrastructure as code using the exact same programming language you use to write your application. AWS CDK turns constructs into cloud resources using a two step process. First synthesis, which turns the SDK calls into a lower level metadata description, kind of like how a compiler turns your code into lower level machine language. Then deployment, which passes the metadata description to an infrastructure as code service like CloudFormation, Terraform, or Kubernetes, that can turn that infrastructure description into real resources in the cloud.

Levels of abstraction in AWS CDK

Constructs are at the core of AWS CDK, and they come at different levels of abstraction, from level one constructs up to level three constructs.

The basic “level one” constructs are a direct one to one mapping between that construct class and a single cloud resource. For example, the CDK construct CfnService in @aws-cdk/aws-ecs maps one to one to a AWS::ECS::Service resource in CloudFormation. But if you have ever handwritten CloudFormation for an ECS service, you know that this single resource alone is not enough to have a fully functioning service. You also need IAM roles for the service, a security group, and it is likely that you also want additional features such as a CloudWatch log group to store your service’s logs.

For this reason, CDK also comes with “level two” constructs. These constructs package up multiple level one cloud resources that are necessary to create a functioning infrastructure component in the cloud. For example @aws-cdk/aws-ecs provides the FargateService construct. This construct provisions its own IAM role, security group, and has helper methods for interacting with the service. The following code example shows a few of these handy helper methods in action:

const myService = new ecs.FargateService(stack, 'Service', {
  cluster,
  taskDefinition,
  desiredCount: 5
});

myService.connections.allowToDefaultPort(myRdsDatabase, 'Allow connections from myService to database');
mySnsTopic.grantPublish(myService.taskDefinition.taskRole);

In the above code snippet, you can see that it is easy to connect an AWS Fargate service to an Amazon RDS database without needing to write a security group rule by hand. It is easy to grant it permissions to publish to an SNS topic without needing to write an IAM policy by hand. The “level two” CDK construct takes care of those details for you.

CDK abstractions don’t stop there though. The next level of construct is “level three”. The goal of a construct at this level of abstraction is to package up multiple level two constructs and create everything that is necessary to deploy an entire architectural stack. For this reason, we released the @aws-cdk/aws-ecs-patterns module. This module provides handy constructs such as ApplicationLoadBalancedFargateService, which allows you to provision an entire load balanced container deployment with just a few lines of code:

const loadBalancedService = new ecsPatterns.ApplicationLoadBalancedFargateService(stack, 'Service', {
  cluster,
  memoryLimitMiB: 1024,
  taskImageOptions: {
    image: ecs.ContainerImage.fromRegistry('test'),
  },
  desiredCount: 2,
});

Depending on what level of expertise you have and what level of access to configuration you need, you can choose to implement your infrastructure as code at any level of construct. In general, the higher level constructs give you a faster path to success, with prebuilt patterns that have been designed to serve the most common deployment use cases. The lower level constructs give you more direct access to all the options and possible combinations of configuration, but they also require you to have a deeper understanding of the underlying pieces.

This tradeoff often meant that developers could start out quickly and easily with a level three construct like ApplicationLoadBalancedFargateService, but later they would discover some new features or services they wanted to utilize, such as AWS App Mesh. Because setting up App Mesh required extra configuration on the service, it would require developers to fallback to the level two constructs, and reimplement their infrastructure as code from scratch using the underlying FargateService. This time, they would need to configure their own load balancer instead of being able to benefit from the level three construct that they started out with.

Introducing @aws-cdk-containers/ecs-service-extensions

To solve this problem of needing to fallback to a lower level of abstraction, we have built a new CDK module called @aws-cdk-containers/ecs-service-extensions.

This new module provides a level three construct that is designed to grow with you as your needs grow. You can start out using it as a simple quick start for your first basic application. When there are more complicated ECS features you want to configure, such as a service mesh, you can easily add prebuilt extensions that configure these features for you. And if you find something more advanced that can’t be configured with the constructs out of the box, you can even write your own extension to do it for you, without rewriting your entire infrastructure as code.

Let’s look at a simple example first:

import { Container, Environment, HttpLoadBalancerExtension, Service, ServiceDescription } from '@aws-cdk-containers/ecs-service-extensions';

// Create an environment to deploy a service in.
const environment = new Environment(stack, 'production');

// Build out the service description
const webFrontendDescription = new ServiceDescription();
webFrontendDescription.add(new Container({
  cpu: 1024,
  memoryMiB: 2048,
  trafficPort: 80,
  image: ContainerImage.fromRegistry('myrepo/my-frontend'),
}));

// Expose the service to the public with a load balancer ingress
webFrontendDescription.add(new HttpLoadBalancerExtension());

// Implement the service description as a real service inside
// an environment.
const webService = new Service(stack, 'web', {
  environment: environment,
  serviceDescription: webFrontendDescription,
});

These lines of code implement a simple load balanced container deployment. The first construct used is the Environment construct. This construct automatically creates an isolated VPC, an ECS cluster, and any other global resources needed. Then we create a ServiceDescription. This class just gathers up all the specifications of the service you want to create. In this case, we are specifying a container to run, and adding the HttpLoadBalancerExtension. Last but not least, the Service class is instantiated. This turns the service description into a live service inside the environment.

Let’s imagine now that the application needs to be a two tier application with a service mesh to connect the web frontend to a backend API. Service meshes are very powerful for environments with multiple services, but they are often notoriously hard to configure.

In the case of Amazon ECS and AWS App Mesh, it is slightly easier to set up because the service mesh uses fully managed AWS services. Amazon ECS automatically keeps a list of your tasks in AWS Cloud Map, and the AWS App Mesh control plane watches that and automatically pushes configuration to your tasks in the service mesh.

However, you are still responsible for configuring a few pieces:

  • Adding an Envoy Proxy sidecar to your application and configuring it to proxy all the traffic.
  • Configure service mesh routes that allow each container’s Envoy Proxy to talk to other containers.
  • Configure AWS security group rules to open the actual network paths that Envoy Proxy will use to communicate from one instance of your application to another.

These configuration tasks are the perfect place for an ECS service extension. With an ECS service extension, you no longer need to worry about these things, because the extension will ensure that you have a well configured Envoy proxy in your task, and that the correct IAM policies, security group rules, and service mesh routes are created.

Let’s look at a code example of how simple this process of building a service mesh is when using ECS service extensions:

import { AppMeshExtension, Container, Environment, HttpLoadBalancerExtension, Service, ServiceDescription } from '@aws-cdk-containers/ecs-service-extensions';

const environment = new Environment(stack, 'production');
const mesh = new appmesh.Mesh(stack, 'my-mesh');

// Frontend web service
const webFrontendDescription = new ServiceDescription();
webFrontendDescription.add(new Container({
  cpu: 1024,
  memoryMiB: 2048,
  trafficPort: 80,
  image: ContainerImage.fromRegistry('myrepo/my-frontend'),
  environment: {
    // Tell the frontend service the service mesh URL of
    // the backend service, as an env variable.
    BACKEND_URL: 'http://api.production'
  },
}));
webFrontendDescription.add(new AppMeshExtension({ mesh }));
webFrontendDescription.add(new HttpLoadBalancerExtension());

const webService = new Service(stack, 'web', {
  environment: environment,
  serviceDescription: webFrontendDescription,
});

// Backend API service
const backendAPIDescription = new ServiceDescription();
backendAPIDescription.add(new Container({
  cpu: 1024,
  memoryMiB: 2048,
  trafficPort: 80,
  image: ContainerImage.fromRegistry('myrepo/my-backend'),
}));
backendAPIDescription.add(new AppMeshExtension({ mesh }));

const apiService = new Service(stack, 'api', {
  environment: environment,
  serviceDescription: backendAPIDescription,
});

// Connect the frontend to the backend service, this automatically
// opens up security group rules, and creates a service mesh route
// between the two services.
webService.connectTo(apiService);

Adding App Mesh to this architecture was as easy as importing the AppMeshExtension and adding it to the existing service description. There was no need to change out the constructs or reimplement things by falling back to the lower “level two“ constructs. The service mesh can be progressively added to the existing infrastructure as code. Last but not least, there is a helper method called connectTo, which automatically configured the service mesh and security group rules to connect the frontend tier to the backend tier.

This is only the beginning. Today @aws-cdk-containers/ecs-service-extensions ships with the following built-in extensions:

  • AppMeshExtension – adds an Envoy sidecar and creates AWS App Mesh resources for registering the tasks in a service mesh. It automatically configures security groups and service mesh routes to route traffic from one service to another service.
  • FireLensExtension – adds a FireLens-managed Fluent Bit sidecar to your task, configured to route your application logs to CloudWatch, with attached ECS metadata about the task they originated from.
  • XRayExtension – adds an AWS X-Ray daemon to your task, which gathers up trace spans emitted by your application and dispatches them to the cloud, so you can do end to end tracing of your distributed applications.
  • CloudwatchAgentExtension – runs the CloudWatch agent in your task, to gather up stats from the App Mesh Envoy proxy, or just from other general stat emitting processes in the task
  • HttpLoadBalancerExtension – creates a load balancer ingress for the service, which distributes traffic across as many replicas of the task as you need as you scale up.
  • ScaleOnCpuUtilization – automatically scales the number of tasks in the service to maintain a set target CPU consumption
  • AssignPublicIpExtension – for AWS Fargate tasks, it assigns a public IP address and optionally creates and syncs a Route 53 DNS record for directly public access to a front facing internet task.

These extensions can be added all at the same time, or in any combination based on the features that you want to enable. If you decide you don’t need a feature, you can easily remove the extension from the list of added extensions and all the corresponding local settings and resources created by that extension will be removed. You don’t have to chase down individual IAM role policies and feature specific settings to roll things back. Just remove the extension from the list of added extensions in your ServiceDescription and you are back to a clean, minimal state.

Create your own extension

One of the most interesting things about ECS service extensions is that you aren’t limited to the built-in extensions. You can build your own extension as well. The module has a simple extension interface that you can implement to make your own extension that functions right alongside the existing extensions. This works by giving you a few simple hooks to implement, which allow you to make changes to the ECS task definition, service properties, or make use of the created service.

Here is an example of creating an extension with a custom scaling pattern:

export class MyTarget50PercentCpuScaling extends ServiceExtension {
  constructor() {
    super('my-custom-autoscaling');
  }

  // This function modifies properties of the service prior
  // to construct creation.
  public modifyServiceProps(props: ServiceBuild) {
    return {
      ...props,

      // Initially launch 10 copies of the service
      desiredCount: 10
    } as ServiceBuild;
  }

  // This hook utilizes the resulting service construct
  // once it is created
  public useService(service: ecs.Ec2Service | ecs.FargateService) {
    const scalingTarget = service.autoScaleTaskCount({
      minCapacity: 5, // Min 5 tasks
      maxCapacity: 20 // Max 20 tasks
    });

    scalingTarget.scaleOnCpuUtilization('TargetCpuUtilization50', {
      targetUtilizationPercent: 50,
      scaleInCooldown: cdk.Duration.seconds(60),
      scaleOutCooldown: cdk.Duration.seconds(60),
    });
  }
}

Now that this extension is written, you can easily reuse it across your own services, or even share it across your entire organization so that everyone is using the same scaling logic. Developers can drop in the MyTarget50PercentCpuScaling extension rather than needing to manually configure their service’s scaling behavior. As you can imagine, the possibilities are endless. Just a few ideas to start with:

  • An extension that adds a required security or monitoring sidecar.
  • An extension that automatically adds a set of common shared database secrets or environment variables that all services need.
  • An extension that builds a custom CloudWatch dashboard for the service, or creates a default set of recommended metric alarms for the service.

All of these things and more can be accomplished by writing a custom extension, and that logic can then be easily reused across all your services. Most importantly any future updates to the logic can be centrally managed by changing the extension in one place, and all services that have adopted the extension can receive those changes.

Conclusion

It’s still day one for @aws-cdk-containers/ecs-service-extensions and we want to hear your feedback on what works well, what doesn’t, and what things you’d like to see added. If this concept of service extensions for ECS is interesting to you, please check out the package on Github or the CDK TypeScript documentation. Feel free to open an issue or even a PR on the repository if you have ideas about more extensions that you’d like to see added to the default list of built-in extensions.

Nathan Peck

Nathan Peck

Developer advocate for serverless containers at Amazon Web Services, working on Amazon Elastic Container Service and AWS Fargate. Likes building startup architecture and microservices.