en
Feedback
Сергей Озеранский

Сергей Озеранский

Open in Telegram

О разработке, DevOps, DevSecOps, архитектуре, безопасности. Без воды и скучных теорий - только реальный мой опыт, инсайты из практики и немного жизни.

Show more
2 346
Subscribers
-124 hours
-107 days
-4830 days
Posts Archive
При расхождении: ERROR в лог и одна строка adjustment, где баланс до — это сумма леджера, а баланс после — хранимый остаток. Сам остаток не меняется. Баланс — источник истины для того, сколько клиенту можно тратить, и сверка не должна молча менять эту сумму. Она фиксирует разницу и делает так, что леджер снова объясняет остаток целиком. Повторный запуск ничего не найдёт: разница уже записана. В норме таких строк ноль. Каждая — повод искать баг.

Леджер: журнал, которому можно верить Баланс говорит, сколько у клиента денег. Леджер объясняет, откуда взялась эта цифра. Теперь про него: как устроена таблица, почему в неё нельзя записать неверную строку и что происходит, когда одну и ту же операцию присылают дважды. Только добавление Леджер — таблица, в которую можно только вставлять. У репозитория нет методов update и delete для неё. Ошибку в леджере не исправляют, её объясняют новой записью. В каждой строке: тип операции, сумма, баланс до, баланс после, ключ идемпотентности, ссылка на источник (джоба, платёж, возврат) и кто это сделал. Семь типов операций Три уменьшают баланс: debit — списание за джобу, refund — возврат клиенту через платёжного провайдера, reversal — зарезервирован для внутренних отмен. Три увеличивают: credit — пополнение, bonus — начисление сверх оплаты, free_grant — стартовый грант. Седьмой, adjustment, единственный со знаковой суммой: его пишет сверка, о которой ниже. Направление операции задаётся типом, а не знаком. Сумма всегда положительная. Так по одной колонке видно, что это за операция, и нельзя случайно записать "списание на минус пять долларов". Postgres проверяет арифметику На таблице стоит CHECK:

(type IN ('debit','refund','reversal')
   AND balance_after = balance_before - amount)
OR (type IN ('credit','bonus','free_grant')
   AND balance_after = balance_before + amount)
OR type = 'adjustment'
Строку, в которой числа не сходятся, база отклонит, даже если в коде баг. Это последняя линия обороны, и она не зависит от того, кто и откуда пишет. Откуда берутся баланс до и после? В прошлом посте списание делалось одним UPDATE с RETURNING, и именно возвращённое значение попадает в леджер. Одна транзакция: изменили баланс, получили новый остаток, вставили строку с ним. Разойтись эти два числа не могут. Одна операция — одна запись Мир вокруг нашего кода ненадёжен. Платежный провайдер присылает webhook об оплате, не получает ответ вовремя и присылает ещё раз. Воркер списания падает после UPDATE, но до commit, и повторяет цикл. Ретрай после сетевой ошибки. Во всех случаях операция одна, а попыток несколько. Каждая операция несёт уникальный ключ: payment:<id платежа>, job:<id>:period:<начало периода>, refund:<id возврата>, grant:user:<id>. На колонке UNIQUE-индекс. Защита от дублирования на уровне БД. Схема записи:
SAVEPOINT → UPDATE баланса → INSERT в леджер с ON CONFLICT DO NOTHING.
Если INSERT вернул строку, savepoint фиксируется. Если не вернул, ключ уже есть: savepoint откатывается, UPDATE баланса отменяется вместе с ним, а наружу возвращается существующая запись с пометкой "уже применено". Почему не проверить ключ до UPDATE? Потому что между проверкой и вставкой второй процесс успеет сделать то же самое. Арбитром должен быть уникальный индекс, а не код. Savepoint нужен, чтобы откатить только эту операцию, не трогая внешнюю транзакцию: воркер списания держит блокировку на строке джобы, и терять её из-за дубликата нельзя. Леджер это не двойная запись В бухгалтерии каждая операция затрагивает два счёта, и сумма дебетов всегда равна сумме кредитов. У нас single-entry: одна строка, один аккаунт, направление в типе. Платформа ведёт только один вид счёта — обязательства перед клиентами. Выручка, комиссии провайдера и деньги на расчётном счёте живут в бухгалтерии. Если появятся внутренние взаиморасчёты между несколькими видами счетов, double-entry окупится. Пока это усложнение без выгоды. Сверка Баланс — денормализованная сумма леджера. Это ускоряет горячий путь, но два числа могут разойтись, если где-то есть баг. Раз в десять минут воркер считает по леджеру сумму со знаками для каждого аккаунта и сравнивает с хранимым остатком.

