Перейти до вмісту

Безпека, експлуатація і вибір БД

Аналітик «Крамниці» підключається тим самим логіном shop, що й застосунок. Його SELECT email, birth_date FROM customers віддає персональні дані (ПД) п’яти тисяч покупців, а DELETE FROM reviews без WHERE стирає всі відгуки. База не порушила жодного правила: shop володіє всіма таблицями.

Буває й інакше: сайт гальмує, а процесор майже вільний. Одна сесія відкрила транзакцію й забула її закрити, і в таблиці з п’ятьма живими рядками вже 92 246 мертвих версій.

А на нараді пропонують перенести каталог у MongoDB, рекомендації в Neo4j, а кошики у Valkey. Кожна база додає свій бекап і моніторинг, хоча ніхто не перевіряв, чи PostgreSQL справді не впорається.

Модуль про три рішення: хто що може з даними, що міряти, що обрати. Приклади на PostgreSQL 18.6 у Docker, розмір small (Датасет), якщо не сказано інше.

Передумови. Ролі застосунку й пул: модуль 6. VACUUM: модуль 10. WAL і PITR: модуль 11. Слоти й логічна реплікація: модуль 12.

Ролі й найменші привілеї

Section titled “Ролі й найменші привілеї”

Роль у PostgreSQL одночасно користувач і група: з LOGIN вона підключається. Новий об’єкт належить творцю, а чужим ролям недоступний, доки власник не виконає GRANT. Пастка типових налаштувань: псевдороль PUBLIC («усі») має CONNECT на кожну базу й EXECUTE на кожну функцію. Право CREATE у схемі public з неї знято з PostgreSQL 15. Принцип найменших привілеїв (хмарний модуль 3) дає «Крамниці» три логіни замість shop.

Матриця доступу: ролі застосунок, аналітик, підтримка й продавець до п’яти таблиць «Крамниці», з позначками про колонки й рядкиcustomersordersorder_itemsproductspaymentsshop_appчитає, пишечитає, пишечитає, пишечитаєчитає, пишеshop_analystбез ПДбез нотаткичитаєчитаєчитаєshop_supportчитаєчитає, statusчитаєчитаєчитаєshop_sellerнемає доступусвої (RLS)свої (RLS)свої (RLS)немає доступужовті клітинки: частина колонок або рядківпідтримка стирає покупця лише функцією erase_customer (SECURITY DEFINER)Поза RLS: суперкористувач і BYPASSRLS; власник таблиці без FORCE ROW LEVEL SECURITY
Три логіни й роль продавця до п'яти таблиць. Жовті клітинки: лише частина колонок або рядків.
CREATE ROLE shop_app LOGIN;
CREATE ROLE shop_analyst LOGIN;
CREATE ROLE shop_support LOGIN;
GRANT USAGE ON SCHEMA public TO shop_app, shop_analyst, shop_support;
GRANT SELECT, INSERT, UPDATE ON customers, orders, order_items, payments, inventory TO shop_app;
GRANT SELECT ON products TO shop_app;
GRANT INSERT ON view_events TO shop_app;
GRANT SELECT ON products, order_items, payments, view_events TO shop_analyst;
GRANT SELECT (customer_id, settlement_id, status) ON customers TO shop_analyst;
GRANT SELECT (order_id, customer_id, status, placed_at, total_amount) ON orders TO shop_analyst;
GRANT SELECT ON customers, orders, order_items, payments TO shop_support;
GRANT UPDATE (status, updated_at) ON orders TO shop_support;

Застосунок пише замовлення, але ні DELETE, ні запису в products йому не видано, тож і ін’єкція UPDATE products SET price = 0.01 з модуля 6 упирається в permission denied. Аналітик не бачить email, імені й дати народження, і SELECT * FROM customers для нього відхиляється цілком: досить однієї колонки без права. А INSERT … RETURNING event_id у view_events застосунку не вдасться: RETURNING потребує SELECT на повернені колонки.

Права діють на наявні таблиці. Для майбутніх є ALTER DEFAULT PRIVILEGES FOR ROLE shop IN SCHEMA public GRANT SELECT ON TABLES TO shop_analyst: таблицю promo_stats, створену потім під shop, аналітик читає одразу, застосунок ні. Умова прив’язана до ролі-творця, тож міграція під іншим логіном її не побачить. Права не обмежують сесій, а годинний запит аналітика займає з’єднання застосунку (модуль 6), тому на роль вішають ліміти: ALTER ROLE shop_analyst CONNECTION LIMIT 3, SET statement_timeout = '30s', застосунку SET idle_in_transaction_session_timeout = '10s'. Додайте REVOKE CONNECT ON DATABASE … FROM PUBLIC: у контейнері shop_analyst без жодного GRANT зайшов у базу postgres.

