3sky's notes

Minimal blog about IT

NodeJS app on LKE with Pulumi

2020-12-06 12 min read 3sky

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

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

    "id": "ap-west",
    "id": "ca-central",
    "id": "ap-southeast",
    "id": "us-central",
    "id": "us-west",
    "id": "us-east",
    "id": "eu-west",
    "id": "ap-south",
    "id": "eu-central",
    "id": "ap-northeast",

Get available image type for out Linodes

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

  "linode/alpine3.10"
  "linode/alpine3.11"
  "linode/alpine3.12"
  "linode/alpine3.9"
  "linode/arch"
  "linode/centos7"
  "linode/centos8"
  [...]

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

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

Get machines type with description

http  https://api.linode.com/v4/linode/types | jq '.data[]? | select(.id=="g1-gpu-rtx6000-4")'

{
  "id": "g1-gpu-rtx6000-4",
  "label": "Dedicated 128GB + RTX6000 GPU x4",
  "price": {
    "hourly": 6,
    "monthly": 4000
  },
  "addons": {
    "backups": {
      "price": {
        "hourly": 0.24,
        "monthly": 160
      }
    }
  },
  "memory": 131072,
  "disk": 2621440,
  "transfer": 20000,
  "vcpus": 24,
  "gpus": 4,
  "network_out": 10000,
  "class": "gpu",
  "successor": null
}

Or just types

http  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

mkdir pulumi-linodes
cd pulumi-linodes
pulumi new typescript --name "linode-vms" \
--description "Build some Linodes" \
--stack dev \
--dir .

Some magic code

import * as pulumi from "@pulumi/pulumi";
import * as linode from "@pulumi/linode";


const instanceTemplate = {
    authorizedKeys: ["ssh-rsa AAAAB3NzaC1yc... [email protected]"],
    group: "foo",
    image: "linode/arch",
    label: "simple_instance",
    privateIp: true,
    region: "eu-central",
    rootPass: "terr4form-test",
    tags: ["foo"],
    type: "g6-standard-1",
}

const web1 = new linode.Instance("web1", instanceTemplate);
const web2 = new linode.Instance("web2", instanceTemplate);

// that's the value which we want to get as a stacks variable
export const publicIp1 = web1.ipAddress;
export const publicIp2 = web2.ipAddress;

Run the code

$ pulumi config set --secret linode:token

$ pulumi up

Previewing update (dev)

View Live: https://app.pulumi.com/3sky/test/dev/previews/d713d3e4-f731-40ba-bcb2-d7858f83897c

     Type                      Name      Plan       
 +   pulumi:pulumi:Stack       test-dev  create     
 +   ├─ linode:index:Instance  linode2   create     
 +   └─ linode:index:Instance  linode1   create     
 
Resources:
    + 3 to create

Do you want to perform this update? yes
Updating (dev)

View Live: https://app.pulumi.com/3sky/test/dev/updates/1

     Type                      Name      Status      
 +   pulumi:pulumi:Stack       test-dev  created     
 +   ├─ linode:index:Instance  linode1   created     
 +   └─ linode:index:Instance  linode2   created     
 
Outputs:
    instanceIpAddress1: "172.105.94.134"
    instanceIpAddress2: "172.104.253.52"

Resources:
    + 3 created

Duration: 1m16s

Let’s check that VMs are working

Both should be accessible via ssh

Most important cloud-native rules - clean up resources

pulumi destroy
pulumi 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:

npm init [--force|-f|--yes|-y|--scope]
npm init <@scope> (same as `npx <@scope>/create`)
npm 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:

package name: (intro) hello-world
version: (1.0.0) 
description: Hello world app
entry point: (app.js) 
test command: 
git repository: 
keywords: 
author: 
license: (ISC) 
About to write to /home/3sky/repos/node/node-intro/package.json:

{
  "name": "hello-world",
  "version": "1.0.0",
  "description": "Hello world app",
  "main": "app.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "",
  "license": "ISC"
}


Is this OK? (yes) yes

Nice, but why I can’t just type:

npm init \
    --name Hello-world \
    --version 1.0.0 \
    ...

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.

npm 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

// app.js
const express = require('express')
const app = express();
const port = 3000;

app.get('/', (req, res) => {
  res.status(200).json({"msg": "Hello world!"})
});


app.get('/status', (req, res) => {
  res.status(200).json({"status": "OK"});
});

app.listen(port, () => {
  console.log(`Example app listening on port ${port}!`)
});

module.exports = app;
//server.js
var app = require("./app");

Tests

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

npm install mocha chai request

Also, we need to put our tests in test directory

mkdir test

and finally, let’s write some test cases.

// test/app.js
var expect  = require('chai').expect;
var request = require('request');
var app = require("../app");

describe('main and status', function() {
    describe ('main page', function() {
        it('main', function(done){
            request('http://localhost:3000/', function(error, response, body) {
                expect(response.statusCode).to.equal(200);
                done();
            });
        });
        it('main', function(done){
            request('http://localhost:3000/', function(error, response, body) {
                expect(body).to.equal('{"msg":"Hello world!"}');
                done();
            });
        });
    });

    describe ('status page', function() {
        it('status', function(done){
            request('http://localhost:3000/status', function(error, response, body) {
                expect(response.statusCode).to.equal(200);
                done();
            });
        });
        it('status', function(done){
            request('http://localhost:3000/status', function(error, response, body) {
                expect(body).to.equal('{"status":"OK"}');
                done();
            });
        });
    });
});

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.

