エムスリーテックブログ

エムスリー(m3)のエンジニア・開発メンバーによる技術ブログです

Spannerのノード数を時間帯ごとに変更する

エムスリーエンジニアリングGの遠藤(@en_ken)です。 BIRというチームでアンケートシステム周りの開発を担当しています。 この記事はエムスリー Advent Calendar 2021 20日目です。

BIRは特性ごとに異なる複数のアンケートシステムを抱えていますが、アンケートの配信処理自体はDolphinという1システムに集約しています。(このあたりのアーキテクチャの話はこちらの記事にまとめられています。)

DolphinのDBには、マネージドサービスでスケールが容易なことや、SQLを利用できることからCloud Spannerを採用しています。 このSpannerのDBに対し、時間帯に応じてノード数を変更する対応を行ったので紹介します。

なぜ必要になったか

端的に言えばコストの最適化のためです。

Dolphinに各アンケートの配信流入が集約されてきたことや、 他チームのシステム連携などが進んだことでAPI利用が増え、 DBのCPU使用率が徐々に増えてきました。

容易にスケールできるのがSpannerの良いところなので、 必要に応じてノード数を増やしてきましたが、ノードを増やした際のコストは無視できるものではありません。 東京リージョンの1ノードあたりの料金/時間 は記事執筆時点で$1.17なのでノードを1台増やすと年間100万前後のコストが増加することになります。

そこで負荷に合わせて無駄なくスケールアウト・スケールインすることでコスト低減を図ろうと考えました。

方針の決定

f:id:enkn:20211217100210p:plain

上記が、導入前におけるDolphinのSpanner DB 1日のCPU使用率の一例です。

最初にCPU負荷に応じて動的にスケールするようなスマートな方法も調査しましたが、 サービスの特性上、短時間に急激にアクセスが増加する時間帯があり、 負荷を検出してからスケールしていては対応できないことから検討から除外しました。

CPU使用率の過去の傾向を見てみると、アクセス数が多い朝・昼・夕方とバッチ系処理が走る夜間の一部のほぼ決まった時間でCPU使用率が高くなり、夜間はほとんど使用されていません。 傾向自体は一般的ではありますが、Dolphinでは過去数カ月分の負荷を確認しても同様の傾向の中に収まっていたため、時間帯ベースで必要なノード数を十分決定できそうだと判断しました。

このことから、時刻に応じてノード数を制御する方式を採用しました。

ノード数を可変にするに当たり、まずは以下の方針で時間帯ごとのノード数を調整しました。

  • 30分単位でノード数を調整
  • CPU使用率が1度でも65%を超える時間帯は1台追加
    • SpannerのモニタリングUI上に表示される「インスタンスあたりの推奨最大値」が65%
  • CPU使用率が30%を常時下回る時間帯は1台削減
    • 30%を切っていれば、台数を減らしても上記推奨最大値を超えることは無いと判断
  • それ以外は現状維持

実現方法

実現にあたり、GoでSpanner APIを利用する際にはmercariさんのブログが参考になりました。ありがたや。

engineering.mercari.com

異なる点として、以前のSpannerでは1ノード単位でスケーリングしかできませんでしたが、 今年のリリースで処理ユニットと言う単位でのスケールができるようになっています。 1ノード以下であれば、「1ノード=1000処理ユニット」として100処理ユニット単位で制御できるので0.1ノードずつスケールできるようになりました。1ノード以下の設定は現在プレビュー版という位置づけであること、1ノード以上の場合は1ノード単位のスケールになってしまうことなどから本番では利用できませんが、QA環境では利用価値がありそうです。

Goのライブラリでもこの仕様に追従しているので、より詳細な制御ができる処理ユニット数でスケールを指定する方針としました。

実行はもともとBIRで運用されているジョブ実行基盤の上に乗せるかたちでFargateのECSタスクとして実現しましたが、 今回はインフラ部分の詳細は割愛します。

スケーリングスケジュールの定義

スケーリングのスケジュールは定期的なチューニングが入ることが予想され、その度にコードを変更したくなったので環境変数で設定することとしました。 変更時刻と処理ユニット数を記述できるよう以下のようなjson形式を採用しました。

