0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

30億行のClickHouseログ検索をMCPにして、AIに自分でログを調べさせる

0
Posted at

1. 背景:なぜログ検索を「AI に使わせる」のか

日々の開発や運用でよくやるのは、「あるリクエストがそのとき何をしていたか」を追う作業です。問い合わせ対応、挙動確認、データの裏取り——用途はさまざまですが、従来は人が Grafana / Kibana のパネルを開いて一件ずつ追っていました。

そして AI 時代になり、こうした調査を coding agent が手伝ってくれるようになってきました。Claude Code が開発やデバッグを手伝ってくれるとき、agent 自身がこう調べたくなります——「この注文のあのリクエスト、ゲートウェイが受け取った body は何で、どのステータスコードを返した?」。パネルをポチポチさせる(そもそも agent には押せない)くらいなら、検索能力そのものを MCP ツールにして、自然言語でそのまま調べさせた方が早い。

そしてこれが成立する前提は、土台に耐えられる DB があること。ログ量が大きすぎて、多条件のアドホック検索 + 集計 をこなせるのは列指向 OLAP だけ——まさに ClickHouse の得意分野です。


2. まず規模感を

項目 数値
総行数 約30.1億行(3,014,466,650)
保持期間 約6か月
日次増分 約2,000万行/日(直近14日で 17.4M〜22.6M)

一言で言うと 行が「太い」 ——各行に header の Map と request/response body を丸ごと持っているためです。この事実が、後述するすべての最適化の核心「検索時はできるだけあの巨大カラムに触れない」につながります。


3. テーブル構造(イメージ)

実テーブルはもっと項目が多いのですが、ここでは後で触れる「特殊な」カラムを残した等価のイメージを示します:

-- イメージ(実際の DDL ではありません)。ポイントはこれらのカラムの「型」
CREATE TABLE http_logs
(
    timestamp        DateTime64(9, 'UTC'),
    request_id       String,
    direction        LowCardinality(String),   -- inbound / outbound
    request_method   LowCardinality(String),   -- GET / POST / ...
    request_url      String,
    status_code      Int32,
    service          LowCardinality(String),
    client_ip        IPv4,
    upstream_ip      IPv4,
    duration         Float64,                   -- ms
    user_agent       JSON,                      -- 取り込み時にstring/os/device/browserへ解析済み
    request_headers  Map(String, String),
    response_headers Map(String, String),
    request_body     String,                    -- 数十KBに達することも
    response_body    String
)
ENGINE = MergeTree
PARTITION BY toYYYYMMDD(timestamp)
ORDER BY (service, timestamp)
TTL toDateTime(timestamp) + INTERVAL 6 MONTH;

「重い」カラムが3つあります:user_agentJSON 型)、request_headers / response_headersMap 型)、*_body(巨大な String)。これらが以降の性能チューニングの主役です。JSON 型は比較的新しいバージョンの ClickHouse が必要です。


4. アーキテクチャ:ひとつの検索ロジックを人と AI で共有

  Web UI (人)   ─┐
                 ├─►  アプリ層(認証 / パラメータ化クエリ構築 / マスキング / 監査)  ─►  ClickHouse (http_logs)
  MCP client(AI) ─┘

設計の基本方針:

  • 自由 SQL は開放しない。公開するのは 固定のパラメータ化ツール 一式だけ:条件で一覧検索、request_id で単一明細取得、あるリソースの変更履歴、各カラムの頻出値集計(facets)。人も AI も同じツールを叩きます。
  • すべての SQL は {name:Type} バインディング で、ユーザー入力を文字列連結しません。さらに 読み取り専用のプレフィックス許可リストSELECT/WITH/SHOW/DESCRIBE/EXISTS で始まるものだけ許可)を重ねます。インジェクション面はほぼ塞がれます。
  • 出力は構造化 JSON envelope に統一data + meta{行数 / 打ち切りか / 時間窓 / 絞り込み条件} + hints)。機微なフィールドはマスキングエンジンを通します。
  • 呼び出しごとに監査ログを書く(誰が / どのツールを / どの引数で / 何行 / 何ミリ秒)。

