Skip to content

Commit 0a498d1

Browse files
committed
docs: add Ruby deploy guide and runnable example for Datum compute
1 parent 5852ed0 commit 0a498d1

7 files changed

Lines changed: 626 additions & 0 deletions

File tree

docs/guides/deploy-a-ruby-app.md

Lines changed: 367 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,367 @@
1+
# Deploy a Ruby Web Service on Datum Compute
2+
3+
> Last verified: 2026-06-02 against the `hello-ruby` example and the live `kraft` / `datumctl compute` CLIs.
4+
> The complete, ready-to-deploy example for this guide lives in [`examples/hello-ruby/`](../../examples/hello-ruby/).
5+
6+
This guide walks you through taking a Ruby HTTP service from source code to a live, reachable instance on Datum compute. By the end you will have:
7+
8+
- A Ruby application packaged as a Unikraft unikernel image
9+
- The image published to the Unikraft Cloud metro registry
10+
- A running workload deployed with `datumctl compute deploy`
11+
- A verified HTTP response from your instance
12+
13+
**What you need before starting:**
14+
15+
- `kraft` (KraftKit) installed and authenticated to your Unikraft Cloud metro. The metro URL and token are supplied to `kraft cloud` commands; this guide assumes they are available as `$UKC_METRO` and `$UKC_TOKEN` in your shell.
16+
- `datumctl` installed with the compute plugin, authenticated to your Datum Cloud project.
17+
- Docker (with BuildKit) running locally.
18+
- Ruby (for local development only — the build happens inside Docker).
19+
20+
---
21+
22+
## 1. Write the application
23+
24+
Create a project directory and add one file.
25+
26+
**`server.rb`**
27+
28+
```ruby
29+
require "socket"
30+
31+
port = (ENV["PORT"] || "8080").to_i
32+
server = TCPServer.new("0.0.0.0", port)
33+
34+
$stdout.puts "listening on :#{port}"
35+
$stdout.flush
36+
37+
def respond(client, body)
38+
client.write "HTTP/1.1 200 OK\r\n"
39+
client.write "Content-Type: text/plain; charset=utf-8\r\n"
40+
client.write "Content-Length: #{body.bytesize}\r\n"
41+
client.write "Connection: close\r\n"
42+
client.write "\r\n"
43+
client.write body
44+
end
45+
46+
loop do
47+
client = server.accept
48+
begin
49+
request_line = client.gets
50+
while (line = client.gets) && line != "\r\n"
51+
end
52+
53+
path = request_line ? request_line.split(" ")[1].to_s : "/"
54+
if path == "/healthz"
55+
respond(client, "ok\n")
56+
else
57+
respond(client, "Hello from Datum (Ruby)\n")
58+
end
59+
rescue StandardError
60+
ensure
61+
client.close
62+
end
63+
end
64+
```
65+
66+
The service listens on `$PORT` (default `8080`), answers `/healthz` with `ok`, and uses only the standard library — no gems. (It uses `socket`/`TCPServer` directly; `webrick` is no longer a default gem in Ruby 3.x.)
67+
68+
---
69+
70+
## 2. Build and publish the unikernel image with `kraft`
71+
72+
### Why Ruby ships the interpreter and its library closure
73+
74+
Datum's Unikraft runtime uses an app-elfloader that loads your application as the unikernel entrypoint. Compiled languages (Go, Rust) ship a single fully static binary. Ruby is different: the MRI interpreter is a **dynamically linked** executable — it needs its loader (`/lib64/ld-linux-x86-64.so.2`) and a set of glibc/system shared libraries at runtime. The rootfs ships the interpreter, its standard library, `libruby`, the loader, and those shared libraries, and the unikernel runs on the same `base:latest` runtime as Go and Rust.
75+
76+
There is one important constraint to get right. **The unikernel extracts its rootfs into an in-RAM filesystem at boot, so the image must stay small.** Copying the entire `/usr/lib/x86_64-linux-gnu` directory (hundreds of MB of ICU data, static archives, etc.) overflows that RAM disk and the boot fails:
77+
78+
```
79+
[libukcpio] ...: Failed to load content: No space left on device (28)
80+
[libposix_vfs_fstab] Failed to extract CPIO to /: -3
81+
```
82+
83+
The fix is to ship **only the precise shared-library closure** the interpreter, `libruby`, and the standard-library C extensions actually need. The Dockerfile below computes that closure at build time with `ldd`.
84+
85+
A plain `docker build` OCI image will NOT boot on the runtime. The image must be in the Unikraft Cloud format produced by `kraft`. The `Kraftfile` and `kraft cloud deploy` command handle this packaging.
86+
87+
### Write the Dockerfile
88+
89+
```dockerfile
90+
FROM ruby:3.3.6-bookworm AS base
91+
92+
# Stage the exact shared-library closure for the interpreter + libruby + the full
93+
# stdlib C-extension set (rubyarchdir, which includes the enc/*.so encoding
94+
# modules Ruby autoloads). Walking ldd over each captures libraries the stdlib
95+
# links lazily that a hand-written list would miss. SONAME symlinks are preserved
96+
# so the loader resolves NEEDED entries.
97+
RUN set -eu; \
98+
mkdir -p /rootfs-libs; \
99+
archdir="$(ruby -e 'print RbConfig::CONFIG["rubyarchdir"]')"; \
100+
{ \
101+
ldd /usr/local/bin/ruby; \
102+
ldd /usr/local/lib/libruby.so.3.3; \
103+
for f in $(find "$archdir" -name '*.so'); do ldd "$f"; done; \
104+
} 2>/dev/null \
105+
| awk '/=>/ {print $3}' \
106+
| grep -E '^/(usr/)?lib' \
107+
| sort -u > /tmp/sonames.txt; \
108+
while read -r p; do \
109+
[ -n "$p" ] || continue; \
110+
real="$(readlink -f "$p")"; \
111+
cp -a "$real" "/rootfs-libs/$(basename "$real")"; \
112+
if [ "$(basename "$p")" != "$(basename "$real")" ]; then \
113+
ln -sf "$(basename "$real")" "/rootfs-libs/$(basename "$p")"; \
114+
fi; \
115+
done < /tmp/sonames.txt; \
116+
du -sh /rootfs-libs
117+
118+
FROM scratch
119+
120+
# Interpreter + stdlib + libruby (all pinned via ruby:3.3.6-bookworm so the
121+
# /usr/local/lib/ruby tree and libruby version match the ruby binary).
122+
COPY --from=base /usr/local/bin/ruby /usr/local/bin/ruby
123+
COPY --from=base /usr/local/lib/ruby /usr/local/lib/ruby
124+
COPY --from=base /usr/local/lib/libruby.so.3.3.6 /usr/local/lib/libruby.so.3.3.6
125+
COPY --from=base /usr/local/lib/libruby.so.3.3 /usr/local/lib/libruby.so.3.3
126+
COPY --from=base /usr/local/lib/libruby.so /usr/local/lib/libruby.so
127+
128+
# glibc dynamic loader (the program interpreter named in the ruby ELF header).
129+
COPY --from=base /lib/x86_64-linux-gnu/ld-linux-x86-64.so.2 /lib64/ld-linux-x86-64.so.2
130+
131+
# The precise shared-library closure, under both default loader search paths so
132+
# NEEDED SONAMEs resolve without an ld.so.cache (intentionally not copied: it
133+
# references libraries we did not ship; the loader falls back to its default
134+
# trusted search paths, where these libraries live).
135+
COPY --from=base /rootfs-libs/ /lib/x86_64-linux-gnu/
136+
COPY --from=base /rootfs-libs/ /usr/lib/x86_64-linux-gnu/
137+
138+
COPY ./server.rb /server.rb
139+
```
140+
141+
> **Note:** pin the interpreter patch version (`ruby:3.3.6-bookworm`). The copied `/usr/local/lib/ruby` standard-library tree and `libruby.so.3.3.6` must match the `ruby` binary; a version skew breaks `require`.
142+
143+
### Write the Kraftfile
144+
145+
```yaml
146+
spec: v0.6
147+
148+
runtime: base:latest
149+
150+
rootfs: ./Dockerfile
151+
152+
cmd: ["/usr/local/bin/ruby", "/server.rb"]
153+
```
154+
155+
`runtime: base:latest` is the Unikraft Cloud app-elfloader runtime. `rootfs: ./Dockerfile` tells `kraft` to build the rootfs from your Dockerfile.
156+
157+
### Start a BuildKit daemon
158+
159+
`kraft` uses BuildKit to build the rootfs. Start one if you don't already have one running:
160+
161+
```sh
162+
docker run -d --name buildkit --privileged moby/buildkit:latest
163+
```
164+
165+
### Build and publish with `kraft cloud deploy --no-start`
166+
167+
Use `kraft` only to build and publish the image — you deploy the running workload with `datumctl compute` in the next step. The `--no-start` (`-S`) flag builds the unikernel package and pushes it to the metro registry **without** starting an instance. It pushes to `index.unikraft.io/datum/<name>`. The `-M` flag sets the memory allocation in MiB and is required — use at least `1024`.
168+
169+
```sh
170+
export KRAFTKIT_NO_CHECK_UPDATES=true
171+
172+
kraft cloud --metro "$UKC_METRO" --token "$UKC_TOKEN" \
173+
--buildkit-host docker-container://buildkit \
174+
deploy --no-start -M 1024 --name hello-ruby \
175+
--runtime base:latest --rootfs ./Dockerfile .
176+
```
177+
178+
After this command completes, your image is available at `index.unikraft.io/datum/hello-ruby:latest`, ready for Datum compute to deploy.
179+
180+
---
181+
182+
## 3. Deploy on Datum compute
183+
184+
You have two options: a manifest file (recommended for repeatability) or flags.
185+
186+
### Option A — manifest file (recommended)
187+
188+
Create `workload.yaml`:
189+
190+
```yaml
191+
apiVersion: compute.datumapis.com/v1alpha
192+
kind: Workload
193+
metadata:
194+
name: hello-ruby
195+
labels:
196+
app: hello-ruby
197+
spec:
198+
template:
199+
metadata:
200+
labels:
201+
app: hello-ruby
202+
spec:
203+
runtime:
204+
resources:
205+
instanceType: datumcloud/d1-standard-2
206+
sandbox:
207+
containers:
208+
- name: app
209+
image: index.unikraft.io/datum/hello-ruby:latest
210+
ports:
211+
- name: http
212+
port: 8080
213+
protocol: TCP
214+
networkInterfaces:
215+
- network:
216+
name: default
217+
placements:
218+
- name: default
219+
cityCodes:
220+
- DFW
221+
scaleSettings:
222+
minReplicas: 1
223+
instanceManagementPolicy: OrderedReady
224+
```
225+
226+
Deploy it:
227+
228+
```sh
229+
datumctl compute deploy -f workload.yaml -y
230+
```
231+
232+
### Option B — flags
233+
234+
```sh
235+
datumctl compute deploy hello-ruby \
236+
--image=index.unikraft.io/datum/hello-ruby:latest \
237+
--city=DFW \
238+
--port=8080 \
239+
--min=1
240+
```
241+
242+
Both forms create (or update) the workload. The `--city` flag accepts one or more city codes; `DFW` targets the US Central region.
243+
244+
---
245+
246+
## 4. Verify the instance is running
247+
248+
List instances and watch for the status to reach `Running`:
249+
250+
```sh
251+
datumctl compute instances --workload=hello-ruby
252+
```
253+
254+
A healthy instance shows `Ready: true` and `Running`. The `EXTERNAL IP` column is populated once the instance is live.
255+
256+
For a detailed view of a single instance, including conditions and any failure reason:
257+
258+
```sh
259+
datumctl compute instances describe <instance-name>
260+
```
261+
262+
Once the instance is `Running`, curl the external endpoint. UKC fronts the service with TLS on port 443 and redirects plain HTTP on port 80:
263+
264+
```sh
265+
# Get the external IP or hostname from the instance list, then:
266+
curl https://<EXTERNAL-IP>/
267+
# -> Hello from Datum (Ruby)
268+
269+
curl https://<EXTERNAL-IP>/healthz
270+
# -> ok
271+
```
272+
273+
Use `-k` if the TLS certificate is self-signed in your metro:
274+
275+
```sh
276+
curl -k https://<EXTERNAL-IP>/
277+
```
278+
279+
---
280+
281+
## 5. Update the workload
282+
283+
To deploy a new version, rebuild and publish the image (repeating step 2), then redeploy. Using the manifest:
284+
285+
```sh
286+
kraft cloud --metro "$UKC_METRO" --token "$UKC_TOKEN" \
287+
--buildkit-host docker-container://buildkit \
288+
deploy --no-start -M 1024 --name hello-ruby \
289+
--runtime base:latest --rootfs ./Dockerfile .
290+
291+
datumctl compute deploy -f workload.yaml -y
292+
```
293+
294+
Or with flags:
295+
296+
```sh
297+
datumctl compute deploy hello-ruby \
298+
--image=index.unikraft.io/datum/hello-ruby:latest \
299+
--city=DFW \
300+
--port=8080
301+
```
302+
303+
Watch the rollout progress:
304+
305+
```sh
306+
datumctl compute rollout hello-ruby
307+
```
308+
309+
---
310+
311+
## 6. Clean up
312+
313+
```sh
314+
# Delete the workload and all its instances.
315+
datumctl compute destroy hello-ruby -y
316+
317+
# Stop the local BuildKit daemon.
318+
docker rm -f buildkit
319+
```
320+
321+
---
322+
323+
## Troubleshooting
324+
325+
### The image fails to boot: "No space left on device"
326+
327+
```
328+
[libukcpio] ...: Failed to load content: No space left on device (28)
329+
[libposix_vfs_fstab] Failed to extract CPIO to /: -3
330+
```
331+
332+
The rootfs is too large for the unikernel's in-RAM filesystem. This happens if you copy the whole `/usr/lib/x86_64-linux-gnu` directory instead of the trimmed library closure. Use the `ldd`-driven closure in the Dockerfile above, and avoid copying static `.a` archives, ICU data, or other bulk you don't use.
333+
334+
### The application fails to `require` a module: missing shared library
335+
336+
If the console shows a load error about a missing `.so` when your code uses a standard-library module (or a gem), that library was not included in the closure. Add it by re-running `ldd` over the relevant stdlib extension (`rubyarchdir/**/*.so`) or your gem's compiled extension and confirming it lands in `/rootfs-libs`. Gems with **C extensions** ship their own `.so` files that may link further system libraries — those must be present in the rootfs too. Pure-Ruby gems need nothing extra.
337+
338+
### Instance shows `Ready` but the endpoint doesn't respond
339+
340+
If an instance reports `Ready` but a `curl` to its endpoint hangs or fails, the unikernel may not have booted cleanly. The unikernel console is the source of truth — read it directly:
341+
342+
```sh
343+
kraft cloud --metro "$UKC_METRO" --token "$UKC_TOKEN" \
344+
instance logs <ukc-instance-name>
345+
```
346+
347+
A healthy boot prints your `listening on :8080` line. A boot error (the rootfs-size or missing-library cases above) appears here. The `<ukc-instance-name>` appears in the instance's details from `datumctl compute instances describe <instance-name>`.
348+
349+
### Image pull failures on the instance
350+
351+
`datumctl compute instances describe <instance-name>` reports a condition with reason `ImageUnavailable` when the platform cannot pull the image. Confirm:
352+
353+
- The image was pushed to `index.unikraft.io/datum/<name>` (the metro registry), not to an external container registry like GHCR or Docker Hub. The platform pulls from the UKC metro registry.
354+
- The `kraft cloud deploy` command completed without errors and printed the image reference.
355+
- The image name in `workload.yaml` matches exactly what `kraft cloud deploy` reported, including the `latest` tag.
356+
357+
### Instance is stuck and not progressing
358+
359+
```sh
360+
datumctl compute instances describe <instance-name>
361+
```
362+
363+
Look at the conditions in the output. Common states:
364+
365+
- `QuotaGranted: False` — compute quota has not been provisioned for the project. Contact your platform operator.
366+
- `Programmed: False` — the instance has not been scheduled to a node yet. This is normal for a few seconds after deploy; if it persists, check that the city code in your workload matches an available location.
367+
- `Ready: False, reason: SchedulingGatesPresent` — a scheduling prerequisite (such as a network) has not been satisfied. Confirm your project has a `default` Network resource provisioned.

examples/hello-ruby/.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
# Build artifacts produced locally by kraft; not committed.
2+
.unikraft/

0 commit comments

Comments
 (0)