Introduction

GitHub this note shows how to deploy multiple NextJS on Amazon ECS using CDK.

  • Create an Amazon ECS Cluster
  • Create a service
  • Create ALB with HTTPS listener
  • Update Route53 CNAME Record

ECR Image

Let create two ecr repositories

import { RemovalPolicy, Stack, StackProps, aws_ecr } from "aws-cdk-lib";
import { Construct } from "constructs";
 
interface EcrProps extends StackProps {
  repoName: string;
}
 
export class EcrStack extends Stack {
  public readonly repoName: string;
  constructor(scope: Construct, id: string, props: EcrProps) {
    super(scope, id, props);
 
    const ecr = new aws_ecr.Repository(this, `${props.repoName}`, {
      removalPolicy: RemovalPolicy.DESTROY,
      repositoryName: props.repoName,
      autoDeleteImages: true,
    });
 
    this.repoName = ecr.repositoryName;
  }
}

Then we can create two ecr repositories

const blogEcr = new EcrStack(app, "BlogEcr", {
  repoName: "blog-ecr",
  env: {
    region: process.env.CDK_DEFAULT_REGION,
    account: process.env.CDK_DEFAULT_ACCOUNT,
  },
});
 
const cluster = new EcsStack(app, "EcsCluster", {
  vpcId: vpcId,
  vpcName: vpcName,
  env: {
    region: process.env.CDK_DEFAULT_REGION,
    account: process.env.CDK_DEFAULT_ACCOUNT,
  },
});

ECS Cluster

Let create a ECS Cluster

  • Interface with vpcId and vpcName which already existing
  • Create an application load balancer
  • Create HTTP and HTTPS listener
interface EcsProps extends StackProps {
  vpcId: string;
  vpcName: string;
}
 
export class EcsStack extends Stack {
  public readonly cluster: aws_ecs.Cluster;
 
  constructor(scope: Construct, id: string, props: EcsProps) {
    super(scope, id, props);
 
    Aspects.of(this).add(new CapacityProviderDependencyAspect());
 
    // lookup an existed vpc
    const vpc = aws_ec2.Vpc.fromLookup(this, "LookUpVpc", {
      vpcId: props.vpcId,
      vpcName: props.vpcName,
    });
 
    // ecs cluster
    this.cluster = new aws_ecs.Cluster(this, "EcsClusterForWebServer", {
      vpc: vpc,
      clusterName: "EcsClusterForWebServer",
      containerInsights: true,
      enableFargateCapacityProviders: true,
    });
  }
}

We need to add a IASpect to fix error when destroying the ecs stack

/**
 * Add a dependency from capacity provider association to the cluster
 * and from each service to the capacity provider association.
 */
class CapacityProviderDependencyAspect implements IAspect {
  public visit(node: IConstruct): void {
    if (node instanceof aws_ecs.CfnClusterCapacityProviderAssociations) {
      // IMPORTANT: The id supplied here must be the same as the id of your cluster. Don't worry, you won't remove the cluster.
      node.node.scope?.node.tryRemoveChild("EcsClusterForWebServer");
    }
 
    if (node instanceof aws_ecs.Ec2Service) {
      const children = node.cluster.node.findAll();
      for (const child of children) {
        if (child instanceof aws_ecs.CfnClusterCapacityProviderAssociations) {
          child.node.addDependency(node.cluster);
          node.node.addDependency(child);
        }
      }
    }
  }
}

Chat Service

Let create a chat service

interface ChatBotProps extends StackProps {
  cluster: aws_ecs.Cluster;
  ecrRepoName: string;
  certificate: string;
  vpcId: string;
  vpcName: string;
}

Create a chat service

export class ChatBotService extends Stack {
  public readonly service: aws_ecs.FargateService;
 
