GUI全称是图形用户界面(Graphical User Interface),我们平时使用的大部分软件,例如浏览器、办公软件、聊天软件等都是有图形界面的。
Docker运行GUI软件的技术已经算不上是什么新鲜事了。上个月,我在一次微信群的活动中,分享了通过共享X11套接字的方法在本地和远程容器中运行GUI软件的方法, 这篇 记录刊登了那次分享的内容。如果说这些把戏已经算是把Docker玩出花儿了,那么今天我们再来说一些更厉害的东西。
还是先回到这个系列的主题上:CoreOS。
CoreOS作为一个为服务器集群设计的系统,与许多其他专用于服务器端的Linux发行版一样,有一个与桌面Linux发行版很直观的不同:它没有GUI桌面环境。同时由于CoreOS使用了只读的系统分区,想在系统上直接安装一个X11服务是行不通的。这就意味着,容器中的软件不能像先前分享中所演示的那样,直接通过共享宿主系统的X11服务实现GUI界面的呈现。那么,有没有什么其他的思路能让CoreOS上运行GUI软件呢?
既然无法依靠宿主系统,为了运行GUI软件,只能全靠在容器里面这一亩三分地上白手起家了。
依据这个思路,我们准备在容器里面从零搭建整个X11的世界。不过,要是安装标准的X11服务,加上它的各种依赖,少说需要几百MB的额外空间,其他啥都没装就把镜像变大好几倍了,工程着实浩大。好在开源界已经有了许多种轻量级X11服务替代品,例如 Xdummy
、 Xvfb
和 Xpra
。这些平时不太显眼的宝藏在容器中可以大有作为。
俗话说不积跬步无以至千里,在深入到的具体的GUI环境实施方案之前,让我们先从更加全局的角度考虑一下,除去不同方案所需的特殊软件,作为一个需要对外提供显示图形环境的容器,有哪些属于基础设施需要解决的问题。
以使用Ubuntu 14.04的Docker镜像为例,下面这些都是常见的公共基础配置:
我们现在要创建的是一个自给自足Docker环境。因此这一步是要对原始的Docker基础镜像做一些所有后面用到的镜像都需要的前期准备。
首先,在Ubuntu的Docker官方镜像中是没有缓存Apt的软件包列表的。因此在做其他任何基础软件的安装前,都需要至少先做一次 apt-get update
。有时为了加快apt-get安装软件的速度,还需要修改Apt源的列表文件 /etc/apt/sources.list
。相应的操作用命令表示如下:
# 使用Ubuntu官方的Apt源,也可以根据实际需要修改为国内源的地址 echo "deb http://archive.ubuntu.com/ubuntu trusty main universe/n" > /etc/apt/sources.list echo "deb http://archive.ubuntu.com/ubuntu trusty-updates main universe/n" >> /etc/apt/sources.list
在容器构建时,我们通常不用关心更新Apt缓存过程中打印的日志,此时可以使用 -qq
参数隐藏这些输出。同时为了避免更新过程中需要进行交互操作,还需要使用 -y
参数来消除更新过程中所有向用户提问的部分。完整的Apt更新命令如下:
apt-get update -qqy
其次,Docker官方的Ubuntu镜像是不支持中文的,为了在能够在界面中显示汉字内容,我们需要安装相应的语言包,设置系统的字符集为 zh_CN.UTF-8
,并将系统的默认字体设置为中文。
安装中文语言包的操作如下,注意其中的 apt-get install
命令,除了使用 -qqy
参数去掉不必要的日志和提问外,还加上了 --no-install-recommends
参数来避免安装非必须的文件,从而减小镜像的体积:
# 安装中文语言 /usr/share/locales/install-language-pack zh_CN locale-gen zh_CN.UTF-8 dpkg-reconfigure --frontend noninteractive locales apt-get -qqy --no-install-recommends install language-pack-zh-hans
安装完成中文语言后,还需设置运行上下文的环境变量,使得程序默认使用中文。由于历史原因,不同的应用程序可能采用不同的环境变量设定语言,推荐同时设置 LANGUAGE
、 LANG
、 LC_ALL
三个变量,值都设定为 zh_CN.UTF-8
即可。
字体的安装包含两部分,安装基础X11字体和安装中文字体,基础字体没啥说的,把Apt仓库里面 xfonts-
开头的常用几个包安装了就行。
# 安装基本字体 apt-get -qqy --no-install-recommends install fonts-ipafont-gothic xfonts-100dpi xfonts-75dpi xfonts-cyrillic xfonts-scalable
中文字体推荐使用 文泉驿微米黑 ,这是一套文泉驿基于Google开源字体Droid Sans Fallback开发的TrueType字体。它本身遵循GNU GPL开源协议,包含了2万多个常用汉字,并且已经默认包含在Apt的仓库中了,安装也非常简单。
# 安装文泉驿微米黑字体 apt-get -qqy --no-install-recommends install ttf-wqy-microhei
再通过软连接设置一下系统默认的默认中文字体,语言的配置就完成了。
# 将文泉驿微米黑设置为系统默认字体 ln /etc/fonts/conf.d/65-wqy-microhei.conf /etc/fonts/conf.d/69-language-selector-zh-cn.conf
接下来是时区。Linux中的系统时区是使用 TZ
环境变量和 /etc/timezone
配置文件指定的。将 TZ
变量赋值为 PRC
, /etc/timezone
文件内容写入 Asia/Shanghai
即可。完成以后需要再使用 dpkg-reconfigure
命令重新配置一次 tzdata
系统软件包。
dpkg-reconfigure --frontend noninteractive tzdata
用于有些软件禁止使用root用户启动,比如Chrome浏览器。为了方便使用,我们通常还会给镜像添加一个除了root以外的用户,并为这个用户赋予sudo权限并设置密码。下面的命令创建了名为 linfan
的用户,添加免密sudo权限,然后设置密码为 pa55w0rd
。
useradd linfan --shell /bin/bash --create-home usermod -a -G sudo linfan echo 'linfan ALL = (ALL) NOPASSWD: ALL' >> /etc/sudoers echo 'linfan:pa55w0rd' | chpasswd
最后,我们还可以在这个基础镜像中安装一些常用的软件,例如curl、wget、x11-apps等。
apt-get -qqy --no-install-recommends install curl wget
到目前为止,这个镜像还没有涉及太多界面显示相关的内容,然而它已经包含了后面要介绍的两种界面显示方法所需的公共基础文件。因此我们可以将它做成一个Base镜像,下面是完整的Dockerfile:
FROM ubuntu:14.04 MAINTAINER FanLin <linfan.china@gmail.com> # 使用root用户 USER root # 使用Ubuntu官方的Apt-get源 RUN echo "deb http://archive.ubuntu.com/ubuntu trusty main universe/n" > /etc/apt/sources.list / && echo "deb http://archive.ubuntu.com/ubuntu trusty-updates main universe/n" >> /etc/apt/sources.list # 更新源 RUN apt-get update -qqy # 配置中文语言 ENV LANGUAGE zh_CN.UTF-8 ENV LANG zh_CN.UTF-8 ENV LC_ALL=zh_CN.UTF-8 RUN /usr/share/locales/install-language-pack zh_CN / && locale-gen zh_CN.UTF-8 / && dpkg-reconfigure --frontend noninteractive locales / && apt-get -qqy --no-install-recommends install language-pack-zh-hans # 安装基本字体 RUN apt-get -qqy --no-install-recommends install / fonts-ipafont-gothic / xfonts-100dpi / xfonts-75dpi / xfonts-cyrillic / xfonts-scalable # 安装文泉驿微米黑字体 RUN apt-get -qqy install ttf-wqy-microhei / && ln /etc/fonts/conf.d/65-wqy-microhei.conf /etc/fonts/conf.d/69-language-selector-zh-cn.conf # 设置时区 ENV TZ "PRC" RUN echo "Asia/Shanghai" | tee /etc/timezone / && dpkg-reconfigure --frontend noninteractive tzdata # 添加具有免密码sudo权限的普通用用户 RUN useradd linfan --shell /bin/bash --create-home / && usermod -a -G sudo linfan / && echo 'linfan ALL = (ALL) NOPASSWD: ALL' >> /etc/sudoers / && echo 'linfan:pa55w0rd' | chpasswd # 安装其他基础软件 RUN apt-get -qqy --no-install-recommends install curl wget # 删除不必要的软件和Apt缓存包列表 RUN apt-get autoclean && / apt-get autoremove && / rm -rf /var/lib/apt/lists/*
注意在这个文件的最末尾,我加上了几行用于清理多余软件和Apt缓存的命令,这样做是为了减少镜像的体积。这些在构建镜像时更新的Apt缓存在未来被用于基础镜像时可能已经过时了,因此没有必要保留它们,而是应该让每个基于这个镜像的子镜像重新执行 apt-get update
来获取最新的软件包列表。
进入Dockerfile所在的目录,通过下面的命令构建出这个镜像并加上名为 linfan/uibase
的标签。
$ docker build -t linfan/uibase .
在安装中文的步骤中会出现一些警告信息,可以暂且忽略它们。如果注意观察输出的日志,会发现从安装中文语言以后的部分输出的日志已经全部变成中文了。这是因为Dockerfile的编译其实是在一个临时容器中进行的,而这个容器的系统语言在这一步中被改成了中文的缘故。
在X11协议中,分为X11服务端和X11客户端两个部分。X11服务端是用于驱动具体显示硬件将数据进行展示的模块,而X11客户端则接收应用程序和用户的操作,并产生刷新屏幕信息的命令发送给服务端。服务端与客户端可以是在同一个主机上,也可以通过网络相连。如下图所示:
Xvfb
的全称是“X virtual frame buffer”,是一种X11服务端的特殊实现。说比较特殊是因为Xvfb不需要实际的显示装置和硬件驱动,它将渲染的图像内容保存在内存中,最初的应用场景主要是用于自动化测试等不需要看到执行界面的地方,作为完整X服务的替代。
在前面已经提过,之所以考虑到使用这个工具,还有一个很重要的原因:轻量。 Xvfb
的所有文件放在一起只有大约10MB的大小(加上一些额外依赖的包,实际增加镜像的体积大概在几十MB)。这样一种轻量级的X11服务器用在Docker里面使用实在是在合适不过了,此外, Xvfb
也与CoreOS不支持图形显示、没有显示器驱动的情况十分契合。
现在还有个关键性的问题:怎样把内存里的渲染数据表现出来。为此,我们需要引入另一个Linux下的工具软件 X11vnc
,它提供了将X11服务端内容获取出来并展现到远程的用户控制端的功能。
在X11的通信协议中有一个十分重要的变量: DISPLAY
。这个变量能够决定X11的服务端怎样监听来自客户端的控制指令。DISPLAY的格式是 unix:端口
或 主机地址:端口
,前一种格式表示使用本地的UNIX套接字,后一种表示使用TCP套接字。换句话说,前一种适用于X11服务端和客户端在同一个主机上的情况,而后一种适用于X11服务端与客户端分布在不同主机的情况。
在这个方案中,咋看起来CoreOS不具备显示设备和显卡驱动,显示的内容展现在远端的用户屏幕中,似乎应该使用后一种地址格式。然而,实际上 X11vnc
虽然将数据传送到了远程的展现端,但它本身却是X11的客户端。正如 X11vnc
的名字所体现的,它在这个过程中扮演了一个中介者的角色,将 Xvfb
服务在Docker容器中通过X11协议传输的显示数据获取后,再通过另一种VNC远程控制协议将这些数据转发出来,而用户操作的是一个VNC协议的客户端。因此,对于X11的部分来说,它的服务端和客户端运行在同一个主机上(也就是同一个容器里), DISPLAY
的变量值应该使用本地的任意UNIX端口地址。这个通信流程如下图所示:
虽然用户无法直接看到Xvfb的图形渲染结果,但 Xvfb
的确在内存里实实在在存放了实际的图像数据。 Xvfb
服务在运行程序时通过 DISPLAY
变量的值监听X11服务请求,而 X11vnc
服务在运行时则通过 DISPLAY
变量的值(也可以通过 -display
参数传入)获取X11图形数据并转发到VNC客户端。
和X11协议相似,VNC协议也同时支持的图像数据和控制指令的双向传输。这种协议设计出来就是为了做远程控制的,它最初用于AT&T的欧洲研究实验室开发的的同名软件VNC,全称是“虚拟网络计算机”(Virtual Network Computer)。它在Windows、Linux、Mac甚至手机系统上都有客户端,因此实施起来十分方便。
经过这么个颇有些一波三折的路程,显示数据最终被妥妥的投递到了用户的显示器。方案介绍完了,下面我们就来制作这个Docker镜像。
从Docker的最佳实践来看,凡是能够被复用的部分都应该考虑独立成可以复用的镜像。基于Xvfb和X11vnc的运行环境之上可以运行各种不同的具体应用,因此我们应该将这部分功能做成与实际应用无关的基础镜像。
首先脑力验算一下,这个镜像需要做的事情有哪些。
恩,整齐归一,思路上确实是很清晰的。不过其实我们还漏了一个东西:窗口管理器。
使用过Linux的用户大概都听说过KDE和GNOME。它们都是比较常见的窗口管理器,窗口管理器是做什么的呢? 这篇 文章介绍了X11的设计理念,在这种理念的驱动下,X11的界面与窗口是两个完全不同的东西。简单来说,如果没有窗口管理器,用户虽然能够看到和操作界面却不能改变窗口的大小、位置,软件的界面会被以启动时指定的固定大小,显示在屏幕的左上角为原点的固定区域内。这种操作体验显然是不愉快的。
在容器中尽量使用轻量的解决方案,KDE和GNOME这些动辄几百MB的窗口管理器,实在有碍观瞻。因此,我们的镜像将使用另一款只有不到10MB的窗口管理器: Fluxbox 。选择它一方面由于它的轻巧、高效且稳定,另一方面则是因为 它的代码是开源的 ,且仍然在持续更新中,并不是一个已经过时的项目。
VNC、Xvfb和Fluxbox都已经在Ububtu官方的Apt源中,因此安装很简单:
apt-get -qqy install x11vnc xvfb fluxbox
配置的部分,Xvfb和Fluxbox都是开箱即用,基本不需要什么配置。X11vnc则相对麻烦一些,需要额外的配置处理,主要是屏幕的分辨率、色彩深度和可选的连接密码等。
屏幕的分辨率是使用环境变量 SCREEN_WIDTH
、 SCREEN_HEIGHT
分别对应屏幕的宽度和高度,而色彩深度是通过 SCREEN_DEPTH
变量指定的。
另外在启动X11vnc时默认情况下会有一些需要交互的问题,可以通过设置Ubuntu的环境变量 DEBIAN_FRONTEND
值为 noninteractive
,并设置 DEBCONF_NONINTERACTIVE_SEEN
值为 true
来绕过它们。
此外, DISPLAY
变量的值也应该在这个镜像中定义出来。前面已经介绍过,我们需要使用UNIX套接字的方式让Xvfb和X11vnc通信。在Linux中,每个UNIX套接字本质上是系统 /tmp/.X11-unix
目录下面依据套接字端口编号命名的一个特殊文件,例如 unix:1
其实就是 /tmp/.X11-unix/X1
这个套接字文件。作为演示,我们可以选一个比较大的UNIX套接字编号,例如 unix:99
。另外,设置本地的 DISPLAY
变量值时,惯例上可以省略前面的"unix"而直接将值设置为":99"。
VNC协议允许一个可选的连接密码。但出于服务器安全的考虑,VNC默认是禁止使用空密码连接的。因此用户要么设置一个密码,要么开启许可空密码连接。人家好心禁用掉的东西,我们还是保留下来吧。使用 x11vnc -storepasswd
命令可以将指定的明文密码Hash处理后保存为文件,作为用户连接时的密码验证内容。我们的将连接密码设置为 pa55w0rd4vnc
:
mkdir -p ~/.vnc x11vnc -storepasswd pa55w0rd4vnc ~/.vnc/passwd
最后要说的是镜像服务的启动。由于设计到了多个服务程序的协作,且由于服务之间的依赖,它们之间有一定的运行顺序要求,因此,设计这样一个容器的启动命令要比单一服务更复杂一些。它需要通过一个单独的启动脚本或专门的进程管理工具进行管理,例如Supervisord。为了不再增加镜像的复杂度,这里不准备介绍进程管理工具的使用,而是直接通过脚本控制服务的启动流程。下面是一个参考的启动脚本,脚本的每个部分都已经加上了适当的注释,不再详述。注意这个脚本中使用了一个 APP_START
变量,这个变量的值就是要被运行的GUI软件启动命令,将由具体应用的镜像来设定。
#!/bin/bash # 将屏幕分辨率和色彩深度的环境变量组合成 export GEOMETRY="$SCREEN_WIDTH""x""$SCREEN_HEIGHT""x""$SCREEN_DEPTH" # 注册结束信号的捕获器,当容器结束时,尝试让应用程序优雅的关闭 function shutdown { kill -s SIGTERM $NODE_PID wait $NODE_PID } trap shutdown SIGTERM SIGINT # 使用Xvfb后台运行指定具有界面的软件,并记录下Xvfb程序的PID sudo -E -i -u linfan / DISPLAY=$DISPLAY / xvfb-run --server-args="$DISPLAY -screen 0 $GEOMETRY -ac +extension RANDR" / $APP_START & NODE_PID=$! # 等待Xvfb程序启动完成 for i in $(seq 1 10); do xdpyinfo -display $DISPLAY >/dev/null 2>&1 if [ $? -eq 0 ]; then break fi echo Waiting xvfb... sleep 0.5 done # 后台运行窗口管理器Fluxbox fluxbox -display $DISPLAY & # 后台运行X11vnc服务 x11vnc -forever -usepw -shared -rfbport 5900 -display $DISPLAY & # 由于所有服务都是后台启动的,最后这个wait确保了在程序结束前,容器不会停止 wait $NODE_PID
将这个脚本保存成名为“entry_point.sh”的文本文件,在与这个脚本相同的目录创建如下Dockerfile,它包含前面介绍的了与VNC、Xvfb和Fluxbox相关的所有构建步骤:
FROM linfan/uibase:latest MAINTAINER FanLin <linfan.china@gmail.com> # 更新源 RUN apt-get update -qqy # 安装 VNC、Xvfb 和 Fluxbox RUN apt-get -qqy install x11vnc xvfb fluxbox # 生成Hash过的密码文件 RUN mkdir -p ~/.vnc / && x11vnc -storepasswd pa55w0rd4vnc ~/.vnc/passwd # 删除不必要的软件和Apt缓存包列表 RUN apt-get autoclean && / apt-get autoremove && / rm -rf /var/lib/apt/lists/* # 屏蔽交互界面 ENV DEBIAN_FRONTEND noninteractive ENV DEBCONF_NONINTERACTIVE_SEEN true # 屏幕尺寸和颜色深度 ENV SCREEN_WIDTH 1360 ENV SCREEN_HEIGHT 1020 ENV SCREEN_DEPTH 24 # 可以使用任意Unix套接字编号 ENV DISPLAY :99.0 # 暴露VNC的端口 EXPOSE 5900 # 拷贝启动脚本 COPY entry_point.sh /opt/bin/entry_point.sh RUN chmod +x /opt/bin/entry_point.sh CMD ["/opt/bin/entry_point.sh"]
在这个Dockerfile中我们暴露了一个 5900
端口,这个端口是 X11vnc
服务默认的对外VNC协议通信端口。通过这个端口,用户就可以用VNC客户端连接到容器中的图像内容了。最后在与这个Dockerfile相关的目录下执行 docker build
命令,并为容器加上 linfan/x11vnc
标签。
$ docker build -t linfan/x11vnc .
镜像编译完成后,检查一下最终生成镜像的体积。比最初的ubuntu镜像大约增加了170MB,还算可以接受。
$ docker images REPOSITORY TAG IMAGE ID CREATED VIRTUAL SIZE x11vnc latest 8a20d873be36 1 hours ago 358.1 MB uibase latest d717f56a9b29 2 hours ago 256.8 MB ubuntu 14.04 8251da35e7a7 3 days ago 188.3 MB
现在,万事俱备只欠东风。关键的一步,往容器里面安装用户自己的应用软件。这个部分就是可以依照具体情况随意发挥的地方了,镜像中的程序可以是从代码编译的、从网上下载的、或者直接通过apt-get安装的。安装好后,只需要要将需要运行的GUI软件启动命令设置到“APP_START”变量中就可以了。
下面以安装Chrome浏览器为例,演示Dockerfile的写法:
FROM linfan/x11vnc:latest MAINTAINER FanLin <linfan.china@gmail.com> # 安装Chrome RUN wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add - / && echo "deb http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google-chrome.list / && apt-get update -qqy / && apt-get -qqy install google-chrome-stable # 设置启动命令 ENV APP_START "/opt/google/chrome/chrome infoq.com.cn"
构建这个镜像,加上 linfan/chrome
标签,然后启动一个名为 chrome01
的容器实例,并找到它映射到外部的端口:
$ docker build -t linfan/chrome . $ docker run --name chrome01 -P -d linfan/chrome $ docker port chrome01 5900/tcp -> 0.0.0.0:32775
然后通过本地的VNC软件连接到这个外部端口,就可以看到一个运行这的Chrome浏览器啦!
这个镜像已经完美了吗?显然远远没有。
通过脚本启动的GUI程序窗口(例如Chrome浏览器)在被用户关闭之后,整个容器就会跟着结束,这些许并不是许多用户所期望的效果。前面已经提到,除了简单的通过脚本来启动所需的服务,还可以通过进程管理工具来获得更多的后台服务控制能力。推荐采用 Supervisord 作为Docker的后台服务管理工具,它支持自动监控服务状态,并当服务故障结束时自动重启服务,是一个十分实用的容器服务帮手。
其次,这个方案中使用的 X11vnc
需要通过专用的VNC客户端接入。对于Mac用户,操作系统已经原生支持这种协议的远程连接。然而对于Linux和Windows的用户都需要安装额外的VNC客户端软件才能使用,并不是十分方便。 NoVNC 是一个开源的工具软件,它能运行在服务器上作为VNC的客户端,并将图形和控制信息通过HTML的Canvas技术展示在浏览器中。因此使用者只需要在任意设备上打开指定的浏览器端口即可接入。它引入的新的一层协议转换(X11<=>VNC<=>HTTP)但相比它带来的便捷性,依然是很值得的。
此外,这个方案依然还有许多其他值得改进的地方等待着被发现和改进。如果你有这方面看法,欢迎与我共同探讨。
现实项目中,在Docker中运行GUI软件并不是一种十分典型的应用场景,然而这种场景确实展示出了容器技术应用的广泛性,甚至在最具权威的2015DockerCon大会上也被作为技惊四座的话题让现场沸腾。
这个系列中,将使用两篇文章分别介绍了除了在DockerCon演示中使用的容器与宿主系统共享X11套接字方式以外,其他的两种在容器中运行GUI软件的方式。它们的共同特定是,不依赖于宿主系统上的界面窗口服务,因此在CoreOS和其他只有命令行的Linux环境中也能够使用。
在系列的下篇中,将介绍使用Xpra和Xephyr服务在Docker中运行GUI软件的方法,它避免了Xvfb和X11vnc服务构建方式使用X11和VNC两种协议重复转发数据的弊端。并将在最后对目前常见的4种Docker运行GUI软件的方法做一个比较和总结。万变不离其宗,相信了解这些不同的实用方法后,读者能够根据具体的情况找到最适合自己场景的一种。
感谢郭蕾对本文的策划和审校。
给InfoQ中文站投稿或者参与内容翻译工作,请邮件至editors@cn.infoq.com。也欢迎大家通过新浪微博(@InfoQ,@丁晓昀),微信(微信号: InfoQChina )关注我们,并与我们的编辑和其他读者朋友交流(欢迎加入InfoQ读者交流群 )。