Plan 9とGo言語のブログ

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

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しています。

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

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

意外と難しいos/execの話

Goのos/execパッケージは、別のコマンドを扱うためのパッケージですが、使い方を間違えたり、気が散っていたりするとリソースリークを引き起こす可能性があります。こないだリークするコードを目にしたので、どういった理由なのかも含めて紹介します。ここに書いたコードは単純化していますが、大筋は現実のコードと同じです。

v1

まず問題のあるコード。これは

3
4
9

のような1行に1つ数値が書かれたテキストを出力するコマンドを実行して、数値を合計を返す関数です。

func Sum(name string, args ...string) (int, error) {
    cmd := exec.Command(name, args...)
    r, err := cmd.StdoutPipe()
    if err != nil {
        return 0, err
    }
    if err := cmd.Start(); err != nil {
        return 0, err
    }

    var sum int
    scanner := bufio.NewScanner(r)
    for scanner.Scan() {
        s := strings.TrimSpace(scanner.Text())
        n, err := strconv.Atoi(s)
        if err != nil {
            return 0, fmt.Errorf("invalid number: %w", err)
        }
        sum += n
    }
    if err := scanner.Err(); err != nil {
        return 0, err
    }
    if err := cmd.Wait(); err != nil {
        return 0, err
    }
    return sum, nil
}

この実装の問題は、exec.Cmd.Waitしないところです。exec.Cmd.Start で生成したプロセスは、処理を終えても親プロセスに終了コードを返すまでは残り続けます。親プロセスはexec.Cmd.Waitで終了コードを取り出すため、この実装ではstrconv.Atoiがエラーになってしまうとプロセスを回収することができません。サービスのように稼働し続けるプログラムの場合、回収されないプロセスがそのうちプロセス数の上限に達してしまい、それ以上プロセスが作られずエラーになります。運が悪い場合は、kill(1)さえ起動できなくなって再起動するしか方法がなくなります。そのため、exec.Cmd.Startした場合は必ずexec.Cmd.Waitしなければなりません。

v2

次に、returnする前にexec.Cmd.Waitを呼ぶようにしたバージョンです。

func Sum(name string, args ...string) (int, error) {
    cmd := exec.Command(name, args...)
    r, err := cmd.StdoutPipe()
    if err != nil {
        return 0, err
    }
    if err := cmd.Start(); err != nil {
        return 0, err
    }

    var sum int
    scanner := bufio.NewScanner(r)
    for scanner.Scan() {
        s := strings.TrimSpace(scanner.Text())
        n, err := strconv.Atoi(s)
        if err != nil {
            cmd.Wait() // Waitするように変更、エラーはAtoiの方を返すので無視する
            return 0, fmt.Errorf("invalid number: %w", err)
        }
        sum += n
    }
    if err := cmd.Wait(); err != nil { // scanner.Errより先にWaitするように変更
        return 0, err
    }
    if err := scanner.Err(); err != nil {
        return 0, err
    }
    return sum, nil
}

これでstrconv.Atoiに失敗した場合は概ねプロセスが回収されるようになりますが、まだ問題は残っています。exec.Cmd.Waitプロセスが終了するまで待つので、何らかの原因によりプロセスが終了できない場合は無限に待ち続けてしまいます。具体的には、

#!/bin/sh

awk '
BEGIN {
  for(i = 1; i <= 100000; i++)
      print "10000a"
}
'

Sumに与えると、macOSの場合は途中で停止します(環境によって異なる場合があります)。呼び出す側はこんな雰囲気。

func main() {
    n, err := Sum("sh", "long.sh")
    if err != nil {
        log.Println("Sum:", err)
        continue
    }
    log.Println("Sum:", n)
}

