こんにちは、株式会社ラクスで先行技術検証や、ビジネス部門に技術情報を提供する取り組みを行っている技術推進課に所属している鈴木(@moomooya)です。
ラクスの開発部ではこれまで社内で利用していなかった技術要素を自社の開発に適合するか検証し、ビジネス要求に対して迅速に応えられるようにそなえる 「
2020年度は通年で「無停止リリース」について取り組んでいるので、途中ではありますが紹介したいと思います。
今までの記事はかみせんカテゴリからどうぞ。 tech-blog.rakus.co.jp
今回の目標
弊社のサービスはBtoBのサービスであることもあり、多くのユーザーが営業時間外となる夜間に計画停止を行ってのリリースを行っていました。 しかし今後サービス規模の増大にともない、極力停止しないリリースが求められることや、エンジニアの夜間作業を避けるために、リリース時も極力サービスを止めない構成を模索します。
「無停止リリース」の定義
完全な無停止を目指すと大規模な分散システムにするなどコスト面で厳しい構成になると考えられたので、あくまで「ユーザーの利便性を極力損なわずにサービスを提供し続ける」という方針で検討しました。
具体的には以下のような要件を挙げています。
- ユーザビリティの確保
- ログインセッションが切られることなくログイン状態を維持できる
- 操作内容が「なかったこと」にならないようにする
- システム的な完全無停止は目的にしない
- 内部的な停止は局所化極小化を目指す
- 影響がでる部分についてUI側でフォロー可能な構成を目指す
- プログレスバーを出せるようにする
- 処理完了ではなく処理受付としてレスポンスを返す
- など
考慮するポイント
「ユーザビリティを低下させない」要件をもう少し噛み砕くと以下のような要素にまとめられると考えました。
- セッション管理
- リリース中もログインセッションが切断されない
- 古いバージョンのログインユーザーのみ強制ログアウト可能
- アプリケーション構成
- リリース中も処理トランザクションが正常終了する
- リリース前後のサーバーが混在している状態でいずれの環境にアクセスしていても正常に処理される
- 入力パラメータの過不足がある場合に異常な更新が行われない
- DB運用
- どのタイミングでもクエリが消失せずに整合性が保たれること
- どのタイミングでもDBにアクセスできること
セッション管理
セッション管理において
- リリース中もログインセッションが切断されない
- 一定期間経過後に古いバージョンのログインユーザーのみ強制ログアウト可能
という要件を満たす方法を考えました。
リリース中のログインセッション維持
まず「リリース中にログインセッションが切断されない」ということを逆に考えると、なぜリリース作業においてログインセッションが途切れるのかという話になります。
もっともシンプルなログインセッションの保持方法はアプリケーションサーバーに保持させることですが、この場合は保持しているアプリケーションサーバーを再起動してしまうとログインセッションが失われます。
そこでログインセッションの保持をアプリケーションサーバーの外に出す必要があります。データベースサーバーに保持することで
- アプリケーションサーバーの再起動の影響を受けない
- データベースサーバーの再起動でも(正副サーバーのデータを同期しているので)消失しない
とリリース中のログインセッション維持は実現できそうです。
ただ実現は出来るものの、ログインセッションの破棄処理をアプリケーションに実装する必要が生まれたり、頻繁なデータベースアクセスが発生してしまうという課題が生まれます。
これらを解決するためにRedisをセッションサーバーとして採用することを検討しました。クラウドネイティブな構成では(AWSだとElastiCacheを利用して)比較的よく採用される構成だと思います。
Redisを採用するモチベーションとしては以下の点が挙げられます。
- インメモリデータベースなので小さなデータを頻繁にアクセスする用途に最適
- key-value型のDBだけどログインセッションの維持に使うなら十分
- レコードにTTL: Time To Live(有効期限)を設定することで自動破棄ができる
- TTLは更新も可能なので「最終アクセスから n 秒」という指定が容易
- DBで保持したときのように冗長化も可能
古いバージョンのログインユーザーのみ強制ログアウト可能
バージョンが混在しても利用可能にはするものの、いつまでも古いバージョンからのアクセスを有効にし続けるわけにもいきません。いずれは古いバージョンのログインセッションを破棄する必要があります。ただしこのときも新しいログインセッションには影響を出したくありません。
これを実現するために「ログイン処理をどのバージョンで行ったか」を記録することで実現できると考えました。セッションデータにアプリケーションバージョンのデータを残すのです。
"(token_id)": { "user_id": xxxxxxxxx, "app_ver": 1.0 }
のようなイメージです。
これによりセッションサーバーから{ "app_ver": 1.0 }
のログインセッションを削除するなどして、指定したバージョンでログインセッションを維持しているユーザーのみを強制ログアウト可能にします。
アプリケーション構成
アプリケーション構成において
- リリース中も処理トランザクションが正常終了する
- リリース前後のサーバーが混在している状態でいずれの環境にアクセスしていても正常に処理される
- 入力パラメータの過不足がある場合に異常な更新が行われない
という要件を満たす方法を考えました。
リリース中の処理トランザクションの正常終了
こちらは言い換えると処理中トランザクションの完了を待ってからリリース処理をしてあげればいいわけなので、リクエスト振り分けを止めて待ってあげればいいです。
HTTP Proxyで振り分け制御をするわけですが、リリースの開始判断は人間がやるとしても振り分け制御自体は自動でやりたいです。実現するための機能としては振り分け先ノードのステータスを判断するActive Health Checkと、リクエストが完了するまで切断しないDraining Modeを使ってあげれば実現できます。
当初この部分に nginx を利用しようとしていましたが、Active Health CheckとDraining Modeがnginx+(有償版)でないと使えないということがわかりました。システム構成的にコストが見合わなかった1ので、別のソリューションを探すことにしました。そこでApacheを確認してみると無償版でも利用できるということだったので、Apacheを採用しています。
なおApacheはnginxの普及以降「重い」という印象がありましたが、Apache 2.4系を評価し直してみると既に改善されているようだったことも今回の採用につながっています。
リリース前後のサーバーが混在している状態でいずれの環境にアクセスしていても正常に処理される
APIバージョンを複数サポートするという話ですが、試行錯誤した結果サンプル実装では以下のようなソースコードの構成にしています。
/ |--app.py # Webアプリ本体。`v1/api.py`を読み込み |--README.md |--requirements.txt |--v1/ # APIバージョン:1 のソースコードディレクトリ | | # v3 を作るときに削除される想定 | |--__init__.py | |--api.py # エンドポイントを定義 | |--assets.py # 実処理モジュール | |--auth.py # 実処理モジュール | |--users.py # 実処理モジュール |--v2/ # APIバージョン:2 を作るときは v1 をコピーして改修
ウェブアプリケーションフレームワークに用意されている、パスを指定してルーターモジュールを読み込む機能(Path Groupとか呼ばれる機能。FlaskだとBlueprint)でバージョンごとのモジュールを読み込みます。
# app.py #... # バージョンごとのAPI読込 from v1.api import api as api_v1 app.register_blueprint(api_v1, url_prefix='/api/v1') # ...
# v1/api.py from flask import Flask, Blueprint, jsonify # 実処理モジュールの読み込み from . import auth from . import users from . import assets api = Blueprint('api', __name__) # エンドポイントの定義 @api.route('/signup', methods=['POST']) def post_signup(): return auth.post_signup() # ...
APIバージョンが変わる際にはまるごとコピーして別バージョンとして読み込む形になるので重複コードが発生しますが、今回は2バージョン(最新と1世代前)までしかサポートしない想定だったので許容しています。無理に重複コードをなくすことよりも、2世代前のサポートが外しにくくなることを避けることを優先しました。
入力パラメータの過不足がある場合に異常な更新が行われない
無停止でアップデートを行うと、APIリクエストに必要なパラメータが異なったバージョンのアプリケーションにWebクライアントが接続されるケースが出てきます。この場合にも不正なデータ更新は行われないようにしなければなりません。
必須パラメータが不足するような組み合わせであればエラーになるため、データの更新は行われません。エラー後に最新バージョンの取得と再ログインを促せれば操作を続けられます。 エラーも発生させたくない場合があると思いますが、その場合はAPIバージョンを変えて2バージョン受け付けられる状態にすることになります。このとき2バージョンの差異をアプリケーションとDBで吸収しなければなりませんが、どんな場合に吸収できて、どんな場合に吸収できないのかは今後検証していきたいと思います。
DB運用
DB運用について
- どのタイミングでもクエリが消失せずに整合性が保たれること
- どのタイミングでもDBにアクセスできること
- 再起動中やフェールオーバー中もアクセスできること
- ブロックすることなくDDL操作を行えること
という要件を満たす方法を検討しました。
どのタイミングでもクエリが消失せずに整合性が保たれること
こちらに関しては新たなクエリリクエストだけを止めて処理中のクエリが完了するまでリリース担当エンジニアが見て判断する方針にしました。 現状の運用だと、リリースの際に完全に無人であることは考えにくく機械的に検知する必要性を感じなかったためにこの方針にしています。
新たなクエリリクエストの停止についてはDBプロキシであるMariaDB MaxScale(以下、MaxScale)を利用して振り分け処理を行う設計にしています。
MaxScaleにはフェールオーバー時にもアプリケーション側からの再試行を伴わずに、トランザクションを喪失しないよう遅延、再試行を行う機能があります。
どのタイミングでもDBにアクセスできること
こちらは想定している条件下では「書き込み」に関しては短時間の停止を避けられませんでしたが、片系が再起動中などでフェールオーバーが発生している場合などでも「読み込み」は常時可能になるよう設計を進めています。
MaxScaleによって参照クエリと更新クエリの分離、DBサーバーノードの管理・冗長化を行います。 冗長化されたDBサーバークラスタを構成することで、無停止で実行できないSQL操作やDBMS自体のアップデートがあった場合にもローリングアップデートで実行し、ダウンタイムを極力短縮する予定です。 この際「書き込み」はマスタのフェールオーバー中だけ実行できなくなりますが、「読み込み」は停止することなく常に実行できる見込みです。
また、DBに関してはDDLでどこまでロックされるかもシステム全体のアクセス可否に影響してきます。
MariaDBやMySQLは同じOSS-DBであるPostgreSQLと比較してオンラインDDLの対応で先行しています。PostgreSQLではカラム追加などのテーブル定義の変更操作はDMLを(SELECTでさえ)阻害してしまいますが、MariaDB/MySQLは多くの操作がオンラインDDLに対応しているため、テーブル定義の変更を伴う場合でもアクセスできることが期待できます。ただし、この手の機能は往々にして運用上の制約がつきものなので引き続き実際に動作させて検証していきたいと思います。
まとめ
- セッションサーバーにRedisを採用して外部化
- HTTPプロキシにApacheを採用
- アプリケーションは最大2バージョンに対応
- DBMSはMariaDBを採用し、MaxScaleでクラスタ化
- MariaDBのオンラインDDLを活用
今後の計画
ここまでのお話はそれぞれの要素のカタログスペックを元にした設計です。これらが実際に期待した通りの挙動を実現してくれるのかどうかについては今年度後半で検証を進めていきたいと思います(検証結果も2021年3月ごろにブログで共有予定です)。
- 環境の構築
- テスト項目、テスト方法の検討
- 本当に無停止でリリースできるか検証
- (余力があれば)PostgreSQLの場合にどこまでできるか検証
エンジニア中途採用サイト
ラクスでは、エンジニア・デザイナーの中途採用を積極的に行っております!
ご興味ありましたら是非ご確認をお願いします。
https://career-recruit.rakus.co.jp/career_engineer/カジュアル面談お申込みフォーム
どの職種に応募すれば良いかわからないという方は、カジュアル面談も随時行っております。
以下フォームよりお申込みください。
forms.gleイベント情報
会社の雰囲気を知りたい方は、毎週開催しているイベントにご参加ください! rakus.connpass.com
-
1ノード年間10万円。ノード数が多くなりがちなので厳しい料金体系でした。↩