Sansan Tech Blog

Sansanのものづくりを支えるメンバーの技術やデザイン、プロダクトマネジメントの情報を発信

Go言語とCloud Runを用いてチーム間のデータ連携システムを作成した話

1. はじめに

こんにちは、DSOC サービス開発部 Bill One Entryグループの内田祐司です。 この投稿がSansan Builders Blog 初投稿となります。 今回は、Go言語とCloud Runを用いて他チームとのデータ連携を行う仕組みを開発した時のことを紹介したいと思います。

2. 今回の開発が必要になった経緯

『Bill One Entry』とは、Sansanが提供しているクラウド請求書受領サービス『Bill One』を通して取り込まれた請求書をデータ化するためのシステムです。 『Bill One Entry』の裏側では、AIやOCRに加え、入力オペレーターが請求書のデータの入力作業を行っています。そのため、取り込まれる請求書の枚数=データの件数が急増すると必然的に納品までに時間がかかってしまうケースが発生します。

そこで既存のオペレーターだけではなく、クラウドユーザーの手も借りてデータ化のスピードアップをしようということになりました。

DSOCでは名刺のデータ化を行うシステムである『GEES』が既にクラウド入力を活用しており、データ化に際してはセキュリティーに配慮し、情報としての価値がなくなるまで再度分割(切片化)してオペレーターに入力してもらっています。このノウハウを生かし、クラウドユーザーのポイントの管理はGEES側で受け持ち、Bill One Entry側では以下2種類のシステムをGEESと連携するために開発することになりました。

  • 入力された内容をGEES側に送信する部分
  • GEES側に送信したデータとBill One Entryで保持している入力データを突合し、再送漏れがあればGEESに再送する部分

これが、今回開発した「チーム間のデータ連携システム」です。

3. Go言語とCloud Runを用いた理由

Bill One Entryのシステムは、Node.js・Reactで開発したものをGCP上で動かしています。

通常、今回のようなアプリケーションを作成するときはCloud Functionsを用いていたのですが、今回の開発を始めた当初はCloud FunctionsではRubyをはじめとするサポートされていない言語がありました。 そのため、Cloud Functionsを用いた場合はCloud Functionsでサポートされている言語に限定されてしまうことになります。

その点、Cloud Runであればコンテナを用いるのでサポートの有無に関係無く言語を選択することが可能です。 今後、他言語を自由に扱う可能性を考慮してCloud Runを新たにプロダクトに導入することにしました。

また、Googleが開発しているGo言語であればGCPのドキュメントが充実しており、なおかつ以前からパフォーマンスに定評があったのでプロダクトに新しい風を吹き込む目的も含めてGo言語を採用することにしました。

4. システムの概要

今回開発したシステムの概要図を以下に貼り付けます。

システム概要

図の中でも、今回Cloud RunとGo言語を用いて開発したのは赤枠で囲んである2箇所です。

4.1 各アプリケーションの役割と処理

以下に赤枠で囲んだ部分の役割と処理順について簡単に記載します。

  • ①メッセージ送信用アプリ

    • 役割
      • クラウドユーザーから入力されたデータなどをAPIを通してGEESに送信します。
    • 処理
      1. クラウドユーザーに入力された内容は Google App Engine へとPOSTされるので、一緒に入力したユーザの情報などをPub/Sub(以降「メッセージ送信用Pub/Sub」と表記)に一度Publishします。
      2. メッセージ送信用Pub/Sub -> Cloud Runアプリ(以降「メッセージ送信用アプリ」と表記)にデータをPublishします。
      3. Pub/Subからメッセージを受け取ったメッセージ送信用アプリは、メッセージに含まれるデータを1件ずつGEESのAPIにPOSTする。
  • ②突合アプリ

    • 役割
      • GEESに送信されたデータがBill One Entryのfirestoreのデータと差分が発生していないかの確認を行います
      • もし差分が発生してしていた場合は、差分のデータを再送する
    • 処理
      1. 1時間に1回CloudSchedulerからPub/Sub(以降「突合用Pub/Sub」と表記)に通知をする
      2. CloudSchedulerから通知を受け取った突合用Pub/SubはCloud Runアプリ( 以降「突合アプリ」と表記)に通知をする
      3. 通知を受け取った突合アプリは以下の処理を行う
        • GEESのS3にあらかじめ保存されている入力データのCSVファイルをダウンロード
        • firestoreからデータを取得
        • GEESのCSVデータとfirestoreから取得したデータを比較し、差分が無いかを調べる
        • 差分が発生した場合
          • 差分データをメッセージ送信用Pub/SubにまとめてPublishする
          • 差分データを検知したことをSlackに通知する
        • チェックが終了したCSVファイルをBill One EntryのCloudStorageに保存する

