Daniele Polencic
Daniele Polencic

Scaling Node.js apps on Kubernetes

Published in October 2019


Scaling Node.js apps on Kubernetes

Table of contents

  1. Scaling, high availability and resiliency
  2. The application
  3. Scaling and statefulness
  4. Making the app stateless
  5. Testing the app
  6. Containerising the app
  7. Updating the Knote configuration
  8. Defining the MinIO component
  9. Deploying the app
  10. Scaling the app

Scaling, high availability and resiliency

Scaling means increasing or decreasing the number of instances of an application component.

You could scale your app to 2, 3, or 10 copies.

The benefits of this are twofold:

But not all apps can be scaled horizontally.

If your application has any local state, you might need to refactor the code to remove it.

In this section, you will learn how to scale your app to any number of replicas in Kubernetes.

And you will learn how to refactor your apps to externalise any local state.

The application

The application that you will scale on Kubernetes is the following:

Adding images and notes in Knote

The application is made of two parts:

  1. A front-end written in Node.js and Express.
  2. A MongoDB to persist the data.

All the code to build and deploy the app is available in this repository.

If you want to learn how to develop and package the application in a Docker container, you might want to read this chapter of the course.

If you need help to set up a local Kubernetes cluster or deploy the application on Kubernetes, you might want to read this other chapter.

If you deployed the application already, you should delete the previous deployment and start fresh.

Scaling and statefulness

First of all, deploy your application to your Minikube cluster and wait until all the Pods are ready:

bash

kubectl apply -f kube

At the moment, there is one Pod running for the app and one for the database.

Kubernetes makes it very easy to increase the number of replicas to 2:

bash

kubectl scale --replicas=2 deployment/knote

You can watch how a new Pod is created with:

bash

kubectl get pods -l app=knote --watch

The -l flag restricts the output to only those Pods with a app=knote label.

There are now two replicas of the Knote Pod running.

So, are you already done?

Reaccess your app:

bash

minikube service knote --url

And create a note with a picture.

