Skip to content

Commit e082ceb

Browse files
h4x3rotabclaude
andcommitted
docs(consul-postgres-ha): rewrite Layer-2 narrative around peer/service VIPs
ARCHITECTURE.md replaces the identity-port plane with the peer-VIP (127.50.0.0/24) + service-VIP (127.10.0.0/24) layering and the static infra-port allowlist; the worked-example traversal diagram now traces a Connect-mTLS call all the way through Envoy + EDS + mesh-conn + remote Envoy → local Postgres. The "trust boundaries" and "what's nice" sections are tightened. ROBUSTNESS.md: Layer 2 failure-mode table swapped from identity-port collisions to VIP collisions / out-of-range / alias provisioning; configuration-drift section reflects PEERS_JSON's shrunk shape; "are we playing too many tricks" mentions the peer-VIP plane as the ours-bit instead. FAILOVER.md: ssh + docker-exec recipes now use canonical 5432 (Postgres) and 8008 (Patroni REST); leader-change watch loop uses Consul's catalog API on coord-0 instead of the per-ordinal Patroni REST forwarder that no longer exists. README.md: quickstart connection example flips to `psql -h postgres-master -p 5432`; gateway-URL convention table updated for the new canonical ports; Patroni-specific table points at `local.service_vips` and the postgres sidecar registration. rollout.sh: COORDINATOR_HTTP_PORT default 18200 → 8500 (canonical Consul HTTP); coordinator_app_id lookup updated for the coordinator_app_ids map output. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 11c7e5e commit e082ceb

5 files changed

Lines changed: 226 additions & 127 deletions

File tree

consul-postgres-ha/ARCHITECTURE.md

Lines changed: 136 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,9 @@ Per pair we then run **one QUIC connection** (quic-go) over the
4141
ICE conn, treating `ice.Conn` as a `net.PacketConn`. QUIC provides
4242
loss recovery, congestion control, and stream multiplexing on top of
4343
the lossy UDP underlay. Streams come in two flavours: some are
44-
long-lived (one per protocol port, carrying length-prefixed UDP
45-
datagrams), the rest are ephemeral (one per accepted TCP connection).
44+
long-lived (one per UDP-bearing infra port, carrying length-prefixed
45+
UDP datagrams), the rest are ephemeral (one per accepted TCP
46+
connection).
4647

4748
```
4849
┌── ICE conn / QUIC ──┐
@@ -65,73 +66,107 @@ maintains five of them.
6566
> almost immediately under load. QUIC has the loss recovery + flow
6667
> control that yamux is forced to assume from below it.
6768
68-
## Layer 2 — identity-port plane
69+
## Layer 2 — peer-VIP / service-VIP plane
6970

7071
This is the trick that makes the overlay invisible to the applications
71-
above. Every peer has a unique port for every protocol. mesh-conn
72-
binds the **other** peers' identity ports on `127.0.0.1` and bridges
73-
each one to the right ICE+QUIC peer link, **preserving source ports**
74-
so the destination app sees the packet as coming from
75-
`127.0.0.1:<sender's identity port>` — which is exactly what the app
76-
uses to identify the sender.
77-
78-
So inside any CVM the entire cluster looks like a single loopback
79-
host. Eight protocol ports per peer (serf_lan, server_rpc, http_api,
80-
grpc, webdemo, sidecar_public, postgres, patroni_rest), spread by
81-
`base + ordinal`:
72+
above. Two carved-out loopback `/24`s, allocated cluster-wide:
73+
74+
- **Peer VIPs** in `127.50.0.0/24` — one per peer (the peer's
75+
ordinal+1 in the example template). Identifies *who* a piece of
76+
infrastructure traffic is for.
77+
- **Service VIPs** in `127.10.0.0/24` — one per Connect upstream a
78+
worker consumes (e.g. webdemo, postgres-master, postgres-replica).
79+
Identifies *what service* an app is calling.
80+
81+
mesh-conn binds a small **static infra-port allowlist** on every
82+
*other* peer's VIP and forwards each accepted connection to the right
83+
remote peer over the QUIC link:
84+
85+
| Port | Used by | Proto |
86+
|-------|-------------------------------------------------------|-------------|
87+
| 21000 | Envoy Connect public mTLS — webdemo sidecar | TCP |
88+
| 21001 | Envoy Connect public mTLS — postgres sidecar | TCP |
89+
| 8300 | Consul server RPC (server-to-server, client-to-server)| TCP |
90+
| 8301 | Consul serf-LAN gossip | UDP + TCP |
91+
92+
The allowlist is static and small by design — mesh-conn knows
93+
**peers, not services**. Apps never dial peer VIPs; only Envoy and
94+
Consul-agent do, and both speak well-known platform ports. Adding a
95+
port to the allowlist is a code change in `mesh-conn/main.go`, not
96+
a runtime catalog watch.
8297