4.2 アプリの構成

今回実装した各アプリケーションの構成は以下のようになっています。

  • ①メッセージ送信用アプリ
├── README.md
├── api-client // APIにデータをPOSTするためのパッケージ
│   ├── api_client.go
│   └── api_client_test.go
├── flags-common.yaml
├── flags.yaml
├── gees // api-clientを用いてGEES側にデータを送信するためのパッケージ
│   ├── send_entries.go
│   └── send_entries_test.go
├── go.mod // このアプリケーションで使用しているモジュールを管理
├── go.sum
├── logger // ロギング用のパッケージ
│   └── log_client.go
├── main.go
├── message // メッセージ送信用Pub/Subからデータを受け取る
│   ├── pubsub.go
│   └── pubsub_test.go
├── notify // エラー発生時にsentryに通知するためのパッケージ
│   └── sentry.go
└── scripts // メッセージ送信用のPub/Subを作成するためのスクリプトファイル(Goファイルではない)
    └── create-pubsub.sh
  • ② 突合アプリ
├── README.md
├── auth // GEESのS3に認証を行うためのAPIキーを取得するためのパッケージ
│   ├── api_key.go
│   └── api_key_test.go
├── converter // CSVのデータを構造体に変換するためのパッケージ
│   ├── csv_converter.go
│   └── csv_converter_test.go
├── executer // 差分チェックを実行するためのパッケージ
│   ├── diff_check_executer.go
│   └── diff_check_executer_test.go
├── firestore // firestore にアクセスしてデータを取得するためのパッケージ
│   ├── firestore_client.go
│   └── firestore_client_test.go
├── flags-common.yaml
├── flags.yaml
├── gees // GEESのS3にアクセスしてCSVファイルをダウンロードするパッケージ
│   ├── gees_s3_client.go
│   └── gees_s3_client_test.go
├── go.mod // このアプリケーションで使用しているモジュールを管理
├── go.sum
├── logger // ロギング用のパッケージ
│   └── log_client.go
├── main.go
├── models // コンバートされたfirestore のデータ、もしくはCSVのデータを保持するためのモデル
│   ├── crowd_entry.go
│   ├── piece_entry.go
│   └── piece_entry_matching_history.go
├── notify // エラーの通知や差分チェックの結果を通知するパッケージ
│   ├── sentry.go
│   └── slack_client.go
├── pubsub // メッセージ送信用Pub/Subに再送用データをPublishするパッケージ
│   ├── pubsub_client.go
│   └── pubsub_client_test.go
├── scripts // 突合用Pub/Subやfirestoreアクセスのためのサービスアカウント作成のためのスクリプトファイル(Goファイルではない)
│   ├── create-pubsub.sh
│   └── create-service-account.sh
├── storage // CloudStorageにチェックし終えたCSVファイルをアップロードするためのパッケージ
│   ├── cloud_storage_client.go
│   └── cloud_storage_client_test.go
└── utils
    ├── path_to_jst_time.go
    ├── path_to_jst_time_test.go
    ├── pickup_list.go
    └── pickup_list_test.go

いずれもアプリケーションを作成し始めるときには以下のコマンドでモジュール管理ツール「Modules」を初期化しています。

$ go mod init

その後、必要なパッケージを以下のコマンドで追加しています。

$ go get -u {追加するモジュール名}

また、開発の過程で参照されなくなったモジュールは以下のコマンドで一括削除できます。

$ go mod tidy

ちなみに、どちらのアプリケーションもルートディレクトリ以下にmain.goを配置していますが、 これは後述するBuildpacksを用いる際に重要です。

というのもBuildpacks がコンテナメージを選択する際に、ルートディレクトリに設置してあるファイルを見て、「何の言語が使用されているのか?」を基準に選択するようなのです。 そのため、ルートディレクトリには使用されている言語が明確に分かるように最低1つは言語ファイルを設置しておく必要があります。

4.3 デプロイ

今回、実装したアプリケーションを実際にCloud Runにデプロイして動作させる為に3つの手順をコマンドとして用意しました。

  1. DockerイメージをビルドしてContainer Registoryにアップロードする
  2. アップロードして最新のイメージをCloud Runにデプロイ
  3. トラフィックを新たにデプロイしたCloud Runに移行

