Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,15 @@ PG_PASSWORD=oos4ookahc7si4eejooFee9teePeiqu3quuihuo9Ahhoo7eegh4ohj4fi7gai3vu

COLIMA_MEMORY=12
COLIMA_CPU=4

# Optional: pin the pg-bouncer container's static IP.
#
# Default (when this is unset): the script reads
# incus network get incusbr0 ipv4.address
# and uses .10 inside whatever subnet incusbr0 happens to be on. So with
# a 192.168.100.0/24 bridge you get 192.168.100.10; with a 10.224.165.0/24
# bridge you get 10.224.165.10; etc.
#
# Override here only if you need a specific address (e.g. you already have
# something on .10 in that subnet).
# PG_BOUNCER_IP=
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
/.zed
.env
/var/active-slot
96 changes: 76 additions & 20 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
packages := incus colima jq

.PHONY: deps
deps:
HOMEBREW_NO_AUTO_UPDATE=1 brew install incus colima
HOMEBREW_NO_AUTO_UPDATE=1 brew upgrade incus colima
HOMEBREW_NO_AUTO_UPDATE=1 brew install $(packages)
HOMEBREW_NO_AUTO_UPDATE=1 brew upgrade $(packages)

-include .env
export
Expand All @@ -23,20 +25,50 @@ status:

.PHONY: stop
stop:
colima stop \
--verbose
colima stop --verbose

PG_DEV := scripts/pg-dev-local

# ----- lifecycle ----------------------------------------------------------

.PHONY: pg.up
pg.up:
$(PG_DEV) up
$(MAKE) pg.logs

.PHONY: pg.down
pg.down:
$(PG_DEV) down

.PHONY: pg.status
pg.status:
$(PG_DEV) status

.PHONY: pg.endpoint
pg.endpoint:
@$(PG_DEV) endpoint

.PHONY: pg.promote
pg.promote:
$(PG_DEV) promote

# ----- active backend -----------------------------------------------------

.PHONY: pg.psql
pg.psql:
$(PG_DEV) psql

.PHONY: pg.shell
pg.shell:
$(PG_DEV) shell

.PHONY: pg.ip
pg.ip:
@$(PG_DEV) ip

.PHONY: pg.logs
pg.logs:
$(PG_DEV) logs

.PHONY: pg.snapshot
pg.snapshot:
$(PG_DEV) snapshot $(name) $(if $(force),--force,)
Expand All @@ -53,21 +85,47 @@ pg.restore-last:
pg.snapshots:
$(PG_DEV) snapshots

.PHONY: pg.ip
pg.ip:
$(PG_DEV) ip
# ----- staging backend ----------------------------------------------------

.PHONY: pg.psql
pg.psql:
$(PG_DEV) psql
.PHONY: pg.staging.psql
pg.staging.psql:
$(PG_DEV) staging.psql

.PHONY: pg.shell
pg.shell:
$(PG_DEV) shell
.PHONY: pg.staging.shell
pg.staging.shell:
$(PG_DEV) staging.shell

.PHONY: pg.status
pg.status:
$(PG_DEV) status
.PHONY: pg.staging.logs
pg.staging.logs:
$(PG_DEV) staging.logs

.PHONY: pg.staging.snapshot
pg.staging.snapshot:
$(PG_DEV) staging.snapshot $(name) $(if $(force),--force,)

.PHONY: pg.staging.restore
pg.staging.restore:
$(PG_DEV) staging.restore $(name)

.PHONY: pg.staging.restore-last
pg.staging.restore-last:
$(PG_DEV) staging.restore-last

.PHONY: pg.staging.reset
pg.staging.reset:
$(PG_DEV) staging.reset

# ----- bouncer ------------------------------------------------------------

.PHONY: pg.bouncer.logs
pg.bouncer.logs:
$(PG_DEV) bouncer.logs

.PHONY: pg.bouncer.reload
pg.bouncer.reload:
$(PG_DEV) bouncer.reload

# ----- export / import (active backend) -----------------------------------

.PHONY: pg.export
pg.export:
Expand All @@ -77,9 +135,7 @@ pg.export:
pg.import-last:
$(PG_DEV) import-last

.PHONY: pg.logs
pg.logs:
incus exec pg-dev -- tail -f /var/log/postgresql/postgresql-17-main.log
# ----- colima ------------------------------------------------------------

.PHONY: delete
delete:
Expand Down
142 changes: 118 additions & 24 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,53 +1,147 @@
# Summary

Working with Postgres database dumps can be painful, if you want to test database data- or schema migrations. Since `pg_restore` can be slow if there are complex indexes like `GIN` indexes.
Working with PostgreSQL dumps is painful when `pg_restore` takes ~90 minutes
and your dev database is unreachable for the whole window. This repo wraps
a colima VM running incus + ZFS to give you:

On Linux there is tooling to solve this problem: lightweight system containers via `incus` (backed by LXC, using kernel namespaces and cgroups) and file system snapshots via ZFS. Both are available on macOS through `colima`, which runs a Linux VM transparently in the background.
- **two long-lived Postgres backends** (`pg-dev-a`, `pg-dev-b`) with
independent ZFS snapshot timelines, so already-loaded states stay cheap to
checkpoint and restore;
- **one pgbouncer container** with two listeners on a stable IP, so
`pg_restore` can load a new dump on the staging backend through one port
while your app keeps querying the active backend on the other;
- **a single command** (`make pg.promote`) to atomically swap which backend
is "active" — clients keep their TCP connection, the dataset underneath
changes.

