NodeJS app on LKE with Pulumi

Welcome

I had an interesting idea, write a small app with something. Deploy this app on K8S on Linode (LKE), with the usage of Pulumi. I started with the Golang app as well as with pulumi-go, but I realized that I can turn it into a bit more fresh experience. I've never before use Node and TypeScript. Also building images for another toolset is always a challenge, especially if you in normal life use npm build, and push all artifacts on Nginx. The post is one of the longest on this blog, I hope it will be interesting for you at least it was for me.

Tools used in this episode

  • http
  • jq
  • pulumi
  • nodeJS
  • podman
  • emacs

Linode

I'm looking for a small, gentle, but solid hosting company. To be honest there are two candidate - Linode and DigitalOcean. Both of them look pretty. For some reason, I've received a message from Fred. Fred is working for Linode. Free testing resources are always great, so here I'm. But first, Linode has a great REST API. It's really cool, we can skip using cli/web page, we can just use curl/httpie. Look at those examples below.

Get regiones

 1http https://api.linode.com/v4/regions | jq '.data[].id?'
 2
 3    "id": "ap-west",
 4    "id": "ca-central",
 5    "id": "ap-southeast",
 6    "id": "us-central",
 7    "id": "us-west",
 8    "id": "us-east",
 9    "id": "eu-west",
10    "id": "ap-south",
11    "id": "eu-central",
12    "id": "ap-northeast",

Get available image type for out Linodes

 1http https://api.linode.com/v4/images | jq '.data[].id?'
 2
 3  "linode/alpine3.10"
 4  "linode/alpine3.11"
 5  "linode/alpine3.12"
 6  "linode/alpine3.9"
 7  "linode/arch"
 8  "linode/centos7"
 9  "linode/centos8"
10  [...]

In general 36 types of images, If you don't believe check it.

1http https://api.linode.com/v4/images | jq '.data[].id?' | wc -l

Get machines type with description

 1http  https://api.linode.com/v4/linode/types | jq '.data[]? | select(.id=="g1-gpu-rtx6000-4")'
 2
 3{
 4  "id": "g1-gpu-rtx6000-4",
 5  "label": "Dedicated 128GB + RTX6000 GPU x4",
 6  "price": {
 7    "hourly": 6,
 8    "monthly": 4000
 9  },
10  "addons": {
11    "backups": {
12      "price": {
13        "hourly": 0.24,
14        "monthly": 160
15      }
16    }
17  },
18  "memory": 131072,
19  "disk": 2621440,
20  "transfer": 20000,
21  "vcpus": 24,
22  "gpus": 4,
23  "network_out": 10000,
24  "class": "gpu",
25  "successor": null
26}

Or just types

1http  https://api.linode.com/v4/linode/types | jq '.data[].id'

For more examples check out their docs

Pulumi

What about Pulumi? It's as they said Modern Infrastructure as Code tool. Not so popular as Terraform, with a different approach, and... a lot of great ideas. The first one is the language range. You don't need to learn HCL, or any other DSL. You like Python - OK, they have it. Maybe JavaScript or Go? Both here. Also, .NET, if somebody will ask. In general nice tool, they have WEB UI, with all your stacks, config, etc. So you don't need to take care of state files. Also, code changes etc. Really useful stuff. That's for now. I will use NodeJS with TypeScript. Why? Because I can.

Let's init some project

1mkdir pulumi-linodes
2cd pulumi-linodes
3pulumi new typescript --name "linode-vms" \
4--description "Build some Linodes" \
5--stack dev \
6--dir .

Some magic code

 1import * as pulumi from "@pulumi/pulumi";
 2import * as linode from "@pulumi/linode";
 3
 4
 5const instanceTemplate = {
 6    authorizedKeys: ["ssh-rsa AAAAB3NzaC1yc... [email protected]"],
 7    group: "foo",
 8    image: "linode/arch",
 9    label: "simple_instance",
10    privateIp: true,
11    region: "eu-central",
12    rootPass: "terr4form-test",
13    tags: ["foo"],
14    type: "g6-standard-1",
15}
16
17const web1 = new linode.Instance("web1", instanceTemplate);
18const web2 = new linode.Instance("web2", instanceTemplate);
19
20// that's the value which we want to get as a stacks variable
21export const publicIp1 = web1.ipAddress;
22export const publicIp2 = web2.ipAddress;

Run the code

 1$ pulumi config set --secret linode:token
 2
 3$ pulumi up
 4
 5Previewing update (dev)
 6
 7View Live: https://app.pulumi.com/3sky/test/dev/previews/d713d3e4-f731-40ba-bcb2-d7858f83897c
 8
 9     Type                      Name      Plan       
