三年前我曾写过一篇《使用 Docker 和 Nginx 打造高性能的二维码服务》,时过境迁,容器软件、基础系统、Nginx、QRCode 依赖库都经历了版本升级,为了构建可维护,性能更好的 QRCode 生成服务,就有了本篇折腾内容。

希望本篇内容的出现可以帮到同样需要减少各种语言、框架中二维码生成实现代码的你。

写在前面

TLDR,如果你是曾经的读者,可以直接访问下面的链接,然后搭建属于你的高性能二维码服务,镜像非常小巧,DockerHub 上显示只有 13.47MB,如果你下载解压到本地,也仅有 32.9MB,相比 Nginx 官方相同版本最小的镜像只大了 10MB。

如果你希望了解这个服务是怎么构建的,可以接着阅读下面的章节。如果你想了解该如何使用,可以直接翻阅至使用部分。

准备源代码

这里需要准备三份代码:Nginxlibqrencodengx_http_qrcode_module

Nginx的代码版本选择和基础镜像版本一致就好;libqrencode 在 alpine 软件仓库中的版本太过陈旧,我们这里使用最新的发布版本 4.1.1;ngx_http_qrcode_module 作者没有准备版本,所以这里我将代码 fork 了一份,做了一些细节修改,并打上了一个名为 2020.01.06 的版本。

这样做还有一个好处,如果软件代码没有版本,我们只能通过 Git 或者 Zipball 方式下载,这两种方式我们都还需要在镜像中多安装一款对应的软件进行代码下载或者解压缩,而使用 “release” 后的版本代码,则可以直接使用系统镜像自带的 tar 来处理压缩包,进一步控制镜像大小:

NGINX_VERSION=1.19.6
LIBQR_VERSION=4.1.1
NGX_LIBQR_VERSION=20210106

curl -L https://github.com/fukuchi/libqrencode/archive/v${LIBQR_VERSION}.tar.gz -o "v${LIBQR_VERSION}.tar.gz" 
curl -L "http://nginx.org/download/nginx-${NGINX_VERSION}.tar.gz" -o nginx.tar.gz
curl -L "https://github.com/soulteary/ngx_http_qrcode_module/archive/${NGX_LIBQR_VERSION}.tar.gz" -o ${NGX_LIBQR_VERSION}.tar.gz

构建服务镜像

之前构建服务的时候,采用的是使用通用基础镜像编译 Nginx 和它的“小伙伴”(模块),在三年后的今天,我们不妨直接使用 Nginx 基础镜像,所谓“原汤化原食”,最大限度复用官方提供的环境、配置参数、入口脚本…毕竟,偷懒是工程师的美德。

FROM nginx:1.19.6-alpine

准备 Nginx 构建环境

虽然我们使用 Nginx 官方镜像作为基础镜像,但是因为要再次构建 Nginx,所以基础构建工具必不可少。从官方镜像源文件中,我们可以找到必备的工具的安装命令:

apk add --no-cache --virtual .build-deps gcc libc-dev make openssl-dev pcre-dev zlib-dev linux-headers libxslt-dev gd-dev geoip-dev perl-dev libedit-dev mercurial bash alpine-sdk findutils

这里在安装软件的时候声明安全列表名称为 .build-deps,方便用完后一键清理,节约镜像容量。

准备 QRCode 构建环境

根据官方文档,在 alpine 中找到各种依赖包的名称,和处理 Nginx 构建环境时一样,将依赖安装列表声明为 .build-qrcode

apk add --no-cache --virtual .build-qrcode openssl-dev pcre-dev zlib-dev build-base autoconf automake libtool libpng-dev libgd pcre pcre-dev pkgconfig gd-dev

编译 QREncode 依赖库

编译 QREncode 非常简单,先使用 autogen 脚本生成配置文件,接着就是“C语言编译安装”一键三连常规操作:

tar -zxC /usr/src -f v${LIBQR_VERSION}.tar.gz
cd /usr/src/libqrencode-${LIBQR_VERSION} && \
   ./autogen.sh && LDFLAGS=-lgd ./configure && \
   make && make install

编译 Nginx 执行文件

前文提到我们为什么要使用 Nginx 官方镜像来进行编译构建,因为能“偷懒”,原汁原味复用官方构建配置和运行环境:

tar -zxC /usr/src -f nginx.tar.gz && \
cd /usr/src/nginx-$NGINX_VERSION && \

