How to build a microservices infrastructure on AWS using Terraform, ECS Fargate, and CloudMap | part two

Stefano Monti
9 min readJan 10, 2023

--

This is the second part of this post: https://medium.com/aws-infrastructure/how-to-build-a-microservices-infrastructure-on-aws-using-terraform-ecs-fargate-and-cloudmap-f20ac7b82d73.
If you haven’t read it yet, I recommend you follow the link and prepare your infrastructure to permit the single service creation as described on this page.

Let’s begin with downloading the repo:
https://github.com/stech-world/build-a-microservices-infrastructure-on-aws-using-terraform-ecs-fargate-and-cloudmap
Here we can find the following folder structure:

This post will describe how to deploy ECR Repos, and services code and repos.

ECR

resource "aws_ecr_repository" "fgms-uno" {
name = "fgms-uno"
}

resource "aws_ecr_repository" "fgms-due" {
name = "fgms-due"
}

resource "aws_ecr_repository" "fgms-tre" {
name = "fgms-tre"
}

The aws_ecr_repository resource type creates an Elastic Container Registry (ECR) repository in AWS. In this case, three ECR repositories are being defined: fgms-uno, fgms-due, and fgms-tre.

Each repository is defined by a block of code with the following format:

resource "aws_ecr_repository" "REPOSITORY_NAME" {
name = "REPOSITORY_NAME"
}

The name argument specifies the name of the ECR repository. This must be unique within the region and account specified in the provider block.

Once the Terraform code is applied, these three ECR repositories will be created in the specified AWS account and region. They can be used to store Docker images and manage their lifecycle.

SERVICES

This is what our simple application will do:
— uno-service receives an HTTP request from the load balancer and, using Cloud Map as internal DNS routes the request to due-service and tre-service;
— uno-service prepares the response to the load balancer using responses from due-service and tre-service.

You can imagine uno-service as a frontend app that gathers info from the underlying microservices and responds to requests from the outside.

Apps Code

The code is a basic nodejs express app that returns a static response. The only difference is in the uno-service; it has a proxy role so, the code contains due HTTP requests with Axios to due-service and tre-service.

uno-service code

const express = require('express')
const fetch = require('node-fetch');
const app = express()
const port = 3000

app.get('/', async (req, res) => {
const dueResponse = await fetch(`${process.env.DUE_SERVICE_API_BASE}:3000`)
const treResponse = await fetch(`${process.env.TRE_SERVICE_API_BASE}:3000`)
const dueData = await dueResponse.json();
const treData = await treResponse.json();
res.json({
msg: "Hello world! (from uno)",
due:{
url: process.env.DUE_SERVICE_API_BASE,
data: dueData,
},
uno:{
url: process.env.TRE_SERVICE_API_BASE,
data: treData,
}
})
})

app.get('/healthcheck', (req, res) => {
res.send('Hello World!')
})

app.listen(port, () => {
console.log(`Example app listening on port ${port}`)
})

due/tre-service code

const express = require('express')
const fetch = require('node-fetch');
const app = express()
const port = 3000

app.get('/', (req, res) => {
res.json({
msg: "Hello world! (from tre)",
})
})
app.get('/healthcheck', (req, res) => {
res.send('Hello World!')
})

app.listen(port, () => {
console.log(`Example app listening on port ${port}`)
})

Apps Dockerfile

FROM node:8-alpine
WORKDIR /usr/app
COPY package.json .
RUN npm i --quiet
COPY . .
RUN npm install -g pm2@4.2.1
CMD ["pm2-runtime", "./index.js"]

FROM node:8-alpine is a Dockerfile directive that specifies the base image for the Docker image that is being built. In this case, it specifies that the image should be based on the node:8-alpine image, which is a lightweight version of the Node.js runtime environment that is based on the Alpine Linux distribution.

WORKDIR /usr/app is a Dockerfile directive that sets the working directory for subsequent instructions in the Dockerfile. In this case, it sets the working directory to /usr/app.

COPY package.json . is a Dockerfile directive that copies the package.json file from the context (the directory containing the Dockerfile) to the working directory (/usr/app).

RUN npm i --quiet is a Dockerfile directive that runs the command npm i --quiet in the container. This command installs all of the dependencies specified in the package.json file. The --quiet flag suppresses npm's progress output.

COPY . . is a Dockerfile directive that copies all files from the context (the directory containing the Dockerfile) to the working directory (/usr/app).

