Sansanのインターン(1回目)に参加してきました!

2024/10/13に公開

はじめに

こんにちは。swapmanです。7/16〜8/23の約1ヶ月間、Sansanの就業型インターンシップに参加してきました!今回はその振り返りと自分が業務で何を行ったのかをまとめていきます!

Sansanのインターンに出会うまで

僕はサポーターズの逆求人イベントでSansanのリクルーターと知り合って選考が進み、就業型インターン生として参加させてもらえることになりました。SansanはSansan, Bill One, Eightなど多数の素晴らしいSaaSを作っていますが、僕はContract One(CO) という契約書DXサービスを開発しているチームに参加させていただきました!

Sansanで使った技術スタック

多分、気になる人多いと思うんで書いておきます。
僕は今回触ることはなかったですが、COでは基本的に以下の構成です。
これからインターン行く人は参考になるかな?

Frontend: React(Typescript)
Backend: Ktor (Serverside Kotlin)
ORM: JOOQ, JDBI
DB: PostgreSQL
Cloud: Google Cloud(Cloud Run, Cloud Build)

僕は今回Kotlinしか書いてないよ。Kotlinは初めてこのインターンで書いたけど、すっげーいい言語でした!Java結構書いてた僕からすれば馴染みやすかったです。
あと、COはSample Codeがプロジェクト内にディレクトリを切っておいてくれてるし、Docsも準備してくれているので、キャッチアップはしやすい!
基本的にDDDで開発しててオニオンアーキテクチャを使って開発してました!

Sansanで何したか?

COには契約書全文検索機能があるのですが、僕がインターンに参加した時は全文検索をSQLのLIKEで行っていました。データが少なければLIKEによるパターンマッチでパフォーマンス的に問題が出ません。しかし、グロース期に突入していたCOではクエリによっては検索に1分以上の時間を要するケースが散見されていました。この課題を解決すべくCOにElasticsearchを導入することで検索を爆速にするプロジェクトが1年前から始動していました。僕はその一端であるCloud Run上で走るElasticsearch再インデックスバッチ構築を設計から実装まで全て行いました!...と言ってもあんまり何言ってるかわからない気がするので、ちょっと説明しますね!

Elasticsearchって何?

ElasticsearchはElastic社が開発しているOpenSourceの分散型の全文検索エンジンです。(裏はApache Luceneが動いてます。)
https://www.elastic.co/guide/en/elasticsearch/reference/current/getting-started.html
Elasticsearchに保存する何かはドキュメントと呼ばれ、保存するときのスキーマはマッピングとして自分で定義することができます。マッピングには、文書に含まれるワードをどのようにトークナイズするかを管理するトークナイザや、特殊文字などをフィルタリングするフィルタの設定を入れることができます。ドキュメントはインデックス(DBでいうテーブルに当たる)に保存されます。さらにインデックスはシャードという物理的な単位に分散されます。この分散化を行うことで検索や更新クエリを並列に処理することが可能となり処理が高速化されます。また、Elasticsearchはドキュメントと含まれる単語の間に転置インデックスを作成することで、各単語がどの文書に出現するかをマッピングしているため通常に比べて検索が早くなります。まぁ要するに色々工夫して検索とか更新とかを早くしてくれるすげー奴と思ってくれたらOKです。

再インデックスって何?

ここで少し本題に近づくんですが、Elasticsearchには再インデックスってのがあるんです。
COが扱う契約書の中には企業名の表記揺れが含まれます。例えばAmazon Inc、アマゾン株式会社、(株)アマゾンは全て同じAmazonを指すのですが文字列として全く違うものですが、ユーザーからしてみれば検索ウィンドウにAmazonと入れたら上記全てが引っかかってほしいのです。この問題を解決するために、COでは社名表記揺れ辞書というものを作成して表記揺れを吸収しています。この辞書の中身を変更したり、パフォーマンスチューニングのためにシャード数を変更することは、ドキュメントをElasticsearchにインデキシングするプロセスが変わることを意味します。
また、Elasticsearchはドキュメントをインデキシングする際には、あらかじめ定義したマッピングに従うのですが、このマッピングの変更といった破壊的変更が加わる場合、Elasticsearchは新しいマッピングやシャード数を適用したインデックスを作成し、古いインデックスに登録されているドキュメントを全て移し替える必要があります。この一連のお引越し作業をElasticsearchの再インデックスと言います。
Elasticsearchの再インデックスの説明は本当にこれだけです。
なーんだ簡単じゃねぇかって思ったそこの貴方!
次の章を読んでください。このタスクの難しさがわかると思います。

