- tl;dr
- はじめに
- DuckDB とは
- DuckDB では何が読めるのか
- 使ってみる
- 他ツールではなく DuckDB を使うメリット
- しくじりポイント
- 終わりに
- 付録 MySQL のデータを読み込む例の MySQL 側の準備
tl;dr
- DuckDB 便利だよ。分析以外でも使えるよ
- 色々な場所のデータを閲覧・結合できるよ。標準SQLも使えるよ
- ただし、細かい落とし穴は色々あるので気をつけてね
はじめに
2023年4月にデータエンジニアとして入社したmin(@not_rogue)です。暖かくなるにつれ、YouTube で見た南伊豆ロングトレイル | 松崎町に行く機運が日々高まっています!弊社では
- 各種マスター情報は Amazon Aurora に
- 各種ログを Amazon S3 に
保存しています。業務の中で、こういった各所のデータを見たいことがたまにあります。例えば、
- 障害があった時の原因調査のために S3 のデータを確認したい
- さらに、その時、S3 上のログと Aurora 上のマスターと紐づけて見たい
といった場合です。
こういった場合に、DuckDB というツールが便利では?ということで、その使い方をご紹介します。
DuckDB とは
シングルバイナリで動作するデータベース(いわゆる Embedded database)の一つです。同じカテゴリのプロダクトでは SQLite3 が有名ですが、DuckDB は分析用途よりのプロダクトです。DuckDB のその他特徴や SQLite3 との違いについては
などをご参照ください。
DuckDB では何が読めるのか
色々あります!ファイルフォーマットとしては- CSV
- JSON
- Parquet
- Excel
ファイルの置き場所としては
- ローカル
- AWS/Azure/Google Cloud のオブジェクトストレージ
- HTTP(HTTPS)
他のデータベースとしては
- MySQL
- PostgreSQL
- SQLite3
のデータを読み込むことができます。(書き込みもできます)
本記事ではそのうち、
- S3 上の JSON
- MySQL上のテーブル
をDuckDBで読み込みました。
使ってみる
インストール(DuckDB Installation - DuckDB)は済んでいる前提です。macOS Ventura・DuckDB 0.10 で試しました。S3 上のJSON を読んでみる
手順としては、- (この記事では省略)AWS の認証情報を設定(アクセスキーやSSO 等)
- AWS クレデンシャルを読み込み
- read_json_auto で JSON ファイルを読み込み
- スキーマを推測して自動でカラムにしてくれます(明示的にカラムを指定することも可能)
- GZIP 圧縮にも対応してくれます
するだけです。
S3 上の JSON ファイルの例として、AWS Open Data Sponsorship Program で公開されているAWS Marketplace: Open Observatory of Network Interference (OONI)のデータを見てみます。
OONI は各種インターネットサイトへの検閲・ブロック情報を収集するプロジェクトで、gzip圧縮された JSON(NDJSONと かJSON Lines と呼ばれるやつ)が S3 に公開されています。
どんなデータか AWS cli と jq で見てみます。
aws s3 cp --no-sign-request s3://ooni-data-eu-fra/jsonl/example/US/20201005/00/20201005_US_example.x.f7d82996d07a0e96.jsonl.gz - | gzcat | head -n1 | jq ''
{
"resolver_network_name": "Microsoft Corporation",
"software_name": "miniooni",
"software_version": "0.18.0-alpha",
"test_runtime": 1.000464311,
"test_start_time": "2020-10-05 02:19:55",
"resolver_asn": "AS8075",
"probe_network_name": "Microsoft Corporation",
"test_keys": {
"success": true
},
"data_format_version": "0.2.0",
"measurement_start_time": "2020-10-05 02:19:55",
"test_version": "0.0.1",
"test_name": "example",
"resolver_ip": "13.89.169.76",
"input": null,
"probe_ip": "127.0.0.1",
"report_id": "20201005T021955Z_AS8075_9SU2fyJnElS2ENUND7jDTdAMS0RhchSsFWblxFV8VoROEny7O6",
"probe_asn": "AS8075",
"annotations": {
"platform": "linux",
"assets_version": "20200929203018",
"engine_version": "0.18.0-alpha",
"engine_name": "ooniprobe-engine"
},
"probe_cc": "US",
"measurement_uid": "012020100514b4aa5fed8e67e3970e7b24c7601f"
}
DuckDB でファイル単位で見てみます。$ duckdb
# AWSクレデンシャルを読み込み
D call load_aws_credentials();
# S3上のJSONを
D select * from read_json_auto('s3://ooni-data-eu-fra/jsonl/example/US/20201005/00/20201005_US_example.x.f7d82996d07a0e96.jsonl.gz') limit 1;
┌──────────────────────┬───────────────┬──────────────────┬──────────────┬─────────────────────┬──────────────┬───┬──────────────────────┬───────────┬──────────────────────┬──────────┬──────────────────────┐
│ resolver_network_n… │ software_name │ software_version │ test_runtime │ test_start_time │ resolver_asn │ … │ report_id │ probe_asn │ annotations │ probe_cc │ measurement_uid │
│ varchar │ varchar │ varchar │ double │ timestamp │ varchar │ │ varchar │ varchar │ struct(platform va… │ varchar │ varchar │
├──────────────────────┼───────────────┼──────────────────┼──────────────┼─────────────────────┼──────────────┼───┼──────────────────────┼───────────┼──────────────────────┼──────────┼──────────────────────┤
│ Microsoft Corporat… │ miniooni │ 0.18.0-alpha │ 1.000464311 │ 2020-10-05 02:19:55 │ AS8075 │ … │ 20201005T021955Z_A… │ AS8075 │ {'platform': linux… │ US │ 012020100514b4aa5f… │
├──────────────────────┴───────────────┴──────────────────┴──────────────┴─────────────────────┴──────────────┴───┴──────────────────────┴───────────┴──────────────────────┴──────────┴──────────────────────┤
│ 1 rows 20 columns (11 shown) │
└─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
途中カラムが省略されています。表示フォーマットを変えると確認できます。
(デフォルトの duckbox というフォーマットではウィンドウ幅に合わせて省略してくれるようです)
D .mode box
D select * from read_json_auto('s3://ooni-data-eu-fra/jsonl/example/US/20201005/00/20201005_US_example.x.f7d82996d07a0e96.jsonl.gz') limit 1;
┌───────────────────────┬───────────────┬──────────────────┬──────────────┬─────────────────────┬──────────────┬───────────────────────┬───────────────────┬─────────────────────┬────────────────────────┬──────────────┬───────────┬──────────────┬───────┬───────────┬────────────────────────────────────────────────────────────────────────────┬───────────┬────────────────────────────────────────────────────────────────────────────────────┬──────────┬──────────────────────────────────────────┐
│ resolver_network_name │ software_name │ software_version │ test_runtime │ test_start_time │ resolver_asn │ probe_network_name │ test_keys │ data_format_version │ measurement_start_time │ test_version │ test_name │ resolver_ip │ input │ probe_ip │ report_id │ probe_asn │ annotations │ probe_cc │ measurement_uid │
├───────────────────────┼───────────────┼──────────────────┼──────────────┼─────────────────────┼──────────────┼───────────────────────┼───────────────────┼─────────────────────┼────────────────────────┼──────────────┼───────────┼──────────────┼───────┼───────────┼────────────────────────────────────────────────────────────────────────────┼───────────┼────────────────────────────────────────────────────────────────────────────────────┼──────────┼──────────────────────────────────────────┤
│ Microsoft Corporation │ miniooni │ 0.18.0-alpha │ 1.000464311 │ 2020-10-05 02:19:55 │ AS8075 │ Microsoft Corporation │ {'success': true} │ 0.2.0 │ 2020-10-05 02:19:55 │ 0.0.1 │ example │ 13.89.169.76 │ │ 127.0.0.1 │ 20201005T021955Z_AS8075_9SU2fyJnElS2ENUND7jDTdAMS0RhchSsFWblxFV8VoROEny7O6 │ AS8075 │ {'platform': linux, 'assets_version': 20200929203018, 'engine_version': 0.18.0-... │ US │ 012020100514b4aa5fed8e67e3970e7b24c7601f │
└───────────────────────┴───────────────┴──────────────────┴──────────────┴─────────────────────┴──────────────┴───────────────────────┴───────────────────┴─────────────────────┴────────────────────────┴──────────────┴───────────┴──────────────┴───────┴───────────┴────────────────────────────────────────────────────────────────────────────┴───────────┴────────────────────────────────────────────────────────────────────────────────────┴──────────┴──────────────────────────────────────────┘
desc コマンドで、read_json_auto が推測したスキーマを確認できます。(見やすさのためdescコマンドの結果を、limitで制限しています)
D select * from (desc select * from read_json_auto('s3://ooni-data-eu-fra/jsonl/example/US/20201005/00/20201005_US_example.x.f7d82996d07a0e96.jsonl.gz') ) order by column_name limit 3;
┌─────────────────────┬───────────────────────────────────────────────────────────────────────────────────────────────┬─────────┬─────────┬─────────┬─────────┐
│ column_name │ column_type │ null │ key │ default │ extra │
│ varchar │ varchar │ varchar │ varchar │ varchar │ varchar │
├─────────────────────┼───────────────────────────────────────────────────────────────────────────────────────────────┼─────────┼─────────┼─────────┼─────────┤
│ annotations │ STRUCT(platform VARCHAR, assets_version VARCHAR, engine_version VARCHAR, engine_name VARCHAR) │ YES │ │ │ │
│ data_format_version │ VARCHAR │ YES │ │ │ │
│ input │ JSON │ YES │ │ │ │
└─────────────────────┴───────────────────────────────────────────────────────────────────────────────────────────────┴─────────┴─────────┴─────────┴─────────┘
もちろん、普通のテーブル同様に集計することもできます。D select count(1), max(test_start_time), min(test_start_time) from read_json_auto('s3://ooni-data-eu-fra/jsonl/example/US/20201005/00/20201005_US_example.x.f7d82996d07a0e96.jsonl.gz') limit 1;
┌──────────┬──────────────────────┬──────────────────────┐
│ count(1) │ max(test_start_time) │ min(test_start_time) │
│ int64 │ timestamp │ timestamp │
├──────────┼──────────────────────┼──────────────────────┤
│ 108 │ 2020-10-05 17:41:44 │ 2020-10-05 02:19:55 │
└──────────┴──────────────────────┴──────────────────────┘
create table as select(CTAS )で DuckDB のフォーマットで保存もできます。一度作成すると JSON を直接読むより、クエリが早くなります。D create table hoge as select * from read_json_auto('s3://ooni-data-eu-fra/jsonl/example/US/20201005/00/20201005_US_example.x.f7d82996d07a0e96.jsonl.gz');
D .timer on
D select count(1), max(test_start_time), min(test_start_time) from hoge limit 1;
┌──────────┬──────────────────────┬──────────────────────┐
│ count(1) │ max(test_start_time) │ min(test_start_time) │
│ int64 │ timestamp │ timestamp │
├──────────┼──────────────────────┼──────────────────────┤
│ 108 │ 2020-10-05 17:41:44 │ 2020-10-05 02:19:55 │
└──────────┴──────────────────────┴──────────────────────┘
Run Time (s): real 0.002 user 0.001139 sys 0.000752
# (json直接クエリする時はreal 2.529でした)
ワイルドカード・Glob で複数ファイルを指定することもできます。D select count(1) from read_json_auto('s3://ooni-data-eu-fra/jsonl/whatsapp/JP/202001*/**') limit 1;
┌──────────┐
│ count(1) │
│ int64 │
├──────────┤
│ 62 │
└──────────┘
# filenameオプションつけると対応するファイルがわかります
D select filename from read_json_auto('s3://ooni-data-eu-fra/jsonl/whatsapp/JP/202001*/**', filename=true) limit 2;;
┌───────────────────────────────────────────────────────────────────────────────────────┐
│ filename │
│ varchar │
├───────────────────────────────────────────────────────────────────────────────────────┤
│ s3://ooni-data-eu-fra/jsonl/whatsapp/JP/20200102/00/20200102_JP_whatsapp.l.0.jsonl.gz │
│ s3://ooni-data-eu-fra/jsonl/whatsapp/JP/20200102/00/20200102_JP_whatsapp.l.0.json
リレーショナルデータベース
DuckDB では MySQL/PostgreSQL/SQLite3 上のテーブルに対して、DuckDB を経由してクエリを実行することができます。ここでは、MySQL 上のテーブルに対してクエリを実行してみます。MySQL の例として、Docker 上に構築した MySQL サーバーに、ASN 番号を持つテーブルを用意したものを使います。用意方法については付録をご覧ください。
DuckDB から MySQL 上のデータにアクセスするには、拡張をインストールして、ATTACH コマンドを呼ぶだけです。
$ duckdb
D INSTALL mysql;
D ATTACH 'host=127.0.0.1 password=example user=root port=3308 database=test' AS mysqldb (TYPE mysql);
DuckDB 上の mysqldb というデータベースに、MySQL 上のテーブルが同期され、
- MySQL 上のテーブルの読み書き
- MYSQL 上のテーブルと、DuckDB 上の他のテーブルとの組み合わせ
ができるようになります。
# MySQLのテーブルが見えます
D .tables
asn
# MySQLのテーブルが閲覧できます
D select * from mysqldb.asn limit 1;
┌─────────┬─────────┬────────────────────────┐
│ asn │ country │ name │
│ varchar │ varchar │ varchar │
├─────────┼─────────┼────────────────────────┤│ AS23456 │ UN │ 4Byte ASN Place Holder │
└─────────┴─────────┴────────────────────────┘
# MySQLのデータとMySQL以外のテーブルを組み合わせることもできます
D select * from (select test_start_time, probe_asn from read_json_auto('s3://ooni-data-eu-fra/jsonl/example/US/20201005/00/20201005_US_example.x.f7d82996d07a0e96.jsonl.gz') limit 1) s left join mysqldb.asn on asn.asn = s.probe_asn;
┌─────────────────────┬───────────┬─────────┬─────────┬─────────────────────────────────────────────────────┐
│ test_start_time │ probe_asn │ asn │ country │ name │
│ timestamp │ varchar │ varchar │ varchar │ varchar │
├─────────────────────┼───────────┼─────────┼─────────┼─────────────────────────────────────────────────────┤/
│ 2020-10-05 02:19:55 │ AS8075 │ AS8075 │ US │ MICROSOFT-CORP-MSN-AS-BLOCK - Microsoft Corporation │
└─────────────────────┴───────────┴─────────┴─────────┴─────────────────────────────────────────────────────┘
ただし、MySQL 上のテーブルにクエリすると、一度全部ローカルに持って来る可能性があるので注意が必要です。試しに、where があるクエリを DuckDB から実行してみます。
D select * from mysqldb.asn where asn = 'AS560' limit 1;
┌─────────┬─────────┬───────────────────────────────────┐
│ asn │ country │ name │
│ varchar │ varchar │ varchar │
├─────────┼─────────┼───────────────────────────────────┤
│ AS560 │ US │ LVLT-560 - Level 3 Communications │
└─────────┴─────────┴───────────────────────────────────┘
MySQL 側のログを見ると、where がないクエリが実行されています。
docker container exec some-mysql sh -c 'tail -n3 /var/lib/mysql/*.log'
2024-04-02T06:45:09.484894Z 25 Query SELECT `asn`, `country`, `name` FROM `test`.`asn`
2024-04-02T06:45:09.510722Z 25 Query COMMIT
2024-04-02T06:45:09.511091Z 25 Quit
pushdown(データソース側で絞り込みを行う機能)を有効にすると、where が MySQL の方で実行されます。
# DuckDB側
D set mysql_experimental_filter_pushdown = true;
D select * from mysqldb.asn where asn = 'AS560' limit 1;
# MySQLのログを見るとwhereが付記されています
docker container exec some-mysql sh -c 'tail -n3 /var/lib/mysql/*.log'
2024-04-02T06:52:00.941808Z 27 Query SELECT `asn`, `country`, `name` FROM `test`.`asn` WHERE (`asn` = 'AS560' AND `asn` IS NOT NULL) LIMIT 1
2024-04-02T06:52:00.952084Z 27 Query COMMIT
2024-04-02T06:52:00.952604Z 27 Quit
ただし、実質的に同じような where 文でも、pushdown が効かない場合もあります。実行前にクエリプランを確認するのが吉なようです。
# (pushdownを有効にして)DuckDB側で=ではなく、inでselectしてみる
D select * from mysqldb.asn where asn in ('AS560') limit 1;
# MySQL側のログにはwhereがないです
docker container exec some-mysql sh -c 'tail -n3 /var/lib/mysql/*.log'
2024-04-02T06:55:53.505738Z 29 Query SELECT `asn`, `country`, `name` FROM `test`.`asn`
2024-04-02T06:55:53.589020Z 29 Query COMMIT
2024-04-02T06:55:53.590234Z 29 Quit
他ツールではなく DuckDB を使うメリット
今回記載した- S3 上の JSON 読み込み
- Aurora のデータの読み込み
をそれぞれ閲覧するだけであれば、
- Amazon Athena や一度ダウンロードしてjq
- 各種データベースのクライアント(MySQL Workbench や DBeaver)
などでも対応可能です。DuckDB で閲覧するメリットとしては、
- 違う置き場所にあるデータを、組み合わせて閲覧できる
- Athena のGlue Data Catalog のような事前設定が不要
- JSON やCSV のスキーマを推測してくれる
- 一般的な SQL が使える
- ある程度の大きさのファイルも(数100MB〜GB)処理できる
などがあります。
しくじりポイント
ここまでは「DuckDB 便利だねー」というお話ですが、いくつか躓いたポイントがあったので共有します。(特にリリースされたばかりの)バージョンには気をつける
SQLite3 は20年以上歴史のある成熟したツールですが、DuckDB はまだまだ新しいツールでバグを踏むこともあります。例えば、バージョン0.8系ではgzipを読めない(こともある)バグがありました。この件に関しては迅速に(2週間?)バグ修正いただいていますが、活発に機能追加・変更されていることを考えると、
- バージョンを上げる時は様子をみて行う
- 代替案も用意しておく
- ミッションクリティカルな箇所では使わない
を頭に入れておいた方が良さそうです。
S3 のオブジェクト数が多い場合不都合がありがち
体感的には、データサイズが大きい場合(〜数GB・数十GB)では、私の端末(macbook pro 2023)でもリーズナブルな時間(単純なクエリでは数秒・数分)で処理できる場合が多いです。しかし、S3 上にあり、かつ、処理対象のオブジェクト数が多い場合は、処理に時間がかかる、もしくは処理がエラーで終わる場合もあります。
一度S3のオブジェクトを(AWS CLI などで)ローカルに保存し、そのディレクトリに対してクエリを投げてあげると、S3 上のデータを直接閲覧するよりも時間が短くなる場合がありそうです(ダウンロードの時間を含めたとしても)。
# 約千個のオブジェクトのあるフォルダ
> aws s3 ls --recursive --no-sign-request 's3://ooni-data-eu-fra/jsonl/whatsapp/JP/' | grep -c jsonl
1229
# 10分待っても終わりませんでした
D select count(1) from read_json_auto('s3://ooni-data-eu-fra/jsonl/whatsapp/JP/**') limit 1;
# ローカルに一度ダウンロード(下コマンド)した同じデータにクエリ
# aws s3 cp --recursive s3://ooni-data-eu-fra/jsonl/whatsapp/JP/ .//ooni
# ダウンロードは43秒かかりました
D select count(1) from read_json_auto('./ooni/**' ) limit1;
┌──────────┐
│ count(1) │
│ int64 │
├──────────┤
│ 2842 │
└──────────┘
Run Time (s): real 1.168 user 1.116827 sys 0.191204
スレッドの調整が必要な場合も
ファイル数が多い場合と関係して、一つ一つのファイルが小さい場合でも OOM が発生する時があります。その場合、同時に実行するスレッド数(Configuration Reference の threads オプション)を小さくしてあげると解決できる場合があります。
Redshift には未対応
PostgreSQL 拡張を使うことで DuckDB を経由して PostgreSQL に接続することができますが、2024/3時点でPostgreSQL 拡張には対応していません(Redshift がサポートしていない PostgreSQL の構文を使用しているため)。終わりに
今回のブログでは、DuckDB をファイルビューアーとして使えそうという!と紹介させていただきました。Pandas や SQLite3 の代替として、分析用途で紹介されることが多い DuckDB ですが、このような使い道もあることがご参考になれば幸いです。付録 MySQL のデータを読み込む例の MySQL 側の準備
適当に Docker コンテナで MySQL サーバーを起動します。docker run --name some-mysql -e MYSQL_ROOT_PASSWORD=example -d -p 3308:3306 mysql:8.3
サンプルデータを持つテーブルとしてvvuksan さんがGitHub で公開しているasnを取り込んだテーブルを作ります。
mysql -u root -h 127.0.0.1 -P 3308 -pexample --local-infile=1
mysql> create database test;
mysql> use test;
mysql> create table asn (asn varchar(255), country varchar(2), name varchar(255) );
mysql> load data local infile 'ダウンロードしたasn.csv' into table asn fields terminated by ',';
mysql> set global general_log = on;