Проектирование и реализация функции подписки Vizend

Проектирование и реализация функции подписки Vizend

Платформа для распространения себя: проектирование подписки

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

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

Джеймс

Site footer