Фронтенд-разработка может жить без независимого деплоя, пока у нее не больше 7 микрофронтендов. Но, чем выше число, тем сильнее страдают процессы. Наша команда в Mindbox прошла через это с Octopus, когда деплоила в Yandex Cloud S3. Причем на все обновления был один свободный бакет. Заливаешь код в мастер, а в это время то же самое делают еще пять разработчиков. Скапливается очередь, код еле ползет, а через час деплой вообще обваливается — Octopus не справился с нагрузкой. Пока чинишь это, оказывается, что твои обновления уже попали в продакшен заодно с чужими. 

Когда число проектов возросло до 14, все это повторялось с каждым разработчиком по несколько раз в день. Поэтому мы решили вслед за коллегами-бэкендерами перейти на независимый деплой в Kubernetes.

В этой статье собран опыт платформы автоматизации маркетинга Mindbox по реформированию фронтенда:

  • Kubernetes вместо Yandex Cloud S3: деплоим микрофронтенды без сбоев

  • Автоматизированный вывод метаданных: экономим ресурсы разработки

  • Постепенный переход: меняем деплой без вреда для пользователей

  • Хот-тестинг: ускоряем обновление фронтенда

  • Советы: как улучшить деплой без микрофронтендов и Kubernetes

Исходные данные

Команды: 68 бэкенд-разработчиков, 12 фронтенд, 10 SRE.

Бэкенд: CDP (customer data platform) как основной монолит и пара десятков микросервисов вокруг него.

Фронтенд:

  • старый — смесь C# Razor и React, который выдается из монолитного бэка;

  • новые микрофронтенды на React, которые разделены по бизнес-доменам и выдаются из двух бакетов в Yandex Cloud S3 — А и B.

  • Репозитории: код разных МКФ хранится в отдельных репозиториях.

  • CI/CD для деплоя: GitHub actions и Octopus.

Kubernetes вместо Yandex Cloud S3: деплоим микрофронтенды без сбоев

В Mindbox долгое время микрофронтенды деплоились созависимо. После пуша в мастер Github Action упаковывал обновленный код в бандл. Octopus подхватывал этот бандл и забирал из хранилища другие — с актуальным кодом всех микрофронтендов. Дальше он проверял, какой из бакетов в Yandex Cloud S3 сейчас свободен — А или B. Предположим, А. Тогда Octopus выгружал в него бандлы и направлял туда трафик.

Созависимый деплой в Yandex Cloud S3
Созависимый деплой в Yandex Cloud S3

У такой системы два недостатка.

Первый в том, что Octopus сбоит, когда число микрофронтендов переваливает за 7 и все они деплоятся одновременно. Он не умеет работать с микрофронтендами последовательно. Вместо того, чтобы целиком обновить один, а потом браться за следующий, Octopus постоянно переключается между ними. Из-за этого деплой зависает — приходится перезапускать его вручную. Вот как выглядит эта проблема на примере двух приложений — C и D:

  1. Запущен деплой обновлений приложения C. 

  2. Octopus направляет код приложения C в свободный бакет A.

  3. Запущен деплой обновлений приложения D. Обновления C еще не выгрузились в бакет.

  4. Octopus бросает C, переключается на D и несет его код в тот же бакет A, который по-прежнему свободен.

  5. Код приложения D выгружается в бакет A.

  6. Octopus возвращается к деплою C. Но к тому времени бакет A уже занят, код C не может в него попасть и деплой прерывается.

Деплой кода приложений C и D. Octopus направил C в свободный бакет А, но не закончил выгрузку и переключился на приложение D. Из-за этого деплой кода C обвалился
Деплой кода приложений C и D. Octopus направил C в свободный бакет А, но не закончил выгрузку и переключился на приложение D. Из-за этого деплой кода C обвалился

