てくすた

ピクスタ株式会社のエンジニア・デザイナーがつづるよもやまテクニカルブログです

Apache Passengerをやめました

こんにちは、開発部でインフラエンジニアをしているもりです。
「はてなダイアリー」が2019年の春で終了してしまいますね。エンジニアブログといえば「はてなダイアリー」とたくさん読ませて頂いたので寂しい思いです。
さて、今回のテーマはPIXTAのRailsアプリケーションの実行環境をApache Passengerからnginxとunicornの構成に移行したはなしです。

はじめに

Railsアプリケーションを動かす上でのWebサーバーとアプリケーションサーバーの違いとか役割については、こちらの記事が参考になったので、もしこんがらがっている方は最初に読んで頂ければと思います。

移行後の運用や障害対応においてこれらの違いを理解しておくと、問題が発生した場合の切り分けと対処に役立つと思います。

Apache Passengerで何が課題だったか

上記を踏まえ、PIXTAのRailsアプリケーションをPassengerで動かしていて課題となっていたことを列挙しておきます。

単一障害点である

これはシステムの構成上の問題ですが、PIXTAのサービスは元々モノリシリックであったものを、マイページ等のメインとなる機能(以下、本体)と検討中リスト、素材検索、定額制の4つの機能に分割し、それぞれのサービスを本体のWebサーバーによって振り分けをおこなっていました。 この本体のRailsアプリケーションをApache Passengerで動かしていました。

f:id:philip_moris:20180913174704p:plain

この構成だと、本体のサービスが落ちた場合にその配下にある他のサービスも使えなくなりサービス全体としての可用性は低いのが課題でした。この構成についてもnginx + unicornに移行する際に変更することにしました。

他サービスと運用方法が異なる

検討中リスト、素材検索、定額制については後発であったこともあり既にnginx + unicornの構成でRailsアプリケーションを動かしており、本体と運用方法、特にリリース手順に差異がありました。

デプロイの負担

新しいバーションのRailsアプリケーションをリリースする際、リリース直後のロードに時間がかかりすぎロードバランサのヘルスチェックがタイムアウトする問題がありました。これを回避するためサービスを構成するインスタンスについて、ローリングアップデートする形でリリースしており全インスタンスで完了するまでに20分ほど要しておりました。その間リリース担当者は拘束されるので負担となっていました。運用でカバーしている状態でありました。

nginxとunicornでどう解決したか

上記の課題についてnginxとunicornにすることでどう解決したかについて説明します。

機能の分離

単一障害点になっていた本体を各サービスへの振り分け部分(以下、リバースプロキシ)だけ切り出し、クライアントからのhttpリクエストを最初に受けるようにして、本体の機能を他サービスと同列に配置しました。

f:id:philip_moris:20180913182436p:plain

この構成ですと、本体が落ちていてもリバースプロキシが生きていれば他のサービスは使える状態になるので障害範囲が狭められます。リバースプロキシはapacheだけが動いている振り分け用で障害リスクは前の構成と比較し格段に下がったと思います。

ゼロダウンタイムデプロイ

unicornにはpreloadの機能があり、workerをフォークする前にアプリの先読みを行い、新プロセスを立ち上げて旧プロセスを停止することでダウンタイムをなくすことが可能になります。以下がpreload_appを有効にした場合のunicorn再起動時の挙動です。

    1 26802 26215 26215 ?           -1 Sl    4000   0:14 unicorn_rails master (old) --env staging --daemonize -c /src/config/unicorn.conf
26802 26851 26215 26215 ?           -1 Sl    4000   0:00  \_ unicorn_rails worker[0] --env staging --daemonize -c /src/config/unicorn.conf
26802 26854 26215 26215 ?           -1 Sl    4000   0:00  \_ unicorn_rails worker[1] --env staging --daemonize -c /src/config/unicorn.conf
26802 26857 26215 26215 ?           -1 Sl    4000   0:00  \_ unicorn_rails worker[2] --env staging --daemonize -c /src/config/unicorn.conf
26802 26860 26215 26215 ?           -1 Sl    4000   0:00  \_ unicorn_rails worker[3] --env staging --daemonize -c /src/config/unicorn.conf
26802 27015 26215 26215 ?           -1 Rl    4000   0:01  \_ ruby /bin/unicorn_rails --env staging --daemonize -c /src/config/unicorn.conf

