Edited at

サーバーを利用するネイティブアプリのためのシームレスなデプロイメント手法の提案


問題


  • サーバーを利用するネイティブアプリの場合、アプリ側のリリースをサーバーのリリースと一致させることが難しい


    • サーバーのリリースとアプリのストアへのリリースのタイミングを可能な限り揃えてもダウンロードできるようになるまでにはユーザーごとのタイムラグがあったりする

    • 新旧クライアントが混ざった状態でアクセスしてくることは避けられない



  • バージョンが混じることで、通常の開発フローではなかなか検出できない不具合が発生し開発者の頭を悩ませてしまう


よくある解決策


  • サーバーアクセス時にバージョンチェックを行い、パスしない場合アクセスを遮断する


    • そのユーザーにとっては、アップデートが落ちてくるまで実質サーバーは停止しているのと同じ



  • 頑張って少なくとも1つ前のバージョンのクライアントに対する互換性を維持しながらサーバーを更新する


    • だいたいはうまくいくが、無駄な工夫を強いられるのはマイナス。また互換性を維持できないけど修正しなくてはならないケースもある



  • インテリジェントなプロキシ(eg. envoy)を前面においてヘッダーなどをみてバックエンドサービスへのルーティングを制御する


    • 悪くないが、envoyのクラスタを運用するような暇があるのは大きな会社だけ。マネージドなサービスなどあれば話は変わってくる




従来の解決策の問題


  • 単純な手法ではユーザーにとってのサービスの停止を伴うか、バージョン違いによる不具合の発生を許容する必要が出てきてしまう


    • シームレスにサービスのコンポーネントの更新ができない



  • 自前でインテリジェントなプロキシのクラスタを管理するのはどのような組織でもできるソリューションではない


    • 現状のIaaS大手が提供するマネージドサービスだけで実現できるべき




提案手法


インクリメンタルブルーグリーンデプロイメント(IBGD)


  • ブルーグリーンデプロイメントと似ているが、新しいバージョンのデプロイを行う時に新しくクラウドリソースを生成してデプロイを行う

  • デプロイ後、旧サーバーのためのリソースを残し、明示的に消すまでアクセスできる状態とする

  • クライアントのアクセス先は、自身のバージョンに従ってmetaエンドポイントから返されるURLで制御する


    • 新バージョンのクライアントは新しいサービスへ、旧バージョンのクライアントは古いサービスにアクセスする

    • metaエンドポイントのみがクライアントに埋め込まれているURLである

    • これにより、例えば、appleで審査中のクライアントのみがprodの新しいバージョンにアクセスする、といったことが既存のクライアントに影響のない形で実現可能になる



  • ある程度トラフィックが新バージョンへ移った(99%とか)段階で旧バージョンのリソースを削除する


実装


metaデータの構造


  • サービスを提供するクライアントとクライアントがアクセスするエンドポイント毎にバージョンを定義する


    • バージョンは単調増加な数字



  • 全コンポーネントのバージョンをコンポーネント名をキーとする連想配列に格納したものをVersionMapと呼ぶ



    • VersionMapはその組み合わせで正常に動作するコンポーネントのバージョンの集合ということ



 例)

{
"Android": 15, //android クライアントバージョン
"iOS": 16, //iOS クライアントバージョン
"game": 10, //gameの基本的行動に対するAPIのエンドポイントのバージョン
"pvp": 3, //pvp機能に関するAPIのエンドポイントのバージョン
"guild": 6, //guild機能に関するAPIのエンドポイントのバージョン
"assetbundle": 3, // アセットバンドルのエンドポイントのバージョン
"masterdata": 20, // マスターデータのエンドポイントのバージョン
}


  • 今リリースされているVersionMap (クライアントのバージョンベース)とその前後のVesionMapを組み合わせたものがmetadata

{

"next": VersionMap //次期バージョンの組み合わせ
"curr": VersionMap //現バージョンの組み合わせ
"prev": VersionMap //1つ前のバージョンの組み合わせ
}


metaエンドポイント


  • クライアントのバージョンを受け取ってmetaデータを返す独立したエンドポイント


    • 実際にはバージョンからURLを生成して返す(そのロジックをクライアントに置かないため)



  • cloud functionsのようなserverlessで実装される

  • クライアントは、iOS/Androidのようなプラットフォームとそのバージョンをリクエストに送る


    • 送られてきたバージョンがcurrに含まれるバージョンと同じ、大きい、小さいに応じてcurr, next, prevに対応したメタデータがクライアントへ返される




サーバーの更新


  • 新しいエンドポイントを古いエンドポイントを消さずにデプロイする


    • その内容でmetaデータのnextを書き換えておく



  • この段階では内部で使われている新しいクライアントを持っているユーザーだけが新しいエンドポイントにアクセスできる


クライアントの更新準備


  • iOSなど、審査が必要なクライアントであれば、それを行う。審査のクライアントは新しいクライアントであるため新しいエンドポイントにアクセスできる


metaエンドポイントの更新


  • nextに含まれているバージョンの全てのクライアントがリリースした(注:rolling updateなどであれば、直ちに全員が利用可能にはならないが、rolling updateを開始した時点でリリースとみなす)後に以下のような更新を行う(cascadeと呼んでいる)


    • currをprevにコピーし、nextをcurrにコピーする



  • 更新後は新しいクライアントを使っているユーザーは先ほどサーバーの更新でデプロイされたエンドポイントに、まだアップデートが落ちてきていないユーザーは以前のエンドポイントにアクセスする


    • ユーザーのクライアントの更新が進めば旧エンドポイントにアクセスするユーザーは減っていく




旧エンドポイントの削除


  • ほとんどのトラフィックが移行し、旧エンドポイントが不要になったら削除する

  • この時点でまだ旧クライアントを利用しているユーザーはエラーにせざるを得ない(404とか)


具体的なワークフローの例


検討するケース


  • circle CI利用

  • developブランチで次のリリースの開発をしている

  • stageでQA, prodにリリース


    • それぞれ同名のブランチにpushをするとcircle CIが動き出す




巨大なmonorepoに全てのコンポーネントが格納されている(googleスタイル。個人的にはオススメ)


  • レポジトリのどこかにmetadataを直接ファイルの形で格納しておく


  1. 確認済みのstageブランチをprodにpushする

  2. circle CIのjobはgit diffでパスのマッチングをして、必要なコンポーネントのデプロイを行う。それぞれのコンポーネントのデプロイのためのスクリプトはレポジトリに存在するmetadataからバージョンを読み込み、デプロイが正常に完了したらmetadataのnextのところの自分のバージョンを増やしておく


    • これでnextのVersionMapは正常なものになっているはず(確認したのと同じバージョンの組み合わせのはずなため)



  3. nextを更新したmetadataをデプロイする

  4. cascadeしたmetadataを自動でPRに出しておく(develop => stageの場合はバグってもいいのでこれもそのままデプロイする)

  5. もしクライアントの更新があれば更新作業が完了するまで待ってから、最終的にprodで確認するなりして4をマージ

  6. 5.のマージの時点で旧バージョンのエンドポイントを削除するためのPRを自動的に生成する

  7. 十分旧エンドポイントへのトラフィックがなくなったら6.をマージしてエンドポイントを削除


サービスを構成するコンポーネントがいくつかのレポジトリに分散されて格納されている


  • このケースの場合nextのVersionMap、つまりその組み合わせで正常に動作するコンポーネントのバージョンの集合の構成をする際に問題がおきがち。

  • それを避けるため、metadataをファイルの形で格納するレポジトリを作り、サービスを構成するコンポーネントを含むレポジトリを全てsubmoduleとして含めておく。基本的にこのレポジトリの更新をトリガーにしてデプロイを行う


  1. サービスを構成するコンポーネントを含むレポジトリのうち、更新があったもの全てのstageブランチのコミットハッシュで、metadataレポジトリのstageブランチのサブモジュールのバージョンを更新する。

  2. metadataレポジトリのstageをprodにpushする

  3. circle CIのjobはsubmoduleのバージョンが変更されたかに応じてサービスを構成するコンポーネントを含むレポジトリを更新しdeployする

  4. 以下はmonorepoの場合の3.以降と同様


この手法の注意点


  • データベースは1つ


    • アクセスのある可能性のある全てのサーバーのバージョンで問題なく動かなくてはならない

    • クライアントとサーバーの下位互換を維持する場合と違って、アクセスパターンがかなり限定されるため、経験上はほとんどの場合うまくやっていける




データベーススキーマ変更はonlineで行う必要がある


  • mysqlならpt-online-schema-changeのようなツール

  • あるいはspannerを使うとか


データベーススキーマ変更は下位互換性がマスト


  • ちゃんと変更のスケジュールを考える必要がある


    • あるいは不定形のスキーマを積極的に使う(JSON/protobufなど)



  • 例: カラムの削除


    1. コードからアクセス部分を消してdeploy

    2. アクセスするコードを動かしているバージョンがなくなった時点でカラムの削除をdeploy