Plan 9とGo言語のブログ

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

go getだけでコマンドのバージョンを埋め込む

2022年8月、Go 1.18対応版にアップデートしました

久しぶりのGoネタです。Go 5 Advent Calendar 2020の18日目が空いていたので書きました。

Goで実装されたコマンドでは、ビルドした時点のバージョンを埋め込むため以下のようなMakefileを用意することがあると思います。

.PHONY: build
build:
  go build -ldflags '-X main.Version=$(VERSION)'

しかしこの方法では、go installなどMakefileを経由せずビルドしたバイナリには適切なバージョンが埋め込まれない問題があります。個人的な意見では、可能な限りgo getでインストールできる状態を維持した方が良いと思っていますが、バージョンを埋め込むためには他に方法がないので仕方がないと理解していました。しかしGo 1.19現在、runtime/debug.ReadBuildInfoを使うと、Goモジュールで管理される場合においてはビルド時のバージョンを取れるようになっていて、これを使えば自前でバージョンを埋め込む必要がなくなるので、go installだけで適切なバージョンを表示させることができます。

バージョンの取得

バージョンを取得したい箇所で、以下のように書きましょう。

import (
    "runtime/debug"
)

func Version() string {
    info, ok := debug.ReadBuildInfo()
    if !ok {
        // Goモジュールが無効など
        return "(devel)"
    }
    return info.Main.Version
}

これで、v1.0.0などのような、Goモジュールが認識しているバージョン文字列を取得できます。簡単ですね。

runtime/debug.BuildInfo構造体

Go 1.19時点の定義は以下のようになっていて、mainパッケージのバージョン以外にも、コマンドが依存しているモジュールのimportパスやバージョンも取得できます。ただし、runtime/debug.BuildInfoに含まれるモジュールはコマンドをビルドするために必要なものだけです。例えばテストでのみ参照するモジュールは含まれません。

type BuildInfo struct {
    GoVersion string         // Version of Go that produced this binary.
    Path      string         // The main package path
    Main      Module         // The module containing the main package
    Deps      []*Module      // Module dependencies
    Settings  []BuildSetting // Other information about the build.
}

type Module struct {
    Path    string  // module path
    Version string  // module version
    Sum     string  // checksum
    Replace *Module // replaced by this module
}

type BuildSetting struct {
    // Key and Value describe the build setting.
    // Key must not contain an equals sign, space, tab, or newline.
    // Value must not contain newlines ('\n').
    Key, Value string
}

BuildSettingは、ビルドしたときのフラグやリポジトリの情報を持った値です。Go 1.18時点では以下のような値を保持しています。

// Key=Valueで列挙
-compiler=gc
CGO_ENABLED=1
CGO_CFLAGS=
CGO_CPPFLAGS=
CGO_CXXFLAGS=
CGO_LDFLAGS=
GOARCH=amd64
GOOS=linux
GOAMD64=v1
vcs=git
vcs.revision=ddf71bc3f52c7c221cea82bd26547d0346715f14
vcs.time=2020-12-17T05:50:03Z
vcs.modified=true

従って、コミットハッシュもBuildInfoから参照することが可能です。

どのように動くのか

この方法を導入した場合に、バージョン表記はどうなるのかをいくつかのパターンで見ていきましょう。

普通にgo installした場合

コマンドのimportパスをそのままgo getした場合ですね。この場合は、Goモジュールが認識する最新のバージョンが使われます。

% go install github.com/lufia/go-version-example
go: downloading github.com/lufia/go-version-example v0.0.2
go: github.com/lufia/go-version-example upgrade => v0.0.2
go: downloading github.com/pkg/errors v0.9.1

% go-version-example 
Main = v0.0.2
Deps[github.com/pkg/errors] = v0.9.1

古いバージョンを指定した場合

この場合も、正しくバージョンを取得できます。

% go install github.com/lufia/go-version-example@v0.0.1
go install github.com/lufia/go-version-example@v0.0.1
go: downloading github.com/lufia/go-version-example v0.0.1

% go-version-example 
Main = v0.0.1

ブランチ名などを明記した場合

正規のバージョンよりmainブランチが進んでいる場合に、@mainを明記してgo getした場合はバージョンの後ろに時刻とハッシュ値が付与されます。

% go install github.com/lufia/go-version-example@main
go: downloading github.com/lufia/go-version-example v0.0.2-0.20201217055003-ddf71bc3f52c
go: github.com/lufia/go-version-example main => v0.0.2-0.20201217055003-ddf71bc3f52c

% go-version-example 
Main = v0.0.2-0.20201217055003-ddf71bc3f52c
Deps[github.com/pkg/errors] = v0.9.1

カレントディレクトリがmainモジュールと同じ場合

少し分かりづらいのですが、カレントディレクトリがGoモジュール管理下にあり、そのimportパスとgo getするimportパスが同一の場合。例えば、

// go.mod
module github.com/lufia/go-version-example

を持つリポジトリの中で

% go install github.com/lufia/go-version-example

としてコマンドをビルドした場合、このときは手元でどのようなタグ付けがされていたとしても、mainのバージョンは常に(devel)という文字列が取得されます。Makefileと併用しようとすると手元にはソースコードが一式あるはずなので、この挙動は少し厄介ですね*1

% cd $GOPATH/src/github.com/lufia/go-version-example

% go install github.com/lufia/go-version-example

% go-version-example 
Main = (devel)
Deps[github.com/pkg/errors] = v0.9.1

どうやっているのか

2020-12-18 16:30: 対応フォーマットの話はgo version -mの話だったので書き換えました。

runtime/debug.ReadBuildInfoは何を読んでいるのか、ですが、ビルド時にruntime.modinfoへモジュール情報が埋め込まれていて、これをruntime/debug.BuildInfoへパースしているようですね。雰囲気で読んだ感じだと、埋め込んでいるのはgo/src/cmd/go/internal/work/exec.gobuild()辺りかな。

runtime.modinfoとだいたい同じ値がgo version -mで調べることができます。こっちはGo 1.13以降で対応されました。

% go version -m ~/bin/ivy
/Users/lufia/bin/ivy: go1.14.6
    path    robpike.io/ivy
    mod robpike.io/ivy  v0.0.0-20191204195242-5feaa23cbcf3 h1:tff9UcwX5PKD7Z+Q7O9EIILnQVm5uRY0/xP90+oXtog=

go versionが読む情報は、例えばELFフォーマットのバイナリでは.go.buildinfoという名前のセクションに64KBのデータとして存在します。Mach-Oの場合はセクション名が__go_buildinfoであったりなど若干の差はありますが、どれも同じデータが埋め込まれています。これらの詳細は、go/src/cmd/go/internal/version以下のソースコードを眺めると良いでしょう。

他の実行ファイルに含まれるバージョンを調べたい場合

Go 1.18より、debug/buildinfoパッケージを使うと、go version -mと同様に指定したファイルのバージョンを調べることができるようになりました。それぞれの値は上と同じなのでコードだけ紹介します。

package main

import (
    "debug/buildinfo"
    "flag"
    "fmt"
    "log"
)

func main() {
    log.SetFlags(0)
    flag.Parse()

    for _, arg := range flag.Args() {
        info, err := buildinfo.ReadFile(arg)
        if err != nil {
            log.Fatalln("can't get BuildInfo:", err)
        }
        fmt.Printf("Main = %s (%s)\n", info.Main.Version, info.Main.Sum)
        for _, m := range info.Deps {
            fmt.Printf("Deps[%s] = %s (%s)\n", m.Path, m.Version, m.Sum)
        }
        fmt.Println("Settings:")
        for _, s := range info.Settings {
            fmt.Printf("\t%s=%s\n", s.Key, s.Value)
        }
    }
}

まとめ

