Running Node.js HTTP Server on ECS

2020.06.15

Recently, everyone prefers to use container and I am the one of them.
AWS has the powerful container platform called ECS and has computing resources called Fargate.
In this article, I'm gonna run a HTTP Server with Express.js on a container .

Build a Docker Image

At first, we make a new project and write dependencies on package.json. we only need Express.js in this example, so just add express 4.x.

$ yarn init -y
$ yarn add express

We write some code to run HTTP server.
We use a small sample code that simply returns "Hello from ECS on Fargate" in order to confirm that Node.js container runs on ECS without any problems.

const express = require('express');

const app = express();

app.get('/', (req, res) => {
  res.send('Hello from ECS on Fargate');
});

app.listen(8080, () => console.log('This app listening on port 8080'));

Did your application work well? you can see how the container works using below command.

$ node index.js

This app listening on port 8080

Then, we write Dockerfile to build the new Docker Image. This Dockerfile is very simple, install dependencies and spawn a process.

FROM node:latest

WORKDIR /usr/src/app

COPY package.json yarn.lock ./
RUN yarn

COPY . .

EXPOSE 8080
CMD [ "node", "index.js" ]

There might be no changes in dependencies, but it's a good practice to separate "package.json" and "application code" from COPY key.
Separating "package.json" and "application code" from COPY key allows the cache to work.

