NGINX.COM
Web Server Load Balancing with NGINX Plus

避免 10 大 NGINX 配置错误

在帮助 NGINX 用户解决问题时,我们经常会发现配置错误,这种配置错误也屡屡出现在其他用户的配置中,甚至有时还会出现在我们的 NGINX 工程师同事编写的配置中!本文介绍了 10 个最常见的错误,并解释了问题所在以及相应的解决方法。

  1. 每个 worker 的文件描述符不足
  2. error_log off 指令
  3. 未启用与上游服务器的 keepalive 连接
  4. 忘记指令继承的工作机制
  5. proxy_buffering off 指令
  6. if 指令使用不当
  7. 过多的健康检查
  8. 不安全地访问指标
  9. 当所有流量都来自同一个 /24 CIDR 块时使用 ip_hash
  10. 不采用上游组

错误 1:每个 worker 没有足够的文件描述符

worker_connections 指令用于设置 NGINX worker 进程可以打开的最大并发连接数(默认为 512)。所有类型的连接(例如与代理服务器的连接)都计入最大值,而不仅仅是客户端连接。但重要的是要记住,最终每个 worker 的并发连接数还有另一个限制:操作系统对分配给每个进程的文件描述符 (file descriptor,即FD) 最大数量的限制。在现代 UNIX 发行版中,默认限制为 1024。

对于除最小的NGINX部署之外的 所有部署,将每个 worker 的连接数限制为 512 可能太少了。事实上,我们将随 NGINX 开源版二进制文件和 NGINX Plus 一起分发的默认 nginx.conf 文件将其增加到 1024。

常见的配置错误是没有将 FD 的限制增加到至少两倍的 worker_connections 的值。解决方法是在主配置上下文中使用 worker_rlimit_nofile 指令设置该值。

这就是需要更多 FD 的原因:从 NGINX worker 进程到客户端或上游服务器的每个连接都消耗一个 FD。当 NGINX 充当 Web 服务器时,每个客户端连接使用一个 FD,每个服务的文件使用一个 FD,这样每个客户端至少需两个 FD(但大多数网页是由许多文件构建的)。当充当代理服务器时,NGINX 分别使用一个 FD 连接客户端和上游服务器,并可能用到第三个 FD 给用于临时存储服务器响应的文件。作为缓存服务器时,NGINX 的行为类似于缓存响应的 Web 服务器,如果缓存为空或过期,则类似于代理服务器。

NGINX 为每个日志文件使用一个 FD,并会用几个 FD 与主进程通信,但与用于连接和文件的 FD 数量相比,这些数量通常很少。

UNIX 提供了几种方法来设置每个进程的 FD 数量:

  • 如果从 shell 启动 NGINX,则使用 ulimit 命令
  • 如果将 NGINX 作为服务启动,则使用init 脚本或 systemd 服务清单变量
  • /etc/security/limits.conf 文件

使用的方法取决于您如何启动 NGINX,而 worker_rlimit_nofile 与您启动 NGINX 的方式无关。

FD 的数量也有系统范围的限制,您可以使用操作系统的 sysctl fs.file-max 命令进行设置。它通常足够大,但有必要验证所有 NGINX worker 进程可能使用的文件描述符的最大数量 (worker_rlimit_nofile * worker_processes) 明显小于 fs.file‑max。如果 NGINX 以某种方式使用了所有可用的 FD(例如,在 DoS 攻击期间),此时甚至都无法登录机器来解决问题。

错误 2:error_log off 指令

常见的错误是认为 error_log off 指令会禁用日志记录。事实上,与 access_log指令不同,error_log 不包含 off 参数。如果在配置中添加了 error_log off 指令,则 NGINX 会在 NGINX 配置文件的默认目录(通常是/etc/nginx)中创建一个名为 off 的错误日志文件。

我们不建议禁用错误日志,因为它是调试 NGINX 任何问题时的重要信息来源。但是,如果存储空间非常有限,记录的数据可能足以耗尽可用的磁盘空间,此时禁用错误日志记录可能有意义。在主配置上下文中包含该指令:

error_log /dev/null emerg;

