Apache HTTP Server 在长期运行或高并发场景下,偶尔会出现进程内存只增不减,最终触发 OOM Killer 或服务响应延迟上升。这类现象通常不是简单的配置错误,而是某个模块或过滤器在请求处理过程中分配了内存却没有正确归还。临时重启可以缓解,但无法根除。要彻底解决,需要找到具体泄漏点并修改代码或配置。本文结合 APR 内存池、常见模块开发错误和调试工具,梳理一套从定位到修复的完整思路。

一、Apache 内存泄漏的常见原因
Apache 使用 APR(Apache Portable Runtime)内存池来管理请求生命周期内的内存分配。每个请求会关联一个 request pool,请求结束时整个池子被销毁,池中所有内存一次性释放。这种机制大幅降低了模块开发中忘记释放内存的风险,但也带来了另一种隐患:如果模块把数据错误地挂到进程级或连接级 pool 上,就会造成内存长时间无法回收。
另一个常见原因是模块混合使用 malloc、free 与 APR 池。比如某个模块在请求处理中用 malloc 分配了缓冲区,却因为异常分支提前返回,没有执行 free;或者把 malloc 得到的指针交给 APR 池管理,导致二次释放或泄漏。自定义连接过滤器、输出过滤器以及缓存模块如果维护了全局链表或哈希表,但没有在连接关闭或子进程退出时清理,也很容易积累内存。
配置层面的长连接与 KeepAlive 设置也可能放大泄漏效果。泄漏量如果和请求数成正比,短连接下可能不明显,但 KeepAlive 长连接复用进程时,泄漏会集中在少数进程上,表现为单个 httpd 进程内存不断上涨。因此排查前先确认是哪个 MPM 模式,以及泄漏是否与特定模块相关。
二、定位内存泄漏的实用工具与步骤
第一步是观察进程内存变化。启用 mod_status 并打开 ExtendedStatus,可以通过 server-status 页面看到每个工作进程的 CPU、请求数和内存占用。连续多次请求相同的测试 URL,记录单个进程的 RSS 增量。如果 RSS 持续上升且不回落,基本可以判断存在泄漏。也可以使用 ps 命令按 RSS 排序,找到异常进程。
第二步是用 gdb 抓取堆栈。对于已经出现泄漏的 httpd 进程,可以用 gdb attach 上去,执行 call malloc_stats() 或查看 /proc/PID/maps 确认堆增长。如果需要知道哪些分配未被释放,更好的方式是使用 Valgrind 的 memcheck 工具。以单进程模式启动 Apache 并限制请求数,运行:
valgrind --leak-check=full --show-leak-kinds=definite --log-file=valgrind.log /usr/local/apache2/bin/httpd -X
其中 -X 参数让 httpd 进入单进程调试模式,避免 fork 干扰。执行若干请求后优雅停止进程,Valgrind 会输出泄漏块的大小、调用栈和分配来源。根据调用栈可以快速定位到是哪个模块的哪一行代码。
第三步是结合 AddressSanitizer 重新编译模块。对于仍然能复现的泄漏,使用 -fsanitize=address 编译自定义模块,并在启动时设置 ASAN_OPTIONS=detect_leaks=1,运行压测后终止进程,ASAN 会给出更精确的泄漏报告。这种方法对 APR 池的误用尤其有效,因为它能捕获 malloc 与 free 不匹配以及池中直接 malloc 的对象。
三、典型泄漏场景与修复代码
下面看一个自定义模块中常见的错误:在请求处理函数里用 APR 池分配了一个缓冲区,但随后又调用 malloc 创建了另一个结构体,却忘记在返回前释放。先展示错误代码:
static int my_handler(request_rec *r)
{
char *buf = apr_palloc(r->pool, 4096);
my_data_t *data = malloc(sizeof(my_data_t));
if (data == NULL) {
return HTTP_INTERNAL_SERVER_ERROR;
}
data->buf = buf;
data->len = 4096;
/* 业务处理,可能提前 return */
if (some_error) {
return HTTP_INTERNAL_SERVER_ERROR; /* 这里忘记 free(data) */
}
ap_set_content_type(r, "text/plain");
ap_rputs(data->buf, r);
free(data);
return OK;
}这段代码的问题在于如果 some_error 为真,函数直接返回,malloc 分配的 data 没有被释放。虽然 buf 来自请求池最终会释放,但 data 是堆内存,与请求池无关,因此每次异常请求都会泄漏一块内存。修复方法有两种:一是统一使用请求池分配 data;二是确保所有返回路径都释放 data。推荐使用请求池,因为这样不需要手动管理生命周期。
修复后的代码可以这样写:
static int my_handler(request_rec *r)
{
char *buf = apr_palloc(r->pool, 4096);
my_data_t *data = apr_palloc(r->pool, sizeof(my_data_t));
if (data == NULL) {
return HTTP_INTERNAL_SERVER_ERROR;
}
data->buf = buf;
data->len = 4096;
if (some_error) {
return HTTP_INTERNAL_SERVER_ERROR; /* pool 会在请求结束时统一释放 */
}
ap_set_content_type(r, "text/plain");
ap_rputs(data->buf, r);
return OK;
}另一个典型场景是输出过滤器在初始化时创建了临时缓冲区,但在过滤函数返回错误或连接中断时没有释放。输出过滤器可能被调用多次,如果每次都在 ctx 中保存 malloc 的指针而不清理,连接关闭时就会泄漏。正确的做法是在过滤器的清理回调中释放这些资源,或者直接使用连接池分配。
修复完成后,需要重新编译并部署模块,再使用相同的压力测试脚本复现。观察进程 RSS 是否保持平稳,以及 Valgrind 日志中是否还有 definite leak 类型。对于涉及全局缓存的情况,还需要确认缓存淘汰策略是否真正释放了节点内存。
四、验证与长期预防策略
修复后不能只凭一次请求通过就下结论。要使用压力工具如 ab 或 wrk 持续发送请求至少数万次,并采集每分钟的进程 RSS 数据。如果内存曲线在初始增长后趋于水平,说明泄漏基本解决;如果仍缓慢上升,需要重新检查是否还有其他分配点未覆盖。
长期预防方面,建议在模块开发阶段就开启编译告警和静态分析。使用 -Wall -Wextra 以及 clang 的 scan-build 可以发现一些明显的 malloc 未释放路径。对于 APR 模块,尽量遵循所有内存都来自请求池或连接池的原则,避免在工作进程中长期持有 malloc 内存。如果必须使用 malloc,务必提供对应的清理函数,并在连接关闭或进程退出时调用。
此外,可以为关键模块编写单元测试,配合 Valgrind 在 CI 中运行。每新增一个分配路径,都加入泄漏检测用例。Apache 自身的 test 框架虽然不覆盖所有模块,但可以借助 httpd -t 和模块自检脚本减少回归风险。通过工具链与代码规范结合,才能把内存泄漏控制在发布之前。