本文实践环境:
Operating System: Ubuntu 20.04.2 LTS
Kernel: Linux 5.8.0-50-generic
Architecture: x86-64
Docker Client/Server Version: 20.10.5
本文源于工作中遇到的一个容器进程执行的问题,这里记录一下分析过程。
使用容器运行服务,一般有两种方式:docker run -it
和 docker run -d
,前者主要是交互式,即当前终端内有输入和输出,后者主要是让容器在后台运行。如果代码中涉及到终端交互(即容器内进程运行过程中需要等待终端输入才会继续运行),那这两种方式会有哪些差别呢?
程序模拟
这里使用一个非常简单的c程序进行说明:
demonlee@demonlee-ubuntu:debug$ cat test2.c
#include <stdio.h>
#include <string.h>
#include <unistd.h>
int main (int argc, char **argv){
setbuf(stdout, NULL);
printf("test begin...\n");
int i=0;
for(; i<argc; i++){
printf("argc[%d] is:[%s]\n", i, argv[i]);
}
// 让程序休眠一会,防止后台运行结束太快,无法进行相关监测
sleep(20);
printf("sleep end...\n");
char str[2048];
memset(str, 0, sizeof(str));
// 从stdin标准输入中读取数据
fgets(str, sizeof(str), stdin);
printf("str: [%s], [%ld]..\n", str, strlen(str));
printf("test end...\n");
return 0;
}
demonlee@demonlee-ubuntu:debug$ gcc -o test2 test2.c
demonlee@demonlee-ubuntu:debug$ cat Dockerfile_v2
FROM centos:8.1.1911
COPY ./test2 /home/leostudio/
WORKDIR /home/leostudio/
ENTRYPOINT ["./test2", "2021-05-10"]
demonlee@demonlee-ubuntu:debug$ docker build -t c-signal-test:v2 -f Dockerfile_v2 .
Sending build context to Docker daemon 40.96kB
Step 1/4 : FROM centos:8.1.1911
---> 470671670cac
Step 2/4 : COPY ./test2 /home/leostudio/
---> Using cache
---> 485a9f27d498
Step 3/4 : WORKDIR /home/leostudio/
---> Using cache
---> 17970861c0bf
Step 4/4 : ENTRYPOINT ["./test2", "2021-05-10"]
---> Using cache
---> 061b98323722
Successfully built 061b98323722
Successfully tagged c-signal-test:v2
demonlee@demonlee-ubuntu:debug$
以上为相关内容文件内容及编译打包过程,下面使用两种方式运行容器。
demonlee@demonlee-ubuntu:debug$ docker run -it --name c-signal-test-it c-signal-test:v2
test begin...
argc[0] is:[./test2]
argc[1] is:[2021-05-10]
sleep end...
kkk
str: [kkk
], [4]..
test end...
demonlee@demonlee-ubuntu:debug$ docker run -d --name c-signal-test-d c-signal-test:v2
a2233600ae78df5f4042e865554d961df43a17c5554080417681654d2bc3d102
demonlee@demonlee-ubuntu:debug$ docker logs c-signal-test-d
test begin...
argc[0] is:[./test2]
argc[1] is:[2021-05-10]
sleep end...
str: [], [0]..
test end...
demonlee@demonlee-ubuntu:debug$ docker start c-signal-test-it
c-signal-test-it
demonlee@demonlee-ubuntu:debug$ docker logs -f c-signal-test-it
test begin...
argc[0] is:[./test2]
argc[1] is:[2021-05-10]
sleep end...
kkk
str: [kkk
], [4]..
test end...
test begin...
argc[0] is:[./test2]
argc[1] is:[2021-05-10]
sleep end...
可以看到:1)使用docker run -it
交互方式执行,容器(c-signal-test-it)进程会停下来等待终端输入,当输入 ‘kkk’ 并敲回车键后,程序才继续运行;2)docker run -d
方式则直接运行结束,容器(c-signal-test-d)未等待终端输入;3)当重新通过docker start 启动第一个容器(c-signal-test-it)后,此时通过docker logs -f
查看日志,发现进程在等待终端输入。
从直觉上看,这里比较让人困惑的有2处:1)后台运行的容器(c-signal-test-d)是怎么不等待标准输入,直接运行结束的? 2)第3点,docker start
重新启动容器(c-signal-test-it),也未开启终端交互,为啥也可以等待标准输入呢?
现象分析
- 通过
docker run
的命令选项来分析
官网列出的Options有好几十个(或通过docker run --help
获取),这里只将我们关注的几个摘录出来:
--attach , -a Attach to STDIN, STDOUT or STDERR
--detach , -d Run container in background and print container ID
--interactive , -i Keep STDIN open even if not attached
--tty , -t Allocate a pseudo-TTY
可以看到:
1)-t
选项让Docker分配一个伪终端(pseudo-tty)并绑定到容器的标准输入上,-i
则让容器的标准输入保持打开;
2)-d
选项,分离模式,让容器保持后台运行,然后打印一个容器id,即守护态方式运行;
3)-a
选项,在前台运行模式下,将控制台附加到容器进程的标准输入、标准输出或标准错误输出上,如果不指定,则默认附加全部(即STDIN, STDOUT and STDERR)(参考1, 参考2)
通过docker inspect <containerId>
将容器的信息展示出来后,将二者进行比较(左边使用-it
选项,右边使用-d
选项):
从图中可以发现,使用-it
选项创建的容器,其中的tty、OpenStdin等选项都是true,即为该容器分配了控制终端,打开了标准输入等。所以无论是使用哪种方式运行容器,这些都是存在的。而-d
选项创建的容器则没有这些,因而无法进行输入。
通过
docker start
再次启动容器后,可以使用docker attach
将STDIN, STDOUT 和 STDERR附加到该容器上,从而实现输入输出交互。
- 通过系统调用分析
上面算是从理论上进行的分析,具体后台运行的容器是如何跳过标准输入的呢?
思考一下,我们可以发现:代码要从STDIN中读取数据,那么肯定要进行系统调用,即调用内核的函数完成数据的获取。依据这个思路,我们就能通过strace工具来跟踪了(先使用ps命令找到进程pid)。
# 后台运行容器:docker run -d
demonlee@demonlee-ubuntu:process$ sudo strace -p 55796
[sudo] password for demonlee:
strace: Process 55796 attached
restart_syscall(<... resuming interrupted read ...>) = 0
write(1, "sleep end...", 12) = 12
write(1, "\n", 1) = 1
fstat(0, {st_mode=S_IFCHR|0666, st_rdev=makedev(0x1, 0x3), ...}) = 0
# 注意下面这一行报错
ioctl(0, TCGETS, 0x7fff8a8952d0) = -1 ENOTTY (Inappropriate ioctl for device)
brk(NULL) = 0x55ce8215e000
brk(0x55ce8217f000) = 0x55ce8217f000
brk(NULL) = 0x55ce8217f000
read(0, "", 4096) = 0
write(1, "str: [], [0]..\n", 15) = 15
write(1, "test end...", 11) = 11
write(1, "\n", 1) = 1
exit_group(0) = ?
+++ exited with 0 +++
demonlee@demonlee-ubuntu:process$
# 前台运行容器: docker run -it
demonlee@demonlee-ubuntu:process$ sudo strace -p 59872
[sudo] password for demonlee:
strace: Process 59872 attached
restart_syscall(<... resuming interrupted read ...>) = 0
write(1, "sleep end...", 12) = 12
write(1, "\n", 1) = 1
fstat(0, {st_mode=S_IFCHR|0620, st_rdev=makedev(0x88, 0), ...}) = 0
brk(NULL) = 0x564e1a166000
brk(0x564e1a187000) = 0x564e1a187000
brk(NULL) = 0x564e1a187000
# 日志打印到下面这一行,就开始等待,当(在另一个终端)输入“hello, world”并敲击回车之后才继续打印后续的日志
read(0, 0x564e1a166260, 1024) = ? ERESTARTSYS (To be restarted if SA_RESTART is set)
--- SIGWINCH {si_signo=SIGWINCH, si_code=SI_KERNEL} ---
read(0, 0x564e1a166260, 1024) = ? ERESTARTSYS (To be restarted if SA_RESTART is set)
--- SIGWINCH {si_signo=SIGWINCH, si_code=SI_KERNEL} ---
read(0, "hello,world\n", 1024) = 12
write(1, "str: [hello,world\n], [12]..\n", 28) = 28
write(1, "test end...", 11) = 11
write(1, "\n", 1) = 1
exit_group(0) = ?
+++ exited with 0 +++
demonlee@demonlee-ubuntu:process$
从上面的注释,我们发现docker run -d
创建的容器中有一行报错:
ioctl(0, TCGETS, 0x7fff8a8952d0) = -1 ENOTTY (Inappropriate ioctl for device)
通过man 2 ioctl
来查阅一下这个函数:
IOCTL(2) Linux Programmer's Manual
NAME
ioctl - control device
SYNOPSIS
#include <sys/ioctl.h>
int ioctl(int fd, unsigned long request, ...);
DESCRIPTION
The ioctl() system call manipulates the underlying device parameters of special files. In particular, many operating characteristics of character special files (e.g., terminals) may be controlled with ioctl() requests. The argument fd must be an open file descriptor.
The second argument is a device-dependent request code. The third argument is an untyped pointer to memory. It's traditionally char *argp(from the days before void * was valid C), and will be so named for this discussion.
An ioctl() request has encoded in it whether the argument is an in parameter or out parameter, and the size of the argument argp in bytes.
Macros and defines used in specifying an ioctl() request are located in the file <sys/ioctl.h>.
RETURN VALUE
Usually, on success zero is returned. A few ioctl() requests use the return value as an output parameter and return a nonnegative value on success. On error, -1 is returned, and errno is set appropriately.
ERRORS
EBADF fd is not a valid file descriptor.
EFAULT argp references an inaccessible memory area.
EINVAL request or argp is not valid.
ENOTTY fd is not associated with a character special device.
ENOTTY The specified request does not apply to the kind of object that the file descriptor fd references.
...
int ioctl(int fd, unsigned long request, ...)
中的第一个参数表示文件描述符,且 “The argument fd must be an open file descriptor.”,如果调用发生错误,则返回-1,并设置errno。
看到这里,就理解了上面的那行报错了:0号文件描述符没有被打开,导致ioctl调用失败(ENOTTY (Inappropriate ioctl for device)),对应函数返回-1,程序接着运行。
总结
使用docker run -d
运行容器,不会打开标准输入,如果代码中需要从标准输入获取信息,只能通过docker run -it
创建容器,以前台交互式的方式运行。