Sansan Tech Blog

Sansanのものづくりを支えるメンバーの技術やデザイン、プロダクトマネジメントの情報を発信

Vol. 08 Cloud Pub/Sub 基盤におけるアプリとインフラ間の不整合をデプロイ前に検知する

この記事は、Bill One開発Unitブログリレー2025の第8弾、およびSansan Advent Calendar 20259日目の記事になります!

こんにちは、技術本部 Bill One Engineering Unit の藪下(@yatsbashy)です。

私が前回投稿したブログリレーの記事では、Cloud Pub/Sub のデッドレターの設計と運用について紹介しました。Bill One では、請求書の到着や承認、支払依頼の作成といった業務上の出来事を「ドメインイベント」として表現し、それらを Cloud Pub/Sub を用いた非同期メッセージングで各マイクロサービスに伝播させています。

今回も同じく Pub/Sub を題材に、「ドメインイベント」と「インフラ(Pub/Sub リソース)」の不整合をどう防ぐかにフォーカスし、Bill One で実際に導入した安全機構とその設計について紹介したいと思います。

1. はじめに:アプリとインフラの不整合リスク

Bill One では、ドメインイベントと Pub/Sub トピックを 1:1 で対応付ける方針をとっています。たとえば invoice-createdpayment-requested のように、ビジネス上の出来事ごとに専用のトピックを用意しています。

この設計はイベント駆動の見通しを良くしてくれる一方で、ドメインイベントの数だけ Pub/Sub トピックが増えていくため、アプリの定義とインフラの状態がずれるリスクも比例して大きくなります。

かつて Bill One では、アプリから Terraform コードを自動生成するコマンドを用意し、ドメインイベントの定義から Pub/Sub リソースを構築していました。アプリの定義がそのまま Terraform の定義に変換されるため、アプリとインフラの整合性が取りやすいというメリットがありました。

一方で、この仕組みの特徴はインフラがアプリに依存している点にあります。通常であれば「まずインフラを構築し、その上でアプリを開発する」という順序を取りますが、このコマンド前提の運用では「アプリで定義したものをインフラとして構築する」ため、依存の向きが一般的なケースと逆転していました。

こうした依存関係の逆転は長期的には扱いづらいと判断し、Bill One では Pub/Sub リソースの作成は Terraform だけで行う方針へと切り替えました。つまり「Terraform で定義されたものをアプリコードで参照する」という、一般的な依存方向に戻した形です。

しかしその結果として「アプリで定義している Pub/Sub トピック」と「Terraform で作成されたトピック」が食い違うヒューマンエラーが起こり得る状態にもなりました。

こうした前提のもとで、イベント駆動の柔軟さを保ちながら、アプリとインフラの不整合をどう防ぐかが、本記事のテーマです。

2. 本記事のゴールと適用範囲

まずは、この不整合について本記事がどこまでをカバーするのかを整理します。

本記事で紹介する仕組みのゴールは、とてもシンプルです。

「アプリが『発行する』と定義している Pub/Sub トピックが、デプロイ時点ですべて Google Cloud 上に存在していることを保証する」

ここで、以降の話の前提として Bill One における Pub/Sub の使い方を簡単に整理しておきます。Bill One の Pub/Sub による非同期メッセージングは、おおよそ次のような流れです。

  1. 発行側マイクロサービス(Cloud Run)からトピックへメッセージを発行する。
  2. Push サブスクリプションがトピックからメッセージを受け取る。
  3. 購読側マイクロサービスの API エンドポイントへ HTTP リクエストされる。

Bill One の Pub/Sub による非同期メッセージングの流れ

このうち、今回対象としているのは 1 における発行側から見た トピックの存在有無 です。

Terraform で定義されていないトピック ID をアプリコードに書いてしまうと、デプロイ前の CI がそれを検知し、デプロイを失敗させます。

一方で、クラウド上に存在するもののアプリからは参照されていないトピックは「未使用リソース」とみなし、この仕組みでは問題にしません(逆向きの不整合は許容する設計です)。

このように、1 章で述べた通り依存方向を「アプリ → Terraform」に戻した上で、人手に頼らず Pub/Sub トピックの不整合を防ぐ。そのために採った経緯と実装を、以降の章で詳しく紹介していきます。

3. どんな仕組みで守るか

ここまでで触れてきたように、Bill One では「Terraform で定義された Pub/Sub トピックを、アプリコードから正しく参照できているか」を担保したい、という課題がありました。

