本文使用「署名 4.0 国际 (CC BY 4.0)」许可协议,欢迎转载、或重新修改使用,但需要注明来源。 [署名 4.0 国际 (CC BY 4.0)](https://creativecommons.org/licenses/by/4.0/deed.zh) 本文作者: 苏洋 创建时间: 2020年11月01日 统计字数: 12976字 阅读时间: 26分钟阅读 本文链接: https://soulteary.com/2020/11/01/use-nginx-to-build-a-front-end-log-statistics-service-service.html ----- # 使用 Nginx 构建前端日志统计服务(打点采集)服务 工作中经常会遇到需要“数据支撑”决策的时候,那么可曾想过这些数据从何而来呢?如果业务涉及 Web 服务,那么这些数据的来源之一便是服务器上各种服务器的请求数据,如果我们将专门用于统计的数据进行服务器区分,有一些服务器专注于接收“统计类型”的请求,那么产生的这些日志便是“打点日志”。 本文将介绍如何在容器中使用 Nginx 简单搭建一个支持前端使用的统计(打点采集)服务,避免引入过多的技术栈,徒增维护成本。 ## 写在前面 不知你是否想过一个问题,当一个页面中的打点事件比较多的时候,页面打开的瞬间将同时发起无数请求,此刻非宽带环境下用户体验将不复存在,打点服务器也将面临来自友军的业务 DDoS 行为。 所以这几年中,不断有公司将数据统计方案由 GET 切换为 POST 方案,结合自研定制的 SDK,对客户端的数据统计进行进行“打包合并”,并进行有一定频率的增量日志上报,极大的解决了前端性能问题、以及降低了服务器的压力。 五年前,我曾分享过如何[构建易于扩展的前端统计脚本](https://soulteary.com/2015/09/22/build-scalable-analytics-script.html),感兴趣可以进行关联阅读。 ## 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 ``` 你将看到下面的返回结果: ``` 405 Not Allowed

405 Not Allowed


nginx/1.19.3
``` 按图索骥,查看 Nginx 模块 [`modules/ngx_http_stub_status_module.c`](https://github.com/nginx/nginx/blob/4bf4650f2f10f7bbacfe7a33da744f18951d416d/src/http/modules/ngx_http_stub_status_module.c) 和 [`http/ngx_http_special_response.c`](https://github.com/nginx/nginx/blob/15544440425008d5ad39a295b826665ad56fdc90/src/http/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[] = "" CRLF "405 Not Allowed" CRLF "" CRLF "

405 Not Allowed

" 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](https://tools.ietf.org/html/rfc7231#section-6.5.5) 展示错误码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 ,可以轻松进行实例的水平扩展,处理更多的请求。感兴趣可以翻阅我[之前的文章](https://soulteary.com/tags/traefik.html)。 ## 最后 本文仅介绍了数据采集的皮毛,更多的内容或许后续有时间会细细道来。要给我家毛孩子付猫粮尾款啦,先写到这里吧。 --EOF