AWS Elastic Container Service (ECS)

Amazon Elastic Container Service (Amazon ECS) is a scalable, high-performance container orchestration service that supports Docker containers and allows you to easily run and scale containerized applications on AWS. ECS eliminates the need for you to install and operate your own container orchestration software, manage and scale a cluster of virtual machines, or schedule containers on those virtual machines.

On This Page

Overview

Pulumi Crosswalk for AWS ECS simplifies deploying containerized applications into ECS and managing all of the associated resources. This includes simple support for load-balanced container services and one-off tasks, in addition to managing the clusters and associated scaling, network, and security policies. This includes ECS Fargate – the simplest option, alleviating the need to manage the cluster’s servers themselves – in addition to ECS classic – providing full control over the underlying EC2 machine resources that power your cluster.

An alternative to ECS is Amazon’s Elastic Kubernetes Service (EKS). Similar ECS, EKS lets you operate containerized applications in a cluster. EKS tends to be more complex to provision and manage, but has the added advantage of using the industry standard container orchestrator, Kubernetes, and therefore can help with portability between clouds and on-premises configurations. Please see the Pulumi Crosswalk for AWS EKS documentation for more information about using EKS.

Creating a Load Balanced ECS Service

To run a Docker container in ECS using default network and cluster settings, we can use the awsx.ecs.FargateService class. Because we want to access this container over port 80 using a stable address, we will use a load balancer:

import * as awsx from "@pulumi/awsx";

// Create a load balancer on port 80 and spin up two instances of Nginx.
const lb = new awsx.elasticloadbalancingv2.ApplicationListener("nginx", { port: 80 });
const nginx = new awsx.ecs.FargateService("nginx", {
    taskDefinitionArgs: {
        containers: {
            nginx: {
                image: "nginx",
                memory: 128,
                portMappings: [ lb ],
            },
        },
    },
    desiredCount: 2,
});

// Export the load balancer's address so that it's easy to access.
export const url = lb.endpoint.hostname;

After deploying this program, we can access our 2 Nginx web servers behind our load balancer:

$ curl https://$(pulumi stack output url)
<!DOCTYPE html>
<html>
<body>
<h1>Welcome to nginx!</h1>
</body>
</html>

We have chosen to create an Elastic Load Balancer so that we can access our services over the Internet at a stable address, spread evenly across 2 instances. Any of the ELB options described in the Pulumi Crosswalk for ELB documentation can be used with our ECS service.

Behind the scenes, our program also creates an ECS cluster in the default VPC to run the compute. This is something we can configure if we want to use a different VPC.

Because we’ve used Fargate, we don’t need to specify anything about our machine instances. Instead, Fargate will manage that for us automatically based on the optional memory and cpu values we request for our containers.

For many scenarios, this is exactly what we want: a simple way of just running containerized applications. While this approach is simple and hides a lot of complexity, it’s often desirable to control more of what is going on.

Explicitly Creating ECS Clusters for EC2 or Fargate

The awsx.ecs.Cluster class creates a new ECS cluster for Tasks and Services to run within. If you don’t specify a cluster explicitly, then a default one will be created that is configured to use your region’s default VPC.

There are a few reasons to want to create a cluster explicitly. The first is to isolate the compute running in different clusters from one another. Another is to place your cluster in a VPC so that its is isolated at the networking level. If you want to schedule non-Fargate Tasks and Services, in fact, you will need to create a cluster explicitly, because you will need to define an Auto Scaling Group that controls the EC2 instances powering it.

To use an explicit cluster, create an instance and pass it as the cluster property for the awsx.ecs.FargateService or awsx.ecs.EC2Service constructors:

import * as awsx from "@pulumi/awsx";

// Create an ECS cluster explicitly, and give it a name tag.
const cluster = new awsx.ecs.Cluster("custom", {
    tags: {
        "Name": "my-custom-ecs-cluster",
    },
});

// Deploy a Service into this new cluster.
const nginx = new awsx.ecs.FargateService("nginx", {
    cluster,
    // ... as before ...
});

In this example, we simply specify the tags for our cluster. Below we will see examples of other possibilities.

Creating an ECS Cluster in a VPC

To create an ECS cluster inside of a VPC, we will first create or use an existing VPC using any of the techniques described in the Pulumi Crosswalk for AWS VPC documentation. Then we simply pass that as the vpc argument for our cluster’s constructor:

import * as awsx from "@pulumi/awsx";

const vpc = new awsx.ec2.Vpc("vpc", { ... });
const cluster = new awsx.ecs.Cluster("custom", { vpc });

const nginx = new awsx.ecs.FargateService("nginx", {
    cluster,
    // ... as before ...
});