请注意,在 NGINX 读取并验证配置之前,该指令不会应用。因此,每次 NGINX 启动或重新加载配置时,它可能会记录到默认的错误日志位置(通常为/var/log/nginx/error.log),直到配置验证后。更改日志目录的方法是,在 nginx 命令中添加 -e <error_log_location> 参数。

错误 3:未启用与上游服务器的 keepalive 连接

默认情况下,NGINX 会为每个新的传入请求打开一个到上游(后端)服务器的新连接。这种操作虽然安全但是低效,因为 NGINX 和服务器必须交换三个数据包来建立连接,并交换三个或四个数据包来终止连接。

在流量高峰期,为每个请求打开一个新连接会耗尽系统资源,最终导致根本无法打开连接。原因是:对于每个连接,源地址、源端口、目标地址和目标端口的4元组必须是唯一的。对于从 NGINX 到上游服务器的连接,四元组中的三个(第一个、第三个和第四个)是固定的,只有源端口是变量。当连接关闭时,Linux 套接字会处于 TIME‑WAIT 状态两分钟,在流量高峰期时这会增加可用源端口池耗尽的可能性。如果发生这种情况,NGINX 将无法打开与上游服务器的新连接。

解决方法是在 NGINX 和上游服务器之间启用keepalive 连接 —— 该连接不会在请求完成时关闭,而是保持打开状态以用于其他请求。这样做既降低了源端口耗尽的可能性,又提高了性能。

启用 keepalive 连接的方法是:

  • 在每个 upstream{} 块中包含 keepalive 指令,以设置保存在每个 worker 进程缓存中的到上游服务器的空闲 keepalive 连接数。

    请注意,keepalive 指令不限制 NGINX worker 进程可以打开的上游服务器的连接总数——这一点经常被误解。所以 keepalive 的参数不需要像您想象的那么大。

    我们建议将该参数设置为 upstream{} 块中列出的服务器数量的两倍。这足以让 NGINX 保持与所有服务器的 keepalive 连接,同时也足够小,上游服务器还可以处理新的传入连接。

    另请注意,当您在 upstream{} 块中指定负载均衡算法时 —— 使用 haship_hashleast_connleast_timerandom 指令 —— 该指令必须位于 keepalive 指令之前。通常,在 NGINX 配置中,指令顺序并不重要,而这是少数例外之一。

  • 在将请求转发到上游 group 的 location{} 块中,添加以下指令以及 proxy_pass 指令:

    proxy_http_version 1.1;
    proxy_set_header   "Connection" "";

    NGINX 默认使用 HTTP/1.0 连接上游服务器,并相应地将 Connection: close 标头添加到它所转发到服务器的请求中。这样尽管 upstream{} 块中包含了keepalive 指令,但每个连接仍然会在请求完成时关闭。

    proxy_http_version 指令告知 NGINX 使用 HTTP/1.1,proxy_set_header 指令将从 Connection 标头中删除 close 值。

错误 4:忘记指令继承的工作机制

NGINX 指令是向下继承的,或者是“由外而内”继承的:一个上下文(一个嵌套在另一个上下文,即上下文中的上下文)继承父上下文包含的指令的设置。例如,http{} 上下文中的所有server{}location{} 块都继承了包含在 http 级别的指令的值,并且 server{} 块中的指令被它的所有子 location{} 块继承。但是,当父上下文及其子上下文中包含相同的指令时,这些值不会相加 —— 相反,子上下文中的值会覆盖父上下文中的值。

常见的错误是忘记了这种数组指令的“覆盖规则”,它不仅可以包含在多个上下文中,而且还可以在给定上下文中包含多次。例如 proxy_set_headeradd_header —— 第二个名称中包含“add”导致覆盖规则很容易被忘记。

我们可以通过 add_header 的例子来说明继承的工作机制:

http {
    add_header X-HTTP-LEVEL-HEADER 1;
    add_header X-ANOTHER-HTTP-LEVEL-HEADER 1;

    server {
        listen 8080;
        location / {
            return 200 "OK";
        } 
    }

    server {
        listen 8081;
        add_header X-SERVER-LEVEL-HEADER 1;

        location / {
            return 200 "OK";
        }

        location /test {
            add_header X-LOCATION-LEVEL-HEADER 1;
            return 200 "OK";
        }

        location /correct {
            add_header X-HTTP-LEVEL-HEADER 1;
            add_header X-ANOTHER-HTTP-LEVEL-HEADER 1;

            add_header X-SERVER-LEVEL-HEADER 1;
            add_header X-LOCATION-LEVEL-HEADER 1;
            return 200 "OK";
        } 
    }
}