4.3.1 DockerイメージをビルドしてContainer Registoryにアップロードする

Cloud Runで動作させるアプリケーションなので、本来ならDockerコンテナを作成するためのDockerfileが必要です。 しかし今回はBuildpacksを用いることでDockerfileを作成する必要がなくなりました。 Buildpacksの方で使用するコンテナイメージを適切に選択してくれるのです。

具体的には、以下のようにコマンドを実行します。

$ gcloud alpha builds submit --pack image=gcr.io/{GCPのプロジェクト名}/{作成するコンテナイメージの名称}:latest

これで使用するコンテナイメージを自動で選択した上で、新たなコンテナイメージを作成してContainer Registoryにアップロードしてくれます。

4.3.2 アップロードして最新のコンテナイメージをCloud Runにデプロイ

「4.3.1」でContainer Registoryにアップロードした最新のイメージを用いてCloud Runにコンテナイメージをデプロイします。

$ gcloud run deploy {コンテナイメージ} --flags-file={設定情報などを記載したflagsファイル名}

4.3.3 トラフィックを新たにデプロイしたCloud Runに移行

新たにCloud Runアプリケーションをデプロイすると、トラフィックがそちらに移行されるようにする必要があります。 具体的には以下のコマンドを実行します。

$ gcloud run services update-traffic {作成したCloud Runアプリケーション名} --flags-file={設定情報などを記載したflagsファイル名} --to-latest

上記3種類のコマンドを連続して実行することによって、デプロイが可能になります。

5. 開発・検証時に遭遇した問題点

その1: Cloud Runで並行処理を動かした時のトラブル

当初、突合アプリ内部では3種類の突合処理を以下のようにgoroutine を用いて並行に動作させていました。

go diffCheckExecuter.Exec("entry")
go diffCheckExecuter.Exec("match")
go diffCheckExecuter.Exec("unmatch") 

ところが実際に動かしてみると処理が徐々に遅くなっていき、最終的にはログも吐かずに動きが止まってしまうという事態に遭遇しました。 似たような現象が以前Cloud Functionsでも発生したことがあり、その時もは並列処理をやめることで事なきを得ました。

はっきりとした原因は特定できていないものの、「おそらく並列・並行処理にあまり強く無いのだろう」と判断して今回もgoroutineの使用を止めることでしのぎました。

その2: Pub/Subの再送間隔は最長で600秒まで

以下は、 Pub/Subのサブスクリプション設定画面の例ですが、この項目の内「バックオフ期間の最大値」という項目は最大で600秒までしか設定できません。

サブスクリプションの設定

この項目は、一度Pub/Subで保持したメッセージをPublishしてから次に再送処理としてPublishをするまでの最大の待機時間を設定するための項目です。

今回の設計ではメッセージ送信用のPub/SubからCloud Runのメッセージ送信アプリに向けてメッセージをPublishしているわけですが、メッセージ送信用Pub/Sub -> メッセージ送信アプリ -> GEES のAPIへのPOSTを全て600秒以内に全て終えられなければPub/Subのサブスクリプションが自動的に再送処理を開始することになります。

仮に突合アプリでデータの差分が1万件検知された場合、 差分として検知したデータを全て1メッセージに含めてメッセージ送信用Pub/SubにPublishするように実装すると、メッセージ送信用アプリが1万件全てのデータ送信を600秒以内に終えられないと再送が走ってしまうことになります。

そうなると、同じデータが何度も重複してGEES側に送信されてしまいます。

このような事態を防ぐため、Pub/SubにPublishする1メッセージのデータ量は長くかかっても600秒以内に送信を終えられるような量に抑える必要があります。

今回は検知した差分を100件単位に区切って1メッセージとすることで1つのメッセージ内のデータ送信を全て600秒以内に終えられるようになり、問題に対処しました。

6. まとめ

今回は主に実装したものの構成や手段などに重点をおいて紹介させていただきました。 今後Bill One EntryではGo言語を用いる機会がどれだけ存在するかは不明ですが、そのときには改めてGo言語での実装の中身にももう少しご紹介出来れば、と考えております。

チームのプロダクトに新たな風を吹き込むというのも改めて良いものだな、という思いを、このBlogを書いてみて改めて噛み締めています。

参考文献


buildersbox.corp-sansan.com

© Sansan, Inc.