  constructor(scope: Construct, id: string, props: ChatBotProps) {
    super(scope, id, props);
 
    // lookup an existed vpc
    const vpc = aws_ec2.Vpc.fromLookup(this, "LookUpVpc", {
      vpcId: props.vpcId,
      vpcName: props.vpcName,
    });
 
    // task role pull ecr image
    const executionRole = new aws_iam.Role(
      this,
      "RoleForEcsTaskToPullEcrChatbotImage",
      {
        assumedBy: new aws_iam.ServicePrincipal("ecs-tasks.amazonaws.com"),
        roleName: "RoleForEcsTaskToPullEcrChatbotImage",
      }
    );
 
    executionRole.addToPolicy(
      new aws_iam.PolicyStatement({
        effect: Effect.ALLOW,
        actions: ["ecr:*"],
        resources: ["*"],
      })
    );
 
    // ecs task definition
    const task = new aws_ecs.FargateTaskDefinition(
      this,
      "TaskDefinitionForWeb",
      {
        family: "latest",
        cpu: 2048,
        memoryLimitMiB: 4096,
        runtimePlatform: {
          operatingSystemFamily: aws_ecs.OperatingSystemFamily.LINUX,
          cpuArchitecture: aws_ecs.CpuArchitecture.X86_64,
        },
        // taskRole: "",
        // retrieve container images from ECR
        // executionRole: executionRole,
      }
    );
 
    // task add container
    task.addContainer("NextChatbotContainer", {
      containerName: "chat-bot-ecr",
      memoryLimitMiB: 4096,
      memoryReservationMiB: 4096,
      stopTimeout: Duration.seconds(120),
      startTimeout: Duration.seconds(120),
      environment: {
        FHR_ENV: "DEPLOY",
      },
      // image: aws_ecs.ContainerImage.fromRegistry(
      //   "public.ecr.aws/b5v7e4v7/chat-bot-ecr:latest"
      // ),
      image: aws_ecs.ContainerImage.fromEcrRepository(
        aws_ecr.Repository.fromRepositoryName(
          this,
          "chat-bot-ecr",
          props.ecrRepoName
        )
      ),
      portMappings: [{ containerPort: 3000 }],
    });
 
    // service
    const service = new aws_ecs.FargateService(this, "ChatbotService", {
      vpcSubnets: {
        subnetType: aws_ec2.SubnetType.PUBLIC,
      },
      assignPublicIp: true,
      cluster: props.cluster,
      taskDefinition: task,
      desiredCount: 2,
      // deploymentController: {
      // default rolling update
      // type: aws_ecs.DeploymentControllerType.ECS,
      // type: aws_ecs.DeploymentControllerType.CODE_DEPLOY,
      // },
      capacityProviderStrategies: [
        {
          capacityProvider: "FARGATE",
          weight: 1,
        },
        {
          capacityProvider: "FARGATE_SPOT",
          weight: 0,
        },
      ],
    });
 
    // scaling on cpu utilization
    const scaling = service.autoScaleTaskCount({
      maxCapacity: 4,
      minCapacity: 2,
    });
 
    scaling.scaleOnMemoryUtilization("CpuUtilization", {
      targetUtilizationPercent: 50,
    });
 
    // application load balancer
    const alb = new aws_elasticloadbalancingv2.ApplicationLoadBalancer(
      this,
      "AlbForEcs",
      {
        loadBalancerName: "AlbForEcsDemo",
        vpc: vpc,
        internetFacing: true,
      }
    );
 
    // add listener
    const listener = alb.addListener("Listener", {
      port: 80,
      open: true,
      protocol: aws_elasticloadbalancingv2.ApplicationProtocol.HTTP,
    });
 
    // add target
    listener.addTargets("EcsService", {
      port: 80,
      targets: [
        service.loadBalancerTarget({
          containerName: "chat-bot-ecr",
          containerPort: 3000,
          protocol: aws_ecs.Protocol.TCP,
        }),
      ],
      healthCheck: {
        timeout: Duration.seconds(10),
      },
    });
 
    // add listener https
    const listenerHttps = alb.addListener("ListenerHttps", {
      port: 443,
      open: true,
      protocol: aws_elasticloadbalancingv2.ApplicationProtocol.HTTPS,
      certificates: [ListenerCertificate.fromArn(props.certificate)],
    });
 
    // listner add target
    listenerHttps.addTargets("EcsServiceHttps", {
      port: 80,
      targets: [
        service.loadBalancerTarget({
          containerName: "chat-bot-ecr",
          containerPort: 3000,
          protocol: aws_ecs.Protocol.TCP,
        }),
      ],
      healthCheck: {
        timeout: Duration.seconds(10),
      },
    });
 
    // exported
    this.service = service;
  }
}