この原因は、exec.Cmd.StdoutPipeでコマンドの出力をpipe(2)していますが、実はパイプにはバッファが存在するのでプログラムの出力がパイプのバッファを超えると、バッファが空くまでOSによって止められます。正常な場合はscanner.Scanが読み込みをするとバッファが空いて、後続の出力を書き出せるようになりすべての出力が終わればプロセスは終了しますが、上記のSum関数はエラーになったら以降を読まないため、ずっとバッファが解放されずにプロセスが終わりません。結果、Waitが無限に待ち続けることになります。

v3

これを解決する方法はいくつかありますが、個人的にはCommandContextを使う方法が無難かなと思います。

func Sum(name string, args ...string) (int, error) {
    ctx, cancel := context.WithCancel(context.Background())
    cmd := exec.CommandContext(ctx, name, args...)
    r, err := cmd.StdoutPipe()
    if err != nil {
        return 0, err
    }
    if err := cmd.Start(); err != nil {
        return 0, err
    }

    var sum int
    scanner := bufio.NewScanner(r)
    for scanner.Scan() {
        s := strings.TrimSpace(scanner.Text())
        n, err := strconv.Atoi(s)
        if err != nil {
            cancel()
            cmd.Wait() // Atoiのエラーを優先するのでWaitのエラーは無視
            return 0, fmt.Errorf("invalid number: %w", err)
        }
        sum += n
    }
    if err := scanner.Err(); err != nil {
        cancel()
        cmd.Wait() // scannerのエラーを優先するのでWaitのエラーは無視
        return 0, err
    }
    if err := cmd.Wait(); err != nil {
        return 0, err
    }
    return sum, nil
}

余談になりますが、StdoutPipepipe(2)を作っているので、ファイルが2つopenされた状態になります。ただしこのpipeは、StartでエラーになったりWaitが呼ばれたりすると閉じられるので、大きなリークに繋がることはおそらく無いでしょう。

今から始めるPlan 9

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

ベル研の公式ディストリビューションは、2015年1月に最後のメンテナだったGeoff Collyerさんが離職されたことにより、現在活動が停止していますが、代わりにコミュニティで派生したものがいくつかあり、現在はそれらを使う方が主流です。あまり情報がまとまっていないので、個人的に把握しているものについて今から始める人のために様子をまとめてみます。

Plan 9 from User Space (plan9port)

インストール方法は過去に書いたのでこちらを参照ください。

これはOSではなく、Plan 9ツールをUnixに移植したものです。当然、Plan 9カーネルが提供する名前空間の編成などは使えませんが、acme(1)エディタやrc(1)シェルなどをPlan 9ネイティブのものと同じように使えますし、9p(1)コマンドで各9pサービスが提供する名前空間も扱えます。date(1)などUnixと同名のコマンドも含まれていますが、既存のシェルスクリプトなどを壊さないために、通常はUnix側を優先するようになっています。Plan 9側のコマンドを使いたい場合、9 lsのように9(1)で切り替えます。

おそらく、acme(1)またはrc(1)を使ってもらうのが実用的ですが、どちらも個性的なので最初は全然使い方がわからないと思いますので今後どこかで使い方を紹介する予定です。rc(1)コマンドライン履歴の機能を持っていないので、rlwrapから起動すると良いでしょう。

開発はGitHubのIssueやPull Requestを使って行います。以前はRuss Coxさんがマージしていましたが、2019年後半あたりからDan Crossさんが主にマージしているようです。(plan9port-dev)

9front

確か2011年ごろ、Plan 9公式の更新が滞り始めた頃にforkしたディストリビューションです。新しいハードウェア、暗号スイートの追加などが行われているので、ネイティブで動作するPlan 9をインストールするには、9frontを使うのが一番簡単だと思います。

ただし現在、公式のPlan 9とは異なる部分がいくつかあります。例えばファイルシステムcwfsまたはhjfsの2択で、公式が採用しているfossilディストリビューションに含まれてさえいないので、本来の雰囲気を楽しみたい人には向きません。個人的にもインストールはしたことがありますが、使ってはいないので、参考リンクだけ。

開発は独自のMercurialリポジトリで行なっているようです。

9legacy

