Композиция корректности. Как собрать рабочую систему из сервисов с подтверждённой спецификацией
«Программы должны быть написаны так, чтобы их корректность можно было доказать, а не угадать тестированием.»
— Niklaus Wirth, Systematic Programming
Это финальная статья серии про корректность программ. В предыдущих мы по кирпичу собирали модуль, сервис и спецификацию:
- «Модульность программы» — модуль как чёрный ящик с одним входом и одним выходом.
- «Правильность программы» — корректность по построению, не по тестированию.
- «README — это продукт» — документация как контракт для четырёх потребителей.
- «Сколько компонентных тестов нужно сервису» — формула и логический вывод.
- «Компонентные тесты на практике» — семь тестов на passkey-demo-api.
- «Два скилла дисциплины» — vertical slice, конструкторы вместо фабрик, головной модуль как пайп.
Сервис в результате этой дисциплины имеет спецификацию, подтверждённую тестами в изоляции. Юнит-тесты по формуле «1 + ветки антецедента» подтверждают, что каждый модуль ведёт себя по своему контракту. Компонентные сценарии в Gherkin через Docker Compose подтверждают, что сервис как чёрный ящик ведёт себя по OpenAPI/AsyncAPI и по карте режимов отказа в README.
Корректность при этом достигается проектированием, не тестированием. Тестирование, как помним из Дейкстры, может только показать наличие ошибок, но не их отсутствие. Тесты в нашей дисциплине — это исполняемая документация контракта, которую машина периодически сверяет с реальным кодом. Подтверждение, а не доказательство.
Но в индустрии один такой сервис никому не нужен. Нужна система — десять, двадцать, сто сервисов, которые ходят друг к другу через сеть, и эта система должна работать.
И вот здесь индустрия ставит поверх всей пирамиды ещё один этаж: интеграционные тесты, e2e на Playwright, системные стенды, регрессионные прогоны на стейджинге. Тысячи сценариев. Часы прогонов. Десятки человеко-месяцев на поддержку.
В этой статье покажу, что весь этот этаж — компенсация отсутствия двух простых вещей: машиночитаемого контракта и канареечного деплоя. При наличии обоих корректность системы выводится из корректности сервисов и совместимости их контрактов. Без e2e, без стейджинга, без бесконечной регрессии.
В этой главе рассмотрим:
- Что такое композиция корректности
- Сколько тестов нужно для одного сервиса. Возврат к формуле
- Транзитивность корректности через контракт
- Третий уровень — валидация контрактов, а не Pact
- Эволюция контракта в CI провайдера
- Стейджинг как ритуал
- Канарейка с SLO как настоящий тест
- Дисциплина композиции на 10 / 20 / 100 сервисов
- Что мы выкинули
- Замыкание курса
Что такое композиция корректности
В формальных методах есть простая идея: если функция f доказана корректной по своему контракту Cf, и функция g доказана корректной по своему контракту Cg, и выход f удовлетворяет входу g, то композиция g(f(x)) корректна без отдельного доказательства. Это базовая теорема Хоара о композиции, и она же — основание всей нашей дисциплины проектирования модулей.
В курсе мы применили эту идею внутри сервиса. Дерево модулей с одним входом и одним выходом, контракты с антецедентом и консеквентом, граф вызовов с проверкой «консеквент A ⊆ антецедент B» (Шаг 9 скилла проектирования) — это и есть локальная композиция корректности. Сервис правилен, потому что его модули правильны и их контракты согласованы.
Теперь то же самое — на уровень выше. Сервис — это модуль более крупного масштаба. У него тоже есть один вход (API), один выход (ответ или эффект на интеграции), антецедент (валидный запрос по схеме) и консеквент (ответ по схеме плюс эффекты на собственных интеграциях). И спецификация сервиса подтверждена компонентными тестами в Docker Compose против реальных зависимостей. Корректность по построению — заслуга проектирования; компонентные тесты лишь сверяют, что построенное соответствует контракту.
Если у двух сервисов спецификации подтверждены тестами в изоляции, и контракт потребителя совместим с контрактом провайдера, — пара сервисов работает по своему общему контракту без отдельной проверки на стенде «двух вместе». То же для трёх, десяти, ста. Это композиция корректности на уровне сервисов.
Звучит академично. На практике это означает простую вещь: если мы умеем подтвердить спецификацию одного сервиса в изоляции и проверить совместимость двух контрактов, мы умеем собрать любую систему. Никаких системных тестов, никаких e2e, никакого стейджинга.
Весь остаток статьи — про то, как именно проверять совместимость контрактов и как закрывать оставшийся класс рисков канарейкой.
Сколько тестов нужно для одного сервиса. Возврат к формуле
Прежде чем складывать сервисы в систему, зафиксируем, что значит «сервис с подтверждённой спецификацией». Это работа второй статьи курса, но без неё дальнейший аргумент висит в воздухе.
Спецификация сервиса как чёрного ящика описывает два класса утверждений:
- Штатное поведение — одно утверждение на API: «при валидном запросе сервис делает то-то».
- Поведение при отказе каждой внешней интеграции — по утверждению на каждую различимую ветку обработки в адаптере этой интеграции. Адаптер — отдельный модуль, в котором инкапсулирована работа с одной конкретной интеграцией (БД, брокером, апстрим-сервисом).
Отсюда формула:
Где N_API — число эндпоинтов сервиса, i пробегает по всем внешним интеграциям сервиса (СУБД, брокер сообщений, апстрим HTTP-сервисы, объектное хранилище и т. п.), а для каждой интеграции считается число различимых веток обработки в её адаптере.
Происхождение: контракт сервиса с внешним миром конечен. Сколько утверждений в контракте — столько тестов. Не больше и не меньше. Не «всё возможное» (это математически невозможно, см. пример с ФИО клиента — 7×10¹⁵¹ комбинаций), а ровно столько утверждений, сколько мы написали в спецификации.
Пример: сервис с одним API
Сервис регистрации клиента. Один эндпоинт POST /clients. Интеграции:
- PostgreSQL — сохраняет клиента;
- Kafka — публикует событие
client_registered.
Адаптеры спроектированы просто: любая ошибка зависимости → одна ошибка наружу. Одна различимая ветка в каждом.
1 (POST /clients, happy path)
+ 1 (БД недоступна → 503)
+ 1 (брокер недоступен → 503)
= 3 компонентных теста
Пример: сервис с пятью API
Сервис на 5 REST-эндпоинтов. Две внешние интеграции:
- PostgreSQL — для хранения;
- Брокер сообщений (Kafka / RabbitMQ — неважно) — для публикации событий.
Проектное решение по адаптерам: различаем сетевой отказ и отказ системы. Это разные классы проблем с разной реакцией.
- Сетевой отказ — таймаут, разрыв соединения, DNS не отвечает. Удалённая сторона может быть жива, проблема в канале. Типичная реакция — повторить запрос (
503 Service Unavailable+Retry-After). - Отказ системы — удалённая сторона ответила ошибкой по существу: ошибка SQL, нарушение схемы, NACK от брокера, тема не найдена. Удалённая сторона жива, но ситуация не лечится повтором. Реакция — отдать
500 Internal Server Errorнаверх без retry.
В каждом адаптере — две различимых ветки. Считаем:
5 (по одному happy-path-сценарию на эндпоинт)
+ 2 (СУБД: отказ сети + отказ системы)
+ 2 (брокер: отказ сети + отказ системы)
= 9 компонентных тестов
Раскладка по сценариям:
| # | Сценарий | Тип |
|---|---|---|
| 1 | POST /resource — happy path |
штатное поведение |
| 2 | GET /resource/{id} — happy path |
штатное поведение |
| 3 | PUT /resource/{id} — happy path |
штатное поведение |
| 4 | DELETE /resource/{id} — happy path |
штатное поведение |
| 5 | GET /resource (список) — happy path |
штатное поведение |
| 6 | СУБД — отказ сети → 503 + Retry-After |
адаптер БД, сеть |
| 7 | СУБД — отказ системы → 500, без retry |
адаптер БД, система |
| 8 | Брокер — отказ сети → 503 + Retry-After |
адаптер брокера, сеть |
| 9 | Брокер — отказ системы → 500 |
адаптер брокера, система |
Девять сценариев на сервис с пятью API. Не пятьдесят, не сто. Конкретное конечное число, выводящееся из спецификации и из проектных решений по адаптерам.
Заметьте, как работают проектные решения по адаптерам. Если бы мы не различали сеть и систему — было бы 5 + 1 + 1 = 7 тестов. Если бы мы дополнительно различали в адаптере БД таймаут запроса и таймаут транзакции — стало бы 11 тестов. И тут же возникает правильный вопрос: а зачем нам различать?
Сервис существует ради бизнес-ценности, а не ради исчерпывающей классификации чужих ошибок. Мы не пишем сервис для вывода ошибок СУБД, S3 или внешнего API. Мы пишем сервис, который делает что-то полезное — регистрирует клиента, проводит платёж, публикует пост — и иногда не может это сделать.
Из этого следует принцип проектирования адаптера: различимая ветка в адаптере имеет право на существование только если она транслируется в различимое поведение сервиса наружу. То есть: либо в разный HTTP-статус, либо в разный error.code в теле ответа, либо в принципиально разную внутреннюю обработку (retry, фолбэк на кеш, деградация в read-only). Если разные классы отказов поставщика приводят к одному и тому же ответу клиенту — это одна ветка, не две.
Проверьте на нашем примере. Сеть vs система — заслуженно разные ветки: на сетевой отказ мы отвечаем 503 + Retry-After (клиент должен повторить), на отказ системы — 500 (клиент повторять не должен). Разное поведение, разные ветки, два теста. Таймаут запроса vs таймаут транзакции — если оба приводят к одному и тому же 503 наружу, то это в спецификации сервиса одна ситуация: «БД недоступна». Внутри адаптера это может быть две разные конструкции if, но в контракте сервиса это одно утверждение. Один тест.
Это и есть ответ на индустриальный вопрос «а нужно ли тестировать каждый код ошибки СУБД, который может прилететь». Нет, не нужно. В спецификацию сервиса попадает только то, что меняет его поведение наружу. Если поставщик умеет 200 разных ошибок, а сервис из них делает один 503 — в контракте сервиса один режим отказа этой интеграции. Один тест.
Отсюда формула остаётся точной: число тестов задаётся контрактом сервиса с внешним миром. Не задаётся ни внутренним устройством сервиса, ни богатством чужих API ошибок, ни вкусом разработчика. Изменилась спецификация — число пересчитывается формулой. Не изменилась — число фиксировано.
Что эта формула гарантирует
Когда сервис прошёл N_тестов по формуле — его соответствие спецификации подтверждено в изоляции. Каждое утверждение контракта с внешним миром проверено в Docker Compose с реальными зависимостями. Больше тестов поверх ничего не добавляют — они дублируют уже проверенное или проверяют то, что должно быть на юнит-уровне (валидация полей, бизнес-логика, ветвления внутри модулей).
И главное — это число известно заранее, до написания кода. Спецификация конечна, формула считает, бюджет на тестирование становится предсказуемым. Не «потратим N человеко-дней, пока не наловим все баги», а «сервис требует столько-то сценариев, столько-то времени на их разработку, столько-то секунд на прогон в CI». Производство становится оцениваемым по тем же лекалам, по которым в электронике оценивается trim-конвейерной проверки чипа: датасет, число тестпойнтов, прогнозируемое время цикла.
Теперь у нас есть единица композиции — сервис с подтверждённой спецификацией (N_тестов сценариев по формуле, все зелёные). Можно складывать.
Транзитивность корректности через контракт
Сформулируем теорему. Без LaTeX-а, в одну фразу.
Если сервис P корректен по контракту Cp, сервис Q корректен по контракту Cq, и Cp.responses ⊆ Cq.expectations, то P → Q корректна.
Расшифровка по словам:
- Cp.responses — множество ответов и эффектов, которые P гарантирует наружу: ответы 2xx со схемами, ответы 5xx со списком
error.code, события в брокер со схемами. - Cq.expectations — множество входов, которые Q умеет разобрать и обработать: запросы 2xx, ошибки 5xx с известными ему
error.code, события, на которые он подписан. - ⊆ — «содержится в». Всё, что P может выдать, Q умеет принять.
И это отношение между двумя YAML-файлами, не между двумя сервисами. P и Q могут быть на разных языках, в разных репозиториях, в разных командах — это неважно. Совместимость живёт в контракте, не в коде.
Транзитивность означает: для системы из N сервисов с M связями между ними нужно проверить M совместимостей пар контрактов, а не комбинаторный взрыв всех возможных путей запроса через систему. Это растёт линейно, не экспоненциально. На 100 сервисов с типичными 3–5 связями на сервис — это 300–500 пар, каждая проверяется за секунды.
Сравните с e2e: путь запроса через 5 сервисов даёт 2⁵ = 32 комбинации happy/failure ответов каждого узла. Это уже 32 сценария на одну цепочку. А цепочек в системе из 100 сервисов — сотни. Десятки тысяч сценариев, ни один из которых не доказателен по Дейкстре (см. статью о компонентных тестах, раздел «Почему "протестировать всё" невозможно»).
Этот раздел — главное обещание статьи. Дальше — как именно проверять совместимость контрактов и что делать с оставшимся классом рисков.
Третий уровень — валидация контрактов, а не Pact
В индустрии для проверки совместимости сервисов есть устоявшийся ответ: consumer-driven contracts, обычно через Pact. Это работает так:
- Потребитель пишет код мока, описывающего его ожидания от провайдера.
- Мок прогоняется в тестах потребителя — провайдера в этот момент нет.
- Запись мока (контракт) публикуется в Pact Broker.
- Провайдер в своём CI поднимает себя, прогоняет против него все опубликованные контракты потребителей, отчитывается в Broker.
- Если провайдер ломает контракт — Broker подсветит, какой потребитель пострадает.
Это работает. Это лучше, чем ничего. Но это компенсация отсутствия машиночитаемого контракта.
Когда Pact проектировался (Джеймс Бротбэк и команда, около 2013 года), у HTTP-сервисов не было общепринятого формата спецификации. Swagger/OpenAPI существовал, но не был обязательным, и в нём отсутствовал стандарт на 5xx-ответы с error.code. AsyncAPI ещё не появился. Сервис обещал что-то на странице в Confluence или в коде — и потребителю оставалось писать мок и надеяться, что провайдер не сломает его.
Pact симулирует реальность, чтобы её проверить. Это правильный приём, когда реальность не описана.
В мире, который мы строим в курсе, реальность описана. У каждого сервиса есть OpenAPI или AsyncAPI как единственный источник истины (см. скилл проектирования, Шаг 0):
- все эндпоинты со схемами запроса и ответа;
- все 5xx-ответы с
error.code(правило различимости из второй статьи); - все события publish и subscribe со схемами в AsyncAPI;
- лежит в репозитории сервиса, версионируется, ревьюится.
Если два сервиса держат контракты в таком виде, совместимость между ними проверяется сравнением двух YAML-документов, а не прогоном моков на стенде. Это статическая верификация контракта, а не динамический тест.
Контракт-валидатор: что он делает
Третий уровень пирамиды — это не «ещё один класс тестов», а инструмент. Я предлагаю называть его контракт-валидатор, чтобы не путать с Pact-style contract tests.
Что он проверяет:
| Проверка | На стороне провайдера | На стороне потребителя |
|---|---|---|
| Структура схем | поля, типы, обязательность | поля, которые читает, существуют в схеме провайдера |
| Коды ошибок | список error.code в 5xx |
каждый error.code, который умеет разобрать, есть у провайдера |
| Заголовки | заявленные заголовки (Retry-After, Idempotency-Key) |
те же заголовки в ожиданиях клиента |
| Каналы AsyncAPI | топик и схема publish | подписка на тот же топик с той же схемой |
| Эволюция (diff) | не убрано обязательное поле, не сужен тип | — |
Вход у валидатора — два YAML-файла. Выход — список несовместимостей или «ok». Никаких моков, никакой сети, никакого Docker. Прогон занимает секунды и не зависит от ничего, кроме самих файлов.
Распиновка сервиса и pinout
В электронике у каждой микросхемы есть распиновка (pinout): на какие пины какие сигналы заходят, какие напряжения, какие протоколы. Чтобы две микросхемы заработали вместе, их распиновки должны сходиться. Это проверяется до того, как платы паяют, — по datasheet, не на стенде.
В сервисной архитектуре ровно ту же роль играет спецификация: OpenAPI описывает «распиновку» REST-сервиса, AsyncAPI — асинхронного. Эндпоинты, схемы, коды ошибок, каналы, форматы сообщений — это распиновка сервиса. Совместимость двух сервисов — это совместимость их распиновок. И, как в электронике, она проверяется до сборки, по двум документам.
Для этой проверки в курсе разрабатывается тулинг — pinout: экосистема валидаторов контрактов consumer↔provider на основе OpenAPI и AsyncAPI. Состав:
| Модуль | Назначение | Статус |
|---|---|---|
| pinout-asyncapi | валидатор контрактов AsyncAPI 3.0: Request-Reply, Fire-and-Forget, Pub-Sub | готов к использованию |
| pinout-openapi | валидатор контрактов OpenAPI по той же модели consumer/provider | запланирован |
| pinout-netlist | координатор: граф consumer↔provider, метаданные, версии контрактов | запланирован |
| pinout-cli | единый CLI-фронт для CI/CD | запланирован |
Метафора распиновки задаёт и название второго модуля — netlist. В печатных платах netlist — формальное описание всех соединений в схеме до её разводки. В сервисной архитектуре netlist — формальное описание всех связей consumer↔provider в системе до её сборки. Не Confluence-схема «на словах», а машиночитаемый граф, по которому валидаторы прогоняют совместимости.
pinout и индустриальный тулинг — разные классы проверок
Важно не путать pinout с тем, что уже есть в индустрии. В индустрии есть инструменты для обнаружения ломающих изменений в одной спецификации во времени: oasdiff для OpenAPI, asyncapi diff для AsyncAPI, Buf breaking для protobuf. Они отвечают на вопрос: «не ломаю ли я свой контракт по сравнению со вчерашней версией».
Это другой класс проверки, не тот, что решает pinout. Сведём в таблице:
| Класс проверки | Вопрос, на который отвечает | Инструмент | Состояние |
|---|---|---|---|
| Breaking change detection | «не ломаю ли я свой контракт по сравнению со вчерашней версией» | oasdiff, asyncapi diff, Buf | индустриальный, готовый |
| Consumer↔provider compatibility | «мой контракт совместим с контрактом сервиса, к которому я хожу» | pinout | разрабатывается в курсе |
Обе проверки нужны, и обе живут в CI. Первая защищает потребителей сервиса от ломающих изменений его собственного контракта. Вторая защищает систему в целом от несовместимости контрактов между сервисами. Они не заменяют друг друга — они закрывают разные классы рисков.
Почему вторую проверку индустрия не решила готовым инструментом — отдельный разговор. Краткая версия: в эпоху REST-без-схем её симулировали через Pact, и индустрия закрепилась на этом решении даже после прихода OpenAPI. Никто не дописал «настоящий» валидатор совместимости спецификаций, потому что Pact формально работал. pinout закрывает эту нишу прямо, через сравнение машиночитаемых распиновок.
Сравнение в одной таблице
| Pact (CDC) | Контракт-валидатор | |
|---|---|---|
| Источник истины | код моков потребителя | OpenAPI/AsyncAPI обоих сервисов |
| Когда прогоняется | в CI провайдера, через broker, с подъёмом сервиса | в CI любого сервиса, на YAML |
| Что нужно поддерживать | мок-код + Pact Broker + версионирование | один YAML на сервис |
| Время прогона | минуты на пару | секунды на пару |
| Стоимость на N сервисов | растёт как число пар × прогонов | линейно |
| Что доказывает | конкретный мок конкретного потребителя совпадает с поведением провайдера | контракты двух сервисов структурно совместимы |
Pact решает задачу окольным путём — через симуляцию. Валидатор решает её прямо — через сравнение спецификаций. Когда контракт стал машиночитаемым, симуляция стала лишней.
Где валидатор живёт в репозитории
Скилл для агента — отдельной статьёй после этой, по образцу skill для компонентных тестов. Но структура уже понятна:
service-template/
├── api-specification/
│ ├── openapi.yaml
│ └── asyncapi.yaml
├── pinout-checks/
│ ├── upstream/ # распиновки сервисов, к которым ходим
│ │ ├── billing-openapi.yaml
│ │ └── notifications-asyncapi.yaml
│ └── compatibility.report.md # автогенерируется в CI
└── skills/
└── pinout-validator/
└── SKILL.md
Сервис в своём CI проверяет:
- Свой контракт стабилен.
oasdiff(илиasyncapi diff) против предыдущей версии — нет breaking changes без мажорной версии. - Свой код соответствует своему контракту. Уже подтверждено компонентными тестами.
- Свой контракт совместим с контрактами апстримов. Для каждого
upstream/*.yaml— прогонpinout, отчёт вcompatibility.report.md.
Если хоть одно — красное, PR не сливается. Никакой системный стенд не поднимается.
Эволюция контракта в CI провайдера
Композиция корректности держится на одном инварианте: контракт обратно совместим в пределах мажорной версии. Это значит — провайдер не может тихо сломать то, на что подписаны потребители.
Правила эволюции конечны и формализуются как линтер. Список — не мой, это общеинженерная практика (см. рекомендации Google API Design Guide, OpenAPI Initiative versioning guide, Buf breaking change rules для protobuf):
| Изменение | Совместимо? |
|---|---|
| Добавить опциональное поле в ответ | да |
| Добавить новый эндпоинт | да |
Добавить новый error.code под существующий 5xx |
да, если потребители готовы получить unknown_error |
| Сделать обязательное поле опциональным в ответе | да |
| Сделать опциональное поле обязательным в ответе | да |
| Добавить обязательное поле в запрос | нет |
| Сделать опциональное поле обязательным в запросе | нет |
| Убрать поле из ответа | нет |
| Сменить тип поля | нет |
Сменить семантику error.code (тот же код, другое значение) |
нет |
| Убрать эндпоинт | нет |
«Нет» означает: мажорная версия (v2), параллельная работа двух мажоров до миграции потребителей, удаление v1 только после явного подтверждения всех потребителей. Это процесс, не «выкатим и посмотрим».
Прогон в CI на каждый PR:
oasdiff breaking \
api-specification/openapi.yaml@HEAD \
api-specification/openapi.yaml@main \
--fail-on ERR
Если PR содержит breaking change без мажорной версии — CI красный, PR не сливается. Сервис физически не может выкатить ломающее изменение без явного решения. Не «забыли подумать о потребителях» — система не пустит.
Это инвариант большего масштаба, чем юнит-тест. Юнит-тест говорит: «модуль ведёт себя по контракту». Линтер контракта говорит: «контракт сам по себе стабилен». Оба — статические проверки, обе детерминированные, обе быстрые.
Стейджинг как ритуал
Теперь к нелюбимой части индустриальной практики. Стейджинг — отдельный окружение, копия прода, на которое выкатывается новая версия сервиса перед продом. На стейджинге прогоняют e2e-тесты, регрессию, нагрузочные сценарии. Зелёный стейджинг — индульгенция на выкатку в прод.
Спросим прямо: что именно подтверждает зелёный стейджинг?
Разберём по классам рисков:
| Класс риска | Закрывается на стейджинге? |
|---|---|
| Ошибка в коде модуля | нет — это юнит-уровень, подтверждено до коммита |
| Ошибка в контракте сервиса | нет — это компонент-уровень, подтверждено в CI |
| Несовместимость контрактов между сервисами | нет — это контракт-валидатор, подтверждено в CI |
| Нагрузка прода | нет — стейджинг не воспроизводит нагрузку |
| Данные прода | нет — данные на стейджинге другие |
| Состояние прода (БД, кеши, очереди в незакрытых состояниях) | нет — состояние стейджинга чистое |
| Взаимодействие со сторонними системами в проде | нет — стейджинг ходит в стейджинг-сторонних |
| Конфигурация прода | нет — конфиги стейджинга другие |
Стейджинг закрывает класс «ничего». Он закрывает символический класс: «мы что-то прогнали и оно зелёное, значит у нас всё хорошо». Это ритуал успокоения, не инженерная проверка.
Возражение, которое здесь обычно звучит: «но мы же ловим ошибки на стейджинге, факт!». Да, ловите. Но это те же ошибки, что вы бы поймали на канарейке за следующие 30 минут — только с меньшим лагом, без задержки на сборку стейджа и без дорогого человеко-часа на чтение Allure-отчётов.
И второе возражение: «но e2e на стейджинге проверяют интеграцию». Нет, не проверяют. Они проверяют, что на стейджинговой инфраструктуре при стейджинговых данных при стейджинговой нагрузке запрос проходит через цепочку сервисов. На проде ни одно из этих условий не выполняется. Подтверждение, полученное на стейджинге, привязано к стейджингу и на прод не переносится — формально это просто другая программа в другом окружении.
Стейджинг как класс окружений — это компенсация отсутствия канареечного деплоя. Когда непонятно, как безопасно выкатить, делают «второй прод поменьше», прогоняют там и надеются. Когда канарейка освоена как инструмент — стейджинг уходит вместе с e2e на нём.
И ещё одно. Содержание стейджинга стоит реальных денег. Есть компании, у которых стейджинг по инфраструктуре сопоставим с продом.
Канарейка с SLO как настоящий тест
Альтернатива стейджингу — канареечный деплой. Идея простая: новая версия сервиса выкатывается на маленький процент реального трафика, метрики этого процента сравниваются с метриками старой версии, при ухудшении — автоматический откат, при стабильности — постепенное увеличение доли.
Эта практика описана в книгах Sam Newman (Building Microservices, 2-е изд., главы 8 и 12) и Google SRE Workbook (главы про progressive rollouts и error budgets). Это не экспериментальная техника, это индустриальный стандарт продакшен-деплоя последние десять лет.
Что канарейка проверяет:
| Класс риска | Закрывается канарейкой? |
|---|---|
| Нагрузка прода | да — это реальная нагрузка прода, в малой доле |
| Данные прода | да — это реальные данные прода |
| Состояние прода | да — то же состояние |
| Взаимодействие со сторонними системами в проде | да — реальные сторонние |
| Конфигурация прода | да — конфиг прода |
| Регрессии, не пойманные компонентными тестами | да — реальный поток запросов задействует реальные ветки |
Тот класс рисков, который стейджинг не закрывает, — канарейка закрывает по построению. И не «надо бы ещё придумать кейсы», а автоматически — реальный трафик прода по определению содержит все распределения, которые есть в проде.
SLI / SLO / error budget — критерии решения
Канарейка — это деплой плюс автоматическое решение об откате. Решение принимается не глазами оператора, а сравнением метрик. Это означает: должны быть метрики, должны быть пороги, должно быть правило сравнения.
Определения (формализованы в Google SRE Workbook):
- SLI (Service Level Indicator) — измеряемая характеристика поведения сервиса. Пример: «доля 5xx-ответов в общем числе ответов за минутный интервал».
- SLO (Service Level Objective) — численная цель по SLI. Пример: «доля 5xx-ответов меньше 0,1% за rolling 1h».
- Error budget — допустимое отклонение от SLO. Если SLO 99,9% на 30 дней, бюджет ошибок = 0,1% × 30 дней = 43 минуты деградации в месяц.
- Burn rate — скорость расхода бюджета. Если за 1 час «сгорело» 5% месячного бюджета — burn rate высокий, ситуация требует реакции.
Для канарейки достаточно трёх SLI на сервис:
| SLI | Что показывает | Типичный SLO для синхронного API |
|---|---|---|
| Доля 5xx-ответов | корректность ответов | 99,9% за rolling 5m |
| Латентность p99 | соответствие скоростному контракту | < 500ms за rolling 5m |
| Латентность p50 | поведение типичного запроса | < 100ms за rolling 5m |
Алгоритм канарейки (упрощённо):
- Выкатываем v2 на 1% трафика. Засекаем 5 минут.
- Сравниваем SLI v2 с SLI v1 на том же временном окне.
- Если SLI v2 в пределах SLO И не хуже v1 более чем на N% — продвигаем до 5%, ждём 5 минут.
- Повторяем для 5% → 25% → 50% → 100%. На каждом шаге та же сверка.
- На любом шаге, если SLI v2 нарушает SLO или ухудшение относительно v1 превышает порог — автоматический откат: трафик возвращается на v1, v2 снимается, алёрт оператору.
Конкретные пороги — на стороне SRE-команды. Школа не дискутирует, какой именно burn rate приемлем у конкретного бизнеса; школа фиксирует, что решение принимает алгоритм по метрикам, а не человек по фотографии Grafana.
Инструменты, которые это делают
Чтобы не плодить иллюзию, что это «надо самому написать»: канареечный деплой как сервис давно реализован в production-tooling. Есть два слоя инструментов, которые работают в связке.
Оркестраторы канареечного деплоя — управляют постепенным переключением трафика и откатом:
- Argo Rollouts (Kubernetes) — нативная поддержка canary с автоматическими analysis runs по Prometheus/Datadog/CloudWatch метрикам.
- Flagger (Kubernetes, Flux) — то же самое в идеологии GitOps.
- Spinnaker (open source, изначально Netflix, сейчас CD Foundation) — pipeline-движок с canary-стадиями.
- AWS CodeDeploy / Google Cloud Deploy — облачные деплой-сервисы с поддержкой canary из коробки.
Аналитические движки — статистически сравнивают метрики канарейки с базой и выносят решение promote / rollback:
- Kayenta — open source automated canary analysis от Netflix и Google (опубликован в 2018, описан в блоге Netflix Tech и блоге Google Cloud). Использует U-критерий Манна-Уитни для определения значимых отклонений. Netflix перенёс на него всю свою канареечную систему; используется Adobe, Waze и многими другими.
Связка типична такая: Argo Rollouts или Spinnaker управляют переключением трафика и вызывают Kayenta (или встроенный analysis) на каждом шаге. Если анализ показывает деградацию — автоматический откат. Эти инструменты решают задачу детерминированной выкатки с автооткатом; не нужно собирать своё.
Что осталось делать инженеру
Когда у тебя в репозитории сервиса:
- юнит-тесты по формуле для модулей логики (подтверждают модули по контракту);
- компонентные тесты в Docker Compose для API сервиса (подтверждают сервис по спецификации);
- валидатор контрактов в CI (статически доказывает совместимость с соседями);
- канарейка с SLO и автооткатом в проде (закрывает класс продакшен-рисков),
— то на каждый коммит инженер делает следующее: пишет код, прогоняет CI, мержит зелёный PR, видит зелёную канарейку. Всё. Никакого ритуала «выкатили на стейдж, дали тестировщикам полдня погонять, исправили три флака, выкатили в прод, посмотрели».
Полный CI-цикл от коммита до 100% трафика на новой версии — десятки минут, не дни. И каждый шаг детерминирован.
Дисциплина композиции на 10 / 20 / 100 сервисов
Принципы не меняются с масштабом. Меняется давление на отдельные элементы дисциплины — и за чем нужно следить внимательнее. Сведу одной таблицей.
| Аспект | 10 сервисов | 20 сервисов | 100 сервисов |
|---|---|---|---|
| Контракты в YAML | желательно | обязательно | обязательно, иначе хаос |
| Линтер обратной совместимости | необязательно | в CI каждого сервиса | в CI + общий ревью |
| Контракт-валидатор | по требованию | в CI потребителя на каждый деплой | автоматический на каждый merge провайдера |
| Реестр сервисов и связей | можно держать в README | каталог сервисов (Backstage, internal portal) | обязателен с генерацией графа связей |
| Канарейка | желательно | стандарт для всех | стандарт + автоматизация рулсетов |
| Стейджинг как окружение | если уже есть — оставить | начинать снимать | его не должно быть |
| E2e на UI | один happy-path журей на критичные сценарии | то же | то же — не растёт с числом сервисов |
| Дисциплина владения контрактом | команда сервиса | команда + ревью платформы | команда + контракт-ревью платформы как gate |
| Время сборки уверенности от коммита до прод | 30 минут | 30 минут | 30 минут |
Главный пункт в таблице — последний. Время от коммита до прод-канарейки не должно расти с числом сервисов в системе, потому что каждый сервис деплоится независимо и его CI не зависит от других. Это и есть смысл независимо деплоящихся сервисов: автономия команды.
В индустриальной практике, к сожалению, чаще наблюдается обратное: чем больше сервисов, тем больше «общих» этапов в выкатке, тем дольше цикл. На 100 сервисов компании накапливают часовые системные регрессии, дневные релизы, недельные «релизные поезда». Это симптом того, что корректность не строится локально, а ловится коллективным прогоном — и потому масштабируется не линейно, а ужасно.
Один момент про e2e на UI стоит пояснить отдельно. UI-сценарий через несколько экранов — это журей пользователя, и его имеет смысл проверять, потому что у UI нет внутренней спецификации, против которой можно прогнать компонент. Но таких сценариев на продукт единицы — 5–10 критичных путей, не сотни. Они дополняют компонентные тесты бэкенда, не заменяют их. И поднимаются на канарейке против реального бэкенда, не на стейджинге.
Что мы выкинули
Сведём финальный список индустриальных артефактов, которые в рациональной разработке не нужны, с обоснованием для каждого.
| Артефакт | Почему не нужен в этой дисциплине |
|---|---|
| Интеграционные тесты в смысле «прогон двух сервисов вместе» | Компонентный тест провайдера уже подтверждает его контракт. Совместимость потребителя с этим контрактом проверяется валидатором статически. |
| Системные тесты на полной топологии | Из попарной совместимости контрактов и подтверждённой спецификации каждого сервиса корректность системы следует по построению. Полный прогон не добавляет информации. |
| E2e через 5+ сервисов | Комбинаторный взрыв путей делает прогон недоказательным по Дейкстре. Реальный трафик канарейки покрывает все статистические распределения автоматически. |
| Стейджинг как окружение | Не воспроизводит ни нагрузку, ни данные, ни состояние прода. Закрывает «символический» класс рисков, не реальный. |
| Pact Broker и consumer-driven contract tests | Компенсация отсутствия машиночитаемого контракта. При наличии OpenAPI/AsyncAPI заменяется сравнением распиновок (pinout) — за секунды. |
| Regression suites на UI после каждого деплоя | Поведение UI описывается компонентным тестом (если UI — отдельный сервис со своей спецификацией) или контрактом backend'а, против которого UI пишется. |
| «Стабилизационные» спринты | Если корректность достигается проектированием, а соответствие спецификации подтверждается локально и прогрессивно — нет накопленных багов, которые нужно стабилизировать. Был бы регулярный поток инцидентов от канарейки — это сигнал, что дисциплина проседает на одном из четырёх уровней, и чинить надо там, не «выделять спринт». |
| Огромные QA-команды на регрессию | Компонентные тесты пишет разработчик с ИИ как часть кода — их немного, и в формуле всё посчитано заранее. Это освобождает QA-инженеров для более полезной работы: учиться разрабатывать сервисы с ИИ и переходить в разработку, либо сосредоточиться на ручной проверке мобильного приложения и фронтенда, где живой UX, жесты и реальные устройства автоматизации поддаются плохо. И то и другое — ценнее, чем прогонять регрессию глазами. |
Это не «упрощение». Это устранение слоёв, которые были компенсацией за отсутствие инженерной дисциплины. Когда дисциплина есть — слои отваливаются сами.
Замыкание курса
Возвращаемся к Дейкстре, с которого начинали в первой статье серии.
Тестирование программ может служить для доказательства наличия ошибок, но никогда не докажет их отсутствия.
За полтора десятка статей курса мы построили дисциплину, в которой корректность достигается проектированием, а тесты подтверждают соответствие спецификации:
- Юнит — модуль ведёт себя по антецеденту и консеквенту. Подтверждается формулой «1 + ветки».
- Компонент — сервис ведёт себя по спецификации. Подтверждается Gherkin-сценариями против реальных зависимостей.
- Контракт — совместимость сервисов друг с другом. Доказывается сравнением распиновок (
pinout). - Канарейка — поведение системы под реальной нагрузкой. Проверяется метриками в проде с автооткатом.
Каждый уровень закрывает свой класс рисков. Между ними нет дыр и нет дубляжа. Корректность системы выводится из корректности её частей (заложенной при проектировании, подтверждённой компонентными тестами) и совместимости их контрактов (доказанной статически) — как у Хоара. Тестирование при этом — не сеть подстраховок и не доказательство отсутствия ошибок, а исполняемая документация: способ описать ожидаемое поведение так, чтобы машина могла его периодически сверять с кодом.
Это не теория. Это работающая инженерная дисциплина, которой 50 лет — мы просто долго отказывались её применять. Дейкстра написал A Discipline of Programming в 1976 году. Хоар написал статью о логике программ в 1969-м. Вирт показал систематическое программирование в 1973-м. Всё, что мы сделали в курсе, — взяли эти идеи, перенесли на современный стек (HTTP-сервисы, OpenAPI, Kubernetes, ИИ-агенты, канареечный деплой) и собрали их в работающий процесс.
Индустрия закопала эти идеи под слоем «гибких практик», бесконечных пирамид тестов и стейджинг-окружений. Мы их откопали.
Школа мастерства «Рациональная Разработка» — это про то, чтобы вы умели это применять на своих проектах. Не как догму, а как доказательную инженерную дисциплину, которая экономит вашему работодателю миллионы и возвращает вам контроль над тем, что вы делаете.
Конец курса. Дальше — практика.
Источники
- Hoare C. A. R. An Axiomatic Basis for Computer Programming. Communications of the ACM, 1969.
- Dijkstra E. W. A Discipline of Programming. Prentice-Hall, 1976.
- Wirth N. Systematic Programming: An Introduction. Prentice-Hall, 1973.
- Meyer B. Applying "Design by Contract". IEEE Computer, 1992.
- Newman S. Building Microservices. 2nd ed. O'Reilly, 2021. Главы 8 (Deployment) и 12 (Resiliency).
- Beyer B. et al. The Site Reliability Workbook. O'Reilly, 2018. Главы про SLO, error budgets, progressive rollouts.
- Fowler M. Microservice Testing. 2014.
- OpenAPI Initiative. Versioning and Compatibility Guide. 2023.
- Buf. Breaking Change Detector Rules. docs.buf.build.
- Burrell G., Sanden C. Automated Canary Analysis at Netflix with Kayenta. Netflix Tech Blog, 2018.
- Google Cloud. Introducing Kayenta: An open automated canary analysis tool from Google and Netflix. 2018.
- Morev M. Сколько компонентных тестов нужно сервису. codemonsters.team, 2026.
- Morev M. Компонентные тесты на практике. codemonsters.team, 2026.
- Morev M. Два скилла дисциплины. codemonsters.team, 2026.
codemonstersteam/pinout— экосистема валидаторов контрактов consumer↔provider.codemonstersteam/pinout-asyncapi— валидатор контрактов AsyncAPI 3.0 (Request-Reply, Fire-and-Forget, Pub-Sub).ubik-life/passkey-demo-api— учебный сервис курса.ubik-life/service-template— шаблон сервиса с AGENTS.md и скиллами.