criu restoreを非特権コンテナから呼ぶことはできるか

CRIUによるプロセスのリストアには複数のcapabilityが必要であり、コンテナ内部で行うためには--privileged相当で起動された特権コンテナでなければいけません。例えば、TCP connection repairCAP_NET_ADMINが必要なのは明らかですが、もっと単純なプロセスであれば非特権コンテナでもリストアできるのでは...?と考えたのが事の始まりです。

背景

仕事で面倒を見ているRailsアプリの起動が遅いという問題がありました。起動が遅いとさまざまな弊害があるのですが、その一つにオートスケールが間に合わないというのがあります。

AWS FargeteGoogle Cloud Runといった現代的なサーバーレスソリューションでは、ひとつひとつのコンピューティングリソースは小さく、リクエスト数の増加に応じて水平にスケールします。このようなアーキテクチャでは、コンテナの起動が遅いと以下のような問題があります。

  • 一部のリクエストへのレスポンス速度が悪化する、タイムアウトする(コールドスタート問題)
  • 処理待ちのリクエストが増えやすくなり、過剰にスケールアウトする

これらの問題を防ぐためには、あらかじめコンテナを多めに起動しておく必要があるのですが、当然ながら追加の費用がかかり、オートスケーリングのメリットを享受できません。

なんとか起動を早くする方法が無いかと調べていたところ、CRIUの存在を知りました。

github.com

CRIUは実行中のプロセスの状態をファイルに書き出し(チェックポイント)、そこから再開(リストア)する機能を提供します。CRIUによるチェックポイント/リストアの動作イメージは上記リポジトリのsimple loopのデモが参考になります。C言語で記述されたループカウンタ変数iの状態が復元されていることがわかります。

CRIU: Checkpoint and restore of simple loop processasciinema.org

これを使って、時間のかかる初期化処理を既に終えたプロセスをリストアすることで、高速に起動できないかと考えました。ちなみに、同様のアイディアは過去にudzuraさんがHaconiwaで検討しています。

udzura.hatenablog.jp

しかし、冒頭でも書いた通り、コンテナ内でcriu restoreを実行するためには特権が必要であり、FargateやCloud Runでは利用できません。なんとかならないものか。

リストアの仕組み

そもそもCRIUはどうやってプロセスのリストアを実現しているのでしょうか。特にループカウンタの状態を復元するなんて、どんな魔法を使っているのか見当もつきません。

復元される対象はいくつかあるのですが、ループカウンタに関して言えば「実行中のプロセスにマッピングされているメモリの中身をファイルに書き出して、新しく起動したプロセス上で、元の仮想アドレスに読み込んだメモリの中身をmmapする」ことで実現しているようです(かなり曖昧な理解です)。

criu.org

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というオプションの存在に気付きました。これは求めていたものでは!?

github.com

この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

git.kernel.org

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を調べてみたら、同じような事象の報告がありました。

github.com

どうやら/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で修正されている問題のようでした。

github.com

ということで、この修正が取り込まれたバージョンをビルドして使います。最新の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_RESTORECAP_SYS_PTRACEさえあれば、CRIUによる単純なプロセスのリストアが可能であることがわかりました。CAP_SYS_PTRACEは既にAWS Fargateでもサポートされており、フィードバック次第では同様にCAP_CHECKPOINT_RESTOREがサポートされる未来があるかもしれません。

github.com

実際に、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だけ付与するのはできなさそう...?