CacooはなぜKubernetesによるmicroservicesへの道を選んだのか?

こんにちは。Cacoo チームの木村(@cohhei)です。Cacoo チームでは、 Kubernetes によるアーキテクチャの microservices 化に取り組んでいます。今回は私たち Cacoo チームが microservices 化によって解決しようとしている課題と取り組みの内容、その成果についてご紹介します。

この記事では以下の内容を含みます。

  • Cacoo の開発チームがどんな課題を抱えていたか
  • 何故 microservices の道を選んだか
  • どんな技術を選んだか
  • microservices 化してどうだったか
  • 現状の課題

課題:古いフレームワークとモノリシックなアプリケーション

Cacoo は2009年にベータ版がリリースされた歴史のあるプロダクトで、モノリシックなアプリケーション上ですべての機能が実行されていました。

そのため、それぞれのコードの依存関係を十分に理解した上で開発を行う必要がありました。またフレームワークやライブラリの変更による影響範囲が大きく、簡単に入れ替えることが難しい状態でした。他にも長いビルド時間が開発スピードに影響するなどの問題が発生していました。

メンテナンスが終了したフレームワークやライブラリ

Cacoo では Seasar2 という Web フレームワークを使っていたのですが、すでに開発が終了しているものでした。

できる限り早く別のフレームワークに移行したいのですが、モノリシックなアプリケーションのフレームワークを入れ替えるには、ほとんどすべてのコードを変更する必要があります。

また、フレームワークのドキュメントがほぼ日本語という問題もありました。 Cacoo の開発チームは福岡本社だけでなくニューヨークにもメンバーが在籍しています。機械翻訳などで対応することも可能だったようですが、日本語のドキュメントはニューヨークのメンバーにとって大きな負担となっていました。

モノリシックなアプリケーション

アプリケーションは多数の機能を持ち、そこには強い依存関係がありました。Web サイトと Cacoo アプリケーションがその最たる例です。

ここで言う Web サイトとは Cacoo の静的な Web ページ群です。例えば https://cacoo.com/features などがそうです。

一方で Cacoo アプリケーションとは実際に図を編集する Cacoo の Web アプリケーションです。Web サイトがアプリケーションに依存していました。そのため、サイトを更新するためにはアプリケーションをビルドしてデプロイする必要がありました。

また、新しい技術をとりいれることが難しいという問題もあります。ライブラリの変更が全体に影響するため、気軽に変更しづらくなります。開発者が自分の得意な言語、フレームワーク、ライブラリを独自に選択することは基本的にできませんでした。

コードが大きいため、ビルド、テスト、デプロイに時間がかかっていました。

また、異なる拠点のメンバーがひとつのリポジトリに対して変更を加えるため、コミュニケーションコストも次第に大きくなります。新しいメンバーがキャッチアップするのにも時間がかかります。これらの問題は開発スピードに悪い影響を与えていました。

microservices のメリット

そこで、 microservices 化する道を選びました。一般的に、 microservices には以下のようなメリットがあると考えています。

  • サービスごとに技術を選択できる
  • 部分的な変更が容易になる
  • (うまく設計できれば)サーバーリソースを最適化できる
  • (うまく設計できれば)堅牢なシステムになる

サービスごとに技術を選択できる

サービスごとに独立したアプリケーションとなるため、仕様さえ満たしていればどんな言語やフレームワークを仕様していても問題ありません。原則として開発者が自律的に技術を選択することができます。

部分的な変更が容易になる

インターフェースさえ満たしていれば内部のロジックを変更しても、殆どの場合問題ありません。異なる拠点間で開発を進める場合、このメリットは大きくなります。コードの変更が与える影響が限定的なため、事前に影響範囲を確認するコストが小さくなります。

サーバーリソースを最適化できる

うまくサービスを分割することができれば、ひとつのインスタンスですべてが動いている場合と比べてサーバーリソースを最適化することができます。

