G-gen の佐々木です。当記事では Spanner の組み込みのオートスケーリング機能である Managed autoscaler を紹介します。 前提知識 Spanner とは インスタンス、ノード、処理ユニット オープンソースのオートスケーリングツール Managed autoscaler とは スケーリングのトリガー リードレプリカのオートスケーリング 費用 制限事項 オートスケーリングの設定方法 検証 サンプルコード(Go) 環境変数の設定 Spanner インスタンスの作成 データの準備 データベースとテーブルの作成 データの挿入 挿入したデータの確認 Managed autoscaler の設定 オートスケーリングの動作確認 後処理 前提知識 Spanner とは Spanner は、強整合性を保証する RDB(リレーショナルデータベース)の特徴と、グローバルに水平スケーリングできる NoSQL データベースの特徴を併せ持つ、フルマネージドな RDB サービスです。 参考 : Spanner Spanner の詳細については、以下の記事も参照してください。 blog.g-gen.co.jp インスタンス、ノード、処理ユニット Spanner は インスタンス という単位で管理され、インスタンスに割り当てられたコンピューティングリソースがインスタンス内のデータベースに割り当てられます。インスタンスには ノード 、もしくは 処理ユニット (processing units)という単位でコンピューティングリソースを割り当て、1,000処理ユニットが1ノードに相当します。このリソースの量によってストレージの上限も決められており、コンピューティングリソースもしくはストレージのどちらかに合わせてインスタンスのスケーリングを行う必要があります。 Spanner におけるノードと処理ユニットの追加・削除(スケールアウト・スケールイン)は 無停止 で行われるのが特徴であり、データベース接続を切断することなく、需要に応じた柔軟な水平スケーリングを実現できます。 オープンソースのオートスケーリングツール 当記事で紹介する Managed autoscaler が一般提供(GA)になる以前は、オープンソースのオートスケーラーを使用して Spanner にオートスケーリングを設定することができました。 オートスケーラーツールは Cloud Scheduler、Pub/Sub、Cloud Run functions、Firestore といった Google Cloud のサービスによって構成されており、Cloud Monitoring API でインスタンスの負荷状況を確認し、Spanner インスタンスのスケーリングをトリガーします。 当記事ではオープンソースのオートスケーラーツールに関する解説は行いません。組み込み機能である Managed autoscaler が一般提供になったことから、基本的にはそちらを使用してスケーリングを設定することになるでしょう。 参考: Autoscaler tool overview 参考: cloudspannerecosystem/autoscaler(GitHub) Managed autoscaler とは Managed autoscaler は Spanner に組み込みのオートスケーリング機能です。負荷に応じてインスタンスのサイズ(ノード数または処理ユニット数)が自動的に調整されます。 参考: Autoscaling overview 参考: Managed autoscaler Spanner には3種類の エディション が存在し、エディションによって提供される機能と料金が異なります。Managed autoscaler は Enterprise と Enterprise Plus エディションで利用できる機能です。Standard エディションでは利用できません。 参考 : Spanner editions overview スケーリングのトリガー Managed autoscaler は、以下の要素に基づいてインスタンスのスケーリングを行います。 スケーリング基準 説明 高優先度 CPU 使用率ターゲット (High priority CPU utilization target) スケーリングをトリガーする CPU 使用率(10%~90% で設定)。 インスタンスの CPU 使用率がターゲット値を超えるとスケールアウトがトリガーされる。 CPU 使用率がターゲット値より大幅に低い場合にスケールインがトリガーされる。 ストレージ使用率ターゲット (Storage utilization target) スケーリングをトリガーするストレージ使用率(10~99% で設定)。 下限(Minimum limit) スケールインの下限(最小1ノードまたは1,000処理ユニット)。 Managed Autoscaler を設定すると、インスタンスのデフォルトのサイズがこの値になる。 上限(Maximum limit) スケールアウトの上限。 下限の10倍を超える値に設定することはできない(下限が3ノードであれば30ノードまで設定可能) Spanner は高優先度 CPU 使用率ターゲットとストレージ使用率ターゲットの値に基づき、それぞれで推奨容量を算出します。算出された推奨容量のうち、高いほうが自動で選択されてスケーリングが行われます。 たとえば、ストレージ使用率ターゲットにより10ノード、高優先度 CPU 使用率ターゲットにより12ノード必要であると算出された場合、Managed autoscaler はインスタンスを12ノードにスケーリングします。 スケーリングが起こると、ノード間でストレージの最適化が行われ、単一のノードが過負荷にならないようにトラフィックが均等に分散されます。 参考 : How managed autoscaler works リードレプリカのオートスケーリング Managed autoscaler では、 非対称読み取り専用オートスケーリング (Asymmetric read-only autoscaling)と呼ばれる、リードレプリカのみのスケーリングもサポートされています。 参考 : Asymmetric read-only autoscaling Spanner では3種類のレプリカ(読み取り・書き込み、読み取り専用、ウィットネス)を使用することができますが、この中で 読み取り専用レプリカ のみ独立したオートスケーリングを使用することができます。 参考 : Read-only replicas リードレプリカのオートスケーリングは、リージョンごとに以下のパラメータを設定することができます。 スケーリング基準 説明 高優先度CPU使用率ターゲット (High priority CPU utilization target) スケーリングをトリガーする CPU 使用率(10%~90% で設定)。 最小コンピューティング容量制限 (Minimum compute capacity limit) スケールインの下限(ノード数または処理ユニット数)。 最大コンピューティング容量制限 (Maximum compute capacity limit) スケールアウトの上限(ノード数または処理ユニット数)。 費用 Managed autoscaler によるオートスケーリングが行われると、確保されたリソースの分、追加で料金が発生します。 Managed autoscaler を設定すると、スケールインの下限として設定されたコンピューティング容量が常時確保されるため、これが利用料のベースラインとなります。したがって、必要以上に下限を大きくしないように調整する必要があります。 既存のインスタンスにオートスケーリングを設定する場合は、過去の CPU 使用率、ストレージ使用率など各種モニタリング指標を確認し、適切なスケールイン下限、スケールアウト上限を設定します。 制限事項 Managed autoscaler を有効化したインスタンスは、以下の制限があります。 Spanner エディションの Standard を利用できない Managed autoscaler を設定しているインスタンスは 移動 することができない スケールインの下限は最低 1ノード以上、または1,000 処理ユニット以上に設定する必要がある 既存のインスタンスでオートスケーリングを有効にしたとき、既存のインスタンスのコンピューティング容量がスケールインの下限よりも一時的に低くなる可能性がある( 既存のコンピューティング容量 < スケールインの下限 のとき) オートスケーリングの設定方法 Spanner インスタンス作成時または更新時に、Managed autoscaler を有効化することができます。 たとえば gcloud CLI を使用して、既存のインスタンスに対してオートスケーリングを設定する場合、以下のようにコマンドを実行します。CPU 使用率ターゲット値とストレージ使用率ターゲット値は、どちらも必須フラグとなっています。 # インスタンスを更新して Managed autoscaler を有効化する([]内はリードレプリカの設定) # ノード数を指定する場合 $ gcloud spanner instances update < SpannerインスタンスのID > \ --autoscaling-min-nodes =< 最小ノード数 > \ --autoscaling-max-nodes =< 最大ノード数 > \ --autoscaling-high-priority-cpu-target =< CPU使用率ターゲット値 > \ --autoscaling-storage-target =< ストレージ使用率ターゲット値 > \ [ --asymmetric-autoscaling-option \ location =< リードレプリカのロケーション > ,\ min_nodes =< 最小ノード数 > ,\ max_nodes =< 最大ノード数 > ,\ high_priority_cpu_target =< CPU使用率ターゲット値 >] # インスタンスを更新して Managed autoscaler を有効化する([]内はリードレプリカの設定) # 処理ユニット数を指定する場合 $ gcloud spanner instances update < SpannerインスタンスのID > \ --autoscaling-min-processing-units =< 最小処理ユニット数 > \ --autoscaling-max-processing-units =< 最大処理ユニット数 > \ --autoscaling-high-priority-cpu-target =< CPU使用率ターゲット値 > \ --autoscaling-storage-target =< ストレージ使用率ターゲット値 > \ [ --asymmetric-autoscaling-option \ location =< リードレプリカのロケーション > ,\ min_nodes =< 最小処理ユニット数 > ,\ min_processing_units =< 最大処理ユニット数 > ,\ high_priority_cpu_target =< CPU使用率ターゲット値 >] オートスケーリングはコンソールから設定することもできます。 コンソールからオートスケーリングを設定する 参考: Enable or modify managed autoscaler on an instance 検証 サンプルコード(Go) 当記事では Go のコードを使用して Spanner インスタンス内のデータベースに対する操作を行い、Managed autoscaler の挙動を確認します。 以下のコード(main.go)では、実行時の引数に応じてデータベースとテーブルの作成、データの挿入、データの取得などの操作を行います。 // main.go package main import ( "context" "flag" "fmt" "io" "os" "strconv" "sync" "cloud.google.com/go/spanner" "google.golang.org/api/iterator" database "cloud.google.com/go/spanner/admin/database/apiv1" adminpb "cloud.google.com/go/spanner/admin/database/apiv1/databasepb" ) type Database struct { projectId string instanceId string name string } // データベースとテーブルを作成する func createDB(ctx context.Context, w io.Writer , adminClient *database.DatabaseAdminClient, db Database) error { // データベースを作成し、Singners テーブルと Albumsテーブルを作成する op, err := adminClient.CreateDatabase(ctx, &adminpb.CreateDatabaseRequest{ Parent: "projects/" + db.projectId + "/instances/" + db.instanceId, CreateStatement: "CREATE DATABASE `" + db.name + "`" , ExtraStatements: [] string { `CREATE TABLE Singers ( SingerId INT64 NOT NULL, FirstName STRING(1024), LastName STRING(1024), SingerInfo BYTES(MAX) ) PRIMARY KEY (SingerId)` , `CREATE TABLE Albums ( SingerId INT64 NOT NULL, AlbumId INT64 NOT NULL, AlbumTitle STRING(MAX) ) PRIMARY KEY (SingerId, AlbumId), INTERLEAVE IN PARENT Singers ON DELETE CASCADE` , }, }) if err != nil { return err } // データベース作成完了を待つ if _, err := op.Wait(ctx); err != nil { return err } fmt.Fprintf(w, "Created database [%s] \n " , db) return nil } // データを挿入する func insertData(ctx context.Context, w io.Writer , client *spanner.Client) error { _, err := client.ReadWriteTransaction(ctx, func (ctx context.Context, txn *spanner.ReadWriteTransaction) error { // Singers テーブルにデータを挿入する _, err := txn.Update(ctx, spanner.Statement{ SQL: `INSERT INTO Singers (SingerId, FirstName, LastName) VALUES (1, 'Marc', 'Richards'), (2, 'Catalina', 'Smith'), (3, 'Alice', 'Trentor'), (4, 'Lea', 'Martin'), (5, 'David', 'Lomond')` , }) if err != nil { return err } // Albums テーブルにデータを挿入する _, err = txn.Update(ctx, spanner.Statement{ SQL: `INSERT INTO Albums (SingerId, AlbumId, AlbumTitle) VALUES (1, 1, 'Total Junk'), (1, 2, 'Go, Go, Go'), (2, 1, 'Green'), (2, 2, 'Forever Hold Your Peace'), (2, 3, 'Terrified')` , }) if err != nil { return err } return nil }) if err != nil { return err } fmt.Fprintf(w, "data inserted \n " ) return nil } // Singers テーブルと Albums テーブルを結合してデータを取得する func joinData(ctx context.Context, w io.Writer , client *spanner.Client) error { iter := client.Single().Query(ctx, spanner.Statement{ SQL: `SELECT Singers.FirstName, Singers.LastName, Albums.AlbumTitle FROM Singers JOIN Albums ON Singers.SingerId = Albums.SingerId` , }) defer iter.Stop() fmt.Fprintln(w, "SingerId, AlbumId, AlbumTitle" ) for { row, err := iter.Next() if err == iterator.Done { break } if err != nil { return err } var ( firstName, lastName, albumTitle string ) if err := row.Columns(&firstName, &lastName, &albumTitle); err != nil { return err } fmt.Fprintf(w, "%s %s %s \n " , firstName, lastName, albumTitle) } return nil } // Spanner に負荷をかけるため joinData を並行処理で実行する func concJoinData(ctx context.Context, w io.Writer , client *spanner.Client, n int ) error { c := make ( chan bool , 1000 ) // 並行処理数の上限を設定 var wg sync.WaitGroup for i := 0 ; i < n; i++ { c <- true wg.Add( 1 ) go func () { defer wg.Done() if err := joinData(ctx, io.Discard, client); err != nil { fmt.Fprintln(w, err) } <-c }() } wg.Wait() fmt.Fprintln(w, "Concurrent join completed" ) return nil } func run(ctx context.Context, w io.Writer , cmd string , db Database) error { dbStr := fmt.Sprintf( "projects/%s/instances/%s/databases/%s" , db.projectId, db.instanceId, db.name) // コマンドに応じて処理を実行 switch cmd { case "createdb" : ctx := context.Background() adminClient, err := database.NewDatabaseAdminClient(ctx) if err != nil { return err } defer adminClient.Close() if err := createDB(ctx, w, adminClient, db); err != nil { return err } case "insertdata" : ctx := context.Background() dataClient, err := spanner.NewClient(ctx, dbStr) if err != nil { return err } defer dataClient.Close() if err := insertData(ctx, w, dataClient); err != nil { return err } case "joindata" : ctx := context.Background() dataClient, err := spanner.NewClient(ctx, dbStr) if err != nil { return err } defer dataClient.Close() if err := joinData(ctx, w, dataClient); err != nil { return err } case "concjoin" : ctx := context.Background() dataClient, err := spanner.NewClient(ctx, dbStr) if err != nil { return err } defer dataClient.Close() // 環境変数から並行処理数を取得 n, err := strconv.Atoi(os.Getenv( "CONC_NUM" )) if err != nil { return err } if err := concJoinData(ctx, w, dataClient, n); err != nil { return err } default : fmt.Println( "Usage: main.go [createdb|insertdata|joindata|concjoin]" ) return nil } return nil } func main() { // 環境変数からプロジェクトID、インスタンスID、データベース名を取得 projectId := os.Getenv( "PROJECT_ID" ) instanceId := os.Getenv( "INSTANCE_ID" ) dbname := os.Getenv( "DB_NAME" ) db := Database{ projectId: projectId, instanceId: instanceId, name: dbname, } flag.Parse() if len (flag.Args()) == 0 || len (flag.Args()) > 1 { fmt.Println( "Usage: main.go [createdb|insertdata|joindata|concjoin]" ) os.Exit( 1 ) } cmd := flag.Arg( 0 ) ctx := context.Background() if err := run(ctx, os.Stdout, cmd, db); err != nil { fmt.Println(err) os.Exit( 1 ) } } コード実行時の引数と実行される関数、処理内容の対応は以下のようになっています。 引数 実行される関数 処理内容 createdb createDB Spanner インスタンスにデータベースを作成し、その中に Singers テーブルと Albums テーブルを作成する。 insertdata insertData Singers テーブルと Albums テーブルにデータを挿入する。 joindata joinData Singers テーブルと Albums テーブルを結合する SELECT クエリを実行する。 concjoin concJoinData joinData 関数を並行して実行する。Spanner インスタンスに負荷をかけるために使用する。 参考: Go で Spanner を使ってみる 環境変数の設定 実行するコマンドやコード内から参照する環境変数を、ローカルの Linux 実行環境に設定します。 当記事では Spanner インスタンスの名前を test-instance 、データベースの名前を example-db とします。 export PROJECT_ID = < プロジェクトID > export INSTANCE_ID =test-instance export DB_NAME =example-db Spanner インスタンスの作成 Spanner インスタンスを作成します。まずは Standard エディションのインスタンスを最小サイズで作成します。 オートスケーリングの設定はインスタンス作成時でも可能ですが、当記事では後から設定を行います。 # Spanner インスタンスを作成する $ gcloud spanner instances create ${INSTANCE_ID} \ --config = regional-asia-northeast1 \ --description =" Test Instance " \ --processing-units = 100 参考: gcloud spanner instances create データの準備 データベースとテーブルの作成 Spanner インスタンスにデータベースを作成し、2つのテーブルを作成します。 # データベースとテーブルを作成する $ go run main.go createdb このコマンドを実行すると、データベースが作成されたあと、コード内に直書きされた以下のクエリによってテーブルが2つ作成されます。 CREATE TABLE Singers ( SingerId INT64 NOT NULL , FirstName STRING( 1024 ), LastName STRING( 1024 ), SingerInfo BYTES( MAX ) ) PRIMARY KEY (SingerId); CREATE TABLE Albums ( SingerId INT64 NOT NULL , AlbumId INT64 NOT NULL , AlbumTitle STRING( MAX ) ) PRIMARY KEY (SingerId, AlbumId), INTERLEAVE IN PARENT Singers ON DELETE CASCADE; データの挿入 2つのテーブルに、コード内に直書きされたデータを挿入します。 # 2つのテーブルにデータを挿入する $ go run main.go insertdata 挿入したデータの確認 挿入したデータを確認するため、以下のコマンドでクエリを実行します。 # 2つのテーブルを結合する SELECT クエリを実行する $ go run main.go joindata 以下のようにクエリの結果が返ってきます。 # 出力例 $ go run main.go joindata SingerId, AlbumId, AlbumTitle Marc Richards Total Junk Marc Richards Go, Go, Go Catalina Smith Green Catalina Smith Forever Hold Your Peace Catalina Smith Terrified Managed autoscaler の設定 Spanner インスタンスの設定を変更し、Managed autoscaler を有効化します。 Managed autoscaler はエディションが Enterprise 以上のインスタンスで使用できるため、エディションも変更する必要があります。また、オートスケーリングの下限は 1ノード(1,000処理ユニット)以上にする必要があるため、ここでは1ノードに設定します。 # Spanner インスタンスに Managed autoscaler の設定をする $ gcloud spanner instances update ${INSTANCE_ID} \ --edition = ENTERPRISE \ --autoscaling-max-nodes = 3 \ --autoscaling-min-nodes = 1 \ --autoscaling-high-priority-cpu-target = 10 \ --autoscaling-storage-target = 80 当記事では以下のようにインスタンスを更新します。動作確認のため、オートスケーリングをトリガーする CPU 使用率は低めに設定しておきます。 フラグ 設定値 説明 --edition ENTERPRISE Spanner インスタンスのエディションを設定する。 ENTERPRISE_PLUS でも可。 --autoscaling-max-nodes 3 スケーリングの最大値をノード数で設定する。 処理ユニットで設定する場合は --autoscaling-max-processing-units を使用する。 --autoscaling-min-nodes 1 スケーリングの最小値をノード数で設定する(最小1)。 処理ユニットで設定する場合は --autoscaling-min-processing-units を使用する(最小1,000)。 --autoscaling-high-priority-cpu-target 10 オートスケーリングをトリガーする CPU 使用率の値を設定する。(%) --autoscaling-storage-target 80 オートスケーリングをトリガーするストレージ使用率の値を設定する。(%) 以下のコマンドでオートスケーリング有効化後の設定を確認します。 # Spanner インスタンスの詳細を確認する(出力抜粋) $ gcloud spanner instances describe ${INSTANCE_ID} autoscalingConfig: autoscalingLimits: maxNodes: 3 minNodes: 1 autoscalingTargets: highPriorityCpuUtilizationPercent: 10 storageUtilizationPercent: 80 参考: gcloud spanner instances update オートスケーリングの動作確認 オートスケーリングを設定した Spanner インスタンスに負荷をかけ、スケーリング動作を確認します。 まず、環境変数にクエリの実行数を設定します。 # クエリ実行数を設定する $ export CONC_NUM = 1000000 以下のコマンドを実行し、Spanner インスタンスに負荷をかけるための処理を実行します。 # 並行処理でクエリを実行し、Spanner インスタンスに負荷をかける $ go run main.go concjoin コマンド実行後少し待つと、Spanner の CPU 使用率がターゲット値を超過し、Managed autoscaler によってノードのスケールアウトが行われます。 Managed autoscaler によるスケールアウト その後、CPU 使用率が下がってからしばらく待つと、ノードのスケールインが行われます。スケールアウトと比較して、ターゲット値を下回ってからスケールインが行われるまでに少し長めの間隔があることがわかります。 Managed autoscaler によるスケールイン 後処理 Spanner は個人の検証目的としては高価なサービスのため、動作確認後は忘れずにインスタンスを削除します。 # Spanner インスタンスを削除する $ gcloud spanner instances delete test-instance 参考: Spanner pricing 佐々木 駿太 (記事一覧) G-gen最北端、北海道在住のクラウドソリューション部エンジニア 2022年6月にG-genにジョイン。Google Cloud Partner Top Engineer 2025 Fellowに選出。好きなGoogle CloudプロダクトはCloud Run。 趣味はコーヒー、小説(SF、ミステリ)、カラオケなど。 Follow @sasashun0805