In recent years, the concept of Cloud Native has become popular. Containerization is the basis of Cloud Native best practice, which we must understand in the process of Cloud Native practice. Then what is containerization? And why do we containerize?

When we deploy server applications, if each application is deployed on the host separately, then the resource utilization rate of the machine with less service pressure will be low, and it will be troublesome to expand the capacity. When multiple applications are deployed on the same host, they may encounter dependency conflicts on the operating system, resource competition, and even attacks by processes with higher privileges. We want to maximize the utilization of host resources, simplify horizontal scaling, and isolate applications from each other. Container technology can solve this problem.

So what is a container? A container is a collection of view-isolated, resource-restrictable, and independent file system processes. The bottom layer relies on the isolation capabilities provided by the operating system (such as Linux Namespace technology, Chroot file system isolation, and Cgroups resource management). Under the cloud native best practices, Containers have become the smallest unit for testing and distributing applications.

Next, we use the current very common containerization scheme Docker to containerize node.js applications:

What is a Docker?

Docker is an open platform for developing, shipping, and running applications.

Docker is the most common application containerization scheme at present. It provides complete capabilities of container image packaging, container distribution, container running and so on to help developers implement application containerization. It itself is based on C/S architecture. It runs a daemon dockerd in the background to provide the core capabilities of Docker. The command-line tool Docker communicates with the daemon through API to realize the interaction between developers and Docker. Docker provides container distribution capability through Registry. Docker Hub is the official public image source provided by Docker. If conditions permit, a private image source can also be deployed by Docker.

Containers and Images

Before we get into application containerization, it’s worth talking about the concepts of images and containers in Docker:

Images are container templates that combine a series of container build instructions. Images can have inheritance relationships, meaning that one image can be superimposed with additional build instructions on top of another base image. You can create your own image or use an already built image. If you want to use Docker to build your own image, you need to use Dockerfile to define the set of instructions to build the image, which will be expanded in the practice section below.

Containers are an instance of mirroring, a process-level sandbox environment that is isolated from each other and from the host machine. You can configure the container’s network connection or mount additional storage space to it. The state of the container at startup is defined by the image, such as files, environment variables, etc. When the container is removed, any changes in the state of the container are removed as a whole. It is this feature that allows each container-based application change to be deployed on a container-by-container basis without fear of side effects.

Mirror layered

The image of Docker is composed of a series of Layers, and a series of Layers is a stack structure. Any instruction in Dockerfile that will cause modification of the file system (including write operations, add and delete files) will create a new Layer based on the previous Layer. This also means that if you delete a file, even though the file is deleted at the new Layer, the file still exists at the previous Layer, and the deletion operation has no effect on the size of the final image.

Each Layer is read-only except the last one. Each new Layer saves only the modified part of the file system based On the copy-on-write policy. For files to be read, the system directly reads the files from the old Layer. If any changes are made, the system copies the old content to the new Layer for modification. All this is to maximize the space efficiency of the image file.When a Container is instantiated based on an image, a new Layer is created at the top of the Layers stack. This Layer is called the Container Layer and is used to record changes to the file system when the Container runs.

Therefore, we find that the difference between a Container and an image is reflected in the top Container Layer. The creation, operation and destruction of a Container are all reflected in the Container Layer and have no impact on the bottom Layer of the image. Create a new Container Layer.

Docker implements image Layers and Container Layer through storage drivers. Although different drivers have different implementation details, they basically use stack structure and CoW strategy.

Node.js application containerization

Basic Environment Preparations

The first step is to prepare the basic running environment of the application in the container. We are a Node.js application, so we should at least install the Node.js environment in the container.

Let’s create our Dockerfile first:

FROM centos:7