У системы с двумя бакетами есть и второй недостаток: нарушается принцип независимого деплоя. Поскольку Octopus собирает данные всех известных микрофронтендов, команды разработчиков не могут выкладывать свой код автономно и зависят друг от друга. Представьте, что приложения C и D обновляются одновременно:

  1. В продакшене — версия 1 приложения C и 1 — приложения D. 

  2. Запускается деплой новых версий — C 2 и D 2. Код обеих направляется в единственный свободный бакет А.

  3. Octopus сначала выкладывает С 2 и попутно собирает весь актуальный код, какой находит, в том числе D 2.

  4. В продакшене — версии C 2 и D 2.

  5. Octopus собирается выложить D 2, но ее деплой уже обвалился, поскольку бакет А был занят. Тем не менее код D 2 ранее попал в продакшен вместе с C 2.

Чем больше обновлений так пересекаются, тем сложнее отследить их статус.

Деплой в Octopus

Статус деплоя

Продакшен

Запуск     C 2

                  D 2

С 2 — активный

D 2 — активный

C 1

D 1

Выкладка C 2

С 2 — завершен

D 2 — прерван

C 2

D 2

Выкладка D 2

С 2 — завершен

D 2 — прерван

C 2

D 2

При деплое версии 2 приложения C Octopus выложил актуальный код всех микрофронтендов. В продакшен попала версия 2 приложения D, хотя ее деплой прервался. Из-за этого статус обновлений D 2 отобразился неверно

Чтобы микрофронтенды выкладывались независимо и без сбоев, можно запустить деплой в Kubernetes. Схема следующая. Когда обновляется код микрофронтенда, Github Action все так же собирает его в бандл. А дальше бандл упаковывается не в папку, а в докер-контейнер с Nginx внутри. Octopus переносит контейнер в Kubernetes, где он запускается в нужном окружении.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ .Values.name }}-deployment-{{ .Values.environment }}
spec:
  replicas: {{ .Values.services.replicas }}
  selector:
    matchLabels:
      product: {{ .Values.name }}
      
      # Помечаем, что этот под содержит в себе код микрофронтенда.
      microfrontend-pod: {{ .Values.isMicrofrontendPod | quote }}
  template:
    metadata:
      labels:
        product: {{ .Values.name }}
        microfrontend-pod: {{ .Values.isMicrofrontendPod | quote  }}
        deploy-environment: {{ .Values.environment }}
    spec:
      imagePullSecrets:
        - name: image-pull-{{ .Values.environment }}
      containers:
        - name: {{ .Values.name }}-pod
          image: "image-repo/{{ .Values.name }}:{{ $.Values.packageVersion }}"
          imagePullPolicy: Always
          resources:
            requests:
              cpu: {{ .Values.services.resources.requests.cpu }}
              memory: {{ .Values.services.resources.requests.memory }}
            limits:
              cpu: {{ .Values.services.resources.limits.cpu }}
              memory: {{ .Values.services.resources.limits.memory }}
          ports:
            - containerPort: 8080
      tolerations:
        - key: dedicated
          operator: Equal
          value: mindbox-worker
          effect: NoSchedule
        - key: dedicated
          operator: Equal
          value: mindbox-worker
          effect: PreferNoSchedule
---
apiVersion: v1
kind: Service
metadata:
  name: {{ .Values.name }}-service-{{ .Values.environment }}
spec:
  selector:
    product: {{ .Values.name }}
    microfrontend-pod: {{ .Values.isMicrofrontendPod | quote }}
    deploy-environment: {{ .Values.environment }}
  ports:
    - name: main
      protocol: TCP
      port: 8080
      targetPort: 8080
---
apiVersion: traefik.containo.us/v1alpha1
kind: IngressRoute
metadata:
  name: {{ .Values.name }}-ingressroute-{{ .Values.environment }}-index-html
  labels:
    mindbox/traefik: common
spec:
  entryPoints:
    - websecure
  routes:
  # Собираем URL, по которому будет отвечать под. 
  # Нам нужно, чтобы он отвечал по всем запросам по определенному URL 
  # и заголовку "environment", который проставляется в HAProxy.
    - match: HostRegexp(`{tenant:.*}.{{ .Values.host }}`) && (PathPrefix(`/{{ .Values.namespace }}`)) && Headers(`environment`, `{{ .Values.environment }}`)
      kind: Rule
      priority: 50
      services:
        - port: 8080
          name: frontend-initial-builder-service-{{ .Values.environment }}
  tls:
    options:
      name: agrade-tls-options
      namespace: traefik-common
