本篇文章,我来分享如何使用 Docker 来搭建一个能够跑在本地的轻量图片搜索引擎,实现日常生活中我们习以为常,但是实现起来颇为麻烦的功能:以图搜图。

写在前面

之前网上看到一个问题《如何在自己计算机上以图搜图?》,接近两百人关注,十万次浏览,十来个答案里,就是没有一篇内容是针对问题,展开“如何实现”,并且给出行之有效的实现方案的回答,正好上周制作了一个小巧的 Milvus 镜像:《向量数据库入坑:入门向量数据库 Milvus 的 Docker 工具镜像》

那么,本周的向量数据库入坑系列,就聊聊“图片搜索”这个话题吧。不同于以往,这次我们先来看搭建的图片搜索引擎的效果,再来展开聊如何实现。

如果你等不及看效果,可以参考Milvus 项目官网的例子,来体验一下随手拍的内容在一百万张图片中进行快速相似性检索的体验(30ms 内!)

Milvus 官方包含一百万张图片的 Demo

一键启动图片搜索引擎

如果你有安装 Docker,那么可以在本地执行这条命令,来快速启动一个本地的图片搜索引擎,实现快速的以图搜图:

docker run --rm -it --name=milvus -p 3000:3000 -v `pwd`/images:/images soulteary/image-search-app:2.1.0

在命令执行完毕之后,我们在浏览器中访问 http://127.0.0.1:3000 就能够看到下面的界面啦,个人觉得比 Milvus 项目官网 Demo 界面好看 :D

本地启动的图片搜索引擎界面

在使用这个本地图片搜索引擎之前,我们需要先准备一些图片,我使用百度图片搜索,以游戏、动漫为关键词找到了大概 60 张壁纸,将这些图片扔到上面命令执行之后,本地自动创建的 images 目录中。

从网络中随机找的一些动漫、游戏图片

接着点击界面中的“+”号,页面会自动变灰,提示我们应用正在使用模型对图片进行编码(embedding),以及将计算出(抽取)的特征向量存入向量数据库 Milvus 里。这里变灰时间和我们本地机器的性能、刚刚在文件夹内放置图片数有关,设备性能越强,图片数据相对少,可以减少等待的时间。

进行计算的时候,页面会暂时变灰

当图片数据处理完毕之后,界面会恢复正常,同时界面中会提示我们加载正常的图片数目。

计算完毕,界面提示数据量有变化

接下来,我们可以先使用一张并不包含在 60 张之内的卡通图片,来验证搜索结果是否符合预期:

查找不存在的图片

当然,也可以使用包含在刚刚 60 张图片之内的文件,来进一步判断这个图片搜索引擎的效果:

查找存在的图片

考虑到难免会出现,需要调试应用内具体程序或者进行分析的需求,这个图片搜索工具还内置了一个 “Web Console”,可以避免我们切换命令行终端,然后敲 docker exec -it $ContainerNameOrHash ... 来进行调试,直接在浏览器中就能解决问题,是不是很方便?

内置在浏览器中的命令行工具

好了,在看到效果之后,我们来聊聊如何快速制作这样一个简单实用的工具吧。

因为想要快速构建,完全从零到一编写就不是一个“明智之选”了,这里我考虑使用技巧来对已有的开源项目中的例子进行改造,让它能够变成类似上面这样,我们想要的样子。

开源社区原先图片搜索 Demo 的缺陷

在实现之前,我们需要先了解下要改造的开源项目例子原本的样子和有哪些缺陷。

在 Milvus 的 Boot Camp 中,原先图片搜索的“Quick Deploy” 示例是这样工作的:将分布式的 Milvus 使用 “docker-compose” 的方式进行本地部署,然后搭配一套前端界面,以及 MySQL 来完成搜索引擎的原始图片数据匹配。

官方图片搜索示例架构

虽然架构图上没有将 Milvus 所有的依赖都标注在图片中,但是通过阅读目录中的 docker-compose.yml 文件,我们可以看到,这套本地部署示例中,实际上还蛮复杂的,包含了下面六个部分:

  • 数据存储 Etcd:quay.io/coreos/etcd:v3.5.0
  • 对象存储 Minio:minio/minio:RELEASE.2020-12-03T00-03-10Z
  • 向量数据库 Milvus:milvusdb/milvus:v2.0.2
  • 关系数据库 MySQL:mysql:5.7
  • 图片搜索应用:milvusbootcamp/img-search-server:towhee0.6
  • 用户前端界面:milvusbootcamp/img-search-client:1.0

想要完成上面这些服务的启动,大概需要下载 1G 出头的镜像数据。除此之外,在 docker-compose.yml 中,这些组件还被绑定了不同的 ipv4_address,与网络环境配置存在耦合。并且在首次加载图片的时候存在两个比较麻烦的问题:

  1. Web 客户端存在因为 CORS 跨域问题,导致图片上传不了的问题。
  2. 如果编写一个额外的脚本进行调用,会发现首次提交数据加载,将会触发程序按需下载未被下载的组件:opencvtimmtorchtorchvision,以及resnet50 模型,这些内容的数据下载量在 1G 多,因为众所周知的问题,很容易出现下载失败、下载中断的问题,导致最终可能满怀期待的运行,结果根本“玩不起来”。

而如果想调整或调试应用,以及查看具体的日志进行问题分析,还需要使用 docker exec 进入上述六个容器之一,再进行操作,非常麻烦。

总结一下上面的问题:

  1. 对用户暴露了比较多原本不必要的应用复杂性。
  2. 整体下载量比较大,下载过程存在因为资源下载失败玩不起来的问题。
  3. 从下载到玩起来需要比较久的时间。
  4. 简单调试和分析定位问题比较麻烦。
  5. 包含了前端界面的服务,在使用上存在一些基础的前端问题需要解决。

好了,在清楚了开源项目现存的问题之后,就可以“开刀”啦。

应用架构

上面的问题有很大一部分是示例架构不够合理引起的,所以,我们需要摸清楚原本的架构,以及先进行架构调整。

原始架构

Milvus 原本的图片搜索示例架构

