1. 为什么这套方案值得亲手折腾一次
源码编译安装 nginx,这件事在 Linux 运维圈里属于"入门级动作、高手级细节"。标题说的是在 Red Hat 系的企业级 Linux 上用源码编译安装 nginx,但真正决定成败的从来不是./configure && make && make install这三行命令能不能跑通——这套流程十分钟就能背下来——而是编译参数怎么定、依赖走哪条路、装完之后怎么托管成系统服务、以及日后升级怎么不把线上业务搞崩。我见过太多机器上/usr/local/nginx目录存在、二进制也能启动,但 SSL 模块没编进去、HTTP/2 用不了、日志切分靠手写脚本、升级一次停机二十分钟的情况。
这篇内容面向三类人:一是刚接手 Linux 服务器、需要把 nginx 装在自己指定路径下的新手;二是被软件包里 nginx 版本老、模块不全折腾过、想拿回编译主动权的中级运维;三是需要给业务方一个"可解释、可复现、可回滚"的部署方案、却不想每次都翻官方文档的负责人。我会把参数选择背后的理由、并行编译的机器资源换算、systemd 托管的几个易错字段、平滑升级的信号机制,以及那些文档里不会写的坑,全部摊开讲一遍。
先说清楚一件事:源码编译不是"更高级",它只是"更可控"。包管理装出来的 nginx 是别人替你做的决策集合,源码编译是你自己做决策,代价是每个决策都得自己担责任。理解这一点,后面的所有选择才有意义。
1.1 源码编译和包管理的真实差距在哪
很多人以为源码编译的唯一好处是"版本新",其实版本只是最表层的东西。我更看重的是三件事:模块的确定性、路径的确定性、以及升级路径的确定性。
第一是模块确定性。发行版仓库里的 nginx 为了通用性,往往把模块拆成一堆 subpackage,比如某个-mod-http-*包、某个 stream 相关的独立包。你要用--with-stream_ssl_preread_module做四层 TLS 前置识别,或者用--with-http_auth_request_module做子请求鉴权,经常会遇到"这个模块在这个发行版版本里根本没打包"的窘境。源码编译时,模块是你写在configure参数里的,跑完nginx -V能完整打印出编译时的全部开关,这种"我说了算"的确定感,在排查线上诡异问题时价值极高。
第二是路径确定性。包管理版本默认把配置放在/etc/nginx、日志放在/var/log/nginx、二进制放在/usr/sbin,并且这些路径被写死在编译期。当你需要和某个既有目录规范对齐,或者需要把 nginx 装到一个独立分区、独立用户下做隔离时,包管理版本会让你很别扭。源码编译用--prefix一套参数就能把二进制、配置、日志、pid 文件、锁文件全部收拢到一个目录树里,迁移和备份都是整目录拷贝,干净利落。
第三是升级路径确定性。包管理升级是"换二进制 + 保留配置",但你没法控制旧进程什么时候退出,也没法在一个进程里同时跑两套配置做灰度。源码编译的平滑升级机制(USR2 + WINCH 那套信号)允许你在不中断连接的前提下把新二进制换上去,发现有问题再用老 master 进程回滚——这套能力在关键业务窗口期是真能救命的。
| 对比维度 | 发行版包管理安装 | 源码编译安装 |
|---|---|---|
| 版本获取速度 | 跟随发行版生命周期,通常滞后 | 可以精确到某个次版本 |
| 模块可选性 | 受限于官方打包清单 | 写什么参数就有什么模块 |
| 安装路径 | 分散在/etc、/var、/usr | 统一收敛到--prefix目录 |
| 升级方式 | 替换二进制,重启生效 | 支持信号驱动的平滑升级和回滚 |
| 运维成本 | 低,dnf update一把梭 | 高,需要自己写作 unit、日志切分 |
| 安全补丁跟进 | 发行版负责回溯移植 | 需要自己盯上游 changelog |
看这张表就知道,源码编译不是无脑更优,它是一个"用运维投入换控制力"的交易。如果你只有三台边缘小机器、跑的是纯静态资源,包管理版本完全够用,别为了折腾而折腾。但如果你的机器承载着网关、鉴权、四层转发这类不能随便重启的角色,那这套折腾就非常值得。
1.2 我在什么场景下会毫不犹豫选源码
判断标准其实很简单:当你需要的东西在包管理版本里"刚好缺那么一点"的时候,就是转向源码的时机。我总结了几种典型情况。
需要特定三方模块或非默认内置模块。比如要给静态资源加 br 压缩、要做 Lua 级别的动态逻辑、要用特定的地理位置库做区域判断,这些通常不在发行版默认打包范围内。即便有,版本耦合也常常让人抓狂。
需要固定一个跨机器完全一致的编译产物。批量部署场景下,几百台机器的 nginx 必须是同一个二进制、同一套模块、同一份编译参数,否则出问题时你连"是不是编译差异导致的"这个变量都排除不了。源码编译加统一打包分发,是解决这个问题的标准做法。
需要跑比发行版新两个大版本的特性。举个具体的例子,HTTP/2 的指令写法在较新版本里从listen 443 ssl http2改成了独立的http2 on,早期版本还要求 OpenSSL 1.0.2 以上才支持 ALPN。如果你在新老版本混用的环境里想统一配置写法,编译时的 OpenSSL 版本就成了硬约束,包管理版本给不了你这个自由度。
提示:不要因为"源码编译显得专业"就上源码。先问自己一句——包管理版本缺的到底是哪个模块、哪个版本、哪个路径?如果答不上来,说明你暂时不需要源码编译。
2. 动手前的环境盘点与依赖准备
编译这活儿,八成的时间浪费在依赖上。Red Hat 系现在的版本谱系跨度不小,7 系用yum,8 系和 9 系用dnf,而 8 系之后又引入了模块化仓库(AppStream/BaseOS 分离),dnf groupinstall "Development Tools"装出来的东西和 7 系的yum groupinstall不完全一致。我建议先花两分钟把环境摸清楚,别上来就敲命令。
先用cat /etc/os-release确认发行版主版本号,用uname -r看内核版本,用getconf LONG_BIT确认是 64 位,再用df -h /usr/local看看目标分区还有多少空间——源码目录加上编译中间产物,一个完整编译过程吃掉 300MB 到 500MB 是很正常的,如果你还要把 OpenSSL、PCRE2、zlib 的源码一起编进去,留 2GB 更保险。最后用free -h看内存,这个数字直接决定后面make -j能开多大并行度。
2.1 编译工具链与三类库的取舍
nginx 本体只依赖 C 编译器,但你要的绝大多数功能都挂在外部队库上。核心是三样:PCRE2(正则,location里的正则匹配、rewrite全靠它)、zlib(gzip 压缩)、OpenSSL(HTTPS)。此外还有一些可选依赖:libxslt用于 XSLT 模块,gd用于图片过滤模块,perl用于嵌入式 Perl。
安装基础工具链的命令,8 系和 9 系这样写:
dnf install -y gcc gcc-c++ make automake autoconf libtool dnf install -y pcre2-devel zlib-devel openssl-devel dnf install -y libxslt-devel gd-devel perl perl-devel perl-ExtUtils-Embed7 系把dnf换成yum,把pcre2-devel换成pcre-devel即可。这里有个细节值得说一下:nginx 从 1.21 版本之后默认走 PCRE2,早期版本走 PCRE1,如果你编译的是老版本 nginx 却装了pcre2-devel,configure 阶段会报找不到 pcre 库,这个坑我第一次踩的时候排查了半小时。
然后是几个"看起来是工具、实际是必需"的包。redhat-rpm-config提供了一堆编译宏定义,某些版本上缺了它会在 configure 阶段报奇怪的错误;libuuid-devel在你要用--with-http_dav_module之类的模块时可能被间接引用。稳妥起见,我习惯在编译前跑一次dnf groupinstall -y "Development Tools",把整个开发组装上,反正是编译机,多装点没坏处。
注意:不要在生产容器里装完整开发工具链。常规做法是在一台专门的构建机上编译好,把二进制打包成 rpm 或者 tar 包分发到目标机器,目标机器只需要运行时库。这样既安全又省资源。
2.2 目录规划与专用账号
这一步很多人跳过,然后一年后回来看发现/usr/local/nginx下面堆着历史版本的备份、残留的 pid 文件、手动切的日志。我的习惯是先定好目录骨架再动手。
mkdir -p /usr/local/nginx/{conf,sbin,logs,client_body_temp,proxy_temp,fastcgi_temp,uwsgi_temp,scgi_temp} groupadd -r nginx 2>/dev/null useradd -r -g nginx -s /sbin/nologin -M -d /usr/local/nginx nginx 2>/dev/null chown -R nginx:nginx /usr/local/nginx/logs这里几个参数都值得解释。-r表示创建系统账号,UID 落在系统账号区间内,不会和业务用户冲突;-s /sbin/nologin是关键,nginx 进程被攻破后攻击者拿到的是一个不能登录的 shell,这是最小权限原则的落地;-M不创建家目录,因为 nginx 用户不需要家目录;-d /usr/local/nginx把家目录字段指向安装目录,某些场景下(比如~nginx路径展开)会用到。
临时目录那五个(client_body_temp、proxy_temp、fastcgi_temp、uwsgi_temp、scgi_temp)是 nginx 处理大请求体、代理响应时的落盘位置。如果你不预建,nginx 启动时会自己在 prefix 下创建,但权限是按启动用户来的,一旦你后续调整了运行用户,就会出现"临时目录写不进去导致 413 或者 502"的问题。提前建好并统一 owner,能省掉一次深夜排查。
关键是logs目录的属主。很多人把整个 prefix 目录chown -R给 nginx,这在只用 80 端口时没问题;但当你监听 443 时,master 进程是以 root 启动再降权到 nginx 的,日志目录必须让 nginx 用户可写。所以正确做法是:prefix 根目录保持 root 所有,只有logs和五个 temp 目录给 nginx 用户。
| 目录/文件 | 属主 | 权限 | 说明 |
|---|---|---|---|
/usr/local/nginx | root:root | 755 | 安装根目录 |
/usr/local/nginx/sbin | root:root | 755 | 二进制目录,只读 |
/usr/local/nginx/conf | root:root | 750 | 配置文件,普通用户不可读 |
/usr/local/nginx/logs | nginx:nginx | 750 | 日志与 pid,需可写 |
五个*_temp目录 | nginx:nginx | 700 | 临时落盘区 |
3. configure 参数怎么定:一次讲透每个开关
configure是整个编译过程里唯一真正需要动脑的环节,make只是体力活。我把参数分成四类:路径类、身份类、模块类、三方库类。路径类决定装在哪,身份类决定以谁的身份跑,模块类决定能干什么,三方库类决定依赖从哪来。
3.1 路径与身份参数:把安装产物收拢到一处
路径参数的原则只有一个——全部指到同一个 prefix 下,避免二进制在sbin、配置在conf、日志在/var/log这种散落状态。至于为什么不直接用--prefix让它自动推导,原因是推导出来的相对路径在某些打包和迁移场景下会出问题,显式写死更稳。
./configure \ --prefix=/usr/local/nginx \ --sbin-path=/usr/local/nginx/sbin/nginx \ --conf-path=/usr/local/nginx/conf/nginx.conf \ --error-log-path=/usr/local/nginx/logs/error.log \ --http-log-path=/usr/local/nginx/logs/access.log \ --pid-path=/usr/local/nginx/logs/nginx.pid \ --lock-path=/usr/local/nginx/logs/nginx.lock \ --http-client-body-temp-path=/usr/local/nginx/client_body_temp \ --http-proxy-temp-path=/usr/local/nginx/proxy_temp \ --http-fastcgi-temp-path=/usr/local/nginx/fastcgi_temp \ --http-uwsgi-temp-path=/usr/local/nginx/uwsgi_temp \ --http-scgi-temp-path=/usr/local/nginx/scgi_temp \ --user=nginx \ --group=nginx--lock-path容易被忽略。老版本的 nginx 在某些文件系统上会用它做启动互斥,虽然新版本用得少了,但写上无害,而且能避免默认落到/var/lock目录导致的权限麻烦。
--user和--group是编译期默认值,也就是说nginx.conf里如果没有写user指令,就采用这个值。这里有个常见误区:有人以为编译时写了--user=nginx就万事大吉,结果配置里又写了user nobody;,实际生效的是配置文件的。编译参数只提供默认值,配置文件优先级更高。
提示:
--with-cc-opt和--with-ld-opt可以追加编译器和链接器参数。我通常加--with-cc-opt="-O2 -fstack-protector-strong"开启栈保护,这对线上服务是一层低成本的安全加固。
3.2 模块取舍:少即是多,但别砍掉将来要用的
模块参数这里最容易犯两种错误:一是"能加的都加上",编出一堆业务根本用不到的东西,攻击面变大、编译变慢;二是砍太狠,过三个月业务提了个需求,发现得重编。
我的取舍原则是:HTTP 核心功能全开,安全相关全开,性能相关按需,第三方扩展一律不加。
必装的几个,我列一下理由:
--with-http_ssl_module:HTTPS 基础,不加这个listen 443 ssl直接报错。这个没得商量。--with-http_v2_module:HTTP/2 支持,现代浏览器的并发优势全在这。注意较新版本里还需要在listen行或单独指令里启用。它的前提是 OpenSSL 版本足够新,能支持 ALPN。--with-http_realip_module:放在负载均衡后面必须要有它,否则$remote_addr全是前置代理的地址,日志分析和限流全都失真。这是最容易被遗漏、又最难事后补救的模块之一。--with-http_stub_status_module:给监控系统提供/status端点,暴露活跃连接数、累计请求数。没它你根本不知道 nginx 现在扛了多少。--with-http_gzip_static_module:允许直接返回预压缩好的.gz文件,省掉实时压缩的 CPU 开销。--with-http_sub_module:响应内容替换,做域名改写、简单的 HTML 注入时有用。--with-threads:开启线程池,处理大文件静态资源时不阻塞事件循环,磁盘慢的场景下提升明显。--with-stream和--with-stream_ssl_module:四层转发能力,做数据库代理、TCP 负载均衡必须要有。这一块很多人不装,等到要给数据库做前置代理时才发现得重编。
不建议无脑加的:--with-http_dav_module(WebDAV 有安全隐患,除非业务明确需要)、--with-http_perl_module(嵌入 Perl,维护成本高、性能不如 FastCGI)、--with-debug(会显著降低性能,只在排查问题时临时编一个)。另外地理位置库类模块在新版本里已经从 MaxMind 老库迁移到别的方案,参数写法变了,按你用的 nginx 版本对应文档走,别抄老教程。
3.3 三方库的三条路线:系统库、源码目录、静态链接
这是最体现功力的一环。nginx 对 PCRE2 / zlib / OpenSSL 的引用有两条路:一是链接系统已安装的.so,二是让 nginx 自己把这三个库编进二进制。
路线的选择标准是:要不要特定的 OpenSSL 版本。PCRE2 和 zlib 走系统库完全够用,版本差异影响很小。但 OpenSSL 不一样。如果你的系统发行版自带的 OpenSSL 版本偏老,不支持 TLS 1.3,或者你想开启某些特定算法,那就必须用--with-openssl=指定源码目录,让 nginx 编译时把它们一起编进去。
# 准备源码目录 cd /usr/local/src tar -xzf openssl-3.x.tar.gz tar -xzf pcre2-10.x.tar.gz tar -xzf zlib-1.3.x.tar.gz # 在 nginx configure 中指定 ./configure \ ... \ --with-openssl=/usr/local/src/openssl-3.x \ --with-openssl-opt="enable-tls1_3 no-shared" \ --with-pcre=/usr/local/src/pcre2-10.x \ --with-zlib=/usr/local/src/zlib-1.3.x这里--with-openssl-opt里的no-shared值得说一句:它让 OpenSSL 只产出静态库,nginx 会把静态库链接进去。好处是编译出来的 nginx 二进制自包含,换机器不用担心中间库版本不一致;代价是二进制体积变大,而且 OpenSSL 出安全公告时必须重新编译 nginx 才能修补。这是一个明确的权衡——追求部署一致性就选静态,追求补丁响应速度就选动态链接系统库。
用--with-*指定源码目录还有个副作用要注意:nginx 是用你指定的目录去编译,而不是去装一个库到系统里。所以目录名必须是解压后的顶层目录,且里面要能找到Configure(OpenSSL)或configure(PCRE2/zlib)脚本。如果目录名写错了或者解压层级多套了一层,configure 阶段会报找不到文件,错误信息还相当晦涩。
4. 编译、安装与验证的完整流水线
4.1 并行度与内存的换算关系
make -j N的 N 不是随便拍的。每个 gcc 编译进程编译 nginx 的单个源文件时,峰值内存大约在 100MB 到 200MB 之间,OpenSSL 的部分源文件吃内存更多,能到 250MB 上下。所以并行度的上限大致是:可用内存(GB)除以 0.3,再和 CPU 核数取较小值。
举两个具体例子。一台 2 核 2GB 的机器,2 ÷ 0.3 约等于 6,跟核数取小是 2,所以-j 2最稳;如果开-j 4,编译 OpenSSL 那几个大文件时极容易触发 OOM Killer,表现出来是gcc: Killed signal terminated program cc1,很多人以为是代码问题,其实是内存爆了。一台 8 核 16GB 的机器,16 ÷ 0.3 约 53,跟核数取小是 8,-j 8完全没问题。
# 稳妥的写法:自动取核数,但不超过 8 JOBS=$(nproc) [ "$JOBS" -gt 8 ] && JOBS=8 make -j"$JOBS" make install编译时间参考:4 核机器上,不含 OpenSSL 源码时大约 1 到 2 分钟;带上 OpenSSL 源码大约 4 到 6 分钟。如果超过十分钟还没进展,多半是卡在某个依赖探测上,看一眼objs/autoconf.err能知道详情。
还有个小技巧:如果你只是改配置、不换版本,不需要重新编译。只有模块增减、编译参数变化、版本升级才需要重来。日常调整nginx.conf用nginx -s reload就够了,很多人以为改了配置就得重编,那是误解。
4.2 装完必须做的五项自检
make install跑完 banner 打印出来不代表成功。我有一套固定的自检清单,五项都过才算落地。
第一项,看版本和编译参数。/usr/local/nginx/sbin/nginx -V会打印nginx version、built by、configure arguments三行。重点核对 configure arguments 里该有的模块是不是都在,特别是我上面强调的 realip、stub_status、ssl、v2、stream。
第二项,测配置文件语法。/usr/local/nginx/sbin/nginx -t -c /usr/local/nginx/conf/nginx.conf。-t会做语法检查和文件可达性检查,输出syntax is ok和test is successful。注意这一步会尝试打开日志文件,如果 logs 目录权限不对,此时就会暴露出来。
第三项,前台试跑。/usr/local/nginx/sbin/nginx -g "daemon off;"让它在终端里跑,能看到启动日志实时输出。按 Ctrl+C 停掉。这一步是为了确认端口能绑上、pid 文件能写、用户能切换。如果 80 端口被占用(比如系统自带的 httpd 或者已经有一个 nginx 在跑),会报bind() to 0.0.0.0:80 failed (98: Address already in use),用ss -lntp | grep :80定位占用进程。
第四项,验证模块真的可用。光看-V输出不够,得让配置真正引用来验证。比如写一个临时配置片段启用stub_status,curl一下看有没有返回数据;比如listen 443 ssl后nginx -t不报错,说明 SSL 模块确实编进去了。
第五项,检查动态依赖。ldd /usr/local/nginx/sbin/nginx会列出它依赖的所有.so。如果里面有not found,说明运行环境缺库,这在你把二进制拷到另一台机器时尤其常见。这也是我前面建议静态链接 OpenSSL 的原因——少一个依赖就少一个出问题的点。
/usr/local/nginx/sbin/nginx -V /usr/local/nginx/sbin/nginx -t /usr/local/nginx/sbin/nginx -g "daemon off;" # 前台跑一下,Ctrl+C 退出 ldd /usr/local/nginx/sbin/nginx | grep -i "not found"自检清单过完,基本可以确认编译本身的正确性。接下来才是真正影响长期运维体验的部分:怎么让它以系统服务的方式被管理。
5. 让服务真正可用:systemd 托管与日志治理
5.1 手写 unit 文件时那几个不能错的字段
源码安装最大的缺口就是没有 service 文件。systemctl start nginx报Unit nginx.service not found,这个体验对新手很不友好。自己写一个 unit 文件,内容不多,但几个字段写错就会出各种诡异问题。
[Unit] Description=nginx - high performance web server Documentation=https://nginx.org/en/docs/ After=network-online.target remote-fs.target nss-lookup.target Wants=network-online.target [Service] Type=forking PIDFile=/usr/local/nginx/logs/nginx.pid ExecStartPre=/usr/local/nginx/sbin/nginx -t -c /usr/local/nginx/conf/nginx.conf ExecStart=/usr/local/nginx/sbin/nginx -c /usr/local/nginx/conf/nginx.conf ExecReload=/bin/kill -s HUP $MAINPID ExecStop=/bin/kill -s QUIT $MAINPID TimeoutStopSec=5 KillMode=mixed PrivateTmp=true LimitNOFILE=65535 Restart=on-failure RestartSec=3 [Install] WantedBy=multi-user.target逐条说几个关键点。
Type=forking配合PIDFile是必须的。nginx 默认以守护进程方式启动,master 进程 fork 之后父进程就退出了,systemd 需要靠 PIDFile 才能追踪到真正的 master。如果你把Type写成simple,systemd 会认为启动命令一返回服务就"退出"了,然后不停重启。
ExecStartPre里的-t是保险丝。有它在,配置文件语法错误时systemctl start会直接失败并在journalctl -u nginx里显示原因,而不是启动到一半进程没了。这个设计我强烈建议保留,能让故障暴露在启动阶段而不是运行阶段。
ExecReload=/bin/kill -s HUP $MAINPID让systemctl reload nginx等价于优雅重载配置,不中断现有连接。注意要写绝对路径/bin/kill,因为 systemd 的 PATH 环境和 shell 里不一样,只写kill可能找不到。
KillMode=mixed是个细节但重要。默认的control-group会在停止服务时把整个 cgroup 里的进程全部 SIGTERM,包括 master 和所有 worker,可能导致正在处理的请求被硬中断。mixed模式下 SIGTERM 只发给主进程(master),由 master 自己去优雅关闭 worker,而 SIGKILL 才会发给整个组。配合TimeoutStopSec=5,超过 5 秒还没优雅退出就强制杀。
LimitNOFILE=65535解决文件描述符上限问题。系统默认nofile常常是 1024,nginx 在并发上来之后会出现worker_connections are not enough或者Too many open files。这里的数值和后面worker_connections的乘积关系要在脑子里有数。
注意:
worker_processes * worker_connections是理论上限的并发连接数,但实际受制于两个东西:一是单个 worker 进程的 fd 上限(LimitNOFILE),二是系统全局的fs.file-max。如果worker_connections 10240而LimitNOFILE只有 1024,那实际只能开 1024 个,配置写了也白写。这是我见过的最高频的"配了没用"现象。
配置写完执行三步走:
systemctl daemon-reload systemctl enable --now nginx systemctl status nginx --no-pager5.2 端口放行、SELinux 与日志切分
服务起来了不代表外部能访问。Red Hat 系有两个门禁:防火墙和 SELinux,前者拦网络包,后者拦进程行为,两个都会让你对着一个"服务明明在跑但就是连不上"的页面发呆。
防火墙这边,先确认firewalld是否在运行,然后按需放行:
firewall-cmd --permanent --add-service=http firewall-cmd --permanent --add-service=https firewall-cmd --reload firewall-cmd --list-all如果你把 nginx 监听在 8080 这类非标准端口,不能只加端口,还得处理 SELinux。SELinux 默认只允许 web 进程绑定带http_port_t标签的端口(通常是 80、443、8080 之外的一批)。监听新端口会报Permission denied,error.log里能看到bind() to 0.0.0.0:8888 failed (13: Permission denied)。解决办法是给端口打上正确的标签:
semanage port -a -t http_port_t -p tcp 8888 semanage port -l | grep http_port_t如果 nginx 需要主动往外发请求(做反向代理到后端),还要开一个布尔值:setsebool -P httpd_can_network_connect 1。不开的话,反向代理会 502,错误日志里是connect() failed (13: Permission denied) while connecting to upstream。这个报错文案非常容易误导人去查后端服务,实际上是本机 SELinux 拦的。
日志切分这件事,我自己定过规矩:源码安装的 nginx 必须配 logrotate,不允许出现单个 access.log 长到几个 G 才发现的情况。包管理版本自带/etc/logrotate.d/nginx,源码版本得自己写。
/usr/local/nginx/logs/*.log { daily rotate 30 missingok notifempty compress delaycompress sharedscripts postrotate /bin/kill -USR1 $(cat /usr/local/nginx/logs/nginx.pid 2>/dev/null) 2>/dev/null || true endscript }这里的关键是postrotate里的USR1信号。它的语义是"重新打开日志文件",nginx 收到后会关掉旧 fd、按配置路径重新打开,这样 logrotate 改名后的旧文件才能真正被释放,新日志写到新文件里。如果你用copytruncate模式(先拷再清空),在超高并发下可能丢掉清空瞬间写入的日志,所以我偏好信号重开的方式。
rotate 30是保留 30 天,compress开启 gzip 压缩归档,delaycompress让最近一份归档先不压(避免和正在写的进程抢文件)。missingok和notifempty是两个安全性开关,防止日志文件不存在时误报错、防止空日志占位置。
6. 平滑升级与回滚:源码安装的最大红利
6.1 信号驱动的热升级到底怎么走
这是源码编译相对包管理最有价值的能力,也是最多人不敢碰的部分。其实流程拆开看就五步,理解了每一步的信号语义,心里就有底了。
前置动作是记录当前编译参数,一定要记,因为它决定了新二进制能不能和老进程共存:
/usr/local/nginx/sbin/nginx -V 2>&1 | tee /usr/local/nginx/backup/nginx-V-$(date +%F).txt然后编译新版本,不要执行 make install,因为 install 会覆盖正在运行的二进制。只把objs/nginx拿出来放到旁边:
cd /usr/local/src/nginx-<新版本> ./configure <照抄上面记录的全部参数> make -j"$(nproc)" cp objs/nginx /usr/local/nginx/sbin/nginx.new验证新二进制能正常解析现有配置:
/usr/local/nginx/sbin/nginx.new -t -c /usr/local/nginx/conf/nginx.conf通过后开始热替换。第一步,给正在运行的 master 发USR2信号。master 收到后会做三件事:把老 pid 文件重命名为nginx.pid.oldbin,然后用新二进制启动一个新的 master 和一个新的 worker 组。此时新老两套进程同时存在,都在监听同一个端口(因为 listen 的 fd 被子进程继承),新连接会由内核分配到两套进程上。
kill -USR2 $(cat /usr/local/nginx/logs/nginx.pid) sleep 2 ps -ef | grep "nginx:" | grep -v grep第二步,确认新进程正常后,给老 master 发WINCH信号。老 master 收到后会让自己的 worker 优雅退出(处理完当前请求再退),但老 master 本身还留着。这一步之后,所有流量都由新进程处理。
kill -WINCH $(cat /usr/local/nginx/logs/nginx.pid.oldbin)第三步,观察一段时间(我一般看五分钟到半小时,视业务量而定),确认新版本没问题,给老 master 发QUIT,彻底退出。
kill -QUIT $(cat /usr/local/nginx/logs/nginx.pid.oldbin)回滚路径正好相反,这也是为什么要留着老 master 不立刻杀。如果新版本出问题,给老 master 发HUP让它重新拉起 worker,再给新 master 发QUIT让它退出。整个过程中连接不中断,因为端口 fd 一直在。
# 回滚:先唤醒老 master 的 worker,再让新 master 退出 kill -HUP $(cat /usr/local/nginx/logs/nginx.pid.oldbin) kill -QUIT $(cat /usr/local/nginx/logs/nginx.pid)提示:热升级前必须确认新二进制的编译参数和老的一致,特别是
--prefix、--pid-path、--lock-path这几个路径参数。如果路径变了,新旧进程会各自操作不同的 pid 文件,信号就发错了对象,那时候场面会比较难看。
6.2 这套机制背后的原理,值得多想一层
为什么能做到连接不中断?核心在于 Unix 的fork语义和文件描述符继承。master 进程在启动时已经bind并listen了端口,得到的是一个监听 socket 的 fd。当 masterfork出 worker 时,worker 继承了这个 fd,于是多个进程可以同时accept同一个监听 socket,内核负责唤醒哪个进程来处理新连接。热升级时,新 master 是从二进制文件重新 exec 出来的,但它并没有自己 bind,而是通过环境变量接收老 master 传过来的监听 fd。所以端口从未被释放,也就没有"断开连接"这个时刻。
这也解释了几个看起来很怪的现象。比如为什么新老进程能同时监听 80 端口——因为没有第二次 bind,而是共享同一个已存在的 socket。再比如为什么升级期间ss -lntp会看到两个进程都持有同一个端口——这是正常的,不是配置错误。
还有一个实践中的坑:如果你在升级前手动systemctl stop nginx,那 master 是被 QUIT 掉的,监听 fd 随之释放,就没法做无缝接管了,只能重启。热升级的前提是"老 master 保持存活"。所以操作顺序上,永远是先起新的、再退老的。
7. 常见问题速查与踩坑实录
7.1 编译阶段的典型报错
编译报错的文案通常很吓人,但绝大多数集中在依赖缺失和路径写错两类。我整理了一张速查表,基本覆盖了日常能遇到的九成情况。
| 报错关键词 | 根本原因 | 处理方式 |
|---|---|---|
the HTTP rewrite module requires the PCRE library | PCRE2 开发包缺失,或版本不匹配 | 装pcre2-devel;老版本 nginx 装pcre-devel |
the HTTP gzip module requires the zlib library | zlib 开发包缺失 | dnf install zlib-devel |
SSL modules require the OpenSSL library | OpenSSL 开发包缺失,或--with-openssl路径不对 | 装openssl-devel,检查路径是否为解压顶层目录 |
C compiler cc is not found | gcc 未装,或 PATH 异常 | 装 gcc,确认which cc有输出 |
./configure: error: invalid option "--with-xxx" | 使用的 nginx 版本不支持该模块 | 对照该版本文档查模块名,老版本某些模块名不同 |
cc1: Killed signal terminated program | 并行度过高导致 OOM | 降低-j数值,或加 swap |
undefined reference to ... | 链接参数缺失,通常是--with-ld-opt没写全 | 补-L路径或减少静态链接 |
PCRE2 library is not found in ... | 用--with-pcre指了目录但目录内无有效源码 | 用--with-pcre时目录必须是源码根目录 |
表里最后一条我印象特别深。当时我图省事,把 PCRE2 的 tar 包解压到了/usr/local/src/pcre2-10.42,但里面还套了一层同名目录,导致--with-pcre=/usr/local/src/pcre2-10.42找不到configure脚本。报错信息只说"不在该目录",没说是层级问题,我盯着路径对了三遍才反应过来。后来养成的习惯是解压完先ls一下这个目录,确认能看到configure、Makefile.in这些文件再往下走。
7.2 运行阶段那些"看着不像 nginx 问题"的问题
运行期的问题更有意思,因为很多报错信息指向的地方和真实原因完全无关。
现象一:systemctl status显示服务 active,但浏览器访问超时。大概率是防火墙没放行,或者云主机还有一层安全组。先本机curl -I 127.0.0.1验证服务本身,再从外部访问验证链路。本机通外部不通,问题一定在网络层,别去翻 nginx 配置。
现象二:反向代理返回 502,错误日志写Permission denied。Red Hat 系上八成是 SELinux。用getenforce确认状态,ausearch -m avc -ts recent能看到被拒绝的具体操作。前面提到的httpd_can_network_connect布尔值是最常见的一条,httpd_can_network_relay在某些场景也需要。
现象三:nginx -t报open() "/usr/local/nginx/logs/error.log" failed (13: Permission denied)。这是权限问题,但要注意 nginx 的检查机制:-t是用当前用户执行的,如果当前是普通用户而日志目录只有 nginx 用户可写,就会报这个。解决办法不是给日志 777,而是用 root 执行-t,或者临时chmod后立刻改回。给日志目录777是个坏习惯,会让同机其他用户能篡改日志。
现象四:业务量上来后error.log刷worker_connections are not enough。需要同时调三处:worker_connections值、systemd 的LimitNOFILE、系统fs.file-max。只改第一处是最常见的无效操作。另外worker_connections的数值是"每个 worker 的连接数",而一个反向代理场景下,一个客户端连接会占用前后各一个 fd(客户端一个、上游一个),所以实际能撑的客户端数是总连接数的一半左右。
现象五:修改配置reload后新配置不生效。先看nginx -t是否通过,再看ps -ef里 master 的启动时间有没有变(如果没变说明 reload 没成功)。还有一种是配置被 include 覆盖了,nginx -T(大写的 T)能打印出所有生效配置,包括 include 进来的,比-t更好用,排查"配置到底先读哪个"的问题非常有效。
现象六:二进制拷到新机器启动报error while loading shared libraries。ldd一看某个.so是not found。这就是动态链接的代价。要么在新机器装齐依赖,要么重新用静态链接 OpenSSL 方式编译。生产上我倾向于后者,一致性问题一次性解决。
7.3 我自己总结的几条经验
第一条,编译参数一定要存档。每台机器上/usr/local/nginx/backup/放一份nginx -V的输出,文件名叫带日期。升级时直接打开抄,不用靠记忆。这个习惯帮我避免过好几次参数漏写导致的模块缺失。
第二条,先小流量验证再全量切。热升级看起来无感,但新版本的配置兼容性、模块行为变化仍然可能出问题。我的做法是先在一台权重低的机器上升级,观察半小时,没问题再滚动到其余机器。
第三条,不要在生产机上长期保留编译源码目录。/usr/local/src/nginx-x.y.z编完就可以清掉,留着除了占空间没别的用。真要回溯,用存档的编译参数加上当前版本号,十分钟就能重编一个一模一样的出来。
第四条,把 nginx 的监控端点纳入基础监控。stub_status输出就三个核心数字:Active connections、accepts/handled/requests。handled比accepts小说明有连接被丢弃,通常是连接数打满了;requests减去handled能看出长连接复用率。这几个数字配上阈值告警,比等业务方打电话过来要有用得多。
说到最后,我个人在实际操作中的体会是:源码编译安装 nginx 这件事,真正的门槛不在编译本身,而在"装完之后你打算怎么运营它"。编译十分钟,运营三年。那十分钟里多花的力气,全都会在之后的每一次升级、每一次排查、每一次扩容里慢慢还回来。我现在给团队定的规范是:凡是承担流量入口角色的机器,一律源码编译,参数存档、systemd 托管、logrotate 配齐、热升级流程演练过一遍——这四条是底线,缺一条就不算装完。