Skip to content

Commit 403bf49

Browse files
amillerclaude
andcommitted
Add postgres-r2: durable Postgres in a CVM, disk- and node-loss drills
Postgres in a dstack CVM that treats the disk as a cache: wal-g ships every WAL segment to an S3-compatible bucket (Cloudflare R2), encrypted under a key the app derives from the KMS — nothing outside the enclave ever holds it. Two drills. Disk loss: wipe the volume, redeploy the same app as a standby, promote. Node loss: `phala cvms replicate` a standby onto another node (same app id -> same derived key, no connection to the primary), SIGKILL the primary mid-write, promote, count acked writes lost. Real numbers: 429/771 lost at archive_timeout=60s, 16/739 at 15s (the bound is the guarantee, the sample depends on where the kill lands). verify.sh checks the three claims live. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BVSS9AUWZVgcHbmLr4KvBt
1 parent d506980 commit 403bf49

3 files changed

Lines changed: 463 additions & 0 deletions

File tree

‎postgres-r2/README.md‎

Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
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

Comments
 (0)