Skip to content

Commit ddb2fa2

Browse files
h4x3rotabclaude
andcommitted
fix(consul-postgres-ha): unify Connect registration on inline SidecarService
The previous design carved Connect registration into two patterns: A (platform owns the parent service + sidecar inline) and B (workload owns the parent, platform registers a standalone `Kind: connect-proxy` pointing at it). Pattern B is what the Patroni-style HA workload was supposed to use. What that shape misses: Consul's discovery chain does NOT return a proxy as a Connect endpoint for its parent unless the parent registration carries the inline `Connect.SidecarService` field. With two-table-entries Pattern B, `proxycfg`'s local-destination watch satisfies (the proxy points at a known DestinationServiceID) — but `/v1/health/connect/postgres-master` returns an empty endpoint set, Envoy's upstream cluster gets zero hosts, and every consumer-side listener accepts then immediately closes, surfacing as `pg_basebackup: server closed the connection unexpectedly` on every replica startup. Unify both patterns onto the inline shape: one registration per backend, with the proxy declared as `Connect.SidecarService` on the parent service. The platform sidecar pre-registers the parent + inline sidecar idempotently at boot; for Patroni's HA case, Patroni's own `register_service` is set to `false` so it doesn't deregister the platform's parent every loop iteration, and a role-watcher in `patroni/entrypoint.sh` re-PUTs the same parent service_id with `Tags=["master"]` or `["replica"]` (the full spec, including the inline SidecarService block, so the proxy registration survives the tag flip). Three secondary fixes that fell out: - Service IDs use hyphens only (`${parent}-${peer-id}`). Patroni's natural `${scope}/${name}` convention contains a `/`, which trips up `consul connect envoy -sidecar-for`'s URL handling. - Role-watcher polls `/patroni` instead of `/` on the Patroni REST. `/` returns 503 on non-leaders, which made the role-watcher's `curl -fsS http://:8008/` fail forever on every replica and the `replica` tag never propagated. - `patroni/Dockerfile` adds `curl` (the role-watcher's HTTP client). End-to-end verified on a fresh 6-CVM cluster: 1 leader + 2 streaming replicas inside ~3 min of apply, zero persistent Consul proxycfg errors, soft-kill leader → 33s failover RTO (matches the documented baseline), old leader rejoins via WAL replay. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 185cd42 commit ddb2fa2

4 files changed

Lines changed: 140 additions & 120 deletions

File tree

consul-postgres-ha/compose/worker.yaml

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -78,10 +78,10 @@ services:
7878
# CLUSTER_NAME drives Patroni's `scope` — every peer's patroni
7979
# must use the same value to land in the same cluster. Postgres
8080
# binds the canonical 127.0.0.1:5432; Patroni REST on 127.0.0.1:8008.
81-
# The Patroni entrypoint also hosts the role-watcher loop that
82-
# mirrors Patroni's current role into the postgres-sidecar
83-
# registration's Service.Tags (workload-specific, lives here
84-
# instead of in the platform sidecar).
81+
# Patroni's native Consul integration registers the parent service
82+
# `${scope}/${peer}` with role-aware tags; the platform sidecar
83+
# pre-registers a stub at the same service_id so consumer-side
84+
# Envoy can come up before Patroni has bootstrapped.
8585
# WORKAROUND: superuser + replication passwords generated in
8686
# Terraform and broadcast identically to every worker. See
8787
# cluster.tf.
@@ -91,11 +91,10 @@ services:
9191
- PATRONI_REPLICATION_PW=${PATRONI_REPLICATION_PW}
9292
# SERVICES_JSON: service VIPs for /etc/hosts (patroni needs to
9393
# resolve postgres-master to its local Envoy upstream so
94-
# primary_conninfo routes through the mesh), AND the sidecar
95-
# registration shape the role-watcher re-PUTs with tags.
94+
# primary_conninfo routes through the mesh).
9695
- SERVICES_JSON=${SERVICES_JSON}
9796
# PEERS_JSON: role-watcher needs SELF_VIP to set Address on the
98-
# sidecar registration.
97+
# inline SidecarService entry.
9998
- PEERS_JSON=${PEERS_JSON}
10099
volumes:
101100
- /tmp/dstack-runtime/instance:/run/instance:ro

consul-postgres-ha/mesh-sidecar/entrypoint.sh

Lines changed: 75 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -39,11 +39,18 @@
3939
# ]
4040
#
4141
# Services with the same canonical `port` collapse onto one backend
42-
# (one sidecar_port, one Envoy). `registers_parent=false` means the
43-
# workload auto-registers its parent service under its own scope; the
44-
# platform only registers a standalone connect-proxy pointing at it,
45-
# and the workload owns any role-tag mutation on the sidecar via the
46-
# Consul agent HTTP API.
42+
# (one sidecar_port, one Envoy). For `registers_parent=false`, the
43+
# workload owns the *authoritative* parent service registration
44+
# (tags, health check) under its own `scope` — e.g. Patroni's native
45+
# Consul integration registers `${scope}/${peer}` with role tags and a
46+
# REST-API health check. To avoid the chicken-and-egg where the
47+
# workload can't run (no consumer-side Envoy listener yet) before it
48+
# has registered (Patroni only registers after pg_basebackup, which
49+
# needs the listener), the platform pre-registers a *stub* parent
50+
# service at the same service_id so Consul's proxycfg can populate
51+
# the connect-proxy's state immediately. The workload's later PUT
52+
# overwrites the stub in place (same service_id ⇒ idempotent), adding
53+
# its check and role tags.
4754
#
4855
# Supervision policy: any one inner process dying takes the whole
4956
# container down. Compose `restart: on-failure` brings it back in
@@ -240,78 +247,76 @@ if [ "$ROLE" = "worker" ]; then
240247
SPEC_FILE="/tmp/sidecar-${PARENT}.json"
241248
ENVOY_BOOT="/tmp/envoy-${PARENT}.json"
242249

250+
# Both patterns use a single registration with inline SidecarService:
251+
# that's the shape Consul recognises as a Connect-aware service +
252+
# its proxy. A bare `Kind: connect-proxy` plus a separate parent
253+
# without `Connect.SidecarService` is not enough — Consul won't
254+
# return the proxy as a Connect endpoint for the parent, so the
255+
# discovery chain resolves to zero hosts and consumer Envoys close
256+
# incoming connections immediately.
257+
#
258+
# The two patterns differ only in service_id and what overwrites
259+
# the registration over time:
260+
#
261+
# pattern A (webdemo): platform-only. Parent ID is
262+
# "${parent}-${peer}", tagged "peer=${peer}". Nothing else
263+
# touches this registration.
264+
#
265+
# pattern B (Patroni): platform pre-registers a "stub" with no
266+
# role tags. Patroni's entrypoint role-watcher re-PUTs the
267+
# same service_id with tags ["master"] / ["replica"] tracked
268+
# from Patroni REST. We keep `register_service: false` in
269+
# patroni.yml so Patroni's own integration stays out of the
270+
# service catalog (it only uses Consul as DCS); see
271+
# patroni/entrypoint.sh for the role-watcher.
272+
# Service ID format is `${parent}-${peer}` for both patterns so it
273+
# never contains a slash. Slashes in service IDs trip up
274+
# `consul connect envoy -sidecar-for` URL handling.
275+
PARENT_ID="${PARENT}-${PEER_ID}"
243276
if [ "$REGISTERS_PARENT" = "true" ]; then
244-
# Pattern A: platform owns the parent service AND its sidecar.
245-
# TCP check on the canonical port — generic across HTTP/non-HTTP
246-
# workloads; passes once the app's listener is up. SidecarService
247-
# inline means a single PUT registers both records.
248-
jq -n \
249-
--arg name "$PARENT" \
250-
--arg id "${PARENT}-${PEER_ID}" \
251-
--arg peer "$PEER_ID" \
252-
--argjson port "$LOCAL_PORT" \
253-
--arg vip "$SELF_VIP" \
254-
--argjson sport "$SIDECAR_PORT" \
255-
--argjson upstreams "$UPSTREAMS" '{
256-
Name: $name,
257-
ID: $id,
258-
Address: "127.0.0.1",
259-
Port: $port,
260-
Tags: ["peer=" + $peer],
261-
Check: {
262-
TCP: ("127.0.0.1:" + ($port|tostring)),
263-
Interval: "10s",
264-
Timeout: "2s",
265-
DeregisterCriticalServiceAfter: "1m"
266-
},
267-
Connect: {
268-
SidecarService: {
269-
Address: ("127.50.0." + $vip),
270-
Port: $sport,
271-
Proxy: {
272-
LocalServiceAddress: "127.0.0.1",
273-
LocalServicePort: $port,
274-
Upstreams: $upstreams
275-
}
276-
}
277-
}
278-
}' > "$SPEC_FILE"
279-
ENVOY_ARGS=(-sidecar-for="${PARENT}-${PEER_ID}")
277+
TAGS_JSON='["peer='"$PEER_ID"'"]'
280278
else
281-
# Pattern B: workload auto-registers the parent under its
282-
# `scope`; platform registers a standalone connect-proxy pointing
283-
# at it. Role/state tags on the sidecar (e.g. master/replica) are
284-
# managed by the workload itself via the Consul agent HTTP API;
285-
# the subset resolvers below filter on those tags.
286-
jq -n \
287-
--arg id "$SIDECAR_ID" \
288-
--arg name "${PARENT}-sidecar-proxy" \
289-
--arg parent "$PARENT" \
290-
--arg vip "$SELF_VIP" \
291-
--argjson port "$LOCAL_PORT" \
292-
--argjson sport "$SIDECAR_PORT" \
293-
--argjson upstreams "$UPSTREAMS" '{
294-
Kind: "connect-proxy",
295-
ID: $id,
296-
Name: $name,
297-
Address: ("127.50.0." + $vip),
298-
Port: $sport,
299-
Tags: [],
300-
Proxy: {
301-
DestinationServiceName: $parent,
302-
LocalServiceAddress: "127.0.0.1",
303-
LocalServicePort: $port,
304-
Upstreams: $upstreams
305-
}
306-
}' > "$SPEC_FILE"
307-
ENVOY_ARGS=(-proxy-id="$SIDECAR_ID")
279+
TAGS_JSON='[]'
308280
fi
309281

