読者です 読者をやめる 読者になる 読者になる

クラウドワークス エンジニアブログ

日本最大級のクラウドソーシング「クラウドワークス」の開発の裏側をお届けするエンジニアブログ

これだけはやっておきたい〜マイクロサービスのデプロイメント

Microservices Architecture

Scala大好きインフラエンジニアの九岡(@mumoshu)です。マイブームはConcourse CIですが、今日はマイクロサービスの話をさせていただきます。

TL;DR;

「サービスの負荷上がってきたし、マイクロサービス化しよう。マイクロサービス化って、Railsアプリ分割して、それぞれCapistranoでデプロイしておけばいいんでしょ?」*1

マイクロサービス化をするためには、アプリケーションだけでなくインフラや運用のことも考える必要があります。 この記事では、クラウドソーシングのクラウドワークスが来るマイクロサービス化に向けて認識しているデプロイメント上の問題とその対策を紹介します*2

  • テストからデプロイまでがめんどくさいよ問題
    • →Dev/Prod Parity、Infrastructure as Code、CI、ビルドパイプライン
  • リリースに1時間かかるよ問題
    • →ビルドキャッシュ、デプロイキャッシュ、マシンイメージ、スナップショット、コンテナイメージとCache Busting
  • マイクロサービス化したらエラーが多発するよ問題
    • →データベースカップリング、マイクロサービスの前方互換・後方互換、結果整合性、負荷対策、Graceful Degradation、非同期化とダブルライト、CQRS
  • 本番環境でデバッグできないよ問題
    • →分散ロギング、分散トレーシング、トランザクションID

これからマイクロサービス化を控えている、マイクロサービス化したけど期待通りの効果が得られてない、という方の参考になれば幸いです。

We are hiring!

クラウドワークスではサービスの発展・未来に向けて、自らも作れるアーキテクトを募集しています!

この記事について

クラウドワークスは現在、モノリシックなRailsアプリとして開発されていますが、近い将来のマイクロサービスアーキテクチャへの移行に向けて開発を行っています。その時にこれだけはやっておきたいと考えていることを紹介します。

わかりやすさのために、マイクロサービス化の過程で直面しやすい(と個人的に思っている*3)問題別に考え方と対策、参考リンクをまとめていきます。

また、どういう状態からマイクロサービス化を進めるかによって直面する問題は異なるのですが、昨今の「マイクロサービス」のバズり具合を鑑みて、サービスを立ち上げた直後からマイクロサービス化を進めた場合に直面しそうなことも含めて広く浅くカバーします。

広く浅く、という趣旨にそって、問題の詳細な解説、対策の詳細な解説については、参考リンクを参照いただくことを想定しています*4

目次

前提

ここでいう「デプロイメント」ってどこからどこまで?

コードを変更してから、それがユーザさんに届くまで

Rails前提?

No

この記事の内容はRailsに特化していない汎用的なものです。おそらくあなたのサービスで他の言語やフレームワーク等を採用してマイクロサービスを開発することも、同じことが言えるはずです。

この記事の構成

できるだけ以下のような構成に沿うようにしていますので、それを意識して読んでいただくとわかりやすいかもしれません。 また、この構成に添っていないところのご指摘をはじめ、内容の不備等のあらゆるフィードバックは歓迎です!

  • 問題1
  • 対策1-1
    • 想定される原因(この対策が聞くのは、問題がどういう原因だった場合か)
    • 対策の詳細
  • 対策1-2
    • 想定される原因
    • 対策の詳細
  • 問題2
  • 対策2-1 …

テストからデプロイまでがめんどくさいよ問題

マイクロサービス化したら、一部マイクロサービスを変更してテストするたびに同僚に声をかけたり、設定ファイルを変更してデプロイしたりしなきゃいけなくてめんどう。

これは元々あった問題がマイクロサービス化で悪化した・顕在化したという状態だと思います。

Dev/Prod ParityとInfrastructure as Code

Dev/Prod Parity*5は開発環境と本番環境が(実際には、”できるだけ”)一致した状態のことです。 Infrastructure as Codeはインフラ全体をコードで表現することで、アプリケーション開発同様に生産的にインフラを開発できる状態のことです。

そもそもテストするたびにデプロイが必要ということは、

  • 環境構築が自動化されていない
    • 例) 各開発者のローカル環境に全マイクロサービスをデータ以外本番と同等の状態で起動する方法がない
  • 環境構築が不完全に自動化されている*6
    • 例) vagrant upすると開発環境が手に入るけど、実は一部マイクロサービスは共用のテストサーバにおいてあったり
    • 例)vagrant upした環境と本番環境の構築手順や内容が違いすぎて心配

といったことはないでしょうか?

開発環境と本番環境の差異を減らすことができれば、それだけテストするたびに共用テストサーバにデプロイする機会は減るはずです。

開発環境と本番環境の差異を減らすためには、Chef、Puppet、Ansible、Saltstackなど主にConfiguration as Code*7を実現するツールを活用して、開発環境と本番環境を含むインフラ全体を統一的にコードで表現(Infrastructure as Code)・自動化された手順で構築できるようにしておきましょう*8

参考リンク

simplearchitect.hatenablog.com

また、アプリケーションコードを保守性のために複数モジュールに分割するように、各マイクロサービスを実行するVM(のイメージ)やコンテナ(イメージ)、インフラもモジュール*9分割しておきましょう。

インフラのモジュール化には、以下の様なものを利用します。

サービスディスカバリ

