From 0dab8798c6b716414ec4c03cfea7fa3d630d8e9f Mon Sep 17 00:00:00 2001 From: Scot Wells Date: Wed, 3 Jun 2026 10:48:03 -0400 Subject: [PATCH 1/2] docs: add serverless Node.js example mounting a function via ConfigMap Adds a runnable example demonstrating a serverless pattern on Datum compute: a generic Node.js runtime image with no application code baked in, where the function is supplied at deploy time via a ConfigMap mounted at /app. One image serves any function, and swapping the code is a ConfigMap edit + restart with no image rebuild. Validated end to end on the ash-sore-hamster metro: the ConfigMap mounts as a read-only rom at /app, Node executes the mounted file, and editing only the ConfigMap swaps the running response (v1 -> v2) on the same image. Relies on the referenced ConfigMap/Secret delivery from #129 (base-compat:latest runtime + erofs rootfs). Co-Authored-By: Claude Opus 4.8 (1M context) --- examples/serverless-js-configmap/.gitignore | 2 + examples/serverless-js-configmap/Dockerfile | 28 ++++++ examples/serverless-js-configmap/Kraftfile | 27 ++++++ examples/serverless-js-configmap/README.md | 92 +++++++++++++++++++ .../serverless-js-configmap/configmap.yaml | 36 ++++++++ examples/serverless-js-configmap/index.js | 28 ++++++ .../serverless-js-configmap/workload.yaml | 60 ++++++++++++ 7 files changed, 273 insertions(+) create mode 100644 examples/serverless-js-configmap/.gitignore create mode 100644 examples/serverless-js-configmap/Dockerfile create mode 100644 examples/serverless-js-configmap/Kraftfile create mode 100644 examples/serverless-js-configmap/README.md create mode 100644 examples/serverless-js-configmap/configmap.yaml create mode 100644 examples/serverless-js-configmap/index.js create mode 100644 examples/serverless-js-configmap/workload.yaml diff --git a/examples/serverless-js-configmap/.gitignore b/examples/serverless-js-configmap/.gitignore new file mode 100644 index 00000000..9c7b8803 --- /dev/null +++ b/examples/serverless-js-configmap/.gitignore @@ -0,0 +1,2 @@ +# Build artifacts produced locally; not committed. +.unikraft/ diff --git a/examples/serverless-js-configmap/Dockerfile b/examples/serverless-js-configmap/Dockerfile new file mode 100644 index 00000000..ea29502e --- /dev/null +++ b/examples/serverless-js-configmap/Dockerfile @@ -0,0 +1,28 @@ +# Multi-stage build for the GENERIC serverless Node.js runtime image. +# +# This is the key difference from examples/hello-node: NO application/JS code is +# baked in. The final scratch stage carries ONLY the Node runtime -- the `node` +# interpreter plus every shared library it links against. The function itself +# (index.js) is supplied at deploy time via a ConfigMap mounted at /app, so one +# image serves any number of functions and code swaps need no rebuild. +# +# Node is NOT a static-PIE binary -- it is a dynamic musl ELF that needs the +# musl loader and a handful of shared libraries at boot. So this rootfs ships +# the node interpreter AND every .so it links, and the unikernel runs on the +# base-compat:latest runtime (the binary-compatibility / dynamic-loader +# elfloader variant), NOT base:latest. A scratch image missing those libs won't +# boot (library-not-found). +FROM node:22-alpine AS build +# Record node's dynamic-library requirements in the build log for auditing. +RUN echo "=== ldd /usr/local/bin/node ===" && ldd /usr/local/bin/node || true + +FROM scratch +# The node interpreter -- the entire "function runtime". No app code here. +COPY --from=build /usr/local/bin/node /usr/bin/node +# musl dynamic loader + libc (ld-musl is both loader and libc on alpine). +COPY --from=build /lib/ld-musl-x86_64.so.1 /lib/ld-musl-x86_64.so.1 +# C++/GCC runtime libraries node links against. +COPY --from=build /usr/lib/libgcc_s.so.1 /usr/lib/libgcc_s.so.1 +COPY --from=build /usr/lib/libstdc++.so.6 /usr/lib/libstdc++.so.6 +# os-release lets node/libc identify the platform cleanly. +COPY --from=build /etc/os-release /etc/os-release diff --git a/examples/serverless-js-configmap/Kraftfile b/examples/serverless-js-configmap/Kraftfile new file mode 100644 index 00000000..2a55dd16 --- /dev/null +++ b/examples/serverless-js-configmap/Kraftfile @@ -0,0 +1,27 @@ +# Kraftfile for the generic serverless Node.js runtime image. +# +# spec v0.7 + rootfs.format: erofs is what makes Datum ConfigMap/Secret file +# mounts materialize into the unikraft microVM (validated 2026-06-03 on the +# ash-sore-hamster metro). base-compat:latest is required because node is a +# dynamic musl ELF (see Dockerfile). +# +# The cmd points node at /app/index.js -- a MOUNTED path, not a baked one. The +# image contains no index.js; the Workload mounts the `js-function` ConfigMap at +# /app, so /app/index.js is the function supplied at deploy time. +# +# Build/push (push-only, do not start): +# kraft cloud --metro /v1 --token \ +# --buildkit-host docker-container://buildkit \ +# deploy --no-start -M 512 --name serverless-js-runtime \ +# --runtime base-compat:latest --rootfs ./Dockerfile . +spec: v0.7 + +name: serverless-js-runtime + +runtime: base-compat:latest + +rootfs: + source: ./Dockerfile + format: erofs + +cmd: ["/usr/bin/node", "/app/index.js"] diff --git a/examples/serverless-js-configmap/README.md b/examples/serverless-js-configmap/README.md new file mode 100644 index 00000000..296a4c08 --- /dev/null +++ b/examples/serverless-js-configmap/README.md @@ -0,0 +1,92 @@ +# serverless-js-configmap + +A serverless pattern on Datum compute: a **generic Node.js runtime image with no +application code baked in**, where the function (`index.js`) is supplied at +deploy time via a **ConfigMap mounted at `/app`**, and Node executes the mounted +file. Swapping the function is a ConfigMap edit + restart — **no image rebuild**. + +This works because Datum ConfigMap/Secret file mounts materialize into the +unikraft microVM as read-only `rom` devices when the image is packaged with +`spec: v0.7`, `runtime: base-compat:latest`, and `rootfs.format: erofs`. + +## Files + +| File | Purpose | +|------|---------| +| `Dockerfile` | Multi-stage build of the **generic** runtime: a `scratch` image carrying only the `node` interpreter + its musl/libgcc/libstdc++ `.so` files. No app code. | +| `Kraftfile` | `spec: v0.7`, `base-compat:latest`, `rootfs.format: erofs`, `cmd: ["/usr/bin/node", "/app/index.js"]` (a **mounted** path). | +| `index.js` | The function: a dependency-free Node stdlib HTTP server on `$PORT` (default 8080). Returns a versioned message; logs a versioned startup line. This is the ConfigMap payload, **not** baked into the image. | +| `configmap.yaml` | ConfigMap `js-function` with key `index.js` (the `v1` function). | +| `workload.yaml` | A `compute.datumapis.com/v1alpha` Workload mounting `js-function` at `/app`, port 8080, `d1-standard-2`, DFW, `minReplicas: 1`. Env intentionally empty (see Pitfalls). | + +## 1. Build + push the generic runtime image (push-only) + +```sh +export KRAFTKIT_NO_CHECK_UPDATES=true +TOKEN=... # ash-sore-hamster metro token (ukc-credentials secret) + +kraft cloud --metro https://api.ash-sore-hamster.unikraft.cloud/v1 --token "$TOKEN" \ + --buildkit-host docker-container://buildkit \ + deploy --no-start -M 512 --name serverless-js-runtime \ + --runtime base-compat:latest --rootfs ./Dockerfile . +``` + +Packages as an EroFS archive and pushes to +`index.unikraft.io/datum/serverless-js-runtime:latest`. This image is built +**once** and reused for every function version. + +## 2. Apply the ConfigMap on the PROJECT control plane + +The ReferencedData resolver that turns ConfigMap data into the unikernel mount +runs project-side. Applying on the cell is a dead end. + +```sh +kubectl --context datum-project-datum-cloud -n default apply -f configmap.yaml +``` + +## 3. Deploy the Workload + +```sh +datumctl compute deploy -f workload.yaml -y +``` + +## 4. Validate + +```sh +# External FQDN (also wakes the instance from scale-to-zero standby): +curl https:/// # -> Hello from a ConfigMap-mounted function — v1 +curl https:///healthz # -> ok + +# UKC instance: confirm the JS mount is a rom at /app and the VM booted: +kraft cloud --metro /v1 --token "$TOKEN" instance get -o json # roms[].at == /app, boot_time_us > 0 +kraft cloud --metro ash-sore-hamster --token "$TOKEN" instance logs # "mount /dev/ukp_romN -> /app" + "serverless-js: vN listening on :8080" +``` + +Derive `` from the cell Pod annotation +`cloud.unikraft.v1.instances/fqdns` → first DNS label of `privateFqdn`. + +## 5. Swap the function (the headline) + +Edit `index.js` inside `configmap.yaml` (bump `VERSION` to `v2` and the +message), re-apply on the project plane, then recreate the instance so the new +read-only `rom` is baked at boot: + +```sh +kubectl --context datum-project-datum-cloud -n default apply -f configmap.yaml +datumctl compute restart serverless-js-fn # rolling restart -> recreate instance +curl https:/// # -> ... — v2 (same image, no rebuild) +``` + +Mounts are read-only roms baked at boot, so a content change only takes effect +once the instance is **recreated** — restarting the workload is the realistic +"redeploy the function" path. The runtime image is never rebuilt or repushed. + +## Pitfalls + +- **`base-compat:latest`, not `base:latest`** — `node` is a dynamic musl ELF and + needs the dynamic-loader runtime plus the copied `.so` files. A `scratch` + image missing those libs won't boot. +- **Keep `env` empty.** In a busy namespace, kraftlet-injected k8s service-link + env can overflow the UKC kernel-cmdline buffer (`InvalidKernelCommandLine`, + boot `0.00 ms` / standby). The mount needs no env; `PORT` defaults to 8080. +- **The mount is read-only.** The function must not write into `/app`. diff --git a/examples/serverless-js-configmap/configmap.yaml b/examples/serverless-js-configmap/configmap.yaml new file mode 100644 index 00000000..d2714cc2 --- /dev/null +++ b/examples/serverless-js-configmap/configmap.yaml @@ -0,0 +1,36 @@ +# The serverless function payload, delivered as a ConfigMap. The `index.js` key +# becomes the file /app/index.js inside the microVM (a ConfigMap volume mounted +# at /app is a directory; each data key becomes a file under it). +# +# Apply this on the PROJECT control plane -- the ReferencedData resolver that +# turns ConfigMap data into the unikernel mount runs project-side: +# kubectl --context datum-project-datum-cloud -n default apply -f configmap.yaml +# +# To swap the function: edit the VERSION string below (v1 -> v2), re-apply, then +# `datumctl compute restart `. Mounts are read-only roms baked at +# boot, so the instance must be recreated to pick up new content -- restart is +# the realistic "redeploy the function" path. No image rebuild required. +apiVersion: v1 +kind: ConfigMap +metadata: + name: js-function +data: + index.js: | + const http = require('http'); + + const VERSION = 'v1'; + const port = parseInt(process.env.PORT, 10) || 8080; + + const server = http.createServer((req, res) => { + if (req.url === '/healthz') { + res.writeHead(200, { 'Content-Type': 'text/plain' }); + res.end('ok\n'); + return; + } + res.writeHead(200, { 'Content-Type': 'text/plain' }); + res.end('Hello from a ConfigMap-mounted function — ' + VERSION + '\n'); + }); + + server.listen(port, () => { + console.log('serverless-js: ' + VERSION + ' listening on :' + port); + }); diff --git a/examples/serverless-js-configmap/index.js b/examples/serverless-js-configmap/index.js new file mode 100644 index 00000000..f14e6645 --- /dev/null +++ b/examples/serverless-js-configmap/index.js @@ -0,0 +1,28 @@ +// The serverless "function" -- supplied at deploy time via a ConfigMap, NOT +// baked into the runtime image. Datum mounts the ConfigMap at /app, so this +// file lands at /app/index.js and the Kraftfile's cmd points node at it. +// +// Dependency-free (Node stdlib only): the generic runtime image ships no +// node_modules, so the function must not `require` anything outside core. +// +// The VERSION string is the swap marker: change it in configmap.yaml, re-apply, +// restart the workload, and the HTTP response proves the new code took effect +// with no image rebuild -- the core serverless value prop. +const http = require('http'); + +const VERSION = 'v1'; +const port = parseInt(process.env.PORT, 10) || 8080; + +const server = http.createServer((req, res) => { + if (req.url === '/healthz') { + res.writeHead(200, { 'Content-Type': 'text/plain' }); + res.end('ok\n'); + return; + } + res.writeHead(200, { 'Content-Type': 'text/plain' }); + res.end('Hello from a ConfigMap-mounted function — ' + VERSION + '\n'); +}); + +server.listen(port, () => { + console.log('serverless-js: ' + VERSION + ' listening on :' + port); +}); diff --git a/examples/serverless-js-configmap/workload.yaml b/examples/serverless-js-configmap/workload.yaml new file mode 100644 index 00000000..3f01a08d --- /dev/null +++ b/examples/serverless-js-configmap/workload.yaml @@ -0,0 +1,60 @@ +# Datum Cloud Workload: serverless Node.js function via ConfigMap mount. +# +# The image is the GENERIC runtime (node + libs, no code). The function arrives +# by mounting the `js-function` ConfigMap at /app, so /app/index.js is the file +# the Kraftfile's cmd runs. Swap the function by editing the ConfigMap and +# restarting -- no rebuild of this image. +# +# Field shapes (validated against examples/config-secret-probe on this branch): +# - spec.template.spec.volumes[]: {name, configMap:{name}} -> mounted as a dir +# - container.volumeAttachments[]: {name, mountPath} -> k8s VolumeMount +# - instanceType lives at runtime.resources (NOT container resources; the +# validating webhook rejects container-level resources on this branch). +# +# Apply the ConfigMap on the PROJECT plane FIRST (see configmap.yaml), then: +# datumctl compute deploy -f workload.yaml -y +# +# NOTE: env is intentionally EMPTY. In a busy namespace, kraftlet-injected k8s +# service-link env can overflow the UKC kernel-cmdline buffer +# (InvalidKernelCommandLine, boot 0.00 ms / standby). The mount needs no env and +# PORT defaults to 8080 in index.js, so we keep env empty to stay under budget. +apiVersion: compute.datumapis.com/v1alpha +kind: Workload +metadata: + name: serverless-js-fn + labels: + app: serverless-js-fn +spec: + template: + metadata: + labels: + app: serverless-js-fn + spec: + volumes: + - name: fn-vol + configMap: + name: js-function + runtime: + resources: + instanceType: datumcloud/d1-standard-2 + sandbox: + containers: + - name: app + image: index.unikraft.io/datum/serverless-js-runtime:latest + volumeAttachments: + - name: fn-vol + mountPath: /app + ports: + - name: http + port: 8080 + protocol: TCP + networkInterfaces: + - network: + name: default + placements: + - name: default + cityCodes: + - DFW + scaleSettings: + minReplicas: 1 + instanceManagementPolicy: OrderedReady From c2db19eff4ecc8e80c9e2adc257b325acc9aa819 Mon Sep 17 00:00:00 2001 From: Scot Wells Date: Wed, 3 Jun 2026 10:55:37 -0400 Subject: [PATCH 2/2] docs: generate the function ConfigMap from index.js via Kustomize Replace the hand-written configmap.yaml with a Kustomize configMapGenerator so index.js is the single source of truth -- no second embedded copy of the function to drift. disableNameSuffixHash keeps the generated ConfigMap named js-function, which the Workload references by name and deploys out-of-band via datumctl (so a content-hash suffix would break the mount and buys nothing here). Apply and swap now use `kubectl apply -k .`; README and the index.js header updated accordingly. Co-Authored-By: Claude Opus 4.8 (1M context) --- examples/serverless-js-configmap/README.md | 25 +++++++------ .../serverless-js-configmap/configmap.yaml | 36 ------------------- examples/serverless-js-configmap/index.js | 7 ++-- .../kustomization.yaml | 26 ++++++++++++++ 4 files changed, 45 insertions(+), 49 deletions(-) delete mode 100644 examples/serverless-js-configmap/configmap.yaml create mode 100644 examples/serverless-js-configmap/kustomization.yaml diff --git a/examples/serverless-js-configmap/README.md b/examples/serverless-js-configmap/README.md index 296a4c08..92ea4da2 100644 --- a/examples/serverless-js-configmap/README.md +++ b/examples/serverless-js-configmap/README.md @@ -15,8 +15,8 @@ unikraft microVM as read-only `rom` devices when the image is packaged with |------|---------| | `Dockerfile` | Multi-stage build of the **generic** runtime: a `scratch` image carrying only the `node` interpreter + its musl/libgcc/libstdc++ `.so` files. No app code. | | `Kraftfile` | `spec: v0.7`, `base-compat:latest`, `rootfs.format: erofs`, `cmd: ["/usr/bin/node", "/app/index.js"]` (a **mounted** path). | -| `index.js` | The function: a dependency-free Node stdlib HTTP server on `$PORT` (default 8080). Returns a versioned message; logs a versioned startup line. This is the ConfigMap payload, **not** baked into the image. | -| `configmap.yaml` | ConfigMap `js-function` with key `index.js` (the `v1` function). | +| `index.js` | The function and the **single source of truth** for the ConfigMap: a dependency-free Node stdlib HTTP server on `$PORT` (default 8080). Returns a versioned message; logs a versioned startup line. Mounted into the image, **not** baked in. | +| `kustomization.yaml` | A Kustomize `configMapGenerator` that generates the `js-function` ConfigMap from `index.js` (so there is no hand-maintained copy of the function to drift). `disableNameSuffixHash: true` keeps the name stable for the Workload reference. | | `workload.yaml` | A `compute.datumapis.com/v1alpha` Workload mounting `js-function` at `/app`, port 8080, `d1-standard-2`, DFW, `minReplicas: 1`. Env intentionally empty (see Pitfalls). | ## 1. Build + push the generic runtime image (push-only) @@ -35,15 +35,20 @@ Packages as an EroFS archive and pushes to `index.unikraft.io/datum/serverless-js-runtime:latest`. This image is built **once** and reused for every function version. -## 2. Apply the ConfigMap on the PROJECT control plane +## 2. Generate + apply the ConfigMap on the PROJECT control plane -The ReferencedData resolver that turns ConfigMap data into the unikernel mount -runs project-side. Applying on the cell is a dead end. +`kubectl apply -k` runs the `configMapGenerator`, building the `js-function` +ConfigMap from `index.js` and applying it. The ReferencedData resolver that +turns ConfigMap data into the unikernel mount runs project-side, so apply it on +the project plane — applying on the cell is a dead end. ```sh -kubectl --context datum-project-datum-cloud -n default apply -f configmap.yaml +kubectl --context datum-project-datum-cloud -n default apply -k . ``` +(`kubectl kustomize .` renders the generated ConfigMap to stdout if you want to +inspect it first.) + ## 3. Deploy the Workload ```sh @@ -67,12 +72,12 @@ Derive `` from the cell Pod annotation ## 5. Swap the function (the headline) -Edit `index.js` inside `configmap.yaml` (bump `VERSION` to `v2` and the -message), re-apply on the project plane, then recreate the instance so the new -read-only `rom` is baked at boot: +Edit `index.js` directly (bump `VERSION` to `v2` and the message) — it is the +only copy. Re-apply with `-k` to regenerate the ConfigMap, then recreate the +instance so the new read-only `rom` is baked at boot: ```sh -kubectl --context datum-project-datum-cloud -n default apply -f configmap.yaml +kubectl --context datum-project-datum-cloud -n default apply -k . datumctl compute restart serverless-js-fn # rolling restart -> recreate instance curl https:/// # -> ... — v2 (same image, no rebuild) ``` diff --git a/examples/serverless-js-configmap/configmap.yaml b/examples/serverless-js-configmap/configmap.yaml deleted file mode 100644 index d2714cc2..00000000 --- a/examples/serverless-js-configmap/configmap.yaml +++ /dev/null @@ -1,36 +0,0 @@ -# The serverless function payload, delivered as a ConfigMap. The `index.js` key -# becomes the file /app/index.js inside the microVM (a ConfigMap volume mounted -# at /app is a directory; each data key becomes a file under it). -# -# Apply this on the PROJECT control plane -- the ReferencedData resolver that -# turns ConfigMap data into the unikernel mount runs project-side: -# kubectl --context datum-project-datum-cloud -n default apply -f configmap.yaml -# -# To swap the function: edit the VERSION string below (v1 -> v2), re-apply, then -# `datumctl compute restart `. Mounts are read-only roms baked at -# boot, so the instance must be recreated to pick up new content -- restart is -# the realistic "redeploy the function" path. No image rebuild required. -apiVersion: v1 -kind: ConfigMap -metadata: - name: js-function -data: - index.js: | - const http = require('http'); - - const VERSION = 'v1'; - const port = parseInt(process.env.PORT, 10) || 8080; - - const server = http.createServer((req, res) => { - if (req.url === '/healthz') { - res.writeHead(200, { 'Content-Type': 'text/plain' }); - res.end('ok\n'); - return; - } - res.writeHead(200, { 'Content-Type': 'text/plain' }); - res.end('Hello from a ConfigMap-mounted function — ' + VERSION + '\n'); - }); - - server.listen(port, () => { - console.log('serverless-js: ' + VERSION + ' listening on :' + port); - }); diff --git a/examples/serverless-js-configmap/index.js b/examples/serverless-js-configmap/index.js index f14e6645..6e462d89 100644 --- a/examples/serverless-js-configmap/index.js +++ b/examples/serverless-js-configmap/index.js @@ -5,9 +5,10 @@ // Dependency-free (Node stdlib only): the generic runtime image ships no // node_modules, so the function must not `require` anything outside core. // -// The VERSION string is the swap marker: change it in configmap.yaml, re-apply, -// restart the workload, and the HTTP response proves the new code took effect -// with no image rebuild -- the core serverless value prop. +// The VERSION string is the swap marker: change it here, re-apply with +// `kubectl apply -k .` (Kustomize regenerates the js-function ConfigMap from +// this file), restart the workload, and the HTTP response proves the new code +// took effect with no image rebuild -- the core serverless value prop. const http = require('http'); const VERSION = 'v1'; diff --git a/examples/serverless-js-configmap/kustomization.yaml b/examples/serverless-js-configmap/kustomization.yaml new file mode 100644 index 00000000..cc775990 --- /dev/null +++ b/examples/serverless-js-configmap/kustomization.yaml @@ -0,0 +1,26 @@ +# Generates the `js-function` ConfigMap from index.js, so index.js is the single +# source of truth -- there is no second, hand-maintained copy of the function to +# drift. The `index.js` file becomes the ConfigMap key `index.js`, which Datum +# mounts to /app/index.js inside the microVM. +# +# Apply on the PROJECT control plane (the ReferencedData resolver runs there): +# kubectl --context datum-project-datum-cloud -n default apply -k . +# +# To swap the function: edit index.js and re-apply with `-k .` -- Kustomize +# regenerates the ConfigMap from the new file. Then `datumctl compute restart +# serverless-js-fn` to recreate the instance and pick up the new read-only rom. +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +configMapGenerator: + - name: js-function + files: + - index.js + +generatorOptions: + # Keep a stable name: the Workload references `js-function` by name and is + # deployed out-of-band via `datumctl compute deploy`, not through Kustomize, so + # Kustomize cannot rewrite the reference to a hashed name. A content-hash suffix + # would therefore break the mount and buys us nothing here (the instance is + # recreated by `datumctl compute restart`, not by a ConfigMap name change). + disableNameSuffixHash: true