10 +   pulumi:pulumi:Stack       test-dev  create     
11 +   ├─ linode:index:Instance  linode2   create     
12 +   └─ linode:index:Instance  linode1   create     
13 
14Resources:
15    + 3 to create
16
17Do you want to perform this update? yes
18Updating (dev)
19
20View Live: https://app.pulumi.com/3sky/test/dev/updates/1
21
22     Type                      Name      Status      
23 +   pulumi:pulumi:Stack       test-dev  created     
24 +   ├─ linode:index:Instance  linode1   created     
25 +   └─ linode:index:Instance  linode2   created     
26 
27Outputs:
28    instanceIpAddress1: "172.105.94.134"
29    instanceIpAddress2: "172.104.253.52"
30
31Resources:
32    + 3 created
33
34Duration: 1m16s

Let's check that VMs are working

Both should be accessible via ssh

Most important cloud-native rules - clean up resources

1pulumi destroy
2pulumi rm stack

App in nodeJS

The node looks like the best backend framework ever. Fast, popular and salaries are high. Maybe that's a good time to switch career paths? I don't think so. Tech is tech, we still need to learn. What about the Stateful app on Elixir in the next article? Today we have Node, let's use it.

Hard start

In the beginning, we need to init our app to create package.json something like pom.xml.

OK, I typed npm init help... NPM installed some package with come CLI interface...damn. Next try npm init --help:

1npm init [--force|-f|--yes|-y|--scope]
2npm init <@scope> (same as `npx <@scope>/create`)
3npm init [<@scope>/]<name> (same as `npx [<@scope>/]create-<name>`)

Hm? I'm not sure where is the problem. Ok, let's use npm init. And I get a lot of params to set:

 1package name: (intro) hello-world
 2version: (1.0.0) 
 3description: Hello world app
 4entry point: (app.js) 
 5test command: 
 6git repository: 
 7keywords: 
 8author: 
 9license: (ISC) 
10About to write to /home/3sky/repos/node/node-intro/package.json:
11
12{
13  "name": "hello-world",
14  "version": "1.0.0",
15  "description": "Hello world app",
16  "main": "app.js",
17  "scripts": {
18    "test": "echo \"Error: no test specified\" && exit 1"
19  },
20  "author": "",
21  "license": "ISC"
22}
23
24
25Is this OK? (yes) yes

Nice, but why I can't just type:

1npm init \
2    --name Hello-world \
3    --version 1.0.0 \
4    ...

Fortunately, I get a package.json file

Add express framework

If I'm correct that is a very popular web framework for NodeJS. Also looks, minimal and clean.

1npm install express

As a result, I get more dependencies inside my package.json

Write base app code

For testing purposes, I'll split the app into app.js and server.js. That gives me the ability to run independent tests.

Sample app. JSON type response, with 200 status code. Two endpoints / and /status

 1// app.js
 2const express = require('express')
 3const app = express();
 4const port = 3000;
 5
 6app.get('/', (req, res) => {
 7  res.status(200).json({"msg": "Hello world!"})
 8});
 9
10
11app.get('/status', (req, res) => {
12  res.status(200).json({"status": "OK"});
13});
14
15app.listen(port, () => {
16  console.log(`Example app listening on port ${port}!`)
17});
18
19module.exports = app;
1//server.js
2var app = require("./app");

Tests

The same situation as before. Popular, well-known libs. I hope it's enough.

1npm install mocha chai request

Also, we need to put our tests in test directory

1mkdir test

and finally, let's write some test cases.

 1// test/app.js
 2var expect  = require('chai').expect;
 3var request = require('request');
 4var app = require("../app");
 5
 6describe('main and status', function() {
 7    describe ('main page', function() {
 8        it('main', function(done){
 9            request('http://localhost:3000/', function(error, response, body) {
10                expect(response.statusCode).to.equal(200);
11                done();
12            });
13        });
14        it('main', function(done){
15            request('http://localhost:3000/', function(error, response, body) {
16                expect(body).to.equal('{"msg":"Hello world!"}');
17                done();
18            });
19        });
20    });
21
22    describe ('status page', function() {
23        it('status', function(done){
24            request('http://localhost:3000/status', function(error, response, body) {
25                expect(response.statusCode).to.equal(200);
26                done();
27            });
28        });
29        it('status', function(done){
30            request('http://localhost:3000/status', function(error, response, body) {
31                expect(body).to.equal('{"status":"OK"}');
32                done();
33            });
34        });
35    });
36});

Pack nodeJS into binary

I think that is a very interesting topic. Almost the most exciting here. I was curious that is there a possibility to make a binary from NodeJS code. In this case, we can skip Nginx, or big Node container image. After few minutes I found two modules - pkg and nexe. The first one looks less complex, and give me better docs. So let's pack it.

