На связи Игорь Латкин, управляющий партнер и системный архитектор в KTS

Мы на своём опыте разобрались в развертывании stateless- и stateful-сервисов, и теперь хотим поделиться с вами. Мы в KTS не раз создавали подобные инфраструктуры, перепробовали разные решения и выясняли, как построить эффективные процессы.

Сегодня мы поговорим о динамических окружениях для stateless- и stateful-сервисов, обсудим особенности и проблемы, которые могут возникнуть и возникали у нас. Вы узнаете:

  • Зачем нужны динамические окружения

  • Какие неочевидные сложности они создают

  • Как работать с окружениями для stateless- и stateful-сервисов

  • Какие проблемы возникают с микросервисными приложениями и как этих проблем избежать

Мы будем поднимать вопросы в разрезе общего опыта в KTS. Статья создавалась по мотивам вебинара. Если у вас останутся вопросы, буду рад ответить на них в комментариях.

Оглавление

Зачем нужны динамические окружения

Какие проблемы создают динамические окружения

Какие сложности возникают со stateless-приложениями

Как получать доступ ко всему, что мы деплоили

Как работать со stateful-сервисам

Как решать проблему пустой базы данных

Как воспринимать микросервисы

Как автоматизировать очистку ресурсов

Выводы

Зачем нужны динамические окружения

Первое предназначение динамических окружений — это тестирование фичей независимо друг от друга.

Нередко можно встретить, что такая задача решается с помощью выделенных стендов:

Представим, что у вас в инфраструктуре есть от двух до пяти зафиксированных стендов, и разработчик деплоит свою разработанную фичу на этот стенд. В таких ситуациях возникает множество разных проблем: начинаются конфликты интересов, стенды кончаются, хочется увеличить объем одновременно тестируемых фичей.

Все становится еще сложнее, если речь идет о разных зонах тестирования:

  • Визуальных интерфейсов (связки фронтенда и бэкенда)

  • Бэкенд-приложения

  • API

  • Отдельных взаимодействий

Второе большое предназначение динамических окружений — это ускорение релизов. В процесс DevOps обычно входит несколько стадий, формирующих некий цикл разработки/релизов:

В целом динамические окружения покрывают весь этот цикл, но большая часть все же связана со сборкой и тестированием приложения. С этой точки зрения, важна скорость: нужно уметь часто собирать приложения, эффективно тестировать и быстро гасить окружения, когда они больше не нужны.

Какие проблемы создают динамические окружения

Динамические окружения заметно помогают в работе, но есть и подводные камни, о которых стоит помнить:

  • Тиражирование инфраструктуры. Приложение может взаимодействовать с базами данных, очередями, кешами, другими сторонними сервисами и микросервисами. В работе с динамическими окружениями приходится создавать копии всего подобного окружения несколько раз для одного проекта.

  • Расходы. Динамические окружения, очевидно, дублируют много ресурсов. Это могут быть облачные ресурсы или наши приложения, которые потребляют оперативную память и занимают память на диске. За все это нужно платить реальными деньгами, если вы пользуетесь публичным облаком, или виртуальными — если речь о внутренних контурах.

  • Чистка стендов. Очевидно, что нам не нужны сотни окружений. Чтобы вовремя удалять лишнее, понадобится механизм автоматического ретеншена.

  • Микросервисы. С созданием динамических окружений для микросервисов не все так прозрачно и очевидно, но эту тему мы затронем чуть позже.

Какие сложности возникают со stateless-приложениями

Первое, на чем хочется остановиться — это stateless-приложения. С ними чуть проще, потому что нет баз данных, очередей и прочих элементов инфраструктуры. Типичный пример stateless-приложений — это фронтенд-приложения на React или NextJS. Например, в KTS типичный фронтенд-проект устроен так (слева вы видите список файлов):

В таком проекте нет ничего, связанного с деплоем или со сборкой docker-образа. С деплоем связан лишь GitLab CI, который импортирует дефолтный CI. Это позволяет тиражировать весь процесс CI/CD на сотни проектов. Этот CI уже содержит в себе все необходимое для поднятия динамических окружений. Обычно мы их поднимаем так, чтобы эта функциональность была максимально отстраненной от DevOps-инженеров и тех, кто занимается инфраструктурой. Чтобы она работала сама по себе, нужно учесть несколько важных аспектов.

