Nginx 配置快速入门

2024-06-01 ⏳14.1分钟(5.6千字)

今天面向初学者分享一篇 Nginx 配置的入门材料。Nginx 安装之后附带的 nginx.conf 篇幅较长,令人望而生畏。初学者限于自身经验不足,很难分清重点。今天我尝试抛开默认配置,从最小的配置出发,带领大家学习从静态 Web 站点到动态 API 网关等各类功能配置。希望能给大家带来一些启发。如果有问题或者想法,欢迎留言讨论。

安装过程在此不作赘述,大家可以使用 yum/apt 之类的包管理器安装,也可以自己下载源码编译安装。如果条件允许,我建议大家从源码编译安装。该方法虽然过程比较繁琐,而且容易出错,但它有一个不小的优点,即可以使用 Nginx 的最新版本。有些功能,比如 HTTP3 之类,只有比较新的版本才支持。发行版自带的 Nginx 以稳定为主,版本通常不会太新。

安装之后先确认自己的版本号,需要用到-V参数。比如我在自己 mac 上装的是 1.27.0。除了版本号,还会显示一些默认路径、编译参数和开启的模块等信息。

$ nginx -V
nginx version: nginx/1.27.0
built by clang 15.0.0 (clang-1500.3.9.4)
built with OpenSSL 3.3.0 9 Apr 2024
TLS SNI support enabled
configure arguments:
--prefix=/usr/local/Cellar/nginx/1.27.0
--sbin-path=/usr/local/Cellar/nginx/1.27.0/bin/nginx
--with-cc-opt='...' --with-ld-opt='...'
--conf-path=/usr/local/etc/nginx/nginx.conf
--pid-path=/usr/local/var/run/nginx.pid
--lock-path=... --http-client-body-temp-path=...
--http-proxy-temp-path=... --http-fastcgi-temp-path=...
--http-uwsgi-temp-path=... --http-scgi-temp-path=...
--http-log-path=/usr/local/var/log/nginx/access.log
--error-log-path=/usr/local/var/log/nginx/error.log
--with-compat --with-debug
--with-http_addition_module --with-http_auth_request_module --with-http_dav_module
--with-http_degradation_module --with-http_flv_module --with-http_gunzip_module
--with-http_gzip_static_module --with-http_mp4_module --with-http_random_index_module
--with-http_realip_module --with-http_secure_link_module --with-http_slice_module
--with-http_ssl_module --with-http_stub_status_module --with-http_sub_module
--with-http_v2_module --with-http_v3_module
--with-ipv6 --with-mail --with-mail_ssl_module --with-pcre --with-pcre-jit
--with-stream --with-stream_realip_module --with-stream_ssl_module
--with-stream_ssl_preread_module

安装完成后,系统通常会自动启动 Nginx 服务。Nginx 在默认配置下会根据当前 CPU 核数确定要启动的 Worker 进程数量。比如我有一台 Oracle 的四核 ARM 虚拟机:

$ ps auxf|grep nginx
root      ... nginx: master process /usr/sbin/nginx -g daemon on; master_process on;
www-data  ...  \_ nginx: worker process
www-data  ...  \_ nginx: worker process
www-data  ...  \_ nginx: worker process
www-data  ...  \_ nginx: worker process

这里简单提一下 Nginx 的进程架构。从上面可知,Nginx 的进程有两种:Master 进程,或主进程;worker 进程,或工作进程。一个 Nginx 实例只有一个主进程,负责管理子进程,子进程具体负责处理客户端发来的 HTTP 请求。大家注意,因为 Nginx 需要执行监听 80 端口等特权操作,这部分需要 root 权限才能执行,所以 Nginx 进程也需要使用 root 账号运行。但网络程序难免会有漏洞,如果被外界攻破,攻击者就会获得 root 权限。为此,通常的做法是让主进程以 root 账号运行,完成特权操作之后启动工作进程,每个工作进程切换到非特权账号,也就是此处的 www-data 账号。这样一来,就算工作进程被攻击者控制,它也只能以 www-data 账号做破坏。因为 www-data 是非特权账号,通常不会产生太严重的后果。这是业界通行的实践。初学者一定要建立正确的权限观念。比如平时操作一定要使用非 root 账号,只在必要的时候切换到 root 账号或者使用 sudo 来临时执行特权指令。强烈不建议大家为了方便,就日常使用 root 账号更改系统。

现在新的 Linux 发行版通常使用 systemd 来管理服务。我们修改 Nginx 配置后需要让正在运行的进程重新加载配置。如果用 systemd 可以执行:

systemctl reload nginx

但也有系统不用 systemd 组件。大家可以直接使用 Nginx 提供的 -s reload

nginx -s reload

只不过大家在执行之前需要先执行 nginx -t 来检查当前配置有没有错误,然后再重新加载。

-s 参数还有很多其他功能,比如控制 Nginx 退出等。但这里只推荐使用配置重载功能,其他功能还是建议使用类似 systemd 的服务管理组件。

好了,现在我们把 Nginx 的默认配置和系统自动启动的进程放到一边,现在开始构建我们自己的配置文件。

一个最简单的配置文件只包含如下内容:

event {}

是的,你没看错,只有一行。这里的 event 表示 Nginx 的事件模块,如果不配置就会报错。后面的大括号里是空的,表示所有配置都采用默认值。

我们需要使用 -c 参数指定配置文件路径:

nginx -c /tmp/nginx.conf

命令执行后,Nginx 很快就会退出。查看系统进程列表就会发现多出一组 Nginx 进程来。 Nginx 默认会以所谓的 daemon 模式运行,也就是后台运行。这对于生产环境当然是必要的,但对我们日常学习来说却很不方便。我们可以临时关闭后台运行特性,需要添加一行配置:

daemon off;
event {}

这时候我们再执行上面的 Nginx 命令,就会发现不会退出了。只有按 ctrl-c 后才会退出。这样我样可以很方便地观察进程的行为的输出。

上面的配置虽然简单,却是无用。它甚至都没有监听 TCP 端口,处理网络请求根本就无从谈起。现在我们在这个基础上起一个静态的 Web 服务。因为监听 1024 以内的端口需要 root 权限。为了避免权限问题,我们用 8080 这类的非特权端口。每个 Web 网站都需要指定所谓的根目录。假设网站的根目录为 /tmp/www,请求网址是 http://localhost/foo/a.txt,那么它对应服务器上的文件就是 /tmp/www/foo/a.txt。于是我们便有了下面的配置文件:

daemon off;
event {}
http {
  server {
    listen [::]:8080 ipv6only=off;
    root /tmp/www;
  }
}

这里多了一个 http {},所有跟 HTTP 协议相关的配置都在这个块里面。其中声明了一个 server {} 表示一个 Web 网站,也有资料称之为虚拟主机。网站需要指定监听的地址和端口以及前面说的根目录录。

listen 表示监听地址,大多数资料都直接写成 listen 8080。但我这里写的比较复杂,前面的 [::]:8080 表示监听 IPv6 下的 8080 端口,后面的 ipv6only=off 表示同时监听 IPv4 地址。这样 Nginx 就能同时支持 IPv6 和 IPv4 两种网络。无论你的服务器是否接入了 IPv6 网络,我们先在应用层面支持上,为 IPv6 的普及也贡献一份力量。

root /tmp/www 就比较简单的了,不多说。但这里提一点,在 Nginx 的配置文件中,像是 http/server/listen 这些都叫指令。有的指令后面需要加大括号,有的是直接加参数。如果是加了大括号,那么最后的大括号后面不需要加分号,也就是不需要写成 http {};;但如果是直接加参数,那么最后一定要加分号,比如 root /tmp/www;。这是 Nginx 的约定,记住就好。

我们现在可以重启 Nginx 并访问 8080 端口试试:

$ curl 127.0.0.1:8008/
<html>
<head><title>404 Not Found</title></head>
<body>
<center><h1>404 Not Found</h1></center>
<hr><center>nginx/1.27.0</center>
</body>
</html>

服务器会报 404 状态码,也就是对应文件不存在。到底哪里出问题了呢?为了排查问题,我们需要开启错误日志来调试。Nginx 的错误日志指令为 error_log,它的用法如下:

语法: error_log file [level];
默认值: error_log logs/error.log error;
上下文: main, http, mail, stream, server, location

语法很简单,就是在 error_log 指令之后加上错误日志的文件路径,再后面是可选的日志级别。如果没有指定,默认会把 error 级别的日志写到 logs/error.log 文件。最后的上下文大家要注意。Nginx 所有的配置指令都有上下文(context)限制。比如这里表示 error_log 可以用在 main/http/mail/stream/server/location 这些上下文。这个所谓的上下文可以简单理解为后面需要加大括号的指令,比如这里的 http 和 server。location 我们后面会讲到。main 比较特殊,理论上它应该对应 main {}。但 Nginx 对 main 作了特殊处理,可以不需要专门写出来。也就是说,从形式上,我们前面的配置文件应该写成:

main {
  daemon off;
  event {}
  http {
    server {
      listen [::]:8080 ipv6only=off;
      root /tmp/www;
    }
  }
}

但 Nginx 对 main 做了简单,可以省略掉。虽然省略后写起来更直观一些,但在另一些地方就需要额外解释一番~

error_log 支持在文件路径中传 stderr 参数,表示可以把报错写入系统的标准错误流中,这样我们就能直接看到报错内容了:

daemon off;
event {}
http {
  server {
    listen [::]:8080 ipv6only=off;
    root /tmp/www;
    error_log stderr error;
  }
}

再访问一次就会看到如下报错:

2024/06/02 20:28:15 [error] 25392#0: *1 "/tmp/www/index.html"
is not found (2: No such file or directory),
client: ::ffff:127.0.0.1,
server: , request: "GET / HTTP/1.1", host: "127.0.0.1:8080"

我们需要创建 /tmp/www 目录和 index.html 文件:

mkdir /tmp/www
echo Hello, Nginx > /tmp/www/index.html

这回再访问一次就能看到 curl 输出 “Hello, Nginx”。

除了错误日志外,通常我们希望记录网站的访问日志,也就是 access log。把客户端的每一次访问都记录下来,方便后面统计分析。访问日志由 ngx_http_log 模块提供,对应的配置指令为 access_log。它有很多参数,很难在入门材料里一一说明,这里只说最重要的两个,也就是前两个。

第一个参数为日志文件路径,也就是日志要保存到哪里。第二个参数为日志格式,可以简单理解为要在日志里保存哪些内容。日志格式可以通过 log_format 指令定义。Nginx 默认提供名为 combined 的格式,是从 Apache 那边继承过来的,格式如下:

log_format combined '$remote_addr - $remote_user [$time_local] '
                    '"$request" $status $body_bytes_sent '
                    '"$http_referer" "$http_user_agent"';

这里面以$开头的是 Nginx 内置的变量,变量的值大多从 HTTP 协议中提取。比如这里的 $request 表示 HTTP 协议的请求行,如 GET / HTTP/1.1 这种,$http_user_agent 表示客户端的 UA。完整的变量列表可以参考 Nginx 的官方文档

access_logerror_log 类似,它可以应用到 http/server/location 等多种上下文。就上面的配置而言,我们可以将其加到 server 上:

daemon off;
event {}
http {
  server {
    listen [::]:8080 ipv6only=off;
    root /tmp/www;
    error_log stderr error;
    access_log /dev/stdout combined;
  }
}

重启 Nginx 再次访问会看到如下输出:

::ffff:127.0.0.1 - - [02/Jun/2024:21:09:36 +0800] "GET / HTTP/1.1" 200 13 "-" "curl/8.6.0"

access_log 不像 error_log 原生支持 stdout 这类特殊路径。但在 UNIX 系统上,stdout 对应特殊的设备文件 /dev/stdout,这也是一个「普通」文件,可以用作 access_log 的目标路径,这样就能实现直接向标准输出设备打印访问日志了。

不过在实际生产环境中,Nginx 的访问日志大多数还是要写入磁盘,然后由类似 logstash 这样的日志客户端收集并发送到统一的日志服务;Nginx 也支持直接将日志发送到 syslog,然后再由 syslog 转发到日志服务。当然了,在容器化环境中,Nginx 也可以直接把日志输出到 stdout,然后由容器环境来收集并转发日志。本文只帮大家建议日志的基础概念,这些高级话题不在本文展开。

以上为大家展示了单个 Web 站点的配置。Nginx 支持配置多个站点。如果是内部使用,最方便的配置办法是为每个站点分配单独的端口,比如:

daemon off;
event {}
http {
  server {
    listen [::]:8080 ipv6only=off;
    root /tmp/www1;
    error_log /tmp/log/www1.error.log error;
    access_log /tmp/log/www1.access.log combined;
  }
  server {
    listen [::]:8081 ipv6only=off;
    root /tmp/www2;
    error_log /tmp/log/www2.error.log error;
    access_log /tmp/log/www2.access.log combined;
  }
}

这里我们声明了两个站点,其中 8080 端口对应根目录为 /tmp/www1;8081 端口对应目录 /tmp/www2。

如果是对外提供服务,这种配置可能就不适用了。大多时候我们需要在标准 80/443 端口对外提供服务。要想在同一端口提供不同的网站,就需要用到域名。Nginx 支持为不同的网站指定域名。

假设我们有 www1.local 和 www2.local 两个域名,都指向 127.0.0.1。需要在 /etc/hosts 中添加如下内容:

127.0.0.1 www1.local www2.local

网站都监听 8080 端口,上面的配置可以改为:

daemon off;
event {}
http {
  server {
    listen [::]:8080 ipv6only=off;
    server_name www1.local;
    root /tmp/www1;
    error_log /tmp/log/www1.error.log error;
    access_log /tmp/log/www1.access.log combined;
  }
  server {
    listen [::]:8080 ipv6only=off;
    server_name www2.local;
    root /tmp/www2;
    error_log /tmp/log/www2.error.log error;
    access_log /tmp/log/www2.access.log combined;
  }
}

HTTP 客户端发起请求时都会附带 Host 头信息,Nginx 会根据 Host 字段跟每个站点的 server_name 匹配来确定返回哪个网站的数据。

以上是静态网站的场景,域名、路径都是事先确定的。Nginx 还支持很多动态特性。比如像是 GitHub Pages 这种服务,每个用户都能获得一个 ${user}.github.io 域名,指向自己的网站。这里的${user}会持续变化,不可能全部都写到 Nginx 的配置里。为此,我们可以使用 Nginx 的变量功能。上面的配置文件可以改写为:

daemon off;
event {}
http {
  server {
    listen [::]:8080 ipv6only=off;
    server_name ~^(.*)\.github\.io;
    root /sites/$1;
  }
}

这里用到了正则表达式,以~开头,使用括号提取用户名部分,然后在 root 中使用$1 引用前面匹配的内容。如果用户访问 foo.github.io,Nginx 会尝试返回 /sites/foo 目录下的内容。

如果不用正则,上面的配置还能简化:

daemon off;
event {}
http {
  server {
    listen [::]:8080 ipv6only=off;
    server_name *.github.io;
    root /sites/$host;
  }
}

这种简化性能比正则要强很多,不过用户的目录需要从 /sites/foo 变为 /sites/foo.github.io,因为这里直接使用了 Nginx 内置的变量 $host

以上只是 Nginx 主机名处理的一部分功能,更多功能请阅读官方文档

前面的例子都是以 server 也就站点为单位,现在说一下 location。所谓 location 可以简单理解为文件路径模式或者特征,Nginx 可以使用 location 为某种路径下的请求做统一的处理。

假设所有的 js 都放在 assets/js/ 目录,我们希望对该目录的所有文件都开启 gzip 压缩,可以这样写:

server {
  location /assets/js {
    gzip on;
  }
}

这里省略了其他配置,具体 gzip 配置的细节也不展开。主要是演示如何使用 location 指令。这里的 location /assets/js 表示如果路径中前缀为 /assets/js 那么就开启 gzip 压缩。这里默认是前缀匹配。特别的,因为所有路径都是以 / 开头,所以 location / 就会匹配到所有的请求。location 还支持相等匹配(=)和正则匹配(~),本文不展开讨论。

以上讲的都是静态或者半动态功能,下面讲一下动态功能。我这里说的动态功能其实就是代理功能。

所谓代理就是不想把某个 HTTP 服务直接暴露给使用方,中间加一层转发。这个中间层可以做鉴权、计费、限流、审计等各种功能。假设我们内部的服务叫 api.local,对外的代理叫 example.com,使用 HTTP 1.1 通信。那么代理配置为:

daemon off;
event {}
http {
  server {
    listen [::]:8080 ipv6only=off;
    server_name example.com;
    root /sites/example;
    location /api/ {
        proxy_pass http://api.local;
    }
  }
}

这里用到了 proxy_pass 指令。所以 URL 前缀为 http://example.com/api/ 的请求都会被转发到 api.local 的 80 端口上。api.local 的返回内容也会被 Nginx 转发给调用方。这就是最简单的代理配置。

这里的调用方称为 downstream,被调方 api.local 称为上游。Nginx 在中间作代理。

除了这种直接用域名的代理转发外,在实践中,我们常常需要将客户端的请求转发到不同的上游服务器,这可以用到 Nginx 的 upstream 功能。比如我们可以在 server 中定义如下配置:

upstream backend {
    server backend1.example.com:8080  weight=5;
    server backend2.example.com:8080  weight=5;
    server backend3.example.com:8080  backup;
}

这里的 backend 定义了三个上游服务器,其中 backend1 和 backend2 权重都是5,backend3 用作备份服务器,只有在前两个都出问题的情况下 Nginx 才会把请求转发给 backup 上游。

有了 upstream 配置,我们就可以在 proxy_pass 中直接引用它:

location /api/ {
    proxy_pass http://backup;
}

以上只是展示了最基本的 upstream 转发例子。在实际的生产环境中,上游服务器可能是因为版本发布或者自身问题反复出现变化,Nginx 的配置文件也得跟着变,而且要不断热加载。一种简单的方案是通过类似 console-template 这类工具来监听 upstream 变化并动态生成配置再 reload Nginx。但这样会导致客户端大量重新建立 TCP 连接,在性能上会有很大的问题。所以也有人给 Nginx 添加了动态更新 upstream 的能力。Nginx 的商业版也提供类似的能力,但开源版一直不接受相关的功能。

Nginx 代理的另外一个功能就是只支持 HTTP 1.x 协议的上游代理。也就是说,如果你的服务用的是 HTTP2 或者 HTTP3,那么对不起,Nginx 无法做中间代理。官方给出的理由是在服务器环境用 HTTP 1.x 就够了,没有必要用 HTTP2 或 HTTP3。这种解释未免太牵强,核心原因是现有的 upstream 体系只支持 HTTP 1.x,在设计的时候没考虑其他情况,要支持 HTTP2 或 HTTP3 就得大改,现在还不迫切。虽然不支持通用的 HTTP2 上游服务,但新版的 Nginx 却支持 GRPC 代理,算是在当前市场环境下的一种妥协。如果不支持 GRPC 的话,估计很多人就不用 Nginx 了~

最后说一下 HTTPS 或者 SSL 网站。

