経緯の説明
2018〜2019年に、起動処理に時間がかかりがちなアプリケーションについて、起動状態のプロセスを CRIU によりダンプし、そこから起動するアプローチについて研究していました。
RubyKaigi 2019で話しているのでスライドなどをどうぞ。
このとき、プログラムをどのタイミングで止めるか、あるいは止める方法について自動的な基準で判定して実施できないか、などの問題がありました。
例えば、起動してしまってアクセスが頻繁に来ている状態のアプリケーションをそのままダンプしてしまうと、中途半端な通信状態のソケットがダンプで残存してしまうなど問題がありそうだということは考えられます。
また、起動後にMySQLなどの外部プロセスとESTABLISHEDなコネクションを持った状態でダンプしてしまう場合、CRIUはソケットも正しくダンプ・リストアしてくれますが、起動後に起動前のNetwork namespaceと違うIPがアサインされているとそのソケットは当然、動かなくなってしまいます。そのあたりをCRIUは考慮できません。したがってNetwork namespace上のデバイスにあるIP含めての再生が必須となります。仕方ないようにも思いますが、こうなってしまうのは、あまりコンテナのユースケースにそぐわないように思います。
一方、あまりに初期段階でダンプをしてしまうと起動時間の低減という目的に貢献しません。
そういうわけでたとえば、 listen(2)
のような起動のキーとなるシステムコールを選定し、その呼び出しにフックしてダンプできるとどうだろう? というアイデアがありました。
この辺りはまつもとりーさんが以前にまとめています。
seccomp + SCMP_ACT_TRACE
による停止の問題点
システムコールの呼び出しを契機に特定の処理を動かす方法として、2018年段階では seccomp + SCMP_ACT_TRACE
ルールを用いる方法がありました。
ざっくりいうと、 listen(2)
などのシステムコールに SCMP_ACT_TRACE
ルールを登録し、親プロセスで ptrace(2)
を発行して監視し、システムコール呼び出しのイベントが起こったら処理を起動するような実装になります。
この方法は確かに listen(2)
を検知します。ところが、問題として、イベントを監視する親プロセスで ptrace(2)
のトレースを有効にする一方、ダンプを実施するcriuのサービスも、内部で少し違ったフラグの ptrace(2)
を発行する(停止などのためのようです)ので、 ptrace(2)
によるアタッチのコンフリクトが起こるという事態が発覚します。以下は失敗時のログの例で、ptraceのattachやdetachが不正でPIDを検出できないようなログが出ていました。
(00.000663) Seized task 30738, state 1 (00.000670) seccomp: Collected tid_real 30738 mode 0x2 (00.000688) Seizing 30738's 30739 thread (00.000694) Warn (compel/src/lib/infect.c:120): detached failed : 30739 (No such process) (00.000697) Warn (compel/src/lib/infect.c:132): Unable to interrupt task: 30739 (Operation not permitted)
もう一つの問題として、何かしらタイミングよくダンプに成功したとしても、再生したプロセスはseccompのルールが有効状態のまま再生するので、結局当該システムコールの呼び出しに支障が出てしまう点がありました。 SCMP_ACT_TRACE
ルールで監視されたシステムコール呼び出しは、トレーサがいない場合、デフォルトで ENOSYS
でエラーになる挙動をします。呼び出される直前でダンプしたとすれば、再生して直後に ENOSYS
でプロセスが落ちてしまうことになります。
上述した松本さんのアプローチではCRIU側に、再生時にseccomp mode 2をDISABLEするようなパッチを加えています。このような複雑な前提のあるパッチをCRIUのupstreamに含めるのは骨が折れる仕事となりそうに思われました。
seccomp の SCMP_ACT_NOTIFY
の登場
そういう背景でこの研究は止まっていたのですが、2020年の今はSeccomp User Notificationが利用できるようになりました。これについては以下の記事が詳しいです。
seccomp で SCMP_ACT_NOTIFY
ルールを登録すると、システムコール呼び出しを通知するためのfdが作成されます。このfdはUNIXドメインソケットを経由して任意のプロセスに渡すことができ、このfdを監視することでユーザランドのプロセスから、システムコールの処遇を決定することができます。
もう一つ興味深いこととして、その監視して通知を取り出したプロセス側で処理をブロックしていると、呼び出した側のプロセスもそのシステムコールでブロックするような挙動になります。すなわち、ここでも任意のシステムコールでプロセスを停止させられるということになります。
少し実装を紹介します。以下のようにseccomp contextを作成しつつ、RubyのWebrickサーバを起動するプログラムがあります。
uds_path = "/tmp/tracer.sock" context = Seccomp.new(default: :allow) do |rule| rule.notify(:listen) end context.load nfd = context.notify_fd uds = UNIXSocket.open(uds_path) Seccomp.sendfd(uds.fileno, nfd) uds.close IO.for_fd(nfd) Exec.execve( ENV.to_hash, "/usr/bin/ruby", "-run", "-e", "httpd", "--", "/var/www/html" )
/tmp/tracer.sock
は別のターミナルから、以下のプログラムで作ります。
uds_path = "/tmp/tracer.sock" serv = UNIXServer.new(uds_path) loop do sock = serv.accept nfd = Seccomp.recvfd(sock.fileno) notif = Seccomp::Notification.new(nfd) notif.respond do |n| print "Received listen(2) invocation from PID = #{n.pid}\n" + "You can 1) make CRIU image 2) continue process 3) abort process: " res = gets.chomp if res == "1" if system "mhctl service dump -t #{n.pid}" puts "Dump OK!" exit else raise "Dump is failed" end elsif res == "2" puts "Continue." #n.continue = true n.retval = 0 else n.retval = -1 n.reterror = -999 # TODO: specified retcode in cakehole end end sock.close IO.for_fd(nfd).close end
treceeプログラムを起動すると*1、たしかにlistenの直前で停止して別のターミナルで処遇を決定できる状態になりました。
$ sudo mhctl service add /usr/local/libexec/mruby /usr/local/libexec/wrap_trap.rb c532f319d287c3ecd50e2a4b05525f74 ... $ sudo ./mruby/bin/mruby examples/systrap/tracer.rb Invoker process connected! Received listen(2) invocation from PID = 4346 You can 1) make CRIU image 2) continue process 3) abort process:
このプロセスは以下のようなstack、すなわち特定のカーネル関数で停止しています。ptraceやシグナルでは停止していない(T
statusになっていない)という点に注目してください。
$ sudo cat /proc/4346/stack [<0>] seccomp_do_user_notification.isra.0+0xf5/0x1f0 [<0>] __seccomp_filter+0xe2/0x690 [<0>] __secure_computing+0x42/0xe0 [<0>] syscall_trace_enter+0x10d/0x280 [<0>] do_syscall_64+0x6e/0xc0 [<0>] entry_SYSCALL_64_after_hwframe+0x44/0xa9
ここで、 1
を押すことでイメージを作成します。それは成功します。
システムコール再開時の問題
しかし、実は、このCRIUイメージからプロセスを再開しても、その後の起動に失敗しdefunctになってしまいます。
$ sudo mhctl image list OBJECT_ID COMM PAGE_SIZE CTIME 871916aff83f3b15740e51fcb01abfc6 ruby 20.32MiB 2020-12-25 11:00:17 +0000 $ sudo mhctl service restore --from 871916aff83f3b15740e51fcb01abfc6 871916aff83f3b15740e51fcb01abfc6 $ sudo mhctl service list OBJECT_ID PID PPID ARGS 4489 4479 $ ps auxf | grep 448[9] -B3 root 3155 99.9 0.0 7500 3536 pts/0 R 02:38 510:42 | \_ ./mruby/bin/miehistod root 3157 0.0 0.0 8084 596 pts/0 S 02:38 0:00 | | \_ /bin/sleep inf root 4479 0.7 0.0 7828 5232 pts/0 S 11:04 0:02 | | \_ /usr/local/bin/runmh --restored root 4489 0.0 0.0 0 0 ? Zs 11:04 0:00 | | \_ [ruby] <defunct>
ログは以下のように出ており、停止した listen(2) システムコール呼び出しが ENOSYS
エラーになっています。すなわち、実は、もともとの2番目の問題であるseccompのコンテクストとともに再生され、それでトレーサがおらず実行に失敗するという問題は解消していません。
/usr/lib/ruby/2.7.0/socket.rb:202:in `listen': Function not implemented - listen(2) (Errno::ENOSYS) from /usr/lib/ruby/2.7.0/socket.rb:202:in `listen' from /usr/lib/ruby/2.7.0/socket.rb:765:in `block in tcp_server_sockets' from /usr/lib/ruby/2.7.0/socket.rb:227:in `each' from /usr/lib/ruby/2.7.0/socket.rb:227:in `foreach' from /usr/lib/ruby/2.7.0/socket.rb:763:in `tcp_server_sockets' from /usr/lib/ruby/2.7.0/webrick/utils.rb:65:in `create_listeners' from /usr/lib/ruby/2.7.0/webrick/server.rb:127:in `listen' from /usr/lib/ruby/2.7.0/webrick/server.rb:108:in `initialize' from /usr/lib/ruby/2.7.0/webrick/httpserver.rb:47:in `initialize' from /usr/lib/ruby/2.7.0/un.rb:345:in `new' from /usr/lib/ruby/2.7.0/un.rb:345:in `block in httpd' from /usr/lib/ruby/2.7.0/un.rb:71:in `setup' from /usr/lib/ruby/2.7.0/un.rb:324:in `httpd' from -e:1:in `<main>'
回避策はあるでしょうか?
「何もしないシステムコール」で元のシステムコールをラップする
システムコール単位では私はいいアイデアが思いつかなかったので、一旦ライブラリコールで考えてみました。具体的には以下のような関数を実装した共有ライブラリを作ります。
int cakehole(void); #define _GNU_SOURCE #include <dlfcn.h> int listen(int sockfd, int backlog) { int (*super)(int, int); cakehole(); super = dlsym(RTLD_NEXT, "listen"); return super(sockfd, backlog); }
このライブラリを LD_PRELOAD
でよみこませると、実質的に、ライブラリとしての listen(3)
の呼び出しの直前に cakehole()
という関数を呼び出せることになります。後は、この cakehole()
の段階で何かしらのシステムコールを呼ばせて、seccompでトラップできると良さそうです。
例えば以下のように実装しました。
#include <sys/types.h> #include <unistd.h> #include <signal.h> int cakehole(void) { int sigcakehole = SIGRTMAX - 4; signal(sigcakehole, SIG_IGN); return kill(getpid(), sigcakehole); }
Linuxで扱えるシグナルのうち34〜64番はリアルタイムシグナルという特殊な扱いのものになります。詳細はTLPIに譲りますが、数が多いため*2何かしらのシグナルはプログラムで不使用のはずです。その不使用のシグナルを signal(2)
で無視するようにし、直後にそのシグナルを発行すれば、実質何もしないシステムコール呼び出しが可能になります。
seccompでその番号でのシグナル呼び出しを監視してあげます。
# 検証環境でSIGRTMAX - 4 = 60 $ kill -l ... 58) SIGRTMAX-6 59) SIGRTMAX-5 60) SIGRTMAX-4 61) SIGRTMAX-3 62) SIGRTMAX-2
context = Seccomp.new(default: :allow) do |rule| rule.notify(:kill, Seccomp::ARG(:>=, 0), Seccomp::ARG(:==, 60)) end context.load
すると、結果的に cakehole()
の関数内部でプロセスの起動処理は停止し、そこでのダンプが可能になるというわけです。
この cakehole()
は失敗でも成功でも後続の処理を継続させるようになっています*3。したがって、リストア後の処理全体も正常に継続します。また、複数回 listen(3)
を呼び出すことも問題ありません。
というような実装に成功したので、いったんブログに残しておこうと思います。
もともとの FastContainer での利用はもちろん、一般的なプロセスイメージ作成に応用できそうな気もしますが、改めて見ると荒唐無稽なことをしてる気もしますし、まだどういうツッコミどころや罠があるかわからないので色々と試したい感じです。
実装はGitHubにもアップしています。
たとえばmiehistöのいち機能にして統合するなども便利かもしれません。
*1:miehistö経由 (https://udzura.hatenablog.jp/entry/2020/11/17/145132) で起動してCRIUで簡単にダンプできるようにしています
*2:というより、使っているプログラムが世にほとんどないのではないかと思われる
*3:tracer側の指定で特殊なerrnoを返させるようにもできるので、例えばなにかしら運用判断で処理を停止させられる実装も可能だと思います