我开发了一个基于 Beancount 的账本托管服务 HostedBeans,欢迎大家来了解纯文本复式记账并试用我的服务。
标签 #Docker

为什么在 Apple Silicon 上装 Docker 这么难

最近公司的很多同事都换上了搭载 M1 Pro 或 M1 Max 的新款 MacBook Pro,虽然日常使用的软件如 Chrome、Visual Studio Code 和 Slack 都已经适配得很好了,但面对 Docker 却犯了难。

众所周知,Docker 用到了 Linux 的两项特性:namespaces 和 cgroups 来提供隔离与资源限制,因此无论如何在 macOS 上我们都必须通过一个虚拟机来使用 Docker。

在 2021 年 4 月时,Docker for Mac(Docker Desktop)发布了 对 Apple Silicon 的实验性支持,它会使用 QEMU 运行一个 ARM 架构的 Linux 虚拟机,默认运行 ARM 架构的镜像,但也支持运行 x86 的镜像。

QEMU 是一个开源的虚拟机(Virtualizer)和仿真器(Emulator),所谓仿真器是说 QEMU 可以在没有来自硬件或操作系统的虚拟化支持的情况下,去模拟运行一台计算机,包括模拟与宿主机不同的 CPU 架构,例如在 Apple Silicon 上模拟 x86 架构的计算机。而在有硬件虚拟化支持的情况下,QEMU 也可以使用宿主机的 CPU 来直接运行,减少模拟运行的性能开销,例如使用 macOS 提供的 Hypervisor.Framework

Docker for Mac 其实就是分别用到了 QEMU 的这两种能力来在 ARM 虚拟机上运行 x86 镜像,和在 Mac 上运行 ARM 虚拟机。

Docker for Mac 确实很好,除了解决新架构带来的问题之外它还对文件系统和网络进行了映射,容器可以像运行在本机上一样访问文件系统或暴露网络端口到本机,几乎感觉不到虚拟机的存在。但 LeanCloud 加入 TapTap 之后已经不是小公司了,按照 Docker Desktop 在 2021 年 8 月推出的 新版价格方案,我们每个人需要支付至少 $5 每月的订阅费用。倒不是我们不愿意付这个钱,只是我想要找一找开源的方案。

之前在 Intel Mac 上,我们会用 Vagrant 或 minikube 来创建虚拟机,它们底层会使用 VirtualBox 或 HyperKit 来完成实际的虚拟化。但 VirtualBox 和 HyperKit 都没有支持 Apple Silicon 的计划。实际上目前开源的虚拟化方案中只有 QEMU 对 Apple Silicon 有比较好的支持,QEMU 本身只提供命令行的接口,例如 Docker for Mac 调用 QEMU 时的命令行参数是这样:

/Applications/Docker.app/Contents/MacOS/qemu-system-aarch64 -accel hvf \
-cpu host -machine virt,highmem=off -m 2048 -smp 5 \
-kernel /Applications/Docker.app/Contents/Resources/linuxkit/kernel \
-append linuxkit.unified_cgroup_hierarchy=1 page_poison=1 vsyscall=emulate \
panic=1 nospec_store_bypass_disable noibrs noibpb no_stf_barrier mitigations=off \
vpnkit.connect=tcp+bootstrap+client://192.168.65.2:61473/f1c4db329a4a520d73a79eaa1360de7be7d09948a1ac348b04c8e01f6f6eb2c9 \
console=ttyAMA0 -initrd /Applications/Docker.app/Contents/Resources/linuxkit/initrd.img \
-serial pipe:/var/folders/12/_bbrd4692hv8r9bx_ggw5kp80000gn/T/qemu-console1367481183/fifo \
-drive if=none,file=/Users/ziting/Library/Containers/com.docker.docker/Data/vms/0/data/Docker.raw,format=raw,id=hd0 \
-device virtio-blk-pci,drive=hd0,serial=dummyserial -netdev socket,id=net1,fd=3 -device virtio-net-device,netdev=net1,mac=02:50:00:00:00:01 \
-vga none -nographic -monitor none

