Plan 9とGo言語のブログ

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

ベル研Plan 9を9legacyの安定版にアップデートする

従来のPlan 9公式サイトplan9.bell-labs.com/plan9は2020年現在、更新が停止しておりアクセスができません。代わりに、9p.io/plan9で最終更新日である2015年1月時点のデータがミラーされていて、Wikiやcontribソースコードなどベル研公式サイトが提供していたほとんどのリソースにアクセスできる状態になっています。

いまPlan 9をインストールするなら、

  • 9frontをインストールする
  • 9legacyをインストールする

のどちらかが無難ですが、9p.ioでは当然インストーラもミラーされているので、ベル研によって最後にリリースされたCDまたはUSBディスクイメージを9p.ioからダウンロードしてインストール可能です。とはいえ2015年のイメージなので、TLS 1.2に対応していないなど、色々錆び付いていてそのままでは使い物になりません。この記事と次の記事で、

  • ベル研Plan 9から9legacyの安定版に更新する
  • Gitを使って9legacyの更新を追う
  • (ついでに)Google Compute Engine用のディスクイメージを作る

の3点について、個人的にどうやっているのか紹介します。長くなったのでこの記事では安定版へアップデートするところまで。

ベル研Plan 9のインストール

まずはベル研Plan 9のインストールが必要なのでQEMUにインストールしましょう。この記事では最終的にCompute Engineイメージを作るためQEMUを使いますが、作らないのであればQEMU以外でも構いません。*1

ところで、QEMUには多くのオプションがあり、virtioを使う場合などは非常に長いコマンドラインになってしまうので簡単なwrapperを用意しました。以下の説明ではwrapperと生のオプションを併記しますが、だいぶシンプルになっていると思うので良ければどうぞ。

github.com

イメージ作成とインストール

まずはディスクを作ります。Compute Engineイメージのためraw形式でディスクイメージを作っていますが、Compute Engineイメージを作らないなら、どの形式でも構いません。

$ qemu-img create disk0.raw 10G

次に、ベル研Plan 9のCDイメージからブートします。

$ curl https://9p.io/plan9/download/plan9.iso.bz2 | bunzip2 | tar x
$ ./start.bash -d plan9.iso
qemu-system-x86_64 -m 1G -smp 2 \
  -drive file=disk0.raw,format=raw,cache=writethrough,id=hd0,index=0 \
  -device e1000,netdev=ether0 \
  -netdev user,id=ether0,net=10.0.2.0/24 \
  -machine type=pc,accel=hvf \ # ← Linuxならaccel=kvm
  -drive file=plan9.iso,index=2,media=cdrom \
  -boot order=d

この後は通常のPlan 9をインストールする手順なので省略しますが、fossil+ventiではなくfossilのみでフォーマットすると、GNU tarでtar --sparseした際にイメージサイズを小さくできるので、Compute Engineイメージを作る場合はこちらが良いと思います。少々面倒ですが、後からfossil+ventiディスクに移行することは可能です。

インストールが終わったら、あとで使うためにインストール直後のdisk0.rawをバックアップしておきましょう。ここではdisk0-orig.rawとします。

$ cp disk0.raw disk0-orig.raw

9legacyの安定版へアップデート

インストール直後のdisk0.rawから起動します。

$ ./start.bash
qemu-system-x86_64 -m 1G -smp 2 \
  -drive file=disk0.raw,format=raw,cache=writethrough,id=hd0,index=0 \
  -device e1000,netdev=ether0 \
  -netdev user,id=ether0,net=10.0.2.0/24 \
  -machine type=pc,accel=hvf

以下のプロンプトが表示されたら、glendaでログインするとrioと共にウィンドウが表示されると思います。

user[none]: 

グレーの背景で右クリックすると、新しいウィンドウを作成できるので作りましょう。以下の作業は、このウィンドウで行います。

ネットワーク設定

まずはネットワークの設定から。上のqemuコマンドを使っていればDHCPによってIPアドレスを設定できると思います。

# DHCPからIPアドレスを設定(再起動すると消える)
% ip/ipconfig

# DNSサーバのIPアドレスを設定
% cat >>/lib/ndb/local

ipnet=qemu-net ip=10.0.2.0 ipmask=255.255.255.0
    dns=10.0.2.3
^D

# DNSクライアントを起動(再起動すると消える)
% ndb/dns -r

このコマンドが何をしているのかは、以下の記事が少し参考になるかもしれません。

ここまで終われば、少なくともインターネットには出られるはず。

% ip/ping -n3 8.8.8.8

9legacy-toolの準備

9legacyはベル研Plan 9に対するパッチ集なので、PatchesからStableと9k kernelにリストされているパッチをローカルのソースコードに当てていく作業となります。一般的には、

% cd /
% hget http://9legacy.org/9legacy/patch/xxx.diff | ape/patch -p1

という手順なのですが、数が多くて非常に大変だし、頻繁にパッチが適用できず中途半端な状態に陥りがちなので、パッチを管理するためのコマンド群を作成しました。

しかしこの時点では、まだPlan 9ではTLS 1.2が使えないのでGitHubからダウンロードできません。なので少し面倒ですが、u9fsを使ってローカルから渡してしまいましょう。u9fsはローカルで実行します。著者の説明ではlaunchdから起動していますが、面倒なので、ここではplan9portのlisten1で一時的に起動させる方法を取りました。

まずはコンパイル

$ make

u9fsPlan 9から接続する際の認証情報を/etc/u9fs.keyから読み込みます*2。このファイルは上から、パスワード、ユーザ名、認証ドメインを1行ずつ書いたファイルなので作ってしまいましょう。u9fs -A fileオプションを使うと、/etc/u9fs.key以外のファイルでも扱えます。

# u9fsが認証するユーザー情報をu9fs.keyに書く; それぞれ値は何でもいい
$ cat u9fs.key
password
username
authdom

できたらu9fsを起動して、Plan 9側からマウントします。

# 9fsポート(564)でlisten
$ listen1 'tcp!*!9fs' ./u9fs -a p9any -A u9fs.key -u $USER -l log

# Plan 9側からu9fsへ接続(ローカルのIPアドレスは192.168.1.3とする)
% 9fs 192.168.1.3
(認証情報を聞かれるのでu9fs.keyに書いた内容を入力する)

% cd /n/192.168.1.3

ファイルのコピーが終わったらu9fsは終了して構いません。

% unmount /n/192.168.1.3

$ pkill u9fs
$ rm u9fs.key log

9legacyパッチを適用

9legacy-toolを使って、手元のソースコードを更新する方法は以下のとおりです。

# パッチを管理するディレクトリを$home/lib/9legacyに作成
% 9legacy/init

# 最新のパッチリストを取得
% 9legacy/update

# システム全体を更新対象のソースツリーとして設定
% echo 'srv -AWP replica' >>/srv/fscons
% mount -c /srv/replica $home/lib/9legacy/plan9

# Stableと9k kernelパッチを適用
% 9legacy/stable >stable.list
% 9legacy/9k >9k.list
% 9legacy/installall -n stable.list 9k.list

# 適用したパッチを確認
% 9legacy/list

# 後始末
% unmount $home/lib/9legacy/plan9
% rm /srv/replica

ソースコードのリビルド

これで手元のソースコードが最新の状態となったので、コマンドやカーネルを更新しましょう。

% cd /sys/src
% mk install

カーネルも更新しておきます。

% cd /sys/src/9
% mk 'CONF=pcf'
% 9fat:
% cp 9pcf /n/9fat/9pcf

% cd /sys/src/9/pcboot
% mk 9load
% cp 9load /n/9fat/9load

ところで9fatはただのFATファイルシステムなんですが、8文字以上のファイル名でも普通に作成できます。だけども9loadから扱えなくなってしまうのでファイル名の長さには注意しましょう。

Virtioを使う

この手順は間違えると起動しなくなるので、必要ならディスクイメージをコピーしておきましょう