ベル研Plan 9からの変更をパッチとして提供しています。当然パッチ集なので、

  1. ベル研Plan 9を入れる
  2. インターネットに接続できるように設定する
  3. 9legacyのパッチを適用する
  4. コンパイラなどのリビルド
  5. カーネルのリビルド

など、慣れない環境でこれを行うのは大変です。Downloadページで、パッチを適用したISOイメージやディスクイメージも提供されているので、最初はこれらを使うのが良いでしょう。GoのGo Dashboardで稼働しているPlan 9は9legacyのはず。

メンテナはDavid du Colombier(@0intro)さん。 GitHubplan9-contribにPull Requestを送るとたまに拾ってくれますが、このリポジトリは9legacyと同期されていないので、どういう基準でパッチが採用されているのかは分かりません。

plan9-rpi

Plan 9Raspberry Pi用ポートです。メンテナはRichard Millerさん。最新のカーネルソースコード/sources/contrib/miller/9/bcmにあるようです。公式サイト等は特になさそう?でした。

残念ながらRPi持っていないので使ったことはありませんが、RPi 4で動いたとの噂は聞いたことがあります。インストールはPlan9 on raspberry pi 3が良さそうな雰囲気ですが、URLが古いのでplan9.bell-labs.com9p.ioに置き換えて読みましょう。

Harvey OS

Plan 9のコードをgccclangコンパイルできるように変更したディストリビューションです。ANSI/POSIX環境の拡張なども含まれているようです。ソースのビルドには、mkfileの代わりにbuild.jsonが使われます。

いつの間にかDockerイメージも作られるようになっていました。一応起動はしましたが、まだExperimentalなのでうまく動いてくれなかった...

$ docker run -ti --rm harveyos/harvey

ドキュメント

plan9.bell-labs.comのミラーが9p.ioに置かれています。Wikiインストーラ等もミラーされているので、ドキュメントを参照したい場合はこれか、cat-v.orgを読むといいでしょう。

macOS 10.15 Catalinaでexecvが失敗する

macOS 10.15では、execv(2)する前に実行していたプロセスの実行ファイルが削除されていると、execv(2)ENOENTを返す場合があります。どういうことかというと、

  1. a.outを実行
  2. a.out実行中にa.outファイルを削除(削除できる)
  3. a.outfork(2)して親は子プロセスを待つ
  4. 子プロセスがexecv(2)a.outとは別のプログラム(例えば/usr/bin/vm_stat)になる
  5. ENOENTエラーが返る

この動作は、発生する環境では常に発生しますが、しない場合は全く発生しないようです。現在確認できた環境だと、以下のような結果になりました。再現する場合としない場合で何が異なるのか分かっていませんが、Symantecなどのツールが入っていなくても再現するようでした。

  • macOS 10.15.1 (19B2106): 0/1の環境で再現しない
  • macOS 10.15.1 (19B88): 2/3の環境で再現する
  • macOS 10.14.x: 今のところ一度も再現しない

これを試してみたい場合、以下のプログラムを保存して、

cc program.c
./a.out /usr/bin/vm_stat

で実行してみてください。実行するとファイルが消えるので、もう一度行いたい場合は再コンパイルが必要です。

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <unistd.h>
#include <stdarg.h>

char *argv0;

static void
fatal(char *fmt, ...)
{
    va_list arg;

    va_start(arg, fmt);
    fprintf(stderr, "%s: ", argv0);
    vfprintf(stderr, fmt, arg);
    if(fmt[strlen(fmt)-1] == ':')
        fprintf(stderr, " %s\n", strerror(errno));
    va_end(arg);
    exit(1);
}

static void
run(char **args, int n)
{
    int i, pid, status;

    for(i = 0; i < n; i++){
        switch(pid = fork()){
        case -1:
            fatal("child fork:");
            break;
        case 0:
            if(execv(args[0], args) < 0)
                fatal("child exec:");
            break;
        default:
            if(waitpid(pid, &status, 0) < 0)
                fatal("child waitpid %d:", pid);
            fprintf(stderr, "child %s: exit %d\n", args[0], status);
            break;
        }
        sleep(1);
    }
}

