【kintone】物体検出モデルを kintone アプリと連携するための環境構築

# はじめに
前回、前々回で、建築資材の物体検出を YOLOv8 を利用して実装する方法を書きました。

https://qiita.com/yutakam/items/848bb44831d3ee715452

https://qiita.com/yutakam/items/c0601bbea6c9d3ae53ae

弊社では業務アプリとしてサイボウズ社の kintone を使用していますので、kintone アプリとの連携を実装してみたいと思います。

シナリオの概要としては以下のようになります。
① kintone アプリに対象となる画像 (パイプ/クランプ等の資材) をアップロードする。
② アップロードされた画像を、作成した物体検出モデルで推論し、結果としてバウンディングボックス付き画像と検知した個数を kintone アプリに返して該当レコードを更新する。

この kintone と連携する環境として外部に仮想マシンを構築するのが本記事の目的となります。

# 1. 環境の構成
kintone との連携環境として GCE 上に仮想マシンを構築します。
環境のイメージとしては以下のようになります。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/351434/9a64f1ed-fbd8-ce03-f64e-72c45e8c1087.png" width=80%>

# 2. 仮想マシンの構築
## 2.1 VM インスタンス作成
GCE で仮想マシンを作成します。既定値から変更した内容は以下の通りです。
- リージョン：asia-northeast1 (東京)
- ゾーン：asia-northeast1-a
- マシンタイプ：n2-standard-2 (2 vCPU、1 コア、8 GB メモリ)
- ブートディスク：サイズ：20 GB (既定値 10 GB) / イメージ：Ubuntu 22.04 LTS

マシンスペックですが、
・コストの関係で GPU は未使用
・ディスクサイズは 10 GB ではいずれ不足するためとりあえず倍の 20 GB
・イメージは使い慣れた Ubuntu 22.04
としました。

## 2.2 外部 IPv4 アドレスの予約
kintone からの通信を受け取るため固定の外部 IP アドレスとします。
作成した VM インスタンスを編集画面で開き、"ネットワーク インターフェース" を展開し外部 IPv4 アドレスを静的に予約します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/351434/c11ce51c-7064-0b24-d2ce-01cbbc1eccbc.png)

# 3 インスタンスへ接続
## 3.1 SSH による接続準備
作成した VM インスタンスに対し、コンソール (ブラウザ) から SSH 接続します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/351434/85974942-2547-3124-d4c9-059e15a105dc.png)

以下のコマンドでキーペアを作成します。
``` bash
ssh-keygen -t ed25519 -f ./key/id_ed25519
```

実行結果例です。パスフレーズも設定します。
秘密鍵 (id_ed25519) と公開鍵 (id_ed25519.pub) が作成されます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/351434/c00a9921-0e63-0eb6-1281-6ba66db2ea52.png)

秘密鍵はローカルにダウンロードしておきます。ブラウザの SSH 画面の右上にある「ファイルをダウンロード」から、秘密鍵のフルパスを指定してダウンロードします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/351434/9dcfb7fa-ecb4-ae3e-0704-84d57c91fdb1.png)

一方、公開鍵は VM インスタンスに設定します。
インスタンスの編集画面で "SSH 認証鍵" セクションで、公開鍵 (id_ed25519.pub) の中身をコピペします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/351434/f726adeb-a1aa-c9b4-c4c0-87ba3aea5c56.png)

## 3.2 SSH による接続
いったん TeraTerm から SSH 接続確認してみます。
設定したパスフレーズと秘密鍵を使用して接続出来れば OK です。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/351434/ce8e0c58-906c-f3e0-9016-97eadcc49539.png)


ターミナルソフトを使用してもよいのですが、通常の業務では Windows 上で VSCode を使用してコーディングしますので、VSCode から接続して使用ます。
VSCode の拡張機能の "Remote - SSH" をインストールします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/351434/0afded99-b47a-ec53-1f2e-0c36c9196a73.png)

接続の設定ファイルはホームディレクトリ配下の「.ssh」ディレクトリ配下の "config" を作成して編集します。
(ディレクトリ、ファイルが存在しない場合は手動で作成します)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/351434/51a93424-edf9-41a8-916c-4e17145ac989.png)

"config" ファイルに接続情報を記載します。
```
Host kintone
    HostName xxx.xxx.xxx.xxx
    Port 22
    User USER_NAME
    IdentityFile "PATH\TO\Private_Key_File"
```

VSCode を起動し、"リモート エクスプローラー" に設定ファイルに記載したホストがリストされ VM インスタンスに接続出来れば OK です。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/351434/c90fae06-ee68-0122-7a11-68623ee2591a.png)

以上で作業の準備が整いました。

# 4. 環境の準備
作業に入る前に、各種環境の設定を実施します。

① ルートのパスワード設定
``` bash
sudo passwd root
```

② パッケージ情報の更新
``` bash
sudo apt update
```

③ pip のインストール
``` bash
sudo apt install python3-pip
```

