macOS Docker 挂载卷文件 IO 慢解决办法

最近在本地开发 Laravel 5.5 项目时,发现一个 TTFB 巨高的问题。

之前在第一次尝试 Laravel-Admin 的时候也发现了这样的问题,不过因为没有继续下一步开发,所以就没深究。

今天花了几个小时找到了问题所在,并将解决方法一起记录在这里。

问题

现象

访问本地 Laravel 项目,只请求根路由,空白大概 3~4 秒后才开始加载 HTML,整个请求-响应过程花了大概 8 秒左右。

使用浏览器开发者工具审查,发现 Timing 中 waiting (TTFB)的时间大约在 2.5~4 秒之间。

期望

秒开。因为同样的项目放在服务器上都是能够秒开的。

分析过程

  • 排除法:代码逻辑

首先,在服务器上的环境下能够正常工作,而在我本地却不能,代码库都是同一个,因此先排除代码写得烂的原因,将注意力放在 PHP 运行环境及其周边的配置上。

其次,TTFB 慢基本肯定是服务端的问题,因为是浏览器在等待服务器的响应。

具体而言是:等待服务器返回响应的第一个字节。

  • 是不是 nginx 配置不对?

这个也可以快速简单验证一下,因为从客户端发起的请求经过 nginx 后会被代理到 php-fpm 的某个 worker,因此,可以在 php-fpm worker 刚开始处理请求时就先终止,即可以简单判断是否时 ngnix 的配置不合理导致的。

在 Laravel 的入口文件 public/index.php 中的第一行代码处直接终止:

1
2
<?php
die;

在 FireFox 57.0.3 上随机结果如下:

1
2
3
4
5
6
7
Blocked: → 7 ms
DNS resolution: → 7 ms
Connecting: → 0 ms
TLS setup: → 0 ms
Sending: → 0 ms
Waiting: → 8 ms
Receiving: → 0 ms

这里的 Waiting 变为不到 10 ms,因此,可以排出 nginx 的原因了。

  • 那是 PHP 代码的问题?

虽然一开始我们排出了代码层面的原因,但是仍然可以追踪代码的执行时间,去找到底哪块运行慢了,因为仍有可能和运行环境关联的某些代码调用“被变慢了”。

OK,那简单测试下 Laravel 处理请求用了多少时间,仍然修改 pubblic/index.php 这个文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<?php
define('LARAVEL_START', microtime(true));
require __DIR__.'/../vendor/autoload.php';
$app = require_once __DIR__.'/../bootstrap/app.php';
$kernel = $app->make(Illuminate\Contracts\Http\Kernel::class);
$response = $kernel->handle(
$request = Illuminate\Http\Request::capture()
);

// 检查 Laravel 处理完请求用了多久
dd(microtime(true) - LARAVEL_START); // 单位:秒

$response->send();
$kernel->terminate($request, $response);

仍然在 Firefox 57 上请求,得到的结果是:2.5~3 秒!而同样的测试在服务器上得到的结果是 0.2 秒左右。

那么,基本上可以肯定就是 Laravel 运行过程中某个地方必然花了大量的时间,而且这个「某个地方」肯定和我本地的开发环境的某处配置密切相关。

最后祭出 xhprof 来找出费时的调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// ini_set('xdebug.profiler_enable', 1);
// ini_set('xdebug.profiler_output_dir', 'xdebug');

xhprof_enable(XHPROF_FLAGS_MEMORY | XHPROF_FLAGS_CPU);

define('LARAVEL_START', microtime(true));
require __DIR__.'/../vendor/autoload.php';
$app = require_once __DIR__.'/../bootstrap/app.php';
$kernel = $app->make(Illuminate\Contracts\Http\Kernel::class);
$response = $kernel->handle(
$request = Illuminate\Http\Request::capture()
);

$data = xhprof_disable();