为了实际使用 QEMU 进行开发,我们需要一个使用上更友好的封装,能够自动配置好 Docker 和 Kubernetes(或者至少方便编写像 Vagrantfile 一样的脚本),提供类似 Docker for Mac 的网络映射和文件映射,于是我找到了 Lima。

Lima 自称是 macOS 上的 Linux 子系统(macOS subsystem for Linux),它使用 QEMU 运行了一个 Linux 虚拟机,其中安装有 rootless 模式的 containerd,还通过 SSH 提供了文件映射和自动的端口转发。

但为什么是 containerd 而不是 Docker 呢?随着容器编排平台 Kubernetes 如日中天,社区希望将运行容器这个关键环节进行标准化,让引入 Docker 之外的其他容器运行时更加容易,于是 推出了 Container Runtime Interface (CRI)。containerd 就是从 Docker 中拆分出的一个 CRI 的实现,相比于 Docker 本体更加精简,现在也交由社区维护。

因此如 Lima 这样新的的开源软件会更偏好选择 containerd 来运行容器,因为组件更加精简会有更好的性能,也不容易受到 Docker 产品层面变化的影响。nerdctl 是与 containerd 配套的命令行客户端(nerdcontainerd 的末尾 4 个字母),用法与 docker 或 docker-compose 相似(但并不完全兼容)。

所谓 rootless 则是指通过替换一些组件,让容器运行时(containerd)和容器都运行在非 root 用户下,每个用户都有自己的 containerd,这样绝大部分操作都不需要切换到 root 来进行,也可以减少安全漏洞的攻击面。

但我们希望能在本地运行完整的 rootful 模式的 dockerd 和 Kubernetes 来尽可能地模拟真实的线上环境,好在 Lima 提供了丰富的 自定义能力,我基于社区中的一些脚本(docker.yamlminikube.yaml)实现了我们的需求,而且这些自定义的逻辑都被以脚本的形式写到了 yaml 描述文件中,只需一条命令就可以创建出相同的虚拟机。

~ ❯ limactl start docker.yaml
? Creating an instance "docker" Proceed with the default configuration
INFO[0005] Attempting to download the image from "https://cloud-images.ubuntu.com/focal/current/focal-server-cloudimg-arm64.img"
INFO[0005] Using cache "/Users/ziting/Library/Caches/lima/download/by-url-sha256/ae20df823d41d1dd300f8866889804ab25fb8689c1a68da6b13dd60a8c5c9e35/data"
INFO[0006] [hostagent] Starting QEMU (hint: to watch the boot progress, see "/Users/ziting/.lima/docker/serial.log")
INFO[0006] SSH Local Port: 55942
INFO[0006] [hostagent] Waiting for the essential requirement 1 of 5: "ssh"
INFO[0039] [hostagent] Waiting for the essential requirement 2 of 5: "user session is ready for ssh"
INFO[0039] [hostagent] Waiting for the essential requirement 3 of 5: "sshfs binary to be installed"
INFO[0048] [hostagent] Waiting for the essential requirement 4 of 5: "/etc/fuse.conf to contain \"user_allow_other\""
INFO[0051] [hostagent] Waiting for the essential requirement 5 of 5: "the guest agent to be running"
INFO[0051] [hostagent] Mounting "/Users/ziting"
INFO[0051] [hostagent] Mounting "/tmp/lima"
INFO[0052] [hostagent] Forwarding "/run/lima-guestagent.sock" (guest) to "/Users/ziting/.lima/docker/ga.sock" (host)
INFO[0092] [hostagent] Waiting for the optional requirement 1 of 1: "user probe 1/1"
INFO[0154] [hostagent] Forwarding TCP from [::]:2376 to 127.0.0.1:2376
INFO[0304] [hostagent] Forwarding TCP from [::]:8443 to 127.0.0.1:8443
INFO[0332] [hostagent] Waiting for the final requirement 1 of 1: "boot scripts must have finished"
INFO[0351] READY. Run `limactl shell docker` to open the shell.
INFO[0351] To run `docker` on the host (assumes docker-cli is installed):
INFO[0351] $ export DOCKER_HOST=tcp://127.0.0.1:2376
INFO[0351] To run `kubectl` on the host (assumes kubernetes-cli is installed):
INFO[0351] $ mkdir -p .kube && limactl cp minikube:.kube/config .kube/config

我还发现了另外一个基于 Lima 的封装 —— Colima,默认提供 rootful 的 dockerd 和 Kubernetes,但 Colima 并没有对外暴露 Lima 强大的自定义能力,因此我们没有使用,但对于没那么多要求的开发者来说,也是一个更易用的选择。

在默认的情况下,Lima 中的 Docker 在 Apple Silicon 上只能运行 ARM 架构的镜像,但就像前面提到的那样,我们可以使用 QEMU 的模拟运行的能力来运行其他架构(如 x86)的容器。qemu-user-static 是一个进程级别的模拟器,可以像一个解释器一样运行其他架构的可执行文件,我们可以利用 Linux 的一项 Binfmt_misc中文版)的特性让 Linux 遇到特定架构的可执行文件时自动调用 qemu-user-static,这种能力同样适用于容器中的可执行文件。

社区中也有 qus 这样的项目,对这些能力进行了封装,只需执行一行 docker run --rm --privileged aptman/qus -s -- -p x86_64 就可以让你的 ARM 虚拟机魔法般地支持运行 x86 的镜像。

/usr/bin/containerd-shim-runc-v2
\_ /qus/bin/qemu-x86_64-static /usr/sbin/nginx -g daemon off;
    \_ /qus/bin/qemu-x86_64-static /usr/sbin/nginx -g daemon off;
    \_ /qus/bin/qemu-x86_64-static /usr/sbin/nginx -g daemon off;
    \_ /qus/bin/qemu-x86_64-static /usr/sbin/nginx -g daemon off;
    \_ /qus/bin/qemu-x86_64-static /usr/sbin/nginx -g daemon off;

使用 qus 运行 x86 镜像的进程树如上,所有进程(包括创建出的子进程)都自动通过 QEMU 模拟运行。

回到题目中的问题,因为 Docker 依赖于 Linux 内核的特性,所以在 Mac 上必须通过虚拟机来运行;Apple Silicon 作为新的架构,虚拟机的选择比较受限,因为有些镜像并不提供 ARM 架构的镜像,所以有时还有模拟运行 x86 镜像的需求;Docker Desktop 作为商业产品,有足够的精力来去解决这些「脏活累活」,但它在这个时间点选择不再允许所有人免费使用;开源社区中新的项目都希望去 Docker 化,用 containerd 取代 dockerd,但这又带来了使用习惯的变化并且可能与线上环境不一致。因为这些原因,目前在 Apple Silicon 上安装 Docker 还是需要花一些时间去了解背景知识的,但好在依然有这些优秀的开源项目可供选择。

虽然 云引擎 也是基于 Docker 等容器技术构建的,但云引擎力图为用户提供开箱即用的使用体验而不必自己配置容器环境、编写构建脚本、收集日志和统计数据。如果想得到容器化带来的平滑部署、快速回滚、自动扩容等好处但又不想花时间配置,不如来试试云引擎。

其他参考资料:

2016 年度小结(技术方面)

今年年初我花了三个月的业余时间用 Laravel 开发了一个项目,在此之前,除了去年换工作准备面试时,我并没有正经地用过什么 PHP 框架。在我看来,Laravel 其实并没有太多的独创性,而只是把其他社区中那些被实践证明非常有价值的东西带到了 PHP 社区,例如自动测试、包管理、依赖注入等等。

回到 PHP 本身,PHP7 无论在语言特性还是性能上都无可挑剔,我觉得限制它的使用的更多的是在异步模型上 —— 或者说它缺少一个好的异步模型。也许有人觉得 Node.js 的事件队列十分难以捉摸,但由于 Node.js 是我在生产环境使用得最多的语言,我十分熟悉 Node.js 的异步模型,对其他「普通」的语言的异步模型却比较难以接受。今年我在 Openresty 中写了一些 Lua 代码,对 Lua 的协程也有了一些简单的了解,同时我也了解到在 Python 中被使用最多的异步模型同样也是基于协程。

协程最大的好处是不会对代码有侵入性 —— 你写代码的时候依然是照常地写同步代码,只需在执行的时候引入协程(对于 Web 服务就是在每收到一个请求的时候启动一个协程)就可以享受轻量级进程的好处,用更低的 CPU 和内存开销来支持更高的并发。相比之下 Node.js 的异步代码则是显式的,需要时刻考虑异步的问题,也很容易出现疏忽和错误。而且异步的函数是有传染性的,如果你调用了一个异步函数,那么后面的代码都要用异步的方式写,虽然我们有 Promise 等异步抽象,但这其实就是侵入性的体现 —— 你必须选择一个异步抽象,这个异步抽象会混入你所有的代码。

那是不是说 Python 的协程就比 Node.js 的事件队列要好呢?其实我觉得大部分的情况下是这样的,但从理论上来说,Node.js 还是会有更好的性能,因为协程虽然比线程要轻量级,但依然是一种封装 —— 需要在协程之间调度,需要保存和恢复执行上下文,而 Node.js 的异步是没有任何的封装的 —— Node.js 里你没有办法去管理异步任务(domain 模块也早就被 deprecated 了),因为异步任务并不是运行在像协程的这样一个容器里的,因此省掉这一层抽象会带来更好的性能。

Laravel 是一个一站式的开发框架,我之前并没有用过这类的框架,在 Node.js 中也不是很流行这样的框架,好处自然就是组件之间有着很好的兼容性和一致的设计,质量也有保证;劣势则是选择的余地比较少(实际上服务容器的概念就是为了弥补一站式框架不便于更换组件的问题),因为这些组件是框架整体的一部分,所以对一些比较小众的需求缺乏支持,这些组件也较少考虑脱离 Laravel 独立地使用。


今年我还花了很多时间去了解和使用 InfluxDB 这样的时序数据库,在 Web 后端的开发过程中,有很多日志、监控、统计类的数据都是时序数据 —— 这些数据量会很大、每一条都和一个时间关联、查询时通常也按照时间范围进行查询、查询时我们通常会将一段时间的数据进行分组和聚合。InfluxDB 为了支持基于 Tag 的筛选和分组,采用了按列存储的方式 —— 每个 Tag 的值的组合都被称作一个序列,被独立地存储以备检索。

因为这些数据都和时间关联,同时数据库也提供了比较好的对分组和聚合的支持,所以可以很轻松地使用 Chronograf 或 Grafana 之类的可视化工具画出图表,对这些指标进行监控。实际上去年我做了一个和时序数据库非常相关的项目,即 leanengine-sniper,今年我又花了一些时间把这个算是自部署的系统包装成了一个云服务(LeanEngine APM),实际上这就是一个针对特定场景的、简易的时序数据库和基于时序数据的可视化工具。


做一个象棋 AI 是我一直以来的一个想法,于是今年接着 AlphaGo 的热度,我用 TypeScript、React 和 Web Worker 等技术在浏览器中实现了一个非常弱的国际象棋 AI —— Wizard Chess。其实只能说是把关键的组件都实现了出来,但实际上 AI 走的每一步棋都很弱。显然这是一个对性能非常敏感的项目,根据我去年的经验要尽可能避免对数据的修改,但在我调研了 Immutable.js 之后我并没有选用它,因为我觉得可能我不需要它提供的那么复杂的数据结构,而是自己在编码时注意不要修改参数、函数总是返回新的对象即可。

在 Wizard Chess 中我也试用了 TypeScript,它给 JavaScript 实现了编译期的类型检查,这会非常有助于在编译期发现和类型有关的错误,但并不能做到真正的静态类型语言的那种程度,尤其 JavaScript 中存在大量取值为 null 或 undefined 的情况,同时需要为所使用的库找到定义文件也是一大痛点。

到目前大家已经普遍认为 TypeScript 比 CoffeeScript 有着更好的前景,CoffeeScript 这个项目也显得有些疏于维护了,这令我非常痛心。虽然 ES2015 已经补齐了 JavaScript 语言本身的一些短板,但我觉得 CoffeeScript 还是有它独特的价值的,比如用缩进区分代码块、更少的括号,以及通过问号进行空值判断。


