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
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
ssh [email protected]
ssh [email protected]
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:
server.js
- become the main filetest
- new user scriptbuild
andbuild-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 isnode14-linux-x64
. But our deployment platform will be alpine so, the target should benode14-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.
- 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 .
- 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"
}
}
- In case of changing version update-modules
npm update
- 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
- Run this simple code
pulumi config set --secret linode:token
pulumi up
- 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.
- 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 .
- 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"
}
}
- In case of changing the version - update-modules
npm update
- 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
);
- 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.