static void
usage(void)
{
    fprintf(stderr, "usage: %s cmd [args...]\n", argv0);
    exit(2);
}

int
main(int argc, char **argv)
{
    int pid, status;

    argv0 = argv[0];
    if(argc <= 1)
        usage();
    if(unlink(argv0) < 0)
        fatal("unlink %s:", argv);
    switch(pid = fork()){
    case -1:
        fatal("fork failed:");
        break;
    case 0:
        run(argv+1, 100);
        break;
    default:
        if(waitpid(pid, &status, 0) < 0)
            fatal("waitpid %d:", pid);
        break;
    }
    return 0;
}

問題がない場合、vm_statの結果が100回出力されますが、エラーになる環境だと以下のエラーログが100回出力されます。

./a.out: child exec: No such file or directory
child vm_stat: exit 256

再現する場合としない場合で、macOSの仕様変更なのか何かのバグなのか分からないので、一応フィードバックアシスタントでAppleにフィードバックを送っておきました。

2020-12-10追記

同僚氏の調査によると、

  • posix_spawnやvforkなら発生しない
  • システム環境設定→セキュリティとプライバシー→プライバシータブ→デベロッパツールの「ターミナル」にチェックを入れる

のどちらかであれば回避できるらしい。syspolicydという機能による影響みたい。

macOSのセキュリティとプライバシー保護

plan9portのAcmeエディタを通常のmacOSアプリケーションと同じような感覚で使えるようにするため、起動処理をAppleScriptで実装したappパッケージを使っています(GitHub)。AppleScriptを使っているのは、シェルスクリプトだけではファイルのドロップが実現できなかったという事情があります。

Finderを制御するアクセスを要求しています

いつからだったか忘れましたが、このアプリケーションを実行した際に、突然macOSからアクセス要求ダイアログが表示されるようになりました。

f:id:lufiabb:20191015135019p:plain

なぜ表示されたのかわからないので、確認してから許可しようと思って「許可しない」を選ぶと、次にアプリケーションを起動してもアクセス要求ダイアログは表示されず、常にFinderにApple Eventsを送信する権限がありませんというエラーが発生するようになってしまいました。

f:id:lufiabb:20191015135045p:plain

インターネットによると、システム環境設定→セキュリティとプライバシーと進んで、プライバシータブからオートメーションを選択するとアクセスを要求したアプリケーションがリストされていると書かれていましたが、今回遭遇したケースでは何も表示されません。

f:id:lufiabb:20191015135101p:plain

こうなってしまった場合、tccutil(1)でリセットすると再びダイアログを表示させることができます。

$ tccutil reset AppleEvents

TCC

TCCとはTCC: A Quick Primerによると、Transparency, Consent, and Controlのことで、アプリケーションがユーザデータへ無制限にアクセスさせないための保護機構のようです。また、コマンドのマニュアルにはPrivacy Databaseという名称も見て取れます。今回のエラーはAppleEventsサービスの設定をリセットしていますが、これ以外にもPhotosCameraなどいくつかあります。Helping Your Users Reset TCC Privacy Policy Decisionsにサービスの詳細なリストが掲載されています。

Mojave時点では、残念ながらtccutil(1)resetサブコマンドしか持っていないので、何を許可しているのかを調べる方法はありません。データベースは~/Library/Application Support/com.apple.TCC/TCC.dbまたは/Library/Application Support/com.apple.TCC/にありますが、SIP(System Integrity Protection)によってrootでさえアクセスを拒否されるので、どうしても読みたければセーフモードで起動させる必要があります。

$ sudo ls /Library/Application\ Support/com.apple.TCC/
Password:
ls: : Operation not permitted

セーフモードでcom.apple.TCCを読むための手順は以下のリンクを参考にしてください。

System Policy

