cli-kintoneでKintoneアプリを CloudStorage と cloudsql に自動バックアップ (1/2)

この記事は Kintoneアドベントカレンダー2022のために書かれました。

### きっかけ

Kintoneをビジネスで使うためにはデータのバックアップは必須です。Kintoneには ファイル書き出し機能があり、これでレコードの内容をCSV形式で書き出すことができます。しかしこれでは毎回人間がマニュアル操作によってバックアップを行う必要があり、自動化したいところです。また、標準のファイル書き出し機能ではローカルPCへのバックアップしか想定されておらず、「バックアップの属人性、属PC性（？）」を排除できません。バックアップは定期的に、自動的に、無限に行いたいものです。
そこで、KintoneアプリのレコードをGoogle Cloud Platformへ自動的にバックアップ、RDBにinsertする仕組みを ヴィップシステム社の中川さん と一緒に試験実装してみました。

### 試験実装プロジェクトの名前について
cli-kintoneなので「栗きんとん(clikinton)」としましたが、この名前はcli-kintoneのことをサイボウズ内部でもそう呼んでいるそうです。紛らわしいので後で名前を変えます。

### 利用サービス
今回のプロジェクトではGoogle Cloud Platform（GCP）を利用しています。

* Google Cloud Platform
  * Cloud Scheduler
  * Cloud Pub/Sub
  * Cloud Functions
  * Cloud Storage
  * Cloud SQL

### サイボウズ提供ツール
Kintoneのレコードをバックアップ／レストアするためのCLIツールです。
* cli-kintone 1.0