8398
```
8499
inside worker-3 CVM (network_mode: host)
85100
┌───────────────────────────────────────────────────────────────────┐
86-
│ local apps bind their OWN identity ports (base + ordinal=3):
101+
│ local apps + platform processes bind canonical ports on lo:
87102
│ │
88-
│ consul agent ▶ 127.0.0.1:18003 (serf) 18103 (rpc) │
89-
│ 18203 (http) 18303 (grpc) │
90-
│ webdemo ▶ 127.0.0.1:18503 │
91-
│ envoy sidecar ▶ 127.0.0.1:18603 (public mTLS) │
92-
│ patroni / pg ▶ 127.0.0.1:18703 (postgres) 18803 (REST) │
103+
│ patroni / pg ▶ 127.0.0.1:5432 (postgres) │
104+
│ 127.0.0.1:8008 (patroni REST) │
105+
│ webdemo ▶ 127.0.0.1:8080 │
106+
│ envoy-webdemo ▶ 127.0.0.1:21000 (sidecar public mTLS) │
107+
│ envoy-postgres ▶ 127.0.0.1:21001 (sidecar public mTLS) │
108+
│ consul agent ▶ 127.0.0.1:8500 (HTTP) │
109+
│ 127.0.0.1:8502 (gRPC, Envoy xDS) │
110+
│ 127.0.0.1:8301 (serf gossip) │
111+
│ 127.0.0.1:8300 (server RPC; coords only) │
93112
│ │
94-
mesh-conn binds OTHER peers' identity ports on 127.0.0.1:
113+
loopback aliases (provisioned once at sidecar entrypoint):
95114
│ │
96-
│ ports[0..7] + 0 ◄── coord-0 │
97-
│ ports[0..7] + 1 ◄── coord-1 │
98-
│ ports[0..7] + 2 ◄── coord-2 │
99-
│ ports[0..7] + 4 ◄── worker-4 │
100-
│ ports[0..7] + 5 ◄── worker-5 │
115+
│ 127.50.0.<vip>/32 for every peer — peer VIPs │
116+
│ 127.10.0.<vip>/32 per declared upstream — service VIPs │
101117
│ │
102-
│ all UDP/TCP traffic to those ports is shipped through the │
103-
│ matching ICE+QUIC connection to the corresponding peer. │
118+
│ mesh-conn binds the allowlist on OTHER peers' VIPs, e.g. on │
119+
│ worker-3: │
120+
│ │
121+
│ 127.50.0.1:8301,8300,21000,21001 ◄── coord-0 │
122+
│ 127.50.0.2:8301,8300,21000,21001 ◄── coord-1 │
123+
│ 127.50.0.3:8301,8300,21000,21001 ◄── coord-2 │
124+
│ 127.50.0.5:8301,8300,21000,21001 ◄── worker-4 │
125+
│ 127.50.0.6:8301,8300,21000,21001 ◄── worker-5 │
126+
│ │
127+
│ Envoy listens on the service VIPs for declared upstreams: │
128+
│ │
129+
│ 127.10.0.10:8080 → cluster `webdemo` │
130+
│ 127.10.0.20:5432 → cluster `postgres-master` │
131+
│ 127.10.0.21:5432 → cluster `postgres-replica` │
132+
│ │
133+
│ /etc/hosts maps service names → service VIPs, so apps just call │
134+
│ `postgres-master:5432`. │
104135
└───────────────────────────────────────────────────────────────────┘
105136
```
106137

