运行 Docker 容器中的 X 应用程序可靠地位于通过 SSH 连接的服务器上,无需“--net host”

Run X application in a Docker container reliably on a server connected via SSH without "--net host"

没有Docker容器,使用SSH X11转发(ssh -X)直接在远程服务器上运行一个X11程序.当服务器上的 Docker 容器中的应用程序 运行s 时,我试图让同样的事情工作。当使用 -X 选项通过 SSH 连接到服务器时,会设置 X11 隧道,并且环境变量“$DISPLAY”通常会自动设置为 "localhost:10.0" 或类似值。如果我只是尝试 运行 Docker 中的 X 应用程序,我会收到此错误:

Error: GDK_BACKEND does not match available displays

我的第一个想法是使用“-e”选项实际将 $DISPLAY 传递到容器中,如下所示:

docker run -ti -e DISPLAY=$DISPLAY name_of_docker_image

这有帮助,但不能解决问题。错误消息更改为:

Unable to init server: Broadway display type not supported: localhost:10.0
Error: cannot open display: localhost:10.0

在网上搜索后,我发现我可以做一些 xauth 魔术来修复身份验证。我添加了以下内容:

SOCK=/tmp/.X11-unix
XAUTH=/tmp/.docker.xauth
xauth nlist $DISPLAY | sed -e 's/^..../ffff/' | xauth -f $XAUTH nmerge -
chmod 777 $XAUTH
docker run -ti -e DISPLAY=$DISPLAY -v $XSOCK:$XSOCK -v $XAUTH:$XAUTH \ 
  -e XAUTHORITY=$XAUTH name_of_docker_image

但是,这只有在将“--net host”添加到 docker 命令时才有效:

docker run -ti -e DISPLAY=$DISPLAY -v $XSOCK:$XSOCK -v $XAUTH:$XAUTH \ 
  -e XAUTHORITY=$XAUTH --net host name_of_docker_image

这是不可取的,因为它使整个主机网络对容器可见。

为了在没有“--net host”的情况下 docker 中的远程服务器上 运行 现在缺少什么?

我明白了。当您使用 SSH 连接到计算机并使用 X11 转发时,/tmp/.X11-unix 不用于 X 通信,与 $XSOCK 相关的部分是不必要的。

任何 X 应用程序宁愿使用 $DISPLAY 中的主机名,通常 "localhost" 并使用 TCP 连接。然后通过隧道返回到 SSH 客户端。当对 Docker 使用“--net host”时,Docker 容器的 "localhost" 将与 Docker 主机相同,因此它会正常工作。

未指定“--net host”时,Docker使用默认桥接网络模式。 这意味着"localhost"意味着容器内部与主机不同的东西,并且容器内部的X应用程序将无法通过引用[=57]看到X服务器=].因此,为了解决这个问题,必须将 "localhost" 替换为主机的实际 IP 地址。这通常是“172.17.0.1”或类似的。检查 "ip addr" 以获得 "docker0" 接口。

这可以通过 sed 替换来完成:

DISPLAY=`echo $DISPLAY | sed 's/^[^:]*\(.*\)/172.17.0.1/'`

此外,SSH 服务器通常未配置为接受到此 X11 隧道的远程连接。然后必须通过编辑 /etc/ssh/sshd_config(至少在 Debian 中)和设置来更改:

X11UseLocalhost no

然后重启SSH服务器,用"ssh -X"重新登录服务器。

差不多就这样了,但还剩下一个复杂的问题。如果任何防火墙在 Docker 主机上 运行ning,则必须打开与 X11 隧道关联的 TCP 端口。端口号是$DISPLAY中:.之间的数字加上6000.

获取TCP端口号,可以运行:

X11PORT=`echo $DISPLAY | sed 's/^[^:]*:\([^\.]\+\).*//'`
TCPPORT=`expr 6000 + $X11PORT`

然后(如果使用 ufw 作为防火墙),为 172.17.0.0 子网中的 Docker 容器打开此端口:

ufw allow from 172.17.0.0/16 to any port $TCPPORT proto tcp

所有命令都可以放在一个脚本中:

XSOCK=/tmp/.X11-unix
XAUTH=/tmp/.docker.xauth
xauth nlist $DISPLAY | sed -e 's/^..../ffff/' | sudo xauth -f $XAUTH nmerge -
sudo chmod 777 $XAUTH
X11PORT=`echo $DISPLAY | sed 's/^[^:]*:\([^\.]\+\).*//'`
TCPPORT=`expr 6000 + $X11PORT`
sudo ufw allow from 172.17.0.0/16 to any port $TCPPORT proto tcp 
DISPLAY=`echo $DISPLAY | sed 's/^[^:]*\(.*\)/172.17.0.1/'`
sudo docker run -ti --rm -e DISPLAY=$DISPLAY -v $XAUTH:$XAUTH \
   -e XAUTHORITY=$XAUTH name_of_docker_image

