很多 docker 初学者,在运行容器的时候,或者是写第一个 dockerfile 的时候,问题最多的就是容器启动后就停了,怎么看都觉得命令没有问题,容器也没有错误日志,dockerfile 也就那么几条……
其实你没有错,错的是 docker,它执行的太快了
这话怎么说呢,我拿 nginx 官方的 dockerfile 给你解释下。
上面是 nginx 官方的 dockerfile 文件,我把set部分删掉了,其他没啥,主要看下CMD
为什么这里不是systemctl nginx start
,或者/etc/init.d/nginx start
,再或者 nginx 直接启动,而是用 daemon off 的方式启动?
这是因为如果 nginx 用后台模式运行,启动的命令执行完之后,这个启动的命令就退出了,这个时候,容器也就跟着退出了。
又为什么命令执行完,容器就退出了?这个要从 Linux 内核说起。
在 Linux 操作系统中,当内核初始化完毕之后,会启动一个 init 进程,这个进程是整个操作系统的第一个用户进程,所以它的进程 ID 为 1,也就是我们常说的 PID1 进程,然后所有的用户态进程,都是这个进程的子进程,所以,整个系统的用户进程,都是由init进程作为根进程的。
要了解这个PID1进程,要从以下几个概念了解:
- 进程表项
Linux 内核程序通过进程表对进程进行管理, 每个进程在进程表中占有一项,称为进程表项,它记录了进程的状态,打开的文件描述符等等一系统信息。当一个进程结束了运行或在半途中终止了运行,那么内核就需要释放该进程所占用的系统资源。这包括进程运行时打开的文件,申请的内存等。
但是,这里要注意的是,进程表项并没有随着进程的退出而被清除,它会一直占用内核的内存。为什么会有这么奇怪的行为呢?这是因为在某些程序中,我们必须明确地知道进程的退出状态等信息,而这些信息的获取是由父进程调用wait/waitpid而获取的。
设想这样一种场景,如果子进程在退出的时候直接清除文件表项的话,那么父进程就很可能没有地方获取进程的退出状态了,因此操作系统就会将文件表项一直保留至wait/waitpid 系统调用结束。
- 僵尸进程
僵尸进程指的是:进程退出后,到其父进程还未对其调用wait/waitpid之间的这段时间所处的状态。一般来说,这种状态持续的时间很短,所以我们一般很难在系统中捕捉到。但是,一些粗心的程序员可能会忘记调用wait/waitpid,或者由于某种原因未执行该调用等等,那么这个时候就会出现长期驻留的僵尸进程了。如果大量的产生僵尸进程,其进程号就会一直被占用,可能导致系统不能产生新的进程。
然后还有我们经常会见到的一种情况,就是父进程先于子进程结束,这种情况多见于手动kill某个父进程的情况,这种情况就是下面要说到的。
- 孤儿进程
父进程先于子进程退出,那么子进程将成为孤儿进程。孤儿进程将被init进程(进程号为1)接管,并由init进程对它完成状态收集(wait/waitpid)工作。
PID1负责清理那些被抛弃的进程所留下来的痕迹,有效的回收的系统资源,保证系统长时间稳定的运行
了解了Linux 的 PID1,接着来看下容器中的 PID1 进程。
熟悉docker都知道,docker容器并不是一个完整的linux的操作系统,它也没什么内核初始化过程,更没有像init(1)这样的初始化过程。在docker容器中被标志为PID1的进程实际上就是一个普通的用户进程,我们还拿nginx官方的镜像起的容器来看。
我用docker run -d nginx直接启动的
可以看到,就是Dockerfile中指定的CMD那个进程,注意:如果你启动容器的时候,指定了命令,会覆盖CMD,也就是CMD是条默认启动的命令参数,如果启动容器时指定了命令,会覆盖,当Dockerfile中有多条CMD时,执行最后一条
这个进程其实在宿主机上有一个普通的用户进程ID
之所以在容器中PID变成1,是因为linux内核提供的PID namespaces功能,如果宿主机上所有用户进程构成了一个完整的树形结构,那么PID namespaces实际上就是将这个CMD或ENTRYPOINT进程及其子进程作为另外一个分支,很显然这部分也是一个树形结构
当我们在宿主机上kill掉这个进程ID,那么整个容器便会处于退出状态
这也就解释了上面为什么命令执行完之后,容器就退出了
认真的小伙伴从上面图中看到了,我上面说linux中PID1进程为所有用户进程的父进程,但是在容器里面,通过ps命令看到的进程的父进程都是“0”,这又是为什么呢?
前面提到,容器中的进程树实际上是宿主机进程树的一棵子树,或者说分支,那么我们在宿主机上就可以找到这颗子树的父进程。
我们可以看到,这个docker容器中PID 0的进程应该就是这个containerd-shim
我们结合docker的结构图看一下
从架构图中,我们可以看到 containerd-shim 进程下还有一个 runC 进程,但是我们在上面过程中,并没有发现 runC 这个进程。
runC 是 OCI 标准的一个参考实现,而 OCI Open Container Initiative,是由多家公司共同成立的项目,并由 Linux 基金会进行管理,致力于 container runtime 的标准的制定和 runc 的开发等工作。runc,是对于 OCI 标准的一个参考实现,是一个可以用于创建和运行容器的 CLI(command-line interface)工具。
runc直接与容器所依赖的 cgroup/linux kernel 等进行交互,负责为容器配置cgroup/namespace 等启动容器所需的环境,创建启动容器的相关进程。
事实上,Docker 容器的创建过程是这样子的 docker-containerd-shim –> runC –> entrypoint,而我们看到的最终状态是 docker-containerd-shim –> entrypoint,而runc进程创建完容器之后,自己就先退出去了,所以我们上面的过程中一直没有出现。
看到这里你应该了解,为什么你启动容器或写好的dockerfile,总是刚启动就退出,而且没有任何错误了吧!