本文仅针对Hyperf 1.x版本, 实际上2.x版本也遭遇到同样的现象, 但是和1.x版本是不一样的问题
在对SCRM项目后端应用进行迁移时, 收到集群的一个告警
OOM的全称是Out Of Memory
, 翻译成中文就是内存用完了, 和后面的killed连起来就是这个容器组的内存用完了所以k8s把它杀掉了, 容器组的内存限制配置如下
resources:
limits:
cpu: '1'
memory: 1Gi
requests:
cpu: 500m
memory: 200Mi
可以看到上面的配置运行容器组使用1G的内存, 这对正常的PHP应用来说是足够的, 况且这个容器组刚启动还没有任何流量的情况下就被OOM killed了, 这显然是不正常的
在传统的Nginx + PHP-FPM架构中, 一个请求对应一个进程, 请求结束后进程占用的内存会被释放, 但Swoole是常驻型的, 一个进程处理所有请求, 内存只会在GC时或者进程结束时被释放
由于容器是刚启动就被OOM killed, 也就是说不是由于接口导致内存泄露的, 那就是启动阶段的某个环节出了问题, 启动时的这两行日志引起了我的注意
Detected an available cache, skip the app scan process.
Detected an available cache, skip the vendor scan process.
翻译过来就是发现了可用的缓存, 跳过app/vendor的扫描, 这里的扫描指的是Hyperf的注解扫描, 注解在业务代码中大量使用, 极大的提高了开发效率, 但是注解并不是PHP原生支持的功能, 框架读取我们写的注解实际上用的是很原始的方式, 即是对所有代码进行语法分析, 语法分析的本质是字符串操作, 也就是把所有代码加载到内存中, 然后转换成AST, 再读取其中的注解, 如果不理解这个过程也没关系, 只要记住一点扫描的动作需要把所有代码当做字符串加载到内存中
Hyperf提供了预生成缓存的功能, 正如上面两行输出, 我们可以先生成一个缓存然后应用启动的时候就不需要扫描了, 这个预生成的动作放在镜像构建里, 这会增加镜像构建时的开销, 但减轻了容器运行时的开销
RUN composer install --no-dev -vvv
COPY . .
RUN composer dump-autoload -o
在Dockerfile中使用了composer dump-autoload -o
命令进行预生成缓存, 执行完后会生成两个缓存文件
2m@2ms-MacBook-Pro scrm % ls -la runtime/container
total 80
drwxr-xr-x 5 2m staff 160 9 25 17:36 .
drwxr-xr-x 3 2m staff 96 9 25 17:36 ..
-rw-r--r-- 1 2m staff 15895 9 25 19:09 annotations.app.cache
-rw-r--r-- 1 2m staff 21618 9 25 19:08 annotations.vendor.cache
drwxr-xr-x 6 2m staff 192 9 25 17:36 proxy
上面两个以.cache
为后缀的就是缓存文件, 照理说有缓存了Hyperf启动时就会读取, 然后跳过上面说的注解扫描动作, 但奇怪的事情发生了, 在k8s里的容器启动时输出的日志是这样的
2m@2ms-MacBook-Pro scrm % php bin/hyperf.php
Scanning app ...
Scan app completed, took 1636.6400718689 milliseconds.
Detected an available cache, skip the vendor scan process.
从日志中看到框架并没有读取app缓存, 而是重新进行了一次扫描, 基本可以确定这个扫描动作就是导致OOM的原因, 那为什么框架会不读取app缓存呢, 下面进行debug
如果需要逐个环节去追Hyperf的启动流程, 这会很复杂, 所以使用一种比较简单粗暴的方法快速定位相关代码, 搜索关键字
我们直接在vendor/hyperf
目录下搜索Scanning
这个字符串, 这能让我们快速定位到相关代码
追进去找到一个loadMetadata
方法, 大概长这样
private function loadMetadata(array $paths, $type)
{
if (empty($paths)) {
return true;
}
$cachePath = $this->cachePath.'.'.$type.'.cache';
$pathsHash = md5(implode(',', $paths));
if ($this->hasAvailableCache($paths, $pathsHash, $cachePath)) {
$this->printLn('Detected an available cache, skip the '.$type.' scan process.');
[, $serialized] = explode(PHP_EOL, file_get_contents($cachePath));
$this->scanner->collect(unserialize($serialized));
return false;
}
$this->printLn('Scanning '.$type.' ...');
// 扫描注解 & 生成缓存逻辑
return true;
}
从这个方法代码看起来只能是第八行的hasAvailableCache
调用返回了false
导致代码走到15行后面的生成注解逻辑, 继续追hasAvailableCache
方法
private function hasAvailableCache(array $paths, string $pathsHash, string $filename): bool
{
if (!$this->enableCache) {
return false;
}
if (!file_exists($filename) || !is_readable($filename)) {
return false;
}
$handler = fopen($filename, 'r');
while (!feof($handler)) {
$line = fgets($handler);
if (trim($line) !== $pathsHash) {
return false;
}
break;
}
$cacheLastModified = filemtime($filename) ?? 0;
$finder = new Finder();
$finder->files()->in($paths)->name('*.php');
foreach ($finder as $file) {
if ($file->getMTime() > $cacheLastModified) {
return false;
}
}
return true;
}
可以看到有四个地方会返回false
, 在每个可能返回false
的地方打上标记, 发现是走到了第13行的false返回了, 这段代码的逻辑是读取缓存文件(runtime/container/annotations.app.cache)的第一行, 然后与$pathsHash
变量进行比较, 所以问题就是缓存文件的第一行和$pathsHash
的值不一致导致的, 回到loadMetadata
方法查看$pathsHash
的生成规则
$pathsHash = md5(implode(',', $paths));
可以看到$pathsHash
是由$paths
数组使用逗号拼成字符串后的md5值, 那$paths
是什么呢, 篇幅所限这里就不展开追踪, $paths
的值是从配置annotations.scan.paths
字段获取的, 先来看一下这个配置
use Symfony\Component\Finder\Finder;
return [
'scan' => [
'paths' => value(function () {
$paths = [];
$dirs = Finder::create()->in(BASE_PATH.'/app')
->depth('< 1')
->exclude(['Model'])
->directories();
/** @var SplFileInfo $dir */
foreach ($dirs as $dir) {
$paths[] = $dir->getRealPath();
}
return $paths;
}),
'ignore_annotations' => [
'mixin',
],
],
];
这个配置指示了注解扫描器要扫描哪些目录下的文件, 从上面的代码来看就是扫描app/目录下除了Model
下的所有文件, 打印这个数组可以看到
Array
(
[0] => /opt/www/app/WorkWechat
[1] => /opt/www/app/Wechat
[2] => /opt/www/app/Admin
[3] => /opt/www/app/Exception
[4] => /opt/www/app/Manage
[5] => /opt/www/app/Command
[6] => /opt/www/app/Middleware
[7] => /opt/www/app/Common
[8] => /opt/www/app/Listener
[9] => /opt/www/app/Validate
[10] => /opt/www/app/PublicApi
[11] => /opt/www/app/DataCollection
[12] => /opt/www/app/Job
)
这看起来没什么问题, 因为这些目录是固定的, 那为什么把这些目录拼起来之后md5的值会改变呢? 这问题的答案很简单, PHP的遍历是不保证顺序的, 就比如上面的输出是镜像构建时生成缓存的时候输出的, 但是当容器启动时, 输出就变成了
Array
(
[0] => /opt/www/app/Middleware
[1] => /opt/www/app/Exception
[2] => /opt/www/app/Listener
[3] => /opt/www/app/Wechat
[4] => /opt/www/app/WorkWechat
[5] => /opt/www/app/Common
[6] => /opt/www/app/Manage
[7] => /opt/www/app/Job
[8] => /opt/www/app/PublicApi
[9] => /opt/www/app/Command
[10] => /opt/www/app/Validate
[11] => /opt/www/app/DataCollection
[12] => /opt/www/app/Admin
)
虽然里面的内容没变但是顺序变了, 拼接成字符串之后自然就不一样了! 解决方法也很简单, 只要确保$paths
数组内的目录顺序始终保持一致就ok了
use Symfony\Component\Finder\Finder;
return [
'scan' => [
'paths' => value(function () {
$paths = [];
$dirs = Finder::create()->in(BASE_PATH.'/app')
->depth('< 1')
->exclude(['Model'])
->directories();
/** @var SplFileInfo $dir */
foreach ($dirs as $dir) {
$paths[] = $dir->getRealPath();
}
// Fix
asort($paths);
return $paths;
}),
'ignore_annotations' => [
'mixin',
],
],
];
仅加了一行代码, 在返回$paths
数组前使用asort
函数对数组的值进行一次排序, 这样就确保了每次返回的$paths
数组内容都是完全一致的了
修改前
2m@2ms-MacBook-Pro scrm % kubectl top po -n scrm
NAME CPU(cores) MEMORY(bytes)
app-prod-5f494856cd-8f49h 0m 1118Mi
app-prod-5f494856cd-pvv2x 35m 1122Mi
修改后
2m@2ms-MacBook-Pro scrm % kubectl top po -n scrm
NAME CPU(cores) MEMORY(bytes)
app-prod-7d65bcd67-5sf5r 0m 656Mi
app-prod-7d65bcd67-hbpq5 0m 655Mi
内存使用量下降了一倍, Nice!