282+
jq -n \
283+
--arg name "$PARENT" \
284+
--arg id "$PARENT_ID" \
285+
--argjson port "$LOCAL_PORT" \
286+
--arg vip "$SELF_VIP" \
287+
--argjson sport "$SIDECAR_PORT" \
288+
--argjson tags "$TAGS_JSON" \
289+
--argjson upstreams "$UPSTREAMS" '{
290+
Name: $name,
291+
ID: $id,
292+
Address: "127.0.0.1",
293+
Port: $port,
294+
Tags: $tags,
295+
Check: {
296+
TCP: ("127.0.0.1:" + ($port|tostring)),
297+
Interval: "10s",
298+
Timeout: "2s",
299+
DeregisterCriticalServiceAfter: "1m"
300+
},
301+
Connect: {
302+
SidecarService: {
303+
Address: ("127.50.0." + $vip),
304+
Port: $sport,
305+
Proxy: {
306+
LocalServiceAddress: "127.0.0.1",
307+
LocalServicePort: $port,
308+
Upstreams: $upstreams
309+
}
310+
}
311+
}
312+
}' > "$SPEC_FILE"
313+
ENVOY_ARGS=(-sidecar-for="$PARENT_ID")
314+
310315
# Re-register on every boot via the agent HTTP API (PUT-idempotent).
311-
log "registering ${PARENT} sidecar (port=${SIDECAR_PORT}, base-id=${BASE_ID})"
316+
log "registering ${PARENT} (id=${PARENT_ID}) + sidecar (port=${SIDECAR_PORT}, base-id=${BASE_ID})"
312317
until curl -fsS -X PUT --data-binary @"$SPEC_FILE" \
313318
http://127.0.0.1:8500/v1/agent/service/register; do
314-
log "${PARENT} sidecar register failed; retrying"
319+
log "${PARENT} register failed; retrying"
315320
sleep 2
316321
done
317322