Blog Service

Similar as the chat service, we also can create a blog service

BlogServiceStack
export class BlogService extends Stack {
  public readonly service: aws_ecs.FargateService;
 
  constructor(scope: Construct, id: string, props: BlogProps) {
    super(scope, id, props);
 
    // lookup an existed vpc
    const vpc = aws_ec2.Vpc.fromLookup(this, "LookUpVpcBlogService", {
      vpcId: props.vpcId,
      vpcName: props.vpcName,
    });
 
    // task role pull ecr image
    const executionRole = new aws_iam.Role(
      this,
      "RoleForEcsTaskToPullEcrChatbotImageBlogService",
      {
        assumedBy: new aws_iam.ServicePrincipal("ecs-tasks.amazonaws.com"),
        roleName: "RoleForEcsTaskToPullEcrChatbotImageBlogService",
      }
    );
 
    executionRole.addToPolicy(
      new aws_iam.PolicyStatement({
        effect: Effect.ALLOW,
        actions: ["ecr:*"],
        resources: ["*"],
      })
    );
 
    // ecs task definition
    const task = new aws_ecs.FargateTaskDefinition(
      this,
      "TaskDefinitionForWebBlogService",
      {
        family: "latest",
        cpu: 2048,
        memoryLimitMiB: 4096,
        runtimePlatform: {
          operatingSystemFamily: aws_ecs.OperatingSystemFamily.LINUX,
          cpuArchitecture: aws_ecs.CpuArchitecture.X86_64,
        },
        // taskRole: "",
        // retrieve container images from ECR
        // executionRole: executionRole,
      }
    );
 
    // task add container
    task.addContainer("NextChatbotContainerBlogService", {
      containerName: "blog-ecr",
      memoryLimitMiB: 4096,
      memoryReservationMiB: 4096,
      stopTimeout: Duration.seconds(120),
      startTimeout: Duration.seconds(120),
      environment: {
        FHR_ENV: "DEPLOY",
      },
      // image: aws_ecs.ContainerImage.fromRegistry(
      //   "public.ecr.aws/b5v7e4v7/blog-ecr:latest"
      // ),
      image: aws_ecs.ContainerImage.fromEcrRepository(
        aws_ecr.Repository.fromRepositoryName(
          this,
          "blog-ecr",
          props.ecrRepoName
        )
      ),
      portMappings: [{ containerPort: 3000 }],
    });
 
    // service
    const service = new aws_ecs.FargateService(this, "BlogService", {
      vpcSubnets: {
        subnetType: aws_ec2.SubnetType.PUBLIC,
      },
      assignPublicIp: true,
      cluster: props.cluster,
      taskDefinition: task,
      desiredCount: 2,
      // deploymentController: {
      // default rolling update
      // type: aws_ecs.DeploymentControllerType.ECS,
      // type: aws_ecs.DeploymentControllerType.CODE_DEPLOY,
      // },
      capacityProviderStrategies: [
        {
          capacityProvider: "FARGATE",
          weight: 1,
        },
        {
          capacityProvider: "FARGATE_SPOT",
          weight: 0,
        },
      ],
    });
 
    // scaling on cpu utilization
    const scaling = service.autoScaleTaskCount({
      maxCapacity: 4,
      minCapacity: 2,
    });
 
    scaling.scaleOnMemoryUtilization("CpuUtilization", {
      targetUtilizationPercent: 50,
    });
 
    // application load balancer
    const alb = new aws_elasticloadbalancingv2.ApplicationLoadBalancer(
      this,
      "AlbForEcsBlogService",
      {
        loadBalancerName: "AlbForEcsDemoBlogService",
        vpc: vpc,
        internetFacing: true,
      }
    );
 
    // add listener
    const listener = alb.addListener("ListenerBlogService", {
      port: 80,
      open: true,
      protocol: aws_elasticloadbalancingv2.ApplicationProtocol.HTTP,
    });
 
    // add target
    listener.addTargets("EcsServiceBlogService", {
      port: 80,
      targets: [
        service.loadBalancerTarget({
          containerName: "blog-ecr",
          containerPort: 3000,
          protocol: aws_ecs.Protocol.TCP,
        }),
      ],
      healthCheck: {
        timeout: Duration.seconds(10),
      },
    });
 
    // add listener https
    const listenerHttps = alb.addListener("ListenerHttpsBlogService", {
      port: 443,
      open: true,
      protocol: aws_elasticloadbalancingv2.ApplicationProtocol.HTTPS,
      certificates: [ListenerCertificate.fromArn(props.certificate)],
    });
 
    // listner add target
    listenerHttps.addTargets("EcsServiceHttpsBlogService", {
      port: 80,
      targets: [
        service.loadBalancerTarget({
          containerName: "blog-ecr",
          containerPort: 3000,
          protocol: aws_ecs.Protocol.TCP,
        }),
      ],
      healthCheck: {
        timeout: Duration.seconds(10),
      },
    });
 
    // exported
    this.service = service;
  }
}

