GAE/Goにおけるコスト最適化 #golang

Go Advent Calendar 2018 2日目の記事です。1日目はtenntennさんの「実装して理解するスライス #golang」でした。

せっかくなので実益に繋がる話を書くべきだと思っておりまして、今回はGoConでGAE/Go 2nd genに対して注目が集まっていたのもあり、「GAE/Goでの運用コスト最適化」について書こうと思います。

(とはいえ、この分野だとGCPUG界隈のsinmetalさんやvvakameさん、apstndbさんあたりが圧倒的に詳しく、僕が書くことに関してはやや恐縮するのですが)

どれくらいコストを減らしたか

サービスを運営する中で、クライアントからAPIが叩かれるだけでなく、クローラー等による大量のアクセスがある場合、いくら小規模でもかなり費用が可算できます。そしてそれは安いと評判であるGAEでも例外ではありません。

お財布が困窮していたわけではないのですが、少なくとも圧迫はしていたので改善した結果、1/10に費用を削減することができました。ちなみに、改善前であっても無料のshared memcacheを呼び出したり、cron頻度を落とすなどはしていましたが、それを設定やコードで気遣えてなかったところを改善した、という感じです。

何がネックだったか

さて、GAEで僕のお財布から金をポンポン抜き取ってくれてたのは、主に以下の2つです

  • App Engine Cloud Datastore Read Ops(Datastoreの読み込み)
  • App Engine Frontend Instances(インスタンス稼働費)

僕はクローラーをバンバン動かすようなサービスを運用しているため、Readの方がはるかに圧迫していた、ということですね。そのため、Writeの方はそこまでお財布に響くようなものではありませんでした。

どうやってネックを解決したか

さて、本題です。これまで様々な資料を見たところ、以下のようなことに気をつければ費用は減るようだとわかりました。

  • Auto Scalingの設定変更によるインスタンスの固定費用の抑制
  • Edge cacheによる静的リソースキャッシュによる参照回数の軽減
  • Datastore Small Operations(KeysOnly)によるRead費用削減
  • Remoteのmemcache, ないしLocal InMemoryキャッシュのexpires調整
  • Batch RPCでの関連Entityの同時取得

上から順に具体的な対応を見ていきましょう。

Auto Scalingの設定変更

sinmetalさんがこの辺詳しいのですが、インスタンスがオートスケールしたり、待機してる際の設定をちゃんといじると、固定の費用がだいぶ抑えられます。僕の場合、クローラーが動いた後とかは待機してるだけのインスタンスが1つか2つくらいあって、それに費用がかかっていました。

実際に設定を適用したapp.yamlの設定は次のようになっています。

instance_class: F1
automatic_scaling:
target_cpu_utilization: 0.70
target_throughput_utilization: 0.70
min_idle_instances: 0
max_idle_instances: 1
min_instances: 0
min_pending_latency: automatic
max_pending_latency: automatic
max_concurrent_requests: 80

target_cpu_utilizationとtarget_throughput_utilization(CPU利用率、同時リクエスト数の閾値)は0.6がデフォルトですが、別に0.75とかでも全然動くので、0.7ならまだまだ大丈夫でしょう。

idle_instances, min_instancesの設定が今回のキモですね。1つのリクエストでめちゃくちゃにメモリを消費するような処理をしていない限り、大量のリクエストが来たタイミングで勝手にスケールしてくれるわけですが、待機時のインスタンス数に対して僕はもう少しナイーブになるべきだったようです。極力減らしていい場合は減らしましょう。特にユーザーアクセスがないAPIの場合は。

あとはmax_concurrent_requestsとかも気にすべきですね。デフォルトが8らしくてまじかよって感じなのですが、これも80くらいに増やしてしまって問題ないそうです。Task Queueの同時実行数が多く、その結果APIへのアクセスが大量に来てるのに処理できる数が少なかったりすると、レイテンシが増加したり、インスタンスの起動時間も長くなります。

追記)調子乗っていきなりあげすぎないようにしましょう。まあそんな障害起きたりはしてないんだけど、徐々に負荷見て調整するに越したことはなさそうです。

Edge cacheによる静的リソースキャッシュによる参照回数の軽減

めちゃくちゃ安価に利用でき、キャパも大きいと名高いGCP edge cacheの活用です。直接静的リソースのされずに費用削減をしたい場合は、レスポンスに以下のようにCache-Controlを含めましょう。

w.Header().Set("Cache-Control", "public, max-age=15")

Datastore Small Operations(KeysOnly)によるRead費用削減

正直、Auto Scalingの設定とこれが一番効きました。

DatastoreにはSmall Operationsという呼び出しがあり、具体的にはKeysOnlyというメソッドを経由して、EntityのKeyだけを取得する場合には、費用がかからないというものです。