consul-postgres-ha/patroni/Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
# /etc/patroni.yml and execs `patroni`.
77
FROM postgres:16-alpine
88

9-
RUN apk add --no-cache python3 py3-pip py3-psycopg2 py3-yaml jq tini su-exec
9+
RUN apk add --no-cache python3 py3-pip py3-psycopg2 py3-yaml jq tini su-exec curl
1010

1111
# python-consul + python-etcd are optional; we only ship the consul DCS.
1212
RUN pip install --no-cache-dir --break-system-packages \

consul-postgres-ha/patroni/entrypoint.sh

Lines changed: 58 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -65,8 +65,17 @@ restapi:
6565
6666
consul:
6767
host: ${CONSUL_HTTP}
68-
register_service: true
69-
service_check_interval: 5s
68+
# Patroni's native register_service is set to false because the
69+
# platform sidecar owns the parent service registration (Address,
70+
# Port, check). The role-watcher loop below polls Patroni's REST API
71+
# and re-PUTs the parent registration with tags ["master"] or
72+
# ["replica"] so service-resolver subset filters match. We do this
73+
# because Patroni deregisters the service every loop iteration while
74+
# `role=uninitialized` (the entire pre-bootstrap window on a
75+
# replica), which wipes the registration our connect-proxy depends
76+
# on for proxycfg state. With register_service:false Patroni stays
77+
# out of the agent's service catalog and just uses Consul as DCS.
78+
register_service: false
7079
7180
bootstrap:
7281
dcs:
@@ -121,26 +130,19 @@ tags:
121130
clonefrom: false
122131
EOF
123132

