freeeのマイクロサービス基盤とWire導入

こんにちは、freee株式会社でソフトウェアエンジニアをしているterashiこと寺島です。 この記事はfreee Developers Advent Calendarの6日目です。

今年の7月より新しくサービス基盤というチームを立ち上げたので、ここではチームの取り組みと、Goの基盤ライブラリへのWireの導入事例を紹介します。

freeeのマイクロサービス化への道

最近Web業界でGoやKubernetesによるマイクロサービスの採用事例の記事などをよく見ますね。 freeeも当初は単一Railsアプリケーションでしたが、会計だけでなく人事労務、会社設立などとプロダクトが増え、機能が増えてと複雑化していき、開発チームの規模も大きくなっていく中で、2年ほど前から少しずつサービスを分離しながらマイクロサービス化を進めています。

最初に切り出したサービスでは、メモリフットプリントの小ささ、パフォーマンス向上のしやすさ、学習のしやすさなどからGoを採用し、JSON APIサーバーとして書き始めましたが、途中でRubyとGoのクライアントを自動生成できるのとAPIに型を定義できるのに惹かれてgRPCに切り替えました。この目的だけならSwaggerでもよかったのですが、将来的にはマイクロサービス間の通信がパフォーマンスボトルネックになることが想定されるので、そこを常時接続とバイナリフォーマットで最適化しやすいgRPCを選択しました。

そうこうしているうちにkube-awsのメンテナをはじめとして、Kubernetes on AWS全般にコントリビュートしているmumoshuさんがSREとしてfreeeに入社してくれて、一気にKubernetes環境が用意できるようになり、次のサービスからはKubernetesでホストするようにしました。

また、gRPCも特にネットワーク周りのベストプラクティス(こちらの記事で詳しく紹介しています)を固めるのに時間がかかってしまったのもあって、一部goaフレームワークを使ったサービスもあります。

最終的にGo + gRPC + Kubernetesと最近ではよくある構成に落ち着いたのですが、ここに至るまでに微妙に違う構成のサービスが混在するようになってしまいました。 同時並行で作ったことで各種やり方の知見を溜めることができてのは、けして悪くはなかったと思うのですが、このままだと管理が辛い負債と化すので、サービス基盤チームで知見をまとめて、共通コンポーネント化を進めています。

サービス基盤チームの取り組み

freeeのサービス基盤チームは、3年後のより大規模になったプロダクトと開発チームを見据えて、開発のボトルネックになりそうな部分を今のうちに解消し、サービス開発の基盤を整備するのをミッションとして、今年の7月に始動しました。

現在このチームが主に取り組んでいるのが上記マイクロサービスのための共通コンポーネント化とデータベース周りのパフォーマンス対策です。

共通コンポーネント化は各サービスがドメインロジックに注力できるように、gRPCの場合はproto定義とRPCの実装を書きさえすれば、すぐにproduction環境にデプロイできる形を目指しています。 ロギング、モニタリング、分散トレース、サーバー管理、ヘルスチェック、エラー通知、リクエストメタデータ、DB・KVSへの接続、k8sのテンプレートなどはサービス基盤が責任を持ち、例えばモニタリングの項目を増やすなら共通コンポーネントで実装し、各サービスはバージョンを上げるだけでいいように現在実装を進めています。 また、Rails側もリクエストメタデータの引き回しなどはgem化して各プロダクトに入れていっています。

データベースに関しては今のところはAWS RDSのスケールアップで対応できているのですが、RDSの上限が増えるよりもfreeeの利用量の伸びの方が早いので、Auroraを検討しつつもシャーディングによるスケールアウトの選択肢も取れるように準備を進めています。 freeeでは基本事業所単位で情報を管理しているのですが、ユーザーは複数の事業所に所属できるのでそのまま事業所のIDでシャーディングしようとすると問題になります。 そこでユーザー情報を管理するマイクロサービスに切り出しつつ、このサービスを共通コンポーネントの最初の利用者として、使いやすいコンポーネントになるように並行で開発をしています。

現在はエンジニア2名と開発本部長の体制でスタートしたばかりですが、採用も進めているので、このような共通基盤の開発を一緒にやってみたいと思ってくれる方は、ぜひ応募してみてください。

freee k.k. Careers - 開発基盤(サービス基盤):eng

Goの社内共通ライブラリとWireの導入

さて文章だけだとつまらないので、どのような形で共通コンポーネントを実装することにしたのかを紹介しましょう。

2年前からの最初のサービス切り出しプロジェクトに私も参加していて、実は当初から今後のために共通部分は後続のサービスでも使えるように作ろうとしていたのですが、現状ばらばらの実装になっている通りうまくいきませんでした。 原因としてはどこまで自前実装するか、pkg/errors, gormなどのオープンソースライブラリを利用するかの判断をするだけの知見が溜まっていなかったのもあるのですが、最大の問題はDB接続情報などを管理するconfig packageの設計でした。

configの型定義を一箇所にまとめてしまったため、共通設定 (e.g. DB接続) とサービス固有の設定 (e.g. 依存サービスのURL) が混ざってしまい、共通機能のpackageがconfig packageに依存するとサービス固有の部分にまで依存してしまって、単純にimportすればいいようになりませんでした。 制御の反転 (IoC) が必要だったのですが、当時はプロジェクトのリリースの優先度も高く、後回しになってしまっていました。

サービス基盤チームとして共通化に再挑戦するにあたり、前回の反省からどのように疎結合なコンポーネントをつくるのがいいかを検討していた際に、ちょうど折よくgo-cloudの一部としてWireが公開されました。 Wireはgo-cloudでGCPでもAWSでも同じようにコンポーネントを使えるようにするために作られた静的DI (Dependency Injection) ツールなのですが、単独でも使えてGoでの開発によくマッチしそうだったのでfreeeのサービス基盤でも使わせてもらいました。

WireはGoでよくあるNew関数をdependencyである引数からオブジェクトを生成するProviderとし、Injectorという関数でアプリケーションに必要なProvider Setを宣言した上で、コード生成により初期化を行います。 細かい説明はGo blogや弊社エンジニアbudougumi0617がGo(Un)Conferenceで発表した資料が参考になります。

例としてMySQLを使うgRPCサービスを共通基盤コンポーネントとアプリケーションに分けてWireを使って書いてみます。 ここでは一部抜粋しますが、全体はgithub.com/terashi58/wire-exampleに公開してあります。

まず、sql.DBを作成するProviderを用意します。

// base/database/db.go
package database

import (
  "log"
  "database/sql"

  "github.com/google/wire"
)

// Set is a Wire provider set that produces a *sql.DB.
var Set = wire.NewSet(
  Open,
)

// DataSource specifies how to connect to a source database.
type DataSource struct {
  DriverName     string
  DataSourceName string
}

// Open opens a connection to a SQL database.
func Open(src DataSource) (*sql.DB, func(), error) {
  db, err := sql.Open(src.DriverName, src.DataSourceName)
  if err != nil {
    return nil, nil, err
  }

  cleanup := func() {
    if err := db.Close(); err != nil {
      log.Print(err)
    }
  }
  return db, cleanup, nil
}

単純にsql.Openをラップしているだけですが、二番目の戻り値で後処理を指定しています。

DBの種類によって設定の仕方は違うので、MySQL用のDataSouceのProviderも作ります。 上のdb.goに入れてもいいのですが、PostgreSQLなどが増えたときに余計なドライバをリンクしなくていいように分離します。

// base/database/mysql/mysql.go
package mysql

import (
  "github.com/go-sql-driver/mysql"
  "github.com/google/wire"

  "github.com/terashi58/wire-example/base/database"
)

// Set is a Wire provider set that produces a *sql.DB with MySQL driver.
var Set = wire.NewSet(
  database.Set,
  NewDataSource,
)

// NewDataSource generates a DataSource for MySQL.
func NewDataSource(cfg *mysql.Config) database.DataSource {
  return database.DataSource{
    DriverName:     "mysql",
    DataSourceName: cfg.FormatDSN(),
  }
}

基板側としてgRPCサーバーとヘルスチェック用のHTTPサーバーも用意しますが、ここでは省略します。

続いてアプリ側として、DBを使うgRPC Serviceを実装します。

// app/service/greeter/greeter.go
package greeter

import (
  "context"

  "database/sql"
  "github.com/google/wire"
  "github.com/terashi58/wire-example/app/proto"
  "google.golang.org/grpc"
)

// Set is a Wire provider set that produces a *Server.
var Set = wire.NewSet(
  NewServer,
)

// Server implements the gRPC Greeter service.
type Server struct {
  db *sql.DB
}

// NewServer creates a new Greeter server.
func NewServer(db *sql.DB) *Server {
  return &Server{
    db: db,
  }
}

// Register implements rpc.ServiceImpl.
func (s *Server) Register(gs *grpc.Server) {
  proto.RegisterGreeterServer(gs, s)
}

// HelloWorld returns a "hello world" message.
func (s *Server) HelloWorld(ctx context.Context, req *proto.HelloWorldRequest) (*proto.HelloWorldResponse, error) {
  // Use s.db some how.
  return &proto.HelloWorldResponse{Message: "hello world"}, nil
}

必須ではないですがRPCサーバーに関係するProviderはpackageとしてまとめてしまうと見通しがよくなります。

// app/service/service.go
package service

import (
  "github.com/google/wire"

  "github.com/terashi58/wire-example/app/service/greeter"
  "github.com/terashi58/wire-example/app/service/pinger"
  "github.com/terashi58/wire-example/base/rpc"
  "github.com/terashi58/wire-example/base/rpc/interceptor"
)

// Set is a Wire provider set that produces a *rpc.Server.
var Set = wire.NewSet(
  rpc.Set,             // Provider Set for grpc.Server
  interceptor.Default, // Provider of gRPC interceptors
  greeter.Set,
  pinger.Set,
  NewServices,
)

// NewServices provides a list of gRPC services.
func NewServices(gs *greeter.Server, ps *pinger.Server) []rpc.ServiceImpl {
  return []rpc.ServiceImpl{gs, ps}
}

pingerは別のgRPC Serviceです。多数のgRPC Serviceに分割されていてもこのレイヤでまとめられます。

MySQLへの接続情報は環境変数から取ることにしてみます。

// app/config/env.go
package config

import (
  "os"

  "github.com/go-sql-driver/mysql"
  "github.com/google/wire"
)

// Set is a Wire provider set that gives configuration for this app.
var Set = wire.NewSet(
  MysqlConfig,
)

// MysqlConfig generates config to connect MySQL.
func MysqlConfig() *mysql.Config {
  cfg := mysql.NewConfig()
  cfg.User = os.Getenv("DB_USER")
  cfg.Passwd = os.Getenv("DB_PASS")
  cfg.Net = "tcp"
  cfg.Addr = os.Getenv("DB_ADDR")
  cfg.DBName = os.Getenv("DB_NAME")
  cfg.ParseTime = true
  return cfg
}

ひとつならwire.Setを作る意味は無いのですが、今後増えたときにまとめて参照できるように始めからSetを定義しておきます。

Providerが一通りできたので、main.goの横にInjectorを作ります。

// app/injector.go
// +build wireinject

package main

import (
  "context"
  "fmt"
  "time"

  "github.com/google/wire"
  "github.com/terashi58/wire-example/app/config"
  "github.com/terashi58/wire-example/app/service"
  "github.com/terashi58/wire-example/base/database/mysql"
  "github.com/terashi58/wire-example/base/rpc"
  "github.com/terashi58/wire-example/base/server"
)

func httpConfig(flags *cliFlags) server.Config {
  return server.Config{
    Addr:         fmt.Sprintf(":%d", flags.StatusPort),
    ServeTimeout: 5 * time.Second,
  }
}

func rpcConfig(flags *cliFlags) rpc.Config {
  return rpc.Config{
    Addr: fmt.Sprintf(":%d", flags.Port),
  }
}

func initializeApp(ctx context.Context, flags *cliFlags) (*app, func(), error) {
  wire.Build(
    appSet,
    server.Set,
    service.Set,
    mysql.Set,
    config.Set,
    httpConfig,
    rpcConfig,
  )
  return nil, nil, nil
}

なおappSetcliFlagsはmain.goで定義しています。

initializeApp関数はnilを返していますが、これをコード生成により置き換えます。 先頭の +build wireinject というコメントがみそで、この指定によりWireの処理中には対象になるけど実際のbuild時にはこのファイルは無視されるようになります。

では最後にwireコマンドを実行してみましょう。

app$ wire

これにより以下のようなコードが生成されます。

// Code generated by Wire. DO NOT EDIT.

//go:generate wire
//+build !wireinject

package main

import (
        ...
)

// Injectors from injector.go:

func initializeApp(ctx context.Context, flags *cliFlags) (*app, func(), error) {
        serverConfig := httpConfig(flags)
        params := &server.Params{
                Config: serverConfig,
        }
        serverServer := server.New(params)
        config2 := rpcConfig(flags)
        mysqlConfig := config.MysqlConfig()
        dataSource := mysql.NewDataSource(mysqlConfig)
        db, cleanup, err := database.Open(dataSource)
        if err != nil {
                return nil, nil, err
        }
        greeterServer := greeter.NewServer(db)
        pingerServer := pinger.NewServer()
        v := service.NewServices(greeterServer, pingerServer)
        v2 := interceptor.Default()
        rpcParams := &rpc.Params{
                Config:       config2,
                Services:     v,
                Interceptors: v2,
        }
        rpcServer := rpc.New(rpcParams)
        mainAppParams := appParams{
                statusServer: serverServer,
                rpcServer:    rpcServer,
        }
        mainApp := newApp(mainAppParams)
        return mainApp, func() {
                cleanup()
        }, nil
}

...

このファイルは+build !wireinjectと指定されているので、injector.goとは逆にWireの処理中だけは無視されます。

あとはmain.goでRPC Serverを起動するだけです。

この程度の規模であれば直接手で書いたほうが早いのですが、数が増えてくると管理が大変になりグローバル変数などの使用を考えてしまいます。 実際今作っているサービスではProviderの数が既に30を超えており、手でメンテするとなるとうっとする量になっています。

最後に

元々はconfig packageをどうするかを検討してWireを導入したのですが、結果としてDIに合わせたコードを書くようになり、疎結合で扱いやすくテストも書きやすいpackageになり、共通コンポーネントの品質が上がりました。 また、WireはInjector以外ではWire向けのコードを必要とせず*1、小さい規模なら普通に手で書くコードをツールで生成するだけというシンプルな構成なので、複雑なレイヤーが無く見通しいいコードになります。 ぱっと見ただけだと単純でたいしたことをしていないようだけど、使い込むと価値が出てくるのは設計がよくできているということだと思うので、私も見習っていきたいです。

再掲になりますが、freee株式会社ではスモールビジネスを支えるプロダクトの基盤を作るソフトウェアエンジニアを募集しています。もし興味があればご連絡ください。

jobs.jobvite.com

明日は人事労務freeeのPM、巨人 (身長面で) こっしーこと浅越です。お楽しみに!

*1:Provider SetはなくてもInjectorに直接書けます