はじめに
最近機会があってモジュラモノリスについてあれこれぼんやり考えています。
その性質とかをかるく整理しつつ、モジュール境界としてgRPCをつかってみるアプローチについてあれこれ考えてみます。grpc-goについてのみ考えていて他の言語でどうなるかは考慮してないのであしからず
ProtocolBuffersやgRPCは、IDLとそのコード生成を中心とした強力なエコシステムを持っており、そのメリットを享受しつつモジュラモノリスのモジュール境界として利用できんかな?という思考実験です。この構成は将来的にマイクロサービスにしたくなったときの抵抗も比較的穏やかなように思います。
そのままだと無駄にネットワークリクエストを垂れ流したりするので
- unix domain socketの利用
- local function callへの置き換え
の2つのアプローチを試してみようと思います。
これらはモジュール境界として考えたときの境界強度が異なるので、強度の差からくる性質の違いとかもかるく考察できればと思います。
それではやっていこー
そもそもマイクロサービスになにを求めていたんだっけ?
ソフトウェアアーキテクチャにはそれぞれ任意の性質があります。
マイクロサービスアーキテクチャなどのような物理的なコンピューティングの分散を伴うアプローチはざっくり以下の性質を得られます。(こまかいところはシステム分解の粒度によるし、便宜上言及していない性質もあるのであしからず)
- 独立性
- 耐障害性
- 各clientがフォールバック挙動を想定してないと、逆にサービス数だけ可用性の掛け算になって下がるので注意
- 開発組織のスケール容易性
- この性質は高い独立性からくる副次的なもの
- 物理的に設定されたサービスの境界によって、サービス内の独立性が担保されサービスとチームの自律が促される
- チームとサービスが十分自律してるなら、それ増やせば線形に開発組織がスケールできるよな理論
- 一方でモジュール間の依存関係などを十分注意しないと、デプロイの独立性などの良い性質が部分的に失われる
で、そのトレードオフとしてシステムとしての複雑さ、ネットワーク負荷のコスト、サービス数ごとに伸びる管理コストなどを受け入れています。アプリケーション目線の代表的な課題としては分散システム上の整合性の担保などがあげられます。
(なお、システムの複雑さやそこに起因する管理コストについては割と大きなコストですが、専門チームなどを設置してその部分の課題解決ができれば組織内での影響が大きく、十分にスケールした開発組織においては掛けたコストに対してレバレッジが効きやすい側面もあります。)
マイクロサービスのようなサービスを分割させたアーキテクチャを検討ないしは実行したということは、得られるよい性質のいずれかまたは複数を求めていたということです。
マイクロサービスと比較したモジュラモノリスの性質
モノリスと比較したとき、マイクロサービスにはいくつかの好ましい性質があることが整理出来ました。
いっぽうで分散したシステムには新たに生まれる課題もあるため、そのコストの支払いが難しかったり、組織がスケールする予定がないのでコストを支払ってもレバレッジが効きづらいなどの事情で選択出来ないこともあるでしょう。また、マイクロサービスを実際に採用したものの先述の理由などから得られるメリットよりコストのほうが高くついてしまった!などのケースもありそうです。
そのような流れも受けてか、最近はモノリスとマイクロサービスの間のグラデーションのなかで求める性質を絞ってちょうどいいポイントを探る動きを観測範囲でよく見かけます。
そのグラデーションのなかの選択肢の一つして、今回は モジュラモノリス について考えてみます。
モジュラモノリスは、ようはアプリケーションの作りとしては単一のデプロイメントで実行されるモノリシックな構成だけど、それぞれを一定単位のモジュールに分解することでマイクロサービスのような開発のスケールを実現させよう!というアプローチです。
さきほどのマイクロサービスでえられる性質と比較すると、ざっくり以下のような関係性になります。
マイクロサービス | モジュラモノリス | モノリス | |
---|---|---|---|
独立性 | ○(物理的に分解されたサービス群なので、個別に調整可能) | △(デプロイメントやインフラはシステム単位の変更のみ。論理的に分割されたモジュール内の仕様の独立性はある。言語選択の自由はモジュール境界の強度によるけど事実上ないと考えておいたほうがよさげ) | ✕(システム全体での変更のみ) |
耐障害性 | ○(clientがエラー時の挙動を考慮している前提つき) | ✕(部分的に落ちたら全滅) | ✕(部分的に落ちたら全滅) |
開発組織のスケール容易性 | ○(物理的な境界を伴うのでチームが自律しやすく、チームとサービスを増やせば開発組織がスケールする) | ○~△(モジュール境界の強度次第でチームの自律しやすさが変動) | ✕(サービスが大きく依存関係も複雑で開発者への認知負荷が大きい) |
システム分割のコストとか | ✕(コンポーネントが複数存在してシステムが複雑化するので、専門チームが必要なくらい管理コストがある) | ○~△(物理的なマシンは単一だけどモジュール境界の強さによっては悩みが生まれる) | ○(複雑な要件でも比較的課題すくなめで素朴に実装できる) |
組織の状況などによってシステムにどの性質を比重高く求めるかは異なると思いますが、僕の見聞きする範囲では先程のマイクロサービスで得られる性質のうち、特に 開発組織のスケール容易性 を比較的比重高めに求めている組織が多いように思います。(これはぼくの観測範囲でよくみかけるというだけで、もちろん観測範囲の中でもそうではない企業もありますし一般化するつもりはありません)
そのあたりを考えると、開発組織はスケールさせたい、けどマイクロサービスで生まれる課題へのコストは払えない、マイクロサービスによって得られる開発組織のスケール以外の得られる性質もそこまで重要視していない、などの条件がそろえばモジュラモノリスは十分検討価値のある選択肢になりそうです。
モジュラモノリスの難しい点
ここまででモジュラモノリスは状況によって十分採用価値がありそうなアーキテクチャであることがわかりました。
それだけ聞くとめちゃめちゃいい感じの選択肢のように思うんですが、例えばモノリスであっても複雑で巨大なアプリケーションほどなにかしらのモジュール機構をすでに利用しているはずです。それなのに成熟したアプリケーションではあっちこっちから該当モジュールが呼び出されて依存のツリーが不明瞭になり開発者の認知負荷は上がってしまいます。
考えてみるとモノリスとモジュラモノリスの性質を分ける構成要素は モジュール分割のやりかた だけのように思います。つまり、モノリスとモジュラモノリスを分ける要素として
- モジュール粒度
- モジュール依存関係
- モジュール境界の強度
あたりに差がありそうです。
モジュラモノリスの難しさは一言でいうと モジュールの切り方 にあります。どのような粒度にするか、またどのように依存関係を整理するかはマイクロサービスにも共通する課題ですが、モジュール境界の強度についてはマイクロサービスとは異なった課題です。
自律したチームの構築と、そこからもたらされる開発組織のスケール容易性の獲得に着目すると、 モジュール粒度は1チームを担当につけてちょうど過不足なく稼働できるようなサイズ で モジュールの依存関係は有向非巡回グラフ(依存を循環させない) かつ モジュール境界は強め のようなモジュールの切り方をすると良さそうです。
モジュールの粒度や依存関係の選択については、マイクロサービスでも同様の課題があり整理する必要があります。いっぽうモジュール境界の強度についてはモジュラモノリスならではの課題です。これらの観点について組織にあった意思決定をしてモノリスと差をつけていく感じになりそうですね。
今回はモジュール境界の強さに着目していろいろ選択肢を比較しようと思います。
余談ですが、モジュール境界の強度により重心をおいて物理的に分割しちゃったのがマイクロサービス!ということもできるかもしれません。だとするとモジュラモノリスはモジュール境界の強度のグラデーションのなかで別の選択肢をさぐったやつ!みたいな関係性になる感じですね。
モジュラモノリスでgRPCをモジュール境界としてつかってみる
ここから本題です!前置きが長かった〜
モジュラモノリスのモジュール境界としてgRPC(やそれに関連するProtocolBuffersエコシステム)を使うのはありなのでは?なんか便利そうだし、一歩進んで利用するにあたって実装上どういう選択肢があるのか比較したくない?というのがこの記事の主旨です!
今回は物理的に同一マシンで動いて単一の粒度でデプロイメントされるアプリケーションを前提としつつ、gRPCエコシステムをつかったモジュール境界の強度がことなる2つの選択肢を試してみていろいろ比較したいと思います。unix domain socketを使った通信と、local function callに置き換える2つのアプローチを紹介します!
なんでgRPCをモジュール境界として試すのかというと、ProtocolBuffersエコシステムに乗ることで得られるコード生成/lintなどの支援や、protoファイル自体のドキュメンテーションとして価値などが、モジュール境界を作るにあたってとてもよい性質だと考えたためです!あとはのちにマイクロサービスなどを検討したときに(いくつか難しい要素があるとはいえ)比較的スムーズに以降できるのも理由の一つです。
なおGoは可視性の制御の粒度が荒めな言語なので、そのまま同一レポジトリで複数モジュールを実装するとモジュール内に公開要素があった場合にgRPCで設定した境界以外の呼び出しを許容してしまう場合があります。このあたりを重く見るならなにか対策を検討したほうがよいでしょう。
たとえば、自作linterなどで境界外の呼び出しを防ぐアプローチなどです。あるいは、Serverの実装単位でレポジトリごと分割して処理の本体をinternal配下に配置したりするとモジュール境界以外の呼び出しを防げて幸せになれるかも!とかです。設定したい境界の強さに合わせてどこまでやるかは程度によりますが...(レポジトリ自体分けるならそもそもgRPCのコード生成とかあんまり考えなくても十分強いモジュール境界なのでそれだけでいいじゃん感もでてくる)。
Goの可視性への対策はそれ単体でも各選択肢でPros/Consがあり意思決定が難しい部分なのでここでは詳細には扱いません。
以後モジュール境界としての性質を述べるときは、上にあるようなGoの可視性に関する課題などについては意識しないこととします。必要がある場合は適宜何らかのアプローチで予期しないモジュールへのアクセスを塞いであるぞ〜な前提で考えていきます。
unix domain socketを利用した接続(プロセス間通信としてのモジュール境界)
grpc-goではオプションにて任意のnet.Connを経由してコネクションを確立できるので、unix domain socketを利用しちゃおう!というアプローチです。同一マシン上のunix domain socketを介した通信がモジュール境界になります。
該当のソケット上にHTTP2/gRPCのパケットがぺたーっと乗るので、アプリケーション上からはあたかも通常のネットワーク通信かのようなモジュール境界に見えます。
どうやって実装するかというと、server/clientともにoptionでunix domain socketなnet.Connを指定するだけです。
以下実装例です。モジュラモノリスの想定なので、同一process上でgoroutineを分けてserver/clientどっちもある想定。動くコードなのでお決まりの記述っぽいのが多いですが、重要なのはserverで listener, err := net.Listen("unix", "/tmp/hello.sock")
してunix domain socketをのConnを受け付けるところと、clinentでunix domain socketに向かって接続するdialerを grpc.WithContextDialer(udsDialer)
で食わせてるところです。
package main import ( "context" "fmt" "log" "net" "os" "os/signal" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" hellopb "google.golang.org/grpc/examples/helloworld/helloworld" ) type server struct { hellopb.UnimplementedGreeterServer } func (s *server) SayHello(_ context.Context, in *hellopb.HelloRequest) (*hellopb.HelloReply, error) { return &hellopb.HelloReply{ Message: fmt.Sprintf("hello, %s!", in.GetName()), }, nil } func main() { // server listener, err := net.Listen("unix", "/tmp/hello.sock") if err != nil { panic(err) } s := grpc.NewServer() hellopb.RegisterGreeterServer(s, &server{}) go func() { log.Print("start server, addr: /tmp/hello.sock") s.Serve(listener) }() // client go func() { udsDialer := func(ctx context.Context, addr string) (net.Conn, error) { var d net.Dialer return d.DialContext(ctx, "unix", addr) } conn, err := grpc.Dial( "/tmp/hello.sock", grpc.WithTransportCredentials(insecure.NewCredentials()), grpc.WithBlock(), grpc.WithContextDialer(udsDialer), ) if err != nil { panic(err) } defer conn.Close() client := hellopb.NewGreeterClient(conn) res, err := client.SayHello( context.Background(), &hellopb.HelloRequest{ Name: "convto", }, ) if err != nil { panic(err) } fmt.Println(res.GetMessage()) }() q := make(chan os.Signal, 1) signal.Notify(q, os.Interrupt) <-q log.Println("shutdown server...") s.GracefulStop() }
さてunix domain socketでの通信について、これはモジュール境界の強度としてはそこそこ強めです。間に通信プロトコルが挟まってるので使い心地は通常のgRPCとほぼ同じです。
今回はモジュラモノリスで同一のデプロイメントなアプリケーションを想定してるので、serverとclientの通信が親が同一プロセスな複数スレッド間の通信になります。そのため任意のグローバル変数がシステム全体で参照できたりもできます。そのあたりはマイクロサービスよりも境界が弱くなる部分ですね。
それ以外は(Goの可視性制御の観点を整理した前提であれば)各サービスからみるとほぼマイクロサービスと同等の境界に見えると思います。
モジュール境界からやり取りできる値は、グローバル変数を除くとgRPCプロトコルとして含めることのできる値に限られます。たとえばあるtxを発行したDB Connを渡したりするのは防ぐ方向に力学が働きそうです。
(グローバル変数としては共有できるのでtxidをkeyにしたDB Connをもったデータ構造などをグローバルに定義しつつ、gRPCとしてtxidを送る!とかで渡せるっちゃ渡せることには注意。ただちゃんとやると登録済みtxの管理とか考えること多くて面倒なのでわりと強い抵抗力はありそう。しばらく応答ないtxを一定時間でかってにabortさせとく!とか、スパイクして風速でるとデータ構造がでかくなってメモリ面でこまるからcap設定したりいい感じにバッファ管理したり〜とか)
もちろん強めの境界は柔軟な連携ができないので困りごともあるんですが、裏を返すと予期しない外部との依存が起こらなくなるので、モジュールの独立性は高くなりやすい性質も持っています。この境界の強度を突き詰めた先にはマイクロサービスがありますが、モジュラモノリスでも十分強い境界は設置できるのでこの性質を重視したい場合は十分選択余地があるでしょう。
local function callを利用した接続(関数呼び出しとしてのモジュール境界)
はじめに弁明しておくと、これはgrpc-goによって生成されたコードを利用してlocal function callでの呼び出しをするアプローチであり、gRPCパケット自体は流れないので厳密にはたぶんgRPCではないです。
本記事では(grpc-goの生成コードを利用するのもあり)便宜上gRPCの利用形態の一種とカテゴライズして紹介しますが、厳密な定義などに沿った分類ではないのであしからず。
どうやって実装するかというと、grpc-goによって生成される通信を行うinterfaceである grpc.ClientConnInterface
は差し替え可能なので、自前で直接Server関数をcallする実装を作って差し替えるだけです。
grpc-goでコード生成すると、client生成時に以下のような関数を呼び出すことになります
func NewGreeterClient(cc grpc.ClientConnInterface) GreeterClient { return &greeterClient{cc} }
で、引数の grpc.ClientConnInterface
はこんなかんじです。
https://github.com/grpc/grpc-go/blob/master/clientconn.go#L441-L450
// ClientConnInterface defines the functions clients need to perform unary and // streaming RPCs. It is implemented by *ClientConn, and is only intended to // be referenced by generated code. type ClientConnInterface interface { // Invoke performs a unary RPC and returns after the response is received // into reply. Invoke(ctx context.Context, method string, args interface{}, reply interface{}, opts ...CallOption) error // NewStream begins a streaming RPC. NewStream(ctx context.Context, desc *StreamDesc, method string, opts ...CallOption) (ClientStream, error) }
通常はこのinterfaceの実装として、grpc.Dial()で取得した grpc.ClientConn
を食わせるわけですね。
察しのよい方は気づいたと思うんですが、これを自前で実装しちゃおう!というのが今回の試みです。とりあえずベタッと書くと以下の感じです。
type virtualConn struct { server *server } func (c *virtualConn) Invoke(ctx context.Context, method string, args interface{}, reply interface{}, _ ...grpc.CallOption) error { // サンプル実装なので決め打ちで適当に実装してますが、 // 実際はmethodごとにswitchしたりreflect使ってうまくやるといいと思います in, ok := args.(*hellopb.HelloRequest) if !ok { return fmt.Errorf("invalid type: %T", args) } _, ok = reply.(*hellopb.HelloReply) if !ok { return fmt.Errorf("invalid type: %T", reply) } res, err := c.server.SayHello(ctx, in) if err != nil { return err } reflect.ValueOf(reply).Elem().Set(reflect.ValueOf(res).Elem()) return nil } func (c *virtualConn) NewStream(_ context.Context, _ *grpc.StreamDesc, _ string, _ ...grpc.CallOption) (grpc.ClientStream, error) { return nil, status.Error(codes.Unknown, "stream not supported") }
(↑あくまで検証のための間に合せの実装であり、コメントにあるようにちゃんとやるならもうちょっと整理したほうがよさげです。 SNKRDUNKさんの事例 は割と参考になりそう)
このアプローチはさっきのunix domain socketよりも境界は弱めです。なぜなら単なるlocal function callであり、grpc-goによって生成されるclientコードはリクエストにctxを含むので、ctxの利用用途であるリクエストスコープの値の伝搬は柔軟に行えるからです。リクエスト単位の値をとくに考慮せずに伝搬できるのでunix domain socketを使う場合よりもさらに柔軟です。
たとえば先程例にあげたtx開始済みのDB Connなどについても、(DB Connはリクエストスコープなのかという別観点の論点はありつつ)ctxに含ませれば簡単に別モジュールに渡すことが出来ます。こちらの場合は外部のバッファなどは必要とせずに変数として渡せるので、unix domain socketのときに紹介したような課題もなく、アーキテクチャとしての抵抗は比較的少ないです。というかそういうことをされる前提で採用したほうがよいです。トランザクションをリクエストスコープで共有する情報として捉えるならtxを利用してモジュール間の整合性を取ることはほかの選択肢と比較して容易でしょう。
tracing用のメタデータなどもctxにつけて渡せばいいので、unix domain socketのアプローチと比較すると伝搬が楽です。(unix domain socketでやるなら頑張ってgRPCメタデータとして伝搬してモジュール内でとりだして再セット!みたいなことになりそう)
grpc-goによって生成されるインターフェースでは悪さできるのはctxくらいなので、プログラム書く目線でさっきのunix domain socketの案とくらべるとそのあたりがモジュール境界の強度の差になりそうです。リクエストスコープの解釈をちょっと捻じ曲げてctxにつっこむ方向に力学が働きやすいと思います。
なにかといろいろ渡す方向に流れやすいというのは、裏を返せば将来的により強いモジュール境界を採用しづらい方向に進みやすいということでもあり、いつかより強いモジュール境界(マイクロサービスなど)を採用したくなったときなどにリクエストスコープの値の伝搬まわりは課題になると思います。
あとはgRPCとしての通信やシリアライズなどは一切経由しないので、オーバーヘッドはかなり小さめなことにも言及しておきます。通信起因のエラーなども一切ないはずなので、他の選択と比較してそのへんの性質はよりモノリスなアプリケーションに近いです。
まとめ
- モノリスとマイクロサービスの間には、得られるよい性質のなかでなにを重視するかによっては、グラデーションの中にいろいろな選択肢がありそう
- 開発組織のスケールに絞って考えるとモジュラモノリスは有効な選択肢になりそう
- モジュラモノリスを組織のスケールにつなげるためには、チームの独立性を維持できるくらい強めなモジュール境界の強度があるほうがよさそう
- grpc-goをつかったモジュール境界は強度の異なる2つのアプローチがありそう
みんなで組織にあったいい感じの選択をしてうまい具合のデリバリーができるといいっすね。ひきつづき頑張っていきます。