開発・本番環境の差異を減らしていく過程で、個人的に最後まで残りやすいと思っているのが、サービスディスカバリの問題です。

ここでのサービスディスカバリとは、マイクロサービスへの接続情報(例えばホスト、ポート番号など)が静的でない環境で、それを特定することです。 マイクロサービスのテストにあたって、設定ファイルを変更してデプロイが必要・・・ということはないでしょうか?

サービスディスカバリが有効な場面

  • Webサーバとバックグランドジョブサーバ(日本だとバッチサーバと呼ばれることもあるようです?)に、同じRailsアプリをデプロイしていませんか?
    • サーバを増やすたびに、特定のスクリプトを手動か自動で起動して、ホスト名のリストを更新する必要があったり、ホスト名のリストを手で書き換えてデプロイする必要があったりしませんか?
  • EC2インスタンスを入れ替えると、手動でssh configの修正が必要だったりしませんか?

そういう運用をされているのであれば、サービスディスカバリを自動化することで、 一部マイクロサービスだけを入れ替えて開発を効率化する、といったことがやりやすくなるかもしれません。

サービスディスカバリの実装

よく見聞きする順で実装を並べてみます。

  • EC2のタグ(にサーバロールそのものや、ロールを含むサーバ名が設定されてる?)
  • めったにホスト名の変わらないロードバランサ
  • DNS
  • ZooKeeper、etcd、Consul、Chef Server、Salt Masterなどに問い合わせ

また、実装方法としては、

  • サーバ上で動く謎のbashスクリプト(とそれによって書き換えられるホスト名が書かれたなんらかの設定ファイル等)
  • アプリケーションコードの一部
  • …などを汎用化したサービスディスカバリ用のライブラリ、フレームワークの一機能*10

基本的に後者のほうが初期投資は必要なものの、長期的に見て保守性が上がって継続的なDev/Prod Parityの改善につながりやすいと思います。

もしあなたのサービスがモノリシックなアプリをロール毎に分けたクラスタにデプロイするような構成をとっている場合、実はサービスディスカバリに相当する仕組みが既にあるかもしれません。

CI

マイクロサービス毎にそれぞれCI(+デプロイ時に分散ロック)

サービスがAとBという2つのマイクロサービスから構成されるとします。 Aを更新するときは、Bとの互換性があることをテストしてから、Aを更新したいし、逆にBを更新するときはAとの互換性をテストしてから、ですよね。 さて、皆さんはこれをどうやって実現しますか?

  • AとBのGitレポジトリをそれぞれ分けて、それぞれにCIを導入。ビルドスクリプト中では他方の最新版を起動して、インテグレーションテストによって互換性を担保。インテグレーションテストが通ったらリリース。

この場合、それぞれのマイクロサービスのデプロイが同時並行で進行すると、マイクロサービス間の互換性の種類(どちらがどちらに対して後方互換・前方互換か?)によって問題が発生してしまうので、分散ロックが必要になることがあります。 クラウドワークスではインフラ構築を自動化した際に既にこれが必要になったため、JoumaeというOSSを開発して対応しています。

ビルドパイプライン

個人的には、そもそも分散ロックが必要な時点で何かがおかしい。

Aを更新したらBとの結合テストをしてからAを更新、Bを更新したらAとの結合テストをしてからBを更新、またはSlackからbotに依頼したらAかBを更新…やりたかったことはこれではないでしょうか。その観点からすると、複数GitレポジトリにそれぞれCIするとか、分散ロックなどはただの手段です。やりたいことをもっと直接的に表現できるツールはないでしょうか。

こういう複雑な依存関係を持つシステムのビルドには、ビルドパイプラインが役立ちます。

OSSとしては、

SaaSとしては、

などがビルドパイプラインに対応しています。

ビルドパイプラインの対応を明示していなかったとしても、CLI、APIを利用して任意のパラメータでビルドを起動することができるCIであれば、ビルドパイプラインを実現することも可能ではあります。

CIとビルドパイプラインの中間にいると個人的に思っているのはAWS CodePipelineです。ただ、カバー範囲がConcourse CI = CodePipeline + Jenkinsという感じで、CodePipeline自体にコードをビルドする機能があるわけではないので、個人的に気軽には使おうとは思えませんでした。

ビルドパイプラインのテスト

前述の複雑なビルドパイプラインが必要になったとき、それをどうやってテストしますか? 例えばTravis CIを使っているとして、.travis.ymlを変更してgit push?その内容が間違っていたら・・・?その間にTravis CI本番デプロイが走ったら・・・?

マイクロサービス化するとビルドパイプラインは複雑化します。複雑化しても、これまでどおりの方法でビルドを気軽に改善していけるでしょうか?

方法は3つあります。一つは、ビルドパイプラインをコードで表現できるCIを利用することです。

皆さんご存知のTravis CIや.travis.ymlにビルド設定を書くことができるので、最悪テスト用のGitレポジトリを用意して、暗号化したファイルや環境変数を移して、.travis.ymlをコピペすることで、同じビルドパイプラインをテストすることもできます。 Werckerはwercker.ymlで、Shippableはshippable.ymlで同様のことができます。ただし、ShippableはCIとビルドパイプラインを別物として扱っていて、CIの設定はshippable.ymlに入りますが、ビルドパイプラインは入りません(Web UIでポチポチ)*11

2つめの方法は、ビルドをローカルで実行する方法を用意しているCIを利用することです。

