Moving a Node.js app from PaaS to Kubernetes Tutorial

RisingStack's services:

Sign up to our newsletter!

In this article:

From this Kubernetes tutorial, you can learn how to move a Node.js app from a PaaS provider while achieving lower response times, improving security and reducing costs.

Before we jump into the story of why and how we migrated our services to Kubernetes, it’s important to mention that there is nothing wrong with using a PaaS. PaaS is perfect to start building a new product, and it can also turn out to be a good solution as an application advances – it always depends on your requirements and resources.

PaaS

Trace by RisingStack, our Node.js monitoring solution was running on one of the biggest PaaS providers for more than half a year. We have chosen a PaaS over other solutions because we wanted to focus more on the product instead of the infrastructure.

Our requirements were simple; we wanted to have:

  • fast deploys,
  • simple scaling,
  • zero-downtime deployments,
  • rollback capabilities,
  • environment variable management,
  • various Node.js versions,
  • and “zero” DevOps.

What we didn’t want to have, but got as a side effect of using PaaS:

  • big network latencies between services,
  • lack of VPC,
  • response time peaks because of the multitenancy,
  • larger bills (pay for every single process, no matter how small it is: clock, internal API, etc.).

Trace is developed as a group of micro-services, you can imagine how quickly the network latency and billing started to hurt us.

Kubernetes tutorial

From our PaaS experience, we knew that we are looking for a solution that needs very few DevOps effort but provides a similar flow for our developers. We didn’t want to lose any of the advantages I mentioned above – however, we wanted to fix the outstanding issues.

We were looking for an infrastructure that is more configuration-based, and anyone from the team can modify it.

Kubernetes with its’ configuration-focused, container-based and micro-services friendly nature convinced us.

Let me show you what I mean under these “buzzwords” through the upcoming sections.

What is Kubernetes?

Kubernetes is an open-source system for automating deployments, scaling, and management of containerized applications – kubernetes.io

I don’t want to give a very deep intro about the Kubernetes elements here, but you need to know the basic ones for the upcoming parts of this post.

My definitions won’t be 100% correct, but you can think of it as a PaaS to Kubernetes dictionary:

  • pod: your running containerized application with environment variables, disk, etc. together, pods born and die quickly, like at deploys,
    • in PaaS: ~currently running app
  • deployment: configuration of your application that describes what state do you need (CPU, memory, env. vars, docker image version, disks, number of running instances, deploy strategy, etc.):
  • in PaaS: ~app settings
  • secret: you can separate your credentials from environment variables,
  • in PaaS: not exist, like a shared separated secret environment variable, for DB credentials etc.
  • service: exposes your running pods by label(s) to other apps or to the outside world on the desired IP and port
  • in PaaS: built-in non-configurable load balancer

How to set up a running Kubernetes cluster?

You have several options here. The easiest one is to create a Container Engine in Google Cloud, which is a hosted Kubernetes. It’s also well integrated with other Google Cloud components, like load balancers and disks.

You should also know, that Kubernetes can run anywhere like AWS, DigitalOcean, Azure etc. For more information check out the CoreOS Kubernetes tools.

Running the application

First, we have to prepare our application to work well with Kubernetes in a Docker environment.

If you are looking for a tutorial on how to start an app from scratch with Kubernetes check out their basics tutorial.

Node.js app in Docker container

Kubernetes is Docker-based, so first we need to containerize our application. If you are not sure how to do that, check out our previous post: Dockerizing Your Node.js Application

If you are a private NPM user, you will also find this one helpful: Using the Private NPM Registry from Docker

“Procfile” in Kubernetes

We create one Docker image for every application (Git repository). If the repository contains multiple processes like: serverworker and clock we choose between them with an environment variable. Maybe you find it strange, but we don’t want to build and push multiple Docker images from the very same source code, it would slow down our CI.

Environments, rollback, and service-discovery

Staging, production

During our PaaS period we named our services like trace-foo and trace-foo-staging, the only difference between the staging and production application was the name prefix and the different environment variables. In Kubernetes it’s possible to define namespaces. Each namespace is totally independent from each other and doesn’t share any resources like secrets, config, etc.

$ kubectl create namespace production
$ kubectl create namespace staging

Application versions

In a containerized infrastructure, each application version should be a different container image with a tag. We use the Git short hash as a Docker image tag.

foo:b37d759
foo:f53a7cb

To deploy a new version of your application, you only need to change the image tag in your application’s deployment config, Kubernetes will do the rest.

Kubernetes Tutorial: The Deploy Flow

(Deploy flow)

Any change in your deployment file is versioned and you can rollback to them anytime.

$ kubectl rollout history deployment/foo
deployments "foo":
REVISION    CHANGE-CAUSE
1           kubectl set image deployment/foo foo=foo:b37d759
2           kubectl set image deployment/foo foo=foo:f53a7cb

During our deploy process, we only replace Docker images which are quite fast – they only require a couple of seconds.

Service discovery

Kubernetes has a built-in simple service discovery solution: The created services expose their hostname and port as an environment variable for each pod.

const fooServiceUrl = `http://${process.env.FOO_SERVICE_HOST}:${process.env.FOO_SERVICE_PORT}`

If you don’t need advanced discovery, you can just start using it, instead of copying your service URLs to each other’s environment variables. Kind of cool, isn’t it?

Production ready application

The reallly challenging part of jumping into a new technology is to know what you need to be production ready. In the following section we will check what you should consider to set up in your app.

Zero downtime deployment and failover

Kubernetes can update your application in a way that it always keeps some pods running and deploy your changes in smaller steps – instead of stopping and starting all of them at the same time.