为项目撰写 HTTP API 文档一直是一个很纠结的事情,为了他人阅读和理解容易,应该详细地列出所有参数的细节,但这样又会导致文档有大量重复的内容,维护将会十分困难。用来生成 HTTP API 文档的方案有很多,我最后选择了 RAML —— 这是一个基于 YAML 的用来定义 HTTP API 的语言,它提供了非常多的特性(type、resourceType、trait)来对定义进行复用,你可以根据你的代码的架构去组织这些定义(例如如果几个接口都挂载了同样的中间件,便可以使用一个 trait 来定义这个中间件的行为),最后通过一个编译环节生成可以阅读的 API 文档。

后来我还了解了一下 GraphQL,并实现了一个 在 LeanCloud 上使用 GraphQL 的 Demo,GraphQL 解决了 RESTful API 缺乏范式、类型不够严格、对关系数据支持弱、难以发现等问题。但目前对 GraphQL 的应用仍非常有限,我想大概是为了支持 GraphQL 需要服务器端进行很大的改动,同时引入这样一层复杂的抽象,也会带来很大的性能开销,可能 GraphQL 更适合的场景会是 BaaS 和开放 API 吧。


如果说我在番茄土豆学到的是如何开新坑、如何以开放的心态去接受新的技术的话,在 LeanCloud 这一年多则是去长时间地维护一个复杂的系统,在保证兼容性和可用性的前提下,渐进式地对系统进行改进,引入新的技术。之前在番茄土豆的时候,也是因为自己的水平提高得比较快,经常想要进行一次彻底的重构 —— 其实应该称之为重写才对。但往往没有好的结果,因为这种大的重写会花费很多的时间,会导致新版本和原有代码差别越来越大,甚至更换了新的语言或数据库,很难保证和原有代码有着一样的行为。同时因为数据操作的不兼容,在上线时也必须一下子全量上线,结果就是导致最后的上线时间一拖再拖。

今年上半年云引擎也有过一次很大的 改版,允许用户对实例进行更多的管理以及大量的内部重构,其中我得到的经验就是要进行渐进式的重构 —— 将大的修改划分为若干个小任务,逐步地将这些小修改上线进行测试,而不是一次性上线一个大的修改。让新旧代码混跑一段时间,保证新旧代码对数据的操作是互相兼容的,虽然在开发上需要实现很多过渡代码,在过渡完成后还需要清理这部分代码,但这种出现问题可以随时回滚的能力会让你对上线新的修改非常有信心,反而能够加速整体的重构进程。

今年在 LeanCloud 我也开了几个新坑,的确新的项目在发布或上线之前的进化速度是非常快的,一旦有好的想法就可以立刻实施而不必顾及兼容问题,甚至也可以跳过很多的测试和 Code Review。有句话是说「不要过早的进行优化」,但我有些怀疑这个观点,在正式发布或上线之前很可能是最好的优化时机,至少要考虑到后续的优化并预留出修改的空间。一旦项目上线,那么每个修改都要有充分的理由、都要去顾及兼容性并进行全面的测试,这时再进行优化会是非常低效的,我也的确遇到一些项目是因为最开始的设计失误导致后期几乎没办法去优化。


随着 Docker 生态的发展,「微服务」是个比较火热的话题,但大都还是一些方法论,没看到太多具体的实践经验。目前我对微服务的理解主要是两方面,一是对项目进行拆分,减少单个开发者需要接触的代码量;另一方面是对服务进行隔离,缩小故障的影响范围,更好地进行水平拓展。今年我也基于这两个出发点进行了一些实践 —— 项目的不同服务使用同一个代码库,互相共享很大一部分代码,包括自动测试也是在一起运行的。但每个服务有着不同的入口点,会被单独地部署和运行。

大概这是一种不够彻底的微服务吧,我知道当然可以将共同的代码发布为单独的包,来实现更加彻底的拆分。但把一个组件独立为一个包其实是一个很严肃的事情,可能需要它有单独的仓库、文档、版本号,当 API 发生变化时还要考虑兼容。对于一个还没有那么复杂的项目来说,这个开销可能会很大程度上影响迭代速度,所以我还是选择使用同一代码库,这样进行修改时会更加灵活,通过自动测试来保证修改不会引入问题。

