上篇文章提到如何在容器环境中使用阿里云离线IP地理位置库,前文中测试性能看起来满足日常离线小样本、低频率私密调用性能没有大的问题,但是针对大量数据的场景,再不搭建集群多实例的情况下,显然是无法满足需求的。
本篇记录 C++ 版本 SDK 的踩坑过程,希望帮助后面的同学节约折腾时间。另外希望看到本文的产品 PD 同学可以尽快推动文档完善。
写在前面
查询 IP 的场景分为在线和离线两种模式,对于有大量大尺寸访问日志的场景,一般因为以下原因,会放弃在线公有云 API 调用:
- 对于机器资源要求颇高,需要更多的节点、更大的带宽
- 跨区域存在很高的时延
- 需要针对存量日志进行流式处理或者切分,执行效率上也有着非常大的限制
- 服务需要额外的运维成本
所以需要转变思路,寻找更高效的解决方案:
- 内网部署,减少网络开销
- 简化接口实现,提升执行效率
- 使用集群提供更高性能的服务能力
官方目前提供了三个版本的 SDK (Java 、Python 、C++),下载了解发现 Python 和 C++ 版本本质都是 C++ 实现,从性能角度考虑,我们放弃了 Java 版本,上篇已经从 Python 版本实现过了,本篇从 C++ 版本入手。因为当前生产服务都使用了容器部署维护,所以这里也需要考虑容器化实现。
基于官方 C++ SDK 进行容器化封装
因为在服务器使用,所以这里需要参考官方 C++ SDK (Linux环境)的文档:https://help.aliyun.com/document_detail/172359.html,截止本文产生,文档更新日期为 2020-10-10 14:29:50
。
文档中依旧提示可以在 Linux x86_64
环境使用,不过兼容列表比之前 Python 少了一个 Ubuntu 环境,只剩下了 RHEL 和 CentOS。依旧本着绿色环保,要试试是否能使用 Alpine 启动执行。
尝试封装 Alpine 容器镜像
和上一篇不同的是,我们除了因为 Alpine 版本缺少我们需要的 2.3.0 版本以上的 botan2 ,需要源码构建外,还需要配置支持构建 SDK 和我们程序的 C++ 环境。
但是有了上一篇的踩坑记录,在 Alpine 环境下从源码构建 botan2 就顺利多了。首先还是编写构建 botan2 的基础容器环境:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | FROM python:3.9.0-alpine3.12 ENV LANG en_US.UTF-8 ENV LANGUAGE en_US.UTF-8 ENV LC_ALL=en_US.UTF-8 RUN echo '' > /etc/apk/repositories && \ echo "https://mirror.tuna.tsinghua.edu.cn/alpine/v3.12/main" >> /etc/apk/repositories && \ echo "https://mirror.tuna.tsinghua.edu.cn/alpine/v3.12/community" >> /etc/apk/repositories && \ echo "Asia/Shanghai" > /etc/timezone RUN apk add --no-cache gcc musl-dev g++ make WORKDIR /app/ ADD vendor/botan-2.13.0.tar.gz ./ RUN cd botan-2.13.0 && ./configure.py && make && make install |
接着我们编写可以支持编译构建C++应用的环境,在原有文件中添加以下几行(程序文件使用官方文档中的示例程序):
1 2 3 4 5 6 | # 官方 C++ SDK 中的 libgeoclient.so COPY src/c-plus-alpine/libgeoipclient.so /usr/local/lib/ COPY src/c-plus-alpine/*.cpp /app/ COPY src/c-plus-alpine/*.hpp /app/ RUN g++ -o testgeoipclinet example.cpp -I. -lgeoipclient -lbotan-2 -std=c++11 |
虽然我们明明在编写 Dockerfile 的时候,已经将官方 IP 库文件放在了 /usr/local/lib/
目录下,但是在执行后依旧会得到类似下面的错误:
1 | /usr/lib/gcc/x86_64-alpine-linux-musl/9.3.0/../../../../x86_64-alpine-linux-musl/bin/ld: warning: libm.so.6, needed by /usr/local/lib/libgeoipclient.so, not found (try using -rpath or -rpath-link) |
原因很简单,官方在文档中有提到,程序默认会读取 LD_LIBRARY_PATH
这个环境变量,且通过试验发现,并不会在该目录之外的系统目录进行依赖查找…
1 2 | RUN export LD_LIBRARY_PATH=/usr/local/lib && \ g++ -o testgeoipclinet example.cpp -I. -lgeoipclient -lbotan-2 -std=c++11 |
调整原来的命令后,我们继续执行,则会发现另外一个错误:
1 2 3 | /usr/lib/gcc/x86_64-alpine-linux-musl/9.3.0/../../../../x86_64-alpine-linux-musl/bin/ld: warning: libm.so.6, needed by /usr/local/lib/libgeoipclient.so, not found (try using -rpath or -rpath-link) /usr/lib/gcc/x86_64-alpine-linux-musl/9.3.0/../../../../x86_64-alpine-linux-musl/bin/ld: warning: libc.so.6, needed by /usr/local/lib/libgeoipclient.so, not found (try using -rpath or -rpath-link) /usr/lib/gcc/x86_64-alpine-linux-musl/9.3.0/../../../../x86_64-alpine-linux-musl/bin/ld: /tmp/ccngCKkF.o: in function `main': |
通过寻找我们在 Alpine 官方 Package 仓库中找到了:libc6-compat这个包含 libm.so.6
和 libc.so.6
的软件包。
继续调整原来的 Dockerfile,在其中添加安装这个包的命令:
1 2 3 | RUN apk add libc6-compat RUN export LD_LIBRARY_PATH=/usr/local/lib && \ g++ -o testgeoipclinet example.cpp -I. -lgeoipclient -lbotan-2 -std=c++11 |
再次运行构建,会得到一个“船新”的错误反馈:
1 2 | /usr/lib/gcc/x86_64-alpine-linux-musl/9.3.0/../../../../x86_64-alpine-linux-musl/bin/ld: /tmp/ccCLoPGH.o: in function `main': example.cpp:(.text+0x85): undefined reference to `alibaba::dns::GeoIPClient::GeoIPClient(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > const&, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > const&)' |
这里的错误提示我们依赖库有问题,或许是不能使用动态链接,先修改 Dockerfile 将动态链接库替换为静态库。
1 | COPY src/c-plus-alpine/libgeoipclient.a /usr/local/lib/ |
再次尝试构建,发现问题依旧:
1 2 | /usr/lib/gcc/x86_64-alpine-linux-musl/9.3.0/../../../../x86_64-alpine-linux-musl/bin/ld: /usr/local/lib/libgeoipclient.a(geoipclient.o): relocation R_X86_64_32 against undefined symbol `__pthread_key_create' can not be used when making a PIE object; recompile with -fPIE /usr/lib/gcc/x86_64-alpine-linux-musl/9.3.0/../../../../x86_64-alpine-linux-musl/bin/ld: /usr/local/lib/libgeoipclient.a(ipv4_record_store.o): relocation R_X86_64_32 against `.rodata' can not be used when making a PIE object; recompile with -fPIE |
尝试使用上篇文章的 Ubuntu 环境的链接库,得到的结果也是如此,所以在官方正式推出 Alpine 环境编译的库之前,我们是不能使用 Alpine 镜像来执行 C++ SDK和相关程序的。
所以,只好调转方向,试试其他环境啦。
简化官方示例文件
在继续封装之前,我们先对官方SDK和示例代码进行简单精简,去掉非 Linux 环境等不必要的内容。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 | #pragma once #include <string> namespace alibaba { namespace dns { class GeoIPClientImpl; class GeoIPClient { public: explicit GeoIPClient(const std::string &license_path, const std::string &ipdata_path); ~GeoIPClient(); std::string search(const std::string &ip); private: GeoIPClientImpl *impl_; }; } } |
将上面的内容保存为 geoipclient.hpp
后,我们继续处理 example.cpp
文件:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | #include <iostream> #include "geoipclient.hpp" using namespace alibaba::dns; int main() { try { GeoIPClient client("/opt/license-ipv4.lic", "/opt/license-ipv4.dex"); std::cout << client.search("47.116.2.4") << std::endl; ; } catch (std::exception &e) { std::cout << "get error:" << e.what() << std::endl; } return 0; } |
将两个文件保存到相同目录中后,我们继续进行镜像封装。
封装Ubuntu / Debian 环境的容器镜像
在 Alpine 版本构建失败后,和官方维护的同学沟通得知官方没有使用容器环境进行构建,暂时不支持 Alpine,官方目前使用 CentOS 7 和 Ubuntu 进行源码构建。
但是通过搜索发现 CentOS 7 其实并不能直接使用阿里云官方文档中的 yum install botan2
来完成依赖软件安装。加之考虑当前多数线上系统使用 Ubuntu / Debian,所以这里采用 Debian 作为容器基础环境进行继续尝试。
这里可以直接参考上篇进行 Dockerfile 编写:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | FROM python:3.9.0-buster WORKDIR /app/ # 编译使用 COPY vendor/ubuntu/libbotan-2.so.13 /usr/local/lib/libbotan-2.so # 编译+运行依赖 COPY vendor/ubuntu/* /usr/lib/ # BUILD BY YOURSELF # ADD vendor/botan-2.13.0.tar.gz ./ # RUN cd botan-2.13.0 && ./configure.py && make && make install COPY src/c-plus-ubuntu/*.cpp ./ COPY src/c-plus-ubuntu/*.hpp ./ RUN g++ -o example example.cpp -I. -lgeoipclient -lbotan-2 -std=c++11 CMD ./example |
这里有一些需要注意的细节:
- debian 没有
libbotan-2
这个软件包,虽然查找到libbotan-2-dev
中包含我们需要的软件库,但是查看软件包仓库页面可以发现版本并不能满足我们的需求,所以这里只能使用源码编译,或者直接使用阿里云官方提供的二进制文件。 - 官方提供二进制在使用上有一些比较黑盒的限制,在构建的时候,需要将 libbotan 复制到
/usr/local/lib/
目录,否则构建会报错。 - 在编译后产物使用的时候,则需要将二进制文件保存至
/usr/lib/
目录 - 实测
export LD_LIBRARY_PATH=/usr/local/lib
并不需要声明,Ubuntu / Debian版本会自动寻找依赖库。
使用命令 docker build -t alidns-geoip:cplus-ubuntu .
构建镜像,然后使用 docker run --rm -it alidns-geoip:cplus-ubuntu
启动应用,会看到程序正常运行。
1 2 3 4 | # docker run --rm -it alidns-geoip:cplus-ubuntu finish loading data, taken: 0 seconds {"country":"中国","province":"上海市","city":"上海市","county":"浦东新区","isp":"阿里云","country_code":"CN","country_en":"China","province_en":"Shanghai","city_en":"Shanghai","longitude":"121.567706","latitude":"31.245944_5","isp_code":"1000323","routes":"中国电信/中国联通/中国移动/中国铁通/中国教育网","province_code":"310000","city_code":"310000","county_code":"310115"} |
接下来我们验证执行性能,并进行基础优化。
验证容器内基础查询性能
进入 Ubuntu 镜像封装的容器执行 time ./example
,会得到类似下面的结果:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | time ./example real 0m0.605s user 0m0.589s sys 0m0.013s time ./example real 0m0.574s user 0m0.593s sys 0m0.014s time ./example real 0m0.645s user 0m0.625s sys 0m0.016s |
可以看到整体运行时间和上一篇内容中 python 示例没有太大区别,但是 sys 运行时间基本减少到了一半,C++编译器确实还是更强。
之所以为什么这么慢,之前的文章我们有分析和验证过,所以这里我们直接开始使用之前的“套路”来针对程序进行性能优化。
使用 CS 模式优化程序性能
因为之前没有写过 C++
,所以花了几个小时搜索网上资料整合了一套示例代码,先贴 server.cpp 内容 ,包含一些调试信息,将阿里云官方 SDK 头文件扔一块,大概 130 行不到,如果将校验、异常等信息放在客户端,应该能砍到100行左右:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 | #include <string> namespace alibaba { namespace dns { class GeoIPClientImpl; class GeoIPClient { public: explicit GeoIPClient(const std::string &license_path, const std::string &ipdata_path); ~GeoIPClient(); std::string search(const std::string &ip); private: GeoIPClientImpl *impl_; }; } // namespace dns } // namespace alibaba #include <iostream> #include <stdio.h> #include <unistd.h> #include <unistd.h> #include <errno.h> #include <string.h> #include <stdlib.h> #include <arpa/inet.h> #include <sys/socket.h> #include <sys/types.h> #include <netinet/in.h> #include <regex> #include <chrono> #include <ctime> using namespace alibaba::dns; using namespace std; #define PORT 1024 string findVaildIP(std::string const &s) { std::regex const pattern("(\\d{1,3}(\\.\\d{1,3}){3})"); std::smatch match; if (std::regex_search(s, match, pattern)) return match[1]; else return ""; } int main() { GeoIPClient client("/opt/license-ipv4.lic", "/opt/license-ipv4.dex"); int sock_fd = socket(AF_INET, SOCK_DGRAM, 0); if (sock_fd < 0) { perror("socket"); exit(1); } struct sockaddr_in addr_serv; int len; memset(&addr_serv, 0, sizeof(struct sockaddr_in)); addr_serv.sin_family = AF_INET; addr_serv.sin_port = htons(PORT); addr_serv.sin_addr.s_addr = htonl(INADDR_ANY); len = sizeof(addr_serv); if (bind(sock_fd, (struct sockaddr *)&addr_serv, sizeof(addr_serv)) < 0) { perror("bind error:"); exit(1); } int querySize; char queryBuffer[20]; char send_buf[4096]; struct sockaddr_in addr_client; printf("Server Ready:\n"); while (1) { querySize = recvfrom(sock_fd, queryBuffer, sizeof(queryBuffer), 0, (struct sockaddr *)&addr_client, (socklen_t *)&len); if (querySize < 0 || querySize > 16) { const char errorMsg[] = "{\"code\":-1,\"desc\":\"query size error.\"}"; sendto(sock_fd, errorMsg, strlen(errorMsg), 0, (struct sockaddr *)&addr_client, len); continue; } queryBuffer[querySize] = '\0'; string findIP = findVaildIP(queryBuffer); if (findIP == "") { const char errorMsg[] = "{\"code\":-1,\"desc\":\"IP FORMAT ERROR.\"}"; sendto(sock_fd, errorMsg, strlen(errorMsg), 0, (struct sockaddr *)&addr_client, len); continue; } try { string result = client.search(findIP); const char *str = result.data(); sendto(sock_fd, str, strlen(str), 0, (struct sockaddr *)&addr_client, len); } catch (std::exception &e) { std::cout << "Error:" << e.what() << std::endl; const char errorMsg[] = "{\"code\":-1,\"desc\":\"Server Error.\"}"; sendto(sock_fd, errorMsg, strlen(errorMsg), 0, (struct sockaddr *)&addr_client, len); } } close(sock_fd); return 0; } |
上面服务端实现大概做了几件事:
- 启动一个 UDP Server,绑定端口到 1024 ,监听任意网卡数据信息。
- 使用阿里云 IP 库 SDK初始化一个实例放到内存中,等待使用。
- 接收客户端发来的消息,并验证是否为 IP v4地址格式,如果是则进行查询,将消息返回客户端。
接着来实现客户端 client.cpp 代码,九十行内解决战斗:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 | #include <stdio.h> #include <string.h> #include <errno.h> #include <stdlib.h> #include <unistd.h> #include <sys/types.h> #include <sys/socket.h> #include <netinet/in.h> #include <arpa/inet.h> #include <regex> #include <iostream> #define PORT 1024 #define HOST "127.0.0.1" using namespace std; string findVaildIP(std::string const &s) { std::regex const pattern("(\\d{1,3}(\\.\\d{1,3}){3})"); std::smatch match; if (std::regex_search(s, match, pattern)) return match[1]; else return ""; } int main(int argc, char *argv[]) { if (argc != 2) { printf("{\"code\": -1, \"desc\":\"argument lost.\"}"); return 1; } string ipParam = argv[1]; string findIP = findVaildIP(ipParam); if (findIP == "") { printf("{\"code\": -1, \"desc\":\"Need IP.\"}"); return 1; } char ipBuffer[20]; strncpy(ipBuffer, findIP.c_str(), sizeof(ipBuffer)); ipBuffer[sizeof(ipBuffer) - 1] = 0; int sock_fd = socket(AF_INET, SOCK_DGRAM, 0); if (sock_fd < 0) { printf("{\"code\": -1, \"desc\":\"socket error.\"}"); exit(1); } struct sockaddr_in addr_serv; int len; memset(&addr_serv, 0, sizeof(addr_serv)); addr_serv.sin_family = AF_INET; addr_serv.sin_addr.s_addr = inet_addr(HOST); addr_serv.sin_port = htons(PORT); len = sizeof(addr_serv); char respBuffer[4096]; int send_num = sendto(sock_fd, ipBuffer, strlen(ipBuffer), 0, (struct sockaddr *)&addr_serv, len); if (send_num < 0) { printf("{\"code\": -1, \"desc\":\"connect error.\"}"); exit(1); } int responseSize = recvfrom(sock_fd, respBuffer, sizeof(respBuffer), 0, (struct sockaddr *)&addr_serv, (socklen_t *)&len); if (responseSize < 0) { printf("{\"code\": -1, \"desc\":\"receive error.\"}"); exit(1); } respBuffer[responseSize] = '\0'; printf("%s", respBuffer); close(sock_fd); return 0; } |
简单概述下客户端做了哪些事情:
- 允许使用命令行执行,接收传递的参数,并验证是否为IP格式
- 使用 UDP 协议尝试连接服务端,查询请求的IP地址
接着修改 Dockerfile :
1 2 3 4 5 6 7 8 9 10 11 | FROM python:3.9.0-buster WORKDIR /app/ COPY vendor/ubuntu/libbotan-2.so.13 /usr/local/lib/libbotan-2.so COPY vendor/ubuntu/* /usr/lib/ COPY src/c-plus-ubuntu/*.cpp ./ RUN g++ -o server server.cpp -I. -lgeoipclient -lbotan-2 -std=c++11 RUN g++ -o client client.cpp -I. -lgeoipclient -lbotan-2 -std=c++11 |
执行 docker build -t alidns-geoip:cplus-ubuntu .
构建镜像,分别执行 docker run --rm -it --name=cplus alidns-geoip:cplus-ubuntu ./server
和 docker exec -it cplus ./client 47.116.2.4
会看到久违的 IP 查询结果(体感上的速度有明显的提升)。
1 | {"country":"中国","province":"上海市","city":"上海市","county":"浦东新区","isp":"阿里云","country_code":"CN","country_en":"China","province_en":"Shanghai","city_en":"Shanghai","longitude":"121.567706","latitude":"31.245944_5","isp_code":"1000323","routes":"中国电信/中国联通/中国移动/中国铁通/中国教育网","province_code":"310000","city_code":"310000","county_code":"310115"} |
接着使用 docker exec -it cplus bash
进入容器,还是执行 time 来验证程序基础运行性能。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | time ./client 47.116.2.4 real 0m0.005s user 0m0.003s sys 0m0.002s time ./client 47.116.2.4 real 0m0.005s user 0m0.000s sys 0m0.002s time ./client 47.116.2.4 real 0m0.005s user 0m0.003s sys 0m0.000s |
在无缓存的情况下,单个程序实例的性能相比之前已经提升了两个数量级,这里可以看到,时间中有数值为零的内容,说明 time 精度在这里已经不够用了。
这里为了获取更精准的时间消耗,在程序中添加了一个“埋点”,执行后可以看到不经过本地环境网络传输情况下的时间损耗:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | auto start = std::chrono::system_clock::now(); auto end = std::chrono::system_clock::now(); try { string result = client.search(findIP); const char *str = result.data(); sendto(sock_fd, str, strlen(str), 0, (struct sockaddr *)&addr_client, len); } catch (std::exception &e) { std::cout << "Error:" << e.what() << std::endl; const char errorMsg[] = "{\"code\":-1,\"desc\":\"Server Error.\"}"; sendto(sock_fd, errorMsg, strlen(errorMsg), 0, (struct sockaddr *)&addr_client, len); } std::chrono::duration<double> elapsed_seconds = end - start; std::time_t end_time = std::chrono::system_clock::to_time_t(end); std::cout << "finished computation at " << std::ctime(&end_time) << "elapsed time: " << elapsed_seconds.count() << "s\n"; |
重新编译执行后会惊奇的发现性能比想象中的还要好:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | finished computation at Sat Oct 31 11:17:56 2020 elapsed time: 5.5e-06s finished computation at Sat Oct 31 11:17:57 2020 elapsed time: 6e-06s finished computation at Sat Oct 31 11:17:57 2020 elapsed time: 4.9e-06s finished computation at Sat Oct 31 11:17:58 2020 elapsed time: 9.3e-06s finished computation at Sat Oct 31 11:17:58 2020 elapsed time: 5e-06s finished computation at Sat Oct 31 11:17:59 2020 elapsed time: 5.1e-06s finished computation at Sat Oct 31 11:18:00 2020 elapsed time: 3.9e-06s |
不需要详细计算,算上各种损耗,粗略估计,同机请求,即使单实例情况解决每秒 10万以上的请求问题不大。
优化镜像文件尺寸
和之前一样,在满足需求之后,我们对镜像尺寸进行检查,发现出现了 913MB
这样惊人的尺寸。
再次使用多阶段构建大法,进行容器尺寸瘦身。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | FROM python:3.9.0-buster AS GEOIP_BUILDER WORKDIR /app/ COPY vendor/ubuntu/libbotan-2.so.13 /usr/local/lib/libbotan-2.so COPY vendor/ubuntu/* /usr/lib/ COPY src/c-plus-ubuntu/*.cpp ./ RUN g++ -o server server.cpp -I. -lgeoipclient -lbotan-2 -std=c++11 RUN g++ -o client client.cpp -I. -lgeoipclient -lbotan-2 -std=c++11 CMD ./server FROM ubuntu:20.04 WORKDIR /app/ COPY vendor/ubuntu/* /usr/lib/ COPY --from=GEOIP_BUILDER /app/server /app/ COPY --from=GEOIP_BUILDER /app/client /app/ |
构建完毕,再看容器镜像尺寸,已经缩减到了 93.5MB
。
最后
本篇中,我们对官网 C++ SDK 进行了一些简单的修改和扩充,并结合容器进行了镜像构建,使用不到两百行代码,实现了在容器内高性能的调用阿里云地理位置库。
以目前的性能来看,稍加扩展是能够满足我们在线和离线计算需求的,我就暂时不折腾啦。如果你愿意的话,可以尝试使用 libv 、 proxygen 拓展这个 SDK,会发现新大陆和新玩法。
–EOF