Ramekin은 Raspberry Pi나 미니 PC 같은 소형 보드를 앱 하나를 컨테이너로 실행하는 기기로 바꾸는 IoT 개발·배포·업데이트 플랫폼이다. 개발자는 컴퓨터에서 앱을 빌드해 기기에서 시험한 뒤 여러 기기로 배포할 수 있으며, 새 버전이 정상 동작하지 않으면 기기가 이전 버전으로 자동 복구한다.

  • 새 버전은 기존 버전을 대체하기 전에 상태 검사를 통과해야 한다. 실패하면 기기가 자동으로 롤백한다.
  • 이미지에 서명하고 알려진 취약점을 검사하며, 여러 기기에 배포하는 릴리스는 암호화한다.
  • gokrazy를 기반으로 하며, 기기에는 셸이나 패키지 관리자가 없고 앱과 작은 에이전트만 둔다.
  • 앱은 Apptainer 정의 파일로 설명한다. Dockerfile은 몇 분 만에 변환할 수 있으며, 셸에서 대화형으로 빌드하는 방법도 있다. 컨테이너 도구는 Apptainer만 필요하다.

빠른 시작: Hello World

아래 예제는 nginx로 기기에서 웹페이지를 제공한 뒤 페이지를 수정해 업데이트한다. 각 빌드는 몇 초 걸린다고 프로젝트는 설명한다.

개발용 컴퓨터에는 Go, apptainer, 기기를 플래시할 gok가 필요하다. CVE 검사를 하려면 syft와 grype도 설치한다. 두 도구가 없으면 build와 push에 --no-scan을 전달한다.

예제의 기본 플랫폼은 arm64 보드다. PC나 VM을 사용하려면 ramekin init --platform linux/amd64와 device init --target amd64를 쓴다.

프로젝트와 키를 만들고 CLI를 설치한다.

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

index.html을 만들고, 생성된 example.def를 아래 정의 파일로 바꾼다. Chainguard의 nginx 이미지를 기반으로 페이지를 넣고 80번 포트에서 제공한다.

html
<!doctype html>
<title>Ramekin</title>
<h1>Hello</h1>
text
# 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

빌드하면 23 MiB SIF 이미지가 생성되고 취약점 검사를 거친다. 예제는 검사 결과가 0건일 것으로 안내한다.

sh
ramekin build

개발 기기를 초기화하고 SD 카드나 디스크에 플래시한다. /dev/sdX는 실제 장치에 맞춰야 하며, 이 명령은 해당 저장 장치의 내용을 지운다.

sh
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를 설정한 개발 기기는 ramekin push를 받지만 릴리스 채널은 무시한다. --health-http는 페이지가 HTTP 200을 반환한 뒤에만 새 버전을 수락하도록 한다.

기기에 이미지를 보내고 페이지를 확인한다.

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

페이지를 수정하고 새 버전을 보낸다.

sh
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

새 버전의 상태 검사에 실패하면 기기는 이전 버전을 계속 실행하고 push는 ROLLED BACK을 보고한다. 이 예제와 게시된 릴리스는 ramekin-simple-example에서 볼 수 있다. 기기 여러 대를 자동 업데이트하려면 아래의 배포 절차를 따른다.

TodoMVC 예제

고급 예제는 웹페이지, API, SQLite 데이터베이스를 작은 Go 서버 하나로 제공하는 TodoMVC 앱이다. 에뮬레이션 없이 기기용으로 컴파일하는 2단계 정의 파일, 업데이트 뒤에도 유지되는 /data의 데이터베이스, 앱을 실제로 확인하는 상태 검사 방법을 보여준다. 릴리스는 ramekin-advanced-example에 게시돼 있다.

빠른 시작의 첫 단계에서 ramekin init hello-web 대신 ramekin init todomvc를 실행한 뒤 앱을 만든다. 다음은 전체 서버인 main.go다.

go
// 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)
}

