«Чорний понеділок» нашої компанії або як ми пережили 5,5 годин даунтайму та знайшли рішення

Привіт! Я Дмитро Дзюбенко, і вже понад десять років працюю у фінтех-індустрії, з яких вісім — як співзасновник і CTO компанії Corefy. Це B2B SaaS-платформа, що допомагає онлайн-бізнесам керувати всіма аспектами платіжних операцій: здійснювати платежі та виплати, організовувати звітність, а також дає можливість запустити власного платіжного провайдера.

Раніше я ділився досвідом розгортання інфраструктури платіжного процесингу на AWS. Сьогодні ж хочу розповісти про найважчий день в історії компанії Corefy, щоб ви могли навчитися на наших помилках і уникнути подібних ситуацій у себе.

Бекграунд: вибір варіанту реалізації аналітики

У 2018 році Corefy вперше вирушав на WebSummit. Ми розуміли, що, хоча продукт у нас вже був, нічого не зможе краще підкреслити його цінність для потенційних клієнтів, ніж візуалізація процесингових даних за допомогою графіків. Усі люблять дивитися на графіки про гроші, особливо якщо це власні гроші. Тому ми почали шукати можливі рішення реалізації аналітики.

Власний сервіс аналітики

До Corefy ми працювали над іншим проєктом, після якого у нас залишився чудовий дешборд із набором віджетів та можливістю їх кастомізації для користувачів. Спочатку була ідея використати його, однак через велику кількість вимог до кастомізації графіків у нас вийшло б щось на кшталт власної Grafana, тож від цього задуму вирішили відмовитися.

Grafana

Це рішення здавалося ідеальним. Ми навіть створили PoC, який інтегрував відрендерені графіки Grafana в наш дашборд. Графіки вставлялися в нього через Embedded, а через нашу nginx-проксі користувачам передавалися вже відрендерені зображення. Користувач навіть не помітив би, що під капотом працює Grafana. Таке рішення було досить легко масштабувати, проте рівень інтерактивності залишав бажати кращого — це були статичні графіки, в яких неможливо було нічого змінити.

Redash

Цей open-source сервіс був написаний на Python і ми могли досить легко інтегрувати його з нашим продуктом. Крім того, він мав значну перевагу над Grafana — можливість експорту аналітичних даних у форматах .csv та .xls, що дуже зручно для ведення фінансової звітності будь-якої компанії. Сервіс також відмінно відповідав нашим вимогам щодо UX і підтримував сповіщення, які користувачі могли налаштовувати через зручний інтерфейс. Не менш важливим фактором на той момент було те, що його вартість становила 0 USD.

Зваживши переваги та недоліки кожного з цих варіантів, ми вирішили зупинитися на Redash.

Запуск сервісу з Redash

Для того, щоб система могла будувати графіки, необхідно було забезпечити підключення до бази даних. Однак, якщо база даних є спільною для всіх клієнтів, виникає проблема з розмежуванням прав доступу — одна організація не повинна мати доступ до даних інших. Оскільки ми використовували базу даних PostgreSQL, розглядали три можливі варіанти:

  • Row Level Security (RLS). Цей інструмент дає змогу динамічно налаштовувати правила доступу до певних рядків даних за допомогою SQL. Наприклад, сапорт може мати доступ до бази даних, але не бачити ордерів на суми понад 100$. Однак RLS працює на рівні даних і вимагає надання доступу на читання всієї схеми БД.
  • View. Створення view, у яких фільтрація здійснюється на основі організації, що дозволяє обмежити доступ до даних тільки для певних користувачів.
  • Окремі storages. Це було б ідеальне рішення, однак ми не були до нього готові з погляду витрат, організації, складу команди.

Зрештою, ми обрали варіант View. Він дозволяв краще контролювати доступ користувачів до конкретних даних, не розкриваючи оригінальну схему БД, яка є нашою інтелектуальною власністю.

Варто підкреслити, що протягом тривалого часу реєстрація у нас була відкритою, і для кожної нової організації автоматично створювався окремий відповідник у Redash з індивідуальним джерелом даних. Запам’ятайте це.

