スライド:
@udzura です。今から私は、Ruby on Railsのアプリケーションを最速で起動するための方法についてお話しします。RoRに限らず、Sinatraであったり、他の言語で書かれた「アプリケーションサーバー」、たとえば「ルビィの冒険」でお馴染みのDjangoであったり、Elixirやその他の物にも有効なはずです。
で、最初に、Linuxの「チェックポイント・リストア」の仕組みについてお話しします。これは、今日のお話しにとってとても大事なものです。
CRIUのロゴです。公式に「クリュー」って発音します。ちなみに私は一つだけパッチを送っていて、ほとんどCで書かれたCRIUのコードもなんとなく眺めています。
ところで、CRIUって聞いたことがありますか?
まあそうですよね... では、「プロセス」は聞いたことがおありですか?当然ですよね、皆さんはコンピューターを使ってるはずですから :)
CRIUというものは、その「プロセス」の情報をダンプしてイメージ化したり、そのイメージから元のプトセスを再生したりするための、Linux向けのツールです。「Checkpoint and Restore In Userspace」の略で、CRIUは基本的にプロセス向けのツールですが、特殊なプロセスであるコンテナにも使えます。
ちなみに、内部的に、CRIUはLinuxカーネルの機能をいくつか使っています:
- /proc ファイルシステム
- cgroup から取得できる属性や統計値
- TCPチェックポイント機能。これはLinux 3.5からの機能です。
- その他、様々なシステムコール
これらを用いてプロセスの情報を取得してイメージに落としているわけですね。
CRIUを用いると、以下のことができます。
- マイグレーション
- 起動時間の短縮
一つ目のマイグレーションの例を出します。
もしあなたが、いくつかのコンテナを立ち上げているホスト自体を再起動したい、そしてコンテナ自体は様々な理由で再起動はしたくない! となった場合、CRIUの出番です。 CRIUを使うとまず、ホスト上のコンテナをイメージにダンプすることができます。そしてそれらを別のホストにコピーした後で、そこからダンプしたコンテナを再生することができます。再起動なしでのマイグレーションです。
ちなみに、VMをkvmで管理しているような場合は、VMをホスト間でライブマイグレーションさせることができます。CRIUはVMで言うところのこの機能に対応するものです。
そして、起動時間の短縮についても例をお話しします:
みなさんが比較的「起動が重たい」アプリケーションを - たとえばJenkins, Redmine, あるいはレガシーでモノリシックなRailsのアプリなど - 運用しているとして、それらを再起動しなければならないとすると躊躇してしまう場面があるかもしれません。
たとえばJenkinsは、Dockerhubにある jenkins:latest
イメージを使った場合、5秒の起動時間(8080番ポートをリスンするのに必要な時間です)がかかります。これは、単一のコンテナにデプロイしている場合だであれば、少なくとも5秒間のダウンタイムを伴ってしまうと言うことにつながりますね。
しかし、もしみなさんが「事前の」アプリケーションプロセスのダンプを取得していれば、CRIUはそれらのイメージを利用して、アプリケーションをより短い時間で再生することが可能です。
多くの場合、スクリプトの評価時間であったり、言語のVMの初期化の時間が、起動にかかった時間の多くの部分を占めています。しかしCRIUはこう行った初期化を、存在するプロセスの、初期化が終わりアプリケーションコードがロードされたランタイムメモリの状態をダンプし、そしてメモリを直接ダンプから再生することでスキップすることができます。
もうすこし詳細な例をお話しします。Railsを、CRIUを用いてイメージからブートストラップする話です。
ところで、私たち(udzuraの所属企業)は以下のような、コンテナを用いたプラットフォームを運用しています。
このプラットフォームにはフロントのWeb Proxyがあり、Virtual Hostをベースにアクセスをフォワード氏、ngx_mrubyのようなものでバックエンドのコンテナを動的に決めています。ユーザはそれぞれのアプリをホストに持ち、コンテナによって各環境は分離されています。ユーザーは自分たちのアプリを最低限の制約のもとデプロイできます。
さらにこのプラットフォームの特徴をざっくり説明しますね。このプラットフォームでは、あるコンテナホストのサーバーがクラッシュした時、同じコンテンツを持った新しいコンテナをもう一つのホストで自動的に立ち上げ、後続のリクエストを新しい方へリダイレクトします。ユーザにとっては、ホストの一つがクラッシュしたとしても、ダウンタイムが起こらなかったように見えます。
こういう仕組みはきっと、ユーザーも運用者も幸せにしてくれますよね :)
しかしこのアーキテクチャは、当然のことながら、コンテナに再起動を強いることになります。私たちは、どうにかしてこのやっかいな再起動の時間を減らせないかと考えました。
CRIUはこの仕組みにとって最適な選択のように見えました。私は、このツールを、Ruby on Railsのアプリケーションの再起動時間を調査計測し、比較することで検証することにしました。
はじめに、私はRoRの起動時間を、CRIUを使っている場合といない場合とで、よし単純なVagrant上のVM環境で比較しました。
レスポンス時間の計測手順について説明します。まず、ベンチマーカーなどを使って継続的なリクエストを流します。そして全てのリクエストのレスポンスタイムを記録します。このベンチ中に、アプリケーションをホストしているコンテナを、あえて停止します。
その次のリクエストで、アーキテクチャは新しいコンテナを立ち上げてからレスポンスを返すように動作します。そしれは一つのリクエストのトランザクションの中で行います。なので、このタイミングの全体のレスポンスタイムは、起動時間と、本来のアプリケーションのレスポンスタイムとで構成されます。
このベンチを本環境で実験しました。これが結果です。CRIUを使った方がより速く見えます。
<graph 0>
それから私はシンプルなRailsアプリケーションを私たちのプラットフォームのステージング環境にデプロイし、ベンチマーククライアントをアプリケーションに走らせ、ベンチ中のそれぞれのリクエスト・レスポンスでの毎回の経過時間を計測しながら、ホストの「リロケーション」を強制するプログラムを走らせました。図はこういう感じです。
これが、CRIUを使わない時のRailsのリロケーション時間の結果です。
<graph 1>
ところでみなさんはすでに起動時間を削減するためのテクノロジーをご存知ですよね。「bootsnap」gemです。このgemを使った時の起動時間も計測しました。これが結果です。
<graph 2>
そして、CRIUのイメージをこのアプリケーションからあらかじめ作成し、存在する2つのホストにrsyncしました。こうすることでシステムはコンテナの起動の際に、新しいホストに移動してからもCRIUイメージを使うようになります。結果はこちら。
<graph 3>
比較します。
CRIU利用時、リロケーション時のレスポンスタイムは、何の戦略も施さない場合の1/4、bootsnapのみを使った場合の1/2になっています。
加えて、CRIUはコンテナのスケールアウトを高速にするのにも効果的です。
私たちは、たった一つのCRIUのイメージから、いくらでもコンテナを作成することができます。CRIUからの起動はスクラッチで起動するよりも高速です - ご覧の通り。
OK、CRIUが「コンテナの」起動にとても効果的だとわかりました。しかし私は多分、みなさんがこう考えているのではないかと思います。「ぼくらのアプリケーションはVMにデプロイされていて、コンテナを使っていないよ。」
一般的に、VMベースのアプリケーションをコンテナにマイグレートするのは大変ですね。
システムの特性のうち多くの面が、VMとコンテナで異なっています。例えば、VMはinitプロセスを持ち、sshdやrsyslogdといったデーモンを管理し、フルスタックなファイルシステムをOS全体の管理のために必要とします。コンテナは多くの場合これらの機能を省略しています。アプリケーションプロセスをコンテナのPID=1にしたり、システム管理のためのデーモンを起こさなかったり(このタイプのエージェント型デーモンが欲しい場合は、サイドカーコンテナという技を使います)、シンプルで最低限で、軽量なrootファイルシステムなどなど。
こういった違いは、開発者たちにアプリの意向を困難にさせています。
ということで、VMを使っている開発者のために、チェックポイントを簡単に取得できて、そこからアプリケーションを再生するためのツールが存在したら便利そうじゃありませんか?
はい、実はあります。私は、そのツールを「Grenadine」と名付けました。ちなみにmrubyで書きました。RubyKaigiのために!
Grenadineは「普通の」VMで、チェックポイントとその再生を管理するためのツールです。言い訳ですが、Grenadineの品質はまだまだPoCのレベルです :(
Grenadineでのチェックポイントの作成方法をご覧に入れます。
なにはともあれGrenadineをインストールします。しかし私はdebやrpmといったパッケージをまだ作成していません。ということで「事前に」つくったバイナリを使いますね :)
ということでデモ動画を見てみましょう。
Railsアプリをデプロイします。revison 1とします。
それから、 grenadine daemon
でこのアプリケーションを起動します。
Railsアプリケーションが上がっています。curlなどで動作確認をしましょう。OKなら、はい、このRailsアプリのチェックポイントを作ります。簡単ですよ。 grenadine dump
と打つだけです・
grenadine list
でチェックポイントイメージを確認できます。
これらの管理されているイメージから、アプリケーションの再生もできます。 grenadine restore
を打ってください。 grenadine kill
で停止します。
OK、では、revision 2のRailsアプリをデプロイします。
そうしたら、またデーモンを作り、ダンプします。
今、我々は2つのイメージを持っています。revision 1とrevision 2ですね。このイメージのうちどちらでも選んで、もう一度起動させることができます。で、再生はだいたい1秒以内で完了します。一方で、古いリビジョンのアプリのデプロイは、もっと時間がかかりますよね。Grenadineは最速でのロールバックを実現します!
では、今からアプリケーションのrevision 1をもう一度提供することにしましょう。 grenadine restore 2
あるいは grenadine restore --from HASH
を打ちます。Hashは grenadine list
で確認できます。
はい、この通り動いています。
次は中身のお話です。ご注意: これからのトークはLinuxのコンテナランタイムの実装の話をしますので、Linuxがチョットワカル方を対象としています。ですがご心配なく、(m)rubyのコードも出てきますので、読めるんじゃないでしょうか :)
実際、 grenadine daemon
は最低限なミニマリストコンテナを内部で作るのです。つまり、Grenadineはコンテナランタイムの一種とも言えます。
私は今まで二つのコンテナをmrubyで書いてきました。Haconiwaと、grenadineです。なので、mrubyはコンテナランタイムを作るのに最適だと思います :) こちらは、コンテナランタイム、もしくは「コンテナ化された」プロセスをmrubyで書くダミーコードです。まずfork()して、fork後のプロセスの各種属性を分離して、execve()を発行します。waitpid()などでコンテナの親プロセスからwaitして管理するのも良いやり方です。
それから、プログラムはシングルスレッドで動いている必要があり、1.9以降のCRubyよりmrubyの方が便利ではあります。
じゃあ2つの、コンテナ深い話をしましょう。
CRIUは、それぞれのプロセスのPIDを含めて同じように再生しようと試みます。なのでPIDを分離していない場合に、ダンプした時のPIDがすでに使われてしまっていたような場合、プロセスの再生は失敗してしまいます。GrenadineはPID名前空間をunshareしている必要があります。PIDのunshareより、そのコンテナの名前空間内部では、それぞれプロセスはPID=1から開始するようになります。これPIDの衝突は避けられます。
そして隔離されたPID名前空間でも /proc ファイルシステムが動作するよう、マウント名前空間もunshareします。
これはGrenadineの実際の該当するコードです。cloneシステムコールを特殊なフラグを立てたforkとして利用します。また、fork/exec/waitはUNIXのプロセスを作る上での基礎です。こういうシステムプログラミングに興味がある方は「なるほどUNIXプロセス」は必読です。
Grenadineはアドホックなroot filesystemをバインドマウントを用いて作成し、 /tmp や /var/log のような重要なディレクトリをホストとアドホックファイルシステムとで共有し、新しいrootにpivot_rootします。
これがGrenadineの該当コードです。ここでテンポラリディレクトリを作ってpivot_rootで入ります。また、Grenadineは自分でホストと同じように扱えるrootを作っています。
このメカニズムのおかげで、 grenadine daemon
が作成するプロセスは普通の、VMベースのアプリケーションデーモンに見えます。しかし内部では、一部の属性がunshareされています。これにより、CRIU
でのチェックポイントの作成が簡単になるわけです。
Grenadineは、歴史的な、VMベースのアプリケーション環境に、最低限の変更で導入できることを意図しています。
ところで近年は、何もかもがコンテナ化され、オーケストレーションされて、マイクロサービスベースになっていく時代です。
Grenadineは今の所歴史的なVMインスタンスに対して使えるようになっています。将来的には、GrenadineをKubernetesやそのほかのコンテナベースのシステムに対してもチェックポイント/リストアに使えるようにしたいです。クラウドネイティブなチェックポイント/リストアのためのミドルにしたい。
現在のGrenadineは、比較的小さなアプリケーションと一緒に使うと便利そうです。ですがすぐにより大きな、モノリシックなRailsアプリケーションにも使えるようになるでしょう。きっと...。そういうわけでみなさんのRailsアプリにぜひGrenadineを試してみてください。なるべく早く、rmpやdebファイルをリリースします :(
次世代のLinuxカーネルの機能を使ってみましょう!