前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >使用 Nginx 构建前端日志统计服务(打点采集)服务

使用 Nginx 构建前端日志统计服务(打点采集)服务

作者头像
soulteary
发布2020-11-09 15:41:03
1.3K0
发布2020-11-09 15:41:03
举报

使用 Nginx 构建前端日志统计服务(打点采集)服务

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

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

写在前面

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

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

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

POST 请求在 Nginx 环境下的问题

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

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

代码语言:javascript
复制
docker run --rm -it -p 3000:80 nginx:1.19.3-alpine

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

代码语言:javascript
复制
curl -d '{"key1":"value1", "key2":"value2"}' -X POST http://localhost:3000

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

代码语言:javascript
复制
<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的源码可以看到下面的实现:

代码语言:javascript
复制
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 的配置文件保存到当前目录中。

代码语言:javascript
复制
docker run --rm -it nginx:1.19.3-alpine cat /etc/nginx/conf.d/default.conf > default.conf

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

代码语言:javascript
复制
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;

代码语言:javascript
复制
server {
    listen 80;
    server_name localhost;
    charset utf-8;

    location / {
        return 200 "soulteary";
    }

    error_page 405 =200 $uri;
}

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

代码语言:javascript
复制
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 验证请求是否正常。

代码语言:javascript
复制
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 日志记录也会多一条看起来正常的记录:

代码语言:javascript
复制
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的。

先执行下面的命令:

代码语言:javascript
复制
docker run --rm -it nginx:1.19.3-alpine cat /etc/nginx/nginx.conf

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

代码语言:javascript
复制
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 的路径:

代码语言:javascript
复制
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 启动服务。

代码语言:javascript
复制
volumes:
  - ./nginx.conf/:/etc/nginx/nginx.conf

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

代码语言:javascript
复制
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 数据:

代码语言:javascript
复制
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; 指令,避免不必要的日志进行记录。

代码语言:javascript
复制
location /internal-api-path {
    access_log off;
    default_type application/json;
    return 200 '{"code": 0, data:"soulteary"}';
}

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

代码语言:javascript
复制
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 请求,会看到日志已经能够正常解析,不会出现两条日志了。

代码语言:javascript
复制
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 配置如下:

代码语言:javascript
复制
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;
    }
}

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

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

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

代码语言:javascript
复制
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));

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

代码语言:javascript
复制
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 请求的处理。

代码语言:javascript
复制
if ( $request_method !~ ^(POST|OPTIONS)$ ) { return 405; }

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

代码语言:javascript
复制
map $http_origin $corsHost {
    default 0;
    "~(.*).soulteary.com"  1;
    "~(.*).baidu.com"   1;
}

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

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

代码语言:javascript
复制
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 代码,会发现请求已经可以正常执行了,前端数据会返回:

代码语言:javascript
复制
{code: 0, data: "soulteary"}

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

代码语言:javascript
复制
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 参数补全这个数据,即可拿到符合预期的返回:

代码语言:javascript
复制
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 配置信息如下:

代码语言:javascript
复制
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;
    }
}

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

代码语言:javascript
复制
location /health {
    access_log off;
    return 200;
}

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

代码语言:javascript
复制
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

本文使用「署名 4.0 国际 (CC BY 4.0)」许可协议,欢迎转载、或重新修改使用,但需要注明来源。署名 4.0 国际 (CC BY 4.0)

本文作者: 苏洋

本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2020-11-01,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 折腾技术 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 使用 Nginx 构建前端日志统计服务(打点采集)服务
    • 写在前面
      • POST 请求在 Nginx 环境下的问题
        • 让 Nginx “原生”支持 POST 请求
          • 解决 Nginx 日志中丢失的 POST 数据
            • 改进 Nginx 配置,优化日志记录
              • 模拟前端客户端常见跨域请求
                • 使用 Nginx 解决前端跨域问题
                  • 相对完整的 Nginx 配置
                    • 最后
                    相关产品与服务
                    命令行工具
                    腾讯云命令行工具 TCCLI 是管理腾讯云资源的统一工具。使用腾讯云命令行工具,您可以快速调用腾讯云 API 来管理您的腾讯云资源。此外,您还可以基于腾讯云的命令行工具来做自动化和脚本处理,以更多样的方式进行组合和重用。
                    领券
                    问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档