くしゃみのちり紙

ブログ名はチラ裏的なノリでつけました。汚いですね。すみません。

GrafanaでS3に置いてあるログファイルを参照する

きっかけは以下のツイートを見かけたことでした。

なにこれ良さそう。GrafanaもDuckDBも全然知らんけど。

ということで試してみました。

キーアイテム

grafana-duckdb-datasourceというGrafanaプラグインを使用します。 github.com

前提

  • Grafana Version: v11.6.0-82874 (e5b49a406f)
  • Container Image: grafana/grafana-enterprise:main-ubuntu
  • Container OS: Ubuntu 22.04.5 LTS
  • Container CPU Architecture: ARM64
  • Container Runtime: podman version 5.3.1
  • Host OS: M1 Mac

M1 MacでPodmanでGrafanaコンテナを使いました。でもたぶん、WindowsとかDockerとかでもだいたい動くんじゃないかと思います。試してはいませんが。

環境構築

grafana-duckdb-datasourceプラグインの準備に関しては公式の手順に従います。 GitHub - motherduckdb/grafana-duckdb-datasource

ただし今回はGrafanaコンテナを使うので、それを加味した手順を以下に記します。

Grafanaの設定ファイルを用意

grafana-duckdb-datasourceのインストール手順にある通り、未署名のプラグインをGrafanaに登録するには、そういうのを許可するための記述を設定ファイルに追加する必要があるそうです。ちなみに、Grafana公式の設定リファレンスはこちら。
Configure Grafana | Grafana documentation

今回はGrafanaコンテナを使うので、コンテナから設定ファイルをホスト側にコピーしてきてそれを編集します。

まずGrafanaコンテナから設定ファイル(grafana.ini)を取得します。

podman run -d --rm --name=grafana grafana/grafana-enterprise:main-ubuntu
podman cp grafana:/etc/grafana/grafana.ini ./conf/

コピーした設定ファイルの配置先をここでは./conf/としていますが、どこでも構いません。

コピーしたら、Grafanaコンテナは停止します。ここでは設定ファイルがほしくて起動しただけなので。--rmをつけているので、停止したら削除までされます。後でオプションを増やしてまたちゃんと起動します。

podman stop grafana

コピーしてきたgrafana.ini を編集します。 allow_loading_unsigned_pluginsという項目が設定ファイルにすでにあるはずなので、許可したいプラグインであるmotherduck-duckdb-datasourceを右辺に書きます。

  • Before
;allow_loading_unsigned_plugins =
  • After
allow_loading_unsigned_plugins = motherduck-duckdb-datasource

項目の行頭にセミコロン;がついている場合、それはコメントアウト記号なので除去します。

motherduck-duckdb-datasourceプラグインを用意

公式の手順にある通り、motherduck-duckdb-datasourceプラグインをダウンロードしてきてunzipコマンドで展開します。 ここではplugins/motherduck-duckdb-datasourceに展開したものとします。

ls plugins/motherduck-duckdb-datasource

バージョン次第で内容が変わるかもしれませんが、motherduck-duckdb-datasourceフォルダをlsすると概ね以下のような内容になっているはずです。

CHANGELOG.md                             gpx_duckdb_datasource_darwin_arm64*      grafana-lokiexplore-app/                 module.js.LICENSE.txt
LICENSE                                  gpx_duckdb_datasource_linux_amd64*       grafana-pyroscope-app/                   module.js.map
README.md                                gpx_duckdb_datasource_linux_arm64*       img/                                     plugin.json
go_plugin_build_manifest                 gpx_duckdb_datasource_windows_amd64.exe* module.js

Grafanaコンテナを起動

さて準備が整いました。Grafanaコンテナを起動しましょう。

podman run -d --user root --rm -p 3000:3000 \
-v ./conf/grafana.ini:/etc/grafana/grafana.ini \
-v ./plugins:/var/lib/grafana/plugins \
-e "GF_DEFAULT_APP_MODE=development" \
--name=grafana grafana/grafana-enterprise:main-ubuntu

以下、簡単な説明です。

  • --user root: ルートユーザを使っています。motherduck-duckdb-datasourceプラグインがコンテナ内で動作するために必要な権限を与えるためです。
  • -p 3000:3000: 3000番ポートを使っています。すでに他のアプリで使っている場合などは適宜変更してください。
  • -v ./conf/grafana.ini:/etc/grafana/grafana.ini: 編集した設定ファイルコンテナにバインドマウントしています。
  • -v ./plugins:/var/lib/grafana/plugins: motherduck-duckdb-datasourceプラグインを、Grafanaコンテナのプラグインディレクトリ配下にバインドマウントしています。
  • -e "GF_DEFAULT_APP_MODE=development": なんか開発モードみたいな設定にしないといけない?っぽいので、環境変数を設定しています。
  • grafana/grafana-enterprise:main-ubuntu: Grafana公式のコンテナイメージを使います。Alpine Linuxのイメージもあるようなのですが、それだとうまくいかないのでUbuntuベースのイメージを使います。