如果已有服务化的基础设施,这种服务的拆分其实还是很容易的,但如果从头搭建一套微服务的基础设施则还是需要一些工作的。例如为了管理不同服务的配置,标准化部署过程,你需要一个 CI;为了提供不同服务所需要的环境、在运行时进行隔离,你需要有一个容器引擎;为了管理和调度容器、充分利用资源,你还需要一个集群管理器;为了能够平滑地进行部署,你还需要服务发现和负载均衡;为了收集和检索日志,你还需要一套日志收集和分析器;更别提还有的统计、监控和报警需求了。对于这些基础设施我也有自己的一些实践和看法,我还是比较期待新的一年里能在业余时间按照我自己的选择去搭建一个这样的微服务平台,大概也算是给 RootPanel 划上一个句号。


很时候服务不可用都是因为数据库的问题导致的,不同于无状态的负载均衡或应用容器,数据库存储了所有的状态,这意味着你不能简单地重新创建一个数据库实例,而必须要顾及到其中的数据。为了保证数据库的可用性,最简单的办法就是运行多个数据库实例,互相之间同步数据,在故障时切换到另外一个实例。但这样又会引入新的问题,如果发生网络分区怎么办?于是我开始深入地了解 CAP 提出的分布式系统的限制,了解 Paxos 这样的算法如何在分布式的系统中达成共识,也了解了各种数据库提供了怎样的分布式能力和怎样的高可用解决方案,这样在以后为项目选择数据库的时候应该会更加有针对性。

在这个过程中我也读到了「SRE: Google 运维解密」新出版的中文版,书中介绍了作为世界上最大的互联网公司,Google 是如何在规模迅速增长的情况下继续保证服务的可用性的。书中介绍了很多原则和方法,读完这本书让我觉得热血沸腾,相比于写代码实现确定的需求,也许去应对未知的故障会更有趣?但在此之前可能还有很多知识需要学习,首先在一番纠结后,选择了 Ansible 作为配置管理工具,开始尝试将我的服务器上的所有服务都通过定义文件进行描述,这项工作持续了将近三个月的时间,到现在还未全部完成。用一组定义文件去代替对服务器的直接操作,这样的好处是非常明显的 —— 这些文件可以进入版本控制让所有修改有据可查,可以随时应用在新的服务器上,也可以随时在已有的服务器上进行验证和重放,通过 Ansible 这类工具所提供的特性,也可以对这些配置进行高层次的抽象,来管理更复杂的配置和大量的服务器。

我们不光要使用不可变的数据结构来控制可变状态,像服务器这样的基础设施也可以让它们变成「不可变」的。其实 Docker 的容器就是一个很好的例子,所有的容器都是从 Dockerfile 生成出来的,当你需要修改容器中的运行环境的时候,你不是直接在容器内进行修改,而是去修改 Dockerfile —— 因为它是容器的模板。我们也可以总是通过定义文件来描述基础设施,每次修改后都重新验证服务器与定义文件中的描述一致,这样我们便不必关心服务器上的状态了。也不会出现服务器多人维护,配置混乱难以迁移的情况了,它永远看上去和新的一样。


此外今年我还公开或半公开地做了五次技术分享,准备每个分享都花了我起码半个月的时间,其中的四次已经被我整理成了文字版本:

Docker 与容器化技术实践

这篇文章由我 7 月末在 Connext 2016 进行的一次技术分享整理而来。

RP 主机

我在高一的时候开始尝试搭建自己的网站,当时市面上的「虚拟主机」基本上只提供 PHP 环境,限制也比较多。于是我在 Linode 以每月 20 美元的价格买了一台 Linux VPS 用来搭建网站,但当时我的零花钱无法负担这个开销,于是尝试性地公开出售服务器资源,为此我编写了一套叫 RootPanel 的虚拟主机管理系统。

和其他虚拟主机不同的是,RP 主机的用户可以有非常大的权限 —— 可以登录 SSH, 运行 Node.js、Python 之类的程序;而 RootPanel 则通过 Web 的界面允许用户使用 MySQL、MongoDB 数据库,并且通过 Nginx 共享 80 端口,RootPanel 会检查用户的请求是否符合权限要求,然后去与 Nginx 这些系统服务交互。

