Bỏ qua

ADR-0005: PgBouncer làm connection pooler trước Postgres

  • Status: Accepted
  • Date: 2026-07-25
  • Supersedes: —
  • Superseded by: —

Bối cảnh

Một Postgres 16 container duy nhất trên data-node phục vụ toàn bộ database (user_db, field_db, booking_db, payment_db, keycloak_db, langfuse_db, chatbot_db) qua chung user football_admin (ADR-0001). postgres:16-alpine dùng max_connections mặc định = 100, không override ở đâu trong repo này.

Phía consumer, mỗi NestJS pod giữ TypeORM pool max: 20 (football-booking-backend-k3s/libs/database/src/typeorm.config.ts). Đếm từ football-booking-gitops/envs/production/backend/, 4 service nối Postgres trực tiếp (booking-service, field-service, payment-service, user-service) mỗi service có maxReplicas: 3 (gitops/charts/backend-service/values.yaml). Ở HPA scale full, riêng 4 service này đã có thể mở tới 4 × 3 × 20 = 240 connection — vượt max_connections=100 chỉ với 2/3 số service scale gần hết. Cộng thêm go-service (pgxpool) và chatbot-service/LangFuse (dùng chung Postgres instance này qua chatbot_db/langfuse_db) thì áp lực thực tế còn cao hơn. Đây là rủi ro production thật, chưa từng gây sự cố nhưng chưa có phòng vệ nào ở tầng hạ tầng.

Quyết định

Thêm PgBouncer (edoburu/pgbouncer:v1.25.2-p0) vào docker-compose/stateful-stack.yml (và bản song song stateful-stack.local.yml cho dev), chạy cạnh Postgres trên data-node, pool_mode=transaction, default_pool_size=20, lắng nghe port 6432 mở ra ngoài (giống cách 5432 đang mở) để K3s pod trên worker node kết nối qua MASTER_IP:6432.

Cập nhật 2026-07-25: đã chuyển traffic runtime của user-service, field-service, booking-service, payment-service (NestJS) và go-availability-engine sang pgbouncer:6432 (roles/helm_apps/tasks/main.ymlbackend-secrets, go-services-secrets), verify qua SHOW CLIENTS trên PgBouncer thấy connection thật từ các service này, state idle đúng theo transaction pooling.

chatbot-service CỐ Ý chưa chuyển — dùng user Postgres riêng chatbot (khác football_admin), user này chưa có trong userlist.txt của PgBouncer (image chỉ tự sinh userlist cho 1 user từ DB_USER/DB_PASSWORD). Cần đổi sang DATABASE_URLS (nhiều user) hoặc thêm auth_query trước khi chuyển chatbot_db qua pooler — việc riêng, chưa làm ở ADR này.

