paint-brush
Supercharge Your JavaScript Dockerfile for High-Performance Production Deployments.by@fed4wet
1,374 reads
1,374 reads

Supercharge Your JavaScript Dockerfile for High-Performance Production Deployments.

by Oleh SypiahinMay 30th, 2023
Read on Terminal Reader
Read this story w/o Javascript
tldt arrow

Too Long; Didn't Read

Here is a tutorial that provides step-by-step guidance on how to write a Dockerfile for a NestJS project, using it as an example to create a production-optimized image.
featured image - Supercharge Your JavaScript Dockerfile for High-Performance Production Deployments.
Oleh Sypiahin HackerNoon profile picture

Here is a tutorial that provides step-by-step guidance on how to write a Dockerfile for a NestJS project, using it as an example to create a production-optimized image.


This article is well-suited for various JavaScript projects, including Angular, React, Vue.js, and more. The code example provided is nearly identical across these projects.

What is Dockerfile?

Dockerfile


Shall we go over the fundamentals?


A Dockerfile is a text file that contains instructions for building a Docker image. It is used to automate the process of creating containers using Docker.


The Dockerfile consists of instructions, each performing a specific action during the image build process. For example, in a Dockerfile, you can specify the base image, install dependencies, copy files, configure the environment, declare ports, and more.


Some key instructions in a Dockerfile include:

  • FROM: Specifies the base image upon which the new image will be built.
  • RUN: Executes commands inside the container during the image build process.
  • COPY: Copies files and directories from the host machine to the container.
  • WORKDIR: Sets the working directory for subsequent instructions.
  • EXPOSE: Declares the ports that the container will listen on during runtime.
  • CMD: Specifies the command or executable that should run upon starting the container.


Here is a basic example of a Dockerfile.


# A Dockerfile must begin with a FROM instruction.
# New base image will be built based on the Node.js version 20 
# Image that is based on Debian Linux.
FROM node:20

# Create app directory
WORKDIR /usr/src/app

# A wildcard is used to ensure both package.json AND package-lock.json are copied
COPY package*.json ./

# Install app dependencies
RUN npm install

# Bundle app source
COPY . .

# Creates a "dist" folder with the production build
RUN npm run build

# Start the server using the production build
CMD [ "node", "dist/main.js" ]

Testing the container on the local machine.

Now, let’s perform local testing to verify the expected behavior of the Dockerfile. First, execute the following command in your terminal at the root of your project to build the image for

my-nest-app:

docker build -t my-nest-app .

Using a space and dot at the end of the docker build command is a convenient way to indicate the current directory as the build context if the Dockerfile is located in the same directory.


You can use the command docker images in your terminal to confirm that the image has been created successfully. This command will display a list of Docker images stored on your local machine.

docker images
REPOSITORY                   TAG       IMAGE ID       CREATED          SIZE
my-nest-app                latest    014f7f222139   56 seconds ago    1.24GB


Let’s start the container and run the image with this command (be sure to use the same image name used above):

docker run -p80:3000 my-nest-app


All works, but you can see suspicious 1.24 GB. Come and get it! Just 1.24 GB! Just! / sarcasm.

As the saying goes, time is valuable, and so is space.

Time is money, friend! As space is money too.


Usually, the payment for a server in the cloud or hosting services is based on various factors, such as:

  1. Processor time: Payment for using processor resources (for example, processing power and number of cores).
  2. Storage: Pay for the use of disk space for data storage.
  3. Memory capacity: pay for the amount of RAM used.
  4. Network traffic: payment for data transfer inside and outside the server.
  5. Other Resources: Pay for additional features or specialized features.


For example, check prices to use cloud servers such as AWS.


To proceed, we need to decrease the dimensions of the image. But before we copy any code, it’s crucial to have a firm grasp of Docker Basics. So let’s dive into it.

Why it’s too big? Or “FROM” Docker with Love.

The FROM instruction should be the first instruction in the Dockerfile and is typically placed at the beginning of the file. It sets the foundation for further building the image, and all subsequent instructions in the Dockerfile will be applied to this base image.


Using FROM allows you to create new images based on existing images and add and configure components, dependencies, and application-specific configurations.


So, if we do FROM node:20 let's take a look at what is included in the image. Inside the image, you will find the following:


  1. Operating System (ex., Linux Debian):