何が技術的に難しいの?

前章で再インデックスの概要は分かってもらえたと思います。
再インデックスを行うと言っても、サービス無停止で行うかダウンタイムを設けて行うかで実装難易度は全く異なります。どういうことか説明します。
ダウンタイムを作ると、アプリからのDBへの書き込みとElasticsearchへの書き込みを完全に停止できるので、新しいインデックスを作成して、古いインデックスに入っているドキュメントを全て移行するだけで終わりです。Elasticsearchはドキュメントを挿入してすぐにその変更が反映されるわけではないので、リフレッシュを入れるかバッチ終了時刻から長めにリードタイムを設ければ確実に再インデックスを実行することができます。
サービスダウンタイムありの再インデックス

ですが、ダウンタイムを設けない場合、話は難しくなります。
ダウンタイムを設けないので古いインデックスに常時書き込みが入ります。 書き込みが入ったドキュメントをすでに新しいインデックスに移行していたとしていた場合、upsertを行う必要があります。また、再インデックスバッチの終了タイミングも適当ではいけません。アプリからの書き込みを止めないので、バッチが終わる瞬間に書き込みが入った場合、新しいインデックスにその書き込みが入らないかも知れません。それがクライアントにとって非常に重要な契約書だった場合、インシデント間違いなしです。
ね、考えることが多くて結構難しくないですか...? 
僕がバッチを設計するときに参考にした記事はこれです。これ読むと結構わかるかも...?
https://tech.legalforce.co.jp/entry/2021/12/21/190129

実装した再インデックスバッチの詳細

やっと本題です。
まず、バッチの大まかな流れは以下です。

  1. 新しいインデックスを作成する
  2. 古いインデックスからドキュメントを全部引っこ抜いて、新しいインデックスにぶち込む
  3. 2を行なっている間にアプリから書き込みのあったドキュメントを取得する
  4. 3で取得したドキュメントを新しいインデックスにぶち込む
  5. 3.4を新しいアプリがリリースされるまで繰り返す
  6. バッチを終了する

1は特に面白くないです。ElasticsearchのCreate Index APIを叩けば終わりです。
https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-create-index.html

2は結構面白いです。正確な値は言えませんが本番環境には数十から数百万の契約書があります。なので、1枚ずつシーケンシャルに新しいインデックスに移行すると1秒/1枚だとしても、とんでもない時間がかかってしまいますし、今後サービスがスケールすることを考えると高速化が必要だとわかります。高速化のために、契約書群を100枚ずつにチャンクして、そのチャンクを5並列で処理する実装にしました。これによってstg環境では10分ほどでバッチが完了したことを確認でき、本番環境ではメンターさんによると1〜2時間程度で実行が終了したようです。本番環境で動くElasticsearchを載せるインスタンスはstg環境のインスタンスよりも強いので、並列数は5より上げても問題ないかなぁとか思ってます〜。

3〜6がメインです。ここは頑張った!本当にチームメンバー、メンターとめっちゃ議論した!(メンターのHさん、本当にありがとうございます...)
バッチ終了タイミングにフォーカスして全体像を話すとわかりやす気がするので、それで行こうかな。
バッチは、古いインデックスにあるドキュメントが全て新しいインデックスに移されたことが確認できて、尚且つ、新しいElasticsearchのインデックスを参照するアプリ(新しいアプリ)がCloud Run上にデプロイされて、アプリからの書き込みが新しいインデックスに向けて行われるようになったタイミングで終了する必要があります。 これをどう実現するかを見ていきましょうか。

最初に古いインデックスにあるドキュメントが全て新しいインデックスに移されたことをバッチが検知する仕組みをどのように作るかについて説明します。