It’s not just helpful to prevent zero downtime deploys; it also avoids killing your whole application when you misconfigure something. Your mistake stops escalating to all of the running pods after Kubernetes detects that your new pods are unhealthy.

Kubernetes supports several strategies to deploy your applications. You can check them in the Deployment strategy documentation.

Graceful stop

It’s not mainly related to Kubernetes, but it’s impossible to have a good application lifecycle without starting and stopping your process in a proper way.

Start server

const server = MyServer()
Promise.all([
   db1.connect()
   db2.connect()
])
  .then() => server.listen(3000))

Gracefull server stop

process.on('SIGTERM', () => {
  server.close()
    .then() => Promise.all([
      db1.disconnect()
      db2.disconnect()
    ])
   .then(() => process.exit(0))
   .catch((err) => process.exit(-1))
})

Liveness probe (health check)

In Kubernetes, you should define health check (liveness probe) for your application. With this, Kubernetes will be able to detect when your application needs to be restarted.

Web server health check

You have multiple options to check your applications health, but I think the easiest one is to create a GET /healthz endpoint end check your applications logic / DB connections there. It’s important to mention that every application is different, only you can know what checks are necessary to make sure it’s working.

app.get('/healthz', function (req, res, next) {
  // check my health
  // -> return next(new Error('DB is unreachable'))
  res.sendStatus(200)
})
livenessProbe:
    httpGet:
      # Path to probe; should be cheap, but representative of typical behavior
      path: /healthz
      port: 3000
    initialDelaySeconds: 30
    timeoutSeconds: 1

Worker health check

For our workers we also set up a very small HTTP server with the same /healthz endpoint which checks different criteria with the same liveness probe. We do it to have company-wide consistent health check endpoints.

Readiness probe

The readiness probe is similar to the liveness probe (health check), but it makes sense only for web servers. It tells the Kubernetes service (~load balancer) that the traffic can be redirected to the specific pod.

It is essential to avoid any service disruption during deploys and other issues.

readinessProbe:
    httpGet:
      # You can use the /healthz or something else
      path: /healthz
      port: 3000
    initialDelaySeconds: 30
    timeoutSeconds: 1

Logging

For logging, you can choose from different approaches, like adding side containers to your application which collects your logs and sends them to custom logging solutions, or you can go with the built-in Google Cloud one. We selected the built-in one.

To be able to parse the built in log levels (severity) on Google Cloud, you need to log in the specific format. You can achieve this easily with the winston-gke module.

// setup logger
cons logger = require(‘winston’)
cons winstonGke = require(‘winston-gke’)
logger.remove(logger.transports.Console)
winstonGke(logger, config.logger.level)

// usage
logger.info(‘I\’m a potato’, { foo: ‘bar’ })
logger.warning(‘So warning’)
logger.error(‘Such error’)
logger.debug(‘My debug log)

If you log in the specific format, Kubernetes will automatically merge your log messages with the container, deployment, etc. meta information and Google Cloud will show it in the right format.

Your applications first log message has to be in the right format, otherwise it won’t start to parse it correctly.

To achieve this, we turned our npm start to silent, npm start -s in a Dockerfile: CMD ["npm", "start", "-s"]

Monitoring

We check our applications with Trace which is optimized from scratch to monitor and visualize micro-service architectures. The service map view of Trace helped us a lot during the migration to understand which application communicates with which one and what are the database and external dependencies.

Kubernetes Tutorial: Services in our infrastructure

(Services in our infrastructure)

Since Trace is environment independent, we didn’t have to change anything in our codebase, and we could use it to validate the migration and our expectations about the positive performance changes.

Kubernetes tutorial : Response times after the migration

(Stable and fast response times)

Example

Check out our all together example repository for Node.js with Kubernetes and CircleCI:
https://github.com/RisingStack/kubernetes-nodejs-example

Tooling

Continuous deployment with CI

It’s possible to update your Kubernetes deployment with a JSON path, or update only the image tag. After you have a working kubectl on your CI machine, you only need to run this command:

$ kubectl --namespace=staging set image deployment/foo foo=foo:GIT_SHORT_SHA

Debugging

In Kubernetes it’s possible to run a shell inside any container, it’s this easy:

$ kubectl get pod

NAME           READY     STATUS    RESTARTS   AGE
foo-37kj5   1/1       Running   0          2d

$ kubectl exec foo-37kj5 -i -t -- sh
# whoami       
root

Another useful thing is to check the pod events with:

$ kubectl describe pod foo-37kj5

You can also get the log message of any pod with the:

$ kubectl log foo-37kj5

Code piping

At our PaaS provider, we liked code piping between staging and production infrastructure. In Kubernetes we missed this, so we built our own solution.

It’s a simple npm library which reads the current image tag from staging and sets it on the production deployment config.

Because the Docker container is the very same, only the environment variable changes.

SSL termination (https)

Kubernetes services are not exposed as https by default, but you can easily change this. To do so, read how to expose your applications with TLS in Kubernetes.

Conclusion

To summarize our experience with Kubernetes: we are very satisfied with it.

We improved our applications response time in our micro-service architecture. We managed to raise security to the next level with the private network (VPC) between apps.

Also, we reduced our costs and improved the failover with the built-in rolling update strategy and liveness, readiness probes.

If you are in a state when you need to think about your infrastructure’s future, you should definitely take Kubernetes into consideration!

If you have questions about migrating to Kubernetes from a PaaS, feel free to post them in the comment section.

Share this post

Twitter
Facebook
LinkedIn
Reddit