Доставка, платформа и миграция¶
Доставка и платформа¶
Сервис, который нельзя независимо выкатить — это не микросервис, это distributed monolith (см. Macro-Architecture). Доставка — то, что делает независимость деплоя реальной, а не декларативной. Эта секция — про то, как упаковать, на чём крутить и как катить.
Контейнер — единица упаковки
Docker берут почти всегда: способ зафиксировать «работает у меня» в артефакт, который работает
и в проде. Внутри образа — рантайм, зависимости, бинарь сервиса. Снаружи — только env и порты.
Что это даёт микросервисам: - единый формат для всех сервисов независимо от языка — Go, Java, Python едут одинаково - иммутабельный артефакт — один и тот же image тегом проходит через staging в prod - быстрый старт и плотная упаковка — десятки сервисов на ноде без оверхеда VM
Грабли образов:
- толстый base image — python:3.12 вместо slim/distroless тянет сотни мегабайт и CVE
- сборка и рантайм в одном слое — без multi-stage build в прод едет компилятор и dev-зависимости
- latest-тег — теряешь воспроизводимость, не понимаешь, что реально крутится
Детали по Docker (multi-stage, слои, размер) — в отдельном docker-топике этого репо. Здесь
контейнер интересен как то, что оркестратор раскладывает по нодам.
Kubernetes — оркестратор
Когда сервисов десятки, руками их по нодам не разложишь. Kubernetes берёт на себя placement,
рестарты, масштабирование и service discovery через DNS — без выделенного registry (см. варианты
discovery в Macro-Architecture).
Базовые объекты, которые нужно знать про сервис:
- Deployment — desired state: «держи N реплик этого образа». Падает pod — поднимет, катишь новую
версию — сделает rolling update сам.
- Service — стабильное имя и virtual IP перед набором подов. Поды эфемерны, их IP меняются;
Service даёт постоянную точку и балансирует трафик. DNS-имя orders.default.svc — и есть
service discovery из коробки.
- HPA (HorizontalPodAutoscaler) — добавляет/убирает реплики по метрике (CPU, RPS, длина очереди).
- PodDisruptionBudget — «не убивай больше M подов за раз». Защищает от того, что node drain или
ролл-апдейт одновременно выбьет все реплики.
Requests и limits — не бюрократия, а то, как scheduler понимает, влезет ли под на ноду:
- requests — сколько гарантированно резервируется. По нему scheduler решает placement.
- limits — потолок. CPU выше limit — throttling. Memory выше limit — OOMKill пода.
- забыл requests — под улетает в BestEffort QoS, его убьют первым при нехватке памяти на ноде.
resources:
requests:
cpu: "250m" # гарантия для scheduler
memory: "256Mi"
limits:
memory: "512Mi" # OOMKill выше этого; CPU-limit часто не ставят, чтобы не словить throttling
Note
Memory limit ставить почти всегда стоит — иначе утёкший сервис съест ноду и потащит соседей. CPU limit спорнее: жёсткий cap даёт throttling даже когда CPU на ноде свободен. Часто ставят только request на CPU и limit на память.
Манифестов на сервис набирается много, размножать руками на dev/staging/prod больно. Поэтому:
- Helm — шаблонизатор с values на окружение. Один chart, разные values.yaml. Плюс — пакеты и
версионирование релиза. Минус — Go-templating поверх YAML быстро превращается в кашу.
- Kustomize — оверлеи без шаблонов: base плюс патчи на окружение. Плюс — чистый YAML, встроен в
kubectl. Минус — сложную логику не выразишь, только наложение патчей.
Service Mesh — сеть как инфраструктура
Service Mesh — это инфраструктурный слой, который берёт на себя service-to-service общение
(routing, retries, mTLS, observability) прозрачно через sidecar-прокси, вынося это из кода сервиса.
Термин ввели в Buoyant с Linkerd (2016), позже подтянулся Istio (2017).
Идея простая: рядом с каждым подом ставят sidecar-контейнер (классически — прокси Envoy). Весь
вход-выход сервиса идёт через него. Сервис думает, что зовёт http://orders, а реально упирается
в локальный прокси, который и делает mTLS, retry, timeout, маршрутизацию.
под orders под payments
┌────────────────────┐ ┌────────────────────┐
│ app ──► proxy │── mTLS ───► │ proxy ──► app │
│ (Envoy) │ │ (Envoy) │
└────────────────────┘ └────────────────────┘
▲ ▲
└──────── control plane ────────────┘
(политики, сертификаты, маршруты)
Sidecar — это общий паттерн co-located helper'а (Burns & Oppenheimer, 2016), прокси меша — лишь один его случай.
Что mesh даёт без единой строчки в коде сервиса: - mTLS между сервисами — взаимная аутентификация по сертификатам, ротация прозрачно. Это identity, не authz: mTLS доказывает, кто звонит, но не что ему можно (детали protocol-уровня — в Communication & API Style). - retries, timeouts, circuit breaking — политикой, а не библиотекой в каждом сервисе - канареечные и weighted-маршруты — «5% трафика на v2» правится в конфиге меша - observability даром — прокси видит весь трафик, отдаёт метрики и спаны (golden signals, tracing)
Минусы — честные: - сложность — control plane, sidecar-инъекция, CRD политик — это ещё одна большая система в эксплуатации - latency — каждый хоп теперь идёт через два прокси. Обычно единицы миллисекунд, но они есть. - ресурсы — sidecar на каждый под, память и CPU умножаются на число подов
Когда подходит: - много сервисов, нужен mTLS повсеместно и единые политики retry/timeout без правки кода
Грабли: - mesh ради mesh на трёх сервисах — накладные расходы и сложность не окупятся. Те же retry и timeout дешевле прописать библиотекой. Mesh начинает окупаться на десятках сервисов. - sidecar — не догма: появились ambient/sidecarless режимы, где прокси не на каждом поде
Twelve-Factor App — контракт с платформой
Twelve-Factor App — это набор из двенадцати принципов для SaaS-приложений, чтобы они были портируемы между платформами, готовы к continuous deployment и масштабировались без переделки (Adam Wiggins, Heroku, 2011).
Манифест старше контейнеров и не про микросервисы конкретно. Но именно он описывает, каким должен
быть процесс, чтобы оркестратор мог им свободно управлять. Самое важное для жизни в Kubernetes:
- config в env — никаких хардкод-урлов и паролей в образе. Один image, конфиг снаружи, через
env-переменные. Это и делает image иммутабельным артефактом на все окружения.
- stateless-процессы — никакого состояния в памяти или на локальном диске между запросами.
Состояние — в БД, кэше, объектном сторе. Иначе HPA и rolling update ломают сессии.
- disposability — процесс стартует быстро и завершается gracefully по SIGTERM. K8s гасит и
поднимает поды постоянно; сервис, который 30 секунд поднимается, ломает rolling update.
- logs как поток в stdout — не пиши в файлы, пиши в stdout. Сбор логов — забота платформы.
- port binding — сервис сам слушает порт, а не живёт внутри внешнего app-сервера.
Note
Соблюсти «config в env» и объявить полное соответствие — частая ошибка. Двенадцать факторов работают как набор; половинчатое соблюдение даёт половину профита.
CI/CD — пайплайн на сервис
Главный принцип: один pipeline на один сервис. Это прямое следствие независимого деплоя — сломал
orders, катишь только orders, остальные сервисы не трогаешь.
Типовой конвейер для одного сервиса:
push ──► test ──► build image ──► push to registry ──► deploy
(unit, (multi-stage (с тегом = git sha, (rolling /
contract) Docker) не latest) canary)
- тег образа = git sha (или semver), не
latest— всегда понятно, что катится и на что откатываться - contract testing в пайплайне (
Pact, consumer-driven contracts) ловит ломающие изменения API до деплоя — детали контрактов в Communication & API Style - деплой не из CI руками, а через GitOps (ниже)
Грабли: - один общий pipeline на все сервисы — самая частая ошибка. Любое изменение гоняет сборку и тесты всего мира, деплой становится связанным — и ты потерял независимость, ради которой пилил сервисы. Монорепо это допускает, но тогда пайплайн должен собирать только изменённые сервисы (path-фильтры, affected-граф), а не всё подряд.
GitOps — git как источник истины
GitOps — это подход, где желаемое состояние кластера лежит в git, а агент в кластере непрерывно подтягивает его и приводит реальность к описанному.
Чем отличается от push-деплоя из CI:
- push-модель — CI после сборки сам делает kubectl apply. У CI есть доступ в прод-кластер, дрейф
состояния никто не ловит.
- pull-модель (GitOps) — агент в кластере (Argo CD, Flux) сам сверяет git и кластер, тянет
изменения и чинит drift. CI только обновляет тег образа в git-репо манифестов.
Зачем:
- git — единственный источник истины. Что в git, то и в кластере. Откат — это git revert.
- аудит даром — вся история деплоев в git-логе, кто и что катил.
- drift detection — агент видит ручную правку в кластере и возвращает к описанному в git.
Связка с CI обычно такая: CI собирает image и коммитит новый тег в git-репозиторий манифестов,
дальше Argo CD или Flux подхватывают и раскатывают. CI не ходит в кластер напрямую.
Локальная разработка — кластер на ноутбуке
Тридцать сервисов на ноутбуке руками не поднимешь. Что используют:
- docker-compose — простой кейс: подними сервис плюс его БД, брокер, пару соседей. Не Kubernetes,
но для разработки одного сервиса с зависимостями хватает.
- kind / k3d — настоящий Kubernetes локально (в Docker). Нужен, когда хочешь те же манифесты,
Helm-чарты и mesh, что в проде, а не отдельный compose-мир.
- Tilt / Skaffold — живой цикл «правишь код → пересборка образа → передеплой в локальный
кластер» автоматически. Закрывают боль ручного rebuild-redeploy при работе с kind/k3d.
Грабли:
- compose-мир расходится с прод-манифестами. Локально одно, в кластере другое — баги ловишь только
на staging. Если в проде Kubernetes — держи локально kind/k3d с теми же манифестами.
Деплой-стратегии — как переключать трафик
Rolling update — дефолт в Kubernetes: поды новой версии поднимаются, старые гасятся пачками. Без
простоя, но какое-то время в проде живут обе версии — держи API обратно совместимым.
Когда rolling мало, берут: - Blue-green — два полных окружения, blue и green. Новая версия катится в простаивающее, трафик переключается разом после проверки, старое держат для мгновенного отката (Humble & Farley, 2010). Цена — двойные ресурсы; грабли — общая БД и миграции схемы между окружениями. - Canary — новую версию катят на малую долю трафика, смотрят метрики, постепенно поднимают долю (Danilo Sato, 2014). Нужен реальный автоматический мониторинг для решения promote/rollback — иначе это не canary, а релиз вслепую. - Feature flags — выкатка кода и включение фичи разведены: код в проде, фича за флагом, рубильник отдельно от деплоя. Деплой перестаёт быть событием «включения».
Чем canary отличается от blue-green: blue-green переключает весь трафик разом между двумя окружениями, canary сдвигает долю постепенно. Это про риск релиза, не про A/B-тест продуктовых гипотез. Подробнее про выбор стратегии под топологию — в Macro-Architecture.
Миграция с монолита¶
Big-bang переписывание монолита в микросервисы почти всегда проваливается. Долго, рискованно, бизнес
встаёт. Рабочий путь — Strangler Fig (Martin Fowler, 2004): новый код растёт по краям старого,
функциональность отщепляется по куску, монолит сжимается, пока не умрёт. Сам паттерн и вопрос «а нужны
ли тебе вообще микросервисы» — в Macro-Architecture. Здесь — механика переезда: в каком порядке резать,
как развязывать данные, чем защищать новую модель.
Что отщеплять первым
Не самое важное, а самое удобное. Кандидат на первый вынос — кусок, который: - слабо связан с остальным монолитом (мало вызовов через границу, мало общих таблиц); - часто меняется (вынес — и катишь независимо, выигрыш виден сразу); - читает больше, чем пишет (меньше боли с распределёнными транзакциями на старте); - имеет понятную бизнес-границу (notifications, search, reporting — классика).
Грабли: лезть первым в core-домен. Там границы ещё не устаканились, связей максимум, цена ошибки
высокая. Core оставляешь на потом, когда набил руку на периферии. Резать рано, до понимания границ
(Bounded Context) — отдельная ловушка, про неё в Macro-Architecture.
Пять шагов одного отщепления
Один цикл миграции — это не «вынес сервис», а пять шагов. Пропустишь шаг с данными — получишь distributed monolith.
- Фасад — поставь точку перехвата перед монолитом (gateway, reverse proxy, фасадный модуль). Весь трафик к куску идёт через неё. Пока проксирует в монолит как есть.
- Вынес кусок — подними новый сервис с логикой, но данные ещё читает из старой базы.
- Развязал данные — у сервиса своя база, синхронизация старой и новой на переходный период.
- Переключил трафик — фасад начинает слать на новый сервис. Постепенно: canary, потом весь трафик.
- Погасил старое — удалил код из монолита, выпилил его доступ к этим таблицам.
шаг 1: фасад шаг 2-3: parallel run шаг 5: legacy мёртв
client client client
| | |
[facade] --> [monolith] [facade] --> [new svc] [facade] --> [new svc]
| \ | |
| +--> [monolith] [new DB]
[new DB] <-sync-> [old DB]
Между шагами 3 и 4 живёшь в parallel run: оба пишут, данные синхронизируются в обе стороны или от старого к новому. Это самая нервная фаза — здесь ловишь расхождения, пока цена отката мала.
База развязывается первой
Главный корень distributed monolith — общая база. Два сервиса в одну схему — это не два сервиса,
а один, размазанный по двум деплоям: миграцию схемы не выкатишь независимо, релизы связаны, любой
ALTER ломает соседа. Принцип Database per Service (детально в Macro-Architecture про distributed monolith)
— данными владеет один сервис, остальные ходят через API. Речь о логической изоляции владения, не
обязательно об отдельном физическом инстансе: schema-per-service в общем инстансе тоже считается.
Развязка почти всегда болезненнее, чем вынос кода. Порядок обычно такой: - найди все места, где чужой код читает/пишет твои таблицы (грепом по схеме, по запросам в логах); - спрячь таблицы за API сервиса — чужие прямые запросы заменяются на вызовы; - раздели схему физически — отдельная база/схема, у монолита отзывается доступ; - разорви FK через границу — внешний ключ между сервисами невозможен, ссылку держишь по id, целостность проверяешь в приложении или принимаешь eventual consistency.
Грабли: вынести сервис, оставив общую базу. Снаружи микросервис, внутри — два деплоя на одной схеме, жёстко связанные. Худшее из обоих миров: сетевые задержки распределёнки плюс связность монолита.
Когда строки в одной транзакции расползаются по двум базам — атомарного коммита больше нет.
2PC даёт сильную согласованность, но он блокирующий: упал координатор после фазы голосования —
участники висят с захваченными locks до восстановления. В проде чаще берут saga плюс
Transactional Outbox. Это уже про согласованность данных — детали в соответствующей секции,
теория sync-vs-async — в Macro-Architecture.
Anti-Corruption Layer: чтобы старая модель не протекла
Anti-Corruption Layer (Eric Evans, 2003) — это прослойка-переводчик между новым сервисом и
моделью монолита, которая не даёт старой модели протечь в новую.
Зачем: модель монолита обычно кривая — легаси-имена, мусорные поля, чужие инварианты. Если новый сервис говорит на ней напрямую, вся эта кривизна просочится в его домен. ACL переводит туда-обратно: внутри сервиса — чистая новая модель, на границе — мэппинг в язык монолита.
Чем отличается от обычного адаптера: это не «ещё один DTO-маппер». ACL — защитная позиция в context mapping. Задача — держать целостность твоей модели, а не просто переложить поля. Маппинг там обычно есть, но смысл в защите.
Где живёт ACL на переезде: - между новым сервисом и таблицами монолита (пока данные ещё общие); - между новым сервисом и API монолита (когда монолит зовут как сервис); - двусторонне в parallel run — события монолита переводятся в модель сервиса и обратно.
Когда монолит дёргает новый сервис через REST/gRPC поверх ACL — детали протоколов в Communication & API Style. ACL — про модель на границе, не про транспорт.
Note
ACL — это про защиту, а не про вечность. На переезде он почти всегда временный: живёт, пока монолит ещё дышит, и умирает вместе с последней общей таблицей. Если ACL прирос намертво — значит, монолит так и не погасили.
Branch by Abstraction: когда резать на уровне кода
Strangler Fig перехватывает на границе процесса — фасад, gateway, прокси. Но кусок не всегда
торчит наружу отдельным вызовом. Часто он закопан в середину монолита и зовётся как обычный модуль.
Снаружи такое прокси не перехватишь.
Branch by Abstraction — это про вынос изнутри:
- ставишь абстракцию (интерфейс) перед старой реализацией прямо в коде монолита;
- весь монолит переключаешь на этот интерфейс — старая реализация за ним;
- пишешь новую реализацию, которая ходит в новый сервис по сети;
- переключаешь интерфейс на новую (через флаг/конфиг, можно постепенно);
- удаляешь старую реализацию, когда новая прижилась.
Чем отличается от Strangler Fig: Strangler режет по границе процесса снаружи, Branch by Abstraction
— по границе абстракции внутри кода. На практике комбинируешь: внутренние зависимости вытаскиваешь
через абстракцию, внешние вызовы перехватываешь фасадом.
Плюс — feature flag на переключении: трафик переводишь на новую реализацию долей, при расхождении откатываешь флагом мгновенно, без передеплоя.
Если коротко:
- Big-bang переписать всё сразу →
Strangler Fig, отщепляй по куску. - Не знаешь, что выносить → бери слабосвязанное, часто меняющееся, read-heavy с понятной границей.
- Тянет в core первым → не лезь, набей руку на периферии (notifications, search, reporting).
- Вынес сервис, база общая → это distributed monolith, развязывай данные первыми.
- Старая модель лезет в новый сервис →
Anti-Corruption Layerна границе. - Кусок закопан внутри монолита, прокси не достаёт →
Branch by Abstraction+ feature flag. - Транзакция расползлась по двум базам → не
2PC, аsaga+Transactional Outbox.