マイクロサービス化を支える継続的切り替え術

こんにちはこんにちは。技術部のクックパッドサービス基盤グループのシム(@shia)です。グループ名が大きいですね。

クックパッドで運営しているサービスの中、一番古くから存在しているレシピサービス (cookpad.com) ——以下このサービスのコードベースを cookpad_all と呼びます——があります。 クックパッドサービス基盤グループはこのレシピサービスの運用及び改善という責務を持つグループとして今年の2月に発足しました。 わかりやすい業務の一つとしてはお台場プロジェクトが挙げられます。 お台場プロジェクトに関しては昨年12月の最後を飾った青木さんの クックパッド基幹システムのmicroservices化戦略 〜お台場プロジェクト1年半の軌跡〜という素晴らしい記事があるので紹介は省きます。

お台場プロジェクトの一つとして、僕は最近 cookpad_all からフィーチャーフォン向けのサービスである「モバれぴ」を分離するという作業をしています。このサービスはコードベースが大きいので、どうやって元のコードベースから新しい方に切り替えて行くのかが非常に大事です。この記事では現在進行形である、モバれぴの切り替え戦略に関して紹介してみようと思います。

始める前に

この記事でのサービス分離作業は巨大な一つのコードベースから、他に対する依存が少ない、もしくは関係のない機能・サービスなどをマイクロサービスとして分離することを意味します。ですので

  • コードベースが完全に分離される
  • 内部的には別のサービスとして提供する
  • 切り出し元と通信が必要であれば HTTP API や gRPC などの方法を用いる

を前提にしてお話していきます。

コードベースの切り替え戦略を考える

コードベース切り替えには大きく2つの戦略があります。 いわゆるビックバンデプロイと小分けにして継続的にデプロイしていく、というものですね。

モバれぴは完全に作り直すことにしたため、まずビックバンデプロイは無理だと判断しました。 仕様が同じとはいえ実装詳細が変わる以上、必ずなにかの問題が起こります。問題が起きると全体をロールバックするしかないので、全体をリリース & ロールバックが繰り返される状況になり作業は進まなくなるし、リリースのたびにユーザーに迷惑をかけることになります。 であれば取れる戦略としては後者のみですね。

昨年に取り組んだよく似ている事例として、iPhone/Android アプリ向け機能「料理きろく」のコード(API エンドポイント)を cookpad_all から1つのマイクロサービスとして切り出す(分離する)作業を行いました。 その時もビックバンデプロイは絶対避けたかったので、切り出し元であるサービスの手前でリバースプロキシとして存在している NGINX を利用し、新しいコードベースへ流すエンドポイントを指定できるようにし、これを増やしていくという方法を取りました。

NGINX の location で上流を切り替える戦略

f:id:riseshia:20190305074302p:plain
location を利用する切り替え戦略

この図では赤い矢印が切り替え完了したエンドポイントに対するリクエストで、青い矢印が切り替えが終わってないエンドポイントに対するリクエストです。 まずリクエストは ELB を通じてその後ろのリバースプロキシの役割を担当する NGINX へ流れます。そこでリクエストのパスを見て切り出し元にリクエストを流すか、切り出し先のサーバに流すかを決めるわけですね。 一つのエンドポイント(ここでは /v1/recipes)の実装を終えて、本番に投入したい状況になったとしましょう。このエンドポイントに対して以下のような NGINX 設定を書きます。

location ~ ^/v1/recipes {
  proxy_pass <切り出し先>;
}
location / {
  proxy_pass <切り出し元>;
}

このように location で新コードベースに流したいエンドポイントのパスを追加し、proxy_pass でリクエストを流せばいいわけです。

NGINX の location を利用した切り替え戦略の問題

すでにお気付きの方もいると思いますが、この方法はインフラ環境を頻繁にいじる必要があるという問題があります。

まず、本番環境のインフラをいじるだけで障害が発生するリスクが生じます。 そして特定のエンドポイントを切り替えるのにインフラ側とアプリ側のオペレーションが両方発生すること自体が不便です。 エンドポイントを一つ切り替えるだけなのに、新アプリをデプロイして、 NGINX をリロードして……エンドポイントが 20個ある場合、真面目にやっていくとしたらこれを 20回しないといけません。気が遠くなる面倒さです。 ということで手抜きをし始めると、1回に1個以上のエンドポイントを切り替えるようになるわけですが、これはビックバンデプロイに近づく結果になります。言い換えると、ロールバックの確率が高くなるとも言えるでしょう。 今考えるとエンドポイントの数が多くなかったとはいえ、よく頑張ったな〜という気持ちになります。