ECS Deployment

  • ECS task role versus task execution role
  • NextJS env for production
  • Build and test conatiner image
  • Manual update an ECS service
  • CI/CD pipeline to deploy ECS services

ECS task role is permissions to call AWS service from the running container such as S3, Polly.

ECS task execution role is permissions for the Amazon ECS service to pull container image from ECR.

[!IMPORTANT] Please save enviroment variables in .env for production

Dockerfile
FROM node:16-alpine AS deps
# FROM public.ecr.aws/docker/library/node:16-alpine

RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./
RUN \
  if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
  elif [ -f package-lock.json ]; then npm ci; \
  elif [ -f pnpm-lock.yaml ]; then yarn global add pnpm && pnpm i --frozen-lockfile; \
  else echo "Lockfile not found." && exit 1; \
  fi

FROM node:16-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN yarn build

FROM node:16-alpine AS runner
WORKDIR /app
ENV NODE_ENV production
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static

USER nextjs
EXPOSE 3000
ENV PORT 3000
CMD ["node", "server.js"]

Build and push the image to ECR repository

# please export ACCOUNT in terminal
import os
 
# parameters
REGION = "ap-southeast-1"
REPO_NAME = "blog-ecr"
ACCOUNT = os.environ["ACCOUNT"]
 
# delete all docker images
os.system("sudo docker system prune -a")
 
# build image
os.system(f"sudo docker build -t {REPO_NAME} . ")
 
#  aws ecr login
os.system(f"aws ecr get-login-password --region {REGION} | sudo docker login --username AWS --password-stdin {ACCOUNT}.dkr.ecr.{REGION}.amazonaws.com")
 
# get image id
IMAGE_ID=os.popen(f"sudo docker images -q {REPO_NAME}:latest").read()
 
# tag image
os.system(f"sudo docker tag {IMAGE_ID.strip()} {ACCOUNT}.dkr.ecr.{REGION}.amazonaws.com/{REPO_NAME}:latest")
 
# create ecr repository
os.system(f"aws ecr create-repository --registry-id {ACCOUNT} --repository-name {REPO_NAME}")
 
# push image to ecr
os.system(f"sudo docker push {ACCOUNT}.dkr.ecr.{REGION}.amazonaws.com/{REPO_NAME}:latest")

Let test the container image locally

sudo docker run -d -p 3000:3000 $ACCOUNT.dkr.ecr.ap-southeast-1.amazonaws.com/blog-ecr:latest
aws ecs update-service \
--cluster EcsClusterForNextApps \
--service $SERVICE_NAME \
--force-new-deployment