Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 `<NAME>_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`.
Expand Down
2 changes: 2 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:"
Expand Down
11 changes: 11 additions & 0 deletions docker-compose.application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
8 changes: 5 additions & 3 deletions docker-compose.database.external.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
56 changes: 56 additions & 0 deletions docker-compose.pgbouncer.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# 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
# 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:
memory: ${PGBOUNCER_MEM_LIMIT:-64m}
cpus: "${PGBOUNCER_CPU_LIMIT:-0.5}"
3 changes: 3 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions docs/architektura/healthchecks-autoheal.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<baza>_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`)

Expand Down
3 changes: 3 additions & 0 deletions docs/architektura/uslugi.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down Expand Up @@ -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`.
10 changes: 8 additions & 2 deletions docs/eksploatacja/komendy.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -193,14 +194,19 @@ 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.

- **`make test-waf`** stawia atrapę backendu i webserver z *prawdziwą* konfiguracją
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,
Expand All @@ -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

Expand Down
1 change: 1 addition & 0 deletions docs/konfiguracja/architektura.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions docs/konfiguracja/limity-zasobow.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,3 +187,14 @@ 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) — `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
`max_connections − max(40, 20 + 0,75 × rdzenie)`
([pgbouncer](pgbouncer.md#pula-a-max_connections)).
81 changes: 81 additions & 0 deletions docs/konfiguracja/pgbouncer.md
Original file line number Diff line number Diff line change
@@ -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.
Loading
Loading