今回は、Webサーバの実装に依存することなく、OSレイヤでWebサーバソフトウェアが起動時に実行するであろうシステムコールを監視して、そのタイミングでプロセスをイメージ化する方法(PoC)について紹介します。
その前に、まずは前提の一致ということで、僕は以前から、Webサーバプロセスの性質について、プロアクティブ性とリアクティブ性という分類について述べてきました。 プロアクティブ性とリアクティブ性について簡単にまとめると、以下のようになります。
Webサーバ機能のプロアクティブ性とリアクティブ性
突発的なアクセス集中のような変化に耐えうるシステムを構築するためには,負荷の状態に基いて適切なインスタンスの数を決定し,必要以上にコンピュータリソースを使用しないように設計することも重要である. 単一のサーバに高集積にホストが収容可能であり,ホスト単位でのリソース管理を適切に行いながら,セキュリティと性能および負荷に強いWebホスティング環境を構築することを目的とした場合,Webサーバ機能をプロアクティブ性とリアクティブ性に基いて分類できる. 以下に,Webサーバ機能のプロアクティブ性とリアクティブ性を定義する.
プロアクティブ性とは,Webサーバ機能を持つ仮想マシン,コンテナおよびWebサーバプロセスが予め起動しており,リクエストに応じて仮想マシンやコンテナの状態を即時変更できないが,常に起動状態であるため,高速にリクエストを処理できる性質とする. また,常時Webサーバ機能を稼働させておく必要があるため,リソース効率が悪い. プロアクティブ性をもったオートスケールは事前にアクセス頻度から予測を行い,予測に基づいた数だけインスタンスを起動させておくようなアプローチである.
リアクティブ性とは,CGIやFastCGIのように,アプリケーションが実用上現実的な速度で起動可能であることを前提に,リクエストに応じてアプリケーションを起動する性質とする. リアクティブ性を持つWebサーバ機能は,起動と停止のコストは生じるため,性能面はプロアクティブ性を持つWebサーバ機能より劣るが,リクエストを受信しない限りはプロセスが起動しないため,リソース効率が良い. また,リクエストに応じて複数起動させるといった変更に強い処理が実装し易い. 一例として,FastCGIはリソース効率と性能を両立するために,一定期間起動して連続するリクエストを高速に処理可能とするアーキテクチャをとっている.
しかしながら,CGIのようなリアクティブ性に基づく従来の処理手法は性能面の問題などから利用されなくなってきており,オートスケールについても,リクエスト単位で仮想マシンやコンテナを都度起動させるコストを考慮すると,実用的な性能を満たすことは困難である.
ref: 松本 亮介, 近藤 宇智朗, 三宅 悠介, 力武 健次, 栗林 健太郎, FastContainer: Webアプリケーションコンテナの状態をリアクティブに決定するコンテナ管理アーキテクチャ, 研究報告インターネットと運用技術(IOT), Vol.2017-IOT-38(14), pp.1-8, Jun 2017.
上記でも述べた通り、リアクティブ性をもつWebサーバプロセスは、古くは待機メモリ節約のためのinetdやxinetd、最近ではサーバーレスアーキテクチャといった方面で再び注目を浴びています。 しかし、リアクティブ性の本質はリソース効率化というよりは、むしろ、予測できない変化に対してリアクティブに状態を変えられるところにあります。 その特性が、結果的に外的要因の変化に対して、システム内部の実質的なリソースの増減だけでなく、性能や予測できない変化にうまくフィットさせられるのです。
今後、フラッシュクラウドといった予測できない突発的なアクセスに対して、プロアクティブに見積もったり予測的に事前対応するよりも、変化に強い基盤を用意してリアクティブに自動化で対応することが重要になると考えます。そのためには、例えばWebサーバの文脈で考えた時には、いかにWebサーバプロセスの起動を速くするかが、その「変化に強いリアクティブな性質」を強化するためのアプローチになるでしょう。
そこで、CRIUというプロセスをイメージ化する技術にかねてから注目していたのですが、Webサーバが起動してしまってからイメージ化するのは、ネットワークレイヤーや起動後などの状態を持ったままイメージ化されるため、そのようなイメージは、アドレスやポートの衝突だったり、起動後のコンテンツの状態を持ってしまったり、OSの対応状況を考えると、起動の高速化という観点では実用上使いにくいと考えていました。
一方で、じゃあ各種Webサーバやアプリケーションサーバの起動処理時に、socketを作ってsetsockopt()なりbind()しlisten()する直前のタイミングでプロセスをイメージ化すれば、不要な状態を持たなくて良いと考えましたが、これだと、各種サーバソフトウェアの初期化処理に手を入れて、各々に依存した拡張実装を追加する必要があり、汎用性がこれまた低くなるなと思っていました。
それから月日は流れ、藤園を眺めていた時に、キュイーーーーーーーーーーーん!と何かがおりてきて、それだったら、各サーバソフトウェアの起動処理を外部からOSレイヤで監視して、socketのlistenに至る直前の処理で共通して実行されるようなシステムコール(socket(), setsockopt(), bind(), listen(), accept()などなど)が実行される直前で、イメージ化してやればいいじゃん、と考えました。
ほとんどのサーバソフトウェアは、基本的にlistenするタイミングではほとんどの初期化処理が終わっているはずで、そのタイミングでイメージ化できれば、状態もほとんど持っていないし、そのイメージから復帰してlistenする速度も極めて速くなるだろう、という考え方です。例えばrailsで長めの初期化処理が終わって、いざlistenしてサービスインだ!という状態の直前でイメージ化できることを想像すると、そのイメージからのリストアによる起動速度の効率性は想像にたやすいでしょう。
これができれば、サーバソフトウェアの実装に依存せず、全てOSレイヤで外部からプロセスの軌道処理完了直前の段階でイメージ化することができそうです。
ということで、それが可能かを昨晩ゴニョゴニョしまして、結論、できそうです。
それを実現するための方法とPoCを紹介します。
seccompとptraceでシステムコールを監視しCRIUでイメージ化する
上記の、Webサーバソフトウェアの起動処理完了で、かつネットワークがListenしていない状態のプロセスをイメージかすることを目指します。そのためには、seccompで監視するシステムコールを設定し、その上でイメージ化したいサーバプロセスをfork()してからexecv()しつつ、親プロセスから対象のサーバプロセスのseccompイベントをptrace()で監視し、イベントが発火、すなわち、監視するシステムコールが実行される直前の段階で、そのプロセスをCRIUでイメージ化してやります。
都合の良いことに、secompを使うmrbgemのmruby-seccompを向かいに座っている @udzura さんが、CRIUでプロセスをイメージ化するmrbgemを僕が既に昔作っていたので、それを少しだけ弄って、以下のようなコードでイメージ化することに成功しました。
socket = "/var/run/criu_service.socket" images = "/tmp/dump_test" log = "dump.log" c = CRIU.new c.set_images_dir images c.set_service_address socket c.set_log_file log c.set_shell_job true pid = Process.fork do context = Seccomp.new(default: :allow) do |rule| rule.trace(:setsockopt, 0) end context.load exec '/home/ubuntu/DEV/mruby/bin/simpleserver/webserver', '/home/ubuntu/DEV/mruby/bin/simpleserver/server.conf' end ret = Seccomp.start_trace(pid) do |syscall, _pid, ud| name = Seccomp.syscall_to_name(syscall) puts "[#{_pid}]: syscall #{name}(##{syscall}) called. (ud: #{ud}), dump the process image." c.set_pid _pid c.dump puts "the pid of process image is #{_pid} into #{images} dir." end
このコードでやっていることは、mruby製のシンプルなWebサーバをexecで実行し、そのWebサーバのsetsockopt()システムコールを監視し、setsockopt()システムコールが実行されるタイミングをSeccomp.start_trace
でトレースします。
Seccomp.start_trace
の実装自体はptrace()でseccompイベントを監視し、発火したらブロックを実行するような処理になっています。
その上で、ブロックの中ではCRIUのCAPIをbindingしたmruby-criuによって、対象のプロセスをイメージかします。
このWebサーバはだいたい起動するとメモリ1MBぐらいなのですが、それぐらいのプロセスで、大体dumpとrestoreはそれぞれ数msecで完了するので、普通に最初から起動するのと比較して、かなり速く起動できそうです。 このあたりのイメージ化とそこからのリストアの時間が、メモリサイズやその他設定に基いてどれぐらい違いが出てくるかは、今後検証予定です。
このコードを動かすようにするために、mruby-seccompのstart_traceメソッドに少し手を入れて、一度だけブロックを実行する処理に書き換えました。 その上で、CRIUのdumpの実行時には、CRIU自体もptraceで一度プロセスの処理をPTRACE_INTERRUPTでサスペンドしてからイメージ化をするために、CRIUにも手を入れて、既にサスペンド済みの場合は改めてptraceでサスペンドをしないようにしました。
さらに、CRIUではseccomp mode 2に対応した処理が入っているのですが、その処理を通すと、なぜかイメージからプロセスをrestoreした後に、プロセスがseccompの処理で落ちてしまうという問題があったため、そこについても、復帰後はむしろseccompはサスペンドしたいので、CRIUの中のdump時にseccomp mode 2をDISABLEにしてから保存するように書き換えました。
PTRACE_O_SUSPEND_SECCOMP
ではCRIU側でなぜかseccompをdisableできなかったので、seccomp mode 2の場合にそれをイメージに反映する処理を除外しました。
このような調整で、うまく上記のコードで実行したサーバソフトウェアの実装によらず、Webサーバの特性上必ず実行されるようなシステムコールをOSレイヤで監視して、そのタイミングでプロセスをイメージかすることができました。
めでたしめでたし。
まとめ
今日はWebサーバが起動時に共通して実行するようなシステムコールをseccompとptraceで監視して、システムコール実行前にcriuでイメージ化するPoCを書いた。そのためにcriuをそれなりに書き換えることになったけど、これでWebサーバの種類によらず起動完了直前のプロセスを汎用的にイメージ化できる。
— 松本 亮介 / まつもとりー (@matsumotory) 2018年4月23日
criuでネットワークやTCPセッションも含めてイメージ化するのは状態を持ち過ぎで扱いにくい。一方、起動時のsocket()実行時のプロセス状態をイメージ化するために、複数あるWebサーバやappサーバの拡張を書くのは大変。そこで、プロセスが任意のシステムコールをフックした時にcriuすれば良いと考えた
— 松本 亮介 / まつもとりー (@matsumotory) 2018年4月23日
ということで、サーバソフトウェアの起動を、それほどネットワークの状態を持つことなく、かといって起動処理において時間がかかる前処理などは終わってlistenする直前のプロセスの状態を、各種サーバソフトウェアの実装に手を入れたりすることなく実現するためのイメージ化手法が実現できました。
この方法を使えば、今回のケースだけでなく、内部実装によらずにOSレイヤから任意のシステムコールを監視して、特定のタイミングでプロセスのイメージ化が可能になります。
引き続き、CRIUのseccomp modeの不明な問題や、これを実プロダクトに導入できるレベルまでブラッシュアップしていきたいと思います。