Compute Engineで実行する場合、virtio必須なので手元の環境もvirtioを扱うようにしておきましょう。Compute Engineイメージを作らないなら、グラフィックスが使えなくなるなど不都合もあるので、この手順は省略しても構いません。

基本的な手順はvirtioを使うの通りですが、9legacyパッチでカーネルコンフィグにvirtioが追加されているので、デバイス名の変更と再起動だけ行います。ストレージはvirtio-blkとvirtio-scsiの2通り存在していて、QEMUのオプションでどちらを使うか切り替えられますが、Compute Engineに合わせて、この手順ではvirtio-scsiを使います。Plan 9から見ると、virtio-scsiの場合はデバイス名が/dev/sd00のように数字で構成されていて、virtio-blkは/dev/sdF0のようにアルファベットと数字で構成されます。

% 9fat:
% ed /n/9fat/plan9.ini
(bootfile, bootargs, bootdiskエントリのsdC0をsd00に変更)

% cd /tmp
% fossil/conf /dev/sdC0/fossil >fossil.conf
% ed fossil.conf
(fossil.confにあるsdC0をsd00に変更)
% fossil/conf -w /dev/sdC0/fossil fossil.conf
% rm fossil.conf

# Ventiを使っている場合のみ
% venti/conf /dev/sdC0/arenas >venti.conf
% ed venti.conf
(venti.confにあるsdC0をsd00に変更)
% venti/conf -w /dev/sdC0/arenas <venti.conf

virtioに切り替えるとドライバが対応していなくてグラフィックスが使えなくなります。コンソールに流れたログを遡って読んだり、コピペしたりなどができなくなるのはさすがに不便なので、シリアルコンソールを使えるように設定しておきましょう。

% echo 'console=0 b115200 l8 pn s1' >>/n/9fat/plan9.ini

これで再起動します。virtioを使うため、QEMUの起動オプションが変わります。

$ ./start.bash -v
qemu-system-x86_64 -m 1G -nographic -smp 2 \
  -device virtio-scsi-pci,id=scsi \
  -device scsi-hd,drive=hd0 \
  -drive file=disk0.raw,format=raw,cache=writethrough,id=hd0,if=none,index=0 \
  -device virtio-net-pci,netdev=ether0 \
  -netdev user,id=ether0,net=10.0.2.0/24 \
  -machine type=pc,accel=hvf

-nographicオプションを付けて起動したQEMUは閉じるボタンなどはありません。ctl+aに続けてcキーを入力するとQEMUモニタに入れるので、そこでqと入力すれば終了できます。また、おそらく終了した時点でターミナルが壊れているので、tputでリセットしておきましょう。

% fshalt

ctl+a q
(qemu) q
$ tput smam  # 反対のオプションはrmam

ところでLinuxでは、認識した順に/dev/sda, /dev/sdbとデバイス名が決まっていきますが、Plan 9は単純に接続された場所によって決まります。SATAプライマリのスレーブに接続されたディスクは、Plan 9では常に/dev/sdC1ですし、セカンダリのマスタは/dev/sdD0です。後の手順でも少し触れますが、Compute Engineの起動ディスクはSCSIの2番目にあるディスクとして接続されているようで、Plan 9からは/dev/sd01と見えます。

おわりに

ひとまずこれで、現在の9legacy安定版にまで更新できました。virtioを使えるようにできた後で、

$ cp disk0.raw disk.raw
$ gtar -Sczf plan9.tar.gz disk.raw

とするとCompute Engineで使えるディスクイメージを作れますが、とはいえ9legacyはパッチ集であり、同じパッチ名でも更新される時があるので、このままでは9legacyの更新に追従することがとても困難です*3。そこで次の記事では、Gitを使ってアップデートする方法を紹介します。

*1:動くかどうかは知りませんが...少なくともESXiでは動作確認しました

*2:認証を不要とすることも試したけどうまく接続できなかった

*3:パッチは2015年時点のソースに対する差分だけど手元はもう変わっている

Plan 9はファイルをどのように使っているか

Plan 9は、ほとんどの操作をファイルを通して行います。リソースの参照だけではなく、シグナルの送信*1などカーネルに対して命令を送る場合も、ある程度はファイルを通して行えます。初見では、これらの習慣は馴染みがないと思うので、普段使いそうなものをいくつか紹介します。

Plan 9Unixよりeverything is a fileを徹底していると言われていますが、Rob Pike氏によるとeverything has the same interfaceとのことです。ファイルというインターフェイスを通して全てのことを行う様子は、以下の例からも理解してもらえるんじゃないでしょうか。

それでは、まずは簡単なところから。

コピー・ペースト

カーネルが提供する/dev/snarfを読み書きするだけです。read(2)すると、エディタなどでコピーしたデータを読み出せます。write(2)したデータは、エディタなど他のプログラムから読み出せます。

% echo hello world >/dev/snarf
% cat /dev/snarf

CopyではなくSnarfというあまり見ない名前を使っている理由は、データを複製しているわけではないから、だそうです。

プロセス操作

プロセス関連の操作は/proc以下のファイルを扱います。/proc/<pid>/statusでプロセスの状態を確認したり、/proc/<pid>/noteで外部からシグナル(Note)を送ったりできます。

% sleep 100 &
% ps | grep sleep
glenda        43551    0:00   0:00        8K Sleep    sleep
% cat /proc/43551/status

シグナル(Note)を送る場合は/proc/<pid>/noteファイルへ文字列を書き込みます。例えばPlan 9のkill(1)はコマンドを出力するだけで、実際にkillするためにはrc(1)へパイプさせる必要があります。

% kill sleep
echo kill>/proc/43551/note # sleep
% kill sleep  | rc

シグナルと異なり、Noteは文字列なので送る側と受け取る側で合意できていれば、文字列の内容はなんでも構いません。プログラム側では書き込まれた文字列が届くので、テキストをみてどう振る舞うかを決めます。

void
catchnote(void *, char *msg)
{
    if(strstr(msg, "alarm"))
        noted(NCONT);
    else
        noted(NDFLT);
}

void
main(void)
{
    notify(catch);
}

システムが送ってくるNoteはnotify(2)でドキュメント化されています。

プロセスの強制終了

ハンドラの有無に関わらず強制終了させたい場合は/proc/<pid>/ctlkillと書き込みます。これはUnixkill -9に相当します。noteと違って、こちらは決まったコマンドしか受け付けません。

% slay sleep
echo kill>/proc/43532/ctl # sleep
% slay sleep | rc

GUI関連

スクリーンショット

rioが動作しているなら、/dev/screenファイルがrioによって提供されているので、これをread(2)するとその時点のスクリーン全体に相当するビットマップが読めます。

% cp /dev/screen screen.bmp

/dev/wsys以下にはウインドウ毎のファイルが用意されているので、/dev/wsys/<n>/windowを読むと画像を取得できます。

% cat /dev/winid
          2
% lc /dev/wsys
1  2
% cp /dev/wsys/2/window win.bmp

スクロールさせる

rioが提供するターミナル(と呼んでもいいのか定かではないけど)は、コマンドの出力が表示範囲を超えてもデフォルトではスクロールしません。代わりに一定量溢れてしまうとプロセスが一時中断します。マウスの中ボタンメニューからscrollを選んでもいいのですが、/dev/wsys/<n>/wctlscrollと書き込むと、該当するウィンドウではプログラムの出力に従ってスクロールするようになります。

# winidから自身のwindow idが読める
% cat /dev/winid
          2
% echo scroll >/dev/wsys/2/wctl

ネットワーク設定

ネットワーク関連の操作は/net以下を扱います。

IPアドレス設定

自身のIPアドレスを設定する場合は、/net/ipifc/cloneadd ipaddr ipmaskと書き込むことで行います。

% echo add 192.168.1.3 255.255.255.0 >/net/ipifc/clone

静的ルーティングは/net/iprouteadd ipnet ipmask ipgwと書きます。