一時的に新旧のマスタープロセス、PID:26802とPID:27015が立ち上がっていることがわかります。

    1 27015 26215 26215 ?           -1 Sl    4000   0:14 unicorn_rails master --env staging --daemonize -c /src/config/unicorn.conf
27015 27048 26215 26215 ?           -1 Sl    4000   0:00  \_ unicorn_rails worker[0] --env staging --daemonize -c /src/config/unicorn.conf
27015 27051 26215 26215 ?           -1 Sl    4000   0:00  \_ unicorn_rails worker[1] --env staging --daemonize -c /src/config/unicorn.conf
27015 27054 26215 26215 ?           -1 Sl    4000   0:00  \_ unicorn_rails worker[2] --env staging --daemonize -c /src/config/unicorn.conf
27015 27057 26215 26215 ?           -1 Sl    4000   0:00  \_ unicorn_rails worker[3] --env staging --daemonize -c /src/config/unicorn.conf

旧プロセス停止され新マスタープロセス、PID:27015でworkerが起動していますね。 このデプロイ方法により、サービスをホストする全てのインスタンスで同時にデプロイ操作が可能になり、10分ほど短縮することができました。

ついでにやったこと

本番環境への切り替えはDNSの切り替えによるBlue-Greenを考えていたので、新環境ではロードバランサをElastic Load BalancerからApplication Load Balancerに変更しました。
ALBで実現できたことは

  • 複数のTLS証明書をサポートしているので、pixta.jpドメインと*.pixtastock.comドメイン両方が集約できる
  • AWS WAFが使える
  • ACMも使えるので証明書管理も楽

切り替え方法

先に記載した通り、構成変更後の環境を新しく用意し、PIXTAのサービスドメインをALBのDNS nameに変更するだけなのでメンテナンスウィンドウを設けることはせずに営業時間内に完了することができました。

運用後に出てきた課題

実際トラフィックを流し始めて、顕在化してきた課題について説明します。

unicornがメモリ喰う

メモリ使用率をモニタしてるとわかるのですが、時間の経過とともに上昇していく傾向がありました。そこでunicorn-worker-killerを導入してみました。これは予めメモリ使用量や捌いたリクエスト数の閾値を設定し、それを超過した場合にworkerプロセスをkillするgemです。
config.ru内にuse(Rackミドルウェア)で指定するだけですね。具体的な値については環境毎に異なるので環境変数としました。

require 'unicorn/worker_killer'

max_request_min = ENV['WORKER_KILLER_REQUEST_MIN']
max_request_max = ENV['WORKER_KILLER_REQUEST_MAX']

# Max requests per worker
use Unicorn::WorkerKiller::MaxRequests, max_request_min.to_i, max_request_max.to_i, verbose = true

oom_min = ENV['WORKER_KILLER_MEM_MIN']
oom_max = ENV['WORKER_KILLER_MEM_MAX']

# Max memory size (RSS) per worker
use Unicorn::WorkerKiller::Oom, oom_min.to_i, oom_max.to_i

これを、run Rails.applicationの前に書くことで、リクエストを受けた時は Unicorn::WorkerKiller::MaxRequests -> Unicorn::WorkerKiller::Oom -> Railsアプリケーションの順に処理されます。導入後はメモリ使用量も安定し、連休開けなどリリースの間隔が空いた直後に失敗していたデプロイも滞りなく終えることができ無用な再実行が発生しなくなりました。

起動制御の対象がapacheからunicornへ

Passengerはapacheのモジュールとして使っていたので、Railsアプリケーションの起動制御はapacheの起動制御でしたが、移行直後はその名残かnginxを再起動してもアプリケーションが読み込まれないとゆう勘違いが多くありました。nginxをWebサーバーとした場合は、unicornにリクエストを渡すリバースプロキシとしての役割なので、nginxの起動制御は直接Railsアプリケーションに影響しないのです。操作の対象はunicornに変わりますね。

まとめ

Railsアプリケーションのコードをいじらずとも、Webサーバーやアプリケーションサーバーを入れ替えることができるのはRackとゆうインターフェースがrubyのWebアプリケーションフレームワークとアプリケーションサーバーを仲介してくれてるからなんですね。 サービスとしては見た目も機能も変化してない一方で、耐障害性や運用保守性の非機能面での品質の向上がこの構成変更によって実現できたのではないかと考えています。