/bin, /sbin, /usr/bin, /usr/sbin: Directories containing operating system executables. /etc: Directory with configuration files. /lib, /usr/lib: Directories with operating system libraries. /var: Directory for variable data such as logs, temporary files, etc.


  1. Node.js: /usr/local/bin: Directory where Node.js executables such as node and npm are located. /usr/local/lib/node_modules: Directory where Node.js packages installed for the project reside.


  2. Project Files: /app Or another directory specified in the Dockerfile: Directory where your project is located. This could be the working directory where project files are copied from the host system during the image build process.

Legitimate question - what OS is doing here? Let's dive deeper.

Why do we need OS in the image?

Including the operating system in a Docker image or build image creates an isolated, portable environment for running applications. In addition, the operating system provides the necessary infrastructure and resources for application execution within a container or virtual machine, ensuring compatibility and stable application execution across different environments.


Clear? Clear. So, now we understand that in our image, we have OS and Node.js with various libraries even if we don’t use them.


The bad news is that the lion’s share of the image is OS and Node.js. So your application will be tiny compared to this LION. Or WHALE.


Your tiny app and significant “other”.


The good news is that Node provides many images for the most popular OS. So we need to choose what suits us.

Choose wisely, and success will follow.


Let’s look at what’s in the box of our Node.

  1. Node.js on Debian: A Node.js build based on the Debian Linux distribution, providing compatibility with other Debian components and packages.

  2. Node.js on Alpine Linux: A lightweight Node.js build running on Alpine Linux, known for its compact size and fast boot time.

  3. Node.js on Ubuntu: A Node.js build based on the Ubuntu Linux distribution, one of the most popular Linux distributions with extensive support and a large community.

  4. Node.js on Windows: A Node.js build specifically designed to run on Windows operating systems, optimized for Windows environment features.


Also, for Linux, we can select some subversion:

  1. alpine: Lightweight Linux distribution.

  2. current: The latest, up-to-date version of the software.

  3. bullseye: Code name for Debian 11.

  4. slim: Stripped-down version of distribution with minimal package set.

  5. buster: Code name for Debian 10.

  6. hydrogen: Deprecated version of Node.js (0.10.x).

  7. lts: Long-Term Support version with extended maintenance.


Also, NodeJS has a combination of versions, like 20-bullseye-slim , current-buster-slim etc. Full list of versions with image size: https://hub.docker.com/_/node


Node provides a format for versions.

# Format example
node:<version>-<subversion>
# Real Example 
FROM node:20-alpine


Our goal is to decrease image size, and the most suitable tool for this task is the fatless alpine, although there are some reservations to consider.

Alpine is our Star. Our Hope. Our Love. Our Thumbelina.

So, please return to our docker file. We need to change the version from FROM node:20 to FROM node:20-alpine.


Let's re-run the commands to build the image and check your container out:

docker build -t nest-cloud-run .
docker run -p80:3000 nest-cloud-run


Let’s rundocker images and … drumroll… you see significant changes:

docker images
REPOSITORY                   TAG       IMAGE ID       CREATED          SIZE
my-nest-app                 latest    004f7f222139   31 seconds ago   188MB


And we have 188MB. Enjoy! Applause. The curtain falls. We are all satisfied.


Is it the end? No!

More cases, deeper.

For small run-and-gun applications, it’s the best-case scenario. But in my current project, I encountered customer security requirements:


  • After building the project —  we must do Code Analysis through SonarQube. Commonly referred to as Sonar — code analysis tool designed to assess the quality and security of a codebase.
  • Ensures that the container contains the latest package versions and necessary security patches, which is crucial for maintaining the security and stability of your application.


This means updating the container, downloading the Sonar Scanner, unzipping the file, and running this application.


RUN apk update && apk upgrade --no-cache
RUN curl -u ....
RUN unzip ....
RUN /sonar-scanner/bin/sonar-scanner


But immediately, we have a problem.

/bin/sh : curl : not found

/bin/sh : zip : not found


The reason is apparent: alpine is fatless, and the cause only has a few libraries inside that can help you build projects without headaches.


In our case, the alpine under the box doesn't have a curl or zip. Spoiler: Also, we need Java’s openjdk8-jre to run sonar.


There are two methods to approach this situation:

  1. Lazy and quick.
  2. Smart and advanced.

Lazy and quick. Some magic, so keep your eyes peeled.

It's a kind of Magic...Magic..Magic...