まず、Elasticsearchのドキュメントマッピングにupsert日時を記録するtimestampフィールドを設けておきます。このtimestampフィールドの時刻、すなわち最後に書き込みが行われた時刻と再インデックスバッチによる移行が試みられた時刻(threshold_timestamp)を比較して、移行された後に古いアプリによって書き込みが行われたドキュメントを古いインデックスから抽出してきます。
ただし、この操作によって抽出されたドキュメント群がDBに書かれている情報と同期が取れている保証はどこにもありません。 そのため、古いインデックスに登録されているドキュメントのIDを使って最新の契約書情報をDBから取得し、新しいインデックスにドキュメントを挿入します。(新しいインデックスにドキュメントを挿入する際にも注意が必要で、他の並列サブルーチンによる更新が同じドキュメントに対してほぼ同時に実行される可能性や実行の順序がネットワークの関係で入れ替わる可能性を考慮する必要があるので、挿入前にElasticsearchが標準で提供するドキュメントバージョン管理のためのフィールドを確認する必要があります。Elasticsearchは結果整合性しか担保しないので、この辺は几帳面に頑張る必要があります。)

インターンで実装したサービスダウンタイムなしの再インデックス

この一連のフィルタリングを新しいアプリがデプロイされるまで続けます。新しいアプリがデプロイされ、移行していないドキュメントが古いインデックスに存在しなくなる、すなわち最後に再インデックスを試みようとした時刻(threshold_timestamp)よりも後に更新されたドキュメントが古いインデックスにない状態になると、Google Cloud Run上のlogに「もし、新しいアプリをデプロイしたんなら、再インデックスバッチを終了してもいいよ」という風に表示されます。
このログが継続的に出たことを確認し、新しいアプリをリリースしていたら、再インデックスバッチ実行者は、バッチにバッチ自身を終了する旨を伝える必要があります。
これを実現した方法は次のようになります。

再インデックス開始時にバッチ終了を知らせるファイル(バッチ終了ファイル)をGCSにおいておき、バッチを終了させたいタイミングで手動でバッチ終了ファイルをGCSから削除し、このファイルの削除によってバッチは終了タイミングを検知します。ちなみにGCSはローカルのディレクトリにマウントできるので、それを使えばファイルをおいておくのは簡単に実装できます。
終了タイミング通知を手動にした理由は、COのリリースフローが一部手動になっており、その慣わしに習った感じです。(実はこの伏線を10月から参加する2回目のSansanインターンで回収します!)
以上がバッチの詳細になります。ふぅー。

インターンを終えて

このインターンは、今まで参加したインターンの中で一番学びになりました。
Elasticsearchが初めてでとか、Kotlin書いたことないから書けてよかったとかもそりゃそうなんだけど。僕的には以下が3点が一番大きいです。

1. プロジェクトをリードするエンジニア達と惜しみなくバッチ設計における懸念点を議論できた
2. 個人開発だと考えないパフォーマンスを考えた並列処理の実装
3. 本番環境で自分のコードが動いていると胸を張って言える事

まず1は普通のインターンじゃありえない。周りの友達はIssueの消化みたいなインターンが多い中、僕は設計から実装まで一気通貫でやらせてもらえた。何より結果整合性しか担保しないElasticsearchによって生み出される懸念点について、SREチーム(特にMさんとKさん)も交えて議論させていただけたことは本当に勉強になった。 設計ドキュメントを自分で作ってチームメンバーに接待じゃなく本気のレビューをもらえたのは嬉しいし勉強になりました。

次に2だけど、口でパフォーマンスを意識した実装が...っていうのは簡単だけど、CPU使用率などのメトリクスを見ながら並列数を判断する経験は、仕事だったら当たり前なのかもだけど学生のうちにそれを経験できたのは本当に感謝しかない。

最後に3。これはおまけみたいなものだけど、自分が書いたコードが企業のプロダクションコードの中で動いているって思うだけでワクワクするしContract Oneへの愛情が沸きます。こんな経験できたのはSansanしかない!

以上3つからわかるようにSansanのインターンは本当に最高なんだってこと!
みんな優しいし右も左も技術力が高い人ばっかりです。
僕をインターンに連れてきてくれた笹川さんとメンターのHさんには感謝しかないです!
社員としてここに帰ってこれるようにもっと精進しないとね。
今回はこんなとこで!

Discussion