本文使用「署名 4.0 国际 (CC BY 4.0)」许可协议,欢迎转载、或重新修改使用,但需要注明来源。 [署名 4.0 国际 (CC BY 4.0)](https://creativecommons.org/licenses/by/4.0/deed.zh) 本文作者: 苏洋 创建时间: 2020年10月31日 统计字数: 3382字 阅读时间: 7分钟阅读 本文链接: https://soulteary.com/2020/10/31/dockerize-aliyun-geoip-part-2.html ----- # 阿里云 IP 地理位置库(淘宝IP库)实践(后篇) 上篇文章提到如何[在容器环境中](https://soulteary.com/2020/10/30/dockerize-aliyun-geoip-part-1.html)使用阿里云离线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](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 的基础容器环境: ```bash 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++应用的环境,在原有文件中添加以下几行(程序文件使用官方文档中的示例程序): ```bash # 官方 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/` 目录下,但是在执行后依旧会得到类似下面的错误: ```TeXT /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` 这个环境变量,且通过试验发现,并不会在该目录之外的系统目录进行依赖查找... ```bash RUN export LD_LIBRARY_PATH=/usr/local/lib && \ g++ -o testgeoipclinet example.cpp -I. -lgeoipclient -lbotan-2 -std=c++11 ``` 调整原来的命令后,我们继续执行,则会发现另外一个错误: ```bash /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](https://pkgs.alpinelinux.org/package/edge/main/x86_64/libc6-compat)这个包含 `libm.so.6` 和 `libc.so.6` 的软件包。 继续调整原来的 Dockerfile,在其中添加安装这个包的命令: ```bash 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 ``` 再次运行构建,会得到一个“船新”的错误反馈: ```TeXT /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, std::allocator > const&, std::__cxx11::basic_string, std::allocator > const&)' ``` 这里的错误提示我们依赖库有问题,或许是不能使用动态链接,先修改 Dockerfile 将动态链接库替换为静态库。 ```bash COPY src/c-plus-alpine/libgeoipclient.a /usr/local/lib/ ``` 再次尝试构建,发现问题依旧: ```TeXT /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 环境等不必要的内容。 ```cpp #pragma once #include 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` 文件: ```cpp #include #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 编写: ```bash 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` 中包含我们需要的软件库,但是查看[软件包仓库页面](https://packages.debian.org/unstable/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` 启动应用,会看到程序正常运行。 ```bash # 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`,会得到类似下面的结果: ```bash 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行左右: ```cpp #include 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 #include #include #include #include #include #include #include #include #include #include #include #include #include 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** 代码,九十行内解决战斗: ```cpp #include #include #include #include #include #include #include #include #include #include #include #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 : ```bash 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 查询结果(体感上的速度有明显的提升)。 ```json {"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 来验证程序基础运行性能。 ```python 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 精度在这里已经不够用了。 这里为了获取更精准的时间消耗,在程序中添加了一个“埋点”,执行后可以看到不经过本地环境网络传输情况下的时间损耗: ```cpp 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 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"; ``` 重新编译执行后会惊奇的发现性能比想象中的还要好: ```cpp 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` 这样惊人的尺寸。 再次使用多阶段构建大法,进行容器尺寸瘦身。 ```bash 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