Docker 在 Coding WebIDE 项目中的运用

阅读数:4043 2015 年 6 月 18 日

话题:语言 & 开发架构

随着云计算技术的日新月异,云端的代码仓库、分工协作、演示运行已经被人们广为接受。云端开发的出现也正是顺应了这一趋势。Docker 作为一个轻量级的隔离环境,无疑是云端开发解决资源和效率问题的秘药良方。

记得 4 月份的杭州 Docker Meetup 有一参会者提问,“作为一个云主机的租户,向主机商购买的计算资源,其获得的配额不是真实值而只是上限,觉得不值。”这个问题似乎揭露了商家的生意经,但是本人却有不同的看法。正是因为共享技术的发展,才让云计算资源变得廉洁而被广为接受,Docker 最大的价值也在这里。

从技术特性上看,VM 和 Docker 有些重合点。但是虚拟机是基于 Hypervisor 技术的,而 Docker 是基于容器技术的。Hypervisor 要比 Container 更底层,不是同一层面的竞争关系,真实的场景多是先 Hypervisor 再 Container,通俗的说法就是在 VM 里跑 Container。

Hypervisor 技术让多个操作系统共享一个 CPU 硬件,这些操作系统独立运行,并不知道彼此的存在,仿佛独占了所有的硬件资源。

Container 技术让多个用户空间共享一个操作系统,这些用户空间彼此隔绝,仿佛独占整个操作系统。

我们都知道,文件是对 I/O 设备的抽象表示,虚拟存储器是对主存和磁盘 I/O 设备的抽象表示,进程则是对处理器、主存和 I/O 设备的抽象表示。相比之下,虚拟化将操作系统从硬件中抽象出来,容器技术将应用从操作系统抽象出来。

一个正在执行的进程,由于虚拟内存技术,就其视角来看,仿佛拥有了整个操作系统的计算资源。但是 Container 的抽象和进程抽象不是在一个层面的。简单说,一台物理设备可以借助于 Hypervisor 技术,运行多个 VM;而个操作系统可以借助 Container 技术,运行多个 Container,Container 里又可以有多个进程。

上面简单的介绍了一些 Docker 技术的背景,言归正传。

为什么选用 Docker 而不是更成熟的 VM

实现 WebIDE 首先解决的就是环境隔离,多个用户之间不会相互干扰。物理机是相互隔离的,但是为每一个用户分配一台真实的物理机,显然是不合现实的。

VM 可以提供和物理机一样的隔离效果,由于 VM 共享硬件,所以更省资源。一个可行的方案是借助 IaaS 平台商提供的 OpenAPI 来操作 VM。这样对物理主机和宿主操作系统的维护工作可以完全委托给 IaaS 平台商。

相比 Docker Container,VM 有一个很大的技术优势是支持休眠。操作系统在系统级实现了休眠,这样用户的工作状态,内存中的数据可以完整的持久化。作为一个常年不关机的开发者,个人觉得这个功能非常实用。可惜 Docker 只提供了睡眠(类似于进程级别的挂起),而做不到休眠。随着CRIU技术的发展,相信 Docker 很快会支持的。

另外 VM 在不同宿主机之间的迁移问题,经过多年社区的积累越来越成熟。如果选择向 IaaS 平台商购买 VM 服务,这部分工作也不用关心。Docker Container 数据的迁移,面临着自制。目前 Docker 官方提供迁移 Container(非 image)的命令,只能迁移文件,无法保留状态(比如外部 mount 的目录)。

考虑到架构的微服务化,如文件服务、Git 服务、Terminal 服务、Runtime 服务。有些服务是单例的,另一些则会随着用户会话状态而动态地创建和销毁。当应用实例很多的时候,虚拟化技术的 Overhead 是需要考虑的因素。为了某个服务而启动整个操作系统有些负担不起。除了过度的内存消耗,启动耗时也存在差异,Container 只是用户空间的一个或者一组进程,所以启动耗时基本是毫秒级别,而 VM 至少是秒级,有的甚至是分钟级(休眠还原的时候)。

做比较的时候总是各有优劣,但最终打动我们的除了 Docker 的轻量,还有其生机勃勃。我们相信备受社区关注的技术,许多顾虑的问题终究会有解决方案的。

基于 Container 的 Web Terminal

一个完整的 IDE 需要具备很多功能,比如文件管理、版本管理、编辑器、编译器、执行环境等等。初次上线的最小功能集合里,我们认为 Web IDE 区别于 Web Editor 的一个功能亮点就是 Web Terminal。

Web Terminal 和 SSH 的工作原理类似,通过架设在 TCP 之上的应用层协议实现对主机的远程控制。相信大多数开发者都有 SSH 的使用经验,理解其工作原理的仅占少数。开始研究之初,我们也和大多数人一样搞不清楚 terminal、tty、pty、shell、bash 之间的区别,所以先来理理概念。

什么是 Terminal?