motherduck-duckdb-datasourceプラグインの確認

Grafanaが立ち上がったら、motherduck-duckdb-datasourceプラグインがちゃんと使えそうか確認します。 http://localhost:3000 でGrafanaにアクセスします。

Grafanaのログイン画面

初期設定のユーザ/パスワードはどちらもadminなので、それを入力してLog inボタンを押します。 パスワード設定画面が表示されますが、一時的な用途ならスキップしても構いません。もちろんきっちり設定しても構いません。

ログインしたら、サイドメニューから Administration > Plugins and data > Plugins に進みます。 進んだら、検索欄でduckとかで検索しましょう。 Duckdb-Datasourceというのが出てきたらOKです。

Plugins画面にDuckdb-Datasourceが存在している

motherduck-duckdb-datasourceプラグインでS3のログファイルを参照する

ではプラグインを使ってS3のログファイルを参照してみましょう。

(ここまでもそうですが)ここからはDuckDBのドキュメントが参考になります。 参照できるデータファイルのフォーマットや参照する方法も、DuckDBの以下のドキュメントに従います。 duckdb.org

ログファイルはなんでも良いのですが、私はSpring Bootで適当に出力したJSONL形式のログを使いました。 少し前に以下の記事に掲載されているスライドを見て興味が湧き、試したときのログファイルがちょうどあったのでした。

irof.hateblo.jp

ということでログファイルをS3の適当なバケットにアップロードします。 なおこのとき、バケットのパブリックアクセス設定を緩める必要はありません。

DuckDBはS3 APIをサポートしていて、S3へのアクセスにはAWSのアクセスキーを利用するためです。 duckdb.org

アクセスキーといえば、AWS CLIとかでお馴染みの、AWS APIを利用するのに必要なあれですね。 ということであれを用意しましょう。用意したら、それをプラグインに登録しましょう。 さきほどのPlugin一覧で表示されたDuckdb-Datasourceをクリックします。

DuckDB-Datasourceのプラグイン詳細画面

警告が大きく出ていますが承知の上です。構わず(?)右上のAdd new data sourceをクリックします。 すると、motherduck-duckdb-datasourceのデータソース設定画面が表示されます。なお、サイドメニューのData sourceからmotherduck-duckdb-datasourceを検索しても同じ画面に辿りつきます。

設定項目の説明はプラグインの公式にあります。
GitHub - motherduckdb/grafana-duckdb-datasource

  • Path: DuckDBのファイルのパスを記入する欄らしいです。未設定の場合、インメモリモードになります。S3のファイルを読むにはこのインメモリモードで問題ないので未設定のままとします。
  • MotherDuck Token: MotherDuck APIとやらを使う場合のトークンらしいです。今回は使わないのでノータッチ。(そのうち調べたい)

その他に、設定画面にはInit SQLという項目もあります。 ここには、データソースとのコネクションが確立された際に実行される初期化用のSQLを記述できます。 で、DuckDBからS3へのアクセスにはアクセスキーが必要なので、この初期化SQLでDuckDBにアクセスキーを登録します。 公式(前掲したS3 API Supportのページ)を見ると認証チェーン(認証情報の登録先候補を順に走査して使えるやつがあれば使うやつ)なんかもあるみたいですが、今回は1つの認証情報をDuckDBに登録する方法を使います。 ということで、Init SQLの入力欄には次のようにSQLを入力します。

CREATE SECRET secret1 (
    TYPE S3,
    KEY_ID 'アクセスキー',
    SECRET 'シークレットアクセスキー',
    REGION 'リージョン。東京ならap-northeast-1'
);

KEY_ID, SECRET, REGIONには例のあれを指定します。 記述したら、Save & testを押します。

motherduck-duckdb-datasourceのデータソース設定画面

SQLや登録情報に誤りがなければ、以下のようにData source is workingという感じのメッセージが表示されます。

データソース設定完了の様子

これで、認証情報の登録が完了しました。 ではいよいよS3のログファイルを参照してみましょう。

サイドメニューのExplore を選択し、画面上部のプルダウンでmotherduck-duckdb-datasourceを選択します。 次に、画面上部右側のBuilder/CodeのトグルでCodeを選択します。するとクエリの入力欄が出てくるので、次のように入力します。

SELECT * FROM read_json_auto('s3://heraction-log-temp/boot.log');

s3://~の部分には、対象のログのS3 URIを指定します。できたら、入力欄の右上のRun queryを押します。

するとどうでしょう、S3に置いてあるログファイルの中身が表示されたではありませんか!