そのために採った方針は以下のようなものです。

  1. アプリが発行し得るドメインイベントに対応した Pub/Sub トピック ID の一覧をテキストファイルに出力する。
  2. テキストファイルをビルド時に Docker イメージ内へ同梱する。
  3. デプロイ時の CI でイメージからテキストファイルを取り出す。
  4. 各行のトピック名について gcloud コマンドを実行して Google Cloud 上に存在するか検証する。

どこか 1 件でも存在しないトピックが見つかった時点で、そのデプロイは失敗させます。また、ファイル自体が見つからない場合も、仕組みが正しく組み込まれていないと判断して失敗させます。

仕組み自体は小さく保ちつつ、「アプリが使うつもりのものが Google Cloud に存在していない」という致命的なパスだけを確実に潰すことを目指しました。

デプロイ時の処理の流れ

デプロイ時の流れをシーケンス図にすると以下のようになります。なおここでは Pub/Sub トピック ID 一覧のテキストファイルを pubsub-topics.txt と称します。

sequenceDiagram
    participant GA as GitHub Actions
    participant DE as Docker Engine
    participant AR as Artifact Registry
    participant PS as Cloud Pub/Sub
    participant CR as Cloud Run

    GA->>DE: Dockerfile からビルド実行
    activate GA
    DE->>DE: トピック一覧出力スクリプト実行
    DE->>DE: pubsub-topics.txt を書き込み
    DE-->>GA: コンテナイメージ
    GA->>AR: コンテナイメージ push
    deactivate GA

    GA->>AR: コンテナイメージ pull
    activate GA
    AR-->>GA: コンテナイメージ
    GA->>GA: コンテナイメージから pubsub-topics.txt を取得
    alt 取得失敗
        GA->>GA: デプロイ中止
    end

    loop pubsub-topics.txt 1 行ごとに実行
        GA->>PS: gcloud pubsub topics describe 実行
        PS-->>GA: トピック情報
    end
    GA->>GA: 突合
    alt 突合失敗
        GA->>GA: デプロイ中止(fail)
    else 突合成功
        GA->>CR: デプロイ
    end
    deactivate GA

4. 実装の流れ

ここからは、実際にどのように実装しているかを順に見ていきます。

4-1. Dockerfile で pubsub-topics.txt をイメージに同梱する

まずはマイクロサービスごとに「発行先のトピック ID の一覧」を生成し、それを ビルド時に pubsub-topics.txt としてイメージ内に書き込むようにします。

ポイントは次の 2 つです。

  • アプリの実装言語に依存しないこと
    • → 一覧の生成は各サービスの責務ですが、言語に寄らない統一的な仕組みにするため、最終的に「改行区切りのテキストファイル」を用意すればよい、というルールだけ定めました。
  • ビルドが通らないとファイルが生成されないようにすること
    • → トピック名の追加漏れを防ぐため、「イベント定義を増やしたのに pubsub-topics.txt を更新していない」という状態を起こりにくくします。

フォーマットは単純で、1 行 1 トピック ID のプレーンテキストです。トピック ID は <microservice-name>.<domain-event-name> の形式としています。

xx-service.invoice-created
xx-service.payment-requested
...

Dockerfile では、たとえば次のようなステップを追加します。

FROM node:22 AS build
COPY . /app
WORKDIR /app

# ここでアプリごとのスクリプトなどを叩いてトピック一覧を生成する。
RUN node scripts/generate-pubsub-topics.js > /app/pubsub-topics.txt

# 以降は通常のビルド
RUN npm ci && npm run build

FROM gcr.io/distroless/nodejs22

COPY --from=build /app/dist ./dist
COPY --from=build /app/pubsub-topics.txt /pubsub-topics.txt

WORKDIR /app
CMD ["dist/server.js"]

先述の通り、実際には各マイクロサービスの技術スタックに合わせて実装しており、「どのディレクトリをスキャンしてトピック ID を列挙するか」といった細かい部分はチームに任せています。

4-2. pubsub-topics.txt をイメージから取り出して Pub/Sub と突合する

次にデプロイ前の GitHub Actions に組み込むためのシェルスクリプト(以下 check-pubsub-topics.sh)を実装します。処理は次のようなイメージです。

  1. docker createdocker cp によって pubsub-topics.txt をイメージから取得する。
  2. 取得したファイルをもとに行単位でトピックの存在を確認する。

実装は概ね次のようなイメージです。

