Перейти к содержанию

Доставка, платформа и миграция

Доставка и платформа

Сервис, который нельзя независимо выкатить — это не микросервис, это 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.

  1. Фасад — поставь точку перехвата перед монолитом (gateway, reverse proxy, фасадный модуль). Весь трафик к куску идёт через неё. Пока проксирует в монолит как есть.
  2. Вынес кусок — подними новый сервис с логикой, но данные ещё читает из старой базы.
  3. Развязал данные — у сервиса своя база, синхронизация старой и новой на переходный период.
  4. Переключил трафик — фасад начинает слать на новый сервис. Постепенно: canary, потом весь трафик.
  5. Погасил старое — удалил код из монолита, выпилил его доступ к этим таблицам.
шаг 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.