对于监听端口 8080 的服务器,server{}location{} 块中都没有 add_header 指令。所以继承很简单,我们看到 http{} 上下文中定义了下面两个http消息头:

% curl -is localhost:8080
HTTP/1.1 200 OK
Server: nginx/1.21.5
Date: Mon, 21 Feb 2022 10:12:15 GMT
Content-Type: text/plain
Content-Length: 2
Connection: keep-alive
X-HTTP-LEVEL-HEADER: 1
X-ANOTHER-HTTP-LEVEL-HEADER: 1
OK

对于监听端口 8081 的服务器,server{} 块中有一个 add_header 指令,但其子 location / 块中没有该指令。server{} 块中定义的消息头覆盖了 http{} 上下文中定义的两个http消息头:

% curl -is localhost:8081
HTTP/1.1 200 OK
Server: nginx/1.21.5
Date: Mon, 21 Feb 2022 10:12:20 GMT
Content-Type: text/plain
Content-Length: 2
Connection: keep-alive
X-SERVER-LEVEL-HEADER: 1
OK

在子 location /test 块中,有一个 add_header 指令,该指令覆盖了其父 server{} 块中的http消息头和 http{} 上下文中的两个http消息头:

% curl -is localhost:8081/test
HTTP/1.1 200 OK
Server: nginx/1.21.5
Date: Mon, 21 Feb 2022 10:12:25 GMT
Content-Type: text/plain
Content-Length: 2
Connection: keep-alive
X-LOCATION-LEVEL-HEADER: 1
OK

如果我们希望 location{} 块保留其父上下文中定义的http消息头以及本地定义的任何http消息头,我们必须在 location{} 块中重新定义父http消息头。这就是我们在 location /correct 块中所做的:

% curl -is localhost:8081/correct
HTTP/1.1 200 OK
Server: nginx/1.21.5
Date: Mon, 21 Feb 2022 10:12:30 GMT
Content-Type: text/plain
Content-Length: 2
Connection: keep-alive
X-HTTP-LEVEL-HEADER: 1
X-ANOTHER-HTTP-LEVEL-HEADER: 1
X-SERVER-LEVEL-HEADER: 1
X-LOCATION-LEVEL-HEADER: 1
OK

错误 5: proxy_buffering off 指令

NGINX 默认启用代理缓冲(proxy_buffering 指令设置为 on)。代理缓冲意味着 NGINX 将来自服务器的响应存储在内部缓冲区中,并且在整个响应被缓冲之后才开始向客户端发送数据。缓冲有助于优化慢速客户端的性能 —— 因为 NGINX 缓冲响应的时间与客户端检索所有响应的时间一样长,代理服务器可以尽可能快地返回响应,然后返回到可用的状态以响应其他请求。

如果代理缓冲被禁用,则 NGINX 只会在默认为一个内存页大小(4 KB 或 8 KB,具体取决于操作系统)的缓冲区内缓存服务器响应的开头部分后就开始向客户端传输。通常,这个缓存空间只够缓存响应http消息头。NGINX 收到响应后会同步发送给客户端,迫使服务器处于空闲状态,直到 NGINX 可以接受下一个响应段为止。

因此,对于经常在 NGINX 配置中看到 proxy_buffering off 指令的情况,我们感到非常惊讶。也许这样做是为了减少客户端延迟,但其影响可以忽略不计,而关闭后的副作用却很多:代理缓冲被禁用后,速率限制和缓存即便配置了也不起作用,性能也会受影响等等。

只有在少数情况下,禁用代理缓冲可能有意义(例如长轮询),因此我们强烈建议不要更改默认设置。有关更多信息,请参阅《NGINX Plus 管理指南》

错误 6:if 指令使用不当

if 指令使用起来很棘手,尤其是在 location{}块中。它通常不会按照预期执行,甚至还会导致出现段错误。事实上,在 NGINX Wiki 中有一篇题为“if 问题多多 (If is Evil)”的文章,详细讨论了 if 问题以及如何避免这些问题。