④ タイムゾーン・言語を変更
``` bash
sudo apt install language-pack-ja-base language-pack-ja ibus-mozc
sudo timedatectl set-timezone Asia/Tokyo
sudo localectl set-locale LANG=ja_JP.UTF-8
```

# 5. 通信環境の構築
kintone と本環境の間の https 通信環境を用意します。
python を使用できる軽量な Web フレームワークである Flask を使用します。
Web サーバーには nginx。その間のインターフェースとして uWSGI を使用します。
以下のイメージです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/351434/7deb3061-4bf2-78fb-da3c-ba4d4bd79c47.png)


## 5.1 各種インストール
必要なモジュールをインストールします。
``` bash
sudo apt install -y nginx
```
``` bash
sudo pip3 install uwsgi
```
``` bash
pip3 install flask
```

## 5.2 nginx の構成
先に、VM インスタンスのファイアウォールを変更しておきます。
外部からの http / https アクセスを有効化します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/351434/3af54645-4b7d-0879-8db4-c408c98caaf2.png)

nginx の設定ファイルを作成します。
``` bash
sudo vi /etc/nginx/conf.d/flask.conf
```

``` bash
server {
    server_name xxx.xxx.co.jp;  # 予約した外部 IP アドレスに解決されるインターネットホスト名を DNS で設定しておきます。
    location / {                # ルートのリクエストをキャッチ
        root /PATH/TO/flask;    # 探索先ディレクトリ
        index index.html;
        proxy_pass http://127.0.0.1:5000; # リクエストの転送先
        proxy_redirect default; # レスポンスの Location ヘッダーを更新
    }

    access_log /PATH/TO/flask-access.log;
    error_log /PATH/TO/flask-error.log; 
}

```
nginx を再起動します。
``` bash
sudo systemctl restart nginx
```

## 5.3 Flask の構成
先に設定した flask.conf の flask ディレクトリ配下の構造を以下とします。
``` bash
flask
├── templates
│   └── index.html
└── home.py
```
index.html を適当に用意します。
``` html
<html>
    <head>
        <meta charset="utf-8" />
    </head>
    <body>
        <h1>Hello World!</h1>
    </body>
</html>
```

home.py の内容です。
``` py
from flask import Flask, render_template

app = Flask(__name__)

@app.route('/')
def home():
    return render_template('index.html')

if __name__ == '__main__':
    app.run(debug=True, host='0.0.0.0', port=5000)

```
実行して外部から接続確認します。
``` bash
$ python3 ./run.py 
 * Serving Flask app 'run'
 * Debug mode: on
WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.
 * Running on all addresses (0.0.0.0)
 * Running on http://127.0.0.1:5000
 * Running on http://xxx.xxx.xxx.xxx:5000
Press CTRL+C to quit
 * Restarting with stat
 * Debugger is active!
 * Debugger PIN: 291-073-651
127.0.0.1 - - [12/Oct/2023 01:11:18] "GET / HTTP/1.0" 200 -
```

外部から http 接続して問題ないことを確認します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/351434/8385e501-392c-088b-4740-71baec4825b6.png)

kintone から Webhook を受け取るためには https 通信が必要となるため、無料 SSL である Let's Encrypt を導入します。
まずモジュールをインストールします。
``` bash
sudo apt install certbot python3-certbot-nginx
```

証明書を発行します。質問に Y で答えます。
``` bash
sudo certbot --nginx -d xxxx.xxxx.co.jp
```

問題なく証明書が発行されました。
``` bash
Deploying certificate
Successfully deployed certificate for xxx.xxx.co.jp to /etc/nginx/conf.d/flask.conf
Congratulations! You have successfully enabled HTTPS on https://xxx.xxx.co.jp
```
改めて設定ファイルを確認すると、色々と設定が追加されていることが見て取れます。
``` bash
server {
    server_name xxx.xxx.co.jp;
    location / {
        root /PATH/TO/flask;
        index index.html;
        proxy_pass http://127.0.0.1:5000;
        proxy_redirect default;
    }

    access_log /PATH/TO/flask-access.log;
    error_log /PATH/TO/flask-error.log; 


    listen 443 ssl; # managed by Certbot
    ssl_certificate /etc/letsencrypt/live/xxx.xxx.co.jp/fullchain.pem; # managed by Certbot
    ssl_certificate_key /etc/letsencrypt/live/xxx.xxx.co.jp/privkey.pem; # managed by Certbot
    include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
    ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot

}

server {
    if ($host = xxx.xxx.co.jp) {
        return 301 https://$host$request_uri;
    } # managed by Certbot


    server_name xxx.xxx.co.jp;
    listen 80;
    return 404; # managed by Certbot


}
```

証明書の更新を検証します。
``` bash
sudo certbot renew --dry-run
```
こちらも問題ないことを確認します。
``` bash
Congratulations, all simulated renewals succeeded: 
  /etc/letsencrypt/live/xxx.xxx.co.jp/fullchain.pem (success)
```

再度 home.py を実行し、今度は https で接続できることを確認します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/351434/43ae4a2a-1473-fec4-e671-95348cb5e800.png)

