NGINX Unit 应用服务器

讲解 NGINX Unit 的进程模型与配置体系,覆盖 controller 与 router 的分工、多语言应用定义、路由匹配与监听器配置、通过控制套接字实现动态重载与零停机,以及与 NGINX 反向代理配合时的部署边界、可观测性与排错方法。

NGINX Unit 是 NGINX 官方推出的多语言应用服务器,它把「进程管理」和「请求路由」两件事从应用框架里抽离出来,交给常驻进程统一编排。对已经用 NGINX 做反向代理的团队而言,Unit 提供了一条不必重写业务代码就能获得动态重载、多语言混部与细粒度路由的路径。本文从 Unit 的进程模型讲起,展开应用与路由配置、动态重载的零停机机制,以及它与 NGINX 反代配合时的边界与坑点。

1. Unit 的定位与进程模型

Unit 要解决的问题很具体:传统应用服务器把路由、静态文件、进程守护、热重载全部塞进框架内部,语言一旦确定就很难更换;而反向代理层又拿不到应用级的语义,只能按 URL 前缀转发。Unit 把中间这层独立出来,形成一条清晰的分工链。

Unit 启动后主要有三类进程:

  • controller 进程:唯一的配置管理者,读取控制套接字上的 JSON 配置,负责解析、校验、落盘与下发。它本身不处理任何业务流量。
  • router 进程:负责监听端口、解析 HTTP 请求、执行路由匹配,然后把请求通过内部套接字转发给具体的应用进程。
  • 应用进程:由 router 按需拉起,每种语言对应一个运行时适配层(Python、Node.js、PHP、Ruby、Java、Go、Perl 等)。

这个模型的关键收益是:配置变更不需要重启监听端口。controller 把新配置编译成内部结构后通知 router,router 用新的路由表处理后续请求,老的应用进程处理完在途请求后自然退出。这与 nginx -s reload 的「新老 worker 交替」在思想上一致,但粒度更细——Unit 可以只重启某个应用,而不影响同一监听器上的其他应用。

一个容易被忽略的细节:Unit 的 router 和应用进程之间走的是 Unix 域套接字或抽象命名空间套接字,不走 TCP 回环。这减少了协议栈开销,也让应用进程可以完全隐藏在网络命名空间之外,只暴露 router。

1.1 与 NGINX 反代的职责划分

很多人会问:既然 Unit 自己就能监听 443、做 TLS、做路由,那 NGINX 还留在前面做什么?答案是两者的强项不同。

能力NGINX 反代Unit
缓存与压缩完整(proxy_cache、gzip/brotli)基础(静态文件、少量响应头)
限流与 WAF完整(limit_req、ModSecurity)无
应用级路由需手工写 location原生支持(routes 匹配)
多语言进程管理无原生支持
动态重载配置级应用级

结论是常见的双层架构:NGINX 处理边缘关注点(TLS 终结、缓存、限流、日志),Unit 处理应用关注点(进程、路由、语言运行时)。这样 NGINX 侧的配置非常稳定,几乎只在证书轮换时变动;而应用的增删改由 Unit 的控制 API 完成,可以交给 CI 流水线自动化。

2. 安装与最小可用配置

Unit 的安装包由官方仓库提供,核心是 unit 主程序和每种语言的模块包(如 unit-python3.11、unit-node20)。只有装了对应模块,才能在配置里声明那种语言的应用。

# Debian/Ubuntu
sudo apt install unit unit-python3.11 unit-node20

# 确认控制套接字存在
ls -l /var/run/unit/control.sock

# 查看当前完整配置
sudo curl --unix-socket /var/run/unit/control.sock \
  http://localhost/config

所有配置都通过一个 HTTP-over-Unix-socket 的接口读写。GET /config 读取全量配置,PUT /config/<path> 更新某个子树。这种「JSON 即配置」的设计让配置本身可以被程序生成,也天然适合做版本管理。

最小可用配置只需要三块:listeners、applications、routes。先看一个能跑起来的版本。

{
  "listeners": {
    "*:8080": {
      "pass": "applications/demo"
    }
  },
  "applications": {
    "demo": {
      "type": "python",
      "path": "/srv/app/",
      "module": "wsgi",
      "processes": 4
    }
  }
}

pass 指向 applications/demo,意思是监听器把请求直接交给这个应用。processes 是每个应用进程内部的线程数上限(Unit 用多进程 + 每进程多线程模型),而不是进程总数;进程总数由 router 根据负载动态调整。这一点经常被误解,配成 "processes": 64 后反而因为内存膨胀导致 OOM。

2.1 配置的原子性与落盘

Unit 在 PUT 时会先做完整校验:类型是否存在、路径是否可读、语言模块是否加载。校验失败返回 400 并附带具体错误,旧配置保持不变。校验通过后新配置写入磁盘(/var/lib/unit/conf.json),再通知 router 生效。因此不存在「配置写了一半服务挂掉」的中间态。

# 只更新 demo 应用的 processes 字段
sudo curl -X PUT --unix-socket /var/run/unit/control.sock \
  -d '8' \
  http://localhost/config/applications/demo/processes

# 删除某个应用
sudo curl -X DELETE --unix-socket /var/run/unit/control.sock \
  http://localhost/config/applications/demo

3. 应用定义:多语言运行时

每种语言的 applications 定义大同小异,差异集中在入口点声明上。

3.1 Python 与 WSGI/ASGI

Python 应用需要声明 module(模块名)和 callable(可调用对象名)。WSGI 与 ASGI 都用同一个 type: python,区别在 module 指向的入口。

{
  "applications": {
    "api": {
      "type": "python",
      "path": "/srv/api/",
      "module": "asgi",
      "callable": "app",
      "processes": 4,
      "environment": {
        "PYTHONPATH": "/srv/api",
        "DJANGO_SETTINGS_MODULE": "config.settings.prod"
      }
    }
  }
}

path 会被加入模块搜索路径,environment 里的变量在应用进程启动时注入。注意 processes 语义是线程数,Python 受 GIL 限制,线程数对 CPU 密集任务收益有限,更适合用多进程部署(多个 Unit 实例或用 limits 分片)。

3.2 Node.js 与 PHP

Node.js 应用的入口是一个 CommonJS 模块,导出 { fetch } 或 { request, response } 形式的处理函数。Unit 会为每个 worker 线程加载一次模块,模块顶层代码因此会被执行多次,不要在顶层做全局状态初始化。

{
  "applications": {
    "web": {
      "type": "node",
      "path": "/srv/web/",
      "module": "app.js",
      "processes": 8
    }
  }
}

PHP 应用则是按 script 指定入口脚本,每个请求一个独立的 PHP 进程(或复用进程池),语义更接近传统 FPM。

{
  "applications": {
    "phpapp": {
      "type": "php",
      "root": "/srv/php/",
      "script": "index.php",
      "index": "index.php",
      "processes": 4
    }
  }
}

3.3 应用级隔离与资源限制

limits 子对象可以限制请求体大小、超时与并发:

{
  "applications": {
    "api": {
      "type": "python",
      "path": "/srv/api/",
      "module": "asgi",
      "limits": {
        "timeout": 30,
        "requests": 5000,
        "request_body_size": 10485760
      }
    }
  }
}

timeout 是应用处理单请求的硬上限,超时后 Unit 直接断开并记录日志;requests 限制单进程处理多少请求后主动重启,用来规避内存泄漏累积。这两个参数是 Unit 相对传统框架的独特价值——语言无关的资源护栏。

4. 路由与监听器

当同一个监听器后面挂着多个应用时,需要 routes 做匹配分发。

4.1 匹配规则

routes 是一个有序数组,逐条匹配,命中即停。每条规则可以有 match(匹配条件)和 action(动作)。

{
  "listeners": {
    "*:8080": {
      "pass": "routes"
    }
  },
  "routes": [
    {
      "match": {
        "uri": "/api/*"
      },
      "action": {
        "pass": "applications/api"
      }
    },
    {
      "match": {
        "host": "admin.example.com",
        "uri": ["/", "/dashboard/*"]
      },
      "action": {
        "pass": "applications/admin"
      }
    },
    {
      "action": {
        "pass": "applications/fallback"
      }
    }
  ]
}

match 支持 uri、host、method、scheme、headers、arguments、cookies、source(客户端 IP 段)等条件,多个条件之间是 AND 关系,同一个条件的数组值是 OR 关系。uri 支持 * 通配与 ! 取反(如 "uri": ["!*.php"]),这让迁移 PHP 应用时排除敏感路径变得简单。

最后一条不带 match 的规则是兜底,等价于 NGINX 里的 location /。漏写兜底会导致未匹配请求返回 404,这是新手最常见的排错点。

4.2 监听器与 TLS

Unit 原生支持 TLS 终结,证书以 PEM 形式通过 API 上传,之后用名字引用:

sudo curl -X PUT --unix-socket /var/run/unit/control.sock \
  --data-binary @fullchain.pem \
  http://localhost/config/certificates/example/bundle

sudo curl -X PUT --unix-socket /var/run/unit/control.sock \
  --data-binary @privkey.pem \
  http://localhost/config/certificates/example/key
{
  "listeners": {
    "*:443": {
      "pass": "routes",
      "tls": {
        "certificate": "example",
        "session": {
          "cache_size": 8000,
          "timeout": 300
        }
      }
    }
  }
}

证书热更新只需要重新 PUT bundle 内容,不需要改配置也不需要重载,这对证书自动化非常友好。不过要注意:Unit 的 TLS 能力比 NGINX 弱,不支持 OCSP stapling 的细粒度配置,也不支持 SNI 动态证书的多级回退,因此生产环境更推荐在 NGINX 层终结 TLS,Unit 只监听内部明文端口。

5. 动态重载与零停机

Unit 的配置变更天然是热生效的,但「热生效」不等于「零停机」,理解差异很重要。

5.1 重载时到底发生了什么

当 PUT 更新 applications/api 时,router 的行为是:

  1. 按新配置预启动一批新应用进程(预热);
  2. 新请求路由到新进程;
  3. 老进程停止接收新请求,处理完在途请求后退出;
  4. 超过 limits.timeout 仍未完成的老进程被强制回收。