TCCの他にもSystem Policyというシステム保護機構が存在していて、TCCはユーザデータを保護するもので、これは名前の通りシステムを保護するためのものです。例えばシステム環境設定→セキュリティとプライバシーApp Storeと確認済みの開発元からのアプリケーションなどから許可した開発元などが管理されているようです。System Policyは、spctl(8)コマンドを使うと現在の設定内容を取得できます(--listオプションはマニュアルに載っていませんが...)。

$ sudo spctl --list
8[Apple System] P20 allow lsopen
        anchor apple
3[Apple System] P20 allow execute
        anchor apple
2[Apple Installer] P20 allow install
        anchor apple generic and certificate 1[subject.CN] = "Apple Software Update Certification Authority"
2711[Mac App Store] P10 allow install
        anchor apple generic and certificate leaf[field.1.2.840.113635.100.6.1.10] exists
5[Mac App Store] P10 allow install
        anchor apple generic and certificate leaf[field.1.2.840.113635.100.6.1.10] exists
...

$ sudo spctl --status
assessments enabled

これだけ見ても何も分かりませんね。System Policyをリセットするには、デフォルトデータベースをコピーすればいいです。これはセーフモードでなくても上書きできます。

$ sudo cp /var/db/.SystemPolicy-default /var/db/SystemPolicy

参考

ACL

ファイルの属性では、伝統的なパーミッションの他にACL(おそらくPOSIX ACL)が使われていて、パーミッションは間違っていないけどファイルの更新ができないといったことが発生します。ACLls -leで確認できるので、おかしいなと思ったら眺めてみましょう。

Acidの使い方(基本)

AcidはPlan 9またはPlan9portで使えるデバッガです。おそらくAcidの用途で最も多いのは、suicide*1したプロセスをアタッチして、stk()lstk()を使って落ちた様子をみてCtl-dで抜ける、ような使い方だと思いますが、Acidはこれ自体がシェルとCの中間みたいなプログラミング言語になっていて、やれることは非常に多いです。が、そこまで書くのは大変なので、今回の記事ではブレークポイントで止めて値を眺めるまでの基本的な使い方を書きました。

使い方

acidコマンドの引数にデバッグしたいファイルを渡すと、まだプロセスが作られていない状態でデバッガが起動します。acid:というプロンプトが表示されるので、引数が必要であればprogargsにスペース区切りで設定してプロセスを作成しましょう。

% acid /bin/git
/bin/git: 386 plan 9 executable
/sys/lib/acid/port
/sys/lib/acid/386

// 引数を設定(必要なら)
acid: progargs = "stash save Debugging"

// プロセス生成
acid: new()
1415: system call  _main       SUBL $0xc,SP
1415: breakpoint   main+0x3   CALL trace2_initialize_clock(SB)

// プロセスの処理を開始
acid: cont()

acidはプロセスIDを与えると直接そのプロセスをアタッチします。この場合は、プロセスはすでに動いているためnew()でプロセスを作成する必要はありません。

% acid <プロセスID>
/bin/git: 386 plan 9 executable
/sys/lib/acid/port
/sys/lib/acid/386

// スタックトレース取得
acid: stk()

Acidの変数

変数はプログラムのシンボルテーブルで扱うものがそのまま参照できます*2。具体的に書いた方が分かりやすいと思うので、以下のプログラムを説明のために使います。

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

typedef struct Addr Addr;
struct Addr {
    char *host;
    int port;
};

char *network = "tcp";

void
main(void)
{
    Addr a;

    a.host = "localhost";
    a.port = 9000;
    print("%s!%s!%d\n", network, a.host, a.port);
    exits(nil);
}

Acidはシンボルテーブルを参照します。このため、デバッグ対象プログラムの変数を参照するには、ローカル変数の場合はfunc:nameのように関数名と変数名を:で繋げてアクセスします。グローバル変数ならnameのように変数名だけでアクセス可能です。

acid: network
0x00006010

