dotHatch におけるアーキテクチャと運用のポイント
Zenn 読者の皆さん、こんにちは。今回は弊社で開発・運用している dotHatch のアーキテクチャ及び運用におけるポイントを紹介します。
フロントエンド
Next.js
フロントエンドには Next.js を採用しています。SSR(サーバサイドレンダリング)、得られる情報量・トレンドなどを鑑みて決定しました。
React を含めて学習コストがやや高いかなという印象はありますが、React-Hooks は Typescript がシンプルに記述ができるようになるためありがたいです。また、Next.js でバックエンドを作る場合、同じリポジトリで簡単に記述できるところも生産性が高いと感じています。
バックエンド
Golang
バックエンドは、以下の理由から Golang を選択しました。(個人の意見です)
- 個人的に慣れている
- シンプルに記述できる(可読性が高い)
- 比較的性能が良い
もちろん、Next.js のバックエンドで開発するでもよかったのですが(もしかしたらバックエンドも Next.js で作ってしまった方がトータルコストは比較的安くできたかもしれないですが)、上記の理由であったり最初にベースを開発する際に並行して作業に取り組んでいたなどの理由で、そうなっています。結果論ですが、エンジニアたちの「開発者体験」的にも Golang を採用してよかったなと感じています。
実際のサーバ環境 (Cloud Run) でも、早い起動・レスポンスで性能的に全く問題なく動いており、そこも言語として評価できるポイントだと感じています。
その他利用ツール
- PostgreSQL
- MySQL に比べて大きな理由は無いですが、個人的に慣れている、機能が比較的多い印象というところで採用しています。
- SQLite
- migration の検証やユニットテストの実行のために利用しています。
-
GORM
- ORマッパー
-
golang-migrate
- DBマイグレーションツールです。SQLを記述してリポジトリに登録することでDBリリースを行います。
-
golangci-lint
- リンター
-
swaggo
- OpenAPI定義自動生成
インフラ
dotHatch のバックエンドは、Google Cloud で動いています。AWS の方が社内の有識者は多かったのですが、私が個人的に Google Cloud の方が得意であり運用面でメリットが多いと考え、あえてそちらを選択しました。(個人の感想です)
Firebase の特定の操作等の API が提供されていない一部を除いて、すべて Terraform で構築・運用しています。
構成図
dotHatch は Google Cloud 上でコンテナアプリケーションを動かしているだけの非常にシンプルな構成です。
フロントエンドを Cloud Run で配信しているところはポイントですが、後述します。
Github Actions から Cloud Run にデプロイ
バックエンド・フロントエンドどちらもコンテナ化し、Cloud Run にデプロイしています。Go と Cloud Run との相性がいいのかバックエンドにおける性能問題は全く発生しておらず、コールドスタート時でも極めて優秀なレスポンスタイムを保っています。開発環境など通常時は起動コンテナ数を 0 にして運用しています。
その他利用サービス・プロダクト
-
Cloud SQL
- PostgreSQL を動かしています。常時起動しているので、コストが高いです。Serverless 形の DBaaS が Google Cloud にもあるとお財布に優しくなるのですが。
-
Cloud Storage
- 画像やファイルのアップロード保存先として利用しています。
-
Identity Platform (firebase)
- Google 認証やID・パスワード認証の IDaaS として利用しています。二要素認証にも対応しています。
-
Github Actions(通常の Hosted Runner)
- CI/CD を動かしています。ユニットテストやDBマイグレーションも実行しています。
運用における考慮点
実際に運用している中で改善したいくつかの活動を紹介します。
OpenAPI定義自動生成
swaggo というツールを使って、ソースコード上の annotation からAPI定義を自動生成する運用を行っています。API定義からソースコード自動生成するタイプのツールもあるようですが、メンテナンスするコードが複雑化したり、結局生成したあと手直ししたりする、というケースがあるようなので、コードの自動生成ツールは見送りました。(個人の意見です)
このツールを導入後、API定義の運用が効率的になったと感じています。
サンプルコード、swaggo の公式ページより引用
// ShowAccount godoc
// @Summary Show an account
// @Description get string by ID
// @Tags accounts
// @Accept json
// @Produce json
// @Param id path int true "Account ID"
// @Success 200 {object} model.Account
// @Failure 400 {object} httputil.HTTPError
// @Failure 404 {object} httputil.HTTPError
// @Failure 500 {object} httputil.HTTPError
// @Router /accounts/{id} [get]
func (c *Controller) ShowAccount(ctx *gin.Context) {
id := ctx.Param("id")
aid, err := strconv.Atoi(id)
if err != nil {
httputil.NewError(ctx, http.StatusBadRequest, err)
return
}
account, err := model.AccountOne(aid)
if err != nil {
httputil.NewError(ctx, http.StatusNotFound, err)
return
}
ctx.JSON(http.StatusOK, account)
}
またローカルでバックエンドサーバを起動すると同時にAPI定義をそのサーバ上で参照できるようにしたのも良い選択だったと考えています。
フロントエンドをCloud Run で実行
先述の通り、Next.js で書かれたフロントエンドをコンテナ化し、Cloud Run にデプロイしています。フロントエンドのみであれば、配信用 Web サーバで十分です。ただ、それはそれでバックエンドとは別にフロントエンド用のデプロイ方式を設計・構築・運用する必要がありますし、それによってどのくらいの費用対効果が得られるのか、が不明確でした。このため、フロントエンドも Cloud Run にデプロイすることにしました。公式のDockerfile にもあるように、コンテナ化は非常に簡単に実装できますし、何よりもバックエンドとほぼ同じやり方で運用することができます。
Cloud Run を使うことによって、Node.js バックエンドがそのまま開発できるというメリットもあります。当初は Next.js でバックエンドを実装する予定はなかったのですが、一部の Firebase SDK を利用する API を Next.js のバックエンドとして実装しています。(一部の SDK が Go に対応していないため)
VPC 内の DB に対するマイグレーション
昨今、DBのマイグレーション運用は一般に多く運用されているかと思います。一方で、肝心のDBサーバはクローズドな環境、VPC に配置することも多いでしょう。その場合 Github Actions の通常環境(GithubがホストするRunner)からは直接接続できないため Self-Hosted Runner をVPC内の環境で動かすことが多いのではないかと思います。
dotHatch では、踏み台サーバ(Compute Engine) にSSHポートフォワーディングで接続することで、Github ホストの Runner を使いつつ、VPC内の Cloud SQL へのマイグレーションを実現しています。具体的には、Cloud SQL Auth Proxy を動かしておいて、そのポートフォワーディングしておきます。
踏み台サーバにはDB接続以外では全く利用していないので、利用時のみ起動するような運用にしたいところです。IAMプロキシを使って踏み台サーバに接続するため、Cloud Workload Identity を使っていれば認証情報を Github 側で持つ必要はなく非常にセキュアな運用を実現できます。
なお、Cloud Workload Identity についてもう少し知りたい方は、こちらも併せてご参照ください。(AWS の Web Identity の紹介ですが、全く同じ機能なので参考になると思います)
SQLite を利用した自動テスト・テストコード自動生成
バックエンドでは、ユニットテストを開発ライフサイクルの中で自動実行しています。PostgreSQL を利用する Repository 層のコードについては、当初DBの動きを模擬するフェイクの実装を別で用意してテストを行っていました。開発が進んでくるとフェイクのメンテナンスのコストも上がってきたのでフェイクをメンテしなくてもよい方法として、SQLite を導入しました。各テストコードの最初のステップで、SQLite を初期化するコードを記述し、DB接続ではそのインスタンスに接続するような形でアプリケーションを初期化します。
SQLite のインスタンス・データはメモリ上にのみデプロイされ、各ケースが終了したらインスタンスは開放されるように実行できるため、環境が汚れることも後片付けもありません。ORマッパーの GORM が PostgreSQL と SQLite の差分を吸収してくれるので、フェイクの開発もゼロになりました。ただしマイグレーションは生の SQL によって実装しており PostgreSQL しか対応していない記述もあるため、そこは現状ダブルメンテの必要があります。それを上回るメリットを享受していますので、導入は良かったと感じています。SQLite の導入時の苦労や運用については、別で記事を書きたいと思います。
またユニットテストケース作成もコストが掛かるようになってきたため、こちらもテストコード自動生成によって改善を行いました。(テストケース自動生成ではありません。テストコード自動生成です)ユーザはテストケースをスプレッドシートに記述し、シートのメニューからツールを実行します。すると、GAS が動いてスプレッドシートの情報を元にテストコードを自動生成します。自動生成されるとそのままユーザのPCにダウンロードされるため、それをローカルリポジトリに配置してテストを実行することができます。
tt.Run(
"400: バリデーションエラー",
func(t *testing.T) {
t.Parallel()
userId := ""
user := model.User{
ID: uuid.Nil,
}
e := echo.New()
var (
rec *httptest.ResponseRecorder
req *http.Request
c echo.Context
)
rec = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/api/users/", nil)
c = e.NewContext(req, rec)
c.SetPath("/api/users/:id")
c.SetParamNames("id")
c.SetParamValues(userId)
u := new(mocks.UserUseCase)
u.On("GetUser", userId).Return(user, nil)
h := handler.NewUserHandler(u)
err := h.GetUser(c, false)
if assert.Error(t, err) {
assert.EqualError(t, err, "code=400, message=Invalid Request")
}
},
)
まとめ
dotHatch のアーキテクチャや利用しているソフトウェア、及び運用のポイントについて紹介しました。最初から上記すべてを導入していたわけではなく、開発を進めながら地道に運用改善を繰り返して来た結果、現在の構成になっています。
次のチャレンジとしては以下あたりを想定しています。
- バックエンドも Next.js で記述する
- 運用やソースコード量が圧縮される(特に型共有の観点)ので、効率的な開発ができるかもしれません。
- GORM でマイグレーション
- golang-migrate は SQL の記述になるので、SQL 仕様の差分は吸収できません。GORM でマイグレーションまで実行することで、マイグレーションのコードも一本化できるはず、です。
- バックエンドAPIソースコードの自動生成
- 業務ロジックの少ない基本的な GRUD 操作を行う処理は、かなり記述が効率化できると思います。
なにかアップデートがあれば随時更新していきたいと思います。
Discussion