假设您不是 root,因此需要使用 sudo。

您可以 运行:

而不是 sudo chmod 777 $XAUTH
sudo chown my_docker_container_user $XAUTH
sudo chmod 600 $XAUTH

防止服务器上的其他用户在知道您为什么创建 /tmp/.docker.auth 文件的情况下也能够访问 X 服务器。

我希望这能让它在大多数情况下都能正常工作。

就我而言,我坐在“远程”并连接到“docker_host”上的“docker_container”:

remote --> docker_host --> docker_container

为了使用 VScode 更轻松地调试脚本,我将 SSHD 安装到“docker_container”,在端口 22 上报告,映射到“[=48=”上的另一个端口(比如 1234) ]".

所以我可以通过 ssh(从“远程”)直接连接 运行 容器:

ssh -Y -p 1234 appuser@docker_host.local

(其中 appuser 是“docker_container”中的用户名。我现在在我的本地子网上工作,所以我可以通过 .local 映射引用我的服务器。对于外部 IP,只要确保你的路由器映射到这台机器的这个端口。)

这会通过 ssh 直接从我的“远程”到“docker_container”建立连接。

remote --> (ssh) --> docker_container

在“docker_container”中,我安装了 sshd sudo apt-get install openssh-server(您可以将其添加到您的 Dockerfile 以在构建时安装)。

要允许 X11 转发工作,请编辑 /etc/ssh/sshd_config 文件:

X11Forwarding yes
X11UseLocalhost no

然后重启容器内的ssh。您应该从 shell 执行到容器中,从“docker_host”执行此操作,而不是当您通过 ssh 连接到“docker_container”时执行此操作:(docker exec -ti docker_container bash)

重启sshd: sudo service ssh restart

当您通过 ssh 连接到“docker_container”时,请检查 $DISPLAY 环境变量。它应该是这样的

appuser@3f75a98d67e6:~/data$ echo $DISPLAY
3f75a98d67e6:10.0

通过 ssh 从“docker_container”中执行您最喜欢的 X11 图形程序进行测试(如 cv2.imshow())

如果您设置 X11UseLocalhost = no,您甚至允许 外部流量到达 X11 套接字 。即指向本机外网IP的流量可以到达SSHD X11转发。还有两个 可能 适用的安全机制(防火墙、X11 身份验证)。尽管如此,如果您正在摆弄用户甚至 application-specific 问题,我还是希望单独留下 系统全局设置


这是在 sshd 配置中更改 X11UseLocalhost 的替代方法:

                                           + docker container net ns +
                                           |                         |
           172.17.0.1                      |   172.17.0.2            |
        +- docker0 --------- veth123@if5 --|-- eth0@if6              |
        |  (bridge)          (veth pair)   |   (veth pair)           |
        |                                  |                         |
        |  127.0.0.1                       +-------------------------+
routing +- lo
        |  (loopback)
        |
        |  192.168.1.2
        +- ens33
           (physical host interface)

默认情况下 X11UseLocalhost yes,sshd 在根网络名称空间上侦听 127.0.0.1。我们需要从 docker 网络命名空间内部获取 X11 流量到根网络 ns 中的环回接口。 veth 对连接到 docker0 网桥,因此两端可以在没有任何路由的情况下与 172.17.0.1 通信。根net ns中的三个接口(docker0loens33)可以通过路由进行通信。

我们希望实现以下目标:

                                           + docker container net ns +
                                           |                         |
           172.17.0.1                      |   172.17.0.2            |
        +- docker0 --------< veth123@if5 --|-< eth0@if6 -----< xeyes |
        |  (bridge)          (veth pair)   |   (veth pair)           |
        v                                  |                         |
        |  127.0.0.1                       +-------------------------+
routing +- lo >------- sshd -+
           (loopback)        |
                             v
           192.168.1.2       |
           ens33 ------<-----+
           (physical host interface)

我们可以让 X11 应用程序直接与 172.17.0.1 对话以“逃避”docker 网络 ns。这是通过适当设置 DISPLAY 来实现的:export DISPLAY=172.17.0.1:10:

                                           + docker container net ns+
                                           |                         |
           172.17.0.1                      |   172.17.0.2            |
           docker0 --------- veth123@if5 --|-- eth0@if6 -----< xeyes |
           (bridge)          (veth pair)   |   (veth pair)           |
                                           |                         |
           127.0.0.1                       +-------------------------+
           lo
           (loopback)
         
           192.168.1.2
           ens33
           (physical host interface)

现在,我们添加一条iptables规则,在根网ns中从172.17.0.1路由到127.0.0.1:

iptables \
  --table nat \
  --insert PREROUTING \
  --proto tcp \
  --destination 172.17.0.1 \
  --dport 6010 \
  --jump DNAT \
  --to-destination 127.0.0.1:6010

sysctl net.ipv4.conf.docker0.route_localnet=1

也许您可以通过仅路由来自该容器 (veth end) 的流量来改进这一点。另外,老实说,我不太确定为什么需要 route_localnet127/8 似乎是一个奇怪的数据包源/目的地,因此默认情况下禁用路由。您可能还可以将流量从 docker 网络 ns 内的环回接口重新路由到 veth 对,然后从那里到根网络 ns 中的环回接口。

使用上面给出的命令,我们得到:

                                           + docker container net ns +
                                           |                         |
           172.17.0.1                      |   172.17.0.2            |
        +- docker0 --------< veth123@if5 --|-< eth0@if6 -----< xeyes |
        |  (bridge)          (veth pair)   |   (veth pair)           |
        v                                  |                         |
        |  127.0.0.1                       +-------------------------+
routing +- lo
           (loopback)

           192.168.1.2
           ens33
           (physical host interface)

但是,现在我们正在尝试以 172.17.0.1:10 身份访问 X11 服务器。这不会在 x 权限文件 (~/.Xauthority) 中找到该条目,通常类似于 <hostname>:10。使用 Ruben 的建议在 docker 容器中添加一个可见的新条目:

xauth add 172.17.0.1:10 . <cookie>

其中<cookie>是SSH X11转发设置的cookie,例如通过 xauth list.

您可能还必须在防火墙中允许流量进入 172.17.0.1:6010


您还可以从 docker 容器网络命名空间内的主机启动应用程序:

sudo nsenter --target=<pid of process in container> --net su - $USER <app>

如果没有 su,您将成为 运行 root。当然,你也可以使用另一个容器,共享网络命名空间:

sudo docker run --network=container:<other container name/id> ...

上面显示的 X11 转发机制适用于整个网络名称空间(实际上,适用于连接到 docker0 网桥的所有内容)。因此,它适用于容器网络命名空间内的任何应用程序。

我使用了一种可以完全在 docker 容器中执行的自动化方法。

只需要将DISPLAY变量传递给容器,然后挂载.Xauthority。 此外,它仅使用 DISPLAY 变量中的端口,因此它也适用于 DISPLAY=localhost:XY.Z.

的情况

创建文件 source-me.sh,内容如下:

# Find the containers address in /etc/hosts
CONTAINER_IP=$(grep $(hostname) /etc/hosts | awk '{ print  }')
# Assume the docker-host IP only differs in the last byte
SUBNET=$(echo $CONTAINER_IP | sed 's/\.[^\.]$//')
DOCKER_HOST_IP=${SUBNET}.1

# Get the port from the DISPLAY variable
DISPLAY_PORT=$(echo $DISPLAY | sed 's/.*://'  | sed 's/\..*//')
# Create the correct display-name
export DISPLAY=$DOCKER_HOST_IP:$DISPLAY_PORT

# Find an existing xauth entry for the same port (DISPLAY_PORT), 
# and copy everything except the dispay-name
# filtering out entries containing /unix: which correspond to "same-machine" connections
ENTRY=$(xauth -n list | grep -v '/unix\:' | grep "\:${DISPLAY_PORT}" | head -n 1 | sed 's/^[^ ]* *//')
# Prepend our display-name
ENTRY="$DOCKER_HOST_IP:$DISPLAY_PORT $ENTRY"
# Add the new xauth entry. 
# Because our .Xauthority file is mounted, a new file 
# named ${HOME}/.Xauthority-n will be created, and a warning 
# is printed on std-err 
xauth add $ENTRY 2> /dev/null
# replace the content of ${HOME}/.Xauthority with that of ${HOME}/.Xauthority-n
# without creating a new i-node.
cat ${HOME}/.Xauthority-n > ${HOME}/.Xauthority

创建以下 Dockerfile 进行测试:

FROM ubuntu
RUN apt-get update
RUN apt-get install -y xauth
COPY source-me.sh /root/
RUN cat /root/source-me.sh >> /root/.bashrc
 
# xeyes for testing:
RUN apt-get install -y x11-apps

构建并运行:

docker build -t test-x .
docker run -ti \
    -v $HOME/.Xauthority:/root/.Xauthority:rw \
    -e DISPLAY=$DISPLAY \
    test-x \
    bash

容器内,运行:

xeyes

至 运行 non-interactively,您必须确保 source-me.sh 来源:

docker run \
    -v $HOME/.Xauthority:/root/.Xauthority:rw \
    -e DISPLAY=$DISPLAY \
    test-x \
    bash -c "source source-me.sh ; xeyes"