ただし、Acidから参照した変数はそのアドレスを表します。Cで言うと(void*)&networkのような扱いです。単項*演算子を使うと、networkが指しているメモリの値を取り出すことができます。ソースコード上では*networkはC文字列へのポインタですが、AcidではCの型情報が失われているので、ただの整数です。

acid: *network
0x00006850

Acidでは、Cの型とは別に変数のフォーマットが存在します。現在変数が持っているフォーマットはwhatisで調べたり、fmtで変更したりできます。そして*演算子がメモリから取り出す値のサイズはAcidの変数が持つフォーマットに依存します。例えばs(文字列)なら\0まで読み込みますし、D(4バイト整数)なら4バイト読みます。

それではnetwork変数が指している文字列を表示してみましょう。

acid: whatis network
integer variable format X
acid: x = fmt(*network, 's') // *network=xが指すアドレスには文字列が格納されている(x自体はただの整数)
acid: x = *network\s         // fmtのショートハンド(*の方が結合強い)
acid: whatis x
integer variable format s
acid: s = *x
string variable
acid: s
tcp
acid: *(*network\s)          // これでもいい
tcp

フォーマットを変更すれば、整数や浮動小数なども読み取れます。*と似たような@演算子もあるようです。利用可能なすべてのフォーマットはAcid Manualを参照してください。

構造体などのメンバ変数

構造体などの場合、Acidは型情報を持っていないのでメンバ変数を名前で参照できません。もちろん変数の先頭アドレスからオフセット分だけ加算すればアクセス可能ですが、アラインメントなどを考慮すると非常に面倒です。8c -aオプションを使うと、Acidで利用できる型を生成してくれるので、これを使うといいでしょう。他のコンパイラではDWARFとして埋め込まれているような情報も、Plan 9では人が読み書きできるようなテキストファイルとして分けて扱います。

# -nオプションを省略するとstdoutへ出力
# -aaオプションの場合は.hファイルの内容を出力に含まない
% 8c -an main.c

-aオプションが生成したファイルには、以下のようにCの構造体や共用体と同じ名前の型や関数が含まれます。

sizeofAddr = 8;
aggr Addr
{
    'X' 0 host;
    'D' 4 port;
};

defn
Addr(addr) {
    complex Addr addr;
    print("  host    ", addr.host\X, "\n");
    print("  port    ", addr.port, "\n");
};

これらのファイルは、acid -lオプションやinclude(string)などで読み込んで使います。

% acid -l ./main.acid 8.out
// キャストする場合
acid: x = (Addr)main:a
acid: x
     host 0x00006854
     port 9000
acid: *(x.host)
localhost

// 関数を使う場合
acid: Addr(main:a)
     host 0x00006854
     port 9000

ブレークポイント設定

ブレークポイントの設定はbpset(address)bpdel(address)関数で行います。acidはシンボルテーブルを参照するので、address引数には関数名をそのまま渡してもいいし、filepc(where)関数でファイル名と行番号から明示的に与えることもできます。また、現在設定中のブレークポイントが知りたければbptab()関数が使えます。

acid: bpset(strbuf_vinsertf)
acid: bpset(filepc("strbuf.c:262"))
acid: bpset(filepc("strbuf.c:274"))
acid: bptab()
    0x00236588 strbuf_vinsertf     SUBL    $0x20,SP
    0x002365ad strbuf_vinsertf+0x25       MOVL    0x8(BP),CX
    0x002366b4 strbuf_vinsertf+0x12c  MOVL    0x8(SI),CX
acid: cont()

ブレーク中の操作

ブレークポイントで停止すると、プロセスはStopped*3状態に遷移して、acidが入力を受け付けるようになります。

ソースコードレジスタの表示

停止している場所を調べるためにソースコードの参照ができます。

acid: src(addr)   // addrを中心に10行Cのソースを表示
acid: line(addr)  // addrの行だけCのソースを表示
acid: asm(addr)   // addrから30行アセンブリのソースを表示

