Skip to content

Commit e313516

Browse files
committed
docs: add Python deploy guide and runnable example for Datum compute
1 parent 5852ed0 commit e313516

6 files changed

Lines changed: 514 additions & 0 deletions

File tree

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

Lines changed: 360 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,360 @@
1+
# Deploy a Python Web Service on Datum Compute
2+
3+
> Last verified: 2026-06-02 against the `hello-python` example and the live `kraft` / `datumctl compute` CLIs.
4+
> The complete, ready-to-deploy example for this guide lives in [`examples/hello-python/`](../../examples/hello-python/).
5+
6+
This guide walks you through taking a Python HTTP service from source code to a live, reachable instance on Datum compute. By the end you will have:
7+
8+
- A Python 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+
- Python (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.py`**
27+
28+
```python
29+
import os
30+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
31+
32+
33+
class Handler(BaseHTTPRequestHandler):
34+
def _respond(self, body):
35+
payload = body.encode("utf-8")
36+
self.send_response(200)
37+
self.send_header("Content-Type", "text/plain; charset=utf-8")
38+
self.send_header("Content-Length", str(len(payload)))
39+
self.end_headers()
40+
self.wfile.write(payload)
41+
42+
def do_GET(self):
43+
if self.path == "/healthz":
44+
self._respond("ok\n")
45+
else:
46+
self._respond("Hello from Datum (Python)\n")
47+
48+
def log_message(self, format, *args):
49+
pass
50+
51+
52+
def main():
53+
port = int(os.environ.get("PORT", "8080"))
54+
server = ThreadingHTTPServer(("0.0.0.0", port), Handler)
55+
print("listening on :%d" % port, flush=True)
56+
server.serve_forever()
57+
58+
59+
if __name__ == "__main__":
60+
main()
61+
```
62+
63+
The service listens on `$PORT` (default `8080`), answers `/healthz` with `ok`, and uses only the standard library — no third-party dependencies.
64+
65+
---
66+
67+
## 2. Build and publish the unikernel image with `kraft`
68+
69+
### Why Python ships the interpreter and its library closure
70+
71+
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. Python is different: the CPython interpreter is a **dynamically linked** executable — it needs its loader (`/lib64/ld-linux-x86-64.so.2`) and a set of glibc shared libraries at runtime. The rootfs ships the interpreter, the standard library, the loader, and those shared libraries, and the unikernel runs on the same `base:latest` runtime as Go and Rust.
72+
73+
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, Tcl/Tk, X11, static archives) overflows that RAM disk and the boot fails:
74+
75+
```
76+
[libukcpio] ...: Failed to load content: No space left on device (28)
77+
[libposix_vfs_fstab] Failed to extract CPIO to /: -3
78+
```
79+
80+
The fix is to ship **only the precise shared-library closure** the interpreter and its standard-library C extensions actually need. The Dockerfile below computes that closure at build time with `ldd`.
81+
82+
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.
83+
84+
### Write the Dockerfile
85+
86+
```dockerfile
87+
FROM python:3.12.11-bookworm AS base
88+
89+
# Stage the exact shared-library closure for the interpreter + the whole stdlib
90+
# C-extension set. Walking ldd over the interpreter, libpython, and every
91+
# lib-dynload/*.so extension captures libraries the stdlib dlopens lazily (e.g.
92+
# libssl/libcrypto via _ssl, liblzma via _lzma) that a hand-written list misses.
93+
# SONAME symlinks are preserved so the loader resolves NEEDED entries.
94+
RUN set -eu; \
95+
mkdir -p /rootfs-libs; \
96+
{ \
97+
ldd /usr/local/bin/python3.12; \
98+
ldd /usr/local/lib/libpython3.12.so.1.0; \
99+
for f in /usr/local/lib/python3.12/lib-dynload/*.so; do ldd "$f"; done; \
100+
} 2>/dev/null \
101+
| awk '/=>/ {print $3}' \
102+
| grep -E '^/(usr/)?lib' \
103+
| sort -u > /tmp/sonames.txt; \
104+
while read -r p; do \
105+
[ -n "$p" ] || continue; \
106+
real="$(readlink -f "$p")"; \
107+
cp -a "$real" "/rootfs-libs/$(basename "$real")"; \
108+
if [ "$(basename "$p")" != "$(basename "$real")" ]; then \
109+
ln -sf "$(basename "$real")" "/rootfs-libs/$(basename "$p")"; \
110+
fi; \
111+
done < /tmp/sonames.txt; \
112+
du -sh /rootfs-libs
113+
114+
FROM scratch
115+
116+
# Interpreter + stdlib (the python3.12 dir version MUST match libpython below).
117+
COPY --from=base /usr/local/bin/python3.12 /usr/local/bin/python3
118+
COPY --from=base /usr/local/lib/python3.12 /usr/local/lib/python3.12
119+
COPY --from=base /usr/local/lib/libpython3.12.so.1.0 /usr/local/lib/libpython3.12.so.1.0
120+
121+
# glibc dynamic loader (the program interpreter named in the python ELF header).
122+
COPY --from=base /lib64/ld-linux-x86-64.so.2 /lib64/ld-linux-x86-64.so.2
123+
124+
# The precise shared-library closure, under both default loader search paths so
125+
# NEEDED SONAMEs resolve without an ld.so.cache. (ld.so.cache is intentionally
126+
# NOT copied: it references libraries we did not ship; the loader falls back to
127+
# the default trusted search paths, where these libraries live.)
128+
COPY --from=base /rootfs-libs/ /lib/x86_64-linux-gnu/
129+
COPY --from=base /rootfs-libs/ /usr/lib/x86_64-linux-gnu/
130+
131+
COPY ./server.py /server.py
132+
```
133+
134+
> **Note:** pin the interpreter patch version (`python:3.12.11-bookworm`). The copied `/usr/local/lib/python3.12` standard-library tree must match `libpython3.12.so.1.0`; a version skew between the two breaks imports.
135+
136+
### Write the Kraftfile
137+
138+
```yaml
139+
spec: v0.6
140+
141+
runtime: base:latest
142+
143+
rootfs: ./Dockerfile
144+
145+
cmd: ["/usr/local/bin/python3", "/server.py"]
146+
```
147+
148+
`runtime: base:latest` is the Unikraft Cloud app-elfloader runtime. `rootfs: ./Dockerfile` tells `kraft` to build the rootfs from your Dockerfile.
149+
150+
### Start a BuildKit daemon
151+
152+
`kraft` uses BuildKit to build the rootfs. Start one if you don't already have one running:
153+
154+
```sh
155+
docker run -d --name buildkit --privileged moby/buildkit:latest
156+
```
157+
158+
### Build and publish with `kraft cloud deploy --no-start`
159+
160+
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 — Python needs more than the static Go/Rust binaries, so use at least `1024`.
161+
162+
```sh
163+
export KRAFTKIT_NO_CHECK_UPDATES=true
164+
165+
kraft cloud --metro "$UKC_METRO" --token "$UKC_TOKEN" \
166+
--buildkit-host docker-container://buildkit \
167+
deploy --no-start -M 1024 --name hello-python \
168+
--runtime base:latest --rootfs ./Dockerfile .
169+
```
170+
171+
After this command completes, your image is available at `index.unikraft.io/datum/hello-python:latest`, ready for Datum compute to deploy.
172+
173+
---
174+
175+
## 3. Deploy on Datum compute
176+
177+
You have two options: a manifest file (recommended for repeatability) or flags.
178+
179+
### Option A — manifest file (recommended)
180+
181+
Create `workload.yaml`:
182+
183+
```yaml
184+
apiVersion: compute.datumapis.com/v1alpha
185+
kind: Workload
186+
metadata:
187+
name: hello-python
188+
labels:
189+
app: hello-python
190+
spec:
191+
template:
192+
metadata:
193+
labels:
194+
app: hello-python
195+
spec:
196+
runtime:
197+
resources:
198+
instanceType: datumcloud/d1-standard-2
199+
sandbox:
200+
containers:
201+
- name: app
202+
image: index.unikraft.io/datum/hello-python:latest
203+
ports:
204+
- name: http
205+
port: 8080
206+
protocol: TCP
207+
networkInterfaces:
208+
- network:
209+
name: default
210+
placements:
211+
- name: default
212+
cityCodes:
213+
- DFW
214+
scaleSettings:
215+
minReplicas: 1
216+
instanceManagementPolicy: OrderedReady
217+
```
218+
219+
Deploy it:
220+
221+
```sh
222+
datumctl compute deploy -f workload.yaml -y
223+
```
224+
225+
### Option B — flags
226+
227+
```sh
228+
datumctl compute deploy hello-python \
229+
--image=index.unikraft.io/datum/hello-python:latest \
230+
--city=DFW \
231+
--port=8080 \
232+
--min=1
233+
```
234+
235+
Both forms create (or update) the workload. The `--city` flag accepts one or more city codes; `DFW` targets the US Central region.
236+
237+
---
238+
239+
## 4. Verify the instance is running
240+
241+
List instances and watch for the status to reach `Running`:
242+
243+
```sh
244+
datumctl compute instances --workload=hello-python
245+
```
246+
247+
A healthy instance shows `Ready: true` and `Running`. The `EXTERNAL IP` column is populated once the instance is live.
248+
249+
For a detailed view of a single instance, including conditions and any failure reason:
250+
251+
```sh
252+
datumctl compute instances describe <instance-name>
253+
```
254+
255+
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:
256+
257+
```sh
258+
# Get the external IP or hostname from the instance list, then:
259+
curl https://<EXTERNAL-IP>/
260+
# -> Hello from Datum (Python)
261+
262+
curl https://<EXTERNAL-IP>/healthz
263+
# -> ok
264+
```
265+
266+
Use `-k` if the TLS certificate is self-signed in your metro:
267+
268+
```sh
269+
curl -k https://<EXTERNAL-IP>/
270+
```
271+
272+
---
273+
274+
## 5. Update the workload
275+
276+
To deploy a new version, rebuild and publish the image (repeating step 2), then redeploy. Using the manifest:
277+
278+
```sh
279+
kraft cloud --metro "$UKC_METRO" --token "$UKC_TOKEN" \
280+
--buildkit-host docker-container://buildkit \
281+
deploy --no-start -M 1024 --name hello-python \
282+
--runtime base:latest --rootfs ./Dockerfile .
283+
284+
datumctl compute deploy -f workload.yaml -y
285+
```
286+
287+
Or with flags:
288+
289+
```sh
290+
datumctl compute deploy hello-python \
291+
--image=index.unikraft.io/datum/hello-python:latest \
292+
--city=DFW \
293+
--port=8080
294+
```
295+
296+
Watch the rollout progress:
297+
298+
```sh
299+
datumctl compute rollout hello-python
300+
```
301+
302+
---
303+
304+
## 6. Clean up
305+
306+
```sh
307+
# Delete the workload and all its instances.
308+
datumctl compute destroy hello-python -y
309+
310+
# Stop the local BuildKit daemon.
311+
docker rm -f buildkit
312+
```
313+
314+
---
315+
316+
## Troubleshooting
317+
318+
### The image fails to boot: "No space left on device"
319+
320+
```
321+
[libukcpio] ...: Failed to load content: No space left on device (28)
322+
[libposix_vfs_fstab] Failed to extract CPIO to /: -3
323+
```
324+
325+
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 (which ships ~20–30 MB of libraries, not hundreds), and avoid copying `.a` static archives, ICU data, or GUI toolkits you don't use.
326+
327+
### The application fails to import a module: missing shared library
328+
329+
If the console shows an `ImportError` about a missing `.so` when your code imports a standard-library module (or a dependency), that library was not included in the closure. Add it by re-running `ldd` over the relevant `lib-dynload/*.so` (or your dependency's compiled extension) and confirming it lands in `/rootfs-libs`. Third-party packages with **C extensions** (numpy, cryptography, …) ship their own `.so` files that may link further system libraries — those must be present in the rootfs too. Pure-Python dependencies need nothing extra.
330+
331+
### Instance shows `Ready` but the endpoint doesn't respond
332+
333+
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:
334+
335+
```sh
336+
kraft cloud --metro "$UKC_METRO" --token "$UKC_TOKEN" \
337+
instance logs <ukc-instance-name>
338+
```
339+
340+
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>`.
341+
342+
### Image pull failures on the instance
343+
344+
`datumctl compute instances describe <instance-name>` reports a condition with reason `ImageUnavailable` when the platform cannot pull the image. Confirm:
345+
346+
- 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.
347+
- The `kraft cloud deploy` command completed without errors and printed the image reference.
348+
- The image name in `workload.yaml` matches exactly what `kraft cloud deploy` reported, including the `latest` tag.
349+
350+
### Instance is stuck and not progressing
351+
352+
```sh
353+
datumctl compute instances describe <instance-name>
354+
```
355+
356+
Look at the conditions in the output. Common states:
357+
358+
- `QuotaGranted: False` — compute quota has not been provisioned for the project. Contact your platform operator.
359+
- `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.
360+
- `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)