[
    {"change_at": "01:00", "processing_units": 1000},
    {"change_at": "05:00", "processing_units": 2000},
    {"change_at": "18:00", "processing_units": 3000},
    {"change_at": "20:00", "processing_units": 2000}
]

例えば、上記の例では

  • 01:00に処理ユニット数を1000(1ノード)にスケールイン
  • 05:00に処理ユニット数を2000(2ノード)に戻す
  • 18:00に処理ユニット数を3000(3ノード)にスケールアウト
  • 20:00に処理ユニット数を2000(2ノード)に戻す

を繰り返す動きになります。

スケーリング制御の実装

実際のコードからは大幅に省略していますが、スケーリングの制御自体は以下のコードで実現できます。

package main

import (
    "context"
    "fmt"
    "log"

    spannerClient "cloud.google.com/go/spanner/admin/instance/apiv1"
    "google.golang.org/genproto/googleapis/spanner/admin/instance/v1"
    "google.golang.org/genproto/protobuf/field_mask"
)


func main() {
    ctx := context.Background()

    //環境変数から現在あるべき処理ユニット数を取得(省略)
    units := GetProcessingUnitsNow() 

    client, err := spannerClient.NewInstanceAdminClient(ctx)
    if err != nil {
        log.Printf(": %+v", err)
        return
    }
    defer client.Close()

    instanceName := fmt.Sprintf("projects/%v/instances/%v", "GCPプロジェクト名", "SpannerインスタンスID")

    req := &instance.UpdateInstanceRequest{
        Instance: &instance.Instance{
            Name:            instanceName,
            ProcessingUnits: units,
        },
        FieldMask: &field_mask.FieldMask{
            Paths: []string{"processing_units"}, //変更したいフィールドを追加する
        },
    }
    op, err := client.UpdateInstance(ctx, req)
    if err != nil {
        log.Printf(": %+v", err)
        return
    }
    _, err = op.Wait(ctx)
    if err != nil {
        log.Printf(": %+v", err)
        return
    }

}

実装に利用したCloud Spanner APIのAPI仕様が興味深かったです。 UpdateInstance()メソッドの引数であるUpdateInstanceRequestにはインスタンスの設定値を表現するInstanceとともにFieldMaskフィールドがあり、 更新したい値は上記実装のProcessingUnitsのようにInstanceのフィールドに渡すのですが、これだけでは値は更新されません。 上記の実装でFieldMaskPathsに"processing_units"という文字列が渡っているように、PathsInstanceのフィールド名を渡すことでそのフィールドのみが更新されるようになっています。 一見複雑ですが、未設定のInstanceのフィールド値の更新がどうなるのかといった利用者側の混乱を招かないので良いI/F設計だなと思いました。

導入してみた結果

本番環境に導入して1ヶ月ほど経ちましたが、80%超えなど以前ほどCPU使用率が高い状態になることはほぼなくなりました。また、夜間のノード数を減らしましたがCPU使用率は安定して推移しています。今のところ想定外の負荷が発生している時間帯はないので、一旦は成功と言えそうです。

f:id:enkn:20211217100227p:plain

現状落ち着いているスケーリング設定をコストの面で見ると、本番環境のDBのみだと1割程度のコスト増となりましたが、 副次的な効果としてQA環境のDBは利用しない夜間に1ノード未満にするなどコスト減できたため、本番とQAの2環境の合計では以前の9割程度のコストに収まりました。

まとめ

時間帯ごとにSpannerのノード数(正確には処理ユニット数)を変更する方法を紹介しました。

適用結果として、以前より全体のCPU使用率は均されたかと思いますが、 依然として負荷が高い時間帯や低い時間帯はあります。 高い時間帯は対応していきたいと思いますが、低い時間帯はノード数の最適化をやりすぎると突発的な変化があったときに対応できないので、どこまでやるのかは悩ましいなと感じています。

また、新しい機能追加などがあった場合には傾向が変化する可能性があるため、継続的なモニタリングを続けていきたいと思います。

We're hiring!

Cloud Spannerを一緒にメンテナンスしてくれるメンバーを募集中です! 興味がある方は是非下のリンクから問い合わせてください。

jobs.m3.com