criu restoreを非特権コンテナから呼ぶことはできるか
CRIUによるプロセスのリストアには複数のcapabilityが必要であり、コンテナ内部で行うためには--privileged
相当で起動された特権コンテナでなければいけません。例えば、TCP connection repairにCAP_NET_ADMIN
が必要なのは明らかですが、もっと単純なプロセスであれば非特権コンテナでもリストアできるのでは...?と考えたのが事の始まりです。
背景
仕事で面倒を見ているRailsアプリの起動が遅いという問題がありました。起動が遅いとさまざまな弊害があるのですが、その一つにオートスケールが間に合わないというのがあります。
AWS FargeteやGoogle Cloud Runといった現代的なサーバーレスソリューションでは、ひとつひとつのコンピューティングリソースは小さく、リクエスト数の増加に応じて水平にスケールします。このようなアーキテクチャでは、コンテナの起動が遅いと以下のような問題があります。
これらの問題を防ぐためには、あらかじめコンテナを多めに起動しておく必要があるのですが、当然ながら追加の費用がかかり、オートスケーリングのメリットを享受できません。
なんとか起動を早くする方法が無いかと調べていたところ、CRIUの存在を知りました。
CRIUは実行中のプロセスの状態をファイルに書き出し(チェックポイント)、そこから再開(リストア)する機能を提供します。CRIUによるチェックポイント/リストアの動作イメージは上記リポジトリのsimple loopのデモが参考になります。C言語で記述されたループカウンタ変数i
の状態が復元されていることがわかります。
これを使って、時間のかかる初期化処理を既に終えたプロセスをリストアすることで、高速に起動できないかと考えました。ちなみに、同様のアイディアは過去にudzuraさんがHaconiwaで検討しています。
しかし、冒頭でも書いた通り、コンテナ内でcriu restore
を実行するためには特権が必要であり、FargateやCloud Runでは利用できません。なんとかならないものか。
リストアの仕組み
そもそもCRIUはどうやってプロセスのリストアを実現しているのでしょうか。特にループカウンタの状態を復元するなんて、どんな魔法を使っているのか見当もつきません。
復元される対象はいくつかあるのですが、ループカウンタに関して言えば「実行中のプロセスにマッピングされているメモリの中身をファイルに書き出して、新しく起動したプロセス上で、元の仮想アドレスに読み込んだメモリの中身をmmapする」ことで実現しているようです(かなり曖昧な理解です)。
mmap(2)を使うと、任意の仮想アドレスにファイルをマッピングできます。これで元のプロセスのアドレス空間を復元すれば、プロセスを復元できる...という話らしいです。他にも色々難しいことをやっていて、完全には理解できていないのですが、とりあえず「mmapが呼べるならば初期化処理を終えたRailsアプリをリストアできるはず」ぐらいの雑な理解で先に進みます。自身のプロセスのメモリマッピングを変更する程度ならば、コンテナ内からでもできて良さそうです。
非特権コンテナからリストアしてみる
非特権コンテナからのリストアを実際に試してみましょう。環境を合わせるために、後に使うUbuntuイメージを--privileged
で起動して、その中でcriu dump
します。
$ docker run --rm -v ./:/tmp/criu --privileged -it ubuntu:22.04 /bin/bash root@2de03eafaec3:/# uname -a Linux 2de03eafaec3 6.2.0-1018-azure #18~22.04.1-Ubuntu SMP Tue Nov 21 19:25:02 UTC 2023 x86_64 x86_64 x86_64 GNU/Linux
リストアされるプロセスはRubyで書かれた単純なループです。
1.step do |i| puts i sleep 1 end
# ruby loop.rb 1 2 3 4 5
該当のプロセスがPID 3328で動いていることを確認し、criu dump -t 3328
で書き出します。CRIUのバージョンはapt install
で入ったv3.16.1です。
# criu --version Version: 3.16.1 # criu dump -t 3328 -j Error (criu/util.c:635): exited, status=1 Error (criu/util.c:635): exited, status=1 Warn (criu/kerndat.c:869): Can't keep kdat cache on non-tempfs # ls core-3328.img files.img ids-3328.img loop.rb pagemap-3328.img pstree.img stats-dump tty-info.img fdinfo-2.img fs-3328.img inventory.img mm-3328.img pages-1.img seccomp.img timens-0.img
書き出されたプロセスがKillされていることが確認できます。
# ruby loop.rb 1 2 3 4 5 6 7 Killed
これで準備は完了です。この書き出されたプロセスを使って、今度は--privileged
なしで起動したコンテナからリストアできるか確認してみます。
$ docker run --rm -v ./:/tmp/criu -it ubuntu:22.04 /bin/bash # criu restore -j Error (criu/util.c:635): exited, status=1 Error (criu/util.c:635): exited, status=1 Error (criu/tun.c:85): tun: Unable to create tun: No such file or directory Warn (criu/sk-unix.c:224): unix: Unable to open a socket file: Operation not permitted Error (criu/net.c:3726): Unable create a network namespace: Operation not permitted Warn (criu/net.c:3782): NSID isn't reported for network links Warn (criu/net.c:3442): Unable to get socket network namespace Error (criu/net.c:1246): Unexpected error: -1(Operation not permitted) Error (criu/kerndat.c:1166): Unable create a network namespace: Operation not permitted Error (criu/util.c:1339): Can't wait or bad status: errno=0, status=256 Error (criu/kerndat.c:1359): kerndat_has_nftables_concat failed when initializing kerndat. Error (criu/crtools.c:210): Could not initialize kernel features detection.
capabilityが足りないのでエラーになりました。ここまでは予想通りですね。
--unprivileged
を試してみる
最初はコードを変更しながら、どこまでエラーを無視して非特権コンテナでリストアが進められるか検証していたのですが、途中で--unprivileged
というオプションの存在に気付きました。これは求めていたものでは!?
このunprivilegedは非特権コンテナのことではなく、非rootユーザーのことを意味しているようなのですが、まずは試してみましょう。このオプションはv3.18で追加されているので、検証バージョンはv3.18です。
$ criu restore --unprivileged -j CRIU needs to have the CAP_SYS_ADMIN or the CAP_CHECKPOINT_RESTORE capability: setcap cap_checkpoint_restore+eip criu
実行すると、CAP_SYS_ADMIN
またはCAP_CHECKPOINT_RESTORE
が必要というエラーになります。後者はCRIUチームが新しくLinuxカーネルに導入したcapabilityで、かつてCAP_SYS_ADMIN
にまとめられていたチェックアウト/リストアの機能を切り出したものです*1。
Dockerではアローリスト方式で利用できるcapabilityが制限されているので、CAP_CHECKPOINT_RESTORE
もデフォルトでは制限されています。しかし、利用できる機能はかなり限定的なので、仮に許可しても、CAP_SYS_ADMIN
のような弊害はなさそうに見えます。
というわけで、--cap-add
でcapabilityを追加して実行してみましょう。
$ docker run --rm -v ./:/tmp/criu --cap-add CHECKPOINT_RESTORE -it ubuntu:22.04 /bin/bash # criu restore --unprivileged -j Warn (criu/kerndat.c:1103): $XDG_RUNTIME_DIR not set. Cannot find location for kerndat file Warn (criu/kerndat.c:1103): $XDG_RUNTIME_DIR not set. Cannot find location for kerndat file Error (criu/cr-restore.c:1322): Can't open -1/sys/kernel/ns_last_pid on procfs: Read-only file system Error (criu/cr-restore.c:1448): Setting PID failed Error (criu/cr-restore.c:2547): Restoring FAILED.
PIDの設定でエラーになっています。Issueを調べてみたら、同じような事象の報告がありました。
どうやら/sys
が読み取り専用でマウントされているために発生する事象のようです。/sys/kernel/ns_last_pid
はPID名前空間で最後に割り当てられたPIDが記録されており、これを書き換えることでPIDのリストアを実現しています。
説明されているように--security-opt systempaths=unconfined --security-opt apparmor=unconfined
を付与して再度実行します。
$ docker run --rm -v ./:/tmp/criu --cap-add CHECKPOINT_RESTORE --security-opt systempaths=unconfined --security-opt apparmor=unconfined -it ubuntu:22.04 /bin/bash # criu restore --unprivileged -j Warn (criu/kerndat.c:1103): $XDG_RUNTIME_DIR not set. Cannot find location for kerndat file Warn (criu/kerndat.c:1103): $XDG_RUNTIME_DIR not set. Cannot find location for kerndat file 3328: Error (criu/tty.c:831): tty: Can't set tty params on 0xc: Operation not permitted 3328: Error (criu/files.c:1213): Unable to open fd=0 id=0xc Error (criu/cr-restore.c:2547): Restoring FAILED.
今度は別のエラーが出ました。同じくリポジトリ内を調べたところ、以下のPRで修正されている問題のようでした。
ということで、この修正が取り込まれたバージョンをビルドして使います。最新のv3.19にも入っていないので、記事執筆時点の最新をcheckoutしてビルドします(なぜかv3.18になっていますが...)。依存関係はInstallationを参考にlibprotobuf-devなどをインストールすれば簡単にビルドできます。
# criu --version Version: 3.18 GitID: v3.18-190-g50aa6da65 # criu restore --unprivileged -j Warn (criu/kerndat.c:1153): $XDG_RUNTIME_DIR not set. Cannot find location for kerndat file Warn (criu/kerndat.c:1153): $XDG_RUNTIME_DIR not set. Cannot find location for kerndat file pie: 3328: Error (criu/pie/restorer.c:343): Unable to restore capabilities: -1 pie: 3328: Error (criu/pie/restorer.c:2168): BUG at criu/pie/restorer.c:2168 Error (compel/src/lib/infect.c:1612): Task 3328 is in unexpected state: b7f Error (compel/src/lib/infect.c:1618): Task stopped with 11: Segmentation fault Error (criu/cr-restore.c:2469): Can't stop all tasks on rt_sigreturn Error (criu/cr-restore.c:2530): Killing processes because of failure on restore. The Network was unlocked so some data or a connection may have been lost. Error (criu/cr-restore.c:2557): Restoring FAILED.
capabilityのリストアに失敗しています。エラーが出ている場所をもう少し見てみます。
ret = sys_capset(&hdr, data); if (ret) { pr_err("Unable to restore capabilities: %d\n", ret); return -1; }
https://github.com/checkpoint-restore/criu/blob/criu-dev/criu/pie/restorer.c#L343
capset(2)でエラーが返されているようです。元のプロセスのcapabilityはcore-[pid].img
に記録されており*2、この情報を元にsys_capset
で復元しています。crit
を使うと、どんなcapabilityが含まれているか確認できます。
$ crit show core-3328.img | jq '.entries[0].thread_core.creds' { "uid": 0, "gid": 0, "euid": 0, "egid": 0, "suid": 0, "sgid": 0, "fsuid": 0, "fsgid": 0, "cap_inh": [ 0, 0 ], "cap_prm": [ 4294967295, 511 ], "cap_eff": [ 4294967295, 511 ], "cap_bnd": [ 4294967295, 511 ], "secbits": 0, "groups": [ 0 ] }
4294967295は32bitの最大値なので、すべてのcapabilityがセットされます。特権コンテナで実行されていたので当然ですね。つまり、criu dump
を非特権コンテナで行うとエラーを回避できる可能性があります。ちなみに、dumpにはptrace(2)が必要なので、CAP_SYS_PTRACE
の付与と、Seccompの無効化が必要です。
$ docker run --rm -v ./:/tmp/criu --cap-add CHECKPOINT_RESTORE --cap-add SYS_PTRACE --security-opt systempaths=unconfined --security-opt apparmor=unconfined --security-opt seccomp=unconfined -it ubuntu:22.04 /bin/bash # ruby loop.rb 1 2 3 4 5
# ../criu/criu dump -t 3536 --unprivileged -j Warn (criu/kerndat.c:1153): $XDG_RUNTIME_DIR not set. Cannot find location for kerndat file Warn (criu/kerndat.c:1153): $XDG_RUNTIME_DIR not set. Cannot find location for kerndat file
# ruby loop.rb 1 2 3 4 5 6 7 Killed
書き出されたファイルを使って、criu restore
を実行します。
# ../criu/criu restore --unprivileged -j Warn (criu/kerndat.c:1153): $XDG_RUNTIME_DIR not set. Cannot find location for kerndat file Warn (criu/kerndat.c:1153): $XDG_RUNTIME_DIR not set. Cannot find location for kerndat file 8 9 10 11 12
成功!!!
コンテナの起動時にプロセスをrestoreする
最終的にやりたいのは、コンテナ起動時にプロセスをリストアすることなので、一応確認しておきます。以下のようなDockerfileを用意します。
FROM ubuntu:20.04 RUN apt update && apt install -y libbsd-dev libprotobuf-c-dev libnl-3-dev libnet-dev RUN apt install -y ruby COPY ./criu/criu /usr/local/bin COPY loop.rb /work/ WORKDIR /work
docker build
の中でcriu dump
までできるとよいのですが、ptrace(2)が制限されているので、普通のビルドではできません*3。よって、docker commit
を使って、書き出されたプロセスを含むイメージを作成します。
$ docker build -t criu_restore . $ docker run --cap-add CHECKPOINT_RESTORE --cap-add SYS_PTRACE --security-opt systempaths=unconfined --security-opt apparmor=unconfined --security-opt seccomp=unconfined -it criu_restore /bin/bash # ruby loop.rb 1 2 3
$ docker exec -it c97d0bda31c6 /bin/bash # criu dump -t 21 -j --unprivileged Warn (criu/kerndat.c:1153): $XDG_RUNTIME_DIR not set. Cannot find location for kerndat file Warn (criu/kerndat.c:1153): $XDG_RUNTIME_DIR not set. Cannot find location for kerndat file # ls core-21.img files.img ids-21.img loop.rb pagemap-21.img pstree.img stats-dump tty-info.img fdinfo-2.img fs-21.img inventory.img mm-21.img pages-1.img seccomp.img timens-0.img
$ docker commit c97d0bda31c6 criu_restore:dumped
イメージができたら、コンテナの起動時にcriu restore
します。
$ docker run --rm --cap-add CHECKPOINT_RESTORE --cap-add SYS_PTRACE --security-opt systempaths=unconfined --security-opt apparmor=unconfined --security-opt seccomp=unconfined -it criu_restore:dumped criu restore -j --unprivileged Warn (criu/kerndat.c:1153): $XDG_RUNTIME_DIR not set. Cannot find location for kerndat file Warn (criu/kerndat.c:1153): $XDG_RUNTIME_DIR not set. Cannot find location for kerndat file 4 5 6
できましたね。
CRIUでコールドスタート問題が解決するかも
今回の調査で、CAP_CHECKPOINT_RESTORE
とCAP_SYS_PTRACE
さえあれば、CRIUによる単純なプロセスのリストアが可能であることがわかりました。CAP_SYS_PTRACE
は既にAWS Fargateでもサポートされており、フィードバック次第では同様にCAP_CHECKPOINT_RESTORE
がサポートされる未来があるかもしれません。
実際に、AWS LambdaではSnapStartと呼ばれるCRIUを組み込んだ機能が提供されています。これはJava向けに改修されたCRaCというプロジェクトをベースにしているそうです。
docs.aws.amazon.com developer.mamezou-tech.com
RailsアプリでCRIUの恩恵を得るためには、PumaのようなHTTPサーバーが接続を受け入れる前にcriu dump
する必要があります。このように、他にも超えないといけない壁はいくつかありそうですが、実現できると夢が広がりそうですね。
*1:https://github.com/checkpoint-restore/criu/issues/2322 で質問して教えてもらいました
*2:書き出されたファイルの詳細は https://criu.org/Images に書かれています
*3:https://github.com/moby/moby/issues/1916 を参照。--allow security.insecureを使えばできるが、特定のcapabilityだけ付与するのはできなさそう...?