Знаковая корректировка — служебный путь, и баланс она не трогает. Её пишет только фоновая сверка баланса с леджером, когда находит расхождение: одна строка, которая объясняет разницу, без изменения остатка. Ручных правок остатка нет вообще: можно начислить бонус, но он пройдёт через тот же путь, что и пополнение, с записью в леджер.

Баланс: как хранить деньги клиента и не ошибиться В прошлом посте разделили биллинг и леджер: баланс разрешает операции, леджер их объясняет. Сегодня про первую половину — таблицу баланса. Одна строка на клиента Баланс — это отдельная таблица с одной строкой на аккаунт. В ней текущий остаток и два накопительных счётчика: сколько всего зачислено и сколько всего потрачено. Счётчики не участвуют в решениях, они для дашборда и аналитики. Решает только остаток. В чём хранить Не во float. Это база: 0.1 + 0.2 в двоичной арифметике не равно 0.3. Баланс меняется каждые несколько секунд работы каждой джобы, это тысячи мелких списаний, и ошибка округления в нём накапливается. Не в центах. Ставка раннера — доли цента за минуту, а списываем мы за секунды. В центах такая операция либо округляется в ноль, либо требует накопителя дробных остатков рядом с балансом. Мы храним целое число микро-долларов: 1 USD = 1 000 000 единиц, BIGINT в Postgres. Целочисленная арифметика точна, а шести знаков после запятой хватает, чтобы за всю джобу накопить погрешность меньше одной единицы. Decimal живёт только на границах: ввод суммы пополнения и вывод пользователю. Побочный эффект: ошибка в значении это значит ошибиться не на проценты, а в миллион раз. Поэтому число 1 000 000 в коде встречается ровно один раз, в модуле с прайсингом, а конвертация в доллары и обратно идёт только через две функции рядом с ним. Всё остальное работает в единицах и о долларах не знает. Одна точка изменения Баланс меняется только в одном классе — репозитории биллинга. Ни один сервис, роутер или воркер не пишет UPDATE в эту таблицу напрямую. Это единственный способ гарантировать, что каждое изменение остатка сопровождается записью в леджер, проверкой лимитов и корректным ключом идемпотентности. Списание одним запросом Классическая ошибка: прочитать баланс, проверить в коде, что хватает, и записать новое значение. Между чтением и записью успевает другая операция, и клиент уходит в минус, которого никто не разрешал. У нас проверка и изменение — одно выражение:

UPDATE tenant_billing
SET credits_balance = credits_balance - :amount
WHERE tenant_id = :id
  AND credits_balance - :amount >= -:buffer