前回はインフラの作業コストとコピペに近い移植方式を考慮した結果、コントローラー単位で切り替えるようにしていました。 ですがモバれぴはビューの存在とすべてのリソースを API 経由で取得するというポリシーにしたため、作業コストが跳ね上がっていました。結果、いちいち移行していたらインフラ作業コストも跳ね上がるはずなので、いつまで経っても作業が進まなくなるのは明らかでした。

モバれぴでの切り替え戦略

前回、料理きろくの分離作業から得た教訓は

  • 切り替えでインフラオペレーションを使いたくない
  • ロールバックが簡単にできるようにしたい
  • 1アクション単位で切り替えたい

というものでした。つまり、切り替え作業コストを最小限に減らしたい。 これらの問題はすべてのリクエストを新コードベースに送り、新コードベースの方で、未実装の場合は切り出し元に問い合わせるよう、503(Service unavailable) ステータスコードを返すことで NGINX に要求することで解決できます。実際どんな感じにリクエストが処理されるのか図を見てみましょう。

f:id:riseshia:20190305074333p:plain
503 & proxy_next_stream を利用する切り替え戦略

赤い矢印が切り替え完了したエンドポイントに対するリクエストで、青い矢印が切り替えが終わってないエンドポイントに対するリクエストです。 図からもわかるように NGINX は 503 ステータスコードを受け取ったら切り出し元へリトライするだけです。どのリクエストを旧コードベースに流すか決めるのは切り出し先なので、こっちのコードを変更するだけで切り替え作業を完了します。

この方式は upstream の server の backup モードと proxy_next_stream 設定を利用すれば実装が可能です。具体的な実装方法の前にこれらの機能に関して説明します。

NGINX upstream のサーバ状態

まずは NGINX の upstream に対して簡単に説明をしたいと思います。

NGINX の upstream は幾つかのサーバ群を一つのグループとして定義することができ、その内部でよしなにロードバランシングを行うことができます。さらに、グループとして定義した各サーバに対してどういうロードバランシング戦略をとるか、ラウンドロビンならどれくらいの重みでリクエストを流していくかなど、それなりに細かい設定を行うことができます。そしてここにはサーバの状態の扱いも含まれています。

upstream 内部で定義されたサーバの状態は2つ存在します。リクエストを受けられる状態(available)、リクエストを処理できない状態(unavailable)ですね。これはどういう条件で変化するのでしょうか? 答えは server ディレクティブで設定した fail_timeout(デフォルトは 10s) と max_fails(デフォルトは 1) にあります。 サーバに対して max_fails 回リクエストが失敗したら fail_timeout の間、そのサーバを unavailable とします。具体的な例を見てみましょう。

upstream backend {
    server backend1;
    server backend2;
}

このような設定があり、2つのサーバが仲良くリクエストを処理している状態から backend2 が何かしらに理由でリクエスト処理に失敗し、 unavailable 状態になりました。その後の 10秒の間は backend に送られるすべてのリクエストは backend1 が処理します。 ちなみに max_fails=0 にする場合、リクエスト処理に失敗したサーバは unavailable にはならず、常に available 扱いされるようになります。

ここまでの説明で質問が2つくらい浮かび上がると思います。

  • 失敗したリクエストはどう扱うのか?
  • そもそもここでの失敗は何を意味するのか?

失敗したリクエストは利用可能なサーバがあればそこへ再度流されます。これは NGINX の内部の挙動で、ユーザー側からは1リクエストとして認識される、ということを忘れないようにしましょう。もし利用可能なサーバが存在しなければ?バックアップのサーバが存在するのならそれを使います。バックアップは名前からも推測できると思いますが、普段は使われません。

upstream backend {
    server backend1;
    server backend2 backup;
}

このような設定がある場合、正常な状態では backend1 のみにリクエストを流します。もし backend1 が unavailable になるとその時初めて backend2 が利用されるようになります。 backend1 が available 状態に戻ったら backend2 はまた利用されなくなります。

