Plan 9とGo言語のブログ

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

明日から松山でRuby Kaigi 2025が開催されるので地元の好きな食べ物紹介する

Ruby Kaigi 2025が開催されるようです。

rubykaigi.org

自分自身は過去に社内ツールとしてRedmineやGitlabを運用したくらいしかRubyとの関わりはありませんが、せっかく松山で開催される*1ので他県の人にもおすすめできる食べ物を紹介しようかなと思いました。

私自身は松山よりももう少し南の出身なので宇和島由来のものが多いですが、松山でも食べられます。

鯛めし(宇和島風)

鯛めしは、鯛ごと炊き込んだものと、鯛の刺身がご飯の上に乗っている2種類あって、宇和島風は後者の刺身が乗っている方です。

www.jalan.net

確か松山空港の2階にも食べられる店があったと思います。炊き込むのもおいしいけれど個人的にはこっちの方が好きですね。

じゃこ天

何の魚なのかは知らないけれど、なんか魚の身をすりつぶした練り物です。

www.maff.go.jp

味が付いているのでそのまま食べるのが好みですが、醤油をつけても美味しいと思います。だいたい蒲鉾と同じ扱いなので料理に入れてもいいですね。駅でも空港でもだいたい売ってます。

どら一(いち)

ハタダという地元の企業が展開している和菓子です。最後の「一」は長音記号ではなくて漢数字の1。

www.hatada.co.jp

餡とクリームが入っていて甘そうなんだけど、そこまで甘くなくないので甘いものが苦手という人でなければ食べやすいんじゃないかなと思います。毎年春(5月?)から秋くらいまで販売停止するのだけど、今はまだ売っているのでちょうどいい機会ですね。これも松山空港などで普通に売っています。

3日間のお供にぜひ。

*1:交通事情あまり便利じゃないと思うけどなんで松山なのか全然分かってない

GitHubでコミットの署名を必須にする

先日、比較的広く使われているGitHub Actionsであるtj-actions/changed-filesに不正なコードが混入された問題があった。インシデントの発生した原因は後で詳しい人が書いてくれると思うけれど、少なくとも今(2025-03-16)の理解では、bot用のPersonal Access Token(PAT)が適切に管理されていなかったことによるものらしい。

なので対策としてはPATの管理方法に向くのが筋だとは思うのだが、オープンなPRとその作者のPATがあれば悪意のある変更を入れられるんじゃないか、というのが気になってしまった。例えば過去に何度もコントリビュートしてくれている人のPRに自動生成ファイルが含まれていたとき、その人が作成した repo の権限を持ったPAT*1が運悪く漏洩していたなら、第三者が後からコミットを書き換えられるのではないか。レビューするときに自動生成ファイルも全部見るかというと、疲れているときは読み飛ばすこともあると思う。

現実には、運悪くPATが漏洩することは多くないと思うけれども、起きてからでは遅いので、自分のコミットには署名を必ず付けよう、@lufia のコミットに署名がなければ弾いてほしい、という気持ちで手順を調べた。GPG鍵の運用については以下の記事を参考にした。

準備

まずは gnupg パッケージ(Arch Linuxの場合)が必要になるが、pacman の依存に入っているので普通はインストールされていると思う。

run0 pacman -S gnupg

XDG Base Directoryにこだわりがあるなら、GNUPGHOME を設定して ~/.local/share/gnupg に変更する。この環境変数が未設定の場合は ~/.gnupg にファイルが作られる。

export GNUPGHOME="${XDG_DATA_HOME:-~/.local/share}/gnupg"
mkdir -m 700 -p $GNUPGHOME

鍵を作成するためには gpg-agent が実行されている必要があるのだが、GNUPGHOMEディレクトリを変更すると、エージェントと通信するUNIXドメインソケットのパスが以下のように変わってしまう。

$ gpgconf --list-dirs | grep agent-socket
agent-socket:/run/user/60331/gnupg/S.gpg-agent

$ export GNUPGHOME=~/.local/share}/gnupg
$ gpgconf --list-dirs | grep agent-socket
agent-socket:/run/user/60331/gnupg/d.i11o9nniqjp5zmemejdfxw8f/S.gpg-agent

そうすると、gnupg パッケージが用意してくれている /usr/lib/systemd/user/gpg-agent.socket%t/gnupg/S.gpg-agent へのアクセスを扱う構成なので、GNUPGHOME 変更後のパスと食い違いが生じてしまって意図した動作をしない。なので systemctl edit --usergpg-agent.socket ユニットのパラメータを更新する。

