GitHub this note shows how to deploy multiple NextJS on Amazon ECS using CDK.
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,
},
});
Let create a ECS Cluster
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);
}
}
}
}
}
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;
}
}
Similar as the chat service, we also can create a blog service
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 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
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