By default, the cluster will be given a security group permitting all egress from the cluster, on any TCP port, and ingress from any address on port 22 and targeting any of the exposed load balancer endpoints in your cluster. If you wish to override these defaults, pass the securityGroupIds property to the constructor.

Creating an Auto Scaling Group for ECS Cluster Instances

Using Fargate is easy, because we don’t have to worry about the EC2 instances powering our cluster. In the case that we want more control over the instances and their configuration, however, we can create an Auto Scaling Group explicitly, and the cluster will then use that to run all compute scheduled inside our cluster. This is required to use EC2Service.

import * as awsx from "@pulumi/awsx";

const cluster = new awsx.ecs.Cluster("custom");

const asg = cluster.createAutoScalingGroup("custom", {
    templateParameters: { minSize: 5 },
    launchConfigurationArgs: { instanceType: "t2.medium" },
});

Because we’re manually managing our cluster’s compute, we are also responsible for ensuring our cluster has enough capacity to meet our workload’s demands. It is typically not desirable to use a fixed quantity of servers. Instead, please refer to Automatic Scaling with Amazon ECS for best practices on setting up auto-scaling for your ECS workloads. Remember, Fargate handles all of this for you behind the scenes, but with less control.

Using an Existing ECS Cluster

If you already have an ECS cluster that you’d like to use, and would like to define Tasks and Services that deploy into it, you can supply the cluster argument to the constructor:

import * as awsx from "@pulumi/awsx";

// Fetch an existing cluster.
const cluster = new awsx.ecs.Cluster("custom", {
    cluster: aws.ecs.Cluster.get("existing_cluster_id"),
});

// Deploy a Service into the existing cluster.
const nginx = new awsx.ecs.FargateService("nginx", {
    cluster,
    // ... as before ...
});

Notice that we are using aws.ecs.Cluster.get to look up our existing cluster by its ID and then creating an awsx.ecs.Cluster out of it. (Note the package difference!) The former is the raw resource description, while the latter is the object type required to work with the Pulumi Crosswalk for AWS ECS APIs.

ECS Tasks, Containers, and Services

We saw example uses above but didn’t describe the details of how ECS core concepts work, or are authored in your application.

To deploy your application to ECS, it must be containerized. This means authoring a Dockerfile that specifies how all of your application’s runtime dependencies are built and packaged up. This is then used to create an image that is used by the ECS runtime to mount and run your code, as services scale out. For more information about container technology, see Docker Basics for Amazon ECS.

Given an image, ECS requires that you author a Task Definition, which specifies what requirements your Docker application has of the underlying cluster. This includes information about the container(s) to run. After that, ECS containers may be run as one off Tasks, or long-lived Services.

ECS Task Definitions

A task definition is required to run Docker containers in Amazon ECS. We saw above that each Service takes a taskDefinitionArgs object. Some of the parameters you can specify this task definition include:

  • image: The Docker image to use with each container in your task.
  • cpu and memory: How much CPU and memory to use with each task or each container within a task.
  • networkMode: The Docker networking mode to use for the containers (none, bridge, awsvpc, or host).
  • logGroup: The logging configuration to use for your tasks (by default, a new group with 1 day retention).
  • volumes: Any data volumes that should be used with the containers in the task.
  • executionRole: The IAM role that your tasks should assume while running.

Of course, the most important part of a task definition is the containers map, which specifies one or many containers to run as part of your task.

ECS Container Definitions

A TaskDefinition’s containers property specifies the Docker configuration for one or more container instances that are launched by the task.

The simplest way to specify a container to run is to provide a string to the image parameter of the container definition. This string is either the name of an image on Docker Hub, an ECR Repository, or any valid Docker repository URL.

For example, this example simply uses the nginx image from the Docker Hub:

import * as awsx from "@pulumi/awsx";

const listener = new awsx.elasticloadbalancingv2.NetworkListener("listener", { port: 80 });
const task = new awsx.ecs.FargateTaskDefinition("task", {
    containers: {
        nginx: {
            image: "nginx",
            memory: 128,
            portMappings: [ listener ],
        },
    },
});

This has the effect of running a single container within our task that runs the Nginx web server.

Services

ECS allows you to run and maintain a specified number of instances of a task definition simultaneously in a cluster. This is called a Service. If any of your tasks should fail or stop for any reason, ECS launches another instance of your task definition to replace it and maintain the desired count of tasks using your chosen scheduling strategy.

Although we have seen simple examples of Service definitions above, there are many additional capabilities.