Travis CIの場合、あなたはコンテナベースの新しい環境を使っているなら、Travis CIが使っているビルドコンテナを手元でdocker runすることで、ビルドを再現することができます。 Werckerの場合、wercker cliでビルドをローカル環境で実行することができます。

3つめの方法は、CIサービス自体をローカルに用意する方法です。 例えば、Concourse CIの場合、Vagrantで自分専用のConcourse CIをVirtualBox上に起動することができます。 git pushしなくても、気軽に自分専用のConcourse CIの中でだけ新しいビルドパイプラインを試すことが出来ます。

リリースに1時間かかるよ問題

何か変更するたびに、apt/yum/etc、bundle/npm/etc、git clone、○千件のテスト、AMI作成、Dockerイメージ作成、…

以下のような最悪なケースを考えてみると、一行のコード変更で78分かかりますね。

  • apt-get install等によるパッケージインストールに3分、
  • bundle install等によるライブラリインストールに10分、
  • テストに必要なgit clone(しかも画像、音声、動画等のバイナリが含まれるので○GBある)に10分
  • ○千件のテスト(しかもテストスイート毎にDBの全テーブルを作成しなおしたりで時間がかかる)に30分
  • AMI作成(10分)
  • サーバプロビジョニング(20分)
  • Dockerイメージ作成(毎回ワーキングツリーをそのままdockerコンテナにコピーしてbundle install)15分

それぞれのステップで「こんなにかからないだろう」と思った方は素晴らしいです! 既に抑えるべきところを抑えられているはずです。

とにかくキャッシュ

デプロイキャッシュ

デプロイするたびにbundle installやnpm installがゼロから走って数分以上の時間がかかる、のような状態になっていないでしょうか?

Capistranoなど既存のデプロイツールを定石どおり使っていればなかなかこういう状態にはならないですが、もしなってしまっている場合は今一度デプロイツールのドキュメント等を読みなおして、デプロイキャッシュを有効にしましょう。

デプロイキャッシュのポイントは、主に以下の二点です(他に一般的なものがあればぜひ教えてください)。

Gitレポジトリ

デプロイ元・先のどちらかで毎回git cloneしているのは何かがおかしいです。例えば、初回はgit clone、2回め以降はgit fetchで済んでいるのであればデプロイキャッシュが効いているといえます。

依存ライブラリ

デプロイ元・先のどちらかで毎回ゼロからbundle install等を実行しているのは何かがおかしいです。変更があったライブラリだけがインストールされる状態になっていればデプロイキャッシュが効いているといえます。

ビルドキャッシュ

デプロイキャッシュ同様、最低限ソースコードと依存ライブラリだけでもキャッシュしておき、それぞれ変更があった場合に差分だけをGitHubやrubygems.org等から取得できる状態にしておくべきです。

ここはあまり難しく考えなくても、Travis CI等のCIサービスが提供してくれているものを気軽に使って恩恵を受けている方も多いのではないでしょうか?*12

また、コンパイル・トランスパイルを要する言語(静的型付け言語にかぎらず。Sass、TypeScript、Scala等多岐にわたります)を使っている場合にありがちなのですが、毎回フルコンパイルが走る状態になってしまっていないでしょうか? コンパイル結果を適切にキャッシュして、変更されたソースコードに対応する部分だけがコンパイルされる状態(Incremental Compilation)にしておきましょう。

マシンイメージ

AWSならEC2のAMIですね。

特にインフラもコードで管理しているような場合、テストのたびにサーバを自動構築して、アプリをデプロイして、Serverspec等のテストを実行する、ということをしますよね。オートスケーリングやBlueGreenデプロイとのかねあいで、サーバを頻繁に作成・デプロイすることもありますが、そういう場合に数分〜数十分かかるChefレシピを最初から最後まで適用して初めてアプリをデプロイ・・・ということをやっていると、前述のとおりそれだけで数十分の時間がかかってしまいます。

また、前述のビルドキャッシュ・デプロイキャッシュは、何も考えないと新たに作成したサーバにはききませんよね(どこにビルドキャッシュ・デプロイキャッシュを保持しておくのか?)

そこで、サーバをプロビジョニング、またはデプロイした都度AMIを作成することで、AMIにキャッシュが含まれた状態にしておくことで、リリースにかかる時間を短縮することができます。

CDP:Stampパターン - AWS-CloudDesignPattern

スナップショット

ビルドキャッシュやデプロイキャッシュに相当するデータを、EBSスナップショット等に保持しておき、使う直前にマウントする方法です。

Snapshotパターン - AWS-CloudDesignPatternの関連性があると思いますが、CDPのSnapshotパターンはデプロイを目的にしかユースケースは説明されてないので、これがCDPのスナップショットパターンです、というのははばかられますね。

個人的には、この方式を知ったきっかけは、Painless AWS Auto Scaling With EBS Snapshots And Capistrano - Boomを読んだことです。他にも事例があったらこっそり教えてください。

コンテナイメージ

マイクロサービス化の機会にコンテナ仮想化も始める、というケースをよく見聞きします。 「Docker化したらデプロイが遅くなった」って聞いたことないでしょうか? 「Capistrano時代はremote_cacheでデプロイキャッシュをきかせていて高速にデプロイできていたのが、Docker化したらアプリを更新するたびにパッケージインストールとGitレポジトリのワーキングツリーの完全なコピーが毎回発生して、デプロイが遅くなった」みたいなことになっていないでしょうか?

Dockerイメージのレイヤーはキャッシュされます。キャッシュしたい単位でレイヤーをつくることで、コンテナ型仮想化に移行する前に普通にやっていたビルドキャッシュやデプロイキャッシュに相当することができます。

