Mercari Engineering Blog

We're the software engineers behind Mercari. Check out our blog to see the tech that powers our marketplace.

Microservices の裏で動く Microservices を Go で開発している話

Mercari Advent Calendar 2018 の 25 日目はメルカリ JP の Microservices Development Team の @codehex がお送りします。

これまで私達は Microservices を開発している旨を様々なテックイベントやカンファレンスで話してきました。中でも Mercari Tech Conf 2018 で Monolith なアプリケーションから Microservices へ移行するために、私達がどうしているかという話が目立っていたと思います。

そのうちの一つである Listing Service という出品機能の Microservice の話がありました。

資料の内容をまだ知らない方のために、本記事を理解するために補足します。

  • メルカリでは Microservices を基本的に Google Cloud Platform(以下、GCP と表記する) 上で作成する
  • 現在は Monolith が持つエンドポイント単位を Microservices へ移行している(1 : N = エンドポイント : Microservices)
  • メルカリが保持するデータは、基本的にさくらインターネット株式会社が提供するデータセンター(以下、さくらと表記する)へデプロイしている MySQL に保存される

本記事では上記の資料で少しだけ登場した、さくら上にある Microservices の一つである Item Service を開発する際に得た知見について記述します。Listing Service から商品情報を登録する際に呼び出される Create を例にとって説明するので Go のコードもチラ見せします。

なぜ環境が分かれるのか

メルカリではこれまでに 10 億品以上の出品が行われました。

出品される各商品には沢山の付加情報(商品の説明、カテゴリ、ブランド、誰が出品したのか、etc...)もついてきます。データベースの増え続けるディスク容量への対策のために、下記のリンク先のような DB を分割する方法も採られてきました。

tech.mercari.com

これらの大規模な情報量をさくらの環境から GCP 等のクラウド環境のストレージへ移行するのは、夜間メンテを何度か行うような短期間で済ませられるものではありません。

そのため、少なくとも現状では GCP 上の Microservices から、さくらの MySQL へアクセスする必要があります。しかし Listing Service 等、GCP 上に存在する Microservices がさくら上の MySQL にアクセスする際、セキュリティ上の観点からインターネットを通して直接アクセスさせたくありませんでした。

そこで私達は、さくらの環境に新規作成した CRUD の役割のみを担う Microservices を作成し、GCP の Microservices からはそれを経由して、さくらの MySQL にへアクセスすることにしました。

Item Service の Create について

Item Service は商品情報に関する CRUD を行うサービスです。Listing Service から商品情報を登録(生成)するために Create が呼び出されます。

Item Service の Create では新規の商品情報の登録が行われます。大雑把なフローは次のとおりです。

f:id:codehex:20181224120607j:plain

私は CRUD の操作をする際に発生しうるエラーを減らすことと、万が一エラーが生じた時に原因を特定しやすくすることが必要だと考えています。

そこで Item Service が行っている工夫を少しだけ紹介します。

・起動時に接続先のチェック

DB が分割されていることから、商品情報を作成するためにそれぞれの DB に紐づく DSN へ接続する必要があります。アプリケーションの起動時に、接続先の DSN が正しいかどうかをチェックすることで、リクエストを受けたときに設定ミスによるエラーの発生を防ぐことが可能になります。

上記を行うために下記のようなコード(疑似)を記述しました。このコードは起動時に一回だけ呼び出されます。

func (c *Conns) CheckConnections(ctx context.Context) error {
    const q = "SELECT 1 FROM %s LIMIT 1"
    var errs []error
    for tablename, dbinfo := range c.mapper {
        db := c.conns[dbinfo]
        _, err := db.ExecContext(ctx, fmt.Sprintf(q, tablename))
        if err != nil {
            errs = append(errs,
                errors.Wrapf(err, "%s - %s is not found", dbname, tablename),
            )
        }
    }
    if len(errs) != 0 {
        return multiError(errs)
    }
    return nil
}
  • const q = "SELECT 1 FROM %s LIMIT 1" は接続先の DSN にテーブルが存在すれば 1 行だけ 1 を返すクエリ
  • c.mapper はキーがテーブル名で、値はそのテーブルが存在する DSN となる。この mapper は設定ファイルから作成される
  • db := c.conns[dbinfo] では DSN をキーとして map から sql.DB を取得する
  • ほとんどの場合、もしクエリを実行して失敗していれば row が見つからなかったという sql.ErrNoRows が返る