CONFARGS=$(nginx -V 2>&1 | sed -n -e 's/^.*arguments: //p') \
CONFARGS=${CONFARGS/-Os -fomit-frame-pointer/-Os} && \
echo $CONFARGS && \

./configure --with-compat $CONFARGS --add-module=../ngx_http_qrcode_module/ && \
make && make install && \

使用 sed 将 nginx -V 输出参数进行截断,然后使用字符串替换方式去掉我们不需要的参数,在 Nginx 配置过程中,使用 --with-compat 参数将“官方”参数拼合到命令中即可节约我们大量精力去折腾基础配置。

如果你决定使用 ubuntu 或者 debian 版本的镜像(比如官方不带 alpine)的镜像,进行构建,这里获取参数需要使用临时 shell 文件进行中转,因为不同的 shell 对于引号的处理模式有些不同,例如下面这样:

CONFARGS=$(nginx -V 2>&1 | sed -n -e 's/^.*arguments: //p') \
echo "./configure --with-compat $CONFARGS --add-module=../ngx_http_qrcode_module/">tmpconf.sh && chmod 755 tmpconf.sh 
    && ./tmpconf.sh && rm tmpconf.sh

如果使用非 alpine 镜像,除了上面的内容外,还需要补全一些基础依赖,比如 pcre 等。

完成的配置文件

将上面的配置进行整合,稍作调整,就能够得到完成的 Docker 镜像配置文件了。

FROM nginx:1.19.6-alpine

ARG NGINX_VERSION=1.19.6
ARG LIBQR_VERSION=4.1.1
ARG NGX_LIBQR_VERSION=20210106

RUN apk add --no-cache --virtual .build-deps gcc libc-dev make openssl-dev pcre-dev zlib-dev linux-headers libxslt-dev gd-dev geoip-dev perl-dev libedit-dev mercurial bash alpine-sdk findutils && \
    apk add --no-cache --virtual .build-qrcode openssl-dev pcre-dev zlib-dev build-base autoconf automake libtool libpng-dev libgd pcre pcre-dev pkgconfig gd-dev && \
    mkdir -p /usr/src && cd /usr/src && \
    curl -L https://github.com/fukuchi/libqrencode/archive/v${LIBQR_VERSION}.tar.gz -o "v${LIBQR_VERSION}.tar.gz" && \
    tar -zxC /usr/src -f v${LIBQR_VERSION}.tar.gz && \
    cd /usr/src/libqrencode-${LIBQR_VERSION} && ./autogen.sh && LDFLAGS=-lgd ./configure && make && make install && cd /usr/src && \
    curl -L "http://nginx.org/download/nginx-${NGINX_VERSION}.tar.gz" -o nginx.tar.gz && \
    curl -L "https://github.com/soulteary/ngx_http_qrcode_module/archive/${NGX_LIBQR_VERSION}.tar.gz" -o ${NGX_LIBQR_VERSION}.tar.gz && \
    tar zxvf ${NGX_LIBQR_VERSION}.tar.gz && mv ngx_http_qrcode_module-${NGX_LIBQR_VERSION} ngx_http_qrcode_module && \
    tar -zxC /usr/src -f nginx.tar.gz && \
    cd /usr/src/nginx-$NGINX_VERSION && \
    CONFARGS=$(nginx -V 2>&1 | sed -n -e 's/^.*arguments: //p') \
    CONFARGS=${CONFARGS/-Os -fomit-frame-pointer/-Os} && \
    echo $CONFARGS && \
    ./configure --with-compat $CONFARGS --add-module=../ngx_http_qrcode_module/ && \
    make && make install && \
    apk del .build-deps .build-qrcode && \
    rm -rf /tmp/* && rm -rf /var/cache/apk/* && rm -rf /usr/src/ && \
    curl -L https://raw.githubusercontent.com/soulteary/ngx_http_qrcode_module/master/conf/nginx.conf -o /etc/nginx/nginx.conf

如果你在国内构建,希望构建速度变快,可以在镜像执行软件安全前添加一句命令,对软件源进行修改,再进行构建操作:

RUN cat /etc/apk/repositories | sed -e "s/dl-cdn.alpinelinux.org/mirrors.aliyun.com/" | tee /etc/apk/repositories

基础使用

前文提到,我已经将代码和镜像提到了官方仓库,所以如果你只是想了解如何做,和想使用,使用下面的命令可以一键获取已经构建好的镜像文件。

docker pull soulteary/nginx-qrcode-server:release-2021.01.06

如果你希望直接查看效果,可以使用 docker 基础命令将服务启动在本机的某个端口:

docker run --rm -it -p 8080:80 soulteary/nginx-qrcode-server:release-2021.01.06

然后打开浏览器,访问 http://localhost:8080,即可看到服务正常运行(展示一个默认二维码)。

三年前一样,你可以访问类似 http://localhost:8080/?size=150&margin=20&txt=https%3A%2F%2Fsoulteary.com 来尝试通过调整 URL 参数获得更加适合你的使用场景的生成结果。

默认配置

如果你想进行一些细节调整,可以参考默认配置,将其修改为更符合你使用场景的配置。

worker_processes  1;

events {
    worker_connections  1024;
}


http {
    include       mime.types;
    default_type  application/octet-stream;

    sendfile        on;

    keepalive_timeout  65;

    server {
        listen       80;
        server_name  localhost;

		location / {

		    set $fg_color 000000;
		    set $bg_color FFFFFF;
		    set $level 0;
		    set $hint 2;
		    set $size 300;
		    set $margin 80;
		    set $version 2;
		    set $case 0;
		    set $txt "https://soulteary.com";

		    if ( $arg_fg_color ){
                set $fg_color $arg_fg_color;
		    }
		    if ( $arg_bg_color ){
                set $bg_color $arg_bg_color;
		    }
		    if ( $arg_level ){
                set $level $arg_level;
		    }
		    if ( $arg_hint ){
                set $hint $arg_hint;
            }
            if ( $arg_size ){
                set $size $arg_size;
            }
            if ( $arg_margin ){
                set $margin $arg_margin;
            }
            if ( $arg_ver ){
                set $version $arg_ver;
            }
            if ( $arg_case ){
                set $case $arg_case;
            }
            if ( $arg_txt ){
                set $txt $arg_txt;
            }


			qrcode_fg_color $fg_color;
			qrcode_bg_color $bg_color;

			qrcode_level    $level;
			qrcode_hint     $hint;
			qrcode_size     $size;
			qrcode_margin   $margin;
			qrcode_version  $version;
			qrcode_casesensitive $case;
			qrcode_urlencode_txt $txt;

			qrcode_gen;
		}

    }
}

简单性能测试

这个模式下,默认足够应对一些基础场景,我们先以笔记本为环境,使用 ab 进行简单进行性能测试:

ab -n 1000 -c 10 -r http://localhost:8080/

This is ApacheBench, Version 2.3 <$Revision: 1879490 $>
Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/
Licensed to The Apache Software Foundation, http://www.apache.org/

Benchmarking localhost (be patient)
Completed 100 requests
Completed 200 requests
Completed 300 requests
Completed 400 requests
Completed 500 requests
Completed 600 requests
Completed 700 requests
Completed 800 requests
Completed 900 requests
Completed 1000 requests
Finished 1000 requests


Server Software:        nginx/1.19.6
Server Hostname:        localhost
Server Port:            8080

Document Path:          /
Document Length:        579 bytes

Concurrency Level:      10
Time taken for tests:   2.711 seconds
Complete requests:      1000
Failed requests:        0
Total transferred:      722000 bytes
HTML transferred:       579000 bytes
Requests per second:    368.91 [#/sec] (mean)
Time per request:       27.107 [ms] (mean)
Time per request:       2.711 [ms] (mean, across all concurrent requests)
Transfer rate:          260.11 [Kbytes/sec] received

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0    0   0.1      0       1
Processing:     5   27   2.6     26      39
Waiting:        5   27   2.7     26      39
Total:          6   27   2.7     26      39

Percentage of the requests served within a certain time (ms)
  50%     26
  66%     27
  75%     28
  80%     28
  90%     30
  95%     31
  98%     34
  99%     38
 100%     39 (longest request)

可以看到本地单机在使用默认配置,不进行优化的情况下,默认 QPS 是 368,计算响应时间在 3 毫秒内。足够一般的业务或小规模场景使用,毕竟你不会真的只使用一台和笔记本性能差不多的机器作为生产服务器。

如果换上一台小规格的(4C4G)的云服务器,并使用另外一台机器进行访问性能测试,可以看到单机器单实例,性能并不会有太多波动,依旧是每个请求大概消耗3 毫秒。

Server Software:        nginx/1.19.6
Server Hostname:        192.168.93.25
Server Port:            8080

Document Path:          /?txt=123
Document Length:        557 bytes

Concurrency Level:      10
Time taken for tests:   2.960 seconds
Complete requests:      1000
Failed requests:        0
Total transferred:      700000 bytes
HTML transferred:       557000 bytes
Requests per second:    337.79 [#/sec] (mean)
Time per request:       29.604 [ms] (mean)
Time per request:       2.960 [ms] (mean, across all concurrent requests)
Transfer rate:          230.91 [Kbytes/sec] received

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0    0   0.1      0       3
Processing:     4   29   1.5     29      32
Waiting:        4   29   1.5     29      32
Total:          4   29   1.4     30      32

如果你的请求密集程度不高,单是这样的配置的机器和运行方式,假设请求分布均匀,足够满足每分钟一百万次的请求。但是现实中请求一定存在高峰和低谷,所以接下来,我们再来进行一些基础优化,加强应对高并发场景的能力。

搭配内存缓存实现高性能展示

因为本方案中挑战高性能二维码生成,本质是依赖高性能的生成工具,以及 Nginx 异步非阻塞 IO ,依赖 CPU 密集计算实现。所以为了进一步提升服务能力,可以下手的点除了继续优化代码之外,最简单的方案便是堆无状态的可水平扩展实例数量和增加缓存,减少不必要的重复计算,把CPU让给更有计算需要的“请求”。

同时我们看到响应时间已经在个位数毫秒级别,为了进一步提升性能,这里务必要避免资源落盘,最优解是 Nginx 本身应用内存,次优解是各种能够保持长链接的内存缓存应用。

Nginx 本身并不开放自身的内存,但是为了满足这类需求,从大概十年前就提供了外部内存模块(ngx_http_memcached_module),可以在不改动代码的情况下使用这个模块来完成计算内容的持久化,以及请求不落磁盘。

水平扩展实例,通过重复启动容器可以轻松做到,搭配 SLB、HAProxy 、甚至是 Nginx 都可以,这里依旧选择 Traefik 作为前端,只需要一条启动命令,服务注册、负载均衡就都完事了。

先给出 docker-compose.yml 完整配置,使用 K8S 的同学可以参考修改。

version: "3.6"

services:

  qrcode:
    image: soulteary/nginx-qrcode-server:release-2021.01.06
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf:ro
    networks:
      - traefik
    labels:
      - traefik.enable=true
      - traefik.docker.network=traefik
      - traefik.http.routers.qrcode-www.rule=Host(`qrcode.lab.io`)
      - traefik.http.routers.qrcode-www.entrypoints=http
      - traefik.http.routers.qrcode-ssl.rule=Host(`qrcode.lab.io`)
      - traefik.http.routers.qrcode-ssl.entrypoints=https
      - traefik.http.routers.qrcode-ssl.tls=true
      - traefik.http.services.qrcode-backend.loadbalancer.server.scheme=http
      - traefik.http.services.qrcode-backend.loadbalancer.server.port=80
    expose:
      - 80
    restart: always
    depends_on: 
      - memcached
    environment:
      - TZ=Asia/Shanghai
    logging:
        driver: "json-file"
        options:
            max-size: "10m"

  memcached:
    image: memcached:1.6.9-alpine
    expose:
      - 11211
    networks:
      - traefik
    restart: always
    logging:
        driver: "json-file"
        options:
            max-size: "10m"

networks:
  traefik:
    external: true

因为要和 memcached “梦幻联动”,所以我们还需要修改默认的 Nginx 配置文件:

worker_processes 1;

events {
    worker_connections 1024;
}

http {
    include mime.types;
    default_type application/octet-stream;

    sendfile on;
    keepalive_timeout 65;

    upstream memcache_server {
        server memcached:11211;
        keepalive 512;
    }


    server {
        listen 80;
        server_name localhost;

        location = /favicon.ico {
            access_log off; empty_gif;
        }

        memcached_buffer_size 4k;
        memcached_connect_timeout 100ms;
        memcached_read_timeout 100ms;
        memcached_send_timeout 100ms;
        memcached_socket_keepalive on;

        location / {
            set $memcached_key "$uri?$args";
            #or set $memcached_key $query_string;
            memcached_pass memcache_server;
            error_page 404 502 504 = @private;
        }


        location @private {
            # internal;
            add_header X-Cache-Key $memcached_key;

            set $fg_color 000000;
            set $bg_color FFFFFF;
            set $level 0;
            set $hint 2;
            set $size 300;
            set $margin 80;
            set $version 2;
            set $case 0;
            set $txt "https://soulteary.com";

            if ( $arg_fg_color ) {
                set $fg_color $arg_fg_color;
            }
            if ( $arg_bg_color ) {
                set $bg_color $arg_bg_color;
            }
            if ( $arg_level ) {
                set $level $arg_level;
            }
            if ( $arg_hint ) {
                set $hint $arg_hint;
            }
            if ( $arg_size ) {
                set $size $arg_size;
            }
            if ( $arg_margin ) {
                set $margin $arg_margin;
            }
            if ( $arg_ver ) {
                set $version $arg_ver;
            }
            if ( $arg_case ) {
                set $case $arg_case;
            }
            if ( $arg_txt ) {
                set $txt $arg_txt;
            }

            qrcode_fg_color $fg_color;
            qrcode_bg_color $bg_color;

            qrcode_level $level;
            qrcode_hint $hint;
            qrcode_size $size;
            qrcode_margin $margin;
            qrcode_version $version;
            qrcode_casesensitive $case;
            qrcode_urlencode_txt $txt;

            qrcode_gen;
        }


    }
}

使用 docker-compose up --sacale qrcode=4 -d 一键启动四个相同的 QRCode 实例。

接着使用 ab 再次在另外一台机器上对这台机器进行网络请求测试,并适当增大测试请求数量,多次测试可以看到 4C4G 的云服务器的 QPS 提升到了 600+,而单个请求的响应时间缩短到了 1.6 毫秒左右,差不多是单机每分钟可承受200万次请求的服务能力。

实际生产场景,我们会使用核心数更多的机器、以及增加机器节点数量,可以带来更大的服务响应能力,在应对突发流量时,可通过云服务弹性部署,进一步提升响应能力。

这里吐槽一下,我的笔记本单机单实例的情况,居然QPS到达了2700,云虚拟机上的性能测试真的是看脸。

ab -n 10000 -c 10 http://qrcode.lab.io/?txt=123
This is ApacheBench, Version 2.3 <$Revision: 1807734 $>
Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/
Licensed to The Apache Software Foundation, http://www.apache.org/

Benchmarking qrcode.lab.io (be patient)
Completed 1000 requests
Completed 2000 requests
Completed 3000 requests
Completed 4000 requests
Completed 5000 requests
Completed 6000 requests
Completed 7000 requests
Completed 8000 requests
Completed 9000 requests
Completed 10000 requests
Finished 10000 requests


Server Software:        nginx/1.19.6
Server Hostname:        qrcode.lab.io
Server Port:            80

Document Path:          /?txt=123
Document Length:        557 bytes

Concurrency Level:      10
Time taken for tests:   16.185 seconds
Complete requests:      10000
Failed requests:        0
Total transferred:      7050000 bytes
HTML transferred:       5570000 bytes
Requests per second:    617.86 [#/sec] (mean)
Time per request:       16.185 [ms] (mean)
Time per request:       1.618 [ms] (mean, across all concurrent requests)
Transfer rate:          425.38 [Kbytes/sec] received

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0    0   0.1      0       5
Processing:     4   16   7.7     14      67
Waiting:        4   16   7.7     14      67
Total:          4   16   7.7     14      67

Percentage of the requests served within a certain time (ms)
  50%     14
  66%     18
  75%     20
  80%     22
  90%     27
  95%     32
  98%     36
  99%     40
 100%     67 (longest request)

此外,观察服务器 CPU 使用情况,发现可以轻松将 CPU 打满,丝毫不会浪费你的每一分钱。

线上使用,根据自己需求水平扩展相同规格的几台虚拟机,并水平扩展实例个数,即可实现满足自己业务需求的高性能 QRCode 服务啦,当然,如果你的二维码生成需求是确定的,可以减少 Nginx 配置中动态的部分,让一些配置“常量化”,进一步减少计算量,以及避免一些恶意的请求浪费计算资源。

因为我们使用了 Nginx,这里如果想设置服务能力上限,避免资源被滥用,也可以通过 Nginx 常规方式快捷的实现一些功能需求:设置 LimitReq 来限制和避免一些外部恶意请求,以及结合日志进分析来获取二维码生成和访问统计计数等需求。

最后

原本想使用二阶段构建,将 Ngx_QRCode 模块构建为动态模块,构建出一套更小的镜像。但是因为调用 GD 库编译存在一些问题,暂时作罢,有时间再搞吧。

相比三年前的镜像,这次构建结果小了一半之多,还是挺欣慰的。

–EOF