서버가 포함할 index.html은 다음과 같다.

html
<!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>

SQLite 드라이버는 순수 Go 구현이므로 교차 컴파일에 C 도구 체인이 필요하지 않다.

sh
go mod init todomvc && go mod tidy

생성된 example.def를 아래 2단계 정의 파일로 바꾼다. 첫 단계는 서버를 컴파일하고, 두 번째 단계는 기기에서 실행된다.

text
# 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 "$@"

첫 빌드는 취약점 검사를 포함해 약 30초, 재빌드는 약 10초 걸리며 10 MiB SIF를 만든다. wolfi-base를 사용하면 검사 결과가 0건일 것으로 안내한다.

sh
ramekin build
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는 개발 기기를 설정해 ramekin push는 받되 릴리스 채널은 무시하게 한다. --health-http는 API가 응답하고 데이터베이스가 열린 뒤에만 새 릴리스를 수락하게 한다.

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

브라우저에서 http://todo/를 열어 항목을 추가하거나 API를 호출한다.

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

제목을 바꾸고 다시 배포한다.

sh
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

페이지를 새로고침하면 바뀐 제목을 볼 수 있다. 데이터베이스는 /data에 저장돼 업데이트와 롤백 뒤에도 유지된다. 새 버전이 상태 검사에 실패하면 이전 버전을 계속 실행하며 push는 ROLLED BACK을 보고한다.

작동 방식과 여러 기기 배포

개발용 컴퓨터는 앱을 Apptainer SIF 이미지 하나로 빌드하고 취약점을 검사한다. 그 뒤 이미지를 시험 기기에 바로 보내거나, 기기들이 확인하는 GitHub 릴리스로 게시한다. 기기에서는 gokrazy의 Go 초기화 프로그램과 Ramekin 프로그램 ramekind가 실행된다. 기기에는 셸, Apptainer 실행 파일, 패키지 관리자가 없다. ramekind가 이미지를 마운트하고 직접 실행하며 서명 확인, 복호화, 채널 확인, 상태 검사에 따른 설치와 롤백을 처리한다. 기본 이미지는 최소 구성의 cgr.dev/chainguard/wolfi-base다. Ramekin은 CoreOS, RancherOS, Talos에서 영감을 받았다.

릴리스는 GitHub Releases에 게시한다. 무료이며 GitHub CDN에서 제공되고, GitHub 외에 새 계정을 만들 필요는 없다. 릴리스용 저장소를 골라 ramekin.yaml의 releases.github: owner/repo에 적는다. 기기는 익명으로 내려받으므로 저장소를 공개해야 한다. 이미지는 암호화하지만 릴리스 이름, 버전, 크기는 공개된다. 릴리스 전용 저장소를 두면 목록을 관리하기 쉽다.

저장소 설정의 Settings > General > Releases에서 immutable releases를 켜면 게시된 릴리스의 파일과 태그를 변경하거나 삭제할 수 없다. ramekin에는 저장소 콘텐츠와 릴리스에 쓸 수 있는 토큰을 제공한다. gh auth login을 쓰거나, GITHUB_TOKEN에 Contents 읽기·쓰기 권한을 가진 세분화된 토큰을 설정한다.

sh
ramekin publish --version 1.0.0 # gate on CVEs, sign, encrypt, create the release, point "stable" at it
ramekin releases
ramekin promote 0.9.0 --channel stable # roll the whole fleet back

게시되는 항목은 릴리스 <app>-<version>의 <app>.sif.age(서명·암호화된 이미지), sbom.json, 서명된 manifest.json이다. 기기가 확인하는 서명된 채널 포인터는 브랜치 ramekin-channels의 <app>/<channel>.json에 저장되며 주소는 https://raw.githubusercontent.com/<owner>/<repo>/ramekin-channels/<app>/<channel>.json이다.