Now try to reload your app a couple of times (i.e. hit your browser's reload button).

Did you notice any glitch?

The picture that you added to your note is not displayed on every reload.

If you pay attention, the picture is only displayed on every second reload, on average.

Why is that?

Remember that your application saves uploaded pictures in the local file system.

If your app runs in a container, then pictures are saved within the container's file system.

When you had only a single Pod, this was fine.

But since you have two replicas, there's an issue.

The picture that you previously uploaded is saved in only one of the two Pods.

When you access your app, the knote Service selects one of the available Pods.

When it selects the Pod that has the picture in its file system, the image is displayed.

But when it selects the other Pod, the picture isn't displayed, because the container doesn't have it.

Your application is stateful.

The pictures in the local filesystem constitute a state that is local to each container.

To be scalable, applications must be stateless.

Stateless means that an instance can be killed restarted or duplicated at any time without any data loss or inconsistent behaviour.

You must make your app stateless before you can scale it.

In this section, you will refactor your app to make it stateless.

But before you proceed, remove your current application from the cluster:

bash

kubectl delete -f kube

The command ensures that the old data in the database's persistent volume does not stick around.

Making the app stateless

How can you make your application stateless?

The challenge is that uploaded pictures are saved in the container's file system where they can be accessed only by the current Pod.

However, you could save the pictures in a central place where all Pods can access them.

An object storage is an ideal system to store files centrally.

You could use a cloud solution, such as Amazon S3.

But to hone your Kubernetes skills, you could deploy an object storage service yourself.

MinIO is an open-source object storage service that can be installed on your infrastructure.

And you can install MinIO in your Kubernetes cluster.

Let's start by changing the app to use MinIO.

First of all, install the MinIO SDK for JavaScript:

bash

npm install minio

And include it in your index.js file:

index.js

const minio = require('minio')

You also need a few constants:

index.js

const minioHost = process.env.MINIO_HOST || 'localhost'
const minioBucket = 'image-storage'

Next, you should connect to the MinIO server.

As for MongoDB, your app should keep trying to connect to MinIO until it succeeds.

You should gracefully handle the case when the MinIO Pod is started with a delay.

Also, you have to create a so-called bucket on MinIO, which can be seen as the "folder" where your pictures are saved.

The above tasks are summarised as code in the following function:

index.js

async function initMinIO() {
  console.log('Initialising MinIO...')
  const client = new minio.Client({
    endPoint: minioHost,
    port: 9000,
    useSSL: false,
    accessKey: process.env.MINIO_ACCESS_KEY,
    secretKey: process.env.MINIO_SECRET_KEY,
  })
  let success = false
  while (!success) {
    try {
      if (!(await client.bucketExists(minioBucket))) {
        await client.makeBucket(minioBucket)
      }
      success = true
    } catch {
      await new Promise(resolve => setTimeout(resolve, 1000))
    }
  }
  console.log('MinIO initialised')
  return client
}

Go on and add this function to the index.js file.

You should call it in the start function:

index.js

async function start() {
  const db = await initMongo()
  const minio = await initMinIO()
  // ...
}

Now you should modify the handler of the /note route to save the pictures to MinIO, rather than to the local file system (changed lines are highlighted):

index.js

async function start() {
  // ...
  app.post(
    '/note',
    multer({ storage: multer.memoryStorage() }).single('image'),
    async (req, res) => {
      if (!req.body.upload && req.body.description) {
        await saveNote(db, { description: req.body.description })
        res.redirect('/')
      } else if (req.body.upload && req.file) {
        await minio.putObject(
          minioBucket,
          req.file.originalname,
          req.file.buffer
        )
        const link = `/img/${encodeURIComponent(req.file.originalname)}`
        res.render('index', {
          content: `${req.body.description} ![](${link})`,
          notes: await retrieveNotes(db),
        })
      }
    }
  )
  // ...
}

You're almost done.

There's one last thing to do.

The pictures are served to the clients via the /img route of your app.

So, you should create an additional route:

index.js

async function start() {
  // ...
  app.get('/img/:name', async (req, res) => {
    const stream = await minio.getObject(
      minioBucket,
      decodeURIComponent(req.params.name),
    )
    stream.pipe(res)
  })
  // ...
}

The /img route retrieves a picture by its name from MinIO and serves it to the client.

Now you're done.

Note that you can find the final source code file in this repository.

But can you be sure that you didn't introduce any bug into the code?

Please test your code right now before you build a new Docker image and deploy it to Kubernetes.

To do so, you can run your changed app locally.

Testing the app

Your app has two dependencies now: MongoDB and MinIO.

To run your app locally, you must run its dependencies too.

You can run both MongoDB and MinIO as Docker containers.

MinIO is provided as minio/minio on Docker Hub.

You can start a MinIO container like this:

bash

docker run \
  --name=minio \
  --rm \
  -p 9000:9000 \
  -e MINIO_ACCESS_KEY=mykey \
  -e MINIO_SECRET_KEY=mysecret \
  minio/minio server /data

Note that mykey and mysecret are the MinIO credentials and you can choose them yourself.

And you can start a MongoDB container like this:

bash

docker run \
  --name=mongo \
  --rm \
  -p 27017:27017 \
  mongo

Note that both of these commands expose a container port to the local machine with the -p flag.

You will use those containers as dependent components while your app is connected to localhost.

You can start the app like this:

bash

MINIO_ACCESS_KEY=mykey MINIO_SECRET_KEY=mysecret node index.js

Note that it's necessary to set the MINIO_ACCESS_KEY and MINIO_SECRET_KEY variables every time you run your app.

Furthermore, the values of these variables must match the same credentials defined earlier for MinIO.

You should never hard-code credentials in the application code, that's why these environment variables don't have any default values in index.js.

If you visit http://localhost:3000, you should see your app.

Test if everything works correctly by creating some notes with pictures.

Your app should behave just like it did before.

But the crucial difference is that now uploaded pictures are saved in the MinIO object storage rather than on the local file system.

When you're convinced that your app works correctly, terminate it with Ctrl-C and stop MongoDB and MinIO with:

bash

docker stop mongo minio
docker rm mongo minio

You created a new version of your app.

It's time to package it as a new Docker image.

Containerising the app

To build a new Docker image of your app, run the following command:

bash

docker build -t <username>/knote-js:2.0.0 .

You should be familiar with that command.

It is precisely the command that you used to build the first version of the app in the "Containerisation" section.

Only the tag is different, though.

You should also upload your new image Docker Hub:

bash

docker push <username>/knote-js:2.0.0

Please replace <username> with your Docker ID.

The two versions of the image will live side-by-side in your Docker Hub repository.

You can use the second version by specifying <username>/knote-js:2.0.0, but you can still use the first version by specifying <username>/knote-js:1.0.0.

You have now three Docker images, all available on Docker Hub.

Let's do a little exercise.

You will rerun your application, but this time with all three components as Docker containers.

This is how you ran the application in the "Containerisation" section, but there you had only two containers, now you have three.

First of all, make sure that you still have the knote Docker network:

bash

docker network ls

Next, run the MongoDB container as follows:

bash

docker run \
  --name=mongo \
  --rm \
  --network=knote \
  mongo

Then, run the MinIO container as follows:

bash

docker run \
  --name=minio \
  --rm \
  --network=knote \
  -e MINIO_ACCESS_KEY=mykey \
  -e MINIO_SECRET_KEY=mysecret \
  minio/minio server /data

Note the following about these two docker run commands:

  1. They don't publish any ports to the host machine with the -p flag, because they don't need to be accessed from the host machine. The only entity that has to access them is the Knote container
  2. Both containers are started in the knote Docker network to enable them to communicate with the Knote container

Now, run the Knote container as follows:

bash

docker run \
  --name=knote \
  --rm \
  --network=knote \
  -p 3000:3000 \
  -e MONGO_URL=mongodb://mongo:27017/dev \
  -e MINIO_ACCESS_KEY=mykey \
  -e MINIO_SECRET_KEY=mysecret \
  -e MINIO_HOST=minio \
  learnk8s/knote-js:2.0.0

Note the following about this command:

These latter two environment variables are particularly important.

The hostname of the MONGO_URL variable is mongo — this corresponds to the name of the MongoDB container.

The MINIO_HOST variable is set to minio — this corresponds to the name fo the MinIO container.

These values are what allows the Knote container to talk to the MongoDB and MinIO container in the Docker network.

Remember from the "Containerisation" section that containers in the same Docker network can talk to each other by their names.

You should now be able to access your app on http://localhost:3000.

Verify that your app still works as expected.

But since you didn't do any further changes to your app, everything should still work correctly.

When you're done, stop and tidy up with:

bash

docker stop knote minio mongo
docker rm knote minio mongo

After this digression to Docker, let's return to Kubernetes.

Updating the Knote configuration

If you want to deploy the new version of your app to Kubernetes, you have to do a few changes to the YAML resources.

In particular, you have to update the Docker image name and add the additional environment variables in the Deployment resource.

You should change the Deployment resource in your knote.yaml file as follows (changed lines are highlighted):

kube/knote.yaml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: knote
spec:
  replicas: 1
  selector:
    matchLabels:
      app: knote
  template:
    metadata:
      labels:
        app: knote
    spec:
      containers:
        - name: knote
          image: learnk8s/knote-js:2.0.0
          ports:
            - containerPort: 3000
          env:
            - name: MONGO_URL
              value: mongodb://mongo:27017/dev
            - name: MINIO_ACCESS_KEY
              value: mykey
            - name: MINIO_SECRET_KEY
              value: mysecret
            - name: MINIO_HOST
              value: minio
          imagePullPolicy: Always

Please notice that the command below runs the learnk8s/knote-js:2.0.0 image. If you wish to use yours, replace learnk8s with your Docker ID.

The Deployment is ready to be submitted to Kubernetes.

However, something else is missing.

You should create a Kubernetes description for the new MinIO component.

So, let's do it.

Defining the MinIO component

The task at hand is to deploy MinIO to a Kubernetes cluster.

You should be able to guess what the Kubernetes description for MinIO looks like.

It should look like the MongoDB description that you defined in the "Deploying to Kubernetes" section.

As for MongoDB, MinIO requires persistent storage to save its state.

Also like MongoDB, MinIO must be exposed with a Service for Pods inside the cluster.

Here's the complete MinIO configuration:

kube/minio.yaml

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: minio-pvc
spec:
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: 256Mi
---
apiVersion: v1
kind: Service
metadata:
  name: minio
spec:
  selector:
    app: minio
  ports:
    - port: 9000
      targetPort: 9000
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: minio
spec:
  strategy:
    type: Recreate
  selector:
    matchLabels:
      app: minio
  template:
    metadata:
      labels:
        app: minio
    spec:
      containers:
        - name: minio
          image: minio/minio:RELEASE.2020-03-14T02-21-58Z
          args:
            - server
            - /storage
          env:
            - name: MINIO_ACCESS_KEY
              value: mykey
            - name: MINIO_SECRET_KEY
              value: mysecret
          ports:
            - containerPort: 9000
          volumeMounts:
            - name: storage
              mountPath: /storage
      volumes:
        - name: storage
          persistentVolumeClaim:
            claimName: minio-pvc

Go on and save the content in a file named minio.yaml in the kube directory.

The YAML file has a Deployment, Service and PersistenVolumeClaim definition.

So, if you recall the MongoDB resource definition, you should have no troubles understanding the MinIO configuration.

If you want to learn more about deploying stateful applications to Kubernetes, check out the Managing state module in the Learnk8s Academy.

You just defined the last of the three components of your application.

Your Kubernetes configuration is now complete.

It's time to deploy the application!

Deploying the app

Now comes the big moment!

You will deploy the new version of your app to Kubernetes.

First of all, make sure that you have the following three YAML files in the kube directory:

bash

tree .
kube/
├── knote.yaml
├── minio.yaml
└── mongo.yaml

Also, make sure that you deleted the previous version of the app from the cluster:

bash

kubectl get pods

The command shouldn't output any resources.

Deleting the previous version of the app makes sure that the old data in the MongoDB database is removed.

Now deploy your app to Kubernetes:

bash

kubectl apply -f kube

Watch your Pods being created:

bash

kubectl get pods --watch

You should see three Pods.

Once all three Pods are Running, your application is ready.

Access your app with:

bash

minikube service knote --url

The command should open your app in a web browser.

Verify that it works correctly by creating some notes with pictures.

It should work as expected!

As a summary, here is what your application looks like now:

Knote application architecture

It consists of three components:

  1. Knote as the primary application for creating notes
  2. MongoDB for storing the text of the notes, and
  3. MinIO for storing the pictures of the notes.

Only the Knote component is accessible from outside the cluster — the MongoDB and MinIO components are hidden inside.

At the moment, you're running a single Knote container.

But the topic of this section is "scaling".

And you completed some major refactoring to your app to make it stateless and scalable.

So, let's scale it!

Scaling the app

Your app is now stateless because it saves the uploaded pictures on a MinIO server instead of the Pod's file system.

In other words, when you scale your app, all pictures should appear on every request.

It's the moment of truth.

Scale the Knote container to 10 replicas:

bash

kubectl scale --replicas=10 deployment/knote

There should be nine additional Knote Pods being created.

You can watch them come online with:

bash

kubectl get pods -l app=knote --watch

After a short moment, the new Pods should all be Running.

Go back to your app in the web browser and reload the page a couple of times.

Are the pictures always displayed?

Yes they are!

Thanks to statelessness, your Pods can now be scaled to any number of replicas without any data loss or inconsistent behaviour.

You just created a scalable app!

When you're done playing with your app, delete it from the cluster with:

bash

kubectl delete -f kube

And stop Minikube with:

bash

minikube stop

You don't need Minikube anymore.

In the next section, you will create a new Kubernetes cluster in the cloud and deploy your app there!