Ràng buộc bắt buộc đi kèm quyết định này:

  • Port 5432 của Postgres giữ nguyên, dùng cho migration/admin job và Terraform provider (football-booking-terraform/live/*/postgres/main.tf, cần superuser=true để CREATE ROLE/CREATE DATABASE/GRANT — các thao tác DDL này không tương thích với transaction pooling).
  • Bất kỳ client nào dùng extended query protocol với server-side prepared statement cache theo connection (vd: pgx/pgxpoolgo-service) phải chuyển sang simple query protocol trước khi route qua PgBouncer, nếu không sẽ lỗi prepared statement does not exist khi PgBouncer đổi backend connection giữa các transaction.

Lựa chọn khác đã cân nhắc

  • bitnami/pgbouncer: từng là lựa chọn mặc định phổ biến, nhưng loại vì thay đổi chính sách phân phối image legacy của Bitnami (2025) khiến các tag cụ thể không còn đảm bảo pull được lâu dài từ registry miễn phí — rủi ro cho một stateful component chạy dài hạn.
  • Tăng max_connections của Postgres thay vì thêm pooler: đơn giản hơn (chỉ đổi 1 config), nhưng không giải quyết gốc vấn đề — mỗi connection Postgres tốn ~5-10MB RAM và tăng context-switch overhead khi số connection active cao; chỉ trì hoãn vấn đề tới ngưỡng cao hơn, không loại bỏ.
  • PgBouncer chạy trong K3s (sidecar hoặc Deployment riêng ở worker node) thay vì Docker Compose ở data-node: nhất quán hơn với hướng chuyển dần workload sang K3s, nhưng loại vì Postgres vẫn là Docker Compose trên data-node — đặt pooler cùng node với DB nó pool tránh thêm 1 network hop qua Kafka-style external listener, và giữ nhất quán với cách Kafka/Redis/ Mongo hiện tại đều expose thẳng từ data-node.

Hệ quả

  • Được: giới hạn số connection thật tới Postgres bất kể số pod HPA scale ra bao nhiêu (server_active_connections bị chặn trần bởi default_pool_size thay vì bởi tổng pool.max của từng pod cộng lại); giảm tail latency lúc scale-out vì pod mới không cần tự handshake TCP/auth trực tiếp tới Postgres; tách rõ traffic runtime (6432) khỏi traffic admin/migration/ Terraform (5432), dễ audit hơn.
  • Đánh đổi: thêm 1 thành phần hạ tầng nữa trên data-node — vốn đã là single point of failure (ADR-0001) — PgBouncer down đồng nghĩa mọi service runtime mất kết nối DB dù Postgres vẫn khỏe. Không giảm tải CPU/IO thật của Postgres, chỉ giảm số connection vật lý — nếu bottleneck là query chậm thì ADR này không giúp gì. Cần rà lại mọi nơi dùng session-level state (SET search_path, pg_advisory_lock giữ xuyên nhiều statement, LISTEN/NOTIFY) trước khi chuyển traffic của service đó qua PgBouncer, vì các state đó không đảm bảo còn nguyên giữa các transaction dưới pool_mode=transaction.
  • Ảnh hưởng role/playbook: roles/stateful_stack (deploy service mới, không cần thêm vault secret — tái dùng DB_ADMIN_PASSWORD sẵn có); roles/obs_stack (thêm pgbouncer-exporter vào observability-stack.yml); roles/monitoring (thêm scrape job + dashboard ConfigMap "PgBouncer — Connection Pools"). Chưa cần chạy lại roles/helm_apps ở giai đoạn này vì chưa đổi DB_HOST của service nào.
  • football-booking-go-service-k3s/pkg/postgres/pool.go đã đổi sang pgx.QueryExecModeSimpleProtocol để chuẩn bị tương thích PgBouncer transaction pooling trước — không ảnh hưởng hành vi khi vẫn đang nối thẳng Postgres.
  • Đã deploy thật lên data-node/obs-node/k3s-master (2026-07-25) và verify qua psql. 2 vấn đề phát sinh lúc deploy thật, đã sửa và ghi lại để lần sau không lặp lại:
  • edoburu/pgbouncer:1.21.0 không tồn tại thật trên Docker Hub (đoán nhầm tag) — dùng v1.25.2-p0.
  • Image mặc định nghe port 5432 nội bộ trừ khi set LISTEN_PORT — phải set LISTEN_PORT=6432 để khớp port map 6432:6432.
  • AUTH_TYPE=md5 khiến image lưu password vào userlist.txt dạng hash MD5 thay vì plaintext, nên PgBouncer không đủ dữ liệu để SCRAM handshake xuống Postgres 16 (wrong password type) — đổi sang AUTH_TYPE=scram-sha-256 để image lưu plaintext, PgBouncer tự chuyển đổi sang auth method backend yêu cầu.
  • pgbouncer-exporter dùng biến env PGBOUNCER_EXPORTER_CONNECTION_STRING, KHÔNG phải DATA_SOURCE_NAME (khác convention của postgres-exporter) — dùng nhầm khiến exporter fallback về default connection string localhost:6543 và báo pgbouncer_up=0.

Liên kết

  • PR/commit: —
  • Docs liên quan: docker-compose/stateful-stack.yml, docker-compose/stateful-stack.local.yml
  • ADR liên quan: ADR-0001