В результаті спрощено сервіс виглядав так:

Щоб користуватися нашим продуктом, юзер повинен створити обліковий запис, прив’язаний до певної організації. Під час реєстрації організації в дашборді автоматично створюється роль у БД, яка дає доступ користувачу до необхідних даних у схемі. Після цього організація інтегрується з Redash, де налаштовується datasource і з’являються дефолтні графіки.

CREATE FUNCTION create_user(in organization_id text, in password_salt text)
  RETURNS text
AS $$
DECLARE
  user_password_salt text = password_salt;
  user_username      text = concat('analytics_', lower(organization_id));
  user_password      text = md5(concat(organization_id, user_password_salt));
BEGIN
    CREATE SCHEMA IF NOT EXISTS analytics;
    EXECUTE format('CREATE ROLE %s WITH ENCRYPTED PASSWORD ''%s''', user_username, user_password);
    EXECUTE format('ALTER ROLE %s WITH LOGIN', user_username);
    EXECUTE format('GRANT CONNECT ON DATABASE %s TO %s', current_database(), user_username);
    EXECUTE format('REVOKE ALL ON ALL TABLES IN SCHEMA public FROM %s', user_username);
    EXECUTE format('GRANT SELECT ON ALL TABLES IN SCHEMA analytics TO %s', user_username);
    EXECUTE format('GRANT SELECT ON ALL SEQUENCES IN SCHEMA analytics TO %s', user_username);
    EXECUTE format('GRANT USAGE ON SCHEMA analytics TO %s', user_username);
    EXECUTE format('ALTER DEFAULT PRIVILEGES IN SCHEMA analytics GRANT SELECT ON TABLES TO %s', user_username);
    EXECUTE format('ALTER ROLE %s SET search_path TO analytics', user_username);
    RETURN user_username;
END
$$
LANGUAGE plpgsql
VOLATILE
SECURITY DEFINER;

Щоб View розумів, що треба обмежувати доступ, ми використовували таку умову:

create or replace view analytics.table as
SELECT id, code FROM methods
WHERE (lower((organization_id)::text) =
replace((CURRENT_USER)::text, 'analytics_'::text, ''::text));

З погляду кінцевого користувача в дашборді з’явилася кнопка, яка дозволяла перейти до зовнішнього сервісу в межах своєї організації. Там він міг створювати власні віджети, експортувати аналітичні дані у форматі .csv та виконувати інші завдання. Сервіс працював бездоганно, особливо з огляду на те, що на той момент в нашій БД було ще мало даних.

Зі зростанням кількості клієнтів ми розуміли, що подальший розвиток значно підвищить навантаження на базу даних, а її вартість може вирости до, як нам тоді здавалося, космічних масштабів. Тож ми вирішили дослідити можливості self-hosted сервісів.

Наш сетап PostgreSQL

Redash пройшов через усі інфраструктурні зміни, описані в попередній статті, включно з роботою на AWS RDS, AWS RDS provisioned disks, EC2 Patroni cluster, і AWS RDS Aurora.

На початку ми використовували AWS RDS, що забезпечувало базову надійність і масштабованість. Проте зі зростанням навантаження та обсягу даних почали відчувати обмеження цієї конфігурації. Тому вирішили перейти на кластер EC2 з Patroni, який надавав більший контроль над налаштуваннями PostgreSQL і дозволяв забезпечити високу доступність за допомогою автоматичного перемикання на репліки в разі збою.

Patroni — це надійне рішення для управління кластерами PostgreSQL з високою доступністю, яке використовує Etcd або Consul для зберігання конфігурації та координації між вузлами. Ця архітектура дозволила масштабувати нашу базу даних відповідно до потреб бізнесу та забезпечити стабільну роботу сервісу.

На момент інциденту ми працювали саме на кластері EC2 з Patroni. Архітектура нашої платформи виглядала так:

Про Vacuum