// 1. 输出执行时间大于 1s 的调用
foreach ($data as $key => $value) {
if ($value['wt'] > 1000000) {
echo $value['wt'], ' => ', $key, PHP_EOL, PHP_EOL;
}
}
die;

// 2. 按耗时从高到低排序调用
$caller = array_keys($data);
$waste = array_column($data, 'wt');
arsort($waste);
dd(array_combine($caller, $waste));

其中,1 的输出前几名为:

1
2
3
4
5
6
7
1270540 => Composer\Autoload\ClassLoader::loadClass==>Composer\Autoload\includeFile
1249227 => spl_autoload_call==>Composer\Autoload\ClassLoader::loadClass
1144616 => Illuminate\Foundation\Http\Kernel::bootstrap==>Illuminate\Foundation\Application::bootstrapWith
1144694 => Illuminate\Foundation\Http\Kernel::sendRequestThroughRouter==>Illuminate\Foundation\Http\Kernel::bootstrap
1477033 => Illuminate\Foundation\Http\Kernel::handle==>Illuminate\Foundation\Http\Kernel::sendRequestThroughRouter
1483877 => main()==>Illuminate\Foundation\Http\Kernel::handle
1682673 => main()

2 的输出中执行时间大于 1 秒前几名为:

1
2
3
4
5
6
7
"main()==>microtime" => 1701018
"main()==>define" => 1452871
"main()==>load::vendor/autoload.php" => 1447434
"main()==>load::composer/autoload_real.php" => 1266664
"ComposerAutoloaderInitf46c83d95828cc8aa03348539a43bebb::getLoader==>spl_autoload_register" => 1248349
"ComposerAutoloaderInitf46c83d95828cc8aa03348539a43bebb::loadClassLoader==>load::composer/ClassLoader.php" => 1105931
"spl_autoload_call==>ComposerAutoloaderInitf46c83d95828cc8aa03348539a43bebb::loadClassLoader" => 1105903

因此可以确认一点,是 composer 加载类文件太慢了,可以在跟路由处打印下一共加载了多少个文件:

1
dd(get_included_files());

其实也就 200 来个文件,为什么就这么慢呢。

解决办法

我本地的开发环境是:代码在 macOS 本地编辑,通过 docker volumes 实时同步到容器的 webroot。是不是 docker 容器在读取 macOS 本地文件时性能不行呢?

于是我以 “docker mac volume slow” 为关键词 Google 到这个 GitHub issue:File access in mounted volumes extremely slow #77。找到了大概如下几种解决方案

dinghy

加速本地开发时宿主和 VM 之间文件共享速度慢的痛点问题,不过不喜欢的是必须安装一种虚拟机,和我使用 docker 作为本地开发环境的意图矛盾(嫌虚拟机太重太慢),所以我没用这个。

docker-sync

也是为了解决在 macOS 和 windows 上使用 docker 作为本地开发环境的过程文件共享速度巨慢的问题而解决的,不过看了下文档,要安装不少东西,配置不够简洁,个人不喜欢。

docker-machine-nfs

和 dinghy 一样,需要安装虚拟机,在后再在虚拟机中安装 docker,然后借助 docker-machine 来管理 docker 容器,所以也没有选择。

docker-bg-sync

这个脚本使用 Docker 容器解决 Docker 问题,因为本身我已经在 macOS 上运行 Docker 了,因此不需要再安装任何东西,使用很简单,只需要再多运行一个容器,关联文件同步的命令也非常简单,因此我最后采用了这种办法。

作者使用的是 docker-compose 来将 App 容器和负责后台同步该 App 容器的代码目录一起启动的,而我的 App 容器已经运行过了,因此这里我只单独启动这个同步容器。

1
2
3
4
5
6
7
8
9
10
11
docker run \
-e SYNC_DESTINATION=/data/www \
-e SYNC_MAX_INOTIFY_WATCHES=40000 \
-e SYNC_NODELETE_SOURCE=0 \
-e SYNC_VERBOSE=1 \
-d \
--name bg-sync-app-name \
--volume /path/to/project:/source \
--volumes-from 427dc38a8292:rw \
--privileged=true \
cweagans/bg-sync

