一旦你把一个应用程序部署到生产环境中,总会有一天它会出现意外情况。在那一天来临之前,提前了解可能会发生的情况总是很好的。在继续进行更复杂的部署之前,对容器调试有一个良好的理解也是非常重要的。如果没有调试技巧,很难发现编排系统出了问题的地方。因此,让我们来看一下如何调试容器。
从根本上说,调试容器化应用程序与在系统上调试普通进程并没有太大的区别,只是使用的工具有些不同。Docker提供了一些相当好用的工具来帮助你!其中一些工具与常规系统工具相对应,而另一些则更加强大。
还有一个关键点需要理解,即你的应用程序并不是在与其他Docker进程隔离的系统中运行。它们共享一个内核,并且根据容器的配置,它们可能会共享其他资源,比如存储子系统和网络接口。这意味着你可以从系统中获取关于你的容器正在做什么的大量信息。
如果你习惯于在虚拟机环境中调试应用程序,你可能会认为你需要进入容器来检查应用程序的内存或CPU使用情况,或者调试其系统调用。然而,事实并非如此!尽管在许多方面感觉像是虚拟化层,但容器中的进程实际上只是在Linux主机本身上的进程。如果你想要查看机器上所有Linux容器的进程列表,你可以登录服务器并运行带有你喜欢的命令行选项的ps命令。不过,你也可以在任何地方使用docker container top命令来查看从底层Linux内核的视角下运行在容器中的进程列表。让我们更详细地了解一下在调试容器化应用程序时可以执行的一些操作,而这些操作不需要使用docker container exec或nsenter。
进程输出(Process Output)
在调试容器时,你首先想要知道的是容器内部正在运行的内容。正如之前提到的,Docker内置了一个命令,用于完成这个任务:docker container top。这并不是查看容器内部运行情况的唯一方法,但绝对是最简单易用的方法。让我们看看它是如何工作的:
yaml
$ docker container run --rm -d --name nginx-debug --rm nginx:latest
796b282bfed33a4ec864a32804ccf5cbbee688b5305f094c6fbaf20009ac2364
$ docker container top nginx-debug
UID PID PPID C STIME TTY TIME CMD
root 2027 2002 0 12:35 ? 00:00 nginx: master process nginx -g daemon off;
uuidd 2085 2027 0 12:35 ? 00:00 nginx: worker process
uuidd 2086 2027 0 12:35 ? 00:00 nginx: worker process
uuidd 2087 2027 0 12:35 ? 00:00 nginx: worker process
uuidd 2088 2027 0 12:35 ? 00:00 nginx: worker process
uuidd 2089 2027 0 12:35 ? 00:00 nginx: worker process
uuidd 2090 2027 0 12:35 ? 00:00 nginx: worker process
uuidd 2091 2027 0 12:35 ? 00:00 nginx: worker process
uuidd 2092 2027 0 12:35 ? 00:00 nginx: worker process
$ docker container stop nginx-debug
要运行docker container top命令,我们需要传递容器的名称或ID,然后我们将得到一个清晰的列表,显示容器内部正在运行的内容,并按照PID的顺序排列,就像我们从Linux的ps输出中所期望的那样。
不过,在这里还有一些奇怪的地方。主要的问题在于用户ID和文件系统的名称空间。
重要的是要理解,特定用户ID(UID)对应的用户名在每个容器和主机系统之间可能完全不同。甚至可能在容器或主机的/etc/passwd文件中根本没有与特定UID关联的具名用户。这是因为Unix不要求UID必须有与之关联的具名用户,而我们在"命名空间"中详细讨论的Linux命名空间为容器的有效用户和基础主机上的用户之间提供了一定程度的隔离。
让我们来看一个更具体的例子。考虑一个运行Ubuntu 22.04的生产Docker服务器,并在其上运行一个包含Ubuntu发行版的容器。如果你在Ubuntu主机上运行以下命令,你会发现UID 7对应的用户名是lp:
scss
$ id 7
uid=7(lp) gid=7(lp) groups=7(lp)
如果我们进入在该Docker主机上运行的标准Fedora容器,你会发现在Fedora容器的/etc/passwd文件中,UID 7对应的用户名是halt。通过运行以下命令,你可以看到容器对于UID 7拥有完全不同的认知:
ruby
$ docker container run --rm -it fedora:latest /bin/bash
root@c399cb807eb7:/# id 7
uid=7(halt) gid=0(root) groups=0(root)
root@c399cb807eb7:/# grep x:7: /etc/passwd
halt:x:7:0:halt:/sbin:/sbin/halt
root@409c2a8216b1:/# exit
如果我们在理论上的Ubuntu Docker服务器上运行ps aux命令,并且在容器以UID 7(-u 7)的身份运行时,我们会发现Docker主机显示容器进程是由lp而不是halt运行的:
perl
$ docker container run --rm -d -u 7 fedora:latest sleep 120
55...c6
$ ps aux | grep sleep
lp 2388 0.2 0.0 2204 784 ? ... 0:00 sleep 120
vagrant 2419 0.0 0.0 5892 1980 pts/0 ... 0:00 grep --color=auto sleep
如果在主机系统上配置了一个像nagios或postgres这样的众所周知的用户,但在容器中没有配置该用户,然而容器使用相同的ID运行其进程,这可能会特别令人困惑。这种命名空间化可能会使ps命令的输出看起来非常奇怪。例如,如果你没有仔细观察,它可能会看起来像是在Docker主机上的nagios用户在运行在容器内部启动的postgresql守护进程。
同样,由于进程对文件系统有不同的视图,显示在ps输出中的路径是相对于容器而不是主机的。在这些情况下,知道进程运行在容器中是非常重要的。
这就是使用Docker工具来查看容器中运行内容的方法。但这并不是唯一的方法,在调试情况下,可能并不是最佳方法。如果你登录到Docker服务器并运行普通的Linux ps命令来查看运行中的进程,你会得到一个完整的列表,包括所有容器化的和非容器化的进程,就像它们都是等价的进程一样。有一些方法可以查看进程输出,使得事情更加清晰明了。例如,你可以通过以树状形式查看Linux ps输出来方便调试,这样你就可以看到所有从Docker衍生的进程。下面是一个例子,使用BSD命令行选项来查看当前运行两个容器的系统;我们将截取输出,只展示我们关心的部分。
bash
$ ps axlfww
... /usr/bin/containerd
...
... /usr/bin/dockerd -H fd:// --containerd=/run/containerd/containerd.sock
... _ /usr/bin/docker-proxy -proto tcp -host-ip 0.0.0.0 -host-port 8080 \
-container-ip 172.17.0.2 -container-port 8080
... _ /usr/bin/docker-proxy -proto tcp -host-ip :: -host-port 8080 \
-container-ip 172.17.0.2 -container-port 8080
...
... /usr/bin/containerd-shim-runc-v2 -namespace moby -id 97...3d -address /run/...
... _ sleep 120
...
... /usr/bin/containerd-shim-runc-v2 -namespace moby -id 69...7c -address /run/...
在这里,你可以看到我们正在运行一个containerd的实例,这是Docker守护进程使用的主要容器运行时。dockerd目前有两个docker-proxy子进程正在运行,关于这一点,我们将在"网络检查"中详细讨论。
每个使用containerd-shim-runc-v2的进程代表一个单独的容器以及运行在该容器内部的所有进程。在这个例子中,我们有两个容器。它们显示为containerd-shim-runc-v2,后面跟着一些关于进程的附加信息,包括容器ID。在这种情况下,我们正在一个容器中运行Google的cadvisor的一个实例,以及在另一个容器中运行sleep命令的一个实例。每个映射端口的容器至少会有一个docker-proxy进程,用于在容器和主机Docker服务器之间映射所需的网络端口。在这个例子中,两个docker-proxy进程与cadvisor相关联。其中一个正在映射IPv4地址的端口,另一个正在映射IPv6地址的端口。
由于ps命令的树状输出,很清楚地显示了哪些进程在哪个容器中运行。如果你更喜欢Unix SysV命令行选项,你可以通过ps -ejH来获得类似的输出,但不够美观。
bash
$ ps -ejH
... containerd
...
... dockerd
... docker-proxy
... docker-proxy
...
... containerd-shim
... cadvisor
...
... containerd-shim
... sleep
你可以使用pstree命令获得更简洁的Docker进程树视图。在这里,我们将使用pidof命令将其限定在属于Docker的树结构中:
css
$ pstree `pidof dockerd`
dockerd─┬─docker-proxy───7*[{docker-proxy}]
├─docker-proxy───6*[{docker-proxy}]
└─10*[{dockerd}]
这不会显示PIDs,因此仅用于了解进程之间的连接关系。但当主机上有很多进程运行时,这是一个概念上清晰的输出。它更加简洁,并提供了一个很好的高级视图来展示进程之间的连接关系。在这里,我们可以看到与之前ps输出中显示的相同的容器,但树状结构被折叠,所以我们会看到像7*这样的倍数,表示有七个重复的进程。
如果我们运行pstree命令,我们可以获得包含PIDs的完整树状结构,如下所示:
scss
$ pstree -p `pidof dockerd`
dockerd(866)─┬─docker-proxy(3050)─┬─{docker-proxy}(3051)
│ ├─{docker-proxy}(3052)
│ ├─{docker-proxy}(3053)
│ ├─{docker-proxy}(3054)
│ ├─{docker-proxy}(3056)
│ ├─{docker-proxy}(3057)
│ └─{docker-proxy}(3058)
├─docker-proxy(3055)─┬─{docker-proxy}(3059)
│ ├─{docker-proxy}(3060)
│ ├─{docker-proxy}(3061)
│ ├─{docker-proxy}(3062)
│ ├─{docker-proxy}(3063)
│ └─{docker-proxy}(3064)
├─{dockerd}(904)
├─{dockerd}(912)
├─{dockerd}(913)
├─{dockerd}(914)
├─{dockerd}(990)
├─{dockerd}(1014)
├─{dockerd}(1066)
├─{dockerd}(1605)
├─{dockerd}(1611)
└─{dockerd}(2228)
这个输出让我们非常清楚地看到了所有附属于Docker的进程以及它们在运行什么。
如果你想检查一个单独的容器及其进程,你可以确定容器的主进程ID,然后使用pstree命令查看所有相关的子进程:
scss
$ ps aux | grep containerd-shim-runc-v2
root 3072 ... /usr/bin/containerd-shim-runc-v2 -namespace moby -id 69...7c ...
root 4489 ... /usr/bin/containerd-shim-runc-v2 -namespace moby -id f1...46 ...
vagrant 4651 ... grep --color=auto shim
$ pstree -p 3072
containerd-shim(3072)─┬─cadvisor(3092)─┬─{cadvisor}(3123)
│ ├─{cadvisor}(3124)
│ ├─{cadvisor}(3125)
│ ├─{cadvisor}(3126)
│ ├─{cadvisor}(3127)
│ ├─{cadvisor}(3128)
│ ├─{cadvisor}(3180)
│ ├─{cadvisor}(3181)
│ └─{cadvisor}(3182)
├─{containerd-shim}(3073)
├─{containerd-shim}(3074)
├─{containerd-shim}(3075)
├─{containerd-shim}(3076)
├─{containerd-shim}(3077)
├─{containerd-shim}(3078)
├─{containerd-shim}(3079)
├─{containerd-shim}(3080)
├─{containerd-shim}(3121)
└─{containerd-shim}(3267)
进程检查(Process Inspection)
如果你已登录到Docker服务器,你可以使用所有标准的调试工具来检查正在运行的进程。常见的调试工具如strace将按预期工作。在下面的代码中,我们将检查一个运行在容器内部的nginx进程:
arduino
$ docker container run --rm -d --name nginx-debug --rm nginx:latest
$ docker container top nginx-debug
UID PID PPID ... CMD
root 22983 22954 ... nginx: master process nginx -g daemon off;
systemd+ 23032 22983 ... nginx: worker process
systemd+ 23033 22983 ... nginx: worker process
$ sudo strace -p 23032
strace: Process 23032 attached
epoll_pwait(10,
你可以看到我们获得了与主机上的非容器化进程相同的输出。同样,使用lsof命令可以显示进程中打开的文件和套接字,一切正常:
bash
$ sudo lsof -p 22983
COMMAND PID USER ... NAME
nginx 22983 root ... /
nginx 22983 root ... /
nginx 22983 root ... /usr/sbin/nginx
nginx 22983 root ... /usr/sbin/nginx (stat: No such file or directory)
nginx 22983 root ... /lib/aarch64-linux-gnu/libnss_files-2.31.so (stat: ...
nginx 22983 root ... /lib/aarch64-linux-gnu/libc-2.31.so (stat: ...
nginx 22983 root ... /lib/aarch64-linux-gnu/libz.so.1.2.11 (path inode=...)
nginx 22983 root ... /usr/lib/aarch64-linux-gnu/libcrypto.so.1.1 (stat: ...
nginx 22983 root ... /usr/lib/aarch64-linux-gnu/libssl.so.1.1 (stat: ...
nginx 22983 root ... /usr/lib/aarch64-linux-gnu/libpcre2-8.so.0.10.1 (stat: ...
nginx 22983 root ... /lib/aarch64-linux-gnu/libcrypt.so.1.1.0 (path ...
nginx 22983 root ... /lib/aarch64-linux-gnu/libpthread-2.31.so (stat: ...
nginx 22983 root ... /lib/aarch64-linux-gnu/libdl-2.31.so (stat: ...
nginx 22983 root ... /lib/aarch64-linux-gnu/ld-2.31.so (stat: ...
nginx 22983 root ... /dev/zero
nginx 22983 root ... /dev/null
nginx 22983 root ... pipe
nginx 22983 root ... pipe
nginx 22983 root ... pipe
nginx 22983 root ... protocol: UNIX-STREAM
nginx 22983 root ... pipe
nginx 22983 root ... pipe
nginx 22983 root ... protocol: TCP
nginx 22983 root ... protocol: TCPv6
nginx 22983 root ... protocol: UNIX-STREAM
nginx 22983 root ... protocol: UNIX-STREAM
nginx 22983 root ... protocol: UNIX-STREAM
需要注意的是,文件路径都是相对于容器对支持文件系统的视图,这与主机的视图不同。因此,如果你在主机系统上,可能无法轻松地找到正在运行的容器中的特定文件。在大多数情况下,最好使用docker container exec进入容器,以便以与其内部进程相同的视图查看文件。
只要你是root用户并具有适当的权限,你也可以像运行GNU调试器(gdb)和其他进程检查工具一样运行。
值得一提的是,还可以运行一个新的调试容器,该容器可以看到现有容器的进程,并因此提供额外的工具来调试问题。我们将在后面的"命名空间"和"安全性"部分讨论这个命令的底层细节。
less
$ docker container run -ti --rm --cap-add=SYS_PTRACE \
--pid=container:nginx-debug spkane/train-os:latest bash
[root@e4b5d2f3a3a7 /]# ps aux
USER PID %CPU %MEM ... TIME COMMAND
root 1 0.0 0.2 ... 0:00 nginx: master process nginx -g daemon off;
101 30 0.0 0.1 ... 0:00 nginx: worker process
101 31 0.0 0.1 ... 0:00 nginx: worker process
root 136 0.0 0.1 ... 0:00 bash
root 152 0.0 0.2 ... 0:00 ps aux
[root@e4b5d2f3a3a7 /]# strace -p 1
strace: Process 1 attached
rt_sigsuspend([], 8
[Control-C]
strace: Process 1 detached
<detached ...>
[root@e4b5d2f3a3a7 /]# exit
$ docker container stop nginx-debug
控制进程
当你直接在Docker服务器上拥有一个shell时,你可以以许多方式处理容器化的进程,就像处理系统上的其他进程一样。如果你是远程连接,可能会使用docker container kill命令发送信号,因为这样更方便。但如果你已经登录到Docker服务器进行调试会话,或者因为Docker守护进程没有响应,你可以像终止其他进程一样杀掉容器内的进程。
除非你杀掉容器中的顶层进程(容器内的PID 1),否则杀掉一个进程不会终止容器本身。如果你想终止一个失控的进程,这可能是可取的,但它可能会让容器处于意外状态。开发者可能期望看到他们的容器在docker container ls中列出的所有进程都在运行。这可能会让像Mesos或Kubernetes这样的调度器或其他监控你的应用程序的系统产生混淆。请记住,对外部世界来说,容器应该像一个单一的封装体。如果你需要终止容器内的某个进程,最好是替换整个容器。容器提供了一个与工具进行交互的抽象层,它们期望容器内部是可预测的并保持一致。
发送信号并不仅仅是为了终止进程。由于容器化的进程在许多方面都像普通进程一样,它们可以接收Linux kill命令手册中列出的所有Unix信号。许多Unix程序在接收特定的预定义信号时会执行特殊操作。例如,当接收到SIGUSR1信号时,nginx会重新打开其日志。使用Linux kill命令,你可以向本地服务器上的容器进程发送任何Unix信号。
因为容器与其他进程一样工作,所以了解它们可能以不太有帮助的方式与你的应用程序交互是很重要的。对于生成后台子进程的容器,即任何fork并以守护进程方式运行的进程,存在一些特殊需求,这样父进程就不再管理子进程的生命周期。Jenkins构建容器就是一个常见的例子,人们经常在这里看到问题。当守护进程进入后台时,它们会成为Unix系统上PID 1的子进程。进程1是特殊的,并且通常是某种init进程。
PID 1负责确保子进程被回收。在你的容器中,默认情况下,你的主进程将是PID 1。由于你可能不会处理来自你的应用程序的子进程回收,容器中可能会有僵尸进程。这个问题有几种解决方法。第一种是在容器中运行一个你自己选择的init系统,它能够处理PID 1的职责。在容器内部可以很容易地使用s6、runit等,在前面的注释中有描述。
但Docker本身提供了一个更简单的选项,解决了这个问题而不需要具备完整init系统的所有功能。如果你在docker container run命令中提供了--init标志,Docker将启动一个非常小的init进程,基于tini项目,在容器启动时作为PID 1。你在Dockerfile中指定的CMD将传递给tini,并以你预期的方式工作。但是,它会替换Dockerfile中ENTRYPOINT部分中的任何内容。
当你启动一个没有--init标志的Linux容器时,在进程列表中会得到如下内容:
arduino
$ docker container run --rm -it alpine:3.16 sh
/ # ps -ef
PID USER TIME COMMAND
1 root 0:00 sh
5 root 0:00 ps -ef
/ # exit
请注意,在这种情况下,我们启动的CMD成为PID 1。这意味着它负责回收子进程。如果我们启动一个重要的容器,可以通过传递--init选项来确保当父进程退出时,子进程被回收:
bash
$ docker container run --rm -it --init alpine:3.16 sh
/ # ps -ef
PID USER TIME COMMAND
1 root 0:00 /sbin/docker-init -- sh
5 root 0:00 sh
6 root 0:00 ps -ef
/ # exit
在这里,你可以看到PID 1进程是/sbin/docker-init。它又根据命令行上的指定为我们启动了shell二进制文件。由于现在我们在容器内部有了一个init系统,PID 1的责任就落在它身上,而不是我们用来启动容器的命令。在大多数情况下,这是你想要的结果。你可能不需要一个完整的init系统,但至少在生产环境中应该考虑将tini放在你的容器内。
一般来说,只有在容器内运行多个父进程或者有一些不正确响应Unix信号的进程时,你可能需要一个init进程。
网络检查
与进程检查相比,调试容器化应用程序在网络层面可能更加复杂。与在主机上运行的传统进程不同,Linux容器可以通过多种方式连接到网络。如果你正在运行默认设置,就像绝大多数人一样,那么你的所有容器都通过Docker创建的默认桥接网络连接到网络。这是一个虚拟网络,其中主机是通往世界其他部分的网关。我们可以使用Docker附带的工具来检查这些虚拟网络。你可以通过调用docker network ls命令来查看存在哪些网络:
sql
$ docker network ls
NETWORK ID NAME DRIVER SCOPE
f9685b50d57c bridge bridge local
8acae1680cbd host host local
fb70d67499d3 none null local
在这里,我们可以看到默认的桥接网络,主机网络(用于在主机网络模式下运行的容器,参见"主机网络"),以及none网络(完全禁用容器的网络访问)。如果你使用docker compose或其他编排工具,它们可能会创建具有不同名称的其他网络。
但是,知道存在哪些网络并不会让你更容易看到这些网络中的内容。因此,你可以使用docker network inspect命令查看哪些容器连接到了特定名称的网络。这会产生相当多的输出。它会显示所有连接到指定网络的容器,并显示有关网络本身的许多细节。让我们来看一下默认的桥接网络:
ruby
$ docker network inspect bridge
json
[
{
"Name": "bridge",
...
"Driver": "bridge",
"EnableIPv6": false,
...
"Containers": {
"69e9...c87c": {
"Name": "cadvisor",
...
"IPv4Address": "172.17.0.2/16",
"IPv6Address": ""
},
"a2a8...e163": {
"Name": "nginx-debug",
...
"IPv4Address": "172.17.0.3/16",
"IPv6Address": ""
}
},
"Options": {
"com.docker.network.bridge.default_bridge": "true",
...
"com.docker.network.bridge.host_binding_ipv4": "0.0.0.0",
"com.docker.network.bridge.name": "docker0",
...
},
"Labels": {}
}
]
为了缩小输出,我们在这里省略了一些细节。但我们可以看到,在桥接网络上有两个容器,并且它们连接到主机上的docker0桥接。我们还可以看到每个容器的IP地址(IPv4Address和IPv6Address)以及它们绑定到的主机网络地址(host_binding_ipv4)。当你试图理解桥接网络的内部结构时,这是很有用的。如果你的容器在不同的网络上,根据网络的配置方式,它们可能无法相互通信。
正如我们所见,容器通常会有自己的网络栈和IP地址,除非它们在主机网络模式下运行,我们将在"网络"中进一步讨论这个问题。但是,当我们从主机机器本身查看它们时情况又如何呢?因为容器有自己的网络和地址,所以它们不会在主机上的所有netstat输出中显示。但我们知道你映射到容器的端口是绑定到主机上的。
在Docker服务器上运行netstat -an的效果如下所示:
ruby
$ sudo netstat -an
Active Internet connections (servers and established)
Proto Recv-Q Send-Q Local Address Foreign Address State
tcp 0 0 0.0.0.0:8080 0.0.0.0:* LISTEN
tcp 0 0 127.0.0.53:53 0.0.0.0:* LISTEN
tcp 0 0 0.0.0.0:22 0.0.0.0:* LISTEN
tcp 0 0 192.168.15.158:22 192.168.15.120:63920 ESTABLISHED
tcp6 0 0 :::8080 :::* LISTEN
tcp6 0 0 :::22 :::* LISTEN
udp 0 0 127.0.0.53:53 0.0.0.0:*
udp 0 0 192.168.15.158:68 0.0.0.0:*
raw6 0 0 :::58 :::* 7
...
在这里,我们可以看到我们正在监听的所有接口。我们的容器绑定到IP地址0.0.0.0的端口8080。这显示了出来。但当我们要求netstat显示绑定到该端口的进程名称时,会发生什么呢?
bash
$ sudo netstat -anp
Active Internet connections (servers and established)
Proto ... Local Address Foreign Address ... PID/Program name
tcp ... 0.0.0.0:8080 0.0.0.0:* ... 1516/docker-proxy
tcp ... 127.0.0.53:53 0.0.0.0:* ... 692/systemd-resolve
tcp ... 0.0.0.0:22 0.0.0.0:* ... 780/sshd: /usr/sbin
tcp ... 192.168.15.158:22 192.168.15.120:63920 ... 1348/sshd: vagrant
tcp6 ... :::8080 :::* ... 1522/docker-proxy
tcp6 ... :::22 :::* ... 780/sshd: /usr/sbin
udp ... 127.0.0.53:53 0.0.0.0:* ... 692/systemd-resolve
udp ... 192.168.15.158:68 0.0.0.0:* ... 690/systemd-network
raw6 ... :::58 :::* ... 690/systemd-network
我们看到了相同的输出,但注意绑定到该端口的是docker-proxy。这是因为在其默认配置中,Docker有一个使用Go编写的代理,位于所有容器和外部世界之间。这意味着当我们查看这个输出时,通过Docker运行的所有容器都与docker-proxy相关联。注意,这里没有关于docker-proxy正在处理哪个特定容器的线索。幸运的是,docker container ls命令会显示哪些容器绑定到哪些端口,所以这并不是什么大问题。但这不是显而易见的,你在调试生产故障之前可能想要知道这一点。尽管如此,对netstat传递p标志可以帮助识别哪些端口与容器关联。
其他网络检查命令基本按预期工作,包括tcpdump,但重要的是要记住docker-proxy存在于主机的网络接口和容器之间,并且容器在虚拟网络上有自己的网络接口。
镜像历史
当你构建和部署单个容器时,很容易追踪它来自何处以及它所基于的镜像。但当你要运送许多由不同团队构建和维护的镜像的容器时,这很快变得难以管理。你如何知道在你的容器下面实际上有哪些层?你容器的镜像标签希望能清楚地表明你正在运行的应用程序的构建版本,但镜像标签并不会透露你的应用程序所构建的镜像层。docker image history命令可以帮助你解决这个问题。你可以查看在检查的镜像中存在的每一层,每一层的大小以及用于构建它的命令:
bash
$ docker image history redis:latest
IMAGE ... CREATED BY SIZE COMMENT
e800a8da9469 ... /bin/sh -c #(nop) CMD ["redis-server"] 0B
<missing> ... /bin/sh -c #(nop) EXPOSE 6379 0B
<missing> ... /bin/sh -c #(nop) ENTRYPOINT ["docker-entry... 0B
<missing> ... /bin/sh -c #(nop) COPY file:e873a0e3c13001b5... 661B
<missing> ... /bin/sh -c #(nop) WORKDIR /data 0B
<missing> ... /bin/sh -c #(nop) VOLUME [/data] 0B
<missing> ... /bin/sh -c mkdir /data && chown redis:redis ... 0B
<missing> ... /bin/sh -c set -eux; savedAptMark="$(apt-m... 32.4MB
<missing> ... /bin/sh -c #(nop) ENV REDIS_DOWNLOAD_SHA=f0... 0B
<missing> ... /bin/sh -c #(nop) ENV REDIS_DOWNLOAD_URL=ht... 0B
<missing> ... /bin/sh -c #(nop) ENV REDIS_VERSION=7.0.4 0B
<missing> ... /bin/sh -c set -eux; savedAptMark="$(apt-ma... 4.06MB
<missing> ... /bin/sh -c #(nop) ENV GOSU_VERSION=1.14 0B
<missing> ... /bin/sh -c groupadd -r -g 999 redis && usera... 331kB
<missing> ... /bin/sh -c #(nop) CMD ["bash"] 0B
<missing> ... /bin/sh -c #(nop) ADD file:6039adfbca55ed34a... 74.3MB
使用docker image history命令可以非常有用,例如,当你试图确定为什么最终镜像的大小比预期要大得多时。层按顺序列出,第一个层位于列表底部,最后一个层位于列表顶部。 在这里,我们可以看到命令输出在一些情况下被截断了。对于较长的命令,向docker image history命令添加--no-trunc选项将允许你查看用于构建每个层的完整命令。只需要注意,在大多数情况下,--no-trunc会使输出变得更大并且更难以视觉上扫描。
检查容器
在第4章中,我们向您展示了如何阅读docker container inspect的输出以查看容器的配置。但在其下面是主机磁盘上专门用于容器的目录。通常情况下,这是/var/lib/docker/containers。如果你查看该目录,会包含很长的SHA哈希值,如下所示:
shell
$ sudo ls /var/lib/docker/containers
106ead0d55af55bd803334090664e4bc821c76dadf231e1aab7798d1baa19121
28970c706db0f69716af43527ed926acbd82581e1cef5e4e6ff152fce1b79972
3c4f916619a5dfc420396d823b42e8bd30a2f94ab5b0f42f052357a68a67309b
589f2ad301381b7704c9cade7da6b34046ef69ebe3d6929b9bc24785d7488287
959db1611d632dc27a86efcb66f1c6268d948d6f22e81e2a22a57610b5070b4d
a1e15f197ea0996d31f69c332f2b14e18b727e53735133a230d54657ac6aa5dd
bad35aac3f503121abf0e543e697fcade78f0d30124778915764d85fb10303a7
bc8c72c965ebca7db9a2b816188773a5864aa381b81c3073b9d3e52e977c55ba
daa75fb108a33793a3f8fcef7ba65589e124af66bc52c4a070f645fffbbc498e
e2ac800b58c4c72e240b90068402b7d4734a7dd03402ee2bce3248cc6f44d676
e8085ebc102b5f51c13cc5c257acb2274e7f8d1645af7baad0cb6fe8eef36e24
f8e46faa3303d93fc424e289d09b4ffba1fc7782b9878456e0fe11f1f6814e4b
这有点令人畏惧。但那些只是以长格式表示的容器ID。如果你想查看特定容器的配置,你只需要使用docker container ls命令找到它的短ID,然后找到与之匹配的目录:
bash
$ docker container ls
CONTAINER ID IMAGE COMMAND ...
c58bfeffb9e6 gcr.io/cadvisor/cadvisor:v0.44.1-test "/usr/bin/cadvisor..." ...
你可以从docker container ls命令中查看短ID,然后将其与ls /var/lib/docker/containers输出进行匹配,找到以c58bfeffb9e6开头的目录。命令行的自动补全在这里非常有帮助。如果你需要精确匹配,可以执行docker container inspect c58bfeffb9e6,并从输出中获取长ID。该目录包含一些与容器相关的相当有趣的文件:
lua
$ cd /var/lib/docker/containers/\
c58bfeffb9e6e607f3aacb4a06ca473535bf9588450f08be46baa230ab43f1d6
$ ls -la
total 48
drwx--x--- 4 root root 4096 Aug 20 10:38 .
drwx--x--- 30 root root 4096 Aug 20 10:25 ..
-rw-r----- 1 root root 635 Aug 20 10:34 c58bf...f1d6-json.log
drwx------ 2 root root 4096 Aug 20 10:24 checkpoints
-rw------- 1 root root 4897 Aug 20 10:38 config.v2.json
-rw-r--r-- 1 root root 1498 Aug 20 10:38 hostconfig.json
-rw-r--r-- 1 root root 13 Aug 20 10:24 hostname
-rw-r--r-- 1 root root 174 Aug 20 10:24 hosts
drwx--x--- 2 root root 4096 Aug 20 10:24 mounts
-rw-r--r-- 1 root root 882 Aug 20 10:24 resolv.conf
-rw-r--r-- 1 root root 71 Aug 20 10:24 resolv.conf.hash
正如我们在第5章中讨论的那样,该目录包含一些直接绑定到容器中的文件,比如hosts、resolv.conf和hostname。如果你正在运行默认的日志记录机制,那么这个目录也是Docker存储包含在docker container logs命令中显示的日志的JSON文件的位置,也是支持docker container inspect输出的JSON配置(config.v2.json)和容器的网络配置(hostconfig.json)的位置。resolv.conf.hash文件由Docker用于确定容器的文件与主机上当前文件的差异,以便进行更新。
在发生严重故障时,这个目录也非常有帮助。即使我们无法进入容器,或者docker没有响应,我们可以查看容器的配置情况。了解这些文件在容器内部是从哪里挂载的也非常有用。请记住,修改这些文件是一个不好的主意。Docker期望它们包含真实的信息,如果你改变了这个现实,就会出现问题。但这是了解容器中发生的事情的另一种途径。
文件系统检查
无论实际使用的后端是什么,Docker都有一个分层文件系统,允许它跟踪任何给定容器中的更改。这是在构建时组装镜像的方式,但在你尝试确定Linux容器是否改变了任何东西以及如果改变了什么时,它也非常有用。容器化应用的一个常见问题是它们可能会继续向容器的文件系统中写入东西。通常情况下,你不希望你的容器这样做,因为这可能会带来一些问题,因此在调试时查找你的进程是否已经向容器中写入是很有帮助的。有时候,这也有助于找出在容器中存在的杂乱的日志文件。与大多数核心工具一样,这种类型的检查内置在docker命令行工具中,并且也通过API暴露出来。让我们来看看这给我们展示了什么。让我们启动一个快速的容器并使用它的名称来进行探索:
bash
$ docker container run --rm -d --name nginx-fs nginx:latest
1272b950202db25ee030703515f482e9ed576f8e64c926e4e535ba11f7536cc4
$ docker container diff nginx-fs
C /run
A /run/nginx.pid
C /var
C /var/cache
C /var/cache/nginx
A /var/cache/nginx/scgi_temp
A /var/cache/nginx/uwsgi_temp
A /var/cache/nginx/client_temp
A /var/cache/nginx/fastcgi_temp
A /var/cache/nginx/proxy_temp
C /etc
C /etc/nginx
C /etc/nginx/conf.d
C /etc/nginx/conf.d/default.conf
$ docker container stop nginx-fs
nginx-fs
每行的开头都是A或C,分别代表已添加或已更改。我们可以看到这个容器正在运行nginx,nginx配置文件已经被写入,而且在一个名为/var/cache/nginx的新目录中创建了一些临时文件。当你尝试优化和加固容器的文件系统使用时,了解容器文件系统的使用方式非常有用。
进一步的详细检查需要使用docker container export、docker container exec、nsenter等来探索容器,以查看文件系统中的确切内容。但是docker container diff为你提供了一个很好的起点。
总结
到目前为止,你应该对如何在开发和生产环境中部署和调试单个容器有了很好的了解,但是如何将其扩展到更大的应用程序生态系统呢?在接下来的章节中,我们将介绍一个较为简单的Docker编排工具:Docker Compose。这个工具是单个Linux容器和生产编排系统之间的良好桥梁。它在开发环境和整个DevOps流程中提供了很多价值。