RETURNING credits_balance
Postgres берёт блокировку на строку, проверяет условие уже под ней и возвращает новый остаток. Два параллельных списания выстраиваются в очередь, второе видит результат первого. Никаких SELECT FOR UPDATE и логики сравнения в приложении. Если запрос затронул ноль строк, это одно из двух: аккаунта нет или денег не хватает. Различаем отдельным запросом и поднимаем разные ошибки. Зачем буфер Обратите внимание на -:buffer в условии. Баланс может уйти в минус, но не ниже $1. Причина в природе продукта. Проверки денег на старте джобы нет: пока баланс в порядке, раннеры клиента доступны, и джоба стартует. Дальше она работает минуты, а списание идёт по ходу. Если ровно на нуле отклонить очередное списание, джоба упадёт на середине, клиент потеряет и время, и уже потраченные деньги. Буфер даёт ей доработать. При уходе ниже нуля аккаунт получает отметку блокировки, и его раннеры ставятся на паузу: новые джобы не получают исполнителя, работающие платформа не прерывает. Отметка ставится один раз и не перетирается повторными списаниями, так что видно, когда именно клиент ушёл в минус. Пополнение, выводящее баланс в плюс, отметку снимает. Кто решает блокировать, а кто выполняет — разные слои. Репозиторий умеет только списать и вернуть остаток. Решение "остаток отрицательный, значит блокируем" принимает сервис. Так политику можно менять, не трогая механику. Три способа уменьшить баланс Обычное списание с буфером — для джоб. Увеличивает счётчик потраченного. Принудительное списание без буфера — для возвратов. Клиент запросил refund после того, как потратил часть пополнения; возврат всё равно применяется целиком, баланс уходит в глубокий минус, аккаунт приостанавливается. Счётчик потраченного не трогается: возврат — это не расход.

Как устроены биллинг и леджер в tempus.build tempus.build продаёт минуты CI-раннеров по предоплате. Списание посекундное, ошибки в деньгах недопустимы, а каждая финансовая операция должна быть прозрачна для аудита. Начну с теории, а в следующем посте покажу, как это реализовано. Немного теории В заголовке два слова: биллинг и леджер. Это два связанных инструмента, но самодостаточных и решающих разные задачи. И прежде чем разбирать их, важно развести ещё одну вещь: биллинг не проводит платежи. Проведением платежей занимается платёжный провайдер: авторизует карту, списывает деньги, делает расчет. Для tempus.build это внешняя система. Биллинг узнаёт от неё только факт: "от клиента X пришло N денег" — и с этого момента начинается его работа. Биллинг — это тарификация и учёт. Он знает, сколько стоит секунда раннера, зачисляет пополнения, списывает за выполненные джобы и отвечает на вопрос "сколько у клиента денег сейчас и хватит ли на эту операцию". Его рабочий инструмент — баланс: одно число, которое читают перед каждой операцией и меняют после. Деньги в реальном мире биллинг не двигает, он ведёт их учёт внутри продукта. Леджер отвечает на вопрос "как мы к этому числу пришли". Это история: журнал всех движений, каждое с суммой, направлением и причиной. В бухгалтерии этой идее очень много лет: ни одна запись в книге не стирается, ошибка исправляется новой записью. Зачем разделять баланс и леджер? Потому что у них разные, местами противоположные требования. Состояние должно быть быстрым и атомарным. Перед стартом джобы нужно за один запрос узнать, хватает ли денег, и сразу их зарезервировать, пока конкурирующая операция не сделала то же самое. История должна быть неизменяемой и полной. По ней разбирают споры с клиентом, сверяют платежи с платёжными системами и банками, ищут баги в расчётах, строят аналитику. Если её можно править задним числом, ей нельзя верить. Это единственный источник истины обо всех движениях средств по балансу аккаунта. Если хранить только баланс, при любой аномалии нечем объяснить, откуда взялась цифра. Если хранить только историю, каждая проверка "хватает ли денег" превращается в SUM по всей истории под блокировкой в БД. На горячем пути это неприемлемо. Да и просто неудобно. Поэтому обе сущности живут рядом, но с чёткими ролями: баланс разрешает операции, леджер их объясняет. Главная инженерная задача — чтобы они никогда не разошлись.

Зато точно. Или не очень? 😅 Пишите, в чём храните например, деньги или баллы: float, decimal, integer в минимальных единицах
Зато точно. Или не очень? 😅 Пишите, в чём храните например, деньги или баллы: float, decimal, integer в минимальных единицах или свой вариант.