% echo add 192.168.10.0 255.255.255.0 192.168.1.1 >/net/iproute

設定の確認

/net/ndbを読むと、設定されている情報の一部が読めます。ネットワーク関連のプログラムは、このファイルを読んで必要な情報を得ています。

% cat /net/ndb
ip=192.168.1.3 ipmask=255.255.255.0 ipgw=192.168.1.1
    sys=cpu
    dom=cpu.local.internal
    dns=192.168.1.1
    ntp=192.168.1.1

/net/iprouteからはルーティングテーブルを読めます。

% cat /net/iproute
0.0.0.0         /96  192.168.1.1     4    none   -
192.168.1.0     /120 192.168.1.0     4i   ifc    -
192.168.1.0     /128 192.168.1.0     4b   ifc    -
192.168.1.3     /128 192.168.1.3     4u   ifc    0
192.168.1.255   /128 192.168.1.255   4b   ifc    -
127.0.0.0       /104 127.0.0.0       4i   ifc    -
127.0.0.0       /128 127.0.0.0       4b   ifc    -
127.0.0.1       /128 127.0.0.1       4u   ifc    -
127.255.255.255 /128 127.255.255.255 4b   ifc    -
255.255.255.255 /128 255.255.255.255 4b   ifc    -

/net/ipselftabはホスト自身だと判断するIPアドレスのテーブルです。受信したパケットの宛先がこのテーブルに該当すると、カーネル/net/tcp/net/udp以下のファイルを通してパケットをプロセスへ伝えます。

cpu% cat /net/ipselftab
127.0.0.0                                    01 6b  
192.168.1.0                                  01 6b  
127.0.0.1                                    01 6u  
192.168.1.3                                  01 6u  
127.255.255.255                              01 6b  
255.255.255.255                              02 6b  
192.168.1.255                                01 6b  

ところで普通は、これらの設定はip/ipconfigで行いますが、ip/ipconfigDHCPなどから構成を取得して、以下のファイルを更新しているだけです。上記以外にも、多くの設定やコマンドが用意されているので、興味があればip(3)を読むと参考になるでしょう。また、パケットルーティングに関連するカーネルの動作は以下の記事にも書きました。

ネットワークプログラミング

Plan 9にはdial(2)やannounce(2)など高レベルの関数が用意されていますが、これらはシステムコールではなく、ただのライブラリ関数です。*2

TCPクライアント

どのようにOSがファイルを扱うか見るため、高レベル関数は使わずに直接/net/tcp/net/udpなどのディレクトリを扱って通信を行ってみましょう。まずTCPクライアントは、/net/tcp/cloneconnectコマンドを書き込んで、cloneを開いたまま/net/tcp/<n>/dataを読み書きすることになります。以下のコードはTCPのポート9000へ接続して、サーバからデータを受け取るものです。

#include <u.h>
#include <libc.h>

void
main(void)
{
    int ctl, fd, n, c;
    char buf[1<<10];

    /* ctlを閉じるとコネクションも閉じるので開いたままにする */
    ctl = open("/net/tcp/clone", ORDWR);
    if(ctl < 0)
        sysfatal("open: %r");
    /* 新しく割り当てられたコネクション番号を読む */
    n = read(ctl, buf, sizeof(buf)-1);
    if(n < 0)
        sysfatal("read: %r");
    buf[n] = '\0';
    c = atoi(buf);

    fprint(ctl, "connect 127.1!9000");
    snprint(buf, sizeof buf, "/net/tcp/%d/data", c);
    fd = open(buf, ORDWR);
    if(fd < 0)
        sysfatal("open: %r");
    n = read(fd, buf, sizeof(buf)-1);
    if(n < 0)
        sysfatal("read: %r");
    buf[n] = '\0';
    print("%s", buf);

    close(fd);
    close(ctl);
}

/net/tcp/cloneをopen(2)すると、OSによって新しいコネクションのctl(/net/tcp/<n>/ctl)へリダイレクトされたファイルディスクリプタを得ますが、プログラムはまだコネクション番号を知らないので/net/tcp/<n>/dataをopen(2)できません。コネクション番号はctlを読むことにより取得しています。

TCPサーバ

サーバのコードは、Listenするポート番号をannounceコマンドで登録しておきます。/net/tcp/<n>/listenファイルをopen(2)すると、登録したポート番号へ新しい接続があるまでブロックされて、アクセスがあった時にopen(2)から戻ります。このときクライアント毎に新しくコネクションが作成されるので、listenファイルをopen(2)したファイルディスクリプタからコネクション番号を読んでdataファイルを読み書きします。

#include <u.h>
#include <libc.h>

int netopen(int n, char *name, int mode);
int readint(int fd);

void
main(void)
{
    int ctl, cfd, fd;
    int c, c1;
    char buf[1<<10];

    ctl = open("/net/tcp/clone", ORDWR);
    if(ctl < 0)
        sysfatal("open: %r");
    c = readint(ctl);

    fprint(ctl, "announce 9000");
    for(;;){
        cfd = netopen(c, "listen", OREAD);
        /*
        * クライアントへのコネクション番号を取得する。
        * このコードでは1クライアントしか同時に処理できないので、
        * 本当はfork(2)した方が良いけどサンプルなので手抜き....
        */
        c1 = readint(cfd);
        fd = netopen(c1, "data", OWRITE);
        fprint(fd, "hello world\n");
        close(fd);
        close(cfd);
    }
    close(ctl);
}

int
netopen(int n, char *name, int mode)
{
    int fd;
    char buf[100];

    snprint(buf, sizeof buf, "/net/tcp/%d/%s", n, name);
    fd = open(buf, mode);
    if(fd < 0)
        sysfatal("open: %r");
    return fd;
}

int
readint(int fd)
{
    int n;
    char buf[100];

    n = read(fd, buf, sizeof(buf)-1);
    if(n < 0)
        sysfatal("read: %r");
    buf[n] = '\0';
    return atoi(buf);
}

サーバの方は少し複雑な動きをしますが、やっていることは一般的なファイルの読み書きだけなので、シェルスクリプトなどでネットワークプログラムも書けますし、他ホストの/netをimportして、それを読み書きすると簡易なプロキシのように振る舞えます。また、プログラム開発などでは、/netと同じインターフェイスを9Pファイルサーバとして実装して、bind(1)で差し替えてしまうと、プログラムに何の変更も行わずに通信をモックしたりできますね。これらの特徴はファイルというインターフェイスで統一している強みだと思います。

関連マニュアル

Plan 9では上で紹介したもの以外にも、多くのものをファイル経由で扱います。これらは、多くのものがマニュアルの3章または4章に書かれているので困ったら探してみると良いでしょう。

*1:Plan 9ではNoteと呼ぶ

*2:Plan 9のマニュアル2章はライブラリ関数で、システムコールと区別しません

Plan 9カーネルのIPルーティング

Plan 9カーネルがIPパケットをどうやってルーティングしているか調べた。Plan 9のネットワークプログラミングは少し癖があるので、まずカーネルが提供するファイルの使い方を整理する。

使い方

Plan 9でネットワークプログラミングを行う場合、一般的には/net以下のファイルを読み書きすることになる。例えばIPアドレスを設定する場合、/net/ipifc/cloneをopen(2)してaddコマンドを書き込むことで行う。

% echo add 192.168.1.3 255.255.255.0 >/net/ipifc/clone

追加したIPアドレスごとに、/net/ipifc以下に数字だけのサブディレクトリが作られる。/net/ipifc/0/localファイルを読むと、設定したIPアドレスを取得できる。他にも状態を取得するファイルなどが存在する。

/net/iprouteを読むと、現在のルーティングテーブルが読める。当然だけど上で追加したIPアドレスも含まれる。

