LayerX エンジニアブログ

LayerX の エンジニアブログです。

connect-go による複数サービスの開発とユニバーサルバイナリによる改善

こんにちは、LayerX の id:snamura です。7月はLayerXエンジニアブログを活発にしよう月間 ということなので、本日は複数サービスに分割されていく中で、開発環境を改善した話を書きます。

LayerX が提供するバクラクでは、 Decoupling a service from monolith with Protocol buffers and connect-go で紹介した通り、 buf.build の提供する connect を使ったサービス化が進められています。

connect の活用によって、既存のHTTPサーバーをサービスとしてインターフェイスを再定義したり、モノリスを無理に分割することなく、ドメインごとにインターフェイスを定義し、サービスの分割が可能になりました。

connectはgRPCのサービス定義を起点に動作するため、シンプルなインターフェイスと、 http.Server のサポートにより、サービスの分割が容易になります。分割しやすいので、サービスの疎結合化を推進できるのですが、サービス数はどんどん増えていきます。サービスが増えてくると、様々な問題に直面することもあります。特に以下の3つは、モノリスでは遭遇しない、開発者の体験を阻害する可能性のある問題です。

  • ローカル環境の構築が困難
  • ビルドに時間がかかる
  • デプロイに時間がかかる

上記の問題に遭遇すると、ローカル開発環境やデプロイの複雑性が増して「サービス分割しないほうがよかった」という気持ちに駆られるものです。

LayerX でも既に20個以上のサービスが作られており、上記の課題を感じ始めています。そこで、Mac が提供している複数CPUアーキテクチャに対して単一のバイナリで表現するユニバーサルバイナリに習って、複数サービスを単一のバイナリで提供するためのユニバーサルバイナリを導入するアプローチを取っています。(ここで言うユニバーサルバイナリは、複数CPUアーキテクチャではなく、複数サービスを1つのバイナリに取り込むものです)

このアプローチは、特にmonorepoで複数のサービスを構築している場合に便利です。LayerXでは、monorepoで開発しているサービス群はTrunk Based Development で開発しており、常にmainブランチがデプロイ対象となります。ブランチにある各コミットは時系列のスナップショットとなるため、ユニバーサルバイナリはある時点でのサービス全体の状態を表すのに、都合が良いのです。

all-in-one サーバーと、個別起動サーバー

ユニバーサルバイナリには、2種類に起動アプローチを作りました。1つは、all-in-one モードで、すべてのサービスが同時に同じ単一プロセスで起動します。もう1つは、起動時にコマンドライン引数などで利用するサービスを選択する、選択式起動モードです。

all-in-one サーバーはローカル開発において便利な起動方法で、すべてのサービスが一括して1プロセスで起動するので、それぞれのサービスプロセスを個別に立ち上げる必要がありません。

選択式起動は、本番環境などで便利な起動方法です。プロセス内で利用したいサービスのみを起動することで、ユニバーサルなコンテナイメージやバイナリを1つ用意するだけで、あらゆる種類のサービス起動パターンを実現できます。また、デプロイ時にすべてのサービスを個別のバイナリやコンテナイメージとしてビルドする必要もなくなります。

どうやってユニバーサルバイナリを作るか

ユニバーサルバイナリは、単に connect-go のサービス実装を1つの http.Server 上のルートに登録することで実現します。これは一般的な gRPC でも全く同様の方法が利用できますが、 connect-go は http.Server を利用できるため、 gRPC 以外にも HTTP のサービスを共存することができます。

バクラクでは、connect-go のサーバーを1つ抽象化して http.Server で表現しているため、単一の http.Server に複数登録しても、それぞれの http.Handler が独立した環境を維持できるようにしています。

connect-go を実装したサーバーは、例えば下記のように http.Handler を返却します。connect-go の Service Handler を直接使わずに、自前の http.ServeMux をラップすることで、このサービスだけの HTTPミドルウエア や関連ライブラリなどを実装する余地を残します。

package main

import (
    "net/http" 

    "github.com/acme/ourmonorepo/go/proto/demo/v1/demov1connect"
    "github.com/acme/ourmonorepo/go/services/demo/v1/hello"
)

type server struct {
    handler http.Handler
}

func (s *server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    s.handler.ServeHTTP(w, r)
}

func NewServer() http.Handler {
    hellosvc := hello.New()
    mux := http.NewServeMux()
    mux.Handle(demov1connect.NewHelloServiceHandler(hellosvc))
    return &server{handler: mux}
}

こうした作成したサーバーを、1つのサーバーに同居するには、下記のようなコードを書きます。グローバルなミドルウエアなどはこちらに登録することができます。

import (
  "log"
      "net/http"

    "github.com/acme/ourmonorepo/go/services/demo/v1/hello"
    "github.com/acme/ourmonorepo/go/services/demo/v1/world"
    "golang.org/x/net/http2"
    "golang.org/x/net/http2/h2c"
)

