最近的几个项目对于 IP 查询需求愈发强烈,使用免费数据库和在线接口已经不能够满足我们的需求。于是我们使用了阿里云(IP地理位置库)淘宝IP地址库首页推荐)的离线版本,在使用过程中发现了一些文档的不足,缺失了容器场景的使用案例。

本篇记录 Python 版本 SDK 的踩坑过程,希望帮助后面的同学节约折腾时间。另外希望看到本文的产品 PD 同学可以尽快推动文档完善。

写在前面

查询 IP 的场景分为在线和离线两种模式,对于有大量大尺寸访问日志的场景,一般因为以下原因,会放弃在线公有云 API 调用:

  • 对于机器资源要求颇高,需要更多的节点、更大的带宽
  • 跨区域存在很高的时延
  • 需要针对存量日志进行流式处理或者切分,执行效率上也有着非常大的限制
  • 服务需要额外的运维成本

所以需要转变思路,寻找更高效的解决方案:

  • 内网部署,减少网络开销
  • 简化接口实现,提升执行效率
  • 使用集群提供更高性能的服务能力

官方目前提供了三个版本的 SDK (Java 、Python 、C++),下载了解发现 Python 和 C++ 版本本质都是 C++ 实现,从性能角度考虑,我们放弃了 Java 版本,先从 Python 版本入手。因为当前生产服务都使用了容器部署维护,所以这里也需要考虑容器化实现。

基于官方 Python SDK 进行容器化封装

因为在服务器使用,所以这里需要参考官方 Python SDK (Linux环境)的文档:https://help.aliyun.com/document_detail/182378.html,截止本文产生,文档更新日期为 2020-10-10 14:22:40

文档中虽然提示可以在 Linux x86_64 环境使用,虽然提示兼容列表未指明(RHEL/ CentOS/Ubuntu),但是本着绿色环保,还是要试试是否能使用 Alpine 启动执行,毕竟占用资源更低,更加“节能环保”嘛。

因为IP库依赖 botan2 ,所以尝试 RUN apk add botan2,发现:

ERROR: unsatisfiable constraints:
  botan2 (missing):
    required by: world[botan2]

虽然在Alpine官方找到了 ** botan-dev** 这个库,包含 botan2,但是版本是 2.11.0-r5,低于阿里云文档中要求的 2.13.0 ,只好另辟蹊径。

封装 Python 版 Alpine 容器镜像

官方推荐使用源码构建的方式来安装 botan2,那么我们就按照这个方式来吧。阿里云文档中记录的依赖编译方式也很简单,下载源码执行命令即可:

wget https://github.com/randombit/botan/archive/2.13.0.tgz
tar -xvzf 2.13.0.tgz 
./configure.py
make
make instal

但是这里有一个小问题,botan 作者在 GitHub 开源仓库未曾发布过 tgz 版本的压缩包,这个地址是错误的,需要使用 https://github.com/randombit/botan/archive/2.13.0.tar.gz,将源码下载到本地稍后构建镜像时使用。

这里直接给出 alpine 环境下编译 botan2 的 Dockerfile,已配好基础构建工具环境:

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