1npm install pkg

In the below JSON file we have 3 interesting lines:

  1. server.js- become the main file
  2. test - new user script
  3. build and build-alpine, here we should stop a bit longer. When we build binary, we need to specify the node version, platform, and architecture. So when we building for our local machine(linux in my case), the build's target is node14-linux-x64. But our deployment platform will be alpine so, the target should be node14-alpine-x64.
 1{
 2  "name": "hello-world",
 3  "version": "1.0.0",
 4  "description": "Hello world app",
 5  "main": "server.js",
 6  "scripts": {
 7    "test": "./node_modules/.bin/mocha --exit",
 8    "build": "./node_modules/.bin/pkg --targets node14-linux-x64 server.js",
 9    "build-alpine": "./node_modules/.bin/pkg --targets node14-alpine-x64 server.js"
10  },
11  "author": "",
12  "license": "ISC",
13  "dependencies": {
14    "chai": "^4.2.0",
15    "express": "^4.17.1",
16    "mocha": "^8.2.1",
17    "pkg": "^4.4.9"
18  }
19}

Dockerfile

Ah, the Dockerfile, K8S peoples deprecated docker a few days ago. However, Dockerfiles are still some kind of standard. I have some tries with Buildah(maybe that another good topic). Unfortunately, it's a totally different approach to declaration file, At this time we have this, maybe in the feature I will use Buildah or I don't know buildpacks.

 1# important note: if I want to build binary for alpine 
 2# I should build on alpine
 3# we'll avoid error, debugging, etc
 4FROM alpine:latest as BASE
 5
 6RUN apk add --update nodejs npm
 7WORKDIR /usr/src/app
 8
 9COPY package-lock.json package.json ./
10RUN npm install
11
12COPY . .
13
14RUN npm test
15RUN npm run-script build-alpine
16
17
18FROM alpine:latest
19
20# for run  binary we need this c++ lib 
21RUN apk add libstdc++
22RUN addgroup -S node && adduser -S node -G node
23WORKDIR /usr/src/app
24
25COPY --chown=node:node --from=BASE /usr/src/app/server .
26
27EXPOSE 3000
28CMD [ "./server" ]

OK, when we have code and Dockerfile, we should build a container and push it to quay.io. Docker Hub has download rate limit.

1podman build -t quay.io/3sky/hello-node:1.0 .
2podman login quay.io
3podman push quay.io/3sky/hello-node:1.0 

Build LKE stack

We have an app, so it will be nice to have a platform. As you may know, it isn't the best solution in a regular environment to host a small app on Kubernetes cluster. Fortunately, in my example, the platform is for free and the app will be stopped after 10minuts(I suppose a lot faster). Let's start with pulumi.

  1. Make a dir with skeleton
1mkdir pulumi-lke
2cd pulumi-lke
3pulumi new typescript --name "lke-in-europe" \
4--description "Setup LKE with typescript" \
5--stack dev \
6--dir .
  1. Checkout package.json and fix Pulumi's package version if needed
 1{
 2    "name": "lke-in-europe",
 3    "devDependencies": {
 4        "@types/node": "^10.0.0"
 5    },
 6    "dependencies": {
 7        "@pulumi/linode": "^2.7.3",
 8        "@pulumi/pulumi": "^2.15.1"
 9    }
10}
  1. In case of changing version update-modules
1npm update
  1. Add some code
 1//pulumi-lke/index.ts
 2import * as pulumi from "@pulumi/pulumi";
 3import * as linode from "@pulumi/linode";
 4
 5// k8sVersion version of k8s, the latest one is 1.18
 6const k8sVersion = "1.18"
 7// workersPool number of workers
 8const workersPool = 2
 9// instanceType of instance
10const instanceType = "g6-standard-1"
11// region it's instance region param
12const region = "eu-central"
13
14const my_cluster = new linode.LkeCluster("my-super-lke", {
15    k8sVersion: k8sVersion,
16    label: "testing noder",
17    pools: [{
18        count: workersPool,
19        type: instanceType,
20    }],
21    region: region,
22    tags: ["prod", "testing"],
23});
24
25export const kubeconfig = my_cluster.kubeconfig
  1. Run this simple code
1pulumi config set --secret linode:token
2pulumi up
  1. If everything goes okay, we're able to list our nodes
1pulumi stack output kubeconfig | base64 -d > ~/.linode/kubeconf
2export KUBECONFIG=~/.linode/kubeconf

As a result, we should get a similar output:

1$ kubectl get node
2
3NAME                          STATUS   ROLES    AGE     VERSION
4lke14613-17901-5fcbeb2ccd6d   Ready    <none>   6m33s   v1.18.8
5lke14613-17901-5fcbeb2d30e9   Ready    <none>   6m34s   v1.18.8

