Pooling Configuration
The pgEdge Ansible Collection can run pgBouncer on pgEdge nodes to provide a
pooled connection endpoint beside the direct PostgreSQL one. The
install_pgbouncer and setup_pgbouncer roles use the parameters on this
page.
Pooling is opt-in per cluster, through pgbouncer_enabled. Set it on the
pgedge group and every pgEdge node runs a pooler in front of its own
PostgreSQL; leave it unset and no node is touched. It is a cluster-wide
setting, like is_ha_cluster, and not a per-node one — see
pgbouncer_enabled for why. See
Inventory Structure for where it goes, and
Proxy Configuration for the HAProxy listener that
fronts the poolers in a high availability cluster.
How Pooling Fits the Cluster
Each pooler forwards every database to the PostgreSQL instance on its own
node, over 127.0.0.1. A pooled node therefore serves two endpoints:
pg_port for direct connections and pgbouncer_port for pooled ones.
The two endpoints are not interchangeable. They resolve against different
client authentication rules, so a client that reaches one is not guaranteed to
reach the other, and nothing in the collection fails a pooled client over to
PostgreSQL. A dead pooler is an outage of the pooled endpoint by design;
Restart=always in the pooler's service unit is the mitigation.
Spock replication never routes through a pooler. Cross-zone subscriptions use
proxy_port on the direct listener, so pooling changes nothing about
replication.
In a high availability cluster, every node of a zone pools, because
pgbouncer_enabled applies to the whole cluster. That is what keeps the pooled
endpoint available: the pooled HAProxy listener health-checks Patroni's leader
endpoint, so it routes only to the pooler on the current leader, and any node
of the zone can become that leader.
pgbouncer_enabled
- Type: Boolean
- Default:
false - Description: This parameter enables the connection pooler. When it is
true, every pgEdge node runs pgBouncer in front of its own PostgreSQL and serves a pooled endpoint onpgbouncer_port; when it isfalse, no pooler is configured and no pooled endpoint exists. It belongs in the vars of thepgedgegroup.
Pooling is a property of the cluster rather than of a node, and the
init_server role rejects an inventory whose pgEdge nodes disagree about it.
A zone that pooled only some of its nodes would have a pooled endpoint that
worked until the Patroni leader moved to a node with no pooler, at which
point the endpoint would have no backend at all — and the direct endpoint
cannot stand in for it, because the two resolve against different client
authentication rules.
Set pgbouncer_auth_password alongside it. The pooler's own PostgreSQL login
is written to disk, so init_server refuses to deploy a pooled cluster while
that password is still the default.
In the following example, the inventory gives every node in the cluster a pooled endpoint:
pgedge:
vars:
pgbouncer_enabled: true
pgbouncer_auth_password: "{{ vault_pgbouncer_auth_password }}"
pgbouncer_port
- Type: Integer
- Default:
6432 - Description: This parameter specifies the port each pooler listens on. It
must differ from
pg_port, because a pooled node runs both services and the pooler's unix socket is named after its port, so an equal value collides with PostgreSQL's own socket as well as its TCP port. Theinit_serverrole rejects the collision before anything is provisioned.
In the following example, the inventory moves the pooled endpoint off the default port:
pgedge:
vars:
pgbouncer_port: 6433
pgbouncer_listen_addr
- Type: String
- Default:
* - Description: This parameter specifies the addresses the pooler binds. The default accepts connections on every interface, which is what a pooled endpoint fronted by HAProxy needs. Set it to a specific address to narrow the exposure.
pgbouncer_auth_user
- Type: String
- Default:
pgbouncer_auth - Description: This parameter specifies the PostgreSQL role the pooler logs in
as. The
setup_postgresrole creates the role wherever a zone has a pooled node, withLOGINandNOINHERITand no other privilege than executing the credential lookup. It is also the account that reaches the pooler's admin console.
pgbouncer_auth_password
- Type: String
- Default:
secret - Description: This parameter specifies the password for
pgbouncer_auth_user. It is the only password the collection writes to disk in plain text, in the pooler'sauth_file, andinit_serverrefuses to deploy a pooled cluster while it is stillsecret. Store it in a vault.
In the following example, the inventory takes the password from a vault:
pgedge:
vars:
pgbouncer_auth_password: "{{ vault_pgbouncer_auth_password }}"
pgbouncer_pool_mode
- Type: String
- Default:
session - Options:
session,transaction - Description: This parameter specifies how much of a session the pooler is allowed to reuse. It is the one pooling setting with consequences beyond performance.
session hands a client one backend for the whole connection and returns it
only at disconnect. Nothing about the session is shared, so every client sees
exactly the PostgreSQL it would see unpooled. The pool is then a hard
concurrency limit rather than a multiplier: when pgbouncer_default_pool_size
is below the number of concurrent clients, the clients without a backend
simply wait.
transaction returns the backend at the end of every transaction, which is
what multiplexes many clients onto few backends. The cost is that anything
living outside a transaction is no longer reliably the client's own. pgBouncer
runs server_reset_query only in session mode, so in transaction mode a SET
stays on the backend and the next client to use it inherits the value. The
same applies to advisory locks, LISTEN/NOTIFY, temporary tables, WITH
HOLD cursors, and session-level prepared statements. Applications that touch
none of those pool safely; applications that do need session mode, or must set
their state inside each transaction.
pgbouncer_default_pool_size
- Type: Integer
- Default:
25 - Description: This parameter specifies how many backend connections the pooler opens per user and database pair. In session mode this is also the number of clients that can be served at once.
pgbouncer_max_client_conn
- Type: Integer
- Default:
1000 - Description: This parameter specifies how many client connections the pooler
accepts. It also sizes two things outside
pgbouncer.ini: the file descriptor limit in the pooler's service unit, and the pooled HAProxy listener'smaxconn. See the connection budget.
pgbouncer_max_prepared_statements
- Type: Integer
- Default:
0 - Description: This parameter specifies how many protocol-level prepared
statements the pooler tracks per connection. Above
0, pgBouncer keeps track of them itself and replays each one onto whichever backend a transaction lands on, which is what makes prepared statements usable in transaction mode.
Without it, a driver that keeps server-side prepared statements across transactions eventually names one the new backend has never seen. pgJDBC and psycopg3 both start doing this once a statement passes their prepare threshold, so the failure arrives after some traffic rather than on the first query. Turning the driver's threshold off, or putting it in simple-query mode, is the client-side alternative.
The collection writes this value unconditionally, including 0, because
pgBouncer 1.24 and later default to 200 when the parameter is omitted.
pgbouncer_ignore_startup_parameters
- Type: String
- Default:
extra_float_digits - Description: This parameter lists the startup parameters the pooler accepts
from a client and then discards. pgBouncer refuses a connection outright,
with
FATAL: unsupported startup parameter, when a client asks for a parameter it does not track, and it inspects the contents ofoptionsthe same way. Naming a parameter here stops the refusal, and that is all it does: the value is not carried to the backend.
extra_float_digits is in the default because pgJDBC sends it on every
connection and discarding it costs nothing: it affects the text precision of
floats, not results.
Think Twice Before Adding search_path
Naming search_path here does not forward it. The client connects and
then resolves every unqualified name against the default search path, with
no error on either side. A hard failure at connect time is the better
outcome, so put schema selection in the role's search_path or in an
explicit SET.
pgbouncer_client_tls_sslmode
- Type: String
- Default:
allow - Options:
disable,allow,prefer,require - Description: This parameter specifies the TLS policy on the pooled endpoint.
The collection stages the certificate
setup_postgresgenerated, so the pooled and direct endpoints present the same certificate and replacing one replaces both.
The default allow accepts exactly what the direct endpoint accepts: TLS when
the client asks for it, plaintext when it does not, so enabling pooling
changes no existing client's behavior. require makes TLS mandatory on the
pooled endpoint. disable turns it off and omits the certificate entirely,
which also makes any hostssl or hostnossl rule in the pooler's rules
meaningless.
verify-ca and verify-full are not accepted. Both demand a client
certificate, which needs the pg_hba.conf option syntax pgBouncer cannot
parse. The init_server role rejects an unsupported value before deployment.
pgbouncer_tls_cert_source
- Type: String
- Default:
tls/postgres/server.crt - Description: This parameter specifies the controller-side certificate the
pooler serves to clients, relative to the playbook's own directory. The
default is the staging directory
setup_postgresgenerates into, which is also where PGDATA's copy came from. Point it at a different certificate to change what the pooled endpoint presents.
pgbouncer_tls_key_source
- Type: String
- Default:
tls/postgres/server.key - Description: This parameter specifies the controller-side private key that
matches
pgbouncer_tls_cert_source. It is installed mode0600.
pgbouncer_hba_rules
- Type: List of dictionaries
- Default:
[] - Description: This parameter provides additional client authentication rules
for the pooled endpoint only, in the same shape as
custom_hba_rules. Each rule acceptscontype,databases,users,source, andmethodkeys. See Client Authentication below for what the pooler admits without them.
In the following example, the inventory admits an application subnet to the pooled endpoint and to nothing else:
pgedge:
vars:
pgbouncer_hba_rules:
- contype: host
databases: demo
users: appuser
source: 10.0.4.0/24
method: scram-sha-256
pgbouncer_limit_nofile
- Type: Integer
- Default:
pgbouncer_max_client_conn * 2 + 1024 - Description: This parameter specifies the file descriptor limit in the
pooler's systemd drop-in. Both packaged units ship
LimitNOFILEcommented out, leaving the pooler with systemd's default of 1024, fewer descriptors thanmax_client_connalone. The default budgets two descriptors per client connection, for its socket and the backend it is paired with, plus a fixed allowance for the idle server pools, the listening sockets, the log, and the admin console.
pgbouncer_package
- Type: String
- Default:
pgedge-pgbouncer - Description: This parameter specifies the package
install_pgbouncerinstalls. The collection requires pgBouncer 1.21 or later, which is wheremax_prepared_statementsarrived.
Authentication
The pooler keeps no list of PostgreSQL passwords. It logs in to PostgreSQL as
pgbouncer_auth_user and calls a SECURITY DEFINER lookup to fetch the
requested role's stored SCRAM verifier:
auth_user = pgbouncer_auth
auth_query = SELECT username, password FROM pgbouncer.get_auth($1)
auth_dbname = postgres
The setup_postgres role creates the role and the lookup in the maintenance
database wherever pgbouncer_enabled is set. The function reads pg_shadow as
its superuser owner, has a pinned search_path, lives in its own schema, and
grants EXECUTE to the authentication user alone. It returns nothing for a
role whose VALID UNTIL has passed, so the pooler refuses an expired password
itself rather than accepting the client and failing the backend login.
Four consequences follow, and they are the reason the collection maintains no userlist:
- Every PostgreSQL role works through the pooled endpoint, including a role created long after deployment.
- A rotated password takes effect on the pooled endpoint immediately, and the old one is refused.
- An expired
VALID UNTILis honored at the pooled endpoint, and the role is refused there exactly as it is onpg_port. - Only one password is written to disk:
pgbouncer_auth_password, in/etc/pgbouncer/userlist.txt. The pooler needs it before it can run the lookup, and it is stored in plain text because a stored SCRAM verifier cannot be used to log in. pgBouncer derives the verifier from it, so SCRAM is still what goes on the wire.
The pooler's admin console is the one path the lookup cannot serve, so
admin_users and stats_users name pgbouncer_auth_user and nobody else.
Reach it over the unix socket:
sudo psql -h /run/pgbouncer -p 6432 -U pgbouncer_auth pgbouncer -c 'SHOW POOLS'
Use -h /var/run/postgresql on Debian-based systems. Who can use that path is
bounded by the socket directory rather than by a rule: the directory is mode
0700 on RHEL-based systems, so root, and mode 2775 on Debian-based ones,
so root or postgres.
Client Authentication
The pooler enforces its own pg_hba.conf, in /etc/pgbouncer, rendered from
the same variables that drive the PostgreSQL rules. It cannot share
PostgreSQL's file: on RHEL-based systems that file lives in a 0700 PGDATA
owned by postgres while the pooler runs as pgbouncer.
auth_type = hba, so the rules select the method per client and address and
the lookup supplies the credential to check it against. An address the rules
do not name is refused before any password is considered, with no
authentication method is found.
Without any configuration, the pooler admits the following:
| Rule | Purpose |
|---|---|
local all <auth_user> scram-sha-256 |
The admin console, over the unix socket. |
local all postgres peer |
Local psql to the pooled databases. Reachable only where postgres can traverse the socket directory, which on RHEL-based systems is nowhere. |
host all all 127.0.0.1/32 scram-sha-256 |
The node's own loopback. |
host <databases> <pgedge_user>,<db_user> <node>/32 |
Every pgEdge node in the cluster. |
host <databases> all <proxy>/32 |
The zone's HAProxy nodes, plus proxy_node when it is set. |
An application reaching the pooler across the network is named by none of those, so it has to be added. Two parameters do that, and the difference matters:
custom_hba_rulesis shared with PostgreSQL. A client named there reaches both endpoints.pgbouncer_hba_rulesis pooler-only. A client named there reaches the pooled endpoint and cannot connect to PostgreSQL directly, which is usually the grant that was meant.
Rules the Pooler Cannot Enforce
Three limits are worth knowing before writing rules.
The proxy rules cannot be narrowed by client address. HAProxy's pooled listener is TCP passthrough, so the pooler sees the proxy's address and not the client's. Those rules also name every role rather than the cluster's two, because the pooled endpoint exists for applications and an application uses whichever role the operator created for it. This is the one place the pooled rules are wider than the direct ones, and the rules are first-match, so nothing added afterwards narrows them. Restrict that path at the proxy or in the network.
pgBouncer parses a subset of pg_hba.conf. It rejects the ident method
and option syntax such as clientcert=verify-ca, and it skips a line it
cannot parse with a warning rather than refusing to start, so a dropped
reject rule would leave the pooler quietly more permissive than PostgreSQL.
The init_server role validates custom_hba_rules and pgbouncer_hba_rules
against what the pooler accepts before anything is provisioned: contype must
be local, host, hostssl, or hostnossl, and method must be trust,
reject, md5, password, scram-sha-256, peer, or cert.
trust does not mean what it appears to. A trust rule still requires the
role to be listed in auth_file, or pgBouncer answers "trust" authentication
failed. It also breaks credential pass-through for that client: with no
password collected, the backend login fails with server login failed: wrong
password type. Use password or scram-sha-256.
Rules That Are Not Mirrored
Two classes of PostgreSQL rule are deliberately absent from the pooler's file.
Physical replication rules are not mirrored, because a replication connection
cannot be pooled and a rule permitting one would only mislead. Neither are the
rules setup_backrest adds for the backup host, for the same reason.
Client Libraries Requesting Wire Protocol 3.2
A client whose libpq requests PostgreSQL wire protocol 3.2 or later
cannot connect through a pooler running pgBouncer 1.25.2 or earlier while
auth_query is in use: pgBouncer answers NegotiateProtocolVersion
twice, and the client reports received duplicate protocol negotiation
message. This is an upstream pgBouncer bug with a merged but unreleased
fix. Every stable libpq defaults to protocol 3.0 and is unaffected. The
client-side workaround is max_protocol_version=3.0 in the connection
string, or PGMAXPROTOCOLVERSION=3.0 in the environment. See
pgBouncer Issues.