RUN npm install -g pm2@4.2.1 is a Dockerfile directive that installs the pm2 package (version 4.2.1) globally in the container. pm2 is a process manager for Node.js applications.

CMD ["pm2-runtime", "./index.js"] is a Dockerfile directive that specifies the default command to run when the container is started. In this case, it specifies that the pm2-runtime command should be run with the ./index.js argument. This will start the Node.js application in ./index.js using pm2.

Upload every docker image in the respective ECR repo

The file ecr-deploy-script.sh contains bash command with AWS-CLI to upload container images on ECR.

You must run the following commands for every apps (uno, due, tre):

aws ecr get-login-password --region eu-west-1 | docker login --username AWS --password-stdin <YOUR AWS ACCOUNT ID>.dkr.ecr.eu-west-1.amazonaws.com

docker build -t uno ./uno
# docker buildx build --platform=linux/amd64 -t uno ./uno !!In case you use apple m1 chip use this command instead of the first!!
docker tag uno <YOUR AWS ACCOUNT ID>.dkr.ecr.eu-west-1.amazonaws.com/fgms-uno:v1
docker push <YOUR AWS ACCOUNT ID>.dkr.ecr.eu-west-1.amazonaws.com/fgms-uno:v1

aws ecr get-login-password --region eu-west-1 | docker login --username AWS --password-stdin <YOUR AWS ACCOUNT ID>.dkr.ecr.eu-west-1.amazonaws.com is a command that retrieves an authentication token from AWS Identity and Access Management (IAM) and then uses it to log in to the Amazon Elastic Container Registry (ECR) in the eu-west-1 region. The | symbol pipes the output of the aws command into the docker login command.

docker build -t uno ./uno is a command that builds a Docker image from the Dockerfile in the ./uno directory and tags the image with the name uno.

docker tag uno <YOUR AWS ACCOUNT ID>.dkr.ecr.eu-west-1.amazonaws.com/fgms-uno:v1 is a command that tags the uno image with a new name that includes the specified Amazon ECR registry and repository name.

docker push <YOUR AWS ACCOUNT ID>.dkr.ecr.eu-west-1.amazonaws.com/fgms-uno:v1 is a command that pushes the uno image to the specified Amazon ECR repository.

Service Terraform Stack

Inside the folder stacks/services there are all the services AWS Resources needed to create the infrastructure.


resource "aws_ecs_task_definition" "fgms_uno_td" {
family = "fgms_uno_td"
network_mode = "awsvpc"
requires_compatibilities = ["FARGATE"]
cpu = "256"
memory = "512"
execution_role_arn = aws_iam_role.fgms_uno_task_role.arn

container_definitions = jsonencode(
[
{
cpu : 256,
image : "248581660709.dkr.ecr.eu-west-1.amazonaws.com/fgms-uno:v1",
memory : 512,
name : "fgms-uno",
networkMode : "awsvpc",
environment : [
{
name : "DUE_SERVICE_API_BASE",
value : "http://${data.terraform_remote_state.services-due.outputs.fgms_due_service_namespace}.${data.terraform_remote_state.dns.outputs.fgms_private_dns_namespace}"
},
{
name : "TRE_SERVICE_API_BASE",
value : "http://${data.terraform_remote_state.services-tre.outputs.fgms_tre_service_namespace}.${data.terraform_remote_state.dns.outputs.fgms_private_dns_namespace}"
}
],
portMappings : [
{
containerPort : 3000,
hostPort : 3000
}
],
logConfiguration : {
logDriver : "awslogs",
options : {
awslogs-group : "/ecs/fgms_log_group",
awslogs-region : "eu-west-1",
awslogs-stream-prefix : "uno"
}
}
}
]
)
}

resource "aws_ecs_service" "fgms_uno_td_service" {
name = "fgms_uno_td_service"
cluster = data.terraform_remote_state.ecs_cluster.outputs.fgms_ecs_cluster_id
task_definition = aws_ecs_task_definition.fgms_uno_td.arn
desired_count = "1"
launch_type = "FARGATE"

network_configuration {
security_groups = ["${aws_security_group.ecs_tasks_sg.id}"]
subnets = ["${data.terraform_remote_state.vpc.outputs.fgms_private_subnets_ids[0]}"]
}

load_balancer {
target_group_arn = aws_alb_target_group.fgms_uno_tg.id
container_name = "fgms-uno"
container_port = 3000
}

service_registries {
registry_arn = aws_service_discovery_service.fgms_uno_service.arn
}
}