124-
# Role watcher: mirror Patroni's current role into the postgres-sidecar
125-
# registration's Tags. Lives here (not in the platform sidecar) because
126-
# the role <-> tag mapping is Patroni-specific knowledge — the platform
127-
# sidecar registers the proxy with no tags, and only Patroni knows when
128-
# it has flipped master/replica.
133+
# Role-watcher: poll Patroni's REST API and re-PUT the parent service
134+
# registration `${CLUSTER}-${PEER_ID}` with role-aware tags. Consul's
135+
# /v1/agent/service/register is a complete replace, so each PUT must
136+
# carry the full spec — same shape the platform sidecar pre-registers,
137+
# including the inline `Connect.SidecarService` that links the proxy
138+
# to this parent. Without that field Consul doesn't return the proxy
139+
# as a Connect endpoint for the parent and the discovery chain
140+
# resolves to zero hosts.
129141
#
130-
# Service-resolver subset filters (`Service.Tags contains "master"`)
131-
# apply to the SIDECAR registration, not Patroni's parent service. We
132-
# re-PUT the sidecar via the local consul agent's HTTP API; the PUT is
133-
# idempotent on (Name, ID), so it acts as an in-place tag update.
134-
PEERS_JSON_FOR_ROLE="${PEERS_JSON:?PEERS_JSON required (for role-watcher to compute self vip)}"
135-
SELF_VIP=$(echo "$PEERS_JSON_FOR_ROLE" | jq -r --arg id "$PEER_ID" '.[] | select(.id == $id) | .vip')
136-
[ -n "$SELF_VIP" ] || { echo "FATAL: $PEER_ID not in PEERS_JSON" >&2; exit 1; }
137-
138-
# Same registration shape the platform sidecar emits in pattern B
139-
# (standalone connect-proxy, parent=CLUSTER auto-registered by Patroni).
140-
# We rebuild it here rather than relying on a shared file because the
141-
# platform sidecar lives in a different container with its own /tmp;
142-
# reconstructing from SERVICES_JSON + PEERS_JSON keeps this loop
143-
# self-contained.
142+
# The subset filters in the service-resolver
143+
# (`Service.Tags contains "master"` / "replica") match on the parent's
144+
# tags, so flipping `$TAG` here is what makes `postgres-master:5432`
145+
# follow the current leader live.
144146
ROLE_BACKEND=$(
145147
echo "$SERVICES_JSON" \
146148
| jq -c --arg parent "$CLUSTER" '
@@ -156,18 +158,22 @@ ROLE_BACKEND=$(
156158
'
157159
)
158160

161+
PEERS_JSON_FOR_ROLE="${PEERS_JSON:?PEERS_JSON required (role-watcher needs SELF_VIP)}"
162+
SELF_VIP=$(echo "$PEERS_JSON_FOR_ROLE" | jq -r --arg id "$PEER_ID" '.[] | select(.id == $id) | .vip')
163+
[ -n "$SELF_VIP" ] || { echo "FATAL: $PEER_ID not in PEERS_JSON" >&2; exit 1; }
164+
159165
if [ "$(echo "$ROLE_BACKEND" | jq -r '.sidecar_port // "null"')" = "null" ]; then
160166
echo "[role-watcher] no SERVICES_JSON entry with parent=$CLUSTER — skipping watcher"
161167
else
162-
# Background subshell — tini reaps it when this script's process tree
163-
# tears down on container stop. We don't trap or `wait`: the original
164-
# design had this loop as a sibling background process in the platform
165-
# sidecar, and we preserve that lifecycle here.
166168
(
169+
echo "[role-watcher] starting (service_id=${CLUSTER}-${PEER_ID}, rest=127.0.0.1:${REST_PORT})"
167170
PREV=""
168171
while true; do
169172
sleep 5
170-
PATRONI_ROLE=$(curl -fsS --max-time 2 http://127.0.0.1:${REST_PORT}/ 2>/dev/null \
173+
# `/` returns 503 on non-leaders (libpq's idiomatic way to advertise
174+
# role to load balancers); `/patroni` is 200 on every node and
175+
# carries the same JSON.
176+
PATRONI_ROLE=$(curl -fsS --max-time 2 "http://127.0.0.1:${REST_PORT}/patroni" 2>/dev/null \
171177
| jq -r '.role // empty' 2>/dev/null)
172178
case "$PATRONI_ROLE" in
173179
master|primary) TAG="master" ;;
@@ -176,28 +182,38 @@ else
176182
esac
177183
[ "$TAG" = "$PREV" ] && continue
178184
SPEC=$(jq -n \
179-
--arg id "${CLUSTER}-sidecar-${PEER_ID}" \
180-
--arg name "${CLUSTER}-sidecar-proxy" \
181-
--arg parent "$CLUSTER" \
182-
--arg vip "$SELF_VIP" \
185+
--arg name "$CLUSTER" \
186+
--arg id "${CLUSTER}-${PEER_ID}" \
183187
--arg tag "$TAG" \
184-
--argjson backend "$ROLE_BACKEND" '{
185-
Kind: "connect-proxy",
186-
ID: $id,
188+
--arg vip "$SELF_VIP" \
189+
--argjson backend "$ROLE_BACKEND" \
190+
--argjson port "$PG_PORT" '{
187191
Name: $name,
188-
Address: ("127.50.0." + $vip),
189-
Port: $backend.sidecar_port,
192+
ID: $id,
193+
Address: "127.0.0.1",
194+
Port: $port,
190195
Tags: [$tag],
191-
Proxy: {
192-
DestinationServiceName: $parent,
193-
LocalServiceAddress: "127.0.0.1",
194-
LocalServicePort: $backend.port,
195-
Upstreams: $backend.upstreams
196+
Check: {
197+
TCP: ("127.0.0.1:" + ($port|tostring)),
198+
Interval: "5s",
199+
Timeout: "2s",
200+
DeregisterCriticalServiceAfter: "1m"
201+
},
202+
Connect: {
203+
SidecarService: {
204+
Address: ("127.50.0." + $vip),
205+
Port: $backend.sidecar_port,
206+
Proxy: {
207+
LocalServiceAddress: "127.0.0.1",
208+
LocalServicePort: $backend.port,
209+
Upstreams: $backend.upstreams
210+
}
211+
}
196212
}
197213
}')
198214
if printf '%s' "$SPEC" | curl -fsS -X PUT --data-binary @- \
199-
http://${CONSUL_HTTP}/v1/agent/service/register; then
200-
echo "[role-watcher] ${CLUSTER}-sidecar tag: $PREV -> $TAG (patroni role=$PATRONI_ROLE)"
215+
"http://${CONSUL_HTTP}/v1/agent/service/register"; then
216+
echo "[role-watcher] ${CLUSTER}-${PEER_ID} tag: ${PREV:-<none>} -> $TAG (patroni role=$PATRONI_ROLE)"
201217
PREV="$TAG"
202218
fi
203219
done

0 commit comments

Comments
 (0)