TL;DR
列指向ストレージは、高速かつ柔軟なクエリ性能を維持しながら、ログを効率的に保存する手段を提供します。生のログを構造化されたカラムに変換し、最適化されたデータ型を使用し、類似した値をまとめて並べ替えることで、170 倍以上の圧縮率を達成できます。
大規模なログの保存は困難を伴います。
ログは通常、効率的に圧縮されない非構造化テキストです。それにもかかわらず、ログにはアプリケーションやシステムの挙動に関する重要な履歴情報が含まれており、障害発生時のデバッグに極めて有用な、失いたくない情報です。
オブザーバビリティシステムにおいて、ログはトレースやメトリクスと並んで扱われます。ログとは異なり、トレースやメトリクスは最初から構造化されており値の重複が多いため、列指向フォーマットで自然に圧縮されます。Timestamp、ServiceName、Latency などのフィールドは列指向ストレージによく適合し、効果的に圧縮されます。
しかし、ログにも同様の構造を持たせることができたらどうでしょうか。各メッセージの可変部分を特定し、個別のカラムに抽出し、最も効率的なデータ型を使用し、類似したデータをディスク上で近接して保存することで、ログでも同様の高効率な圧縮を実現できます。
本記事では、このアプローチを試して生のログを構造化データへと変換し、170 倍の圧縮率の達成を目指します。後続のブログ記事では、ログのクラスタリング技術を活用してこれらのアイデアをあらゆるタイプのログへと拡張し、圧縮率を自動的に向上させる方法を探ります。
圧縮が重要である理由
圧縮は単にストレージを削減するだけでなく、パフォーマンスやリソースの使用状況にも好影響をもたらします。
I/Oの削減とクエリの高速化
データサイズが小さくなれば、ディスクからの読み取りにかかる時間が短縮されます。列指向データベースでは、クエリが大量のデータをスキャンすることが多いため、圧縮ブロックを読み取ることで I/O が大幅に削減されます。これにより、ディスク帯域幅やネットワークなどのリソースへの負荷が軽減され、クエリ実行の高速化につながります。
ストレージ要件の削減
保存が必要なデータ量を削減することで、ストレージコストを抑えられます。容量が比較的安価な S3 などのオブジェクトストレージを使用している場合でも、高速アクセスのためにローカルの NVMe にキャッシュできるデータ量が増えるため、圧縮は依然として重要です。
キャッシュ効率の向上
圧縮されたデータはメモリやキャッシュ層に収まりやすくなります。これによりキャッシュヒット率が向上し、ディスクからデータを取得する必要性が低下します。
Nginx アクセスログを用いた検証
Nginx アクセスログは、Nginx サーバーが処理したすべてのリクエストを記録します。広く知られているデータセットであるため、読者の皆様もイメージしやすく、実験を再現しやすい題材です。
各ログには通常、クライアントの IP アドレス、タイムスタンプ、HTTP メソッド、ステータスコード、ユーザーエージェント、レスポンスタイムなどの情報が含まれます。以下は nginx ログのエントリ例です。
185.161.113.50 - - [2019-02-04 23:40:49] "GET /filter/p62,b113?page=0 HTTP/1.1" 200 33948 "https://www.zanbil.ir/" "Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/71.0.3578.98 Safari/537.36"これらのログは、今回の実験に最適な題材です。
-
一貫した構造: Nginx ログフォーマットによって定義された固定パターンに従っています。値(タイムスタンプ、IP、URL、レスポンスタイム)は変化しますが、全体の構造は安定しています。
-
多様なデータ型の混在: nginx アクセスログには、IP アドレス、数値、文字列、日付が含まれています。最適な保存方法を検討する上で、さまざまなデータ型が程よく混在しています。
-
大容量: 本番環境におけるアクセスログは急速に蓄積され、ギガバイト単位やテラバイト単位のデータが生成されることも珍しくありません。この規模は、圧縮効率やストレージコストに直結します。
nginx ログは通常プレーンテキストファイルに保存されますが、その形式は予測可能です。これは一般的なアプリケーションログの代表例とは言えないかもしれません。その点は認識しつつも、楽観的な上限値を設定するためにこのデータセットを選択しています。より構造が曖昧なログで達成可能な圧縮フォーマットについては、後続の記事で取り上げます。
Get started with ClickStack
Spin up the world’s fastest and most scalable open source observability stack, in seconds.
Try nowベースラインの取得
ベンチマークを目的とする実験では、まずベースラインを確立する必要があります。ここでのベースラインは、ログデータセットを非圧縮でローカルディスクに保存した際に消費するストレージ容量です。
実験には、6,600 万件のエントリを含む nginx アクセスログのデータセットを使用します。後のコマンドでファイルを公開していますので、このテストはどなたでも再現可能です。このファイルを非圧縮でディスク上に配置すると、20 GB のディスク容量を使用します。
$ wc -l nginx-66.log
66747290 nginx-66.log
$ du -h nginx-66.log
20G nginx-66.logClickHouse はディスクへのデータ保存時に圧縮を行い、さまざまな圧縮アルゴリズムをサポートしているため、実験との比較対象としていくつかのアルゴリズムの数値を把握しておくことも重要です。
性能を比較するため、生のログファイルを GZIP、ZSTD(3)、LZ4 で圧縮しました。表から分かるように、ディスク上の圧縮率はすでに非常に優れており、ZSTD(3) だけでも 38 倍の圧縮率を達成しています。
| 圧縮方式 | ディスクサイズ | 圧縮率 |
|---|---|---|
| なし | 20 GB | 1 |
| LZ4 | 1 GB | 20x |
| GZIP | 641 MB | 31x |
| ZSTD(3) | 522 MB | 38x |
圧縮率は、圧縮後のサイズを元の非圧縮ディスクサイズで割って算出しています。
比較用のベースラインが得られたので、ClickHouse にデータを取り込んでアイデアを適用していきましょう。
ClickHouse へのログの取り込み
まず、ログを挿入するためのテーブルを作成します。単純な String フィールドを持ち、特定の並び順を持たない nginx_raw という名前にします。
CREATE TABLE nginx_raw
(
`Body` String
) ORDER BY ()テーブルを作成したら、ログファイルを取り込めます。以下の例では、S3 から直接挿入しています。また、データセット全体が正しく取り込まれたことを確認します。
INSERT INTO nginx_raw SELECT line As Body FROM s3('https://datasets-documentation.s3.eu-west-3.amazonaws.com/http_logs/nginx-66.log.gz', 'LineAsString')
SELECT count() FROM nginx_raw
┌──count()─┐
│ 66747290 │ -- 66.75 million
└──────────┘上記の挿入コマンドを使用すれば、ご自身の ClickHouse インスタンスにデータを読み込めます。ロード時間はローカルの ClickHouse リソースと接続環境によって異なります(ファイルサイズは約 640 MB です)。
このテーブルがどれだけのディスク容量を使用しているかを確認してみましょう。各テーブルパートの詳細(ディスク上の非圧縮サイズや圧縮サイズなど)を保持している system.parts テーブルにクエリを実行して確認できます。
SELECT
`table`,
formatReadableSize(SUM(data_uncompressed_bytes)) AS uncompressed_size,
formatReadableSize(SUM(data_compressed_bytes)) AS compressed_size
FROM system.parts
WHERE (database = 'logs_blog') AND (`table` = 'nginx_raw') AND active
GROUP BY `table`
ORDER BY `table` ASC
┌─table─────┬─uncompressed_size─┬─compressed_size─┐
│ nginx_raw │ 20.19 GiB │ 575.62 MiB │
└───────────┴───────────────────┴─────────────────┘予想通り、非圧縮サイズはローカルディスク上のサイズと一致しています。内部的に、ClickHouse はデフォルトで ZSTD(1) 圧縮を使用しているため、圧縮サイズも同等の値となっています。
Nginx ログの構造化ログへの変換(最大 56 倍)
170 倍の圧縮に向けた最初のステップは、プレーンなログを、それぞれの意味のある値(IP アドレス、リクエストメソッド、URL、ステータスコード、ユーザーエージェントなど)を独自のカラムに保存できる構造化ログへと変換することです。
文字列全体を 1 つのカラムとして保存する代わりに、以下のように個別のカラムにパースできます。
| カラム名 | データ型 | 値の例 |
|---|---|---|
| remote_addr | IPv4 | 185.161.113.50 |
| remote_user | String | user |
| time_local | DateTime | 2025-10-13 10:00:00 |
| request_type | String | GET |
| request_path | String | /index.html |
| status | String | HTTP/1.1 |
| size | UInt16 | 200 |
| referrer | String | - |
| user_agent | String | Mozilla/5.0 (...) |
Nginx ログの定義済みフォーマットにより、正規表現マッチング関数を用いたパースが容易に行えます。
nginx アクセスログ用に定義したスキーマを使用して、新しいテーブルを作成しましょう。
CREATE TABLE nginx_column_tuple
(
`remote_addr` String,
`remote_user` String,
`time_local` DateTime,
`request_type` String,
`request_path` String,
`request_protocol` String,
`status` UInt64,
`size` UInt64,
`referer` String,
`user_agent` String
)
ORDER BY ()次に、nginx_raw テーブルをソースとして使用し、正規表現を適用して各値を独自のカラムに抽出しながら、このテーブルにログデータセットを取り込みます。
INSERT INTO nginx_column_tuple
SELECT
m[1],
m[2],
parseDateTimeBestEffortOrNull(m[3]),
m[4],
m[5],
m[6],
toUInt64OrZero(m[7]),
toUInt64OrZero(m[8]),
if(length(trim(m[9])) = 0, '-', m[9]),
m[10]
FROM (
SELECT arrayElement(extractAllGroups(
toValidUTF8(Body),'^(\S+) - (\S+) \[([^\]]+)\] "([A-Z]+)?\s*(.*?)\s*(HTTP\S+)?" (\d{3}) (\d+) "([^"]*)" "([^"]*)"'
), 1) AS m
FROM nginx_raw
);このテーブルのサイズを改めて確認してみましょう。
SELECT
`table`,
formatReadableSize(sum(data_compressed_bytes)) AS compressed_size,
formatReadableSize(sum(data_uncompressed_bytes)) AS uncompressed_size
FROM system.columns
WHERE `table` = 'nginx_column_tuple'
GROUP BY `table`
FORMAT VERTICAL
Row 1:
──────
table: nginx_column_tuple
compressed_size: 359.52 MiB
uncompressed_size: 18.48 GiB非圧縮サイズが前回の実験よりも小さくなっていることにご注目ください。この削減は、列指向フォーマットの採用と、正規表現によるフィルタリングで不要な文字が除去されたことによるものです。
圧縮サイズは 359.52 MB となり、圧縮率は 56 倍に達しました。ログを構造化して主要な値を個別のカラムに保存するだけで、圧縮率は 35 倍から 56 倍へと向上します。ログは元の形式のまま保存されてはいませんが、重要な情報は一切失われていません。必要であればログを復元することも可能であり、その手順は後ほど本記事で扱います。
データ型の最適化(最大 92 倍)
列指向データベースでは、カラムのデータ型を慎重に選択することが重要です。データ型によって、その型で取り得る最大値を格納するためにディスク容量がどれだけ確保されるかが決まります。実際の値が小さくても、データベースはそのデータ型のフルサイズに基づいて領域を使用します。なお、その型に小さな値しか保存されない場合でも、データ自体は良好に圧縮されるはずです(連続する 0 が多いと圧縮に有利に働くため)。しかし、読み取り時にデータを展開する際には、依然として相当なメモリを無駄に消費してしまいます。
先ほど確認したように、nginx アクセスログには ClickHouse がサポートする特定のデータ型に合致するデータが含まれており、最初の実験でもそれらを一部使用しました。ここからは、使用する型をさらに最適化していきます。
カラムに辞書符号化を適用してディスク上のデータ保存方法を最適化する LowCardinality 型を活用できます。唯一の注意点は、この手法が効果を発揮するのは一定のスケールまでという点です。実際に検証してみるのが最善ですが、経験則として、カーディナリティ(ユニークな値の数)が 10 万未満のカラムであれば、このアプローチの恩恵を受けられます。
各カラムのカーディナリティを確認してみましょう。
SELECT * APPLY uniq
FROM nginx_column_tuple
FORMAT VERTICAL
Row 1:
──────
uniq(remote_addr): 258115
uniq(remote_user): 2
uniq(time_local): 2579628 -- 2.58 million
uniq(request_type): 5
uniq(request_path): 881124
uniq(request_protocol): 3
uniq(status): 15
uniq(size): 69712
uniq(referer): 103048
uniq(user_agent): 28344この結果から、String カラムである remote_user、request_type、request_protocol、user_agent が LowCardinality の適切な候補であることが分かります。
次に、ClickHouse がサポートする圧縮コーデックを活用して、圧縮率をさらに最適化できます。
LowCardinality と各種圧縮コーデックを組み合わせて、新たな実験を行ってみましょう。
CREATE TABLE nginx_column_tuple_optimized_types
(
`remote_addr` IPv4,
`remote_user` LowCardinality(String),
`time_local` DateTime CODEC(Delta(4), ZSTD(1)),
`request_type` LowCardinality(String),
`request_path` String CODEC(ZSTD(6)),
`request_protocol` LowCardinality(String),
`status` UInt16,
`size` UInt32,
`referer` String CODEC(ZSTD(6)),
`user_agent` LowCardinality(String)
)
ORDER BY ()データはすでにカラム形式で保存されているため、テーブル nginx_column_tuple から再取り込みを行うだけで済みます。
INSERT INTO nginx_column_tuple_optimized_types SELECT * FROM nginx_column_tuple;テーブルサイズを確認してみましょう。
SELECT
`table`,
formatReadableSize(sum(data_compressed_bytes)) AS compressed_size,
formatReadableSize(sum(data_uncompressed_bytes)) AS uncompressed_size
FROM system.columns
WHERE `table` = 'nginx_column_tuple_optimized_types'
GROUP BY `table`
FORMAT VERTICAL
Row 1:
──────
table: nginx_column_tuple_optimized_types
compressed_size: 218.42 MiB
uncompressed_size: 9.69 GiB圧縮サイズは 218 MB まで縮小し、圧縮率は 92 倍になりました。素晴らしい成果ですが、公約した 170 倍にはまだ届いていません。私たちにはまだ使える手段が残されています。それは、ディスク上でのデータの並べ替え順序です。
ディスク上のデータの並べ替え(178 倍を達成!)
大半の圧縮アルゴリズムは、同一の値や類似した値が隣り合って保存されている場合に効果を発揮します。ClickHouse では、主キー/ソートキーを指定することで、テーブルのカラムが書き込まれる順序を制御できます。したがって、可能な限り最高の圧縮率を目指す場合、圧縮に有利となるソートキーを意図的に選択できます。
ソートキーはユーザーのアクセスパターンも考慮する必要がある点にご注意ください。キーの先頭に近いカラムでのフィルタリングは、後半に現れる(または全く含まれない)カラムよりも高速になります。ここでは圧縮のみを目的に最適化しています。本番環境では、通常、クエリアクセスパターンと最適な圧縮のバランスを取る必要があります(ただし、一般に圧縮率が高まることでクエリパフォーマンスの向上にもつながります)。
より高い圧縮率を達成するには、テーブルのすべてのカラムを最も効率的に圧縮できるソートキーを選択する必要があります。カラムの合計サイズとカーディナリティのバランスを考慮しなければなりません。カーディナリティが高いカラムは、たとえ多くの容量を消費していても並べ替えの恩恵をあまり受けられず、後続のカラムの値が連続する機会が減るため、後続カラムの圧縮も阻害してしまいます。下の図に示すように、データをひとまとめにクラスタリングしやすいカラムを使用すると、圧縮効率が高まります。