Контрольований прохід: SECURITY DEFINER

Section titled “Контрольований прохід: SECURITY DEFINER”

Підтримка не має UPDATE на customers, але мусить стирати покупця на його вимогу. Функція SECURITY DEFINER виконується з правами власника, тож дає одну дію замість права на таблицю.

СпробуйPostgreSQLCtrl+Enter — виконати

Без SET search_path функція шукає customers у схемах викликача, і той підсуне власний об’єкт, що виконається з правами власника. Без REVOKE … FROM PUBLIC функцію викличе будь-хто, бо EXECUTE на нову функцію видано всім. Під shop_support прямий UPDATE customers відхиляється, а erase_customer(2) проходить.

Права на таблицю діляться на «все» й «нічого», а продавець має бачити лише свої товари й замовлення з його позиціями в тих самих таблицях. Row-level security (RLS) додає до кожного запиту умову на рядки: її вмикають на таблиці, а правила задає CREATE POLICY.

Роль shop_seller використовує довірений шар застосунку: після автентифікації продавця він у кожній транзакції виконує SET LOCAL app.seller_id = '5', і це працює через PgBouncer у режимі transaction (модуль 6). Політика на order_items читає products, а той уже відфільтровано, тож умови нашаровуються. current_setting(…, true) не падає, коли параметра немає, а nullif знімає порожній рядок, що лишається після SET LOCAL: незадане значення дає NULL і нуль рядків.

СпробуйPostgreSQLCtrl+Enter — виконати

На medium із 35 000 товарів:

Хто читає products Рядків
shop_seller, app.seller_id = 5 31
shop_seller без параметра; shop_app, якому політики не дали 0
shop, власник таблиці 35 000
shop після FORCE ROW LEVEL SECURITY 0
postgres (суперкористувач) навіть з FORCE; роль із BYPASSRLS 35 000

Увімкнена RLS без політики відмовляє всім: shop_app після ENABLE побачив нуль товарів, тож решті ролей потрібні політики USING (true). Власник обходить RLS, доки немає FORCE, а суперкористувач і BYPASSRLS завжди, тож міграції й бекапи бачать усе. Представлення виконується з правами власника: seller_summary над products віддавало продавцю всі 600 груп, а після ALTER VIEW … SET (security_invoker = true) (PostgreSQL 15) одну. І app.seller_id є звичайним параметром: хто має прямий SQL під цією роллю, виконає SET app.seller_id = '6' сам, тож продавцю з прямим доступом потрібні власна роль і політика на current_user.

Політика входить у кожен запит, тож її видно в плані. Підзапити політик лишаються hashed SubPlan, у JOIN вони не перетворюються. Для продавця 5 на medium count(*) з orders читає 8 344 буфери й іде 177 мс, а індекси на products(seller_id) та order_items прискорюють лише підзапит (130 мс). Виграш дає ключ власника в кожній таблиці, до якого політика звертається напряму, як ключ шардування в модулі 13: з seller_id в order_items й індексом (seller_id, order_id) count(*) з order_items читає 4 буфери за 0.04 мс, а «20 останніх замовлень» іде 1.8 мс проти 0.4 мс без RLS.

Персональні дані і стирання

Section titled “Персональні дані і стирання”

ПД у «Крамниці»: customers.email, full_name, birth_date, settlement_id; вільна orders.customer_note; поведінка в view_events.customer_id; sellers.name для ФОП. Копії лежать у бекапах, архіві WAL, репліках, журналах і системах, куди дані доставили. Це не юридична порада: з двох актів виводяться вимоги до інженерії, а текст читайте в першоджерелах (GDPR, Закон № 2297-VI). GDPR: мінімізація й обмеження строку зберігання (стаття 5), право на стирання (17), захист за задумом (25), безпека обробки (32), повідомлення про витік за 72 години (33). Закон України: статті 6, 8, 15 (видалення або знищення) і 24 (захист); редакція від 14.06.2025, а законопроєкт № 8153 на заміну йому, наскільки нам відомо, не ухвалений.

Для бази це чотири вимоги. Не збирати зайвого: birth_date має бути потрібна задачі. Мати одну процедуру стирання, як erase_customer (модуль 5). Видавати доступ за ролями. І рахувати з копіями: стерті дані лишаються в бекапах і архіві WAL, доки копії не ротуються, а відновлення їх повертає, тож потрібні відомий строк зберігання копій і список стертих покупців, який повторно застосовують після відновлення (модуль 11). Про базу, відкриту в інтернет без автентифікації, див. розбір у модулі 16.