---
apiVersion: traefik.containo.us/v1alpha1
kind: IngressRoute
metadata:
  name: {{ .Values.name }}-ingressroute-{{ .Values.environment }}
  labels:
    mindbox/traefik: common
spec:
  entryPoints:
    - websecure
  routes:
  # Собираем URL, по которому будет отвечать под. 
  # Нам нужно, чтобы он отвечал по всем запросам по определенному URL 
  # и заголовку "environment", который проставляется в HAProxy.
    - match: HostRegexp(`{tenant:.*}.{{ .Values.host }}`) && PathPrefix(`/v2_static/{{ regexReplaceAll "-" .Values.name  "_"}}/`) && Headers(`environment`, `{{ .Values.environment }}`)
      kind: Rule
      priority: 50
      services:
        - name: {{ .Values.name }}-service-{{ .Values.environment }}
          port: 8080
  tls:
    options:
      name: agrade-tls-options
      namespace: traefik-common

Helm chart пода микрофронтенда

То есть вместо двух бакетов в Yandex Cloud S3 мы получаем десяток изолированных контейнеров в Kubernetes и таким образом решаем проблему с деплоем большого количества микрофронтендов. Их можно обновлять независимо друг от друга.

Независимый деплой в Kubernetes
Независимый деплой в Kubernetes

Автоматизированный вывод метаданных: экономим ресурсы разработки

Каждый микрофронтенд предоставляет свой файл с метаданными. На основе этих файлов выстраивается роутинг и наполняется основное меню страницы. Если браузер станет загружать их все по отдельности, чтобы вывести меню пользователю, это займет много времени. Чтобы ускорить процесс, все метаданные нужно предварительно объединить в один файл.

С прежним деплоем Octopus собирал метаданные одновременно с релизом кода. Он запускал скрипт, в котором был список микрофронтендов, и этот скрипт находил файлы с метаданными — remoteEntry.js. Затем Octopus склеивал все в один файл initial.js и создавал index.html со ссылкой на него. Все это вместе с бандлами отправлялось в бакет в Yandex Cloud S3. По запросу пользователя браузер выводил index.html с актуальными метаданными.

В Kubernetes сервис Initial builder собирает метаданные только тех микрофронтендов, которые находятся в рантайме. Можно было бы, как раньше, использовать сервис с вложенным списком микрофронтендов и обновлять этот список вручную. Но мы вместо этого автоматизировали процесс с помощью специального класса сервисов под названием headless services. Теперь все докер-контейнеры помечены тегами. Initial builder с помощью headless services считывает их и находит контейнеры с данными, которые запросил пользователь. Дальше Initial builder достает файлы с метаданными и склеивает в один, который затем передает по назначению.

export const getAdressessOfPods = async (req: Request, res: Response, next: NextFunction) => {
  // Получаем из переменных среды имена сервисов и сортируем их, 
  // чтобы опрашивать в нужной последовательности.
  const headlessServices = getSortedHeadlessNames(Object.keys(process.env));
  const reachedModules = [];

  const headlessServicePromises =
    headlessServices.length === 0
      ? [getAddressesOfMcf()]
      : headlessServices.map((service) => getAddressesOfMcf(process.env[service]));

  const modules = await Promise.allSettled([...headlessServicePromises]);

  for (const module of modules) {
    if (module.status === 'rejected') {
      logMessage(`can't get mcfAddressArray; reason: ${module.reason}`, {
        host: req.hostname,
      });
      continue;
    }

    reachedModules.push(...module.value);
  }

  if (reachedModules.length === 0) {
    const errorMessage = `no headless services found. headlessServices: ${headlessServices}`;
    logException(new Error(errorMessage));
    res.status(500);
    res.send('no modules found');
    return;
  }
  
  // Складываем полученные адреса в locals, 
  // чтобы получить их в другой middleware.
  res.locals[RES_LOCALS.mcfAddresses] = reachedModules;

  next();
};

Получение списка подов микрофронтендов

