🦔

Cloud Run Jobsからpsqldefを使ってCloud SQLのDBをマイグレーションする(Terraform 使用)

2023/02/17に公開

sqldef(psqldef)とは?

sqldefridgepole にインスパイアされた DB マイグレーションツールです。
psqldef はその PostgreSQL 版です。
宣言的な記述でデータベースのスキーマを定義することで、DB のスキーマとの差分を自動的に migrate してくれるスグレモノです。
ridgepole と違い、Ruby の DSL を使う必要がなく、Ruby の実行環境がなくても実行できます。
しかし、もちろん psqldef 自体のインストールは必要となります。

モチベーション

Cloud SQL でホスティングされている DB のマイグレーションにはいくつか方法があります。

  1. Cloud SQL と接続するサービス自体にマイグレーションの処理を組み込んでおき自動的に実行する
  2. 限られた場所からのみ接続できるようにして、手元の開発環境などからマイグレーションする
  3. Cloud Migration Serviceを使用する
  4. マイグレーション用のサービスを別途用意し、Cloud SQL と接続する

1 の方法だと、サービスにマイグレーションツール等を含める必要があります。
今回はサービスを Cloud Run にデプロイする前提でしたので、Docker イメージのサイズが増大してしまうが嫌で選択しませんでした。
ただ、デプロイするサービスが Rails とかで、Rails 組み込みのマイグレーションツールや ridgepole を使用する場合は、この戦略になりそうです。

2 の方法だと、リモートワークで参加しているメンバー分の接続許可が面倒なのと、固定 IP じゃない場合の更新が面倒です。
また、マイグレーションを実行する環境のバージョン差等によって実行結果が異なってしまうことを防ぐことができません。

3 と 4 はおそらく似たようなアプローチになると思います。
マイグレーション用の環境を別途用意し、その環境と Cloud SQL を接続してマイグレーションを実行します。
今回は開発体験上の問題から、psqldef を使いたかったので、4 の手法を選択することになりました。

また、マイグレーション用の環境は、マイグレーションを実行するときのみ必要ですが、できるだけ簡単に起動して実行できるようにしたいと考えました。
そこで、Cloud Run Jobs を選択しました。

当初の方針(うまくいかなかった)

ドキュメントの通り、接続する SQL インスタンスを Run 側で指定しておくことで、UNIX ドメインソケット経由で Cloud Run と Cloud SQL を接続できます。(Cloud SQL でパブリック IP を構成済の場合のみ)

当初、これを利用して、Cloud Run Jobs と Cloud SQL を接続してマイグレーションを行おうとしました。
以下、terraform の Cloud Run Jobs 部分の抜粋です。

resource "google_cloud_run_v2_job" "<サービス名>-api-migrate-cloud-run-job" {
  name         = "<サービス名>-api-migrate-cloud-run-job"
  # 省略...

  template {
    template {
      service_account = google_service_account.<サービス名>-api-cloud-run-service-account.email
      volumes {
        name = "cloudsql"
        cloud_sql_instance {
          instances = [google_sql_database_instance.<サービス名>-api-sql-instance.connection_name]
        }
      }
      containers {
        image   = "${var.base_location}-docker.pkg.dev/${data.google_project.project.project_id}/<サービス名>-api/migration:latest"
        command = ["/app/setup_db.sh"]
        env {
          name  = "DB_HOST"
          value = "/cloudsql/${google_sql_database_instance.<サービス名>-api-sql-instance.connection_name}"
        }

        # 省略...

        volume_mounts {
          name       = "cloudsql"
          mount_path = "/cloudsql"
        }
      }
    }
  }
  # 省略
}

command にはマイグレーション用のスクリプトを指定しています。

#!/bin/bash

# 省略...

# DB_HOSTには`/cloudsql/...`が入っている
psqldef -U ${DB_USER} -h ${DB_HOST} ${DB_NAME} < db/schema.sql

うまくいかなかった理由

psqldef は引数 -h でホスト URL か UNIX ドメインソケットを指定できるようになっています。
よって、今回もそのように指定したのですが、接続できませんでした。
エラーメッセージを見ると 127.0.0.1:5432 に接続しようとしていました。

psqldef のソースコードを覗くと、-h に指定したファイルが存在すればソケットとして扱う、ということになっていました。

実際コンテナの /cloudsql の中身を見てみると README 以外のファイルは存在しませんでした。
この状態でも psql では、/cloudsql/<SQL接続名> にアクセスすることで、自動的に接続ができることは確認しました。
通常のソケットと異なり、ファイルは存在しないため、この方法では psqldef は使用できないことがわかりました。

