本文使用「署名 4.0 国际 (CC BY 4.0)」许可协议,欢迎转载、或重新修改使用,但需要注明来源。 [署名 4.0 国际 (CC BY 4.0)](https://creativecommons.org/licenses/by/4.0/deed.zh) 本文作者: 苏洋 创建时间: 2021年04月15日 统计字数: 5961字 阅读时间: 12分钟阅读 本文链接: https://soulteary.com/2021/04/15/use-docker-and-node-to-build-a-formula-rendering-service-part-2.html ----- # 使用 Docker 和 Node 搭建公式渲染服务(中篇) 在前篇文章[《使用 Docker 和 Node 搭建公式渲染服务(前篇)》](https://soulteary.com/2021/04/14/use-docker-and-node-to-build-a-formula-rendering-service-part-1.html)中,我们已经使用 Nginx 和开源软件 Math-API 搭建了一个基础的公式渲染服务。虽然在测试中可以正常工作,但是存在高并发的情况下服务压力过大,会导致预期之外的事情发生。 本篇文章,我们就接着上篇文章内容,在尽可能“不编码”的情况下,继续进行性能调优工作。 ## 写在前面 在公式服务实际的使用场景中,存在“首次生成公式图片后,内容被多次请求”,简而言之,满足“读多写少”的使用模式。 在对服务进行优化之前,我们先使用[“前篇”文章](https://soulteary.com/2021/04/14/use-docker-and-node-to-build-a-formula-rendering-service-part-1.html)的配置来启动服务,进行一些运行数据收集,作为服务优化前的参考基准。 ```bash server { listen 80; client_max_body_size 1k; access_log off; location / { if ( $arg_source = '') { return 404; } set $args source=$arg_source&input=latex&inline=0&output=svg&width=256; proxy_pass http://math-api:3000; } location = /get-health { access_log off; default_type text/html; return 200 'alive'; } } ``` 接着,请求下面的公式链接,让服务能够绘制一个简单复杂度的公式,并打开浏览器控制台,观察并记录页面响应时间。 ```bash http://localhost:3000/render?source=a=b\\E=mc^2%20+%20\int_a^a%20x\,%20dx ``` ![进行多次请求,并记录该配置下的响应性能](https://attachment.soulteary.com/2021/04/15/past-benchmark.png) 可以看到,首次绘制生成的请求响应接近 80ms ,随后应用创建内存缓存后,服务响应时间缩短到了 20ms ,虽然看起来数值尚可,但是在高并发的测试下,响应不是很理想。 前面提到,考虑到“绘制公式”是一次性的,而嵌入在页面中的图片则会被大量频繁访问,故先不针对绘制进行优化,而是选择对预期总请求量占比最高的“20ms”响应的缓存开刀。 在避免使用极端数值的前提下,随便抽取一次 Node 服务有缓存时的响应作为参考,稍后可以与我们优化后的结果进行对比。 ![某一次访问的详细情况](https://attachment.soulteary.com/2021/04/15/past-detail.png) ## 使用文件缓存提升服务性能 由于语言机制、工具使用场景的差异,相比 Nginx 而言,Node 执行效率会弱一些,尤其是处理偏静态的资源,而我们动态**绘制出的公式**,正是静态资源范畴。 在不借助三方模块、和外部应用的前提下,仅使用 Nginx 自带的“文件缓存”功能,已经能够完成一个读多写少、支持强缓存业务的性能优化。那么,我们来调整 Nginx 配置,让 Nginx 能够缓存来自 Node 的计算结果。 ```bash proxy_cache_path /data/nginx/cache levels=1:2 keys_zone=formula:10m max_size=2g inactive=24h use_temp_path=off; proxy_cache_key "$request_uri"; proxy_cache_valid any 24h; server { listen 80; client_max_body_size 1k; access_log on; location / { if ( $arg_source = '') { return 404; } set $args source=$arg_source&input=latex&inline=0&output=svg&width=256; proxy_cache formula; proxy_buffering on; proxy_ignore_headers Expires; proxy_hide_header Expires; proxy_ignore_headers X-Accel-Expires; proxy_hide_header X-Accel-Expires; proxy_ignore_headers Cache-Control; proxy_hide_header Cache-Control; proxy_ignore_headers Set-Cookie; proxy_hide_header Pragma; add_header X-Proxy-Cache $upstream_cache_status; proxy_pass http://math-api:3000; } location = /get-health { access_log off; default_type text/html; return 200 'alive'; } } ``` Nginx [官方教程](https://www.nginx.com/resources/wiki/start/topics/examples/reverseproxycachingexample/) 对于Proxy Cache 的介绍十分简短,在我们的场景下,需要额外添加一些指令,来确保我们的请求结果**一定能够被缓存**,主要包含以下三个场景: - 对于正确的结果,我们要进行缓存,避免重复进行计算。 - 当用户浏览器要求不使用 Nginx 缓存的时候,我们依旧能够使用缓存内容进行响应。 - 遭遇有意或无意构造错误请求,服务被攻击的时候,我们能够进行计算结果缓存,避免服务重复针对错误数据进行计算,浪费资源。 为了低成本持久化计算结果,可以将容器缓存写入位置挂载在本地或者其他合适的位置,在编排文件中进行类似下面的声明: ```yaml ... nginx: image: nginx:1.19.8-alpine restart: always ports: - 3000:80 volumes: - ./default.conf:/etc/nginx/conf.d/default.conf:ro - ./cache:/data/nginx/cache:rw ... ``` 在完成 Nginx 缓存功能配置后,再次请求相同的公式地址若干次,并对请求结果进行观察。 ![同样进行多次请求,记录该配置下的响应性能](https://attachment.soulteary.com/2021/04/15/nginx-benchmark.png) 可以看到缓存内容的响应时间从 20ms 缩短到了平均 5~6ms ,不严谨的说,原本处理一个请求的时间,我们可以完成 3~4 倍的服务支撑,并且因为 Nginx 在处理大并发的情况下,服务性能衰减远比 Node 低,实际性能提升非常可观(感兴趣可以深入的测试来进行验证)。 还是随便展开一个请求的详情,可以看到 TTFB 从 20ms 缩短到了 2ms。这里主要得益于语言红利以及 Nginx 针对缓存的特殊优化,感兴趣的同学可以围观 Nginx 关于 Cache 处理的源代码,探索 Nginx 在文件缓存上做了哪些工作:[`ngx_http_file_cache.c`](https://github.com/nginx/nginx/blob/1e92a0a4cef98902aed35d7b402a6a402951aba4/src/http/ngx_http_file_cache.c)。 ![同样进行多次请求,记录该配置下的响应性能](https://attachment.soulteary.com/2021/04/15/nginx-detail.png) ## 限制不合理的高频调用 前文使用文件缓存方式,针对高频访问的计算结果进行访问优化,初步解决了计算结果的缓存性能问题。我们来继续看看如何针对计算过程进行优化。 如果有心人构造足够多的未被请求、未能调用 Nginx 缓存的公式内容,构造“缓存击穿”场景,我们的服务可能会存在因为服务器总资源有限,“结果计算不过来”而导致拒绝服务,从而影响对正常用户的内容展示。 在不优化计算相关代码(Node)之前,我们能够解决这个问题的最简单方案便是针对请求进行频率限制。 Nginx 的频率限制,主要采取漏斗算法,官方曾推出过[一篇博文](https://www.nginx.com/blog/rate-limiting-nginx/),对这个功能进行详细的介绍,感兴趣的同学可以自行了解。我们在这里有一个共识即可:Nginx 会在遭遇峰值压力的时候,预设一个流量桶,针对桶内的请求使用先进先出的策略提供服务,对桶外的请求进行放弃操作。(每年的购物节似曾相识的场景,笑) 为了能够让 Nginx 进行请求限速,我们基于之前的配置进行一些调整: ```bash proxy_cache_path /data/nginx/cache levels=1:2 keys_zone=formula:10m max_size=2g inactive=24h use_temp_path=off; proxy_cache_key "$request_uri"; proxy_cache_valid any 24h; limit_req_zone $binary_remote_addr zone=limitbyip:10m rate=5r/s; server { listen 80; client_max_body_size 1k; access_log on; location / { limit_req zone=limitbyip burst=12 delay=8; if ( $arg_source = '') { return 404; } set $args source=$arg_source&input=latex&inline=0&output=svg&width=256; proxy_cache formula; proxy_buffering on; proxy_ignore_headers Expires; proxy_hide_header Expires; proxy_ignore_headers X-Accel-Expires; proxy_hide_header X-Accel-Expires; proxy_ignore_headers Cache-Control; proxy_hide_header Cache-Control; proxy_ignore_headers Set-Cookie; proxy_hide_header Pragma; add_header X-Proxy-Cache $upstream_cache_status; proxy_pass http://math-api:3000; } location = /get-health { access_log off; default_type text/html; return 200 'alive'; } } ``` 在上面的配置中,我们根据访客IP进行请求速率限制,假设一个页面最多会出现 12 张公式图,我们允许用户一次性并发下载其中的 8张图,随后 4 张在下载完毕第一批图片之后再进行下载。如果这个时间里,这个用户还在尝试请求更多的图片,那么我们将降低对这个用户的服务响应能力,允许他每秒获取 5 张图片,超出这个速率的请求将被当作溢出水桶的水,而被丢弃,毕竟这个场景不是“正常人”的行为。 更新完 Nginx 配置后,重新启动服务。使用 `wrk` 进行 16 线程 100 并发的高频率请求,模拟有人恶意访问服务(这里是不严谨模拟,访问的是相同的地址,相对严谨的模拟,需要编写脚本,进行动态改写参数,对服务进行独立部署,先偷个懒)。 ```bash wrk -t16 -c 100 -d 10s http://localhost:3000/render\?source\=a\=b\\E\=mc\^2%20+%20\int_a\^a%20x\,%20dx Running 10s test @ http://localhost:3000/render?source=a=b\E=mc^2%20+%20int_a^a%20x,%20dx 16 threads and 100 connections Thread Stats Avg Stdev Max +/- Stdev Latency 22.88ms 87.45ms 881.04ms 96.83% Req/Sec 733.13 157.63 1.42k 66.44% 117000 requests in 10.03s, 41.72MB read Non-2xx or 3xx responses: 117000 Requests/sec: 11662.60 Transfer/sec: 4.16MB ``` 根据测试请求日志,可以看到 10 秒钟内,我们的服务接收到了 1 万 1 千多次请求。 在请求的过程中,同样使用浏览器对服务状态进行相对直观的访问记录。 ![压力测试中,服务对于相同来源用户的响应](https://attachment.soulteary.com/2021/04/15/req-limit.png) 可以看到测试过程中,“正常合理”请求之外的请求都被返回了 “503 Service Temporarily Unavailable”,而非公式图片内容。这样做从根本上减少了服务绘制计算的并发压力,而请求结束后,再次进行访问,可以看到服务又很快的会恢复到正常的响应水平。 到此为止,一个基本能用的服务就完成了。 ## 其他 另外再提两个细节,实际生产使用,还需要配合 waf 或者 fail2ban,针对恶意的 IP 进行长时间的封禁,避免无意义的服务响应。 以及如果你是在容器内使用,需要将默认的 `binary_remote_addr` IP 字段替换为 SLB、CDN 或者其他可信来源传递的 IP 标识请求头,真正做到“无视非合理请求”,将资源留给正常用户。 ## 最后 关于公式渲染的前两篇内容,就先写到这里。 在这两篇内容中,我们尽可能使用配置来完成功能,但是仅仅是配置不足以完成极致的性能调整,下一篇内容中,我们将稍微调整应用代码、以及软件架构,来对服务性能进行进一步提升。 --EOF