打破容器的界限——使用nsenter实现Docker“内网穿透”

Docker 是个好东西,我们都知道。它的优点就是“隔离”,所有的依赖、监听的网络端口、运行产生的垃圾文件都被隔离在Docker容器里,应用数据、日志、服务端口被 Docker 以统一的形式暴露出来,简单且易维护。

但它的缺点,也是“隔离”。在生产环境中,我们当然认为最干净的环境是最好的,但一旦某些bug暴露出来,需要我们去对容器中的服务进行操作时,比如写个Python、修改数据库内容、调试服务连通性,就到了令人头痛的时候了。在容器里, dignetstat 这种豪华的工具自然不能指望,有的容器里连 wgetcurl 这种最基本的命令行工具都没有。怎么办呢?

一般来讲,我们有两种选择。一是把服务端口暴露出来,在容器外面连接暴露端口进行操作。二是在容器内安装我们需要的调试工具、运行环境,或将必要的工具 bind mount 进去,在容器内部进行操作。但这两种方式,各有优点,也各有缺点。所以今天,我们试一试第三种选择——使用 nsenter 工具,让宿主机上的程序“借用” Docker 容器的运行上下文 [1] (主要是网络与进程namespace),打破容器内外的界限,方便开发者利用宿主机上的工具快速解决问题。

同时,文中还会分享我写的一个小脚本,通过我们宿主机 glibc 提供的一个特殊机制,优雅的解决容器内网域名解析的问题。我敢打包票,针对这个问题,我这个绝对是东半球第二好用的解决方案。

现存问题

我们大家都用 Docker 好多年了,看到这里有人已经会有疑问了。不就是个 Docker 容器嘛, docker run -p 8001:80 ,不管他是个Web还是个啥,端口映射出来不就行了,它怎么就不香了?

它当然香。但它只能爽一时,不能爽一世。下面的这台主机上,光 Web 服务就有16个,其实Web服务还好,我们还可以用 traefik 统一管理,通过不同域名管理后端服务映射。除此之外,不同服务包含了不同的依赖,通过 docker-compose ,不同服务搭建在互相隔离的内部网络中,在服务A内网中的 postgres:5432 ,连接到的数据库和服务B的 postgres:5432 当然不同,全部暴露出来对安全性、隔离性、管理难度有非常大的影响,就说端口号,默认端口被恶意扫描怎么办?端口号冲突了怎么办?随便写个端口号,二十来个服务对应端口谁又记得住呢?如果是公网 VPS ,这个问题更大。而且像 ES 、HDFS 等依赖主机 IP 保证正常功能的服务,主节点 指示客户端连接 从节点 的时候,提供给客户端的是一个内网IP,这样的话,即使映射出来端口,又有什么用呢?

Traefik Dashboard

上面针对的是前言中提到的 解决方案一,现在我们再来谈谈 解决方案二。这个方法的问题就更显而易见了。你永远都不知道,你遇到的下一个 docker image 有多奇葩。不是所有容器都有 apt-get、apk 等等包管理器的,有的容器甚至没有 bash,没有 busybox,连 libc 都没有,即使把配置好的工具目录 bind mount 进去,能不能运行都是个问题。从开发的角度来讲,我日常用到最多的 VSCode,如果仅仅是连接到一台 Linux 服务器上写代码,VSCode Remote 是一个很好的选择,但如果把所有的依赖包都装到容器里,即使能够把容器内部的文件暴露出来,但没有 Language Server 的加成,没有代码补全、API 文档、语法分析,那 VSCode 就只能做一个光秃秃的编辑器了,对工作效率也会有很大的影响。

举个例子,下面的截图是 willnorris/imageproxy 镜像[2]的内容。可以看到,这里面除了程序本体 /app/imageproxy 之外,只有 CA 预置证书名单、 passwd 文件和时区数据库。这里面没有 libc,没有包管理器,甚至没有 busybox,因此也没有 shell。

容器:willnorris/imageproxy