通常,在 if{} 块中,您可以一直安全使用的指令只有 returnrewrite。以下示例使用 if 来检测包含 X‑Test http消息头的请求(可以是您想要测试的任何条件)。NGINX 返回 430 (Request Header Fields Too Large) 错误,在指定的位置 @error_430 进行拦截并将请求代理到名为 b 的上游 group。

location / {
    error_page 430 = @error_430;
    if ($http_x_test) {
        return 430; 
    }

    proxy_pass http://a;
}

location @error_430 {
    proxy_pass b;
}

对于 if 的这个用途及许多其他用途,通常可以完全避免使用该指令。在以下示例中,当请求包含 X‑Test 标头时,map{} 块将 $upstream_name 变量设置为 b ,并且请求被代理到以b 命名的上游 group。

map $http_x_test $upstream_name {
    default "b";
    ""      "a";
}

# ...

location / {
    proxy_pass http://$upstream_name;
}

错误 7:过多的健康检查

配置多个虚拟服务器将请求代理到同一个上游组十分常见(换句话说,在多个 server{} 块中包含相同的 proxy_pass 指令)。这里的错误是在每个 server{} 块中都添加一个 health_check 指令。这样做只会增加上游服务器的负载,而不会产生任何额外信息。

显然,解决方法是每个 upstream{} 块只定义一个健康检查。此处,我们在一个指定位置为名为 b 的上游 group 定义了健康检查,并进行了适当的超时和http消息头设置。

location / {
    proxy_set_header Host $host;
    proxy_set_header "Connection" "";
    proxy_http_version 1.1;
    proxy_pass http://b;
}

location @health_check {
    health_check;
    proxy_connect_timeout 2s;
    proxy_read_timeout 3s;
    proxy_set_header Host example.com;
    proxy_pass http://b;
}

在复杂的配置中,它可以进一步简化管理,将所有健康检查位置以及 NGINX Plus API 仪表盘分组到单个虚拟服务器中,如本例所示。

server {
	listen 8080;
 
	location / {
	    # …
 	}
 
	location @health_check_b {
	    health_check;
	    proxy_connect_timeout 2s;
	    proxy_read_timeout 3s;
	    proxy_set_header Host example.com;
	    proxy_pass http://b;
	}
 
	location @health_check_c {
	    health_check;
	    proxy_connect_timeout 2s;
	    proxy_read_timeout 3s;
	    proxy_set_header Host api.example.com;
	    proxy_pass http://c;
	}
 
	location /api {
	    api write=on;
	    # directives limiting access to the API (see 'Mistake 8' below)
	}
 
	location = /dashboard.html {
	    root   /usr/share/nginx/html;
	}
}

有关 HTTP、TCP、UDP 和 gRPC 服务器的健康检查的更多信息,请参阅《NGINX Plus 管理员指南》

错误 8:不安全访问指标

Stub Status 模块提供了有关 NGINX 操作的基本指标。对于 NGINX Plus,您还可以使用 NGINX Plus API 收集更广泛的指标集。通过在 server{}location{} 块中分别包含 stub_statusapi 指令来启用指标收集,您随后可以通过访问 URL 来查看这些指标。(对于 NGINX Plus API,您还需要为要收集指标的 NGINX 实体 —— 虚拟服务器、上游 group、缓存等配置共享内存区;请参阅《NGINX Plus 管理指南》中的说明。)

其中一些指标是敏感信息,可被用来攻击您的网站或 NGINX 代理的应用,我们有时在用户配置中看到的错误是未限制对相应 URL 的访问。此处我们将介绍一些可以保护指标的方法。在第一个示例中我们将使用 stub_status

通过以下配置,互联网上的任何人都可以访问 http://example.com/basic_status 上的指标。

server {
    listen 80;
    server_name example.com;

    location = /basic_status {
        stub_status;
    }
}

使用 HTTP 基本身份验证保护指标

采用 HTTP 基本身份验证相关的方式为指标添加密码保护,包含 auth_basicauth_basic_user_file 指令。文件(此处为 .htpasswd)列出了可以登录查看指标的客户端的用户名和密码:

server {
    listen 80;
    server_name example.com;

    location = /basic_status {
        auth_basic “closed site”;
        auth_basic_user_file conf.d/.htpasswd;
        stub_status;
    }
}