倒杯水等编译完成,执行 find / | grep botan | grep -v /app 会看到 botan 安装包含以下位置:

  • /usr/local/share/doc/botan-2.13.0/*
  • /usr/local/lib/libbotan-2*
  • /usr/local/include/botan-2/*
  • /usr/local/bin/botan

构建核心依赖完毕之后,就可以处理程序了,这里有一个 trick 的地方,我们使用了官方 Python SDK 中的 Ubuntu 环境的 libgeoipclient.so ,如果是基于 Alpine 构建,性能会更好(和官方沟通,暂时没有这个系统的构建产物)。

官方 SDK GeoIpClient.py 中声明了引用 libbotan 的地址,默认是将文件放置于相对目录ubuntu内,为了符合直觉,我们将该文件中的地址目录修改为 lib,并根据自己情况删除掉了 Python2 的兼容实现(都2020年啦)。同样,因为容器内使用,去掉了一些没有必要的系统库引用(示例,实际使用结合当时最新的官方 SDK 修改即可)。

import ctypes
import copy
import sys
import socket

botan_lib = '/usr/local/lib/libbotan-2.so.13'
geoip_lib = '/usr/local/lib/libgeoipclient.so'

system_encoding = 'utf-8'

string_types = str,
integer_types = int,
text_type = str
binary_type = bytes


def ensure_binary(s, encoding='utf-8', errors='strict'):
    """Coerce **s** to six.binary_type.
      - `str` -> encoded to `bytes`
      - `bytes` -> `bytes`
    """
    if isinstance(s, binary_type):
        return s
    if isinstance(s, text_type):
        return s.encode(encoding, errors)
    raise TypeError("not expecting type '%s'" % type(s))


def ensure_str(s, encoding='utf-8', errors='strict'):
    """Coerce *s* to `str`.
      - `str` -> `str`
      - `bytes` -> decoded to `str`
    """
    # Optimization: Fast return for the common case.
    if type(s) is str:
        return s
    return s.decode(encoding, errors)


class GeoIpException(Exception):
    pass


class ClientWrapper(ctypes.Structure):

    _fields_ = [
        # there may be bug in Python3 Win64
        # set momory location 64 bit
        ("client", ctypes.c_uint64),
        ("error_msg", ctypes.c_char_p)
    ]


ctypes.CDLL(botan_lib, mode=ctypes.RTLD_GLOBAL)
so = ctypes.cdll.LoadLibrary
lib = so(geoip_lib)
lib.GeoIPClient_new.restype = ClientWrapper
lib.GeoIPClinet_search.restype = ctypes.c_int


class GeoIpClient(object):

    def __init__(self, licensefile_path, datafile_path):
        self.licensefile_path = licensefile_path
        self.datafile_path = datafile_path
        self.obj = lib.GeoIPClient_new(ensure_binary(licensefile_path),
                                       ensure_binary(datafile_path))
        # if GeoIpClient obj is null
        if self.obj.client == 0:
            raise GeoIpException(self.obj.error_msg)

    def search(self, ip):
        buffer_size = 4096
        buf = ctypes.create_string_buffer(buffer_size)
        # should spec ctypes.c_uint64 for self.obj.client cause of bug in Python3 Win64
        rec = lib.GeoIPClinet_search(ctypes.c_uint64(
            self.obj.client), ensure_binary(ip), buf, buffer_size)

        if rec == 1:
            # Inner Error in Lib, Maybe IP format Wrong or Speed Limit
            return ''
        out = buf.value

        return ensure_str(out, encoding=system_encoding)

    def close(self):
        lib.GeoIPClinet_free(ctypes.c_uint64(self.obj.client))

接着将官方文档中的示例也简化一下:

import GeoIpClient

client = GeoIpClient.GeoIpClient("/opt/license-ipv4.lic", "/opt/license-ipv4.dex")
print('init finish')
print(client.search('47.116.2.4'))
client.close()

将上面的内容保存为 example.py 和 SDK 放置到一起,准备继续封装容器使用:

# 离线IP库和授权文件
ADD data/YOUR_GEOIP_DATA.dex /opt/license-ipv4.dex
ADD data/YOUR_LICENSE.lic /opt/license-ipv4.lic
# 程序执行依赖库文件
COPY vendor/ubuntu/libgeoipclient.so /usr/local/lib/
RUN cp -r /usr/local/lib/libbotan-2* /usr/local/lib/
# 程序文件
COPY app ./

CMD python example.py

执行 docker build -t alidns-geoip:python-alpine3.12 . 完成镜像构建,再执行 docker run --rm -it alidns-geoip:python-alpine3.12 会看到程序已经可以顺利运行了。

finish loading data, taken: 0 seconds
init finish
{"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"}

优化 Alpine 镜像尺寸

Python 官方容器 python:3.9.0-alpine3.12 只有44 MB,但是观察我们构建出来的镜像,会看到镜像居然有 396MB 的巨大身材,完全丧失了使用 Alpine 的优势,所以需要继续进行一些基础优化。

使用多阶段构建大法,先将编译 botan 和我们的运行环境分离。

FROM python:3.9.0-alpine3.12 AS BOTAN_BUILDER

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



FROM python:3.9.0-alpine3.12
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 libstdc++
WORKDIR /app/

COPY --from=BOTAN_BUILDER /usr/local/lib/libbotan-2.so.13 /usr/local/lib/
COPY vendor/ubuntu/libgeoipclient.so /usr/local/lib/
COPY app/main.py app/GeoIpClient.py ./

CMD python example.py

构建完毕,整个容器镜像会从接近 400 MB 的庞然大物瘦身为 56.7MB ,相对可以接受了。

这里进行一些补充说明:

  • 因为没有 gcc / g++ 等工具,所以需要安装 libstdc++工具包,让 c++ 编译等程序能够正常运行。
  • 因为构建阶段安装目录为 /usr/local/lib/,所以这里最好也将构建阶段产物复制到新容器相同的目录,避免 Python 加载动态链接库后报找不到文件到错误,当然,Python SDK 中的类库取值也需要改为 botan_lib = '/usr/local/lib/libbotan-2.so.13'
  • 授权文件和离线数据库使用文件 mount 的形式使用即可,方便后续持续更新官方数据库。

封装 Python 版 Ubuntu 容器镜像

和官方维护的同学沟通,得知对方使用 Ubuntu 虚拟机进行该版本的库文件编译,考虑到持续使用,这里也对 Ubuntu 版本进行镜像封装,如果后续基础库不兼容 Alpine 运行,不影响已上线业务继续使用。

这里直接使用官方 Python SDK 离线包里的文件即可:

FROM python:3.9.0-buster
WORKDIR /app/
COPY vendor/ubuntu/* /usr/local/lib/
COPY src/python-ubuntu/ ./
# 测试阶段可以将授权放入此,线上可以结合其他脚本进行持续动态更新
ADD data/YOUR_GEOIP_DATA.dex /opt/license-ipv4.dex
ADD data/YOUR_LICENSE.lic /opt/license-ipv4.lic

CMD python example.py

验证容器内基础查询性能

分别进入 Ubuntu 和 Alpine 封装的容器执行 time python example.py,会得到类似下面的结果:

time python example.py 

real	0m0.619s
user	0m0.597s
sys	0m0.021s

time python example.py 

real	0m0.668s
user	0m0.634s
sys	0m0.024s

time python example.py 

real	0m0.627s
user	0m0.608s
sys	0m0.016s
time python example.py 

real	0m 0.71s
user	0m 0.69s
sys	0m 0.02s

time python example.py 

real	0m 0.70s
user	0m 0.67s
sys	0m 0.02s

time python example.py 

real	0m 0.71s
user	0m 0.68s
sys	0m 0.02s

这个执行结果对于低频率的请求或许还能接受,但是对于要处理大量日志数据时,就完全不可用了,差不多一秒只能处理一个半请求,** QPS 为 1**。

究其原因,如果使用默认的 SDK 示例,每次调用应用,都会使用 Python 先从硬盘加载数据文件,以及加载加解密库对数据文件进行解密,然后再进行数据查询,推测如果内容可以一直在内存里,可以减少大量IO(可能还有解密)计算,节约不少时间。

那么就来试试看吧。

使用 CS 模式优化程序性能

把使用模式变成 CS 模式,最简单的便是启动一个 Server,对外提供某种协议让外部进行通信调用,以 Python 3 著名的 Web Server 示例代码为模版进行简单修改:

# --coding:utf-8--
from http.server import BaseHTTPRequestHandler, HTTPServer
from os import path
from urllib.parse import urlparse
from re import match

import GeoIpClient

LICENSE_FILE_PATH = '/opt/license-ipv4.lic'
IPDATA_FILE_PATH = '/opt/license-ipv4.dex'

client = GeoIpClient.GeoIpClient(LICENSE_FILE_PATH, IPDATA_FILE_PATH)


class testHTTPServer_RequestHandler(BaseHTTPRequestHandler):

    def log_message(self, format, *args):
        return

    def do_GET(self):
        querypath = urlparse(self.path)
        query = querypath.path[1:]

        findIP = match(r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$", query)

        if findIP:
            ip = findIP.group()
        else:
            self.send_error(404, 'IP NOT VAILED')

        try:
            content = bytes(client.search(ip), 'utf-8')
            self.send_response(200)
            self.send_header('Content-type', 'application/json')
            self.end_headers()
            self.wfile.write(content)
        except IOError:
            self.send_error(404, 'File Not Found: %s' % self.path)
            client.close()


def run():
    server_address = ('0.0.0.0', 8000)
    httpd = HTTPServer(server_address, testHTTPServer_RequestHandler)
    httpd.serve_forever()


if __name__ == '__main__':
    run()

可以看到,上面这段不足 50 行的代码里,基本只做了三件事:

  • 在端口 8000 启动一个Web 服务,处理路径中包含 IP 字符串的请求
  • 验证请求IP格式是否有效
  • 调用阿里云官方 SDK 进行IP数据查询

然后使用官方示例中的 IP 执行 time curl localhost:8000/47.116.2.4 分别在两个系统镜像启动的容器中进行测试。

Ubuntu 镜像的测试结果类似下面:

time curl localhost:8000/47.116.2.4

real	0m0.197s
user	0m0.104s
sys	0m0.088s

time curl localhost:8000/47.116.2.4

real	0m0.011s
user	0m0.003s
sys	0m0.008s

time curl localhost:8000/47.116.2.4

real	0m0.010s
user	0m0.006s
sys	0m0.005s

可以看到最差情况下(第一次)的时间缩短到了原本的 1/3,而后续两次调用更是缩减到了 0.1 ms(感谢 Python 强大的 Cache 机制),也就是说单实例 QPS 提到了 5 ~ 100,这个数据看起来能够满足小样本离线/内网环境进行数据分析,但是数据量如果再大一些,还是会有问题。

于是继续使用 Alpine 镜像进行验证,然而 Alpine 镜像会出现一些 Bug,只有第一次调用会有返回,后续调用会 hang 住:

time curl localhost:8000/47.116.2.4
real	0m 0.00s
user	0m 0.00s
sys	0m 0.00s

虽然使用端口映射,我们在容器外可以正常使用,但是因为上面测试过程中的奇怪问题,即使我们封装了小巧的 Alpine 镜像,暂时也不好使用其在生产环境处理大量的数据。只好退而求其次使用 Ubuntu 版本的镜像,希望官方后续会推出 Alpine 环境的程序库吧。

最后

本篇我们对官方 Python 进行了一些简单的修改和扩充,结合容器进行了镜像构建,实现了使用容器调用阿里云地理位置库。

翻阅之前的博客文章,你可以使用流行的水平扩展容器实例数量来提升 QPS ,解决稍微大一些的数据量的分析,但是如前文提到的,你将不可避免的会遇到切割日志、不得不添加机器数量,甚至添加异步队列等方式来解决这个简单的 IP转换 需求。

下一篇,将从另外一个角度入手,进一步解决处理超大尺寸日志时的性能、维护成本的问题。

–EOF