% cat /net/iproute
0.0.0.0         /96  192.168.1.1     4    none   -
192.168.1.0     /120 192.168.1.0     4i   ifc    -
192.168.1.0     /128 192.168.1.0     4b   ifc    -
192.168.1.3     /128 192.168.1.3     4u   ifc    0
192.168.1.255   /128 192.168.1.255   4b   ifc    -
127.0.0.0       /104 127.0.0.0       4i   ifc    -
127.0.0.0       /128 127.0.0.0       4b   ifc    -
127.0.0.1       /128 127.0.0.1       4u   ifc    -
127.255.255.255 /128 127.255.255.255 4b   ifc    -
255.255.255.255 /128 255.255.255.255 4b   ifc    -

/net/iprouteaddコマンドをwrite(2)すると、スタティックルートを追加できる。また、デバッグ機能もあって、ルーティングテーブルにrouteコマンドを書くと、宛先へのルートを出力してくれる。

% echo add 192.168.10.0 255.255.255.0 192.168.1.1 >/net/iproute
% echo route 8.8.8.8 >/net/iproute

パケットを送る場合は、/net/udp/clone/net/tcp/cloneをopen(2)すると、openしている間は<n>ディレクトリが作られるので、/net/udp/<n>/ctlに宛先などをwrite(2)して*1/net/udp/<n>/dataに送りたいデータをwrite(2)する。

ソースコードを追う

大まかな使い方をみたので、続けてカーネルではどう扱われているかを調べる。以下では都度ソースコードを引用しているが、これらは必要なところを抜粋しているもので、完全なリストではない。また、ソースコードの場所は特に明記しない限り/sys/src/9/ipからの相対パスを使う。

カーネルの動作を追うにあたって、重要なデータ構造があるので先にそれを眺める。

9Pセッションの扱い

カーネルの中では、9PセッションはおよそChan構造体で管理されている。Plan 9カーネルは9Pクライアントの側面を持ち、同時に9Pサーバでもあるので少し表現が難しい。

struct Chan {
    ulong dev;
    Qid qid;
};

devは、カーネル内部に存在するipfsという配列のインデックスになっている。ipfsはユーザによってattach(5)されたIPプロトコルスタックの配列で、#I0, #I1などIPプロトコルスタック(devipと呼ばれる)を9Pでattachすると、カーネル内部のipfsFs構造体が追加される。この処理はdevip.cipattachが行う。attachが終わると、以降の9Pセッションで使うための、devqidなどが初期化されたChan構造体を得る。

qidはunique idの略で、9Pにおいて特に重要な値となる。qidは9Pサーバ上のファイルを指し、名前の通りファイルサーバ上で一意となる。9Pセッションではqidを使ってopen(5)やread(5)を行うことになる。Unixのinodeと似ているが、qidにはファイルのバージョンも含まれる。

ipfs配列

ipfsはユーザによってattach(5)されたIPプロトコルスタックの配列と書いたが、具体的にどういった値を扱うのか見ていく。

enum {
    Maxproto = 20,
    Nfs = 128
};

struct Proto {
    int x; /* protocol index */
    int ipproto; /* ip protocol type */

    Conv *conv[]; /* array of conversations */
};

struct Fs {
    int dev;
    Proto *p[Maxproto+1];
};

Fs *ipfs[Nfs];

Proto構造体

ipfs#I0などdevipをattach(5)したものなのでFsがそのまま#I<n>に対応するが、Protoとは何か。これは#I0/tcp#I0/udpなど、/netディレクトリ以下にある各種プロトコルが対応する。#I0/ipifcなどPlan 9独自なものも含まれる。Protoのメンバー変数pは、attach(5)した時に初期化され、ユーザ操作によって増減することはおそらくない。

static Chan*
ipattach(char *spec)
{
    Chan *c;

    dev = atoi(spec);
    ipgetfs(dev);
    c = devattach('I', spec);
    mkqid(&c->qid, QID(0, 0, Qtopdir), 0, QTDIR);
    c->dev = dev;
    c->aux = newipaux(...);
    return c;
}

static Fs*
ipgetfs(int dev)
{
    /* ipprotoinitは9/port/mkdevcで生成したCのコードに含まれる */
    /* 実態はudpinit, tcpinitなど各種プロトコルのinit関数 */
    extern void (*ipprotoinit[])(Fs*);

    if(ipfs[dev] == nil){
        f = smalloc(sizeof(Fs));
        ip_init(f);
        arpinit(f);
        ...
        /* プロトコルごとにProtoを初期化してf->pに追加する */
        for(i = 0; ipprotoinit[i]; i++)
            ipprotoinit[i](f);
        ipfs[dev] = f
    }
    return ipfs[dev];
}

Conv構造体

最後に、Protoのメンバー変数にconvというConv構造体の配列がある。これはざっくり言ってしまうと、現在アクティブなコネクションを表し、ユーザ操作によって増減する。

struct Conv {
    int x; /* conversation index */
    Proto *p;

    uchar laddr[IPaddrlen]; /* local IP address */
    uchar raddr[IPaddrlen]; /* remote IP address */
    ushort lport;
    ushort rport;

    Queue *rq;  /* queue data waiting to be read */
    Queue *wq;  /* queue data waiting to be written */
    void *pctl; /* protocol specific stuff */
    Route *r;   /* last route used */
};

例えば/net/udp/cloneをopen(2)したとき、いろいろ経てカーネルipopenに処理が移る。ここでFsprotoclone関数により、新しいConv構造体がProtoconv配列に追加される。

static Chan*
ipopen(Chan *c)
{
    Proto *p;
    Conv *cv;
    Fs *f;

    f = ipfs[c->dev];
    switch(TYPE(c->qid)){
    case Qclone:
        p = f->p[PROTO(c->qid)];
        cv = Fsprotoclone(p, ATTACHER(c));
        mkqid(&c->qid, QID(p->x, cv->x, Qctl), 0, QTFILE);
        break;
    }
    ...
}

以後、このChanが継続して利用される。ここで作られたConvは、close(2)が呼ばれたときにipcloseによりcloseconvが呼ばれて閉じる。

これまで出てきた構造体をユーザから見えるものに当てはめると

構造体 対応するファイル例 メモ
Fs /net, /net.alt 実態は#I0, #I1...
Proto /net/ipifc, /net/udp
Conv /net/udp/0, /net/udp/1

パケットを送出する場合

パケットを送出する場合に、カーネルではどう扱われるのか。

NICIPアドレスを割り当てたとき

/net/ipifc/cloneへの書き込みはipifc.cipifcctlに届く。addコマンドの場合は同ファイルのipifcaddに渡される。

static char*
ipifcctl(Conv* c, char**argv, int argc)
{
    Ipifc *ifc;

    ifc = (Ipifc*)c->ptcl;
    if(strcmp(argv[0], "add") == 0)
        return ipifcadd(ifc, argv, argc, 0, nil);
}

char*
ipifcadd(Ipifc *ifc, char **argv, int argc, 一部省略)
{
    Fs *f;
    Iplifc *lifc;

    f = ifc->conv->p->f;
    ...
    lifc = smalloc(sizeof(Iplifc));
    ipmove(lifc->local, ip);
    ipmove(lifc->mask, mask);
    ...lifcの値を初期化...

    lifc->next = nil;

    ...ifc->lifcの末尾にlifcを追加...

    if(isv4(ip))
        v4addroute(f, "ifc ", rem, mask, rem, Rifc);
    addselfcache(f, ifc, lifc, ip, Runi);
    if(isv4(ip) || ipcmp(ip, IPnoaddr) == 0){
        bcast = (ipとmaskでブロードキャストアドレスを計算)
        addselfcache(f, ifc, lifc, bcast, Rbcast);
        bcast = (ipとmaskでネットワークアドレスを計算)
        addselfcache(f, ifc, lifc, bcast, Rbcast);
        addselfcache(f, ifc, lifc, (255.255.255.255), Rbcast);
    }
}

色々やっているけど、ここでは以下の内容で簡単に覚えておくと良い。

  • 自身のIPアドレスを設定
  • ルーティングテーブルにtype = Rifcとして自身のルートを追加
  • ルーティングテーブルにtype = Rbcastとしてサブネットを追加

ルーティングテーブルにスタティックルートを追加したとき

/net/iprouteにwrite(2)した場合のこと。上では簡単にしか書かなかったので、ここで詳しく読む。

ルーティングテーブルはプロトコルスタックごとに存在するので、Fs構造体にv4rootメンバー変数が用意されている。他にもIPv6の場合はv6rootが用意されているが、基本的には同じなのでここではIPv4だけをみていく。

#define Lroot   10

struct Fs
{
    Route *v4root[1<<Lroot]; /* 1024個のルートツリー */
};

struct Route
{
    RouteTree;

    V4route v4;
};

struct RouteTree
{
    Route *right; // Route.v4と比べて大きいアドレス(it > v4.address)
    Route *left;  // Route.v4と比べて小さいアドレス(it < v4.endaddress)
    Route *mid;   // Route.v4に含む(it >= v4.address && it <= v4.endaddress)
    uchar type;   // Rv4 | Rifc | Rptpt | Runi | Rbcast | Rmulti | Rproxy
    Ipifc *ifc;
    ...
};

struct V4route
{
    ulong address;    // (宛先アドレス&ネットマスク)の先頭アドレス
    ulong endaddress; // (宛先アドレス&ネットマスク)の最終アドレス
    uchar gate[IPv4addrlen]; // nexthop
};

ところで、RouteのメンバーRouteTreeは型名だけ書かれている。Plan 9のCコンパイラは拡張されていて、Goの埋め込みと同じことができるようになっている*2。従って、Route *rという変数があれば、r->leftRouteTreeのメンバー変数leftを扱える。

/net/iprouteadd命令をwrite(2)した場合、文字列はそのままiproute.croutewriteに届く。ここでは書き込まれた文字列をパースして、ネットワークアドレス、ネットマスク、宛先アドレスをv4addrouteに渡す。

long
routewrite(Fs *f, Chan *c, char *p, int n)
{
    Cmdbuf *cb;

    cb = parsecmd(p, n);
    if(strcmp(cb->f[0], "add") == 0){
        parseip(addr, cb->f[1]);
        parseipmask(mask, cb->f[2]);
        parseip(gate, cb->f[3]);
        v4addroute(f, tag, addr+IPv4off, mask+IPv4off, gate+IPv4off, 0);
    }
}

#define    V4H(a)  ((a&0x07ffffff)>>(32-Lroot-5))

void
v4addroute(Fs *f, char *tag, uchar *a, uchar *mask, uchar *gate, int type)
{
    Route *p;
    ulong sa, ea, m;
    int h, eh;

    m = nhgetl(mask);
    sa = nhgetl(a) & m;
    ea = sa | ~m;

    eh = V4H(ea);
    for(h = V4H(sa); h <= eh; h++){
        p = allocroute(Rv4 | type);
        p->v4.address = sa;
        p->v4.endaddress = ea;
        memmove(p->v4.gate, gate, sizeof(p->v4.gate));
        ...
        addnode(f, &f->v4root[h], p);
    }
}

v4rootテーブル

v4addroutev4rootにルート情報を追加する。v4rootは全てのIPv4アドレスを1024分割して管理するテーブルで、IPネットワークアドレスのV4Hを計算して、該当するツリー全てに登録する。例えば0.0.0.0/0のマッチする範囲は0.0.0.0〜255.255.255.255なので全てのツリーに登録される。具体的にV4Hを計算してみると以下のようになる。

V4route r = {
    .address    = 0,
    .endaddress = 0x07ffffff,
};

int sa = V4H(r.address)    => 0
int ea = V4H(r.endaddress) => 1023

一方で192.168.1.0/24は192.168.1.0〜192.168.1.255の範囲なので、

V4route r = {
    .address    = 0xc0a80100,
    .endaddress = 0xc0a801ff,
};

int sa = V4H(r.address)    => a80100 => 84
int ea = V4H(r.endaddress) => a801ff => 84

他にも172.16.0.0/16の場合、

V4route r = {
    .address = ac100000,
    .address = ac10ffff,
};

となって、あとは同じなので省略するがv4rootの決まった位置に割りあげられる。なので上記の2つをルートに追加すると、カーネル内部ではこのようなツリーができる。

v4root[] = {
    [0]    = 0.0.0.0/0
    [1]    = 0.0.0.0/0
    ...
    [84]   = 0.0.0.0/0, 192.168.1.0/24
    ...
    [1023] = 0.0.0.0/0
}

ルートを調べる場合は、宛先アドレスのV4Hを計算して該当するツリーだけ調べれば、ルート情報があれば見つかるし、ルートがなくても必ずデフォルトルートが選び出される。

パケットを送出するとき

/net/udp/<n>/dataに書き込むと9Pに変換されて、最終的にudp.cudpkickへ書き込んだデータが届く。ここでヘッダなどを構築して、ipoput4に渡す。ここまでがUDPの責務で、ipoput4以降がIP層の責務となる。

ipoput4ip.cで実装されている。ipoput4は送出するNICを特定するためv4lookupを呼び出す。これはiproute.cで実装されていて、v4lookupはルートの追加でみたように、宛先アドレスのV4Hを計算して、対応するv4rootのツリーを探索する。

Route*
v4lookup(Fs *f, uchar *a, Conv *c)
{
    Route *p, *q;
    ulong la;
    Ipifc *ifc;

    la = nhgetl(a);
    for(p=f->v4root[V4H(la)]; p;){
        if(la >= p->v4.address){
            if(la <= p->v4.endaddress){
                q = p;
                p = p->mid;
            }else
                p = p->left;
        }else
            p = p->left;
    }
    if(q){
        if(q->type & Rifc)
            gate = (q->v4.addressの値)
        else
            gate = (q->v4.gateの値)
        ifc = findipifc(f, gate, q->type);
        q->ifc = ifc;
    }
    return q;
}

ルートが定まれば(上のコードでif(q)のところ)パケットを送り出すべき宛先(nexthop)が得られるため、nexthopに対応するNICfindipifcで探す。

これ以降もarpなど続くが、ルートを得る処理はここで終わり。Plan 9のIP関連コードのほとんどは/sys/src/9/ip*3にまとまっていて、全部で2万行程度なので読みやすくて非常に良い。

*1:cloneをopen(2)したfdがそのままctlになるので、新しくopen(2)する必要はない

*2:Plan 9の方が先だけど

*3:一部/sys/src/libip/sys/src/9/portにもある

Plan 9を別の環境に移行する

この記事では、手元で動いているPlan 9環境をそのままGCPに移行する手順を紹介します。移行する一連のなかで、

  • Fossilだけで動いている環境にVentiを設定する
  • VentiからFossilを復旧する

といった、日本語だと情報があまりないものを扱います。移行先はGCPを選んでいますが、これはCompute Engineがシリアルコンソールをサポートしていて何かあった場合でも対応しやすいからなだけで、他の環境でもそれほど違いはありません。

ベル研Plan 9のファイルサーバは、3rd editionまで利用されていたKen Thompsonのfs(dumpfs)からFossil+Ventiに移り変わっています。Fossil+Ventiは管理が複雑などの理由から、9frontなどではken fsと同じように扱えるcwfsが人気ですが、複雑なところを差し引いてもFossil+Ventiの方が便利だと思うので個人的にはこちらを使っています。

Ventiはディスクブロックの内容をハッシュ化して、決まった場所に書き込むストレージです。これ自体はただのストレージで、ファイルシステムではありません。FossilはVentiを利用したファイルシステムです。Fossil単体でも動作しますがVentiと連携するように設定しておくと、デフォルトでは1日1回、差分をVentiへ書き込みます。VentiがあればFossilはいつでも再構築できるため、これを使って移行するわけですね。

Ventiについては以下のリンクを参照ください。

移行先Ventiの用意

Ventiディスクの作成