채널 포인터는 게시나 승격 때마다 바뀌므로 변경할 수 없는 릴리스에 둘 수 없다. publish는 처음 사용할 때 다른 브랜치의 이력 없이 ramekin-channels 브랜치를 만든다. 릴리스 전용 저장소라면 포인터를 기본 브랜치에 둘 수도 있다. ramekin.yaml에서 releases.branch: main으로 설정하며 고급 예제가 이 방식을 쓴다.

publish는 발견 항목이 scan.fail_on(기본값 high) 이상이면 배포를 거부한다. 허용한 위험은 scan.ignore에 기록하며 --allow-vulns는 한 번만 검사 기준을 우회한다. 이미 게시된 버전 이름을 재사용하는 것도 거부한다.

기기를 초기화하고 채널을 확인하도록 설정한다.

sh
ramekin device init sensor-01 --health-http http://127.0.0.1/
gok --parent_dir ~/gokrazy -i sensor-01 overwrite --full /dev/sdX

기기는 지터를 두고 5분마다 채널을 확인한다. 더 높은 시리얼 번호의 릴리스를 찾으면 내려받아 복호화·검증한 뒤 설치한다. GitHub CDN은 채널 파일을 최대 5분 캐시하므로 게시물이 모든 기기에 도달하기까지 약 10분 걸린다.

업데이트를 위해 기기에 인증 정보를 둘 필요는 없다. 기기는 HTTPS로 내려받기만 하며, 내려받는 항목은 모두 검사한다. 채널 포인터와 매니페스트는 프로젝트 키로 서명하고, 이미지는 매니페스트의 SHA-256과 일치해야 하며 유효한 서명을 가져야 한다. 따라서 정적 호스트나 CDN에서도 같은 파일을 제공할 수 있다. 다른 호스트를 쓰려면 device.json의 update.url을 설정한다.

기기 상태를 받으려면 ramekin device set <device> --status-url https://…로 웹훅이나 자체 서비스의 주소를 지정한다. 기기는 검사할 때마다 현재·이전 버전과 마지막 오류 등을 담은 JSON 보고서를 POST한다. 직접 접속할 수 있는 기기의 같은 정보는 ramekin status <device>로 확인한다.

업데이트와 자동 롤백

기기는 /perm/ramekin/images에 현재 이미지와 이전 이미지, 두 개를 보관한다. 새 이미지는 /perm에 내려받아 age로 복호화하고 매니페스트의 SHA-256과 대조한 뒤, 기기에 내장된 프로젝트 공개 키로 SIF 서명을 검증한다. 검증을 마치면 시험 중인 이미지로 기록하고 기존 컨테이너를 멈춘 뒤 새 컨테이너를 시작한다.

새 컨테이너는 health.grace(기본 15초) 동안 실행돼야 한다. 설정한 경우 health.http, health.tcp, health.exec 검사도 health.timeout 안에 통과해야 한다. 통과하면 새 이미지가 현재 버전이 되고 기존 이미지는 이전 버전으로 남는다. 실패하면 기존 컨테이너를 다시 시작하고 해당 기기에서 새 이미지를 실패로 표시해 자동으로 재시도하지 않는다.

전원 손실과 부팅 실패에도 복구 절차가 적용된다. 시험 도중 전원이 끊기면 다음 부팅에서 최대 3번까지 시험을 재시도한 뒤 포기한다. 현재 릴리스가 부팅 때 상태 검사를 통과하지 못하면 이전 릴리스로 돌아간다. 커밋 뒤 컨테이너가 충돌하면 지수형 백오프를 적용해 다시 시작하며 대기 시간은 최대 5분이다.

전체 기기를 이전 버전으로 되돌리려면 ramekin promote <older-version>을 실행한다. 이 명령은 더 높은 새 시리얼 번호로 채널을 다시 가리킨다. 운영체제와 에이전트 업데이트에는 gokrazy의 A/B 루트 파티션을 사용한다. ramekin device update <device>는 gok update를 실행한다.