const createRegExForReplace = (MCFName: string) =>
  new RegExp(`\\/\\*!\\s@mcf\\sstart\\s${MCFName}\\s\\*\\/.+\\/*!\\s@mcf\\send\\s${MCFName}\\s\\*\\/`);

export const buildNewInitialJs = ({ modulesArray }: BuildNewInitialJsArgs) => {
  let newInitial = initEmptyModulesList;

  if (modulesArray.length === 0) {
    throw new Error('No MCF array are provided');
  }

  modulesArray.forEach((moduleCode) => {
    const safeNoduleCode = deleteNewLines(moduleCode);
    const name = REGEX_FOR_FIND_MCF_NAME.exec(safeNoduleCode);
    const moduleName = name?.groups?.['MCF_NAME'];

    if (!moduleName) {
      logException(INVALID_REMOTE_ENTRY_ERROR);
      return;
    }

    const isMcfNameExistInInitialJs = newInitial.search(createRegExForReplace(moduleName));

    if (isMcfNameExistInInitialJs === -1) {
      newInitial = newInitial.concat(stub, safeNoduleCode, stub, initModule(moduleName));
    } else {
      newInitial = newInitial.replace(createRegExForReplace(moduleName), safeNoduleCode);
    }
  });

  newInitial = newInitial.concat(stub, loadModules);

  return pasteNewLines(newInitial);
};

export const handleInitialJs = async (_: Request, res: Response) => {
  res.type('.js');
  try {
    // Получаем файлы с метаданными из каждого пода.
    const modulesArray = await getRemoteEntries(res.locals[RES_LOCALS.mcfAddresses]);

    // Собираем массив с метаданными в один файл и отдаем его пользователю.
    const newInitialJs = buildNewInitialJs({
      modulesArray,
    });
    res.send(newInitialJs);
  } catch (error) {
    Sentry.captureException(error);
    res.status(500).send("Can't build initial.js");
  }
};

Создание единого файла с метаданными initial.js

Автоматизированный вывод метаданных
Автоматизированный вывод метаданных

Постепенный переход: меняем деплой без вреда для пользователей

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

Какое-то время Initial builder не собирал файл с метаданными целиком, а обновлял тот, что собирался в Yandex Cloud S3 старым способом. То есть Initial builder получал запрос от пользователя и скачивал из Yandex Cloud S3 файл с метаданными. Дальше он с помощью headless services находил и скачивал все доступные файлы с метаданными микрофронтендов. В начале каждого такого файла есть системное имя — точно такое же указано в исходном файле из Yandex Cloud S3. С помощью регулярного выражения фрагменты метаданных в старом файле заменялись на новые. После этого браузер получал и выводил актуальные метаданные.

Таким образом у нас какое-то время часть микрофронтендов жила на новом деплое, часть на старом. Пользователи ничего не заметили.

Хот-тестинг: ускоряем обновление фронтенда

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

Изначально код тестировали в четырех окружениях:

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

  • стандард — проект с очищаемой базой, только для е2е-тестов;

  • бета — 10% клиентов, которые готовы мириться с возможными багами ради того, чтобы получить обновления первыми;

  • стейбл — все остальные клиенты.

Чтобы ускорить процесс, мы ввели для фронтенда что-то вроде престейджинга — хот-тестинг. Когда мы открываем pull request, GitHub Action собирает код в бандл, упаковывает в докер-образ и ставит на него тег, равный хешу ветки. Дальше докер-образ запускается в Kubernetes, где отличается от докер-контейнеров только тем, что отвечает на запросы от одного определенного домена. Чтобы этот домен получил все актуальные данные плюс обновление, в GitHub Action собирается отдельный под Initial builder, для которого указывается два headless service — основной и дополнительный. Основной находит любые докер-контейнеры в стейджинге, а дополнительный — только новый докер-образ.

# Поднимаем для тестирования отдельный Initial builder.
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: frontend-initial-builder-ht-HASH_PAYLOAD
  namespace: microfrontends