全ての DSN とテーブルの組み合わせに対してクエリを投げることを試み、エラーが返ればログにエラー内容を吐いてアプリケーションの起動を失敗させています。

この仕組みによって設定の変更が入った変更をデプロイしたとしても、設定ミスによるエラーレートの上昇が生じる可能性がなくなりました。よって設定に関しては安心してデプロイできます。

・エラーの処理

先程も記述した通り、万が一エラーが生じた時に原因を特定しやすくすることが必要だと考えています。そこでエラーのスタックトレースを作成する github.com/pkg/errors を用いてエラーをラップしました。レスポンス時にエラーログとして、スタックトレースとともにエラー内容をロギングすれば調査に役立ちます。

これは Item Service の観点からだと良さそうですが、リクエスト元の Microservice からすると次の問題が生じます。

  1. エラー内容に合わせた gRPC のステータスコードが欲しい
  2. ひと目でエラーの原因が分かるようにエラーを送ってきて欲しい*1

これらを解決するために gRPC のミドルウェアに次の処理を行うミドルウェアを追加しました。

  1. スタックトレースを持つ、ラップされた状態のエラーをロギングする
  2. エラーをアンラップし gRPC コードを判別できる、根本的なエラーを取り出して返す

送るべき gRPC コードを判断するために、NotFound を例に次のようなエラーをラップするコードも追加しました。

type notfound struct {
    err  error
    code codes.Code
}

// Error returs error message
func (n *notfound) Error() string {
    return n.err.Error()
}

// GRPCStatus returns gRPC status of the error
func (n *notfound) GRPCStatus() *status.Status {
    return status.New(n.code, n.Error())
}

// NotFoundWrap returns error which satisfied GRPCError interface.
func NotFoundWrap(err error) error {
    if err == nil {
        return nil
    }
    return WithStack(&notfound{
        err:  err,
        code: codes.NotFound,
    })
}

Item Service 内で発生し得るエラーを、上記の NotFoundWrap のようなラップを行う関数を用いて、スタックトレースとともに gRPC コードを返してくれるようラップします。*2

そして、ミドルウェア部分で行うエラーをアンラップする処理が次のとおりです。

for e := err; e != nil; {
    switch e.(type) {
    case errors.Causer:
        e = e.(errors.Causer).Cause()
    case errors.GRPCError:
        return e
    default:
        e = wrapErrorCode(e)
    }
}
return nil

ここで出てくる wrapErrorCode 関数はアンラップを完了したが gRPC のコードが分からないエラーに対して再度ラップしてあげます。例として、アンラップして判明したエラーが context.Cancel だった場合、codes.Canceled をエラーになるように再度ラップします。

これらの処理によって互いに嬉しいエラーを取得できるようになりました。

・トランザクション処理

DB が分割されているので、全ての DSN へトランザクションを開始し、COMMITROLLBACK も同様に行わなければなりませんでした。

Monolith なアプリケーションでも、これらの処理をサポートする汎用性の高い DB インターフェースが用意されてましたが、提供する機能を絞った Microservice へ移植するにはとても大きすぎました。

そこで先程紹介した c.mapperdb := c.conns[dbinfo] を活用して、各 DSN へトランザクションを開始する仕組みを新たに考えました。最終的に Item Service 専用の DB インターフェースを提供するパッケージとして作成でき、Monolith と異なって Item Service が本当に必要とする機能だけを実装できました。

それを用いて、どうトランザクションを行っているのかエンドポイント側のコードを記述します。

