feat(infra): add PgBouncer connection pooling for production PostgreSQL

Introduces PgBouncer as a connection pooler between the API service and
PostgreSQL in docker-compose.prod.yml, reducing connection overhead and
improving concurrency under production load.

- Add PgBouncer service (edoburu/pgbouncer:1.23.1-p2) with transaction
  pool mode, max_client_conn=200, default_pool_size=20
- Route API DATABASE_URL through PgBouncer (port 6432), keep direct
  connection (DATABASE_URL_DIRECT) for Prisma migrations/introspection
- Create infra/pgbouncer/ config: pgbouncer.ini, userlist template,
  and entrypoint script with runtime env-var substitution
- Update prisma.config.ts to prefer DATABASE_URL_DIRECT for migrations
- Add K6 load test (e2e/load/pgbouncer-pool-test.js) with ramp-up to
  200 VUs, pool exhaustion detection, and p95 < 2s threshold
- Add PgBouncer env vars to .env.example

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-10 20:15:21 +07:00
parent f5ef9d8c86
commit 05abbc5250
7 changed files with 335 additions and 3 deletions

View File

@@ -12,7 +12,9 @@ services:
- '${API_PORT:-3001}:3001'
environment:
NODE_ENV: production
DATABASE_URL: postgresql://${DB_USER}:${DB_PASSWORD}@postgres:5432/${DB_NAME}
DATABASE_URL: postgresql://${DB_USER}:${DB_PASSWORD}@pgbouncer:6432/${DB_NAME}
# Direct connection for migrations (bypasses PgBouncer — required for DDL)
DATABASE_URL_DIRECT: postgresql://${DB_USER}:${DB_PASSWORD}@postgres:5432/${DB_NAME}
REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379
TYPESENSE_HOST: typesense
TYPESENSE_PORT: 8108
@@ -27,7 +29,7 @@ services:
AI_SERVICES_API_KEY: ${AI_API_KEY}
RUN_MIGRATIONS: ${RUN_MIGRATIONS:-false}
depends_on:
postgres:
pgbouncer:
condition: service_healthy
redis:
condition: service_healthy
@@ -165,6 +167,49 @@ services:
networks:
- goodgo-net
# ── Connection Pooling ─────────────────────────────────────────────────────
pgbouncer:
image: edoburu/pgbouncer:1.23.1-p2
container_name: goodgo-pgbouncer
restart: unless-stopped
entrypoint: ['/bin/sh', '/etc/pgbouncer/entrypoint.sh']
environment:
DB_USER: ${DB_USER}
DB_PASSWORD: ${DB_PASSWORD}
PGBOUNCER_POOL_SIZE: ${PGBOUNCER_POOL_SIZE:-20}
PGBOUNCER_MAX_CLIENT_CONN: ${PGBOUNCER_MAX_CLIENT_CONN:-200}
PGBOUNCER_ADMIN_PASSWORD: ${PGBOUNCER_ADMIN_PASSWORD:-pgbouncer_admin_secret}
PGBOUNCER_STATS_PASSWORD: ${PGBOUNCER_STATS_PASSWORD:-pgbouncer_stats_secret}
volumes:
- ./infra/pgbouncer/pgbouncer.ini:/etc/pgbouncer/pgbouncer.ini:ro
- ./infra/pgbouncer/userlist.txt.template:/etc/pgbouncer/userlist.txt.template:ro
- ./infra/pgbouncer/entrypoint.sh:/etc/pgbouncer/entrypoint.sh:ro
depends_on:
postgres:
condition: service_healthy
healthcheck:
test: ['CMD-SHELL', 'pg_isready -h 127.0.0.1 -p 6432 -U ${DB_USER}']
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
deploy:
resources:
limits:
memory: 256m
cpus: '0.5'
reservations:
memory: 64m
security_opt:
- no-new-privileges:true
logging:
driver: json-file
options:
max-size: '5m'
max-file: '3'
networks:
- goodgo-net
redis:
image: redis:7-alpine
container_name: goodgo-redis