Skip to main content

Run simple Node.js application in Kubernetes cluster

· 8 min read
Matej Jelluš
Tech leader and IT nerd who is constantly trying new things, sharing his experiences and still enjoys writing code in his free time. Currently looking for new challenges and opportunities.

Write simple Node.js backend using TypeScript, Express and Terminus and run it in Digital Ocean Kubernetes cluster. In the end you will have application which is running in multiple replications and has access to environment variable passed to the application by Kubernetes secret.

Requirements:

  • Digital Ocean account
  • be able to write simple Node.js application
  • basic knowledge about Kubernetes

Digital Ocean Kubernetes

In this example I am using Kubernetes from Digital Ocean.

Create Kubernetes cluster

Create a new Kubernetes cluster and you can use the cheapest nodes (droplets) available, which are $10/month per node. You can also choose a datacenter region that is nearest to you or your potential customers.

Create a cluster

When you hit "Create Cluster" button, you will see a list of steps through which you need to go. The cluster needs some time to initialize and be ready to use. Now you have couple of minutes, so you can continue reading and prepare the application, then return here and finish this part.

Install NGINX Ingress Controller

In the right panel you have option to install so called 1-Click apps. Mandatory application to install is NGINX Ingress Controller.

1-Click apps

Install Cert manager

To be able to use secure communication (use https) we need to generate SSL certificate and "attach" it to the ingress. We use cert-manager to manage certificates (generate and renew). Install the latest available version, in this case it is v1.6.0:

kubectl apply -f https://github.com/jetstack/cert-manager/releases/download/v1.6.0/cert-manager.yaml

Verify the installation:

kubectl get pods --namespace cert-manager
NAME                                       READY   STATUS    RESTARTS   AGE
cert-manager-5cfb94c959-lltfh 1/1 Running 0 5d9h
cert-manager-cainjector-676bbc785b-wbtbn 1/1 Running 0 5d9h
cert-manager-webhook-67574fc5f5-7j5nw 1/1 Running 0 5d9h

Application

Let's have a basic Node.js application which will do 2 things:

  • return random number generated on application start
  • return environment variable that is passed to the application by Kubernetes

With random generated number we can easilly check if the application is running behind load balancer. Kubernetes is using round-robin algorithm by default, so if you make several requests you should get different number every time. For example the number of replications is 2, which means 2 applications are running, each will generate a different random number like 576 and 737. If are making requests, you should get back:

{"appId":"1.0.576"}
{"appId":"1.0.737"}
{"appId":"1.0.576"}
{"appId":"1.0.737"}
...

Because you don't want to store sensitive data with your code, you probably want to pass some variables to the application by the server. In K8S this is done through ConfigMap or Secret. That's why we need another route which will return value from the config and we will be able to verify it was passed to the application.

Initialize the project

Create a new folder:

mkdir node-docker-kubernetes

Install dependencies:

yarn add express
yarn add --dev @types/express @types/node nodemon typescript

Add start and build script to your package.json file:

{
"scripts": {
"start": "tsc -w & nodemon -q -w dist dist/index.js",
"build": "tsc"
}
}

Create src/config.ts file

// src/config.ts
const config = {
exampleParameter: 'value of example parameter',
secretParameter: process.env.SECRET_PARAMETER,
}

export default config;

Create src/index.ts file

// src/index.ts
import express from 'express';
import config from './config';

const app = express();
const appId = `1.0.${Math.floor(Math.random() * 1000)}`;

app.get('/', (req, res) => {
res.send('Hello kubernetes!');
});

app.get('/app-id', (req, res) => {
res.json({ appId });
});

app.get('/env', (req, res) => {
res.json(config);
});

app.get('/env/:name', (req, res) => {
res.json({
name: req.params.name,
value: config[req.params.name],
});
});

app.listen(3000, () => {
logger.info('Server started at http://localhost:3001');
});

Create a dockerfile

FROM node:16-alpine AS build

USER node
RUN mkdir /home/node/node-express-kubernetes-example/ && chown -R node:node /home/node/node-express-kubernetes-example
WORKDIR /home/node/node-express-kubernetes-example

COPY --chown=node:node . .
RUN yarn install --frozen-lockfile && yarn build

FROM node:14-alpine

USER node
EXPOSE 3001

RUN mkdir /home/node/node-express-kubernetes-example/ && chown -R node:node /home/node/node-express-kubernetes-example
WORKDIR /home/node/node-express-kubernetes-example

COPY --chown=node:node --from=build /home/node/node-express-kubernetes-example/dist ./dist
COPY --chown=node:node --from=build /home/node/node-express-kubernetes-example/package.json /home/node/node-express-kubernetes-example/yarn.lock ./
RUN yarn install --frozen-lockfile --production

CMD [ "node", "dist/index.js" ]

Build image and push it to repository

If you are using Docker Hub as your container registry:

docker build -t <your user name>/<project name> .

docker tag <your user name>/<project name> <your user name>/<project name>:<tag>

docker push <your user name>/<project name>:<tag>

If you want to use DigitalOcean:

info

Before you push, you need to be authenticated in our registry. For this, you need to use doctl utility, init first auth context, login and then you will be able to push.

doctl registry login
docker build -t <your user name>/<project name> .

docker tag <your user name>/<project name> registry.digitalocean.com/<your user name>/<project name>:<tag>

docker push registry.digitalocean.com/<your user name>/<project name>:<tag>

