Plan 9とGo言語のブログ

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

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

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

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

意外と難しい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にフィードバックを送っておきました。