Универсальный Dockerfile. Одна из хороших практик в работе с ним — это иметь общий Dockerfile для типичных приложений, которые вы деплоите в своей компании. При таком подходе Dockerfile не копируется в каждом проекте, а подставляется из CI/CD. Это позволяет централизованно менять различные конфигурации и Image Prefix, если у вас такой используется. На какое-то время мы переехали на прокси Docker Hub, что позволило быстро внести изменения во все проекты одновременно и заново их задеплоить.

Гибкая конфигурация сборки. Самое важное, о чем стоит помнить — не стоит собирать docker-образ для конкретного окружения, иначе вам придется собирать очень много образов под каждый dev, prod, stage и так далее. Желательно, чтобы у вас был единый образ, который можно менять снаружи с помощью переменных окружений в runtime или как-то по-другому. К сожалению, это не всегда простая задача, особенно для фронтовых проектов — может понадобится помощь разработчиков.

Dockerfile можно достаточно универсально параметризировать
ARG IMAGE_PREFIX
ARG NODE_IMAGE=node:16
ARG NGINX_IMAGE=nginx:alpine

FROM ${IMAGE_PREFIX}${NODE_IMAGE} as builder

ARG KTS_NPM_PROXY_TOKEN
ARG KTS_NPM_PROXY_REGISTRY
ARG SENTRY_DSN
ARG SENTRY_AUTH_TOKEN
ARG SENTRY_URL
ARG SENTRY_ORG
ARG SENTRY_PROJECT
ARG API_URL
ARG CI_COMMIT_REF_SLUG
ARG CI_COMMIT_SHORT_SHA

# building…

CI/CD. Примерно так выглядит процесс для dev у нас:

Здесь есть этап сборки, deploy-dev, сборка odr-архива для отправки в VK мини-приложения и нотификация. Мы получаем автоматические уведомления в Telegram и всегда знаем, что происходит со сборкой — это позволяет быстрее реагировать на инциденты и проблемы.

Деплой в продакшн. Это может быть деплой в Kubernetes, в docker-образах, бакет S3, кастомные сценарии, VK ODR или что-то другое — мы сконцентрируемся на dev. Проще всего строить динамические окружения именно на Kubernetes. Раньше мы строили такие окружения на простых файлах Docker Compose, но приходилось выполнять много ручной работы, а вот с Kubernetes все удобно выстраивается в единый пайплайн.

Будем считать, что мы деплоим стенды по отдельным неймспейсам. Это удобно: не нужно переживать за пересечение ресурсов окружения по именам, в рамках неймспейса все независимо. В таком случае мы деплоим контент-приложение, в котором может быть несколько реплик в каждом окружении.

Допустим, у нас есть три окружения: два для проекта 1 и одно для проекта 2:

Далее эта схема работает вот так:

Перед всеми окружениями должен стоять прокси, который сможет направлять routed traffic в конкретное окружение. Здесь стоит выбор, как это делать: по доменным именам или любым другим образом.

Подключение к API. Есть два способа, которые мы используем. Очевидно, что фронтенд-приложение обычно взаимодействует с бэкендом, если это не простой лендинг. В таких случаях необязательно делить бэкенд на окружения без особой необходимости. Каждое фронтенд-окружение может взаимодействовать с одним и тем же бэкендом.

Подключение к API (shared)
Подключение к API (shared)
Подключение к API (dedicated)
Подключение к API (dedicated)

Здесь есть два возможных решения: 

  • CORS — с фронтенд-приложения захардкожен URL-адрес или домен API-приложения (api.example.com), а фронтенд разложен на кастомном домене (project1-env1.example.com). Запрос из этого домена в api.example.com кросс-доменный, поэтому разрешите кросс-доменные запросы со стороны API или бэкенда. Это очевидный путь, но немного неудобный, потому что его нужно каждый раз настраивать заново.

  • Self Proxy — мы используем именно этот подход, потому что он позволяет работать в нашем фронтовом домене. Там мы создаем прокси на /api, который будет проксировать в api-host.example.com. В общем, создается ingress, который проксирует на общий домен.

apiVersion: v1 
kind: Service 
metadata: 
  name: project1-env1-api-proxy 