[Socket]
ListenStream=
ListenStream=%t/gnupg/d.i11o9nniqjp5zmemejdfxw8f/S.gpg-agent

GPGのマスターキーとサブキー

GPGにはマスターキーと、マスターキーによって署名されたサブキーというものがある。雑にいえばUnixにおける root と一般ユーザーみたいなもので、普段はサブキーを利用する運用が安全らしい。マスターキーが漏洩してしまうともう何もできることは無いが、この運用なら仮にサブキーが漏洩してもマスターキーは無事なのでサブキーの失効も可能になる。

以下ではマスターキーと、コミット署名用のサブキー1つを作成するための手順を紹介する。

マスターキーの作成

対話モードでは以下のような流れとなる。デフォルトは ECC (sign and encrypt) だけど証明(Certify)のみ必要なので --expert オプションを与えて署名(Sign)を無効化した。それ以外は好きなものを選べば良い。

$ gpg --full-generate-key --expert
Please select what kind of key you want:
   (1) RSA and RSA
   (2) DSA and Elgamal
   (3) DSA (sign only)
   (4) RSA (sign only)
   (7) DSA (set your own capabilities)
   (8) RSA (set your own capabilities)
   (9) ECC (sign and encrypt) *default*
  (10) ECC (sign only)
  (11) ECC (set your own capabilities)
  (13) Existing key
  (14) Existing key from card
Your selection? 11

Possible actions for this ECC key: Sign Certify Authenticate
Current allowed actions: Sign Certify

   (S) Toggle the sign capability
   (A) Toggle the authenticate capability
   (Q) Finished

Your selection? S <--- Signを無効化

Your selection? Q <--- 完了

Please specify how long the key should be valid.
         0 = key does not expire
      <n>  = key expires in n days
      <n>w = key expires in n weeks
      <n>m = key expires in n months
      <n>y = key expires in n years
Key is valid for? (0)

対話モードが不便な場合、gpg --batch オプションを使うと非対話モードで実行することもできるらしい。バッチ処理で必要となるファイルの内容は公式のマニュアルを参照。

鍵のリストを表示

gpg --list-keys コマンドを使う。うまく作れている様子がみえる。

$ gpg --list-keys
/home/lufia/.local/share/gnupg/pubring.kbx
------------------------------------------
pub   ed25519 2025-03-15 [C]
      BF6A9F34814124AE28BD01597C63237ED4C24B72
uid           [ultimate] example <user@example.com>

$ gpg --list-secret-keys
/home/lufia/.local/share/gnupg/pubring.kbx
------------------------------------------
sec   ed25519 2025-03-15 [C]
      BF6A9F34814124AE28BD01597C63237ED4C24B72
uid           [ultimate] example <user@example.com>

コマンド実行結果の BF6A9... がマスターキーのフィンガープリント(指紋)と呼ばれる。また、[C] と書かれている部分は鍵が持っている役割を表している。C 以外には以下のフラグが存在する。

  • S: Signing (署名)
  • C: Certify (証明)
  • E: Encrypt (暗号化)
  • A: Authenticate (認証)

上記の例は --full-generate-key のとき証明(Certify)のみを選んだのでマスターキーの秘密鍵と公開鍵の2つしか作られていないが、暗号化(Encrypt)を選んだ場合は追加で暗号化(E)の機能を持ったサブキーも作られる。

マスターキーをバックアップ

普段使う環境に置いておくと紛失した場合に困るので、マスターキーは安全な場所に置いておき必要なときにだけ取り出したい。そこでまずはマスターキーの秘密鍵をバックアップする。--armor オプションはテキスト形式で出力する。

gpg --armor --export-secret-keys -o secret-key 'user@example.com'

コマンドが完了すると secret-keyPGP PRIVATE KEY BLOCK ヘッダを持つ秘密鍵が出力されるので、これは物理的に安全な場所へ保管する。

参考: --export-secret-keys オプションはマスターキーだけではなく、サブキーの秘密鍵も全てエクスポートする。上から順に実行している場合は、この時点ではマスターキーしかないので問題ないけれど、他の鍵がある状態でマスターキーの秘密鍵だけバックアップしたい場合は困る。その場合はフィンガープリントの末尾に ! を付けて実行すればいい*2

gpg -a --export-secret-keys BF6A9F34814124AE28BD01597C63237ED4C24B72!

Git署名用サブキーの追加

