DEV Community

Cover image for Run my own serverless service on a Raspberry Pi with Spin

Run my own serverless service on a Raspberry Pi with Spin

Photo by Phil Hearing on Unsplash

I want to launch my own serverless service running in my local network

Idea

As a huge fan of serverless computing, I thought it would be fun to run your own "serverless-like" environment locally and use it inside the home network.

A while ago, I started playing with the Spin framework. This is an awesome way to create applications in WASM and run them basically anywhere. Spin is an open-source, Cloud Native Foundation sandbox project.

WASM itself is an interesting technology, and it fits great into the serverless environment. The cool thing about it is that it runs in the sandbox and doesn't bring much execution overwhelm. There are a few languages that have some way to compile to WASM.

Part of the fun is that I am going to use my Raspberry Pi 3 as a server running Spin. I will "publish" my functions there. The expectation is that I will end up with my own energy-efficient serverless service running inside my home network.

Architecture

The code is available in the GitHub repository

Raspberry Pi is my server that runs Spin service and exposes the endpoint to call APIs. At this point, it is connected to my local network and can be reached only from it.

I develop an application on my local machine and deploy it by rsync over SSH.

The data for the app is persistent in the sqlite DB that runs on the Raspberry Pi

Goal

I create a book catalog that I can query for books I have. For this experiment, it will be a single endpoint that returns a book based on ID.

Application development

I start from installing Spin on my local machine
I install ready-to-use templates for Rust (there are also versions for Python, TypeScript, TinyGo) spin templates install --git https://github.com/spinframework/spin --update
The last thing would be to add WASM target to Rust toolchain: rustup target add wasm32-wasip1

Once it is done, let's create the app boilerplate. I run spin up and pick the http-rust template

Now I run spin build and spin up

All right! The app is running on the localhost and I can CURL it to see a dummy "Hello World"

Ok, let's write actual code. Spin abstracts away "server-ish" part, and working in it feels a bit like writing Lambda Functions. Components are defined in the spin.toml

#:schema https://schemas.spinframework.dev/spin/manifest-v2/latest.json

spin_manifest_version = 2

[application]
name = "books-spin"
version = "0.1.0"
authors = ["szymon"]
description = ""

[[trigger.http]]
route = "/book"
component = "books-spin"

[component.books-spin]
source = "target/wasm32-wasip1/release/books_spin.wasm"
allowed_outbound_hosts = []
[component.books-spin.build]
command = "cargo build --target wasm32-wasip1 --release"
watch = ["src/**/*.rs", "Cargo.toml"]
Enter fullscreen mode Exit fullscreen mode

I have an HTTP trigger defined along with the path. There is also a build configuration for the component.

The handler code looks familiar. I start by defining the data model

// lib.rs
#[derive(Serialize, Debug)]
pub struct Book {
    pub title: String,
    pub author: String,
    pub year: u32,
}
Enter fullscreen mode Exit fullscreen mode

Now the handler. Inside, I define a router and add handlers for specific methods.

// lib.rs
#[http_component]
fn handle_books_spin(req: Request) -> anyhow::Result<impl IntoResponse> {
    let mut router = spin_sdk::http::Router::new();

    router.add("/book", spin_sdk::http::Method::Get, api::get_book_by_id);

    println!("Handling request to {:?}", req.header("spin-full-url"));

    Ok(router.handle(req))
}
Enter fullscreen mode Exit fullscreen mode

Finally, the function to get the given book. For now it returns some dummy data

// lib.rs

mod api {
    use spin_sdk::http::Params;

    use super::*;

    pub(crate) fn get_book_by_id(
        _req: Request,
        _params: Params,
    ) -> anyhow::Result<impl IntoResponse> {
        let dummy_book = Book {
            title: String::from("Dummy Book"),
            author: String::from("Dummy Author"),
            year: 2023,
        };

        Ok(Response::builder()
            .status(200)
            .header("content-type", "application/json")
            .body(serde_json::to_string(&dummy_book)?)
            .build())
    }
}
Enter fullscreen mode Exit fullscreen mode

Let's test it with spin build && spin up

Add SQLite

One of the cool features of the Spin is that it wraps APIs for various features. One of them is an interface for the embedded SQLite Database.

