Skip to content

Commit 82b9f4c

Browse files
scotwellsclaude
andcommitted
docs: add guide for deploying a Ruby service on Datum compute
Adds a self-contained tutorial that takes a developer from a Ruby web service in source form to a live, reachable Instance on Datum compute. It walks through packaging the app as a Unikraft unikernel with kraft, publishing the image to the metro registry, deploying the workload with datumctl compute deploy, and verifying the HTTP response, with troubleshooting guidance for the common boot, image-pull, and scheduling failures along the way. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 5852ed0 commit 82b9f4c

1 file changed

Lines changed: 366 additions & 0 deletions

File tree

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

0 commit comments

Comments
 (0)