在上面的图片中,我们能够清晰的看到应用被分为了“五层”,除去偏抽象不涉及具体某个应用的“用户交互层”之外:

  • “前端服务”:包含了 Nginx 和使用 Node.js 构建好的 React 单页面应用,提供浏览器内的界面交互,后端服务计算信息结果展示。
  • “推理服务”:包含了使用 Towhee 0.6 和 ResNet50 模型,以及 FastAPI 搭建的 AI 推理服务,用于将用户提交的图片数据进行向量转换。
  • “向量检索服务”:包含了使用 Milvus 2.x 、Etcd、MinIO 搭建的简单版本的向量数据查询程序,用于将用户提交单张图片的向量与库存信息进行相似度匹配,得到最相似的一组向量结果。
  • “关联检索服务”:包含了 MySQL 数据库,用于将 Milvus 查找到的结果进行文件反查,找到相似向量结果背后代表的具体是哪些图片。

对应用架构有了一定了解之后,我们就可以进行架构的重新设计啦。

架构调整

因为我们的目标是本地运行的轻量图片搜索服务,所以我们可以考虑对不同的功能层“做减法”,来降低整体复杂度、提升应用综合性能,降低应用运行所需的资源。

简化后的图片搜索示例的架构

我的策略比较简单,分为三类:

  • 删除不必要的组件:MySQL,使用 Milvus 2.x 新支持的 String 数据类型,完全可以在检索相似向量结果之后,一并拿到这些向量原本归属于那张图片。去掉 MySQL 容器镜像依赖,除了能够减少至少 100MB 的数据(本地解压后 430MB)之外,还能够提升应用检索时的性能,减少因为查询 MySQL 失败带来的功能不可用。
  • 简化核心服务实现:Milvus,使用上篇文章提到的“Embedded Milvus”替换完整的 Milvus 单机架构,简化掉 Etcd、MinIO 依赖,使用之前制作的小巧镜像替换官方的镜像,得到至少 200MB 的数据下载量减少。
  • 合并前后端的实现:将前端容器和 Python 容器进行合并,去掉不必要的额外运行和维护成本(Web 服务没必要运行多个),避免应用启动后还需要从网络进行额外的数据下载,同时想办法减少数据下载量,这里至少能够节约接近 1G 的数据下载量。

在了解到策略之后,我们来进行图片搜索应用的镜像重构。

重构应用镜像

没有好的基础镜像,一切“轻量”都是空中楼阁,我们先从基础镜像聊起。

使用精简的 Embedded Milvus 镜像作为基础镜像

设计多个容器的应用优化有一个容易被忽略的点,如果我们采用相同的基础镜像,将能够极大减少数据下载量。

在不提 Etcd、MinIO、MySQL,我们对之前运行的镜像中的基础镜像进行整理,能够得到下面三种镜像:

  • Python 推理服务:FROM python:3.7-slim-busterFROM buildpack-deps:buster
  • 前端服务:FROM nginx:1.17-alpineFROM debian:bullseye-slim
  • Milvus 镜像:FROM ubuntu:20.04

不难发现基本都是 Debian / Ubuntu 系,只是依赖的版本不同。

在之前的文章里,我们制作过一个小巧的 Milvus Embedded 镜像(soulteary/portable-docker-app/milvus),它基于 python:3.9-slim-buster(debian buster 镜像),为了尽量减少工作量,接下来整个应用的搭建,关于基础镜像部分,我们的选择就是它了。

使用 Milvus String 字符串替代 MySQL

前面提到了我们可以使用 Milvus 的 “String 数据类型”来替代原本依赖的 MySQL 服务,比如在应用中原本我们创建数据表,使用的结构是这样:

field1 = FieldSchema(name="id", dtype=DataType.INT64, descrition="int64", is_primary=True, auto_id=True)
field2 = FieldSchema(name="embedding", dtype=DataType.FLOAT_VECTOR, descrition="float vector",
...

可以将其重构为:

field1 = FieldSchema(name='path', dtype=DataType.VARCHAR, descrition='path to image', max_length=500, is_primary=True, auto_id=False)
field2 = FieldSchema(name="embedding", dtype=DataType.FLOAT_VECTOR, descrition="image embedding vectors",
...

原本我们需要从 MySQL 中查找到的图片路径,现在直接在获取相似向量数据的时候,就能够顺带得到了,那么自然也就不需要依赖 MySQL 这个容器镜像啦。

完整的改动比较多,可以参考:soulteary/portable-docker-app/commit/988c5dc05971a70c767494faef72beee3fc69c72

明确服务端镜像内应用依赖

服务端镜像除了代码需要调整之外,最需要解决的问题就是镜像内“应用依赖”的问题:Python PyPI 包最少安装哪些就行?推理服务依赖的模型能否直接内置到镜像中?Towhee 运行时下载的 “operator” 能否直接内置镜像中?

先来解决第一个问题,如何将应用中的镜像依赖“盘清楚”。

对应用默认使用的 Dockerfile 进行基础镜像版本调整,保持和上文中提到的基础镜像环境版本一致(Python 3.7 => Python 3.9),删除掉原本的 Python 依赖安装命令,为后续摸清依赖做准备:

#FROM python:3.7-slim-buster
FROM python:3.9-slim-buster


RUN pip3 install --upgrade pip
RUN apt-get update
RUN apt-get install ffmpeg libsm6 libxext6  -y

WORKDIR /app/src
COPY . /app

#RUN pip3 install -r /app/requirements.txt

CMD python3 main.py

将上面的内容保存为 Dockerfile,然后执行 docker build -t server:test-environment . 完成镜像构建,在得到镜像后,执行 docker run --rm -it server:test-environment bash 进入推理服务的应用容器中。

然后执行 python main.py 触发预期中的缺少依赖报错:

Traceback (most recent call last):
  File "/app/src/main.py", line 1, in <module>
    import uvicorn
ModuleNotFoundError: No module named 'uvicorn'

通过报错,结合项目中的 requirements.txt 文件,来修正项目所需的 Python 依赖。为了减少下载依赖所花费的时间,我们可以设置“清华源”:

pip config set global.index-url https://pypi.tuna.tsinghua.edu.cn/simple

过程中会遇到提示缺少 “Google” 依赖的问题:

Traceback (most recent call last):
  File "/app/src/main.py", line 8, in <module>
    from milvus_helpers import MilvusHelper
  File "/app/src/milvus_helpers.py", line 3, in <module>
    from pymilvus import connections, FieldSchema, CollectionSchema, DataType, Collection, utility
  File "/usr/local/lib/python3.9/site-packages/pymilvus/__init__.py", line 13, in <module>
    from .client.stub import Milvus
  File "/usr/local/lib/python3.9/site-packages/pymilvus/client/stub.py", line 3, in <module>
    from .grpc_handler import GrpcHandler
  File "/usr/local/lib/python3.9/site-packages/pymilvus/client/grpc_handler.py", line 12, in <module>
    from ..grpc_gen import milvus_pb2_grpc
  File "/usr/local/lib/python3.9/site-packages/pymilvus/grpc_gen/milvus_pb2_grpc.py", line 5, in <module>
    from . import common_pb2 as common__pb2
  File "/usr/local/lib/python3.9/site-packages/pymilvus/grpc_gen/common_pb2.py", line 5, in <module>
    from google.protobuf.internal import enum_type_wrapper
ModuleNotFoundError: No module named 'google'

可以参考上一篇文章,通过安装下面的依赖来解决报错:

pip install pymilvus==2.1.0 protobuf==3.20.2

在明确了下面 5 个依赖之后:

diskcache==5.2.1
fastapi==0.65.2
pymilvus==2.1.0
towhee==0.8.0
uvicorn==0.13.4

我们再次执行 python main.py,将会看到 towhee 开始通过下载补全程序运行所需要的依赖:

root@96291354bb9f:/app/src# python main.py 
Downloading image_decode.yaml: 100%|█████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████| 241/241 [00:00<00:00, 281kiB/s]
Downloading requirements.txt: 100%|███████████████████████████████████████████████████████████
Downloading image_decode.py: 100%|███████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████| 297/297 [00:00<00:00, 119kiB/s]
...
Collecting opencv-python
  Downloading https://pypi.tuna.tsinghua.edu.cn/packages/af/bf/8d189a5c43460f6b5c8eb81ead8732e94b9f73ef8d9abba9e8f5a61a6531/opencv_python-4.6.0.66-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (60.9 MB)
     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 60.9/60.9 MB 19.5 MB/s eta 0:00:00
Requirement already satisfied: towhee in /usr/local/lib/python3.9/site-packages (from -r /root/.towhee/operators/towhee/image_decode/main/requirements.txt (line 2)) (0.8.0)
Requirement already satisfied: numpy>=1.17.3 in /usr/local/lib/python3.9/site-packages (from opencv-python->-r /root/.towhee/operators/towhee/image_decode/main/requirements.txt (line 1)) (1.23.3)
...
Installing collected packages: opencv-python
Successfully installed opencv-python-4.6.0.66
2022-09-24 01:04:59,471 | INFO | repo_manager.py | download | 276 | Successfully download the repo: towhee/image-decode.
Downloading timm_image.py: 100%|█████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████| 5.75k/5.75k [00:00<00:00, 655kiB/s]
Downloading requirements.txt: 100%|███████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████| 31.0/31.0 [00:00<00:00, 32.2kiB/s]
...
Requirement already satisfied: numpy in /usr/local/lib/python3.9/site-packages (from -r /root/.towhee/operators/image-embedding/timm/main/requirements.txt (line 1)) (1.23.3)
Collecting timm>=0.5.4ts.txt:   0%|                                                                                                                                       | 0.00/31.0 [00:00<?, ?iB/s]
  Downloading https://pypi.tuna.tsinghua.edu.cn/packages/72/ed/358a8bc5685c31c0fe7765351b202cf6a8c087893b5d2d64f63c950f8beb/timm-0.6.7-py3-none-any.whl (509 kB)
     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 510.0/510.0 kB 16.0 MB/s eta 0:00:00
...
Collecting torch
  Downloading https://pypi.tuna.tsinghua.edu.cn/packages/1e/2f/06d30fbc76707f14641fe737f0715f601243e039d676be487d0340559c86/torch-1.12.1-cp39-cp39-manylinux1_x86_64.whl (776.4 MB)
     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 776.4/776.4 MB 437.8 kB/s eta 0:00:00
Collecting torchvision
  Downloading https://pypi.tuna.tsinghua.edu.cn/packages/ed/44/ab2f3a6670a054c5ec0e7c295f437d043a4be46f07ff2d9fce73cadb7549/torchvision-0.13.1-cp39-cp39-manylinux1_x86_64.whl (19.1 MB)
     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 19.1/19.1 MB 37.7 MB/s eta 0:00:00
...
Collecting pillow!=8.3.*,>=5.3.0
  Downloading https://pypi.tuna.tsinghua.edu.cn/packages/01/61/3ff85fb4bb596ce3d223c8fcf93c8df5c12bc8899dfb4fb3cb1c5b20dd5f/Pillow-9.2.0-cp39-cp39-manylinux_2_28_x86_64.whl (3.2 MB)
     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 3.2/3.2 MB 43.1 MB/s eta 0:00:00
...
Installing collected packages: torch, pillow, torchvision, timm
Successfully installed pillow-9.2.0 timm-0.6.7 torch-1.12.1 torchvision-0.13.1
...

通过整理上面过程中的信息,以及结合 pip list 输出日志,我们不难得到程序运行所需要的关键依赖:

diskcache==5.2.1
fastapi==0.65.2
pymilvus==2.1.0
protobuf==3.20.2
towhee==0.8.0
uvicorn==0.13.4
opencv-python==4.6.0.66
torch==1.12.1
torchvision==0.13.1
Pillow==9.2.0
timm==0.6.7

结束当前的 docker 容器,我们对 Dockerfile 进行调整,将依赖安装写在 Dockerfile 或者更新项目的 requirements.txt 中。考虑到后续还要继续调整,我选择前者,调整 Dockerfile:

FROM python:3.9-slim-buster
RUN sed -i -E "s/\w+.debian.org/mirrors.tuna.tsinghua.edu.cn/g" /etc/apt/sources.list
RUN apt-get update && \
    apt-get install -y ffmpeg libsm6 libxext6 && \
    apt-get remove --purge -y && rm -rf /var/lib/apt/lists/*

RUN pip config set global.index-url https://pypi.tuna.tsinghua.edu.cn/simple && \
    pip install diskcache==5.2.1 fastapi==0.65.2 pymilvus==2.1.0 towhee==0.8.0 uvicorn==0.13.4 protobuf==3.20.2 opencv-python==4.6.0.66 torch==1.12.1 torchvision==0.13.1 Pillow==9.2.0 timm==0.6.7 && \
    pip cache purge

WORKDIR /app/src
COPY . /app

CMD python3 main.py

再次构建镜像,在重新创建容器并进入推理服务运行环境之前,我们先来看下本地镜像的文件大小:

server   test-environment   e779e74476eb   18 seconds ago   2.51GB

在记录当前镜像尺寸之后,接着来解决 Towhee 自动下载的 operator 依赖,和模型依赖。

明确服务端镜像内模型依赖

再次执行 docker run --rm -it server:test-environment bash 命令,进入安装好 Python 依赖的新容器中,再次执行 python main.py

root@a957b9b130b4:/app/src# python main.py 
Downloading requirements.txt: 100%|███████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████| 20.0/20.0 [00:00<00:00, 7.36kiB/s]
Downloading image_decode.py: 100%|███████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████| 297/297 [00:00<00:00, 127kiB/s]
Requirement already satisfied: towhee in /usr/local/lib/python3.9/site-packages (from -r /root/.towhee/operators/towhee/image_decode/main/requirements.txt (line 2)) (0.8.0)
...
2022-09-24 01:44:47,921 | INFO | repo_manager.py | download | 276 | Successfully download the repo: towhee/image-decode.
Downloading test_torchscript.py: 100%|██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████| 2.55k/2.55k [00:00<00:00, 1.37MiB/s]
Downloading timm_image.py: 100%|████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████| 5.75k/5.75k [00:00<00:00, 3.05MiB/s]
Downloading test_onnx.py: 100%|█████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████| 2.51k/2.51k [00:00<00:00, 1.00MiB/s]
...
2022-09-24 01:44:54,985 | INFO | repo_manager.py | download | 276 | Successfully download the repo: image-embedding/timm.
2022-09-24 01:44:55,563 | INFO | helpers.py | load_pretrained | 244 | Loading pretrained weights from url (https://github.com/rwightman/pytorch-image-models/releases/download/v0.1-rsb-weights/resnet50_a1_0-14fe96d1.pth)
Downloading: "https://github.com/rwightman/pytorch-image-models/releases/download/v0.1-rsb-weights/resnet50_a1_0-14fe96d1.pth" to /root/.cache/torch/hub/checkpoints/resnet50_a1_0-14fe96d1.pth
2022-09-24 01:45:04,438 | ERROR | milvus_helpers.py | __init__ | 24 | Failed to connect Milvus: <MilvusException: (code=2, message=Fail connecting to server on 127.0.0.1:19530. Timeout)>
root@a957b9b130b4:/app/src# 

先忽略连接 Milvus 的报错,通过观察上面的日志,我们能够看到 Towhee 分别将 operator 和模型存放在了 /root/.towhee//root/.cache/torch/,查看目录可以看到内容大小尚可,适合直接保存在容器中。

root@a957b9b130b4:/# du -hs ~/.cache/
99M	  /root/.cache/
root@a957b9b130b4:/# du -hs ~/.towhee/
260K  /root/.towhee/

那么如何将上面的运行中才会触发下载的内容保存到容器中呢?这里有两个方案:

  • 通过 docker cp 将运行妥当的容器中的资源复制到本地,然后在构建过程中再复制到新的镜像中。
  • 调整程序,实现一个构建时运行,不会出现副作用,但是能够将资源初始化完毕的程序。

这里,我选择第二个方案,调整源代码中的 encode.py 程序,让程序运行的时候,能够触发 Towhee 自动下载 operator 以及模型动作:

import towhee
from towhee.functional.option import _Reason

class ResNet50:
    def __init__(self):
        self.pipe = (towhee.dummy_input()
                    .image_decode()
                    .image_embedding.timm(model_name='resnet50')
                    .tensor_normalize()
                    .as_function()
        )

    def resnet50_extract_feat(self, img_path):
        feat = self.pipe(img_path)
        if isinstance(feat, _Reason):
            raise feat.exception
        return feat


if __name__ == "__main__":
    ResNet50().resnet50_extract_feat('https://i1.sinaimg.cn/dy/deco/2013/0329/logo/LOGO_1x.png')

在调整完程序之后,我们来调整容器镜像的 Dockerfile:

FROM python:3.9-slim-buster
RUN sed -i -E "s/\w+.debian.org/mirrors.tuna.tsinghua.edu.cn/g" /etc/apt/sources.list
RUN apt-get update && \
    apt-get install -y ffmpeg libsm6 libxext6 && \
    apt-get remove --purge -y && rm -rf /var/lib/apt/lists/*

RUN pip config set global.index-url https://pypi.tuna.tsinghua.edu.cn/simple && \
    pip install diskcache==5.2.1 fastapi==0.65.2 pymilvus==2.1.0 towhee==0.8.0 uvicorn==0.13.4 protobuf==3.20.2 opencv-python==4.6.0.66 torch==1.12.1 torchvision==0.13.1 Pillow==9.2.0 timm==0.6.7 && \
    pip cache purge

WORKDIR /app/src
COPY . /app

RUN python3 encode.py
CMD python3 main.py

再次构建镜像之后,我们再次查看容器镜像尺寸:

server   test-environment   89b9e4ca9da8   10 seconds ago   2.61GB

相比较之前的镜像,尺寸大了 100M左右,来到了 2.61 G。我们暂且不进行额外的容器尺寸优化,先继续进行重构。

前端应用镜像的重构

相比较上面的“应用模块”所使用的镜像,前端使用的镜像的问题相对多一些:

  • 使用目前 LTS 版本支持的 Node v18,无法正确构建程序。
  • 构建的产物比较大,并且携带调试信息(sourcemap)。
  • 最终产物镜像基于 Nginx,存在杀鸡用牛刀的问题。
  • 示例代码中包含比较多的脚手架的无用文件。
  • 当前代码存在跨域问题需要解决。

我们先来解决无法构建的问题。

在执行 npm install 完成了依赖安装之后,不论是执行 npm run start 还是 npm run build,都将会得到类似下面的错误信息:

npm run start

Starting the development server...

Error: error:0308010C:digital envelope routines::unsupported
    at new Hash (node:internal/crypto/hash:67:19)
    at Object.createHash (node:crypto:133:10)
    at module.exports (/Users/soulteary/portable-docker-app/reverse-image-search/client/node_modules/webpack/lib/util/createHash.js:135:53)
    at NormalModule._initBuildHash (/Users/soulteary/portable-docker-app/reverse-image-search/client/node_modules/webpack/lib/NormalModule.js:417:16)
    at /Users/soulteary/portable-docker-app/reverse-image-search/client/node_modules/webpack/lib/NormalModule.js:452:10
    at /Users/soulteary/portable-docker-app/reverse-image-search/client/node_modules/webpack/lib/NormalModule.js:323:13
    at /Users/soulteary/portable-docker-app/reverse-image-search/client/node_modules/loader-runner/lib/LoaderRunner.js:367:11
    at /Users/soulteary/portable-docker-app/reverse-image-search/client/node_modules/loader-runner/lib/LoaderRunner.js:233:18
    at context.callback (/Users/soulteary/portable-docker-app/reverse-image-search/client/node_modules/loader-runner/lib/LoaderRunner.js:111:13)
    at /Users/soulteary/portable-docker-app/reverse-image-search/client/node_modules/babel-loader/lib/index.js:59:103 {
  opensslErrorStack: [ 'error:03000086:digital envelope routines::initialization error' ],
  library: 'digital envelope routines',
  reason: 'unsupported',
  code: 'ERR_OSSL_EVP_UNSUPPORTED'
}

Node.js v18.2.0

解决这个问题比较简单,只需要在执行命令前添加 NODE_OPTIONS="--openssl-legacy-provider" ,再执行命令即可。

当我们执行 NODE_OPTIONS="--openssl-legacy-provider" npm run start,启动应用的时候,会得到“编译失败”的报错。

Failed to compile.

/Users/soulteary/portable-docker-app/reverse-image-search/client/node_modules/pretty-format/build/index.d.ts
TypeScript error in /Users/soulteary/portable-docker-app/reverse-image-search/client/node_modules/pretty-format/build/index.d.ts(7,13):
'=' expected.  TS1005

     5 |  * LICENSE file in the root directory of this source tree.
     6 |  */
  >  7 | import type { NewPlugin, Options, OptionsReceived } from './types';
       |             ^
     8 | export type { Colors, CompareKeys, Config, Options, OptionsReceived, OldPlugin, NewPlugin, Plugin, Plugins, PrettyFormatOptions, Printer, Refs, Theme, } from './types';
     9 | export declare const DEFAULT_OPTIONS: Options;
    10 | /**

出现这个问题的原因是,我们的依赖安装有一些问题(NPM依赖前后兼容存在问题、项目没有正确的 NPM 依赖 lock 文件),我们需要先通过下面的方式,来获得正确的依赖声明文件。

package.json 中,我们能够看到类似下面的内容:

{
  "name": "milvus-search-demo-react",
  "version": "0.1.0",
  "private": true,
  "dependencies": {
    "@material-ui/core": "^4.7.1",
    "@material-ui/icons": "^4.5.1",
    "@testing-library/jest-dom": "^4.2.4",
    "@testing-library/react": "^9.3.2",
    "@testing-library/user-event": "^7.1.2",
    "@types/jest": "^24.0.0",
    "@types/node": "^12.0.0",
    "@types/react": "^16.9.0",
    "@types/react-dom": "^16.9.0",
...
}

通过观察,我们可以发现这个项目是由 Create React APP 脚手架生成的,包含了 Jest 测试依赖,但是实际项目并没有用到它,所以在使用上面方式创建依赖文件时,可以将 package.json 中的相关测试依赖都删除掉,并且将版本先进行“固定”。

{
  "name": "milvus-search-demo-react",
  "version": "0.1.0",
  "private": true,
  "resolutions": {
    "@types/react": "16.9.0"
  },
  "dependencies": {
    "@material-ui/core": "4.7.1",
    "@material-ui/icons": "4.5.1",
    "@types/node": "12.0.0",
    "@types/react": "16.9.0",
    "@types/react-dom": "16.9.0",
    "axios": "^0.19.0",
    "material-ui-dropzone": "2.4.7",
    "react": "16.12.0",
    "react-dom": "16.12.0",
    "react-images": "1.0.0",
    "react-photo-gallery": "8.0.0",
    "react-scripts": "3.4.0",
    "typescript": "3.7.2"
  },
...

在调整完 package.json 之后,我们先尝试修复并创建正确的项目依赖文件:

rm -rf node_modules
rm package-lock.json

npm install --package-lock-only --ignore-scripts && npx npm-force-resolutions

在创建了正确的 package-lock.json 之后,我们执行 npm install 完成依赖下载,再次执行 NODE_OPTIONS="--openssl-legacy-provider" npm run start,构建的问题就消失了,浏览器中也能够正常访问开发模式的前端界面啦。

接下来,我们来解决构建内容携带调试信息,产物体积比较大的问题,执行构建命令 NODE_ENV=production NODE_OPTIONS="--openssl-legacy-provider" npm run build,成功构建之后,能够得到类似下面的日志输出:

> milvus-search-demo-react@0.1.0 build
> react-scripts build

Creating an optimized production build...
Compiled successfully.

File sizes after gzip:

  139.08 KB  build/static/js/2.6c77c2f7.chunk.js
  6.31 KB    build/static/js/main.23a50c64.chunk.js
  784 B      build/static/js/runtime-main.7cefac8c.js
  278 B      build/static/css/main.5ecd60fb.chunk.css

接着,使用命令查看构建产物尺寸,记录大小,方便后续对比。

du -hs build
2.5M	build

调整代码比较多,详细方案可以查看这条变更记录,主要思路是使用注入 Create React App 这个脚手架默认的 “webpack” 来禁用不必要的“优化”,比如生成 “SW”、“Manifest”、进行不必要的文件拆分等;以及剔除项目中不必要的资源文件等。构建后的日志结果看起来差异不大,但是请求数减少了 75%:

Compiled successfully.

File sizes after gzip:

  142.78 KB  build/static/js/main.9d1b30ce.js

再来看一下构建产物体积:

du -hs build
528K	build

实际的产物体积从 2.5M 瘦身到了 500K 左右,在实际使用上带来的性能提升,还是非常容易被感知到的。

在搞定上面这几项工作之后,我们的“素材准备”就就绪了,可以开始进一步的应用镜像搭建啦。

优化容器实现

想要实现前文中相对简单好用的镜像,我们接下来需要依次解决:“镜像融合”

为服务端容器镜像瘦身

在之前的镜像构建中,我们还有两个比较明显的优化点:基础镜像、Python 文件体积依赖比较大。

因为我们已经将基础镜像由 Python 3.7 升级为了 python:3.9-slim-buster,本质上已经和之前提到的 Embedded Milvus 的基础镜像保持了一致,并验证过镜像构建的可行性。

那么,我们可以进一步将其再进行替换,并且删除掉 Embedded 镜像中已经存在的 pymilvusprotobuf 的版本依赖,进一步减少容器镜像的体积:

FROM soulteary/milvus:embed-2.1.0
RUN apt-get update && \
    apt-get install -y ffmpeg libsm6 libxext6 && \
    apt-get remove --purge -y && rm -rf /var/lib/apt/lists/*

RUN pip config set global.index-url https://pypi.tuna.tsinghua.edu.cn/simple && \
    pip install diskcache==5.2.1 fastapi==0.65.2 towhee==0.8.0 uvicorn==0.13.4 opencv-python==4.6.0.66 torch==1.12.1 torchvision==0.13.1 Pillow==9.2.0 timm==0.6.7 && \
    pip cache purge

WORKDIR /app/src
COPY . /app

RUN python3 encode.py

同时,考虑到我们是在本地运行,其实不一定非常支持 GPU 推理,可以将 GPU 版本的 PyTorch 替换为 CPU Only 版本:

RUN pip install https://download.pytorch.org/whl/cpu/torch-1.12.1%2Bcpu-cp39-cp39-linux_x86_64.whl && \
    pip install https://download.pytorch.org/whl/cpu/torchvision-0.13.1%2Bcpu-cp39-cp39-linux_x86_64.whl

在构建过程中,我们也能够看到这两个版本 PyTorch 体积的差距:

 => => #   Downloading https://pypi.tuna.tsinghua.edu.cn/packages/1e/2f/06d30fbc76707f14641fe737f0715f601243e039d676be487d0340559c86/torch-1.12.1-cp39-cp39-manylinux1_x86_64.whl (776.4 MB)

 => => #   Downloading https://download.pytorch.org/whl/cpu/torch-1.12.1%2Bcpu-cp39-cp39-linux_x86_64.whl (189.2 MB)

完整代码实现,可以参考这里,调整好服务端的 Dockerfile 之后,我们执行 soulteary/image-search-app:server-2.1.0,完成最终版的服务端 Docker 镜像“素材”的构建。

执行 docker run --rm -it --name=milvus soulteary/image-search-app:server-2.1.0 将能够得到 “Milvus” 程序运行的日志输出:

---Milvus Proxy successfully initialized and ready to serve!---

然后新开一个终端会话,执行 docker exec -it milvus python main.py,对镜像进行测试,得到类似下面的报错:

2022-09-24 04:58:06,649  INFO  helpers.py  load_pretrained  244  Loading pretrained weights from url (https://github.com/rwightman/pytorch-image-models/releases/download/v0.1-rsb-weights/resnet50_a1_0-14fe96d1.pth)
2022-09-24 04:58:06,758  ERROR  utils.py  check_file_field  105  Form data requires "python-multipart" to be installed.

在得到 python-multipart 依赖缺失的信息之后,结合上文中提到过的方法,我们定位出缺少依赖的具体版本,然后 python-multipart==0.0.5 这个依赖补充到服务端的 Dockerfile 中,再次构建更新镜像,再次执行 python main.py

2022-09-24 05:00:03,411  INFO  helpers.py  load_pretrained  244  Loading pretrained weights from url (https://github.com/rwightman/pytorch-image-models/releases/download/v0.1-rsb-weights/resnet50_a1_0-14fe96d1.pth)
INFO:     Started server process [142]
2022-09-24 05:00:03,539  INFO  server.py  serve  64  Started server process [142]
INFO:     Waiting for application startup.
2022-09-24 05:00:03,540  INFO  on.py  startup  26  Waiting for application startup.
INFO:     Application startup complete.
2022-09-24 05:00:03,540  INFO  on.py  startup  38  Application startup complete.
INFO:     Uvicorn running on http://0.0.0.0:5000 (Press CTRL+C to quit)
2022-09-24 05:00:03,540  INFO  server.py  _log_started_message  199  Uvicorn running on http://0.0.0.0:5000 (Press CTRL+C to quit)

可以看到 Python 推理服务正确的运行起来了,到这里我们的服务端素材镜像的构建和验证就都完成了。

生成前端资源镜像

在前文中,我们已经修正了应用的构建问题。

前文中提到我们要合并前后端实现,所以先暂时将它封装为资源容器镜像,以备后用:

FROM node:18-alpine as Builder
ENV NODE_ENV=production
ENV NODE_OPTIONS="--openssl-legacy-provider"
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build

FROM scratch
COPY --from=Builder /app/build  /app/assets

将经过调整的代码保存在项目目录后,我们来实现“合并前后端实现”。

借助 ttyd 实现能够进行在线调试的 Web Console

前文提过,我们想要让应用直接支持在线调试,而不必非得使用 docker cli 进行调试分析。

能够提供在线 Web Console 的应用有很多,我这里选择的是使用 Golang 实现的 ttyd,项目作者本身有提供镜像,为了得到更小的产物,我们选择基于官方镜像制作更小的产物镜像:

FROM tsl0922/ttyd:latest AS Console

FROM soulteary/milvus:embed-2.1.0 As Builder
RUN apt-get update && \
    apt-get install -y upx && \
    apt-get remove --purge -y && rm -rf /var/lib/apt/lists/*
WORKDIR /app/console/
COPY --from=Console /usr/bin/ttyd  ./
RUN upx -9 -o ttyd.minify ttyd

FROM scratch
COPY --from=Builder /app/console/ttyd.minify /ttyd

在完成镜像构建之后,我们可以通过执行下面的命令,来验证程序被正常压缩处理:

docker run --rm -it soulteary/image-search-app:console-2.1.0 /ttyd -v

ttyd version 1.7.1-942bdab

使用 Golang 实现小而强大的应用网关

前面提到,我们希望之前应用中的“前后端”能够合并,并且能够提供一个在线调试工具,避免使用 docker cli 进行调试分析。

考虑到实现效率和不同功能之间的资源隔离,我将采用三个独立的程序来实现这个功能,为了能够让程序看起来像是一个,前端程序可以避免不必要的诸如跨域之类的问题。这里选择使用 Golang 实现一个简单的 Web Server,能够提供轻量、高效的访问体验,以及通过反向代理的方式,将上文中准备好的 “Web Console” 和“Python API” 聚合到一块儿。使用 Golang 还有一个额外的好处,就是可以构建出环境无关的、小巧的二进制文件,方便跨容器环境运行。

完整的实现,我上传到了 GitHub,感兴趣的同学可以自取:soulteary/portable-docker-app/reverse-image-search/gateway

这里,我简单分享三个小技巧,首先是如何实现一个性能不差、资源不会受到篡改、稳定的静态服务器:

//go:embed assets/favicon.png
var Favicon embed.FS

//go:embed assets/index.html
var HomePage []byte

//go:embed assets
var Assets embed.FS

func API(pythonAPI string, consoleAPI string, port string) {

...
	r.Any("/", func(c *gin.Context) {
		c.Data(http.StatusOK, "text/html; charset=utf-8", HomePage)
	})

	favicon, _ := fs.Sub(Favicon, "assets")
	r.Any("/favicon.png", func(c *gin.Context) {
		c.FileFromFS("favicon.png", http.FS(favicon))
	})

	static, _ := fs.Sub(Assets, "assets/static")
	r.StaticFS("/static", http.FS(static))
...
}

上面是使用 Go Embed.Fs 直接将静态文件嵌入程序的例子,如果你想了解更多相关信息,阅读早些时候的两篇文章:《深入浅出 Golang 资源嵌入方案:前篇》《深入浅出 Golang 资源嵌入方案:go-bindata篇》

至于快速将其他程序提供的网络服务聚合在一起,可以通过下面的小技巧,创建针对具体地址的反向代理路由:

func createProxy(proxyTarget string) gin.HandlerFunc {
	return func(c *gin.Context) {
		remote, err := url.Parse(proxyTarget)
		if err != nil {
			panic(err)
		}

		proxy := httputil.NewSingleHostReverseProxy(remote)
		proxy.Director = func(req *http.Request) {
			req.Header = c.Request.Header
			req.Host = remote.Host
			req.URL.Scheme = remote.Scheme
			req.URL.Host = remote.Host
			req.URL.Path = c.Param("proxyPath")
		}

		proxy.ServeHTTP(c.Writer, c.Request)
	}
}

最后,在原本的应用代码中,是通过在启动 Nginx 之前,先执行一个配置生成脚本,生成一个静态 JS 文件,作为前端程序配置使用:

#!/bin/bash

rm -rf ./env-config.js
touch ./env-config.js

echo "window._env_ = {" >> ./env-config.js
while read -r line || [[ -n "$line" ]];
do
  if printf '%s\n' "$line" | grep -q -e '='; then
    varname=$(printf '%s\n' "$line" | sed -e 's/=.*//')
    varvalue=$(printf '%s\n' "$line" | sed -e 's/^[^=]*=//')
  fi
  value=$(printf '%s\n' "${!varname}")
  [[ -z $value ]] && value=${varvalue}
  echo "  $varname: \"$value\"," >> ./env-config.js
done < .env

echo "}" >> ./env-config.js

因为我们将前后端服务合并到了一起,原本生成静态文件交给 Nginx 提供访问的模式不再需要,所以这里可以简单的声明一个路由,将这个原本由“bash shell”生成的文件交给 Go 程序直接输出:

r.GET("/env-config.js", func(c *gin.Context) {
	c.Data(http.StatusOK, "application/javascript; charset=utf-8", []byte(`window._env_ = {API_URL: "/api"}`))
	c.Abort()
})

在生成“应用网关”的镜像时,我们就可以将前文中提到的“前端资源”镜像利用起来了,将镜像和网关程序打包成一个“干净又卫生”的二进制文件:

FROM soulteary/image-search-app:assets-2.1.0 as Assets

FROM golang:1.19.0-buster AS GoBuilder
RUN sed -i -E "s/\w+.debian.org/mirrors.tuna.tsinghua.edu.cn/g" /etc/apt/sources.list
RUN apt-get update && apt-get install -y upx

ENV GO111MODULE=on
ENV CGO_ENABLED=0
ENV GOPROXY=https://goproxy.cn

WORKDIR /app
COPY --from=Assets /app/assets /app/internal/web/assets
COPY gateway/  ./
RUN go build -ldflags "-w -s" -o gateway main.go && \
    upx -9 -o gateway.minify gateway

FROM scratch
COPY --from=GoBuilder /app/gateway.minify /gateway

在构建完毕之后,我们使用容器运行这个服务,来进行简单的页面功能验证:

docker run --rm -it -p 3000:3000 soulteary/image-search-app:gateway-2.1.0 /gateway

命令执行完毕之后,我们将得到下面的日志输出:

Proxy API Addr: http://127.0.0.1:5000
Proxy Console API Addr: http://127.0.0.1:8090
Web Server port: 3000

浏览器访问 “http://127.0.0.1:3000”,能够看到熟悉的界面,说明服务正常。

网关服务运行默认界面

在搞定了“前后端”合并之后,我们来实现最终的应用镜像,将上面的服务相对妥善的置入一个容器。

实现 All-In-One 的容器镜像

虽然胖容器不是 Docker 官方推崇的,但是并不影响在本地使用场景中,我们选择这种方案,选择类似方案进行实践的厂商包含:Bitnami、GitLab 、Atlassian 的镜像默认都是这个方案。

为了实现这个方案,我们需要引入一个进程管理工具,我这里的选择是:supervisor。

在 Docker 容器中配置 supervisor

在容器中安装 Supervisor,非常简单,尤其是我们又基于 Debian / Ubuntu 系的操作系统:

FROM soulteary/milvus:embed-2.1.0

LABEL MAINTAINER=soulteary@gmail.com

RUN apt update && apt install supervisor -y && \
    apt-get remove --purge -y && rm -rf /var/lib/apt/lists/*

SHELL ["/bin/bash", "-c"]

RUN echo $' \n\
[unix_http_server] \n\
file=/var/run/supervisor.sock \n\
chmod=0700 \n\

[inet_http_server] \n\
port=0.0.0.0:8080 \n\

[supervisord] \n\
nodaemon=true \n\
logfile=/var/log/supervisor/supervisord.log \n\
pidfile=/var/run/supervisord.pid \n\
childlogdir=/var/log/supervisor \n\

[rpcinterface:supervisor] \n\
supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface \n\

[supervisorctl] \n\
serverurl=unix:///var/run/supervisor.sock \n\

[program:milvus] \n\
command=/entrypoint.sh \n\

[program:server] \n\
directory=/app/server \n\
command=python main.py \n\

[program:gateway] \n\
command=/app/gateway/gateway \n\

[program:console] \n\
command=/app/console/ttyd --port=8090 bash \n\

'> /etc/supervisor/supervisord.conf

CMD ["/usr/bin/supervisord","-c","/etc/supervisor/supervisord.conf"]

上面的 Dockerfile 中,不但能够完成程序的安装,还声明了一个配置,用于将 Supervisor 本身的管理界面、Web Console、应用网关、向量数据库 Milvus、Python 推理服务都进行进程管理,确保服务能够持续运行,遇到故障进行自动恢复。

在搞定 Supervisor 的安装和配置之后,我们来将上文中的各种“资源镜像”糅合到这个镜像中,在 Dockerfile 的头部先添加资源镜像,并分别为它们起好别名。

FROM soulteary/image-search-app:server-2.1.0 AS Server
FROM soulteary/image-search-app:gateway-2.1.0 AS Gateway
FROM soulteary/image-search-app:console-2.1.0 AS Console

FROM soulteary/milvus:embed-2.1.0
LABEL MAINTAINER=soulteary@gmail.com
...

接着,在 Dockerfile 尾部追加从资源镜像复制资源的指令:

...
CMD ["/usr/bin/supervisord","-c","/etc/supervisor/supervisord.conf"]

COPY --from=Gateway /gateway                                 /app/gateway/
COPY --from=Console /ttyd                                    /app/console/ttyd
COPY --from=Server  /app/server                              /app/server
COPY --from=Server  /usr/local/lib/python3.9/site-packages   /usr/local/lib/python3.9/
COPY --from=Server  /root/.cache/torch                       /root/.cache/torch
COPY --from=Server  /root/.towhee                            /root/.towhee

当我们构建完镜像之后,分别使用两个终端执行下面两条命令:

docker run --rm -it --name=milvus -p 3000:3000 -p 8080:8080 -v `pwd`/images:/images soulteary/image-search-app:2.1.0

docker exec -it milvus python /app/server/encode.py

不出意外,我们将得到报错信息,程序将会提示我们缺少下面的系统动态链接库:libGL.solibGLX.so 等等。解决方案和上一篇制作 Embedded Milvus 镜像一致,需要耐心的一点点扣出来依赖关系,最终结果类似下面这样。

# extra deps for python application
COPY --from=Server /usr/lib/x86_64-linux-gnu/libGL.so.1.7.0               /usr/lib/x86_64-linux-gnu/
COPY --from=Server /usr/lib/x86_64-linux-gnu/libGLX.so.0.0.0              /usr/lib/x86_64-linux-gnu/
COPY --from=Server /usr/lib/x86_64-linux-gnu/libGLdispatch.so.0.0.0       /usr/lib/x86_64-linux-gnu/
COPY --from=Server /usr/lib/x86_64-linux-gnu/libX11.so.6.3.0              /usr/lib/x86_64-linux-gnu/
COPY --from=Server /usr/lib/x86_64-linux-gnu/libXext.so.6.4.0             /usr/lib/x86_64-linux-gnu/
...
RUN ln -s /usr/lib/x86_64-linux-gnu/libGL.so.1.7.0             /usr/lib/x86_64-linux-gnu/libGL.so.1           && \
    ln -s /usr/lib/x86_64-linux-gnu/libGLX.so.0.0.0            /usr/lib/x86_64-linux-gnu/libGLX.so.0          && \
    ln -s /usr/lib/x86_64-linux-gnu/libGLdispatch.so.0.0.0     /usr/lib/x86_64-linux-gnu/libGLdispatch.so.0   && \
    ln -s /usr/lib/x86_64-linux-gnu/libX11.so.6.3.0            /usr/lib/x86_64-linux-gnu/libX11.so.6          && \
...

在完成最终的 Docker 镜像的编写之后,我们将镜像构建为容器,然后推送到 DockerHub 上,通过 DockerHub 平台的二次镜像压缩,就能够得到一个相对小巧又好用的、开箱即用的“以图搜图” Docker 镜像啦。

完整的程序代码,我上传到了 soulteary/portable-docker-app/reverse-image-search,有需要的同学可以自取。

最后

不知不觉又写了这么多,希望这篇文章能够帮助到想要快速搭建图搜系统,实现以图搜图功能的同学,也希望详尽的设计和容器应用调优,能够给你启发,让你的程序性能越来越棒!

好了,这次就先写到这里了。

–EOF