当时 Docker 还没有出现,我用了一些比较「传统」的方式来隔离 RP 主机上用户的权限:

  • 文件系统:Unix users(文件权限)、quota-tools(磁盘空间)
  • CPU 和内存:自行编写脚本来调整进程优先级(CPU 超限时)和杀进程(内存超限时)
  • 进程和网络:因为 Unix 本身的权限,无权向其他进程发送信号

在运行 Web 服务时,后端程序(例如 PHP-FPM、Python 的 uwsgi、Node.js 应用进程)本身由用户运行,以 Unix Socket 的方式提供服务(Unix Socket 会遵守 Unix 的文件权限机制),然后可以在 RootPanel 的 Web 界面上配置从域名到 Unix Socket 的映射(RootPanel 会检查你配置的 Unix Socket 是否在你的 home 目录中等),由 Nginx 完成反向代理,实现共享 80 端口。

Redis 和 Memache 这种轻量级数据库也是由用户自行运行的,通过 Unix Socket 提供服务来避免被其他用户访问到。出于性能考虑,所有用户会共同使用同一个 MySQL 和 MongoDB,用户可以在 RootPanel 的 Web 上创建和管理数据库,RootPanel 会为每个用户分配一个用户名和密码,使用这些数据库本身的用户机制进行权限控制。

当然 RP 主机现在已经被关掉了,详见 RP 主机和 GreenShadow 关闭计划

「隔离」和「资源控制」

到后来 2014 年初的时候我发现了 Docker, 它是一个基于 Linux 的轻量级虚拟化技术,可以以非常低的成本来创建与主机隔离的、可以独立进行资源控制的「容器」。

在前面 RP 主机的例子中,我们虽然一定程度地解决了这两个问题,但并不完美。隔离方面 RP 主机只做到了权限的隔离,但用户依然可以看到其他用户和它们的进程、网络链接;资源控制方面,CPU 和内存都依赖于脚本进行控制,控制的粒度和准确性显然不如利用内核本身的特性。

Docker 使用 Linux 2.6 提供的 namespaces 特性来隔离容器之间的文件系统(mount namespace)、主机名(UTS namespace)、进程(PID & IPC namespace)、网络(network namespace)、用户(user namespace)。使容器中的进程只能看到与自己有关的系统资源,完全感觉不到主机上其他的容器的存在。

Docker 还使用了 Linux 2.6 提供的 cgroups 特性来统计和限制容器的系统资源,包括 CPU(cpuset & cpu & cpuacct cgroup)、内存(memory cgroup)、磁盘 IO(blkio cgroup)等。在资源控制方面,因为是由内核执行的,因此可以进行非常细粒度的控制,例如在 CPU 上,既可以为容器设置权重,也可以直接设置最大使用率。

联合文件系统

在解决了隔离和资源控制之后,我们可以允许容器自由地修改容器内的文件系统,每个容器可以使用不同的发行版、运行不同版本的系统服务。但为了允许容器去自定义它们的文件系统,我们必须要为每个容器挂载一个单独的根目录,这样将会占用大量的磁盘空间。

为了解决这个问题,Docker 基于「联合文件系统(AUFS、OverlayFS)」实现了一个「镜像」的功能。联合文件系统是一种可以将不同的目录,以分层「叠加」的方式挂载为一个文件系统的文件系统。Docker 会将不同容器间共同的部分作为一个共用的只读层(例如发行版就是一个层),然后为每个容器再叠加一个可写的层,容器对文件系统的修改会写入到这个可写的层,而不是共享的层,在容器运行结束后,这个可写的层也可以固化为一个只读的层,被其他容器复用(这就是 docker build 的过程)。

将应用封装为镜像

Docker 的容器实际上都是从 Docker 镜像创建出来的,可以说镜像是容器的模板,这个概念类似于进程是由可执行文件创建出来的,但镜像不仅仅包含可执行文件,而是包含了一个程序允许所需要的所有环境。

我们可以通过 Dockerfile 来创建镜像,Dockerfile 中包含了若干指令,这些指令会在容器中被执行,而这些指令对文件系统的修改,会作为构建出的镜像中的一个「层」。