次に、コミットの署名をするためのサブキーを追加する。サブキーの追加は gpg --edit-key を使う。--edit-key はユーザーIDを要求するが、ユーザーIDはArchWikiのGnuPGによると、鍵のフィンガープリント、メールアドレス、名前の一部などが使えて、結果的に鍵が一意に特定できればなんでもいいらしい。試した限りでは大文字小文字も区別しない。

$ gpg --edit-key 'user@example.com' --expert

gpg> addkey
Please select what kind of key you want:
   (3) DSA (sign only)
   (4) RSA (sign only)
   (5) Elgamal (encrypt only)
   (6) RSA (encrypt only)
  (10) ECC (sign only)
  (12) ECC (encrypt only)
  (14) Existing key from card
Your selection? 10
Please select which elliptic curve you want:
   (1) Curve 25519 *default*
   (4) NIST P-384
   (6) Brainpool P-256
Your selection? 1
Please specify how long the key should be valid.
         0 = key does not expire
      <n>  = key expires in n days
      <n>w = key expires in n weeks
      <n>m = key expires in n months
      <n>y = key expires in n years
Key is valid for? (0) 1y
Key expires at Mon Mar 16 01:45:11 2026 JST
Is this correct? (y/N) y
Really create? (y/N) y

最後に save を忘れると保存されない。逆に中止したい場合は quit とする。

gpg> save

マスターキーを普段の環境から取り除く

マスターキーを削除する方法はいくつか存在するようだったが、簡単そう*3なのでKeygripを使う方法で取り除く。Keygripは --with-keygrip オプションを付けると出力内容に含まれる。

$ gpg --with-keygrip --list-key BF6A9F34814124AE28BD01597C63237ED4C24B72
pub   ed25519 2025-03-15 [C]
      BF6A9F34814124AE28BD01597C63237ED4C24B72
      Keygrip = 1D3FDCE6C25A225890E03665FA3EBEFE927D3337
uid           [ultimate] example <user@example.com>
sub   ed25519 2025-03-15 [S] [expires: 2027-03-15]
      Keygrip = 312EB9F5918CF8CDD8EAA1A0CDFCA906D1B67037

マスターキーのKeygripが分かったら、Keygripを使って鍵を削除する。

gpg-connect-agent 'DELETE_KEY 1D3FDCE6C25A225890E03665FA3EBEFE927D3337' /bye

これで --list-secret-keyssec# と表示され、利用できない状態となる。公開鍵の方は変わらない。

$ gpg --list-keys
/home/lufia/.local/share/gnupg/pubring.kbx
------------------------------------------
pub   ed25519 2025-03-15 [C]
      BF6A9F34814124AE28BD01597C63237ED4C24B72
uid           [ultimate] example <user@example.com>

$ gpg --list-secret-keys
/home/lufia/.local/share/gnupg/pubring.kbx
------------------------------------------
sec#  ed25519 2025-03-15 [C]
      BF6A9F34814124AE28BD01597C63237ED4C24B72
uid           [ultimate] example <user@example.com>

secpub の意味は次の通り。

  • sec: 秘密鍵(Secret key)
  • ssb: サブキーの秘密鍵(Secret Subkey)
  • pub: 公開鍵(Public key)
  • sub: サブキーの公開鍵(Public Subkey)
  • sec#: 秘密鍵だけど鍵がローカルに存在しない状態

Gitのコミットに署名する

GitでGPGを使って署名するためには、サブキーのIDが必要なので調べる。

$ gpg --list-keys --keyid-format=long
...
sub   ed25519/773E2B27856EDC91 2025-03-15 [S] [expires: 2027-03-15]

ここで表示された 773E2B27856EDC91 はサブキーのフィンガープリントから末尾8バイトだけを取り出したもの*4で、これをGitの設定に追加する。

git config --global user.signingkey 773E2B27856EDC91
git config --global commit.gpgsign true

公開鍵を以下のコマンドで出力してGitHubに登録する。

gpg --armor --export 773E2B27856EDC91

以上でサブキーを使ったコミットへの署名が行われる。

コミットの署名は --show-signature オプションで見れる。

git log --show-signature

GitHubリポジトリでコミットの署名を強制したい

署名されていないコミットを弾きたい場合は、GitHubの保護ブランチ機能に Require signed commits オプションがあるので有効にするといい。これを設定しておくと、未署名のコミットを保護されたブランチにpushしたとき以下のようなエラーで弾かれる。

