Бесперебойный деплой микрофронтендов с Kubernetes: как настроить

от автора

Фронтенд-разработка может жить без независимого деплоя, пока у нее не больше 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 или отдельные инстансы приложений на виртуальных компьютерах.


ссылка на оригинал статьи https://habr.com/ru/company/mindbox/blog/711898/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *