Plan 9とGo言語のブログ

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

Goモジュールでツールもバージョン管理する

Goモジュール管理下では、プロジェクトで使うGo製ツールのバージョンも管理できます。今までの経験では、ツールのバージョンが上がって困ることは記憶にないですが、とはいえ2018年5月ごろにprotoc-gen-goが大きめの変更を入れたこともあるので、バージョン管理しておいて損はないでしょう。このハックは、割とGoモジュール初期からあったようですが、最近使ったので書きました。

使い方

ツールを追加する

Go 1.13時点では、モジュール管理しているリポジトリgoimportsなどのツールをgo getすると、go.modが書き換えられて管理対象に入ります*1が、恒久的にソースコードへ含まれる訳ではないため、go mod tidyなどで整理すると、ツールのインストール時に追加されたモジュールがgo.modから削除されます。ここではツールもバージョン管理したいので、ビルド制約(build constraints)でビルド対象に含まれないようにしたファイルに、利用するツールを書き並べていきます。このときのビルドタグやファイル名はなんでも構いませんが、公式ではファイル名にtools.go、ビルドタグにtoolsが使われているので、合わせておいくといいでしょう。

// +build tools

package main

import (
    // コマンドまでのパスを書く
    _ "golang.org/x/tools/cmd/stringer"
    _ "golang.org/x/lint/golint"
)

これでgo mod tidyすると、その時点の最新バージョンがGoモジュール管理対象に入ります。または最初からバージョンを指定したい場合はgo getで明示しましょう。

// 全部最新でいい場合
% go mod tidy

// バージョン指定する場合
% go get golang.org/x/lint/golint@v0.0.0...

% git add go.mod go.sum

なぜこれで動くのかというと、tools.goはビルド制約があるのでビルド対象には含まれません。しかしgo mod tidyはビルド制約に関わらずモジュールを探すので、安全にツールのバージョンをgo.modに残せます。

ツールをインストールする

CIなど、go.modで指定されたバージョンをインストールしたい場合は、go installを使います。

% go install golang.org/x/lint/golint

バージョンを維持したい場合は必ずgo installを使いましょう。go getは現在参照可能な最新のバージョンをインストールするため、go.modが更新されてしまいます。

ツールをアップデートする

この場合はgo getで更新すればいいですね。

// バージョン確認
% go list -m -u all

// 以下のうちどれかでアップデート
% go get          golang.org/x/lint/golint
% go get -u       golang.org/x/lint/golint
% go get -u=patch golang.org/x/lint/golint

% git add go.mod go.sum

go getの使い分け

Goモジュールではgo getだけでgo.modに関わらず最新のバージョンを取得するので、go get -uとの違いについてGOPATHモードのgo getを知っている人は混乱するかもしれません。これはgo help module-getによると、インストールするモジュールが依存するモジュールをどう扱うか、を表すようです。

  • go get <pkg>: <pkg>go.modに書かれたバージョンをminimal version selectionで維持する
  • go get -u <pkg>: <pkg>が依存するモジュールも同じメジャーバージョン内でアップデートする
  • go get -u=patch <pkg>: <pkg>が依存するモジュールも同じマイナーバージョン内でアップデートする

アップデートしたいなんらかの事情があるとか、モジュール自体の更新が滞ってない限りはgo getを使えば良さそうですね。また、go getgo installの違いはバージョンを更新するかどうか、です。

  • go get <pkg>: 最新の<pkg>go.modを更新する
  • go install <pkg>: メインリポジトリgo.modに従う

つらいところ

Goモジュールを通してつらい部分は、ツールのバージョン管理でもそのまま残ります。具体的には、ツールがセマンティックバージョニングを守っていない場合、公式のモジュールデータベースにバージョンが記録されないのでGOPRIVATEGONOSUMDB環境変数を使ってリポジトリを直接見に行く必要があります。

例えばgithub.com/github/hubは2020年2月時点で、モジュールデータベース上はv2.11.2+incompatibleが最新バージョンとなっていますが、GitHubのタグではv2.14.1まで存在します。そのため、v2.14.1をインストールしたい場合、以下のようにバージョンを直接書き換えたうえで公式データベースを参照しないよう回避しなければなりません。

% go mod edit -require github.com/github/hub@v2.14.1+incompatible
% GONOSUMDB=github.com/github/hub go install github.com/github/hub // go getするとダメ

ところで、hubの新しいバージョンが公式モジュールデータベースに記録されない理由ですが、このモジュールはv2.12.0go.modを持つように変わりました。そうすると、タグ付けされたバージョンがv2以上であるのにimport pathがgithub/hub/v2のような形になっていないためincompatibleなバージョンとして扱われます。現在、公式モジュールデータベースはgo.mod対応モジュールではincompatibleなバージョンを扱わないようで、その結果としてv2.12.0以降のバージョンが登録されなくなってしまっているようです。

これは手元で以下のコマンドを実行してみると分かります。

% go version
go version go1.13.5 darwin/amd64

% go get github.com/github/hub@v2.14.1
go: finding github.com/github/hub v2.14.1
go: finding github.com/github/hub v2.14.1
go get github.com/github/hub@v2.14.1: github.com/github/hub@v2.14.1: invalid version: module contains a go.mod file, so major version must be compatible: should be v0 or v1, not v2

どう使うと良いのか

冒頭で書いたように、Go製ツールのバージョンが上がって致命的に困ったことは今のところありません。なのでツールのバージョンにそれほど神経質になる必要はない気がします。また、手元を古いバージョンで固定するのはあまり筋が良くないので、開発者の手元では新しいバージョンを使うようにして、CI側のバージョンを固定しておくとツールの最新バージョンでCIが誰にも気づかれず突然壊れることがなくなって便利かな、と思います。

*1:golang/go#30515go.modを更新しないオプションも検討されています

OpenTelemetryでメトリックを記録する

OpenTelemetryトレースの例はいっぱい見つかりますが、メトリックはまだ実装中*1だからなのか、ほとんど見つからなかったので調べたことをまとめました。

OpenTelemetryの概要は公式ドキュメントのOverviewを眺めると雰囲気わかると思います。

2020/04/30追記: いくつか変更があったので大きめの差分を以下の記事にまとめました

blog.lufia.org

使い方

OpenTelemetryでは、メトリックの記録と、メトリックをバックエンドサービスへ送るためのExporter設定を行う必要があります。ExporterはStackdriverやPrometheusなど標準で用意されていているものを使うこともできるし、なければ自分で作ることもできます。この記事では、(せっかく作ったので)Mackerel Exporterを使ってコードを書きますが、どのExporterを選んでも計測するコードには影響ありません。

事前に用意されているメトリック用のExporterはopentelemetry-goリポジトリにいくつかあります。

Exporterの準備

まずExporterの準備を行いましょう。OpenTelemetryのGo用パッケージを使って書いていきます。go.opentelemetry.io/otelは大きく/api以下のパッケージと/sdk以下のパッケージに分かれています。/sdk以下のパッケージは、/apiの裏で参照されていたり、独自のExporterを実装する場合などに必要となりますが、メトリックを記録するだけなら通常は/api以下のパッケージだけを使えばよい設計になっています。

import (
    "context"
    "log"
    "os"
    "runtime"
    "time"

    "go.opentelemetry.io/otel/api/core"
    "go.opentelemetry.io/otel/api/global"
    "go.opentelemetry.io/otel/api/key"
    "go.opentelemetry.io/otel/api/metric"

    "github.com/lufia/mackerelexporter-go"
)

var (
    keyHostID      = key.New("host.id")
    keyHostName    = key.New("host.name")
    keyServiceNS   = key.New("service.namespace")
    keyServiceName = key.New("service.name")
)

func main() {
    apiKey := os.Getenv("MACKEREL_APIKEY")
    pusher, err := mackerel.InstallNewPipeline(mackerel.WithAPIKey(apiKey))
    if err != nil {
        log.Fatal(err)
    }
    defer pusher.Stop()

    ...
}

以上のコードで、Exporterは一定周期(Checkpoint)ごとに計測した値をバックエンドサービスへ送るようになりました。他のExporterでも、オプションなどは変わると思いますが基本的にはInstallNewPipelineメソッドが用意されていると思うので、それを使えばいいでしょう。Checkpointの間隔はExporterによって異なります。Mackerelの場合は常に1分単位ですが、stdoutに出力するだけのExporterは1秒間隔です。

Exporterは複数設定してもエラーにはなりませんが、計測したメトリックはどれか1つのExporterにしか送られませんので1つだけ設定して使いましょう。OpenTelemetry Collectorのドキュメントによると、Collectorを使うと複数のExporterへ送ることができると読めますが、使ったことはないので分かりません。

メトリックの用意

続けて、Exporterを登録しただけではメトリックの記録はできませんので、使う準備をしていきましょう。

meter := global.MeterProvider().Meter("example/ping")
mAlloc := meter.NewInt64Gauge("runtime.memory.alloc", metric.WithKeys(keys...))

global.MeterProvider().Meter(string)metric.Providerを作り、そこへカウントなどを記録していく使い方となります。上のコード例ではint64を扱うハコ(Gauge)を1つ用意しました。OpenTelemetryでは、このようにメトリックの種類と値の型の組み合わせで記録したい時系列データを表現します。

メトリックの種類

メトリックの種類に選べるものは以下の3つです。

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

MeasureGaugeの違いは分かりづらいのですが、GaugeはCheckpointの最終値だけ分かれば良い場合に使います。例えばメモリの使用量について、途中がどんな値であれ最終的にCheckpoint時点の値さえ分かればいいならGaugeにするといいでしょう。そうではなく、Checkpointまでに記録した値を集計したい要望があるならMeasureを選びましょう。Measureは期間内に発生した値を郡として扱うので、Exporterはその値を使って最大・最小・平均・合計などを計算できます。

値の型(NumberKind)

メトリック値の型は以下の3つから選べます。

  • Int64NumberKind
  • Float64NumberKind
  • Uint64NumberKind

この型は、具体的なメトリックの値(core.Number)の型となります。

metric.WithKeysは何をしているのか

上のコード例で、

meter.NewInt64Gauge("runtime.memory.alloc", metric.WithKeys(keys...))

と書きましたが、このmetric.WithKeysは何をしているのでしょうか。これは、メトリックの値と一緒に記録することが推奨されるラベルを設定しています。keysはファイルの先頭で宣言しているので、言い換えると上のコードで準備したruntime.memory.allocメトリックは

  • host.id
  • host.name
  • service.namespace
  • service.name

のラベル4つを値と一緒に記録することを推奨する、と表現できます。ラベルの名前は好きなものを使って構いませんが、OpenTelemetryは標準的なリソース名が定義されているので、それに合わせた方が便利でしょう。

推奨ラベル以外のラベルが渡された場合の扱いは、Exporterの実装によって異なります。Mackerel用のExporterは推奨ラベル以外を無視しますが、他のExporter、例えばstdoutに出力するExporterは推奨に含まないラベルも渡されたもの全てをそのまま扱います。この動作はBatcherインターフェイスの実装に以下のどちらを選ぶかによって変わります。

defaultkeysは推奨ラベルのみ扱う実装です。反対にungroupedはなんでも扱います。

メトリックを記録する

メトリックの値は、用途によって4種類の記録方法が使えます。

  • ラベルや値を全て指定して記録する - Direct
  • 事前に設定しておいたラベルを使って記録する - Bound
  • バッチ処理 - 複数の値をまとめてatomicに記録する - Batch
  • metric.InstrumentImplを使って記録する - Impl

以下でDirect, Bound, Batchの3つについて書き方を紹介します。Implについてはドキュメントを読めば雰囲気は分かると思うので調べてみてください。

全て指定して記録(Direct)

ラベルと値を両方指定する方法です。

gauge := meter.NewInt64Gauge(...)
gauge.Set(ctx, 100, labels)

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

measure := meter.NewInt64Measure(...)
measure.Record(ctx, 10, labels)

ラベルを事前に設定しておく(Bound)

ラベルを省略できるので、同じラベルでなんども記録する場合に便利です。

gauge := meter.NewInt64Gauge(...)
boundGauge := gauge.Bind(labels)
boundGauge.Set(ctx, 100)

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

measure := meter.NewInt64Measure(...)
boundMeasure := measure.Bind(labels)
boundMeasure.Record(ctx, 10)

複数の値をまとめて記録(Batch)

Exporterは非同期にメトリックをバックエンドへ送っているため、タイミングによっては、本来は2つペアとなるメトリックなのに片方だけ更新された状態でCheckpointに到達してしまった、という状態が起こり得ます。そういった、不完全な状態でExporterが送らないように、関連する値をまとめて更新する方法が用意されています。

meter.RecordBatch(ctx, labels,
    gauge.Measurement(100),
    counter.Measurement(1),
    measure.Measurement(10),
)

Batchで記録する場合は、ラベルをBindさせる手段は無さそうです。

ラベルの値を設定する

今後、属性(Attribute)に名前が変更されそうですが、今はまだラベルと呼びます。

これまで、ラベルはキーしか定義していませんでしたが、ラベルはキーと値で構成されるものです。メトリックにラベルを設定する場合は値も必要なので

hostID := keyHostID.String("10-1-2-241")
statusNotFound := keyStatusCode.Int32(404)

のように必要なペアを作ってメトリックと一緒に記録しましょう。キーはcore.Key型で、値はcore.ValueType型です。値には以下の型を使えます。

  • BOOL
  • INT32
  • INT64
  • UINT32
  • UINT64
  • FLOAT32
  • FLOAT64
  • STRING

ペアになったラベルはcore.KeyValueです。どれもcoreパッケージで定義されています。

まとめ

OpenTelemetryのMetric APIは、

  • アプリケーションは必要なときに値を記録する
  • 記録した値を一定周期でサービスへ送る

をするだけです。とりわけOpenTelemetryを使わず自前実装しても、大した手間ではありません。だけどもOpenTelemetryの開発が進むにつれてエコシステムも整備されていくはずです。今はopentelemetry-goリポジトリpluginディレクトリにはトレース用のプラグイン(extension)しかありませんが、database/sqlnet/http、Redisなどのメトリックを扱うプラグインは今後おそらく用意されるでしょうし、ドキュメントを眺めた雰囲気では、Collectorなど群を扱うグッズも増えていく気がします。メトリック送信のしくみを自前実装する方が、覚えることが少なく心理的に手を出しやすいけれども、エコシステムの恩恵を受けるために今のうちから対応しておくといいんじゃないかなと思っています。

*1:Project Statusによるとv0.2

転売しない人に譲りたい

人からもらった家電があったのだけれど、しばらく使っていないし今後も使う予定がなかったし、未開封のまま捨てるのも勿体ないなという気持ちがあったので某フリマサイトで売った。まあまあ良い金額にはなったけれど、おそらく転売目的だろうと思われる人に購入されてしまって、体験としては微妙に後味が悪いものだった。

売れたけど

出品後、当たり前のように値引き交渉があった。金額にはこだわりが無かったけれど転売目的なのかどうかは気をつけていたので、購入希望者が過去に出品した数を眺めた。該当アカウントの出品数は年に数個程度だったので、転売目的ならもっと多いだろうと判断して売ったのだけれど、発送のやり取りで「他にも同種の在庫があれば引き取る」と言われてしまって、今回売った家電は同じものをいくつも持つようなジャンルではないので、転売目的だなと気づいた。出品アカウントを分けているんだろうか。

もともと、自分自身の性格は物に対して(おそらく普通の人より)愛着を持ってしまう傾向があるので、本当に必要なものしか買わないし、買ったら長く使うし滅多に捨てない(数年で劣化するものは別だけれど)。なので転売のために買われたこの家電は、まだ使われることはなんだと考えると残念な気持ちになるので、多少安くても、さいあく無償で譲ることになったとしても、物が作られた本来の使い方をきちんとしてくれる人に渡って欲しかったと思う。

まあ取り引きは成立してしまったので今更言っても仕方がない。次はもっとうまく見極めようと思うが、体験は良くなかったので次も某フリマサイトを使うかは分からない。

あとがき

これを読んだ翌日だったので、萎えるなーと思ったのでした。

GAE第2世代で実装方法はどう変わったか

久しぶりにGAE/Goで自分用サービス作ったとき、第1世代(Go 1.9まで)と全く違っていて混乱したので自分用メモ。

DatastoreがCloud Firestoreになった

以前はAppEngine専用のDatastore APIを使っていたが、Cloud Datastoreを経て現在はCloud Firestoreを使うようになっているようだった。

2020年1月現在、新しくGCPプロジェクトを作成するとDatastoreモードのCloud FirestoreまたはネイティブモードのCloud Firestoreどちらかを選ぶ必要がある。既存のプロジェクトでCloud Datastoreを利用している場合は将来的に自動でDatastoreモードのCloud Firestoreにマイグレーションされるらしい。

一度選択すると、同じプロジェクトでは変更ができない*1ので、よほどの理由がない限りはネイティブモードを選べば良いと思う。ネイティブモードを選択しても、FirestoreのためにFirebaseコンソールとGCPコンソールを使い分ける必要はなく、GCPコンソールからFirestoreにアクセスできるし、今回は使わなかったがgocloud.dev/docstore(pkg.go.dev)はcloud.google.com/go/firestoreを使わずFirestore v1 APIを叩いているのでネイティブモードで慣れておいて損はない。

firestore.Client.Docnil DocumentRefエラーになる

以下のコードでnilが返ってきてしまう。

doc := firestore.Client.Doc("a/b/c")

docnilなので、このメソッドを呼び出すと以下のエラーが発生する。

firestore: nil DocumentRef

原因はパスの要素数で、ドキュメントとして参照する場合のパスは偶数個の要素でなければ扱えない。なのでDoc("a/b/c/d")なら偶数個なので正しいDocumentRefを取得できる。

コレクションとドキュメントが交互になるよう注意してください。コレクションとドキュメントは常にこのパターンに従う必要があります。コレクション内のコレクションや、ドキュメント内のドキュメントは参照できません。

同じ名前でCollectionとDocumentが存在できるのか

c, _ := firestore.NewClient(ctx, projectID)
articleRef := c.Doc("Articles/<id>")
commentRef := c.Doc("Articles/<id>/Comments/<n>")

のように、ドキュメントと名前が重複するコレクションは作れるのか?という話。Cloud Firestore データモデルに同じような構造のサンプルコードが書かれているので奇妙な設計というわけではなさそうだった。

カーソルはどうするの

ドキュメントのIDを使って、firestore.Query.StartAtまたはfirestore.Query.StartAfterを使うと途中から読める。

q := c.Collection("a/b/c").Where("is_draft", "==", false)
q = q.OrderBy(firestore.DocumentID, firestore.Asc)
q = q.StartAfter("<id>") // 最後に読んだID
iter := q.Documents(ctx)
defer iter.Stop()

保存したDocumentのIDを調べたい

ドキュメントへのパスを指定してDocumentRefを生成する場合は、パスに使った値を使えば良いが、保存されているドキュメントをイテレータで読み出す場合に困った。結局はfirestore.DocumentRef型にIDがあるのでそれを使うと良い。firestore.DocumentSnapshot型はRefフィールドにDocumentRefを持っている。

iter := c.Collection("Articles").Documents(ctx)
defer iter.Stop()

for {
    doc, err := iter.Next()
    if err == iterator.Done {
        return m, nil
    }
    fmt.Println(doc.Ref.ID)
}

まとめて書き込みしたい

firestore.Client.Batchを使うと良いが、これは1回のコミットで最大500件までの制限がある。制限を超えると、以下のエラーが発生する。

maximum 500 writes allowed per request

この場合は単純に、500件ごとにfirestore.WriteBatchを作り直せば良い。

for _, a := range requests { // 500件ごとに分割してある
    b := c.Batch()
    for _, p := range a {
        b.Create(c.Doc(p.Key), p)
    }
    if err := b.Commit(); err != nil {
        return err
    }
}

ただし、firestore.WriteBatchを作り直さずにそのまま使いまわすと、同じエラーが発生する。

Firestoreのテストはどうするの

gcloud beta emulators firestoreエミュレータが用意されている。

cronとタスクキュー

cronはCloud Schedulerに移行する

これはまだAppEngineのcronが使えるので以前のままcron.yamlを使った。今ならCloud SchedulerとCloud Functionsで作れば良さそうに思う。

AppEngineタスクキューはCloud Tasksに移行

AppEngineのタスクキューとだいたい同じ感覚で使えるが、突然Protocol Buffersの型が出てきてつらみがある。これも今ならCloud Functionsの方が良いかもしれない。

import (
    cloudtasks "cloud.google.com/go/cloudtasks/apiv2"
    taskspb "google.golang.org/genproto/googleapis/cloud/tasks/v2"
)

...

c, err := cloudtasks.NewClient(ctx)
if err != nil {
    return err
}
defer c.Close()

queuePath := path.Join(
    "projects", projectID,
    "locations", locationID,
    "queues", "default",
)
req := &taskspb.CreateTaskRequest{
    Parent: queuePath,
    Task: &taskspb.Task{
        MessageType: &taskspb.Task_AppEngineHttpRequest{
            AppEngineHttpRequest: &taskspb.AppEngineHttpRequest{
                HttpMethod:  taskspb.HttpMethod_POST,
                RelativeUri: "/_ah/tasksadd",
            },
        },
    },
}
for _, task := range tasks {
    body, err := json.Marshal(task)
    if err != nil {
        return err
    }
    req.Task.GetAppEngineHttpRequest().Body = body
    if _, err := c.CreateTask(ctx, req); err != nil {
        return err
    }
}

デプロイ

無視するファイルはgcloudignoreに書く

node_modulesなどデプロイする必要のないファイルは.gcloudignoreに書くと無視できる。

node_modules/

staticでルーティングしたファイルが404 not found

Go 1.11以降(2nd gen以降?)は、go.modファイルのある場所がカレントディレクトリになる。そのため、

app/
  app.yaml
  static/
    bundle.js
    index.html
go.mod
go.sum

このようなファイル階層のとき、app/app.yamlからの相対パスを書いても読めない。

# ダメな例
handlers:
- url: /api/.*
  script: auto

- url: /bundle.js
  static_files: static/bundle.js
  upload: static/bundle.js

- url: /(.*)
  static_files: static/index.html
  upload: static/index.html

これだと、go.modの位置にはstatic/ディレクトリは存在しないので参照できない。

上の記事にもあるが、app.yamlを以下のように変更するか、またはgo.modapp.yamlを同じディレクトリに置くと良い。

handlers:
- url: /api/.*
  script: auto

- url: /bundle.js
  static_files: app/static/bundle.js
  upload: app/static/bundle.js

- url: /(.*)
  static_files: app/static/index.html
  upload: app/static/index.html

Go 1.13を使いたい

app.yamlに設定すれば普通に使える。

runtime: go113

無料枠(Always-Free)はスケーリングとインスタンスクラスによって異なる

GAEの場合、Google Cloudの無料枠では

1 日あたり28時間のフロントエンドインスタンス時間、1日あたり9時間のバックエンドインスタンス時間

としか書かれていないが、実際は割り当てに書かれているように、インスタンスクラスによって

  • Automaticスケーリングの場合はF1インスタンスクラスなら28時間まで無料
  • Basic/Manualの場合はB1インスタンスクラスなら9時間まで無料
  • それ以外は対象外

という制限がある。スケーリングについては以下のリンクが詳しい。

Cloud IAP

app.yamllogin: requiredが使えなくなったので、お手軽に認証したければCloud Identity-Aware Proxyを使うと良い。Googleアカウント以外にも対応する必要があるなら、Identity Platformというサービスが使えるらしい。

面倒なアプリのログイン機能を超簡単に実装する on GCP

Cloud IAPでOwnerを持っているユーザなのにアクセスできない

オーナーは設定変更する権限だけで、アクセス権は持っていない。アクセスするためにIAP-Secured Web App Userの追加が必要だった。セキュリティIdentity-Aware Proxyと進んで、情報パネルにアカウントを追加する。

*1:別のプロジェクトには影響しない

Acmeエディタの使い方

この記事は本来Plan 9 Advent Calendar 2019の11日目でしたが、今は27日ですね、はい...すいません...

Acmeエディタ、使ってみたけど何もわからないという声をよく聞くので、基本的な使い方を説明します。この記事ではplan9portのacmeを使うことを想定していますが、Plan 9acmeでも基本的には同じです。

使い方

ターミナル等から起動すると、カレントディレクトリのリストを含んだwindowが表示されます。

$ acme

f:id:lufiabb:20191226133203p:plain
起動直後の画面

Acmeはタイル状にファイルやディレクトリを扱います。この画面では、右側にホームディレクトリを表示していて、1つのファイルごとにタグという青い領域(tag)とファイル内容が書かれた黄色い領域(body)を持ちます。また、カラム毎やウィンドウ全体にもタグ領域が存在します。

操作方法

Acmeではマウスを多用することになります。マウスは3つボタンが必要です。ホイールでもボタンとして反応するなら支障ありませんが、Magic Trackpadなどボタンの同時押しができないものは、Acmeを使うには向かないのでお勧めしませんが、macOSの場合はキーボードと合わせてボタンを押すことで他のボタンとして扱えます。

  • 2ボタン: Altキーを押しながら1ボタン
  • 3ボタン: Cmdキーを押しながら1ボタン

ボタンは左から1, 2, 3ボタンと呼びます。1ボタン(左)はクリックした場所にカーソルを移動させたり範囲を選択したりなど、よくある操作を行います。ファイル内容の領域でキーボードから文字を入力すると、ファイルを開いている場合はウインドウが編集済み(dirty)に変わります*1。タグ領域のファイル名より左側にあるが青くなっている場合、そのファイルは編集した後に保存していない状態を表します。

マウスの2ボタン(中)は選択したテキストまたは前後スペースまでのテキストを実行します。例えばExitの上で2ボタンをクリックすると、(何も編集中でなければ)Acmeが終了します。Acmeは画面に表示しているテキスト(上の画面ではNewcol, Xerox, Snarfなど)を全て編集できるので、Goのコードを書いているウィンドウのタグ領域に

/path/to/file ... Del Snarf | Look go test

のように追加して、go testを選択した状態で2ボタンを押す(または2ボタンを押しながら選択して離す)とコマンドを実行できます。こういったよく使うコマンドを、タグ領域にボタンとして置いておくと、2ボタンを押すだけで各種コマンドが実行できるので便利です*2。2ボタンで実行するコマンドは、開いているファイルまたはディレクトリと同じ階層をカレントディレクトリとします。

Acmeには、以下のような組み込みコマンドがあります。

コマンド 説明
Get ファイルを再読み込みする
Put ファイルを保存する
Del ウィンドウを閉じる(編集中ならエラー)
Delete 編集中でもウィンドウを閉じる
Font プロポーショナルフォント等幅フォントを切り替える
Edit ed(1)コマンドのようなものを実行
Snarf 選択したテキストをコピー(Snarf bufferに残る)
Cut 選択したテキストをカット(Snarf bufferに残る)
Paste Snarf bufferの内容をペースト
Undo ファイルの変更を元に戻す
Redo ファイルの変更をやり直す
Look Look xxxとするとxxxをファイルから検索する
New 新しいウィンドウを開く
Zerox 同じウィンドウを複製する(編集中の状態も共有)
Newcol カラムを作成する
Delcol カラムを閉じる
Dump ウィンドウの状態を$HOME/acme.dumpに書き出す(acme -lオプションで読み込む)
Exit Acmeを終了する

この他にも色々な組み込みコマンドがあるので、知りたい人はacme(1)を読んでください。これらのうち一部しかタグ領域に表示されていませんが、タグ領域はただのテキストなので、自分で入力すればコマンドとして使うことができるようになります。以前はタグ領域を複数行にできませんでしたが、今は何行でも大丈夫です。また、これらの組み込みコマンド以外にも、Acmeファイルサーバを扱うコードを書けば、Language Serverクライアントなど任意の操作を行える拡張を作ることもできます。

3ボタン(右)は、ファイルを開く・テキストを検索する、など色々な操作に使います。

  1. 選択したテキストと同名のファイルやディレクトリがあればそのファイルを開く
  2. URLのようであれば(実際はplumber(4)により)ブラウザでURLを開く
  3. どれでもなければファイルの内容からテキストを検索する

のように動作します。この3ボタンの操作も、Acmeファイルサーバを扱うコードを書けば好きなようにカスタマイズできます。通常はこの挙動で困りませんが、ファイルが開かれてしまうと困る場合はLookコマンドを使いましょう。

Mouse Chording

3ボタンマウスを使っている場合、Snarf, Cut, Pasteはマウス操作で行えます。

  • 1ボタンを押しながら2ボタン: 選択したテキストをCut
  • 1ボタンを押しながら3ボタン: コピーしたテキストをカーソル位置にPaste
  • 1ボタンを押しながら2ボタンでCutした後、1ボタンを押したまま3ボタン: Snarf

この操作は慣れてくると非常によく使うことになりますが、Magic Trackpadなどでは同時押しができないため使うことができません。また、ホイールだと誤ってスクロールしてしまうことがあり、慣れるまでは誤動作にイライラします。なので、なるべく3ボタンマウスを用意した方が良いと思います。

現在、業務では、前職のこの記事に書いたように、左手にトラックパッド、右手に3ボタンマウスを使っています。3ボタンマウスだけではブラウザなどでスクロールする際に不満がありましたが、その辺りも解決するのでおすすめです。

ファイル操作

Acmeは、ファイル名:行番号の書式をうまく扱います。この書式で書かれたテキストの上で3ボタンをクリックすると、行を選択した状態でファイルを開きます。これはコードリーディングにとても便利です。Goはコンパイルエラーやスタックトレースなどファイルを参照する場面でファイル名:行番号を使っているので、エラーメッセージに含まれるファイル名を3ボタンクリックすると問題の行に移動できます。または、git grep -nも同じ書式なので、適当な場所(通常はタグ領域)にgit grep -n xxを書いて2ボタンで実行させて、その検索結果からそのまま3ボタンでファイルを開けます。もちろん検索結果もただのテキストなので、不要だと思った行は実行結果から削除すれば邪魔になりません。

これ以外にもファイル名:開始行,終了行で範囲を選択できるし、ファイル名:/正規表現とするとファイルの先頭から正規表現にマッチしたテキストを探したり、ファイル名:-/正規表現でファイルの末尾から探したりできます。ファイル名を省略して:行番号とすると、そのウィンドウが開いているファイル内容から該当する行へ移動します。

ターミナル

Acme上でシェル実行を行うwinというコマンドがあるので、タグ領域などにwinと書いて2ボタンをクリックすると、ターミナルを起動することができます。このターミナルはいわゆるダム端末というもので、ANSIエスケープシーケンスなどは扱えませんが、代わりに全てテキストなのでコマンドの出力結果も編集することができます。

ただ、この辺り、Gitなど最近のツールとすこぶる相性が悪いので、個人的に使っている設定を近いうちに紹介します。

Editコマンド

Editコマンドはテキストをコマンドで編集するときに使います。Editはスペースに続けてed(1)に似たコマンドが必要です。よく使うコマンドをここでは紹介しますが、全部は無理なので、興味があればsam(1)を読んでください。

Editの書式は

Edit [address] [command]

となっていて、[address]を省略するとファイル内容の現在選択している範囲がEditコマンドの編集対象として使われます。いくつか例を挙げます。

// ファイル全てのgetをGetに置き換え
Edit 1,$ s/get/Get/g

// これでも同じ(カンマは省略できない)
Edit , s/get/Get/g

// 10〜20行目を選択するだけで何も変更しない
Edit 10,20

// 行末にスペースがあれば削除
Edit , s/ +$//g

これである程度の編集は可能ですが、特定範囲内の一部だけ編集したいなど、もっと複雑なコマンドのためにループと条件分岐も用意されています。

ループと条件分岐

ループはx//またはy//で書きます。

x/regexp/command
y/regexp/command

例えば<...>を全て取り除きたい場合は以下のように書きます。

Edit , x/<[^>]+>/d

x//s///gと似ていますが、ループはマッチした部分ごとに任意のEditコマンドを記述できる点が異なります。実際に上の例では、テキスト置換(s)ではなくテキスト削除(d)を使っています。また、編集対象となる範囲をマッチしたテキストの範囲に再設定するので、後続のコマンドはその範囲を対象として動作するようになります。ややこしいですがx//もコマンドなので、x//でマッチした範囲をさらにx//で絞り込むこともできます。

y//正規表現で分割した文字列ごとにコマンドを実行できます。スペース区切りのテキスト末尾にカンマを入れたい場合はこのように書きます。a/text/はテキストを範囲の末尾に追加するコマンドです。

Edit y/[ \n]+/a/,/

条件分岐はg//またはv//を使います。

g/regexp/command
v/regexp/command

g//は編集対象範囲に、指定した正規表現にマッチするテキストがあればコマンドを実行します。v//は逆にマッチするテキストがなければコマンドを実行します。

最後に複雑な例をひとつ。

// Goのimportにあるghe.example.com/pkgをgithub.com/pkgに置き換え
Edit /^import \($/+1;/^\)/-1y/\n/g/ghe\.example\.com\/pkg/s/ghe\.example\.com/github.com/

こういった複雑なコマンドを組み立てる場合は、g/regexp/=#のように=#コマンドを使うと、どの部分がマッチしていくのか確認しながら進めることができて便利なのでぜひ使ってみてください。

*1:ディレクトリの場合、またはファイル名の末尾が+Errorsの場合は変化しない

*2:コマンドはrc(1)経由で起動するので=はクオートが必要

opencensus-goでPlan 9用のmackerel-agentを作っている話

この記事はMackerel Advent Calendar 2019の12日目です。

公式に提供されているmackerel-agentはGoで書かれていて、WindowsLinuxmacOSなどに対応していますが、Plan 9には対応していません。個人のサーバはPlan 9が動作しているし良い機会なので、opencensus-goを使ってPlan 9で動くmackerel-agentを作ってみようと思いました。

OpenCensusはOpenTelemetryへ統合されることが発表されていますが、移行は考慮されるでしょうし、まだOpenCensusでいいかなと思いました。

ソースコードはこちら。

github.com

まだメモリ関連メトリックの投稿しかできませんが、とりあえず動いています。

f:id:lufiabb:20191212133113p:plain
動いている様子

概要

OpenCensusについて

OpenCensusはメトリック収集と分散トレーシングのフレームワークです。net/httpgRPC、Redis、MongoDBなどを扱うインテグレーションはあらかじめ用意されていますが、OpenCensus自体が例えばホストのメモリ使用状況などを取得してくれるわけではありません。メトリックの計測自体はWebアプリケーションなど自身が行なう必要があります。そうして、取得した値をOpenCensusが提供する仕様で収集しておくと、対応したバックエンドサービスへ定期的に送られる、というものです。

バックエンドサービスへ送るものは、OpenCensusではExporterと呼びます。公式ページに各言語で対応しているExporter(リンクはGoの場合)のリストが提供されています。Mackerelはリストに入っていませんが、カスタムExporterを作ることは可能なので、今回はこれも実装することにしました。ただし、完全に汎用的なExporterを作るのは(後で記述するように)難しいので、Plan 9用mackerel-agentのためだけに使うと割り切った実装をしています。

mackerel-agent-plan9がやること

公式のmackerel-agentはホスト情報やメトリックの投稿、プラグイン実行など意外と色々なことをやっています。これを全て再現するのはとても大変なので、最低限動くところだけ実装しました。

  • ホストの登録(ホストメトリック投稿のために必要)
  • メトリックの収集(この記事ではメモリだけ対応)
  • 収集したメトリックの投稿(Exporter)

これ以外のこと、具体的には

  • ホスト情報の更新
  • カスタムメトリックの投稿
  • サービスメトリックの投稿
  • メトリック投稿失敗時のリトライ
  • プラグインの実行

などは全て未対応です(後で対応するかもしれませんが...)

Mackerelのメトリック

上でいくつかメトリックの種類が出てきたので整理しておくと、Mackerelにおけるメトリックは以下のように分類されます。

  • ホストメトリック
    • システムメトリック
      • 公式mackerel-agentが自動的に収集するもの
    • カスタムメトリック
  • サービスメトリック
    • ホストに紐づかないもの

ホストと一括りに呼んでいますが、ここでは物理、仮想マシンに限らず、RDSやLambda Functionなどもまとめてホスト(スタンダードホスト・マイクロホストの違いはありますが...)として扱っています。この辺りの詳細はメトリックの種類 - missasanの日記で丁寧に書かれています。

実装

では実装の話です。まずPlan 9からメトリックを収集できなければ始まりません。Plan 9では、カーネルが提供する/dev/swapから、その時点のメモリの利用状況を読み取ることが可能です。各行の説明は省きますが、read(2)すると以下のような行指向のテキストファイルになっているのでbufio.Scannerなどで読み進めていけばいいので簡単ですね。

1071185920 memory
4096 pagesize
61372 kernel
2792/200148 user
0/160000 swap
9046176/219352384 kernel malloc
0/16777216 kernel draw

メトリックの収集

取得したメトリックを、OpenCensusのStats/Metricsとして扱える状態にするには、OpenCensusのMeasureやViewなどが必要です。OpenCensusの用語と、バックエンドサービス(この記事ではMackerelのこと)の用語が異なっていて難しいので、およそ同じ概念だろうと思う対応表を用意しました。

OpenCensus Mackerel メモ
Measure グラフ定義 メトリックの単位や名前
Aggregation 該当なし? 合計、カウント、最終値など
Tag グラフ定義? 名前と値のkey-valueペア
View グラフ定義 上3つの概念をまとめたもの
Measurement メトリックの値 記録した時点のメトリック値
View Data 該当なし? メトリック投稿前のバッファ
Stats メトリック メトリックのコレクション
Integration プラグイン 上で説明したので省略

次に具体的なコードです。OpenCensusでStackdriver Monitoringにメトリクスを送信する - YAMAGUCHI::weblogによると、OpenCensusのView設定はパッケージグローバルで用意しておくのがベストプラクティスとのことなので同じように書きました。

import (
    "go.opencensus.io/stats"
    "go.opencensus.io/stats/view"
    "go.opencensus.io/tag"
)

var (
    mMemUsed       = stats.Int64("memory/used", "Used", "By")
    mMemAvail      = stats.Int64("memory/available", "Avail", "By")
    mMemTotal      = stats.Int64("memory/total", "Total", "By")

    memUsedView = &view.View{
        Measure:     mMemUsed,
        Name:        "memory/used",
        Description: "Memory used",
        Aggregation: view.LastValue(),
        TagKeys:     []tag.Key{HostKeyID},
    }
    memAvailView = &view.View{
        Measure:     mMemAvail,
        Name:        "memory/available",
        Description: "Memory available",
        Aggregation: view.LastValue(),
        TagKeys:     []tag.Key{HostKeyID},
    }
    memTotalView = &view.View{
        Measure:     mMemTotal,
        Name:        "memory/total",
        Description: "Memory total",
        Aggregation: view.LastValue(),
        TagKeys:     []tag.Key{HostKeyID},
    }

    HostKeyID   = tag.MustNewKey("meta.host.id")
    KeyHostName = tag.MustNewKey("meta.host.name")
    KeyOS       = tag.MustNewKey("meta.os")
    KeyCPUName  = tag.MustNewKey("meta.cpu.name")
    KeyCPUMHz   = tag.MustNewKey("meta.cpu.mhz")
)

まず

// 引数は左から、メトリック名、説明、単位
mMemUsed = stats.Int64("memory/used", "Used", "By")

ですが、これはOpenCensusのMeasureとしてメモリの使用量を定義しています。メトリック名はそのままMackerelのシステムメトリック名に対応しますが、区切りを.から/に変更しています。OpenCensusが用意しているインテグレーションのメトリック名はopencensus.io/http/client/request_bytesのような形で事前に定義されているので、将来このインテグレーションを使った時に困らないよう、OpenCensusの習慣に合わせて区切りを/として扱うことにしました。Mackerelでは.区切りなので、ExporterがMackerelへ投稿する前に.へ置き換えます。memory/used/を置き換えるとmemory.usedとなって、Mackerelのシステムメトリックとして扱えます。名前については公式ドキュメントのMeasureも目を通すといいでしょう。

Byはバイト数を表現する単位です。OpenCensusの単位はThe Unified Code for Units of Measureに準拠している必要があって、この仕様では世の中の色々な単位が用意されていますが、実際のところ安全に使える単位は1(単位無し)、Bymsの3種類くらいではないでしょうか。ちなみにこの仕様に出てくる表ではprint, c/sc/iの3列があって、printは良くわかりませんが残りはcase sensitiveまたはcase insensitiveを意味しているようです。

次にViewです。これはMeasureで記録する値(Measurement)をどう扱うか、を定義します。

memUsedView = &view.View{
    Measure:     mMemUsed,
    Name:        "memory/used",
    Description: "Memory used",
    Aggregation: view.LastValue(),
    TagKeys:     []tag.Key{HostKeyID},
}

MeasureフィールドはViewが扱うMeasureを設定します。Nameは今回たまたまMeasureと同じ値ですが、異なっていても構いません。AggregationフィールドはCount, Distribution, LastValue, Sumの4つから選びます。

MeasureとViewの違いですが、雑な表現をするとMeasureが計測対象で、Viewはその見え方です。例えばSQLのクエリ実行時間を計測する場合、アプリケーションがクエリを実行するたびに実行時間(Measure)を計測して値(Measurement)をView Dataへ保存します。保存した値(Measurement)は一定周期でExporterがバックエンドへ送信しますが、この時点でView Dataに複数の値が収集されている可能性があります。Viewを通すことでメトリックの合計や最終値などといった集積値として扱えます。mackerel-agentの場合、1分単位でホストの状態を収集するので、ほとんどは最終値(LastValue)でしょう。

TagKeysは、OpenCensusuではMeasurementを計測する時に、一緒に複数のタグも付与することができるのですが、このタグからどれを使うのかを定義しています。

値(Measurement)の計測

OpenCensusで値を記録するのは、stats.Recordを呼ぶだけです。メモリの使用量を収集するコードは以下のようになります。

if err := view.Register(views...); err != nil {
    log.Fatal(err)
}
host, err := p9stats.ReadHost(*rootdir)
if err != nil {
    log.Fatal(err)
}
ctx, _ := tag.New(context.Background(),
    tag.Insert(HostKeyID, id),
    tag.Insert(KeyHostName, host.Sysname),
    tag.Insert(KeyOS, "plan9"),
    tag.Insert(KeyCPUName, host.CPU.Name),
    tag.Insert(KeyCPUMHz, strconv.Itoa(host.CPU.Clock)),
)

t := time.NewTicker(30 * time.Second)
defer t.Stop()
for {
    <-t.C
    m, err := p9stats.ReadMemStats(*rootdir)
    if err != nil {
        log.Fatal(err)
    }
    stats.Record(ctx,
        mMemUsed.M(m.UserPages.Used*m.PageSize),
        mMemAvail.M(m.UserPages.Avail*m.PageSize),
        mMemTotal.M(m.Total),
    )
}

ctxを作るところでTagを付与していますが、tag.Newctxに付与したタグは、stats.Recordで一緒に記録されてExporterから参照できるようになります。途中でタグの値を変更したい場合、tag.Newtag.Updatetag.Upsertなどを使って更新するといいでしょう。

ctx, _ = tag.New(ctx, tag.Update(KeyHostName, host.Sysname))

Mackerelとの繋ぎ込み

MackerelはAPIを公開しているので自前で実装しても難しくはないですが、せっかくmackerel-client-goが用意されているのでこれを使います。

問題はExporterの実装で、どうやら現状、実装パターンは2通りあるようです。

ExportView

まずはExportViewを実装するパターン。公式のWritting a custom exporterにもこの方法が書かれています。

import "go.opencensus.io/stats/view"

type Exporter interface {
    ExportView(vd *view.Data)
}

このインターフェイスを実装して、

view.RegisterExporter(&customMetricsExporter{})
view.SetReportingPeriod(1 * time.Minute)

でExporterを登録しておくと、上の例では1分間隔でViewごとにExportViewの呼び出しが行われます。view.DataからViewなども参照できるので、ここで値を取り出してMackerelに送ればいいでしょう。

ただし、事情はあまりよく分かっていませんが、いくつかのExporter実装を読むとExportViewはDeprecatedとされていて、代わりにExportMetricsを使うように書かれていました。

ExportMetrics

インターフェイスExportMetricsだけですが、これはStartStopと合わせて使われます。

import "go.opencensus.io/metric/metricexport"

type Exporter interface {
    ExportMetrics(ctx context.Context, data []*metricdata.Metric) error
}

こちらのパターンでは、StatsやView Dataの代わりにMetricやTimeSeriesという用語が使われていて、metricdata.MetricからViewを参照できません。ただしstats/view/view_to_metric.goを読むと、ViewをDescriptorに変換してくれているようなので記録する側のコードはそのまま使えます。ただし、ViewとDescriptorが完全に対応するわけではなく、Resourceなど設定する方法がないものも存在します。resourcekeysなど便利そうだけど使えません。

ExportMetricsで実装したコードはこのようになりました。Exporterを起動する部分。

e, err := exporter.NewExporter(exporter.Options{
})
if err != nil {
    log.Fatal(err)
}
if err := e.Start(1 * time.Minute); err != nil {
    log.Fatal(err)
}
defer e.Stop()

Exporter自体の実装。

// Exporter is a stats exporter that uploads data to Mackerel.
type Exporter struct {
    opts Options
    once sync.Once
    r    *metricexport.IntervalReader
    c    *mackerel.Client
}

// Options contains options for configuring the exporter.
type Options struct {
    APIKey string
}

func NewExporter(o Options) (*Exporter, error) {
    c := mackerel.NewClient(o.APIKey)
    return &Exporter{
        opts: o,
        c:    c,
    }, nil
}

// Start starts the metric exporter.
func (e *Exporter) Start(interval time.Duration) error {
    var err error
    e.once.Do(func() {
        e.r, err = metricexport.NewIntervalReader(&metricexport.Reader{}, e)
    })
    if err != nil {
        return err
    }
    //trace.RegisterExporter(e)
    e.r.ReportingInterval = interval
    return e.r.Start()
}

func (e *Exporter) Stop() {
    //trace.UnregisterExporter(e)
    e.r.Stop()
}

func (e *Exporter) ExportMetrics(ctx context.Context, data []*metricdata.Metric) error {
    a := convertToHostMetrics(data)
    if err := e.c.PostHostMetricValues(a); err != nil {
        e.ErrLog(err)
        return err
    }
    return nil
}

func convertToHostMetrics(a []*metricdata.Metric) []*mackerel.HostMetricValue {
    var r []*mackerel.HostMetricValue
    for _, p := range a {
        // View.Nameの値から'/'を'.'に置き換え
        name := metricName(p.Descriptor)

        // 値と一緒に記録したタグからホストIDを取り出す
        i := labelKeyIndex(p.Descriptor, HostKeyID.Name())
        if i < 0 {
            continue
        }
        for _, ts := range p.TimeSeries {
            if !ts.LabelValues[i].Present {
                continue
            }
            hostID := ts.LabelValues[i].Value

            // OpenCensusのMetricをMackerelのホストメトリックに変換
            a := hostMetricValues(hostID, metricValues(name, ts.Points))
            r = append(r, a...)
        }
    }
    return r
}

func labelKeyIndex(d metricdata.Descriptor, key string) int {
    for i, k := range d.LabelKeys {
        if k.Key == key {
            return i
        }
    }
    return -1
}

func hostMetricValues(...省略...)

これで収集したメトリックを、Exporterを通してMackerelへ投稿できるようになりました。実際はホスト登録なども必要ですが、mackerel-client-goを使っておけばそんなに迷うことはないでしょう。tag.Newで付与したタグは以下のコードで取り出せるので、ホスト登録の際にタグからホスト名などを解決できると良いかもしれません。

m := tag.FromContext(ctx)
m.Value(key)

ExportMetricの実装は、調べながら書いたので不格好ですね。公式サンプル実装が用意されているので、どこから何を参照すればいいか分からない時にとても参考になりました。

思ったことなど

今はシステムメトリックしか考慮していませんが、システムメトリック以外はカスタムメトリックという前提にすれば、意外とExporterは使い回しできるかもしれないなと思いました。サービスメトリックを表現したい場合は、HostKeyIDの代わりにServiceKeyIDを用意しておくと、どのタグが付けられているかによって表現できそうな気がします。ただ、他のバックエンドへ一緒に送ることを考えると、ホスト登録やメトリック分類のためにタグ付けを必須とするのは行儀が悪い振る舞いかもしれません。まあ今はPlan 9用mackerel-agentのためだけに使うので問題ないですが。

もう一つ、これはPlan 9用だとしても発生する問題で、Mackerelのグラフ定義は親子関係になっていて、ExportMetricsに届いたViewからグラフ定義を作るのは難しい(どこで切ればいいか分からない!)のでカスタムメトリックはどう扱えばいいか悩んでいます。システムメトリックの場合は、グラフ定義は用意されているので何も考える必要ありませんでしたが、カスタムメトリックではグラフ定義の存在を避けられません。例えば最後の/までを親にする等で回避できないかなーなんて考えています。(実際シェルでプラグイン実装するとそのように振る舞ってそう?)

個人的によく眺めるPlan 9情報

この記事はPlan 9 Advent Calendar 2019の6日目です。

Plan 9関連の情報を集めるときに、個人的によく使う場所を紹介します。これらの他にも、DiscordコミュニティやIRCチャンネルなどもあるようですが、だいたい9fans眺めていれば済む気はします。

ML

Plan 9、9front、plan9portなど全体的に話をする場所です。週に10〜20通くらいの流量。

plan9portの開発に関連する場所です。流量は少ないので、最近は9fans/plan9portのIssuesを眺めるだけでも良いかもしれない?

SNS

Plan 9関連の話題がたまにポストされるのでsubscribeしています。

たまに眺めています。エド・ウッドの映画や芸人さんの情報も混ざっているのでノイズは多め。

月に数回程度ですが、たまに眺めています。