上篇文章提到如何在容器环境中使用阿里云离线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.6libc.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 ./serverdocker 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