*注意,"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]:github.com/opencontain…
分离式新终端
在分离模式下创建新的伪终端时,出现了一个相当明显的问题
我们如何使用 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