resource "aws_security_group" "ecs_tasks_sg" {
name = "ecs_tasks_sg"
description = "allow inbound access from the ALB only"
vpc_id = data.terraform_remote_state.vpc.outputs.fgms_vpc_id

ingress {
protocol = "tcp"
from_port = "3000"
to_port = "3000"
security_groups = ["${data.terraform_remote_state.alb.outputs.fgms_alb_sg_id}"]
}

ingress {
protocol = -1
from_port = 0
to_port = 0
self = true
}

egress {
protocol = "-1"
from_port = 0
to_port = 0
cidr_blocks = ["0.0.0.0/0"]
}
}


resource "aws_alb_target_group" "fgms_uno_tg" {
name = "fgms-uno-tg"
port = 80
protocol = "HTTP"
vpc_id = data.terraform_remote_state.vpc.outputs.fgms_vpc_id
target_type = "ip"
health_check {
path = "/healthcheck"
}
}

resource "aws_alb_listener" "fgms_uno_tg_listener" {
load_balancer_arn = data.terraform_remote_state.alb.outputs.fgms_alb_id
port = "80"
protocol = "HTTP"

default_action {
target_group_arn = aws_alb_target_group.fgms_uno_tg.id
type = "forward"
}
}

The aws_ecs_task_definition resource creates an ECS task definition, which is a blueprint for an ECS task. The task definition specifies the Docker image to use for the task, the number of CPU and memory units to use, and the command to run when the task is launched. The task definition also specifies the IAM role that the task should use and the environment variables that should be passed to the container when it is started.

The aws_ecs_service resource creates an ECS service, which is a long-running task that is typically used to host a web application. The service is associated with the ECS cluster and task definition defined in the code block, and it specifies the desired number of tasks that should be running and the launch type (Fargate). The service also specifies the network configuration and load balancer settings. The service is also registered with a service discovery service, which allows it to be discovered by other services.

The aws_security_group resource creates an Amazon Elastic Compute Cloud (Amazon EC2) security group, which is a virtual firewall that controls inbound and outbound traffic to and from the ECS tasks. The security group only allows inbound traffic on port 3000 from the specified security group and allows all outbound traffic.

The aws_alb_target_group resource creates an Amazon Elastic Load Balancer (ALB) target group, which is used to route traffic to the ECS tasks. The target group is associated with the specified security group and port.

The aws_alb_listener_rule resource creates an ALB listener rule, which specifies how traffic should be routed to the target group. The listener rule routes traffic from the specified ALB listener to the target group based on the specified host header and path pattern.

Regarding Cloud Map, we declared the following resource with DNS stack as a dependency:

resource "aws_service_discovery_service" "fgms_uno_service" {
name = var.fgms_uno_service_namespace

dns_config {
namespace_id = data.terraform_remote_state.dns.outputs.fgms_dns_discovery_id

dns_records {
ttl = 10
type = "A"
}

routing_policy = "MULTIVALUE"
}

health_check_custom_config {
failure_threshold = 2
}
}

The aws_service_discovery_service resource creates an Amazon Route 53 service discovery service, which allows services to discover each other through DNS. The service is given a name, which is specified using the name parameter and is derived from a variable.

The dns_config block specifies the DNS configuration for the service discovery service. It specifies the ID of the Amazon Route 53 namespace that the service should use, as well as the DNS records and routing policy for the service. The DNS records specify the Time To Live (TTL) value and type for the service.

The health_check_custom_config block specifies custom health check configurations for the service discovery service. It specifies the number of consecutive failures required before the health check status is considered a failure.

Deploy

Let’s deploy every service (after uploading the docker image on ECR) with Terraform commands described in the part one of this tutorial:

$ cd stacks/dns
$ terraform init
$ terraform plan
$ terraform apply

The services deploy order is:

  1. due
  2. tre
  3. uno

Uno needs cloud map DNS record value of due and tre so, it must be the last one.

The following image represents all records inside the private Cloud Map hosted zone:

TEST ALL THE SYSTEM

Once every stack has been deployed, it’s time for testing our application!

Go to the load balancer section inside the EC2 console:

Under the DNS name there’s the load balancer URL to copy and paste into the browser.

Copy the URL in the search bar of the browser and you should receive the following response.

If you get an output like this you successfully complete this tutorial; otherwise, check this page to find if something missing.

Bye 😘

--

--