从用户的角度来看,Terminal 是键盘和显示器的组合,也称为 TTY(电传打字机的缩写)。键盘输入字符,显示器显示字符。从进程的角度来看,终端是字符设备,可以通过 read、write、ioctl 等系统调用来读写和控制该设备。

TTY 早已进入了博物馆,桌面系统上字符界面基本被 GUI 界面替代。取而代之是一个称之为 Terminal Emulator(终端模拟器)的窗口程序,该程序显示的字符界面就是曾经物理显示器里的完整内容。

Terminal 作为真实的物理设备已经不复存在了,但是为了和面向终端的程序(比如 Bash)进行通信,于是就了发明了 pty(Pseudoterminal,伪终端)。pty 是一对 master-slave 设备,master 设备表现得像一个文件,slave 设备表现得像一个终端设备,当 Terminal Emulator 作为一个非面向终端的程序不直接与 pty slave 通讯,而是通过文件读写流与 pty master 通讯,pty master 再将字符输入经过线路规程的转换传送给 slave,slave 进一步传递给 bash。

Bash 是一个命令行的解释器,通常也是进程会话的主进程,其职责是解释执行终端设备(或者伪终端的 slave 设备)传递过来的字符串和控制字符,执行命令。

Web Terminal 的工作原理

理解了上面背景知识之后,再看 SSH 的原理图。

SSH 是一个典型的 server-client 模式架构,用户通过终端将字符流传递给 SSH client。SSH client 和 SSH server 之间通过 TCP/IP 协议进行通讯。远端的 server 创建一对 pty,并且 fork+exec 一个 bash 进程,server 进程通过 pty 对与 bash 进行交互。

仿照 SSH 的工作原理,我们在 HTTP 协议之上设计了 Web Terminal,见下图:

真实实现中,Socket.io 是应用层的通讯协议。Terminal Emulator 是一个纯 JS 的实现,Node.js 后端使用pty.js模块来创建 pty 对。

当解决了 Web Terminal 的整体架构以后,嵌入 Docker Container 已是水到渠成。

僵尸进程问题

我们知道 Docker 由于缺少 init 0 而导致僵尸进程无法回收的问题迄今存在。Terminal 作为控制终端,会在使用过程中执行若干命令,这些命令对应进程如果与其父进程脱离父子关系,那僵尸进程问题就来了。

Docker 官方推荐的一个 Container 只跑一个进程。如果 Container 与进程同生共死,僵尸进程的问题基本不会遇到。但是 Web Terminal 所在 Container 里启动了 bash,而 bash 可以随意执行命令启动进程,僵尸进程问题很难避免。好在社区提供了更好的解决方案:phusion/baseimage。在 Dockerfile 里将的 FROM ubuntu改为FROM phusion/baseimage,再按照文档说明做些调整基本就好了。

Container 作为构建和管理工具

通常,我们都是把 App 部署到 Docker 里去。大致步骤就是编写 Dockerfile,再构建成 image,然后借助 private registry 在分布式的集群中分发。由于开发环境、测试环境和生产环境存在差异,往往构建交付物涉及到大量参数和环境变量的设定,过程非常繁琐,一般都会脚本化。所以 IDE 项目基本都是 Dockerfile 旁边放置了一个 Gemfile 和 Rakefile。通过 Ruby Rake 来驱动整个构建过程。

作为脚本语言与 Shell 相比,Ruby 的好处是:

  • 隔绝了 Darwin,Linux 平台之间某些命令的细微差异;
  • 对于 Shell 擅长的部分,可以通过'`'符号方便的嵌入调用;
  • 具备完备正则等字符串处理功能;
  • 方便调用 Docker api 的;
  • 可以集成 Capistrano 等分布式管理工具;

但 Ruby 不像 Shell 那样信手拈来,需要进行适当的配置,比如,RVM 安装指定版本,修改 gem source 之类的。

从前配置这些基础环境,都是记录成 Markdown 文档,一堆 apt-get、sed 指令。但是引入 Docker 以后,有更好的选择。

我们的方式如下:

编写一个配置构建环境的 Dockerfile,构建成 image。

docker build --rm -t="ide-docker-registry.coding.local/ide-builder:0.0.5" .

push 到 registry 里。

docker push ide-docker-registry.coding.local/ide-builder

在构建服务器创建构建所需的 builder,通过 mount 外部目录的方式,构建环境和外部环境交互文件。

docker run --name coding_ide_builder -d -t -v $CODING_IDE_HOME:/data/coding-ide-home  --net=host --restart=always ide-docker-registry.coding.local/ide-builder

进入构建环境执行命令。

docker exec -i -t coding_ide_builder bash

或者直接构建。

docker exec -i -t coding_ide_builder rake
Container 环境的资源限制问题

资源限制主要针对 CPU、内存、磁盘和网络带宽等共享资源的限制。一方面,我们提倡共享,事实上不是所有的用户都需要长时间的占满所需的资源配额,不需要的时候可以释放出来分享给其他用户,因为共享才会更便宜。另一方面,也需要对可共享资源设定一个最大的限制配额,以防止某些用户过度占用而影响其他用户的使用体验。

