走进docker(07):docker start命令背后发生了什么?

上一篇介绍过了docker create以后,这篇来看看docker start是怎么根据create以后的结果运行容器的。html

启动容器

在这里咱们先启动上一篇中建立的那个容器,而后看看docker都干了些什么。python

#根据容器名称启动容器(也能够根据容器ID来启动)
root@dev:~# docker start docker_test
docker_test

#能够看出容器正在后台运行bash
root@dev:~# docker ps
CONTAINER ID   IMAGE    COMMAND       CREATED          STATUS         PORTS   NAMES
967438113fba   ubuntu   "/bin/bash"   38 minutes ago   Up 8 seconds           docker_test

start的大概流程

  • docker(client)发送启动容器命令给dockerdlinux

  • dockerd收到请求后,准备好rootfs,以及一些其它的配置文件,而后经过grpc的方式通知containerd启动容器docker

  • containerd根据收到的请求以及配置文件位置,建立容器运行时须要的bundle,而后启动shim进程,让它来启动容器json

  • shim进程启动后,作一些准备工做,而后调用runc启动容器ubuntu

下面就来详细的了解一下每一步都干了些什么。segmentfault

dockerd

首先来看看dockerd收到客户端的启动容器请求后,作了些什么。bash

准备rootfs

dockerd作的第一件事情就是准备好容器运行时须要的rootfs,因为在docker create建立容器的时候,容器的全部layer都已经准备好了,如今就差一步将他们合并起来了,对于aufs来讲,须要经过mount的方式将全部的layer合并起来,对于其余的文件系统来讲,有些可能不须要这一步,/var/lib/docker/aufs/mnt下面已是合并好的rootfs了。服务器

下面来看看这个容器启动以后/var/lib/docker/aufs/mnt下的内容。网络

#init目录下没有文件
root@dev:/var/lib/docker/aufs/mnt# tree 305226f2e0755956ada28b3baf39b18fa328f1a59fd90e0b759a239773db2281-init/
305226f2e0755956ada28b3baf39b18fa328f1a59fd90e0b759a239773db2281-init/

0 directories, 0 files

#305226f...目录下面的内容就是rootfs的内容,包含了大量的文件
root@dev:/var/lib/docker/aufs/mnt# tree 305226f2e0755956ada28b3baf39b18fa328f1a59fd90e0b759a239773db2281 | tail
    │   ├── lastlog
    │   └── wtmp
    ├── mail
    ├── opt
    ├── run -> /run
    ├── spool
    │   └── mail -> ../mail
    └── tmp

692 directories, 4804 files

#虽然在容器中,/dev/console,/etc/hosts,/etc/hostname,
#/etc/resolv.conf这几个文件都有内容,
#但从外面主机的mount namespace中来看的话,仍是空的,
#由于bind mount发生在容器中的mount namespace中,因此外面根本就看不到
root@dev:/var/lib/docker/aufs/mnt/305226f2e0755956ada28b3baf39b18fa328f1a59fd90e0b759a239773db2281# ls -l ./dev/console ./etc/hosts ./etc/hostname ./etc/resolv.conf
-rwxr-xr-x 1 root root 0 Jun 25 11:25 ./dev/console
-rwxr-xr-x 1 root root 0 Jun 25 11:25 ./etc/hostname
-rwxr-xr-x 1 root root 0 Jun 25 11:25 ./etc/hosts
-rwxr-xr-x 1 root root 0 Jun 25 11:25 ./etc/resolv.conf

和上一篇中create以后的内容相比,惟一的差异就是305226f...目录下有了内容,而init目录下仍是空的,说明对于aufs文件系统来讲,它只须要构造好最上面的一层就能够了,不须要init层和它下面全部层合并以后的结果,你们有兴趣的话能够检查一下/var/lib/docker/aufs/mnt目录下的其它目录的内容,会发现其它层的文件夹也全是空的,由于aufs只在运行的时候动态的将容器的最上面一层和下面的全部层进行合并,合并的过程等同于下面的命令:

root@dev:/var/lib/docker/aufs/diff# mkdir /tmp/rootfs
root@dev:/var/lib/docker/aufs/diff# mount -t aufs -o br=./305226f2e0755956ada28b3baf39b18fa328f1a59fd90e0b759a239773db2281=rw:./305226f2e0755956ada28b3baf39b18fa328f1a59fd90e0b759a239773db2281-init=ro:./7938f2b32c53a9e0d3974f9579dd9dbb450202e1e11fe514e31556d4ea808c4e=ro:./4c10796e21c796a6f3d83eeb3613c566ca9e0fd0a596f4eddf5234b87955b3c8=ro:./fd0ba28a44491fd7559c7ffe0597fb1f95b63207a38a3e2680231fb2f6fe92bd=ro:./b656bf5f0688069cd90ab230c029fdfeb852afcfd0d1733d087474c86a117da3=ro:./1e83d2ea184e08eed978127311cc96498e319426abe2fb5004d4b1454598bd76=ro none /tmp/rootfs

root@dev:/var/lib/docker/aufs/diff# tree /tmp/rootfs/ | tail
    │   ├── lastlog
    │   └── wtmp
    ├── mail
    ├── opt
    ├── run -> /run
    ├── spool
    │   └── mail -> ../mail
    └── tmp

693 directories, 4820 files

#这里mount后的文件夹和文件数量要多于上面的/var/lib/docker/aufs/mnt/305226f2e0755956ada28b3baf39b18fa328f1a59fd90e0b759a239773db2281,
#可能跟mount时使用的参数有关,具体状况我没有仔细研究,
#有兴趣的话能够参考源代码docker/daemon/graphdriver/aufs/aufs.go中的aufsMount函数。

关于aufs文件系统的使用能够参考:Linux文件系统之aufs

准备容器内部须要的文件

rootfs准备好了以后,dockerd接着就会准备一些容器里面须要用到的配置文件,先看看container目录下的变化:

root@dev:/var/lib/docker/containers# tree 967438113fba0b7a3005bcb6efae6a77055d6be53945f30389888802ea8b0368/
967438113fba0b7a3005bcb6efae6a77055d6be53945f30389888802ea8b0368/
├── 967438113fba0b7a3005bcb6efae6a77055d6be53945f30389888802ea8b0368-json.log
├── checkpoints
├── config.v2.json
├── hostconfig.json
├── hostname
├── hosts
├── resolv.conf
├── resolv.conf.hash
└── shm

2 directories, 7 files

容器启动后,多了下面这几个文件,这几个文件都是docker动态生成的:

  • 967438113fba0b7a3005bcb6efae6a77055d6be53945f30389888802ea8b0368-json.log:容器的日志文件,后续容器的stdout和stderr都会输出到这个目录。固然若是配置了其它的日志插件的话,日志就会写到别的地方。

  • hostname:里面是容器的主机名,来自于config.v2.json,由docker create命令的-h参数指定,若是没指定的话,就是容器ID的前12位,这里即为967438113fba

  • resolv.conf:里面包含了DNS服务器的IP,来自于hostconfig.json,由docker create命令的--dns参数指定,没有指定的话,docker会根据容器的网络类型生成一个默认的,通常是主机配置的DNS服务器或者是docker bridge的IP。

  • resolv.conf.hash:resolv.conf文件的校验码

  • shm:为容器分配的一个内存文件系统,后面会绑定到容器中的/dev/shm目录,能够由docker create的参数--shm-size控制其大小,默认是64M,其本质上就是一个挂载到/dev/shm的tmpfs,因为这个目录的内容是放在内存中的,因此读写速度快,有些程序会利用这个特色而用到这个目录,因此docker事先为容器准备好这个目录。

注意:除了日志文件外,其它文件在每次容器启动的时候都会自动生成,因此修改他们的内容后只会在当前容器运行的时候生效,容器重启后,配置又都会恢复到默认的状态

准备OCI须要的bundle

什么是容器的runtime?中,介绍过bundle的概念,它主要包含一个名字叫作config.json的配置文件。

dockerd在生成这个文件前,要作一些准备工做,好比建立好cgroup的相关目录,准备网络相关的配置等,而后才生成config.json文件。

cgroup的相关目录能够直接经过命令find /sys/fs/cgroup/ -name 967438113fba0b7a3005bcb6efae6a77055d6be53945f30389888802ea8b0368找到.
网络相关的内容这里不介绍,后续会有专门的文章进行介绍。

bundle被dockerd放在了目录/run/docker/libcontainerd/967438113fba0b7a3005bcb6efae6a77055d6be53945f30389888802ea8b0368下,咱们这里主要看一下生成的config.json文件中一些比较常见且易懂的字段。

只有当容器在运行的时候,目录/run/docker/libcontainerd/967438113fba0b7a3005bcb6efae6a77055d6be53945f30389888802ea8b0368才存在,容器中止执行后该目录会被删除掉,下一次启动的时候会再次被建立。

#这里的只截取了部分输出,仅供参考
root@dev:/run/docker/libcontainerd/967438113fba0b7a3005bcb6efae6a77055d6be53945f30389888802ea8b0368# cat config.json |python -m json.tool
{
    "hostname": "967438113fba",     #主机名
    "linux": {
        "cgroupsPath": "/docker/967438113fba0b7a3005bcb6efae6a77055d6be53945f30389888802ea8b0368",        #cgroup路径
        "namespaces": [           #须要加入的namespace,只有type没有值表示建立并加入一个新的namespace,这里没看到user namespace,说明docker默认状况下是不开启user namespace的。
            {
                "type": "mount"
            },
            {
                "type": "network"
            },
            {
                "type": "uts"
            },
            {
                "type": "pid"
            },
            {
                "type": "ipc"
            }
        ]
    },
    "mounts": [        #须要mount到容器中的文件或者目录,这里列出来的的几个文件就是上面介绍的由dockerd进程生成的那几个文件,它们将经过bind的方式mount到容器中
        {
            "destination": "/etc/resolv.conf",
            "options": [
                "rbind",
                "rprivate"
            ],
            "source": "/var/lib/docker/containers/967438113fba0b7a3005bcb6efae6a77055d6be53945f30389888802ea8b0368/resolv.conf",
            "type": "bind"
        },        
        {
            "destination": "/etc/hostname",
            "options": [
                "rbind",
                "rprivate"
            ],
            "source": "/var/lib/docker/containers/967438113fba0b7a3005bcb6efae6a77055d6be53945f30389888802ea8b0368/hostname",
            "type": "bind"
        },
        {
            "destination": "/etc/hosts",
            "options": [
                "rbind",
                "rprivate"
            ],
            "source": "/var/lib/docker/containers/967438113fba0b7a3005bcb6efae6a77055d6be53945f30389888802ea8b0368/hosts",
            "type": "bind"
        },
        {
            "destination": "/dev/shm",
            "options": [
                "rbind",
                "rprivate"
            ],
            "source": "/var/lib/docker/containers/967438113fba0b7a3005bcb6efae6a77055d6be53945f30389888802ea8b0368/shm",
            "type": "bind"
        }
    ],
    "process": {      #这里/bin/bash就是进程启动后要运行的程序,
        "args": [
            "/bin/bash"
        ],
        "cwd": "/",
        "env": [
            "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
            "HOSTNAME=967438113fba",
            "TERM=xterm"
        ],
        "terminal": true,
        "user": {
            "gid": 0,
            "uid": 0
        }
    },
    "root": {       #rootfs的路径
        "path": "/var/lib/docker/aufs/mnt/305226f2e0755956ada28b3baf39b18fa328f1a59fd90e0b759a239773db2281"
    }
}

准备IO文件

在bundle目录里面,除了上面介绍的容器配置文件以外,dockerd还建立了一些跟io相关的命名管道,用来和容器之间进行通讯,好比这里的init-stdin文件用来向容器的stdin中写数据,init-stdout用来接收容器的stdout输出。

#bundle目录里面除了config.json以外,还有两个文件
root@dev:/run/docker/libcontainerd/967438113fba0b7a3005bcb6efae6a77055d6be53945f30389888802ea8b0368# tree
.
├── config.json
├── init-stdin
└── init-stdout

0 directories, 3 files

#这两个文件是命名管道文件
root@dev:/run/docker/libcontainerd/967438113fba0b7a3005bcb6efae6a77055d6be53945f30389888802ea8b0368# file init-stdin init-stdout
init-stdin:  fifo (named pipe)
init-stdout: fifo (named pipe)

#它们被dockerd和docker-containerd-shim两个进程所打开
root@dev:/run/docker/libcontainerd/967438113fba0b7a3005bcb6efae6a77055d6be53945f30389888802ea8b0368# lsof *
COMMAND    PID USER   FD   TYPE DEVICE SIZE/OFF NODE NAME
dockerd   1218 root   18u  FIFO   0,18      0t0  640 init-stdin
dockerd   1218 root   21u  FIFO   0,18      0t0  641 init-stdout
dockerd   1218 root   24w  FIFO   0,18      0t0  640 init-stdin
dockerd   1218 root   25r  FIFO   0,18      0t0  641 init-stdout
docker-co 7971 root    7u  FIFO   0,18      0t0  640 init-stdin
docker-co 7971 root    9u  FIFO   0,18      0t0  640 init-stdin
docker-co 7971 root   10r  FIFO   0,18      0t0  640 init-stdin
docker-co 7971 root   12u  FIFO   0,18      0t0  641 init-stdout
docker-co 7971 root   13w  FIFO   0,18      0t0  641 init-stdout
docker-co 7971 root   14u  FIFO   0,18      0t0  641 init-stdout
docker-co 7971 root   15r  FIFO   0,18      0t0  641 init-stdout
docker-co 7971 root   16w  FIFO   0,18      0t0  640 init-stdin

#7971是容器进程docker-containerd-shim 
root@dev:/run/docker/libcontainerd/967438113fba0b7a3005bcb6efae6a77055d6be53945f30389888802ea8b0368# ps -ef|grep 7971|grep docker
root      7971  1311  0 17:43 ?        00:00:00 docker-containerd-shim 967438113fba0b7a3005bcb6efae6a77055d6be53945f30389888802ea8b0368 /var/run/docker/libcontainerd/967438113fba0b7a3005bcb6efae6a77055d6be53945f30389888802ea8b0368 docker-runc

上面只有init-stdin和init-stdout,没有init-stderr,那是由于咱们建立容器的时候指定了-t参数,意思是让docker为容器建立一个tty(虚拟的),在这种状况下,stdout和stderr将采用一样的通道,即容器中进程往stderr中输出数据时,会写到init-stdout中。

待上面的文件都准备好了以后,经过grpc的方式给containerd发送请求,通知containerd启动容器。

containerd

containerd主要功能是启动并管理运行时的全部contianer。

准备相关文件

containerd会建立目录/run/docker/libcontainerd/containerd/967438113fba0b7a3005bcb6efae6a77055d6be53945f30389888802ea8b0368/init并将相关文件放到这里。

只有当容器在运行的时候,目录/run/docker/libcontainerd/containerd/967438113fba0b7a3005bcb6efae6a77055d6be53945f30389888802ea8b0368才存在,容器中止执行后该目录会被删除掉,下一次启动的时候会再次被建立。

root@dev:/run/docker/libcontainerd/containerd/967438113fba0b7a3005bcb6efae6a77055d6be53945f30389888802ea8b0368/init# file *
control:       fifo (named pipe)
exit:          fifo (named pipe)
log.json:      empty
pid:           ASCII text, with no line terminators
process.json:  ASCII text, with very long lines
shim-log.json: empty
starttime:     ASCII text, with no line terminators
  • control: 用来往shim发送控制命令,包括关闭stdin和调整终端的窗口大小。

  • exit:shim进程退出的时候,会关闭该管道,而后containerd就会收到通知,作一些清理工做。

  • process.json:包含容器中进程相关的一些属性信息,后续在这个容器上执行docker exec命令时会用到这个文件。

  • log.json: runc若是运行失败的话,会写日志到这个文件

  • shim-log.json:shim进程执行失败的话,会写日志到这个文件

  • pid:容器启动后,runc会将容器中第一个进程的pid写到这个文件中(外面pid namespace中的pid)

  • starttime:记录容器的启动时间

启动过程

  1. contianerd收到启动容器请求后,就会建立control、exit、process.json这三个文件

  2. 而后启动shim进程,等着runc建立容器并将容器里第一个进程的pid写入pid文件

  3. 若是containerd读取pid文件失败,则读取shim-log.json和log.json,看出了什么异常

  4. 若是读取pid文件成功,说明容器建立成功,则将当前时间做为容器的启动时间写入starttime文件

  5. 调用runc的start命令启动容器

