From 70a7ad3dc3ec42a0783baddd9c13c96b8356d15b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Sun, 27 Sep 2026 19:46:08 +0200 Subject: [PATCH 1/9] docs(spec): pgbouncer przed PostgreSQL dla appservera Co-Authored-By: Claude Opus 5.5 --- .../specs/2026-09-27-pgbouncer-design.md | 234 ++++++++++++++++++ 1 file changed, 234 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-27-pgbouncer-design.md diff --git a/docs/superpowers/specs/2026-09-27-pgbouncer-design.md b/docs/superpowers/specs/2026-09-27-pgbouncer-design.md new file mode 100644 index 0000000..5cc37e4 --- /dev/null +++ b/docs/superpowers/specs/2026-09-27-pgbouncer-design.md @@ -0,0 +1,234 @@ +# pgbouncer przed PostgreSQL dla appservera — projekt + +Data: 2026-09-27 · Status: zatwierdzony kształt, spec do przeglądu + +## Cel + +Przyspieszyć odpowiedzi appservera, usuwając koszt nawiązywania połączenia +z PostgreSQL przy **każdym** żądaniu HTTP. + +### Pomiar, który to uzasadnia (publikacje.up.lublin.pl, 2026-09-27) + +Z kontenera `appserver`, 30 prób, mediany: + +| Co | Czas | +|---|---| +| nowe połączenie `psycopg2.connect` (SCRAM, fork backendu) | **18 ms** (13–33) | +| pierwsze zapytanie na świeżym połączeniu (zimny catcache) | 9,7 ms | +| to samo zapytanie drugi raz | 1,5 ms | +| całe żądanie do appservera (24 h, n = 33 207) | **100 ms** (p90 145, p99 1063) | + +Django ma `CONN_MAX_AGE=0` — każde żądanie otwiera i zamyka połączenie. Szacunek +straty: ~15–25 ms na żądanie (~15–25 % mediany). To **szacunek ze składowych**; +efekt po wdrożeniu mierzymy osobno (sekcja *Kryteria akceptacji*). + +Dlaczego nie `CONN_MAX_AGE > 0`: appserver działa pod ASGI, gdzie Django nie +radzi sobie z trwałymi połączeniami (żądania trafiają do różnych wątków; na +produkcji silnik to już `django_bpp.db_connclosed_fix`). Pula zewnętrzna omija +problem bez zmiany obrazu BPP. + +### Co to NIE jest + +Ochrona przed floodem. Tę dają limity z 2026-09-27 (globalny w nginx, +`GUNICORN_LIMIT_CONCURRENCY` w obrazie BPP). pgbouncer w trybie `session` nie +zmniejsza liczby równoczesnych połączeń do bazy — zmniejsza koszt każdego z nich. + +## Decyzje (ustalone z operatorem) + +1. **Przez pulę idzie wyłącznie `appserver`.** authserver (auth paneli, znikomy + ruch), Celery, denorm-queue (`LISTEN`), netdata (`bpp_monitor` ma widzieć + prawdziwy serwer) i backup/restore (`docker exec` w `dbserver`) — bez zmian. +2. **Domyślnie włączone.** `git pull && make up` przełącza appserver na pulę bez + edycji `.env`. +3. **Dwa zestawy zmiennych, bez podmiany znaczenia.** `DJANGO_BPP_DB_HOST`/`_PORT` + dalej znaczy „prawdziwa baza”. Nowe `DJANGO_BPP_DB_APP_HOST`/`_APP_PORT` + wskazują, dokąd łączy się appserver. +4. **Jedna konfiguracja dla bazy w compose i zewnętrznej.** pgbouncer łączy się + z `DJANGO_BPP_DB_HOST`/`_PORT`, niezależnie od trybu. Baza zewnętrzna jest + dedykowana BPP i nie wymaga TLS. +5. **Tryb `session`, na sztywno.** `transaction` łamie `LISTEN`, session-level + advisory locks (`src/django_bpp/db_locks.py`), `WITH HOLD` cursory, `SET` + i `PREPARE` (tabela zgodności: ). + `session` wspiera wszystko, a przy zwrocie do puli wykonuje `DISCARD ALL` + (`server_reset_query`, domyślnie tylko w tym trybie). +6. **To samo konto co dziś.** `auth_file` generowany z `DJANGO_BPP_DB_USER` + i `DJANGO_BPP_DB_PASSWORD` z `.env`; `auth_type = scram-sha-256` (dopuszcza + w `auth_file` hasło jawne). `auth_query` i kopiowanie sekretów SCRAM + odrzucone — więcej ruchomych części przy jednym użytkowniku aplikacji. + +## Architektura + +``` + ┌──────────── appserver ────────────┐ + │ DJANGO_BPP_DB_HOST=${DJANGO_BPP_DB_APP_HOST:-pgbouncer} + │ DJANGO_BPP_DB_PORT=${DJANGO_BPP_DB_APP_PORT:-6432} + └──────────────┬────────────────────┘ + │ SCRAM (to samo konto) + ┌──────▼──────┐ + │ pgbouncer │ pool_mode=session, :6432 + └──────┬──────┘ + │ DJANGO_BPP_DB_HOST:DJANGO_BPP_DB_PORT + ┌───────────────────▼────────────────────┐ + │ dbserver (compose) ALBO baza zewn. │◄── authserver, Celery, + └────────────────────────────────────────┘ denorm-queue, netdata +``` + +### Pliki + +| Plik | Rola | +|---|---| +| `docker-compose.pgbouncer.yml` (nowy) | usługa `pgbouncer`, własne `x-logging` (anchory nie przechodzą przez `include:`) | +| `docker-compose.yml` | `include:` nowego pliku z `env_file: ${BPP_CONFIGS_DIR}/.env` | +| `docker-compose.application.yml` | appserver: nadpisanie `DJANGO_BPP_DB_HOST`/`_PORT` w `environment:` + `depends_on: pgbouncer: service_healthy` | +| `defaults/pgbouncer/entrypoint.sh` (nowy) | renderuje `pgbouncer.ini` + `userlist.txt`, potem `exec pgbouncer` | + +`environment:` w compose ma pierwszeństwo przed `env_file:`, więc nadpisanie +działa bez ruszania `.env`. Interpolacja `${DJANGO_BPP_DB_APP_HOST}` czyta +`$BPP_CONFIGS_DIR/.env` przez `env_file` na poziomie `include:`. + +### Usługa `pgbouncer` + +- Obraz `edoburu/pgbouncer:${PGBOUNCER_VERSION:-v1.25.2-p0}` — utrzymywany + (wydanie 2026-06-10), budowany z oficjalnego tarballa, działa jako `postgres` + (uid 70), ma `pg_isready`, `psql`, `nc`; `/etc/pgbouncer` należy do `postgres`. +- `entrypoint: ["/bpp-entrypoint.sh"]` — **własny** skrypt zamiast entrypointu + obrazu. Tamten wpisuje hasło do `userlist.txt` bez escapowania (`"` w haśle + psuje plik) i wkleja wartości do formatu `printf` (`%` psuje konfigurację). +- `env_file: ${BPP_CONFIGS_DIR}/.env` na poziomie usługi (ten z `include:` + służy tylko interpolacji — ta sama lekcja co przy `dbserver`). +- `depends_on: dbserver: service_healthy` — w trybie zewnętrznym `dbserver` to + sentinel sondujący prawdziwą bazę, więc działa w obu trybach. +- `restart: always`, `logging: *default-logging`. +- `deploy.resources.limits`: `PGBOUNCER_MEM_LIMIT` (domyślnie `64m`), + `PGBOUNCER_CPU_LIMIT` (domyślnie `0.5`). +- Bez opublikowanego portu — tylko sieć compose. + +### Healthcheck + +`psql` przez pgbouncer do bazy: `SELECT 1` na `127.0.0.1:6432` kontem aplikacji +(`PGPASSWORD=$$DJANGO_BPP_DB_PASSWORD`, `$$` — Compose interpoluje `$`). +Sprawdza całą ścieżkę: pgbouncer → uwierzytelnienie → baza. `pg_isready` sam +w sobie sprawdzałby tylko, czy pgbouncer przyjmuje połączenia. + +### Konfiguracja renderowana przez `entrypoint.sh` + +`/etc/pgbouncer/pgbouncer.ini`: + +```ini +[databases] + = host= port= + +[pgbouncer] +listen_addr = 0.0.0.0 +listen_port = 6432 +auth_type = scram-sha-256 +auth_file = /etc/pgbouncer/userlist.txt +pool_mode = session +default_pool_size = +max_db_connections = +max_client_conn = +query_wait_timeout = 30 +server_tls_sslmode = prefer +ignore_startup_parameters = extra_float_digits +``` + +Pozycja bazy **bez `user=`** — pgbouncer loguje się do PostgreSQL danymi klienta +(tym samym kontem). `server_reset_query` zostaje domyślne (`DISCARD ALL`). +`server_tls_sslmode = prefer`: TLS nie jest wymagany, a `prefer` działa z nim +i bez niego. + +`/etc/pgbouncer/userlist.txt`: `"" ""`, każdy `"` w polu podwojony +(format z dokumentacji: *„Double quotes in a field value can be escaped by +writing two double quotes”*). Uprawnienia `0600`. + +### Zmienne + +| Zmienna | Domyślnie | Uwagi | +|---|---|---| +| `DJANGO_BPP_DB_APP_HOST` | `pgbouncer` | wyłączenie puli: adres prawdziwej bazy | +| `DJANGO_BPP_DB_APP_PORT` | `6432` | wyłączenie puli: port prawdziwej bazy | +| `PGBOUNCER_POOL_SIZE` | `60` | = `BPP_NGINX_GLOBAL_CONN`; musi być wyraźnie < `max_connections` | +| `PGBOUNCER_MAX_CLIENT_CONN` | `200` | > `GUNICORN_LIMIT_CONCURRENCY` (80) × `WEB_CONCURRENCY` | +| `PGBOUNCER_VERSION` | `v1.25.2-p0` | przypięty tag, jak `HTML2DOCX_VERSION` | +| `PGBOUNCER_MEM_LIMIT` / `_CPU_LIMIT` | `64m` / `0.5` | jak reszta `*_LIMIT` | + +Liczby walidowane w skrypcie jak w `25-render-bpp-limits.sh`: puste = domyślna, +śmieć = `OSTRZEZENIE` w logu + domyślna. Brak nowej zmiennej wymaganej — **zero +migracji `.env`**, stary `.env` działa (kontrakt backwards-compat). + +Wyłączenie puli (`docs/konfiguracja/pgbouncer.md`): + +``` +DJANGO_BPP_DB_APP_HOST=dbserver # albo host zewnętrznej bazy +DJANGO_BPP_DB_APP_PORT=5432 +make up +``` + +Usługa `pgbouncer` dalej działa bezczynnie (kilka MB) — celowo, bez warunkowego +compose; jedno pokrętło, łatwy powrót. + +## Awarie + +| Sytuacja | Skutek | Obrona | +|---|---|---| +| pgbouncer pada | leży strona (appserver); Celery, denorm, panele działają | `restart: always`; bramka zdrowia `make up` | +| baza niedostępna | jak dziś — appserver dostaje błąd połączenia | healthcheck `pgbouncer` czerwony, widoczny w `make doctor` | +| błędne hasło w `.env` | pgbouncer wstaje, logowanie klienta pada | healthcheck (`psql` przez pulę) czerwony → appserver nie startuje | +| pula pełna | klient czeka do `query_wait_timeout` (30 s), potem błąd | limity nginx/appservera trzymają ruch poniżej rozmiaru puli | +| zmiana hasła | `.env` → `make up` odtwarza kontener (zmiana `env_file`) | — | + +## Testy + +**Statyczne** (`tests/test_makefile.sh`, na wyrenderowanym `docker compose config` +— grep po źródle nie widzi interpolacji): + +- domyślnie appserver ma `DJANGO_BPP_DB_HOST=pgbouncer`, `_PORT=6432`; +- z `DJANGO_BPP_DB_APP_HOST=dbserver` + `_PORT=5432` — bezpośrednio; +- **żaden inny** serwis nie łączy się z `pgbouncer` (authserver, workerserver, + celerybeat, denorm-queue, flower, netdata); +- `pgbouncer` ma `logging`, limity, `depends_on: dbserver`, serwisowy `env_file`; +- `$$` w healthchecku przetrwał render (`test_compose_shell_vars_escaped`). + +**Na żywo** (`scripts/test-pgbouncer.sh` → `make test-pgbouncer`, do CI obok +`test-nginx-limits`): prawdziwy `postgres` (SCRAM) + `pgbouncer` z naszym +`entrypoint.sh`: + +1. logowanie przez pulę tym samym kontem; +2. hasło z `"`, `%`, `$`, spacją — logowanie działa; +3. `LISTEN` działa; advisory lock wzięty w połączeniu A jest **zwolniony** po + rozłączeniu A (`DISCARD ALL`) — połączenie B go dostaje; +4. dwa kolejne połączenia klienta dostają **ten sam** `pg_backend_pid()` — + dowód ponownego użycia, czyli źródła przyspieszenia; +5. baza pod nazwą hosta inną niż `dbserver` (symulacja trybu zewnętrznego); +6. śmieć w `PGBOUNCER_POOL_SIZE` → ostrzeżenie + domyślna, pgbouncer wstaje; +7. healthcheck czerwony przy złym haśle. + +Mutacje do sprawdzenia: bez podwajania `"`, `pool_mode=transaction` +(przypadek 3 musi paść), `DISCARD ALL` wyłączone. + +## Kryteria akceptacji + +- testy statyczne i `make test-pgbouncer` zielone lokalnie i w CI; +- na publikacje.up.lublin.pl po wdrożeniu: skrypt pomiarowy (jak z 2026-09-27) + pokazuje spadek kosztu połączenia appserver → baza wyraźnie poniżej 18 ms, + a mediana `request_time` w access logu nie rośnie; +- `make doctor` i bramka zdrowia `make up` zielone. + +## Dokumentacja + +- `docs/konfiguracja/pgbouncer.md` (nowa): po co, co idzie przez pulę, zmienne, + wyłączenie, tryb zewnętrzny, diagnostyka (`SHOW POOLS` przez konsolę admina + nie jest włączona — logi `docker compose logs pgbouncer`). +- `docs/architektura/uslugi.md`: nowa usługa, zależności. +- `docs/konfiguracja/limity-zasobow.md`: `PGBOUNCER_*_LIMIT`, relacja + `POOL_SIZE` ↔ `max_connections`. +- `mkdocs.yml` nav. +- `CLAUDE.md` — tylko tripwire'y: wyłącznie `session`; nie przepinać + `DJANGO_BPP_DB_HOST` na pulę; denorm-queue nigdy przez pulę; własny entrypoint + zamiast obrazowego (escapowanie); `POOL_SIZE` < `max_connections`. + +## Poza zakresem + +Celery przez pulę; tryb `transaction`; TLS do bazy; kolektor netdata dla +pgbouncera; konsola admina pgbouncera; sprzątanie zdublowanych zmiennych +`DJANGO_BPP_DB_*` w `.env` na publikacje.up.lublin.pl (osobno, przed wdrożeniem). From 174aaa247b6ec3c05419ffcae09c2ac78aacfb5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Sun, 27 Sep 2026 21:48:52 +0200 Subject: [PATCH 2/9] =?UTF-8?q?docs(spec):=20pgbouncer=20=E2=80=94=20popra?= =?UTF-8?q?wki=20po=20review=20(sonda,=20wylacznik,=20limity,=20logi)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../specs/2026-09-27-pgbouncer-design.md | 252 ++++++++++++------ 1 file changed, 175 insertions(+), 77 deletions(-) diff --git a/docs/superpowers/specs/2026-09-27-pgbouncer-design.md b/docs/superpowers/specs/2026-09-27-pgbouncer-design.md index 5cc37e4..73e6ed3 100644 --- a/docs/superpowers/specs/2026-09-27-pgbouncer-design.md +++ b/docs/superpowers/specs/2026-09-27-pgbouncer-design.md @@ -1,11 +1,12 @@ # pgbouncer przed PostgreSQL dla appservera — projekt -Data: 2026-09-27 · Status: zatwierdzony kształt, spec do przeglądu +Data: 2026-09-27 · Status: kształt zatwierdzony; wersja 2 po review (poprawki +W1–W6, D1–D11), do przeglądu ## Cel -Przyspieszyć odpowiedzi appservera, usuwając koszt nawiązywania połączenia -z PostgreSQL przy **każdym** żądaniu HTTP. +Przyspieszyć odpowiedzi appservera, usuwając większość kosztu nawiązywania +połączenia z PostgreSQL przy **każdym** żądaniu HTTP. ### Pomiar, który to uzasadnia (publikacje.up.lublin.pl, 2026-09-27) @@ -13,19 +14,31 @@ Z kontenera `appserver`, 30 prób, mediany: | Co | Czas | |---|---| -| nowe połączenie `psycopg2.connect` (SCRAM, fork backendu) | **18 ms** (13–33) | +| nowe połączenie `psycopg2.connect` (SCRAM + fork backendu) | **18 ms** (13–33) | | pierwsze zapytanie na świeżym połączeniu (zimny catcache) | 9,7 ms | | to samo zapytanie drugi raz | 1,5 ms | | całe żądanie do appservera (24 h, n = 33 207) | **100 ms** (p90 145, p99 1063) | -Django ma `CONN_MAX_AGE=0` — każde żądanie otwiera i zamyka połączenie. Szacunek -straty: ~15–25 ms na żądanie (~15–25 % mediany). To **szacunek ze składowych**; -efekt po wdrożeniu mierzymy osobno (sekcja *Kryteria akceptacji*). +Django ma `CONN_MAX_AGE=0` — każde żądanie otwiera i zamyka połączenie. + +### Co pgbouncer zdejmie, a czego nie + +- **Znika:** fork backendu PostgreSQL i zimny catcache/relcache. `DISCARD ALL` + przy zwrocie do puli to `CLOSE ALL; SET SESSION AUTHORIZATION DEFAULT; RESET + ALL; DEALLOCATE ALL; UNLISTEN *; SELECT pg_advisory_unlock_all(); DISCARD + PLANS; DISCARD TEMP; DISCARD SEQUENCES` + () — cache katalogu + zostaje ciepły. `DISCARD PLANS` dotyka tylko planów PL/pgSQL (triggery denormu), + przeplanowanych raz na połączenie klienta — dziś i tak płaconych. +- **Zostaje:** SCRAM na odcinku Django → pgbouncer. libpq liczy PBKDF2, a + pgbouncer z hasłem jawnym w `auth_file` buduje sekret SCRAM ad hoc przy każdym + logowaniu klienta (losowa sól), w jednowątkowej pętli zdarzeń. +- **Szacunek:** koszt połączenia spada z ~18 ms + ~8 ms zimnego cache do rzędu + **3–6 ms**. To szacunek ze składowych; efekt mierzymy (*Kryteria akceptacji*). Dlaczego nie `CONN_MAX_AGE > 0`: appserver działa pod ASGI, gdzie Django nie -radzi sobie z trwałymi połączeniami (żądania trafiają do różnych wątków; na -produkcji silnik to już `django_bpp.db_connclosed_fix`). Pula zewnętrzna omija -problem bez zmiany obrazu BPP. +radzi sobie z trwałymi połączeniami (żądania trafiają do różnych wątków). +Pula zewnętrzna omija problem bez zmiany obrazu BPP. ### Co to NIE jest @@ -41,14 +54,18 @@ zmniejsza liczby równoczesnych połączeń do bazy — zmniejsza koszt każdego 2. **Domyślnie włączone.** `git pull && make up` przełącza appserver na pulę bez edycji `.env`. 3. **Dwa zestawy zmiennych, bez podmiany znaczenia.** `DJANGO_BPP_DB_HOST`/`_PORT` - dalej znaczy „prawdziwa baza”. Nowe `DJANGO_BPP_DB_APP_HOST`/`_APP_PORT` + dalej znaczy „prawdziwa baza”. Nowe `BPP_APPSERVER_DB_HOST`/`_PORT` (prefiks + `BPP_` jak inne zmienne czytane tylko przez deploy, np. `BPP_NGINX_GLOBAL_*`) wskazują, dokąd łączy się appserver. 4. **Jedna konfiguracja dla bazy w compose i zewnętrznej.** pgbouncer łączy się z `DJANGO_BPP_DB_HOST`/`_PORT`, niezależnie od trybu. Baza zewnętrzna jest dedykowana BPP i nie wymaga TLS. -5. **Tryb `session`, na sztywno.** `transaction` łamie `LISTEN`, session-level - advisory locks (`src/django_bpp/db_locks.py`), `WITH HOLD` cursory, `SET` - i `PREPARE` (tabela zgodności: ). +5. **Tryb `session`, na sztywno.** Powód: Django tworzy nazwane kursory + `WITH HOLD` w autocommicie (`QuerySet.iterator()`; + `django/db/backends/postgresql/base.py`: `withhold=self.connection.autocommit`), + a tabela zgodności () daje + `WITH HOLD CURSOR: Never` w trybie `transaction`; tak samo `LISTEN`, `SET`, + `PREPARE`. (BPP używa wyłącznie `pg_advisory_xact_lock` — to NIE jest powód.) `session` wspiera wszystko, a przy zwrocie do puli wykonuje `DISCARD ALL` (`server_reset_query`, domyślnie tylko w tym trybie). 6. **To samo konto co dziś.** `auth_file` generowany z `DJANGO_BPP_DB_USER` @@ -60,8 +77,8 @@ zmniejsza liczby równoczesnych połączeń do bazy — zmniejsza koszt każdego ``` ┌──────────── appserver ────────────┐ - │ DJANGO_BPP_DB_HOST=${DJANGO_BPP_DB_APP_HOST:-pgbouncer} - │ DJANGO_BPP_DB_PORT=${DJANGO_BPP_DB_APP_PORT:-6432} + │ DJANGO_BPP_DB_HOST=${BPP_APPSERVER_DB_HOST:-pgbouncer} + │ DJANGO_BPP_DB_PORT=${BPP_APPSERVER_DB_PORT:-6432} └──────────────┬────────────────────┘ │ SCRAM (to samo konto) ┌──────▼──────┐ @@ -79,21 +96,25 @@ zmniejsza liczby równoczesnych połączeń do bazy — zmniejsza koszt każdego |---|---| | `docker-compose.pgbouncer.yml` (nowy) | usługa `pgbouncer`, własne `x-logging` (anchory nie przechodzą przez `include:`) | | `docker-compose.yml` | `include:` nowego pliku z `env_file: ${BPP_CONFIGS_DIR}/.env` | -| `docker-compose.application.yml` | appserver: nadpisanie `DJANGO_BPP_DB_HOST`/`_PORT` w `environment:` + `depends_on: pgbouncer: service_healthy` | -| `defaults/pgbouncer/entrypoint.sh` (nowy) | renderuje `pgbouncer.ini` + `userlist.txt`, potem `exec pgbouncer` | +| `docker-compose.application.yml` | appserver: nadpisanie `DJANGO_BPP_DB_HOST`/`_PORT` w `environment:` + `depends_on: pgbouncer: service_started` | +| `pgbouncer/entrypoint.sh` (nowy) | wersjonowany kod wykonywalny (precedens: `dbserver/`, nie `defaults/` — to nie szablon konfiguracji operatora); renderuje `pgbouncer.ini` + `userlist.txt`, sprawdza `max_connections`, potem `exec pgbouncer` | +| `scripts/configure-resources.sh` | `pgbouncer:64` na liście MEM + prefiks `PGBOUNCER` | `environment:` w compose ma pierwszeństwo przed `env_file:`, więc nadpisanie -działa bez ruszania `.env`. Interpolacja `${DJANGO_BPP_DB_APP_HOST}` czyta -`$BPP_CONFIGS_DIR/.env` przez `env_file` na poziomie `include:`. +działa bez ruszania `.env`. Interpolacja `${BPP_APPSERVER_DB_HOST}` czyta +`$BPP_CONFIGS_DIR/.env` przez `env_file` na poziomie `include:` (precedens: +`${DJANGO_BPP_ENABLE_PROMETHEUS:-false}` w `docker-compose.application.yml`). ### Usługa `pgbouncer` - Obraz `edoburu/pgbouncer:${PGBOUNCER_VERSION:-v1.25.2-p0}` — utrzymywany (wydanie 2026-06-10), budowany z oficjalnego tarballa, działa jako `postgres` (uid 70), ma `pg_isready`, `psql`, `nc`; `/etc/pgbouncer` należy do `postgres`. -- `entrypoint: ["/bpp-entrypoint.sh"]` — **własny** skrypt zamiast entrypointu - obrazu. Tamten wpisuje hasło do `userlist.txt` bez escapowania (`"` w haśle - psuje plik) i wkleja wartości do formatu `printf` (`%` psuje konfigurację). +- `entrypoint: ["sh", "/bpp-entrypoint.sh"]` — przez interpreter, nie przez bit + `+x` (ten sam wzorzec co `dbserver`; `+x` w tym repo już ginął, `cc8439d`). + Skrypt POSIX `sh` (Alpine/busybox). Własny zamiast entrypointu obrazu: tamten + wpisuje hasło do `userlist.txt` bez escapowania (`"` w haśle psuje plik) + i wkleja wartości do formatu `printf` (`%` psuje konfigurację). - `env_file: ${BPP_CONFIGS_DIR}/.env` na poziomie usługi (ten z `include:` służy tylko interpolacji — ta sama lekcja co przy `dbserver`). - `depends_on: dbserver: service_healthy` — w trybie zewnętrznym `dbserver` to @@ -103,12 +124,31 @@ działa bez ruszania `.env`. Interpolacja `${DJANGO_BPP_DB_APP_HOST}` czyta `PGBOUNCER_CPU_LIMIT` (domyślnie `0.5`). - Bez opublikowanego portu — tylko sieć compose. -### Healthcheck +### appserver → pgbouncer: `service_started`, nie `service_healthy` + +Z `service_healthy` wyłącznik puli nie działałby w scenariuszu, w którym jest +najbardziej potrzebny: gdy pgbouncer nie osiąga `healthy`, `make up` staje na +„container pgbouncer is unhealthy”, nawet po przełączeniu appservera na bazę. +Gwarancję, że baza żyje, daje pozostawione `dbserver: service_healthy`; +pgbouncer startuje w ułamku sekundy; ewentualny wyścig kończy się jednym +restartem appservera (`restart: always`). + +### Healthcheck — osobna pula dla sondy + +Sonda `psql … -c 'SELECT 1'` przez **osobny wpis** bazy `_health` z +`pool_size=1`. Przez główną pulę sonda stałaby w kolejce pod nasyceniem +(60 zajętych połączeń = limit nginx), padałaby 3× i robiła pgbouncera +`unhealthy` dokładnie podczas ruchu, który limity mają obsłużyć — `make up`, +`post-deploy-check` i `make doctor` alarmowałyby fałszywie. `pool_size` jest +per wpis, a `max_db_connections` liczy się per baza pgbouncera, więc alias nie +gryzie się z limitem głównej puli (). + +`interval: 10s`, `timeout: 5s`, `retries: 3`, `start_period: 10s`. +Hasło: `PGPASSWORD=$$DJANGO_BPP_DB_PASSWORD` (`$$` — Compose interpoluje `$`). +Sprawdza całą ścieżkę: pgbouncer → uwierzytelnienie → baza. -`psql` przez pgbouncer do bazy: `SELECT 1` na `127.0.0.1:6432` kontem aplikacji -(`PGPASSWORD=$$DJANGO_BPP_DB_PASSWORD`, `$$` — Compose interpoluje `$`). -Sprawdza całą ścieżkę: pgbouncer → uwierzytelnienie → baza. `pg_isready` sam -w sobie sprawdzałby tylko, czy pgbouncer przyjmuje połączenia. +Uwaga: healthcheck **appservera** (`/health/`, `SELECT 1`) idzie przez główną +pulę — stąd domyślna pula 80 z zapasem ponad `BPP_NGINX_GLOBAL_CONN` (60). ### Konfiguracja renderowana przez `entrypoint.sh` @@ -116,10 +156,11 @@ w sobie sprawdzałby tylko, czy pgbouncer przyjmuje połączenia. ```ini [databases] - = host= port= + = host= port= +_health = host= port= dbname= pool_size=1 [pgbouncer] -listen_addr = 0.0.0.0 +listen_addr = * listen_port = 6432 auth_type = scram-sha-256 auth_file = /etc/pgbouncer/userlist.txt @@ -127,54 +168,87 @@ pool_mode = session default_pool_size = max_db_connections = max_client_conn = -query_wait_timeout = 30 -server_tls_sslmode = prefer -ignore_startup_parameters = extra_float_digits +query_wait_timeout = 15 +log_connections = 0 +log_disconnections = 0 +stats_users = ``` -Pozycja bazy **bez `user=`** — pgbouncer loguje się do PostgreSQL danymi klienta -(tym samym kontem). `server_reset_query` zostaje domyślne (`DISCARD ALL`). -`server_tls_sslmode = prefer`: TLS nie jest wymagany, a `prefer` działa z nim -i bez niego. +- Wpisy baz **bez `user=`** — pgbouncer loguje się do PostgreSQL danymi klienta + (tym samym kontem). +- `server_reset_query` domyślne (`DISCARD ALL`); `server_tls_sslmode` domyślne + `prefer` — przy bazie z włączonym SSL pgbouncer użyje TLS bez weryfikacji CA, + bez — połączy się jawnie. Nie wpisujemy go, bo to wartość domyślna. +- `ignore_startup_parameters` nie ustawiamy: psycopg2/libpq nie wysyła + `extra_float_digits` (to JDBC/Npgsql), a `client_encoding` jest śledzony + domyślnie. +- `log_connections`/`log_disconnections` = 0: domyślne `1` dałyby 2 linie logu + na każde żądanie HTTP (~66 tys./dobę do Loki). Błędy logowania i `stats:` co + 60 s zostają. +- `stats_users` = konto aplikacji: read-only `SHOW POOLS`/`SHOW STATS` na + konsoli `pgbouncer` bez nowego sekretu (diagnostyka `cl_waiting` przy pełnej + puli). +- `query_wait_timeout = 15` (domyślnie 120): pełna pula to dziś szybki błąd, + po zmianie żądanie by wisiało; 15 s ogranicza czas trzymania slotu uvicorna + i `limit_conn`. `/etc/pgbouncer/userlist.txt`: `"" ""`, każdy `"` w polu podwojony -(format z dokumentacji: *„Double quotes in a field value can be escaped by -writing two double quotes”*). Uprawnienia `0600`. +(: *„Double quotes in a field value can +be escaped by writing two double quotes”*). Uprawnienia `0600`. + +### Sprawdzenie `max_connections` przy starcie + +Autotune daje `max_connections = 100 × RAM_GB` (cap 250, `dbserver/autotune.sh`); +na małych hostach to 50–100, a do tego samego serwera łączą się bezpośrednio +Celery, denorm-queue, authserver, celerybeat, netdata i backup. Pula większa niż +`max_connections` wpada w pętlę `server_login_retry` (15 s) z opóźnionymi +błędami u klientów. + +Entrypoint po wyrenderowaniu odpytuje bazę bezpośrednio (`psql -h +$DJANGO_BPP_DB_HOST … -c 'SHOW max_connections'`, to samo konto). Jeśli +`PGBOUNCER_POOL_SIZE > max_connections − 40` → `OSTRZEZENIE` w logu i przycięcie +puli do `max(10, max_connections − 40)`. 40 = zapas na połączenia bezpośrednie +(Celery przy 75 % rdzeni, denorm-queue, beat, authserver, netdata, backup, sesje +administracyjne). Gdy zapytanie się nie powiedzie → `OSTRZEZENIE`, bez +przycinania, start kontynuowany (nie blokujemy strony na sondzie pomocniczej). ### Zmienne | Zmienna | Domyślnie | Uwagi | |---|---|---| -| `DJANGO_BPP_DB_APP_HOST` | `pgbouncer` | wyłączenie puli: adres prawdziwej bazy | -| `DJANGO_BPP_DB_APP_PORT` | `6432` | wyłączenie puli: port prawdziwej bazy | -| `PGBOUNCER_POOL_SIZE` | `60` | = `BPP_NGINX_GLOBAL_CONN`; musi być wyraźnie < `max_connections` | -| `PGBOUNCER_MAX_CLIENT_CONN` | `200` | > `GUNICORN_LIMIT_CONCURRENCY` (80) × `WEB_CONCURRENCY` | +| `BPP_APPSERVER_DB_HOST` | `pgbouncer` | wyłączenie puli: adres prawdziwej bazy | +| `BPP_APPSERVER_DB_PORT` | `6432` | wyłączenie puli: port prawdziwej bazy | +| `PGBOUNCER_POOL_SIZE` | `80` | zapas ponad `BPP_NGINX_GLOBAL_CONN` (60) na healthcheck appservera i joby Ofelii; przycinane do `max_connections − 40` | +| `PGBOUNCER_MAX_CLIENT_CONN` | `1000` | przekroczenie to twardy błąd, nie kolejka; połączenia klienckie są tanie; ma być ≫ `GUNICORN_LIMIT_CONCURRENCY` × `WEB_CONCURRENCY` | | `PGBOUNCER_VERSION` | `v1.25.2-p0` | przypięty tag, jak `HTML2DOCX_VERSION` | -| `PGBOUNCER_MEM_LIMIT` / `_CPU_LIMIT` | `64m` / `0.5` | jak reszta `*_LIMIT` | +| `PGBOUNCER_MEM_LIMIT` / `_CPU_LIMIT` | `64m` / `0.5` | jak reszta `*_LIMIT`; wpisywane przez `make configure-resources` | -Liczby walidowane w skrypcie jak w `25-render-bpp-limits.sh`: puste = domyślna, -śmieć = `OSTRZEZENIE` w logu + domyślna. Brak nowej zmiennej wymaganej — **zero -migracji `.env`**, stary `.env` działa (kontrakt backwards-compat). +Liczby walidowane w skrypcie jak w `defaults/webserver/25-render-bpp-limits.sh`: +puste = domyślna, śmieć = `OSTRZEZENIE` + domyślna. Brak nowej zmiennej +wymaganej — **zero migracji `.env`**, stary `.env` działa (kontrakt +backwards-compat). Wyłączenie puli (`docs/konfiguracja/pgbouncer.md`): ``` -DJANGO_BPP_DB_APP_HOST=dbserver # albo host zewnętrznej bazy -DJANGO_BPP_DB_APP_PORT=5432 +BPP_APPSERVER_DB_HOST=dbserver # albo host zewnętrznej bazy +BPP_APPSERVER_DB_PORT=5432 make up ``` Usługa `pgbouncer` dalej działa bezczynnie (kilka MB) — celowo, bez warunkowego -compose; jedno pokrętło, łatwy powrót. +compose; jedno pokrętło, łatwy powrót. Dzięki `service_started` powrót działa +także, gdy pgbouncer jest `unhealthy`. ## Awarie | Sytuacja | Skutek | Obrona | |---|---|---| -| pgbouncer pada | leży strona (appserver); Celery, denorm, panele działają | `restart: always`; bramka zdrowia `make up` | -| baza niedostępna | jak dziś — appserver dostaje błąd połączenia | healthcheck `pgbouncer` czerwony, widoczny w `make doctor` | -| błędne hasło w `.env` | pgbouncer wstaje, logowanie klienta pada | healthcheck (`psql` przez pulę) czerwony → appserver nie startuje | -| pula pełna | klient czeka do `query_wait_timeout` (30 s), potem błąd | limity nginx/appservera trzymają ruch poniżej rozmiaru puli | +| pgbouncer pada | leży strona (appserver); Celery, denorm, panele działają | `restart: always`; bramka zdrowia `make up`; wyłącznik działa mimo `unhealthy` | +| baza niedostępna | appserver dostaje błąd połączenia natychmiast (`server_login_retry`), powrót po awarii opóźniony o ≤ 15 s | healthcheck `pgbouncer` czerwony, widoczny w `make doctor` | +| błędne hasło w `.env` | pgbouncer wstaje, logowanie klienta pada | healthcheck czerwony; appserver zwraca błędy bazy | +| pula pełna | klient czeka do 15 s (`query_wait_timeout`), potem błąd | limity nginx/appservera trzymają ruch poniżej puli; `SHOW POOLS` (`stats_users`) i `stats:` w logu pokazują `cl_waiting`/`wait` | +| pula > `max_connections` | — | przycięcie przy starcie z `OSTRZEZENIE` | | zmiana hasła | `.env` → `make up` odtwarza kontener (zmiana `env_file`) | — | ## Testy @@ -183,11 +257,14 @@ compose; jedno pokrętło, łatwy powrót. — grep po źródle nie widzi interpolacji): - domyślnie appserver ma `DJANGO_BPP_DB_HOST=pgbouncer`, `_PORT=6432`; -- z `DJANGO_BPP_DB_APP_HOST=dbserver` + `_PORT=5432` — bezpośrednio; +- z `BPP_APPSERVER_DB_HOST=dbserver` + `_PORT=5432` — bezpośrednio; - **żaden inny** serwis nie łączy się z `pgbouncer` (authserver, workerserver, celerybeat, denorm-queue, flower, netdata); -- `pgbouncer` ma `logging`, limity, `depends_on: dbserver`, serwisowy `env_file`; -- `$$` w healthchecku przetrwał render (`test_compose_shell_vars_escaped`). +- appserver → pgbouncer ma `condition: service_started`; +- `pgbouncer` ma `logging`, limity, `depends_on: dbserver`, serwisowy + `env_file`, entrypoint przez `sh`; +- `$$` w healthchecku przetrwał render (`test_compose_shell_vars_escaped`); +- `configure-resources` zna `pgbouncer` (asercje kompletu MEM). **Na żywo** (`scripts/test-pgbouncer.sh` → `make test-pgbouncer`, do CI obok `test-nginx-limits`): prawdziwy `postgres` (SCRAM) + `pgbouncer` z naszym @@ -195,40 +272,61 @@ compose; jedno pokrętło, łatwy powrót. 1. logowanie przez pulę tym samym kontem; 2. hasło z `"`, `%`, `$`, spacją — logowanie działa; -3. `LISTEN` działa; advisory lock wzięty w połączeniu A jest **zwolniony** po - rozłączeniu A (`DISCARD ALL`) — połączenie B go dostaje; -4. dwa kolejne połączenia klienta dostają **ten sam** `pg_backend_pid()` — +3. `LISTEN` działa przez pulę; +4. **reset sesji między klientami**, w fazach: A bierze + `pg_advisory_lock(42)` i zostaje połączony → B dostaje + `pg_try_advisory_lock(42) = false`; A się rozłącza → B (nowe połączenie + klienta) dostaje `true`. Plus `SET` z A nie jest widoczny w kolejnym + połączeniu (`RESET ALL`). (Samo „B dostaje lock po A” przeszłoby też + w `transaction` — lock sesyjny jest reentrantny na tym samym backendzie.) +5. dwa kolejne połączenia klienta dostają **ten sam** `pg_backend_pid()` — dowód ponownego użycia, czyli źródła przyspieszenia; -5. baza pod nazwą hosta inną niż `dbserver` (symulacja trybu zewnętrznego); -6. śmieć w `PGBOUNCER_POOL_SIZE` → ostrzeżenie + domyślna, pgbouncer wstaje; -7. healthcheck czerwony przy złym haśle. +6. `psql --single-transaction -f` ze skryptem `SET`/`CREATE TABLE`/`COPY` — + odpowiednik `baseline_load` przy świeżej instalacji; +7. healthcheck: zielony normalnie; **zielony przy nasyconej głównej puli** + (klienci trzymają wszystkie `POOL_SIZE` połączeń); czerwony przy złym haśle; +8. śmieć w `PGBOUNCER_POOL_SIZE` → ostrzeżenie + domyślna, pgbouncer wstaje; +9. `POOL_SIZE` > `max_connections − 40` (baza z niskim `max_connections`) → + przycięcie + `OSTRZEZENIE`. + +Mutacje, które muszą wywrócić test: bez podwajania `"` (2), `pool_mode = +transaction` (4), `server_reset_query =` pusty (4), sonda przez główną pulę (7). + +## Pomiar wydajności -Mutacje do sprawdzenia: bez podwajania `"`, `pool_mode=transaction` -(przypadek 3 musi paść), `DISCARD ALL` wyłączone. +Skrypt pomiarowy z 2026-09-27 (czas `connect` + zapytanie zimne/ciepłe z +kontenera appservera), uruchomiony przed i po. Do zmierzenia jako **opcja, nie +domyślna**: `auth_type = md5` na odcinku appserver → pgbouncer (sieć wewnętrzna +compose) zdejmuje PBKDF2 po obu stronach — jeśli pomiar pokaże, że SCRAM +dominuje pozostały koszt, osobna decyzja. ## Kryteria akceptacji - testy statyczne i `make test-pgbouncer` zielone lokalnie i w CI; -- na publikacje.up.lublin.pl po wdrożeniu: skrypt pomiarowy (jak z 2026-09-27) - pokazuje spadek kosztu połączenia appserver → baza wyraźnie poniżej 18 ms, - a mediana `request_time` w access logu nie rośnie; +- na publikacje.up.lublin.pl po wdrożeniu: czas `connect` + pierwsze zapytanie + (dziś 18 + 9,7 ms) spada do rzędu ≤ 8 ms, a mediana `request_time` w access + logu nie rośnie; - `make doctor` i bramka zdrowia `make up` zielone. ## Dokumentacja -- `docs/konfiguracja/pgbouncer.md` (nowa): po co, co idzie przez pulę, zmienne, - wyłączenie, tryb zewnętrzny, diagnostyka (`SHOW POOLS` przez konsolę admina - nie jest włączona — logi `docker compose logs pgbouncer`). +- `docs/konfiguracja/pgbouncer.md` (nowa): po co i co realnie daje, co idzie + przez pulę, zmienne, wyłączenie, tryb zewnętrzny, diagnostyka (`SHOW POOLS` + przez `stats_users`, `stats:` w logu). - `docs/architektura/uslugi.md`: nowa usługa, zależności. - `docs/konfiguracja/limity-zasobow.md`: `PGBOUNCER_*_LIMIT`, relacja - `POOL_SIZE` ↔ `max_connections`. + `DBSERVER_MEM_LIMIT → max_connections → PGBOUNCER_POOL_SIZE`. +- `docs/eksploatacja/komendy.md`: `make test-pgbouncer`. - `mkdocs.yml` nav. -- `CLAUDE.md` — tylko tripwire'y: wyłącznie `session`; nie przepinać - `DJANGO_BPP_DB_HOST` na pulę; denorm-queue nigdy przez pulę; własny entrypoint - zamiast obrazowego (escapowanie); `POOL_SIZE` < `max_connections`. +- `CLAUDE.md` — tylko tripwire'y: wyłącznie `session` (powód: `WITH HOLD` + cursors); nie przepinać `DJANGO_BPP_DB_HOST` na pulę; denorm-queue nigdy przez + pulę; własny entrypoint zamiast obrazowego (escapowanie); sonda przez osobny + wpis `_health`; `service_started`, nie `service_healthy`. ## Poza zakresem Celery przez pulę; tryb `transaction`; TLS do bazy; kolektor netdata dla -pgbouncera; konsola admina pgbouncera; sprzątanie zdublowanych zmiennych -`DJANGO_BPP_DB_*` w `.env` na publikacje.up.lublin.pl (osobno, przed wdrożeniem). +pgbouncera; konsola admina (`admin_users`); `.gitattributes` z `*.sh eol=lf` +(problem istniejący, dotyczy też `dbserver/*.sh`); sprzątanie zdublowanych +zmiennych `DJANGO_BPP_DB_*` w `.env` na publikacje.up.lublin.pl (osobno, przed +wdrożeniem). From 95ec1ad6bb27566737842f540d50492f29cdd312 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Sun, 27 Sep 2026 22:20:34 +0200 Subject: [PATCH 3/9] =?UTF-8?q?docs(spec):=20pgbouncer=20v3=20=E2=80=94=20?= =?UTF-8?q?bez=20wylacznika,=20service=5Fhealthy=20jak=20dbserver;=20popra?= =?UTF-8?q?wki=20z=202.=20review?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../specs/2026-09-27-pgbouncer-design.md | 119 ++++++++++-------- 1 file changed, 66 insertions(+), 53 deletions(-) diff --git a/docs/superpowers/specs/2026-09-27-pgbouncer-design.md b/docs/superpowers/specs/2026-09-27-pgbouncer-design.md index 73e6ed3..e791ef0 100644 --- a/docs/superpowers/specs/2026-09-27-pgbouncer-design.md +++ b/docs/superpowers/specs/2026-09-27-pgbouncer-design.md @@ -1,7 +1,6 @@ # pgbouncer przed PostgreSQL dla appservera — projekt -Data: 2026-09-27 · Status: kształt zatwierdzony; wersja 2 po review (poprawki -W1–W6, D1–D11), do przeglądu +Data: 2026-09-27 · Status: kształt zatwierdzony; wersja 3 po dwóch rundach review, do przeglądu ## Cel @@ -96,8 +95,8 @@ zmniejsza liczby równoczesnych połączeń do bazy — zmniejsza koszt każdego |---|---| | `docker-compose.pgbouncer.yml` (nowy) | usługa `pgbouncer`, własne `x-logging` (anchory nie przechodzą przez `include:`) | | `docker-compose.yml` | `include:` nowego pliku z `env_file: ${BPP_CONFIGS_DIR}/.env` | -| `docker-compose.application.yml` | appserver: nadpisanie `DJANGO_BPP_DB_HOST`/`_PORT` w `environment:` + `depends_on: pgbouncer: service_started` | -| `pgbouncer/entrypoint.sh` (nowy) | wersjonowany kod wykonywalny (precedens: `dbserver/`, nie `defaults/` — to nie szablon konfiguracji operatora); renderuje `pgbouncer.ini` + `userlist.txt`, sprawdza `max_connections`, potem `exec pgbouncer` | +| `docker-compose.application.yml` | appserver: nadpisanie `DJANGO_BPP_DB_HOST`/`_PORT` w `environment:` + `depends_on: pgbouncer: service_healthy` | +| `pgbouncer/entrypoint.sh` (nowy), bind-mount `./pgbouncer/entrypoint.sh:/bpp-entrypoint.sh:ro` | wersjonowany kod wykonywalny (precedens: `dbserver/`, nie `defaults/` — to nie szablon konfiguracji operatora); renderuje `pgbouncer.ini` + `userlist.txt`, sprawdza `max_connections`, potem `exec pgbouncer` | | `scripts/configure-resources.sh` | `pgbouncer:64` na liście MEM + prefiks `PGBOUNCER` | `environment:` w compose ma pierwszeństwo przed `env_file:`, więc nadpisanie @@ -124,14 +123,14 @@ działa bez ruszania `.env`. Interpolacja `${BPP_APPSERVER_DB_HOST}` czyta `PGBOUNCER_CPU_LIMIT` (domyślnie `0.5`). - Bez opublikowanego portu — tylko sieć compose. -### appserver → pgbouncer: `service_started`, nie `service_healthy` +### appserver → pgbouncer: `service_healthy`, tak jak do `dbserver` -Z `service_healthy` wyłącznik puli nie działałby w scenariuszu, w którym jest -najbardziej potrzebny: gdy pgbouncer nie osiąga `healthy`, `make up` staje na -„container pgbouncer is unhealthy”, nawet po przełączeniu appservera na bazę. -Gwarancję, że baza żyje, daje pozostawione `dbserver: service_healthy`; -pgbouncer startuje w ułamku sekundy; ewentualny wyścig kończy się jednym -restartem appservera (`restart: always`). +pgbouncer to infrastruktura tej samej kategorii co `dbserver`: bez niej strona +nie działa. Zachowanie jest więc identyczne — appserver startuje dopiero przy +zdrowym pgbouncerze, a `unhealthy` pgbouncer wywraca `make up` (`--wait` czeka +na wszystkie usługi z healthcheckiem) i zapala bramkę zdrowia. Żadnego +osobnego „wyłącznika”: wycofanie zmiany to, jak przy każdej innej, powrót do +poprzedniego commita bpp-deploy i `make up`. ### Healthcheck — osobna pula dla sondy @@ -204,21 +203,33 @@ Celery, denorm-queue, authserver, celerybeat, netdata i backup. Pula większa ni `max_connections` wpada w pętlę `server_login_retry` (15 s) z opóźnionymi błędami u klientów. -Entrypoint po wyrenderowaniu odpytuje bazę bezpośrednio (`psql -h -$DJANGO_BPP_DB_HOST … -c 'SHOW max_connections'`, to samo konto). Jeśli -`PGBOUNCER_POOL_SIZE > max_connections − 40` → `OSTRZEZENIE` w logu i przycięcie -puli do `max(10, max_connections − 40)`. 40 = zapas na połączenia bezpośrednie -(Celery przy 75 % rdzeni, denorm-queue, beat, authserver, netdata, backup, sesje -administracyjne). Gdy zapytanie się nie powiedzie → `OSTRZEZENIE`, bez -przycinania, start kontynuowany (nie blokujemy strony na sondzie pomocniczej). +Entrypoint po wyrenderowaniu odpytuje bazę bezpośrednio (`PGCONNECT_TIMEOUT=5 +psql -h $DJANGO_BPP_DB_HOST … -c 'SHOW max_connections'`, to samo konto; +timeout, żeby start nie zawisł). Zapas na połączenia bezpośrednie: + + rezerwa = max(40, 20 + ceil(0.75 × nproc)) + +— Celery ma `floor(0.75 × rdzenie)` dzieci, każde z połączeniem +(`src/django_bpp/celery_tasks.py`), plus ~10 stałych (denorm-queue, beat, +authserver, netdata, backup, sesje admina, alias `_health`) i 3 sloty +`superuser_reserved_connections`. `nproc` w kontenerze widzi rdzenie hosta. +Stałe 40 przestaje wystarczać powyżej ~32 rdzeni. + +Jeśli `PGBOUNCER_POOL_SIZE > max_connections − rezerwa` → przycięcie puli do +`max(10, max_connections − rezerwa)` i `OSTRZEZENIE` z instrukcją: „obniż +`BPP_NGINX_GLOBAL_CONN` do ≤ N albo podnieś `DBSERVER_MEM_LIMIT`”. Na hostach +skonfigurowanych przez `make configure-resources` to nie wystąpi (minimalny +`DBSERVER_MEM_LIMIT` daje `max_connections` ≈ 150); dotyczy ręcznie +zaniżonego limitu i baz zewnętrznych. Gdy zapytanie się nie powiedzie → +`OSTRZEZENIE`, bez przycinania, start kontynuowany. ### Zmienne | Zmienna | Domyślnie | Uwagi | |---|---|---| -| `BPP_APPSERVER_DB_HOST` | `pgbouncer` | wyłączenie puli: adres prawdziwej bazy | -| `BPP_APPSERVER_DB_PORT` | `6432` | wyłączenie puli: port prawdziwej bazy | -| `PGBOUNCER_POOL_SIZE` | `80` | zapas ponad `BPP_NGINX_GLOBAL_CONN` (60) na healthcheck appservera i joby Ofelii; przycinane do `max_connections − 40` | +| `BPP_APPSERVER_DB_HOST` | `pgbouncer` | dokąd łączy się appserver; np. `dbserver`/host bazy zewnętrznej = z pominięciem puli | +| `BPP_APPSERVER_DB_PORT` | `6432` | port dla powyższego | +| `PGBOUNCER_POOL_SIZE` | `80` | zapas ponad `BPP_NGINX_GLOBAL_CONN` (60) na healthcheck appservera i joby Ofelii; przycinane do `max_connections − rezerwa` (niżej) | | `PGBOUNCER_MAX_CLIENT_CONN` | `1000` | przekroczenie to twardy błąd, nie kolejka; połączenia klienckie są tanie; ma być ≫ `GUNICORN_LIMIT_CONCURRENCY` × `WEB_CONCURRENCY` | | `PGBOUNCER_VERSION` | `v1.25.2-p0` | przypięty tag, jak `HTML2DOCX_VERSION` | | `PGBOUNCER_MEM_LIMIT` / `_CPU_LIMIT` | `64m` / `0.5` | jak reszta `*_LIMIT`; wpisywane przez `make configure-resources` | @@ -228,24 +239,17 @@ puste = domyślna, śmieć = `OSTRZEZENIE` + domyślna. Brak nowej zmiennej wymaganej — **zero migracji `.env`**, stary `.env` działa (kontrakt backwards-compat). -Wyłączenie puli (`docs/konfiguracja/pgbouncer.md`): - -``` -BPP_APPSERVER_DB_HOST=dbserver # albo host zewnętrznej bazy -BPP_APPSERVER_DB_PORT=5432 -make up -``` - -Usługa `pgbouncer` dalej działa bezczynnie (kilka MB) — celowo, bez warunkowego -compose; jedno pokrętło, łatwy powrót. Dzięki `service_started` powrót działa -także, gdy pgbouncer jest `unhealthy`. +`BPP_APPSERVER_DB_HOST`/`_PORT` to zwykłe pokrętło konfiguracji, nie tryb +awaryjny. Ustawione na prawdziwą bazę kierują appserver z pominięciem puli; +usługa `pgbouncer` dalej działa i dalej musi być zdrowa (appserver od niej +zależy), bo to wciąż element infrastruktury. ## Awarie | Sytuacja | Skutek | Obrona | |---|---|---| -| pgbouncer pada | leży strona (appserver); Celery, denorm, panele działają | `restart: always`; bramka zdrowia `make up`; wyłącznik działa mimo `unhealthy` | -| baza niedostępna | appserver dostaje błąd połączenia natychmiast (`server_login_retry`), powrót po awarii opóźniony o ≤ 15 s | healthcheck `pgbouncer` czerwony, widoczny w `make doctor` | +| pgbouncer pada | leży strona (appserver); Celery, denorm, panele działają | `restart: always`; `make up` pada, bramka zdrowia czerwona — jak przy `dbserver` | +| baza niedostępna | zatrzymany kontener: błąd od razu (`server_login_retry`); host bez odpowiedzi (baza zewnętrzna za firewallem): pierwszy klient czeka do `server_connect_timeout` (15 s); powrót po awarii opóźniony o ≤ 15 s | healthcheck `pgbouncer` czerwony, widoczny w `make doctor` | | błędne hasło w `.env` | pgbouncer wstaje, logowanie klienta pada | healthcheck czerwony; appserver zwraca błędy bazy | | pula pełna | klient czeka do 15 s (`query_wait_timeout`), potem błąd | limity nginx/appservera trzymają ruch poniżej puli; `SHOW POOLS` (`stats_users`) i `stats:` w logu pokazują `cl_waiting`/`wait` | | pula > `max_connections` | — | przycięcie przy starcie z `OSTRZEZENIE` | @@ -257,10 +261,10 @@ także, gdy pgbouncer jest `unhealthy`. — grep po źródle nie widzi interpolacji): - domyślnie appserver ma `DJANGO_BPP_DB_HOST=pgbouncer`, `_PORT=6432`; -- z `BPP_APPSERVER_DB_HOST=dbserver` + `_PORT=5432` — bezpośrednio; +- z `BPP_APPSERVER_DB_HOST=dbserver` + `_PORT=5432` — appserver bezpośrednio do bazy; - **żaden inny** serwis nie łączy się z `pgbouncer` (authserver, workerserver, celerybeat, denorm-queue, flower, netdata); -- appserver → pgbouncer ma `condition: service_started`; +- appserver → pgbouncer ma `condition: service_healthy`; - `pgbouncer` ma `logging`, limity, `depends_on: dbserver`, serwisowy `env_file`, entrypoint przez `sh`; - `$$` w healthchecku przetrwał render (`test_compose_shell_vars_escaped`); @@ -273,24 +277,30 @@ także, gdy pgbouncer jest `unhealthy`. 1. logowanie przez pulę tym samym kontem; 2. hasło z `"`, `%`, `$`, spacją — logowanie działa; 3. `LISTEN` działa przez pulę; -4. **reset sesji między klientami**, w fazach: A bierze - `pg_advisory_lock(42)` i zostaje połączony → B dostaje - `pg_try_advisory_lock(42) = false`; A się rozłącza → B (nowe połączenie - klienta) dostaje `true`. Plus `SET` z A nie jest widoczny w kolejnym - połączeniu (`RESET ALL`). (Samo „B dostaje lock po A” przeszłoby też - w `transaction` — lock sesyjny jest reentrantny na tym samym backendzie.) +4. **izolacja klientów** (pula ≥ 2): A bierze `pg_advisory_lock(42)` + i zostaje połączony → B dostaje `pg_try_advisory_lock(42) = false`. + W `transaction` A oddaje backend po autocommicie, B trafia na ten sam + i dostaje reentrantne `true` — mutacja wykryta. +4b. **reset sesji** przy `PGBOUNCER_POOL_SIZE=1` (następny klient zawsze + dostaje ten sam backend, więc wynik jest deterministyczny): A bierze lock + i robi `SET`, rozłącza się → B dostaje lock (`true`) i nie widzi `SET` + (`DISCARD ALL`). Przy większej puli B mógłby trafić na inny backend + i przejść także pod mutacją „pusty `server_reset_query`”. 5. dwa kolejne połączenia klienta dostają **ten sam** `pg_backend_pid()` — dowód ponownego użycia, czyli źródła przyspieszenia; 6. `psql --single-transaction -f` ze skryptem `SET`/`CREATE TABLE`/`COPY` — odpowiednik `baseline_load` przy świeżej instalacji; -7. healthcheck: zielony normalnie; **zielony przy nasyconej głównej puli** - (klienci trzymają wszystkie `POOL_SIZE` połączeń); czerwony przy złym haśle; +7. healthcheck (pod `timeout 5`): zielony normalnie; **zielony przy nasyconej + głównej puli** (`PGBOUNCER_POOL_SIZE=2`, dwóch klientów trzyma oba + połączenia); czerwony przy złym haśle; 8. śmieć w `PGBOUNCER_POOL_SIZE` → ostrzeżenie + domyślna, pgbouncer wstaje; -9. `POOL_SIZE` > `max_connections − 40` (baza z niskim `max_connections`) → - przycięcie + `OSTRZEZENIE`. +9. `POOL_SIZE` > `max_connections − rezerwa` (baza z niskim `max_connections`) → + przycięcie + `OSTRZEZENIE`; +10. brak ostrzeżenia o limicie deskryptorów plików przy `max_client_conn=1000` + („kernel file descriptor limit”). Mutacje, które muszą wywrócić test: bez podwajania `"` (2), `pool_mode = -transaction` (4), `server_reset_query =` pusty (4), sonda przez główną pulę (7). +transaction` (4), `server_reset_query =` pusty (4b), sonda przez główną pulę (7). ## Pomiar wydajności @@ -303,16 +313,18 @@ dominuje pozostały koszt, osobna decyzja. ## Kryteria akceptacji - testy statyczne i `make test-pgbouncer` zielone lokalnie i w CI; -- na publikacje.up.lublin.pl po wdrożeniu: czas `connect` + pierwsze zapytanie - (dziś 18 + 9,7 ms) spada do rzędu ≤ 8 ms, a mediana `request_time` w access - logu nie rośnie; +- na publikacje.up.lublin.pl po wdrożeniu: mediana czasu `connect` + pierwsze + zapytanie jest **co najmniej 3× niższa** niż dziś (27,7 ms), a mediana + `request_time` w access logu nie rośnie; - `make doctor` i bramka zdrowia `make up` zielone. ## Dokumentacja - `docs/konfiguracja/pgbouncer.md` (nowa): po co i co realnie daje, co idzie - przez pulę, zmienne, wyłączenie, tryb zewnętrzny, diagnostyka (`SHOW POOLS` - przez `stats_users`, `stats:` w logu). + przez pulę, zmienne, tryb zewnętrzny, diagnostyka — gotowa komenda + `docker compose exec pgbouncer sh -c 'PGPASSWORD="$DJANGO_BPP_DB_PASSWORD" + psql -h 127.0.0.1 -p 6432 -U "$DJANGO_BPP_DB_USER" pgbouncer -c "SHOW POOLS"'` + (`stats_users`) oraz pole `wait` w liniach `stats:` w logu. - `docs/architektura/uslugi.md`: nowa usługa, zależności. - `docs/konfiguracja/limity-zasobow.md`: `PGBOUNCER_*_LIMIT`, relacja `DBSERVER_MEM_LIMIT → max_connections → PGBOUNCER_POOL_SIZE`. @@ -321,7 +333,8 @@ dominuje pozostały koszt, osobna decyzja. - `CLAUDE.md` — tylko tripwire'y: wyłącznie `session` (powód: `WITH HOLD` cursors); nie przepinać `DJANGO_BPP_DB_HOST` na pulę; denorm-queue nigdy przez pulę; własny entrypoint zamiast obrazowego (escapowanie); sonda przez osobny - wpis `_health`; `service_started`, nie `service_healthy`. + wpis `_health`; appserver → pgbouncer `service_healthy` jak do `dbserver`, + bez „wyłącznika” (`scale`, kłamiąca sonda). ## Poza zakresem From 9edacceaf69b488920fd19c870e4edf8687528c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Sun, 27 Sep 2026 22:55:52 +0200 Subject: [PATCH 4/9] =?UTF-8?q?docs(plan):=20pgbouncer=20=E2=80=94=20plan?= =?UTF-8?q?=20implementacji?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../superpowers/plans/2026-09-27-pgbouncer.md | 971 ++++++++++++++++++ 1 file changed, 971 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-27-pgbouncer.md diff --git a/docs/superpowers/plans/2026-09-27-pgbouncer.md b/docs/superpowers/plans/2026-09-27-pgbouncer.md new file mode 100644 index 0000000..1eb84af --- /dev/null +++ b/docs/superpowers/plans/2026-09-27-pgbouncer.md @@ -0,0 +1,971 @@ +# pgbouncer przed PostgreSQL dla appservera — plan implementacji + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Usługa `pgbouncer` (tryb `session`), przez którą appserver łączy się z PostgreSQL — domyślnie włączona, ta sama konfiguracja dla `dbserver` i bazy zewnętrznej. + +**Architecture:** Nowy `docker-compose.pgbouncer.yml` z obrazem `edoburu/pgbouncer`, ale z WŁASNYM skryptem startowym `pgbouncer/entrypoint.sh` (render `pgbouncer.ini` + `userlist.txt`, walidacja zmiennych, przycięcie puli do `max_connections`, podkomenda `zdrowie` dla healthchecka). appserver dostaje w `environment:` nadpisanie `DJANGO_BPP_DB_HOST/_PORT` z `BPP_APPSERVER_DB_HOST/_PORT` (domyślnie `pgbouncer:6432`) i `depends_on: pgbouncer: service_healthy`. Reszta serwisów bez zmian. + +**Tech Stack:** Docker Compose v2 (`include:`), pgbouncer 1.25.2 (`edoburu/pgbouncer:v1.25.2-p0`, Alpine, busybox `sh`, uid 70), PostgreSQL 18, bash (testy), MkDocs. + +**Spec:** `docs/superpowers/specs/2026-09-27-pgbouncer-design.md` (wersja 3, commit `95ec1ad`). Wykonawca czyta spec i plan. + +## Global Constraints + +- Tryb `pool_mode = session` NA SZTYWNO — bez zmiennej, bez przełącznika. +- Przez pulę idzie **wyłącznie appserver**. authserver, workerserver, celerybeat, denorm-queue, flower, netdata, backup — bez zmian. +- `DJANGO_BPP_DB_HOST`/`_PORT` w `.env` dalej znaczą „prawdziwa baza”; NIE przepinamy ich na pulę. +- Zmienne i domyślne: `BPP_APPSERVER_DB_HOST=pgbouncer`, `BPP_APPSERVER_DB_PORT=6432`, `PGBOUNCER_POOL_SIZE=80`, `PGBOUNCER_MAX_CLIENT_CONN=1000`, `PGBOUNCER_VERSION=v1.25.2-p0`, `PGBOUNCER_MEM_LIMIT=64m`, `PGBOUNCER_CPU_LIMIT=0.5`. +- Zero migracji `.env`; stary `.env` musi działać bez edycji (kontrakt backwards-compat w CLAUDE.md). +- appserver → pgbouncer: `condition: service_healthy` (jak do `dbserver`). Żadnego `scale`, żadnej „kłamiącej” sondy. +- Healthcheck przez osobny wpis bazy `_health` z `pool_size=1`. +- `entrypoint: ["sh", "/bpp-entrypoint.sh"]` — nie polegamy na bicie `+x`. +- Skrypt startowy: POSIX `sh` (busybox), nie bash. +- `$$` w każdym inline shellu w compose (CLAUDE.md, „CRITICAL: `$$`”). +- Każda nowa usługa: `logging: *default-logging`, własne `x-logging` w pliku. +- `query_wait_timeout = 15`, `log_connections = 0`, `log_disconnections = 0`, `stats_users = `, `listen_addr = *`, `listen_port = 6432`. +- Rezerwa przy przycinaniu: `max(40, 20 + ceil(0.75 × nproc))`; przycięcie do `max(10, max_connections − rezerwa)`. + +## Review Focus + +- **Hasło ze znakami specjalnymi** (`"`, `%`, `$`, spacja, `\`) — logowanie przez pulę ma działać tak samo jak bezpośrednio. Pokryte: Task 1, przypadek 2 (hasło testowe zawiera też `\`). +- **Brak wymaganej zmiennej bazy** (`DJANGO_BPP_DB_NAME`/`_USER`/`_PASSWORD`/`_HOST` pusty) — pgbouncer ma nie wstać z czytelnym komunikatem, a nie wstać z pustą konfiguracją. Pokryte: Task 1, przypadek 11. +- **Baza niedostępna w chwili startu pgbouncera** (sonda `max_connections` nie odpowiada) — start nie może wisieć dłużej niż kilka sekund i nie może się przerwać. Pokryte: Task 1, przypadek 12. +- **Stary `.env` bez żadnej nowej zmiennej** — `git pull && make up` ma przełączyć appserver na pulę bez edycji. Pokryte: Task 2, render compose z minimalnym `.env`. +- **Operator kieruje appserver bezpośrednio do bazy** (`BPP_APPSERVER_DB_HOST=dbserver`) — appserver łączy się bezpośrednio, pozostałe serwisy bez zmian. Pokryte: Task 2. + +--- + +## File Structure + +| Plik | Odpowiedzialność | +|---|---| +| `pgbouncer/entrypoint.sh` (nowy) | render konfiguracji, walidacja, przycięcie puli, `exec pgbouncer`; podkomenda `zdrowie` | +| `scripts/test-pgbouncer.sh` (nowy) | test na żywo: prawdziwy postgres + pgbouncer z naszym skryptem | +| `docker-compose.pgbouncer.yml` (nowy) | usługa `pgbouncer` | +| `docker-compose.yml` | `include:` nowego pliku | +| `docker-compose.application.yml` | appserver: nadpisanie hosta/portu bazy + `depends_on` | +| `scripts/configure-resources.sh` | `pgbouncer:64` + prefiks `PGBOUNCER` | +| `tests/test_makefile.sh` | asercje statyczne na wyrenderowanym compose + configure-resources | +| `mk/misc.mk`, `Makefile`, `.github/workflows/ci.yml` | `make test-pgbouncer`, `make test`, CI | +| `docs/konfiguracja/pgbouncer.md` (nowy), `docs/architektura/uslugi.md`, `docs/konfiguracja/limity-zasobow.md`, `docs/eksploatacja/komendy.md`, `docs/konfiguracja/architektura.md`, `mkdocs.yml`, `CLAUDE.md` | dokumentacja | + +--- + +### Task 1: Skrypt startowy pgbouncera + test na żywo + +**Files:** +- Create: `pgbouncer/entrypoint.sh` +- Create: `scripts/test-pgbouncer.sh` + +**Interfaces:** +- Produces: `sh /bpp-entrypoint.sh` — renderuje `/etc/pgbouncer/pgbouncer.ini` i `/etc/pgbouncer/userlist.txt`, potem `exec pgbouncer /etc/pgbouncer/pgbouncer.ini`. +- Produces: `sh /bpp-entrypoint.sh zdrowie` — exit 0, gdy `SELECT 1` przez `127.0.0.1:6432`, baza `${DJANGO_BPP_DB_NAME}_health`, kontem aplikacji zwraca `1` w ≤ 3 s; inaczej exit ≠ 0. Task 2 używa tego w healthchecku. +- Consumes (env): `DJANGO_BPP_DB_HOST`, `DJANGO_BPP_DB_PORT` (domyślnie 5432), `DJANGO_BPP_DB_NAME`, `DJANGO_BPP_DB_USER`, `DJANGO_BPP_DB_PASSWORD`, `PGBOUNCER_POOL_SIZE`, `PGBOUNCER_MAX_CLIENT_CONN`. +- Log: wszystkie komunikaty z prefiksem `bpp-pgbouncer:`; ostrzeżenia zawierają słowo `OSTRZEZENIE`. + +- [ ] **Step 1: Napisz test na żywo `scripts/test-pgbouncer.sh`** + +```bash +#!/usr/bin/env bash +# SC2016: fragmenty SQL/sh w apostrofach CELOWO — rozwijaja sie w kontenerze. +# SC2015: `warunek && ok ... || zle ...` jest bezpieczne, bo `ok` zawsze +# zwraca 0. +# shellcheck disable=SC2016,SC2015 +# Test pgbouncera BPP na zywo: prawdziwy PostgreSQL (SCRAM) + obraz +# edoburu/pgbouncer z NASZYM skryptem startowym (pgbouncer/entrypoint.sh). +# +# Sprawdza zachowanie, nie tekst konfiguracji: logowanie tym samym kontem +# (takze z haslem pelnym metaznakow), izolacje i reset sesji miedzy klientami +# (tryb session + DISCARD ALL), ponowne uzycie backendu (zrodlo przyspieszenia), +# sonde zdrowia przy nasyconej puli, walidacje zmiennych i przyciecie puli do +# max_connections. Spec: docs/superpowers/specs/2026-09-27-pgbouncer-design.md +# +# Nie wymaga .env ani dzialajacej instalacji BPP — tylko dockera. + +set -uo pipefail + +REPO_DIR="$(cd "$(dirname "$0")/.." && pwd)" +PRZEBIEG="${PGB_TEST_SUFFIX:-$$}" +NET="bpp-pgb-test-net-$PRZEBIEG" +PG="bpp-pgb-test-db-$PRZEBIEG" +PGB="bpp-pgb-test-bouncer-$PRZEBIEG" +CLI="bpp-pgb-test-cli-$PRZEBIEG" +PG_IMAGE="postgres:18.4" +PGB_IMAGE="edoburu/pgbouncer:${PGBOUNCER_VERSION:-v1.25.2-p0}" +DB="bpp" +USR="bpp" +# Haslo celowo pelne metaznakow: " (format userlist.txt), % (printf), +# $ (sh/Compose), \ (escape), spacja. +HASLO='p"a%s$ w\x' + +BLEDY=0 +ok() { echo " OK $*"; } +zle() { echo " BLAD $*"; BLEDY=$((BLEDY + 1)); } + +# shellcheck disable=SC2317,SC2329 # wolane przez `trap` +czysc() { + [ "${PGB_TEST_KEEP:-0}" = 1 ] && { echo "PGB_TEST_KEEP=1 — zostaja $PG $PGB $CLI $NET"; return; } + docker rm -f "$PG" "$PGB" "$CLI" >/dev/null 2>&1 + docker network rm "$NET" >/dev/null 2>&1 +} +trap czysc EXIT + +docker info >/dev/null 2>&1 || { echo "BLAD: docker niedostepny."; exit 1; } + +# start_db MAX_CONNECTIONS [ALIAS] — PostgreSQL pod aliasem sieciowym. +# Alias inny niz "dbserver" celowo: pgbouncer ma dzialac z dowolnym hostem +# (tryb bazy zewnetrznej). +start_db() { + docker rm -f "$PG" >/dev/null 2>&1 + docker run -d --name "$PG" --network "$NET" --network-alias "${2:-baza-zewnetrzna}" \ + -e POSTGRES_DB="$DB" -e POSTGRES_USER="$USR" -e POSTGRES_PASSWORD="$HASLO" \ + "$PG_IMAGE" -c "max_connections=$1" >/dev/null || return 1 + for _ in $(seq 1 30); do + docker exec "$PG" pg_isready -U "$USR" -d "$DB" -h 127.0.0.1 >/dev/null 2>&1 && return 0 + sleep 1 + done + return 1 +} + +# start_pgb [-e VAR=wartosc ...] — pgbouncer z naszym skryptem, czeka na port. +start_pgb() { + docker rm -f "$PGB" >/dev/null 2>&1 + docker run -d --name "$PGB" --network "$NET" --network-alias pgbouncer \ + -e DJANGO_BPP_DB_HOST=baza-zewnetrzna -e DJANGO_BPP_DB_PORT=5432 \ + -e DJANGO_BPP_DB_NAME="$DB" -e DJANGO_BPP_DB_USER="$USR" \ + -e DJANGO_BPP_DB_PASSWORD="$HASLO" \ + "$@" \ + -v "$REPO_DIR/pgbouncer/entrypoint.sh:/bpp-entrypoint.sh:ro" \ + --entrypoint sh "$PGB_IMAGE" /bpp-entrypoint.sh >/dev/null || return 1 + for _ in $(seq 1 20); do + docker exec "$PGB" nc -z 127.0.0.1 6432 >/dev/null 2>&1 && return 0 + [ "$(docker inspect -f '{{.State.Running}}' "$PGB" 2>/dev/null)" = true ] || return 1 + sleep 0.5 + done + return 1 +} + +# q SQL — jedno polaczenie klienta przez pule, wynik bez naglowkow. +q() { + docker exec -e PGPASSWORD="$HASLO" "$CLI" \ + psql -h pgbouncer -p 6432 -U "$USR" -d "$DB" -Atq -v ON_ERROR_STOP=1 -c "$1" 2>&1 +} + +# trzymaj SEKUNDY SQL... — klient w tle: wykonuje SQL, potem trzyma polaczenie +# BEZCZYNNIE (`\! sleep`, bez zapytania) przez SEKUNDY sekund. Bezczynnosc jest +# istotna: w trybie transaction pgbouncer oddaje wtedy backend do puli, wiec +# nastepny klient trafia na TEN SAM backend (LIFO) — i mutacja jest wykryta +# deterministycznie. `SELECT pg_sleep()` zajmowalby backend w obu trybach. +trzymaj() { + local s="$1"; shift + local args=() + for sql in "$@"; do args+=(-c "$sql"); done + docker exec -d -e PGPASSWORD="$HASLO" "$CLI" \ + psql -h pgbouncer -p 6432 -U "$USR" -d "$DB" -Atq "${args[@]}" -c "\\! sleep $s" +} + +ini() { docker exec "$PGB" cat /etc/pgbouncer/pgbouncer.ini; } +log_pgb() { docker logs "$PGB" 2>&1; } + +echo "== przygotowanie ==" +docker network create "$NET" >/dev/null +docker run -d --name "$CLI" --network "$NET" --entrypoint sleep "$PG_IMAGE" infinity >/dev/null +start_db 300 || { echo "BLAD: PostgreSQL nie wstal."; exit 1; } + +echo "== 1-3. logowanie, haslo z metaznakami, LISTEN ==" +if start_pgb; then + [ "$(q 'SELECT 1')" = "1" ] && ok "logowanie przez pule tym samym kontem (haslo z \" % \$ \\ spacja)" \ + || zle "logowanie przez pule: $(q 'SELECT 1')" + [ "$(q 'LISTEN kanal; SELECT 1')" = "1" ] && ok "LISTEN przez pule" || zle "LISTEN: $(q 'LISTEN kanal; SELECT 1')" + LOG="$(log_pgb)" + grep -q "file descriptor" <<<"$LOG" && zle "ostrzezenie o limicie deskryptorow: $(grep 'file descriptor' <<<"$LOG")" \ + || ok "brak ostrzezenia o limicie deskryptorow (max_client_conn=1000)" + G="$(ini)" + grep -q "^pool_mode = session$" <<<"$G" && ok "pool_mode = session" || zle "brak pool_mode = session" + grep -q "^default_pool_size = 80$" <<<"$G" && ok "pula domyslna 80" || zle "pula: $(grep pool_size <<<"$G")" + grep -q "^max_client_conn = 1000$" <<<"$G" && ok "max_client_conn 1000" || zle "brak max_client_conn = 1000" + [ "$(docker exec "$PGB" stat -c %a /etc/pgbouncer/userlist.txt)" = "600" ] && ok "userlist.txt 0600" \ + || zle "userlist.txt ma tryb $(docker exec "$PGB" stat -c %a /etc/pgbouncer/userlist.txt)" +else + zle "pgbouncer nie wstal: $(log_pgb | tail -3)" +fi + +echo "== 5. ponowne uzycie backendu (zrodlo przyspieszenia) ==" +P1="$(q 'SELECT pg_backend_pid()')"; P2="$(q 'SELECT pg_backend_pid()')" +[ -n "$P1" ] && [ "$P1" = "$P2" ] && ok "dwa kolejne polaczenia klienta -> ten sam backend ($P1)" \ + || zle "rozne backendy: '$P1' vs '$P2'" + +echo "== 6. psql --single-transaction -f (jak baseline_load) ==" +W6="$(docker exec -i -e PGPASSWORD="$HASLO" "$CLI" psql -h pgbouncer -p 6432 -U "$USR" -d "$DB" \ + -Atq -v ON_ERROR_STOP=1 --single-transaction -f - 2>&1 <<'SQL' +SET client_min_messages = warning; +CREATE TABLE baseline_test (id int, nazwa text); +COPY baseline_test (id, nazwa) FROM stdin; +1 pierwszy +2 drugi +\. +SELECT count(*) FROM baseline_test; +SQL +)" +[ "$W6" = "2" ] && ok "SET/CREATE/COPY w jednej transakcji przez pule" || zle "baseline: $W6" + +echo "== 4. izolacja klientow (lock trzymany przez A) ==" +trzymaj 4 "SELECT pg_advisory_lock(42)" +sleep 1 +[ "$(q 'SELECT pg_try_advisory_lock(42)')" = "f" ] && ok "B nie dostaje locka trzymanego przez A" \ + || zle "B dostal lock A — klienci dziela backend (tryb transaction?)" +sleep 4 + +echo "== 7. sonda zdrowia przy nasyconej glownej puli ==" +if start_pgb -e PGBOUNCER_POOL_SIZE=2; then + docker exec "$PGB" sh /bpp-entrypoint.sh zdrowie >/dev/null 2>&1 && ok "sonda zielona (pusta pula)" \ + || zle "sonda czerwona przy pustej puli" + # w trybie session bezczynny klient i tak trzyma backend -> pula 2 pelna + trzymaj 8 "SELECT 1"; trzymaj 8 "SELECT 1" + sleep 1 + if timeout 5 docker exec "$PGB" sh /bpp-entrypoint.sh zdrowie >/dev/null 2>&1; then + ok "sonda zielona przy pelnej glownej puli (osobny wpis _health)" + else + zle "sonda czerwona/zawieszona przy pelnej puli — idzie przez glowna pule" + fi + sleep 8 +else + zle "pgbouncer nie wstal (pula 2)" +fi + +echo "== 4b. reset sesji miedzy klientami (pula 1 = deterministycznie) ==" +if start_pgb -e PGBOUNCER_POOL_SIZE=1; then + PA="$(q "SELECT pg_advisory_lock(42); SET bpp.test = 'zostalo'; SELECT pg_backend_pid()" | tail -1)" + WB="$(q "SELECT pg_backend_pid() || '|' || pg_try_advisory_lock(42) || '|' || coalesce(current_setting('bpp.test', true), '')")" + PB="${WB%%|*}" + [ "$PA" = "$PB" ] && ok "B trafil na backend A ($PA) — wynik deterministyczny" \ + || zle "B na innym backendzie ('$PA' vs '$PB') — test nie rozstrzyga" + # bool w konkatenacji tekstowej to 'true'/'false', nie 't'/'f' + [ "$WB" = "$PA|true|" ] && ok "po rozlaczeniu A: lock zwolniony, SET niewidoczny (DISCARD ALL)" \ + || zle "stan sesji A przeciekl do B: '$WB'" +else + zle "pgbouncer nie wstal (pula 1)" +fi + +echo "== 7b. sonda czerwona przy zlym hasle ==" +if start_pgb -e DJANGO_BPP_DB_PASSWORD=zle-haslo; then + timeout 8 docker exec "$PGB" sh /bpp-entrypoint.sh zdrowie >/dev/null 2>&1 \ + && zle "sonda zielona mimo zlego hasla" || ok "sonda czerwona przy zlym hasle" +else + zle "pgbouncer nie wstal przy zlym hasle (ma wstac, a sonda ma byc czerwona)" +fi + +echo "== 8. smiec w zmiennych nie kladzie pgbouncera ==" +if start_pgb -e PGBOUNCER_POOL_SIZE=abc -e PGBOUNCER_MAX_CLIENT_CONN="10 0"; then + G="$(ini)"; LOG="$(log_pgb)" + grep -q "^default_pool_size = 80$" <<<"$G" && grep -q "^max_client_conn = 1000$" <<<"$G" \ + && ok "bledne wartosci zastapione domyslnymi" || zle "nie wrocono do domyslnych: $(grep -E 'pool_size|client_conn' <<<"$G")" + grep -q "OSTRZEZENIE: PGBOUNCER_POOL_SIZE='abc'" <<<"$LOG" && ok "ostrzezenie w logu" \ + || zle "brak ostrzezenia o PGBOUNCER_POOL_SIZE" +else + zle "pgbouncer nie wstal na blednych wartosciach" +fi + +echo "== 9. przyciecie puli do max_connections ==" +start_db 50 || zle "PostgreSQL (max_connections=50) nie wstal" +if start_pgb; then + G="$(ini)"; LOG="$(log_pgb)" + grep -q "^default_pool_size = 10$" <<<"$G" && grep -q "^max_db_connections = 10$" <<<"$G" \ + && ok "pula 80 przycieta do 10 przy max_connections=50" || zle "brak przyciecia: $(grep pool_size <<<"$G")" + grep -q "OSTRZEZENIE: .*max_connections" <<<"$LOG" && grep -q "BPP_NGINX_GLOBAL_CONN" <<<"$LOG" \ + && ok "ostrzezenie z instrukcja dla operatora" || zle "brak ostrzezenia o przycieciu" +else + zle "pgbouncer nie wstal przy max_connections=50" +fi + +echo "== 12. baza niedostepna przy starcie — start nie wisi i nie pada ==" +docker stop "$PG" >/dev/null +T0=$(date +%s) +if start_pgb; then + T=$(( $(date +%s) - T0 )) + [ "$T" -le 12 ] && ok "pgbouncer wstal w ${T}s mimo niedostepnej bazy" || zle "start trwal ${T}s" + grep -q "OSTRZEZENIE: .*max_connections" <<<"$(log_pgb)" && ok "ostrzezenie o nieudanej sondzie" \ + || zle "brak ostrzezenia o nieudanej sondzie max_connections" + grep -q "^default_pool_size = 80$" <<<"$(ini)" && ok "bez sondy: pula bez przyciecia" || zle "pula zmieniona bez sondy" +else + zle "pgbouncer nie wstal przy niedostepnej bazie" +fi + +echo "== 11. brak wymaganej zmiennej = glosny blad ==" +docker rm -f "$PGB" >/dev/null 2>&1 +docker run --name "$PGB" --network "$NET" \ + -e DJANGO_BPP_DB_HOST=baza-zewnetrzna -e DJANGO_BPP_DB_NAME="$DB" -e DJANGO_BPP_DB_USER="$USR" \ + -v "$REPO_DIR/pgbouncer/entrypoint.sh:/bpp-entrypoint.sh:ro" \ + --entrypoint sh "$PGB_IMAGE" /bpp-entrypoint.sh >/dev/null 2>&1 +RC=$? +[ "$RC" -ne 0 ] && grep -q "DJANGO_BPP_DB_PASSWORD" <<<"$(log_pgb)" \ + && ok "brak DJANGO_BPP_DB_PASSWORD: exit $RC z nazwa zmiennej" || zle "brak hasla nie zatrzymal startu (rc=$RC)" + +echo +if [ "$BLEDY" -eq 0 ]; then echo "WYNIK: wszystko zgodne."; exit 0; fi +echo "WYNIK: $BLEDY niezgodnosci." +exit 1 +``` + +- [ ] **Step 2: Uruchom — ma paść (brak skryptu startowego)** + +Run: `chmod +x scripts/test-pgbouncer.sh && git add scripts/test-pgbouncer.sh && git update-index --chmod=+x scripts/test-pgbouncer.sh && ./scripts/test-pgbouncer.sh 2>&1 | tail -5` +Expected: `BLAD pgbouncer nie wstal: ...` i `WYNIK: N niezgodnosci.`, exit 1. + +- [ ] **Step 3: Napisz `pgbouncer/entrypoint.sh`** + +```sh +#!/bin/sh +# ============================================================================ +# Skrypt startowy pgbouncera BPP (zamiast entrypointu obrazu edoburu/pgbouncer) +# ============================================================================ +# +# Wywolanie (docker-compose.pgbouncer.yml): +# sh /bpp-entrypoint.sh -> render konfiguracji + exec pgbouncer +# sh /bpp-entrypoint.sh zdrowie -> sonda healthchecka (exit 0 = zdrowy) +# +# DLACZEGO WLASNY, A NIE OBRAZOWY: entrypoint obrazu wpisuje haslo do +# userlist.txt bez escapowania (`"` w hasle psuje plik) i wkleja wartosci do +# FORMATU printf (`%` psuje konfiguracje). Z obrazu bierzemy tylko binarke. +# +# Tryb `session` NA SZTYWNO: Django uzywa kursorow WITH HOLD (QuerySet.iterator +# w autocommicie), ktorych tryb transaction nie wspiera; tak samo LISTEN, SET, +# PREPARE. Tabela: https://www.pgbouncer.org/features.html +# +# Spec: docs/superpowers/specs/2026-09-27-pgbouncer-design.md +# ============================================================================ + +set -eu + +ME="bpp-pgbouncer" +KONF="/etc/pgbouncer/pgbouncer.ini" +USERS="/etc/pgbouncer/userlist.txt" +PORT_PULI=6432 + +log() { echo "$ME: $*"; } + +wymagana() { + eval "_w=\${$1:-}" + # shellcheck disable=SC2154 # ustawiana przez eval wyzej + if [ -z "$_w" ]; then + log "BLAD: brak zmiennej $1 w .env — pgbouncer nie wie, dokad sie laczyc" >&2 + exit 1 + fi +} + +# liczba_lub_domyslna NAZWA DOMYSLNA — ten sam kontrakt co +# defaults/webserver/25-render-bpp-limits.sh: puste = domyslna, smiec = +# ostrzezenie + domyslna (literowka w .env nie moze polozyc strony). +liczba_lub_domyslna() { + eval "_v=\${$1:-}" + # shellcheck disable=SC2154 + _v=$(printf '%s' "$_v" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//') + if [ -z "$_v" ]; then echo "$2"; return; fi + case "$_v" in + *[!0-9]*) + log "OSTRZEZENIE: $1='$_v' nie jest liczba nieujemna — uzywam $2" >&2 + echo "$2" ;; + *) printf '%s\n' "$_v" | sed 's/^0*//; s/^$/0/' ;; + esac +} + +# Pole userlist.txt: w cudzyslowach, kazdy `"` podwojony +# (https://www.pgbouncer.org/config.html, "Authentication file format"). +pole() { printf '"%s"' "$(printf '%s' "$1" | sed 's/"/""/g')"; } + +if [ "${1:-}" = "zdrowie" ]; then + # Osobny wpis bazy `_health` z pool_size=1: sonda nie stoi w kolejce + # za nasycona glowna pula (inaczej pgbouncer robil sie `unhealthy` + # dokladnie pod ruchem, ktory limity nginx maja obsluzyc). + PGPASSWORD="$DJANGO_BPP_DB_PASSWORD" PGCONNECT_TIMEOUT=3 \ + psql -h 127.0.0.1 -p "$PORT_PULI" -U "$DJANGO_BPP_DB_USER" \ + -d "${DJANGO_BPP_DB_NAME}_health" -Atqc 'SELECT 1' 2>/dev/null | grep -qx 1 + exit $? +fi + +wymagana DJANGO_BPP_DB_HOST +wymagana DJANGO_BPP_DB_NAME +wymagana DJANGO_BPP_DB_USER +wymagana DJANGO_BPP_DB_PASSWORD +DB_PORT="${DJANGO_BPP_DB_PORT:-5432}" + +PULA=$(liczba_lub_domyslna PGBOUNCER_POOL_SIZE 80) +KLIENCI=$(liczba_lub_domyslna PGBOUNCER_MAX_CLIENT_CONN 1000) +[ "$PULA" -ge 1 ] || { log "OSTRZEZENIE: PGBOUNCER_POOL_SIZE=0 — uzywam 80" >&2; PULA=80; } +[ "$KLIENCI" -ge 1 ] || { log "OSTRZEZENIE: PGBOUNCER_MAX_CLIENT_CONN=0 — uzywam 1000" >&2; KLIENCI=1000; } + +# --- Przyciecie puli do max_connections ------------------------------------- +# Do tego samego serwera lacza sie BEZPOSREDNIO: Celery (floor(0.75 x rdzenie) +# dzieci), denorm-queue, beat, authserver, netdata, backup, sesje admina, +# alias _health, 3 sloty superuser_reserved_connections. nproc widzi rdzenie +# hosta. Pula wieksza niz max_connections wpada w petle server_login_retry. +RDZENIE=$(nproc 2>/dev/null || echo 4) +REZERWA=$(( 20 + (3 * RDZENIE + 3) / 4 )) +[ "$REZERWA" -ge 40 ] || REZERWA=40 +if MAXC=$(PGPASSWORD="$DJANGO_BPP_DB_PASSWORD" PGCONNECT_TIMEOUT=5 \ + psql -h "$DJANGO_BPP_DB_HOST" -p "$DB_PORT" -U "$DJANGO_BPP_DB_USER" \ + -d "$DJANGO_BPP_DB_NAME" -Atqc 'SHOW max_connections' 2>/dev/null) \ + && [ -n "$MAXC" ]; then + LIMIT=$(( MAXC - REZERWA )) + [ "$LIMIT" -ge 10 ] || LIMIT=10 + if [ "$PULA" -gt "$LIMIT" ]; then + log "OSTRZEZENIE: PGBOUNCER_POOL_SIZE=$PULA przekracza max_connections=$MAXC minus rezerwa $REZERWA — przycinam do $LIMIT. Obniz BPP_NGINX_GLOBAL_CONN do <= $LIMIT albo podnies DBSERVER_MEM_LIMIT (autotune: 100 polaczen na 1 GB)." >&2 + PULA=$LIMIT + fi +else + log "OSTRZEZENIE: nie udalo sie odczytac max_connections z $DJANGO_BPP_DB_HOST:$DB_PORT — pula $PULA bez sprawdzenia" >&2 +fi + +# --- Render -------------------------------------------------------------------- +umask 077 +{ + pole "$DJANGO_BPP_DB_USER"; printf ' '; pole "$DJANGO_BPP_DB_PASSWORD"; printf '\n' +} > "$USERS" + +umask 022 +cat > "$KONF" <&1 | grep -E "OK|BLAD|WYNIK"` +Expected: same `OK`, ostatnia linia `WYNIK: wszystko zgodne.` + +Jeśli `4b` zgłasza „B na innym backendzie” — pula 1 nie dała determinizmu; sprawdź `docker exec $PGB cat /etc/pgbouncer/pgbouncer.ini` (czy `default_pool_size = 1`) zanim cokolwiek zmienisz. + +- [ ] **Step 5: Mutacje — każda musi wywrócić test (potem przywróć plik)** + +Dla każdej: zmień, uruchom `./scripts/test-pgbouncer.sh 2>&1 | grep -E "BLAD|WYNIK"`, zanotuj, `git checkout pgbouncer/entrypoint.sh`. + +| Mutacja w `pgbouncer/entrypoint.sh` | Oczekiwany BLAD | +|---|---| +| w `pole()` usuń `\| sed 's/"/""/g'` | przypadek 1–3 (logowanie) | +| `pool_mode = session` → `pool_mode = transaction` | przypadek 4 („B dostal lock A”) | +| dopisz `server_reset_query =` pod `pool_mode` | przypadek 4b („stan sesji A przeciekl”) | +| w `zdrowie` zamień `${DJANGO_BPP_DB_NAME}_health` na `${DJANGO_BPP_DB_NAME}` | przypadek 7 („sonda czerwona/zawieszona przy pelnej puli”) | + +- [ ] **Step 6: shellcheck + commit** + +Run: `docker run --rm -e LANG=C.UTF-8 -v "$PWD:/mnt" -w /mnt koalaman/shellcheck:stable pgbouncer/entrypoint.sh scripts/test-pgbouncer.sh` +Expected: brak wyjścia, exit 0. + +```bash +git add pgbouncer/entrypoint.sh scripts/test-pgbouncer.sh +git update-index --chmod=+x scripts/test-pgbouncer.sh +git commit -m "feat(pgbouncer): skrypt startowy (session, sonda _health, przyciecie puli) + test na zywo" +``` + +--- + +### Task 2: Usługa w compose + appserver przez pulę + testy statyczne + CI + +**Files:** +- Create: `docker-compose.pgbouncer.yml` +- Modify: `docker-compose.yml` (lista `include:`) +- Modify: `docker-compose.application.yml` (blok `appserver:` — `environment:` i `depends_on:`) +- Modify: `tests/test_makefile.sh` (nowy test + wpis na liście uruchamianych) +- Modify: `mk/misc.mk`, `Makefile`, `.github/workflows/ci.yml` + +**Interfaces:** +- Consumes: `sh /bpp-entrypoint.sh` i `sh /bpp-entrypoint.sh zdrowie` z Task 1. +- Produces: usługa compose `pgbouncer` na porcie 6432 w sieci projektu; `make test-pgbouncer`. + +- [ ] **Step 1: Napisz test statyczny w `tests/test_makefile.sh`** + +Wstaw przed nagłówkiem `# TEST 11c: WAF — klikalny cross-filtr` (ten sam styl co `test_nginx_global_limits_wired`): + +```bash +# ============================================================ +# TEST 11e: pgbouncer — tylko appserver przez pule, reszta bezposrednio +# ============================================================ +# Na WYRENDEROWANYM `docker compose config` (grep po zrodle nie widzi +# interpolacji). `-p` jawnie: nazwa projektu z repo-lokalnego .env (np. +# pozostalosc po init-configs z katalogiem tmp.XXXX) potrafi byc niepoprawna. + +_render_compose() { + # $1 = katalog konfiguracji z .env; wynik na stdout + (cd "$REPO_DIR" && BPP_CONFIGS_DIR="$1" docker compose -p bpp-compose-test config 2>"$1/stderr.txt") +} + +test_pgbouncer_compose() { + yellow "=== Test 11e: pgbouncer — kto laczy sie przez pule ===" + + if ! command -v docker >/dev/null 2>&1 || ! docker info >/dev/null 2>&1; then + skip_or_fail "docker niedostepny — pomijam render compose config" + return + fi + + local cfg out app + cfg=$(mktemp -d) + # Minimalny STARY .env: zadnej nowej zmiennej — tak wyglada instalacja + # po `git pull` (kontrakt backwards-compat). + printf 'DJANGO_BPP_HOST_BACKUP_DIR=%s\nDJANGO_BPP_DB_HOST=dbserver\nDJANGO_BPP_DB_PORT=5432\n' \ + "$cfg" > "$cfg/.env" + + if ! out="$(_render_compose "$cfg")"; then + fail "compose config nie wyrenderowal sie ($(tail -1 "$cfg/stderr.txt"))" + rm_rf_root "$cfg"; return + fi + + app="$(svc_block appserver <(printf '%s\n' "$out"))" + assert_svc_contains "appserver domyslnie przez pgbouncer" "DJANGO_BPP_DB_HOST: pgbouncer" "$app" + if printf '%s\n' "$app" | grep -Eq 'DJANGO_BPP_DB_PORT: "?6432"?$'; then + pass "appserver domyslnie na porcie 6432" + else + fail "appserver: brak DJANGO_BPP_DB_PORT 6432" + fi + if printf '%s\n' "$app" | grep -A1 -E '^ pgbouncer:$' | grep -q 'condition: service_healthy'; then + pass "appserver czeka na zdrowy pgbouncer (jak na dbserver)" + else + fail "appserver -> pgbouncer bez condition: service_healthy" + fi + + local s blok + for s in authserver workerserver celerybeat denorm-queue flower; do + blok="$(svc_block "$s" <(printf '%s\n' "$out"))" + if printf '%s\n' "$blok" | grep -q 'pgbouncer'; then + fail "$s laczy sie przez pgbouncer (ma isc bezposrednio)" + else + pass "$s bezposrednio do bazy" + fi + done + + local pgb + pgb="$(svc_block pgbouncer <(printf '%s\n' "$out"))" + assert_svc_contains "pgbouncer: logging local" "driver: local" "$pgb" + assert_svc_contains "pgbouncer: entrypoint przez sh" "- sh" "$pgb" + assert_svc_contains "pgbouncer: skrypt startowy" "/bpp-entrypoint.sh" "$pgb" + assert_svc_contains "pgbouncer: sonda przez podkomende" "zdrowie" "$pgb" + assert_svc_contains "pgbouncer: czeka na dbserver" "dbserver:" "$pgb" + assert_svc_contains "pgbouncer: limit pamieci 64m" "memory: \"67108864\"" "$pgb" + assert_file_contains "pgbouncer: serwisowy env_file" 'env_file: ${BPP_CONFIGS_DIR}/.env' \ + "$REPO_DIR/docker-compose.pgbouncer.yml" + + # Operator kieruje appserver bezposrednio do bazy. + printf 'BPP_APPSERVER_DB_HOST=dbserver\nBPP_APPSERVER_DB_PORT=5432\n' >> "$cfg/.env" + out="$(_render_compose "$cfg")" + app="$(svc_block appserver <(printf '%s\n' "$out"))" + assert_svc_contains "BPP_APPSERVER_DB_HOST=dbserver -> appserver bezposrednio" \ + "DJANGO_BPP_DB_HOST: dbserver" "$app" + + rm_rf_root "$cfg" +} +``` + +Dopisz wywołanie na liście na końcu pliku, po `test_nginx_global_limits_wired`: + +```bash +test_pgbouncer_compose +``` + +- [ ] **Step 2: Uruchom — ma paść** + +Run: `bash tests/test_makefile.sh &1 | grep -E "11e|pgbouncer|RESULTS"` +Expected: `FAIL: appserver domyslnie przez pgbouncer ...` (brak usługi i nadpisania). + +Uwaga dla wykonawcy: jeśli `memory: "67108864"` nie pasuje, sprawdź faktyczny format w `docker compose -p x config | grep -A3 memory` i popraw **asercję** na to, jak Compose renderuje `64m` — nie zmieniaj limitu. + +- [ ] **Step 3: Utwórz `docker-compose.pgbouncer.yml`** + +```yaml +# pgbouncer przed PostgreSQL — pula polaczen WYLACZNIE dla appservera. +# +# Po co: Django (CONN_MAX_AGE=0, ASGI) otwiera nowe polaczenie przy kazdym +# zadaniu; pomiar 2026-09-27: 18 ms connect + ~8 ms zimnego cache na ~100 ms +# mediany zadania. Pula w trybie session oddaje gotowy, rozgrzany backend. +# Spec: docs/superpowers/specs/2026-09-27-pgbouncer-design.md +# +# Laczy sie z DJANGO_BPP_DB_HOST/_PORT — dbserver ALBO baza zewnetrzna, +# ta sama konfiguracja. Reszta serwisow (Celery, denorm-queue z LISTEN, +# netdata, backup) laczy sie bezposrednio, jak dotad. + +x-logging: &default-logging + driver: "local" + options: + max-size: "${LOG_MAX_SIZE:-150m}" + max-file: "${LOG_MAX_FILE:-5}" + +services: + pgbouncer: + image: edoburu/pgbouncer:${PGBOUNCER_VERSION:-v1.25.2-p0} + restart: always + logging: *default-logging + # Serwisowy env_file jest KONIECZNY: ten z `include:` sluzy tylko + # interpolacji i nie trafia do kontenera (ta sama lekcja co dbserver). + env_file: ${BPP_CONFIGS_DIR}/.env + # Przez interpreter, nie przez bit +x (w tym repo +x juz ginal). + # Wlasny skrypt zamiast entrypointu obrazu — patrz naglowek skryptu. + entrypoint: ["sh", "/bpp-entrypoint.sh"] + volumes: + - ./pgbouncer/entrypoint.sh:/bpp-entrypoint.sh:ro + depends_on: + # W trybie bazy zewnetrznej dbserver to sentinel sondujacy prawdziwa + # baze — wiec to dziala w obu trybach. + dbserver: + condition: service_healthy + healthcheck: + # Osobny wpis bazy `_health` (pool_size=1): sonda nie stoi w kolejce za + # nasycona glowna pula. + test: ["CMD", "sh", "/bpp-entrypoint.sh", "zdrowie"] + interval: 10s + timeout: 5s + retries: 3 + start_period: 10s + deploy: + resources: + limits: + memory: ${PGBOUNCER_MEM_LIMIT:-64m} + cpus: "${PGBOUNCER_CPU_LIMIT:-0.5}" +``` + +- [ ] **Step 4: Dopisz `include:` w `docker-compose.yml`** — po wpisie `docker-compose.database.yml`/`BPP_DATABASE_COMPOSE`, a przed `docker-compose.infrastructure.yml`: + +```yaml + - path: docker-compose.pgbouncer.yml + env_file: + - ${BPP_CONFIGS_DIR}/.env +``` + +- [ ] **Step 5: appserver w `docker-compose.application.yml`** + +W bloku `appserver:` zamień: + +```yaml + environment: + DJANGO_BPP_ENABLE_PROMETHEUS: ${DJANGO_BPP_ENABLE_PROMETHEUS:-false} +``` + +na: + +```yaml + environment: + DJANGO_BPP_ENABLE_PROMETHEUS: ${DJANGO_BPP_ENABLE_PROMETHEUS:-false} + # appserver laczy sie z baza przez pgbouncer (docker-compose.pgbouncer.yml). + # `environment:` wygrywa z `env_file:`, wiec DJANGO_BPP_DB_HOST w .env + # dalej znaczy "prawdziwa baza" dla reszty serwisow i dla pgbouncera. + # BPP_APPSERVER_DB_HOST=dbserver (lub host bazy zewn.) + _PORT=5432 = + # appserver bezposrednio do bazy. + DJANGO_BPP_DB_HOST: ${BPP_APPSERVER_DB_HOST:-pgbouncer} + DJANGO_BPP_DB_PORT: ${BPP_APPSERVER_DB_PORT:-6432} +``` + +i w `depends_on:` tego bloku, po `dbserver:` + `condition: service_healthy`, dopisz: + +```yaml + # Jak do dbserver: bez puli strona nie dziala, wiec zepsuty pgbouncer + # ma zatrzymac start (i `make up --wait`), a nie przepuscic appserver. + pgbouncer: + condition: service_healthy +``` + +- [ ] **Step 6: Uruchom testy statyczne — mają przejść** + +Run: `bash tests/test_makefile.sh &1 | grep -E "11e|FAIL:|RESULTS"` +Expected: wszystkie asercje 11e `PASS`; jedyny `FAIL` dopuszczalny lokalnie na macOS to znany `invalid project name "tmp..."` z testu 11a (pozostałość w repo-lokalnym `.env`; na CI go nie ma). + +- [ ] **Step 7: make target, `make test`, CI** + +`mk/misc.mk` — do pierwszej linii `.PHONY:` dopisz ` test-pgbouncer`, a pod targetem `test-nginx-limits:` dodaj: + +```make + +# pgbouncer na zywo: prawdziwy PostgreSQL (SCRAM) + obraz edoburu/pgbouncer +# z NASZYM skryptem startowym. Sprawdza logowanie (takze haslo z metaznakami), +# izolacje i reset sesji (tryb session + DISCARD ALL), ponowne uzycie backendu, +# sonde przy nasyconej puli, walidacje i przyciecie puli do max_connections. +# Zmienne: PGB_TEST_KEEP=1 (zostaw kontenery). +test-pgbouncer: + @./scripts/test-pgbouncer.sh +``` + +`Makefile` — w recepturze `test:` po `@./scripts/test-nginx-limits.sh` dodaj linię ` @./scripts/test-pgbouncer.sh`; w `help` po linii `test-nginx-limits` dodaj: + +```make + @echo " test-pgbouncer - pgbouncer (pula dla appservera) na zywym PostgreSQL (~1 min)" +``` + +`.github/workflows/ci.yml` — w jobie `test-runtime`, po kroku `Globalny limit nginx`: + +```yaml + + # pgbouncer: logowanie, tryb session (izolacja + reset sesji), ponowne + # uzycie backendu, sonda przy pelnej puli, przyciecie do max_connections. + - name: pgbouncer + run: ./scripts/test-pgbouncer.sh +``` + +- [ ] **Step 8: Commit** + +```bash +git add docker-compose.pgbouncer.yml docker-compose.yml docker-compose.application.yml \ + tests/test_makefile.sh mk/misc.mk Makefile .github/workflows/ci.yml +git commit -m "feat(pgbouncer): usluga w compose, appserver przez pule (service_healthy), testy statyczne + CI" +``` + +--- + +### Task 3: `make configure-resources` zna pgbouncera + +**Files:** +- Modify: `scripts/configure-resources.sh` (`FIXED_MEM`, `var_prefix_for`) +- Modify: `tests/test_makefile.sh` (`test_configure_resources`) + +**Interfaces:** +- Produces: `PGBOUNCER_MEM_LIMIT=64m` w `.env` po `make configure-resources`. + +- [ ] **Step 1: Dopisz asercję w `test_configure_resources`**, po linii z `dozzle cap 64m`: + +```bash + assert_file_contains "pgbouncer cap 64m" "PGBOUNCER_MEM_LIMIT=64m" "$cfg/.env" + assert_file_not_contains "brak UNKNOWN_*" "UNKNOWN_MEM_LIMIT=" "$cfg/.env" +``` + +- [ ] **Step 2: Uruchom — ma paść** + +Run: `bash tests/test_makefile.sh &1 | grep -E "pgbouncer cap|UNKNOWN|RESULTS"` +Expected: `FAIL: pgbouncer cap 64m`. + +- [ ] **Step 3: Implementacja** — w `scripts/configure-resources.sh`, w `FIXED_MEM` po `"autoheal:32"` dopisz `"pgbouncer:64"`; w `var_prefix_for` po linii `autoheal)` dopisz: + +```bash + pgbouncer) echo "PGBOUNCER" ;; +``` + +- [ ] **Step 4: Uruchom — ma przejść** + +Run: `bash tests/test_makefile.sh &1 | grep -E "pgbouncer cap|UNKNOWN|RESULTS"` +Expected: `PASS: pgbouncer cap 64m`, `PASS: brak UNKNOWN_*`. + +- [ ] **Step 5: Commit** + +```bash +git add scripts/configure-resources.sh tests/test_makefile.sh +git commit -m "feat(pgbouncer): configure-resources przydziela PGBOUNCER_MEM_LIMIT" +``` + +--- + +### Task 4: Dokumentacja + +Użyj skilla `docs-sync` przed edycją. + +**Files:** +- Create: `docs/konfiguracja/pgbouncer.md` +- Modify: `mkdocs.yml` (nav, po `PostgreSQL — wersje i upgrade`) +- Modify: `docs/architektura/uslugi.md` (tabela usług, sekcja „Zależności startu”) +- Modify: `docs/konfiguracja/limity-zasobow.md` +- Modify: `docs/eksploatacja/komendy.md` (sekcja „Testy”) +- Modify: `docs/konfiguracja/architektura.md` (drzewo plików compose) +- Modify: `CLAUDE.md` (drzewo compose + tripwire'y) + +- [ ] **Step 1: `docs/konfiguracja/pgbouncer.md`** + +````markdown +# pgbouncer — pula połączeń dla appservera + +Appserver łączy się z PostgreSQL przez **pgbouncer** w trybie `session`. +Włączone domyślnie, bez żadnej zmiany w `.env`. + +## Po co + +Django w appserverze otwiera nowe połączenie z bazą przy **każdym** żądaniu +i zamyka je na końcu. Pomiar na publikacje.up.lublin.pl (27.09.2026): samo +połączenie kosztowało **18 ms**, a pierwsze zapytanie na świeżym połączeniu +kolejne ~8 ms (zimny cache katalogu) — przy medianie całego żądania 100 ms. + +pgbouncer trzyma gotowe połączenia z bazą i oddaje je kolejnym żądaniom. Znika +tworzenie procesu PostgreSQL i zimny cache; zostaje tańsze logowanie do samego +pgbouncera (SCRAM). + +To **nie** jest ochrona przed zalewem ruchu — tę dają +[limity nginx i appservera](../architektura/rate-limiting.md#limit-globalny). + +## Co idzie przez pulę + +| Serwis | Połączenie z bazą | +|---|---| +| appserver | **przez pgbouncer** | +| authserver, workerserver, celerybeat, denorm-queue, flower | bezpośrednio | +| netdata (`bpp_monitor`) | bezpośrednio — ma widzieć prawdziwy serwer | +| backup / restore | `docker exec` w `dbserver` — bez zmian | + +pgbouncer łączy się z `DJANGO_BPP_DB_HOST`/`DJANGO_BPP_DB_PORT` — czyli +z `dbserver` albo z [bazą zewnętrzną](postgresql.md). Ta sama konfiguracja +w obu trybach. Logowanie tym samym kontem co dotąd +(`DJANGO_BPP_DB_USER`/`_PASSWORD`). + +## Zmienne (`$BPP_CONFIGS_DIR/.env`) + +| Zmienna | Domyślnie | Znaczenie | +|---|---|---| +| `BPP_APPSERVER_DB_HOST` | `pgbouncer` | dokąd łączy się appserver | +| `BPP_APPSERVER_DB_PORT` | `6432` | port dla powyższego | +| `PGBOUNCER_POOL_SIZE` | `80` | maks. połączeń pgbouncera do bazy | +| `PGBOUNCER_MAX_CLIENT_CONN` | `1000` | maks. połączeń appservera do pgbouncera | +| `PGBOUNCER_VERSION` | `v1.25.2-p0` | tag obrazu `edoburu/pgbouncer` | +| `PGBOUNCER_MEM_LIMIT` / `_CPU_LIMIT` | `64m` / `0.5` | [limity zasobów](limity-zasobow.md) | + +Pusta wartość = domyślna; błędna liczba = `OSTRZEZENIE` w logu i domyślna. + +Appserver bezpośrednio do bazy (z pominięciem puli): + +``` +BPP_APPSERVER_DB_HOST=dbserver # albo host bazy zewnętrznej +BPP_APPSERVER_DB_PORT=5432 +``` + +potem `make up`. Usługa `pgbouncer` dalej działa i dalej musi być zdrowa. + +## Pula a `max_connections` + +Do tego samego serwera łączą się też bezpośrednio Celery, denorm-queue +i reszta. Przy starcie pgbouncer odczytuje `max_connections` i — jeśli pula +się nie mieści — **przycina ją** z ostrzeżeniem w logu: + +``` +bpp-pgbouncer: OSTRZEZENIE: PGBOUNCER_POOL_SIZE=80 przekracza max_connections=100 minus rezerwa 40 — przycinam do 60. Obniz BPP_NGINX_GLOBAL_CONN do <= 60 albo podnies DBSERVER_MEM_LIMIT (autotune: 100 polaczen na 1 GB). +``` + +## Diagnostyka + +```bash +docker compose logs pgbouncer | grep bpp-pgbouncer # konfiguracja przy starcie +docker compose logs pgbouncer | grep stats # co 60 s; pole "wait" > 0 = kolejka +docker compose exec pgbouncer sh -c 'PGPASSWORD="$DJANGO_BPP_DB_PASSWORD" psql -h 127.0.0.1 -p 6432 -U "$DJANGO_BPP_DB_USER" pgbouncer -c "SHOW POOLS"' +``` + +W `SHOW POOLS` kolumna `cl_waiting` > 0 znaczy, że appserver czeka na wolne +połączenie z bazą (po 15 s dostaje błąd). + +## Awarie + +pgbouncer jest infrastrukturą jak `dbserver`: gdy jest `unhealthy`, appserver +nie startuje, a `make up` kończy się błędem. Celery, denorm i panele działają +dalej. +```` + +- [ ] **Step 2: nav w `mkdocs.yml`** — po linii ` - PostgreSQL — wersje i upgrade: konfiguracja/postgresql.md` dodaj: + +```yaml + - pgbouncer (pula połączeń): konfiguracja/pgbouncer.md +``` + +- [ ] **Step 3: `docs/architektura/uslugi.md`** — w tabeli usług po wierszu `| **dbserver** | ...` dodaj: + +```markdown +| **pgbouncer** | Pula połączeń PostgreSQL (tryb `session`) — **wyłącznie** dla appservera; reszta łączy się bezpośrednio ([pgbouncer](../konfiguracja/pgbouncer.md)) | +``` + +W sekcji `## Zależności startu` dopisz punkt: `pgbouncer` czeka na zdrowy `dbserver`; `appserver` czeka na zdrowy `pgbouncer`. (Dopasuj do formatu tej sekcji — przeczytaj ją najpierw.) + +- [ ] **Step 4: `docs/konfiguracja/limity-zasobow.md`** — po akapicie o `GUNICORN_LIMIT_CONCURRENCY` dodaj: + +```markdown +### pgbouncer — `PGBOUNCER_*` + +`PGBOUNCER_MEM_LIMIT` (64 MB) i `_CPU_LIMIT` (0,5) — `make configure-resources` +wpisuje stały cap. `PGBOUNCER_POOL_SIZE` (80) to sufit połączeń puli do bazy; +musi się zmieścić w `max_connections` razem z połączeniami bezpośrednimi. +Łańcuch zależności: `DBSERVER_MEM_LIMIT` → autotune `max_connections` +(100 na 1 GB, maks. 250) → pula przycinana przy starcie do +`max_connections − max(40, 20 + 0,75 × rdzenie)` +([pgbouncer](pgbouncer.md#pula-a-max_connections)). +``` + +- [ ] **Step 5: `docs/eksploatacja/komendy.md`** — w bloku „Testy” po linii `make test-nginx-limits ...` dodaj: + +``` +make test-pgbouncer # Czy pula pgbouncera działa (session, reset sesji, przycięcie puli) +``` + +oraz dopisz `test-pgbouncer` do zdania „…nie wymagają `.env`…” i do listy `./scripts/…` w notce „Na maszynie bez repo-owego `.env`”. + +- [ ] **Step 6: drzewo compose** w `docs/konfiguracja/architektura.md` i `CLAUDE.md` — pod linią `docker-compose.database.yml` / `database.external.yml` dodaj: + +``` +├── docker-compose.pgbouncer.yml # pgbouncer (pula dla appservera) +``` + +- [ ] **Step 7: tripwire w `CLAUDE.md`** — nowa sekcja po `### Service dependencies`: + +```markdown +### pgbouncer — only appserver, only `session` + +`appserver` reaches PostgreSQL through `pgbouncer` (`docker-compose.pgbouncer.yml`); every other service connects directly. `DJANGO_BPP_DB_HOST`/`_PORT` keep meaning "the real DB" — the appserver override is `BPP_APPSERVER_DB_HOST`/`_PORT` in its `environment:`. **Anti-fixes — do NOT:** (1) switch to `pool_mode = transaction` — Django's `QuerySet.iterator()` uses `WITH HOLD` cursors, and `LISTEN`/`SET`/`PREPARE` break too (https://www.pgbouncer.org/features.html); (2) repoint `DJANGO_BPP_DB_HOST` at the pool or route `denorm-queue` (`LISTEN`, all-day connection) through it; (3) go back to the image's own entrypoint — it writes the password into `userlist.txt` unescaped and pastes values into a `printf` format, so `"` or `%` in the password breaks login; (4) point the healthcheck at the main pool — it would queue behind a saturated pool and flip `unhealthy` exactly under load, hence the `_health` alias with `pool_size=1`; (5) add an off-switch (`scale`, lying probe, `service_started`) — pgbouncer is infrastructure like `dbserver`, `appserver` waits on it `service_healthy` and an unhealthy one must fail `make up`. Pool is clamped at start to `max_connections − max(40, 20 + 0.75×nproc)`. Tests: `make test-pgbouncer` (live, mutation-checked) + `test_pgbouncer_compose`. Operator doc: `docs/konfiguracja/pgbouncer.md`. +``` + +- [ ] **Step 8: `mkdocs build --strict`** + +Run: `uvx --with-requirements docs/requirements.txt mkdocs build --strict -d "$TMPDIR/site" 2>&1 | tail -3` +Expected: `Documentation built in …`, bez `WARNING`/`ERROR`. + +- [ ] **Step 9: Commit** + +```bash +git add docs/konfiguracja/pgbouncer.md mkdocs.yml docs/architektura/uslugi.md \ + docs/konfiguracja/limity-zasobow.md docs/eksploatacja/komendy.md \ + docs/konfiguracja/architektura.md CLAUDE.md +git commit -m "docs(pgbouncer): strona operatora, uslugi, limity, komendy, tripwire'y w CLAUDE.md" +``` + +--- + +### Task 5: Weryfikacja całości + +**Files:** brak zmian (chyba że coś padnie — wtedy poprawka w pliku, którego dotyczy, i osobny commit). + +- [ ] **Step 1: Wszystkie testy lokalnie** + +```bash +./scripts/test-pgbouncer.sh 2>&1 | tail -1 +./scripts/test-nginx-limits.sh 2>&1 | tail -1 +./scripts/test-waf.sh 2>&1 | tail -1 +bash tests/test_makefile.sh &1 | grep -E "FAIL:|RESULTS" +docker run --rm -e LANG=C.UTF-8 -v "$PWD:/mnt" -w /mnt koalaman/shellcheck:stable \ + pgbouncer/entrypoint.sh scripts/test-pgbouncer.sh tests/test_makefile.sh +``` + +Expected: trzy `WYNIK: wszystko zgodne.` / `Wszystkie … zgodnie z oczekiwaniem.`; `RESULTS` bez nowych `FAIL` (dopuszczalny tylko znany lokalny `invalid project name "tmp..."` z testu 11a); shellcheck czysty. + +- [ ] **Step 2: Render compose z prawdziwym układem** — sprawdź, że `make up` na świeżym katalogu się nie wywróci na interpolacji: + +Run: `d=$(mktemp -d); printf 'DJANGO_BPP_HOST_BACKUP_DIR=%s\n' "$d" > "$d/.env"; BPP_CONFIGS_DIR=$d docker compose -p bpp-check config --services | sort | grep -x pgbouncer; rm -rf "$d"` +Expected: `pgbouncer`. + +- [ ] **Step 3: Push brancha i CI** + +```bash +git push -u origin feat/pgbouncer +gh pr create --base main --title "feat: pgbouncer (session) przed PostgreSQL dla appservera" --body-file <(printf '%s\n' "Spec: docs/superpowers/specs/2026-09-27-pgbouncer-design.md" "Plan: docs/superpowers/plans/2026-09-27-pgbouncer.md" "" "🤖 Generated with [Claude Code](https://claude.com/claude-code)") +gh pr checks --watch +``` + +Expected: wszystkie joby `pass`. NIE merguj bez zgody użytkownika. + +- [ ] **Step 4: Pomiar na produkcji — POZA tym planem** + +Wdrożenie na publikacje.up.lublin.pl i pomiar przed/po (kryterium: `connect` + pierwsze zapytanie ≥ 3× szybciej niż 27,7 ms, mediana `request_time` nie rośnie) wykonuje się dopiero po merge'u i na wyraźne polecenie użytkownika. Przed wdrożeniem posprzątać zdublowane `DJANGO_BPP_DB_*` w `.env` tamtej instalacji. From 032967b16ccfba3f1bc13538706e80e6b4aeaff3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Sun, 27 Sep 2026 23:35:07 +0200 Subject: [PATCH 5/9] feat(pgbouncer): skrypt startowy (session, sonda _health, przyciecie puli) + test na zywo Co-Authored-By: Claude Opus 5.5 --- pgbouncer/entrypoint.sh | 135 +++++++++++++++++++++ scripts/test-pgbouncer.sh | 247 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 382 insertions(+) create mode 100644 pgbouncer/entrypoint.sh create mode 100755 scripts/test-pgbouncer.sh diff --git a/pgbouncer/entrypoint.sh b/pgbouncer/entrypoint.sh new file mode 100644 index 0000000..8e5a2b2 --- /dev/null +++ b/pgbouncer/entrypoint.sh @@ -0,0 +1,135 @@ +#!/bin/sh +# ============================================================================ +# Skrypt startowy pgbouncera BPP (zamiast entrypointu obrazu edoburu/pgbouncer) +# ============================================================================ +# +# Wywolanie (docker-compose.pgbouncer.yml): +# sh /bpp-entrypoint.sh -> render konfiguracji + exec pgbouncer +# sh /bpp-entrypoint.sh zdrowie -> sonda healthchecka (exit 0 = zdrowy) +# +# DLACZEGO WLASNY, A NIE OBRAZOWY: entrypoint obrazu wpisuje haslo do +# userlist.txt bez escapowania (`"` w hasle psuje plik) i wkleja wartosci do +# FORMATU printf (`%` psuje konfiguracje). Z obrazu bierzemy tylko binarke. +# +# Tryb `session` NA SZTYWNO: Django uzywa kursorow WITH HOLD (QuerySet.iterator +# w autocommicie), ktorych tryb transaction nie wspiera; tak samo LISTEN, SET, +# PREPARE. Tabela: https://www.pgbouncer.org/features.html +# +# Spec: docs/superpowers/specs/2026-09-27-pgbouncer-design.md +# ============================================================================ + +set -eu + +ME="bpp-pgbouncer" +KONF="/etc/pgbouncer/pgbouncer.ini" +USERS="/etc/pgbouncer/userlist.txt" +PORT_PULI=6432 + +log() { echo "$ME: $*"; } + +wymagana() { + eval "_w=\${$1:-}" + # shellcheck disable=SC2154 # ustawiana przez eval wyzej + if [ -z "$_w" ]; then + log "BLAD: brak zmiennej $1 w .env — pgbouncer nie wie, dokad sie laczyc" >&2 + exit 1 + fi +} + +# liczba_lub_domyslna NAZWA DOMYSLNA — ten sam kontrakt co +# defaults/webserver/25-render-bpp-limits.sh: puste = domyslna, smiec = +# ostrzezenie + domyslna (literowka w .env nie moze polozyc strony). +liczba_lub_domyslna() { + eval "_v=\${$1:-}" + # shellcheck disable=SC2154 + _v=$(printf '%s' "$_v" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//') + if [ -z "$_v" ]; then echo "$2"; return; fi + case "$_v" in + *[!0-9]*) + log "OSTRZEZENIE: $1='$_v' nie jest liczba nieujemna — uzywam $2" >&2 + echo "$2" ;; + *) printf '%s\n' "$_v" | sed 's/^0*//; s/^$/0/' ;; + esac +} + +# Pole userlist.txt: w cudzyslowach, kazdy `"` podwojony +# (https://www.pgbouncer.org/config.html, "Authentication file format"). +pole() { printf '"%s"' "$(printf '%s' "$1" | sed 's/"/""/g')"; } + +if [ "${1:-}" = "zdrowie" ]; then + # Osobny wpis bazy `_health` z pool_size=1: sonda nie stoi w kolejce + # za nasycona glowna pula (inaczej pgbouncer robil sie `unhealthy` + # dokladnie pod ruchem, ktory limity nginx maja obsluzyc). + PGPASSWORD="$DJANGO_BPP_DB_PASSWORD" PGCONNECT_TIMEOUT=3 \ + psql -h 127.0.0.1 -p "$PORT_PULI" -U "$DJANGO_BPP_DB_USER" \ + -d "${DJANGO_BPP_DB_NAME}_health" -Atqc 'SELECT 1' 2>/dev/null | grep -qx 1 + exit $? +fi + +wymagana DJANGO_BPP_DB_HOST +wymagana DJANGO_BPP_DB_NAME +wymagana DJANGO_BPP_DB_USER +wymagana DJANGO_BPP_DB_PASSWORD +DB_PORT="${DJANGO_BPP_DB_PORT:-5432}" + +PULA=$(liczba_lub_domyslna PGBOUNCER_POOL_SIZE 80) +KLIENCI=$(liczba_lub_domyslna PGBOUNCER_MAX_CLIENT_CONN 1000) +[ "$PULA" -ge 1 ] || { log "OSTRZEZENIE: PGBOUNCER_POOL_SIZE=0 — uzywam 80" >&2; PULA=80; } +[ "$KLIENCI" -ge 1 ] || { log "OSTRZEZENIE: PGBOUNCER_MAX_CLIENT_CONN=0 — uzywam 1000" >&2; KLIENCI=1000; } + +# --- Przyciecie puli do max_connections ------------------------------------- +# Do tego samego serwera lacza sie BEZPOSREDNIO: Celery (floor(0.75 x rdzenie) +# dzieci), denorm-queue, beat, authserver, netdata, backup, sesje admina, +# alias _health, 3 sloty superuser_reserved_connections. nproc widzi rdzenie +# hosta. Pula wieksza niz max_connections wpada w petle server_login_retry. +RDZENIE=$(nproc 2>/dev/null || echo 4) +REZERWA=$(( 20 + (3 * RDZENIE + 3) / 4 )) +[ "$REZERWA" -ge 40 ] || REZERWA=40 +if MAXC=$(PGPASSWORD="$DJANGO_BPP_DB_PASSWORD" PGCONNECT_TIMEOUT=5 \ + psql -h "$DJANGO_BPP_DB_HOST" -p "$DB_PORT" -U "$DJANGO_BPP_DB_USER" \ + -d "$DJANGO_BPP_DB_NAME" -Atqc 'SHOW max_connections' 2>/dev/null) \ + && [ -n "$MAXC" ]; then + LIMIT=$(( MAXC - REZERWA )) + [ "$LIMIT" -ge 10 ] || LIMIT=10 + if [ "$PULA" -gt "$LIMIT" ]; then + log "OSTRZEZENIE: PGBOUNCER_POOL_SIZE=$PULA przekracza max_connections=$MAXC minus rezerwa $REZERWA — przycinam do $LIMIT. Obniz BPP_NGINX_GLOBAL_CONN do <= $LIMIT albo podnies DBSERVER_MEM_LIMIT (autotune: 100 polaczen na 1 GB)." >&2 + PULA=$LIMIT + fi +else + log "OSTRZEZENIE: nie udalo sie odczytac max_connections z $DJANGO_BPP_DB_HOST:$DB_PORT — pula $PULA bez sprawdzenia" >&2 +fi + +# --- Render -------------------------------------------------------------------- +# rm -f: obraz ma juz userlist.txt (0644), a przekierowanie do ISTNIEJACEGO +# pliku zachowuje jego tryb — umask dziala tylko przy tworzeniu. +rm -f "$USERS" +umask 077 +{ + pole "$DJANGO_BPP_DB_USER"; printf ' '; pole "$DJANGO_BPP_DB_PASSWORD"; printf '\n' +} > "$USERS" + +umask 022 +cat > "$KONF" </dev/null 2>&1 + docker network rm "$NET" >/dev/null 2>&1 +} +trap czysc EXIT + +docker info >/dev/null 2>&1 || { echo "BLAD: docker niedostepny."; exit 1; } + +# start_db MAX_CONNECTIONS [ALIAS] — PostgreSQL pod aliasem sieciowym. +# Alias inny niz "dbserver" celowo: pgbouncer ma dzialac z dowolnym hostem +# (tryb bazy zewnetrznej). +start_db() { + docker rm -f "$PG" >/dev/null 2>&1 + docker run -d --name "$PG" --network "$NET" --network-alias "${2:-baza-zewnetrzna}" \ + -e POSTGRES_DB="$DB" -e POSTGRES_USER="$USR" -e POSTGRES_PASSWORD="$HASLO" \ + "$PG_IMAGE" -c "max_connections=$1" >/dev/null || return 1 + for _ in $(seq 1 30); do + docker exec "$PG" pg_isready -U "$USR" -d "$DB" -h 127.0.0.1 >/dev/null 2>&1 && return 0 + sleep 1 + done + return 1 +} + +# start_pgb [-e VAR=wartosc ...] — pgbouncer z naszym skryptem, czeka na port. +start_pgb() { + docker rm -f "$PGB" >/dev/null 2>&1 + docker run -d --name "$PGB" --network "$NET" --network-alias pgbouncer \ + -e DJANGO_BPP_DB_HOST=baza-zewnetrzna -e DJANGO_BPP_DB_PORT=5432 \ + -e DJANGO_BPP_DB_NAME="$DB" -e DJANGO_BPP_DB_USER="$USR" \ + -e DJANGO_BPP_DB_PASSWORD="$HASLO" \ + "$@" \ + -v "$REPO_DIR/pgbouncer/entrypoint.sh:/bpp-entrypoint.sh:ro" \ + --entrypoint sh "$PGB_IMAGE" /bpp-entrypoint.sh >/dev/null || return 1 + for _ in $(seq 1 20); do + docker exec "$PGB" nc -z 127.0.0.1 6432 >/dev/null 2>&1 && return 0 + [ "$(docker inspect -f '{{.State.Running}}' "$PGB" 2>/dev/null)" = true ] || return 1 + sleep 0.5 + done + return 1 +} + +# q SQL — jedno polaczenie klienta przez pule, wynik bez naglowkow. +q() { + docker exec -e PGPASSWORD="$HASLO" "$CLI" \ + psql -h pgbouncer -p 6432 -U "$USR" -d "$DB" -Atq -v ON_ERROR_STOP=1 -c "$1" 2>&1 +} + +# trzymaj SEKUNDY SQL... — klient w tle: wykonuje SQL, potem trzyma polaczenie +# BEZCZYNNIE (`\! sleep`, bez zapytania) przez SEKUNDY sekund. Bezczynnosc jest +# istotna: w trybie transaction pgbouncer oddaje wtedy backend do puli, wiec +# nastepny klient trafia na TEN SAM backend (LIFO) — i mutacja jest wykryta +# deterministycznie. `SELECT pg_sleep()` zajmowalby backend w obu trybach. +trzymaj() { + local s="$1"; shift + local args=() + for sql in "$@"; do args+=(-c "$sql"); done + docker exec -d -e PGPASSWORD="$HASLO" "$CLI" \ + psql -h pgbouncer -p 6432 -U "$USR" -d "$DB" -Atq "${args[@]}" -c "\\! sleep $s" +} + +ini() { docker exec "$PGB" cat /etc/pgbouncer/pgbouncer.ini; } +log_pgb() { docker logs "$PGB" 2>&1; } + +echo "== przygotowanie ==" +docker network create "$NET" >/dev/null +docker run -d --name "$CLI" --network "$NET" --entrypoint sleep "$PG_IMAGE" infinity >/dev/null +start_db 300 || { echo "BLAD: PostgreSQL nie wstal."; exit 1; } + +echo "== 1-3. logowanie, haslo z metaznakami, LISTEN ==" +if start_pgb; then + [ "$(q 'SELECT 1')" = "1" ] && ok "logowanie przez pule tym samym kontem (haslo z \" % \$ \\ spacja)" \ + || zle "logowanie przez pule: $(q 'SELECT 1')" + [ "$(q 'LISTEN kanal; SELECT 1')" = "1" ] && ok "LISTEN przez pule" || zle "LISTEN: $(q 'LISTEN kanal; SELECT 1')" + LOG="$(log_pgb)" + # pgbouncer loguje przy starcie (poziom LOG, nie ostrzezenie): + # kernel file descriptor limit: N (hard: H); max_client_conn: C, max expected fd use: M + # Realny problem to N < M — pod pelnym obciazeniem zabraklo by deskryptorow. + FD="$(grep -o 'kernel file descriptor limit: [0-9]*\|max expected fd use: [0-9]*' <<<"$LOG" | grep -o '[0-9]*$' | tr '\n' ' ')" + read -r FD_LIMIT FD_UZYCIE <<<"$FD" + if [ -n "${FD_UZYCIE:-}" ] && [ "$FD_LIMIT" -ge "$FD_UZYCIE" ]; then + ok "limit deskryptorow $FD_LIMIT >= przewidywane zuzycie $FD_UZYCIE (max_client_conn=1000)" + else + zle "limit deskryptorow za niski lub brak linii w logu: '${FD}'" + fi + G="$(ini)" + grep -q "^pool_mode = session$" <<<"$G" && ok "pool_mode = session" || zle "brak pool_mode = session" + grep -q "^default_pool_size = 80$" <<<"$G" && ok "pula domyslna 80" || zle "pula: $(grep pool_size <<<"$G")" + grep -q "^max_client_conn = 1000$" <<<"$G" && ok "max_client_conn 1000" || zle "brak max_client_conn = 1000" + [ "$(docker exec "$PGB" stat -c %a /etc/pgbouncer/userlist.txt)" = "600" ] && ok "userlist.txt 0600" \ + || zle "userlist.txt ma tryb $(docker exec "$PGB" stat -c %a /etc/pgbouncer/userlist.txt)" +else + zle "pgbouncer nie wstal: $(log_pgb | tail -3)" +fi + +echo "== 5. ponowne uzycie backendu (zrodlo przyspieszenia) ==" +P1="$(q 'SELECT pg_backend_pid()')"; P2="$(q 'SELECT pg_backend_pid()')" +[ -n "$P1" ] && [ "$P1" = "$P2" ] && ok "dwa kolejne polaczenia klienta -> ten sam backend ($P1)" \ + || zle "rozne backendy: '$P1' vs '$P2'" + +echo "== 6. psql --single-transaction -f (jak baseline_load) ==" +W6="$(docker exec -i -e PGPASSWORD="$HASLO" "$CLI" psql -h pgbouncer -p 6432 -U "$USR" -d "$DB" \ + -Atq -v ON_ERROR_STOP=1 --single-transaction -f - 2>&1 <<'SQL' +SET client_min_messages = warning; +CREATE TABLE baseline_test (id int, nazwa text); +COPY baseline_test (id, nazwa) FROM stdin; +1 pierwszy +2 drugi +\. +SELECT count(*) FROM baseline_test; +SQL +)" +[ "$W6" = "2" ] && ok "SET/CREATE/COPY w jednej transakcji przez pule" || zle "baseline: $W6" + +echo "== 4. izolacja klientow (lock trzymany przez A) ==" +trzymaj 4 "SELECT pg_advisory_lock(42)" +sleep 1 +[ "$(q 'SELECT pg_try_advisory_lock(42)')" = "f" ] && ok "B nie dostaje locka trzymanego przez A" \ + || zle "B dostal lock A — klienci dziela backend (tryb transaction?)" +sleep 4 + +echo "== 7. sonda zdrowia przy nasyconej glownej puli ==" +if start_pgb -e PGBOUNCER_POOL_SIZE=2; then + docker exec "$PGB" sh /bpp-entrypoint.sh zdrowie >/dev/null 2>&1 && ok "sonda zielona (pusta pula)" \ + || zle "sonda czerwona przy pustej puli" + # w trybie session bezczynny klient i tak trzyma backend -> pula 2 pelna + trzymaj 8 "SELECT 1"; trzymaj 8 "SELECT 1" + sleep 1 + if timeout 5 docker exec "$PGB" sh /bpp-entrypoint.sh zdrowie >/dev/null 2>&1; then + ok "sonda zielona przy pelnej glownej puli (osobny wpis _health)" + else + zle "sonda czerwona/zawieszona przy pelnej puli — idzie przez glowna pule" + fi + sleep 8 +else + zle "pgbouncer nie wstal (pula 2)" +fi + +echo "== 4b. reset sesji miedzy klientami (pula 1 = deterministycznie) ==" +if start_pgb -e PGBOUNCER_POOL_SIZE=1; then + PA="$(q "SELECT pg_advisory_lock(42); SET bpp.test = 'zostalo'; SELECT pg_backend_pid()" | tail -1)" + WB="$(q "SELECT pg_backend_pid() || '|' || pg_try_advisory_lock(42) || '|' || coalesce(current_setting('bpp.test', true), '')")" + PB="${WB%%|*}" + [ "$PA" = "$PB" ] && ok "B trafil na backend A ($PA) — wynik deterministyczny" \ + || zle "B na innym backendzie ('$PA' vs '$PB') — test nie rozstrzyga" + # bool w konkatenacji tekstowej to 'true'/'false', nie 't'/'f' + [ "$WB" = "$PA|true|" ] && ok "po rozlaczeniu A: lock zwolniony, SET niewidoczny (DISCARD ALL)" \ + || zle "stan sesji A przeciekl do B: '$WB'" +else + zle "pgbouncer nie wstal (pula 1)" +fi + +echo "== 7b. sonda czerwona przy zlym hasle ==" +if start_pgb -e DJANGO_BPP_DB_PASSWORD=zle-haslo; then + timeout 8 docker exec "$PGB" sh /bpp-entrypoint.sh zdrowie >/dev/null 2>&1 \ + && zle "sonda zielona mimo zlego hasla" || ok "sonda czerwona przy zlym hasle" +else + zle "pgbouncer nie wstal przy zlym hasle (ma wstac, a sonda ma byc czerwona)" +fi + +echo "== 8. smiec w zmiennych nie kladzie pgbouncera ==" +if start_pgb -e PGBOUNCER_POOL_SIZE=abc -e PGBOUNCER_MAX_CLIENT_CONN="10 0"; then + G="$(ini)"; LOG="$(log_pgb)" + grep -q "^default_pool_size = 80$" <<<"$G" && grep -q "^max_client_conn = 1000$" <<<"$G" \ + && ok "bledne wartosci zastapione domyslnymi" || zle "nie wrocono do domyslnych: $(grep -E 'pool_size|client_conn' <<<"$G")" + grep -q "OSTRZEZENIE: PGBOUNCER_POOL_SIZE='abc'" <<<"$LOG" && ok "ostrzezenie w logu" \ + || zle "brak ostrzezenia o PGBOUNCER_POOL_SIZE" +else + zle "pgbouncer nie wstal na blednych wartosciach" +fi + +echo "== 9. przyciecie puli do max_connections ==" +start_db 50 || zle "PostgreSQL (max_connections=50) nie wstal" +if start_pgb; then + G="$(ini)"; LOG="$(log_pgb)" + grep -q "^default_pool_size = 10$" <<<"$G" && grep -q "^max_db_connections = 10$" <<<"$G" \ + && ok "pula 80 przycieta do 10 przy max_connections=50" || zle "brak przyciecia: $(grep pool_size <<<"$G")" + grep -q "OSTRZEZENIE: .*max_connections" <<<"$LOG" && grep -q "BPP_NGINX_GLOBAL_CONN" <<<"$LOG" \ + && ok "ostrzezenie z instrukcja dla operatora" || zle "brak ostrzezenia o przycieciu" +else + zle "pgbouncer nie wstal przy max_connections=50" +fi + +echo "== 12. baza niedostepna przy starcie — start nie wisi i nie pada ==" +docker stop "$PG" >/dev/null +T0=$(date +%s) +if start_pgb; then + T=$(( $(date +%s) - T0 )) + [ "$T" -le 12 ] && ok "pgbouncer wstal w ${T}s mimo niedostepnej bazy" || zle "start trwal ${T}s" + grep -q "OSTRZEZENIE: .*max_connections" <<<"$(log_pgb)" && ok "ostrzezenie o nieudanej sondzie" \ + || zle "brak ostrzezenia o nieudanej sondzie max_connections" + grep -q "^default_pool_size = 80$" <<<"$(ini)" && ok "bez sondy: pula bez przyciecia" || zle "pula zmieniona bez sondy" +else + zle "pgbouncer nie wstal przy niedostepnej bazie" +fi + +echo "== 11. brak wymaganej zmiennej = glosny blad ==" +docker rm -f "$PGB" >/dev/null 2>&1 +docker run --name "$PGB" --network "$NET" \ + -e DJANGO_BPP_DB_HOST=baza-zewnetrzna -e DJANGO_BPP_DB_NAME="$DB" -e DJANGO_BPP_DB_USER="$USR" \ + -v "$REPO_DIR/pgbouncer/entrypoint.sh:/bpp-entrypoint.sh:ro" \ + --entrypoint sh "$PGB_IMAGE" /bpp-entrypoint.sh >/dev/null 2>&1 +RC=$? +[ "$RC" -ne 0 ] && grep -q "DJANGO_BPP_DB_PASSWORD" <<<"$(log_pgb)" \ + && ok "brak DJANGO_BPP_DB_PASSWORD: exit $RC z nazwa zmiennej" || zle "brak hasla nie zatrzymal startu (rc=$RC)" + +echo +if [ "$BLEDY" -eq 0 ]; then echo "WYNIK: wszystko zgodne."; exit 0; fi +echo "WYNIK: $BLEDY niezgodnosci." +exit 1 From 9e058585f05e2372a680d231b0a1b8b8d25e324b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Sun, 27 Sep 2026 23:38:42 +0200 Subject: [PATCH 6/9] feat(pgbouncer): usluga w compose, appserver przez pule (service_healthy), testy statyczne + CI Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 5 +++ Makefile | 2 + docker-compose.application.yml | 11 +++++ docker-compose.pgbouncer.yml | 48 ++++++++++++++++++++ docker-compose.yml | 3 ++ mk/misc.mk | 10 ++++- tests/test_makefile.sh | 80 ++++++++++++++++++++++++++++++++++ 7 files changed, 158 insertions(+), 1 deletion(-) create mode 100644 docker-compose.pgbouncer.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 96a780e..d29b454 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -99,6 +99,11 @@ jobs: - name: Globalny limit nginx run: ./scripts/test-nginx-limits.sh + # pgbouncer: logowanie, tryb session (izolacja + reset sesji), ponowne + # uzycie backendu, sonda przy pelnej puli, przyciecie do max_connections. + - name: pgbouncer + run: ./scripts/test-pgbouncer.sh + # Przepuszcza prawdziwy defaults/alloy/config.alloy przez prawdziwe linie # logu i sprawdza detected_level oraz pola modsec_*. - name: Pipeline logow Alloy diff --git a/Makefile b/Makefile index ec2cce1..7a9502b 100644 --- a/Makefile +++ b/Makefile @@ -40,6 +40,7 @@ test: @./scripts/test-alloy.sh @bash tests/test_makefile.sh @./scripts/test-nginx-limits.sh + @./scripts/test-pgbouncer.sh @./scripts/test-waf.sh ifdef FIRST_RUN @@ -228,6 +229,7 @@ help: @echo " test - Wszystkie trzy zestawy, od najtanszego (~6-8 min)" @echo " test-alloy - Sam pipeline logow Alloy: detected_level + modsec_* (~30 s)" @echo " test-nginx-limits - Globalny limit ruchu do appservera na zywym nginksie (~30 s)" + @echo " test-pgbouncer - pgbouncer (pula dla appservera) na zywym PostgreSQL (~1 min)" @echo " test-waf - Sam WAF: ModSecurity + OWASP CRS na realnych payloadach (~3 min)" @echo "" @echo " Versioning:" diff --git a/docker-compose.application.yml b/docker-compose.application.yml index ee14efe..bb7c061 100644 --- a/docker-compose.application.yml +++ b/docker-compose.application.yml @@ -89,6 +89,13 @@ services: env_file: ${BPP_CONFIGS_DIR}/.env environment: DJANGO_BPP_ENABLE_PROMETHEUS: ${DJANGO_BPP_ENABLE_PROMETHEUS:-false} + # appserver laczy sie z baza przez pgbouncer (docker-compose.pgbouncer.yml). + # `environment:` wygrywa z `env_file:`, wiec DJANGO_BPP_DB_HOST w .env + # dalej znaczy "prawdziwa baza" dla reszty serwisow i dla pgbouncera. + # BPP_APPSERVER_DB_HOST=dbserver (lub host bazy zewn.) + _PORT=5432 = + # appserver bezposrednio do bazy. + DJANGO_BPP_DB_HOST: ${BPP_APPSERVER_DB_HOST:-pgbouncer} + DJANGO_BPP_DB_PORT: ${BPP_APPSERVER_DB_PORT:-6432} volumes: - staticfiles:/staticroot - media:/mediaroot @@ -97,6 +104,10 @@ services: condition: service_healthy dbserver: condition: service_healthy + # Jak do dbserver: bez puli strona nie dziala, wiec zepsuty pgbouncer + # ma zatrzymac start (i `make up --wait`), a nie przepuscic appserver. + pgbouncer: + condition: service_healthy healthcheck: # interval: 10s zeby workery (depends_on appserver: service_healthy) # ruszyly szybciej po faktycznym ready gunicorna. start_period 1800s diff --git a/docker-compose.pgbouncer.yml b/docker-compose.pgbouncer.yml new file mode 100644 index 0000000..1fb9d8c --- /dev/null +++ b/docker-compose.pgbouncer.yml @@ -0,0 +1,48 @@ +# pgbouncer przed PostgreSQL — pula polaczen WYLACZNIE dla appservera. +# +# Po co: Django (CONN_MAX_AGE=0, ASGI) otwiera nowe polaczenie przy kazdym +# zadaniu; pomiar 2026-09-27: 18 ms connect + ~8 ms zimnego cache na ~100 ms +# mediany zadania. Pula w trybie session oddaje gotowy, rozgrzany backend. +# Spec: docs/superpowers/specs/2026-09-27-pgbouncer-design.md +# +# Laczy sie z DJANGO_BPP_DB_HOST/_PORT — dbserver ALBO baza zewnetrzna, +# ta sama konfiguracja. Reszta serwisow (Celery, denorm-queue z LISTEN, +# netdata, backup) laczy sie bezposrednio, jak dotad. + +x-logging: &default-logging + driver: "local" + options: + max-size: "${LOG_MAX_SIZE:-150m}" + max-file: "${LOG_MAX_FILE:-5}" + +services: + pgbouncer: + image: edoburu/pgbouncer:${PGBOUNCER_VERSION:-v1.25.2-p0} + restart: always + logging: *default-logging + # Serwisowy env_file jest KONIECZNY: ten z `include:` sluzy tylko + # interpolacji i nie trafia do kontenera (ta sama lekcja co dbserver). + env_file: ${BPP_CONFIGS_DIR}/.env + # Przez interpreter, nie przez bit +x (w tym repo +x juz ginal). + # Wlasny skrypt zamiast entrypointu obrazu — patrz naglowek skryptu. + entrypoint: ["sh", "/bpp-entrypoint.sh"] + volumes: + - ./pgbouncer/entrypoint.sh:/bpp-entrypoint.sh:ro + depends_on: + # W trybie bazy zewnetrznej dbserver to sentinel sondujacy prawdziwa + # baze — wiec to dziala w obu trybach. + dbserver: + condition: service_healthy + healthcheck: + # Osobny wpis bazy `_health` (pool_size=1): sonda nie stoi w kolejce za + # nasycona glowna pula. + test: ["CMD", "sh", "/bpp-entrypoint.sh", "zdrowie"] + interval: 10s + timeout: 5s + retries: 3 + start_period: 10s + deploy: + resources: + limits: + memory: ${PGBOUNCER_MEM_LIMIT:-64m} + cpus: "${PGBOUNCER_CPU_LIMIT:-0.5}" diff --git a/docker-compose.yml b/docker-compose.yml index 21d5157..87c7384 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,6 +21,9 @@ include: - path: ${BPP_DATABASE_COMPOSE:-docker-compose.database.yml} env_file: - ${BPP_CONFIGS_DIR}/.env + - path: docker-compose.pgbouncer.yml + env_file: + - ${BPP_CONFIGS_DIR}/.env - path: docker-compose.infrastructure.yml env_file: - ${BPP_CONFIGS_DIR}/.env diff --git a/mk/misc.mk b/mk/misc.mk index e9ed387..abb15a4 100644 --- a/mk/misc.mk +++ b/mk/misc.mk @@ -1,4 +1,4 @@ -.PHONY: clean wait debug-show-current-settings test-docker-versions test-config-path test-grafana-datasources test-rclone test-winget-ids test-waf test-alloy test-nginx-limits +.PHONY: clean wait debug-show-current-settings test-docker-versions test-config-path test-grafana-datasources test-rclone test-winget-ids test-waf test-alloy test-nginx-limits test-pgbouncer clean: -find . -name '*~' -o -name '\#*' -o -name '.*~' | xargs rm -f @@ -55,6 +55,14 @@ test-waf: test-nginx-limits: @./scripts/test-nginx-limits.sh +# pgbouncer na zywo: prawdziwy PostgreSQL (SCRAM) + obraz edoburu/pgbouncer +# z NASZYM skryptem startowym. Sprawdza logowanie (takze haslo z metaznakami), +# izolacje i reset sesji (tryb session + DISCARD ALL), ponowne uzycie backendu, +# sonde przy nasyconej puli, walidacje i przyciecie puli do max_connections. +# Zmienne: PGB_TEST_KEEP=1 (zostaw kontenery). +test-pgbouncer: + @./scripts/test-pgbouncer.sh + # Test pipeline'u logow Alloy. Przepuszcza PRAWDZIWY defaults/alloy/config.alloy # przez fixture z prawdziwymi liniami (audit log WAF-a + typowe formaty logow # aplikacji), podmieniajac tylko zrodlo (plik zamiast dockera) i ujscie diff --git a/tests/test_makefile.sh b/tests/test_makefile.sh index 206100d..22023c6 100755 --- a/tests/test_makefile.sh +++ b/tests/test_makefile.sh @@ -606,6 +606,85 @@ test_nginx_global_limits_wired() { fi } +# ============================================================ +# TEST 11e: pgbouncer — tylko appserver przez pule, reszta bezposrednio +# ============================================================ +# Na WYRENDEROWANYM `docker compose config` (grep po zrodle nie widzi +# interpolacji). `-p` jawnie: nazwa projektu z repo-lokalnego .env (np. +# pozostalosc po init-configs z katalogiem tmp.XXXX) potrafi byc niepoprawna. + +_render_compose() { + # $1 = katalog konfiguracji z .env; wynik na stdout + (cd "$REPO_DIR" && BPP_CONFIGS_DIR="$1" docker compose -p bpp-compose-test config 2>"$1/stderr.txt") +} + +test_pgbouncer_compose() { + yellow "=== Test 11e: pgbouncer — kto laczy sie przez pule ===" + + if ! command -v docker >/dev/null 2>&1 || ! docker info >/dev/null 2>&1; then + skip_or_fail "docker niedostepny — pomijam render compose config" + return + fi + + local cfg out app + cfg=$(mktemp -d) + # Minimalny STARY .env: zadnej nowej zmiennej — tak wyglada instalacja + # po `git pull` (kontrakt backwards-compat). + printf 'DJANGO_BPP_HOST_BACKUP_DIR=%s\nDJANGO_BPP_DB_HOST=dbserver\nDJANGO_BPP_DB_PORT=5432\n' \ + "$cfg" > "$cfg/.env" + + if ! out="$(_render_compose "$cfg")"; then + fail "compose config nie wyrenderowal sie ($(tail -1 "$cfg/stderr.txt"))" + rm_rf_root "$cfg"; return + fi + + app="$(svc_block appserver <(printf '%s\n' "$out") || true)" + assert_svc_contains "appserver domyslnie przez pgbouncer" "DJANGO_BPP_DB_HOST: pgbouncer" "$app" + if printf '%s\n' "$app" | grep -Eq 'DJANGO_BPP_DB_PORT: "?6432"?$'; then + pass "appserver domyslnie na porcie 6432" + else + fail "appserver: brak DJANGO_BPP_DB_PORT 6432" + fi + if printf '%s\n' "$app" | grep -A1 -E '^ pgbouncer:$' | grep -q 'condition: service_healthy'; then + pass "appserver czeka na zdrowy pgbouncer (jak na dbserver)" + else + fail "appserver -> pgbouncer bez condition: service_healthy" + fi + + local s blok + for s in authserver workerserver celerybeat denorm-queue flower; do + blok="$(svc_block "$s" <(printf '%s\n' "$out") || true)" + if printf '%s\n' "$blok" | grep -q 'pgbouncer'; then + fail "$s laczy sie przez pgbouncer (ma isc bezposrednio)" + else + pass "$s bezposrednio do bazy" + fi + done + + local pgb + pgb="$(svc_block pgbouncer <(printf '%s\n' "$out") || true)" + assert_svc_contains "pgbouncer: logging local" "driver: local" "$pgb" + assert_svc_contains "pgbouncer: entrypoint przez sh" "- sh" "$pgb" + assert_svc_contains "pgbouncer: skrypt startowy" "/bpp-entrypoint.sh" "$pgb" + assert_svc_contains "pgbouncer: sonda przez podkomende" "zdrowie" "$pgb" + assert_svc_contains "pgbouncer: czeka na dbserver" "dbserver:" "$pgb" + assert_svc_contains "pgbouncer: limit pamieci 64m" "memory: \"67108864\"" "$pgb" + # shellcheck disable=SC2016 # wzorzec grep-a: `${BPP_CONFIGS_DIR}` ma zostac literalne + assert_file_contains "pgbouncer: serwisowy env_file" 'env_file: ${BPP_CONFIGS_DIR}/.env' \ + "$REPO_DIR/docker-compose.pgbouncer.yml" + + # Operator kieruje appserver bezposrednio do bazy. Wartosc INNA niz + # DJANGO_BPP_DB_HOST z .env (dbserver) — inaczej asercja przechodzilaby + # bez nadpisania, bo env_file i tak wnosi DJANGO_BPP_DB_HOST=dbserver. + printf 'BPP_APPSERVER_DB_HOST=baza-bezposrednia\nBPP_APPSERVER_DB_PORT=5432\n' >> "$cfg/.env" + out="$(_render_compose "$cfg")" + app="$(svc_block appserver <(printf '%s\n' "$out") || true)" + assert_svc_contains "BPP_APPSERVER_DB_HOST -> appserver bezposrednio do bazy" \ + "DJANGO_BPP_DB_HOST: baza-bezposrednia" "$app" + + rm_rf_root "$cfg" +} + # ============================================================ # TEST 11c: WAF — klikalny cross-filtr (regula / atak / IP / sciezka) # ============================================================ @@ -2258,6 +2337,7 @@ test_compose_bind_mounts test_compose_shell_vars_escaped test_waf_audit_only_rules test_nginx_global_limits_wired +test_pgbouncer_compose test_waf_crossfilter test_log_monitoring_waf_filter test_env_sample From 1043befe5af0877336af68cc576ee053a0be1cdb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Sun, 27 Sep 2026 23:41:14 +0200 Subject: [PATCH 7/9] feat(pgbouncer): configure-resources przydziela PGBOUNCER_MEM_LIMIT Co-Authored-By: Claude Opus 5.5 --- scripts/configure-resources.sh | 2 ++ tests/test_makefile.sh | 2 ++ 2 files changed, 4 insertions(+) diff --git a/scripts/configure-resources.sh b/scripts/configure-resources.sh index e7e2e97..3a54c31 100755 --- a/scripts/configure-resources.sh +++ b/scripts/configure-resources.sh @@ -136,6 +136,7 @@ FIXED_MEM=( "dozzle:64" "ofelia:64" "autoheal:32" + "pgbouncer:64" ) # Format: name:floor_mb:surplus_weight. Dziela pule po odjeciu fixed. @@ -448,6 +449,7 @@ var_prefix_for() { dozzle) echo "DOZZLE" ;; ofelia) echo "OFELIA" ;; autoheal) echo "AUTOHEAL" ;; + pgbouncer) echo "PGBOUNCER" ;; *) echo "UNKNOWN" ;; esac } diff --git a/tests/test_makefile.sh b/tests/test_makefile.sh index 22023c6..151fd7c 100755 --- a/tests/test_makefile.sh +++ b/tests/test_makefile.sh @@ -1659,6 +1659,8 @@ test_configure_resources() { assert_file_contains "netdata cap 320m" "NETDATA_MEM_LIMIT=320m" "$cfg/.env" assert_file_contains "grafana cap 192m" "GRAFANA_MEM_LIMIT=192m" "$cfg/.env" assert_file_contains "dozzle cap 64m" "DOZZLE_MEM_LIMIT=64m" "$cfg/.env" + assert_file_contains "pgbouncer cap 64m" "PGBOUNCER_MEM_LIMIT=64m" "$cfg/.env" + assert_file_not_contains "brak UNKNOWN_*" "UNKNOWN_MEM_LIMIT=" "$cfg/.env" assert_file_contains "appserver obecny" "APPSERVER_MEM_LIMIT=" "$cfg/.env" assert_file_contains "dbserver obecny" "DBSERVER_MEM_LIMIT=" "$cfg/.env" # Po konsolidacji+renamie: jeden worker `workerserver` -> WORKER_MEM_LIMIT From 4b7f4be6733a6d7fadd85ff5e90520bd3897a2c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Sun, 27 Sep 2026 23:43:32 +0200 Subject: [PATCH 8/9] docs(pgbouncer): strona operatora, uslugi, limity, komendy, tripwire'y w CLAUDE.md Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 6 +++ docs/architektura/uslugi.md | 3 ++ docs/eksploatacja/komendy.md | 10 +++- docs/konfiguracja/architektura.md | 1 + docs/konfiguracja/limity-zasobow.md | 10 ++++ docs/konfiguracja/pgbouncer.md | 81 +++++++++++++++++++++++++++++ mkdocs.yml | 1 + 7 files changed, 110 insertions(+), 2 deletions(-) create mode 100644 docs/konfiguracja/pgbouncer.md diff --git a/CLAUDE.md b/CLAUDE.md index 15b20c2..7039568 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,6 +33,7 @@ Operator topics and their canonical pages: - Multi-host: `docs/konfiguracja/multi-host.md` - Resource limits: `docs/konfiguracja/limity-zasobow.md` - PostgreSQL versions/upgrade: `docs/konfiguracja/postgresql.md` +- pgbouncer (appserver connection pool): `docs/konfiguracja/pgbouncer.md` - Make commands: `docs/eksploatacja/komendy.md` - Planned-downtime deploys (`run-with-warning`): `docs/eksploatacja/przerwa-techniczna.md` - Backups / server migration: `docs/eksploatacja/backup-i-rclone.md`, `docs/eksploatacja/przenosiny-serwera.md` @@ -65,6 +66,7 @@ docker-compose.yml # Main orchestration — includes only, de ├── docker-compose.monitoring.yml # Netdata, Loki, Grafana, Alloy, Dozzle ├── docker-compose.database.yml # PostgreSQL + postgresql_data volume (default) │ └ docker-compose.database.external.yml # external DB — swapped in via ${BPP_DATABASE_COMPOSE} +├── docker-compose.pgbouncer.yml # pgbouncer — connection pool for appserver only ├── docker-compose.infrastructure.yml # Nginx, Redis ├── docker-compose.application.yml # appserver, authserver, ofelia, autoheal + staticfiles/media volumes ├── docker-compose.workers.yml # workerserver, denorm-queue, workerserver-status, celerybeat, flower @@ -295,6 +297,10 @@ Compose interpolates `$VAR` **before** the string reaches the container and cann `appserver` (migrations) before the worker; `workerserver` depends on `appserver` healthy; `denorm-queue` requires `workerserver` healthy; `celerybeat` uses `service_started` for `appserver` (faster start). Service table + data flow: `docs/architektura/uslugi.md`. +### pgbouncer — only appserver, only `session` + +`appserver` reaches PostgreSQL through `pgbouncer` (`docker-compose.pgbouncer.yml`); every other service connects directly. `DJANGO_BPP_DB_HOST`/`_PORT` keep meaning "the real DB" — the appserver override is `BPP_APPSERVER_DB_HOST`/`_PORT` in its `environment:`. **Anti-fixes — do NOT:** (1) switch to `pool_mode = transaction` — Django's `QuerySet.iterator()` uses `WITH HOLD` cursors, and `LISTEN`/`SET`/`PREPARE` break too (https://www.pgbouncer.org/features.html); (2) repoint `DJANGO_BPP_DB_HOST` at the pool or route `denorm-queue` (`LISTEN`, all-day connection) through it; (3) go back to the image's own entrypoint — it writes the password into `userlist.txt` unescaped and pastes values into a `printf` format, so `"` or `%` in the password breaks login; (4) point the healthcheck at the main pool — it would queue behind a saturated pool and flip `unhealthy` exactly under load, hence the `_health` alias with `pool_size=1`; (5) add an off-switch (`scale`, lying probe, `service_started`) — pgbouncer is infrastructure like `dbserver`, `appserver` waits on it `service_healthy` and an unhealthy one must fail `make up`. Pool is clamped at start to `max_connections − max(40, 20 + 0.75×nproc)`. Tests: `make test-pgbouncer` (live, mutation-checked) + `test_pgbouncer_compose`. Operator doc: `docs/konfiguracja/pgbouncer.md`. + ### Scheduled jobs / nightly restarts (Ofelia) Daily maintenance, SSL renew, log rotation, and staggered 05:00–05:25 nightly restarts (`kill 1` via read-only `docker.sock`) are Ofelia labels in the compose files. Full schedule: `docs/architektura/zadania-ofelia.md`. diff --git a/docs/architektura/uslugi.md b/docs/architektura/uslugi.md index 2b197f5..1a22412 100644 --- a/docs/architektura/uslugi.md +++ b/docs/architektura/uslugi.md @@ -9,6 +9,7 @@ | **appserver** | Serwer aplikacji Django + migracje | | **authserver** | Django auth proxy dla nginx — bez migracji/collectstatic, startuje w sekundy | | **dbserver** | PostgreSQL + denormalizacja | +| **pgbouncer** | Pula połączeń PostgreSQL (tryb `session`) — **wyłącznie** dla appservera; reszta łączy się bezpośrednio ([pgbouncer](../konfiguracja/pgbouncer.md)) | | **webserver** | Nginx (reverse proxy + static files) | | **redis** | Cache, broker Celery i result backend | @@ -87,3 +88,5 @@ Compose'owe `profiles:` trzymają te usługi poza domyślnym `docker compose up` `appserver` healthy (tranzytywnie `dbserver`). - `denorm-queue` wymaga `workerserver` healthy. - `celerybeat` używa `service_started` (nie `_healthy`) dla `appserver` — szybszy start. +- `pgbouncer` czeka na `dbserver` healthy (w trybie bazy zewnętrznej to sentinel sondujący + prawdziwą bazę); `appserver` czeka na `pgbouncer` healthy — jak na `dbserver`. diff --git a/docs/eksploatacja/komendy.md b/docs/eksploatacja/komendy.md index a7def74..d4f0672 100644 --- a/docs/eksploatacja/komendy.md +++ b/docs/eksploatacja/komendy.md @@ -185,6 +185,7 @@ flush całej bazy wylogowywałby wszystkich przy każdym deployu. ```bash make test-waf # Czy WAF blokuje ataki i przepuszcza legalny ruch BPP make test-nginx-limits # Czy globalny limit ruchu do appservera działa (wspólna pula dla wszystkich IP) +make test-pgbouncer # Czy pula pgbouncera działa (session, reset sesji, przycięcie puli) make test-alloy # Czy pipeline logów nadaje poprawny poziom i pola modsec_* make test-docker-versions # Logika mapowania digest ↔ CalVer make test-config-path # Normalizacja ścieżki katalogu konfiguracyjnego @@ -193,7 +194,7 @@ make test-upgrade # Próba generalna migracji na kopii produkcyjnej b make test-deploy-with-warning # Sesja wdrożenia z ostrzeżeniem (mocki, bez Dockera) ``` -`test-waf`, `test-nginx-limits` i `test-alloy` **nie wymagają `.env`, działającej instalacji ani sieci +`test-waf`, `test-nginx-limits`, `test-pgbouncer` i `test-alloy` **nie wymagają `.env`, działającej instalacji ani sieci produkcyjnej** — stawiają własne kontenery i sprzątają po sobie. Kod wyjścia = liczba niezgodności, więc nadają się do CI. @@ -201,6 +202,11 @@ niezgodności, więc nadają się do CI. z `defaults/webserver/`, po czym strzela baterią zapytań o znanym z góry wyniku. Payloady ataku to prawdziwe próby sqlmap z lipca 2026. Szczegóły: [WAF](../architektura/waf.md#sprawdzenie-czy-waf-dziala-make-test-waf). +- **`make test-pgbouncer`** stawia prawdziwy PostgreSQL i pgbouncera z naszym + skryptem startowym ([pgbouncer](../konfiguracja/pgbouncer.md)): logowanie + (także hasło z metaznakami), izolacja i reset sesji między klientami, ponowne + użycie procesu bazy, sonda przy pełnej puli, przycięcie puli do + `max_connections`. `PGB_TEST_KEEP=1` zostawia kontenery. - **`make test-nginx-limits`** stawia produkcyjny obraz nginx przed atrapą appservera i sprawdza [limit globalny](../architektura/rate-limiting.md#limit-globalny) na żywo: klient z innego IP dostaje 429 po wyczerpaniu puli przez pierwszego, @@ -225,7 +231,7 @@ niezgodności, więc nadają się do CI. Bez pliku `.env` obok `docker-compose.yml` Makefile wchodzi w tryb pierwszego uruchomienia i wystawia **tylko** cel `setup` — `make test-alloy` zgłosi wtedy „No rule to make target". Skrypty można wołać wprost: - `./scripts/test-alloy.sh`, `./scripts/test-waf.sh`, `./scripts/test-nginx-limits.sh`. + `./scripts/test-alloy.sh`, `./scripts/test-waf.sh`, `./scripts/test-nginx-limits.sh`, `./scripts/test-pgbouncer.sh`. ## Wydanie i wersja diff --git a/docs/konfiguracja/architektura.md b/docs/konfiguracja/architektura.md index 37841d1..47c7e88 100644 --- a/docs/konfiguracja/architektura.md +++ b/docs/konfiguracja/architektura.md @@ -9,6 +9,7 @@ docker-compose.yml # Główna orkiestracja ├── docker-compose.monitoring.yml # Netdata, Loki, Grafana, Alloy, Dozzle ├── docker-compose.database.yml # PostgreSQL + wolumen postgresql_data (domyslnie) │ └ docker-compose.database.external.yml # baza zewnetrzna — podmiana przez ${BPP_DATABASE_COMPOSE} +├── docker-compose.pgbouncer.yml # pgbouncer (pula dla appservera) ├── docker-compose.infrastructure.yml # Nginx, Redis ├── docker-compose.application.yml # appserver, authserver, ofelia, autoheal + wolumeny staticfiles/media ├── docker-compose.workers.yml # workerserver, denorm-queue, workerserver-status, celerybeat, flower diff --git a/docs/konfiguracja/limity-zasobow.md b/docs/konfiguracja/limity-zasobow.md index f5a68ef..dc48385 100644 --- a/docs/konfiguracja/limity-zasobow.md +++ b/docs/konfiguracja/limity-zasobow.md @@ -187,3 +187,13 @@ floodzie baza kończy na `too many clients` (incydent z 27.09.2026 — - Zmienna jest czytana przez obraz BPP z tą zmianą; na starszym obrazie nie działa (bez błędu). Nazwa to celowo nie `UVICORN_…`, bo tryb deweloperski (`uvicorn --reload`) czyta zmienne `UVICORN_*` sam. + +### pgbouncer — `PGBOUNCER_*` + +`PGBOUNCER_MEM_LIMIT` (64 MB) i `_CPU_LIMIT` (0,5) — `make configure-resources` +wpisuje stały cap. `PGBOUNCER_POOL_SIZE` (80) to sufit połączeń puli do bazy; +musi się zmieścić w `max_connections` razem z połączeniami bezpośrednimi. +Łańcuch zależności: `DBSERVER_MEM_LIMIT` → autotune `max_connections` +(100 na 1 GB, maks. 250) → pula przycinana przy starcie do +`max_connections − max(40, 20 + 0,75 × rdzenie)` +([pgbouncer](pgbouncer.md#pula-a-max_connections)). diff --git a/docs/konfiguracja/pgbouncer.md b/docs/konfiguracja/pgbouncer.md new file mode 100644 index 0000000..139471c --- /dev/null +++ b/docs/konfiguracja/pgbouncer.md @@ -0,0 +1,81 @@ +# pgbouncer — pula połączeń dla appservera + +Appserver łączy się z PostgreSQL przez **pgbouncer** w trybie `session`. +Włączone domyślnie, bez żadnej zmiany w `.env`. + +## Po co + +Django w appserverze otwiera nowe połączenie z bazą przy **każdym** żądaniu +i zamyka je na końcu. Pomiar na publikacje.up.lublin.pl (27.09.2026): samo +połączenie kosztowało **18 ms**, a pierwsze zapytanie na świeżym połączeniu +kolejne ~8 ms (zimny cache katalogu) — przy medianie całego żądania 100 ms. + +pgbouncer trzyma gotowe połączenia z bazą i oddaje je kolejnym żądaniom. Znika +tworzenie procesu PostgreSQL i zimny cache; zostaje tańsze logowanie do samego +pgbouncera (SCRAM). + +To **nie** jest ochrona przed zalewem ruchu — tę dają +[limity nginx i appservera](../architektura/rate-limiting.md#limit-globalny). + +## Co idzie przez pulę + +| Serwis | Połączenie z bazą | +|---|---| +| appserver | **przez pgbouncer** | +| authserver, workerserver, celerybeat, denorm-queue, flower | bezpośrednio | +| netdata (`bpp_monitor`) | bezpośrednio — ma widzieć prawdziwy serwer | +| backup / restore | `docker exec` w `dbserver` — bez zmian | + +pgbouncer łączy się z `DJANGO_BPP_DB_HOST`/`DJANGO_BPP_DB_PORT` — czyli +z `dbserver` albo z [bazą zewnętrzną](postgresql.md). Ta sama konfiguracja +w obu trybach. Logowanie tym samym kontem co dotąd +(`DJANGO_BPP_DB_USER`/`_PASSWORD`). + +## Zmienne (`$BPP_CONFIGS_DIR/.env`) + +| Zmienna | Domyślnie | Znaczenie | +|---|---|---| +| `BPP_APPSERVER_DB_HOST` | `pgbouncer` | dokąd łączy się appserver | +| `BPP_APPSERVER_DB_PORT` | `6432` | port dla powyższego | +| `PGBOUNCER_POOL_SIZE` | `80` | maks. połączeń pgbouncera do bazy | +| `PGBOUNCER_MAX_CLIENT_CONN` | `1000` | maks. połączeń appservera do pgbouncera | +| `PGBOUNCER_VERSION` | `v1.25.2-p0` | tag obrazu `edoburu/pgbouncer` | +| `PGBOUNCER_MEM_LIMIT` / `_CPU_LIMIT` | `64m` / `0.5` | [limity zasobów](limity-zasobow.md) | + +Pusta wartość = domyślna; błędna liczba = `OSTRZEZENIE` w logu i domyślna. + +Appserver bezpośrednio do bazy (z pominięciem puli): + +``` +BPP_APPSERVER_DB_HOST=dbserver # albo host bazy zewnętrznej +BPP_APPSERVER_DB_PORT=5432 +``` + +potem `make up`. Usługa `pgbouncer` dalej działa i dalej musi być zdrowa. + +## Pula a `max_connections` + +Do tego samego serwera łączą się też bezpośrednio Celery, denorm-queue +i reszta. Przy starcie pgbouncer odczytuje `max_connections` i — jeśli pula +się nie mieści — **przycina ją** z ostrzeżeniem w logu: + +``` +bpp-pgbouncer: OSTRZEZENIE: PGBOUNCER_POOL_SIZE=80 przekracza max_connections=100 minus rezerwa 40 — przycinam do 60. Obniz BPP_NGINX_GLOBAL_CONN do <= 60 albo podnies DBSERVER_MEM_LIMIT (autotune: 100 polaczen na 1 GB). +``` + +## Diagnostyka + +```bash +docker compose logs pgbouncer | grep bpp-pgbouncer # konfiguracja przy starcie +docker compose logs pgbouncer | grep stats # co 60 s; pole "wait" > 0 = kolejka +docker compose exec pgbouncer sh -c 'PGPASSWORD="$DJANGO_BPP_DB_PASSWORD" psql -h 127.0.0.1 -p 6432 -U "$DJANGO_BPP_DB_USER" pgbouncer -c "SHOW POOLS"' +``` + +W `SHOW POOLS` kolumna `cl_waiting` > 0 znaczy, że appserver czeka na wolne +połączenie z bazą (po 15 s dostaje błąd). + +## Awarie + +pgbouncer jest infrastrukturą jak `dbserver`: gdy jest `unhealthy`, appserver +nie startuje, a `make up` kończy się błędem. Celery, denorm i panele działają +dalej. diff --git a/mkdocs.yml b/mkdocs.yml index 7d17012..6961ea6 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -93,6 +93,7 @@ nav: - Multi-host: konfiguracja/multi-host.md - Limity zasobów: konfiguracja/limity-zasobow.md - PostgreSQL — wersje i upgrade: konfiguracja/postgresql.md + - pgbouncer (pula połączeń): konfiguracja/pgbouncer.md - Eksploatacja: - Najważniejsze komendy: eksploatacja/komendy.md - Aktualizacje i wersje obrazów: eksploatacja/aktualizacje.md From f7e89030f7a9bb8ffcea5a886f2ac5843a229f81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Mon, 28 Sep 2026 00:07:44 +0200 Subject: [PATCH 9/9] =?UTF-8?q?fix(pgbouncer):=20poprawki=20po=20review=20?= =?UTF-8?q?=E2=80=94=20ulimit=20nofile,=20test=20bez=20coreutils=20timeout?= =?UTF-8?q?,=20netdata=20w=20tescie,=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ulimits.nofile 4096 w compose: domyslny soft limit zalezy od daemona (czesto 1024), a pgbouncer przy max_client_conn=1000 potrzebuje ~1174 - test-pgbouncer: z_limitem (bash) zamiast timeout — na stockowym macOS brak timeout dawal falszywe OK w 7b i falszywy BLAD w 7 - test statyczny: netdata na liscie serwisow laczacych sie bezposrednio - docs: komentarz trybu bazy zewnetrznej, healthcheck pgbouncera, PGBOUNCER_CPU_LIMIT nie jest liczony przez configure-resources Co-Authored-By: Claude Opus 5.5 --- docker-compose.database.external.yml | 8 ++++--- docker-compose.pgbouncer.yml | 8 +++++++ docs/architektura/healthchecks-autoheal.md | 5 +++++ docs/konfiguracja/limity-zasobow.md | 5 +++-- scripts/test-pgbouncer.sh | 25 ++++++++++++++++++++-- tests/test_makefile.sh | 9 +++++++- 6 files changed, 52 insertions(+), 8 deletions(-) diff --git a/docker-compose.database.external.yml b/docker-compose.database.external.yml index 1e13618..92e7827 100644 --- a/docker-compose.database.external.yml +++ b/docker-compose.database.external.yml @@ -17,9 +17,11 @@ # dzięki czemu 'make db-backup', 'make dbshell-psql' itp. działają bez # zmian w targetach. # -# Aplikacja (appserver, authserver, workerserver-*) łączy się BEZPOŚREDNIO -# z zewnętrzną bazą przez DJANGO_BPP_DB_HOST z .env - sentinel nie jest -# pośrednikiem, tylko wartownikiem. +# authserver, workerserver, celerybeat, denorm-queue i netdata łączą się +# BEZPOŚREDNIO z zewnętrzną bazą przez DJANGO_BPP_DB_HOST z .env; appserver +# przez pgbouncer (docker-compose.pgbouncer.yml), który z kolei łączy się +# z tym samym DJANGO_BPP_DB_HOST. Sentinel nie jest pośrednikiem, tylko +# wartownikiem. x-logging: &default-logging driver: "local" diff --git a/docker-compose.pgbouncer.yml b/docker-compose.pgbouncer.yml index 1fb9d8c..855a938 100644 --- a/docker-compose.pgbouncer.yml +++ b/docker-compose.pgbouncer.yml @@ -41,6 +41,14 @@ services: timeout: 5s retries: 3 start_period: 10s + # Wlasny limit deskryptorow: domyslny soft limit zalezy od daemona Dockera + # (czesto 1024), a pgbouncer przy max_client_conn=1000 potrzebuje ~1134 + # (liczy to i loguje przy starcie: "max expected fd use"). Podnoszac + # PGBOUNCER_MAX_CLIENT_CONN powyzej ~3500, podnies i to. + ulimits: + nofile: + soft: 4096 + hard: 4096 deploy: resources: limits: diff --git a/docs/architektura/healthchecks-autoheal.md b/docs/architektura/healthchecks-autoheal.md index da5645c..ed3738b 100644 --- a/docs/architektura/healthchecks-autoheal.md +++ b/docs/architektura/healthchecks-autoheal.md @@ -25,6 +25,11 @@ inaczej fallback na starą `healthcheck_broker.py` (cold-import + broker connect) dla wstecznej zgodności z pinowanym starym obrazem. Wcześniej sama broker-sonda pod niskim capem CPU trwała 4–10 s i wydłużała start celerybeat do ~218 s. +- `pgbouncer` — `sh /bpp-entrypoint.sh zdrowie`: `SELECT 1` przez pgbouncer do + bazy, przez **osobny** wpis `_health` z własną pulą 1 połączenia — sonda + nie stoi w kolejce za nasyconą główną pulą, więc nie robi się `unhealthy` pod + ruchem. Nie jest obserwowany przez autoheal; `appserver` czeka na niego + `service_healthy` ([pgbouncer](../konfiguracja/pgbouncer.md)). ### Image-level (Dockerfile `HEALTHCHECK`) diff --git a/docs/konfiguracja/limity-zasobow.md b/docs/konfiguracja/limity-zasobow.md index dc48385..54d2944 100644 --- a/docs/konfiguracja/limity-zasobow.md +++ b/docs/konfiguracja/limity-zasobow.md @@ -190,8 +190,9 @@ floodzie baza kończy na `too many clients` (incydent z 27.09.2026 — ### pgbouncer — `PGBOUNCER_*` -`PGBOUNCER_MEM_LIMIT` (64 MB) i `_CPU_LIMIT` (0,5) — `make configure-resources` -wpisuje stały cap. `PGBOUNCER_POOL_SIZE` (80) to sufit połączeń puli do bazy; +`PGBOUNCER_MEM_LIMIT` (64 MB) — `make configure-resources` wpisuje stały cap; +`PGBOUNCER_CPU_LIMIT` (0,5) zostaje na domyślnej wartości z compose +(`configure-resources` go nie liczy). `PGBOUNCER_POOL_SIZE` (80) to sufit połączeń puli do bazy; musi się zmieścić w `max_connections` razem z połączeniami bezpośrednimi. Łańcuch zależności: `DBSERVER_MEM_LIMIT` → autotune `max_connections` (100 na 1 GB, maks. 250) → pula przycinana przy starcie do diff --git a/scripts/test-pgbouncer.sh b/scripts/test-pgbouncer.sh index 0bd6eae..ca03b98 100755 --- a/scripts/test-pgbouncer.sh +++ b/scripts/test-pgbouncer.sh @@ -62,7 +62,10 @@ start_db() { # start_pgb [-e VAR=wartosc ...] — pgbouncer z naszym skryptem, czeka na port. start_pgb() { docker rm -f "$PGB" >/dev/null 2>&1 + # --ulimit jak w docker-compose.pgbouncer.yml: bez tego wynik testu + # deskryptorow zalezy od daemona Dockera, a nie od naszej konfiguracji. docker run -d --name "$PGB" --network "$NET" --network-alias pgbouncer \ + --ulimit nofile=4096:4096 \ -e DJANGO_BPP_DB_HOST=baza-zewnetrzna -e DJANGO_BPP_DB_PORT=5432 \ -e DJANGO_BPP_DB_NAME="$DB" -e DJANGO_BPP_DB_USER="$USR" \ -e DJANGO_BPP_DB_PASSWORD="$HASLO" \ @@ -96,6 +99,24 @@ trzymaj() { psql -h pgbouncer -p 6432 -U "$USR" -d "$DB" -Atq "${args[@]}" -c "\\! sleep $s" } +# z_limitem SEKUNDY KOMENDA... — jak `timeout`, ale w czystym bashu: stockowy +# macOS nie ma `timeout` (to coreutils), a exit 127 z brakujacego polecenia +# dawal w 7b FALSZYWE "OK" (sonda "czerwona"), a w 7 falszywy BLAD. +# Zwraca kod komendy albo 124 po przekroczeniu czasu. +z_limitem() { + local sek="$1"; shift + "$@" & local pid=$! + ( sleep "$sek"; kill "$pid" 2>/dev/null ) & local straznik=$! + local rc=0 + wait "$pid" || rc=$? + if kill "$straznik" 2>/dev/null; then + wait "$straznik" 2>/dev/null + else + rc=124 + fi + return "$rc" +} + ini() { docker exec "$PGB" cat /etc/pgbouncer/pgbouncer.ini; } log_pgb() { docker logs "$PGB" 2>&1; } @@ -163,7 +184,7 @@ if start_pgb -e PGBOUNCER_POOL_SIZE=2; then # w trybie session bezczynny klient i tak trzyma backend -> pula 2 pelna trzymaj 8 "SELECT 1"; trzymaj 8 "SELECT 1" sleep 1 - if timeout 5 docker exec "$PGB" sh /bpp-entrypoint.sh zdrowie >/dev/null 2>&1; then + if z_limitem 5 docker exec "$PGB" sh /bpp-entrypoint.sh zdrowie >/dev/null 2>&1; then ok "sonda zielona przy pelnej glownej puli (osobny wpis _health)" else zle "sonda czerwona/zawieszona przy pelnej puli — idzie przez glowna pule" @@ -189,7 +210,7 @@ fi echo "== 7b. sonda czerwona przy zlym hasle ==" if start_pgb -e DJANGO_BPP_DB_PASSWORD=zle-haslo; then - timeout 8 docker exec "$PGB" sh /bpp-entrypoint.sh zdrowie >/dev/null 2>&1 \ + z_limitem 8 docker exec "$PGB" sh /bpp-entrypoint.sh zdrowie >/dev/null 2>&1 \ && zle "sonda zielona mimo zlego hasla" || ok "sonda czerwona przy zlym hasle" else zle "pgbouncer nie wstal przy zlym hasle (ma wstac, a sonda ma byc czerwona)" diff --git a/tests/test_makefile.sh b/tests/test_makefile.sh index 151fd7c..77e65b9 100755 --- a/tests/test_makefile.sh +++ b/tests/test_makefile.sh @@ -652,7 +652,7 @@ test_pgbouncer_compose() { fi local s blok - for s in authserver workerserver celerybeat denorm-queue flower; do + for s in authserver workerserver celerybeat denorm-queue flower netdata; do blok="$(svc_block "$s" <(printf '%s\n' "$out") || true)" if printf '%s\n' "$blok" | grep -q 'pgbouncer'; then fail "$s laczy sie przez pgbouncer (ma isc bezposrednio)" @@ -669,6 +669,13 @@ test_pgbouncer_compose() { assert_svc_contains "pgbouncer: sonda przez podkomende" "zdrowie" "$pgb" assert_svc_contains "pgbouncer: czeka na dbserver" "dbserver:" "$pgb" assert_svc_contains "pgbouncer: limit pamieci 64m" "memory: \"67108864\"" "$pgb" + # Wlasny ulimit: domyslny soft limit deskryptorow zalezy od daemona (czesto + # 1024), a pgbouncer przy max_client_conn=1000 potrzebuje ~1134. + if printf '%s\n' "$pgb" | grep -A2 -E '^ +nofile:$' | grep -q 'soft: 4096'; then + pass "pgbouncer: ulimit nofile 4096" + else + fail "pgbouncer: brak ulimits.nofile (soft 4096) — limit zalezy od daemona Dockera" + fi # shellcheck disable=SC2016 # wzorzec grep-a: `${BPP_CONFIGS_DIR}` ma zostac literalne assert_file_contains "pgbouncer: serwisowy env_file" 'env_file: ${BPP_CONFIGS_DIR}/.env' \ "$REPO_DIR/docker-compose.pgbouncer.yml"