可能な限り最高の圧縮を達成するには、影響の大きさと低カーディナリティのバランスが取れたソートキーを選択する必要があります。まず、テーブル内でどのカラムが最も多くの容量を占めているかを把握しなければなりません。そのためには、テーブルカラムの統計情報を含む system.columns テーブルにクエリを実行します。
INSERT INTO nginx_column_tuple_optimized_types SELECT * FROM nginx_column_tuple;テーブルサイズを確認してみましょう。
SELECT
name,
formatReadableSize(sum(data_compressed_bytes)) AS compressed_size,
formatReadableSize(sum(data_uncompressed_bytes)) AS uncompressed_size
FROM system.columns
WHERE `table` = 'nginx_column_tuple_optimized_types'
GROUP BY name
ORDER BY sum(data_uncompressed_bytes) DESC
┌─name─────────────┬─compressed_size─┬─uncompressed_size─┐
1. │ referer │ 13.69 MiB │ 4.96 GiB │
2. │ request_path │ 127.19 MiB │ 3.55 GiB │
3. │ size │ 39.37 MiB │ 254.62 MiB │
4. │ time_local │ 22.58 MiB │ 254.62 MiB │
5. │ remote_addr │ 10.76 MiB │ 254.62 MiB │
6. │ user_agent │ 589.91 KiB │ 129.05 MiB │
7. │ status │ 3.42 MiB │ 127.31 MiB │
8. │ request_type │ 722.43 KiB │ 63.78 MiB │
9. │ request_protocol │ 62.79 KiB │ 63.78 MiB │
10. │ remote_user │ 56.81 KiB │ 63.78 MiB │
└──────────────────┴─────────────────┴───────────────────┘この情報とカーディナリティを組み合わせることで、最も優れた圧縮をもたらすソートキーを特定できます。
| カラム名 | 非圧縮サイズ | カーディナリティ |
|---|---|---|
| referer | 4.96 GiB | 103048 |
| request_path | 3.55 GiB | 881124 |
| size | 254.62 MiB | 69712 |
| time_local | 254.62 MiB | 2579628 |
| remote_addr | 254.62 MiB | 258115 |
| user_agent | 129.05 MiB | 28344 |
| status | 127.31 MiB | 15 |
| request_type | 63.78 MiB | 5 |
| request_protocol | 63.78 MiB | 3 |
| remote_user | 63.78 MiB | 2 |
この情報をもとに、ソートキーの候補として referrer、remote_addr、user_agent を特定しました。request_path もサイズが大きいため興味深い候補ですが、カーディナリティが高いため、大きな圧縮効果は期待できません。
考慮すべきもう 1 つの要素は値の分布です。中程度から高めのカーディナリティであっても、少数の値が大半のデータを占めている場合、そのカラムは優れたソートキーになり得ます。
referer、remote_addr、user_agent、request_path について、各カラムの上位 20 件の値を取得し、その割合を計算します。
referer と user_agent のカラムは、他の 2 つに比べて偏った分布を示しています。また、約 12 番目の値以降は分布がなだらかになるため、それ以降の長いテールを分析する必要はありません。
これに基づき、ソートキーとして referer、remote_user、user_agent、request_path を選択します。考え方としては、最もサイズが大きい referer カラムから開始します。その高いカーディナリティは偏った分布によって相殺され、圧縮に寄与します。次に、後続カラムのデータクラスターを崩さない低カーディナリティなカラムである remote_addr が続きます。その後、referer と同様の理由から user_agent を含めます。最後に request_path で並べ替えることで、その大きなカラムに対してさらなる圧縮メリットを得られます。
テーブルを作成してデータを取り込んでみましょう。
-- テーブルの作成
CREATE TABLE nginx_column_tuple_optimized_types_sort
(
`remote_addr` IPv4,
`remote_user` LowCardinality(String),
`time_local` DateTime CODEC(Delta(4), ZSTD(1)),
`request_type` LowCardinality(String),
`request_path` String CODEC(ZSTD(6)),
`request_protocol` LowCardinality(String),
`status` UInt16,
`size` UInt32,
`referer` String CODEC(ZSTD(6)),
`user_agent` LowCardinality(String)
)
ORDER BY (referer, user_agent, remote_user, request_path);
-- 新しいテーブルへデータを取り込み
INSERT INTO nginx_column_tuple_optimized_types_sort SELECT * FROM nginx_column_tuple;このテーブルが使用している合計サイズを確認してみましょう。
SELECT
`table`,
formatReadableSize(sum(data_compressed_bytes)) AS compressed_size,
formatReadableSize(sum(data_uncompressed_bytes)) AS uncompressed_size
FROM system.columns
WHERE `table` = 'nginx_column_tuple_optimized_types_sort'
GROUP BY `table`
FORMAT VERTICAL
Row 1:
──────
table: nginx_column_tuple_optimized_types_sort
compressed_size: 109.12 MiB
uncompressed_size: 9.70 GiBディスク上の生のログファイルと比較して、驚異的な圧縮率が得られました。20 GB から 109 MB へと削減され、178 倍の圧縮率を達成しました!
(少し残念な)現実に立ち返る
これは素晴らしい成果ですが、正直なところ、nginx ログを referer や user_agent で絞り込むケースは一般的ではありません。これらのフィールドは分析時に役立つものの、大半のクエリは「直近 1 時間のアクセスログを表示する」といった時間による絞り込みを行います。この、より典型的なシナリオにおける圧縮率を簡単に確認してみましょう。
time_local を最初のソートキーとしてテーブルを作成します。タイムスタンプの高いカーディナリティによる影響を抑えるため、値をより粗い時間単位(日単位など)に丸めて、カーディナリティの問題を回避できます。
-- テーブルの作成
CREATE TABLE logs_blog.nginx_column_tuple_optimized_types_time_sort
(
`remote_addr` IPv4,
`remote_user` LowCardinality(String),
`time_local` DateTime CODEC(Delta(4), ZSTD(1)),
`request_type` LowCardinality(String),
`request_path` String CODEC(ZSTD(6)),
`request_protocol` LowCardinality(String),
`status` UInt16,
`size` UInt32,
`referer` String CODEC(ZSTD(6)),
`user_agent` LowCardinality(String)
)
ORDER BY (toStartOfDay(time_local), referer, user_agent, remote_user, request_path);
-- データの取り込み
INSERT INTO logs_blog.nginx_column_tuple_optimized_types_time_sort SELECT * FROM logs_blog.nginx_column_tuple_optimized_types_sort;
-- サイズの確認
SELECT
`table`,
formatReadableSize(sum(data_compressed_bytes)) AS compressed_size,
formatReadableSize(sum(data_uncompressed_bytes)) AS uncompressed_size
FROM system.columns
WHERE `table` = 'nginx_column_tuple_optimized_types_time_sort'
GROUP BY `table`
FORMAT VERTICAL
Row 1:
──────
table: nginx_column_tuple_optimized_types_time_sort
compressed_size: 380.82 MiB
uncompressed_size: 9.76 GiB結果は先ほどほど高くはなく、このケースでは約 50 倍の圧縮にとどまります。ソートキーの選択が全体の圧縮効率にどれほど大きく影響するかがよく分かります。
簡単なまとめ
生のログを効率よく圧縮するためのアプローチを適用することで、178 倍の圧縮率を達成しました。以下の表は、すべての実験とその結果をまとめたものです。
| 名前 | ストレージ | 圧縮 | サイズ (バイト) | サイズ (可読) | 圧縮率 |
|---|---|---|---|---|---|
| nginx-66.log | local | None | 21237294480 | 20G | 1.00 |
| nginx-66.log.gz | local | GZIP | 672053616 | 641M | 31.60 |
| nginx_raw | Clickhouse | Uncompressed | 21673487121 | 20.19 GiB | 0.98 |
| nginx_raw | Clickhouse | Compressed | 603582977 | 575.62 MiB | 35.19 |
| nginx_column_tuple | Clickhouse | Compressed | 1387994151 | 1.29 GiB | 15.30 |
| optimized_types | Clickhouse | Compressed | 229027241 | 218.42 MiB | 92.73 |
| optimized_types_sort | Clickhouse | Compressed | 118984801 | 109.12 MiB | 178.49 |
| optimized_types_sort_time | Clickhouse | Compressed | 402837184 | 380.82 MiB | 52.71 |
まとめ
ログを極めて高い圧縮率で保存することは難易度が高いものの、ClickHouse のような列指向データベースを用いれば実現可能です。生のログを構造化フォーマットに変換し、各フィールドを効率的なデータ型で保存し、類似した値が集まるようにデータを並べ替えることで、目覚ましい水準の圧縮を達成できます。このレイアウトはクエリ性能の観点では必ずしも常に理想的とは言えませんが、ログデータを可能な限り省スペースな形式で保持することが目的である場合には、非常に優れた選択肢となります。
本記事では、列指向ストレージがいかに最高水準のログ圧縮を実現し、I/O 効率の改善、クエリの高速化、ストレージコストの削減をもたらすかを解説しました。Nginx のアクセスログを例に、170 倍以上の圧縮を達成できました。



