返回资讯中心
外部精选
软件工程
#开发者实时流

Show HN: Ramekin – IoT development, deployment and update platform via Apptainer

IoT development, deployment, and update platform

Hacker News 最新mrbluecoat2026/10/31 · 2 天前30 分钟阅读

以下正文同步自 Hacker News 最新,版权归原站所有,已转换为易读排版。

IoT development, deployment, and update platform

Ramekin turns a small board (a Raspberry Pi, a mini PC, or similar) into an appliance that runs one app in a container. You build the app on your computer, push it to the device to try it, then publish it to a whole fleet. Every device updates itself safely.

Serve a web page from a device with nginx, then change it and push the update. Each build takes a few seconds.

The examples assume an arm64 board, which is the default platform. For a PC or VM, use ramekin init --platform linux/amd64 and device init --target amd64.

go install github.com/ascension-association/ramekin/cmd/ramekin@latest ramekin init hello-web && cd hello-web && rm app.sh ramekin keys generate

  1. Write the page. Create index.html:
<!doctype html> <title>Ramekin</title> <h1>Hello</h1>

Replace the generated example.def with this definition file. It starts from Chainguard's nginx image, adds the page, and serves it on port 80:

A static web page served by nginx, from Chainguard's minimal image, which

is rebuilt often to stay free of known vulnerabilities. The -dev variant

includes the shell ramekind uses to start it.

Bootstrap: docker From: cgr.dev/chainguard/nginx:latest-dev

%files index.html /usr/share/nginx/html/index.html

%post

Serve on port 80, which Ramekin keeps free by moving gokrazy's

dashboard to 8181.

cat > /etc/nginx/conf.d/nginx.default.conf <<'CONF' server { listen 80; root /usr/share/nginx/html; } CONF

  1. Build. This produces a 23 MiB SIF and scans it. Expect zero findings.

ramekin build

  1. Create and flash a development device.

ramekin device init hello --no-updates --health-http http://127.0.0.1/ gok -i hello overwrite --full /dev/sdX # use your SD card or disk; this erases it

--no-updates makes this a dev device: it accepts ramekin push and ignores release channels.

--health-http makes the device accept a new version only after the page returns 200.

  1. Push it and open the page.

ramekin push hello # OK: hello is running dev-… curl http://hello/ # <h1>Hello</h1>

  1. Change it and push again.

sed -i 's|<h1>Hello</h1>|<h1>Hello World</h1>|' index.html ramekin build && ramekin push hello --version hello-2 curl http://hello/ # <h1>Hello World</h1> ramekin status hello # current: hello-2, previous: the first push ramekin logs hello -f

If a new version fails its health check, the device keeps running the old one, and push reports ROLLED BACK.

This project is in ramekin-simple-example, along with published releases of it. To have a fleet of devices update themselves, see "Ship to a fleet of devices" below.

Each section below expands with a click. You don't need any of them to get started.

A full-stack TodoMVC app, with a web page, an API and a SQLite database, as one small Go server. It shows a two-stage definition file that compiles for the device without emulation, a persistent database in /data, and a health check that exercises the app. Its releases are published in ramekin-advanced-example.

Start with step 1 of the quick start, using ramekin init todomvc instead of ramekin init hello-web. Then:

  1. Write the app. Create main.go, the whole server:

// todomvc serves a TodoMVC app: the page, a JSON API, and SQLite storage. package main

import ( "database/sql" _ "embed" "encoding/json" "flag" "log" "net/http"

_ "modernc.org/sqlite" )

//go:embed index.html var page []byte

type todo struct { ID int64 json:"id" Title string json:"title" Completed bool json:"completed" }

func main() { addr := flag.String("addr", ":80", "listen address") dbPath := flag.String("db", "/data/todos.db", "SQLite database; keep it in /data so it survives updates") flag.Parse()

db, err := sql.Open("sqlite", *dbPath) if err != nil { log.Fatal(err) } db.SetMaxOpenConns(1) if _, err := db.Exec(CREATE TABLE IF NOT EXISTS todos ( id INTEGER PRIMARY KEY, title TEXT NOT NULL, completed INTEGER NOT NULL DEFAULT 0)); err != nil { log.Fatal(err) }

http.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Write(page) }) http.HandleFunc("GET /api/todos", func(w http.ResponseWriter, r *http.Request) { rows, err := db.Query(SELECT id, title, completed FROM todos ORDER BY id) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } defer rows.Close() todos := []todo{} for rows.Next() { var t todo if err := rows.Scan(&t.ID, &t.Title, &t.Completed); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } todos = append(todos, t) } json.NewEncoder(w).Encode(todos) }) http.HandleFunc("POST /api/todos", func(w http.ResponseWriter, r *http.Request) { var t todo if err := json.NewDecoder(r.Body).Decode(&t); err != nil || t.Title == "" { http.Error(w, "a title is required", http.StatusBadRequest) return } res, err := db.Exec(INSERT INTO todos (title) VALUES (?), t.Title) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } t.ID, _ = res.LastInsertId() json.NewEncoder(w).Encode(t) }) http.HandleFunc("PATCH /api/todos/{id}", func(w http.ResponseWriter, r *http.Request) { var t todo if err := json.NewDecoder(r.Body).Decode(&t); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } exec(w, UPDATE todos SET completed = ? WHERE id = ?, db, t.Completed, r.PathValue("id")) }) http.HandleFunc("DELETE /api/todos/{id}", func(w http.ResponseWriter, r *http.Request) { exec(w, DELETE FROM todos WHERE id = ?, db, r.PathValue("id")) }) http.HandleFunc("DELETE /api/todos", func(w http.ResponseWriter, r *http.Request) { exec(w, DELETE FROM todos WHERE completed, db) // "Clear completed" })

log.Printf("todomvc on %s, data in %s", *addr, *dbPath) log.Fatal(http.ListenAndServe(*addr, nil)) }

func exec(w http.ResponseWriter, query string, db *sql.DB, args ...any) { if _, err := db.Exec(query, args...); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.WriteHeader(http.StatusNoContent) }

Create index.html, the page the server embeds:

<!doctype html> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>todos</title> <style> body { font: 16px system-ui, sans-serif; background: #f5f5f5; max-width: 32rem; margin: 2rem auto; padding: 0 1rem; } h1 { text-align: center; font-weight: 200; font-size: 4rem; color: #b83f45; margin: 0 0 1rem; } .app { background: #fff; box-shadow: 0 2px 6px rgb(0 0 0 / .15); } #new { width: 100%; box-sizing: border-box; padding: 1rem; font-size: 1.25rem; border: 0; border-bottom: 1px solid #eee; } ul { list-style: none; margin: 0; padding: 0; } li { display: flex; align-items: center; gap: .75rem; padding: .75rem 1rem; border-bottom: 1px solid #eee; } li span { flex: 1; } li.done span { text-decoration: line-through; color: #999; } button { border: 0; background: none; cursor: pointer; color: #777; } li button { color: #b83f45; font-size: 1.25rem; } footer { display: flex; justify-content: space-between; padding: .75rem 1rem; color: #777; font-size: .875rem; } </style> <h1>todos</h1> <div class="app"> <input id="new" placeholder="What needs to be done?" autofocus> <ul id="list"></ul> <footer><span id="left"></span><button id="clear">Clear completed</button></footer> </div> <script> const $ = id => document.getElementById(id); const api = (path, method = "GET", body) => fetch("/api/todos" + path, { method, body: body && JSON.stringify(body) }) .then(r => (r.status === 200 ? r.json() : null));

async function render() { const todos = await api(""); $("list").replaceChildren(...todos.map(t => { const li = document.createElement("li"); li.className = t.completed ? "done" : ""; li.innerHTML = '<input type="checkbox"><span></span><button title="Delete">×</button>'; li.querySelector("span").textContent = t.title; const box = li.querySelector("input"); box.checked = t.completed; box.onchange = () => api("/" + t.id, "PATCH", { completed: box.checked }).then(render); li.querySelector("button").onclick = () => api("/" + t.id, "DELETE").then(render); return li; })); const left = todos.filter(t => !t.completed).length; $("left").textContent = left + (left === 1 ? " item left" : " items left"); }

$("new").onkeydown = e => { const title = e.target.value.trim(); if (e.key !== "Enter" || !title) return; e.target.value = ""; api("", "POST", { title }).then(render); }; $("clear").onclick = () => api("", "DELETE").then(render); render(); </script>

Fetch the SQLite driver, which is pure Go, so cross-compiling needs no C toolchain:

go mod init todomvc && go mod tidy

Replace the generated example.def with this two-stage definition file. The first stage compiles the server, and the second is what runs on the device:

Build stage: runs on the workstation's architecture and cross-compiles for

the device ({{ TARGETOS }}/{{ TARGETARCH }}), so no emulation is needed.

Bootstrap: docker From: cgr.dev/chainguard/go:latest Stage: build

%files go.mod /src/ go.sum /src/ main.go /src/ index.html /src/

%post cd /src

/cache persists between builds, so rebuilds take seconds.

export GOMODCACHE=/cache/go/mod GOCACHE=/cache/go/build CGO_ENABLED=0 GOOS={{ TARGETOS }} GOARCH={{ TARGETARCH }} go build -trimpath -ldflags=-s -o /todomvc .

Final stage: what runs on the device, built for its platform.

Bootstrap: docker From: cgr.dev/chainguard/wolfi-base:latest Stage: final

%files from build /todomvc /usr/bin/todomvc

%runscript

The database lives in /data, which persists across updates and rollbacks.

exec /usr/bin/todomvc -addr :80 -db /data/todos.db "$@"

  1. Build. The first build takes about 30 seconds, including the vulnerability scan; rebuilds take about 10. It produces a 10 MiB SIF. On wolfi-base, expect zero findings.

ramekin build

  1. Create and flash a development device.

ramekin device init todo --no-updates --health-http http://127.0.0.1/api/todos gok -i todo overwrite --full /dev/sdX # use your SD card or disk; this erases it

--no-updates makes this a dev device: it accepts ramekin push and ignores release channels.

--health-http makes the device accept a new release only after the API answers, which also proves the database opened.

  1. Push it and add some todos.

ramekin push todo # OK: todo is running dev-…

Open http://todo/ in a browser and add a few todos. Or use the API:

curl -X POST -d '{"title":"Buy milk"}' http://todo/api/todos curl http://todo/api/todos # [{"id":1,"title":"Buy milk","completed":false}]

  1. Change the title and push again.

sed -i 's|>todos<|>Ramekin todos<|g' index.html ramekin build && ramekin push todo --version todo-2 curl http://todo/api/todos # your todos survived the update ramekin status todo # current: todo-2, previous: the first push ramekin logs todo -f

Reload the page to see the new title. The database lives in /data, which persists across updates and rollbacks. If a new version fails its health check, the device keeps running the old one, and push reports ROLLED BACK.

Your workstation builds the app into a single file (an Apptainer SIF image). It scans the image for vulnerabilities, then either pushes it straight to a test device or publishes it as a GitHub release that your fleet watches.

flowchart LR subgraph Workstation A[definition file<br/>or overlay.img] -->|ramekin build| B[app.sif] B -->|syft + grype| C{CVE policy} B -->|ramekin push| D C -->|ramekin publish<br/>sign + age-encrypt| GH end GH[(GitHub Releases<br/>+ channel branch)] subgraph Fleet["Device(s): gokrazy + ramekind"] D[ramekind] --> E[A/B install<br/>health check<br/>rollback] end GH -->|HTTPS: poll signed channel manifest| D GH -->|ramekin watch<br/>daily rescan of SBOMs| W[webhook alert]

Loading

The device runs gokrazy's Go init plus one Ramekin program, ramekind. There is no shell, no apptainer binary and no package manage

正文由 探How 从来源站点 RSS 同步,内容未经改写;遇到排版缺失或需要图片、视频时请以原文为准。

打开原文
去社区讨论 →