spec: 
  type: ExternalName 
  externalName: api-host.exeample.com 

---

api.Version: networking.k8s.io/v1 
kind: Ingress 
metadata: 
  name: project1-env1-api-proxy 
  annotations: 
    nginx.ingress.kubernetes.io/upstream-vhost: "api-host.exeample.com"
spec: 
  # ... 
  rules: 
    - host: project1-env1.devexample.com
     http:
       paths: 
       - path: /api
       pathType: Prefix 
       backend:
         service: 
           name: project1-env1-api-proxy 
           port: 
             name: http 

В некоторых ситуациях для конкретного фронтенд-окружения создается отдельное бэкенд-окружение. В таком случае можно настроить CI так, чтобы переопределять API-домен, на который будет ходить фронтенд. 

Как получать доступ ко всему, что мы деплоили

Первый вариант — внутренний контур с VPN:

В таком случае нужно задумываться только о том, как подключаться к VPN и как сделать так, чтобы ваш сервис был доступен только внутри VPN. 

Для нас это не совсем актуально, потому что нам часто нужен доступ извне — например, чтобы поделиться ссылкой с заказчиком, у которого нет доступа к внутреннему VPN. Поэтому нам приходится жить во внешнем контуре:

В случае с внешним контуром нам приходится каким-то образом защищать наши сервисы — например, с помощью Basic авторизации. Также достаточно остро встает проблема выписывания валидных TLS-сертификатов. С ней мы прошли три стадии:

  1. Выписывать сертификат на каждое окружение каждого домена. Довольно долго мы выписывали сертификат через Let’s Encrypt и не упирались в лимиты, но затем потеряли доступ к прошлому кластеру — пришлось пересетапить кластер и выписать все сертификаты одновременно. Это привело к тому, что мы уперлись во все возможные rate-лимиты. 

В качестве быстрого решения мы засетапили несколько новых доменов первого уровня и перевели на них самые горящие проекты, но в целом я не советую этот способ — приходится выпускать слишком много сертификатов. Кстати, лучше использовать не основной домен компании/проекта, а отдельный. Например, связанный с нашим окружением домен имеет вид: <имя окружения>.<имя проекта>.devexample.com.

  1. Выписывать сертификат на проект. Мы выписывали на *.<project>.example.com и копировали этот сертификат между всеми окружениями. Это в разы сокращает количество создаваемых сертификатов. Но к сожалению, это не поможет, если начнется массовое перевыписывание сертификатов или проектов станет очень много.

  2. Выписывать сертификат на wildcard. Это самое очевидное решение. Здесь мы не можем использовать поддомены из-за особенностей работы сертификатов, выпущенных на wildcard домены, например *.example.com (сертификаты Let’s Encrypt не распространяют свое действие на поддомены более высокого уровня). Поэтому мы используем свою форму окружения (<имя проекта>-<имя окружения>.example.com, например, projectA-main.example.com) и деплоим все проекты в таком формате. Тогда мы выписываем один сертификат, копируем его во все возможные неймспейсы и используем без выписывания дополнительных сертификатов.

Как распространять сертификаты? Под эту задачу мы реализовали свой контроллер в Kubernetes и писали о нем на Хабре. Также рекомендую посмотреть в сторону Kyverno. В примере ниже можно увидеть как раз копирование секрета из неймспейса default во все остальные.

apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: sync-dev-tls-secret
  annotations:
    policies.kyverno.io/title: Sync Secrets
    policies.kyverno.io/category: Sample
    policies.kyverno.io/subject: Secret
spec:
  rules:
    - name: sync-certificate
      match:
        resources:
          kinds:
            - Namespace
      generate:
        apiVersion: v1
        kind: Secret
        name: mycompany-dev-tls
        namespace: "{{ request.object.metadata.name }}"
        synchronize: true
      clone:
        namespace: default
        name: mycompany-dev-tls

Как работать со stateful-сервисами

Что такое stateful-приложение? Представим, что у нас есть бэкенд — некоторый blackbox, который выполняет какие-то действия и работает с базой данных. Это может быть Postgres, in-memory решение или что-то иное — пока важно только то, что есть некоторое состояние, которое где-то персистится. 

