Dockerizing NodeJs application with multi-stage build
Intro to Docker
Docker is one of the most popular open-source application containerization platform that is used for developing, shipping and running applications using containers. basically, deploying, testing, or scaling an application’s functionality independently is made possible by the modularization of its functionality using Docker containers.
To execute several containers on the same OS, Docker makes use of resource isolation in the OS kernel. On top of an abstraction layer of physical hardware resources, virtual machines (VMs) contain a whole OS with executable code.
If you are new to Docker please read this awesome article first, Docker — Beginner’s Guide.
What is Docker Multi-Stage build? ( #or the concept)
The multi-stage build is a feature that was introduced in Docker 17.05, that allow us to build smaller final container image by specifying multiple helper imagers (stages) within the same Dockerfile
. Each stage is used to complete a task. For example, building the project.
It allows us to provide multiple FROM
instructions to a Dockerfile
. Each of these FROM
instructions can COPY
artifacts from the previous build stage. So, it removes all the intermediate steps such as downloading the code, installing dependencies, testing, building...etc from the final image. which will reduce the additional layers from the final image and eventually the size of the final image will be smaller and also reduces the attack surface.
Here is a size comparison between a basic nodejs project with a typescript build process and eslint as linter.
You can see that the image build using a multi-stage build is much smaller compared to the normal build. Because the normal build includes all of the development dependencies and typescript source code and also normal build include all the common Debian packages including build tools. While the multi-stage build doesn’t include any of that.
Before multi-stage build
Before the multistage build is available in docker, basically two dockerfiles were used to create a small image. First dockerfile for development that contains everything needed for development such as build tools and testing tools. Second dockerfile for production that only contains the application and only the required tools to run the application. Apart from these dockerfiles, an additional shell script is also needed to automate the process of building the final image. This method is referred to as the builder pattern.
A basic example
It's useful to know how this was done with the builder pattern before the multi-stage build. for this example, I am using a simple typescript nodejs project.
To start you need to execute the bash script. When the bash script started to execute,
- It will build a new image from
Dockerfile.build
file. The Dockerfile will start from the node base image and install all the dependencies and dev dependencies. Then it will copy the application from local storage and build the code. - Then it will create a new container from the previous image and copy the dist folder to local storage as
distapp
and remove the container. - In the last step, it will build the final image from
Dockerfile.prod
file. The Dockerfile will start from the node alpine base image and install only the required dependencies. After that Dockerfile will copydistapp
folder.
- Also for the final image
node:alpine
image is used because the final image does not need to have common Debian packages such as building tools. because of that, this image is much smaller compared to the node image.
Advantages of Multi-stage build
- In the builder pattern, we have to maintain separate dockerfiles for each stage, but in a multi-stage build, we only have one dockerfile in that one file we can have as many stages as we want.
- In the builder pattern, we have to maintain the bash script to automate the build process, but in a multi-stage build, we don’t need to maintain such a file.
- In the builder pattern, we have to copy the artificats to the local system, but in a multi-stage build, we don’t have to do that.
- In multi-stage build, we have the capability to build each stage individually using
--target
flag.
Let's Create a Multi-Stage build
First, there are some new syntax concepts for the multi-stage build,
AS
— This is used to provide an alias to the stage that starts withFROM
(Ex :FROM node AS base
) and this stage can be used as a base image for the next stages using the alias (Ex :FROM base
).--from=stage
— This is used as an option inCOPY
to copy files from the specified stage (Ex:COPY --from=base /usr/scr/app ./app
).--target
— This is used as an option indocker build
to build each stage individually (Ex :docker build --target base -t project:base .
). This option is useful when debugging.
let's create our multi-stage docker file for the nodejs application. For this example, I am also using a simple typescript nodejs project.
When started to build from Dockerfile,
- In the first stage, it will use
node:17.9.0
asbase
then install the dependencies and dev dependencies and copy the application. - In the second stage, it will use the previous
base
stage as a base image and execute the linting tools. - In the third stage, it will use the previous
linter
stage as a base image and build the application. - In the final stage, it will use
node alpine
as the base image. Then install only the required dependencies andCOPY
build code frombuilder
stage.
Compared to the builder pattern this multi-stage build reduces the complexity while producing the small final image as before.
I hope you’ve liked this article and I am very keen on hearing your thoughts about it. Just give a comment and I’ll be more than happy to reply.
ENJOY YOUR CODING! 🚀