107-
Every peer has the symmetric layout — own ports bound by apps, other
108-
peers' ports bound by mesh-conn.
138+
Every peer has a symmetric layout: own ports bound by apps + Envoy +
139+
Consul, other peers' VIP+allowlist bound by mesh-conn. Self's VIP is
140+
also aliased on `lo` (local short-circuit: dialing
141+
`127.50.0.<self-vip>:21000` routes through the kernel directly to
142+
the local Envoy, no mesh-conn hop).
109143

110144
## Layer 3 — apps
111145

112146
Consul agents, Envoy sidecars, webdemo, Patroni, anything else. These
113-
think they're talking to peers on `127.0.0.1`. They never see ICE,
114-
QUIC, TURN, or the public internet. Stock HashiCorp Consul, stock
115-
Envoy, stock Patroni.
147+
think they're talking to peers on `127.0.0.1` and to services by
148+
name. They never see ICE, QUIC, TURN, or the public internet. Stock
149+
HashiCorp Consul, stock Envoy, stock Patroni.
116150

117151
## How a single call traverses all four layers
118152

119-
A Connect-style mTLS call from `worker-3`'s webdemo to `worker-4`'s
120-
webdemo:
153+
A Connect-mTLS call from `worker-3`'s webdemo to whichever peer's
154+
webdemo Envoy load-balances onto:
121155