Обычно мы работаем с несколькими репликами этого приложения, которые взаимодействуют с базой данных. Но зачастую архитектура проекта чуть сложнее:

Рассмотрим эту структуру подробнее. У вас могут быть:

  • Процессы приложения (backend на рисунке), выполняющие роль, например API

  • Фоновые процессы (демоны), которые взаимодействуют с той же БД

  • Фоновый процессинг задач, для которого нужны очередь и сами консьюмеры

  • Слой кеширования в виде, например, Redis или Memcached

  • Сохранение файлов в хранилище (например, S3), которое находится в облаке или выделенной инфраструктуре

Все компоненты тесно связаны: ходят в одну базу данных, в один кеш, в одну очередь. Это все мы будем считать одним микросервисом. Таких сервисов может быть много, и мы получаем микросервисную архитектуру:

При этом перед всеми ними наверняка есть прокси, которое проксируют запросы до этих сервисов.

Когда архитектура проекта усложняется, появляются новые нюансы. Например, если мы хотим внедрить динамические окружения для подобного проекта, нужно найти ответы на такие вопросы: 

  • Как поднимать копии проекта для каждой ветки? 

  • Нужно ли дублировать БД, кеш, s3 и очереди? 

  • Как можно сэкономить? 

  • Как комплексно тестировать разные миркросервисы и их взаимосвязь?

Для начала постараемся ответить на другой вопрос: как сделать так, чтобы разработчики могли устанавливать все без DevOps-инженеров? Нам  в этом помогут Terraform, Crossplane и другие подобные инструменты Infrastructure as Code. Первый подход с деплоем окружений для бэкенда — изолировать все компоненты:

Все компоненты изолированы
Все компоненты изолированы

Допустим, у нас есть окружение для проекта, и мы деплоим компоненты в рамках этого окружения (базу данных, кеш и очередь). В рамках Kubernetes всё может быть в одном неймспейсе. Это простой путь, который реализуется с помощью опции dependencies внутри Helm Chart. Так можно по необходимости включать и выключать компоненты в окружении:

apiVersion: v2 
name: mybackend
type: application
version: 0.1.7 
appVersion: "0.0.1"

dependencies: 
- name: rabbitmq
  version: 12.14.1 
  repository: https://charts.bitnami.com/bitnami 
  condition: rabbitmq.enabled 
- name: redis 
  version: 18.18.0 
  repository: https://charts.bitnami.com/bitnami 
  condition: redis.enabled 
- name: postgresql 
  version: 16.2.0 
  repository: https://charts.bitnami.com/bitnami 
  condition: postgresql.enabled 

При этом не всегда удобно использовать базу данных, которая деплоится внутри каждого неймспейса. Мы можем сэкономить ресурсы: вынести эту базу в отдельный инстанс и использовать ее во всех окружениях. 

Здесь есть три пути:

  1. Вручную писать bash-скрипты, которые будут создавать базу данных при поднятии нового окружения и удалять ее при удалении окружения. Это тяжелый путь, но иногда он единственный (если вы работаете без облака, Terraform и Crossplane):

CREATE DATABASE project1_env1;

CREATE DATABASE project1_env2;

CREATE DATABASE project2_env1;
Общая БД
Общая БД
  1. Использовать Terraform. В таком случае мы размещаем базу данных в облаке и используем этот единый инстанс. Другими словами, мы создаем базы данных динамически с помощью Terraform:

resource "yandex_mdb_postgresql_database" "foo" { 
  cluster_id = yandex_mdb_postgresql_cluster.project1.id 
  name       = "project1_env1"
  owner      = yandex_mdb_postgresq _user.project1_env1.name
  lc_collate = "en_US.UTF-8"
  lc_type    = "en_US.UTF-8"
}
  1. Использовать Crossplane. Это похожий на Terraform инструмент, который решает ту же задачу, но с помощью Kubernetes YAML-манифестов. 

    Допустим, мы хотим поднять новое окружение env1 для проекта project1. Вместе с остальными манифестами проекта мы можем задеплоить манифест PostgresqlDatabase, который создаст в облаке базу данных с нужным именем и привяжет ее к общему инстансу БД для dev окружений. 

    Мы получим секреты, и приложение сможет подключиться к базе. Так мы сможем переиспользовать один инстанс СУБД для подобных динамических окружений, не тратя лишние деньги и делая это автоматически при развертывании окружения:

apiVersion: mdb.yandex-cloud.jet.crossplane.lo/vlalphal
kind: PostgresqlDatabase 
metadata:
  name: projectl-env1 
spec: 
  providerConfigRef: 
    name: yc-provider 
  forProvider: 
    name: projectl-envl 
    clusterId: {{ .Values.testCluster.clusterid }} 
    owner: project1-env1-user 
    lcCollate: en US.UTF-8 
    lcType: en_US.UTF-8 

Как решать проблему пустой базы данных

Когда мы поднимаем окружение описанным способом, очевидно, что база данных будет пустая. Здесь есть два варианта, как стартовать не с нуля: 

  • Генерировать данные при выкатке приложения

  • Добавлять в базу данных слепок из stage или production

Второй вариант кажется предпочтительнее, ведь так мы сможем оперировать реальными данными, а не моковыми. Например, если вы делаете образовательный портал, будет здорово добавить в базу курсы с продакшена, чтобы наглядно видеть, как все меняется и взаимодействует.

Итак, у нас наверняка есть выделенный контур с production и выделенный контур с dev:

Как нам перевести данные из production в dev?

Первый путь — деплой базы данных внутри каждого окружения.

Например, вы используете Postgres, наследуетесь от этого Postgres-образа и докидываете туда данные:

apiVersion: storage.yandex-cloud.jet.crossplane.io/vlalphal 
kind: Bucket 
metadata: 
  name: project1-env1-bucket 
spec: 
  forProvider: 
    accessKeyRef: 
      name: example-sa-static-key 
    secretKeySecretRef: 
      name: sa-key-conn 
      namespace: project1-env1 
      key: attribute.secret_key
    bucket: "dev-project1-env1-bucket"
    acl: "public-read"
    forceDestroy: true 
  providerConfigRef: 
    name: yc-provider 

Можно делать это с помощью механизма Schedule a pipeline в GitLab CI/CD. Можно сделать так, чтобы каждый день запускался ваш кастомный скрипт: каждый день он будет ходить в прод БД, делать слепок базы или брать последний бэкап, обфусцировать и заливать данные в образы для dev БД.

Весь процесс выглядит так:

Второй путь — это деплой через общую базу данных. Допустим, в Yandex Cloud у нас есть инстанс PostgreSQL БД и некоторая production база данных внутри. Yandex Cloud предоставляет достаточно удобный инструмент, под названием Data Transfer, который может подключаться к разным источникам на входе и выходе и переносить данные единоразово или все время.

Наш подход переноса данных будет выглядеть следующим образом. С помощью Crossplane мы создаем новую базу данных, поднимаем манифест Data Transfer, пропускаем это все через наш кастомный  обфускатор данных, дожидаемся завершения — и всё, окружение готово к работе:

apiVersion: datatransfer.yandex-cloud.jet.crossplane.io/vlalphal
kind: Transfer 
metadata: 
  name: project1-env1-transfer 
spec:
  forProvider: 
    folderId: {{ .Values.dataTransfer.folderId }}
    name: "project1-env1-transfer" 
    sourceIdRef: 
      name: project1-prod-source 
    targetIdRef: 
      name: project1-env1-target
    type: SNAPSHOT_ONLY
providerConfigRef:
  name: yc-provider

Создавая объект Data Transfer, мы указываем ID источника, откуда тянуть данные и куда записывать данные. Мы используем тип Snapshot Only, чтобы сделать просто слепок — нам не нужны постоянные инкрементальные переливки данных.

Посмотрим, как это выглядит: 

Таким образом, наши данные хранятся в одной-единственной dev базе данных. Внутри этого dev инстанса есть базы данных под каждое окружение, которые автоматически наливаются нашим Data Transfer из production или из бэкапа.

Облачные ресурсы работают точно так же. Если вы хотите бакет S3, для этого можно также использовать Crossplane. Тогда при создании бакета данные для авторизации  пропишутся автоматически в определенный секрет, который приложение сможет подтянуть при старте.

Самое крутое, что вы можете — передать флажок forceDestroy: true (только не используйте это для production). Когда окружение гаснет, бакет очищается автоматически, и вы не платите лишние деньги за эти ресурсы.

