Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7184e7b578 | ||
|
|
e2afacb5fc | ||
|
|
da8f02310a | ||
|
|
ccc49ca7d1 |
@@ -1,6 +1,6 @@
|
||||
# supaspin
|
||||
|
||||
Version 1.6.1
|
||||
Version 1.6.5
|
||||
|
||||
Create, list, show and delete throwaway [Supabase](https://supabase.com) instances on a
|
||||
single-node [microk8s](https://microk8s.io) server, from a terminal menu.
|
||||
@@ -16,8 +16,8 @@ endangering them. Nothing in it is specific to any one app.
|
||||
| Web API | `https://<slot hostname>` (auth, rest, graphql, realtime, storage, functions); the certificate is from Let's Encrypt when that is enabled, otherwise self-signed |
|
||||
| Postgres | `<LAN IP>:544N` (instance 1 → 5441, 2 → 5442, …); which addresses listen is set by **Port bind** |
|
||||
| Studio (admin UI) | `http://<LAN IP>:300N` (3001, 3002, …), not on the public hostname |
|
||||
| Disk | fixed-size ext4 image files, mounted at boot: **db 2 GB, storage 1 GB, every other volume 256 MB** |
|
||||
| Memory | limits add up to **9 GiB** per instance, requests to 1.875 GiB (table below) |
|
||||
| Disk | fixed-size ext4 image files, mounted at boot: **db 12 GB, storage 1 GB, every other volume 256 MB** |
|
||||
| Memory | limits add up to **11 GiB** per instance, requests to 2.375 GiB (table below) |
|
||||
| Priority | pods run at priority -100 (`supaspin-low`); when the server runs short of memory they are evicted first and never evict anyone else |
|
||||
| Secrets | generated fresh per instance: JWT secret, anon and service keys, Postgres password, Studio password, and the internal keys |
|
||||
| Uploads | stored in the instance's own storage image (no MinIO); deleted with the instance |
|
||||
@@ -33,7 +33,9 @@ of instances at once. Numbers are reused: create picks the lowest free one.
|
||||
|
||||
- A Debian/Ubuntu server with **microk8s** (or any Kubernetes with `kubectl` and `helm` on the
|
||||
path), with an ingress controller installed: **ingress-nginx** or **Traefik** (the microk8s
|
||||
`ingress` addon is either, depending on the version).
|
||||
`ingress` addon is either, depending on the version). With **Traefik**, its CRDs must be present
|
||||
on the cluster — they come with the Traefik installation itself; supaspin creates `Middleware`
|
||||
objects and does not install cluster-wide types.
|
||||
- With **Let's Encrypt** enabled: **cert-manager** installed, DNS `A` records for every hostname
|
||||
slot pointing at the server, and ports 80 and 443 forwarded to it (Let's Encrypt fetches a test
|
||||
file over port 80 to issue each certificate). Without it: no cert-manager, no public DNS — each
|
||||
@@ -77,7 +79,7 @@ sudo supaspin
|
||||
| Server LAN IP | address shown for the Postgres and Studio ports (detected) |
|
||||
| Port bind | `lan` = LAN IP only (default; keeps Postgres and the login-less Studio off a public interface), `all` = every interface incl. localhost (for a box behind NAT running its own apps), `local` = localhost only |
|
||||
| Ingress class | usually `public` on microk8s |
|
||||
| Ingress controller | `traefik` or `nginx`; Traefik instances get a Middleware that caps uploads at 50 MiB, nginx gets the equivalent annotations |
|
||||
| Ingress controller | `traefik` or `nginx`; every instance's Ingress carries the nginx annotations (50 MiB upload cap, long timeouts) and, in Traefik mode, additionally points at a `Middleware` object with the same 50 MiB cap - so the cap is enforced no matter which of the two actually routes the hostname |
|
||||
| Let's Encrypt | `y` fetches a real certificate per hostname (needs public DNS, ports 80/443, cert-manager); `n` gives every instance a self-signed certificate and creates no cert-manager objects |
|
||||
| Let's Encrypt e-mail | for certificate expiry notices (asked only when Let's Encrypt is on) |
|
||||
| Hostname slots | comma-separated; a name with a number in it keys that slot (`supa4.didgibot.com` = slot 4, so instance 4 gets it with ports 5444/3004), a plain name takes its position in the list |
|
||||
@@ -115,12 +117,12 @@ sudo supaspin env N # the full .env (contains secrets)
|
||||
sudo supaspin tunnel N # the tunnel commands, step by step
|
||||
sudo supaspin push-env N TARGET [--port P] [--key FILE] [--dry-run] # see "Copying an instance's values"
|
||||
sudo supaspin delete N [--yes]
|
||||
sudo supaspin upgrade [N] # apply this version's memory caps to running instances
|
||||
sudo supaspin upgrade [N] # apply this version's memory caps and log retention to running instances
|
||||
sudo supaspin suspend N # stop an instance, keep its data (see "Suspending an instance")
|
||||
sudo supaspin resume N # start it again
|
||||
sudo supaspin mail # change the saved default mail settings
|
||||
sudo supaspin setup # change settings
|
||||
supaspin --version # prints "supaspin 1.3.1"
|
||||
supaspin --version # prints "supaspin X.Y.Z"
|
||||
```
|
||||
|
||||
### Connecting your app
|
||||
@@ -268,13 +270,13 @@ stores the new memory limits and leaves it asleep; they take effect when you res
|
||||
|
||||
| Service | Request | Limit | Highest use measured |
|
||||
|---|---|---|---|
|
||||
| db | 512 MiB | 2048 MiB | 273 MiB |
|
||||
| db | 1024 MiB | 4096 MiB | 273 MiB |
|
||||
| analytics | 128 MiB | 1536 MiB | 694 MiB |
|
||||
| vector | 128 MiB | 512 MiB | 290 MiB |
|
||||
| kong | 128 MiB | 768 MiB | 269 MiB |
|
||||
| realtime | 128 MiB | 768 MiB | 300 MiB |
|
||||
| auth, functions, imgproxy, meta, rest, storage, studio | 128 MiB each | 512 MiB each | 203 MiB (studio) |
|
||||
| **Total** | **1.875 GiB** | **9 GiB** | |
|
||||
| **Total** | **2.375 GiB** | **11 GiB** | |
|
||||
|
||||
The request is what the scheduler sets aside for a service and can never be given to anything else.
|
||||
The limit is the ceiling the kernel kills the container at. They do different jobs here.
|
||||
@@ -300,7 +302,9 @@ every delivery with an error, and vector was killed against its 1024 MiB ceiling
|
||||
minutes with the undeliverable backlog. The fix is log retention: every instance gets a pg_cron
|
||||
job (set up on create, upgrade and resume) that deletes analytics logs older than **2 days** every
|
||||
night. pg_cron in this image only runs jobs from the `postgres` database, so the job lives there
|
||||
and reaches the log tables in `_supabase` over a local dblink connection. That bounds both the
|
||||
and reaches the log tables in `_supabase` over a local dblink connection. The dblink extension
|
||||
and the helper function live in the `extensions` schema, where the app's database owner has no
|
||||
rights, so instance code can never call them. That bounds both the
|
||||
db image and vector's backlog, so vector is back to the 512 MiB ceiling the production supabase
|
||||
charts carry.
|
||||
|
||||
@@ -317,6 +321,9 @@ pods are replaced service by service and nothing else about the instance changes
|
||||
- **Certificate stays pending.** The hostname's DNS record must point at the server and port 80 must
|
||||
reach the ingress: `kubectl get certificate,order,challenge -n supaspin-N`.
|
||||
- **"no free hostname slot".** Delete an instance or add a slot in Settings (and its DNS record).
|
||||
- **Create failed** with `no matches for kind "Middleware" in version "traefik.io/v1alpha1"`. The
|
||||
config says Traefik but the cluster has no Traefik CRDs: either install Traefik (its CRDs come
|
||||
with it) or set the ingress controller to `nginx` in Settings.
|
||||
- **Create failed.** `sudo supaspin delete N`, then create again.
|
||||
- **Port already in use.** Postgres and Studio ports are fixed per number (`5440+N`, `3000+N`);
|
||||
another program is using it. Free the port or use another instance number.
|
||||
|
||||
@@ -39,7 +39,7 @@ import tty
|
||||
|
||||
import yaml
|
||||
|
||||
VERSION = "1.6.1"
|
||||
VERSION = "1.6.5"
|
||||
CONFIG_PATH = os.environ.get("SUPASPIN_CONFIG", "/etc/supaspin/config.yaml")
|
||||
STATE_DIR = os.environ.get("SUPASPIN_STATE", "/var/lib/supaspin")
|
||||
CHART_REPO = "https://supabase-community.github.io/supabase-kubernetes"
|
||||
@@ -53,7 +53,12 @@ CERTS_NS = "supaspin-certs"
|
||||
|
||||
# Image cap per volume, in MiB. The chart's separate imgproxy volume is not used: imgproxy has to
|
||||
# read the uploaded files, so it mounts the storage volume instead (see instance_values).
|
||||
VOLUMES = {"db": 2048, "storage": 1024, "functions": 256,
|
||||
# The db image is 6 GB because of the analytics logs: browser-test traffic wrote about 1.2 GB of
|
||||
# logs a day on instance 2 (2026-10-14), the nightly cleanup keeps LOG_RETENTION of them, and the
|
||||
# write-ahead log may take up to 1 GB on top. That peaks near 4.5 GB; 2 GB filled up on instance 4.
|
||||
# Doubled to 12 GB on 2026-11-03 at Igor's request: instance 4 had reached 4.7 GB of its 6 GB and
|
||||
# instances kept having to be recreated when the image filled up.
|
||||
VOLUMES = {"db": 12288, "storage": 1024, "functions": 256,
|
||||
"snippets": 256, "deno": 256, "pgsodium": 256}
|
||||
# The user Postgres runs as inside supabase/postgres 17.6.1.136 (`id postgres` in the image).
|
||||
# Production's older image used 2000; do not copy that number.
|
||||
@@ -71,7 +76,8 @@ DB_UID, DB_GID = 100, 101
|
||||
# realtime 269 / 287 / 300 MiB against 384
|
||||
# kong 260 / 269 / 265 MiB against 512
|
||||
# studio 203, storage 187, rest 162 MiB against 256
|
||||
# db 163 / 175 / 273 MiB against 2048 - left alone, it has room
|
||||
# db 163 / 175 / 273 MiB against 2048 - doubled to 1024 / 4096 on 2026-11-03 at Igor's
|
||||
# request, together with the db image, so a larger database has room to work in
|
||||
# On 2026-10-06 instance 3 filled its 2 GB database image with logs: analytics (Logflare) never
|
||||
# deletes what it stored, the db died of it, and vector's undelivered backlog was killed against
|
||||
# the 1024 MiB ceiling every few minutes. The fix is setup_log_retention: a pg_cron job deletes
|
||||
@@ -81,7 +87,7 @@ DB_UID, DB_GID = 100, 101
|
||||
# would take the memory away from everything else on the node straight away.
|
||||
SERVICES = ["db", "analytics", "kong", "auth", "functions", "imgproxy", "meta",
|
||||
"realtime", "rest", "storage", "studio", "vector"]
|
||||
MEMORY = {"db": (512, 2048), "analytics": (128, 1536), "kong": (128, 768), "realtime": (128, 768),
|
||||
MEMORY = {"db": (1024, 4096), "analytics": (128, 1536), "kong": (128, 768), "realtime": (128, 768),
|
||||
"vector": (128, 512)}
|
||||
DEFAULT_MEMORY = (128, 512)
|
||||
|
||||
@@ -189,24 +195,27 @@ def buffering_middleware(n):
|
||||
|
||||
|
||||
def ingress_annotations(cfg, n):
|
||||
"""Ingress annotations for instance n, shaped for the configured ingress controller.
|
||||
With Traefik the upload cap lives in a Middleware object created next to the instance
|
||||
and the ingress points at it; with nginx it is proxy-body-size, and the chart's own
|
||||
rewrite-target default must be nullified or every API path breaks. The cert-manager
|
||||
annotation orders a certificate from the supaspin-letsencrypt issuer; without it the
|
||||
TLS secret keeps whatever supaspin put there (a saved or a self-signed certificate)."""
|
||||
"""Ingress annotations for instance n.
|
||||
Both controllers' annotations are always present: an annotation only takes effect
|
||||
when a controller of that family actually routes the ingress, so on a Traefik
|
||||
cluster the nginx keys sit inert (and vice versa) and nothing needs reconfiguring
|
||||
when the router changes. The upload cap therefore lives in two places that agree:
|
||||
proxy-body-size for nginx, and the Middleware object the traefik annotation points
|
||||
at. The chart's own rewrite-target default must be nullified or every API path
|
||||
breaks. The cert-manager annotation orders a certificate from the
|
||||
supaspin-letsencrypt issuer; without it the TLS secret keeps whatever supaspin put
|
||||
there (a saved or a self-signed certificate)."""
|
||||
anns = {}
|
||||
if cfg.get("lets_encrypt", True):
|
||||
anns["cert-manager.io/cluster-issuer"] = ISSUER
|
||||
if cfg.get("ingress_controller", "nginx") == "traefik":
|
||||
anns["traefik.ingress.kubernetes.io/router.middlewares"] = \
|
||||
f"{namespace(n)}-{buffering_middleware(n)}@kubernetescrd"
|
||||
else:
|
||||
# The chart default rewrites every path to "/", which breaks all APIs; null removes it.
|
||||
anns["nginx.ingress.kubernetes.io/rewrite-target"] = None
|
||||
anns["nginx.ingress.kubernetes.io/proxy-body-size"] = "50m"
|
||||
anns["nginx.ingress.kubernetes.io/proxy-read-timeout"] = "3600"
|
||||
anns["nginx.ingress.kubernetes.io/proxy-send-timeout"] = "3600"
|
||||
if cfg.get("ingress_controller", "nginx") == "traefik":
|
||||
anns["traefik.ingress.kubernetes.io/router.middlewares"] = \
|
||||
f"{namespace(n)}-{buffering_middleware(n)}@kubernetescrd"
|
||||
return anns
|
||||
|
||||
|
||||
|
||||
+14
-11
@@ -109,10 +109,10 @@ class Values(unittest.TestCase):
|
||||
|
||||
def test_memory_caps_add_up(self):
|
||||
req, lim = sp.total_memory_mib()
|
||||
# db 2048, analytics 1536, kong 768, realtime 768, vector 512, the other seven 512 each
|
||||
self.assertEqual(lim, 2048 + 1536 + 768 + 768 + 512 + 7 * 512)
|
||||
self.assertEqual(lim / 1024, 9.0)
|
||||
self.assertEqual(req, 512 + 11 * 128) # 1.875 GiB
|
||||
# db 4096, analytics 1536, kong 768, realtime 768, vector 512, the other seven 512 each
|
||||
self.assertEqual(lim, 4096 + 1536 + 768 + 768 + 512 + 7 * 512)
|
||||
self.assertEqual(lim / 1024, 11.0)
|
||||
self.assertEqual(req, 1024 + 11 * 128) # 2.375 GiB
|
||||
# Measured on three running instances on 2026-09-22; the ceiling has to stay clear of the
|
||||
# highest use, or the kernel kills the container in a loop the way it did on instance 1.
|
||||
self.assertGreaterEqual(sp.MEMORY["analytics"][1], 1536) # used 694 MiB, died at 768
|
||||
@@ -125,8 +125,9 @@ class Values(unittest.TestCase):
|
||||
self.assertGreaterEqual(sp.DEFAULT_MEMORY[1], 512) # studio used 203 against 256
|
||||
# The requests stay where they were: the node is shared with production and reserving
|
||||
# what the instances really use would leave production nothing to schedule into.
|
||||
# Only db's went up, from 512 to 1024 MiB, with its limit (2026-11-03).
|
||||
self.assertTrue(all(m[0] == 128 for s, m in sp.MEMORY.items() if s != "db"))
|
||||
self.assertEqual(req / 1024, 1.875)
|
||||
self.assertEqual(req / 1024, 2.375)
|
||||
for svc in sp.SERVICES:
|
||||
self.assertEqual(self.v["deployment"][svc]["priorityClassName"], "supaspin-low")
|
||||
self.assertIn("memory", self.v["deployment"][svc]["resources"]["limits"])
|
||||
@@ -143,7 +144,7 @@ class Values(unittest.TestCase):
|
||||
|
||||
def test_volume_caps(self):
|
||||
p = self.v["persistence"]
|
||||
self.assertEqual(p["db"]["size"], "2048Mi")
|
||||
self.assertEqual(p["db"]["size"], "12288Mi")
|
||||
self.assertEqual(p["storage"]["size"], "1024Mi")
|
||||
for vol in ("functions", "snippets", "deno", "pgsodium"):
|
||||
self.assertEqual(p[vol]["size"], "256Mi")
|
||||
@@ -216,13 +217,15 @@ class IngressShaping(unittest.TestCase):
|
||||
self.assertEqual(a["nginx.ingress.kubernetes.io/proxy-read-timeout"], "3600")
|
||||
self.assertNotIn("traefik.ingress.kubernetes.io/router.middlewares", a)
|
||||
|
||||
def test_traefik_native(self):
|
||||
def test_traefik_also_keeps_nginx_annotations(self):
|
||||
# The nginx keys must be present in traefik mode too: while nginx routes the
|
||||
# ingress they enforce the cap, and they are inert the day Traefik takes over.
|
||||
a = self.ann({"ingress_controller": "traefik"})
|
||||
self.assertEqual(a["traefik.ingress.kubernetes.io/router.middlewares"],
|
||||
"supaspin-2-supa2-buffering@kubernetescrd")
|
||||
for nginx_key in ("rewrite-target", "proxy-body-size", "proxy-read-timeout",
|
||||
"proxy-send-timeout"):
|
||||
self.assertNotIn(f"nginx.ingress.kubernetes.io/{nginx_key}", a)
|
||||
self.assertIsNone(a["nginx.ingress.kubernetes.io/rewrite-target"])
|
||||
self.assertEqual(a["nginx.ingress.kubernetes.io/proxy-body-size"], "50m")
|
||||
self.assertEqual(a["nginx.ingress.kubernetes.io/proxy-read-timeout"], "3600")
|
||||
self.assertEqual(a["cert-manager.io/cluster-issuer"], "supaspin-letsencrypt")
|
||||
|
||||
def test_lets_encrypt_off_drops_cert_manager_annotation(self):
|
||||
@@ -476,7 +479,7 @@ class Manifests(unittest.TestCase):
|
||||
pv = sp.pv_manifest(CFG, 2, "db")
|
||||
self.assertEqual(pv["spec"]["claimRef"], {"namespace": "supaspin-2", "name": "supa2-supabase-db"})
|
||||
self.assertEqual(pv["spec"]["local"]["path"], "/data/supabase/2/db/data")
|
||||
self.assertEqual(pv["spec"]["capacity"]["storage"], "2048Mi")
|
||||
self.assertEqual(pv["spec"]["capacity"]["storage"], "12288Mi")
|
||||
self.assertEqual(pv["spec"]["persistentVolumeReclaimPolicy"], "Retain")
|
||||
self.assertEqual(pv["spec"]["nodeAffinity"]["required"]["nodeSelectorTerms"][0]
|
||||
["matchExpressions"][0]["values"], ["node1"])
|
||||
|
||||
Reference in New Issue
Block a user