У PostgreSQL є такий прекрасний механізм, як Multiversion Concurrency Control (MVCC). Саме він робить PostgreSQL надійним в плані послідовності виконання ваших транзакцій на основі присвоєних їм ідентифікаторів. Завдяки цьому механізму в PostgreSQL зберігається кожна версія рядка, і залишається в таблиці до моменту, поки не буде видалена.

Тому існує така мейнтенанс-процедура як Vacuum, яка вичищає всі старі версії рядків. У неї є дві цілі: скинути ідентифікатори цих транзакцій, щоб запобігти переповненню лічильника транзакцій (wraparound), та звільнити місце, зайняте видаленими або оновленими рядками. Vacuum проходить по таблицях і видаляє рядки, які більше не потрібні, дозволяючи базі даних повторно використовувати цей простір.

Про Wraparound

У PostgreSQL кожна транзакція отримує унікальний ідентифікатор (Transaction ID або XID), який є 32-бітним числом. Це означає, що після приблизно чотирьох мільярдів транзакцій лічильник XID здійснює wraparound і починає знову з нуля.

Якщо не виконувати регулярний anti-wraparound Vacuum, щоб оновити метадані рядків і помітити старі транзакції як завершені, база даних може втратити можливість визначати, які рядки є видимими для поточних транзакцій. Це може призвести до пошкодження даних або збоїв у роботі бази даних.

Щоб запобігти цим проблемам, PostgreSQL автоматично запускає процеси autovacuum для обслуговування таблиць. Якщо ці процеси не можуть виконатися, наприклад, через вимкнення autovacuum або блокування таблиць, база даних може перейти в режим лише для читання.

Забезпечення надійності сервісу

Для гарантованої стабільності та безперебійної роботи сервісу ми розробили комплексний підхід до забезпечення надійності, який містив такі ключові компоненти.

План відновлення після аварій (Disaster Recovery Plan, DRP) з використанням бекапів та відновлення (Backup-Restore). Ми передбачили регулярне створення резервних копій усіх критичних даних та конфігурацій системи. Ці бекапи зберігалися в захищених сховищах і регулярно перевірялися на цілісність. У разі серйозних збоїв або втрати даних, ми могли швидко відновити роботу сервісу, мінімізуючи час простою та запобігаючи втраті важливої інформації. Важливою частиною DRP було також періодичне тестування процесів відновлення, щоб упевнитися в їхній ефективності та готовності до реальних інцидентів.

Автоматичне перемикання на репліку (Replica Failover). Для забезпечення високої доступності бази даних використовували реплікацію на декілька серверів. Основний сервер бази даних постійно синхронізувався з репліками, що дозволяло зберігати актуальність даних у режимі реального часу. У випадку відмови основного сервера, система автоматично перемикалася на одну з реплік без втрати даних і з мінімальним впливом на користувачів. Такий механізм забезпечував безперервність сервісу та підвищував його стійкість до апаратних або програмних збоїв.

Створення знімків системи (Snapshots). Ми регулярно робили знімки стану наших серверів та баз даних. Знімки дозволяли швидко відкотитися до стабільної версії системи у разі непередбачених проблем, таких як критичні помилки при оновленнях або впровадженні нових функцій. Використання знімків прискорювало процес відновлення окремих компонентів і зменшувало час простою, оскільки не вимагало повного відновлення з бекапів.

Ми були впевнені, що регулярні бекапи, снепшоти та фейловер захистять нас від будь-яких проблем. Але цей інцидент показав нам, що навіть найбільш продумані плани можуть виявитися недостатніми без урахування найгірших можливих сценаріїв.

День інциденту або наш «чорний понеділок»

2 листопада 2020 року — дата, яку ми ще довго памʼятатимемо. Це мав би бути звичайний понеділок, але все пішло інакше.

О 9:00 записи в процесинговій БД перестали оброблятись, але ми про це ще не знали. Повідомлення в загальний чат інцидентів надійшло лише о 9:14.

Одразу після того, як я це прочитав, подумав, що проблема лише в тому, що одна з реплік відстала, і дані на дешборді Grafana просто не оновлюються. Я вирішив, що якщо ми просто перемкнемо datasource в Grafana на primary, все налагодиться. Але нічого не налагодилося, а в логах з’явився такий рядок:

SQLSTATE[54000]: Program limit exceeded: 7 ERROR:  database is not accepting commands to avoid wraparound data loss in database "paycore_production"
HINT:  Stop the postmaster and vacuum that database in single-user mode. You might also need to commit or roll back old prepared transactions, or drop stale replication slots.

Це найстрашніший лог у моєму житті, особливо з огляду на те, що на сервері вже було 2 бази даних по 2 ТБ.

Тож о 9:24 ми вже зрозуміли, що маємо серйозні проблеми, і почали робити все можливе, щоб їх вирішити.

Ми спробували запустити процедуру failover, але з’ясувалося, що дані на всіх репліках та всі снепшоти були пошкоджені на фізичному рівні.

Через години спроб ми вирішили йти іншим шляхом — видалити всі історичні дані. Для відновлення процесингу на той момент нам потрібні були дані конфігурації, дані доступу для роботи з різними системами, збережені карти та клієнти. Тому ми застосували команду truncate table для всіх інших даних, які не були критично важливі на той момент. Це дозволило скоротити обсяг бази даних до 20 ГБ, після чого ми запустили vacuum full, зробили дамп і розгорнули базу на одному з серверів нашого кластеру.

О 14:27 процесинг запустився, і робота була відновлена. Протягом наступного місяця ми поступово, невеликими частинами, переносили ті 2 ТБ даних у робочу БД.

Розслідування інциденту

Нам було важливо зрозуміти ключову причину цього інциденту, особливо враховуючи, що в самому PostgreSQL існують механізми для запобігання таким ситуаціям. Перш за все ми почали перевіряти параметри vacuum і увімкнули його на певних таблицях, де раніше відключили вручну, оскільки використовували pg_repack для запуску vacuum у зручний для нас час.

Саме по собі відключення vacuum на таблицях не могло призвести до таких наслідків. Тому, поки ми відновлювали історичні дані, паралельно шукали в логах причину, чому не спрацював anti-wraparound.

Причини, які нам вдалось ідентифікувати:

  • Після переїзду на потужніший кластер БД не було проведено модифікацію параметрів autovacuum для таблиць.
  • Не була увімкнена опція log_autovacuum_min_duration, яка б виявляла проблеми із запуском процесу autovacuum.
  • Метрики для autovacuum не знімалися.
  • При переході на уніфіковану систему моніторингу pgwatch ми втратили метрики по dead_tuples/live_tuples.

Ліквідація наслідків

Після аналізу причин простою тривалістю п’ять з половиною годин ми розробили план усунення наслідків та впровадили заходи, які б допомогти запобігти подібним інцидентам у майбутньому, зокрема:

  • Відновлення даних для забезпечення нормальної роботи клієнтів.
  • Виведення даних про dead_tuples/last_autovacuum у систему моніторингу.
  • Налаштування кожної процесингової таблиці з індивідуальними опціями для процесу autovacuum.
  • Налаштування логічної реплікації на одну БД.
  • Розробка плану повного відновлення роботи системи у разі виходу з ладу БД.
  • Впровадження культури моделювання інцидентів.
  • Розподіл клієнтів на різні групи інфраструктур (що є особливо важливо для B2B SaaS-бізнесу).

Також, щоб запобігти подальшим проблемам на фізичному рівні, ми вирішили створити ще одну логічну репліку. У випадку повторення подібної проблеми, навіть якщо ми не знатимемо її точну причину, це дозволить мати додатковий невеликий сервер із логічною реплікацією таблиць для швидкого відновлення процесингу. У майбутньому це рішення допоможе автоматизувати дії, які під час інциденту ми виконували вручну.

Тоді це здавалося нам єдиним здоровим рішенням і досить довго ми з ним жили.

Хто винен

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

