---
title: "3つのクラウドで1000兆行を処理：LogHouseのスケーリング"
date: "2026-07-23T14:06:30.601Z"
author: "Sergey Veletskiy"
category: "Engineering"
excerpt: "私たちは社内のロギングプラットフォームを19 PiBから431 PiB、そして1.59 quadrillion(千兆)行までスケールさせました。本記事では、書き込み80 GiB/sを処理しながらもクエリを高速に保ち、基盤の複雑さをユーザーから見えないようにするために、LogHouseをどのように再設計したかをご紹介します。"
---

# 3つのクラウドで1000兆行を処理：LogHouseのスケーリング

LogHouseは、ClickHouse Cloudの社内ログ基盤で、現在**431 PiB**の未圧縮データを**1.59京行**にわたって保持しています。3つのクラウドプロバイダーの30以上のリージョンにまたがっており、そのいずれに対する典型的なクエリも、数百ミリ秒で結果を返します。

これはシリーズの第3回にあたります。第1回では、LogHouseを構築してDatadogを置き換え、19 PiBに到達しました。第2回では100 PBを突破し、システムテーブルのテレメトリ専用に構築したパイプラインであるSysExを紹介しました。SysExは現在、私たちのスタック内でOpenTelemetryと並んで稼働しています。この3本の記事を通じて、急峻な成長カーブが描かれています。19 PiB → 431 PiB、2年間で23倍の飛躍、そして37兆行 → 1.59京行という規模です。

## はじめに

2年前、アーキテクチャはシンプルでした。1リージョンにつき1つのClickHouseインスタンスがあり、ログが必要な人はそのリージョンに直接接続していました。数個のAWSリージョン、19 PiBの規模ではこれで問題ありませんでしたが、30以上のリージョン、431 PiBの規模ではもはや通用しません。

まず書き込みパスに負荷がかかり始めました。ClickHouse Cloudの成長に伴い、そこを流れるデータ量も増え、ピーク時には**80 GiB/秒、1秒あたり1.9億行**にまで達しています。これに追随するため、書き込みパスは2つのことをうまくこなす必要がありました。水平にスケールすること、そしてその間も信頼性を維持することです。

最初の課題への答えがジオシャーディングです。書き込みは到着したリージョン内にとどめ、リージョン間のエグレスコストを回避しつつ、各リージョンが独立してスケールできるようにします。需要が増えれば、混雑するリージョンでは複数のセルを持つことができ、システムの他の部分に影響を与えることなく、ほぼ線形にスケールできます。

問題は、ジオシャーディングは書き込み側の判断でありながら、読み取り側にコストを転嫁する点にあります。1リージョン1セルでさえ、リージョンごとのデータソースモデルは管理も利用も既にぎこちなくなっていました。セルが複数になれば、これは持続不可能になるでしょう。私たちは、その複雑さをLogHouseにクエリを投げる人々に押し付けるつもりはありませんでした。

そこで私たちは両者を調和させることを目指しました。書き込みは自由にどれだけでも分割できるようにしつつ、読み取りに対してはあたかも下層のトポロジーが存在しないかのように振る舞わせる、というものです。この記事の残りでは、それをどう実現したかを説明します。まず書き込みパス、次にセルの作成を透過的にする読み取り層、そして最後に私たちが直面し始めているスケーリング上の限界についてです。

## 数字で見る:1京行

現在、LogHouseは**431 PiB**の未圧縮データを**1.59京行 (1.59 × 10¹⁵)**にわたって保持しています。これは2年前と比較して約**23倍**の増加です。