Как воспринимать микросервисы

Есть два способа воспринимать микросервисы.

Первый подход — микросервисы как части целого. Предположим, вы делаете большой сервис с несколькими микросервисами внутри него. В этом случае мы воспринимаем, что весь сервис деплоится как одна большая сущность, при этом не обязательно, чтобы каждый микросервис менялся при каждом релизе.

При таком подходе можно использовать подход Umbrella чартов в Helm. В таком случае у нас есть некоторый большой чарт, в котором нет темплейтов и ресурсов, есть только dependency на отдельные микросервисы. Здесь есть прекрасная возможность запинить версии микросервисов, то есть сказать, что у нас микросервис А — версия 1.0, микросервис C — 1.3.

Это подход рабочий, но не совсем удобный, если над продуктом работает множество команд. Все становится еще неудобнее, если команды работают над каждым микросервисом в отдельности. При таком подходе всегда есть надкоманда, которая объединяет все микросервисы в один — получается, что мы зависим от какого-то верхнего управления над всеми микросервисами и весь смысл независимой выкатки и существования микросервисов теряется.

По поводу динамических окружений: с одной стороны, поднимать их достаточно удобно. Мы перечислили все наши зависимости с нужными версиями, запинили, задеплоили, получили окружение для всего большого сервиса. Но зачастую это перерасход ресурсов: в 80—90% случаев вам не нужно поднимать новую копию каждого микросервиса для каждой ветки или стенда сервиса. Скорее всего, вам нужно окружение под конкретный микросервис, который вы хотите протестировать. Для этого может хватить ресурсов, которые подняты в кластере, особенно если это крупные сервисы с большим объемом данных.

Второй подход — микросервисы как отдельные независимые сервисы.

Для примера представим, что у нас есть микросервисы А, B и С, и мы строим для них динамические окружения:

В какой-то момент нам нужно поднять новое env4 окружение для микросервиса C, при этом у нас есть некоторые доработки для микросервиса А в env1 и для микросервиса B в env2.

Из-за этого во время запуска нового окружения мы должны добавить связи с нужными окружениями через, например, конфигурацию приложения. Те окружения могут быть погашены, но это скорее процессная проблема, нежели техническая.

Такой подход кажется более жизнеспособным. Он особенно полезен, если над проектом работает несколько команд с частыми и независимыми циклами деплоя и множеством окружений. 

Как автоматизировать очистку ресурсов

Напомним, что наш CI/CD устроен примерно так:

Обратите внимание, что у каждого окружения есть стоп-джобы — это очень удобный механизм гашения окружений. Стоп-джобы удобны тем, что они запускаются при ручном запуске: можно вручную нажать кнопку «Остановить» и выполнить скрипт, который остановит окружение. 

Если имя ветки содержится в имени окружения, что эти джобы автоматически вызываются:

  • При удалении ветки 

  • При мердж-реквесте

Например, разработчик отправил мердж-реквест, команда поревьюила, тестировщик протестировал, клиент посмотрел и одобрил. Как только мы мержим, ресурсы удаляются автоматически.

Выводы

Динамическое окружение — это очень большой вопрос, и в этой статье мы затронули только самые базовые вещи, с которыми наши клиенты сталкиваются чаще всего. Главные выводы, которые мы хотим подсветить:

  • Стенды для бэкенда зачастую влекут за собой много расходов

  • Работать со стендами для бэкенда сложно с точки зрения технологий и процессов

  • Важно не забывать чистить стенды

  • Выбирайте тип стендов для микросервисов, который ближе всего к вашей инфраструктуре

  • Важно наладить процесс наливки пустой базы (выше мы обсудили несколько способов)

Если вам интересно изучить эту тему подробнее, вы можете: 

Другие статьи про DevOps для начинающих:

Другие статьи про DevOps для продолжающих:

Комментарии (2)


  1. luxter
    06.08.2024 11:59

    А для различия фича-окружений используется версионирование в приложениях или по хешу коммита разделение происходит?


    1. igorcoding Автор
      06.08.2024 11:59

      Обычно окружения поднимаются на домене, содержащим название ветки в git. То есть не произвольный коммит поднимается в качестве стенда, а именно ветка, либо тег.