#!/bin/bash
set +e

PROJECT=$1
DOCKER_IMAGE=$2

# コンテナイメージから pubsub-topics.txt を取り出す
CONTAINER_ID=`docker create $DOCKER_IMAGE`
docker cp $CONTAINER_ID:/pubsub-topics.txt $RUNNER_TEMP # 
if [ $? -gt 0 ]; then
  echo "pubsub-topics.txt が見つかりません"
  docker rm $CONTAINER_ID
  exit 1
else
  docker rm $CONTAINER_ID
fi

FILEPATH=$RUNNER_TEMP/pubsub-topics.txt

# pubsub-topics.txt の 1 行ごとにトピックを照合する
ERR=0
while read line
do
  gcloud pubsub topics describe --project=$PROJECT $line --format="flattened(name)"
  if [ $? -gt 0 ]; then
    echo "次のトピックが存在しません: $line"
    ERR=1
  fi
done < $FILEPATH

if [ $ERR -eq 1 ]; then
  echo "存在しないトピックがあります"
  exit 1
else
  echo "すべてのトピックが存在しています"
  exit 0
fi

なお pubsub-topics.txt が空の場合はトピックが不要とみなして成功するようにしています。

ここでの tips ですが、トピックの参照には gcloud pubsub topics list ではなくgcloud pubsub topics describe を使用し、トピックごとに実行しています。プロジェクト内のトピック数が増えてくると list のレスポンスが重くなりがちですが、describe であれば「存在確認したいものだけ」をピンポイントに見ることができます。

また実行は GitHub Actions で行うため、pubsub-topics.txt はランナー上の一時ディレクトリ RUNNER_TEMP へコピーしています。

docs.github.com

4-3. GitHub Actions への組み込み

最後にこれらをデプロイ用の GitHub Actions に組み込みます。次のようなワークフローを実装します。

  1. Docker イメージをビルドして Artifact Registry に push する。
  2. check-pubsub-topics.sh を実行する。
  3. トピックの突合に成功した場合のみ Cloud Run へデプロイする。

2 だけを再利用可能ワークフローに切り出したのが次のようなイメージです。

name: Check Pub/Sub Topics

on:
  workflow_call:
    inputs:
      project:
        required: true
        type: string
      docker-image:
        required: true
        type: string

jobs:
  check-pubsub-topics:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v5

      - name: Authenticate to Google Cloud
        uses: google-github-actions/auth@v3
        with:
          workload_identity_provider: ${{ secrets.WIF_PROVIDER }}
          service_account: ${{ secrets.DEPLOY_SERVICE_ACCOUNT }}

      - name: Install the Cloud SDK
        uses: google-github-actions/setup-gcloud@v3

      - name: Check Cloud Pub/Sub Topics
        run: |
          .github/workflows/scripts/check-pubsub-topics.sh ${{ inputs.project }} ${{ inputs.docker-image }}

5. 運用上の注意点

この仕組みを導入すると、「どのタイミングで Pub/Sub トピックを作成するか」というリリース戦略に制約が入ります。

前提として、アプリのデプロイより前に Pub/Sub のトピックが Terraform によってプロビジョンされている必要があります。

もし Terraform が未適用でトピックが存在しない場合、check-pubsub-topics.sh が失敗し、CI の段階でデプロイが止まります。このため、アプリと Terraform を完全に同時リリースする運用とはやや相性が悪く、Bill One では「先にインフラを適用してからアプリをデプロイする」という順序を基本としています。

6. 採用しなかった選択肢

設計を検討する中で候補に挙がったものの、最終的には採用しなかった案もいくつかあります。ここでは代表的なものを紹介します。

6-1. 人手の検証に委ねる

最もシンプルな案は、「Terraform の差分をレビューするときに、Pub/Sub topic 名の整合性を人間がチェックする」運用です。

しかし、Bill One のようにドメインイベント駆動でサービスが増えていくと、Pub/Sub topic の作成機会もどんどん増えていきます。チーム規模が大きくなるほどレビューの抜け漏れも起こりやすく、「人が気をつける」で守るには限界があると判断しました。

6-2. grep ベースの CI

もう少し仕組み寄りの案として、「Terraform のコードとアプリコードを CI の中で grep し、同じ文字列が両方に存在するかをチェックする」という方法も検討しました。

ただしこの方法は、あくまで 文字列一致に依存したゆるめのチェックになってしまいます。