まずVentiのデータを先にGCPへ移行する必要があるので、GCPでディスクを作成しましょう。ここで作るディスクはそのまま移行後のVentiディスクとなります。後から追加も可能ですが管理が煩雑になるので、必要な分のサイズを用意しておいてください。

% gcloud compute --project=$GCP_PROJECT disks create venti \
    --type=pd-standard --size=200G --zone=asia-northeast2-a

% gcloud compute --project=$GCP_PROJECT disks list
NAME   LOCATION           LOCATION_SCOPE  SIZE_GB  TYPE         STATUS
venti  asia-northeast2-a  zone            200      pd-standard  READY

上記では標準のディスクで200GB用意しました。Ventiへのアクセス頻度はそれほど多くないので、ディスクの読み書き速度はそれほど必要ありません。

データのコピー

次に、移行したいホストのVentiから、上記で作成したディスクへデータをコピーします。ここではreadwriteを使う方法で行いますが、コピー方法は以下のエントリにいくつか挙げているので好みの方法を選んでもらって構いません。

また、以下の例では作業用のPlan 9端末(fsという名前)を使っていますが、こちらもplan9portで代用できます。古い記事ですがplan9portでVentiを構築する場合は以下を参考にしてください。

というわけで、GCP上の移行先ディスクをインスタンスに接続して、Ventiをサービスするところまでやりましょう。ファイアウォールventiのポートも通しておきます。

% gcloud compute --project=$GCP_PROJECT instances attach-disk fs \
    --disk=venti --zone=asia-northeast2-a
% gcloud compute --project=$GCP_PROJECT instances start fs \
    --zone=asia-northeast2-a
% gcloud compute --project=$GCP_PROJECT connect-to-serial-port fs \
    --zone=asia-northeast2-a

% gcloud compute --project=$GCP_PROJECT firewall-rules create default-allow-venti \
    --direction=INGRESS --network=default \
    --action=ALLOW --rules=tcp:17034 --source-ranges=0.0.0.0/0

ここからは作業用のPlan 9端末でVentiディスクを初期化します。いくつかパーティションを切っていますが、arenaが実際のファイル内容を保存する場所、isectはindex sectionの略でハッシュ値とデータが保存されているディスクのブロックを特定するための領域、bloomはなくても動きますが、速度改善のためのものです。

# did not find master boot recordエラー対策
term% disk/mbr /dev/sd02/data

term% disk/fdisk -bawp /dev/sd02/data
part plan9 63 419425020

term% disk/prep -bw -a arenas -a isect -a bloom /dev/sd02/plan9
arenas 398453696
isect 19922685
bloom 1048576

これで必要なパーティションが揃いました。

% ls -lp /dev/sd02
--rw-r----- S 0 glenda glenda 204008292352 Mar 12 03:47 arenas
--rw-r----- S 0 glenda glenda    536870912 Mar 12 03:47 bloom
--rw-r--r-- S 0 glenda glenda            0 Mar 12 03:47 ctl
--rw-r----- S 0 glenda glenda 214748364800 Mar 12 03:47 data
--rw-r----- S 0 glenda glenda  10200414720 Mar 12 03:47 isect
--rw-r----- S 0 glenda glenda 214745577984 Mar 12 03:47 plan9
-lrw------- S 0 glenda glenda            0 Mar 12 03:47 raw

Ventiディスクをフォーマットしていきます。

% venti/fmtarenas arenas /dev/sd02/arenas
fmtarenas /dev/sd02/arenas: 380 arenas, 204,007,497,728 bytes storage, 524,288 bytes for index map

% venti/fmtisect isect /dev/sd02/isect
fmtisect /dev/sd02/isect: 1,245,070 buckets of 215 entries, 524,288 bytes for index map

% venti/fmtbloom /dev/sd02/bloom
fmtbloom: using 512MB, 32 hashes/score, best up to 95,443,717 blocks

% cat >venti.conf
index main

isect /dev/sd02/isect
arenas /dev/sd02/arenas
bloom /dev/sd02/bloom
^D

% venti/conf -w /dev/sd02/arenas venti.conf

% venti/fmtindex /dev/sd02/arenas
fmtindex: 380 arenas, 1,244,919 index buckets, 204,001,271,808 bytes storage

上記コマンドのventi/fmtindexは、それぞれ個別にフォーマットしたパーティションをまとめてひとつのVentiで扱うためのコマンドです。また、最後にあるventi/confは設定をarenasパーティションの先頭に埋め込んでいます。venti/ventiは起動する時に、ここから設定を読んでパーティションを扱います。

これで準備ができたので起動しましょう。

% venti/venti -c /dev/sd02/arenas
2020/0818 17:13:14 venti: conf...
venti/venti: bloom filter bigger than mem pcnt; resorting to minimum values (9MB total)
venti/venti: mem 1,048,576 bcmem 2,097,152 icmem 6,291,456...init...icache 6,291,456 bytes = 98,304 entries; 4 scache
sync...queue...announce tcp!*!venti...serving.

ここまで正常に終われば外から接続できるようになっています。

% nc -v <移行先インスタンスのIPアドレス> 17034

readwriteでデータをコピーします。9legacyのスクリプトを移行元にダウンロードして、以下の値を書き換えます。

# 移行元IPアドレス(通常はこのまま)
venti=127.0.0.1

# 移行先IPアドレスとポート番号
host=tcp!<移行先インスタンスのIPアドレス>!17034

これで実行するとVentiのデータをコピーします。移行元Ventiの容量によっては数時間かかるので、途中で切断されないように気をつけてください。

% >info
% rc ./readwrite

終わったら、移行元のvacスコアを使って、移行先Ventiからファイルが読めるか確認します。

% venti=127.1
% echo vac:xxx >score.vac
% vac score.vac
% lc /n/vac

% unmount /n/vac

またはreadwriteの代わりにventi/copyも使えます。この場合はvacスコアから辿れる範囲内しかコピーしませんが、fossilと一緒に運用しているなら一般的には最後のスコアから全て辿れるので十分です。

% fossil/last /dev/sdC0/fossil
vac:xxx

% venti/copy -f localhost:17034 <移行先インスタンスのIPアドレス>:17034 vac:xxx

VentiからFossilの再構築

次に、Ventiの最終スコアを使ってFossilを構築します。これまで使っていたディスクはレスキュー用に残しておいて、代わりに新しいディスクを使うことにします。Fossilは速度が必要なのでpd-ssdで作ります。

% gcloud compute --project=$GCP_PROJECT disks create fossil \
    --type=pd-ssd --size=20GB --zone=asia-northeast2-a
% gcloud compute --project=$GCP_PROJECT instances attach-disk fs \
    --disk=fossil --zone=asia-northeast2-a

これで新しいディスクは/dev/sd03に接続されました。後はこれを初期化していきます。

term% disk/mbr -m /386/mbr /dev/sd03/data
term% disk/fdisk -baw /dev/sd03/data
term% disk/prep -bw -a 9fat -a nvram -a fossil -a cache -a swap /dev/sd03/plan9

term% venti=127.1
term% venti/venti -c /dev/sd02/arenas
term% fossil/flfmt -v xxx /dev/sd03/fossil  # xxxはvacスコアだけどハッシュ値だけ

FossilとVentiの連携

fossil/flfmt -vで初期化したFossilは、起動時にVentiが動いていることを必須とします。そのためFossilのopenコマンドで-Vオプションを使ってはいけません。

# 今は/dev/sd03/fossilだけど最終的にブートディスクとなるので/dev/sd01/fossilとして書き込む
term% cat >fossil.conf
fsys main config /dev/sd01/fossil
fsys main open -c 3000
^D

term% fossil/conf -w /dev/sd03/fossil fossil.conf

また、Fossilよりも前にVentiが起動している必要があります。このためにplan9.iniventi=の追加が必要です。カーネルventi=エントリがplan9.iniに書かれている場合にVentiを起動するようになっています。