spec:
  selector:
    matchLabels:
      app: frontend-initial-builder-ht-HASH_PAYLOAD
  replicas: 1
  template:
    metadata:
      labels:
        app: frontend-initial-builder-ht-HASH_PAYLOAD
      annotations:
        commit_sha: CI_COMMIT_SHA
    spec:
      imagePullSecrets:
        - name: image-pull-staging
      containers:
        - name: initial-builder-pod
          imagePullPolicy: Always
          image: image-repo/frontend-initial-builder:latest
          resources:
            requests:
              cpu: "500m"
              memory: "300M"
            limits:
              cpu: "1000m"
              memory: "500M"
          ports:
          - containerPort: TARGET_PORT
          env:
# Передаем через переменные окружения названия headless service для поиска МКФ:
# первый - базовый, который получит весь код на стейджинг.
            - name: headless_service_1
              value: headless-mcf-finder-staging

# Второй – дополнительный, который найдет только приложение, которое тестируем.             
            - name: headless_service_2
              value: headless-mcf-finder-ht-HASH_PAYLOAD
            - name: ENVIRONMENT
              value: staging
      tolerations:
        - key: dedicated
          operator: Equal
          value: mindbox-worker
          effect: NoSchedule
        - key: dedicated
          operator: Equal
          value: mindbox-worker
          effect: PreferNoSchedule
---
apiVersion: v1
kind: Service
metadata:
  name: frontend-initial-builder-ht-s-HASH_PAYLOAD
  namespace: microfrontends
spec:
  selector:
    app: frontend-initial-builder-ht-HASH_PAYLOAD
  ports:
    - name: main
      protocol: TCP
      port: TARGET_PORT
      targetPort: TARGET_PORT

Helm chart для поднятия специальной версии Initial builder для в режиме хот-тестинга

# Поднимаем под с кодом МКФ, который хотим протестировать.
# В названии пода HASH_PAYLOAD - уникальный хеш, который используется для навигации трафика. 
# Этот же хеш в домене, по которому будет открываться тестовый проект.
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: hot-testing-HASH_PAYLOAD
  namespace: microfrontends
spec:
  selector:
    matchLabels:
      app: hot-testing-HASH_PAYLOAD
  replicas: 1
  template:
    metadata:
      labels:
        app: hot-testing-HASH_PAYLOAD
      annotations:
        commit_sha: CI_COMMIT_SHA
    spec:
      imagePullSecrets:
        - name: image-pull-staging
      containers:
      - name: hot-testing
        imagePullPolicy: Always
        image: DOCKER_IMAGE
        resources:
          requests:
            cpu: "30m"
            memory: "200M"
          limits:
            cpu: "45m"
            memory: "300M"
        ports:
        - containerPort: TARGET_PORT
      tolerations:
        - key: dedicated
          operator: Equal
          value: mindbox-worker
          effect: NoSchedule
        - key: dedicated
          operator: Equal
          value: mindbox-worker
          effect: PreferNoSchedule
---
apiVersion: v1
kind: Service
metadata:
  name: hot-testing-service-HASH_PAYLOAD
  namespace: microfrontends
spec:
  selector:
    app: hot-testing-HASH_PAYLOAD
  ports:
    - name: main
      protocol: TCP
      port: TARGET_PORT
      targetPort: TARGET_PORT
---
# Headless service, который надет под с тестируемым приложением.
apiVersion: v1
kind: Service
metadata:
  name: headless-mcf-finder-ht-HASH_PAYLOAD
  namespace: microfrontends
spec:
  clusterIP: None
  selector:
    app: hot-testing-HASH_PAYLOAD
  ports:
    - name: main
      protocol: TCP
      port: TARGET_PORT

Helm chart для поднятия пода с тестируемым МКФ

# Чтобы хот-тестинг выглядел как полноценное приложение, 
# часть трафика перенаправляем на домен со стейджингом.

apiVersion: v1
kind: Service
metadata:
  name: hot-testing-base-service-HASH_PAYLOAD
  namespace: microfrontends
spec:
  type: ExternalName
  externalName: BASE_PROJECT_URL
  ports:
    - name: main
      port: 443
      protocol: TCP
      targetPort: 443

---

# Распределяем трафик по подам.
apiVersion: traefik.containo.us/v1alpha1
kind: IngressRoute
metadata:
  name: hot-testing-ingressroute-HASH_PAYLOAD
  namespace: microfrontends