また、「アプリ側の実装と Terraform を常に並行して編集する」ことを前提にしてしまうため、現場の開発フローに強い制約を強いる点もネックでした。結果として、厳密性と運用負荷の両面から採用を見送りました。

6-3. Single Source of Truth を用意して両者に注入

もう一段踏み込んだ案として、「トピック ID の Single Source of Truth をどこか 1 か所に置き、そこからアプリと Terraform の両方へ注入する」という設計も検討しました。

しかしアプリと Terraform の双方が安全に参照できる専用のストアを設計する必要があり、加えて

  • 環境変数や設定ファイルからコードへのマッピングミス
  • 生成タイミングやキャッシュの扱い

といった新たな課題も生まれます。

「ミスの芽を減らすための仕組み」が、かえって重く複雑になってしまう懸念があったため、今回はシンプルな pubsub-topics.txt 方式に留めました。

6-4. アプリ起動時の突合

「アプリの起動時に、Pub/Sub トピックの存在をチェックする」という案もありました。

一見すると手軽ですが、Cloud Run のようなスケーラブルな環境では インスタンスの起動やスケールアウトのたびに検証が走ることになり、起動時間のばらつきや遅延の原因になり得ます。

また、「トピックが存在しないことが本番トラフィックの流入後に発覚する」ケースも排除しきれません。

今回の目的は「デプロイ前に安全に失敗させる」ことだったため、起動時チェックではなく CI 段階での検証を採用しました。

6-5. 定期実行 API による検知

別案として、「バッチや定期実行ジョブから API を叩き、不整合を検出したら通知する」という方法も考えられます。

しかしこの場合、リリース後に不整合が見つかることになります。

通知が飛んできたタイミングではすでにイベントが発行されているかもしれず、「事故を早期に検知する」ことはできても、「事故そのものを起こさない」ことは保証できません。

「安全に失敗させる」という観点からは、やはりデプロイ前にパイプラインを止める方が合理的だと判断しました。

6-6. Pull Request CI への組み込み

最後に「PR の CI で Pub/Sub トピックの突合を行う」という案も検討しました。

Terraform の適用前に PR がレビューされる運用では、「まだ Terraform に反映されていないだけ」の状態でも PR が落ちてしまい、開発体験を損ねます。

結果として、PR 時点ではチェックを行わず、デプロイ前のパイプラインでのみ突合する構成にしています。

これにより、開発サイクルを阻害せず、本番リリースの直前でだけ強いガードを効かせるバランスに落とし込めました。

7. 今後の拡張

今回の仕組みは「Pub/Sub トピックが存在するかどうか」を確認するだけの、極めてミニマルなものです。

一方で、さらに安全性を高めようとすると、次のような検証も視野に入ってきます。

  • サブスクリプションの存在確認
  • サブスクリプションの push エンドポイントと API エンドポイントの突合

これらを含めれば Pub/Sub 周りの事故をより広く防げるようになりますが、その分だけ仕組みは重く、実装コストも運用コストも増えていきます。

Bill One としては、まずは 「トピックの存在有無」から始めるスモールスタートを選びました。

8. まとめ

Bill One ではドメインイベントと Pub/Sub トピックを 1:1 で対応付けた非同期メッセージングを採用しています。その一方で、Terraform をインフラの Single Source of Truth としつつ、アプリコードとの不整合をどう防ぐかが課題になっていました。

そこで次のような小さい仕組みを導入しました。

  1. ビルド時に Docker イメージへトピック一覧ファイルを同梱する。
  2. デプロイ時にファイルを取り出してトピックの存在を確認する。

これにより、「アプリが発行するつもりの Pub/Sub トピックが Terraform で作られていない」という事故を、デプロイ前の段階で確実にあぶり出せるようになりました。

仕組み自体はシンプルで、最初に導入してしまえば以後の管理コストも高くありません。ミスが起こりやすいポイントに対して「人が気をつける」のではなく「CI が壊してくれる」ようにすることで、日々の開発の安心感が得られるようになりました。

9. 参考

Sansan技術本部ではカジュアル面談を実施しています

https://forms.gle/gbLPPzecKpyb5yR78

Sansan技術本部では中途の方向けにカジュアル面談を実施しています。Sansan技術本部での働き方、仕事の魅力について、現役エンジニアの視点からお話しします。「実際に働く人の話を直接聞きたい」「どんな人が働いているのかを事前に知っておきたい」とお考えの方は、ぜひエントリーをご検討ください。

© Sansan, Inc.