func (s *service) Create(ctx context.Context, req *pb.CreateRequest) (*pb.CreateResponse, error) {
    args, err := s.validateCreateRequest(ctx, req)
    if err != nil {
        return nil, errors.Wrap(err, "failed to validate request params")
    }

    tx, err := s.tryBegin(ctx, args)
    if err != nil {
        return nil, errors.Wrap(err, "failed to begin transaction")
    }

    if err := s.createInTransaction(ctx, tx, args); err != nil {
        if e := tx.Rollback(); e != nil {
            log.Warn(ctx, "failed to rollback database", e)
        }
        return nil, errors.Wrap(err, "failed to create item (in transaction)")
    }
    if err := tx.Commit(); err != nil {
        if errors.Has(err, dbi.ErrShouldRollback) {
            if e := tx.Rollback(); e != nil {
                log.Warn(ctx, "failed to rollback database", err)
            }
        }
        return nil, errors.Wrap(err, "failed to commit transaction")
    }
    return makeResp(args), nil
}
  • tryBegin() で各 DSN に対してトランザクションを開始する
  • Rollback()Commit() で全てのトランザクションに対して ROLLBACK もしくは COMMIT を行う
  • createInTransaction() でトランザクションに関する処理を行う。
    • メソッドの中にトランザクションで行う処理をまとめることによって、ROLLBACK を行いやすい
  • Nested transaction を意図させるコードを用いてないので、単一のトランザクションを行っているかのようなコードで記述できる

かなりシンプルに記述ができてるかと思います。

Microservices の裏で動く Microservices であることを意識する

ここまでコードを書く上で意識してきた点をいくつか記述してきましたが、至って特殊なことはしていない CRUD なサービスだったと思います。サービスの実装以外にもいくつか意識した点がありました。その中の一つは API の設計方針でした。

私達が開発している Microservices は GCP 上の Microservices からリクエストが来ます。そのために提供する API はどんな設計方針にしようか悩んでいたところ @deeeet さんから次のアドバイスを貰いました。

  • Microservices 同士で密な連携を取らずに、気がついたら他のサービスからリクエストが来ていたというのがベスト
    • e.g. あるサービスが Google Map API を叩く場合
      • そのサービスの開発者は Google Map API のドキュメントを読む
      • API Key とともにリクエストを投げる仕組みを開発、サービスに導入
      • ここまでそのサービスの Google Map API 開発者とのやりとりは発生しなかった(つまり自然に新しいリクエストが発生)
    • 毎回コミュニケーションが発生するとサービスのスケールが辛くなるため
  • Item Service なら商品情報に特化した、汎用性のある API を提供しましょう

一部どうしてもほぼ密結合になっている API もあるのですが、このアドバイスのおかげで、大体の部分で汎用性のある API を提供することができました。そして開発者間でそれらの API を使用するためのコミュニケーションコストが減り*3、より Microservices の開発に専念できるようになった気がします。

全体を通して

Microservices の裏で動く Microservices だからといって何か特別なことを行っているわけではありません。主な処理の内容は CRUD ですし、Create や Update の処理でトランザクションが必要になればその部分の処理を担保しているくらいです。しかし私達の Microservices は、お客様が提供していただいた情報を操作する重要な部分であるため、上記で挙げた部分以外にも様々な工夫が見られます。

これからもお客様の体験が良くなるよう精一杯取り組んでいきますので来年も宜しくおねがいします!

PR

弊社では Microservices 化へ挑戦したいエンジニアを引き続き募集しています。上記で紹介したコードの詳細も入社後に読むことができるので、気になった方はどしどしご応募ください!!

careers.mercari.com

*1:エラーをラップすると "wrapped msg: origin msg" のようにメッセージが長くなっていきます

*2:https://godoc.org/google.golang.org/grpc/status#FromError を参照

*3:こういう風に使えないかといった質問の受け答えは僅かですが発生してます。