상태 검사는 새 버전이 작동하는지 기기가 판단하는 기준이다. 설정하지 않으면 컨테이너가 15초 동안 실행되는 것만 확인한다. 앱이 지원하는 가장 강한 검사를 고른다. HTTP 서비스를 제공하면 --health-http http://127.0.0.1/로 2xx 응답을 확인한다. 센서 폴러나 MQTT 발행자처럼 포트가 없으면 검사를 지정하지 않아도 되며, 컨테이너가 유예 시간만 버티면 된다. 데이터베이스나 브로커처럼 포트를 열면 --health-tcp 127.0.0.1:5432로 연결을 확인한다. 준비 상태 확인 도구가 있으면 --health-exec로 앱의 실제 동작을 확인할 수 있다. 명령은 컨테이너의 바이너리·파일·마운트·유닉스 소켓, 환경과 PATH를 사용한다. 종료 코드 0은 정상이며 각 시도는 최대 30초 걸릴 수 있다. 포트는 없지만 상태 파일을 쓰는 앱에는 --health-exec "sh -c 'find /data/heartbeat -mmin -1 | grep -q .'" 같은 스크립트를 쓸 수 있다.

검사 명령은 컨테이너의 프로세스 네임스페이스에 들어가지 않는다. 따라서 프로세스 목록에는 컨테이너만이 아니라 기기의 프로세스가 표시된다. 소켓 기반 준비 상태 도구에는 이 제약이 적용되지 않는다. 검사를 여러 개 설정하면 모두 통과해야 한다.

기기 설정과 네트워크

ramekin device init은 기기의 gokrazy 인스턴스 설정인 /etc/ramekin/device.json을 작성한다. 개발 컴퓨터에서는 ~/gokrazy/<device>/config.json에 해당한다. ramekin device set으로 설정을 바꿀 수 있으며, 인수 없이 실행하면 비밀 값을 가린 현재 설정을 보여준다.

sh
ramekin device set hello
ramekin device set hello --health-http http://127.0.0.1/ --health-grace 10s
ramekin device set db --health-exec "pg_isready -h 127.0.0.1" --health-timeout 3m
ramekin device set hello --env LOG_LEVEL=debug --bind /dev/ttyUSB0:/dev/ttyUSB0 --apply

--health-http URL, --health-tcp host:port는 새 릴리스가 수락되기 전에 통과해야 하는 검사를 정한다. ""로 설정을 제거할 수 있다. --health-exec "cmd args"는 컨테이너 안에서 실행해 종료 코드 0을 요구하는 명령이다. 예로 pg_isready, redis-cli ping, sqlite3 /data/app.db 'select 1'을 들 수 있으며 ""로 제거한다. --health-grace D, --health-timeout D는 검사 전에 필요한 최소 실행 시간(기본 15초)과 검사 통과에 허용되는 시간(기본 2분)을 정한다.

--env KEY=VALUE, --unset-env KEY는 컨테이너 환경 변수를 설정·제거하며 반복할 수 있다. --bind src:dst[:ro], --unbind dst는 바인드 마운트를 설정·해제하며 반복할 수 있다. 기본값은 data:/data와 /dev:/dev다. --command "…"는 이미지의 runscript를 셸 방식 인용 규칙으로 덮어쓰며 ""로 되돌린다. --hostname, --channel, --interval D는 컨테이너 호스트 이름, 릴리스 채널, 확인 간격을 지정한다. --apply는 ramekin device update를 통해 즉시 배포하며 기기를 재부팅한다.

--apply가 없으면 변경은 다음 ramekin device update 때 적용된다. device set은 에이전트 자체 검증으로 설정 결과를 확인한 뒤 저장하므로 기기가 거부할 설정을 전달하지 않는다. 이전 파일은 config.json.bak으로 보관한다. 상대 경로 바인드 소스는 /perm/ramekin/ 아래에 놓이며 업데이트 뒤에도 유지된다. API 토큰과 키처럼 device set이 다루지 않는 설정은 device init에서 만든다.

