在 MacOS 上运行 Docker 太慢!
你是否也觉得,MacOS 中的 Docker 非常慢?本文作者想出了解决办法,不妨来试试看。
原文链接:https://www.paolomainardi.com/posts/docker-performance-macos/
声明:本文为 CSDN 翻译,未经允许,禁止转载。
在撰写本文之际,为了获得良好的性能和开发者体验,我的选择包括:
利用 VirtioFS 在 Docker 桌面版、Rancher 桌面版、Colima 之间共享文件系统,但这种方法仍然存在一些问题。
使用命名卷,如果你使用 VSCode,可以通过 DevContainers 等获得良好的开发者体验。
PHP 项目可以使用 DDEV + Mutagen。
如果你是 VI/Emacs 用户,只需要在容器中使用编辑器和工具即可。
如何在 MacOS 上运行 Docker?
在 macOS 和 Windows 上,运行 Docker 引擎需要 Linux 内核。这一点 Windows 和 macOS 并没有任何特别之处,之所以我们看不到 Linux 内核,是因为有人帮你做了这一切。相反,在任何操作系统上,Docker CLI 和 docker-compose 都是原生的二进制可执行文件。
关于微软,有两件事值得一提。第一,Windows 支持以原生的方式通过 Docker 在 Windows 上运行容器。这要归功于 2016 年微软和 Docker 合作,共同努力创建在 Windows 上实现 Docker 规范的容器引擎。
第二,微软曾尝试以原生的方式支持 Linux 进程,具体方式是通过实时转换系统调用,在 Windows 内核(WSL1)上直接运行 Linux 进程,而不需要对程序进行任何修改。
我们回到在 macOS 上运行 Docker 的话题。
Docker for Mac 是 Docker 公司的官方产品,专为在 macOS 上无缝运行 Docker 容器而设计,甚至支持 Kubernetes。下面是 Docker for Mac 的一些特性:
Docker for Mac 在 LinuxKit VM 中运行,最近换成了 Virtualization Framework。
文件系统共享是利用一种名叫 OSXFS 的专有技术实现的。但如果需要访问大量文件,这个系统就太慢了(对,我说的就是 Node 和 PHP),因此我更看好一个新的方案:VirtioFS。
网络基于 VPNKit。
可以运行 Kubernetes。
这是一款闭源产品,根据公司的规模可能还需要付费(“拥有超过 250 名员工或年收入超过 1000 万美元的公司”)。
在我看来,Docker for Mac 有两个有效的开源替代方案,二者都使用了 Lima,它们也有文件系统的常见问题,而且刚开始使用 VirtioFS:
Rancher 桌面版
Colima
所以,总结一下:
Docker 容器仍然是 Linux 进程,需要虚拟机才能在其他操作系统上运行。
Docker-cli 和 docker-compose 是原生二进制文件。
由于 Docker 在虚拟机中运行,因此需要通过其他渠道来与虚拟机共享主机文件系统,我们可以在 OSXFS(专有且已弃用)、gRCP FUSE 或 VirtioFS 之间进行选择。这些技术都有一定的问题,我个人最看好 VirtioFS。
如果想在 Mac 上运行 Docker 容器,你可以以在 Docker for Mac(闭源)、Rancher 桌面版(开源软件) 和 Colima(开源软件)之间进行选择。
什么是 Docker 的”卷“?
下面,我们来讨论另一个容易引起混淆的话题:Docker 的卷。
Docker 容器不能保存状态,这意味着只有当容器存在,容器内的文件才能存在,让我们看一个例子:
➜ ~ # Run a container and keep it running for 300 seconds.
➜ ~ docker run -d --name ephemeral busybox sh -c "sleep 300"
d0b02322a9eef184ab00d6eee34cbd22466e7f7c1de209390eaaacaa32a48537
➜ ~ # Inspect a container to find the host PID.
➜ ~ docker inspect --format '{{ .State.Pid }}' d0b02322a9eef184ab00d6eee3
4cbd22466e7f7c1de209390eaaacaa32a48537
515584
➜ ~ # Find the host path of the container filesystem (in this case is a btrfs volume)
➜ ~ sudo cat /proc/515584/mountinfo | grep subvolumes
906 611 0:23 /@/var/lib/docker/btrfs/subvolumes/b237a173b0ba81eb0a60d35b59b0cc5ed[truncated]
➜ ~ # Read the container filesystem from the host.
➜ ~ sudo ls -ltr /var/lib/docker/btrfs/subvolumes/b237a173b0ba81eb0a60d35
b59b0cc5ed7247423bed4b4dcbb5af57a1c3318eb
total 4
lrwxrwxrwx 1 root root 3 Nov 17 21:00 lib64 -> lib
drwxr-xr-x 1 root root 270 Nov 17 21:00 lib
drwxr-xr-x 1 root root 4726 Nov 17 21:00 bin
drwxrwxrwt 1 root root 0 Dec 5 22:00 tmp
drwx------ 1 root root 0 Dec 5 22:00 root
drwxr-xr-x 1 root root 16 Dec 5 22:00 var
drwxr-xr-x 1 root root 8 Dec 5 22:00 usr
drwxr-xr-x 1 nobody nobody 0 Dec 5 22:00 home
drwxr-xr-x 1 root root 0 Dec 10 18:37 proc
drwxr-xr-x 1 root root 0 Dec 10 18:37 sys
drwxr-xr-x 1 root root 148 Dec 10 18:37 etc
drwxr-xr-x 1 root root 26 Dec 10 18:37 dev
➜ ~ # Now write something from the container and see if it's reflected on the host filesystem.
➜ ~ docker ps -l
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
d0b02322a9ee busybox "sh -c 'sleep 300'" 4 minutes ago Up 4 minutes ephemeral
➜ ~ docker exec -it d0b02322a9ee touch hello-from-container
➜ ~ sudo ls -ltr /var/lib/docker/btrfs/subvolumes/b237a173b0ba81eb0a60d35b59b0cc5ed7247
423bed4b4dcbb5af57a1c3318eb | grep hello
-rw-r--r-- 1 root root 0 Dec 10 18:42 hello-from-container
➜ ~ # Now write something from the host to see reflected on the container.
➜ ~ sudo touch /var/lib/docker/btrfs/subvolumes/b237a173b0ba81eb0a60d35b59b0cc5ed72474
23bed4b4dcbb5af57a1c3318eb/hello-from-the-host
➜ ~ docker exec -it d0b02322a9ee sh -c "ls | grep hello-from-the-host"
hello-from-the-host
➜ ~ # Stop the container.
➜ ~ docker stop d0b02322a9ee
➜ ~ sudo ls -ltr /var/lib/docker/btrfs/subvolumes/b237a173b0ba81e
b0a60d35b59b0cc5ed7247423bed4b4dcbb5af57a1c3318eb
total 4
lrwxrwxrwx 1 root root 3 Nov 17 21:00 lib64 -> lib
drwxr-xr-x 1 root root 270 Nov 17 21:00 lib
drwxr-xr-x 1 root root 4726 Nov 17 21:00 bin
drwxrwxrwt 1 root root 0 Dec 5 22:00 tmp
drwx------ 1 root root 0 Dec 5 22:00 root
drwxr-xr-x 1 root root 16 Dec 5 22:00 var
drwxr-xr-x 1 root root 8 Dec 5 22:00 usr
drwxr-xr-x 1 nobody nobody 0 Dec 5 22:00 home
drwxr-xr-x 1 root root 0 Dec 10 18:44 sys
drwxr-xr-x 1 root root 0 Dec 10 18:44 proc
drwxr-xr-x 1 root root 148 Dec 10 18:44 etc
drwxr-xr-x 1 root root 26 Dec 10 18:44 dev
-rw-r--r-- 1 root root 0 Dec 10 18:45 hello-from-the-host
➜ ~ # Filesystem still exists until it is just stopped, but now let's remove it.
➜ ~ docker rm ephemeral
ephemeral
➜ ~ sudo ls -ltr /var/lib/docker/btrfs/subvolumes/b237a173b0ba81eb0a
60d35b59b0cc5ed7247423bed4b4dcbb5af57a1c3318eb
ls: cannot access '/var/lib/docker/btrfs/subvolumes/b237a173b0ba81eb0
a60d35b59b0cc5ed7247423bed4b4dcbb5af57a1c3318eb': No such file or directory
以上信息说明:
1. 容器在运行和停止状态下,宿主会为其分配一个文件系统。
2. 删除容器,关联的文件系统也会被删除。
现在我们对 Docker 管理文件系统的方式有了清晰了解,很容易看出如果没有合适的数据持久层,使用容器就会有非常大的风险,这就是我们使用卷的原因。
绑定挂载
正如Docker文档的记载:“Docker 从早期就开始支持绑定挂载。与卷相比,绑定挂载的功能有限。我们可以通过绑定挂载,将宿主上的文件或目录挂载到容器。绑定挂需要指定宿主上的绝对路径。相比之下,使用卷会在宿主的 Docker 存储目录中创建一个新目录,并由 Docker 管理目录。”
如果想在容器内绑定挂载主机的目录,只需运行以下命令:
docker run -v <host-path>:<container-path> <container>
下面,我们来看看绑定挂载实际的运行情况:
相信你已经看到问题了:绑定挂载允许在容器内挂载宿主的文件系统,你可以想象在用户和容器之间有一个虚拟机,这会让一切都变得更复杂。
绑定挂载的工作方式大致如下:
当你从 Mac 挂载某个路径时,其实是在要求 Linux 虚拟机挂载 Mac 上的网络共享文件系统的路径。
通常,你不需要手动设置这个繁琐的配置,工具可以帮你自动完成。
你可以按照如下方式查看挂载到 Docker 桌面版上的文件或目录:
Preferences -> Resources -> File sharing
如上图所示,我们将本地路径 /Users 等挂载到了 Linux 的虚拟机上,因此,我们也可以像本地目录一样挂载 Mac 目录:
➜ ~ docker run --rm -it -v /Users/paolomainardi:$(pwd) alpine ash
2/ # ls -ltr
3total 60
4drwxr-xr-x 12 root root 4096 May 23 2022 var
5drwxr-xr-x 7 root root 4096 May 23 2022 usr
6drwxrwxrwt 2 root root 4096 May 23 2022 tmp
7drwxr-xr-x 2 root root 4096 May 23 2022 srv
8drwxr-xr-x 2 root root 4096 May 23 2022 run
9drwxr-xr-x 2 root root 4096 May 23 2022 opt
10drwxr-xr-x 2 root root 4096 May 23 2022 mnt
11drwxr-xr-x 5 root root 4096 May 23 2022 media
12drwxr-xr-x 7 root root 4096 May 23 2022 lib
13drwxr-xr-x 2 root root 4096 May 23 2022 home
14drwxr-xr-x 2 root root 4096 May 23 2022 sbin
15drwxr-xr-x 2 root root 4096 May 23 2022 bin
16dr-xr-xr-x 13 root root 0 Dec 11 14:33 sys
17dr-xr-xr-x 260 root root 0 Dec 11 14:33 proc
18drwxr-xr-x 1 root root 4096 Dec 11 14:33 etc
19drwxr-xr-x 5 root root 360 Dec 11 14:33 dev
20drwxr-xr-x 3 root root 4096 Dec 11 14:33 Users
21drwx------ 1 root root 4096 Dec 11 14:33 root
22/ # ls -ltr /Users/paolomainardi/
23total 4
24drwxr-xr-x 4 root root 128 Dec 18 2021 Public
25drwx------ 4 root root 128 Dec 18 2021 Music
26drwx------ 5 root root 160 Dec 18 2021 Pictures
27drwx------ 4 root root 128 Dec 18 2021 Movies
28drwxr-xr-x 4 root root 128 Dec 18 2021 bin
29drwxr-xr-x 4 root root 128 Dec 31 2021 go
30-rw-r--r-- 1 root root 98 Apr 1 2022 README.md
31drwxr-xr-x 5 root root 160 Apr 3 2022 webapps
32drwx------ 6 root root 192 Nov 12 16:50 Applications
33drwxr-xr-x 3 root root 96 Nov 28 21:23 Sites
34drwxr-xr-x 14 root root 448 Nov 28 22:22 temp
35drwx------ 105 root root
卷
正如 Docker 文档记载:
卷是持久化 Docker 容器生成和使用的数据的首选机制。虽然绑定挂载主要依赖宿主的目录结构和操作系统,但卷完全由 Docker 管理。与绑定挂载相比,卷有几个优点。
卷的几个优点:
卷比绑定挂载更容易备份或迁移。
你可以使用 Docker CLI 命令或 Docker API 管理卷。
卷可用于 Linux 和 Windows 容器。
卷可以更安全地在多个容器之间共享。
卷驱动程序允许你将卷存储在远程主机或云提供商上,以加密卷的内容或添加其他功能。
新卷的内容可以由容器预先填充。
Docker 桌面版上的卷比 Mac 和 Windows 主机上的绑定挂载具有更高的性能。
如上所述,卷相对于绑定挂的优势是显而易见,尤其是最后一点,但其最大的缺点是开发者体验。卷的设计初衷本来就不是满足这个需求,因此使用它们进行编程的难度比较大。
下面,我们来演示一下如何使用卷,我将依次展示:
1.在 localhost 上启动并运行一个用 node 实现的 API;
2.在宿主上完成整个开发过程;
3.创建一个 Docker 卷;
4.使用 Docker 卷创建容器;
5.在具有卷的容器内实施开发工作流程。
你可能已经注意到了,卷最大的缺点是容器外所做的任何更改都必须使用 docker cp 复制到容器内。
虽然在容器中安装 VI 可以缓解这个问题,但是这样就无法访问我自己的配置文件(以点开头的文件),也没办法使用我喜欢的 VSCode。
卷与绑定挂载
通过上述讨论,我们可以得出两个结论:
1.绑定挂载是在容器内移动文件时最自然的方式,但当 Docker 引擎在虚拟机中运行时,它们会遇到文件系统共享引发的严重的性能问题。
2.卷的性能远超绑定挂载,但开发的难度较大(例如本地编辑器无法看到容器外的文件)。
比较基准
为了演示卷与绑定挂载对性能的影响有多大,我创建了一个简单的测试套件:在一个空项目(create-react-app)上运行一些 npm install 测试。
环境:
上层:macOS - Docker 桌面版 - 启用 virtioFS
底层:Linux
测试(npm 安装):
1.主机上的原生节点;
2.没有绑定挂载或卷的 Docker;
3.采用绑定挂载的 Docker,但只有 node_modules 采用卷;
4.采用绑定挂载的 Docker。
我们可以看到性能影响的结果:当仅使用绑定挂载时,macOS 的速度会降低 3.5 倍左右(使用 gRPC Fuse 时会降低 10 倍),“罪魁祸首”是 node_modules 目录(只是一个什么都没做的 React 应用就有 3.7 万个文件,占据了 328M)。将此目录(node_modules)移动到卷中,消耗的时间就与 Linux 不相上下了,约为 7.65 秒。
上面,我们以 Node 为例,但其他开发生态系统受到的影响大致相同,而对于包含数十万个小文件的目录来说,性能几乎是灾难。
解决问题
以上,我们对各个组件及其工作方式做了简单地介绍,接下来的问题是:为了在 Mac 上使用 Docker 时获得良好性能和开发者体验,推荐方式是什么?
目前来看,答案就是使用 Docker Desktop for Mac 加上 VirtioFS,这样可以在性能和开发者体验之间的一个很好的折衷,虽然最终呈现的速度比 Linux 慢,但在大多数情况下,二者的差异可以忽略不计。
在使用 Node 或 PHP 开发新项目时,你不会每次都从头重新安装 node_modules 或 vendor,这种情况很少见,如果真的遇到这种情况,就需要花费更多的时间。
不过需要记住以下几点:
这是一款闭源产品;
你需要使用许可,具体取决于使用情况;
由于这是一项非常新的技术,因此仍然存在许多问题。
那么,我们究竟有哪些选择呢?
绑定挂载 + 卷
正如下面的结果所示,这是获得接近原生性能的最佳方法,但会给开发者体验带来严重的问题。
让我们看一个例子:
我们无法从主机上看到容器正在写入的文件,这是一个很大的问题,因为它会影响我们编写软件的方式以及 IDE 理解我们处理代码库的方式。如何解决这个问题?具体视个人的开发习惯而定。
终端编辑器的用户
你只需要一个容器,其中包含你所需要的编辑器和工具,然后挂载文件和代码,就可以享受到近乎原生的体验。
> docker run --rm -v $HOME/.vimrc:/home/node/.vimrc -v $PWD:/usr/src/app node:lts bash
GUI 编辑器/IDE 用户
这类用户的情况将更加复杂。桌面应用程序都绑定到了各自的操作系统,通常它们无法理解很多底层抽象,比如 Docker 容器中的文件系统。
由于此时的问题是我们无法从 Docker 卷读取文件或向从 Docker 卷写入文件,因此我们需要一个能够进行这种双向同步的工具,最常见的解决方案之一是 Mutagen。
你可以利用 Mutagen 实现宿主与容器之间的双向同步文件。刚开始使用工具并不太容易,因为它包括一个守护进程、一个 CLI 工具和一些配置文件。它本来支持 Composer,后来该功能成了Docker 桌面版的一个实验特性,后被弃用,又被 Mutagen 的作者吸收成为 Docker 桌面版的扩展。最后值得一提的是,它也得到了 DDEV 项目的支持和集成。
但是,如果我们不想安装额外的软件该怎么办?能不能将 GUI 编辑器作为 Docker 容器运行,以便访问文件吗?在 Linux 上运行 GUI 编辑器很容易,但我们没有现成的解决方案在 macOS 上运行 Linux GUI 应用程序, 除非你安装 XQuartz,并使用一些小技巧。
好消息是,微软为 Windows 实现了一个 Wayland 合成器,能够运行原生 Linux GUI 应用程序:WSLg。然而,这并不是使用编辑器的常见方式,而且功能非常有限,与操作系统的集成度也很低。
最好的解决方案是在本地系统上运行 IDE,而且还能够使用 Docker 容器,鱼与熊掌兼得。那么,我们需要编写这样的工具吗?答案是不需要,因为我们已有现成的工具:开发容器(https://containers.dev/)。
开发容器
开发容器(Development Container)是一个开放规范,它为容器添加了一些专用于开发的内容和设置。
开发容器允许你将容器作为功能齐全的开发环境。你可以利用它运行应用程序,并与代码库所需的工具、库或运行时相分离,而且还能辅助持续集成和测试。开发容器可以在私有云或公共云中运行,既可以在本地运行,也可以通过远程运行。
这个项目是微软创建的,规范在 CC4 许可下发布。完整的“支持工具”列表,请参见这里(https://github.com/devcontainers/spec/blob/main/docs/specs/supporting-tools.md)。可以看到,几乎所有的微软编辑工具和云服务都得到了支持,当然也包括 Visual Studio Code 和 GitHub Codespaces。
然而,这个项目也有一些竞争对手:
Gitpod
GCP 云工作站
AWS Cloud9
Docker 开发环境
如何使用开发容器
你需要在项目的根目录下创建一个文件 .devcontainer/devcontainer.json,供 VsCode 使用。
总的来说,这个项目的整体思路非常好,并且由于它是一个开放标准,因此可以很容易地被其他供应商采用,例如:
Gitpod
Jetbrains
nVIM
总结要点:
一切都是基于容器的。
我们可以自定义 VSCode 扩展,定义一个可在团队成员之间共享的开发环境。
通过开发容器,在容器内使用 Docker 和 Docker-compose。
SSH 开箱即用(必须启动并运行 ssh-agent)。
在本地或云中重用完全相同的配置。
总结
文本的主要目的是讨论如何提高在 macOS 上运行 Docker 的性能,但我觉得首先我们需要充分理解一些基本概念,因为这些基本概念是理解和充分利用系统的基础。
有人可能想问,为什么不直接使用 Linux?
日常工作我确实使用 Linux,我认为 Linux 可以作为最佳开发环境。但我们必须考虑硬件生态系统,特别是苹果芯片已上市,它们在性能、电池寿命和服务方面都有非常出色的表现。
因此,我想鱼与熊掌兼得。如今的机器足够强大,可以无缝运行它们。还有一些创造性的组合方式,比如将 macOS 和 NixOS 组合在一起。我们可以使用自己喜欢的硬件,并寻找最适合自己的工作流程。
《2022-2023 中国开发者大调查》重磅启动,欢迎扫描下方二维码,参与问卷调研,更有 iPad 等精美大礼等你拿!