*注意,"runc "的默认配置(前台、新终端)通常是大多数用户的最佳选择。
通常是大多数用户的最佳选择。本文档旨在解释不同模式的目的,并试图引导用户避免常见错误和误解。
一般来说,Unix(和类 Unix)操作系统上的大多数进程都有 3 个标准文件描述符,统称为"标准 IO (stdio
):
- 0
:标准输入(
stdin`),进程的输入流 1
: 标准输出(stdout
),进程的输出流2
: 标准错误 (stderr
),来自进程的错误流
在通过 runc
创建和运行容器时,必须注意新容器进程接收的 stdio
的结构。在某些方面容器只是普通的进程,而在其他方面,它们是你机器上一个隔离的子分区(类似于虚拟机)。这意味着IO 结构不像普通程序那么简单(普通程序通常
只是使用你给它们的文件描述符)。
其他文件描述符
在我们继续之前,需要注意的是进程可以拥有比 stdio
更多的文件描述符。在 runc
中,默认情况下不会将其他文件描述符传递给孕育容器的进程。如果希望显式地传递文件描述符,就必须使用 --preserve-fds
选项。这些辅助文件描述符不具有本文档将进一步讨论的任何奇怪的语义(这些语义只适用于 stdio
)。runc`对它们无感。
应该注意的是,--preserve-fds
并不维护单个文件文件描述符。而是传递文件描述符(不包括包括 stdio
或 LISTEN_FDS
)的个数给容器。
示例如下:
% runc run --preserve-fds 5 <container>
runc
将传递前 5
个文件描述符(3
, 4
, 5
, 6
和 7
– 假定没有配置 LISTEN_FDS
)。
除了 --preserve-fds
,LISTEN_FDS
文件描述符会自动传递,以便进行 systemd
风格的套接字激活。要扩展上述示例:
% LISTEN_PID=$pid_of_runc LISTEN_FDS=3 runc run --preserve-fds 5 <container>
现在 runc
将传递前 8
个文件描述符(它还会将 LISTEN_FDS=3
和 LISTEN_PID=1
传给容器)。前 3
个(3
、4
、和 5
)因 LISTEN_FDS
而传递,其他 5
(6
, 7
, 8
, 9
、和 10
)因 --preserve-fds
而传递。如果在类似 systemd
单元文件中直接使用 runc
时,应注意这一点。要禁用LISTEN_FDS
样式的传递,只需取消设置 LISTEN_FDS
。
将文件描述符传递给容器进程时要非常小心。
由于 Linux 内核的某些(缺少的)特性,容器在访问容器根文件系统之外的某些类型的文件描述符(如 O_PATH
描述符)时,容器可以脱离容器的支点(pivoted )挂载命名空间。这在过去曾导致 CVE。
终端模式
runc
支持两种不同的方法将 stdio
传递给容器的主进程:
- 新终端 (
terminal: true
) - pass-through (
terminal: false
)
当第一次使用 runc
时,这两种模式看起来惊人地相似,但这可能很有欺骗性。因为不同的模式的特性相当不同。
默认情况下,runc spec
会创建一个配置,同时也创建一个新的终端(terminal: true
)。但是,如果 terminal: ...
行不存在,则默认为直通模式。
一般情况下,我们建议使用新终端,因为这意味着像sudo
等工具可以在容器内运行。但如果你知道自己在做什么,或者把 runc
作为非交互式管道的一部分,直通模式会帮到你。
新终端
在新终端模式下,runc
将创建一个全新的 “控制台”(更准确地说,是一个新的伪终端,使用容器的/dev/pts/ptmx`命名空间)
以新终端模式启动进程时,runc
将执行以下操作:
- 创建一个新的伪终端。
- 将从端(slave end)作为容器主进程的
stdio
传递给它。 - 将主端(master end)发送给一个进程,以便与容器主进程(master process)的
stdio
进行交互(参考runc模式)
需要注意的是,由于与容器的通信使用的是新的伪终端,因此会出现一些奇怪的属性,这些奇怪属性可能会让你大吃一惊。例如,默认情况下,所有新的伪终端都会
都会在 stdout
和 stderr
上将字节 '\n'
翻译成序列 '\r\n'
。
此外,还有 一系列的 ioctls(2)
只能与伪终端 stdio
交互。
问题
如果您看到以下错误
open /dev/tty: no such device or address
表示无法打开终端(因为没有终端)。当可能发生在 stdin(也可能是 stdout 和 stderr)被重定向的情况下、或者在某些缺乏 tty 的环境中(如 GitHub Actions runners)。
解决这个问题的办法是不在容器中使用终端,如在config.json中配置terminal:false
。如果容器真的需要终端(某些程序需要终端),你可以使用以下方法提供终端。
一种方法是使用带有 -tt
标记的 ssh
。第二个 t
标志会强制分配终端即使本地没有终端,也会强制分配终端。这在(有些 ssh
实现只在 stdin 上寻找终端)而stdio不是终端时是需要的
另一种方法是在 script
工具下运行 runc,如下所示:
$ script -e -c 'runc run <container>'
直通
如果你已经设置了一些文件句柄,并希望作为容器的stdio来用,那么您可以让 runc
传递它们(没必要与 --preserve-fds
文件描述符传递一样–(详情参考runc模式)。示例如下(假设在 config.json
中设置了 terminal: false
):
% echo input | runc run some_container > /tmp/log.out 2> /tmp/log.err
在这里,容器的各种 stdio
文件描述符将被替换为文件描述符:
stdin
将来自echo input
管道。stdout
将输出到主机上的/tmp/log.out
。stderr
将输出到主机上的/tmp/log.err
。
应该注意的是,在容器内看到的实际文件句柄根据使用 runc
的模式,可能会有所不同(例如1
引用的文件可能是直接引用的"/tmp/log.out",也可能是 "runc "使用的缓冲输出管道)。但无论哪种情况,结果是一样的。原则上,你应该在管道线上使用 新终端模式,介绍了分离模式后,你对它们的区别会更清楚。
runc
运行模式
runc
本身以两种模式运行:
- 前台
- 分离模式
你可以在任何一种 runc
模式下使用终端模式。不过,有一些考虑因素可能会让我们更倾向于使用一种模式,而不是另一种。需要注意的是,虽然两种模式(终端和runc
)在概念上是相互独立的,但你应该知道所有组合的复杂性。
*一般来说,我们推荐使用前台模式,因为它最直接,唯一的缺点是runc
进程要长时间运行。分离模式很难正确使用,通常需要自己管理 stdio
*
前台
runc
的默认(也是最直接的)模式。在这种模式下,您的runc
命令仍处于前台,容器进程作为子进程。所有的 stdio
都通过前台的 runc
进程缓冲(无论您使用的是哪种终端模式)。这与在 shell 中交互运行一个普通进程非常相似(如果你在 shell 中使用 runc
与进程交互,就应该使用这种方式)。
因为在这种模式下,stdio
会被缓冲,所以它一些非常重要的的特殊性要牢记:
-
使用 新终端模式,容器将把一个 伪终端看作它的
stdio
(如你所料)。然而,前台runc
进程的stdio
仍将是该进程启动时的stdio
而runc
在它的stdio
和容器的stdio
之间拷贝所有stdio
。这意味着一旦创建了一个新的伪终端,runc将在容器的整个生命周期内管理它。 -
使用 直通模式,前台
runc
的stdio
将不传递给容器。 相反,容器的stdio
是一组管道,用于在runc
的stdio
和容器的stdio
之间复制数据。这意味着容器永远无法直接访问宿主文件描述符 除了容器运行时创建的管道、 但这应该不成为问题)。
前台运行模式的主要缺点是需要一个长期运行的前台 runc
进程。如果杀死前台 runc
进程进程,就再也无法访问容器的 stdio
(在大多数情况下,这将导致容器因
SIGPIPE
或其他错误)。推而广之,这意味着长期运行的前台 runc
进程中的任何错误(如内存泄漏)或一个游离的OOM-kill扫描可能会导致容器被杀死 ,而这并非用户的过失。此外,在前台模式下,没有办法将一个文件描述符作为其 stdio
直接传递给容器进程(如–preserve-fds`那样)。
这些缺陷显然是次优的,也是 runc
具有名为 "分离模式 "的额外模式的原因。
分离
与前台模式不同,在分离模式下,容器启动后没有长期运行的进程。事实上,根本没有长期运行的 runc
进程。不过,这意味着调用者来处理 runc
为你设置好的 stdio
。在 shell 中这意味着 runc
命令将退出,控制权返回 shell。
您可以通过以下方式之一在分离模式下运行 runc
:
runc run -d ...
操作类似于runc run
,但它是分离的。*runc create
之后是runc start
, 这是 OCI 运行时规范所定义的标准容器生命周期(runc create
完全设置了容器,等待runc start
开始执行用户代码)。
分离模式的主要用例是那些像包装runc的高级工具。通过在分离模式下运行 runc
,这些工具对容器的 stdio
有更多的控制,而不会受到 runc
的干扰。(cri-o
或 containerd
等 runc
的大多数封装工具都使用分离模式,原因就在于此。)
遗憾的是,使用分离模式要比使用前台模式复杂一些,需要更多的考量。主要是因为现在要由调用者来管理容器的 stdio
。
另一个复杂之处在于,父进程要负责充当容器的subreaper。简而言之,您需要在父进程中调用prctl(PR_SET_CHILD_SUBREAPER,1,…),并正确处理作为subreaper的影响。否则可能会导致在主机上累积僵尸进程。
这些任务通常由专门的(最小的)监控进程执行执行。为了便于比较,其他运行时(如 LXC)并没有等效的分离模式,而是将监控进程集成到容器运行时本身。–这需要权衡利弊,而 runc选择支持通过这种分离模式将监控责任委托给父进程
分离式直通
在分离模式下,直通实际上言出必行 – runc
进程的 stdio
文件描述符被直通(未被触及)容器的 stdio
文件描述符。该选项的目的是允许用户自己为容器设置 stdio
,然后强制 runc
只使用他们预先准备好的 stdio
。(没有任何伪终端的滑稽动作)。如果你不明白这为什么有用,就不要使用这个选项。
在使用分离直通(尤其是在 shell 中),你必须非常小心。原因是使用分离直通后,你将把主机文件描述符传递给容器。通常,你的 stdio
在你的主机上是一个伪终端。恶意容器可以利用特定于 TTY 的 ioctls
如TIOCSTI
等 TTY 特有的 ioctls
来伪造输入(请记住,在分离模式下,控制权会返回到您的 shell,因此您给容器提供的终端会被 shell 提示符读取)。
在shell 中通过分离式直通运行非恶意容器时还存在其他一些问题。
-
容器的输出将与 shell 的输出交错(以一种非确定的方式)交错在一起,而没有任何真正的方法来区分某个特定的输出来自哪里。
-
任何输入到
stdin
的内容都会被非确定地拆分,然后交给容器或 shell。(因为两者对同一 FIFO 式文件描述符的read(2)
时都会被阻塞)。
它们都与这样一个事实有关,即当您的主机或容器试图从 stdio
读取(或向 stdio
写入)时,就会发生竞争。这个问题在 shell 中尤为明显,因为在 shell 中,终端通常已被设置为原始模式(在这种模式下,每次按键都会调用read(2)
)。
注意: 目前还有一个 [已知问题][issue-1721],即使用
detached pass-through 会导致容器挂起,如果stdout
或
stdout
或stderr
是管道,使用分离直通会导致容器挂起(不过这应该只是暂时的问题)。
[issue-1721]:https://github.com/opencontainers/runc/issues/1721
分离式新终端
在分离模式下创建新的伪终端时,出现了一个相当明显的问题我们如何使用 runc
创建的新终端?与直通不同,runc
创建了一组新的文件描述符,这些文件描述符需要被需要被something使用,容器通信才能正常工作。
解决这个问题的方法是使用 Unix 域套接字。
Unix 套接字有一个名为 "SCM_RIGHTS "的功能,它允许通过一个domain套接字发送文件描述符。文件描述符通过 Unix 套接字发送到一个完全独立的进程(该进程可以像打开文件一样使用该文件描述符)。在分离的新终端模式下使用 runc
时,用户可以通过这种方式访问伪终端的主文件描述符。
为此,有一个新选项(如果要在分离的新终端模式下使用 runc
就必须使用该选项): --console-socket
。该选项使用到unux domain socket的路径,runc
将连接该路径并发送伪终端主文件描述符。获取伪终端主文件描述符的一般过程如下:
- 在某个路径
$socket_path
上创建一个 Unix 域套接字。 - 调用
runc run
或runc create
,参数为--console-socket $socket_path
。 - 使用
recvmsg(2)
获取由runc
使用SCM_RIGHTS
发送的文件描述符。 - 现在,管理器(init-process 或者是 parent container process?)可以使用检索到的伪终端主端与容器的
stdio
进行交互。
在 runc
退出后,唯一拥有伪终端主文件描述符副本的进程就是从套接字中读取文件描述符的进程。
注意: 目前
runc
不支持抽象套接字地址(由于 不可能传递第一个字符为空字节的 `argv
字符)。将来可能会改变,但目前必须使用有效的
路径名。
为了帮助用户使用分离的新终端模式,我们提供了Go-runc` 绑定中的 Go 实现]containerd/go-runc.Socket,以及[一个简单的客户端]recvtty