PHP-FPM 之外的选择:Nginx Unit 是什么
个人站长部署 PHP 网站,默认路径几乎是固定的:Nginx 做前端,PHP-FPM 通过 FastCGI 处理后端。这套组合能跑,但它的配置是分裂的——Nginx 一个配置文件、PHP-FPM 一个进程池配置文件,两边要手动对齐 socket 路径、超时、buffer 大小,改一处常常要动两处,出错时排查还得来回看两套日志。
Nginx Unit 是 Nginx 团队后来推出的应用服务器,思路完全不同:它把「接收 HTTP 请求」和「运行应用代码」合并到一个进程里,用一套 JSON 配置同时描述路由和进程管理,而且配置可以热加载——改完立刻生效,不用 reload、不断连接。
它一开始只支持 PHP 和 Python,现在支持的语言已经很全:PHP、Python、Node.js、Java、Go、Ruby、Perl、WebAssembly。对个人站长最直接的价值是:
- 把 Nginx + PHP-FPM 两套配置简化成 Unit 一套,配置更少、脑负担更轻。
- 配置热加载,改
max_children、加路由不用重启服务、不断线。 - 同一台机器上跑多种语言的应用(比如 PHP 主站 + Python 定时接口 + Node 的小工具),全由 Unit 统一管。
- 内置静态文件服务、路由匹配、URL 重写、限速,很多原本要写 Nginx 规则的事直接用它搞定。
它不适合所有场景:Unit 的功能广度不如 Nginx(没有完整的反代生态、没有大量第三方模块),所以常见架构是「Nginx 在最前面做 TLS 和缓存,把动态请求转给 Unit」;也可以让 Unit 直接对外。下面从安装讲到配置、热加载、多语言,最后给几个必踩的坑。
安装 Unit:用官方源,别自己编译
Unit 官方提供了各发行版的仓库,装起来很干净。以 Debian 12 为例:
apt update
apt install -y curl gnupg2 ca-certificates lsb-release
# 导入官方 GPG key
curl -fsSL https://unit.nginx.org/keys/nginx-keyring.gpg \
| gpg --dearmor -o /usr/share/keyrings/nginx-keyring.gpg
# 添加 Unit 官方源
echo "deb [signed-by=/usr/share/keyrings/nginx-keyring.gpg] \
https://packages.nginx.org/unit/debian/ $(lsb_release -cs) unit" \
> /etc/apt/sources.list.d/unit.list
apt update
# 装核心 + PHP 语言的模块(模块版本要和 PHP 主版本对应)
apt install -y unit unit-php unit-python3
systemctl enable --now unit
systemctl status unit --no-pager其他语言模块同理:unit-php、unit-python3、unit-nodejs20、unit-java、unit-go、unit-ruby。装哪个就支持哪个,不装的不占资源。
Unit 默认监听 127.0.0.1:8080(具体端口看配置),管理接口在 /var/run/unit/control.sock。它有个独立的控制 socket 用于热加载配置,默认只有 unit 用户能访问——这点很重要,后面讲安全时会用到。
核心概念:configuration 三段结构
Unit 的配置是 JSON,通过控制 API 提交。顶层只有三个键,记住它们就掌握了大半:
{
"listeners": { ... }, // 监听哪个地址端口
"routes": [ ... ], // 怎么路由请求(可嵌套,即路由树)
"applications": { ... } // 应用怎么跑(语言、进程数、工作目录)
}一个最小的 PHP 站点配置长这样:
{
"listeners": {
"127.0.0.1:8300": {
"pass": "routes"
}
},
"routes": [
{
"match": { "uri": "!*.php" },
"action": {
"share": "/var/www/site$uri",
"fallback": { "pass": "applications/phpapp" }
}
},
{
"action": { "pass": "applications/phpapp" }
}
],
"applications": {
"phpapp": {
"type": "php",
"root": "/var/www/site/",
"script": "index.php",
"processes": { "max": 10, "spare": 4 }
}
}
}逐段解释:
- listeners 里
127.0.0.1:8300是监听地址,pass: "routes"表示交给路由树处理。对外服务时如果前面有 Nginx,监听 127.0.0.1 就够;让 Unit 直接对外就写*:80。 - routes 是路由树。第一条:
match uri !*.php(不是 PHP 结尾的请求)走share——直接从磁盘发静态文件;若文件不存在(fallback)交给 PHP 处理,这是给「伪静态」用的:URL 没有对应文件时交给框架入口。 - applications 定义应用。
type: "php"、root是站点根目录、script是默认入口。 - processes 就是 PHP-FPM 里
pm.max_children的等价物。max: 10表示最多 10 个进程,spare: 4表示常备空闲进程。这里还支持idle_timeout(空闲多久回收)等参数,比 PHP-FPM 的进程池配置更集中。
通过控制 API 热加载配置
Unit 的配置不直接编辑文件,而是通过 unitc 工具或控制 socket 的 API 提交。官方客户端 unitc 让这件事变得很简单:
# 把完整的 JSON 覆盖到 /config
unitc /config < myconfig.json
# 只改某一个子树(比如只改 applications/phpapp 的进程数)
unitc /config/applications/phpapp < phpapp.json
# 查看当前配置
unitc /config
# 查看某个部分
unitc /config/listeners用 curl 直接调控制 API 也行(socket 路径视安装方式而定):
# 读取当前配置
curl --unix-socket /var/run/unit/control.sock http://localhost/config/
# 只更新 applications/phpapp 这个子树(PUT 是合并,不是覆盖整个 config)
curl -X PUT --data-binary @phpapp.json \
--unix-socket /var/run/unit/control.sock \
http://localhost/config/applications/phpapp
# 看某个 listener
curl --unix-socket /var/run/unit/control.sock \
http://localhost/config/listeners/127.0.0.1:8300热加载的价值在这里体现得淋漓尽致:你想把 PHP 进程从 10 调到 20,改一行 JSON、PUT 一下,几百毫秒内新进程就起来了,正在处理的请求不受影响,连接不断。对比 PHP-FPM——改 pm.max_children 要 service php-fpm reload,虽然也是平滑重载,但涉及发信号、旧进程优雅退出,配置分散在两个文件里还容易忘同步。Unit 把「配置」和「生效」之间的链路缩到最短。
更妙的是,PUT 支持「点路径」精确修改,不需要提交整份配置。比如只想改一个路由的限速值,就 PUT 那个路由节点,其余部分原样不动——这在自动化脚本里非常好用。
一台机器跑多种语言
个人站长常常一台机器上跑着杂七杂八的东西:主站是 PHP,某个接口用 Python 写的,还有个小工具是 Node.js。传统上要分别用 PHP-FPM、gunicorn/uWSGI、pm2 三套进程管理器,各配各的端口、各写各的 systemd unit,日志分成三处。
用 Unit,这些都能收进同一份配置:
{
"listeners": {
"127.0.0.1:8300": { "pass": "routes" }
},
"routes": [
{
"match": { "uri": "/api/*" },
"action": { "pass": "applications/pyapp" }
},
{
"match": { "uri": "/tools/*" },
"action": { "pass": "applications/nodeapp" }
},
{ "action": { "pass": "applications/phpapp" } }
],
"applications": {
"phpapp": { "type": "php", "root": "/var/www/site/", "script": "index.php" },
"pyapp": { "type": "python",
"path": "/opt/pyapp/",
"module": "wsgi",
"callable": "app" },
"nodeapp": { "type": "external",
"executable": "/usr/bin/node",
"arguments": ["/opt/nodeapp/server.js"] }
}
}几点注意:
- Python 用 WSGI 时,
module指向模块名、callable指向 WSGI 应用对象;用 ASGI(FastAPI/Starlette)则type还是python,通过protocol: "asgi"指定。 - Node.js 常见写法是把 Node 应用仍作为「外部进程」由 Unit 管理(
type: "external"),Unit 负责拉起和守护;也可以用 Unit 的 Node 模块直接跑(type: "nodejs",需要应用导出符合 Unit 接口的模块)。 - 路由匹配是按顺序的,先写的先匹配。把具体的
/api/*、/tools/*放前面,兜底的 PHP 放最后,逻辑才正确。
这样一来,语言各不相同但也无所谓了,Unit 里一份 JSON 就是全部真相,进程数、工作目录、入口全在一处。配上 unitc 热加载,加一个接口、改一个进程数都是秒级的操作。
静态文件、限速与 TLS
Unit 内置的能力,很多原本要靠 Nginx 规则实现:
{
"settings": {
"http": {
"max_body_size": 10000000, // 约 10MB,等价 client_max_body_size
"discard_unsafe_fields": true
}
},
"routes": [
{
"match": { "uri": "/static/*" },
"action": {
"share": "/var/www/site/static",
"response_headers": {
"Cache-Control": "public, max-age=2592000",
"X-Content-Type-Options": "nosniff"
}
}
},
{
"match": { "uri": "/api/*", "method": "POST" },
"action": {
"pass": "applications/phpapp",
"limits": { "rate": 5, "burst": 10, "delay": 0 }
}
},
{ "action": { "pass": "applications/phpapp" } }
]
}- share 直接发静态文件,还能顺手压
Cache-Control、X-Content-Type-Options这类响应头。 - limits 就是请求限速,等价于 Nginx 的
limit_req:每秒 5 个请求、突发 10 个、不延迟。用来保护登录接口、评论接口很合适。 - max_body_size 等价于
client_max_body_size,默认值偏小,上传文件要记得调大,否则会 413。
TLS 也可以在 Unit 里直接配(listeners 里加 tls 对象指向证书链和私钥),不过很多人还是喜欢让 Nginx/Caddy 在最前面处理证书自动续期,Unit 只管应用。两种都行,选你顺手的。
五个必踩的坑
坑一:静态文件路径没配对,全变成 404。 Unit 的 share 是「从指定根目录找文件」,路径拼接规则和 Nginx 的 root/alias 语义不完全一样。写 share: "/var/www/site/static" 配 uri: "/static/*" 时,要确认请求 /static/a.png 最终落到 /var/www/site/static/static/a.png 还是 /var/www/site/static/a.png——用 share: "/var/www/site" 配合完整 uri 更不容易错。上线前一定用一个真实静态文件测通。
坑二:忘了 fallback,伪静态 404。 使用框架(ThinkPHP、Laravel、Typecho 等)时,URL 往往不对应真实文件(比如 /post/123)。路由里必须给静态文件匹配加 fallback 指向应用,否则不存在的文件直接返回 404,永远到不了框架入口。这是从 Nginx try_files 迁移过来最容易漏的一步。
坑三:控制 socket 权限太松,等于给了 root。 能访问 Unit 控制 socket 的人可以任意改配置,包括把某个应用的可执行文件改成任意命令——等同于命令执行。所以 socket 必须只允许 unit 用户访问,绝不暴露到公网、不放进任何 Web 可读目录。如果你自己写了脚本调控制 API,脚本权限也要收紧。
坑四:用旧配置整体覆盖,把别人加的监听弄丢。 PUT /config 是替换整个配置。如果你手上有份过期的 JSON,一提交就把别人(或你自己另一个脚本)新加的应用、监听全冲掉。改配置优先用「点路径 PUT」只改需要改的子树;要整体替换时,先 GET /config 拿到当前完整配置,改完再 PUT,别用本地旧文件。
坑五:以为装了 Unit 就能替代 Nginx 的一切。 Unit 没有 Nginx 那种庞大的反代/模块生态,也缺少成熟的缓存、灰度、复杂重写能力。现实里最常见也最稳的架构是 Nginx 在前(TLS、proxy_cache、限流、WAF),Unit 在后跑应用。把 Unit 当「更简洁的 PHP-FPM + 内置路由」,而不是「Nginx 杀手」,预期才不会错。
要不要把现有站点迁到 Unit
给你一个务实的判断:
- 如果现在网站跑得稳定、你对 Nginx + PHP-FPM 那套配置很熟,不必为了新而迁。稳定压倒一切。
- 如果你正要新建站、或受够了 PHP-FPM 配置文件分散、或一台机器上跑着多种语言想统一管理,Unit 值得一试——它的配置集中、热加载、多语言统一,确实能省心。
- 迁移时可以先并行:Nginx 加一个测试 location 把部分请求转给 Unit,验证没问题再逐步切全量。Unit 和 PHP-FPM 可以同时跑在同一台机器,互不干扰。
Unit 的意义不在于「又冒出一个新服务器」,而在于它把「配置应用怎么跑」这件事变得前所未有的直接:一份 JSON、一个控制接口、改完即生效。对一个人维护服务器、没时间在好几套配置文件之间来回对照的站长来说,这种「简单可维护」本身就是生产力。工具好不好,最后看的不是功能列表有多长,而是它让你少踩多少坑、少花多少时间。Unit 在这一点上,交出了让人愿意一试的答卷。