Plan 9とGo言語のブログ

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

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がバックエンドに送る周期

Git credentialプロトコルの脆弱性(CVE-2020-5260)を眺めた

Gitは認証の必要なリポジトリへアクセスするとき、ユーザ名とパスワード入力を必要としますが、アクセスするたび入力するのは煩わしいので、資格情報を記憶するために、git-credential-から始まる名前のカスタムヘルパーコマンドが存在します。これらカスタムヘルパーはGitから標準入出力を扱う普通のコマンドで、標準入力経由でGitから渡されたパラメータに従って、対応するパスワードを標準出力を介してGitへ返します。ヘルパーコマンドが呼ばれるルールはgitcredentials(7)に書かれています。

これらのヘルパーコマンドとGitの間で使われるプロトコルがGit credentialプロトコルです。プロトコルは1行に1つのキーバリューが書かれるテキストプロトコルで、リクエストは空行で終わります。ヘルパーコマンドは空行までを読み取って、不足しているパラメータ(通常はパスワードだけ)を返します。これらのパラメータはリポジトリのURLから生成されます。以下はGit credentialプロトコルでパスワードを取得するときの例です。

protocol=https
host=github.com
username=lufia

password=xxx

macOSを使っている場合は、以下のようにするとKeychainからパスワードを取り出せます。最後の空行を忘れないようにしましょう。

% `git --exec-path`/git-credential-osxkeychain get
protocol=https
host=github.com
username=lufia

CVE-2020-5260

日本時間だと4/15 3:00頃にGit 2.26.1がリリースされました。このリリースでCVE-2020-5260というGit credentialプロトコル脆弱性が修正されました。

上記でみたように、Git credentialプロトコルは改行文字でパラメータを区切ります。ところでURLはパーセントエンコーディングされた値を一部の場所で使うことができるため、https://host?%0aのように改行文字も与えることができてしまいます。そしてややこしいことに、Gitはcredentialプロトコルで扱うURLのパースを厳密には行っていません。そのため、外部からプロトコルへ介入することができてしまいます。例えば以下の場合、

https://evil.example.com?%0agithub.com/xxx

Git 2.26.0までは、Gitは%0aをデコードしてヘルパーコマンドへ渡すため、

protocol=https
host=evil.example.com
host=github.com

のようにhost=パラメータが2回現れます。同じパラメータが届いた場合は後勝ちなのでhost=github.comのパスワードがGitへ戻されますが、しかしGitが実際に通信するリポジトリのホストはevil.example.comなので、パスワードが漏れてしまって非常にまずいですね、という脆弱性です。

影響

ヘルパーコマンドは、git-remote-httpリポジトリへアクセスするとき、またはgit imap-sendでパッチを送るときに使われるようでした。そのため、影響を受けるプロトコルは現代ではhttpsがほとんどでしょう。ssh経由でアクセスしている場合はgit-remote-httpを使わないので、おそらく影響しないと思われます(間違っていたら指摘ください)。ただし、SourceTreeなどGitクライアントや、場合によってはHomebrewやGoコンパイラなど、裏でGitコマンドを使っている場合も対象となるので注意が必要です。

やるべきこと

脆弱性が修正されたバージョンにGitをアップデートしましょう。Git credentialのヘルパーコマンドを全て無効化する方法もあるようですが、絶対にアップデートする方が簡単です。

パスワードは変更した方が良いのか

信用できるリポジトリしか扱わない前提があれば、パスワードの変更は不要だとは思いますが、少しでも不安なら変更した方が安全だと思います。

Gitの認証情報管理にFactotumを使う

Gitは認証が必要なリポジトリにアクセスするとき、credential helperと呼ばれるコマンドを実行します。credential helperはただの標準入出力を扱うプログラムで、一般的には git-credential-xxx という命名になっています。おそらく以下のうちどれかをよく使うのではないかと思います。

macOSの場合は、デフォルトでgit-credential-osxkeychainが使われるので、自分で設定することは少ないかもしれませんね。helperコマンドはgit configで設定します。

git config --global credential.helper cache

Gitは認証が必要だとわかった時点で、git configで設定したhelperコマンドを実行します。このときコマンドには、以下のような値が標準入力経由で渡されることになります。入力はname=value形式で1行1つ記述されていて、最後に空行で終わります。これらの値は状況によって省略されるものもあります。

protocol=https
host=github.com
path=path/to
username=lufia
password=xxx

helperは入力を空行まで読み込んで、条件に対応する値を標準出力でGitに返す必要があります。基本的にはpasswordだけを返せば動くと思いますが、それ以外の値も返せば、入力に優先して返した値が使われます。protocolなどを個別に返してもいいし、urlとしてまとめても同じです。

protocol=https
host=github.com
path=path/to
username=lufia
password=xxx
url=https://lufia:xxx@github.com/path/to
quit=0

credential helperの詳細はPro Gitに書かれています。

また、呼び出す側の実装はこのファイルです。

標準で持っているhelperはgit-coreディレクトリに入っています。

% git --exec-path
/Library/Developer/CommandLineTools/usr/libexec/git-core

% 9 ls -p /Library/Developer/CommandLineTools/usr/libexec/git-core/git-credential-*
git-credential-cache
git-credential-cache--daemon
git-credential-osxkeychain
git-credential-store

Factotumを使う

上でみたように、credential helperはただの標準入出力を扱うプログラムなので、echoで固定の文字列を返してもいいし、ネットワークからパスワードを持ってくるコマンドを自作することもできます。ところでPlan 9にはfactotumという認証エージェントが存在していて、これはPlan 9ツールをUnixに移植したplan9portでも使えるので、factotumからパスワードを取り出すcredential helperを実装してみました。完全なソースコードはこちらです。

github.com

Factotum

FactotumはSSH秘密鍵やログインパスワードなどを一括して扱うプログラムです。key=valueのリストを管理します。!で始まる属性は秘密情報扱いとなり、表示されません。

% factotum
% echo 'key proto=pass role=client service=git dom=github.com user=lufia !password=xxx' |
> 9p write factotum/ctl
% 9p read factotum/ctl
key proto=pass role=client service=git dom=github.com user=lufia !password?

上記の例ではpassプロトコル(生のパスワードを扱うプロトコル)を使いましたが、これ以外にもp9sk1rsaなどいくつか用意されています。protoの値によって必須となる属性は変わります。passプロトコルではuserpassword属性が必須です。任意の属性はいくつ追加しても構いません。ここでは、Git用のパスワードと識別するためにserviceキーと、対象サービスのドメインdom属性を追加しています。

登録された情報を使う場合、proto=pass role=client user=lufiaのように絞り込むための属性を使ってfactotumへアクセスします。ところで、Plan 9libauthにはproto=passのための関数があるので、それを使えば十分です。

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

...

UserPasswd *up;

up = auth_getuserpasswd(nil, "proto=pass role=client user=lufia");
if(up == nil)
    sysfatal("auth_getuserpasswd: %r");
print("username=%s\n", up->username);
print("password=%s\n", up->passwd);
free(up);

Gitはcredential helperからパスワードを取り出せなかった場合に入力を促して、その内容をcredential helperに保存しようとします。factotumにエントリを登録する場合はfactotum/ctlファイルに書き込めば終わりです。ただし、Plan 9ネイティブの場合は/mntfactotumが提供するファイルをマウントしているので/mnt/factotum/ctlopenすればいいんですが、plan9portの場合は直接参照することができないため、lib9pclientを使う必要があります。

#include <u.h>
#include <libc.h>
#include <thread.h>
#include <9pclient.h>

...

CFsys *fs;
CFid *fid;

fs = nsamount("factotum", nil);
if(fs == nil)
    sysfatal("nsamount: %r");
fid = fsopen(fs, "ctl", OWRITE);
if(fid == nil)
    sysfatal("fsopen: %r");
if(fswrite(fid, "key proto=pass ...", n) < 0)
    sysfatal("fswrite: %r");
fsclose(fid);
fsunmount(fs);

上記ではfsopenを使いましたが、代わりにfsopenfdを使うと、CFidの代わりにファイルディスクリプタを受け取ることができて、以降普通のファイルとして扱えるのでこちらの方が便利かもしれません。

Plan 9ネイティブなfactotumはパスワードを扱うだけあって、swapしないようになっていたり、デバッガの接続を禁止していたりと安全な作りになっていますが、plan9portのfactotumにはそういった仕様は盛り込まれていなさそうです。また、factotum単体ではプロセスが死ぬと記憶していたパスワードは消えてしまうので、永続化したい場合はsecstoredと一緒に使いましょう。

macOSの場合

launchd設定例です。

com.bell-labs.plan9.factotum.plist

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>EnvironmentVariables</key>
    <dict>
        <key>PLAN9</key>
        <string>/usr/local/plan9</string>
        <key>PATH</key>
        <string>/usr/local/plan9/bin</string>
    </dict>
    <key>Label</key>
    <string>com.bell-labs.plan9.factotum</string>
    <key>ProgramArguments</key>
    <array>
        <string>/usr/local/plan9/bin/factotum</string>
    </array>
    <key>RunAtLoad</key>
    <false/>
    <key>KeepAlive</key>
    <dict>
        <key>OtherJobEnabled</key>
        <dict>
            <key>com.bell-labs.plan9.secstored</key>
            <true/>
        </dict>
        <key>SuccessfulExit</key>
        <false/>
    </dict>
</dict>
</plist>

com.bell-labs.plan9.secstored.plist

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>EnvironmentVariables</key>
    <dict>
        <key>PLAN9</key>
        <string>/usr/local/plan9</string>
    </dict>
    <key>Label</key>
    <string>com.bell-labs.plan9.secstored</string>
    <key>ProgramArguments</key>
    <array>
        <string>/usr/local/plan9/bin/secstored</string>
        <string>-v</string>
    </array>
    <key>RunAtLoad</key>
    <true/>
</dict>
</plist>

Go 1.14でシステムコールがEINTRエラーを返すようになった

Go 1.13までのゴルーチンの切り替えは、チャネルの送受信やシステムコール呼び出し、関数呼び出し前にコンパイラが暗黙的に挿入する処理などによって行われていました。そのため、上記の切り替わり操作を全く行わないループなどがあれば、そのゴルーチンがずっと実行されます。

func loop() {
    // この辺りにコンパイラがコード挿入している

    for {
        // 切り替わり処理が行われないので無限に実行される
    }
}

この結果、$GOMAXPROCSが1の場合はプログラムが停止します。コンパイラが挿入するコードは、インライン展開された場合やgo:nosplitディレクティブが記述された場合には行われないので、関数呼び出しをしていてもゴルーチンが切り替わらない場合はあります。

Goのスケジューラについてはこの辺りが詳しいです。

これ自体はGoを学び始めた人がよく引っかかるものですね。

何が問題なのか

このように、Goでは特定の場所でしかゴルーチンの切り替えが行われないため、Non-cooperative goroutine preemptionというプロポーザル*1によると、GCのStopTheWorld時間が長くなったりするそうです。

Go 1.14では、上記のようなコードでも切り替えられるように、ゴルーチンの切り替えがSA_RESTARTフラグ付きのSIGURGによっても行われるようになりました。ざっとコードを眺めた雰囲気だと、SIGURGGCの処理で送っているみたいですね。この結果、Go 1.14のリリースノートには

A consequence of the implementation of preemption is that on Unix systems, including Linux and macOS systems, programs built with Go 1.14 will receive more signals than programs built with earlier releases. This means that programs that use packages like syscall or golang.org/x/sys/unix will see more slow system calls fail with EINTR errors.

と書かれています。システムコール実行中にシグナルを受け取ると、システムコールEINTRを返す場合があるので、syscallgolang.org/x/sys/unixを直接使っている場合は適切なエラーハンドリングしましょう、とのことです。osnetなど、他のパッケージを使っている場合もたぶん同じで、Go 1.13まではエラーにならなかったシステムコールEINTRエラーになるパターンが増えます。

どう対応するべきか

Linuxの場合*2システムコールや呼び出し時のオプションによって、シグナルで中断した場合の動作は以下の2通りに分かれます。

  • シグナルを処理した後で再開される
  • EINTRエラーを返す

どのシステムコールがどんな条件でEINTRを返すのかは、Linuxマニュアルに詳しく書かれていました。

自動でカーネルが再開してくれる場合は、当然ですが今までと同じように動作するため対応不要です。Goランタイムが送るSIGURGにはSA_RESTARTフラグが付いているので、ほとんどは大丈夫そう。EINTRを返すことがあるシステムコールの場合は、そのままエラーを返すかリトライするかを決める必要があるでしょう。とはいえsyscall.EINTRを直接使ってしまうと、他のOSに移植する際にとても困ります*3。Goのsyscall.ErrnoTemporaryメソッドを実装していて、EINTRの場合はtrueを返すので、代わりにこれを使うと良いと思います。

type temporaryer interface {
    Temporary() bool
}

_, err := r.Read(buf)
if err != nil {
    if e, ok := err.(temporaryer); ok {
        fmt.Println(e.Temporary())
    }
}

ただし、close(2)の場合、システムコールから戻った時点でファイルディスクリプタは無効になっており、同じファイルディスクリプタが他のファイルに割り当てられる可能性があるため、リトライしてはいけないようです。

おまけ

Goでファイルの存在確認でも似たようなことを書きましたが、ファイルの書き込みを行う場合、deferを使って、

w, err := os.OpenFile(file, os.O_WRONLY|os.O_APPEND, 0660)
if err != nil {
    return err
}
defer w.Close()

_, err := w.Write(data)
return err

みたいに書いてしまうと、close(2)のエラーが拾えません。その結果、書き込みが失敗しているのに成功扱いしてしまいます。読み込みだけの場合はこれで問題ありませんが、書き込みをしたファイルのCloseは、OSが持っているバッファをディスクに書き込もうとしてエラーになる可能性があるので、きちんとエラーを拾いましょう。

2020-03-02追記

ファイルの場合、os.File.Sync でバッファをフラッシュしておいて、Closeではエラーを発生させないようにするのがベストプラクティスのようです。

2020-07-11追記

EINTRの発生をmattnさんが調査されていたけれど、Goの標準関数を使う限りは発生しないようでした。

*1:issueはgolang/go#24543です

*2:他のOSでも概ね同じだと思いますが

*3:syscallが提供している型を公開メソッドで直接参照するのは良くないと思います