社内 AI エージェント Synapse を MCP サーバ化して Entra ID で認証する

エンジニアの佐野です。Synapse というデータ分析を補助する AI エージェントを運用しています。以下の前回の記事からの続きです。

tech.kanmu.co.jp

最近これの機能分割を行い、Synapse の Web アプリケーション部分 (Synapse Chat)と、BigQuery へのアクセスや社内ナレッジを提供する部分を分割しました。後者は認証つき MCP サーバ として切り出され、 tools-gw (ツールゲートウェイ) と名付けられました。これによってカンム社員は既存の Synapse Chat (ウェブブラウザ)に加えて、各位が好きなツール (Claude Code, Claude Desktop, Codex...)*1 を利用して、カンムの社内用語や業務用語がそのまま通じる状態で AI ツールを利用することができるようになりました。

MCP サーバについて SNS を眺めると「CLI で良い」という意見が散見されます。おそらくは「"エンジニア"がローカルマシンから AI エージェントを使って開発を行う」という前提で話しているんじゃあないかと思います。その文脈であればそれもありだと思います。一方で Synapse の場合はエンジニア以外の職種も好きなツールを利用して業務にAIを活用できる基盤を目指しています。この前提に立つと CLI を標準的な利用形態にするのは少し難しいと判断しています。エンジニアではない職種のメンバーへの CLI の配布や運用を考えると、社内サポートの負荷が高くなるためです。また権限管理の面でも問題に直面します。BigQuery を CLI で利用する場合は Google Cloud のアカウントを社員やパートナーごとに配ることになるのですが、それはあまり得策とは思えません。仮にエンジニアだとしても、例えば私自身は Google Cloud にかなり強い権限を持っているのですが、AI エージェントがよくわからない判断を行ってデータを破壊したら会社に小さくない影響を与えます。プロンプトを無視されたり AI エージェントが不穏なコマンドを実行することがあるのは LLM を利用している人であれば直面したことがあると思います。

ちょっと前置きが長くなりましたが、いずれにせよ MCP を導入するメリットがあると踏んでこのような運用としているのですが、本日はその構成やコンセプトについて書きます。

  1. 全体構成と技術スタック
  2. 認証と認可
  3. 中央管理による展開
  4. まとめ

1. 全体構成と技術スタック

前回の記事ではちょっと絵が雑でしたが...今回はしっかり描きました(どうでもいいですが、LLM じゃなくて自力です。自分の指示が悪いのか呪符のような図を描かれてしまったので...)。

ユーザーの入り口は2つあります。ALB が2枚いてブラウザで Synapse Chat を使う経路と、Claude Code や Claude Desktop などの MCP クライアントから直接 tools-gw に接続する経路です。どちらの経路でも裏側で動くツール群は同じ tools-gw が提供しています。

コンポーネント

Synapse のコンポーネントは同じ ECS クラスタで稼働する3つの ECS タスクと RDS で構成されています。ECS タスク同士の通信は ECS Service Connect を使っています。

  • Synapse Chat: FastAPI + Google ADK で構築された Web アプリケーションです。ブラウザにチャットを提供して SSE (Server-Sent Events) でストリーミング応答を返します。フロントは Vite + TypeScript です。tools-gw に MCP で接続し、ユーザーの認証情報を伝播しつつ tools-gw の提供するツール群を MCP 経由で利用します。
  • tools-gw: FastMCP で構築された認証つき MCP サーバです。BigQuery へのクエリ実行、Vertex AI Code Execution によるコード実行、Notion 検索などのツールを提供しています。ツールは業務に必要そうなものであれば続々と実装していく予定です。次の Knowledge と合わせて Synapse の中心といっていいコンポーネントです。
  • knowledge: これも FastMCP で構築された MCP サーバです。セマンティックレイヤーとサービス辞書を YAML ファイルとして管理していて、それを検索するためのツールを提供しています。これの存在によりユーザはカンム用語や業務用語をそのままチャットや Claude Desktop から投げつけても AI がそれを理解できるようになっています。tools-gw からMCPマウント(mcp.mount(namespace='knowledge')) していて、MCP クライアントからは tools-gw 経由でシームレスにアクセスできます。
  • RDS: PostgreSQL の小さめのインスタンスです。Google ADK のセッション保存先として利用しています。ここでいうセッションは LLM との会話のようなものだと思ってください。

外部サービス

  • Vertex AI: Google ADK から利用します。バックエンドに Gemini がいます。Vertex AI はバックエンドに Claude もいるのですが、万が一に備えて Claude シリーズのモデルにも切り替えられるようにしてあります。
  • Entra ID: カンムは Microsoft 365 を利用しているので Entra ID (旧 Azure AD) が全社員のアカウント管理に使われています。Synapse の認証はすべてここに寄せています。
  • GCS: 生成されたグラフ画像や、ユーザーが投稿した画像(Synapse はマルチモーダル対応しています)の保存が行われます。
  • BigQuery: データ分析の本丸です。tools-gw の execute_sql ツールを通じてクエリを実行します。
  • Code Execution: Vertex AI の Code Execution サービスです。Python の実行環境がサンドボックスとして提供されていて、pandas や matplotlib などが使えます。BigQuery から取得したデータの加工やグラフ描画に使います。なお、Synapse Chat からの利用はこの Code Execution がコード実行とグラフ化を担いますが、Claude Desktop などの MCP クライアントからの利用では、コード実行やグラフ化をクライアント側で行うこともできるため、tools-gw の Code Execution を使うかどうかはユーザーに委ねられています。
  • Notion: tools-gw が Notion API を叩いて社内ドキュメントを検索・取得します。

処理の流れ

ユーザーが「先月のチャージ金額の推移をグラフで見せて」とリクエストしたときの流れを追ってみます。

Synapse Chat 経由の場合:

  1. ブラウザで Synapse Chat にアクセスし、Entra ID でログイン
  2. チャット欄に「先月のチャージ金額の推移を教えてください。グラフ化してください。」と入力
  3. Synapse Chat が Google ADK 経由で Gemini にリクエストを送る
  4. LLM (Gemini) が tools-gw を利用してセマンティックレイヤーから「チャージ」の意味やそれに関連するテーブル定義やカラムの意味、1レコードが表す意味を把握
  5. LLM (Gemini) が SQL を生成して tools-gw の execute_sql で BigQuery にクエリする
  6. LLM (Gemini) が結果を受け取り tools-gw の execute_code にコードを投げつけてグラフ化を依頼
  7. グラフ画像が GCS に保存され、チャット画面に表示される
  8. LLM (Gemini) が結果を要約して回答

Claude Code / Claude Desktop 経由の場合:

  1. Claude Desktop を起動(カンムのオーガニゼーションにログイン済み)
  2. カスタムコネクタ経由で tools-gw に自動接続(初回は Entra ID の認証が走る)
  3. チャット欄に「先月のチャージ金額の推移を教えてください。グラフ化してください。」と入力
  4. LLM (Claude) が tools-gw を利用してセマンティックレイヤーから「チャージ」の意味やそれに関連するテーブル定義やカラムの意味、1レコードが表す意味を把握
  5. LLM (Claude) が SQL を生成して tools-gw の execute_sql で BigQuery にクエリする
  6. LLM (Claude) が結果を受け取りグラフ化を行う (tools-gw の Code Execution を使うかどうかはユーザーの指示や LLM 次第)
  7. LLM (Claude) が結果を要約して回答

どちらの経路でも裏側で動くセマンティックレイヤーやツール群は同じものです。Knowledgeによって「チャージ」「ポチっとチャージ」「BASE I」といった社内用語や業務用語がそのまま通じます。

MCP サーバとして分離した理由

前回の記事の時点では Synapse は1つのウェブアプリケーションでした。これを MCP サーバに分離した動機は冒頭にも書きましたが改めて整理すると次の通りとなります。

  • クライアントの自由化: ブラウザ以外のツール(Claude Code, Claude Desktop 等)からも同じツール群を利用可能にしたかった
  • 権限の集約: 各社員に Google Cloud のアカウントを配る代わりに、tools-gw がサービスアカウントを使って BigQuery などにアクセスする。社員はtools-gw に対して認証するだけでよい
  • 認証の統一: Entra ID に寄せることでアカウント管理を一元化する

権限の集約と認証の統一については次のセクションで詳しく書きます。

2. 認証と認可

AI エージェントに自分の強い権限をそのまま渡すのは危険です。OWASP の Top 10 for Large Language Model Applications にも過剰な権限の付与がリスクとして挙げられていますが、LLM に対して必要以上の権限を与えないことは AI エージェントのセキュリティにおける原則です。我々もこのプラクティスに則った設計をしようとしています。

Synapse のセキュリティは3つのレイヤーで構成しています。ネットワーク境界、認証、認可です。

ネットワーク境界: Cloudflare WARP による IP 制限

まず前提として、カンムでは社員のマシンに Cloudflare WARP がインストールされています。これに乗っかって Synapse Chat も tools-gw も ALB のレイヤーで Cloudflare WARP を送信元とする IP 制限をかけます。こうすることで Synapse へのアクセスは WARP を経由したトラフィックに限定されます。つまりそもそも社員のマシン以外から Synapse にリクエストを送ることができません。tools-gw については後述するClaude カスタムコネクタのため、claude.ai からのリクエストもさばく必要があるので WARP に加えて claude.ai からの通信も許可しています。

認証: Entra ID を唯一の認証基盤とする

Synapse には2つの入り口がありますがどちらも Entra ID で認証します。

Synapse Chat の認証

Synapse Chat へのログインは OAuth 2.0 Authorization Code Flow + PKCE です。

  1. ユーザーがブラウザで Synapse Chat にアクセス
  2. 未認証であれば Entra ID の認可エンドポイントにリダイレクト
  3. ユーザーが Entra ID でログイン(SSO が効いていれば自動)
  4. 認可コードがコールバック URL に返る
  5. Synapse Chat がトークンエンドポイントで ID Token を取得して検証

Synapse Chat が tools-gw にリクエストを中継する際は、共有シークレットとともにユーザー情報を伝播させます。tools-gw 側は共有シークレットが一致すればこれらのヘッダを信頼し、Entra ID への再検証は行いません。

Claude Code や Claude Desktop など外部 MCP クライアントの認証

Claude Code や Claude Desktop など外部の MCP クライアントが直接 tools-gw に接続する場合は、クライアント自身が Entra ID の OAuth フローを踏みます。

以下、開発で(開発に利用している Claude が)結構ハマったのですが、「どこがキツかった?」と聞いてみたら↓とのことです。

一つ厄介な問題がありました。MCP SDK は OAuth 2.0 の RFC 8707 (Resource Indicators) に準拠していて、認可リクエストに `resource` パラメータを付与するのですが、Entra ID v2.0 はこのパラメータをサポートしておらず、エラーになります。

※ Entra ID v1.0 エンドポイントは `resource` をサポートしていますが v2.0 では廃止されており、代わりに scope で audience を表現する設計になっています。

この問題に対して、tools-gw 側に薄い OAuth プロキシを置くことで解決しました。

MCP Client → tools-gw OAuth Proxy → Entra ID

具体的には以下の3つのエンドポイントを tools-gw に追加しています。

1. `/.well-known/openid-configuration` — ディスカバリメタデータを返す。`authorization_endpoint` と `token_endpoint` を自分自身のプロキシエンドポイントに向ける
2. `/oauth/authorize` — `resource` パラメータを除去して Entra ID にリダイレクト
3. `/oauth/token` — `resource` パラメータを除去して Entra ID のトークンエンドポイントにプロキシ

やっていることは本当にシンプルで、`resource` パラメータを `pop` して、あとは素通しするだけです。PKCE のフローはクライアントと Entra ID の間でそのまま成立します。プロキシ側にクライアントシークレットやステート管理は不要です。

# oauth_proxy.py (簡略化)
async def oauth_authorize(request: Request) -> RedirectResponse:
    params = dict(request.query_params)
    params.pop("resource", None)  # これだけ
    url = f"{entra_base}/authorize?{urlencode(params)}"
    return RedirectResponse(url=url, status_code=302)

正直これを思いつくまでにかなり時間を使いました。MCP SDK のソースを読んで「なぜ `resource` を送るのか」を理解するところから始まり、Entra ID 側の仕様との齟齬に気づくまで丸一日かかっています。解決策自体は数十行のコードなのですが...。

ようは MCP SDK が使っているオプショナルな拡張 (RFC 8707) の resource パラメータを Entra ID v2.0 が受け付けないのでこちらのコードでそれを吸収するパッチを当てる必要があった、ということです。

Entra ID 側の仕様との齟齬に気づくまで丸一日かかっています

自力だったら3日くらいは溶けたかもしれない...

認可: Entra ID のクレームを利用した権限制御

Synapse Chat 側、MCP クライアント側、どちらの経路で認証されても tools-gw には Entra ID で得られた以下のものが渡ってきます。

  • user_id: 社員のメールアドレス
  • roles: Entra ID で付与されたロール
  • caller_id: 呼び出し元の識別子

各ツールはこの情報を参照して動作させることができます。具体的には以下のような認可制御です。

  • ロールに応じた BigQuery サービスアカウントの切り替え: 個人情報を含むカラムへのアクセスは特定のロールを持つユーザーに限定する。ロールに応じて BigQuery 接続に使うサービスアカウントを切り替えることで参照可能なカラムをサービスアカウントレベルで絞ることができる
  • caller_id に応じた制御: 「誰が」「何経由で」ツールを呼んだかが識別できるのでクライアントの種類に応じて応答を調整できる。

社員個人に権限に応じた Google Cloud などのアカウントを配るのではなく、tools-gw がそれを仲介する設計にしています。これにより呼び出されるツール側での個人のアカウント管理が不要になります。Entra ID が事実上の社員名簿になっているのでそこだけ管理すればよいです。

これは前述の OWASP への対応でもあります。AI エージェントがどんなプロンプトを受けても、tools-gw が中間に立って「このユーザーにはこの範囲しか触らせない」という制御をかけられます。LLM にプロンプトで「個人情報は返すな」と指示するだけでは不十分です。プロンプトでの制御に加えて、アプリケーション層での権限制御も確実に行う必要があります。

3. 中央管理による展開

ここまではアーキテクチャとセキュリティの話でしたが、最後にこれを社員にどう届けるかという話をします。 MCP サーバとして切り出したことの実利がわかりやすい部分だと思っています。

Entra ID を社員名簿として

カンムでは Entra ID が事実上の社員名簿であることに触れました。入社すれば Entra ID にアカウントが作られ、配置換えされればロールが変わり、退社すればアカウントが無効化されます。Synapse の認証をすべて Entra ID に寄せることで社員名簿の管理をそのまま Synapse のアカウント管理にすることができます。

Google Cloud をよく例に挙げていますが、Google Cloud と Entra ID の連携もできるとは思います。ただ、自分は Owner 権限を持っているが、Synapse 利用のみ別のリードオンリーのアカウントにしたい...となるとややこしくなりそうなので調査検証はしていないです。「こうすると楽だよ」とか知っていたら誰か教えてください。

Claude カスタムコネクタ: MCP サーバ設定の一元管理

Claude Desktop や Claude Code から tools-gw に接続するための設定は、Claude のカスタムコネクタで行っています。これは Claude.ai のオーガニゼーション管理画面からオーナーが設定するものです。カスタムコネクタをオーガニゼーションレベルで設定しておくと、そのオーガニゼーションに所属するメンバーの Claude Desktop や Claude Code にはあらかじめコネクタが利用可能な状態で配信されます。

このように、設定がされると Claude Code や Claude Desktop の設定画面にバンと現れます。リポジトリや個々の社員のマシンには何も置く必要がありません。Claude Desktop (または Claude Code) をインストールしてカンムのオーガニゼーションにログインすればいいだけです。エンジニアではない職種のメンバーも詰まらずに使えるようにしたいと考えています。

Claude のカンムオーガニゼーションは Entra ID とともにカンムの ITチーム(情シス)が管理していて、個々の社員が Synapse 固有の設定をする必要は一切ありません。

ちなみにあまり触れませんでしたが「外部MCPクライアント」のラインナップには Codex もあります。Codex から tools-gw に接続することもできます。ただ、細かい説明は割愛しますが、 Codex の場合はローカルに mcp-remote をインストールして MCP の stdio と HTTP を仲介する仕組みを作るか、tools-gw を DCR (Dynamic Client Registration) に対応させる...といった対応が必要になります。Synapse に少し手直しが必要であるというのと、まさに CLI を社員の手元に入れてもらう必要があります。検証中に mcp-remote がプロセス起動しっぱなしでポート掴んでて kill する必要がある...という事案に何度か直面したことがあるため、やはりこちらの方式はまだ積極的に社内に展開しづらい状況です。エンジニア職を除いて...。

4. まとめ

  • Synapse の機能分割と MCP サーバ化について書いた。
  • MCP サーバとして切り出したことで社員はガバナンス承認を受けたツールを自由に使って MCP サーバにアクセスできるようになった。
  • Claude のカスタムコネクタを利用することで Claude の製品については、MCP サーバの設定がオーガニゼーションレベルで配信され、社員は Claude Code や Claude Desktop をインストールするだけで、セマンティックレイヤーや Synapse のツール群がすぐに利用できるようになった。
  • Cloudflare WARP によるネットワーク境界の防御、Entra ID による認証、ロールとクライアント識別による認可制御によってアクセスコントロールが敷かれている。

Synapse は日々進化しており、セマンティックレイヤーの継続的なメンテナンスが課題でしたが最近それも自動化されました。記事の頭では MCP を推しているようなことも書きましたがこのあたりはすぐ潮流が変化するため引き続きこれでいいのかを都度判断し続ける必要があります。まるで盤石であるかのように書いた認証基盤も、Entra ID の仕様変更や MCP の認証回りのアップデートに影響を受けるため、そのあたりのメンテナンスコストは織り込んでおく必要があります。

プロダクトのインフラだけでなく、情報システム部の管理する仕組み(WARP, Entra ID)も巻き込んだ仕組み作りなのでなかなか面白いです。MDM と連携して SKILLS が降ってくる仕組みなんかもあると便利かもしれない。次はこの基盤に乗ったアンビエントエージェントの実装にも取り組んでいく予定です。

おわり

*1:ただし、社内でAI利用のガバナンス承認を受けたものに限る

データ分析を加速する AI エージェント Synapse の開発と運用

エンジニアの佐野です。最近はバンドルカードのデータ探索を行うための社内プロダクト Synapse (と名付けた) の開発をしています。雨後の筍のように AI の名を冠したソフトウェアやサービスが出るようになって久しいのですが、カンムでもとりわけ生成 LLM をはじめとする AI は業務で大いに活用されています。例えばソフトウェア開発では各位が Claude Code や Cursor を活用してコードを書いています。わからないことがあったら ChatGPT や Gemini に質問を投げかける、NotebookLM にドキュメントを要約してもらう、Notion や Box など AI 搭載をうたう SaaS ではそれを活用する...といったような。これらはもはや各社当たり前のようにやっているくらいには AI の活用は市民権を得たと思います。

さて自分の所属するチームですが、それら AI の個人活用の一歩先、AI で事業インパクトに貢献する、という目論見でデータ分析に強いエージェントの開発を行っています。今日はそのエージェント開発をなぜデータ分析における探索フェーズの支援にフォーカスさせたのかから始まりその思想と現在の実装、運用について書きます。

  1. なぜデータ分析か
  2. Synapse デモ
  3. データ分析の従来のフローと Synapse 導入後のフローの違い、Synapse のスタンス
  4. Synapse のシステム構成の現在
  5. AI 利用のガバナンス
  6. 課題と方針
  7. まとめ

1. なぜデータ分析か

なぜデータ分析か?ですが、それにはバンドルカードの成功体験があります。バンドルカードは息の長いプロダクトで、2016年に生まれもうすぐ10周年になります。その収益を支えているポチっとチャージという機能があるのですが、その機能はリリース当初からデータドリブンで改善が行われていました。エンジニアもエンジニアではない職種の人も日々 SQL を投げて探索的にデータを確認、仮説を立ててそれを検証して...という PDCA を回していました。それによってポチっとチャージは成長して、今でもカンムの主力機能の1つとなっています。そしてそのデータドリブンの開発という企業文化+SQL勉強会もあり、現在も職種を問わず DB にアドホックなクエリをしてデータを調べる、という行為が日常的に行われています。 このようにカンムでは「仮説を立て、検証を高速に回すデータ分析」が価値を生んできました。

時は流れ今の時代、AI 時代になったのですが、当初このエージェントは「気軽に社内のデータに触れられるもの」を目指しました。背景として、SQL を扱うという土台はあるもののテーブル構造を理解する必要があったり SQL 自体を書くのが手間であったりという状況もありました。自然言語でデータに質問できるインターフェースを作ればこの敷居を下げられるのではないか。そんな仮説からスタートしています。

チームはこの AI エージェントを社内に公開した後、社内のユーザからの使われ具合や入力されたプロンプトを見て議論を行いました。議論の結果、ポチっとチャージの成功や SQL 勉強会から続いているデータドリブンの思想を継承して次のステージに進むこととデータ分析に強い方向に尖らせることを決定し、チームはこのエージェントを Synapse と名付けました。

2. Synapse デモ

ここで Synapse のイメージを示したいのでデモとして不正取引の特徴量の調査をやってみます。見せられないものが多く白抜きばかりになってしまい恐縮ですが...。

このような調査をやると金額のビンを変更してヒストグラムを作り直したり、GROUP BY で別切り口で調査したり...といった作業が発生するのですが、このあたりの作業と結果の要約を LLM に依頼できます。これらの作業自体は高度ではありませんが試行回数が多くボトルネックになりがちです。この工程を人よりも素早くそして何度でも試せます。従来は1つの切り口を試すのに数十分かかっていた作業を、数秒〜数分で反復できるようにするのが狙いです。

簡単なデモを提示したところでこれが従来のデータ分析とどう異なるのかを説明します。

3. データ分析の従来のフローと Synapse 導入後のフローの違い、Synapse のスタンス

以下に一般的なデータ分析のフローを示します。データ探索の箇所は本記事で肝となるので小項目も書いています。

  • 1: 課題設定
  • 2: データ理解/定義
  • 3: データ探索
    • 3.1: SQL作成
    • 3.2: テーブル/グラフ可視化
    • 3.3: データの要約・特徴抽出
    • 3.4: 仮説候補の提示
  • 4: 仮説の選別
  • 5: 解釈・意思決定
  • 6: 施策実行

そして従来のように人間が自力で頑張る場合と Synapse のような LLM をデータ分析に活用するケースでの違いを表にします。

フェーズ 従来型 LLM活用 (Synapse)
1. 課題設定 作業 (人)
・ビジネス課題の言語化

アウトプット
・課題文(例:新規ユーザの7日継続率が低い)
・対象ユーザ(例:直近3ヶ月の新規登録ユーザ)
作業 (人)
・ビジネス課題の言語化(※従来型と同じ)

アウトプット
・課題文(例:新規ユーザの7日継続率が低い)
・対象ユーザ(例:直近3ヶ月の新規登録ユーザ)
2. データ理解 / 定義 作業 (人)
・テーブル定義書を読む
・イベント仕様を確認
・継続率の定義を決める

アウトプット
・テーブル/カラム定義メモ
・定義した継続率
・定義はドキュメントと個人の解釈に依存
作業 (人)
・セマンティックレイヤーを作成
・継続率の定義を明示的に定義

アウトプット
・セマンティック定義一覧
・定義した継続率
・LLMが参照する正式な意味定義
3.1 データ探索(入力) 作業 (人)
・継続率とユーザ属性を紐付けるSQLを設計・作成
・JOIN条件や日付計算を調整
SQLを実行・修正

アウトプット
・継続率の集計表
作業 (人)
自然言語で継続率やユーザ属性に関する問いを立てる
・問いをエージェントに入力

アウトプット
・継続率の集計表
3.2 データ探索(可視化) 作業 (人)
・集計結果をもとにグラフを作成

アウトプット
・継続率推移のグラフ
作業 (システム / LLM支援)
・生成された集計結果を自動で可視化

アウトプット
・継続率推移のグラフ
3.3 データ探索(要約・特徴抽出) 作業 (人)
・数値やグラフを吟味
・差分や特徴量を見つける

アウトプット
・結果の要約
・特徴量候補
作業 (LLM)
・結果を要約
・差分や特徴量候補を提示

アウトプット
・結果の要約
・特徴量候補
3.4 仮説立案 作業 (人)
・表やグラフを見て仮説を言語化

アウトプット
・複数の仮説候補
作業 (LLM)
・データに基づく仮説候補を提示

アウトプット
・複数の仮説候補
4. 仮説選別 作業 (人)
・仮説を選別
・優先順位付け

アウトプット
・選定した仮説
・想定される効果量
作業 (人)
・仮説を選別
・優先順位付け

アウトプット
・選定した仮説
・想定される効果量
5. 解釈・意思決定 作業 (人)
・分析結果の解釈
・施策の意思決定

アウトプット
・検証方針・施策方針
作業 (人)
・分析結果の解釈
・施策の意思決定

アウトプット
・検証方針・施策方針
6. 施策実行 作業 (人)
・施策の実装
・モニタリング
作業 (人)
・施策の実装
・モニタリング

要約すると Synapse がデータの特徴抽出や仮説候補の提示を行って分析者の仮説立案を支援します。スタンスとしては Synapse の出力は常に仮説候補であり、意思決定や判断を行うツールではないというスタンスです。それの評価・選別は人が行います。これは LLM の得意分野だけを当てはめた構図になります。Synapse は当然誤りを犯します (SQLのミスや意味の取り違え) 。しかし人が同じ誤りを犯すよりも早く何度でも試せる点に価値があります。ほとんどの社員が SQL を叩ける文化があるからこそ、そのボトルネックが明確でした。

※ セマンティックレイヤーは、Synapse運用者が事前に用意するデータの意味やロジックを説明する辞書です。分析者が毎回作るものではありません。

再三になりますが以下のような役割分担です。仮説の選別や検証の設計も担うようにしてもいいかもしれませんがまずは小さく始めています。

  • 意味を決める: 人
  • 問いを設計する: 人
  • 探索を実行する: LLM
  • 要約・仮説候補提示: LLM
  • 仮説を選ぶ: 人
  • 検証を設計する: 人
  • 意思決定する: 人

ちなみにですが...Synapse は BI, AutoML ではなく探索フェーズの反復を高速化することで分析の質を上げることを目的としています。

  • BI: 指標を定常モニタリングし、組織で同じ数字を見る
  • AutoML: 答えを出す(予測・最適化)
  • Synapse: 探索フェーズを支援して仮説を生む

強いて言うなら ChatBI に近い領域に入ると思います。ただし Synapse は SQL を省力化するだけの ChatBI ではなく要約・特徴抽出・仮説候補提示まで含めた探索ループの支援に踏み込む方向です。

4. Synapse のシステム構成の現在

Synapse の構成は次のようになっています。アプリケーションは AWS ECS で稼働させていて、そのアプリケーションが Google Cloud の Vertex AI (Generative AI, Code Execution) 経由で LLM の呼び出しやグラフ化のためのコード実行を行います。データソースは現在は BigQuery となっていて、生成されたグラフや SQL は GCS に置かれます。AI エージェントのフレームワークGoogle ADK です。Session というのは平たく言うと Google ADK がユーザの問い合わせ内容やユーザの管理を行うものです。Vertex AI をバックエンドにしてそれを管理することもできますがレイテンシ軽減のために RDS に格納するようにしています。

リージョンがバラバラになっているのですがこれにはいくつかの運用上の理由があります。

  • AWS ECS: カンムのメインのインフラなので。アプリケーションデプロイ〜モニタリングまでの一式の自動化やクレデンシャルの安全な管理の仕組みが既にある。
  • Session: Vertex AI のセッションのレイテンシが気になったので近いロケーションに逃がした。
  • BigQuery: カンムでは BigQuery を長らく運用しておりそのロケーションが US のため。
  • Code Execution: us-central1 しかサポートされていないため。
  • GCS: 日本なので。
  • Generative AI: Gemini 3 Pro などの最新モデルは global ロケーションでしか使えないため。また Claude など Google 外に存在する LLM を呼び出すこともできるのだがそれも global である必要があるため。

5. AI 利用のガバナンス

需要がありそうなので AI 利用のガバナンスについて触れておきます。カンムではもちろん LLM を利用する際はそのベンダのデータの取り扱いとモデルごとの利用規約を重視します。

Synapse はシステム構成で述べた通り Vertex AI 経由で Gemini 3 Pro を利用しています。また Vertex AI 経由で Claude のモデルも...と書いた通り、設定を変えれば Claude のモデルを利用することもできます。ただしプライバシーポリシーや利用規約が Claude を直接使うのと異なる可能性があるのでもし似たような構成を敷こうと考えている方は注意した方がよいです。

冒頭でも述べた通り、カンムでは以下のようにして Claude や Gemini はすでに業務で活用しています。

  • ユーザ (我々) -> Claude
  • ユーザ (我々) -> Gemini

しかしこの構成は次のようになります。

  • ユーザ (我々) -> Vertex AI -> Gemini
  • ユーザ (我々) -> Vertex AI -> Claude

このとき Claude が Vertex AI から送信されたデータをどう扱うか?Vertex AI 自体が Claude に送信するデータをどう扱うか?について注意が必要です。以下が私が参照したドキュメントで、これらのドキュメントを元にカンム社内のポリシーの整理を行いました。釈迦に説法かとは思いますが各種利用規約やプライバシーポリシーは目を通しておくべきです。

加えてですが、AI というよりは一般的なシステム運用の観点においても BigQuery を始め Google Cloud に入るデータやその保存ポリシーについてももちろんコンセンサスがとられた上で運用を行っています。

6. 課題と方針

とりあえず運用を開始して使ってもらっていますが課題は山盛りです。課題と向き合いつつ大事にしている方針を書いておきます。

AI の最新情報に追従しつつ地に足をつけた運用をする

この分野は毎月のようにアップデートがあり、 LLM のアップデートや新製品が出るたびに刺激的な言葉が踊ります。しかしながら、例えばモデルを例にとっても Gemini 3 Pro <-> Claude Opus 4.5 のような切り替えを行っても何かが劇的に変わることはありませんし我々の課題の根本解決にはならないです。ある程度使うモデルは固定したまま Synapse の特に回答精度の改善に注力すべきです。もしかしたら根本解決手段が上位のモデルを使うことであるかもしれませんが要素はそれだけではないはずです。数打ちゃ当たれのような思考でモデルやツールスタックをコロコロ切り替えていると何が効いたのかがわからなくなります。AI 利用のガバナンスの項目で Claude のモデルに切り替えることもできると触れましたが、基本的には現在使っている Gemini シリーズで評価と運用を行っていきます。とはいいつつどこかで他モデルの検証や切り替え判断のポイントを設けようと思っています。強力な武器や新しい概念が出てきたときはドラスティックにそれに変更する決断も必要です。チェックポイントは設けつつもある程度は今のスタックで課題解決を行う方針でいます。

セマンティックレイヤーの運用

セマンティックレイヤーはデータの意味やロジックを明示化するもので、正体は Synapse が読み込むテキストファイルです。カンムのデータベースに存在するテーブルやカラムの説明が書かれているもの (※) でその量は膨大です。これの拡充および精緻化が回答の精度にも繋がるのでここの運用をどう回すかが鍵になっていると考えています。セマンティックレイヤーは一度作って終わりではなくプロダクトや施策の変化に応じて継続的なメンテナンスが必要になるのですがその運用をどう回していくかが大きな課題になっています。

※ RAG とは違います。RAG は使わないのか?と思う人もいるかもしれませんが、上記地に足をつけた運用の箇所でも書いた通り目の前の課題を明確にしたうえでツール導入は行う方針です。

Text-to-SQL の評価