That was cool, my fastest K8S cluster ever. So let's create some Kubernetes objects without yamls.

Build K8S objects

I've heard about Pulumi as a Terraform like software. After reading docs I realized that Pulimi allows me to provide K8S's objects just like any other stack. It's so simple and readable. We can skip yaml writing, and focus on apps or processes - awesome news.

  1. Build pulumis stack
1cd ..
2mkdir pulumi-k8s
3cd pulumi k8s
4
5pulumi new kubernetes-typescript  --name "k8s-objects" \
6--description "Control k8s objects via Pulumi" \
7--stack dev \
8--dir .
  1. Checkout package.json again and fix Pulumi's package version if needed
 1{
 2    "name": "k8s-objects",
 3    "devDependencies": {
 4        "@types/node": "^10.0.0"
 5    },
 6    "dependencies": {
 7        "@pulumi/linode": "^2.7.3",
 8        "@pulumi/pulumi": "^2.15.1",
 9        "@pulumi/kubernetesx": "^0.1.1"
10    }
11}
  1. In case of changing the version - update-modules
1npm update
  1. Add not-yaml file
 1//pulumi-k8s/index.ts
 2import * as pulumi from "@pulumi/pulumi";
 3import * as k8s from "@pulumi/kubernetes";
 4
 5const config = new pulumi.Config();
 6
 7const appName = "hello-node";
 8const appLabels = { app: appName };
 9const appImage = "quay.io/3sky/hello-node:1.0"
10// port which our container uses
11const targetPort = 3000
12
13// Build LKE stack
14const deployment = new k8s.apps.v1.Deployment(appName, {
15    spec: {
16        selector: { matchLabels: appLabels },
17        replicas: 1,
18        template: {
19            metadata: { labels: appLabels },
20            spec: { containers: [{ name: appName, image: appImage }] }
21        }
22    }
23});
24
25// Allocate an IP to the Deployment.
26const frontend = new k8s.core.v1.Service(appName, {
27    metadata: { labels: deployment.spec.template.metadata.labels },
28    spec: {
29        type: "LoadBalancer",
30        ports: [{ port: 80, targetPort: targetPort, protocol: "TCP" }],
31        selector: appLabels
32    }
33});
34
35// When "done", this will print the public IP.
36export const ip = frontend.status.loadBalancer.apply(
37          (lb) => lb.ingress[0].ip || lb.ingress[0].hostname
38      );
  1. Check the code out
1pulumi config set --secret linode:token
2pulumi up

Test it

That's a moment of truth. My first NodeJS app, on LKE with the usage of Pulumi. Sample HTTP request will be enough.

 1❯❯ http $(pulumi stack output ip)/status
 2
 3HTTP/1.1 200 OK
 4Connection: keep-alive
 5Content-Length: 15
 6Content-Type: application/json; charset=utf-8
 7Date: Sat, 05 Dec 2020 20:53:05 GMT
 8ETag: W/"f-v/Y1JusChTxrQUzPtNAKycooOTA"
 9X-Powered-By: Express
10
11{
12   "status": "OK"
13}

Ladies and gentlemen, it works! Uff a lot of things could go wrong. As always when we play with different new tools.

Clean up

1pulumi destroy 
2pulumi stack rm 
3cd ../pulumi-lke
4pulumi destroy 
5pulumi stack rm 

Summary

A lot of stuff to sum up. Let's start with Linode. Very nice provider, quite affordable if we play around with new tools. REST API is clear and very helpful if we want to check something, skipping the part about learning a new CLI tool is sweet. I need to mention the level of complexity whole ecosystem. When I worked on GCP, a lot of variables need to be configured. Security groups, roles, accesses, that super useful... but if your infra is small, or your team is small, or you're one-many-army, all this stuff become another boring and time-consuming work.
Another tool - Pulumi. This project brings some fun in IaaC, the possibility to write in different languages, better documentation, WEB UI. I like to write code, so I feel more comfortable here than in HCL. Although the tool needs more investigation from my side.
Next NodeJS, nothing to say. It's working, docs are poor, is hard to find a good source of information. Community is big, so there are tons of posts, about different stuff, but finding specific info is difficult. At least for me. Nonetheless, apps are small, easy to read, testing is effortless and fast. node_modules is an intresting idea, something like ~.m2 per project. Big storage consumption, but easier dependency consistency.
At this moment I will stay with Linode and Pumumi for sure. In the case of NodeJS, it's good to understand npm, package.json, since the JavaScript ecosystem is very popular.
I almost forgot this whole article, as well as code, was written in emacs - Doom Emacs to be exact. An interesting piece of software - if someone like VIM, and want to try something new Doom Emacs is an option.