|
| 1 | +# Postgres that survives losing its disk, or its node |
| 2 | + |
| 3 | +Your CVM was just redeployed. Where did your database go? |
| 4 | + |
| 5 | +It's gone. A dstack app volume is cache-grade storage — the developer guide says so — and a |
| 6 | +database sitting on one was never durable, only uninterrupted. This example stops treating the |
| 7 | +disk as the database: Postgres ships every write-ahead log segment to an S3-compatible bucket |
| 8 | +(Cloudflare R2 here) with [wal-g](https://github.com/wal-g/wal-g), and the disk becomes a cache |
| 9 | +you can delete. |
| 10 | + |
| 11 | +Then it goes one step further. The same archive is enough to stand up a second copy of the |
| 12 | +database on a **different node**, one that has never spoken to the first. Kill the first node |
| 13 | +and promote the second. What that costs, in acknowledged writes, is measured below. |
| 14 | + |
| 15 | +The part that makes it a TEE example rather than an ops recipe: **the encryption key is derived |
| 16 | +from the app's identity, not handed to it.** The bucket holds bytes nobody can read — not |
| 17 | +Cloudflare, not you — and the only thing that turns them back into a database is attested code |
| 18 | +running under the same app id. On a second node, that means the same app id on a second node. |
| 19 | + |
| 20 | +## Run it |
| 21 | + |
| 22 | +```bash |
| 23 | +phala deploy -n pg-r2 -c docker-compose.yml \ |
| 24 | + -e AWS_ENDPOINT=https://<account-id>.r2.cloudflarestorage.com \ |
| 25 | + -e AWS_ACCESS_KEY_ID=... -e AWS_SECRET_ACCESS_KEY=... \ |
| 26 | + -e WALG_S3_PREFIX=s3://<bucket>/pg |
| 27 | + |
| 28 | +./verify.sh pg-r2 |
| 29 | +``` |
| 30 | + |
| 31 | +No `POSTGRES_PASSWORD` and no encryption key in that command — both are derived at boot from |
| 32 | +`GetKey` on the guest agent socket. The only secrets you pass are the bucket credentials, which |
| 33 | +belong to Cloudflare's side of the arrangement and cannot be derived. |
| 34 | + |
| 35 | +`PG_ROLE` defaults to `primary`. The other value is `standby`, and both drills below use it. |
| 36 | + |
| 37 | +## Drill 1: the disk |
| 38 | + |
| 39 | +The deployment is not the lesson. This is: |
| 40 | + |
| 41 | +```bash |
| 42 | +# 1. leave a canary |
| 43 | +psql "$DSN" -c "CREATE TABLE canary(t timestamptz); INSERT INTO canary VALUES (now())" |
| 44 | + |
| 45 | +# 2. wait for the segment to land (archive_timeout, 60s by default), then destroy the disk |
| 46 | +phala ssh pg-r2 -- 'docker rm -f $(docker ps -q); docker volume rm <app>_pgdata' |
| 47 | + |
| 48 | +# 3. bring the same app back as a standby, built from the archive alone |
| 49 | +phala deploy --cvm-id pg-r2 -c docker-compose.yml -e PG_ROLE=standby \ |
| 50 | + -e AWS_ENDPOINT=... -e AWS_ACCESS_KEY_ID=... -e AWS_SECRET_ACCESS_KEY=... \ |
| 51 | + -e WALG_S3_PREFIX=s3://<bucket>/pg |
| 52 | + |
| 53 | +# 4. it comes up read-only, replaying the archive; make it the primary again |
| 54 | +psql "$DSN" -c "SELECT pg_promote()" |
| 55 | + |
| 56 | +# 5. the canary is still there |
| 57 | +psql "$DSN" -c "SELECT * FROM canary" |
| 58 | +``` |
| 59 | + |
| 60 | +Nothing was copied from the old node and no key was carried across. The app re-derived the key |
| 61 | +because it is the same app. |
| 62 | + |
| 63 | +## Drill 2: the node |
| 64 | + |
| 65 | +Same idea, but the second copy is built on another node *before* anything goes wrong, and the |
| 66 | +first node is killed the hard way while a client is writing to it. |
| 67 | + |
| 68 | +```bash |
| 69 | +# 1. a standby on a second node, sharing the primary's app id |
| 70 | +cat > standby.env <<EOF |
| 71 | +PG_ROLE=standby |
| 72 | +AWS_ENDPOINT=https://<account-id>.r2.cloudflarestorage.com |
| 73 | +AWS_ACCESS_KEY_ID=... |
| 74 | +AWS_SECRET_ACCESS_KEY=... |
| 75 | +WALG_S3_PREFIX=s3://<bucket>/pg |
| 76 | +EOF |
| 77 | +phala cvms replicate pg-r2 --node-id <other-node> -e standby.env |
| 78 | +``` |
| 79 | + |
| 80 | +`replicate` creates a second instance of the *same app* (same app id, same compose hash) on |
| 81 | +the node you name. It gets the bucket credentials from the env file because those cannot be |
| 82 | +derived. It does not get the archive key. It boots, asks `GetKey` for `/pg-r2/walg/v1`, gets |
| 83 | +the same 32 bytes the primary got, and `wal-g backup-fetch` reads the primary's encrypted |
| 84 | +archive with a key nobody handed it. Then it sits in recovery pulling each new segment as it |
| 85 | +lands. |
| 86 | + |
| 87 | +```bash |
| 88 | +# 2. confirm it owes the primary nothing |
| 89 | +psql "$STANDBY_DSN" -tAc "SHOW primary_conninfo" # empty |
| 90 | +psql "$STANDBY_DSN" -tAc "SELECT count(*) FROM pg_stat_activity WHERE backend_type='walreceiver'" # 0 |
| 91 | +psql "$STANDBY_DSN" -tAc "SELECT pg_is_in_recovery()" # t |
| 92 | + |
| 93 | +# 3. write through the gateway, recording every id the server acknowledged |
| 94 | +yes 'INSERT INTO drill DEFAULT VALUES RETURNING id;' \ |
| 95 | + | psql "$DSN" -tAq -v ON_ERROR_STOP=1 | grep -E '^[0-9]+$' > acked.txt & |
| 96 | + |
| 97 | +# 4. mid-write, SIGKILL the primary. No archive flush, no restart. |
| 98 | +phala ssh pg-r2 -- 'docker rm -f $(docker ps -q)' |
| 99 | + |
| 100 | +# 5. promote the standby and count what did not make it |
| 101 | +psql "$STANDBY_DSN" -c "SELECT pg_promote(true, 120)" |
| 102 | +psql "$STANDBY_DSN" -tAc "SELECT id FROM drill ORDER BY id" > survived.txt |
| 103 | +comm -23 <(sort -u acked.txt) <(sort -u survived.txt) | wc -l |
| 104 | + |
| 105 | +# 6. the old primary is a dead node now; treat it as one |
| 106 | +phala cvms delete pg-r2 --force |
| 107 | +``` |
| 108 | + |
| 109 | +The standby's DSN is the primary's app id on the other node's gateway; `phala cvms list` will |
| 110 | +show you both instances. Step 3 goes through the gateway on purpose: an ack is only an ack if |
| 111 | +the client received it, and the client is the only place that survives the primary dying. |
| 112 | + |
| 113 | +> [!WARNING] |
| 114 | +> **A different app cannot read this archive.** `GetKey` derives from app identity, so deploying |
| 115 | +> this compose as a *new* CVM produces a different key and wal-g fails with `corrupted chunk` — |
| 116 | +> that is decryption failing, not a damaged object. Measured on two CVMs running this exact |
| 117 | +> example: |
| 118 | +> |
| 119 | +> ``` |
| 120 | +> pg-r2-example /pg-r2/walg/v1 -> 736c54f314defc07… |
| 121 | +> pg-r2-restored /pg-r2/walg/v1 -> 160f640db26d2529… |
| 122 | +> ``` |
| 123 | +> |
| 124 | +> `phala cvms replicate` is the supported way to get a second instance under the same app id. |
| 125 | +> The alternatives are `phala deploy --custom-app-id <id> --nonce <n>`, or a supplied |
| 126 | +> `WALG_LIBSODIUM_KEY` — and the moment you supply one, someone outside the enclave is holding |
| 127 | +> the key you were trying not to have. Decide before your first backup, not after your last. |
| 128 | +
|
| 129 | +## What `verify.sh` proves |
| 130 | +
|
| 131 | +It is handed nothing. It re-derives the superuser password and logs in with it, then re-derives |
| 132 | +the archive key — which it must, because without it wal-g cannot read the bucket at all. |
| 133 | +
|
| 134 | +``` |
| 135 | +== 1. keys are derived, not stored |
| 136 | + superuser path, twice : b60a400df24a9af7… / b60a400df24a9af7… same |
| 137 | + archive-key path : 736c54f314defc07… unrelated |
| 138 | + logging in with the re-derived password: postgres |
| 139 | + |
| 140 | +== 2. the archive is current |
| 141 | + segments archived : 6 (0 failed) |
| 142 | + newest segment is : 72s old |
| 143 | + waiting to archive : 0 .ready files |
| 144 | + |
| 145 | +== 3. the bucket holds ciphertext |
| 146 | + a live segment on disk : 16d10600010000000000000200000000 |
| 147 | + the object in the bucket: d830dc91fa382fe4f6fea96b3008cfb1 |
| 148 | + the object after wal-g : 16d10600010000000000000600000000 |
| 149 | + WAL magic is 16d1 — the bucket copy does not have it, the wal-g copy does |
| 150 | +``` |
| 151 | +
|
| 152 | +Every WAL page opens with the same magic for a given server version, so the magic is what |
| 153 | +carries across segments; comparing whole heads would prove nothing. |
| 154 | +
|
| 155 | +`verify.sh` reaches the CVM over `phala ssh`, which a replicated instance does not accept (it |
| 156 | +is not issued your SSH key). Run it against the primary. Check the standby over its DSN, as in |
| 157 | +Drill 2 step 2. |
| 158 | +
|
| 159 | +## The numbers, with denominators |
| 160 | +
|
| 161 | +Both drills below were run on real CVMs on two nodes, writing through the gateway at roughly |
| 162 | +12–13 inserts/s, killing the primary with `docker rm -f` (SIGKILL: nothing flushes, nothing |
| 163 | +restarts), then promoting a standby that had derived its own key. |
| 164 | +
|
| 165 | +| `archive_timeout` | acknowledged | lost | as time, at the observed write rate | |
| 166 | +|---|---:|---:|---| |
| 167 | +| 60 s | 771 | **429** | about 33 s of writes | |
| 168 | +| 15 s | 739 | **16** | about 1 s of writes | |
| 169 | +
|
| 170 | +- **RPO — what a failure costs.** Whatever has not reached the archive yet, which is at most |
| 171 | + `archive_timeout` of writes. That bound is the guarantee. The *sample* depends on where the |
| 172 | + kill lands in the archive cycle: 429 used more than half the 60 s window, 16 used almost none |
| 173 | + of the 15 s one, and a rerun would land somewhere else inside each bound. Quote the bound. |
| 174 | + What the two rows do show is that the knob works: lowering the timeout narrows the window, at |
| 175 | + the cost of more and smaller objects in the bucket. |
| 176 | +- **RTO — how long the rebuild takes.** Restore time tracks the *compressed archive*, not the |
| 177 | + logical database: **333 MB of incompressible rows came back in 29.3 s** (about 11 MB/s), while |
| 178 | + 1.18 GB of repetitive rows took 15.2 s because its archive is nearly empty, and an empty |
| 179 | + database hits a fixed-cost floor near 12 s. With a standby already caught up, promotion itself |
| 180 | + is sub-second and the RTO is your detection time. Measure your own data before promising |
| 181 | + anyone either number. |
| 182 | +- **What the idle standby costs.** A caught-up standby polls the bucket for the next segment. |
| 183 | + At Postgres's default 5 s retry that came to ~19 HEAD requests/s, 0 bytes retrieved, about |
| 184 | + 1.7 M class B operations a day — roughly $0.42/day on R2, and the whole bill. The compose |
| 185 | + sets `wal_retrieve_retry_interval=30s`, which cuts it about 6×. Promotion drains the restore |
| 186 | + command first, so this interval does not change what a failover loses. |
| 187 | +
|
| 188 | +## After the failover |
| 189 | +
|
| 190 | +The promoted node is now a primary with one copy of the data and no standby. `archive_mode=on` |
| 191 | +is inert while in recovery and arms the moment `pg_promote` runs, so it resumes shipping WAL to |
| 192 | +the bucket on its own; without that a promoted standby would be a single copy with no archive, |
| 193 | +and the next failure would lose everything. Check it: |
| 194 | +
|
| 195 | +```bash |
| 196 | +psql "$DSN" -tAc "SELECT archived_count, last_archived_time FROM pg_stat_archiver" |
| 197 | +``` |
| 198 | + |
| 199 | +Then give it a standby of its own with another `phala cvms replicate`, and delete the dead |
| 200 | +instance. A restart of the promoted container comes back as a primary: the standby staging |
| 201 | +only runs on an empty data directory, and promotion removed `standby.signal`. |
| 202 | + |
| 203 | +## How it works |
| 204 | + |
| 205 | +- **Keys from `GetKey`.** `POST /GetKey` on `/var/run/dstack.sock` with a path returns 32 bytes of |
| 206 | + hex — exactly wal-g's libsodium key size — plus a signature chain. Same app and path, same key |
| 207 | + on every boot, on every node running the app, and after a total rebuild; different path, |
| 208 | + unrelated key. The derivation path carries the domain separation. |
| 209 | +- **It fails closed.** No socket, or a derivation that returns something unusable, and the |
| 210 | + container exits. A database that would ship plaintext WAL into someone else's bucket should not |
| 211 | + start at all. |
| 212 | +- **The standby never talks to the primary.** `primary_conninfo` is set empty on purpose; |
| 213 | + `restore_command = wal-g wal-fetch` is its only source. Recovery depends on the bucket and |
| 214 | + the app's key, and on nothing that dies with the primary. |
| 215 | +- **Archiving is configured on both roles.** So a promoted standby re-arms without a redeploy. |
| 216 | +- **The base backup is pushed with the derived key in its environment.** WAL alone restores |
| 217 | + nothing; it needs a base to replay onto. The entrypoint pushes one on first boot from the |
| 218 | + process that holds the key. Do not push one by hand with `docker exec ... wal-g backup-push`: |
| 219 | + that shell has no key, wal-g writes an unencrypted base, and the standby's fetch fails with |
| 220 | + `corrupted chunk` because it is trying to decrypt plaintext. |
| 221 | +- **wal-g is pinned by sha256** and verified before it runs. Pulling an unverified binary into a |
| 222 | + measured enclave at boot gives away most of what the measurement was for. |
| 223 | +- **TLS terminates inside the enclave**, so a client talks to Postgres rather than to the gateway. |
| 224 | + Connect with `sslnegotiation=direct` (libpq 17+) — the gateway routes `5432s` by peeking the TLS |
| 225 | + SNI, and libpq's default handshake gets dropped with a misleading "server closed the connection |
| 226 | + unexpectedly". |
| 227 | +- **`archive_timeout` is a startup argument.** It has the highest precedence, so `ALTER SYSTEM` |
| 228 | + cannot change it; pass `-e ARCHIVE_TIMEOUT=15` and redeploy. |
| 229 | + |
| 230 | +## Not covered here |
| 231 | + |
| 232 | +**RPO 0.** An archive-only standby cannot have what the primary never archived. Closing the |
| 233 | +window rather than bounding it means streaming replication between the nodes, which is a |
| 234 | +different design: the standby then depends on a live connection to the primary, and the key |
| 235 | +story has to cover the stream as well as the bucket. |
| 236 | + |
| 237 | +**A second failover.** After promotion the new primary archives on a new timeline. Building a |
| 238 | +fresh standby from that archive, and failing over to it, was not exercised here. |
| 239 | + |
| 240 | +**Detection.** The drill promotes by hand. Nothing here decides that the primary is dead. |
| 241 | + |
| 242 | +**Archive rollback.** The bucket holds ciphertext, and a freshness check catches an archiver that |
| 243 | +has stopped — but nothing here detects a storage provider that serves an *older* archive that is |
| 244 | +internally consistent. wal-g will restore it and the result looks healthy at the wrong point in |
| 245 | +history. Closing that needs a monotonic commitment to the archive head, kept somewhere the storage |
| 246 | +provider does not control. It is the honest open problem in this design. |
| 247 | + |
| 248 | +## Requirements |
| 249 | + |
| 250 | +Two CVMs' worth of capacity on nodes your workspace can see (`phala nodes list`), each with |
| 251 | +egress to your object store and the guest agent socket mounted (the compose does that). |
| 252 | +Postgres 17, wal-g 3.0.9, any S3-compatible bucket. A libpq 17 client for the drills; on an |
| 253 | +older distribution, `docker run --rm -i postgres:17 psql` works. |
0 commit comments