工作中经常会遇到需要“数据支撑”决策的时候,那么可曾想过这些数据从何而来呢?如果业务涉及 Web 服务,那么这些数据的来源之一便是服务器上各种服务器的请求数据,如果我们将专门用于统计的数据进行服务器区分,有一些服务器专注于接收“统计类型”的请求,那么产生的这些日志便是“打点日志”。

本文将介绍如何在容器中使用 Nginx 简单搭建一个支持前端使用的统计(打点采集)服务,避免引入过多的技术栈,徒增维护成本。

写在前面

不知你是否想过一个问题,当一个页面中的打点事件比较多的时候,页面打开的瞬间将同时发起无数请求,此刻非宽带环境下用户体验将不复存在,打点服务器也将面临来自友军的业务 DDoS 行为。

所以这几年中,不断有公司将数据统计方案由 GET 切换为 POST 方案,结合自研定制的 SDK,对客户端的数据统计进行进行“打包合并”,并进行有一定频率的增量日志上报,极大的解决了前端性能问题、以及降低了服务器的压力。

五年前,我曾分享过如何构建易于扩展的前端统计脚本,感兴趣可以进行关联阅读。

POST 请求在 Nginx 环境下的问题

看到这个小节的标题,你或许会感到迷惑,日常对 Nginx 进行 POST 交互司空见惯,会有什么问题呢?

我们不妨做一个小实验,使用容器启动一个 Nginx 服务:

docker run --rm -it -p 3000:80 nginx:1.19.3-alpine

然后使用 curl 模拟日常业务中的 POST 请求:

curl -d '{"key1":"value1", "key2":"value2"}' -X POST http://localhost:3000

你将看到下面的返回结果:

<html>
<head><title>405 Not Allowed</title></head>
<body>
<center><h1>405 Not Allowed</h1></center>
<hr><center>nginx/1.19.3</center>
</body>
</html>

按图索骥,查看 Nginx 模块 modules/ngx_http_stub_status_module.chttp/ngx_http_special_response.c的源码可以看到下面的实现:

static ngx_int_t
ngx_http_stub_status_handler(ngx_http_request_t *r)
{
    size_t             size;
    ngx_int_t          rc;
    ngx_buf_t         *b;
    ngx_chain_t        out;
    ngx_atomic_int_t   ap, hn, ac, rq, rd, wr, wa;

    if (!(r->method & (NGX_HTTP_GET|NGX_HTTP_HEAD))) {
        return NGX_HTTP_NOT_ALLOWED;
    }
...
}


...

static char ngx_http_error_405_page[] =
"<html>" CRLF
"<head><title>405 Not Allowed</title></head>" CRLF
"<body>" CRLF
"<center><h1>405 Not Allowed</h1></center>" CRLF
;


#define NGX_HTTP_OFF_4XX   (NGX_HTTP_LAST_3XX - 301 + NGX_HTTP_OFF_3XX)

...
    ngx_string(ngx_http_error_405_page),
    ngx_string(ngx_http_error_406_page),
...

没错,默认情况下,NGINX 并不支持记录 POST 请求,会根据 RFC7231 展示错误码405。所以一般情况下,我们会借助 Lua /Java / PHP / Go / Node 等动态语言进行辅助解析。

那么如何来解决这个问题呢?能否单纯的使用性能好、又轻量的 Nginx 来完成对 POST 请求的支持,而不借助外力吗?

让 Nginx “原生”支持 POST 请求

为了更清晰的展示配置,我们接下来使用 compose 来启动 Nginx 进行实验,在编写脚本之前,我们需要先获取配置文件,使用下面的命令行将指定版本的 Nginx 的配置文件保存到当前目录中。

docker run --rm -it nginx:1.19.3-alpine cat /etc/nginx/conf.d/default.conf > default.conf

默认的配置文件内容如下:

