Nginx静态资源配置优化

服务器上同时运行有多个服务,于是配置了 Nginx 作为统一的入口,根据路由前缀将请求转发到不同的服务上。
HTTP API,WebSocket,TCP forwarding,static content,之前各个服务一直相安无事,但最近出现了问题,服务端 WebSocket 的连接总数偶尔会急剧下降,然后随着客户端的断线重连逐渐恢复。
对多次故障发生时间点附近的服务器日志进行对比分析后,确认 WebSocket 服务本身没有问题,问题出在 Nginx 提供的静态资源服务上。

问题分析

先根据监控日志找到故障发生的时间点,然后查看该时间点前后的 WebSocket 服务端日志,发现是 Socket.IOping 在设定的超时时间内没有收到客户端的 pong 响应,导致服务端主动关闭了连接。
又检查了同一服务器上其它服务的日志,发现在相同的一段时间内各个服务都没有收到新请求,Nginx 的 access.log 也有一段明显的空白期,因此初步怀疑是网络波动导致的。
但随着故障的多次发生,该故障由单纯网络波动引起的可能性变得越来越小,同事对比了多次故障日志后发现 Nginx 日志空白期前的最后几个请求都是图片资源,且所处目录在同一块 NFS 磁盘上,该磁盘存放的是用户上传时间较早的一部分静态资源,由于访问频率不高专门降低了磁盘读写速率,于是推测是这部分静态资源阻塞住了 Nginx,进而影响了其它服务在特定时间段内与用户的正常通信。

模拟复现

查看服务器上的 Nginx 配置文件,提取其中有关静态资源的选项后得到最小化配置如下:

worker_processes        auto;

events {
    worker_connections  1024;
}

http {
    include             mime.types;
    default_type        application/octet-stream;

    sendfile            on;
    sendfile_max_chunk  1m;
    tcp_nopush          on;
    tcp_nodelay         on;
    keepalive_timeout   65;

    server {
        listen          8080;
        server_name     _;
        root            /srv/http;
    }
}

其中的 sendfiletcp_nopushtcp_nodelay 等选项均参考了 Nginx Docs: Serving Static Content 中的推荐配置,没有看出来明显的问题。

读取文件阻塞的情况不太方便直接利用 NFS 进行模拟,于是本地使用 FUSE/srv/http/mnt/ 文件夹上挂载了一个虚拟文件系统,通过在自定义的 getattrread 调用中加入等待时间来模拟不同情况下的阻塞:

#define FUSE_USE_VERSION 31

#include <fuse.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>

const char filename[] = "slow";
const char contents[] = "123456789abcdef\n";

void debug_printf(const char *format, ...)
{
    char now_time_str[20];
    time_t timer = time(NULL);
    strftime(now_time_str, 20, "%Y-%m-%d %H:%M:%S", localtime(&timer));
    printf("%s \033[1;32m[I]\033[0m ", now_time_str);

    va_list args;
    va_start(args, format);
    vprintf(format, args);
    va_end(args);
}

void *s_init(struct fuse_conn_info *conn, struct fuse_config *cfg)
{
    debug_printf("call s_init()\n");
    return NULL;
}

int s_getattr(const char *path, struct stat *stbuf, struct fuse_file_info *fi)
{
    debug_printf("call s_getattr(%s)\n", path);

    memset(stbuf, 0, sizeof(struct stat));
    if (strcmp(path, "/") == 0)
    {
        stbuf->st_mode = 0040755;
        return 0;
    }
    else if (strncmp(path + 1, filename, strlen(filename)) == 0)
    {
        // sleep(60);
        stbuf->st_mode = 0100444;
        stbuf->st_size = strlen(contents);
        return 0;
    }
    else
        return -2;
}

int s_readdir(const char *path, void *buf, fuse_fill_dir_t filler, off_t offset, struct fuse_file_info *fi, enum fuse_readdir_flags flags)
{
    debug_printf("call s_readdir(%s)\n", path);

    filler(buf, ".", NULL, 0, 0);
    filler(buf, "..", NULL, 0, 0);
    char name[20];
    for (int i = 0; i < 10; i++)
    {
        sprintf(name, "%s_%d", filename, i);
        filler(buf, name, NULL, 0, 0);
    }

    return 0;
}

int s_open(const char *path, struct fuse_file_info *fi)
{
    debug_printf("call s_open(%s)\n", path);
    return 0;
}

int s_read(const char *path, char *buf, size_t size, off_t offset, struct fuse_file_info *fi)
{
    debug_printf("call s_read(%s)\n", path);

    // sleep(60);
    size_t len = strlen(contents);
    if (offset >= len)
        return 0;
    size = size > len - offset ? len - offset : size;
    memcpy(buf, contents + offset, size);

    return size;
}

const struct fuse_operations s_op = {
    .init = s_init,
    .getattr = s_getattr,
    .readdir = s_readdir,
    .open = s_open,
    .read = s_read,
};

int main(int argc, char *argv[])
{
    return fuse_main(argc, argv, &s_op, NULL);
}

在 ArchLinux 上安装 fuse3 依赖后,需要在 /etc/fuse.conf 中取消 user_allow_other 的注释,方便 Nginx 默认的 http 用户访问当前用户挂载的 fuse。然后使用 gcc -Wall slow_mount.c `pkg-config fuse3 --cflags --libs` -o slow_mount 得到编译结果,最后执行 ./slow_mount -f -o allow_other -o auto_unmount /srv/http/mnt 将其挂载至 /srv/http/mnt/

此时在 /srv/http/mnt/ 目录下可以看到 slow_0slow_9 十个文本文件,其内容均为 123456789abcdef\n,访问过程中 slow_mount 会在日志中打印出具体的调用过程。例如执行 curl -v 127.0.0.1:8080/mnt/slow_0,对应输出为:

slow_mount_log

将 Nginx 配置文件中的 worker_processes 值减小至 2 来方便快速占满所有 worker,在 /srv/http/ 目录下创建内容为 ok 的文本文件 ok.txt 模拟同一 Nginx 上的其它服务,取消 slow_mount.c 源代码中 s_read() 函数内 sleep(60) 一行的注释重新编译挂载。

To Be Continued…