CPU 限制

Docker 提供了两个参数来控制 CPU 的分配策略,--cpuset--cpu-shares

--cpuset="0" [...] 将 Container 限定于某几个 CPU 核心上。针对这一特性,我们制定的策略是将重要的 Container 服务分配在独立的核心上,以保证服务的质量。

--cpu-shares可以调节 Container 获得的时间片。我们通过这个配置来调节 Web Terminal 所创建进程对 CPU 的占用率。

内存限制

Web Terminal 里用户的自由度是很大的,对内存限制可以减少恶意破坏。Docker 配置内存限制相对简单。另外,我们禁用了 swap 分区,以减少对磁盘的压力。

磁盘限制

由于用户可以完全自由的访问磁盘,我们最希望 Container 磁盘镜像文件具备 thin provisioning 特性,不需要预分配所有空间也可以限定其大小。

对于 Container 的磁盘限制分为两部分,对最上层可写 layer 的限制和对被 mount 的可写目录的限制。

限制可写 layer

Docker Daemon 提供了四种 storage-driver:aufsdevicemapperbtrfsoverlay。如果 Linux 发行版本支持 aufs,那它就是默认的 storage-driver,反之则是 devicemapper。aufs 最早被 Docker 支持,而且支持共享二级制文件和动态库文件所占用的内存,btrfs 和 overlay 不支持此特性,但是比 aufs 速度更快。devicemapper 的特点是支持 thin provisioning 和 copy on write。

限制 layer 的大小,devicemapper 是目前唯一的选择。启动 devicemapper 后,Docker 会为所有的 Container 创建一个共享存储池,其实质上是一个大文件,另外也会限定每个 Container 的大小。这两个数字的制定需要慎重,因为考虑到数据迁移,修改很不容易。

限制被 mount 的可写目录

Docker run 的时候 mount 进 Container 的可写目录是不受 devicemapper 的限制,所以需要额外处理。WebIDE 场景中 workspace 目录是被多个 Container 实例中共享读写的,作为用户工作目录,需要设定一个最大的空间限制。

谈到 Linux 磁盘空间限制,最先想到 quota,它常用于 ftp 服务中限定用户最大可用空间。但 quota 有一个技术限制,仅仅适用于整个文件系统而无法针对单个目录。所以 quota 方案在共享目录的场景不可行。

Linux 支持将一个磁盘镜像文件 mount 成目录,磁盘镜像文件可以限定大小。当镜像文件撑满的时候,目录就不可写了。这是我们目前找到最靠谱的方案。

限制网络带宽

Docker 没有直接提供限制网络带宽的命令行参数,但借助 Docker 的底层技术 Cgroup 可以实现。创建一个 Network classifier group,对 cgroup 进行带宽限制的设定,将 Container 都指定到该组里去。Traffic Controller(tc) 和 Netfilter(iptables) 都支持针对 cgroup 指定规则。

关于 Dockerize 的程度与思考

基于 Docker 更容易实现架构的微服务化。借助于 Docker 的 link 特性和 Fig 工具,Container 可以像乐高积木一样把所有的组件都组合起来。Nginx、Jetty、MySQL、Redis 等一系列服务都可以封装到独立的 Container 中去。

全面 Dockerize 的最大好处是整个体系都是一致的,所有的组件都是 Container。WebIDE 在架构初期,考虑全面 Dockerize 的方案,比如把 MySQL 分成两个 Container,一个存放安装文件,另一个存放数据文件。应用服务器自不必说也在 Container 里。但是当考虑 Nginx 是否也要放进 Container 里,大家想法有些分歧,Container 的价值在 Nginx 上是否明显值得探讨。也正因为存在不同的声音,我们放弃了全面 Dockerize。个人的觉得已有的经验和脚本不应该放弃,我们应该节省出更多的精力来做更重要和紧迫的事情。

作者简介

杜万Coding.net 全栈工程师,目前负责 Coding WebIDE 项目的架构和研发。从事了近 10 年以 Java 语言为主的软件开发工作,热衷于整合框架和开发工具,关注交互设计,喜欢写技术博客,Linux 拥趸。近期开始学习和关注 Elixir 函数语言。

参考阅读

  1. Hypervisor - Wikipedia
  2. Operating-system-level virtualization - Wikipedia
  3. User space - Wikipedia
  4. Basics – Docker, Containers, Hypervisors, CoreOS
  5. devicemapper - a storage backend based on Device Mapper
  6. Resizing Docker Containers with the Device Mapper plugin
  7. Network classifier cgroup

感谢郭蕾对本文的审校。

给 InfoQ 中文站投稿或者参与内容翻译工作,请邮件至editors@cn.infoq.com。也欢迎大家通过新浪微博(@InfoQ@丁晓昀),微信(微信号:InfoQChina)关注我们,并与我们的编辑和其他读者朋友交流(欢迎加入 InfoQ 读者交流群)。