上篇文章提到如何在容器环境中使用阿里云离线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 的基础容器环境:
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++应用的环境,在原有文件中添加以下几行(程序文件使用官方文档中的示例程序):
# 官方 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/
目录下,但是在执行后依旧会得到类似下面的错误:
/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
这个环境变量,且通过试验发现,并不会在该目录之外的系统目录进行依赖查找…
RUN export LD_LIBRARY_PATH=/usr/local/lib && \
g++ -o testgeoipclinet example.cpp -I. -lgeoipclient -lbotan-2 -std=c++11
调整原来的命令后,我们继续执行,则会发现另外一个错误:
/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,在其中添加安装这个包的命令:
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
再次运行构建,会得到一个“船新”的错误反馈:
/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 将动态链接库替换为静态库。
COPY src/c-plus-alpine/libgeoipclient.a /usr/local/lib/
再次尝试构建,发现问题依旧:
/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 环境等不必要的内容。
#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
文件:
#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 编写:
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
启动应用,会看到程序正常运行。
# 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
,会得到类似下面的结果:
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行左右:
#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 代码,九十行内解决战斗:
#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 :
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 查询结果(体感上的速度有明显的提升)。
{"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 来验证程序基础运行性能。
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 精度在这里已经不够用了。
这里为了获取更精准的时间消耗,在程序中添加了一个“埋点”,执行后可以看到不经过本地环境网络传输情况下的时间损耗:
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";
重新编译执行后会惊奇的发现性能比想象中的还要好:
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
这样惊人的尺寸。
再次使用多阶段构建大法,进行容器尺寸瘦身。
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