Write your first Kubernetes charm for an Express app¶
Imagine you have an Express app backed up by a database such as PostgreSQL and need to deploy it. In a traditional setup, this can be quite a challenge, but with Charmcraft you’ll find yourself packaging and deploying your Express app in no time. In this tutorial we will build a Kubernetes charm for an Express app using Charmcraft, so we can have an Express app up and running with Juju. Let’s get started!
This tutorial should take 90 minutes for you to complete.
If you’re new to the charming world, Express apps are specifically supported with a template to quickly generate a rock and a matching template to generate a charm. A rock is a special kind of OCI-compliant container image, while a charm is a software operator for cloud operations that use the Juju orchestration engine. The result is an Express app that can be easily deployed, configured, scaled, and integrated on any Kubernetes cluster.
What you’ll need¶
A local system, e.g., a laptop, with AMD64 or ARM64 architecture which has sufficient resources to launch a virtual machine with 4 CPUs, 4 GB RAM, and a 50 GB disk.
Familiarity with Linux.
What you’ll do¶
Create an Express app.
Use that to create a rock with Rockcraft.
Use that to create a charm with Charmcraft.
Use that to test, deploy, configure, etc., your Express app on a local Kubernetes cloud with Juju.
Repeat the process, mimicking a real development process.
Set things up¶
First, install Multipass.
See also
See more: Multipass | How to install Multipass
Use Multipass to launch an Ubuntu VM with the name charm-dev
from the 24.04 blueprint:
multipass launch --cpus 4 --disk 50G --memory 4G --name charm-dev 24.04
Once the VM is up, open a shell into it:
multipass shell charm-dev
In order to create the rock, you need to install Rockcraft with classic confinement, which grants it access to the whole file system:
sudo snap install rockcraft --channel latest/edge --classic
LXD will be required for building the rock. Make sure it is installed and initialized:
lxd --version
lxd init --auto
If LXD
is not installed, install it with sudo snap install lxd
.
In order to create the charm, you’ll need to install Charmcraft:
sudo snap install charmcraft --channel latest/edge --classic
MicroK8s is required to deploy the Express application on Kubernetes.
Let’s install MicroK8s using the 1.31-strict/stable
track, add the current
user to the group, and activate the changes:
sudo snap install microk8s --channel 1.31-strict/stable
sudo adduser $USER snap_microk8s
newgrp snap_microk8s
Several MicroK8s add-ons are required for deployment:
# Required for Juju to provide storage volumes
sudo microk8s enable hostpath-storage
# Required to host the OCI image of the application
sudo microk8s enable registry
# Required to expose the application
sudo microk8s enable ingress
Check the status of MicroK8s:
sudo microk8s status --wait-ready
If successful, the terminal will output microk8s is running
along with a list of enabled and disabled add-ons.
Juju is required to deploy the Express application.
We’ll install Juju using the 3.6/stable
track. Since the snap is
sandboxed, we’ll also manually create a directory to contain
its files. Once Juju is ready, we initialize it by bootstrapping a
development controller:
sudo snap install juju --channel 3.6/stable
mkdir -p ~/.local/share
juju bootstrap microk8s dev-controller
It could take a few minutes to download the images.
Create a directory for the app:
mkdir expressjs-hello-world
cd expressjs-hello-world
As the expressjs-framework
extensions for Rockcraft and Charmcraft are
still in development, we must enable experimental extensions for each:
export ROCKCRAFT_ENABLE_EXPERIMENTAL_EXTENSIONS=true
export CHARMCRAFT_ENABLE_EXPERIMENTAL_EXTENSIONS=true
Create the Express app¶
Start by creating the “Hello, world” Express app that will be used for this tutorial.
Install npm
and express-generator
to initialize the Express module:
sudo apt update -y && sudo apt install npm -y
sudo npm install -g express-generator@4
express app
cd app
Run the Express app locally¶
Install the necessary packages for the app so it can run:
npm install
With the packages installed, let’s run the Express app to verify that it works:
npm start
Test the Express app by using curl
to send a request to the root
endpoint. You will need a new terminal for this; use
multipass shell charm-dev
to open a new terminal in Multipass:
curl -I localhost:3000
If the output has an HTTP/1.1 200 OK status message, then we know the newly-built app is working.
The Express app looks good, so we can stop it for now from the original terminal using Ctrl + C.
Pack the Express app into a rock¶
Now let’s create a container image for our Express app. We’ll use a rock, which is an OCI-compliant container image based on Ubuntu.
First, we’ll need a rockcraft.yaml
project file. We’ll take advantage of a
pre-defined extension in Rockcraft with the --profile
flag that caters
initial rock files for specific web app frameworks. Using the
expressjs-framework
profile, Rockcraft automates the creation of
rockcraft.yaml
and tailors the file for an Express app.
From the ~/expressjs-hello-world
directory, initialize the rock:
cd ..
rockcraft init --profile expressjs-framework
The rockcraft.yaml
file will automatically be created and set the name
based on your working directory.
Check out the contents of rockcraft.yaml
:
cat rockcraft.yaml
The top of the file should look similar to the following snippet:
name: expressjs-hello-world
# see https://documentation.ubuntu.com/rockcraft/en/latest/explanation/bases/
# for more information about bases and using 'bare' bases for chiselled rocks
base: bare # as an alternative, a ubuntu base can be used
build-base: [email protected] # build-base is required when the base is bare
version: '0.1' # just for humans. Semantic versioning is recommended
summary: A summary of your ExpressJS app # 79 char long summary
description: |
This is expressjs-hello-world's description. You have a paragraph or two to tell the
most important story about it. Keep it under 100 words though,
we live in tweetspace and your description wants to look good in the
container registries out there.
# the platforms this rock should be built on and run on.
# you can check your architecture with `dpkg --print-architecture`
platforms:
amd64:
# arm64:
# ppc64el:
# s390x:
...
Verfiy that the name
is expressjs-hello-world
.
Ensure that platforms
includes the architecture of your host. Check
the architecture of your system:
dpkg --print-architecture
Edit the platforms
key in rockcraft.yaml
if required.
Let’s pack the rock:
rockcraft pack
Depending on your system and network, this step can take several minutes to finish.
Once Rockcraft has finished packing the Express rock,
the terminal will respond with something similar to
Packed expressjs-hello-world_0.1_<architecture>.rock
.
The file name reflects your system’s architecture. After
the initial pack, subsequent rock packings are faster.
The rock needs to be copied to the MicroK8s registry, which stores OCI archives so they can be downloaded and deployed in the Kubernetes cluster. Copy the rock:
rockcraft.skopeo copy \
--insecure-policy \
--dest-tls-verify=false \
oci-archive:expressjs-hello-world_0.1_$(dpkg --print-architecture).rock \
docker://localhost:32000/expressjs-hello-world:0.1
This command contains the following pieces:
--insecure-policy
: adopts a permissive policy that removes the need for a dedicated policy file.--dest-tls-verify=false
: disables the need for HTTPS and verify certificates while interacting with the MicroK8s registry.oci-archive
: specifies the rock we created for our Express app.docker
: specifies the name of the image in the MicroK8s registry.
See also
Create the charm¶
From the ~/expressjs-hello-world
directory, let’s create a new directory
for the charm and change inside it:
mkdir charm
cd charm
Similar to the rock, we’ll take advantage of a pre-defined extension in
Charmcraft with the --profile
flag that caters initial charm files for
specific web app frameworks. Using the expressjs-framework
profile, Charmcraft
automates the creation of the files needed for our charm, including a
charmcraft.yaml
project file, requirements.txt
and source code for the
charm. The source code contains the logic required to operate the Express app.
Initialize a charm named expressjs-hello-world
:
charmcraft init --profile expressjs-framework --name expressjs-hello-world
The files will automatically be created in your working directory.
Check out the contents of charmcraft.yaml
:
cat charmcraft.yaml
The top of the file should look similar to the following snippet:
# This file configures Charmcraft.
# See https://juju.is/docs/sdk/charmcraft-config for guidance.
name: expressjs-hello-world
type: charm
base: [email protected]
# the platforms this charm should be built on and run on.
# you can check your architecture with `dpkg --print-architecture`
platforms:
amd64:
# arm64:
# ppc64el:
# s390x:
# (Required)
summary: A very short one-line summary of the ExpressJS app.
...
Verify that the name
is expressjs-hello-world
. Ensure that platforms
includes the architecture of your host. Edit the platforms
key in the
project file if required.
Let’s pack the charm:
charmcraft pack
Depending on your system and network, this step can take several minutes to finish.
Once Charmcraft has finished packing the charm, the terminal will
respond with something similar to
Packed expressjs-hello-world_ubuntu-24.04-<architecture>.charm
.
The file name reflects your system’s architecture. After the initial
pack, subsequent charm packings are faster.
Deploy the Express app¶
A Juju model is needed to handle Kubernetes resources while deploying the Express app. The Juju model holds the app along with any supporting components. In this tutorial, our model will hold the Express app, ingress, and a PostgreSQL database.
Let’s create a new model:
juju add-model expressjs-hello-world
Constrain the Juju model to your architecture:
juju set-model-constraints -m expressjs-hello-world \
arch=$(dpkg --print-architecture)
Now let’s use the OCI image we previously uploaded to deploy the Express
app. Deploy using Juju by specifying the OCI image name with the
--resource
option:
juju deploy \
./expressjs-hello-world_$(dpkg --print-architecture).charm \
expressjs-hello-world \
--resource app-image=localhost:32000/expressjs-hello-world:0.1
It will take a few minutes to deploy the Express app. You can monitor its progress with:
juju status --watch 2s
It can take a couple of minutes for the app to finish the deployment.
Once the status of the App has gone to active
, you can stop watching
using Ctrl + C.
Tip
To monitor your deployment, keep a juju status
session active in a
second terminal.
See more: Juju | juju status
The Express app should now be running. We can monitor the status of
the deployment using juju status
, which should be similar to the
following output:
~$
juju status
Model Controller Cloud/Region Version SLA Timestamp
expressjs-hello-world dev-controller microk8s/localhost 3.6.5 unsupported 12:24:51+03:00
App Version Status Scale Charm Channel Rev Address Exposed Message
expressjs-hello-world active 1 expressjs-hello-world 0 10.152.183.38 no
Unit Workload Agent Address Ports Message
expressjs-hello-world/0* active idle 10.1.157.75
Let’s expose the app using ingress. Deploy the
nginx-ingress-integrator
charm and integrate it with the Express app:
juju deploy nginx-ingress-integrator --channel=latest/stable --trust
juju integrate nginx-ingress-integrator expressjs-hello-world
The hostname of the app needs to be defined so that it is accessible via the ingress. We will also set the default route to be the root endpoint:
juju config nginx-ingress-integrator \
service-hostname=expressjs-hello-world path-routes=/
Note
By default, the port for the express-framework
extension should be 8080.
If you want to change the default port, it can be done with the configuration
option port
that will be exposed as the PORT
to the Express app.
Monitor juju status
until everything has a status of active
.
Use
curl -I http://expressjs-hello-world --resolve expressjs-hello-world:80:127.0.0.1
to send a request via the ingress. It should show the
HTTP/1.1 200 OK
status message.
Note
The --resolve expressjs-hello-world:80:127.0.0.1
option to the curl
command is a way of resolving the hostname of the request without
setting a DNS record.
The development cycle¶
So far, we have worked though the entire cycle, from creating an app to deploying it. But now – as in every real-world case – we will go through the experience of iterating to develop the app, and deploy each iteration.
Configure the Express app¶
To demonstrate how to provide a configuration to the Express app,
we will make the greeting configurable. We will expect this
configuration option to be available in the Express app configuration under the
keyword GREETING
. Change to the ~/expressjs-hello-world/app/routes
directory and replace the code in index.js
with
the following:
var express = require('express');
var router = express.Router();
let greeting = process.env["APP_GREETING"]
if (!greeting){
greeting = "Hello, world!";
}
/* GET home page. */
router.get('/', function(req, res, next) {
res.send(greeting);
});
module.exports = router;
Update the rock¶
Since we’re changing the app we should update the version of the rock.
Increment the version
in rockcraft.yaml
to 0.2
such that the
top of the rockcraft.yaml
file looks similar to the following:
name: expressjs-hello-world
# see https://documentation.ubuntu.com/rockcraft/en/latest/explanation/bases/
# for more information about bases and using 'bare' bases for chiselled rocks
base: bare # as an alternative, a ubuntu base can be used
build-base: [email protected] # build-base is required when the base is bare
version: '0.2' # just for humans. Semantic versioning is recommended
summary: A summary of your ExpressJS app # 79 char long summary
description: |
This is expressjs-hello-world's description. You have a paragraph or two to tell the
most important story about it. Keep it under 100 words though,
we live in tweetspace and your description wants to look good in the
container registries out there.
# the platforms this rock should be built on and run on.
# you can check your architecture with `dpkg --print-architecture`
platforms:
amd64:
# arm64:
# ppc64el:
# s390x:
...
Let’s pack and upload the rock:
rockcraft pack
rockcraft.skopeo --insecure-policy copy --dest-tls-verify=false \
oci-archive:expressjs-hello-world_0.2_$(dpkg --print-architecture).rock \
docker://localhost:32000/expressjs-hello-world:0.2
Redeploy the charm¶
We’ll redeploy the new version with juju refresh
.
Change back into the charm directory using cd charm
.
The expressjs-framework
Charmcraft extension supports adding configurations
to charmcraft.yaml
, which will be passed as environment variables to
the Express app. Add the following to the end of the
charmcraft.yaml
file:
# configuration snippet for ExpressJS app
config:
options:
greeting:
description: |
The greeting to be returned by the Express application.
default: "Hello, world!"
type: string
Note
Configuration options are automatically capitalized and -
are replaced
by _
. An APP_
prefix will also be added as a namespace
for app configurations.
We can now pack and deploy the new version of the Express app:
charmcraft pack
juju refresh expressjs-hello-world \
--path=./expressjs-hello-world_$(dpkg --print-architecture).charm \
--resource app-image=localhost:32000/expressjs-hello-world:0.2
After we wait for a bit monitoring juju status
the app
should go back to active
again. Verify that the new configuration
has been added using
juju config expressjs-hello-world | grep -A 6 greeting:
,
which should show the configuration option.
Using curl http://expressjs-hello-world --resolve
expressjs-hello-world:80:127.0.0.1
shows that the response is Hello, world!
as expected.
Now let’s change the greeting:
juju config expressjs-hello-world greeting='Hi!'
After we wait for a moment for the app to be restarted, using
curl http://expressjs-hello-world --resolve expressjs-hello-world:80:127.0.0.1
should now return the updated Hi!
greeting.
Integrate with a database¶
Now let’s keep track of how many visitors your app has received. This will require integration with a database to keep the visitor count. This will require a few changes:
We will need to create a database migration that creates the
visitors
table.We will need to keep track how many times the root endpoint has been called in the database.
We will need to add a new endpoint to retrieve the number of visitors from the database.
Let’s start with the database migration to create the required tables.
The charm created by the expressjs-framework
extension will execute the
migrate.sh
script if it exists. This script should ensure that the
database is initialized and ready to be used by the app. We will
create a migrate.sh
file containing this logic.
Back out to the ~/expressjs-hello-world/app
directory.
Create the migrate.sh
file using a text editor and paste the
following code into it:
#!/bin/bash
PGPASSWORD="${POSTGRESQL_DB_PASSWORD}" psql -h "${POSTGRESQL_DB_HOSTNAME}" -U "${POSTGRESQL_DB_USERNAME}" "${POSTGRESQL_DB_NAME}" -c "CREATE TABLE IF NOT EXISTS visitors (timestamp TIMESTAMP NOT NULL, user_agent TEXT NOT NULL);"
Note
The charm will pass the Database connection string in the
POSTGRESQL_DB_CONNECT_STRING
environment variable once
PostgreSQL has been integrated with the charm.
Change the permissions of the file migrate.sh
so that it is executable:
chmod u+x app/migrate.sh
To connect the Express app to PostgreSQL, we will use
the pg-promise
library. The app code needs to be updated to keep track of
the number of visitors and to include a new endpoint to retrieve the
number of visitors. Create a new file called
visitors.js
in the ~/expressjs-hello-world/app/routes
directory
and paste the following code into it:
var express = require("express");
var router = express.Router();
const pgp = require("pg-promise")(/* options */);
const PG_CONNECT_STR = process.env["POSTGRESQL_DB_CONNECT_STRING"];
console.log("PG_CONNECT_STR", PG_CONNECT_STR);
let db = null;
if (PG_CONNECT_STR) {
db = pgp(PG_CONNECT_STR);
}
/* GET visitors count. */
router.get("/", async function (req, res, next) {
console.log("visitors request");
if (!db) {
console.error("Database connection is not initialized");
return res.status(500).send("Database connection error");
}
try {
const result = await db.one("SELECT count(*) FROM visitors");
const numVisitors = result.count;
res.send(`Number of visitors: ${numVisitors}\n`);
} catch (error) {
console.error("An error occurred while executing query:", error);
res.status(500).send("Error retrieving visitors count");
}
});
module.exports = router;
Open index.js
in a text editor and
replace the code in it with the following to record the number of
visitors:
var express = require('express');
var router = express.Router();
const pgp = require("pg-promise")(/* options */);
const PG_CONNECT_STR = process.env["POSTGRESQL_DB_CONNECT_STRING"];
/* GET home page. */
router.get('/', async function(req, res, next) {
console.log("new hello world request");
if (!PG_CONNECT_STR) {
console.error("Database connection string is not set");
return res.status(500).send("Database connection error");
}
const db = pgp(PG_CONNECT_STR);
try {
const userAgent = req.get("User-Agent");
const timestamp = new Date();
await db.none(
"INSERT INTO visitors (timestamp, user_agent) VALUES ($1, $2)",
[timestamp, userAgent]
);
const greeting = process.env["APP_GREETING"] || "Hello, world!";
res.send(greeting + "\n");
} catch (error) {
console.error("An error occurred:", error);
res.status(500).send("An error occurred while processing your request");
} finally {
db.$pool.end(); // Close the database connection pool
}
});
module.exports = router;
Change back to the ~/expressjs-hello-world/app
directory,
open app.js
in a text editor and replace its content with the following
code to add the new route:
var createError = require('http-errors');
var express = require('express');
var path = require('path');
var cookieParser = require('cookie-parser');
var logger = require('morgan');
var indexRouter = require('./routes/index');
var usersRouter = require('./routes/users');
var visitorsRouter = require('./routes/visitors');
var app = express();
// view engine setup
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'jade');
app.use(logger('dev'));
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(cookieParser());
app.use(express.static(path.join(__dirname, 'public')));
app.use('/', indexRouter);
app.use('/users', usersRouter);
app.use('/visitors', visitorsRouter);
// catch 404 and forward to error handler
app.use(function(req, res, next) {
next(createError(404));
});
// error handler
app.use(function(err, req, res, next) {
// set locals, only providing error in development
res.locals.message = err.message;
res.locals.error = req.app.get('env') === 'development' ? err : {};
// render the error page
res.status(err.status || 500);
res.render('error');
});
module.exports = app;
Add the new package in the Express project:
npm install pg-promise
Update the rock again¶
For the migrations to work, we need the postgresql-client
package
installed in the rock. By default, the expressjs-framework
uses the bare
base, so we will also need to install a shell interpreter. Let’s do it as a
slice, so that the rock doesn’t include unnecessary files. Open the
rockcraft.yaml
file using a text editor and add the following to the
end of the file:
parts:
runtime-debs:
plugin: nil
stage-packages:
# Added manually for the migrations
- postgresql-client
runtime-slices:
plugin: nil
stage-packages:
# Added manually for the migrations
- bash_bins
Increment the version
in rockcraft.yaml
to 0.3
such that the
top of the rockcraft.yaml
file looks similar to the following:
name: expressjs-hello-world
# see https://documentation.ubuntu.com/rockcraft/en/latest/explanation/bases/
# for more information about bases and using 'bare' bases for chiselled rocks
base: bare # as an alternative, a ubuntu base can be used
build-base: [email protected] # build-base is required when the base is bare
version: '0.3' # just for humans. Semantic versioning is recommended
summary: A summary of your ExpressJS app # 79 char long summary
description: |
This is expressjs-hello-world's description. You have a paragraph or two to tell the
most important story about it. Keep it under 100 words though,
we live in tweetspace and your description wants to look good in the
container registries out there.
# the platforms this rock should be built on and run on.
# you can check your architecture with `dpkg --print-architecture`
platforms:
amd64:
# arm64:
# ppc64el:
# s390x:
...
Let’s pack and upload the rock:
rockcraft pack
rockcraft.skopeo --insecure-policy copy --dest-tls-verify=false \
oci-archive:expressjs-hello-world_0.3_$(dpkg --print-architecture).rock \
docker://localhost:32000/expressjs-hello-world:0.3
Update the charm again¶
Change back into the charm directory using cd charm
.
The Express app now requires a database which needs to be declared in the
charmcraft.yaml
file. Open charmcraft.yaml
in a text editor and
add the following section to the end of the file:
# requires snippet for Express app with a database
requires:
postgresql:
interface: postgresql_client
optional: false
We can now pack and deploy the new version of the Express app:
charmcraft pack
juju refresh expressjs-hello-world \
--path=./expressjs-hello-world_$(dpkg --print-architecture).charm \
--resource app-image=localhost:32000/expressjs-hello-world:0.3
Now let’s deploy PostgreSQL and integrate it with the Express app:
juju deploy postgresql-k8s --trust
juju integrate expressjs-hello-world postgresql-k8s
Wait for juju status
to show that the App is active
again.
During this time, the Express app may enter a blocked
state as it
waits to become integrated with the PostgreSQL database. Due to the
optional: false
key in the endpoint definition, the Express app will not
start until the database is ready.
Once the Express app is active
, running curl http://expressjs-hello-world
--resolve expressjs-hello-world:80:127.0.0.1
should still return the Hi!
greeting.
To check the local visitors, use
curl http://expressjs-hello-world/visitors --resolve expressjs-hello-world:80:127.0.0.1
,
which should return Number of visitors 1
after the
previous request to the root endpoint.
This should be incremented each time the root endpoint is requested. If we
repeat this process, the output should be as follows:
~$
curl http://expressjs-hello-world --resolve expressjs-hello-world:80:127.0.0.1
Hi!
~$
curl http://expressjs-hello-world/visitors --resolve expressjs-hello-world:80:127.0.0.1
Number of visitors 2
Tear things down¶
We’ve reached the end of this tutorial. We went through the entire development process, including:
Creating a Express app
Deploying the app locally
Packaging the app using Rockcraft
Building the app with Ops code using Charmcraft
Deplyoing the app using Juju
Exposing the app using an ingress
Configuring the app
Integrating the app with a database
If you’d like to quickly tear things down, start by exiting the Multipass VM:
exit
And then you can proceed with its deletion:
multipass delete charm-dev
multipass purge
If you’d like to manually reset your working environment, you can run the
following in the rock directory ~/expressjs-hello-world
for the tutorial:
charmcraft clean
# Back out to main directory for cleanup
cd ..
rockcraft clean
# exit and delete the charm dir
rm -rf charm
# delete app directory
rm -rf app
# delete all the files created during the tutorial
rm expressjs-hello-world_0.1_$(dpkg --print-architecture).rock \
expressjs-hello-world_0.2_$(dpkg --print-architecture).rock \
expressjs-hello-world_0.3_$(dpkg --print-architecture).rock \
rockcraft.yaml
# Remove the juju model
juju destroy-model expressjs-hello-world --destroy-storage --no-prompt --force
You can also clean up your Multipass instance by exiting and deleting it using the same commands as above.
Next steps¶
By the end of this tutorial you will have built a charm and evolved it in a number of typical ways. But there is a lot more to explore:
If you are wondering… |
Visit… |
---|---|
“How do I…?” |
|
“How do I debug?” |
|
“How do I get in touch?” |
|
“What is…?” |
|
“Why…?”, “So what?” |