PCレジスタに現在のプログラムカウンタが設定されているので、これを使うのが便利でしょう。

acid: src(*PC)
/tmp/x/git/strbuf.c:274
 269       strbuf_grow(sb, len);
 270       memmove(sb->buf + pos + len, sb->buf + pos, sb->len - pos);
 271       /* vsnprintf() will append a NUL, overwriting one of our characters */
 272       save = sb->buf[pos + len];
 273       len2 = vsnprintf(sb->buf + pos, len + 1, fmt, ap);
>274        sb->buf[pos + len] = save;
 275       if (len2 != len)
 276           BUG("your vsnprintf is broken (returns inconsistent lengths)");
 277       strbuf_setlen(sb, sb->len + len);
 278   }
 279   

演算もできるので現在位置から少し前も見られます。

acid: asm(*PC-10)
strbuf_vinsertf+0x122 0x002366aa   MOVL sb+0x0(FP),SI
strbuf_vinsertf+0x126 0x002366ae   MOVL len+0x1c(SP),BP
strbuf_vinsertf+0x12a 0x002366b2   MOVL AX,BX
strbuf_vinsertf+0x12c 0x002366b4   MOVL 0x8(SI),CX
strbuf_vinsertf+0x12d 0x002366b5   DECL SI
strbuf_vinsertf+0x12e 0x002366b6   ORB  CL,0xa048dea(CX)
strbuf_vinsertf+0x134 0x002366bc   ADDL pos+0x4(FP),AX
strbuf_vinsertf+0x138 0x002366c0   MOVBSX   save+0x17(SP),CX
strbuf_vinsertf+0x13d 0x002366c5   MOVB CL,0x0(AX)
...

レジスタに保存されている値はregs()などで表示できます。

acid: regs()
PC  0x002366b4 strbuf_vinsertf+0x12c  /tmp/x/git/strbuf.c:274
SP  0xdfffe884 ECODE 0xf01006d6 EFLAG 0x00000692
CS  0x00000023 DS   0x0000001b SS 0x0000001b
GS  0x0000001b FS   0x0000001b ES 0x0000001b
TRAP    0x00000003 breakpoint
AX  0x0000000b BX  0x0000000b CX  0x0074571b DX  0x004ebd7c
DI  0xffffffff SI  0xdfffeabc BP  0x00000016

他に、汎用レジスタ浮動小数レジスタだけ表示する関数もありますが、通常はregs()があれば十分でしょう。

スタックトレースの取得

stk()またはlstk()関数でスタックトレースを取得できます。lstk()stk()と似ていますが、ローカル変数の値も含めて出力されます。

ステップ

cont()またはstep()関数で処理を再開できます。

acid: step() // 1命令を実行
acid: cont() // なんらかで停止するまで実行

マルチプロセス

デバッグ中のプログラムがfork(2)した場合、新しいプロセスはStopped状態になって停止します。そのプロセスをデバッグしたい場合は

% acid <新しいプロセスのPID>

でアタッチしてcont()すればデバッグ可能ですし、処理を進めたいだけなら

% echo start >/proc/<新しいプロセスのPID>/ctl

で再開できます。プロセス操作の詳細はproc(3)を読んで下さい。

または、acid:プロンプトで待ち受けている状態であれば、以下の関数が使えるかもしれません。

acid: procs()      // アクティブなプロセスリスト取得
acid: setproc(pid) // pidをカレントプロセスに切り替え
acid: start(pid)   // 停止しているpidを再開

ライブラリ

acid-l libraryオプションを与えると/sys/lib/acid以下*4のライブラリを読み込みます。特にtrussは、実際に発行されたシステムコールを確認できるので便利です。

% acid -l truss /bin/git
acid: progargs = "xx"
acid: new()
acid: truss()

参考情報

*1:Unixで言うところのSEGV

*2:Acidの予約語と被った場合は名前の先頭に$を付ける

*3:suicideした場合はBroken

*4:p9pの場合は$PLAN9/acid