122156
```
123157
worker-3 webdemo
124-
GET http://127.0.0.1:19000/hello ── Layer 3, app on its
125-
local sidecar upstream
158+
GET http://webdemo:8080/hello ── Layer 3, app dialing
159+
/etc/hosts → 127.10.0.10 a service name
126160
127-
worker-3 envoy sidecar
128-
picks endpoint via Consul-supplied EDS
129-
opens mTLS to "127.0.0.1:18604" (worker-4's sidecar via mesh-conn)
161+
worker-3 envoy-webdemo
162+
listener on 127.10.0.10:8080 (cluster `webdemo`)
163+
EDS endpoint pick → e.g. 127.50.0.5:21000 (worker-4's webdemo sidecar)
164+
opens mTLS to "127.50.0.5:21000"
130165
131166
132-
worker-3 mesh-conn (TCP listener on 127.0.0.1:18604)
167+
worker-3 mesh-conn (TCP listener on 127.50.0.5:21000)
133168
reads bytes off the local TCP listener
134-
opens a QUIC stream tagged "port=18604"
169+
opens a QUIC stream tagged "port=21000"
135170
writes through the worker-3↔worker-4 QUIC connection ── Layer 2 → 1
136171
│ ╱
137172
│ ╱ here Layer 1 (QUIC frames over the ICE conn)
@@ -140,15 +175,15 @@ worker-3 mesh-conn (TCP listener on 127.0.0.1:18604)
140175
│ ╱
141176
142177
worker-4 mesh-conn (QUIC stream accept on the worker-3 ICE conn)
143-
reads stream header → "port=18604"
144-
dials TCP to 127.0.0.1:18604 (worker-4's actual sidecar)
178+
reads stream header → "port=21000" (allowlist-validated)
179+
dials TCP to 127.0.0.1:21000 (worker-4's webdemo sidecar)
145180
splices stream ↔ TCP conn ── Layer 1 → 2
146181
147182
148-
worker-4 envoy sidecar
183+
worker-4 envoy-webdemo
149184
validates origin's mTLS cert against Connect CA
150185
checks intention webdemo → webdemo (allow)
151-
forwards to local 127.0.0.1:18504 (LocalServicePort)
186+
forwards to local 127.0.0.1:8080 (LocalServicePort)
152187
153188
154189
worker-4 webdemo
@@ -157,6 +192,36 @@ worker-4 webdemo
157192

158193
Reply takes the same path in reverse.
159194

195+
## Patroni replication uses the same path
196+
197+
A Patroni replica following the leader is just another consumer of
198+
the `postgres-master` Connect upstream — no special case. The
199+
replica's `primary_conninfo` carries `host=postgres-master port=5432`
200+
(the same constant string registered as `postgresql.connect_address`
201+
on every Patroni instance). The local Envoy at `127.10.0.20:5432`
202+
proxies to the leader's `127.50.0.<leader-vip>:21001`, which lands
203+
on the leader's postgres-sidecar Envoy and gets forwarded to local
204+
`127.0.0.1:5432`. The Connect mesh stays in the data path for
205+
streaming WAL + `pg_basebackup` — the 2× Envoy hop tax is the cost
206+
of keeping mesh-conn workload-agnostic.
207+
208+
Service-resolver subsets pick the right peer:
209+
210+
```hcl
211+
Kind = "service-resolver"
212+
Name = "postgres"
213+
Subsets = {
214+
master = { Filter = "Service.Tags contains \"master\"" }
215+
replica = { Filter = "Service.Tags contains \"replica\"" }
216+
}
217+
```
218+
219+
Patroni auto-registers the parent `postgres` service with the
220+
current role as a tag (`master` on the leader, `replica` on
221+
followers). The subset filter strips down EDS endpoints to the right
222+
peer's sidecar, and the `postgres-master` / `postgres-replica`
223+
service-resolvers redirect to the right subset of `postgres`.
224+
160225
## mesh-conn × QUIC — how they work together
161226

162227
The bit that's worth being precise about: mesh-conn is built on top
@@ -177,8 +242,9 @@ TEE layer, not TLS identity) and gives back a `*quic.Conn`.
177242
We need to carry multiple logical channels over the unreliable UDP
178243
underlay:
179244

180-
- One long-lived stream per identity port (8 of these per peer-pair)
181-
carrying length-prefixed UDP datagrams.
245+
- One long-lived stream per UDP-bearing allowlist port (currently
246+
one: 8301 for serf gossip), carrying length-prefixed UDP
247+
datagrams.
182248
- One ephemeral stream per accepted local TCP connection, opened and
183249
closed on demand.
184250

@@ -222,10 +288,11 @@ When a stream opens, the **first 3 bytes** carry a mesh-conn header:
222288
datagrams.
223289
- `tag = 0x33`**streamTCP** — per-connection raw TCP byte stream.
224290

225-
The 16-bit `port` is the **receiver's own identity port** for the
226-
protocol slot this stream serves. The receiver looks it up in
227-
`self.Ports`, finds the index, and pairs the stream with the right
228-
local socket / dial target.
291+
The 16-bit `port` is the **receiver's local port to dial / write
292+
into**. The receiver validates it against the static allowlist
293+
(`{21000, 21001, 8300, 8301}`); anything else is rejected. There is
294+
no per-peer port-list lookup — ports are platform-level constants
295+
the same on every CVM.
229296

230297
UDP-over-stream uses an explicit 2-byte big-endian length prefix per
231298
datagram, since QUIC streams are byte-oriented (like yamux was) and
@@ -255,7 +322,8 @@ format.
255322
## Trust boundaries
256323

257324
- **App → Envoy** is plaintext on loopback. Same CVM, same TEE.
258-
- **Envoy → Envoy** is mTLS, certs signed by Consul's Connect CA.
325+
- **Envoy → Envoy** is mTLS, certs signed by Consul's Connect CA
326+
(built-in CA provider; root in Raft, no external derivation).
259327
End-to-end across the overlay; mesh-conn just sees encrypted bytes.
260328
- **mesh-conn → mesh-conn** rides ICE. Direct UDP between CVMs on the
261329
public internet (or TURN-relayed if hole-punching ever fails).
@@ -265,22 +333,25 @@ format.
265333
- …all confidential traffic above it is **already encrypted by Envoy
266334
mTLS** (Layer 3), so the wire is safe even if someone could see the
267335
UDP datagrams.
268-
- **Consul gossip** is currently unencrypted (we didn't set a gossip
269-
key); RPC is plaintext. Both are confined to inside the overlay,
270-
but a full setup would set `-encrypt=...` and TLS for
271-
RPC. See [ROBUSTNESS.md](ROBUSTNESS.md).
336+
- **Consul gossip** is encrypted via `-encrypt` (Stage-1 workaround:
337+
key generated in Terraform and broadcast via env; Stage-2
338+
attestation will replace this with TEE-rooted material). RPC is
339+
plaintext. Both are confined to inside the overlay, but a full
340+
setup would also configure TLS for RPC. See [ROBUSTNESS.md](ROBUSTNESS.md).
272341

273342
## What's nice about this shape
274343

275344
- **Layer 3 has zero awareness of layers below.** Consul, Envoy,
276-
webdemo all think they're on a flat loopback. Anything that runs
277-
against Consul today (Vault, Nomad, Boundary, custom apps) drops in
278-
unchanged.
279-
- **Layer 1 is a single component (mesh-conn, ~700 LoC Go including
280-
the QUIC adapter) and has zero awareness of Consul.** It just
281-
bridges ports. It would equally well move Postgres replication,
282-
Redis Sentinel, Kafka, etc. — and in fact this example uses it for
283-
Patroni+Postgres replication.
345+
webdemo, Patroni all think they're on a flat loopback talking to
346+
services by name. Anything that runs against Consul today (Vault,
347+
Nomad, Boundary, custom apps) drops in unchanged.
348+
- **Layer 1 + 2 are a single component (mesh-conn, ~600 LoC Go
349+
including the QUIC adapter) and have zero awareness of services.**
350+
mesh-conn forwards bytes between peer VIPs on a static infra-port
351+
allowlist; it does not know what Consul, Patroni, or Envoy are.
352+
It would equally well move Postgres replication, Redis Sentinel,
353+
Kafka, etc. — and in fact this example uses it for both Consul
354+
cluster gossip and Patroni-replication-via-Envoy.
284355
- **Layer 0 is dumb infra.** Just a public IP running coturn + a tiny
285356
broker. Fungible and not in the data path once peers are connected.
286357

consul-postgres-ha/FAILOVER.md

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,17 @@ PW=$(ssh ... root@${W1}-22.${GW} "cat /tmp/dstack-runtime/secrets/patroni-superu
2929

3030
### 1. Snapshot pre-state + mark a "before" row
3131

32+
Postgres + Patroni REST bind their canonical ports on every worker
33+
(no per-ordinal arithmetic): `127.0.0.1:5432` for Postgres,
34+
`127.0.0.1:8008` for Patroni REST. Both are local to the CVM, so
35+
queries go via `ssh` + `docker exec`.
36+
3237
```bash
3338
ssh ... root@${W1}-22.${GW} \
34-
"docker exec dstack-sidecar-1 sh -c 'curl -s http://127.0.0.1:18803/cluster' | jq"
39+
"docker exec dstack-sidecar-1 sh -c 'curl -s http://127.0.0.1:8008/cluster' | jq"
3540