Готуючись до виступу на Highload fwdaysʼ24, я вирішив провести власне розслідування, в ході якого переглянув усі Release Notes PostgreSQL, які були актуальні на момент інциденту. І саме там, у версії 13.10, я знайшов справжню причину — помилка «wrong tuple length» наприкінці виконання Vacuum виникала, якщо Vacuum потрібно було оновити значення datfrozenxid поточної бази даних, а база даних мала стільки наданих привілеїв, що її значення datacl було винесено за межі основної таблиці.

Як я вже згадував, реєстрація користувачів у нас була відкрита, і з часом їх кількість перевищила 600. Саме це і стало критичним моментом.

У цьому тестовому середовищі за допомогою Docker compose ви зможете відтворити проблему, підставивши версію PostgreSQL 13.9.

Висновки: сподівайся на краще, готуйся до гіршого

Маючи дві репліки і WAL-и, ми були впевнені, що наш Recovery Point Objective становить лічені секунди. Втрата даних для нас була неприпустимою, і, по суті, цього не сталося.

Проте, переживши той інцидент, усвідомили свій найбільший прорахунок — ми не мали уявлення, скільки часу займе відновлення бази даних розміром у 2 ТБ з бекапу, бо завжди використовували снепшоти й доливання даних з wal-g з S3. Лише згодом зрозуміли, наскільки важливо регулярно перевіряти Recovery Time Objective, особливо з урахуванням зростання обсягу баз даних.

Тож завжди сподівайтесь на краще, але готуйтесь до гіршого.

Зима близько — збираємо на обігрівачі

Дякую кожному, хто дочитав статтю до цього моменту, і запрошую вас долучитися до збору на обігрівачі, аби разом подякувати захисникам, які дають нам можливість продовжувати працювати й обмінюватися досвідом.

Скоро настануть холоди, до яких потрібно бути готовими, тож ми збираємо гроші на три автономні обігрівачі Webasto для ЗСУ.

Посилання на банку: send.monobank.ua/jar/56w6JJkA31
Номер картки банки: 5375 4112 2267 6913

Ще раз дякую і залишаємось на звʼязку!

Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті

👍ПодобаєтьсяСподобалось31
До обраногоВ обраному10
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Ми були впевнені, що регулярні бекапи, снепшоти та фейловер захистять нас від будь-яких проблем

Схоже мав кілька років тому. Це був малий стартап, де була AWS RDS з MySQL.
Снепшоти робились часто, але в якийсь момент база крешанула (вже не памятаю що було, гугл казав ресторити бекап).
Добре щоб був канал з автопостінгом помилок з проду в слаку, за 5 хв я вже пробував відресторити снепшот, а воно не працює. Поробував старіший — теж не працює.
Повезло що зранку робив дамп бази для репродюсу баги. Відресторив його. А за тих кілька втрачених годин було лише кілька ордерів від лише існуючих юзерів.
І я їх вручну в базу подобавляв на основі імейлів з підтвердженням ордеру і даних про оплату в страйпі.

Часто згадую цю проблему коли на ентерпрайзі треба кілька днів чекати апрув на деплой хот фікса=)

У 2017р GitLab помилково дропнув свою продакшен БД, теж на Postrge. У них було 5 різновідів бекапів, включаючи бекапи віртуальних машин з самою БД. Коли вони спробували скористатись хоч якимось — виявилось, що насправді жодна з бекап процедур не працювала корректно, ніхто про це не знав, тож робочого бекапу бази в них не було. Ситуацію врятувало те що хтось з команди зробив собі копію прод бази на локальний комп для розробки чи тестування за кілька годин до інциденту. Але данні всіх клієнтів за кілька годин проміжку часу були втрачені.

І вони стрімили процес відновлення роботи сервісу. Протягом 5.5 годин у нас була можливість з цього приводу пожартувати :)

А зараз ви теж на Redash? Ми з нього на Metabase переїхали десь рік тому.

Закриваємо його потихеньку. Задачу з аналітикою вирішуємо по іншому — не all in one інструмент, а через data source, а клієнт вже користується чим хоче.

З приводу Redash, осі ці рухи з view, створенням користувачів, заведенням датасорсів можна було б не робити, і насправді цей інструмент не є розрахованим на embedded використання. Про це прямо сказано ось тут: redash.io/...​oards/sharing-dashboards

