ローファイ日記

出てくるコード片、ぼくが書いたものは断りがない場合 MIT License としています http://udzura.mit-license.org/

任意のライブラリコールでプログラムを停止し、起動用のCRIUイメージを作成するアプローチについて

経緯の説明

2018〜2019年に、起動処理に時間がかかりがちなアプリケーションについて、起動状態のプロセスを CRIU によりダンプし、そこから起動するアプローチについて研究していました。

RubyKaigi 2019で話しているのでスライドなどをどうぞ。

speakerdeck.com

このとき、プログラムをどのタイミングで止めるか、あるいは止める方法について自動的な基準で判定して実施できないか、などの問題がありました。

例えば、起動してしまってアクセスが頻繁に来ている状態のアプリケーションをそのままダンプしてしまうと、中途半端な通信状態のソケットがダンプで残存してしまうなど問題がありそうだということは考えられます。

また、起動後にMySQLなどの外部プロセスとESTABLISHEDなコネクションを持った状態でダンプしてしまう場合、CRIUはソケットも正しくダンプ・リストアしてくれますが、起動後に起動前のNetwork namespaceと違うIPがアサインされているとそのソケットは当然、動かなくなってしまいます。そのあたりをCRIUは考慮できません。したがってNetwork namespace上のデバイスにあるIP含めての再生が必須となります。仕方ないようにも思いますが、こうなってしまうのは、あまりコンテナのユースケースにそぐわないように思います。

一方、あまりに初期段階でダンプをしてしまうと起動時間の低減という目的に貢献しません。

そういうわけでたとえば、 listen(2) のような起動のキーとなるシステムコールを選定し、その呼び出しにフックしてダンプできるとどうだろう? というアイデアがありました。

この辺りはまつもとりーさんが以前にまとめています。

hb.matsumoto-r.jp

seccomp + SCMP_ACT_TRACE による停止の問題点

システムコールの呼び出しを契機に特定の処理を動かす方法として、2018年段階では seccomp + SCMP_ACT_TRACE ルールを用いる方法がありました。

udzura.hatenablog.jp

ざっくりいうと、 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が利用できるようになりました。これについては以下の記事が詳しいです。

gihyo.jp

seccomp で SCMP_ACT_NOTIFY ルールを登録すると、システムコール呼び出しを通知するためのfdが作成されます。このfdはUNIXドメインソケットを経由して任意のプロセスに渡すことができ、このfdを監視することでユーザランドのプロセスから、システムコールの処遇を決定することができます。

もう一つ興味深いこととして、その監視して通知を取り出したプロセス側で処理をブロックしていると、呼び出した側のプロセスもそのシステムコールでブロックするような挙動になります。すなわち、ここでも任意のシステムコールでプロセスを停止させられるということになります。

少し実装を紹介します。以下のようにseccomp contextを作成しつつ、RubyWebrickサーバを起動するプログラムがあります。

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にもアップしています。

github.com

github.com

たとえばmiehistöのいち機能にして統合するなども便利かもしれません。

*1:miehistö経由 (https://udzura.hatenablog.jp/entry/2020/11/17/145132) で起動してCRIUで簡単にダンプできるようにしています

*2:というより、使っているプログラムが世にほとんどないのではないかと思われる

*3:tracer側の指定で特殊なerrnoを返させるようにもできるので、例えばなにかしら運用判断で処理を停止させられる実装も可能だと思います