![loghouse_growth_2024_2026 (2).svg](https://clickhouse.com/uploads/loghouse_growth_2024_2026_2_502338d93f.svg)

| システム | 未圧縮サイズ |       行数       |
|:------:|:-----------------:|:----------------:|
| SysEx  | 335 PiB           | 1.48京           |
| OTel   | 95 PiB            | 109兆            |
| 合計   | 431 PiB           | 1.59京           |

[SysEx](/blog/scaling-observability-beyond-100pb-wide-events-replacing-otel)(ClickHouseのシステムテーブルのエクスポートを担当)がデータ量の78%、行数の93%を占めており、これは前回の記事で説明したアーキテクチャ上の賭けが正しかったことを明確に裏付けています。OpenTelemetryは引き続き、infoレベル以上のstdout/stderrの収集を担当し、SysExがシステムテーブルをスクレイピングできないクラッシュループ状態のサービスに対するセーフティネットとして機能しています。

431 PiB全体はディスク上では**27 PiB**に圧縮され、およそ16倍の圧縮率となります。これはLogHouseの初期から一貫している値です。ほとんどのテーブルは**180日間**保持され、多くのSysExテーブルは最大**365日間**保持されるため、LogHouseユーザーは調査や履歴分析のために長い時間軸を利用できます。

### 成長を牽引しているもの

前回の記事以降、私たちは新しいデータソースを追加してきました。OTelのトレース、Kubernetesのオブジェクト履歴、そしてSysExによる追加のシステムテーブルなどです。しかし、成長の主な要因はこれらのどれよりもシンプルで、単にClickHouse Cloudが成長し続けているということです。顧客が増えればクラスターが増え、クラスターが増えればログとイベント、クエリ履歴、システムテレメトリが増えます。データ量は運用中のクラスター数にほぼ比例してスケールします。

### それを支えるインフラ

LogHouseは現在、3つのクラウドプロバイダーにまたがる**33のジオシャード(合計36セル)**の上で稼働しており、各セルは個別のClickHouse Cloudインスタンスです。ほとんどのジオシャードは単一のセルで構成されていますが、最も混雑するリージョンには複数のセルがあります。

![geo_sharding.png](https://clickhouse.com/uploads/geo_sharding_34d4e97f39.png)

ピーク時、システムは以下を取り込んでいます:

- 未圧縮データ**80 GiB/秒**
- **1秒あたり1.9億行**
- **1秒あたり約5,800件のINSERT**

![loghouse_sizes.png](https://clickhouse.com/uploads/loghouse_sizes_2810b815db.png)

最も混雑する単一セルは、ピーク時に**21 GiB/秒、1秒あたり6,100万行、1秒あたり620件のINSERT**を処理します。

![cell_traffic_loghouse.png](https://clickhouse.com/uploads/cell_traffic_loghouse_4e4bf4c286.png)

### なぜセルを作ったのか

第一の理由は障害の影響範囲(ブラストレディウス)です。セル(ClickHouseクラスター)で何か問題が起きたとき、影響を受けるのはそこを流れるログだけです。リージョン内の他の部分は通常通り書き込みと読み取りを続けられます。1リージョンに1セルだけだと、運用上の問題が起きるたびにリージョン全体の可視性が失われることになります。

第二の理由はデータプレーンとの整合性です。ClickHouse Cloud自体も内部的には既にセル単位で構成されており、私たちのログインフラもその構造にミラーリングできます。これにより、1つのKubernetesクラスターにリージョンのログトラフィックすべてを負担させるのではなく、各LogHouseセルを予測可能で均等に分散した負荷を持つKubernetesクラスターに配置できます。

---

## 今すぐ始めましょう

ClickHouse をお使いのオブザーバビリティデータで試してみませんか？ClickHouse Cloud なら数分で始められ、$300 分の無料クレジットを受け取れます。

[Sign up](https://console.clickhouse.cloud/signUp?intent=o11y&loc=blog-cta-1378-sign-up&utm_blogctaid=1378)

---

## セル単位で分離された書き込み

1つのリージョンに1つのセルでは足りない場合、そのリージョン内にセルを追加し、ソースとなるKubernetesクラスターをセカンダリのシャーディングキーとして利用します。クライアント側で少し工夫すれば、さらに多くのセルを作ることも可能です。その結果、36個の独立したClickHouseクラスターが並列に書き込みを行い、経路上のどこにも競合ポイントが存在しない構成になります。

### Async Insertsによる小さな書き込み問題の解消

常にローカルでセルを作成することでスケーリングは解決します。しかしこれは、ClickHouseユーザーが必ず一度は遭遇する障害モードに真正面からぶつかることになります。私たちは何百ものライター(SysExスクレイパー、OTelコレクター、データプレーンサービス)を抱えており、そのほとんどが小さなチャンクを継続的に送信し続けています。これを素朴にClickHouseに向けると、各同期INSERTがそれぞれ独自のデータパートを作り、毎秒何千もの微小パートが生まれます。新しいパートができるたびに、ClickHouse Keeperでのメタデータ更新、データをフラッシュするためのネットワークトラフィック、そして小さなパートをより大きなものに統合するためのマージ負荷が発生します。マージスケジューラは追いつかなくなり、最終的にClickHouseは `TOO_MANY_PARTS` エラーで書き込みを拒否します。

公式ガイダンスでは、少なくとも10,000〜100,000行にバッチ化し、1テーブルあたり秒間1回程度以下に抑えることが推奨されています。しかし、何百ものプロデューサーにそれほど大きなバッチをローカルでバッファリングさせるのは非現実的です。データは時間的な鮮度が重要で、コレクターがクラッシュすれば保持していたものが失われてしまうためです。

Async Insertsはこの問題を直接的に解決します。小さなINSERTのたびにディスク上に新しいデータパートを作るのではなく、ClickHouseは受信した行をサーバー側のバッファに蓄積します。バッファが設定されたしきい値(時間または データサイズの上限)に達すると、蓄積された行がまとめて1つの適切なサイズのパートとしてフラッシュされます。

以下は、最も負荷の高いセルにおけるピーク時間帯の、私たちの最も忙しい2つのテーブルの状況です。

|                       |       `otel_logs`      |                  `text_log`                 |
|:---------------------:|:--------------------:|:-----------------------------------------:|
| ライター                | OTelコレクター      | SysExスクレイパー                            |
| クライアントINSERTサイズ    | 約8 MiB、ばらつき小 | 非常に大きなばらつき — p50で23 MiB、p99で696 MiB |
| クライアントINSERT数/時間 | 290,284              | 74,373                                    |
| 作成パート数/時間  | 12,082               | 18,197                                    |
| バッチ比率        | 約24×                 | 約4×                                       |
| 結果として得られるパートサイズ   | 195 MiB / 178K行  | 311 MiB / 911K行                     |


`otel_logs` はAsync Insertsが本来想定した典型的なケースです。数百のOTelコレクターから約8 MiBの書き込みが安定的に流れ込みます。そのうちおよそ24件が1つのパートにまとめられ、パート作成レートを1桁以上圧縮します。

`text_log` の形はまったく異なります。SysExスクレイパーは送信前にすでに積極的にバッチングを行っており、リクエストあたり23 MiBから700 MiB近くまで変動します。私たちは意図的にクライアント側のバッチングをこれ以上進めないようにしています。スクレイパー側でバッファを大きくすると、大きなメモリ負荷と決して小さくないインフラ改修が必要になるためです。そのためバッチ比率は控えめな4×にとどまります。しかしこの規模では4×でも大きな効果があります。単一セル上の単一テーブルで、1時間あたり74,373件対18,197件という差は、Keeper上のパート作成エントリが1時間で約56,000件減り、マージスケジューラの処理項目も56,000件減ることを意味します。

ライターの形はまったく異なりますが、結果は同じです。パートは178K〜911K行で195〜311 MiBのレンジに収まります。

### 適切なパーティションキーの選び方

[パーティショニングのベストプラクティス](/docs/best-practices/choosing-a-partitioning-key)ガイドで説明されているように、ClickHouseにおけるパーティショニングは主にデータ管理の技法です。ディスク上のデータ配置、TTLポリシーによる古いデータの削除、そしてパートのマージ挙動を制御します。ClickHouseは同じパーティション内のパートのみをマージし、パーティションをまたいでマージすることは決してありません。重要な制約はカーディナリティで、ガイドはパート数を管理可能に保つために、パーティション値のバリエーションを100〜1,000未満に抑えることを推奨しています。

![partitioning_loghouse.png](https://clickhouse.com/uploads/partitioning_loghouse_19bcfa6c4a.png)

_パーティショニング戦略の例。詳細な説明については[ドキュメント](https://clickhouse.com/docs/partitions)を参照してください。_

私たちのSysExテーブルのほとんどは月単位でパーティション分割されています。180日または365日のTTLでは、常時6〜12個のアクティブなパーティションが存在することになり、推奨範囲内に十分収まりますし、リテンションポリシーとも綺麗に整合します。

`text_log` のような最大級のテーブルでは、月単位のパーティションが問題になり、原因を正確に突き止めるまでには時間がかかりました。マージが遅すぎるわけではなかったのです。最大級のテーブルでは、1ヶ月分の中で最大マージパートサイズ(150GB)に達したパートが大量に積み上がり、ClickHouseは[それ以上のマージを停止](https://clickhouse.com/docs/merges)します。このサイズは設定で引き上げることもできますが、パートを大きくすると読み取り性能が悪化するため、マージで抜け出そうとするのは正解ではありません。パート数は月の中で増え続け、too-many-partsの上限へ着実に近づいていきました。

これを解決するため、月単位から日単位のパーティショニングへ切り替えました。180日のTTLで約180個のアクティブパーティションになり、これでもカーディナリティの推奨範囲内に十分収まります。各パーティションが受け取るINSERTは少なくなり、パートは小さく管理しやすいまま維持でき、マージも追いつきます。TTLによる削除も、ClickHouseが1日分のパーティションを1回のメタデータ操作で削除できるため、より綺麗に行えます。トレードオフとして、長期間にわたるクエリはより多くのパーティションに触れることになりますが、実際にはLogHouseユーザーは狭い時間窓でフィルタリングすることがほとんどなので、これはめったに問題になりません。

### OTelデータの信頼性のある配信

[前回の投稿](https://clickhouse.com/blog/scaling-observability-beyond-100pb-wide-events-replacing-otel)では、SysExのプル型モデルと、それが自然にバックフィルを扱いデータロスを回避する仕組みを説明しました。私たちのOTelパイプライン(こちらも依然として95 PiB、109兆行を扱っています)にも、独自の信頼性の物語が必要でした。

Async Insertsとパーティショニングは大きな助けになりましたが、より難しい問題には無力です。ターゲットとなるClickHouseセル自体が利用できない場合はどうなるのか。コレクターのインメモリバッファには限界があり、こちらの準備が整っているかどうかに関係なくログは流れ続けます。

INSERTが失敗したときのOpenTelemetryのデフォルト動作は、バッチをメモリにバッファしてリトライすることです。私たちのパイプラインもこの動作でスタートしました。コレクターは失敗したバッチをメモリに保持し、Horizontal Pod Autoscaler(HPA)がメモリ圧に応じてレプリカを追加していました。問題は容量です。インメモリバッファはPodのメモリで制限され、HPAのスケールアップには時間がかかります。LogHouseの障害が十分に長引けば、どちらも追いつけなくなります。

そこで、その下にS3を重ねました。コレクターはデフォルトでは失敗したバッチをメモリにバッファしますが、メモリが逼迫すると、バッファはS3バケット(永続的で事実上無制限のバックストップ)に退避されます。バックグラウンドプロセスがそのバケットをドレインし、セルが再び正常になり次第、バッチのLogHouseへのリトライを行います。バッチが安全にINSERTされると、S3から削除されます。

LogHouseの障害は、データロスではなくインジェスト遅延として現れるようになりました。**インメモリバッファが短い中断を吸収し、S3がロングテールを受け止め**、ClickHouseは復旧後に自分のペースでバックログを処理します。書き込みレイテンシの若干の増加と引き換えに、はるかに落ち着いた障害モードを手に入れました。パイプラインが正常でないときも、コレクターはクラッシュせず、データもドロップせず、復旧に手動介入も必要としません。

Async Insertsも、日単位パーティショニングも、S3フォールバックも、単一の英雄的な修正ではありません。継続的に調整し続けているカーブ上の点にすぎません。書き込みパスは常にチューニングを続けています。ここではAsync Insertのしきい値、あちらではパーティションキー、役割を終えたインデックスの削除、といった具合に。

## クロスクラウド・クロスリージョンでの低レイテンシ分散クエリ

これらすべての前は、LogHouseを読むにはデータがどこにあるかを知る必要がありました。各リージョンは独自のFQDNを持つ独立したClickHouseインスタンスであり、LogHouseユーザーは関心のあるリージョンに `clickhouse-client` またはリージョンごとのGrafanaデータソースとして直接接続していました。十数リージョンでもすでに管理・利用が煩雑でしたが、状況は悪化する一方でした。忙しいリージョンを複数のセルに分割した瞬間、ユーザーはデータがどのリージョンにあるかだけでなく、その中のどのセルにあるかまで知らなければならなくなります。これは私たちが越えたくない一線でした。

そこで、任意のリージョンに2つ目のセルを追加する前に[Distributedテーブル](https://clickhouse.com/docs/engines/table-engines/special/distributed)を導入しました。これはセル作成の結果ではなく、前提条件でした。目指したのは、トポロジーを完全に隠す単一のエンドポイントです。ユーザーは1つのテーブルにクエリを投げ、ルーティングはその下で行われます。

これらのテーブルはClickStackの利用体験も大幅に向上させました。以前から、内部の可観測性ワークフローをもっとClickStackのUIに移したいと考えていました。以前のGrafanaはリージョン対応のルーティングに依存しており、ユーザーはデータをクエリする前に特定のリージョンのデータソースを選ぶ必要がありました。汎用的な可観測性インターフェースでこのモデルとUIロジックを再現するのは現実的ではありません。Distributedテーブルはこの複雑さを取り除き、単一の論理データソースを通じてリージョンをまたいでデータをクエリできるようにしました。

### 3レベルのテーブル階層

これを実現するには、単一のエンドポイント以上のものが必要でした。ユーザーからセルとリージョンを隠しつつ、効率的なルーティングを保ち、下層のトポロジーが時とともに進化することを許す階層構造が必要でした。

私たちはこれを、すべてのセルに同一に展開された同じスキーマを使う3レベルのテーブル構造で解決しました。

![three_level_table_hierarchy.png](https://clickhouse.com/uploads/three_level_table_hierarchy_d0f8970595.png)

_Distributedテーブルは、物理テーブルとセルの上に位置する仮想的なフェデレーションレイヤーです。LogHouseでは、セルとリージョンを透過的にまたぐリクエストルーティングを提供する、安定したクエリエンドポイントとして機能します。図では単一の論理レイヤーとして描かれていますが、Distributedテーブル自体はデータを保持せず、すべてのClickHouseノード上に作成する必要があります。_

一番下には、実際のデータをSharedMergeTreeテーブルとして持ちます。例えば `otel.generic_logs_0` です。

その上には、**リージョナルなDistributedテーブル**(`otel.generic_logs_region`)があり、`regional` クラスターを参照します。このクラスターにはローカルリージョン内のセルだけがKubernetes Service経由でリストされているため、トラフィックはKubernetesクラスターの外に出ず、IstioやNLBのオーバーヘッドもありません。

一番上には、グローバルなDistributedテーブル(`otel.generic_logs`)があり、`global` クラスターを参照します。このクラスターにはすべてのリージョンがIstioエンドポイント経由(ClickHouse Cloudの顧客も使用している同じロードバランサー)でリストされます。重要なのは、このクラスター設定における `shard_name` がリージョン名に設定されている点です。これはシャードプルーニングで重要になります。

```sql
-- レベル1: global — すべてのリージョンとクラウドにまたがる
otel.generic_logs
  Engine: Distributed('global', 'otel', 'generic_logs_region', sharding_key)

-- レベル2: regional — 1つのリージョン内のセルにまたがる
otel.generic_logs_region
  Engine: Distributed('regional', 'otel', 'generic_logs_0')

-- レベル3: local — 実際のデータ
otel.generic_logs_0
  Engine: SharedMergeTree
```

この階層構造により、新しいリージョンの追加（`global` クラスタ設定にエントリを追加する）や、ホットリージョンへのセルの追加（該当リージョンの `regional` クラスタ設定にエントリを追加する）が、LogHouse ユーザーがクエリを実行するテーブルを変更することなく容易に行えます。Distributed テーブルは安定した API 契約として機能します。ユーザーは常にグローバルテーブルにクエリを発行し、ルーティングは透過的に行われます。

各セルは独自の `remote_servers` 設定を持ちます。ローカルリージョンおよびセルは常に `localhost` として表現されるため、リージョンから出る必要のないクエリはネットワークに触れることがありません。

トポロジーを隠蔽すること以外にも、Distributed エンジンはこの規模で重要となる2つの利点をもたらします。

第一に、すべてのセルへの永続的な接続プールを維持することで、クエリごとの DNS 解決、TLS ハンドシェイク、接続確立を排除できます。36 セルにまたがってクエリを実行する際、これは大きな差になります。

第二に、リトライロジックが組み込まれています。一時的なネットワーク障害や古くなったプール接続によりリモートセルへの接続が失敗した場合、エンジンは新しい接続でリトライします。これはクラウド境界を越えて一時的な障害が定期的に発生する環境では不可欠です。

第三の要素であるシャーディングキーのサポートは、SELECT のレイテンシを実際に低く抑える鍵となるものであり、次のセクションで扱います。

### `optimize_skip_unused_shards` によるクエリの高速化

LogHouse のクエリの多くは、「過去1時間のリージョン Y におけるネームスペース X のログを表示せよ」といった形になります。時刻とネームスペースはパーティショニング（`EventDate`）とプライマリキー（`Namespace`、`EventTime`）で処理されます。ではリージョンについてはどうでしょうか？

単一のグローバル Distributed テーブルは、正しく動作させるのは簡単ですが、遅くするのも簡単です。何もしなければ、すべてのクエリを 36 セルすべてに扇状に展開し、データが単一のリージョン内にすべて存在する場合であっても、最も遅いセルの応答を待つことになります。この末尾のレイテンシこそが問題の本質です。クロスリージョンのホップが発生するたびに、古い接続や遅いセルが p99 を悪化させる可能性がある地点が増えていきます。

解決策は、ClickHouse の `optimize_skip_unused_shards` 設定とシャーディングキーの組み合わせです。Distributed テーブルが特定のリージョンのデータを保持しているセルを知っていれば、それ以外をすべてスキップできます。

私たちは、このリージョン → シャードのマッピングを手作業で保守したくはありませんでした。ClickHouse がすでに把握しているクラスタトポロジーから直接得たかったのです。`system.clusters` をソースとしたディクショナリは、まさにこれを実現します。`global` クラスタのレイアウトを、シャーディングキー関数として呼び出せる形に持ち上げてくれるのです。ディクショナリの用途としては最も自明とは言えませんが、シンプルであり、また私たちはリージョンやセルを定期的に追加しているため、マッピングが古くならないように短めのライフタイムでリフレッシュさせています。

```sql
CREATE DICTIONARY default.regionToShard
(
    `region` String,
    `shardID` Int64
)
PRIMARY KEY region
SOURCE(CLICKHOUSE(
    QUERY 'SELECT shard_name AS region, shard_num - 1 AS shardID
           FROM system.clusters
           WHERE name = ''global'''
))
LIFETIME(MIN 0 MAX 300)
LAYOUT(COMPLEX_KEY_HASHED())
```

グローバルなDistributedテーブルは、シャーディングキーとして `dictGet('default.regionToShard', 'shardID', Region)` を使用しています。クエリに `WHERE Region = 'us-east-1'` が含まれ、`optimize_skip_unused_shards` が有効になっている場合、ClickHouseはシャーディングキー式を評価し、どのセルが一致するかを判定して、そのセルにのみクエリを送信します。以前は36個のセルにアクセスしていたクエリが、今では2個で済むようになります。

![query_routing_loghouse.png](https://clickhouse.com/uploads/query_routing_loghouse_1d477af092.png)

同じ仕組みをリージョン内のセルに対しても適用してさらに絞り込むことは可能ですが、リージョン内のセルは物理的に近接しており、その効果は複雑さに見合わないため、実装していません。実質的にすべての価値は、リージョンレベルでの絞り込みにあります。

### 実際のレイテンシ

具体的に見てみましょう。典型的なクエリパターンとして、直近1時間におけるリージョンごとの `otel-collector` エラー数のカウントを考えます:

```sql
SELECT
    Region,
    count() AS errors
FROM otel.generic_logs
WHERE (EventDate = today()) AND (EventTime >= (now() - toIntervalHour(1))) AND (SeverityText = 'ERROR') AND (Namespace = 'o11y') AND (PodName LIKE 'loghouse-agent%')
GROUP BY ALL
ORDER BY ALL ASC
SETTINGS force_optimize_skip_unused_shards = 0


    ┌─Region───────────────┬─errors─┐
 1. │ af-south-1           │      3 │
 2. │ ap-east-1            │      2 │
 3. │ ap-northeast-1       │     37 │
 4. │ ap-northeast-2       │      2 │
 5. │ ap-south-1           │     52 │
 6. │ ap-southeast-1       │     42 │
 7. │ ap-southeast-2       │     25 │
 8. │ asia-northeast1      │     14 │
 9. │ asia-southeast1      │     32 │
10. │ australia-southeast1 │      2 │
11. │ australiaeast        │      1 │
12. │ ca-central-1         │      2 │
13. │ eastus2              │      1 │
14. │ eu-central-1         │    169 │
15. │ eu-north-1           │      6 │
16. │ eu-west-1            │    218 │
17. │ eu-west-2            │     53 │
18. │ europe-west2         │      4 │
19. │ europe-west3         │      1 │
20. │ europe-west4         │    194 │
21. │ germanywestcentral   │      9 │
22. │ il-central-1         │      2 │
23. │ sa-east-1            │      1 │
24. │ us-central1          │     79 │
25. │ us-east-1            │    535 │
26. │ us-east-2            │    216 │
27. │ us-east1             │     27 │
28. │ us-west-2            │    260 │
29. │ westus3              │      2 │
    └──────────────────────┴────────┘

29 rows in set. Elapsed: 1.591 sec. Processed 33.39 million rows, 248.34 MB (20.99 million rows/s., 156.13 MB/s.)
Peak memory usage: 299.57 MiB.
```

**`Region` フィルタなし**の場合、これは全 36 セルにファンアウトします。クエリはエラーのある 29 リージョンの行を返し、3,300 万行を処理して約 **1.6 秒**で完了します。これは通常のフリート全体のクエリよりも遅く、`text_log` がその理由を明確に示しています。コーディネーターは 5 つの古いプール済み TCP 接続に当たっており、そのうち 1 つが `asia-southeast1` 宛でした。シンガポールへの完全な TCP+TLS ハンドシェイクにおよそ 700 ms かかり、その後シンガポールのセルが実行と結果のストリーミングにさらに約 600 ms を要しました。ローカルでのプランニング、主キーによるプルーニング、集約の合計はわずか約 75 ms — 残りはまさに私たちが回避したかったテールレイテンシの問題です。

**ローカルリージョンにフィルタした場合**(クエリを発行した `Region = 'us-east-2'`)、コンポーネント別にエラーを収集する同様のクエリは、270 万行を処理して **270 ms** で完了します。

```sql
SELECT
    LA['component'] AS component,
    count() AS errors
FROM otel.generic_logs
WHERE (EventDate = today()) AND (EventTime >= (now() - toIntervalHour(1))) AND (SeverityText = 'ERROR') AND (Namespace = 'o11y') AND (PodName LIKE 'loghouse-agent%') AND (Region IN ('us-east-1'))
GROUP BY ALL
ORDER BY ALL ASC

 ┌─component────┬─errors─┐
 │              │      3 │
 │ fileconsumer │    612 │
 └──────────────┴────────┘

2 rows in set. Elapsed: 0.831 sec. Processed 9.55 million rows, 240.12 MB (11.48 million rows/s., 288.83 MB/s.)
Peak memory usage: 386.84 MiB.
```

クロスクラウドかつ大陸をまたぐリージョン(`Region = 'europe-west4'`)に絞り込むと、約291msとなり、実は同一クラウド内のus-east-1へのクエリよりも高速です。大陸間のネットワークホップは確かにコストを追加しますが、その影響は小さめです。支配的な要因は各セルがスキャンするデータ量であり、`europe-west4`は単純に行数が少なかった(281万行)というだけの話です。

```sql
SELECT
    LA['component'] AS component,
    count() AS errors
FROM otel.generic_logs
WHERE (EventDate = today()) AND (EventTime >= (now() - toIntervalHour(1))) AND (SeverityText = 'ERROR') AND (Namespace = 'o11y') AND (PodName LIKE 'loghouse-agent%') AND (Region IN ('europe-west4'))
GROUP BY ALL
ORDER BY ALL ASC

 ┌─component────┬─errors─┐
 │              │     41 │
 │ fileconsumer │    136 │
 └──────────────┴────────┘

2 rows in set. Elapsed: 0.291 sec. Processed 2.81 million rows, 60.71 MB (9.65 million rows/s., 208.60 MB/s.)
Peak memory usage: 275.53 MiB.
```

**いくつかのリージョンに絞り込むと**（`Region IN ('europe-west4', 'us-east-1', 'us-east-2')`）、合計レイテンシは約900msに収まります。各セルは並列に実行されるため、全体の実行時間は合計ではなく、最も遅いセルによって決まります。

```sql
SELECT
    Region,
    LA['component'] AS component,
    count() AS errors
FROM otel.generic_logs
WHERE (EventDate = today()) AND (EventTime >= (now() - toIntervalHour(1))) AND (SeverityText = 'ERROR') AND (Namespace = 'o11y') AND (PodName LIKE 'loghouse-agent%') AND (Region IN ('europe-west4', 'us-east-1', 'us-east-2'))
GROUP BY ALL
ORDER BY errors DESC

 ┌─Region───────┬─component────┬─errors─┐
 │ us-east-1    │ fileconsumer │    612 │
 │ us-east-2    │ fileconsumer │    193 │
 │ europe-west4 │ fileconsumer │    132 │
 │ europe-west4 │              │     41 │
 │ us-east-1    │              │      3 │
 │ us-east-2    │              │      1 │
 └──────────────┴──────────────┴────────┘

6 rows in set. Elapsed: 0.909 sec. Processed 15.23 million rows, 388.90 MB (16.75 million rows/s., 427.70 MB/s.)
Peak memory usage: 385.83 MiB.
```

LogHouse ユーザーがクエリに `Region` フィルターを含めている限り、レイテンシは対象データが世界のどこにあるかではなく、マッチしたセルがスキャンすべきデータ量に支配され、クエリは 1 秒未満で返ってきます。この 1 つのフィルターとシャーディングキーの仕組みが組み合わさることで、グローバルに分散された Distributed テーブルがまるでローカルテーブルのように感じられるのです。

### ローカルテーブルが適切な答えとなる場合

特筆すべきパターンが 1 つあります。ログの読み取りには Distributed テーブルがデフォルトとして適していますが、クエリに JOIN やサブクエリが含まれる場合には注意が必要です。

ほとんどのログ調査では、すでに単一セル内でローカルに存在するデータのみを JOIN や相関させれば済みます。次のような単純なクエリを考えてみましょう。

```sql
SELECT col1, col2
FROM otel.generic_logs
WHERE Region = 'us-east-1'
  AND col3 IN (SELECT col4 FROM otel.generic_logs_0 WHERE ...)
```

外側のクエリは引き続き `optimize_skip_unused_shards` の恩恵を受け、該当するセル上でのみ実行されます。サブクエリは同じセル上で `generic_logs_0` に対してローカルに実行され、分散解決は行われません。

### フリート全体のクエリ:本当に全体が必要な場合

LogHouse のユーザーには、リージョンをまたいだ分析、フリート全体でのリリース検証、あるいは特定のリージョンに閉じないパターンの調査など、真にフリート全体の可視性を必要とする場面があります。こうしたクエリはすべてのセルで並列に実行され、上記の数値が示す通り、大陸をまたいで数十ギガバイトを扱う場合でも良好なパフォーマンスを発揮します。

とはいえ、私たちが実際に実行するフリート全体のクエリは、常に単純なカウントというわけではありません。以下は、36 のセル全体で 50 GB のログデータを処理する実例です:

```sql
SELECT
    Timestamp,
    Body,
    SeverityText,
    ServiceName,
    PodName,
    ContainerName,
    Region,
    Cell,
    Namespace,
    LogAttributes AS LA,
    ResourceAttributes AS RA
FROM otel.generic_logs
WHERE ((EventTime >= (now() - toIntervalMinute(5))) AND (EventTime <= now())) AND (EventDate = today()) AND (Namespace = 'o11y')
ORDER BY Timestamp DESC
LIMIT 500

500 rows in set. Elapsed: 1.501 sec. Processed 646.35 million rows, 49.64 GB (430.68 million rows/s., 33.08 GB/s.)
Peak memory usage: 1.06 GiB.
```

3つのクラウドをまたいで50GBを2秒未満でスキャンできます。フリート全体の分析は、最後の手段としてだけでなく、探索的なツールとして実用的なレベルにあります。

注意点が2つあります。フリート全体のレイテンシは最も遅いセルによって決まるため、ネットワークの一瞬の不調が全体の時間を引っ張ってしまいます。アドホックな分析には問題ありませんが、アラート対象にするようなものには向きません。また、`skip_unavailable_shards` はデフォルトでオフのままです ― セルが利用不可のときに不完全な結果を黙って返すよりも、はっきりと失敗させたいからです。ユースケース上必要な場合には、手動で有効化しています。

## 次のステップ

LogHouseに「完成」はありません。2年前は1つのクラウドに19 PiB、リージョンごとに1つのClickHouseインスタンスを持ち、そのリージョンに直接接続してクエリしていました。現在は3つのクラウドをまたぐ400 PiB超の規模になり、その下にあるすべてのセルを隠す単一のDistributedテーブルの背後で動いています。各段階で何かしらの再構築を余儀なくされました。まずはインジェスションパイプライン、次に収集モデルとスキーマ、そして本記事で述べたリード・ライト層です。

現在取り組んでいるいくつかの課題を紹介します:

**メモリ消費量の削減。** テーブルが大きくなるにつれて、主キーインデックスとgranuleメタデータのメモリフットプリントが実質的な制約となってきました。最近、アダプティブなgranularityと主キーキャッシュを採用し、メモリ使用量を再び抑え込みました。これらは十分に大きなトピックなので、おそらく次回の投稿を丸ごとこの話題に充てることになるでしょう。

**非同期挿入の耐久性の強化。** 現在は `wait_for_async_insert=0` で運用しており、クライアントはfire-and-forget方式で書き込みレイテンシを低く保ち、クライアント側のバッファも小さく抑えています。その代償として、バッチがバッファされた時点でクライアントは処理を先に進めてしまうため、フラッシュが完了する前に何かで中断されると再試行する仕組みがありません。承認付きの非同期挿入に切り替え、クライアント側で発生するバックプレッシャを扱えるようにすることが次の課題です。

**顧客に影響を与えないSysExの収集。** 現在まだ収集できていないシステムテーブルのデータがあります。稼働中のClickHouseインスタンスから引き出すと顧客ワークロードに影響しかねないためです。代わりに、そのデータをオブジェクトストレージから直接読み込み、ライブインスタンスを完全にバイパスする計画です。これによりテレメトリの残る空白を、顧客に一切影響を与えずに埋められるはずです。

**より多くのOTelトレースとメトリクス。** SysExのギャップを埋める以外にも、LogHouseに保存するテレメトリの幅を広げたいと考えています。特にOTelトレースとOTelメトリクスです。インフラは準備できています。次のステップは、これらの新しいデータタイプを大規模に追加していく中で、インジェスション、リテンション、クエリパターンがすべて成り立つことを確認することです。