パッケージならversion pinning、ライブラリならcache bustingを適切に使います*13

version pinningは、インストールするパッケージのバージョンを明示する*14ことで、パッケージを更新した際にレイヤーのキャッシュがちゃんと更新されるようにすることです。

cache bustingは、この文脈においては、ライブラリ依存性を定義したファイル*15とその他のファイル(アプリケーションコード、設定等)を別々にレイヤーにすることで、インストールしたライブラリをキャッシュすることです。アプリケーションコードを更新するたびに、何故かライブラリインストールもゼロから行う必要がある…というような自体を防ぐことができます。

具体例としては、パッケージなら apt-get update && apt-get install -y packager.foo=1.3.* のようにversion pinningして、1.3.*のようなバージョンをDockerfileで変えるまではキャッシュします。また、Railsアプリでbundle install結果をキャッシュするような場合、単に ADD <project_root> してから bundle install するのではなく、GemfileとGemfile.lockをADDしてからbundle install、そのあとでADD <project_root> する、といった具合です。

Best practices for writing Dockerfiles How to Skip Bundle Install When Deploying a Rails App to Docker if the Gemfile Hasn’t Changed | I Like Stuff

一点注意しておきたいのは、Dockerコンテナのデプロイ先となるDockerデーモンを実行しているサーバです。Dockerデーモンを実行するサーバが頻繁に作成・破棄されるような構成で、Dockerイメージを保持していない(デフォルトでは /var/lib/docker に保存されています)場合、Dockerイメージのビルドをどれだけ工夫してもデプロイキャッシュの効果は得られません(当たり前かもしれませんが)。前述のマシンイメージやスナップショットを活用して、Dockerイメージをキャッシュしておきましょう。

How to Maximize Your Docker Image Caching Techniques - CenturyLink Cloud Developer Center

マイクロサービス化したらエラーが多発するよ問題

おそらくですが、「マイクロサービス化したら」エラーが多発し始めたということなら、マイクロサービス間の依存関係、データの整合性、データベースカップリング、後述のGraceful Degradationなどの考え方が役に立つと思います。

データベースカップリング

データベースカップリングとは、複数のマイクロサービスがデータベースを共有してしまっている状態です。共有しているデータベースが障害等で落ちてしまって、代替のデータベースもないような状態であれば、依存しているすべてのマイクロサービスがDBと一緒に落ちてしまいます。

何かシステムに障害が発生したとき、全マイクロサービスが落ちてしまう、ということであれば、おそらくDBやキャッシュ等を共有しているとかで、実はサービス間に間接的な依存関係が生まれてしまっている、ということが考えられます。 もしそれが問題になるようであれば、マイクロサービス毎にデータベース等を分けることによって、あるDBの障害が直接的には一部のマイクロサービスにしか影響しないようにします。

データベースカップリングを解消した上で、それでも一部マイクロサービスの障害が全サービスのダウンにつながってしまうようであれば、依存元のマイクロサービスを後述のGraceful Degradationに対応させるとよいでしょう。

Database per service

マイクロサービスの依存関係と互換性の話

後方互換・前方互換は意識してますか? マイクロサービスがそれぞれ独立してデプロイできるようにするためには、デプロイ前後でマイクロサービス間の互換性が保たれている必要があります。

いくつか例を交えながら説明してみます。

  • あるマイクロサービスを更新すると、一瞬他のマイクロサービスからエラーが多発する
  • あるマイクロサービスを更新したあと、他のマイクロサービスが継続的にエラーを出すようになった
  • 個別にリリースするとエラーが多発するから、BlueGreenデプロイしている。具体的には、全マイクロサービスを一回止めてから、新しいクラスタにデプロイした全マイクロサービス起動して、切り替えている

1つめは、依存先マイクロサービスが無停止デプロイになってないが、依存元サービスは依存先が常に動いていることを前提に組まれていて、前提の不一致がある状態です。エラーが起きている部分のSLAによりますが、一時的に動かなくても問題ない機能であれば、依存元のほうでGraceful Degradationに対応させて、依存先サービスの停止が依存元に影響しないようにします。常に動いていないと困る機能であれば、依存先サービスのほうで無停止デプロイができるようにします。無停止デプロイの方法は、ローリングデプロイやBlueGreenデプロイがあります。

2つめは依存元のマイクロサービスに前方互換性がない状態で、依存先のマイクロサービスを更新してしまっているパターンです。依存元のマイクロサービスに、依存先マイクロサービスの新バージョン対応を入れて前方互換にしてから、依存先サービスを更新しましょう。

3つめはマイクロサービス間の依存関係が整理されていないため、よくわからなくなってしまっている状態です。 その状態を整理せずにデプロイで対処しようとした結果、デプロイのためだけにマイクロサービスのGraceful Stop、Startの機能を作りこんで、実質的にはモノリスに戻ってしまっている状態かもしれません。 そこまでやらなくても、各マイクロサービスの依存関係の方向と互換性を整理すれば、個別にデプロイできる可能性があります。

結果整合性

結果整合性というのは、簡単にいうと、データへのある変更が、そのうち全体に反映される、ということです。

例えば、以下の様なスタートアップあるあるは結果整合性の例です。

「MySQLにすべてのデータを入れていたけど、キーワード検索(LIKE検索)で負荷が高まりすぎて限界に来たのでElasticsearchなどの全文検索システムを導入した。ユーザ操作起因でMySQL上でinsertしたデータが、10秒後にElasticsearchから読みだした結果には反映されていない。ユーザから見ると送信したデータが検索結果に反映されていないので、お問い合わせにつながってしまった。」