컨테이너는 기기의 네트워크를 공유하므로 기기가 이미 사용하는 포트를 쓸 수 없다. Ramekin은 gokrazy 대시보드를 80번 포트에서 옮겨 앱이 80번과 443번 포트를 사용할 수 있게 한다. 8181번은 gokrazy 대시보드와 업데이트 API, 8095번은 device.json의 listen으로 정하는 ramekind API(push, logs, status)에 쓰인다. ramekin device init은 인스턴스 설정에 "Update": {"HTTPPort": "8181"}을 넣는다. gok은 이 값으로 기기를 설정하고 연결한다. 운영체제나 에이전트를 업데이트할 때는 ramekin device update <device>를 실행한다. 이 명령이 gok update를 대신 실행한다.

DHCP로 재부팅 뒤 IP 주소가 바뀔 수 있다. 기기에 처음 연결한 뒤 ramekin은 MAC 주소를 .ramekin/devices/<device>.json에 저장한다. 기록된 주소에서 응답이 없으면 push, logs, status, restart, device update가 로컬 /24를 검색해 해당 MAC을 가진 새 IP를 찾고, 기기의 응답을 확인한 뒤 주소를 저장하고 작업을 계속한다. ramekin device find <device>로 같은 검색을 직접 실행할 수도 있다. device update는 해당 실행 중에만 gok을 새 IP로 연결하므로 이름을 계속 사용하려면 /etc/hosts나 DNS도 바꿔야 한다.

이 기능에는 재부팅 뒤에도 MAC 주소가 유지되는 보드가 필요하다. 일부 보드는 부팅할 때마다 임의 MAC 주소를 만든다. NIC나 장치 트리에 주소가 설정되지 않으면 Linux가 임의 주소를 만들 수 있다. 이런 보드에서는 장치 트리에 NIC의 local-mac-address를 고정한다. DHCP 예약도 고정하면 IP 주소를 유지할 수 있다.

이미 ~/gokrazy/<device>가 있다면 ramekin device init <device>는 새 인스턴스를 만들지 않고 기존 인스턴스에 ramekind를 추가한다. 다른 설정은 보존하고 config.json.bak에 백업한다. Ramekin 기기는 gokrazy 패키지 github.com/ascension-association/ramekin/cmd/ramekind와 github.com/ascension-association/gk-lockscreen 두 개만 실행한다. 커널은 KernelPackage에서 가져오며, 저장소 이름은 kernel 또는 kernel.<name>이어야 한다. 값이 없으면 gok의 기본값인 github.com/gokrazy/kernel.rpi를 쓴다.

device init과 모든 device update는 이 구성을 강제한다. Packages에서 breakglass, serial-busybox, 여기에 나열된 커널 등 다른 패키지를 제거하고 해당 PackageConfig도 삭제한다. 빠진 ramekind나 gk-lockscreen은 추가하며 변경 사항을 출력한다. 커널 저장소도 Packages에서 제거한다. gok는 KernelPackage에서만 커널을 가져온다. DHCP, NTP 같은 gokrazy 자체 시스템 서비스는 별도로 설정되므로 영향을 받지 않는다.

sh
gok -i hello add github.com/ascension-association/ramekin/cmd/ramekind # or a local checkout's cmd/ramekind
ramekin device init hello --no-updates --health-http http://127.0.0.1/
ramekin device update hello # instead of `gok overwrite`

기존 대시보드가 80번 포트를 사용하는 인스턴스에 device init으로 ramekind를 추가하면 설정은 8181번으로 바뀌지만, 다음 업데이트 전까지 기기는 계속 80번에서 응답한다. 이때 일반 gok update는 8181번에 접속하려다 실패한다. ramekin device update는 이 한 차례의 전환을 감지하고 로컬 8181번 연결을 기기의 80번 포트로 전달한다. 기기가 재부팅해 8181번을 사용하면 그 뒤부터는 해당 포트로 직접 연결한다. 기본값이 아닌 포트를 이미 쓰는 인스턴스는 기존 포트를 유지한다.