这里的参数和作者在说明中的 docker-composer.yml 保持一致。

注意事项

这里的 volumes-from 必须是容器的 ID,而不要是名称(我当时用 name 时报错了),而且运行 App 的容器启动时必须指定一个暴露给 docker-bg-sync 的路径:--volume /path/to/app/runtime,然后这里的 SYNC_DESTINATION 也必须等于 /path/to/app/runtime 才能正常工作,否则可能会出现 “Destination path not found” 的报错。

权限(坑)

一开始我运行同步容器时候没有配置 UNISON_USER/UNISON_UID/UNISON_GROUP/UNISON_GID 这四个环境变量,有个烦人的权限问题,就是修改过的文件权限在每次文件发生改变后就恢复默认上面四个变量的默认值,即 root,这样的话,如果项目需要发生了写日志等需要写权限的操作,就会报错。

要规避这种情况,解决办法如下:

    1. 先进入应用容器,现将 web root 改为为服务器用户所有,我这里是这样:
1
chown -R nginx:nginx /data/www
    1. 获得应用容器的服务器用户 ID 属性
1
2
id nginx
# uid=998(nginx) gid=996(nginx) groups=996(nginx)

这里必须是在应用容器中存在的用户和用户 ID,而不能是随便的一个 USER 和 UID,否则也会出现权限问题。

    1. 停用并删除之前的同步容器,将第 2 步中的输出赋值给 UNISON_* 四个参数后重新开一个同步容器,即:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
docker run \
-e SYNC_DESTINATION=/data/www \
-e SYNC_MAX_INOTIFY_WATCHES=40000 \
-e SYNC_NODELETE_SOURCE=0 \
-e SYNC_VERBOSE=1 \
-e UNISON_USER=nginx \
-e UNISON_UID=998 \
-e UNISON_GROUP=nginx \
-e UNISON_GID=996 \
-d \
--name bg-sync-app-name \
--volume /path/to/project:/source \
--volumes-from 427dc38a8292:rw \
--privileged=true \
cweagans/bg-sync

docker-bg-sync 的同步容器在启动时会创建好 nginx 用户和 nginx 组,然后以 nginx 用户的身份去执行同步操作。

自定义 Unison 配置

使用 SYNC_EXTRA_UNISON_PROFILE_OPTS 选项将 Unison 配置追加到 /home/nginx/.unison/default.prf

1
2
-e SYNC_EXTRA_UNISON_PROFILE_OPTS='ignore = Path .phpintel/*
ignore = Path .docker-sync/*' # 直接在单引号中换行即可

总结

docker-bg-sync 的原理是在后台运行一个 unison 不停的从宿主系统目录同步到指定的应用容器的执行目录。可进入 cweagans/bg-sync 容器中 UNISON_USER 指定的用户 home 根目录下查看 unison.log 同步日志。

使用 unison 后台自动同步这种方式替换 docker run 命令通过 --volume 参数直接绑定宿主项目目录和容器的运行路径后,本地 Laravel 项目 TTBF 正常了,同样只请求根路由,大概降低到 200~300 ms。

可见,文件 IO 是多么影响性能。

附录:终极解决办法

https://www.vim.org/。(:P

因为,直接在 docker 里面写代码的话,就不存在同步这个问题了。

不过至于慢不慢还有待测试,毕竟 macOS 不具备 Linux Kernel,macOS 中的 Docker 必须先运行在一个 Linux 虚拟机之上( Windows 同)。

On Linux systems, Docker directly leverages the kernel of the host system, and file system mounts are native.

On Windows and Mac, it’s slightly different. These operating systems do not provide a Linux Kernel, so Docker starts a virtual machine with a small Linux installed and runs Docker containers in there. File system mounts are also not possible natively and need a helper-system in between, which both Docker and Cachalot provide.

参考