I have a set of instructions outlining what needs to be done.

  • Install the current version of NodeJS with all libraries.
  • RUN all necessary maintenance (such as downloading SonarQube, ensuring the container contains the latest package versions and necessary security patches, etc.).
  • Build a successful image with the full version current version of Node JS;
  • Magic0: Delete (remove, replace) a massive part of the image with OS and other unnecessary files.
  • Magic1: Install fatless NodeJS-alpine;
  • Magic2: Copy the bundled code from the build stage to the production image of the management of the new NodeJS-alpine


At the last moment, we would like to perform a magic trick and transform the image of node-20 from an unimportant elephant to a small ant called "alpine". Will it work? Certainly

The logic of your docker will be like this:


###################
# BUILD FOR PRODUCTION
###################

# Base image for production
FROM node:20 as build

# ... your build instructions here

###################
# PRODUCTION
###################

# Base image for production
FROM node:20-alpine as production

# ... your production instructions here


Real Dockerfile:

###################
# BUILD FOR PRODUCTION
###################

FROM node:20 as build

WORKDIR /usr/src/app

COPY --chown=node:node package*.json ./

# In order to run `npm run build` we need access to the Nest CLI which is a dev dependency. In the previous development stage we ran `npm ci` which installed all dependencies, so we can copy over the node_modules directory from the development image
COPY --chown=node:node --from=development /usr/src/app/node_modules ./node_modules

COPY --chown=node:node . .

# Run the build command which creates the production bundle
RUN npm run build

# Set NODE_ENV environment variable
ENV NODE_ENV production

# Running `npm ci` removes the existing node_modules directory and 
# passing in --only=production ensures that only the production dependencies
# are installed. This ensures that the node_modules directory is as
# optimized as possible.
RUN npm ci --only=production && npm cache clean --force

###################
# PRODUCTION
###################

FROM node:20-alpine as production

# Copy the bundled code from the build stage to the production image
COPY --from=build /app/dist /app/dist
COPY --from=build /app/libs /app/libs
COPY --from=build /app/node_modules /app/node_modules

# Start the server using the production build
CMD [ "node", "dist/main.js" ]

Bonus for the curious, or how does it work?

You may ask, “Where does the first full version of the Node go?”.

After all, did you notice that in the Dockerfile, we didn’t remove the node of the first version? So how?


If the FROM instruction is used a second time in a Dockerfile, so you are starting a new build stage based on a different base image. In this case, the previous version of the image specified in the first FROM instruction will no longer be used in the subsequent build stages.


In Docker, each FROM instruction starts a new build stage, which is a separate layer representing changes to the image's file system. When you reuse the FROM instruction, a new build stage begins with the base image specified in the second FROM instruction, and the previous layer with the previous image is no longer used.


This means that the previous version of the image and its layers still exist in the Docker file system, but they no longer affect the subsequent build stages. If you want to completely remove the previous version of the image and all its layers, you can perform a cleanup of unused layers using the docker image prune command.


Also popular to set up docker to clean itself up on a schedule.

Smart and advanced.

Gilbert’s law: The biggest problem at work is that none tells you what to do.


For this situation, we won't deal with different versions. Instead, we'll focus on strengthening specific areas using alpine only.


But now, we are responsible for determining the necessary packages to meet our requirements and including them in the file. It can often feel like a shot in the dark. For example, I encountered that Sonar needs Java and other packages.


FROM node:20-alpine

###################
# This command performs the update and upgrade of Alpine Linux packages.
# Use "apk update" to get the latest package index, 
# and "apk upgrade" to update installed packages. 
# Add the "--no-cache" flag when building a Docker image
# to avoid unnecessary caching and reduce image size.
###################
RUN apk update && apk upgrade --no-cache

RUN apk add --no-cache \
  curl \
  unzip \
  libc6-compat \
  openjdk8-jre

###################
# AFTER BUILD FOR PRODUCTION
###################

RUN curl -u ....
RUN unzip ....
RUN /sonar-scanner/bin/sonar-scanner


A place for everything and everything in its place.

I dedicated a significant amount of time to figuring out the necessary packages 🤔, but it was worth it. I decreased the build process time by a few minutes 🕐 and reduced the number of files in Docker compared to the Lazy and Quick approach. As a result, Docker won’t have to work too hard — removing my files twice. And for now, I have already saved several hours ⏰ and my “time is money” account keeps getting bigger.