runtime/debug.ReadBuildInfoでコマンドに埋め込まれたバージョンを取得できることを紹介しました。多くの場合は、これを使うとgo installだけでも適切なバージョンが取得できると思います。個人的にはgo installでコマンドを導入することが多くあるので、Makefileに依存しないこの方法が広まってくれると嬉しいのですが、Makefileでクロスコンパイルしている場合は容易に(devel)表記となってしまうところは、少し注意が必要かもしれませんね。

*1:一時的にcdでカレントディレクトリを変更すれば回避は可能ですが

dfコマンドはどこからファイルシステムの統計を取得するのか

CPUやメモリの統計は/proc以下のファイルを見れば調べられますが、ファイルシステムの容量などはどうやって取得しているんだろうと気になったでdf(1)のコードを眺めてみました。

ライブラリの動作検証で用意したコードはこちら。

Linuxの場合

Linuxでは、ファイルシステムの情報はstatvfs(3)で取得できるようです。これはstatfs(2)システムコールのラッパーという扱いですが、基本的にはstatvfs(3)を使うように推奨されます。statvfs(3)は以下のように使います。

#include <stdio.h>
#include <string.h>
#include <stdint.h>
#include <inttypes.h>
#include <errno.h>
#include <sys/statvfs.h>

char *argv0;

void fstat(char *);
uintmax_t bused(struct statvfs *);
uintmax_t bsize(struct statvfs *, uintmax_t);

int
main(int argc, char **argv)
{
    int i;

    argv0 = argv[0];
    for(i = 1; i < argc; i++)
        fstat(argv[i]);
    return 0;
}

void
fstat(char *path)
{
    struct statvfs fs;

    if(statvfs(path, &fs) < 0){
        fprintf(stderr, "%s: statvfs: %s\n", argv0, strerror(errno));
        exit(1);
    }
    printf("path %s\n", path);
    printf("block size %lu\n", fs.f_bsize);
    printf("fragment size %lu\n", fs.f_frsize);
    printf("fragments in a block %lu\n", fs.f_bsize/fs.f_frsize);

    printf("used blocks %" PRIuMAX "\n", bsize(&fs, bused(&fs)));
    printf("avail blocks %" PRIuMAX "\n", bsize(&fs, fs.f_bavail));
    printf("total blocks %" PRIuMAX "\n", bsize(&fs, fs.f_blocks));
    printf("used inodes %u\n", fs.f_files - fs.f_ffree);
    printf("free inodes %u\n", fs.f_ffree);
    printf("flags");
    if(fs.f_flag&ST_RDONLY)
        printf(" readonly");
    printf("\n");
    printf("namelen %lu\n", fs.f_namemax);
    printf("\n");
}

uintmax_t
bused(struct statvfs *f)
{
    return f->f_blocks - f->f_bfree;
}

uintmax_t
bsize(struct statvfs *f, uintmax_t n)
{
    return n * f->f_frsize / 1024;
}

実行した結果、df(1)の出力と同じ値になっているので正しそうですね。

$ cc fs_linux.c
$ ./a.out /
path /
block size 4096
fragment size 4096
fragments in a block 1
used blocks 15635224
avail blocks 42476028
total blocks 61252420
used inodes 561671
free inodes 3345913
flags
namelen 255

$ df -P /
Filesystem     1024-blocks      Used Available Capacity Mounted on
overlay           61252420  15635224  42476028      27% /

$ df -i /
Filesystem         Inodes   IUsed      IFree IUse% Mounted on
overlay           3907584  561671    3345913   15% /

マウントされたファイルシステムのリストは、getmntent(3)を使うと取得できます。使い方はこんな雰囲気。

FILE *r;
struct mntent *p;

r = setmntent("/etc/mtab", "r");
while(p = getmntent(r))
    printf("%s\n", p->mnt_dir);
endmntent(r);

setmntent(3)の第1引数にファイルパスを渡すようになっていて、上の例でもそうですがこのファイルパスには度々/etc/mtabが使われます。手元の環境では、/etc/mtab/proc/self/mountinfoへのシンボリックリンクとなっているようでした*1getfsent(3)という似た名前の関数もあるけど、こちらは非推奨のようです。

macOSの場合

macOSでは、getmntent(3)が存在しないので、上記のコードがコンパイルできません。また、statvfs(3)は存在しますが、f_ffreeの値がdf(1)の結果と異なっています。

% ./a.out /    
path /
block size 1048576
fragment size 4096
fragments in a block 256
used blocks 22031496
avail blocks 670026888
total blocks 976490576
used inodes 488433
free inodes 586997151     <- この値だけおかしい
flags readonly
namelen 255

% df /         
Filesystem   512-blocks     Used Available Capacity iused      ifree %iused  Mounted on
/dev/disk1s5  976490576 22031496 670024808     4%  488433 4881964447    0%   /

macOSdf(1)ソースコードを読むと、statvfs(3)ではなくstatfs(2)が使われていて、こちらを使えばdf(1)の出力と同じ値を取得できました。ただし、statfs.f_bsizestatvfs.f_frsize相当の値で、statfs.f_iosizestatvfs.f_bsize相当となっていて、f_bsizeの意味が異なっているところは難しいですね。

struct statfs fs;

if(statfs(path, &fs) < 0){
    fprintf(stderr, "%s: statfs: %s\n", argv0, strerror(errno));
    exit(1);
}
printf("mounted on %s\n", fs.f_mntonname);
printf("fstype %s\n", fs.f_fstypename);
printf("block size %u\n", fs.f_iosize);
printf("fragment size %u\n", fs.f_bsize);
printf("fragments in a block %u\n", fs.f_iosize/fs.f_bsize);
...

これでdfと同じ値を読むことができました。

% ./a.out /
mounted on /
fstype apfs
block size 1048576
fragment size 4096
fragments in a block 256
used blocks 22031496
avail blocks 670031712
total blocks 976490576
used inodes 488433
free inodes 4881964447
flags readonly

% df /
Filesystem   512-blocks     Used Available Capacity iused      ifree %iused  Mounted on
/dev/disk1s5  976490576 22031496 670031712     4%  488433 4881964447    0%   /

最後にmacOSでマウントしているファイルシステムの列挙は、getmntinfo(3)で取れます。

struct statfs *f, *p, *e;
int n;

n = getmntinfo(&f, MNT_NOWAIT);
if(n < 0){
    fprintf(stderr, "%s: getmntinfo: %s\n", argv0, strerror(errno));
    return;
}
e = f+n;
for(p = f; p < e; p++){
    printf("mounted on %s\n", p->f_mntonname);
    ...
}
free(f); /* mallocされるのでfreeが必要 */

getmntinfo(3)の代わりに、もっと低レベルなgetfsstat(2)を使っても実現できます。

/* 最初の引数にNULLを渡すとマウントされたファイルシステムの数を調べられる */
n = getfsstat(NULL, 0, MNT_NOWAIT);
if(n < 0){
    fprintf(stderr, "%s: getfsstat: %s\n", argv0, strerror(errno));
    return;
}
f = malloc(sizeof(*f)*n);
if(f == NULL){
    fprintf(stderr, "%s: malloc: %s\n", argv0, strerror(errno));
    return;
}
if(getfsstat(f, sizeof(*f)*n, MNT_NOWAIT) < 0){
    fprintf(stderr, "%s: getfsstat: %s\n", argv0, strerror(errno));
    return;
}

他OSの場合

手元にないので実験していませんが、coreutilsのコードを読む限りは大変そうな気配がありました。

*1:手元とは言ったけど他もだいたい同じかな

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

# 以下のコマンドでは追記しているけど、既にあれば値を書き換える方が良い
% echo 'monitor=vesa' >>/n/9fat/plan9.ini
% echo 'console=0 b115200 l8 pn s1' >>/n/9fat/plan9.ini

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

$ ./start.bash -v
qemu-system-x86_64 -m 1G -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を使ってアップデートする方法を紹介します。

blog.lufia.org

*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
fsys main snaptime -s 60 -a 0500 -t 2880
^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はまだ新しい記法に対応していません。