bootfile=sdC0!9fat!9pccpuf
bootargs=local!#S/sdC0/fossil
bootdisk=local!#S/sdC0/fossil
venti=#S/sdC0/arenas

# *debugload=1
# *noahciload=1
# *nodumpstack=1
# *noetherprobe=1
# *nousbprobe=1
# [debug]
# baud=9600
# config for initial cd booting
# console=0
# this would disable ether and usb probing.
# very cautions settings to get started.
# will defeat booting from usb devices.
*nobiosload=1
*nomp=1
debugboot=1
dmamode=ask
partition=new
mouseport=ps2
monitor=xga
vgasize=1024x768x32

console=0 b115200 l8 pn s1

また、このディスクはブートディスクとなるのでカーネルやローダなども入れる必要があります。ただし標準配布されているカーネルvirtioが有効になっていません。Compute Engineで動かすためにvirtioを組み込んだカーネルを使っているはずなので、現行のディスクからカーネルなどを新しいディスクに移行します。

term% 9fat:
term% cd /n/9fat
term% disk/format -b /386/pbslba -d -r 2 /dev/sd03/9fat 9load 9pcf plan9.ini

これで、ブートディスクを新しいFossilに変更して再起動すれば以前の環境そのまま移行できます。

トラブル事例

vacスコアを紛失した

arenasが残っていれば/sys/src/cmd/venti/words/dumpvacrootsで取り出せます。Unixの場合はそのままだと動かないので、Windows Azure上でLinuxをventiバックアップ先にするに変更したものを載せています。

Go関連の比較的新しいTips

READMEにpkg.go.devのバッジを貼る

godoc.orgはpkg.go.devに移行していくことが告知されているので、新しいプロジェクトではREADME.mdに貼っているバッジを移行しましょう。pkg.go.devのURLやバッジは

// バッジ
https://pkg.go.dev/badge/<package path>

// リンク
https://pkg.go.dev/<package path>

の形を取ります。例えばgithub.com/lufia/backoffの場合は以下のように書きます。

# Backoff
...summary...

[![GoDev][godev-image]][godev-url]

...description...


[godev-image]: https://pkg.go.dev/badge/github.com/lufia/backoff
[godev-url]: https://pkg.go.dev/github.com/lufia/backoff

pkg.go.devのバージョンを更新する

GitHubなどで新しいタグをpushしても、何もしなければ(少なくとも数日は)モジュールインデックスに反映されません。すぐに更新したい場合、最新バージョンを明記してgo getしておきましょう。

// アクセスすればいいので-dオプションをつけてもいい
% go get github.com/lufia/backoff@v1.3.0

これで数時間後には反映されるはずです。モジュールの動作は以下の記事がとても詳しいので読んでおくとよくわかります。

Goバイナリのランタイムバージョンを調べる

runtime.Versionを使うと、Goでビルドされたコマンド自身は、どのバージョンでビルドされたのかを実行時に調べることができますが、ファイル名を指定して調べる方法は(少なくとも簡単に調べる方法は)提供されていませんでした。

// 自身のランタイムバージョンしか取れない
fmt.Println("Version:", runtime.Version())

Go 1.13から、go versionコマンドに-mオプションが追加されました。このコマンドに実行ファイルを渡すと、どのバージョンでビルドされたのかを調べられるようになりました。モジュールが使われている場合は、モジュールのバージョンも調べられます。また、ディレクトリを渡した場合は、ディレクトリに含まれるたGoバイナリ全てを調べます。

% go version -m ~/bin/act
/Users/lufia/bin/act: go1.14.6
    path    github.com/nektos/act
    mod github.com/nektos/act   v0.2.10   h1:aMSXUGybVyLIqe3ak9GyCtRVpBxwAiSBR5stqas0lj0=
    dep github.com/MichaelTJones/walk   v0.0.0-20161122175330-4748e29d5718 h1:FSsoaa1q4jAaeiAUxf9H0PgFP7eA/UL6c3PdJH+nMN4=
    dep github.com/andreaskoch/go-fswatch   v1.0.0    h1:la8nP/HiaFCxP2IM6NZNUCoxgLWuyNFgH0RligBbnJU=
    dep github.com/containerd/containerd    v1.3.3    h1:LoIzb5y9x5l8VKAlyrbusNPXqBY0+kviRloxFUMFwKc=
    dep github.com/containerd/continuity    v0.0.0-20190426062206-aaeac12a7ffc h1:TP+534wVlf61smEIq1nwLLAjQVEK2EADoW3CX9AuT+8=
    dep github.com/docker/cli   v0.0.0-20190822175708-578ab52ece34 h1:H/dVI9lW9zuagcDsmBz2cj8E8paBX5FarjO7oQCpbVA=
    dep github.com/docker/distribution  v2.7.1+incompatible   h1:a5mlkVzth6W5A4fOsS3D2EO5BUmsJpcB+cRlLU7cSug=
    dep github.com/docker/docker    v0.0.0-20200229013735-71373c6105e3 h1:hq9QaRK9JJOg7GItpuSSA3MrBoEN3c3llxQappEq9Zo=
...

1.13より前のバージョンでビルドしたコマンドは、ランタイムのバージョンだけならgoversionで調べられます。こちらもgo versionと同様に、ディレクトリを渡せます。

% go get github.com/rsc/goversion
% goversion ~/bin

チャネルのスライスでselectする

動的に増減する複数のチャネルを使って、どれでもいいので送信可能になったチャネルへデータを送る、または受信可能なデータを取り出す動作を実装したい場合にreflect.Selectが使えます。以下の例は受信しか行っていませんが、だいたいの使い方はこんな雰囲気。

package main

import (
    "fmt"
    "math/rand"
    "reflect"
    "time"
)

func main() {
    rand.Seed(time.Now().UnixNano())
    a := make([]chan int, 5)
    for i := 0; i < len(a); i++ {
        a[i] = make(chan int)
    }
    cases := make([]reflect.SelectCase, len(a))
    for i, c := range a {
        cases[i] = reflect.SelectCase{
            Dir:  reflect.SelectRecv,
            Chan: reflect.ValueOf(c),
        }
    }

    const N = 10
    go func() {
        for i := 0; i < N; i++ {
            off := rand.Int() % len(a)
            a[off] <- i
        }
    }()
    for i := 0; i < N; i++ {
        // casesに含まれるチャネルのどれかにデータが届いたらSelectを抜ける
        off, v, ok := reflect.Select(cases)
        fmt.Println(off, v, ok)
    }
}

ところで、Goの前身となる言語にはこういった機能が言語仕様に盛り込まれていたのですが、Goでは意図的に外されたようです。なので回避できるなら使わない方が良いのかもしれません。代わりに、例えばchan chan intのようにチャネルを送受信するチャネルを使うと、同じようなことはできます。

runtime.GOOSとbuild constraintsのどっちを使う

golang-nutsより。

all.bashタイムアウト

Goをソースからコンパイルするときテストを実行しますが、このテストは10分弱でタイムアウトします。マシン性能が悪くタイムアウトしてしまう場合、環境変数GO_TEST_TIMEOUT_SCALEに2を設定すると、タイムアウトが標準の2倍になります。Plan 9でのテストは一部とても遅いものがあるので、この設定が必要です。

presentスライド

Go関連の発表でよく使われる、テキストを書くとスライドにしてくれるpresentというツール*1があるのですが、以前は独特な記法で書く必要がありました。最近この記法が、Markdownに似たものに変更されたようです。

とはいえ、今も古い記法に対応していて、どっちの記法を使うのかは、テキスト中に#(スペースを含む)が1つでもあればMarkdown風の記法が使われます。

ただし、talks.godoc.orgはまだ新しい記法に対応していません。

Contribディレクトリにソースコードを公開する

2020年現在、ユーザが作成したPlan 9関連ソースコードの多くは9p.io/contribでホストされています。9p.ioは元々、ベル研の公式サイトが不安定だった頃に、ドキュメントやWikiが読めなくて困るので善意でミラーを行っていただけでしたが、公式が消滅してからは事実上のオフィシャル扱いになっていますね。

