Running PgBouncer as a Kubernetes Sidecar
This guide is part of PgBouncer vs RDS Proxy vs pgpool-II. Once you have chosen PgBouncer, a second decision follows immediately: run it as a shared central deployment, or as a sidecar container in every application pod. The two topologies fail differently, and the sidecar is the right default for most Kubernetes workloads.
The problem the sidecar solves is connection amplification. A hundred pods each holding a modest pool of twenty connections is two thousand backends against a database that can serve two hundred. A central PgBouncer fixes the arithmetic but introduces a network hop, a single point of failure, and a component that must itself be sized, monitored, and rolled. A sidecar keeps the multiplexing while removing the hop and the shared failure domain — at the cost of more PgBouncer processes to configure.
Sidecar versus central deployment
| Dimension | Sidecar (one per pod) | Central deployment (shared) |
|---|---|---|
| Network path | localhost, no extra hop | one extra hop, plus a Service or load balancer |
| Failure domain | one pod | every client of the shared instance |
| Connection amplification | still one pool per pod, but a small one | fully collapsed across the fleet |
| Server-side arithmetic | pods x pool_size — must stay small per pod |
one budget, easy to reason about |
| Rollout risk | rolls with the application | a separate rollout that disrupts everyone |
| Prepared statements | same constraints either way | same constraints either way |
| Operational cost | config duplicated into every pod | one thing to run and monitor |
The trade-off is stark on the server-side arithmetic row. A sidecar does not eliminate amplification, it reduces it: each pod still opens pool_size server connections, so a hundred pods with default_pool_size = 4 is four hundred backends. That is better than two thousand and worse than a central pool of forty. The sidecar wins when pool_size per pod can be genuinely small — which transaction pooling makes possible — and loses when the fleet is very large.
A useful threshold: if replicas x default_pool_size comfortably fits inside your database’s connection budget with headroom, use sidecars. If it does not, either lower default_pool_size further or move to a central deployment. Do not run both layers unless you have a specific reason; two proxies in series double the failure modes and make SHOW POOLS output ambiguous.
The pod specification
The essential shape is a second container in the same pod, listening on localhost, with the application pointed at 127.0.0.1:6432 instead of the database:
apiVersion: apps/v1
kind: Deployment
metadata:
name: orders-api
spec:
replicas: 40
template:
spec:
terminationGracePeriodSeconds: 60
containers:
- name: app
image: registry.internal/orders-api:1.24.0
env:
- name: DATABASE_URL
value: postgres://orders_app@127.0.0.1:6432/orders
lifecycle:
preStop:
exec:
command: ["sleep", "10"] # stop taking traffic first
- name: pgbouncer
image: edoburu/pgbouncer:1.23.1
ports:
- name: pgbouncer
containerPort: 6432
env:
- name: DB_HOST
value: orders-db.internal
- name: DB_USER
valueFrom: { secretKeyRef: { name: orders-db, key: username } }
- name: DB_PASSWORD
valueFrom: { secretKeyRef: { name: orders-db, key: password } }
- name: POOL_MODE
value: transaction
- name: DEFAULT_POOL_SIZE
value: "4"
- name: MIN_POOL_SIZE
value: "1"
- name: MAX_CLIENT_CONN
value: "200"
- name: SERVER_IDLE_TIMEOUT
value: "600"
resources:
requests: { cpu: 50m, memory: 32Mi }
limits: { memory: 64Mi }
readinessProbe:
tcpSocket: { port: 6432 }
initialDelaySeconds: 2
periodSeconds: 5
lifecycle:
preStop:
exec:
command: ["sh", "-c", "sleep 25"] # outlive the app container
Four details in that manifest carry most of the value.
DEFAULT_POOL_SIZE is small deliberately. Forty replicas at four server connections is 160 backends — a number that fits inside a 200-connection database with headroom. Raising it to twenty because “twenty felt right for a pool” would produce 800 and exhaust the ceiling. The sizing arithmetic is in configuring PgBouncer pool_size and max_client_conn, applied here per pod rather than per cluster.
PgBouncer is single-threaded, so a CPU request of 50m is usually generous for a sidecar handling one pod’s traffic, and a CPU limit is dangerous: throttling a single-threaded event loop shows up as latency on every query through it. Set a memory limit, leave CPU unlimited or generously above the request.
No CPU limit, but a memory limit. PgBouncer’s memory is dominated by max_client_conn x pkt_buf; 200 clients at 2 KB is under half a megabyte, so 64 Mi is ample and protects the node from a runaway.
The application still needs its own pool. A common mistake is to set the application pool to an enormous value on the theory that PgBouncer absorbs it. The application pool bounds queue depth at the layer where you can attach a timeout and a circuit breaker, so keep it modest — typically two to four times the pod’s thread or worker count.
Shutdown ordering
This is where sidecar deployments most often go wrong. When Kubernetes terminates a pod, it sends SIGTERM to every container simultaneously. If PgBouncer exits first, the application’s in-flight transactions fail with connection-reset errors during every rolling deploy.
The fix has two parts. First, make PgBouncer outlive the application by giving it a longer preStop sleep, as above. Second, use a SIGTERM handling mode in PgBouncer that drains rather than kills. PgBouncer’s default SIGTERM behaviour is an immediate shutdown; SIGINT performs a graceful one that waits for active transactions to finish. Container images differ in what they forward, so verify with your image:
lifecycle:
preStop:
exec:
command:
- sh
- -c
- |
# graceful drain, then wait for the app to finish
kill -INT 1 || true
sleep 25
If your cluster runs Kubernetes 1.29 or later, native sidecar containers — declared as initContainers with restartPolicy: Always — solve the ordering properly: they start before the application containers and are terminated after them, without any sleep hacks:
spec:
initContainers:
- name: pgbouncer
image: edoburu/pgbouncer:1.23.1
restartPolicy: Always # makes this a native sidecar
ports:
- containerPort: 6432
Prefer that form where it is available. The preStop sleep is a workaround for clusters that do not yet support it.
Authentication and secrets
The sidecar needs credentials for the database, and passing them as plain environment variables — as above, for brevity — means they appear in kubectl describe pod and in the container’s environment. Two better options exist.
Mount userlist.txt from a Secret and use auth_file with SCRAM hashes rather than plaintext:
volumeMounts:
- name: pgbouncer-auth
mountPath: /etc/pgbouncer/userlist.txt
subPath: userlist.txt
readOnly: true
Or use auth_query, where PgBouncer looks up the client’s password hash from the database itself using a single dedicated lookup role. That keeps per-user credentials out of every pod entirely — only the lookup role’s credential is distributed:
auth_type = scram-sha-256
auth_user = pgbouncer_auth
auth_query = SELECT usename, passwd FROM pg_shadow WHERE usename = $1
For managed databases with IAM authentication the calculus changes again: tokens are short-lived, so the sidecar needs a refresh mechanism. On AWS that generally means preferring RDS Proxy, which handles token rotation natively — one of the concrete decision factors covered in PgBouncer vs RDS Proxy vs pgpool-II.
Common failure patterns and remediation
| Symptom | Underlying cause | Remediation |
|---|---|---|
FATAL: sorry, too many clients already after scaling up |
replicas x default_pool_size exceeded the database ceiling |
Lower default_pool_size; add an HPA maximum that respects the budget |
| Connection resets during every rolling deploy | PgBouncer exits before the application | Add a longer preStop on the sidecar, or use native sidecars |
| Latency spikes correlated with CPU throttling | A CPU limit on a single-threaded proxy | Remove the CPU limit; keep the request |
| Prepared statement errors after adding the sidecar | Transaction mode multiplexes server connections | Disable server-side prepared statements, or use PgBouncer 1.21+ with max_prepared_statements |
| Application cannot reach the proxy at startup | Sidecar not ready before the app connects | Use native sidecars, or retry with backoff in the app’s startup path |
| Metrics missing for the proxy layer | No exporter alongside the sidecar | Add a third container running the PgBouncer exporter, scraped per pod |
| Server connections climbing after HPA scale-down | Old pods terminated before their server connections closed | Set server_idle_timeout low enough that orphaned backends are reaped |
Every pod holds min_pool_size connections at rest |
Correct behaviour, but multiplied by replica count | Set min_pool_size to 0 or 1 on large fleets |
Observability for a sidecar fleet
A central PgBouncer has one SHOW POOLS to read. A sidecar fleet has one per pod, which is both harder and more useful — per-pod queue depth tells you whether saturation is uniform or concentrated on a few pods.
Run the exporter as a third container in the pod and scrape it with a pod-level ServiceMonitor, so every series carries the pod label. The queue depth expression then aggregates naturally:
# pods where the local proxy has clients queued
sum by (pod) (pgbouncer_pools_client_waiting_connections) > 0
# fleet-wide server connections against the database budget
sum(pgbouncer_pools_server_active_connections
+ pgbouncer_pools_server_idle_connections)
That second expression is the one to alert on. It is the number that must stay below your database’s connection budget, and in a sidecar topology it moves whenever the replica count moves — including automatically, if an autoscaler is involved. Wire it into the dashboard described in Prometheus and Grafana Pool Metrics, and treat it as a capacity signal rather than an incident signal.
Operational boundary
A sidecar does not change what transaction pooling costs you. Session-level features — advisory locks held across statements, LISTEN/NOTIFY, session-scoped temporary tables, and SET outside a transaction — remain unavailable regardless of where the proxy runs. Those constraints are the subject of Transaction vs Statement Pooling Tradeoffs, and they are unchanged by topology.
Nor does a sidecar reduce total server-side connections below what a central deployment achieves. If your replica count is high enough that even default_pool_size = 2 blows the budget, the sidecar topology has reached its limit and a central deployment — or a managed proxy — is the answer.
Frequently Asked Questions
What pool_size should a sidecar use?
Should the application still have its own pool?
Does the sidecar need a liveness probe?
Can I run the exporter in the same container?
How do I avoid distributing database passwords to every pod?
auth_query with a single lookup role, or mount a SCRAM userlist.txt from a Secret. Plain environment variables are the least good option.What happens when the HPA scales up during a traffic spike?
default_pool_size server connections. If the maximum replica count times default_pool_size exceeds the database ceiling, the spike that triggered the scale-up also exhausts the database. Set the HPA maximum from the connection budget, not only from CPU.Is a DaemonSet a middle ground?
Related
- PgBouncer vs RDS Proxy vs pgpool-II — the parent guide on choosing a proxy.
- Configuring PgBouncer pool_size and max_client_conn — the sizing arithmetic applied per pod.
- PgBouncer Transaction vs Statement Pooling — the mode a sidecar almost always runs in.
- PgBouncer Metrics Monitoring — reading per-pod proxy statistics.
- Connection Pool Sizing Formulas — deriving the budget the replica count must respect.