自己紹介とアジェンダ

福本晃之氏(以下、福本):では、今日は「RailsのAPIを普通に動かしたい話」というテーマで進めていきます。よろしくお願いします。(スライドを指して)アジェンダはこちらです。今日はこんな感じで進めていきます。

最初に自己紹介をします。福本です。ちょうど1年半くらい前の2019年10月にメドピアに入って、それからずっとバックエンドの開発を中心に進めている感じです。

まず、僕はメドピアの中で、予防医療の観点から食事に関わるサービスの開発に携わっています。メドピアにはFitsPlusというグループ会社があり、そこで開発をしています。今日は詳細はあまり話しませんが、特定保健指導という領域があり、そこのプラットフォームの開発に携わっています。

“普通”がちゃんとできることが意外と難しい

詳細をテックフローに書きましたが、(スライドを指して)このようなアーキテクチャです。

ポイントとしては、一番真ん中にモノリシックのでかいRailsがあって、同じRailsアプリケーション上に、スキーマの違うアプリや、それ向けのAPIが複数存在しています。さらに、各APIスキーマのコンテキストが微妙に異なる点もポイントです。

続いてタイトルの「RailsのAPIを普通に動かしたい話」についてお話します。

「”普通”is何ですか?」と思われるでしょうが、僕は「ちゃんとスキーマ定義どおりにレスポンスを返す」または「エラー検知ができること」だと考えています。逆に言うと、エラーじゃないものは検知しないとか。ほかに、テストがちゃんとできることを普通だと捉えています。

しかし僕は、この普通が意外と難しいんじゃないかと勝手に思っていて。

今日は普通を目指す話をします。ベストプラクティスのようなかっこいい話ではなく、「最低限こういうことを守っていこう」とか、「こういうのやりがち」という話を共有できたらなと思っています。

リリース後に起きた問題、100回鳴ったエラーの通知

まず最初に社外のスマホアプリからバックエンドのサーバーに通信して、サーバー経由で自社のRailsにAPI通信でリクエストがくるものについてお話します。(スライドを指して)実際にはこういう構成になっているサービスなのですが、「なんでこういう構成になってんだ」というツッコミはちょっと効くのでやめてほしいです(笑)。

1日の食事の写真をスマホのアプリからアップロードすると、その写真がアプリのサーバーに届きます。その後、うちのRailsにAPI通信で写真などのデータが連携される、という構成です。

リリース前に、あるエンジニアが「テストも書いているし、staging検証も動作検証もイケたぞ」「通知されたエラーも全部潰したし、これはイケる!」と調子に乗っていたやつがいたんですけど、「リリース後に問題が……」となりました。もう完全にフリですね。

同じエラーの通知が100回鳴りました。うちはRollbarを使っているんですけど、僕は(スライドを指して)この「100th」という回数の文字を初めて見て「すみません」みたいな感じで(笑)。さらに、ユニークなはずのレコードが2つ作られるすごく香ばしい状態になりました。

この時の僕の表情がスライドのような感じです。これ全部僕の顔です。

「なんでや」という感じですが、「その時はこんな感じのコードを書いていました」というものをお見せします。実際のコードよりかなり簡単に書いていますが。こんな感じで、パッチのAPIでパラメータのIDから既存のリソースを取ってきて、アソシエーションをfind_or_create_byして、そのパラメータの値で更新をし、終わった後にJobを投げる感じの処理です。

この処理の緑色で囲んだ部分の複合的な要因で、さっきのような感じになっていました。

原因と解決方法1:トランザクション

原因と解決については(スライドを指して)この3つですが、1つずつ見ていきます。

トランザクションです。スライドの緑色で囲んだ部分はトランザクションを張っているんですけど、見た瞬間に「おい」となる人もたぶんいると思います。僕も後から見て「おい」となったんですが、もう完全に凡ミスです。

ということで、find_or_create_by自体がトランザクションを張るので、find_or_create_byはトランザクションの中であまりカジュアルに使わない方がいい。

トランザクション、難しくないですか? 僕は「トランザクションナニモワカラナイ」という人なんですけど(笑)。find_or_create_byをしないことが一番簡単で、あと、トランザクションをrequire: trueさせると、いちおうネストして中で抜けられる。さらに、トランザクションを抜けて、ちゃんとリトライさせましょう、ということです。

また、create_withして、INSERTする時にlockを外してもいいと思います。でも、さっきのトランザクションのネストやテーブルのlockのところは、コード上で「こういう状態を想定しています」と(コードの読者に)伝えるのが無理な気がしたので。いろいろ試してみましたが、やめました。いろいろ手は打ちましたが、単純にfind_or_create_byしない方向で考えています。

原因と解決方法2:並列度の高いAPIリクエスト

2つ目は、並列度の高いAPIリクエストです。ここは、トランザクションをミスっただけなら、エラーになって処理されないだけなのではないかと思います。100回も怒られなくていいんじゃないかと思いました、この通信されてきたログをいろいろあさってみた結果、同じエンドポイントのAPIに対して、中身(Body)が違う複数のリクエストが、ほぼ同時に来ていました。

あとで把握したところ、同じ1日の食事、例えば朝昼晩の3つ写真が送られてきたら、それを溜めて一気に送っていたらしいんです。

