Plan 9とGo言語のブログ

主にPlan 9やGo言語の日々気づいたことを書きます。

sd-journalライブラリでsystemdのジャーナルログを読む

systemd の管理するログは /var/log/journal または /run/log/journal 以下に出力されていますが、これらのログは独自のバイナリ形式で保存されているため、プログラムからログを読みたい場合は以下のような手段を経る必要があります。

  1. journalctl -o exportJournal Export Formatとしてログを読む
  2. systemd-journal-gatewayd.serviceを経由してJournal JSON Formatとしてログを読む
  3. Native C API(sd-journal)を使ってログを読む

公式にJournal File Formatというドキュメントでバイナリ形式のフォーマット仕様を読めますが、ドキュメントの最初に

Or, to put this in other words: this low-level document is probably not what you want to use as base of your project. You want our C API instead! And if you really don't want the C API, then you want the Journal Export Format instead! This document is primarily for your entertainment and education.

のように書かれていて、バイナリログを直接読むことは推奨されていません。そこで、この記事では sd-journal というCのライブラリを使ってジャーナルを読む実装を紹介します。ここではCを使っていますが、Goからcgoを使ってもいいですし、他の言語からCを呼び出すときにも参考になるでしょう。

コードのコンパイル方法

Cコンパイラにはリンクするライブラリを与えるフラグがあると思うので、それで libsystemd を指定してください。

$ gcc -lsystemd -o journalread main.c

複数のアーキテクチャ用にクロスコンパイルする場合はGitHub ActionsでC言語のコードをクロスコンパイルするを読んでください。

ログを読む

ファイルと同様に、ジャーナルログを読むときはログを開く手続きが必要です。

#include <systemd/sd-journal.h>

int
main(void)
{
    sd_journal *j;

    if(sd_journal_open(&j, SD_JOURNAL_LOCAL_ONLY|SD_JOURNAL_SYSTEM) < 0)
        fatal("failed to open journal: %m\n");

    /* ここに j を操作してログを読むためのコードを書く */

    sd_journal_close(j);
    return 0;
}

sd_journal_openに渡している SD_JOURNAL_LOCAL_ONLY フラグは /var/log/journal 以下を参照するフラグです。/run/log/journal 以下を参照するための SD_JOURNAL_RUNTIME_ONLY フラグもあります。

もうひとつ、SD_JOURNAL_SYSTEM フラグを渡すとシステムサービスやカーネルのログを対象とします。ユーザーサービスのログを扱うための SD_JOURNAL_CURRENT_USER フラグもあります。journalctl コマンドでいえば、それぞれ --system オプションと --user オプションに相当します。

ログを読み進める

1行ずつログを読むときはsd_journal_nextsd_journal_get_dataを使います。

int rv, e;
char *s, *m;
size_t n;

sd_journal_set_data_threshold(j, 0);
while((rv=sd_journal_next(j)) > 0){
    e = sd_journal_get_data(j, "MESSAGE", (void*)&s, &n);
    if(e == -ENOENT) /* フィールドが無い場合はENOENTが返る */
        continue;
    if(e < 0)
        fatal("failed to get MESSAGE: code=%d\n", -e);

    /* sは "MESSAGE=xxx" のようにフィールド名も含んでいるので8文字飛ばす */
    printf("message = %*s\n", n-8, s+8);
}
if(rv < 0)
    fatal("failed to move next: %m\n");

デフォルト設定の場合、sd_journal_get_data は64KBを越える長さのテキストを途中で切り詰めます。切り詰めるサイズを変更したい場合は sd_journal_set_data_threshold で上限となるサイズを設定します。0を設定すると無制限となります。

あとは sd_journal_next でカーソルを進めて、sd_journal_get_data でカーソル位置のログを読むことになりますが、このとき1つのログには複数のフィールドが存在しています。例えば以下のような用途でフィールドがあります。

  • MESSAGE: ログ出力したメッセージそのもの
  • PRIORITY: 1(alert)、6(info)のようなエラーレベルの数値、0が最高で7が最低
  • UNIT: ログを発行したユニット名
  • _SYSTEMD_UNIT: ログを発行したユニット名

このようなフィールド名を指定して、ログから必要なデータを読んでいきます。このとき、sd_journal_get_data が返したデータは、次の sd_journal_next で上書きされてしまうので、別の場所で参照したい場合は自分でコピーを作らなければいけません。フィールド名は上記の他にもいっぱいあるので、詳細はsystemd.journal-fieldsのマニュアルを見てください。

sd_journal_get_data が返すエラーの種類や詳細はマニュアルに書かれています。

名前がアンダースコア(_)で始まるフィールド

アンダースコアが1つだけの場合、そのフィールドは systemd によって保護されたフィールドです。これらの値はユーザーのコードから変更できません。