这里面的秘诀就是 Dockerfile 中的一行, FROM scratch 这个特殊用法 [3] 。我们在这里不去细究个中原理,只需要知道,开发者为自己构建的镜像体积沾沾自喜的时候,背后都是用户的一把把辛酸泪。

就个人来讲,我非常喜欢用 Python,但是对于容器环境,特别是别人家的容器来说,Python 是非常笨重的东西。安装 Python、安装 pip,通过 pip 再安装所需的库,某些 native 的包还需要依赖 gcc。针对不同 base 的容器,还要随机应变……我们是不是应该有个一劳永逸的方法呢?

什么是 namespace

namespace [4]是 Linux 内核提供的一个系统资源隔离机制。是 Docker 得以实现其功能的基石。namespace 机制提供对 8 种系统资源进行隔离,其中我们最为关注的,是其中的网络(Network)命名空间与挂载(Mount)命名空间在不同进程上下文中的变化。Docker 通过这两种 namespace ,将容器内进程可见的网络接口和文件系统,与宿主机隔离开来。这里所提到的网络接口,不仅包括 docker 创建的 br-XXXXXXvethXXXXXXX@ifXXX 等虚拟网桥,也包括本地连接 loopback ,即 127.0.0.1

nsenter示例

我们通过下面的示例演示一下,通过 nsenter [5]命令改变进程 namespace 后,对进程会产生什么变化。从宿主机的角度去看,对于每个进程,其 namespace 信息可以通过 proc 文件系统中的 /proc/<PID>/ns/ 目录下的文件进行表示,每一个 namespace 表现为一个文件描述符。使用 nsenter 调用命令之后,我们可以看到 ls 进程的多个 namespace 确实发生了变化。具体表现为后面方括号中的 id 变化。同时,这个 id 是不会随命令多次执行而变化的。

nsenter 示例

使用nsenter访问容器内网

从 nsenter 的 manpage [5:1]我们可以得知,只需要知道目标进程的 PID 即可通过 nsenter 进入其命名空间。但是还需要注意的是,具有访问权限的用户受到 Linux 的 capabilities 机制[6]限制(与 Linux 文件系统的访问限制相同),一般来讲,只有 root 用户或者具有 CAP_DAC_OVERRIDE 权限的用户才能访问其他用户的 /proc/<PID>/ns/ 目录,这个目录的所有者与路径中 PID 对应进程的所有者相同。我们只要采用 sudo 命令运行 nsenter,就不需要顾忌这些问题了。

这个 PID 怎么获得呢?从容器内部运行 ps 这类命令肯定是不可能的,从容器里面看,PID 肯定都是 1 。但是在容器外面通过 docker inspect 命令可以获取某个容器内,PID 为 1 的进程在宿主机上对应的 PID。

docker inspect -f '{{.State.Pid}}' $CONTAINER_NAME

把这些命令串到一起,我们就可以验证一下效果了。

sudo nsenter --all -t "$(docker inspect -f '{{.State.Pid}}' "$CONTAINER_NAME")" $COMMAND

docker top 与 docker inspect 输出

nsenter访问方式缺点

需要注意的是,上面的示例中我运行的是 nsenter --all ,将容器内进程的所有 namespace 赋予给我们即将运行的命令,这其中就包括了 mount namespace。这意味着,进程载入的时候,会从容器内部加载所需的所有文件和函数库,所以上面的示例中,我实际运行的是 huginn_web_1 容器自带的 ps 命令,而不是宿主机上的,这与本文想达到的目的是不符的——我们就是不想改动容器才这么搞嘛。

但是,所有进程管理工具,包括 htopps 等等,都是要依赖 /proc 目录的内容的。因此,如果要使用这类工具,就只能通过前言中提到的其他方式了,如果是我,我大概会静态编译一个 htop 或者 busybox,再 docker cp 进去。

因此,使用 nsenter 可以做的事情,主要还是内网穿透,对于容器内进程的管理、文件的修改,可能就需要结合其他的方式了。

容器内网穿透实例

我们以一个日常操作为例,一步步说明如何在容器外简单快速的接入到 Docker 容器网络中并进行服务维护。本例使用 compose file 搭建了一个 Huginn 服务,其网络拓扑如下。在 web 容器中,通过各个服务的容器名+端口如 postgres:5432elasticsearch:9200 即可访问内网中各个服务。

Huginn网络拓扑

在这个例子中,我编写了一个 Python 脚本,从 PostgreSQL 数据库中检索数据并输出到 ElasticSearch 中,同时连接 PG 数据库网络(backend)和 ES 数据库网络(es)的容器有两个,在这里,我们选择 web 容器作为 nsenter 的目标。操作 PG 的 Python 库 psycopg2 就是一个使用纯 C 语言编写的 Python 模块,安装时依赖 gcc 编译器,因此我将它安装到宿主机上的一个 pyenv 虚拟环境中。

代码的内容是将 PG 中的数据进行处理后存入 ES,在此不再详述。我们的大体思路是利用 nsenter 使 Python 进程能够连接容器内网的数据库服务,那么,在代码中配置服务器连接的时候,还是需要指定一个容器内网的 IP 地址的。这个地址从哪里找呢?

Docker容器内域名解析

可知 PG 数据库的地址是 172.27.0.2 ,我们可以用以下命令调用 pyenv 中的 python,验证一下在宿主机上安装的 psycopg2 能否连接内网的数据库。

sudo nsenter --net -t "$(docker inspect -f '{{.State.Pid}}' huginn_web_1)" "$(pyenv prefix web)/bin/python"

通过对比可以发现,如果连接失败的话, psycopg2 会在 connect 命令执行后抛出异常,因此这个 IP 是可用的。

image-20201117193212516

但是还有一个问题,写代码怎么可以把 IP 硬编码进去呢?这可太难看了。docker network 的网段在 docker-compose down 之后就被重置了,服务重启后,无论是 IP 还是网段都不能保证与之前相同。我们需要想一个方法,让容器内的 DNS 解析规则同样应用到我们容器外部的程序中。

容器内网DNS解析的小技巧

在上节中,我们查看了容器中 /etc/resolv.conf 的内容,发现容器里解析域名所使用的 DNS 是 127.0.0.11 ,它是 Docker 内嵌的一个 DNS 服务,它的具体实现可以参见 Stackoverflow 的一个回答[7]。但是这个 DNS 地址我们又不能直接用,因为连 Docker 自己都没有一个更好的办法去 override 域名解析的执行流程,只能暗搓搓的在容器 namespace 里 mount 一下,覆盖掉 /etc/resolv.conf 的内容。总结下来,只有下面几个方法可以修改单个进程的 DNS。

  1. 新建一个 mount namespace ,用自定义的 resolv.conf 覆盖那个文件。
  2. 修改系统中的 resolv.conf 文件。
  3. hook 掉 libc 里面的 fopen[8] 或 getaddrinfo/gethostbyname[9]

还是不优雅嘛。不过方式3如果能够实现,应该可以比我下面说的方法,在操作上还要简单一些,后续我会再做一些尝试。

既然如此,我们可以另辟蹊径,使用 glibc 自带的 HOSTALIASES 机制 [9:1][10] [11],构造一个类似 hosts 文件的域名别名列表,再通过名为 HOSTALIASES 的环境变量传给目标进程,就可以变相实现让宿主机上的应用能够解析容器内部的 IP 和 domain了。

至于这个域名别名列表,我们可以使用 nsenter 配合 dig 命令,从容器内批量查询,最后生成一个文件。为了这个需求我写了下面的一个小脚本。

#!/bin/bash

CONTAINER="$1"
PID="$(docker inspect -f '{{.State.Pid}}' "$CONTAINER")"
PROVIDER="xip.io"
# PROVIDER="traefik.me"


if [ -z "$PID" ]; then
>&2 echo "usage: $0 <container name>"
exit 1
fi