Шифрування в спокої і аудит

Section titled “Шифрування в спокої і аудит”

Шифрування диска чи тому хмари (LUKS, KMS) захищає від викрадення накопичувача, але не від доступу до змонтованої файлової системи. pgcrypto шифрує окремі колонки, та ключ мусить прийти на сервер. Ключ поруч із даними видно одразу: з log_statement = 'all' у журналі з’явився statement: SELECT pgp_sym_decrypt(…, 'secret-key-123') разом із ключем, а ключ на тій самій машині чи в тій самій базі потрапляє в дамп із шифртекстом. Шифрування колонки щось дає, лише коли ключ живе окремо (KMS, хмарний модуль 12) або шифрує клієнт.

Аудит починається з log_statement (none, ddl, mod, all), який задають і для ролі: ALTER ROLE shop_support SET log_statement = 'mod'. Межі видно в експерименті: UPDATE orders … від shop_support у журнал потрапив, а SELECT erase_customer(5) із трьома UPDATE усередині ні, бо зовнішній запит є SELECT. Журнал містить значення, тобто ПД, тож і сам потребує захисту й строку. Розширення pgaudit пише структуровані записи за класами READ, WRITE, ROLE, DDL; у нашому образі його немає, тож це за документацією.

Автентифікація і шифрування в русі

Section titled “Автентифікація і шифрування в русі”

Хто й звідки підключається, вирішує pg_hba.conf: діє перший рядок, що збігся за типом з’єднання, базою, роллю й адресою; правила й помилки синтаксису до перезавантаження показує pg_hba_file_rules. У контейнері для сокета стоїть trust, тому psql -U postgres усередині нього входить без пароля, а решта йде через scram-sha-256. scram-sha-256 є типовим значенням password_encryption з PostgreSQL 14: у pg_authid лежить SCRAM-SHA-256$4096:…, значення для перевірки, а не пароль. MD5 у PostgreSQL 18 застарілий і видає попередження при встановленні пароля.

Пароль захищає вхід, а не те, що летить мережею після нього. Для цього є TLS, а вимогливість клієнта задає sslmode. Типовий prefer мовчки переходить на відкритий текст, якщо TLS немає. У контейнері видано сертифікат для db.shop.test справжнім УЦ, а потім підмінено його сертифікатом зловмисного УЦ з тим самим іменем; клієнт довіряє лише справжньому кореню.

Сервер sslmode Результат
без TLS prefer, require відкритий текст; server does not support SSL
справжній сертифікат require, verify-full TLSv1.3, TLS_AES_256_GCM_SHA384
справжній, підключення за 127.0.0.1 verify-full … does not match host name "127.0.0.1"
зловмисний УЦ, те саме ім’я require / verify-full з’єднано / certificate verify failed

Остання пара рядків і є MITM: канал зашифровано, але до того, хто його тримає. require не питає, чий це сертифікат, тож від підслуховування захищає, а від підміни сервера ні. verify-full перевіряє ланцюжок до довіреного кореня й ім’я, як розібрано в модулі 15 курсу мереж. У продакшні ставлять його, а в pg_hba.conf hostssl замість host.

Експлуатація: що вимірювати

Section titled “Експлуатація: що вимірювати”
Метрика Що означає Модуль
pg_stat_statements: сумарний час за запитом що з’їдає базу, а не що найдовше 6, 9
лаг реплікації і слоти що втратиться при failover (перемиканні на репліку); покинутий слот заповнює диск мастера 12
n_dead_tup VACUUM не встигає або його стримує давня транзакція 10
age(datfrozenxid) проти autovacuum_freeze_max_age відстань до зупинки запису через wraparound 10
pg_stat_checkpointer: num_requested проти num_timed точки викликає max_wal_size, а не час 11
pg_stat_archiver.failed_count архів WAL мовчить, PITR уже не працює 11
з’єднання за станами, найстаріша idle in transaction пул вичерпано або транзакція тримає знімок 6, 10
blks_hit проти blks_read робочий набір не вміщається в кеш 7

Як це насправді: моніторинг

Section titled “Як це насправді: моніторинг”

pgbench (8 клієнтів, 10 с) на базі, де одна сесія відкрила BEGIN і мовчить. pg_stat_statements і репліка є лише в контейнері, тож запити для Docker. Перший, SELECT calls, total_exec_time, query FROM pg_stat_statements ORDER BY total_exec_time DESC, показав два UPDATE гарячих рядків pgbench_branches і pgbench_tellers по 92 246 викликів (64 с і 19 с сумарно). Далі мертві версії й сесії:

SELECT relname, n_live_tup, n_dead_tup, last_autovacuum IS NOT NULL AS vacuumed
FROM pg_stat_user_tables ORDER BY n_dead_tup DESC LIMIT 2;
SELECT state, count(*) AS n, max(now() - xact_start) FILTER (WHERE state = 'idle in transaction') AS oldest_tx
FROM pg_stat_activity WHERE backend_type = 'client backend' GROUP BY state;
relname | n_live_tup | n_dead_tup | vacuumed
------------------+------------+------------+----------
pgbench_branches | 5 | 92246 | t
pgbench_tellers | 50 | 92246 | t

Автовакуум працював, але нічого не міг прибрати: знімок відкритої транзакції ще бачив ті версії (модуль 10). Діагноз ставить другий запит: одна сесія idle in transaction віком 13 с. Лікує idle_in_transaction_session_timeout. Решта метрик: репліка була streaming із лагом 0 байтів, слот replica1 reserved, age(datfrozenxid) 92 517 (0.05 % від ліміту), failed_count 0, влучання в кеш 99.9 %.

SELECT state, pg_wal_lsn_diff(sent_lsn, replay_lsn) AS lag_bytes, replay_lag FROM pg_stat_replication;
SELECT slot_name, wal_status, pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn) AS retained FROM pg_replication_slots;
SELECT datname, age(datfrozenxid) FROM pg_database ORDER BY 2 DESC LIMIT 1;
SELECT num_timed, num_requested FROM pg_stat_checkpointer;
SELECT archived_count, failed_count FROM pg_stat_archiver;
SELECT round(100.0 * blks_hit / (blks_hit + blks_read), 2) AS hit_pct FROM pg_stat_database WHERE datname = current_database();

Тривожить динаміка: слот, що росте щогодини, або failed_count більший за нуль. Що з цього бере на себе провайдер, див. хмарний модуль 7.

Оновлення і відновлення як процедури

Section titled “Оновлення і відновлення як процедури”

Мінорне оновлення (18.5 на 18.6): зупинка, заміна бінарників, запуск; формат даних той самий. Мажорна версія підтримується п’ять років: PostgreSQL 14 отримає останній реліз 12 листопада 2026 року. Мажорне оновлення змінює формат каталогу, і шляхів три. Дамп і відновлення (модуль 11): найпростіше й найдовше. pg_upgrade перетворює каталог на місці: з --link він ставить жорсткі посилання замість копій, тож швидкий, але після старту нового кластера старий непридатний. У 18 він переносить статистику оптимізатора (крім розширеної), решту дозбирає vacuumdb --analyze-in-stages --missing-stats-only. Майже без простою можна оновити через логічну реплікацію (модуль 12): дані переносяться на сервер нової версії, клієнтів перемикають, коли підписник наздогнав; DDL і послідовності вона не переносить.

Копія, яку не відновлювали, не копія (модуль 11), тож відновлення роблять за розкладом: базова копія й архів WAL розгортаються на чистій машині на момент часу, контрольні запити (кількість замовлень, найсвіжіший запис) перевіряють результат, а тривалість записують як справжній RTO. Бекапи й відмовостійкість можна віддати провайдеру (хмарний модуль 13), а перевірка відновлення й права доступу лишаються вашими.

Вибір починається з патерну доступу, а не з назви системи. Числа в таблиці виміряно в зазначених модулях.

Задача Патерн доступу Модель і система Виміряно Вистачить PostgreSQL?
замовлення, платежі транзакція над кількома рядками реляційна (2–5, 10) у MongoDB транзакція вдвічі дорожча (16) так, це її задача
кошик, сесії ключ → значення, TTL Valkey (14) 200 тис. операцій/с так, поки не гарячий ключ
каталог з різними атрибутами документ jsonb + GIN або MongoDB (16) medium, GIN: 11 буферів, 0.09 мс так, без другої системи
події переглядів дописування, вікно за часом партиції (13), Scylla (17) тиждень, small: 53 буфери проти 712 так, доки запис вміщається на вузол
рекомендації зв’язки змінної глибини CTE або Neo4j (18) глибина 6, medium: 15.9 мс проти 8 мс так; граф виграє на шляху
аналітика скани мільйонів рядків DuckDB, ClickHouse (19) у модулі 19 не на мільярдах рядків
пошук і схожість морфологія, ембединги tsvector, pgvector або OpenSearch (20) medium: GIN 82 буфери проти 2760 в ILIKE; HNSW 0.26 мс проти 7.5 мс точного так, доки векторів не десятки мільйонів