マイクロサービス化したとき、あるマイクロサービスが受け付けたデータを、複数のマイクロサービスに反映しなければならなかったとします。その場合、マイクロサービス間の通信はプロセス内、プロセス間の通信より十分に遅いため、いままでのように「データを順番に反映してすべて成功したらユーザにレスポンスを返す」といったことが単純にはできません。ありがちなのは、単純に非同期化、例えば「ユーザにはレスポンスを即時返して、データをジョブキューを使って非同期で順番に反映する」みたいな実装にしてしまって、いつのまにか結果整合性を採用してしまっていた、という自体です。

結果整合になるのがまずいということではありません。強い整合性より結果整合性を前提にしたほうが一般にスケーラビリティが高くなるので、負荷対策の一環で結果整合性を採用せざるも負えない場合もあるはずです。ただし、その場合でも結果整合であることを意識した設計(非同期なデータ反映がすべて完了したことを通知するなど)や、それを前提としたユーザとのコミュニケーション*16をおすすめします。

以下の様な用語について調べてみると、参考になるかもしれません。

  • Strong Consistency と Eventual Consistency(強い整合性と結果整合性)
    • 例)非同期処理なしで単純にWebサーバでの処理とMySQLへの通信だけでトランザクションを完結させてた状態(最新のデータが常にMySQLにあるので、読み書きをMySQLにつけておけば大抵の場合強い整合性が担保される)から、Master-Slaveレプリケーションを始めたりMySQL外にジョブを保存するジョブキューを導入したタイミングで意識せずに結果整合になってしまっていたり…
  • Consistent Reads と Stale Reads(一貫した読み込みと最新でない読み込み*17
    • 例)MySQLのマスタDBへの問い合わせは常に最新のデータを返す(Consistent Reads)が、スレーブへの問い合わせはレプリケーションが追いつくまでは古いデータを返す(Stale Reads)

参考リンク

負荷が上がるたびにサービスが落ちる

「マイクロサービス化したのに、負荷が上がるとサービスが落ちるよ」

マイクロサービス化したら自動的に分散開発しやすくて高性能・高可用性なサービスが実現する…というわけではなくて、そのようにつくる必要があります。

デプロイメントの観点からいうと、「マイクロサービス化したのに、負荷があがるとサービスが落ちるよ」というのは、マイクロサービスアーキテクチャかどうかと関係なく、単にそのデプロイメント環境に必要な負荷特性に耐えられないアプリケーション設計になってしまっている、と思います。

もはやマイクロサービスと関係ないのですが、おそらくマイクロサービス化したあとに直面するとマイクロサービスアーキテクチャの問題と誤認されてしまう気がしたので、念のため書いておきます。

落とせない機能を実現している部分であればロードバランシング、オートスケーリング、Database per service, Read-Write Splitting、シャーディング、CQRS、落とせる場合はGraceful Degradationを取り入れることで改善できる可能性があります。

シャーディング以降はアプリケーションへの変更なしでは逆に難しいケースも多いので、「本番環境だけCQRSしたいからインフラエンジニアがんばって!」は通用しないかもしれません*18

ロードバランシング

ロードバランシングは、いわずもがな、負荷分散のことですね。1つのマシンまたはサーバでは、量的、時間的にデータが処理しきれない場合に、処理を複数マシンに分散させることです。

ロードバランシングしなくても済んでいる状態でマイクロサービスアーキテクチャを採用するというケースも考えづらいのですが、可能性の話として。そういう場合は、身もふたもないですが、ロードバランシングを始めましょう。最初に状態を持っているシステム(データベースやキャッシュ、オブジェクトストレージなど)さえ分離できれば、あなたのアプリケーションサーバをロードバランシングできる状態になるはずです。その次に「Webアプリと○○(DBやキャッシュやオブジェクトストレージ以外の何か)が同居してるよ!」という状態の場合は、それを分離するとよいかもしれません*19

オートスケーリング

データベースカップリング等がない状態、動作中のシステム間で共有しているものが何もない状態でマイクロサービスが落ちてしまうということであれば、あなたのマイクロサービスが稼働する単一サーバのコンピューティングリソース(メモリかCPUかネットワークか)が足りていないのかもしれません。その場合、淡々とオートスケーリングしましょう。

なお、オートスケーリングが必須、ということはありません。

皆さんが運用されているWebサービスの負荷特性はどうなっていますか?

  1. 平日・休日かかわらず同じような負荷でしょうか?
  2. 一日中同じような負荷でしょうか?
  3. TV放映やニュースサイト掲載、マーケティングメール等によるプロモーションはないでしょうか?
  4. 公式・非公式にレスポンスタイム等の基準が設けられていないでしょうか?
  5. インフラコストに厳しくないでしょうか?

いずれも該当しない場合、おそらくあたなのサービスにはオートスケーリングは不要です。(もしかしたら、マイクロサービス化も「すぐには」必要ないかもしれません。ぼくの見識が乏しいだけな気もしますが、オートスケーリングが不要な規模なら、実はマイクロサービス化するほど組織やソフトウェアが複雑化してないのでは?)

