一休.com Developers Blog

一休のエンジニア、デザイナー、ディレクターが情報を発信していきます

Go + gRPCによるマイクロサービス構築

f:id:ryo-utsunomiya:20190614171536p:plain:w320

こんにちは。宿泊事業本部の宇都宮です。

最近、とあるマイクロサービスをローンチしました。このアプリケーションの業務的な役割は諸事情により省略しますが、以下のような特性をもっています。

想定されるリクエスト数は、平常時で30req/sec、ピーク時には60req/sec程度になります。行う処理はシンプルで、DBにいくつかSELECT文を投げて、ビジネスロジックに沿った結果を返すことです。

また、基盤系のアプリケーションなので、各開発者の開発環境(WindowsとMacが混在)でも動作する必要があります。

したがって、このアプリケーションに求められる要件は、

  • 高パフォーマンス
  • 高信頼性
  • クロスプラットフォームで動作すること

などになります。

このアプリケーションを構築するにあたって採用した技術と、開発の際に気をつけたこと、運用開始してから直したことなどを紹介します。

採用した技術

Go

開発言語としてはGoを採用しました。

一休のサーバサイドで広く使われている言語/ランタイムは以下の通りです。

  • VB, C#/.NET Framework: 一休.com等
  • C#/.NET Core: メール配信基盤等
  • Python: 一休.comレストランの新アーキテクチャ、一休.comスパ
  • VBScript: 一休.comレストランの旧アーキテクチャ
  • JavaScript/Node.js: 一休.comレストランのBFF
  • Go: 行動ログ収集API

これらのうち、Go以外の言語が選外になった理由は以下の通りです。

  • VBScript, .NET Framework: 新機能の追加が止まっており、将来性がない
  • Python, Node.js: 開発環境にランタイムのインストールが必要なのが微妙(バイナリを配布するだけにしたい)
  • .NET Core: 要件は全て満たしているが、開発者の採用面が…

.NET CoreとGoはどちらも要件を満たしているので、.NET Frameworkの経験を活かしやすい.NET Coreか、採用の強そうなGoかは迷いました。が、As-isではなくTo-beで考えた方がいいかなということで、Goを選定しました。

gRPC

データのやりとりにはgRPCを採用しました。今回のアプリケーションの場合、RESTでも十分だと思っていましたが、より高速に動作しそう、という理由でgRPCを採用しました。

先行して開発されていた別のアプリケーションでもgRPCを採用していて、そちらで技術検証が済んでいた、という点もポイントです。

ただ、一休ではVBScript等レガシーな技術も使っているので、どうしてもgRPCが使えなくなる事態もありうると思っていました。なので、REST APIも並行して開発しました。なお、結果的には、gRPCで問題なかったです。

余談ですが、現在、一休の一部サービスでは、VBScript(Classic ASP)からgRPCを叩いています。C#(.NET Framework)でgRPCクライアントを開発し、このdllをCOM経由で使っています。

擬似コードですが、大体こんな感じです:

Dim client
Dim response

Set client = Server.CreateObject("Ikyu.GrpcServiceClient")
Set response = client.GetMember(memberSeq)

開発の際に気をつけたこと

プレゼンテーションとドメインの分離

前述したとおり、gRPCでいくか、RESTでいくか、迷っていました。gRPCかREST(JSON over HTTP)かは、結局プレゼンテーションの問題なので、プレゼンテーションとドメインをきっちり分けて、プレゼンテーションを簡単に切り換えられるように気をつけました。

擬似コードですが、gRPCのrpcは以下のような実装になっています。

func (s *server) GetMember(ctx context.Context, in *pb.GetMemberRequest) (*pb.Member, error) {
    member, err := memberService.FindByMemberSeq(in.MemberSeq)
    if err != nil {
        // エラー処理
    }
    return &pb.Member{
        MemberSeq: member.MemberSeq,
    }, nil
}

これに対して、RESTのハンドラ(MVCのコントローラー相当)は以下のような実装になっています(フレームワークはgin)。