while IFS='' read -r line; do
IP="$(sudo nsenter --net -t "$PID" dig +short $line @127.0.0.11)"
[ -z "$IP" ] && echo "wtf? I get nothing for $line"
echo "$line $IP.$PROVIDER"
done

至于使用方法,只需要把需要解析的域名通过 stdin 传给这个脚本,运行参数指定目标容器的名称,并把它的输出保存即可。

$ ./hostalias.sh huginn_web_1 > /tmp/huginn_web.hostalias <<EOF
postgres
elasticsearch
EOF
$ cat /tmp/huginn_web.hostalias
postgres 172.27.0.2.traefik.me
elasticsearch 172.26.0.3.traefik.me

需要注意的一点是,HOSTALIASES 的机制是将对一个域名的查询转换为对另一个域名的查询,这一点与 hosts 文件不同。因此在文件的一行中,空格前面后面两个字段都必须是域名。因此,我们需要一个公共的泛域名解析服务为我们提供任意 IP 转域名的功能[12]。我了解的几个比较好用的服务包括 xip.iotraefik.me 。这个解析服务的效果看上面的例子显而易见,也可以点进它们的网站去查看说明。

有了这个 HOSTALIASES 文件,我们就可以把 postgreselasticsearch 这种 docker 内网域名,直接写到脚本里,然后通过这个文件的映射关系让它自己转换 IP 了。再也不需要关心 IP 了。

至于最终脚本的参数配置与运行,就变得朴实无华,且枯燥了。

脚本数据库连接部分

脚本运行输出

结语

我前面提到了,这是东半球第二好用的解决方案?对。本来我写的是最好用的。但是在写文章的过程中,经过总结思考,我认为,通过 LD_PRELOAD 插入一个 hook,修改 resolv.conf 将 DNS 改为 127.0.0.11 ,再配合 nsenter 应该才是最最省事的方法。毕竟使用 HOSTALIAS 对于每个 docker network 都需要重新配置一次,即使用我的脚本再方便,终究还是要多输入一行命令,不过对于懒人,也罢。这些解决方法已经比我们之前熟悉的方法要方便得多了。

封面图:

再稍微正常一点的封面图:


  1. The underlying technology - Docker overview | Docker Documentation https://docs.docker.com/get-started/overview/#the-underlying-technology ↩︎

  2. imageproxy/Dockerfile at main · willnorris/imageproxy https://github.com/willnorris/imageproxy/blob/main/Dockerfile ↩︎

  3. Create a simple parent image using scratch - Create a base image | Docker Documentation https://docs.docker.com/develop/develop-images/baseimages/#create-a-simple-parent-image-using-scratch ↩︎

  4. namespaces - overview of Linux namespaces - Miscellaneous https://www.mankier.com/7/namespaces ↩︎

  5. nsenter - run program in different namespaces - man page https://www.mankier.com/1/nsenter ↩︎ ↩︎

  6. capabilities - overview of Linux capabilities - man page https://www.mankier.com/7/capabilities ↩︎

  7. how does Docker Embedded DNS resolver work? - Stack Overflow https://stackoverflow.com/a/50730336/1043209 ↩︎

  8. how to change a file’s content for a specific process only? - Unix & Linux Stack Exchange https://unix.stackexchange.com/a/361312/264704 ↩︎

  9. HOSTALIASES https://blog.tremily.us/posts/HOSTALIASES/ ↩︎ ↩︎

  10. hostname - hostname resolution description - Miscellaneous https://www.mankier.com/7/hostname ↩︎

  11. Overriding DNS entries per process - Unix & Linux Stack Exchange https://unix.stackexchange.com/a/304865/264704 ↩︎

  12. hosts - Hostaliases file with an IP address - Unix & Linux Stack Exchange https://unix.stackexchange.com/a/226318/264704 ↩︎

打破容器的界限——使用nsenter实现Docker“内网穿透”

https://blog.rabit.pw/2020/docker-service-management-w-nsenter/

作者

Known Rabbit

发布于

2020-11-15

更新于

2020-11-18

许可协议

评论