Ruby on Rails 5.1から6.1へのバージョンアップをカナリアリリースしました

こんにちは、スタメンのエンジニア、津田です。最近、弊社のサービスで、Ruby on Rails を 5.1 から 6.1 へバージョンアップした際、社内ユーザーからのリクエストのみを6.1環境へ送るカナリアリリースを実施したため、対応をまとめました。

今回は、Railsバージョンだけではなく、同時に以下のような変更を行いました。

  • EC2インスタンス上で動いていたRailsアプリケーションを、ECS上のDockerコンテナへ移行
  • capistranoによるデプロイから、ecspresso、CodeDeployを組み合わせたコンテナのデプロイへ移行

Railsのバージョンアップは徐々に行うのが本来だと思うのですが、数年間実施できておらず、メジャーバージョンはじめ、多くの更新が溜まっていました。E2E含め自動テストはかなり整備していますが、フレームワークのバージョンアップとなれば、どうしても手動で確認が必要な部分も残っています。アプリケーション全体の再確認をやるのであれば、コンテナ移行のように、アプリケーション全体の確認が必要なリリースも一度にまとめてしまいたい、ということで、かなり大掛かりな変更になってしまいました。

ただ、変更点が多いため、できる限り安全に、いつでも切り戻しを行えるよう、EC2上の Rails 5.1 と、ECS上の Rails 6.1 を並行稼動する期間を設け、各種の確認が済んでから切り替える、という手順を踏んでいます。

並行稼働中

並行稼働は一ヶ月ほど、以下の図のような形式で行いました。

f:id:TSUD-Kyosuke:20211108143830p:plain
構成図

一つのAWSアカウント内に、異なるRailsのバージョンを利用した、同じアプリケーションを2つ並行稼動させています。Railsのバージョンが異なる以外は、基本的に同じ挙動をするようにしています。

アプリケーションで稼働していたRailsのプロセスは4種類ありました。Webアプリケーション本体(Puma)、非同期処理のSidekiq, delayed_job、EC2インスタンス上のcronから呼び出されるrakeタスクです。

通常のユーザーへ影響を出すこと無く、社内ユーザーのリクエストから発生した処理のみを検証の対象にするため、以下のように工夫しました。

Puma

すべての入り口となるPumaへのリクエスト振り分けは、Application Load Balancerのリスナールールで行いました。

  1. 確認の最初期は、HTTPヘッダーに特定の文字列が含まれている場合のみ、ECS環境へ振り分ける
  2. 2週間ほど、自社のオフィスのIPからきたリクエストのみECS環境へ振り分ける

ある程度のユーザー数もいるため、負荷や、必要なリソースの確認もここで行っています。

Sidekiq, delayed_job

Sidekiq, delayed_jobは、非同期ジョブの発行元となるpumaのリクエストが、社内のユーザーである場合のみ、ECS環境での動くようにしたかったため、以下のように対応しました。

Sidekiqは、ジョブの受け渡しにredisを利用しているため、ECS環境へデプロイするソースでは使用するRedisのサーバーを変更しました。ECS環境では、ジョブを登録する側(基本的にPuma上のアプリケーション)、ジョブを実行する側(sidekiqのワーカー)も、新規に設置したredisを参照します。

delayed_jobはテーブルにJobが作成されるため、Delayed::Backend::ActiveRecord::Job.table_nameを置き換え、EC2環境とECS環境で別のテーブルをジョブキューとして使用するようにしました。

Cron

cronで実行されているジョブに関しては、重複して実行することが出来ないものが多く、そもそも一つのジョブが処理する範囲がユーザー単位ではないため、完全な並行稼働は出来ませんでした。一部の、重複して実行しても問題ないジョブのみ、両環境で並行稼働して挙動を確認しています。

また、ECS環境へ移行するのに伴い、常時起動するEC2インスタンスを全廃しています。OSのcronとしては実行できないため、いままで利用していたwhenever gemの設定ファイルを流用できる、elastic_wheneverを一部改造して利用しました。

elastic_wheneverはCloudwatch EventsからECSのタスクを起動するのですが、ECSのCapacity ProviderにASGProviderを指定しているにもかかわらず、タスクがPendingにならず、無言で起動を諦めてしまうという問題に遭遇しました。こちらはAWSのサポートの方に相談しても原因がつかめず、Step Functions経由でリトライしてみたらどうか、というアドバイスを頂いたため、Step Functionsを呼び出すようにしています。Step Functions経由で呼び出すと、ちゃんとクラスタのオートスケールを待ってタスクが起動するため、結局リトライ自体は入れませんでしたが、各タスク起動前後に共通の処理を割り込ませたりもできるため、Step Functions経由にしたのは正解だったと思います。

アプリケーションデプロイの課題

アプリケーションデプロイは平日であれば、日に数回は行っています。Rails 6.1のアプリケーションとRails 5.1のアプリケーションは同じ機能を持つようにしましたが、コードとしては差分があり、5.1に機能が追加された際は、6.1にマージし、テストしてからデプロイする必要があるため、並行稼動に際しての問題になりました。

現環境の更新から、新環境の更新までどうしても、タイムラグが発生するため、現行の5.1をリリースした場合はALBから新環境にリクエストを流すのを自動的に停止し、新環境の6.1が現環境の5.1に追いついてから、リクエストの振り分けを再開するようにしました。ただ、マージ作業を毎回行うのは非常な手間なので、並行稼働期間中、夕方以降はリリースを基本的に停止し、その間にマージ、新環境へのデプロイを行う運用としました。

共有されるリソースへの対応

データベースはAurora MySQLを利用しており、現環境、新環境で共有しています。また、redisはsidekiq用以外にキャッシュ、ユーザーセッション用に存在しており、こちらも共有しています。基本的に同じデータを同時に両環境から扱っても問題なかったのですが、いくつか、そのままでは事前に検証したstaging環境で問題が発生したため、以下のような対応を行っています。

Aurora MySQLのクエリキャッシュ

これは正確な原因がつかめなかったのですが、クエリキャッシュが有効な状態で、Rails 5.1のアプリケーションと、Rails 6.1のアプリケーションからAurora MySQLへ全く同じSQLを実行すると、クエリキャッシュが原因でエラーが発生します。

おそらく、mysql2 gemのこのissueでレポートされているうちのいくつかと同じ問題だと思うのですが、解決できなかったため、パフォーマンスに問題が出ないのを確認した上で、Aurora MySQLのquery_cache_sizeを0としました。

rackのバージョンを固定

Rails 4.2.x から 5.0.x にアップグレードする際にカナリアリリースすると session が取得できなくなる不具合を回避するで説明されている問題が発生したため、Gemのバージョンを一時的に固定して回避しています。

CSRFトークンが異なる

Rails 6.1から、CSRFトークンのエンコードが変更されました。Upgrade-safe URL-safe CSRF tokensによって、6.0以前のCSRFトークンを持ったユーザーが、6.1環境へPOSTしてもエラーとはならないのですが、6.1以降のCSRFトークンを持ったユーザーが6.0以前の環境へPOSTすると、CSRF検証に失敗します。通常のアップグレードではこういった状況は発生しないのですが、同じユーザーからのリクエストがバージョンの異なる新・旧環境にちらばるようなカナリアリリースでは発生しえます。今回は、同じユーザーであれば基本的に同じバージョンのサーバーでリクエストが処理されるため、こちらは回避できました。リクエストの何%が新環境で処理される、というような設定だと問題になっていたと思われます。

まとめ

結局、この並行稼働期間中に発見されたアプリケーションの問題はほとんどありませんでした。しかし、ある程度の期間並行稼働することで、アプリケーションのproduction環境での挙動や、最終的な切り替えの手順、切り戻しの手順をはっきり把握でき、安心・安全のリリースが行えたのは良かったと思います。