Containers Can Be Serverless Too - Running Applications on Fargate via ECS
When building applications on AWS, we need to run our code somewhere: a computation service. There are a lot of well-known and mature computation services on AWS. You’ll often find Lambda as the primary choice, as it’s where you don’t need to manage any infrastructure. You only need to bring your code - it’s Serverless.
However, more options can be considered serverless, and they are even more mature than Lambda.
In this post, we'll introduce you to the Elastic Container Service (ECS) and one of its computing options, Fargate.
As mostly, this post comes with a hands-on repository that you can easily deploy into your own AWS account.
What is ECS?
ECS is a highly scalable and fast container management service. It offers a management plane to orchestrate containers in your cluster. Simply run, stop, and manage containers. 🏗️
It comes with many features to ease your development process and reduce operations and liabilities.
With the Fargate launch type, you don't need to worry about the underlying infrastructure. You'll only need to determine which container image you want to run and what workload capabilities you require in terms of memory or virtual CPUs.
It's fully integrated with AWS IAM. You can define fine-grained permissions based on your requirements and never have to think about users or passwords. Define any level of isolation that you want or require from a compliance perspective.
It’s integrated with CloudWatch Metrics and Logs. All of your logs are automatically shipped to CloudWatch and ECS will also forward important runtime metrics like CPU and memory usage. All of this comes without additional configuration efforts.
ECS is one of AWS's most battle-tested services and is often a perfect fit for critical core infrastructure that needs to handle high-volume request microservice APIs.
Key Concepts of ECS
While exploring and learning about ECS, you'll come across many key terms that might not be easy to understand initially. However, they are essential for understanding how ECS works. Let's dive into containers, tasks and task definitions, services, and clusters.
Containers
Docker is a fundamental component of container services, allowing the creation of lightweight environments known as containers that can run applications across different operating systems.
These containers encapsulate all necessary components, such as specific libraries or language versions, enabling execution on any machine.
Multiple containers can operate on a single machine, using intra-container communication while maintaining host security through strict separation.
As applications and container numbers grow, managing and orchestrating these containers becomes challenging, involving tasks like deployment, scaling, and lifecycle management.
ECS addresses these challenges by providing a management plane and automations, allowing developers to concentrate more on application development instead of these operations.
Task Definition
A task definition serves as a blueprint for launching containers, encompassing several key properties.
The launch type determines the service used to execute tasks, such as EC2, Fargate, or External.
Two essential roles are required: the task execution role, which grants permissions for starting containers and accessing secrets, and the task role, which provides permissions for applications within containers to interact with AWS services.
The container image specifies the Docker image from a registry like Amazon ECR. vCPU and memory allocation define the compute resources, with options varying by launch type.
Environment variables allow the injection of stage-dependent parameters.
Secrets enable secure injection of sensitive data from AWS Secrets Manager, requiring appropriate permissions.
The logging configuration specifies the log driver and destination, with options varying by launch type.
Finally, exposed ports define how inbound traffic is mapped between ECS and the container image.
Task
A task represents the execution of a task definition, consisting of a group of containers running on the same host. Defined using the Docker-Compose file format, tasks allow the specification of container images, environment variables, port mappings, and additional options for each container.
Tasks can be launched directly and will continue running until manually stopped or they exit naturally, without automatic replacement.
Service
A service is a persistent process responsible for managing tasks, ensuring a specified number of tasks are consistently operational.
If a task fails due to unexpected container exits, causing the number of healthy tasks to drop below the set threshold, ECS automatically initiates a new task to maintain the desired task count.
Cluster
A cluster is a logical grouping that organizes tasks or services, which operate on infrastructure registered to it. This infrastructure can be provided by AWS Fargate, managed EC2 instances, on-premise servers, or remotely managed virtual machines.
Launch Type
The launch type determines how a container runs. In ECS, you have several options: EC2, Fargate, or External. Since Fargate requires the least operational effort, we'll focus on it in this article.
Creating Our First ECS Service That Runs a Node.js Application on Fargate
After we have acquired all the necessary knowledge, we can proceed to build our first service, which runs a docker-based application with ECS in Fargate.
We need to:
Initialize a new container registry at ECR, which will later be the source from where ECS pulls our application.
Set up our sample application, package it into a Docker image, and push it to our new repository.
Set up a new task definition that references our image in ECR. It is the blueprint for running our application in a Fargate task.
Creating the necessary networking infrastructure. We'll need to set up a VPC, multiple subnets, a security group, a load balancer, a target group and an internet gateway.
Finally, launch our Serverless service. The service will orchestrate our containers and guarantee that a certain number of tasks are always healthy.
Send requests to our application!
An elementary architecture diagram, without going into the details of the task, task definition, service, and cluster of ECS, would look like the following:
Creating a Container Registry
The first thing we need is a new repository in the Amazon Elastic Container Registry. This is where our images will be stored later on. We’ll also create a lifecycle policy so that our old container images will be deleted automatically.
const repository = new aws.ecr.Repository('backend', { name: 'awsfundamentals' });
new aws.ecr.LifecyclePolicy('backendLifecyclePolicy', {
repository: repository.name,
policy: JSON.stringify({
rules: [
{
rulePriority: 1,
description: 'Delete untagged images',
selection: {
tagStatus: 'untagged',
countType: 'imageCountMoreThan',
countNumber: 1,
},
action: {
type: 'expire',
},
},
],
}),
});
If we push a new image with the tag latest
, the previous image will lose this tag and become untagged. Once it's untagged, our policy will delete it. Only the latest
image and the most recent untagged image (which was the previous latest
) will be kept.
Setting up Our Application and Our Container
Our image store is ready. Let’s create a simple Node.js application now.
const express = require('express');
const cors = require('cors');
const axios = require('axios');
const app = express();
const port = 80;
app.use(cors());
const fetchMetadata = async () => {
try {
const response = await axios.get(process.env.ECS_CONTAINER_METADATA_URI);
return response.data;
} catch (error) {
console.error('Error fetching metadata:', error);
return null;
}
};
app.get('/', async (_req, res) => {
if (process.env.AWS_REGION) {
const metadata = await fetchMetadata();
res.json({
message: 'Hello World from Fargate! 🏗️',
metadata,
});
} else {
res.json({
message: 'Hello World from Local! 🏠',
});
}
});
app.listen(port, () => {
console.log(`Example app listening at http://localhost:${port}`);
});
It's a simple Express server that returns a small JSON when you access the base route. This is enough to get started. To include the necessary dependencies, run npm init
and npm i express axios cors
. By adding the cors package, we prevent cross-site scripting errors when calling the service from our frontend. With axios, we have an easy-to-use HTTP client that we can use to get the task's metadata. More on that later.
Afterward, we’ll bundle everything inside a Docker container:
# Use the Node.js 22 Alpine image as the base
FROM node:22-alpine
# Install curl
RUN apk add --no-cache curl
# Set the working directory
WORKDIR /app
# Copy package.json and install dependencies
COPY package.json .
# Install the dependencies
RUN npm install
# Copy the application code
COPY *.js .
# Expose port 80
EXPOSE 80
# Start the application
CMD ["npm", "start"]
We can now build the image and push it to ECR. The instructions on how to do that can always be found directly at your repository when you click on the View push commands
button.
--platform linux/amd64
argument. For example: docker buildx build --platform linux/amd64 -t awsfundamentals .
. This will build the image for the correct architecture. Else, you’ll face the error exec /usr/local/bin/
docker-entrypoint.sh
: exec format error
.Now that our image is available in ECR, we can set up the necessary container infrastructure to run it.
Creating the Necessary Networking Infrastructure
To set up the necessary networking resources for deploying our Node.js application on AWS Fargate with ECS, we need to create the following components:
VPC (Virtual Private Cloud): Think of this as your own private network within AWS. It keeps your resources isolated and secure.
Subnets: These are parts within your VPC. By placing them in different availability zones (geographically separate data centers), we can ensure that the application remains available even if one zone has issues or is not available.
Internet Gateway: This acts like a bridge, allowing your resources in the VPC to connect to the internet.
Route Table: This is like a map that directs network traffic. It tells your resources how to reach the internet via the Internet Gateway.
Route Table Associations: These link your subnets to the Route Table, ensuring they follow the traffic rules you've set.
Security Group: Think of this as a firewall. It controls what kind of network traffic can enter or leave your resources. In our case, it allows web traffic (HTTP and HTTPS) to flow in and out.
Application Load Balancer: This distributes incoming web traffic across your application instances, ensuring no single instance gets overwhelmed and improving reliability.
Target Group: This is a set of destinations (like your ECS tasks) that the Load Balancer sends traffic to. It helps manage where the traffic goes. We’ll later register our Fargate tasks within that target group.
Listener: This component listens for incoming traffic on a specific port (like HTTP on port 80) and forwards it to the Target Group (which will then submit traffic to our running Fargate tasks).
We’ll skip the code details in this post, but you can find it inside our hands-on repository.
Setting up the Task Definition, Service, and Cluster
All of our preconditions are fulfilled now, we can create our ECS components.
const createTaskDefinition = (params: { repositoryUrl: $util.Output<string>; taskRole: aws.iam.Role; executionRole: aws.iam.Role }) => {
const { repositoryUrl, taskRole, executionRole } = params;
return repositoryUrl.apply(
(url) =>
new aws.ecs.TaskDefinition('taskdefinition', {
requiresCompatibilities: ['FARGATE'],
family: 'awsfundamentals',
cpu: '256',
memory: '1024',
networkMode: 'awsvpc',
executionRoleArn: executionRole.arn,
taskRoleArn: taskRole.arn,
containerDefinitions: JSON.stringify([
{
name: 'backend',
essential: true,
image: `${url}:latest`,
memory: 1024,
portMappings: [
{
containerPort: 80,
hostPort: 80,
protocol: 'tcp',
appProtocol: 'http',
},
],
logConfiguration: {
logDriver: 'awslogs',
options: {
'awslogs-group': '/ecs/awsfundamentals',
'awslogs-region': 'us-east-1',
'awslogs-stream-prefix': 'ecs',
},
},
healthCheck: {
command: ['CMD-SHELL', 'curl -f http://localhost/ || exit 1'],
interval: 30,
timeout: 5,
retries: 3,
startPeriod: 0,
},
environment: [
{
name: 'ECS_CONTAINER_METADATA_URI',
value: 'http://169.254.170.2/v4',
},
{
name: 'ECS_ENABLE_CONTAINER_METADATA',
value: 'true',
},
],
},
]),
})
);
};
Let’s inspect what we’re doing here:
Defining our infrastructure capabilities for the entire task definition - This includes our compute resources (256 for CPU, which means 0.25 vCPUs, with 1GB of memory), the compute type (Fargate in our case), as well as the execution role (used by ECS to start and run our task) and the task role (the role used by the application running inside our task).
Setting Up Our Containers: We'll specify where to find our image and which tag to use, how the ports are mapped, and what the log and health check configurations look like.
With the environment variable ECS_ENABLE_CONTAINER_METADATA
, we’re allowing our app to call an internal endpoint to access the metadata of the ECS task. We’ll forward this to our frontend for learning purposes.
Now, we can finally create our service and cluster.
const createClusterAndService = (params: {
taskDefinition: $util.Output<aws.ecs.TaskDefinition>;
securityGroup: aws.ec2.SecurityGroup;
subnets: aws.ec2.Subnet[];
targetGroup: aws.lb.TargetGroup;
}) => {
const { taskDefinition, securityGroup, subnets, targetGroup } = params;
const cluster = new aws.ecs.Cluster('cluster', { name: 'awsfundamentals' });
new aws.ecs.Service('service', {
name: 'awsfundamentals',
cluster: cluster.arn,
desiredCount: 1,
launchType: 'FARGATE',
taskDefinition: taskDefinition.arn,
networkConfiguration: {
assignPublicIp: true,
subnets: subnets.map((s) => s.id),
securityGroups: [securityGroup.id],
},
loadBalancers: [
{
targetGroupArn: targetGroup.arn,
containerName: 'backend',
containerPort: 80,
},
],
});
};
The service's network configuration will use the VPC, subnets, and security group we created earlier. It will also register our tasks within our target group, which is linked to our load balancer.
Now we have everything what we need. When we deploy our application (npx sst dev
), ECS will provision our task running on Fargate.
When you’re visiting the ECS console, you should find something like this after a few seconds of initial bootstrap.
More details for the task itself can be found when clicking on our service’s name and going to the Tasks
tab:
In the example screenshot, we’ve updated our application and task definiton multiple times. Each time, ECS will pull the new image and replace the old task with a new one!
latest
tag on our image, just pushing a new image to ECR won't start a deployment in ECS. To do this, we need to publish a new version of our task definition. With SST, this can be easily done by changing any line in the task definition creation.Let’s access our frontend via http://localhost:3000
to see that everything works together.
Great! We see that our backend application returns the metadata of our Fargate task. ✨
Wrap Up
Amazon ECS with Fargate and AWS Lambda are both serverless options on AWS, but they come for different needs.
Lambda is ideal for event-driven, short-lived tasks, offering cost-effective scalability without server management.
ECS with Fargate, on the other hand, provides more control and flexibility, suitable for long-running processes and complex applications with multiple services. It supports Docker, integrates well with AWS services, and allows granular resource management, making it a preferred choice for companies that need a battle-tested, mature AWS service for their core resources.
You'll almost never find a company that runs its infrastructure on AWS without using ECS with Fargate in some part.
We love ECS, and we hope you will too.
FAQ
What is AWS Fargate? AWS Fargate is a serverless compute engine for containers that works with Amazon ECS. Unlike traditional ECS with EC2, where you manage the underlying EC2 instances, Fargate allows you to run containers without having to manage servers. You only need to specify the resources required for your containers, and Fargate handles the rest.
How does ECS ensure the availability of my application? ECS ensures application availability through its service feature, which maintains a specified number of running tasks. If a task fails, ECS automatically launches a new one to replace it, ensuring that the desired number of tasks is always running.
What are the key components needed to deploy a containerized application on ECS using Fargate? To deploy a containerized application on ECS using Fargate, you need to set up a container registry (like Amazon ECR), define a task definition, create a service, and configure the necessary networking infrastructure, including a VPC, subnets, security groups, and a load balancer.
Can I use ECS with existing Docker containers? Yes, ECS is fully compatible with Docker containers. You can use your existing Docker images by pushing them to a container registry like Amazon ECR and referencing them in your ECS task definitions.
What are the cost considerations when using ECS and Fargate? With ECS and Fargate, you pay for the compute and storage resources your containers use. Fargate charges are based on the vCPU and memory resources consumed by your containerized applications. Additionally, services like load balancers and data transfer may incur additional costs.