ただ、こういうことに気づいて、そもそも同時にAPIが呼ばれるという考えが僕になく、どういうふうにAPIが叩かれるのかという事情も把握していなかった。さらに、「これって同時に叩かれたらどうなるのか」というトランザクションの扱いも、あまりわかっていなかったことが反省点としてあります。

解決の方針としては、「検索と更新は分ける」という感じです。検索はトランザクション外で実行。たぶん、APIの処理全体を明示的にリトライさせたほうがいい。リトライする時に、ほかのトランザクションでコミットされた結果を拾えるようにしたほうがいいと思います。

原因と解決方法3:ActiveJobによる非同期処理

3つ目は、ActiveJobによる非同期処理です。これは、若干APIにかかわらずというところですが、最後にこのトランザクションが抜けたあとに謎のJobの処理が入っていました。

これはS3への画像で、先ほどの食事写真の画像アップロードをJobで渡しているんですね。(スライドを指して)具体的にはこんな感じですが、詳細は割愛します。

直接うちのRailsに写真や画像データが届いていない以上、受け取り元のサーバーから、S3のbucketにアクセスできるkeyをもらい、そのkeyをもとにS3のAPIを叩いて、いったんうちのサーバーに画像をダウンロードします。そして、サーバーにダウンロードした画像をうちのS3 bucketに上げなおす処理が必要なので、それをやっています。

詳細は、別途個人的にZennに書いたので、興味のある人は見てください。

(スライドを指して)もう一回置きましたが、Jobの部分はDeserializationErrorが起きまくっていました。

原因としては、Jobの引数に配列を渡していたのが、タイミングによってはGlobaliDのシリアライズに配列を渡すと失敗するらしいんです。たぶん、うまくシリアライズできなかったためにエラーが起きていました。

そして、エラーが起きたら、そのあと死にJobが残りました。Jobが破棄されないことと、Sidekiqが律儀にリトライしようとしたことが原因でした。

僕の気持ちがスライドの右側です。リトライで盛られていたので、たぶん実際は100回も起きていたわけではありませんが、こういうのが起きちゃいました、っていう感じでした。

Jobに渡す時のポイントとしては、引数に単一のインスタンスを渡すことと、Jobごとリトライするので、Sidekiqのリトライ機能を使ったほうがいいです。ただし、Sidekiq6系以上でないと使えません。僕が直した時はまだSidekiq6系ではなかったので、使えませんでした。

あと、DeserializationErrorが起きたら、そのJobはもうどうしようもないので、discard_onで指定して、明示的にJobを破棄したほうがいいです。さらに今回はやらなかったんですけど、トランザクションの中でJobを呼ぶと実行タイミングがズレてしまうのでうまくいかない。そこも気をつけたほうがいいと思います。

原因の3つを追ったら、ことなきを得ました。ピッタリ止みました。めでたしめでたし、という感じです。おまけですが、テスト。並列テストのところは、正直ちょっとうまいこと書けていないので、なんかうまいことやっている事例などがあったら教えてください、という感じです。

API開発では奥行きを意識する

それでは最後にまとめます。API開発では、トランザクションや並列度など、奥行きを意識しましょう。あと、APIを叩く時の、叩く側の事情にも関心を持つ、うまくリトライさせる。さらに、検索と更新/作成は分ける。Jobの実行タイミングがあるので、それと通常の処理との違いを理解する。以上、「普通」の話をしました。ありがとうございました。

質疑応答

司会者:ありがとうございました。質問がきています。「トランザクション処理に失敗して、結局データは壊れてしまったんですか?」

福本:おっしゃるとおりです。2つずつ作られていたので、あとから直しました。Rakeタスクで重複データを滅尽しました。

司会者:無事復旧しましたか?

福本:無事復旧しました。Rakeでやった理由としては、ちゃんとRSpecを書きたかったからです。例えば2つ消されるとか、逆に消されないとかはやりたくなくって、ちゃんとテストで消えるかどうかを確かめたかった。なので、いわゆるConsoleで削除はしませんでした。

司会者:ほかに「API連携は難しいと思いますが、インターフェイスのやり取りなど工夫されていたことはありますか?」という質問です。

福本:かっこつけて言うと、いわゆるスキーマ駆動開発じゃないんですが、ローカルだとSwaggerでOpenAPIをやっていまして。

司会者:かっこつけて言ってくださいよ(笑)。

福本:(笑)。アーキテクチャを見せたほうが早いですね。Swaggerを使っていて、いわゆるOpenAPIのドキュメントを使っています。あとは、テストをcommitteeで書いていて、レスポンスをどう返すかの担保もしています。モックもサーバーも、apisproutという、たぶんちょっとマイナーなものを起動させて、「こうリクエストにリトライしたらいいんだね」とか「こういうふう返ってくるんだね」ということがわかるようにはしています。ただ、そのapisproutが、悪さをするというか(笑)。

例えば、アクセスするコントローラーで、よくAPIだとapi/v1/なんかでディレクトリを切ると思いますが、そのapi/v1をなぜかパスする。リクエストすると(apisprout側で)ignoreされて、2回つけないといけないんです(笑)。api/v1/api/v1/users/1みたいなをやらないといけない。ちょっとどうしよっかな、みたいな感じでしたね(笑)。そういうことがありつつも、工夫はしています。

司会者:ちょうど時間になったので、発表ありがとうございました。