Let's Encrypt で発行された証明書の有効期間は 3 ヵ月です。
定期的に更新するように cron を設定します。
(実際に更新されるのは有効期限が 30 日を切ってからにはなります。)
``` bash
PATH=/usr/sbin:/usr/bin:/bin
XX XX XX * * /usr/bin/certbot renew --post-hook "systemctl restart nginx" >> /tmp/certbot.log 2>&1
```

## 5.4 WSGI の構成
uWSGI を設定します。
home.ini を作成します。
``` bash
flask
├── templates
│   └── index.html
├── home.py
└── home.ini
```

``` ini
[uwsgi]

# uWSGI 実行時にアプリケーションのルートディレクトリに移動
chdir = /PATH/TO/flask

# uWSGI アプリケーションとして実行する Pythonフ ァイルのパス
wsgi-file = /PATH/TO/flask/home.py

# home.py 内で定義されているアプリケーションオブジェクトの名前
callable = app

# UNIX ソケットファイルを使用して nginx と通信
socket = /tmp/home.sock

# UNIX ソケットファイルのパーミッション
chmod-socket = 666

# uWSGI をマスターモードで実行
master = true

# uWSGIのログの保存先
logto = /PATH/TO/flask/home.log

# uWSGI が起動時に生成するプロセス ID を保存
pidfile = /PATH/TO/flask/home.pid

```

先ほど作成した flask.conf を変更し uWSGI と連携します。
ルートへのアクセスは UNIX ソケットを介して uWSGI に転送され、uWSGI はこのソケットを使ってアプリケーションと通信します。
nginx は再起動します。
``` bash
    location / {
        # root /PATH/TO/flask;
        # index index.html;
        # proxy_pass http://127.0.0.1:5000;
        # proxy_redirect default;
        include uwsgi_params;
        uwsgi_pass unix:///tmp/home.sock;
    }
```

uwsgi 経由でバックグランドで実行します。
先ほどの index.html の内容が表示されることを確認します。
``` bash
uwsgi --ini /PATH/TO/flask/home.ini &
```
停止する場合は以下のコマンドを実行します。
``` bash
uwsgi --stop /PATH/TO/flask/home.pid
```

上記コマンドを cron に登録しておくことでインスタンスの起動時に実行されますが、systemd を使用してサービスとして管理します。
``` bash
$ sudo vi /etc/systemd/system/home.service
```

``` ini
[Unit]
Description = uWSGI
After = syslog.target

[Service]
ExecStart = /usr/local/bin/uwsgi --ini /PATH/TO/flask/home.ini

User=xxxx
Group=xxx
RuntimeDirectory=uwsgi
Restart=always
KillSignal=SIGQUIT
Type=notify
StandardError=syslog
NotifyAccess=all

[Install]
WantedBy=multi-user.target
```
設定ファイルを作成したら起動して動作確認します。
``` bash
$ sudo systemctl start home.service
```

自動起動する場合のコマンドです。
``` bash
$ sudo systemctl enable home.service
```

とりあえず、若干長くなりましたが環境の準備は整いました。


# 6. kintone 連携
当初の目的は、kintone アプリとの連携でした。
ここまで来ればほぼ出来たようなものです。

## 6.1 kintone アプリの設定
連携するアプリの設定画面にある "Webhook" を設定します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/351434/2f7c47b1-3f2f-3edc-90c6-7ad70c6495f5.png)

Webhook 先を追加します。
実際に本番で使用する場合は、サービス毎にサブディレクトリを切ることになると思います。
("https\://xxx.co.jp/api/service1" 等)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/351434/fbf46240-781e-9bd0-3246-a23243788e34.png)

## 6.2 kintone からの Webhook を受け取る python コード
最もシンプルに記載すると、以下のようなコードで "record_data" 変数に kintone のレコードが JSON で取得できます。
``` py
from flask import Flask, request

app = Flask(__name__)


@app.route('/', methods=['POST'])
def test():
    record_data = request.json['record']

    return 'OK'


if __name__ == '__main__':
    app.run()

```

あとは取得できたレコードのデータを使用して好きなように処理すれば OK です。
今回のシナリオでは以下のような処理を実装しました。

① アップロードされた資材の画像の filekey を取得
② filekey から実際の画像をローカルにダウンロード
③ 作成した物体検出モデルを使用して画像を推論
④ 推論結果の矩形が描画された画像を kintone の一時領域にアップロードして filekey を取得
⑤ 上記画像の filekey とバウンディングボックス数を、該当レコードのフィールドに対して更新

# 7. まとめ
GCE インスタンスでの推論は GPU ではなく CPU で実行しましたが、推論にかかる時間としては 1~2 秒くらいでした。
リアルタイム性を求めていませんので業務利用には耐えうると判断しました。

Ultralytics のサイトには
**Export to ONNX or OpenVINO for up to 3x CPU speedup.**
という記載もありますので、そのうち試してみたいと思います。

https://docs.ultralytics.com/modes/export/