🐳

【GCP】転職先のRailsをreplicaに対応させたので色々記録

2022/10/05に公開

きっかけ

DB負荷的にそろそろだし今やっとく?みたいな流れ。

環境

まずrails側

  • ruby 3.0.3
  • Rails 6.1.6.1

そしてインフラ

  • GKE
  • CloudSQL(MySQL5.7)
  • CloudSQL Proxy

現状のインフラ構成と大まかな変更内容の確認

Kubernetes上のdefaultネームスペースでRails等、sqlproxyというネームスペースでCloudSQL Proxyが動いている。
defaultがDBにアクセスするときは、sqlproxy内のproxyに繋ぎ、DBとやり取りをしている状態。

今回はこれに、proxyを一つ追加しPrimaryDB用とReplicaDB用の二つのproxyを立てる構成で行こうと思います。

CloudSQLにreplicaインスタンスの追加

ここは特に説明することもないので、replicaを追加したいインスタンスのサイドバーから「レプリカ->リードレプリカを作成」でOKです。細かい設定は各自お好みで。

replica用のユーザー追加

後述しますが、今回のやり方ではRailsがreplicaに接続している時は自動でUpdate等を禁止してくれるのですが、念のためreplica用に権限を減らしたユーザーを追加しておきます。

GCP上から「ユーザー->ユーザーアカウントを追加」で追加してください。注意として、replicaインスタンスからはユーザー追加できませんので、primaryインスタンスで操作してください。

そして、このままの状態のユーザーは権限を過剰に持っていますので、SELECT権限のみ付与します。

mysql> revoke all privileges, grant option from '<YOUR_REPLICA_USER>'@'cloudsqlproxy~%';
mysql> grant select on <YOUR_REPLICA> to '<YOUR_REPLICA_USER>'@'cloudsqlproxy~%';

Railsの設定

過去にはreplicaとの接続の切り替えを勝手にやってくれるgemもあったらしいですが、今回はRails6から?標準で存在する機能を使って実現したいと思います。
標準機能を使う場合、主に二つ方法があって

  • GETやHEADのリクエストを受け取るとreplica、PUTやDELETEのリクエストを受け取るとprimaryに自動で接続する(もう少し正確に言うとロールを切り替える?)
  • replicaを使いたい時に都度設定を変える

となるのですが、今回はシステムの要件的に後者で実装することになりました。

database.ymlの修正

primaryとreplicaの二つのDBに対応させるため、以下のようなymlになります。

database.yml
default: &default
  adapter: mysql2
  pool: 5
  host: <%= ENV.fetch('RAILS_DB_HOST', '') %>
  username: <%= ENV.fetch('RAILS_DB_USER', 'root') %>
  password: <%= ENV.fetch('RAILS_DB_PASSWORD', 'rootroot') %>
  socket: <%= ENV.fetch('RAILS_DB_SOCKET', '/tmp/mysql.sock') %>
  port: <%= ENV.fetch('RAILS_DB_PORT', 3306) %>
  charset: utf8mb4
  collation: utf8mb4_bin
  encoding: utf8mb4

development:
  primary: # development環境でprimaryインスタンスに接続するための設定
    <<: *default
    database: development
  replica: # development環境でreplicaインスタンスに接続するための設定
    <<: *default
    database: development
    host: <%= ENV.fetch('RAILS_DB_REPLICA_HOST', '') %>
    username: <%= ENV.fetch('RAILS_DB_REPLICA_USER', 'replica') %>
    password: <%= ENV.fetch('RAILS_DB_REPLICA_PASSWORD', 'replica') %>
    replica: true # replicaはこれが必要

production:
  primary: # production環境でprimaryインスタンスに接続するための設定
    <<: *default
    database: production
  replica: # production環境でreplicaインスタンスに接続するための設定
    <<: *default
    database: production
    host: <%= ENV.fetch('RAILS_DB_REPLICA_HOST', '') %>
    username: <%= ENV.fetch('RAILS_DB_REPLICA_USER', 'replica') %>
    password: <%= ENV.fetch('RAILS_DB_REPLICA_PASSWORD', 'replica') %>
    replica: true # replicaはこれが必要

各環境ごとにprimaryとreplicaという設定が増えていて、それぞれ名前の通りインスタンスごとの接続設定を追加しています。
ここで重要なポイントとして、

database.yml
development:
  replica:
    ...
    replica: true # <-これ