npm 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.
{
  "name": "hello-world",
  "version": "1.0.0",
  "description": "Hello world app",
  "main": "server.js",
  "scripts": {
    "test": "./node_modules/.bin/mocha --exit",
    "build": "./node_modules/.bin/pkg --targets node14-linux-x64 server.js",
    "build-alpine": "./node_modules/.bin/pkg --targets node14-alpine-x64 server.js"
  },
  "author": "",
  "license": "ISC",
  "dependencies": {
    "chai": "^4.2.0",
    "express": "^4.17.1",
    "mocha": "^8.2.1",
    "pkg": "^4.4.9"
  }
}

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.

# important note: if I want to build binary for alpine 
# I should build on alpine
# we'll avoid error, debugging, etc
FROM alpine:latest as BASE

RUN apk add --update nodejs npm
WORKDIR /usr/src/app

COPY package-lock.json package.json ./
RUN npm install

COPY . .

RUN npm test
RUN npm run-script build-alpine


FROM alpine:latest

# for run  binary we need this c++ lib 
RUN apk add libstdc++
RUN addgroup -S node && adduser -S node -G node
WORKDIR /usr/src/app

COPY --chown=node:node --from=BASE /usr/src/app/server .

EXPOSE 3000
CMD [ "./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.

podman build -t quay.io/3sky/hello-node:1.0 .
podman login quay.io
podman 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
mkdir pulumi-lke
cd pulumi-lke
pulumi new typescript --name "lke-in-europe" \
--description "Setup LKE with typescript" \
--stack dev \
--dir .
  1. Checkout package.json and fix Pulumi’s package version if needed
{
    "name": "lke-in-europe",
    "devDependencies": {
        "@types/node": "^10.0.0"
    },
    "dependencies": {
        "@pulumi/linode": "^2.7.3",
        "@pulumi/pulumi": "^2.15.1"
    }
}
  1. In case of changing version update-modules
npm update
  1. Add some code
//pulumi-lke/index.ts
import * as pulumi from "@pulumi/pulumi";
import * as linode from "@pulumi/linode";

// k8sVersion version of k8s, the latest one is 1.18
const k8sVersion = "1.18"
// workersPool number of workers
const workersPool = 2
// instanceType of instance
const instanceType = "g6-standard-1"
// region it's instance region param
const region = "eu-central"

const my_cluster = new linode.LkeCluster("my-super-lke", {
    k8sVersion: k8sVersion,
    label: "testing noder",
    pools: [{
        count: workersPool,
        type: instanceType,
    }],
    region: region,
    tags: ["prod", "testing"],
});

export const kubeconfig = my_cluster.kubeconfig
  1. Run this simple code
pulumi config set --secret linode:token
pulumi up
  1. If everything goes okay, we’re able to list our nodes
pulumi stack output kubeconfig | base64 -d > ~/.linode/kubeconf
export KUBECONFIG=~/.linode/kubeconf

As a result, we should get a similar output:

$ kubectl get node

NAME                          STATUS   ROLES    AGE     VERSION
lke14613-17901-5fcbeb2ccd6d   Ready    <none>   6m33s   v1.18.8
lke14613-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
cd ..
mkdir pulumi-k8s
cd pulumi k8s

pulumi new kubernetes-typescript  --name "k8s-objects" \
--description "Control k8s objects via Pulumi" \
--stack dev \
--dir .
  1. Checkout package.json again and fix Pulumi’s package version if needed
{
    "name": "k8s-objects",
    "devDependencies": {
        "@types/node": "^10.0.0"
    },
    "dependencies": {
        "@pulumi/linode": "^2.7.3",
        "@pulumi/pulumi": "^2.15.1",
        "@pulumi/kubernetesx": "^0.1.1"
    }
}
  1. In case of changing the version - update-modules
npm update
  1. Add not-yaml file
//pulumi-k8s/index.ts
import * as pulumi from "@pulumi/pulumi";
import * as k8s from "@pulumi/kubernetes";

const config = new pulumi.Config();

const appName = "hello-node";
const appLabels = { app: appName };
const appImage = "quay.io/3sky/hello-node:1.0"
// port which our container uses
const targetPort = 3000

// Build LKE stack
const deployment = new k8s.apps.v1.Deployment(appName, {
    spec: {
        selector: { matchLabels: appLabels },
        replicas: 1,
        template: {
            metadata: { labels: appLabels },
            spec: { containers: [{ name: appName, image: appImage }] }
        }
    }
});

// Allocate an IP to the Deployment.
const frontend = new k8s.core.v1.Service(appName, {
    metadata: { labels: deployment.spec.template.metadata.labels },
    spec: {
        type: "LoadBalancer",
        ports: [{ port: 80, targetPort: targetPort, protocol: "TCP" }],
        selector: appLabels
    }
});

// When "done", this will print the public IP.
export const ip = frontend.status.loadBalancer.apply(
          (lb) => lb.ingress[0].ip || lb.ingress[0].hostname
      );
  1. Check the code out
pulumi config set --secret linode:token
pulumi 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.

❯❯ http $(pulumi stack output ip)/status

HTTP/1.1 200 OK
Connection: keep-alive
Content-Length: 15
Content-Type: application/json; charset=utf-8
Date: Sat, 05 Dec 2020 20:53:05 GMT
ETag: W/"f-v/Y1JusChTxrQUzPtNAKycooOTA"
X-Powered-By: Express

{
   "status": "OK"
}

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

Clean up

pulumi destroy 
pulumi stack rm 
cd ../pulumi-lke
pulumi destroy 
pulumi 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.