spec:
  entryPoints:
    - websecure
  routes:
  
  # Весь трафик, который должен обрабатывать тестируемый МКФ, направляем в под, который подняли. 
  # Ищем его по имени с хешем.
  - match: >-
      Host(`hot-testing-HASH_PAYLOAD-staging.mindbox.ru`)
      && PathPrefix(`/v2_static/PROJECT_FOLDER/`) 
    kind: Rule
    services:
      - name: hot-testing-service-HASH_PAYLOAD
        port: TARGET_PORT
  
  # Запросы за метаданными направляем в под с Initial builder, который подняли для теста.
  # За счет этого мы получаем метаданные, с замененной частью.
  - match: >-
      Host(`hot-testing-HASH_PAYLOAD-staging.mindbox.ru`)
      && PathPrefix(`/v2_static/initial_builder/`) 
    kind: Rule
    services:
      - name: frontend-initial-builder-ht-s-HASH_PAYLOAD
        port: TARGET_PORT
  
  # Весь остальной трафик направляем на обычный домен стейджинга.
  - match: Host(`hot-testing-HASH_PAYLOAD-staging.mindbox.ru`)
    kind: Rule
    services:
      - name: hot-testing-base-service-HASH_PAYLOAD
        port: 443
        passHostHeader: false

  tls:
    options:
      name: agrade-tls-options
      namespace: traefik-common

Helm chart для маршрутизации трафика в хот-тестинге

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

Маршрутизация трафика после того, как пользователь запросил страницу в режиме хот-тестинга
Маршрутизация трафика после того, как пользователь запросил страницу в режиме хот-тестинга

Запрос метаданных проходит по цепочке:

  1. Трафик направляется в под Initial builder, созданный для тестового домена.

  2. Initial builder с помощью основного headless service собирает метаданные всех микрофронтендов, а с помощью дополнительного — метаданные тестового микрофронтенда.

  3. В общем файле с метаданными Initial builder заменяет один из фрагментов на более новый — от тестового микрофронтенда.

  4. Итоговый файл initial.js уходит к пользователю.

Автоматизированный вывод метаданных в режиме хот-тестинга
Автоматизированный вывод метаданных в режиме хот-тестинга

В то же время под с тестовым микрофронтендом выдает пользователю код тестируемого приложения, а другие поды — весь остальной код.

В результате продакт-менеджер видит страницу со свежими обновлениями.

Так устроен изолированный проект с версией фронтенда, доступной для тестирования. Примерно такая же схема у нас с е2е в pull request. Мы хотим получить обратную связь об обновлениях еще до того, как зальем код в мастер. Поэтому создаем в проекте разработки отдельный под и проводим на нем тесты.

Советы: как улучшить деплой без микрофронтендов и Kubernetes

Если у вас еще нет микрофронтендов и весь фронтенд — это один большой монолит, можете позаимствовать из статьи хот-тестинг. Собирайте отдельную версию статики и отдавайте ее по запросу с каким-нибудь маркером. У нас это хеш в поддомене, но можно сделать и квер-параметры или заголовки.

Если у вас есть микрофронтенды и они деплоятся созависимо, можно создать много мелких подов в Kubernetes и настроить независимый деплой. В дальнейшем можно реализовать, например, АB-тесты разной статики, если это нужно бизнесу.

Внедрить независимый деплой можно и без Kubernetes — используйте AWS Lambda или отдельные инстансы приложений на виртуальных компьютерах.

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


  1. nimishin
    20.01.2023 18:46
    +1

    хорошая тема, кубер ест много ресурсов, как их оптимизировать - животрепещущая проблема


  1. chemtech
    21.01.2023 17:30
    +1

    Спасибо за пост. Напишите пожалуйста что такое Octopus и как он примерно работает. Или приложите ссылку. Спасибо.


    1. PetrNikitin Автор
      23.01.2023 11:00
      +1

      Octopus - https://octopus.com/ - это система доставки кода. Мы в ней настраиваем автоматизацию выкладки, чтобы один и тот же релиз последовательно выкатывался в разные окружения