func main() {
    mux := http.NewServeMux()
    mux.Handle("/demo.v1.HelloService/", hello.NewServer())
    mux.Handle("/demo.v1.WorldService/", world.NewServer())

    srv := &http.Server{
        Addr:    ":9000",
        Handler: h2c.NewHandler(mux, &http2.Server{}),
    }
    if err := srv.ListenAndServe(); err !=nil {
        log.Fatal(err)
  }
}

このサーバーを起動すると、複数サービスが同時に稼働し、HTTP Route ごとにアクセスできる HTTP Server になります。

本番環境でこのユニバーサルライブラリを使って、単一のサービスを起動する場合は、環境変数やコマンドライン引数などから、起動したいサービスを選択するコードを書き、必要な http.Handler のみを登録することで、単一サービスとして必要なライブラリのみをロードし、起動することができます。

import (
    "flag"
    "log"
    "net/http"

    "github.com/acme/ourmonorepo/go/services/demo/v1/hello"
    "github.com/acme/ourmonorepo/go/services/demo/v1/world"
    "golang.org/x/net/http2"
    "golang.org/x/net/http2/h2c"
)

func main() {
  spec := struct{ service string }{}
  flag.StringVar(&spec.service, "service", "", "service identifier")
  flag.Parse()
  
  mux := http.NewServeMux()

    switch spec {
    case "demo.v1.HelloService":
        mux.Handle(hello.NewServer())
    case "demo.v2.WorldService":
        mux.Handle(world.NewServer())
    }

  srv := &http.Server{
        Addr:    ":9000",
        Handler: h2c.NewHandler(mux, &http2.Server{}),
    }
  if err := srv.ListenAndServe(); err !=nil {
    log.Fatal(err)
  }
}

複数サービスのユニバーサルバイナリがもたらす利点

複数サービスを運用するような環境では、ユニバーサルバイナリによって、以下の利点がもたらされます。

ローカル開発プロセスのシンプル化

単一のプロセスを起動すればすべての必要なサービスが起動するため、monolith API のようなシンプルさを維持できます。

ビルド時間の短縮

ユニバーサルバイナリを1つビルドし、コンテナイメージを作れば、すべてのサービスがそのバイナリを参照できるので、サービス数が増えても、デプロイ時間を短く保つことができます。 また、ビルドイメージが1つになることで、イメージスキャンの回数を抑制できるので、インフラのコストにも効果があります。さらに、イメージの脆弱性も、複数に分散せずに1つに集約できるので、対応しやすくなります。

柔軟なデプロイ

サービスごとに起動コンテナを調整できるため、例えば Service A, Service B は同じサーバー、Service C は独立したサーバーで動かす、などの柔軟な選択肢をデプロイ先に提供することができます。また、開発するときに、それぞれのサービスが、ローカルで動いているかリモートなのかを意識する必要がなくなります。

ビルドしたバイナリサイズの比較

複数サービスをバイナリに含めた場合、サービスのサイズは気になるところです。試しに、いくつかのバクラクのサービス群とユニバーサルにしたバイナリのサイズを比較してみました

サービス名 サイズ
ユニバーサル 126 MB
Service A 85 MB
Service B 57 MB
Service C 39 MB

依存ライブラリが重そうなサービスで、85MB 軽いサービスだと 40MB 程度のサイズでした。ユニバーサルはこれに比べると 126 MB になっており、サイズ自体は大きくなってしまいます。ただ、20個あるサービスを1つにまとめても 126 MB 程度だと考えると、かなり軽量なサイズに収まっています。

ビルド時間の比較

ユニバーサルバイナリは関連ライブラリやすべてのロジックを含むため、当然ビルド時間も長くなります。キャッシュに乗ればビルドは高速なので、開発時はそこまで強く意識する必要はありませんが、以下にキャッシュなし時でのビルド時間の比較をしてみました。

サービス名 キャッシュなし時 キャッシュあり時
ユニバーサル 150.45s 0.75s
Service A 104.13s 0.51s
Service B 60.17s 0.34s
Service C 47.46s 0.29s

キャッシュがない場合のビルド時間は長くなってしまっていますが、キャッシュが効いている場合は意識する必要はなさそうです。特にローカル開発におけるリビルド時間への影響は少なそうです。CIにおけるビルド時間は長くはなりますが、複数サービスを全てをビルドするのと比較すると、ユニバーサルバイナリのビルド時間はかなり短くなります。

まとめ

上記のように、複数サービスを統合した connect-go のユニバーサルバイナリは、マイクロサービスなどの大量のサービスが生み出される開発現場において、開発者体験の様々な課題を解決してくれます。このアプローチを洗練させ、疎結合化を推進しながら、その開発者体験の両立を目指していきたいと思います。