使用 allowdeny 指令保护指标

如果您不希望强制授权用户登录,并且您知道他们用于访问指标的 IP 地址,另一个选项是使用allow 指令。您可以指定单独的 IPv4 和 IPv6 地址以及 CIDR 范围。deny all 指令将阻止来自任何其他地址的访问。

server {
    listen 80;
    server_name example.com;

    location = /basic_status {
        allow 192.168.1.0/24;
        allow 10.1.1.0/16;
        allow 2001:0db8::/32;
        allow 96.1.2.23/32;
        deny  all;
        stub_status;
    }
}

两种方法相结合

如果我们想结合使用这两种方法怎么办?我们可以允许客户端在没有密码的情况下从特定地址访问指标,但来自不同地址的客户端仍然需要登录。为此,我们使用 satisfy any 指令。它告诉 NGINX 允许使用 HTTP 基本身份验证凭证登录或使用预批准的 IP 地址登录的客户端访问。为了提高安全性,您可以将 satisfy 设置为 all,要求来自特定地址的人登录。

server {
    listen 80;
    server_name monitor.example.com;

    location = /basic_status {
        satisfy any;

        auth_basic “closed site”;
        auth_basic_user_file conf.d/.htpasswd;
        allow 192.168.1.0/24;
        allow 10.1.1.0/16;
        allow 2001:0db8::/32;
        allow 96.1.2.23/32;
        deny  all;
        stub_status;
    }
}