This includes control of the scheduling of your service:

  • desiredCount: The number of instances of the task definition to place and keep running. Defaults to 1. Do not specify if using the DAEMON scheduling strategy.

  • orderedPlacementStrategies: Service level strategy rules that are taken into consideration during task placement. List from top to bottom in order of precedence. The maximum number of strategies is 5.

  • placementConstraints: Rules that are taken into consideration during task placement. Maximum number of 10.

  • schedulingStrategy: The scheduling strategy to use for the service. The valid values are REPLICA and DAEMON. Defaults to REPLICA. Note that Fargate tasks do not support the DAEMON scheduling strategy.

  • waitForSteadyState: Wait for the service to reach a steady state (like aws ecs wait services-stable) before considering a deployment complete. Defaults to true.

In addition to control of the health checking of your service:

  • deploymentMaximumPercent: The upper limit (as a percentage of the service’s desiredCount) of the number of running tasks that can be running in a service during a deployment. Not valid when using the DAEMON scheduling strategy.

  • deploymentMinimumHealthyPercent: The lower limit (as a percentage of the service’s desiredCount) of the number of running tasks that must remain running and healthy in a service during a deployment.

  • healthCheckGracePeriodSecond: Seconds to ignore failing load balancer health checks on newly instantiated tasks to prevent premature shutdown, up to 7200. Only valid for services configured to use load balancers.

In addition to security and networking configuration:

  • iamRole: ARN of the IAM role that allows Amazon ECS to make calls to your load balancer on your behalf. This parameter is required if you are using a load balancer with your service, but only if your task definition does not use the awsvpc network mode. If using awsvpc network mode, do not specify this role. If your account has already created the Amazon ECS service-linked role, that role is used by default for your service unless you specify a role

  • networkConfiguration: The network configuration for the service. This parameter is required for task definitions that use the awsvpc network mode to receive their own Elastic Network Interface, and it is not supported for other network modes.

For additional information about each of these settings, please refer to the AWS documentation.

Building and Publishing Docker Images Automatically

Containers with Pulumi Crosswalk for AWS ECS are far more flexible than just accepting a pre-existing image URL, however, and can even refer to a Dockerfile on disk so that you don’t need to arrange to build and publish it separately ahead of time. This makes it very easy to use private registrations for your ECS workloads.

For example, fromPath will run a Docker build in that path, push the result up to an ECR repository, and then pass the private ECR repostory path to the container:

const task = new awsx.ecs.FargateTaskDefinition("task", {
    containers: {
        nginx: {
            image: awsx.ecs.Image.fromPath("<path-to-dockerfile>"),
            // ...
        },
    },
});

For more control over how the Docker image is built and published, fromDockerBuild can be used. This allows you to control the build context, whether to cache multi-stage builds, and so on:

const task = new awsx.ecs.FargateTaskDefinition("task", {
    containers: {
        nginx: {
            image: awsx.ecs.Image.fromDockerBuild({
                context: "./app",
                dockerfile: "./app/Dockerfile-multistage",
                cacheFrom: { stages: [ "build" ] },
            }),
            // ...
        },
    },
});

Finally, you can create a container image from a callback function. This allows you to author the same code that runs in the container within your Pulumi application directly, much like magic functions for Lambda:

const listener =
    new awsx.elasticloadbalancingv2.NetworkTargetGroup("custom", { port: 8080 })
                                   .createListener("custom", { port: 80 });

const service = new awsx.ecs.EC2Service("custom", {
    cluster,
    desiredCount: 2,
    taskDefinitionArgs: {
        containers: {
            webserver: {
                memory: 128,
                portMappings: [ listener ],
                image: awsx.ecs.Image.fromFunction(() => {
                    const rand = Math.random();
                    const http = require("http");
                    http.createServer((req: any, res: any) => {
                        res.end(`Hello, world! (from ${rand})`);
                    }).listen(8080);
                }),
            },
        },
    },
});

This example runs an anonymous web server inside of an image built and published automatically to ECR.

For more information about using ECR, please refer to the Pulumi Crosswalk for AWS ECR documentation.

Running Fire and Forget Tasks

A ECS TaskDefinition can be used to define a Service, or it can be run on demand in a “fire and forget” manner (for example, from within a Lambda callback). This can be done by calling the run method on the Task instance. This run call must be supplied a cluster to run in.

For example, continuing from above:

import * as aws from "@pulumi/aws";
import * as awsx from "@pulumi/awsx";

const cluster = new awsx.ecs.Cluster("my-cluster");

const helloTask = new awsx.ecs.FargateTaskDefinition("hello-world", {
    container: {
        image: "hello-world",
        memory: 20,
    },
});

const api = new aws.apigateway.x.API("hello-world-api", {
    routes: [{
        path: "/hello",
        method: "GET",
        eventHandler: async (req) => {
            // Anytime someone hits the /hello endpoint, schedule our task.
            const result = await helloTask.run({ cluster });
        },
    }],
});

The calls to run must specify which cluster to run the task in, and may control other aspects of scheduling.

Additional ECS Resources

For more information about Amazon ECS, please read the following: