Building self hosted lambda

Building a functions as a service platform in Go from first principles

Hello 👋🏻





I'm Ashu

I do go/rust stuff for infra, building for the craft #trad coder
Day job: Sr. software - compute infra @ Rippling
2

The problem

I wanted Lambda-like developer experience, but on my own cluster, with my own control plane.

what's it doing.. you write functions; you get apis — same “lambda” contract, on your cluster.
Artifact Hub: litefunctions Artifact Hub QR
helm install litefunctions oci://registry-1.docker.io/ashupednekar535/litefunctions
what we got
Kubernetes Go
curl -sS -X POST "https://lambda.us-east-1.amazonaws.com/2015-03-31/functions/Spacelift_Test_Lambda_Function/invocations" \
  -H "Content-Type: application/json" \
  -d '{"key1":"world"}'
3

What does Lambda-like really mean?

At a high level it’s the layer between traffic and your code: it tracks what’s deployed, queues and routes work, and scales compute with traffic—so you ship functions and let the platform own inventory, concurrency, and runners instead of wiring that yourself.
Lambda-style architecture: control plane, invoke path, and runtimes
4

The real question

What actually has to happen when someone invokes a function?




edit Neovim
build Docker
spawn Pod
route Istio
run
Go Rust Python JavaScript


Bottom line, we version function code, build artifacts and spin up pods in response to traffic For dynamic languages, hot reload the code into pre-provisioned runtimes Kubernetes and node pools are where scaling and capacity land—scheduling, autoscaling, elasticity under those pods
5

Why Go for this?


It fits right in to the cloud native ecosystem, ideal for building components like ingestor, operator, runtime, the ui to the ecosystem from istio, gitea, nats, cruncy, and kubernetes itself are written in go
6

Components we had to build

LiteFunctions architecture
Operator
Reconciles functions and wiring in the cluster from CRDs.
Gateway
TLS and routing from clients into invokes.
Ingestor
HTTP entry: handles sync invokes; publishes async work to the broker.
Runtimes
Runs your functions—Go, Rust, Python, TypeScript, Lua—behind one invoke contract.
Dependencies & state
Crunchy Postgres
Crunchy Postgres
Durable state and metadata
Valkey
Valkey
Cache, queues, fast key/value
NATS
Broker (NATS)
Pub/sub and event streams
7

Portal: control plane

QR: portal handlers
portal handlers
Portal is where platform intent starts: users, projects, and VCS automation.
Identity
WebAuthn auth, session boundaries, project access.
Projects
Create project, set roles, sync functions from repo.
VCS + Actions
Create repo, add webhook, push vendor workflow, track runs.
8

WebAuthn contracts

QR: auth spec
auth/spec.go
Webauthn, where security and convinience don't have to be at odds
Challenge state, credential writes, and user session are explicit interfaces.
Pseudocode
begin registration -> store challenge session
finish registration -> verify attestation -> persist credential
login -> verify assertion -> create user session cookie
// internal/auth/spec.go
type PasskeyUser interface {
  webauthn.User
  AddCredential(*webauthn.Credential) error
  UpdateCredential(*webauthn.Credential) error
}
// internal/auth/spec.go
type PasskeyStore interface {
  GetOrCreateUser(userName string) (PasskeyUser, error)
  SaveCredential(user PasskeyUser, cred *webauthn.Credential) error
  GetSession(token string) (webauthn.SessionData, bool)
  SaveSession(username, token string, data webauthn.SessionData) error
  SessionStore
}
9

DB schema + sqlc

QR: query.sql
query.sql
SQLc, the perfect database abstraction
DDL
-- migrations/00001_init_schema.sql
-- +goose Up
-- +goose StatementBegin
CREATE TABLE projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL UNIQUE,
description TEXT,
created_by BYTEA NOT NULL
);
-- +goose StatementEnd
          goose up
DML
-- internal/project/adaptors/query.sql
-- name: CreateProject :one
INSERT INTO projects (name, description, created_by)
VALUES ($1, $2, $3)
RETURNING *;

-- name: GetProjectByID :one
SELECT * FROM projects WHERE id = $1;
          sqlc generate
Typed code
// generated shape (query.sql.go)
type CreateProjectParams struct {
Name string
Description pgtype.Text
CreatedBy []byte
}
func (q *Queries) CreateProject(ctx context.Context, arg CreateProjectParams) (Project, error)
Let AI draft SQL, generate type safe adaptors.
10

Git plumbing (billy/memfs)

QR: repo spec
repo/spec.go
Git plumbing in-memory: fast, isolated, and no host FS coupling.
// internal/project/repo/spec.go
type GitRepo struct {
  Storage *memory.Storage
  Fs      billy.Filesystem
  Repo    *git.Repository
}

func NewGitRepo(project string, branch *string) (*GitRepo, error) {
  fs := memfs.New()               // in-memory billy FS
  r := GitRepo{
    Fs:      fs,                  // no disk dependency
    Storage: memory.NewStorage(), // in-memory git object db
    Options: &git.CloneOptions{ URL: ... },
  }
  // SetupAuth -> Clone -> cache per project
}
// pseudocode path
repo := NewGitRepo(project)
repo.Fs.Create(path)       // write function file in memfs
repo.Commit(path)          // git commit signature
repo.Push()                // vendor remote
wins stateless, no file IO | risks process local, redundancy / drift
11

Vendor interfaces (GitHub/Gitea)

QR: vendor interface
vendors/spec.go
Apart from standard git plumbing, we need to interface with the vendor directly for certain stuff like createRepo, addWebhook, etc
standard issue strategy pattern to abstract the integration from the callers
// internal/project/vendors/spec.go
type VendorClient interface {
  CreateRepo(ctx context.Context, opts CreateRepoOptions) (*Repository, error)
  DeleteRepo(ctx context.Context, owner, repo string) error
  AddWebhook(ctx context.Context, owner, repo string, opts WebhookOptions) (*Webhook, error)
  AddWorkflow(project string) error
  GetActionsProgress(ctx context.Context, owner, repo string, opts ActionsProgressOptions) (*ActionsProgress, error)
}

func NewVendorClient() (VendorClient, error) {
  switch pkg.Cfg.VcsVendor {
  case "github": return NewGitHubClient(pkg.Cfg.VcsToken), nil
  case "gitea":  return NewGiteaClient(pkg.Cfg.VcsBaseUrl, pkg.Cfg.VcsToken), nil
  default:       return nil, fmt.Errorf("unsupported vendor")
  }
}
Why this matters: handler logic stays stable while vendor backends swap behind interfaces.
12

Ingestor: decouple accept from execution

QR: request.go on GitHub
Ingestor data flow

Clients hit the ingestor first—sync calls map to a straight HTTP request/response path.

Async work goes over the broker, the ingestor stays fast and dumb, publishes off the HTTP hot path, and runtimes subscribe and do the work

The ingestor can also request the operator to warm a function, basically set is_active on the Function CRD to true so reconcile runs.

ingestor/pkg/broker/request.go
WebSocket → NATS · Req + Produce · subject …exec.lang.reqId
package broker

type Req struct {
	Project, Name, Lang, ReqId string
}

func Produce(
	nc *nats.Conn,
	w http.ResponseWriter,
	r *http.Request,
	lang string,
) (*websocket.Conn, *Req, error) {
	// Upgrade; goroutine: ReadMessage → Publish
}
13

Sync invocation flow

Read r.Body, then write headers and the response body on w in one goroutine—no channels; the “plate” is what you Encode or Write.

package pkg

import (
	"encoding/json"
	"net/http"
)

func Handle(w http.ResponseWriter, r *http.Request) {
	var req map[string]any
	_ = json.NewDecoder(r.Body).Decode(&req)
	resp := map[string]any{"ok": true, "echo": req}

	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusOK)
	_ = json.NewEncoder(w).Encode(resp) // response body
}
14

There u go

we got ourselves an api
15

Demo

Function + route + language in the editor — save, and commit. Vim motions btw
16

Async invocation flow

Chunks arrive on input; you spin a goroutine that sends on out—like plates from the belt.

Runtime / WS can drain out to the wire—decoupled from one HTTP read.

package pkg

func StreamHandler(input <-chan []byte) <-chan []byte {
	out := make(chan []byte)
	go func() {
		defer close(out)
		for raw := range input {
			c := transform(raw)
			out <- c // may block (backpressure)
		}
	}()
	return out
}

func transform(b []byte) []byte { return b }
17

Demo

Same stack — async path over NATS; WS / stream where the runtime exposes it.
18

Static runtimes

QR: Go consumer
go consumer
QR: Rust consumer
rust consumer

