1# ============================================================================= 2# AI Software Factory — PostgreSQL read replica (hot standby) — TASK-0135. 3# 4# Streams WAL from the primary (postgres/statefulset.yaml) via 5# `pg_basebackup` (first boot, initContainer) + continuous logical→physical 6# replication (standby.signal). The API's DATABASE_REPLICA_URL points at the 7# pooler's `factory;reader` entry (pgbouncer.yaml) → this Service. 8# 9# Staleness contract (perf-report §6): the replica lags. `db.read()` serves 10# tree/listing/search from it; permission + If-Match version checks + writes 11# stay on the primary. A read-only hot standby rejects writes at the DB. 12# 13# The replica is a SEPARATE StatefulSet (its own PVC). It is NOT part of the 14# PITR set — a replica loss is re-created by re-running the base backup, and 15# PITR restores target a fresh instance (runbook §8.1), never the replica. 16# ============================================================================= 17apiVersion: v1 18kind: ConfigMap 19metadata: 20 name: factory-pg-replica-config 21 labels: 22 app.kubernetes.io/name: postgres 23 app.kubernetes.io/component: replica 24 app.kubernetes.io/part-of: ai-software-factory 25data: 26 # Empty file — its presence makes postgres start as a standby. Mounted at 27 # $PGDATA/standby.signal after the base backup. 28 standby.signal: "" 29 # Streaming-replication connection to the primary. The standby runs as the 30 # `factory` role (a SUPERUSER-ish app role) so it can stream WAL from the 31 # primary's `factory` role. `primary_slot_name` is empty — with a single 32 # replica a physical replication slot is optional; set it if you add more 33 # standbys (wal-g PITR also uses slots, but those are restore-time only). 34 standby.conf: | 35 primary_conninfo = 'host=factory-db.factory.svc port=5432 user=factory password=%(REPLICA_PASSWORD)s application_name=factory-replica' 36 primary_slot_name = '' 37 hot_standby = on 38 hot_standby_feedback = on 39--- 40apiVersion: apps/v1 41kind: StatefulSet 42metadata: 43 name: postgres-replica 44 labels: 45 app.kubernetes.io/name: postgres 46 app.kubernetes.io/component: replica 47 app.kubernetes.io/part-of: ai-software-factory 48spec: 49 serviceName: factory-db-replica 50 replicas: 1 51 selector: 52 matchLabels: 53 app.kubernetes.io/name: postgres 54 app.kubernetes.io/component: replica 55 template: 56 metadata: 57 labels: 58 app.kubernetes.io/name: postgres 59 app.kubernetes.io/component: replica 60 spec: 61 # initContainer: first boot only. Base-backup the primary's data into 62 # the PVC, then drop standby.signal + standby.conf in place. If the 63 # standby.signal is already present (a restore), skip the base backup 64 # (the volume already holds a valid standby data dir). 65 initContainers: 66 - name: pg-basebackup 67 image: postgres:16-alpine 68 command: 69 - sh 70 - -c 71 - | 72 set -e 73 DATA="${PGDATA:-/var/lib/postgresql/data}" 74 if [ -f "$DATA/standby.signal" ]; then 75 echo "[replica-init] standby.signal present — reusing existing data dir" 76 exit 0 77 fi 78 echo "[replica-init] base backup from primary (first boot)" 79 mkdir -p "$DATA" 80 su postgres -c "PGPASSWORD=\"\$REPLICA_PASSWORD\" pg_basebackup -h postgres.factory.svc -p 5432 -U factory -D $DATA -X stream -c fast" 81 # standby.signal + standby.conf (from the mounted ConfigMap) are 82 # placed by the entrypoint below (it owns $PGDATA). 83 env: 84 - name: PGDATA 85 value: /var/lib/postgresql/data 86 - name: REPLICA_PASSWORD 87 valueFrom: { secretKeyRef: { name: factory-db, key: POSTGRES_PASSWORD } } 88 volumeMounts: 89 - name: pgdata 90 mountPath: /var/lib/postgresql/data 91 containers: 92 - name: postgres 93 image: postgres:16-alpine 94 env: 95 - name: PGDATA 96 value: /var/lib/postgresql/data 97 - name: POSTGRES_USER 98 value: factory 99 - name: POSTGRES_DB 100 value: factory 101 - name: REPLICA_PASSWORD 102 valueFrom: { secretKeyRef: { name: factory-db, key: POSTGRES_PASSWORD } } 103 # The official entrypoint sees a NON-EMPTY $PGDATA with 104 # standby.signal → it starts the standby (no re-init). The 105 # standby.conf is placed by the entrypoint's first-run branch 106 # below; to be safe we also copy it in the initContainer. 107 command: 108 - sh 109 - -c 110 - | 111 set -e 112 DATA="${PGDATA:-/var/lib/postgresql/data}" 113 # Place standby.signal + standby.conf if not already present. 114 [ -f "$DATA/standby.signal" ] || cp /etc/pg-replica/standby.signal "$DATA/standby.signal" 115 [ -f "$DATA/standby.conf" ] || sed "s/%(REPLICA_PASSWORD)s/$REPLICA_PASSWORD/" /etc/pg-replica/standby.conf > "$DATA/standby.conf" 116 # Run postgres as the postgres user (the official image's user). 117 su postgres -c "pg_ctl -D $DATA -w -t 60 start" 118 exec tail -f /dev/null 119 ports: 120 - name: postgres 121 containerPort: 5432 122 envFrom: 123 - secretRef: { name: factory-db } 124 readinessProbe: 125 exec: 126 command: 127 - sh 128 - -c 129 - | 130 # A standby is ready when it can ACCEPT a connection and is 131 # in recovery (pg_is_in_recovery = 't'). 132 [ "$$(su postgres -c "psql -tAc \"select pg_is_in_recovery()\" 2>/dev/null || true)") = "t" ] 133 initialDelaySeconds: 5 134 periodSeconds: 5 135 resources: 136 requests: { cpu: 250m, memory: 512Mi } 137 limits: { cpu: "1", memory: 2Gi } 138 volumeMounts: 139 - name: pgdata 140 mountPath: /var/lib/postgresql/data 141 - name: replica-conf 142 mountPath: /etc/pg-replica 143 readOnly: true 144 volumes: 145 - name: replica-conf 146 configMap: { name: factory-pg-replica-config } 147 volumeClaimTemplates: 148 - metadata: 149 name: pgdata 150 spec: 151 accessModes: ["ReadWriteOnce"] 152 resources: 153 requests: 154 storage: 20Gi 155--- 156apiVersion: v1 157kind: Service 158metadata: 159 name: factory-db-replica 160 labels: 161 app.kubernetes.io/name: postgres 162 app.kubernetes.io/component: replica 163 app.kubernetes.io/part-of: ai-software-factory 164spec: 165 selector: 166 app.kubernetes.io/name: postgres 167 app.kubernetes.io/component: replica 168 ports: 169 - name: postgres 170 port: 5432 171 targetPort: 5432 172