At the end of Dockerfile, I defined CMD to specify the runtime(what's process?) and arguments.
We can override the process setting through Docker CLI when you need to investigate the container or run Bash commands.

Make a ECR Repository and push the image

In the previous section, we created a Docker Image, but this is only in our local environment.
This means, no one out there can use this Docker image. To make this accessible to everyone, let's create a new Docker Image Registry and push/publish it.

$ aws --region us-east-1 ecr create-repository \
  --repository-name express-server

{
    "repository": {
        "repositoryArn": "arn:aws:ecr:us-east-1:123456789012:repository/express-server",
        "registryId": "123456789012",
        "repositoryName": "express-server",
        "repositoryUri": "123456789012.dkr.ecr.us-east-1.amazonaws.com/express-server",
        "createdAt": 1592145919.0,
        "imageTagMutability": "MUTABLE",
        "imageScanningConfiguration": {
            "scanOnPush": false
        }
    }
}

It's about the time to push your image.

$ aws --region us-east-1 ecr get-login-password \
  | docker login \
      --username AWS \
      --password-stdin \
      `aws sts get-caller-identity --query Account --output text`.dkr.ecr.us-east-1.amazonaws.com/express-server

Then, build a Docker Image and push it.

$ docker build \
  -t `aws sts get-caller-identity --query Account --output text`.dkr.ecr.us-east-1.amazonaws.com/express-server:latest .

$ docker push \
  `aws sts get-caller-identity --query Account --output text`.dkr.ecr.us-east-1.amazonaws.com/express-server:latest

Create ECS Cluster and define Task Definition

At the end of the preparation for running the container, we make a ECS Cluster and define a Task Definition. ECS Cluster is a logical group for ECS Tasks and ECS Services.

ECS Task is instance of containers that include configurations by AWS for example, around the network.
How to make an ECS Task? When we execute RunTask API, AWS refers ECS Task Definition and makes ECS Task. So ECS Task Definition is a whole of setting about ECS Task.
ECS Task Definition and ECS Task are like class and instance. We can make any number of instances from class, in addition we add parameters when we initialize an instance. Same goes for ECS.

ECS Service manages ECS Tasks such as to specify and maintain the number of ECS Tasks.
if any of your tasks failed or stopped, the ECS Service scheduler automatically launches another ECS Task instances.
Additionally, ECS Service enables traffic routing using ELB or Service Discovery.

In this article, ECS Service is not mentioned... anyway you don't fear the ECS like you don't fear the reaper. Always I go and hit a cowbell, you think need more cowbell.

Anyway you understand about ECS deeply or not, let's make a ECS Cluster and a ECS Task Definition.

$ aws --region us-east-1 ecs create-cluster \
  --cluster-name my-cluster

{
    "cluster": {
        "clusterArn": "arn:aws:ecs:us-east-1:123456789012:cluster/my-cluster",
        "clusterName": "my-cluster",
        "status": "ACTIVE",
        "registeredContainerInstancesCount": 0,
        "runningTasksCount": 0,
        "pendingTasksCount": 0,
        "activeServicesCount": 0,
        "statistics": [],
        "tags": [],
        "settings": [
            {
                "name": "containerInsights",
                "value": "enabled"
            }
        ],
        "capacityProviders": [],
        "defaultCapacityProviderStrategy": []
    }
}

Before we define a Task Definition, guess what AWS needs to run a Docker container.

If you want to run a container from a new Docker Image, you execute docker pull before docker run. You just execute docker run and run a container? Think about where the image is coming from, docker run includes pulling image.

Did you attach a Internet Gateway to a VPC? Did you check Route Table? Did you make a NAT Gateway... am I persistent?
I want to mention is you need to be connected to the internet properly.

No need to permission when pulls a public Docker Image, but the Docker Image Registry we made is a private.
ECS and ECR are two different services, ECS can't pull Docker Image from private ECR Repository by default.

We need to grant access to a ECR Repository when we run ECS Tasks.
ECS Task Execute Role enable to this, add permission for running ECS Tasks, pull images from ECR Repository, records logs to CW Logs Streams, and so on.
So we need to make a new IAM Role.

$ aws iam create-role \
  --role-name my-task-execution-role \
  --assume-role-policy-document '{"Version":"2012-10-17","Statement":[{"Sid":"","Effect":"Allow","Principal":{"Service":"ecs-tasks.amazonaws.com"},"Action":"sts:AssumeRole"}]}'

{
    "Role": {
        "Path": "/",
        "RoleName": "my-task-execution-role",
        "RoleId": "XXXXXXXXXXXXXXXXXXXXX",
        "Arn": "arn:aws:iam::123456789012:role/my-task-execution-role",
        "CreateDate": "2020-06-14T15:53:27Z",
        "AssumeRolePolicyDocument": {
            "Version": "2012-10-17",
            "Statement": [
                {
                    "Sid": "",
                    "Effect": "Allow",
                    "Principal": {
                        "Service": "ecs-tasks.amazonaws.com"
                    },
                    "Action": "sts:AssumeRole"
                }
            ]
        }
    }
}

Attach the policy to IAM Role.

$ aws iam attach-role-policy \
  --role-name my-task-execution-role \
  --policy-arn arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy

Everything we prepare exclude Task Definition.
Task Definition is a setting about an ECS Task, your containers are running EC2 or Fargate, Docker Image URI, how many CPU and memory resolve for running, and so on...

task-definition.json

{
  "family": "my-node-app",
  "executionRoleArn": "arn:aws:iam::123456789012:role/my-task-execution-role",
  "networkMode": "awsvpc",
  "requiresCompatibilities": [
    "FARGATE"
  ],
  "cpu": "256",
  "memory": "512",
  "containerDefinitions": [
    {
      "name": "express-server",
      "image": "123456789012.dkr.ecr.us-east-1.amazonaws.com/express-server",
      "essential": true,
      "memory": 384,
      "memoryReservation": 128,
      "portMappings": [
        {
          "containerPort": 8080
        }
      ],
      "readonlyRootFilesystem": true,
      "privileged": false,
      "user": "1000"
    }
  ]
}

Write Task Definition and define task definition.

$ aws --region us-east-1 ecs register-task-definition \
  --cli-input-json file://task-definition.json

{
    "taskDefinition": {
        "taskDefinitionArn": "arn:aws:ecs:us-east-1:123456789012:task-definition/my-node-app:1",
        "containerDefinitions": [
            {
                "name": "express-server",
                "image": "123456789012.dkr.ecr.us-east-1.amazonaws.com/express-server",
                "cpu": 0,
                "memory": 384,
                "memoryReservation": 128,
                "portMappings": [
                    {
                        "containerPort": 8080,
                        "hostPort": 8080,
                        "protocol": "tcp"
                    }
                ],
                "essential": true,
                "environment": [],
                "mountPoints": [],
                "volumesFrom": [],
                "user": "1000",
                "privileged": false,
                "readonlyRootFilesystem": true
            }
        ],
        "family": "my-node-app",
        "executionRoleArn": "arn:aws:iam::123456789012:role/my-task-execution-role",
        "placementConstraints": [],
        "compatibilities": [
            "EC2",
            "FARGATE"
        ],
        "requiresCompatibilities": [
            "FARGATE"
        ],
        "cpu": "256",
        "memory": "512"
    }
}

Run ECS Task

it's time to run a ECS Task.
You need a public subnet and security group that has ingress rule from your home IP address to 8080 port.

$ aws --region us-east-1 ecs run-task \
	--task-definition my-node-app \
	--cluster my-cluster \
	--count 1 \
  --launch-type FARGATE \
	--network-configuration "{\"awsvpcConfiguration\":{\"subnets\":[\"subnet-xxxxxxxxxxxxxxxxx\"],\"securityGroups\":[\"sg-xxxxxxxxxxxxxxxxx\"],\"assignPublicIp\":\"ENABLED\"}}"

{
    "tasks": [
        {
            "attachments": [
                {
                    "id": "xxxxx-xxxx-xxxx-xxxx-xxxxxx",
                    "type": "ElasticNetworkInterface",
                    "status": "PRECREATED",
                    "details": [
                        {
                            "name": "subnetId",
                            "value": "subnet-xxxxxxxxxxxxxxxxxxx"
                        }
                    ]
                }
            ],
            "availabilityZone": "us-east-1a",
            "clusterArn": "arn:aws:ecs:us-east-1:123456789012:cluster/my-cluster",
            "containers": [
                {
                    "containerArn": "arn:aws:ecs:us-east-1:123456789012:container/xxxxx-xxxx-xxxx-xxxx-xxxxxx",
                    "taskArn": "arn:aws:ecs:us-east-1:123456789012:task/my-cluster/xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
                    "name": "express-server",
                    "image": "123456789012.dkr.ecr.us-east-1.amazonaws.com/express-server",
                    "lastStatus": "PENDING",
                    "networkInterfaces": [],
                    "cpu": "0",
                    "memory": "384",
                    "memoryReservation": "128"
                }
            ],
        }
    ],
    "failures": []
}

Check the response.

$ curl http://xxx.xxx.xxx.xxx:8080

Hello from ECS on Fargate

Everything working well! In the end, stop the task.

$ aws --region us-east-1 ecs  stop-task \
  --cluster my-cluster \
  --task xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

To closely

We learned about core concepts of ECS, but it's not production ready and includes a few bad practices...
So we need to know about ECS Service and how to build Docker Image well.

Reference