HTTPS 使用 443 端口,而且还需要指定 SSL 密钥和证书。这部分要想实践最好是有一台能在公网访问的 VPS 比较方便。

SSL 密钥是根据密码学原理生成的一段随机数,别人几乎不可能猜出来,以此确保安全。通过密钥生成对应的公钥,客户端使用公钥加密的数据只有用服务器的密钥才能解密,这就实现了安全加密。但客户端怎么样能获得服务端的公钥呢?显然不可能使用加密信道,因为加密要用到公钥。但如果不加密,会不会有中间人拦截了服务端的公钥,并替换成自己的公钥,这样中间人就能收到客户端加密的数据了。此所谓中间人攻击。为了解决这个问题,业界搞出了 SSL 证书,也叫 CA 证书。简单来说就是由第三方 CA 机构为服务器的公钥做电子签名,签名里面还附带了服务器域名,这样就形成 SSL 证书。客户端收到服务器的证书后会先验证 CA 的签名是否有效,这部分同样使用非对称加密,但可信 CA 机构的公钥已经内置到操作系统而且会及时更新,所以不存在中间人冒充 CA 的问题。通过 CA 的公钥就能验证服务器公钥的签名,进而确定 SSL 公钥与服务器域名的对应的关系。这就是 SSL 证书大致的工作原理。

为此,我们需要为 HTTPS 服务生成密钥和证书。早期证书都要付费购买,现在有 letsencrypt 和 zero 这类的公益 CA,域名证书可以免费申请。但代价则是需要配置 ACME 客户端,因为免费证书的有效期只有三个月。

申请免费证书的方式有很多。我这里只简单介绍一下 acme.sh 这个工具。

安装非常简单:

curl https://get.acme.sh | sh -s email=my@example.com

大家需要填写自己的有效邮箱,这样证书快过期时会收到提醒。

申请证书需要验证域名控制权。验证方法也有很多,今天我只说 HTTP-01 challenge。简单这种方式要求服务器上已经运行了 HTTP 服务,也就是说有进程在监听 80 端口。假设我们要给 example.com 申请证书,而且 web 服务的根目录为 /var/www/html,则可以执行如下命令

acme.sh --issue -d example.com -w /var/www/html

acme.sh 会把申请的证书保存在 ~/.acme.sh/example.com_ecc 目录。其中的 example.com.key 就是密钥文件,example.com.cer 就是对应的证书文件。但有些浏览器要求服务器把中间 CA 的证书也发过来,所以还有一个 fullchain.cer 文件,里面包含了所有 CA 和当前密钥的证书。

不要忘记将 acme.sh 加到定时任务,定期更新 SSL 证书并让 Nginx 重新加载配置。

0 3 * * * "/root/.acme.sh"/acme.sh --cron --home "/root/.acme.sh" && /usr/sbin/nginx -s reload

最后在 server 中监听 443 端口并指定密钥和证书就可以了:

daemon off;
event {}
http {
  server {
    listen [::]:443 ipv6only=off ssl;
    server_name example.com;
    ssl_ciphers         HIGH:!aNULL:!MD5;
    ssl_protocols       TLSv1.2 TLSv1.3;
    ssl_certificate     /path/to/cert.pem;
    ssl_certificate_key /path/to/cert.key;

    root /tmp/www;
  }
}

这里的 listen 指令后面多了 ssl 参数。ssl_ciphers 指令用于指定 TLS 加密通信所用的算法,这里的 HIGH:!aNULL:!MD5 表示使用高强度算法,且排除跟 NULL 和 MD5 相关。 ssl_protocols 指定 TLS 版本,当今建议最低支持 1.2,再低的版本不安全。ssl_certificate 指定密钥,ssl_certificate_key 指定证书。

重启后就可以通过 https://example.com 访问 HTTPS 站点了。

本文到此就得结束了。限于篇幅,不可能讲得很细,但基本覆盖了 Nginx 的核心内容。如果读者有疑问或者建议,欢迎留言讨论。