Contribディレクトリ以下には、9p.ioに登録されたユーザごとにサブディレクトリが用意されていて*1、これらの読み込みは匿名ユーザ(none)でも可能ですが、ファイルを書き込むためには当然アカウントが必要になります。しかし9p.ioにアカウントを作る方法は、おそらくどこにも書かれていません。今回、自分のディレクトリを作ってもらったので、メモも兼ねて公開します。

アカウント作成

アカウントを作るためのフォームなどは何も用意されていません。なので9p.ioを管理されている@0introさんに直接メールしましょう。@0introさんのメールアドレスをここで公開するのは穏やかではないので、GitHubや9fans MLなどから調べてください。依頼するときの文章は、この程度で大丈夫でした。

Hi, David.

Please would you create my account (id: lufia) on 9p.io/contrib?

おそらく善意での運用なのでのんびり待ちましょう。自分のアカウントを作ってもらった時は1ヶ月ほど経ってから返信がありました。

認証ドメインの追加

アカウントが作られたら、/lib/ndb/localに認証ドメインを追加します。

authdom=9p.io
  auth=cetus.9grid.fr

これでPlan 9端末の設定は終わりです。

マウントする

次のコマンドで、9p.ioの正式なユーザ権限で9p.io/sourcesを/n/sourcesにマウントできます。

% srv -a 9p.io
% mount /srv/9p.io /n/sources

このコマンドの途中で、今回作ってもらったユーザ名とパスワードの入力が必要です。入力すればいいだけですが、とはいえ接続のたびに毎回パスワードを入力するのは面倒なので、factotumまたはsecstoreに入れておくと良いでしょう。

% echo 'key proto=p9sk1 role=client dom=9p.io user=lufia !password=xxx' >/mnt/factotum/ctl

factotumに入れておくと、認証が必要な時はfactotumが自動で行ってくれるようになるので便利ですね。

contribディレクトリの使い方

現在Plan 9 Wikiへの反映は止まっていますが、他の誰かが見る場所なので、以下のルールは守っておいて損はないと思います。

READMEとINDEX

ユーザディレクトリ直下にREADMEINDEXファイルを置きましょう。READMEContrib indexページの各ユーザごとにあるテキストに使われるもので、作者情報やライセンスなどを書きます。最初16行までが認識されます。INDEXは公式WikiContribページによると、パッケージ名と説明を1行ごとに書き並べるファイルです。

package_name: Description here

package_namecontrib/$USERからのファイル名またはディレクトリ名です。その後にコロンで区切ってパッケージの説明を書きます。例えばディレクトリの内容が

.
|-- patch
|   |-- cpp.diff
|   `-- il.c
`-- git-credential-factotum
    `-- mkfile

の場合、INDEXはこのようになります(通常は英語で書きますがこの記事では雰囲気だけ)。

patch/cpp.diff: Cプリプロセッサのパッチ
patch/il.c: ILをIPv6対応したもの
git-credential-factotum: Git credential helper

コーディングスタイル

公開するソースコードやパッチなどは、どちらもPlan 9コーディング規約を守っておくと良いですね。

contrib/install

fgbさんによって作られた、Contrib以下のツールをインストールするためのスクリプトです。バイナリを公開する時は、これに対応しておくと喜ばれます。実際はreplica(1)のフロントエンドになっているようですね。

contribをGitリポジトリのremoteにする

contribディレクトリはマウントして使うので、通信が遅いことを除けば通常のファイルと何も違いがありません。だいたいこんな雰囲気です。

% cd /n/sources/contrib/lufia/git-credential-factotum
% git init
% cd $home/src
% git clone -l /n/sources/contrib/lufia/git-credential-factotum

% git remote add 9p.io /n/sources/contrib/lufia/git-credential-factotum
% git push 9p.io

*1:古くはplan9.bell-labs.comから引き継いだものもある

OpenTelemetryメトリックにObserverとResourceが追加されました

以前の記事で、OpenTelemetryでメトリックを記録するを書きましたが、現在いくつか変更が入っています。細かい型名やメソッド名が変わったものは除いて、大きめの変更点をまとめました。

Observerの追加

以前までは、メトリックの種類は

  • Measure - 複数の値を記録するもの(例: HTTPハンドラのレイテンシ)
  • Gauge - 最新の値だけ分かればいいもの(例: メモリ利用率)
  • Count - カウンタ(例: GC回数)

の3種類でしたが、ここからGaugeがなくなって、代わりにObserverが追加されました。ドキュメントによると、Gaugeが使われるケースはOSやインフラなどの値を取得することが多く、この処理は(単純な計算に比べて)コストが高いので、非同期に行えるようにしたようです。

使い方は今までのものと少し異なり、事前に関数を登録しておきます。

import (
    "runtime"

    "go.opentelemetry.io/otel/api/global"
    "go.opentelemetry.io/otel/api/metric"
    "go.opentelemetry.io/otel/api/unit"
)

meter := global.MeterProvider().Meter("example/ping")
meter.RegisterInt64Observer("runtime.memory.alloc", func(result metric.Int64ObserverResult) {
    var m runtime.MemStats
    runtime.ReadMemStats(&m)
    result.Observe(int64(m.Alloc), labels...)
}, metric.WithUnit(unit.Bytes))

こうしておくと、OpenTelemetryのSDKはCheckpoint*1に到達するたびに、登録しておいた関数を暗黙的に実行して、result.Observeで返した値をExporterへ渡してくれるようになります。result.Observeはラベルが異なれば別の値として扱うため、1つの関数で何回呼び出しても構いません。これらの使い方以外は、Observerは使い方以外はGaugeと同じで、最終値しか取れません。また、登録した関数を解除する方法はありません。

ところで、上で挙げた例のruntime.MemStatsは1回の取得で複数の値を持っています。例えばAlloc, HeapAllocなどメモリの値をそれぞれ別のメトリックで扱いたい場合は、メトリックの数だけReadMemStatsが呼ばれてしまって効率が悪くなります。この点について現在issueが上がっているので、おそらく近いうちにまた変更が入るでしょう。

リソースがラベルから分離

今までは、メトリックにラベルをつける場合、

counter := meter.NewInt64Counter(...)
counter.Add(ctx, 1, labels)

のように、計測するときに全て渡すことしかできませんでした。Bindで設定しておくことは可能ですが、それでも一括で設定しかできませんでした。ですが一般的に、インスタンスIDやホスト名などのラベルは、メトリックにかかわらず全て一定で変化がありません。こういった、リソースを示すためのラベルを一括して設定できるようになりました。

以下はexporters/metric/stdoutの場合ですが、リソースに対応しているExporterは、Exporterの初期化を行う前後になんらかの方法でリソースを渡す方法があるんじゃないかなと思います。

import (
    "go.opentelemetry.io/otel/api/key"
    "go.opentelemetry.io/otel/exporters/metric/stdout"
    "go.opentelemetry.io/otel/sdk/metric/controller/push"
    "go.opentelemetry.io/otel/sdk/resource"
    "go.opentelemetry.io/otel/sdk/resource/resourcekeys"
)

pusher, err := stdout.InstallNewPipeline(stdout.Config{}, push.WithResource(resource.New(
    key.String(resourcekeys.HostKeyID, "1-2-3-4"),
    key.String(resourcekeys.HostKeyName, "localhost"),
)))

今まで通り、メトリックを記録する時にもラベルを設定することができます。リソースまたはメトリックで設定したラベルは、Exporterには

  1. リソースとして扱うラベル
  2. メトリックに紐づくラベル

のように分かれて渡されます。これら2種類のラベルをどのように扱うかはExporterの実装依存となりますが、おそらく多くの実装ではこれらをマージして扱うんじゃないかなと思います。

*1:Exporterがバックエンドに送る周期