Sansan Tech Blog

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

Node.jsによるReverse Proxy実装をGoでリプレイスした話

Node.jsによるReverse Proxy実装をGoでリプレイスした話

技術本部 Bill One Engineering Unit(以下、Bill One EU)の川原です。23卒としてSansan株式会社に入社し、インボイス管理サービス「Bill One」の開発をしています。今回はNode.jsによるReverse Proxy実装をGoでリプレイスした話をします。

リプレイスしたReverse Proxy

Bill Oneの機能の1つに、メールで送られた請求書をBill Oneが代理で受領する機能があります。

Bill Oneでは、SendGridという外部サービスを介してメールを受信しており、Cloud Run上のマイクロサービスへリクエストを送っています。

SendGridとCloud Run上のマイクロサービスの間には、SendGridからのWebhookイベントをプロキシするためにemail-webhook-proxyというマイクロサービスを挟んでいます。今回はこのサービスをGoでリプレイスしました。

なぜGoでリプレイスするのか

既存のNode.jsによるemail-webhook-proxyの実装には次の課題がありました。

  • 採用していたライブラリが直近更新されていない。
  • OOM(Out of Memoryの略)でまれにプロセスが落ちる(リクエストはSendGridより再実行される)

OOMでプロセスが落ちてしまった場合には、500番台のレスポンスがSendGridへ返ることで、SendGridからリクエストが再実行されます。

OOMが発生する理由はBill Oneというプロダクトの特性上、大きく次の2つがあります。

  • システムから請求書を一括で送付した際にリクエストが急増する
  • メールに請求書PDFが添付されて送られるため、リクエストボディのサイズが大きい。

プロダクトの成長に伴うリクエストの増加に対応するため、よりメモリ効率を意識した実装が必要でした。

今回は上記の課題を解決するためにGoでリプレイスする案を採用しました。他にも既存実装をNode.jsで改善する方法もありました。しかし、Goであれば標準ライブラリのみを用いて実装できるため、今後のメンテナンスがしやすくなることを踏まえてGoを採用しました。

Bill Oneには技術バックログという技術課題やチャレンジを扱うバックログがあります。詳しくはこの記事をご覧ください。

このGoによるリプレイスはBill Oneの技術バックログの1つとして、 チームの開発プロジェクトと並行して、合間にGoに詳しいメンバーに相談やレビューなどのサポートを受けながら進めていきました。

実装

ここではリプレイスの目的であるOOMを防ぐためのメモリ効率の改善について紹介します。

email-webhook-proxyはSendGirdからの Inbound Email Parse WebhookEvent WebhookをNetworkサービスにプロキシする役割を持っています。

そのためemail-webhook-proxyが正常に動作しない場合、メールで請求書を送ったがBill Oneで受領できていないというインシデントになってしまいます。

Bill Oneのプロダクトの成長に伴い、メールで受領する請求書の数が急増しているため、リクエストを高いリソース効率で処理する必要があります。そのため、リクエストをメモリに展開せずに処理するストリーム処理を実装しました。 Goでは、あらゆるI/Oがio.Reader、io.Writerインターフェースを実装しているため、簡単にストリーム処理を実装できます。

また、Inbound Email Parse Webhookをプロキシする際には、運用時の調査用としてリクエストボディをGoogle CloudのCloud Storage上に永続化しています。

今回は、メモリ効率化のため一連の永続化処理には標準ライブラリのio.TeeReaderを用いてストリーム処理を実装しました。

io.TeeReaderを用いてリクエストのBodyの読み込みとCloud Storageへの書き込みを同時に行うことで、メモリ使用量を削減しています。

(次のコードは実際のコードを簡略化しています)

func inboundEmailWebhookHandler(pr *httputil.ReverseProxy, sc *storage.Client) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        ctx := r.Context()

        csw, err := CloudStorageWriter(ctx, sc, r.Header.Get("Content-Type"))
        if err != nil { //  Error Handling
            return
        }
        defer csw.Close()

        r.Body = io.NopCloser(io.TeeReader(r.Body, csw))
        pr.ServeHTTP(w, r)
    }
}

func CloudStorageWriter(ctx context.Context, client *storage.Client, contentType string) (io.WriteCloser, error) {

    bkt := client.Bucket("bucketName")
    obj := bkt.Object("xxx/request-body.txt")
    w := obj.NewWriter(ctx)

    return w, nil
}

リプレイスした結果

既存のNode.js実装からGoでリプレイスすると、次のようなメモリ使用率の改善が見られました。

画像上部の赤いグラフは既存のインスタンス、オレンジのグラフは今回実装したGoのインスタンスのメモリ使用率を表しています。

既存のインスタンスのメモリ使用率が45%付近だったのに対し、リプレイスしたGoのインスタンスでは、10〜15%で推移しています。(トラフィックを70:30で分けながら移行したため、グラフは同時に存在する時間があります)

その後はCloud Storage Clientの設定の最適化など、組織内のGoに詳しいメンバーがさらに改善を加えたことで、現在は10%満たない範囲でメモリ使用率が安定しています。

直近2ヶ月のメモリ使用量

以上のように、Goでリプレイスする目的だったリソース効率の改善を達成できました。

終わりに

今回は、既存のマイクロサービスをGoでリプレイスした話を紹介しました。 紹介した他にも、認証やロギングなど多くの学びを得ながら進められました。 リリース後もリプレイスしたマイクロサービスのCDの改善などに取り組んでいます。

メインの開発でプロダクトに向き合いつつも、技術的な改善やチャレンジに取り組めるのがBill One EUのすごく好きなところです。 このバックログに取り組んだ当時は1年目の新卒でしたが、1年目であっても技術的なチャレンジができる、それをサポートする環境がBill One EUにはあると感じています。最後まで読んでいただきありがとうございました。

20240312182329
20240315190344

© Sansan, Inc.