Ship small static binaries in distroless or scratch—images stay tiny (often tens of MB, few layers). Each function gets its own dedicated consumer pod that starts fast.

Scale each function up or down independently without dragging a shared interpreter along.

Gotcha · runtime
Since Go 1.22, default GOMAXPROCS follows Linux cgroup CPU limits—great in containers, but it breaks old assumptions that GOMAXPROCS tracked the whole host. Re-check load tests and pprof if you sized workers as “all CPUs.”
Gotcha · scratch & deps
FROM scratch means no libc—for HTTPS you need pure Go TLS (crypto/tls) and bundle CA certs if you verify remotes. Avoid CGO wrappers (e.g. libgit2): we used go-git with a billy-style filesystem so Git stays pure Go and the binary stays static-friendly. Same idea for any dep: prefer stdlib / pure Go over .so shims.
19

Dynamic runtimes

QR: Python consumer
py consumer

Python / JS share a long-lived runtime per project: pull sources from VCS, then hot-reload into the running process—iteration without a full image rebuild every time.

Tradeoff: larger images (hundreds of MB, many layers) and slower cold paths than static binaries—balanced by that reload loop.

pseudo · git event → reload
# Python
async def on_git_update(event):
    await sync_repo() # fetch / checkout
    importlib.invalidate_caches()
    mod = importlib.reload(sys.modules["functions." + name])
    # register handler; next request uses new code

// Node
async function onGitUpdate() {
  await git.pull();
  delete require.cache[require.resolve("./functions/" + name)];
  handlers.set(name, require("./functions/" + name));
}
Gotcha
Python: importlib + sys.modules—stale modules after hot swap; invalidate deliberately. GIL doesn’t fix races on shared globals across concurrent requests. JS (Node): require cache—bust or version modules on reload; one event loop, but shared mutable state still bites under load.
20

Operator: desired state -> running state

An operator basically lets us have Kubernetes understand a custom YAML and do what we need to on the cluster
21

The reconcile loop

Recover cleanly from partial failures without duplicating state.

QR: operator on GitHub
internal/controller/function_controller.go
Reconcile is the heart: read desired state from the API (backed by etcd), then create/update/delete Deployments and Services to match your Function CRD.
package controller

type FunctionReconciler struct {
	client.Client
	Scheme *runtime.Scheme
}

func (r *FunctionReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
	// Get Function; early-exit on NotFound
	// If !IsActive: maybe delete Deployment+Service (skip if shared runtime still in use)
	// If active: NewDeployment / NewService; SetControllerReference; Create or Update
	// Patch Function.Spec.DeProvisionTime (keep-warm)
}
Reconcile path function activation spin up deploy / HPA map services set deprovision time cleanup
22

Packaging it all

version control Git
CI Jenkins
CD Argo CD
orchestration Kubernetes
pubsub NATS
state
Postgres Redis
traffic Istio
runtime Helm
Compute + traffic + state packaged as one
helm install litefunctions oci://registry-1.docker.io/ashupednekar535/litefunctions
Artifact Hub listing Artifact Hub QR
23

What's next

Explore stronger isolation for multi-tenant function execution
Container vs microVM vs unikernel (for function runtimes) Containers MicroVMs Unikernels today Firecracker research • Fast startup, mature tooling • Shared kernel boundary • Great DX, weaker tenant isolation • VM boundary per workload • Better isolation than containers • Higher boot/memory overhead • App + minimal OS in one image • Very small footprint possible • Tooling/debug story less mature Best when: Cost + developer speed dominate. Single-tenant or trusted workloads. Best when: Need stronger isolation per function without going full custom kernel. Best when: You optimize deeply for footprint and can invest in ops tooling.
Inspiration: NanoVMs Go unikernel tutorial and Firecracker-style microVM deployments as future runtime experiments.
24

Contributions welcome

GitHub issues for litefunctions
QR: github.com/ashupednekar/litefunctions github.com/ashupednekar/litefunctions
function packages
gateway ingress policies
HTTP over pubsub
alternative runtimes like Firecracker / microVMs / unikernels
…and much more
issues / PRs welcome 👋
25

Thank you

That was it folks… questions?
come hang with me over at…
LinkedIn QR
X QR
Threads QR
Bluesky QR
👋
26
Use the left and right arrow keys or click the left and right edges of the page to navigate between slides.
(Press 'H' or navigate to hide this message.)