Друга база додає синхронізацію, і вона ламається, якщо її робити «двома записами» з коду.

Подвійний запис: після фіксації в PostgreSQL процес гине, і пошук не отримує зміни. Outbox: подія лежить у тій самій транзакції, ретранслятор доставляє її принаймні раз.Подвійний записзастосунокPostgreSQLєValkey / пошукнемає: бази розійшлися1. COMMIT замовленнязбій процесу2. запис у другу системуOutbox і CDCзастосунокPostgreSQLзамовлення + outbox, одна транзакціяValkey / пошукретранслятор / CDC з WALповтор безпечний: обробка ідемпотентна
Угорі збій між двома записами лишає системи різними. Внизу подія комітиться в тому самому COMMIT, а далі її доставляють із повтором.

Процес міг загинути між COMMIT у PostgreSQL і записом у пошук, тож пошук про зміну не дізнається; зворотний порядок покаже замовлення, якого немає, а try/catch не лікує, бо відкат у другій системі теж може не дійти. Надійно зводити зміну до однієї транзакції: замовлення й рядок події в outbox комітяться разом, а окремий процес доставляє подію. Його пишуть на FOR UPDATE SKIP LOCKED (модуль 10) або беруть зміни з WAL: логічне декодування (модуль 12) на UPDATE orders SET status = 'confirmed' віддало в слоті BEGIN, table public.orders: UPDATE: order_id[bigint]:21728 … і COMMIT. Доставка йде принаймні один раз, тож споживач має бути ідемпотентним, а системи рано чи пізно збігаються (eventual consistency, модуль 13).

Звідси ціна окремої бази: ще один бекап зі своїм відновленням, моніторинг, набір збоїв і команда.

Гарячий лічильник: UPDATE hot_counter SET n = n + 1 WHERE id = 1 із 16 клієнтів pgbench дає 1 846 транзакцій за секунду, бо кожне оновлення чекає блокування рядка, а INCR у Valkey із 16 клієнтами на тій самій машині 225 тисяч. Розкласти лічильник на 16 рядків піднімає PostgreSQL до 11 029, як розщеплення гарячого ключа в модулі 13. Порівняння нерівне: COMMIT у PostgreSQL чекає fdatasync, а Valkey із типовими налаштуваннями ні (модуль 14). Тому лічильники й топи, які можна перерахувати, кладуть у пам’ять, а гроші ні. Друга межа: запис, що не вміщається на один вузол; шардинг платить транзакціями й гарячими ключами, на 64 шардах за product_id найбільший шард у 4.81 раза більший за середній (small, модуль 13).

Порядок такий. Почати з PostgreSQL і вбудованого в нього: jsonb, партиції, tsvector, рекурсивні CTE. Вимірювати на реальному навантаженні. Виносити в окрему систему лише те, що вимір назвав вузьким місцем, і разом із нею планувати синхронізацію, відновлення та моніторинг.

Типові помилки розуміння

Section titled “Типові помилки розуміння”

«Увімкнули RLS, і продавець бачить лише своє». Власник без FORCE, суперкористувач, BYPASSRLS і представлення з правами власника бачать усе.

«sslmode=require шифрує, тож безпечно». Сервер із сертифікатом зловмисного УЦ проходить require, і лише verify-full його відкидає.

«Закрили DELETE, і доступ закрито». PUBLIC має CONNECT і EXECUTE за замовчуванням: аналітик зайшов у postgres без жодного GRANT.

«Покупця стерли, і дані зникли». Вони лишаються в бекапах, архіві WAL, журналах і системах, куди їх доставили.

Перевір себе

1. Після `ENABLE ROW LEVEL SECURITY` на `products` і політики для `shop_seller` застосунок під `shop_app` бачить нуль товарів. Чому?
2. Клієнт із `sslmode=require` підключився до сервера з сертифікатом невідомого УЦ. Що це означає?
3. Чим небезпечна `SECURITY DEFINER` без `SET search_path` і `REVOKE EXECUTE … FROM PUBLIC`?
4. Автовакуум працює, але в таблиці з п’ятьма рядками `n_dead_tup` дорівнює 92 246. Що перевірити першим?
5. Застосунок пише замовлення в PostgreSQL, потім окремим викликом у пошук, і процес гине між ними. Що надійніше?

Лабораторної до модуля немає. Увесь набір L1–L12 разом складає шлях курсу: від запиту й схеми через транзакції, відновлення й реплікацію до графа, аналітики й пошуку.