上一篇文章中,我们提到了针对应用封装“资源容器”。本篇文章中,我们继续聊聊完成容器优化,以及封装最终的应用镜像。

优化容器实现

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

为服务端容器镜像瘦身

在之前的镜像构建中,我们还有两个比较明显的优化点:基础镜像、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

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

借助 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[3]

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

//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 资源嵌入方案:前篇》[4]、《深入浅出 Golang 资源嵌入方案:go-bindata篇》[5]

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

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

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

最后

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

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

--EOF


引用链接

[1][2][3][4][5][6]

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

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


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