容器启动运行模式以及附加分离原理
在发现很多教程文档里进行容器交互时都是直接使用 -it 参数为例子,并没有更多解释说明 -i、-t 的含义及区别,笔者带着疑惑试图去理解说明其中原理,本文中如有错误麻烦指出 😀。
以下例子中使用 podman 工具,与 docker 客户端操作相同。
容器启动模式
在制作镜像时,Dockerfile 必须至少指定 CMD 或 ENTRYPOINT 其中的一个来启动默认程序,通常我们会有以下三种程序运行模式:
- 默认模式:直接运行一个可执行程序,执行完输出结果即结束
- 交互模式:前台启动一个可持续交互运行的程序
- 分离模式:启动一个后台持续运行的程序
默认模式
$ podman run --rm ubuntu pwd
/
执行完 pwd 命令后容器就结束退出,然后在 --rm 效果下自动删除容器:
$ podman ps -a
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
分离模式
[core@localhost ~]$ podman run --rm nginx
...
在进程完成之前我们不能返回到我们的 shell 提示符。默认情况下容器进程的标准输出 ( stdout ) 和标准错误 ( stderr ) 流与我们的终端链接。因此,我们可以在终端中看到容器的控制台输出。
分离模式把容器作为守护进程,并与会话的控制终端分离。我们使用 -d|--detach 选项以分离模式运行容器:
[core@localhost ~]$ podman run --rm -d nginx
43765140fad728f0241c75f4f5be5b924b7a5cbee632af15ac045157bd6502a1
交互模式
-a|--attach:即将终端附加到容器的标准流(STDIN, STDOUT和STDERR),这意味着我们可以通过终端输入输出与进程交互。如果没有指定 -a 选项,默认会 attach stdout 和 stderr。上面的命令效果相当于下面:
$ podman run --rm -a stdout -a stderr ubuntu pwd
/
但是当我们执行以下命令开启与 bash 交互,却发现容器给停止了。
ubunut 镜像默认启动程序 Bash
$ podman run --rm -a stdin -a stdout ubuntu
尽管我们 attach 容器的 stdin,但容器的输入流实际上是关闭的:
先通过 sleep 启动阻塞程序,
$ podman run --rm -a stdin -a stdout ubuntu sleep 30000
再通过另一个终端查看详情:
[core@localhost ~]$ lsns
NS TYPE NPROCS PID USER COMMAND
4026531834 time 13 1460 core /usr/lib/systemd/systemd --user
4026531835 cgroup 12 1460 core /usr/lib/systemd/systemd --user
4026531836 pid 129 1460 core /usr/lib/systemd/systemd --user
4026531837 user 7 1460 core /usr/lib/systemd/systemd --user
4026531838 uts 12 1460 core /usr/lib/systemd/systemd --user
4026531839 ipc 12 1460 core /usr/lib/systemd/systemd --user
4026531840 net 12 1460 core /usr/lib/systemd/systemd --user
4026531841 mnt 7 1460 core /usr/lib/systemd/systemd --user
4026532285 user 123 1649 core catatonit -P
4026532351 mnt 4 1649 core catatonit -P
4026532422 net 1 25717 core sleep 30000
4026532545 mnt 1 25717 core sleep 30000
4026532546 mnt 1 25679 core /usr/bin/slirp4netns --disable-host-loopback --mtu=65520 --enable-sandbox --enable-seccomp --enable-ipv6 -c -r 3 --netns-t
4026532550 uts 1 25717 core sleep 30000
4026532551 ipc 1 25717 core sleep 30000
4026532552 pid 1 25717 core sleep 30000
4026532553 cgroup 1 25717 core sleep 30000
进程 25717 即我们刚刚启动的容器。
[core@localhost ~]$ lsof -p 25717
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
sleep 25717 core cwd DIR 0,49 28 41943573 /
sleep 25717 core rtd DIR 0,49 28 41943573 /
sleep 25717 core txt REG 0,49 35000 134218012 /bin/sleep
sleep 25717 core mem REG 252,4 134218012 /bin/sleep (path dev=0,46, inode=2885535)
sleep 25717 core mem REG 252,4 202375315 /lib/x86_64-linux-gnu/libc-2.27.so (stat: No such file or directory)
sleep 25717 core mem REG 252,4 202375297 /lib/x86_64-linux-gnu/ld-2.27.so (stat: No such file or directory)
sleep 25717 core 0u CHR 1,3 0t0 4 /dev/null
sleep 25717 core 1w FIFO 0,13 0t0 2328957 pipe
sleep 25717 core 2w FIFO 0,13 0t0 2328958 pipe
其中 sleep 25717 core 0u CHR 1,3 0t0 4 /dev/null 的 0u 即是容器的输入流,发现是字符设备 /dev/null。
Linux 中的
/dev/null是一个空设备文件。这将丢弃任何写入它的内容,并在读取时返回 EOF。
interactive 选项
-i|--interactive:让容器的标准输入保持打开状态,即使容器的标准输入,输出和错误流没有被附加。
附带 -i 时 -a stdin 相当默认带上。重复上面实验操作:
[core@localhost ~]$ podman run --rm -i ubuntus
[core@localhost ~]$ lsns
NS TYPE NPROCS PID USER COMMAND
4026531834 time 13 1460 core /usr/lib/systemd/systemd --user
4026531835 cgroup 12 1460 core /usr/lib/systemd/systemd --user
4026531836 pid 129 1460 core /usr/lib/systemd/systemd --user
4026531837 user 7 1460 core /usr/lib/systemd/systemd --user
4026531838 uts 12 1460 core /usr/lib/systemd/systemd --user
4026531839 ipc 12 1460 core /usr/lib/systemd/systemd --user
4026531840 net 12 1460 core /usr/lib/systemd/systemd --user
4026531841 mnt 7 1460 core /usr/lib/systemd/systemd --user
4026532285 user 123 1649 core catatonit -P
4026532351 mnt 4 1649 core catatonit -P
4026532422 net 1 25827 core bash
4026532545 mnt 1 25827 core bash
4026532546 mnt 1 25791 core /usr/bin/slirp4netns --disable-host-loopback --mtu=65520 --enable-sandbox --enable-seccomp --enable-ipv6 -c -r 3 --netns-t
4026532550 uts 1 25827 core bash
4026532551 ipc 1 25827 core bash
4026532552 pid 1 25827 core bash
4026532553 cgroup 1 25827 core bash
[core@localhost ~]$ lsof -p 25827
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
bash 25827 core cwd DIR 0,49 28 57671966 /
bash 25827 core rtd DIR 0,49 28 57671966 /
bash 25827 core txt REG 0,49 1113504 134217858 /bin/bash
bash 25827 core mem REG 252,4 134217858 /bin/bash (path dev=0,46, inode=2117823)
bash 25827 core mem REG 252,4 202375451 /lib/x86_64-linux-gnu/libnss_files-2.27.so (stat: No such file or directory)
bash 25827 core mem REG 252,4 202375445 /lib/x86_64-linux-gnu/libnsl-2.27.so (stat: No such file or directory)
bash 25827 core mem REG 252,4 202375455 /lib/x86_64-linux-gnu/libnss_nis-2.27.so (stat: No such file or directory)
bash 25827 core mem REG 252,4 202375447 /lib/x86_64-linux-gnu/libnss_compat-2.27.so (stat: No such file or directory)
bash 25827 core mem REG 252,4 202375315 /lib/x86_64-linux-gnu/libc-2.27.so (stat: No such file or directory)
bash 25827 core mem REG 252,4 202375355 /lib/x86_64-linux-gnu/libdl-2.27.so (stat: No such file or directory)
bash 25827 core mem REG 252,4 202375489 /lib/x86_64-linux-gnu/libtinfo.so.5.9 (stat: No such file or directory)
bash 25827 core mem REG 252,4 202375297 /lib/x86_64-linux-gnu/ld-2.27.so (stat: No such file or directory)
bash 25827 core 0r FIFO 0,13 0t0 2329821 pipe
bash 25827 core 1w FIFO 0,13 0t0 2329822 pipe
bash 25827 core 2w FIFO 0,13 0t0 2329823 pipe
这次发现输入流是一个命名管道,终端与容器之间的交互是通过管道文件!实际上除了通过管道文件进行交互,还能通过 TTY 设备文件。
tty 选项
了解更多 TTY
TTY (电传打字机 teletype 的缩写) 过去是计算机附加的终端设备,现在 Linux 的中 TTY 是一个软件仿真终端的概念,当我们打开一个终端软件如 zsh,在“一切皆文件”的 Linux 中有着对应的设备文件,输入 tty 可查到对应的设备文件:
$ tty
/dev/ttys003
为此,我们只需要把容器的标准流绑定到 TTY 上,对着 TTY 设备文件读写即可与用户终端进行交互。当用户在控制台中启动普通程序时,通常其 stdin、stdout 和 stderr 流将连接到会话的控制终端:
$ tty
/dev/ttys003
$ cat
然后在打开另一个终端,查找 cat 进程相关信息
$ tty
/dev/ttys004
$ lsof -p 18825
...
cat 18825 lianyuansheng 0u CHR 16,3 0t373 1525 /dev/ttys003
cat 18825 lianyuansheng 1u CHR 16,3 0t373 1525 /dev/ttys003
cat 18825 lianyuansheng 2u CHR 16,3 0t373 1525 /dev/ttys003
...
可以看出 cat 子进程的标准流自动绑定到 ttys003 上。但进程的控制终端不一定总是与它的流所连接的终端相同。您可以使用以下ps命令查看这些内容:
$ sudo ps -ax -o pid,tty
PID TTY
1 ??
50 ttys003
51 ??
- TTY 列表示进程的控制终端
- ?? 意味着守护进程
回到容器的启动选项:
-t|--tty:为容器分配一个伪 TTY 终端并绑定到容器的标准输上。
上面所说的 TTY 是内核提供的作为系统操作交互的一部分,而伪终端 PTY(pseudo TTY)则是运行在用户态,可自定义于进程间的交互。通过打开特殊的设备文件 /dev/ptmx 创建,由一对双向的字符设备构成,称为 PTY master 和 PTY slave, PTY master 和 PTY slave 之间进行双向读写。
[core@localhost ~]$ podman run --rm -t -a stdout -a stdin ubuntu
root@f1dc70c90896:/# pwd
/
root@f1dc70c90896:/#
启动另一个终端查看到进程绑定了 PTY slave:
[core@localhost ~]$ lsof -p 29269
...
bash 29269 core 0u CHR 136,0 0t0 3 /dev/pts/0
bash 29269 core 1u CHR 136,0 0t0 3 /dev/pts/0
bash 29269 core 2u CHR 136,0 0t0 3 /dev/pts/0
...
那么 PTY master 呢?PTY master 是绑定在哪里?由谁操作?查看终端与 Podman 进程关联到同一 PTY 设备文件:
[core@localhost ~]$ ps a
...
29188 pts/0 Ss 0:00 -bash
29223 pts/0 Sl+ 0:09 podman run --rm -t -a stdin -a stdout ubuntu
...
[core@localhost ~]$ lsof /dev/pts/0
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
bash 29188 core 0u CHR 136,0 0t0 3 /dev/pts/0
bash 29188 core 1u CHR 136,0 0t0 3 /dev/pts/0
bash 29188 core 2u CHR 136,0 0t0 3 /dev/pts/0
bash 29188 core 255u CHR 136,0 0t0 3 /dev/pts/0
podman 29223 core 0u CHR 136,0 0t0 3 /dev/pts/0
podman 29223 core 1u CHR 136,0 0t0 3 /dev/pts/0
podman 29223 core 2u CHR 136,0 0t0 3 /dev/pts/0
整体架构如下:
+----------+ +----------+ +---------------+
| Terminal | ---[attach]--- | Podman | <-- [pty] -- read/write -- [pts] --> | Container |
+----------+ +----------+ +---------------+
Terminal Fork 出的 Podman 进程并且其持有 PTY master 设备。这样通过中间的 Podman 进程,终端即可间接与容器交互。
后半部分可以换做管道 FIFO,即 -i 参数下的模式:
+----------+ +----------+ +---------------+
| Terminal | ---[attach]--- | Podman | <-- FIFO --> | Container |
+----------+ +----------+ +---------------+
-it
从前面我们知道容器交互的原理,并且有两个交互启动参数:
-i:打开容器输入流,通过管道文件交互-t:为容器分配伪终端,通过伪终端设备文件交互
但通常我们会更推荐使用 -it 组合参数:
-i:将终端附加到容器- 通过伪终端与进程交互,因为终端(包括伪终端)具有某些属性可以控制显示格式
- 并且还会拦截处理特殊键序,发送进程信号,比如
- 当用户按 CTRL+c 时,它向连接到 PTY slave 的进程发送 kill -2(SIGINT) 信号
- 当用户按 CTRL+z 时,它向连接到 PTY slave 的进程发送 kill -STOP 信号
从附加模式中分离
CTRL-c 通常是终端结束会话回到原 shell 提示符的常用方法。而在附加模式(默认模式和交互模式)下的容器,输入 CTRL-c 可能会导致容器停止。以附加模式启动的 nginx:
[core@localhost ~]$ podman run --rm nginx
在键盘输入 CTRL-c 后容器停止。有时我们不希望 CTRL+c 断开时杀死容器,那怎么办?实际原理上 CTRL-c 会被终端拦截发送成 SIGINT 信号,然后被 Podman 代理转发到容器:
+----------+ +----------+ +---------------+
| Terminal | ---[Signal]---> | Podman | --[Signal]--> | Container |
+----------+ +----------+ +---------------+
让我们覆盖传递 -–sig-proxy=false 的行为:
[core@localhost ~]$ podman run --rm --sig-proxy=false nginx
...输出省略
输入 CTRL-c 后,容器还在后台运行。
^C[core@localhost ~]$ podman ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
59841a040b52 localhost/nginx:latest nginx -g daemon o... 5 seconds ago Up 5 seconds ago gallant_shirley
[core@localhost ~]$
其实在这过程分了终端与容器,最终被销毁的是中间的 Podman 进程。我们在终端输入以下命令,不断循环捕获 SIGINT 信号,然后按 CTRL-c :
[core@localhost ~]$ podman run --rm --sig-proxy=false ubuntu /bin/bash -c 'trap "echo got it" 2;while :;do sleep 0.1;done'
^C[core@localhost ~]$
在另一个终端查询进程信息:
[core@localhost ~]$ ps a
PID TTY STAT TIME COMMAND
5452 pts/0 Ss 0:00 -bash
100715 pts/0 Sl+ 0:00 podman run --rm --sig-proxy=false ubuntu /bin/bash -c trap "echo got it" 2;while :;do sleep 0.1;done
100933 pts/1 R+ 0:00 ps a
// 输入 `CTRL-c` 后
[core@localhost ~]$ ps a
PID TTY STAT TIME COMMAND
5452 pts/0 Ss+ 0:00 -bash
101053 pts/1 R+ 0:00 ps a
[core@localhost ~]$ podman ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
cb060aeb4618 localhost/ubuntu:latest /bin/bash -c trap... 43 seconds ago Up 43 seconds ago romantic_villani
从实验上看,输入 CTRL-c 后,并没有捕获到信号输出 echo got it;podman 进程销毁了;而容器依旧还在运行。
再来看个交互模式下的例子:
[core@localhost ~]$ podman run --rm --sig-proxy=false -it nginx
...输出省略
输入 CTRL-c 后发现容器竟然终止了,-–sig-proxy=false 不起效,原因是作为 TTY 交互模式下,CTRL-c 殊键序作为伪终端输入会被拦截处理特,发送信号给了容器里的 1 号进程。
所以在附加模式下与容器分离时要特别注意对容器造成影响。可通过 --detach-keys 可以自定义分离键序:
[core@localhost ~]$ podman run --rm --sig-proxy=false -it --detach-keys="ctrl-x" nginx
...输出省略
键入 CTRL-x 后,容器还在后台运行。
[core@localhost ~]$ podman ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
fdaa412851a2 localhost/nginx:latest nginx -g daemon o... 6 seconds ago Up 6 seconds ago sweet_hofstadter
kill 1 号进程
有了前面经验后,我们尝试下面例子,却发现再怎么输入 CTRL-c 都没法终止容器回到原终端。难道容器没有收到 SIGINT 信号?其实不然。
[core@localhost ~]$ podman run --rm -it ubuntu
root@6df454625261:/# ^C
root@6df454625261:/# ^C
root@6df454625261:/#
答案参考《容器实战高手课》- 02 | 理解进程(1):为什么我在容器中不能kill 1号进程?
TL;DR:
- 每个容器内部都会启动一个用户态的 init 进程,称为 1 号进程。1 号进程最基本的功能都是创建出 Linux 系统中其他所有的进程,并且管理这些进程。虽然最佳方式是一个容器只启动一个进程,但我们难免会启动一些监控进程等,而且很多时候跑在容器的应用服务是多进程架构。
- Linux 内核针对每个 Nnamespace 里的 init 进程,把只有 default handler 的信号都给忽略了
- 用户可以自己注册了 handler 编码退出,这样初始进程是可以响应 SIGINT 或 SIGTERM 等信号
- 但请注意在 Linux 中 SIGKILL 和 SIGSTOP 这两个特权信号不允许自定义捕获
attach vs exec - 有什么区别?
与正在运行的容器进行交互有两条命令:
podman attach <cid>:跟容器的 1 号进程交互podman exec <cid> [option] <commond>:在容器中启动新的进程执行命令
理解了前面内容,这两条命令原理也就差不多了。区分它们关键在于是否与容器 1 号进程交互,同时要注意对信号对容器的影响。
总结
最后总结,主要理解:
- 容器的启动运行模式:附加模式、分离模式
- 附加模式
- 默认模式
- 交互模式
- 附加模式
还有交互模式下 -i(使用管道)、-t(使用 TTY) 区别以及大致架构 Terminal = Podman <=> Container。最后容器对信号量处理问题(忽略所有信号,除非自定义捕获),需要特别注意对容器影响。