Cloud Datastore用キャッシュパッケージの検討
現在App Engine(Standard Environment) + Go1.11環境*1でgoonというパッケージを利用し、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/datastore
のClientをそのまま使うことができる- 使い方が大きく変わるような付加機能は提供しない
- キャッシュのバックエンドを自由に選択できる
上記を実現するのに使用するのがgRPCのUnaryClientInterceptorというAPIです。
UnaryClientInterceptor
はリモート呼び出しの代わりに呼ばれる関数となっています。
DatastoreはgRPCで各種オペレーションを実行するため、NewClientのopts
にUnaryClientInterceptor
を渡すことでキャッシュ処理を差し込むことが可能です。
使用例:
// optを渡さなければキャッシュを無効にできる opt := option.WithGRPCDialOption( grpc.WithUnaryInterceptor(MemoryInterceptor), // grpc.WithUnaryInterceptor(MemcacheInterceptor), // grpc.WithUnaryInterceptor(RedisInterceptor), )
ただし、UnaryClientInterceptorに渡されるmethod
やreq
、reply
といった引数はDatastoreのClient
の各メソッドと1:1で対応しているわけではないため、どのメソッドがどのgRPC呼び出しになるのかを把握した上でキャッシュ戦略に応じた実装をしていく必要があります。
goon
に合わせたキャッシュ戦略とする場合、
- 非トランザクションでキー指定のデータ取得時にキャッシュの参照や保存を行い、
- Get
- GetMulti
- データ変更時にキャッシュの削除を行う
- Put/Transaction.Put
- PutMulti/Transaction.PutMulti
- Delete/Transaction.Delete
- DeleteMulti/Transaction.DeleteMulti
- Mutation/Transaction.Mutation
ので、Lookup
とCommit
の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 (キャッシュの参照や保存)
Lookup
はreq
としてLookupRequestを受け取り、結果はreply
にLookupResponseを設定します。
実装する処理としては次のような流れです。
1. reqのread_optionsをチェックし、トランザクション内だった場合はinvokerを呼び出してreturnする 2. reqのkeysを使ってキャッシュを参照する * 全てのデータが取得できればreplyのfoundに設定してreturnする 3. reqのkeysを2で取得できなかったキーのみにしてinvokerを呼び出す 4. replyのfoundに2で取得したデータを追加する 5. 3で取得したデータをキャッシュに書き込む
データが見つからなかった場合のハンドリングはClient
のGet
やGetMulti
内で行われるため、UnaryClientInterceptor
の中で特殊な処理をする必要はありません。
/google.datastore.v1.Datastore/Commit (キャッシュの削除)
Commit
はreq
としてCommitRequestを受け取り、結果はreply
にCommitResponseを設定します。
実装する処理としては次のような流れです。
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
すると良さそう。
素のVimでGoを書く時のテクニック
にて、
外部ネットワークに繋がらない環境で
vim
とgo
しか使えない
という縛りでライブコーディングをしてきました。
実際にそういう環境があるのか?という話はさておき、そのような状況でも以下を駆使してそこそこコードが書けます。
:%!gofmt
でコードを整形できる:r! go doc
の結果を適当なバッファに出力する- Vim内でドキュメントが読める
- ドキュメントの内容を
CTRL-N
やCTRL-P
で補完できる
:set path+=$GOROOT/src
で標準パッケージを検索できるようにする
作ったものは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に憧れており、元から自動補完は使っておらず、オムニ補完にもそこまで依存しないようにしていたので、このくらいのものであれば無くても特に困らなかったからです。
まあ常に役立つものではありませんが、覚えておくと何かの時に便利かもしれません。
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/app
はpkg
に変更app.go
はpkg.go
に変更
- 依存関係の管理に
go.mod
を使う- リポジトリのルートに配置
1) ./main.go
を作成する
. ├── app.yaml ├── go.mod ├── go.sum ├── main.go └── pkg ├── handler ├── ... └── pkg.go
2) ./cmd/app/main.go
を作成し、app.yaml
のmainでパスを指定する
. ├── app.yaml ├── cmd │ └── app │ └── main.go ├── go.mod ├── go.sum └── pkg ├── handler ├── ... └── pkg.go
この時、プライベートなパッケージ*2を使っているとCloud Buildで依存関係を解決できません。
対策としては、代わりにvendor
を使う、もしくはgo.mod
のreplace
ディレクティブを使う、のどちらかです。
しかし、vendor
を使う場合にはgo.mod
を.gcloudignore
に追加し、GO111MODULE
をoff
にしないといけません。
また、go.mod
を使うにはGO111MODULE
をon
にしないといけません。
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のsubmodules
やsubtree
で管理することになり、それが煩雑になってしまう可能性があります。
そういった場合、現段階だと1)をGOPATH
配下に置くのが無難です。
app.yaml
でmain
を指定することができませんが、今のプロジェクトでは必要なかったので以下のディレクトリ構成にすることにしました。
. ├── 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 参照
*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. コード署名
<?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からデータを取得するのにgoonのGoon.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%ほど増加していた...