一つでも該当して、しかもマイクロサービスアーキテクチャを採用するなら、まず時間または負荷ベースのオートスケーリングを始めることをおすすめします。 まず、マイクロサービス毎に負荷特性が異なることが考えられるので、モノリシックなサービスより単純なメトリクスや的確なオートスケーリングが実現できて、得られるコスト削減効果などが高くなると想像されるからです。構成やサービス数や環境(テスト環境なら・・・)所定の時間にEC2インスタンスを手動で増減させてコスト削減、とかもできなくないですが、やりたいですか・・?

  • AutoScaling+CodeDeploy+Capistrano ポイントは、LaunchConfigurationからEC2インスタンスを起動したとき、自動的にCodeDeployによるデプロイが走る、というところです。

CodeDeployを使わないパターンだと、 * 予めアプリをAMIに焼いておいて、LaunchConfigurationから起動したときは焼いてあるバージョンのアプリをそのまま起動する * 予めアプリをAMIに焼いておいて、LaunchConfigurationのUser-Dataに最新アプリをデプロイするシェルスクリプト(内部でcapistranoを使ったり、使わなかったり)を設定しておき、インスタンス起動時にcloud-initにそれを実行させる

Graceful Degradation

Graceful Degradation*20は、マイクロサービスの文脈においては、「依存先のマイクロサービスが落ちたとき、依存元のサービスは機能を低下させたり、性能を落とすだけで、稼働はしつづける」という意味合いです。

Graceful Degradationの副次的なメリットして、デプロイ方式の選択肢が増える、ということもあります。 サービスAがBに依存していて、AがGraceful Degradationに対応していてBが落ちても問題ない、という状況になっている場合、Bを無停止でデプロイする必要もなくなるからです*21

Graceful Degradation自体はマイクロサービスとは関係ない概念ですが、マイクロサービスアーキテクチャを採用したサービスの可用性を高める目的の他に、モノリシックなサービスの可用性を高めたり、システム移行や検証中のサービスダウンを予防する目的でも使うことがあります。 そう呼んでいないだけで、みなさんのサービスにもGraceful Degradationがあるかもしれませんね。

非同期化とCQRS

非同期処理

非同期処理とは、簡単にいうと、結果を待たずに別スレッド、別プロセス、別マシンで処理を行うことです。

ユーザから受けたリクエストをリアルタイムに処理していると時間的に間に合わないときに、単純に非同期化して対応することがありますよね。 例えば、Webアプリでよくある「リクエストを起因にジョブキューにジョブを入れて、ジョブは別サーバで処理する」というのも非同期処理です。 しかし、非同期化した結果発生するエラーもあります。例えば、ダブルライトと結果整合性に関連するものです。

ダブルライト

ダブルライトとは、アプリケーションの1つのトランザクション中で2つのデータストアに書き込みを行うことです。

DBとジョブキューにダブルライトして、DBにデータがある前提で、非同期で後処理を行う・・・といった設計にした場合を考えてみると、前述の結果整合性の問題が発生します。すると、結果整合性を前提にしていなかった他のコードでエラーが発生することがあるかもしれません。

また、強い整合性の「雰囲気」がでるように、DBにとジョブキューとキャッシュにトリプルライトして、他のコードはキャッシュを読むようにすることで、重い計算は非同期化しつつも最新のデータがユーザに見えるようにする、というような対策をする場合もあるかもしれません。ここで問題なのは、非同期化の副作用としてのコードの複雑化とバグ発生です。

そもそも、ダブルライト方式の場合、データ不整合の考慮漏れがあるかもしれません。例えば、一方のライトが失敗したときにちゃんとロールバックをつくりこんでいるでしょうか?ロールバックしない場合、「DBへのライトは失敗したけど、ライト成功を前提にしていた非同期処理が始まってしまう」といったことになります。この例だと、非同期処理側で「DBへのライト失敗の可能性」を考慮していなければ、エラーが発生してしまう可能性があります。

そして、アプリケーションコードに、ダブルライトを「ちゃんと」やるためのデータ不整合の考慮やロールバック、リトライの対応を作りこむと、コードは複雑化します。

CQRS

このような考慮漏れやコードの複雑化に対応する一つの手段がCQRSです。

CQRSとは、Command Query Responsibility Segregationの略で、日本語では「コマンド・クエリ責務分離」と呼ばれたります。

今回のように「負荷が上がるとマイクロサービスが落ちるよ」という文脈だと、アプリケーションにおいて、モデルをコマンド(更新または書き込み)とクエリ(検索または読み込み)で分割することで、それぞれに適したアーキテクチャやデータストアを選択しやすくする、という目的で採用することがあります*22

単純に「非同期処理」と捉えてそれを多用すると、前述のダブルライトをカジュアルにやってしまってデータ不整合や結果整合性の考慮もれの発生や、コードの複雑化を招いてしまいます。前述の単純なダブルライトをCQRSを用いた方式に変えるとすると、例えば「ユーザのリクエストをコマンドに変換して、すぐレスポンスを返す。ユーザへのレスポンスにはコマンドの結果は原理的に含められないので、それ前提の表示をする。コマンドは非同期的に、最低一回以上処理される。コマンドを起因に、DBへの反映、DBへの反映が成功したら後処理をする。ユーザがクエリ投げたクエリはDBへ現状反映されているデータを返す(結果整合性)」となります。単純にダブルライトするより、かなり秩序だった設計ができるのではないでしょうか?(実装が簡単になるとは言ってない)

参考リンク

  • Martin FowlerさんのCQRS
  • Scala好きなら知ってる元Typesafes社現Lightbend社のCQRS&EventSourcingフレームワークLagom
  • MSDNのCQRS Journey

本番環境でデバッグできないよ問題

