9

使用 Docker 和 Node 搭建公式渲染服务(中篇)

 3 years ago
source link: https://soulteary.com/2021/04/15/use-docker-and-node-to-build-a-formula-rendering-service-part-2.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

使用 Docker 和 Node 搭建公式渲染服务(中篇)

2021年04月15日阅读Markdown格式5961字12分钟阅读

在前篇文章《使用 Docker 和 Node 搭建公式渲染服务(前篇)》中,我们已经使用 Nginx 和开源软件 Math-API 搭建了一个基础的公式渲染服务。虽然在测试中可以正常工作,但是存在高并发的情况下服务压力过大,会导致预期之外的事情发生。

本篇文章,我们就接着上篇文章内容,在尽可能“不编码”的情况下,继续进行性能调优工作。

在公式服务实际的使用场景中,存在“首次生成公式图片后,内容被多次请求”,简而言之,满足“读多写少”的使用模式。

在对服务进行优化之前,我们先使用“前篇”文章的配置来启动服务,进行一些运行数据收集,作为服务优化前的参考基准。

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';
    }
}

接着,请求下面的公式链接,让服务能够绘制一个简单复杂度的公式,并打开浏览器控制台,观察并记录页面响应时间。

http://localhost:3000/render?source=a=b\\E=mc^2%20+%20\int_a^a%20x\,%20dx

进行多次请求,并记录该配置下的响应性能

进行多次请求,并记录该配置下的响应性能

可以看到,首次绘制生成的请求响应接近 80ms ,随后应用创建内存缓存后,服务响应时间缩短到了 20ms ,虽然看起来数值尚可,但是在高并发的测试下,响应不是很理想。

前面提到,考虑到“绘制公式”是一次性的,而嵌入在页面中的图片则会被大量频繁访问,故先不针对绘制进行优化,而是选择对预期总请求量占比最高的“20ms”响应的缓存开刀。

在避免使用极端数值的前提下,随便抽取一次 Node 服务有缓存时的响应作为参考,稍后可以与我们优化后的结果进行对比。

某一次访问的详细情况

某一次访问的详细情况

使用文件缓存提升服务性能

由于语言机制、工具使用场景的差异,相比 Nginx 而言,Node 执行效率会弱一些,尤其是处理偏静态的资源,而我们动态绘制出的公式,正是静态资源范畴。

在不借助三方模块、和外部应用的前提下,仅使用 Nginx 自带的“文件缓存”功能,已经能够完成一个读多写少、支持强缓存业务的性能优化。那么,我们来调整 Nginx 配置,让 Nginx 能够缓存来自 Node 的计算结果。

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 官方教程 对于Proxy Cache 的介绍十分简短,在我们的场景下,需要额外添加一些指令,来确保我们的请求结果一定能够被缓存,主要包含以下三个场景:

  • 对于正确的结果,我们要进行缓存,避免重复进行计算。
  • 当用户浏览器要求不使用 Nginx 缓存的时候,我们依旧能够使用缓存内容进行响应。
  • 遭遇有意或无意构造错误请求,服务被攻击的时候,我们能够进行计算结果缓存,避免服务重复针对错误数据进行计算,浪费资源。

为了低成本持久化计算结果,可以将容器缓存写入位置挂载在本地或者其他合适的位置,在编排文件中进行类似下面的声明:

...
  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 缓存功能配置后,再次请求相同的公式地址若干次,并对请求结果进行观察。

同样进行多次请求,记录该配置下的响应性能

同样进行多次请求,记录该配置下的响应性能

可以看到缓存内容的响应时间从 20ms 缩短到了平均 5~6ms ,不严谨的说,原本处理一个请求的时间,我们可以完成 3~4 倍的服务支撑,并且因为 Nginx 在处理大并发的情况下,服务性能衰减远比 Node 低,实际性能提升非常可观(感兴趣可以深入的测试来进行验证)。

还是随便展开一个请求的详情,可以看到 TTFB 从 20ms 缩短到了 2ms。这里主要得益于语言红利以及 Nginx 针对缓存的特殊优化,感兴趣的同学可以围观 Nginx 关于 Cache 处理的源代码,探索 Nginx 在文件缓存上做了哪些工作:ngx_http_file_cache.c

同样进行多次请求,记录该配置下的响应性能

同样进行多次请求,记录该配置下的响应性能

限制不合理的高频调用

前文使用文件缓存方式,针对高频访问的计算结果进行访问优化,初步解决了计算结果的缓存性能问题。我们来继续看看如何针对计算过程进行优化。

如果有心人构造足够多的未被请求、未能调用 Nginx 缓存的公式内容,构造“缓存击穿”场景,我们的服务可能会存在因为服务器总资源有限,“结果计算不过来”而导致拒绝服务,从而影响对正常用户的内容展示。

在不优化计算相关代码(Node)之前,我们能够解决这个问题的最简单方案便是针对请求进行频率限制。

Nginx 的频率限制,主要采取漏斗算法,官方曾推出过一篇博文,对这个功能进行详细的介绍,感兴趣的同学可以自行了解。我们在这里有一个共识即可:Nginx 会在遭遇峰值压力的时候,预设一个流量桶,针对桶内的请求使用先进先出的策略提供服务,对桶外的请求进行放弃操作。(每年的购物节似曾相识的场景,笑)

为了能够让 Nginx 进行请求限速,我们基于之前的配置进行一些调整:

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 并发的高频率请求,模拟有人恶意访问服务(这里是不严谨模拟,访问的是相同的地址,相对严谨的模拟,需要编写脚本,进行动态改写参数,对服务进行独立部署,先偷个懒)。

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 千多次请求。

在请求的过程中,同样使用浏览器对服务状态进行相对直观的访问记录。

压力测试中,服务对于相同来源用户的响应

压力测试中,服务对于相同来源用户的响应

可以看到测试过程中,“正常合理”请求之外的请求都被返回了 “503 Service Temporarily Unavailable”,而非公式图片内容。这样做从根本上减少了服务绘制计算的并发压力,而请求结束后,再次进行访问,可以看到服务又很快的会恢复到正常的响应水平。

到此为止,一个基本能用的服务就完成了。

另外再提两个细节,实际生产使用,还需要配合 waf 或者 fail2ban,针对恶意的 IP 进行长时间的封禁,避免无意义的服务响应。

以及如果你是在容器内使用,需要将默认的 binary_remote_addr IP 字段替换为 SLB、CDN 或者其他可信来源传递的 IP 标识请求头,真正做到“无视非合理请求”,将资源留给正常用户。

关于公式渲染的前两篇内容,就先写到这里。

在这两篇内容中,我们尽可能使用配置来完成功能,但是仅仅是配置不足以完成极致的性能调整,下一篇内容中,我们将稍微调整应用代码、以及软件架构,来对服务性能进行进一步提升。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK