データ分析を加速する 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】

Goのコネクションプーリングまわりのメトリクス収集

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

Goでデータベースを使う場合には、以下のメソッドでコネクションプーリングまわりの設定を調整することが多いと思います。

MaxOpenConnsを設定してアプリケーションからの接続がデータベースのリミットを超えないようにしたり、MaxIdleConnsを設定してアイドルコネクションを保持し接続が都度発生しないようにしたりしますが、サービスにデプロイされた後はその設定が正しいか調べるためメトリクスが必要になります。

func (db *DB) Stats()はそれらコネクションプーリングの統計データを返すメソッドで、以下のような構造体を返します。

type DBStats struct {
    MaxOpenConnections int // Maximum number of open connections to the database.

    // Pool Status
    OpenConnections int // The number of established connections both in use and idle.
    InUse           int // The number of connections currently in use.
    Idle            int // The number of idle connections.

    // Counters
    WaitCount         int64         // The total number of connections waited for.
    WaitDuration      time.Duration // The total time blocked waiting for a new connection.
    MaxIdleClosed     int64         // The total number of connections closed due to SetMaxIdleConns.
    MaxIdleTimeClosed int64         // The total number of connections closed due to SetConnMaxIdleTime.
    MaxLifetimeClosed int64         // The total number of connections closed due to SetConnMaxLifetime.
}

カンムのサービスではモニタリング・ログ収集にDatadog、Goアプリからのログ出力にzerologを使っているので、アプリ起動時に以下のようなgoroutineを実行してコネクションプーリングの統計データをログ出力するようにしてみました。

// goroutine
func LogDBStats(db *sql.DB, host string, interval time.Duration, logger *zerolog.Logger) func() {
    ticker := time.NewTicker(interval)
    done := make(chan struct{})

    go func() {
        for {
            select {
            case <-done:
                return
            case <-ticker.C:
                logger.Info().
                    Str("log_type", "db_stats").
                    Str("host", host).
                    Interface("db_stats", db.Stats()).
                    Msg("Database statistics")
            }
        }
    }()

    return func() {
        ticker.Stop()
        close(done)
    }
}
// アプリ側
if cfg.DBStatsIntervalSec > 0 {
    interval := time.Duration(cfg.DBStatsIntervalSec) * time.Second
    cleanup := utils.LogDBStats(db, cfg.DBHost, interval, &logger)
    defer cleanup()
}

こうするとDatadogのログ管理でコネクションプーリングの統計データを見られるようになります。

またログの属性値からグラフを作成することも可能です。

これでコネクションプーリングまわりをモニタリングできるようになって便利になりました…

…と、思っていたのですが、dd-trace-goを調べてみたらv1.63.0でDB Statsを収集するための変更が入っていました。

github.com

なのでcontrib/database/sqlsqltrace.Open()/OpenDB()を使っている場合、WithDBStats()を使えば普通にメトリクスを収集することができます。

db := sqltrace.OpenDB(connector, sqltrace.WithServiceName("my-service"), sqltrace.WithDBStats())

Datadogのログ管理でメトリクスを収集する場合、メトリクスの保持期間がログの保持期間と同じなってしまうので、長期的にメトリクスを保持したい場合はWithDBStats()を使った方が良さそうです。