どのサービスでエラーが起きたのかわからない

分散トレーシング

分散トレーシングは、あるリクエストの処理中に分散システムのどのサブシステムでどのような処理がどのような順番で行われたか、を追跡することです。

ユーザのリクエストに応答するマイクロサービスAがあり、Aが内部的にBやCなどの他のマイクロサービスと通信してユースケースを実現するとします。 最終的にレスポンスエラーとなってしまった場合、マイクロサービスA、B、Cのどのクラスのどのメソッドでエラーが起きたか知りたいとします。どうやって調べたらよいでしょうか。エラーが発生する場合以外にも、レスポンスが遅い場合にどのマイクロサービスでどれだけの時間がかかっているかを知りたいケースもあります。これも同様に、どうやって調べたらよいでしょうか。

例えば、マイクロサービスがそれぞれの複数台のサーバで動いていて、あるリクエストがエラーレスポンスに至った経緯を調べるために全サーバのログをダウンロード・結合して検索しなければならないとしたら、かなり苦労しそうですよね。アプリケーションの一連の処理が複数マイクロサービスで分散実行されたとき、それぞれの実行状況を追跡専用の分散とレーシングシステムに集約しておいて、あとで検索できるようにしておけば、分散システムにおけるデバッグやパフォーマンスチューニングをやりやすくなります。

分散トレーシングを目的とした著名なOSSにZipkinがあります。

また、分散トレーシングはいわゆるAPM*23サービスの一機能として提供されている場合があります。 例えば、NewRelicならCross Application Tracingという機能でそれです。

参考リンク

分散ロギング

分散ロギング*24は、各システムから出力したログを集約して一元的に管理・検索することです*25

大抵のシステムは動作状況を記録したログを出力します。分散システムの場合も同様で、各システムがそれぞれログを出力するはずです。ログを参照しようと思うたびに、全サーバへアクセスしてログを漁ったりするのは面倒ですよね。分散ロギングを利用することで、分散システムが複雑化した場合でも、一箇所でログを管理・検索することができます。

分散ロギングに関しては、ログの収集に関してはFluentd、Logstashなど、収集したログの一元化と可視化に関してはKibanaなどのOSSがあります。 SaaSとしては、ログ収集から可視化まで対応しているhttps://www.loggly.com/https://papertrailapp.com/http://stackshare.io/logentriesあたりが有名でしょうか。

参考リンク

Centralized Logging ·

トランザクションID

個人的に分散トレーシングに欠かせない、分散ロギングで大切、と思っているのがトランザクションID(またはリクエストID)です。 トランザクションIDとは、その名の通りトランザクションを一意に識別するIDです。

Aがユーザからのリクエストを受け付けた時、リクエスト*26毎にグローバルなトランザクションID*27を払い出します。

分散ロギングの場合、タイムスタンプとトランザクションIDをログメッセージに付与しておけば、ログをトランザクションIDで絞り込むことによって、マイクロサービスをまたがる1つのユースケースで発生したログを時系列順に見ることができます。

分散トレーシングの場合、RPCコールのログにトランザクションIDを付与しておくことで、やはり特定のトランザクションで呼び出されたすべてのRPCコールを知ることができます。

既存の分散ロギングや分散トレーシングのシステムを採用しないとしても、マイクロサービス化するときにトランザクションIDのことを念頭において簡易的にでも分散ロギング・トレーシングをしておけば、本番デバッグが捗ると思います。

まとめ

マイクロサービス化をするためには、アプリケーションだけでなくインフラや運用のことも考える必要があります。 この記事では、クラウドワークスが来るマイクロサービス化に向けて認識しているデプロイメント上の問題とその対策を紹介しました。

  • テストからデプロイまでがめんどくさいよ問題
    • →Dev/Prod Parity、Infrastructure as Code、CI、ビルドパイプライン
  • リリースに1時間かかるよ問題
    • →ビルドキャッシュ、デプロイキャッシュ、マシンイメージ、スナップショット、コンテナイメージとCache Busting
  • マイクロサービス化したらエラーが多発するよ問題
    • →データベースカップリング、マイクロサービスの前方互換・後方互換、結果整合性、負荷対策、Graceful Degradation、非同期化とダブルライト、CQRS
  • 本番環境でデバッグできないよ問題
    • →分散ロギング、分散トレーシング、トランザクションID

これからマイクロサービス化を控えている、マイクロサービス化したけど期待通りの効果が得られてない、という方の参考になれば幸いです。

「あとがき」という名のポエム

「これだけはやっておきたい」なんて釣りタイトルをつけておいてなんですが、「マイクロサービスアーキテクチャを採用するなら、『移行当初』からこのようなことを考慮したデプロイを絶対に実現しておくべき」、という話ではありません。この中で何を最低限やるのが自分たちのためになるのかを考えながら、ピックアップしていただくとよいと思います。

We are hiring!

この記事でご紹介したことを抑えたマイクロサービス化が、クラウドソーシングの未来への1ステップであると筆者は信じています。このような「あるべき姿」を考えた上で、いまの目標(直接的には納期や、売上のことが多いでしょうか)のために理想からみれば妥協と思えることをしつつも、少しずつでもあるべき姿に向かって行動する、という姿勢。その先に「あるべき姿」を達成したX年後があると思っています。