I just need to add a list of all allowed SQLite databases to the component's definition.

#...

[component.books]
source = "books/target/wasm32-wasip1/release/books.wasm"
allowed_outbound_hosts = []
sqlite_databases = ["default"]

#...
Enter fullscreen mode Exit fullscreen mode

I create a simple migration file

CREATE TABLE IF NOT EXISTS books (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    title TEXT NOT NULL,
    author TEXT NOT NULL,
    year INTEGER NOT NULL
);

INSERT INTO books (id, title, author, year) VALUES (1, 'Wieża Jaskółki', 'Andrzej Sapkowski', 2014)
ON CONFLICT(id) DO UPDATE SET title=excluded.title, author=excluded.author, year=excluded.year;
Enter fullscreen mode Exit fullscreen mode

Now, it is enough to run Spin with the sqlite flag. The db file is created in the .spin directory

spin up --sqlite @migration.sql
Enter fullscreen mode Exit fullscreen mode

For now, I keep connection inside a handler function. I am not sure if in the WASM environment, there is the idea of warm and cold execution. I need to check it, but for my small POC it doesn't really matter.

The updated handler function

// lib.rs
// ...
pub(crate) fn get_book_by_id(
        _req: Request,
        params: Params,
    ) -> anyhow::Result<impl IntoResponse> {
        println!("{:?}", &params);

        let id_param = params.get("id").ok_or(anyhow!("Missing id parameter"))?;

        let id = id_param.parse::<i64>()?;

        let query_params = [Value::Integer(id)];

        let connection = Connection::open_default()?;

        let rowset = connection.execute(
            "SELECT title, author, year FROM books WHERE id = ?",
            &query_params,
        )?;

        let books: Vec<Book> = rowset
            .rows()
            .map(|row| Book {
                title: row.get::<&str>("title").unwrap().to_string(),
                author: row.get::<&str>("author").unwrap().to_string(),
                year: row.get::<u32>("year").unwrap(),
            })
            .collect();

        let book = books.first().ok_or(anyhow!("Book not found"))?;

        Ok(Response::builder()
            .status(200)
            .header("content-type", "application/json")
            .body(serde_json::to_string(&book)?)
            .build())
    }
//...
Enter fullscreen mode Exit fullscreen mode

Now I should be able to get a book by ID.

Nice.

Deploy to Raspberry Pi

I am using a Raspberry Pi 3 B+. After SSHing into it, I install the Spin. I don't need any tools for development, as the idea is to load compiled WASM files.

The flow I plan to use is the following: I create a systemd service to run my Spin server. Then I sync spin.toml, SQL migration, and built binaries using rsync. After each sync, I restart the service.

My service is defined in the spin-books.service

[Unit]
Description=Spin Books Service
After=network.target

[Service]
User=szymon
Group=szymon
WorkingDirectory=/home/szymon/projects/spin-books
ExecStart=/usr/local/bin/spin up --listen 0.0.0.0:3003 --sqlite @migration.sql
Restart=always

[Install]
WantedBy=multi-user.target
Enter fullscreen mode Exit fullscreen mode

I sync this file to the /etc/systemd/system/ directory on the Raspberry Pi.

After running spin build on the local machine, I synced the config and created binaries:

rsync -avz --relative spin.toml migration.sql target/wasm32-wasip1/release/books_spin.wasm szymon@szymonpi3.local:~/projects/spin-books
Enter fullscreen mode Exit fullscreen mode

Now I can run the service and make sure that my spin application is up and running on the Raspberry Pi

sudo systemctl daemon-reload
sudo systemctl start spin-books
sudo systemctl status spin-books
Enter fullscreen mode Exit fullscreen mode

Looks good:

I can call the endpoint from my development machine:

Fantastic! I have my own WASM-based application on the Raspberry Pi running in my local network.

One more thing: I make sure it will start after rebooting by running sudo systemctl enable spin-books.service. Now every time I start the Raspberry Pi, my book service starts too.

Summary

When working on the blog post, I wanted to check how hard it would be to build a simple WASM-based backend application. It turned out that Spin makes it really easy. It can be easily run on the Raspberry Pi, which unlocks a fun experience of building your own serverless service.

The code is available in the GitHub repository

Top comments (0)