こうすると「AI にログ検索能力を足す」作業は「agent をこの MCP server につなぐ」だけに縮みます。認証情報はサーバ側に集約され、失効可能・監査可能で、ClickHouse の読み取り専用アカウントを一人ひとりに配らずに済みます。


5. ClickHouse クエリチューニング実践(本編)

以下の数字はすべて、上記の30億行テーブルでの実測値です。

5.1 JSON カラム:サブカラムを読む。toString で丸ごとは NG —— 約9×

単一リクエストの詳細を取るとき、最初は生の UA 文字列を得るために JSON カラムを丸ごと文字列化していました:

-- 遅い:toString は JSON オブジェクトを丸ごと再構築し、全サブカラムを読む
SELECT toString(user_agent) FROM http_logs WHERE request_id = {rid:String} ...

必要なサブカラムだけを読むように変更:

-- 速い:user_agent.string サブカラム1つにしか触れない
SELECT toString(user_agent.string) AS user_agent FROM http_logs WHERE request_id = {rid:String} ...

単一行の詳細取得が実測 約1.5s → 約0.17s、およそ9倍。 理屈は単純で、toString(丸ごと) はオブジェクトの全サブカラムを読み出して組み立て直すのに対し、実際に欲しいのはそのうち1フィールドだけ。JSON カラムは必ず サブカラムアクセス を活かすべきです。

5.2 Map カラム:headers[key] 要素アクセス / mapContains を使う。Map 丸ごと toString は NG —— 約3×

header 検索には3パターンあり、性能が大きく違います:

-- ① header キー指定 + 値の部分一致(速い、最優先):Map 要素アクセス
WHERE request_headers['Referer'] LIKE {v:String}

-- ② ある header が存在するかだけ判定:mapContains
WHERE mapContains(request_headers, {k:String})

-- ③ キー名が不明で全 headers を部分一致(遅い):Map 丸ごと toString
WHERE toString(request_headers) LIKE {v:String}

同じ24時間の時間窓で、①は③より約3倍速い。そこでツール設計上は「キー指定」を第一経路にし、キー名が不明なときだけ Map 丸ごとスキャンにフォールバックします——しかも返り値で「この経路は遅い」と明示 し、まず facets で時間窓内に出現した header キー名を集計してから精密に検索するよう誘導します。

5.3 LIKE 部分一致:まずメタ文字をエスケープ、その上に「ミニ・ブール構文」

ログ中のリソース ID にはよくアンダースコア(_)が含まれますが、_LIKE ではワイルドカードです。エスケープしないと ord_123ordX123 に誤マッチします。なので部分一致は必ず _ % \ をエスケープ:

def esc_like(s: str) -> str:
    return s.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")

その上に、Grafana / Lucene 風のブールクエリ を ClickHouse の LIKE / match にコンパイルします:

  • スペース = AND(すべて命中)、リテラル OR = または、-x / NOT x = 除外、"a b" = スペースを含むフレーズ、url: / body: = カラム限定。
  • 例:carts ord_1(両方含む)、alipay OR wechat(いずれか)、webhooks -health

メリット:人と AI がまったく同じクエリ DSL を使える ——UI の検索ボックスも、agent が持つツール引数も、背後は同一のコンパイラです。

5.4 コネクション再利用:thread-local な長命コネクション + 失効時の自己修復

最初はクエリのたびに client を新規作成し、使い終わったら close していました。これは リクエストごとに DNS + TCP + HTTP ハンドシェイクをやり直す に等しく、さらに version / timezone の探索往復も走ります——ネットワークセグメントをまたぐと数百ミリ秒〜秒級の固定コストになり、たまに起きる接続タイムアウトが大きなスパイクとして積み上がります。

対策:スレッドごとに長命コネクションを1本持つ(server は上限付きスレッドプールでツールを実行 → スレッド数が有界 → 実質コネクションプール)。コネクションが失効したら破棄して作り直し、1回だけリトライすれば十分。client はスレッドをまたがず共有しないので ロック不要 です。