对于 NGINX Plus,您可以使用相同的技术来限制访问 NGINX Plus API 端点(在以下示例中为 http://monitor.example.com:8080/api/)以及 http://monitor.example.com/dashboard.html 上的实时活动监控仪表盘。

在没有密码的情况下,此配置只允许来自 96.1.2.23/32 网络或本地主机的客户端访问。由于指令是在 server{} 级别定义的,因此相同的限制同时应用于 API 和仪表板。附带说明一下,apiwrite=on 参数意味着这些客户端也可以使用 API 进行配置更改。

有关配置 API 和仪表盘的更多信息,请参阅《NGINX Plus 管理员指南》

server {
    listen 8080;
    server_name monitor.example.com;
 
    satisfy any;
    auth_basic “closed site”;
    auth_basic_user_file conf.d/.htpasswd;
    allow 127.0.0.1/32;
    allow 96.1.2.23/32;
    deny  all;

    location = /api/ {    
        api write=on;
    }

    location = /dashboard.html {
        root /usr/share/nginx/html;
    }
}

错误 9:当所有流量都来自同一个 /24 CIDR 块时使用 ip_hash

ip_hash 算法基于客户端 IP 地址的哈希值,在 upstream{} 块中的服务器间进行流量负载均衡。哈希键是 IPv4 地址或整个 IPv6 地址的前三个八位字节。该方法建立会话持久性,这意味着来自客户端的请求始终传递到同一服务器,除非该服务器不可用。

假设我们已将 NGINX 部署为虚拟专用网络中的反向代理(按高可用性配置)。我们在 NGINX 前端放置了各种防火墙、路由器、四层负载均衡器和网关,以接受来自不同来源(内部网络、合作伙伴网络和 Internet 等)的流量,并将其传递给 NGINX 以反向代理到上游服务器。以下是 NGINX 的初始配置:

http {

    upstream {
        ip_hash;
        server 10.10.20.105:8080;
        server 10.10.20.106:8080;
        server 10.10.20.108:8080;
    }
 
    server {# …}
}

但事实证明存在一个问题:所有“拦截”设备都位于同一个 10.10.0.0/24 网络上,因此对于 NGINX 来说,看起来所有流量都来自该 CIDR 范围内的地址。请记住,ip_hash 算法会散列 IPv4 地址的前三个八位字节。在我们的部署中,每个客户端的前三个八位字节都是相同的(都为 10.10.0),因此它们的哈希值也都是相同的,没法将流量分配到不同服务器。

解决方法是在哈希算法中使用 $binary_remote_addr 变量作为哈希键。该变量捕获完整的客户端地址,将其转换为二进制表示,IPv4 地址为 4 个字节,IPv6 地址为 16 个字节。现在,每个拦截设备的哈希值都不同,负载均衡可正常进行。

我们还添加了 consistent 参数以使用 ketama 哈希方法而不是默认值。这大大减少了在服务器集更改时重新映射到不同上游服务器的键的数量,为缓存服务器带来了更高的缓存命中率。

http {
    upstream {
        hash $binary_remote_addr consistent;
        server 10.10.20.105:8080;
        server 10.10.20.106:8080;
        server 10.10.20.108:8080;
    }

    server {# …}
}

错误 10:未采用上游组

假设您在最简单的用例中使用 NGINX,作为监听端口 3000的单个基于 NodeJS 的后端应用的反向代理。常见的配置可能如下所示:

http {

    server {
        listen 80;
        server_name example.com;

        location / {
            proxy_set_header Host $host;
            proxy_pass http://localhost:3000/;
        }
    }
}

非常简单,对吧?proxy_pass 指令告诉 NGINX 客户端向哪里发送请求。NGINX 需要做的就是将主机名解析为 IPv4 或 IPv6 地址。建立连接后,NGINX 将请求转发给该服务器。

这里的错误是,假定只有一台服务器(因此没有理由配置负载均衡),因此不需要创建 upstream{} 块。事实上,一个 upstream{} 块会解锁几项有助于提高性能的特性,如以下配置所示:

http {

    upstream node_backend {
        zone upstreams 64K;
        server 127.0.0.1:3000 max_fails=1 fail_timeout=2s;
        keepalive 2;
    }

    server {
        listen 80;
        server_name example.com;

        location / {
            proxy_set_header Host $host;
            proxy_pass http://node_backend/;
            proxy_next_upstream error timeout http_500;

        }
    }
}

zone 指令建立一个共享内存区,主机上的所有 NGINX worker 进程都可以访问有关上游服务器的配置和状态信息。几个上游组 可以共享该内存区。对于 NGINX Plus,该区域还支持您使用 NGINX Plus API 更改上游组中的服务器和单个服务器的设置,而无需重启 NGINX。有关详细信息,请参阅《NGINX Plus 管理指南》

server 指令有几个参数可用来调整服务器行为。在本示例中,我们改变了 NGINX 用以确定服务器不健康,因此没有资格接受请求的条件。此处,只要通信尝试在每个 2 秒期间失败一次(而不是默认的在 10 秒期间失败一次),就会认为服务器不健康。

我们结合使用此设置与 proxy_next_upstream 指令,配置在什么情况下 NGINX 会认为通信尝试失败,在这种情况下,它将请求传递到上游组中的下一个服务器。在默认错误和超时条件中,我们添加了 http_500,以便 NGINX 认为来自上游服务器的 HTTP 500 (Internal Server Error)代码表示尝试失败。

keepalive 指令设置每个 worker 进程缓存中保留的上游服务器的空闲 keepalive 连接的数量。我们已经在“错误 3:未启用与上游服务器的 keepalive 连接”中讨论了这样做的好处。

在 NGINX Plus 中,您还可以配置与上游组 有关的其他功能:

  • 上文提到了 NGINX 开源版仅在启动时将服务器主机名解析为 IP 地址一次。server 指令的 resolve参数能够支持 NGINX Plus 监控与上游服务器的域名对应的 IP 地址的变化,并自动修改上游配置而无需重新启动。

    service 参数进一步支持 NGINX Plus 使用 DNS SRV 记录,其中包括有关端口号、权重和优先级的信息。这对于通常动态分配服务端口号的微服务环境非常重要。

    有关解析服务器地址的更多信息,请参阅我们的博文“NGINX 和 NGINX Plus 使用 DNS 进行服务发现”。

  • server 指令的 slow_start 参数支持 NGINX Plus 逐渐增加发送到新近被认为是健康且可用于接受请求的服务器的请求量。这可以防止请求激增,避免服务器不堪重负,进而导致再次失败。

  • queue 指令允许 NGINX Plus 在无法选择上游服务器来处理请求时将请求放入队列中,而不是立即向客户端返回错误。

资源

如欲试用 NGINX Plus,请立即下载 30 天免费试用版,或与我们联系以讨论您的用例。

Hero image
Microservices: From Design to Deployment

The complete guide to microservices development

Tags

No More Tags to display