Plan 9とGo言語のブログ

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

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が提供している型を公開メソッドで直接参照するのは良くないと思います

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)経由で起動するので=はクオートが必要