アンダースコアが2つ続いている場合、ログのアドレスをシリアライズしたものを意味します。これらのフィールドは、以下で紹介するフィルタの条件には使えません。

色々なUNITフィールド

ところで、上で UNIT_SYSTEMD_UNIT を挙げましたが、多くのsystemサービスでは

_SYSTEMD_UNIT=dbus-broker.service

とだけ設定されるものが多いのですけれど、btrfs-scrub@-.timer の場合は

_SYSTEMD_UNIT=init.scope
UNIT=btrfs-scrub@-.timer

のように2つ設定されます。また、pipewire.service の場合は、

_SYSTEMD_UNIT=user@60331.service
_SYSTEMD_USER_UNIT=pipewire.service

となります。最後に user サービスの gvfs-metadata.service が記録するログエントリは

_SYSTEMD_UNIT=user@60331.service
_SYSTEMD_USER_UNIT=init.scope
USER_UNIT=gvfs-metadata.service

です。欲しいユニット名がどこに出現するかを確認したうえでフィールドを読むことをおすすめします。

カーソル位置を記憶する

カーソルはsd_journal_get_cursorで取得できます。

char *cursor;

if(sd_journal_get_cursor(j, &cursor) < 0)
    fatal("failed to get cursor: %m\n");

ここで取得したカーソルはただの文字列なので、そのままファイルに保存すればいいでしょう。次の実行でカーソル位置まで移動したい場合はsd_journal_seek_cursorで移動します。

if(sd_journal_test_cursor(j, cursor) < 0)
    fatal("invalid cursor: %m\n");
if(sd_journal_seek_cursor(j, cursor) < 0)
    fatal("failed to seek to the cursor: %m\n");

注意点として、カーソルを移動しただけではログエントリの読み込みをしていないので sd_journal_get_data 等が使えません。なので移動した後は必ず sd_journal_next または同等の処理を行いましょう。また、ここで読めるログは「sd_journal_get_cursor を取得した時点のログ」と同じものです。なので「カーソルの次に書かれたログ」を読みたい場合は sd_journal_next が2回必要です。

ログをフィルタする

sd_journal_get_data でフィールドを取得してからプログラム上でフィルタしてもいいのですが、sd-journal にはsd_journal_add_matchというライブラリ側でフィルタしてくれる仕組みが用意されています。

/* エラーの場合は負数を返しますが、ここではエラー処理を省略します */
sd_journal_add_match(j, "SYSLOG_FACILITY=9", 0);
sd_journal_add_match(j, "PRIORITY=5", 0);
sd_journal_add_match(j, "PRIORITY=6", 0);

とてもシンプルな関数なんですが、動作は非常に難解です。この関数を複数回実行した場合は次のルールに従います。

  • 異なるフィールドの条件を与えると、それぞれ AND として結合する
  • 同じフィールドの条件があれば、それぞれを最も高い優先順位の OR で結合する
  • 完全に同じ条件は1つにまとめる

これを上のコードに当てはめると、次のように解釈できます。

SYSLOG_FACILITY=0 AND (PRIORITY=0 OR PRIORITY=1)

任意のOR条件を追加する

ここで sd_journal_add_disjunction を呼び出すと、それまでに構築した式を OR で繋げて、新しい式の構築を開始します。

sd_journal_add_disjunction(j);

結果は次のような式になります。

(
    SYSLOG_FACILITY=9 AND (PRIORITY=5 OR PRIORITY=6)
) OR (
    -- まだ何もないが、以降sd_journal_add_matchするとここに入る
)

SYSLOG_FACILITY=0 を追加してみましょう。

sd_journal_add_match(j, "SYSLOG_FACILITY=0", 0);

結果です。

(
    SYSLOG_FACILITY=9 AND (PRIORITY=5 OR PRIORITY=6)
) OR (
    SYSLOG_FACILITY=0
    -- 次にsd_journal_add_matchすると、ここに新しく式が追加される
)

任意のAND条件を追加する

最後、sd_journal_add_conjunctionAND 条件を追加します。OR よりも優先順位が高いので、より大きな範囲で AND を構築します。

sd_journal_add_conjunction(j);
sd_journal_add_match(j, "_SYSTEMD_UNIT=systemd-timesyncd.service", 0);

最終的なフィルタ式です。

(
    (
        SYSLOG_FACILITY=9 AND (PRIORITY=5 OR PRIORITY=6)
    ) OR (
        SYSLOG_FACILITY=0
    )
) AND (
    (
        _SYSTEMD_UNIT=systemd-timesyncd.service
        -- 次にsd_journal_add_matchすると、ここに新しく式が追加される
    )
    -- 次にsd_journal_disjunctionすると、ここに新しくOR式が追加される
)
-- 次にsd_journal_conjunctionすると、ここに新しくAND式が追加される