(おまけ)その他ハマったところ

SQL インスタンス名が長すぎると、/cloudsql/<SQL接続名> が長くなり、psql でも接続できませんでした。
結果的にはパスが、長すぎることが原因でした。
公式ドキュメントに、ある通り、Linux ベースのオペレーティング システムでは、ソケットパスの最大長は 108 文字です。
しかも、/cloudsql/<SQL接続名> はシンボリックリンク的なもので、実際のパスは /tmp/cloudsql...(うろ覚え)となり、/cloudsql/... よりも長くなります。
SQL インスタンスの名前はできるだけ短くシンプルなものにしましょう。

うまく行った方針

psqldef ではソケットの指定がうまくできないとわかったので、プライベート IP で SQL インスタンスへ接続する方針に変更しました。
まず、SQL インスタンスをプライベート IP でも接続できるように設定します。

resource "google_compute_network" "<サービス名>-api-sql-private-network" {
  provider                = google-beta
  name                    = "<サービス名>-api-sql-private-network"
  auto_create_subnetworks = false
}

resource "google_compute_subnetwork" "<サービス名>-api-sql-private-subnetwork" {
  name          = "<サービス名>-api-sql-private-subnetwork"
  ip_cidr_range = "10.0.0.0/28"
  region        = var.base_region
  network       = google_compute_network.<サービス名>-api-sql-private-network.id
}

resource "google_compute_global_address" "<サービス名>-api-sql-private-ip-address" {
  provider = google-beta

  name          = "<サービス名>-api-sql-private-ip-address"
  purpose       = "VPC_PEERING"
  address_type  = "INTERNAL"
  prefix_length = 16
  network       = google_compute_network.<サービス名>-api-sql-private-network.id
}

resource "google_service_networking_connection" "<サービス名>-api-sql-private-vpc-connection" {
  provider = google-beta

  network                 = google_compute_network.<サービス名>-api-sql-private-network.id
  service                 = "servicenetworking.googleapis.com"
  reserved_peering_ranges = [google_compute_global_address.<サービス名>-api-sql-private-ip-address.name]
}

resource "google_sql_database_instance" "<サービス名>-api-sql-instance" {
  provider = google-beta

  # 省略...
  depends_on       = [google_service_networking_connection.<サービス名>-api-sql-private-vpc-connection]

  settings {
    # 省略...

    ip_configuration {
      ipv4_enabled                                  = true
      private_network                               = google_compute_network.<サービス名>-api-sql-private-network.id
      enable_private_path_for_google_cloud_services = true
    }
  }
}

そして、VPC アクセス経由で SQL インスタンスに接続します。

resource "google_vpc_access_connector" "<サービス名>-api-run-to-sql-vpc-connector" {
  machine_type = "f1-micro"
  name         = "vpc-connector"
  subnet {
    name = google_compute_subnetwork.<サービス名>-api-sql-private-subnetwork.name
  }
  min_instances = 2
  max_instances = 3
  region        = var.base_region
}

resource "google_cloud_run_v2_job" "<サービス名>-api-migrate-cloud-run-job" {
  name         = "<サービス名>-api-migrate-cloud-run-job"
  # 省略...

  template {
    template {
      service_account = google_service_account.<サービス名>-api-cloud-run-service-account.email
      containers {
        image   = "${var.base_location}-docker.pkg.dev/${data.google_project.project.project_id}/<サービス名>-api/migration:latest"
        command = ["/app/setup_db.sh"]
        env {
          name  = "DB_HOST"
          value = google_sql_database_instance.<サービス名>-api-sql-instance.private_ip_address
        }

        # 省略...

      }
      vpc_access {
        connector = google_vpc_access_connector.<サービス名>-api-run-to-sql-vpc-connector.id
        egress    = "ALL_TRAFFIC"
      }
    }
  }

  # 省略...
}

この方法だと、コンソールから Cloud Run Jobs のジョブを実行することで、マイグレーションを行うことができました。
これにより以下の恩恵を得ることができました。

  • サービス用イメージにはマイグレーションツールを含む必要がなくなりました
  • サービスと切り分け、マイグレーションステップを独立させたことで、スキーマ適用前のレビューがやりやすくなりました
  • コンソールからポチッとするだけなので、簡単にマイグレーションを実行できるようになりました

Discussion