Docker 镜像让应用的「交付」变得简单了,在理想的情况下,Dockerfile 中包含了构建应用所需要的运行环境的指令,而镜像则是一次构建的结果,Docker 为镜像提供了二进制级别的兼容性,镜像可以被传输到其他 Linux 主机上直接运行,交付一个应用就像发送一个可执行文件那么简单。基于我们前面提到的联合文件系统,Docker 镜像在传输时只会传输新的层,如果不同的镜像基于同一个基础的镜像(层)来构建,那么并不会产生额外的传输和存储开销。

无状态的容器

在一个服务器端系统中,包含了大量业务逻辑、需要频繁修改的「应用进程」是最不稳定的部分,它可能会出错、会崩溃重启、会占用大量的计算资源,因此我们必须要能够快速地对包含应用的容器进行调整。为了做到这一点,一个得到了广泛认同的实践就是将应用实现为「无状态」的,即不在内存中持久性地保存数据,而是将这些状态存储到专门的数据库(Redis 等)中,这些数据库会有自己的分布式解决方案,而不必我们操心。

这样我们便可以随时停止和启动一个应用容器,而不必担心数据丢失或状态不同步,同时无状态的应用对容器数量也毫不关心,我们可以根据业务的负载情况随时调整容器数量进而增加业务的负载能力,应用也不关心这些进程运行在哪台服务器上(Docker 的镜像为容器提供了一致的运行环境),只要前端的负载均衡(Nginx)可以发现它即可,因此我们还需要一种「服务发现」的机制让负载均衡服务能够感知到新容器的加入和已有容器的退出。

分布式的容器调度

在一个公有云的场景中,我们往往需要管理运行在几十台服务器上的几千个容器,物理设备总是可能出现故障的,随着集群规模的增长,出现故障的频率将会越来越高,我们必须能够自动地发现和恢复这些故障,我们将这种程序称为「集群管理器」,它需要关注的问题包括:

  • 容器崩溃:应用进程因错误或内存超限退出,可以简单通过设置 Docker 的重启策略来解决。
  • 容器僵死:负载均衡器应该能够将应用容器无响应的情况通知给集群管理器来重启容器。
  • 服务器崩溃或失联:集群管理需要将崩溃的服务器上的容器移动到其他的服务器并从负载均衡中移除。

在一些计划中的维护任务也需要保证服务不中断:

  • 部署新版本:应逐个启动新版本的容器并加入负载均衡,确认新容器工作正常后再将旧容器从负载均衡中移除并停止。
  • 调度到资源充足的服务器:集群管理器应该能够感知到各个服务器的负载情况,将负载较高的服务器的容器移动到负载较低的服务器。

集群管理器需要决定将容器部署到哪台服务器,需要考虑的因素包括:

  • 服务器实际负载。
  • 有时容器会声明自己需要多少资源,虽然实际并没有占用这么多,但一段时间之后可能会有变化。
  • 将容器分散到不同的服务器以应对单个服务器失效。

集群管理器还应该能够应对自身或所依赖的服务失效的情况,通过反复地重试保证实际运行的容器与计划中的一致。

在集群管理器方面社区已经有了很多成熟的解决方案,例如 Docker Swarm、Kubernetes、Marathon,作为私有云来说都基本够用,但在公有云的场景下经常还是需要自己开发一部分功能来和现有系统(例如计费)整合的。

小结

因为随着需要处理的数据量越来越大,我们必须将系统设计成分布式的,这需要我们将计算资源进行一个抽象,不去考虑有关运行环境的细节问题,所以虚拟化是一个大的趋势。基于 Docker 的容器是虚拟化解决方案中的一种,也是目前受到了非常多关注的一种,这大概是因为内核级别的虚拟化有着非常好的性能,同时 Docker 作为一个开源的产品也有着非常活跃的社区。我今天主要介绍的是我在部署环节对 Docker 的实践,但 Docker 的应用并不止于此,在开发和测试环节同样有 Docker 的身影。

参考链接

1

精子生于 1995 年,英文 ID jysperm.

订阅推送

通过 Telegram Channel 订阅我的博客日志、产品和项目的动态:

王子亭的博客 @ Telegram


通过邮件订阅订阅我的博客日志、产品和项目的动态(历史邮件):

该博客使用基于  Hexo  的  simpleblock  主题。博客内容使用  CC BY-NC-ND  授权发布。最后生成于 2023-12-20.