func (h *MemberHandler) GET(c *gin.Context) {
    member, err := memberService.FindByMemberSeq(c.Param("member-seq"))
    if err != nil {
        // エラー処理
    }
    c.JSON(http.StatusOK, gin.H{
        "memberSeq":      member.MemberSeq,
    })
}

このように、ほとんどの業務処理はドメイン層で行い、プレゼンテーション層はドメインサービスから戻ってきた処理結果を表現することのみを行うようにしました。これによって、gRPCとRESTを容易に併存可能にし、開発終盤まで決断を遅らせられるようにしました。

ユニットテストとインテグレーションテスト

高信頼性のためには、コーナーケースも含めたテストが必要です。そこで、ドメイン層についてはユニットテストを徹底しました。

また、DBと通信するコードはインフラストラクチャー層として分離し、こちらについては実際のDB(SQL Server)をDockerに立ててテストを行うインテグレーションテストを行いました。高速な動作のために、ビジネスロジックをSQLのクエリで表現している部分もあるため、インテグレーションテストもある程度しっかりやっています。

Goは標準でテストのための仕組みが組み込まれているので、テストが書きやすい言語だと思います。

今後の運用を考えると、Consumer-Driven Contractsのようなテストも用意した方がいいのかな、と検討中です。

運用開始してから直したこと

sql.DB を頻繁にオープン・クローズしない

Goのイディオムとして、取得したリソースは defer Close() するというものがあります(Close() のエラーハンドリングが必要な場合を除く)。そのため、最初はDBと通信するコードを以下のように書いていました。

func (s *server) GetMember(ctx context.Context, in *pb.GetMemberRequest) (*pb.Member, error) {
    db, err := sql.Open("sqlserver", dsn)
    if err != nil {
        // エラー処理
    }
    defer db.Close()

    memberService := NewMemberService(db)
    ... 
}

RPCの始めでDB接続をオープンして、終わりでクローズしています。これでもそれなりの性能は出ていたのですが、ドキュメントを確認すると、気になる記述がありました。

The returned DB is safe for concurrent use by multiple goroutines and maintains its own pool of idle connections. Thus, the Open function should be called just once. It is rarely necessary to close a DB.

https://golang.org/pkg/database/sql/#Open

sql.Open() の結果として取得できる sql.DB 構造体は、一度Openした後はプログラム中でずっと使い回すべきで、Closeを呼ばなければならないことはまれである、とされています。

sql.DB はコネクションの実体ではなく、コネクションプールなので、サーバの起動時に一度だけOpenし、その後は使い回すのがよさそうです。

func main() {
    db, err := sql.Open("sqlserver", dsn)
    if err != nil {
        // エラー処理
    }
    // gRPCサーバの実体となる server 構造体にdbフィールドを持たせる
    s := grpc.NewServer()
    pb.RegisterMemberServiceServer(s, &server{db: db})
}

// RPCのハンドラでは server.db を使う
func (s *server) GetMember(ctx context.Context, in *pb.GetMemberRequest) (*pb.Member, error) {
    memberService := NewMemberService(s.db)
    ... 
}

このように変更した結果、低負荷時のレスポンスタイムが改善しました(10~20ms => 5ms)。

あわせて、コネクションプール周りの設定も行いました。こちらは、以下の記事を参考にさせていただきました。

DSAS開発者の部屋:Re: Configuring sql.DB for Better Performance

db.SetMaxOpenConns(50)
db.SetMaxIdleConns(50)                   // MaxOpenConns 以上の値にすること
db.SetConnMaxLifetime(100 * time.Second) // 最大接続数 × 秒数 が目安

コネクションプールの設定によって、高負荷に対する耐性も上がりました。負荷試験を行ったところ、当初の実装では300req/sec程度の負荷をかけるとタイムアウトが頻発するようになっていましたが、sql.DBの使い回しとコネクションプールの設定チューニングによって、300req/secでも正常に結果を返すことができるようになりました。

むすび

今回はGo + gRPCというスタックでアプリケーションを開発した際の知見をいくつか紹介しました。一休では、引き続き、Go言語によるWebアプリケーション開発を進めていきたいと考えています。

doda.jp