_tls = threading.local()

def client(force_new=False):
    c = getattr(_tls, "client", None)
    if force_new and c is not None:
        try: c.close()
        except Exception: pass
        c = None
    if c is None:
        c = new_client()          # driver 内部の keep-alive コネクションプールを再利用
        _tls.client = c
    return c

def query(sql, params):
    try:
        return client().query(sql, parameters=params)
    except (OperationalError, OSError):
        # keep-alive が対向/中間機器に切られている可能性。新しいコネクションで1回だけリトライ
        return client(force_new=True).query(sql, parameters=params)

5.5 時間窓:文字列で渡し、明示的に UTC で解釈

業務側はローカルタイムゾーン(JST)で「直近1時間」と考えたがりますが、保存・比較は UTC でなければなりません。そこで境界を文字列で渡し、ClickHouse に明示的な UTC 解釈をさせます:

WHERE timestamp >= parseDateTime64BestEffort({start:String}, 9, 'UTC')
  AND timestamp <= parseDateTime64BestEffort({end:String}, 9, 'UTC')

返却時には各タイムスタンプを UTC と JST の両方で返します。人は JST を見て、機械は UTC を突き合わせます。

5.6 body 巨大カラム:substring で長さを絞って取得、length は別に取る

1行の body は数十 KB になり得ます。詳細を見るときに丸ごと引っ張る必要はありません:

SELECT
    substring(request_body, 1, 12000) AS request_body,       -- 先頭12KBだけ
    length(request_body)              AS request_body_length -- 実際の長さは別途取得、打ち切り判定に使う
FROM http_logs WHERE request_id = {rid:String} ...

フロント/agent は受け取った後で「この部分は打ち切られていて、元の body は実際 N バイトある」と分かります。黙ってデータを落とすことがありません。


6. 結果を LLM に渡す:コンパクトなシリアライズで token を30〜40%節約

検索結果はすぐ数十行になります。標準 JSON(各行でフィールド名を毎回繰り返す)のままモデルに渡すと token の無駄が大きい。そこで「均一なフラット行」のリスト結果に対して、コンパクトなシリアライズ を一段かけます:

「ヘッダ1行 + パイプ区切りのデータ行」に畳む。カラム名は一度だけ、ネストしたフィールドはドットで展開(例 status.code / status.label):

# 標準 JSON(各行でフィールド名を繰り返す)
[{"request_id":"...","method":"GET","status_code":200},
 {"request_id":"...","method":"POST","status_code":404}, ...]

# コンパクト後(カラム名は一度だけ)
request_id | method | status_code
r-abc      | GET    | 200
r-def      | POST   | 404

実測で token を約30〜40%節約。 ポイント:

  • 畳むのは MCP(モデルに渡す)経路だけWeb UI 経路 は従来どおり完全な構造化 envelope を受け取ってツリー描画するので、互いに影響しません。
  • 単一明細 / 本文つき / ネストが不揃いな結果は畳まず、そのままコンパクトな JSON で透過。
  • token は agent のコストであり、同時にコンテキスト予算でもあります。圧縮すればより多くの行をコンテキストに載せられ、agent が一度に「見える」ログが増えます。

7. まとめ

  • ClickHouse は30億行のログのアドホック検索を難なくこなします。ただし使い方が肝心です:JSON サブカラムアクセス、Map 要素アクセス、コネクション再利用、パラメータ化バインディング、巨大カラムに触れない——どれも数倍の差を生みます。
  • この検索能力を MCP に包む と、ログ調査は「人がパネルをポチポチ」から「AI が自分で調べる」に変わります。coding agent が本番障害調査にどんどん参加していく今、これは「AI 時代のデータベース」の、とても自然で実用的な新しい入り口だと思います。

もし皆さんも agent にデータソースをつないでいるなら、踏んだ落とし穴などぜひ交換しましょう 🙌

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?