Cloud BuildでCloud SQLのマイグレーションをしたい

# 概要

CIでデプロイの自動化はよく行うかと思いますが、データベースのマイグレーション作業も自動化したかったりします。

GCPを使っているとCIは`Cloud Build`でDBは`Cloud SQL`を使うケースがあるかと思いますが、同じGCPサービスながらCloud BuildからCloud SQLに接続する過程で色々苦労があったので、備忘録を残します。

ちなみにマイグレーションはgooseというライブラリを用いています。

# 構成

コードの構成は以下のようになっています。

```
database/ # データベース関連のファイル置き場。
  db/
    dbconf.yml
    migrations/
      2019xxxxxxxxxx_hogehoge.sql
      2019yyyyyyyyyy_fugafuga.sql
docker/
  Dockerfile.migrate # マイグレーション用のコマンドを記載したDockerfile
```

Dockerfileの中身は以下の通りです。

```Dockerfile:Dockerfile.migrate

FROM golang:1.12-stretch as builder

RUN apt-get update && apt-get install -y \
  git \
  wait-for-it

ENV GO111MODULE on
RUN go get bitbucket.org/liamstask/goose/cmd/goose

# WORKDIRは自分のプロジェクトパスに設定
WORKDIR /go/src/github.com/kshibata101/sample-project/database
COPY . .

# envはdb/dbconf.ymlに書いているものを指定。今回はcloud_sqlへ接続する設定を用いる。
CMD goose -env cloud_sql up
```

db/dbconf.ymlに書いた内容を元にDBへの接続を試みます。

```yml:db/dbconf.yml

cloud_sql:
    driver: mysql
    open: $CLOUD_SQL_DSN
```