これは具体的には、条件に一致するEntity数をカウントするクエリで活用できます。

b, err := infrastructure.BoomFromContext(ctx)
if err != nil {
return nil, err
}
query = b.Client.NewQuery("Video").Filter("Enabled = ", true)
co, err := g.Count(query.KeysOnly())

というのがKeysOnlyの用途紹介で多かったのですが、それよりも今回の場合、より多くの場面でキャッシュを有効化するために、KeysOnlyが力を発揮します。

例として、次のようなクエリをご覧ください。これは、公開日の降順でリソースを取得するようなDatastoreのクエリです。

これだと、memcacheのRead/Writeのオペレーションが走らず、毎度Datastoreにアクセスすることになります。呼び出し時点ではEntityのKeyが特定できないという当たり前の理由から、です。

一方で、KeysOnlyでKeyの一覧を先出ししておけば、キャッシュも有効化され、複雑なクエリでも無駄なRead Opsが走りません。キャッシュが有効な間はほぼ無料です。

このように、Keyの一覧を元にしてEntityを取得する共通メソッドを定義しておいて、それにKeysOnlyで取得したKey一覧を渡して上げることで、ほぼ全てのRead Opsでmemcache + Datastore Small Opsのコンボを使ったクエリ無料化が実現できます。

Remoteのmemcache, ないしLocal InMemoryキャッシュのexpires調整

普段Datastoreにアクセスするときは、goonではなくmercari.go/datastoreを使っているのですが、このライブラリのいいところは、キャッシュ用のミドルウェアのバインドがめちゃくちゃに楽だったり、RPC Retryの設定が楽なところにあります。

実はローカルのIn memoryキャッシュもサポートされてるので、このようにlocalとremoteのキャッシュを2つバインドして多段キャッシュを実現できます。(このコードを見るとまだaedatastoreとaememcache使ってることがバレてしまいますね!!!)

expiresも簡単に設定できますので、もしあんまり変化つけなくてもいいなら割と長く(それこそ24*time.Hourとか)してしまって、memdを使い倒しましょう。ログ出力をついでに設定しておくと、キャッシュされてなさそうなRead Opsにも気づけるのでぜひやっておきましょう。

なお、localcacheはサンプルとして載せましたが、僕は使うのをやめました。メモリ使用率が上がってインスタンスの作成回数が上がってしまうので、memdに寄せてしまうことで多少速度を(と言ってもそこまで大した変化ではないんだけど)犠牲にして、Auto Scalingの費用を抑えました。

Batch RPCでの関連Entityの同時取得

datastoreにおいて、GetMultiやPutMultiで一括のRPCコールをすることは、DatastoreのOpsを減らす上で最も重要で、やっていない人はいないレベルかと思います。しかし、Batchモードを利用した関連Entityの同時取得は、もしかしたらやっていないかも…

vvakameさんのqiita記事(後述の参考資料にあります)に詳しいですが、Batchモードで関連Entityを含めた一括のデータ取得ができます。Execするクエリを積み上げておいて、再帰的に実行してくれる便利なやつです。

例えばこうやって使います。関連EntityのIDを知っている場合は、それを一括でBatchモードで呼び出すと良さそうです。

ns, err := repo.GetNewsByIDs(ctx, nIDs)
if err != nil {
return nil, "", err
}

// Batch get NewsSources
bt, err := infrastructure.BatchFromContext(ctx)
if err != nil {
log.Printf(err.Error())
return nil, "", err
}

for _, n := range ns {
nss := &entity.NewsSource{}
nss.BatchFill(ctx, bt, n.NewsSourceID, nil)
n.NewsSource = nss
}

err = bt.Exec()
if err != nil {
return nil, "", err
}

今後注意するべきであろうポイント

さて、色々やって金が節約されて、いい飯が食えるようになったと思います。

ただ、料金に関して今後注意するべきポイントがいくつかあります。

Cloud MemoryStoreについて

今後、2nd genのGAEに移行する中で、無料のmemcacheインスタンスではなくリザーブドなRedisに移行して、多少の費用がかかることになると思います。また、格納できるデータ量にも上限がかかってくるので、今みたいにフリーダムなキャッシュができなくなりそうです。

Search APIについて

検索機能をSearch APIで実現している場合、費用はかなり抑えられますが、これも2nd genでなんらかの移行を行う必要が出てきそうです。ElasticSearch on GCE とかでしょうか…

なんにせよ、検索をしている場合は今よりも費用がかかりそうです。

まとめ

実益に繋がるGAE/Goのコスト最適化について書きました。金を失わない為にも、ぜひ最適な設定でGAEを活用してみてください。

参考資料