クラウドワークスのエンジニアの森田(@minamijoyo)です。
ついにRails5がリリースされましたね。今日はRails5じゃないですけど、Rails3/4並行稼働させた話をしようと思います。Railsバージョンアップを検討している方々の参考になれば幸いです。
はじめに
去る2016/03/28 「Rails Upgrade Casual Talks」というイベントでRails3/4並行稼働させる仕組みを作ってる話をしました。 イベントの模様はエンジニアブログにきびたん(@ctokoro_me)がまとめてくれてるのでこちらを参照して下さい。
上記のイベントで発表した内容はこちらです↓
その後、並行稼働させつつ業務機能ごとに段階的にアップグレードを行い、無事にすべての切り替えが終わったので、 Rails3/4並行稼働の仕組みのおさらいと、実際にやってみて良かったこと悪かったことなどを共有します。
イベントでは時間の都合で、URL振り分けをするnginxのリバプロ層のところの説明をだいぶ端折ったので、この記事ではそのあたりの補足説明を多めにしようかと思います。
やったこと
なぜ並行稼動させるのか
そもそもなぜ並行稼働させたいのか?一言でいうと「小さくリリースしていきたい」からです。
CrowdWorksはWebサービスではありますが、アプリケーションの構造としては業務系システムに近く、お仕事マッチング以外にも、お仕事のプラットフォームとして様々な機能を提供しています。
一発リリースせずに、ひと手間をかけて並行稼動させるかどうかは、対象とするシステムの規模や特性、組織の方針などによって異なると思います。
以降では並行稼働させるために、具体的にどのような仕組みで実現したのかについて書いていきます。
Rails3/4並行稼働の仕組み
Gemfileの共存
Rails3/4で並行稼動させるためにコードのブランチが分かれると、差分が大きくなりすぎてコンフリクト解消が辛くなるので、基本的にバージョンアップ対応の修正はすべてmasterブランチに入れていく方針としました。これを実現するためには、複数バージョンのGemfileを共存させる必要があります。
具体的にはGemfileを複数用意して環境変数 BUNDLE_GEMFILE
で制御します。 まずリポジトリ直下のデフォルトの Gemfile
はRails3用のファイルとしてそのまま使い、 別途 gemfiles/rails4.gemfiles
というファイルを作って、以下のようにデフォルトのファイルを読み込んだ上で、Rails3/4の差分を記載します。
# Rails3用のGemfileを読み込み eval_gemfile File.expand_path(File.join(File.dirname(__FILE__), '../Gemfile')) # Rails3でしか使わないものをリストから削除 ignored_gems = %w( rails … ) dependencies.delete_if do |g| # Rails 4では:assetsグループは廃止される ignored_gems.include?(g.name) || g.groups.include?(:assets) end # git リポジトリを指定しているものはsourceからも削除 ignored_git_source_uris = %w( git://github.com/xxxxx/xxxxx.git ) @sources.git_sources.delete_if do |source| ignored_git_source_uris.include?(source.uri) end # Rails4でしか使わないgemをリストに追加 gem 'rails', '~> 4.2.6’ …
このようにRailsバージョンに合わせて複数のGemfileを用意して、実行時に環境変数 BUNDLE_GEMFILE
でどちらを読み込むかを切り替えできるようにします。
リクエストの振り分け
次にリクエストの振り分けについて説明します。
システム構成の概要
並行稼働時のリクエストの振り分けは、Rails3/4用のサーバ群をそれぞれ用意し、前段にnginxによるリバプロ層を追加してURLで振り分けする構成としました。 説明を簡略化するため多少省略していますが、システム構成の概要は以下のようなイメージです。
また、URLでの振り分けは、移行対象のパスを完全に10:0でRails4に振り分けるのではなく、9:1ぐらいの割合で振り分けて、一部Rails3にトラフィックが残るようにしました。 この意図は、問題が発生した際に、両バージョンで発生するかどうかの切り分けの用途や、もしRails3しか発生しない潜在的な問題があると、切り戻し作業自体が二次災害を引き起こすリスクがあると考えたからです。
nginx.confの例
システム構成図で全体的なイメージは掴めたかと思いますので、次に具体的なnginxの設定を見ていきましょう。 説明のため若干簡略化していますが、nginx.confのイメージは以下のようになります。
URLを正規表現マッチさせてRails3/4適切なupstreamを選択し、upstreamの設定でロードバランスやフェールオーバの制御などをします。
http { resolver xxx.xxx.xxx.xxx valid=5s; upstream rails4 { server unix:/var/run/nginx_rails4.sock weight=9 max_fails=1 fail_timeout=60; server unix:/var/run/nginx_rails3.sock weight=1; } upstream rails3 { server unix:/var/run/nginx_rails3.sock; } upstream rails4_only { server unix:/var/run/nginx_rails4.sock; } upstream rails3_only { server unix:/var/run/nginx_rails3.sock; } server { listen xxxx; server_name example.com; proxy_next_upstream error timeout http_502 http_503 http_504; set_real_ip_from xxx.xxx.xxx.xxx/xx; real_ip_header X-Forwarded-For; proxy_set_header X-Forwarded-Scheme $http_x_forwarded_proto; proxy_set_header Host $host; ... location / { if ($http_x_backend_version = "3") { proxy_pass http://rails3_only; break; } if ($http_x_backend_version = "4") { proxy_pass http://rails4_only; break; } proxy_pass http://rails3; } location ~ ^/hoge/ { if ($http_x_backend_version = "3") { proxy_pass http://rails3_only; break; } if ($http_x_backend_version = "4") { proxy_pass http://rails4_only; break; } proxy_pass http://rails4; } } server { listen xxxx; server_name assets3.example.com; set_real_ip_from xxx.xxx.xxx.xxx/xx; real_ip_header X-Forwarded-For; ... location ~ ^/assets/ { proxy_pass http://rails3_only; break; } } server { listen xxxx; server_name assets4.example.com; set_real_ip_from xxx.xxx.xxx.xxx/xx; real_ip_header X-Forwarded-For; ... location ~ ^/assets/ { proxy_pass http://rails4_only; break; } } server { listen unix:/var/run/nginx_rails3.sock; server_name example.com assets3.example.com; set_real_ip_from xxx.xxx.xxx.xxx/xx; set_real_ip_from unix:; real_ip_header X-Forwarded-For; proxy_set_header X-Forwarded-Scheme $http_x_forwarded_proto; proxy_set_header Host $host; set $elb_rails3 "internal-lb-backend3-XXXXXXXXX.ap-northeast-1.elb.amazonaws.com"; ... location / { proxy_pass http://$elb_rails3:80; } } server { listen unix:/var/run/nginx_rails4.sock; server_name example.com assets4.example.com; set_real_ip_from xxx.xxx.xxx.xxx/xx; set_real_ip_from unix:; real_ip_header X-Forwarded-For; proxy_set_header X-Forwarded-Scheme $http_x_forwarded_proto; proxy_set_header Host $host; set $elb_rails4 "internal-lb-backend4-XXXXXXXXX.ap-northeast-1.elb.amazonaws.com"; ... location / { proxy_pass http://$elb_rails4:80; } } }
要素ごとに、ポイントを補足説明していきます。
負荷分散とフェールオーバー
まずhttpコンテキストですが、rails3/4用のupstreamにunixドメインソケットで接続する設定をそれぞれ定義しています。
http { resolver xxx.xxx.xxx.xxx valid=5s; upstream rails4 { server unix:/var/run/nginx_rails4.sock weight=9 max_fails=1 fail_timeout=60; server unix:/var/run/nginx_rails3.sock weight=1; } upstream rails3 { server unix:/var/run/nginx_rails3.sock; } upstream rails4_only { server unix:/var/run/nginx_rails4.sock; } upstream rails3_only { server unix:/var/run/nginx_rails3.sock; }
一旦ローカルのUnixドメインソケットを経由したり、冒頭でresolverを定義しているのは、バックエンドのELBのDNS解決を行うための設定です。これについては後述します。
また、アセットの配信やデバッグ目的で必ず固定で振り分けるための rails3_only / rails4_only のupstreamも別途定義しています。
rails4用のupstreamはweightパラメータでロードバランスさせ、接続エラーなどの時は、rails3側にフェールオーバさせるような構成とします。 フェールオーバさせるためには後述の proxy_next_upstream の設定と組み合わせて使います。
次にURLを見て振り分けするためのserverコンテキストです。
server { listen xxxx; server_name example.com; proxy_next_upstream error timeout http_502 http_503 http_504; set_real_ip_from xxx.xxx.xxx.xxx/xx; real_ip_header X-Forwarded-For; proxy_set_header X-Forwarded-Scheme $http_x_forwarded_proto; proxy_set_header Host $host;
proxy_next_upstream
は、proxy先のserverがエラーを返した場合に、次のupstreamにフェールオーバさせるための設定で、フェールオーバさせる条件を設定しています。 接続エラーの他に特定のHTTPステータスコードなどでもエラーハンドリングが可能です。
ELB多段構成でのX-Forwarded-Protoヘッダの維持
上記のserverコンテキストでは proxy_set_header
で意図的に X-Forwarded-Proto
ヘッダを X-Forwarded-Scheme
にセットしなおしています。
X-Forwarded-Proto
ヘッダは、ELBでhttpsを復号化する場合、バックエンドのサーバにはhttpで通信しますが、これが元々https/httpのどちらだったのかを判別するためにELBがセットしてくれるHTTPヘッダです。
しかしながら、今回のようにELBを2段構成で使う場合、2段目のELBにはhttpでアクセスするため、2段目のELBで X-Forwarded-Proto
ヘッダが上書きされてしまい、Rails側で元々どちらのリクエストだったのかが判別できなくなってしまいます。RailsはというかRackは X-Forwarded-Proto
と X-Forwarded-Scheme
のどちらも見てくれるので、このように X-Forwarded-Scheme
にセットしておくことで元のリクエストがhttps/httpのどちらだったのかをアプリケーション側で判別できるようにしています。
URLの振り分けルール
URLの振り分けルールは、基本的に正規表現のマッチパターンを書いていきます。
location / { if ($http_x_backend_version = "3") { proxy_pass http://rails3_only; break; } if ($http_x_backend_version = "4") { proxy_pass http://rails4_only; break; } proxy_pass http://rails3; } location ~ ^/hoge/ { if ($http_x_backend_version = "3") { proxy_pass http://rails3_only; break; } if ($http_x_backend_version = "4") { proxy_pass http://rails4_only; break; } proxy_pass http://rails4; }
この例では ^/hoge/
にマッチした場合は、rails4に、それ以外のデフォルトは rails3にプロキシします。 ただし、デバッグ目的で、カスタムHTTPヘッダ(この例では X-BACKEND-VERSION
)を付与すると、明示的に好きな方にプロキシできるように仕込んであります。
if文をlocationの外に出すとnginxの設定としてうまく動かなかったので、若干冗長な書き方になっていますが、実際にはURLの振り分けルールが大量にあるので、 振り分けルールは管理上nginx.confにベタ書きせずに、振り分けルールをChefのtemplate(=ERB)で雛形を作って、nginx.confを機械的に生成するようにしました。
Chefが分からない人でもRailsやってるならERBはわかると思うので、おおよそのイメージで説明すると、 以下の様な振り分けのルールの正規表現を列挙したJSONファイルを用意しておいて、
{ "override_attributes": { "nginx": { "rails4_path": { "regexps": [ "^/(hoge|fuga)/?", "^/public/(hoge|fuga)/?", "^/admin/piyo/?", ... ] } } }
以下のようなERBのテンプレートでループして機械的にnginx.confの振り分けルールを生成します。
# # Rails 4として公開するパス (正規表現指定) # <% node[:nginx][:rails4_path][:regexps].each do |regexp| %> location ~ <%= regexp %> { if ($http_x_backend_version = "3") { proxy_pass http://rails3_only; break; } if ($http_x_backend_version = "4") { proxy_pass http://rails4_only; break; } proxy_pass http://rails4; } <% end %>
また、アセットの振り分け先は、Rails3/4でアセットのプリコンパイル結果が同じにならなかったので、Railsサーバ側でasset_hostを変えて、振り分け先をそれぞれ固定しました。
server { listen xxxx; server_name assets3.example.com; set_real_ip_from xxx.xxx.xxx.xxx/xx; real_ip_header X-Forwarded-For; ... location ~ ^/assets/ { proxy_pass http://rails3_only; break; } } server { listen xxxx; server_name assets4.example.com; set_real_ip_from xxx.xxx.xxx.xxx/xx; real_ip_header X-Forwarded-For; ... location ~ ^/assets/ { proxy_pass http://rails4_only; break; } }
バックエンドのELBのDNS名前解決
最後に、Rails3/4それぞれのupstream先のserverコンテキストの定義です。
server { listen unix:/var/run/nginx_rails3.sock; server_name example.com assets3.example.com; set_real_ip_from xxx.xxx.xxx.xxx/xx; set_real_ip_from unix:; real_ip_header X-Forwarded-For; proxy_set_header X-Forwarded-Scheme $http_x_forwarded_proto; proxy_set_header Host $host; set $elb_rails3 "internal-lb-backend3-XXXXXXXXX.ap-northeast-1.elb.amazonaws.com"; ... location / { proxy_pass http://$elb_rails3:80; } } server { listen unix:/var/run/nginx_rails4.sock; server_name example.com assets4.example.com; set_real_ip_from xxx.xxx.xxx.xxx/xx; set_real_ip_from unix:; real_ip_header X-Forwarded-For; proxy_set_header X-Forwarded-Scheme $http_x_forwarded_proto; proxy_set_header Host $host; set $elb_rails4 "internal-lb-backend4-XXXXXXXXX.ap-northeast-1.elb.amazonaws.com"; ... location / { proxy_pass http://$elb_rails4:80; } }
Unixドメインソケット経由で受けてバックエンドのELBに振り分けします。 internal-lb-backend3-XXXXXXXXX.ap-northeast-1.elb.amazonaws.com
はELBのエンドポイントです。 Unixドメインソケット経由で接続するので、 X-Forwarded-For
ヘッダで正しくエンドユーザのIPアドレスが引き継げるように set_real_ip_from
で unix:
を追加しています。
そもそもなぜUnixドメインソケット経由で接続する必要があるかと、nginxはデフォルトでは起動時にバックエンド先のDNS名を名前解決してIPアドレスをキャッシュしてしまいます。 nginxからリバプロ先のバックエンドがELBの場合は、ELBのIPアドレスが動的に変化する可能性があり、IPアドレスがキャッシュされないようにDNS名を動的に名前解決しないといけないのですが、 upstreamコンテキストで名前解決するためのresolveオプション使おうと思ったら残念ながら有償版の機能でした。
あれこれ試行錯誤した結果、無償版の範囲でやるには、このようにupstreamで負荷分散とフェールオーバ制御してローカルのドメインソケットに振り分けして、 振り分け先のserverコンテキストでELBのドメイン名を一旦変数でsetするとproxy_passで参照するときに名前解決されるという合わせ技でDNS名を動的に名前解決できるようになりました。
この問題の詳細は、以前Qiitaにも書いたので、詳細は以下を参照して下さい。
Rails3/4用のサーバ群
Rails3/4用のサーバ群はほぼ同じ設定のサーバ群をコピペで作る必要があります。
CrowdWorksではAWSリソースをTerraformのコードで管理していますが、サーバを立てると言ってもEC2のインスタンスのコードをコピペすればよいだけという簡単な話ではなく、周辺のELB/AutoScale/CloudWatch/CodeDeployなどの関連するAWSリソースも作成する必要があります。
Terraformにはこのような関連するリソースをまとめて一つのモジュールとして定義して再利用可能にする仕組みがあります。
Terraformでmodule機能を利用するには、例えばmodule/app配下に関連するリソースを通常のtfファイルとして定義し、
resource "aws_autoscaling_group" "app" { name = "app-${var.env}-${var.group}-asg" ... }
resource "aws_cloudwatch_metric_alarm" "asg" { alarm_name = "awsec2-${aws_autoscaling_group.app.name}-High-Status-Check-Failed-Any" ... }
moduleに変数として渡したいものをvariableで宣言しておきます。
variable "env" { } variable "group" { }
実際にmoduleを利用したい場所で、以下のようにmoduleのパスを指定して、変数を渡して読み込みます。
module "app_rails3" { source = "./module/app" env = "${var.env}" group = "rails3" } module "app_rails4" { source = "./module/app" env = "${var.env}" group = "rails4" }
このようにTerraformのmodule機能を利用することで、ほぼ同じようなAWS設定のサーバ群のテンプレートを作りつつ、適宜リソース名など変数を差し込めるようになります。 また環境変数 BUNDLE_GEMFILE などサーバ内の設定差分は、Chefのenvironmentsとして差分を管理してインスタンス生成時に注入できるようにしました。
やってみてどうだったか
実際にやってみてどうだったか、個人的な感想として良かったこと悪かったことなどを振り返ります。
良かったこと
URL単位で段階リリースした
これは並行稼働の仕組みそのものですが、URL単位で段階リリースしたのは、少しずつリリースできる安心感があり、手間をかけて準備しただけの価値があったと思います。 テストで稼働確認できたところからリリースできるので、既知のバグがある箇所だけ後回しにしたり、リリース後に問題が見つかっても対象の機能だけ戻したりなど柔軟な対応が可能でした。
また、一部URLだけ切り戻しするための正規表現が複雑になることがあり面倒だったので、 段階移行の終盤で正規表現にマッチしたURLをRails4に振り分けするルールの他に、正規表現にマッチしたURLはRails3に振り分けするというルールも書けるようにしたのですが、 最初から振り分けルールは両方書けるようにしておけばよかったかなぁと思います。
カスタムHTTPヘッダを見てRails3/4を指定して振り分けできるようにしておいた
カスタムHTTPヘッダを見てRails3/4を指定して振り分けできるようにしておいたのは、発生した不具合などが、Rails4でのみ問題が再現するかどうか障害切り分けでデバッグが捗りました。 並行稼働中に不具合が発生すると、それがRailsバージョンアップに起因する問題なのかどうかを速やかに判断し、バージョンアップ起因の問題であれば、対象のURLを切り戻しするなどの対応が必要です。 なので、Railsバージョンによって挙動が異なるかどうかを簡単に確認できるようにしておいたのはよかったです。
同じようなサーバ群を並べて作るのにTerraformのmoduleにしておいた
並行稼働するためにほぼ同じようなサーバ群を並べて作るのに、AWSコンソールでポチポチすると作業ミスしやすいのでインフラをコード化してあるのは大事です。 インフラがコード化されていると、staging環境でテストした構成がそのままproduction環境に持っていける安心感があります。 ただTerraformのコードも、EC2インスタンス以外の関連する周辺のリソースなどがあると、コピペしてリネームするなど煩雑になりがちです。 Terraformのmoduleで関連するリソースをまとめて定義することで、共通部分と差分が明示的に分離されて分かりやすくなり、 また、移行後に不要になったRails3サーバ群を削除するのもmoduleの読み込み部分を削除するだけでゴミ消しも簡単でした。
悪かったこと
Rails3/4並行稼働させるためのRails的なハマりポイント(リクエストの互換性やデータの互換性など)については、前述のスライドの後半にも書いてますので、そちらも参考にしてみてください。 ここでは、並行稼働させることによって発生した問題について失敗談を共有します。
asset_hostの分離
Rails3/4を並行稼働させるにあたり、Rails3/4のasset_hostを分離しましたが、これに起因した問題がいろいろ発生しました。
元々Rails3でのasset_syncに一部問題があって、現状CDNなど使わずにassetsのプリコンパイルを各サーバでやっていた経緯があったのですが、 sprocketsのバージョン差分の影響か依存するライブラリの影響か、Rails3/4でプリコンパイル結果のダイジェストが一致しなかったので、asset_hostをRails3/4で分けました。
また、Rails4での仕様変更でsprocketsがプリコンパイル結果にダイジェストなしのファイルを含めなくなりました。 この結果assetsの参照をRailsのヘルパーを使わずにハードコードしていた箇所でassetsのリンク切れが発生しました。
このプリコンパイル結果にダイジェストなしのファイルを含めなくなる仕様変更自体は認識していたのですが、assetsを参照する処理の修正漏れが一部ありました。
やってみた感想としてasset_host分けるのはいろいろな問題を引き起こすのであまりオススメしません。 CDNが使えない場合でも、愚直にアセットだけ両方のサーバに配布するなど他に回避方法がないか検討した方がよいかと思います。
memcachedのキャッシュキーの分離
一部ActiveRecord::Baseのオブジェクトをシリアライズしてmemcachedにキャッシュしている箇所があり、対応としてRails3/4でキャッシュのキーを分けました。 しかしながら、実装の都合上、明示的にキャッシュキー指定して更新している箇所があり、Rails3/4両方のキャッシュを更新すべきときに、片方しか更新されない問題が発生しました。
この問題自体は、更新タイミングの見落とさなければ防げたかもしれませんが、そもそもRailsバージョンに依存する構造をもつオブジェクトをシリアライズして保存するのはやめた方がよいです。
デプロイ処理の作りこみ
その他、悪かったことではないかもしれませんが、デプロイ周りの自動化を作りこみしていると並行稼働状態でも動くようにするのがいろいろ大変でした。
現状の仕組みの前提を説明するのが大変なので詳細は割愛しますが、並行稼働させるためには単純にサーバ立てればOKという話ではなく、 並行稼働しているアプリケーションのリビジョンがRails3/4で同じになるようにデプロイ周りの調整が必要です。 Rails3/4で直列にデプロイすると単純に作業時間が2倍になってしまうので、並列にデプロイしつつ、同期を取ったりする必要があります。 皆さん何がしか自動化のために作りこみはしていると思いますが、並行稼働構成にする場合に、どのような影響があるかは早い段階から検討&検証する必要があります。
まとめ
この記事では、Rails3/4の並行稼働の仕組みについて説明し、実際にやってみて良かったことや悪かったことなどを共有しました。
ある程度の規模のシステムになると、なかなかえいやでRailsアップグレードするわけにもいかないのが現実です。 並行稼働は仕組みを準備するのに手間はかかりますが、小さくリリースできるのは継続的デリバリーできるという安心感があるので、選択肢の1つとして検討してみてはいかがでしょうか。
この記事が、Railsアップグレードを検討されている方々の参考になれば幸いです。
We're Hiring !
クラウドソーシングのクラウドワークスでは、Rails5にバージョンアップしたいエンジニアや、がんばらないで成果を出すエンジニアを募集中です。