This repository provides some scripting to serve a daily loop of creating and restoring snapshots of a Postgres instance.
Design rationale lives in [SPEC.md](SPEC.md). The scenarios below cover the
day-to-day surface.

# Usage
# Scenarios

## Once
## Once, after cloning

```shell
make deps
make deps # incus, colima, jq on macOS
cp .env.example .env # edit PG_* if you don't like the defaults
make start # boot colima with the incus runtime
make pg.up # provision pg-dev-a, pg-dev-b, pg-bouncer (~15 min)
make pg.endpoint # prints connection info + a ready-to-paste .pgpass line
```

## Daily
`make pg.endpoint` prints something like (with `<bouncer-ip>` being .10 in
whatever subnet `incus network get incusbr0 ipv4.address` reports — e.g.
`192.168.100.10` if your bridge is on `192.168.100.0/24`):

```
active host=<bouncer-ip> port=5432 dbname=<PG_DB> (current data)
staging host=<bouncer-ip> port=5433 dbname=<PG_DB> (import target / opposite of active)

.pgpass line (one line, covers both ports):
<bouncer-ip>:*:*:<PG_USER>:<PG_PASSWORD>
```

Paste that single line into `~/.pgpass` and you're done with auth forever.
The bouncer IP is pinned at the device level — it survives reboots,
promotes, snapshot restores, everything short of `make pg.down`. Override
the auto-pick by setting `PG_BOUNCER_IP` in `.env`.

## Day to day: querying the database

Port **5432** always means *current data*. Pick whatever client you like:

```shell
# Start colima
make pg.up
psql -h <bouncer-ip> -p 5432 -d $PG_DB # any psql, any app
make pg.psql # quick shell via incus exec
make pg.logs # tail postgres logs
```

Port **5433** is the staging backend — the opposite slot. Useful for
ad-hoc exploration of "the other dataset" or for verifying a dump
mid-import.

## Importing a fresh dump (the headline workflow)

This is what this repo exists for. The import runs on staging through the
bouncer; the active backend keeps serving live queries the entire time.

```shell
# 1. Wipe staging back to its clean `initial` snapshot.
make pg.staging.reset

# 2. Restore the dump through the bouncer's staging port (:5433).
pg_restore --host=<bouncer-ip> --port=5433 --dbname=$PG_DB \
--jobs=4 your-dump.pgdump # ~90 min, no blocking

# 3. Sanity-check the loaded data while still on staging.
psql -h <bouncer-ip> -p 5433 -d $PG_DB -c '\dt'

# Create a snapshot
make pg.snapshot name=$(date +%Y-%m-%dT%H-%M-%S)_dump_import
# 4. Checkpoint the loaded state on the staging slot.
make pg.staging.snapshot name=initial-loaded

# List snapshots
make pg.snapshots
# 5. Promote. Sub-second; clients keep their TCP connections through the
# bouncer; the dataset underneath flips.
make pg.promote
```

After `pg.promote`, apps on :5432 see the freshly imported data. The
previously active backend is now reachable on :5433 with its full snapshot
timeline intact, ready to be rolled back to or wiped for the next import.

# Restore a named snapshot, drops potential following after confirmation
make pg.restore name=initial
## Rolling back a bad import

# Restore the most recent snapshot without confirmation
make pg.restore-last
You promoted, ran the app, and the new data is broken. The previous backend
is untouched on :5433. One command undoes the promote:

# Tail postgres logs
make pg.logs
```shell
make pg.promote # flips back — :5432 now points at the old data again
```

No data is regenerated. The pointer just inverts.

## Snapshotting / restoring during normal dev

Each backend has its own snapshot timeline. The unprefixed commands act on
whichever slot is currently active:

```shell
make pg.snapshot name=$(date +%Y-%m-%dT%H-%M-%S)_before-migration
# ... run a destructive migration ...
make pg.restore name=$(date +%Y-%m-%dT%H-%M-%S)_before-migration
make pg.restore-last # most recent snapshot, no confirmation
make pg.snapshots # list
```

## Special
The `pg.staging.*` family mirrors these for the staging slot, if you want
to stage multiple checkpoints before a promote.

Snapshots are bound to one colima instance (`make pg.up`). Destroying the instance will kill all snapshots. You may export and import snapshots, but while faster than `pg_restore` in my case, it still is _not fast_.
## Inspecting state

```shell
make pg.export
time make recreate pg.import-last
make pg.status # active slot + container states
make pg.endpoint # both ports with their roles + .pgpass line
make pg.bouncer.logs # tail both pgbouncer instances
```

## Tearing down

```shell
make pg.down # delete pg-dev-a, pg-dev-b, pg-bouncer (irreversible)
make stop # stop colima
make delete # nuke colima entirely; rebuild fresh with `make recreate`
```

Snapshots live inside the colima VM. `make delete` loses them all.
`make pg.export` / `make pg.import-last` serialise the *active* backend
(data + snapshots) to a tarball under `var/`, which survives a colima
rebuild — slower than ZFS snapshots, but the only way out of the VM.

# Questions

## Why a `Makefile` if you have a script

Because I like to have the shell autocompletion of `make`.
Because I like the shell autocompletion of `make`.

## Where's the design rationale

[SPEC.md](SPEC.md).
Loading
Loading