Пойду смотреть, как и зачем команда Deckhouse сводит свои продукты в одну платформу. Сейчас у них целая экосистема инфраструк
Пойду смотреть, как и зачем команда Deckhouse сводит свои продукты в одну платформу. Сейчас у них целая экосистема инфраструктурных решений. 17.09.26 в 12:00 коллеги объяснят, что меняется. Особенно для тех, у кого рядом живут контейнеры, виртуалки и ИИ-нагрузки. Отдельно обещают сценарии для частных облаков и платформ данных. Если у вас инфраструктура размазана по нескольким средам, послушайте вместе со мной

Если вы все еще хотите разбираться в программировании, а не просто просто нажимать “Yes” в консоле, то нашел интересный репозиторий https://github.com/faif/python-patterns Это коллекция паттернов проектирования и идиом на Python. Глобально, ничего нового там нет. Это в основном классические паттерны «банды четырёх», просто собранные на примерах и в одном месте. Так что, если хочется развиваться, то такое, я считаю, нужно знать даже с LLM.

Хочется обнять всех, кому сейчас тяжело из-за того, что происходит с интернетом. Я не в РФ, но сталкиваюсь с этим регулярно. Чуть больше года назад был в Мск месяц и страдал. И даже близко не представляю, насколько это тяжко сейчас. Держитесь 🫂

Значете, что меня в последнее время все более и более раздражает. То, что приходится решать проблемы, которых раньше и быть не могло, при решении очень простых, базовых, понях задач. Да, это снова пост про яндекс облако. При всем моем уважении к поддержке яндекс (они классно работают, нет претензий), я выполняя казалось бы обычные задачи, сталкиваюсь с необычнми проблеми. И да, не всегда виноват яндекс облако, но так как я работаю с ним, то и упоминаю его. Ситуация такая: Собрал лендинг: Astro (SSG, чистая статика), выкатил на объектное хранилище с website-hosting + CDN перед ним. Открывается мгновенно, Lighthouse зелёный, OG-теги на месте. Все ок. Кидаю ссылку в Telegram - превью не появляется. Открываю Facebook Sharing Debugger - HTTP 403 и приписка "This response code could be due to a robots.txt block. Please allowlist facebookexternalhit on your sites robots.txt config to utilize Facebook scraping". Ну я то знаю что дело не во мне. Ну ок, поехали разбираться (а вообще для такого простого сценария я не должен дебажить это дерьмо) 1. robots.txt? -> User-agent: * / Allow: /. Чисто. Мимо. 2. Может, режется по User-Agent? curl -A "facebookexternalhit/1.1" https://…/ → 200 curl -A "TelegramBot" https://…/ → 200 Оба 200. Не UA. 3. TLS? openssl s_client -> полная цепочка (leaf + 2 промежуточных), verify return code: 0. Тоже не то, да и FB получил именно HTTP 403, а не ошибку рукопожатия, значит соединение успешно установлено и FB получил "нормльный" HTTP ответ. 4. Мигает ли источник под нагрузкой? 40 параллельных запросов напрямую к website-эндпоинту хранилища, в обход CDN -> 40/40 = 200. Все ок. Да и это же CDN для статики! Какая нахрен нагрузка. 5. Может, гео-блок / вообще недоступность извне? Прогнал через check-host из 12 стран (в т.ч. США и Нидерланды, там ДЦ FB и Telegram) -> везде 200. Сторонний OG-скраппер microlink.io (headless-браузер) -> корректно прочитал и title, и картинку. Даже в ВК (прости Господи) OG загрузился. Burst 15 параллельных, HEAD-запрос, отдельно og-image, запрос с Range - все 200. Из моего окружения 403 не воспроизводится ВООБЩЕ ничем. 200 получают: браузеры, curl с любым UA, 12 стран, США, Нидерланды, сторонние скрапперы. А 403 только реальные краулеры Facebook и Telegram. Единственная переменная, которую я не могу подделать это исходные IP-диапазоны (ASN). Еще раз проверил настройки CDN-ресурса: ограничение по IP нет, по странам нет, защита токеном нет. Конфиг пустой. Значит 403 прилетает на уровне edge/сети и фильтруются конкретные ASN краулеров. Ну я пошел дальше в поддержку облака. Ответ был очень ожидаемым: "Со стороны Yandex Cloud фильтраций и ограничений трафика на уровне настроек ваших CDN-ресурсов нет — правила по IP, ASN, User-Agent и Referer не включены. Наблюдаемое поведение похоже на фильтрацию трафика на транзитных сетях за пределами инфраструктуры Yandex Cloud, повлиять на которую с нашей стороны напрямую нельзя." "Заебись у вас хип-хоп", как читали в своем ганста репе группа Триагрутрика.

