Vizendサブスクリプション機能の設計と実装

Vizendサブスクリプション機能の設計と実装

自己自身をデプロイするプラットフォームのサブスクリプション設計

Vizend Kollex サブスクリプションで直面した分散デプロイの整合性問題と解決策

1. 背景と問題定義

Vizend Showcaseは、Kollex(複数のマイクロサービスをまとめたデプロイ単位)をPavilion(テナント)単位のKubernetesクラスターにGitOps方式でプロビジョニングするプラットフォームです。Kollexは、ユーザーに公開される画面単位であるEpisodeと、そのEpisodeを支えるプラットフォームバックエンドサービスであるDrama(gate・metro・porto・showcaseなど)で構成されます。ユーザーがKollexをサブスクライブすると、Showcaseはサブスクリプションの事実をSubscriptionLifecycleEventとしてMetro(認可・サブスクリプション状態を管理するプラットフォームサービス)に発行し、同時にGitOpsリポジトリへKubernetesマニフェストを書き込んだ後、ArgoCDで同期します。つまり、デプロイオーケストレーションの主体は外部サービスではなく、Showcase自身です。

このパイプラインは、サブスクリプション対象が外部ワークロードである限り、単方向の非同期フローとしてきれいに整理されます。問題は、Vizendプラットフォームを構成するShowcaseサービス自体が1つのKollexとしてパッケージ化されている点で発生します。つまりShowcaseは自分自身をサブスクライブ可能なデプロイ対象として公開しており、この場合、サブスクリプションを処理する主体と、サブスクリプションによって置き換えられる対象が同一プロセスになります。本稿では、この自己参照型(self-referential)デプロイ構造で発生する整合性問題と、それをステートマシンおよびイベント分割によって解決した過程を扱います。

2. Builtin Kollexと自己参照型デプロイ

2-1. Builtin Kollexの定義

Kollexエンティティは、isBuiltin属性によってプラットフォーム構成バンドルかどうかを区別します。builtin=trueのKollexは、Vizendプラットフォーム自体を構成するサービス群を指します。Pavilionが新規作成されると、Showcaseはブートストラップの過程でこのBuiltin Kollexのサブスクリプションを自動的に開始し、その結果、Showcaseとその依存インフラが該当Pavilionクラスターにデプロイされます。

通常のKollexサブスクリプションとBuiltin Kollexサブスクリプションを分ける本質的な制約は、次のように要約できます。

デプロイ完了の判定を行うプロセスが、デプロイ中に置き換えられるプロセスと同一である。

Showcaseが新しいバージョンへロールアウトされる間、既存のShowcaseプロセスは終了します。デプロイ完了を確認できる主体はロールアウト後に起動した新しいプロセスだけであり、このプロセスは直前のプロセスのインメモリ状態をまったく共有しません。したがって、Builtinサブスクリプションのライフサイクルは、単一プロセスの連続実行を前提とする通常のサブスクリプションとは分離された状態遷移経路を持つ必要があります。

2-2. SubscribeLifecycleStateの遷移経路

2つのサブスクリプション種別は同じSubscribeLifecycleState enumを共有しますが、異なる経路をたどります。通常のサブスクリプションが単一トランザクションのフローで終了するのに対し、Builtinサブスクリプションはプロセス再起動の時点を境に、前後が分離されます。

// 일반 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. デプロイイベントの2段階分割

Builtinサブスクリプションは、SubscriptionLifecycleEventを2段階に分けて発行します。この分割の根拠は、EpisodeとDramaの間にあるデプロイ依存順序です。

Showcaseはユーザーに公開されるEpisodeであると同時に、gate・metro・portoとともにデプロイされるプラットフォームバックエンド層、すなわち自分自身を指すshowcase Dramaでもあります。Builtin Kollexをサブスクライブすると、このshowcase Dramaがサブスクリプション対象に含まれ、結果としてShowcaseが自分自身を新しいバージョンへ置き換えます。もしshowcase Dramaがまだ新しいバージョンへ収束(converge)していない状態で旧バージョンのプロセスが完了シグナルを発行すると、置き換えが完了していないにもかかわらずサブスクリプションが完了処理される偽陽性(false positive)が発生します。

これを防ぐため、Metroへ発行する1次サブスクリプションイベントにはEpisodeのみを含め、showcase Dramaは除外します。showcase Dramaの置き換えは、別のself-deployイベントとしてShowcase内部に委譲します。新しいプロセスが正常に起動し、イメージバージョンの検証に合格した後に初めて、Dramaを含む最終イベントを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)を照合します。2つの値が一致すれば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()は、優先順位が定義された候補プロパティを順番に探索し、すべて存在しない場合はパッケージ化されたJARマニフェストのImplementation-Versionへフォールバックします。

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の時点でBeanの初期化が完了していても、メッセージブローカーへの接続とトランザクションマネージャーが完全に安定するまで、短い猶予が必要だからです。同期発行の代わりにポーリングベースの非同期発行を採用し、起動直後の一時的な不安定区間を回避します。

@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)でat-least-once配信が前提となる環境では、発行側が自律的に重複を抑制することで、パイプライン全体の整合性を単純化できます。

4. External Drama — 既存インフラとの衝突回避

Pavilionクラスターには、すでに運用中の共通インフラが存在する場合があります。新たにサブスクライブするKollexのDramaがこの既存インフラと重複すると、再インストールによって運用中のワークロードを妨害する危険があります。このように外部ですでに管理されている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では、複数のPavilionが同じKollexを独立してサブスクライブします。各Pavilionは分離されたクラスターを持ち、Kollexを構成するEpisode・Dramaは、Pavilionごとに個別のローカル識別子として登録されます。この構造では、ローカル識別子だけでは異なるテナントのリソースをグローバルに区別できず、識別子の衝突が発生します。

GID(Global ID)は、クラスターコンテキスト識別子(CID)とPavilionのローカル識別子を結合して、グローバルな一意性を確保します。

// 로컬 식별자 — 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"

SubscriptionLifecycleEventにGIDが併せて渡されるため、MetroとShowcaseのデプロイ段階では、イベントがどのクラスター・テナントのどのリソースを指示しているのかを曖昧さなく決定できます。設計ガイドでもEpisodeKey・DramaKey・KollexKeyのfull GIDを信頼し、GIDが空の場合はフォールバックせず失敗すべきだと規定しています。サブスクリプションロジックのE2Eテストがイベントペイロードの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サブスクリプション機能の実装難易度は、「サブスクリプション」というドメイン用語の単純さに反比例しました。自己参照型デプロイという1つの制約が、次のような分散システムの整合性問題を連鎖的に引き起こしました。

  • 状態の永続化 — デプロイによって置き換えられるプロセスの進行状態をデータベースに保持し、再起動に耐えるようにする

  • イベントの2段階分割 — インフラ依存関係が収束する前の偽の完了を防ぐ

  • バージョン検証 — 意図したイメージが実際に起動したことを環境に依存せず保証する

  • 冪等発行 — at-least-once配信環境で発行側が重複を抑制する

  • グローバル識別子 — マルチテナントのローカル識別子の衝突をGIDで解消する

これらのいずれか1つでも欠けると、サブスクリプショントランザクションは成功として終了する一方で、実際のサービスは動作しない部分的失敗(partial failure)に至ります。ドメイン表面の単純さの下にある分散整合性の問題を、永続ステートマシンとイベント分割という定型化されたパターンによって解消できたことが、この機能から得た核心的な設計経験です。

James

Site footer