3641
ssh ... root@${W1}-22.${GW} "PGPASSWORD='$PW' docker exec -e PGPASSWORD dstack-patroni-1 \
37-
psql -h 127.0.0.1 -p 18703 -U postgres -d postgres \
42+
psql -h 127.0.0.1 -p 5432 -U postgres -d postgres \
3843
-c \"INSERT INTO demo(msg) VALUES ('before failover') RETURNING id, msg;\""
3944
```
4045

@@ -51,15 +56,18 @@ ssh ... root@${W1}-22.${GW} "docker stop -t 0 dstack-patroni-1"
5156
### 3. Watch the election + first write on the new leader
5257

5358
```bash
54-
# Poll W4's /cluster endpoint every ~1s; promotion shows when the
55-
# leader-key expires from Consul KV (TTL=30s) and a replica wins.
56-
while ! curl -s http://127.0.0.1:18804/cluster | jq -e '.members[]|select(.role=="leader" and .name!="worker-3")' >/dev/null; do
59+
# Poll Consul's catalog from coord-0 — the postgres-master service is
60+
# only registered on whichever worker Patroni currently considers the
61+
# leader. When the registered Node changes from worker-3 to a new one,
62+
# the failover is complete.
63+
COORD0=... # coordinator-0 app_id
64+
while [ "$(curl -s https://${COORD0}-8500s.${GW}/v1/catalog/service/postgres-master | jq -r '.[0].Node')" = "worker-3" ]; do
5765
sleep 1
5866
done
5967

60-
# Try to write on whichever replica got promoted.
68+
# Try to write on whichever replica got promoted (W2 = worker-4 here).
6169
ssh ... root@${W2}-22.${GW} "PGPASSWORD='$PW' docker exec -e PGPASSWORD dstack-patroni-1 \
62-
psql -h 127.0.0.1 -p 18704 -U postgres -d postgres \
70+
psql -h 127.0.0.1 -p 5432 -U postgres -d postgres \
6371
-c \"INSERT INTO demo(msg) VALUES ('after failover') RETURNING id;\""
6472
```
6573

0 commit comments

Comments
 (0)