监听容器

待容器启动以后,containerd还须要监听容器的OOM事件和容器退出事件,以便及时做出响应,OOM事件经过cgroup的内存限制机制进行监听(经过group.event_control),而容器退出事件经过exit这个命名pipe来实现。

按道理来讲若是容器里面的全部进程属于一个pid namespace的话,id为1的进程退出后,容器也就退出了,调用wait函数并传入容器里第一个进程的pid也能知道容器是否退出,不肯定为何containerd必定要弄个exit来监听容器的退出,我没有继续深刻研究,多是由于pipe的fd能够经过epool来统一监听而且是异步,处理起来方便。

shim

shim进程被containerd启动以后,第一步是设置子孙进程成为孤儿进程后由shim进程接管,即shim将变成孤儿进程的父进程,这样就保证容器里的第一个进程不会由于runc进程的退出而被init进程接管。

从Linux 3.4开始,prctl增长了对PR_SET_CHILD_SUBREAPER的支持,这样就能够控制孤儿进程能够被谁接管,而不是像之前同样只能由init进程接管。

接着根据传入的参数设置好要启动进程的stdin,stdout,stderr(来自于上面的init-stdin,init-stdout,init-stderr),而后调用runc create命令建立容器,容器建立成功后,runc会将容器的第一个进程的pid写入上面containerd目录下的pid文件中,这样containerd进程就知道容器建立成功了,因而containerd接着就会调用runc start启动容器。

runc

runc会被调用两次,第一次是shim调用runc create建立容器,第二次是containerd调用runc start启动容器。

建立容器

runc会根据参数中传入的bundle目录名称以及容器ID,建立容器.

建立容器就是启动进程/proc/self/exe init,因为/proc/self/exe指向的是本身,因此至关于fork了一个新进程,而且新进程启动的参数是init,至关于运行了runc initrunc init会根据配置建立好相应的namespace,同时建立一个叫exec.fifo的临时文件,等待其它进程打开这个文件,若是有其它进程打开这个文件,则启动容器。

启动容器

启动容器就是运行runc start,它会打开并读一下文件exec.fifo,这样就会触发runc init进程启动容器,若是runc start读取该文件没有异常,将会删掉文件exec.fifo,因此通常状况下咱们看不到文件exec.fifo。

runc建立的容器都会在在/run/runc下有一个目录,里面有一个state.json文件(上面说到的exec.fifo这个临时文件也在这里),包含当前容器详细的配置及状态信息。对于本文中的这个容器,相应的目录为/run/runc/967438113fba0b7a3005bcb6efae6a77055d6be53945f30389888802ea8b0368。

root@dev:/run/runc/967438113fba0b7a3005bcb6efae6a77055d6be53945f30389888802ea8b0368# ls
state.json

#经过runc state命令,能够查到指定容器的相关信息
root@dev:/run/runc/967438113fba0b7a3005bcb6efae6a77055d6be53945f30389888802ea8b0368# docker-runc state 967438113fba0b7a3005bcb6efae6a77055d6be53945f30389888802ea8b0368
{
  "ociVersion": "1.0.0-rc2-dev",
  "id": "967438113fba0b7a3005bcb6efae6a77055d6be53945f30389888802ea8b0368",
  "pid": 8001,
  "status": "running",     #刚建立时这里的状态是created,只有运行runc start以后这里才变成running
  "bundle": "/run/docker/libcontainerd/967438113fba0b7a3005bcb6efae6a77055d6be53945f30389888802ea8b0368",
  "rootfs": "/var/lib/docker/aufs/mnt/305226f2e0755956ada28b3baf39b18fa328f1a59fd90e0b759a239773db2281",
  "created": "2017-06-25T04:04:18.830443417Z"
}

若是咱们平时单独的调用runc命令的话,能够将建立容器和启动容器这两步合并成一步,那就是runc run,具体启动方法可参考“走进docker(03):如何绕过docker运行hello-world?”中关于runc运行bundle的介绍。

结束语

docker start命令干的活不少,这里只是介绍了大概的流程和涉及的进程和文件,还有一些其余东西并无涉及到,好比存储插件和网络,后续在专门介绍相关部分的时候再详细介绍。

相关文章
相关标签/搜索