Сканнеры долбятся. Домену 1 сутки.
Сканнеры долбятся. Домену 1 сутки.

Значит открываем max.ru, логинимся. Во время логина появляется каптча, нажимаем ссылку Terms Of Use и вуаля. Еще один найденн
+1
Значит открываем max.ru, логинимся. Во время логина появляется каптча, нажимаем ссылку Terms Of Use и вуаля. Еще один найденный баг...

Есть такое мнение, что испанцы медленные и вообще пофигисты. Они действительно в чем-то более простые и расслабленные, это общий южный вайб. У меня нет статистики, кто бездельник, а кто нет. Но сфера услуг, по моим ощущениям, может где-то быть ниже, чем та, к которой мы привыкли. Начиная с самого простого — тебе просто не отвечают или долго обещают что-то сделать. Поэтому славяне любят обращаться к славянам. Это дороже, но и результат другой. Хотя, пожив в Испании уже два года, я понял, что не везде нужно идеально. Иногда нужно просто «необходимо и достаточно» (как и в программировании). Но вот к чему я. Я хочу привести два примера, и сразу не поймешь, где испанцы, а где русские. 1. Мне нужен юридический адрес — такая опция есть, вполне легальная, и я могу его арендовать для корреспонденции и указания в официальных документах. Обратился в компанию, специализирующуюся на этом. Первый ответ от них был через 2 недели. Потом я ответил на их вопросы, и вот уже прошло еще две недели — ответа нет. 2. Искал клининг, обратился в 5 компаний, ответ от двух, клининг выполнила в итоге одна. Вторая слилась на этапе общения, не предложив удобного варианта. Первый кейс — это Испания. Второй кейс — это Россия. Как мы видим, пофигизм есть везде.

🤷‍♂️
🤷‍♂️

Безопасность кода не должна держаться на ручных обвязках Многие команды строят процесс разработки на GitLab CE, а недостающие
Безопасность кода не должна держаться на ручных обвязках Многие команды строят процесс разработки на GitLab CE, а недостающие функции безопасности добавляют самостоятельно. В итоге контроль распределяется между репозиторием и CI/CD, а работа с секретами строится на переменных GitLab или самописной интеграции с Vault. Чем больше таких решений, тем сложнее их сопровождение. 28 августа в 12:00 на вебинаре «Единый контур безопасной разработки с Deckhouse: Code + Stronghold» покажем, как решить эти задачи с помощью Deckhouse Code и Deckhouse Stronghold.
Разберём: — push rules, approval rules и CodeOwners; — риски хранения секретов в переменных CI/CD; — нативную интеграцию через JWT/OIDC; — разграничение доступа через bound claims; — управление конфигурацией хранилища с помощью GitOps-плагина.
Будет много демо: покажем, как механизмы работают на разных этапах разработки. 👉 Зарегистрироваться

То, чего ожидал вообще меньше всего - документация Python доступна на русском языке. https://blog.python.org/2026/08/the-python-documentation-is-now-available-in-russian/

