Part-1: Containers 101, Deploy a Node.js App Using Docker and Kubernetes
This blog is part one of a two blog series that details how to get started with containerization using Docker and Kubernetes for deployment, and later how to perform chaos engineering using LitmusChaos 2.0. Find Part-2 of the blog here.
So you’ve just come across this term called Containerisation and now you’re wondering that as an aspiring software product engineer, or a DevOps engineer, what role will it play in your day-to-day work. After all, applications can be deployed without containers, and in fact, that has been the norm for a long time until containerization technologies like Docker, Kubernetes came into the big picture. So what’s all the fuss about?
In this blog, I’d try to answer all your questions, starting from what are containers, how containers got themselves into the limelight, why one should use them, what is container orchestration, advantages of container orchestration, and finally we will deploy a Node.js application using Docker and Minikube Kubernetes. Please take note that this blog will try to put more emphasis on the practical aspect of using Containers, and won’t encompass the basic theoretical concepts of Docker and Kubernetes at a large, but only those concepts which are necessary to understand the demo.
What are Containers?
As Docker defines it:
A container is a standard unit of software that packages up code and all its dependencies so the application runs quickly and reliably from one computing environment to another.
Simply said, containers allow applications to run in an isolated environment of their own, along with all its dependencies. This decoupling makes it simple and consistent to bundle and deploy container-based applications along with all their dependencies, regardless of whether the target environment is a private data center, the public cloud, or even a developer’s personal laptop.
Difference between Containers and Virtual Machines
A hypervisor virtualizes physical hardware in conventional virtualization. As a result, each virtual machine has a guest OS, a virtual copy of the hardware that the OS needs to run, and an application with all of its related libraries and dependencies. On the same physical server, multiple virtual machines running different operating systems will coexist. A VMware VM, for example, can coexist with a Linux VM, which in turn can coexist with a Microsoft VM, and so on.
Containers virtualize the operating system (typically Linux or Windows) rather than the underlying hardware, so each container only contains the portable and its libraries and dependencies. Containers are small, fast, and portable since, unlike virtual machines, they do not need a guest OS in every instance and can instead rely on the host OS’s features and resources.
Containers, like virtual machines, allow developers to make better use of physical machines’ CPU and memory. On the other hand, Containers go much further because they support microservice architectures, which allow for more granular deployment and scaling of application components. This is a more appealing option than scaling up an entire monolithic framework because a single component is experiencing load issues.
Why we should Use Containers
- Faster time to market: Keeping the competitive advantage needs new software and services. Organizations may use growth and organizational agility to accelerate the implementation of new services.
- Deployment velocity: Containerization allows a quicker move from production to implementation. It allows DevOps teams to reduce deployment times and frequency by breaking down barriers.
- Reduction of IT infrastructure: Containerization increases the density of device workloads, improve the utilization of your server compute density, and cut software licensing costs to save money.
- Performance in IT operations: Containerization allows developers to streamline and automate the management of multiple applications and resources into a single operating model to improve operational performance.
- Obtain greater freedom of choice: Any public or private cloud can be used to package, ship, and run applications.
What is Container Orchestration
As VMware defines it:
Container orchestration is the automation of much of the operational effort required to run containerized workloads and services. This includes a wide range of things software teams need to manage a container’s lifecycle, including provisioning, deployment, scaling (up and down), networking, load balancing and more.
Running containers in production can easily become a huge effort due to their lightweight and ephemeral nature. When used in conjunction with microservices, which usually run in their own containers, a containerized application will result in hundreds or thousands of containers being used to construct and run any large-scale system.
If handled manually, this can add a lot of difficulties. Container orchestration, which offers a declarative way of automating much of the job, is what makes the organizational complexity manageable for creation and operations, or DevOps. This makes it a natural match for DevOps teams and cultures, which aim for much greater speed and agility than conventional software development teams.
Advantages of Container Orchestration
- Improved Resilience: Container orchestration software can improve stability by automatically restarting or scaling a container or cluster.
- Simplification of Operations: The most significant advantage of container orchestration, and the primary explanation for its popularity, is simplified operations. Containers add a lot of complexity, which can easily spiral out of control if you don’t use container orchestration to keep track of it.
- Enhanced Security: Container orchestration’s automated approach contributes to the protection of containerized applications by reducing or removing the risk of human error.
Demo: Deploy a Node.js App Using Docker and Kubernetes
Let’s get our hands dirty by deploying a simple Node.js application using a Docker container, followed by deploying the container image in a Minikube Kubernetes cluster in our own development machine.
Before we move on to the actual demo, let's check a few pre-requisites off the list so that we will all be on the same page:
- Node.js version 10.19.0
- Docker version 20.10.6
- Minikube version 1.20.0
- Virtualbox 6.1.6
It's worth mentioning that I will be using a machine running on Ubuntu 20.04 for this demo, though you should be fine with a Windows machine too. For this demo, we’ll not cover the installation part of Docker and Minikube since they are pretty straightforward and require no special instruction, and focus on the deployment part only.
1. Node.js Application
Let’s start with a very basic Node.js “Hello World” application for this demo. The application has been developed as any other Node.js application, after initializing an empty repository using
npm init. The
package.json generated is as follows:
Once that’s done, we can set up our
index.js as follows:
Here we have a pretty basic Node.js server and all it does is serve the string ‘Hello World’ when a GET request is sent to the loopback address, at port 3000. Upon executing the above code using
node index.js we obtain the following output:
And we do get our Hello World at
Simple, right? Once that’s done, let’s move on to the sweet part, creating a container image of our application using Docker.
Once we have our application up and running, we can proceed to dockerizing the application. But before we do that, let us initialize a startup script in our
package.json so that our application can be readily executed by Docker. Therefore, we’d modify our
package.json as follows:
Now we are all set to Dockerize our application! To do that we need to simply create a
Dockerfile in the same directory as of our
index.js. A Dockerfile is simply a set of instructions required for building the container image of the application. Our
Dockerfilelooks something like this:
Let us walk through each of these commands to better understand their purpose.
FROM instruction initializes a new build stage and sets the Base Image for subsequent instructions. A Base Image is simply a Docker image that has no parent image, which is created using the
FROM scratch directive. As such, a valid
Dockerfile must start with a
FROM instruction. Here we are using the
node v12.0 slim Base Image.
WORKDIR command is used to set the working directory for all the subsequent
Dockerfile instructions. If the
WORKDIR is not manually created, it gets created automatically during the processing of the instructions. It does not create new intermediate Image layers. Here we set our working directory as
COPY instruction copies new files or directories from
<src> and adds them to the filesystem of the container at the path
<dest>. Here we are copying the
package.json file to
/app directory. Interestingly, we don’t copy the rest of the files into
/app just yet. Can you guess why? This is because we’d like Docker to cache the first 3 commands so that every time we run
docker build we won’t need to execute those commands, and thus improve our build speed.
RUN command can be used in two ways; either through a
Dockerfile as shown here, otherwise through the Docker CLI. The
RUN instruction will execute any commands in a new layer on top of the current image and commit the results. The resulting committed image will be used for the next step in the
Dockerfile. To install the Node.js app dependencies from the
package.json file, we use the
RUN command here.
Next, we use
COPY to move all the remaining files to the
Finally, we use the
CMD command to execute the Node.js application. The main purpose of a
CMD is to provide defaults for an executing container. These defaults can include an executable, or they can omit the executable, in which case we must specify an
ENTRYPOINT instruction as well. There can only be one
CMD instruction in a Dockerfile. If we list more than one
CMD then only the last
CMD will take effect.
And we’re done with the
Dockerfile. Now we’re all set to build our container image, which can be done using the command
docker build -t hello-world:1.0 .
The only important takeaway here is that we MUST tag our image via
-t flag, as it’d be very much beneficial for us to deploy and manage the container image later on. Here we have specified the name of our container image as
hello-world and tagged it with its version
1.0. We had seen a similar practice with our Base Image
node:12-slim as well. Lastly, we specified the directory from where the
Dockerfile is located using the
Upon building the image, Docker tries to fetch the Base Image if it's not present in the local registry. Next, it executes the set of instructions given in the
Dockerfile in a sequential order to complete the build process. Here’s the build output:
Upon a successful build, we can view our image using the command
node image is also present here since we have used it as our Base Image. Now we’re all set to run our application. Let’s run our docker container using the command
docker run -it -p 3000:3000 hello-world:1.0
Here we’re specifying that we want to run the container image in an interactive mode using the flag
-it. Further, we specify the port mapping of our container using the
-p flag, where we direct that the
3000 port of the host machine is mapped to the
3000 port of the container. Finally, we specify the name and tag of the image to be run. Hence we obtain:
Thus, we have successfully deployed a container image of our application using Docker, which we can verify by visiting
Further, we can run the container in detached mode by specifying
-d flag in place of
-it in the previous command:
docker run -d -p 3000:3000 hello-world:1.0
The long alphanumeric string output is nothing but the UUID long identifier of the running container. We can further inspect the properties of this container using the command
docker container ls:
Amazing! We have just deployed our very first Docker container and now we’re all set for our next destination: Kubernetes.
As the container image of our application is now ready, all that’s left is to deploy our container image to a Kubernetes deployment. We’d use a Minikube cluster for the deployment of our container image locally.
It's important to take note that a Minikube cluster would have only one worker node, which will be created using a virtual machine, and the control plane will reside in our own machine only.
To start Minikube, we can use the command
minikube start. It's worth pointing out that a minimum of 2 CPUs, 2GB RAM, and 20GB Disk Space is required for starting a Minikube cluster using this command. One may check the number of processing units in their machine using the
Once the Minikube cluster starts up, you’d get the following output in the terminal:
Now that our Minikube cluster is up and running, let’s devise a deployment strategy for our container image.
Currently, our container image is stored locally in our own Docker Local Registry which is present in our machine. The Registry is nothing but a stateless, highly scalable server-side application that stores and allows the distribution of Docker images. So, either we can use the image directly from the Local Registry to deploy it in the Minikube server, or we can first push our image to a Hosted Registry such as Docker Hub and later pull it for the image deployment. The latter is a more suitable approach when working in a team.
The former approach, however, has an unnoticed aspect. Minikube comes with its own Docker ecosystem when we install it on our machine. If we create Docker images on our computer and try to use them in a Kubernetes deployment, we will get the ErrImageNeverPull error since it always tries to get the image from its own Local Registry or Docker Hub, resulting in an error as the pod is started.
To verify this, let’s do a small experiment. We can still view our Local Registry images using the command
Now, let’s try to access the Docker Images of Minikube. To do that we need to first SSH into the Minikube’s VM. To do that, we can use the command
Now that we are inside the Minikube’s VM, we can again use the
docker images command and this time the hello-world image or the node image is nowhere to be found:
The SSH can be exited using the
To get around this issue, we have two options. Either we can push our image first to a Hoster Registry and then pull it into our Minikube VM’s Docker, or we can directly build our Docker image using Minikube’s Docker daemon. Let’s try to deploy our container image using the second approach.
Firstly, we need to set the environment variable using the eval command as
eval $(minikube docker-env). This will allow us to use the Docker daemon of Minikube to be used for the subsequent command. You can confirm that now the Minikube’s Docker is being used by using the
docker images command again and this time you’d find the images from your Minikube Docker:
Next, use the build command for docker image as you’d normally do using the command
docker build -t hello-world:1.0 .:
Now that our Docker image is in the right Registry, we can proceed towards the actual deployment. We can deploy our image either using a deployment manifest file or by using
kubectl create deployment command directly. The first approach is a better approach since it gives us much more flexibility to specify our exact Pod configuration.
Let’s define our
A few interesting observations are to be made over here. Notice that
replicas has been set to 1 for the time being. This means that there will always be exactly one pod for our deployment under the present configuration. The
imagePullPolicy is set to “Never” as the image is expected to be fetched from the Minikube Docker’s Local Registry. Finally under
containerPort has been set to 3000 because our Node.js application listens on that port.
Now, let’s create the container deployment using the following command
kubectl create -f deployment.yml, assuming that the terminal is open in the directory where
deployment.yml file is present. We get the following output:
Woohoo! We just deployed our container image in the Kubernetes cluster! To verify our deployment, we can see all the deployments in our cluster using the command
kubectl get deployments:
As we can see, our deployment is successfully created. We can also inspect the pods associated with this deployment using the command
kubectl get pods:
As per our manifest file, only one pod is created. Though, we can still increase or decrease the number of Pod replicas in our deployment using the following command
kubectl scale --replicas=3 deployment hello-world. This command will create 2 more Pods which will be exact replicas of the Pod that we have created:
We can verify the newly created Pods by again using the command
kubectl get pods:
Though we have deployed our container image, still we can’t access the application just yet, for the lack of a Service. As we know, Service in Kubernetes is an abstraction that defines a logical set of Pods and a policy by which to access them. Services enable a loose coupling between dependent Pods. For our purpose, we’d create a NodePort Service to be able to access our deployment. A NodePort exposes the Service on the same port of each selected Node in the cluster using NAT.
Let’s define a
A few points to notice: Under
ports, we have defined the
targetPort in relation to the Service itself i.e.
port refers to the port exposed by the service and
targetPort refers to the port used by our deployment aka. the Node.JS application.
To create this Service we’d use the following command
kubectl create -f service.yml, assuming that the terminal is open in the directory where
service.yml file is present. We get the following output:
We just created our NodePort service, which we can verify using the command
kubectl get services:
As evident, we have successfully created a NodePort Service. Noticeably, it doesn’t have an external IP, therefore we can use port-forwarding to access our deployment at a specified port. The command to do so is specified as
kubectl port-forward svc/hello-world-svc 3000:3000:
And now if we go to
Congratulations! You have just deployed a containerized application using Docker and Kubernetes by adhering to the best practices. Though we have touched only the tip of the iceberg, I hope this demo has made you a little bit more familiar with the containers, and how Docker and Kubernetes can be used for containerizing and deploying applications.
In the next part of this series, we’d explore the world of Chaos Engineering using Litmus Chaos! We’d understand the best principles of Chaos Engineering and witness how Litmus Chaos performs Kubernetes-Native Chaos Engineering for attaining unparalleled resiliency in our Kubernetes application. Find Part-2 of the blog here.
With that, I’d like to welcome you to the world of containers and chaos engineering. Come join me at the Litmus community to contribute your bit in developing chaos engineering for everyone. Stay updated on the latest Litmus trends through the Kubernetes Slack channel (Look for #litmus channel).
Don’t forget to share these resources with someone who you think might benefit from them. Thank you. 🙏