Skip to content

Commit 9d84aee

Browse files
h4x3rotabclaude
andcommitted
docs(consul-postgres-ha): explain why subsets exist (Patroni-style HA)
A user reading the declarative-services config can see `subset` as a field but not understand *why* it's there. The reason is load-bearing and worth being explicit about: subsets are how Consul tracks dynamic state (a Patroni instance's master/replica role) without re-registration. Workloads with built-in Consul integration rewrite their own tags on role changes; consumers track those changes via EDS push, transparent to the app. That's what makes `postgres-master:5432` always reach whoever is leader right now, sub-second. Three places of doc, increasing in depth: 1. cluster.tf: a comment block at `services_raw` explains the "why" at first contact with the config. 2. README "Adapting to your own workload": new section walks the two patterns explicitly — Consul-blind workload (Pattern A, webdemo) vs Consul-native (Pattern B, Patroni) — with a decision table for which to pick. 3. ARCHITECTURE.md: new section "How the master tag flows through EDS on failover" traces the chain end-to-end with a timing breakdown. Config syntax stays at native Consul vocabulary (`subset`, `service-resolver`) — the audience for this template is people who know or are learning Consul service mesh, and the 1-to-1 mapping between our config and Consul's docs is worth more than a prettier alias. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent c2db690 commit 9d84aee

3 files changed

Lines changed: 162 additions & 6 deletions

File tree

consul-postgres-ha/ARCHITECTURE.md

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,53 @@ followers). The subset filter strips down EDS endpoints to the right
228228
peer's sidecar, and the `postgres-master` / `postgres-replica`
229229
service-resolvers redirect to the right subset of `postgres`.
230230

231+
### How the master tag flows through EDS on failover
232+
233+
The subset filter isn't just a static routing rule — it's what makes
234+
failover transparent at the consumer side. The chain is short and
235+
worth tracing:
236+
237+
```
238+
T+0 Patroni promotes worker-5 (leadership lock moves in Consul KV).
239+
T+~1s worker-5's role-watcher polls Patroni REST, sees role=master,
240+
PUTs the sidecar registration with Tags=["master"].
241+
(The old leader's role-watcher does the symmetric flip:
242+
Tags=["replica"].)
243+
T+~1s Local Consul agent on worker-5 anti-entropies the new tags up
244+
to a Consul server.
245+
T+~1s Consul server pushes an EDS update to every consumer's Envoy:
246+
"for cluster postgres-master, the subset master is now
247+
{worker-5's sidecar:21001} — the previous member is gone."
248+
T+~1s Each consumer Envoy retargets the postgres-master listener at
249+
127.10.0.20:5432. Existing in-flight connections to the old
250+
endpoint die; the next connection lands on worker-5.
251+
```
252+
253+
The whole loop is bounded by Patroni's `loop_wait` (10s default but
254+
we set the role-watcher to 5s) plus EDS push latency (sub-second on a
255+
healthy Consul). Failover RTO from the app's perspective is "the time
256+
my retry-on-disconnect loop takes" — no service-discovery code, no
257+
DNS update, no client-library failover string. Just an Envoy upstream
258+
endpoint set that changes underneath the listener.
259+
260+
Two non-obvious consequences of doing it this way:
261+
262+
- **No re-registration churn.** Patroni's instance stays continuously
263+
registered in Consul's catalog as it gains and loses leadership.
264+
Only the tag changes. A deregister/re-register flow would race
265+
health checks and risk a "no member" window.
266+
- **The platform sidecar is unaware of any of this.** It registered
267+
the Connect sidecar-proxy once at startup with no tags; the
268+
workload (Patroni) owns the dynamic state. Pattern A workloads
269+
(webdemo) never touch the role-watcher path — `subset = null`
270+
means there's no tag-dependent EDS filter to update.
271+
272+
If you write your own Consul-native workload (Pattern B — Vault,
273+
Nomad, custom Raft), the same shape applies: register your parent
274+
once under `CLUSTER_NAME`, write the tag-flipping loop in your
275+
workload's entrypoint, declare role-aware `service-resolver` subsets
276+
in `local.services`, and Connect's EDS plumbing handles the rest.
277+
231278
## mesh-conn × QUIC — how they work together
232279

233280
The bit that's worth being precise about: mesh-conn is built on top

consul-postgres-ha/README.md

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,88 @@ declaration order — first service gets `vip=10 sidecar_port=21000`,
182182
second `vip=11 sidecar_port=21001`, and so on. Plan-time validation
183183
catches duplicate `(name, subset)` tuples.
184184