정의 파일과 영구 오버레이

ramekin.yaml의 source.mode로 빌드 방식을 정한다. 기본값인 정의 파일 모드는 빠른 시작 예제에서 사용한다. ramekin build는 정의 파일(ramekin.yaml의 source.def, ramekin init이 만드는 example.def, 또는 지정한 다른 이름)을 사용해 apptainer build를 실행한다.

빌드 단계는 개발 컴퓨터의 아키텍처에서 실행하고 마지막 단계만 기기용으로 빌드한다. Apptainer의 --arch는 모든 단계에 적용돼 컴파일러까지 에뮬레이션으로 실행할 수 있다. Ramekin은 개발 컴퓨터의 아키텍처에서 빌드하고 마지막 단계의 기본 이미지만 기기 플랫폼에 맞춘다. latest가 수정 사항을 계속 받을 수 있도록 매 빌드에서 해당 이미지를 확인한다. 이는 Docker의 FROM --platform=$BUILDPLATFORM과 같은 방식이다. 교차 컴파일용 빌드 인수 {{ TARGETOS }}와 {{ TARGETARCH }}는 기기의 값(예: linux와 arm64), {{ BUILDARCH }}는 개발 컴퓨터의 값이다.

각 단계의 %post에는 빌드 사이에 유지되는 /cache 캐시가 있다. GOCACHE, PIP_CACHE_DIR, npm_config_cache 같은 도구 캐시를 이 경로에 두면 재빌드 시간을 줄일 수 있다. 필요한 경우 마지막 단계의 %post는 개발 컴퓨터와 기기 아키텍처가 다르면 qemu binfmt 아래 기기 아키텍처로 실행된다. 따라서 무거운 작업은 빌드 단계에 두는 편이 낫다. %files의 상대 경로는 정의 파일이 있는 디렉터리를 기준으로 해석되며 Ramekin이 실제 빌드에 사용한 정의 파일은 build/.ramekin.def에 저장된다.

영구 오버레이 모드에서는 ramekin overlay init으로 기본 이미지를 가져와 overlay.img를 만든다. ramekin shell은 이 오버레이에서 셸을 열며 apk add, 파일 수정 등 변경은 이미지에 유지된다. 앱 실행 명령을 source.runscript에 설정하고 ramekin build를 실행하면 삭제 항목을 포함해 기본 이미지와 오버레이를 SIF 하나로 합친다.

기기는 프로젝트의 서명 키 하나만 신뢰한다. device init은 keys/ramekin-sign.pub를 기기에 넣는다. 같은 기기에 다른 프로젝트를 보내려면 해당 프로젝트의 keys/와 .ramekin/ 디렉터리를 복사하거나 device init을 다시 실행한다.

Dockerfile과 이미지 변환

기존 이미지는 정의 파일 두 줄로 사용할 수 있다. Apptainer는 이미지의 ENTRYPOINT, CMD, ENV를 컨테이너의 runscript와 환경 변수로 바꾼다.

text
Bootstrap: docker
From: ghcr.io/you/your-app:1.2.3

개발 컴퓨터에만 있는 이미지는 실행 중인 Docker에서 Bootstrap: docker-daemon으로 읽을 수 있다. 또는 docker save -o app.tar your-app으로 저장한 뒤 Bootstrap: docker-archive와 From: app.tar를 사용한다. oci-archive도 같은 방식으로 쓸 수 있다. 이미지 아키텍처는 기기와 맞아야 한다. Ramekin이 플랫폼을 자동 선택하는 것은 Bootstrap: docker로 레지스트리 이미지를 가져오는 경우뿐이다.