Beginning with Redash V8, an embedded dashboard may use parameters. But any user can modify them, which makes Redash the wrong tool for embedded analytics.

Але звісно $0 манить :) але з точки зору придатності як embedded BI (де є все необхідне для secure embedding, ізоляції доступу в SaaS, white labeling і т.д.), можна було б розглянути, наприклад, SeekTable ^_^

Дякую за коментар! Ми redash не використовували, як embedded, проте спроби були. Він працює повністю незалежно від основного customer dashboard, за рахунок безшовних переходів. І дякую за референс на продукт, будемо мати на увазі.

Ого, бачив ваш продукт, але не знав що він український )

Дуже прикрий кейс, після читання доків швидкої склалося враження, що це не достатньо добре задокументовано в плані обмежень і застережень для dba. виглядає як кейс на грані з доволі серйозним багом самого pg. Але для бізнесу такий довгий повний downtime це буде важко пояснити — тому так треба тестувати і відповідно змінювати дизайн системи відповідно до потреб.

У PostgreSQL є такий прекрасний механізм, як Multiversion Concurrency Control (MVCC). Саме він робить PostgreSQL надійним в плані послідовності виконання ваших транзакцій на основі присвоєних їм ідентифікаторів. Завдяки цьому механізму в PostgreSQL зберігається кожна версія рядка, і залишається в таблиці до моменту, поки не буде видалена.

Саме рішення теж викликає питання як ви його тут позиціонуєте — рішення конфліктв це дуже ситуативна задача(у переважній більшості кейсів достатньо просто зафіксувати зміни останнього врайтера) не уявляю що там така система що на кожній таблиці треба резолвати конфлікти , більше виглядає так що не осилили базовий db locking/updlocking і знайшли щось наче краще ніж dirty reads вімкнути — snapshot зробити всюди.

не уявляю що там така система

Там проблeма by design в постргeci, що вони вжe 15+ рокiв костилi лiплять з оптимiзацiями цього вакуума, замiсть того щоб пeрeробити. Воно начe стає кращe, в 17-й вeрсiї знову оптимiзацiй з цього приводу додали, алe ж блiн, я цi проблeми пам’ятаю щe в 2007 роцi в 8 чи 9-й вeрciї.

Дякую за коментар! В статті мені хотілося донести інформацію, що інструменти якими ми користуємося можуть мати дуже неприємні баги і сукупність факторів зіграє проти вас. Саме тому потрібно бути готовим до такого роду сценаріїв і відповідно їх моделювати.

Зміна рівнів ізоляції в цьому випадку нам би нічим не допомогла.

Дешева рибка погана юшка
Або
За дурною головою і ногам нема спокою.

Тут головне які очікування по даун айму

Це дозволило скоротити обсяг бази даних до 20 ГБ

Тобто у вас було 20Гб критично важливих даних для роботи сервісу, і 2Tb всякої-всячини. Чи винесли ви ці 20Гб в окрему БД після інциденту, щоб отримати маленьку гарну операційну БД?

Дякую за прекрасне питання! Коротка відповідь — ні. Є рух в цьому напрямку, проте процес не швидкий.

В момент інциденту ми деякі метрики вичитували ще з Postgresql, а тепер для цього є окрема БД. Тому ми мали мати доступ до історії щоб повноцінно працювала маршрутизація транзакцій і відновленння роботи з 20Гб даних було не повноцінне до заливки хоча б останнього місяця транзакцій.

Дякую за статтю. А який лоад був на БД — райти, ріди, розмір, і тд? І який сервер?

Із радістю поділився б, проте в постмортемі не зафіксовані всі дані. Кластер в той час складався із 3 нод i3en.2xlarge.

Класна стаття!

Пам’ятаю цей інцидент з часів коли ще працював в компанії, хоч і вже давненько було. Окрема історія це комунікація з клієнтами під час таких збоїв.

Дякую, Діма! Хотілося б мати менше таких історій :)

Підписатись на коментарі