工作中经常会遇到需要数据支撑决策的时候,那么可曾想过这些数据从何而来呢?如果业务涉及Web服务,那么这些数据的来源之一便是服务器上各种服务器的请求数据,如果我们将专门用于统计的数据进行服务器区分,有一些服务器专注于接收统计类型的请求,那么产生的这些日志便是打点日志。 本文将介绍如何在容器中使用Nginx简单搭建一个支持前端使用的统计(打点采集)服务,避免引入过多的技术栈,徒增维护成本。写在前面 不知你是否想过一个问题,当一个页面中的打点事件比较多的时候,页面打开的瞬间将同时发起无数请求,此刻非宽带环境下用户体验将不复存在,打点服务器也将面临来自友军的业务DDoS行为。 所以这几年中,不断有公司将数据统计方案由GET切换为POST方案,结合自研定制的SDK,对客户端的数据统计进行进行打包合并,并进行有一定频率的增量日志上报,极大的解决了前端性能问题、以及降低了服务器的压力。 五年前,我曾分享过如何构建易于扩展的前端统计脚本,感兴趣可以进行关联阅读。POST请求在Nginx环境下的问题 看到这个小节的标题,你或许会感到迷惑,日常对Nginx进行POST交互司空见惯,会有什么问题呢? 我们不妨做一个小实验,使用容器启动一个Nginx服务:dockerrunrmitp3000:80nginx:1。19。3alpine 然后使用curl模拟日常业务中的POST请求:curld{key1:value1,key2:value2}XPOSThttp:localhost:3000 你将看到下面的返回结果:htmlheadtitle405NotAllowedtitleheadbodycenterh1405NotAllowedh1centerhrcenternginx1。19。3centerbodyhtml 按图索骥,查看Nginx模块modulesngxhttpstubstatusmodule。c和httpngxhttpspecialresponse。c的源码可以看到下面的实现:staticngxinttngxhttpstubstatushandler(ngxhttprequesttr){sizetsize;ngxinttrc;ngxbuftb;ngxchaintout;ngxatomicinttap,hn,ac,rq,rd,wr,wa;if(!(rmethod(NGXHTTPGETNGXHTTPHEAD))){returnNGXHTTPNOTALLOWED;}。。。}。。。staticcharngxhttperror405page〔〕htmlCRLFheadtitle405NotAllowedtitleheadCRLFbodyCRLFcenterh1405NotAllowedh1centerCRLF;defineNGXHTTPOFF4XX(NGXHTTPLAST3XX301NGXHTTPOFF3XX)。。。ngxstring(ngxhttperror405page),ngxstring(ngxhttperror406page),。。。 没错,默认情况下,NGINX并不支持记录POST请求,会根据RFC7231展示错误码405。所以一般情况下,我们会借助LuaJavaPHPGoNode等动态语言进行辅助解析。 那么如何来解决这个问题呢?能否单纯的使用性能好、又轻量的Nginx来完成对POST请求的支持,而不借助外力吗?让Nginx原生支持POST请求 为了更清晰的展示配置,我们接下来使用compose来启动Nginx进行实验,在编写脚本之前,我们需要先获取配置文件,使用下面的命令行将指定版本的Nginx的配置文件保存到当前目录中。dockerrunrmitnginx:1。19。3alpinecatetcnginxconf。ddefault。confdefault。conf 默认的配置文件内容如下:server{listen80;servernamelocalhost;charsetkoi8r;accesslogvarlognginxhost。access。logmain;location{rootusrsharenginxhtml;indexindex。htmlindex。htm;}errorpage404404。html;redirectservererrorpagestothestaticpage50x。htmlerrorpage50050250350450x。html;location50x。html{rootusrsharenginxhtml;}proxythePHPscriptstoApachelisteningon127。0。0。1:80location。php{proxypasshttp:127。0。0。1;}passthePHPscriptstoFastCGIserverlisteningon127。0。0。1:9000location。php{roothtml;fastcgipass127。0。0。1:9000;fastcgiindexindex。php;fastcgiparamSCRIPTFILENAMEscriptsfastcgiscriptname;includefastcgiparams;}denyaccessto。htaccessfiles,ifApachesdocumentrootconcurswithnginxsonelocation。ht{denyall;}} 稍作精简,我们会得到一个更简单的配置文件,并在其中添加一行errorpage405200uri;:server{listen80;servernamelocalhost;charsetutf8;location{return200soulteary;}errorpage405200uri;} 将本小节开始部分的命令改写为dockercompose。yml并添加volumes,把刚刚导出的配置文件映射到容器内,方便使用后续使用compose启动容器进行验证。version:3services:ngx:image:nginx:1。19。3alpinerestart:alwaysports:3000:80volumes:。default。conf:etcnginxconf。ddefault。conf 使用dockercomposeup启动服务,然后使用前面的curl模拟POST验证请求是否正常。curld{key1:value1,key2:value2}HContentType:applicationjsonHorigin:gray。baai。ac。cnXPOSThttp:localhost:3000soulteary 执行完毕,除了得到soulteary这个字符串返回之外,Nginx日志记录也会多一条看起来正常的记录:ngx1192。168。16。1〔31Oct2020:14:24:480000〕POSTHTTP1。12000curl7。64。1 但是,如果你细心的话,你会发现日志中并未包含我们发送的数据,那么这个问题该如何解决呢?解决Nginx日志中丢失的POST数据 这个问题其实是老生常谈,默认Nginx服务器记录日志格式并不包含POSTBody(性能考虑),并且在没有proxypass的情况下,是不会解析POSTBody的。 先执行下面的命令:dockerrunrmitnginx:1。19。3alpinecatetcnginxnginx。conf 可以看到默认的logformat配置规则中确实并没有任何关于POSTBody中的数据。usernginx;workerprocessesauto;errorlogvarlognginxerror。logwarn;pidvarrunnginx。pid;events{workerconnections1024;}http{includeetcnginxmime。types;defaulttypeapplicationoctetstream;logformatmainremoteaddrremoteuser〔timelocal〕requeststatusbodybytessenthttprefererhttpuseragenthttpxforwardedfor;accesslogvarlognginxaccess。logmain;sendfileon;tcpnopushon;keepalivetimeout65;gzipon;includeetcnginxconf。d。conf;} 所以解决这个问题的方案也不难,新增一个日志格式,添加POSTBody变量(requestbody),然后添加一个proxypass路径,激活Nginx解析POSTBody的处理逻辑。 考虑到维护问题,我们前文中的配置文件与这个配置进行合并,并定义一个名为internalapipath的路径:usernginx;workerprocessesauto;errorlogvarlognginxerror。logwarn;pidvarrunnginx。pid;events{workerconnections1024;}http{includeetcnginxmime。types;defaulttypeapplicationoctetstream;logformatmainremoteaddrremoteuser〔timelocal〕requeststatusbodybytessenthttprefererhttpuseragenthttpxforwardedforrequestbody;accesslogvarlognginxaccess。logmain;sendfileon;keepalivetimeout65;server{listen80;servernamelocalhost;charsetutf8;location{proxypasshttp:127。0。0。1internalapipath;}locationinternalapipath{accesslogoff;defaulttypeapplicationjson;return200{code:0,data:soulteary};}errorpage405200uri;}} 将新配置文件保存为nginx。conf后,调整compose中的volumes配置信息,再次使用dockercomposeup启动服务。volumes:。nginx。conf:etcnginxnginx。conf 再次使用curl模拟之前的POST请求,会看到Nginx日志多了两条记录,第一条记录中包含了我们所需要的POST数据:192。168。192。1〔31Oct2020:15:05:480000〕POSTHTTP1。120029curl7。64。1{key1:value1,key2:value2}127。0。0。1〔31Oct2020:15:05:480000〕POSTinternalapipathHTTP1。020029curl7。64。1 但是这里不完美的地方还有很多:服务器可以正常接收GET请求,我们在日志处理的时候需要进行大量抛弃动作,并且在暂存的时候,磁盘空间也存在不必要的浪费。用于激活NginxPOSTBody解析能力的路径可以被随意调用,产生无意义日志,同样存在上面的问题。更关键的,日志中的数据看起来还需要额外加工处理,进行转码,解析效率会有不必要的性能损耗。 接下来我们来继续解决这些问题。改进Nginx配置,优化日志记录 首先,在日志格式中添加escapejson参数,要求Nginx解析日志请求中的JSON数据:logformatmainescapejsonremoteaddrremoteuser〔timelocal〕requeststatusbodybytessenthttprefererhttpuseragenthttpxforwardedforrequestbody; 然后,在不需要记录日志的路径中,添加accesslogoff;指令,避免不必要的日志进行记录。locationinternalapipath{accesslogoff;defaulttypeapplicationjson;return200{code:0,data:soulteary};} 接着使用Nginxmap指令,和Nginx中的条件判断,过滤非POST请求的日志记录,以及拒绝处理非POST请求。maprequestmethodloggable{default0;POST1;}。。。server{location{if(requestmethod!POST){return405;}accesslogvarlognginxaccess。logmainifloggable;proxypasshttp:127。0。0。1internalapipath;}。。。} 再次使用curl请求,会看到日志已经能够正常解析,不会出现两条日志了。192。168。224。1〔31Oct2020:15:19:590000〕POSTHTTP1。120029curl7。64。1{key1:value1,key2:value2} 同时,也不会再记录任何非POST请求,使用POST请求的时候,会提示405错误状态。 这个时候,你或许会好奇,为什么这个405和前文中不同,不会被重定向为200呢?这是因为这个405是我们根据触发条件手动设置的,而非Nginx逻辑运行过程中判断出新的结果。 当前的Nginx配置如下:usernginx;workerprocessesauto;errorlogvarlognginxerror。logwarn;pidvarrunnginx。pid;events{workerconnections1024;}http{includeetcnginxmime。types;defaulttypeapplicationoctetstream;logformatmainescapejsonremoteaddrremoteuser〔timelocal〕requeststatusbodybytessenthttprefererhttpuseragenthttpxforwardedforrequestbody;sendfileon;keepalivetimeout65;maprequestmethodloggable{default0;POST1;}server{listen80;servernamelocalhost;charsetutf8;location{if(requestmethod!POST){return405;}accesslogvarlognginxaccess。logmainifloggable;proxypasshttp:127。0。0。1internalapipath;}locationinternalapipath{accesslogoff;defaulttypeapplicationjson;return200{code:0,data:soulteary};}errorpage405200uri;}} 但是到这里就真的结束了吗?模拟前端客户端常见跨域请求 我们打开熟悉的百度,在控制台中输入下面的代码,模拟一次常见的业务跨域请求。asyncfunctiontestCorsPost(url,data{}){constresponseawaitfetch(url,{method:POST,mode:cors,cache:nocache,credentials:sameorigin,headers:{ContentType:applicationjson},redirect:follow,referrerPolicy:noreferrer,body:JSON。stringify(data)});returnresponse。json();}testCorsPost(http:localhost:3000,{hello:soulteary})。then(dataconsole。log(data)); 代码执行完毕后,你会看到一个经典的提示信息:Accesstofetchathttp:localhost:3000fromoriginhttps:www。baidu。comhasbeenblockedbyCORSpolicy:Responsetopreflightrequestdoesntpassaccesscontrolcheck:NoAccessControlAllowOriginheaderispresentontherequestedresource。Ifanopaqueresponseservesyourneeds,settherequestsmodetonocorstofetchtheresourcewithCORSdisabled。POSThttp:localhost:3000net::ERRFAILED 观察Network网络面板,会看到有两条失败的新请求:RequestURL:http:localhost:3000RequestMethod:OPTIONSStatusCode:405NotAllowedRequestURL:http:localhost:3000RequestMethod:POST没有响应结果 让我们继续调整配置,解决这个常见的问题吧。使用Nginx解决前端跨域问题 我们首先调整之前的过滤规则,允许OPTIONS请求的处理。if(requestmethod!(POSTOPTIONS)){return405;} 跨域请求是前端常见场景,许多人会偷懒使用来解决问题,但是Chrome等现代浏览器在新版本中有些场景不能使用这样宽松的规则,而且为了业务安全,一般情况,我们会在服务端设置允许进行跨域请求的域名白名单,参考上文中的方式,我们可以很容易的定义出类似下面的Nginxmap配置,来谢绝所有前端非授权跨域请求:maphttporigincorsHost{default0;(。)。soulteary。com1;(。)。baidu。com1;}server{。。。location{。。。if(corsHost0){return405;}。。。}} 这里有一个trick的地方,Nginx的路由内的规则编写,并不完全类似级编程语言一样,可以顺序执行,是具备优先级覆盖关系的,所以为了能够让前端正常调用接口进行数据提交,这里需要这样书写规则,存在四行代码冗余。if(corsHost0){return405;}if(corsHost1){不需要CookieaddheaderAccessControlAllowCredentialsfalse;addheaderAccessControlAllowHeadersAccept,Authorization,CacheControl,ContentType,DNT,IfModifiedSince,KeepAlive,Origin,UserAgent,XMxReqToken,XRequestedWith,Date,Pragma;addheaderAccessControlAllowMethodsPOST,OPTIONS;addheaderAccessControlAllowOriginhttporigin;}OPTION请求返回204,并去掉BODY响应,因NGINX限制,需要重复上面的前四行配置if(requestmethodOPTIONS){addheaderAccessControlAllowCredentialsfalse;addheaderAccessControlAllowHeadersAccept,Authorization,CacheControl,ContentType,DNT,IfModifiedSince,KeepAlive,Origin,UserAgent,XMxReqToken,XRequestedWith,Date,Pragma;addheaderAccessControlAllowMethodsPOST,OPTIONS;addheaderAccessControlAllowOriginhttporigin;addheaderAccessControlMaxAge1728000;addheaderContentTypetextplaincharsetUTF8;addheaderContentLength0;return204;} 再次在网页中执行前面的JavaScript代码,会发现请求已经可以正常执行了,前端数据会返回:{code:0,data:soulteary} 而Nginx日志,则会多一条符合预期的记录:172。20。0。1〔31Oct2020:15:49:170000〕POSTHTTP1。120031Mozilla5。0(Macintosh;IntelMacOSX10156)AppleWebKit537。36(KHTML,likeGecko)Chrome86。0。4240。111Safari537。36{hello:soulteary} 而使用curl执行之前的命令,继续模拟纯接口调用,则会发现出现了405错误响应,这是因为我们的请求中不包含origin请求头,无法表明我们的来源身份,在请求中使用H参数补全这个数据,即可拿到符合预期的返回:curld{key1:value1,key2:value2}HContentType:applicationjsonHorigin:www。baidu。comXPOSThttp:localhost:3000{code:0,data:soulteary}相对完整的Nginx配置 到现在为止,我们基本实现一般的采集功能,满足基本诉求的Nginx配置信息如下:usernginx;workerprocessesauto;errorlogvarlognginxerror。logwarn;pidvarrunnginx。pid;events{workerconnections1024;}http{includeetcnginxmime。types;defaulttypeapplicationoctetstream;logformatmainescapejsonremoteaddrremoteuser〔timelocal〕requeststatusbodybytessenthttprefererhttpuseragenthttpxforwardedforrequestbody;sendfileon;keepalivetimeout65;maprequestmethodloggable{default0;POST1;}maphttporigincorsHost{default0;(。)。soulteary。com1;(。)。baidu。com1;}server{listen80;servernamelocalhost;charsetutf8;location{if(requestmethod!(POSTOPTIONS)){return405;}accesslogvarlognginxaccess。logmainifloggable;if(corsHost0){return405;}if(corsHost1){不需要CookieaddheaderAccessControlAllowCredentialsfalse;addheaderAccessControlAllowHeadersAccept,Authorization,CacheControl,ContentType,DNT,IfModifiedSince,KeepAlive,Origin,UserAgent,XMxReqToken,XRequestedWith,Date,Pragma;addheaderAccessControlAllowMethodsPOST,OPTIONS;addheaderAccessControlAllowOriginhttporigin;}OPTION请求返回204,并去掉BODY响应,因NGINX限制,需要重复上面的前四行配置if(requestmethodOPTIONS){addheaderAccessControlAllowCredentialsfalse;addheaderAccessControlAllowHeadersAccept,Authorization,CacheControl,ContentType,DNT,IfModifiedSince,KeepAlive,Origin,UserAgent,XMxReqToken,XRequestedWith,Date,Pragma;addheaderAccessControlAllowMethodsPOST,OPTIONS;addheaderAccessControlAllowOriginhttporigin;addheaderAccessControlMaxAge1728000;addheaderContentTypetextplaincharsetUTF8;addheaderContentLength0;return204;}proxypasshttp:127。0。0。1internalapipath;}locationinternalapipath{accesslogoff;defaulttypeapplicationjson;return200{code:0,data:soulteary};}errorpage405200uri;}} 如果我们结合容器使用,只需要在其中添加一段额外的路由定义,单独用于健康检查,就能够实现一个简单稳定的采集服务。继续对接后续的数据转存、处理程序。locationhealth{accesslogoff;return200;} 而compose配置文件,相比较之前,不过多了几行健康检查定义罢了:version:3services:ngx:image:nginx:1。19。3alpinerestart:alwaysports:3000:80volumes:etclocaltime:etclocaltime:roetctimezone:etctimezone:ro。nginx。conf:etcnginxnginx。confhealthcheck:test:wgetspiderlocalhosthealthexit1interval:5stimeout:10sretries:3 结合Traefik,可以轻松进行实例的水平扩展,处理更多的请求。感兴趣可以翻阅我之前的文章。最后 本文仅介绍了数据采集的皮毛,更多的内容或许后续有时间会细细道来。要给我家毛孩子付猫粮尾款啦,先写到这里吧。