# devtoolset
RUN yum install -y centos-release-scl
RUN yum install -y devtoolset-8
# git
RUN yum install -y git
# node
RUN (curl -sL https://rpm.nodesource.com/setup_12.x | bash -) && yum install -y nodejs
Copy the code

My base image is based on centos7, then I installed c++ support and git via yum, and now I’m going to install node. I chose NodeSource’s node.js distribution. It provides unified Node.js distribution for Linux operating systems based on their package management capabilities. You can specify the version manually, and I’ve chosen node.js 12.x for the time being. If you wish to use the LTS version, you can also replace it:

curl -fsSL rpm.nodesource.com/setup_lts.x | bash –

We will try to build the image for the first time, while giving our image a name by tag, executing the command in the current directory:

docker build --tag centos7_node12 .
Copy the code

After the command is executed normally and the image is built, we try to start a container based on the image:

docker run -it centos7_node12 bash
Copy the code

We turn on the interactive mode with the -i and -t of docker run, specify the image centos7_node12 we want to start, followed by the sh command we want to execute to turn on shell interaction. The command immediately following the docker run command is equivalent to the CMD command of the Dockerfile, providing the behavior of a container to run. If the CMD command is defined in the Dockerfile, it is the default command to run the container. If another directive is passed while the Docker is running, the CMD directive in the Dockerfile will be overwritten. For details, see the official description of the CMD directive.

Now that we have started our container and can execute any command from the shell, we can try our Node.js installation to see if it works:

node --version
# output v12.22.7
npm --version
# output 6.14.15
Copy the code

When we exit the shell through exit, the container execution also exits automatically. We check the status of the current running container by executing docker ps -a, and we find that the container just running is not destroyed, but changed to exit state. You can even restart the container based on the state of the last container exit:

You can replace it with your own ContainerID
docker attach 82f41de4afce
Copy the code

If you want to start a container that will self-destruct upon exit, you can do so by adding the –rm argument:

docker run -it --rm centos7_node12 bash
Copy the code

The application package

Once we have our basic Node.js execution environment in place, we’ll start packing our application into a container. For the sake of illustration, we’ll simplify our application to the following implementation (assuming our implementation is in the app directory and the entry file is app/server.js) :

const ronin = require('ronin-server')
const mocks = require('ronin-mocks')

const server = ronin.server()

server.use('/', mocks.server(server.Router(), false.true))
server.start()
Copy the code

In the process of packaging the image, we COPY the source code to the file system of the image by way of COPY:

WORKDIR /app
COPYapp/* ./
Copy the code

It is not enough to package the source code, but to install the NPM dependencies, add the following line:

RUN npm install --production
Copy the code

Finally, the app launches:

ENTRYPOINT [ "node"."server.js" ]
Copy the code

Instead of using CMD, we use ENTRYPOINT. Using ENTRYPOINT treats the container like an executable file. All commands, whether specified in a Dockerfile or passed in via docker run, Are considered startup parameters appended to ENTRYPOINT. Obviously, this approach is more appropriate for application deployment.

We rebuild our image again and start a container to execute our application:

docker build --tag node12_app_demo .
docker run -it --rm node12_app_demo
Copy the code

Our test application will listen on port 8000 after startup. Let’s try to access it:

curl --request POST \
  --url http://localhost:8000/test \
  --header 'content-type: application/json' \
  --data '{"msg": "testing" }'
  
# output
# curl: (7) Failed to connect to localhost port 8000: Connection refused
Copy the code

But it seems to have failed. Port 8000 does not appear to be monitored. Why?

Container network

Docker is an isolated sandbox environment, which also includes network isolation. When Dockerd starts, a default bridge network is loaded and every new container is connected to it. If no network mode is specified, the container will use the default bridge network. The container network is isolated from the host. We need to configure port mapping:

docker run -it --rm -p 8000:8000 node12_demo
Copy the code

Port 8000 of the host is mapped to the container network, so that we can access our application. Let’s try again to access our application successfully:

curl --request POST \
  --url http://localhost:8000/test \
  --header 'content-type: application/json' \
  --data '{"msg": "testing" }'

# output
# {"code":"success","payload":[{"msg":"testing","id":"5bdac299-3bf6-4c53-8a9c-d28fee58dae7","createDate":"2021-11-14T13:16 Z: 58.227}}]"
Copy the code

Data volumes are mounted

Because we put the container as the smallest unit of deployment, and container deployment is stateless, namely the change in the container in the new container before deployment, will not be retained, this will be a problem in most cases, but also can have hope to be preserved, such as server logs even after service to redeploy can still be retained for the problem. We assume that the application will export logs to the logs directory:

VOLUME /logs
Copy the code

I won’t do examples in this section, but it is recommended that every application consider this section when containerizing.

The container release

Finally, you can explicitly publish your container using docker push, which can be a public source, but most often to your own private source:

docker push node12_demo
Copy the code

The last

We used a very simple example to demonstrate the containerization of a Node.js application based on Docker. In this process, we also briefly introduced the concept of image and container in Docker. In fact, this is just the tip of the iceberg. There will also be more output from cloud native practices.