でも、「あるべき姿」への投資って普段はなかなか出来ないですよね。 目先の目標を達成していかないと信頼されず、「あるべき姿」へ向かうリソースを捻出することができません。 それなのに、目先の納期を達成しているだけだと「あるべき姿」と現実とのギャップが限界にきて、いつしかN人の力では変えられなくなってしまうかもしれません。Nが無限なら、どれだけ「あるべき姿」へのステップを先延ばしにしても問題ありません。しかし、実際には事業や労働市場の状況に応じてNには限りがあります。

サービスの将来を見据えて、ぎりぎりのNを見つけ出し行動することは、アーキテクチャに造詣の深いあなたにしかできません!

というわけで、クラウドワークスでは、21世紀の新しい働き方をつくるアーキテクト、インフラエンジニアを募集しています! 事業を継続的に発展させ、人々の働き方を変えるために戦略的な開発を行いたいエンジニアの方、一緒に働きせんか? あなたのご応募をお待ちしています。

*1:この時点でカッとなった方は、ぜひ本記事の最後のほうをお読みくださいmm

*2:マイクロサービス化の目的は他のエンジニアが書いてくれると思います

*3:「こんな問題もよくあるのでは?」というものがあったら、ぜひこっそり教えていただけるとうれしいです!

*4:参考リンク漏れの指摘や提案は歓迎です!

*5:http://12factor.net/ja/dev-prod-parity

*6:様々なトレードオフにより、ここに着地している開発組織も多いのではないでしょうか

*7:サーバの設定を自動化するというメリットを提供してくれます

*8:インフラ全体をコード化する、ということがポイントです。個人的には、開発環境と本番環境を「それぞれ」の方法でコード化しても、あまりメリットはないと思っています。それぞれConfigurartion as Codeが実現できていても、全体の共通化ができていないというのは、アプリケーションに例えると、よく似た別々のアプリケーションを個別に開発しているような状態です。インフラもソフトウェア開発同様共通化で保守性や生産性を上げるなら、インフラもパーツではなく全体をコード化すべき、というのが筆者の持論です。

*9:コンテキストによっては「レイヤー」と表現することもあると思います

*10:おそらくサービスロケーターかそれに近しい名前がついていると思います。service locator

*11:余談ですが、クラウドワークスのRailsアプリで利用しているCIサービスはSemaphoreといいます。Semaphphoreはここで紹介したようなコードによる設定の管理はできません。どうしよう・・・

*12:例えば、Travis CIであれば、設定ファイルに一行書いておけば、bundle installしたライブラリをビルド成功時にキャッシュしてくれます。 https://docs.travis-ci.com/user/caching/

*13:version pinningの結果、cache bustingがうまくできる、というような説明もできるかもしれません

*14:pinningという表現からは「固定」という印象がしますが、固定することが目的ではないと思うので「明示」と意訳してみました

*15:例:Gemfile、package.json、composer.json、pom.xml、build.sbtなど

*16:メールアドレス確認メールが届くまでは○分ほどかかることがあります、とか、登録したお仕事が検索結果に反映されるまでには○分ほどかかることがあります、とか

*17:Stale Readsの一般的な日本語訳がわからないので、ご存知の方は教えてください!

*18:あえて、「本番環境だけCQRSできるインフラエンジニア募集!」という募集を出してみるのも面白いかもしれません

*19:例えばですが、創業期のスタートアップでAWS ElasticBeanstalkやOpsWorksやHeroku等を利用している・・・みたいなケースだと、ロードバランシングとオートスケーリングに関してはすぐに解決できる状態になっているかもしれませんね

*20:Fail Softと呼ばれることもあるようです。Graceful Degradationは、例えばWebフロントエンド界隈でも「モダンブラウザとレガシーブラウザに両対応するけど、レガシーブラウザではデザイン性や機能性を落とす」みたいな意味合いで使われることもあるありますよね。一方で、Progressive Enhancementという言葉も聞いたことがあるかもしれませんが、こちらはレガシーブラウザでは基本的な機能・デザイン等を提供して、モダンブラウザではよりリッチな機能・デザイン等を提供する、という感じです。ユーザさん見えには同じことかもしれませんが、エンジニア的にはどちらの立場をとるかによって設計が変わったりするかもしれませんね。

*21:余談ですが、クラウドワークスの場合は、マイクロサービス化以前にGraceful Degradationの考え方は既に取り入れています。 現在、お仕事やクラウドワーカーの皆さまを全文検索できるようにElasticsearchを利用しているのですが、移行期間中にElasticsearchが落ちた場合に元のMySQLベースの検索にフォールバックすることで、応答性は落ちますがサービスはギリギリ落ちないようにしていました。内部的にはgracefullyというOSSを開発・利用しています

*22:個人的には、モデルをわけなくても実際ソフトウェアの保守性やサービスに負荷的に明らかな限界が見えてないのであれば、まだ採用するには早いはず。CQRSは、それなしでやれることを全部やってから採用する方法で、適用範囲も絞ったほうがよいと思います。

*23:Application Performance Monitoring

*24:Distributed LoggingまたはCentralized Logging

*25:わかりづらいですが、Distribueted Log(分散ログ)とはニュアンスが異なります。分散ログは、高耐久性のあるデータストアを実現するために使う「ログ」を、スケーラビリティのために分散システムとして実装したものです。参考: 強固なデータ・インフラストラクチャを構築するためのログの活用(デュアル書き込みがダメな理由)PART 1. | インフラ・ミドルウェア | POSTD

*26:またはNewRelicなどではWeb/Non-Webトランザクションと呼んだりします

*27:NewRelicの場合X-NewRelic-Id、Zipkinの場合はTrace Idですね

© 2016 CrowdWorks, Inc., All rights reserved.