gooseはgo-sql-driver/mysqlを使っているようなので、openには[dsnの形式](https://github.com/go-sql-driver/mysql#dsn-data-source-name)で記載します。
dsnにはパスワードなどの設定情報が含まれるため、環境変数としてCloud Buildから入れるようにします。

# 事前準備

- IAM
  - Cloud BuildからCloud SQLへ接続するためにIAMでCloud Buildのサービスアカウントに対して、「Cloud SQL 管理者」を設定しておきます。
- Cloud SQL
  - マイグレーション対象となるDBを作成します。
  - このとき作成したインスタンスやDBの情報を後ほど使用します。

# マイグレーション手順

今回の手順は[似たようなことをしている記事](https://stackoverflow.com/questions/52352103/run-node-js-database-migrations-on-google-cloud-sql-during-google-cloud-build/52366671)があったため、そちらを参考にさせていただきました。

```yaml
# https://stackoverflow.com/questions/52352103/run-node-js-database-migrations-on-google-cloud-sql-during-google-cloud-build/52366671#
steps:
  - id: 'build'
    name: 'gcr.io/cloud-builders/docker'
    args: ['build', '--no-cache', '-f', 'docker/Dockerfile.migrate',
           '-t', 'gcr.io/$PROJECT_ID/migration:$_TAG', 'database/']
  - id: 'cloudsql-proxy'
    name: 'gcr.io/cloudsql-docker/gce-proxy'
    entrypoint: 'sh'
    args: ['-c', '/cloud_sql_proxy -dir=/cloudsql -instances=$_CLOUD_SQL_CONNECTION_NAME & while [ ! -f /cloudsql/stop ]; do sleep 2; done']
    volumes:
      - name: cloudsql
        path: /cloudsql
  - id: 'migration'
    name: 'gcr.io/cloud-builders/docker'
    args: ['run', '-e', 'CLOUD_SQL_DSN=$_CLOUD_SQL_DSN',
           '-v', 'cloudsql:/cloudsql', # volumeはhost側はpathではなくnameを指定しろとのこと
           'gcr.io/$PROJECT_ID/migration:$_TAG']
    volumes:
      - name: cloudsql
        path: /cloudsql
    waitFor:
      - 'build'
  - name: 'gcr.io/cloud-builders/docker'
    entrypoint: 'sh'
    args: ['-c', 'touch /cloudsql/stop']
    volumes:
      - name: cloudsql
        path: /cloudsql
    waitFor:
      - 'migration'
substitutions:
  _TAG: 'latest'
  _CLOUD_SQL_CONNECTION_NAME: '' # required
  _CLOUD_SQL_DSN: '' # required
images:
  - 'gcr.io/$PROJECT_ID/migration:$_TAG'

```

要点としては、Cloud SQL Proxyをwhile sleepで立ち上げておき、それと並列でマイグレーションの処理を実行するところ位ですが、順に見ていこうと思います。

## ステップ1: docker build

```yml
  - id: 'build'
    name: 'gcr.io/cloud-builders/docker'
    args: ['build', '--no-cache', '-f', 'docker/Dockerfile.migrate',
           '-t', 'gcr.io/$PROJECT_ID/migration:$_TAG', 'database/']
```

初めにbuildを行います。
`-t`で指定したタグ名については、gcr.io/$PROJECT_ID/以下は好きな名称を指定して問題ありません。

## ステップ2: Cloud SQL Proxy

```yml
  - id: 'cloudsql-proxy'
    name: 'gcr.io/cloudsql-docker/gce-proxy'
    entrypoint: 'sh'
    args: ['-c', '/cloud_sql_proxy -dir=/cloudsql -instances=$_CLOUD_SQL_CONNECTION_NAME & while [ ! -f /cloudsql/stop ]; do sleep 2; done']
    volumes:
      - name: cloudsql
        path: /cloudsql
    waitFor:
      - '-'
```

gce-proxyのクラウドビルダーを利用してCloud SQL Proxyを立ち上げます。

`$_CLOUD_SQL_CONNECTION_NAME` にはCloud SQLの`インスタンス接続名`を使います。
Cloud Buildのトリガー設定で代入変数に埋め込んでおきます。

立ち上げるコマンドがややトリッキーですが、Cloud Buildではステップが移ると立ち上げたプロセスが落ちてしまうので、単純なコマンドでは次のステップでproxy経由での接続ができません。

そこで、whileを使ってcloud_sql_proxyコマンドを立ち上げたままの状態を維持します。

ただし、whileをずっと残しておくと逆にいつまでもビルドが終わらないため、マイグレーションが終わったタイミングで `/cloudsql/stop` にファイルを作成(ステップ4)しそれを検知して処理が終わるように仕向けます。

ファイルやディレクトリもステップ間で共有されないのですが、こちらはvolumesのオプションを使うことで共有できるようになります。
`/cloudsql/stop` ファイルがあるか判定するため `/cloudsql` をvolumesに指定しておきます。

waitForオプションはCloud Buildにおいてビルドの順列化・並列化をできるようにするものです。
通常は書いた順番に処理されていきますが、waitForを指定することで特定のビルドが終わったらこれを実行する、といったことができるようになります。

次のステップ３が始まる前にはproxyを立ち上げておきたいため、waitForに`-`を指定します。
`-`のみ指定するとビルド開始直後に実行されるようになります。

## ステップ3: migration

```yml
  - id: 'migration'
    name: 'gcr.io/cloud-builders/docker'
    args: ['run', '-e', 'CLOUD_SQL_DSN=$_CLOUD_SQL_DSN',
           '-v', 'cloudsql:/cloudsql', # volumeはhost側はpathではなくnameを指定しろとのこと
           'gcr.io/$PROJECT_ID/migration:$_TAG']
    volumes:
      - name: cloudsql
        path: /cloudsql
    waitFor:
      - 'build'
```

先程ビルドしたdockerを起動することでマイグレーションを行います。

runの-eオプションで環境変数を渡せるため、DB接続に用いるDSN情報はCloud Buildの代入変数(ここでは `$_CLOUD_SQL_DSN` )を利用して埋め込みます。

```:dsn形式
[username[:password]@][protocol[(address)]]/dbname[?param1=value1&...&paramN=valueN]
```

Cloud SQLへの接続は公式でCloud SQL Proxy経由で行うことが推奨されているため、dsnもunix domain socket形式で記述することになります。

dsn上ではprotocolの部分を `unix` として記載します。
addressはproxyの立ち上げ場所にもよりますが、 今回はステップ2で`/cloudsql`をdirと指定したため、 `/cloudsql/{Cloud SQL インスタンス接続名}` が入ります。
最終的には以下のような値を`$_CLOUD_SQL_DSN`に設定します。

```
username:password@unix(/cloudsql/project-id:asia-northeast1:cloud-sql-instance-id)/database?charset=utf8mb4&parseTime=True&loc=UTC
```

また気をつけなければならないのが-vオプションです。
Cloud SQLに接続するproxyはホスト側の `/cloudsql` 以下に作られていますが、コンテナ側に渡す際の `-v` オプションでホスト側は`path`ではなく`name`を指定する必要があります。
([Issue](https://github.com/GoogleCloudPlatform/cloud-builders/issues/372)に言及あり）

つまりホスト側を`/cloudsql`などのパス形式にしていると、うまく接続ができないので注意してください。

`waitFor`のオプションについては、'build'を指定しているためステップ1が終わり次第実行されます。
前の項目で説明した通りステップ2はずっと実行され続けているため、ステップ2に依存する形にするといつまで経っても実行されません。ステップ1の後に実行されるようにします。

## ステップ4: touch /cloudsql/stop

```yml
  - name: 'gcr.io/cloud-builders/docker'
    entrypoint: 'sh'
    args: ['-c', 'touch /cloudsql/stop']
    volumes:
      - name: cloudsql
        path: /cloudsql
    waitFor:
      - 'migration'
```

migrationが終わった後に/cloudsql/stopにファイルを作成します。
これをするとステップ2がファイルの存在を検知して終了し、ビルド全体が完了されます。

# まとめ

ということでCloud BuildからCloud SQLのマイグレーションをする手順の紹介でした。

他にも方法はあると思いますが、多少強引でもCloud SQL Proxyを立ち上げてやる方法もあるよ、くらいに思っていただればいいかと思います。