In the previous lesson we took web-store and bookings-api to production. It was thirteen objects and a long checklist, but the margin for error was generous: if a pod is lost, an identical one is born and nobody notices. Here that safety net disappears. bookings-postgres holds the personal data of Rutas Norte's customers, their bookings and their payments. A lost pod can mean minutes of downtime; a lost volume, an existential problem for the company.
This lesson works through the complete scenario of a stateful workload in production: from the uncomfortable question of whether that database should be in Kubernetes at all, to the timed restore procedure, by way of failover, major-version upgrades and the very different case of redis-cache, where losing the data is acceptable.
Compliance warning. Everything described here affects customers' personal data (name, email, travel history, tokenised payment details) and business continuity. Decisions about where backups are located, retention periods, encryption, staff access to the data and cross-region transfers must be reviewed and approved by the organisation's compliance officer before being applied. The values in this material are illustrative and do not replace that review.
Contents
- The prior question: should the database live in Kubernetes?
- What makes a stateful workload special
bookings-postgreswith CloudNativePG- How
bookings-apiconnects: the connection pooler - Failover: simulating it and measuring it
- Backups and point-in-time recovery
- Major-version PostgreSQL upgrades
- Read replicas for
occupancy-reports redis-cache: when losing the data is acceptable- Checklist for a stateful workload in production
- The prior question: should the database live in Kubernetes?
It is tempting to answer "yes" for the sake of consistency: if everything else is in the cluster, so should the database be. That is a bad reason. The right question is which option minimises total risk at a cost the company can bear.
There are three real options.
| Criterion | Provider-managed (RDS, Cloud SQL) | Operator inside the cluster (CloudNativePG) | Hand-rolled StatefulSet |
|---|---|---|---|
| Infrastructure cost | High: a 40-80 % premium over equivalent compute | Medium: you pay list price for compute and disk | Low, apparently |
| Staffing cost | Very low | Medium: you have to know the operator and PostgreSQL | Very high: someone must be an expert in both |
| Operational effort | Backups, patches and failover are the provider's job | The operator automates backups, failover and minor upgrades | Everything by hand or with home-grown scripts |
| Control and fine tuning | Limited: restricted extensions and parameters | Total: any extension and parameter | Total |
| Portability between clouds | Low: it is the classic anchor point | High: the same manifest on any cluster | High |
| Risk of catastrophic failure | Low: someone on 24×7 call responds | Medium: depends on the team's maturity | High: the rare failure arrives at dawn |
| Time to production | Days | Weeks | Months, and never entirely |
| Latency from the pods | One network hop outside the cluster (1-3 ms) | Inside the cluster (< 1 ms) | Inside the cluster |
The decision criterion, in one sentence: use the managed database unless you have a concrete reason not to, and never build a hand-rolled StatefulSet for a production database.
Concrete reasons that justify the operator inside the cluster:
- The managed bill is out of proportion to the size of the company.
- You need extensions or parameters the provider does not allow.
- There is a requirement for portability between clouds or deployment in your own data centre.
- The team already has demonstrable operational maturity on Kubernetes and someone with real PostgreSQL knowledge.
Reasons that justify nothing: "it looks tidier", "this way everything is Kubernetes", "the YAML is pretty".
Rutas Norte's decision. In module 6 CloudNativePG was introduced as a replacement for the hand-rolled StatefulSet, and in module 10 EKS in eu-west-1 was chosen. The platform team has decided to keep bookings-postgres inside the cluster with CloudNativePG, with two explicit conditions written into the minutes: that backups always go to an object store outside the cluster, and that the restore is tested quarterly. The reason for the decision is not technical but economic and about portability: the data volume is modest (around 120 GB), the equivalent managed instance with multi-zone high availability would cost roughly three times as much, and the board wants to keep the option of moving the platform to another cloud in two years' time.
It is a legitimate decision with one clear consequence: the platform team takes on the database on-call rota. If nobody is willing to sign up to that, the right answer was the managed one.
- What makes a stateful workload special
Four properties that break the comfortable assumptions of the previous module.
2.1. Identity
A bookings-api pod is interchangeable with any other. A PostgreSQL pod is not: one is the primary and accepts writes, the rest are replicas and only read. The identity is not in the label, it is in the state of replication at that instant, and it can change without anyone deploying anything.
2.2. Order
When starting a PostgreSQL cluster, the first instance must initialise the data directory; the others must clone from it. When upgrading, you have to upgrade the replicas first and promote afterwards. RollingUpdate knows nothing about that.
2.3. Data that cannot be lost
The volume is not a cache: it is the asset. This changes three things at once:
- The PV's reclaim policy must be
Retain, notDelete(05-02). - Deleting the resource must not delete the data: explicit protection is needed.
- Backups are neither optional nor an operational detail: they are part of the design.
2.4. Upgrades that do not tolerate a naive RollingUpdate
| Aspect | Stateless (bookings-api) |
Stateful (bookings-postgres) |
|---|---|---|
| Replacing a pod | Trivial, in seconds | Means cloning or reconnecting replication |
| Two versions at once | Normal during a deployment | Dangerous: incompatible data formats |
| Rollback | rollout undo, seconds |
Sometimes impossible: the data format has already changed |
| Scaling to zero | No consequences | Total service outage |
| Losing the volume | Irrelevant | Catastrophic |
The practical conclusion: for anything stateful, you do not deploy workloads, you delegate to an operator that understands that specific database. That is exactly what we saw in 06-07 and what we are going to apply now.
bookings-postgres with CloudNativePG
bookings-postgres with CloudNativePGCloudNativePG is an operator that implements a PostgreSQL cluster with streaming replication, primary election, continuous backups and controlled upgrades. It does not use StatefulSets: it manages the pods directly because it needs finer control over ordering.
graph TB
subgraph op["Namespace cnpg-system"]
OPR[CloudNativePG operator]
end
subgraph pro["Namespace rutas-norte-pro"]
subgraph cl["Cluster bookings-postgres"]
P[(instance-1<br/>PRIMARY)]
R1[(instance-2<br/>replica)]
R2[(instance-3<br/>replica)]
end
RW[Service ...-rw<br/>writes]
RO[Service ...-ro<br/>replicas only]
R[Service ...-r<br/>any instance]
POOL[PgBouncer pooler<br/>...-pooler-rw]
API[bookings-api]
INF[occupancy-reports]
end
S3[(Object store<br/>backups + WAL)]
OPR -.reconciles.-> cl
P -->|WAL streaming| R1
P -->|WAL streaming| R2
RW --> P
RO --> R1
RO --> R2
API --> POOL --> RW
INF --> RO
P -->|continuous archiving| S3
3.1. The complete Cluster resource
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
name: bookings-postgres
namespace: rutas-norte-pro
spec:
# 3 instances: 1 primary + 2 replicas. With 2 we would survive one failure,
# but while the replica was being rebuilt we would have no redundancy left.
instances: 3
imageName: ghcr.io/cloudnative-pg/postgresql:16.4
# On losing the primary, we wait at most 30 s before promoting.
# Higher = more downtime; lower = risk of promotion caused by a
# transient network blip.
failoverDelay: 0
switchoverDelay: 60
primaryUpdateStrategy: unsupervised # the operator upgrades and switches over on its own
primaryUpdateMethod: switchover # switches over cleanly, does not restart the primary
bootstrap:
initdb:
database: bookings
owner: app_bookings
secret:
name: bookings-postgres-app # created by External Secrets
encoding: UTF8
localeCollate: es_ES.UTF-8
localeCType: es_ES.UTF-8
postInitApplicationSQL:
- CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
- CREATE EXTENSION IF NOT EXISTS pgcrypto;
postgresql:
parameters:
max_connections: "200"
shared_buffers: "1GB" # ~25 % of the pod's memory
effective_cache_size: "3GB"
work_mem: "16MB"
maintenance_work_mem: "256MB"
wal_compression: "on"
max_wal_size: "4GB"
checkpoint_completion_target: "0.9"
random_page_cost: "1.1" # SSD disk
log_min_duration_statement: "500" # logs queries taking more than 500 ms
log_checkpoints: "on"
shared_preload_libraries: "pg_stat_statements"
pg_hba:
# TLS only and only from the cluster's pod network.
- hostssl bookings app_bookings 10.244.0.0/16 scram-sha-256
resources:
requests: { cpu: "1", memory: 4Gi }
limits: { memory: 4Gi } # Guaranteed QoS on memory (03-05)
storage:
size: 200Gi
storageClass: rutasnorte-fast # the high-IOPS class from 05-04
walStorage:
# WAL on a separate volume: stops a WAL write spike from leaving
# the data with no space, and improves performance.
size: 50Gi
storageClass: rutasnorte-fast
# Spread across zones: never two instances on the same node.
affinity:
enablePodAntiAffinity: true
topologyKey: kubernetes.io/hostname
podAntiAffinityType: required
monitoring:
enablePodMonitor: true # Prometheus discovers it by itself (07-03)
backup:
retentionPolicy: "30d"
barmanObjectStore:
destinationPath: s3://rutasnorte-backups-pro/bookings-postgres
s3Credentials:
inheritFromIAMRole: true # federated identity, no keys (10-06)
wal:
compression: gzip
maxParallel: 4
data:
compression: gzip
immediateCheckpoint: false
jobs: 2The decisions worth understanding:
instances: 3and not 2. With two instances, as soon as one goes down you are left with no redundancy exactly when you need it most, because rebuilding a 120 GB replica takes a while. Three is the minimum you can defend in production.primaryUpdateMethod: switchover. When a minor upgrade is applied, the operator upgrades the replicas first, then promotes an already-upgraded replica and finally upgrades the old primary. The outage is measured in seconds, not minutes.- Separate
walStorage. The WAL has a very different write pattern from the data and, above all, if the data volume fills up because of the WAL, PostgreSQL stops. Separating them turns a high-severity incident into a minor one. podAntiAffinityType: required. Withpreferred, a tightly packed cluster can place two instances on the same node and high availability becomes fictional.inheritFromIAMRole: true. No access key stored anywhere: the pod's ServiceAccount has a cloud role attached. It is the direct continuation of what we saw in 08-01 and 10-06.retentionPolicy: "30d". This value must be validated by the compliance officer: retaining too little breaches accounting and continuity obligations; retaining too much, with personal data inside, breaches the storage-limitation principle.
3.2. The scheduled backup
The Cluster's backup section defines where and how; you also need to say when the base backup is taken.
apiVersion: postgresql.cnpg.io/v1
kind: ScheduledBackup
metadata:
name: bookings-postgres-daily
namespace: rutas-norte-pro
spec:
# Format with seconds: 03:15 every day (cluster time, UTC).
schedule: "0 15 3 * * *"
backupOwnerReference: self
cluster:
name: bookings-postgres
method: barmanObjectStoreWith the daily base backup and continuous WAL archiving, the data-loss window is seconds rather than a day: you restore the most recent base backup and replay the WAL up to the desired instant. That is the point-in-time recovery of section 6.
3.3. How the operator picks the primary and which Services it publishes
The operator keeps one pod as the primary and records its identity in the Cluster status. When the primary stops responding to the checks for longer than failoverDelay, the operator picks the replica with the most advanced WAL, promotes it and reconfigures the others to follow the new one. All of that without a single manifest changing.
The Services it creates automatically:
| Service | What it points to | Use at Rutas Norte |
|---|---|---|
bookings-postgres-rw |
Always the current primary | Writes from bookings-api |
bookings-postgres-ro |
Replicas only | Queries from occupancy-reports |
bookings-postgres-r |
Any instance | Diagnostics; rarely used |
NAME AGE INSTANCES READY STATUS PRIMARY
bookings-postgres 214d 3 3 Cluster in healthy state bookings-postgres-1
- How
bookings-api connects: the connection pooler
bookings-api connects: the connection poolerPostgreSQL creates one process per connection. With max_connections: 200 and an HPA that over the May bank-holiday weekend takes bookings-api to 40 replicas with a pool of 20 connections each, the arithmetic is devastating: 800 connections requested against 200 available. The result is not slowness, it is connections being refused and 500 errors on ticket sales.
The solution is a connection pooler (PgBouncer) in front of the database, which CloudNativePG manages as a resource of its own:
apiVersion: postgresql.cnpg.io/v1
kind: Pooler
metadata:
name: bookings-postgres-pooler-rw
namespace: rutas-norte-pro
spec:
cluster:
name: bookings-postgres
instances: 3 # the pooler must be redundant too
type: rw # points to the current primary, following it through switchovers
pgbouncer:
poolMode: transaction # returns the connection at the end of each transaction
parameters:
max_client_conn: "1000" # what we accept from the pods
default_pool_size: "40" # what we actually open against PostgreSQL
reserve_pool_size: "10"
server_idle_timeout: "120"
template:
spec:
containers: []
topologySpreadConstraints:
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: ScheduleAnyway
labelSelector:
matchLabels:
cnpg.io/poolerName: bookings-postgres-pooler-rwA thousand application connections are multiplexed over forty real ones. That is why the bookings-api ConfigMap from the previous lesson points to bookings-postgres-pooler-rw and not directly to the -rw Service.
The price to pay: poolMode: transaction is incompatible with session-bound features (named prepared statements in some drivers, LISTEN/NOTIFY, session temporary tables, persistent SET). In bookings-api it was verified that none of them is used. If any were, the alternative is session, which greatly reduces the pooler's advantage.
- Failover: simulating it and measuring it
A high-availability mechanism that has never been tested is a hypothesis. This is the test Rutas Norte runs in rutas-norte-pre before every peak season.
5.1. The experiment
# 1. Initial state: who is the primary.
kubectl -n rutas-norte-pre get cluster bookings-postgres -o jsonpath='{.status.currentPrimary}{"\n"}'
# 2. Sustained write load for the duration of the experiment.
kubectl -n rutas-norte-pre run write-load --rm -it --restart=Never \
--image=registry.rutasnorte.example/tools/pgbench:16 -- \
pgbench -h bookings-postgres-pooler-rw -U app_bookings -c 10 -T 180 -P 5 bookings &
# 3. We kill the primary outright (not a clean delete: we simulate node loss).
kubectl -n rutas-norte-pre delete pod bookings-postgres-1 --grace-period=0 --force
# 4. We watch the promotion second by second.
kubectl -n rutas-norte-pre get cluster bookings-postgres -w \
-o custom-columns='PRIMARY:.status.currentPrimary,READY:.status.readyInstances,PHASE:.status.phase'PRIMARY READY PHASE
bookings-postgres-1 3 Cluster in healthy state
bookings-postgres-1 2 Failing over
bookings-postgres-2 2 Failing over
bookings-postgres-2 2 Cluster in healthy state
bookings-postgres-2 3 Cluster in healthy state5.2. The measured numbers
Results from the latest drill in rutas-norte-pre (three repetitions, mean values):
| Phase | Time |
|---|---|
| Detection of the primary's failure | 4 s |
| Promotion of the most advanced replica | 6 s |
Update of the -rw Service to the new IP |
2 s |
| Pooler reconnection | 3 s |
| Perceived total write outage | ≈ 15 s |
| Rebuilding the failed instance as a replica | 4 min |
| Data loss (committed transactions) | 0 |
Fifteen seconds unable to write. If bookings-api does nothing about it, those fifteen seconds are fifteen seconds of 500 errors on ticket purchases, and in the middle of the May bank-holiday weekend that is several hundred lost sales.
5.3. What bookings-api must do to survive those seconds
Three mechanisms, and the order matters.
Bounded timeouts. Without a timeout, a connection to a dead primary hangs until the operating system's timeout (minutes). With one, it fails fast and can be retried.
const pool = new Pg.Pool({
host: process.env.PG_HOST,
max: Number(process.env.PG_POOL_MAX),
connectionTimeoutMillis: 3000, // acquiring a connection
idleTimeoutMillis: 30000,
statement_timeout: 5000, // no query blocks a thread indefinitely
keepAlive: true
});Retries with exponential backoff and jitter, only for what is idempotent. Retrying a SELECT is safe. Retrying INSERT INTO bookings without more thought can duplicate the booking and charge the customer twice: an idempotency key is required.
const TRANSIENT_ERRORS = new Set([
'ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT',
'57P01', // admin_shutdown: the primary is shutting down
'57P03', // cannot_connect_now: starting up
'40001' // serialization_failure
]);
async function withRetries(operation, { attempts = 4, baseMs = 200 } = {}) {
let last;
for (let i = 0; i < attempts; i++) {
try {
return await operation();
} catch (e) {
last = e;
const transient = TRANSIENT_ERRORS.has(e.code);
if (!transient || i === attempts - 1) throw e;
// Exponential backoff with jitter: stops 40 replicas from
// all retrying in the very same millisecond.
const delay = baseMs * 2 ** i * (0.5 + Math.random());
log.warn({ attempt: i + 1, code: e.code, delay }, 'retrying');
await new Promise((r) => setTimeout(r, delay));
}
}
throw last;
}With four attempts and a 200 ms base, the window covered is around 4-6 seconds. It does not cover the full fifteen seconds of the outage, and that is deliberate: stretching it further would make requests pile up on the server until memory ran out.
A circuit breaker for what cannot be retried. When failures exceed a threshold, the circuit opens and the API stops trying for a few seconds, returning an immediate degraded response instead of accumulating hung requests.
// Honest degradation: the timetable query is served from redis-cache,
// the purchase returns 503 with Retry-After instead of hanging.
app.post('/bookings', async (req, res) => {
if (circuit.isOpen()) {
return res.status(503)
.set('Retry-After', '10')
.json({ error: 'service_temporarily_unavailable' });
}
...
});The combination of the three reduces the real impact of a failover to a few seconds of partial degradation, with sales recovering on their own. It is the difference between a severity-1 incident and a note in the log.
- Backups and point-in-time recovery
6.1. The three questions that define the policy
| Question | Concept | Value at Rutas Norte |
|---|---|---|
| How much data can we lose? | Recovery point objective (RPO) | 5 minutes |
| How long can we be down? | Recovery time objective (RTO) | 1 hour |
| How long do we keep the backups? | Retention | 30 days (pending compliance validation) |
With continuous WAL archiving, the real RPO is seconds. The RTO is the one you have to measure, and it is only measured by restoring.
6.2. Point-in-time recovery
CloudNativePG restores by creating a new Cluster from the object store. You never restore "on top of" the existing cluster: you bring up a parallel one, verify it and then decide.
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
name: bookings-postgres-restored
namespace: rutas-norte-pro
spec:
instances: 1 # one instance is enough to verify
imageName: ghcr.io/cloudnative-pg/postgresql:16.4
storage:
size: 200Gi
storageClass: rutasnorte-fast
bootstrap:
recovery:
source: backup-source
recoveryTarget:
# Just before the accidental deletion at 11:47.
targetTime: "2026-08-06 11:45:00.000000+00:00"
externalClusters:
- name: backup-source
barmanObjectStore:
destinationPath: s3://rutasnorte-backups-pro/bookings-postgres
serverName: bookings-postgres
s3Credentials:
inheritFromIAMRole: true
wal:
maxParallel: 8 # high parallelism: speeds up WAL replayNAME INSTANCES READY STATUS
bookings-postgres-restored 1 0 Setting up primary
bookings-postgres-restored 1 0 Recovering from backup
bookings-postgres-restored 1 1 Cluster in healthy stateVerification before accepting the restore as good:
kubectl -n rutas-norte-pro exec -it bookings-postgres-restored-1 -- \
psql -U postgres bookings -c \
"SELECT count(*) AS bookings, max(created_at) AS latest FROM bookings;"6.3. The real time, measured
Drill of 12 July 2026 on a 118 GB backup:
| Phase | Time |
|---|---|
| Deciding the target instant and writing the manifest | 6 min |
| Volume provisioning and base backup download | 21 min |
| WAL replay up to the target instant | 9 min |
| Integrity verification and row count | 4 min |
Switching bookings-api over to the restored cluster |
3 min |
| Total | 43 min |
Forty-three minutes, within the one-hour RTO, but with little margin. Two actions came out of the drill: raising maxParallel from 4 to 8 for recovery (already applied above) and keeping the restore manifest written and versioned in the repository, with the target instant as the only parameter to fill in. The six minutes of "writing the manifest" under pressure are the worst possible place to improvise.
A backup that has never been restored does not exist. That is a literal statement, not a rhetorical figure. The real failure modes are mundane: the store credential expired four months ago and nobody looked at the alert; the bucket had a lifecycle policy that moved objects to cold storage with hours of retrieval latency; the backup was being taken, but of a database that was no longer the production one. All of them are caught by restoring, and none of them is caught by checking that the
ScheduledBackupis green.
Rutas Norte's rule: a full timed restore every quarter, with minutes taken and the time written down. It is in the maintenance calendar in 11-06.
6.4. Watching that backups are actually happening
- alert: PostgresBackupStale
expr: |
time() - cnpg_collector_last_available_backup_timestamp{cluster="bookings-postgres"} > 36 * 3600
for: 15m
labels: { severity: critical, team: platform }
annotations:
summary: "No valid backup of bookings-postgres for more than 36 hours"
runbook: "https://wiki.rutasnorte.example/runbooks/postgres-backup-failed"
- Major-version PostgreSQL upgrades
Minor upgrades (16.4 → 16.6) are handled by the operator on its own: you change imageName, it upgrades the replicas, switches over and upgrades the old primary. An outage of seconds.
Major ones (16 → 17) are another matter: the data directory format changes, streaming replication does not work between different versions and rolling back is not trivial. There are three routes.
| Method | Outage | Risk | Rollback | When to use it |
|---|---|---|---|---|
Dump and restore (pg_dump/pg_restore) |
Hours for 120 GB | Low | Easy: the original is still intact | Small databases or a wide window |
In-place pg_upgrade |
5-15 min | Medium | Hard once converted | Short window and an experienced team |
| Logical replication to a new cluster | 1-2 min | Low-medium | Easy up until the cutover | When the outage must be minimal |
Rutas Norte chose logical replication. The procedure, in summary:
# Target cluster on 17, populated by import with logical replication.
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
name: bookings-postgres-17
namespace: rutas-norte-pro
spec:
instances: 3
imageName: ghcr.io/cloudnative-pg/postgresql:17.2
storage: { size: 200Gi, storageClass: rutasnorte-fast }
bootstrap:
initdb:
import:
type: microservice
databases: ["bookings"]
source:
externalCluster: source-16
externalClusters:
- name: source-16
connectionParameters:
host: bookings-postgres-rw.rutas-norte-pro.svc.cluster.local
user: postgres
dbname: bookings
password:
name: bookings-postgres-superuser
key: passwordSequence on the day of the change:
- Weeks before: bring up the 17 cluster in
rutas-norte-pre, run the fullbookings-apitest suite against it and compare execution plans for the ten most expensive queries. A change of plan in the planner is the real risk of a major upgrade. - Days before: bring up 17 in
proand leave it syncing by logical replication until the lag is down to milliseconds. - Cutover window (about 90 s): put
bookings-apiinto read-only mode via a feature flag, confirm zero lag, stop the subscription, point the ConfigMap at the 17 cluster's pooler, restart the deployment and remove read-only mode. - Afterwards: watch for 48 hours. The 16 cluster is kept for a week shut down but intact, as the rollback plan.
One detail that is always forgotten: a full ANALYZE after the migration. The planner statistics are not carried over, and without them queries run slowly for hours and it looks as though the upgrade went wrong.
- Read replicas for
occupancy-reports
occupancy-reportsoccupancy-reports is the nightly CronJob that goes over months of bookings to calculate occupancy by route and by time slot. These are heavy queries which, run against the primary, compete with ticket sales.
The solution is straightforward: point it at the -ro Service, which only routes to replicas.
apiVersion: batch/v1
kind: CronJob
metadata:
name: occupancy-reports
namespace: rutas-norte-pro
spec:
schedule: "0 3 * * *"
concurrencyPolicy: Forbid
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 5
jobTemplate:
spec:
backoffLimit: 2
template:
spec:
restartPolicy: OnFailure
serviceAccountName: occupancy-reports
containers:
- name: reports
image: registry.rutasnorte.example/rutasnorte/reports@sha256:c7d8e9f0a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f6071829304152
env:
- name: PG_HOST
value: bookings-postgres-ro # replicas, never the primary
- name: PG_OPTIONS
# Tolerates up to 5 min of replication lag instead of
# cancelling the query if incoming WAL conflicts with it.
value: "-c statement_timeout=1800000"
resources:
requests: { cpu: 500m, memory: 1Gi }
limits: { memory: 2Gi }Two warnings about read replicas:
- Eventual consistency. A replica runs a few milliseconds behind. For reports that is irrelevant; for "I have just booked and I cannot see my booking" it is an error visible to the user. Rule at Rutas Norte: what the user has just written is read from the primary; everything else can go to a replica.
- Recovery conflicts. A long query on the replica can conflict with incoming WAL and be cancelled. It is tuned with
max_standby_streaming_delay, accepting more replication lag during the reports in exchange.
redis-cache: when losing the data is acceptable
redis-cache: when losing the data is acceptableRedis is a stateful workload too, but of a different category: its data is rebuildable. That difference changes everything.
At Rutas Norte, redis-cache holds three things:
| Data | Rebuildable? | Consequence of losing it |
|---|---|---|
| Timetable and price cache | Yes, from PostgreSQL | A load spike on the database for a few minutes |
| Shopping basket (15 min TTL) | No | The user loses their selection and has to redo it |
| Rate-limiting counters | Yes, they rebuild themselves | A brief window with no effective rate limiting |
The basket is the awkward case: it is not critical like a confirmed booking, but losing it is visible and annoying.
Rutas Norte's decision: AOF persistence enabled with appendfsync everysec, a single node with a persistent volume and no replica. The reasoning:
- Without persistence, every pod restart (a node upgrade, a configuration change) empties every active basket. That happens several times a month.
- With AOF every second, a clean restart loses at most one second of writes and the baskets survive.
- Setting up Redis Sentinel or a cluster for data with a fifteen-minute TTL is complexity with no return.
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: redis-cache
namespace: rutas-norte-pro
spec:
serviceName: redis-cache
replicas: 1
selector:
matchLabels: { app.kubernetes.io/name: redis-cache }
template:
metadata:
labels: { app.kubernetes.io/name: redis-cache }
spec:
securityContext:
runAsNonRoot: true
runAsUser: 999
fsGroup: 999
terminationGracePeriodSeconds: 30
containers:
- name: redis
image: redis@sha256:d1e2f3a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60
args:
- --appendonly
- "yes"
- --appendfsync
- everysec
- --maxmemory
- 900mb
- --maxmemory-policy
# allkeys-lru would evict baskets when memory filled up. volatile-lru
# only evicts keys with a TTL, which are precisely the cache ones.
- volatile-lru
- --save
- ""
ports: [{ name: redis, containerPort: 6379 }]
resources:
requests: { cpu: 100m, memory: 1Gi }
limits: { memory: 1Gi }
readinessProbe:
exec: { command: ["redis-cli", "ping"] }
periodSeconds: 5
livenessProbe:
tcpSocket: { port: redis }
periodSeconds: 15
volumeMounts:
- { name: data, mountPath: /data }
volumeClaimTemplates:
- metadata: { name: data }
spec:
accessModes: ["ReadWriteOnce"]
storageClassName: rutasnorte-standard # does not need high IOPS
resources: { requests: { storage: 10Gi } }And, above all, the application assumes Redis may not be there: if the cache does not respond, bookings-api goes to PostgreSQL; if the basket is gone, the user is asked to redo their selection with a clear message. No Redis call may bring down a request.
async function schedulesFor(line, date) {
try {
const cached = await redis.get(`schedules:${line}:${date}`);
if (cached) return JSON.parse(cached);
} catch (e) {
log.warn({ err: e.message }, 'redis unavailable, falling back to the database');
}
const rows = await querySchedules(line, date);
redis.setex(`schedules:${line}:${date}`, 300, JSON.stringify(rows)).catch(() => {});
return rows;
}One extra caution: if Redis goes down over the May bank-holiday weekend, the entire read load shifts to PostgreSQL all at once. You have to size the database to survive that scenario, or the failure of a dispensable component turns into the failure of one that is not.
- Checklist for a stateful workload in production
| # | Check | Status at Rutas Norte |
|---|---|---|
| 1 | Documented decision on managed / operator / hand-rolled, with reasons | ✔ Platform minutes |
| 2 | A mature operator is used, not a home-grown StatefulSet | ✔ CloudNativePG |
| 3 | At least 3 instances, spread across zones with required anti-affinity |
✔ |
| 4 | Volumes with reclaimPolicy: Retain and a suitable class |
✔ rutasnorte-fast |
| 5 | WAL on a separate volume | ✔ 50 Gi |
| 6 | Automatic backups to a store outside the cluster | ✔ Object store |
| 7 | Continuous WAL archiving for point-in-time recovery | ✔ Real RPO of seconds |
| 8 | Full restore tested and timed this quarter | ✔ 12/07/2026, 43 min |
| 9 | Alert if there is no recent valid backup | ✔ PostgresBackupStale |
| 10 | Failover simulated and measured | ✔ ≈ 15 s, 0 loss |
| 11 | The application has timeouts, retries and a circuit breaker | ✔ |
| 12 | Connection pooler sized for the HPA's peak | ✔ 1000 → 40 |
| 13 | Heavy reads directed to replicas | ✔ occupancy-reports |
| 14 | Major-upgrade procedure written and tested in pre |
✔ Logical replication |
| 15 | Database-specific metrics and dashboards | ✔ PodMonitor + dashboard |
| 16 | Encryption at rest and in transit | ✔ Encrypted volumes, hostssl |
| 17 | Data access with minimal, audited RBAC | ✔ Quarterly review |
| 18 | Compliance review of retention, location and access | ⧗ Pending annual renewal |
Row 18 is not bureaucracy: in a PostgreSQL cluster holding personal data, backup retention, the region of the object store, who can open a psql session against production and what is recorded about it are decisions with legal consequences, not merely technical ones.
Common Mistakes and Tips
- Putting the database on Kubernetes for the sake of aesthetic consistency. If nobody on the team knows how to do a point-in-time recovery or is willing to be on call, the right answer is the managed database, even if it costs more.
- Trusting a green
ScheduledBackup. The indicator says the process ran, not that the backup is restorable. Only a timed restore proves that. - Backups in the same cluster or the same account that produces the data. An accidental deletion with broad permissions, or a credential compromise, takes the data and the backup at once. A separate store and, if possible, a separate account with immutability.
- Connecting the application to the
-rwService with no pooler. It works perfectly until the HPA scales, and then it fails on exactly the busiest sales day. - Retrying non-idempotent writes. It duplicates bookings and charges. Every retryable write needs an idempotency key.
- Using
allkeys-lruin a Redis that holds baskets. When memory fills up it evicts active baskets.volatile-lru, with a TTL only on what is cache, protects the data that does matter. - Tip: write the restore manifest before you need it and keep it in the repository with the target instant as a blank to fill in. It saves the most expensive minutes of the incident.
- Tip: run
ANALYZEafter any migration or major upgrade. Without statistics the planner makes bad decisions and everything looks broken. - Tip: size the database for the scenario in which the cache is absent. Otherwise a dispensable failure turns into a critical one.
Exercises
Exercise 1: choosing the right option
Rutas Norte is about to launch a new product, freight-routes, with its own PostgreSQL database of around 8 GB, three developers, no dedicated platform team and a launch planned in six weeks. The data includes contact details for corporate customers. Recommend one of the three options and justify the decision with at least four criteria from the table in section 1. Also state what additional review is required because of the contact details.
Exercise 2: sizing the connection pooler
bookings-api has an HPA with maxReplicas: 40 and each replica opens up to 20 connections. occupancy-reports opens 5 connections against the replicas. bookings-postgres has max_connections: 200, of which PostgreSQL reserves 3 for the superuser. Work out the maximum connections requested without a pooler, explain what happens at the May bank-holiday peak and check whether max_client_conn: 1000 and default_pool_size: 40 are appropriate values.
Exercise 3: diagnosing a failed restore drill
During the quarterly drill, the restore Cluster ends up like this:
NAME INSTANCES READY STATUS
bookings-postgres-restored 1 0 Recovering from backup
$ kubectl -n rutas-norte-pro logs bookings-postgres-restored-1 | tail -3
ERROR: WAL segment 000000010000004A000000E1 not found in archive
FATAL: could not receive data from WAL streamList three possible causes, say how to tell them apart and what fix you would apply in each case. Also state what this implies about the real RPO.
Solutions
Solution 1. Recommendation: the provider-managed database. Criteria: (a) staffing cost, there is no platform team and running the operator would fall on three developers who need to be building the product; (b) time to production, six weeks leaves no room to acquire operational maturity with CloudNativePG; (c) risk of catastrophic failure, with no on-call rota in place a night-time failure would go unanswered; (d) infrastructure cost, the provider's premium on 8 GB is small in absolute terms, quite unlike the case of bookings-postgres with 120 GB. The hand-rolled StatefulSet is ruled out from the start. Additional review: the contact details of corporate customers are personal data of the individuals acting as contacts, so the choice of region, the backup retention and the terms with the data processor (the cloud provider) must be reviewed by the compliance officer before signing the contract.
Solution 2. Without a pooler: 40 × 20 = 800 connections from bookings-api, plus 5 from reports = 805 requested against 197 usable. At the May bank-holiday peak, as soon as 197 is exceeded PostgreSQL refuses with FATAL: sorry, too many clients already; the affected pods fail their readiness probes, leave the load balancer, the HPA sees more load on the remaining ones and scales further, making the problem worse: a destructive feedback loop. With the pooler: max_client_conn: 1000 covers the 805 requested with margin (appropriate); default_pool_size: 40 plus reserve_pool_size: 10 opens at most 50 real connections against the primary, comfortably within 197, leaving room for reports, maintenance and the superuser. Both values are correct. It is worth watching the PgBouncer queue-wait metric: if it grows during the peak, the bottleneck becomes default_pool_size and it would need raising (with headroom up to around 150 real connections).
Solution 3. Possible causes: (a) insufficient retention, the WAL for that period has already been deleted by the 30-day policy or by a bucket lifecycle rule; you tell it apart by listing the wals/ prefix in the store and checking whether the segment exists; fix: choose a target instant within the available range and review the retention policy and the lifecycle rules. (b) Continuous archiving failure, the primary stopped archiving WAL at some point (expired credential, permissions, full disk) and there is a gap; you tell it apart by looking at cnpg_collector_last_failed_archive_time and the primary's logs; fix: repair the archiving and, very importantly, take a new base backup immediately. (c) Wrong permissions or path in the restore, the serverName or the destinationPath do not match those of the source, or the federated role has no read access to that prefix; you tell it apart because every segment would fail, not one specific one; fix: correct serverName/destinationPath and the role's permissions. Implication for the RPO: if the archiving has gaps, the declared RPO of 5 minutes is false, and the real one is the age of the last complete base backup, which can be nearly 24 hours. That is exactly why the PostgresBackupStale alert exists and why the quarterly drill is mandatory.
Conclusion
We have worked through, end to end, the scenario of a stateful workload in production. We started with the question best not dodged — whether the database should be in Kubernetes — and arrived at a reasoned decision with conditions attached: at Rutas Norte it stays inside the cluster with CloudNativePG, with backups outside and a restore tested every quarter. We saw what makes state special (identity, order, irreplaceable data, upgrades that do not tolerate RollingUpdate), the complete Cluster resource with its decisions justified, the connection pooler that stops the HPA's success from bringing down the database, the failover measured at fifteen seconds and the three mechanisms that let bookings-api ride through them with no visible errors, point-in-time recovery with its real time of 43 minutes, the major-version upgrade by logical replication, the read replicas for the reports and redis-cache as the case where losing the data is acceptable, provided the application accepts that.
The idea that sums the lesson up: with stateful workloads, what saves you is not the manifests but the tested procedures. The backup that was never restored, the failover that was never simulated and the upgrade that was never rehearsed in pre are debt waiting to fall due at the worst possible moment.
We now have the stateless application and the database in production. What we have not covered yet is how the code gets there. In the next lesson, CI/CD with Kubernetes, we follow the complete path from a developer's git push to the pod serving requests in rutas-norte-pro, by way of building the image, scanning and signing it, testing against an ephemeral cluster and promoting between environments with Argo CD.
Kubernetes Course
Module 1: Introduction to Kubernetes
- What Is Kubernetes?
- Kubernetes Architecture
- Key Concepts and Terminology
- Setting Up a Kubernetes Cluster
- The Kubernetes CLI: kubectl
- Objects, YAML Manifests and the Declarative Model
- The Course Project: the Rutas Norte Platform
Module 2: Core Kubernetes Components
- Pods
- ReplicaSets
- Deployments
- Updates, Rollbacks and Deployment Strategies
- Services
- Namespaces
- Labels, Selectors and Annotations
Module 3: Configuration and Secret Management
- ConfigMaps
- Secrets
- Environment Variables
- Resource Quotas and Limits
- LimitRanges and Quality of Service (QoS) Classes
- ServiceAccounts and API Access from Pods
Module 4: Networking in Kubernetes
- Cluster Networking
- Service Types
- Internal DNS and Service Discovery
- Ingress Controllers
- TLS and Certificate Management with cert-manager
- Network Policies
Module 5: Storage in Kubernetes
- Volumes
- Persistent Volumes
- Persistent Volume Claims
- Storage Classes
- Dynamic Provisioning, Expansion and Snapshots
- Backup and Restore of Persistent Data
Module 6: Advanced Kubernetes Concepts
- StatefulSets
- DaemonSets
- Jobs and CronJobs
- Init Containers, Sidecars and Multi-Container Patterns
- Scheduling: Affinity, Taints and Tolerations
- Custom Resource Definitions (CRDs)
- Operators and the Controller Pattern
Module 7: Monitoring and Logging
- Health Checks and Probes
- Metrics Server and kubectl top
- Monitoring with Prometheus
- Visualization and Alerting with Grafana and Alertmanager
- Centralized Logging with Elasticsearch, Fluentd and Kibana (EFK)
- Application Debugging and Cluster Events
Module 8: Kubernetes Security
- Role-Based Access Control (RBAC)
- Security Contexts and Container Hardening
- Pod Security Policies and Pod Security Standards
- Network Security
- Image Security
- Auditing, Scanning and Vulnerability Management
Module 9: Scaling and Performance
- Horizontal Pod Autoscaling
- Vertical Pod Autoscaling
- Cluster Autoscaling
- Event-Driven and Custom-Metric Scaling with KEDA
- High Availability: PodDisruptionBudgets and Topology
- Performance Tuning
Module 10: Kubernetes Ecosystem and Tooling
- Minikube and Local Environments with kind
- Kubeadm
- Helm
- Kustomize
- GitOps with Argo CD and Flux
- Managed Kubernetes: EKS, AKS and GKE
Module 11: Case Studies and Real-World Applications
- Deploying a Web Application
- Running Stateful Applications
- CI/CD with Kubernetes
- Deployment Strategies: Blue-Green and Canary
- Multi-Cluster Management
- Production Operations: Incidents, Runbooks and Costs