Получилось - рабочий IP удалось получить с 6-й попытки. Узел в зоне B получил рабочий адрес сразу, с первой попытки. Зона A оказалась сложнее: рабочий IP выдался только после нескольких перевыпусков. По наблюдениям, оба рабочих адреса (в разных зонах) попали в один общий префикс (/17), тогда как все нерабочие адреса в зоне A были из других блоков.

Короче, это казино 🎰 Яндекс.Облако подтверждает, что это фильтрация трафика на уровне систем DPI или ТСПУ со стороны магистральных операторов связи. Единственное что остается перебирать адреса, так как публичный IP-адрес можно получить только автоматически либо выбрать из списка ранее зарезервированных адресов. Отдельного пула адресов с гарантированной доступностью или возможностью выбрать адрес по маршруту до конкретного провайдера в облаке не предусмотрено. Либо такой опции нет для массмаркета.

Каждый раз, когда мне по каким-то причинам на глаза попадается информация о моей потенциальной пенсии по возрасту в Росси, я
Каждый раз, когда мне по каким-то причинам на глаза попадается информация о моей потенциальной пенсии по возрасту в Росси, я все еще немного поражаюсь этой экономики. Никогда не рассчитывал на пенсию. А на сообщение банка хочется ответить: "если бы, да кабы, да во рту росли грибы, тогда бы был не рот, а целый огород"

Помните, я писал, что нас ждут последствия от всего, что происходит с интернетом в России? И что я уже попадал на IP, с которого у VM нет исходящего доступа в глобальный интернет (Cloud.ru)? Вчера словил ещё один кейс, который снова подтверждает эти рассуждения. Поднимал в Yandex.Cloud (да, вы часто слышите от меня про него, но так вышло, я с этим облаком работаю) свежую VM и получил для нее публичный IP. Долго не мог подключиться по SSH. При этом ровно такая же VM в другой зоне работает во всех отношениях. Самое любопытное то, что адрес выглядел полностью живым. Что проходило нормально: - ICMP - ping 0% потерь, но латентность рваная: min/avg/max = 0.224 / 3.286 / 9.403 ms, stddev 4.3 ms. - MTU - пакеты с Don't Fragment до 1500 байт проходили в обе стороны без потерь (то есть это не MTU/фрагментация). - TCP :22 - трехстороннее рукопожатие завершалось: nc -vz -> succeeded, ssh -vvv -> debug1: Connection established. Где вставало: debug1: Connecting to <IP> port 22 debug1: Connection established. Connection timed out during banner exchange TCP-сессия поднималась, но баннер сервера (SSH-2.0-OpenSSH_…, ~40 байт, первые прикладные байты от сервера) до клиента не доходил - соединение висело до таймаута. Что это исключает: не firewall (TCP пускается), не MTU (1500 DF ходит), и не сама машина, так как по serial-консоли sshd был поднят, ssh.socket listening, cloud-init отработал чисто. А соседний узел в другой зоне (другой диапазон адресов) по тому же каналу отвечал идеально: полный баннер OpenSSH_9.6p1, KEX, авторизация, ровная латентность. Значит режется не SSH как протокол и не VM, а конкретный диапазон адресов. Пошел в чат Yandex.Cloud, а там ровно с этим же сидит еще человек. У него пять адресов подряд оказались нерабочими. Мне повезло: хватило одной замены, чтобы получить рабочий. Никак кроме переназначения нового внешнего IP это не лечится. После смени - SSH заработал мгновенно: баннер, KEX, вход. Та же машина, тот же образ, тот же порт, сменился только IP-диапазон. Мне не очень понятно, будет ли Yandex.Cloud что-то с этим делать. Например, исключать из продажи такие адреса. Но я думаю таких адресов очень много, раз можно 5 раз попасть на "паленые" адреса. Да и сам яндекс, я думаю, не знает еще маштабов трагедии. Вряд ли с ними делятся списками того, что на ТСПУ заблокировано и почему.