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

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

У системы с двумя бакетами есть и второй недостаток: нарушается принцип независимого деплоя. Поскольку Octopus собирает данные всех известных микрофронтендов, команды разработчиков не могут выкладывать свой код автономно и зависят друг от друга. Представьте, что приложения C и D обновляются одновременно:
-
В продакшене — версия 1 приложения C и 1 — приложения D.
-
Запускается деплой новых версий — C 2 и D 2. Код обеих направляется в единственный свободный бакет А.
-
Octopus сначала выкладывает С 2 и попутно собирает весь актуальный код, какой находит, в том числе D 2.
-
В продакшене — версии C 2 и D 2.
-
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 и таким образом решаем проблему с деплоем большого количества микрофронтендов. Их можно обновлять независимо друг от друга.

Автоматизированный вывод метаданных: экономим ресурсы разработки
Каждый микрофронтенд предоставляет свой файл с метаданными. На основе этих файлов выстраивается роутинг и наполняется основное меню страницы. Если браузер станет загружать их все по отдельности, чтобы вывести меню пользователю, это займет много времени. Чтобы ускорить процесс, все метаданные нужно предварительно объединить в один файл.
С прежним деплоем 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 уходит три запроса: запрос метаданных, кода тестируемого приложения и остального кода.

Запрос метаданных проходит по цепочке:
-
Трафик направляется в под Initial builder, созданный для тестового домена.
-
Initial builder с помощью основного headless service собирает метаданные всех микрофронтендов, а с помощью дополнительного — метаданные тестового микрофронтенда.
-
В общем файле с метаданными Initial builder заменяет один из фрагментов на более новый — от тестового микрофронтенда.
-
Итоговый файл
initial.jsуходит к пользователю.

В то же время под с тестовым микрофронтендом выдает пользователю код тестируемого приложения, а другие поды — весь остальной код.
В результате продакт-менеджер видит страницу со свежими обновлениями.
Так устроен изолированный проект с версией фронтенда, доступной для тестирования. Примерно такая же схема у нас с е2е в pull request. Мы хотим получить обратную связь об обновлениях еще до того, как зальем код в мастер. Поэтому создаем в проекте разработки отдельный под и проводим на нем тесты.
Советы: как улучшить деплой без микрофронтендов и Kubernetes
Если у вас еще нет микрофронтендов и весь фронтенд — это один большой монолит, можете позаимствовать из статьи хот-тестинг. Собирайте отдельную версию статики и отдавайте ее по запросу с каким-нибудь маркером. У нас это хеш в поддомене, но можно сделать и квер-параметры или заголовки.
Если у вас есть микрофронтенды и они деплоятся созависимо, можно создать много мелких подов в Kubernetes и настроить независимый деплой. В дальнейшем можно реализовать, например, АB-тесты разной статики, если это нужно бизнесу.
Внедрить независимый деплой можно и без Kubernetes — используйте AWS Lambda или отдельные инстансы приложений на виртуальных компьютерах.
ссылка на оригинал статьи https://habr.com/ru/company/mindbox/blog/711898/
Добавить комментарий