
Show HN: Ramekin – IoT development, deployment and update platform via Apptainer
IoT development, deployment, and update platform
以下正文同步自 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
- Write the page. Create index.html:
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
- Build. This produces a 23 MiB SIF and scans it. Expect zero findings.
ramekin build
- 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.
- Push it and open the page.
ramekin push hello # OK: hello is running dev-… curl http://hello/ # <h1>Hello</h1>
- 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:
- 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 "$@"
- 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
- 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.
- 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}]
- 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 同步,内容未经改写;遇到排版缺失或需要图片、视频时请以原文为准。