Dockerfile의 FROM image는 Bootstrap: docker와 From: image, FROM image AS build는 Stage: build인 단계로 바꾼다. COPY --from=build /a /b는 %files from build와 /a /b 행으로, RUN cmd는 실패한 명령에서 멈추는 셸 스크립트 %post로 옮긴다. COPY src dst는 %files에 src dst를 적는다. URL이나 아카이브를 가져오는 ADD는 %post에서 curl -fsSLo ... 또는 tar -xf ...를 사용한다. ENV KEY=value는 %environment의 export KEY=value로 바꾸며 빌드에 필요한 값은 %post에서도 export한다. ARG NAME=default는 %arguments에 NAME=default를 적고 {{ NAME }}으로 사용한다. WORKDIR /dir는 %post와 %runscript의 cd /dir로, CMD와 ENTRYPOINT는 %runscript의 exec your-app "$@"로 옮긴다. LABEL key=value는 %labels의 key value가 된다. 빌드 단계가 기본적으로 네이티브로 실행되므로 FROM --platform=$BUILDPLATFORM은 필요하지 않으며 {{ TARGETOS }}와 {{ TARGETARCH }}를 사용한다. RUN --mount=type=cache,...의 캐시는 /cache 아래에 둔다.

EXPOSE, VOLUME, USER, HEALTHCHECK에 해당하는 정의 파일 항목은 없다. 포트는 컨테이너가 기기 네트워크를 공유하므로 앱의 수신 주소로 정한다. /data는 업데이트 뒤에도 유지되는 데이터 경로이며 ramekin device set --bind로 추가 경로를 설정할 수 있다. 앱은 기기에서 root로 실행되므로 권한을 낮춰야 하면 앱에서 처리해야 한다. 상태 검사는 ramekin device set --health-http, --health-tcp, --health-exec로 지정한다.

마지막 단계에는 /bin/sh가 있어야 한다. ramekind가 runscript를 실행하기 전에 이미지의 환경을 불러올 때 이 셸을 쓴다. Distroless나 scratch 기반 이미지에는 셸이 없다. 셸이 포함된 이미지 변형을 사용하거나(빠른 시작은 nginx:latest 대신 Chainguard의 nginx:latest-dev를 사용한다), 앱을 cgr.dev/chainguard/wolfi-base 같은 작은 기본 이미지에 복사하거나, ramekin device set --command "/usr/bin/your-app --flag"로 runscript를 우회할 수 있다.

취약점 검사와 보안

ramekin build, push, scan은 syft로 SBOM을 만들고 grype로 취약점을 확인한다. publish는 검사 결과를 릴리스 승인 기준으로 사용하고 SBOM을 릴리스에 함께 저장한다. ramekin watch는 현재 채널의 모든 SBOM을 최신 취약점 데이터와 다시 대조해 릴리스 뒤 공개된 CVE도 알린다. ramekin init은 이를 위한 일일 GitHub Actions 워크플로를 추가한다. ramekin watch --interval 24h를 장시간 실행하는 방식도 가능하다. alerts.min_severity 이상인 항목은 alerts.webhook으로 POST한다. JSON 본문에는 Slack·Mattermost용 text, Discord용 content, 구조화된 findings가 담긴다.

이미지는 ECDSA P-256 키로 서명한 SIF이며 DSSE를 사용한다. apptainer sign/verify --key와 상호 운용할 수 있다. 기기는 설치 전마다 프로세스 안에서 서명을 확인한다. 채널 매니페스트에도 서명한다. 기기는 해당 채널에서 이미 처리한 시리얼 번호와 같거나 낮은 번호를 무시하며 각 이미지의 SHA-256과 서명을 확인한다.

기밀성을 위해 SIF를 age로 플릿 키 또는 기기별 키(device init --own-key)에 암호화한다. 공개 릴리스 저장소에는 이름과 크기만 드러난다. publish는 버전을 재사용하지 않는다. GitHub의 변경 불가 릴리스를 켜면 게시된 파일과 태그를 수정하거나 삭제할 수 없으며, 설정이 꺼져 있으면 publish가 알려준다. 채널 브랜치의 Git 이력에는 모든 게시와 승격이 기록된다. 기기별 푸시 API는 device init이 만드는 Bearer 토큰을 사용한다.

