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
1ssh [email protected]
2ssh [email protected]
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:
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
.
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.
- 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 .
- 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}
- In case of changing version update-modules
1npm update
- 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
- Run this simple code
1pulumi config set --secret linode:token
2pulumi up
- 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.
- 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 .
- 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}
- In case of changing the version - update-modules
1npm update
- 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 );
- 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.