Cloud Datastore用キャッシュパッケージの検討

現在App Engine(Standard Environment) + Go1.11環境*1goonというパッケージを利用し、Datastoreのデータをキャッシュしています。
goonはバックエンドとしてApp Engineのmemcache*2を使用しており、こちらがGo1.12から使えなくなってしまうので他のパッケージに移行しなければなりません。

App Engineのmemcacheに依存しない既存パッケージとしては、

などがありますが、どちらも独自のクライアントを作成するものとなっています。

goonを使っていて気になったのが、独自クライアント方式はGoogleが提供するパッケージには無い便利な機能があるというメリットがあるものの、本家の機能が全て使えなかったり、そもそもパッケージとしての使い方が異なっているため、Go1.11以降で推奨されているcloud.google.com/go/datastoreへの移行はもちろん、元になっているgoogle.golang.org/appengine/datastoreに戻すことすら手間がかかってしまうという点です。

機能面で困ることなく、ずっと使い続けることができるのであれば特に気にするところではないのかもしれませんが、goonの場合は更新があまり活発ではなく、移行時に大幅にコードを書き直さないといけないことがわかっているため、次のようなパッケージを検討することにしました。

  • cloud.google.com/go/datastoreClientをそのまま使うことができる
  • 使い方が大きく変わるような付加機能は提供しない
  • キャッシュのバックエンドを自由に選択できる

上記を実現するのに使用するのがgRPCのUnaryClientInterceptorというAPIです。
UnaryClientInterceptorはリモート呼び出しの代わりに呼ばれる関数となっています。 DatastoreはgRPCで各種オペレーションを実行するため、NewClientoptsUnaryClientInterceptorを渡すことでキャッシュ処理を差し込むことが可能です。

使用例:

// optを渡さなければキャッシュを無効にできる
opt := option.WithGRPCDialOption(
    grpc.WithUnaryInterceptor(MemoryInterceptor),
    // grpc.WithUnaryInterceptor(MemcacheInterceptor),
    // grpc.WithUnaryInterceptor(RedisInterceptor),
)

ただし、UnaryClientInterceptorに渡されるmethodreqreplyといった引数はDatastoreのClientの各メソッドと1:1で対応しているわけではないため、どのメソッドがどのgRPC呼び出しになるのかを把握した上でキャッシュ戦略に応じた実装をしていく必要があります。

Client gRPC
Get /google.datastore.v1.Datastore/Lookup
GetMulti /google.datastore.v1.Datastore/Lookup
Put /google.datastore.v1.Datastore/Commit
PutMulti /google.datastore.v1.Datastore/Commit
Delete /google.datastore.v1.Datastore/Commit
DeleteMulti /google.datastore.v1.Datastore/Commit
Mutate /google.datastore.v1.Datastore/Commit
Run /google.datastore.v1.Datastore/RunQuery
Count /google.datastore.v1.Datastore/RunQuery
GetAll /google.datastore.v1.Datastore/RunQuery
RunInTransaction NewTransaction後、CommitまたはRollback
NewTransaction /google.datastore.v1.Datastore/BeginTransaction
Transaction.Get /google.datastore.v1.Datastore/Lookup
Transaction.GetMulti /google.datastore.v1.Datastore/Lookup
Transaction.Put Transaction.Commitにまとめられる
Transaction.PutMulti Transaction.Commitにまとめられる
Transaction.Delete Transaction.Commitにまとめられる
Transaction.DeleteMulti Transaction.Commitにまとめられる
Transaction.Mutate Transaction.Commitにまとめられる
Transaction.Commit /google.datastore.v1.Datastore/Commit
Transaction.Rollback /google.datastore.v1.Datastore/Rollback

goonに合わせたキャッシュ戦略とする場合、

  • トランザクションでキー指定のデータ取得時にキャッシュの参照や保存を行い、
    • Get
    • GetMulti
  • データ変更時にキャッシュの削除を行う
    • Put/Transaction.Put
    • PutMulti/Transaction.PutMulti
    • Delete/Transaction.Delete
    • DeleteMulti/Transaction.DeleteMulti
    • Mutation/Transaction.Mutation

ので、LookupCommitの2つのRPCをインターセプトすることになります。

func(ctx context.Context, method string, req, reply interface{}, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error {
    switch method {
    case "/google.datastore.v1.Datastore/Lookup":
        // キャッシュの参照や保存
        return nil
    case "/google.datastore.v1.Datastore/Commit":
        // キャッシュの削除
        return nil
    }
    return invoker(ctx, method, req, reply, cc, opts...)
} 
/google.datastore.v1.Datastore/Lookup (キャッシュの参照や保存)

LookupreqとしてLookupRequestを受け取り、結果はreplyLookupResponseを設定します。

実装する処理としては次のような流れです。

1. reqのread_optionsをチェックし、トランザクション内だった場合はinvokerを呼び出してreturnする
2. reqのkeysを使ってキャッシュを参照する
   *  全てのデータが取得できればreplyのfoundに設定してreturnする
3. reqのkeysを2で取得できなかったキーのみにしてinvokerを呼び出す
4. replyのfoundに2で取得したデータを追加する
5. 3で取得したデータをキャッシュに書き込む

データが見つからなかった場合のハンドリングはClientGetGetMulti内で行われるため、UnaryClientInterceptorの中で特殊な処理をする必要はありません。

/google.datastore.v1.Datastore/Commit (キャッシュの削除)

CommitreqとしてCommitRequestを受け取り、結果はreplyCommitResponseを設定します。

実装する処理としては次のような流れです。

1. invokerを呼び出す
   * エラーだった場合はreturnする
2. reqのmutationsにupdate、upsert、deleteがあれば対応するキーのキャッシュデータを削除する
   * 削除に失敗した場合はエラーを返す

キャッシュの削除に失敗すると古いデータが残り続けてしまうため、

のいずれかを行わないとデータに不整合が生じてしまいます。

これについて、特に困っているのがRunInTransactionの場合、キャッシュ削除の失敗はロールバックの対象にならず、個別にロールバックを呼び出すこともできないというところです。
そのため、

  • キャッシュのバックエンドにリトライ処理を入れる
    • 無限にリトライしてしまう可能性もある...
  • キャッシュに有効期限を持たせる
    • 一定時間は不整合を許容できるのであれば...
  • キャッシュを削除してからDatastoreを更新する
    • キャッシュ削除〜Datastore更新の間に別の処理が入ると結局不整合になる
    • それならキャッシュ戦略を変更した方が...
  • いっそのことRunInTransactionを使わないようにする

などなど、このあたりはもう少し検討をしなければいけない状況です。
ただ実際に動かしてみないとわからないところもあると思うので、近いうちに実装して色々と試してみようと思っています。

Stackdriverの設定をterraformで管理する

今までアラートポリシーやカスタム指標などのStackdriverの設定をyamlやテキストで保存し、自作ツールで適用するようにしていましたが、自作ツールのメンテナンスが面倒になってきたのでterraformに移行することにしました。

移行にあたって、既存の設定ファイルは破棄し、terraformerを使って以下のように再作成しました。

1. 既存設定のインポート

PROJECT_ID="プロジェクトID"
BUCKET="gs://バケット名"
OUT="出力先ディレクトリ"
terraformer import google --resources=monitoring -o $OUT -p '{output}' --state=bucket --bucket=$BUCKET --projects=$PROJECT_ID

※terraformer v0.7.8の場合、terraform v0.12への対応ができていない部分があるので以下を行う必要があります

  • bucket.tfを修正
  • terraform 0.12upgrade を実行

※slack通知はauth_tokenがマスクされた状態だったので設定を削除し、terraform state rmで管理から外しました

2. カスタム指標の作成

terraformer v0.7.8はカスタム指標に対応していないため、gcloudコマンドで取得した内容を .tf に変換しました。

gcloud --project=$PROJECT_ID logging metrics list --format=json

このままだとterraform apply時に新規作成しようとしてエラーになってしまうため、terraform importでterraformの管理下に追加します。

3. 設定の適用

手元に一通りの設定が揃ったら適用します。

terraform fmt
terraform validate
terraform plan
terraform apply

※必須フィールドが無い場合はterraform apply時にエラーになるので個別に対応します

Datastore Emulatorのエンドポイント

Cloud Datastore エミュレータは起動した後、gcloudコマンドではなくREST APIを使って操作するらしい。
が、そのあたりの情報が全然まとまっていなかったので本体の中をチラ見して必要そうなところを調べてみた。

cloud-datastore-emulator 2.1.0
Method Path Description
GET / OKを返すだけ
POST /reset データをリセットする*1
POST /persist データを永続化する*2
POST /shutdown datastore emulatorを落とす
POST /_ah/admin/datastore /reset と同じ
POST /_ah/admin/quit /shutdownと同じ

ユニットテストで使う際は--no-store-on-diskをつけて起動し、

  • テストケースごとに POST /reset
  • テストが終わったらPOST /shutdown

すると良さそう。

*1:--store-on-diskの場合はヌルポ

*2:--no-store-on-diskの場合はnot supportedが返ってくる

素のVimでGoを書く時のテクニック

gorillavim.connpass.com

にて、

外部ネットワークに繋がらない環境でvimgoしか使えない

という縛りでライブコーディングをしてきました。

実際にそういう環境があるのか?という話はさておき、そのような状況でも以下を駆使してそこそこコードが書けます。

  1. :%!gofmtでコードを整形できる
  2. :r! go docの結果を適当なバッファに出力する
    • Vim内でドキュメントが読める
    • ドキュメントの内容をCTRL-NCTRL-Pで補完できる
  3. :set path+=$GOROOT/srcで標準パッケージを検索できるようにする
    • :findなどで標準パッケージのディレクトリやファイルを開ける
    • :grep*1 で色々と探せる
    • 開いたファイルはただ見るだけではなく、補完の対象にもできる

作ったものはGET /fizzbuzz/:numberにアクセスするとFizzBuzzを返すというHTTPサーバです。

package main

import (
    "net/http"
    "strconv"
    "strings"
)

func fizzbuzz(w http.ResponseWriter, r *http.Request) {
    p := strings.TrimPrefix(r.URL.Path, "/fizzbuzz/")
    if p == "" {
        http.NotFound(w, r)
        return
    }

    n, _ := strconv.Atoi(p)
    if n < 1 {
        http.NotFound(w, r)
        return
    }

    switch {
    case n%5 == 0 && n%3 == 0:
        w.Write([]byte("FizzBuzz"))
    case n%5 == 0:
        w.Write([]byte("Buzz"))
    case n%3 == 0:
        w.Write([]byte("Fizz"))
    default:
        w.Write([]byte(p))
    }
    w.Write([]byte("\n"))
}

func main() {
    http.HandleFunc("/fizzbuzz/", fizzbuzz)
    http.ListenAndServe(":8080", nil)
}

ただ当日は1.のgofmtしか使いませんでした。
というのもスパルタンなVimmerに憧れており、元から自動補完は使っておらず、オムニ補完にもそこまで依存しないようにしていたので、このくらいのものであれば無くても特に困らなかったからです。

まあ常に役立つものではありませんが、覚えておくと何かの時に便利かもしれません。

*1:grepコマンドがない場合は:vimgrep

App Engineのgo111用ディレクトリ構成

App Engine Standard EnvironmentでGo1.9を使う時は以下のように、リポジトリのルートをGOPATHとして設定し、appcfg.pyを使ってデプロイをしていました。

.
├── app.yaml
└── src
    └── app
        ├── glide.lock
        ├── glide.yaml
        ├── app.go
        ├── handler
        ├── ...
        └── vendor
  • app.go
package app

import (
        "net/http"

        "app/handler"
)

func init() {
        http.Handle("/", handler.New())
}

これがgo111のランタイムになると、main関数が必須になり、デプロイコマンドもgcloud app deployに変わります。*1

そのため、以下の条件を満たしつつ、どのようなディレクトリ構成にするのが良いのか考えてみました。

  • app.yamlのパスは変更しない
  • GOPATHリポジトリごとに変更しなくても良いようにする
    • src/apppkgに変更
    • app.gopkg.goに変更
  • 依存関係の管理にgo.modを使う

1) ./main.goを作成する

.
├── app.yaml
├── go.mod
├── go.sum
├── main.go
└── pkg
    ├── handler
    ├── ...
    └── pkg.go

2) ./cmd/app/main.goを作成し、app.yamlmainでパスを指定する

.
├── app.yaml
├── cmd
│  └── app
│      └── main.go
├── go.mod
├── go.sum
└── pkg
    ├── handler
    ├── ...
    └── pkg.go

この時、プライベートなパッケージ*2を使っているとCloud Buildで依存関係を解決できません。
対策としては、代わりにvendorを使う、もしくはgo.modreplaceディレクティブを使う、のどちらかです。

しかし、vendorを使う場合にはgo.mod.gcloudignoreに追加し、GO111MODULEoffにしないといけません。
また、go.modを使うにはGO111MODULEonにしないといけません。
GO111MODULEリポジトリGOPATHの配下に置くか、外に置かでautoの時の値が変わるため*3、組み合わせが非常に複雑です。

まとめると次のようになります。

依存管理 リポジトリの場所 GO111MODULE デプロイ可否
go.mod GOPATH配下 auto(=off) offだと使用不可
go.mod GOPATH配下 on OK
go.mod GOPATH外 auto(=on) OK
go.mod GOPATH外 off offにできない
vendor GOPATH配下 auto(=off) 1)はOK、2)はNG
vendor GOPATH配下 on NG
vendor GOPATH外 auto(=on) NG
vendor GOPATH外 off offにできない

※確認する際にCloud Buildで使われたイメージはgcr.io/gae-runtimes/go111_app_builder:go111_20190503_1_11_9_RC00です

正しくgo.modを使えば問題なくデプロイできるため、replace対象のパッケージが管理できるのであればこちらを使うのが良さそうです。
ただ、replace対象のパッケージはgitのsubmodulessubtreeで管理することになり、それが煩雑になってしまう可能性があります。

そういった場合、現段階だと1)をGOPATH配下に置くのが無難です。
app.yamlmainを指定することができませんが、今のプロジェクトでは必要なかったので以下のディレクトリ構成にすることにしました。

.
├── app.yaml
├── go.mod  // デプロイしない
├── go.sum  // デプロイしない
├── main.go
├── pkg
│  ├── handler
│  ├── ...
│  └── pkg.go
└── vendor  // `GO111MODULE=on go mod vendor` で作成

*1:https://cloud.google.com/appengine/docs/standard/go111/go-differences 参照

*2:GitHub Enterprise含む

*3:GOPATH配下: off、GOPATH外: on

MojaveでGDB 8.3を使う

1. インストール

brew install gdb --HEAD

2019/04/13時点でインストールされるバージョンは8.3.50。
2019/05/14以降は--HEAD不要。

echo "set startup-with-shell off" >> ~/.gdbinit

を実行するようにと表示されるが、ファイルの場所は$HOME/.config/gdb/initでも良い。

2. 証明書の作成

キーチェーンアクセスを起動し、メニューから

キーチェーンアクセス > 証明書アシスタント > 証明書を作成...

を選択。

以下を入力したら他はデフォルトのまま(期限は伸ばしておいても良い)ひたすら「続ける」を押していく。

名前: gdb-cert
証明書のタイプ: コード署名
✅: デフォルトを無効化

「ログイン」に作成されたgdb-certを「システム」にドラッグ&ドロップし、
証明書の情報を開いて「信頼」の「コード署名」を「常に信頼」に変更。

3. コード署名

gdb-entitlement.xmlを作成する。

<?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>com.apple.security.cs.debugger</key>
    <true/>
</dict>
</plist>

署名する。

codesign --entitlements gdb-entitlement.xml -fs gdb-cert $(which gdb)

4. 設定を反映させる

sudo pkill taskgated

goonのクエリをチューニングした時のメモ

App Engine(Go 1.9)でDatastoreからデータを取得するのにgoonGoon.Runを使っていたが、プロパティを追加する改修*1をした後、 latencyが遅くなり、DatastoreでDeadline exceededが頻繁に発生するようになってしまった。

とりあえず該当するリクエストをStackdriver Traceで見てみるとdatastore_v3.Nextが7回ほど呼ばれていることがわかった。

まずはこの呼び出し回数を減らせば速くなるだろうと思い、Query.BatchSizeを設定してみたが、呼び出し回数に変化は無く、全く効果がなかった。
それも変だなと思い、試しにデバッグログを仕込んで1回の呼び出しでどれくらいのデータを取得できているのか見てみたところ、なんと90件くらいしか取得できていなかった。

ここまで改修前の状態を確認していなかったので念のため調べてみると、datastore_v3.Nextの呼び出し回数は5回、1回あたりのデータ取得件数は約120件だった。
このことから、原因はプロパティを追加したことでデータのサイズが増え、一度に取得できる件数が減ったことによるDatastoreへのアクセス増ということがわかった。

ということで次はMemcacheを使ってDatastoreへのアクセスを減らすようにしてみた。
方法としては今までのクエリにQuery.KeysOnlyを設定してキーだけを取得するようにし、本体はGoon.GetMultiで取ってくるというシンプルなもの。
しかし、これだと1000件のデータを取得しようとするとGoon.GetMultiがMemcacheにデータを格納する際に大量のメモリを消費してしまうため、エラーになったり、インスタンスが落ちるようになってしまった。
そこでGoon.GetMultiの呼び出しが100件ずつになるようにしてみたところ、エラーは解消されたように見えたが、どうやらMemcacheにデータが乗っておらず、常にDatastoreにアクセスしてしまっていた。
仕方なく50件まで減らしたら安定してキャッシュが効くようになり、ようやく速度が改善されるようになった。

そもそもデータ構造がイマイチっぽいので本来はそこから手を入れるのが良さそうだが、今からそれをやるのもなかなかに厳しい状況なので当分はこれでしのぐことになりそう。

*1:後で確認したらデータサイズが80%ほど増加していた...