本篇文章,我来分享如何使用 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
本地启动的图片搜索引擎界面
images
从网络中随机找的一些动漫、游戏图片

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

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

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

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

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

查找不存在的图片

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

查找存在的图片
docker exec -it $ContainerNameOrHash ...
内置在浏览器中的命令行工具

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

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

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

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

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

官方图片搜索示例架构
docker-compose.yml
quay.io/coreos/etcd:v3.5.0minio/minio:RELEASE.2020-12-03T00-03-10Zmilvusdb/milvus:v2.0.2mysql:5.7milvusbootcamp/img-search-server:towhee0.6milvusbootcamp/img-search-client:1.0
docker-compose.ymlipv4_address
opencvtimmtorchtorchvisionresnet50
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,我们对之前运行的镜像中的基础镜像进行整理,能够得到下面三种镜像:

FROM python:3.7-slim-busterFROM buildpack-deps:busterFROM nginx:1.17-alpineFROM debian:bullseye-slimFROM ubuntu:20.04

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

python:3.9-slim-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 这个容器镜像啦。

完整的改动比较多,可以参考:。

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

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

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

Python 3.7 => Python 3.9
#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
Dockerfiledocker 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
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
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
requirements.txt
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 bashpython 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# 
/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
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。我们暂且不进行额外的容器尺寸优化,先继续进行重构。

前端应用镜像的重构

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

sourcemap

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

npm installnpm run startnpm 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",
...
}
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.jsonnpm installNODE_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.9-slim-buster
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)
soulteary/image-search-app:server-2.1.0
docker run --rm -it --name=milvus soulteary/image-search-app:server-2.1.0
---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-multipartpython-multipart==0.0.5python 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,感兴趣的同学可以自取:。

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

//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))
...
}

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

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
# 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 镜像啦。

完整的程序代码,我上传到了 ,有需要的同学可以自取。

最后

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

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

--EOF


引用链接

[1][2][3][4][5][6][7][8][9][10][11][12][13][14]

如果你觉得内容还算实用,欢迎点赞分享给你的朋友,在此谢过。

如果你想更快的看到后续内容的更新,请戳 “点赞”、“分享”、“喜欢” ,这些免费的鼓励将会影响后续有关内容的更新速度。


此外,我们有一个小小的折腾群,里面聚集了一些喜欢折腾的小伙伴。

在不发广告的情况下,我们在里面会一起聊聊软硬件、HomeLab、编程上的一些问题,也会在群里不定期的分享一些技术沙龙的资料。

喜欢折腾的小伙伴,欢迎阅读下面的内容,扫码添加好友。

添加好友,请备注实名和公司或学校、注明来源和目的,否则不会通过审核。


本文使用「署名 4.0 国际 (CC BY 4.0)」许可协议,欢迎转载、或重新修改使用,但需要注明来源。 署名 4.0 国际 (CC BY 4.0)