Synapse のコア機能は自然言語からの SQL 生成と実行です。これの精度をどう評価するか...。

  • SQL の正しさ(実行可能か)
  • 意味の正しさ(問いに答えているか)
  • 再現性(同じ質問で同じ結果が出るか)

Agent フレームワークとの付き合い

今は Google ADKPython 版 (※) を使っています。が、やはり各ベンダーもそれに該当するソフトウェア (OpenAI: OpenAI Agents SDK, Anthropic: Claude Agent SDK) をリリースしており今後の業界の勢力図やスタンダードがどうなっていくのかを注視しています。

adk-python

チャット機能の運用と開発

UX に関わる部分です。今は Streamlit を使っています。最近 Streamlit よりは FastAPI だという情報が出回ったのを観測していて、そもそもフロントは自分で作らないという案も考えていたりします。たとえば Claude Desktop をフロントにして裏側に Synapse を配置する構成など…。

フィードバックをどう得るか

Synapse は社内プロダクトなので性善説に基づいたフィードバックは得やすいのですがその設計自体をどうするか...。現在は Synapse の回答内容に :+1: or :-1: のバイナリフィードバックのみを受けることができるようにしているのですが、それでもボタンを押してくれる人は少ないです。ユーザ (社員) のフィードバックを受けて改善に回すような仕組みが必要です。

7. まとめ

  • Synapse という社内のデータ分析を支援する AI エージェント開発についてコンセプトを述べた
  • カンムでは「SQLで探索し、仮説検証を高速に回す文化」が価値を生んできたため、Synapse もその延長として 特に探索フェーズの反復を高速化することにフォーカスした
  • Synapse は 自然言語SQL生成 → 実行 → 可視化 → 要約 → 仮説候補提示 までを一貫して支援し、分析者の試行回数を増やす
  • 一方で、Synapse の出力は常に「仮説候補」であり、評価・選別・意思決定は人が行う(AIが意思決定するツールではない)
  • 精度を担保するために、セマンティックレイヤー(データの意味の辞書)を事前に整備し、LLMが意味を取り違えにくい構造にしている
  • 実運用では、モデルやツール選定の浮つきよりも「回答精度の改善」「セマンティックの運用」「Text-to-SQL評価」「フィードバック設計」が主要な課題になるが、業界の変容は常に注視している。
  • このようなソフトウェアを作るときはデータの取り扱い・利用規約・ZDR などのガバナンス整理が不可欠である。
  • そして AI が当たり前になった今だからこそ、どこを人が担いどこを AI に任せるかを意識的に設計することが重要だと考えている。

カンムでは一緒に働く仲間を募集しています。

team.kanmu.co.jp

おわり

Go Conference 2025 Capture The Flag 模範解答

バックエンドチームの近松です。

カンムでは、Go Conference 2025 開催前に Capture The Flag (CTF) を公開していました。皆さん、お楽しみいただけたでしょうか?

本記事では、CTF の模範解答および作問にまつわる裏話を紹介します。

作問担当者

今回の CTF では、過去に作問経験がある knee を筆頭に、今回初めて作問を担当する kshun、近松(nchika)の合計3名で作問しました。

  • knee(CTO):エグゼクティブプロデューサー担当
  • kshun(セキュリティチーム):作問担当
  • 近松(nchika)(バックエンドチーム):作問、実装担当

余談ですが、近松は不正対策の業務を担当しているため、2025年4月〜7月の間に CTF を通してセキュリティの勉強をしてきました。例えば、ハッキングラボの作り方HackerOne で、CTF の経験を積んできました。

その流れで、今回は作問担当として立候補しました。

想定ターゲットユーザー

CTF 初心者や未経験者を想定しました。

フラグ一覧

