Develop a Serverless TypeScript API on AWS ECS with Fargate
Tags: typescript, node, fargate, aws
Published: 2024-06-19 12:00:00

AWS Fargate is a serverless compute engine that allows you to run containers without managing servers. With Fargate, you no longer have to provision clusters of virtual machines to run ECS containers: this is all done for you.

Fargate has an Amazon ECS construct that can host an API. In this take, we will build a Fargate service using the AWS CDK, put the API in a docker image, and then host it inside Amazon ECS.

The API will be a pizza API and we'll store the data in a DynamoDB table.

Let’s get started!

## Why Use Fargate with ECS?

Fargate has some benefits over lambda functions because of provisioned servers. When you have a high volume of traffic, you can save on costs by running containers. These can scale out to as many servers as needed to meet the current demand and avoid paying per request. Once you have millions of requests per day or even per hour, paying per actual load starts to become more cost-effective.

The one gotcha here is that scaling out can become a bit of a hassle because, by the time you spin up a new container, it is already too late to handle the current load. A good technique is to come up with scaling strategies ahead of time, which means you definitely must have a predictable load.

## Requirements

Feel free to [clone the sample code for this project from GitHub](https://github.com/beautifulcoder/node-fargate-api).

Be sure to have the latest version of Node and the AWS CDK Toolkit installed.

```shell
> npm install -g aws-cdk
```

Then, simply spin up a new TypeScript project using the toolkit.

```shell
> mkdir node-fargate-api
> cd node-fargate-api
> cdk init --language typescript
> cdk synth
```

## Build the ECR Image

First, install Fastify and DynamoDB in the root `package.json` file. Then, create an `app` folder to contain the application.

```shell
> npm i fastify @aws-sdk/client-dynamodb @aws-sdk/util-dynamodb --save
> mkdir app
```

Create the `app/api.ts` file and add this code snippet:

```typescript
import Fastify from "fastify";
import {
  DynamoDBClient,
  GetItemCommand,
  PutItemCommand,
} from "@aws-sdk/client-dynamodb";
import { unmarshall } from "@aws-sdk/util-dynamodb";

const client = new DynamoDBClient();

const fastify = Fastify({
  logger: true,
});

fastify.get("/pizzas/:id", async (request, reply) => {
  const { id } = request.params as any;

  const res = await client.send(
    new GetItemCommand({
      TableName: "pizzas",
      Key: { id: { N: id } },
    })
  );

  const item = res.Item;

  if (item === undefined) {
    reply.callNotFound();

    return;
  }

  const pizza = unmarshall(item);
  const { ingredients } = pizza;

  await reply.status(200).send({ ...pizza, ingredients: [...ingredients] });
});

fastify.put("/pizzas/:id", async (request, reply) => {
  const { id } = request.params as any;
  const { name, ingredients } = request.body as any;

  const pizza = {
    id: { N: id },
    name: { S: name },
    ingredients: { SS: ingredients },
  };

  await client.send(
    new PutItemCommand({
      TableName: "pizzas",
      Item: pizza,
    })
  );

  await reply.status(200).send({ id, name, ingredients });
});

fastify.get("/health", async (request, reply) => {
  await reply.status(200).send();
});

const { ADDRESS = "localhost", PORT = 3000 } = process.env;

const start = async (): Promise<void> => {
  try {
    await fastify.listen({ port: Number(PORT), host: ADDRESS });
  } catch (err) {
    fastify.log.error(err);
    process.exit(1);
  }
};

void start();
```

This is a pizza API with GET/PUT endpoints. We store the data in a DynamoDB table.

_**Note**: The `host` and `port` come from an environment variable when this runs in Fargate. This guarantees the app runs on `0.0.0.0` and not the loopback IP address. The port number must also be `80` for HTTP traffic._

To upload the app to an ECR image, create a repository in the AWS console.

- Log in to AWS and click 'Elastic Container Registry'
- Click 'Create repository'
- Leave it as Private
- Set a name, for example, `pizza-fargate-api`, and make note of this

Next, create the Dockerfile in the root folder.

```text
FROM node:20-alpine

WORKDIR /usr/src/app

COPY package*.json ./
COPY tsconfig.json ./
COPY app/* ./
RUN npm ci --omit=dev && npm run build
COPY . .

ENV ADDRESS=0.0.0.0 PORT=80

CMD ["node", "app/api.js"]
```

_**Note**: For this docker build to work, be sure to move `typescript` and `@types/node` in the `package.json` file from dev to just plain dependencies._

For the load balancer to get access to the container, be sure to specify the `PORT` and `ADDRESS`. As mentioned, this cannot be the loopback `127.0.0.0` because it does not allow incoming connections from outside the container. In Fastify, this is the default behavior, so we must manually set this IP address.

In ECR, click on the newly created repository, then 'View push commands'. This lists instructions for uploading the docker image to the repository (they are self-explanatory, so we will not repeat them here).

Once the commands complete successfully, the image should appear in the Amazon Elastic Container Registry.

![AWS ECR image](https://blog.appsignal.com/images/blog/2024-06/node-fargate-api-1.png)

## The AWS CDK

Open the `node-fargate-api-stack.ts` file and drop in this entire code snippet.

```typescript
import * as cdk from "aws-cdk-lib";
import type { Construct } from "constructs";
import * as ec2 from "aws-cdk-lib/aws-ec2";
import * as ecs from "aws-cdk-lib/aws-ecs";
import * as ecs_patterns from "aws-cdk-lib/aws-ecs-patterns";
import * as dynamodb from "aws-cdk-lib/aws-dynamodb";
import * as iam from "aws-cdk-lib/aws-iam";
import * as ecr from "aws-cdk-lib/aws-ecr";
import * as logs from "aws-cdk-lib/aws-logs";

export class NodeFargateApiStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    const vpc = ec2.Vpc.fromLookup(this, "vpc", {
      vpcId: "VPC_ID",
    });

    const cluster = new ecs.Cluster(this, "cluster", {
      vpc,
    });

    const repository = ecr.Repository.fromRepositoryName(
      this,
      "repository",
      "pizza-fargate-api"
    );

    const service = new ecs_patterns.ApplicationLoadBalancedFargateService(
      this,
      "api",
      {
        cluster,
        cpu: 256,
        desiredCount: 1,
        taskImageOptions: {
          image: ecs.ContainerImage.fromEcrRepository(repository, "latest"),
          containerPort: 80,
          logDriver: new ecs.AwsLogDriver({
            streamPrefix: "api",
            logRetention: logs.RetentionDays.THREE_DAYS,
          }),
        },
        memoryLimitMiB: 512,
        publicLoadBalancer: true,
      }
    );

    const taskCount = service.service.autoScaleTaskCount({ maxCapacity: 5 });
    taskCount.scaleOnCpuUtilization("cpu-scaling", {
      targetUtilizationPercent: 45,
      scaleInCooldown: cdk.Duration.seconds(60),
      scaleOutCooldown: cdk.Duration.seconds(60),
    });

    service.targetGroup.configureHealthCheck({ path: "/health" });

    const table = new dynamodb.Table(this, "table", {
      tableName: "pizzas",
      partitionKey: {
        name: "id",
        type: dynamodb.AttributeType.NUMBER,
      },
    });

    service.taskDefinition.taskRole.addToPrincipalPolicy(
      new iam.PolicyStatement({
        actions: ["dynamodb:*"],
        resources: [table.tableArn],
      })
    );
  }
}
```

The `ApplicationLoadBalancedFargateService` construct needs a VPC (which should already exist in your AWS account). The load balancer directs HTTP traffic from the internet to the cluster. This is the reason why it is publicly available.

The DynamoDB table simply declares a main partition key: the number id and table name. The task definition is then granted full access to the DynamoDB table.

The `cpu`, `desiredCount`, and `memoryLimitMiB` set scalability configuration for the service when it first starts. The service uses task definitions to spin up new containers that can handle the load. You can manually scale Fargate by updating the desired task count in your service definition. This CDK also creates auto-scaling policies based on metrics like CPU utilization. Fargate uses CloudWatch alarms to trigger scaling actions based on metrics or events.

For this CDK to work, be sure to update the main entry point in `bin/node-fargate-api.ts`. Set the account number and region.

## Unit Test the CDK

Type `cdk synth` in a terminal, then inspect the CloudFormation YAML template that will get uploaded to AWS. This has enough information to write unit tests.

Open `node-fargate-api.test.ts` in the `test` folder and enter this code snippet.

```typescript
import * as cdk from "aws-cdk-lib";
import { Template } from "aws-cdk-lib/assertions";
import * as api from "../lib/node-fargate-api-stack";

test("Fargate service created", () => {
  const app = new cdk.App();

  const stack = new api.NodeFargateApiStack(app, "test-stack", {
    env: { account: "0123456789", region: "us-east-1" },
  });

  const template = Template.fromStack(stack);

  template.hasResourceProperties("AWS::ECS::Service", {
    LaunchType: "FARGATE",
    LoadBalancers: [
      {
        ContainerName: "web",
        ContainerPort: 80,
      },
    ],
  });
});

test("DynamoDB table created", () => {
  const app = new cdk.App();

  const stack = new api.NodeFargateApiStack(app, "test-stack", {
    env: { account: "0123456789", region: "us-east-1" },
  });

  const template = Template.fromStack(stack);

  template.hasResourceProperties("AWS::DynamoDB::Table", {
    TableName: "pizzas",
  });
});
```

This time, it is important to specify the `env` for each test because the VPC lookup in the stack requires the environment. Luckily, you can mock the account id and region because it is just a unit test. What is important is that the unit test verifies the YAML template since we are creating these specific resources via the CDK.

## The Load Balancer

Before we can check that the load balancer works, simply deploy the CDK.

```shell
> cdk deploy
```

_**Note**: If the deploy takes a very long time, it is likely that the load balancer cannot clear the health endpoint check. Simply cancel the deploy, then go back and double-check your CDK._

The CDK should have an output with a publicly accessible URL from the load balancer. You can now hit the two endpoints with CURL.

```shell
> curl -i -X PUT -H "Content-Type: application/json" \
  -d "{\"name\":\"Pepperoni Pizza\",\"ingredients\":[\"cheese\",\"tomato\",\"pepperoni\"]}" \
  http://LBNAME-ACCOUNT.REGION.elb.amazonaws.com/pizzas/1

> curl -i -X GET -H "Content-Type: application/json" http://LBNAME-ACCOUTN.REGION.elb.amazonaws.com/pizzas/1
```

To smack this API with as many requests as possible, write this K6 script and save it as a JavaScript file.

```javascript
import { check } from "k6";
import http from "k6/http";

export default function () {
  const res = http.get(
    "http://LBNAME-ACCOUNT.REGION.elb.amazonaws.com/pizzas/1"
  );
  check(res, {
    "is status 200": (r) => r.status === 200,
  });
}
```

Then, run a load test using K6:

```shell
k6 run --vus 50 --duration 20m load-test.js
```

This test simulates 50 users hitting the API at the same time for 20 minutes. The reason you want to go for 20 minutes is because Fargate takes a few minutes to spin up new tasks to handle the current load. This is the reason why Fargate does better with predictable load. By the time the new task spins up, it might already be too late. (In contrast, a lambda function cold starts in mere seconds on the slower end and is therefore more suitable for spiky _unpredictable_ traffic).

The load test might also show some failing requests. This happens because tasks are sometimes too slow to spin up and respond to current incoming traffic.

By the end of the load test, you should see the maximum amount of configured tasks running in your service.

![Fargate tasks](https://blog.appsignal.com/images/blog/2024-06/node-fargate-api-2.png)

Before we end, be sure to fire a `cdk destroy` so you don't accrue further EC2 charges. The one downside to Fargate is that you continue to pay for the running service even when there is no traffic.

## Wrapping Up

In this post, we built and hosted a Fargate service using AWS CDK and Amazon ECS.

We've seen that Fargate running on ECS can be a great alternative to lambda functions. Given predictable traffic and heavy load, it is possible to save on costs because you are not paying per request.

Happy coding!

**P.S. If you liked this post, [subscribe to our JavaScript Sorcery list](https://blog.appsignal.com/javascript-sorcery) for a monthly deep dive into more magical JavaScript tips and tricks.**

**P.P.S. If you need an APM for your Node.js app, go and [check out the AppSignal APM for Node.js](https://www.appsignal.com/nodejs).**