$ git push
remote: error: GH013: Repository rule violations found for refs/heads/main.
remote: Review all repository rules at https://github.com/lufia/plug/rules?ref=refs%2Fheads%2Fmain
remote: 
remote: - Commits must have verified signatures.
remote:   Found 1 violation:
remote: 
remote:   b254e04b07d138e4bfbe6a9743a7e1941d1be5bc
remote: 
To https://github.com/lufia/plug.git
 ! [remote rejected] main -> main (push declined due to repository rule violations)

同様に、署名のないコミットがプルリクエストに含まれていたときも、以下のようなエラーでマージがブロックされる。

未署名のコミットが混ざっているのでマージがブロックされている様子

*1:HTTPでGitHubにpushしている場合は該当する

*2:付けない場合はマッチする鍵を広く選択するらしい

*3:後から知ったけど gpg --delete-secret-keys の方が簡単かも

*4:フルサイズを表示したければ --with-subkey-fingerprint オプションを使う

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_seek_cursor(j, cursor) < 0)
    fatal("failed to seek to the cursor: %m\n");
if(sd_journal_next(j) < 0)
    fatal("invalid cursor: %m\n");

// カーソル位置に移動できたか調べる:
// >0: カーソル位置に移動した
// =0: カーソルそのものは無かったが近くに移動した
// <0: なんらかのエラーが起きた
if(sd_journal_test_cursor(j, cursor) < 0) // 0の場合は一致していないが近くには移動した
    fatal("invalid 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式が追加される

GitHub ActionsでC言語のコードをクロスコンパイルする

GitHub ActionsではARM64ランナーも公開されつつありますが、ここでは gcc を使ったクロスコンパイルを説明します。この記事ではホスト*1アーキテクチャx86_64、ターゲット*2アーキテクチャarm64 としていますが、他のターゲットでも同様の手順となるでしょう。また、C言語を前提に書いていますが、他の言語でもライブラリをリンクする場合は参考になるんじゃないかなと思います。

aptリポジトリの準備

まずはターゲットとなるアーキテクチャをパッケージ管理システムに追加します。

sudo dpkg --add-architecture arm64

GitHub Actionsのubuntuランナーにはx86パッケージのリポジトリしか設定されていないので、ARMパッケージがあるaptリポジトリのURLを/etc/apt/sources.list.d/arm64.listに設定します。

deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports jammy main restricted
deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports jammy-security main restricted
deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports jammy-updates main restricted

また、GitHub Actionsランナーの /etc/apt/sources.listアーキテクチャを制限していないので、このままだと arm64 パッケージも探してしまって警告が出力されます。どのみちデフォルトのリポジトリarm64 パッケージは用意されていないので、探さないよう arch= オプションを設定しておきます。

sudo sed -i -E '/^deb(-src)? ([^[])/s/ / [arch=amd64,i386] /' /etc/apt/sources.list

sources.list の各項目がどんな意味なのかは以下の記事が分かりやすいと思います。

kujira16.hateblo.jp

コンパイラとライブラリのインストール

ここまで終われば、コンパイラと必要なライブラリをインストールしましょう。

sudo apt update
sudo apt install -y gcc-aarch64-linux-gnu
sudo apt install -y libsystemd-dev libsystemd-dev:arm64 # libsystemdをリンクしたい場合の例

このとき、 gcc では aarch64 の部分がターゲーットのアーキテクチャ名になります。また、リンクするライブラリはパッケージ名の後ろに :arm64 のようにアーキテクチャ名を追加します。

arm64とaarch64の関係

ここまでで、 arm64aarch64 といった名称を使いましたが、何が違うのでしょうか。

これらの名前は、aarch64 は命令セットの名前に、arm64 はARMプロセッサの64bitアーキテクチャに由来します。クロスコンパイルする状況においては結局どちらも64bit ARMを意味していますが、歴史的な事情によって使っている名称が異なります。

以下は各システムがどちらの表記を使っているかまとめた表です。せっかくなのでx86の表記も加えてみました。

システム x86 ARM
GCC x86_64 aarch64
Clang x86_64 aarch64 1
GNU x86_64 arm64
Debian/Ubuntu amd64 arm64
RHEL x86_64 aarch64
Plan 9 amd64 arm64
Go amd64 arm64
Windows x64 2 arm64

なので、gccパッケージのターゲット名は aarch64 となっているし、ライブラリのアーキテクチャ名はDebian/Ubuntu命名に沿うので libsystemd:arm64 表記が使われているわけですね*3

ソースコードをビルドする

ビルドするときはターゲット用の gcc を使えばいいだけです。 make を使っている場合は CC 変数にセットします。

make CC=aarch64-linux-gnu-gcc

ライブラリのリンク等は、ターゲット用の gcc がターゲット用のライブラリを探してくれるので、開発者が意識することはありません。

ワークフロー

ここまでのワークフローをまとめます。

steps:
  - name: Add an architecture to install packages
    run: |
      sudo dpkg --add-architecture arm64
      sudo sed -i -E '/^deb(-src)? ([^[])/s/ / [arch=amd64,i386] /' /etc/apt/sources.list

      source /etc/lsb-release
      o="$(mktemp)"
      url='http://ports.ubuntu.com/ubuntu-ports'
      echo "deb [arch=arm64] $url $DISTRIB_CODENAME main restricted" >>"$o"
      echo "deb [arch=arm64] $url $DISTRIB_CODENAME-security main restricted" >>"$o"
      echo "deb [arch=arm64] $url $DISTRIB_CODENAME-updates main restricted" >>"$o"
      sudo install -m 644 "$o" /etc/apt/sources.list.d/arm64.list
  - name: Build sources
    run: |
      sudo apt update
      sudo apt install -y gcc-aarch64-linux-gnu
      sudo apt install -y libsystemd-dev libsystemd-dev:arm64
      make CC=aarch64-linux-gnu-gcc

リポジトリの設定部分はlufia/workflows/.github.actions/setup-multiarchとして複合ワークフローにしておいたので、よければ使ってください。

steps:
    - uses: lufia/workflows/.github/actions/setup-multiarch@v0.5.0
      with:
        arch: arm64

  1. AppleバックエンドのことをARM64と呼んでいたがAArch64に統合された
  2. x86-64 表記もあるが、x64 の方が多いと思う

*1:ソースコードコンパイルする側

*2:ビルドされたバイナリを実行する側

*3:理屈は分かるけど紛らわしいので統一してほしい

Goで関数呼び出しを繋げてパイプライン演算子を再現する

最近、Goで関数呼び出しを無限に繋げる書き方を気に入っています。文字で書いても伝わらないと思うので実例を挙げると、例えばこういう書き方。

repeat(yield)("しか", 1)("のこ", 3)("こし", 1)("たん", 2)

どうやって実現しているのかというと、自身を参照する型を作ればいいだけです。

type Emitter func(s string, n int) Emitter

func repeat(yield func(string) bool) Emitter

完全なコード例は以下のGo Playgroundを見てください。

このような、関数呼び出しを繋げる方法でパイプライン演算子を再現するとどうなるか?と思って試してみた記事です。

パイプラインを作る

パイプライン演算子を使うと、 c(b(a(10))) という呼び出しを 10 |> a |> b |> c のように書けます。左から右に読めるので、処理の流れを追いやすくなりますね。

話は変わって、いま所属している企業では関数型ドメインモデリングの読書会が行われています。この書籍ではパイプライン演算子を多用していますが、Goにはパイプライン演算子がありません。無くてもそれほど困らないものの、パイプライン自体は関数を無限に繋げていくものなので、最初に紹介した方法を使ってパイプラインを実現できないかなと考えました。

// 空文字列ならエラー
func require(s string) (string, error)

// printしてsを返す
func tee(s string) string

// 以下のように書けると嬉しいが、このまま実現はできない
result := pipe("hello world")(require)(tee)(strings.ToUpper)

なんだけど、実際は色々な課題があって上記のようには実現できません。

  • パイプラインの初期値や、計算途中の状態を保存する場所がない
  • 関数の戻り値が関数なので、最後に結果を返す手段がない
  • requirestringerror を返すので型が異なる

試行錯誤の結果、dmmf-go/internal/pipeでは少し不恰好だけど近しいものを実現できました。

result, err := pipe.Value("hello world").Catch(require)(tee)(strings.ToUpper).ValueErr()

以下で、実現のためにやったことの一部を紹介します。

計算の状態を保存する

まず、ここで実装するパイプラインでは関数呼び出しを繋げたいので、パイプライン型の基底型は関数です。

type pipe[T any] func(f func(v T) T) pipe[T]

このように定義すると pipe(f1)(f2) のように連続して呼び出せるのですが、 pipe[T] は関数なので任意の値を持たせることができません。具体的には、pipe[T] 型に パイプラインを識別する情報 が追加できません。そういった制約があるため、計算の状態を残すには「実行時に取れる情報」から決める必要があります。例えば実行時のコールスタックやゴルーチンIDなどが考えられますが、今回は関数ポインタをパイプライン識別に利用しました。

どういうことかというと、一般的に関数ポインタは関数ごとに1つですが、無名関数の場合は記述する毎に作られます。例えば以下の場合、

package main
func main() {
    f1 := func() { ... }
    f2 := func() { ... }
}

このとき、f1f2 は異なる関数ポインタを持ちます。内部的には、無名関数は main.main.func1main.main.func2 として作られるようですね。そしてGoは関数のインライン展開を行うので、以下の例でいえば pipe.Value の呼び出しをインライン展開できれば、関数ポインタをパイプラインの特定に使えます。

// pipe.Valueをインライン展開できれば、p1とp2の関数ポインタは異なるので識別できる
p1 := pipe.Value(10)
p2 := pipe.Value(20)

インライン展開されるためには、複雑な関数ではないことが条件です。

Go 1.22.5では、次のような複雑度ならインライン展開されます。

var states map[uintprt]*state

func Value[T any](v T) pipe[T] {
    s := &state{}
    var f pipe[T]
    f = func(g func(T) T) pipe[T] {
        s.current = g(s.current)
        return f
    }
    addr :=  **(**uintptr)(unsafe.Pointer(&f))
    states[addr] = s
    return f
}

ここで、本当は reflect.Value.Pointer を使いたいけれど、使ってしまうとインライン展開されなかったので、関数ポインタの取得を unsafe.Pointer で行っています。

エラーを扱う

Goでは型にメソッドを実装できるので、関数にメソッドを追加しました。計算の状態を保存できるようになったので、これはすぐに実装できます。

func (p pipe[T]) Catch(f func(T) (T, error)) pipe[T] {
    addr :=  **(**uintptr)(unsafe.Pointer(&p))
    s := states[addr]
    s.current, s.err = f(s.current)
    return p
}

pipe[T]func(f func(T) (T, error)) pipe[T] としてもいいのですが、エラーを常に求められるのも使いづらいなと感じたので、そのようにはしませんでした。

結果を返す

上記と同様に、こちらもメソッドを実装して対応しました。

func (p pipe[T]) ValueErr() (T, error) {
    addr :=  **(**uintptr)(unsafe.Pointer(&p))
    s := states[addr]
    delete(states, addr)
    return s.current, s.err
}

作ってみた感想など

上記の他にも、関数呼び出しを繋げるために色々と工夫をしています。

  • パイプラインの途中でエラーが発生した場合は後続の関数を呼ばない
  • パイプラインをコピーさせないように pipe[T] 型を公開しない
    • インライン展開された場所に依存するので、例えば再帰呼び出しされると関数ポインタが競合する
    • 他の関数引数や戻り値に pipe[T] を使えないのでコピーされるリスクを減らせる
  • 型の変換をするために別の関数を使って行う
    • Go 1.22.5時点ではメソッドに型パラメータを持たせられないので仕方なく

今回、パイプラインを作ってみてどうかでいえば、エラー処理を一箇所にまとめられるのは便利かなと思いました。次のコードは書籍の例ですが、エラーを最後に判定するだけになっていて若干すっきり記述できています。

func PlaceOrder(order *UnvalidatedOrder) {
    var (
        validateOrderConfig    ValidateOrderConfig
        priceOrderConfig       PriceOrderConfig
        acknowledgeOrderConfig AcknowledgeOrderConfig
    )
    p1 := pipe.Value(order)
    p2 := pipe.From(p1, validateOrderConfig.ValidateOrder)
    p3 := pipe.From(p2, priceOrderConfig.PriceOrder)
    p4 := pipe.From(p3, acknowledgeOrderConfig.AcknowledgeOrder)
    v, err := p4.ValueErr()
}

ただし、関数を繋げられる必要はあまりないかもしれません。関数呼び出しを繋げるために不要な制限を持ち込んでしまっているので、普通に構造体を返した方が扱いやすいと思います。まあ試してみた記事の結論としては、これで十分でしょう。

Plan 9とInfernoにおけるtar(1)の変化

小ネタです。以下の記事を読んでいて、

なぜ不要なのかは元記事を読んでもらうといいのだけど、ここではPlan 9ではどうなのか気になったのでtar(1)を調べてみた。ベル研UNIXの子孫なので当然だろうけど、Plan 9のマニュアルでは key の存在がそのまま残っている。

tar key [ file ... ]

The key is a string that contains at most one function letter plus optional modifiers.

なんだけど、そこで終わりではなく、Plan 9から派生したInfernoでは tar(1) コマンドが無くなっていて、代わりにgettar(1)で置き換えられている。他にも puttar(1)lstar(1) があって、それぞれ tar x, tar c, tar t に相当する。もともと、tar(1)crtx はサブコマンドのようなものだと言われていたけど、Infernoで再定義する際にサブコマンドではなく別のコマンドとして整理したのは「一つのことをうまくやる」の現れなのかなと思った。

余談だけど、1つの文字に固有の意味があって、それらを並べて一連の文字列で表現するものはPlan 9にいくつか残っている。例えばパーミッションlarwx もそうだし、以前使われていたファイルサーバ専用カーネルではディスクの構成も1つの文字列で表現していた。具体的には h は(S)ATAディスクを、 wSCSIディスクを意味して、それに続く数字で「どのディスクなのか」を識別する。これを組み合わせて (w1w2w3) なら3つのディスクを単純に連結する意味になるし、[w1w2w3]{w1w2w3}RAID 0RAID 1相当の意味となっていた。初見だとむちゃくちゃ混乱するけど、慣れるとこれはこれで使い易いと思うのですよね。

Goでモンキーパッチするライブラリを作った

Goで単体テストを実装する場合、動的な言語のように「テスト実行中に外部への依存を置き換える」といったことはできません。代わりに、

のように、テスト対象をテスト可能な実装に変更しておき、テストの時は外部への依存をモック等に置き換えて実行する場合が多いのではないかと思います。

個人的な体験でいえば、テスト可能な実装に置き換えていく過程で設計が洗練されていく*1ことは度々あるので、面倒を強制されているというよりは設計を整理するための道具といった捉え方をしているのですが、そうは言っても動的な言語に比べると面倒だなと感じるときは少なからずあります。既存の実装がテスト可能になっておらず、変更するコストが高い場合は特にそうですね。

そんなとき、気軽にモンキーパッチできると嬉しいんじゃないかと思って、テストの時だけ関数を置き換えられるようなライブラリを作りました。

github.com

このライブラリはtenntenn/testtimeにとても影響を受けています。

使い方

試しに標準ライブラリの time.Now を置き換えます。具体的なコードは次のようになります。

import (
    "testing"
    "time"

    "github.lufia/plug"
)

func isLeap() bool {
    now := time.Now()
    return (now.Year() % 4) == 0 // 主題ではないのでうるう年の実装は省略
}

func TestIsLeap(t *testing.T) {
    scope := plug.CurrentScopeFor(t)
    key := plug.Func("time.Now", time.Now)
    plug.Set(scope, key, func() time.Time {
        return time.Date(2024, 5, 10, 11, 0, 0, 0, time.UTC)
    })
    if !isLeap() {
        t.Errorf("2024 is a leap year")
    }
}

plug.Func の第1引数は関数の名前を指定します。理由は後述しますが、これは必ず以下の書式で記述してください。

  • (package-path).(function-name)
  • (package-path).(type-name).(method-name)

例を挙げると math/rand/v2.Nnet/http.Client.Do などです。標準の go doc が受け取る引数と似せていますが、パッケージ名の省略はできません*2

これで、TestIsLeap の中で実行した time.Now は固定で2024年5月10日の時刻を返すようになります。スタックを抜けない限り影響は続くので、isLeap 関数が呼び出す time.Time も固定の値を返します。

テストの実行

テストを実行するときは以下のように実行してください。-overlayオプションが必要です。

go test -overlay <(go run github.com/lufia/plug/cmd/plug@latest)

# 分けて書いてもいい
go run github.com/lufia/plug/cmd/plug@latest >overlay.json
go test -overlay overlay.json

-overlay オプションの詳細は、上で挙げたtenntennさんの記事を読んでもらうと良いのですが、ここでは以下のようなことを実行しています。

  • カレントディレクトリのソースコードから plug.Func を探す
  • plug.Func の第2引数を動的に置き換えできるように書き換える
  • 実行スタックに関連づいたスコープを抜けるまで、plug.Set の第3引数に渡す関数で time.Now を置き換える
  • time.Now を呼び出したとき、実行スタックを遡って直近の time.Now を呼び出し、結果を返す
  • 該当する関数が実行スタック上で置き換えられてなければ本物の結果を返す

plug@latest はカレントディレクトリに plug/ というディレクトリを作成しますが、これは実行するたびに生成するので、不要になったら消しても問題ありません。

サブテストで部分的に置き換える

一部のサブテスト実行中だけ、別の値に置き換えたい場合は、サブテストで同じように書くと実現できます。

func TestIsLeap(t *testing.T) {
    scope := plug.CurrentScopeFor(t)
    key := plug.Func("time.Now", time.Now)
    plug.Set(scope, key, func() time.Time {
        return time.Date(2024, 5, 10, 11, 0, 0, 0, time.UTC)
    })
    t.Run("サブテスト", func(t *testing.T) {
        scope := plug.CurrentScopeFor(t)
        plug.Set(scope, key, func() time.Time { ... })
        // これ以降、サブテストの中では別の値を返す
    })
    // サブテストの外では2024年5月の時刻を返す
}

メソッドを置き換える

メソッドも置き換えできます。以下の例では、net/http.ClientDo メソッドを置き換えているので、http.Get にも影響しています。

func TestHTTPClientGet(t *testing.T) {
    scope := plug.CurrentScopeFor(t)
    key := plug.Func("net/http.Client.Do", (*http.Client)(nil).Do)
    plug.Set(scope, key, func(req *http.Request) (*http.Response, error) {
        return &http.Response{StatusCode: 200}, nil
    })
    resp, _ := http.Get("https://example.com")
}

ジェネリック関数を置き換える

型パラメータのある関数は、型ごとに関数を渡します。

func TestMathRand(t *testing.T) {
    scope := plug.CurrentScopeFor(t)
    key := plug.Func("math/rand/v2.N", rand.N[int])
    plug.Set(scope, key, func(n int) int {
        return 3
    })
    fmt.Println(rand.N[int](10))
}

このとき、 rand.N[int]plug.Set で差し替わった関数が使われますが、 rand.N[int64] は登録していないので本物の実装が使われます。

関数の引数や呼び出し回数を検査する

内部的に呼ばれた回数を持っているので、それを使って期待した通りに呼ばれているかを検査できます。plug.FuncRecorder[T] に渡す構造体のフィールドは、関数引数の名前に対応したものが使われます。このとき、関数引数の名前をブランク指定子(_)にしていると無視します。

func TestRecorder(t *testing.T) {
    scope := plug.CurrentScopeFor(t)
    key := plug.Func("os.Getenv", func(string) string {
        return "dummy"
    })
    var r plug.FuncRecorder[struct {
        Key string `plug:"key"`
    }]
    plug.Set(scope, key, fake).SetRecorder(&r)

    os.Getenv("PATH")
    if r.Count() != 1 {
        t.Errorf("Count = %d; want 1", r.Count())
    }
    if r.At(0).Key != "PATH" {
        t.Errorf("At(0).Key = %s; want PATH", r.At(0).Key)
    }
}

今後の予定

実行のたびに静的解析をして必要なファイルを生成しているので、パッケージが多くなってくると有意に遅くなります。Goツールチェーンとパッケージのバージョンが変わらなければ基本的には生成するファイルも同じものになるので、うまく最適化ができるといいですね。

他にも、ジェネリック型のメソッドに対応したりとか、go build でも使えるようにしたりなど、色々とやりたいことはあります。

捕捉: なぜ文字列のキーを必要としているか

Goでは関数が同一かどうかを比較することができません。ジェネリックでない関数の場合は reflect.ValueOf(os.Getenv).Pointer() を経由することで比較できますし、Linux/AMD64の場合はだいたい期待通りに動きますが reflect.Value.Pointer のドキュメントには以下のように書かれています。

If v's Kind is Func, the returned pointer is an underlying code pointer, but not necessarily enough to identify a single function uniquely. The only guarantee is that the result is zero if and only if v is a nil func Value.

さらにジェネリック関数では、型パラメータごとに異なる関数ポインタが割り当てられるようで、 reflect.Value.Pointer での比較にも失敗します。

func N[T any](n T) {}

func N1[T any](n T) func(T) {
    return N[T]
}

func N2[T any](n T) func(T) {
    return N[T]
}

func main() {
    fmt.Println(reflect.ValueOf(N[int]).Pointer() == reflect.ValueOf(N[int]).Pointer())   // true
    fmt.Println(reflect.ValueOf(N1[int]).Pointer() == reflect.ValueOf(N[int]).Pointer())  // false
    fmt.Println(reflect.ValueOf(N2[int]).Pointer() == reflect.ValueOf(N2[int]).Pointer()) // true
}

runtime.FuncForPC なども含めて色々と試してみたけれど、Go 1.22時点では良い方法がなかったので、今の形に落ち着きました。

*1:テストコードと同様にドキュメントを書いているときにもよく起きる

*2:go docは http.Client と記述すると推測してくれる