今回、レシピサイトに埋め込んだフラグ(注:明示的にフラグと書いていなかったものを含む)は、以下の3点です。

  • 他人のレシピを閲覧(権限管理の不備)
  • ログインフォームから全ユーザー名、全パスワードを取得(不適切なSQL
  • 複数のバイナリを結合し、画像を生成(観察力の要求)

「他人のレシピを閲覧」に関する解説

まず、CTF 初心者や未経験者は、ログインフォームから素直にログインするだろうと想定しました。ログイン後は、下図が示すようにダッシュボード画面からレシピ詳細画面(ぎょうざ、いくらとポテト、ピザ)を順番に確認する人が多いと想定しました。

レシピサイトのトップページ

レシピ詳細画面に遷移すると、特に API を実行できそうなボタンがありません。存在するのは、作問者が作った料理の写真と調理手順だけです。

ピザの詳細

ページから目を離してみましょう。

注意深く URL を観察すると、「レシピ詳細画面は連番を振られて管理されていそうだ」「権限の不備があれば、他のレシピ詳細ページにアクセスできそうだ」と予想できます。作問者としてはそのように予想して欲しかったので、1, 2, 3 と番号を振らずに、意図的に2, 3, 5と欠番がある状態で URL に番号を振って実装しました。

/recipe/4にアクセスすると、さしみ料理のページに辿り着きます。

さしみ料理

おめでとうございます🎉このページはフェイクです。

さしみ料理のページは、フラグをゲットしたと錯覚させるために用意しました。実際のところはもう一つ理由があり、料理の画像が余っていたので、フェイクページで画像を消費しようと考えました。なお、正解のページは、/recipe/13に仕込んであります。若い番号(= /recipe/4)で隠されたページを見つけたけれども、特にヒントがなくて困る人が出てくるといいな、と思いながら実装しました。

別解としては、curl などのコマンドを利用して、HTTP ステータスが404以外のページを集める方法を想定していました。私が過去に解いた CTF にはこのパターンがありました。

ログインフォームから全ユーザー情報を取得

次に怪しいのは、ログインフォームです。

CTF で自由入力できるテキストエリアを見ると、SQL インジェクションが決まる可能性を考える人がいることでしょう。そのような方達のために、今回はツール(例:sqlmap)を使わなくてもお手軽に SQL インジェクションできるログインフォームを作りました。

ログインフォーム

このログインフォーム(正確には認証処理)は凄まじく脆弱で、以下のような実装になっています。ユーザーから受け取ったユーザー名とパスワード(入力値そのまま)で DB を検索します。パスワードは、平文で DB に格納されています。怖いですね。

query := fmt.Sprintf("SELECT username, password FROM users WHERE username='%s' AND password='%s'", username, password)

このSQLクエリは、プレースホルダーを用いていません。プレースホルダーがある場合は、

  1. プリペアードステートメントによって SQL の構文が事前確定
  2. プレースホルダー部分を置き換えて、SQL 実行

の流れになります。言い換えると、ユーザーが不正な入力値を渡して SQL 構文が変わった場合、SQL 実行時にエラーとなります。

さて、SQL インジェクションの模範解答を以下に示します。例えば、ユーザー名に kanmu' OR '1'='1' --、パスワードに任意文字列を入力します。この例では、WHERE 句が`username='kanmu' OR '1'='1'となり、OR '1'='1'は常に true なので、全ユーザーの情報が取得できます。--部分でパスワード条件をコメントアウトしています。

SQL インジェクションが決まると、全ユーザーの情報が綺麗に整形されて表示されます。

SQLインジェクション

全ユーザー情報

なお、SQL インジェクションを問題に組み込んだ理由は、CSV に SQL を実行できる自作ライブラリ(nao1215/filesql)がサーバーでキチンと動作するかを試してみたかったからです。

バイナリ結合による画像生成

SQL インジェクションで取得した他ユーザー情報でログインすると、ステーキソースのレシピが表示されます。ステーキソースのページは、先ほど説明した正解のページ(/recipe/13)です。

ステーキソースのページ

ステーキソースのレシピ詳細画面の下部には、フラグが示されています。このフラグボタンを押すと、flag.zipのダウンロードが始まります。

ダウンロードフラグ

flag.zipは、パスワードがかかっています。ここでのパスワード突破方法は、以下の2パターンを想定していました。

  1. SQL インジェクション結果を利用(zip ユーザーのパスワードが、flag.zipのパスワード)
  2. ZIP パスワードをツールでクラック(例:John the ripper の利用)

解答者が zip ユーザーと zip ファイルを結びつけられない可能性を考え、ツールで簡単にクラックできる脆弱なパスワード("qwerty123456")としました。 zip ファイルを展開すると、以下のファイルが入っています。

  • apple
  • garlic
  • mirin
  • onion
  • soy_sauce
  • README.md

README には、以下の文章が書かれています(読みやすいように改行を加えました)。

$ cat README.md
## Go Conference 2025 ノベルティの受け取り方

本ディレクトリ内にある食材情報の中から、
ノベルティ(ステーキソース)に含まれる隠し味を
カンム社員に伝えてください。

見事正解された方には、ノベルティを進呈します。

この README を読み、「ステーキソースのページ(/recipe/13)に何かヒントが書かれているな」と推測してもらうことを期待しました。ステーキソースの調理手順を確認すると、以下の食材が使われています。

ステーキソースの作り方

この調理手順を読むと、「zip ファイルに入っていたファイル(食材名称が英語で書かれたファイル)」と「調理手順に書かれている食材」が一致しています。しかし、何をすれば良いか分からないで詰まってしまった人がいると思われます。ヒントは、調理手順に書かれている「全てが混ざり合い」の部分で、この文章はファイルを結合すると何かが表れることを示唆しています。

もう一つのヒントは、ファイル自体から情報を得ることです。バイナリアンであればバイナリを覗いたかもしれませんが、作問者としては file コマンドの利用を想定しました。以下のように file コマンドを実行すると、どうやら PNG ファイルが含まれていることが分かります。

$ file apple
apple: data

$ file garlic
garlic: data

$ file mirin
mirin: data

$ file onion
onion: PNG image data, 100 x 272, 8-bit/color RGBA, non-interlaced

$ file soy_sauce
soy_sauce: Clarion Developer (v2 and above) help data

ここまでの流れで、「ステーキソースの調理手順通りにファイルを連結すると、PNG 画像になりそうだ」と予想できます。もう一度、ステーキソースの調理手順を確認して、ファイルを結合してみましょう!

$ cat onion apple garlic soy_sauce mirin > restored.png

隠し味(フラグ)が現れました!お疲れ様でした!

隠し味

作問にまつわる裏話

CTF の作問は、kshunが「問題の答えは、ステーキソースの原材料のうちのどれかにしておく」と発言したことをキッカケに進んでいきました。

作問のキッカケ

この発言を受けて、ノベルティ(ステーキソース)の隠れていない隠し味を答えにしようと、近松は考えました。

隠れていない隠し味に対する誰かのツッコミ

その後は、おおよそ以下のような流れです。

  1. kshun :「バイナリファイルから画像(フラグ)を作ればよい」と発案
  2. 近松:「ファイル名が原材料名のバイナリファイルを複数渡して、結合させる」と発案
  3. 近松:分割したファイルをシェルスクリプトに埋め込む案を実装
  4. 近松シェルスクリプトでは可搬性の問題があるので、Go で再実装

バイナリファイルを分割してから結合し直す案は、「料理は混ぜる工程がある」、「ファイルを食材に見立てよう。分割ファイルを結合して戻すことは、分霊箱(horcrux)が実証している」と考えて、発案しました。ここで分霊箱に発想が至った理由は、私自身分かりません。

初期実装でシェルスクリプトを利用した理由は、画像を5分割してから Go 製のバイナリに埋め込んで、取り出すのが大変だったからです。「バイナリを操作するコマンド」や「バイナリからファイルを取り出す binwalk コマンド」を使って、正確にファイルを取り出せないので「これは殆どの人が対応できない」と判断しました。そこで、シェルスクリプトbase64 エンコードしたバイナリを埋め込んで、解答者に取り出してもらう方法で検証を進めました。

しかし、途中で「シェルスクリプトでは、LinuxMac で動作保証するのが大変。Windows で保証するのはもっと厳しい」と気づきました。この辺りの大変さは、Software Design 2024年12月号 第1特集第4章に書いてあります(著者:近松)。

可搬性であれば、Go を採用すべきです。しかし、Go 製のバイナリから分割ファイルを取り出せません。どうにか binwalk でファイルを抽出できないかと考えた時に、「zip にまとめれば取り出せる」と気づきました。ここからの実装はとても早く、 Go であれば Web アプリも簡単に実装できるので、フラグが想定より増えました。そう、SQL インジェクションや権限不備のフラグは、近松がノリで実装しました。ノリノリで実装した結果、バイナリに埋め込んだレシピ画像が多すぎて、binwalk で zip ファイルを取り出せなくなりました。泣く泣く、ダウンロードボタンを設けて、zip ファイルをダウンロードする仕様にしました。

最後に

カンムでは一緒に働けるメンバを募集しています。私と一緒に CTF を作問しましょう。

team.kanmu.co.jp

Go Conference 2025 Capture The Flag 公開

バックエンドチームの近松です。
株式会社カンムは、Go Conference 2025 に Silver スポンサーとして参加します!

gocon.jp

カンムは例年、プログラミングクイズや Capture The Flag(CTF) を Go Conference 前に公開しています。直近では① 脆弱性を利用してリバースプロキシを突破② バイナリの探索③ ISO 8583 メッセージのパース④ 標準ライブラリの利用ミスに関する脆弱性含むアプリケーションを公開し、皆さんに楽しんでいただきました。

2025年は、レシピサイトをモチーフにした CTF を公開します。

CTF 問題

GitHub に CTF を公開しています。

github.com

CTF 用のサーバーを起動すると、レシピサイトが立ち上がります。 また、Webサイトとしても公開しています。

gocon2025-ctf.dev.kanmu.jp

皆さんは、この脆弱なレシピサイトからフラグを探し当ててください。模範解答や作問意図は、Go Conference 2025(9/27、9/28)終了後に本テックブログで公開します。

CTF レシピサイト

CTF 正解者にはノベルティ

CTF の解答(フラグ)にたどり着いた方には、カンムからノベルティを差し上げます。

  • ノベルティの受け取り方:Go Conference 2025でカンムブースにいる社員にフラグ内容を伝え、回答が正しいこと

今回の CTF にチャレンジせずに Go Conference 2025 へ参加された方向けに、会場でクイズを準備しています。クイズに正解された方にも、ノベルティを差し上げます。

CTF に関するハッシュタグ

今回の CTF に関するご感想やご意見は、X(旧 Twitter)のハッシュタグ#kanmu2025ctf」でお待ちしております。

また、ネタバレを含むコメント、次回への要望がある方は、専用の Issue を用意してあります。こちらでご歓談ください。

【CTF出題予定!】カンムは Go Conference 2025 にシルバースポンサーとして参加します

こんにちは、サクっと資金調達 開発チーム ソフトウェアエンジニアの hata です。

カンムは、2025年9月に開催される Go Conference 2025 にシルバースポンサーとして参加します。

Go Conference には例年関わってきましたが、今年も引き続き、スポンサーという形で Go のコミュニティを応援できることを嬉しく思います。

Go は弊社のプロダクト開発においても主要な言語のひとつであり、エンジニアが日々の業務で触れている技術です。Go を取り巻く技術的な挑戦や知見は、我々にとっても常に関心のあるテーマであり、カンファレンスでの情報交換や登壇の機会を大切にしています。

セッションに登壇します

私は 「panicと向き合うGo開発 - nilawayで探る見逃されるnil参照とその対策」 をテーマに9月28日(日) 15:30 - 15:50のセッションに参加します。

発表では、Go の開発現場でよくある panic を題材に、参加者の皆さんが Go の nil に対する言語仕様や、安全性を支える設計思想への理解を一段深めることを目指します。

gocon.jp

ブース企画として CTF を出題します

当日カンムは会場にブースを出展予定です。今年のカンムのブース企画はオンラインからオフラインにまたがる取り組みを予定しています。

以前の Go Conference でも好評いただいていた CTF 19月17日 に投稿するテックブログにて出題します。 CTF を解けた方はその回答をブースにお持ちいただき、正解の場合はカンムの特製ノベルティをプレゼントします🎁

ぜひ奮ってご参加ください!

当日、Abema Towersでお会いしましょう!

当日カンムからは時間帯に応じて CTOの knee、私 hataGenki SugawaraShuhei Katsumata がブースにいる予定です。

会場でお会いできるのを楽しみにしています。

team kanmu https://team.kanmu.co.jp/

zenn https://zenn.dev/p/kanmu_dev

カンムテックブログ https://tech.kanmu.co.jp/


  1. Capture The Flag, 情報セキュリティ分野では、専門知識や技術を競い合うハッキングコンテストのことを指す

jackc/pgxのErrBadConnリトライ・target_session_attrs

プラットフォームチームの菅原です。

GolangPostgreSQLドライバ jackc/pgxについて最近まで知らなかった機能があったので紹介します。

driver.ErrBadConnでのリトライ

データベースの再起動などで切断されたコネクションをコネクションプールから引き当ててエラーになる問題について、SetConnMaxLifetime()を設定して、定期的にコネクションをリフレッシュするしかないと思っていたのですが、こちらの記事でdriver.ErrBadConnのときにリトライしてくれることを知りました。

たしかにドキュメントには

ErrBadConn should be returned by a driver to signal to the database/sql package that a driver.Conn is in a bad state (such as the server having earlier closed the connection) and the database/sql package should retry on a new connection.

と書いてあり、database/sqlのコードを読むとfunc (db *DB) retry()でリトライ処理を行っています。

// go/src/database/sql/sql.go

// maxBadConnRetries is the number of maximum retries if the driver returns
// driver.ErrBadConn to signal a broken connection before forcing a new
// connection to be opened.
const maxBadConnRetries = 2

func (db *DB) retry(fn func(strategy connReuseStrategy) error) error {
    for i := int64(0); i < maxBadConnRetries; i++ {
        err := fn(cachedOrNewConn)
        // retry if err is driver.ErrBadConn
        if err == nil || !errors.Is(err, driver.ErrBadConn) {
            return err
        }
    }

    return fn(alwaysNewConn)
}

pgxのコードではSafeToRetry()がtrueを返すときにErrBadConnを返していました。特定のエラーや、エラー発生時にデータ送信がなかった場合などにリトライが許可されるようです。

cf. https://github.com/search?q=repo%3Ajackc%2Fpgx%20SafeToRetry&type=code

// pgx/stdlib/sql.go

    if err != nil {
        if pgconn.SafeToRetry(err) {
            return nil, driver.ErrBadConn
        }
        return nil, err
    }

以下のコードで動作を確認してみました。

package main

import (
    "database/sql"
    "fmt"
    "net/url"
    "time"

    _ "github.com/jackc/pgx/v5/stdlib"
    "github.com/mattn/go-tty"
)

func main() {
    url := &url.URL{
        Scheme: "postgres",
        User:   url.UserPassword("postgres", "xxx"),
        Host:   "xxx.ap-northeast-1.rds.amazonaws.com:5432",
        Path:   "postgres",
    }

    db, err := sql.Open("pgx", url.String())

    if err != nil {
        panic(err)
    }

    defer db.Close()
    db.SetConnMaxLifetime(0)
    db.SetConnMaxIdleTime(0)
    db.SetMaxIdleConns(1)
    db.SetMaxOpenConns(1)

    tty, err := tty.Open()

    if err != nil {
        panic(err)
    }

    defer tty.Close()

    for {
        // キー入力を待つ
        tty.ReadRune()

        var n int
        err = db.QueryRow("select 1").Scan(&n)

        if err != nil {
            fmt.Println(err)
            continue
        }

        fmt.Printf("select 1 => %d\n", n)
    }
}
// database/sql/sql.go

func (db *DB) retry(fn func(strategy connReuseStrategy) error) error {
    for i := int64(0); i < maxBadConnRetries; i++ {
        err := fn(cachedOrNewConn)
        // retry if err is driver.ErrBadConn
        if err == nil || !errors.Is(err, driver.ErrBadConn) {
            return err
        }
        // リトライ時の出力を追加
        fmt.Printf("[INFO] retried with error: %s\n", err)
    }

    return fn(alwaysNewConn)
}

キー入力でselect 1を実行しながら途中でデータベースを再起動してみると、リトライされていることが確認できました。

select 1 => 1
select 1 => 1
select 1 => 1
# ここでデータベースを再起動
[INFO] retried with error: driver: bad connection
select 1 => 1

target_session_attrs

こちらは別のブログ記事で知ったのですが、go-sql-driver/mysqlにはrejectReadOnlyというパラメーターがあり、Auroraがフェイルオーバーした際に降格したreaderノードに書き込みを行う問題を回避できるようになっていました。

pgxでも同様の機能がないか調べたところtarget_session_attrsというパラメーターで接続するノードの種別を指定できるようになっていました。

cf. https://github.com/jackc/pgx/blob/70f7cad2226dc12406b105f8bb5be9c62780aaf7/pgconn/config.go#L402-L417

   switch tsa := settings["target_session_attrs"]; tsa {
    case "read-write":
        config.ValidateConnect = ValidateConnectTargetSessionAttrsReadWrite
    case "read-only":
        config.ValidateConnect = ValidateConnectTargetSessionAttrsReadOnly
    case "primary":
        config.ValidateConnect = ValidateConnectTargetSessionAttrsPrimary
    case "standby":
        config.ValidateConnect = ValidateConnectTargetSessionAttrsStandby
    case "prefer-standby":
        config.ValidateConnect = ValidateConnectTargetSessionAttrsPreferStandby
    case "any":
        // do nothing
    default:
        return nil, &ParseConfigError{ConnString: connString, msg: fmt.Sprintf("unknown target_session_attrs value: %v", tsa)}
    }

libpqにある機能ですがpgxも独自に実装しているようです。

// ValidateConnectTargetSessionAttrsReadWrite is a ValidateConnectFunc that implements libpq compatible
// target_session_attrs=read-write.
func ValidateConnectTargetSessionAttrsReadWrite(ctx context.Context, pgConn *PgConn) error {

以下のコードで動作を確認してみました。

package main

import (
    "database/sql"
    "fmt"
    "net/url"
    "time"

    _ "github.com/jackc/pgx/v5/stdlib"
)

func main() {
    params := url.Values{}
    // params.Add("target_session_attrs", "read-write")

    url := &url.URL{
        Scheme:   "postgres",
        User:     url.UserPassword("postgres", "xxx"),
        Host:     "xxx.ap-northeast-1.rds.amazonaws.com:5432",
        Path:     "postgres",
        RawQuery: params.Encode(),
    }

    db, err := sql.Open("pgx", url.String())

    if err != nil {
        panic(err)
    }

    defer db.Close()
    db.SetConnMaxLifetime(0)
    db.SetConnMaxIdleTime(0)
    db.SetMaxIdleConns(1)
    db.SetMaxOpenConns(1)

    for {
        time.Sleep(1 * time.Second)

        r, err := db.Exec("insert into test values ($1)", time.Now().String())

        if err != nil {
            fmt.Println(err)
            continue
        }

        n, _ := r.RowsAffected()
        fmt.Printf("RowsAffected: %d\n", n)
    }
}

target_session_attrsを設定しないコードを動かしてフェイルオーバーを行うと、切り替え後にERROR: cannot execute INSERT in a read-only transactionが発生しつづけてしまいます。

RowsAffected: 1
RowsAffected: 1
RowsAffected: 1
unexpected EOF
failed to connect to `user=postgres database=postgres`:
    xxx.xxx.xxx.xxx:5432 (xxx.ap-northeast-1.rds.amazonaws.com): dial error: dial tcp xxx.xxx.xxx.xxx:5432: connect: connection refused
    xxx.xxx.xxx.xxx:5432 (xxx.ap-northeast-1.rds.amazonaws.com): dial error: dial tcp xxx.xxx.xxx.xxx:5432: connect: connection refused
...
ERROR: cannot execute INSERT in a read-only transaction (SQLSTATE 25006)
ERROR: cannot execute INSERT in a read-only transaction (SQLSTATE 25006)
ERROR: cannot execute INSERT in a read-only transaction (SQLSTATE 25006)
...

target_session_attrs=read-writeを設定した場合には、切り替え後に検証が行われ書き込み可能なコネクションに接続することを確認できました。

RowsAffected: 1
RowsAffected: 1
RowsAffected: 1
unexpected EOF
failed to connect to `user=postgres database=postgres`:
    xxx.xxx.xxx.xxx:5432 (xxx.ap-northeast-1.rds.amazonaws.com): dial error: dial tcp xxx.xxx.xxx.xxx:5432: connect: connection refused
    xxx.xxx.xxx.xxx:5432 (xxx.ap-northeast-1.rds.amazonaws.com): dial error: dial tcp xxx.xxx.xxx.xxx:5432: connect: connection refused
...
failed to connect to `user=postgres database=postgres`:
    xxx.xxx.xxx.xxx:5432 (xxx.ap-northeast-1.rds.amazonaws.com): ValidateConnect failed: read only connection
    xxx.xxx.xxx.xxx:5432 (xxx.ap-northeast-1.rds.amazonaws.com): ValidateConnect failed: read only connection
RowsAffected: 1
RowsAffected: 1
RowsAffected: 1
...

まとめ

MySQLの話からなんとなく調べてみただけだったのですが有用な機能を知ることができました。

再接続やフェイルオーバー時のノード選択などの機能はライブラリに実装されず自前でライブラリを拡張することも多いのですが、このようにライブラリ側で実装されているとデータベースを運用する立場としてはとてもありがたいです。

自分の知らない機能はまだまだある気はするので、時間のあるときにでもこまごま深掘りできたらと思っています。

Go 1.24 で map が30%以上高速化!Swiss Tableとは?

Go1.24 がついに公開されましたね。その中でとても興味深い改善内容がありました。

簡単にいうと以下のような内容です。

  • map処理が30%+の高速化
  • CockroachDB1のチームで高性能なSwiss Tableを開発

なんとGo 1.24ではMapに関する処理が30%+の高速化しているそうです! それを実現しているSwiss Tableとは何かを少しみてみたいと思います。

初学者が勉強的に読んでいるので、誤った解釈などあれば教えて下さい 🙏

Go の従来の map

Go 1.23 以前の map は、バケットとオーバーフローバケット を備えた従来のハッシュテーブルが使用されていたそうです。

この方式の課題は、キャッシュの非効率性とポインタ追跡に課題があったそうです。

その問題を解決すべく選ばれたのは Swiss Table でした

cockroachdbのswissリポジトリのreadmeを読むと以下のようなドキュメントやライブラリもあるので、興味があれば覗いてみて下さい!

Swiss Table2とは?

高速なオープンアドレス方式3のハッシュテーブル実装であり、ハッシュ値の分割とSIMD(Single Instruction Multiple Data)4 命令の活用により、従来のハッシュテーブルよりも効率的な操作を実現しています。

ハッシュ値の分割:H1とH2

Swiss Tableでは、キーから生成された64ビットのハッシュ値を以下のように分割します。

  • H1(上位57ビット):テーブル内のグループ(バケット)の開始位置を決定するために使用

  • H2(下位7ビット)メタデータとして保存され、キーのハッシュシグネチャとして機能

この分割により、H1はデータの格納場所を特定し、H2はキー比較の前に候補を絞り込むためのフィルタとして機能します。

SIMD(Single Instruction Multiple Data)の活用

SIMDは、単一の命令で複数のデータを同時に処理する技術です。Swiss Tableでは、SIMD命令を使用して、メタデータの複数のバイトを一度に比較することで、検索や挿入操作の効率を大幅に向上させています。

具体的な手順

  1. メタデータの読み取り:H1で特定されたグループのメタデータ(複数のコントロールバイト)をSIMDレジスタに読み込み
  2. H2との比較SIMD命令を使用して、読み込んだメタデータとターゲットのH2を同時に比較
  3. 結果の解析:比較結果から、有効な候補の位置を特定し、実際のキーと値の比較

この方法により、複数のスロットを一度に検査でき、キャッシュの局所性を高め、分岐予測のミスを減らすことで、全体的なパフォーマンスが向上します。

これらの工夫により、Swiss Tableは高い効率性とパフォーマンスを実現しているようです。

CockroachDBのSwiss Table は何が違うのか

では、CockroachDBのSwiss Tableはどう実装されていることで、30%以上のパフォーマンス向上を果たしているのかを見てみましょう。

基本的には、GoogleのSwiss Tableの設計を踏襲しているようですね。

  • オープンアドレス方式とメタデータ配列
    • ここで、各スロットに1バイトのコントロールメタデータ)を持たせ、空・削除済み・使用中を示す点が説明されています。

https://github.com/cockroachdb/swiss/blob/main/map.go#L1468-L1473:embed:lang=go https://github.com/cockroachdb/swiss/blob/main/map.go#L25-L38:embed:lang=go

  • SIMD(Single Instruction Multiple Data)の活用
    • ARM向けのSWAR(SIMD Within A Register)による処理についても記載されており、複数スロットを一括でチェックする仕組みが解説されています。

https://github.com/cockroachdb/swiss/blob/main/map.go#L35-L38:embed:lang=go

  • 探索戦略(プロービング)
    • H1(上位57ビット)を使ってグループの開始位置を決定し、グループ内のコントロールバイト(H2)を調べることで候補を絞り込む方法が説明されています。
    • また、probeSeq 型の実装部分にも、グループごとの線形探索とグループ間の二次探索(quadratic probing)の流れが示されています。

https://github.com/cockroachdb/swiss/blob/main/map.go#L52-L59:embed:lang=go

  • バケットサイズとキャッシュ最適化
    • Google版ではメタデータとスロットが分離しているのに対し、CockroachDB版(この実装)では8個のコントロールバイトと8個のスロットをグループ化することで、同一キャッシュライン内でアクセスできるようにしている点が説明されています。

https://github.com/cockroachdb/swiss/blob/main/map.go#L40-L50:embed:lang=go

  • 削除処理の最適化
    • Tombstone(削除済みのフラグ)を使い、かつ隣接スロットの状態をチェックすることで、プロービングの不整合を防ぎながら効率的に削除を行う方法が述べられています。

https://github.com/cockroachdb/swiss/blob/main/map.go#L61-L69:embed:lang=go

https://github.com/cockroachdb/swiss/blob/main/map.go#L71-L93:embed:lang=go

ざっくりとまとめるとこんな感じでしょうか?

Google Abseil(C++ CockroachDB swiss(Go)
メタデータ管理 メタデータとスロットを分離 メタデータとスロットを同じキャッシュラインに配置
キャッシュ最適化 N-1スロット + N+groupSizeメタデータ 8スロット + 8メタデータでキャッシュ効率を向上
探索アルゴリズム グループ単位の線形探索 線形 + 二次探索を組み合わせたハイブリッド探索
リサイズ バケットを一斉にリサイズ 拡張可能なハッシュで局所的にリサイズ
削除処理 Tombstone利用 Tombstoneに加え、隣接スロットのチェックで最適化
SIMD最適化 x86: SIMD / ARM: SWAR x86: SIMD / ARM: SWAR + メタデータ最適化

今後の改善余地は?

このツイートを読むと、SIMDの使用率の改善、削除処理の向上、サイズ変更戦略の変更でより改善の余地を残しているそうです!


  1. CockroachDB githubの文言を引用しますが、

    CockroachDBは、トランザクション処理と強い一貫性を持つキーバリューストア上に構築された分散型SQLデータベースです。水平スケーリングが可能で、ディスク、マシン、ラック、さらにはデータセンターの障害にも最小限の遅延で対応し、手動の介入を必要としません。強い一貫性を持つACIDトランザクションをサポートし、データの構造化、操作、およびクエリのための馴染みのあるSQL APIを提供します。

    普段、分散型SQLデータベースを作成しているチームがコントリビュートしてくれていたのですね。 そのチームが開発したSwiss Tableが今回の改善に大きく貢献しているようです。

  2. SwissTable: A High-Performance Hash Table Implementationを参考
  3. オープンアドレス
  4. SIMD 【Single Instruction/Multiple Data】