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