そして NGINX はどのようなレスポンスを失敗として扱うのか。 これに関する回答は proxy_next_upstream (脚注: 使ってるモジュールによっては使える設定が違います。 http://nginx.org/en/docs/http/ngx_http_upstream_module.html#server の max_fails オプションの説明に一覧が乗っているので参考してください)にあります。 この設定からはなにを持って失敗と定義するかを決められます。

デフォルトでは大雑把に言うとサーバにリクエストを流して NGINX がレスポンスを受け取りヘッダーを確認するまでの通信の中、なにか問題が起きれば失敗です。つまり、アプリが側の処理結果ではなく、通信がちゃんとできているかで判定している感じですね。 もちろん設定を変更することで 500、502、503 などのステータスコードを受け取った場合も失敗として扱うことが可能です。例えばサーバが 503 を返した場合を失敗扱いしたいのであれば

server {
  proxy_next_stream http_503;
}

のようにすればいいです。他のオプションや詳しい説明が必要であればドキュメントを参照してください。

503 と proxy_next_stream を組み合わせる

前述した方法を具体的に説明すると

  • 切り出し先で未実装なエンドポイントに対してすべて 503 を返す
  • 手前の NGINX では backup として切り出し元のサーバを指定し、 proxy_next_stream で 503 をリクエスト失敗と判定し、切り出し元にリトライする

ということをすればいいです。コードを見ていきましょう。

切り出し先では実装されてないエンドポイントなら形だけ作り 503 を返すようにします。 Rails であればコントローラに以下のような感じで書けます。

class RecipesController
  prepend_before_action :not_implemented, except: %i[edit]
  def create
    # ...
  end
  def show
    # ...
  end
  def edit
    # 移植完了したアクションでは普通のレスポンスを返せる
  end

  def not_implemented
    head :service_unavailable
  end
end

次は NGINX の設定ですね。

upstream backend {
    server new_backend max_fails=0;
    server old_backend backup;
}
server {
  proxy_next_stream http_503 non_idempotent;

  location / {
    proxy_pass backend;
  }
}

まずは max_fails を 0 にすることで、 new_backend が unavailable 扱いされることなくすべてのリクエストを処理するようにします。 そして proxy_next_stream で 503 ステータスコードを受け取ったら次のサーバにリトライするようにします。 old_backend を backup として設定するのは、リクエストに失敗したときのみ使われるようにするためです。 non_idempotent オプションを使うのは冪等ではないリクエスト(POST、PATCH など)の場合でもリトライを有効にするためです。 *1

ちなみになぜ 503 なのかといえば、

  • アプリケーション側から返す可能性が低い
  • proxy_next_stream でリトライすることができる
  • コードの意味がそれなりに自然に見える

という基準で選択しました。

503 と proxy_next_stream を利用した切り替え戦略の問題点

さて、最初のお気持ちをもう一回思い出してみます。

  • 切り替えにコストを使いたくない
    • head :service_unavailable を削除してデプロイすれば勝手に切り替わるので切り替えにかける追加コストはほぼゼロになりました
  • ロールバックを簡単にしたい
    • 通常のロールバックをするだけなので追加コストはゼロです
  • 1 アクション単位で切り替えたい
    • アクション単位で切り替えられるようになりました

すべての問題点を解決できていますね。ただ全てが便利になるのか?というと違います。例えば、以下のような不便さがありえます。

継続的な切り替えの場合は対象エンドポイント周りの切り替え作業中にはウェブ上のインターフェース(i.e. フォームで渡す引数の名前、ルーティングパスなど)を変えたい場合は慎重になる必要があると思いますが、この戦略の場合は特に切り替えの単位が細かいので、もっと気をつける必要があります。例えばフォームは旧サービスから返すのに、提出は新サービス、という状況もあり得るためですね。 同じ理由で、両サービスのルーティングテーブルは常に同期しておくのをおすすめします。

ちなみにモバれぴの場合、前述したようにアクティブな開発は行われていないため、この制約によるデメリットはありませんでした。

最後にレイテンシーに対する心配もあると思います。移植されてないエンドポイントの場合、 NGINX から新サービスへの 1RTT が無駄になるからですね。しかも Rails のミドルウェアを一周します。モバれぴでは、

  • リクエストの処理時間は ActiveRecord などの IO や、ビューレンダリングが支配的なのでこのように 503 を返すだけなら、そこまでは影響しないはず
  • ある程度のレスポンスの遅れは許容する
  • 移植作業が進むにつれ、その遅れは自然解消する

ということからこの方式で問題ないと判断しました。そして以下がその結果です。

f:id:riseshia:20190305074353p:plain
導入前後のレイテンシーの変化

これはモバれぴの一番前にある ELB の平均レイテンシーのグラフです。 1/17 の昼にてこの戦略のための NGINX 設定変更が行われますが、誤差の範囲に収まってるような印象を受けるので、大抵の場合は問題がないと思っても良いでしょう。

まとめ

この記事ではモバれぴを cookpad_all から分離する中、切り替え作業を継続的かつ小コストで進めるために選択した戦略、そしてそこに至った経験談、それに必要な背景知識などを説明しました。

もしこの話でお台場プロジェクトに興味が湧いたのであればぜひご連絡ください!

*1:non_idempotent を使っていいのか、の質問があると思いますが、この場合起りえる 503 は二種類があり、一つがこちらで意図して返す 503(未実装だよ〜)、新サービスの方から応答がないという本当の意味での 503 ですね。どちらの場合もリソースの処理をしないのでおかしな実装をしないかぎり問題ありません。