Run the application in Kubernetes

On the image below you can see what parts you need to define and how a request is handled inside. You need an ingress, which knows the domain name of your application and because each pod has a limited lifetime (it can be destroyed and recreated anytime) you cannot connect to pods directly. It needs a service where the request is forwarded and this service knows to which pods it should talk to.

Kubernetes architecture

Create deployment

Deployment tells Kubernetes how to create or modify instances of the pods. You can define here also livenessProbe and readinessProbe.

apiVersion: apps/v1
kind: Deployment
metadata:
name: node-express-kubernetes-example-deployment
spec:
replicas: 2
selector:
matchLabels:
app: node-express-kubernetes-example-deployment
template:
metadata:
labels:
app: node-express-kubernetes-example-deployment
spec:
containers:
- name: node-express-kubernetes-example-application
image: registry.digitalocean.com/juffalow/node-express-kubernetes-example:latest
ports:
- containerPort: 3001
env:
- name: SECRET_PARAMETER
valueFrom:
secretKeyRef:
name: node-express-kubernetes-example-secret
key: SECRET_PARAMETER
livenessProbe:
httpGet:
path: /health/liveness
port: 3001
initialDelaySeconds: 5
periodSeconds: 20
readinessProbe:
httpGet:
path: /health/liveness
port: 3001
initialDelaySeconds: 5
periodSeconds: 20
imagePullSecrets:
- name: registry-juffalow

Create Ingress

Ingress exposes HTTP and HTTPS routes from outside the cluster to services within the cluster.

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: node-express-kubernetes-example-ingress
annotations:
kubernetes.io/ingress.class: nginx
cert-manager.io/issuer: node-express-kubernetes-example-letsencrypt-dev
spec:
tls:
- hosts:
- node-example.juffalow.com
secretName: node-express-kubernetes-example-tls
rules:
- host: node-example.juffalow.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: node-express-kubernetes-example-cluster-ip
port:
number: 80

Create Production Issuer

Issuers, and ClusterIssuers, are Kubernetes resources that represent certificate authorities that are able to generate signed certificates by honoring certificate signing requests.

apiVersion: cert-manager.io/v1
kind: Issuer
metadata:
name: node-express-kubernetes-example-letsencrypt-dev
spec:
acme:
email: juffalow@juffalow.com
server: https://acme-v02.api.letsencrypt.org/directory
privateKeySecretRef:
name: node-express-kubernetes-example-letsencrypt-private-key
solvers:
- http01:
ingress:
class: nginx

Create Service

apiVersion: v1
kind: Service
metadata:
name: node-express-kubernetes-example-cluster-ip
spec:
ports:
- name: http
port: 80
protocol: TCP
targetPort: 3001
- name: https
port: 443
protocol: TCP
targetPort: 3001
selector:
app: node-express-kubernetes-example-deployment
type: ClusterIP

Create Secret

A Secret is an object that contains a small amount of sensitive data such as a password, a token, or a key. The values for all keys in the data field have to be base64-encoded strings. If the conversion to base64 string is not desirable, you can choose to specify the stringData field instead, which accepts arbitrary strings as values.

apiVersion: v1
kind: Secret
metadata:
name: node-express-kubernetes-example-secret
data:
# Value: DatabaseCredentials
SECRET_PARAMETER: RGF0YWJhc2VDcmVkZW50aWFscw==

To generate a base64 string, you can use command line command:

echo -n "<value>" | base64

Craete these resources in Kubernetes

You have everything ready and now you just need to create all these resources in your Kubernetes cluster. To do this, you use apply command:

kubectl apply -f ./secret.yaml

kubectl apply -f ./deployment.yaml

kubectl apply -f ./service.yaml

kubectl apply -f ./ingress.yaml

kubectl apply -f ./production_issuer.yaml

To check if it is created, you can use get command for each resource:

kubectl get secrets

kubectl get deployments

kubectl get services

kubectl get ingresses

kubectl get issuers

Get the public IP address

Now the application should be running inside the Kubernetes cluster. To be able to access it, you need to know the external ip address. This is IP address pointing to LoadBalancer, your nginx-ingress-controller you created in the beginning (1-Click app). You can check it with kubectl command:

kubectl get svc --namespace=ingress-nginx
NAME                                               TYPE           CLUSTER-IP       EXTERNAL-IP     PORT(S)                      AGE
nginx-ingress-ingress-nginx-controller LoadBalancer 10.245.125.99 67.207.78.247 80:30565/TCP,443:30894/TCP 415d
nginx-ingress-ingress-nginx-controller-admission ClusterIP 10.245.254.107 <none> 443/TCP 415d
nginx-ingress-ingress-nginx-controller-metrics ClusterIP 10.245.86.28 <none> 9913/TCP 415d

Or you can see in DigitalOcean application:

Kubernetes architecture

So if you pointed your (sub)domain to this IP address, you should be able to connect to your application. You can check if this example application is running here https://node-example.juffalow.com.

References

Udemy:

Books:

Running example:


Do you like this post? Is it helpful? I am always learning and trying new technologies, processes and approaches. When I struggle with something and finally manage to solve it, I share my experience. If you want to support me, please use button below. If you have any questions or comments, please reach me via email juffalow@juffalow.com.

I am also available as a mentor if you need help with your architecture, engineering team or if you are looking for an experienced person to validate your thoughts.