こんにちは。弁護士ドットコム クラウドサイン事業本部 Product Engineering 部の須山と申します。CloudSign はサービスを開始してから約 7 年が経過しています。その間数多くの機能追加・拡張を続けている中で技術的な負債を残していくことは、どの企業でもよくある話ではないでしょうか。そんな中 CloudSign では技術的負債を解消することを主な目的としたチームを 2 年程前から結成して、日々改善活動を進めています(それ以外にも、新機能の技術検証や基盤開発も担当しています)
今回はチーム発足時から活動しているモノリシックアプリケーションの分割に関してやってきたことをまとめました。これまでの活動を大きく 3 つの段階に分けて紹介します。
その1. モノリシックなアプリケーションの分割
いきなりマイクロサービス化といった話ではなく、まずは以下の点に着目し複雑な状況をシンプルにしていく改善をしていきました。
- ローカル環境とそれ以外の環境での差異を解消
- 一枚岩となっているアプリケーションを実行環境ごとに分割
ローカル環境とそれ以外の環境での差異
当初 CloudSign のユーザー提供部分のサービス(以下メインサービスという)は Go 言語を使用していることもあり、バイナリを AWS EC2 上に配置して動作していました。しかしローカル環境では Docker を使ったコンテナ環境を使用していたため、環境の差異が発生してました。
すぐに大きな問題が発生するわけではありませんでしたが、EC2 の管理をしなければなりません。またコンテナ化することでゆくゆくは Kubernetes 化といった選択肢も取れるためまずはこの差異解消を進めました。
アプリケーションを実行環境ごとに分割
上述のアプリケーションのバイナリの中にはメインサービス以外にも定期実行しているバッチや常時実行されるワーカーも含まれていました。当然バッチもローカル環境との差異もありますし、とあるバッチを改修した場合別のバッチもすべてビルドされるといった無駄を含んだ構成となっていました。
またモデルやリポジトリ、コンポーネントといったものがメインサービスへ強く依存していました。他にも環境変数を 1 つの .env ファイルで共有している点も整理する必要がありました。以下に現状のアプリケーションの構成と課題点をまとめました。
環境ごとのインフラ構成の差異はコンテナベースに合わせていきました。アプリケーションが一枚岩状態になっているのも、コード上で分離しそれぞれ別々に CI/CD を運用できる状態へと改善していきました。モデルやリポジトリ、コンポーネントなどは共通パッケージへ外出しをしました(弊社では core パッケージとしています)。将来的にはサービス分割が進むに連れサービス内へと core パッケージの移動も考えられますが現状の密結合を回避するためにこのような方針で進めました。環境変数に関してはアプリケーションごとにファイルを分割しました。
アプリケーションのコンテナ化
問題点をある程度整理しました。実際の取り組みとしてはメインサービスやバッチ・ワーカーをコンテナ化していきました。インフラ技術としては主に ECS を利用しています。理由としましてはすでに新規のバッチやワーカーは ECS で構築しており、社内に知見がすでにあるのと Terraform といった資源もあるためです。
そして、バッチ・ワーカーがすでに 40 近くあり終わりの見えない作業となっていましたが、コンテナ化を進める中でいくつか課題点が見えてきました。
- ローカル環境で AWS リソースを使用する際に個々でリソースを生成・使用している
- ユニットテストで AWS などの外部サービスと密結合なテストが行われている
インフラストラクチャ目線でのアプリケーションの分割はこの段階で徐々に進んできましたが、コードベースでは上記のような点で密結合な箇所がまだまだあるといった状態です。今度はこういったシステム間の疎結合化に着目していきました。
その2. システムの疎結合化
ユニットテストやローカル環境で外部サービスと密結合となっている箇所の大半は AWS リソースでしたのでそこを中心に疎結合化を進めました。主に SQS、SES、S3 の利用箇所を対応しました。
外部サービスとの依存を減らし、ユニットテスト・ローカル環境を改善
ここからは各 AWS リソースごとにどのように疎結合化していったのかを紹介します。基本的に実行環境ごとに実装を切り替えられるよう抽象化し、ユニットテストではモック化、ローカル環境ではエミュレータに代替するような方針で進めました。
SQS から NATS へ
ローカル環境ではもともと AWS SQS は個々にリソースを作成して使用していました。ユニットテストもし辛いため、ローカル環境・ユニットテストでは NATS を利用するように変更してきました。
NATS とは Cloud Native な高性能分散メッセージングシステムです。Go クライアント もあり、既存システムを大きく変更させずに組み込めたのでこちらを採用しました。公式ページで他の類似ミドルウェアとの比較 もあるので選択時には参考になると思います。また NATS は最大で 1 回(at most once)のメッセージ配信を保証しており、サブスクライバーがまだ立ち上がっていない場合や、その他の理由でメッセージが届かない場合があります。なので弊社では最低でも 1 回(at least once)のメッセージ配信を保証して NATS JetStream を採用しています。
実装の抽象化
NATS への切り替えだけに限らずですが、基本的に環境などで実装を切り替える際には実装の抽象化をしています。抽象化にはオーソドックスに interface を定義して interface の実装を満たす struct をそれぞれ用意しています。ここでは Queue
インタフェースを定義し、NATSQueue
構造体や SQSQueue
構造体、FIFOQueue
構造体といった struct を用意しています。例として以下のような具合です。ユニットテスト時には testify などを使用してモック化しています。
import ( "github.com/nats-io/nats.go" ) // Queue インタフェース type Queue interface { Enqueue(body string, groupID string) (string, error) Dequeue(reply string) error Receive() ([]*Message, error) } // Queue のメッセージを表す構造体 type Message struct { MessageID string Body string ReceiptHandle string } type NATSQueue struct { // NATS のクライアントなどを保持する client *NATSClient } type NATSClient struct { innerCon *nats.Conn } // NATSQueue でのエンキュー処理 func (q *NATSQueue) Enqueue(body, _ string) (string, error) { // ここに実装を書く } // NATSQueue でのデキュー処理 func (q *NATSQueue) Dequeue(reply string) error { // ここに実装を書く } // NATSQueue でのレシーブ処理 func (q *NATSQueue) Receive() ([]*Message, error) { // ここに実装を書く } // 同様に SQSQueue、FIFOQueue といった実装を用意する
SES から MailHog へ
CloudSign には、ユーザー登録時や書類の送信時などにメールを送信する機能があります。従来は、どの環境でも SES を使って実際にメールを送信していました。これですとメールの誤送信の危険もあるのでテスト用のメールサーバーを立てるような形式にしました。
テスト用メールサーバーもいろいろありますが MailHog を採用しました。理由としましては Dockerfile が提供されていて簡単に組み込み可能な点とメールの永続化も可能なためです。
S3 から MinIO へ
従来、書類(pdf ファイル)や他のファイル(CSV など)はローカル環境では S3 であったりローカルストレージを使用しています。特にローカルストレージの場合はモノリシックな場合ストレージを共有できていましたがアプリケーションを分割することで共有はできなくなりました。
外部サービス・ローカルストレージ以外の選択肢として、ローカルでも使用できるストレージサーバーとして MinIO を使用しています。MinIO は S3 と互換性のあるオブジェクトストレージサーバーです。MinIO の使いやすい点として S3 互換のため AWS SDK for Go が使えるため従来の S3 を使った実装を流用でき、エンドポイントを切り替えることで使い分けが可能です。
このように各種リソースに対してそれぞれ別々のエミュレータを用意するような対応を取りました。しかし LocalStack といった 1 つのツールで代替できるほうが便利だねという話もあり、今後はこちらに置き換えていこうとも考えています。
ここまで、大まかなハード・ソフトウェア両面でのモノリシックなアプリケーションの分割を進めてきました。ここからはサービスとしてアプリケーションを分割していく段階に入ります。
その3. システムをサービス分割
ある程度現状のアプリケーションを整理した段階で、今度はシステムを責務ごとに分解していきました。分解するにあたって、今後新しくサービスを作るといった場合でも参考になるような取り組みをいくつか実施しました。
Goa を使用してアプリケーションのひな形を作成
サービスの分割はメインサービスの中から責務ごとに処理を API として切り出しています。今後も同じような状況で API として切り出すケースは往々にしてあると思っているので、横展開可能な技術選定をしました。フレームワークとしては Goa を採用しています。弊社はもともと Go 言語を採用しておりその中で Goa を選択しました。Goa は大きな特徴として独自 DSL を宣言することでルーティングやリクエスト・レスポンス構造体、クライアント、CLI ツール、OpenAPI 仕様の API 仕様書といったものをすべてコマンド生成してくれます。これらはすべて HTTP、gRPC に対応したものを生成してくれます。DSL の学習コストはかかりますが、API のリクエストハンドリング部分を自動生成してくれるのでビジネスロジックの記述に専念でき開発の高速化を見込めます。またクライアント側も Go 製であれば生成されたクライアントコードを利用できるのでわざわざコードを記述する手間もなくなります。
サービス階層の構成
Goa はクリーン・アーキテクチャ・パターンを考慮した設計がされています。階層は以下で形成されています(公式の資料 の真ん中あたりにわかりやすい図が記載されています)。
- トランスポート階層
- リクエスト・レスポンスのエンコード・デコードしてその値を検証する階層
- エンドポイント階層
- トランスポート階層とサービス階層をつなぐ階層で HTTP と gRPC の別々のルールを共通のシグネチャで表現して差異のない形でサービス階層へ受け渡す階層
- サービス階層
- ビジネスロジックを記述する階層
またミドルウェアに関してはトランスポート階層、エンドポイント階層の両方でカスタマイズが可能です。
ここまでは Goa を使用する際の共通の階層となります。弊社ではサービス階層をテスタブルな形を保つため以下の階層にしています。
- ドメイン階層
- エンティティや値オブジェクト、インフラストラクチャで実装するリポジトリのインタフェースを管理する階層
- ユースケース階層
- Goa のエンドポイント階層とドメイン階層やインフラストラクチャ階層の橋渡しやロジックのバリデーションをする階層
- インフラストラクチャ階層
- データベースや API、外部サービスといったアプリケーションの外にあるリソースへのアクセスを担う階層
ディレクトリ構成としては以下となっています。
. ├── cmd ├── design // DSL │ └── design.go ├── domain // ドメイン階層 │ ├── models │ └── repointf ├── gen // トランスポート階層、エンドポイント階層(Goaの自動生成部分) │ ├── service │ ├── grpc │ └── http ├── infra // インフラストラクチャ階層 ├── middleware └── usecases // ユースケース階層
各層を単体で実行できるようにし、上位の層から下位の層を利用する際には下位をモック化することで異常ケースのテストなども簡単に実施できるようにするためこういった形式としました。
Datadog APM でログのトレーサビリティを担保
サービスを分割するうえでログのトレーサビリティは必ず考慮する点かと思います。弊社ではまだそこまで多くのサービスはありませんが、調査する際に各サービスのログを個別に追うのは結構な手間です。こういった課題の解消に OpenTelemetry の規格に対応した製品はいくつかありますが、弊社では Datadog APM を利用しています。
Datadog APM にはいろいろな言語の SDK が提供されており、Go 言語もあります。SDK を組み込むことで Datadog の画面上で分散トレーシングやサービスマッピングの可視化、ダッシュボードの生成・プロファイラでの結果の可視化などを簡単に組み込めます。以下では簡単にトレーシングの使い方を紹介します。
導入方法は Datadog のドキュメントにあります。Kubernetes や ECS といったインフラに Datadog Agent を導入する手順や言語ごとのトレーサーの起動方法が記載されています。
スパンの生成
導入が完了した状態で関数内に以下のようにスパンを設定すると関数内での処理時間を可視化できます。
スパンの生成(基本形)
import ( "gopkg.in/DataDog/dd-trace-go.v1/ddtrace/tracer" ) func Create() { // 生成された ctx にはスパンの情報が含まれます。なので、引数に context を設定するとその子となるスパンを作成します。 span, ctx := tracer.StartSpanFromContext(context.Background(), "operationName") defer span.Finish() ... }
各エンドポイントに設定していくやり方もいいですが、全リクエストの処理時間を可視化したい場合はミドルウェアで設定するのもいいと考えます。その場合公式の GitHub にすでに用意されているものが使えます。例えば、gin や chi といったフレームワークやデータベース系のミドルウェアが dd-trace-go/contrib
として公式で用意されています。tracer のタグやリソースの設定などが参考になるので組み込み時は見てみるといいと考えます。
またユニットテスト時には mocktracer を使用することで擬似的に span を生成したテストも可能です。
サービス間でのスパンの連携
サービス分割後にログのトレーサビリティを下げないためには、A サービスから B サービスをリクエストしたといった一連の操作ログが可視化できていないと調査が大変です。スパンは HTTP やメッセージングなどの通信間で情報を連携できます。これによってサービスの操作を一度にまとめて可視化が可能です。
HTTP リクエストの場合、クライアント / サービスそれぞれで以下のような実装で連携できます。
HTTP リクエスト時のスパン連携(クライアント側)
import ( "net/http" "gopkg.in/DataDog/dd-trace-go.v1/ddtrace/tracer" ) func handler(w http.ResponseWriter, r *http.Request) { span, ctx := tracer.StartSpanFromContext(r.Context(), "operationName") defer span.Finish() req, _ := http.NewRequestWithContext(ctx, "GET", "http://127.0.0.1:8080", nil) if err := tracer.Inject(span.Context(), tracer.HTTPHeadersCarrier(req.Header)); err != nil { // エラー処理 } http.DefaultClient.Do(req) }
HTTP リクエスト時のスパン連携(サーバー側)
import ( "net/http" "gopkg.in/DataDog/dd-trace-go.v1/ddtrace/tracer" ) func handler(w http.ResponseWriter, r *http.Request) { spanctx, err := tracer.Extract(tracer.HTTPHeadersCarrier(r.Header)) if err != nil { // エラー処理 } span := tracer.StartSpan("operationName", tracer.ChildOf(spanctx)) defer span.Finish() ... }
上記の例ではスパン情報の伝搬方法(キャリア)としてリクエストヘッダーを用いるHTTPHeadersCarrier
を使用しています。
キューのメッセージングなどでスパン情報を伝搬する場合はメッセージ内に情報を入れてやり取りする方法の TextMapCarrier
をとっています。
メッセージ送信時のスパン連携(パブリッシャー側)
import ( "gopkg.in/DataDog/dd-trace-go.v1/ddtrace/tracer" ) func publish() { span := tracer.StartSpan("operationName") defer span.Finish() traceID := span.Context().TraceID() spanID := span.Context().SpanID() // メッセージに traceID, spanID を設定してパブリッシュする ... }
HTTP リクエスト時のスパン連携(サブスクライバー側)
import ( "gopkg.in/DataDog/dd-trace-go.v1/ddtrace/tracer" ) func subscribe() { // メッセージをサブスクライブしてメッセージの配列 resp を取得した状況 for _, msg := range resp { spanctx, err := tracer.Extract(tracer.TextMapCarrier(map[string]string{ tracer.DefaultTraceIDHeader: "TraceID", // msg から TraceID を取得して設定 tracer.DefaultParentIDHeader: "SpanID", // msg から SpanID を取得して設定 })) if err != nil { // エラー処理 } span := tracer.StartSpan("operationName", tracer.ChildOf(spanctx)) defer span.Finish() ... } }
このような設定で基本的なサービス間のスパンの連携が可能となります。
サービス分割の拡大化
他のチームへの横展開がしやすく、運用面でのトラブルが起こりにくいように配慮しながら、サービスを分割してきました。また古くなってきたシステムに関しては設計から見直して運用面や性能面の向上を目指して取り組んでいます。
最近は書類の検索機能のリプレイスも現在のチームで取り組み、無事にリリースできました(リリースノート でも共有させていただきました)。こちらに関しては、お客様からも好評のご意見をいただけたようで大変嬉しく思っています。検索機能の速度改善についても、別の機会にご紹介できたらと思います。
まとめ
弊社でのモノリシックなアプリケーションに対して開発効率を改善する取り組みを記載しました。以下に、簡単にこれまでの取り組みを振り返っていきます。
- モノリシックなアプリケーションをまずはサービス分割といった責務単位ではなくもっと大きな粒度で整理しました
- ローカル環境とそれ以外の環境での差異を整理しすべてコンテナベースに切り替えました
- ジョブやワーカーといった処理をアプリケーションから切り出し、実行単位でアプリケーションが独立するように整理しました
- 外部サービスとの密結合な作りを疎結合化していきました
- ローカル環境での不要な外部サービス利用を減らし、NATS、MailHog、MinIO といったサービスを使って実行環境ごとに切り替えられる構成としました
- 外部サービスを使用しないことでテスタブルなコードへしていきました
- モノリシックなアプリケーションを責務単位で切り出し、横展開化を検討しながらモデルケースを目指す取り組みました
- Goa を用いてコード生成で開発の省力化、ビジネスロジック開発へ専念できる状態を目指しました
- 分散トレーシングには Datadog APM を利用してログを可視化し、サービスを切り出しても調査可能な状況を目指しました
まだまだ改善していくべき点は残っており、今後もこれらの対応を振り替えつつより良いものへとアプリケーションを育てていければと思っています。