server {
    listen       80;
    server_name  localhost;

    #charset koi8-r;
    #access_log  /var/log/nginx/host.access.log  main;

    location / {
        root   /usr/share/nginx/html;
        index  index.html index.htm;
    }

    #error_page  404              /404.html;

    # redirect server error pages to the static page /50x.html
    #
    error_page   500 502 503 504  /50x.html;
    location = /50x.html {
        root   /usr/share/nginx/html;
    }

    # proxy the PHP scripts to Apache listening on 127.0.0.1:80
    #
    #location ~ \.php$ {
    #    proxy_pass   http://127.0.0.1;
    #}

    # pass the PHP scripts to FastCGI server listening on 127.0.0.1:9000
    #
    #location ~ \.php$ {
    #    root           html;
    #    fastcgi_pass   127.0.0.1:9000;
    #    fastcgi_index  index.php;
    #    fastcgi_param  SCRIPT_FILENAME  /scripts$fastcgi_script_name;
    #    include        fastcgi_params;
    #}

    # deny access to .htaccess files, if Apache's document root
    # concurs with nginx's one
    #
    #location ~ /\.ht {
    #    deny  all;
    #}
}

稍作精简,我们会得到一个更简单的配置文件,并在其中添加一行 error_page 405 =200 $uri;

server {
    listen 80;
    server_name localhost;
    charset utf-8;

    location / {
        return 200 "soulteary";
    }

    error_page 405 =200 $uri;
}

将本小节开始部分的命令改写为 docker-compose.yml 并添加 volumes,把刚刚导出的配置文件映射到容器内,方便使用后续使用 compose 启动容器进行验证。

version: "3"

services:

  ngx:
    image: nginx:1.19.3-alpine
    restart: always
    ports:
      - 3000:80
    volumes:
      - ./default.conf/:/etc/nginx/conf.d/default.conf

使用 docker-compose up 启动服务,然后使用前面的 curl 模拟 POST 验证请求是否正常。

curl -d '{"key1":"value1", "key2":"value2"}' -H "Content-Type: application/json" -H "origin:gray.baai.ac.cn" -X POST http://localhost:3000

soulteary

执行完毕,除了得到 “soulteary” 这个字符串返回之外, Nginx 日志记录也会多一条看起来正常的记录:

ngx_1  | 192.168.16.1 - - [31/Oct/2020:14:24:48 +0000] "POST / HTTP/1.1" 200 0 "-" "curl/7.64.1" "-"

但是,如果你细心的话,你会发现日志中并未包含我们发送的数据,那么这个问题该如何解决呢?

解决 Nginx 日志中丢失的 POST 数据

这个问题其实是老生常谈,默认 Nginx 服务器记录日志格式并不包含 POST Body(性能考虑),并且在没有 proxy_pass 的情况下,是不会解析 POST Body的。

先执行下面的命令:

docker run --rm -it nginx:1.19.3-alpine cat /etc/nginx/nginx.conf

可以看到默认的 log_format 配置规则中确实并没有任何关于 POST Body 中的数据。

user  nginx;
worker_processes  auto;

error_log  /var/log/nginx/error.log warn;
pid        /var/run/nginx.pid;