185+
### Two patterns: Consul-blind vs. Consul-native workloads
186+
187+
A workload's relationship to Consul determines how its service-mesh
188+
entry is shaped. Two patterns cover the realistic cases:
189+
190+
#### Pattern A — Consul-blind workload (`webdemo` is the example)
191+
192+
The app is a plain process that listens on a port. It doesn't know
193+
Consul exists; it never opens a connection to the local Consul agent;
194+
it has no leader/follower concept. The **platform sidecar** registers
195+
the service on the app's behalf, with the canonical port as the
196+
backend and a standalone Connect-proxy in front of it. There's only
197+
one parent service, named the same as the entry, no subsets.
198+
199+
Declaration:
200+
201+
```hcl
202+
{ name = "webdemo", port = 8080, subset = null }
203+
```
204+
205+
This is the default for "I just wrote a microservice and I want it on
206+
the mesh." If you have no opinions about leader election, this is the
207+
shape to use.
208+
209+
#### Pattern B — Consul-native workload (`patroni` is the example)
210+
211+
The app integrates with Consul as part of how it operates — Patroni
212+
uses Consul's KV store as its leader-election lock and registers
213+
itself in Consul's service catalog under its `scope` (which we set to
214+
`CLUSTER_NAME` = `"demo"`). On every failover, Patroni rewrites tags
215+
(`master` / `replica`) on its own registration. From a single parent
216+
service we get *multiple* logical names — one per role — each as a
217+
service-resolver that filters the parent's tags into the subset its
218+
consumer wants:
219+
220+
```hcl
221+
{ name = "postgres-master", port = 5432, subset = "master" }
222+
{ name = "postgres-replica", port = 5432, subset = "replica" }
223+
```
224+
225+
Two entries, same canonical port → one Envoy public listener shared
226+
across both, two service-resolvers with different subset filters. The
227+
platform sidecar does **not** register a parent for this case (Patroni
228+
already did it); it only stamps the Connect sidecar-proxy. The
229+
role-watcher in `patroni/entrypoint.sh` is the loop that maintains
230+
tag consistency between Patroni's view of leadership and Consul's
231+
catalog.
232+
233+
Failover round-trips through this in <1 second:
234+
235+
```
236+
Patroni promotes worker-5 to leader
237+
238+
worker-5's role-watcher writes Tags=["master"] on worker-5's sidecar
239+
240+
Consul EDS push: subset master = [worker-5's sidecar:21001]
241+
242+
every consumer's Envoy retargets `postgres-master` to worker-5
243+
244+
next psql connection lands on worker-5
245+
```
246+
247+
No DNS update, no service deregistration, no client-side retry-loop.
248+
That's what `subset` buys you, and it's why this field exists in our
249+
config even though only one of the three example services uses it.
250+
251+
#### When to pick which
252+
253+
| Question | Pattern A | Pattern B |
254+
|---|---|---|
255+
| Does your workload have a built-in Consul integration? | No | Yes |
256+
| Does your workload register itself with Consul? | No — platform does | Yes — workload does |
257+
| Does your workload have leader election? | No | Probably yes |
258+
| Will tags on the service change at runtime? | No | Yes (the workload rewrites them) |
259+
| Number of `local.services` entries per workload? | One | One per role |
260+
| Need a `subset` field? | No (`subset = null`) | One per role |
261+
262+
If you're not sure, you're probably building Pattern A. Pattern B is
263+
specifically for "this thing has its own opinions about leader
264+
election that need to surface to consumers" — Patroni, Vault, Nomad,
265+
custom raft-based services.
266+
185267
### Workload-specific pieces remaining
186268

187269
Only two files contain workload-specific logic after this refactor:

consul-postgres-ha/cluster-example/cluster.tf

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -164,12 +164,39 @@ locals {
164164
# name Consul service name (also the /etc/hosts alias).
165165
# port Canonical app port. App binds 127.0.0.1:port; the local
166166
# Envoy upstream listener binds 127.10.0.<vip>:port.
167-
# subset Optional. When set, this entry is a subset filter on a
168-
# shared backend (the postgres-master/-replica split is the
169-
# only case today). Entries sharing the same `port` collapse
170-
# into one producer-side sidecar — same Envoy public
171-
# listener, same Connect-mTLS endpoint, just different
172-
# service-resolver names with different subset filters.
167+
# subset Optional. Set this when a single backend hosts multiple
168+
# role-aware logical services (the postgres-master/replica
169+
# split is the only case today). See below.
170+
#
171+
# ## Why `subset` exists
172+
#
173+
# `subset` is native Consul vocabulary — `service-resolver` config
174+
# entries filter a parent service's instances by tag, and each
175+
# filter is called a subset. We expose the same field here because
176+
# the audience for this template is people who use (or are learning)
177+
# Consul Connect, and importing Consul's vocabulary lets them map
178+
# 1-to-1 between our config and Consul's docs.
179+
#
180+
# The reason this matters for HA Postgres specifically: Patroni
181+
# uses Consul as its distributed lock store (DCS) and registers
182+
# itself with the local Consul agent on startup. As leadership
183+
# changes, Patroni rewrites the `master` / `replica` tag on its own
184+
# service instance — no re-registration, no destroy/recreate. A
185+
# `service-resolver` with `Subsets { master = Filter("...master") }`
186+
# then routes consumer traffic by tag, transparent to the app.
187+
# That's what makes `postgres-master:5432` always reach whoever is
188+
# leader right now, with sub-second failover via Consul's EDS push.
189+
#
190+
# So `subset` is not decoration — it's the field that turns a
191+
# static service name (`postgres-master`) into a live, leader-
192+
# aware route. Workloads that don't have a leader concept
193+
# (webdemo) leave it null and Consul Connect treats the entry as
194+
# a plain service-resolver redirect.
195+
#
196+
# See README.md "Adapting to your own workload" for the
197+
# two-pattern walkthrough (Consul-blind vs Consul-native), and
198+
# ARCHITECTURE.md for how tags flow through the EDS data plane on
199+
# failover.
173200
services_raw = [
174201
{ name = "webdemo", port = 8080, subset = null },
175202
{ name = "postgres-master", port = 5432, subset = "master" },

0 commit comments

Comments
 (0)