Go で新しいサービスを実装する際に意識したポイント

こんにちは!ソフトウェアエンジニアとして活動している @nissy_dev です。

サイボウズでは、各プロダクトを新しいインフラ基盤に移行する取り組みを進めています。この記事では、その一環としてサイボウズ Office とメールワイズのテナント管理ロジックを Go で新たに実装する際に意識したポイントについて紹介します。


目次


テナント管理ロジックのオーナシップの移行

現在、Cybozu では各プロダクトを新しいインフラ基盤「Neco」へと移行中です。この移行は、サービス運用の効率性と柔軟性を向上させるための重要なステップになっています。

この新しい基盤に移行する前は、kintone、Garoon、サイボウズ Office、メールワイズといった各アプリケーションのテナント管理ロジック (作成、更新、削除など) は Python で自動化され、クラウド基盤チームによって管理されていました。しかし、これらのロジックにはアプリケーション固有の知識が含まれているため、メンテナンスコストが高くなっていました。

そこで、Neco への移行に伴い、テナント管理ロジックのオーナーシップを各アプリケーションチームに移行することにしました。新しいインフラ基盤では、クラウド基盤チームが管理するテナント管理サービスが gRPC を利用して各アプリケーションのテナント管理のロジックを実行する構成になっています。

マルチテナント SaaS アーキテクチャの構築」という本では、テナントのプロビジョニング (≒ 作成) のオーナシップをどこで持つのかが重要な選択の 1 つであると紹介されています。次の図では、プロビジョニングをどこで実行するのかの選択肢を表しています。

テナントのプロビジョニングをどこで実行するかを表した図
(引用: マルチテナント SaaS アーキテクチャの構築)

コントロールプレーンに該当するサービスは、クラウド基盤チームが開発しているテナント管理サービスと捉えることができます。テナント管理ロジックのオーナーシップの移行は、まさに図の右から左の構成へ移行に対応していて、アプリケーションチームが緑色のテナントプロビジョニングのロジックを実装します。

Go を利用した新しいサービスのモノレポ開発

チームでは、サイボウズ Office とメールワイズのテナント管理ロジックを Go で実装しています。また、関連する他のいくつかのサービスの実装も同時に必要となったため、モノレポでの開発を進めています。

当初は、チームメンバーが比較的慣れている Node.js での実装も検討しました。しかし、gRPC に関するエコシステムがまだ成熟していないことや、他のチームがすでに Go での実装を進めていたことも考慮し、最終的に Go で実装する選択をしました。

ここでは、Go で新しいサービスを実装する際に意識したポイントについて紹介します。

ディレクトリ構成

Go のモノレポ構成には、リポジトリ内に単一の go.mod を配置する single module 構成と、複数の go.mod を配置する multi module 構成の 2 つがあります。

今回は、サービス間でのコードの独立性を重視して、サービスごとに go.mod を作成する multi module 構成を採用しました。各サービス内のディレクトリ構成については、公式ドキュメントの Organizing a Go module を参考に、以下のような構成にしました。

service/
├── cmd/   # プログラムのエントリー
│   ├── server-1/main.go
│   ├── server-2/main.go
│   ...
│
├── internal/   # サーバーで利用されるロジック
│   ├── client/   # API クライアント
│   ├── core/     # ドメインモデル
│   ...
│
├── e2e/   # E2E テスト
├── Dockerfile
├── go.mod
├── go.sum

また、multi module 構成でもモジュール間でのコードの共有は可能ですが、共有するモジュールのバージョン管理が複雑になるコストと、共有したいコードがそれほど多くないことを踏まえて、今回は共有せずに重複するコードを許容しています。そのため、基本的に内部のコードは internal ディレクトリにすべて実装するようにしています。

サービスの多くは gRPC サーバーですが、proto ファイルは別のリポジトリで管理しています。これは、複数のチームのサービスで共通して利用される proto ファイルを一元管理するためです。一元管理しているリポジトリでは proto ファイルからコードを生成し、各チームのサービスが利用できる形で提供しています。

ビルドやリントツール

各サービスの Go のバージョンについては、go.mod の go directive で管理しています。最近までは aqua というツールを使って管理していたのですが、Go の 1.21 で導入された Go Toolchain の仕組みにより必要ないと判断しました。

Go Toolchain は、開発環境のバージョンが go.mod で指定されたものと異なる場合に、ビルドに必要な最低バージョンを自動で管理してくれます。例えば、開発環境で 1.21.1 を使用していて、go.mod1.22.3 を指定している場合、1.22.3 のバイナリがインストールされます。詳しい動作については、次のスライドが参考になります。

ビルド時には、ローカルのディレクトリ構成やユーザ名がバイナリに残らないように -trimpath フラグを有効化します。

リンターについては、golangci-lint を採用し、バージョン管理には aqua を利用しています。バージョンを管理する aqua.yaml や設定ファイルの .golangci.yaml はルートディレクトリに配置し、複数のサービスで共通のものを利用しています。

エラーハンドリング

Go では、エラー生成のためのメソッドとして errors.Newfmt.Errorf が提供されています。一方で、これらのメソッドで生成したエラーにはスタックトレースが含まれていないため、エラーログからエラーの発生箇所を特定するのに時間がかかってしまいます。

このため、スタックトレースのサポートが充実している cockroachdb/errorsgo-errors/errors などのサードパーティパッケージの利用を検討しました。しかし、これらのパッケージではスタックトレースの扱い方がそれぞれ異なっており、将来的にメンテナンスが終了した場合の移行コストが大きくなる懸念がありました。

そこで、今回のサービスではエラーのスタックトレースの取得は見送り、代わりにエラーをラップする際に適切なメッセージを付与することで、エラーの発生箇所を特定しやすいようにしています。

エラーのラッピングには、fmt.Errorf のフォーマット指定子 %w%v を利用できます。%w を用いてエラーを作成すると Unwrap() メソッドが実装され、fmt.Errorf の引数に指定したエラーに errors.Is などのメソッドでアクセスすることができます。

基本的には、Working with Errors in Go 1.13 のドキュメントに従って %v でラップを行い、実装の詳細がエラーを通じて不必要に公開されることを避けるようにしています。

Wrap an error to expose it to callers. Do not wrap an error when doing so would expose implementation details.

ただ、この原則はハイラムの法則が大事になってくるような不特定多数から使われるライブラリに適したものではあるので、他チームとコードを共有しない小さいサービスではここまで厳密にしなくてもよいかもしれません。

ログとメトリクス

ログについては、Go 1.21 で追加された構造化ログのためのパッケージ log/slog を利用しています。以前は構造化ログを出力するために third party のパッケージを導入する必要がありましたが、log/slog の登場によりその必要がなくなりました。log/slog を使った詳しい構造化ログの実装については、次の記事が参考になります。

メトリクスについては、Prometheus の公式クライアントパッケージを利用し、メトリクスを収集 (scrape) するためのエンドポイントを実装しています。

一方で、最近ではメトリクスの収集に OpenTelemetry を利用することもあるかと思います。OpenTelemetry を利用することで、モニタリングのバックエンドを変更する際にアプリケーションへの影響を最小限に抑えられるなどのメリットがあります。新しいインフラ基盤では Prometheus 互換の VictoriaMetrics をメトリクス収集のバックエンドとして全社横断で利用しており、バックエンドを変更する可能性が低いことを踏まえて OpenTelemetry を利用しないことにしました。

なお、執筆中に社内のメンバーから、VictoriaMetrics が OpenTelemetry 形式のメトリクスをネイティブにサポートしていることを教えていただきました。トレース収集の優先度が上がってきた場合には、トレースとメトリクスの実装に OpenTelemetry の利用を検討することになりそうです。

また、gRPC のサーバーの基本的なメトリクスやログについては、go-grpc-middleware を使って実装しています。

テスト

サービス内の各パッケージについては、テスト用のパッケージを作成してユニットテストを実装します。ユニットテストの実行時には、-race フラグ を利用してレースディテクションを有効化します

データベースを利用するものに関しては、並列実行を可能にするためにテストケースごとにデータベースを作成する方針で実装しました。次のような helper メソッドを作成し、各テストケースで利用します。細かいエラーハンドリングは、コードが長くなるので省略しています。

func SetupDatabase(t *testing.T) *sqlx.DB {
    t.Helper()
    db, _ := openDB("", os.Getenv("MYSQL_ROOT_USER"), os.Getenv("MYSQL_ROOT_PASSWORD"))

    dbName, user, password := "db_"+randomString(), "user_"+randomString(), randomString()
    setupQueries := fmt.Sprintf(`CREATE DATABASE %s;
          CREATE USER '%s'@'localhost' IDENTIFIED BY '%s';
          GRANT ALL PRIVILEGES ON %s.* TO '%s'@'localhost';`,
        dbName, user, password, dbName, user)
    db.Exec(setupQueries)

    dbForTest, _ := openDB(dbName, user, password)

    t.Cleanup(func() {
        defer db.Close()
        defer dbForTest.Close()

        cleanupQueries := fmt.Sprintf(`DROP DATABASE IF EXISTS %s;
                  DROP USER IF EXISTS '%s'@'localhost';`, dbName, user)
        db.Exec(cleanupQueries)
    })

    return dbForTest
}

func openDB(database, user, password string) (*sqlx.DB, error) {
    conf := mysql.NewConfig()
    conf.Addr = os.Getenv("MYSQL_HOST") + ":" + os.Getenv("MYSQL_PORT")
    conf.DBName = database
    conf.User = user
    conf.Passwd = password
    conf.MultiStatements = true
    return sqlx.Open("mysql", conf.FormatDSN())
}

// ランダムな 6 文字の英数字を生成
func randomString() string {
    b := make([]byte, 3)
    rand.Read(b)
    return hex.EncodeToString(b)
}

テストケースの数は 100 件を超えていますが、全てのテストを並列実行できるように設計したおかげで実行時間を短く抑えることができ、開発時のフィードバックループを短く保てています。

各サービスの E2E テストには、Kubernetes Operator でよく利用されている Ginkgo というフレームワークを採用しました。

Ginkgo は Behavior Driven Development (BDD) のアプローチを採用しており、DescribeContextIt などの構文を提供しています。これにより、Go で一般的な Table Driven Test と比べて、テストの目的や振る舞いをより直感的に表現することができます。

Ginkgo のドキュメントに記載されているテストコードの例を次に示します。JavaScript でテストを書いた経験のある人には馴染やすい形式になっていると思います。アサーションには、Gingo とよく一緒に使われる Gomega を利用します。

Describe("checking out a book", Ordered, func() {
    var libraryClient *library.Client
    var book *books.Book
    var err error

    BeforeAll(func() {
        libraryClient = library.NewClient()
        Expect(libraryClient.Connect()).To(Succeed())
    })

    It("can fetch a book from a library", func() {
        book, err = libraryClient.FetchByTitle("Les Miserables")
        Expect(err).NotTo(HaveOccurred())
        Expect(book.Title).To(Equal("Les Miserables"))
    })

    AfterAll(func() {
        Expect(libraryClient.Disconnect()).To(Succeed())
    })
})

また、E2E テストでは信頼性の高いテストを実現するため、外部サービスへのリクエストはフェイクサーバーに向けるようにしています。このフェイクサーバーは、実際の API の挙動をインメモリで再現したものです。外部サービスのほとんどは gRPC サーバーであり、proto ファイルを元にフェイクサーバーを実装することができるため、実装や管理コストについては現時点では問題にはなっていません。

CI

CI では、実行時間を短くするためにキャッシュを利用したいです。Go のビルドキャッシュなどを利用するには actions/setup-go が提供している機能を利用するのが一般的ですが、今回はこの方法を採用しませんでした。

actions/setup-go の機能を利用すると、トピックブランチでもキャッシュが生成されビルドキャッシュの容量(10GB)を逼迫します。そのため、せっかく保存したキャッシュを効率的に活用できない可能性があります。この問題については、次のブログでも詳しく説明されています。

そこで、actions/cacheを利用して、~/go/pkg/mod~/.cache/go-build~/.cache/golangci-lint をキャッシュする composite action を実装することにしました。この方法では、actions/setup-go では未対応である restore-keys を使用した柔軟なキャッシュのリストアも可能になります。

name: Save go cache
inputs:
  go_mod_path:
    description: "path to go.mod"
    required: true
  key:
    description: "cache key"
    required: true
runs:
  using: composite
  steps:
    - name: Save go module caches
      if: github.ref_name == 'main'
      uses: actions/cache/save@v4
      with:
        path: |
          ~/go/pkg/mod
        key: ${{ runner.os }}-go-modules-${{ inputs.key }}-${{ hashFiles(inputs.go_mod_path) }}
    - name: Save go build caches
      if: github.ref_name == 'main'
      uses: actions/cache/save@v4
      with:
        path: |
          ~/.cache/go-build
          ~/.cache/golangci-lint
        key: ${{ runner.os }}-go-build-${{ inputs.key }}-${{ github.sha }}
---
name: Restore go cache
inputs:
  go_mod_path:
    description: "path to go.mod"
    required: true
  key:
    description: "cache key"
    required: true
runs:
  using: composite
  steps:
    - name: Restore go module caches
      uses: actions/cache/restore@v4
      with:
        path: |
          ~/go/pkg/mod
        key: ${{ runner.os }}-go-modules-${{ inputs.key }}-${{ hashFiles(inputs.go_mod_path) }}
        restore-keys: ${{ runner.os }}-go-modules-${{ inputs.key }}-
    - name: Restore go build caches
      uses: actions/cache/restore@v4
      with:
        path: |
          ~/.cache/go-build
          ~/.cache/golangci-lint
        key: ${{ runner.os }}-go-build-${{ inputs.key }}-${{ github.sha }}
        restore-keys: ${{ runner.os }}-go-build-${{ inputs.key }}-

まとめ

今回の記事では、新しいサービスを Go で実装する際に意識した点について紹介しました。筆者は初めて Go を書いたのですが、標準ライブラリの手厚さやコンパイルの速さなどについてはとても気に入りました。

サイボウズ Office とメールワイズ の新しいインフラ基盤への移行はまだはじまったばかりなので、今後も移行で得られた知見について発信できたらと思います。もしこの記事を読んでサイボウズに興味を持った方がいれば、次のリンクからご応募お待ちしています!