JSONLログをSelect Allした画面

SELECT句でカラムを指定することも可能です。なおここのカラムとは、JSONL形式におけるJSONデータ各行に含まれる、各プロパティのキー名称です。 ただし上記のように、カラム名(キー名称)にドット.が含まれている場合、二重引用符で囲む必要があります。

Select句でカラムを指定した画面

WHEREORDER BYも書けちゃいます。

WHERE句とORDER BY句を追加した画面

この辺はもう完全にDuckDBの領域っぽいので、DuckDBのドキュメントに書かれてあることはだいたい可能なんじゃないかなと思います。たぶん。 ちなみに、S3じゃなくてローカルのファイルを読み込むこともできます。ただしもちろん、 Grafanaをコンテナ起動した場合はコンテナのファイルシステムがローカルになる(この表現合ってる…?)ので、ホスト側のファイルを読み込ませるにはマウントしたりする必要があります。

所感

ここまでたどり着くのに苦労しました。やたら遠回りしたし、初めてOSSにIssueを上げた後でクローズ済みのIssueで似たようなのがあることに気づいたりして。 ただ、一度構築できてしまえば手触りはかなり良い感じです。 機能不足を感じる部分もありますが、まだまだこれからも改善されていくことを期待しています。Issue上げてOSSコントリビュートデビューしたことだし、自分でそういうのを提案していくのもありかも。 でもその前に、GrafanaやDuckDBの学習過程をかなりすっ飛ばしているのでそっちをやらねば。

あと今回は雑に13KB程度のログファイルを読み込んだのですが、ログファイルが大きくなったときにどの程度メモリを食うのかは気になります。 まあそんなに大きくならないようにログローテーションを組みなさい、という話ではありますが。

まとめ

grafana-duckdb-datasourceを使ってS3のログファイルを参照する方法をご紹介しました。 今回は単に動かしてみたかっただけなので雑にバインドマウントしたりしましたが、真面目に運用するのであれば細部は適宜検討する必要があるでしょう。

InputStreamを巻き戻す

JavaでInputStreamから読み取ったデータをInputStreamに返したい、読み取ったのをなかったことにしてほしい、というときがたまにありますよね。

ありませんか? ありませんよね。でも私は過去にありました。そんなときどうすればよいでしょうか。

PushbackInputStreamという選択肢

PushbackInputStreamという、そのものズバリって感じの名前がついたクラスがいます。頼もしいですね。

docs.oracle.com

からくりはドキュメントにある通り、読み取ったデータを内部的にバッファリングしているという単純なものです。

では試してみましょう。まず準備として、適当にソケットサーバを作ります。

package heraction;

import java.io.IOException;
import java.net.ServerSocket;

public class Main {
    public static void main(String[] args) throws IOException {
        try (var server = new ServerSocket(9999)) {
            while (true) {
                try (
                        var client = server.accept();
                        var is = client.getInputStream();
                ) {
                    String message = new String(is.readAllBytes());
                    System.out.println(message);
                }
            }
        }
    }
}

9999ポートで待ち受けて、受け取ったデータを文字列変換して標準出力に吐く単純な実装です。動作を確認してみましょう。このコードを動かしておきつつ、なんらかの手段で9999番ポートにメッセージを送ります。ncコマンドでやってみましょう。

% echo "Hello, InputStream" | nc 127.0.0.1 9999 

すると、送ったメッセージがソケットサーバ実行環境のコンソールに出力されます。

8:39:28:  ':pushback-inputstream:heraction.Main.main()' を実行中…

Reusing configuration cache.
> Task :pushback-inputstream:processResources NO-SOURCE
> Task :pushback-inputstream:compileJava UP-TO-DATE
> Task :pushback-inputstream:classes UP-TO-DATE

> Task :pushback-inputstream:heraction.Main.main()
Hello, InputStream

いいですね。では一旦、ソケットサーバを止めてしまいます。無限ループしているので強制終了しましょう。もうちょっと優雅に止めたいですね。

では次に、PushbackInputStreamを使って以下の機能を実装しましょう。

  • メッセージの先頭4バイトが「stop」ならソケットサーバを停止
  • それ以外なら以前のように標準出力に文字列を吐いて継続
package heraction;

import java.io.IOException;
import java.io.PushbackInputStream;
import java.net.ServerSocket;

public class Main {
    public static void main(String[] args) throws IOException {
        try (var server = new ServerSocket(9999)) {
            while (true) {
                try (
                        var client = server.accept();
                        var is = new PushbackInputStream(client.getInputStream(), 4);
                ) {
                    var buffer = new byte[4];
                    is.read(buffer);
                    if ("stop".equalsIgnoreCase(new String(buffer))) {
                        System.out.println("サーバー止めるよ");
                        break;
                    } else {
                        is.unread(buffer);
                    }

                    String message = new String(is.readAllBytes());
                    System.out.println(message);
                }
            }
        }
    }
}

