Although most developers are shifting to serverless and containerized architectures for building their applications, EC2 instances are still among the most popular and used AWS Services. In this blog, I will walk you through the steps required to deploy your scalable NodeJS applications on Amazon EC2 using AWS CodePipeline and mention some of the challenges that you might face while setting up this solution. It might first seem simple, but trust me it requires more effort than you expect and that's the main reason I am writing this blog today.
Okay enough said, now lets rock and roll! 🎸
Services covered in this blog:
- Amazon EC2
- AWS CodePipeline EC2
- AWS CodeBuild
- AWS CodeDeploy
- NodeJS
- Elastic Load Balancing
- Amazon Auto Scaling
- PM2
- NGINX
I will assume that you have successfully set up your underlying infrastructure using your preferred method (Manually, CDK, CloudFormation, Terraform, etc.)
So, you have set up your EC2 instances, CodeDeploy Agent, Autoscaling Group, installed the latest Nginx, NodeJS, and PM2 versions on the EC2 instances, and ready to deploy your NodeJS Application via AWS CodePipeline. First, you start by creating a new Pipeline project, connect to your source provider such as GitHub, then CodeBuild for compiling your source code and running some unit tests then finally, you choose AWS Code Deploy for deploying your latest releases on Amazon EC2 through the deployment group. The tricky part comes with the buildspec.yml and appspec.yml files where you can set a collection of commands used to build and deploy your code. The first thing that comes to mind is creating the below buildspec and appspec files.
buildspec.yml file
version: 0.2
phases:
install:
runtime-versions:
nodejs: 10
commands:
- echo Installing
pre_build:
commands:
- echo Installing source NPM dependencies.
- npm install
build:
commands:
- echo Build started on `date`
- echo Compiling the Node.js code
- npm run build
post_build:
commands:
- echo Build completed on `date`
artifacts:
files:
- '**/*'
appspec.yml file
version: 0.0
os: linux
files:
- source: /
destination: /usr/share/nginx/html
You push your code to your version control system (GitHub in our case) and trigger your first CodePipeline pipeline and guess what? The pipeline will successfully complete at this stage. Now, we are excited to run our node script using "npm start" but suddenly we get the below error:
Error: Cannot find module '../package.json'
But how? We are pretty sure that our package.json files are located under the root directory and libraries in the node_modules folder. Honestly speaking, the only fix for this issue is to run npm rebuild
or just remove the node_modules folder and run npm install
again on the EC2 instance. After doing that, you will be able to start your node script. That's great but it doesn't meet our requirements. We are looking for a fully automated deployment with zero human intervention. Luckily, the life cycle event hooks section of the Code Deploy appspec.yml file will solve this for us by creating a couple of bash scripts that can replace the "npm install and build" steps executed by Code Build leaving AWS Code Build for the test cases phase only. Here's how our two files look like now:
buildspec.yml file
version: 0.2
phases:
pre_build:
commands:
- echo Installing source NPM dependencies...
- npm install
build:
commands:
- echo Build started on `date`
- echo Compiling the Node.js code
- echo Running unit tests
- npm test
post_build:
commands:
- echo Build completed on `date`
artifacts:
files:
- '**/*'
appspec.yml file
version: 0.0
os: linux
files:
- source: /
destination: /usr/share/nginx/html
hooks:
BeforeInstall:
- location: scripts/BeforeInstallHook.sh
timeout: 300
AfterInstall:
- location: scripts/AfterInstallHook.sh
timeout: 300
- BeforeInstall: Use to run tasks before the replacement task set is created. One target group is associated with the original task set. If an optional test listener is specified, it is associated with the original task set. A rollback is not possible at this point.
#!/bin/bash
set -e
yum update -y
pm2 update
- AfterInstall: Use to run tasks after the replacement task set is created and one of the target groups is associated with it. If an optional test listener is specified, it is associated with the original task set. The results of a hook function at this lifecycle event can trigger a rollback.
#!/bin/bash
set -e
cd /usr/share/nginx/html
npm install
npm run build
Note: We are setting the set -e flag to stop the execution of our scripts in the event of an error.
Another issue you might face even after updating your appspec and buildspec files is: The deployment failed because a specified file already exists at this location: /usr/share/nginx/html/.cache/plugins/somefile.js
In our case, we will solve this by simply asking CodeDeploy to replace already existing files using the overwrite:true
option.
Final appspec.yml file
version: 0.0
os: linux
files:
- source: /
destination: /usr/share/nginx/html
overwrite: true
hooks:
BeforeInstall:
- location: scripts/BeforeInstallHook.sh
timeout: 300
AfterInstall:
- location: scripts/AfterInstallHook.sh
timeout: 300
Perfect, we have reached a stage that after AWS CodePipeline is successfully complete, we are now able to start our npm script without facing any issues. It's time to automatically restart our application upon every new deployment using PM2 which is a process management tool responsible for running and managing our Node.js applications.
Simply, run sudo npm install pm2@latest -g
on your EC2 instances, then generate the pm2 ecosystem.config.js file to declare the applications/services you would like to deploy your code into by executing this command pm2 ecosystem
. PM2 will generate a sample file for you so make sure it matches your application structure.
ecosystem.config.js file
module.exports = {
apps : [{
name: "npm",
cwd: '/usr/share/nginx/html',
script: "npm",
args: 'start',
env: {
NODE_ENV: "production",
HOST: '0.0.0.0',
PORT: '3000',
},
}]
}
At this stage, you can simply run pm2 start ecosystem.config.js
and PM2 will start your application for you. But that's not the only power of PM2. This module can actually restart your application automatically upon every new release by simply including the watch parameter in the ecosystem.config.js file.
Final ecosystem.config.js file_
module.exports = {
apps : [{
name: "npm",
cwd: '/usr/share/nginx/html',
script: "npm",
args: 'start',
watch: true,
env: {
NODE_ENV: "production",
HOST: '0.0.0.0',
PORT: '3000',
},
}]
}
Wonderful! We have set up a fully automated deployment pipeline that can run unit tests, install, build, and deploy the node modules on the Amazon EC2 instances then PM2 takes care of restarting the application for us.
Okay, what if our server got rebooted for some reason? We want our app to start automatically and this can also be accomplished by using the pm2 startup
parameter that can be executed after starting your application.
Have we missed anything so far? Oh yes! Autoscaling
We want to make sure that our production environment scalable enough to accommodate huge loads on our application.
This can easily be set up through AWS CodeDeploy by updating the deployment group environment configuration from Amazon EC2 instances "Tagging Strategy' to Amazon EC2 Auto Scaling groups. This is a great feature by AWS CodeDeploy where it can deploy your latest revisions to new instances automatically while keeping your desired number of instances healthy throughout the deployment. However, we will face another challenge here. PM2 startup makes sure that your application is started after any instance reboot but it, unfortunately, doesn't work this way when Autoscaling Group launch new instances thus the application doesn't automatically run in the event of horizontal scaling. But don't worry I got your back!
In order to solve this issue, go to your Launch Configuration settings, and in the "userdata" section add the below bash script to it.
#!/bin/bash -ex
# restart pm2 and thus node app on reboot
crontab -l | { cat; echo "@reboot sudo pm2 start /usr/share/nginx/html/ecosystem.config.js -i 0 --name \"node-app\""; } | crontab -
# start the server
pm2 start /usr/share/nginx/html/ecosystem.config.js -i 0 --name "node-app"
There you go! Now you have a highly scalable NodeJS Application that is fully automated using AWS CodePipeline.
Conclusion
I hope this blog has been informative to you all. I have tried as much as possible to make this blog look like a story because the main purpose of writing it is to show you the many challenges DevOps Engineers and Developers face to set up this solution and the various ways used to solve it. I will not stop updating this project and will make sure it has an improvement plan because I know it can even be better!
References:
- https://regbrain.com/article/node-nginx-ec2
- https://pm2.keymetrics.io/docs/usage/startup
- https://www.digitalocean.com/community/tutorials/how-to-set-up-a-node-js-application-for-production-on-ubuntu-20-04
- https://cloudnweb.dev/2019/12/a-complete-guide-to-aws-elastic-load-balancer-using-nodejs/
- https://pm2.keymetrics.io/docs/usage/watch-and-restart/
- https://pm2.keymetrics.io/docs/usage/application-declaration/#cli
Top comments (2)
good post!!
Glad you like it!