events {
    worker_connections  1024;
}


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

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

    access_log  /var/log/nginx/access.log  main;

    sendfile        on;
    #tcp_nopush     on;

    keepalive_timeout  65;

    #gzip  on;

    include /etc/nginx/conf.d/*.conf;
}

所以解决这个问题的方案也不难,新增一个日志格式,添加 POST Body 变量(request_body),然后添加一个 proxy_pass 路径,激活 Nginx 解析 POST Body 的处理逻辑。

考虑到维护问题,我们前文中的配置文件与这个配置进行合并,并定义一个名为 /internal-api-path 的路径:

user nginx;
worker_processes auto;

error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;

events {
    worker_connections 1024;
}

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

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

    access_log /var/log/nginx/access.log main;
    sendfile on;
    keepalive_timeout 65;

    server {
        listen 80;
        server_name localhost;
        charset utf-8;

        location / {
            proxy_pass http://127.0.0.1/internal-api-path;
        }

        location /internal-api-path {
            # access_log off;
            default_type application/json;
            return 200 '{"code": 0, data:"soulteary"}';
        }

        error_page 405 =200 $uri;
    }
}

将新配置文件保存为 nginx.conf 后,调整 compose 中的 volumes 配置信息,再次使用 docker-compose up 启动服务。

volumes:
  - ./nginx.conf/:/etc/nginx/nginx.conf

再次使用 curl 模拟之前的 POST 请求,会看到 Nginx 日志多了两条记录,第一条记录中包含了我们所需要的 POST 数据:

192.168.192.1 - - [31/Oct/2020:15:05:48 +0000] "POST / HTTP/1.1" 200 29 "-" "curl/7.64.1" "-" {\x22key1\x22:\x22value1\x22, \x22key2\x22:\x22value2\x22}
127.0.0.1 - - [31/Oct/2020:15:05:48 +0000] "POST /internal-api-path HTTP/1.0" 200 29 "-" "curl/7.64.1" "-" -

但是这里不完美的地方还有很多:

  • 服务器可以正常接收 GET 请求,我们在日志处理的时候需要进行大量“抛弃动作”,并且在暂存的时候,磁盘空间也存在不必要的浪费。
  • 用于激活 Nginx POST Body 解析能力的路径可以被随意调用,产生无意义日志,同样存在上面的问题。
  • 更关键的,日志中的数据看起来还需要额外加工处理,进行转码,解析效率会有不必要的性能损耗。

接下来我们来继续解决这些问题。

改进 Nginx 配置,优化日志记录

首先,在日志格式中添加 escape=json 参数,要求 Nginx 解析日志请求中的 JSON 数据:

log_format main escape=json '$remote_addr - $remote_user [$time_local] "$request" '
    '$status $body_bytes_sent "$http_referer" '
    '"$http_user_agent" "$http_x_forwarded_for" $request_body';

然后,在不需要记录日志的路径中,添加 access_log off; 指令,避免不必要的日志进行记录。

location /internal-api-path {
    access_log off;
    default_type application/json;
    return 200 '{"code": 0, data:"soulteary"}';
}

接着使用 Nginx map 指令,和 Nginx 中的条件判断,过滤非 POST 请求的日志记录,以及拒绝处理非 POST 请求。

map $request_method $loggable {
    default 0;
    POST 1;
}
...
server {
    location / {
        if ( $request_method !~ ^POST$ ) { return 405; }
        access_log /var/log/nginx/access.log main if=$loggable;
        proxy_pass http://127.0.0.1/internal-api-path;
    }
...
}

再次使用 curl 请求,会看到日志已经能够正常解析,不会出现两条日志了。

192.168.224.1 -  [31/Oct/2020:15:19:59 +0000] "POST / HTTP/1.1" 200 29 "" "curl/7.64.1" "" {\"key1\":\"value1\", \"key2\":\"value2\"}

同时,也不会再记录任何非 POST 请求,使用 POST 请求的时候,会提示 405 错误状态。

这个时候,你或许会好奇,为什么这个 405 和前文中不同,不会被重定向为 200 呢?这是因为这个 405 是我们根据触发条件“手动设置”的,而非 Nginx 逻辑运行过程中判断出新的结果。

当前的 Nginx 配置如下:

user nginx;
worker_processes auto;

error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;

events {
    worker_connections 1024;
}

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

    log_format main escape=json '$remote_addr - $remote_user [$time_local] "$request" '
    '$status $body_bytes_sent "$http_referer" '
    '"$http_user_agent" "$http_x_forwarded_for" $request_body';

    sendfile on;
    keepalive_timeout 65;

    map $request_method $loggable {
        default 0;
        POST 1;
    }

    server {
        listen 80;
        server_name localhost;
        charset utf-8;


        location / {
            if ( $request_method !~ ^POST$ ) { return 405; }
            access_log /var/log/nginx/access.log main if=$loggable;
            proxy_pass http://127.0.0.1/internal-api-path;
        }

        location /internal-api-path {
            access_log off;
            default_type application/json;
            return 200 '{"code": 0, "data":"soulteary"}';
        }

        error_page 405 =200 $uri;
    }
}

但是到这里就真的结束了吗?

模拟前端客户端常见跨域请求

我们打开熟悉的“百度”,在控制台中输入下面的代码,模拟一次常见的业务跨域请求。

async function testCorsPost(url = '', data = {}) {
    const response = await fetch(url, {
        method: 'POST',
        mode: 'cors',
        cache: 'no-cache',
        credentials: 'same-origin',
        headers: { 'Content-Type': 'application/json' },
        redirect: 'follow',
        referrerPolicy: 'no-referrer',
        body: JSON.stringify(data)
    });
    return response.json();
}

testCorsPost('http://localhost:3000', { hello: "soulteary" }).then(data => console.log(data));

代码执行完毕后,你会看到一个经典的提示信息:

Access to fetch at 'http://localhost:3000/' from origin 'https://www.baidu.com' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. If an opaque response serves your needs, set the request's mode to 'no-cors' to fetch the resource with CORS disabled.

POST http://localhost:3000/ net::ERR_FAILED

观察 Network 网络面板,会看到有两条失败的新请求:

  • Request URL: http://localhost:3000/
    • Request Method: OPTIONS
    • Status Code: 405 Not Allowed
  • Request URL: http://localhost:3000/
    • Request Method: POST
    • 没有响应结果

让我们继续调整配置,解决这个常见的问题吧。

使用 Nginx 解决前端跨域问题

我们首先调整之前的过滤规则,允许 OPTIONS 请求的处理。

if ( $request_method !~ ^(POST|OPTIONS)$ ) { return 405; }

跨域请求是前端常见场景,许多人会偷懒使用 “*”来解决问题,但是 Chrome 等现代浏览器在新版本中有些场景不能使用这样宽松的规则,而且为了业务安全,一般情况,我们会在服务端设置允许进行跨域请求的域名白名单,参考上文中的方式,我们可以很容易的定义出类似下面的 Nginx map 配置,来谢绝所有前端非授权跨域请求:

map $http_origin $corsHost {
    default 0;
    "~(.*).soulteary.com"  1;
    "~(.*).baidu.com"   1;
}

server {
...
  location / {
    ...
    if ( $corsHost = 0 ) { return 405; }
    ...
  }
}

这里有一个 trick 的地方,Nginx 的路由内的规则编写,并不完全类似级编程语言一样,可以顺序执行,是具备“优先级/覆盖”关系的,所以为了能够让前端正常调用接口进行数据提交,这里需要这样书写规则,存在四行代码冗余。

if ( $corsHost = 0 ) { return 405; }
if ( $corsHost = 1 ) {
# 不需要 Cookie
    add_header 'Access-Control-Allow-Credentials'   'false';
    add_header 'Access-Control-Allow-Headers'       'Accept,Authorization,Cache-Control,Content-Type,DNT,If-Modified-Since,Keep-Alive,Origin,User-Agent,X-Mx-ReqToken,X-Requested-With,Date,Pragma';
    add_header 'Access-Control-Allow-Methods'       'POST,OPTIONS';
    add_header 'Access-Control-Allow-Origin'        '$http_origin';
}
# OPTION 请求返回 204 ,并去掉 BODY响应,因 NGINX 限制,需要重复上面的前四行配置
if ($request_method = 'OPTIONS') {
    add_header 'Access-Control-Allow-Credentials'   'false';
    add_header 'Access-Control-Allow-Headers'       'Accept,Authorization,Cache-Control,Content-Type,DNT,If-Modified-Since,Keep-Alive,Origin,User-Agent,X-Mx-ReqToken,X-Requested-With,Date,Pragma';
    add_header 'Access-Control-Allow-Methods'       'POST,OPTIONS';
    add_header 'Access-Control-Allow-Origin'        '$http_origin';
    add_header 'Access-Control-Max-Age' 1728000;
    add_header 'Content-Type' 'text/plain charset=UTF-8';
    add_header 'Content-Length' 0;
    return 204;
}

再次在网页中执行前面的 JavaScript 代码,会发现请求已经可以正常执行了,前端数据会返回:

{code: 0, data: "soulteary"}

而 Nginx 日志,则会多一条符合预期的记录:

172.20.0.1 -  [31/Oct/2020:15:49:17 +0000] "POST / HTTP/1.1" 200 31 "" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.111 Safari/537.36" "" {\"hello\":\"soulteary\"}

而使用 curl 执行之前的命令,继续模拟纯接口调用,则会发现出现了 405 错误响应,这是因为我们的请求中不包含 origin 请求头,无法表明我们的来源身份,在请求中使用 -H 参数补全这个数据,即可拿到符合预期的返回:

curl -d '{"key1":"value1", "key2":"value2"}' -H "Content-Type: application/json" -H "origin:www.baidu.com" -X POST http://localhost:3000/

{"code": 0, "data":"soulteary"}

相对完整的 Nginx 配置

到现在为止,我们基本实现一般的采集功能,满足基本诉求的 Nginx 配置信息如下:

user nginx;
worker_processes auto;

error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;

events {
    worker_connections 1024;
}

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

    log_format main escape=json '$remote_addr - $remote_user [$time_local] "$request" '
    '$status $body_bytes_sent "$http_referer" '
    '"$http_user_agent" "$http_x_forwarded_for" $request_body';

    sendfile on;
    keepalive_timeout 65;

    map $request_method $loggable {
        default 0;
        POST 1;
    }

    map $http_origin $corsHost {
        default 0;
        "~(.*).soulteary.com"  1;
        "~(.*).baidu.com"   1;
    }

    server {
        listen 80;
        server_name localhost;
        charset utf-8;


        location / {
            if ( $request_method !~ ^(POST|OPTIONS)$ ) { return 405; }
            access_log /var/log/nginx/access.log main if=$loggable;

            if ( $corsHost = 0 ) { return 405; }
            if ( $corsHost = 1 ) {
            # 不需要 Cookie
                add_header 'Access-Control-Allow-Credentials'   'false';
                add_header 'Access-Control-Allow-Headers'       'Accept,Authorization,Cache-Control,Content-Type,DNT,If-Modified-Since,Keep-Alive,Origin,User-Agent,X-Mx-ReqToken,X-Requested-With,Date,Pragma';
                add_header 'Access-Control-Allow-Methods'       'POST,OPTIONS';
                add_header 'Access-Control-Allow-Origin'        '$http_origin';
            }
            # OPTION 请求返回 204 ,并去掉 BODY响应,因 NGINX 限制,需要重复上面的前四行配置
            if ($request_method = 'OPTIONS') {
                add_header 'Access-Control-Allow-Credentials'   'false';
                add_header 'Access-Control-Allow-Headers'       'Accept,Authorization,Cache-Control,Content-Type,DNT,If-Modified-Since,Keep-Alive,Origin,User-Agent,X-Mx-ReqToken,X-Requested-With,Date,Pragma';
                add_header 'Access-Control-Allow-Methods'       'POST,OPTIONS';
                add_header 'Access-Control-Allow-Origin'        '$http_origin';
                add_header 'Access-Control-Max-Age' 1728000;
                add_header 'Content-Type' 'text/plain charset=UTF-8';
                add_header 'Content-Length' 0;
                return 204;
            }

            proxy_pass http://127.0.0.1/internal-api-path;
        }

        location /internal-api-path {
            access_log off;
            default_type application/json;
            return 200 '{"code": 0, "data":"soulteary"}';
        }

        error_page 405 =200 $uri;
    }
}

如果我们结合容器使用,只需要在其中添加一段额外的路由定义,单独用于健康检查,就能够实现一个简单稳定的采集服务。继续对接后续的数据转存、处理程序。

location /health {
    access_log off;
    return 200;
}

而 compose 配置文件,相比较之前,不过多了几行健康检查定义罢了:

version: "3"

services:

  ngx:
    image: nginx:1.19.3-alpine
    restart: always
    ports:
      - 3000:80
    volumes:
      - /etc/localtime:/etc/localtime:ro
      - /etc/timezone:/etc/timezone:ro
      - ./nginx.conf:/etc/nginx/nginx.conf
    healthcheck:
      test: wget --spider localhost/health || exit 1
      interval: 5s
      timeout: 10s
      retries: 3

结合 Traefik ,可以轻松进行实例的水平扩展,处理更多的请求。感兴趣可以翻阅我之前的文章

最后

本文仅介绍了数据采集的皮毛,更多的内容或许后续有时间会细细道来。要给我家毛孩子付猫粮尾款啦,先写到这里吧。

–EOF