⭐️Cloud RunとCloud SQL Enterprise Plusでアクセスの波を乗り越える基盤設計 メモ

https://cloud-ace.jp/event/260929/

発生する問題

  1. アクセス急増
  2. Cloud Runが自動スケール
  3. Cloud Runの裏側にいるCloud SQLはスケールしない
  4. Cloud SQLに設定されているmax_connectionsの値を超過
  5. Too many connectionsなどのエラーが発生
  6. サービスダウン(500エラー)

アプリケーション側(e.g. Prisma)のコネクションプールの仕組みだと対応が難しい。例えばPrismaのコネクションプールの仕組みで管理できるのは、あくまで1コンテナ(1コネクションプール)あたりのコネクション数。Cloud Runでコンテナが爆増したら、その分Cloud SQLへの接続数も爆増してしまう。

関連: Google Cloud Managed Service for PrometheusでPrismaのコネクション数をチューニングする

どう対応する? 従来の方法

  • リードレプリカを大量に立てる
    • アクセス増からリードレプリカを立てるまでに時間がかる
    • シンプルにコスト(お金)がかかる
    • 静的IPのリスト管理が複雑になりがち

あまり筋は良くなさそう…

ではどう対応する? Cloud SQL Enterprise Plusを用いた方法

※前提: Cloud SQLのEnterpriseエディションとEnterprise Plusエディションは異なるプランである点に注意

Cloud SQL Enterprise Plusは、Managed Connection Poolingを標準サポートしているので、これを利用する。

Managed Connection Poolingは、要は今回のようなケースでいい感じにDBへの接続(コネクション)を管理してくれる機能。Cloud Runのそれぞれのコンテナから来る大量のコネクションを束ね、管理することで、Too Many Connectionsエラーを防ぐことができる。

参考: https://docs.cloud.google.com/sql/docs/postgres/managed-connection-pooling?hl=ja

Managed Connection Poolingには以下の2つのモードがある。

  • Sessionモード
  • Transactionモード

推奨アーキテクチャ

                     Cloud Run (containers scale out)
                   |                                  |
             write / update                          read
      (many connections)                    (many connections)
                   v                                  v
  +--------------------------------+   +--------------------------------+
  | Managed Connection Pooling     |   | Managed Connection Pooling     |
  |   mode: Transaction            |   |   mode: Transaction            |
  |   many conns -> few conns      |   |   many conns -> few conns      |
  +--------------------------------+   +--------------------------------+
                   | few connections                  | few connections
                   v                                  v
  +--------------------------------+   +--------------------------------+
  | Primary (only 1 writer)        |   | Read Pool                      |
  |                                |   |   read endpoint (single IP)    |
  |                                |   |     +-- node (read replica)    |
  |                                |   |     +-- node (read replica)    |
  |                                |   |     +-- node  <- autoscale     |
  +--------------------------------+   +--------------------------------+
                   |            replication           ^
                   +----------------------------------+

※ 図では分けて描いたが、Managed Connection Poolingは別サーバーを立てるものではなく、各インスタンスで有効化する機能

2つの機能は別々の問題を解いているので、両方使う。

機能解く問題これだけだと…
Managed Connection Pooling接続数(大量の接続を少数に束ねる)読み取りもPrimaryに集中し、CPUが詰まる
Read Pool読み取り負荷(複数ノードに分散)各ノードのmax_connectionsが溢れる
  • Transactionモードは、DBへの接続を「トランザクション(BEGIN〜COMMIT)の間だけ」貸し出す。アプリはDBと繋がっていても、実際にSQLを投げているのは一瞬で、残りはほぼ待ち時間。その空き時間に他のクライアントへ回すので、少ない本数で大勢をさばける。write/readに関係なくデフォルトかつ推奨。モードはインスタンス単位の設定なので、処理ごとには変えられない
例: Cloud Runのコンテナ A, B が、それぞれアプリ側(Prismaなど)で接続を2本ずつ持つ
    → a1, a2, b1, b2 の計4本がDBへ来る([##] = トランザクション実行中、. = 待ち)

Sessionモード: 1本ずつ占有 → DB側も4本(max_connectionsの枠を4つ消費)
  DB接続1: a1 [##].......[##].....
  DB接続2: a2 ....[##].......[##].
  DB接続3: b1 ..[##].......[##]...
  DB接続4: b2 ......[##].......[##]

Transactionモード: [##] の間だけ借りて返す
  時間 →     t1      t2      t3      t4
  DB接続1:  a1[##]  b1[##]  a1[##]  b1[##]
  DB接続2:  a2[##]  b2[##]  a2[##]  b2[##]
  → どの時点でも同時に実行中なのは2つ → DB側は2本(枠を2つ消費)
    1本だと、もう片方は接続が空くまで待たされる

Sessionモードだと「DB側の接続数 = コンテナ数 × アプリ側の接続数」になり、Cloud Runが増えるほど掛け算で増える。Transactionモードなら、DB側は「同時に実行中のトランザクション数のピーク」だけで済む。

  • readもTransactionモードで問題ない。むしろ単発のSELECTは1クエリ = 1トランザクションなので、終わった瞬間に接続が返り、回転が速い
  • Sessionモードを使うのは、アプリが「接続に覚えさせた設定」に頼っているとき。Transactionモードでは、トランザクションごとに違う接続が割り当てられるので、前の接続で覚えさせた設定が次では消えている(共用PCで設定を変えても、次に座るのは別のPC、のイメージ)
Transactionモードで起きること
  a1: SET timezone = 'Asia/Tokyo'  → DB接続1 がこの設定を覚える
  a1: SELECT now()                 → DB接続2 に回される → timezoneの設定がない!

同じ理由で、PREPARE(Prismaのprepared statementなど)や一時テーブルも使えない。こうした機能に頼るアプリを改修せずに載せたいならSession。まずTransactionで試し、動かなければSession、の順

  • Read Poolの中身はPrimaryの読み取り専用コピー(Read Replica)なので、使えるのはreadだけ。writeは常にPrimary 1台で受ける(複数台に書くとデータの整合性が崩れるため)
  • writeの負荷対策は、スペックを上げる以外にもある
    • readをRead Poolへ逃がし、PrimaryのCPUを書き込みに専念させる(この構成自体がそう)
    • 書き込みを減らす・ならす: まとめて書く(バッチINSERT)、キュー(Pub/Sub、Cloud Tasks)に溜めて一定ペースで書く
    • それでも足りなければ、DBを機能ごとに分ける、または書き込みも台数で増やせるSpannerを検討する
  • **max_connections**は1台のDBが同時に受けられる接続の上限。Cloud Runが増えると接続も掛け算で増え、ノード追加も即時ではないので溢れる。だから接続プールで本数そのものを減らす

参考: Managed Connection Pooling / Configure Managed Connection Pooling / About read pools / Read pool autoscaling