因此零停机的条件是:新进程能在老进程被回收前完成预热。如果应用启动需要加载大模型或建立大量连接,就会出现请求打到未就绪进程上的情况。缓解手段是配置 ready_timeout 并配合外部探针。

5.2 滚动与回滚

由于配置就是 JSON,回滚等价于把旧 JSON 再 PUT 回去:

# 发布前先备份
sudo curl --unix-socket /var/run/unit/control.sock \
  http://localhost/config > /etc/unit/backup-$(date +%s).json

# 回滚
sudo curl -X PUT --unix-socket /var/run/unit/control.sock \
  -d @/etc/unit/backup-1700000000.json \
  http://localhost/config

需要注意 PUT /config 是全量替换,会删掉不在 JSON 里的所有对象(包括证书)。更安全的做法是只 PUT 变化的子树,例如 PUT /config/applications/api,这样证书、其他应用都不受影响。

6. 与 NGINX 反向代理配合

生产推荐拓扑是 NGINX 在前、Unit 在后。NGINX 侧配置非常薄:

upstream unit_backend {
    server 127.0.0.1:8080;
    keepalive 64;
}

server {
    listen 443 ssl;
    http2 on;
    server_name api.example.com;

    ssl_certificate     /etc/nginx/certs/fullchain.pem;
    ssl_certificate_key /etc/nginx/certs/privkey.pem;

    client_max_body_size 10m;

    location / {
        proxy_pass http://unit_backend;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        proxy_read_timeout 60s;
        proxy_connect_timeout 5s;
    }
}

几个要点:

  • 开启 upstream keepalive 并设置 proxy_http_version 1.1 + Connection "",否则每条请求都新建连接,Unit 侧会看到大量短连接。连接复用与健康检查的细节可参考 Nginx upstream keepalive 与健康检查 。
  • 透传真实 IP:Unit 侧的 source 匹配依赖 X-Forwarded-For 或 PROXY protocol。若在 Unit 里配了 "proxy_protocol": true,则监听器必须开启 PROXY protocol,NGINX 侧要相应设置 proxy_protocol on。
  • 不要在两层都做压缩:Unit 的静态文件响应若已带 Content-Encoding,NGINX 侧再 gzip 会重复压缩,需用 gzip_proxied 配合判断。
  • 路由下沉:把「按路径分发」交给 Unit 的 routes,把「按域名/TLS 分发」留给 NGINX,避免两边规则打架。微服务场景下的动态路由可参考 Nginx 动态路由与微服务 。

6.1 常见坑

  • Unit 不识别 X-Forwarded-For 里的多级代理,默认取最后一跳。若中间有 CDN,需要显式配置信任链。
  • 请求体大小两层都要设:NGINX 的 client_max_body_size 拦不住直接访问 Unit 端口的流量,Unit 侧也要设 limits.request_body_size。
  • Unix socket 权限:控制套接字默认只允许 root 访问,CI 里以普通用户调用会得到 403。

7. 可观测性与排错

Unit 的日志默认走 syslog 或文件,可通过配置调整:

{
  "settings": {
    "http": {
      "log_route": true,
      "server_version": false
    }
  }
}
  • log_route: true 会在访问日志中记录命中的路由,排查「为什么请求走到了错误应用」时非常有用。
  • server_version: false 关闭 Server 响应头中的版本号,减少指纹泄露。

应用侧的 stdout/stderr 会被 Unit 捕获并写入配置的日志文件,路径在 applications.<name>.stderr 之类的字段里指定。若应用崩溃,Unit 会在日志里打印退出码与重启次数,配合 processes 与 requests 限制可以定位是 OOM 还是代码异常。

调试时最常用的三条命令:

# 1. 看当前生效配置
sudo curl --unix-socket /var/run/unit/control.sock http://localhost/config

# 2. 看监听器状态
sudo curl --unix-socket /var/run/unit/control.sock http://localhost/status

# 3. 直接对应用进程发请求(绕过 router),确认应用本身是否正常
sudo curl --unix-socket /var/run/unit/control.sock http://localhost/config/applications/api

/status 会返回各监听器的连接数、请求数与应用进程状态,是判断「是 Unit 的问题还是应用的问题」的第一手依据。若 status 显示应用进程数为 0 而请求 502,基本可以确定是应用启动失败——去日志里找 traceback。

8. 总结

NGINX Unit 的价值在于把应用侧的进程与路由管理标准化,让多语言混部和动态重载不再依赖框架各自的热重载实现。它最适合的场景是:已经有 NGINX 边缘层、应用以 WSGI/ASGI 或 Node.js 为主、希望把部署配置收敛成可程序化生成的 JSON。

它不适合的场景同样明确:需要复杂缓存与 WAF 的边缘服务、需要精细 TLS 控制的合规环境、以及重度依赖框架中间件生态的项目。把这些留在 NGINX 或应用框架里,Unit 只做它擅长的那一层,整体架构反而更简单。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「nginx」更多文章

  1. NGINX 容器镜像精简与加固
  2. 多租户虚拟主机与配置生成
  3. 从 Apache 与 Traefik 迁移到 NGINX