[kintone コマンドラインツール（cli-kintone）](https://developer.cybozu.io/hc/ja/articles/10663181361689)

### 構成図

1 KintoneデータをCloud Storageへ保存する
レコードをCSVファイルとしてCloudStorageに保存する
![clikinton構成図.002.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3060358/a9cd0508-1c75-c274-5460-3f4048e0ee81.jpeg)

2 KintoneデータをCloud SQLへ保存する
レコードをRDBにinsertする
![clikinton構成図.001.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3060358/d069558a-5be2-fa2d-bbc9-db551032da6d.jpeg)

### 処理の流れ
1. Cloud Schedulerで指定された日時にPub/Subをキックし、Pub/SubがFunctionsを呼びます。
2. Functionsはcli-kintoneを利用してKintoneアプリからレコードをCSV形式で取得し、そのレコードをCloud StorageへCSVファイルとして保存します。
3. Cloud SQLでDBへ挿入します。なぜRDBへ挿入するかというと、顧客企業の既存DBにKintoneのデータを入れられたら、そのデータを既存のDBと合わせて色々と活用することを想定しています。

### 事前準備
* GCPで利用する各サービスのアクティベート
* cli-kintone 1.0 Linuxバイナリのダウンロード（Cloud FunctionsがLinuxのため）
* Kintoneアプリのドメイン、アプリID、トークンを取得して控える。設定ファイルに記入するため
* Cloud Storageでバケット作成。バケット名は設定ファイルの当該項目と一致させる
* Cloud SQLでバックアップ先テーブルをcreateする。テーブル名、はコード内の当該項目と一致。カラム名はKintoneアプリのカラム名と一致。
以下のバックアップテーブルではカラム名をKintoneのそれと一致させる。レコード番号カラムの今回は試験実装なので各カラムの型は適当です。
```
   Column    |            Type             | Collation | Nullable | Default
-------------+-----------------------------+-----------+----------+---------
 record_num  | text                        |           | not null |
 company     | text                        |           |          |
 division    | text                        |           |          |
 name_kanji  | text                        |           |          |
 name_kana   | text                        |           |          |
 prefecture  | text                        |           |          |
 street      | text                        |           |          |
 telephone   | text                        |           |          |
 blood_type  | text                        |           |          |
 author      | text                        |           |          |
 create_date | timestamp without time zone |           |          |
 updater     | text                        |           |          |
 update_date | timestamp without time zone |           |          |
Indexes:
    "backup_pkey" PRIMARY KEY, btree (record_num)

```
### Cloud Functions

#### プロジェクト構成
KintoneレコードをバックアップするCloud Functionsプロジェクトは以下のような構成です：
* コードは `main.py` に記述し、必要なライブラリは `requirements.txt` に宣言します。
* `cli-kintone` 一式はあらかじめダウンロードしたLinuxバイナリをコピーします。
* バックアップ元Kintoneの情報やバックアップ先のCloud Storageの情報は、環境変数ファイル `.env.yaml` に設定します。
* コーディングが終われば、 `deploy.sh` を実行して、必要なファイルを `deploy/` にコピー、そこで `gcloud functions deploy` コマンドを実行します。

```
clikinton-py-gcp
├── README.md
├── .env.yaml // 環境変数
├── cli-kintone-linux
│   ├── LICENSE
│   ├── NOTICE
│   └── cli-kintone // Linux版実行バイナリ
├── deploy // Cloud Functionsにデプロイするファイル
│   ├── cli-kintone // （コピー）
│   ├── main.py // （コピー）
│   ├── .env.yaml // （コピー）
│   └── requirements.txt // （コピー）
├── deploy.sh // デプロイファイルを deploy/ に移動してデプロイコマンドを実行
├── index.py
├── main.py // Functionsのコード
├── requirements.txt // コードで必要なライブラリを宣言

```
### コード説明

Pub/Subからの呼び出しによって関数 `entry_point_name()` が呼ばれます。この関数は、まず環境変数 `.env.yaml` からKintone, GCPの設定値を読み込みます。
その後、 `cli-kintone` を使ってKintoneの指定アプリのレコードをCSVデータとして取得します。このCSVデータは1行目がヘッダ、2行目以降がボディなのですが、この後、CloudSQLにデータをinsertする際、ヘッダ行があるとエラーがでてしまいます。データのinsertには`googleapiclient` ライブラリを使います。しかし `sqladmin v1beta4` APIにはヘッダ行を無視するというPostgreSQLのCLIには実装されているオプションを使えないため、苦肉の策としてCSVファイルを2つ用意します。ヘッダあり版のCSVファイルを保存すると同時に、一時的にヘッダなしのCSVファイルを保存します。

もちろん、対象となるDBには予めバックアップ用のテーブルを `create` する必要があり、Kintoneアプリと同様のカラムをもたせます。 `sqladmin` APIを利用するときも明示的にカラムを指定していますが、ハードコーディングでは柔軟性がないため、将来的には以下のAPIを利用して動的にカラムを取得するつもりです。

https://developer.cybozu.io/hc/ja/articles/204783170

Cloud Scheduler契機で呼ばれた `insert_to_rdbms()` 関数は、 cli-kintoneを実行して指定のKintoneアプリからレコードをCSV形式で取得、そのデータを Cloud StorageとCloudSQLに保存します。
Cloud SQLにデータを挿入するAPIである `googleapiclient` 経由でCSVデータを挿入する際、1行目にヘッダ行が存在するとエラーが出てしまうため、ヘッドレスのCSVと、ヘッダありCSVの2つのファイルを生成していることに注目してください（この無駄な実装は、APIの仕様が変わらない限り変更できません）。
関数 `delete_record_number()` では、取得したCSVデータから「レコード番号」カラムを削除しています。これは、レコードをKintoneに復帰させる際、元あるデータとレコード番号が重複すると復帰できないためです。

``` main.py
import base64
import functions_framework
import subprocess
import os
import datetime
import google.auth
import io
import pandas
from google.cloud import storage


# Triggered from a message on a Cloud Pub/Sub topic.
@functions_framework.cloud_event
def entry_point_name(cloud_event):
    # Print out the data from Pub/Sub, to prove that it worked
    print(base64.b64decode(cloud_event.data["message"]["data"]))

    domain = os.environ.get('DOMAIN')
    app_id = os.environ.get('APPID')
    token = os.environ.get('TOKEN')
    bucket_name = os.environ.get('BUCKET')

    args = f"record export --base-url https://{domain}.cybozu.com --app {app_id} --api-token {token}"
    res = subprocess.run([f"./cli-kintone {args}"], shell=True, encoding='utf-8', capture_output=True, text=True)
    # TODO "レコード番号"カラムを削除するがRDBにinsertする際もそれでよいか？
    record_num_deleted = delete_record_number(res.stdout)
    # とりあえず時間帯はGMT+9=JST決め打ち
    t_delta = datetime.timedelta(hours=9)
    jst = datetime.timezone(t_delta, 'JST')
    now = datetime.datetime.now(jst)
    fmt = now.strftime('%Y%m%d%H%M%S')
    # googlecloudapi ではCloudSQLへデータをインポートする際にヘッダ行を無視するオプションが存在しないため
    # CloudSQLへimportするためのヘッダなしCSVを保存する（再利用しないので後で消すこと）
    file_name_headless = f"{domain}/{app_id}/{fmt}_headless.csv"
    csv = '\n'.join(record_num_deleted.splitlines()[1:])
    upload_csv(csv, bucket_name, file_name_headless)
    # データをKintoneへimportする際はヘッダ付きCSVファイルを利用する
    file_name = f"{domain}/{app_id}/{fmt}.csv"
    upload_csv(record_num_deleted, bucket_name, file_name)
    # TODO file_name_headless を指定するのではないか？
    import_csv_to_rdb(bucket_name, file_name)
    # ...headless.csv は不要になったので削除する
    delete_csv(bucket_name, file_name_headless)


# CSVデータから"レコード番号"カラムを削除して返す
def delete_record_number(csv_buffer):
    df = pandas.read_csv(io.StringIO(csv_buffer))
    # Kintoneのデフォルト名が"レコード番号"のため
    res = df.drop(['レコード番号'], axis=1)
    buffer = io.StringIO()
    res.to_csv(buffer, index=False)
    return buffer.getvalue()


# 指定したファイルを削除する
def delete_csv(bucket_name, blob_name):
    storage_client = storage.Client()
    bucket = storage_client.bucket(bucket_name)
    blob = bucket.blob(blob_name)
    blob.delete()


# Cloud Storageにファイルをアップロードする
def upload_csv(csv, bucket_name, file_name):
    storage_client = storage.Client()
    bucket_name = storage_client.get_bucket(bucket_name)
    blob = bucket_name.blob(file_name)
    # TODO byte convertエラーが出るためstr()をしているがこの方法は新しい変数を生成して返しているため無駄が多い
    blob.upload_from_string(data=str(csv), content_type='text/csv')


# CloudSQLへCSVファイルをimportする
def import_csv_to_rdb(bucket_name, file_name):
    from googleapiclient import discovery

    credentials, project = google.auth.default()
    service = discovery.build('sqladmin', 'v1beta4', credentials=credentials, cache_discovery=False)
    # Project ID of the project that contains the instance.
    # Cloud SQL instance ID. This does not include the project ID.
    instance = 'YOUR_INSTANCE_NAME_HERE' # TODO .env.yaml に設定しよう
    uri = 'gs://{}/{}'.format(bucket_name, file_name)
    instances_import_request_body = {
        "importContext": {
            "fileType": "CSV",
            "uri": uri,
            "database": 'postgres',
            "kind": "sql#importContext",
            "csvImportOptions": {
                "table": "backup",
                "columns": [
                    # TODO Kintone APIから取得すること
                    "record_num",
                    "company",
                    "division",
                    "name_kanji",
                    "name_kana",
                    "prefecture",
                    "street",
                    "telephone",
                    "blood_type",
                    "author",
                    "create_date",
                    "updater",
                    "update_date",
                ]
            }
        }
    }
    request = service.instances().import_(project=project, instance=instance, body=instances_import_request_body)
    response = request.execute()

```
### 環境設定ファイル

コードとは別に変更する情報、VCSで管理したくない情報はここに記述します。また、実運用のための実装ならトークンなどの重要な情報はSecret Managerなどの専用のサービスで管理すべきかと思います。

``` .env.yaml
DOMAIN: "yourdomain" # Kintoneのドメイン
APPID: "1" # KintoneのApp ID
TOKEN: "yourtokenhere" # KintoneアプリのTOKEN
BUCKET: "yourbucketname" # CLoud Storageのバケット名
```

### デプロイ スクリプト
デプロイ時に毎回長いコマンドを打つのが面倒なので、このように簡単なスクリプトに最低限の手順を記述しました。

``` deploy.sh
#!/bin/sh

cp main.py deploy/main.py
cp requirements.txt deploy/
cp .env.yaml deploy/
cp cli-kintone-linux/cli-kintone deploy/
cd deploy

gcloud functions deploy insert-to-rdbms \
--gen2 \
--region=asia-northeast1 \
--runtime=python310 \
--entry-point=entry_point_name \
--env-vars-file=.env.yaml \
--trigger-topic=your-topic
```
### Function実行結果

#### 
KintoneからバックアップしたレコードがCloud Storageに保存されました。
![スクリーンショット 2022-12-18 8.13.58.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3060358/e0cece19-d0da-9537-3169-5981ff91fecd.png)


CloudSQLで該当DBに接続し、 `select * from backup;` でレコードを確認するとバックアップが取れていました。
```
record_num |   company   |  division  |   name_kanji   |    name_kana     | prefecture |       street       |  telephone   | blood_type |       author       |     create_date     |      updater       |     update_date
------------+-------------+------------+----------------+------------------+------------+--------------------+--------------+------------+--------------------+---------------------+--------------------+---------------------
 1          | なんとか社1 | 営業部1    | 山田太郎1      | やまだたろう1    | 神奈川県1  | ka@nantoka.co      | 03-1111-2222 | A          | hi@ka.co | 2022-09-24 06:43:00 | hi@ka.co | 2022-09-24 06:43:00
 2          | なんとか社2 | 営業部2    | 山田太郎2      | やまだたろう2    | 神奈川県2  | ka@nantoka.co      | 03-1111-2223 | A          | hi@ka.co | 2022-09-24 06:43:00 | hi@ka.co | 2022-09-24 06:43:00
 3          | なんとか社3 | 営業部3    | 山田太郎3      | やまだたろう3    | 神奈川県3  | ka@nantoka.co      | 03-1111-2224 | A          | hi@ka.co | 2022-09-24 06:43:00 | hi@ka.co | 2022-09-24 06:43:00
 4          | かんとか社  | なんとか部 | なんとか何ろう | なんとかなにろう | 神奈川県1  | nanirou@nantoka.co | 111-222-3333 | A          | hi@ka.co | 2022-09-24 06:44:00 | hi@ka.co | 2022-09-24 06:44:00
(4 rows)
```


Cloud StorageにバックアップされたCSVファイルをKintoneに戻すFunctionに関する説明は、 ヴィップシステム社の中川さん からKintone Advent Calendar 2022に投稿していただきます。


https://qiita.com/pocopon/private/27f92d13f54a8a1f8ff2