InputStreamをPushbackInputStreamでラップしました。

var is = new PushbackInputStream(client.getInputStream(), 4);

第2引数の4というのは、バッファリングするサイズです。今回は先頭4バイトが「stop」かどうかを識別できればよいので、4を渡しています。

先頭4バイトを読み取ったら、それをStringに変換して「stop」かどうかを判定(ケースは無視)します。 そして「stop」だったら「サーバー止めるよ」と出力してソケットサーバが止まります。なんかちょっとそれっぽいですね(それとは)。 では「stop」(ケース無視)以外の場合の処理はどうなっているでしょうか。

is.unread(buffer);

読み取ったbyte配列をunreadメソッドに渡しています。これが巻き戻す処理です。

では動かしてみましょう。先ほどと同様に、まずソケットサーバを起動しておきます。そして、メッセージを2回、立て続けに送ります。

% echo "Hello, InputStream" | nc 127.0.0.1 9999 
% echo "Stop, InputStream" | nc 127.0.0.1 9999 

すると、コンソール出力はこんな感じになります。

9:07:45:  ':pushback-inputstream:heraction.Main.main()' を実行中…

Reusing configuration cache.
> Task :pushback-inputstream:processResources NO-SOURCE
> Task :pushback-inputstream:compileJava UP-TO-DATE
> Task :pushback-inputstream:classes UP-TO-DATE

> Task :pushback-inputstream:heraction.Main.main()
Hello, InputStream

サーバー止めるよ

BUILD SUCCESSFUL in 19s
2 actionable tasks: 1 executed, 1 up-to-date
Configuration cache entry reused.
9:08:04:  ':pushback-inputstream:heraction.Main.main()' の実行を完了しました。

いいですね。巻き戻っていることが少し分かりづらいかもしれませんが、巻き戻っていなければ、「Hello, InputStream」ではなく「o, InputStream」というメッセージが出力されているはずです。なぜなら、ソケットサーバでは受信したメッセージの先頭4バイトを最初に読み取っているからです。このことは、unreadメソッドの行をコメントアウトして動かすことで確認できます。

ちなみに、unreadメソッドの実装はこんな感じになっています。

    public void unread(byte[] b) throws IOException {
        unread(b, 0, b.length);
    }
    public void unread(byte[] b, int off, int len) throws IOException {
        ensureOpen();
        if (len > pos) {
            throw new IOException("Push back buffer is full");
        }
        pos -= len;
        System.arraycopy(b, off, buf, pos, len);
    }

なんか全然違うバイト列を渡すこともできそうですね。実際できるので、興味があればお手元で試してみてください。

BufferedInputStreamという選択肢

「PushbackInputStreamが内部的にバッファリングしているだけなら、BufferedInputStreamでも同じようなことができていいのでは?」と考えるのは自然ですね。はい、できます。

package heraction;

import java.io.BufferedInputStream;
import java.io.IOException;
import java.net.ServerSocket;

public class Main {
    public static void main(String[] args) throws IOException {
        try (var server = new ServerSocket(9999)) {
            while (true) {
                try (
                        var client = server.accept();
                        var is = new BufferedInputStream(client.getInputStream(), 4);
                ) {
                    is.mark(4);
                    var buffer = new byte[4];
                    is.read(buffer);
                    if ("stop".equalsIgnoreCase(new String(buffer))) {
                        System.out.println("サーバー止めるよ");
                        break;
                    } else {
                        is.reset();
                    }

                    String message = new String(is.readAllBytes());
                    System.out.println(message);
                }
            }
        }
    }
}

BufferedInputStreamの場合、markメソッドでマーキングができます。

is.mark(4);

そして、resetメソッドでマーキング箇所まで入力ストリームの読み取り位置を巻き戻すことができます。

is.reset();

なんか、こっちの方が巻き戻ってる感がありますね。また内部実装的に、PushbackInputStreamがコピーをしているのに対してBufferedInputStreamはポジションを戻しているだけなので、性能面では後者の方が優れていそうです。ほぼ無視できる差だとは思いますが。

まとめ

InputStreamから読み取ったデータをInputStreamに返したいときは、PushbackInputStreamやBufferedInputStreamが使えるという話でした。

初めまして

IT系の技術ブログを書きたいなーという思いは、以前から自分の中で波を打っていました。 が、マサカリ怖いし三日坊主だしというマイナスの理由が天井になって、踏み出せずにいました。 が、気まぐれに始めてみてもいいかな、という気持ちになんとなくなりました。 ので始めます。技術系に限らず飼い猫のこととかも書くかもです。ゆるくやっていきたいです。