このような値を指定してるのですが、このreplica: trueが設定されている接続がreplicaとして認識され、この接続を使うとInsertやUpdateはできなくなります。

application_record.rbの修正

次はapplication_recordになります。
先ほど設定した接続情報を実際に使える状態にします。

application_record.rb
class ApplicationRecord < ActiveRecord::Base
  ...
  connects_to database: { writing: :primary, reading: :replica }
end

以上でRailsでの準備は終了です。

Kubernetes

Cloud SQL Proxyはほぼコピペで同じものを二つ用意します。

service.yaml
apiVersion: v1
kind: Service
metadata:
  name: mysql-proxy
  namespace: sqlproxy
  labels:
    app: mysql-proxy
spec:
  ports:
    - name: mysql
      protocol: TCP
      port: 3306
      targetPort: mysql
  selector:
    app: mysql-proxy
---
apiVersion: v1
kind: Service
metadata:
  name: mysql-replica-proxy
  namespace: sqlproxy
  labels:
    app: mysql-replica-proxy
spec:
  ports:
    - name: mysql
      protocol: TCP
      port: 3306
      targetPort: mysql
  selector:
    app: mysql-replica-proxy
deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: mysql-proxy
  namespace: sqlproxy
  labels:
    app: mysql-proxy
spec:
  selector:
    matchLabels:
      app: mysql-proxy
  replicas: 1
  template:
    metadata:
      labels:
        app: mysql-proxy
    spec:
      containers:
        - name: sqlproxy
          image: gcr.io/cloudsql-docker/gce-proxy:1.28.0
          imagePullPolicy: IfNotPresent
          command:
	    - "/cloud_sql_proxy",
            - "--dir=/cloudsql",
            - "-instances=<YOUR_CONNECTION_NAME>=tcp:0.0.0.0:3306",
            - "-credential_file=/secrets/cloudsql/credentials.json",
          ports:
            - name: mysql
              containerPort: 3306
          volumeMounts:
            # ...
      volumes:
        # ...
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: mysql-replica-proxy
  namespace: sqlproxy
  labels:
    app: mysql-replica-proxy
spec:
  selector:
    matchLabels:
      app: mysql-replica-proxy
  replicas: 1
  template:
    metadata:
      labels:
        app: mysql-replica-proxy
    spec:
      containers:
        - name: sqlproxy
          image: gcr.io/cloudsql-docker/gce-proxy:1.28.0
          imagePullPolicy: IfNotPresent
          command:
	    - "/cloud_sql_proxy",
            - "--dir=/cloudsql",
            - "-instances=<YOUR_CONNECTION_NAME>=tcp:0.0.0.0:3306",
            - "-credential_file=/secrets/cloudsql/credentials.json",
          ports:
            - name: mysql
              containerPort: 3306
          volumeMounts:
            # ...
      volumes:
        # ...

つづいてアプリケーションのpodです。

deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: app
spec:
  selector:
    matchLabels:
      app: app
  replicas: 3
  template:
    metadata:
      labels:
        app: app
    spec:
      containers:
        - name: app
          image: asia.gcr.io/XXX
          env:
            - name: RAILS_DB_NAME
              value: "production"
	    # primary用
            - name: RAILS_DB_HOST
              value: "mysql-proxy.sqlproxy" # 他のnamaespaceのpodを参照するときの書き方
            - name: RAILS_DB_USER
              value: "root"
            - name: RAILS_DB_PASSWORD
              value: "password"
	    # replica用
            - name: RAILS_DB_REPLICA_HOST
              value: "mysql-replica-proxy.sqlproxy"
            - name: RAILS_DB_REPLICA_USER
              value: "replica"
            - name: RAILS_DB_REPLICA_PASSWORD
              value: "replica"
          ports:
            - containerPort: 3000
              name: app

primary用、replica用の環境変数を間違えないように定義しましょう。

以上で一通りの設定は完了になります。それでは、実際にreplicaに対してクエリを発行してみましょう。

replicaに対してクエリを発行する

replicaにクエリを発行する場合は、ActiveRecord::Base.connected_toというものを使う必要があります。

users = nil

ActiveRecord::Base.connected_to(role: :reading) do # <-これ
  users = User.all
end

render json: users

これでUser.allがreplicaに対して実行されます。逆に言うとActiveRecord::Base.connected_toを使わなければ(ブロックの外であれば)、primaryに対して実行されます。

以上でreplicaへの対応は終了になります。お疲れ様でした。

参考

https://railsguides.jp/active_record_multiple_databases.html

Discussion