Платформа для распространения себя: проектирование подписки
Проблема согласованности распределенного развертывания, с которой столкнулись в подписке Vizend Kollex, и ее решение
1. Предпосылки и определение проблемы
Vizend Showcase — это платформа, которая применяет метод GitOps дляProvisioning Kollex (единица развертывания, состоящая из нескольких микросервисов) в кластере Kubernetes на уровне Pavilion (арендатор). Kollex состоит из Episodes, которые представляют собой экраны, доступные пользователю, и Drama (gate·metro·porto·showcase и т.д.), которые поддерживают эту Episode в качестве бэкэнд-сервисов платформы. Когда пользователь подписывается на Kollex, Showcase публикует факт подписки как SubscriptionLifecycleEvent в Metro (платформенный сервис, управляющий состоянием подписки и авторизацией) и одновременно создает манифест Kubernetes в репозитории GitOps и синхронизирует его с помощью ArgoCD. То есть, главная роль в оркестрации развертывания принадлежит не внешнему сервису, а самому Showcase.
Этот.pipeline. четко организуется как однонаправленный асинхронный поток, при условии, что цель подписки является внешней нагрузкой. Проблема возникает из того, что сам сервис Showcase, формирующий платформу Vizend, упакован в качестве одного Kollex. То есть Showcase демонстрирует себя как целевой объект для подписки, и в этом случае субъект, обрабатывающий подписку, и объект, который заменяется из-за подписки, становятся одним и тем же процессом. Эта статья охватывает проблемы согласованности, возникающие в этой самосссылающей (self-referential) структуре развертывания, и процесс их решения с помощью конечных автоматов и разделения событий.
2. Встроенный Kollex и самосссылающееся развертывание
2-1. Определение встроенного Kollex
Сущность Kollex определяется по свойству isBuiltin как составная часть платформенного оснащения. Kollex с builtin=true указывает на набор сервисов, который сам составляет платформу Vizend. Когда Pavilion создается заново, Showcase автоматически инициирует подписку на этот встроенный Kollex в процессе загрузки, и в результате Showcase и его зависимая инфраструктура развертываются в соответствующий кластер Pavilion.
Сущностное ограничение, различающее обычную подписку на Kollex и подписку на встроенный Kollex, можно резюмировать следующим образом.
Процесс, который должен определить, завершено ли развертывание, совпадает с процессом, который заменяется во время самого развертывания.
Во время развертывания новой версии Showcase существующий процесс Showcase завершается. Субъект, который может подтвердить завершение развертывания, — это только новый процесс, запущенный после развертывания, и этот процесс совершенно не делит состояние с предыдущим процессом в памяти. Поэтому жизненный цикл подписки на встроенный Kollex должен иметь отделенный путь состояния, который отличается от общего, подразумевающего непрерывное выполнение одного процесса.
2-2. Путь перехода SubscribeLifecycleState
Оба типа подписки делят один и тот же enum SubscribeLifecycleState, однако следуют различным маршрутам. В то время как обычная подписка завершается в рамках единого транзакционного потока, подписка на встроенный Kollex разделяется на до и после точки перезапуска процесса.
// 일반 Kollex 구독
Preparing → SyncingMetro → Completed | Failed
// Builtin Kollex 구독 (Self-Deploy)
Preparing
→ SyncingMetro // 1차 구독 이벤트 발행 (Metro)
→ PreparingSelfDeploy // self-deploy 이벤트 발행 직전 단계
→ DeployingSelf // 자기 배포 진행 — 프로세스 종료 임박
──────── 프로세스 재기동 경계 ────────
→ SelfDeployVerified // 신규 프로세스의 이미지 버전 검증 통과
→ FinalizingMetro // 최종 배포 이벤트 발행 (Drama 포함)
→ Completed
// 실패 전이
DeployingSelf → SelfDeployFailed // 이미지 버전 불일치
FinalizingMetro → FinalizeFailed // 이벤트 발행 실패
Даже если процесс завершается в состоянии DeployingSelf, записи SubscribeLifecycle сохраняются в базе данных. Перезапущенный процесс использует эту запись как единственный источник истины (source of truth) и продолжает прерванный жизненный цикл. То, что состояние хранится не в памяти, а в постоянном слое, является ключевым дизайном, стойким к замене процессов.
2-3. Двухступенчатое разделение событий развертывания
Встроенная подписка делит событие жизненного цикла подписки на два этапа. Основание для этого деления заключается в порядке зависимости распространения между Эпизодом и Драмой.
Витрина является как Эпизодом, который виден пользователю, так и слоем бэкенда платформы, который распространяется вместе с gate·metro·porto, то есть самой собой, представляющей витрину Драмы. Подписавшись на Встроенный Kollex, эта витрина Драмы включается в список подписок, и в результате Витрина заменяет саму себя на новую версию. Если Витрина Драмы изначально не сойдется на новую версию, а процесс старой версии завершит сигнал, возникает ложноположительное срабатывание при завершении подписки, даже если замена ещё не завершена.
Чтобы избежать этого, первичное событие подписки, публикуемое через Metro, будет содержать только Эпизод, и Драма Витрины будет исключена. Замена Драмы Витрины делегируется внутри Showcase в рамках отдельного события саморазвертывания. Только после успешного запуска нового процесса и успешной проверки версии образа, финальное событие с включением Драмы будет опубликовано в Metro. Полезная нагрузка этого финального события будет сериализована в JSON в жизненном цикле записи, чтобы быть восстановленной даже после перезапуска.
// 1차 구독 이벤트 → Metro 발행. self-deploy 대상(showcase Drama)은 제외
eventProxy.produceEvent(subscriptionLifecycleEvent); // Episode 포함, Drama 비움
// self-deploy 트리거 — Showcase 내부 이벤트. showcase Drama만 포함
SubscriptionLifecycleEvent selfDeployEvent = event.toBuilder()
.episodes(List.of())
.dramas(galleryDramas)
.build();
applicationEventPublisher.publishEvent(selfDeployEvent);
// 최종 이벤트(Drama 포함)는 재기동 후 Metro 발행을 위해 직렬화 보관
lifecycle.setFinalEventPayloadJson(finalEvent.toJson());
▲ Причина для персистенции полезной нагрузки финального события — перезапущенный процесс не может наследовать контекст в памяти предыдущего процесса
3. Проверка саморазворачивания после перезапуска
3-1. Верификация в момент запуска — ApplicationReadyEvent
Когда новый процесс начинает работу, задача BuiltinSubscribeLifecycleCompletionTask получает событие ApplicationReadyEvent и выполняет проверку. Просматривая жизненный цикл, который был приостановлен в состоянии DeployingSelf, сравнивает ожидаемую версию образа (expectedImageVersion) с наблюдаемой версией образа (observedImageVersion) текущего запущенного процесса. Если два значения совпадают, происходит переход в состояние SelfDeployVerified, в противном случае записывается SelfDeployFailed.
@EventListener(ApplicationReadyEvent.class)
@Transactional
public void verifySelfDeployOnStartup() {
if (!enabled) return;
String observed = resolveObservedImageVersion();
List<SubscribeLifecycle> deploying =
subscribeLifecycleLogic.findByStateIn(List.of(DeployingSelf));
for (SubscribeLifecycle lifecycle : deploying) {
if (Objects.equals(lifecycle.getExpectedImageVersion(), observed)) {
lifecycle.moveTo(SelfDeployVerified);
} else {
lifecycle.recordFailure(SelfDeployFailed, "image version mismatch");
}
subscribeLifecycleLogic.modifySubscribeLifecycle(lifecycle);
}
}
С помощью проверки совпадения версий гарантируется, что "именно тот образ, на который подписка была настроена, действительно запустился". Можно идентифицировать случаи неудачи, когда развертывание задерживается и старая версия процесса временно активна, или когда другая версия запущена в результате отката.
3-2. Интерпретация наблюдаемой версии и независимость от окружения
Наблюдаемая версия образа вводится через различные пути в зависимости от среды выполнения. В среде Kubernetes теги образов передаются как переменные окружения, а в локальной среде разработки такие переменные отсутствуют. resolveObservedImageVersion() последовательно исследует кандидаты по определенному порядку приоритета и, если все отсутствуют, откатывается к Implementation-Version в упакованном манифесте JAR.
private String resolveObservedImageVersion() {
List<String> candidates = List.of(
"vizend.gallery.self-deploy.observed-image-version",
"VIZEND_GALLERY_IMAGE_TAG",
"GALLERY_IMAGE_TAG",
"IMAGE_TAG",
"BUILD_VERSION"
);
for (String name : candidates) {
String value = environment.getProperty(name);
if (StringUtils.hasText(value)) return value.trim();
}
Package pkg = getClass().getPackage(); // 로컬 폴백
return pkg != null ? pkg.getImplementationVersion() : null;
}
Благодаря этой цепочке приоритетов логика верификации не зависит от условия выполнения. Один и тот же код будет работать в CI/CD пайплайне как тег образа Docker, а локально как версия артефакта сборки, и разветвления по окружениям не проникают в код.
3-3. Публикация финального события и идемпотентность
Жизненный цикл в состоянии SelfDeployVerified полагается на фиксированное периодическое расписание для публикации финального события. Причина разделения верификации и публикации заключается в том, что, хотя инициализация бинов могла быть завершена на момент события ApplicationReadyEvent, для полной стабилизации связи с брокером сообщений и менеджером транзакций требуется некий короткий период ожидания. Вместо синхронной публикации используется асинхронная публикация на основе опроса, чтобы избежать временной нестабильности сразу после старта.
@Scheduled(fixedDelayString =
"${vizend.gallery.builtin-subscribe.lifecycle.finalize-interval-ms:10000}")
@Transactional
public void publishFinalMetroEvents() {
List<SubscribeLifecycle> verified =
subscribeLifecycleLogic.findByStateIn(List.of(SelfDeployVerified));
for (SubscribeLifecycle lifecycle : verified) {
if (lifecycle.isFinalEventPublished()) continue; // 멱등 가드
lifecycle.moveTo(FinalizingMetro);
SubscriptionLifecycleEvent event =
SubscriptionLifecycleEvent.fromJson(lifecycle.getFinalEventPayloadJson());
eventProxy.produceEvent(event);
lifecycle.markFinalEventPublished(LocalDateTime.now());
}
}
Гард isFinalEventPublished() обеспечивает идемпотентность. Финальное событие одного и того же жизненного цикла не будет опубликовано повторно при любых обстоятельствах, включая периодические повторные запуски планировщика, сбои обновления состояния после публикации и конкуренции между параллельными экземплярами. В среде, в которой предполагается доставка событий потребителю (Metro) как минимум один раз, сам процесс публикации, предотвращая дублирование, упрощает согласованность всей цепочки.
4. Внешняя Драма — избегание конфликтов с существующей инфраструктурой
В кластерном павильоне уже может существовать общая инфраструктура, находящаяся в эксплуатации. Если подписка на Drama от Kollex дублирует эту существующую инфраструктуру, повторная установка может привести к риску нарушения работы действующих нагрузок. Таким образом, Drama, уже управляемая извне, классифицируется как External Drama. Пользователь может указать конкретную Drama как External в момент подписки, и этот идентификатор передаётся через SubscribeCommand.externalTargetIds.
Ключевое проектное решение заключается в разделении "исключения развёртывания" и "исключения событий". External Drama не должна исключаться из объекта манифеста GitOps, но не должна исключаться из полезной нагрузки событий подписки. Metro требует сопоставления ролей Drama — имени сервиса, порта, идентификатора роли — для авторизации и обнаружения сервисов, и эти метаданные должны передаваться вне зависимости от того, создается ли манифест.
// External Drama: 이벤트에는 포함, 매니페스트 생성만 skip 지시
List<SubscriptionTargetSpec> dramaTargets = dramas.stream()
.map(drama -> {
boolean external = command.getExternalTargetIds().contains(drama.getId());
return SubscriptionTargetSpec.of(drama).withExternal(external);
})
.toList();
// Showcase 배포 단계는 isExternal 플래그로 매니페스트 생성 여부를 분기
Первоначальная реализация исключила External Drama из списка событий в массовом порядке, и в результате Metro не смог интерпретировать конечные точки, связанные с этим Drama, что привело к сбоям в связи между сервисами. Решение "не развёртывать" было неправильно истолковано как "не существует", что оставило урок о необходимости разделения политик развертывания и информации о топологии в разных каналах.
5. GID — система идентификаторов для многоарендных систем
В Vizend несколько павильонов независимо подписываются на один и тот же Kollex. Каждый павильон имеет отдельный кластер, а Episode и Drama, составляющие Kollex, регистрируются с отдельными локальными идентификаторами для каждого павильона. В этой структуре только локального идентификатора недостаточно для глобального различения ресурсов разных арендаторов, что ведет к конфликтам идентификаторов.
GID (Глобальный идентификатор) комбинирует идентификатор контекста кластера (CID) и локальный идентификатор павильона для обеспечения глобальной уникальности.
// 로컬 식별자 — Pavilion 내에서만 유일 (NT:1 = Pavilion ID)
KOLLEX_ID = "NT:1-K0001"
EPISODE_ID = "NT:1-E0002"
DRAMA_ID = "NT:1-D000b"
// GID — 플랫폼 전역에서 유일 (X8FD = CID, 클러스터 컨텍스트)
KOLLEX_GID = "X8FD-NT:1-K0001"
EPISODE_GID = "X8FD-NT:1-E0002"
DRAMA_GID = "X8FD-NT:1-D000b"
Поскольку GID также передаётся вместе с SubscriptionLifecycleEvent, этапы развертывания Metro и Showcase могут определять, какие ресурсы относятся к какому кластеру и арендатору, без всякой неоднозначности. Проектные руководства также доверяют полному GID EpisodeKey, DramaKey и KollexKey и указывают, что если GID пуст, система должна завершиться с ошибкой без резервного варианта. Конечное тестирование логики подписки явно проверяет конструкцию GID в полезной нагрузке событий, так как эта система идентификации является предпосылкой многоарендной согласованности.
// E2E 검증 — GID가 CID + 로컬 식별자 조합으로 구성되는지 확인
assertThat(event.getEpisodes().get(0)
.getEpisodeInfo().getEpisodeKey().getGid())
.isEqualTo(EPISODE_GID); // "X8FD-NT:1-E0002"
assertThat(event.getEpisodes().get(0)
.getEpisodeInfo().getEpisodeKey().getId())
.isEqualTo(EPISODE_ID); // "NT:1-E0002"
6. Заключение
Сложность реализации функции подписки на Kollex была обратно пропорциональна простоте термина "подписка". Один ограничивающий аспект самоссылающегося развертывания порождал цепочку проблем согласованности в распределенных системах.
-
Сохранение состояния — сохраняет состояние процесса, заменяемого развертыванием, в базе данных для устойчивости к перезагрузкам.
-
Двухступенчатое разделение событий — предотвращает ложное завершение до сходимости зависимости инфраструктуры.
-
Проверка версии — независимо от среды гарантирует, что предполагаемое изображение было фактически запущено.
-
Идемпотентная отправка — обеспечивается устранение дублирования со стороны отправителя в окружении доставки "не менее одной".
-
Глобальный идентификатор — разрешает конфликты локальных идентификаторов многоарендной системы с помощью GID.
Если хотя бы один из этих элементов отсутствует, транзакция подписки завершается успешно, но фактический сервис приводит к частичному сбою. Ключевой проектный опыт, полученный при реализации этой функции, заключается в том, что проблемы распределенной согласованности, скрытые под простотой лицевой поверхности домена, могут быть решены с помощью формализованных паттернов, таких как долговременные машины состояний и разделение событий.
Джеймс