각 기기에는 gokrazy 시스템 서비스, ramekind, gk-lockscreen만 실행하며 셸, SSH, 다른 패키지는 두지 않는다. device init과 device update가 이를 강제한다. 기기는 저장소 인증 정보를 갖지 않고 HTTPS로 익명 다운로드만 한다. GitHub 토큰은 개발 컴퓨터나 CI만 보유한다.

keys/ramekin-sign.key와 keys/*.agekey는 비밀 값이며 Git에서 제외된다. keys/ramekin-sign.pub와 keys/recipients.txt는 커밋해도 된다. CI에서는 서명 키 PEM을 RAMEKIN_SIGNING_KEY로 전달한다. ~/gokrazy/<device>/config.json에는 API 토큰과 age 개인 정보가 들어 있으므로 비공개로 보관해야 한다. gokrazy는 이 값을 기기의 루트 파일 시스템에 저장하므로 SD 카드를 가진 사람은 내용을 볼 수 있다. 공유 플릿 키를 쓰면 릴리스도 복호화할 수 있으므로 필요한 경우 device init --own-key를 사용한다.

격리는 패키징을 위한 것이지 보안을 위한 것이 아니다. 컨테이너는 root로 실행되며 mount, PID, IPC, UTS 네임스페이스를 사용하지만 호스트 네트워크와 기본 설정의 호스트 /dev를 공유한다. GPIO, 직렬 포트, USB 작업을 지원하기 위한 구성이다. 커널에는 loop, squashfs, overlayfs가 필요하다. 커널에 내장돼 있지 않으면 ramekind가 모듈로 불러오며, 시작 시와 로그에 누락된 항목을 보고한다.

암호화는 릴리스 저장소에 보관 중일 때와 전송 중에 적용되지만 기기에서는 적용되지 않는다. /perm에 있는 복호화된 SIF는 평문이다. Apptainer의 기본 LUKS 암호화 SIF는 네이티브 런타임에서 지원하지 않는다. 컨테이너 진입점은 PID 1로 실행되며 PID 1은 신호 처리기를 설치하지 않으면 SIGTERM을 무시한다. 따라서 앱은 SIGTERM을 처리해야 한다. 그렇지 않으면 종료 시 stop_timeout(10초)까지 기다린 뒤 SIGKILL을 보낸다.

개발 컴퓨터가 amd64이고 기기가 arm64인 교차 아키텍처 빌드는 마지막 단계의 %post와 ramekin shell에 qemu binfmt가 필요하다. 빌드 단계는 네이티브로 실행된다. 개발용 푸시는 다음 게시 때까지 유지된다. 푸시한 이미지는 채널 시리얼을 올리지 않으므로 채널도 확인하는 기기는 새 릴리스가 게시되면 채널의 버전으로 돌아간다.

기여와 저장소 구성

저장소는 cmd/ramekin의 개발자 CLI, cmd/ramekind의 기기 에이전트(gokrazy 패키지), internal/agent의 감독자(A/B 설치·롤백)와 업데이트 도구 및 HTTP API, internal/container의 네이티브 SIF 런타임(loop·squashfs·overlayfs·네임스페이스), internal/sifimg의 SIF 서명·검증(sylabs/sif), internal/manifest의 서명된 채널 매니페스트, internal/release의 GitHub Releases 게시와 HTTPS 다운로드, internal/build의 정의 파일·오버레이 빌드, internal/scan의 syft/grype 연동과 알림으로 구성된다.

테스트는 go test ./...로 실행한다. 실제 컨테이너 런타임 테스트에는 root 권한이 필요해 sudo go test ./internal/container를 사용한다.