Introduction
With the rise of microservice architecture, the tooling around it has grown tremendously with docker becoming the defacto choice for containerization. A docker container is much lighter than a full fledged virtual machine and allows you to run your applications inside a sandbox environment which is completely isolated from the host machine. These application can then be packaged for easy sharing across multiple platforms.
As your services are encapsulated inside a container, we don't have as much liberty to debug them in real time, as we do when running our service locally without docker. The container OS is running in isolation from your local machine OS. As a result of this we wont be able to make changes to our code and have them reflect in real time and also we wont be able to make request to our servers from outside the containers. In this article we will look at how to dockerize your Node.js application and then debug them in real time when running inside docker containers.
Setup
First lets look at a simple API server written in Node.js. We will use this sample server to dockerize and then later debug it.
const express = require('express');
const app = express();
const port = 3000;
app.get('/', async (req, res) => {
try {
console.log(`Got a hit at ${Date()}`);
const variableToDebug = "docker rules";
res.status(200).send({message: "Success", data : variableToDebug})
} catch (err) {
console.log(err);
res.status(500).send({message: "Something went wrong"})
}
});
app.listen(port, () => console.log(`app listening on port ${port}!`));
This is app.js
of our node server and can run be using node app.js
.
Dockerizing your app
We will now dockerize our express server. We can do so just by using docker-cli
which is a utility docker provides that can be used to interact with docker using shell. However it will a long command with lots of flags so we will use Dockerfile
for the same. A Dockerfile
is config file which can used to configure the steps involved in building a docker image. This way we can share our server and somebody else can use our Dockerfile to build images. Create a new file with name Dockerfile
and paste the following.
FROM node:latest
WORKDIR /app
COPY package.* /app
RUN npm install
COPY . /app
CMD node app.js
EXPOSE 3000
FROM
specifies the container base image: node:latest. This image will contain the latest node and npm installed on it. We can specify the version of the node image as well here.
WORKDIR
defines your working directory. All our run commands will execute in this directory. We will also use this directory as base directory for our code.
COPY
is used to copy files from your local directory to container directory. Docker builds each line of a Dockerfile individually. This forms the 'layers' of the Docker image. As an image is built, Docker caches each layer. Hence when we copy package.json and package-lock.json to our directory and RUN
npm install
before doing the COPY
of complete codebase, it allows us to take advantage of caching. As a result of above order, docker will cache for node_modules
and wont install again unless you change package.json
.
CMD
is used to fire shell commands which will be executed when the container starts. We will use this to start our server.
EXPOSE
does not publish the port, but instead functions as a way of documenting which ports on the container will be published at runtime. We will open the ports while running the image.
While RUN and CMD both are used to execute commands insider container, RUN lets you execute commands inside of your Docker image. These commands get executed once at build time and get written into your Docker image as a new layer. CMD lets you define a default command to run when your container starts.
Use this command to build the image of our application :
docker build -t node-docker .
This commands builds the image for application with -t
flag specifying the name we want to give our image. To verify use command docker images
.
docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
node-docker latest 0567f36cdb70 About a minute ago 950 MB
node latest c31fbeb964cc About a minute ago 943 MB
We have built the image and now we will use this image to run a container. Think of image as a recipe and container as a cake. You can make (run) as many cakes (running instance of image) from the recipe (image). Use this command to start the container :
docker run --rm -it --name node-docker -p 3000:3000 node-docker
The --rm
flag automatically stops and removes the container once the container exits. The -i
and -t
flag combined allows you to work with interactive processes like shell. The -p
flag maps a local port 3000 to a container port 3000. This is our gateway into container. We can ping localhost:3000
which will hit the local 3000 port and then the container will forward that request to our server running on port 3000 inside it. This will start up your server as well and you can verify by :
curl --location --request GET 'http://localhost:3000'
{"message":"Success","data":"docker rules"}
Interacting with Docker
We have fully dockerized our server and now its running inside an isolated container. Two things to note from the above setup are :
-
We have configured dockerfile to
COPY
the code from our local directory to the/app
directory inside the container. This means that any changes you make post building the image wont be reflected and you will have to build the image again in order to incorporate those changes. -
We have to open ports on a container and map it to any internal ports if we want to access. So if we have some other process running on some port we can open it and access it outside our container.
We will solve first one by configuring the docker to use our local directory for code and not copy it at the time of building image. We will use the second one to start some debug processes which we can attach to our debuggers.
Debugging Inside Docker
Console.log aka Caveman Debugging
Caveman debugging is a way of logging variables and strings inside your code so that you can see the statements when that code path triggers. While it is frowned upon we have all been guilty of it and it might actually be helpful in case of simple usecases. Useful or not, knowing how to do so using docker will still help us.
As mentioned above that docker copies over the code from your directory while building the image so our dynamic console.log
wont reflect in the code base. To do so, we will have to use bind mount to mount our local directory as the code directory inside the container. To do we just have to remove the copying and installing step from our dockerfile. So our new Dockerfile
looks like this :
FROM node:latest
WORKDIR /app
CMD node app.js
EXPOSE 3000
We will build the image again using docker build -t node-docker .
Now while running the container we will specify the mount point and location to mount inside the container. Our run command now becomes :
docker run --rm -it --name node-docker -v $PWD:/app -p 3000:3000 node-docker
The -v
flag mounts a local folder into a container folder, using this mapping as its arguments <local relative path>:<container absolute path>
. As our WORKDIR
is /app
we use /app
for container directory and PWD
to pick the code from local machine. This will spawn our server using code on our local machine instead of creating a copy of it inside the container.
But there is still a problem, even when you are running a server without docker, a code change is not reflected on you server untill you restart your server. This where nodemon
comes in. Nodemon is a neat tool to restart your server automatically as soon as a code change happens. It basically watches all the files inside a directory and triggers a restart when something changes.
Install nodemon using npm install --save-dev nodemon
.
We are installing nodemon locally and hence nodemon will not be available in your system path. Instead, the local installation of nodemon can be run by calling it from within an npm script.
Inside our package.json we will add a start script :
"scripts": {
"start": "nodemon app.js"
}
And inside our Dockerfile
we change the execution command to start server :
FROM node:latest
WORKDIR /app
CMD npm start
EXPOSE 3000
Run the container using same command : docker run --rm -it --name node-docker -v $PWD:/app -p 3000:3000 node-docker
.
Now our container will use nodemon to start the server and nodemon will restart the server inside the container if any code change occurs. Since the nodemon will be watching the code on local machine we can make changes and it will reflect in real time! Lets verify this by making the change to response of our api and hitting it again. We don't need to build image or even restart the container.
try {
console.log(`Got a hit at ${Date()}`);
const variableToDebug = "docker rules";
res.status(200).send({message: "Nodemon rules", data : variableToDebug}) }
curl --location --request GET 'http://localhost:3000'
{"message":"Nodemon rules","data":"docker rules"}
Using Debuggers
For more sophisticated folks who have evolved from caveman to civilized people we will want to use debugger to debug our application. Debuggers allow you to set breakpoints inside your code and see variable values at that particular point in execution.
Before using a debugger inside docker, first lets see how does a it work. When you start your node server with --inspect
flag, a Node.js process is spawned listening on a particular port. Any inspector client can attach itself to this process, be it an IDE debugger or Chrome DevTools.
So debugger is just another process running on some port. If we had been debugging without docker we would just attach our debugging client on 9229 (default port) and things will work. As we can expose port from container to local machine we will use this trick to expose debug process as well.
First lets change the start script to run the node server in inspect mode. To do this change the start script to nodemon --inspect=0.0.0.0 app.js
. This will start nodemon in inspect mode and run the debugger on 9229 port.
Second we will expose the 9229 port. We can do this by changing the run command to :
docker run --rm -it --name node-docker -v $PWD:/app -p 3000:3000 -p 9229:9229 node-docker
This will start our server in inspect mode and also expose the debug port for us to use.
You can verify if you debugger is running and you can access it by using command :
lsof -i :9229
We can now go ahead and attach this process to our IDE's debugger. Since VS Code is the most popular IDE, we will look at how attach this debugger in VS Code, but its pretty much the same process to do so in webstrom or atom as well.
Press Cmd(Ctrl)+Shift+P
and find “Debug: Open launch.json”
:
In the launch.json
file, paste the following :
{
"version": "3",
"configurations": [
{
"name": "Attach",
"type": "node",
"request": "attach",
"port": 9229,
"address": "localhost",
"restart": true,
"sourceMaps": false,
"localRoot": "${workspaceRoot}",
"protocol": "inspector",
"remoteRoot": "/app"
}
]
}
Attach the debugger by clicking on Run
on VS Code debug page. It will attach the debugger. Now add some breakpoints.
Lets hit the server and see if the breakpoint capture it.
curl --location --request GET 'http://localhost:3000'
VS Code must come up and should be able to inspect various variables.
So we are now able to debug our application using IDE's debugger. We can make changes to our code, add log lines, add break points without rebuilding our images.
Conclusion
We have learnt how to run our node application inside a isolated docker container and also how to debug them by configuring docker to use our local machine's code directory and also by exposing the debugger port.
The helper code for this article available on github.