例えば、あるタスクのキューが溜まってサーバーリソースを普段より多く使用しているとします。単一のインスタンスの場合はそのインスタンスをまるごとスケールさせる必要があります。

一方でそのタスクのワーカーを別のサービスとして分割していれば、負荷の高いワーカーの処理だけをスケールさせることも可能です。

堅牢なシステムになる

先程のワーカーの例で、ワーカーが捌ききれないほどタスクがあるとします。

単一のインスタンスですべてが動作している場合、ワーカーがリソースを食いつぶしてしまい、すべてのアプリケーションがインスタンスごと落ちてしまう恐れがあります。

サービスをうまく分割できれば、ひとつのワーカーが落ちても影響範囲を限定し、システム全体が落ちるという最悪の事態へのリスクを軽減させられます。

目標:変更を容易に

アーキテクチャーの変更における目標は古いフレームワークやライブラリへの依存をなくし、アプリケーションを microservices に分割することです。そうすることで部分的な変更を容易にし、開発効率を向上させるねらいです。

  • 古いフレームワーク、ライブラリへの依存をなくす
  • microservices 化を進める
    • 複数のサービスとDBに分割
    • バックエンドだけでなくフロントエンドも分割
    • それぞれのサービスが異なる技術を選択できる状態

方針・原則

microservices 化を進める上で、以下のことを方針として開発を進めています。

  • 明確なオーナーシップ
  • 小さいほど良い
  • スクラッチから書きなおしてみる
  • サービス間のインターフェースは強い型付け
  • 開発環境はサービスごとに自由に選択
  • ベストプラクティスに従う

明確なオーナーシップ

どのサービスが誰が責任を持って開発するかを明確にしています。

小さいほど良い

小さく作ることで新しいメンバーがキャッチアップしやすくします。

スクラッチから書きなおしてみる

より適した技術、自分の得意な技術を使ってより良いものをつくるために、既存のコードを再利用するのではなくゼロから書き直すことを推奨します。

サービス間のインターフェースは強い型付け

サービス間でやりとりするデータはスキーマが固定されています。

開発環境はサービスごとに自由に選択

開発者は、自分の得意な技術や好きな技術をある程度裁量を持って選ぶことができます。

ベストプラクティスに従う

プロダクトの歴史的経緯ではなく、一般的なベストプラクティスに従います。例えば The Twelve FactorsGo Code Review CommentsSemantic Versioning などがそうです。

採用した技術

microservices 化する上で多くの技術を新たにとりいれました。以下はその主な例です。

  • Backend/Middleware
    • Kubernetes
    • Protocol Buffers
    • gRPC
    • RabbitMQ
    • golang
  • Monitoring/Alerting
    • Elasticsearch + Kibana
    • Prometheus + Grafana
    • Zipkin

構成

Cacoo の開発チームは福岡、ニューヨーク、アムステルダムの3つの拠点に分かれています。

福岡は Cacoo のエディター画面の機能、ニューヨークとアムステルダムは図の一覧(ダッシュボード)画面の機能、というふうにざっくりと別れています。

ダッシュボード側のフロントエンドは vue.js で、 GraphQL でリクエストを出します。

GraphQL サーバーは envoy を使って gRPC サービス群にアクセスします。gRPC サービスは役割ごとにDBの入出力を行ったり RabbitMQ にイベントやタスクを出したりします。イベントやタスクを受け取るワーカーが複数あり、通知用のDBに流したりメールや Webhook で通知を出したりします。

エディター側はモノリシックなアプリケーションへの依存が比較的多く残っており、エディターのフロントエンドもここに内包されています。

Cacoo 開発チームではこれをレガシーアプリと呼んでいます。これまでレガシーアプリが行っていたエディター関連の処理の多くは editor-api に移行しています。

多くのサービスは Kubernetes 上で動作していますが、すべてを移行できたわけではありません。

Kubernetes

Kubernetes はコンテナオーケストレーションの分野でデファクト・スタンダードになっていますが、はじめからすべてを Kubernetes に移行したわけではありません。

まず前段階としてアプリケーションをコンテナ化して ECS 上で運用することでノウハウを溜め、その後少しずつ Kubernetes に移行していきました。ECS と比較してデプロイの方法やモニタリングの面でより柔軟に使えるというのが理由です。

現在、本番用と開発用で2つの Kubernetes クラスタがあります。さらに namespaces を使って本番用はプロダクションとベータの2つの環境に、開発用はテストの用途ごとに3つの環境に区切られています。別の環境を作りやすいのも Kubernetes を使うメリットです。

また、Horizontal Pod Autoscalerで Pod のオートスケールを、Cluster Autoscalerで Kubernetes クラスタが動作するホストのオートスケールを行っています。

Protocol Buffers

サービス間のインターフェースの多くは Protocol Buffers で定義しています。 Protocol Buffers を使うには .proto ファイルで型を定義し、protoc コマンドでコードを生成します。

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
message Person {
required string name = 1;
required int32 id = 2;
optional string email = 3;
}
message Person { required string name = 1; required int32 id = 2; optional string email = 3; }
message Person {
  required string name = 1;
  required int32 id = 2;
  optional string email = 3;
} 

それぞれのフィールドにはタグナンバーと呼ばれる数値が割り振られていて、順序は明確に定義されます。.proto ファイルの定義から生成されたコードを使うことでサービス間でやり取りするデータの型を強く制限することができます。

gRPC

gRPC は RPC(Remote Procedure Call) のフレームワークで、 Protocol Buffers で定義したインターフェースを使ってコードを生成します。

例えば、以下のような .proto ファイルがあるとします。

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
// The greeting service definition.
service Greeter {
// Sends a greeting
rpc SayHello (HelloRequest) returns (HelloReply) {}
}
// The request message containing the user's name.
message HelloRequest {
string name = 1;
}
// The response message containing the greetings
message HelloReply {
string message = 1;
}
// The greeting service definition. service Greeter { // Sends a greeting rpc SayHello (HelloRequest) returns (HelloReply) {} } // The request message containing the user's name. message HelloRequest { string name = 1; } // The response message containing the greetings message HelloReply { string message = 1; }
// The greeting service definition.
service Greeter {
  // Sends a greeting
  rpc SayHello (HelloRequest) returns (HelloReply) {}
}

// The request message containing the user's name.
message HelloRequest {
  string name = 1;
}

// The response message containing the greetings
message HelloReply {
  string message = 1;
}

このファイルを使って Go のコードを生成すると SayHello を定義した interface と、それを実装した struct が生成されます。

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
type GreeterClient interface {
// Sends a greeting
SayHello(ctx context.Context, in *HelloRequest, opts ...grpc.CallOption) (*HelloReply, error)
}
type greeterClient struct {
cc *grpc.ClientConn
}
func NewGreeterClient(cc *grpc.ClientConn) GreeterClient {
return &greeterClient{cc}
}
func (c *greeterClient) SayHello(ctx context.Context, in *HelloRequest, opts ...grpc.CallOption) (*HelloReply, error) {
out := new(HelloReply)
err := c.cc.Invoke(ctx, "/helloworld.Greeter/SayHello", in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
type GreeterClient interface { // Sends a greeting SayHello(ctx context.Context, in *HelloRequest, opts ...grpc.CallOption) (*HelloReply, error) } type greeterClient struct { cc *grpc.ClientConn } func NewGreeterClient(cc *grpc.ClientConn) GreeterClient { return &greeterClient{cc} } func (c *greeterClient) SayHello(ctx context.Context, in *HelloRequest, opts ...grpc.CallOption) (*HelloReply, error) { out := new(HelloReply) err := c.cc.Invoke(ctx, "/helloworld.Greeter/SayHello", in, out, opts...) if err != nil { return nil, err } return out, nil }
type GreeterClient interface {
	// Sends a greeting
	SayHello(ctx context.Context, in *HelloRequest, opts ...grpc.CallOption) (*HelloReply, error)
}

type greeterClient struct {
	cc *grpc.ClientConn
}

func NewGreeterClient(cc *grpc.ClientConn) GreeterClient {
	return &greeterClient{cc}
}

func (c *greeterClient) SayHello(ctx context.Context, in *HelloRequest, opts ...grpc.CallOption) (*HelloReply, error) {
	out := new(HelloReply)
	err := c.cc.Invoke(ctx, "/helloworld.Greeter/SayHello", in, out, opts...)
	if err != nil {
		return nil, err
	}
	return out, nil
}

Go 以外にも複数の言語に対応しており、 .proto ファイルを共有するだけで異なる言語を使って実装されたサービス間でもデータをやりとりできます。

Cacoo では、「アカウント」や「フォルダー」など DB のテーブルをグループ分けし、担当するテーブルに対してのみ読み書きを行う gRPC サービスを複数構築しています。

例えば、アカウントサービスはアカウントに関連するテーブルのみ変更を行うサービスで、アカウント情報の取得や更新を行うメソッドを持っています。他のサービスは gRPC でそれらのメソッドにアクセスすることができます。

RabbitMQ

microservices 化においてメッセージングの機能は不可欠です。メッセージングを介して複数のサービスが非同期に処理することができます。

また、サービス間の依存を小さくすることも可能です。新しいアーキテクチャに移行する上で、新しいサービスがレガシーなアプリケーションに依存することも多くなります。そのときに RabbitMQ のようなメッセージングを経由することで依存を小さくすることができます。

RabbitMQ はチュートリアルにあるように、キューやPub/Sub、RPCなど複数の機能に対応しており、 Cacoo チームのユースケースに最も適していると判断し、採用しました。

Go

Cacoo では、バックエンドのサービスのほとんどで Go が採用されています。Go はコンパイルが早く実行環境が小さくて済むため、デプロイも高速です。コンテナ化されたインフラと相性がいいため、Cacoo の多くのサービスが Go で書かれています。

もちろんライブラリや言語の特性などの理由から他の言語が採用されることもあります。実際 Cacoo のバックエンドでは Go 以外にも Java や Node.js などが使われています。

Elasticsearch + Kibana

クラスタ上のログはすべて fluentd 経由で Elasticsearch に流して Kibana で可視化しています。

Prometheus + Grafana

Pod のメトリクスの取得・監視は Prometheus を使っています。さらに Grafana を使うことで可視化を行っています。

Zipkin

microservices で構成されたシステムでは、クライアントからのひとつのリクエストが複数のサービスに分散することになります。

そこで Zipkin でリクエストをトレーシングしています。サービスごとにどれだけ時間がかかっているかわかるので、レスポンスが遅いときなどに、どこがボトルネックになっているか簡単に調べることができます。

評価:小さな依存関係

Cacoo の開発チームは福岡、ニューヨーク、アムステルダムの3つの拠点に分かれていますが、多くの場面で互いの開発の進捗状況や細かい仕様などを気にすることなく、ある程度独立して開発を進められていると感じています。

画面を跨ぐような機能の開発や .proto ファイルを追加または編集するときなどはコミュニケーションが発生しますが、互いの責任の範囲がはっきりしているため、認識にずれが出て混乱するようなことはありません。

部分的な変更がしやすくなったと感じています。コードの変更による影響範囲が限定的で明確なため、ライブラリの更新や変更、大規模なリファクタリングに対する心理的な障壁は小さく感じます。

小さなサービスをはじめから書き直すこともあります。ひとつひとつのコードが小さいので歴史的経緯に引きずられることなく現時点のベストだと思える選択ができます。

よく知られている一般的なベストプラクティスに従うことができるので、文化や言葉の違う新しいメンバーが参加してもキャッチアップしやすい環境になっている思います。

ビルドやデプロイもサービスごとに独立して実行されるので、とても高速です。待ち時間が短いので、小さな変更を気軽に適用することができています。

課題:新しい複雑性

アーキテクチャの刷新で多くの課題が改善されましたが、それでもすべてが解決したわけではありませんし、新たな課題も生まれました。

まず、当初の目的であった古いフレームワークへの依存は完全にはなくなっていません。役割がかなり減ったとはいえ、モノリシックなアプリケーションはまだ動作を続けており、Cacoo のいくつかの機能や処理はそれに依存しています。

また、サービスをどこで区切るかという問題が常につきまといます。間違った分割は複雑性を際立たせるだけです。サービスの分割はチーム内で慎重に議論して決めるべきことだと思います。

Cacoo では「小さいほど良い」を原則としてサービスをできる限り小さく分割するようにしています。これは、「十分に小さなアプリケーションを統合するより、大きなアプリケーションを分割するほうが難しい」という一般論を前提としています。

複雑になりすぎない範囲で小さく分割し、問題が顕在化すれば統合を検討するようにしています。いまのところ何かを統合する必要性は感じていませんし、その他の問題もありません。

サービスを分割する基準として、DB のトランザクションがヒントになります。Cacoo では、複数のサービスにまたがる DB への読み書きにおいて、トランザクション処理は諦めています。逆に言えば、トランザクション処理が絶対に必要な処理については、サービスを複数に分割すべきではありません。

サービスを追加するごとにCI/CDのパイプラインが必要になります。うまく効率化する方法がないと開発にパイプラインの整備が追いつかなくなります。

コードの冗長性が増すという問題も目に付きます。複数のサービスごとにコードが独立しているため、同じような処理のコードがあちこちに分散しやすくなります。

Cacoo の場合は gRPC サービスや RabbitMQ に関連する処理などがその典型です。これは microservices の特性上、許容すべきことです。共通の処理をライブラリ化してしまうという手もありますが、そうすると複数のサービスが同一のコードに依存してしまうため、microservices のメリットが減ってしまうことになります。このあたりはチームの状況やコードの特性などを考慮してうまくバランスをとる必要があります。

今後の対応

今後は古いライブラリへの依存を完全になくすべく改善を続ける必要があります。

また、Protocol Buffers 化されてないインターフェースを使っているサービスを更新・入れ替えしていくことでサービス間のインターフェースを統一していく必要もあります。さらにサービスのテンプレートあるいはジェネレーターを作ることで、新しいサービスを作りやすくしたいと考えています。

まとめ

  • Cacoo はいくつかの課題を抱えていた
    • 古いフレームワーク
    • モノリシックなアプリケーションの強い依存関係
    • 多拠点での開発
  • それらを解決するために microservices 化
    • 小さく作って連携
    • 少しずつ分割して ECS 上で運用
    • その後 Kubernetes へ
  • アプリケーションの境界とオーナーシップが明確に
  • 継続的な開発がしやすくなった
    • 部分的に変更しやすい
    • 高速なビルド・デプロイ
  • まだまだ改善が必要

さいごに

Cacoo チームではフロントエンドエンジニア、バックエンドエンジニア、SREを募集しています。

記事を読んで Cacoo のことが気になった方、今回語れなかった Cacoo のフロントエンドの話が聞きたい方、Go を使った開発をやってみたい方、Kubernetes を使ったインフラ構築・運用などの SRE に興味がある方は気軽に話を聞きに来てください。もちろんリモート面談もOKです。

以下のWantedlyページからみなさんのエントリーをお待ちしています!

株式会社ヌーラボの会社情報 – Wantedly 

開発メンバー募集中

より良いチームワークを生み出す

チームの創造力を高めるコラボレーションツール

製品をみる